diff --git a/src/locale/en-US.ts b/src/locale/en-US.ts index 1b82724..cf6389c 100644 --- a/src/locale/en-US.ts +++ b/src/locale/en-US.ts @@ -3444,6 +3444,8 @@ export default { 'channel.salesProducts.tabs.records': 'Publish records', '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.edit': 'Edit', '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.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.scheme.costLowest': 'Lowest cost', @@ -3554,6 +3588,8 @@ export default { 'channel.serviceChannels.tabs.price': 'Channel pricing', 'channel.serviceChannels.tabs.record': 'Publish records', '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.edit': 'Edit', 'channel.serviceChannels.actions.delete': 'Delete', @@ -3612,6 +3648,43 @@ export default { 'channel.serviceChannels.drawer.footer.generatePriceList': 'Generate price table', 'channel.serviceChannels.drawer.footer.save': 'Save', '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.area.listTitle': 'List', diff --git a/src/locale/zh-CN.ts b/src/locale/zh-CN.ts index fc75063..c85ed22 100644 --- a/src/locale/zh-CN.ts +++ b/src/locale/zh-CN.ts @@ -3464,6 +3464,8 @@ export default { 'channel.salesProducts.tabs.records': '发布记录', 'channel.salesProducts.actions.save': '保存', + 'channel.salesProducts.actions.saveDraft': '保存草稿', + 'channel.salesProducts.actions.saveAndPublish': '保存并发布', 'channel.salesProducts.actions.add': '新增', 'channel.salesProducts.actions.edit': '编辑', 'channel.salesProducts.actions.delete': '删除', @@ -3482,6 +3484,38 @@ export default { 'channel.salesProducts.customerGuide.selectProduct': '请先在左侧选择销售产品', '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.scheme.costLowest': '成本最低', @@ -3573,6 +3607,8 @@ export default { 'channel.serviceChannels.tabs.price': '渠道买价', 'channel.serviceChannels.tabs.record': '发布记录', 'channel.serviceChannels.actions.save': '保存', + 'channel.serviceChannels.actions.saveDraft': '保存草稿', + 'channel.serviceChannels.actions.saveAndPublish': '保存并发布', 'channel.serviceChannels.actions.add': '新增', 'channel.serviceChannels.actions.edit': '编辑', 'channel.serviceChannels.actions.delete': '删除', @@ -3633,6 +3669,43 @@ export default { 'channel.serviceChannels.drawer.footer.generatePriceList': '生成价格表', 'channel.serviceChannels.drawer.footer.save': '保存', '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': '列表', diff --git a/src/utils/publishSnapshotDiff.ts b/src/utils/publishSnapshotDiff.ts new file mode 100644 index 0000000..6e10f31 --- /dev/null +++ b/src/utils/publishSnapshotDiff.ts @@ -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 + const keys = Object.keys(record).sort((a, b) => + canonicalKey(a).localeCompare(canonicalKey(b)) + ) + const out: Record = {} + 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) { + 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') +} diff --git a/src/views/channel/salesProducts/index.vue b/src/views/channel/salesProducts/index.vue index 711049f..b9ef3cb 100644 --- a/src/views/channel/salesProducts/index.vue +++ b/src/views/channel/salesProducts/index.vue @@ -42,7 +42,12 @@ import CustomLeftMenu from '@/components/CustomLeftMenu/index.vue'; import type { CustomLeftMenuTreeNode } from '@/components/CustomLeftMenu/types'; import { useI18n } from 'vue-i18n'; - import { diffJson } from 'diff'; + import { + computeSnapshotDiff, + filterDiffRows, + formatSnapshotJson, + type SnapshotDiffRow, + } from '@/utils/publishSnapshotDiff'; import dayjs from 'dayjs'; import * as XLSX from 'xlsx'; import Sortable from 'sortablejs'; @@ -122,6 +127,88 @@ const customerGuideMarkdown = ref(''); const customerGuideSaving = ref(false); const customerGuidePanelRef = ref | null>(null); + const savingConfig = ref(false); + + type ConfigFormSnapshot = { + productCode: string; + productName: string; + group: string; + minTimeliness: string; + maxTimeliness: string; + customerWaybill: string; + labelTemplateId: number | undefined; + blacklistArea: string; + remoteAreaDivision: string; + priceArea: string; + weightCalculationRule: string; + weightRounding: string; + productDescription: string; + preStorageScript: string; + }; + + const configFormBaseline = ref(null); + + function snapshotConfigForm(): ConfigFormSnapshot { + return { + productCode: formData.productCode, + productName: formData.productName, + group: formData.group, + minTimeliness: formData.minTimeliness, + maxTimeliness: formData.maxTimeliness, + customerWaybill: formData.customerWaybill, + labelTemplateId: formData.labelTemplateId, + blacklistArea: formData.blacklistArea, + remoteAreaDivision: formData.remoteAreaDivision, + priceArea: formData.priceArea, + weightCalculationRule: formData.weightCalculationRule, + weightRounding: formData.weightRounding, + productDescription: formData.productDescription, + preStorageScript: formData.preStorageScript, + }; + } + + function resetConfigFormBaseline() { + configFormBaseline.value = snapshotConfigForm(); + } + + const configFormDirty = computed(() => { + const baseline = configFormBaseline.value; + if (!baseline) return false; + const cur = snapshotConfigForm(); + return (Object.keys(baseline) as Array).some( + (key) => cur[key] !== baseline[key] + ); + }); + + const selectedProductContext = computed(() => { + const pid = selectedProduct.value; + if (pid == null) return null; + const product = productList.value.find((p: any) => p.id === pid); + const groupLabel = + groupOptions.value.find((o) => o.value === formData.group)?.label ?? formData.group; + return { + productName: formData.productName || product?.name || '-', + productCode: formData.productCode || '-', + groupName: groupLabel || '-', + }; + }); + + function confirmDiscardUnsavedChanges(): Promise { + const dirty = + (activeTab.value === 'config' && configFormDirty.value) + || (activeTab.value === 'rules' && rulesFormDirty.value); + if (!dirty) return Promise.resolve(true); + return new Promise((resolve) => { + Modal.confirm({ + title: t('channel.salesProducts.unsaved.title'), + content: t('channel.salesProducts.unsaved.message'), + okText: t('channel.salesProducts.unsaved.discard'), + cancelText: t('channel.salesProducts.unsaved.stay'), + onOk: () => resolve(true), + onCancel: () => resolve(false), + }); + }); + } type IdSelectOption = { label: string; value: string }; @@ -756,13 +843,20 @@ const productExpandedKeys = ref(['category-products']); function handleProductSelectFromTree(keys: string[], info: any) { + void handleProductSelectFromTreeAsync(keys, info); + } + + async function handleProductSelectFromTreeAsync(keys: string[], info: any) { const node = info?.node; if (!node || !node.isLeaf) return; const key = node.key; if (!key.startsWith('item-')) return; const id = Number(key.slice(5)); const product = filteredProductList.value.find((p: any) => p.id === id); - if (product) handleSelectProduct(product); + if (!product) return; + const ok = await confirmDiscardUnsavedChanges(); + if (!ok) return; + await handleSelectProduct(product); } async function refreshSalesProductPriceEffectiveInfo(productId: number) { @@ -805,6 +899,7 @@ modifyTime: it.updateTime || it.createTime, modifier: it.updateBy ?? it.UpdateBy ?? it.createBy ?? it.CreateBy ?? '', })); + void refreshPricePublishSyncStatus(productId); } catch (e) { console.error(e); effectiveVersion.value = '--'; @@ -905,11 +1000,37 @@ weight: p.weight ?? p.Weight ?? 0, customers: p.customerIds ?? p.CustomerIds ?? p.customers ?? p.Customers ?? [], })); + resetRulesFormBaseline(); } catch (e) { console.error(e); } } + async function refreshPricePublishSyncStatus(productId?: number) { + const pid = productId ?? selectedProduct.value; + if (pid == null || !priceTableData.value.length) { + pricePublishPending.value = null; + return; + } + try { + const preview = await getSalesProductPublishPreview({ + salesProductId: pid, + operatorType: 'price', + }); + const pub = await getSalesProductPublishPriceList({ salesProductId: pid, page: 1, pageSize: 1 }); + const latest = pub.items?.[0] as Record | undefined; + const online = latest?.content ?? latest?.Content; + if (online == null) { + pricePublishPending.value = true; + return; + } + const { length } = computeSnapshotDiff(online, preview); + pricePublishPending.value = length > 0; + } catch { + pricePublishPending.value = null; + } + } + async function loadRecordsTabData(productId: number) { try { const [pubPrice, pubRules] = await Promise.all([ @@ -989,6 +1110,7 @@ preStorageScript: p.preStorageScript ?? p.PreStorageScript ?? '', }); customerGuideMarkdown.value = p.customerGuideMarkdown ?? p.CustomerGuideMarkdown ?? ''; + resetConfigFormBaseline(); await loadSalesProductRelatedByTab(product.id, activeTab.value); } catch (e: any) { console.error(e); @@ -1007,19 +1129,40 @@ const handleToggleProduct = async (product: any) => { if (!product?.id) return; + const targetEnabled = !!product.isActive; + const previousActive = !targetEnabled; try { + if (!targetEnabled) { + const confirmed = await new Promise((resolve) => { + Modal.confirm({ + title: t('channel.salesProducts.messages.toggleDisableTitle'), + content: t('channel.salesProducts.messages.toggleDisableConfirm', { + name: product.name || product.id, + }), + okText: t('channel.salesProducts.modal.actions.ok'), + cancelText: t('channel.salesProducts.modal.actions.cancel'), + onOk: () => resolve(true), + onCancel: () => resolve(false), + }); + }); + if (!confirmed) { + product.isActive = previousActive; + return; + } + } const d = await getSalesProductDetail(product.id); if (d == null || typeof d !== 'object') { Message.error('无法加载产品详情'); + product.isActive = previousActive; return; } - const active = (d as any).isActive ?? (d as any).IsActive; - const next = toEnableStatus(!isEnableStatusEnabled(active)); + const next = toEnableStatus(targetEnabled); await updateSalesProduct({ ...(d as Record), isActive: next }); product.isActive = isEnableStatusEnabled(next); Message.success('已更新状态'); await loadProductList(); } catch (e: any) { + product.isActive = previousActive; Message.error(e?.message || '更新失败'); } }; @@ -1048,6 +1191,8 @@ await deleteSalesProduct(deletingProduct.value.id); if (selectedProduct.value === deletingProduct.value.id) { selectedProduct.value = null; + configFormBaseline.value = null; + rulesFormBaseline.value = null; } Message.success('删除成功'); deleteProductModalVisible.value = false; @@ -1068,8 +1213,10 @@ // 标签页切换 const handleTabChange = async (key: string) => { + if (key === activeTab.value) return; + const ok = await confirmDiscardUnsavedChanges(); + if (!ok) return; activeTab.value = key; - // 切到卖价/排货/发布记录时主动刷新,确保点击 Tab 会触发接口请求 if (['price', 'rules', 'records'].includes(key) && selectedProduct.value != null) { await loadSalesProductRelatedByTab(selectedProduct.value, key); } @@ -1121,44 +1268,6 @@ } } - // 保存选择弹窗 - const saveConfirmModalVisible = ref(false); - const currentSaveType = ref<'config' | 'price' | 'rules'>('config'); - - // 保存表单 - const handleSave = async () => { - currentSaveType.value = 'config'; - await nextTick(); - saveConfirmModalVisible.value = true; - }; - - // 价格发布:确认后调 POST /channel/sales-product/publish - const handlePublishPrice = async () => { - const pid = selectedProduct.value; - if (pid == null) { - Message.warning('请先选择产品'); - return; - } - if (priceTableData.value.length === 0) { - Message.warning('暂无可发布的费用项'); - return; - } - - currentSaveType.value = 'price'; - try { - await openPublishDiffModal('price'); - } catch (e: any) { - Message.error(e?.message || '加载发布预览失败'); - } - }; - - // 保存排货规则 - const handleSaveRules = async () => { - currentSaveType.value = 'rules'; - await nextTick(); - saveConfirmModalVisible.value = true; - }; - async function persistSalesProductConfig(pid: number) { const d = await getSalesProductDetail(pid); if (d == null || typeof d !== 'object') { @@ -1190,6 +1299,7 @@ isActive: toEnableStatus(isEnableStatusEnabled(prev.isActive ?? prev.IsActive)), scheme: String(prev.scheme ?? prev.Scheme ?? 'costLowest'), }); + resetConfigFormBaseline(); } async function persistSalesProductAllocationDraft(pid: number) { @@ -1209,6 +1319,30 @@ customerIds: row.customers || [], })), }); + resetRulesFormBaseline(); + } + + const publishOperatorType = ref<'price' | 'rules'>('price'); + const publishHasOnlineSnapshot = ref(false); + const publishDiffLength = ref(0); + const publishDiffRows = ref([]); + const pricePublishPending = ref(null); + + const publishDiffStatus = computed(() => { + if (!publishHasOnlineSnapshot.value) return 'first'; + if (publishDiffLength.value === 0) return 'synced'; + return 'changed'; + }); + + const visiblePublishDiffRows = computed(() => + filterDiffRows(publishDiffRows.value, showOnlyDiff.value) + ); + + function applyPublishDiffState(originalRaw: unknown, modifiedRaw: unknown, hasOnline: boolean) { + publishHasOnlineSnapshot.value = hasOnline; + const { length, rows } = computeSnapshotDiff(originalRaw, modifiedRaw); + publishDiffLength.value = length; + publishDiffRows.value = rows; } /** 用后端 publish/preview 接口展示真实快照(含黑名单/计重等内嵌明细) */ @@ -1218,167 +1352,149 @@ Message.warning('请先选择产品'); return; } + publishOperatorType.value = operatorType; const preview = await getSalesProductPublishPreview({ salesProductId: pid, operatorType, }); - modifiedModel.value = JSON.stringify(preview, null, 2); + modifiedModel.value = formatSnapshotJson(preview); const pub = operatorType === 'price' ? await getSalesProductPublishPriceList({ salesProductId: pid, page: 1, pageSize: 1 }) : await getSalesProductPublishRulesList({ salesProductId: pid, page: 1, pageSize: 1 }); const latest = pub.items?.[0] as Record | undefined; - const content = latest?.content ?? latest?.Content; - if (content) { - originalValue.value = - typeof content === 'string' ? content : JSON.stringify(content); - } else { - originalValue.value = JSON.stringify({}, null, 2); - } - try { - const len = diffJson( - JSON.parse(originalValue.value || '{}'), - JSON.parse(modifiedModel.value || '{}') - )?.filter((item) => item.added || item.removed).length || 0; - diffLength.value = len; - } catch { - diffLength.value = 0; - } - showOnlyDiff.value = false; + const online = latest?.content ?? latest?.Content; + const hasOnline = online != null; + originalValue.value = hasOnline ? formatSnapshotJson(online) : formatSnapshotJson({}); + applyPublishDiffState(online ?? {}, preview, hasOnline); + showOnlyDiff.value = publishDiffLength.value > 0; await nextTick(); publishModalMethods.openModal(); } - const handleSaveOnly = async () => { - saveConfirmModalVisible.value = false; + const handlePublishPrice = async () => { const pid = selectedProduct.value; if (pid == null) { Message.warning('请先选择产品'); return; } - try { - if (currentSaveType.value === 'config') { - await persistSalesProductConfig(pid); - } else if (currentSaveType.value === 'rules') { - await persistSalesProductAllocationDraft(pid); - } else { - return; - } - Message.success('保存成功(未生效)'); - if (currentSaveType.value !== 'config') { - await loadSalesProductRelatedByTab(pid, currentSaveType.value); - } - } catch (e: any) { - Message.error(e?.message || '保存失败'); + if (priceTableData.value.length === 0) { + Message.warning('暂无可发布的费用项'); + return; } + try { + await openPublishDiffModal('price'); + } catch (e: any) { + Message.error(e?.message || '加载发布预览失败'); + } + }; + + const handleSaveConfigDraft = async () => { + const pid = selectedProduct.value; + if (pid == null) { + Message.warning('请先选择产品'); + return; + } + if (!configFormDirty.value) return; + savingConfig.value = true; + try { + await persistSalesProductConfig(pid); + Message.success(t('channel.salesProducts.messages.saveDraftSuccess')); + } catch (e: any) { + Message.error(e?.message || t('channel.salesProducts.messages.saveFailed')); + } finally { + savingConfig.value = false; + } + }; + + const handleSaveConfigAndPublish = async () => { + const pid = selectedProduct.value; + if (pid == null) { + Message.warning('请先选择产品'); + return; + } + if (!configFormDirty.value) return; + Modal.confirm({ + title: t('channel.salesProducts.saveConfirm.title'), + content: t('channel.salesProducts.saveConfirm.message'), + okText: t('channel.salesProducts.actions.saveAndPublish'), + cancelText: t('channel.salesProducts.saveConfirm.cancel'), + onOk: async () => { + savingConfig.value = true; + try { + await persistSalesProductConfig(pid); + await openPublishDiffModal('rules'); + } catch (e: any) { + Message.error(e?.message || t('channel.salesProducts.messages.saveFailed')); + } finally { + savingConfig.value = false; + } + }, + }); + }; + + const handleSaveRulesDraft = async () => { + const pid = selectedProduct.value; + if (pid == null) { + Message.warning('请先选择产品'); + return; + } + if (!rulesFormDirty.value) return; + savingConfig.value = true; + try { + await persistSalesProductAllocationDraft(pid); + Message.success(t('channel.salesProducts.messages.saveDraftSuccess')); + } catch (e: any) { + Message.error(e?.message || t('channel.salesProducts.messages.saveFailed')); + } finally { + savingConfig.value = false; + } + }; + + const handleSaveRulesAndPublish = async () => { + const pid = selectedProduct.value; + if (pid == null) { + Message.warning('请先选择产品'); + return; + } + if (!rulesFormDirty.value) return; + Modal.confirm({ + title: t('channel.salesProducts.saveConfirm.title'), + content: t('channel.salesProducts.saveConfirm.message'), + okText: t('channel.salesProducts.actions.saveAndPublish'), + cancelText: t('channel.salesProducts.saveConfirm.cancel'), + onOk: async () => { + savingConfig.value = true; + try { + await persistSalesProductAllocationDraft(pid); + await openPublishDiffModal('rules'); + } catch (e: any) { + Message.error(e?.message || t('channel.salesProducts.messages.saveFailed')); + } finally { + savingConfig.value = false; + } + }, + }); }; // 发布弹窗相关 const [registerPublishModal, publishModalMethods] = useModal() as [any, any]; const modifiedModel = ref(''); const originalValue = ref(''); - const differenceContent = ref(''); - const diffLength = ref(0); - const showOnlyDiff = ref(false); + const showOnlyDiff = ref(true); const publishConfirmVisible = ref(false); const publishDescription = ref(''); const publishConfirmDate = ref(dayjs()); - // 获取原始值(格式化) - const getOriginalValue = computed(() => { - if (originalValue.value) { - try { - return JSON.stringify(JSON.parse(originalValue.value || '{}'), null, 2); - } catch { - return originalValue.value; - } - } - return '{}'; - }); + const getOriginalValue = computed(() => formatSnapshotJson(originalValue.value || {})); - // 获取修改后的值(格式化) - const getModifiedModel = computed(() => { - if (modifiedModel.value) { - try { - return JSON.stringify(JSON.parse(modifiedModel.value || '{}'), null, 2); - } catch { - return modifiedModel.value; - } - } - return '{}'; - }); + const getModifiedModel = computed(() => formatSnapshotJson(modifiedModel.value || {})); - // 结构化差异 - const structuredDiff = computed(() => { - try { - const originObj = JSON.parse(originalValue.value || '{}'); - const newObj = JSON.parse(modifiedModel.value || '{}'); - const parts = diffJson(originObj, newObj)?.filter((p) => p && p.value !== undefined) || []; - const flat: Array<{ text: string; status: 'add' | 'remove' | 'same' }> = []; - parts.forEach((p) => { - const status = p.added ? 'add' : p.removed ? 'remove' : 'same'; - const lines = (p.value || '').split('\n'); - lines.forEach((line, idx) => { - if (idx === lines.length - 1 && line === '') return; - flat.push({ text: line, status }); - }); - }); - const merged: Array< - | { type: 'add' | 'remove' | 'same'; text: string } - | { type: 'change'; oldText: string; newText: string } - > = []; - 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 { - merged.push({ type: curr.status, text: curr.text }); - i += 1; - } - } - return merged; - } catch (err) { - return []; - } - }); - - // 可见的差异行 - const visibleDiffRows = computed(() => { - if (!showOnlyDiff.value) return structuredDiff.value; - return structuredDiff.value.filter((row) => row.type !== 'same'); - }); - - // 确认保存 - 保存并生效 - const handleSaveAndActivate = async () => { - saveConfirmModalVisible.value = false; - const pid = selectedProduct.value; - if (pid == null) { - Message.warning('请先选择产品'); - return; - } - try { - if (currentSaveType.value === 'config') { - await persistSalesProductConfig(pid); - await openPublishDiffModal('rules'); - } else if (currentSaveType.value === 'rules') { - await persistSalesProductAllocationDraft(pid); - await openPublishDiffModal('rules'); - } - } catch (e: any) { - Message.error(e?.message || '保存失败'); - } - }; - - // 处理发布 const handlePublishConfirm = () => { - if (!diffLength.value) { - Message.warning('没有内容需要发布'); + if (!publishDiffLength.value) { + Message.info(t('channel.salesProducts.publish.status.synced')); return; } - // 设置默认发布日期为当前时间 publishConfirmDate.value = dayjs(); publishConfirmVisible.value = true; }; @@ -1386,15 +1502,8 @@ const handleConfirmPublish = async () => { const pid = selectedProduct.value; if (pid == null) return; - const operatorType = currentSaveType.value === 'price' ? 'price' : 'rules'; + const operatorType = publishOperatorType.value; try { - // 配置/排货:先保存再发布;卖价发布仅 publish - if (currentSaveType.value === 'config') { - await persistSalesProductConfig(pid); - } else if (currentSaveType.value === 'rules') { - await persistSalesProductAllocationDraft(pid); - } - await publishSalesProduct({ salesProductId: Number(pid), operatorType, @@ -1406,12 +1515,14 @@ publishConfirmDate.value = dayjs(); publishModalMethods.closeModal(); await loadSalesProductRelatedByTab(pid, 'records'); + if (operatorType === 'price') { + await loadPriceTabData(pid); + } } catch (e: any) { Message.error(e?.message || '发布失败'); } }; - // 取消发布确认 const handleCancelPublishConfirm = () => { publishConfirmVisible.value = false; publishDescription.value = ''; @@ -1482,10 +1593,6 @@ } }; - // 取消保存 - const handleCancelSave = () => { - saveConfirmModalVisible.value = false; - }; // 排货规则相关数据 const shippingScheme = ref('costLowest'); // 成本最低、时效最快、综合最优 @@ -1497,6 +1604,43 @@ const shippingProviderData = ref([]); + type RulesFormSnapshot = { + scheme: string; + providers: Array<{ + serialNumber: number; + serviceChannelId: number | undefined; + weight: number; + customers: string[]; + }>; + }; + + const rulesFormBaseline = ref(null); + + function snapshotRulesForm(): RulesFormSnapshot { + return { + scheme: shippingScheme.value, + providers: shippingProviderData.value.map((row: any) => ({ + serialNumber: Number(row.serialNumber ?? 0), + serviceChannelId: + row.serviceChannelId != null && row.serviceChannelId !== '' + ? Number(row.serviceChannelId) + : undefined, + weight: Number(row.weight ?? 0), + customers: [...(row.customers || [])].map(String).sort(), + })), + }; + } + + function resetRulesFormBaseline() { + rulesFormBaseline.value = snapshotRulesForm(); + } + + const rulesFormDirty = computed(() => { + const baseline = rulesFormBaseline.value; + if (!baseline) return false; + return JSON.stringify(snapshotRulesForm()) !== JSON.stringify(baseline); + }); + // 新增服务商 const handleAddShippingProvider = () => { const newId = Math.max(...shippingProviderData.value.map((item) => item.id), 0) + 1; @@ -2697,14 +2841,99 @@ {{ t(tab.labelKey) }} +
+ + + {{ t('channel.salesProducts.actions.saveDraft') }} + + + {{ t('channel.salesProducts.actions.saveAndPublish') }} + + +
+
+ + + {{ t('channel.salesProducts.actions.saveDraft') }} + + + {{ t('channel.salesProducts.actions.saveAndPublish') }} + + +
+ + +
+ + {{ t('channel.salesProducts.context.product') }}: + {{ selectedProductContext.productName }} + ({{ selectedProductContext.productCode }}) + + | + + {{ t('channel.salesProducts.context.group') }}: + {{ selectedProductContext.groupName }} + + + {{ t('channel.salesProducts.context.unsaved') }} + +
+ +
+
+

{{ t('channel.salesProducts.empty.selectProduct') }}

+
-
+
{{ t('channel.salesProducts.price.effectiveVersion', { version: effectiveVersion }) }} 生效时间:{{ effectiveTime }} + + {{ t('channel.salesProducts.publish.badge.pending') }} + + + {{ t('channel.salesProducts.publish.badge.synced') }} +
-
+
{{ t('channel.salesProducts.rules.schemeTitle') }}
@@ -2814,13 +3043,6 @@ {{ t('channel.salesProducts.actions.add') }} - - {{ t('channel.salesProducts.actions.save') }} -
@@ -2908,7 +3130,7 @@
-
+
-
+
@@ -3011,34 +3233,8 @@
- - -
- -
- - {{ t('channel.salesProducts.modal.actions.cancel') }} - - - {{ t('channel.salesProducts.modal.actions.ok') }} - -
-
-
- -
+
@@ -3245,23 +3441,36 @@
- -
-
- - {{ t('channel.salesProducts.actions.save') }} - -
-
-
+ + +
+ +
+ + {{ t('channel.salesProducts.modal.actions.cancel') }} + + + {{ t('channel.salesProducts.modal.actions.ok') }} + +
+
+
+ - - -
-
-

当前保存并不会生效,请确认是否保存并生效。

-

当前保存并不会生效,请选择是仅保存还是保存并生效。

-
-
- -
-
+
+
+ {{ + publishDiffStatus === 'synced' + ? t('channel.salesProducts.publish.status.synced') + : publishDiffStatus === 'first' + ? t('channel.salesProducts.publish.status.first') + : t('channel.salesProducts.publish.status.changed', { count: publishDiffLength }) + }} +
+
+ {{ t('channel.salesProducts.publish.noDiffDetail') }} +
+
+
-
更新缓存
- 发布 + {{ + publishDiffLength === 0 + ? t('channel.salesProducts.publish.noNeed') + : t('channel.salesProducts.publish.confirm') + }} - - 只看差异 + + {{ t('channel.salesProducts.publish.showOnlyDiff') }}
-
-
-
线上缓存
-
{{ getOriginalValue }}
-
-
-
更新缓存 (差异数: {{ diffLength }})
-
{{ getModifiedModel }}
-
+
+
{{ getModifiedModel }}
-
- 🟢 新增 - 🔴 删除 - 🟡 修改 - ⚪ 不变 -
- -
-
更新缓存
-
- -
- 没有内容需要发布 +
@@ -4160,6 +4375,64 @@ } } + .tabs-toolbar { + display: flex; + align-items: center; + padding: 8px 0; + } + + .product-context-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + padding: 10px 24px; + background: #fafafa; + border-bottom: 1px solid #e8e8e8; + font-size: 13px; + color: #595959; + + .context-label { + color: #8c8c8c; + } + + .context-value { + color: #262626; + font-weight: 500; + } + + .context-code { + margin-left: 4px; + color: #8c8c8c; + } + + .context-divider { + color: #d9d9d9; + } + + .context-dirty-tag { + margin-left: auto; + } + } + + .empty-state-panel { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + min-height: 240px; + } + + .empty-state { + text-align: center; + color: #8c8c8c; + font-size: 14px; + + p { + margin: 0; + } + } + .form-section { flex: 1; padding: 24px; @@ -4244,6 +4517,11 @@ .version-info { font-size: 14px; color: #262626; + + .price-sync-tag { + margin-left: 12px; + vertical-align: middle; + } } .price-header-actions { @@ -5017,6 +5295,65 @@ gap: 12px; } + .publish-diff-status { + padding: 12px 16px; + border-radius: 8px; + border: 1px solid #e8e8e8; + background: #fafafa; + + .status-title { + font-size: 14px; + font-weight: 600; + color: #262626; + } + + .status-desc { + margin-top: 4px; + font-size: 13px; + color: #8c8c8c; + } + + &.is-synced { + border-color: #b7eb8f; + background: #f6ffed; + + .status-title { + color: #389e0d; + } + } + + &.is-changed { + border-color: #ffd591; + background: #fff7e6; + + .status-title { + color: #d46b08; + } + } + + &.is-first { + border-color: #91d5ff; + background: #e6f7ff; + + .status-title { + color: #096dd9; + } + } + } + + .publish-diff-synced-panel { + border: 1px solid #e8e8e8; + border-radius: 8px; + background: #fafafa; + max-height: 420px; + overflow: auto; + + .synced-preview { + margin: 0; + padding: 12px; + } + } + .diff-header { display: flex; justify-content: space-between; diff --git a/src/views/channel/serviceChannels/index.vue b/src/views/channel/serviceChannels/index.vue index 0342b8e..72d49fa 100644 --- a/src/views/channel/serviceChannels/index.vue +++ b/src/views/channel/serviceChannels/index.vue @@ -79,20 +79,74 @@
-
{{ tab.label }}
+
+ + + {{ t('channel.serviceChannels.actions.saveDraft') }} + + + {{ t('channel.serviceChannels.actions.saveAndPublish') }} + + +
+
+ + +
+ + {{ t('channel.serviceChannels.context.channel') }}: + {{ selectedChannelContext.channelName }} + ({{ selectedChannelContext.channelCode }}) + + | + + {{ t('channel.serviceChannels.context.group') }}: + {{ selectedChannelContext.groupName }} + + | + + {{ t('channel.serviceChannels.context.app') }}: + {{ selectedChannelContext.appName }} + + | + + {{ t('channel.serviceChannels.context.apiProvider') }}: + {{ selectedChannelContext.apiProviderName }} + + + {{ t('channel.serviceChannels.context.unsaved') }} + +
+ +
+
+

{{ t('channel.serviceChannels.empty.selectChannel') }}

+
-
+
@@ -161,28 +215,31 @@
- -
-
- - {{ t('channel.serviceChannels.actions.save') }} - -
-
-
+
{{ t('channel.salesProducts.price.effectiveVersion', { version: effectiveVersion }) }} 生效时间:{{ effectiveTime }} + + {{ t('channel.serviceChannels.publish.badge.pending') }} + + + {{ t('channel.serviceChannels.publish.badge.synced') }} +
-
+
@@ -494,13 +551,6 @@ {{ t('channel.serviceChannels.drawer.footer.generatePriceList') }} - - {{ t('channel.serviceChannels.actions.save') }} -
@@ -714,109 +764,116 @@ - - -
-
-

当前保存并不会生效,请选择是仅保存还是保存并生效。

-
-
- -
-
+
+
+ {{ + publishDiffStatus === 'synced' + ? t('channel.serviceChannels.publish.status.synced') + : publishDiffStatus === 'first' + ? t('channel.serviceChannels.publish.status.first') + : t('channel.serviceChannels.publish.status.changed', { count: publishDiffLength }) + }} +
+
+ {{ t('channel.serviceChannels.publish.noDiffDetail') }} +
+
+
-
更新缓存
- 发布 + {{ + publishDiffLength === 0 + ? t('channel.serviceChannels.publish.noNeed') + : t('channel.serviceChannels.publish.confirm') + }} - - 只看差异 + + {{ t('channel.serviceChannels.publish.showOnlyDiff') }}
-
-
-
线上缓存
-
{{ getOriginalValue }}
-
-
-
更新缓存 (差异数: {{ diffLength }})
-
{{ getModifiedModel }}
-
+
+
{{ getModifiedModel }}
-
- 🟢 新增 - 🔴 删除 - 🟡 修改 - ⚪ 不变 -
- -
-
更新缓存
-
- -
- 没有内容需要发布 +
@@ -1125,7 +1182,12 @@ import { IconPlus, IconDelete, IconSearch, IconEdit, IconDragArrow, IconQuestion import { Message, Modal, Input } from '@arco-design/web-vue' import ModalPro from '@/components/ModalPro/index.vue' import useModal from '@/components/ModalPro/useModal' -import { diffJson } from 'diff' +import { + computeSnapshotDiff, + filterDiffRows, + formatSnapshotJson, + type SnapshotDiffRow, +} from '@/utils/publishSnapshotDiff' import dayjs from 'dayjs' import * as XLSX from 'xlsx' import Sortable from 'sortablejs' @@ -1643,6 +1705,7 @@ async function ensureDefaultChannelSelection() { const first = findFirstChannelInTree() if (!first) { selectedChannel.value = null + configFormBaseline.value = null return } const curId = (selectedChannel.value as any)?.id @@ -1657,13 +1720,22 @@ async function ensureDefaultChannelSelection() { } function handleChannelSelectFromTree(keys: string[], info: any) { + void handleChannelSelectFromTreeAsync(keys, info) +} + +async function handleChannelSelectFromTreeAsync(keys: string[], info: any) { const node = info?.node if (!node || !node.isLeaf) return const key = node.key if (!key.startsWith('item-')) return const id = Number(key.slice(5)) const channel = findChannelById(id) - if (channel) selectChannel(channel) + if (!channel) return + if (activeTab.value === 'config' && configFormDirty.value) { + const ok = await confirmDiscardConfigChanges() + if (!ok) return + } + await selectChannel(channel) } // 表单数据 @@ -1678,6 +1750,102 @@ const formData = reactive({ preScript: '' }) +type ConfigFormSnapshot = { + channelCode: string + channelName: string + minTime: string + maxTime: string + forecastType: string + forecastCode: string + description: string + preScript: string +} + +const configFormBaseline = ref(null) +const savingConfig = ref(false) + +function snapshotConfigForm(): ConfigFormSnapshot { + return { + channelCode: formData.channelCode, + channelName: formData.channelName, + minTime: formData.minTime, + maxTime: formData.maxTime, + forecastType: formData.forecastType, + forecastCode: formData.forecastCode, + description: formData.description, + preScript: formData.preScript, + } +} + +function resetConfigFormBaseline() { + configFormBaseline.value = snapshotConfigForm() +} + +const configFormDirty = computed(() => { + const baseline = configFormBaseline.value + if (!baseline) return false + return ( + formData.channelCode !== baseline.channelCode + || formData.channelName !== baseline.channelName + || formData.minTime !== baseline.minTime + || formData.maxTime !== baseline.maxTime + || formData.forecastType !== baseline.forecastType + || formData.forecastCode !== baseline.forecastCode + || formData.description !== baseline.description + || formData.preScript !== baseline.preScript + ) +}) + +const selectedChannelContext = computed(() => { + const ch = selectedChannel.value as any + if (!ch?.id) return null + let group: any = null + for (const g of channelGroups.value) { + if ((g.channels || []).some((c: any) => Number(c.id) === Number(ch.id))) { + group = g + break + } + } + const appId = group?.appId + const spId = group?.apiServiceProviderId ?? group?.serviceProviderId + const appName = appId != null + ? (appOptions.value.find((o) => o.value === appId)?.label ?? String(appId)) + : '-' + const apiProviderName = spId != null + ? (serviceProviderOptions.value.find((o) => o.value === spId)?.label ?? String(spId)) + : '-' + return { + channelName: String(ch.name ?? formData.channelName ?? '-'), + channelCode: String(ch.channelCode ?? formData.channelCode ?? ''), + groupName: String(group?.name ?? '-'), + appName, + apiProviderName, + } +}) + +function confirmDiscardConfigChanges(): Promise { + if (!configFormDirty.value) return Promise.resolve(true) + return new Promise((resolve) => { + Modal.confirm({ + title: t('channel.serviceChannels.unsaved.title'), + content: t('channel.serviceChannels.unsaved.message'), + okText: t('channel.serviceChannels.unsaved.discard'), + cancelText: t('channel.serviceChannels.unsaved.stay'), + onOk: () => resolve(true), + onCancel: () => resolve(false), + }) + }) +} + +async function handleTabClick(tabKey: string) { + if (tabKey === activeTab.value) return + if (activeTab.value === 'config' && configFormDirty.value) { + const ok = await confirmDiscardConfigChanges() + if (!ok) return + } + activeTab.value = tabKey +} + // 搜索/刷新处理 const handleSearch = () => { loadChannelGroups() @@ -2189,6 +2357,7 @@ async function loadServiceChannelPriceRows(serviceChannelId: number | string) { const list = Array.isArray(raw) ? raw : [] priceTableData.value = list.map((p: any, i: number) => mapServiceChannelPriceRow(p, i)) await refreshChannelPriceEffectiveInfo(serviceChannelId) + void refreshPricePublishSyncStatus(serviceChannelId) } function mapPublishRecordRow(r: any) { @@ -2353,11 +2522,6 @@ const handleGeneratePriceList = async () => { Message.success('价格表生成成功') } -// 保存基础信息 -const handleSaveBasicInfo = () => { - Message.success('基础信息保存成功') -} - const savingPriceDrawer = ref(false) function nextServiceChannelPriceSerialNumber(): number { @@ -2750,12 +2914,17 @@ function applyServiceChannelDetailToForm(d: Record) { formData.forecastCode = String(d.forecastCode ?? d.ForecastCode ?? '') formData.description = String(d.description ?? d.Description ?? '') formData.preScript = String(d.preScript ?? d.PreScript ?? '') + resetConfigFormBaseline() } // 选择渠道 const selectChannel = async (channel: any) => { selectedChannel.value = channel - if (!channel?.id) return + if (!channel?.id) { + configFormBaseline.value = null + return + } + void ensureReferenceOptionsLoaded() await ensureChannelPriceDictLoaded() try { const d = await getServiceChannelDetail(channel.id) @@ -2763,6 +2932,7 @@ const selectChannel = async (channel: any) => { Message.error('渠道详情为空,请稍后重试') formData.channelCode = String(channel.channelCode ?? '') formData.channelName = String(channel.name ?? '') + resetConfigFormBaseline() return } applyServiceChannelDetailToForm(d) @@ -2771,6 +2941,7 @@ const selectChannel = async (channel: any) => { Message.error((e as any)?.message || '加载渠道详情失败') formData.channelCode = String(channel.channelCode ?? '') formData.channelName = String(channel.name ?? '') + resetConfigFormBaseline() return } try { @@ -2787,13 +2958,32 @@ const selectChannel = async (channel: any) => { } } -// 切换渠道状态 +// 切换渠道状态(左侧开关会先改 isActive,此处以 UI 目标状态为准) const handleToggleChannel = async (channel: any) => { if (!channel?.id) return + const targetEnabled = !!channel.isActive + const previousActive = !targetEnabled try { + if (!targetEnabled) { + const confirmed = await new Promise((resolve) => { + Modal.confirm({ + title: t('channel.serviceChannels.messages.toggleDisableTitle'), + content: t('channel.serviceChannels.messages.toggleDisableConfirm', { + name: channel.name || channel.channelCode || channel.id, + }), + okText: t('channel.serviceChannels.modal.actions.ok'), + cancelText: t('channel.serviceChannels.modal.actions.cancel'), + onOk: () => resolve(true), + onCancel: () => resolve(false), + }) + }) + if (!confirmed) { + channel.isActive = previousActive + return + } + } const d = await getServiceChannelDetail(channel.id) - const active = d.isActive ?? d.IsActive - const next = toEnableStatus(!isEnableStatusEnabled(active)) + const next = toEnableStatus(targetEnabled) await updateServiceChannel( buildServiceChannelUpdatePayload(d, { isActive: next }) ) @@ -2801,50 +2991,27 @@ const handleToggleChannel = async (channel: any) => { Message.success(`渠道已${channel.isActive ? '启用' : '禁用'}`) await loadChannelGroups() } catch (e: any) { + channel.isActive = previousActive Message.error(e?.message || '更新失败') } } // 删除渠道 - 使用弹窗确认 -/** 保存成功后异步发布(不阻塞保存完成提示;与 POST /channel/service-channel/publish 一致) */ -function runPublishAfterChannelConfigSave( - serviceChannelId: number | string, - operatorType: 'price' | 'rules' -) { - void (async () => { - try { - await publishServiceChannel({ - serviceChannelId, - operatorType, - description: '渠道配置保存并发布', - }) - Message.success('发布成功') - await loadServiceChannelPublishRecords(operatorType) - if (operatorType === 'price') { - await loadServiceChannelPriceRows(serviceChannelId) - } - await loadChannelGroups() - } catch (e: any) { - Message.error(e?.message || '发布失败') - } - })() -} - -/** 先 POST update,成功后再拉详情刷新表单(与「先保存再同步服务端数据」一致) */ +/** 保存渠道配置;可选 await 发布后再提示成功 */ async function executeChannelConfigSave( successMessage: string, options?: { publishAfter?: 'price' | 'rules' } ) { if (!selectedChannel.value || !(selectedChannel.value as any).id) { Message.warning('请先选择渠道') - return + return false } const ch = selectedChannel.value as any const groupId = Number(ch.groupId) if (!Number.isFinite(groupId) || groupId <= 0) { Message.error('缺少分组信息,无法保存') - return + return false } const isActive = toEnableStatus(!!ch.isActive) const payload = buildServiceChannelUpdatePayload( @@ -2863,54 +3030,150 @@ async function executeChannelConfigSave( } ) const savedId = ch.id + savingConfig.value = true try { await updateServiceChannel(payload) - Message.success(successMessage) - // 刷新树并 await 选中渠道拉详情,避免 void selectChannel 与手动 getDetail 竞态导致表单空白或错乱 await loadChannelGroups() - if (options?.publishAfter) { - runPublishAfterChannelConfigSave(savedId, options.publishAfter) + const still = findChannelById(savedId) + if (still) { + await selectChannel(still) } + if (options?.publishAfter) { + try { + await publishServiceChannel({ + serviceChannelId: savedId, + operatorType: options.publishAfter, + description: '渠道配置保存并发布', + }) + await loadServiceChannelPublishRecords(options.publishAfter) + if (options.publishAfter === 'price') { + await loadServiceChannelPriceRows(savedId) + } + await loadChannelGroups() + Message.success(t('channel.serviceChannels.messages.saveAndPublishSuccess')) + } catch (e: any) { + Message.error( + t('channel.serviceChannels.messages.publishFailedAfterSave', { + msg: e?.message || '未知错误', + }) + ) + return false + } + } else { + Message.success(successMessage) + } + return true } catch (e: any) { Message.error(e?.message || '保存失败') + return false + } finally { + savingConfig.value = false } } -// 保存配置:先弹出「保存确认」,再在「仅保存 / 保存并生效」里先 update 再拉详情刷新表单 -const handleSave = async () => { +const handleSaveDraft = async () => { if (!selectedChannel.value || !(selectedChannel.value as any).id) { Message.warning('请先选择渠道') return } - saveConfirmContext.value = 'config' - await nextTick() - saveConfirmModalVisible.value = true + if (!configFormDirty.value) return + await executeChannelConfigSave(t('channel.serviceChannels.messages.saveDraftSuccess')) +} + +const handleSaveAndPublish = async () => { + if (!selectedChannel.value || !(selectedChannel.value as any).id) { + Message.warning('请先选择渠道') + return + } + if (!configFormDirty.value) return + Modal.confirm({ + title: t('channel.serviceChannels.saveConfirm.title'), + content: t('channel.serviceChannels.saveConfirm.message'), + okText: t('channel.serviceChannels.saveConfirm.saveAndPublish'), + cancelText: t('channel.serviceChannels.saveConfirm.cancel'), + onOk: async () => { + await executeChannelConfigSave(t('channel.serviceChannels.messages.saveAndPublishSuccess'), { + publishAfter: 'rules', + }) + }, + }) } // 价格发布:确认后 POST /channel/service-channel/publish(预览快照与卖价对称) +const publishOperatorType = ref<'price' | 'rules'>('price') +const publishHasOnlineSnapshot = ref(false) +const publishDiffLength = ref(0) +const publishDiffRows = ref([]) +const pricePublishPending = ref(null) + +const publishDiffStatus = computed(() => { + if (!publishHasOnlineSnapshot.value) return 'first' + if (publishDiffLength.value === 0) return 'synced' + return 'changed' +}) + +const visiblePublishDiffRows = computed(() => + filterDiffRows(publishDiffRows.value, showOnlyDiff.value) +) + +async function refreshPricePublishSyncStatus(serviceChannelId?: number | string) { + const cid = serviceChannelId ?? (selectedChannel.value as any)?.id + if (cid == null || !priceTableData.value.length) { + pricePublishPending.value = null + return + } + try { + const preview = await getServiceChannelPublishPreview({ + serviceChannelId: cid, + operatorType: 'price', + }) + const pub = await getServiceChannelPublishPriceList({ serviceChannelId: cid, page: 1, pageSize: 1 }) + const latest = pub.items?.[0] as Record | undefined + const online = latest?.content ?? latest?.Content + if (online == null) { + pricePublishPending.value = true + return + } + const { length } = computeSnapshotDiff(online, preview) + pricePublishPending.value = length > 0 + } catch { + pricePublishPending.value = null + } +} + +function applyPublishDiffState(originalRaw: unknown, modifiedRaw: unknown, hasOnline: boolean) { + publishHasOnlineSnapshot.value = hasOnline + const { length, rows } = computeSnapshotDiff(originalRaw, modifiedRaw) + publishDiffLength.value = length + publishDiffRows.value = rows +} + async function openChannelPublishDiffModal(operatorType: 'rules' | 'price') { const cid = (selectedChannel.value as any)?.id if (cid == null) { Message.warning('请先选择渠道') return } + publishOperatorType.value = operatorType const preview = await getServiceChannelPublishPreview({ serviceChannelId: cid, operatorType, }) - modifiedValue.value = JSON.stringify(preview, null, 2) + modifiedValue.value = formatSnapshotJson(preview) const pub = operatorType === 'price' ? await getServiceChannelPublishPriceList({ serviceChannelId: cid, page: 1, pageSize: 1 }) : await getServiceChannelPublishRulesList({ serviceChannelId: cid, page: 1, pageSize: 1 }) const latest = pub.items?.[0] as Record | undefined - if (latest?.content != null) { - originalValue.value = - typeof latest.content === 'string' ? latest.content : JSON.stringify(latest.content, null, 2) + const online = latest?.content ?? latest?.Content + const hasOnline = online != null + if (hasOnline) { + originalValue.value = formatSnapshotJson(online) } else { - originalValue.value = JSON.stringify({}, null, 2) + originalValue.value = formatSnapshotJson({}) } - showOnlyDiff.value = false + applyPublishDiffState(online ?? {}, preview, hasOnline) + showOnlyDiff.value = publishDiffLength.value > 0 await nextTick() ;(publishModalMethods as any).openModal?.() } @@ -3121,28 +3384,6 @@ const handleCancelPriceDrawer = () => { } } -// 保存确认弹窗:渠道配置(与销售产品「仅保存 / 保存并生效」交互一致;买价发布仍用「价格发布」) -const handleSaveOnly = async () => { - saveConfirmModalVisible.value = false - if (saveConfirmContext.value !== 'config') { - saveConfirmContext.value = null - return - } - saveConfirmContext.value = null - await executeChannelConfigSave('保存成功(未生效)') -} - -const handleSaveAndActivate = async () => { - saveConfirmModalVisible.value = false - if (saveConfirmContext.value !== 'config') { - saveConfirmContext.value = null - return - } - saveConfirmContext.value = null - // 渠道配置「保存并生效」:POST /channel/service-channel/publish 传 operatorType=rules - await executeChannelConfigSave('保存成功', { publishAfter: 'rules' }) -} - // 价格抽屉tab列表 const priceDrawerTabList = [ { key: 'basic', labelKey: 'channel.serviceChannels.drawer.tabs.basicInfo' }, @@ -3464,83 +3705,12 @@ const [registerPublishModal, publishModalMethods] = useModal() const originalValue = ref('') const modifiedValue = ref('') -const getOriginalValue = computed(() => { - if (originalValue.value) { - try { - return JSON.stringify(JSON.parse(originalValue.value || '{}'), null, 2) - } catch { - return originalValue.value - } - } - return '{}' -}) +const getOriginalValue = computed(() => formatSnapshotJson(originalValue.value || {})) -const getModifiedModel = computed(() => { - if (modifiedValue.value) { - try { - return JSON.stringify(JSON.parse(modifiedValue.value || '{}'), null, 2) - } catch { - return modifiedValue.value - } - } - return '{}' -}) +const getModifiedModel = computed(() => formatSnapshotJson(modifiedValue.value || {})) // 差异相关 -const showOnlyDiff = ref(false) -const diffLength = computed(() => { - if (!originalValue.value || !modifiedValue.value) return 0 - try { - const diff = diffJson( - JSON.parse(originalValue.value || '{}'), - JSON.parse(modifiedValue.value || '{}') - ) - return diff.filter((d: any) => d.added || d.removed).length - } catch { - return 0 - } -}) - -const visibleDiffRows = computed(() => { - if (!originalValue.value || !modifiedValue.value) return [] - try { - const diff = diffJson( - JSON.parse(originalValue.value || '{}'), - JSON.parse(modifiedValue.value || '{}') - ) - const flat: Array<{ text: string; status: 'add' | 'remove' | 'same' }> = [] - diff.forEach((p: any) => { - const status = p.added ? 'add' : p.removed ? 'remove' : 'same' - const lines = (p.value || '').split('\n') - lines.forEach((line: string, idx: number) => { - if (idx === lines.length - 1 && line === '') return - flat.push({ text: line, status }) - }) - }) - const merged: Array< - | { type: 'add' | 'remove' | 'same'; text: string } - | { type: 'change'; oldText: string; newText: string } - > = [] - 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 { - merged.push({ type: curr.status, text: curr.text }) - i += 1 - } - } - if (showOnlyDiff.value) { - return merged.filter((row) => row.type !== 'same') - } - return merged - } catch { - return [] - } -}) +const showOnlyDiff = ref(true) // 日期禁用 const currentHour = ref(new Date().getHours()) @@ -3587,8 +3757,8 @@ const disabledTime = () => { // 发布确认 const handlePublishConfirm = () => { - if (!diffLength.value) { - Message.warning('没有内容需要发布') + if (!publishDiffLength.value) { + Message.info(t('channel.serviceChannels.publish.status.synced')) return } publishConfirmVisible.value = true @@ -3606,8 +3776,7 @@ const handleConfirmPublish = async () => { Message.warning('请先选择渠道') return } - const operatorType = - activeTab.value === 'records' && publishRecordTab.value === 'rules' ? 'rules' : 'price' + const operatorType = publishOperatorType.value try { await publishServiceChannel({ serviceChannelId: cid, @@ -3620,6 +3789,7 @@ const handleConfirmPublish = async () => { publishConfirmDate.value = null await loadServiceChannelPublishRecords() await loadServiceChannelPriceRows(cid) + await refreshPricePublishSyncStatus(cid) await loadChannelGroups() ;(publishModalMethods as any).closeModal?.() } catch (error: any) { @@ -3627,15 +3797,6 @@ const handleConfirmPublish = async () => { Message.error(error?.message || '发布失败') } } - -// 保存确认(点保存仅打开弹窗;确认后先 POST update,再 GET detail 回填表单) -const saveConfirmModalVisible = ref(false) -const saveConfirmContext = ref<'config' | null>(null) - -const handleCancelSave = () => { - saveConfirmModalVisible.value = false - saveConfirmContext.value = null -}