Files
ctms/frontend/src/utils/setupDiffRows.ts
T
2026-05-12 10:16:52 +08:00

394 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown>;
quarter?: Record<string, unknown>;
};
};
const statusLabelMap: Record<string, string> = {
ACTIVE: "进行中",
CLOSED: "已关闭",
DRAFT: "草稿",
DONE: "已完成",
BLOCKED: "阻塞/延期",
TODO: "未开始",
PENDING: "待处理",
};
const setupFieldLabelMap: Record<keyof SetupConfigDraft, Record<string, string>> = {
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<string, string> = {
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<string, unknown>;
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<string, unknown>).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<string, unknown> | 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<string, unknown>)
: ((raw as Record<string, unknown>) || {});
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<string, unknown> =>
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<string, ArrayRowEntry[]>();
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;
});
};