项目里程碑初步优化

This commit is contained in:
Cheng Zhou
2026-02-27 09:06:06 +08:00
parent 8f3f717e48
commit fd7e3fc948
47 changed files with 2029 additions and 783 deletions
+1 -5
View File
@@ -37,11 +37,7 @@ export const displayUser = (
opts?: { users?: Record<string, string>; members?: Record<string, string> }
) => {
if (!userId) return displayFallback;
const name =
opts?.users?.[userId] ||
opts?.members?.[userId] ||
// 兼容成员数据的 user_id 为 key 的情况
(opts?.members && Object.values(opts.members).find((_, k) => k === userId));
const name = opts?.users?.[userId] || opts?.members?.[userId];
return (name as string) || displayFallback;
};
+176 -19
View File
@@ -13,11 +13,32 @@ export type SetupModuleLabel = {
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>> = {
projectMilestones: {
id: "ID",
name: "里程碑",
planDate: "计划日期",
startDate: "开始日期",
endDate: "结束日期",
durationDays: "耗时(天)",
owner: "负责人",
remark: "备注",
status: "状态",
@@ -35,6 +56,7 @@ const setupFieldLabelMap: Record<keyof SetupConfigDraft, Record<string, string>>
planDate: "计划日期",
owner: "负责人",
remark: "备注",
status: "状态",
},
siteEnrollmentPlans: {
id: "ID",
@@ -65,46 +87,171 @@ const setupFieldLabelMap: Record<keyof SetupConfigDraft, Record<string, string>>
},
};
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 === "monitoringStrategies") return String(row.strategyType || "").trim();
if (moduleKey === "centerConfirm") return String(row.siteName || row.siteId || "").trim();
return "";
};
const formatReadablePath = (moduleKey: keyof SetupConfigDraft, path: string): string => {
const normalized = path.startsWith(`${moduleKey}.`) ? path.slice(moduleKey.length + 1) : path;
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+)\]$/);
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;
return idx === 0 ? `${label}${rowNo}` : `${rowNo}`;
if (idx === 0) {
return rowIdentity ? `${label} / ${rowIdentity}` : `${label}${rowNo}`;
}
return rowIdentity ? rowIdentity : `${rowNo}`;
}
return setupFieldLabelMap[moduleKey][part] || part;
return setupFieldLabelMap[moduleKey][part] || globalReadableFieldMap[part] || part;
});
return labels.join(" / ");
};
export const serializeDiffValue = (value: unknown): string => {
if (value === null || value === undefined) return "-";
if (typeof value === "string") return value || '""';
if (typeof value === "number" || typeof value === "boolean") return String(value);
return JSON.stringify(value);
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 maxLen = Math.max(localArr.length, serverArr.length);
for (let i = 0; i < maxLen; i += 1) {
collectDiffRows(moduleLabel, localArr[i], serverArr[i], `${path}[${i}]`, rows);
const rowIdentity = getArrayRowIdentity(moduleKey, localArr[i] ?? serverArr[i]);
const indexLabel = rowIdentity ? `${i}|${rowIdentity}` : `${i}`;
collectDiffRows(moduleKey, moduleLabel, localArr[i], serverArr[i], `${path}[${indexLabel}]`, rows);
}
return;
}
@@ -115,7 +262,7 @@ const collectDiffRows = (
const keys = Array.from(new Set([...Object.keys(localObj), ...Object.keys(serverObj)]));
keys.forEach((key) => {
const childPath = path ? `${path}.${key}` : key;
collectDiffRows(moduleLabel, localObj[key], serverObj[key], childPath, rows);
collectDiffRows(moduleKey, moduleLabel, localObj[key], serverObj[key], childPath, rows);
});
return;
}
@@ -142,14 +289,24 @@ export const buildSetupReadableDiffRows = (
if (!localDraft || !serverDraft) return [];
const rows: SetupDiffRow[] = [];
moduleLabels.forEach((item) => {
collectDiffRows(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),
};
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;
});
};