From a707edd3c97eece680a13908457bbbeb75a8205d Mon Sep 17 00:00:00 2001 From: shunli Date: Sun, 8 Feb 2026 23:38:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(client):=20=E5=A7=94=E6=B4=BE=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E9=A1=B5=E6=9C=80=E5=B0=8F=E8=BF=90=E8=90=A5=E9=97=AD?= =?UTF-8?q?=E7=8E=AF=EF=BC=88E7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增委派 API 封装与委派管理页面(创建/列表/撤销) - 增加菜单入口与路由守卫(expense.proxy_manage) --- client/src/api/delegations.ts | 59 ++++ client/src/components/navigation/NavLinks.tsx | 6 +- client/src/pages/DelegationsPage.tsx | 262 ++++++++++++++++++ client/src/router/index.tsx | 10 + .../2026-02-08_21-50-09-p4-trialrun-ready.csv | 2 +- 5 files changed, 337 insertions(+), 2 deletions(-) create mode 100644 client/src/api/delegations.ts create mode 100644 client/src/pages/DelegationsPage.tsx diff --git a/client/src/api/delegations.ts b/client/src/api/delegations.ts new file mode 100644 index 00000000..53947156 --- /dev/null +++ b/client/src/api/delegations.ts @@ -0,0 +1,59 @@ +import { apiRequestData } from "./http"; + +export type DelegationStatus = "active" | "scheduled" | "expired" | "revoked"; + +export type DelegationItem = { + id: string; + principalEmployeeId: string; + delegateEmployeeId: string; + scope: "reimbursement_submit" | "all"; + validFrom: string; + validTo: string | null; + revokedAt: string | null; + createdAt: string; + status: DelegationStatus; +}; + +export type CreateDelegationRequest = { + delegateEmployeeId: string; + scope?: "reimbursement_submit" | "all"; + validFrom?: string; + validTo?: string; +}; + +export type CreateDelegationResponse = { + delegation: DelegationItem; +}; + +export type FetchMyDelegationsResponse = { + status: "active" | "scheduled" | "expired" | "revoked" | "all"; + initiated: DelegationItem[]; + received: DelegationItem[]; +}; + +export async function createDelegation(payload: CreateDelegationRequest) { + return apiRequestData("/api/delegations", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function fetchMyDelegations(status: FetchMyDelegationsResponse["status"] = "active") { + const params = new URLSearchParams(); + if (status && status !== "active") { + params.set("status", status); + } + const query = params.toString(); + return apiRequestData(`/api/delegations/mine${query ? `?${query}` : ""}`); +} + +export async function revokeDelegation(delegationId: string) { + return apiRequestData<{ delegation: DelegationItem; outcome: string }>( + `/api/delegations/${delegationId}/revoke`, + { + method: "POST", + body: JSON.stringify({}), + }, + ); +} + diff --git a/client/src/components/navigation/NavLinks.tsx b/client/src/components/navigation/NavLinks.tsx index beabf120..4d65e2ab 100644 --- a/client/src/components/navigation/NavLinks.tsx +++ b/client/src/components/navigation/NavLinks.tsx @@ -31,6 +31,11 @@ export const NAV_ITEMS: NavItem[] = [ label: "审计工作台", anyOf: ["system.audit.view"], }, + { + to: "/delegations", + label: "委派管理", + anyOf: ["expense.proxy_manage"], + }, { to: "/settings/profile", label: "个人设置" }, ]; @@ -67,4 +72,3 @@ export function NavLinks({ onNavigate }: { onNavigate?: () => void }) { ); } - diff --git a/client/src/pages/DelegationsPage.tsx b/client/src/pages/DelegationsPage.tsx new file mode 100644 index 00000000..76ba9951 --- /dev/null +++ b/client/src/pages/DelegationsPage.tsx @@ -0,0 +1,262 @@ +import { useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { mapApiErrorMessage } from "../api/errorMessages"; +import { isApiError } from "../api/types"; +import { createDelegation, fetchMyDelegations, revokeDelegation } from "../api/delegations"; +import { fetchWorkflowApprovers } from "../api/workflow"; +import { PageHeader } from "../components/common/PageHeader"; +import { Button } from "../components/ui/button"; +import { Card } from "../components/ui/card"; +import { Input } from "../components/ui/input"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; + +export function DelegationsPage() { + const queryClient = useQueryClient(); + const [status, setStatus] = useState<"active" | "scheduled" | "expired" | "revoked" | "all">("active"); + const [delegateEmployeeId, setDelegateEmployeeId] = useState(""); + const [scope, setScope] = useState<"all" | "reimbursement_submit">("all"); + const [validTo, setValidTo] = useState(""); + const [error, setError] = useState(""); + + const listQuery = useQuery({ + queryKey: ["delegations", "mine", status], + queryFn: () => fetchMyDelegations(status), + }); + + const approversQuery = useQuery({ + queryKey: ["workflow", "approvers", "delegations"], + queryFn: () => fetchWorkflowApprovers({ page: 1, pageSize: 100 }), + }); + + const delegateOptions = useMemo(() => { + const items = approversQuery.data?.items ?? []; + return items.map((item) => ({ + id: item.id, + label: `${item.name} (${item.employeeNo})`, + })); + }, [approversQuery.data?.items]); + + const createMutation = useMutation({ + mutationFn: () => + createDelegation({ + delegateEmployeeId, + scope, + validTo: validTo ? new Date(validTo).toISOString() : undefined, + }), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["delegations"] }); + setError(""); + }, + onError: (err) => { + if (isApiError(err)) { + setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`); + return; + } + setError(err instanceof Error ? err.message : "创建委派失败"); + }, + }); + + const revokeMutation = useMutation({ + mutationFn: (delegationId: string) => revokeDelegation(delegationId), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["delegations"] }); + setError(""); + }, + onError: (err) => { + if (isApiError(err)) { + setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`); + return; + } + setError(err instanceof Error ? err.message : "撤销委派失败"); + }, + }); + + return ( +
+ + + {error ? ( +

{error}

+ ) : null} + + +

创建委派

+ +
+ + + + + +
+ +
+ +
+
+ + +
+

我的委派

+ +
+ + {listQuery.isLoading ?

加载中...

: null} + {listQuery.isError ? ( +

+ {isApiError(listQuery.error) + ? `${listQuery.error.code}: ${mapApiErrorMessage( + listQuery.error.code, + listQuery.error.details, + listQuery.error.message, + )}` + : "加载委派列表失败"} +

+ ) : null} + + {listQuery.data ? ( +
+
+

我发起的委派

+
+ + + + ID + delegate + scope + status + 操作 + + + + {listQuery.data.initiated.length ? ( + listQuery.data.initiated.map((item) => ( + + {item.id} + {item.delegateEmployeeId} + {item.scope} + {item.status} + + + + + )) + ) : ( + + + 暂无数据 + + + )} + +
+
+
+ +
+

我接收的委派

+
+ + + + ID + principal + scope + status + + + + {listQuery.data.received.length ? ( + listQuery.data.received.map((item) => ( + + {item.id} + {item.principalEmployeeId} + {item.scope} + {item.status} + + )) + ) : ( + + + 暂无数据 + + + )} + +
+
+
+
+ ) : null} +
+
+ ); +} + diff --git a/client/src/router/index.tsx b/client/src/router/index.tsx index b89d6d0d..64bc9fc7 100644 --- a/client/src/router/index.tsx +++ b/client/src/router/index.tsx @@ -3,6 +3,7 @@ import { AppShellLayout } from "../layouts/AppShellLayout"; import { AuthLayout } from "../layouts/AuthLayout"; import { AuditLogsPage } from "../pages/AuditLogsPage"; import { DashboardPage } from "../pages/DashboardPage"; +import { DelegationsPage } from "../pages/DelegationsPage"; import { ForbiddenPage } from "../pages/ForbiddenPage"; import { InvoicePoolPage } from "../pages/InvoicePoolPage"; import { LoginPage } from "../pages/LoginPage"; @@ -93,6 +94,15 @@ export function AppRouter() { } /> + + + + } + /> + } /> diff --git a/issues/2026-02-08_21-50-09-p4-trialrun-ready.csv b/issues/2026-02-08_21-50-09-p4-trialrun-ready.csv index 77ace5ad..537bfa34 100644 --- a/issues/2026-02-08_21-50-09-p4-trialrun-ready.csv +++ b/issues/2026-02-08_21-50-09-p4-trialrun-ready.csv @@ -5,7 +5,7 @@ E3,前端布局导航收敛,统一Sidebar与Topbar交互视觉响应式与权限 E4,待办页精修(列表详情动作),用UI primitives收敛待办列表与详情动作区统一加载空态错误并补data-testid,approve reject return transfer动作可用且动作后列表详情刷新一致,cd client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,client/src/pages/WorkflowTasksPage.tsx | client/src/pages/WorkflowTaskDetailPage.tsx | client/src/api/workflow.ts,"[""E3""]",为E2E补稳定选择器data-testid E5,报销页精修(列表详情联动),收敛报销列表详情展示并优化支付凭证与票据区块一致性并补data-testid,employee可create submit withdraw且finance可pay并填写凭证字段,cd client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,client/src/pages/ReimbursementsPage.tsx | client/src/pages/ReimbursementDetailPage.tsx | client/src/api/reimbursements.ts,"[""E3""]",为E2E补稳定选择器data-testid E6,票据中心精修(OCR与预览),收敛上传列表详情并强化OCR状态展示绑定与签名预览交互并补data-testid,OCR_PROVIDER=mock联调可轮询pending_ocr且ocr_failed展示原因且预览可用,cd client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,client/src/pages/InvoicePoolPage.tsx | client/src/api/invoicePool.ts | client/src/api/http.ts,"[""E3""]",为E2E补稳定选择器data-testid -E7,委派管理前端页(运营可用),新增委派管理页与API封装支持创建列表撤销并加菜单入口与守卫,可创建委派撤销后delegate不可代办且无权限用户403提示正确,"manual: manager创建委派让delegate代办一次后撤销再验证PERM_002",manual,TODO,TODO,TODO,client/src/pages/DelegationsPage.tsx | client/src/api/delegations.ts | client/src/router/index.tsx | client/src/components/navigation/Sidebar.tsx,"[""E3""]",权限码沿用expense.proxy_manage +E7,委派管理前端页(运营可用),新增委派管理页与API封装支持创建列表撤销并加菜单入口与守卫,可创建委派撤销后delegate不可代办且无权限用户403提示正确,"manual: manager创建委派让delegate代办一次后撤销再验证PERM_002",manual,DONE,TODO,TODO,client/src/pages/DelegationsPage.tsx | client/src/api/delegations.ts | client/src/router/index.tsx | client/src/components/navigation/Sidebar.tsx,"[""E3""]",权限码沿用expense.proxy_manage E8,默认账号与失败体验收敛,补dev seed默认账号文档并让前端对网络失败给出可操作提示(含“忘记密码请联系管理员重置”),登录页后端不可达提示服务未启动且README列出默认账号且SYS_001提示可读,"manual: 停后端访问登录页看到提示并按README默认账号登录成功",manual,TODO,TODO,TODO,client/src/pages/LoginPage.tsx | client/src/api/errorMessages.ts | server/README.md | client/README.md,"[""E1""]",仅写dev seed账号不写生产密码与密钥 E9,DB索引补强(试运行性能),为列表分页与筛选补组合索引以降低试运行慢查询风险,迁移可重复执行且回归全绿,cd server && npm run prisma:migrate:deploy && ./scripts/regression-all.sh,manual,TODO,TODO,TODO,server/prisma/schema.prisma | server/prisma/migrations/*,"[""E1""]",新增组合索引仅增不破回滚成本低 E10,后端安全基线(上线级),增加安全头与关键接口限流并完善上传校验与日志脱敏约定,限流触发行为正确安全头存在上传非法类型被拒绝且不泄露token,"manual: curl压测login并验证429与headers并做一次上传非法类型校验",manual,TODO,TODO,TODO,server/src/app.js | server/src/config.js | server/src/routes/auth.js | server/src/routes/invoicePool.js | server/README.md | server/package.json,"[""E1""]",优先使用@fastify/helmet与@fastify/rate-limit并写回滚说明