import { mkdirSync } from "node:fs"; import path from "node:path"; import { expect, test, type Page, type Route } from "@playwright/test"; test.use({ serviceWorkers: "block" }); const screenshotDir = path.resolve(process.cwd(), "../output/playwright/08-05-contract-detail-workspace"); mkdirSync(screenshotDir, { recursive: true }); function screenshotPath(fileName: string) { return path.join(screenshotDir, fileName); } function envelope(data: unknown) { return { requestId: "mock-contract-detail-workspace", data }; } async function fulfillJson(route: Route, data: unknown, status = 200) { await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(envelope(data)) }); } const employee = { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", employeeNo: "HYD000001", name: "合同管理员", phone: "13800000000", email: "contract.manager@hyinda.com", departmentId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", directManagerId: null, status: "active", notificationPushEnabled: true, roleCodes: ["manager"], bankAccount: { bankName: null, bankInstitutionCode: null, accountNumberMasked: null }, profileCompleteness: { hasEmergencyContact: true, hasBankAccount: false, hasAvatar: false, hasIdNumber: false, score: 25 }, wecomBinding: null, }; const contractId = "55555555-5555-4555-8555-555555555551"; const projectId = "11111111-1111-4111-8111-111111111111"; function buildContract(overrides: Record = {}) { return { id: contractId, contractNo: "HT-S-2026-0001", projectId, contractType: "service", counterpartyName: "合成客户有限公司", summary: "年度技术服务合同摘要", amount: "120000.00", currency: "CNY", signedDate: "2026-08-01", effectiveDate: "2026-08-01", expiryDate: "2027-07-31", confidentialityLevel: "restricted", archiveLocation: "线下档案柜 A-01", offlineArchiveNo: "OFF-CONTRACT-001", ownerEmployeeId: employee.id, status: "active", persistedStatus: "active", terminatedAt: null, terminatedReason: null, terminatedBy: null, createdAt: "2026-08-01T08:00:00.000Z", updatedAt: "2026-08-07T08:00:00.000Z", owner: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name }, terminator: null, project: { id: projectId, projectNo: "PRJ-2026-001", name: "合成项目" }, documentRefs: [ { id: "77777777-7777-4777-8777-777777777777", title: "涉密资料线下索引", documentNo: "DOC-2026-001", confidentialityLevel: "restricted", storageType: "offline_archive", offlineArchiveNo: "OFF-DOC-001", createdAt: "2026-08-01T08:00:00.000Z", updatedAt: "2026-08-01T08:00:00.000Z", owner: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name }, }, ], history: [ { id: "audit-created", action: "CONTRACT_REGISTRY_CREATED", summary: "创建合同台账", createdAt: "2026-08-01T08:00:00.000Z", actor: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name }, }, ], ...overrides, }; } function navigationItem(key: string, href: string, label: string, icon: string) { return { key, href, label, icon, placement: "main", permissionCodes: [], featureFlagKey: null }; } async function installContractMocks(page: Page, initialStatus: "active" | "archived" | "terminated" = "active") { let contract = buildContract( initialStatus === "archived" ? { status: "archived", persistedStatus: "archived" } : initialStatus === "terminated" ? { status: "terminated", persistedStatus: "terminated", terminatedAt: "2026-08-07T09:00:00.000Z", terminatedReason: "双方确认提前终止合同并保留原编号", terminatedBy: employee.id, terminator: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name }, } : {}, ); await page.route("**/api/**", async (route) => { const request = route.request(); const url = new URL(request.url()); const pathName = url.pathname; if (pathName === "/api/auth/wecom/config") { await fulfillJson(route, { enabled: false, entryEnabled: false, oauthEnabled: false, corpId: "", agentId: "", loginUrl: null, bindEnabled: false, defaultReturnTo: "/portal", status: "disabled", statusReasons: [] }); return; } if (pathName === "/api/auth/login" || pathName === "/api/auth/refresh") { await route.fulfill({ status: 200, contentType: "application/json", headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" }, body: JSON.stringify(envelope({ accessToken: "mock-contract-token", expiresIn: 900, mustChangePassword: false })), }); return; } if (pathName === "/api/me") { await fulfillJson(route, employee); return; } if (pathName === "/api/system/navigation") { await fulfillJson(route, { workspace: { id: "workspace-contract", persisted: true, profile: "small_team_internal", orgMode: "single_company", primaryEntryMode: "password", notificationStrategy: "in_app", devicePriority: "desktop", moduleVisibility: {}, createdAt: null, updatedAt: null, updatedBy: null }, sections: { main: [navigationItem("portal", "/portal", "工作台", "home_app_logo"), navigationItem("contracts", "/contracts-registry", "合同", "description"), navigationItem("projects", "/projects", "项目", "topic")], admin: [], backup: [] }, recommendedRolePresets: [], }); return; } if (pathName === "/api/notifications") { await fulfillJson(route, { page: 1, pageSize: 1, total: 0, unreadOnly: true, unreadCount: 0, items: [] }); return; } if (pathName === "/api/portal/home") { await fulfillJson(route, { workspace: { id: "workspace-contract", persisted: true, profile: "small_team_internal" }, greeting: { name: employee.name }, cards: [], pendingTasks: { count: 0, href: "/workflow/tasks" }, myApplications: [], riskSummary: null, announcements: [], quickLinks: [], recentActivities: [] }); return; } if (pathName === "/api/projects") { await fulfillJson(route, { items: [{ id: projectId, projectNo: "PRJ-2026-001", name: "合成项目", clientName: "合成客户", clientAlias: null, status: "active", startsAt: "2026-08-01", targetEndAt: null, actualEndAt: null, summary: null, isConfidential: true, owner: null, manager: null, counts: { members: 1, milestones: 0, tasks: 0, risks: 0, overdueMilestones: 0, highPriorityTasks: 0, openRisks: 0 }, updatedAt: null }] }); return; } if (pathName === "/api/employees") { await fulfillJson(route, { page: 1, pageSize: 100, total: 1, items: [{ ...employee, department: null, title: "项目经理", employmentStatus: "active", hireDate: null, avatarUrl: null, createdAt: null, updatedAt: null }] }); return; } if (pathName === "/api/contracts-registry/expiring") { await fulfillJson(route, { days: 30, total: 0, items: [] }); return; } if (pathName === "/api/contracts-registry" && request.method() === "GET") { const { documentRefs: _documentRefs, history: _history, terminatedAt: _terminatedAt, terminatedReason: _terminatedReason, terminatedBy: _terminatedBy, terminator: _terminator, ...item } = contract; await fulfillJson(route, { items: [item] }); return; } if (pathName === `/api/contracts-registry/${contractId}` && request.method() === "GET") { await fulfillJson(route, { item: contract }); return; } if (pathName === `/api/contracts-registry/${contractId}` && request.method() === "PATCH") { const body = request.postDataJSON() as Record; contract = { ...contract, ...body, status: body.status === "archived" ? "archived" : body.status === "active" ? "active" : contract.status, persistedStatus: body.status === "archived" ? "archived" : body.status === "active" ? "active" : contract.persistedStatus, history: [{ id: `audit-${Date.now()}`, action: "CONTRACT_REGISTRY_STATUS_CHANGED", summary: body.status === "archived" ? "状态由“生效中”变为“已归档”" : "状态由“已归档”变为“生效中”", createdAt: "2026-08-07T10:00:00.000Z", actor: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name } }, ...contract.history], }; await fulfillJson(route, { item: contract }); return; } if (pathName === `/api/contracts-registry/${contractId}/terminate` && request.method() === "POST") { const body = request.postDataJSON() as { reason: string }; contract = { ...contract, status: "terminated", persistedStatus: "terminated", terminatedAt: "2026-08-07T10:30:00.000Z", terminatedReason: body.reason, terminatedBy: employee.id, terminator: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name }, history: [{ id: "audit-terminated", action: "CONTRACT_REGISTRY_TERMINATED", summary: `终止原因:${body.reason}`, createdAt: "2026-08-07T10:30:00.000Z", actor: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name } }, ...contract.history], }; await fulfillJson(route, { item: contract }); return; } if (pathName === `/api/contracts-registry/${contractId}/corrections` && request.method() === "POST") { const body = request.postDataJSON() as { summary: string }; contract = { ...contract, history: [{ id: "audit-correction", action: "CONTRACT_REGISTRY_CORRECTION_APPENDED", summary: `追加修正:${body.summary}`, createdAt: "2026-08-07T11:00:00.000Z", actor: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name } }, ...contract.history], }; await fulfillJson(route, { accepted: true }); return; } if (pathName === "/api/contracts-registry" && request.method() === "POST") { const body = request.postDataJSON() as Record; contract = { ...contract, ...body, id: contractId, status: body.status ?? "active", persistedStatus: body.status ?? "active" }; await fulfillJson(route, { item: contract }, 201); return; } if (pathName === `/api/contracts-registry/${contractId}/renew`) { await fulfillJson(route, { item: { ...contract, id: "55555555-5555-4555-8555-555555555552", contractNo: "HT-S-2026-0001-R1", status: "draft", persistedStatus: "draft" }, sourceItem: { ...contract, status: "archived", persistedStatus: "archived" } }); return; } await fulfillJson(route, { message: `unhandled mock api: ${pathName}` }, 404); }); } async function login(page: Page) { await page.goto("/login"); await page.getByTestId("login-identifier").fill(employee.email); await page.getByTestId("login-password").fill("ContractPassword1234"); await page.getByTestId("login-submit").click(); await expect(page).toHaveURL(/\/portal$/); await page.evaluate(() => { document.cookie = "has_session=1; path=/; SameSite=Lax"; }); } test.describe("合同详情工作区", () => { test("列表只负责查找,新增与详情使用稳定路由和证据结构", async ({ page }) => { await installContractMocks(page); await login(page); await page.goto("/contracts-registry"); await expect(page.getByRole("heading", { name: "合同", exact: true })).toBeVisible(); await expect(page.getByTestId("contract-editor")).toHaveCount(0); await expect(page.getByTestId("contracts-registry-table").getByRole("link", { name: "查看详情" })).toBeVisible(); await page.getByTestId("contracts-registry-table").getByRole("link", { name: "查看详情" }).click(); await expect(page).toHaveURL(new RegExp(`/contracts-registry/${contractId}$`)); await expect(page.locator(".section-panel__title").first()).toBeVisible(); const sectionTitles = await page.locator(".section-panel__title").allTextContents(); expect(sectionTitles.slice(0, 4)).toEqual(["状态与下一步", "业务摘要", "证据区", "变更与操作历史"]); await expect(page.getByText("涉密资料请登记线下归档位置", { exact: true })).toBeVisible(); await expect(page.getByText("涉密资料线下索引", { exact: true })).toBeVisible(); await expect(page.getByText("附件完整性", { exact: true })).toHaveCount(0); await expect(page.getByText("上传合同", { exact: false })).toHaveCount(0); await page.screenshot({ path: screenshotPath("contract-detail-desktop.png"), fullPage: true }); await page.setViewportSize({ width: 375, height: 812 }); await expect(page.getByTestId("contract-mobile-sticky-actions")).toBeVisible(); await expect(page.getByTestId("contract-detail-action-panel")).toBeHidden(); const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); expect(overflow).toBeLessThanOrEqual(2); await page.screenshot({ path: screenshotPath("contract-detail-mobile-375x812.png"), fullPage: true }); await page.goto("/contracts-registry"); await page.getByRole("link", { name: "新增合同" }).click(); await expect(page).toHaveURL(/\/contracts-registry\/new$/); await expect(page.getByText("涉密资料请登记线下归档位置", { exact: true })).toBeVisible(); await page.getByTestId("contract-counterparty").fill("未保存的相对方"); await page.getByRole("link", { name: "取消" }).click(); await expect(page.getByTestId("unsaved-changes-dialog")).toContainText("合同表单尚未保存"); await page.getByTestId("unsaved-changes-continue").click(); await expect(page).toHaveURL(/\/contracts-registry\/new$/); await page.getByRole("link", { name: "取消" }).click(); await page.getByTestId("unsaved-changes-discard").click(); await expect(page).toHaveURL(/\/contracts-registry$/); }); test("终止原因禁用说明、对象摘要确认和不可逆终态完整可见", async ({ page }) => { await installContractMocks(page); await login(page); await page.goto(`/contracts-registry/${contractId}`); const terminateButton = page.getByRole("button", { name: "终止合同", exact: true }); await expect(terminateButton).toBeDisabled(); await expect(page.getByRole("alert")).toContainText("请填写终止原因,至少 10 个字"); await page.getByLabel("终止原因").fill("原因太短"); await expect(terminateButton).toBeDisabled(); await page.getByLabel("终止原因").fill("客户项目已取消,双方确认不再继续履行"); await expect(terminateButton).toBeEnabled(); await terminateButton.click(); const confirm = page.getByTestId("action-confirm-dialog"); await expect(confirm).toContainText("HT-S-2026-0001"); await expect(confirm).toContainText("合成客户有限公司"); await expect(confirm).toContainText("120,000.00"); await expect(confirm).toContainText("不可逆"); await page.getByTestId("action-confirm-submit").click(); await expect(page.getByText("已终止", { exact: true }).first()).toBeVisible(); const statusSection = page.locator(".section-panel").filter({ has: page.getByRole("heading", { name: "状态与下一步", exact: true }), }); await expect(statusSection).toContainText("客户项目已取消,双方确认不再继续履行"); await expect(page.getByRole("button", { name: "编辑", exact: true })).toHaveCount(0); await expect(page.getByRole("button", { name: "续约", exact: true })).toHaveCount(0); await expect(page.getByRole("button", { name: "归档", exact: true })).toHaveCount(0); await expect(page.getByRole("button", { name: "恢复", exact: true })).toHaveCount(0); }); test("归档合同锁定编辑并通过追加记录修正,恢复需二次确认", async ({ page }) => { await installContractMocks(page, "archived"); await login(page); await page.goto(`/contracts-registry/${contractId}`); await expect(page.getByRole("button", { name: "编辑", exact: true })).toHaveCount(0); await expect(page.getByText("普通字段已锁定;如需纠错,请追加修正记录。", { exact: true })).toBeVisible(); await page.getByLabel("修正说明").fill("原归档位置登记有误,实际以离线档案号为准"); await page.getByRole("button", { name: "追加记录" }).click(); await expect(page.getByTestId("contract-history")).toContainText("追加修正:原归档位置登记有误,实际以离线档案号为准"); await page.getByRole("button", { name: "恢复", exact: true }).click(); await expect(page.getByTestId("action-confirm-dialog")).toContainText("恢复后合同重新进入生效台账和到期提醒"); await page.getByTestId("action-confirm-submit").click(); await expect(page.getByText("生效中", { exact: true }).first()).toBeVisible(); await expect(page.getByRole("button", { name: "编辑", exact: true }).first()).toBeVisible(); }); });