重构项目合同轻台账与详情命令面板(05-30-project-contract-ledger-detail-shell)

这个提交包含在:
2026-05-30 12:50:03 +08:00
父节点 dd1b655122
当前提交 fed797d19b
共修改 6 个文件,包含 253 行新增和 128 行删除
@@ -31,10 +31,10 @@
## Acceptance Criteria
- [ ] 项目页能看清当前项目状态、风险、下一步和关联业务摘要。
- [ ] 合同页能看清到期、附件完整性、归档和关联上下文。
- [ ] 项目/合同关联区不突破原业务模块权限。
- [ ] 高敏合同正文、报价、银行材料不出现在普通列表和自动化截图中。
- [x] 项目页能看清当前项目状态、风险、下一步和关联业务摘要。
- [x] 合同页能看清到期、附件完整性、归档和关联上下文。
- [x] 项目/合同关联区不突破原业务模块权限。
- [x] 高敏合同正文、报价、银行材料不出现在普通列表和自动化截图中。
## Test Plan
@@ -3,7 +3,7 @@
"name": "project-contract-ledger-detail-shell",
"title": "项目合同轻台账与详情命令面板重构",
"description": "统一项目和合同轻台账、详情命令面板、跨模块摘要跳转和敏感资料边界。",
"status": "planning",
"status": "in_progress",
"dev_type": null,
"scope": "frontend-page-redesign",
"package": null,
+99 -43
查看文件
@@ -21,6 +21,7 @@ import { fetchProjects, type ConfidentialityLevel } from "../api/projects";
import { isApiError } from "../api/types";
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 { DateTextInput } from "../components/common/DateTextInput";
@@ -205,6 +206,9 @@ export function ContractsRegistryPage() {
const [editor, setEditor] = useState<EditorState>(() => buildEmptyEditor(initialProjectId));
const [showExpiring, setShowExpiring] = useState(false);
const [showTrialData, setShowTrialData] = useState(false);
const [showLedgerTools, setShowLedgerTools] = useState(false);
const [confirmExportFormat, setConfirmExportFormat] = useState<ContractRegistryExportFormat | null>(null);
const [confirmStatusTarget, setConfirmStatusTarget] = useState<ContractRegistryItem | null>(null);
const filterPayload = buildFiltersPayload(filters);
const contractsQuery = useQuery({
@@ -263,6 +267,12 @@ export function ContractsRegistryPage() {
setEditor(buildEmptyEditor(filters.projectId));
};
const openEditor = (item: ContractRegistryItem) => {
setShowLedgerTools(true);
setEditorId(item.id);
setEditor(loadEditorFromItem(item));
};
const saveMutation = useMutation({
mutationFn: async () => {
const payload = buildSavePayload(editor, { includeOwnerEmployeeId: canBrowseEmployees });
@@ -325,25 +335,12 @@ export function ContractsRegistryPage() {
}
actions={
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="secondary"
disabled={exportMutation.isPending}
onClick={() => exportMutation.mutate("csv")}
>
导出 CSV
</Button>
<Button
type="button"
variant="secondary"
disabled={exportMutation.isPending}
onClick={() => exportMutation.mutate("xlsx")}
>
导出 Excel
</Button>
<Button asChild variant="secondary" type="button">
<Link to="/projects">返回项目</Link>
</Button>
<Button type="button" variant={showLedgerTools ? "default" : "secondary"} onClick={() => setShowLedgerTools((value) => !value)}>
台账工具
</Button>
</div>
}
/>
@@ -400,10 +397,7 @@ export function ContractsRegistryPage() {
type="button"
size="sm"
variant="secondary"
onClick={() => {
setEditorId(item.id);
setEditor(loadEditorFromItem(item));
}}
onClick={() => openEditor(item)}
>
编辑
</Button>
@@ -417,8 +411,47 @@ export function ContractsRegistryPage() {
) : null}
</div>
<div className="grid gap-6 xl:grid-cols-[400px,minmax(0,1fr)]">
<div className="card-std space-y-4 p-6">
{showLedgerTools ? (
<div className="detail-command-panel space-y-4" data-testid="contracts-ledger-tools">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<h3 className="text-base font-semibold text-zinc-900">台账工具</h3>
<p className="mt-1 text-sm leading-6 text-zinc-500">
新建/编辑、导出和高级到期范围放在这里。导出文件可能包含合同金额、相对方、责任人和归档摘要。
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button type="button" variant="secondary" disabled={exportMutation.isPending} onClick={() => setConfirmExportFormat("csv")}>
{exportMutation.isPending ? "导出中..." : "导出 CSV"}
</Button>
<Button type="button" variant="secondary" disabled={exportMutation.isPending} onClick={() => setConfirmExportFormat("xlsx")}>
{exportMutation.isPending ? "导出中..." : "导出 Excel"}
</Button>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="space-y-2">
<Label>到期日开始</Label>
<DateTextInput
value={filters.expiryDateFrom}
onChange={(event) => setFilters((current) => ({ ...current, expiryDateFrom: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>到期日结束</Label>
<DateTextInput
value={filters.expiryDateTo}
onChange={(event) => setFilters((current) => ({ ...current, expiryDateTo: event.target.value }))}
/>
</div>
</div>
</div>
) : null}
<div className={showLedgerTools || editorId ? "grid gap-6 xl:grid-cols-[400px,minmax(0,1fr)]" : "grid gap-6"}>
{showLedgerTools || editorId ? (
<div className="card-std space-y-4 p-6" data-testid="contracts-editor-panel">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-zinc-900">{editorId ? "编辑合同台账" : "新建合同台账"}</h3>
@@ -662,6 +695,7 @@ export function ContractsRegistryPage() {
{saveMutation.isPending ? (editorId ? "保存中..." : "创建中...") : editorId ? "保存修改" : "创建合同台账"}
</Button>
</div>
) : null}
<div className="card-std space-y-4 p-6">
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
@@ -670,6 +704,11 @@ export function ContractsRegistryPage() {
<div className="mt-1 text-sm text-zinc-500">{filters.projectId ? "已按项目过滤" : "显示全部可见合同"}</div>
</div>
<div className="flex flex-wrap gap-2">
{!showLedgerTools && !editorId ? (
<Button type="button" variant="secondary" onClick={() => setShowLedgerTools(true)}>
新建合同
</Button>
) : null}
<Button
type="button"
variant="secondary"
@@ -688,9 +727,11 @@ export function ContractsRegistryPage() {
>
重置筛选
</Button>
<Button type="button" variant={showTrialData ? "default" : "secondary"} onClick={() => setShowTrialData((value) => !value)}>
{showTrialData ? "隐藏测试数据" : "显示测试数据"}
</Button>
{showLedgerTools ? (
<Button type="button" variant={showTrialData ? "default" : "secondary"} onClick={() => setShowTrialData((value) => !value)}>
{showTrialData ? "隐藏测试数据" : "显示测试数据"}
</Button>
) : null}
</div>
</div>
@@ -752,21 +793,6 @@ export function ContractsRegistryPage() {
/>
</div>
<div className="space-y-2">
<Label>到期日开始</Label>
<DateTextInput
value={filters.expiryDateFrom}
onChange={(event) => setFilters((current) => ({ ...current, expiryDateFrom: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>到期日结束</Label>
<DateTextInput
value={filters.expiryDateTo}
onChange={(event) => setFilters((current) => ({ ...current, expiryDateTo: event.target.value }))}
/>
</div>
</div>
{contractsQuery.isLoading ? <div className="text-sm text-zinc-500">加载中...</div> : null}
@@ -815,8 +841,7 @@ export function ContractsRegistryPage() {
size="sm"
variant="secondary"
onClick={() => {
setEditorId(item.id);
setEditor(loadEditorFromItem(item));
openEditor(item);
}}
>
编辑
@@ -840,7 +865,7 @@ export function ContractsRegistryPage() {
size="sm"
variant="secondary"
disabled={toggleStatusMutation.isPending}
onClick={() => toggleStatusMutation.mutate(item)}
onClick={() => setConfirmStatusTarget(item)}
>
{item.persistedStatus === "archived" ? "恢复生效" : "归档"}
</Button>
@@ -855,6 +880,37 @@ export function ContractsRegistryPage() {
</div>
</div>
</div>
<ActionConfirmDialog
open={Boolean(confirmExportFormat)}
onOpenChange={(open) => {
if (!open) setConfirmExportFormat(null);
}}
kind="sensitiveExport"
targetName="合同台账"
description="导出文件可能包含合同金额、相对方、项目归属、责任人和归档位置摘要。请确认当前筛选范围和用途合规。"
onConfirm={() => {
if (!confirmExportFormat) return;
exportMutation.mutate(confirmExportFormat);
}}
/>
<ActionConfirmDialog
open={Boolean(confirmStatusTarget)}
onOpenChange={(open) => {
if (!open) setConfirmStatusTarget(null);
}}
kind={confirmStatusTarget?.persistedStatus === "archived" ? "enable" : "archive"}
targetName={confirmStatusTarget?.contractNo}
description={
confirmStatusTarget?.persistedStatus === "archived"
? "恢复后该合同会重新作为生效台账参与到期提醒和项目关联摘要。"
: "归档后该合同不再作为生效合同推进,台账、项目关联和离线归档索引仍保留。"
}
confirmLabel={confirmStatusTarget?.persistedStatus === "archived" ? "确认恢复" : "确认归档"}
onConfirm={() => {
if (!confirmStatusTarget) return;
toggleStatusMutation.mutate(confirmStatusTarget);
}}
/>
</PageScaffold>
);
}
+26 -7
查看文件
@@ -5,6 +5,7 @@ import { fetchEmployees } from "../api/employees";
import { archiveProject, createConfidentialDocumentRef, createProjectCommunicationLog, createProjectDelivery, createProjectMember, createProjectMilestone, createProjectRisk, createProjectTask, fetchProject, fetchProjectClosureChecklist, fetchProjectCommunicationLogs, fetchProjectDeliveries, fetchProjectDocumentRefs, fetchProjectExpensesSummary, fetchProjectMembers, fetchProjectMilestones, fetchProjectPurchaseSummary, fetchProjectRisks, fetchProjectTasks, fetchProjectTravelSummary, fetchProjectWeeklyReports, patchProject, patchProjectClosureChecklistItem, patchProjectCommunicationLog, patchProjectMember, patchProjectMilestone, patchProjectRisk, patchProjectTask, type ConfidentialityLevel, type DocumentStorageType, type ProjectCommunicationChannel, type ProjectClosureChecklistStatus, type ProjectDeliveryRecordType, type ProjectDetail, type ProjectMilestoneStatus, type ProjectRiskLevel, type ProjectStatus, type ProjectTaskPriority, type ProjectTaskStatus } from "../api/projects";
import { fetchContractsRegistry } from "../api/contractsRegistry";
import { mapApiErrorMessage } from "../api/errorMessages";
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
import { MetricCard } from "../components/patterns/MetricCard";
@@ -160,6 +161,14 @@ function resolveProjectEvidenceText(detail: ProjectDetail) {
return `交付 ${facts.deliveryRecordCount} · 合同 ${facts.contractCount} · 资料引用 ${facts.confidentialDocRefCount}`;
}
function documentLocationSummary(item: { confidentialityLevel: ConfidentialityLevel; storageType: DocumentStorageType; offlineArchiveNo: string | null }) {
const archiveNo = item.offlineArchiveNo ? ` · ${item.offlineArchiveNo}` : "";
if (item.confidentialityLevel === "confidential" || item.confidentialityLevel === "restricted") {
return `${storageTypeLabel(item.storageType)} · 已登记受控索引${archiveNo}`;
}
return `${storageTypeLabel(item.storageType)} · 归档索引${archiveNo || " · 已登记"}`;
}
export function ProjectDetailPage() {
const { projectId = "" } = useParams();
const queryClient = useQueryClient();
@@ -253,6 +262,7 @@ export function ProjectDetailPage() {
const [editingCommunicationLogId, setEditingCommunicationLogId] = useState<string | null>(null);
const [waiveChecklistTarget, setWaiveChecklistTarget] = useState<{ itemId: string; currentNote: string | null } | null>(null);
const [waiveChecklistNote, setWaiveChecklistNote] = useState("");
const [confirmArchiveProject, setConfirmArchiveProject] = useState(false);
useEffect(() => {
if (!detail) return;
@@ -588,7 +598,7 @@ export function ProjectDetailPage() {
<Button asChild variant="secondary" type="button">
<Link to={`/contracts-registry?projectId=${detail.id}`}>合同台账</Link>
</Button>
<Button type="button" variant="secondary" disabled={archiveProjectMutation.isPending || detail.status === "archived"} onClick={() => archiveProjectMutation.mutate()}>
<Button type="button" variant="secondary" disabled={archiveProjectMutation.isPending || detail.status === "archived"} onClick={() => setConfirmArchiveProject(true)}>
{archiveProjectMutation.isPending ? "归档中..." : "归档项目"}
</Button>
</div>
@@ -1019,7 +1029,7 @@ export function ProjectDetailPage() {
</div>
</SectionPanel>
<SectionPanel title="归档与合规 · 涉密资料引用" description="只登记目录、链接和归档位置,正文与附件仍离线管理。">
<SectionPanel title="归档与合规 · 资料索引" description="只登记最小必要索引,正文、源码、密钥、完整路径和附件仍走受控归档。">
<div className="space-y-4">
<div className="space-y-3">
<Input value={docTitle} onChange={(event) => setDocTitle(event.target.value)} placeholder="资料标题" />
@@ -1027,7 +1037,7 @@ export function ProjectDetailPage() {
<Input value={docNo} onChange={(event) => setDocNo(event.target.value)} placeholder="文档编号(可选)" />
<Input value={docOfflineArchiveNo} onChange={(event) => setDocOfflineArchiveNo(event.target.value)} placeholder="离线档案号(可选)" />
</div>
<Input value={docStoragePath} onChange={(event) => setDocStoragePath(event.target.value)} placeholder="NAS / Git / 文件盘路径或引用地址" />
<Input value={docStoragePath} onChange={(event) => setDocStoragePath(event.target.value)} placeholder="归档位置摘要或受控索引,不填写完整路径、密钥或源码地址" />
<div className="grid gap-3 md:grid-cols-[180px,180px,120px]">
<select className="select-std" value={docConfidentiality} onChange={(event) => setDocConfidentiality(event.target.value as ConfidentialityLevel)}>
{CONFIDENTIALITY_OPTIONS.map((item) => <option key={item} value={item}>{confidentialityLabel(item)}</option>)}
@@ -1039,7 +1049,7 @@ export function ProjectDetailPage() {
</div>
</div>
<Table>
<TableHeader><TableRow><TableHead>资料</TableHead><TableHead>密级</TableHead><TableHead>存储</TableHead><TableHead>路径</TableHead></TableRow></TableHeader>
<TableHeader><TableRow><TableHead>资料</TableHead><TableHead>密级</TableHead><TableHead>存储</TableHead><TableHead>归档索引</TableHead></TableRow></TableHeader>
<TableBody>
{(docsQuery.data?.items ?? []).map((item) => (
<TableRow key={item.id}>
@@ -1049,7 +1059,7 @@ export function ProjectDetailPage() {
</TableCell>
<TableCell>{confidentialityLabel(item.confidentialityLevel)}</TableCell>
<TableCell>{storageTypeLabel(item.storageType)}</TableCell>
<TableCell className="max-w-[280px] truncate">{item.storagePath}</TableCell>
<TableCell className="max-w-[280px] truncate">{documentLocationSummary(item)}</TableCell>
</TableRow>
))}
</TableBody>
@@ -1150,9 +1160,9 @@ export function ProjectDetailPage() {
<div className="mt-1 text-xl font-semibold text-zinc-900">{contractsQuery.data?.items.length ?? 0}</div>
<div className="mt-2 space-y-2 text-sm">
{(contractsQuery.data?.items ?? []).slice(0, 5).map((item) => (
<div key={item.id} className="rounded-lg border border-zinc-100 px-3 py-2">
<Link key={item.id} to={`/contracts-registry?projectId=${detail.id}`} className="block rounded-lg border border-zinc-100 px-3 py-2 hover:bg-zinc-50">
{item.contractNo} · {item.counterpartyName}
</div>
</Link>
))}
</div>
</div>
@@ -1224,6 +1234,15 @@ export function ProjectDetailPage() {
</DialogFooter>
</DialogContent>
</Dialog>
<ActionConfirmDialog
open={confirmArchiveProject}
onOpenChange={setConfirmArchiveProject}
kind="archive"
targetName={detail.projectNo}
description="归档后该项目不再作为新的执行项目推进,历史成员、交付、合同、费用和资料索引仍保留。请确认当前收口项、风险和关联业务已经核对。"
confirmLabel="确认归档"
onConfirm={() => archiveProjectMutation.mutate()}
/>
</>
);
}
+107 -73
查看文件
@@ -65,7 +65,7 @@ export function ProjectsPage() {
const q = searchParams.get("q") ?? "";
const status = (searchParams.get("status") as "all" | ProjectStatus | null) ?? "all";
const mine = searchParams.get("mine") === "1";
const mine = searchParams.get("mine") !== "0";
const canManageProjects = hasAnyPermission(permissions, ["project.manage", "system.settings", "report.dashboard.view"]);
@@ -78,6 +78,7 @@ export function ProjectsPage() {
const [targetEndAt, setTargetEndAt] = useState("");
const [isConfidential, setIsConfidential] = useState(false);
const [showTrialData, setShowTrialData] = useState(false);
const [showLedgerTools, setShowLedgerTools] = useState(false);
const listQuery = useQuery({
queryKey: ["projects", "list", status, mine, q],
@@ -101,15 +102,26 @@ export function ProjectsPage() {
);
const projectItems = listQuery.data?.items ?? [];
const visibleProjectItems = useMemo(
() =>
showTrialData
? projectItems
: projectItems.filter(
(item) => !hasTrialNoise(item.projectNo, item.name, item.clientName, item.clientAlias, item.summary),
),
[projectItems, showTrialData],
);
const visibleProjectItems = useMemo(() => {
const formalItems = showTrialData
? projectItems
: projectItems.filter(
(item) => !hasTrialNoise(item.projectNo, item.name, item.clientName, item.clientAlias, item.summary),
);
return [...formalItems].sort((a, b) => {
const score = (item: ProjectListItem) => {
let value = 0;
if (item.status === "active") value += 40;
if (item.counts.openRisks > 0) value += 30;
if (item.counts.overdueMilestones > 0) value += 20;
if (item.counts.highPriorityTasks > 0) value += 10;
return value;
};
const scoreDelta = score(b) - score(a);
if (scoreDelta !== 0) return scoreDelta;
return (b.updatedAt ?? "").localeCompare(a.updatedAt ?? "");
});
}, [projectItems, showTrialData]);
const hiddenTrialProjectCount = projectItems.length - visibleProjectItems.length;
const projectStats = useMemo(() => {
return visibleProjectItems.reduce(
@@ -166,8 +178,8 @@ export function ProjectsPage() {
else params.delete("status");
}
if (next.mine !== undefined) {
if (next.mine) params.set("mine", "1");
else params.delete("mine");
if (next.mine) params.delete("mine");
else params.set("mine", "0");
}
setSearchParams(params, { replace: true });
}
@@ -182,20 +194,95 @@ export function ProjectsPage() {
<Button asChild variant="secondary" type="button">
<Link to="/contracts-registry">合同台账</Link>
</Button>
<Button type="button" variant={showLedgerTools ? "default" : "secondary"} onClick={() => setShowLedgerTools((value) => !value)}>
台账工具
</Button>
</div>
}
/>
<StatStrip columns={4}>
<MetricCard title="当前项目" value={projectStats.total} note={mine ? "已切换到我的项目" : "当前筛选结果"} />
<MetricCard title="我相关项目" value={projectStats.total} note={mine ? "默认只看我负责/我参与" : "已切换到全范围"} />
<MetricCard title="进行中" value={projectStats.active} note="优先跟进活跃项目" />
<MetricCard title="逾期里程碑" value={projectStats.overdueMilestones} note="延期事项待处理" tone={projectStats.overdueMilestones > 0 ? "warning" : "neutral"} />
<MetricCard title="未关闭风险" value={projectStats.openRisks} note="问题留痕建议绑定责任人" tone={projectStats.openRisks > 0 ? "danger" : "neutral"} />
</StatStrip>
{showLedgerTools ? (
<SectionPanel title="台账工具" description="新建项目、显示测试数据和全范围查看放在这里,避免普通首屏变成配置台。">
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr),260px]">
<div>
{!canManageProjects ? (
<div className="text-sm text-zinc-500">你当前只有查看权限,项目创建入口仅对老板 / 管理员 / 项目管理角色开放。</div>
) : (
<div className="space-y-4">
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label>项目名称</Label>
<Input value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:深圳某客户现场实施" />
</div>
<div className="space-y-2">
<Label>客户名称</Label>
<Input value={clientName} onChange={(event) => setClientName(event.target.value)} placeholder="客户全称" />
</div>
<div className="space-y-2">
<Label>客户简称</Label>
<Input value={clientAlias} onChange={(event) => setClientAlias(event.target.value)} placeholder="可选" />
</div>
<div className="space-y-2">
<Label>项目经理</Label>
<select className="select-std" value={managerEmployeeId} onChange={(event) => setManagerEmployeeId(event.target.value)}>
<option value="">未指定</option>
{managerCandidates.map((item) => (
<option key={item.id} value={item.id}>
{item.name} · {item.employeeNo}
</option>
))}
</select>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label>开始日期</Label>
<DateTextInput value={startsAt} onChange={(event) => setStartsAt(event.target.value)} />
</div>
<div className="space-y-2">
<Label>目标完成</Label>
<DateTextInput value={targetEndAt} onChange={(event) => setTargetEndAt(event.target.value)} />
</div>
</div>
<div className="space-y-2">
<Label>项目摘要</Label>
<Textarea value={summary} onChange={(event) => setSummary(event.target.value)} placeholder="填项目目标、交付范围、关键约束" />
</div>
<label className="flex items-center gap-2 text-sm text-zinc-700">
<input type="checkbox" checked={isConfidential} onChange={(event) => setIsConfidential(event.target.checked)} />
项目含涉密内容(正文与附件继续离线保存)
</label>
<Button
type="button"
disabled={createMutation.isPending || !name.trim() || !clientName.trim()}
onClick={() => createMutation.mutate()}
>
{createMutation.isPending ? "创建中..." : "创建项目"}
</Button>
</div>
)}
</div>
<div className="space-y-3 rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600">
<div className="font-medium text-slate-900">低频查看</div>
<Button type="button" variant={showTrialData ? "default" : "secondary"} onClick={() => setShowTrialData((value) => !value)}>
{showTrialData ? "隐藏测试数据" : "显示测试数据"}
</Button>
<p>试运行默认隐藏 TEST/SMOKE 项目。全范围查看请使用筛选区的范围按钮,避免普通项目负责人默认进入公司全量项目。</p>
</div>
</div>
</SectionPanel>
) : null}
<div className="page-shell-grid">
<div className="space-y-6">
<FilterBar className="grid gap-3 md:grid-cols-[minmax(0,1fr),180px,160px,160px]">
<FilterBar className="grid gap-3 md:grid-cols-[minmax(0,1fr),180px,160px]">
<Input placeholder="搜索项目编号、项目名、客户名" value={q} onChange={(event) => updateSearch({ q: event.target.value })} />
<select className="select-std" value={status} onChange={(event) => updateSearch({ status: event.target.value })}>
{STATUS_OPTIONS.map((item) => (
@@ -205,10 +292,7 @@ export function ProjectsPage() {
))}
</select>
<Button type="button" variant={mine ? "default" : "secondary"} onClick={() => updateSearch({ mine: !mine })}>
{mine ? "只看我的" : "切换到我的"}
</Button>
<Button type="button" variant={showTrialData ? "default" : "secondary"} onClick={() => setShowTrialData((value) => !value)}>
{showTrialData ? "隐藏测试数据" : "显示测试数据"}
{mine ? "我相关" : "全范围"}
</Button>
</FilterBar>
@@ -259,61 +343,11 @@ export function ProjectsPage() {
</SectionPanel>
</div>
<SectionPanel title="新建项目" description="先建主档,再补成员、里程碑与风险。">
{!canManageProjects ? (
<div className="text-sm text-zinc-500">你当前只有查看权限,项目创建入口仅对老板 / 管理员 / 项目管理角色开放。</div>
) : (
<div className="space-y-4">
<div className="space-y-2">
<Label>项目名称</Label>
<Input value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:深圳某客户现场实施" />
</div>
<div className="space-y-2">
<Label>客户名称</Label>
<Input value={clientName} onChange={(event) => setClientName(event.target.value)} placeholder="客户全称" />
</div>
<div className="space-y-2">
<Label>客户简称</Label>
<Input value={clientAlias} onChange={(event) => setClientAlias(event.target.value)} placeholder="可选" />
</div>
<div className="space-y-2">
<Label>项目经理</Label>
<select className="select-std" value={managerEmployeeId} onChange={(event) => setManagerEmployeeId(event.target.value)}>
<option value="">未指定</option>
{managerCandidates.map((item) => (
<option key={item.id} value={item.id}>
{item.name} · {item.employeeNo}
</option>
))}
</select>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label>开始日期</Label>
<DateTextInput value={startsAt} onChange={(event) => setStartsAt(event.target.value)} />
</div>
<div className="space-y-2">
<Label>目标完成</Label>
<DateTextInput value={targetEndAt} onChange={(event) => setTargetEndAt(event.target.value)} />
</div>
</div>
<div className="space-y-2">
<Label>项目摘要</Label>
<Textarea value={summary} onChange={(event) => setSummary(event.target.value)} placeholder="填项目目标、交付范围、关键约束" />
</div>
<label className="flex items-center gap-2 text-sm text-zinc-700">
<input type="checkbox" checked={isConfidential} onChange={(event) => setIsConfidential(event.target.checked)} />
项目含涉密内容(正文与附件继续离线保存)
</label>
<Button
type="button"
disabled={createMutation.isPending || !name.trim() || !clientName.trim()}
onClick={() => createMutation.mutate()}
>
{createMutation.isPending ? "创建中..." : "创建项目"}
</Button>
</div>
)}
<SectionPanel title="下一步提示" description="项目页只保留轻台账和详情入口。">
<div className="space-y-3 text-sm text-zinc-600">
<p>项目列表用于看状态、风险、负责人和进入详情;成员、里程碑、风险、交付记录和关联业务在详情页处理。</p>
<p>合同、采购、报销和付款只展示摘要与跳转,不在项目列表直接处理对方模块动作。</p>
</div>
</SectionPanel>
</div>
</ListWorkspace>
@@ -1076,16 +1076,32 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
await expect(page.getByTestId("workflow-task-overview")).toBeVisible();
await page.screenshot({ path: screenshotPath("02-pc-workflow-detail.png"), fullPage: true });
await page.goto("/projects");
await expect(page.getByRole("heading", { name: "项目", exact: true })).toBeVisible();
await expect(page.getByText("默认只看我负责/我参与")).toBeVisible();
await expect(page.getByText("新建项目")).toHaveCount(0);
await page.getByRole("button", { name: "台账工具" }).click();
await expect(page.getByText("新建项目")).toBeVisible();
await page.goto("/projects/project-wave3");
await expect(page.getByRole("heading", { name: /HYB-W3/ })).toBeVisible();
await expect(page.getByTestId("project-current-command")).toBeVisible();
await expect(page.locator("table").first()).toBeVisible();
await expect(page.getByText("离线档案柜 A-01")).toHaveCount(0);
await page.screenshot({ path: screenshotPath("03-pc-project-detail.png"), fullPage: true });
await page.goto("/contracts-registry?projectId=project-wave3");
await expect(page.getByRole("heading", { name: "合同台账", exact: true })).toBeVisible();
await expect(page.getByTestId("contracts-registry-summary")).toBeVisible();
await expect(page.getByRole("button", { name: "导出 CSV" })).toHaveCount(0);
await page.getByRole("button", { name: "台账工具" }).click();
await expect(page.getByTestId("contracts-ledger-tools")).toBeVisible();
await expect(page.getByTestId("contracts-editor-panel")).toBeVisible();
await expect(page.getByRole("button", { name: "导出 CSV" })).toBeVisible();
await expect(page.getByTestId("contracts-registry-row")).toBeVisible();
await page.getByTestId("contracts-registry-row").getByRole("button", { name: "归档" }).click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("确认归档");
await page.getByTestId("action-confirm-cancel").click();
await page.screenshot({ path: screenshotPath("04-pc-contracts-registry.png"), fullPage: true });
await page.goto("/notifications");