统一首批页面成熟设计基线(05-30-unified-page-design-review)
这个提交包含在:
@@ -31,6 +31,97 @@ and verification contracts. Do not copy broad aesthetic guidance from
|
|||||||
- Do not add decorative landing-page styling to operational OA pages.
|
- Do not add decorative landing-page styling to operational OA pages.
|
||||||
- Run `cd client && npm run typecheck && npm run build` for frontend changes.
|
- Run `cd client && npm run typecheck && npm run build` for frontend changes.
|
||||||
|
|
||||||
|
## Convention: Mature Enterprise Page Baseline
|
||||||
|
|
||||||
|
**What**: From `05-30-unified-page-design-review`, hyb-oa PC/mobile pages use
|
||||||
|
Feishu approval/workbench mental models plus Ant Design / Ant Design Pro page
|
||||||
|
patterns as the mature enterprise baseline. This is a product/interaction
|
||||||
|
reference, not a copy of Feishu or Ant Design branding.
|
||||||
|
|
||||||
|
**Global contracts**:
|
||||||
|
|
||||||
|
- Interaction innovation is off by default for OA, approval, finance, contract,
|
||||||
|
asset and management surfaces. Prefer established Feishu / Ant Design style
|
||||||
|
patterns for page layout, action placement, button naming, tabs, filters,
|
||||||
|
detail panels, confirmations and more menus. Project-specific differences
|
||||||
|
should simplify or hide low-frequency work for small teams, not invent new
|
||||||
|
controls or unusual action flows.
|
||||||
|
- Page titles default to the route name only, such as `工作台`, `待我处理`, `报销`,
|
||||||
|
`合同台账`, `个人设置` and `通知中心`. Page subtitles are off by default and
|
||||||
|
appear only for exception, empty, permission-denied, Enterprise WeChat/OA
|
||||||
|
boundary, external-system boundary or irreversible-action explanations.
|
||||||
|
- Normal workflows should not be explained in default-visible copy. Do not add
|
||||||
|
subtitles, card descriptions, helper rows or notice bars that restate obvious
|
||||||
|
actions such as "待我处理是处理工作", "查看通知消息", "先核对再处理" or "去处理要做的事".
|
||||||
|
- Header primary actions are reserved for creation or a real page-level primary
|
||||||
|
action: `新建报销`, `新建采购`, `新增合同`, `新增资产`, `新建项目`. Workbench,
|
||||||
|
pending-task, notification and read-only record pages must not force a header
|
||||||
|
button for visual symmetry.
|
||||||
|
- Button labels use common enterprise verbs: `处理`, `去处理`, `查看详情`,
|
||||||
|
`查看全部`, `查看进度`, `继续编辑`, `新建`, `新增`, `提交`, `保存`, `取消`,
|
||||||
|
`撤回`, `作废`, `归档`, `更多`. Do not use slogan-like or invented labels
|
||||||
|
such as `处理最急的一件`.
|
||||||
|
- Sidebar entries are role-aware common work entries, not an all-permissions
|
||||||
|
module catalog. Direct entries may include `工作台`, `待我处理`, `报销`, `采购`,
|
||||||
|
`合同`, `项目`, `资产`, `付款`, `应付账款`, `票据`, `团队`, `请假记录` and `管理`
|
||||||
|
when the current role has real use. Low-frequency, frozen, staged or
|
||||||
|
troubleshooting routes stay in `更多` or the `管理` landing page.
|
||||||
|
- List pages use a right-side operation column or card tail. Default row actions
|
||||||
|
are `查看详情`, `处理`, `继续编辑` or `查看进度`; low-frequency or risky actions
|
||||||
|
such as export, batch, void, download, payment and sensitive attachment
|
||||||
|
handling move to `更多`, `台账工具` or the detail page.
|
||||||
|
- Business ledger pages use the same shape: page title and optional creation
|
||||||
|
action, visible common filters, table/card list and right-side operation
|
||||||
|
column. `台账工具` contains export, batch, statistics, templates, full-scope
|
||||||
|
views and advanced filters.
|
||||||
|
- PC detail pages use an Ant Design Pro style detail workspace: top summary,
|
||||||
|
main facts/details/evidence, right-side state and action panel, and bottom
|
||||||
|
history. Do not build PC details as a free stack of equal-weight large cards.
|
||||||
|
- Workflow approval details use one action bar, not vertical action cards:
|
||||||
|
`通过` is the primary action, `退回` and `驳回` are secondary actions, and
|
||||||
|
`转审` defaults to `更多`. Actions that need a reason open a reason input and
|
||||||
|
confirmation after click. Confirmation must include the current object
|
||||||
|
summary and consequences.
|
||||||
|
- Mobile pages use top status/focus content, sectioned single-column details or
|
||||||
|
card lists, and one bottom safe-area primary action. Secondary, destructive,
|
||||||
|
approval, payment and transfer actions go into `更多` or a confirmation
|
||||||
|
panel; do not squeeze multiple risky actions into the bottom bar.
|
||||||
|
- Normal empty sections should disappear when they have no useful content. Empty
|
||||||
|
states only explain current filter/status absence and one real next step.
|
||||||
|
|
||||||
|
**Page map**:
|
||||||
|
|
||||||
|
| Surface | Template | First-screen content | Primary action rule |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `工作台` | 工作台总览 | 待办摘要、常用业务入口、必要提醒 | No forced header action. Use `去处理`, `新建报销`, `查看全部` inside relevant sections. |
|
||||||
|
| `待我处理` | 任务处理列表 | `待处理` / `已处理` tabs, count, minimal filters, task list | Row action is `处理`; `已处理记录` is a tab, not a separate header button. |
|
||||||
|
| `审批详情` | 详情工作区 | Summary, facts/evidence, right summary/action panel, history | One action bar: primary `通过`, secondary `退回`/`驳回`, `转审` in `更多`. |
|
||||||
|
| `报销` / `采购` / `合同` / `项目` / `资产` / `付款` / `应付账款` / `票据` | 业务台账列表 | Common filters plus table/card list | Header creation only when the route owns creation; risky tools in `台账工具` or detail. |
|
||||||
|
| Business details | 详情工作区 | Summary, facts, evidence, relations, history | Right-side state/action panel on PC; bottom primary action on mobile. |
|
||||||
|
| `管理` | 管理配置页 | Grouped landing: `人员组织`, `审批流程`, `基础资料`, `系统设置`, `审计排障` | Management subpages use search/filter, table and right operation column. |
|
||||||
|
| `个人设置` | 管理配置页 variant | `个人资料`, `账号安全`, `通知偏好`, `企业微信绑定` | No employee dossier, permission matrix or org maintenance in the default profile path. |
|
||||||
|
| `通知中心` | 业务台账列表 variant | Event inbox for state changes, reminders, failures and sync results | Notification click deep-links to business detail, workflow task or failure explanation; never duplicates `待我处理`. |
|
||||||
|
| `请假记录` / `考勤` | 业务台账列表 or record view | Enterprise WeChat-owned records, mirror status, exceptions and audit | Ordinary users do not see OA daily leave/punch approval creation; OA emergency entry only for authorized emergency/admin scope. |
|
||||||
|
| Mobile routes | Mobile expression of the same template | Top status/focus, card list or sectioned detail | One bottom safe-area primary action; secondary actions in `更多` or confirmation. |
|
||||||
|
|
||||||
|
**Implementation order for design refactors**:
|
||||||
|
|
||||||
|
1. A-class frequent pages: workbench, pending tasks, workflow task detail,
|
||||||
|
personal settings and notification center.
|
||||||
|
2. Business ledgers and details: reimbursement, procurement, contracts,
|
||||||
|
projects, assets, payments, AP and invoices.
|
||||||
|
3. Management and boundary pages: management landing/subpages, leave records,
|
||||||
|
attendance, Enterprise WeChat archive and mobile equivalents.
|
||||||
|
|
||||||
|
**Tests Required**:
|
||||||
|
|
||||||
|
- Documentation-only updates: `python3 ./.trellis/scripts/task.py validate
|
||||||
|
<task>`, `node scripts/qa/check-trellis-governance.mjs` and
|
||||||
|
`git diff --check`.
|
||||||
|
- Frontend implementation updates: `cd client && npm run typecheck`,
|
||||||
|
`cd client && npm run build`, plus focused Playwright or screenshot coverage
|
||||||
|
for changed A-class, detail, sidebar, action-bar or mobile surfaces.
|
||||||
|
|
||||||
## Frozen / Boundary Items
|
## Frozen / Boundary Items
|
||||||
|
|
||||||
Frozen modules and Enterprise WeChat-owned HR/attendance entries must be labeled
|
Frozen modules and Enterprise WeChat-owned HR/attendance entries must be labeled
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{"file": ".trellis/spec/hyb-oa/project-governance.md", "reason": "Trellis task 合同和提交治理要求"}
|
||||||
|
{"file": ".trellis/spec/hyb-oa/docs-and-evidence.md", "reason": "文档更新和证据记录规则"}
|
||||||
|
{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "设计规范一致性检查"}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# 统一页面设计规范确认稿
|
||||||
|
|
||||||
|
## 参照系
|
||||||
|
|
||||||
|
- 产品心智:飞书审批 / 飞书工作台。
|
||||||
|
- 页面与组件模式:Ant Design / Ant Design Pro。
|
||||||
|
- 使用方式:借鉴成熟中文企业系统的信息架构、按钮命名、动作位置、列表和详情模式;不复制品牌、Logo、配色或视觉资产。
|
||||||
|
|
||||||
|
## 全局规则
|
||||||
|
|
||||||
|
0. 操作型页面默认不做交互创新。页面、按钮、筛选、列表、详情、确认弹窗、更多菜单和移动端动作区优先参考飞书/Ant Design 等成熟系统模式;hyb-oa 的差异只体现在小团队裁剪、权限收敛和业务边界,不自创陌生操作。
|
||||||
|
1. 页面标题只写页面名。副标题默认取消,只在异常、空状态、权限不足、企微/OA 边界、外部系统边界或不可逆动作说明时出现。
|
||||||
|
2. 正常流程不解释。禁止用副标题、卡片描述、提示条解释用户已经能从标题和按钮理解的动作。
|
||||||
|
3. 页头主按钮只用于创建类或真实页面级主动作,例如 `新建报销`、`新建采购`、`新增合同`、`新增资产`。
|
||||||
|
4. 按钮使用成熟系统常规文案:`处理`、`去处理`、`查看详情`、`查看全部`、`查看进度`、`继续编辑`、`新建`、`新增`、`提交`、`保存`、`更多`。禁止 `处理最急的一件` 这类口号型按钮。
|
||||||
|
5. 列表页右侧固定操作列;低频或高风险动作进入 `更多`、`台账工具` 或详情页。
|
||||||
|
6. PC 详情页采用顶部摘要、中部事实/明细/证据、右侧状态与动作栏、底部历史;移动端采用单列分区和底部安全区主动作。
|
||||||
|
7. 审批详情不使用竖向动作卡片。PC 使用一个动作条:`通过` 主按钮,`退回`、`驳回` 次按钮,`转审` 默认进 `更多`;需要理由的动作点击后再展开输入和确认。
|
||||||
|
8. 移动端底部只保留一个主动作,次动作进入 `更多` 或确认面板。
|
||||||
|
9. 通知中心只做事件收件箱,不复制待办,不做快速上手或流程解释。
|
||||||
|
10. 请假、补卡、外出、加班和打卡由企业微信主办;OA 只做记录、镜像、异常、审计和应急入口。
|
||||||
|
|
||||||
|
## 页面归类
|
||||||
|
|
||||||
|
| 页面 | 模板 | 规则 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 工作台 | 工作台总览 | 待办摘要、常用业务入口、必要提醒;不强行放页头主按钮。 |
|
||||||
|
| 待我处理 | 任务处理列表 | `待处理` / `已处理` 两个 Tab;行操作为 `处理`。 |
|
||||||
|
| 审批详情 | 详情工作区 | 摘要、证据、右侧动作栏、历史;禁止竖向动作卡片。 |
|
||||||
|
| 报销、采购、合同、项目、资产、付款、应付账款、票据 | 业务台账列表 | 标题、创建按钮、常用筛选、表格/卡片列表、右侧操作列;低频工具进 `台账工具`。 |
|
||||||
|
| 业务详情 | 详情工作区 | 摘要、事实、证据、关联、右侧状态/动作、底部历史。 |
|
||||||
|
| 管理 | 管理配置页 | 管理首页分 `人员组织`、`审批流程`、`基础资料`、`系统设置`、`审计排障`;子页表格配置。 |
|
||||||
|
| 个人设置 | 账号设置页 | `个人资料`、`账号安全`、`通知偏好`、`企业微信绑定`;组织角色只读折叠,维护入口进管理。 |
|
||||||
|
| 通知中心 | 事件列表 | 状态变化、提醒、失败、同步结果;点击直达业务详情、审批任务或失败说明。 |
|
||||||
|
| 请假记录、考勤 | 记录/镜像页 | 企业微信主办,OA 记录和异常;普通用户不显示 OA 日常新建请假或审批动作。 |
|
||||||
|
| 移动页面 | 同模板移动表达 | 顶部状态、卡片/单列详情、底部一个主动作。 |
|
||||||
|
|
||||||
|
## 实施批次
|
||||||
|
|
||||||
|
1. 第一批:工作台、待我处理、审批详情、个人设置、通知中心。
|
||||||
|
2. 第二批:报销、采购、合同、项目、资产、付款、应付账款、票据的列表和详情。
|
||||||
|
3. 第三批:管理页、请假记录、考勤、企微归档页和移动端对应页面。
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
- 规范确认阶段:`git diff --check`、Trellis task validate、governance check。
|
||||||
|
- 页面实现阶段:`cd client && npm run typecheck`、`cd client && npm run build`、重点页面 Playwright/截图复核。
|
||||||
|
- 面向用户可测前:必须部署并完成公网检查。
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "前端设计规范更新目标文件和页面基线来源"}
|
||||||
|
{"file": ".trellis/tasks/05-30-unified-page-design-review/research/mature-enterprise-ui-reference.md", "reason": "成熟企业页面参照系调研"}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# 统一页面设计规范重审
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
重新统一 hyb-oa 全部 PC/移动页面的设计规则,先解决当前已暴露的“文案解释过多、按钮命名不符合常见系统、动作区位置混乱、审批详情动作竖向铺开、页面模板执行不一致”等问题。规范确认前不进入具体页面实现。
|
||||||
|
|
||||||
|
## What I already know
|
||||||
|
|
||||||
|
- 目标用户是国内、小于 10 人的科技初创公司,角色重叠,默认不是专职 OA 操作员。
|
||||||
|
- 用户明确反馈:
|
||||||
|
- 工作台右上角 `处理最急的一件` 不像常规系统按钮。
|
||||||
|
- 工作台、待我处理、审批详情等页面解释性文案过多。
|
||||||
|
- `待我处理` 页面不应再用大量解释说明普通工作流。
|
||||||
|
- 审批详情页把 `通过`、`驳回`、`退回`、`转审` 等处理动作竖向铺成多个卡片,体验不符合成熟系统。
|
||||||
|
- 现有事实源已规定:
|
||||||
|
- 前端视觉与交互裁决优先使用 `frontend-design` skill。
|
||||||
|
- 页面模板有六类:工作台总览、任务处理列表、业务台账列表、主从工作台、详情工作区、管理配置页。
|
||||||
|
- 审批动作应集中在待我处理/审批详情执行。
|
||||||
|
- HR/考勤日常流程由企业微信主办,OA 做镜像、审计、异常、应急。
|
||||||
|
- 当前规范存在执行层缺口:有原则,但缺少足够细的“页面级验收模板”和“动作区具体坐标”,导致实现时继续产生自创文案和自创布局。
|
||||||
|
- 当前工作区已有与本任务无关的未提交部署脚本变更:`deploy/trialrun/deploy.sh`、`deploy/trialrun/runbook.md`。本任务不得误纳入后续设计提交。
|
||||||
|
|
||||||
|
## Research Notes
|
||||||
|
|
||||||
|
- 初步外部参照见 `research/mature-enterprise-ui-reference.md`。
|
||||||
|
- 设计方向倾向采用成熟中文企业协同/审批系统的保守模式:常规命名、低解释密度、表格/列表动作列、详情页底部或右侧统一动作区、更多操作收纳。
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- 规范已由用户确认,本任务继续承接第一批 A 类高频页面实现。
|
||||||
|
- 后续 B/C 类页面仍按小批量拆分推进,避免一次性重写全系统页面。
|
||||||
|
- “全面采用成熟设计”不等于复制某个产品的品牌视觉,而是采用其信息架构、交互位置、按钮语义和页面模板。
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- 更新当前 Trellis task 的 PRD、设计确认稿和调研记录。
|
||||||
|
- 更新 `.trellis/spec/hyb-oa/frontend-react-vite-design.md`,把成熟企业页面基线、动作坐标、文案边界、页面归类和实施批次写成后续页面实现的统一规范。
|
||||||
|
- 实施第一批 A 类高频页面:工作台、待我处理、审批详情、个人设置、通知中心。
|
||||||
|
- 同步受影响的移动个人设置、报销待办入口文案、顶部壳层通知标题和聚焦 E2E/视觉断言。
|
||||||
|
- 本轮不部署、不做公网复检。
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- 主参照系已确认:产品心智参考飞书审批/飞书工作台,页面和组件规则参考 Ant Design / Ant Design Pro。后续规范采用成熟中文企业协同/OA 系统的常规命名、低解释密度、列表操作列、详情统一动作区和更多操作收纳,不复制竞品品牌视觉。
|
||||||
|
- 交互创新边界已确认:OA、审批、财务、合同、资产、管理等操作型页面默认不做交互创新。页面布局、按钮命名、动作位置、Tab、筛选、详情栏、确认弹窗和更多菜单优先套用飞书/Ant Design 等成熟系统模式;项目特有处理只允许做小团队裁剪和降噪,不自创控件或陌生流程。
|
||||||
|
- 说明文字密度已确认:默认不解释正常流程,只解释异常、风险、空状态、权限不足、外部系统边界和不可逆动作。禁止用页面副标题、卡片描述或提示条解释用户已经能从标题/按钮理解的普通动作。
|
||||||
|
- 工作台首屏已确认:采用飞书式极简工作台,只保留待办摘要、常用业务入口和必要提醒。按钮使用 `去处理`、`新建报销`、`查看全部` 等常规文案,禁止 `处理最急的一件` 这类口号型、自创型按钮。
|
||||||
|
- 审批详情动作区已确认:PC 使用右侧或内容底部统一动作条,结构为一个主按钮、少量次按钮和 `更多`;`通过` 为主按钮,`退回`、`驳回` 为次按钮,`转审` 默认进入 `更多`。点击需填写理由的动作后再展开理由输入和确认弹窗,禁止把多个处理动作竖向铺成大卡片。
|
||||||
|
- 列表页行操作已确认:右侧固定操作列,主要动作使用 `查看详情`、`处理`、`继续编辑`、`查看进度` 等成熟文案;导出、批量、作废、下载、付款等低频或高风险动作进入 `更多`、`台账工具` 或详情页,不在行内铺多个按钮。
|
||||||
|
- 详情页布局已确认:采用 Ant Design Pro 式详情工作区。PC 结构为顶部摘要、中部事实/明细/证据、右侧状态与动作栏、底部历史;移动端转为单列分区和底部动作区。禁止 PC 详情页自由堆叠多个同权重大卡片。
|
||||||
|
- 移动端动作区已确认:底部安全区固定一个主动作,次动作进入 `更多` 或二次确认面板;不把多个审批、付款、删除、转审等动作横向挤在底部,也不把最终动作分散到页面中间区块。
|
||||||
|
- 页面标题已确认:标题默认只写页面名,例如 `工作台`、`待我处理`、`报销`、`合同台账`。副标题默认取消,只在异常、空状态、权限不足、企业微信/OA 边界、外部系统边界或不可逆动作说明时出现。
|
||||||
|
- 页头主按钮已确认:只放创建类或页面级真实主动作,例如 `新建报销`、`新建采购`、`新增合同`、`新增资产`。工作台、待处理、通知等总览/列表页不为了形式强行放主按钮;待办区域内使用 `去处理`、`查看全部` 等常规入口。
|
||||||
|
- 侧边栏入口已确认:常用业务直接展示,管理/低频/冻结/阶段性页面进入 `更多`。默认直接入口包括 `工作台`、`待我处理`、`报销`、`采购`、`合同`、`项目`、`资产`、`付款`、`应付账款`、`票据`、`团队`、`请假记录`、`管理`,但仍需按角色和权限收敛,不能按权限把所有可见模块全铺出来。
|
||||||
|
- `待我处理` 页面已确认:采用飞书审批式任务列表,主 Tab 为 `待处理` / `已处理`。顶部只显示数量和必要筛选;列表行展示事项、申请人/发起人、等待时间、状态、业务编号和右侧 `处理` 操作。`已处理记录` 不作为孤立右上角按钮,页面不显示解释性副标题或大段说明。
|
||||||
|
- 报销、采购、合同、项目、资产、付款、应付账款、票据等业务页已确认统一采用 `业务台账列表`:页头标题与创建按钮、常用筛选区、表格/卡片列表、右侧操作列。低频或高风险工具统一进入 `台账工具`、`更多` 或详情页,不在列表首屏铺开。
|
||||||
|
- 管理配置页已确认:侧边栏只保留 `管理`,管理首页按 `人员组织`、`审批流程`、`基础资料`、`系统设置`、`审计排障` 分组进入;子页采用搜索/筛选、表格和右侧操作列。普通业务页面、工作台和个人设置不得混入管理配置矩阵。
|
||||||
|
- 个人设置页已确认:采用常见网站账号设置结构,只包含 `个人资料`、`账号安全`、`通知偏好`、`企业微信绑定`。组织、角色、直属汇报只做折叠只读摘要;人员档案、权限、组织维护和角色管理全部进入 `管理`。
|
||||||
|
- 通知中心已确认:只做事件收件箱,显示状态变化、提醒、失败、同步结果和流转消息;点击通知必须跳到业务详情、审批任务或失败说明页。通知中心不复制 `待我处理` 列表,不承担快速上手或流程解释职责。
|
||||||
|
- 请假/考勤页已确认:企业微信主办请假、补卡、外出、加班和打卡;OA 页面只做记录、镜像、异常、审计和应急入口。普通用户路径不显示 OA 新建请假或日常审批动作;页面标题使用 `请假记录`、`考勤`,只在边界或异常处说明企业微信/OA 分工。
|
||||||
|
- 落地范围已确认:先一次性写全系统统一设计规范,再分批实现。首批实现优先级为 A 类高频页面:工作台、待我处理、审批详情、个人设置、通知中心;随后扩展到报销、采购、合同、项目、资产、付款、应付账款、票据等业务台账和详情页。
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- 当前无阻塞问题。第二批业务台账和详情页另按后续任务推进。
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- 设计规范必须先明确唯一主参照和辅助组件规范。
|
||||||
|
- 规范必须细化到以下级别:
|
||||||
|
- 页面标题、面包屑、页头按钮、说明文字允许出现的条件。
|
||||||
|
- 工作台、待我处理、审批详情、报销列表、报销详情、采购、合同、项目、资产、付款、应付账款、票据、团队、请假记录、考勤、管理配置、个人设置、通知中心、移动端页面的模板和动作区位置。
|
||||||
|
- 列表页操作列、详情页动作区、表单页底部动作、移动端底部动作的统一规则。
|
||||||
|
- 按钮命名:使用常见动词,如 `处理`、`查看详情`、`新建`、`提交`、`保存`、`更多`;禁止类似 `处理最急的一件` 的自创口号型按钮。
|
||||||
|
- 解释性文案规则:正常流程不解释,只有风险、异常、空状态、权限不足、外部系统边界和不可逆动作才解释。
|
||||||
|
- 审批详情动作规则:默认横向主按钮 + 次按钮 + 更多菜单或底部/右侧固定动作区,不把多个动作纵向铺成多个大卡片。
|
||||||
|
- 规范必须把页面改造拆成可执行批次,并列明验收标准。
|
||||||
|
- 规范确认后,第一批页面实现必须遵守成熟系统基线,不引入自创按钮、解释型副标题或竖向审批动作卡片。
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] 已与用户确认主要产品参照系。
|
||||||
|
- [x] 已更新现有设计规范,不新建分散文档。
|
||||||
|
- [x] 规范覆盖全页面类型和关键业务页面。
|
||||||
|
- [x] 规范明确常规按钮命名、操作区域坐标、说明文字边界和审批详情动作结构。
|
||||||
|
- [x] 规范包含首批实现优先级和验收方式。
|
||||||
|
- [x] 已完成第一批页面实现:工作台、待我处理、审批详情、个人设置、通知中心。
|
||||||
|
- [x] 已同步受影响 E2E 和视觉截图断言。
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
|
||||||
|
- `git diff --check`
|
||||||
|
- `python3 ./.trellis/scripts/task.py validate 05-30-unified-page-design-review`
|
||||||
|
- `node scripts/qa/check-trellis-governance.mjs`
|
||||||
|
- `cd client && npm run build`
|
||||||
|
- `cd client && npx playwright test tests/e2e/login-portal-ux.spec.ts tests/e2e/pc-core-workflow-visual-wave2.spec.ts tests/e2e/workflow-action-confirm-ui.spec.ts tests/e2e/visual-shell.spec.ts tests/e2e/pc-detail-mobile-visual-wave3.spec.ts tests/e2e/r124-mobile-entry-matrix.spec.ts --workers=1 --reporter=list`
|
||||||
|
- 必要时复跑失败用例对应 spec,并检查关键截图。
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- 不复制竞品品牌、Logo、配色或受保护视觉资产。
|
||||||
|
- 不改变已确认的业务边界:请假/考勤日常动作继续优先由企业微信主办;OA 不重复处理。
|
||||||
|
- 不把当前无关部署脚本变更混入本任务提交。
|
||||||
|
- 不在本轮全面改造采购、合同、项目、资产、付款、应付账款、票据和管理配置等第二/第三批页面。
|
||||||
|
- 不做公网部署或公网可测声明。
|
||||||
|
|
||||||
|
## Technical Notes
|
||||||
|
|
||||||
|
- 主要规范文件候选:
|
||||||
|
- `.trellis/spec/hyb-oa/frontend-react-vite-design.md`
|
||||||
|
- `docs/产品设计规则.md`
|
||||||
|
- 已定位当前明显问题代码:
|
||||||
|
- `client/src/pages/PortalHomePage.tsx` 已移除 `处理最急的一件`,并补常用入口。
|
||||||
|
- `client/src/pages/WorkflowTasksPage.tsx` 已改为 `待处理` / `已处理` Tab 和行内 `处理`。
|
||||||
|
- `client/src/pages/WorkflowTaskDetailPage.tsx` 已把审批动作收敛到右侧单一动作栏。
|
||||||
|
- `client/src/pages/ProfilePage.tsx`、`MobileProfilePage.tsx` 已统一为 `个人资料`、`账号安全`、`通知偏好`、`企业微信绑定`。
|
||||||
|
- `client/src/pages/NotificationsPage.tsx` 已去掉解释型页头和返回工作台按钮,通知操作改为 `查看详情`。
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
- 规范草案完成并经用户确认。
|
||||||
|
- 第一批页面实现完成,且不再出现本轮明确禁止的口号型按钮、旧高级面板和竖向审批动作卡片。
|
||||||
|
- 文档、前端构建、聚焦 Playwright、截图复核和 Trellis governance 全部通过。
|
||||||
|
- 未部署时不得告知用户可以公网测试。
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# 成熟企业页面参照调研
|
||||||
|
|
||||||
|
## 目的
|
||||||
|
|
||||||
|
为 hyb-oa 重新统一页面设计规则选择稳定参照。重点不是复制视觉品牌,而是借鉴成熟中文企业协作/OA 系统在信息架构、按钮命名、动作区位置、列表和详情页处理方式上的共识。
|
||||||
|
|
||||||
|
## 参照候选
|
||||||
|
|
||||||
|
### 1. 飞书审批/飞书工作台 + Ant Design Pro/Ant Design 组件规范(推荐)
|
||||||
|
|
||||||
|
适配原因:
|
||||||
|
|
||||||
|
- 飞书审批小组件公开说明中使用 `待办`、`抄送我`、`已发起`、`发起申请` 四类常见入口,且强调从工作台快速查看审批信息并进入处理页面,适合 hyb-oa 工作台和待处理入口收敛。
|
||||||
|
- 飞书开放平台审批页协议中也按 `待审批`、`已审批` 等常见分类表达,符合中文用户心智。
|
||||||
|
- Ant Design/Ant Design Pro 是中文企业后台常用设计系统,官方文档有列表页、详情页、按钮区、文案、数据展示等成熟规则,可作为页面结构和组件行为的执行基线。
|
||||||
|
- Ant Design 按钮规则明确“一个操作区域最多一个主按钮”、行内操作适合文字按钮/链接按钮、危险操作需要二次确认,这能直接纠正当前审批动作区和工作台口号型按钮问题。
|
||||||
|
|
||||||
|
需要注意:
|
||||||
|
|
||||||
|
- 飞书本身偏协同工具,hyb-oa 是轻 OA/财务/资产系统,不能照搬飞书的信息流和营销化协作入口。
|
||||||
|
- Ant Design 是组件/模式系统,不是业务系统;产品入口和业务边界仍以 hyb-oa 现有事实源为准。
|
||||||
|
|
||||||
|
参考链接:
|
||||||
|
|
||||||
|
- 飞书审批小组件使用手册:https://www.feishu.cn/hc/zh-CN/articles/633391673719-%E9%A3%9E%E4%B9%A6%E5%AE%A1%E6%89%B9-%E5%B0%8F%E7%BB%84%E4%BB%B6%E4%BD%BF%E7%94%A8%E6%89%8B%E5%86%8C
|
||||||
|
- 飞书打开审批页协议:https://open.feishu.cn/document/applink-protocol/supported-protocol/open-an-approval-page?lang=zh-CN
|
||||||
|
- Ant Design 设计模式概览:https://ant.design/docs/spec/overview-cn
|
||||||
|
- Ant Design 按钮规范:https://ant.design/docs/spec/buttons-cn
|
||||||
|
- Ant Design 数据列表规范:https://ant.design/docs/spec/data-list-cn
|
||||||
|
- Ant Design 文案规范:https://ant.design/docs/spec/copywriting-cn
|
||||||
|
|
||||||
|
### 2. 企业微信审批/工作台
|
||||||
|
|
||||||
|
适配原因:
|
||||||
|
|
||||||
|
- hyb-oa 当前已集成企业微信,HR/考勤边界由企业微信主办,用户可能熟悉企业微信入口。
|
||||||
|
|
||||||
|
不推荐作为唯一参照:
|
||||||
|
|
||||||
|
- 企业微信更偏移动端和消息入口,PC 业务台账、财务/合同/资产详情页模式不足。
|
||||||
|
- 业务上已明确企业微信主办 HR/考勤,OA 主办报销、采购、合同、资产、付款等;如果完全仿企业微信,容易继续混淆 OA 和企业微信边界。
|
||||||
|
|
||||||
|
### 3. 钉钉 OA/审批
|
||||||
|
|
||||||
|
适配原因:
|
||||||
|
|
||||||
|
- 国内企业对 `待办`、`已处理`、`发起申请`、`审批详情` 等命名熟悉。
|
||||||
|
|
||||||
|
不推荐作为唯一参照:
|
||||||
|
|
||||||
|
- 钉钉偏大型组织和平台入口,容易把 hyb-oa 带回模块多、入口多、说明多的方向。
|
||||||
|
- hyb-oa 面向小于 10 人的小团队,不需要钉钉式全平台工作台。
|
||||||
|
|
||||||
|
## 初步结论
|
||||||
|
|
||||||
|
推荐锁定:
|
||||||
|
|
||||||
|
- 产品心智参照:飞书审批/飞书工作台。
|
||||||
|
- 组件和页面模式参照:Ant Design Pro/Ant Design。
|
||||||
|
|
||||||
|
落地方式:
|
||||||
|
|
||||||
|
- 入口命名采用成熟中文系统常规词,不再用口号式按钮。
|
||||||
|
- 工作台只展示摘要和真实行动,不再解释普通入口。
|
||||||
|
- 列表页采用标题 + 常用筛选 + 表格/列表 + 操作列。
|
||||||
|
- 详情页采用摘要/证据/历史 + 右侧或底部动作区。
|
||||||
|
- 审批动作采用一个主按钮、少量次按钮和更多菜单,禁止多个动作纵向铺成大卡片。
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"id": "unified-page-design-review",
|
||||||
|
"name": "unified-page-design-review",
|
||||||
|
"title": "统一页面设计规范重审",
|
||||||
|
"description": "统一 PC/移动页面设计规范,锁定成熟企业页面基线,先确认规范再实施页面改造。",
|
||||||
|
"status": "in_progress",
|
||||||
|
"dev_type": null,
|
||||||
|
"scope": "frontend,docs",
|
||||||
|
"package": null,
|
||||||
|
"priority": "P2",
|
||||||
|
"creator": "shunli",
|
||||||
|
"assignee": "shunli",
|
||||||
|
"createdAt": "2026-05-30",
|
||||||
|
"completedAt": null,
|
||||||
|
"branch": null,
|
||||||
|
"base_branch": "main",
|
||||||
|
"worktree_path": null,
|
||||||
|
"commit": null,
|
||||||
|
"pr_url": null,
|
||||||
|
"subtasks": [],
|
||||||
|
"children": [],
|
||||||
|
"parent": null,
|
||||||
|
"relatedFiles": [
|
||||||
|
".trellis/spec/hyb-oa/frontend-react-vite-design.md",
|
||||||
|
".trellis/tasks/05-30-unified-page-design-review/prd.md",
|
||||||
|
".trellis/tasks/05-30-unified-page-design-review/design.md",
|
||||||
|
".trellis/tasks/05-30-unified-page-design-review/research/mature-enterprise-ui-reference.md",
|
||||||
|
"client/src/pages/PortalHomePage.tsx",
|
||||||
|
"client/src/pages/WorkflowTasksPage.tsx",
|
||||||
|
"client/src/pages/WorkflowTaskDetailPage.tsx",
|
||||||
|
"client/src/pages/ProfilePage.tsx",
|
||||||
|
"client/src/pages/NotificationsPage.tsx"
|
||||||
|
],
|
||||||
|
"notes": "",
|
||||||
|
"meta": {}
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ const WELCOME_LINKS: WelcomeLinkItem[] = [
|
|||||||
{
|
{
|
||||||
icon: "request_quote",
|
icon: "request_quote",
|
||||||
label: "提交报销",
|
label: "提交报销",
|
||||||
description: "录入票据和报销说明,查看处理进度",
|
description: "录入票据和报销说明,查看进度",
|
||||||
to: "/reimbursements",
|
to: "/reimbursements",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,8 +25,10 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
|
|||||||
{ path: "/assets", title: "资产", group: "业务" },
|
{ path: "/assets", title: "资产", group: "业务" },
|
||||||
{ path: "/workflow/tasks/", title: "审批详情", group: "工作" },
|
{ path: "/workflow/tasks/", title: "审批详情", group: "工作" },
|
||||||
{ path: "/workflow/tasks", title: "待我处理", group: "工作" },
|
{ path: "/workflow/tasks", title: "待我处理", group: "工作" },
|
||||||
|
{ path: "/notifications", title: "通知", group: "工作" },
|
||||||
{ path: "/mobile/assets/scan", title: "移动资产扫描", group: "移动工作台" },
|
{ path: "/mobile/assets/scan", title: "移动资产扫描", group: "移动工作台" },
|
||||||
{ path: "/mobile/approvals", title: "移动审批", group: "移动工作台" },
|
{ path: "/mobile/approvals", title: "移动审批", group: "移动工作台" },
|
||||||
|
{ path: "/mobile/notifications", title: "移动通知", group: "移动工作台" },
|
||||||
{ path: "/mobile/wecom-approval-archive", title: "移动企微审批历史", group: "移动工作台" },
|
{ path: "/mobile/wecom-approval-archive", title: "移动企微审批历史", group: "移动工作台" },
|
||||||
{ path: "/mobile/capture", title: "移动 PDF 发票上传", group: "移动工作台" },
|
{ path: "/mobile/capture", title: "移动 PDF 发票上传", group: "移动工作台" },
|
||||||
{ path: "/mobile/leave", title: "移动请假", group: "移动工作台" },
|
{ path: "/mobile/leave", title: "移动请假", group: "移动工作台" },
|
||||||
|
|||||||
@@ -660,6 +660,43 @@
|
|||||||
@apply border p-3.5;
|
@apply border p-3.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workflow-action-panel {
|
||||||
|
@apply xl:sticky xl:top-20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-action-bar {
|
||||||
|
@apply flex flex-wrap items-center gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-action-more {
|
||||||
|
@apply relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-action-more > summary {
|
||||||
|
border: 1px solid rgba(203, 213, 225, 0.92);
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
@apply flex h-9 cursor-pointer list-none items-center px-3 text-sm font-medium transition-colors hover:bg-slate-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-action-more > summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-action-more[open] > summary {
|
||||||
|
@apply bg-slate-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-action-more > div,
|
||||||
|
.workflow-action-more > button {
|
||||||
|
border: 1px solid rgba(226, 232, 240, 0.94);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
@apply absolute right-0 top-10 z-20 min-w-28 px-3 py-2 text-left text-sm font-medium text-zinc-800 hover:bg-slate-50;
|
||||||
|
}
|
||||||
|
|
||||||
.notification-inbox {
|
.notification-inbox {
|
||||||
@apply space-y-3;
|
@apply space-y-3;
|
||||||
}
|
}
|
||||||
@@ -822,6 +859,33 @@
|
|||||||
@apply overflow-hidden rounded-lg divide-y divide-slate-200/80;
|
@apply overflow-hidden rounded-lg divide-y divide-slate-200/80;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.portal-quick-entry-grid {
|
||||||
|
@apply grid gap-2 sm:grid-cols-2 xl:grid-cols-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-quick-entry {
|
||||||
|
border: 1px solid rgba(226, 232, 240, 0.94);
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
@apply flex min-h-[64px] items-center gap-3 px-3.5 py-3 transition-colors hover:border-slate-300 hover:bg-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-quick-entry__icon {
|
||||||
|
background: rgba(0, 57, 138, 0.08);
|
||||||
|
color: var(--color-accent-primary);
|
||||||
|
@apply flex size-9 shrink-0 items-center justify-center rounded-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-quick-entry__title {
|
||||||
|
@apply block truncate text-sm font-semibold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-quick-entry__action {
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
@apply mt-0.5 block text-xs font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
.portal-task-row {
|
.portal-task-row {
|
||||||
background: rgba(255, 255, 255, 0.88);
|
background: rgba(255, 255, 255, 0.88);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ export function MobileProfilePage() {
|
|||||||
</dl>
|
</dl>
|
||||||
</SectionPanel>
|
</SectionPanel>
|
||||||
|
|
||||||
<SectionPanel title="密码与安全">
|
<SectionPanel title="账号安全">
|
||||||
<div className="mobile-list-card flex min-h-12 items-center justify-between gap-3 text-sm">
|
<div className="mobile-list-card flex min-h-12 items-center justify-between gap-3 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold text-zinc-900">账号密码</div>
|
<div className="font-semibold text-zinc-900">账号密码</div>
|
||||||
@@ -176,7 +176,7 @@ export function MobileProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
</SectionPanel>
|
</SectionPanel>
|
||||||
|
|
||||||
<SectionPanel title="企业微信消息提醒">
|
<SectionPanel title="通知偏好">
|
||||||
<label className="mobile-list-card flex min-h-12 items-center justify-between gap-3 text-sm text-zinc-800">
|
<label className="mobile-list-card flex min-h-12 items-center justify-between gap-3 text-sm text-zinc-800">
|
||||||
<span>接收企业微信消息提醒</span>
|
<span>接收企业微信消息提醒</span>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const PAGE_SIZE = 20;
|
|||||||
|
|
||||||
const NOTIFICATION_TYPE_OPTIONS = [
|
const NOTIFICATION_TYPE_OPTIONS = [
|
||||||
{ label: "全部", value: "" },
|
{ label: "全部", value: "" },
|
||||||
{ label: "待我处理", value: "workflow.task_assigned" },
|
{ label: "审批提醒", value: "workflow.task_assigned" },
|
||||||
{ label: "报销", value: "reimbursement.approved,reimbursement.rejected,reimbursement.returned,reimbursement.paid" },
|
{ label: "报销", value: "reimbursement.approved,reimbursement.rejected,reimbursement.returned,reimbursement.paid" },
|
||||||
{ label: "请假", value: "leave.approved,leave.rejected" },
|
{ label: "请假", value: "leave.approved,leave.rejected" },
|
||||||
{ label: "采购", value: "purchase_request.approved,purchase_request.rejected" },
|
{ label: "采购", value: "purchase_request.approved,purchase_request.rejected" },
|
||||||
@@ -80,24 +80,17 @@ export function NotificationsPage() {
|
|||||||
return (
|
return (
|
||||||
<ListWorkspace>
|
<ListWorkspace>
|
||||||
<WorkspaceHeader
|
<WorkspaceHeader
|
||||||
eyebrow="通知"
|
|
||||||
title="通知中心"
|
title="通知中心"
|
||||||
subtitle="这里是事件收件箱;需要处理的事项会直达业务详情或审批任务。"
|
|
||||||
actions={
|
actions={
|
||||||
<>
|
<Button
|
||||||
<Button
|
type="button"
|
||||||
type="button"
|
variant="secondary"
|
||||||
variant="secondary"
|
data-testid="notifications-mark-all-read"
|
||||||
data-testid="notifications-mark-all-read"
|
disabled={(listQuery.data?.unreadCount ?? 0) === 0 || markAllReadMutation.isPending}
|
||||||
disabled={(listQuery.data?.unreadCount ?? 0) === 0 || markAllReadMutation.isPending}
|
onClick={() => markAllReadMutation.mutate()}
|
||||||
onClick={() => markAllReadMutation.mutate()}
|
>
|
||||||
>
|
全部标为已读
|
||||||
全部标为已读
|
</Button>
|
||||||
</Button>
|
|
||||||
<Button asChild variant="secondary" data-testid="notifications-back">
|
|
||||||
<Link to="/portal">返回工作台</Link>
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -188,7 +181,7 @@ export function NotificationsPage() {
|
|||||||
<div className="mt-3 flex flex-wrap gap-2">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
{targetLink ? (
|
{targetLink ? (
|
||||||
<Button asChild size="sm" variant="secondary" data-testid="notification-mobile-target-link">
|
<Button asChild size="sm" variant="secondary" data-testid="notification-mobile-target-link">
|
||||||
<Link to={targetLink}>查看</Link>
|
<Link to={targetLink}>查看详情</Link>
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -238,7 +231,7 @@ export function NotificationsPage() {
|
|||||||
<div className="flex shrink-0 flex-wrap gap-2">
|
<div className="flex shrink-0 flex-wrap gap-2">
|
||||||
{targetLink ? (
|
{targetLink ? (
|
||||||
<Button asChild size="sm" variant="secondary" data-testid="notification-target-link">
|
<Button asChild size="sm" variant="secondary" data-testid="notification-target-link">
|
||||||
<Link to={targetLink}>查看</Link>
|
<Link to={targetLink}>查看详情</Link>
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
+117
-23
@@ -38,6 +38,14 @@ type EmptyAction = {
|
|||||||
variant?: "primary" | "secondary" | "ghost";
|
variant?: "primary" | "secondary" | "ghost";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type QuickEntry = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
actionLabel: string;
|
||||||
|
icon: string;
|
||||||
|
};
|
||||||
|
|
||||||
const LEAVE_PORTAL_PERMISSIONS = ["leave.view_own", "leave.view_all", "leave.create", "leave.submit"];
|
const LEAVE_PORTAL_PERMISSIONS = ["leave.view_own", "leave.view_all", "leave.create", "leave.submit"];
|
||||||
const EXPENSE_PORTAL_PERMISSIONS = ["expense.view_own", "expense.view_all", "expense.create", "expense.proxy_manage"];
|
const EXPENSE_PORTAL_PERMISSIONS = ["expense.view_own", "expense.view_all", "expense.create", "expense.proxy_manage"];
|
||||||
const PROCUREMENT_PORTAL_PERMISSIONS = ["procurement.view_own", "procurement.view_all", "procurement.create", "procurement.submit"];
|
const PROCUREMENT_PORTAL_PERMISSIONS = ["procurement.view_own", "procurement.view_all", "procurement.create", "procurement.submit"];
|
||||||
@@ -45,6 +53,10 @@ const TRAVEL_PORTAL_PERMISSIONS = ["travel.view_own", "travel.view_all", "travel
|
|||||||
const APPROVAL_PORTAL_PERMISSIONS = ["expense.approve", "leave.approve", "procurement.approve", "travel.approve"];
|
const APPROVAL_PORTAL_PERMISSIONS = ["expense.approve", "leave.approve", "procurement.approve", "travel.approve"];
|
||||||
const FINANCE_PORTAL_PERMISSIONS = ["payment.batch.manage", "risk.review.manage", "expense.finance.pay"];
|
const FINANCE_PORTAL_PERMISSIONS = ["payment.batch.manage", "risk.review.manage", "expense.finance.pay"];
|
||||||
const SYSTEM_PORTAL_PERMISSIONS = ["system.users", "system.settings", "system.audit.view", "notification.channel.manage"];
|
const SYSTEM_PORTAL_PERMISSIONS = ["system.users", "system.settings", "system.audit.view", "notification.channel.manage"];
|
||||||
|
const CONTRACT_PORTAL_PERMISSIONS = ["project.view_all", "project.manage", "system.settings"];
|
||||||
|
const PROJECT_PORTAL_PERMISSIONS = ["project.view_own", "project.view_all", "project.manage"];
|
||||||
|
const ASSET_PORTAL_PERMISSIONS = ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"];
|
||||||
|
const INVOICE_PORTAL_PERMISSIONS = ["invoice.manage"];
|
||||||
|
|
||||||
const SEVERITY_RANK = {
|
const SEVERITY_RANK = {
|
||||||
high: 3,
|
high: 3,
|
||||||
@@ -398,8 +410,8 @@ function buildEmptyActions(permissions: string[], roleCapabilities: RoleCapabili
|
|||||||
const actions: EmptyAction[] = [];
|
const actions: EmptyAction[] = [];
|
||||||
|
|
||||||
if (!roleCapabilities.isAdminOnly && hasAnyPermission(permissions, EXPENSE_PORTAL_PERMISSIONS)) {
|
if (!roleCapabilities.isAdminOnly && hasAnyPermission(permissions, EXPENSE_PORTAL_PERMISSIONS)) {
|
||||||
actions.push({ key: "new-expense", label: "发起报销", href: "/reimbursements", variant: "primary" });
|
actions.push({ key: "new-expense", label: "新建报销", href: "/reimbursements", variant: "primary" });
|
||||||
actions.push({ key: "my-progress", label: "查看我的进度", href: "/reimbursements", variant: "secondary" });
|
actions.push({ key: "my-progress", label: "查看进度", href: "/reimbursements", variant: "secondary" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (roleCapabilities.canManageSystem) {
|
if (roleCapabilities.canManageSystem) {
|
||||||
@@ -409,6 +421,82 @@ function buildEmptyActions(permissions: string[], roleCapabilities: RoleCapabili
|
|||||||
return actions.slice(0, roleCapabilities.canManageSystem && actions.length > 2 ? 3 : 2);
|
return actions.slice(0, roleCapabilities.canManageSystem && actions.length > 2 ? 3 : 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildQuickEntries(permissions: string[], roleCapabilities: RoleCapabilities) {
|
||||||
|
const entries: QuickEntry[] = [];
|
||||||
|
|
||||||
|
if (!roleCapabilities.isAdminOnly && hasAnyPermission(permissions, EXPENSE_PORTAL_PERMISSIONS)) {
|
||||||
|
entries.push({
|
||||||
|
key: "reimbursements",
|
||||||
|
label: "报销",
|
||||||
|
href: "/reimbursements",
|
||||||
|
actionLabel: hasAnyPermission(permissions, ["expense.create", "expense.proxy_manage"]) ? "新建报销" : "查看报销",
|
||||||
|
icon: "request_quote",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasAnyPermission(permissions, PROCUREMENT_PORTAL_PERMISSIONS)) {
|
||||||
|
entries.push({
|
||||||
|
key: "purchase",
|
||||||
|
label: "采购",
|
||||||
|
href: "/purchase-requests",
|
||||||
|
actionLabel: hasAnyPermission(permissions, ["procurement.create", "procurement.submit"]) ? "新建采购" : "查看采购",
|
||||||
|
icon: "shopping_cart",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasAnyPermission(permissions, CONTRACT_PORTAL_PERMISSIONS)) {
|
||||||
|
entries.push({
|
||||||
|
key: "contracts",
|
||||||
|
label: "合同",
|
||||||
|
href: "/contracts-registry",
|
||||||
|
actionLabel: "查看合同",
|
||||||
|
icon: "description",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasAnyPermission(permissions, PROJECT_PORTAL_PERMISSIONS)) {
|
||||||
|
entries.push({
|
||||||
|
key: "projects",
|
||||||
|
label: "项目",
|
||||||
|
href: "/projects",
|
||||||
|
actionLabel: "查看项目",
|
||||||
|
icon: "workspaces",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasAnyPermission(permissions, ASSET_PORTAL_PERMISSIONS)) {
|
||||||
|
entries.push({
|
||||||
|
key: "assets",
|
||||||
|
label: "资产",
|
||||||
|
href: "/assets",
|
||||||
|
actionLabel: "查看资产",
|
||||||
|
icon: "inventory_2",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roleCapabilities.canUseFinance) {
|
||||||
|
entries.push({
|
||||||
|
key: "payments",
|
||||||
|
label: "付款",
|
||||||
|
href: "/payment-batches",
|
||||||
|
actionLabel: "处理付款",
|
||||||
|
icon: "payments",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasAnyPermission(permissions, INVOICE_PORTAL_PERMISSIONS)) {
|
||||||
|
entries.push({
|
||||||
|
key: "invoices",
|
||||||
|
label: "票据",
|
||||||
|
href: "/invoice-pool",
|
||||||
|
actionLabel: "查看票据",
|
||||||
|
icon: "receipt_long",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries.slice(0, 6);
|
||||||
|
}
|
||||||
|
|
||||||
function sumWorkItemCount(items: WorkItem[]) {
|
function sumWorkItemCount(items: WorkItem[]) {
|
||||||
return items.reduce((total, item) => total + Math.max(1, item.count), 0);
|
return items.reduce((total, item) => total + Math.max(1, item.count), 0);
|
||||||
}
|
}
|
||||||
@@ -419,16 +507,12 @@ function SummaryStrip({
|
|||||||
progressCount,
|
progressCount,
|
||||||
blockerCount,
|
blockerCount,
|
||||||
reminderCount,
|
reminderCount,
|
||||||
primaryItem,
|
|
||||||
emptyActions,
|
|
||||||
}: {
|
}: {
|
||||||
userName: string;
|
userName: string;
|
||||||
actionCount: number;
|
actionCount: number;
|
||||||
progressCount: number;
|
progressCount: number;
|
||||||
blockerCount: number;
|
blockerCount: number;
|
||||||
reminderCount: number;
|
reminderCount: number;
|
||||||
primaryItem: WorkItem | null;
|
|
||||||
emptyActions: EmptyAction[];
|
|
||||||
}) {
|
}) {
|
||||||
const attentionCount = blockerCount + reminderCount;
|
const attentionCount = blockerCount + reminderCount;
|
||||||
const summaryText = actionCount > 0
|
const summaryText = actionCount > 0
|
||||||
@@ -440,23 +524,9 @@ function SummaryStrip({
|
|||||||
return (
|
return (
|
||||||
<section className="portal-work-summary" data-testid="portal-work-summary">
|
<section className="portal-work-summary" data-testid="portal-work-summary">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="portal-work-summary__eyebrow">工作台</div>
|
|
||||||
<div className="portal-work-summary__title">{summaryText}</div>
|
<div className="portal-work-summary__title">{summaryText}</div>
|
||||||
<div className="portal-work-summary__meta">{userName} · 当前工作区</div>
|
<div className="portal-work-summary__meta">{userName} · 当前工作区</div>
|
||||||
</div>
|
</div>
|
||||||
{primaryItem ? (
|
|
||||||
<Button asChild type="button">
|
|
||||||
<Link to={primaryItem.href}>处理最急的一件</Link>
|
|
||||||
</Button>
|
|
||||||
) : emptyActions[0] ? (
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
variant={emptyActions[0].variant === "secondary" || emptyActions[0].variant === "ghost" ? emptyActions[0].variant : "default"}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Link to={emptyActions[0].href}>{emptyActions[0].label}</Link>
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -493,6 +563,30 @@ function WorkItemList({ items }: { items: WorkItem[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function QuickEntryPanel({ entries }: { entries: QuickEntry[] }) {
|
||||||
|
if (!entries.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionPanel title="常用入口">
|
||||||
|
<div className="portal-quick-entry-grid" data-testid="portal-quick-entries">
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<Link className="portal-quick-entry" key={entry.key} to={entry.href}>
|
||||||
|
<span className="portal-quick-entry__icon" aria-hidden="true">
|
||||||
|
<span className="material-symbols-outlined text-[18px]">{entry.icon}</span>
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="portal-quick-entry__title">{entry.label}</span>
|
||||||
|
<span className="portal-quick-entry__action">{entry.actionLabel}</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SectionPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function TodayActionPanel({
|
function TodayActionPanel({
|
||||||
items,
|
items,
|
||||||
emptyActions,
|
emptyActions,
|
||||||
@@ -578,11 +672,11 @@ export function PortalHomePage() {
|
|||||||
roleCapabilities.canManageSystem ||
|
roleCapabilities.canManageSystem ||
|
||||||
isManagerRole(permissions);
|
isManagerRole(permissions);
|
||||||
const todayActionItems = buildTodayActionItems(payload, roleCapabilities);
|
const todayActionItems = buildTodayActionItems(payload, roleCapabilities);
|
||||||
|
const quickEntries = buildQuickEntries(permissions, roleCapabilities);
|
||||||
const progressItems = buildProgressItems(payload);
|
const progressItems = buildProgressItems(payload);
|
||||||
const teamBlockers = isManagerView ? buildTeamBlockers(payload, roleCapabilities) : [];
|
const teamBlockers = isManagerView ? buildTeamBlockers(payload, roleCapabilities) : [];
|
||||||
const managementReminders = buildManagementReminders(payload, roleCapabilities, canSetupWorkspace);
|
const managementReminders = buildManagementReminders(payload, roleCapabilities, canSetupWorkspace);
|
||||||
const emptyActions = buildEmptyActions(permissions, roleCapabilities);
|
const emptyActions = buildEmptyActions(permissions, roleCapabilities);
|
||||||
const primaryItem = todayActionItems[0] ?? null;
|
|
||||||
const actionCount = sumWorkItemCount(todayActionItems);
|
const actionCount = sumWorkItemCount(todayActionItems);
|
||||||
const progressCount = sumWorkItemCount(progressItems);
|
const progressCount = sumWorkItemCount(progressItems);
|
||||||
const blockerCount = sumWorkItemCount(teamBlockers);
|
const blockerCount = sumWorkItemCount(teamBlockers);
|
||||||
@@ -605,10 +699,10 @@ export function PortalHomePage() {
|
|||||||
progressCount={progressCount}
|
progressCount={progressCount}
|
||||||
blockerCount={blockerCount}
|
blockerCount={blockerCount}
|
||||||
reminderCount={reminderCount}
|
reminderCount={reminderCount}
|
||||||
primaryItem={primaryItem}
|
|
||||||
emptyActions={emptyActions}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<QuickEntryPanel entries={quickEntries} />
|
||||||
|
|
||||||
<TodayActionPanel items={todayActionItems} emptyActions={emptyActions} />
|
<TodayActionPanel items={todayActionItems} emptyActions={emptyActions} />
|
||||||
|
|
||||||
<div className="portal-task-secondary-grid">
|
<div className="portal-task-secondary-grid">
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export function ProfilePage() {
|
|||||||
mutationFn: (notificationPushEnabled: boolean) => updateNotificationPreferences({ notificationPushEnabled }),
|
mutationFn: (notificationPushEnabled: boolean) => updateNotificationPreferences({ notificationPushEnabled }),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await refreshMe();
|
await refreshMe();
|
||||||
toast.success("通知设置已更新");
|
toast.success("通知偏好已更新");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(resolveProfileError(error));
|
toast.error(resolveProfileError(error));
|
||||||
@@ -108,16 +108,11 @@ export function ProfilePage() {
|
|||||||
<PageScaffold>
|
<PageScaffold>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="个人设置"
|
title="个人设置"
|
||||||
eyebrow="账号与通知"
|
|
||||||
subtitle="只处理个人资料、安全、通知偏好和企业微信绑定。组织和权限维护统一在管理入口处理。"
|
|
||||||
meta={<span className={accountStatusClassName(me?.status)}>{accountStatusLabel(me?.status)}</span>}
|
meta={<span className={accountStatusClassName(me?.status)}>{accountStatusLabel(me?.status)}</span>}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mx-auto max-w-5xl space-y-5">
|
<div className="mx-auto max-w-5xl space-y-5">
|
||||||
<SectionPanel
|
<SectionPanel title="个人资料">
|
||||||
title="个人资料"
|
|
||||||
description="只展示本人基础信息。如需调整组织、岗位或角色,由管理员在管理入口维护。"
|
|
||||||
>
|
|
||||||
<dl className="grid gap-4 text-sm text-zinc-700 md:grid-cols-2">
|
<dl className="grid gap-4 text-sm text-zinc-700 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-zinc-500">姓名</dt>
|
<dt className="text-zinc-500">姓名</dt>
|
||||||
@@ -138,11 +133,10 @@ export function ProfilePage() {
|
|||||||
</dl>
|
</dl>
|
||||||
</SectionPanel>
|
</SectionPanel>
|
||||||
|
|
||||||
<SectionPanel title="密码与安全" description="修改登录密码,保持账号入口可控。">
|
<SectionPanel title="账号安全">
|
||||||
<div className="flex flex-col gap-3 text-sm text-zinc-600 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 text-sm text-zinc-600 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-zinc-900">账号密码</div>
|
<div className="font-medium text-zinc-900">账号密码</div>
|
||||||
<p className="mt-1">修改密码需要输入当前密码;忘记密码时请从登录页找回。</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Button asChild type="button" variant="secondary">
|
<Button asChild type="button" variant="secondary">
|
||||||
<Link to="/change-password">修改密码</Link>
|
<Link to="/change-password">修改密码</Link>
|
||||||
@@ -150,7 +144,7 @@ export function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
</SectionPanel>
|
</SectionPanel>
|
||||||
|
|
||||||
<SectionPanel title="通知设置" description="选择是否接收企业微信个人应用提醒。">
|
<SectionPanel title="通知偏好">
|
||||||
<div className="space-y-3 text-sm text-zinc-600">
|
<div className="space-y-3 text-sm text-zinc-600">
|
||||||
<label className="flex items-center gap-3 text-zinc-800">
|
<label className="flex items-center gap-3 text-zinc-800">
|
||||||
<input
|
<input
|
||||||
@@ -166,7 +160,7 @@ export function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
</SectionPanel>
|
</SectionPanel>
|
||||||
|
|
||||||
<SectionPanel title="企业微信绑定" description="绑定后可从企业微信工作台直接进入 OA;企业微信只负责入口和提醒。">
|
<SectionPanel title="企业微信绑定">
|
||||||
<div className="space-y-4" data-testid="profile-wecom-card">
|
<div className="space-y-4" data-testid="profile-wecom-card">
|
||||||
{bindNotice ? (
|
{bindNotice ? (
|
||||||
<div className={`rounded-lg border px-4 py-3 text-sm ${noticeClasses(bindNotice.tone)}`} data-testid="profile-wecom-notice">
|
<div className={`rounded-lg border px-4 py-3 text-sm ${noticeClasses(bindNotice.tone)}`} data-testid="profile-wecom-notice">
|
||||||
@@ -221,7 +215,6 @@ export function ProfilePage() {
|
|||||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-5 py-4">
|
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-5 py-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-base font-semibold text-zinc-900">账号详情</div>
|
<div className="text-base font-semibold text-zinc-900">账号详情</div>
|
||||||
<div className="mt-1 text-sm text-zinc-500">组织归属和角色摘要,通常不需要修改。</div>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-zinc-500">展开 / 收起</span>
|
<span className="text-sm text-zinc-500">展开 / 收起</span>
|
||||||
</summary>
|
</summary>
|
||||||
|
|||||||
@@ -157,8 +157,8 @@ function resolveListAction(item: ReimbursementListItem) {
|
|||||||
const taskId = item.pendingWorkflow?.task?.id;
|
const taskId = item.pendingWorkflow?.task?.id;
|
||||||
if (taskId && item.pendingWorkflow?.allowedActions.length) {
|
if (taskId && item.pendingWorkflow?.allowedActions.length) {
|
||||||
return {
|
return {
|
||||||
label: "查看处理",
|
label: "处理",
|
||||||
mobileLabel: "查看处理",
|
mobileLabel: "处理",
|
||||||
to: `/workflow/tasks/${taskId}`,
|
to: `/workflow/tasks/${taskId}`,
|
||||||
primary: false,
|
primary: false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -115,31 +115,7 @@ function isAlreadyProcessedTaskError(error: unknown) {
|
|||||||
return isApiError(error) && error.details?.task === "already_processed";
|
return isApiError(error) && error.details?.task === "already_processed";
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveWorkflowNextActionText(input: {
|
type SecondaryWorkflowAction = "reject" | "return" | "transfer";
|
||||||
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() {
|
export function WorkflowTaskDetailPage() {
|
||||||
const { taskId = "" } = useParams();
|
const { taskId = "" } = useParams();
|
||||||
@@ -152,7 +128,9 @@ export function WorkflowTaskDetailPage() {
|
|||||||
const [transferReason, setTransferReason] = useState("当前事项需转交对应审批人处理,请尽快处理。");
|
const [transferReason, setTransferReason] = useState("当前事项需转交对应审批人处理,请尽快处理。");
|
||||||
const [selectedApprover, setSelectedApprover] = useState("");
|
const [selectedApprover, setSelectedApprover] = useState("");
|
||||||
const [actionError, setActionError] = useState("");
|
const [actionError, setActionError] = useState("");
|
||||||
|
const [activeSecondaryAction, setActiveSecondaryAction] = useState<SecondaryWorkflowAction | null>(null);
|
||||||
const [confirmAction, setConfirmAction] = useState<WorkflowConfirmAction | null>(null);
|
const [confirmAction, setConfirmAction] = useState<WorkflowConfirmAction | null>(null);
|
||||||
|
const [transferConfirmOpen, setTransferConfirmOpen] = useState(false);
|
||||||
|
|
||||||
const detailQuery = useQuery({
|
const detailQuery = useQuery({
|
||||||
enabled: Boolean(taskId),
|
enabled: Boolean(taskId),
|
||||||
@@ -200,6 +178,8 @@ export function WorkflowTaskDetailPage() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ["workflow", "task", taskId] }),
|
queryClient.invalidateQueries({ queryKey: ["workflow", "task", taskId] }),
|
||||||
]);
|
]);
|
||||||
setActionError("");
|
setActionError("");
|
||||||
|
setActiveSecondaryAction(null);
|
||||||
|
setTransferConfirmOpen(false);
|
||||||
const messages: Record<string, string> = { approve: "已通过", reject: "已驳回", return: "已退回", transfer: "已转审" };
|
const messages: Record<string, string> = { approve: "已通过", reject: "已驳回", return: "已退回", transfer: "已转审" };
|
||||||
toast.success(messages[action] ?? "操作成功");
|
toast.success(messages[action] ?? "操作成功");
|
||||||
},
|
},
|
||||||
@@ -240,6 +220,20 @@ export function WorkflowTaskDetailPage() {
|
|||||||
setConfirmAction(action);
|
setConfirmAction(action);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestWorkflowTransfer() {
|
||||||
|
if (!selectedApprover) {
|
||||||
|
setActionError("请选择转审目标");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (transferReason.trim().length < 10) {
|
||||||
|
setActionError("转审理由至少 10 个字");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActionError("");
|
||||||
|
setTransferConfirmOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
if (!taskId) {
|
if (!taskId) {
|
||||||
return (
|
return (
|
||||||
<div className="card-std p-5">
|
<div className="card-std p-5">
|
||||||
@@ -283,23 +277,18 @@ export function WorkflowTaskDetailPage() {
|
|||||||
const canTransfer = allowedActions.has("transfer");
|
const canTransfer = allowedActions.has("transfer");
|
||||||
const allowedActionLabels = detail.allowedActions.map(workflowActionLabel).join(" / ");
|
const allowedActionLabels = detail.allowedActions.map(workflowActionLabel).join(" / ");
|
||||||
const businessStatusLabel = workflowInstanceStatusLabel(detail.workflowInstance?.currentStatus);
|
const businessStatusLabel = workflowInstanceStatusLabel(detail.workflowInstance?.currentStatus);
|
||||||
const nextActionText = resolveWorkflowNextActionText({
|
|
||||||
taskStatus: detail.task.status,
|
|
||||||
entityType: detail.workflowInstance?.entityType,
|
|
||||||
allowedActions: detail.allowedActions,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageScaffold>
|
<PageScaffold>
|
||||||
<EntityOverviewHero
|
<EntityOverviewHero
|
||||||
title={`任务详情 · ${detail.task.stepName}`}
|
title="审批详情"
|
||||||
subtitle="先核对业务摘要和证据,再处理通过、退回、驳回或转审。"
|
|
||||||
meta={(
|
meta={(
|
||||||
<>
|
<>
|
||||||
<StatusBadge status={detail.task.status} />
|
<StatusBadge status={detail.task.status} />
|
||||||
<span className="info-chip">业务状态:{businessStatusLabel}</span>
|
<span className="info-chip">业务状态:{businessStatusLabel}</span>
|
||||||
<span className="info-chip">类型:{entityTitle(detail.workflowInstance?.entityType)}</span>
|
<span className="info-chip">类型:{entityTitle(detail.workflowInstance?.entityType)}</span>
|
||||||
|
<span className="info-chip">节点:{detail.task.stepName}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
actions={(
|
actions={(
|
||||||
@@ -311,20 +300,8 @@ export function WorkflowTaskDetailPage() {
|
|||||||
|
|
||||||
<div className="detail-workspace-grid">
|
<div className="detail-workspace-grid">
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<SectionPanel title="当前状态与审批对象" description="首屏只放判断审批所需的状态、摘要和下一步。">
|
<SectionPanel title="当前状态与审批对象">
|
||||||
<div className="space-y-4" data-testid="workflow-task-overview">
|
<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">
|
<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">{detail.task.stepName}</span>} />
|
||||||
<MetricCard title="业务类型" value={<span className="text-base">{entityTitle(detail.workflowInstance?.entityType)}</span>} />
|
<MetricCard title="业务类型" value={<span className="text-base">{entityTitle(detail.workflowInstance?.entityType)}</span>} />
|
||||||
@@ -444,107 +421,6 @@ export function WorkflowTaskDetailPage() {
|
|||||||
</SectionPanel>
|
</SectionPanel>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<SectionPanel title="审批动作" description="高风险动作会再次确认;驳回、退回和转审需要留下可复核说明。">
|
|
||||||
<div className="detail-action-stack">
|
|
||||||
{actionError ? (
|
|
||||||
<p
|
|
||||||
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
|
|
||||||
data-testid="workflow-task-action-error"
|
|
||||||
>
|
|
||||||
{actionError}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="detail-action-card flex flex-wrap gap-2">
|
|
||||||
<Button
|
|
||||||
data-testid="workflow-task-approve"
|
|
||||||
disabled={!allowedActions.has("approve") || mutation.isPending}
|
|
||||||
onClick={() => requestWorkflowAction("approve")}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
通过
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="detail-action-card space-y-2">
|
|
||||||
<p className="text-sm font-medium text-zinc-800">驳回</p>
|
|
||||||
<Textarea
|
|
||||||
data-testid="workflow-task-reject-comment"
|
|
||||||
onChange={(event) => setRejectComment(event.target.value)}
|
|
||||||
rows={2}
|
|
||||||
value={rejectComment}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
data-testid="workflow-task-reject"
|
|
||||||
variant="secondary"
|
|
||||||
disabled={!allowedActions.has("reject") || mutation.isPending || rejectComment.trim().length < 1}
|
|
||||||
onClick={() => requestWorkflowAction("reject")}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
提交驳回
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="detail-action-card space-y-2">
|
|
||||||
<p className="text-sm font-medium text-zinc-800">退回</p>
|
|
||||||
<Textarea
|
|
||||||
data-testid="workflow-task-return-comment"
|
|
||||||
onChange={(event) => setReturnComment(event.target.value)}
|
|
||||||
rows={2}
|
|
||||||
value={returnComment}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
data-testid="workflow-task-return"
|
|
||||||
variant="secondary"
|
|
||||||
disabled={!allowedActions.has("return") || mutation.isPending || returnComment.trim().length < 1}
|
|
||||||
onClick={() => requestWorkflowAction("return")}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
提交退回
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{canTransfer ? (
|
|
||||||
<div className="detail-action-card space-y-2">
|
|
||||||
<p className="text-sm font-medium text-zinc-800">转审</p>
|
|
||||||
<select
|
|
||||||
className="select-std"
|
|
||||||
data-testid="workflow-task-transfer-approver"
|
|
||||||
onChange={(event) => setSelectedApprover(event.target.value)}
|
|
||||||
value={selectedApprover}
|
|
||||||
>
|
|
||||||
<option value="">请选择目标审批人</option>
|
|
||||||
{(approverQuery.data?.items ?? []).map((item) => (
|
|
||||||
<option key={item.id} value={item.id}>
|
|
||||||
{item.name} ({item.employeeNo})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<Textarea
|
|
||||||
data-testid="workflow-task-transfer-reason"
|
|
||||||
onChange={(event) => setTransferReason(event.target.value)}
|
|
||||||
rows={2}
|
|
||||||
value={transferReason}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
data-testid="workflow-task-transfer"
|
|
||||||
variant="secondary"
|
|
||||||
disabled={
|
|
||||||
!allowedActions.has("transfer") ||
|
|
||||||
mutation.isPending ||
|
|
||||||
!selectedApprover ||
|
|
||||||
transferReason.trim().length < 10
|
|
||||||
}
|
|
||||||
onClick={() => mutation.mutate("transfer")}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
提交转审
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</SectionPanel>
|
|
||||||
|
|
||||||
<SectionPanel title="处理历史">
|
<SectionPanel title="处理历史">
|
||||||
<div className="detail-timeline">
|
<div className="detail-timeline">
|
||||||
{detail.history.length ? (
|
{detail.history.length ? (
|
||||||
@@ -565,16 +441,148 @@ export function WorkflowTaskDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<DetailSideMeta title="任务摘要" description="保留审批留痕所需的节点、角色和时间。">
|
<SectionPanel title="处理" className="workflow-action-panel">
|
||||||
|
<div className="detail-action-stack">
|
||||||
|
{actionError ? (
|
||||||
|
<p
|
||||||
|
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
|
||||||
|
data-testid="workflow-task-action-error"
|
||||||
|
>
|
||||||
|
{actionError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="workflow-action-bar">
|
||||||
|
<Button
|
||||||
|
data-testid="workflow-task-approve"
|
||||||
|
disabled={!allowedActions.has("approve") || mutation.isPending}
|
||||||
|
onClick={() => requestWorkflowAction("approve")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
通过
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
data-testid="workflow-task-return-toggle"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={!allowedActions.has("return") || mutation.isPending}
|
||||||
|
onClick={() => setActiveSecondaryAction(activeSecondaryAction === "return" ? null : "return")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
退回
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
data-testid="workflow-task-reject-toggle"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={!allowedActions.has("reject") || mutation.isPending}
|
||||||
|
onClick={() => setActiveSecondaryAction(activeSecondaryAction === "reject" ? null : "reject")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
驳回
|
||||||
|
</Button>
|
||||||
|
{canTransfer ? (
|
||||||
|
<details className="workflow-action-more">
|
||||||
|
<summary>更多</summary>
|
||||||
|
<button
|
||||||
|
data-testid="workflow-task-transfer-menu-item"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveSecondaryAction("transfer")}
|
||||||
|
>
|
||||||
|
转审
|
||||||
|
</button>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeSecondaryAction === "reject" ? (
|
||||||
|
<div className="detail-action-card space-y-2">
|
||||||
|
<p className="text-sm font-medium text-zinc-800">驳回意见</p>
|
||||||
|
<Textarea
|
||||||
|
data-testid="workflow-task-reject-comment"
|
||||||
|
onChange={(event) => setRejectComment(event.target.value)}
|
||||||
|
rows={3}
|
||||||
|
value={rejectComment}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
data-testid="workflow-task-reject"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={!allowedActions.has("reject") || mutation.isPending || rejectComment.trim().length < 1}
|
||||||
|
onClick={() => requestWorkflowAction("reject")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
确认驳回
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeSecondaryAction === "return" ? (
|
||||||
|
<div className="detail-action-card space-y-2">
|
||||||
|
<p className="text-sm font-medium text-zinc-800">退回意见</p>
|
||||||
|
<Textarea
|
||||||
|
data-testid="workflow-task-return-comment"
|
||||||
|
onChange={(event) => setReturnComment(event.target.value)}
|
||||||
|
rows={3}
|
||||||
|
value={returnComment}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
data-testid="workflow-task-return"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={!allowedActions.has("return") || mutation.isPending || returnComment.trim().length < 1}
|
||||||
|
onClick={() => requestWorkflowAction("return")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
确认退回
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeSecondaryAction === "transfer" && canTransfer ? (
|
||||||
|
<div className="detail-action-card space-y-2">
|
||||||
|
<p className="text-sm font-medium text-zinc-800">转审</p>
|
||||||
|
<select
|
||||||
|
className="select-std"
|
||||||
|
data-testid="workflow-task-transfer-approver"
|
||||||
|
onChange={(event) => setSelectedApprover(event.target.value)}
|
||||||
|
value={selectedApprover}
|
||||||
|
>
|
||||||
|
<option value="">请选择目标审批人</option>
|
||||||
|
{(approverQuery.data?.items ?? []).map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.name} ({item.employeeNo})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Textarea
|
||||||
|
data-testid="workflow-task-transfer-reason"
|
||||||
|
onChange={(event) => setTransferReason(event.target.value)}
|
||||||
|
rows={3}
|
||||||
|
value={transferReason}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
data-testid="workflow-task-transfer"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={
|
||||||
|
!allowedActions.has("transfer") ||
|
||||||
|
mutation.isPending ||
|
||||||
|
!selectedApprover ||
|
||||||
|
transferReason.trim().length < 10
|
||||||
|
}
|
||||||
|
onClick={requestWorkflowTransfer}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
确认转审
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</SectionPanel>
|
||||||
|
|
||||||
|
<DetailSideMeta title="任务摘要">
|
||||||
<div>节点:{detail.task.stepName}</div>
|
<div>节点:{detail.task.stepName}</div>
|
||||||
<div>实例状态:{workflowInstanceStatusLabel(detail.workflowInstance?.currentStatus)}</div>
|
<div>实例状态:{workflowInstanceStatusLabel(detail.workflowInstance?.currentStatus)}</div>
|
||||||
<div>当前审批角色:{detail.task.assigneeRole ?? "-"}</div>
|
<div>当前审批角色:{detail.task.assigneeRole ?? "-"}</div>
|
||||||
<div>创建时间:{formatDateTime(detail.task.createdAt)}</div>
|
<div>创建时间:{formatDateTime(detail.task.createdAt)}</div>
|
||||||
<div>完成时间:{formatDateTime(detail.task.completedAt)}</div>
|
<div>完成时间:{formatDateTime(detail.task.completedAt)}</div>
|
||||||
<div>发起时间:{formatDateTime(detail.workflowInstance?.startedAt)}</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>
|
</DetailSideMeta>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -600,6 +608,20 @@ export function WorkflowTaskDetailPage() {
|
|||||||
await mutation.mutateAsync(confirmAction);
|
await mutation.mutateAsync(confirmAction);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={transferConfirmOpen}
|
||||||
|
onOpenChange={setTransferConfirmOpen}
|
||||||
|
title="确认转审审批?"
|
||||||
|
description={`事项:${entityTitle(detail.workflowInstance?.entityType)} · ${detail.task.stepName}。转审后当前任务会交给目标审批人处理,系统会写入审批历史。`}
|
||||||
|
confirmLabel="确认转审"
|
||||||
|
variant="default"
|
||||||
|
testId="workflow-transfer-confirm-dialog"
|
||||||
|
confirmTestId="workflow-transfer-confirm-submit"
|
||||||
|
cancelTestId="workflow-transfer-confirm-cancel"
|
||||||
|
onConfirm={async () => {
|
||||||
|
await mutation.mutateAsync("transfer");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Link, useSearchParams } from "react-router-dom";
|
import { Link, useSearchParams } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
@@ -15,11 +14,9 @@ import {
|
|||||||
FilterBar,
|
FilterBar,
|
||||||
ListWorkspace,
|
ListWorkspace,
|
||||||
PrimaryTable,
|
PrimaryTable,
|
||||||
StatStrip,
|
|
||||||
WorkspaceEmptyState,
|
WorkspaceEmptyState,
|
||||||
WorkspaceHeader,
|
WorkspaceHeader,
|
||||||
} from "../components/patterns/ListWorkspace";
|
} from "../components/patterns/ListWorkspace";
|
||||||
import { MetricCard } from "../components/patterns/MetricCard";
|
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||||
@@ -210,7 +207,7 @@ export function WorkflowTasksPage() {
|
|||||||
const status = normalizeStatusFilter(searchParams.get("status"));
|
const status = normalizeStatusFilter(searchParams.get("status"));
|
||||||
const entityType = normalizeEntityFilter(searchParams.get("entityType"));
|
const entityType = normalizeEntityFilter(searchParams.get("entityType"));
|
||||||
const page = Number(searchParams.get("page") ?? "1") || 1;
|
const page = Number(searchParams.get("page") ?? "1") || 1;
|
||||||
const [showAdvanced, setShowAdvanced] = useState(() => status !== "pending" || entityType !== "all");
|
const taskTab = status === "pending" ? "pending" : "processed";
|
||||||
|
|
||||||
function updateSearchParams(next: {
|
function updateSearchParams(next: {
|
||||||
status?: WorkflowStatusFilter;
|
status?: WorkflowStatusFilter;
|
||||||
@@ -248,9 +245,7 @@ export function WorkflowTasksPage() {
|
|||||||
const totalPages = Math.max(1, Math.ceil((query.data?.total ?? 0) / PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil((query.data?.total ?? 0) / PAGE_SIZE));
|
||||||
const taskItems = query.data?.items ?? [];
|
const taskItems = query.data?.items ?? [];
|
||||||
const isDefaultTaskView = status === "pending" && entityType === "all";
|
const isDefaultTaskView = status === "pending" && entityType === "all";
|
||||||
const advancedVisible = showAdvanced || !isDefaultTaskView;
|
|
||||||
const pendingTotal = summaryQuery.data?.pendingTotal ?? "-";
|
const pendingTotal = summaryQuery.data?.pendingTotal ?? "-";
|
||||||
const overdueTotal = summaryQuery.data?.overdueTotal ?? "-";
|
|
||||||
const pendingEntitySummary =
|
const pendingEntitySummary =
|
||||||
(summaryQuery.data?.byEntityType ?? [])
|
(summaryQuery.data?.byEntityType ?? [])
|
||||||
.filter((item) => item.pendingTotal > 0)
|
.filter((item) => item.pendingTotal > 0)
|
||||||
@@ -260,111 +255,85 @@ export function WorkflowTasksPage() {
|
|||||||
? formatWaitingDuration(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 (
|
return (
|
||||||
<ListWorkspace>
|
<ListWorkspace>
|
||||||
<WorkspaceHeader
|
<WorkspaceHeader title="待我处理" />
|
||||||
eyebrow="审批"
|
|
||||||
title="待我处理"
|
|
||||||
actions={
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
data-testid="workflow-tasks-advanced-toggle"
|
|
||||||
onClick={advancedVisible ? returnToPendingTasks : openProcessedRecords}
|
|
||||||
>
|
|
||||||
{advancedVisible ? "回到待处理" : "已处理记录"}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FilterBar className="ops-command-bar !p-3 md:!p-3.5">
|
<FilterBar className="ops-command-bar !p-3 md:!p-3.5">
|
||||||
<div className="ops-command-bar__row">
|
<div className="ops-command-bar__row">
|
||||||
<div className="flex min-w-0 flex-wrap items-center gap-3" data-testid="workflow-tasks-focus">
|
<div className="flex min-w-0 flex-col gap-3 md:flex-row md:items-center">
|
||||||
<span className="badge-neutral">待处理</span>
|
<Tabs
|
||||||
<span className="text-2xl font-semibold text-zinc-900">{pendingTotal}</span>
|
data-testid="workflow-tasks-status-tabs"
|
||||||
<span className="text-sm text-zinc-500">
|
value={taskTab}
|
||||||
最早:{earliestPendingWaiting}
|
onValueChange={(value) =>
|
||||||
</span>
|
updateSearchParams({
|
||||||
{summaryQuery.data?.overdueTotal ? (
|
status: value === "pending" ? "pending" : "completed",
|
||||||
<span className="rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-700">
|
page: 1,
|
||||||
超时 {summaryQuery.data.overdueTotal}
|
})
|
||||||
</span>
|
}
|
||||||
) : null}
|
>
|
||||||
|
<TabsList className="ops-command-bar__tabs !h-auto !justify-start !bg-slate-50">
|
||||||
|
<TabsTrigger data-testid="workflow-tasks-tab-pending" value="pending">
|
||||||
|
待处理
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger data-testid="workflow-tasks-tab-processed" value="processed">
|
||||||
|
已处理
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center gap-3" data-testid="workflow-tasks-focus">
|
||||||
|
<span className="text-sm font-medium text-zinc-900">待处理 {pendingTotal}</span>
|
||||||
|
<span className="text-sm text-zinc-500">{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">
|
||||||
|
超时 {summaryQuery.data.overdueTotal}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ops-command-bar__actions">
|
<div className="ops-command-bar__actions">
|
||||||
<span className="text-xs text-zinc-500">业务:{pendingEntitySummary}</span>
|
<span className="hidden text-xs text-zinc-500 xl:inline">业务:{pendingEntitySummary}</span>
|
||||||
</div>
|
{taskTab === "processed" ? (
|
||||||
</div>
|
<>
|
||||||
</FilterBar>
|
<label className="ops-filter-label" htmlFor="workflow-status-filter">
|
||||||
|
记录
|
||||||
{advancedVisible ? (
|
|
||||||
<div className="space-y-3" data-testid="workflow-tasks-advanced-panel">
|
|
||||||
<div data-testid="workflow-task-summary">
|
|
||||||
<StatStrip columns={4} className="ops-status-strip">
|
|
||||||
<MetricCard title="待办总数" value={pendingTotal} />
|
|
||||||
<MetricCard title="超时待办" value={overdueTotal} tone="warning" />
|
|
||||||
<MetricCard title="最早待处理" value={earliestPendingWaiting} />
|
|
||||||
<MetricCard
|
|
||||||
title="业务分布"
|
|
||||||
value={<span className="block truncate text-base">{pendingEntitySummary}</span>}
|
|
||||||
/>
|
|
||||||
</StatStrip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FilterBar className="ops-command-bar !p-3 md:!p-3.5">
|
|
||||||
<div className="ops-command-bar__row">
|
|
||||||
<Tabs
|
|
||||||
data-testid="workflow-tasks-status-tabs"
|
|
||||||
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_HISTORY_STATUS_OPTIONS.map((item) => (
|
|
||||||
<TabsTrigger
|
|
||||||
data-testid={`workflow-tasks-tab-${item.value}`}
|
|
||||||
key={item.value}
|
|
||||||
value={item.value}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
<div className="ops-command-bar__actions">
|
|
||||||
<label className="ops-filter-label" htmlFor="workflow-entity-type-filter">
|
|
||||||
业务类型
|
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="workflow-entity-type-filter"
|
id="workflow-status-filter"
|
||||||
className="select-sm min-w-32"
|
className="select-sm min-w-28"
|
||||||
data-testid="workflow-tasks-entity-filter"
|
data-testid="workflow-tasks-processed-status-filter"
|
||||||
value={entityType}
|
value={status === "pending" ? "completed" : status}
|
||||||
onChange={(event) =>
|
onChange={(event) => updateSearchParams({ status: event.target.value as WorkflowStatusFilter, page: 1 })}
|
||||||
updateSearchParams({ entityType: event.target.value as WorkflowEntityFilter, page: 1 })
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{WORKFLOW_ENTITY_OPTIONS.map((item) => (
|
{WORKFLOW_HISTORY_STATUS_OPTIONS.map((item) => (
|
||||||
<option key={item.value} value={item.value}>
|
<option key={item.value} value={item.value}>
|
||||||
{item.label}
|
{item.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</>
|
||||||
</div>
|
) : null}
|
||||||
</FilterBar>
|
<label className="ops-filter-label" htmlFor="workflow-entity-type-filter">
|
||||||
|
业务
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="workflow-entity-type-filter"
|
||||||
|
className="select-sm min-w-32"
|
||||||
|
data-testid="workflow-tasks-entity-filter"
|
||||||
|
value={entityType}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateSearchParams({ entityType: event.target.value as WorkflowEntityFilter, page: 1 })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{WORKFLOW_ENTITY_OPTIONS.map((item) => (
|
||||||
|
<option key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
</FilterBar>
|
||||||
|
|
||||||
{query.isLoading ? <TableSkeleton columns={4} rows={5} /> : null}
|
{query.isLoading ? <TableSkeleton columns={4} rows={5} /> : null}
|
||||||
|
|
||||||
@@ -421,7 +390,7 @@ export function WorkflowTasksPage() {
|
|||||||
data-testid={TEST_IDS.workflow.detailLink}
|
data-testid={TEST_IDS.workflow.detailLink}
|
||||||
>
|
>
|
||||||
<Link to={`/workflow/tasks/${task.id}`}>
|
<Link to={`/workflow/tasks/${task.id}`}>
|
||||||
{task.status === "pending" ? "查看处理" : "查看详情"}
|
{task.status === "pending" ? "处理" : "查看详情"}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -482,7 +451,7 @@ export function WorkflowTasksPage() {
|
|||||||
data-testid={TEST_IDS.workflow.detailLink}
|
data-testid={TEST_IDS.workflow.detailLink}
|
||||||
>
|
>
|
||||||
<Link to={`/workflow/tasks/${task.id}`}>
|
<Link to={`/workflow/tasks/${task.id}`}>
|
||||||
{task.status === "pending" ? "查看处理" : "查看详情"}
|
{task.status === "pending" ? "处理" : "查看详情"}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -591,8 +591,8 @@ test("个人设置默认只展示账号通知和企业微信,不展示权限 c
|
|||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "个人设置" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "个人设置" })).toBeVisible();
|
||||||
await expect(page.getByRole("heading", { name: "个人资料" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "个人资料" })).toBeVisible();
|
||||||
await expect(page.getByRole("heading", { name: "密码与安全" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "账号安全" })).toBeVisible();
|
||||||
await expect(page.getByRole("heading", { name: "通知设置" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "通知偏好" })).toBeVisible();
|
||||||
await expect(page.getByRole("heading", { name: "企业微信绑定" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "企业微信绑定" })).toBeVisible();
|
||||||
await expect(page.getByText("账号详情")).toBeVisible();
|
await expect(page.getByText("账号详情")).toBeVisible();
|
||||||
await expect(page.getByRole("link", { name: "查看员工档案" })).toHaveCount(0);
|
await expect(page.getByRole("link", { name: "查看员工档案" })).toHaveCount(0);
|
||||||
|
|||||||
@@ -330,9 +330,8 @@ test("PC 核心工作流 Wave2 视觉截图", async ({ page }) => {
|
|||||||
await expect(page.locator(".ops-command-bar").first()).toBeVisible();
|
await expect(page.locator(".ops-command-bar").first()).toBeVisible();
|
||||||
await expect(page.getByTestId("workflow-tasks-focus")).toBeVisible();
|
await expect(page.getByTestId("workflow-tasks-focus")).toBeVisible();
|
||||||
await expect(page.getByTestId("workflow-task-summary")).toHaveCount(0);
|
await expect(page.getByTestId("workflow-task-summary")).toHaveCount(0);
|
||||||
await expect(page.getByTestId("workflow-tasks-status-tabs")).toHaveCount(0);
|
await expect(page.getByTestId("workflow-tasks-status-tabs")).toBeVisible();
|
||||||
await page.getByTestId("workflow-tasks-advanced-toggle").click();
|
await page.getByTestId("workflow-tasks-tab-processed").click();
|
||||||
await expect(page.getByTestId("workflow-tasks-advanced-panel")).toBeVisible();
|
await expect(page.getByTestId("workflow-tasks-processed-status-filter")).toBeVisible();
|
||||||
await expect(page.getByTestId("workflow-task-summary")).toBeVisible();
|
|
||||||
await capture(page, "04-workflow-tasks");
|
await capture(page, "04-workflow-tasks");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1070,12 +1070,19 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
|
|||||||
await page.screenshot({ path: screenshotPath("01-pc-reimbursement-detail.png"), fullPage: true });
|
await page.screenshot({ path: screenshotPath("01-pc-reimbursement-detail.png"), fullPage: true });
|
||||||
|
|
||||||
await page.goto("/workflow/tasks/task-wave3");
|
await page.goto("/workflow/tasks/task-wave3");
|
||||||
await expect(page.getByRole("heading", { name: /任务详情/ })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "审批详情" })).toBeVisible();
|
||||||
await expect(page.getByTestId("workflow-task-next-action")).toBeVisible();
|
await expect(page.getByTestId("workflow-task-next-action")).toHaveCount(0);
|
||||||
await expect(page.getByText("审批动作")).toBeVisible();
|
await expect(page.getByText("审批动作")).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("heading", { name: "处理", exact: true })).toBeVisible();
|
||||||
await expect(page.getByTestId("workflow-task-overview")).toBeVisible();
|
await expect(page.getByTestId("workflow-task-overview")).toBeVisible();
|
||||||
await page.screenshot({ path: screenshotPath("02-pc-workflow-detail.png"), fullPage: true });
|
await page.screenshot({ path: screenshotPath("02-pc-workflow-detail.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.goto("/projects");
|
await page.goto("/projects");
|
||||||
await expect(page.getByRole("heading", { name: "项目", exact: true })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "项目", exact: true })).toBeVisible();
|
||||||
await expect(page.getByText("默认只看我负责/我参与")).toBeVisible();
|
await expect(page.getByText("默认只看我负责/我参与")).toBeVisible();
|
||||||
@@ -1102,13 +1109,8 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
|
|||||||
await page.getByTestId("contracts-registry-row").getByRole("button", { name: "归档" }).click();
|
await page.getByTestId("contracts-registry-row").getByRole("button", { name: "归档" }).click();
|
||||||
await expect(page.getByTestId("action-confirm-dialog")).toContainText("确认归档");
|
await expect(page.getByTestId("action-confirm-dialog")).toContainText("确认归档");
|
||||||
await page.getByTestId("action-confirm-cancel").click();
|
await page.getByTestId("action-confirm-cancel").click();
|
||||||
|
await expect(page.getByTestId("action-confirm-dialog")).toHaveCount(0);
|
||||||
await page.screenshot({ path: screenshotPath("04-pc-contracts-registry.png"), fullPage: true });
|
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("05-pc-notifications.png"), fullPage: true });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Wave3 移动关键页视觉截图", async ({ page }) => {
|
test("Wave3 移动关键页视觉截图", async ({ page }) => {
|
||||||
@@ -1137,7 +1139,7 @@ test("Wave3 移动关键页视觉截图", async ({ page }) => {
|
|||||||
await page.screenshot({ path: screenshotPath("06-mobile-reimbursement-detail.png"), fullPage: true });
|
await page.screenshot({ path: screenshotPath("06-mobile-reimbursement-detail.png"), fullPage: true });
|
||||||
|
|
||||||
await page.goto("/mobile/approvals");
|
await page.goto("/mobile/approvals");
|
||||||
await expect(page.getByRole("heading", { name: "待办", exact: true })).toBeVisible();
|
await expect(page.locator("h1", { hasText: "待我处理" })).toBeVisible();
|
||||||
await expect(page.getByTestId("mobile-approvals-focus")).toBeVisible();
|
await expect(page.getByTestId("mobile-approvals-focus")).toBeVisible();
|
||||||
await expect(page.getByTestId("mobile-approvals-list")).toBeVisible();
|
await expect(page.getByTestId("mobile-approvals-list")).toBeVisible();
|
||||||
await expect(page.locator("table")).toHaveCount(0);
|
await expect(page.locator("table")).toHaveCount(0);
|
||||||
|
|||||||
@@ -551,7 +551,7 @@ test.describe("R124 企业微信移动入口矩阵", () => {
|
|||||||
await expect(mobile.page).toHaveURL(/\/mobile\/profile$/);
|
await expect(mobile.page).toHaveURL(/\/mobile\/profile$/);
|
||||||
await expect(mobile.page.getByTestId("mobile-profile-page")).toBeVisible();
|
await expect(mobile.page.getByTestId("mobile-profile-page")).toBeVisible();
|
||||||
await expect(mobile.page.getByTestId("mobile-profile-wecom-card")).toBeVisible();
|
await expect(mobile.page.getByTestId("mobile-profile-wecom-card")).toBeVisible();
|
||||||
await expect(mobile.page.getByRole("heading", { name: "密码与安全" })).toBeVisible();
|
await expect(mobile.page.getByRole("heading", { name: "账号安全" })).toBeVisible();
|
||||||
await expect(mobile.page.getByRole("heading", { name: "组织与角色摘要" })).toBeVisible();
|
await expect(mobile.page.getByRole("heading", { name: "组织与角色摘要" })).toBeVisible();
|
||||||
await expect(mobile.page.getByText("active", { exact: true })).toHaveCount(0);
|
await expect(mobile.page.getByText("active", { exact: true })).toHaveCount(0);
|
||||||
await expect(mobile.page.getByText("manager", { exact: true })).toHaveCount(0);
|
await expect(mobile.page.getByText("manager", { exact: true })).toHaveCount(0);
|
||||||
|
|||||||
@@ -399,7 +399,7 @@ test.describe("visual shell", () => {
|
|||||||
|
|
||||||
await page.goto("/reimbursements?status=reviewing&scope=all");
|
await page.goto("/reimbursements?status=reviewing&scope=all");
|
||||||
await expect(page.getByTestId("reimbursements-table")).toBeVisible();
|
await expect(page.getByTestId("reimbursements-table")).toBeVisible();
|
||||||
await expect(page.getByRole("link", { name: "查看处理" })).toBeVisible();
|
await expect(page.getByTestId("reimbursement-detail-link").filter({ hasText: "处理" })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "批量操作" })).toHaveCount(0);
|
await expect(page.getByRole("button", { name: "批量操作" })).toHaveCount(0);
|
||||||
await expect(page.getByRole("button", { name: "批量通过" })).toHaveCount(0);
|
await expect(page.getByRole("button", { name: "批量通过" })).toHaveCount(0);
|
||||||
await expect(page.getByRole("button", { name: "批量驳回" })).toHaveCount(0);
|
await expect(page.getByRole("button", { name: "批量驳回" })).toHaveCount(0);
|
||||||
@@ -427,7 +427,7 @@ test.describe("visual shell", () => {
|
|||||||
|
|
||||||
await expect(page.getByTestId("workflow-tasks-mobile-list")).toBeVisible();
|
await expect(page.getByTestId("workflow-tasks-mobile-list")).toBeVisible();
|
||||||
await expect(page.getByTestId("workflow-tasks-table")).toBeHidden();
|
await expect(page.getByTestId("workflow-tasks-table")).toBeHidden();
|
||||||
await expect(page.getByRole("link", { name: "查看" })).toBeVisible();
|
await expect(page.getByRole("link", { name: "处理" })).toBeVisible();
|
||||||
|
|
||||||
await attachScreenshot(page, testInfo, "r97-workflow-tasks-mobile.png");
|
await attachScreenshot(page, testInfo, "r97-workflow-tasks-mobile.png");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -299,12 +299,14 @@ test.describe("R7E 审批动作二次确认", () => {
|
|||||||
await page.getByTestId("workflow-action-confirm-cancel").click();
|
await page.getByTestId("workflow-action-confirm-cancel").click();
|
||||||
expect(actionCalls.approve).toBe(0);
|
expect(actionCalls.approve).toBe(0);
|
||||||
|
|
||||||
|
await page.getByTestId("workflow-task-reject-toggle").click();
|
||||||
await page.getByTestId("workflow-task-reject-comment").fill("资料不符合要求,请重新发起。");
|
await page.getByTestId("workflow-task-reject-comment").fill("资料不符合要求,请重新发起。");
|
||||||
await page.getByTestId("workflow-task-reject").click();
|
await page.getByTestId("workflow-task-reject").click();
|
||||||
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("流程将终止");
|
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("流程将终止");
|
||||||
await page.getByTestId("workflow-action-confirm-cancel").click();
|
await page.getByTestId("workflow-action-confirm-cancel").click();
|
||||||
expect(actionCalls.reject).toBe(0);
|
expect(actionCalls.reject).toBe(0);
|
||||||
|
|
||||||
|
await page.getByTestId("workflow-task-return-toggle").click();
|
||||||
await page.getByTestId("workflow-task-return-comment").fill("请补充附件后重新提交。");
|
await page.getByTestId("workflow-task-return-comment").fill("请补充附件后重新提交。");
|
||||||
await page.getByTestId("workflow-task-return").click();
|
await page.getByTestId("workflow-task-return").click();
|
||||||
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("回到申请人修改态");
|
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("回到申请人修改态");
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ async function mockWorkflowBoundaryApis(page: Page) {
|
|||||||
return actionCalls;
|
return actionCalls;
|
||||||
}
|
}
|
||||||
|
|
||||||
test("审批列表边界:企微主办请假只进入查看处理", async ({ page }) => {
|
test("审批列表边界:企微主办请假只进入处理详情", async ({ page }) => {
|
||||||
const actionCalls = await mockWorkflowBoundaryApis(page);
|
const actionCalls = await mockWorkflowBoundaryApis(page);
|
||||||
|
|
||||||
await login(page, MANAGER);
|
await login(page, MANAGER);
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户