R31-1 R31-4: 收口详情页工作台化与 E2E 契约

这个提交包含在:
2026-03-24 08:58:36 +08:00
父节点 994d98e74c
当前提交 279be83931
共修改 17 个文件,包含 906 行新增和 455 行删除
+51
查看文件
@@ -0,0 +1,51 @@
export const TEST_IDS = {
login: {
identifier: "login-identifier",
password: "login-password",
submit: "login-submit",
},
topbar: {
profileTrigger: "topbar-profile-trigger",
},
workflow: {
tasksTable: "workflow-tasks-table",
taskRow: "workflow-task-row",
detailLink: "workflow-task-detail-link",
summary: "workflow-task-summary",
approve: "workflow-task-approve",
reject: "workflow-task-reject",
rejectComment: "workflow-task-reject-comment",
returnBtn: "workflow-task-return",
returnComment: "workflow-task-return-comment",
transferApprover: "workflow-task-transfer-approver",
transferReason: "workflow-task-transfer-reason",
transferBtn: "workflow-task-transfer",
actionError: "workflow-task-action-error",
overview: "workflow-task-overview",
},
reimbursement: {
summary: "reimbursement-summary",
items: "reimbursement-items",
itemAdd: "reimbursement-item-add",
submit: "reimbursement-submit",
withdraw: "reimbursement-withdraw",
pay: "reimbursement-pay",
budgetHint: "reimbursement-budget-hint",
erpExport: "reimbursement-erp-export",
overview: "reimbursement-overview",
},
weeklyReport: {
save: "weekly-report-save",
submit: "weekly-report-submit",
createTodo: "weekly-report-create-todo",
},
todo: {
createTitle: "todo-create-title",
createSubmit: "todo-create-submit",
row: "todo-row",
complete: "todo-complete",
},
audit: {
export: "audit-export",
},
} as const;
+167 -104
查看文件
@@ -23,11 +23,15 @@ import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { CardSkeleton } from "../components/common/CardSkeleton";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { PageHeader } from "../components/common/PageHeader";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { StatusBadge } from "../components/common/StatusBadge";
import { AiSuggestionCard } from "../components/ai/AiSuggestionCard";
import { Button } from "../components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { ReimbursementActions, type PayPayload } from "./reimbursement-detail/ReimbursementActions";
import {
ReimbursementItemEditor,
@@ -80,7 +84,7 @@ export function ReimbursementDetailPage() {
const erpPreviewQuery = useQuery({
enabled: Boolean(reimbursementId) && canManageIntegration && detailQuery.data?.status === "paid",
queryKey: ["reimbursements", reimbursementId, "erp-journal-preview"],
queryKey: ["reimbursements", reimbursementId, "integration-journal-preview"],
queryFn: () => previewReimbursementJournal(reimbursementId),
});
@@ -247,7 +251,7 @@ export function ReimbursementDetailPage() {
},
onSuccess: async (result) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "erp-journal-preview"] }),
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "integration-journal-preview"] }),
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
]);
setActionError("");
@@ -390,18 +394,51 @@ export function ReimbursementDetailPage() {
};
return (
<div className="space-y-6">
<PageHeader
<PageScaffold>
<EntityOverviewHero
title={`报销详情 ${detail.formNumber ?? detail.id}`}
subtitle={`状态:${detail.status}`}
actions={
subtitle="在同一工作台内查看概览、处理明细、跟进风险并进入财务桥接。"
meta={(
<>
<StatusBadge status={detail.status} />
<span className="info-chip">报销日期:{detail.reimbursementDate ?? "-"}</span>
<span className="info-chip">报销人:{detail.owner?.name ?? "-"}</span>
<span className="info-chip">提交人:{detail.submitter?.name ?? "-"}</span>
<span className="info-chip">金额:{detail.totalAmount ?? "-"}</span>
</>
)}
actions={(
<Button asChild variant="secondary" data-testid="reimbursement-back">
<Link to="/reimbursements">返回列表</Link>
</Button>
}
)}
/>
<div className="card-std space-y-2 p-6 text-sm" data-testid="reimbursement-summary">
<div className="detail-shell-grid">
<div className="space-y-6">
<SectionPanel title="概览与关联" description="先看主档和关联项目,再决定是否继续补明细或提交流程。">
<div className="space-y-6" data-testid="reimbursement-overview">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
<div className="text-zinc-500">当前状态</div>
<div className="mt-2"><StatusBadge status={detail.status} /></div>
</div>
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
<div className="text-zinc-500">关联项目</div>
<div className="mt-2 font-medium text-zinc-900">{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"}</div>
</div>
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
<div className="text-zinc-500">明细数</div>
<div className="mt-2 font-medium text-zinc-900">{detail.items.length}</div>
</div>
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
<div className="text-zinc-500">关联票据</div>
<div className="mt-2 font-medium text-zinc-900">{detail.invoices.length}</div>
</div>
</div>
<div className="rounded-lg border border-zinc-200 p-4 text-sm" data-testid="reimbursement-summary">
<div>状态:{detail.status}</div>
<div>报销ID:{detail.id}</div>
<div>报销日期:{detail.reimbursementDate ?? "-"}</div>
<div>金额:{detail.totalAmount ?? "-"}</div>
@@ -410,16 +447,20 @@ export function ReimbursementDetailPage() {
<div>说明:{detail.description || "-"}</div>
</div>
<div className="card-std space-y-3 p-6 text-sm">
<div className="rounded-lg border border-zinc-200 p-4 text-sm">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-zinc-900">关联项目</h3>
<h4 className="text-base font-semibold text-zinc-900">关联项目</h4>
<p className="text-xs text-zinc-500">报销可选挂到项目,用于项目执行面板汇总。</p>
</div>
{detail.project ? <Link to={`/projects/${detail.project.id}`} className="text-sm text-primary hover:underline">查看项目</Link> : null}
{detail.project ? (
<Link to={`/projects/${detail.project.id}`} className="text-sm text-primary hover:underline">
查看项目
</Link>
) : null}
</div>
<select
className="select-std max-w-xl"
className="select-std mt-4 max-w-xl"
value={detail.projectId ?? ""}
onChange={(event) => projectLinkMutation.mutate(event.target.value || null)}
disabled={projectLinkMutation.isPending}
@@ -432,86 +473,12 @@ export function ReimbursementDetailPage() {
))}
</select>
</div>
{budgetHint ? (
<div className="card-std space-y-2 p-6 text-sm" data-testid="reimbursement-budget-hint">
<h3 className="text-base font-semibold text-zinc-900">预算提示</h3>
<div>预算池:{budgetHint.budgetCode} / {budgetHint.budgetName}</div>
<div>
本次提交后预计占用:{budgetHint.nextUsedAmount} / {budgetHint.allocatedAmount}
({budgetHint.nextUsagePercent.toFixed(2)}%)
</div>
<div>控制模式:{budgetHint.controlMode}</div>
{budgetHint.exceedsBudget && budgetHint.controlMode === "block" ? (
<p className="rounded-md border border-red-200 bg-red-50 px-2 py-1 text-red-600">
当前金额将超出预算上限,提交时会被阻断(block 模式)。
</p>
) : budgetHint.exceedsThreshold ? (
<p className="rounded-md border border-amber-200 bg-amber-50 px-2 py-1 text-amber-700">
当前金额已达到预算预警阈值,提交后会记录预算预警审计。
</p>
) : (
<p className="text-zinc-500">当前预算充足,可继续提交。</p>
)}
</div>
) : null}
</SectionPanel>
{canManageIntegration && detail.status === "paid" ? (
<div className="card-std space-y-4 p-6" data-testid="reimbursement-erp-export">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<h3 className="text-base font-semibold text-zinc-900">财务桥接</h3>
<p className="text-sm text-zinc-500">已付款报销单会按标准凭证切片通过 Outbox 异步发送到财务桥接出口。</p>
</div>
<Button onClick={() => erpExportMutation.mutate()} disabled={erpExportMutation.isPending}>
{erpExportMutation.isPending ? "出队中..." : "发送到财务桥接"}
</Button>
</div>
{erpPreviewQuery.isLoading ? <p className="text-sm text-zinc-500">正在加载凭证切片预览...</p> : null}
{erpPreviewQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{isApiError(erpPreviewQuery.error)
? `${erpPreviewQuery.error.code}: ${mapApiErrorMessage(
erpPreviewQuery.error.code,
erpPreviewQuery.error.details,
erpPreviewQuery.error.message,
)}`
: "加载财务桥接预览失败"}
</p>
) : null}
{erpPreviewQuery.data ? (
<div className="grid gap-4 lg:grid-cols-2 text-sm">
<div className="rounded-lg border border-zinc-200 p-4 space-y-2">
<h4 className="font-medium text-zinc-900">凭证切片</h4>
<div>类型:{erpPreviewQuery.data.journal.journalType}</div>
<div>单号:{erpPreviewQuery.data.journal.documentNo ?? "-"}</div>
<div>金额:{erpPreviewQuery.data.journal.totalAmount ?? "-"}</div>
<div>法体:{erpPreviewQuery.data.journal.dimensions.legalEntityCode ?? "-"}</div>
<div>成本中心:{erpPreviewQuery.data.journal.dimensions.costCenterCode ?? "-"}</div>
<div>付款凭证号:{erpPreviewQuery.data.journal.paymentVoucherNo ?? "-"}</div>
<div>付款日期:{erpPreviewQuery.data.journal.paymentDate ?? "-"}</div>
</div>
<div className="rounded-lg border border-zinc-200 p-4 space-y-2">
<h4 className="font-medium text-zinc-900">导出状态</h4>
<div>外部ID:{erpPreviewQuery.data.exportState.externalId ?? "-"}</div>
<div>Outbox:{erpPreviewQuery.data.exportState.lastOutboxId ?? "-"}</div>
<div>队列状态:{erpPreviewQuery.data.exportState.lastOutboxStatus ?? "-"}</div>
<div>最近导出:{formatDateTime(erpPreviewQuery.data.exportState.exportedAt)}</div>
<div className="break-all">最近错误:{erpPreviewQuery.data.exportState.lastError ?? "-"}</div>
</div>
</div>
) : null}
</div>
) : null}
<RiskFlagsCard items={detail.riskFlags} title="待处理风险项" />
<AiSuggestionCard targetType="reimbursement" targetId={detail.id} />
<div className="card-std space-y-4 p-6" data-testid="reimbursement-items">
<SectionPanel title="执行 · 明细与票据" description="明细、票据和编辑器在同一层处理,避免长页面往返。">
<div className="space-y-6">
<div className="space-y-4" data-testid="reimbursement-items">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<h3 className="text-base font-semibold text-zinc-900">报销明细</h3>
@@ -618,20 +585,10 @@ export function ReimbursementDetailPage() {
) : null}
</div>
<ReimbursementActions
status={detail.status}
canPay={canPay}
actionError={actionError}
isPending={mutation.isPending}
onSubmit={() => void handleSubmitAction()}
onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })}
onPay={(payload) => mutation.mutate({ action: "pay", payload })}
/>
<div className="card-std space-y-2 p-6" data-testid="reimbursement-invoices">
<div className="space-y-2 text-sm" data-testid="reimbursement-invoices">
<h3 className="text-base font-semibold text-zinc-900">关联票据</h3>
{(detail.invoices ?? []).length ? (
<div className="space-y-2 text-sm">
<div className="space-y-2">
{detail.invoices.map((item) => (
<div className="rounded-lg border border-zinc-200 px-3 py-2" key={item.id}>
<div className="font-medium text-zinc-900">{item.id}</div>
@@ -645,6 +602,112 @@ export function ReimbursementDetailPage() {
<p className="text-sm text-zinc-500">暂无关联票据</p>
)}
</div>
</div>
</SectionPanel>
<SectionPanel title="风险与桥接" description="预算提示、风险信号、AI 建议与财务桥接统一查看。">
<div className="space-y-6">
{budgetHint ? (
<div className="rounded-lg border border-zinc-200 p-4 text-sm" data-testid="reimbursement-budget-hint">
<h3 className="text-base font-semibold text-zinc-900">预算提示</h3>
<div className="mt-3">预算池:{budgetHint.budgetCode} / {budgetHint.budgetName}</div>
<div>
本次提交后预计占用:{budgetHint.nextUsedAmount} / {budgetHint.allocatedAmount}
({budgetHint.nextUsagePercent.toFixed(2)}%)
</div>
<div>控制模式:{budgetHint.controlMode}</div>
{budgetHint.exceedsBudget && budgetHint.controlMode === "block" ? (
<p className="mt-3 rounded-md border border-red-200 bg-red-50 px-2 py-1 text-red-600">
当前金额将超出预算上限,提交时会被阻断(block 模式)。
</p>
) : budgetHint.exceedsThreshold ? (
<p className="mt-3 rounded-md border border-amber-200 bg-amber-50 px-2 py-1 text-amber-700">
当前金额已达到预算预警阈值,提交后会记录预算预警审计。
</p>
) : (
<p className="mt-3 text-zinc-500">当前预算充足,可继续提交。</p>
)}
</div>
) : null}
<RiskFlagsCard items={detail.riskFlags} title="待处理风险项" />
<AiSuggestionCard targetType="reimbursement" targetId={detail.id} />
{canManageIntegration && detail.status === "paid" ? (
<div className="space-y-4 rounded-lg border border-zinc-200 p-4" data-testid="reimbursement-erp-export">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<h3 className="text-base font-semibold text-zinc-900">财务桥接</h3>
<p className="text-sm text-zinc-500">已付款报销单会按标准凭证切片通过 Outbox 异步发送到财务桥接出口。</p>
</div>
<Button onClick={() => erpExportMutation.mutate()} disabled={erpExportMutation.isPending}>
{erpExportMutation.isPending ? "出队中..." : "发送到财务桥接"}
</Button>
</div>
{erpPreviewQuery.isLoading ? <p className="text-sm text-zinc-500">正在加载凭证切片预览...</p> : null}
{erpPreviewQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{isApiError(erpPreviewQuery.error)
? `${erpPreviewQuery.error.code}: ${mapApiErrorMessage(
erpPreviewQuery.error.code,
erpPreviewQuery.error.details,
erpPreviewQuery.error.message,
)}`
: "加载财务桥接预览失败"}
</p>
) : null}
{erpPreviewQuery.data ? (
<div className="grid gap-4 text-sm lg:grid-cols-2">
<div className="rounded-lg border border-zinc-200 p-4 space-y-2">
<h4 className="font-medium text-zinc-900">凭证切片</h4>
<div>类型:{erpPreviewQuery.data.journal.journalType}</div>
<div>单号:{erpPreviewQuery.data.journal.documentNo ?? "-"}</div>
<div>金额:{erpPreviewQuery.data.journal.totalAmount ?? "-"}</div>
<div>法体:{erpPreviewQuery.data.journal.dimensions.legalEntityCode ?? "-"}</div>
<div>成本中心:{erpPreviewQuery.data.journal.dimensions.costCenterCode ?? "-"}</div>
<div>付款凭证号:{erpPreviewQuery.data.journal.paymentVoucherNo ?? "-"}</div>
<div>付款日期:{erpPreviewQuery.data.journal.paymentDate ?? "-"}</div>
</div>
<div className="rounded-lg border border-zinc-200 p-4 space-y-2">
<h4 className="font-medium text-zinc-900">导出状态</h4>
<div>外部ID:{erpPreviewQuery.data.exportState.externalId ?? "-"}</div>
<div>Outbox:{erpPreviewQuery.data.exportState.lastOutboxId ?? "-"}</div>
<div>队列状态:{erpPreviewQuery.data.exportState.lastOutboxStatus ?? "-"}</div>
<div>最近导出:{formatDateTime(erpPreviewQuery.data.exportState.exportedAt)}</div>
<div className="break-all">最近错误:{erpPreviewQuery.data.exportState.lastError ?? "-"}</div>
</div>
</div>
) : null}
</div>
) : null}
</div>
</SectionPanel>
</div>
<div className="space-y-6">
<DetailSideMeta title="报销摘要" description="帮助处理人先判断单据状态、关联量和当前审批位置。">
<div>报销编号:{detail.formNumber ?? detail.id}</div>
<div>报销人:{detail.owner?.name ?? "-"}</div>
<div>提交人:{detail.submitter?.name ?? "-"}</div>
<div>当前审批:{detail.pendingWorkflow?.task?.step_name ?? "-"}</div>
<div>明细数:{detail.items.length}</div>
<div>票据数:{detail.invoices.length}</div>
<div>关联项目:{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"}</div>
</DetailSideMeta>
<ReimbursementActions
status={detail.status}
canPay={canPay}
actionError={actionError}
isPending={mutation.isPending}
onSubmit={() => void handleSubmitAction()}
onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })}
onPay={(payload) => mutation.mutate({ action: "pay", payload })}
/>
</div>
</div>
<ConfirmDialog
open={Boolean(confirmDeleteItemId)}
@@ -654,6 +717,6 @@ export function ReimbursementDetailPage() {
confirmLabel="删除"
onConfirm={() => { itemDeleteMutation.mutate(confirmDeleteItemId); }}
/>
</div>
</PageScaffold>
);
}
+11 -4
查看文件
@@ -12,6 +12,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from ".
import { toast } from "sonner";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { TEST_IDS } from "../lib/testIds";
function errorMessage(error: unknown, fallback: string) {
if (isApiError(error)) {
@@ -140,7 +141,7 @@ export function TodosPage() {
</TableRow>
) : todoItems.length ? (
todoItems.map((item) => (
<TableRow key={item.id}>
<TableRow data-testid={TEST_IDS.todo.row} key={item.id}>
<TableCell>
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="text-xs text-zinc-500">{item.description ?? "-"}</div>
@@ -151,7 +152,13 @@ export function TodosPage() {
<TableCell>{item.status}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button size="sm" type="button" disabled={completeMutation.isPending || item.status === "done"} onClick={() => completeMutation.mutate(item.id)}>
<Button
data-testid={TEST_IDS.todo.complete}
size="sm"
type="button"
disabled={completeMutation.isPending || item.status === "done"}
onClick={() => completeMutation.mutate(item.id)}
>
完成
</Button>
<Button size="sm" variant="secondary" type="button" disabled={cancelMutation.isPending || item.status === "cancelled"} onClick={() => cancelMutation.mutate(item.id)}>
@@ -177,7 +184,7 @@ export function TodosPage() {
<SectionPanel title="新增待办" description="建议把周报计划、项目跟进或临时事项都拆成清晰动作。">
<div className="space-y-4">
<div className="space-y-2">
<Input placeholder="待办标题" value={title} onChange={(event) => setTitle(event.target.value)} />
<Input data-testid={TEST_IDS.todo.createTitle} placeholder="待办标题" value={title} onChange={(event) => setTitle(event.target.value)} />
</div>
<div className="space-y-2">
<Input placeholder="补充说明(可选)" value={description} onChange={(event) => setDescription(event.target.value)} />
@@ -192,7 +199,7 @@ export function TodosPage() {
))}
</select>
</div>
<Button type="button" onClick={() => createMutation.mutate()} disabled={createMutation.isPending || !title.trim()}>
<Button data-testid={TEST_IDS.todo.createSubmit} type="button" onClick={() => createMutation.mutate()} disabled={createMutation.isPending || !title.trim()}>
{createMutation.isPending ? "新建中..." : "新建待办"}
</Button>
</div>
+17 -10
查看文件
@@ -15,6 +15,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from ".
import { toast } from "sonner";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { TEST_IDS } from "../lib/testIds";
function errorMessage(error: unknown, fallback: string) {
if (isApiError(error)) {
@@ -135,7 +136,13 @@ export function WeeklyReportsPage() {
<div className="text-sm font-medium text-zinc-900">按周归档</div>
<div className="text-sm text-zinc-500">默认按周一归档;提交后会同步更新 Portal 与项目摘要。</div>
</div>
<input className="input-std w-[220px]" type="date" value={weekStart} onChange={(event) => setWeekStart(event.target.value)} />
<input
aria-label="周报归档周"
className="input-std w-[220px]"
type="date"
value={weekStart}
onChange={(event) => setWeekStart(event.target.value)}
/>
</FilterBar>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
@@ -151,16 +158,16 @@ export function WeeklyReportsPage() {
{reportQuery.isError ? <div className="text-sm text-red-600">{errorMessage(reportQuery.error, "加载周报失败")}</div> : null}
<div className="grid gap-4">
<div className="grid gap-2">
<label className="text-sm text-zinc-500">本周完成</label>
<Textarea value={draft.highlightsText} onChange={(event) => setDraft((prev) => ({ ...prev, highlightsText: event.target.value }))} />
<label className="text-sm text-zinc-500" htmlFor="weekly-report-highlights">本周完成</label>
<Textarea id="weekly-report-highlights" value={draft.highlightsText} onChange={(event) => setDraft((prev) => ({ ...prev, highlightsText: event.target.value }))} />
</div>
<div className="grid gap-2">
<label className="text-sm text-zinc-500">当前阻塞</label>
<Textarea value={draft.blockersText} onChange={(event) => setDraft((prev) => ({ ...prev, blockersText: event.target.value }))} />
<label className="text-sm text-zinc-500" htmlFor="weekly-report-blockers">当前阻塞</label>
<Textarea id="weekly-report-blockers" value={draft.blockersText} onChange={(event) => setDraft((prev) => ({ ...prev, blockersText: event.target.value }))} />
</div>
<div className="grid gap-2">
<label className="text-sm text-zinc-500">下周计划</label>
<Textarea value={draft.nextWeekText} onChange={(event) => setDraft((prev) => ({ ...prev, nextWeekText: event.target.value }))} />
<label className="text-sm text-zinc-500" htmlFor="weekly-report-next-week">下周计划</label>
<Textarea id="weekly-report-next-week" value={draft.nextWeekText} onChange={(event) => setDraft((prev) => ({ ...prev, nextWeekText: event.target.value }))} />
</div>
<label className="flex items-center gap-2 text-sm text-zinc-700">
<input type="checkbox" checked={draft.needsHelp} onChange={(event) => setDraft((prev) => ({ ...prev, needsHelp: event.target.checked }))} />
@@ -193,9 +200,9 @@ export function WeeklyReportsPage() {
</div>
))}
<div className="flex flex-wrap justify-end gap-2">
<Button type="button" variant="secondary" onClick={() => createTodoMutation.mutate()} disabled={createTodoMutation.isPending}>转成待办</Button>
<Button type="button" variant="secondary" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>保存草稿</Button>
<Button type="button" onClick={() => submitMutation.mutate()} disabled={submitMutation.isPending}>提交周报</Button>
<Button data-testid={TEST_IDS.weeklyReport.createTodo} type="button" variant="secondary" onClick={() => createTodoMutation.mutate()} disabled={createTodoMutation.isPending}>转成待办</Button>
<Button data-testid={TEST_IDS.weeklyReport.save} type="button" variant="secondary" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>保存草稿</Button>
<Button data-testid={TEST_IDS.weeklyReport.submit} type="button" onClick={() => submitMutation.mutate()} disabled={submitMutation.isPending}>提交周报</Button>
</div>
</div>
</SectionPanel>
+85 -14
查看文件
@@ -16,8 +16,11 @@ import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { buildMobileApprovalsPath, shouldPreferMobileApprovalExperience } from "../pwa";
import { CardSkeleton } from "../components/common/CardSkeleton";
import { PageHeader } from "../components/common/PageHeader";
import { StatusBadge } from "../components/common/StatusBadge";
import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button";
import { Textarea } from "../components/ui/textarea";
@@ -37,6 +40,29 @@ function entityTitle(entityType: string | undefined) {
return entityType || "业务单据";
}
function formatDateTime(value: string | null | undefined) {
if (!value) {
return "-";
}
return value.slice(0, 19).replace("T", " ");
}
function workflowStatusLabel(status: string | undefined) {
if (!status) {
return "-";
}
const labels: Record<string, string> = {
pending: "待派发",
completed: "已完成",
rejected: "已驳回",
cancelled: "已取消",
skipped: "已跳过",
};
return labels[status] ?? status;
}
export function WorkflowTaskDetailPage() {
const { taskId = "" } = useParams();
const navigate = useNavigate();
@@ -156,19 +182,50 @@ export function WorkflowTaskDetailPage() {
const canTransfer = allowedActions.has("transfer") && detail.workflowInstance?.entityType === "reimbursement";
return (
<div className="space-y-6">
<PageHeader
<PageScaffold>
<EntityOverviewHero
title={`任务详情 ${detail.task.stepName}`}
subtitle={`任务ID: ${detail.task.id}`}
actions={
subtitle="在同一工作台内查看审批对象摘要、处理动作和历史留痕。"
meta={(
<>
<StatusBadge status={detail.task.status} />
<span className="info-chip">任务ID:{detail.task.id}</span>
<span className="info-chip">对象类型:{entityTitle(detail.workflowInstance?.entityType)}</span>
<span className="info-chip">实例:{detail.workflowInstance?.id ?? "-"}</span>
</>
)}
actions={(
<Button asChild variant="secondary" data-testid="workflow-task-back">
<Link to="/workflow/tasks">返回待办列表</Link>
</Button>
}
)}
/>
<div className="card-std space-y-2 p-6 text-sm" data-testid="workflow-task-summary">
<div>状态:<StatusBadge status={detail.task.status} /></div>
<div className="detail-shell-grid">
<div className="space-y-6">
<SectionPanel title="审批对象摘要" description="先看对象主信息,再决定通过、驳回、退回或转审。">
<div className="space-y-4" data-testid="workflow-task-overview">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
<div className="text-zinc-500">当前状态</div>
<div className="mt-2"><StatusBadge status={detail.task.status} /></div>
</div>
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
<div className="text-zinc-500">当前节点</div>
<div className="mt-2 font-medium text-zinc-900">{detail.task.stepName}</div>
</div>
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
<div className="text-zinc-500">对象类型</div>
<div className="mt-2 font-medium text-zinc-900">{entityTitle(detail.workflowInstance?.entityType)}</div>
</div>
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
<div className="text-zinc-500">可执行动作</div>
<div className="mt-2 font-medium text-zinc-900">{detail.allowedActions.length ? detail.allowedActions.join(" / ") : "-"}</div>
</div>
</div>
<div className="rounded-lg border border-zinc-200 p-4 text-sm" data-testid="workflow-task-summary">
<div>状态:{workflowStatusLabel(detail.task.status)}</div>
<div>实例:{detail.workflowInstance?.id ?? "-"}</div>
<div>对象类型:{entityTitle(detail.workflowInstance?.entityType)}</div>
{detail.reimbursement ? (
@@ -229,10 +286,11 @@ export function WorkflowTaskDetailPage() {
</>
) : null}
</div>
</div>
</SectionPanel>
<div className="card-std space-y-4 p-6">
<h3 className="text-base font-semibold text-zinc-900">审批动作</h3>
<SectionPanel title="审批动作" description="把通过、驳回、退回和转审统一收口在同一块操作区。">
<div className="space-y-4">
{actionError ? (
<p
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
@@ -296,7 +354,7 @@ export function WorkflowTaskDetailPage() {
<p className="text-sm font-medium text-zinc-800">转审</p>
<select
className="select-std"
data-testid="workflow-task-transfer-to-approver"
data-testid="workflow-task-transfer-approver"
onChange={(event) => setSelectedApprover(event.target.value)}
value={selectedApprover}
>
@@ -330,9 +388,9 @@ export function WorkflowTaskDetailPage() {
</div>
) : null}
</div>
</SectionPanel>
<div className="card-std space-y-3 p-6">
<h3 className="text-base font-semibold text-zinc-900">处理历史</h3>
<SectionPanel title="处理历史" description="所有审批动作、意见和处理人统一留痕。">
<div className="space-y-2">
{detail.history.length ? (
detail.history.map((item) => (
@@ -348,7 +406,20 @@ export function WorkflowTaskDetailPage() {
<p className="text-sm text-zinc-500">暂无历史记录</p>
)}
</div>
</SectionPanel>
</div>
<div className="space-y-6">
<DetailSideMeta title="任务摘要" description="帮助审批人先判断节点、对象和时间,再决定处理动作。">
<div>节点:{detail.task.stepName}</div>
<div>实例状态:{detail.workflowInstance?.currentStatus ?? "-"}</div>
<div>当前审批角色:{detail.task.assigneeRole ?? "-"}</div>
<div>创建时间:{formatDateTime(detail.task.createdAt)}</div>
<div>完成时间:{formatDateTime(detail.task.completedAt)}</div>
<div>发起时间:{formatDateTime(detail.workflowInstance?.startedAt)}</div>
</DetailSideMeta>
</div>
</div>
</PageScaffold>
);
}
+9 -1
查看文件
@@ -62,7 +62,15 @@ export function WorkspaceSetupPage() {
const moduleEntries = useMemo(() => Object.entries(workspace?.moduleVisibility ?? {}), [workspace?.moduleVisibility]);
if (workspaceQuery.isLoading || !workspace) {
return <div className="card-std p-6 text-sm text-zinc-500">加载工作区设置中...</div>;
return (
<div className="space-y-6">
<PageHeader
title="工作区设置"
subtitle="按 10 人以下小团队模式收敛主导航与默认入口。"
/>
<div className="card-std p-6 text-sm text-zinc-500">加载工作区设置中...</div>
</div>
);
}
return (
+7
查看文件
@@ -34,6 +34,12 @@ export const FINANCE_MGR: E2EUser = {
name: "Finance Manager",
};
export const ADMIN: E2EUser = {
identifier: "[email protected]",
password: "AdminPassword1234",
name: "Admin",
};
export type MeResponse = {
id: string;
employeeNo: string;
@@ -80,6 +86,7 @@ export async function login(page: Page, user: E2EUser) {
await page.getByTestId(TEST_IDS.login.password).fill(user.password);
await page.getByTestId(TEST_IDS.login.submit).click();
await expect(page).toHaveURL(/\/portal/);
await expect(page.getByTestId(TEST_IDS.topbar.profileTrigger).first()).toBeVisible();
}
export async function logout(page: Page, userDisplayName?: string) {
@@ -0,0 +1,23 @@
import { test as base } from "@playwright/test";
import { AppShell } from "../pageObjects/AppShell";
import { ReimbursementDetailPageObject } from "../pageObjects/ReimbursementDetailPage";
import { WorkflowTaskDetailPageObject } from "../pageObjects/WorkflowTaskDetailPage";
export const test = base.extend<{
shell: AppShell;
reimbursementDetail: ReimbursementDetailPageObject;
workflowDetail: WorkflowTaskDetailPageObject;
}>({
shell: async ({ page }, use) => {
await use(new AppShell(page));
},
reimbursementDetail: async ({ page }, use) => {
await use(new ReimbursementDetailPageObject(page));
},
workflowDetail: async ({ page }, use) => {
await use(new WorkflowTaskDetailPageObject(page));
},
});
export { expect } from "@playwright/test";
@@ -0,0 +1,45 @@
import { expect, type Page } from "@playwright/test";
import { openBackupModulesIfNeeded, openNavLink, openProfileSettings } from "../navigation";
import { TEST_IDS } from "../testIds";
export class AppShell {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
get sidebar() {
return this.page.locator("nav").first();
}
get topbarProfileTrigger() {
return this.page.getByTestId(TEST_IDS.topbar.profileTrigger).first();
}
async navigate(name: string | RegExp) {
return openNavLink(this.page, name);
}
async openBackupModules() {
return openBackupModulesIfNeeded(this.page);
}
async openProfile(userDisplayName?: string) {
return openProfileSettings(this.page, userDisplayName);
}
async assertMainNavVisible() {
await expect(this.page.getByRole("link", { name: "工作台" }).or(this.page.getByRole("link", { name: "Portal" })).first()).toBeVisible();
await expect(this.page.getByRole("link", { name: /项目/ }).first()).toBeVisible();
}
async assertBackupModulesCollapsed() {
const summary = this.page.locator("summary").filter({ hasText: "备用模块" }).first();
if (await summary.count()) {
const details = summary.locator("xpath=ancestor::details[1]");
await expect(details).not.toHaveAttribute("open", "");
}
}
}
@@ -0,0 +1,66 @@
import { expect, type Page } from "@playwright/test";
import { TEST_IDS } from "../testIds";
export class ReimbursementDetailPageObject {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
get overview() {
return this.page.getByTestId(TEST_IDS.reimbursement.overview);
}
get summary() {
return this.page.getByTestId(TEST_IDS.reimbursement.summary);
}
get itemsTable() {
return this.page.getByTestId(TEST_IDS.reimbursement.items);
}
get addItemBtn() {
return this.page.getByTestId(TEST_IDS.reimbursement.itemAdd);
}
get budgetHint() {
return this.page.getByTestId(TEST_IDS.reimbursement.budgetHint);
}
get erpExport() {
return this.page.getByTestId(TEST_IDS.reimbursement.erpExport);
}
get submitBtn() {
return this.page.getByTestId(TEST_IDS.reimbursement.submit);
}
get withdrawBtn() {
return this.page.getByTestId(TEST_IDS.reimbursement.withdraw);
}
get payBtn() {
return this.page.getByTestId(TEST_IDS.reimbursement.pay);
}
async assertLoaded() {
await expect(this.overview).toBeVisible({ timeout: 15_000 });
await expect(this.summary).toBeVisible({ timeout: 15_000 });
}
async assertStatus(statusText: string) {
await expect(this.summary).toContainText(statusText);
}
// 当前 seed 保证 precheck 不触发 warn/manual_review;若 seed 变更,此处需相应调整。
async submitReimbursement(acceptConfirm = true) {
const dialogPromise = acceptConfirm
? this.page.waitForEvent("dialog", { timeout: 2_000 }).then((dialog) => dialog.accept()).catch(() => undefined)
: Promise.resolve();
await this.submitBtn.click();
await dialogPromise;
}
}
@@ -0,0 +1,66 @@
import { expect, type Page } from "@playwright/test";
import { TEST_IDS } from "../testIds";
export class WorkflowTaskDetailPageObject {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
get overview() {
return this.page.getByTestId(TEST_IDS.workflow.overview);
}
get summary() {
return this.page.getByTestId(TEST_IDS.workflow.summary);
}
get approveBtn() {
return this.page.getByTestId(TEST_IDS.workflow.approve);
}
get rejectCommentInput() {
return this.page.getByTestId(TEST_IDS.workflow.rejectComment);
}
get rejectBtn() {
return this.page.getByTestId(TEST_IDS.workflow.reject);
}
get returnCommentInput() {
return this.page.getByTestId(TEST_IDS.workflow.returnComment);
}
get returnBtn() {
return this.page.getByTestId(TEST_IDS.workflow.returnBtn);
}
get actionError() {
return this.page.getByTestId(TEST_IDS.workflow.actionError);
}
get transferApproverSelect() {
return this.page.getByTestId(TEST_IDS.workflow.transferApprover);
}
async assertLoaded() {
await expect(this.overview).toBeVisible({ timeout: 15_000 });
await expect(this.summary).toBeVisible({ timeout: 15_000 });
}
async approve() {
await this.approveBtn.click();
}
async reject(comment: string) {
await this.rejectCommentInput.fill(comment);
await this.rejectBtn.click();
}
async returnTask(comment: string) {
await this.returnCommentInput.fill(comment);
await this.returnBtn.click();
}
}
+1 -20
查看文件
@@ -1,20 +1 @@
export const TEST_IDS = {
login: {
identifier: "login-identifier",
password: "login-password",
submit: "login-submit",
},
topbar: {
profileTrigger: "topbar-profile-trigger",
},
workflow: {
tasksTable: "workflow-tasks-table",
taskRow: "workflow-task-row",
detailLink: "workflow-task-detail-link",
summary: "workflow-task-summary",
approve: "workflow-task-approve",
},
audit: {
export: "audit-export",
},
} as const;
export { TEST_IDS } from "../../../src/lib/testIds";
+6 -5
查看文件
@@ -1,8 +1,8 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { test, expect } from "./helpers/fixtures";
import { EMPLOYEE, MANAGER, apiPostData, createAuthenticatedApiClient, login, logout } from "./helpers/auth";
import { openNavLink } from "./helpers/navigation";
import { TEST_IDS } from "./helpers/testIds";
import type { Locator, Page } from "@playwright/test";
function formatDate(offsetDays: number) {
const value = new Date();
@@ -69,7 +69,7 @@ async function withdrawLeave(api: Awaited<ReturnType<typeof createAuthenticatedA
return apiPostData<{ leaveRequestId: string; leaveStatus: string }>(api, `/api/leave-requests/${leaveRequestId}/withdraw`, {});
}
test("E2E 请假主链路:草稿 → 提交 → 撤回 + 提交 → 审批", async ({ page }) => {
test("E2E 请假主链路:草稿 → 提交 → 撤回 + 提交 → 审批", async ({ page, workflowDetail }) => {
const employeeClient = await createAuthenticatedApiClient(EMPLOYEE);
const baseOffset = 30 + Number(String(Date.now()).slice(-2));
const firstDate = formatDate(baseOffset);
@@ -104,8 +104,9 @@ test("E2E 请假主链路:草稿 → 提交 → 撤回 + 提交 → 审批", a
const taskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: secondSubmit.currentTaskId }).first();
await expect(taskRow).toBeVisible({ timeout: 30_000 });
await taskRow.getByTestId(TEST_IDS.workflow.detailLink).click();
await page.getByTestId(TEST_IDS.workflow.approve).click();
await expect(page.getByTestId(TEST_IDS.workflow.summary)).toContainText(/状态:(completed|已完成)/);
await workflowDetail.assertLoaded();
await workflowDetail.approve();
await expect(workflowDetail.summary).toContainText(/状态:(completed|已完成)/);
await logout(page, MANAGER.name);
+23 -36
查看文件
@@ -1,24 +1,7 @@
import { expect, test, type Page } from "@playwright/test";
import { test, expect } from "./helpers/fixtures";
import { EMPLOYEE, FINANCE, MANAGER, login, logout } from "./helpers/auth";
import { openNavLink } from "./helpers/navigation";
const EMPLOYEE = { identifier: "[email protected]", password: "EmployeePassword1234", name: "Employee" };
const MANAGER = { identifier: "[email protected]", password: "ManagerPassword1234", name: "Manager" };
const FINANCE = { identifier: "[email protected]", password: "FinancePassword1234", name: "Finance" };
async function login(page: Page, user: { identifier: string; password: string }) {
await page.goto("/login");
await page.getByTestId("login-identifier").fill(user.identifier);
await page.getByTestId("login-password").fill(user.password);
await page.getByTestId("login-submit").click();
await expect(page).toHaveURL(/\/portal/);
}
async function logout(page: Page, userDisplayName: string) {
await page.getByRole("button", { name: userDisplayName }).click();
await page.getByRole("menuitem", { name: "退出登录" }).click();
await expect(page).toHaveURL(/\/login/);
}
import { TEST_IDS } from "./helpers/testIds";
function extractId(text: string, label: string) {
const match = text.match(new RegExp(`${label}:([0-9a-f-]{36})`));
@@ -28,11 +11,13 @@ function extractId(text: string, label: string) {
return match[1];
}
test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登出", async ({ page }) => {
test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登出", async ({ page, reimbursementDetail, workflowDetail }) => {
// 1) employee: create reimbursement
await login(page, EMPLOYEE);
await page.locator("nav").getByRole("link", { name: "我的报销", exact: true }).click();
await (await openNavLink(page, "我的报销")).click();
await expect(page).toHaveURL(/\/reimbursements/);
await expect(page.getByTestId("reimbursements-create")).toBeVisible();
const createResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/reimbursements" && response.ok();
@@ -44,9 +29,7 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
throw new Error(`创建报销单后未返回 id: ${JSON.stringify(createPayload)}`);
}
await page.goto(`/reimbursements/${reimbursementId}`);
await expect(page.getByTestId("reimbursement-summary")).toBeVisible();
const reimbursementSummaryText = await page.getByTestId("reimbursement-summary").innerText();
await reimbursementDetail.assertLoaded();
// 2) employee: upload invoice and wait OCR
await (await openNavLink(page, "票据中心")).click();
@@ -82,7 +65,8 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
// 4) submit reimbursement
await page.goto(`/reimbursements/${reimbursementId}`);
await page.getByTestId("reimbursement-submit").click();
await reimbursementDetail.assertLoaded();
await reimbursementDetail.submitReimbursement();
await expect(page.getByText(/状态:(reviewing|审批中)/)).toBeVisible();
// 5) manager approve
@@ -90,28 +74,31 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
await login(page, MANAGER);
await (await openNavLink(page, "待我处理")).click();
await expect(page.getByTestId("workflow-tasks-table")).toBeVisible();
const managerTaskRow = page.getByTestId("workflow-task-row").filter({ hasText: reimbursementId }).first();
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
const managerTaskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: reimbursementId }).first();
await expect(managerTaskRow).toBeVisible();
await managerTaskRow.getByTestId("workflow-task-detail-link").click();
await page.getByTestId("workflow-task-approve").click();
await expect(page.getByTestId("workflow-task-summary")).toContainText(/状态:(completed|已完成)/);
await managerTaskRow.getByTestId(TEST_IDS.workflow.detailLink).click();
await workflowDetail.assertLoaded();
await workflowDetail.approve();
await expect(workflowDetail.summary).toContainText(/状态:(completed|已完成)/);
await logout(page, MANAGER.name);
// 6) finance approve + pay
await login(page, FINANCE);
await (await openNavLink(page, "待我处理")).click();
const financeTaskRow = page.getByTestId("workflow-task-row").filter({ hasText: reimbursementId }).first();
const financeTaskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: reimbursementId }).first();
await expect(financeTaskRow).toBeVisible();
await financeTaskRow.getByTestId("workflow-task-detail-link").click();
await page.getByTestId("workflow-task-approve").click();
await expect(page.getByTestId("workflow-task-summary")).toContainText(/状态:(completed|已完成)/);
await financeTaskRow.getByTestId(TEST_IDS.workflow.detailLink).click();
await workflowDetail.assertLoaded();
await workflowDetail.approve();
await expect(workflowDetail.summary).toContainText(/状态:(completed|已完成)/);
await page.goto(`/reimbursements/${reimbursementId}`);
await reimbursementDetail.assertLoaded();
await expect(page.getByText(/状态:(approved|已批准)/)).toBeVisible({ timeout: 30_000 });
await page.getByTestId("reimbursement-pay-voucher-no").fill(`E2E-${Date.now()}`);
await page.getByTestId("reimbursement-pay").click();
await reimbursementDetail.payBtn.click();
await expect(page.getByText(/状态:(paid|已支付)/)).toBeVisible({ timeout: 30_000 });
await logout(page, FINANCE.name);
@@ -14,6 +14,11 @@ async function waitForWorkflowTaskCache(page: import("@playwright/test").Page, t
request.onerror = () => reject(request.error ?? new Error("indexeddb_open_failed"));
});
try {
if (!db.objectStoreNames.contains("workflow_task_cache")) {
return false;
}
const store = db.transaction("workflow_task_cache", "readonly").objectStore("workflow_task_cache");
const detail = await new Promise<unknown>((resolve, reject) => {
const query = store.get(currentTaskId);
@@ -21,8 +26,10 @@ async function waitForWorkflowTaskCache(page: import("@playwright/test").Page, t
query.onerror = () => reject(query.error ?? new Error("indexeddb_read_failed"));
});
db.close();
return Boolean(detail);
} finally {
db.close();
}
}, taskId);
},
{ timeout: 15_000 },
+14 -16
查看文件
@@ -1,37 +1,35 @@
import { expect, test } from "@playwright/test";
import { login } from "./helpers/auth";
import { assertBackupModulesCollapsed } from "./helpers/smokeAssertions";
import { test, expect } from "./helpers/fixtures";
import { ADMIN, login } from "./helpers/auth";
import { TEST_IDS } from "./helpers/testIds";
const ADMIN = {
identifier: "[email protected]",
password: "AdminPassword1234",
name: "Admin",
};
test("E2E 管理域 smoke:工作区设置、系统设置、功能开关、流程定义、审计日志", async ({ page }) => {
test("E2E 管理域 smoke:工作区设置、系统设置、功能开关、流程定义、审计日志", async ({ page, shell }) => {
await login(page, ADMIN);
await page.goto("/portal");
await assertBackupModulesCollapsed(page);
await expect(shell.topbarProfileTrigger).toBeVisible();
await shell.assertBackupModulesCollapsed();
await page.goto("/system/workspace");
await (await shell.navigate("工作区设置")).click();
await expect(page).toHaveURL(/\/system\/workspace/);
await expect(page.getByRole("heading", { name: "工作区设置" })).toBeVisible();
await expect(page.getByText("产品模式")).toBeVisible();
await page.goto("/system/settings");
await (await shell.navigate("系统设置")).click();
await expect(page).toHaveURL(/\/system\/settings/);
await expect(page.getByRole("heading", { name: "系统设置" })).toBeVisible();
await expect(page.getByRole("link", { name: "工作区设置", exact: true }).first()).toBeVisible();
await page.goto("/system/feature-flags");
await (await shell.navigate("功能开关")).click();
await expect(page).toHaveURL(/\/system\/feature-flags/);
await expect(page.getByRole("heading", { name: "功能开关" })).toBeVisible();
await expect(page.getByTestId("feature-flags-table")).toBeVisible();
await page.goto("/system/workflow-definitions");
await expect(page).toHaveURL(/\/system\/workflow-definitions/);
await expect(page.getByRole("heading", { name: "流程定义" })).toBeVisible();
await expect(page.getByText("版本列表")).toBeVisible();
await page.goto("/audit-logs");
await (await shell.navigate("审计日志")).click();
await expect(page).toHaveURL(/\/audit-logs/);
await expect(page.getByRole("heading", { name: "审计工作台" })).toBeVisible();
await expect(page.getByTestId(TEST_IDS.audit.export)).toBeVisible();
});
+63
查看文件
@@ -0,0 +1,63 @@
import { test, expect } from "./helpers/fixtures";
import { EMPLOYEE, login, logout } from "./helpers/auth";
import { openNavLink } from "./helpers/navigation";
import { TEST_IDS } from "./helpers/testIds";
function formatWeekStart(offsetDays: number) {
const value = new Date();
value.setUTCDate(value.getUTCDate() + offsetDays);
return value.toISOString().slice(0, 10);
}
test("E2E 周报待办联动:保存 → 生成待办 → 提交 → 完成待办", async ({ page }) => {
const uniqueToken = `Playwright 周报联动 ${Date.now()}`;
const weekStart = formatWeekStart(35 + Number(String(Date.now()).slice(-1)));
await login(page, EMPLOYEE);
await (await openNavLink(page, "周报")).click();
await expect(page.getByRole("heading", { name: "周报", exact: true })).toBeVisible();
const weeklyLoadResponse = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "GET" && url.pathname === "/api/weekly-reports/me" && url.searchParams.get("weekStart") === weekStart && response.ok();
});
await page.getByLabel("周报归档周").fill(weekStart);
await weeklyLoadResponse;
await page.getByLabel("本周完成").fill(`${uniqueToken} 已完成`);
await page.getByLabel("当前阻塞").fill(`${uniqueToken} 阻塞`);
await page.getByLabel("下周计划").fill(`${uniqueToken} 下周计划`);
const saveResponse = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "PUT" && url.pathname === "/api/weekly-reports/me" && response.ok();
});
await page.getByTestId(TEST_IDS.weeklyReport.save).click();
await saveResponse;
await expect(page.getByText("周报已保存")).toBeVisible();
const createTodoResponse = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/todos" && response.ok();
});
await page.getByTestId(TEST_IDS.weeklyReport.createTodo).click();
await createTodoResponse;
await expect(page.getByText("已从周报生成待办")).toBeVisible();
const submitResponse = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/weekly-reports/me/submit" && response.ok();
});
await page.getByTestId(TEST_IDS.weeklyReport.submit).click();
await submitResponse;
await expect(page.getByText("周报已提交")).toBeVisible();
await expect(page.getByText("已提交", { exact: true })).toBeVisible();
await (await openNavLink(page, "待办")).click();
const todoRow = page.getByTestId(TEST_IDS.todo.row).filter({ hasText: uniqueToken }).first();
await expect(todoRow).toBeVisible({ timeout: 30_000 });
await todoRow.getByTestId(TEST_IDS.todo.complete).click();
await expect(todoRow).toContainText("done", { timeout: 30_000 });
await logout(page, EMPLOYEE.name);
});