统一菜单命名并移除剩余台账工具(05-31-unified-menu-remove-ledger-tools-wave2)

这个提交包含在:
2026-05-31 15:06:26 +08:00
父节点 46fa609961
当前提交 43efc9ea08
共修改 47 个文件,包含 498 行新增和 358 行删除
+1 -1
查看文件
@@ -194,7 +194,7 @@ await expect(
- Runtime smoke must distinguish primary sidebar entries from collapsed - Runtime smoke must distinguish primary sidebar entries from collapsed
`更多` entries. `更多` entries.
- Core day-to-day ledgers such as `报销`, `采购申请`, `合同台账`, `项目` and - Core day-to-day ledgers such as `报销`, `采购`, `合同`, `项目` and
`资产` are expected in `main` when the active role has access. `资产` are expected in `main` when the active role has access.
- Low-frequency entries such as `周报` and `公告` may be enabled but should be - Low-frequency entries such as `周报` and `公告` may be enabled but should be
checked in `backup` / `更多`, not forced into `main`. checked in `backup` / `更多`, not forced into `main`.
@@ -52,7 +52,7 @@ reference, not a copy of Feishu or Ant Design branding.
should simplify or hide low-frequency work for small teams, not invent new should simplify or hide low-frequency work for small teams, not invent new
controls or unusual action flows. controls or unusual action flows.
- Page titles default to the route name only, such as `工作台`, `待我处理`, `报销`, - Page titles default to the route name only, such as `工作台`, `待我处理`, `报销`,
`合同台账`, `个人设置` and `通知中心`. Page subtitles are off by default and `合同`, `采购`, `资产`, `个人设置` and `通知中心`. Page subtitles are off by default and
appear only for exception, empty, permission-denied, Enterprise WeChat/OA appear only for exception, empty, permission-denied, Enterprise WeChat/OA
boundary, external-system boundary or irreversible-action explanations. boundary, external-system boundary or irreversible-action explanations.
- Normal workflows should not be explained in default-visible copy. Do not add - Normal workflows should not be explained in default-visible copy. Do not add
@@ -674,7 +674,7 @@ screen.
status, scope and date/time; add business-specific filters such as project, status, scope and date/time; add business-specific filters such as project,
person or type only when they are genuinely frequent. person or type only when they are genuinely frequent.
- The default view must match the route's main job: `我的报销`, `待我处理`, - The default view must match the route's main job: `我的报销`, `待我处理`,
`合同台账`, `资产台账` and similar task nouns. Do not default ordinary users `合同`, `资产` and similar task nouns. Do not default ordinary users
into company-wide management views. into company-wide management views.
- Rows and cards must answer what the object is, current state, why it relates - Rows and cards must answer what the object is, current state, why it relates
to the current user and the next action. to the current user and the next action.
@@ -0,0 +1,5 @@
{"file": ".trellis/spec/hyb-oa/index.md", "reason": "hyb-oa spec entry and read order"}
{"file": ".trellis/spec/hyb-oa/project-governance.md", "reason": "Trellis validation and commit rules"}
{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "frontend action placement and menu naming verification"}
{"file": ".trellis/spec/hyb-oa/docs-and-evidence.md", "reason": "documentation evidence hygiene"}
{"file": ".trellis/spec/hyb-oa/security-privacy.md", "reason": "sensitive export and permissioned action checks"}
@@ -0,0 +1,5 @@
{"file": ".trellis/spec/hyb-oa/index.md", "reason": "hyb-oa spec entry and read order"}
{"file": ".trellis/spec/hyb-oa/project-governance.md", "reason": "Trellis task, solo-developer scope and commit rules"}
{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "frontend visual, template, menu and action-placement rules"}
{"file": ".trellis/spec/hyb-oa/security-privacy.md", "reason": "export and sensitive business action placement constraints"}
{"file": "docs/产品设计规则.md", "reason": "business experience facts and menu/action naming rules"}
@@ -0,0 +1,22 @@
# 实现说明
## 设计口径
- 参考 Ant Design / Ant Design Pro 的成熟后台惯例:业务列表首屏保留标题、常用筛选、列表和明确动作,不再使用泛化 `台账工具`。
- 菜单与页面标题统一短模块名:`采购`、`合同`、`资产`、`付款`、`应付账款`、`票据`。
- 页面内的具体业务对象仍可使用 `采购申请` 等单据名,避免把对象语义和菜单命名混为一类。
## 改造边界
- 只改前端入口、文案、测试断言和稳定设计/业务文档。
- 不改后端权限、审批、付款、OCR、合同、采购或资产状态机。
- 不做公网部署,除非用户后续单独批准。
## 页面迁移
- `采购`:原高级入口改为 `筛选`,保留供应商、法体、成本中心和范围筛选。
- `合同`:页头保留 `新增合同`、`到期列表`、`导出`、`筛选`;到期日与测试数据切换进入高级筛选。
- `项目`:页头使用 `合同`、`显示测试数据`、`新建项目`;新建表单改为明确的 `新建项目` 面板。
- `付款`:原入口改为 `新建批次`,批次创建能力不变。
- `应付账款`:导出直接作为页头 `导出` 下拉,不再包在工具面板。
- `票据`:页头使用 `上传票据`、`绑定`、`导出`,上传和绑定各自打开明确面板。
@@ -0,0 +1,89 @@
# 统一菜单命名并移除剩余台账工具
## Goal
把 wave1 未覆盖的业务页面继续收敛到成熟后台惯例:全系统不再使用泛化的 `台账工具` 按钮,菜单栏统一使用短模块名,避免同一层级混用 `报销`、`采购申请`、`合同台账` 这类不同粒度名称。
## What I Already Know
- 产品面向国内小于 10 人的科技初创公司,首屏要低复杂度、少解释文案、动作位置稳定。
- wave1 已完成 `报销` 和 `资产` 页面去 `台账工具`,并新增列表/详情/编辑薄模板规则。
- 当前仍有 `台账工具` 残留在 `采购`、`合同`、`项目`、`付款`、`应付账款`、`票据`。
- 当前菜单命名残留不一致:`报销`、`采购申请`、`合同台账`、`资产台账` 等混用。
- 用户已确认 wave2 规则:菜单统一为短模块名,页面内按具体上下文显示详情或单据名。
## Scope
- 移除剩余业务页面中的用户可见 `台账工具` 文案和按钮。
- 将原有能力迁移到明确动作:`筛选`、`导出`、`统计`、`新增`、`上传`、`绑定`、`创建批次`、`更多` 或详情页操作区。
- 统一 PC 导航、服务端导航配置和页面路由标题里的模块命名。
- 更新 `docs/产品设计规则.md` 和前端 spec,禁止后续再新增泛化 `台账工具`。
- 更新相关 Playwright 断言,新增或调整防回归检查。
## Requirements
- 菜单栏统一短模块名:
- `报销`
- `采购`
- `合同`
- `项目`
- `资产`
- `付款`
- `应付账款`
- `票据`
- 页面标题也优先使用短模块名;详情页、审批对象、单据正文可以继续使用具体名称如 `采购申请`、`合同详情`、`采购详情`。
- 不删除原功能,只把入口迁移到具体、可理解的位置。
- 不改变后端业务逻辑、权限模型、审批规则、付款状态机、OCR 规则或数据结构。
- 不做公网部署,除非用户后续另行批准。
## Acceptance Criteria
- [x] `client/src/pages/**` 中普通用户页面不再出现可点击的 `台账工具`。
- [x] PC 菜单和服务端导航返回的业务模块名统一为短模块名。
- [x] 采购、合同、项目、付款、应付账款、票据的原工具能力仍可通过明确动作访问。
- [x] `docs/产品设计规则.md` 与 frontend spec 均记录禁用 `台账工具` 和菜单命名规则。
- [x] `cd client && npm run typecheck` 通过。
- [x] `cd client && npm run build` 通过。
- [x] 相关聚焦 Playwright 或静态检查通过。
## Out Of Scope
- 不部署公网。
- 不重做页面视觉大布局和详情页信息架构。
- 不改审批、付款、OCR、资产流转、合同归档等业务状态机。
- 不一次性处理非业务菜单,如后台管理子页面、审计、配置页面的深层命名。
## Test Plan
- `rg -n "台账工具" client/src/pages client/src/components server/src/services/workspaceSettings.js docs/产品设计规则.md .trellis/spec/hyb-oa/frontend-react-vite-design.md`
- `cd client && npm run typecheck`
- `cd client && npm run build`
- 聚焦运行受影响的菜单/视觉/确认类 Playwright 测试。
- `git diff --check`
- `python3 ./.trellis/scripts/task.py validate 05-31-unified-menu-remove-ledger-tools-wave2`
- `node scripts/qa/check-trellis-governance.mjs`
## Validation Results
- `rg -n "台账工具|showLedgerTools|showBatchTools|ledger-tools|合同台账|资产台账|票据中心|AP 运营|ERP 集成" client/src server/src/services/workspaceSettings.js client/src/features/navigation/schema.ts`:无匹配。
- `cd client && npm run typecheck`:通过。
- `cd client && npm run build`:通过。
- `E2E_BASE_URL=http://127.0.0.1:5173 npx playwright test tests/e2e/r103-cross-page-ui-consistency.spec.ts tests/e2e/pc-finance-admin-visual-wave4.spec.ts tests/e2e/visual-shell.spec.ts --workers=1 --reporter=list`:12 passed。
- `git diff --check`:通过。
- `python3 ./.trellis/scripts/task.py validate 05-31-unified-menu-remove-ledger-tools-wave2`:通过。
- `node scripts/qa/check-trellis-governance.mjs`:通过,有 `implement_non_spec_context` 提醒,原因为 `implement.jsonl` 引用了业务事实源 `docs/产品设计规则.md`。
## Technical Notes
- 设计裁决:`frontend-design` skill;成熟后台参考 Ant Design / Ant Design Pro 的命名和动作位置。
- 重点页面:
- `client/src/pages/PurchaseRequestsPage.tsx`
- `client/src/pages/ContractsRegistryPage.tsx`
- `client/src/pages/ProjectsPage.tsx`
- `client/src/pages/PaymentBatchesPage.tsx`
- `client/src/pages/ApOperationsPage.tsx`
- `client/src/pages/InvoicePoolPage.tsx`
- 菜单配置:
- `client/src/components/navigation/NavLinks.tsx`
- `client/src/features/navigation/schema.ts`
- `server/src/services/workspaceSettings.js`
@@ -0,0 +1,26 @@
{
"id": "unified-menu-remove-ledger-tools-wave2",
"name": "unified-menu-remove-ledger-tools-wave2",
"title": "统一菜单命名并移除剩余台账工具",
"description": "移除采购、合同、项目、付款、应付账款和票据页面剩余的泛化台账工具入口,并统一菜单与页面标题为短模块名。",
"status": "in_progress",
"dev_type": null,
"scope": "frontend",
"package": null,
"priority": "P2",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-05-31",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
+1 -1
查看文件
@@ -28,7 +28,7 @@ function entityLabel(entityType: string) {
if (entityType === "travel_request") return "差旅"; if (entityType === "travel_request") return "差旅";
if (entityType === "asset") return "资产"; if (entityType === "asset") return "资产";
if (entityType === "project") return "项目"; if (entityType === "project") return "项目";
if (entityType === "contract_registry") return "合同台账"; if (entityType === "contract_registry") return "合同";
if (entityType === "confidential_doc_ref") return "资料引用"; if (entityType === "confidential_doc_ref") return "资料引用";
return entityType; return entityType;
} }
@@ -29,8 +29,8 @@ export const NAV_ITEMS: NavItem[] = [
{ to: "/projects", label: "项目", icon: "workspaces", anyOf: ["project.view_own", "project.view_all", "project.manage"] }, { to: "/projects", label: "项目", icon: "workspaces", anyOf: ["project.view_own", "project.view_all", "project.manage"] },
{ to: "/system/settings", label: "管理", icon: "settings", anyOf: ["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage", "system.audit.view"] }, { to: "/system/settings", label: "管理", icon: "settings", anyOf: ["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage", "system.audit.view"] },
{ to: "/workflow/tasks", label: "待我处理", icon: "fact_check", anyOf: ["expense.approve", "leave.approve", "attendance.approve", "procurement.approve", "travel.approve"] }, { to: "/workflow/tasks", label: "待我处理", icon: "fact_check", anyOf: ["expense.approve", "leave.approve", "attendance.approve", "procurement.approve", "travel.approve"] },
{ to: "/contracts-registry", label: "合同台账", icon: "description", anyOf: ["project.view_all", "project.manage", "system.settings"] }, { to: "/contracts-registry", label: "合同", icon: "description", anyOf: ["project.view_all", "project.manage", "system.settings"] },
{ to: "/purchase-requests", label: "采购申请", icon: "shopping_cart", anyOf: ["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"] }, { to: "/purchase-requests", label: "采购", icon: "shopping_cart", anyOf: ["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"] },
{ to: "/travel-requests", label: "差旅申请", icon: "flight", anyOf: ["travel.view_all", "travel.manage", "travel.payment.manage"] }, { to: "/travel-requests", label: "差旅申请", icon: "flight", anyOf: ["travel.view_all", "travel.manage", "travel.payment.manage"] },
{ to: "/budgets", label: "预算管理", icon: "account_balance", anyOf: ["budget.view", "budget.manage"] }, { to: "/budgets", label: "预算管理", icon: "account_balance", anyOf: ["budget.view", "budget.manage"] },
{ to: "/payment-batches", label: "付款", icon: "payments", anyOf: ["payment.batch.manage", "risk.review.manage"] }, { to: "/payment-batches", label: "付款", icon: "payments", anyOf: ["payment.batch.manage", "risk.review.manage"] },
@@ -43,7 +43,7 @@ export const NAV_ITEMS: NavItem[] = [
const ACCESSIBLE_LABELS: Record<string, string> = { const ACCESSIBLE_LABELS: Record<string, string> = {
"/reimbursements": "我的报销", "/reimbursements": "我的报销",
"/workflow/tasks": "待我处理", "/workflow/tasks": "待我处理",
"/assets": "资产台账", "/assets": "资产",
}; };
const MANAGEMENT_ACTIVE_PATHS = [ const MANAGEMENT_ACTIVE_PATHS = [
+2 -2
查看文件
@@ -9,8 +9,8 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
{ path: "/attendance", title: "考勤", group: "业务" }, { path: "/attendance", title: "考勤", group: "业务" },
{ path: "/wecom-approval-archive", title: "企微审批历史", group: "业务" }, { path: "/wecom-approval-archive", title: "企微审批历史", group: "业务" },
{ path: "/projects", title: "项目", group: "业务" }, { path: "/projects", title: "项目", group: "业务" },
{ path: "/contracts-registry", title: "合同台账", group: "业务" }, { path: "/contracts-registry", title: "合同", group: "业务" },
{ path: "/purchase-requests", title: "采购申请", group: "业务" }, { path: "/purchase-requests", title: "采购", group: "业务" },
{ path: "/travel-requests", title: "差旅申请", group: "更多" }, { path: "/travel-requests", title: "差旅申请", group: "更多" },
{ path: "/budgets", title: "预算管理", group: "财务" }, { path: "/budgets", title: "预算管理", group: "财务" },
{ path: "/payment-batches", title: "付款", group: "财务" }, { path: "/payment-batches", title: "付款", group: "财务" },
+21 -36
查看文件
@@ -44,6 +44,7 @@ import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { DateTextInput } from "../components/common/DateTextInput"; import { DateTextInput } from "../components/common/DateTextInput";
import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace"; import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -258,7 +259,6 @@ export function ApOperationsPage() {
const [ledgerDateTo, setLedgerDateTo] = useState(""); const [ledgerDateTo, setLedgerDateTo] = useState("");
const [ledgerSearch, setLedgerSearch] = useState(""); const [ledgerSearch, setLedgerSearch] = useState("");
const [ledgerPage, setLedgerPage] = useState(1); const [ledgerPage, setLedgerPage] = useState(1);
const [showLedgerTools, setShowLedgerTools] = useState(false);
const [showLedgerAdvancedFilters, setShowLedgerAdvancedFilters] = useState(false); const [showLedgerAdvancedFilters, setShowLedgerAdvancedFilters] = useState(false);
const [reviewTarget, setReviewTarget] = useState<PaymentBatch | null>(null); const [reviewTarget, setReviewTarget] = useState<PaymentBatch | null>(null);
@@ -605,13 +605,26 @@ export function ApOperationsPage() {
> >
刷新 刷新
</Button> </Button>
<Button <DropdownMenu>
type="button" <DropdownMenuTrigger asChild>
variant="secondary" <Button
onClick={() => setShowLedgerTools((current) => !current)} data-testid="ap-export"
> type="button"
台账工具 variant="secondary"
</Button> disabled={exportMutation.isPending}
>
{exportMutation.isPending ? "导出中..." : "导出"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem data-testid="ap-export-csv" onSelect={() => exportMutation.mutate("csv")}>
导出 CSV
</DropdownMenuItem>
<DropdownMenuItem data-testid="ap-export-xlsx" onSelect={() => exportMutation.mutate("xlsx")}>
导出 Excel
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</> </>
} }
/> />
@@ -629,34 +642,6 @@ export function ApOperationsPage() {
<SectionError message={resolveErrorMessage(financeLedgerQuery.error, "财务台账加载失败")} /> <SectionError message={resolveErrorMessage(financeLedgerQuery.error, "财务台账加载失败")} />
) : null} ) : null}
{showLedgerTools ? (
<div className="finance-command-panel space-y-3" data-testid="ap-ledger-tools">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<h3 className="text-base font-semibold text-zinc-900">台账工具</h3>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button
type="button"
variant="secondary"
onClick={() => exportMutation.mutate("csv")}
disabled={exportMutation.isPending}
>
{exportMutation.isPending ? "导出中..." : "导出 CSV"}
</Button>
<Button
type="button"
variant="secondary"
onClick={() => exportMutation.mutate("xlsx")}
disabled={exportMutation.isPending}
>
{exportMutation.isPending ? "导出中..." : "导出 Excel"}
</Button>
</div>
</div>
</div>
) : null}
<div className="finance-command-panel"> <div className="finance-command-panel">
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as OperationsTab)}> <Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as OperationsTab)}>
<TabsList className="ops-command-bar__tabs !w-full !justify-start"> <TabsList className="ops-command-bar__tabs !w-full !justify-start">
+1 -1
查看文件
@@ -725,7 +725,7 @@ export function AssetRegistryPage() {
return ( return (
<ListWorkspace> <ListWorkspace>
<WorkspaceHeader <WorkspaceHeader
title="资产台账" title="资产"
actions={ actions={
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{isAssetDetailMode ? ( {isAssetDetailMode ? (
+81 -76
查看文件
@@ -216,7 +216,7 @@ export function ContractsRegistryPage() {
const [editor, setEditor] = useState<EditorState>(() => buildEmptyEditor(initialProjectId)); const [editor, setEditor] = useState<EditorState>(() => buildEmptyEditor(initialProjectId));
const [showExpiring, setShowExpiring] = useState(false); const [showExpiring, setShowExpiring] = useState(false);
const [showTrialData, setShowTrialData] = useState(false); const [showTrialData, setShowTrialData] = useState(false);
const [showLedgerTools, setShowLedgerTools] = useState(false); const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
const [showEditor, setShowEditor] = useState(false); const [showEditor, setShowEditor] = useState(false);
const [confirmExportFormat, setConfirmExportFormat] = useState<ContractRegistryExportFormat | null>(null); const [confirmExportFormat, setConfirmExportFormat] = useState<ContractRegistryExportFormat | null>(null);
const [confirmStatusTarget, setConfirmStatusTarget] = useState<ContractRegistryItem | null>(null); const [confirmStatusTarget, setConfirmStatusTarget] = useState<ContractRegistryItem | null>(null);
@@ -293,9 +293,9 @@ export function ContractsRegistryPage() {
await refreshAll(); await refreshAll();
setShowEditor(false); setShowEditor(false);
resetEditor(); resetEditor();
toast.success(editorId ? "合同台账已更新" : "合同台账已创建"); toast.success(editorId ? "合同已更新" : "合同已创建");
}, },
onError: (error) => toast.error(resolveError(error, editorId ? "更新合同台账失败" : "创建合同台账失败")), onError: (error) => toast.error(resolveError(error, editorId ? "更新合同失败" : "创建合同失败")),
}); });
const toggleStatusMutation = useMutation({ const toggleStatusMutation = useMutation({
@@ -324,15 +324,15 @@ export function ContractsRegistryPage() {
}), }),
onSuccess: (response, format) => { onSuccess: (response, format) => {
downloadBlob(response.blob, response.filename ?? `contracts-registry.${format}`); downloadBlob(response.blob, response.filename ?? `contracts-registry.${format}`);
toast.success(`合同台账已导出为 ${format.toUpperCase()}`); toast.success(`合同已导出为 ${format.toUpperCase()}`);
}, },
onError: (error) => toast.error(resolveError(error, "导出合同台账失败")), onError: (error) => toast.error(resolveError(error, "导出合同失败")),
}); });
return ( return (
<PageScaffold> <PageScaffold>
<PageHeader <PageHeader
title="合同台账" title="合同"
actions={ actions={
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{initialProjectId ? ( {initialProjectId ? (
@@ -349,73 +349,58 @@ export function ContractsRegistryPage() {
> >
新增合同 新增合同
</Button> </Button>
<Button type="button" variant={showLedgerTools ? "default" : "secondary"} onClick={() => setShowLedgerTools((value) => !value)}> <Button
台账工具 type="button"
variant={showExpiring ? "default" : "secondary"}
disabled={expiringQuery.isLoading}
onClick={() => setShowExpiring((value) => !value)}
>
到期列表 {expiringContractCount}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="secondary" disabled={exportMutation.isPending}>
{exportMutation.isPending ? "导出中..." : "导出"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setConfirmExportFormat("csv")}>
导出 CSV
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setConfirmExportFormat("xlsx")}>
导出 Excel
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
variant={showAdvancedFilters || filters.expiryDateFrom || filters.expiryDateTo || showTrialData ? "default" : "secondary"}
onClick={() => setShowAdvancedFilters((value) => !value)}
>
筛选
</Button> </Button>
</div> </div>
} }
/> />
{showLedgerTools ? ( {expiringQuery.isError ? (
<div className="detail-command-panel space-y-4" data-testid="contracts-ledger-tools"> <div className="text-sm text-red-600">{resolveError(expiringQuery.error, "加载到期预警失败")}</div>
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> ) : null}
<div> {showExpiring ? (
<h3 className="text-base font-semibold text-zinc-900">台账工具</h3> <div className="detail-command-panel space-y-2" data-testid="contracts-expiring-list">
</div> {expiringItems.map((item) => (
<div className="flex shrink-0 flex-wrap gap-2"> <div key={item.id} className="flex items-center justify-between gap-3 rounded-lg border border-zinc-200 px-3 py-2 text-sm">
<Button <div className="min-w-0">
type="button" <div className="truncate font-medium text-zinc-900">{item.contractNo} · {item.counterpartyName}</div>
variant="secondary" <div className="text-xs text-zinc-500">到期日 {formatDisplayDate(item.expiryDate)} · 责任人 {item.owner?.name ?? "-"}</div>
disabled={expiringQuery.isLoading} </div>
onClick={() => setShowExpiring((value) => !value)} <Button type="button" size="sm" variant="secondary" onClick={() => openEditor(item)}>
> 编辑
到期列表 {expiringContractCount}
</Button>
<Button type="button" variant="secondary" disabled={exportMutation.isPending} onClick={() => setConfirmExportFormat("csv")}>
{exportMutation.isPending ? "导出中..." : "导出 CSV"}
</Button>
<Button type="button" variant="secondary" disabled={exportMutation.isPending} onClick={() => setConfirmExportFormat("xlsx")}>
{exportMutation.isPending ? "导出中..." : "导出 Excel"}
</Button> </Button>
</div> </div>
</div> ))}
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4"> {!expiringQuery.isLoading && !expiringItems.length ? (
<div className="space-y-2"> <div className="text-sm text-zinc-500">暂无到期合同</div>
<Label>到期日开始</Label>
<DateTextInput
value={filters.expiryDateFrom}
onChange={(event) => setFilters((current) => ({ ...current, expiryDateFrom: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>到期日结束</Label>
<DateTextInput
value={filters.expiryDateTo}
onChange={(event) => setFilters((current) => ({ ...current, expiryDateTo: event.target.value }))}
/>
</div>
</div>
{expiringQuery.isError ? (
<div className="text-sm text-red-600">{resolveError(expiringQuery.error, "加载到期预警失败")}</div>
) : null}
{showExpiring ? (
<div className="space-y-2 border-t border-zinc-200 pt-3">
{expiringItems.map((item) => (
<div key={item.id} className="flex items-center justify-between gap-3 rounded-lg border border-zinc-200 px-3 py-2 text-sm">
<div className="min-w-0">
<div className="truncate font-medium text-zinc-900">{item.contractNo} · {item.counterpartyName}</div>
<div className="text-xs text-zinc-500">到期日 {formatDisplayDate(item.expiryDate)} · 责任人 {item.owner?.name ?? "-"}</div>
</div>
<Button type="button" size="sm" variant="secondary" onClick={() => openEditor(item)}>
编辑
</Button>
</div>
))}
{!expiringQuery.isLoading && !expiringItems.length ? (
<div className="text-sm text-zinc-500">暂无到期合同</div>
) : null}
</div>
) : null} ) : null}
</div> </div>
) : null} ) : null}
@@ -425,7 +410,7 @@ export function ContractsRegistryPage() {
<div className="card-std space-y-4 p-6" data-testid="contracts-editor-panel"> <div className="card-std space-y-4 p-6" data-testid="contracts-editor-panel">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div> <div>
<h3 className="text-base font-semibold text-zinc-900">{editorId ? "编辑合同台账" : "新建合同台账"}</h3> <h3 className="text-base font-semibold text-zinc-900">{editorId ? "编辑合同" : "新建合同"}</h3>
<p className="mt-1 text-sm text-zinc-500"> <p className="mt-1 text-sm text-zinc-500">
补齐合同类型、金额、签署/生效/到期日期,续约将自动创建新的草稿合同。 补齐合同类型、金额、签署/生效/到期日期,续约将自动创建新的草稿合同。
</p> </p>
@@ -676,7 +661,7 @@ export function ContractsRegistryPage() {
} }
onClick={() => saveMutation.mutate()} onClick={() => saveMutation.mutate()}
> >
{saveMutation.isPending ? (editorId ? "保存中..." : "创建中...") : editorId ? "保存修改" : "创建合同台账"} {saveMutation.isPending ? (editorId ? "保存中..." : "创建中...") : editorId ? "保存修改" : "创建合同"}
</Button> </Button>
</div> </div>
) : null} ) : null}
@@ -704,13 +689,33 @@ export function ContractsRegistryPage() {
> >
重置筛选 重置筛选
</Button> </Button>
{showLedgerTools ? ( </>
}
advanced={showAdvancedFilters || filters.expiryDateFrom || filters.expiryDateTo || showTrialData ? (
<div className="grid gap-3 md:grid-cols-[160px_160px_auto] xl:grid-cols-[180px_180px_auto]" data-testid="contracts-advanced-filters">
<div className="space-y-2">
<Label>到期日开始</Label>
<DateTextInput
value={filters.expiryDateFrom}
onChange={(event) => setFilters((current) => ({ ...current, expiryDateFrom: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>到期日结束</Label>
<DateTextInput
value={filters.expiryDateTo}
onChange={(event) => setFilters((current) => ({ ...current, expiryDateTo: event.target.value }))}
/>
</div>
<div className="flex items-end">
<Button type="button" variant={showTrialData ? "default" : "secondary"} onClick={() => setShowTrialData((value) => !value)}> <Button type="button" variant={showTrialData ? "default" : "secondary"} onClick={() => setShowTrialData((value) => !value)}>
{showTrialData ? "隐藏测试数据" : "显示测试数据"} {showTrialData ? "隐藏测试数据" : "显示测试数据"}
</Button> </Button>
) : null} </div>
</> </div>
} ) : null}
> >
<div className="grid gap-3 xl:grid-cols-[minmax(180px,1fr)_160px_160px_minmax(220px,1fr)]"> <div className="grid gap-3 xl:grid-cols-[minmax(180px,1fr)_160px_160px_minmax(220px,1fr)]">
<div> <div>
@@ -774,7 +779,7 @@ export function ContractsRegistryPage() {
{contractsQuery.isLoading ? <div className="text-sm text-zinc-500">加载中...</div> : null} {contractsQuery.isLoading ? <div className="text-sm text-zinc-500">加载中...</div> : null}
{contractsQuery.isError ? ( {contractsQuery.isError ? (
<div className="text-sm text-red-600">{resolveError(contractsQuery.error, "加载合同台账失败")}</div> <div className="text-sm text-red-600">{resolveError(contractsQuery.error, "加载合同失败")}</div>
) : null} ) : null}
{!showTrialData && hiddenTrialContractCount > 0 ? ( {!showTrialData && hiddenTrialContractCount > 0 ? (
@@ -804,7 +809,7 @@ export function ContractsRegistryPage() {
</article> </article>
))} ))}
{!contractsQuery.isLoading && !visibleContracts.length ? ( {!contractsQuery.isLoading && !visibleContracts.length ? (
<div className="rounded-lg border border-zinc-200 bg-white p-4 text-sm text-zinc-500">暂无合同台账</div> <div className="rounded-lg border border-zinc-200 bg-white p-4 text-sm text-zinc-500">暂无合同</div>
) : null} ) : null}
</BusinessMobileList> </BusinessMobileList>
@@ -884,7 +889,7 @@ export function ContractsRegistryPage() {
) : ( ) : (
<tr> <tr>
<td className="py-8 text-center text-sm text-zinc-500" colSpan={6}> <td className="py-8 text-center text-sm text-zinc-500" colSpan={6}>
暂无合同台账 暂无合同
</td> </td>
</tr> </tr>
)} )}
@@ -898,7 +903,7 @@ export function ContractsRegistryPage() {
if (!open) setConfirmExportFormat(null); if (!open) setConfirmExportFormat(null);
}} }}
kind="sensitiveExport" kind="sensitiveExport"
targetName="合同台账" targetName="合同"
description="导出文件可能包含合同金额、相对方、项目归属、责任人和归档位置摘要。请确认当前筛选范围和用途合规。" description="导出文件可能包含合同金额、相对方、项目归属、责任人和归档位置摘要。请确认当前筛选范围和用途合规。"
onConfirm={() => { onConfirm={() => {
if (!confirmExportFormat) return; if (!confirmExportFormat) return;
+77 -65
查看文件
@@ -87,7 +87,7 @@ export function InvoicePoolPage() {
const [editTotalAmount, setEditTotalAmount] = useState(""); const [editTotalAmount, setEditTotalAmount] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [confirmAction, setConfirmAction] = useState<{ type: string; invoiceId: string } | null>(null); const [confirmAction, setConfirmAction] = useState<{ type: string; invoiceId: string } | null>(null);
const [showLedgerTools, setShowLedgerTools] = useState(false); const [invoiceActionPanel, setInvoiceActionPanel] = useState<"upload" | "bind" | null>(null);
const previousDetailStatusRef = useRef<InvoicePoolStatus | null>(null); const previousDetailStatusRef = useRef<InvoicePoolStatus | null>(null);
// --- Queries --- // --- Queries ---
@@ -473,7 +473,7 @@ export function InvoicePoolPage() {
return ( return (
<ListWorkspace> <ListWorkspace>
<WorkspaceHeader <WorkspaceHeader
title="票据中心" title="票据"
actions={ actions={
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{isInvoiceDetailMode ? ( {isInvoiceDetailMode ? (
@@ -481,9 +481,42 @@ export function InvoicePoolPage() {
返回列表 返回列表
</Button> </Button>
) : ( ) : (
<Button type="button" variant={showLedgerTools ? "default" : "secondary"} onClick={() => setShowLedgerTools((current) => !current)}> <>
台账工具 <Button
</Button> type="button"
variant={invoiceActionPanel === "upload" ? "default" : "secondary"}
onClick={() => setInvoiceActionPanel((current) => (current === "upload" ? null : "upload"))}
>
上传票据
</Button>
<Button
type="button"
variant={invoiceActionPanel === "bind" ? "default" : "secondary"}
onClick={() => setInvoiceActionPanel((current) => (current === "bind" ? null : "bind"))}
>
绑定
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
data-testid="invoice-export"
type="button"
variant="secondary"
disabled={exportMutation.isPending}
>
{exportMutation.isPending ? "导出中..." : "导出"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem data-testid="invoice-export-csv" onSelect={() => exportMutation.mutate("csv")}>
导出 CSV
</DropdownMenuItem>
<DropdownMenuItem data-testid="invoice-export-xlsx" onSelect={() => exportMutation.mutate("xlsx")}>
导出 Excel
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)} )}
</div> </div>
} }
@@ -495,66 +528,45 @@ export function InvoicePoolPage() {
</p> </p>
) : null} ) : null}
{!isInvoiceDetailMode && showLedgerTools ? ( {!isInvoiceDetailMode && invoiceActionPanel ? (
<div className="finance-command-panel space-y-3" data-testid="invoice-ledger-tools"> <div className="finance-command-panel space-y-3" data-testid="invoice-actions-panel">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between"> {invoiceActionPanel === "upload" ? (
<div> <InvoiceUploadCard
<h3 className="text-base font-semibold text-zinc-900">台账工具</h3> selectedFiles={selectedFiles}
</div> setSelectedFiles={setSelectedFiles}
<DropdownMenu> isUploading={uploadMutation.isPending}
<DropdownMenuTrigger asChild> uploadProgressLabel={
<Button uploadProgress
data-testid="invoice-export" ? `上传中 ${uploadProgress.completed}/${uploadProgress.total}`
type="button" : "上传中..."
variant="secondary" }
disabled={exportMutation.isPending} onUpload={() => uploadMutation.mutate()}
> showProxyOwnerSelector={showProxyOwnerSelector}
{exportMutation.isPending ? "导出中..." : "导出"} proxyDelegationsQuery={proxyDelegationsQuery}
</Button> uploadOwnerEmployeeId={uploadOwnerEmployeeId}
</DropdownMenuTrigger> setUploadOwnerEmployeeId={setUploadOwnerEmployeeId}
<DropdownMenuContent align="end"> />
<DropdownMenuItem data-testid="invoice-export-csv" onSelect={() => exportMutation.mutate("csv")}> ) : null}
导出 CSV {invoiceActionPanel === "bind" ? (
</DropdownMenuItem> <InvoiceBindCard
<DropdownMenuItem data-testid="invoice-export-xlsx" onSelect={() => exportMutation.mutate("xlsx")}> listQuery={listQuery}
导出 Excel reimbursementQuery={reimbursementQuery}
</DropdownMenuItem> travelRequestQuery={travelRequestQuery}
</DropdownMenuContent> travelRequestDetailQuery={travelRequestDetailQuery}
</DropdownMenu> bindMutation={bindMutation}
</div> bindMode={bindMode}
<InvoiceUploadCard setBindMode={setBindMode}
selectedFiles={selectedFiles} canManageTravelPayments={canManageTravelPayments}
setSelectedFiles={setSelectedFiles} selectedInvoiceId={selectedInvoiceId}
isUploading={uploadMutation.isPending} setSelectedInvoiceId={setSelectedInvoiceId}
uploadProgressLabel={ selectedReimbursementId={selectedReimbursementId}
uploadProgress setSelectedReimbursementId={setSelectedReimbursementId}
? `上传中 ${uploadProgress.completed}/${uploadProgress.total}` selectedTravelRequestId={selectedTravelRequestId}
: "上传中..." setSelectedTravelRequestId={setSelectedTravelRequestId}
} selectedTravelOrderId={selectedTravelOrderId}
onUpload={() => uploadMutation.mutate()} setSelectedTravelOrderId={setSelectedTravelOrderId}
showProxyOwnerSelector={showProxyOwnerSelector} />
proxyDelegationsQuery={proxyDelegationsQuery} ) : null}
uploadOwnerEmployeeId={uploadOwnerEmployeeId}
setUploadOwnerEmployeeId={setUploadOwnerEmployeeId}
/>
<InvoiceBindCard
listQuery={listQuery}
reimbursementQuery={reimbursementQuery}
travelRequestQuery={travelRequestQuery}
travelRequestDetailQuery={travelRequestDetailQuery}
bindMutation={bindMutation}
bindMode={bindMode}
setBindMode={setBindMode}
canManageTravelPayments={canManageTravelPayments}
selectedInvoiceId={selectedInvoiceId}
setSelectedInvoiceId={setSelectedInvoiceId}
selectedReimbursementId={selectedReimbursementId}
setSelectedReimbursementId={setSelectedReimbursementId}
selectedTravelRequestId={selectedTravelRequestId}
setSelectedTravelRequestId={setSelectedTravelRequestId}
selectedTravelOrderId={selectedTravelOrderId}
setSelectedTravelOrderId={setSelectedTravelOrderId}
/>
</div> </div>
) : null} ) : null}
+4 -4
查看文件
@@ -117,7 +117,7 @@ export function PaymentBatchesPage() {
const receiptInputRef = useRef<HTMLInputElement | null>(null); const receiptInputRef = useRef<HTMLInputElement | null>(null);
const [selectedReceiptFiles, setSelectedReceiptFiles] = useState<File[]>([]); const [selectedReceiptFiles, setSelectedReceiptFiles] = useState<File[]>([]);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [showBatchTools, setShowBatchTools] = useState(false); const [showCreatePanel, setShowCreatePanel] = useState(false);
const [confirmPaymentAction, setConfirmPaymentAction] = useState<{ const [confirmPaymentAction, setConfirmPaymentAction] = useState<{
kind: "financeReview" | "financePayment" | "cancel"; kind: "financeReview" | "financePayment" | "cancel";
batchId: string; batchId: string;
@@ -356,8 +356,8 @@ export function PaymentBatchesPage() {
刷新 刷新
</Button> </Button>
{!selectedBatchId ? ( {!selectedBatchId ? (
<Button type="button" variant={showBatchTools ? "default" : "secondary"} onClick={() => setShowBatchTools((current) => !current)}> <Button type="button" variant={showCreatePanel ? "default" : "secondary"} onClick={() => setShowCreatePanel((current) => !current)}>
台账工具 新建批次
</Button> </Button>
) : null} ) : null}
</div> </div>
@@ -366,7 +366,7 @@ export function PaymentBatchesPage() {
{error ? <div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</div> : null} {error ? <div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</div> : null}
{!selectedBatchId && showBatchTools ? ( {!selectedBatchId && showCreatePanel ? (
<div className="finance-command-panel space-y-4" data-testid="payment-batch-create-panel"> <div className="finance-command-panel space-y-4" data-testid="payment-batch-create-panel">
<div> <div>
<h3 className="text-base font-semibold text-zinc-900">新建批次</h3> <h3 className="text-base font-semibold text-zinc-900">新建批次</h3>
+3 -3
查看文件
@@ -596,7 +596,7 @@ export function ProjectDetailPage() {
<Link to="/projects">返回项目</Link> <Link to="/projects">返回项目</Link>
</Button> </Button>
<Button asChild variant="secondary" type="button"> <Button asChild variant="secondary" type="button">
<Link to={`/contracts-registry?projectId=${detail.id}`}>合同台账</Link> <Link to={`/contracts-registry?projectId=${detail.id}`}>合同</Link>
</Button> </Button>
<Button type="button" variant="secondary" disabled={archiveProjectMutation.isPending || detail.status === "archived"} onClick={() => setConfirmArchiveProject(true)}> <Button type="button" variant="secondary" disabled={archiveProjectMutation.isPending || detail.status === "archived"} onClick={() => setConfirmArchiveProject(true)}>
{archiveProjectMutation.isPending ? "归档中..." : "归档项目"} {archiveProjectMutation.isPending ? "归档中..." : "归档项目"}
@@ -1090,7 +1090,7 @@ export function ProjectDetailPage() {
<div>负责人:{detail.owner?.name ?? "-"}</div> <div>负责人:{detail.owner?.name ?? "-"}</div>
<div>项目经理:{detail.manager?.name ?? "-"}</div> <div>项目经理:{detail.manager?.name ?? "-"}</div>
<div>周报关联:{counts?.weeklyReports ?? 0}</div> <div>周报关联:{counts?.weeklyReports ?? 0}</div>
<div>合同台账:{counts?.contracts ?? 0}</div> <div>合同:{counts?.contracts ?? 0}</div>
<div>交付记录:{counts?.deliveries ?? 0}</div> <div>交付记录:{counts?.deliveries ?? 0}</div>
<div>资料引用:{counts?.documentRefs ?? 0}</div> <div>资料引用:{counts?.documentRefs ?? 0}</div>
<div className="rounded-lg border border-zinc-200 p-3 text-zinc-600"> <div className="rounded-lg border border-zinc-200 p-3 text-zinc-600">
@@ -1156,7 +1156,7 @@ export function ProjectDetailPage() {
</div> </div>
</div> </div>
<div className="rounded-lg border border-zinc-200 p-4"> <div className="rounded-lg border border-zinc-200 p-4">
<div className="text-sm text-zinc-500">合同台账</div> <div className="text-sm text-zinc-500">合同</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">{contractsQuery.data?.items.length ?? 0}</div> <div className="mt-1 text-xl font-semibold text-zinc-900">{contractsQuery.data?.items.length ?? 0}</div>
<div className="mt-2 space-y-2 text-sm"> <div className="mt-2 space-y-2 text-sm">
{(contractsQuery.data?.items ?? []).slice(0, 5).map((item) => ( {(contractsQuery.data?.items ?? []).slice(0, 5).map((item) => (
+63 -66
查看文件
@@ -82,7 +82,7 @@ export function ProjectsPage() {
const [targetEndAt, setTargetEndAt] = useState(""); const [targetEndAt, setTargetEndAt] = useState("");
const [isConfidential, setIsConfidential] = useState(false); const [isConfidential, setIsConfidential] = useState(false);
const [showTrialData, setShowTrialData] = useState(false); const [showTrialData, setShowTrialData] = useState(false);
const [showLedgerTools, setShowLedgerTools] = useState(false); const [showCreatePanel, setShowCreatePanel] = useState(false);
const listQuery = useQuery({ const listQuery = useQuery({
queryKey: ["projects", "list", status, mine, q], queryKey: ["projects", "list", status, mine, q],
@@ -181,79 +181,76 @@ export function ProjectsPage() {
actions={ actions={
<div className="flex gap-2"> <div className="flex gap-2">
<Button asChild variant="secondary" type="button"> <Button asChild variant="secondary" type="button">
<Link to="/contracts-registry">合同台账</Link> <Link to="/contracts-registry">合同</Link>
</Button> </Button>
<Button type="button" variant={showLedgerTools ? "default" : "secondary"} onClick={() => setShowLedgerTools((value) => !value)}> <Button type="button" variant={showTrialData ? "default" : "secondary"} onClick={() => setShowTrialData((value) => !value)}>
台账工具 {showTrialData ? "隐藏测试数据" : "显示测试数据"}
</Button> </Button>
{canManageProjects ? (
<Button type="button" variant={showCreatePanel ? "default" : "secondary"} onClick={() => setShowCreatePanel((value) => !value)}>
新建项目
</Button>
) : null}
</div> </div>
} }
/> />
{showLedgerTools ? ( {showCreatePanel ? (
<SectionPanel title="台账工具"> <SectionPanel title="新建项目">
<div className="space-y-4"> <div className="space-y-4">
{!canManageProjects ? ( <div className="grid gap-3 md:grid-cols-2">
<div className="text-sm text-zinc-500">你当前只有查看权限,项目创建入口仅对老板 / 管理员 / 项目管理角色开放。</div> <div className="space-y-2">
) : ( <Label>项目名称</Label>
<> <Input value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:深圳某客户现场实施" />
<div className="grid gap-3 md:grid-cols-2"> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>项目名称</Label> <Label>客户名称</Label>
<Input value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:深圳某客户现场实施" /> <Input value={clientName} onChange={(event) => setClientName(event.target.value)} placeholder="客户全称" />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>客户名称</Label> <Label>客户简称</Label>
<Input value={clientName} onChange={(event) => setClientName(event.target.value)} placeholder="客户全称" /> <Input value={clientAlias} onChange={(event) => setClientAlias(event.target.value)} placeholder="可选" />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>客户简称</Label> <Label>项目经理</Label>
<Input value={clientAlias} onChange={(event) => setClientAlias(event.target.value)} placeholder="可选" /> <select className="select-std" value={managerEmployeeId} onChange={(event) => setManagerEmployeeId(event.target.value)}>
</div> <option value="">未指定</option>
<div className="space-y-2"> {managerCandidates.map((item) => (
<Label>项目经理</Label> <option key={item.id} value={item.id}>
<select className="select-std" value={managerEmployeeId} onChange={(event) => setManagerEmployeeId(event.target.value)}> {item.name} · {item.employeeNo}
<option value="">未指定</option> </option>
{managerCandidates.map((item) => ( ))}
<option key={item.id} value={item.id}> </select>
{item.name} · {item.employeeNo} </div>
</option> </div>
))} <div className="grid gap-3 md:grid-cols-2">
</select> <div className="space-y-2">
</div> <Label>开始日期</Label>
</div> <DateTextInput value={startsAt} onChange={(event) => setStartsAt(event.target.value)} />
<div className="grid gap-3 md:grid-cols-2"> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>开始日期</Label> <Label>目标完成</Label>
<DateTextInput value={startsAt} onChange={(event) => setStartsAt(event.target.value)} /> <DateTextInput value={targetEndAt} onChange={(event) => setTargetEndAt(event.target.value)} />
</div> </div>
<div className="space-y-2"> </div>
<Label>目标完成</Label> <div className="space-y-2">
<DateTextInput value={targetEndAt} onChange={(event) => setTargetEndAt(event.target.value)} /> <Label>项目摘要</Label>
</div> <Textarea value={summary} onChange={(event) => setSummary(event.target.value)} placeholder="填项目目标、交付范围、关键约束" />
</div> </div>
<div className="space-y-2"> <label className="flex items-center gap-2 text-sm text-zinc-700">
<Label>项目摘要</Label> <input type="checkbox" checked={isConfidential} onChange={(event) => setIsConfidential(event.target.checked)} />
<Textarea value={summary} onChange={(event) => setSummary(event.target.value)} placeholder="填项目目标、交付范围、关键约束" /> 项目含涉密内容(正文与附件继续离线保存)
</div> </label>
<label className="flex items-center gap-2 text-sm text-zinc-700">
<input type="checkbox" checked={isConfidential} onChange={(event) => setIsConfidential(event.target.checked)} />
项目含涉密内容(正文与附件继续离线保存)
</label>
</>
)}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{canManageProjects ? ( <Button
<Button type="button"
type="button" disabled={createMutation.isPending || !name.trim() || !clientName.trim()}
disabled={createMutation.isPending || !name.trim() || !clientName.trim()} onClick={() => createMutation.mutate()}
onClick={() => createMutation.mutate()} >
> {createMutation.isPending ? "创建中..." : "创建项目"}
{createMutation.isPending ? "创建中..." : "创建项目"} </Button>
</Button> <Button type="button" variant="secondary" onClick={() => setShowCreatePanel(false)}>
) : null} 取消
<Button type="button" variant={showTrialData ? "default" : "secondary"} onClick={() => setShowTrialData((value) => !value)}>
{showTrialData ? "隐藏测试数据" : "显示测试数据"}
</Button> </Button>
</div> </div>
</div> </div>
+11 -13
查看文件
@@ -1,7 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useSearchParams } from "react-router-dom"; import { Link, useSearchParams } from "react-router-dom";
import { Wrench } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { isApiError } from "../api/types"; import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages"; import { mapApiErrorMessage } from "../api/errorMessages";
@@ -281,7 +280,7 @@ export function PurchaseRequestsPage() {
const [supplierFilter, setSupplierFilter] = useState(""); const [supplierFilter, setSupplierFilter] = useState("");
const [legalEntityFilter, setLegalEntityFilter] = useState(""); const [legalEntityFilter, setLegalEntityFilter] = useState("");
const [costCenterFilter, setCostCenterFilter] = useState(""); const [costCenterFilter, setCostCenterFilter] = useState("");
const [showLedgerTools, setShowLedgerTools] = useState(false); const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [selectedId, setSelectedId] = useState(searchParams.get("id") ?? ""); const [selectedId, setSelectedId] = useState(searchParams.get("id") ?? "");
const [hydratedDetailId, setHydratedDetailId] = useState(""); const [hydratedDetailId, setHydratedDetailId] = useState("");
@@ -457,12 +456,12 @@ export function PurchaseRequestsPage() {
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
const purchaseItems = listQuery.data?.items ?? []; const purchaseItems = listQuery.data?.items ?? [];
const hasAdvancedFilters = Boolean(supplierFilter || legalEntityFilter || costCenterFilter || (canViewAll && !mine)); const hasAdvancedFilters = Boolean(supplierFilter || legalEntityFilter || costCenterFilter || (canViewAll && !mine));
const canShowLedgerTools = const canShowAdvancedFilters =
canViewAll || canViewAll ||
Boolean((suppliersQuery.data?.items ?? []).length) || Boolean((suppliersQuery.data?.items ?? []).length) ||
Boolean((legalEntitiesQuery.data?.items ?? []).length) || Boolean((legalEntitiesQuery.data?.items ?? []).length) ||
Boolean((costCentersQuery.data?.items ?? []).length); Boolean((costCentersQuery.data?.items ?? []).length);
const showAdvancedTools = canShowLedgerTools && (showLedgerTools || hasAdvancedFilters); const showAdvancedFilterPanel = canShowAdvancedFilters && (showAdvancedFilters || hasAdvancedFilters);
const emptyState = resolvePurchaseEmptyStateCopy(status); const emptyState = resolvePurchaseEmptyStateCopy(status);
const totalAmountPreview = useMemo( const totalAmountPreview = useMemo(
() => items.reduce((sum, item) => sum + Number(moneyPreview(item.quantity, item.unitPrice)), 0).toFixed(2), () => items.reduce((sum, item) => sum + Number(moneyPreview(item.quantity, item.unitPrice)), 0).toFixed(2),
@@ -803,7 +802,7 @@ export function PurchaseRequestsPage() {
<ListWorkspace> <ListWorkspace>
{downloadReasonDialog} {downloadReasonDialog}
<WorkspaceHeader <WorkspaceHeader
title="采购申请" title="采购"
actions={ actions={
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{isPurchaseDetailMode ? ( {isPurchaseDetailMode ? (
@@ -822,15 +821,14 @@ export function PurchaseRequestsPage() {
{createMutation.isPending ? "创建中..." : "新建采购"} {createMutation.isPending ? "创建中..." : "新建采购"}
</Button> </Button>
) : null} ) : null}
{canShowLedgerTools ? ( {canShowAdvancedFilters ? (
<Button <Button
data-testid="purchase-ledger-tools" data-testid="purchase-advanced-filters"
type="button" type="button"
variant={showAdvancedTools ? "default" : "secondary"} variant={showAdvancedFilterPanel ? "default" : "secondary"}
onClick={() => setShowLedgerTools((current) => !current)} onClick={() => setShowAdvancedFilters((current) => !current)}
> >
<Wrench aria-hidden className="mr-1 size-4" /> 筛选
台账工具
</Button> </Button>
) : null} ) : null}
</> </>
@@ -853,8 +851,8 @@ export function PurchaseRequestsPage() {
{mine ? "我相关" : "全范围"} {mine ? "我相关" : "全范围"}
</span> </span>
} }
advanced={showAdvancedTools ? ( advanced={showAdvancedFilterPanel ? (
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-4" data-testid="purchase-ledger-tools-panel"> <div className="grid gap-3 md:grid-cols-3 xl:grid-cols-4" data-testid="purchase-advanced-filters-panel">
<select <select
className="select-std" className="select-std"
value={supplierFilter} value={supplierFilter}
+2 -2
查看文件
@@ -22,7 +22,7 @@ const MODULE_LABELS: Record<string, string> = {
workspace: "工作区设置", workspace: "工作区设置",
people: "人员管理", people: "人员管理",
organization: "组织架构", organization: "组织架构",
contractsRegistry: "合同台账", contractsRegistry: "合同",
workflowDefinitions: "流程定义", workflowDefinitions: "流程定义",
systemSettings: "管理", systemSettings: "管理",
featureFlags: "功能管理", featureFlags: "功能管理",
@@ -34,7 +34,7 @@ const MODULE_LABELS: Record<string, string> = {
notificationChannels: "通知渠道", notificationChannels: "通知渠道",
workflowTasks: "待我处理", workflowTasks: "待我处理",
dashboard: "仪表盘", dashboard: "仪表盘",
purchaseRequests: "采购申请", purchaseRequests: "采购",
travelRequests: "差旅申请", travelRequests: "差旅申请",
budgets: "预算管理", budgets: "预算管理",
paymentBatches: "付款", paymentBatches: "付款",
+1 -1
查看文件
@@ -83,7 +83,7 @@ test("E2E 资产流转主链路:建卡→领用归还→调拨→盘点", asyn
await login(page, FINANCE_MGR); await login(page, FINANCE_MGR);
await (await openNavLink(page, "资产")).click(); await (await openNavLink(page, "资产")).click();
await expect(page.getByRole("heading", { name: "资产台账" })).toBeVisible({ timeout: 30_000 }); await expect(page.getByRole("heading", { name: "资产" })).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("asset-category-code")).toHaveCount(0); await expect(page.getByTestId("asset-category-code")).toHaveCount(0);
await page.getByRole("button", { name: "资产分类" }).click(); await page.getByRole("button", { name: "资产分类" }).click();
await expect(page.getByTestId("asset-category-manager")).toBeVisible(); await expect(page.getByTestId("asset-category-manager")).toBeVisible();
+1 -1
查看文件
@@ -304,7 +304,7 @@ export async function uploadLiveInvoiceAndWait(page: Page, invoiceFilePath: stri
const url = new URL(response.url()); const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload" && response.ok(); return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload" && response.ok();
}); });
await page.getByRole("button", { name: "台账工具" }).click(); await page.getByRole("button", { name: "上传票据" }).click();
await page.getByTestId("invoice-upload-file").setInputFiles(invoiceFilePath); await page.getByTestId("invoice-upload-file").setInputFiles(invoiceFilePath);
await page.getByTestId("invoice-upload-submit").click(); await page.getByTestId("invoice-upload-submit").click();
const uploadPayload = (await (await uploadResponsePromise).json()) as UploadInvoiceResponse; const uploadPayload = (await (await uploadResponsePromise).json()) as UploadInvoiceResponse;
+2 -2
查看文件
@@ -53,7 +53,7 @@ test("E2E 轻交互 OCR:报销详情上传后自动添加并提交", async ({
await logout(page, EMPLOYEE.name); await logout(page, EMPLOYEE.name);
}); });
test("E2E 轻交互 OCR:票据中心缺关键字段后可在当前页修正并标记可用", async ({ page }) => { test("E2E 轻交互 OCR:票据缺关键字段后可在当前页修正并标记可用", async ({ page }) => {
test.skip(process.env.PLAYWRIGHT_OCR_MOCK !== "1", "需要 mock OCR 环境和 OCR 作业开启"); test.skip(process.env.PLAYWRIGHT_OCR_MOCK !== "1", "需要 mock OCR 环境和 OCR 作业开启");
await login(page, EMPLOYEE); await login(page, EMPLOYEE);
@@ -61,7 +61,7 @@ test("E2E 轻交互 OCR:票据中心缺关键字段后可在当前页修正并
await (await openNavLink(page, "票据")).click(); await (await openNavLink(page, "票据")).click();
await expect(page.getByTestId("invoice-pool-table")).toBeVisible(); await expect(page.getByTestId("invoice-pool-table")).toBeVisible();
await page.getByRole("button", { name: "台账工具" }).click(); await page.getByRole("button", { name: "上传票据" }).click();
await page.getByTestId("invoice-upload-file").setInputFiles({ await page.getByTestId("invoice-upload-file").setInputFiles({
name: `r73-missing-${Date.now()}.pdf`, name: `r73-missing-${Date.now()}.pdf`,
mimeType: "application/pdf", mimeType: "application/pdf",
+3 -3
查看文件
@@ -191,8 +191,8 @@ function mockNavigation(page: import("@playwright/test").Page, mode: "employee"
{ key: "portal", href: "/portal", label: "工作台", icon: "home_app_logo", placement: "main", permissionCodes: [], featureFlagKey: null }, { key: "portal", href: "/portal", label: "工作台", icon: "home_app_logo", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "workflow", href: "/workflow/tasks", label: "待我处理", icon: "fact_check", placement: "main", permissionCodes: [], featureFlagKey: null }, { key: "workflow", href: "/workflow/tasks", label: "待我处理", icon: "fact_check", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "expense", href: "/reimbursements", label: "报销", icon: "request_quote", placement: "main", permissionCodes: [], featureFlagKey: null }, { key: "expense", href: "/reimbursements", label: "报销", icon: "request_quote", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "purchase", href: "/purchase-requests", label: "采购申请", icon: "shopping_cart", placement: "main", permissionCodes: [], featureFlagKey: null }, { key: "purchase", href: "/purchase-requests", label: "采购", icon: "shopping_cart", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "contracts", href: "/contracts-registry", label: "合同台账", icon: "description", placement: "main", permissionCodes: [], featureFlagKey: null }, { key: "contracts", href: "/contracts-registry", label: "合同", icon: "description", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "projects", href: "/projects", label: "项目", icon: "workspaces", placement: "main", permissionCodes: [], featureFlagKey: null }, { key: "projects", href: "/projects", label: "项目", icon: "workspaces", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "assets", href: "/assets", label: "资产", icon: "inventory_2", placement: "main", permissionCodes: [], featureFlagKey: null }, { key: "assets", href: "/assets", label: "资产", icon: "inventory_2", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "team", href: "/team", label: "团队", icon: "groups", placement: "main", permissionCodes: [], featureFlagKey: null }, { key: "team", href: "/team", label: "团队", icon: "groups", placement: "main", permissionCodes: [], featureFlagKey: null },
@@ -211,7 +211,7 @@ function mockNavigation(page: import("@playwright/test").Page, mode: "employee"
main: [ main: [
{ key: "portal", href: "/portal", label: "工作台", icon: "home_app_logo", placement: "main", permissionCodes: [], featureFlagKey: null }, { key: "portal", href: "/portal", label: "工作台", icon: "home_app_logo", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "expense", href: "/reimbursements", label: "报销", icon: "request_quote", placement: "main", permissionCodes: [], featureFlagKey: null }, { key: "expense", href: "/reimbursements", label: "报销", icon: "request_quote", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "purchase", href: "/purchase-requests", label: "采购申请", icon: "shopping_cart", placement: "main", permissionCodes: [], featureFlagKey: null }, { key: "purchase", href: "/purchase-requests", label: "采购", icon: "shopping_cart", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "projects", href: "/projects", label: "项目", icon: "workspaces", placement: "main", permissionCodes: [], featureFlagKey: null }, { key: "projects", href: "/projects", label: "项目", icon: "workspaces", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "assets", href: "/assets", label: "资产", icon: "inventory_2", placement: "main", permissionCodes: [], featureFlagKey: null }, { key: "assets", href: "/assets", label: "资产", icon: "inventory_2", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "leave", href: "/leave-requests", label: "请假记录", icon: "event_available", placement: "main", permissionCodes: [], featureFlagKey: null }, { key: "leave", href: "/leave-requests", label: "请假记录", icon: "event_available", placement: "main", permissionCodes: [], featureFlagKey: null },
+3 -3
查看文件
@@ -40,7 +40,7 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
const url = new URL(response.url()); const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload" && response.ok(); return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload" && response.ok();
}); });
await page.getByRole("button", { name: "台账工具" }).click(); await page.getByRole("button", { name: "上传票据" }).click();
await page.getByTestId("invoice-upload-file").setInputFiles({ await page.getByTestId("invoice-upload-file").setInputFiles({
name: invoiceFileName, name: invoiceFileName,
mimeType: "application/pdf", mimeType: "application/pdf",
@@ -66,7 +66,7 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
// 3) bind invoice to reimbursement // 3) bind invoice to reimbursement
await page.goto("/invoice-pool"); await page.goto("/invoice-pool");
await expect(page.getByTestId("invoice-pool-table")).toBeVisible(); await expect(page.getByTestId("invoice-pool-table")).toBeVisible();
await page.getByRole("button", { name: "台账工具" }).click(); await page.getByRole("button", { name: "绑定" }).click();
await page.getByTestId("invoice-bind-invoice").selectOption(invoiceId); await page.getByTestId("invoice-bind-invoice").selectOption(invoiceId);
await page.getByTestId("invoice-bind-reimbursement").selectOption(reimbursementId); await page.getByTestId("invoice-bind-reimbursement").selectOption(reimbursementId);
await page.getByTestId("invoice-bind-submit").click(); await page.getByTestId("invoice-bind-submit").click();
@@ -111,7 +111,7 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
const batchName = `E2E-报销付款批次-${Date.now()}`; const batchName = `E2E-报销付款批次-${Date.now()}`;
await page.goto("/payment-batches"); await page.goto("/payment-batches");
await expect(page.getByTestId("payment-batch-create-panel")).toHaveCount(0); await expect(page.getByTestId("payment-batch-create-panel")).toHaveCount(0);
await page.getByRole("button", { name: "台账工具" }).click(); await page.getByRole("button", { name: "新建批次" }).click();
const createCard = page.getByTestId("payment-batch-create-panel"); const createCard = page.getByTestId("payment-batch-create-panel");
await expect(createCard).toBeVisible(); await expect(createCard).toBeVisible();
const createBatchResponsePromise = page.waitForResponse((response) => { const createBatchResponsePromise = page.waitForResponse((response) => {
@@ -153,7 +153,7 @@ async function createAsset(page: Page, categoryName: string, suffix: string, ind
}; };
} }
test("ASSET1D 公网资产台账试录:FA/EA-SYS/EA-REPO/EA-BKP", async ({ page }) => { test("ASSET1D 公网资产试录:FA/EA-SYS/EA-REPO/EA-BKP", async ({ page }) => {
test.skip(process.env.ASSET1D_PUBLIC_TRIAL !== "1", "ASSET1D public trial requires explicit ASSET1D_PUBLIC_TRIAL=1"); test.skip(process.env.ASSET1D_PUBLIC_TRIAL !== "1", "ASSET1D public trial requires explicit ASSET1D_PUBLIC_TRIAL=1");
const admin = requireAdminUser(); const admin = requireAdminUser();
const suffix = String(Date.now()).slice(-8); const suffix = String(Date.now()).slice(-8);
@@ -997,7 +997,7 @@ test(`${trialrunIssueId} 浏览器业务先行试运行:报销、审批、请
const employee = await loginRole(page, "employee"); const employee = await loginRole(page, "employee");
try { try {
await page.setViewportSize({ width: 1440, height: 960 }); await page.setViewportSize({ width: 1440, height: 960 });
await (await openNavLink(page, "采购申请")).click(); await (await openNavLink(page, "采购")).click();
const createResponsePromise = page.waitForResponse( const createResponsePromise = page.waitForResponse(
(response) => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/purchase-requests", (response) => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/purchase-requests",
); );
@@ -1168,7 +1168,7 @@ test(`${trialrunIssueId} 浏览器业务先行试运行:报销、审批、请
detail: "报销审批已完成;/payment-batches 返回当前功能未启用,未创建付款批次或执行付款。", detail: "报销审批已完成;/payment-batches 返回当前功能未启用,未创建付款批次或执行付款。",
}; };
} }
await page.getByRole("button", { name: "台账工具" }).click(); await page.getByRole("button", { name: "新建批次" }).click();
const createCard = page.getByTestId("payment-batch-create-panel"); const createCard = page.getByTestId("payment-batch-create-panel");
await expect(createCard.getByRole("button", { name: "创建批次" })).toBeEnabled(); await expect(createCard.getByRole("button", { name: "创建批次" })).toBeEnabled();
const createBatchResponsePromise = page.waitForResponse((response) => { const createBatchResponsePromise = page.waitForResponse((response) => {
@@ -80,18 +80,18 @@ const ROLE_ENVS: Record<RoleKey, { identifier: string; password: string }> = {
const ROUTE_PLANS: RoutePlan[] = [ const ROUTE_PLANS: RoutePlan[] = [
{ role: "employee", route: "/portal", label: "员工工作台", layer: "A", expectation: "accessible" }, { role: "employee", route: "/portal", label: "员工工作台", layer: "A", expectation: "accessible" },
{ role: "employee", route: "/reimbursements", label: "员工报销列表", layer: "A", expectation: "accessible" }, { role: "employee", route: "/reimbursements", label: "员工报销列表", layer: "A", expectation: "accessible" },
{ role: "employee", route: "/invoice-pool", label: "员工票据中心", layer: "B", expectation: "accessible" }, { role: "employee", route: "/invoice-pool", label: "员工票据", layer: "B", expectation: "accessible" },
{ role: "employee", route: "/purchase-requests", label: "员工采购申请", layer: "B", expectation: "accessible" }, { role: "employee", route: "/purchase-requests", label: "员工采购", layer: "B", expectation: "accessible" },
{ role: "employee", route: "/assets", label: "员工资产台账", layer: "B", expectation: "accessible" }, { role: "employee", route: "/assets", label: "员工资产", layer: "B", expectation: "accessible" },
{ role: "employee", route: "/payment-batches", label: "员工付款权限负例", layer: "B", expectation: "forbidden_ok" }, { role: "employee", route: "/payment-batches", label: "员工付款权限负例", layer: "B", expectation: "forbidden_ok" },
{ role: "manager", route: "/portal", label: "主管工作台", layer: "A", expectation: "accessible" }, { role: "manager", route: "/portal", label: "主管工作台", layer: "A", expectation: "accessible" },
{ role: "manager", route: "/workflow/tasks", label: "主管审批待办", layer: "A", expectation: "accessible" }, { role: "manager", route: "/workflow/tasks", label: "主管审批待办", layer: "A", expectation: "accessible" },
{ role: "manager", route: "/purchase-requests", label: "主管采购申请", layer: "B", expectation: "accessible" }, { role: "manager", route: "/purchase-requests", label: "主管采购", layer: "B", expectation: "accessible" },
{ role: "manager", route: "/projects", label: "主管项目列表", layer: "B", expectation: "optional" }, { role: "manager", route: "/projects", label: "主管项目列表", layer: "B", expectation: "optional" },
{ role: "finance", route: "/portal", label: "财务工作台", layer: "A", expectation: "accessible" }, { role: "finance", route: "/portal", label: "财务工作台", layer: "A", expectation: "accessible" },
{ role: "finance", route: "/invoice-pool", label: "财务票据中心", layer: "B", expectation: "accessible" }, { role: "finance", route: "/invoice-pool", label: "财务票据", layer: "B", expectation: "accessible" },
{ role: "finance", route: "/payment-batches", label: "财务付款批次", layer: "B", expectation: "accessible" }, { role: "finance", route: "/payment-batches", label: "财务付款批次", layer: "B", expectation: "accessible" },
{ role: "finance", route: "/ap-operations", label: "财务应付账款", layer: "B", expectation: "accessible" }, { role: "finance", route: "/ap-operations", label: "财务应付账款", layer: "B", expectation: "accessible" },
{ role: "finance", route: "/reimbursements", label: "财务报销列表", layer: "A", expectation: "accessible" }, { role: "finance", route: "/reimbursements", label: "财务报销列表", layer: "A", expectation: "accessible" },
@@ -103,8 +103,8 @@ const ROUTE_PLANS: RoutePlan[] = [
{ role: "finance_mgr", route: "/audit-logs", label: "财务负责人审计日志", layer: "C", expectation: "accessible" }, { role: "finance_mgr", route: "/audit-logs", label: "财务负责人审计日志", layer: "C", expectation: "accessible" },
{ role: "admin", route: "/portal", label: "管理员工作台", layer: "A", expectation: "accessible" }, { role: "admin", route: "/portal", label: "管理员工作台", layer: "A", expectation: "accessible" },
{ role: "admin", route: "/assets", label: "管理员资产台账", layer: "B", expectation: "accessible" }, { role: "admin", route: "/assets", label: "管理员资产", layer: "B", expectation: "accessible" },
{ role: "admin", route: "/contracts-registry", label: "管理员合同台账", layer: "B", expectation: "accessible" }, { role: "admin", route: "/contracts-registry", label: "管理员合同", layer: "B", expectation: "accessible" },
{ role: "admin", route: "/system/workspace", label: "管理员工作区设置", layer: "C", expectation: "accessible" }, { role: "admin", route: "/system/workspace", label: "管理员工作区设置", layer: "C", expectation: "accessible" },
{ role: "admin", route: "/system/approval-rules", label: "管理员审批规则", layer: "C", expectation: "accessible" }, { role: "admin", route: "/system/approval-rules", label: "管理员审批规则", layer: "C", expectation: "accessible" },
{ role: "admin", route: "/system/feature-flags", label: "管理员功能开关", layer: "C", expectation: "accessible" }, { role: "admin", route: "/system/feature-flags", label: "管理员功能开关", layer: "C", expectation: "accessible" },
@@ -276,7 +276,7 @@ const projectDetail = {
startsAt: "2026-05-20", startsAt: "2026-05-20",
targetEndAt: "2026-06-03", targetEndAt: "2026-06-03",
actualEndAt: null, actualEndAt: null,
summary: "真实试运行阶段,重点验证审批、报销、合同台账和移动入口。", summary: "真实试运行阶段,重点验证审批、报销、合同和移动入口。",
isConfidential: true, isConfidential: true,
createdAt: "2026-05-20T09:00:00.000Z", createdAt: "2026-05-20T09:00:00.000Z",
updatedAt: "2026-05-27T09:30:00.000Z", updatedAt: "2026-05-27T09:30:00.000Z",
@@ -324,7 +324,7 @@ const projectDetail = {
{ {
id: "project-task-wave3", id: "project-task-wave3",
title: "整理试运行反馈", title: "整理试运行反馈",
description: "跟进移动端和合同台账反馈", description: "跟进移动端和合同反馈",
status: "doing", status: "doing",
priority: "high", priority: "high",
assigneeEmployeeId: visualMe.id, assigneeEmployeeId: visualMe.id,
@@ -1087,7 +1087,7 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
await expect(page.getByRole("heading", { name: "项目", exact: true })).toBeVisible(); await expect(page.getByRole("heading", { name: "项目", exact: true })).toBeVisible();
await expect(page.getByPlaceholder("搜索项目编号、项目名、客户名")).toBeVisible(); await expect(page.getByPlaceholder("搜索项目编号、项目名、客户名")).toBeVisible();
await expect(page.getByRole("button", { name: "创建项目" })).toHaveCount(0); await expect(page.getByRole("button", { name: "创建项目" })).toHaveCount(0);
await page.getByRole("button", { name: "台账工具" }).click(); await page.getByRole("button", { name: "新建项目" }).click();
await expect(page.getByRole("button", { name: "创建项目" })).toBeVisible(); await expect(page.getByRole("button", { name: "创建项目" })).toBeVisible();
await page.goto("/projects/project-wave3"); await page.goto("/projects/project-wave3");
@@ -1098,12 +1098,11 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
await page.screenshot({ path: screenshotPath("03-pc-project-detail.png"), fullPage: true }); await page.screenshot({ path: screenshotPath("03-pc-project-detail.png"), fullPage: true });
await page.goto("/contracts-registry?projectId=project-wave3"); await page.goto("/contracts-registry?projectId=project-wave3");
await expect(page.getByRole("heading", { name: "合同台账", exact: true })).toBeVisible(); await expect(page.getByRole("heading", { name: "合同", exact: true })).toBeVisible();
await expect(page.getByTestId("contracts-registry-table").getByTestId("contracts-registry-row")).toBeVisible(); await expect(page.getByTestId("contracts-registry-table").getByTestId("contracts-registry-row")).toBeVisible();
await expect(page.getByRole("button", { name: "导出 CSV" })).toHaveCount(0); await expect(page.getByRole("button", { name: "导出" })).toBeVisible();
await page.getByRole("button", { name: "台账工具" }).click(); await page.getByRole("button", { name: "筛选" }).click();
await expect(page.getByTestId("contracts-ledger-tools")).toBeVisible(); await expect(page.getByTestId("contracts-advanced-filters")).toBeVisible();
await expect(page.getByRole("button", { name: "导出 CSV" })).toBeVisible();
await page.getByRole("button", { name: "新增合同" }).click(); await page.getByRole("button", { name: "新增合同" }).click();
await expect(page.getByTestId("contracts-editor-panel")).toBeVisible(); await expect(page.getByTestId("contracts-editor-panel")).toBeVisible();
await page.getByRole("button", { name: "关闭" }).click(); await page.getByRole("button", { name: "关闭" }).click();
@@ -1034,32 +1034,31 @@ test("PC 财务与管理页 Wave4-5 视觉冒烟", async ({ page }) => {
} }
}); });
test("PC 财务台账工具默认收起并可按需打开", async ({ page }) => { test("PC 财务列表使用明确动作入口", async ({ page }) => {
await mockWave4Apis(page); await mockWave4Apis(page);
await page.setViewportSize({ width: 1440, height: 960 }); await page.setViewportSize({ width: 1440, height: 960 });
await login(page); await login(page);
await page.goto("/payment-batches"); await page.goto("/payment-batches");
await expect(page.getByTestId("payment-batch-create-panel")).toHaveCount(0); await expect(page.getByTestId("payment-batch-create-panel")).toHaveCount(0);
await page.getByRole("button", { name: "台账工具" }).click(); await page.getByRole("button", { name: "新建批次" }).click();
await expect(page.getByTestId("payment-batch-create-panel")).toBeVisible(); await expect(page.getByTestId("payment-batch-create-panel")).toBeVisible();
await expect(page.getByTestId("payment-batch-create-panel")).toContainText("新建批次"); await expect(page.getByTestId("payment-batch-create-panel")).toContainText("新建批次");
await page.goto("/ap-operations"); await page.goto("/ap-operations");
await expect(page.getByRole("button", { name: "导出 CSV" })).toHaveCount(0); await expect(page.getByTestId("ap-export")).toBeVisible();
await page.getByRole("button", { name: "台账工具" }).click(); await page.getByTestId("ap-export").click();
await expect(page.getByTestId("ap-ledger-tools")).toBeVisible(); await expect(page.getByTestId("ap-export-csv")).toBeVisible();
await expect(page.getByRole("button", { name: "导出 CSV" })).toBeVisible();
await page.goto("/invoice-pool"); await page.goto("/invoice-pool");
await expect(page.getByTestId("invoice-export")).toHaveCount(0); await expect(page.getByTestId("invoice-actions-panel")).toHaveCount(0);
await page.getByRole("button", { name: "台账工具" }).click(); await page.getByRole("button", { name: "绑定" }).click();
await expect(page.getByTestId("invoice-ledger-tools")).toBeVisible(); await expect(page.getByTestId("invoice-actions-panel")).toBeVisible();
await page.getByTestId("invoice-export").click(); await page.getByTestId("invoice-export").click();
await expect(page.getByTestId("invoice-export-csv")).toBeVisible(); await expect(page.getByTestId("invoice-export-csv")).toBeVisible();
}); });
test("PC 票据中心辅助核验动作可提交并重试", async ({ page }) => { test("PC 票据辅助核验动作可提交并重试", async ({ page }) => {
const failedVerification: MockVerificationRecord = { const failedVerification: MockVerificationRecord = {
...(invoiceListItem.verification as MockVerificationRecord), ...(invoiceListItem.verification as MockVerificationRecord),
id: "verify-wave4-failed", id: "verify-wave4-failed",
@@ -1097,7 +1096,7 @@ test("PC 票据中心辅助核验动作可提交并重试", async ({ page }) =>
expect(triggerRequests).toEqual(["POST"]); expect(triggerRequests).toEqual(["POST"]);
}); });
test("PC 票据中心辅助核验错误提示可理解", async ({ page }) => { test("PC 票据辅助核验错误提示可理解", async ({ page }) => {
const failedVerification: MockVerificationRecord = { const failedVerification: MockVerificationRecord = {
...(invoiceListItem.verification as MockVerificationRecord), ...(invoiceListItem.verification as MockVerificationRecord),
id: "verify-wave4-attempts", id: "verify-wave4-attempts",
@@ -522,7 +522,7 @@ test.describe("R3 权限直连审计", () => {
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/audit-logs", statuses: [403], note: "员工不得查看审计日志" }); await expectApiStatus({ role: "employee", api: employee.api, path: "/api/audit-logs", statuses: [403], note: "员工不得查看审计日志" });
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/notifications/deliveries", statuses: [403], note: "员工不得查看通知投递审计" }); await expectApiStatus({ role: "employee", api: employee.api, path: "/api/notifications/deliveries", statuses: [403], note: "员工不得查看通知投递审计" });
await expectApiMutationStatus({ role: "employee", api: employee.api, path: "/api/exports/ap-operations", method: "post", data: { format: "csv" }, statuses: [403, 409], note: "员工不得导出应付账款数据;409 表示功能门禁关闭且未泄露数据" }); await expectApiMutationStatus({ role: "employee", api: employee.api, path: "/api/exports/ap-operations", method: "post", data: { format: "csv" }, statuses: [403, 409], note: "员工不得导出应付账款数据;409 表示功能门禁关闭且未泄露数据" });
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/contracts-registry", statuses: [200, 409], note: "员工合同台账只能走范围过滤或功能门禁" }); await expectApiStatus({ role: "employee", api: employee.api, path: "/api/contracts-registry", statuses: [200, 409], note: "员工合同只能走范围过滤或功能门禁" });
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/travel-requests?mine=0&pageSize=50", statuses: [403], note: "差旅冻结期员工不得直连差旅列表" }); await expectApiStatus({ role: "employee", api: employee.api, path: "/api/travel-requests?mine=0&pageSize=50", statuses: [403], note: "差旅冻结期员工不得直连差旅列表" });
const employeeInvoices = await expectApiStatus({ const employeeInvoices = await expectApiStatus({
+2 -2
查看文件
@@ -32,7 +32,7 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
await page.setViewportSize({ width: 1440, height: 960 }); await page.setViewportSize({ width: 1440, height: 960 });
await login(page, EMPLOYEE); await login(page, EMPLOYEE);
await (await openNavLink(page, "采购申请")).click(); await (await openNavLink(page, "采购")).click();
const createResponsePromise = page.waitForResponse( const createResponsePromise = page.waitForResponse(
(response) => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/purchase-requests", (response) => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/purchase-requests",
); );
@@ -101,7 +101,7 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
await logout(page, MANAGER.name); await logout(page, MANAGER.name);
await login(page, FINANCE_MGR); await login(page, FINANCE_MGR);
await (await openNavLink(page, "采购申请")).click(); await (await openNavLink(page, "采购")).click();
await page.getByRole("button", { name: "全范围" }).click(); await page.getByRole("button", { name: "全范围" }).click();
await page.getByRole("combobox").first().selectOption("approved"); await page.getByRole("combobox").first().selectOption("approved");
const purchaseRow = page.getByRole("row").filter({ hasText: requestNo }).first(); const purchaseRow = page.getByRole("row").filter({ hasText: requestNo }).first();
@@ -321,7 +321,6 @@ test.describe("R102 reimbursement main flow", () => {
await page.goto("/reimbursements?status=draft"); await page.goto("/reimbursements?status=draft");
await expect(page.getByTestId("reimbursements-tab-draft")).toBeVisible(); await expect(page.getByTestId("reimbursements-tab-draft")).toBeVisible();
await expect(page.getByRole("button", { name: "台账工具" })).toHaveCount(0);
await expect(page.getByTestId("reimbursement-row").getByText(formNumber)).toBeVisible(); await expect(page.getByTestId("reimbursement-row").getByText(formNumber)).toBeVisible();
await expect(page.getByText(reimbursementId)).toHaveCount(0); await expect(page.getByText(reimbursementId)).toHaveCount(0);
@@ -223,7 +223,7 @@ async function mockApis(page: Page) {
sections: { sections: {
main: [ main: [
navigationItem("portal", "/portal", "工作台", "home_app_logo"), navigationItem("portal", "/portal", "工作台", "home_app_logo"),
navigationItem("purchase", "/purchase-requests", "采购申请", "shopping_cart"), navigationItem("purchase", "/purchase-requests", "采购", "shopping_cart"),
navigationItem("invoice", "/invoice-pool", "票据", "receipt_long"), navigationItem("invoice", "/invoice-pool", "票据", "receipt_long"),
], ],
admin: [], admin: [],
@@ -346,10 +346,10 @@ test.describe("R103 cross page UI consistency", () => {
await page.setViewportSize({ width: 1440, height: 960 }); await page.setViewportSize({ width: 1440, height: 960 });
await page.goto("/purchase-requests"); await page.goto("/purchase-requests");
await expect(page.locator("header").filter({ hasText: "业务" }).getByText("采购申请")).toBeVisible(); await expect(page.locator("header").filter({ hasText: "业务" }).getByText("采购")).toBeVisible();
await expect(page.getByRole("heading", { name: "采购申请" })).toBeVisible(); await expect(page.getByRole("heading", { name: "采购" })).toBeVisible();
await expect(page.getByTestId("purchase-requests-table")).toBeVisible(); await expect(page.getByTestId("purchase-requests-table")).toBeVisible();
await expect(page.getByTestId("purchase-ledger-tools")).toBeVisible(); await expect(page.getByTestId("purchase-advanced-filters")).toBeVisible();
await expect(page.getByTestId("purchase-attachment-upload")).toHaveCount(0); await expect(page.getByTestId("purchase-attachment-upload")).toHaveCount(0);
await expect(page.getByTestId("purchase-row-next-step")).toHaveCount(0); await expect(page.getByTestId("purchase-row-next-step")).toHaveCount(0);
await expect(page.getByText("付款处理")).toHaveCount(0); await expect(page.getByText("付款处理")).toHaveCount(0);
@@ -357,7 +357,7 @@ test.describe("R103 cross page UI consistency", () => {
await page.goto(`/purchase-requests?id=${purchaseId}`); await page.goto(`/purchase-requests?id=${purchaseId}`);
await expect(page.getByTestId("purchase-requests-table")).toHaveCount(0); await expect(page.getByTestId("purchase-requests-table")).toHaveCount(0);
await expect(page.getByTestId("purchase-ledger-tools")).toHaveCount(0); await expect(page.getByTestId("purchase-advanced-filters")).toHaveCount(0);
await expect(page.getByTestId("purchase-attachment-upload")).toHaveText("上传附件"); await expect(page.getByTestId("purchase-attachment-upload")).toHaveText("上传附件");
await expect(page.getByText("Choose File")).toHaveCount(0); await expect(page.getByText("Choose File")).toHaveCount(0);
@@ -370,13 +370,13 @@ test.describe("R103 cross page UI consistency", () => {
await page.screenshot({ path: screenshotPath("purchase-desktop.png"), fullPage: true }); await page.screenshot({ path: screenshotPath("purchase-desktop.png"), fullPage: true });
}); });
test("票据中心绑定报销单不展示 UUID 兜底", async ({ page }) => { test("票据绑定报销单不展示 UUID 兜底", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 }); await page.setViewportSize({ width: 1440, height: 960 });
await page.goto("/invoice-pool"); await page.goto("/invoice-pool");
await expect(page.getByRole("heading", { name: "票据中心" })).toBeVisible(); await expect(page.getByRole("heading", { name: "票据" })).toBeVisible();
await expect(page.getByTestId("invoice-detail-panel")).toHaveCount(0); await expect(page.getByTestId("invoice-detail-panel")).toHaveCount(0);
await page.getByRole("button", { name: "台账工具" }).click(); await page.getByRole("button", { name: "绑定" }).click();
await expect(page.getByTestId("invoice-bind-reimbursement")).toContainText("草稿单 · 2026-05-06"); await expect(page.getByTestId("invoice-bind-reimbursement")).toContainText("草稿单 · 2026-05-06");
await expect(page.getByTestId("invoice-bind-reimbursement")).not.toContainText(reimbursementId); await expect(page.getByTestId("invoice-bind-reimbursement")).not.toContainText(reimbursementId);
await expect(page.getByText("/invoice-pool")).toHaveCount(0); await expect(page.getByText("/invoice-pool")).toHaveCount(0);
@@ -387,7 +387,7 @@ test.describe("R103 cross page UI consistency", () => {
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/invoice-pool"); await page.goto("/invoice-pool");
await expect(page.getByRole("heading", { name: "票据中心" })).toBeVisible(); await expect(page.getByRole("heading", { name: "票据" })).toBeVisible();
await expect(page.getByTestId("invoice-pool-mobile-list")).toBeVisible(); await expect(page.getByTestId("invoice-pool-mobile-list")).toBeVisible();
await expect(page.getByText("Choose File")).toHaveCount(0); await expect(page.getByText("Choose File")).toHaveCount(0);
await page.screenshot({ path: screenshotPath("invoice-pool-mobile.png"), fullPage: true }); await page.screenshot({ path: screenshotPath("invoice-pool-mobile.png"), fullPage: true });
@@ -98,7 +98,7 @@ test.describe("R104 public visual acceptance", () => {
await page.screenshot({ path: screenshotPath("desktop-reimbursements.png"), fullPage: true }); await page.screenshot({ path: screenshotPath("desktop-reimbursements.png"), fullPage: true });
await page.goto("/invoice-pool"); await page.goto("/invoice-pool");
await expect(page.getByRole("heading", { name: "票据中心", exact: true })).toBeVisible(); await expect(page.getByRole("heading", { name: "票据", exact: true })).toBeVisible();
await expectNoDesignRegressionCopy(page); await expectNoDesignRegressionCopy(page);
await page.screenshot({ path: screenshotPath("desktop-invoice-pool.png"), fullPage: true }); await page.screenshot({ path: screenshotPath("desktop-invoice-pool.png"), fullPage: true });
@@ -115,7 +115,7 @@ test.describe("R105 pretrial visual acceptance", () => {
await page.screenshot({ path: screenshotPath("admin-mobile-approvals.png"), fullPage: true }); await page.screenshot({ path: screenshotPath("admin-mobile-approvals.png"), fullPage: true });
}); });
test("PC Portal、报销和票据中心保持试运行首页一致性", async ({ page }) => { test("PC Portal、报销和票据保持试运行首页一致性", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 }); await page.setViewportSize({ width: 1440, height: 960 });
await loginLiveUser(page, "employee"); await loginLiveUser(page, "employee");
@@ -130,7 +130,7 @@ test.describe("R105 pretrial visual acceptance", () => {
await page.screenshot({ path: screenshotPath("desktop-reimbursements.png"), fullPage: true }); await page.screenshot({ path: screenshotPath("desktop-reimbursements.png"), fullPage: true });
await page.goto("/invoice-pool"); await page.goto("/invoice-pool");
await expect(page.getByRole("heading", { name: "票据中心", exact: true })).toBeVisible(); await expect(page.getByRole("heading", { name: "票据", exact: true })).toBeVisible();
await expectNoLegacyDesignCopy(page); await expectNoLegacyDesignCopy(page);
await page.screenshot({ path: screenshotPath("desktop-invoice-pool.png"), fullPage: true }); await page.screenshot({ path: screenshotPath("desktop-invoice-pool.png"), fullPage: true });
}); });
+3 -3
查看文件
@@ -517,18 +517,18 @@ test.describe.serial("R106 自动视觉巡检", () => {
await auditPage(desktopPage, { label: "PC 报销详情", route: `/reimbursements/${seedData.reimbursementId}`, screenshotName: "pc-reimbursement-detail" }); await auditPage(desktopPage, { label: "PC 报销详情", route: `/reimbursements/${seedData.reimbursementId}`, screenshotName: "pc-reimbursement-detail" });
await compareButtons(desktopPage, "PC 报销详情", "reimbursement-item-edit", "明细编辑", "reimbursement-item-delete", "明细删除"); await compareButtons(desktopPage, "PC 报销详情", "reimbursement-item-edit", "明细编辑", "reimbursement-item-delete", "明细删除");
await compareButtons(desktopPage, "PC 报销详情", "reimbursement-back", "返回列表", "reimbursement-delete-draft", "删除草稿"); await compareButtons(desktopPage, "PC 报销详情", "reimbursement-back", "返回列表", "reimbursement-delete-draft", "删除草稿");
const invoiceMetrics = await auditPage(desktopPage, { label: "PC 票据中心", route: "/invoice-pool", screenshotName: "pc-invoice-pool" }); const invoiceMetrics = await auditPage(desktopPage, { label: "PC 票据", route: "/invoice-pool", screenshotName: "pc-invoice-pool" });
if (/票据\s*ID/.test(invoiceMetrics.text)) { if (/票据\s*ID/.test(invoiceMetrics.text)) {
addFinding({ addFinding({
severity: "P2", severity: "P2",
page: "PC 票据中心", page: "PC 票据",
category: "业务展示", category: "业务展示",
title: "票据详情仍把内部票据 ID 放在主信息区", title: "票据详情仍把内部票据 ID 放在主信息区",
detail: "票据详情存在“票据 ID”字段,应改为票据号码/销售方/金额等业务信息。", detail: "票据详情存在“票据 ID”字段,应改为票据号码/销售方/金额等业务信息。",
evidence: screenshotPath("pc-invoice-pool"), evidence: screenshotPath("pc-invoice-pool"),
}); });
} }
await detectDangerButtonStyle(desktopPage, "PC 票据中心", screenshotPath("pc-invoice-pool")); await detectDangerButtonStyle(desktopPage, "PC 票据", screenshotPath("pc-invoice-pool"));
await auditPage(desktopPage, { label: "PC 请假", route: "/leave", screenshotName: "pc-leave" }); await auditPage(desktopPage, { label: "PC 请假", route: "/leave", screenshotName: "pc-leave" });
await auditPage(desktopPage, { label: "PC 采购", route: "/purchase-requests", screenshotName: "pc-purchase" }); await auditPage(desktopPage, { label: "PC 采购", route: "/purchase-requests", screenshotName: "pc-purchase" });
await auditPage(desktopPage, { label: "PC 差旅", route: "/travel-requests", screenshotName: "pc-travel" }); await auditPage(desktopPage, { label: "PC 差旅", route: "/travel-requests", screenshotName: "pc-travel" });
@@ -313,7 +313,7 @@ test.describe("R92 手机端票据与报销可用性", () => {
await attachScreenshot(page, testInfo, "r97-reimbursements-mobile-draft.png"); await attachScreenshot(page, testInfo, "r97-reimbursements-mobile-draft.png");
}); });
test("票据中心手机视口可批量选择并顺序上传", async ({ page }, testInfo) => { test("票据手机视口可批量选择并顺序上传", async ({ page }, testInfo) => {
const uploadCounter = { value: 0 }; const uploadCounter = { value: 0 };
await mockInvoicePool(page, uploadCounter); await mockInvoicePool(page, uploadCounter);
await loginThenUseMobileViewport(page); await loginThenUseMobileViewport(page);
@@ -328,7 +328,7 @@ test.describe("R92 手机端票据与报销可用性", () => {
await page.goto("/invoice-pool"); await page.goto("/invoice-pool");
await expect(page.getByTestId("invoice-mobile-row")).toBeVisible(); await expect(page.getByTestId("invoice-mobile-row")).toBeVisible();
await page.getByRole("button", { name: "台账工具" }).click(); await page.getByRole("button", { name: "上传票据" }).click();
await page.getByTestId("invoice-upload-file").setInputFiles([ await page.getByTestId("invoice-upload-file").setInputFiles([
{ name: "invoice-a.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-a") }, { name: "invoice-a.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-a") },
{ name: "invoice-b.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-b") }, { name: "invoice-b.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-b") },
@@ -105,7 +105,7 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
await expect(page).toHaveURL(/\/payment-batches/); await expect(page).toHaveURL(/\/payment-batches/);
await expect(page.getByTestId("payment-batch-create-panel")).toHaveCount(0); await expect(page.getByTestId("payment-batch-create-panel")).toHaveCount(0);
await page.getByRole("button", { name: "台账工具" }).click(); await page.getByRole("button", { name: "新建批次" }).click();
const createCard = page.getByTestId("payment-batch-create-panel"); const createCard = page.getByTestId("payment-batch-create-panel");
await expect(createCard).toBeVisible(); await expect(createCard).toBeVisible();
const createBatchResponsePromise = page.waitForResponse((response) => { const createBatchResponsePromise = page.waitForResponse((response) => {
+2 -2
查看文件
@@ -59,7 +59,7 @@ test("E2E 差旅支付主链路:申请→审批→支付→绑票", async ({ p
const url = new URL(response.url()); const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload" && response.ok(); return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload" && response.ok();
}); });
await page.getByRole("button", { name: "台账工具" }).click(); await page.getByRole("button", { name: "上传票据" }).click();
await page.getByTestId("invoice-upload-file").setInputFiles({ await page.getByTestId("invoice-upload-file").setInputFiles({
name: invoiceFileName, name: invoiceFileName,
mimeType: "application/pdf", mimeType: "application/pdf",
@@ -79,7 +79,7 @@ test("E2E 差旅支付主链路:申请→审批→支付→绑票", async ({ p
await page.goto("/invoice-pool"); await page.goto("/invoice-pool");
await expect(page.getByTestId("invoice-pool-table")).toBeVisible(); await expect(page.getByTestId("invoice-pool-table")).toBeVisible();
await page.getByRole("button", { name: "台账工具" }).click(); await page.getByRole("button", { name: "绑定" }).click();
const bindCard = page.locator(".card-std").filter({ hasText: "绑定业务单据" }).first(); const bindCard = page.locator(".card-std").filter({ hasText: "绑定业务单据" }).first();
await bindCard.getByRole("combobox").first().selectOption("travelOrder"); await bindCard.getByRole("combobox").first().selectOption("travelOrder");
await page.getByTestId("invoice-bind-invoice").selectOption(invoiceId); await page.getByTestId("invoice-bind-invoice").selectOption(invoiceId);
+4 -5
查看文件
@@ -119,8 +119,8 @@ async function mockVisualApis(page: Page) {
navigationItem("portal", "/portal", "工作台", "home_app_logo"), navigationItem("portal", "/portal", "工作台", "home_app_logo"),
navigationItem("approvals", "/workflow/tasks", "待我处理", "fact_check"), navigationItem("approvals", "/workflow/tasks", "待我处理", "fact_check"),
navigationItem("reimbursements", "/reimbursements", "报销", "request_quote"), navigationItem("reimbursements", "/reimbursements", "报销", "request_quote"),
navigationItem("purchase", "/purchase-requests", "采购申请", "shopping_cart"), navigationItem("purchase", "/purchase-requests", "采购", "shopping_cart"),
navigationItem("contracts", "/contracts-registry", "合同台账", "description"), navigationItem("contracts", "/contracts-registry", "合同", "description"),
navigationItem("projects", "/projects", "项目", "workspaces"), navigationItem("projects", "/projects", "项目", "workspaces"),
navigationItem("assets", "/assets", "资产", "inventory_2"), navigationItem("assets", "/assets", "资产", "inventory_2"),
], ],
@@ -367,8 +367,8 @@ test.describe("visual shell", () => {
await expect(page.locator("aside")).toBeVisible(); await expect(page.locator("aside")).toBeVisible();
await expect(page.getByTestId("topbar-profile-trigger")).toBeVisible(); await expect(page.getByTestId("topbar-profile-trigger")).toBeVisible();
await expect(page.locator("aside").getByRole("link", { name: "工作台", exact: true })).toBeVisible(); await expect(page.locator("aside").getByRole("link", { name: "工作台", exact: true })).toBeVisible();
await expect(page.locator("aside").getByRole("link", { name: "采购申请" })).toBeVisible(); await expect(page.locator("aside").getByRole("link", { name: "采购" })).toBeVisible();
await expect(page.locator("aside").getByRole("link", { name: "合同台账" })).toBeVisible(); await expect(page.locator("aside").getByRole("link", { name: "合同" })).toBeVisible();
await expect(page.locator("aside").getByRole("link", { name: "项目" })).toBeVisible(); await expect(page.locator("aside").getByRole("link", { name: "项目" })).toBeVisible();
await expect(page.locator("aside").getByRole("link", { name: "资产" })).toBeVisible(); await expect(page.locator("aside").getByRole("link", { name: "资产" })).toBeVisible();
await expect(page.locator("summary").filter({ hasText: "更多" })).toBeVisible(); await expect(page.locator("summary").filter({ hasText: "更多" })).toBeVisible();
@@ -390,7 +390,6 @@ test.describe("visual shell", () => {
await page.goto("/reimbursements"); await page.goto("/reimbursements");
await expect(page.getByRole("heading", { name: "我的报销" })).toBeVisible(); await expect(page.getByRole("heading", { name: "我的报销" })).toBeVisible();
await expect(page.getByTestId("reimbursements-create")).toBeVisible(); await expect(page.getByTestId("reimbursements-create")).toBeVisible();
await expect(page.getByRole("button", { name: "台账工具" })).toHaveCount(0);
await expect(page.getByTestId("reimbursements-export")).toBeVisible(); await expect(page.getByTestId("reimbursements-export")).toBeVisible();
await expect(page.getByTestId("reimbursements-status-tabs")).toBeVisible(); await expect(page.getByTestId("reimbursements-status-tabs")).toBeVisible();
await expect(page.getByTestId("reimbursements-table")).toBeVisible(); await expect(page.getByTestId("reimbursements-table")).toBeVisible();
+8 -8
查看文件
@@ -27,14 +27,14 @@
页面分层只用于判断优先级、业务信息量和验证范围,不规定具体视觉样式。 页面分层只用于判断优先级、业务信息量和验证范围,不规定具体视觉样式。
- A 类核心流程:登录/找回密码、首页、审批中心、审批/进度移动页、报销列表、报销详情、移动报销详情、通知中心。 - A 类核心流程:登录/找回密码、首页、审批中心、审批/进度移动页、报销列表、报销详情、移动报销详情、通知中心。
- B 类业务运营:票据池、AP 台账、付款批次、请假、采购、资产、项目、项目详情、合同台账。 - B 类业务运营:票据、应付账款、付款批次、请假、采购、资产、项目、项目详情、合同。
- C 类管理配置:用户、组织、主数据、审批规则、工作流定义、系统设置、Feature Flag、审计日志。 - C 类管理配置:用户、组织、主数据、审批规则、工作流定义、系统设置、Feature Flag、审计日志。
A 类页面优先保证真实试运行路径顺畅;B 类页面优先保证台账、复核和导出不误导;C 类页面允许展示更多审计和排障信息,但不得泄露无权限数据。 A 类页面优先保证真实试运行路径顺畅;B 类页面优先保证台账、复核和导出不误导;C 类页面允许展示更多审计和排障信息,但不得泄露无权限数据。
页面模板固定为六类:`工作台总览`、`任务处理列表`、`业务台账列表`、`主从工作台`、`详情工作区`、`管理配置页`。后续新增或重构页面必须先判定页面分级和模板,再设计业务内容;不允许每个页面临时自创布局。移动端是这些模板的移动表达,不单独开一套 PC 壳层。采购、合同、项目、资产、付款、应付账款和票据的首屏默认都是 `业务台账列表`;`主从工作台` 只用于用户显式选择后的执行任务,例如已打开的资产盘点任务,不得在普通台账首屏自动选择第一条记录来填充详情区。 页面模板固定为六类:`工作台总览`、`任务处理列表`、`业务台账列表`、`主从工作台`、`详情工作区`、`管理配置页`。后续新增或重构页面必须先判定页面分级和模板,再设计业务内容;不允许每个页面临时自创布局。移动端是这些模板的移动表达,不单独开一套 PC 壳层。采购、合同、项目、资产、付款、应付账款和票据的首屏默认都是 `业务台账列表`;`主从工作台` 只用于用户显式选择后的执行任务,例如已打开的资产盘点任务,不得在普通台账首屏自动选择第一条记录来填充详情区。
全系统操作语义固定:页面主动作只保留一个且出现在用户预期的主操作区;筛选、台账工具、行级动作、详情高风险动作和移动端底部动作各有稳定位置。不可逆或高风险动作不得混在普通查看/编辑动作里,必须显示当前对象摘要并二次确认。 全系统操作语义固定:页面主动作只保留一个且出现在用户预期的主操作区;筛选、导出、更多、行级动作、详情高风险动作和移动端底部动作各有稳定位置。不可逆或高风险动作不得混在普通查看/编辑动作里,必须显示当前对象摘要并二次确认。
全系统动作必须从业务状态矩阵推导,不由单个页面临时决定。每个核心对象先明确草稿、审批中、被驳回、已通过、待执行/待支付、已完成/已支付、已取消、异常、归档等状态,再决定主动作、次动作、禁止动作、禁用原因、确认方式和审计要求;前端只负责体验显隐和原因解释,最终权限与状态校验仍以服务端为准。 全系统动作必须从业务状态矩阵推导,不由单个页面临时决定。每个核心对象先明确草稿、审批中、被驳回、已通过、待执行/待支付、已完成/已支付、已取消、异常、归档等状态,再决定主动作、次动作、禁止动作、禁用原因、确认方式和审计要求;前端只负责体验显隐和原因解释,最终权限与状态校验仍以服务端为准。
@@ -46,13 +46,13 @@ A 类页面优先保证真实试运行路径顺畅;B 类页面优先保证台
权限不足、空状态和异常状态统一用自然中文说明原因和下一步。普通用户路径不暴露 `403`、接口名、权限 code、内部状态码、路由栈、完整路径、原始 payload 或大段技术错误;空态如果没有真实下一步就不显示模块;管理员、审计和排障页才展示最小必要技术信息。 权限不足、空状态和异常状态统一用自然中文说明原因和下一步。普通用户路径不暴露 `403`、接口名、权限 code、内部状态码、路由栈、完整路径、原始 payload 或大段技术错误;空态如果没有真实下一步就不显示模块;管理员、审计和排障页才展示最小必要技术信息。
全系统列表和台账默认保持轻量。首屏只保留搜索、状态、范围、时间等 3 到 5 个高频筛选;导出、批量、统计、模板、全范围、高级筛选等低频或高风险能力进入 `台账工具`、`更多` 或权限面板;批量动作只在选中记录后出现;移动端只承接搜索、状态筛选、卡片列表和主动作,不复制 PC 复杂台账工具。 全系统列表和台账默认保持轻量。首屏只保留搜索、状态、范围、时间等 3 到 5 个高频筛选;导出、批量、统计、模板、全范围、高级筛选等低频或高风险能力进入 `更多`、明确命名的动作按钮或权限面板,不再使用泛化 `台账工具`;批量动作只在选中记录后出现;移动端只承接搜索、状态筛选、卡片列表和主动作,不复制 PC 复杂后台工具。
合同、采购、项目和财务台账统一采用“列表查找、详情处理”的页面边界。列表页只负责查找、筛选、看状态和进入详情;合同下载、归档、付款、导出、批量处理、作废、敏感附件查看等高风险动作必须放到详情页、详情命令区或台账工具里,并按权限、审计和二次确认执行。 合同、采购、项目和财务列表统一采用“列表查找、详情处理”的页面边界。列表页只负责查找、筛选、看状态和进入详情;合同下载、归档、付款、导出、批量处理、作废、敏感附件查看等高风险动作必须放到详情页、详情命令区、`更多` 或授权审计路径里,并按权限、审计和二次确认执行。
合同、采购、项目和财务台账默认按当前角色收敛视角。普通员工看 `我发起/我相关`,负责人看 `我负责/项目相关`,财务看 `待复核/待付款/异常`,管理员才在管理或台账工具内进入或切换 `全范围`;全范围、导出、批量默认收进台账工具,不铺在普通首屏。 合同、采购、项目和财务列表默认按当前角色收敛视角。普通员工看 `我发起/我相关`,负责人看 `我负责/项目相关`,财务看 `待复核/待付款/异常`,管理员才在明确命名的筛选、更多或管理入口内进入或切换 `全范围`;全范围、导出、批量默认收进 `筛选`、`更多` 或权限面板,不铺在普通首屏。
合同台账 V1 只做登记、归档、到期/续签提醒,以及关联项目、采购和付款。合同页面不做完整合同生命周期管理,不自建电子签、复杂法务审查、条款库、版本协同或 CLM;如需合同审批,走 OA 统一审批中心或另建后续 Trellis task,合同台账页只展示状态、摘要和关联入口。 合同 V1 只做登记、归档、到期/续签提醒,以及关联项目、采购和付款。合同页面不做完整合同生命周期管理,不自建电子签、复杂法务审查、条款库、版本协同或 CLM;如需合同审批,走 OA 统一审批中心或另建后续 Trellis task,合同页只展示状态、摘要和关联入口。
采购台账 V1 只做申请、审批、附件、轻量验收/归档,以及关联合同、项目和付款/AP。采购页面不承接采购订单全生命周期、库存、三单匹配、供应商生命周期管理、招采评标或自动入账;审批通过不代表已付款、已入库或已形成固定资产。 采购台账 V1 只做申请、审批、附件、轻量验收/归档,以及关联合同、项目和付款/AP。采购页面不承接采购订单全生命周期、库存、三单匹配、供应商生命周期管理、招采评标或自动入账;审批通过不代表已付款、已入库或已形成固定资产。
@@ -68,7 +68,7 @@ A 类页面优先保证真实试运行路径顺畅;B 类页面优先保证台
全系统详情页采用统一证据结构。状态与下一步先行,业务摘要其次,明细、发票、附件、合同文件、资产材料、回单等证据居中,审批/处理历史靠后,当前动作面板固定在 PC 右侧或移动端底部。高风险动作不得放在用户看不到证据的位置,确认时必须展示对象摘要和后果。 全系统详情页采用统一证据结构。状态与下一步先行,业务摘要其次,明细、发票、附件、合同文件、资产材料、回单等证据居中,审批/处理历史靠后,当前动作面板固定在 PC 右侧或移动端底部。高风险动作不得放在用户看不到证据的位置,确认时必须展示对象摘要和后果。
全系统证据材料统一在详情页集中处理。列表页和台账页只显示发票、附件、合同文件、回单、资产材料等证据的完整性摘要、缺失提示和进入详情入口,不直接承接预览、下载、删除、批量打包或敏感材料处理;这些动作进入详情页、详情命令区、台账工具或授权审计路径。 全系统证据材料统一在详情页集中处理。列表页和台账页只显示发票、附件、合同文件、回单、资产材料等证据的完整性摘要、缺失提示和进入详情入口,不直接承接预览、下载、删除、批量打包或敏感材料处理;这些动作进入详情页、详情命令区、`更多` 或授权审计路径。
全系统上传材料采用“材料类型先行”。上传前必须明确是 PDF 发票、普通附件、合同文件、付款回单、验收材料、资产材料还是电子资料索引,不使用一个万能 `上传附件` 入口混收所有材料;不同类型分别进入对应校验、权限、审计和展示位置,上传文案必须说明结果。 全系统上传材料采用“材料类型先行”。上传前必须明确是 PDF 发票、普通附件、合同文件、付款回单、验收材料、资产材料还是电子资料索引,不使用一个万能 `上传附件` 入口混收所有材料;不同类型分别进入对应校验、权限、审计和展示位置,上传文案必须说明结果。
@@ -137,7 +137,7 @@ A 类页面优先保证真实试运行路径顺畅;B 类页面优先保证台
- 报销主展示使用后端业务编号,例如 `BX20260506001`;UUID 不作为普通用户主编号。 - 报销主展示使用后端业务编号,例如 `BX20260506001`;UUID 不作为普通用户主编号。
- 报销草稿允许编辑和删除;审批中按审批状态提供可解释下一步;已通过、已支付不提供删除。 - 报销草稿允许编辑和删除;审批中按审批状态提供可解释下一步;已通过、已支付不提供删除。
- 普通员工发票入口在报销详情补充材料,不恢复员工侧“票据中心/发票池”主导航。 - 普通员工发票入口在报销详情补充材料,不恢复员工侧“票据/发票池”主导航。
- 发票状态文案面向用户表达,例如识别中、识别失败、可添加、已添加、重复票据、已归档/已报销;不暴露 OCR provider、绑定整单、不绑定明细等内部术语。 - 发票状态文案面向用户表达,例如识别中、识别失败、可添加、已添加、重复票据、已归档/已报销;不暴露 OCR provider、绑定整单、不绑定明细等内部术语。
- 请假、补卡、外出、加班的员工日常主路径优先走企业微信;OA 负责镜像、审计、异常和应急接管。 - 请假、补卡、外出、加班的员工日常主路径优先走企业微信;OA 负责镜像、审计、异常和应急接管。
- 采购、合同、项目、财务附件和高敏资料不迁移到企业微信原生审批或企业微信附件。 - 采购、合同、项目、财务附件和高敏资料不迁移到企业微信原生审批或企业微信附件。
+1 -1
查看文件
@@ -119,7 +119,7 @@ R5 公网验收后确认:正式发票/OCR 上传入口只接受 PDF 发票文
- 默认治理标签为“已通过 / 内部 / 补充材料 / 标准下载”;若附件实际属于合同、报价、验收、付款证明或其他敏感材料,应按对应业务对象上传,不要混在普通报销补充材料里。 - 默认治理标签为“已通过 / 内部 / 补充材料 / 标准下载”;若附件实际属于合同、报价、验收、付款证明或其他敏感材料,应按对应业务对象上传,不要混在普通报销补充材料里。
- 报销附件可以保存业务办理所需原文,但只限报销人、审批人、财务和被授权角色按职责查看;自动化报告、企业微信消息、聊天和 AI/Codex 处理只能使用脱敏摘要或别名。 - 报销附件可以保存业务办理所需原文,但只限报销人、审批人、财务和被授权角色按职责查看;自动化报告、企业微信消息、聊天和 AI/Codex 处理只能使用脱敏摘要或别名。
- 敏感或 `reason_required` 附件下载必须填写用途并写入审计日志;普通员工不得批量下载他人报销附件。 - 敏感或 `reason_required` 附件下载必须填写用途并写入审计日志;普通员工不得批量下载他人报销附件。
- 报销列表只显示发票和附件的完整性摘要、缺失提示和进入详情入口;发票预览、附件查看/下载/删除、敏感材料用途填写和批量打包必须进入报销详情、财务台账工具或授权审计路径。 - 报销列表只显示发票和附件的完整性摘要、缺失提示和进入详情入口;发票预览、附件查看/下载/删除、敏感材料用途填写和批量打包必须进入报销详情、详情命令区、明确命名的财务动作或授权审计路径。
- 报销页只显示付款状态、回单状态、查看付款或补回单入口;标记已付款、取消付款、批量付款和执行付款必须进入付款/财务处理详情,不能放在报销列表或普通报销详情里直接执行。 - 报销页只显示付款状态、回单状态、查看付款或补回单入口;标记已付款、取消付款、批量付款和执行付款必须进入付款/财务处理详情,不能放在报销列表或普通报销详情里直接执行。
- 报销页展示项目、采购、合同、资产候选或付款记录时只显示摘要和跳转,不复制对方模块的审批、付款、合同正文下载、资产建卡或高敏附件操作。 - 报销页展示项目、采购、合同、资产候选或付款记录时只显示摘要和跳转,不复制对方模块的审批、付款、合同正文下载、资产建卡或高敏附件操作。
+1 -1
查看文件
@@ -320,7 +320,7 @@ WAPC2 请假/补卡只读 POC 操作口径:
| 资料 | 来源 | 负责人 | 说明 | | 资料 | 来源 | 负责人 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 应付账款导出 | OA `应付账款` 的 `台账工具` | 出纳/财务 | 用于核对已付款、待付款和异常项;应付台账不是银行流水 | | 应付账款导出 | OA `应付账款` 的 `导出` | 出纳/财务 | 用于核对已付款、待付款和异常项;应付台账不是银行流水 |
| 报销导出 | OA 报销导出 | 出纳/财务 | 按月导出,必要时筛选已通过或已付款状态 | | 报销导出 | OA 报销导出 | 出纳/财务 | 按月导出,必要时筛选已通过或已付款状态 |
| 票据中心导出 | OA 票据/OCR | 出纳/财务 | 用于核对 PDF 发票识别、绑定和归档状态 | | 票据中心导出 | OA 票据/OCR | 出纳/财务 | 用于核对 PDF 发票识别、绑定和归档状态 |
| 付款批次清单 | OA `付款` | 出纳/财务 | 记录批次、复核、执行和对应报销单 | | 付款批次清单 | OA `付款` | 出纳/财务 | 记录批次、复核、执行和对应报销单 |
+1 -1
查看文件
@@ -81,7 +81,7 @@
合同台账是首批核心,职责限定为登记、归档、到期/续签提醒和关联项目/采购/付款。合同正文是否上传取决于敏感级别和公司制度;不能上传时,应登记线下归档位置。 合同台账是首批核心,职责限定为登记、归档、到期/续签提醒和关联项目/采购/付款。合同正文是否上传取决于敏感级别和公司制度;不能上传时,应登记线下归档位置。
合同列表只显示附件完整性、归档状态、到期/续签提醒和进入详情入口;合同文件预览、附件查看/下载/删除、归档动作、敏感材料用途填写和批量打包必须进入合同详情、详情命令区、台账工具或授权审计路径。 合同列表只显示附件完整性、归档状态、到期/续签提醒和进入详情入口;合同文件预览、附件查看/下载/删除、归档动作、敏感材料用途填写和批量打包必须进入合同详情、详情命令区、`更多` 或授权审计路径。
合同审批、变更审批和归档审批如启用,审批动作集中在 `待我处理`、审批详情或移动审批详情执行。合同台账和普通合同详情只显示审批状态、当前节点、历史、`查看处理`、`查看进度` 或进入审批任务入口,不直接放 `通过`、`驳回`、`退回`、`转审`。 合同审批、变更审批和归档审批如启用,审批动作集中在 `待我处理`、审批详情或移动审批详情执行。合同台账和普通合同详情只显示审批状态、当前节点、历史、`查看处理`、`查看进度` 或进入审批任务入口,不直接放 `通过`、`驳回`、`退回`、`转审`。
+1 -1
查看文件
@@ -79,7 +79,7 @@ V1 管理口径以实物责任、流转、盘点和离职交接为准,不按
| 历史资料包、证照原件、高敏个人资料、工资社保、银行流水、客户原始资料 | 默认只建 `EA-DOC` 索引或线下受控归档,不进入普通业务附件区 | | 历史资料包、证照原件、高敏个人资料、工资社保、银行流水、客户原始资料 | 默认只建 `EA-DOC` 索引或线下受控归档,不进入普通业务附件区 |
| 员工整理、外发复核、正式文件初审材料 | 进入任务/项目/待办协作,但默认使用脱敏副本、摘要或待确认清单 | | 员工整理、外发复核、正式文件初审材料 | 进入任务/项目/待办协作,但默认使用脱敏副本、摘要或待确认清单 |
资产列表只显示资产材料、盘点证据、归档索引和责任信息的完整性摘要、缺失提示和进入详情入口;资产材料预览、下载、删除、替换、批量打包、归档位置查看和敏感元数据处理必须进入资产详情、台账工具或授权审计路径。 资产列表只显示资产材料、盘点证据、归档索引和责任信息的完整性摘要、缺失提示和进入详情入口;资产材料预览、下载、删除、替换、批量打包、归档位置查看和敏感元数据处理必须进入资产详情、详情命令区、`更多` 或授权审计路径。
资产材料上传必须先明确材料类型,例如采购来源证明、领用/归还证明、盘点证据、处置证明、维修记录、电子资料索引或备份验证证据。不同类型适用不同权限、敏感级别、审计和展示位置;合同正文、发票原件、付款回单等业务原文仍走对应业务模块,不进入资产万能附件入口。 资产材料上传必须先明确材料类型,例如采购来源证明、领用/归还证明、盘点证据、处置证明、维修记录、电子资料索引或备份验证证据。不同类型适用不同权限、敏感级别、审计和展示位置;合同正文、发票原件、付款回单等业务原文仍走对应业务模块,不进入资产万能附件入口。
+1 -1
查看文件
@@ -86,7 +86,7 @@
采购/费用申请至少包含申请单、明细、附件、审批实例、轻量验收/归档摘要和业务关联。首批可先实现单据主表加明细列表,不要求完整采购订单模型,也不建立库存、三单匹配或供应商生命周期模型。 采购/费用申请至少包含申请单、明细、附件、审批实例、轻量验收/归档摘要和业务关联。首批可先实现单据主表加明细列表,不要求完整采购订单模型,也不建立库存、三单匹配或供应商生命周期模型。
采购/费用列表只显示附件完整性、验收/归档摘要、缺失提示和进入详情入口;报价、合同草稿、验收材料、付款证明等证据材料的预览、下载、删除、替换和批量打包必须进入详情页、详情命令区、台账工具或授权审计路径。 采购/费用列表只显示附件完整性、验收/归档摘要、缺失提示和进入详情入口;报价、合同草稿、验收材料、付款证明等证据材料的预览、下载、删除、替换和批量打包必须进入详情页、详情命令区、`更多` 或授权审计路径。
采购/费用审批动作集中在 `待我处理`、审批详情或移动审批详情执行。采购/费用列表和普通详情只显示审批状态、当前节点、历史、`查看处理`、`查看进度`、`补材料` 或进入审批任务入口,不直接放 `通过`、`驳回`、`退回`、`转审`。 采购/费用审批动作集中在 `待我处理`、审批详情或移动审批详情执行。采购/费用列表和普通详情只显示审批状态、当前节点、历史、`查看处理`、`查看进度`、`补材料` 或进入审批任务入口,不直接放 `通过`、`驳回`、`退回`、`转审`。
+2 -2
查看文件
@@ -82,7 +82,7 @@ const NAVIGATION_REGISTRY = Object.freeze({
workspace: { key: "workspace", label: "工作区设置", icon: "tune", href: "/system/workspace", anyOf: ["system.settings"] }, workspace: { key: "workspace", label: "工作区设置", icon: "tune", href: "/system/workspace", anyOf: ["system.settings"] },
people: { key: "people", label: "人员管理", icon: "group", href: "/people", anyOf: ["system.users", "report.dashboard.view"], featureFlagKey: FEATURE_FLAG_KEYS.employeeLifecycle }, people: { key: "people", label: "人员管理", icon: "group", href: "/people", anyOf: ["system.users", "report.dashboard.view"], featureFlagKey: FEATURE_FLAG_KEYS.employeeLifecycle },
organization: { key: "organization", label: "组织架构", icon: "account_tree", href: "/organization", anyOf: ["system.users", "report.dashboard.view"], featureFlagKey: FEATURE_FLAG_KEYS.employeeLifecycle }, organization: { key: "organization", label: "组织架构", icon: "account_tree", href: "/organization", anyOf: ["system.users", "report.dashboard.view"], featureFlagKey: FEATURE_FLAG_KEYS.employeeLifecycle },
contractsRegistry: { key: "contractsRegistry", label: "合同台账", icon: "description", href: "/contracts-registry", anyOf: ["project.manage", "project.view_all", "system.settings"], featureFlagKey: FEATURE_FLAG_KEYS.projectLinking }, contractsRegistry: { key: "contractsRegistry", label: "合同", icon: "description", href: "/contracts-registry", anyOf: ["project.manage", "project.view_all", "system.settings"], featureFlagKey: FEATURE_FLAG_KEYS.projectLinking },
workflowDefinitions: { key: "workflowDefinitions", label: "流程定义", icon: "schema", href: "/system/workflow-definitions", anyOf: ["system.settings"], featureFlagKey: FEATURE_FLAG_KEYS.workflowDefinitionAdmin }, workflowDefinitions: { key: "workflowDefinitions", label: "流程定义", icon: "schema", href: "/system/workflow-definitions", anyOf: ["system.settings"], featureFlagKey: FEATURE_FLAG_KEYS.workflowDefinitionAdmin },
systemSettings: { key: "systemSettings", label: "管理", icon: "settings", href: "/system/settings", anyOf: ["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage", "system.audit.view"] }, systemSettings: { key: "systemSettings", label: "管理", icon: "settings", href: "/system/settings", anyOf: ["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage", "system.audit.view"] },
featureFlags: { key: "featureFlags", label: "功能管理", icon: "toggle_on", href: "/system/feature-flags", anyOf: ["system.settings"] }, featureFlags: { key: "featureFlags", label: "功能管理", icon: "toggle_on", href: "/system/feature-flags", anyOf: ["system.settings"] },
@@ -95,7 +95,7 @@ const NAVIGATION_REGISTRY = Object.freeze({
notificationChannels: { key: "notificationChannels", label: "通知渠道", icon: "notifications_active", href: "/system/notification-channels", anyOf: ["notification.channel.manage"] }, notificationChannels: { key: "notificationChannels", label: "通知渠道", icon: "notifications_active", href: "/system/notification-channels", anyOf: ["notification.channel.manage"] },
workflowTasks: { key: "workflowTasks", label: "待我处理", icon: "fact_check", href: "/workflow/tasks", anyOf: ["expense.approve", "leave.approve", "attendance.approve", "procurement.approve", "travel.approve"] }, workflowTasks: { key: "workflowTasks", label: "待我处理", icon: "fact_check", href: "/workflow/tasks", anyOf: ["expense.approve", "leave.approve", "attendance.approve", "procurement.approve", "travel.approve"] },
dashboard: { key: "dashboard", label: "仪表盘", icon: "dashboard", href: "/dashboard", anyOf: ["report.dashboard.view"] }, dashboard: { key: "dashboard", label: "仪表盘", icon: "dashboard", href: "/dashboard", anyOf: ["report.dashboard.view"] },
purchaseRequests: { key: "purchaseRequests", label: "采购申请", icon: "shopping_cart", href: "/purchase-requests", anyOf: ["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"] }, purchaseRequests: { key: "purchaseRequests", label: "采购", icon: "shopping_cart", href: "/purchase-requests", anyOf: ["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"] },
travelRequests: { key: "travelRequests", label: "差旅申请", icon: "flight", href: "/travel-requests", anyOf: ["travel.view_all", "travel.manage", "travel.payment.manage"] }, travelRequests: { key: "travelRequests", label: "差旅申请", icon: "flight", href: "/travel-requests", anyOf: ["travel.view_all", "travel.manage", "travel.payment.manage"] },
budgets: { key: "budgets", label: "预算管理", icon: "account_balance", href: "/budgets", anyOf: ["budget.view", "budget.manage"] }, budgets: { key: "budgets", label: "预算管理", icon: "account_balance", href: "/budgets", anyOf: ["budget.view", "budget.manage"] },
paymentBatches: { key: "paymentBatches", label: "付款", icon: "payments", href: "/payment-batches", anyOf: ["payment.batch.manage", "risk.review.manage"], featureFlagKey: FEATURE_FLAG_KEYS.paymentBatch }, paymentBatches: { key: "paymentBatches", label: "付款", icon: "payments", href: "/payment-batches", anyOf: ["payment.batch.manage", "risk.review.manage"], featureFlagKey: FEATURE_FLAG_KEYS.paymentBatch },