优化工作台与报销任务优先页面(portal-task-first-redesign)
这个提交包含在:
+101
@@ -793,6 +793,107 @@
|
||||
@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 {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid rgba(226, 232, 240, 0.94);
|
||||
|
||||
+407
-997
文件差异内容过多而无法显示
加载差异
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowRight, Clock3, FilePenLine, RotateCcw, Wrench } from "lucide-react";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { fetchMyDelegations } from "../api/delegations";
|
||||
@@ -74,6 +75,40 @@ const REIMBURSEMENT_STATUS_OPTIONS: Array<{ value: ReimbursementStatus; label: s
|
||||
{ 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) {
|
||||
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" ? "已通过" : "已驳回";
|
||||
}
|
||||
|
||||
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() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -126,14 +211,37 @@ export function ReimbursementsPage() {
|
||||
const canViewAll = hasPermission(permissions, "expense.view_all");
|
||||
const canApproveExpense = hasPermission(permissions, "expense.approve");
|
||||
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 showProxyOwnerSelector = canProxySubmit && isAdministration;
|
||||
const canShowLedgerEntry = canViewAll || showProxyOwnerSelector || canUseFinanceTools;
|
||||
const showAdvancedTools = canShowLedgerEntry && (showLedgerTools || scope === "all" || batchMode || showProxyOwnerSelector);
|
||||
const shouldLoadCreateOptions = showAdvancedTools && canCreateExpense;
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: ["reimbursements", "list", status, page, 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({
|
||||
enabled: showProxyOwnerSelector,
|
||||
queryKey: ["delegations", "mine", "active"],
|
||||
@@ -141,13 +249,13 @@ export function ReimbursementsPage() {
|
||||
});
|
||||
|
||||
const templateOptionsQuery = useQuery({
|
||||
enabled: canCreateExpense,
|
||||
enabled: shouldLoadCreateOptions,
|
||||
queryKey: ["reimbursement-templates", "enabled", "create-menu"],
|
||||
queryFn: () => fetchReimbursementTemplates({ enabled: true }),
|
||||
});
|
||||
|
||||
const linkableTravelRequestsQuery = useQuery({
|
||||
enabled: canCreateExpense,
|
||||
enabled: shouldLoadCreateOptions,
|
||||
queryKey: ["reimbursements", "linkable-travel-requests", proxyOwnerEmployeeId || me?.id || "self"],
|
||||
queryFn: () =>
|
||||
fetchLinkableTravelRequests({
|
||||
@@ -187,10 +295,17 @@ export function ReimbursementsPage() {
|
||||
);
|
||||
const canShowBatchEntry =
|
||||
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 allActionableSelected =
|
||||
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(() => {
|
||||
setSelectedReimbursementIds((current) => current.filter((id) => actionableIdSet.has(id)));
|
||||
@@ -399,18 +514,6 @@ export function ReimbursementsPage() {
|
||||
</p>
|
||||
) : 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 ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{exportError}
|
||||
@@ -423,25 +526,65 @@ export function ReimbursementsPage() {
|
||||
</p>
|
||||
) : 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">
|
||||
<div className="ops-command-bar__row">
|
||||
<Tabs
|
||||
data-testid="reimbursements-status-tabs"
|
||||
value={status}
|
||||
onValueChange={(value) => setSearchParams({ status: value, page: "1", scope })}
|
||||
>
|
||||
<TabsList className="ops-command-bar__tabs !h-auto !justify-start !bg-slate-50">
|
||||
{REIMBURSEMENT_STATUS_OPTIONS.map((item) => (
|
||||
<TabsTrigger
|
||||
data-testid={`reimbursements-tab-${item.value}`}
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
>
|
||||
{item.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="text-xs font-semibold text-zinc-500">报销记录</div>
|
||||
<Tabs
|
||||
data-testid="reimbursements-status-tabs"
|
||||
value={status}
|
||||
onValueChange={(value) => setSearchParams({ status: value, page: "1", scope })}
|
||||
>
|
||||
<TabsList className="ops-command-bar__tabs !h-auto !justify-start !bg-slate-50">
|
||||
{REIMBURSEMENT_STATUS_OPTIONS.map((item) => (
|
||||
<TabsTrigger
|
||||
data-testid={`reimbursements-tab-${item.value}`}
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
>
|
||||
{item.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="ops-command-bar__actions">
|
||||
{canApproveExpense ? (
|
||||
@@ -455,14 +598,17 @@ export function ReimbursementsPage() {
|
||||
待我处理
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant={showAdvancedTools ? "default" : "secondary"}
|
||||
onClick={() => setShowLedgerTools((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
高级 / 台账
|
||||
</Button>
|
||||
{canShowLedgerEntry ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={showAdvancedTools ? "default" : "secondary"}
|
||||
onClick={() => setShowLedgerTools((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
<Wrench aria-hidden className="mr-1 size-4" />
|
||||
台账工具
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -506,7 +652,7 @@ export function ReimbursementsPage() {
|
||||
</select>
|
||||
) : null}
|
||||
|
||||
{canCreateExpense ? (
|
||||
{canShowCreateOptionsMenu ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<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>
|
||||
{templateOptionsQuery.isLoading ? (
|
||||
<DropdownMenuItem disabled>正在加载模板...</DropdownMenuItem>
|
||||
) : templateOptions.length ? (
|
||||
{templateOptions.length ? (
|
||||
templateOptions.map((template) => (
|
||||
<DropdownMenuItem
|
||||
key={template.id}
|
||||
@@ -529,16 +673,14 @@ export function ReimbursementsPage() {
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
) : (
|
||||
<DropdownMenuItem disabled>暂无可用模板</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled>当前无可用模板</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">
|
||||
关联差旅申请
|
||||
</div>
|
||||
{linkableTravelRequestsQuery.isLoading ? (
|
||||
<DropdownMenuItem disabled>正在加载差旅申请...</DropdownMenuItem>
|
||||
) : linkableTravelRequests.length ? (
|
||||
{linkableTravelRequests.length ? (
|
||||
linkableTravelRequests.map((travelRequest) => (
|
||||
<DropdownMenuItem
|
||||
key={travelRequest.id}
|
||||
@@ -548,7 +690,7 @@ export function ReimbursementsPage() {
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
) : (
|
||||
<DropdownMenuItem disabled>暂无可关联的差旅申请</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled>当前无可关联申请</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -660,6 +802,7 @@ export function ReimbursementsPage() {
|
||||
const actionLabel = ["draft", "rejected"].includes(item.status) ? "继续编辑" : "查看详情";
|
||||
const displayCode = resolveReimbursementDisplayCode(item);
|
||||
const canDeleteDraft = item.status === "draft" && canDeleteExpense;
|
||||
const workflowHint = resolveWorkflowHint(item);
|
||||
|
||||
return (
|
||||
<article
|
||||
@@ -700,9 +843,9 @@ export function ReimbursementsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.pendingWorkflow?.task?.id ? (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||
待办:{item.pendingWorkflow.task.stepName}
|
||||
{workflowHint ? (
|
||||
<div className={`rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs ${workflowHint.className}`}>
|
||||
{workflowHint.label}
|
||||
</div>
|
||||
) : 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="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>
|
||||
@@ -776,6 +931,7 @@ export function ReimbursementsPage() {
|
||||
);
|
||||
const displayCode = resolveReimbursementDisplayCode(item);
|
||||
const canDeleteDraft = item.status === "draft" && canDeleteExpense;
|
||||
const workflowHint = resolveWorkflowHint(item);
|
||||
|
||||
return (
|
||||
<tr className="table-row" data-testid="reimbursement-row" key={item.id}>
|
||||
@@ -800,9 +956,9 @@ export function ReimbursementsPage() {
|
||||
项目:{item.project.projectNo} · {item.project.name}
|
||||
</div>
|
||||
) : null}
|
||||
{item.pendingWorkflow?.task?.id ? (
|
||||
<div className="text-xs text-amber-700">
|
||||
待办:{item.pendingWorkflow.task.stepName}
|
||||
{workflowHint ? (
|
||||
<div className={`text-xs ${workflowHint.className}`}>
|
||||
{workflowHint.label}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
@@ -869,7 +1025,21 @@ export function ReimbursementsPage() {
|
||||
) : (
|
||||
<tr>
|
||||
<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>
|
||||
</tr>
|
||||
)}
|
||||
@@ -983,7 +1153,6 @@ export function ReimbursementsPage() {
|
||||
<div className="font-medium text-zinc-900">
|
||||
{resolveReimbursementDisplayCode({ formNumber: item.formNumber, id: item.reimbursementId })}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">taskId: {item.taskId}</div>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-zinc-700">
|
||||
{formatBatchOutcome(batchResult.action, item.outcome)}
|
||||
|
||||
@@ -36,11 +36,9 @@ test("E2E 公告与团队状态:公告可见 + 今日状态更新 + Portal 汇
|
||||
await page.getByRole("button", { name: "保存状态" }).click();
|
||||
await expect(page.getByText("今日状态已更新")).toBeVisible({ timeout: 30_000 });
|
||||
await page.goto("/portal");
|
||||
await page.locator("summary").filter({ hasText: "团队与通知" }).click();
|
||||
await page.getByRole("tab", { name: "公告" }).click();
|
||||
await expect(page.getByText(announcementTitle)).toBeVisible();
|
||||
await page.locator("summary").filter({ hasText: "更多入口" }).click();
|
||||
await expect(page.locator('[aria-label="portal-card-today_presence"]')).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
|
||||
await expect(page.locator("summary").filter({ hasText: "团队与通知" })).not.toBeVisible();
|
||||
await expect(page.getByText(announcementTitle)).not.toBeVisible();
|
||||
|
||||
await logout(page, EMPLOYEE.name);
|
||||
|
||||
@@ -51,8 +49,8 @@ test("E2E 公告与团队状态:公告可见 + 今日状态更新 + Portal 汇
|
||||
await expect(page.getByRole("button", { name: "不在岗" })).toBeVisible();
|
||||
|
||||
await page.goto("/portal");
|
||||
await page.getByRole("tab", { name: "公告" }).click();
|
||||
await expect(page.getByText(announcementTitle)).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
|
||||
await expect(page.getByText(announcementTitle)).not.toBeVisible();
|
||||
await expect(page.getByTestId(TEST_IDS.topbar.profileTrigger)).toBeVisible();
|
||||
} finally {
|
||||
await adminClient.dispose();
|
||||
|
||||
@@ -24,7 +24,7 @@ type Envelope<T> = {
|
||||
export async function assertPortalLoaded(page: Page) {
|
||||
await expect(page).toHaveURL(/\/portal/);
|
||||
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) {
|
||||
|
||||
@@ -508,30 +508,17 @@ test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => {
|
||||
|
||||
await login(page, EMPLOYEE);
|
||||
|
||||
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible();
|
||||
await expect(page.getByText("当前焦点")).toBeVisible();
|
||||
await expect(page.getByText("下一步")).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "新建报销" })).toBeVisible();
|
||||
|
||||
await page.locator("summary").filter({ hasText: "更多入口" }).click();
|
||||
const labels = await page.locator('[aria-label^="portal-card-"]').evaluateAll((nodes) =>
|
||||
nodes.map((node) => node.getAttribute("aria-label")),
|
||||
);
|
||||
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();
|
||||
await expect(page.getByTestId("portal-work-summary")).toContainText("今天有 4 件待处理");
|
||||
await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: /待办事项 4 条.*查看待办/ })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: /本周周报待填写.*填写周报/ })).toBeVisible();
|
||||
await expect(page.getByText("当前焦点")).not.toBeVisible();
|
||||
await expect(page.getByText("下一步", { exact: true })).not.toBeVisible();
|
||||
await expect(page.locator("summary").filter({ hasText: "团队与通知" })).not.toBeVisible();
|
||||
await expect(page.locator("summary").filter({ hasText: "更多入口" })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("Portal 管理视角重排正确,更多入口默认折叠", async ({ page }) => {
|
||||
test("Portal 管理视角按角色分发待处理和团队卡点", async ({ page }) => {
|
||||
await mockPasswordLogin(page);
|
||||
await mockNavigation(page, "manager");
|
||||
await mockPortalHome(page, "manager");
|
||||
@@ -541,26 +528,13 @@ test("Portal 管理视角重排正确,更多入口默认折叠", async ({ page
|
||||
|
||||
await login(page, MANAGER);
|
||||
|
||||
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "处理待办" })).toBeVisible();
|
||||
|
||||
await page.locator("summary").filter({ hasText: "角色重点" }).click();
|
||||
const labels = await page.locator('[aria-label^="portal-card-"]').evaluateAll((nodes) =>
|
||||
nodes.map((node) => node.getAttribute("aria-label")),
|
||||
);
|
||||
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();
|
||||
await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: /待我处理 3 条.*去处理/ })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "团队卡点" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: /项目风险未关闭.*处理风险/ })).toBeVisible();
|
||||
await expect(page.locator("summary").filter({ hasText: "团队与通知" })).not.toBeVisible();
|
||||
await expect(page.locator("summary").filter({ hasText: "角色重点" })).not.toBeVisible();
|
||||
await expect(page.getByText("试点观测摘要")).not.toBeVisible();
|
||||
});
|
||||
|
||||
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.getByRole("heading", { name: "今日任务" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("个人设置默认只展示账号通知和企业微信,不展示权限 code", async ({ page }) => {
|
||||
@@ -668,11 +642,11 @@ test("Portal bootstrap refresh 首次瞬时失败后重试成功仍保留登录
|
||||
await page.goto("/portal");
|
||||
|
||||
await expect(page).toHaveURL(/\/portal/);
|
||||
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
|
||||
expect(refreshAttempts).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("Portal 域管理角色也进入经理视图", async ({ page }) => {
|
||||
test("Portal 域管理角色也能看到团队卡点", async ({ page }) => {
|
||||
await mockPasswordLogin(page);
|
||||
await mockNavigation(page, "manager");
|
||||
await mockPortalHome(page, "manager");
|
||||
@@ -682,14 +656,12 @@ test("Portal 域管理角色也进入经理视图", async ({ page }) => {
|
||||
|
||||
await login(page, EMPLOYEE);
|
||||
|
||||
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible();
|
||||
await expect(page.locator("summary").filter({ hasText: "角色重点" })).toBeVisible();
|
||||
await page.locator("summary").filter({ hasText: "角色重点" }).click();
|
||||
await expect(page.locator('[aria-label="portal-card-pending_approvals"]')).toBeVisible();
|
||||
await expect(page.locator('[aria-label="portal-card-team_presence"]')).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "团队卡点" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: /未交周报/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test("Portal 系统摘要审计日志入口指向实际路由", async ({ page }) => {
|
||||
test("Portal 管理提醒正常时不显示空壳", async ({ page }) => {
|
||||
await mockPasswordLogin(page);
|
||||
await mockNavigation(page, "manager");
|
||||
await mockPortalHome(page, "manager");
|
||||
@@ -699,9 +671,9 @@ test("Portal 系统摘要审计日志入口指向实际路由", async ({ page })
|
||||
|
||||
await login(page, ADMIN);
|
||||
|
||||
await page.locator("summary").filter({ hasText: "角色重点" }).click();
|
||||
await expect(page.getByRole("heading", { name: "系统管理" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: /审计日志.*查看/ })).toHaveAttribute("href", "/audit-logs");
|
||||
await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "管理提醒" })).not.toBeVisible();
|
||||
await expect(page.getByText("管理员按需查看")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("企微 WebView 下默认进入移动工作台", async ({ page }) => {
|
||||
|
||||
@@ -194,11 +194,9 @@ test("R70 深层自动辅助试运行:周报待办、公告团队状态、真
|
||||
await expect(page.getByText("今日状态已更新")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.goto("/portal");
|
||||
await page.locator("summary").filter({ hasText: "团队与通知" }).click();
|
||||
await page.getByRole("tab", { name: "公告" }).click();
|
||||
await expect(page.getByText(announcementTitle)).toBeVisible({ timeout: 30_000 });
|
||||
await page.locator("summary").filter({ hasText: "更多入口" }).click();
|
||||
await expect(page.locator('[aria-label="portal-card-today_presence"]')).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
|
||||
await expect(page.locator("summary").filter({ hasText: "团队与通知" })).not.toBeVisible();
|
||||
await expect(page.getByText(announcementTitle)).not.toBeVisible();
|
||||
return announcementTitle;
|
||||
});
|
||||
|
||||
|
||||
@@ -316,8 +316,8 @@ test("PC 核心工作流 Wave2 视觉截图", async ({ page }) => {
|
||||
await page.getByTestId("login-password").fill(visualUser.password);
|
||||
await page.getByTestId("login-submit").click();
|
||||
await expect(page).toHaveURL(/\/portal/);
|
||||
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "处理待办" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: /待我处理.*去处理/ })).toBeVisible();
|
||||
await capture(page, "02-portal");
|
||||
|
||||
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.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.getByText(reimbursementId)).toHaveCount(0);
|
||||
|
||||
@@ -328,7 +330,7 @@ test.describe("R102 reimbursement main flow", () => {
|
||||
await expect(page.getByTestId("action-confirm-dialog")).toContainText(formNumber);
|
||||
await page.getByTestId("action-confirm-submit").click();
|
||||
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 }) => {
|
||||
|
||||
@@ -120,7 +120,7 @@ test.describe("R105 pretrial visual acceptance", () => {
|
||||
await loginLiveUser(page, "employee");
|
||||
|
||||
await page.goto("/portal");
|
||||
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
|
||||
await expectNoLegacyDesignCopy(page);
|
||||
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 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-csv").click();
|
||||
await expect(page.getByTestId("action-confirm-dialog")).toContainText("导出文件可能包含业务或个人信息");
|
||||
|
||||
@@ -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 { EMPLOYEE, FINANCE, login, logout, type E2EUser } from "./helpers/auth";
|
||||
import { EMPLOYEE, FINANCE, login, type E2EUser } from "./helpers/auth";
|
||||
|
||||
const ADMIN: E2EUser = {
|
||||
identifier: "[email protected]",
|
||||
password: "AdminPassword1234",
|
||||
name: "Admin",
|
||||
};
|
||||
const SCREENSHOT_DIR = path.resolve(process.cwd(), "../output/playwright/portal-task-first-redesign");
|
||||
|
||||
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, {
|
||||
body: await page.screenshot({ fullPage: true }),
|
||||
body,
|
||||
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("员工 Portal 视觉基线", async ({ page }, testInfo) => {
|
||||
await mockVisualPortal(page, "employee");
|
||||
await page.setViewportSize({ width: 1440, height: 1200 });
|
||||
await login(page, EMPLOYEE);
|
||||
|
||||
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible();
|
||||
await expect(page.getByText("当前焦点")).toBeVisible();
|
||||
await expect(page.getByText("下一步")).toBeVisible();
|
||||
await expect(page.locator("summary").filter({ hasText: "团队与通知" })).toBeVisible();
|
||||
await expect(page.locator("summary").filter({ hasText: "更多入口" })).toBeVisible();
|
||||
await expect(page.getByTestId("portal-work-summary")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
|
||||
await expect(page.getByText("当前焦点")).not.toBeVisible();
|
||||
await expect(page.getByText("下一步", { exact: true })).not.toBeVisible();
|
||||
await expect(page.locator("summary").filter({ hasText: "团队与通知" })).not.toBeVisible();
|
||||
|
||||
await attachScreenshot(page, testInfo, "portal-employee.png");
|
||||
});
|
||||
|
||||
test("管理员 Portal 视觉基线", async ({ page }, testInfo) => {
|
||||
await mockVisualPortal(page, "admin");
|
||||
await page.setViewportSize({ width: 1440, height: 1200 });
|
||||
await login(page, ADMIN);
|
||||
|
||||
await expect(page.locator('[aria-label="portal-card-pending_approvals"]')).toHaveCount(0);
|
||||
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible();
|
||||
await expect(page.getByText("下一步")).toBeVisible();
|
||||
await expect(page.locator("summary").filter({ hasText: "团队与通知" })).toBeVisible();
|
||||
await expect(page.locator("summary").filter({ hasText: "角色重点" })).toBeVisible();
|
||||
await expect(page.getByText("试点观测摘要")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
|
||||
await expect(page.getByText("下一步", { exact: true })).not.toBeVisible();
|
||||
await expect(page.locator("summary").filter({ hasText: "团队与通知" })).not.toBeVisible();
|
||||
await expect(page.locator("summary").filter({ hasText: "角色重点" })).not.toBeVisible();
|
||||
await expect(page.getByText("试点观测摘要")).not.toBeVisible();
|
||||
|
||||
await attachScreenshot(page, testInfo, "portal-admin.png");
|
||||
await logout(page, ADMIN.name);
|
||||
});
|
||||
|
||||
test("财务 Portal 显示财务待处理入口", async ({ page }, testInfo) => {
|
||||
await mockVisualPortal(page, "finance");
|
||||
await page.setViewportSize({ width: 1440, height: 1200 });
|
||||
await login(page, FINANCE);
|
||||
|
||||
await expect(page.getByText("财务工作台")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible();
|
||||
await expect(page.locator("summary").filter({ hasText: "角色重点" })).toBeVisible();
|
||||
await expect(page.getByTestId("portal-work-summary")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
|
||||
await expect(page.locator("summary").filter({ hasText: "角色重点" })).not.toBeVisible();
|
||||
|
||||
await attachScreenshot(page, testInfo, "portal-finance.png");
|
||||
await logout(page, FINANCE.name);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -333,16 +333,16 @@ test.describe("visual shell", () => {
|
||||
await page.setViewportSize({ width: 1440, height: 1024 });
|
||||
await loginVisualUser(page);
|
||||
|
||||
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible();
|
||||
await expect(page.getByText("下一步")).toBeVisible();
|
||||
await expect(page.locator("summary").filter({ hasText: "团队与通知" })).toBeVisible();
|
||||
await expect(page.locator("summary").filter({ hasText: /角色重点|更多入口/ })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
|
||||
await expect(page.getByText("下一步", { exact: true })).not.toBeVisible();
|
||||
await expect(page.locator("summary").filter({ hasText: "团队与通知" })).not.toBeVisible();
|
||||
await expect(page.locator("summary").filter({ hasText: /角色重点|更多入口/ })).not.toBeVisible();
|
||||
await attachScreenshot(page, testInfo, "pc-portal-wave1.png");
|
||||
|
||||
await page.goto("/reimbursements");
|
||||
await expect(page.getByRole("heading", { name: "我的报销" })).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-status-tabs")).toBeVisible();
|
||||
await expect(page.getByTestId("reimbursements-table")).toBeVisible();
|
||||
|
||||
在新工单中引用
屏蔽一个用户