重构 Wave3 详情与移动体验(05-28-frontend-ux-wave3-business-mobile)
这个提交包含在:
@@ -0,0 +1,4 @@
|
||||
{"file": ".trellis/spec/frontend/index.md", "reason": "Frontend validation entrypoint."}
|
||||
{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "Required frontend build, visual and mobile/detail checks."}
|
||||
{"file": ".trellis/spec/hyb-oa/index.md", "reason": "hyb-oa spec entrypoint for governance, fact-source routing and frontend acceptance boundaries."}
|
||||
{"file": ".trellis/tasks/05-28-frontend-ux-wave3-business-mobile/research/wave3-gap-audit.md", "reason": "Scope and expected checks for Wave3 refresh."}
|
||||
@@ -0,0 +1,4 @@
|
||||
{"file": ".trellis/spec/frontend/index.md", "reason": "Frontend task entrypoint and project-specific spec routing."}
|
||||
{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "Wave0-2 shell/header baseline plus detail/mobile surface rules."}
|
||||
{"file": ".trellis/spec/hyb-oa/index.md", "reason": "hyb-oa spec entrypoint, including product fact-source routing and frontend authority."}
|
||||
{"file": ".trellis/tasks/05-28-frontend-ux-wave3-business-mobile/research/wave3-gap-audit.md", "reason": "Current Wave3 gap audit and page scope decisions."}
|
||||
@@ -0,0 +1,53 @@
|
||||
# Implementation Plan
|
||||
|
||||
## Current Decision
|
||||
|
||||
This task is a Wave3 refresh after the newer Wave0-2 baseline, not a blank Wave3 rebuild.
|
||||
|
||||
## Recommended Implementation Order
|
||||
|
||||
1. PC detail surfaces:
|
||||
- `WorkflowTaskDetailPage`
|
||||
- `ProjectDetailPage`
|
||||
- `ContractsRegistryPage`
|
||||
2. Mobile key surfaces:
|
||||
- `MobileApprovalsPage`
|
||||
- `MobileNotificationsPage`
|
||||
- `MobileReimbursementsPage`
|
||||
- `MobileReimbursementDetailPage`
|
||||
- `MobilePurchaseRequestDetailPage` only for deeplink/detail consistency
|
||||
3. Test baseline:
|
||||
- update focused Wave3 Playwright spec
|
||||
- run typecheck/build/design rules/trellis gates
|
||||
4. Deployment:
|
||||
- only after user approval
|
||||
- run trialrun deploy and public checks
|
||||
|
||||
## Guardrails
|
||||
|
||||
- No backend, schema, permission or workflow-state changes.
|
||||
- Do not remove existing business actions.
|
||||
- Do not add a new contract detail route in this task.
|
||||
- For mobile pages, no desktop tables, no horizontal overflow and no sticky action overlap.
|
||||
|
||||
## Completed Implementation
|
||||
|
||||
2026-05-29:
|
||||
|
||||
- Refreshed PC workflow task detail with a next-action command panel, clearer title/subtitle and evidence-oriented side meta.
|
||||
- Refreshed PC project detail with a current project command panel covering next action, closure, risk and evidence counts.
|
||||
- Refreshed PC contract registry with lifecycle summary metrics, expiring-contract command area and ledger row styling.
|
||||
- Refreshed mobile approvals, notifications, reimbursements, reimbursement detail and purchase detail with mobile focus summaries.
|
||||
- Cleaned residual forbidden UI copy in Sidebar and Portal home so the UI design rule scan has zero findings.
|
||||
- Extended `client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts` to cover project detail, contract registry, mobile reimbursement detail and mobile purchase detail.
|
||||
|
||||
## Local Verification
|
||||
|
||||
- `cd client && npm run build` passed on 2026-05-29.
|
||||
- `cd client && npx playwright test tests/e2e/pc-detail-mobile-visual-wave3.spec.ts --workers=1 --reporter=list` passed: 2 tests.
|
||||
- `node scripts/qa/check-ui-design-rules.mjs` passed with 0 findings.
|
||||
- `python3 ./.trellis/scripts/task.py validate 05-28-frontend-ux-wave3-business-mobile` passed.
|
||||
- `node scripts/qa/check-trellis-governance.mjs` passed.
|
||||
- `git diff --check` passed.
|
||||
|
||||
Deployment remains gated by explicit user approval.
|
||||
@@ -0,0 +1,160 @@
|
||||
# 前端体验 Wave3 业务详情与移动关键页
|
||||
|
||||
## Goal
|
||||
|
||||
在已部署的 Wave0-2 全局体验基线之后,复审并补齐 Wave3 业务详情页和移动关键页,让用户从列表、通知、企业微信或移动首页进入具体业务后,仍然看到统一、简洁、可办理的 OA 工作界面。
|
||||
|
||||
## What I Already Know
|
||||
|
||||
- 用户要求继续推进全站前端体验改进。
|
||||
- Wave0-2 已完成并部署:全局壳、导航、Topbar、页头基线、登录、工作台、审批中心、报销列表/详情和通知中心已按新基线整改。
|
||||
- 历史任务 `.trellis/tasks/archive/2026-05/05-27-pc-detail-mobile-visual-wave3/` 已做过一轮 Wave3,但发生在最新 Wave0-2 新基线之前。
|
||||
- 当前不应重复从零改造,而应做“Wave3 复审与补齐”。
|
||||
- `frontend-design` 是视觉与交互设计裁决来源。
|
||||
- 面向用户可测试的页面改动,只有部署和公网检查通过后才可告知用户测试。
|
||||
|
||||
## Scope
|
||||
|
||||
本任务实施 Wave3 刷新,不改后端业务模型:
|
||||
|
||||
- PC 业务详情/详情相邻页面:
|
||||
- `/workflow/tasks/:taskId`
|
||||
- `/projects/:projectId`
|
||||
- `/contracts-registry`
|
||||
- 移动关键页:
|
||||
- `/mobile`
|
||||
- `/mobile/approvals`
|
||||
- `/mobile/approvals/:taskId`
|
||||
- `/mobile/notifications`
|
||||
- `/mobile/reimbursements`
|
||||
- `/mobile/reimbursements/:reimbursementId`
|
||||
- `/mobile/purchase-requests/:purchaseRequestId` 作为通知/移动深链一致性补充目标
|
||||
- 测试与证据:
|
||||
- 更新或扩展 `client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts`
|
||||
- 覆盖 PC 详情、移动列表/详情、企业微信 WebView 关键视口
|
||||
|
||||
## Product Direction
|
||||
|
||||
- Tone:精密、克制、面向办理的 quiet command surface。
|
||||
- PC 详情页优先回答:当前是什么状态、我下一步做什么、动作有什么后果、证据在哪里。
|
||||
- 移动页优先回答:从消息/首页进入后,我是否能少步骤查看、补材料、审批或回到列表。
|
||||
- 合同和项目相关页面强调:摘要、生命周期、负责人、风险/归档位置,不上传或暴露涉密正文。
|
||||
|
||||
## Requirements
|
||||
|
||||
### R1. PC 详情页一致性
|
||||
|
||||
- 复用 Wave0-2 的全局壳、页头和 detail surface 规则。
|
||||
- 审批详情强化当前节点、申请摘要、可执行动作、依据/附件、处理历史。
|
||||
- 项目详情强化项目状态、下一步动作、执行摘要、风险/交付/归档分组,减少局部卡片噪音。
|
||||
- 合同台账强化生命周期、到期预警、项目关联和导出边界,不新增合同正文存储能力。
|
||||
|
||||
### R2. 移动关键路径
|
||||
|
||||
- 移动审批列表/详情保持离线、草稿、同步和 sticky action 现有能力;只优化信息层级和操作分组。
|
||||
- 移动报销列表/详情突出状态、金额、发票/附件完整性和当前可做动作。
|
||||
- 移动通知保持“事件收件箱”定位,点击应直达业务详情、审批任务或给出可解释提示。
|
||||
- 企业微信 WebView 场景不得出现 PC 表格、横向滚动、按钮文字挤压或底部操作遮挡内容。
|
||||
|
||||
### R3. 文案与操作降噪
|
||||
|
||||
- 删除或收纳解释系统实现的文案。
|
||||
- 保留业务下一步、风险、不可逆操作、敏感附件和导出边界提示。
|
||||
- 不可用动作要解释原因;高风险动作继续保留确认。
|
||||
|
||||
### R4. 验证和发布
|
||||
|
||||
- 本地必须通过 typecheck/build 和 focused Playwright。
|
||||
- 移动/企微相关改动至少覆盖 390x844,并尽量覆盖现有企业微信多视口矩阵。
|
||||
- 若部署给用户测试,必须跑部署脚本和公网 readiness/只读巡检。
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Step 1. Gap-driven edits
|
||||
|
||||
- 不全量重写大页面。
|
||||
- 优先改页面首屏、状态摘要、命令区、移动详情头和空态/错误态。
|
||||
- 保留所有现有 API 调用、权限判断、确认弹窗和 mutation 行为。
|
||||
|
||||
### Step 2. Shared pattern reuse
|
||||
|
||||
- PC 使用 `page-header-shell`、`detail-workspace-grid`、`detail-command-panel`、`detail-fact-grid`、`detail-evidence-card`、`detail-action-stack` 等既有类。
|
||||
- 移动使用 `mobile-focus-card`、`mobile-command-surface`、`mobile-list-card--focus`、`mobile-list-card--unread`、sticky action 既有模式。
|
||||
|
||||
### Step 3. Focused test refresh
|
||||
|
||||
- 扩展 `pc-detail-mobile-visual-wave3.spec.ts` 的 mock 页面检查。
|
||||
- 按影响范围补跑移动入口、企业微信渲染、UI 设计规则和 Trellis 治理检查。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] Wave3 gap audit 已记录,且明确区别于历史 Wave3。
|
||||
- [x] PC 审批详情、项目详情、合同台账完成新基线后的体验补齐。
|
||||
- [x] 移动审批、移动通知、移动报销及移动深链目标完成新基线后的体验补齐。
|
||||
- [x] 没有改变后端 API、权限模型、审批状态机、数据库 schema、企业微信配置或公网真实数据。
|
||||
- [x] `cd client && npm run typecheck` 通过。
|
||||
- [x] `cd client && npm run build` 通过。
|
||||
- [x] Focused Playwright 覆盖 Wave3 页面并通过。
|
||||
- [x] `node scripts/qa/check-ui-design-rules.mjs` 通过。
|
||||
- [x] `python3 ./.trellis/scripts/task.py validate 05-28-frontend-ux-wave3-business-mobile` 通过。
|
||||
- [x] `node scripts/qa/check-trellis-governance.mjs` 和 `git diff --check` 通过。
|
||||
- [ ] 若部署给用户测试,已完成公网部署与公网检查并记录证据。
|
||||
|
||||
## Implementation Result
|
||||
|
||||
2026-05-29 本地实施完成:
|
||||
|
||||
- PC 审批详情新增当前状态/下一步命令区,并保留原审批动作确认、历史和转审能力。
|
||||
- PC 项目详情新增项目办理焦点,提前展示收口、风险、交付、合同和资料引用边界。
|
||||
- PC 合同台账改为生命周期台账入口,新增可见合同、生效中、草稿、归档和 30 天到期摘要,不新增合同详情路由或正文上传能力。
|
||||
- 移动审批、通知、报销列表、报销详情和采购详情新增首屏办理焦点,保留离线草稿、sticky action、原有权限与 mutation 行为。
|
||||
- 侧边栏和首页去除 UI 规则命中的口语化文案,保持工作台文案更克制。
|
||||
|
||||
本地验证完成;公网部署与公网检查待用户单独批准。
|
||||
|
||||
## Test Plan
|
||||
|
||||
```bash
|
||||
cd client && npm run typecheck
|
||||
cd client && npm run build
|
||||
cd client && npx playwright test tests/e2e/pc-detail-mobile-visual-wave3.spec.ts --workers=1 --reporter=list
|
||||
node scripts/qa/check-ui-design-rules.mjs
|
||||
python3 ./.trellis/scripts/task.py validate 05-28-frontend-ux-wave3-business-mobile
|
||||
node scripts/qa/check-trellis-governance.mjs
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Deployment gate, only after approval:
|
||||
|
||||
```bash
|
||||
SYNC_ENV=0 LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash deploy/trialrun/deploy.sh
|
||||
LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh
|
||||
OAOPS1_REPORT_DIR=.trellis/tasks/05-28-frontend-ux-wave3-business-mobile/research/postdeploy-<date>-wave3-public \
|
||||
LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-oa-real-use-daily.sh
|
||||
```
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- 不新增合同详情路由或合同正文/附件库。
|
||||
- 不改后端接口、Prisma schema、审批规则、权限、通知投递、企业微信 OAuth、SMTP 或生产配置。
|
||||
- 不执行真实付款、ERP 写入、企业微信写入、数据库重置或未授权真实业务写入。
|
||||
- 不恢复差旅主线能力。
|
||||
- 不把移动端改造成完整 PC 后台。
|
||||
|
||||
## Research References
|
||||
|
||||
- `research/wave3-gap-audit.md` — 确认历史 Wave3 已存在,本任务应做新基线后的复审补齐。
|
||||
- `.trellis/tasks/archive/2026-05/05-27-pc-detail-mobile-visual-wave3/` — 历史 Wave3 范围、实现记录和测试基线。
|
||||
|
||||
## Technical Notes
|
||||
|
||||
- 重点代码区域:
|
||||
- `client/src/pages/WorkflowTaskDetailPage.tsx`
|
||||
- `client/src/pages/ProjectDetailPage.tsx`
|
||||
- `client/src/pages/ContractsRegistryPage.tsx`
|
||||
- `client/src/pages/MobileApprovalsPage.tsx`
|
||||
- `client/src/pages/MobileNotificationsPage.tsx`
|
||||
- `client/src/pages/MobileReimbursementsPage.tsx`
|
||||
- `client/src/pages/MobileReimbursementDetailPage.tsx`
|
||||
- `client/src/pages/MobilePurchaseRequestDetailPage.tsx`
|
||||
- `client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts`
|
||||
@@ -0,0 +1,51 @@
|
||||
# Wave3 Gap Audit
|
||||
|
||||
## Method
|
||||
|
||||
- Reviewed archived task `.trellis/tasks/archive/2026-05/05-27-pc-detail-mobile-visual-wave3/`.
|
||||
- Reviewed current router and target page files under `client/src/pages/`.
|
||||
- Compared current state against the newer Wave0-2 global shell/header baseline and `frontend-design` direction.
|
||||
|
||||
## Important Finding
|
||||
|
||||
Wave3 was not truly blank. A previous task already improved PC reimbursement detail, PC workflow detail, PC notifications and several mobile key pages, and added `client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts`.
|
||||
|
||||
This new task should therefore be a **Wave3 refresh after Wave0-2**, not a full rewrite. The goal is to align existing detail/mobile pages with the latest shell/header/navigation language and close visible gaps.
|
||||
|
||||
## Candidate Pages
|
||||
|
||||
| Page | Current State | Recommended Action |
|
||||
| --- | --- | --- |
|
||||
| `/workflow/tasks/:taskId` | Already uses `EntityOverviewHero`, `detail-workspace-grid`, `SectionPanel`, status metrics and action panel. | Light refresh: stronger title/subtitle, next-action wording, evidence/history hierarchy, loading/error polish. |
|
||||
| `/projects/:projectId` | Rich business page with `EntityOverviewHero` and many panels, but still mixes `card-std`, local metric grids and long sections. | Moderate refresh: introduce clearer current-state/next-step summary and reduce local card noise. |
|
||||
| `/contracts-registry` | Contract table/list page exists, no dedicated contract detail route found in router. | Treat as contract ledger/detail-adjacent page; improve header, warning summary and lifecycle command area without adding backend routes. |
|
||||
| `/mobile` | Uses mobile focus card and task list; already covered by mobile entry tests. | Light refresh only if needed for consistency with new copy and entry hierarchy. |
|
||||
| `/mobile/approvals` and `/mobile/approvals/:taskId` | Large page; already has mobile list/detail surfaces, offline support and sticky action behavior. | Careful targeted refresh: no large rewrite; improve first-screen summary, command labels and detail action grouping. |
|
||||
| `/mobile/notifications` | Mobile notification inbox exists with filters and direct link behavior. | Align copy and controls with PC notification inbox; avoid adding complexity. |
|
||||
| `/mobile/reimbursements` | Mobile list already has focus summary and status tabs. | Improve list summary/action wording only; preserve create/delete behavior. |
|
||||
| `/mobile/reimbursements/:reimbursementId` | Very large detail page with many editing flows. | Targeted polish around state summary, invoice/attachment sections and sticky actions; no business logic rewrite. |
|
||||
| `/mobile/purchase-requests/:purchaseRequestId` | Existing mobile purchase detail page; useful for notification/deeplink coverage. | Include as secondary target if time permits; do not expand into full procurement redesign. |
|
||||
|
||||
## Proposed Scope Decision
|
||||
|
||||
Recommended MVP:
|
||||
|
||||
1. PC detail refresh:
|
||||
- workflow task detail
|
||||
- project detail
|
||||
- contract registry as contract ledger/detail-adjacent surface
|
||||
2. Mobile key refresh:
|
||||
- mobile approvals list/detail
|
||||
- mobile notifications
|
||||
- mobile reimbursements list/detail
|
||||
- mobile purchase request detail only if it is needed to keep deeplink flows consistent
|
||||
3. Test baseline refresh:
|
||||
- update or extend `pc-detail-mobile-visual-wave3.spec.ts`
|
||||
- run mobile route/design checks where relevant
|
||||
|
||||
Out of scope:
|
||||
|
||||
- No backend API/schema/permission/status-machine changes.
|
||||
- No new contract detail route unless a separate task approves the product/API design.
|
||||
- No full mobile rewrite; use focused improvements that preserve existing offline and sticky action behavior.
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "frontend-ux-wave3-business-mobile",
|
||||
"name": "frontend-ux-wave3-business-mobile",
|
||||
"title": "前端体验 Wave3 业务详情与移动关键页",
|
||||
"description": "在 Wave0-2 新全局体验基线后,复审并补齐业务详情页与移动关键页体验。",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": "client/frontend-ux-wave3-business-mobile",
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "shunli",
|
||||
"assignee": "shunli",
|
||||
"createdAt": "2026-05-28",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export function Sidebar() {
|
||||
<span className="size-2 rounded-full bg-primary" />
|
||||
今日优先
|
||||
</div>
|
||||
<div className="mt-1.5 leading-6">先处理待办,再进入报销、项目和财务流程。</div>
|
||||
<div className="mt-1.5 leading-6">待办、报销、项目和财务流程按优先级进入。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ import { PageHeader } from "../components/common/PageHeader";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { DateTextInput } from "../components/common/DateTextInput";
|
||||
import { WorkspaceNotice } from "../components/patterns/ListWorkspace";
|
||||
import { MetricCard } from "../components/patterns/MetricCard";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Label } from "../components/ui/label";
|
||||
@@ -244,6 +246,10 @@ export function ContractsRegistryPage() {
|
||||
? "保留现有责任人"
|
||||
: "默认当前用户";
|
||||
const amountInvalid = Boolean(editor.amount.trim()) && !AMOUNT_PATTERN.test(editor.amount.trim());
|
||||
const activeContractCount = visibleContracts.filter((item) => item.status === "active").length;
|
||||
const draftContractCount = visibleContracts.filter((item) => item.status === "draft").length;
|
||||
const archivedContractCount = visibleContracts.filter((item) => item.status === "archived").length;
|
||||
const expiringContractCount = expiringQuery.data?.total ?? expiringItems.length;
|
||||
|
||||
const refreshAll = async () => {
|
||||
await Promise.all([
|
||||
@@ -305,10 +311,18 @@ export function ContractsRegistryPage() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageScaffold>
|
||||
<PageHeader
|
||||
eyebrow="合同"
|
||||
title="合同台账"
|
||||
subtitle="维护合同元数据、生命周期、到期预警与导出,不在 OA 存放涉密正文附件。"
|
||||
meta={
|
||||
<>
|
||||
<span className="info-chip">可见 {visibleContracts.length} 份</span>
|
||||
<span className="info-chip">30 天到期 {expiringContractCount} 份</span>
|
||||
<span className="info-chip">私密正文不入库</span>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
@@ -334,12 +348,21 @@ export function ContractsRegistryPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<section className="ops-status-strip grid gap-4 md:grid-cols-2 xl:grid-cols-4" data-testid="contracts-registry-summary">
|
||||
<MetricCard title="可见合同" value={visibleContracts.length} note={filters.projectId ? "已按项目过滤" : "当前筛选结果"} />
|
||||
<MetricCard title="生效中" value={activeContractCount} note="需要关注到期和责任人" />
|
||||
<MetricCard title="草稿" value={draftContractCount} note="补齐摘要、金额和归档位置" tone={draftContractCount > 0 ? "warning" : "neutral"} />
|
||||
<MetricCard title="已归档" value={archivedContractCount} note="只保留台账和线下档案索引" />
|
||||
</section>
|
||||
|
||||
<div className="detail-command-panel space-y-4" data-testid="contracts-registry-command">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500">到期预警</div>
|
||||
<div className="text-sm text-zinc-500">生命周期焦点</div>
|
||||
<div className="mt-1 text-3xl font-semibold text-zinc-900">{expiringQuery.data?.total ?? 0}</div>
|
||||
<div className="text-sm text-zinc-600">份合同将在 {EXPIRING_DAYS} 天内到期</div>
|
||||
<div className="text-sm text-zinc-600">
|
||||
份合同将在 {EXPIRING_DAYS} 天内到期;续约会生成草稿合同,导出仅用于授权复核。
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
@@ -759,7 +782,7 @@ export function ContractsRegistryPage() {
|
||||
|
||||
<div className="space-y-3">
|
||||
{visibleContracts.map((item) => (
|
||||
<div key={item.id} className="rounded-lg border border-zinc-200 p-4">
|
||||
<div key={item.id} className="detail-evidence-card bg-white" data-testid="contracts-registry-row">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
@@ -832,7 +855,7 @@ export function ContractsRegistryPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -996,6 +996,18 @@ export function MobileApprovalsPage() {
|
||||
filteredTaskCards.length > 0 && filteredTaskCards.every((task) => selectedBatchTaskIds.includes(task.id));
|
||||
const shouldShowDraftQueue = !isOnline || currentQueueItems.length > 0;
|
||||
const showMobileApprovalActions = Boolean(isPushDetail && selectedTask && selectedTaskAllowsActions);
|
||||
const approvalFocusText = taskTab === "pending"
|
||||
? filteredTaskCards.length
|
||||
? `当前有 ${filteredTaskCards.length} 条待处理,可优先查看金额、时效或退回风险最高的事项。`
|
||||
: "暂无待处理审批,收到企业微信或站内通知后可从这里直达详情。"
|
||||
: "最近 30 天已处理审批用于复核,不支持再次审批。";
|
||||
const selectedActionText = selectedTask
|
||||
? selectedTaskAllowsActions
|
||||
? canUseRemote
|
||||
? "核对摘要和历史后,可在底部直接通过、驳回或退回。"
|
||||
: "当前离线,仅支持保存通过或驳回草稿,联网后再同步。"
|
||||
: "当前任务已处理,本页只展示业务摘要和处理历史。"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -1094,6 +1106,34 @@ export function MobileApprovalsPage() {
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{!isPushDetail && canAccessRemoteApprovals ? (
|
||||
<section className="mobile-card mobile-focus-card mobile-page-stack" data-testid="mobile-approvals-focus">
|
||||
<div className="mobile-section-header">
|
||||
<div className="min-w-0">
|
||||
<h3 className="mobile-section-title">当前办理焦点</h3>
|
||||
<p className="mobile-list-card__subtitle mt-1">{approvalFocusText}</p>
|
||||
</div>
|
||||
<span className={`mobile-section-meta ${isOnline ? "text-emerald-600" : "text-orange-600"}`}>
|
||||
{isOnline ? "在线" : "离线"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mobile-info-grid text-sm">
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">当前筛选</div>
|
||||
<div className="mt-1 text-xl font-semibold text-zinc-900">{filteredTaskCards.length}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">离线草稿</div>
|
||||
<div className="mt-1 text-xl font-semibold text-zinc-900">{currentQueueItems.length}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">批量模式</div>
|
||||
<div className="mt-1 text-sm font-semibold text-zinc-900">{batchMode ? `已选 ${selectedBatchTaskIds.length}` : "未开启"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{!isPushDetail && canAccessRemoteApprovals ? (
|
||||
<section className="mobile-card mobile-page-stack" data-testid="mobile-approvals-list">
|
||||
<div className="mobile-section-header">
|
||||
@@ -1341,6 +1381,11 @@ export function MobileApprovalsPage() {
|
||||
|
||||
{selectedTask ? (
|
||||
<>
|
||||
<div className="mobile-subcard bg-white text-sm" data-testid="mobile-approvals-detail-focus">
|
||||
<div className="text-xs font-semibold text-zinc-500">下一步</div>
|
||||
<div className="mt-1 leading-6 text-zinc-700">{selectedActionText}</div>
|
||||
</div>
|
||||
|
||||
<div className="mobile-list-card text-sm">
|
||||
<div className="mobile-list-card__header mobile-list-card__header--approval">
|
||||
<div className="min-w-0">
|
||||
|
||||
@@ -116,12 +116,14 @@ export function MobileNotificationsPage() {
|
||||
|
||||
const notificationItems = listQuery.data?.items ?? [];
|
||||
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
|
||||
const targetableNotificationCount = notificationItems.filter((item) => resolveMobileNotificationTargetLink(item)).length;
|
||||
|
||||
return (
|
||||
<PageScaffold className="pb-4">
|
||||
<div className="mobile-page-stack" data-testid="mobile-notifications-page">
|
||||
<PageHeader
|
||||
title="通知"
|
||||
subtitle="事件收件箱:未读优先,点击后回到对应业务页。"
|
||||
meta={<span className="badge-neutral">未读 {listQuery.data?.unreadCount ?? 0}</span>}
|
||||
actions={
|
||||
<Button
|
||||
@@ -136,6 +138,32 @@ export function MobileNotificationsPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<section className="mobile-card mobile-focus-card mobile-page-stack" data-testid="mobile-notifications-focus">
|
||||
<div className="mobile-section-header">
|
||||
<div className="min-w-0">
|
||||
<h3 className="mobile-section-title">当前消息焦点</h3>
|
||||
<p className="mobile-list-card__subtitle mt-1">
|
||||
{unreadOnly ? "只看未读,处理后会自动回到业务详情。" : "全部事件按时间排列,业务操作仍在详情页完成。"}
|
||||
</p>
|
||||
</div>
|
||||
<span className="mobile-section-meta">{selectedType ? "已筛选" : "全部类型"}</span>
|
||||
</div>
|
||||
<div className="mobile-info-grid text-sm">
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">未读</div>
|
||||
<div className="mt-1 text-xl font-semibold text-zinc-900">{listQuery.data?.unreadCount ?? 0}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">当前页</div>
|
||||
<div className="mt-1 text-xl font-semibold text-zinc-900">{notificationItems.length}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">可直达</div>
|
||||
<div className="mt-1 text-xl font-semibold text-zinc-900">{targetableNotificationCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mobile-card mobile-page-stack" data-testid="mobile-notifications-list">
|
||||
<div className="mobile-section-header">
|
||||
<div>
|
||||
|
||||
@@ -54,6 +54,11 @@ export function MobilePurchaseRequestDetailPage() {
|
||||
|
||||
const detail = detailQuery.data ?? null;
|
||||
const displayCode = detail ? resolvePurchaseDisplayCode(detail) : "采购详情";
|
||||
const nextStepText = detail
|
||||
? detail.pendingWorkflow?.task?.stepName
|
||||
? `当前在 ${detail.pendingWorkflow.task.stepName},可从审批详情处理;本页用于核对采购依据。`
|
||||
: resolvePurchaseStatusHint(detail)
|
||||
: "";
|
||||
|
||||
return (
|
||||
<PageScaffold className="space-y-3 pb-4">
|
||||
@@ -77,7 +82,7 @@ export function MobilePurchaseRequestDetailPage() {
|
||||
|
||||
{detail ? (
|
||||
<>
|
||||
<section className="mobile-card" data-testid="mobile-purchase-detail-summary">
|
||||
<section className="mobile-card mobile-focus-card mobile-page-stack" data-testid="mobile-purchase-detail-summary">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-base font-semibold text-zinc-900">{displayCode}</h2>
|
||||
@@ -96,9 +101,14 @@ export function MobilePurchaseRequestDetailPage() {
|
||||
<div className="text-xs text-zinc-500">申请人</div>
|
||||
<div className="mt-1 truncate font-medium text-zinc-900">{detail.applicant?.name ?? "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">当前节点</div>
|
||||
<div className="mt-1 truncate font-medium text-zinc-900">{detail.pendingWorkflow?.task?.stepName ?? "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs leading-5 text-zinc-600">
|
||||
{resolvePurchaseStatusHint(detail)}
|
||||
<div className="mobile-subcard bg-white text-sm">
|
||||
<div className="text-xs font-semibold text-zinc-500">下一步</div>
|
||||
<div className="mt-1 leading-6 text-zinc-700">{nextStepText}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -169,6 +169,34 @@ function itemLabel(item: ReimbursementItem) {
|
||||
return desc ? `${typeName} · ${desc}` : typeName;
|
||||
}
|
||||
|
||||
function resolveMobileReimbursementNextStep(input: {
|
||||
status: ReimbursementStatus;
|
||||
canEdit: boolean;
|
||||
itemCount: number;
|
||||
invoiceCount: number;
|
||||
attachmentCount: number;
|
||||
}) {
|
||||
if (input.canEdit && input.itemCount === 0) {
|
||||
return "先添加报销明细,再绑定发票或补充附件。";
|
||||
}
|
||||
if (input.canEdit && input.invoiceCount === 0) {
|
||||
return "建议先添加或上传发票,提交前会做完整性检查。";
|
||||
}
|
||||
if (input.status === "draft" || input.status === "rejected") {
|
||||
return "核对金额、发票和附件后,使用底部按钮提交审批。";
|
||||
}
|
||||
if (input.status === "reviewing") {
|
||||
return "正在审批中,可查看节点和历史;需要补材料时按审批意见处理。";
|
||||
}
|
||||
if (input.status === "approved") {
|
||||
return "已通过审批,等待财务付款和归档。";
|
||||
}
|
||||
if (input.status === "paid") {
|
||||
return "已完成付款,本页用于查看发票、附件和审批留痕。";
|
||||
}
|
||||
return input.attachmentCount > 0 ? "查看附件、发票和审批记录。" : "查看当前报销状态和审批记录。";
|
||||
}
|
||||
|
||||
export function MobileReimbursementDetailPage() {
|
||||
const { reimbursementId = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -813,6 +841,13 @@ export function MobileReimbursementDetailPage() {
|
||||
const showSubmitAction = Boolean(canEdit && canSubmitExpense);
|
||||
const showStickyActions = Boolean(showSubmitAction || canOpenInvoiceDialog);
|
||||
const showSecondaryActions = Boolean(actionError || canDeleteDraft || !canEdit);
|
||||
const detailNextStep = resolveMobileReimbursementNextStep({
|
||||
status: detail.status,
|
||||
canEdit,
|
||||
itemCount: detail.items.length,
|
||||
invoiceCount: detail.invoices.length,
|
||||
attachmentCount: detail.attachments?.length ?? 0,
|
||||
});
|
||||
|
||||
return (
|
||||
<PageScaffold
|
||||
@@ -838,9 +873,20 @@ export function MobileReimbursementDetailPage() {
|
||||
testId="mobile-reimbursement-detail-header"
|
||||
/>
|
||||
|
||||
<section className="mobile-card">
|
||||
<div className="text-xs text-zinc-500">合计金额</div>
|
||||
<div className="mt-1 text-3xl font-semibold text-zinc-900">{formatCurrency(detail.totalAmount)}</div>
|
||||
<section className="mobile-card mobile-focus-card mobile-page-stack" data-testid="mobile-reimbursement-detail-focus">
|
||||
<div className="mobile-section-header mobile-section-header--top">
|
||||
<div className="min-w-0">
|
||||
<h2 className="mobile-section-title">当前办理摘要</h2>
|
||||
<p className="mobile-list-card__subtitle mt-1">{detailNextStep}</p>
|
||||
</div>
|
||||
<span className={cn("rounded-full px-2.5 py-1 text-xs font-medium", STATUS_TONE[detail.status])}>
|
||||
{STATUS_LABEL[detail.status]}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">合计金额</div>
|
||||
<div className="mt-1 text-3xl font-semibold text-zinc-900">{formatCurrency(detail.totalAmount)}</div>
|
||||
</div>
|
||||
<div className="mobile-metric-grid mt-4 text-sm">
|
||||
<div className="rounded-lg bg-zinc-50 px-3 py-2">
|
||||
<div className="text-xs text-zinc-500">明细</div>
|
||||
|
||||
@@ -54,6 +54,15 @@ function formatDate(value: string | null | undefined) {
|
||||
return value || "未填写日期";
|
||||
}
|
||||
|
||||
function reimbursementListNextStep(status: ReimbursementStatus) {
|
||||
if (status === "draft") return "补齐明细、发票和附件后提交审批。";
|
||||
if (status === "reviewing") return "查看审批节点,必要时补充材料或催办。";
|
||||
if (status === "approved") return "等待财务付款,核对金额和附件是否完整。";
|
||||
if (status === "paid") return "已付款单据用于查询和归档,不再编辑。";
|
||||
if (status === "rejected") return "按审批意见修改后重新提交。";
|
||||
return "查看当前报销状态和可执行操作。";
|
||||
}
|
||||
|
||||
function ReimbursementCard({
|
||||
item,
|
||||
canDeleteDraft,
|
||||
@@ -208,6 +217,7 @@ export function MobileReimbursementsPage() {
|
||||
<PageScaffold className="space-y-4">
|
||||
<PageHeader
|
||||
title="移动报销"
|
||||
subtitle="手机端只处理新建、补材料、查状态和草稿删除。"
|
||||
meta={<span className="badge-neutral">{STATUS_LABEL[status]}</span>}
|
||||
actions={
|
||||
canCreateExpense ? (
|
||||
@@ -223,7 +233,14 @@ export function MobileReimbursementsPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<section className="mobile-card mobile-focus-card">
|
||||
<section className="mobile-card mobile-focus-card mobile-page-stack" data-testid="mobile-reimbursements-focus">
|
||||
<div className="mobile-section-header">
|
||||
<div className="min-w-0">
|
||||
<h3 className="mobile-section-title">当前办理焦点</h3>
|
||||
<p className="mobile-list-card__subtitle mt-1">{reimbursementListNextStep(status)}</p>
|
||||
</div>
|
||||
<span className="mobile-section-meta">{STATUS_LABEL[status]}</span>
|
||||
</div>
|
||||
<div className="mobile-info-grid">
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">当前列表</div>
|
||||
@@ -233,6 +250,10 @@ export function MobileReimbursementsPage() {
|
||||
<div className="text-xs text-zinc-500">当前金额</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-zinc-900">{formatCurrency(String(totalAmount))}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">下一步</div>
|
||||
<div className="mt-1 text-sm font-semibold leading-5 text-zinc-900">{status === "draft" ? "补材料" : status === "reviewing" ? "看进度" : "查归档"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -719,7 +719,7 @@ function TodoAggregatePanel({ items }: { items: TodoAggregateItem[] }) {
|
||||
|
||||
return (
|
||||
<SectionPanel
|
||||
title="今天先处理"
|
||||
title="今日待办焦点"
|
||||
description="审批、TODO 和异常提醒集中在这里。"
|
||||
className="portal-panel-fill"
|
||||
>
|
||||
@@ -1127,7 +1127,7 @@ export function PortalHomePage() {
|
||||
: roleCapabilities.canManageSystem
|
||||
? "先看待处理,再进入配置、审计和通知。"
|
||||
: isManagerView
|
||||
? "先处理审批,再看团队状态和周报缺口。"
|
||||
? "审批待办、团队状态和周报缺口集中查看。"
|
||||
: "先看待办,再提交申请、补材料或查看状态。"}
|
||||
actions={
|
||||
!payload.workspace.persisted && canSetupWorkspace ? (
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { fetchEmployees } from "../api/employees";
|
||||
import { archiveProject, createConfidentialDocumentRef, createProjectCommunicationLog, createProjectDelivery, createProjectMember, createProjectMilestone, createProjectRisk, createProjectTask, fetchProject, fetchProjectClosureChecklist, fetchProjectCommunicationLogs, fetchProjectDeliveries, fetchProjectDocumentRefs, fetchProjectExpensesSummary, fetchProjectMembers, fetchProjectMilestones, fetchProjectPurchaseSummary, fetchProjectRisks, fetchProjectTasks, fetchProjectTravelSummary, fetchProjectWeeklyReports, patchProject, patchProjectClosureChecklistItem, patchProjectCommunicationLog, patchProjectMember, patchProjectMilestone, patchProjectRisk, patchProjectTask, type ConfidentialityLevel, type DocumentStorageType, type ProjectCommunicationChannel, type ProjectClosureChecklistStatus, type ProjectDeliveryRecordType, type ProjectMilestoneStatus, type ProjectRiskLevel, type ProjectStatus, type ProjectTaskPriority, type ProjectTaskStatus } from "../api/projects";
|
||||
import { archiveProject, createConfidentialDocumentRef, createProjectCommunicationLog, createProjectDelivery, createProjectMember, createProjectMilestone, createProjectRisk, createProjectTask, fetchProject, fetchProjectClosureChecklist, fetchProjectCommunicationLogs, fetchProjectDeliveries, fetchProjectDocumentRefs, fetchProjectExpensesSummary, fetchProjectMembers, fetchProjectMilestones, fetchProjectPurchaseSummary, fetchProjectRisks, fetchProjectTasks, fetchProjectTravelSummary, fetchProjectWeeklyReports, patchProject, patchProjectClosureChecklistItem, patchProjectCommunicationLog, patchProjectMember, patchProjectMilestone, patchProjectRisk, patchProjectTask, type ConfidentialityLevel, type DocumentStorageType, type ProjectCommunicationChannel, type ProjectClosureChecklistStatus, type ProjectDeliveryRecordType, type ProjectDetail, type ProjectMilestoneStatus, type ProjectRiskLevel, type ProjectStatus, type ProjectTaskPriority, type ProjectTaskStatus } from "../api/projects";
|
||||
import { fetchContractsRegistry } from "../api/contractsRegistry";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
|
||||
@@ -136,6 +136,30 @@ function formatDateTime(value: string | null) {
|
||||
return formatDisplayDateTime(value);
|
||||
}
|
||||
|
||||
function resolveProjectNextAction(detail: ProjectDetail) {
|
||||
if (detail.status === "archived") {
|
||||
return "项目已归档,主要用于查询合同、交付、费用和资料引用。";
|
||||
}
|
||||
if (detail.closureSummary.pendingCount > 0) {
|
||||
return `优先补齐 ${detail.closureSummary.pendingCount} 项收口清单,再执行完成或归档。`;
|
||||
}
|
||||
if (detail.closureSummary.facts.openRiskCount > 0) {
|
||||
return `当前有 ${detail.closureSummary.facts.openRiskCount} 个未关闭风险,建议指定责任人和截止时间。`;
|
||||
}
|
||||
if (detail.counts.milestones > 0 && detail.status === "active") {
|
||||
return "按里程碑和任务推进项目,客户沟通、交付记录和周报都在本页留痕。";
|
||||
}
|
||||
if (detail.status === "draft") {
|
||||
return "先补齐负责人、项目经理、时间范围和摘要,再进入执行。";
|
||||
}
|
||||
return "当前项目可继续维护执行记录、费用关联和归档资料引用。";
|
||||
}
|
||||
|
||||
function resolveProjectEvidenceText(detail: ProjectDetail) {
|
||||
const facts = detail.closureSummary.facts;
|
||||
return `交付 ${facts.deliveryRecordCount} · 合同 ${facts.contractCount} · 资料引用 ${facts.confidentialDocRefCount}`;
|
||||
}
|
||||
|
||||
export function ProjectDetailPage() {
|
||||
const { projectId = "" } = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -538,13 +562,15 @@ export function ProjectDetailPage() {
|
||||
const milestoneItems = milestonesQuery.data?.items ?? detail.milestones;
|
||||
const taskItems = tasksQuery.data?.items ?? detail.tasks;
|
||||
const riskItems = risksQuery.data?.items ?? detail.risks;
|
||||
const projectNextAction = resolveProjectNextAction(detail);
|
||||
const projectEvidenceText = resolveProjectEvidenceText(detail);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageScaffold>
|
||||
<EntityOverviewHero
|
||||
title={`${detail.projectNo} · ${detail.name}`}
|
||||
subtitle="项目概览、执行、留痕与归档在同一工作台内查看和处理。"
|
||||
subtitle="项目状态、下一步、执行留痕和归档边界集中在一个工作台内处理。"
|
||||
meta={
|
||||
<>
|
||||
<StatusBadge status={detail.status} />
|
||||
@@ -569,7 +595,31 @@ export function ProjectDetailPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
|
||||
<section className="detail-command-panel" data-testid="project-current-command">
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.1fr),minmax(260px,0.9fr)]">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-semibold text-zinc-500">当前办理焦点</div>
|
||||
<p className="mt-1 text-sm leading-6 text-zinc-700">{projectNextAction}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<span className="info-chip">状态:{projectStatusLabel(detail.status)}</span>
|
||||
<span className="info-chip">证据:{projectEvidenceText}</span>
|
||||
{detail.isConfidential ? <span className="badge-warning">涉密资料仅登记引用</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="detail-fact-grid">
|
||||
<div className="detail-fact-card">
|
||||
<div className="detail-fact-card__label">收口待处理</div>
|
||||
<div className="detail-fact-card__value">{detail.closureSummary.pendingCount}</div>
|
||||
</div>
|
||||
<div className="detail-fact-card">
|
||||
<div className="detail-fact-card__label">未关闭风险</div>
|
||||
<div className="detail-fact-card__value">{detail.closureSummary.facts.openRiskCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ops-status-strip grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<MetricCard title="成员" value={counts?.members ?? 0} note="执行角色与观察者" />
|
||||
<MetricCard title="里程碑" value={counts?.milestones ?? 0} note="用于跟踪关键节点" />
|
||||
<MetricCard title="任务" value={counts?.tasks ?? 0} note="按优先级与状态推进" />
|
||||
@@ -580,7 +630,7 @@ export function ProjectDetailPage() {
|
||||
</section>
|
||||
|
||||
{(detail.status === "completed" || detail.status === "archived") && detail.closureSummary.pendingCount > 0 ? (
|
||||
<div className="card-std border-amber-200 bg-amber-50 p-4" data-testid="project-closure-warning">
|
||||
<div className="ops-inline-alert border-amber-200 bg-amber-50 text-amber-800" data-testid="project-closure-warning">
|
||||
<div className="font-medium text-zinc-900">项目已进入收口状态,但仍有 {detail.closureSummary.pendingCount} 项待处理</div>
|
||||
<div className="mt-1 text-sm text-zinc-600">本轮是软约束:允许完成或归档,但请尽快补齐交付、费用、合同与交接说明。</div>
|
||||
</div>
|
||||
|
||||
@@ -115,6 +115,32 @@ function isAlreadyProcessedTaskError(error: unknown) {
|
||||
return isApiError(error) && error.details?.task === "already_processed";
|
||||
}
|
||||
|
||||
function resolveWorkflowNextActionText(input: {
|
||||
taskStatus: string;
|
||||
entityType?: string | null;
|
||||
allowedActions: string[];
|
||||
}) {
|
||||
if (input.taskStatus !== "pending") {
|
||||
return "当前审批任务已结束,本页用于查看摘要、依据和处理历史。";
|
||||
}
|
||||
|
||||
const entityName = entityTitle(input.entityType ?? undefined);
|
||||
if (input.allowedActions.includes("approve")) {
|
||||
return `先核对${entityName}摘要、金额/时间和附件依据;确认无误后通过,存在缺口时退回补充。`;
|
||||
}
|
||||
if (input.allowedActions.includes("return")) {
|
||||
return `当前节点可退回,请说明需要补充的${entityName}材料或字段。`;
|
||||
}
|
||||
if (input.allowedActions.includes("reject")) {
|
||||
return `当前节点可驳回,请写清业务原因,便于申请人和后续审计理解。`;
|
||||
}
|
||||
if (input.allowedActions.includes("transfer")) {
|
||||
return "当前节点可转审,请选择更合适的审批人并说明原因。";
|
||||
}
|
||||
|
||||
return "当前账号没有可执行动作,请返回待办列表或联系管理员核对权限。";
|
||||
}
|
||||
|
||||
export function WorkflowTaskDetailPage() {
|
||||
const { taskId = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -257,17 +283,23 @@ export function WorkflowTaskDetailPage() {
|
||||
const canTransfer = allowedActions.has("transfer");
|
||||
const allowedActionLabels = detail.allowedActions.map(workflowActionLabel).join(" / ");
|
||||
const businessStatusLabel = workflowInstanceStatusLabel(detail.workflowInstance?.currentStatus);
|
||||
const nextActionText = resolveWorkflowNextActionText({
|
||||
taskStatus: detail.task.status,
|
||||
entityType: detail.workflowInstance?.entityType,
|
||||
allowedActions: detail.allowedActions,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageScaffold>
|
||||
<EntityOverviewHero
|
||||
title={`任务详情 ${detail.task.stepName}`}
|
||||
subtitle="摘要、动作和处理历史集中办理。"
|
||||
title={`任务详情 · ${detail.task.stepName}`}
|
||||
subtitle="先核对业务摘要和证据,再处理通过、退回、驳回或转审。"
|
||||
meta={(
|
||||
<>
|
||||
<StatusBadge status={detail.task.status} />
|
||||
<span className="info-chip">业务状态:{businessStatusLabel}</span>
|
||||
<span className="info-chip">类型:{entityTitle(detail.workflowInstance?.entityType)}</span>
|
||||
</>
|
||||
)}
|
||||
actions={(
|
||||
@@ -279,8 +311,20 @@ export function WorkflowTaskDetailPage() {
|
||||
|
||||
<div className="detail-workspace-grid">
|
||||
<div className="space-y-5">
|
||||
<SectionPanel title="审批对象摘要">
|
||||
<SectionPanel title="当前状态与审批对象" description="首屏只放判断审批所需的状态、摘要和下一步。">
|
||||
<div className="space-y-4" data-testid="workflow-task-overview">
|
||||
<div className="detail-command-panel" data-testid="workflow-task-next-action">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-semibold text-zinc-500">下一步</div>
|
||||
<p className="mt-1 text-sm leading-6 text-zinc-700">{nextActionText}</p>
|
||||
</div>
|
||||
<div className="shrink-0 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700">
|
||||
可执行:<span className="font-semibold text-zinc-900">{allowedActionLabels || "无"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StatStrip columns={4} className="ops-status-strip">
|
||||
<MetricCard title="当前节点" value={<span className="text-base">{detail.task.stepName}</span>} />
|
||||
<MetricCard title="业务类型" value={<span className="text-base">{entityTitle(detail.workflowInstance?.entityType)}</span>} />
|
||||
@@ -400,7 +444,7 @@ export function WorkflowTaskDetailPage() {
|
||||
</SectionPanel>
|
||||
) : null}
|
||||
|
||||
<SectionPanel title="审批动作">
|
||||
<SectionPanel title="审批动作" description="高风险动作会再次确认;驳回、退回和转审需要留下可复核说明。">
|
||||
<div className="detail-action-stack">
|
||||
{actionError ? (
|
||||
<p
|
||||
@@ -521,13 +565,16 @@ export function WorkflowTaskDetailPage() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<DetailSideMeta title="任务摘要">
|
||||
<DetailSideMeta title="任务摘要" description="保留审批留痕所需的节点、角色和时间。">
|
||||
<div>节点:{detail.task.stepName}</div>
|
||||
<div>实例状态:{workflowInstanceStatusLabel(detail.workflowInstance?.currentStatus)}</div>
|
||||
<div>当前审批角色:{detail.task.assigneeRole ?? "-"}</div>
|
||||
<div>创建时间:{formatDateTime(detail.task.createdAt)}</div>
|
||||
<div>完成时间:{formatDateTime(detail.task.completedAt)}</div>
|
||||
<div>发起时间:{formatDateTime(detail.workflowInstance?.startedAt)}</div>
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-zinc-700">
|
||||
{nextActionText}
|
||||
</div>
|
||||
</DetailSideMeta>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -264,6 +264,235 @@ const notifications = [
|
||||
},
|
||||
];
|
||||
|
||||
const projectDetail = {
|
||||
id: "project-wave3",
|
||||
projectNo: "HYB-W3",
|
||||
name: "OA 试运行",
|
||||
clientName: "海音达内部",
|
||||
clientAlias: "海音达",
|
||||
ownerEmployeeId: visualMe.id,
|
||||
managerEmployeeId: visualMe.id,
|
||||
status: "active",
|
||||
startsAt: "2026-05-20",
|
||||
targetEndAt: "2026-06-03",
|
||||
actualEndAt: null,
|
||||
summary: "真实试运行阶段,重点验证审批、报销、合同台账和移动入口。",
|
||||
isConfidential: true,
|
||||
createdAt: "2026-05-20T09:00:00.000Z",
|
||||
updatedAt: "2026-05-27T09:30:00.000Z",
|
||||
owner: { id: visualMe.id, employeeNo: visualMe.employeeNo, name: visualMe.name },
|
||||
manager: { id: visualMe.id, employeeNo: visualMe.employeeNo, name: visualMe.name },
|
||||
counts: {
|
||||
members: 2,
|
||||
milestones: 2,
|
||||
tasks: 3,
|
||||
risks: 1,
|
||||
reimbursements: 1,
|
||||
purchaseRequests: 1,
|
||||
travelRequests: 0,
|
||||
todoItems: 2,
|
||||
weeklyReports: 1,
|
||||
communicationLogs: 1,
|
||||
closureChecklistItems: 3,
|
||||
contracts: 1,
|
||||
deliveries: 1,
|
||||
documentRefs: 1,
|
||||
},
|
||||
members: [
|
||||
{
|
||||
id: "member-wave3",
|
||||
employeeId: visualMe.id,
|
||||
role: "manager",
|
||||
joinedAt: "2026-05-20",
|
||||
leftAt: null,
|
||||
employee: { id: visualMe.id, employeeNo: visualMe.employeeNo, name: visualMe.name },
|
||||
},
|
||||
],
|
||||
milestones: [
|
||||
{
|
||||
id: "milestone-wave3",
|
||||
title: "公网试运行准备",
|
||||
description: null,
|
||||
status: "in_progress",
|
||||
dueDate: "2026-05-29",
|
||||
completedAt: null,
|
||||
ownerEmployeeId: visualMe.id,
|
||||
sortOrder: 1,
|
||||
},
|
||||
],
|
||||
tasks: [
|
||||
{
|
||||
id: "project-task-wave3",
|
||||
title: "整理试运行反馈",
|
||||
description: "跟进移动端和合同台账反馈",
|
||||
status: "doing",
|
||||
priority: "high",
|
||||
assigneeEmployeeId: visualMe.id,
|
||||
dueDate: "2026-05-30",
|
||||
blockedReason: null,
|
||||
createdBy: visualMe.id,
|
||||
assignee: { id: visualMe.id, employeeNo: visualMe.employeeNo, name: visualMe.name },
|
||||
creator: { id: visualMe.id, employeeNo: visualMe.employeeNo, name: visualMe.name },
|
||||
createdAt: "2026-05-27T09:00:00.000Z",
|
||||
updatedAt: "2026-05-27T09:10:00.000Z",
|
||||
},
|
||||
],
|
||||
risks: [
|
||||
{
|
||||
id: "risk-wave3",
|
||||
title: "移动端首屏信息过多",
|
||||
detail: "需要保持办理摘要和主要动作优先。",
|
||||
level: "medium",
|
||||
status: "monitoring",
|
||||
ownerEmployeeId: visualMe.id,
|
||||
owner: { id: visualMe.id, employeeNo: visualMe.employeeNo, name: visualMe.name },
|
||||
createdAt: "2026-05-27T09:00:00.000Z",
|
||||
updatedAt: "2026-05-27T09:00:00.000Z",
|
||||
},
|
||||
],
|
||||
communicationSummary: {
|
||||
totalCount: 1,
|
||||
latestHappenedAt: "2026-05-27T10:00:00.000Z",
|
||||
followUpDueTodayCount: 1,
|
||||
overdueFollowUpCount: 0,
|
||||
},
|
||||
closureSummary: {
|
||||
totalCount: 3,
|
||||
pendingCount: 1,
|
||||
waivedCount: 0,
|
||||
warned: true,
|
||||
facts: {
|
||||
openRiskCount: 1,
|
||||
openReimbursementCount: 1,
|
||||
openTravelCount: 0,
|
||||
deliveryRecordCount: 1,
|
||||
contractCount: 1,
|
||||
confidentialDocRefCount: 1,
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
communicationLogEnabled: true,
|
||||
closureChecklistEnabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
const contractRegistryItem = {
|
||||
id: "contract-wave3",
|
||||
contractNo: "HT-2026-W3",
|
||||
projectId: projectDetail.id,
|
||||
contractType: "service",
|
||||
counterpartyName: "北京示例服务有限公司",
|
||||
summary: "OA 试运行服务协议台账摘要",
|
||||
amount: "30000.00",
|
||||
currency: "CNY",
|
||||
signedDate: "2026-05-20",
|
||||
effectiveDate: "2026-05-21",
|
||||
expiryDate: "2026-06-20",
|
||||
confidentialityLevel: "internal",
|
||||
archiveLocation: "离线档案柜 A-01",
|
||||
offlineArchiveNo: "OFF-2026-W3",
|
||||
ownerEmployeeId: visualMe.id,
|
||||
status: "active",
|
||||
persistedStatus: "active",
|
||||
createdAt: "2026-05-20T09:00:00.000Z",
|
||||
updatedAt: "2026-05-27T09:00:00.000Z",
|
||||
owner: { id: visualMe.id, employeeNo: visualMe.employeeNo, name: visualMe.name },
|
||||
project: { id: projectDetail.id, projectNo: projectDetail.projectNo, name: projectDetail.name },
|
||||
};
|
||||
|
||||
const purchaseDetail = {
|
||||
id: "purchase-wave3",
|
||||
requestNo: "CG20260527001",
|
||||
applicantId: visualMe.id,
|
||||
submitterId: visualMe.id,
|
||||
departmentId: "dept-wave3",
|
||||
legalEntityId: "legal-wave3",
|
||||
costCenterId: "cost-wave3",
|
||||
supplierId: "supplier-wave3",
|
||||
documentCategoryId: "category-wave3",
|
||||
budgetPoolId: "budget-wave3",
|
||||
projectId: projectDetail.id,
|
||||
title: "试运行办公物料采购",
|
||||
reason: "补齐试运行期间需要的标签纸和档案用品。",
|
||||
currencyCode: "CNY",
|
||||
totalAmount: "1280.00",
|
||||
expectedDeliveryDate: "2026-05-30",
|
||||
status: "reviewing",
|
||||
createdAt: "2026-05-27T08:30:00.000Z",
|
||||
updatedAt: "2026-05-27T09:30:00.000Z",
|
||||
applicant: { id: visualMe.id, employeeNo: visualMe.employeeNo, name: visualMe.name },
|
||||
submitter: { id: visualMe.id, employeeNo: visualMe.employeeNo, name: visualMe.name },
|
||||
department: { id: "dept-wave3", name: "研发部" },
|
||||
legalEntity: { id: "legal-wave3", code: "HYD", name: "海音达" },
|
||||
costCenter: { id: "cost-wave3", code: "RD", name: "研发中心", legalEntityId: "legal-wave3" },
|
||||
supplier: { id: "supplier-wave3", code: "SUP-W3", name: "北京示例服务有限公司" },
|
||||
documentCategory: { id: "category-wave3", code: "OFFICE", name: "办公用品", entityType: "purchase_request" },
|
||||
budgetPool: { id: "budget-wave3", code: "OPS", name: "运营预算" },
|
||||
project: { id: projectDetail.id, projectNo: projectDetail.projectNo, name: projectDetail.name, status: projectDetail.status },
|
||||
items: [
|
||||
{
|
||||
id: "purchase-item-wave3",
|
||||
description: "档案标签纸",
|
||||
specification: "A4 / 100 张",
|
||||
quantity: "2",
|
||||
unitPrice: "640.00",
|
||||
lineAmount: "1280.00",
|
||||
isAssetCandidate: false,
|
||||
createdAt: "2026-05-27T08:30:00.000Z",
|
||||
updatedAt: "2026-05-27T08:30:00.000Z",
|
||||
},
|
||||
],
|
||||
attachments: [
|
||||
{
|
||||
id: "purchase-att-wave3",
|
||||
originalName: "采购说明.pdf",
|
||||
mimeType: "application/pdf",
|
||||
fileSize: 64000,
|
||||
fileSha256: "purchase-hash-wave3",
|
||||
uploadedBy: visualMe.id,
|
||||
createdAt: "2026-05-27T08:40:00.000Z",
|
||||
uploader: { id: visualMe.id, employeeNo: visualMe.employeeNo, name: visualMe.name },
|
||||
governanceStatus: "approved",
|
||||
sensitivityLevel: "internal",
|
||||
attachmentType: "supporting_material",
|
||||
downloadPolicy: "reason_required",
|
||||
reviewedBy: visualMe.id,
|
||||
reviewedAt: "2026-05-27T08:50:00.000Z",
|
||||
reviewNote: "已复核",
|
||||
archivedBy: null,
|
||||
archivedAt: null,
|
||||
archiveNote: null,
|
||||
},
|
||||
],
|
||||
pendingWorkflow: {
|
||||
instanceId: "workflow-purchase-wave3",
|
||||
currentStatus: "reviewing",
|
||||
currentStepId: "manager-review",
|
||||
task: {
|
||||
id: "task-purchase-wave3",
|
||||
stepId: "manager-review",
|
||||
stepName: "主管审批",
|
||||
assigneeId: visualMe.id,
|
||||
assigneeRole: "manager",
|
||||
},
|
||||
},
|
||||
approvalHistory: [
|
||||
{
|
||||
id: "purchase-history-wave3",
|
||||
taskId: "task-purchase-wave3",
|
||||
actorId: visualMe.id,
|
||||
actorName: visualMe.name,
|
||||
actorEmployeeNo: visualMe.employeeNo,
|
||||
action: "submit",
|
||||
comment: "请审批",
|
||||
createdAt: "2026-05-27T09:00:00.000Z",
|
||||
},
|
||||
],
|
||||
archiveOrder: null,
|
||||
permissions: { canEditDraft: false, canSubmit: false, canWithdraw: false, canArchiveOrder: false },
|
||||
riskFlags: [],
|
||||
};
|
||||
|
||||
function envelope<T>(data: T) {
|
||||
return { requestId: "mock-wave3", data };
|
||||
}
|
||||
@@ -449,8 +678,235 @@ async function mockWave3Apis(page: Page) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/projects/project-wave3") {
|
||||
await fulfillJson(route, { item: projectDetail });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/projects/project-wave3/members") {
|
||||
await fulfillJson(route, { items: projectDetail.members });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/projects/project-wave3/milestones") {
|
||||
await fulfillJson(route, { items: projectDetail.milestones });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/projects/project-wave3/tasks") {
|
||||
await fulfillJson(route, { items: projectDetail.tasks });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/projects/project-wave3/risks") {
|
||||
await fulfillJson(route, { items: projectDetail.risks });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/projects/project-wave3/expenses/summary") {
|
||||
await fulfillJson(route, {
|
||||
totalAmount: reimbursementDetail.totalAmount,
|
||||
items: [
|
||||
{
|
||||
id: reimbursementDetail.id,
|
||||
formNumber: reimbursementDetail.formNumber,
|
||||
status: reimbursementDetail.status,
|
||||
totalAmount: reimbursementDetail.totalAmount,
|
||||
reimbursementDate: reimbursementDetail.reimbursementDate,
|
||||
updatedAt: "2026-05-27T09:20:00.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/projects/project-wave3/travel/summary") {
|
||||
await fulfillJson(route, { totalAmount: "0.00", items: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/projects/project-wave3/purchase-requests/summary") {
|
||||
await fulfillJson(route, {
|
||||
totalAmount: purchaseDetail.totalAmount,
|
||||
items: [
|
||||
{
|
||||
id: purchaseDetail.id,
|
||||
requestNo: purchaseDetail.requestNo,
|
||||
title: purchaseDetail.title,
|
||||
status: purchaseDetail.status,
|
||||
totalAmount: purchaseDetail.totalAmount,
|
||||
updatedAt: purchaseDetail.updatedAt,
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/projects/project-wave3/weekly-reports") {
|
||||
await fulfillJson(route, {
|
||||
items: [
|
||||
{
|
||||
id: "weekly-link-wave3",
|
||||
weeklyReportId: "weekly-wave3",
|
||||
weekStart: "2026-05-25",
|
||||
status: "submitted",
|
||||
employee: { id: visualMe.id, employeeNo: visualMe.employeeNo, name: visualMe.name },
|
||||
progressText: "完成 Wave3 视觉复核",
|
||||
riskText: null,
|
||||
nextStepText: "继续公网验证",
|
||||
updatedAt: "2026-05-27T09:20:00.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/projects/project-wave3/deliveries") {
|
||||
await fulfillJson(route, {
|
||||
items: [
|
||||
{
|
||||
id: "delivery-wave3",
|
||||
title: "试运行准备记录",
|
||||
recordType: "stage_acceptance",
|
||||
summary: "完成页面体验基线复核。",
|
||||
occurredAt: "2026-05-27",
|
||||
owner: { id: visualMe.id, employeeNo: visualMe.employeeNo, name: visualMe.name },
|
||||
createdAt: "2026-05-27T09:20:00.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/projects/project-wave3/document-refs") {
|
||||
await fulfillJson(route, {
|
||||
items: [
|
||||
{
|
||||
id: "doc-ref-wave3",
|
||||
title: "试运行资料索引",
|
||||
documentNo: "DOC-W3",
|
||||
confidentialityLevel: "internal",
|
||||
storageType: "offline_archive",
|
||||
storagePath: "离线档案柜 A-01",
|
||||
offlineArchiveNo: "OFF-2026-W3",
|
||||
updatedAt: "2026-05-27T09:00:00.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/projects/project-wave3/communication-logs") {
|
||||
await fulfillJson(route, {
|
||||
items: [
|
||||
{
|
||||
id: "comm-wave3",
|
||||
happenedAt: "2026-05-27T10:00:00.000Z",
|
||||
channel: "meeting",
|
||||
externalContactName: "试运行负责人",
|
||||
externalOrgName: "海音达",
|
||||
summary: "确认先优化详情页和移动入口。",
|
||||
nextAction: "继续收集反馈",
|
||||
nextFollowUpDate: "2026-05-29",
|
||||
createdBy: visualMe.id,
|
||||
createdAt: "2026-05-27T10:10:00.000Z",
|
||||
updatedAt: "2026-05-27T10:10:00.000Z",
|
||||
canEdit: true,
|
||||
creator: { id: visualMe.id, employeeNo: visualMe.employeeNo, name: visualMe.name },
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/projects/project-wave3/closure-checklist") {
|
||||
await fulfillJson(route, {
|
||||
items: [
|
||||
{
|
||||
id: "closure-wave3",
|
||||
itemKey: "contract",
|
||||
title: "合同归档位置已登记",
|
||||
status: "pending",
|
||||
note: null,
|
||||
sortOrder: 1,
|
||||
completedAt: null,
|
||||
completedBy: null,
|
||||
canEdit: true,
|
||||
completer: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/projects") {
|
||||
await fulfillJson(route, { items: [reimbursementDetail.project] });
|
||||
await fulfillJson(route, {
|
||||
items: [
|
||||
{
|
||||
id: projectDetail.id,
|
||||
projectNo: projectDetail.projectNo,
|
||||
name: projectDetail.name,
|
||||
clientName: projectDetail.clientName,
|
||||
clientAlias: projectDetail.clientAlias,
|
||||
status: projectDetail.status,
|
||||
startsAt: projectDetail.startsAt,
|
||||
targetEndAt: projectDetail.targetEndAt,
|
||||
actualEndAt: projectDetail.actualEndAt,
|
||||
summary: projectDetail.summary,
|
||||
isConfidential: projectDetail.isConfidential,
|
||||
owner: projectDetail.owner,
|
||||
manager: projectDetail.manager,
|
||||
counts: {
|
||||
members: projectDetail.counts.members,
|
||||
milestones: projectDetail.counts.milestones,
|
||||
tasks: projectDetail.counts.tasks,
|
||||
risks: projectDetail.counts.risks,
|
||||
overdueMilestones: 0,
|
||||
highPriorityTasks: 1,
|
||||
openRisks: projectDetail.closureSummary.facts.openRiskCount,
|
||||
},
|
||||
updatedAt: projectDetail.updatedAt,
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/employees") {
|
||||
await fulfillJson(route, {
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
total: 1,
|
||||
items: [
|
||||
{
|
||||
id: visualMe.id,
|
||||
employeeNo: visualMe.employeeNo,
|
||||
name: visualMe.name,
|
||||
email: visualMe.email,
|
||||
phone: visualMe.phone,
|
||||
status: "active",
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/contracts-registry/expiring") {
|
||||
await fulfillJson(route, {
|
||||
days: Number(url.searchParams.get("days") ?? 30),
|
||||
total: 1,
|
||||
items: [contractRegistryItem],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/contracts-registry") {
|
||||
await fulfillJson(route, { items: [contractRegistryItem] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/purchase-requests/purchase-wave3") {
|
||||
await fulfillJson(route, purchaseDetail);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -603,21 +1059,34 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
|
||||
|
||||
await page.goto("/reimbursements/reim-wave3");
|
||||
await expect(page.getByRole("heading", { name: /报销详情/ })).toBeVisible();
|
||||
await expect(page.getByText("办理摘要")).toBeVisible();
|
||||
await expect(page.getByTestId("reimbursement-summary")).toBeVisible();
|
||||
await expect(page.getByTestId("reimbursement-payment-status")).toBeVisible();
|
||||
await page.screenshot({ path: screenshotPath("01-pc-reimbursement-detail.png"), fullPage: true });
|
||||
|
||||
await page.goto("/workflow/tasks/task-wave3");
|
||||
await expect(page.getByRole("heading", { name: /任务详情/ })).toBeVisible();
|
||||
await expect(page.getByTestId("workflow-task-next-action")).toBeVisible();
|
||||
await expect(page.getByText("审批动作")).toBeVisible();
|
||||
await expect(page.getByTestId("workflow-task-overview")).toBeVisible();
|
||||
await page.screenshot({ path: screenshotPath("02-pc-workflow-detail.png"), fullPage: true });
|
||||
|
||||
await page.goto("/projects/project-wave3");
|
||||
await expect(page.getByRole("heading", { name: /HYB-W3/ })).toBeVisible();
|
||||
await expect(page.getByTestId("project-current-command")).toBeVisible();
|
||||
await expect(page.locator("table").first()).toBeVisible();
|
||||
await page.screenshot({ path: screenshotPath("03-pc-project-detail.png"), fullPage: true });
|
||||
|
||||
await page.goto("/contracts-registry?projectId=project-wave3");
|
||||
await expect(page.getByRole("heading", { name: "合同台账", exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("contracts-registry-summary")).toBeVisible();
|
||||
await expect(page.getByTestId("contracts-registry-row")).toBeVisible();
|
||||
await page.screenshot({ path: screenshotPath("04-pc-contracts-registry.png"), fullPage: true });
|
||||
|
||||
await page.goto("/notifications");
|
||||
await expect(page.getByRole("heading", { name: "通知" })).toBeVisible();
|
||||
await expect(page.getByTestId("notification-row").first()).toBeVisible();
|
||||
await expect(page.locator("table")).toHaveCount(0);
|
||||
await page.screenshot({ path: screenshotPath("03-pc-notifications.png"), fullPage: true });
|
||||
await page.screenshot({ path: screenshotPath("05-pc-notifications.png"), fullPage: true });
|
||||
});
|
||||
|
||||
test("Wave3 移动关键页视觉截图", async ({ page }) => {
|
||||
@@ -632,35 +1101,53 @@ test("Wave3 移动关键页视觉截图", async ({ page }) => {
|
||||
|
||||
await page.goto("/mobile/reimbursements");
|
||||
await expect(page.getByRole("heading", { name: "移动报销" })).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-reimbursements-focus")).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-reimbursements-list")).toBeVisible();
|
||||
await expect(page.locator("table")).toHaveCount(0);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await page.screenshot({ path: screenshotPath("05-mobile-reimbursements.png"), fullPage: true });
|
||||
|
||||
await page.goto("/mobile/reimbursements/reim-wave3");
|
||||
await expect(page.getByTestId("mobile-reimbursement-detail-focus")).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-reimbursement-items")).toBeVisible();
|
||||
await expect(page.locator("table")).toHaveCount(0);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await page.screenshot({ path: screenshotPath("06-mobile-reimbursement-detail.png"), fullPage: true });
|
||||
|
||||
await page.goto("/mobile/approvals");
|
||||
await expect(page.getByRole("heading", { name: "审批", exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-approvals-focus")).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-approvals-list")).toBeVisible();
|
||||
await expect(page.locator("table")).toHaveCount(0);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await page.screenshot({ path: screenshotPath("06-mobile-approvals.png"), fullPage: true });
|
||||
await page.screenshot({ path: screenshotPath("07-mobile-approvals.png"), fullPage: true });
|
||||
|
||||
await page.goto("/mobile/approvals/task-wave3");
|
||||
await expect(page.getByTestId("mobile-approvals-detail")).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-approvals-detail-focus")).toBeVisible();
|
||||
await expect(page.locator("table")).toHaveCount(0);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await page.screenshot({ path: screenshotPath("07-mobile-approval-detail.png"), fullPage: true });
|
||||
await page.screenshot({ path: screenshotPath("08-mobile-approval-detail.png"), fullPage: true });
|
||||
|
||||
await page.goto("/mobile/notifications");
|
||||
await expect(page.getByTestId("mobile-notifications-focus")).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-notifications-list")).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-notification-card").first()).toBeVisible();
|
||||
await expect(page.locator("table")).toHaveCount(0);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await page.screenshot({ path: screenshotPath("08-mobile-notifications.png"), fullPage: true });
|
||||
await page.screenshot({ path: screenshotPath("09-mobile-notifications.png"), fullPage: true });
|
||||
|
||||
await page.goto("/mobile/purchase-requests/purchase-wave3");
|
||||
await expect(page.getByTestId("mobile-purchase-detail-summary")).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-purchase-detail-items")).toBeVisible();
|
||||
await expect(page.locator("table")).toHaveCount(0);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await page.screenshot({ path: screenshotPath("10-mobile-purchase-detail.png"), fullPage: true });
|
||||
|
||||
await page.goto("/mobile/me");
|
||||
await expect(page.getByRole("heading", { name: "我的" })).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-more-links")).toBeVisible();
|
||||
await expect(page.locator("table")).toHaveCount(0);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await page.screenshot({ path: screenshotPath("09-mobile-me.png"), fullPage: true });
|
||||
await page.screenshot({ path: screenshotPath("11-mobile-me.png"), fullPage: true });
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户