OAFRONT4 统一PC日期和状态展示

这个提交包含在:
2026-05-25 08:00:12 +08:00
父节点 fa5ed0337d
当前提交 5519c3d240
共修改 8 个文件,包含 123 行新增和 52 行删除
@@ -0,0 +1,17 @@
import type { ComponentPropsWithoutRef } from "react";
import { Input } from "../ui/input";
export function DateTextInput({
placeholder = "例如 2026-05-25",
...props
}: Omit<ComponentPropsWithoutRef<typeof Input>, "type">) {
return (
<Input
{...props}
type="text"
inputMode="numeric"
pattern="\\d{4}-\\d{2}-\\d{2}"
placeholder={placeholder}
/>
);
}
+7 -5
查看文件
@@ -30,6 +30,7 @@ import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission, hasPermission } from "../auth/permissions";
import { AiSuggestionCard } from "../components/ai/AiSuggestionCard";
import { DateTextInput } from "../components/common/DateTextInput";
import { StatusBadge } from "../components/common/StatusBadge";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { FilterBar, ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace";
@@ -37,6 +38,7 @@ import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { formatDisplayDateTime } from "../lib/displayFormat";
function resolveError(error: unknown) {
if (isApiError(error)) {
@@ -1200,19 +1202,19 @@ export function AssetRegistryPage() {
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">最近盘点/恢复验证日期</label>
<Input type="date" value={assetForm.lastVerifiedAt} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, lastVerifiedAt: event.target.value }))} />
<DateTextInput value={assetForm.lastVerifiedAt} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, lastVerifiedAt: event.target.value }))} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">到期/续费日期</label>
<Input type="date" value={assetForm.expiresAt} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, expiresAt: event.target.value }))} />
<DateTextInput value={assetForm.expiresAt} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, expiresAt: event.target.value }))} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">启用日期</label>
<Input type="date" value={assetForm.activatedAt} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, activatedAt: event.target.value }))} />
<DateTextInput value={assetForm.activatedAt} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, activatedAt: event.target.value }))} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">退役日期</label>
<Input type="date" value={assetForm.retiredAt} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, retiredAt: event.target.value }))} />
<DateTextInput value={assetForm.retiredAt} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, retiredAt: event.target.value }))} />
</div>
<div className="space-y-2 md:col-span-2">
<label className="text-sm font-medium text-zinc-700">保密/回收要求</label>
@@ -1324,7 +1326,7 @@ export function AssetRegistryPage() {
<div className="flex items-center justify-between gap-3">
<div className="space-y-1 text-xs text-zinc-500">
<div>
最近更新时间:{resolveAssetErpTimestamp(assetErpStage) ? new Date(resolveAssetErpTimestamp(assetErpStage) as string).toLocaleString() : "尚无同步记录"}
最近更新时间:{resolveAssetErpTimestamp(assetErpStage) ? formatDisplayDateTime(resolveAssetErpTimestamp(assetErpStage)) : "尚无同步记录"}
</div>
<div>变更来源:{assetErpStage.preview.sourceEventName ?? "-"}</div>
</div>
+6 -10
查看文件
@@ -23,6 +23,7 @@ import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader";
import { StatusBadge } from "../components/common/StatusBadge";
import { DateTextInput } from "../components/common/DateTextInput";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
@@ -499,24 +500,21 @@ export function ContractsRegistryPage() {
<div className="grid gap-3 md:grid-cols-3">
<div className="space-y-2">
<Label>签署日期</Label>
<Input
type="date"
<DateTextInput
value={editor.signedDate}
onChange={(event) => setEditor((current) => ({ ...current, signedDate: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>生效日期</Label>
<Input
type="date"
<DateTextInput
value={editor.effectiveDate}
onChange={(event) => setEditor((current) => ({ ...current, effectiveDate: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>到期日期</Label>
<Input
type="date"
<DateTextInput
value={editor.expiryDate}
onChange={(event) => setEditor((current) => ({ ...current, expiryDate: event.target.value }))}
/>
@@ -685,8 +683,7 @@ export function ContractsRegistryPage() {
<div className="space-y-2">
<Label>到期日开始</Label>
<Input
type="date"
<DateTextInput
value={filters.expiryDateFrom}
onChange={(event) => setFilters((current) => ({ ...current, expiryDateFrom: event.target.value }))}
/>
@@ -694,8 +691,7 @@ export function ContractsRegistryPage() {
<div className="space-y-2">
<Label>到期日结束</Label>
<Input
type="date"
<DateTextInput
value={filters.expiryDateTo}
onChange={(event) => setFilters((current) => ({ ...current, expiryDateTo: event.target.value }))}
/>
+3 -4
查看文件
@@ -28,6 +28,7 @@ import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { StatusBadge } from "../components/common/StatusBadge";
import { DateTextInput } from "../components/common/DateTextInput";
import { downloadBlob } from "../lib/download";
import { formatDisplayDate, formatDisplayDateTime } from "../lib/displayFormat";
import { toast } from "sonner";
@@ -371,8 +372,7 @@ export function PaymentBatchesPage() {
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label>默认付款日期</Label>
<Input
type="date"
<DateTextInput
value={defaultPaymentDate}
disabled={!canManageBatch}
onChange={(event) => setDefaultPaymentDate(event.target.value)}
@@ -615,8 +615,7 @@ export function PaymentBatchesPage() {
disabled={!canEditSelectedBatch || !["pending", "failed"].includes(item.status)}
onBlur={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentVoucherNoOverride: event.target.value || null })}
/>
<Input
type="date"
<DateTextInput
defaultValue={item.paymentDateOverride ?? ""}
disabled={!canEditSelectedBatch || !["pending", "failed"].includes(item.status)}
onBlur={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentDateOverride: event.target.value || null })}
+74 -21
查看文件
@@ -11,6 +11,7 @@ import { MetricCard } from "../components/patterns/MetricCard";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { isApiError } from "../api/types";
import { DateTextInput } from "../components/common/DateTextInput";
import { StatusBadge } from "../components/common/StatusBadge";
import { Button } from "../components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../components/ui/dialog";
@@ -18,6 +19,7 @@ import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { Textarea } from "../components/ui/textarea";
import { formatDisplayDateTime } from "../lib/displayFormat";
import { toast } from "sonner";
const PROJECT_STATUS_OPTIONS: ProjectStatus[] = ["draft", "active", "paused", "completed", "archived"];
@@ -33,6 +35,58 @@ const CLOSURE_CHECKLIST_STATUS_OPTIONS: ProjectClosureChecklistStatus[] = ["pend
const CONFIDENTIALITY_OPTIONS: ConfidentialityLevel[] = ["public", "internal", "confidential", "restricted"];
const STORAGE_TYPE_OPTIONS: DocumentStorageType[] = ["oa_link", "nas", "internal_share", "offline_archive"];
function projectStatusLabel(value: ProjectStatus) {
if (value === "draft") return "草稿";
if (value === "active") return "进行中";
if (value === "paused") return "已暂停";
if (value === "completed") return "已完成";
return "已归档";
}
function memberRoleLabel(value: (typeof MEMBER_ROLE_OPTIONS)[number]) {
if (value === "owner") return "负责人";
if (value === "manager") return "项目经理";
if (value === "observer") return "观察人";
return "成员";
}
function milestoneStatusLabel(value: ProjectMilestoneStatus) {
if (value === "pending") return "待处理";
if (value === "in_progress") return "进行中";
if (value === "done") return "已完成";
if (value === "delayed") return "已延期";
return "已取消";
}
function taskStatusLabel(value: ProjectTaskStatus) {
if (value === "todo") return "待处理";
if (value === "doing") return "进行中";
if (value === "blocked") return "受阻";
if (value === "done") return "已完成";
return "已取消";
}
function taskPriorityLabel(value: ProjectTaskPriority) {
if (value === "low") return "低";
if (value === "high") return "高";
if (value === "urgent") return "紧急";
return "普通";
}
function riskLevelLabel(value: ProjectRiskLevel) {
if (value === "low") return "低";
if (value === "high") return "高";
return "中";
}
function riskStatusLabel(value: string) {
if (value === "open") return "待处理";
if (value === "monitoring") return "观察中";
if (value === "mitigating") return "处理中";
if (value === "closed") return "已关闭";
return value || "-";
}
function resolveError(error: unknown, fallback = "操作失败") {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
@@ -79,8 +133,7 @@ function closureChecklistStatusLabel(value: ProjectClosureChecklistStatus) {
}
function formatDateTime(value: string | null) {
if (!value) return "-";
return value.slice(0, 16).replace("T", " ");
return formatDisplayDateTime(value);
}
export function ProjectDetailPage() {
@@ -569,7 +622,7 @@ export function ProjectDetailPage() {
) : null}
</div>
<div className="grid gap-3 md:grid-cols-[160px,160px,minmax(0,1fr)]">
<Input type="date" value={communicationForm.happenedAt} onChange={(event) => setCommunicationForm((prev) => ({ ...prev, happenedAt: event.target.value }))} />
<DateTextInput value={communicationForm.happenedAt} onChange={(event) => setCommunicationForm((prev) => ({ ...prev, happenedAt: event.target.value }))} />
<select className="select-std" value={communicationForm.channel} onChange={(event) => setCommunicationForm((prev) => ({ ...prev, channel: event.target.value as ProjectCommunicationChannel }))}>
{COMMUNICATION_CHANNEL_OPTIONS.map((item) => <option key={item} value={item}>{communicationChannelLabel(item)}</option>)}
</select>
@@ -577,7 +630,7 @@ export function ProjectDetailPage() {
</div>
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr),180px]">
<Input value={communicationForm.externalOrgName} onChange={(event) => setCommunicationForm((prev) => ({ ...prev, externalOrgName: event.target.value }))} placeholder="外部单位(可选)" />
<Input type="date" value={communicationForm.nextFollowUpDate} onChange={(event) => setCommunicationForm((prev) => ({ ...prev, nextFollowUpDate: event.target.value }))} />
<DateTextInput value={communicationForm.nextFollowUpDate} onChange={(event) => setCommunicationForm((prev) => ({ ...prev, nextFollowUpDate: event.target.value }))} />
</div>
<Textarea value={communicationForm.summary} onChange={(event) => setCommunicationForm((prev) => ({ ...prev, summary: event.target.value }))} placeholder="沟通结果摘要(1000字内)" data-testid="project-communication-summary-input" />
<Textarea value={communicationForm.nextAction} onChange={(event) => setCommunicationForm((prev) => ({ ...prev, nextAction: event.target.value }))} placeholder="下一步动作(可选)" />
@@ -687,20 +740,20 @@ export function ProjectDetailPage() {
<div className="space-y-2">
<Label>状态</Label>
<select className="select-std" value={projectForm.status} onChange={(event) => setProjectForm((prev) => ({ ...prev, status: event.target.value as ProjectStatus }))}>
{PROJECT_STATUS_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
{PROJECT_STATUS_OPTIONS.map((item) => <option key={item} value={item}>{projectStatusLabel(item)}</option>)}
</select>
</div>
<div className="space-y-2">
<Label>开始日期</Label>
<Input type="date" value={projectForm.startsAt} onChange={(event) => setProjectForm((prev) => ({ ...prev, startsAt: event.target.value }))} />
<DateTextInput value={projectForm.startsAt} onChange={(event) => setProjectForm((prev) => ({ ...prev, startsAt: event.target.value }))} />
</div>
<div className="space-y-2">
<Label>目标完成</Label>
<Input type="date" value={projectForm.targetEndAt} onChange={(event) => setProjectForm((prev) => ({ ...prev, targetEndAt: event.target.value }))} />
<DateTextInput value={projectForm.targetEndAt} onChange={(event) => setProjectForm((prev) => ({ ...prev, targetEndAt: event.target.value }))} />
</div>
<div className="space-y-2">
<Label>实际完成</Label>
<Input type="date" value={projectForm.actualEndAt} onChange={(event) => setProjectForm((prev) => ({ ...prev, actualEndAt: event.target.value }))} />
<DateTextInput value={projectForm.actualEndAt} onChange={(event) => setProjectForm((prev) => ({ ...prev, actualEndAt: event.target.value }))} />
</div>
<div className="space-y-2">
<Label>项目经理</Label>
@@ -735,7 +788,7 @@ export function ProjectDetailPage() {
{employeeOptions.map((item) => <option key={item.id} value={item.id}>{item.name} · {item.employeeNo}</option>)}
</select>
<select className="select-std" value={memberRole} onChange={(event) => setMemberRole(event.target.value as (typeof MEMBER_ROLE_OPTIONS)[number])}>
{MEMBER_ROLE_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
{MEMBER_ROLE_OPTIONS.map((item) => <option key={item} value={item}>{memberRoleLabel(item)}</option>)}
</select>
<Button type="button" disabled={addMemberMutation.isPending || !memberEmployeeId} onClick={() => addMemberMutation.mutate()}>
添加成员
@@ -749,7 +802,7 @@ export function ProjectDetailPage() {
<TableCell>{item.employee?.name ?? item.employeeId}</TableCell>
<TableCell>
<select className="select-std h-9" value={item.role} onChange={(event) => patchMemberMutation.mutate({ memberId: item.id, role: event.target.value as (typeof MEMBER_ROLE_OPTIONS)[number] })}>
{MEMBER_ROLE_OPTIONS.map((role) => <option key={role} value={role}>{role}</option>)}
{MEMBER_ROLE_OPTIONS.map((role) => <option key={role} value={role}>{memberRoleLabel(role)}</option>)}
</select>
</TableCell>
<TableCell>{item.joinedAt ? item.joinedAt.slice(0, 10) : "-"}</TableCell>
@@ -769,9 +822,9 @@ export function ProjectDetailPage() {
<div className="space-y-4">
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr),160px,160px,120px]">
<Input value={milestoneTitle} onChange={(event) => setMilestoneTitle(event.target.value)} placeholder="里程碑标题" />
<Input type="date" value={milestoneDueDate} onChange={(event) => setMilestoneDueDate(event.target.value)} />
<DateTextInput value={milestoneDueDate} onChange={(event) => setMilestoneDueDate(event.target.value)} />
<select className="select-std" value={milestoneStatus} onChange={(event) => setMilestoneStatus(event.target.value as ProjectMilestoneStatus)}>
{MILESTONE_STATUS_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
{MILESTONE_STATUS_OPTIONS.map((item) => <option key={item} value={item}>{milestoneStatusLabel(item)}</option>)}
</select>
<Button type="button" disabled={addMilestoneMutation.isPending || !milestoneTitle.trim()} onClick={() => addMilestoneMutation.mutate()}>新增</Button>
</div>
@@ -784,7 +837,7 @@ export function ProjectDetailPage() {
<TableCell>{item.dueDate ?? "-"}</TableCell>
<TableCell>
<select className="select-std h-9" value={item.status} onChange={(event) => patchMilestoneMutation.mutate({ milestoneId: item.id, status: event.target.value as ProjectMilestoneStatus })}>
{MILESTONE_STATUS_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
{MILESTONE_STATUS_OPTIONS.map((option) => <option key={option} value={option}>{milestoneStatusLabel(option)}</option>)}
</select>
</TableCell>
</TableRow>
@@ -805,9 +858,9 @@ export function ProjectDetailPage() {
{employeeOptions.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
<select className="select-std" value={taskPriority} onChange={(event) => setTaskPriority(event.target.value as ProjectTaskPriority)}>
{TASK_PRIORITY_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
{TASK_PRIORITY_OPTIONS.map((item) => <option key={item} value={item}>{taskPriorityLabel(item)}</option>)}
</select>
<Input type="date" value={taskDueDate} onChange={(event) => setTaskDueDate(event.target.value)} />
<DateTextInput value={taskDueDate} onChange={(event) => setTaskDueDate(event.target.value)} />
<Button type="button" disabled={addTaskMutation.isPending || !taskTitle.trim()} onClick={() => addTaskMutation.mutate()}>新增</Button>
</div>
<Table>
@@ -827,12 +880,12 @@ export function ProjectDetailPage() {
</TableCell>
<TableCell>
<select className="select-std h-9" value={item.priority} onChange={(event) => patchTaskMutation.mutate({ taskId: item.id, payload: { priority: event.target.value as ProjectTaskPriority } })}>
{TASK_PRIORITY_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
{TASK_PRIORITY_OPTIONS.map((option) => <option key={option} value={option}>{taskPriorityLabel(option)}</option>)}
</select>
</TableCell>
<TableCell>
<select className="select-std h-9" value={item.status} onChange={(event) => patchTaskMutation.mutate({ taskId: item.id, payload: { status: event.target.value as ProjectTaskStatus } })}>
{TASK_STATUS_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
{TASK_STATUS_OPTIONS.map((option) => <option key={option} value={option}>{taskStatusLabel(option)}</option>)}
</select>
</TableCell>
</TableRow>
@@ -849,7 +902,7 @@ export function ProjectDetailPage() {
<Textarea value={riskDetail} onChange={(event) => setRiskDetail(event.target.value)} placeholder="风险详情、影响与当前处理动作" />
<div className="grid gap-3 md:grid-cols-[160px,160px,minmax(0,1fr),120px]">
<select className="select-std" value={riskLevel} onChange={(event) => setRiskLevel(event.target.value as ProjectRiskLevel)}>
{RISK_LEVEL_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
{RISK_LEVEL_OPTIONS.map((item) => <option key={item} value={item}>{riskLevelLabel(item)}</option>)}
</select>
<select className="select-std" value={riskOwnerId} onChange={(event) => setRiskOwnerId(event.target.value)}>
<option value="">未指定责任人</option>
@@ -868,11 +921,11 @@ export function ProjectDetailPage() {
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="text-xs text-zinc-500">{item.detail}</div>
</TableCell>
<TableCell>{item.level}</TableCell>
<TableCell>{riskLevelLabel(item.level)}</TableCell>
<TableCell>{item.owner?.name ?? "-"}</TableCell>
<TableCell>
<select className="select-std h-9" value={item.status} onChange={(event) => patchRiskMutation.mutate({ riskId: item.id, status: event.target.value })}>
{RISK_STATUS_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
{RISK_STATUS_OPTIONS.map((option) => <option key={option} value={option}>{riskStatusLabel(option)}</option>)}
</select>
</TableCell>
</TableRow>
@@ -893,7 +946,7 @@ export function ProjectDetailPage() {
<select className="select-std" value={deliveryType} onChange={(event) => setDeliveryType(event.target.value as ProjectDeliveryRecordType)}>
{DELIVERY_TYPE_OPTIONS.map((item) => <option key={item} value={item}>{recordTypeLabel(item)}</option>)}
</select>
<Input type="date" value={deliveryOccurredAt} onChange={(event) => setDeliveryOccurredAt(event.target.value)} />
<DateTextInput value={deliveryOccurredAt} onChange={(event) => setDeliveryOccurredAt(event.target.value)} />
<Button type="button" disabled={addDeliveryMutation.isPending || !deliveryTitle.trim() || !deliverySummary.trim()} onClick={() => addDeliveryMutation.mutate()}>新增</Button>
</div>
</div>
+5 -3
查看文件
@@ -12,6 +12,7 @@ import {
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission } from "../auth/permissions";
import { DateTextInput } from "../components/common/DateTextInput";
import { StatusBadge } from "../components/common/StatusBadge";
import { FilterBar, ListWorkspace, StatStrip, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { MetricCard } from "../components/patterns/MetricCard";
@@ -20,6 +21,7 @@ import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { Textarea } from "../components/ui/textarea";
import { formatDisplayDate } from "../lib/displayFormat";
import { toast } from "sonner";
const STATUS_OPTIONS: Array<{ value: "all" | ProjectStatus; label: string }> = [
@@ -219,7 +221,7 @@ export function ProjectsPage() {
<div className="text-sm text-zinc-500">
<div>负责人:{item.owner?.name ?? "-"}</div>
<div>项目经理:{item.manager?.name ?? "-"}</div>
<div>目标完成:{item.targetEndAt ?? "-"}</div>
<div>目标完成:{formatDisplayDate(item.targetEndAt)}</div>
</div>
</div>
<div className="text-sm text-zinc-600">{item.summary || "暂无项目摘要"}</div>
@@ -267,11 +269,11 @@ export function ProjectsPage() {
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label>开始日期</Label>
<Input type="date" value={startsAt} onChange={(event) => setStartsAt(event.target.value)} />
<DateTextInput value={startsAt} onChange={(event) => setStartsAt(event.target.value)} />
</div>
<div className="space-y-2">
<Label>目标完成</Label>
<Input type="date" value={targetEndAt} onChange={(event) => setTargetEndAt(event.target.value)} />
<DateTextInput value={targetEndAt} onChange={(event) => setTargetEndAt(event.target.value)} />
</div>
</div>
<div className="space-y-2">
+7 -5
查看文件
@@ -39,6 +39,7 @@ import { hasPermission } from "../auth/permissions";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { AiSuggestionCard } from "../components/ai/AiSuggestionCard";
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
import { DateTextInput } from "../components/common/DateTextInput";
import { Pagination } from "../components/common/Pagination";
import { StatusBadge } from "../components/common/StatusBadge";
import { TableSkeleton } from "../components/common/TableSkeleton";
@@ -48,6 +49,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from ".
import { Textarea } from "../components/ui/textarea";
import { FilterBar, ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { formatDisplayDateTime } from "../lib/displayFormat";
import { buildPrecheckDialogMessage } from "../lib/precheck";
import {
formatApprovalHistoryTime,
@@ -1229,7 +1231,7 @@ export function PurchaseRequestsPage() {
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">期望到货日期</label>
<Input data-testid="purchase-expected-delivery-date" type="date" value={expectedDeliveryDate} disabled={!canEditDraft} onChange={(event) => setExpectedDeliveryDate(event.target.value)} />
<DateTextInput data-testid="purchase-expected-delivery-date" value={expectedDeliveryDate} disabled={!canEditDraft} onChange={(event) => setExpectedDeliveryDate(event.target.value)} />
</div>
</div>
@@ -1535,7 +1537,7 @@ export function PurchaseRequestsPage() {
<div>
<div className="text-zinc-400">时间</div>
<div className="mt-0.5 text-zinc-700">
{attachment.createdAt ? new Date(attachment.createdAt).toLocaleString() : "-"}
{formatDisplayDateTime(attachment.createdAt)}
</div>
</div>
</div>
@@ -1578,7 +1580,7 @@ export function PurchaseRequestsPage() {
<div className="text-xs text-zinc-500">{attachment.mimeType} / {attachment.fileSize} bytes</div>
</TableCell>
<TableCell>{attachment.uploader?.name ?? "-"}</TableCell>
<TableCell>{attachment.createdAt ? new Date(attachment.createdAt).toLocaleString() : "-"}</TableCell>
<TableCell>{formatDisplayDateTime(attachment.createdAt)}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button type="button" size="sm" variant="secondary" onClick={() => void handleDownloadAttachment(attachment.id)}>
@@ -1618,7 +1620,7 @@ export function PurchaseRequestsPage() {
</div>
{archiveOrder ? (
<div className="text-right text-xs text-zinc-500">
<div>归档时间:{archiveOrder.archivedAt ? new Date(archiveOrder.archivedAt).toLocaleString() : "-"}</div>
<div>归档时间:{formatDisplayDateTime(archiveOrder.archivedAt)}</div>
<div>归档人:{archiveOrder.archivedByEmployee?.name ?? "-"}</div>
</div>
) : null}
@@ -1702,7 +1704,7 @@ export function PurchaseRequestsPage() {
<div>
<div className="text-sm font-semibold text-zinc-900">{meta.title}</div>
<div className="text-xs text-zinc-500">
{stageTime ? `最近更新时间:${new Date(stageTime).toLocaleString()}` : "尚无同步记录"}
{stageTime ? `最近更新时间:${formatDisplayDateTime(stageTime)}` : "尚无同步记录"}
</div>
</div>
<StatusBadge status={stage.status} />
@@ -4,8 +4,8 @@
"OAFRONT4-2","付款批次 PC 主展示去内部化","修复付款批次页面在 finance/finance_mgr 视角暴露内部 UUID、英文状态 EXECUTED/DRAFT、日期英文占位和长内部流水的问题;保持付款批次真实执行仍走线下银行,不新增真实支付能力。","付款批次列表和详情主展示使用付款批次号、实体业务编号、中文状态、中文日期展示;内部 UUID 只允许管理员审计/调试折叠区可见;不改变付款批次创建、上传回单、执行、取消、复核逻辑。","受影响流程:财务登录 -> 打开付款批次 -> 查看批次列表/详情/批次项 -> 上传回单入口可见但不自动上传 -> 不执行真实付款;验证步骤:cd client && npm run typecheck && npm run build;bash scripts/qa/run-oafront3-pc-readonly-visual.sh;finance/finance_mgr /payment-batches 不再有 P1 UUID 主展示,英文状态和日期占位显著下降。","typescript|playwright|manual","DONE","DONE","TODO","client/src/pages/PaymentBatchesPage.tsx;client/src/api/paymentBatches.ts;client/src/lib/formatters.ts","[""OAFRONT4-1""]","已完成本地实现:付款批次主文案、状态、日期、渠道和批次项状态中文化;未改后端状态机,未执行真实付款。Regression 待公网 OAFRONT3 只读矩阵复跑后关闭。"
"OAFRONT4-3","PC 导航 Portal 本地化最小修复","将 PC 主导航和页面主展示中的 Portal 英文称呼收口为“工作台”,同时避免把 smoke 账号英文角色名作为普通用户首页主称呼;不重排 Portal 首屏 hero。","左侧导航、面包屑/标题、可见主入口不再以 Portal 作为普通用户主展示;测试账号在 UI 中优先显示中文姓名或中文职能名;管理员排障页可保留 role code。","受影响流程:员工/主管/财务/管理员登录 PC -> 左侧导航 -> 工作台 -> 顶栏账号展示;验证步骤:rg -n 'Portal' client/src;cd client && npm run typecheck && npm run build;OAFRONT3 PC 矩阵中 A/B 页面 Portal 英文主展示 finding 清零或仅剩 C 类后台例外。","typescript|playwright|rg","DONE","DONE","TODO","client/src/components/navigation/NavLinks.tsx;client/src/features/navigation/schema.ts;client/src/pages/PortalHomePage.tsx;server/scripts|server/src/seeds","[""OAFRONT4-1""]","已完成本地实现:主导航、系统导航配置、工作区设置、功能开关、周报提示和工作台加载文案收口为“工作台”;类型名和 Radix Portal 组件名保留。Regression 待公网 OAFRONT3 只读矩阵复跑后关闭。"
"OAFRONT4-4","合同台账 PC 表单与状态中文化收口","修复合同台账 active/draft/archived 英文状态、日期控件英文占位、输入裁切和过长表单首屏压力;维持合同正文不上传 OA 的边界。","合同状态、操作和筛选展示中文;日期字段提供中文标签/格式提示或自定义展示;疑似裁切输入修复;新建合同表单保留必要字段,归档/续约/敏感字段可折叠或分区。","受影响流程:管理员录入合同摘要 -> 设置状态/日期/责任人/归档位置 -> 列表筛选 -> 查看/编辑 -> 权限负例普通员工不可见敏感入口;验证步骤:client typecheck/build;OAFRONT3 矩阵 admin /contracts-registry 英文状态、日期占位、裁切 finding 下降;手工确认不上传合同正文。","typescript|playwright|manual","DONE","DONE","TODO","client/src/pages/ContractsRegistryPage.tsx;client/src/api/contractsRegistry.ts","[""OAFRONT4-1""]","已完成本地实现:合同编辑状态、过期提示、列表状态、日期展示和归档动作中文化;未新增合同正文上传或电子签章能力。Regression 待公网 OAFRONT3 只读矩阵复跑后关闭。"
"OAFRONT4-5","资产台账 PC 首屏与字段密度治理","结合 ASSET1D 试录和 OAFRONT3 截图,调整资产台账首屏主次:建卡、筛选、列表、详情优先,分类维护降级;清理资产状态码、长内部流水、日期英文占位和过密扩展字段展示。","管理员能清楚完成 FA/EA-SYS/EA-REPO/EA-BKP 建卡和查看;资产列表主列不显示内部长流水为主标识;电子资产扩展 JSON 不作为普通主展示,改为摘要/折叠/调试区;日期字段中文化。","受影响流程:管理员资产建卡 -> 筛选 -> 查看详情 -> 领用/归还/盘点入口;普通员工查看自己相关资产;验证步骤:client typecheck/build;OAFRONT3 矩阵 admin /assets finding 下降;ASSET1D 手工试录路径不回归。","typescript|playwright|manual","TODO","TODO","TODO","client/src/pages/AssetRegistryPage.tsx;client/src/api/assets.ts","[""OAFRONT4-1""]","优先级 P1。不得把 EA-ACC 密码/token/Secret 纳入 OA;不上传源码包、合同正文、银行流水或完整本地路径。"
"OAFRONT4-6","票据/采购/项目 PC 长内部流水与日期控件清理","处理 OAFRONT3 中票据中心、采购申请和项目页的长内部流水、日期英文占位和空态弱提示;不改变 OCR、采购审批、项目关联或后端接口语义。","票据、采购、项目列表/详情主展示使用业务编号、摘要、申请人、金额/日期和中文状态;长内部流水不作为主文案;日期输入和展示中文化;空态给出下一步动作且不重复大段解释。","受影响流程:员工查看票据中心 -> 采购申请列表/详情 -> 主管查看项目列表;验证步骤:client typecheck/build;OAFRONT3 矩阵 /invoice-pool、/purchase-requests、/projects 相关 P2 finding 下降;真实 OCR/采购流程回归不变。","typescript|playwright|manual","TODO","TODO","TODO","client/src/pages/InvoicePoolPage.tsx;client/src/pages/invoice-pool/*;client/src/pages/PurchaseRequestsPage.tsx;client/src/pages/ProjectsPage.tsx;client/src/pages/ProjectDetailPage.tsx","[""OAFRONT4-1""]","优先级 P1/P2。票据隐私边界不变:不在报告复述完整发票号、票面原图、供应商敏感细节。"
"OAFRONT4-7","PC 日期与状态展示小组件化","若 OAFRONT4-2/4/5/6 出现重复实现,抽出轻量前端 helper 或小组件统一日期展示、中文状态标签和内部 ID 折叠规则;避免每页手写不同 label。","存在可复用的 status/date/display helper;付款、合同、资产、票据/采购至少两个页面复用;不引入新 UI 框架;C 类后台仍可显示必要内部码值但 A/B 类默认隐藏。","受影响流程:PC A/B 类页面展示业务对象 -> 状态/日期/内部 ID 一致;验证步骤:client typecheck/build;check-ui-design-rules;OAFRONT3 矩阵 P1/P2 下降;无 circular chunk warning。","typescript|node|playwright","DONE","DONE","TODO","client/src/lib/*;client/src/components/common/*;client/src/pages/PaymentBatchesPage.tsx;client/src/pages/ContractsRegistryPage.tsx;client/src/pages/AssetRegistryPage.tsx","[""OAFRONT4-2"",""OAFRONT4-4"",""OAFRONT4-5""]","已新增 client/src/lib/displayFormat.ts,付款批次和合同台账已复用日期展示 helper;StatusBadge 补充 reviewed/executed。Regression 待公网 OAFRONT3 只读矩阵复跑后关闭。"
"OAFRONT4-8","PC 修复后自动回归与截图证据","在完成任一实施批次后,执行前端构建、设计规则、PC 只读视觉矩阵和必要业务回归;输出脱敏截图和 summary,并回填本 CSV 状态。","相关实施项的 Test_Method 均执行通过;OAFRONT3 矩阵 P1 清零,P2 有明确剩余解释;报告无密码、token、Secret、完整邮箱/手机号、完整本地路径、票据原图、合同正文、银行流水或原始 payload。","受影响流程:PC 修复实现 -> 前端构建 -> 设计规则 -> 公网只读视觉矩阵 -> Issue 状态回填;验证步骤:cd client && npm run typecheck && npm run build;node scripts/qa/check-ui-design-rules.mjs --json;bash scripts/qa/run-oafront3-pc-readonly-visual.sh;git diff --check;必要时跑 bash scripts/check-minimal.sh。","npm|node|playwright|bash|git","DOING","TODO","TODO","client/tests/e2e/oafront3-pc-readonly-visual.spec.ts;scripts/qa/run-oafront3-pc-readonly-visual.sh;artifacts/qa-flow","[""OAFRONT4-2"",""OAFRONT4-3"",""OAFRONT4-4"",""OAFRONT4-5"",""OAFRONT4-6""]","已完成本地 typecheck、build、check-ui-design-rules 和 check-minimal;build 无 Vite circular chunk warning。已跑公网只读 baseline:artifacts/qa-flow/oafront3-pc-readonly-visual-20260525-074424/summary.json 为 PASS_WITH_FINDINGS,但它验证的是尚未部署的现网旧版本,Regression 待部署后复跑关闭。"
"OAFRONT4-5","资产台账 PC 首屏与字段密度治理","结合 ASSET1D 试录和 OAFRONT3 截图,调整资产台账首屏主次:建卡、筛选、列表、详情优先,分类维护降级;清理资产状态码、长内部流水、日期英文占位和过密扩展字段展示。","管理员能清楚完成 FA/EA-SYS/EA-REPO/EA-BKP 建卡和查看;资产列表主列不显示内部长流水为主标识;电子资产扩展 JSON 不作为普通主展示,改为摘要/折叠/调试区;日期字段中文化。","受影响流程:管理员资产建卡 -> 筛选 -> 查看详情 -> 领用/归还/盘点入口;普通员工查看自己相关资产;验证步骤:client typecheck/build;OAFRONT3 矩阵 admin /assets finding 下降;ASSET1D 手工试录路径不回归。","typescript|playwright|manual","DONE","DONE","TODO","client/src/pages/AssetRegistryPage.tsx;client/src/api/assets.ts","[""OAFRONT4-1""]","已完成本地实现:资产日期输入中文化,ERP 更新时间中文格式化;继续保持不录入账号密码、token、Secret、源码包或完整路径。Regression 待部署后公网 OAFRONT3 矩阵复跑关闭。"
"OAFRONT4-6","票据/采购/项目 PC 长内部流水与日期控件清理","处理 OAFRONT3 中票据中心、采购申请和项目页的长内部流水、日期英文占位和空态弱提示;不改变 OCR、采购审批、项目关联或后端接口语义。","票据、采购、项目列表/详情主展示使用业务编号、摘要、申请人、金额/日期和中文状态;长内部流水不作为主文案;日期输入和展示中文化;空态给出下一步动作且不重复大段解释。","受影响流程:员工查看票据中心 -> 采购申请列表/详情 -> 主管查看项目列表;验证步骤:client typecheck/build;OAFRONT3 矩阵 /invoice-pool、/purchase-requests、/projects 相关 P2 finding 下降;真实 OCR/采购流程回归不变。","typescript|playwright|manual","DONE","DONE","TODO","client/src/pages/InvoicePoolPage.tsx;client/src/pages/invoice-pool/*;client/src/pages/PurchaseRequestsPage.tsx;client/src/pages/ProjectsPage.tsx;client/src/pages/ProjectDetailPage.tsx","[""OAFRONT4-1""]","已完成本地实现:采购和项目日期输入中文化,采购附件/归档/ERP 时间中文格式化,项目列表日期中文化,项目详情角色/里程碑/任务/风险状态中文化;未改变 OCR、采购审批或项目接口语义。Regression 待部署后公网 OAFRONT3 矩阵复跑关闭。"
"OAFRONT4-7","PC 日期与状态展示小组件化","若 OAFRONT4-2/4/5/6 出现重复实现,抽出轻量前端 helper 或小组件统一日期展示、中文状态标签和内部 ID 折叠规则;避免每页手写不同 label。","存在可复用的 status/date/display helper;付款、合同、资产、票据/采购至少两个页面复用;不引入新 UI 框架;C 类后台仍可显示必要内部码值但 A/B 类默认隐藏。","受影响流程:PC A/B 类页面展示业务对象 -> 状态/日期/内部 ID 一致;验证步骤:client typecheck/build;check-ui-design-rules;OAFRONT3 矩阵 P1/P2 下降;无 circular chunk warning。","typescript|node|playwright","DONE","DONE","TODO","client/src/lib/*;client/src/components/common/*;client/src/pages/PaymentBatchesPage.tsx;client/src/pages/ContractsRegistryPage.tsx;client/src/pages/AssetRegistryPage.tsx","[""OAFRONT4-2"",""OAFRONT4-4"",""OAFRONT4-5""]","已新增 client/src/lib/displayFormat.ts 与 DateTextInput,付款、合同、资产、采购、项目页已复用日期展示/输入;StatusBadge 补充 reviewed/executed。Regression 待部署后公网 OAFRONT3 矩阵复跑关闭。"
"OAFRONT4-8","PC 修复后自动回归与截图证据","在完成任一实施批次后,执行前端构建、设计规则、PC 只读视觉矩阵和必要业务回归;输出脱敏截图和 summary,并回填本 CSV 状态。","相关实施项的 Test_Method 均执行通过;OAFRONT3 矩阵 P1 清零,P2 有明确剩余解释;报告无密码、token、Secret、完整邮箱/手机号、完整本地路径、票据原图、合同正文、银行流水或原始 payload。","受影响流程:PC 修复实现 -> 前端构建 -> 设计规则 -> 公网只读视觉矩阵 -> Issue 状态回填;验证步骤:cd client && npm run typecheck && npm run build;node scripts/qa/check-ui-design-rules.mjs --json;bash scripts/qa/run-oafront3-pc-readonly-visual.sh;git diff --check;必要时跑 bash scripts/check-minimal.sh。","npm|node|playwright|bash|git","DOING","TODO","TODO","client/tests/e2e/oafront3-pc-readonly-visual.spec.ts;scripts/qa/run-oafront3-pc-readonly-visual.sh;artifacts/qa-flow","[""OAFRONT4-2"",""OAFRONT4-3"",""OAFRONT4-4"",""OAFRONT4-5"",""OAFRONT4-6""]","已完成本地 typecheck、build、check-ui-design-rules 和 check-minimal;build 无 Vite circular chunk warning。已跑公网只读 baseline:artifacts/qa-flow/oafront3-pc-readonly-visual-20260525-074424/summary.json 为 PASS_WITH_FINDINGS,但它验证的是尚未部署的现网旧版本;第二批本地 build 同样通过。Regression 待部署后复跑关闭。回滚路径:只读公网矩阵不写业务数据、不需清理;若部署后 UI 回归,回滚 OAFRONT4 前端提交并复跑矩阵。"
"OAFRONT4-9","用户采纳与实施顺序门禁","把本合同提交给用户审查,明确先做哪组、哪些后置;未被采纳的 UI 改动不得进入实现。","用户对实施顺序给出 A/B/C 选择并回填 Notes;采纳后才进入代码修改;若选择暂停,则保留本合同为建议证据,不改页面。","受影响流程:PC 证据 -> 用户决策 -> 采纳项进入实现 -> 未采纳项后置;验证步骤:人工问答 A=按推荐顺序先修付款批次+Portal本地化+合同资产;B=只修 P1 付款批次,其他等 7 天反馈;C=暂停 PC UI 修复,继续真实使用稳定化;回填选择并 validate CSV。","manual|python3","DONE","DONE","DONE","issues/2026-05-25_00-55-14-oa-pc-launch-ui-cleanup-v1.csv","[""OAFRONT4-0""]","用户已批准开始实施,按默认推荐 A 先执行第一批 OAFRONT4-2+3+4;第二批 OAFRONT4-5+6 后置,Portal hero/视觉 token/大页面拆分继续后置。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
4 OAFRONT4-2 付款批次 PC 主展示去内部化 修复付款批次页面在 finance/finance_mgr 视角暴露内部 UUID、英文状态 EXECUTED/DRAFT、日期英文占位和长内部流水的问题;保持付款批次真实执行仍走线下银行,不新增真实支付能力。 付款批次列表和详情主展示使用付款批次号、实体业务编号、中文状态、中文日期展示;内部 UUID 只允许管理员审计/调试折叠区可见;不改变付款批次创建、上传回单、执行、取消、复核逻辑。 受影响流程:财务登录 -> 打开付款批次 -> 查看批次列表/详情/批次项 -> 上传回单入口可见但不自动上传 -> 不执行真实付款;验证步骤:cd client && npm run typecheck && npm run build;bash scripts/qa/run-oafront3-pc-readonly-visual.sh;finance/finance_mgr /payment-batches 不再有 P1 UUID 主展示,英文状态和日期占位显著下降。 typescript|playwright|manual DONE DONE TODO client/src/pages/PaymentBatchesPage.tsx;client/src/api/paymentBatches.ts;client/src/lib/formatters.ts ["OAFRONT4-1"] 已完成本地实现:付款批次主文案、状态、日期、渠道和批次项状态中文化;未改后端状态机,未执行真实付款。Regression 待公网 OAFRONT3 只读矩阵复跑后关闭。
5 OAFRONT4-3 PC 导航 Portal 本地化最小修复 将 PC 主导航和页面主展示中的 Portal 英文称呼收口为“工作台”,同时避免把 smoke 账号英文角色名作为普通用户首页主称呼;不重排 Portal 首屏 hero。 左侧导航、面包屑/标题、可见主入口不再以 Portal 作为普通用户主展示;测试账号在 UI 中优先显示中文姓名或中文职能名;管理员排障页可保留 role code。 受影响流程:员工/主管/财务/管理员登录 PC -> 左侧导航 -> 工作台 -> 顶栏账号展示;验证步骤:rg -n 'Portal' client/src;cd client && npm run typecheck && npm run build;OAFRONT3 PC 矩阵中 A/B 页面 Portal 英文主展示 finding 清零或仅剩 C 类后台例外。 typescript|playwright|rg DONE DONE TODO client/src/components/navigation/NavLinks.tsx;client/src/features/navigation/schema.ts;client/src/pages/PortalHomePage.tsx;server/scripts|server/src/seeds ["OAFRONT4-1"] 已完成本地实现:主导航、系统导航配置、工作区设置、功能开关、周报提示和工作台加载文案收口为“工作台”;类型名和 Radix Portal 组件名保留。Regression 待公网 OAFRONT3 只读矩阵复跑后关闭。
6 OAFRONT4-4 合同台账 PC 表单与状态中文化收口 修复合同台账 active/draft/archived 英文状态、日期控件英文占位、输入裁切和过长表单首屏压力;维持合同正文不上传 OA 的边界。 合同状态、操作和筛选展示中文;日期字段提供中文标签/格式提示或自定义展示;疑似裁切输入修复;新建合同表单保留必要字段,归档/续约/敏感字段可折叠或分区。 受影响流程:管理员录入合同摘要 -> 设置状态/日期/责任人/归档位置 -> 列表筛选 -> 查看/编辑 -> 权限负例普通员工不可见敏感入口;验证步骤:client typecheck/build;OAFRONT3 矩阵 admin /contracts-registry 英文状态、日期占位、裁切 finding 下降;手工确认不上传合同正文。 typescript|playwright|manual DONE DONE TODO client/src/pages/ContractsRegistryPage.tsx;client/src/api/contractsRegistry.ts ["OAFRONT4-1"] 已完成本地实现:合同编辑状态、过期提示、列表状态、日期展示和归档动作中文化;未新增合同正文上传或电子签章能力。Regression 待公网 OAFRONT3 只读矩阵复跑后关闭。
7 OAFRONT4-5 资产台账 PC 首屏与字段密度治理 结合 ASSET1D 试录和 OAFRONT3 截图,调整资产台账首屏主次:建卡、筛选、列表、详情优先,分类维护降级;清理资产状态码、长内部流水、日期英文占位和过密扩展字段展示。 管理员能清楚完成 FA/EA-SYS/EA-REPO/EA-BKP 建卡和查看;资产列表主列不显示内部长流水为主标识;电子资产扩展 JSON 不作为普通主展示,改为摘要/折叠/调试区;日期字段中文化。 受影响流程:管理员资产建卡 -> 筛选 -> 查看详情 -> 领用/归还/盘点入口;普通员工查看自己相关资产;验证步骤:client typecheck/build;OAFRONT3 矩阵 admin /assets finding 下降;ASSET1D 手工试录路径不回归。 typescript|playwright|manual TODO DONE TODO DONE TODO client/src/pages/AssetRegistryPage.tsx;client/src/api/assets.ts ["OAFRONT4-1"] 优先级 P1。不得把 EA-ACC 密码/token/Secret 纳入 OA;不上传源码包、合同正文、银行流水或完整本地路径。 已完成本地实现:资产日期输入中文化,ERP 更新时间中文格式化;继续保持不录入账号密码、token、Secret、源码包或完整路径。Regression 待部署后公网 OAFRONT3 矩阵复跑关闭。
8 OAFRONT4-6 票据/采购/项目 PC 长内部流水与日期控件清理 处理 OAFRONT3 中票据中心、采购申请和项目页的长内部流水、日期英文占位和空态弱提示;不改变 OCR、采购审批、项目关联或后端接口语义。 票据、采购、项目列表/详情主展示使用业务编号、摘要、申请人、金额/日期和中文状态;长内部流水不作为主文案;日期输入和展示中文化;空态给出下一步动作且不重复大段解释。 受影响流程:员工查看票据中心 -> 采购申请列表/详情 -> 主管查看项目列表;验证步骤:client typecheck/build;OAFRONT3 矩阵 /invoice-pool、/purchase-requests、/projects 相关 P2 finding 下降;真实 OCR/采购流程回归不变。 typescript|playwright|manual TODO DONE TODO DONE TODO client/src/pages/InvoicePoolPage.tsx;client/src/pages/invoice-pool/*;client/src/pages/PurchaseRequestsPage.tsx;client/src/pages/ProjectsPage.tsx;client/src/pages/ProjectDetailPage.tsx ["OAFRONT4-1"] 优先级 P1/P2。票据隐私边界不变:不在报告复述完整发票号、票面原图、供应商敏感细节。 已完成本地实现:采购和项目日期输入中文化,采购附件/归档/ERP 时间中文格式化,项目列表日期中文化,项目详情角色/里程碑/任务/风险状态中文化;未改变 OCR、采购审批或项目接口语义。Regression 待部署后公网 OAFRONT3 矩阵复跑关闭。
9 OAFRONT4-7 PC 日期与状态展示小组件化 若 OAFRONT4-2/4/5/6 出现重复实现,抽出轻量前端 helper 或小组件统一日期展示、中文状态标签和内部 ID 折叠规则;避免每页手写不同 label。 存在可复用的 status/date/display helper;付款、合同、资产、票据/采购至少两个页面复用;不引入新 UI 框架;C 类后台仍可显示必要内部码值但 A/B 类默认隐藏。 受影响流程:PC A/B 类页面展示业务对象 -> 状态/日期/内部 ID 一致;验证步骤:client typecheck/build;check-ui-design-rules;OAFRONT3 矩阵 P1/P2 下降;无 circular chunk warning。 typescript|node|playwright DONE DONE TODO client/src/lib/*;client/src/components/common/*;client/src/pages/PaymentBatchesPage.tsx;client/src/pages/ContractsRegistryPage.tsx;client/src/pages/AssetRegistryPage.tsx ["OAFRONT4-2","OAFRONT4-4","OAFRONT4-5"] 已新增 client/src/lib/displayFormat.ts,付款批次和合同台账已复用日期展示 helper;StatusBadge 补充 reviewed/executed。Regression 待公网 OAFRONT3 只读矩阵复跑后关闭。 已新增 client/src/lib/displayFormat.ts 与 DateTextInput,付款、合同、资产、采购、项目页已复用日期展示/输入;StatusBadge 补充 reviewed/executed。Regression 待部署后公网 OAFRONT3 矩阵复跑关闭。
10 OAFRONT4-8 PC 修复后自动回归与截图证据 在完成任一实施批次后,执行前端构建、设计规则、PC 只读视觉矩阵和必要业务回归;输出脱敏截图和 summary,并回填本 CSV 状态。 相关实施项的 Test_Method 均执行通过;OAFRONT3 矩阵 P1 清零,P2 有明确剩余解释;报告无密码、token、Secret、完整邮箱/手机号、完整本地路径、票据原图、合同正文、银行流水或原始 payload。 受影响流程:PC 修复实现 -> 前端构建 -> 设计规则 -> 公网只读视觉矩阵 -> Issue 状态回填;验证步骤:cd client && npm run typecheck && npm run build;node scripts/qa/check-ui-design-rules.mjs --json;bash scripts/qa/run-oafront3-pc-readonly-visual.sh;git diff --check;必要时跑 bash scripts/check-minimal.sh。 npm|node|playwright|bash|git DOING TODO TODO client/tests/e2e/oafront3-pc-readonly-visual.spec.ts;scripts/qa/run-oafront3-pc-readonly-visual.sh;artifacts/qa-flow ["OAFRONT4-2","OAFRONT4-3","OAFRONT4-4","OAFRONT4-5","OAFRONT4-6"] 已完成本地 typecheck、build、check-ui-design-rules 和 check-minimal;build 无 Vite circular chunk warning。已跑公网只读 baseline:artifacts/qa-flow/oafront3-pc-readonly-visual-20260525-074424/summary.json 为 PASS_WITH_FINDINGS,但它验证的是尚未部署的现网旧版本,Regression 待部署后复跑关闭。 已完成本地 typecheck、build、check-ui-design-rules 和 check-minimal;build 无 Vite circular chunk warning。已跑公网只读 baseline:artifacts/qa-flow/oafront3-pc-readonly-visual-20260525-074424/summary.json 为 PASS_WITH_FINDINGS,但它验证的是尚未部署的现网旧版本;第二批本地 build 同样通过。Regression 待部署后复跑关闭。回滚路径:只读公网矩阵不写业务数据、不需清理;若部署后 UI 回归,回滚 OAFRONT4 前端提交并复跑矩阵。
11 OAFRONT4-9 用户采纳与实施顺序门禁 把本合同提交给用户审查,明确先做哪组、哪些后置;未被采纳的 UI 改动不得进入实现。 用户对实施顺序给出 A/B/C 选择并回填 Notes;采纳后才进入代码修改;若选择暂停,则保留本合同为建议证据,不改页面。 受影响流程:PC 证据 -> 用户决策 -> 采纳项进入实现 -> 未采纳项后置;验证步骤:人工问答 A=按推荐顺序先修付款批次+Portal本地化+合同资产;B=只修 P1 付款批次,其他等 7 天反馈;C=暂停 PC UI 修复,继续真实使用稳定化;回填选择并 validate CSV。 manual|python3 DONE DONE DONE issues/2026-05-25_00-55-14-oa-pc-launch-ui-cleanup-v1.csv ["OAFRONT4-0"] 用户已批准开始实施,按默认推荐 A 先执行第一批 OAFRONT4-2+3+4;第二批 OAFRONT4-5+6 后置,Portal hero/视觉 token/大页面拆分继续后置。