feat(client): 初始化前端壳与鉴权守卫(P1-5)

- 技术栈:React 18 + TypeScript + Vite + Tailwind

- API 层:ApiError + 401 自动刷新 + single-flight 重试

- 鉴权:内存 Access Token + Cookie Refresh + 启动 bootstrap

- RBAC:权限映射 + RequireAuth/RequirePermission 路由守卫

- 布局:OA 工作台壳层 + 侧边栏 + 5 个占位页面

- 样式:复用 hyb-web 视觉 token(单向同步)

- 文档:新增 client README,补充 server 联调说明
这个提交包含在:
2026-02-07 16:43:57 +08:00
父节点 f527633716
当前提交 c290e03fef
共修改 35 个文件,包含 4127 行新增和 0 行删除
+48
查看文件
@@ -0,0 +1,48 @@
# hyb-oa Client Runbook (P1-5)
## 1. 前置条件
- Node.js >= 20
- 后端服务可用(`http://127.0.0.1:3000`)
## 2. 安装与启动
```bash
cd /home/shunli/Projects/hyb-oa/client
npm install
npm run dev
```
默认前端地址:`http://127.0.0.1:5173`
## 3. 本地联调建议顺序
```bash
cd /home/shunli/Projects/hyb-oa
docker compose up -d db
cd /home/shunli/Projects/hyb-oa/server
npm run prisma:migrate:deploy
npm run seed:dev
npm run dev
cd /home/shunli/Projects/hyb-oa/client
npm run dev
```
## 4. 鉴权与会话说明
- Access Token 仅保存在内存,不写入 localStorage/sessionStorage。
- Refresh Token 由后端通过 HttpOnly Cookie 维护。
- 前端在 401 时自动调用 `/api/auth/refresh` 并重试一次请求。
- 登录与刷新请求都会附带 `x-device-fingerprint`。
## 5. 风格资源复用策略(锁定)
- 视觉 Token 来源:`/home/shunli/Projects/hyb-web`。
- 采用“复制到 client + 单向同步”,不做跨仓软链与自动联动。
- 如需同步风格,请记录来源 commit 并通过评审。
## 6. 可用脚本
```bash
npm run dev
npm run typecheck
npm run build
npm run preview
```
+12
查看文件
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>hyb-oa Client</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2726
查看文件
文件差异内容过多而无法显示 加载差异
+29
查看文件
@@ -0,0 +1,29 @@
{
"name": "client",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "npm run typecheck && vite build",
"typecheck": "tsc --noEmit",
"preview": "vite preview"
},
"dependencies": {
"clsx": "^2.1.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.0",
"tailwind-merge": "^2.6.1"
},
"devDependencies": {
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.4.5",
"vite": "^5.1.0"
}
}
+6
查看文件
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+155
查看文件
@@ -0,0 +1,155 @@
import { ApiError, type ApiErrorBody, type ApiSuccess } from "./types";
type AccessTokenGetter = () => string | null;
type RefreshAccessToken = () => Promise<string | null>;
type ClearSession = () => void;
let getAccessToken: AccessTokenGetter = () => null;
let refreshAccessToken: RefreshAccessToken | null = null;
let clearSession: ClearSession | null = null;
let refreshPromise: Promise<string | null> | null = null;
export type ConfigureHttpAuthOptions = {
getAccessToken: AccessTokenGetter;
refreshAccessToken: RefreshAccessToken;
clearSession?: ClearSession;
};
export type ApiRequestOptions = Omit<RequestInit, "headers"> & {
headers?: HeadersInit;
auth?: boolean;
retryOn401?: boolean;
accessTokenOverride?: string | null;
};
export function configureHttpAuth(options: ConfigureHttpAuthOptions) {
getAccessToken = options.getAccessToken;
refreshAccessToken = options.refreshAccessToken;
clearSession = options.clearSession ?? null;
}
async function safeParseJson(response: Response): Promise<unknown> {
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) {
return null;
}
try {
return await response.json();
} catch {
return null;
}
}
function buildApiError(response: Response, payload: unknown): ApiError {
const fallbackRequestId = response.headers.get("x-request-id") ?? "";
if (payload && typeof payload === "object" && "error" in payload) {
const body = payload as ApiErrorBody;
return new ApiError({
status: response.status,
code: body.error?.code ?? "SYS_001",
message: body.error?.message ?? "请求失败",
details: body.error?.details,
requestId: body.requestId || fallbackRequestId,
});
}
return new ApiError({
status: response.status,
code: "SYS_001",
message: "请求失败",
requestId: fallbackRequestId,
});
}
async function getRefreshedToken(): Promise<string | null> {
if (!refreshAccessToken) {
return null;
}
if (!refreshPromise) {
refreshPromise = refreshAccessToken()
.catch(() => null)
.finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
async function sendRequest<T>(
path: string,
options: ApiRequestOptions,
): Promise<ApiSuccess<T>> {
const {
auth = true,
retryOn401 = true,
accessTokenOverride,
headers,
...rest
} = options;
const requestHeaders = new Headers(headers ?? {});
const token = accessTokenOverride ?? (auth ? getAccessToken() : null);
if (auth && token) {
requestHeaders.set("authorization", `Bearer ${token}`);
}
if (rest.body && !(rest.body instanceof FormData) && !requestHeaders.has("content-type")) {
requestHeaders.set("content-type", "application/json");
}
const response = await fetch(path, {
...rest,
credentials: "include",
headers: requestHeaders,
});
if (response.status === 401 && auth && retryOn401 && refreshAccessToken) {
const nextToken = await getRefreshedToken();
if (nextToken) {
return sendRequest<T>(path, {
...options,
retryOn401: false,
accessTokenOverride: nextToken,
});
}
if (clearSession) {
clearSession();
}
}
const payload = await safeParseJson(response);
if (!response.ok) {
throw buildApiError(response, payload);
}
if (payload && typeof payload === "object" && "data" in payload && "requestId" in payload) {
return payload as ApiSuccess<T>;
}
return {
requestId: response.headers.get("x-request-id") ?? "",
data: undefined as T,
};
}
export async function apiRequest<T>(
path: string,
options: ApiRequestOptions = {},
): Promise<ApiSuccess<T>> {
return sendRequest<T>(path, options);
}
export async function apiRequestData<T>(
path: string,
options: ApiRequestOptions = {},
): Promise<T> {
const response = await apiRequest<T>(path, options);
return response.data;
}
+67
查看文件
@@ -0,0 +1,67 @@
export type ApiSuccess<T> = {
requestId: string;
data: T;
};
export type ApiErrorBody = {
requestId: string;
error: {
code: string;
message: string;
details?: Record<string, unknown>;
};
};
export type LoginRequest = {
identifier: string;
password: string;
};
export type LoginResponse = {
accessToken: string;
};
export type MeResponse = {
id: string;
employeeNo: string;
name: string;
phone: string;
email: string;
departmentId: string;
directManagerId: string | null;
status: string;
roleCodes: string[];
};
export type AuthState = {
accessToken: string | null;
me: MeResponse | null;
permissions: string[];
ready: boolean;
};
export class ApiError extends Error {
status: number;
code: string;
details?: Record<string, unknown>;
requestId: string;
constructor(params: {
status: number;
code: string;
message: string;
details?: Record<string, unknown>;
requestId?: string;
}) {
super(params.message);
this.name = "ApiError";
this.status = params.status;
this.code = params.code;
this.details = params.details;
this.requestId = params.requestId ?? "";
}
}
export function isApiError(value: unknown): value is ApiError {
return value instanceof ApiError;
}
+143
查看文件
@@ -0,0 +1,143 @@
import {
createContext,
type PropsWithChildren,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { configureHttpAuth } from "../api/http";
import type { AuthState, MeResponse } from "../api/types";
import {
fetchCurrentEmployee,
loginWithPassword,
logoutSession,
refreshSession,
} from "./authService";
import { resolvePermissionsForRoles } from "./permissions";
type AuthContextValue = AuthState & {
login: (identifier: string, password: string) => Promise<void>;
logout: () => Promise<void>;
bootstrapByRefresh: () => Promise<void>;
clearSession: () => void;
};
const AuthContext = createContext<AuthContextValue | null>(null);
const EMPTY_STATE: AuthState = {
accessToken: null,
me: null,
permissions: [],
ready: false,
};
function buildStateWithUser(accessToken: string, me: MeResponse): AuthState {
return {
accessToken,
me,
permissions: resolvePermissionsForRoles(me.roleCodes ?? []),
ready: true,
};
}
export function AuthProvider({ children }: PropsWithChildren) {
const [state, setState] = useState<AuthState>(EMPTY_STATE);
const tokenRef = useRef<string | null>(null);
useEffect(() => {
tokenRef.current = state.accessToken;
}, [state.accessToken]);
const clearSession = useCallback(() => {
tokenRef.current = null;
setState((previous) => ({
...previous,
accessToken: null,
me: null,
permissions: [],
ready: true,
}));
}, []);
const commitAuthenticatedUser = useCallback((accessToken: string, me: MeResponse) => {
tokenRef.current = accessToken;
setState(buildStateWithUser(accessToken, me));
}, []);
const bootstrapByRefresh = useCallback(async () => {
try {
const refreshed = await refreshSession();
const me = await fetchCurrentEmployee(refreshed.accessToken);
commitAuthenticatedUser(refreshed.accessToken, me);
} catch {
clearSession();
}
}, [clearSession, commitAuthenticatedUser]);
const login = useCallback(
async (identifier: string, password: string) => {
const result = await loginWithPassword({ identifier, password });
const me = await fetchCurrentEmployee(result.accessToken);
commitAuthenticatedUser(result.accessToken, me);
},
[commitAuthenticatedUser],
);
const logout = useCallback(async () => {
try {
await logoutSession();
} finally {
clearSession();
}
}, [clearSession]);
useEffect(() => {
configureHttpAuth({
getAccessToken: () => tokenRef.current,
refreshAccessToken: async () => {
try {
const refreshed = await refreshSession();
tokenRef.current = refreshed.accessToken;
setState((previous) => ({
...previous,
accessToken: refreshed.accessToken,
ready: true,
}));
return refreshed.accessToken;
} catch {
clearSession();
return null;
}
},
clearSession,
});
}, [clearSession]);
useEffect(() => {
void bootstrapByRefresh();
}, [bootstrapByRefresh]);
const value = useMemo<AuthContextValue>(
() => ({
...state,
login,
logout,
bootstrapByRefresh,
clearSession,
}),
[bootstrapByRefresh, clearSession, login, logout, state],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within AuthProvider");
}
return context;
}
+47
查看文件
@@ -0,0 +1,47 @@
import { apiRequestData } from "../api/http";
import type { LoginRequest, LoginResponse, MeResponse } from "../api/types";
import { getDeviceFingerprint } from "./deviceFingerprint";
const JSON_HEADERS = {
"content-type": "application/json",
};
export async function loginWithPassword(payload: LoginRequest) {
return apiRequestData<LoginResponse>("/api/auth/login", {
method: "POST",
auth: false,
retryOn401: false,
headers: {
...JSON_HEADERS,
"x-device-fingerprint": getDeviceFingerprint(),
},
body: JSON.stringify(payload),
});
}
export async function refreshSession() {
return apiRequestData<LoginResponse>("/api/auth/refresh", {
method: "POST",
auth: false,
retryOn401: false,
headers: {
"x-device-fingerprint": getDeviceFingerprint(),
},
});
}
export async function logoutSession() {
return apiRequestData<Record<string, never>>("/api/auth/logout", {
method: "POST",
auth: false,
retryOn401: false,
});
}
export async function fetchCurrentEmployee(accessToken: string) {
return apiRequestData<MeResponse>("/api/me", {
auth: true,
retryOn401: false,
accessTokenOverride: accessToken,
});
}
+28
查看文件
@@ -0,0 +1,28 @@
const DEVICE_FINGERPRINT_KEY = "hyb_oa_device_fingerprint";
function fallbackFingerprint() {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
function generateFingerprint() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return fallbackFingerprint();
}
export function getDeviceFingerprint() {
if (typeof window === "undefined") {
return "server-render";
}
const existing = window.localStorage.getItem(DEVICE_FINGERPRINT_KEY);
if (existing) {
return existing;
}
const next = generateFingerprint();
window.localStorage.setItem(DEVICE_FINGERPRINT_KEY, next);
return next;
}
+74
查看文件
@@ -0,0 +1,74 @@
const EMPLOYEE_BASE_PERMISSIONS = [
"expense.create",
"expense.view_own",
"expense.edit_own",
"expense.delete_own",
"expense.submit",
"expense.withdraw",
"invoice.upload",
"invoice.view_own",
"expense.proxy_manage",
] as const;
const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
employee: EMPLOYEE_BASE_PERMISSIONS,
manager: [
...EMPLOYEE_BASE_PERMISSIONS,
"expense.approve",
"expense.transfer",
"expense.view_all",
],
ceo: [
...EMPLOYEE_BASE_PERMISSIONS,
"expense.approve",
"expense.transfer",
"expense.view_all",
],
finance: [
...EMPLOYEE_BASE_PERMISSIONS,
"invoice.manage",
"expense.approve",
"expense.finance.pay",
"expense.view_all",
],
finance_mgr: [
...EMPLOYEE_BASE_PERMISSIONS,
"invoice.manage",
"expense.approve",
"expense.finance.pay",
"expense.view_all",
"expense.finance.override_duplicate",
"system.settings",
],
administration: ["expense.proxy_submit", "expense.view_own"],
hr: ["system.users", "expense.view_all"],
admin: ["*"],
};
function rolePermissions(roleCode: string): readonly string[] {
return ROLE_PERMISSIONS[roleCode] ?? [];
}
export function resolvePermissionsForRoles(roleCodes: string[]) {
const permissions = new Set<string>();
for (const roleCode of roleCodes) {
for (const permission of rolePermissions(roleCode)) {
permissions.add(permission);
}
}
return Array.from(permissions);
}
export function hasPermission(permissionCodes: string[], permissionCode: string) {
return permissionCodes.includes("*") || permissionCodes.includes(permissionCode);
}
export function hasAnyPermission(permissionCodes: string[], candidates: string[]) {
if (candidates.length === 0) {
return true;
}
return candidates.some((candidate) => hasPermission(permissionCodes, candidate));
}
@@ -0,0 +1,17 @@
import type { ReactNode } from "react";
type EmptyStateCardProps = {
title: string;
description: string;
footer?: ReactNode;
};
export function EmptyStateCard({ title, description, footer }: EmptyStateCardProps) {
return (
<section className="card-std">
<h3 className="card-title">{title}</h3>
<p className="card-desc">{description}</p>
{footer ? <div className="mt-4">{footer}</div> : null}
</section>
);
}
@@ -0,0 +1,20 @@
import type { ReactNode } from "react";
type PageHeaderProps = {
title: string;
subtitle?: string;
actions?: ReactNode;
};
export function PageHeader({ title, subtitle, actions }: PageHeaderProps) {
return (
<div className="mb-6 flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-slate-900">{title}</h2>
{subtitle ? <p className="mt-1 text-sm text-secondary">{subtitle}</p> : null}
</div>
{actions ? <div className="shrink-0">{actions}</div> : null}
</div>
);
}
@@ -0,0 +1,69 @@
import { NavLink } from "react-router-dom";
import { useAuth } from "../../auth/AuthContext";
import { hasAnyPermission } from "../../auth/permissions";
type NavItem = {
to: string;
label: string;
anyOf?: string[];
};
const NAV_ITEMS: NavItem[] = [
{ to: "/dashboard", label: "仪表盘" },
{
to: "/reimbursements",
label: "我的报销",
anyOf: ["expense.view_own", "expense.view_all"],
},
{
to: "/workflow/tasks",
label: "待我处理",
anyOf: ["expense.approve"],
},
{
to: "/invoice-pool",
label: "票据中心",
anyOf: ["invoice.upload", "invoice.view_own", "invoice.manage"],
},
{ to: "/settings/profile", label: "个人设置" },
];
export function Sidebar() {
const { permissions } = useAuth();
const visibleItems = NAV_ITEMS.filter((item) => {
if (!item.anyOf) {
return true;
}
return hasAnyPermission(permissions, item.anyOf);
});
return (
<aside className="hidden w-64 shrink-0 border-r border-slate-200 bg-white lg:flex lg:flex-col">
<div className="border-b border-slate-200 px-6 py-5">
<p className="text-sm font-semibold uppercase tracking-wide text-primary">hyb-oa</p>
<h1 className="mt-1 text-lg font-bold text-slate-900">海音达工作台</h1>
</div>
<nav className="flex-1 space-y-1 p-4">
{visibleItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
[
"block rounded-lg px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-primary-50 text-primary"
: "text-slate-700 hover:bg-slate-50 hover:text-slate-900",
].join(" ")
}
>
{item.label}
</NavLink>
))}
</nav>
</aside>
);
}
@@ -0,0 +1,38 @@
import { Link } from "react-router-dom";
import { useAuth } from "../../auth/AuthContext";
export function Topbar() {
const { me, logout } = useAuth();
const roleCodes = me?.roleCodes ?? [];
return (
<header className="sticky top-0 z-20 border-b border-slate-200 bg-white/95 px-4 py-3 backdrop-blur sm:px-6">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm text-secondary">欢迎回来</p>
<p className="text-base font-semibold text-slate-900">{me?.name ?? "-"}</p>
</div>
<div className="flex items-center gap-3">
<div className="hidden text-right sm:block">
<p className="text-xs uppercase tracking-wide text-secondary">角色</p>
<p className="text-sm font-medium text-slate-900">{roleCodes.join(" / ") || "-"}</p>
</div>
<Link className="btn-secondary text-sm" to="/settings/profile">
个人设置
</Link>
<button
className="btn-primary text-sm"
onClick={() => {
void logout();
}}
type="button"
>
退出登录
</button>
</div>
</div>
</header>
);
}
+47
查看文件
@@ -0,0 +1,47 @@
/*
Style tokens source (single-direction sync):
- /home/shunli/Projects/hyb-web
- commit: unavailable_local_reference
*/
@import url("https://fonts.googleapis.com/css2?family=Public+Sans:wght@400;500;700;900&display=swap");
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-background-light text-slate-900 font-sans antialiased selection:bg-primary selection:text-white;
}
#root {
@apply min-h-screen;
}
}
@layer components {
.layout-container {
@apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8;
}
.btn-primary {
@apply rounded-lg bg-primary px-4 py-2 font-bold text-white transition-colors hover:bg-primary-600;
}
.btn-secondary {
@apply rounded-lg border border-slate-200 bg-white px-4 py-2 font-medium text-slate-700 transition-colors hover:bg-slate-50;
}
.card-std {
@apply rounded-xl border border-slate-200 bg-white p-6 text-left shadow-sm transition-all hover:shadow-md;
}
.card-title {
@apply mb-2 text-xl font-bold text-slate-900;
}
.card-desc {
@apply text-slate-600;
}
}
+23
查看文件
@@ -0,0 +1,23 @@
import { Outlet } from "react-router-dom";
import { Sidebar } from "../components/navigation/Sidebar";
import { Topbar } from "../components/navigation/Topbar";
export function AppShellLayout() {
return (
<div className="min-h-screen bg-background-light">
<div className="flex min-h-screen">
<Sidebar />
<div className="flex min-w-0 flex-1 flex-col">
<Topbar />
<main className="flex-1 p-4 sm:p-6">
<div className="layout-container max-w-none">
<Outlet />
</div>
</main>
</div>
</div>
</div>
);
}
+11
查看文件
@@ -0,0 +1,11 @@
import { Outlet } from "react-router-dom";
export function AuthLayout() {
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-primary-50 via-background-light to-slate-100 p-6">
<div className="w-full max-w-md">
<Outlet />
</div>
</div>
);
}
+16
查看文件
@@ -0,0 +1,16 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { AuthProvider } from "./auth/AuthContext";
import { AppRouter } from "./router";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<AppRouter />
</AuthProvider>
</BrowserRouter>
</React.StrictMode>,
);
+77
查看文件
@@ -0,0 +1,77 @@
import { Link } from "react-router-dom";
import { EmptyStateCard } from "../components/common/EmptyStateCard";
import { PageHeader } from "../components/common/PageHeader";
import { useAuth } from "../auth/AuthContext";
export function DashboardPage() {
const { me, permissions } = useAuth();
return (
<div>
<PageHeader
title="仪表盘"
subtitle="前端壳已完成初始化,后续业务模块将在此基础上扩展。"
/>
<div className="grid gap-4 lg:grid-cols-2">
<section className="card-std">
<h3 className="card-title">当前用户</h3>
<dl className="space-y-2 text-sm text-slate-700">
<div className="flex justify-between gap-3">
<dt className="text-secondary">姓名</dt>
<dd className="font-medium text-slate-900">{me?.name ?? "-"}</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-secondary">员工号</dt>
<dd className="font-medium text-slate-900">{me?.employeeNo ?? "-"}</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-secondary">邮箱</dt>
<dd className="font-medium text-slate-900">{me?.email ?? "-"}</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-secondary">角色</dt>
<dd className="font-medium text-slate-900">{me?.roleCodes?.join(" / ") ?? "-"}</dd>
</div>
</dl>
</section>
<section className="card-std">
<h3 className="card-title">权限摘要</h3>
{permissions.length ? (
<div className="flex flex-wrap gap-2">
{permissions.map((permission) => (
<span
className="rounded-full bg-primary-50 px-2.5 py-1 text-xs font-medium text-primary"
key={permission}
>
{permission}
</span>
))}
</div>
) : (
<p className="card-desc">暂无权限信息。</p>
)}
</section>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-3">
<EmptyStateCard
title="我的报销"
description="下一阶段接入报销列表与详情。"
footer={<Link className="text-sm font-medium text-primary" to="/reimbursements">进入页面</Link>}
/>
<EmptyStateCard
title="待我处理"
description="下一阶段接入 workflow 待办列表与动作。"
footer={<Link className="text-sm font-medium text-primary" to="/workflow/tasks">进入页面</Link>}
/>
<EmptyStateCard
title="票据中心"
description="下一阶段接入上传、签名 URL 与绑定流程。"
footer={<Link className="text-sm font-medium text-primary" to="/invoice-pool">进入页面</Link>}
/>
</div>
</div>
);
}
+23
查看文件
@@ -0,0 +1,23 @@
import { Link } from "react-router-dom";
export function ForbiddenPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-background-light p-6">
<div className="card-std max-w-lg text-center">
<p className="text-sm font-semibold uppercase tracking-wide text-primary">403</p>
<h1 className="mt-2 text-2xl font-bold text-slate-900">无权限访问</h1>
<p className="mt-2 text-sm text-secondary">
当前账号没有访问该页面的权限,请联系管理员或返回可访问页面。
</p>
<div className="mt-5 flex justify-center gap-3">
<Link className="btn-secondary" to="/dashboard">
返回仪表盘
</Link>
<Link className="btn-primary" to="/settings/profile">
查看账号信息
</Link>
</div>
</div>
</div>
);
}
+17
查看文件
@@ -0,0 +1,17 @@
import { EmptyStateCard } from "../components/common/EmptyStateCard";
import { PageHeader } from "../components/common/PageHeader";
export function InvoicePoolPage() {
return (
<div>
<PageHeader
title="票据中心"
subtitle="此页面已接入权限守卫,后续将接入上传、绑定、签名 URL 等功能。"
/>
<EmptyStateCard
title="功能建设中"
description="后续迭代将实现发票池列表、上传、绑定、归档与签名预览。"
/>
</div>
);
}
+96
查看文件
@@ -0,0 +1,96 @@
import { useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
function resolveRedirect(searchParams: URLSearchParams) {
const redirect = searchParams.get("redirect");
if (!redirect) {
return "/dashboard";
}
if (!redirect.startsWith("/")) {
return "/dashboard";
}
return redirect;
}
export function LoginPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { login } = useAuth();
const [identifier, setIdentifier] = useState("[email protected]");
const [password, setPassword] = useState("EmployeePassword1234");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const redirectTo = resolveRedirect(searchParams);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
setError("");
try {
await login(identifier.trim(), password);
navigate(redirectTo, { replace: true });
} catch (err) {
if (isApiError(err)) {
setError(`${err.code}: ${err.message}`);
} else {
setError("登录失败,请稍后重试");
}
} finally {
setSubmitting(false);
}
}
return (
<div className="card-std border-primary-100">
<div className="mb-6">
<p className="text-xs font-semibold uppercase tracking-wide text-primary">hyb-oa</p>
<h1 className="mt-2 text-2xl font-bold text-slate-900">登录工作台</h1>
<p className="mt-2 text-sm text-secondary">使用邮箱/手机号与密码登录。</p>
</div>
<form className="space-y-4" onSubmit={handleSubmit}>
<label className="block text-sm font-medium text-slate-700" htmlFor="identifier">
账号
<input
autoComplete="username"
className="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-100"
id="identifier"
onChange={(event) => setIdentifier(event.target.value)}
placeholder="邮箱或手机号"
value={identifier}
/>
</label>
<label className="block text-sm font-medium text-slate-700" htmlFor="password">
密码
<input
autoComplete="current-password"
className="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-100"
id="password"
onChange={(event) => setPassword(event.target.value)}
placeholder="请输入密码"
type="password"
value={password}
/>
</label>
{error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{error}
</p>
) : null}
<button className="btn-primary w-full" disabled={submitting} type="submit">
{submitting ? "登录中..." : "登录"}
</button>
</form>
</div>
);
}
+18
查看文件
@@ -0,0 +1,18 @@
import { Link } from "react-router-dom";
export function NotFoundPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-background-light p-6">
<div className="card-std max-w-lg text-center">
<p className="text-sm font-semibold uppercase tracking-wide text-primary">404</p>
<h1 className="mt-2 text-2xl font-bold text-slate-900">页面不存在</h1>
<p className="mt-2 text-sm text-secondary">请检查地址,或返回工作台首页继续操作。</p>
<div className="mt-5 flex justify-center">
<Link className="btn-primary" to="/dashboard">
回到仪表盘
</Link>
</div>
</div>
</div>
);
}
+56
查看文件
@@ -0,0 +1,56 @@
import { PageHeader } from "../components/common/PageHeader";
import { useAuth } from "../auth/AuthContext";
export function ProfilePage() {
const { me, permissions } = useAuth();
return (
<div>
<PageHeader title="个人设置" subtitle="展示当前登录人的基础信息与权限摘要。" />
<section className="card-std">
<h3 className="card-title">个人信息</h3>
<dl className="grid gap-3 text-sm text-slate-700 md:grid-cols-2">
<div>
<dt className="text-secondary">姓名</dt>
<dd className="font-medium text-slate-900">{me?.name ?? "-"}</dd>
</div>
<div>
<dt className="text-secondary">员工号</dt>
<dd className="font-medium text-slate-900">{me?.employeeNo ?? "-"}</dd>
</div>
<div>
<dt className="text-secondary">邮箱</dt>
<dd className="font-medium text-slate-900">{me?.email ?? "-"}</dd>
</div>
<div>
<dt className="text-secondary">手机号</dt>
<dd className="font-medium text-slate-900">{me?.phone ?? "-"}</dd>
</div>
<div>
<dt className="text-secondary">部门ID</dt>
<dd className="font-medium text-slate-900">{me?.departmentId ?? "-"}</dd>
</div>
<div>
<dt className="text-secondary">直属上级ID</dt>
<dd className="font-medium text-slate-900">{me?.directManagerId ?? "-"}</dd>
</div>
</dl>
</section>
<section className="card-std mt-4">
<h3 className="card-title">权限列表</h3>
<div className="flex flex-wrap gap-2">
{permissions.map((permission) => (
<span
className="rounded-full bg-primary-50 px-2.5 py-1 text-xs font-medium text-primary"
key={permission}
>
{permission}
</span>
))}
</div>
</section>
</div>
);
}
+17
查看文件
@@ -0,0 +1,17 @@
import { EmptyStateCard } from "../components/common/EmptyStateCard";
import { PageHeader } from "../components/common/PageHeader";
export function ReimbursementsPage() {
return (
<div>
<PageHeader
title="我的报销"
subtitle="此页面已完成路由、鉴权与菜单接入,后续将接入真实列表数据。"
/>
<EmptyStateCard
title="功能建设中"
description="后续迭代将实现报销列表、筛选、详情与提交能力。"
/>
</div>
);
}
+17
查看文件
@@ -0,0 +1,17 @@
import { EmptyStateCard } from "../components/common/EmptyStateCard";
import { PageHeader } from "../components/common/PageHeader";
export function WorkflowTasksPage() {
return (
<div>
<PageHeader
title="待我处理"
subtitle="此页面需要 expense.approve 权限,当前用于壳层联调与权限验证。"
/>
<EmptyStateCard
title="功能建设中"
description="后续迭代将接入待办任务列表与 approve/reject/return/transfer 动作。"
/>
</div>
);
}
+68
查看文件
@@ -0,0 +1,68 @@
import type { PropsWithChildren } from "react";
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission } from "../auth/permissions";
function FullPageLoading() {
return (
<div className="flex min-h-screen items-center justify-center bg-background-light text-secondary">
正在加载会话...
</div>
);
}
function toLoginRedirect(pathname: string, search: string, hash: string) {
const target = `${pathname}${search}${hash}`;
return `/login?redirect=${encodeURIComponent(target)}`;
}
export function RequireAuth({ children }: PropsWithChildren) {
const location = useLocation();
const { accessToken, me, ready } = useAuth();
if (!ready) {
return <FullPageLoading />;
}
if (!accessToken || !me) {
return (
<Navigate
to={toLoginRedirect(location.pathname, location.search, location.hash)}
replace
/>
);
}
return <>{children}</>;
}
export function GuestOnly({ children }: PropsWithChildren) {
const { accessToken, me, ready } = useAuth();
if (!ready) {
return <FullPageLoading />;
}
if (accessToken && me) {
return <Navigate to="/dashboard" replace />;
}
return <>{children}</>;
}
export function RequirePermission({
anyOf,
children,
}: PropsWithChildren<{ anyOf: string[] }>) {
const { permissions, ready } = useAuth();
if (!ready) {
return <FullPageLoading />;
}
if (!hasAnyPermission(permissions, anyOf)) {
return <Navigate to="/403" replace />;
}
return <>{children}</>;
}
+73
查看文件
@@ -0,0 +1,73 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { AppShellLayout } from "../layouts/AppShellLayout";
import { AuthLayout } from "../layouts/AuthLayout";
import { DashboardPage } from "../pages/DashboardPage";
import { ForbiddenPage } from "../pages/ForbiddenPage";
import { InvoicePoolPage } from "../pages/InvoicePoolPage";
import { LoginPage } from "../pages/LoginPage";
import { NotFoundPage } from "../pages/NotFoundPage";
import { ProfilePage } from "../pages/ProfilePage";
import { ReimbursementsPage } from "../pages/ReimbursementsPage";
import { WorkflowTasksPage } from "../pages/WorkflowTasksPage";
import { GuestOnly, RequireAuth, RequirePermission } from "./guards";
export function AppRouter() {
return (
<Routes>
<Route
path="/login"
element={
<GuestOnly>
<AuthLayout />
</GuestOnly>
}
>
<Route index element={<LoginPage />} />
</Route>
<Route
path="/"
element={
<RequireAuth>
<AppShellLayout />
</RequireAuth>
}
>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route
path="reimbursements"
element={
<RequirePermission anyOf={["expense.view_own", "expense.view_all"]}>
<ReimbursementsPage />
</RequirePermission>
}
/>
<Route
path="workflow/tasks"
element={
<RequirePermission anyOf={["expense.approve"]}>
<WorkflowTasksPage />
</RequirePermission>
}
/>
<Route
path="invoice-pool"
element={
<RequirePermission anyOf={["invoice.upload", "invoice.view_own", "invoice.manage"]}>
<InvoicePoolPage />
</RequirePermission>
}
/>
<Route path="settings/profile" element={<ProfilePage />} />
</Route>
<Route path="/403" element={<ForbiddenPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
);
}
+1
查看文件
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+39
查看文件
@@ -0,0 +1,39 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
darkMode: "class",
theme: {
extend: {
colors: {
primary: {
DEFAULT: "#00398a",
50: "#e6ecf4",
100: "#ccd9ea",
200: "#99b4d5",
300: "#668ebf",
400: "#3369aa",
500: "#00398a",
600: "#002e6e",
700: "#002253",
800: "#001737",
900: "#000b1c"
},
secondary: "#5e718d",
background: {
light: "#f5f7f8",
dark: "#0f1723"
},
border: "#e2e8f0"
},
fontFamily: {
sans: ["Public Sans", "sans-serif"],
display: ["Public Sans", "sans-serif"]
},
borderRadius: {
lg: "0.5rem",
xl: "0.75rem"
}
}
},
plugins: []
};
+21
查看文件
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}
+14
查看文件
@@ -0,0 +1,14 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": {
target: "http://127.0.0.1:3000",
changeOrigin: true,
},
},
},
});