优化PC核心工作流视觉 Wave2(05-27-pc-core-workflow-visual-wave2)
这个提交包含在:
@@ -32,6 +32,51 @@ or hidden according to the active Issue; do not silently re-enable them.
|
||||
- Trial-facing PC lists must hide obvious TEST/SMOKE records by default when a
|
||||
page-level toggle exists; the toggle may reveal them for troubleshooting.
|
||||
|
||||
## Convention: PC Operation List Density
|
||||
|
||||
**What**: PC list/workflow pages that combine status metrics, tabs/filters,
|
||||
range controls, export and table/list content should reuse the shared operation
|
||||
classes in `client/src/index.css`:
|
||||
|
||||
- `ops-status-strip` for compact status metrics.
|
||||
- `ops-command-bar`, `ops-command-bar__row`, `ops-command-bar__tabs`,
|
||||
`ops-command-bar__actions` and `ops-filter-label` for tabs, filters and
|
||||
secondary actions.
|
||||
- `ops-table-focus` for the primary desktop table container.
|
||||
- `ops-inline-alert` for temporary mode hints such as batch operation notices.
|
||||
|
||||
**Why**: This keeps high-frequency PC pages dense and predictable without
|
||||
recreating page-local Tailwind command bars. It also lets `frontend-design`
|
||||
change the visual treatment once while preserving component contracts.
|
||||
|
||||
**Example**:
|
||||
|
||||
```tsx
|
||||
<StatStrip columns={4} className="ops-status-strip">
|
||||
<MetricCard title="待办总数" value={summary.pendingTotal} />
|
||||
</StatStrip>
|
||||
|
||||
<FilterBar className="ops-command-bar !p-3 md:!p-3.5">
|
||||
<div className="ops-command-bar__row">
|
||||
<Tabs value={status}>
|
||||
<TabsList className="ops-command-bar__tabs !h-auto !justify-start !bg-slate-50">
|
||||
<TabsTrigger value="pending">待处理</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="ops-command-bar__actions">...</div>
|
||||
</div>
|
||||
</FilterBar>
|
||||
|
||||
<PrimaryTable className="hidden md:block ops-table-focus">...</PrimaryTable>
|
||||
```
|
||||
|
||||
**Tests Required**:
|
||||
|
||||
- `cd client && npm run typecheck`
|
||||
- `cd client && npm run build`
|
||||
- Focused Playwright screenshot coverage when changing login, Portal, primary
|
||||
PC list pages or command-bar/table styling.
|
||||
|
||||
## Scenario: Auth Pages And PWA Updates
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "检查前端变更是否遵守视觉裁决、认证页和验证要求。"}
|
||||
{"file": ".trellis/spec/frontend/index.md", "reason": "检查前端包入口和视觉权威边界。"}
|
||||
{"file": ".trellis/spec/guides/index.md", "reason": "检查共享复用和跨层影响。"}
|
||||
@@ -0,0 +1,3 @@
|
||||
{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "前端视觉和认证页布局约束,明确 frontend-design 为视觉裁决来源。"}
|
||||
{"file": ".trellis/spec/frontend/index.md", "reason": "前端包入口和当前视觉权威边界。"}
|
||||
{"file": ".trellis/spec/guides/index.md", "reason": "共享复用与跨层思考入口,避免重复造局部样式系统。"}
|
||||
@@ -0,0 +1,33 @@
|
||||
# 实施记录
|
||||
|
||||
## 实施策略
|
||||
|
||||
1. 先增加少量 PC 工作台共享样式和视觉 token,避免四个页面各自堆 Tailwind。
|
||||
2. 登录页保持现有认证能力,只重排视觉层级和文案。
|
||||
3. 工作台优先压缩解释文字,重排“今日待处理 + 快捷动作 + 动态 + 二级入口”。
|
||||
4. 报销列表和审批待办统一命令栏和列表/表格主视觉。
|
||||
5. 完成后执行前端 typecheck、build、Trellis governance、diff check,并补视觉截图证据。
|
||||
|
||||
## 风险控制
|
||||
|
||||
- 不改 API、schema、权限、状态机、审批规则或企业微信配置。
|
||||
- 不写公网业务数据。
|
||||
- 如果视觉改动影响移动路由,必须在本轮验证中说明。
|
||||
|
||||
## 实施结果
|
||||
|
||||
- 新增 PC 操作台共享样式:`ops-status-strip`、`ops-command-bar`、`ops-table-focus`、`ops-inline-alert`,用于收敛状态指标、筛选/操作区和表格主视觉。
|
||||
- 登录页与找回密码页复用新的 `auth-panel` / `auth-shell`,保留账号密码、邮箱验证码、企业微信入口、忘记密码、密码显隐和 PWA 手动刷新提示。
|
||||
- Portal 工作台压缩说明文字,保留角色差异和权限入口,收紧常用动作、待处理、团队状态和通知区域密度。
|
||||
- 报销列表和审批待办统一为紧凑命令栏 + 状态条 + 表格主视觉,保留范围切换、导出、批量操作和审批快捷入口。
|
||||
- 新增 `client/tests/e2e/pc-core-workflow-visual-wave2.spec.ts`,mock API 覆盖登录页、Portal、报销列表和审批待办,并生成截图证据。
|
||||
- 将 PC 操作列表密度约定沉淀到 `.trellis/spec/hyb-oa/frontend-react-vite-design.md`。
|
||||
|
||||
## 验证记录
|
||||
|
||||
- `cd client && npm run typecheck`:通过。
|
||||
- `cd client && npm run build`:通过。
|
||||
- `cd client && npx playwright test tests/e2e/pc-core-workflow-visual-wave2.spec.ts --workers=1 --reporter=list`:通过,截图输出到 `output/playwright/pc-core-workflow-visual-wave2/`。
|
||||
- `node scripts/qa/check-trellis-governance.mjs`:通过。
|
||||
- `python3 ./.trellis/scripts/task.py validate 05-27-05-27-pc-core-workflow-visual-wave2`:通过。
|
||||
- `git diff --check`:通过。
|
||||
@@ -0,0 +1,74 @@
|
||||
# PC 核心工作流视觉重塑 Wave2
|
||||
|
||||
## Goal
|
||||
|
||||
按 `frontend-design` 重新收敛 PC 核心入口视觉和交互层级,让登录页、工作台、报销列表和审批待办从“通用中后台模板”转向“精密、低噪音、高密度的小团队工作台”。
|
||||
|
||||
## Start Confirmation
|
||||
|
||||
- 用户在 2026-05-27 确认“按照你的建议执行”。
|
||||
- 本任务是前端视觉与交互改进,不改变后端 API、权限模型、审批规则、状态机、数据库 schema、企业微信配置或公网真实数据。
|
||||
|
||||
## Scope
|
||||
|
||||
- 登录页:精简登录方式层级和提示信息,强化“工作台入口”感,不改登录能力。
|
||||
- PC 工作台 `/portal`:压缩解释性文案,突出今日待处理、快捷动作、动态和二级入口。
|
||||
- 报销列表 `/reimbursements`:收敛指标、筛选、范围、导出和批量操作的视觉层级,让表格成为主角。
|
||||
- 审批待办 `/workflow/tasks`:统一为紧凑任务控制台,减少说明感,强化待处理/已处理/批量操作。
|
||||
- 共享样式:在现有 React/Vite/Tailwind 与组件基础上添加少量可复用 PC 工作台样式,避免为单页自造一套完整 UI。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 不改移动专项页面,除非共享样式自然影响且可验证。
|
||||
- 不改报销详情、审批详情、通知中心、财务/AP/票据池和 C 类管理页;这些进入 Wave3/Wave4。
|
||||
- 不新增业务能力、不新增字段、不写公网业务数据、不部署生产配置。
|
||||
- 不把 `docs/产品设计规则.md` 重新扩展为视觉规则;视觉裁决继续以 `frontend-design` 为准。
|
||||
|
||||
## Design Direction
|
||||
|
||||
- Tone:precision operations console,克制、精密、低噪音、高密度。
|
||||
- Composition:少大卡片,强命令栏,表格/列表为主角;状态和动作比解释文字更重要。
|
||||
- Copy:删除或弱化“为何这么设计”的提示,保留业务下一步和风险提示。
|
||||
- Motion:保留轻量进入动画,不引入夸张动效。
|
||||
- Density:PC 端允许更紧凑,移动端不作为本轮主改范围。
|
||||
|
||||
## Requirements
|
||||
|
||||
- 登录页继续支持账号密码、邮箱验证码、企业微信入口、忘记密码和 PWA 更新提示。
|
||||
- 登录页不能恢复 PWA 自动刷新清空表单的问题。
|
||||
- 工作台继续按角色展示不同内容,不泄露无权限入口。
|
||||
- 报销列表继续支持状态切换、范围切换、代理、导出、批量审批、删除草稿和分页。
|
||||
- 审批待办继续支持审批/驳回/退回/转审入口和已处理查看。
|
||||
- 页面不得展示完整账号、Secret、token、真实票据、合同正文、银行资料、完整本地路径或 raw payload。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] 四个核心入口视觉更统一:登录、工作台、报销列表、审批待办。
|
||||
- [x] 解释性文案明显减少;核心动作和状态更突出。
|
||||
- [x] PC 表格/列表页的命令区更紧凑,不牺牲现有业务能力。
|
||||
- [x] `cd client && npm run typecheck` 通过。
|
||||
- [x] `cd client && npm run build` 通过。
|
||||
- [x] 相关 Playwright/视觉截图至少覆盖登录页、工作台、报销列表和审批待办。
|
||||
- [x] `node scripts/qa/check-trellis-governance.mjs` 与 `git diff --check` 通过。
|
||||
|
||||
## Test Plan
|
||||
|
||||
```bash
|
||||
cd client && npm run typecheck
|
||||
cd client && npm run build
|
||||
node scripts/qa/check-trellis-governance.mjs
|
||||
git diff --check
|
||||
```
|
||||
|
||||
按可用本地服务补充 Playwright 视觉截图:
|
||||
|
||||
```bash
|
||||
cd client && npx playwright test <focused visual specs> --workers=1 --reporter=list
|
||||
```
|
||||
|
||||
## Technical Notes
|
||||
|
||||
- 前端设计 skill:`frontend-design`
|
||||
- 前端 spec:`.trellis/spec/hyb-oa/frontend-react-vite-design.md`
|
||||
- 产品体验事实:`docs/产品设计规则.md`
|
||||
- 现有证据参考:`output/playwright/pc-frontend-visual-consistency-wave1/`、`output/playwright/oafront3-pc-readonly-postdeploy/`
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "05-27-pc-core-workflow-visual-wave2",
|
||||
"name": "05-27-pc-core-workflow-visual-wave2",
|
||||
"title": "PC核心工作流视觉重塑 Wave2",
|
||||
"description": "按 frontend-design 收敛登录页、PC 工作台、报销列表和审批待办的视觉层级与操作密度。",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": "client: auth layout, portal, reimbursements, workflow tasks visual refinement",
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "shunli",
|
||||
"assignee": "shunli",
|
||||
"createdAt": "2026-05-27",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -15,13 +15,13 @@ export function AuthPanel({ title, subtitle, children, footer, className, "data-
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"relative w-full overflow-hidden rounded-2xl border border-slate-200/80 bg-white/95 p-6 shadow-[0_24px_80px_rgba(15,23,42,0.10)] ring-1 ring-white/70 backdrop-blur sm:p-8",
|
||||
"auth-panel relative w-full overflow-hidden p-6 sm:p-7",
|
||||
className,
|
||||
)}
|
||||
data-testid={testId}
|
||||
>
|
||||
<div aria-hidden="true" className="absolute inset-x-0 top-0 h-1 bg-[linear-gradient(90deg,#00398a,#0f766e)]" />
|
||||
<div className="space-y-7">
|
||||
<div aria-hidden="true" className="auth-panel__accent" />
|
||||
<div className="space-y-6">
|
||||
<AppBrand compact />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold leading-7 text-slate-950">{title}</h1>
|
||||
|
||||
@@ -9,6 +9,7 @@ export function MetricCard({
|
||||
href,
|
||||
tone = "neutral",
|
||||
ariaLabel,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
value: ReactNode;
|
||||
@@ -16,6 +17,7 @@ export function MetricCard({
|
||||
href?: string;
|
||||
tone?: "neutral" | "warning" | "danger";
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const content = (
|
||||
<div className="relative z-[1]">
|
||||
@@ -28,22 +30,23 @@ export function MetricCard({
|
||||
</div>
|
||||
);
|
||||
|
||||
const className = cn(
|
||||
const rootClassName = cn(
|
||||
"metric-card",
|
||||
tone === "warning" && "metric-card--warning",
|
||||
tone === "danger" && "metric-card--danger",
|
||||
className,
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link to={href} aria-label={ariaLabel} className={className}>
|
||||
<Link to={href} aria-label={ariaLabel} className={rootClassName}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div aria-label={ariaLabel} className={className}>
|
||||
<div aria-label={ariaLabel} className={rootClassName}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
|
||||
+173
-19
@@ -49,6 +49,69 @@
|
||||
@apply space-y-5;
|
||||
}
|
||||
|
||||
.auth-shell {
|
||||
background:
|
||||
radial-gradient(circle at 20% 12%, rgba(0, 57, 138, 0.10), transparent 26rem),
|
||||
radial-gradient(circle at 78% 78%, rgba(19, 168, 184, 0.10), transparent 24rem),
|
||||
linear-gradient(180deg, #f9fbfd 0%, #eff4f8 58%, #e6edf3 100%);
|
||||
}
|
||||
|
||||
.auth-shell__grid {
|
||||
opacity: 0.68;
|
||||
background-image:
|
||||
linear-gradient(rgba(15, 23, 42, 0.052) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(15, 23, 42, 0.052) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
mask-image: linear-gradient(180deg, black, transparent 88%);
|
||||
}
|
||||
|
||||
.auth-shell__rail {
|
||||
background: linear-gradient(180deg, transparent, rgba(0, 57, 138, 0.34), rgba(19, 168, 184, 0.28), transparent);
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
border: 1px solid rgba(203, 213, 225, 0.92);
|
||||
border-radius: var(--radius-lg);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.96)),
|
||||
#ffffff;
|
||||
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.12), 0 1px 0 rgba(255, 255, 255, 0.82) inset;
|
||||
}
|
||||
|
||||
.auth-panel__accent {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 4px;
|
||||
background: linear-gradient(180deg, var(--color-accent-primary), var(--color-accent-secondary));
|
||||
}
|
||||
|
||||
.auth-mode-switch {
|
||||
border: 1px solid rgba(203, 213, 225, 0.88);
|
||||
background: rgba(241, 245, 249, 0.74);
|
||||
@apply grid grid-cols-2 gap-1 rounded-lg p-1;
|
||||
}
|
||||
|
||||
.auth-mode-switch__item {
|
||||
@apply h-9 rounded-md text-sm font-medium transition;
|
||||
}
|
||||
|
||||
.auth-mode-switch__item--active {
|
||||
color: var(--color-accent-primary);
|
||||
background: #ffffff;
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.auth-mode-switch__item--idle {
|
||||
color: var(--color-text-secondary);
|
||||
@apply hover:text-slate-950;
|
||||
}
|
||||
|
||||
.auth-side-entry {
|
||||
border: 1px solid rgba(203, 213, 225, 0.88);
|
||||
background: rgba(248, 250, 252, 0.82);
|
||||
@apply rounded-lg px-3.5 py-3 text-sm leading-6;
|
||||
}
|
||||
|
||||
.workbench-hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@@ -59,6 +122,97 @@
|
||||
@apply flex flex-col gap-4 px-5 py-5 md:flex-row md:items-end md:justify-between md:px-6;
|
||||
}
|
||||
|
||||
.ops-status-strip {
|
||||
@apply grid gap-3;
|
||||
}
|
||||
|
||||
.ops-status-strip .metric-card {
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-xs);
|
||||
@apply px-3.5 py-3.5;
|
||||
}
|
||||
|
||||
.ops-status-strip .metric-card::before {
|
||||
width: 2px;
|
||||
}
|
||||
|
||||
.ops-status-strip .metric-card__title {
|
||||
@apply text-xs font-semibold;
|
||||
}
|
||||
|
||||
.ops-status-strip .metric-card__value {
|
||||
@apply mt-1 text-lg;
|
||||
}
|
||||
|
||||
.ops-status-strip .metric-card__note {
|
||||
@apply mt-1 text-xs leading-5;
|
||||
}
|
||||
|
||||
.ops-command-bar {
|
||||
border-color: rgba(203, 213, 225, 0.92);
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
box-shadow: var(--shadow-xs);
|
||||
@apply p-3 md:p-3.5;
|
||||
}
|
||||
|
||||
.ops-command-bar__row {
|
||||
@apply flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between;
|
||||
}
|
||||
|
||||
.ops-command-bar__tabs {
|
||||
@apply flex w-full flex-wrap justify-start gap-1 rounded-lg border border-slate-200 bg-slate-50 p-1 xl:w-auto;
|
||||
}
|
||||
|
||||
.ops-command-bar__actions {
|
||||
@apply flex w-full flex-wrap items-center gap-2 xl:w-auto xl:justify-end;
|
||||
}
|
||||
|
||||
.ops-filter-label {
|
||||
color: var(--color-text-secondary);
|
||||
@apply text-xs font-semibold;
|
||||
}
|
||||
|
||||
.ops-inline-alert {
|
||||
border-radius: var(--radius-lg);
|
||||
@apply border px-3.5 py-2.5 text-sm leading-6;
|
||||
}
|
||||
|
||||
.ops-table-focus {
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.ops-table-focus table {
|
||||
min-width: 760px;
|
||||
}
|
||||
|
||||
.ops-table-focus thead,
|
||||
.ops-table-focus .table-header {
|
||||
background: rgba(248, 250, 252, 0.96);
|
||||
}
|
||||
|
||||
.ops-table-focus th,
|
||||
.ops-table-focus .table-th {
|
||||
color: var(--color-text-tertiary);
|
||||
@apply px-4 py-3 text-xs font-semibold;
|
||||
}
|
||||
|
||||
.ops-table-focus td,
|
||||
.ops-table-focus .table-td,
|
||||
.ops-table-focus .table-td-strong {
|
||||
@apply px-4 py-3 text-sm;
|
||||
}
|
||||
|
||||
.ops-table-focus tbody tr:hover {
|
||||
background: rgba(248, 250, 252, 0.92);
|
||||
}
|
||||
|
||||
.ops-table-focus .table-td-strong {
|
||||
color: var(--color-text-primary);
|
||||
@apply font-semibold;
|
||||
}
|
||||
|
||||
.workbench-hero::after {
|
||||
display: none;
|
||||
}
|
||||
@@ -312,9 +466,9 @@
|
||||
.portal-list-card {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid rgba(226, 232, 240, 0.94);
|
||||
border-radius: var(--radius-lg);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-xs);
|
||||
@apply block px-3.5 py-3.5 transition duration-150;
|
||||
@apply block px-3.5 py-3 transition duration-150;
|
||||
}
|
||||
|
||||
.portal-list-card:hover {
|
||||
@@ -328,21 +482,21 @@
|
||||
}
|
||||
|
||||
.portal-list-card--compact {
|
||||
border-radius: var(--radius-lg);
|
||||
@apply px-3.5 py-3.5;
|
||||
border-radius: var(--radius-sm);
|
||||
@apply px-3.5 py-3;
|
||||
}
|
||||
|
||||
.portal-action-stack {
|
||||
@apply grid gap-3 sm:grid-cols-2 xl:grid-cols-1;
|
||||
@apply grid gap-2 sm:grid-cols-2 xl:grid-cols-1;
|
||||
}
|
||||
|
||||
.portal-action-tile {
|
||||
min-height: 100%;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border: 1px solid rgba(226, 232, 240, 0.94);
|
||||
border-radius: var(--radius-lg);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-xs);
|
||||
@apply px-3.5 py-3.5 text-left transition duration-150;
|
||||
@apply px-3 py-3 text-left transition duration-150;
|
||||
}
|
||||
|
||||
.portal-action-tile:hover {
|
||||
@@ -358,13 +512,13 @@
|
||||
}
|
||||
|
||||
.portal-action-tile--compact {
|
||||
@apply px-3.5 py-3.5;
|
||||
@apply px-3 py-3;
|
||||
}
|
||||
|
||||
.portal-action-icon {
|
||||
background: rgba(0, 57, 138, 0.08);
|
||||
color: var(--color-accent-primary);
|
||||
@apply flex size-10 items-center justify-center rounded-xl;
|
||||
@apply flex size-9 items-center justify-center rounded-lg;
|
||||
}
|
||||
|
||||
.portal-action-icon--muted {
|
||||
@@ -375,19 +529,19 @@
|
||||
.portal-status-card {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid rgba(226, 232, 240, 0.94);
|
||||
border-radius: var(--radius-panel);
|
||||
@apply px-4 py-4;
|
||||
border-radius: var(--radius-sm);
|
||||
@apply px-3.5 py-3;
|
||||
}
|
||||
|
||||
.portal-team-list {
|
||||
border: 1px solid rgba(226, 232, 240, 0.94);
|
||||
background: rgba(255, 255, 255, 0.64);
|
||||
@apply overflow-hidden rounded-2xl divide-y divide-slate-200/80;
|
||||
@apply overflow-hidden rounded-lg divide-y divide-slate-200/80;
|
||||
}
|
||||
|
||||
.portal-team-row {
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
@apply flex min-h-[64px] items-center gap-3 px-4 py-3;
|
||||
@apply flex min-h-[56px] items-center gap-3 px-3.5 py-2.5;
|
||||
}
|
||||
|
||||
.portal-panel-footer {
|
||||
@@ -396,15 +550,15 @@
|
||||
}
|
||||
|
||||
.portal-insight-tabs__list {
|
||||
@apply h-10 rounded-full border-slate-200 bg-slate-50 p-1;
|
||||
@apply h-9 rounded-lg border-slate-200 bg-slate-50 p-1;
|
||||
}
|
||||
|
||||
.portal-insight-tabs__content {
|
||||
@apply mt-4;
|
||||
@apply mt-3;
|
||||
}
|
||||
|
||||
.portal-secondary-drawer {
|
||||
@apply p-4 md:p-5;
|
||||
@apply p-3.5 md:p-4;
|
||||
}
|
||||
|
||||
.portal-secondary-summary {
|
||||
@@ -417,15 +571,15 @@
|
||||
}
|
||||
|
||||
.portal-secondary-drawer__body {
|
||||
@apply mt-4 space-y-5;
|
||||
@apply mt-4 space-y-4;
|
||||
}
|
||||
|
||||
.portal-secondary-compact-grid {
|
||||
@apply grid gap-4 xl:grid-cols-2;
|
||||
@apply grid gap-3 xl:grid-cols-2;
|
||||
}
|
||||
|
||||
.portal-compact-group {
|
||||
@apply rounded-xl border border-slate-200 bg-white/80 px-3.5 py-3.5;
|
||||
@apply rounded-lg border border-slate-200 bg-white/80 px-3.5 py-3;
|
||||
}
|
||||
|
||||
.portal-compact-group__title {
|
||||
|
||||
@@ -6,21 +6,17 @@ export function AuthLayout() {
|
||||
const { updateReady, applyUpdate, dismissUpdate } = usePwaUpdatePrompt();
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen overflow-hidden bg-[#f4f7fa] px-4 py-8 sm:px-6 lg:px-10">
|
||||
<div className="auth-shell relative min-h-screen overflow-hidden px-4 py-8 sm:px-6 lg:px-10">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 opacity-70"
|
||||
style={{
|
||||
backgroundImage: "linear-gradient(rgba(15, 23, 42, 0.045) 1px, transparent 1px), linear-gradient(90deg, rgba(15, 23, 42, 0.045) 1px, transparent 1px)",
|
||||
backgroundSize: "44px 44px",
|
||||
}}
|
||||
className="auth-shell__grid pointer-events-none absolute inset-0"
|
||||
/>
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 h-1 bg-[linear-gradient(90deg,#00398a,#0f766e)]" />
|
||||
<div className="relative mx-auto flex min-h-[calc(100vh-4rem)] max-w-[448px] flex-col justify-center">
|
||||
<div aria-hidden="true" className="auth-shell__rail pointer-events-none absolute inset-y-8 left-[max(1rem,calc((100vw-520px)/2-88px))] hidden w-px lg:block" />
|
||||
<div className="relative mx-auto flex min-h-[calc(100vh-4rem)] max-w-[460px] flex-col justify-center">
|
||||
{updateReady ? (
|
||||
<div className="mb-4 rounded-xl border border-primary/20 bg-primary/5 px-4 py-3 text-sm text-slate-700" data-testid="auth-pwa-update-notice">
|
||||
<div className="mb-4 rounded-lg border border-primary/20 bg-white/95 px-4 py-3 text-sm text-slate-700 shadow-sm" data-testid="auth-pwa-update-notice">
|
||||
<div className="font-medium text-slate-900">系统有新版本</div>
|
||||
<div className="mt-1 leading-6">完成当前输入后刷新页面即可更新,当前表单不会被自动打断。</div>
|
||||
<div className="mt-1 leading-6">刷新由你确认,当前输入不会被自动清空。</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button size="sm" type="button" onClick={applyUpdate}>
|
||||
现在刷新
|
||||
|
||||
@@ -61,7 +61,7 @@ export function ForgotPasswordPage() {
|
||||
try {
|
||||
await forgotPassword({ email: normalizedEmail });
|
||||
setCodeRequested(true);
|
||||
setMessage("如账号可用,验证码将发送到企业邮箱;请在有效期内完成重置,并查看免提醒邮件。");
|
||||
setMessage("如账号可用,验证码将发送到企业邮箱;10 分钟内有效,请同时查看免提醒邮件。");
|
||||
} catch (err) {
|
||||
if (isApiError(err)) {
|
||||
setError(mapApiErrorMessage(err.code, err.details, err.message));
|
||||
@@ -140,7 +140,7 @@ export function ForgotPasswordPage() {
|
||||
{!emailDeliveryReady ? (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="flex gap-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm leading-6 text-amber-800"
|
||||
className="flex gap-3 rounded-lg border border-amber-200 bg-amber-50 px-3.5 py-2.5 text-sm leading-6 text-amber-800"
|
||||
data-testid="forgot-password-email-delivery-status"
|
||||
>
|
||||
<MailCheck aria-hidden="true" className="mt-0.5 h-4 w-4 flex-none" />
|
||||
@@ -214,13 +214,13 @@ export function ForgotPasswordPage() {
|
||||
) : null}
|
||||
|
||||
{message ? (
|
||||
<div className="mt-4 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700" data-testid="forgot-password-message">
|
||||
<div className="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 px-3.5 py-2.5 text-sm text-emerald-700" data-testid="forgot-password-message">
|
||||
{message}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="mt-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600" data-testid="forgot-password-error">
|
||||
<div className="mt-4 rounded-lg border border-red-200 bg-red-50 px-3.5 py-2.5 text-sm text-red-600" data-testid="forgot-password-error">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
+13
-13
@@ -166,7 +166,7 @@ export function LoginPage() {
|
||||
await requestEmailLoginCode({ email });
|
||||
setEmailCodeRequested(true);
|
||||
setEmailCodeCooldown(60);
|
||||
setEmailCodeMessage("如账号可用,验证码将发送到企业邮箱;请同时查看免提醒邮件。");
|
||||
setEmailCodeMessage("如账号可用,验证码将发送到企业邮箱;10 分钟内有效,请同时查看免提醒邮件。");
|
||||
} catch (err) {
|
||||
if (isApiError(err)) {
|
||||
setEmailCodeError(mapApiErrorMessage(err.code, err.details, err.message));
|
||||
@@ -260,7 +260,7 @@ export function LoginPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600">{error}</div> : null}
|
||||
{error ? <div className="rounded-lg border border-red-200 bg-red-50 px-3.5 py-2.5 text-sm text-red-600">{error}</div> : null}
|
||||
|
||||
<Button data-testid="login-submit" className="w-full" disabled={submitting} type="submit">
|
||||
{submitting ? "登录中..." : "登录"}
|
||||
@@ -309,7 +309,7 @@ export function LoginPage() {
|
||||
{!emailDeliveryReady ? (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="flex gap-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm leading-6 text-amber-800"
|
||||
className="flex gap-3 rounded-lg border border-amber-200 bg-amber-50 px-3.5 py-2.5 text-sm leading-6 text-amber-800"
|
||||
data-testid="login-email-delivery-status"
|
||||
>
|
||||
<MailCheck aria-hidden="true" className="mt-0.5 h-4 w-4 flex-none" />
|
||||
@@ -341,13 +341,13 @@ export function LoginPage() {
|
||||
</div>
|
||||
|
||||
{emailCodeMessage ? (
|
||||
<div className="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700" data-testid="login-email-code-message">
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 px-3.5 py-2.5 text-sm text-emerald-700" data-testid="login-email-code-message">
|
||||
{emailCodeMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{emailCodeError ? (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600" data-testid="login-email-code-error">
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3.5 py-2.5 text-sm text-red-600" data-testid="login-email-code-error">
|
||||
{emailCodeError}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -366,10 +366,10 @@ export function LoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthPanel title="登录工作台" subtitle={showWecomPrimary ? "企业微信进入优先" : loginMode === "email_code" ? "邮箱验证码登录" : "账号密码登录"}>
|
||||
<div className="space-y-5">
|
||||
<AuthPanel title="登录工作台" subtitle={showWecomPrimary ? "企业微信优先入口" : "账号、邮箱或企业微信"}>
|
||||
<div className="space-y-4">
|
||||
{wecomNotice ? (
|
||||
<div className={`rounded-xl border px-4 py-3 text-sm ${noticeClasses(wecomNotice.tone)}`} data-testid="login-wecom-notice">
|
||||
<div className={`rounded-lg border px-3.5 py-2.5 text-sm ${noticeClasses(wecomNotice.tone)}`} data-testid="login-wecom-notice">
|
||||
<div className="font-medium">{wecomNotice.title}</div>
|
||||
<div className="mt-1 leading-6">{wecomNotice.description}</div>
|
||||
</div>
|
||||
@@ -381,7 +381,7 @@ export function LoginPage() {
|
||||
{wecomConfigQuery.isLoading ? "准备中..." : "企业微信一键进入"}
|
||||
</Button>
|
||||
{wecomConfig && !wecomEntry ? (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-6 text-amber-700">
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-6 text-amber-700">
|
||||
企业微信入口不可用,请用账号密码。
|
||||
</div>
|
||||
) : null}
|
||||
@@ -398,10 +398,10 @@ export function LoginPage() {
|
||||
) : null}
|
||||
|
||||
{!showWecomPrimary ? (
|
||||
<div className="grid grid-cols-2 gap-1 rounded-xl border border-slate-200 bg-slate-100/80 p-1" data-testid="login-mode-switch">
|
||||
<div className="auth-mode-switch" data-testid="login-mode-switch">
|
||||
<button
|
||||
aria-pressed={loginMode === "password"}
|
||||
className={`h-10 rounded-lg text-sm font-medium transition ${loginMode === "password" ? "bg-white text-primary shadow-sm" : "text-slate-500 hover:text-slate-900"}`}
|
||||
className={`auth-mode-switch__item ${loginMode === "password" ? "auth-mode-switch__item--active" : "auth-mode-switch__item--idle"}`}
|
||||
data-testid="login-mode-password"
|
||||
type="button"
|
||||
onClick={() => selectLoginMode("password")}
|
||||
@@ -410,7 +410,7 @@ export function LoginPage() {
|
||||
</button>
|
||||
<button
|
||||
aria-pressed={loginMode === "email_code"}
|
||||
className={`h-10 rounded-lg text-sm font-medium transition ${loginMode === "email_code" ? "bg-white text-primary shadow-sm" : "text-slate-500 hover:text-slate-900"}`}
|
||||
className={`auth-mode-switch__item ${loginMode === "email_code" ? "auth-mode-switch__item--active" : "auth-mode-switch__item--idle"}`}
|
||||
data-testid="login-mode-email-code"
|
||||
type="button"
|
||||
onClick={() => selectLoginMode("email_code")}
|
||||
@@ -436,7 +436,7 @@ export function LoginPage() {
|
||||
|
||||
{showBrowserWecomNotice ? (
|
||||
<div
|
||||
className="flex gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm leading-6 text-slate-600"
|
||||
className="auth-side-entry flex gap-3 text-slate-600"
|
||||
data-testid="wecom-entry-browser-notice"
|
||||
>
|
||||
<Building2 aria-hidden="true" className="mt-0.5 h-4 w-4 flex-none text-primary" />
|
||||
|
||||
@@ -388,7 +388,7 @@ function QuickLinksPanel({ items }: { items: PortalHomePayload["quickLinks"] })
|
||||
const safeItems = Array.isArray(items) ? items : [];
|
||||
|
||||
return (
|
||||
<SectionPanel title="常用入口">
|
||||
<SectionPanel title="入口">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{safeItems.length ? safeItems.map((item) => (
|
||||
<Link key={item.key} to={item.href} className="portal-list-card text-sm font-medium text-slate-700">
|
||||
@@ -400,13 +400,13 @@ function QuickLinksPanel({ items }: { items: PortalHomePayload["quickLinks"] })
|
||||
);
|
||||
}
|
||||
|
||||
function SecondaryCardsPanel({ title, description, cards }: { title: string; description: string; cards: PortalHomePayload["cards"] }) {
|
||||
function SecondaryCardsPanel({ title, cards }: { title: string; cards: PortalHomePayload["cards"] }) {
|
||||
if (!cards.length) {
|
||||
return <EmptyStateCard title={title} description="暂无统计项。" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionPanel title={title} description={description}>
|
||||
<SectionPanel title={title}>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{cards.map((card) => (
|
||||
<MetricCard
|
||||
@@ -589,7 +589,7 @@ function GettingStartedCard({
|
||||
return (
|
||||
<SectionPanel
|
||||
title="快速上手"
|
||||
description="完成基础资料。"
|
||||
description="基础资料"
|
||||
actions={(
|
||||
<button
|
||||
type="button"
|
||||
@@ -740,7 +740,7 @@ function TodoAggregatePanel({ items }: { items: TodoAggregateItem[] }) {
|
||||
{item.severity === "high" ? <span className="badge-error">高优先</span> : null}
|
||||
{item.severity === "warning" ? <span className="badge-warning">关注</span> : null}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-primary">进入处理</div>
|
||||
<div className="mt-1 text-sm text-primary">进入</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -748,7 +748,7 @@ function TodoAggregatePanel({ items }: { items: TodoAggregateItem[] }) {
|
||||
</div>
|
||||
{hiddenCount > 0 ? (
|
||||
<div className="portal-panel-footer">
|
||||
<span>还有 {hiddenCount} 项已收起。</span>
|
||||
<span>另有 {hiddenCount} 项。</span>
|
||||
<Link to="/todos" className="font-medium text-primary hover:underline">查看全部待办</Link>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -784,7 +784,7 @@ function TeamStatusBoardPanel({ items }: { items: PortalTeamStatusItem[] }) {
|
||||
</div>
|
||||
{hiddenCount > 0 ? (
|
||||
<div className="portal-panel-footer">
|
||||
<span>已收起 {hiddenCount} 位成员。</span>
|
||||
<span>另有 {hiddenCount} 位。</span>
|
||||
<Link to="/team" className="font-medium text-primary hover:underline">查看全部</Link>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -1053,7 +1053,7 @@ export function PortalHomePage() {
|
||||
key: "approval",
|
||||
icon: "fact_check",
|
||||
label: "处理审批",
|
||||
description: "进入审批中心查看待办",
|
||||
description: "待办处理",
|
||||
to: "/workflow/tasks",
|
||||
});
|
||||
}
|
||||
@@ -1062,7 +1062,7 @@ export function PortalHomePage() {
|
||||
key: "finance",
|
||||
icon: "payments",
|
||||
label: "财务处理",
|
||||
description: "查看付款和风险待处理",
|
||||
description: "付款 / 风险",
|
||||
to: payload.financeSummary.href,
|
||||
});
|
||||
}
|
||||
@@ -1071,7 +1071,7 @@ export function PortalHomePage() {
|
||||
key: "system",
|
||||
icon: "admin_panel_settings",
|
||||
label: "系统管理",
|
||||
description: "账号、配置和审计入口",
|
||||
description: "账号 / 配置",
|
||||
to: "/people",
|
||||
});
|
||||
}
|
||||
@@ -1080,7 +1080,7 @@ export function PortalHomePage() {
|
||||
key: "leave",
|
||||
icon: "event_available",
|
||||
label: "提交请假",
|
||||
description: "直接进入请假申请页",
|
||||
description: "请假申请",
|
||||
to: "/leave-requests?action=create",
|
||||
});
|
||||
}
|
||||
@@ -1089,7 +1089,7 @@ export function PortalHomePage() {
|
||||
key: "expense",
|
||||
icon: "request_quote",
|
||||
label: "提交报销",
|
||||
description: "进入报销工作台创建单据",
|
||||
description: "报销单据",
|
||||
to: "/reimbursements",
|
||||
});
|
||||
}
|
||||
@@ -1097,7 +1097,7 @@ export function PortalHomePage() {
|
||||
key: "attendance",
|
||||
icon: "schedule",
|
||||
label: "考勤汇总",
|
||||
description: "查看企微打卡摘要和 OA 修正",
|
||||
description: "企微摘要 / OA 修正",
|
||||
to: "/attendance",
|
||||
});
|
||||
if (payload.weeklyReport || payload.weeklySummary) {
|
||||
@@ -1105,7 +1105,7 @@ export function PortalHomePage() {
|
||||
key: "weekly",
|
||||
icon: "article",
|
||||
label: "提交周报",
|
||||
description: "进入周报页继续填写或查看团队缺口",
|
||||
description: "填写 / 查看缺口",
|
||||
to: "/weekly-reports",
|
||||
});
|
||||
}
|
||||
@@ -1136,7 +1136,7 @@ export function PortalHomePage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="portal-metric-strip">
|
||||
<div className="portal-metric-strip ops-status-strip">
|
||||
{primaryCards.map((card) => (
|
||||
<MetricCard
|
||||
key={card.key}
|
||||
@@ -1161,7 +1161,7 @@ export function PortalHomePage() {
|
||||
fallback={(
|
||||
<PortalSectionFallback
|
||||
title="快速上手"
|
||||
description="完成基础资料。"
|
||||
description="基础资料"
|
||||
/>
|
||||
)}
|
||||
>
|
||||
@@ -1234,7 +1234,6 @@ export function PortalHomePage() {
|
||||
|
||||
<SecondaryCardsPanel
|
||||
title="项目与组织"
|
||||
description="项目、组织、风险"
|
||||
cards={secondaryCards}
|
||||
/>
|
||||
</SecondaryWorkspacePanel>
|
||||
|
||||
@@ -363,7 +363,7 @@ export function ReimbursementsPage() {
|
||||
<ListWorkspace>
|
||||
<WorkspaceHeader
|
||||
title="我的报销"
|
||||
subtitle="草稿、审批和付款状态。"
|
||||
subtitle="单据、金额、状态。"
|
||||
actions={
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
{canCreateExpense ? (
|
||||
@@ -461,21 +461,21 @@ export function ReimbursementsPage() {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<StatStrip columns={4}>
|
||||
<StatStrip columns={4} className="ops-status-strip">
|
||||
<MetricCard title="当前列表" value={stats.currentPageCount} note={`共 ${listQuery.data?.total ?? 0} 条`} />
|
||||
<MetricCard title="当前金额" value={formatCurrency(stats.currentPageAmount)} note="当前页" />
|
||||
<MetricCard title="审批中" value={stats.reviewing} note="待推进" tone={stats.reviewing > 0 ? "warning" : "neutral"} />
|
||||
<MetricCard title="已支付" value={stats.paid} note={scope === "all" ? "全范围" : "仅自己"} />
|
||||
</StatStrip>
|
||||
|
||||
<FilterBar className="space-y-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<FilterBar className="ops-command-bar space-y-3 !p-3 md:!p-3.5">
|
||||
<div className="ops-command-bar__row">
|
||||
<Tabs
|
||||
data-testid="reimbursements-status-tabs"
|
||||
value={status}
|
||||
onValueChange={(value) => setSearchParams({ status: value, page: "1", scope })}
|
||||
>
|
||||
<TabsList className="flex w-full flex-wrap justify-start gap-1 md:w-auto">
|
||||
<TabsList className="ops-command-bar__tabs !h-auto !justify-start !bg-slate-50">
|
||||
{[
|
||||
{ value: "draft", label: "草稿" },
|
||||
{ value: "reviewing", label: "审批中" },
|
||||
@@ -495,10 +495,10 @@ export function ReimbursementsPage() {
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="ops-command-bar__actions">
|
||||
{showProxyOwnerSelector ? (
|
||||
<select
|
||||
className="select-sm h-10"
|
||||
className="select-sm"
|
||||
data-testid="reimbursements-owner-select"
|
||||
onChange={(event) => setProxyOwnerEmployeeId(event.target.value)}
|
||||
value={proxyOwnerEmployeeId}
|
||||
@@ -518,6 +518,7 @@ export function ReimbursementsPage() {
|
||||
<>
|
||||
<Button
|
||||
data-testid="reimbursements-scope-own"
|
||||
size="sm"
|
||||
variant={scope === "own" ? "default" : "secondary"}
|
||||
onClick={() => setSearchParams({ status, page: "1", scope: "own" })}
|
||||
type="button"
|
||||
@@ -526,13 +527,14 @@ export function ReimbursementsPage() {
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="reimbursements-scope-all"
|
||||
size="sm"
|
||||
variant={scope === "all" ? "default" : "secondary"}
|
||||
onClick={() => setSearchParams({ status, page: "1", scope: "all" })}
|
||||
type="button"
|
||||
>
|
||||
全范围
|
||||
</Button>
|
||||
<Button asChild variant="secondary" type="button">
|
||||
<Button asChild size="sm" variant="secondary" type="button">
|
||||
<Link to="/reimbursements/dashboard">统计分析</Link>
|
||||
</Button>
|
||||
</>
|
||||
@@ -542,6 +544,7 @@ export function ReimbursementsPage() {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
data-testid="reimbursements-export"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={exportMutation.isPending}
|
||||
@@ -567,6 +570,7 @@ export function ReimbursementsPage() {
|
||||
|
||||
{canShowBatchEntry ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={batchMode ? "default" : "secondary"}
|
||||
onClick={() => {
|
||||
setBatchMode((current) => !current);
|
||||
@@ -584,7 +588,7 @@ export function ReimbursementsPage() {
|
||||
</div>
|
||||
|
||||
{batchMode ? (
|
||||
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
<div className="ops-inline-alert border-amber-200 bg-amber-50 text-amber-800">
|
||||
仅勾选你可处理的审批中报销;驳回需填写理由。
|
||||
</div>
|
||||
) : null}
|
||||
@@ -710,7 +714,7 @@ export function ReimbursementsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PrimaryTable className="hidden md:block">
|
||||
<PrimaryTable className="hidden md:block ops-table-focus">
|
||||
<table className="w-full border-collapse text-left" data-testid="reimbursements-table">
|
||||
<thead className="table-header">
|
||||
<tr>
|
||||
@@ -782,13 +786,18 @@ export function ReimbursementsPage() {
|
||||
<td className="table-td">{item.reimbursementDate ?? "-"}</td>
|
||||
<td className="table-td">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
to={`/reimbursements/${item.id}`}
|
||||
className={["draft", "rejected"].includes(item.status) ? "btn-primary" : "btn-secondary"}
|
||||
data-testid="reimbursement-detail-link"
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant={["draft", "rejected"].includes(item.status) ? "default" : "secondary"}
|
||||
>
|
||||
{["draft", "rejected"].includes(item.status) ? "编辑" : "详情"}
|
||||
</Link>
|
||||
<Link
|
||||
to={`/reimbursements/${item.id}`}
|
||||
data-testid="reimbursement-detail-link"
|
||||
>
|
||||
{["draft", "rejected"].includes(item.status) ? "编辑" : "详情"}
|
||||
</Link>
|
||||
</Button>
|
||||
{canDeleteDraft ? (
|
||||
<Button
|
||||
disabled={deleteMutation.isPending}
|
||||
|
||||
@@ -15,7 +15,6 @@ import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||
import {
|
||||
ActionToolbar,
|
||||
FilterBar,
|
||||
ListWorkspace,
|
||||
PrimaryTable,
|
||||
@@ -210,11 +209,11 @@ export function WorkflowTasksPage() {
|
||||
<WorkspaceHeader
|
||||
eyebrow="审批"
|
||||
title="审批中心"
|
||||
subtitle="待处理、已处理和业务类型。"
|
||||
subtitle="任务、节点、业务状态。"
|
||||
/>
|
||||
|
||||
<div data-testid="workflow-task-summary">
|
||||
<StatStrip columns={4}>
|
||||
<StatStrip columns={4} className="ops-status-strip">
|
||||
<MetricCard title="待办总数" value={summaryQuery.data?.pendingTotal ?? "-"} />
|
||||
<MetricCard title="超时待办" value={summaryQuery.data?.overdueTotal ?? "-"} tone="warning" />
|
||||
<MetricCard title="最早待处理" value={formatSummaryTime(summaryQuery.data?.earliestPendingAt)} />
|
||||
@@ -232,14 +231,14 @@ export function WorkflowTasksPage() {
|
||||
</StatStrip>
|
||||
</div>
|
||||
|
||||
<FilterBar>
|
||||
<ActionToolbar className="items-start">
|
||||
<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}
|
||||
onValueChange={(value) => updateSearchParams({ status: value as WorkflowStatusFilter, page: 1 })}
|
||||
>
|
||||
<TabsList className="flex w-full flex-wrap justify-start gap-1 xl:w-auto">
|
||||
<TabsList className="ops-command-bar__tabs !h-auto !justify-start !bg-slate-50">
|
||||
{WORKFLOW_STATUS_OPTIONS.map((item) => (
|
||||
<TabsTrigger
|
||||
data-testid={`workflow-tasks-tab-${item.value}`}
|
||||
@@ -252,13 +251,13 @@ export function WorkflowTasksPage() {
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex w-full items-center gap-2 sm:w-auto">
|
||||
<label className="text-sm font-medium text-zinc-700" htmlFor="workflow-entity-type-filter">
|
||||
<div className="ops-command-bar__actions">
|
||||
<label className="ops-filter-label" htmlFor="workflow-entity-type-filter">
|
||||
业务类型
|
||||
</label>
|
||||
<select
|
||||
id="workflow-entity-type-filter"
|
||||
className="select-std min-w-36 flex-1 sm:flex-none"
|
||||
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 })}
|
||||
@@ -270,7 +269,7 @@ export function WorkflowTasksPage() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</ActionToolbar>
|
||||
</div>
|
||||
</FilterBar>
|
||||
|
||||
{query.isLoading ? <TableSkeleton columns={7} rows={5} /> : null}
|
||||
@@ -352,7 +351,7 @@ export function WorkflowTasksPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PrimaryTable className="hidden md:block">
|
||||
<PrimaryTable className="hidden md:block ops-table-focus">
|
||||
<Table data-testid="workflow-tasks-table">
|
||||
<TableHeader>
|
||||
<tr>
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { Page, Route } from "@playwright/test";
|
||||
|
||||
import { test, expect } from "./helpers/fixtures";
|
||||
|
||||
test.use({ serviceWorkers: "block" });
|
||||
|
||||
const visualUser = {
|
||||
identifier: "[email protected]",
|
||||
password: "VisualPassword1234",
|
||||
name: "Visual Manager",
|
||||
};
|
||||
|
||||
const visualMe = {
|
||||
id: "employee-visual-wave2",
|
||||
employeeNo: "E-WAVE2",
|
||||
name: visualUser.name,
|
||||
email: visualUser.identifier,
|
||||
phone: null,
|
||||
roleCodes: ["manager"],
|
||||
permissions: [
|
||||
"expense.create",
|
||||
"expense.view_own",
|
||||
"expense.view_all",
|
||||
"expense.delete_own",
|
||||
"expense.approve",
|
||||
"leave.approve",
|
||||
"report.dashboard.view",
|
||||
],
|
||||
profileCompleteness: {
|
||||
hasEmergencyContact: true,
|
||||
hasBankAccount: true,
|
||||
hasAvatar: false,
|
||||
hasIdNumber: false,
|
||||
score: 80,
|
||||
},
|
||||
};
|
||||
|
||||
function envelope<T>(data: T) {
|
||||
return { requestId: "mock-pc-wave2", data };
|
||||
}
|
||||
|
||||
async function fulfillJson<T>(route: Route, data: T) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(envelope(data)),
|
||||
});
|
||||
}
|
||||
|
||||
function navigationItem(key: string, href: string, label: string, icon: string, placement = "main") {
|
||||
return { key, href, label, icon, placement, permissionCodes: [], featureFlagKey: null };
|
||||
}
|
||||
|
||||
function reimbursementItem(id: string, status: string, formNumber: string, amount: string) {
|
||||
return {
|
||||
id,
|
||||
formNumber,
|
||||
status,
|
||||
reimbursementDate: "2026-05-27",
|
||||
totalAmount: amount,
|
||||
description: "Wave2 PC 视觉验证",
|
||||
payment: {
|
||||
paymentVoucherNo: null,
|
||||
paymentDate: null,
|
||||
paymentChannel: null,
|
||||
paidAt: null,
|
||||
paidBy: null,
|
||||
},
|
||||
owner: { id: visualMe.id, employeeNo: visualMe.employeeNo, name: visualMe.name },
|
||||
submitter: { id: visualMe.id, employeeNo: visualMe.employeeNo, name: visualMe.name },
|
||||
project: { id: "project-wave2", projectNo: "HYB-WAVE2", name: "OA 试运行" },
|
||||
pendingWorkflow: status === "reviewing"
|
||||
? {
|
||||
task: { id: `task-${id}`, stepName: "主管审批" },
|
||||
allowedActions: ["approve", "reject"],
|
||||
}
|
||||
: null,
|
||||
createdAt: "2026-05-27T09:00:00.000Z",
|
||||
updatedAt: "2026-05-27T09:30:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
const pendingWorkflowTask = {
|
||||
id: "workflow-task-wave2",
|
||||
instanceId: "workflow-instance-wave2",
|
||||
stepId: "manager-review",
|
||||
stepName: "主管审批",
|
||||
assigneeId: visualMe.id,
|
||||
assigneeRole: "manager",
|
||||
status: "pending",
|
||||
outcome: null,
|
||||
createdAt: "2026-05-27T09:05:00.000Z",
|
||||
completedAt: null,
|
||||
workflowInstance: {
|
||||
entityType: "reimbursement",
|
||||
entityId: "reimbursement-wave2-reviewing",
|
||||
currentStatus: "reviewing",
|
||||
},
|
||||
businessSummary: {
|
||||
reference: "BX20260527001",
|
||||
title: "研发采购报销",
|
||||
applicantName: "王经理",
|
||||
amount: "¥1,260.00",
|
||||
duration: null,
|
||||
businessStatus: "reviewing",
|
||||
},
|
||||
allowedActions: ["approve", "reject", "return", "transfer"],
|
||||
};
|
||||
|
||||
async function mockWave2Apis(page: Page) {
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const pathName = url.pathname;
|
||||
|
||||
if (pathName === "/api/auth/capabilities") {
|
||||
await fulfillJson(route, {
|
||||
emailDelivery: { ready: true, statusReasons: [] },
|
||||
emailLoginCode: { enabled: true },
|
||||
passwordReset: { enabled: true },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/auth/sso/providers") {
|
||||
await fulfillJson(route, []);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/auth/wecom/config") {
|
||||
await fulfillJson(route, {
|
||||
enabled: true,
|
||||
entryEnabled: true,
|
||||
oauthEnabled: true,
|
||||
corpId: "wx-wave2",
|
||||
agentId: "1000002",
|
||||
loginUrl: "/api/auth/wecom/login?returnTo=%2Fportal",
|
||||
bindEnabled: true,
|
||||
defaultReturnTo: "/portal",
|
||||
status: "ready",
|
||||
statusReasons: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/auth/login" || pathName === "/api/auth/refresh") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" },
|
||||
body: JSON.stringify(envelope({ accessToken: "mock-pc-wave2-token", expiresIn: 900, mustChangePassword: false })),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/me") {
|
||||
await fulfillJson(route, visualMe);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/system/navigation") {
|
||||
await fulfillJson(route, {
|
||||
workspace: {
|
||||
id: "workspace-wave2",
|
||||
persisted: true,
|
||||
profile: "small_team_internal",
|
||||
orgMode: "single_company",
|
||||
primaryEntryMode: "password",
|
||||
notificationStrategy: "in_app",
|
||||
devicePriority: "desktop",
|
||||
moduleVisibility: {},
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
},
|
||||
sections: {
|
||||
main: [
|
||||
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
|
||||
navigationItem("workflow", "/workflow/tasks", "审批待办", "fact_check"),
|
||||
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
|
||||
],
|
||||
admin: [],
|
||||
backup: [],
|
||||
},
|
||||
recommendedRolePresets: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/notifications") {
|
||||
await fulfillJson(route, {
|
||||
page: 1,
|
||||
pageSize: Number(url.searchParams.get("pageSize") ?? 5),
|
||||
total: 0,
|
||||
unreadOnly: url.searchParams.get("unreadOnly") === "1",
|
||||
unreadCount: 0,
|
||||
items: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/portal/home") {
|
||||
await fulfillJson(route, {
|
||||
workspace: { id: "workspace-wave2", persisted: true, profile: "small_team_internal" },
|
||||
greeting: { name: visualMe.name },
|
||||
cards: [
|
||||
{ key: "pending_approvals", title: "审批待办", value: 3, href: "/workflow/tasks", severity: "warning" },
|
||||
{ key: "team_presence", title: "团队状态", value: "7/9 在岗", href: "/team", severity: "normal" },
|
||||
{ key: "weekly_missing", title: "周报缺口", value: 1, href: "/weekly-reports", severity: "warning" },
|
||||
{ key: "weekly_help", title: "需协助", value: 1, href: "/weekly-reports", severity: "normal" },
|
||||
{ key: "my_projects", title: "项目跟进", value: 4, href: "/projects", severity: "normal" },
|
||||
],
|
||||
pendingTasks: { count: 3, href: "/workflow/tasks" },
|
||||
myApplications: [
|
||||
{ domain: "reimbursement", count: 2, href: "/reimbursements" },
|
||||
{ domain: "leave", count: 1, href: "/leave-requests" },
|
||||
],
|
||||
riskSummary: null,
|
||||
announcements: [{ id: "ann-wave2", title: "试运行提醒", summary: "今日优先处理审批和报销。", href: null }],
|
||||
quickLinks: [{ key: "expense", title: "报销列表", href: "/reimbursements" }],
|
||||
todayPresence: { date: "2026-05-27", status: "office", label: "在岗", note: "已同步企微", href: "/team" },
|
||||
teamPresence: { date: "2026-05-27", office: 7, remote: 1, out: 0, travel: 0, leave: 1, unspecified: 0, total: 9, href: "/team" },
|
||||
weeklyReport: { weekStart: "2026-05-25", status: "draft", needsHelp: false, href: "/weekly-reports" },
|
||||
weeklySummary: { weekStart: "2026-05-25", submittedCount: 8, missingCount: 1, needsHelpCount: 1, href: "/weekly-reports" },
|
||||
todoSummary: { openCount: 2, highPriorityCount: 1, href: "/todos" },
|
||||
projectSummary: null,
|
||||
peopleLifecycle: null,
|
||||
financeSummary: null,
|
||||
adoptionSummary: null,
|
||||
roleCapabilities: {
|
||||
canSubmitApplications: true,
|
||||
canApproveTasks: true,
|
||||
canUseFinance: false,
|
||||
canManageSystem: false,
|
||||
canViewTeam: true,
|
||||
isAdminOnly: false,
|
||||
},
|
||||
recentActivities: [
|
||||
{ id: "activity-wave2-1", type: "reimbursement.submit", actorName: "王经理", summary: "王经理提交了研发采购报销", createdAt: "2026-05-27T08:50:00.000Z", href: "/reimbursements/reimbursement-wave2-reviewing" },
|
||||
],
|
||||
teamStatusBoard: [
|
||||
{ employeeId: "employee-wave2-1", name: "王经理", avatarUrl: null, presenceStatus: "office", attendanceClockIn: "2026-05-27T09:00:00.000Z", attendanceClockOut: null, onLeave: false, leaveType: null },
|
||||
{ employeeId: "employee-wave2-2", name: "李工", avatarUrl: null, presenceStatus: "remote", attendanceClockIn: null, attendanceClockOut: null, onLeave: false, leaveType: null },
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/workflow/tasks/summary") {
|
||||
await fulfillJson(route, {
|
||||
scope: "mine",
|
||||
pendingTotal: 3,
|
||||
overdueTotal: 1,
|
||||
earliestPendingAt: "2026-05-27T09:05:00.000Z",
|
||||
averagePendingAgeHours: 2,
|
||||
byEntityType: [{ key: "reimbursement", pendingTotal: 3 }],
|
||||
byStep: [],
|
||||
byAssigneeRole: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/workflow/tasks") {
|
||||
const status = url.searchParams.get("status") ?? "pending";
|
||||
await fulfillJson(route, {
|
||||
page: 1,
|
||||
pageSize: Number(url.searchParams.get("pageSize") ?? 20),
|
||||
total: status === "pending" ? 1 : 0,
|
||||
items: status === "pending" ? [pendingWorkflowTask] : [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/reimbursements" && request.method() === "GET") {
|
||||
const status = url.searchParams.get("status") ?? "draft";
|
||||
const items = [
|
||||
reimbursementItem("reimbursement-wave2-draft", "draft", "BX20260527000", "320.00"),
|
||||
reimbursementItem("reimbursement-wave2-reviewing", "reviewing", "BX20260527001", "1260.00"),
|
||||
].filter((item) => item.status === status);
|
||||
await fulfillJson(route, { page: 1, pageSize: 20, total: items.length, scope: url.searchParams.get("scope") ?? "own", items });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/reimbursement-templates") {
|
||||
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/reimbursements/linkable-travel-requests") {
|
||||
await fulfillJson(route, { ownerEmployeeId: visualMe.id, items: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillJson(route, {});
|
||||
});
|
||||
}
|
||||
|
||||
async function capture(page: Page, name: string) {
|
||||
const outputDir = path.resolve(process.cwd(), "../output/playwright/pc-core-workflow-visual-wave2");
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
await page.screenshot({ path: path.join(outputDir, `${name}.png`), fullPage: true });
|
||||
}
|
||||
|
||||
test("PC 核心工作流 Wave2 视觉截图", async ({ page }) => {
|
||||
await mockWave2Apis(page);
|
||||
await page.setViewportSize({ width: 1440, height: 960 });
|
||||
|
||||
await page.goto("/login");
|
||||
await expect(page.getByTestId("login-mode-switch")).toBeVisible();
|
||||
await expect(page.getByTestId("wecom-entry-browser-notice")).toBeVisible();
|
||||
await capture(page, "01-login");
|
||||
|
||||
await page.getByTestId("login-identifier").fill(visualUser.identifier);
|
||||
await page.getByTestId("login-password").fill(visualUser.password);
|
||||
await page.getByTestId("login-submit").click();
|
||||
await expect(page).toHaveURL(/\/portal/);
|
||||
await expect(page.locator('[aria-label="portal-card-pending_approvals"]')).toBeVisible();
|
||||
await capture(page, "02-portal");
|
||||
|
||||
await page.goto("/reimbursements?status=reviewing&scope=all");
|
||||
await expect(page.getByTestId("reimbursements-table")).toBeVisible();
|
||||
await expect(page.locator(".ops-command-bar").first()).toBeVisible();
|
||||
await capture(page, "03-reimbursements");
|
||||
|
||||
await page.goto("/workflow/tasks");
|
||||
await expect(page.getByTestId("workflow-tasks-table")).toBeVisible();
|
||||
await expect(page.locator(".ops-command-bar").first()).toBeVisible();
|
||||
await capture(page, "04-workflow-tasks");
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户