R32-1 R32-4 R33-2: 收口工作台交互与报销链路

这个提交包含在:
2026-03-24 20:26:22 +08:00
父节点 5aaa628d54
当前提交 1056c81940
共修改 20 个文件,包含 856 行新增和 141 行删除
+11
查看文件
@@ -81,6 +81,16 @@ export type ReimbursementBudgetHint = {
exceedsBudget: boolean;
};
export type ReimbursementWorkflowProgress = {
currentStepIndex: number;
totalSteps: number;
steps: Array<{
id: string;
name: string;
status: "completed" | "current" | "pending";
}>;
};
export type ReimbursementDetail = {
id: string;
formNumber: string | null;
@@ -125,6 +135,7 @@ export type ReimbursementDetail = {
assignee_role: string | null;
} | null;
} | null;
workflowProgress: ReimbursementWorkflowProgress | null;
riskFlags: RiskFlag[];
items: ReimbursementItem[];
};
+1
查看文件
@@ -16,6 +16,7 @@ export type WorkflowTaskSummary = {
entityId: string;
currentStatus: string;
} | null;
allowedActions: Array<"approve" | "reject" | "return" | "transfer">;
};
export type WorkflowTaskListResponse = {
+79 -18
查看文件
@@ -8,6 +8,15 @@ import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog";
type SearchGroupKey = "people" | "documents" | "projectAnnouncement" | "other";
const SEARCH_GROUPS: Array<{ key: SearchGroupKey; label: string }> = [
{ key: "people", label: "人员" },
{ key: "documents", label: "业务单据" },
{ key: "projectAnnouncement", label: "项目与公告" },
{ key: "other", label: "其他" },
];
function entityLabel(entityType: string) {
if (entityType === "employee") return "人员";
if (entityType === "announcement") return "公告";
@@ -24,6 +33,29 @@ function entityLabel(entityType: string) {
return entityType;
}
function resolveSearchGroup(entityType: string): SearchGroupKey {
if (entityType === "employee") {
return "people";
}
if ([
"reimbursement",
"leave",
"purchase_request",
"travel_request",
"weekly_report",
"todo",
].includes(entityType)) {
return "documents";
}
if (["project", "announcement"].includes(entityType)) {
return "projectAnnouncement";
}
return "other";
}
export function GlobalSearchDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
const navigate = useNavigate();
const [keyword, setKeyword] = useState("");
@@ -31,19 +63,33 @@ export function GlobalSearchDialog({ open, onOpenChange }: { open: boolean; onOp
const searchQuery = useQuery({
enabled: open && trimmed.length >= 2,
queryKey: ["global-search", trimmed],
queryFn: () => searchGlobal(trimmed, 10),
queryFn: () => searchGlobal(trimmed, 20),
});
const groupedItems = useMemo(() => {
const items = searchQuery.data?.items ?? [];
return SEARCH_GROUPS.map((group) => {
const matches = items.filter((item) => resolveSearchGroup(item.entityType) === group.key);
return {
...group,
total: matches.length,
items: matches.slice(0, 3),
};
}).filter((group) => group.total > 0);
}, [searchQuery.data?.items]);
const message = useMemo(() => {
if (!trimmed) return "输入姓名、公告、项目、合同、资产、请假、报销、周报或待办关键字开始搜索。";
if (trimmed.length < 2) return "至少输入 2 个字符。";
if (searchQuery.isLoading) return "搜索中...";
if (searchQuery.isError) {
return isApiError(searchQuery.error)
? `${searchQuery.error.code}: ${mapApiErrorMessage(searchQuery.error.code, searchQuery.error.details, searchQuery.error.message)}`
: "搜索失败,请稍后重试";
}
if (!groupedItems.length) return "未找到匹配结果。";
return null;
}, [trimmed, searchQuery.error, searchQuery.isError]);
}, [groupedItems.length, searchQuery.error, searchQuery.isError, searchQuery.isLoading, trimmed]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -61,24 +107,39 @@ export function GlobalSearchDialog({ open, onOpenChange }: { open: boolean; onOp
{message ? <div className="text-sm text-zinc-500">{message}</div> : null}
<div className="space-y-2">
{(searchQuery.data?.items ?? []).map((item) => (
<button
key={`${item.entityType}-${item.entityId}`}
type="button"
className="w-full rounded-lg border border-zinc-200 px-3 py-3 text-left hover:bg-zinc-50"
onClick={() => {
onOpenChange(false);
navigate(item.href);
}}
>
<div className="space-y-4">
{groupedItems.map((group) => (
<section key={group.key} className="space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="text-xs uppercase text-zinc-500">{item.matchedBy}</div>
<h3 className="text-sm font-semibold text-zinc-900">{group.label}</h3>
<span className="text-xs text-zinc-400">{group.total} 条</span>
</div>
{item.subtitle ? <div className="mt-1 text-sm text-zinc-600">{item.subtitle}</div> : null}
<div className="mt-1 text-xs text-zinc-500">{entityLabel(item.entityType)} · {item.status ?? "-"}</div>
</button>
<div className="space-y-2">
{group.items.map((item) => (
<button
key={`${item.entityType}-${item.entityId}`}
type="button"
className="w-full rounded-lg border border-zinc-200 px-3 py-3 text-left hover:bg-zinc-50"
onClick={() => {
onOpenChange(false);
navigate(item.href);
}}
>
<div className="flex items-center justify-between gap-3">
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="text-xs uppercase text-zinc-500">{item.matchedBy}</div>
</div>
{item.subtitle ? <div className="mt-1 text-sm text-zinc-600">{item.subtitle}</div> : null}
<div className="mt-1 text-xs text-zinc-500">{entityLabel(item.entityType)} · {item.status ?? "-"}</div>
</button>
))}
</div>
{group.total > group.items.length ? (
<div className="text-xs text-zinc-400">查看更多结果</div>
) : null}
</section>
))}
</div>
+31 -1
查看文件
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { fetchNotifications } from "../../api/notifications";
@@ -58,6 +58,36 @@ export function Topbar() {
const unreadCount = unreadQuery.data?.unreadCount ?? 0;
const unreadBadge = unreadCount > 99 ? "99+" : String(unreadCount);
useEffect(() => {
const handleKeydown = (event: KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) {
return;
}
if (event.key.toLowerCase() !== "k") {
return;
}
if (!window.matchMedia("(min-width: 768px)").matches) {
return;
}
const target = event.target;
if (target instanceof HTMLElement) {
const tagName = target.tagName.toLowerCase();
if (target.isContentEditable || tagName === "input" || tagName === "textarea" || tagName === "select") {
return;
}
}
event.preventDefault();
setSearchOpen(true);
};
window.addEventListener("keydown", handleKeydown);
return () => window.removeEventListener("keydown", handleKeydown);
}, []);
return (
<>
<header className="sticky top-0 z-10 border-b border-zinc-200/80 bg-white/90 px-4 py-4 backdrop-blur sm:px-8">
+2
查看文件
@@ -11,6 +11,7 @@ export const TEST_IDS = {
tasksTable: "workflow-tasks-table",
taskRow: "workflow-task-row",
detailLink: "workflow-task-detail-link",
quickApprove: "workflow-task-quick-approve",
summary: "workflow-task-summary",
approve: "workflow-task-approve",
reject: "workflow-task-reject",
@@ -33,6 +34,7 @@ export const TEST_IDS = {
budgetHint: "reimbursement-budget-hint",
erpExport: "reimbursement-erp-export",
overview: "reimbursement-overview",
workflowProgress: "reimbursement-workflow-progress",
},
weeklyReport: {
save: "weekly-report-save",
+27 -40
查看文件
@@ -270,26 +270,6 @@ export function PortalHomePage() {
tone: payload.weeklySummary.missingCount > 0 ? "warning" as const : "normal" as const,
}
: null,
payload.projectSummary
? {
key: "project-summary",
href: payload.projectSummary.href,
label: "待跟进 / 待收口项目",
value: `${payload.projectSummary.followUpDueTodayCount} / ${payload.projectSummary.closurePendingProjectCount}`,
note: `逾期里程碑 ${payload.projectSummary.overdueMilestonesCount},风险 ${payload.projectSummary.openRiskCount}`,
tone: payload.projectSummary.openRiskCount > 0 || payload.projectSummary.closurePendingProjectCount > 0 ? "warning" as const : "normal" as const,
}
: null,
payload.peopleLifecycle
? {
key: "people-lifecycle",
href: payload.peopleLifecycle.href,
label: "入离职 / 资产待归还",
value: `${payload.peopleLifecycle.onboardingCount + payload.peopleLifecycle.offboardingCount} / ${payload.peopleLifecycle.assetReturnPendingCount}`,
note: "用于跟进入职、离职和资产交接闭环。",
tone: payload.peopleLifecycle.offboardingCount > 0 || payload.peopleLifecycle.assetReturnPendingCount > 0 ? "warning" as const : "normal" as const,
}
: null,
] as Array<SummaryItem | null>).filter(isSummaryItem);
return (
@@ -329,30 +309,37 @@ export function PortalHomePage() {
))}
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr),minmax(0,0.9fr)]">
<SummaryPanel title={isManagerView ? "今日管理重点" : "我的申请与资产"} items={isManagerView ? managerSummaryItems : employeeSummaryItems} />
<AnnouncementsPanel announcements={payload.announcements} />
</div>
{isManagerView ? (
<>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.05fr),minmax(0,0.95fr)]">
<SummaryPanel title="今日管理重点" items={managerSummaryItems} />
<QuickLinksPanel items={payload.quickLinks} />
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)]">
{isManagerView ? (
<SecondaryCardsPanel
title="项目跟进与组织协同"
description="把需要继续跟进的项目、人员与风险放到第二层。"
cards={secondaryCards}
/>
) : (
<RecentVisitsPanel />
)}
<div className="grid gap-6 xl:grid-cols-[minmax(0,0.95fr),minmax(0,1.05fr)]">
<AnnouncementsPanel announcements={payload.announcements} />
<SecondaryCardsPanel
title="项目跟进与组织协同"
description="把项目、组织和风险类信息下移到第二阅读层。"
cards={secondaryCards}
/>
</div>
{isManagerView ? (
<AdoptionSummaryPanel adoptionSummary={payload.adoptionSummary} />
) : (
<QuickLinksPanel items={payload.quickLinks} />
)}
</div>
</>
) : (
<>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr),minmax(0,0.9fr)]">
<SummaryPanel title="我的申请与资产" items={employeeSummaryItems} />
<AnnouncementsPanel announcements={payload.announcements} />
</div>
{isManagerView ? <QuickLinksPanel items={payload.quickLinks} /> : null}
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)]">
<RecentVisitsPanel />
<QuickLinksPanel items={payload.quickLinks} />
</div>
</>
)}
</PageScaffold>
);
}
@@ -32,6 +32,7 @@ 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 { TEST_IDS } from "../lib/testIds";
import { ReimbursementActions, type PayPayload } from "./reimbursement-detail/ReimbursementActions";
import {
ReimbursementItemEditor,
@@ -697,6 +698,37 @@ export function ReimbursementDetailPage() {
<div>关联项目:{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"}</div>
</DetailSideMeta>
{detail.status === "reviewing" && detail.workflowProgress ? (
<DetailSideMeta
title="审批进度"
description={`当前位于第 ${detail.workflowProgress.currentStepIndex + 1} / ${detail.workflowProgress.totalSteps} 步。`}
>
<div className="space-y-3" data-testid={TEST_IDS.reimbursement.workflowProgress}>
{detail.workflowProgress.steps.map((step, index) => (
<div
key={step.id}
className={
step.status === "current"
? "rounded-lg border border-primary-200 bg-primary-50 px-3 py-2"
: step.status === "completed"
? "rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2"
: "rounded-lg border border-zinc-200 bg-white px-3 py-2"
}
>
<div className="flex items-center justify-between gap-3 text-sm">
<span className="font-medium text-zinc-900">
第 {index + 1} 步 · {step.name}
</span>
<span className="text-xs text-zinc-500">
{step.status === "completed" ? "已完成" : step.status === "current" ? "当前节点" : "待处理"}
</span>
</div>
</div>
))}
</div>
</DetailSideMeta>
) : null}
<ReimbursementActions
status={detail.status}
canPay={canPay}
+46 -10
查看文件
@@ -1,8 +1,9 @@
import { Link, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchWorkflowTasks } from "../api/workflow";
import { approveWorkflowTask, fetchWorkflowTasks } from "../api/workflow";
import { PageHeader } from "../components/common/PageHeader";
import { Pagination } from "../components/common/Pagination";
import { StatusBadge } from "../components/common/StatusBadge";
@@ -10,6 +11,7 @@ import { TableSkeleton } from "../components/common/TableSkeleton";
import { Button } from "../components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
import { TEST_IDS } from "../lib/testIds";
const PAGE_SIZE = 20;
@@ -31,6 +33,7 @@ function entityLabel(entityType: string | undefined) {
export function WorkflowTasksPage() {
const [searchParams, setSearchParams] = useSearchParams();
const queryClient = useQueryClient();
const status =
(searchParams.get("status") as
@@ -47,6 +50,26 @@ export function WorkflowTasksPage() {
queryFn: () => fetchWorkflowTasks({ status, page, pageSize: PAGE_SIZE }),
});
const approveMutation = useMutation({
mutationFn: (taskId: string) => approveWorkflowTask(taskId),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
queryClient.invalidateQueries({ queryKey: ["notifications"] }),
]);
toast.success("审批已通过");
},
onError: (error) => {
const message = isApiError(error)
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
: error instanceof Error
? error.message
: "审批失败,请稍后重试";
toast.error(message);
},
});
const totalPages = Math.max(1, Math.ceil((query.data?.total ?? 0) / PAGE_SIZE));
return (
@@ -123,14 +146,27 @@ export function WorkflowTasksPage() {
<TableCell><StatusBadge status={task.status} /></TableCell>
<TableCell>{new Date(task.createdAt).toLocaleString()}</TableCell>
<TableCell>
<Button
asChild
size="sm"
variant="secondary"
data-testid="workflow-task-detail-link"
>
<Link to={`/workflow/tasks/${task.id}`}>详情</Link>
</Button>
<div className="flex flex-wrap gap-2">
{status === "pending" && task.allowedActions.includes("approve") ? (
<Button
size="sm"
type="button"
data-testid={TEST_IDS.workflow.quickApprove}
disabled={approveMutation.isPending && approveMutation.variables === task.id}
onClick={() => approveMutation.mutate(task.id)}
>
{approveMutation.isPending && approveMutation.variables === task.id ? "通过中..." : "通过"}
</Button>
) : null}
<Button
asChild
size="sm"
variant="secondary"
data-testid={TEST_IDS.workflow.detailLink}
>
<Link to={`/workflow/tasks/${task.id}`}>详情</Link>
</Button>
</div>
</TableCell>
</TableRow>
))
+75
查看文件
@@ -0,0 +1,75 @@
import { test, expect } from "@playwright/test";
import { ADMIN, login } from "./helpers/auth";
function envelope<T>(data: T) {
return {
requestId: "mock-request",
data,
};
}
test("E2E 全局搜索:按钮打开后按分组展示结果并支持点击跳转", async ({ page }) => {
await page.route("**/api/search?**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
envelope({
items: [
{ entityType: "employee", entityId: "employee-1", title: "王小明", subtitle: "E001 · 财务", status: "active", href: "/people/employee-1", matchedBy: "prefix", updatedAt: null },
{ entityType: "reimbursement", entityId: "reimbursement-1", title: "BX-001", subtitle: "办公室报销", status: "reviewing", href: "/reimbursements/reimbursement-1", matchedBy: "contains", updatedAt: null },
{ entityType: "leave", entityId: "leave-1", title: "请假 2026-03-24", subtitle: "年假", status: "approved", href: "/leave-requests", matchedBy: "contains", updatedAt: null },
{ entityType: "purchase_request", entityId: "purchase-1", title: "CG-001", subtitle: "办公椅采购", status: "reviewing", href: "/purchase-requests/purchase-1", matchedBy: "contains", updatedAt: null },
{ entityType: "travel_request", entityId: "travel-1", title: "CL-001", subtitle: "上海出差", status: "draft", href: "/travel-requests/travel-1", matchedBy: "contains", updatedAt: null },
{ entityType: "project", entityId: "project-1", title: "海音达试点", subtitle: "实施项目", status: "active", href: "/projects/project-1", matchedBy: "exact", updatedAt: null },
{ entityType: "announcement", entityId: "announcement-1", title: "本周通知", subtitle: "统一回填周报", status: null, href: "/announcements", matchedBy: "contains", updatedAt: null },
{ entityType: "asset", entityId: "asset-1", title: "ZC-001", subtitle: "MacBook Pro", status: "in_use", href: "/assets", matchedBy: "contains", updatedAt: null },
],
}),
),
});
});
await login(page, ADMIN);
await page.goto("/portal");
await expect(page.getByTestId("global-search-trigger")).toBeVisible();
await page.getByTestId("global-search-trigger").click();
await expect(page.getByRole("heading", { name: "全局搜索" })).toBeVisible();
const searchResponse = page.waitForResponse((response) => response.url().includes("/api/search?"));
await page.getByPlaceholder("搜索人员、项目、合同、公告、资产、请假、报销、周报、待办").fill("海音");
await searchResponse;
await expect(page.getByRole("heading", { name: "人员" })).toBeVisible();
await expect(page.getByRole("heading", { name: "业务单据" })).toBeVisible();
await expect(page.getByRole("heading", { name: "项目与公告" })).toBeVisible();
await expect(page.getByRole("heading", { name: "其他" })).toBeVisible();
await expect(page.getByRole("button", { name: /BX-001/ })).toBeVisible();
await expect(page.getByRole("button", { name: /请假 2026-03-24/ })).toBeVisible();
await expect(page.getByRole("button", { name: /CG-001/ })).toBeVisible();
await expect(page.getByRole("button", { name: /CL-001/ })).toHaveCount(0);
await expect(page.getByText("查看更多结果")).toBeVisible();
await page.getByRole("button", { name: /BX-001/ }).click();
await expect(page).toHaveURL(/\/reimbursements\/reimbursement-1/);
});
test("E2E 全局搜索:Ctrl+K 和 Cmd+K 可打开搜索弹窗", async ({ page }) => {
await login(page, ADMIN);
await page.goto("/portal");
await expect(page.getByTestId("global-search-trigger")).toBeVisible();
await page.evaluate(() => {
document.body.dispatchEvent(new KeyboardEvent("keydown", { key: "k", ctrlKey: true, bubbles: true, cancelable: true }));
});
await expect(page.getByRole("heading", { name: "全局搜索" })).toBeVisible();
await page.getByRole("button", { name: "关闭" }).click();
await expect(page.getByRole("heading", { name: "全局搜索" })).not.toBeVisible();
await page.evaluate(() => {
document.body.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true, bubbles: true, cancelable: true }));
});
await expect(page.getByRole("heading", { name: "全局搜索" })).toBeVisible();
});
@@ -29,6 +29,10 @@ export class ReimbursementDetailPageObject {
return this.page.getByTestId(TEST_IDS.reimbursement.budgetHint);
}
get workflowProgress() {
return this.page.getByTestId(TEST_IDS.reimbursement.workflowProgress);
}
get erpExport() {
return this.page.getByTestId(TEST_IDS.reimbursement.erpExport);
}
+3 -1
查看文件
@@ -12,6 +12,9 @@ function extractId(text: string, label: string) {
}
test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登出", async ({ page, reimbursementDetail, workflowDetail }) => {
// 主链路包含 OCR 等待和三角色切换,full-local 下会超过默认 120s。
test.setTimeout(240_000);
// 1) employee: create reimbursement
await login(page, EMPLOYEE);
@@ -42,7 +45,6 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
await page.getByTestId("invoice-upload-submit").click();
await expect(page.getByTestId("invoice-detail-panel")).toBeVisible();
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:pending_ocr");
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:available", { timeout: 90_000 });
const invoiceDetailText = await page.getByTestId("invoice-detail-panel").innerText();
@@ -95,6 +95,7 @@ test("E2E 移动审批:离线草稿冲突态可见", async ({ page }) => {
await page.goto(`/workflow/tasks/${task.id}`);
await expect(page).toHaveURL(new RegExp(`/mobile/approvals\\?taskId=${task.id}`));
await expect(page.getByTestId("mobile-approvals-detail")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("mobile-approvals-detail")).toContainText(created.travelRequest.requestNo);
await waitForWorkflowTaskCache(page, task.id);
await page.context().setOffline(true);
+2 -1
查看文件
@@ -100,7 +100,8 @@ test("E2E 项目工作台:沟通回填 + 收口面板 + Portal 摘要", async
await page.goto("/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 adminClient.dispose();
});
@@ -0,0 +1,62 @@
import { test, expect } from "./helpers/fixtures";
import { EMPLOYEE, login, logout } from "./helpers/auth";
import { openNavLink } from "./helpers/navigation";
function extractId(text: string, label: string) {
const match = text.match(new RegExp(`${label}:([0-9a-f-]{36})`));
if (!match?.[1]) {
throw new Error(`Failed to parse ${label} from text: ${text}`);
}
return match[1];
}
test("E2E 报销审批进度:提交后详情页展示当前步骤进度", async ({ page, reimbursementDetail }) => {
await login(page, EMPLOYEE);
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();
});
await page.getByTestId("reimbursements-create").click();
const createPayload = (await (await createResponsePromise).json()) as { data?: { id?: string } };
const reimbursementId = createPayload.data?.id;
if (!reimbursementId) {
throw new Error(`创建报销单后未返回 id: ${JSON.stringify(createPayload)}`);
}
await page.goto(`/reimbursements/${reimbursementId}`);
await reimbursementDetail.assertLoaded();
await (await openNavLink(page, "票据中心")).click();
const marker = `MOCK_E2E_${Date.now()}_${Math.random().toString(16).slice(2)}`;
await page.getByTestId("invoice-upload-file").setInputFiles({
name: `progress-${Date.now()}.png`,
mimeType: "image/png",
buffer: Buffer.from(marker, "utf8"),
});
await page.getByTestId("invoice-upload-submit").click();
await expect(page.getByTestId("invoice-detail-panel")).toBeVisible();
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:available", { timeout: 90_000 });
const invoiceDetailText = await page.getByTestId("invoice-detail-panel").innerText();
const invoiceId = extractId(invoiceDetailText, "票据ID");
await page.getByTestId("invoice-bind-invoice").selectOption(invoiceId);
await page.getByTestId("invoice-bind-reimbursement").selectOption(reimbursementId);
await page.getByTestId("invoice-bind-submit").click();
await page.goto(`/reimbursements/${reimbursementId}`);
await reimbursementDetail.assertLoaded();
await reimbursementDetail.submitReimbursement();
await expect(page.getByText(/状态:(reviewing|审批中)/)).toBeVisible({ timeout: 30_000 });
await expect(reimbursementDetail.workflowProgress).toBeVisible({ timeout: 30_000 });
await expect(reimbursementDetail.workflowProgress).toContainText("当前节点");
await expect(reimbursementDetail.workflowProgress).toContainText(/第 1 步|第 2 步|第 3 步/);
await logout(page, EMPLOYEE.name);
});
@@ -0,0 +1,105 @@
import type { Locator, 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";
function formatDate(offsetDays: number) {
const value = new Date();
value.setUTCDate(value.getUTCDate() + offsetDays);
return value.toISOString().slice(0, 10);
}
async function selectFirstEnabledOption(locator: Locator) {
await expect
.poll(async () => {
const optionValues = await locator.locator("option").evaluateAll((options) =>
options
.map((option) => ({
value: (option as HTMLOptionElement).value,
disabled: (option as HTMLOptionElement).disabled,
}))
.filter((option) => option.value && !option.disabled)
.map((option) => option.value),
);
return optionValues[0] ?? "";
})
.not.toBe("");
const [firstValue] = await locator.locator("option").evaluateAll((options) =>
options
.map((option) => ({
value: (option as HTMLOptionElement).value,
disabled: (option as HTMLOptionElement).disabled,
}))
.filter((option) => option.value && !option.disabled)
.map((option) => option.value),
);
await locator.selectOption(firstValue);
}
async function createLeaveDraft(page: Page, startDate: string, endDate: string, reason: string) {
const createResponsePromise = page.waitForResponse(
(response) => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/leave-requests" && response.ok(),
);
await selectFirstEnabledOption(page.locator("#leave-type"));
await page.locator("#leave-start-date").fill(startDate);
await page.locator("#leave-end-date").fill(endDate);
await page.locator("#leave-start-session").selectOption("am");
await page.locator("#leave-end-session").selectOption("pm");
await page.locator("#leave-reason").fill(reason);
await page.getByRole("button", { name: "创建草稿" }).click();
const createPayload = (await (await createResponsePromise).json()) as { data?: { id?: string } };
const leaveRequestId = createPayload.data?.id;
if (!leaveRequestId) {
throw new Error(`创建请假草稿后未返回 id: ${JSON.stringify(createPayload)}`);
}
return leaveRequestId;
}
async function submitLeave(api: Awaited<ReturnType<typeof createAuthenticatedApiClient>>["api"], leaveRequestId: string) {
return apiPostData<{ leaveRequestId: string; currentTaskId: string }>(api, `/api/leave-requests/${leaveRequestId}/submit`, {});
}
test("E2E 审批列表快捷通过:经理可在待办列表行内直接通过请假申请", async ({ page }) => {
const employeeClient = await createAuthenticatedApiClient(EMPLOYEE);
const startDate = formatDate(45 + Number(String(Date.now()).slice(-2)));
const reason = `Playwright 快捷通过 ${Date.now()}`;
try {
await login(page, EMPLOYEE);
await (await openNavLink(page, "请假")).click();
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
const leaveRequestId = await createLeaveDraft(page, startDate, startDate, reason);
const submitResult = await submitLeave(employeeClient.api, leaveRequestId);
await logout(page, EMPLOYEE.name);
await login(page, MANAGER);
await (await openNavLink(page, "待我处理")).click();
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
const taskRows = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: submitResult.currentTaskId });
await expect(taskRows).toHaveCount(1, { timeout: 30_000 });
const taskRow = taskRows.first();
await expect(taskRow.getByTestId(TEST_IDS.workflow.quickApprove)).toBeVisible();
await expect(taskRow.getByTestId(TEST_IDS.workflow.detailLink)).toBeVisible();
await taskRow.getByTestId(TEST_IDS.workflow.quickApprove).click();
await expect(taskRows).toHaveCount(0, { timeout: 30_000 });
await logout(page, MANAGER.name);
await login(page, EMPLOYEE);
await page.goto("/leave-requests");
await expect(page.getByRole("row").filter({ hasText: startDate }).first()).toContainText("已通过", { timeout: 30_000 });
} finally {
await employeeClient.dispose();
}
});