调整待处理页任务优先体验(portal-task-first-redesign)
这个提交包含在:
@@ -257,6 +257,32 @@ and admin tools available without dominating the default path.
|
||||
asserting the task surface, primary action and secondary/advanced entry
|
||||
behavior.
|
||||
|
||||
### Convention: Approval Task List Defaults
|
||||
|
||||
**What**: PC approval task lists such as `/workflow/tasks` should make
|
||||
`查看处理` the default row action. List-level `通过` is only a secondary shortcut
|
||||
for low-risk items where the row itself carries enough decision context.
|
||||
|
||||
**Contracts**:
|
||||
|
||||
- The row must answer what the item is, who requested it, why it is assigned to
|
||||
the current user, how long it has waited, and the public business reference.
|
||||
- History entry text should be explicit, such as `已处理记录`; avoid user-facing
|
||||
`高级 / 历史` on the default task surface.
|
||||
- Time labels should prefer `已等待多久` and `接收于` over database-shaped labels
|
||||
such as `更新时间` when the source field is task creation/arrival time.
|
||||
- A quick `通过` button may appear only when the task is pending, the server
|
||||
allows `approve`, the item is a low-risk leave/attendance task, required row
|
||||
summary fields are present, and the task has not exceeded the pending window
|
||||
available to the frontend. Reimbursement, procurement, travel, contract and
|
||||
other amount/evidence-heavy tasks should route through `查看处理` first.
|
||||
- The confirm dialog for list quick approval must still explain action
|
||||
consequences and include the specific task summary.
|
||||
|
||||
**Why**: Small-team approvers need to clear work quickly without training
|
||||
themselves to approve financial or evidence-heavy items from an under-specified
|
||||
row.
|
||||
|
||||
## Convention: Workbench Onboarding And Profile Scope
|
||||
|
||||
**What**: PC workbench and account settings should stay task-first, not become
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
{"file":".trellis/spec/hyb-oa/project-governance.md","reason":"Trellis task contract, validation gates, commit rule, and stop conditions for the current task."}
|
||||
{"file":".trellis/spec/hyb-oa/frontend-react-vite-design.md","reason":"Frontend typecheck/build gates and task-first A-class page review checklist."}
|
||||
@@ -0,0 +1,2 @@
|
||||
{"file":".trellis/spec/hyb-oa/frontend-react-vite-design.md","reason":"Frontend task-first OA page rules, PC operation list density, shared command bar/table conventions, and required validation commands."}
|
||||
{"file":".trellis/spec/hyb-oa/approval-workflow.md","reason":"Approval list behavior, action boundaries, confirmation requirements, and role-safe workflow task handling."}
|
||||
@@ -0,0 +1,10 @@
|
||||
# 实现记录
|
||||
|
||||
## 2026-05-29 待处理页面任务优先整改
|
||||
|
||||
- `/workflow/tasks` 默认标题改为 `待我处理`,历史入口文案改为 `已处理记录`。
|
||||
- PC 主体保留表格密度,但改为任务列表式表格,突出事项、申请人、金额/天数、找我原因、等待时间和业务编号。
|
||||
- 默认主按钮改为 `查看处理`;`通过` 仅作为低风险次级快捷动作。
|
||||
- 低风险快捷通过范围保守限定为请假/考勤:请假不超过 1 天、信息完整、任务未超过默认待办时限且服务端允许 approve。
|
||||
- 时间表达从 `更新时间` 改为 `已等待多久` 和 `接收于`。
|
||||
- 未改后端接口、审批详情页、权限模型、状态机、企业微信、生产部署或真实数据写入。
|
||||
@@ -0,0 +1,163 @@
|
||||
# 工作台任务优先重构
|
||||
|
||||
## Goal
|
||||
|
||||
把 PC 工作台从“欢迎语 + 功能入口 + 折叠模块”改成“小团队今天要处理什么”的任务优先页面。首屏只回答:我今天要处理什么、我发起的事是否有状态变化、团队哪里卡住了;低频管理、普通通知、空壳统计和纯入口后置或不显示。
|
||||
|
||||
## What I Already Know
|
||||
|
||||
- 目标用户是国内、小于 10 人的科技初创公司,使用者通常一人多岗,不是专职 OA 操作员。
|
||||
- 工作台定位已确认:`今天要我处理的事` 优先,不是功能入口页。
|
||||
- 当前页面截图显示:大欢迎卡片占用首屏、`今日任务` 中只有一个焦点事项、右侧按钮重复导航、下方 `团队与通知 / 角色重点 / 试点观测摘要` 是折叠空壳。
|
||||
- 当前实现入口主要在 `client/src/pages/PortalHomePage.tsx`。
|
||||
- 当前工作台数据接口是 `client/src/api/portal.ts` 的 `fetchPortalHome()`,后端路由为 `server/src/routes/portal.js` 的 `/api/portal/home`。
|
||||
- 当前测试里大量断言旧文案:`当前焦点`、`下一步`、`团队与通知`、`角色重点`、`试点观测摘要`。
|
||||
- 前端设计裁决以 `frontend-design` skill 为准;产品事实源要求首页只做工作台摘要,不重复完整待办列表、通知列表和进度列表。
|
||||
|
||||
## Confirmed Decisions
|
||||
|
||||
- `待处理` = 所有需要当前用户行动的事项,不限审批。
|
||||
- `待处理` 包括待审批、待补材料、待付款 / 待复核、票据异常、管理员配置缺口、明确分配给我的团队动作。
|
||||
- `待处理` 不包括普通通知、已完成进度、只读动态、低频管理入口。
|
||||
- 移除首屏欢迎大卡片,改成紧凑状态摘要。
|
||||
- `报销进度` 只在有进行中、待补材料、被驳回、待付款等状态时出现;不做固定按钮。
|
||||
- 取消 `团队与通知` 折叠区,改成有真实阻塞才显示的 `团队卡点`。
|
||||
- 取消 `角色重点` 折叠区,角色只影响事项来源和优先级,不单独占一个区域。
|
||||
- `试点观测摘要` 改成仅异常时出现的 `管理提醒`。
|
||||
- 空状态只显示一句话和最多两个高频动作,不显示空壳模块。
|
||||
- 本轮优先前端展示重构,尽量复用现有接口;不新增后端表,不重做审批、报销、通知接口。
|
||||
|
||||
## Requirements
|
||||
|
||||
- R1: 工作台首屏必须从“欢迎入口页”改成“任务优先页”,默认不展示大欢迎卡片。
|
||||
- R2: 顶部展示一行紧凑状态摘要,例如 `今天有 1 件待处理,2 个进度更新。`
|
||||
- R3: 主区命名为 `今日待处理`,聚合所有需要当前用户行动的事项。
|
||||
- R4: 每条待处理事项必须说明 `什么事`、`为什么找我`、`状态/期限`、`下一步动作`。
|
||||
- R5: 不再固定展示 `报销进度` 按钮;只有存在可靠进度状态时才展示到 `我的进度`。
|
||||
- R6: 没有状态级数据时,不用申请数量伪装成进度更新。
|
||||
- R7: 普通通知不进入工作台主区,只保留顶部铃铛和通知中心职责。
|
||||
- R8: `团队卡点` 只在有真实阻塞时显示,最多展示 3 条,更多进入对应页面。
|
||||
- R9: `管理提醒` 只在管理员/负责人存在真实异常时显示,正常时不显示。
|
||||
- R10: 删除 `团队与通知`、`角色重点`、`试点观测摘要` 这类折叠空壳区域。
|
||||
- R11: 角色差异通过事项来源和优先级体现:审批人看到待审批,财务看到待付款/待核票,管理员看到配置/试运行异常,负责人看到团队阻塞。
|
||||
- R12: 空状态只显示 `今天没有需要你处理的事项。`,最多提供 `发起报销`、`查看我的进度`,管理员可有轻量 `管理设置` 入口。
|
||||
- R13: 页面文案使用自然中文业务动作,避免 `当前焦点`、`角色重点`、`入口`、`管理员按需查看` 等内部系统味文案。
|
||||
- R14: 前端实现优先复用现有接口和权限能力;缺真实数据的区域直接不显示,不新增假数据。
|
||||
|
||||
## Scope
|
||||
|
||||
- In scope:
|
||||
- `client/src/pages/PortalHomePage.tsx` 的结构、文案、事项聚合和空状态。
|
||||
- 现有 portal payload 的前端聚合逻辑。
|
||||
- 与工作台旧文案绑定的 E2E/视觉测试。
|
||||
- 必要时更新产品体验事实源或前端 Trellis spec,沉淀“工作台任务优先”规则。
|
||||
- Out of scope:
|
||||
- 新增后端表或新建工作台聚合模型。
|
||||
- 重做审批、报销、通知、财务接口。
|
||||
- 重构移动工作台。
|
||||
- 重构侧边栏、顶部栏、个人设置页。
|
||||
- 公网部署和真实数据写入,除非后续单独批准。
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- 新增后端表或新建工作台聚合模型。
|
||||
- 重做审批、报销、通知、财务接口。
|
||||
- 重构移动工作台主路径;本轮只同步待处理页移动列表的按钮层级和文案一致性。
|
||||
- 重构侧边栏、顶部栏、个人设置页。
|
||||
- 公网部署、生产配置、真实数据写入、企业微信写入或真实付款。
|
||||
|
||||
## Proposed Implementation Plan
|
||||
|
||||
1. 重新定义工作台前端事项模型
|
||||
- 从 `pendingTasks`、`todoSummary`、warning/high `cards`、`financeSummary`、`weeklySummary`、`teamPresence`、`adoptionSummary.hints` 中生成待处理、团队卡点、管理提醒。
|
||||
- 只使用有真实数量或真实异常的来源。
|
||||
|
||||
2. 重构首屏结构
|
||||
- 移除 `WorkbenchHero` 大欢迎卡片或改为极简状态条。
|
||||
- 用 `今日待处理` 作为主区。
|
||||
- 主动作跟随最高优先级事项,不再另放重复按钮清单。
|
||||
|
||||
3. 删除折叠空壳
|
||||
- 移除 `团队与通知`、`角色重点`、固定 `试点观测摘要`。
|
||||
- 用条件渲染的 `团队卡点`、`管理提醒`、`我的进度` 替代。
|
||||
|
||||
4. 调整空状态
|
||||
- 无待处理、无进度、无卡点时只展示一句空状态和少量高频动作。
|
||||
|
||||
5. 更新测试
|
||||
- 修改 `visual-portal.spec.ts`、`login-portal-ux.spec.ts`、`visual-shell.spec.ts` 等旧断言。
|
||||
- 覆盖普通员工、审批人/负责人、财务/管理员视图。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] 工作台首屏不再出现大欢迎卡片。
|
||||
- [ ] 工作台首屏不再出现 `当前焦点`、`下一步`、`团队与通知`、`角色重点`、`试点观测摘要` 旧文案。
|
||||
- [ ] 有待处理事项时,`今日待处理` 直接展示可行动事项和明确下一步。
|
||||
- [ ] 没有待处理事项时,页面显示极简空状态,不出现空壳模块。
|
||||
- [ ] `报销进度` 不作为固定按钮出现;只有可靠状态数据时才进入进度区域。
|
||||
- [ ] 普通通知不在工作台主区重复展示。
|
||||
- [ ] 团队卡点和管理提醒仅在真实异常/阻塞存在时出现。
|
||||
- [ ] 普通员工、审批人/负责人、财务/管理员视图均符合任务优先规则。
|
||||
- [ ] `cd client && npm run typecheck` 通过。
|
||||
- [ ] `cd client && npm run build` 通过。
|
||||
- [ ] 聚焦 Playwright/截图验证通过,并保存重构后截图证据。
|
||||
|
||||
## Technical Notes
|
||||
|
||||
- `client/src/pages/PortalHomePage.tsx` 当前包含:
|
||||
- `WorkbenchHero`
|
||||
- `TodayTaskPanel`
|
||||
- `SecondaryWorkspacePanel`
|
||||
- `AdoptionSummaryPanel`
|
||||
- 多个 `SummaryPanel` / `QuickActionsPanel` / `SecondaryCardsPanel`
|
||||
- 当前工作台 payload 已包含足够的基础字段,但“我的进度”缺少明确状态级数据时应谨慎显示。
|
||||
- `buildAdoptionHints()` 当前会在无异常时返回正向提示;前端管理提醒应过滤这类“正常提示”,只显示异常提示。
|
||||
- 旧视觉测试需要同步改为新产品语义,不能继续用旧折叠区作为健康判断。
|
||||
|
||||
## Test Plan
|
||||
|
||||
- `cd client && npm run typecheck`
|
||||
- `cd client && npm run build`
|
||||
- 聚焦 E2E:
|
||||
- `cd client && npx playwright test tests/e2e/visual-portal.spec.ts --workers=1 --reporter=list`
|
||||
- `cd client && npx playwright test tests/e2e/login-portal-ux.spec.ts --workers=1 --reporter=list`
|
||||
- 必要时补跑 `visual-shell.spec.ts`
|
||||
- 视觉证据:
|
||||
- 普通员工 PC 工作台
|
||||
- 审批人/负责人 PC 工作台
|
||||
- 财务/管理员 PC 工作台
|
||||
|
||||
## Approved Addendum: 待处理页面任务优先整改
|
||||
|
||||
2026-05-29 用户确认对 `/workflow/tasks` 做小范围页面整改,目标是让审批人逐单看详情后谨慎处理,只有低风险事项保留列表页快捷通过。
|
||||
|
||||
- 默认目标:逐单看详情后处理;低风险事项才允许快捷通过。
|
||||
- 按钮层级:`查看处理` 为主按钮;`通过` 作为次级快捷动作。
|
||||
- 快捷通过范围:仅 `请假 / 考勤` 小事项;请假不超过 1 天、信息完整、未超时且当前用户有 approve 权限。
|
||||
- 主体形态:保留 PC 表格密度,但改为任务列表式表格,第一列集中展示什么事、为什么找我、等待多久和编号。
|
||||
- 历史入口:`高级 / 历史` 改为 `已处理记录`;点击后默认进入 `已处理`,历史筛选不混入 `待处理`。
|
||||
- 时间表达:首屏优先显示 `已等待多久`,绝对接收时间作为次级信息;不再把任务创建时间叫 `更新时间`。
|
||||
- 本轮不改后端接口、不改审批详情页、不做部署或真实数据写入。
|
||||
|
||||
## Approved Addendum: PC 报销列表任务优先整改
|
||||
|
||||
2026-05-29 用户确认对 PC `/reimbursements` 列表页做小范围页面整改,目标是让 `我的报销` 默认服务普通员工/发起人,多角色用户仍先看自己的报销,审批、财务和台账能力按角色/权限降噪。
|
||||
|
||||
- 默认对象:普通员工/发起人优先;审批和财务能力作为二级入口,不抢默认视角。
|
||||
- 首屏结构:新增 `被驳回待补正`、`草稿待完善`、`审批中进度` 三张任务卡,显示数量,点击只切换当前页状态筛选。
|
||||
- 默认范围:多角色用户进入 `我的报销` 仍默认看自己的报销,不自动进入全范围台账。
|
||||
- 记录筛选:状态 tabs 保留,但作为 `报销记录` 筛选放在任务面板下方。
|
||||
- 空状态:按当前状态分别写清楚;草稿空态提供 `新建报销` 情境动作。
|
||||
- 主按钮:顶部 `新建报销` 继续作为唯一主按钮,任务面板不放同权重 CTA。
|
||||
- 台账工具:`高级 / 台账` 改为 `台账工具`,仅在至少有一个真实可用二级能力时显示;普通员工仅有 `expense.create` 不显示。
|
||||
- 更多创建方式:按真实可用内容显隐;普通员工不因模板、差旅关联等低频能力看到噪音入口,相关加载/失败不默认暴露到页面顶部。
|
||||
- 列表节点文案:按视角显示 `待你处理` 或 `当前节点`,不再对发起人统一叫 `待办`。
|
||||
- 批量结果:默认隐藏 `taskId` 等内部排障字段。
|
||||
- 本轮范围:只覆盖 PC `ReimbursementsPage`,不扩展到移动报销页或报销详情页,不改后端接口,不部署或写真实数据。
|
||||
|
||||
## Definition Of Done
|
||||
|
||||
- PRD、实现、验证和截图证据在本 Trellis task 中可追溯。
|
||||
- 没有新增假数据、无意义入口或折叠空壳。
|
||||
- 代码提交使用中文 commit message,并引用 `portal-task-first-redesign`。
|
||||
- 如后续要求公网可测,必须先部署并完成公网检查后再告知用户可以测试。
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"id": "portal-task-first-redesign",
|
||||
"name": "portal-task-first-redesign",
|
||||
"title": "工作台任务优先重构",
|
||||
"description": "把 PC 工作台和待处理页调整为小团队任务优先体验,突出今天要处理什么、等待多久和下一步动作。",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": "client frontend task-first portal and workflow tasks UI",
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "shunli",
|
||||
"assignee": "shunli",
|
||||
"createdAt": "2026-05-29",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [
|
||||
"client/src/pages/PortalHomePage.tsx",
|
||||
"client/src/pages/WorkflowTasksPage.tsx",
|
||||
"client/src/index.css"
|
||||
],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -26,10 +26,12 @@ import { MetricCard } from "../components/patterns/MetricCard";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
import { formatDisplayDateTime } from "../lib/displayFormat";
|
||||
import { TEST_IDS } from "../lib/testIds";
|
||||
import { getWorkflowConfirmCopy } from "../lib/workflowActionConfirm";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const WORKFLOW_DEFAULT_DUE_MS = 24 * 60 * 60 * 1000;
|
||||
const WORKFLOW_STATUS_OPTIONS = [
|
||||
{ value: "pending", label: "待处理" },
|
||||
{ value: "completed", label: "已处理" },
|
||||
@@ -37,6 +39,7 @@ const WORKFLOW_STATUS_OPTIONS = [
|
||||
{ value: "cancelled", label: "已取消" },
|
||||
{ value: "skipped", label: "已跳过" },
|
||||
] as const;
|
||||
const WORKFLOW_HISTORY_STATUS_OPTIONS = WORKFLOW_STATUS_OPTIONS.filter((item) => item.value !== "pending");
|
||||
const WORKFLOW_ENTITY_OPTIONS = [
|
||||
{ value: "all", label: "全部业务" },
|
||||
{ value: "reimbursement", label: "报销" },
|
||||
@@ -115,6 +118,18 @@ function displayTitle(task: WorkflowTaskSummary) {
|
||||
return task.businessSummary?.title ?? task.stepName;
|
||||
}
|
||||
|
||||
function displayTaskTitle(task: WorkflowTaskSummary) {
|
||||
const typeLabel = entityLabel(task.workflowInstance?.entityType);
|
||||
const title = displayTitle(task);
|
||||
if (!title || title === "-") {
|
||||
return typeLabel;
|
||||
}
|
||||
if (title === typeLabel || title.startsWith(`${typeLabel}:`) || title.startsWith(`${typeLabel}:`)) {
|
||||
return title;
|
||||
}
|
||||
return `${typeLabel}:${title}`;
|
||||
}
|
||||
|
||||
function displayApplicant(task: WorkflowTaskSummary) {
|
||||
return task.businessSummary?.applicantName ?? "-";
|
||||
}
|
||||
@@ -123,11 +138,106 @@ function displayMetric(task: WorkflowTaskSummary) {
|
||||
return task.businessSummary?.amount ?? task.businessSummary?.duration ?? "-";
|
||||
}
|
||||
|
||||
function formatSummaryTime(value: string | null | undefined) {
|
||||
function parseTimestamp(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
return null;
|
||||
}
|
||||
return value.slice(0, 19).replace("T", " ");
|
||||
const timestamp = new Date(value).getTime();
|
||||
return Number.isNaN(timestamp) ? null : timestamp;
|
||||
}
|
||||
|
||||
function formatWaitingDuration(value: string | null | undefined) {
|
||||
const timestamp = parseTimestamp(value);
|
||||
if (!timestamp) {
|
||||
return "等待时间未知";
|
||||
}
|
||||
const diff = Math.max(0, Date.now() - timestamp);
|
||||
const minutes = Math.floor(diff / (60 * 1000));
|
||||
if (minutes < 1) {
|
||||
return "刚刚接收";
|
||||
}
|
||||
if (minutes < 60) {
|
||||
return `已等待 ${minutes} 分钟`;
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) {
|
||||
return `已等待 ${hours} 小时`;
|
||||
}
|
||||
return `已等待 ${Math.floor(hours / 24)} 天`;
|
||||
}
|
||||
|
||||
function formatReceivedTime(value: string | null | undefined) {
|
||||
const text = formatDisplayDateTime(value);
|
||||
return text === "-" ? "接收时间未知" : `接收于 ${text}`;
|
||||
}
|
||||
|
||||
function formatTaskTimeline(task: WorkflowTaskSummary) {
|
||||
if (task.status !== "pending" && task.completedAt) {
|
||||
const text = formatDisplayDateTime(task.completedAt);
|
||||
return text === "-" ? "完成时间未知" : `完成于 ${text}`;
|
||||
}
|
||||
return formatWaitingDuration(task.createdAt);
|
||||
}
|
||||
|
||||
function isWithinDefaultDueWindow(value: string | null | undefined) {
|
||||
const timestamp = parseTimestamp(value);
|
||||
if (!timestamp) {
|
||||
return false;
|
||||
}
|
||||
return Date.now() - timestamp < WORKFLOW_DEFAULT_DUE_MS;
|
||||
}
|
||||
|
||||
function isTaskDefaultOverdue(task: WorkflowTaskSummary) {
|
||||
return task.status === "pending" && !isWithinDefaultDueWindow(task.createdAt);
|
||||
}
|
||||
|
||||
function parseDurationDays(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const match = /([0-9]+(?:\.[0-9]+)?)/.exec(value);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const days = Number(match[1]);
|
||||
return Number.isFinite(days) ? days : null;
|
||||
}
|
||||
|
||||
function workflowTaskStatusLabel(status: string) {
|
||||
const match = WORKFLOW_STATUS_OPTIONS.find((item) => item.value === status);
|
||||
return match?.label ?? status;
|
||||
}
|
||||
|
||||
function taskReason(task: WorkflowTaskSummary) {
|
||||
return task.stepName ? `${task.stepName}找你处理` : "当前节点找你处理";
|
||||
}
|
||||
|
||||
function taskLeadMeta(task: WorkflowTaskSummary) {
|
||||
const metric = displayMetric(task);
|
||||
return [displayApplicant(task), metric !== "-" ? metric : null, taskReason(task)].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
function taskSecondaryMeta(task: WorkflowTaskSummary) {
|
||||
return [formatReceivedTime(task.createdAt), displayReference(task)].join(" · ");
|
||||
}
|
||||
|
||||
function isLowRiskQuickApproveTask(task: WorkflowTaskSummary) {
|
||||
if (task.status !== "pending" || !(task.allowedActions ?? []).includes("approve")) {
|
||||
return false;
|
||||
}
|
||||
if (!displayTitle(task) || displayApplicant(task) === "-") {
|
||||
return false;
|
||||
}
|
||||
const entityType = task.workflowInstance?.entityType;
|
||||
if (entityType === "leave") {
|
||||
const days = parseDurationDays(task.businessSummary?.duration);
|
||||
if (days === null || days > 1) {
|
||||
return false;
|
||||
}
|
||||
} else if (entityType !== "attendance_correction") {
|
||||
return false;
|
||||
}
|
||||
return isWithinDefaultDueWindow(task.createdAt);
|
||||
}
|
||||
|
||||
function isAlreadyProcessedTaskError(error: unknown) {
|
||||
@@ -202,6 +312,9 @@ export function WorkflowTasksPage() {
|
||||
const totalPages = Math.max(1, Math.ceil((query.data?.total ?? 0) / PAGE_SIZE));
|
||||
const taskItems = query.data?.items ?? [];
|
||||
const confirmCopy = confirmApproveTaskId ? getWorkflowConfirmCopy("approve") : null;
|
||||
const confirmApproveTask = confirmApproveTaskId
|
||||
? taskItems.find((task) => task.id === confirmApproveTaskId) ?? null
|
||||
: null;
|
||||
const isDefaultTaskView = status === "pending" && entityType === "all";
|
||||
const advancedVisible = showAdvanced || !isDefaultTaskView;
|
||||
const pendingTotal = summaryQuery.data?.pendingTotal ?? "-";
|
||||
@@ -211,21 +324,34 @@ export function WorkflowTasksPage() {
|
||||
.filter((item) => item.pendingTotal > 0)
|
||||
.map((item) => `${entityLabel(item.key)} ${item.pendingTotal}`)
|
||||
.join(" / ") || "-";
|
||||
const earliestPendingWaiting = summaryQuery.data?.earliestPendingAt
|
||||
? formatWaitingDuration(summaryQuery.data.earliestPendingAt)
|
||||
: "暂无待处理";
|
||||
|
||||
function returnToPendingTasks() {
|
||||
setShowAdvanced(false);
|
||||
updateSearchParams({ status: "pending", entityType: "all", page: 1 });
|
||||
}
|
||||
|
||||
function openProcessedRecords() {
|
||||
setShowAdvanced(true);
|
||||
updateSearchParams({ status: "completed", entityType: "all", page: 1 });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListWorkspace>
|
||||
<WorkspaceHeader
|
||||
eyebrow="审批"
|
||||
title="审批中心"
|
||||
title="待我处理"
|
||||
actions={
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
data-testid="workflow-tasks-advanced-toggle"
|
||||
onClick={() => setShowAdvanced((value) => !value)}
|
||||
onClick={advancedVisible ? returnToPendingTasks : openProcessedRecords}
|
||||
>
|
||||
{advancedVisible ? "收起高级" : "高级 / 历史"}
|
||||
{advancedVisible ? "回到待处理" : "已处理记录"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
@@ -236,7 +362,7 @@ export function WorkflowTasksPage() {
|
||||
<span className="badge-neutral">待处理</span>
|
||||
<span className="text-2xl font-semibold text-zinc-900">{pendingTotal}</span>
|
||||
<span className="text-sm text-zinc-500">
|
||||
最早:{formatSummaryTime(summaryQuery.data?.earliestPendingAt)}
|
||||
最早:{earliestPendingWaiting}
|
||||
</span>
|
||||
{summaryQuery.data?.overdueTotal ? (
|
||||
<span className="rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-700">
|
||||
@@ -245,18 +371,6 @@ export function WorkflowTasksPage() {
|
||||
) : null}
|
||||
</div>
|
||||
<div className="ops-command-bar__actions">
|
||||
{!isDefaultTaskView ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowAdvanced(false);
|
||||
updateSearchParams({ status: "pending", entityType: "all", page: 1 });
|
||||
}}
|
||||
>
|
||||
回到待处理
|
||||
</Button>
|
||||
) : null}
|
||||
<span className="text-xs text-zinc-500">业务:{pendingEntitySummary}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -268,7 +382,7 @@ export function WorkflowTasksPage() {
|
||||
<StatStrip columns={4} className="ops-status-strip">
|
||||
<MetricCard title="待办总数" value={pendingTotal} />
|
||||
<MetricCard title="超时待办" value={overdueTotal} tone="warning" />
|
||||
<MetricCard title="最早待处理" value={formatSummaryTime(summaryQuery.data?.earliestPendingAt)} />
|
||||
<MetricCard title="最早待处理" value={earliestPendingWaiting} />
|
||||
<MetricCard
|
||||
title="业务分布"
|
||||
value={<span className="block truncate text-base">{pendingEntitySummary}</span>}
|
||||
@@ -280,11 +394,11 @@ export function WorkflowTasksPage() {
|
||||
<div className="ops-command-bar__row">
|
||||
<Tabs
|
||||
data-testid="workflow-tasks-status-tabs"
|
||||
value={status}
|
||||
value={status === "pending" ? "completed" : status}
|
||||
onValueChange={(value) => updateSearchParams({ status: value as WorkflowStatusFilter, page: 1 })}
|
||||
>
|
||||
<TabsList className="ops-command-bar__tabs !h-auto !justify-start !bg-slate-50">
|
||||
{WORKFLOW_STATUS_OPTIONS.map((item) => (
|
||||
{WORKFLOW_HISTORY_STATUS_OPTIONS.map((item) => (
|
||||
<TabsTrigger
|
||||
data-testid={`workflow-tasks-tab-${item.value}`}
|
||||
key={item.value}
|
||||
@@ -321,7 +435,7 @@ export function WorkflowTasksPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{query.isLoading ? <TableSkeleton columns={7} rows={5} /> : null}
|
||||
{query.isLoading ? <TableSkeleton columns={4} rows={5} /> : null}
|
||||
|
||||
{query.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
@@ -343,12 +457,12 @@ export function WorkflowTasksPage() {
|
||||
<article className="mobile-list-card" key={task.id}>
|
||||
<div className="mobile-list-card__header">
|
||||
<div className="min-w-0">
|
||||
<div className="mobile-list-card__title">{displayTitle(task)}</div>
|
||||
<div className="mobile-list-card__title">{displayTaskTitle(task)}</div>
|
||||
<div className="mobile-list-card__subtitle">
|
||||
{entityLabel(task.workflowInstance?.entityType)} · {displayReference(task)}
|
||||
{taskLeadMeta(task)}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge className="mobile-status-slot" status={task.status} />
|
||||
<StatusBadge className="mobile-status-slot" status={task.status} label={workflowTaskStatusLabel(task.status)} />
|
||||
</div>
|
||||
<div className="mobile-field-list mt-3 rounded-lg bg-zinc-50 px-3 py-2">
|
||||
<div className="mobile-field-row">
|
||||
@@ -360,19 +474,30 @@ export function WorkflowTasksPage() {
|
||||
<span className="mobile-field-value">{displayMetric(task)}</span>
|
||||
</div>
|
||||
<div className="mobile-field-row">
|
||||
<span className="mobile-field-label">当前节点</span>
|
||||
<span className="mobile-field-value">{task.stepName}</span>
|
||||
<span className="mobile-field-label">时间</span>
|
||||
<span className="mobile-field-value">{formatTaskTimeline(task)}</span>
|
||||
</div>
|
||||
<div className="mobile-field-row">
|
||||
<span className="mobile-field-label">业务状态</span>
|
||||
<span className="mobile-field-value">{businessStatusLabel(task.businessSummary?.businessStatus)}</span>
|
||||
<span className="mobile-field-label">编号</span>
|
||||
<span className="mobile-field-value">{displayReference(task)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
{status === "pending" && (task.allowedActions ?? []).includes("approve") ? (
|
||||
<Button
|
||||
asChild
|
||||
className={`min-h-11 ${isLowRiskQuickApproveTask(task) ? "" : "col-span-2"}`}
|
||||
size="sm"
|
||||
data-testid={TEST_IDS.workflow.detailLink}
|
||||
>
|
||||
<Link to={`/workflow/tasks/${task.id}`}>
|
||||
{task.status === "pending" ? "查看处理" : "查看详情"}
|
||||
</Link>
|
||||
</Button>
|
||||
{isLowRiskQuickApproveTask(task) ? (
|
||||
<Button
|
||||
className="min-h-11"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
data-testid={TEST_IDS.workflow.quickApprove}
|
||||
disabled={approveMutation.isPending && approveMutation.variables === task.id}
|
||||
@@ -381,17 +506,6 @@ export function WorkflowTasksPage() {
|
||||
{approveMutation.isPending && approveMutation.variables === task.id ? "通过中..." : "通过"}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
asChild
|
||||
className={`min-h-11 ${
|
||||
status === "pending" && (task.allowedActions ?? []).includes("approve") ? "" : "col-span-2"
|
||||
}`}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
data-testid={TEST_IDS.workflow.detailLink}
|
||||
>
|
||||
<Link to={`/workflow/tasks/${task.id}`}>查看</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
@@ -406,14 +520,10 @@ export function WorkflowTasksPage() {
|
||||
<Table data-testid="workflow-tasks-table">
|
||||
<TableHeader>
|
||||
<tr>
|
||||
<TableHead>业务类型 / 编号</TableHead>
|
||||
<TableHead>标题</TableHead>
|
||||
<TableHead>申请人</TableHead>
|
||||
<TableHead>金额/天数</TableHead>
|
||||
<TableHead>当前节点</TableHead>
|
||||
<TableHead>业务状态</TableHead>
|
||||
<TableHead>更新时间</TableHead>
|
||||
<TableHead className="w-24">操作</TableHead>
|
||||
<TableHead className="min-w-[360px]">待处理事项</TableHead>
|
||||
<TableHead className="min-w-[180px]">状态 / 时间</TableHead>
|
||||
<TableHead className="min-w-[160px]">业务编号</TableHead>
|
||||
<TableHead className="w-36 text-right">操作</TableHead>
|
||||
</tr>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -421,23 +531,46 @@ export function WorkflowTasksPage() {
|
||||
taskItems.map((task) => (
|
||||
<TableRow data-testid="workflow-task-row" key={task.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">{entityLabel(task.workflowInstance?.entityType)}</div>
|
||||
<div className="text-xs text-zinc-500">{displayReference(task)}</div>
|
||||
<div className="max-w-[560px] min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<div className="truncate text-sm font-semibold text-zinc-900">{displayTaskTitle(task)}</div>
|
||||
<StatusBadge status={task.status} label={workflowTaskStatusLabel(task.status)} />
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-5 text-zinc-500">{taskLeadMeta(task)}</div>
|
||||
<div className="mt-1 text-xs leading-5 text-zinc-500">{taskSecondaryMeta(task)}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">{displayTitle(task)}</div>
|
||||
<div className="mt-1"><StatusBadge status={task.status} /></div>
|
||||
<div className="text-sm font-medium text-zinc-900">{formatTaskTimeline(task)}</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||
{isTaskDefaultOverdue(task) ? (
|
||||
<span className="badge-warning">可能超时</span>
|
||||
) : null}
|
||||
{businessStatusLabel(task.businessSummary?.businessStatus) !== "-" &&
|
||||
businessStatusLabel(task.businessSummary?.businessStatus) !== workflowTaskStatusLabel(task.status) ? (
|
||||
<span className="badge-neutral">{businessStatusLabel(task.businessSummary?.businessStatus)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{displayApplicant(task)}</TableCell>
|
||||
<TableCell>{displayMetric(task)}</TableCell>
|
||||
<TableCell>{task.stepName}</TableCell>
|
||||
<TableCell>{businessStatusLabel(task.businessSummary?.businessStatus)}</TableCell>
|
||||
<TableCell>{new Date(task.createdAt).toLocaleString()}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{status === "pending" && (task.allowedActions ?? []).includes("approve") ? (
|
||||
<div className="text-sm font-medium text-zinc-900">{displayReference(task)}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{entityLabel(task.workflowInstance?.entityType)}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col items-stretch gap-2 sm:items-end">
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
data-testid={TEST_IDS.workflow.detailLink}
|
||||
>
|
||||
<Link to={`/workflow/tasks/${task.id}`}>
|
||||
{task.status === "pending" ? "查看处理" : "查看详情"}
|
||||
</Link>
|
||||
</Button>
|
||||
{isLowRiskQuickApproveTask(task) ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
data-testid={TEST_IDS.workflow.quickApprove}
|
||||
disabled={approveMutation.isPending && approveMutation.variables === task.id}
|
||||
@@ -446,21 +579,13 @@ export function WorkflowTasksPage() {
|
||||
{approveMutation.isPending && approveMutation.variables === task.id ? "通过中..." : "通过"}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant={status === "pending" && (task.allowedActions ?? []).includes("approve") ? "secondary" : "default"}
|
||||
data-testid={TEST_IDS.workflow.detailLink}
|
||||
>
|
||||
<Link to={`/workflow/tasks/${task.id}`}>查看</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell className="px-6 py-8 text-center text-zinc-500" colSpan={8}>
|
||||
<TableCell className="px-6 py-8 text-center text-zinc-500" colSpan={4}>
|
||||
{isDefaultTaskView ? "当前没有待处理任务" : "当前筛选下没有审批任务"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -486,7 +611,16 @@ export function WorkflowTasksPage() {
|
||||
}
|
||||
}}
|
||||
title={confirmCopy?.title ?? ""}
|
||||
description={confirmCopy?.description}
|
||||
description={confirmCopy ? (
|
||||
<>
|
||||
<span>{confirmCopy.description}</span>
|
||||
{confirmApproveTask ? (
|
||||
<span className="mt-2 block rounded-lg bg-slate-50 px-3 py-2 text-xs leading-5 text-slate-600">
|
||||
当前事项:{displayTaskTitle(confirmApproveTask)} · {taskLeadMeta(confirmApproveTask)}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
) : undefined}
|
||||
confirmLabel={confirmCopy?.confirmLabel}
|
||||
variant={confirmCopy?.variant}
|
||||
testId="workflow-action-confirm-dialog"
|
||||
|
||||
@@ -34,6 +34,8 @@ const mockMe = {
|
||||
},
|
||||
};
|
||||
|
||||
const pendingTaskCreatedAt = new Date().toISOString();
|
||||
|
||||
const pendingTask = {
|
||||
id: "task-confirm-1",
|
||||
instanceId: "instance-confirm-1",
|
||||
@@ -43,7 +45,7 @@ const pendingTask = {
|
||||
assigneeRole: "manager",
|
||||
status: "pending",
|
||||
outcome: null,
|
||||
createdAt: "2026-05-15T09:00:00.000Z",
|
||||
createdAt: pendingTaskCreatedAt,
|
||||
completedAt: null,
|
||||
workflowInstance: {
|
||||
entityType: "leave",
|
||||
@@ -81,7 +83,7 @@ const pendingTaskDetail = {
|
||||
currentStatus: "reviewing",
|
||||
currentStepId: "manager-review",
|
||||
contextData: { standardViolations: [] },
|
||||
startedAt: "2026-05-15T09:00:00.000Z",
|
||||
startedAt: pendingTaskCreatedAt,
|
||||
completedAt: null,
|
||||
},
|
||||
reimbursement: null,
|
||||
|
||||
@@ -84,7 +84,6 @@ test("E2E 审批列表快捷通过:经理可在待办列表行内直接通过
|
||||
await login(page, MANAGER);
|
||||
await (await openNavLink(page, "待我处理")).click();
|
||||
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
|
||||
await page.getByTestId("workflow-tasks-entity-filter").selectOption("leave");
|
||||
|
||||
const taskRows = page.locator(
|
||||
`[data-testid="${TEST_IDS.workflow.taskRow}"]:has(a[href="/workflow/tasks/${submitResult.currentTaskId}"])`,
|
||||
|
||||
在新工单中引用
屏蔽一个用户