394 lines
13 KiB
TypeScript
394 lines
13 KiB
TypeScript
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;
|
||
});
|
||
};
|