优化工作台与报销任务优先页面(portal-task-first-redesign)

这个提交包含在:
2026-05-29 22:31:59 +08:00
父节点 b75fa86e4e
当前提交 a9d91d3259
共修改 15 个文件,包含 988 行新增和 1149 行删除
@@ -244,6 +244,24 @@ secondary operations:
workflow steps that the layout can express. workflow steps that the layout can express.
- Low-frequency capabilities are hidden or moved, not removed, unless the - Low-frequency capabilities are hidden or moved, not removed, unless the
current Trellis task explicitly deletes the capability. current Trellis task explicitly deletes the capability.
- Portal/workbench first screen should use a compact status summary plus
`今日待处理`; do not use a persistent welcome hero, fixed `报销进度` button,
general announcement feed, or folded placeholder sections such as
`团队与通知`, `角色重点` or `试点观测摘要`.
- Workbench sections after `今日待处理` must be conditional: `团队卡点` only for
real blockers, `管理提醒` only for real admin/trial issues, and `我的进度`
only for reliable status updates. Empty data should disappear, not render as
collapsed drawers or statistic cards.
- Each workbench action row should state what the item is, why the current user
is involved, current status/timing, and one next action. Do not represent raw
application counts as progress unless the payload includes an actionable or
status-level signal.
- PC reimbursement list defaults to the initiator's own forms even for users
with finance/approval roles. The first screen may use task cards for
rejected corrections, drafts and reviewing progress; ledger/export/batch,
proxy submit, templates and travel-linking remain secondary and must be hidden
unless the current user has a real matching capability. Row workflow copy
should distinguish `待你处理` from read-only `当前节点`.
**Why**: hyb-oa serves small teams with overlapping roles. Core pages must make **Why**: hyb-oa serves small teams with overlapping roles. Core pages must make
the next click clear for ordinary employees and approvers, while keeping finance the next click clear for ordinary employees and approvers, while keeping finance
+101
查看文件
@@ -793,6 +793,107 @@
@apply h-full; @apply h-full;
} }
.portal-work-summary {
background: rgba(255, 255, 255, 0.96);
border: 1px solid rgba(226, 232, 240, 0.92);
border-radius: var(--radius-panel);
box-shadow: var(--shadow-xs);
@apply flex flex-col gap-3 px-4 py-3.5 md:flex-row md:items-center md:justify-between md:px-5;
}
.portal-work-summary__eyebrow {
color: var(--color-accent-primary);
@apply text-xs font-bold;
}
.portal-work-summary__title {
color: var(--color-text-primary);
@apply mt-1 text-lg font-semibold leading-7 md:text-xl;
}
.portal-work-summary__meta {
color: var(--color-text-tertiary);
@apply mt-1 text-xs font-medium;
}
.portal-task-list {
border: 1px solid rgba(226, 232, 240, 0.94);
background: rgba(255, 255, 255, 0.72);
@apply overflow-hidden rounded-lg divide-y divide-slate-200/80;
}
.portal-task-row {
background: rgba(255, 255, 255, 0.88);
color: var(--color-text-primary);
@apply grid min-h-[68px] grid-cols-[auto,minmax(0,1fr)] items-center gap-3 px-3.5 py-3 transition-colors hover:bg-white md:grid-cols-[auto,minmax(0,1fr),auto,auto];
}
.portal-task-row--warning {
background: rgba(255, 251, 235, 0.74);
}
.portal-task-row--high {
background: rgba(254, 242, 242, 0.78);
}
.portal-task-row__icon {
background: rgba(0, 57, 138, 0.08);
color: var(--color-accent-primary);
@apply flex size-9 items-center justify-center rounded-lg;
}
.portal-task-row--warning .portal-task-row__icon {
@apply bg-amber-50 text-amber-700;
}
.portal-task-row--high .portal-task-row__icon {
@apply bg-red-50 text-red-700;
}
.portal-task-row__title {
color: var(--color-text-primary);
@apply block truncate text-sm font-semibold;
}
.portal-task-row__reason {
color: var(--color-text-secondary);
@apply mt-1 block text-xs leading-5;
}
.portal-task-row__meta {
color: var(--color-text-secondary);
@apply ml-12 inline-flex w-fit rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold md:ml-0;
}
.portal-task-row__action {
color: var(--color-accent-primary);
@apply ml-12 text-sm font-semibold md:ml-0;
}
.portal-empty-start {
border: 1px dashed rgba(203, 213, 225, 0.9);
background: rgba(248, 250, 252, 0.78);
@apply flex flex-col gap-4 rounded-lg px-4 py-4 md:flex-row md:items-center md:justify-between;
}
.portal-empty-start__title {
color: var(--color-text-primary);
@apply text-base font-semibold;
}
.portal-empty-start__desc {
color: var(--color-text-secondary);
@apply mt-1 text-sm leading-6;
}
.portal-inline-actions {
@apply flex flex-wrap gap-2;
}
.portal-task-secondary-grid {
@apply grid gap-4 xl:grid-cols-2;
}
.portal-list-card { .portal-list-card {
background: rgba(255, 255, 255, 0.92); background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(226, 232, 240, 0.94); border: 1px solid rgba(226, 232, 240, 0.94);
文件差异内容过多而无法显示 加载差异
+227 -58
查看文件
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowRight, Clock3, FilePenLine, RotateCcw, Wrench } from "lucide-react";
import { isApiError } from "../api/types"; import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages"; import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchMyDelegations } from "../api/delegations"; import { fetchMyDelegations } from "../api/delegations";
@@ -74,6 +75,40 @@ const REIMBURSEMENT_STATUS_OPTIONS: Array<{ value: ReimbursementStatus; label: s
{ value: "cancelled", label: "已取消" }, { value: "cancelled", label: "已取消" },
]; ];
const REIMBURSEMENT_TASK_STATUS_OPTIONS = [
{
value: "rejected",
title: "被驳回待补正",
idleNote: "暂无需要补正的报销",
activeNote: "优先补齐说明或附件后重新提交",
icon: RotateCcw,
accentClass: "border-red-200 bg-red-50 text-red-700",
},
{
value: "draft",
title: "草稿待完善",
idleNote: "还没有报销草稿",
activeNote: "继续完善明细、发票和附件",
icon: FilePenLine,
accentClass: "border-amber-200 bg-amber-50 text-amber-700",
},
{
value: "reviewing",
title: "审批中进度",
idleNote: "暂无审批中的报销",
activeNote: "查看当前节点和处理进度",
icon: Clock3,
accentClass: "border-sky-200 bg-sky-50 text-sky-700",
},
] satisfies Array<{
value: ReimbursementStatus;
title: string;
idleNote: string;
activeNote: string;
icon: typeof RotateCcw;
accentClass: string;
}>;
function formatCurrency(value: number) { function formatCurrency(value: number) {
return new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(value); return new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(value);
} }
@@ -101,6 +136,56 @@ function formatBatchOutcome(action: BatchAction, outcome: string) {
return action === "approve" ? "已通过" : "已驳回"; return action === "approve" ? "已通过" : "已驳回";
} }
function resolveEmptyStateCopy(status: ReimbursementStatus) {
if (status === "draft") {
return {
title: "还没有报销草稿",
description: "需要报销时先新建单据,再补充明细、发票和附件。",
};
}
if (status === "rejected") {
return {
title: "暂无需要补正的报销",
description: "后续有驳回单据时,会在这里继续编辑并重新提交。",
};
}
if (status === "reviewing") {
return {
title: "暂无审批中的报销",
description: "提交后的报销会在这里显示当前审批进度。",
};
}
if (status === "approved") {
return {
title: "暂无已通过记录",
description: "审批通过但未支付的报销会在这里留存。",
};
}
if (status === "paid") {
return {
title: "暂无已支付记录",
description: "财务支付完成后会在这里查看付款结果。",
};
}
return {
title: "暂无已取消记录",
description: "撤回或取消的报销记录会在这里留存。",
};
}
function resolveWorkflowHint(item: ReimbursementListItem) {
const stepName = item.pendingWorkflow?.task?.stepName;
if (!stepName) {
return null;
}
const actionableByCurrentUser = item.pendingWorkflow?.allowedActions.length ? true : false;
return {
label: `${actionableByCurrentUser ? "待你处理" : "当前节点"}:${stepName}`,
className: actionableByCurrentUser ? "text-amber-700" : "text-zinc-500",
};
}
export function ReimbursementsPage() { export function ReimbursementsPage() {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -126,14 +211,37 @@ export function ReimbursementsPage() {
const canViewAll = hasPermission(permissions, "expense.view_all"); const canViewAll = hasPermission(permissions, "expense.view_all");
const canApproveExpense = hasPermission(permissions, "expense.approve"); const canApproveExpense = hasPermission(permissions, "expense.approve");
const canProxySubmit = hasPermission(permissions, "expense.proxy_submit"); const canProxySubmit = hasPermission(permissions, "expense.proxy_submit");
const canUseFinanceTools =
hasPermission(permissions, "expense.finance.pay") ||
hasPermission(permissions, "payment.batch.manage") ||
hasPermission(permissions, "risk.review.manage") ||
hasPermission(permissions, "invoice.manage");
const isAdministration = (me?.roleCodes ?? []).includes("administration"); const isAdministration = (me?.roleCodes ?? []).includes("administration");
const showProxyOwnerSelector = canProxySubmit && isAdministration; const showProxyOwnerSelector = canProxySubmit && isAdministration;
const canShowLedgerEntry = canViewAll || showProxyOwnerSelector || canUseFinanceTools;
const showAdvancedTools = canShowLedgerEntry && (showLedgerTools || scope === "all" || batchMode || showProxyOwnerSelector);
const shouldLoadCreateOptions = showAdvancedTools && canCreateExpense;
const listQuery = useQuery({ const listQuery = useQuery({
queryKey: ["reimbursements", "list", status, page, scope], queryKey: ["reimbursements", "list", status, page, scope],
queryFn: () => fetchReimbursements({ status, page, pageSize: PAGE_SIZE, scope }), queryFn: () => fetchReimbursements({ status, page, pageSize: PAGE_SIZE, scope }),
}); });
const rejectedSummaryQuery = useQuery({
queryKey: ["reimbursements", "task-summary", "own", "rejected"],
queryFn: () => fetchReimbursements({ status: "rejected", page: 1, pageSize: 1, scope: "own" }),
});
const draftSummaryQuery = useQuery({
queryKey: ["reimbursements", "task-summary", "own", "draft"],
queryFn: () => fetchReimbursements({ status: "draft", page: 1, pageSize: 1, scope: "own" }),
});
const reviewingSummaryQuery = useQuery({
queryKey: ["reimbursements", "task-summary", "own", "reviewing"],
queryFn: () => fetchReimbursements({ status: "reviewing", page: 1, pageSize: 1, scope: "own" }),
});
const proxyDelegationsQuery = useQuery({ const proxyDelegationsQuery = useQuery({
enabled: showProxyOwnerSelector, enabled: showProxyOwnerSelector,
queryKey: ["delegations", "mine", "active"], queryKey: ["delegations", "mine", "active"],
@@ -141,13 +249,13 @@ export function ReimbursementsPage() {
}); });
const templateOptionsQuery = useQuery({ const templateOptionsQuery = useQuery({
enabled: canCreateExpense, enabled: shouldLoadCreateOptions,
queryKey: ["reimbursement-templates", "enabled", "create-menu"], queryKey: ["reimbursement-templates", "enabled", "create-menu"],
queryFn: () => fetchReimbursementTemplates({ enabled: true }), queryFn: () => fetchReimbursementTemplates({ enabled: true }),
}); });
const linkableTravelRequestsQuery = useQuery({ const linkableTravelRequestsQuery = useQuery({
enabled: canCreateExpense, enabled: shouldLoadCreateOptions,
queryKey: ["reimbursements", "linkable-travel-requests", proxyOwnerEmployeeId || me?.id || "self"], queryKey: ["reimbursements", "linkable-travel-requests", proxyOwnerEmployeeId || me?.id || "self"],
queryFn: () => queryFn: () =>
fetchLinkableTravelRequests({ fetchLinkableTravelRequests({
@@ -187,10 +295,17 @@ export function ReimbursementsPage() {
); );
const canShowBatchEntry = const canShowBatchEntry =
status === "reviewing" && scope === "all" && canApproveExpense && actionableItems.length > 0; status === "reviewing" && scope === "all" && canApproveExpense && actionableItems.length > 0;
const showAdvancedTools = showLedgerTools || scope === "all" || batchMode || showProxyOwnerSelector;
const selectedItems = actionableItems.filter((item) => selectedReimbursementIds.includes(item.id)); const selectedItems = actionableItems.filter((item) => selectedReimbursementIds.includes(item.id));
const allActionableSelected = const allActionableSelected =
actionableItems.length > 0 && actionableItems.every((item) => selectedReimbursementIds.includes(item.id)); actionableItems.length > 0 && actionableItems.every((item) => selectedReimbursementIds.includes(item.id));
const taskSummaryTotalByStatus = {
rejected: rejectedSummaryQuery.data?.total,
draft: draftSummaryQuery.data?.total,
reviewing: reviewingSummaryQuery.data?.total,
} satisfies Record<(typeof REIMBURSEMENT_TASK_STATUS_OPTIONS)[number]["value"], number | undefined>;
const canShowCreateOptionsMenu =
canCreateExpense && shouldLoadCreateOptions && (templateOptions.length > 0 || linkableTravelRequests.length > 0);
const emptyState = resolveEmptyStateCopy(status);
useEffect(() => { useEffect(() => {
setSelectedReimbursementIds((current) => current.filter((id) => actionableIdSet.has(id))); setSelectedReimbursementIds((current) => current.filter((id) => actionableIdSet.has(id)));
@@ -399,18 +514,6 @@ export function ReimbursementsPage() {
</p> </p>
) : null} ) : null}
{templateOptionsQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(templateOptionsQuery.error, "加载报销模板失败")}
</p>
) : null}
{linkableTravelRequestsQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(linkableTravelRequestsQuery.error, "加载可关联差旅申请失败")}
</p>
) : null}
{exportError ? ( {exportError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"> <p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{exportError} {exportError}
@@ -423,25 +526,65 @@ export function ReimbursementsPage() {
</p> </p>
) : null} ) : null}
<section className="grid gap-3 md:grid-cols-3" aria-label="我的报销任务">
{REIMBURSEMENT_TASK_STATUS_OPTIONS.map((item) => {
const Icon = item.icon;
const count = taskSummaryTotalByStatus[item.value];
const active = status === item.value && scope === "own";
const displayCount = typeof count === "number" ? count : "-";
return (
<button
className={`card-std min-h-[124px] p-4 text-left transition hover:border-primary/40 hover:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-200 ${
active ? "border-primary/40 bg-primary/5" : ""
}`}
data-testid={`reimbursements-task-card-${item.value}`}
key={item.value}
onClick={() => setSearchParams({ status: item.value, page: "1", scope: "own" })}
type="button"
>
<div className="flex items-start justify-between gap-3">
<span className={`flex size-9 items-center justify-center rounded-lg border ${item.accentClass}`}>
<Icon aria-hidden className="size-4" />
</span>
<ArrowRight aria-hidden className="mt-1 size-4 text-zinc-300" />
</div>
<div className="mt-4 flex items-end justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-zinc-900">{item.title}</div>
<div className="mt-1 text-xs leading-5 text-zinc-500">
{typeof count === "number" && count > 0 ? item.activeNote : item.idleNote}
</div>
</div>
<div className="text-3xl font-semibold tabular-nums text-zinc-950">{displayCount}</div>
</div>
</button>
);
})}
</section>
<FilterBar className="ops-command-bar space-y-3 !p-3 md:!p-3.5"> <FilterBar className="ops-command-bar space-y-3 !p-3 md:!p-3.5">
<div className="ops-command-bar__row"> <div className="ops-command-bar__row">
<Tabs <div className="min-w-0 space-y-2">
data-testid="reimbursements-status-tabs" <div className="text-xs font-semibold text-zinc-500">报销记录</div>
value={status} <Tabs
onValueChange={(value) => setSearchParams({ status: value, page: "1", scope })} data-testid="reimbursements-status-tabs"
> value={status}
<TabsList className="ops-command-bar__tabs !h-auto !justify-start !bg-slate-50"> onValueChange={(value) => setSearchParams({ status: value, page: "1", scope })}
{REIMBURSEMENT_STATUS_OPTIONS.map((item) => ( >
<TabsTrigger <TabsList className="ops-command-bar__tabs !h-auto !justify-start !bg-slate-50">
data-testid={`reimbursements-tab-${item.value}`} {REIMBURSEMENT_STATUS_OPTIONS.map((item) => (
key={item.value} <TabsTrigger
value={item.value} data-testid={`reimbursements-tab-${item.value}`}
> key={item.value}
{item.label} value={item.value}
</TabsTrigger> >
))} {item.label}
</TabsList> </TabsTrigger>
</Tabs> ))}
</TabsList>
</Tabs>
</div>
<div className="ops-command-bar__actions"> <div className="ops-command-bar__actions">
{canApproveExpense ? ( {canApproveExpense ? (
@@ -455,14 +598,17 @@ export function ReimbursementsPage() {
待我处理 待我处理
</Button> </Button>
) : null} ) : null}
<Button {canShowLedgerEntry ? (
size="sm" <Button
variant={showAdvancedTools ? "default" : "secondary"} size="sm"
onClick={() => setShowLedgerTools((current) => !current)} variant={showAdvancedTools ? "default" : "secondary"}
type="button" onClick={() => setShowLedgerTools((current) => !current)}
> type="button"
高级 / 台账 >
</Button> <Wrench aria-hidden className="mr-1 size-4" />
台账工具
</Button>
) : null}
</div> </div>
</div> </div>
@@ -506,7 +652,7 @@ export function ReimbursementsPage() {
</select> </select>
) : null} ) : null}
{canCreateExpense ? ( {canShowCreateOptionsMenu ? (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button size="sm" variant="secondary" type="button"> <Button size="sm" variant="secondary" type="button">
@@ -517,9 +663,7 @@ export function ReimbursementsPage() {
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400"> <div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">
从模板创建 从模板创建
</div> </div>
{templateOptionsQuery.isLoading ? ( {templateOptions.length ? (
<DropdownMenuItem disabled>正在加载模板...</DropdownMenuItem>
) : templateOptions.length ? (
templateOptions.map((template) => ( templateOptions.map((template) => (
<DropdownMenuItem <DropdownMenuItem
key={template.id} key={template.id}
@@ -529,16 +673,14 @@ export function ReimbursementsPage() {
</DropdownMenuItem> </DropdownMenuItem>
)) ))
) : ( ) : (
<DropdownMenuItem disabled>暂无可用模板</DropdownMenuItem> <DropdownMenuItem disabled>当前无可用模板</DropdownMenuItem>
)} )}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400"> <div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">
关联差旅申请 关联差旅申请
</div> </div>
{linkableTravelRequestsQuery.isLoading ? ( {linkableTravelRequests.length ? (
<DropdownMenuItem disabled>正在加载差旅申请...</DropdownMenuItem>
) : linkableTravelRequests.length ? (
linkableTravelRequests.map((travelRequest) => ( linkableTravelRequests.map((travelRequest) => (
<DropdownMenuItem <DropdownMenuItem
key={travelRequest.id} key={travelRequest.id}
@@ -548,7 +690,7 @@ export function ReimbursementsPage() {
</DropdownMenuItem> </DropdownMenuItem>
)) ))
) : ( ) : (
<DropdownMenuItem disabled>暂无可关联的差旅申请</DropdownMenuItem> <DropdownMenuItem disabled>当前无可关联申请</DropdownMenuItem>
)} )}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -660,6 +802,7 @@ export function ReimbursementsPage() {
const actionLabel = ["draft", "rejected"].includes(item.status) ? "继续编辑" : "查看详情"; const actionLabel = ["draft", "rejected"].includes(item.status) ? "继续编辑" : "查看详情";
const displayCode = resolveReimbursementDisplayCode(item); const displayCode = resolveReimbursementDisplayCode(item);
const canDeleteDraft = item.status === "draft" && canDeleteExpense; const canDeleteDraft = item.status === "draft" && canDeleteExpense;
const workflowHint = resolveWorkflowHint(item);
return ( return (
<article <article
@@ -700,9 +843,9 @@ export function ReimbursementsPage() {
</div> </div>
</div> </div>
{item.pendingWorkflow?.task?.id ? ( {workflowHint ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700"> <div className={`rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs ${workflowHint.className}`}>
待办:{item.pendingWorkflow.task.stepName} {workflowHint.label}
</div> </div>
) : null} ) : null}
@@ -750,7 +893,19 @@ export function ReimbursementsPage() {
}) })
) : ( ) : (
<div className="rounded-xl border border-dashed border-zinc-300 px-4 py-8 text-center text-sm text-zinc-500"> <div className="rounded-xl border border-dashed border-zinc-300 px-4 py-8 text-center text-sm text-zinc-500">
暂无数据 <div className="font-semibold text-zinc-900">{emptyState.title}</div>
<div className="mt-1 leading-6 text-zinc-500">{emptyState.description}</div>
{status === "draft" && canCreateExpense ? (
<Button
className="mt-3"
disabled={createMutation.isPending}
onClick={() => startCreateReimbursement()}
size="sm"
type="button"
>
{createMutation.isPending ? "创建中..." : "新建报销"}
</Button>
) : null}
</div> </div>
)} )}
</div> </div>
@@ -776,6 +931,7 @@ export function ReimbursementsPage() {
); );
const displayCode = resolveReimbursementDisplayCode(item); const displayCode = resolveReimbursementDisplayCode(item);
const canDeleteDraft = item.status === "draft" && canDeleteExpense; const canDeleteDraft = item.status === "draft" && canDeleteExpense;
const workflowHint = resolveWorkflowHint(item);
return ( return (
<tr className="table-row" data-testid="reimbursement-row" key={item.id}> <tr className="table-row" data-testid="reimbursement-row" key={item.id}>
@@ -800,9 +956,9 @@ export function ReimbursementsPage() {
项目:{item.project.projectNo} · {item.project.name} 项目:{item.project.projectNo} · {item.project.name}
</div> </div>
) : null} ) : null}
{item.pendingWorkflow?.task?.id ? ( {workflowHint ? (
<div className="text-xs text-amber-700"> <div className={`text-xs ${workflowHint.className}`}>
待办:{item.pendingWorkflow.task.stepName} {workflowHint.label}
</div> </div>
) : null} ) : null}
</td> </td>
@@ -869,7 +1025,21 @@ export function ReimbursementsPage() {
) : ( ) : (
<tr> <tr>
<td className="py-8 text-center text-sm text-zinc-500" colSpan={batchMode ? 7 : 6}> <td className="py-8 text-center text-sm text-zinc-500" colSpan={batchMode ? 7 : 6}>
暂无数据 <div className="mx-auto max-w-sm py-3">
<div className="font-semibold text-zinc-900">{emptyState.title}</div>
<div className="mt-1 leading-6 text-zinc-500">{emptyState.description}</div>
{status === "draft" && canCreateExpense ? (
<Button
className="mt-3"
disabled={createMutation.isPending}
onClick={() => startCreateReimbursement()}
size="sm"
type="button"
>
{createMutation.isPending ? "创建中..." : "新建报销"}
</Button>
) : null}
</div>
</td> </td>
</tr> </tr>
)} )}
@@ -983,7 +1153,6 @@ export function ReimbursementsPage() {
<div className="font-medium text-zinc-900"> <div className="font-medium text-zinc-900">
{resolveReimbursementDisplayCode({ formNumber: item.formNumber, id: item.reimbursementId })} {resolveReimbursementDisplayCode({ formNumber: item.formNumber, id: item.reimbursementId })}
</div> </div>
<div className="text-xs text-zinc-500">taskId: {item.taskId}</div>
</div> </div>
<div className="text-sm font-medium text-zinc-700"> <div className="text-sm font-medium text-zinc-700">
{formatBatchOutcome(batchResult.action, item.outcome)} {formatBatchOutcome(batchResult.action, item.outcome)}
@@ -36,11 +36,9 @@ test("E2E 公告与团队状态:公告可见 + 今日状态更新 + Portal 汇
await page.getByRole("button", { name: "保存状态" }).click(); await page.getByRole("button", { name: "保存状态" }).click();
await expect(page.getByText("今日状态已更新")).toBeVisible({ timeout: 30_000 }); await expect(page.getByText("今日状态已更新")).toBeVisible({ timeout: 30_000 });
await page.goto("/portal"); await page.goto("/portal");
await page.locator("summary").filter({ hasText: "团队与通知" }).click(); await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
await page.getByRole("tab", { name: "公告" }).click(); await expect(page.locator("summary").filter({ hasText: "团队与通知" })).not.toBeVisible();
await expect(page.getByText(announcementTitle)).toBeVisible(); await expect(page.getByText(announcementTitle)).not.toBeVisible();
await page.locator("summary").filter({ hasText: "更多入口" }).click();
await expect(page.locator('[aria-label="portal-card-today_presence"]')).toBeVisible();
await logout(page, EMPLOYEE.name); await logout(page, EMPLOYEE.name);
@@ -51,8 +49,8 @@ test("E2E 公告与团队状态:公告可见 + 今日状态更新 + Portal 汇
await expect(page.getByRole("button", { name: "不在岗" })).toBeVisible(); await expect(page.getByRole("button", { name: "不在岗" })).toBeVisible();
await page.goto("/portal"); await page.goto("/portal");
await page.getByRole("tab", { name: "公告" }).click(); await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
await expect(page.getByText(announcementTitle)).toBeVisible(); await expect(page.getByText(announcementTitle)).not.toBeVisible();
await expect(page.getByTestId(TEST_IDS.topbar.profileTrigger)).toBeVisible(); await expect(page.getByTestId(TEST_IDS.topbar.profileTrigger)).toBeVisible();
} finally { } finally {
await adminClient.dispose(); await adminClient.dispose();
+1 -1
查看文件
@@ -24,7 +24,7 @@ type Envelope<T> = {
export async function assertPortalLoaded(page: Page) { export async function assertPortalLoaded(page: Page) {
await expect(page).toHaveURL(/\/portal/); await expect(page).toHaveURL(/\/portal/);
await expect(page.getByRole("link", { name: "工作台", exact: true }).first()).toBeVisible(); await expect(page.getByRole("link", { name: "工作台", exact: true }).first()).toBeVisible();
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible(); await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
} }
export async function assertForbiddenPage(page: Page) { export async function assertForbiddenPage(page: Page) {
+26 -54
查看文件
@@ -508,30 +508,17 @@ test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => {
await login(page, EMPLOYEE); await login(page, EMPLOYEE);
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible(); await expect(page.getByTestId("portal-work-summary")).toContainText("今天有 4 件待处理");
await expect(page.getByText("当前焦点")).toBeVisible(); await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
await expect(page.getByText("下一步")).toBeVisible(); await expect(page.getByRole("link", { name: /待办事项 4 条.*查看待办/ })).toBeVisible();
await expect(page.getByRole("link", { name: "新建报销" })).toBeVisible(); await expect(page.getByRole("link", { name: /本周周报待填写.*填写周报/ })).toBeVisible();
await expect(page.getByText("当前焦点")).not.toBeVisible();
await page.locator("summary").filter({ hasText: "更多入口" }).click(); await expect(page.getByText("下一步", { exact: true })).not.toBeVisible();
const labels = await page.locator('[aria-label^="portal-card-"]').evaluateAll((nodes) => await expect(page.locator("summary").filter({ hasText: "团队与通知" })).not.toBeVisible();
nodes.map((node) => node.getAttribute("aria-label")), await expect(page.locator("summary").filter({ hasText: "更多入口" })).not.toBeVisible();
);
expect(labels.slice(0, 4)).toEqual([
"portal-card-my_todos",
"portal-card-today_presence",
"portal-card-my_weekly_report",
"portal-card-my_projects",
]);
await expect(page.locator("summary").filter({ hasText: "团队与通知" })).toBeVisible();
await page.locator("summary").filter({ hasText: "团队与通知" }).click();
await expect(page.getByRole("tab", { name: "公告" })).toBeVisible();
await expect(page.getByRole("heading", { name: "团队状态" })).toBeVisible();
await expect(page.getByRole("heading", { name: "通知" })).toBeVisible();
}); });
test("Portal 管理视角重排正确,更多入口默认折叠", async ({ page }) => { test("Portal 管理视角按角色分发待处理和团队卡点", async ({ page }) => {
await mockPasswordLogin(page); await mockPasswordLogin(page);
await mockNavigation(page, "manager"); await mockNavigation(page, "manager");
await mockPortalHome(page, "manager"); await mockPortalHome(page, "manager");
@@ -541,26 +528,13 @@ test("Portal 管理视角重排正确,更多入口默认折叠", async ({ page
await login(page, MANAGER); await login(page, MANAGER);
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible(); await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
await expect(page.getByRole("link", { name: "处理待办" })).toBeVisible(); await expect(page.getByRole("link", { name: /待我处理 3 条.*去处理/ })).toBeVisible();
await expect(page.getByRole("heading", { name: "团队卡点" })).toBeVisible();
await page.locator("summary").filter({ hasText: "角色重点" }).click(); await expect(page.getByRole("link", { name: /项目风险未关闭.*处理风险/ })).toBeVisible();
const labels = await page.locator('[aria-label^="portal-card-"]').evaluateAll((nodes) => await expect(page.locator("summary").filter({ hasText: "团队与通知" })).not.toBeVisible();
nodes.map((node) => node.getAttribute("aria-label")), await expect(page.locator("summary").filter({ hasText: "角色重点" })).not.toBeVisible();
); await expect(page.getByText("试点观测摘要")).not.toBeVisible();
expect(labels.slice(0, 4)).toEqual([
"portal-card-pending_approvals",
"portal-card-team_presence",
"portal-card-weekly_missing",
"portal-card-weekly_help",
]);
await expect(page.locator("summary").filter({ hasText: "团队与通知" })).toBeVisible();
await expect(page.getByText("试点观测摘要")).toBeVisible();
await expect(page.getByRole("link", { name: "采购申请" })).toBeVisible();
await expect(page.getByRole("link", { name: "差旅申请" })).not.toBeVisible();
await page.locator("summary").filter({ hasText: "更多" }).click();
await expect(page.getByRole("link", { name: "差旅申请" })).toBeVisible();
}); });
test("Portal 新员工只显示轻量欢迎入口,不再显示快速上手清单", async ({ page }) => { test("Portal 新员工只显示轻量欢迎入口,不再显示快速上手清单", async ({ page }) => {
@@ -598,7 +572,7 @@ test("Portal 新员工只显示轻量欢迎入口,不再显示快速上手清
await expect(page.getByText("完善个人资料")).not.toBeVisible(); await expect(page.getByText("完善个人资料")).not.toBeVisible();
await expect(page.getByText("了解请假流程")).not.toBeVisible(); await expect(page.getByText("了解请假流程")).not.toBeVisible();
await expect(page.getByText("查看通知中心")).not.toBeVisible(); await expect(page.getByText("查看通知中心")).not.toBeVisible();
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible(); await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
}); });
test("个人设置默认只展示账号通知和企业微信,不展示权限 code", async ({ page }) => { test("个人设置默认只展示账号通知和企业微信,不展示权限 code", async ({ page }) => {
@@ -668,11 +642,11 @@ test("Portal bootstrap refresh 首次瞬时失败后重试成功仍保留登录
await page.goto("/portal"); await page.goto("/portal");
await expect(page).toHaveURL(/\/portal/); await expect(page).toHaveURL(/\/portal/);
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible(); await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
expect(refreshAttempts).toBeGreaterThanOrEqual(2); expect(refreshAttempts).toBeGreaterThanOrEqual(2);
}); });
test("Portal 域管理角色也进入经理视图", async ({ page }) => { test("Portal 域管理角色也能看到团队卡点", async ({ page }) => {
await mockPasswordLogin(page); await mockPasswordLogin(page);
await mockNavigation(page, "manager"); await mockNavigation(page, "manager");
await mockPortalHome(page, "manager"); await mockPortalHome(page, "manager");
@@ -682,14 +656,12 @@ test("Portal 域管理角色也进入经理视图", async ({ page }) => {
await login(page, EMPLOYEE); await login(page, EMPLOYEE);
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible(); await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
await expect(page.locator("summary").filter({ hasText: "角色重点" })).toBeVisible(); await expect(page.getByRole("heading", { name: "团队卡点" })).toBeVisible();
await page.locator("summary").filter({ hasText: "角色重点" }).click(); await expect(page.getByRole("link", { name: /未交周报/ })).toBeVisible();
await expect(page.locator('[aria-label="portal-card-pending_approvals"]')).toBeVisible();
await expect(page.locator('[aria-label="portal-card-team_presence"]')).toBeVisible();
}); });
test("Portal 系统摘要审计日志入口指向实际路由", async ({ page }) => { test("Portal 管理提醒正常时不显示空壳", async ({ page }) => {
await mockPasswordLogin(page); await mockPasswordLogin(page);
await mockNavigation(page, "manager"); await mockNavigation(page, "manager");
await mockPortalHome(page, "manager"); await mockPortalHome(page, "manager");
@@ -699,9 +671,9 @@ test("Portal 系统摘要审计日志入口指向实际路由", async ({ page })
await login(page, ADMIN); await login(page, ADMIN);
await page.locator("summary").filter({ hasText: "角色重点" }).click(); await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
await expect(page.getByRole("heading", { name: "系统管理" })).toBeVisible(); await expect(page.getByRole("heading", { name: "管理提醒" })).not.toBeVisible();
await expect(page.getByRole("link", { name: /审计日志.*查看/ })).toHaveAttribute("href", "/audit-logs"); await expect(page.getByText("管理员按需查看")).not.toBeVisible();
}); });
test("企微 WebView 下默认进入移动工作台", async ({ page }) => { test("企微 WebView 下默认进入移动工作台", async ({ page }) => {
@@ -194,11 +194,9 @@ test("R70 深层自动辅助试运行:周报待办、公告团队状态、真
await expect(page.getByText("今日状态已更新")).toBeVisible({ timeout: 30_000 }); await expect(page.getByText("今日状态已更新")).toBeVisible({ timeout: 30_000 });
await page.goto("/portal"); await page.goto("/portal");
await page.locator("summary").filter({ hasText: "团队与通知" }).click(); await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
await page.getByRole("tab", { name: "公告" }).click(); await expect(page.locator("summary").filter({ hasText: "团队与通知" })).not.toBeVisible();
await expect(page.getByText(announcementTitle)).toBeVisible({ timeout: 30_000 }); await expect(page.getByText(announcementTitle)).not.toBeVisible();
await page.locator("summary").filter({ hasText: "更多入口" }).click();
await expect(page.locator('[aria-label="portal-card-today_presence"]')).toBeVisible();
return announcementTitle; return announcementTitle;
}); });
@@ -316,8 +316,8 @@ test("PC 核心工作流 Wave2 视觉截图", async ({ page }) => {
await page.getByTestId("login-password").fill(visualUser.password); await page.getByTestId("login-password").fill(visualUser.password);
await page.getByTestId("login-submit").click(); await page.getByTestId("login-submit").click();
await expect(page).toHaveURL(/\/portal/); await expect(page).toHaveURL(/\/portal/);
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible(); await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
await expect(page.getByRole("link", { name: "处理待办" })).toBeVisible(); await expect(page.getByRole("link", { name: /待我处理.*去处理/ })).toBeVisible();
await capture(page, "02-portal"); await capture(page, "02-portal");
await page.goto("/reimbursements?status=reviewing&scope=all"); await page.goto("/reimbursements?status=reviewing&scope=all");
@@ -320,6 +320,8 @@ test.describe("R102 reimbursement main flow", () => {
await page.setViewportSize({ width: 1440, height: 960 }); await page.setViewportSize({ width: 1440, height: 960 });
await page.goto("/reimbursements?status=draft"); await page.goto("/reimbursements?status=draft");
await expect(page.getByTestId("reimbursements-task-card-draft")).toBeVisible();
await expect(page.getByRole("button", { name: "台账工具" })).toHaveCount(0);
await expect(page.getByTestId("reimbursement-row").getByText(formNumber)).toBeVisible(); await expect(page.getByTestId("reimbursement-row").getByText(formNumber)).toBeVisible();
await expect(page.getByText(reimbursementId)).toHaveCount(0); await expect(page.getByText(reimbursementId)).toHaveCount(0);
@@ -328,7 +330,7 @@ test.describe("R102 reimbursement main flow", () => {
await expect(page.getByTestId("action-confirm-dialog")).toContainText(formNumber); await expect(page.getByTestId("action-confirm-dialog")).toContainText(formNumber);
await page.getByTestId("action-confirm-submit").click(); await page.getByTestId("action-confirm-submit").click();
await expect(page.getByTestId("reimbursement-row")).toHaveCount(0); await expect(page.getByTestId("reimbursement-row")).toHaveCount(0);
await expect(page.getByRole("cell", { name: "暂无数据" })).toBeVisible(); await expect(page.getByTestId("reimbursements-table").getByText("还没有报销草稿")).toBeVisible();
}); });
test("详情页发票按当前简化流程添加并隐藏原始码值", async ({ page }) => { test("详情页发票按当前简化流程添加并隐藏原始码值", async ({ page }) => {
@@ -120,7 +120,7 @@ test.describe("R105 pretrial visual acceptance", () => {
await loginLiveUser(page, "employee"); await loginLiveUser(page, "employee");
await page.goto("/portal"); await page.goto("/portal");
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible(); await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
await expectNoLegacyDesignCopy(page); await expectNoLegacyDesignCopy(page);
await page.screenshot({ path: screenshotPath("desktop-portal.png"), fullPage: true }); await page.screenshot({ path: screenshotPath("desktop-portal.png"), fullPage: true });
@@ -290,7 +290,7 @@ test.describe("R7E 全站高风险操作二次确认", () => {
await page.getByTestId("action-confirm-submit").click(); await page.getByTestId("action-confirm-submit").click();
await expect.poll(() => calls.deleteReimbursement).toBe(1); await expect.poll(() => calls.deleteReimbursement).toBe(1);
await page.getByRole("button", { name: "高级 / 台账" }).click(); await page.getByRole("button", { name: "台账工具" }).click();
await page.getByTestId("reimbursements-export").click(); await page.getByTestId("reimbursements-export").click();
await page.getByTestId("reimbursements-export-csv").click(); await page.getByTestId("reimbursements-export-csv").click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("导出文件可能包含业务或个人信息"); await expect(page.getByTestId("action-confirm-dialog")).toContainText("导出文件可能包含业务或个人信息");
+185 -17
查看文件
@@ -1,58 +1,226 @@
import { mkdirSync } from "node:fs";
import path from "node:path";
import { expect, test, type Page, type TestInfo } from "@playwright/test"; import { expect, test, type Page, type TestInfo } from "@playwright/test";
import { EMPLOYEE, FINANCE, login, logout, type E2EUser } from "./helpers/auth"; import { EMPLOYEE, FINANCE, login, type E2EUser } from "./helpers/auth";
const ADMIN: E2EUser = { const ADMIN: E2EUser = {
identifier: "[email protected]", identifier: "[email protected]",
password: "AdminPassword1234", password: "AdminPassword1234",
name: "Admin", name: "Admin",
}; };
const SCREENSHOT_DIR = path.resolve(process.cwd(), "../output/playwright/portal-task-first-redesign");
async function attachScreenshot(page: Page, testInfo: TestInfo, name: string) { async function attachScreenshot(page: Page, testInfo: TestInfo, name: string) {
mkdirSync(SCREENSHOT_DIR, { recursive: true });
const body = await page.screenshot({ path: path.join(SCREENSHOT_DIR, name), fullPage: true });
await testInfo.attach(name, { await testInfo.attach(name, {
body: await page.screenshot({ fullPage: true }), body,
contentType: "image/png", contentType: "image/png",
}); });
} }
function envelope<T>(data: T) {
return { requestId: "visual-portal", data };
}
function portalPayload(mode: "employee" | "admin" | "finance") {
const base = {
workspace: { id: "workspace-visual", persisted: true, profile: "small_team_internal" },
greeting: { name: mode === "employee" ? "Employee" : mode === "finance" ? "Finance" : "Admin" },
myApplications: [{ domain: "reimbursement", count: 1, href: "/reimbursements" }],
riskSummary: null,
announcements: [],
quickLinks: [],
todayPresence: null,
weeklyReport: null,
recentActivities: [],
teamStatusBoard: [],
};
if (mode === "finance") {
return {
...base,
cards: [],
pendingTasks: { count: 1, href: "/workflow/tasks" },
teamPresence: null,
weeklySummary: null,
todoSummary: { openCount: 0, highPriorityCount: 0, href: "/todos" },
projectSummary: null,
peopleLifecycle: null,
roleCapabilities: {
canSubmitApplications: true,
canApproveTasks: true,
canUseFinance: true,
canManageSystem: false,
canViewTeam: true,
isAdminOnly: false,
},
financeSummary: { pendingBatchCount: 2, failedItemCount: 1, riskCount: 1, paymentBatchEnabled: true, href: "/ap-operations" },
adoptionSummary: null,
};
}
if (mode === "admin") {
return {
...base,
cards: [],
pendingTasks: { count: 0, href: "/workflow/tasks" },
teamPresence: { date: "2026-05-29", href: "/team", office: 6, remote: 1, out: 0, travel: 0, leave: 0, unspecified: 1, total: 8 },
weeklySummary: { weekStart: "2026-05-25", submittedCount: 6, missingCount: 2, needsHelpCount: 0, href: "/weekly-reports" },
todoSummary: { openCount: 0, highPriorityCount: 0, href: "/todos" },
projectSummary: { myCount: 3, overdueMilestonesCount: 1, highPriorityTaskCount: 0, openRiskCount: 0, followUpDueTodayCount: 0, closurePendingProjectCount: 0, href: "/projects" },
peopleLifecycle: { onboardingCount: 0, offboardingCount: 0, assetReturnPendingCount: 0, href: "/people" },
roleCapabilities: {
canSubmitApplications: false,
canApproveTasks: false,
canUseFinance: false,
canManageSystem: true,
canViewTeam: true,
isAdminOnly: true,
},
financeSummary: null,
adoptionSummary: { activeEmployeeCount: 8, login7dCount: 8, weeklySubmittedCount: 6, weeklyMissingCount: 2, presenceUpdatedCount: 7, presenceMissingCount: 1, announcementCount: 0, login7dRate: 100, weeklySubmissionRate: 75, presenceCoverageRate: 87.5, hints: ["本周仍有 2 人未提交周报"] },
};
}
return {
...base,
cards: [],
pendingTasks: { count: 0, href: "/workflow/tasks" },
teamPresence: null,
weeklySummary: null,
todoSummary: { openCount: 2, highPriorityCount: 1, href: "/todos" },
projectSummary: { myCount: 2, overdueMilestonesCount: 0, highPriorityTaskCount: 1, openRiskCount: 0, followUpDueTodayCount: 0, closurePendingProjectCount: 0, href: "/projects" },
peopleLifecycle: null,
roleCapabilities: {
canSubmitApplications: true,
canApproveTasks: false,
canUseFinance: false,
canManageSystem: false,
canViewTeam: false,
isAdminOnly: false,
},
financeSummary: null,
adoptionSummary: null,
weeklyReport: { weekStart: "2026-05-25", status: "draft", needsHelp: false, href: "/weekly-reports" },
};
}
async function mockVisualPortal(page: Page, mode: "employee" | "admin" | "finance") {
await page.addInitScript(() => {
window.localStorage.removeItem("hyb_oa_show_welcome");
window.localStorage.removeItem("hyb_oa_onboarding_active");
window.localStorage.removeItem("hyb_oa_getting_started_dismissed");
});
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const pathName = new URL(route.request().url()).pathname;
if (pathName === "/api/auth/capabilities") {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({ emailDelivery: { ready: true, statusReasons: [] }, emailLoginCode: { enabled: true }, passwordReset: { enabled: true } })) });
return;
}
if (pathName === "/api/auth/sso/providers") {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope([])) });
return;
}
if (pathName === "/api/auth/wecom/config") {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({ enabled: false, entryEnabled: false, oauthEnabled: false, corpId: "", agentId: "", loginUrl: null, bindEnabled: false, defaultReturnTo: "/portal", status: "disabled", statusReasons: [] })) });
return;
}
if (pathName === "/api/auth/login") {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({ accessToken: "visual-token", expiresIn: 900 })) });
return;
}
if (pathName === "/api/me") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(envelope({
id: `visual-${mode}`,
employeeNo: "VISUAL",
name: mode === "employee" ? "Employee" : mode === "finance" ? "Finance" : "Admin",
email: `${mode}@hyinda.com`,
roleCodes: mode === "employee" ? ["employee"] : mode === "finance" ? ["finance"] : ["admin"],
notificationPushEnabled: true,
profileCompleteness: { hasEmergencyContact: true, hasBankAccount: true, hasAvatar: false, hasIdNumber: false, score: 50 },
wecomBinding: null,
})),
});
return;
}
if (pathName === "/api/system/navigation") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(envelope({
workspace: { id: "workspace-visual", persisted: true, profile: "small_team_internal" },
sections: {
main: [
{ key: "portal", href: "/portal", label: "工作台", icon: "home_app_logo", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "expense", href: "/reimbursements", label: "报销", icon: "request_quote", placement: "main", permissionCodes: [], featureFlagKey: null },
],
admin: [],
backup: [],
},
recommendedRolePresets: [],
})),
});
return;
}
if (pathName === "/api/notifications") {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({ page: 1, pageSize: 1, total: 0, unreadOnly: true, unreadCount: 0, items: [] })) });
return;
}
if (pathName === "/api/portal/home") {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope(portalPayload(mode))) });
return;
}
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({})) });
});
}
test.describe("visual portal", () => { test.describe("visual portal", () => {
test("员工 Portal 视觉基线", async ({ page }, testInfo) => { test("员工 Portal 视觉基线", async ({ page }, testInfo) => {
await mockVisualPortal(page, "employee");
await page.setViewportSize({ width: 1440, height: 1200 }); await page.setViewportSize({ width: 1440, height: 1200 });
await login(page, EMPLOYEE); await login(page, EMPLOYEE);
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible(); await expect(page.getByTestId("portal-work-summary")).toBeVisible();
await expect(page.getByText("当前焦点")).toBeVisible(); await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
await expect(page.getByText("下一步")).toBeVisible(); await expect(page.getByText("当前焦点")).not.toBeVisible();
await expect(page.locator("summary").filter({ hasText: "团队与通知" })).toBeVisible(); await expect(page.getByText("下一步", { exact: true })).not.toBeVisible();
await expect(page.locator("summary").filter({ hasText: "更多入口" })).toBeVisible(); await expect(page.locator("summary").filter({ hasText: "团队与通知" })).not.toBeVisible();
await attachScreenshot(page, testInfo, "portal-employee.png"); await attachScreenshot(page, testInfo, "portal-employee.png");
}); });
test("管理员 Portal 视觉基线", async ({ page }, testInfo) => { test("管理员 Portal 视觉基线", async ({ page }, testInfo) => {
await mockVisualPortal(page, "admin");
await page.setViewportSize({ width: 1440, height: 1200 }); await page.setViewportSize({ width: 1440, height: 1200 });
await login(page, ADMIN); await login(page, ADMIN);
await expect(page.locator('[aria-label="portal-card-pending_approvals"]')).toHaveCount(0); await expect(page.locator('[aria-label="portal-card-pending_approvals"]')).toHaveCount(0);
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible(); await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
await expect(page.getByText("下一步")).toBeVisible(); await expect(page.getByText("下一步", { exact: true })).not.toBeVisible();
await expect(page.locator("summary").filter({ hasText: "团队与通知" })).toBeVisible(); await expect(page.locator("summary").filter({ hasText: "团队与通知" })).not.toBeVisible();
await expect(page.locator("summary").filter({ hasText: "角色重点" })).toBeVisible(); await expect(page.locator("summary").filter({ hasText: "角色重点" })).not.toBeVisible();
await expect(page.getByText("试点观测摘要")).toBeVisible(); await expect(page.getByText("试点观测摘要")).not.toBeVisible();
await attachScreenshot(page, testInfo, "portal-admin.png"); await attachScreenshot(page, testInfo, "portal-admin.png");
await logout(page, ADMIN.name);
}); });
test("财务 Portal 显示财务待处理入口", async ({ page }, testInfo) => { test("财务 Portal 显示财务待处理入口", async ({ page }, testInfo) => {
await mockVisualPortal(page, "finance");
await page.setViewportSize({ width: 1440, height: 1200 }); await page.setViewportSize({ width: 1440, height: 1200 });
await login(page, FINANCE); await login(page, FINANCE);
await expect(page.getByText("财务工作台")).toBeVisible(); await expect(page.getByTestId("portal-work-summary")).toBeVisible();
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible(); await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
await expect(page.locator("summary").filter({ hasText: "角色重点" })).toBeVisible(); await expect(page.locator("summary").filter({ hasText: "角色重点" })).not.toBeVisible();
await attachScreenshot(page, testInfo, "portal-finance.png"); await attachScreenshot(page, testInfo, "portal-finance.png");
await logout(page, FINANCE.name);
}); });
}); });
+5 -5
查看文件
@@ -333,16 +333,16 @@ test.describe("visual shell", () => {
await page.setViewportSize({ width: 1440, height: 1024 }); await page.setViewportSize({ width: 1440, height: 1024 });
await loginVisualUser(page); await loginVisualUser(page);
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible(); await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
await expect(page.getByText("下一步")).toBeVisible(); await expect(page.getByText("下一步", { exact: true })).not.toBeVisible();
await expect(page.locator("summary").filter({ hasText: "团队与通知" })).toBeVisible(); await expect(page.locator("summary").filter({ hasText: "团队与通知" })).not.toBeVisible();
await expect(page.locator("summary").filter({ hasText: /角色重点|更多入口/ })).toBeVisible(); await expect(page.locator("summary").filter({ hasText: /角色重点|更多入口/ })).not.toBeVisible();
await attachScreenshot(page, testInfo, "pc-portal-wave1.png"); await attachScreenshot(page, testInfo, "pc-portal-wave1.png");
await page.goto("/reimbursements"); await page.goto("/reimbursements");
await expect(page.getByRole("heading", { name: "我的报销" })).toBeVisible(); await expect(page.getByRole("heading", { name: "我的报销" })).toBeVisible();
await expect(page.getByTestId("reimbursements-create")).toBeVisible(); await expect(page.getByTestId("reimbursements-create")).toBeVisible();
await expect(page.getByRole("button", { name: "高级 / 台账" })).toBeVisible(); await expect(page.getByRole("button", { name: "台账工具" })).toBeVisible();
await expect(page.getByTestId("reimbursements-export")).not.toBeVisible(); await expect(page.getByTestId("reimbursements-export")).not.toBeVisible();
await expect(page.getByTestId("reimbursements-status-tabs")).toBeVisible(); await expect(page.getByTestId("reimbursements-status-tabs")).toBeVisible();
await expect(page.getByTestId("reimbursements-table")).toBeVisible(); await expect(page.getByTestId("reimbursements-table")).toBeVisible();
+3
查看文件
@@ -46,6 +46,9 @@ A 类页面优先保证真实试运行路径顺畅;B 类页面优先保证台
- 同一业务对象在同一区域只展示一次主标识;同一状态在同一区域只展示一次。 - 同一业务对象在同一区域只展示一次主标识;同一状态在同一区域只展示一次。
- 通知中心是事件收件箱,不是待办列表或进度页副本;通知点击应直达业务详情、审批任务或可解释的失败提示。 - 通知中心是事件收件箱,不是待办列表或进度页副本;通知点击应直达业务详情、审批任务或可解释的失败提示。
- 首页只做工作台摘要,不重复完整待办列表、通知列表和进度列表。 - 首页只做工作台摘要,不重复完整待办列表、通知列表和进度列表。
- 首页首屏优先展示 `今日待处理`:覆盖所有需要当前用户行动的事项,例如待审批、待补材料、待付款/待复核、票据异常、配置缺口和明确分配给当前用户的团队动作;普通通知、已完成进度、只读动态和低频管理入口不算待处理。
- 首页不使用常驻欢迎大卡片、说明型折叠区或入口集合。首屏应使用紧凑状态摘要、可行动事项列表和条件出现的 `团队卡点` / `管理提醒` / `我的进度`,没有真实数据的区域不显示。
- `报销进度`、团队公告、试运行观测等不作为首页固定按钮或固定模块;只有存在可靠状态、真实阻塞或异常时才出现。
- 首页不承担常驻新手教学职责;首次欢迎提示只做一次性、可关闭的常用业务入口,最多 3 个,不放个人设置、通知中心或“了解流程”这类说明型入口。 - 首页不承担常驻新手教学职责;首次欢迎提示只做一次性、可关闭的常用业务入口,最多 3 个,不放个人设置、通知中心或“了解流程”这类说明型入口。
- 审批入口同时服务审批人和发起人:审批人处理待我审批,员工查看我发起的进度。 - 审批入口同时服务审批人和发起人:审批人处理待我审批,员工查看我发起的进度。
- 普通员工不应看到不可点或无权限的审批入口;审批、财务、负责人和管理员只能看到服务端允许的动作。 - 普通员工不应看到不可点或无权限的审批入口;审批、财务、负责人和管理员只能看到服务端允许的动作。