diff --git a/src/utils/publishSnapshotDiff.ts b/src/utils/publishSnapshotDiff.ts index 1c7bc1a..18a10bd 100644 --- a/src/utils/publishSnapshotDiff.ts +++ b/src/utils/publishSnapshotDiff.ts @@ -6,98 +6,196 @@ function canonicalKey(key: string): string { return key.charAt(0).toLowerCase() + key.slice(1) } -const AUDIT_KEYS = new Set([ +/** 对比时可忽略的审计/元数据字段 */ +const NOISE_KEYS = new Set([ 'createtime', 'updatetime', 'createby', 'updateby', 'createtimeutc', 'updatetimeutc', + 'conurrencystamp', + 'isdeleted', + 'extraproperties', + 'deleterid', + 'deletiontime', + 'lastmodificationtime', + 'lastmodifierid', + 'creatorid', ]) +const ARRAY_SORT_KEYS = ['id', 'serialNumber', 'priceId', 'factor', 'name', 'version'] as const + +const DEFAULT_DATE_PREFIXES = ['0001-01-01', '1970-01-01'] + function isDefaultDateString(value: string): boolean { - return /^0001-01-01[ T]00:00:00/.test(value.trim()) + const trimmed = value.trim() + return DEFAULT_DATE_PREFIXES.some((prefix) => trimmed.startsWith(prefix)) +} + +function isDateTimeString(value: string): boolean { + return /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}/.test(value.trim()) +} + +function normalizeDateString(value: string): string { + return value.trim().replace('T', ' ').slice(0, 19) } function normalizeScalar(value: unknown): unknown { - if (typeof value !== 'string') return value - if (isDefaultDateString(value)) return undefined - if (/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}/.test(value)) { - return value.replace('T', ' ').slice(0, 19) + if (value === null || value === undefined) return undefined + if (typeof value === 'string') { + if (value.trim() === '') return undefined + if (isDefaultDateString(value)) return undefined + if (isDateTimeString(value)) return normalizeDateString(value) + return value } + if (typeof value === 'number') { + if (!Number.isFinite(value)) return undefined + if (Number.isInteger(value)) return value + const rounded = Math.round(value * 1_000_000) / 1_000_000 + return Object.is(rounded, -0) ? 0 : rounded + } + if (typeof value === 'boolean') return value return value } +function isPlainObject(value: unknown): value is Record { + return value != null && typeof value === 'object' && !Array.isArray(value) +} + +function detectArraySortKey(items: unknown[]): (typeof ARRAY_SORT_KEYS)[number] | null { + const records = items.filter(isPlainObject) as Record[] + if (records.length === 0) return null + for (const key of ARRAY_SORT_KEYS) { + const canonical = canonicalKey(key) + if ( + records.every((item) => { + const raw = item[key] ?? item[canonical] + return raw !== undefined && raw !== null && raw !== '' + }) + ) { + return key + } + } + return null +} + +function readSortValue(record: Record, key: string): number | string { + const raw = record[key] ?? record[canonicalKey(key)] + if (typeof raw === 'number') return raw + if (typeof raw === 'string') return raw + return 0 +} + function sortArrayItems(items: unknown[]): unknown[] { - if (items.length === 0) return items - const first = items[0] - if (first == null || typeof first !== 'object' || Array.isArray(first)) { - return items - } - const record = first as Record - if ('id' in record) { - return [...items].sort( - (a, b) => Number((a as Record)?.id ?? 0) - Number((b as Record)?.id ?? 0) - ) - } - if ('serialNumber' in record) { - return [...items].sort( - (a, b) => - Number((a as Record)?.serialNumber ?? 0) - - Number((b as Record)?.serialNumber ?? 0) - ) - } - return items + if (items.length <= 1) return items + const sortKey = detectArraySortKey(items) + if (!sortKey) return items + return [...items].sort((a, b) => { + const left = readSortValue(a as Record, sortKey) + const right = readSortValue(b as Record, sortKey) + if (typeof left === 'number' && typeof right === 'number') return left - right + return String(left).localeCompare(String(right)) + }) +} + +function isEmptyContainer(value: unknown): boolean { + if (Array.isArray(value)) return value.length === 0 + if (isPlainObject(value)) return Object.keys(value).length === 0 + return false } /** 递归规范化快照对象,便于语义对比 */ export function normalizeSnapshotForDiff(input: unknown): unknown { + const normalized = normalizeSnapshotNode(input) + if (normalized === undefined || isEmptyContainer(normalized)) return undefined + return normalized +} + +function normalizeSnapshotNode(input: unknown): unknown { if (input === null || input === undefined) return undefined + if (Array.isArray(input)) { const normalized = input - .map((item) => normalizeSnapshotForDiff(item)) + .map((item) => normalizeSnapshotNode(item)) .filter((item) => item !== undefined) return sortArrayItems(normalized) } - if (typeof input === 'object') { - const record = input as Record + + if (isPlainObject(input)) { + const record = input const keys = Object.keys(record).sort((a, b) => canonicalKey(a).localeCompare(canonicalKey(b)) ) const out: Record = {} for (const key of keys) { const ck = canonicalKey(key) - if (AUDIT_KEYS.has(ck)) continue - const val = normalizeSnapshotForDiff(record[key]) - if (val === undefined) continue + if (NOISE_KEYS.has(ck)) continue + const val = normalizeSnapshotNode(record[key]) + if (val === undefined || isEmptyContainer(val)) continue out[ck] = val } - return out + return Object.keys(out).length > 0 ? out : undefined } + return normalizeScalar(input) } -export function parseSnapshotJson(raw: unknown): unknown { +/** 解析快照 JSON,兼容双重编码与 publish record 包装 */ +export function parseSnapshotJson(raw: unknown, depth = 0): unknown { + if (depth > 4) return raw ?? {} if (raw == null || raw === '') return {} + if (typeof raw === 'string') { + const trimmed = raw.trim() + if (!trimmed) return {} try { - return JSON.parse(raw) + return parseSnapshotJson(JSON.parse(trimmed), depth + 1) } catch { return {} } } + + if (isPlainObject(raw)) { + const content = raw.content ?? raw.Content + const hasWrapper = + content != null && + ('serviceChannelId' in raw || + 'ServiceChannelId' in raw || + 'salesProductId' in raw || + 'SalesProductId' in raw || + 'operatorType' in raw || + 'OperatorType' in raw || + 'version' in raw || + 'Version' in raw) + if (hasWrapper) { + return parseSnapshotJson(content, depth + 1) + } + } + return raw } export function formatSnapshotJson(raw: unknown): string { try { const parsed = parseSnapshotJson(raw) - return JSON.stringify(parsed, null, 2) + const normalized = normalizeSnapshotForDiff(parsed) + return JSON.stringify(normalized ?? parsed ?? {}, null, 2) } catch { return typeof raw === 'string' ? raw : '{}' } } +export function snapshotsSemanticallyEqual(originalRaw: unknown, modifiedRaw: unknown): boolean { + try { + const original = normalizeSnapshotForDiff(parseSnapshotJson(originalRaw)) + const modified = normalizeSnapshotForDiff(parseSnapshotJson(modifiedRaw)) + return JSON.stringify(original) === JSON.stringify(modified) + } catch { + return false + } +} + export type SnapshotDiffRow = | { type: 'add' | 'remove' | 'same'; text: string } | { type: 'change'; oldText: string; newText: string } @@ -133,21 +231,22 @@ function flattenDiffParts(diff: ReturnType) { export function computeSnapshotDiff( originalRaw: unknown, modifiedRaw: unknown -): { length: number; rows: SnapshotDiffRow[] } { +): { length: number; rows: SnapshotDiffRow[]; equal: boolean } { try { const original = normalizeSnapshotForDiff(parseSnapshotJson(originalRaw)) const modified = normalizeSnapshotForDiff(parseSnapshotJson(modifiedRaw)) const originalText = JSON.stringify(original) const modifiedText = JSON.stringify(modified) if (originalText === modifiedText) { - return { length: 0, rows: [] } + return { length: 0, rows: [], equal: true } } const diff = diffJson(original, modified) const rows = flattenDiffParts(diff) - const length = diff.filter((item) => item.added || item.removed).length - return { length, rows } - } catch { - return { length: 0, rows: [] } + const length = rows.filter((row) => row.type !== 'same').length + return { length, rows, equal: false } + } catch (error) { + console.warn('[publishSnapshotDiff] compare failed', error) + return { length: -1, rows: [], equal: false } } } diff --git a/src/views/channel/salesProducts/index.vue b/src/views/channel/salesProducts/index.vue index b9ef3cb..cb688ef 100644 --- a/src/views/channel/salesProducts/index.vue +++ b/src/views/channel/salesProducts/index.vue @@ -1025,7 +1025,7 @@ return; } const { length } = computeSnapshotDiff(online, preview); - pricePublishPending.value = length > 0; + pricePublishPending.value = length < 0 ? null : length > 0; } catch { pricePublishPending.value = null; } @@ -1331,6 +1331,7 @@ const publishDiffStatus = computed(() => { if (!publishHasOnlineSnapshot.value) return 'first'; if (publishDiffLength.value === 0) return 'synced'; + if (publishDiffLength.value < 0) return 'changed'; return 'changed'; }); diff --git a/src/views/channel/serviceChannels/index.vue b/src/views/channel/serviceChannels/index.vue index 72d49fa..05cddae 100644 --- a/src/views/channel/serviceChannels/index.vue +++ b/src/views/channel/serviceChannels/index.vue @@ -3109,6 +3109,7 @@ const pricePublishPending = ref(null) const publishDiffStatus = computed(() => { if (!publishHasOnlineSnapshot.value) return 'first' if (publishDiffLength.value === 0) return 'synced' + if (publishDiffLength.value < 0) return 'changed' return 'changed' }) @@ -3135,7 +3136,7 @@ async function refreshPricePublishSyncStatus(serviceChannelId?: number | string) return } const { length } = computeSnapshotDiff(online, preview) - pricePublishPending.value = length > 0 + pricePublishPending.value = length < 0 ? null : length > 0 } catch { pricePublishPending.value = null }