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 联调说明
这个提交包含在:
+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
|
||||
```
|
||||
@@ -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
文件差异内容过多而无法显示
加载差异
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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" />
|
||||
@@ -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: []
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户