feat(08-05-contract-detail-workspace): 完成合同详情与终止状态治理

这个提交包含在:
2026-08-08 02:02:52 +08:00
父节点 7546d372d6
当前提交 21851774dd
共修改 25 个文件,包含 2516 行新增和 1021 行删除
+56 -3
查看文件
@@ -2,7 +2,8 @@ import { apiRequestBlob, apiRequestData } from "./http";
import type { ConfidentialityLevel, EmployeeBasic } from "./projects";
export type ContractRegistryType = "purchase" | "sales" | "service" | "framework" | "other";
export type ContractRegistryPersistedStatus = "draft" | "active" | "archived";
export type ContractRegistryPersistedStatus = "draft" | "active" | "terminated" | "archived";
export type ContractRegistryWritableStatus = Exclude<ContractRegistryPersistedStatus, "terminated">;
export type ContractRegistryStatus = ContractRegistryPersistedStatus | "expired";
export type ContractRegistryExportFormat = "csv" | "xlsx";
@@ -33,12 +34,46 @@ export type ContractRegistryItem = {
ownerEmployeeId: string;
status: ContractRegistryStatus;
persistedStatus: ContractRegistryPersistedStatus;
/** Detail-only terminal evidence is omitted from list responses. */
terminatedAt?: string | null;
terminatedReason?: string | null;
terminatedBy?: string | null;
createdAt: string | null;
updatedAt: string | null;
owner: EmployeeBasic | null;
terminator?: EmployeeBasic | null;
project: { id: string; projectNo: string; name: string } | null;
};
export type ContractDocumentRefSummary = {
id: string;
title: string;
documentNo: string | null;
confidentialityLevel: ConfidentialityLevel;
storageType: string;
offlineArchiveNo: string | null;
createdAt: string | null;
updatedAt: string | null;
owner: EmployeeBasic | null;
};
export type ContractHistoryItem = {
id: string;
action: string;
summary: string;
createdAt: string | null;
actor: EmployeeBasic | null;
};
export type ContractRegistryDetail = Omit<ContractRegistryItem, "terminatedAt" | "terminatedReason" | "terminatedBy" | "terminator"> & {
terminatedAt: string | null;
terminatedReason: string | null;
terminatedBy: string | null;
terminator: EmployeeBasic | null;
documentRefs: ContractDocumentRefSummary[];
history: ContractHistoryItem[];
};
function buildFiltersSearch(filters?: ContractRegistryFilters | string) {
const normalized =
typeof filters === "string"
@@ -67,6 +102,10 @@ export async function fetchExpiringContracts(days = 30) {
);
}
export async function fetchContractRegistryDetail(id: string) {
return apiRequestData<{ item: ContractRegistryDetail }>(`/api/contracts-registry/${id}`);
}
export async function createContractRegistry(payload: {
contractNo: string;
projectId?: string | null;
@@ -82,7 +121,7 @@ export async function createContractRegistry(payload: {
archiveLocation: string;
offlineArchiveNo?: string | null;
ownerEmployeeId?: string;
status?: ContractRegistryPersistedStatus;
status?: ContractRegistryWritableStatus;
}) {
return apiRequestData<{ item: ContractRegistryItem }>("/api/contracts-registry", {
method: "POST",
@@ -107,7 +146,7 @@ export async function patchContractRegistry(
archiveLocation: string;
offlineArchiveNo: string | null;
ownerEmployeeId: string;
status: ContractRegistryPersistedStatus;
status: ContractRegistryWritableStatus;
}>,
) {
return apiRequestData<{ item: ContractRegistryItem }>(`/api/contracts-registry/${id}`, {
@@ -125,6 +164,20 @@ export async function renewContractRegistry(id: string) {
);
}
export async function terminateContractRegistry(id: string, reason: string) {
return apiRequestData<{ item: ContractRegistryItem }>(`/api/contracts-registry/${id}/terminate`, {
method: "POST",
body: JSON.stringify({ reason }),
});
}
export async function appendContractRegistryCorrection(id: string, summary: string) {
return apiRequestData<{ accepted: true }>(`/api/contracts-registry/${id}/corrections`, {
method: "POST",
body: JSON.stringify({ summary }),
});
}
export async function exportContractsRegistry(params: ContractRegistryFilters & { format: ContractRegistryExportFormat }) {
return apiRequestBlob("/api/exports/contracts-registry", {
method: "POST",
@@ -0,0 +1,53 @@
import type {
ContractRegistryStatus,
ContractRegistryType,
} from "../../api/contractsRegistry";
import type { ConfidentialityLevel } from "../../api/projects";
export const CONTRACT_TYPE_OPTIONS: ContractRegistryType[] = [
"purchase",
"sales",
"service",
"framework",
"other",
];
export const CONTRACT_FILTER_STATUS_OPTIONS: Array<{ value: ContractRegistryStatus; label: string }> = [
{ value: "active", label: "生效中" },
{ value: "expired", label: "已过期" },
{ value: "draft", label: "草稿" },
{ value: "terminated", label: "已终止" },
{ value: "archived", label: "已归档" },
];
export function contractTypeLabel(value: ContractRegistryType) {
if (value === "purchase") return "采购合同";
if (value === "sales") return "销售合同";
if (value === "service") return "服务合同";
if (value === "framework") return "框架协议";
return "其他";
}
export function contractStatusLabel(value: string | null | undefined) {
if (value === "active") return "生效中";
if (value === "expired") return "已过期";
if (value === "draft") return "草稿";
if (value === "terminated") return "已终止";
if (value === "archived") return "已归档";
return "未知状态";
}
export function confidentialityLabel(value: ConfidentialityLevel) {
if (value === "public") return "公开";
if (value === "internal") return "内部";
if (value === "confidential") return "机密";
return "限制";
}
export function documentStorageLabel(value: string) {
if (value === "offline_archive") return "线下归档";
if (value === "nas") return "内网存储";
if (value === "internal_share") return "内部共享区";
if (value === "oa_link") return "OA 受控链接";
return "已登记索引";
}
+3 -1
查看文件
@@ -2,7 +2,7 @@ export type PcDetailNavigationState = {
from?: string;
};
export type PcDetailSourcePolicy = "reimbursement" | "purchase-request" | "asset";
export type PcDetailSourcePolicy = "reimbursement" | "purchase-request" | "asset" | "contract";
export function buildPcDetailSource(pathname: string, search: string) {
return `${pathname}${search}`;
@@ -27,6 +27,8 @@ export function resolvePcDetailSource(
? url.pathname === "/purchase-requests" || /^\/projects\/[^/]+$/.test(url.pathname)
: policy === "asset"
? url.pathname === "/assets"
: policy === "contract"
? url.pathname === "/contracts-registry" || /^\/projects\/[^/]+$/.test(url.pathname)
: url.pathname === "/reimbursements" || /^\/workflow\/tasks\/[^/]+$/.test(url.pathname);
if (!allowed) return null;
@@ -0,0 +1,634 @@
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
appendContractRegistryCorrection,
createContractRegistry,
fetchContractRegistryDetail,
patchContractRegistry,
renewContractRegistry,
terminateContractRegistry,
type ContractRegistryDetail,
type ContractRegistryItem,
type ContractRegistryType,
type ContractRegistryWritableStatus,
} from "../api/contractsRegistry";
import { fetchEmployees } from "../api/employees";
import { fetchProjects, type ConfidentialityLevel } from "../api/projects";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission } from "../auth/permissions";
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
import { PageHeader } from "../components/common/PageHeader";
import { StatusBadge } from "../components/common/StatusBadge";
import {
usePostSaveNavigation,
useUnsavedChangesGuard,
} from "../components/common/UnsavedChangesGuard";
import {
BusinessDetailActionPanel,
BusinessDetailAside,
BusinessDetailFact,
BusinessDetailFactGrid,
BusinessDetailMain,
BusinessDetailWorkspace,
} from "../components/patterns/BusinessDetailTemplate";
import {
MOBILE_STICKY_ACTION_PAGE_CLASS,
MobileStickyActionBar,
} from "../components/patterns/MobileStickyActionBar";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Textarea } from "../components/ui/textarea";
import {
CONTRACT_TYPE_OPTIONS,
confidentialityLabel,
contractStatusLabel,
contractTypeLabel,
documentStorageLabel,
} from "../features/contractsRegistry/presentation";
import { formatDisplayDate, formatDisplayDateTime, formatMoney } from "../lib/displayFormat";
import {
buildPcDetailSource,
resolvePcDetailSource,
type PcDetailNavigationState,
} from "../lib/pcDetailNavigation";
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
import { toast } from "sonner";
const CONFIDENTIALITY_OPTIONS: ConfidentialityLevel[] = ["public", "internal", "confidential", "restricted"];
const AMOUNT_PATTERN = /^\d{1,12}(\.\d{1,2})?$/;
type ContractEditorState = {
contractNo: string;
projectId: string;
contractType: ContractRegistryType;
counterpartyName: string;
summary: string;
amount: string;
currency: string;
signedDate: string;
effectiveDate: string;
expiryDate: string;
confidentialityLevel: ConfidentialityLevel;
archiveLocation: string;
offlineArchiveNo: string;
ownerEmployeeId: string;
status: ContractRegistryWritableStatus;
};
function emptyEditor(ownerEmployeeId = ""): ContractEditorState {
return {
contractNo: "",
projectId: "",
contractType: "other",
counterpartyName: "",
summary: "",
amount: "",
currency: "CNY",
signedDate: "",
effectiveDate: "",
expiryDate: "",
confidentialityLevel: "internal",
archiveLocation: "",
offlineArchiveNo: "",
ownerEmployeeId,
status: "active",
};
}
function editorFromDetail(item: ContractRegistryDetail): ContractEditorState {
return {
contractNo: item.contractNo,
projectId: item.projectId ?? "",
contractType: item.contractType,
counterpartyName: item.counterpartyName,
summary: item.summary,
amount: item.amount ?? "",
currency: item.currency,
signedDate: item.signedDate ?? "",
effectiveDate: item.effectiveDate ?? "",
expiryDate: item.expiryDate ?? "",
confidentialityLevel: item.confidentialityLevel,
archiveLocation: item.archiveLocation,
offlineArchiveNo: item.offlineArchiveNo ?? "",
ownerEmployeeId: item.ownerEmployeeId,
status: item.persistedStatus === "draft" ? "draft" : "active",
};
}
function nextStepText(item: ContractRegistryDetail) {
if (item.persistedStatus === "terminated") return "合同已终止,仅保留事实与审计记录。";
if (item.persistedStatus === "archived") return "普通字段已锁定;如需纠错,请追加修正记录。";
if (item.status === "expired") return "合同已过期,可评估续约、归档或终止。";
if (item.persistedStatus === "draft") return "补齐合同信息后,可转为生效合同或归档。";
return "合同处于生效状态,请关注到期和归档安排。";
}
function buildPayload(form: ContractEditorState) {
return {
contractNo: form.contractNo.trim(),
projectId: form.projectId || null,
contractType: form.contractType,
counterpartyName: form.counterpartyName.trim(),
summary: form.summary.trim(),
amount: form.amount.trim() || null,
currency: form.currency.trim().toUpperCase() || "CNY",
signedDate: form.signedDate || null,
effectiveDate: form.effectiveDate || null,
expiryDate: form.expiryDate || null,
confidentialityLevel: form.confidentialityLevel,
archiveLocation: form.archiveLocation.trim(),
offlineArchiveNo: form.offlineArchiveNo.trim() || null,
ownerEmployeeId: form.ownerEmployeeId,
};
}
function validateForm(form: ContractEditorState) {
if (!form.contractNo.trim()) throw new Error("请填写合同编号");
if (!form.counterpartyName.trim()) throw new Error("请填写相对方");
if (!form.summary.trim()) throw new Error("请填写合同摘要");
if (!form.archiveLocation.trim()) throw new Error("请填写归档位置");
if (!form.ownerEmployeeId) throw new Error("请选择负责人");
if (form.amount.trim() && !AMOUNT_PATTERN.test(form.amount.trim())) {
throw new Error("合同金额最多 12 位整数和 2 位小数");
}
}
function ContractEditorFields({
form,
setForm,
projects,
employees,
creating,
}: {
form: ContractEditorState;
setForm: Dispatch<SetStateAction<ContractEditorState>>;
projects: Array<{ id: string; projectNo: string; name: string }>;
employees: Array<{ id: string; employeeNo: string; name: string }>;
creating: boolean;
}) {
return (
<div className="space-y-5" data-testid="contract-editor">
<SectionPanel title="基本信息">
<div className="grid gap-4 md:grid-cols-2">
<label className="space-y-2 text-sm font-medium text-zinc-700">
合同编号
<Input data-testid="contract-no" value={form.contractNo} onChange={(event) => setForm((current) => ({ ...current, contractNo: event.target.value }))} />
</label>
<label className="space-y-2 text-sm font-medium text-zinc-700">
合同类型
<select className="select-std" value={form.contractType} onChange={(event) => setForm((current) => ({ ...current, contractType: event.target.value as ContractRegistryType }))}>
{CONTRACT_TYPE_OPTIONS.map((item) => <option key={item} value={item}>{contractTypeLabel(item)}</option>)}
</select>
</label>
<label className="space-y-2 text-sm font-medium text-zinc-700">
相对方
<Input data-testid="contract-counterparty" value={form.counterpartyName} onChange={(event) => setForm((current) => ({ ...current, counterpartyName: event.target.value }))} />
</label>
<label className="space-y-2 text-sm font-medium text-zinc-700">
关联项目
<select className="select-std" value={form.projectId} onChange={(event) => setForm((current) => ({ ...current, projectId: event.target.value }))}>
<option value="">不关联项目</option>
{projects.map((item) => <option key={item.id} value={item.id}>{item.projectNo} · {item.name}</option>)}
</select>
</label>
<label className="space-y-2 text-sm font-medium text-zinc-700">
负责人
<select className="select-std" value={form.ownerEmployeeId} onChange={(event) => setForm((current) => ({ ...current, ownerEmployeeId: event.target.value }))}>
<option value="">请选择负责人</option>
{employees.map((item) => <option key={item.id} value={item.id}>{item.name} ({item.employeeNo})</option>)}
</select>
</label>
{creating ? (
<label className="space-y-2 text-sm font-medium text-zinc-700">
登记状态
<select className="select-std" value={form.status} onChange={(event) => setForm((current) => ({ ...current, status: event.target.value as ContractRegistryWritableStatus }))}>
<option value="active">生效中</option>
<option value="draft">草稿</option>
</select>
</label>
) : null}
<label className="space-y-2 text-sm font-medium text-zinc-700 md:col-span-2">
合同摘要
<Textarea data-testid="contract-summary" className="min-h-24" value={form.summary} onChange={(event) => setForm((current) => ({ ...current, summary: event.target.value }))} />
</label>
</div>
</SectionPanel>
<SectionPanel title="金额与日期">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<label className="space-y-2 text-sm font-medium text-zinc-700">合同金额<Input data-testid="contract-amount" inputMode="decimal" value={form.amount} onChange={(event) => setForm((current) => ({ ...current, amount: event.target.value }))} /></label>
<label className="space-y-2 text-sm font-medium text-zinc-700">币种<Input value={form.currency} maxLength={8} onChange={(event) => setForm((current) => ({ ...current, currency: event.target.value }))} /></label>
<label className="space-y-2 text-sm font-medium text-zinc-700">签署日期<Input type="date" value={form.signedDate} onChange={(event) => setForm((current) => ({ ...current, signedDate: event.target.value }))} /></label>
<label className="space-y-2 text-sm font-medium text-zinc-700">生效日期<Input type="date" value={form.effectiveDate} onChange={(event) => setForm((current) => ({ ...current, effectiveDate: event.target.value }))} /></label>
<label className="space-y-2 text-sm font-medium text-zinc-700">到期日期<Input type="date" value={form.expiryDate} onChange={(event) => setForm((current) => ({ ...current, expiryDate: event.target.value }))} /></label>
<label className="space-y-2 text-sm font-medium text-zinc-700">
敏感级别
<select className="select-std" value={form.confidentialityLevel} onChange={(event) => setForm((current) => ({ ...current, confidentialityLevel: event.target.value as ConfidentialityLevel }))}>
{CONFIDENTIALITY_OPTIONS.map((item) => <option key={item} value={item}>{confidentialityLabel(item)}</option>)}
</select>
</label>
</div>
</SectionPanel>
<SectionPanel title="归档证据">
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
涉密资料请登记线下归档位置
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="space-y-2 text-sm font-medium text-zinc-700">
归档位置
<Input data-testid="contract-archive-location" value={form.archiveLocation} onChange={(event) => setForm((current) => ({ ...current, archiveLocation: event.target.value }))} />
</label>
<label className="space-y-2 text-sm font-medium text-zinc-700">
离线档案号
<Input data-testid="contract-offline-archive-no" value={form.offlineArchiveNo} onChange={(event) => setForm((current) => ({ ...current, offlineArchiveNo: event.target.value }))} />
</label>
</div>
</SectionPanel>
</div>
);
}
export function ContractRegistryCreatePage() {
return <ContractRegistryWorkspace mode="create" />;
}
export function ContractRegistryDetailPage() {
const { contractId = "" } = useParams();
return <ContractRegistryWorkspace mode="detail" contractId={contractId} />;
}
function ContractRegistryWorkspace({ mode, contractId = "" }: { mode: "create" | "detail"; contractId?: string }) {
const navigate = useNavigate();
const location = useLocation();
const queryClient = useQueryClient();
const { me, permissions } = useAuth();
const creating = mode === "create";
const canManage = hasAnyPermission(permissions, ["project.manage", "project.view_all", "system.settings"]);
const currentPath = buildPcDetailSource(location.pathname, location.search);
const navigationState = location.state as PcDetailNavigationState | null;
const backSource = resolvePcDetailSource(navigationState?.from, currentPath, "contract") ?? "/contracts-registry";
const [editing, setEditing] = useState(creating);
const [form, setForm] = useState<ContractEditorState>(() => emptyEditor(me?.id ?? ""));
const [savedSignature, setSavedSignature] = useState(() => JSON.stringify(emptyEditor(me?.id ?? "")));
const [formError, setFormError] = useState("");
const [terminateReason, setTerminateReason] = useState("");
const [correctionSummary, setCorrectionSummary] = useState("");
const [confirmTerminate, setConfirmTerminate] = useState(false);
const [confirmArchive, setConfirmArchive] = useState(false);
const [confirmRenew, setConfirmRenew] = useState(false);
const detailQuery = useQuery({
enabled: !creating && Boolean(contractId),
queryKey: ["contracts-registry", "detail", contractId],
queryFn: () => fetchContractRegistryDetail(contractId),
});
const projectsQuery = useQuery({
enabled: canManage,
queryKey: ["projects", "contract-options"],
queryFn: () => fetchProjects(),
});
const employeesQuery = useQuery({
enabled: canManage,
queryKey: ["employees", "contract-owner-options"],
queryFn: () => fetchEmployees({ page: 1, pageSize: 100, status: "active" }),
});
const detail = detailQuery.data?.item ?? null;
useEffect(() => {
if (!detail || editing) return;
const next = editorFromDetail(detail);
setForm(next);
setSavedSignature(JSON.stringify(next));
setFormError("");
}, [detail, editing]);
const dirty = editing && JSON.stringify(form) !== savedSignature;
const runGuarded = useUnsavedChangesGuard(dirty, "合同表单");
const runAfterSuccessfulSave = usePostSaveNavigation();
const mergeDetailCache = (item: ContractRegistryItem) => {
queryClient.setQueryData<{ item: ContractRegistryDetail }>(
["contracts-registry", "detail", contractId],
(current) => current ? { item: { ...current.item, ...item } } : current,
);
};
const saveMutation = useMutation({
mutationFn: async () => {
validateForm(form);
const payload = buildPayload(form);
if (creating) return createContractRegistry({ ...payload, status: form.status });
return patchContractRegistry(contractId, payload);
},
onSuccess: async (data) => {
setFormError("");
setSavedSignature(JSON.stringify(form));
if (creating) {
if (!data.item.id) {
const message = "合同已创建,但未返回可用标识,请返回列表确认。";
setFormError(message);
toast.error(message);
return;
}
toast.success("合同已创建");
runAfterSuccessfulSave(() => {
navigate(`/contracts-registry/${data.item.id}`, { replace: true, state: { from: backSource } });
});
void queryClient.invalidateQueries({ queryKey: ["contracts-registry"] }).catch(() => undefined);
return;
}
mergeDetailCache(data.item);
setEditing(false);
toast.success("合同信息已更新");
void Promise.all([
queryClient.invalidateQueries({ queryKey: ["contracts-registry", "detail", contractId] }),
queryClient.invalidateQueries({ queryKey: ["contracts-registry", "list"] }),
]).catch(() => undefined);
},
onError: (error) => {
const message = resolveErrorMessage(error, "保存合同失败");
setFormError(message);
toast.error(message);
},
});
const statusMutation = useMutation({
mutationFn: () => patchContractRegistry(contractId, { status: detail?.persistedStatus === "archived" ? "active" : "archived" }),
onSuccess: (data) => {
mergeDetailCache(data.item);
toast.success(detail?.persistedStatus === "archived" ? "合同已恢复" : "合同已归档");
setConfirmArchive(false);
void queryClient.invalidateQueries({ queryKey: ["contracts-registry"] }).catch(() => undefined);
},
onError: (error) => toast.error(resolveErrorMessage(error, "更新合同状态失败")),
});
const renewMutation = useMutation({
mutationFn: () => renewContractRegistry(contractId),
onSuccess: (data) => {
toast.success("续约草稿已创建,原合同已归档");
navigate(`/contracts-registry/${data.item.id}`, { replace: true, state: { from: backSource } });
void queryClient.invalidateQueries({ queryKey: ["contracts-registry"] }).catch(() => undefined);
},
onError: (error) => toast.error(resolveErrorMessage(error, "合同续约失败")),
});
const terminateMutation = useMutation({
mutationFn: () => terminateContractRegistry(contractId, terminateReason.trim()),
onSuccess: (data) => {
setTerminateReason("");
setConfirmTerminate(false);
mergeDetailCache(data.item);
toast.success("合同已终止");
void queryClient.invalidateQueries({ queryKey: ["contracts-registry"] }).catch(() => undefined);
},
onError: (error) => toast.error(resolveErrorMessage(error, "终止合同失败")),
});
const correctionMutation = useMutation({
mutationFn: () => appendContractRegistryCorrection(contractId, correctionSummary.trim()),
onSuccess: () => {
setCorrectionSummary("");
toast.success("修正记录已追加");
void queryClient.invalidateQueries({ queryKey: ["contracts-registry", "detail", contractId] }).catch(() => undefined);
},
onError: (error) => toast.error(resolveErrorMessage(error, "追加修正记录失败")),
});
const terminateReasonLength = terminateReason.trim().length;
const canEdit = Boolean(canManage && detail && ["draft", "active"].includes(detail.persistedStatus));
const canRenew = Boolean(canManage && detail && ["active", "expired"].includes(detail.status));
const canArchive = Boolean(canManage && detail && ["draft", "active"].includes(detail.persistedStatus));
const canRestore = Boolean(canManage && detail?.persistedStatus === "archived");
const canTerminate = Boolean(canManage && detail && ["active", "expired"].includes(detail.status));
const mobileActionsVisible = editing || canEdit;
const headerTitle = creating ? "新增合同" : detail?.contractNo ?? "合同详情";
const cancelEditing = () => {
if (!detail) return;
const next = editorFromDetail(detail);
setForm(next);
setSavedSignature(JSON.stringify(next));
setEditing(false);
setFormError("");
};
if (!creating && detailQuery.isLoading) {
return <PageScaffold><PageHeader title="合同详情" /><div className="text-sm text-zinc-500">加载中...</div></PageScaffold>;
}
if (!creating && detailQuery.isError) {
return (
<PageScaffold>
<PageHeader title="合同详情" actions={<Button asChild variant="secondary"><Link to={backSource}>返回合同列表</Link></Button>} />
<WorkspaceError message="记录可能不存在或你无权访问" />
</PageScaffold>
);
}
if (!creating && !detail) return null;
return (
<PageScaffold className={mobileActionsVisible ? `${MOBILE_STICKY_ACTION_PAGE_CLASS} lg:pb-0` : undefined}>
<PageHeader
title={headerTitle}
meta={!creating && detail ? <StatusBadge status={detail.status} label={contractStatusLabel(detail.status)} /> : null}
actions={(
<div className="flex flex-wrap gap-2">
{!creating && canEdit && !editing ? <Button className="hidden lg:inline-flex" type="button" onClick={() => setEditing(true)}>编辑</Button> : null}
<Button asChild type="button" variant="secondary"><Link to={backSource}>返回合同列表</Link></Button>
</div>
)}
/>
{formError ? <WorkspaceError message={formError} /> : null}
<BusinessDetailWorkspace testId="contract-detail-workspace">
<BusinessDetailMain>
{editing ? (
<ContractEditorFields
form={form}
setForm={setForm}
projects={(projectsQuery.data?.items ?? []).map(({ id, projectNo, name }) => ({ id, projectNo, name }))}
employees={(employeesQuery.data?.items ?? []).map(({ id, employeeNo, name }) => ({ id, employeeNo, name }))}
creating={creating}
/>
) : detail ? (
<>
<SectionPanel title="状态与下一步" actions={<StatusBadge status={detail.status} label={contractStatusLabel(detail.status)} />}>
<p className="text-sm leading-6 text-zinc-700">{nextStepText(detail)}</p>
{detail.persistedStatus === "terminated" ? (
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800">
<div>终止时间:{formatDisplayDateTime(detail.terminatedAt)}</div>
<div>终止人:{detail.terminator?.name ?? "-"}</div>
<div>终止原因:{detail.terminatedReason ?? "-"}</div>
</div>
) : null}
</SectionPanel>
<SectionPanel title="业务摘要">
<BusinessDetailFactGrid>
<BusinessDetailFact label="合同编号">{detail.contractNo}</BusinessDetailFact>
<BusinessDetailFact label="合同类型">{contractTypeLabel(detail.contractType)}</BusinessDetailFact>
<BusinessDetailFact label="相对方">{detail.counterpartyName}</BusinessDetailFact>
<BusinessDetailFact label="金额">{formatMoney(detail.amount)}</BusinessDetailFact>
<BusinessDetailFact label="签署日期">{formatDisplayDate(detail.signedDate)}</BusinessDetailFact>
<BusinessDetailFact label="生效日期">{formatDisplayDate(detail.effectiveDate)}</BusinessDetailFact>
<BusinessDetailFact label="到期日期">{formatDisplayDate(detail.expiryDate)}</BusinessDetailFact>
<BusinessDetailFact label="负责人">{detail.owner?.name ?? "-"}</BusinessDetailFact>
<BusinessDetailFact label="敏感级别">{confidentialityLabel(detail.confidentialityLevel)}</BusinessDetailFact>
<BusinessDetailFact label="关联项目">
{detail.project ? <Link className="text-primary underline underline-offset-2" to={`/projects/${detail.project.id}`}>{detail.project.projectNo} · {detail.project.name}</Link> : "-"}
</BusinessDetailFact>
</BusinessDetailFactGrid>
<div className="mt-4 rounded-lg bg-zinc-50 px-3 py-2 text-sm leading-6 text-zinc-700">{detail.summary}</div>
</SectionPanel>
<SectionPanel title="证据区">
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
涉密资料请登记线下归档位置
</div>
<BusinessDetailFactGrid className="mt-4">
<BusinessDetailFact label="归档位置">{detail.archiveLocation}</BusinessDetailFact>
<BusinessDetailFact label="离线档案号">{detail.offlineArchiveNo ?? "-"}</BusinessDetailFact>
</BusinessDetailFactGrid>
{detail.documentRefs.length ? (
<div className="mt-4 space-y-3" data-testid="contract-document-refs">
<h4 className="text-sm font-semibold text-zinc-900">涉密资料索引</h4>
{detail.documentRefs.map((item) => (
<article key={item.id} className="rounded-lg border border-zinc-200 px-3 py-2 text-sm">
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="mt-1 text-xs text-zinc-500">
{item.documentNo ?? "无资料编号"} · {documentStorageLabel(item.storageType)} · {item.offlineArchiveNo ?? "无离线档案号"}
</div>
</article>
))}
</div>
) : null}
</SectionPanel>
<SectionPanel title="变更与操作历史">
<div className="space-y-3" data-testid="contract-history">
{detail.history.length ? detail.history.map((item) => (
<article key={item.id} className="border-l-2 border-zinc-200 pl-3 text-sm">
<div className="font-medium text-zinc-900">{item.summary}</div>
<div className="mt-1 text-xs text-zinc-500">{item.actor?.name ?? "系统"} · {formatDisplayDateTime(item.createdAt)}</div>
</article>
)) : <div className="text-sm text-zinc-500">暂无操作历史</div>}
</div>
</SectionPanel>
</>
) : null}
</BusinessDetailMain>
<BusinessDetailAside className="hidden lg:block">
<BusinessDetailActionPanel
status={!creating && detail ? <StatusBadge status={detail.status} label={contractStatusLabel(detail.status)} /> : null}
primaryAction={editing ? (
<Button className="hidden w-full lg:inline-flex" type="button" data-testid="contract-save" disabled={saveMutation.isPending} onClick={() => saveMutation.mutate()}>
{saveMutation.isPending ? "保存中..." : creating ? "创建合同" : "保存合同"}
</Button>
) : canEdit ? (
<Button className="hidden w-full lg:inline-flex" type="button" onClick={() => setEditing(true)}>编辑</Button>
) : null}
secondaryActions={!editing && detail ? (
<>
{canRenew ? <Button type="button" size="sm" variant="secondary" onClick={() => setConfirmRenew(true)}>续约</Button> : null}
{canArchive || canRestore ? <Button type="button" size="sm" variant="secondary" onClick={() => setConfirmArchive(true)}>{canRestore ? "恢复" : "归档"}</Button> : null}
</>
) : null}
summary={!creating && detail ? (
<>
<div>合同编号:{detail.contractNo}</div>
<div>相对方:{detail.counterpartyName}</div>
<div>金额:{formatMoney(detail.amount)}</div>
<div>负责人:{detail.owner?.name ?? "-"}</div>
</>
) : null}
testId="contract-detail-action-panel"
>
{editing && !creating ? (
<Button type="button" variant="secondary" className="w-full" onClick={() => runGuarded(cancelEditing)}>取消编辑</Button>
) : null}
{!editing && canTerminate && detail ? (
<div className="space-y-3" data-testid="contract-terminate-panel">
<div className="text-sm font-semibold text-zinc-900">终止合同</div>
<Textarea
aria-label="终止原因"
className="min-h-24"
value={terminateReason}
onChange={(event) => setTerminateReason(event.target.value)}
placeholder="请填写终止原因,至少 10 个字"
/>
{terminateReasonLength < 10 ? (
<p className="text-sm text-red-600" role="alert">请填写终止原因,至少 10 个字(当前 {terminateReasonLength} 个字)</p>
) : <p className="text-xs text-zinc-500">终止后不可恢复、续约或再归档,合同编号不会释放。</p>}
<Button type="button" variant="destructive" className="w-full" disabled={terminateReasonLength < 10 || terminateMutation.isPending} onClick={() => setConfirmTerminate(true)}>
终止合同
</Button>
</div>
) : null}
{!editing && detail?.persistedStatus === "archived" && canManage ? (
<div className="space-y-3" data-testid="contract-correction-panel">
<div className="text-sm font-semibold text-zinc-900">追加修正记录</div>
<Textarea aria-label="修正说明" className="min-h-20" value={correctionSummary} onChange={(event) => setCorrectionSummary(event.target.value)} placeholder="说明需要修正的事实,至少 10 个字;不会覆盖主记录" />
<Button type="button" className="w-full" variant="secondary" disabled={correctionSummary.trim().length < 10 || correctionMutation.isPending} onClick={() => correctionMutation.mutate()}>
追加记录
</Button>
</div>
) : null}
</BusinessDetailActionPanel>
</BusinessDetailAside>
</BusinessDetailWorkspace>
{mobileActionsVisible ? (
<MobileStickyActionBar className="lg:hidden" testId="contract-mobile-sticky-actions">
{editing ? (
<>
{!creating ? <Button type="button" variant="secondary" onClick={() => runGuarded(cancelEditing)}>取消</Button> : <Button asChild variant="secondary"><Link to={backSource}>取消</Link></Button>}
<Button type="button" data-testid="contract-mobile-save" disabled={saveMutation.isPending} onClick={() => saveMutation.mutate()}>{saveMutation.isPending ? "保存中..." : creating ? "创建合同" : "保存合同"}</Button>
</>
) : canEdit ? <Button type="button" onClick={() => setEditing(true)}>编辑</Button> : null}
</MobileStickyActionBar>
) : null}
<ActionConfirmDialog
open={confirmArchive}
onOpenChange={setConfirmArchive}
kind={canRestore ? "enable" : "archive"}
targetName={detail?.contractNo}
description={canRestore ? "恢复后合同重新进入生效台账和到期提醒。" : "归档后普通字段锁定,后续修正只能追加记录。"}
confirmLabel={canRestore ? "确认恢复" : "确认归档"}
onConfirm={async () => { await statusMutation.mutateAsync(); }}
/>
<ActionConfirmDialog
open={confirmRenew}
onOpenChange={setConfirmRenew}
kind="submit"
title="确认创建续约草稿"
targetName={detail?.contractNo}
description="系统将创建下一版草稿并归档当前合同;原合同编号和历史仍保留。"
confirmLabel="确认续约"
onConfirm={async () => { await renewMutation.mutateAsync(); }}
/>
<ActionConfirmDialog
open={confirmTerminate}
onOpenChange={setConfirmTerminate}
kind="cancel"
title="确认终止合同"
targetName={detail?.contractNo}
description={`合同编号:${detail?.contractNo ?? "-"};相对方:${detail?.counterpartyName ?? "-"};金额:${formatMoney(detail?.amount)}。确认后合同进入不可逆的“已终止”,编号不释放,普通编辑、续约、归档和恢复均不可用。`}
confirmLabel="确认终止"
variant="destructive"
onConfirm={async () => { await terminateMutation.mutateAsync(); }}
/>
</PageScaffold>
);
}
function WorkspaceError({ message }: { message: string }) {
return <div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" role="alert">{message}</div>;
}
文件差异内容过多而无法显示 加载差异
+18
查看文件
@@ -59,6 +59,8 @@ import {
ProjectsPage,
ProjectDetailPage,
ContractsRegistryPage,
ContractRegistryCreatePage,
ContractRegistryDetailPage,
SystemSettingsPage,
TeamPresencePage,
TodosPage,
@@ -165,6 +167,22 @@ const routeElements = (
</RequirePermission>
}
/>
<Route
path="contracts-registry/new"
element={
<RequirePermission anyOf={["project.view_all", "project.manage", "system.settings"]}>
{withRouteFallback(<ContractRegistryCreatePage />)}
</RequirePermission>
}
/>
<Route
path="contracts-registry/:contractId"
element={
<RequirePermission anyOf={["project.view_own", "project.view_all", "project.manage", "system.settings"]}>
{withRouteFallback(<ContractRegistryDetailPage />)}
</RequirePermission>
}
/>
<Route path="weekly-reports" element={withRouteFallback(<WeeklyReportsPage />)} />
<Route path="todos" element={withRouteFallback(<TodosPage />)} />
<Route path="dashboard" element={<DashboardPage />} />
@@ -0,0 +1 @@
export { ContractRegistryCreatePage as default } from "../../pages/ContractRegistryWorkspacePage";
@@ -0,0 +1 @@
export { ContractRegistryDetailPage as default } from "../../pages/ContractRegistryWorkspacePage";
+2
查看文件
@@ -50,6 +50,8 @@ export const PortalHomePage = lazy(() => import("./PortalHomePage"));
export const ProjectsPage = lazy(() => import("./ProjectsPage"));
export const ProjectDetailPage = lazy(() => import("./ProjectDetailPage"));
export const ContractsRegistryPage = lazy(() => import("./ContractsRegistryPage"));
export const ContractRegistryCreatePage = lazy(() => import("./ContractRegistryCreatePage"));
export const ContractRegistryDetailPage = lazy(() => import("./ContractRegistryDetailPage"));
export const PurchaseRequestsPage = lazy(() => import("./PurchaseRequestsPage"));
export const PurchaseRequestDetailPage = lazy(() => import("./PurchaseRequestDetailPage"));
export const ReimbursementDetailPage = lazy(() => import("./ReimbursementDetailPage"));
@@ -0,0 +1,334 @@
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: "[email protected]",
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<string, unknown> = {}) {
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<string, unknown>;
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<string, unknown>;
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();
});
});
@@ -497,6 +497,30 @@ const secondContractRegistryItem = {
offlineArchiveNo: "OFF-2026-W3-02",
};
const contractRegistryDetail = {
...contractRegistryItem,
terminatedAt: null,
terminatedReason: null,
terminatedBy: null,
terminator: null,
documentRefs: [],
history: [
{
id: "audit-contract-wave3",
action: "CONTRACT_REGISTRY_CREATED",
summary: "创建合同台账",
createdAt: "2026-05-20T09:00:00.000Z",
actor: { id: visualMe.id, employeeNo: visualMe.employeeNo, name: visualMe.name },
},
],
};
const secondContractRegistryDetail = {
...contractRegistryDetail,
...secondContractRegistryItem,
history: contractRegistryDetail.history,
};
const purchaseDetail = {
id: "purchase-wave3",
requestNo: "CG20260527001",
@@ -997,6 +1021,16 @@ async function mockWave3Apis(page: Page) {
return;
}
if (request.method() === "GET" && ["/api/contracts-registry/contract-wave3", "/api/contracts-registry/contract-wave3-second"].includes(pathName)) {
await fulfillJson(route, { item: pathName.endsWith("-second") ? secondContractRegistryDetail : contractRegistryDetail });
return;
}
if (pathName === "/api/contracts-registry/contract-wave3" && request.method() === "PATCH") {
await fulfillJson(route, { item: { ...contractRegistryDetail, status: "archived", persistedStatus: "archived" } });
return;
}
if (pathName === "/api/contracts-registry") {
await fulfillJson(route, { items: [contractRegistryItem, secondContractRegistryItem] });
return;
@@ -1409,19 +1443,18 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
const contractRows = page.getByTestId("contracts-registry-table").getByTestId("contracts-registry-row");
await expect(contractRows).toHaveCount(2);
await expect(contractRows.first()).toBeVisible();
await page.getByRole("button", { name: "筛选", exact: true }).click();
await expect(page.getByTestId("contracts-advanced-filters")).toBeVisible();
await page.getByRole("button", { name: "新增合同" }).click();
await expect(page.getByTestId("contracts-editor-panel")).toBeVisible();
await page.getByRole("button", { name: "关闭" }).click();
await expect(contractRows).toHaveCount(2);
await page.getByTestId("contracts-registry-table").getByRole("button", { name: "更多" }).first().click();
await page.getByRole("menuitem", { name: "归档" }).click();
await page.getByRole("button", { name: "更多筛选", exact: true }).click();
await expect(page.getByLabel("到期开始日期")).toBeVisible();
await expect(page.getByRole("button", { name: "编辑", exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "更多", exact: true })).toHaveCount(0);
await page.getByTestId("contracts-registry-table").getByRole("link", { name: "查看详情" }).first().click();
await expect(page).toHaveURL(/\/contracts-registry\/contract-wave3$/);
await expect(page.getByText("状态与下一步", { exact: true })).toBeVisible();
await expect(page.getByText("涉密资料请登记线下归档位置", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "归档", exact: true }).click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("确认归档");
await page.getByTestId("action-confirm-cancel").click();
await expect(page.getByTestId("action-confirm-dialog")).toHaveCount(0);
await page.locator(".ops-command-bar__actions").getByRole("button", { name: "更多" }).click();
await expect(page.getByRole("menuitem", { name: "导出 CSV" })).toBeVisible();
await page.screenshot({ path: screenshotPath("04-pc-contracts-registry.png"), fullPage: true });
});
@@ -1464,7 +1497,7 @@ test("/contracts-registry 在 768x1024 与 1024x768 下操作列可达", async (
expect(["auto", "scroll"]).toContain(metrics.overflowX);
expect(metrics.scrollWidth).toBeGreaterThanOrEqual(metrics.clientWidth);
expect(metrics.documentOverflow).toBeLessThanOrEqual(2);
const operation = table.getByRole("button", { name: "更多" }).first();
const operation = table.getByRole("link", { name: "查看详情" }).first();
await expect(operation).toBeVisible();
await operation.click({ trial: true });
await page.screenshot({
@@ -1474,24 +1507,27 @@ test("/contracts-registry 在 768x1024 与 1024x768 下操作列可达", async (
}
});
test("合同长表单从记录 A 切换到记录 B 时支持取消保持与确认放弃", async ({ page }) => {
test("合同详情编辑离开路由时支持取消保持与确认放弃", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto("/contracts-registry");
const table = page.getByTestId("contracts-registry-table");
const editButtons = table.getByRole("button", { name: "编辑", exact: true });
await editButtons.first().click();
const counterparty = page.getByPlaceholder("合同对方名称");
await table.getByRole("link", { name: "查看详情" }).first().click();
await expect(page).toHaveURL(/\/contracts-registry\/contract-wave3$/);
await page.getByRole("button", { name: "编辑", exact: true }).first().click();
const counterparty = page.getByTestId("contract-counterparty");
await counterparty.fill("合成未保存合同相对方");
await editButtons.nth(1).click();
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$/);
await expect(page).toHaveURL(/\/contracts-registry\/contract-wave3$/);
await expect(counterparty).toHaveValue("合成未保存合同相对方");
await editButtons.nth(1).click();
await page.getByRole("link", { name: "返回合同列表" }).click();
await page.getByTestId("unsaved-changes-discard").click();
await expect(counterparty).toHaveValue(secondContractRegistryItem.counterpartyName);
await expect(page).toHaveURL(/\/contracts-registry$/);
await page.getByTestId("contracts-registry-table").getByRole("link", { name: "查看详情" }).nth(1).click();
await expect(page.getByText(secondContractRegistryItem.counterpartyName, { exact: true })).toBeVisible();
});
test("Wave3 移动关键页视觉截图", async ({ page }) => {