import type { SetupConfigDraft } from "../types/setupConfig"; export type SetupDiffRow = { moduleLabel: string; path: string; changeType: "新增" | "删除" | "修改"; localValue: string; serverValue: string; }; export type SetupModuleLabel = { key: keyof SetupConfigDraft; label: string; }; type ParsedEnrollmentPayload = { cycle?: string; valuesByCycle?: { month?: Record; quarter?: Record; }; }; const statusLabelMap: Record = { ACTIVE: "进行中", CLOSED: "已关闭", DRAFT: "草稿", DONE: "已完成", BLOCKED: "阻塞/延期", TODO: "未开始", PENDING: "待处理", }; const setupFieldLabelMap: Record> = { projectInfo: { code: "项目编号", name: "项目简称", project_full_name: "项目全称", sponsor: "申办方", protocol_no: "方案号", lead_unit: "组长单位", principal_investigator: "主要研究者", main_pm: "主PM", research_analysis: "研究分期", research_product: "研究产品", control_product: "对照产品", indication: "适应症", research_population: "研究人群", research_design: "研究设计", status: "项目状态", plan_start_date: "计划开始日期", plan_end_date: "计划结束日期", planned_site_count: "计划中心数", planned_enrollment_count: "计划入组例数", visit_schedule: "访视计划", }, projectMilestones: { id: "ID", name: "里程碑", planDate: "计划日期", startDate: "开始日期", endDate: "结束日期", durationDays: "耗时(天)", owner: "负责人", remark: "备注", status: "状态", }, enrollmentPlan: { totalTarget: "计划总入组例数", startDate: "计划开始日期", endDate: "计划结束日期", monthlyGoalNote: "月度目标说明", stageBreakdown: "分阶段计划", }, siteMilestones: { id: "ID", milestone: "里程碑", planDate: "计划日期", owner: "负责人", remark: "备注", status: "状态", }, siteEnrollmentPlans: { id: "ID", siteId: "中心ID", siteName: "中心名称", target: "计划例数", startDate: "启动日期", endDate: "完成日期", note: "备注", stageBreakdown: "分阶段计划", }, centerConfirm: { id: "ID", siteId: "中心ID", siteName: "中心名称", confirmer: "确认人", confirmStatus: "确认状态", confirmDate: "确认日期", note: "备注", }, }; const globalReadableFieldMap: Record = { type: "配置类型", valuesByCycle: "分阶段分配明细", month: "按月分配", quarter: "按季度分配", cycle: "统计口径", value: "配置值", }; const getArrayRowIdentity = (moduleKey: keyof SetupConfigDraft, value: unknown): string => { if (!value || typeof value !== "object" || Array.isArray(value)) return ""; const row = value as Record; if (moduleKey === "projectMilestones") return String(row.name || "").trim(); if (moduleKey === "siteMilestones") return String(row.milestone || "").trim(); if (moduleKey === "siteEnrollmentPlans") return String(row.siteName || row.siteId || "").trim(); if (moduleKey === "centerConfirm") return String(row.siteName || row.siteId || "").trim(); return ""; }; type ArrayRowEntry = { key: string; value: unknown; rowIdentity: string; }; const buildArrayRowEntry = (moduleKey: keyof SetupConfigDraft, value: unknown, index: number): ArrayRowEntry => { const rowIdentity = getArrayRowIdentity(moduleKey, value); if (value && typeof value === "object" && !Array.isArray(value)) { const id = String((value as Record).id || "").trim(); if (id) { return { key: `id:${id}`, value, rowIdentity, }; } } if (rowIdentity) { return { key: `identity:${rowIdentity}`, value, rowIdentity, }; } return { key: `index:${index}`, value, rowIdentity: "", }; }; const formatReadablePath = (moduleKey: keyof SetupConfigDraft, path: string): string => { let normalized = path; if (path === moduleKey) { normalized = ""; } else if (path.startsWith(`${moduleKey}.`)) { normalized = path.slice(moduleKey.length + 1); } else if (path.startsWith(`${moduleKey}[`)) { normalized = path.slice(moduleKey.length); if (normalized.startsWith("[")) { normalized = `items${normalized}`; } } if (!normalized || normalized === moduleKey) return "根节点"; const parts = normalized.split("."); const labels = parts.map((part, idx) => { const match = part.match(/^([^\[]+)\[(\d+)(?:\|([^\]]+))?\]$/); if (match) { const fieldKey = match[1]; const rowNo = Number(match[2]) + 1; const rowIdentity = String(match[3] || "").trim(); if (idx === 0 && (fieldKey === String(moduleKey) || fieldKey === "items")) { return rowIdentity || `第${rowNo}行`; } const label = setupFieldLabelMap[moduleKey][fieldKey] || fieldKey; if (idx === 0) { return rowIdentity ? `${label} / ${rowIdentity}` : `${label} 第${rowNo}行`; } return rowIdentity ? rowIdentity : `第${rowNo}行`; } return setupFieldLabelMap[moduleKey][part] || globalReadableFieldMap[part] || part; }); return labels.join(" / "); }; export const serializeDiffValue = (value: unknown): string => { const summarizeEnrollmentValues = (raw: Record | undefined): { count: number; total: number } => { if (!raw || typeof raw !== "object") return { count: 0, total: 0 }; let count = 0; let total = 0; Object.values(raw).forEach((v) => { const n = Number(v); if (!Number.isFinite(n) || n <= 0) return; count += 1; total += Math.floor(n); }); return { count, total }; }; const summarizeStructuredPayload = (raw: unknown): string | null => { try { const parsed = typeof raw === "string" ? (JSON.parse(raw) as Record) : ((raw as Record) || {}); const payloadType = String(parsed?.type || "").trim().toLowerCase(); const enrollmentTypes = new Set(["enrollment_plan_v2", "site_enrollment_plan_v2"]); if (!enrollmentTypes.has(payloadType)) return null; const p = parsed as ParsedEnrollmentPayload; const month = summarizeEnrollmentValues(p.valuesByCycle?.month); const quarter = summarizeEnrollmentValues(p.valuesByCycle?.quarter); const parts = [ `按月:${month.count}期,合计${month.total}人`, `按季度:${quarter.count}期,合计${quarter.total}人`, ]; if (payloadType === "enrollment_plan_v2") { const cycle = String(p.cycle || "").trim(); const cycleLabel = cycle === "quarter" ? "当前口径:季度" : "当前口径:月"; return `分阶段计划(${cycleLabel})${parts.join(";")}`; } return `中心分阶段计划:${parts.join(";")}`; } catch { return null; } }; if (value === null || value === undefined) return "未填写"; if (typeof value === "boolean") return value ? "是" : "否"; if (typeof value === "number") return String(value); if (typeof value === "string") { const text = value.trim(); if (!text) return "未填写"; const statusLabel = statusLabelMap[text.toUpperCase()]; if (statusLabel) return statusLabel; if ((text.startsWith("{") && text.endsWith("}")) || (text.startsWith("[") && text.endsWith("]"))) { const summary = summarizeStructuredPayload(text); if (summary) return summary; try { const parsed = JSON.parse(text); if (Array.isArray(parsed)) return `已配置列表(${parsed.length}项)`; if (parsed && typeof parsed === "object") return "已配置内容"; } catch { // fall through to raw text } } const normalizedText = text.toLowerCase(); if (normalizedText.includes("site_enrollment_plan_v2")) return "中心分阶段计划(已配置)"; if (normalizedText.includes("enrollment_plan_v2")) return "分阶段计划(已配置)"; return text.length > 80 ? `${text.slice(0, 80)}...` : text; } try { const structuredSummary = isPlainObject(value) ? summarizeStructuredPayload(value) : null; if (structuredSummary) return structuredSummary; if (Array.isArray(value)) return `已配置列表(${value.length}项)`; if (value && typeof value === "object") return "已配置内容"; return JSON.stringify(value); } catch { return String(value); } }; const isPlainObject = (value: unknown): value is Record => Boolean(value) && typeof value === "object" && !Array.isArray(value); const collectDiffRows = ( moduleKey: keyof SetupConfigDraft, moduleLabel: string, localValue: unknown, serverValue: unknown, path: string, rows: SetupDiffRow[] ) => { const isStructuredBusinessLeaf = path.endsWith(".stageBreakdown") || path.endsWith(".owner"); if (isStructuredBusinessLeaf) { if (localValue === serverValue) return; const hasLocal = localValue !== undefined; const hasServer = serverValue !== undefined; const changeType: SetupDiffRow["changeType"] = hasLocal && hasServer ? "修改" : hasLocal ? "新增" : "删除"; rows.push({ moduleLabel, path: path || "(root)", changeType, localValue: serializeDiffValue(localValue), serverValue: serializeDiffValue(serverValue), }); return; } if (Array.isArray(localValue) || Array.isArray(serverValue)) { const localArr = Array.isArray(localValue) ? localValue : []; const serverArr = Array.isArray(serverValue) ? serverValue : []; const localEntryMap = new Map(); localArr.forEach((item, index) => { const entry = buildArrayRowEntry(moduleKey, item, index); const list = localEntryMap.get(entry.key); if (list) { list.push(entry); } else { localEntryMap.set(entry.key, [entry]); } }); const consumeLocalEntry = (key: string): ArrayRowEntry | null => { const list = localEntryMap.get(key); if (!list || list.length === 0) return null; const entry = list.shift() || null; if (list.length === 0) localEntryMap.delete(key); return entry; }; let pairIndex = 0; serverArr.forEach((item, index) => { const serverEntry = buildArrayRowEntry(moduleKey, item, index); const localEntry = consumeLocalEntry(serverEntry.key); const rowIdentity = (localEntry?.rowIdentity || serverEntry.rowIdentity || "").trim(); const indexLabel = rowIdentity ? `${pairIndex}|${rowIdentity}` : `${pairIndex}`; collectDiffRows(moduleKey, moduleLabel, localEntry?.value, serverEntry.value, `${path}[${indexLabel}]`, rows); pairIndex += 1; }); localEntryMap.forEach((list) => { list.forEach((localEntry) => { const rowIdentity = localEntry.rowIdentity.trim(); const indexLabel = rowIdentity ? `${pairIndex}|${rowIdentity}` : `${pairIndex}`; collectDiffRows(moduleKey, moduleLabel, localEntry.value, undefined, `${path}[${indexLabel}]`, rows); pairIndex += 1; }); }); if (localArr.length === 0 && serverArr.length === 0) { // Keep behavior explicit for empty array pair to avoid extra recursion. return; } return; } if (isPlainObject(localValue) || isPlainObject(serverValue)) { const localObj = isPlainObject(localValue) ? localValue : {}; const serverObj = isPlainObject(serverValue) ? serverValue : {}; const keys = Array.from(new Set([...Object.keys(localObj), ...Object.keys(serverObj)])); keys.forEach((key) => { const childPath = path ? `${path}.${key}` : key; collectDiffRows(moduleKey, moduleLabel, localObj[key], serverObj[key], childPath, rows); }); return; } if (localValue === serverValue) return; const hasLocal = localValue !== undefined; const hasServer = serverValue !== undefined; const changeType: SetupDiffRow["changeType"] = hasLocal && hasServer ? "修改" : hasLocal ? "新增" : "删除"; rows.push({ moduleLabel, path: path || "(root)", changeType, localValue: serializeDiffValue(localValue), serverValue: serializeDiffValue(serverValue), }); }; export const buildSetupReadableDiffRows = ( localDraft: SetupConfigDraft | null, serverDraft: SetupConfigDraft | null, moduleLabels: SetupModuleLabel[], limit = 300 ): SetupDiffRow[] => { if (!localDraft || !serverDraft) return []; const rows: SetupDiffRow[] = []; moduleLabels.forEach((item) => { collectDiffRows(item.key, item.label, localDraft[item.key], serverDraft[item.key], String(item.key), rows); }); return rows .slice(0, limit) .map((row) => { const moduleKey = moduleLabels.find((item) => item.label === row.moduleLabel)?.key; if (!moduleKey) return row; return { ...row, path: formatReadablePath(moduleKey, row.path), }; }) .filter((row) => { // 屏蔽技术噪音字段,优先展示业务可理解差异 const p = row.path.trim(); if (p === "ID" || p.endsWith(" / ID")) return false; if (p === "中心ID" || p.endsWith(" / 中心ID")) return false; if (p.includes("配置类型") || p.includes("分阶段分配明细")) return false; return true; }); };