feat: unify channel and sales product save/publish UX

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-23 11:25:35 +08:00
parent 0a2ff6993d
commit d02e580d91
5 changed files with 1444 additions and 602 deletions

View File

@ -3444,6 +3444,8 @@ export default {
'channel.salesProducts.tabs.records': 'Publish records', 'channel.salesProducts.tabs.records': 'Publish records',
'channel.salesProducts.actions.save': 'Save', 'channel.salesProducts.actions.save': 'Save',
'channel.salesProducts.actions.saveDraft': 'Save draft',
'channel.salesProducts.actions.saveAndPublish': 'Save & publish',
'channel.salesProducts.actions.add': 'Add', 'channel.salesProducts.actions.add': 'Add',
'channel.salesProducts.actions.edit': 'Edit', 'channel.salesProducts.actions.edit': 'Edit',
'channel.salesProducts.actions.delete': 'Delete', 'channel.salesProducts.actions.delete': 'Delete',
@ -3462,6 +3464,38 @@ export default {
'channel.salesProducts.customerGuide.selectProduct': 'Select a sales product on the left first', 'channel.salesProducts.customerGuide.selectProduct': 'Select a sales product on the left first',
'channel.salesProducts.price.effectiveVersion': 'Effective version: {version}', 'channel.salesProducts.price.effectiveVersion': 'Effective version: {version}',
'channel.salesProducts.empty.selectProduct': 'Select a product from the left',
'channel.salesProducts.context.product': 'Product',
'channel.salesProducts.context.group': 'Group',
'channel.salesProducts.context.unsaved': 'Unsaved',
'channel.salesProducts.unsaved.title': 'Unsaved changes',
'channel.salesProducts.unsaved.message': 'This tab has unsaved changes. Switching will discard them. Continue?',
'channel.salesProducts.unsaved.discard': 'Discard',
'channel.salesProducts.unsaved.stay': 'Stay',
'channel.salesProducts.saveConfirm.title': 'Save confirmation',
'channel.salesProducts.saveConfirm.message': 'After saving, the publish preview opens. Publish only after you confirm the diff.',
'channel.salesProducts.saveConfirm.cancel': 'Cancel',
'channel.salesProducts.messages.saveDraftSuccess': 'Draft saved (not published)',
'channel.salesProducts.messages.saveFailed': 'Save failed',
'channel.salesProducts.messages.toggleDisableTitle': 'Confirm disable',
'channel.salesProducts.messages.toggleDisableConfirm': 'This product will not be available for new orders. Disable "{name}"?',
'channel.salesProducts.publish.title': 'Publish confirmation',
'channel.salesProducts.publish.status.synced': 'Content matches the live cache; no republish needed',
'channel.salesProducts.publish.status.changed': '{count} change(s) detected; confirm to update the live cache',
'channel.salesProducts.publish.status.first': 'Not published yet; confirm to write to the live cache',
'channel.salesProducts.publish.onlineCache': 'Live cache',
'channel.salesProducts.publish.pendingCache': 'Pending publish',
'channel.salesProducts.publish.diffCount': 'Diff count: {count}',
'channel.salesProducts.publish.showOnlyDiff': 'Show diff only',
'channel.salesProducts.publish.confirm': 'Publish',
'channel.salesProducts.publish.noNeed': 'No publish needed',
'channel.salesProducts.publish.noDiffDetail': 'Matches the latest published version',
'channel.salesProducts.publish.legend.add': 'Added',
'channel.salesProducts.publish.legend.remove': 'Removed',
'channel.salesProducts.publish.legend.change': 'Changed',
'channel.salesProducts.publish.legend.same': 'Unchanged',
'channel.salesProducts.publish.badge.pending': 'Pending',
'channel.salesProducts.publish.badge.synced': 'Synced',
'channel.salesProducts.rules.schemeTitle': 'Shipping scheme', 'channel.salesProducts.rules.schemeTitle': 'Shipping scheme',
'channel.salesProducts.rules.scheme.costLowest': 'Lowest cost', 'channel.salesProducts.rules.scheme.costLowest': 'Lowest cost',
@ -3554,6 +3588,8 @@ export default {
'channel.serviceChannels.tabs.price': 'Channel pricing', 'channel.serviceChannels.tabs.price': 'Channel pricing',
'channel.serviceChannels.tabs.record': 'Publish records', 'channel.serviceChannels.tabs.record': 'Publish records',
'channel.serviceChannels.actions.save': 'Save', 'channel.serviceChannels.actions.save': 'Save',
'channel.serviceChannels.actions.saveDraft': 'Save draft',
'channel.serviceChannels.actions.saveAndPublish': 'Save & publish',
'channel.serviceChannels.actions.add': 'Add', 'channel.serviceChannels.actions.add': 'Add',
'channel.serviceChannels.actions.edit': 'Edit', 'channel.serviceChannels.actions.edit': 'Edit',
'channel.serviceChannels.actions.delete': 'Delete', 'channel.serviceChannels.actions.delete': 'Delete',
@ -3612,6 +3648,43 @@ export default {
'channel.serviceChannels.drawer.footer.generatePriceList': 'Generate price table', 'channel.serviceChannels.drawer.footer.generatePriceList': 'Generate price table',
'channel.serviceChannels.drawer.footer.save': 'Save', 'channel.serviceChannels.drawer.footer.save': 'Save',
'channel.serviceChannels.createTime': 'Created: {time}', 'channel.serviceChannels.createTime': 'Created: {time}',
'channel.serviceChannels.empty.selectChannel': 'Select a channel from the left',
'channel.serviceChannels.context.channel': 'Channel',
'channel.serviceChannels.context.group': 'Provider group',
'channel.serviceChannels.context.app': 'Application',
'channel.serviceChannels.context.apiProvider': 'API provider',
'channel.serviceChannels.context.unsaved': 'Unsaved',
'channel.serviceChannels.saveConfirm.title': 'Save confirmation',
'channel.serviceChannels.saveConfirm.message': 'Save only keeps a draft; save & publish pushes channel rules to the live cache.',
'channel.serviceChannels.saveConfirm.saveOnly': 'Save only',
'channel.serviceChannels.saveConfirm.saveAndPublish': 'Save & publish',
'channel.serviceChannels.saveConfirm.cancel': 'Cancel',
'channel.serviceChannels.unsaved.title': 'Unsaved changes',
'channel.serviceChannels.unsaved.message': 'Channel config has unsaved changes. Switching will discard them. Continue?',
'channel.serviceChannels.unsaved.discard': 'Discard',
'channel.serviceChannels.unsaved.stay': 'Stay',
'channel.serviceChannels.messages.saveDraftSuccess': 'Draft saved (not published)',
'channel.serviceChannels.messages.saveAndPublishSuccess': 'Saved and published',
'channel.serviceChannels.messages.publishFailedAfterSave': 'Config saved, but publish failed: {msg}',
'channel.serviceChannels.messages.toggleDisableConfirm': 'This channel will not be available for new orders. Disable "{name}"?',
'channel.serviceChannels.messages.toggleDisableTitle': 'Confirm disable',
'channel.serviceChannels.publish.title': 'Confirm price publish',
'channel.serviceChannels.publish.status.synced': 'Pricing matches the live cache; no republish needed',
'channel.serviceChannels.publish.status.changed': '{count} change(s) detected; confirm to update the live cache',
'channel.serviceChannels.publish.status.first': 'No published pricing yet; confirm to write to the live cache',
'channel.serviceChannels.publish.onlineCache': 'Live cache',
'channel.serviceChannels.publish.pendingCache': 'Pending publish',
'channel.serviceChannels.publish.diffCount': 'Diff count: {count}',
'channel.serviceChannels.publish.showOnlyDiff': 'Show diff only',
'channel.serviceChannels.publish.confirm': 'Publish',
'channel.serviceChannels.publish.noNeed': 'No publish needed',
'channel.serviceChannels.publish.noDiffDetail': 'Pricing matches the latest published version',
'channel.serviceChannels.publish.legend.add': 'Added',
'channel.serviceChannels.publish.legend.remove': 'Removed',
'channel.serviceChannels.publish.legend.change': 'Changed',
'channel.serviceChannels.publish.legend.same': 'Unchanged',
'channel.serviceChannels.publish.badge.pending': 'Pending',
'channel.serviceChannels.publish.badge.synced': 'Synced',
// Channel - Blacklist/Remote Zones (shared) // Channel - Blacklist/Remote Zones (shared)
'channel.area.listTitle': 'List', 'channel.area.listTitle': 'List',

View File

@ -3464,6 +3464,8 @@ export default {
'channel.salesProducts.tabs.records': '发布记录', 'channel.salesProducts.tabs.records': '发布记录',
'channel.salesProducts.actions.save': '保存', 'channel.salesProducts.actions.save': '保存',
'channel.salesProducts.actions.saveDraft': '保存草稿',
'channel.salesProducts.actions.saveAndPublish': '保存并发布',
'channel.salesProducts.actions.add': '新增', 'channel.salesProducts.actions.add': '新增',
'channel.salesProducts.actions.edit': '编辑', 'channel.salesProducts.actions.edit': '编辑',
'channel.salesProducts.actions.delete': '删除', 'channel.salesProducts.actions.delete': '删除',
@ -3482,6 +3484,38 @@ export default {
'channel.salesProducts.customerGuide.selectProduct': '请先在左侧选择销售产品', 'channel.salesProducts.customerGuide.selectProduct': '请先在左侧选择销售产品',
'channel.salesProducts.price.effectiveVersion': '生效版本: {version}', 'channel.salesProducts.price.effectiveVersion': '生效版本: {version}',
'channel.salesProducts.empty.selectProduct': '请从左侧选择销售产品',
'channel.salesProducts.context.product': '当前产品',
'channel.salesProducts.context.group': '产品分组',
'channel.salesProducts.context.unsaved': '未保存',
'channel.salesProducts.unsaved.title': '未保存的更改',
'channel.salesProducts.unsaved.message': '当前 Tab 有未保存的修改,切换后将丢失。是否继续?',
'channel.salesProducts.unsaved.discard': '放弃修改',
'channel.salesProducts.unsaved.stay': '留在此页',
'channel.salesProducts.saveConfirm.title': '保存确认',
'channel.salesProducts.saveConfirm.message': '保存后将打开发布预览,确认差异后才会发布到线上缓存。',
'channel.salesProducts.saveConfirm.cancel': '取消',
'channel.salesProducts.messages.saveDraftSuccess': '草稿已保存(未发布)',
'channel.salesProducts.messages.saveFailed': '保存失败',
'channel.salesProducts.messages.toggleDisableTitle': '确认禁用',
'channel.salesProducts.messages.toggleDisableConfirm': '禁用后该产品将无法用于新订单,是否确认禁用「{name}」?',
'channel.salesProducts.publish.title': '发布确认',
'channel.salesProducts.publish.status.synced': '当前内容与线上一致,无需重复发布',
'channel.salesProducts.publish.status.changed': '检测到 {count} 处变更,确认后将更新线上缓存',
'channel.salesProducts.publish.status.first': '尚未发布过,确认后将写入线上缓存',
'channel.salesProducts.publish.onlineCache': '线上缓存',
'channel.salesProducts.publish.pendingCache': '待发布内容',
'channel.salesProducts.publish.diffCount': '差异数:{count}',
'channel.salesProducts.publish.showOnlyDiff': '只看差异',
'channel.salesProducts.publish.confirm': '确认发布',
'channel.salesProducts.publish.noNeed': '无需发布',
'channel.salesProducts.publish.noDiffDetail': '与最新发布版本一致',
'channel.salesProducts.publish.legend.add': '新增',
'channel.salesProducts.publish.legend.remove': '删除',
'channel.salesProducts.publish.legend.change': '修改',
'channel.salesProducts.publish.legend.same': '不变',
'channel.salesProducts.publish.badge.pending': '待发布',
'channel.salesProducts.publish.badge.synced': '已同步',
'channel.salesProducts.rules.schemeTitle': '排货方案', 'channel.salesProducts.rules.schemeTitle': '排货方案',
'channel.salesProducts.rules.scheme.costLowest': '成本最低', 'channel.salesProducts.rules.scheme.costLowest': '成本最低',
@ -3573,6 +3607,8 @@ export default {
'channel.serviceChannels.tabs.price': '渠道买价', 'channel.serviceChannels.tabs.price': '渠道买价',
'channel.serviceChannels.tabs.record': '发布记录', 'channel.serviceChannels.tabs.record': '发布记录',
'channel.serviceChannels.actions.save': '保存', 'channel.serviceChannels.actions.save': '保存',
'channel.serviceChannels.actions.saveDraft': '保存草稿',
'channel.serviceChannels.actions.saveAndPublish': '保存并发布',
'channel.serviceChannels.actions.add': '新增', 'channel.serviceChannels.actions.add': '新增',
'channel.serviceChannels.actions.edit': '编辑', 'channel.serviceChannels.actions.edit': '编辑',
'channel.serviceChannels.actions.delete': '删除', 'channel.serviceChannels.actions.delete': '删除',
@ -3633,6 +3669,43 @@ export default {
'channel.serviceChannels.drawer.footer.generatePriceList': '生成价格表', 'channel.serviceChannels.drawer.footer.generatePriceList': '生成价格表',
'channel.serviceChannels.drawer.footer.save': '保存', 'channel.serviceChannels.drawer.footer.save': '保存',
'channel.serviceChannels.createTime': '创建: {time}', 'channel.serviceChannels.createTime': '创建: {time}',
'channel.serviceChannels.empty.selectChannel': '请从左侧选择渠道',
'channel.serviceChannels.context.channel': '当前渠道',
'channel.serviceChannels.context.group': '服务商分组',
'channel.serviceChannels.context.app': '应用',
'channel.serviceChannels.context.apiProvider': 'API 服务商',
'channel.serviceChannels.context.unsaved': '未保存',
'channel.serviceChannels.saveConfirm.title': '保存确认',
'channel.serviceChannels.saveConfirm.message': '仅保存不会发布到线上缓存;保存并发布会立即发布渠道规则。',
'channel.serviceChannels.saveConfirm.saveOnly': '仅保存',
'channel.serviceChannels.saveConfirm.saveAndPublish': '保存并发布',
'channel.serviceChannels.saveConfirm.cancel': '取消',
'channel.serviceChannels.unsaved.title': '未保存的更改',
'channel.serviceChannels.unsaved.message': '渠道配置有未保存的修改,切换后将丢失。是否继续?',
'channel.serviceChannels.unsaved.discard': '放弃修改',
'channel.serviceChannels.unsaved.stay': '留在此页',
'channel.serviceChannels.messages.saveDraftSuccess': '草稿已保存(未发布)',
'channel.serviceChannels.messages.saveAndPublishSuccess': '保存并发布成功',
'channel.serviceChannels.messages.publishFailedAfterSave': '配置已保存,但发布失败:{msg}',
'channel.serviceChannels.messages.toggleDisableConfirm': '禁用后该渠道将无法用于新订单,是否确认禁用「{name}」?',
'channel.serviceChannels.messages.toggleDisableTitle': '确认禁用',
'channel.serviceChannels.publish.title': '价格发布确认',
'channel.serviceChannels.publish.status.synced': '当前买价与线上一致,无需重复发布',
'channel.serviceChannels.publish.status.changed': '检测到 {count} 处变更,确认后将更新线上缓存',
'channel.serviceChannels.publish.status.first': '尚未发布过买价,确认后将写入线上缓存',
'channel.serviceChannels.publish.onlineCache': '线上缓存',
'channel.serviceChannels.publish.pendingCache': '待发布内容',
'channel.serviceChannels.publish.diffCount': '差异数:{count}',
'channel.serviceChannels.publish.showOnlyDiff': '只看差异',
'channel.serviceChannels.publish.confirm': '确认发布',
'channel.serviceChannels.publish.noNeed': '无需发布',
'channel.serviceChannels.publish.noDiffDetail': '买价配置与最新发布版本一致',
'channel.serviceChannels.publish.legend.add': '新增',
'channel.serviceChannels.publish.legend.remove': '删除',
'channel.serviceChannels.publish.legend.change': '修改',
'channel.serviceChannels.publish.legend.same': '不变',
'channel.serviceChannels.publish.badge.pending': '待发布',
'channel.serviceChannels.publish.badge.synced': '已同步',
// 渠道 - 黑名单区/偏远分区(共用) // 渠道 - 黑名单区/偏远分区(共用)
'channel.area.listTitle': '列表', 'channel.area.listTitle': '列表',

View File

@ -0,0 +1,101 @@
import { diffJson } from 'diff'
/** 统一键名大小写,避免预览(camelCase)与落库(PascalCase)误判差异 */
function canonicalKey(key: string): string {
if (!key) return key
return key.charAt(0).toLowerCase() + key.slice(1)
}
/** 递归规范化快照对象,便于语义对比 */
export function normalizeSnapshotForDiff(input: unknown): unknown {
if (input === null || input === undefined) return input
if (Array.isArray(input)) {
return input.map((item) => normalizeSnapshotForDiff(item))
}
if (typeof input === 'object') {
const record = input as Record<string, unknown>
const keys = Object.keys(record).sort((a, b) =>
canonicalKey(a).localeCompare(canonicalKey(b))
)
const out: Record<string, unknown> = {}
for (const key of keys) {
out[canonicalKey(key)] = normalizeSnapshotForDiff(record[key])
}
return out
}
return input
}
export function parseSnapshotJson(raw: unknown): unknown {
if (raw == null || raw === '') return {}
if (typeof raw === 'string') {
try {
return JSON.parse(raw)
} catch {
return {}
}
}
return raw
}
export function formatSnapshotJson(raw: unknown): string {
try {
const parsed = parseSnapshotJson(raw)
return JSON.stringify(parsed, null, 2)
} catch {
return typeof raw === 'string' ? raw : '{}'
}
}
export type SnapshotDiffRow =
| { type: 'add' | 'remove' | 'same'; text: string }
| { type: 'change'; oldText: string; newText: string }
function flattenDiffParts(diff: ReturnType<typeof diffJson>) {
const flat: Array<{ text: string; status: 'add' | 'remove' | 'same' }> = []
diff.forEach((part) => {
const status = part.added ? 'add' : part.removed ? 'remove' : 'same'
const lines = (part.value || '').split('\n')
lines.forEach((line, idx) => {
if (idx === lines.length - 1 && line === '') return
flat.push({ text: line, status })
})
})
const merged: SnapshotDiffRow[] = []
let i = 0
while (i < flat.length) {
const curr = flat[i]
const next = flat[i + 1]
if (curr?.status === 'remove' && next?.status === 'add') {
merged.push({ type: 'change', oldText: curr.text, newText: next.text })
i += 2
} else if (curr) {
merged.push({ type: curr.status, text: curr.text })
i += 1
} else {
i += 1
}
}
return merged
}
export function computeSnapshotDiff(
originalRaw: unknown,
modifiedRaw: unknown
): { length: number; rows: SnapshotDiffRow[] } {
try {
const original = normalizeSnapshotForDiff(parseSnapshotJson(originalRaw))
const modified = normalizeSnapshotForDiff(parseSnapshotJson(modifiedRaw))
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: [] }
}
}
export function filterDiffRows(rows: SnapshotDiffRow[], showOnlyDiff: boolean) {
if (!showOnlyDiff) return rows
return rows.filter((row) => row.type !== 'same')
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff