diff --git a/src/api/channel/kpiArea.ts b/src/api/channel/kpiArea.ts index 95ae68d..31d03a6 100644 --- a/src/api/channel/kpiArea.ts +++ b/src/api/channel/kpiArea.ts @@ -244,13 +244,17 @@ export function mapKpiAreaInfoToExcelRow(item: Record): KpiArea } } +export type KpiAreaInfoImportMode = 'incremental' | 'full' + /** POST /channel/kpi-area/info/import multipart */ export async function importKpiAreaInfo( areaId: number, - file: File + file: File, + importMode: KpiAreaInfoImportMode = 'incremental' ): Promise { const form = new FormData() form.append('areaId', String(areaId)) + form.append('importMode', importMode) form.append('file', file) const res = await axiosInstance.post(omsUrl('/channel/kpi-area/info/import'), form) assertBusinessOk(res.data) @@ -266,3 +270,21 @@ export async function importKpiAreaInfo( failures, } } + +/** 分区包版本列表(不含正文) */ +export async function getKpiAreaPublishList(areaId: number | string) { + const res = await axiosInstance.get(omsUrl('/channel/kpi-area/publish/list'), { + params: { areaId }, + }) + const data = unwrapData(res.data) + const items = Array.isArray(data) ? data : data?.items ?? data?.Items ?? [] + return items as Array> +} + +/** 单条分区包(含正文) */ +export async function getKpiAreaPublishRecord(id: number | string) { + const res = await axiosInstance.get(omsUrl('/channel/kpi-area/publish/get'), { + params: { id }, + }) + return unwrapData>(res.data) ?? res.data?.data ?? res.data +} diff --git a/src/api/channel/salesProduct.ts b/src/api/channel/salesProduct.ts index 09c096c..84a5863 100644 --- a/src/api/channel/salesProduct.ts +++ b/src/api/channel/salesProduct.ts @@ -199,6 +199,24 @@ export async function getSalesProductPublishPreview(params: { return unwrapData>(res.data) } +/** 发布同步态(轻量,不含快照正文) */ +export async function getSalesProductPublishSyncStatus(params: { + salesProductId: number | string + operatorType: 'rules' | 'price' | string +}) { + const res = await axiosInstance.get(omsUrl('/channel/sales-product/publish/sync-status'), { params }) + return unwrapData<{ + pending?: boolean + Pending?: boolean + onlineVersion?: string | null + OnlineVersion?: string | null + onlinePublishTime?: string | null + OnlinePublishTime?: string | null + pendingContentHash?: string | null + onlineContentHash?: string | null + }>(res.data) +} + export async function publishSalesProduct(body: { salesProductId: number | string operatorType: string diff --git a/src/api/channel/serviceChannel.ts b/src/api/channel/serviceChannel.ts index b6ceffc..3898be8 100644 --- a/src/api/channel/serviceChannel.ts +++ b/src/api/channel/serviceChannel.ts @@ -279,6 +279,26 @@ export async function getServiceChannelPublishPreview(params: { return (body?.data ?? body?.Data ?? body) as Record } +/** 发布同步态(轻量,不含快照正文),与产品卖价对称 */ +export async function getServiceChannelPublishSyncStatus(params: { + serviceChannelId: number | string + operatorType: 'rules' | 'price' | string +}) { + const res = await axiosInstance.get(omsUrl('/channel/service-channel/publish/sync-status'), { params }) + const body = res.data + assertBusinessOk(body) + return (body?.data ?? body?.Data ?? body) as { + pending?: boolean + Pending?: boolean + onlineVersion?: string | null + OnlineVersion?: string | null + onlinePublishTime?: string | null + OnlinePublishTime?: string | null + pendingContentHash?: string | null + onlineContentHash?: string | null + } +} + export async function publishServiceChannel(body: { serviceChannelId: number | string operatorType: string diff --git a/src/components.d.ts b/src/components.d.ts index cb01837..12adeaa 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -35,6 +35,7 @@ declare module 'vue' { JsonEditorPro: typeof import('./components/JsonEditorPro/index.vue')['default'] MarkdownDocPanel: typeof import('./components/MarkdownDocPanel/index.vue')['default'] MarkdownViewer: typeof import('./components/MarkdownViewer/index.vue')['default'] + MemberLevelBadge: typeof import('./components/MemberLevelBadge/index.vue')['default'] MenuIconPicker: typeof import('./components/MenuIconPicker/index.vue')['default'] ModalForm: typeof import('./components/ModalForm/index.vue')['default'] ModalPro: typeof import('./components/ModalPro/index.vue')['default'] diff --git a/src/components/MemberLevelBadge/index.vue b/src/components/MemberLevelBadge/index.vue new file mode 100644 index 0000000..d50e761 --- /dev/null +++ b/src/components/MemberLevelBadge/index.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/src/components/PublishDiff/index.vue b/src/components/PublishDiff/index.vue index 38b7003..592c507 100644 --- a/src/components/PublishDiff/index.vue +++ b/src/components/PublishDiff/index.vue @@ -158,7 +158,13 @@ class="publish-diff-synced-panel" > -
{{ modifiedJson }}
+
{{ modifiedJson }}
+

+ 快照较大(约 {{ Math.round((modifiedJson?.length || 0) / 1024) }} KB),已省略全文预览以免卡顿。 +

@@ -234,7 +240,8 @@ @toggle="onRawToggle" > {{ rawSummaryText }} -
+ +
{{ originalTitle }}
{{ originalJson || '{}' }}
@@ -244,6 +251,7 @@
{{ modifiedJson || '{}' }}
+

展开后加载原始 JSON…

@@ -471,6 +479,15 @@ font-size: 13px; } + .raw-json-placeholder { + margin: 8px 0 0; + padding: 10px 12px; + border-radius: 6px; + background: var(--color-fill-1, #f7f8fa); + color: var(--color-text-3); + font-size: 12px; + } + .raw-json-details { border: 1px solid var(--color-border-2); border-radius: 6px; diff --git a/src/locale/en-US.ts b/src/locale/en-US.ts index f5a7c7d..8dc106f 100644 --- a/src/locale/en-US.ts +++ b/src/locale/en-US.ts @@ -2228,6 +2228,7 @@ export default { 'order.myOrders.drawer.declaration': 'Declaration', 'order.myOrders.drawer.senderName': 'Sender Name', 'order.myOrders.drawer.company': 'Company', + 'order.myOrders.drawer.country': 'Country', 'order.myOrders.drawer.destCountry': 'Country', 'order.myOrders.drawer.province': 'Province', 'order.myOrders.drawer.city': 'City', @@ -5066,6 +5067,7 @@ export default { 'channel.salesProducts.publish.legend.same': 'Unchanged', 'channel.salesProducts.publish.badge.pending': 'Pending', 'channel.salesProducts.publish.badge.synced': 'Synced', + 'channel.salesProducts.publish.badge.checking': 'Checking…', 'channel.salesProducts.rules.schemeTitle': 'Shipping scheme', 'channel.salesProducts.rules.scheme.costLowest': 'Lowest cost', @@ -5475,6 +5477,7 @@ export default { 'channel.serviceChannels.publish.legend.remove': 'Removed', 'channel.serviceChannels.publish.legend.change': 'Changed', 'channel.serviceChannels.publish.legend.same': 'Unchanged', + 'channel.serviceChannels.publish.badge.checking': 'Checking…', 'channel.serviceChannels.publish.badge.pending': 'Pending', 'channel.serviceChannels.publish.badge.synced': 'Synced', @@ -5489,6 +5492,21 @@ export default { 'channel.area.actions.delete': 'Delete', 'channel.area.actions.import': 'Import', 'channel.area.actions.export': 'Export', + 'channel.area.actions.packVersions': 'Pack versions', + 'channel.area.pack.modalTitle': 'Area packs — {name}', + 'channel.area.pack.tip': 'Packs are pinned on sales price / rules publish. Billing loads by version; missing packs fail hard.', + 'channel.area.pack.empty': 'No packs yet. Publish sales price or rules to create one.', + 'channel.area.pack.emptyContent': '(empty)', + 'channel.area.pack.viewContent': 'View', + 'channel.area.pack.detailTitle': 'Pack content — {version}', + 'channel.area.pack.loadFailed': 'Failed to load pack list', + 'channel.area.pack.loadDetailFailed': 'Failed to load pack content', + 'channel.area.pack.col.version': 'Version', + 'channel.area.pack.col.status': 'Status', + 'channel.area.pack.col.contentHash': 'Content hash', + 'channel.area.pack.col.publishUser': 'Publisher', + 'channel.area.pack.col.publishTime': 'Published at', + 'channel.area.pack.col.description': 'Description', 'channel.area.pagination.total': 'Total {count} records', 'channel.area.pagination.prev': 'Previous', 'channel.area.pagination.next': 'Next', @@ -5585,7 +5603,16 @@ export default { 'channel.area.message.deleteFailed': 'Delete failed. Please try again later.', 'channel.area.import.modalTitle': 'Import area details', 'channel.area.import.tip': - 'Download the template first. Import is incremental: exact duplicate rules in this area are skipped, not overwritten. Country code is required (country name is filled from the code). Put match dimension/mode codes from the Enum sheet. One row, one dimension: postcode rules if filled; state only = state match; state+city = city match; empty = country fallback.', + 'Download the template first. Country code is required (country name is filled from the code). Put match dimension/mode codes from the Enum sheet. One row, one dimension: postcode rules if filled; state only = state match; state+city = city match; empty = country fallback.', + 'channel.area.import.modeLabel': 'Import mode', + 'channel.area.import.mode.incremental': 'Incremental', + 'channel.area.import.mode.full': 'Full replace', + 'channel.area.import.mode.incrementalTip': + 'Keep existing details; exact duplicate rules in this area are skipped, not overwritten.', + 'channel.area.import.mode.fullTip': + 'Clear all details in this area first, then import the Excel rows (irreversible — export a backup first).', + 'channel.area.import.mode.fullConfirm': + 'Full import will delete all details in this area, then write the Excel data. Continue?', 'channel.area.import.downloadTemplate': 'Download template', 'channel.area.import.submit': 'Import', 'channel.area.import.downloadSuccess': 'Template downloaded', diff --git a/src/locale/zh-CN.ts b/src/locale/zh-CN.ts index 47127a1..bc47ea6 100644 --- a/src/locale/zh-CN.ts +++ b/src/locale/zh-CN.ts @@ -2114,6 +2114,7 @@ export default { 'order.myOrders.drawer.declaration': '申报信息', 'order.myOrders.drawer.senderName': '发件人姓名', 'order.myOrders.drawer.company': '公司', + 'order.myOrders.drawer.country': '国家', 'order.myOrders.drawer.destCountry': '国家', 'order.myOrders.drawer.province': '州省', 'order.myOrders.drawer.city': '城市', @@ -5102,6 +5103,7 @@ export default { 'channel.salesProducts.publish.legend.same': '不变', 'channel.salesProducts.publish.badge.pending': '待发布', 'channel.salesProducts.publish.badge.synced': '已同步', + 'channel.salesProducts.publish.badge.checking': '检测中…', 'channel.salesProducts.rules.schemeTitle': '排货方案', 'channel.salesProducts.rules.scheme.costLowest': '成本最低', @@ -5511,6 +5513,7 @@ export default { 'channel.serviceChannels.publish.legend.remove': '删除', 'channel.serviceChannels.publish.legend.change': '修改', 'channel.serviceChannels.publish.legend.same': '不变', + 'channel.serviceChannels.publish.badge.checking': '检测中…', 'channel.serviceChannels.publish.badge.pending': '待发布', 'channel.serviceChannels.publish.badge.synced': '已同步', @@ -5596,7 +5599,13 @@ export default { 'channel.area.search.button.query': '查询', 'channel.area.search.button.reset': '重置', 'channel.area.import.modalTitle': '导入分区明细', - 'channel.area.import.tip': '请先下载模板。导入为增量写入:与本分区已有规则完全相同的行会跳过,不会覆盖。国家代码必填(无需填国家名称,导入时按代码自动带出)。匹配维度、匹配方式请填 Sheet「枚举值」中的编码。一行一个维度:填邮编则为邮编规则(只填起始=前缀;起止相同=精确;起止不同=闭区间);不填邮编只填州=州匹配;州+城市=城市匹配;都空=仅国家兜底。', + 'channel.area.import.tip': '请先下载模板。国家代码必填(无需填国家名称,导入时按代码自动带出)。匹配维度、匹配方式请填 Sheet「枚举值」中的编码。一行一个维度:填邮编则为邮编规则(只填起始=前缀;起止相同=精确;起止不同=闭区间);不填邮编只填州=州匹配;州+城市=城市匹配;都空=仅国家兜底。', + 'channel.area.import.modeLabel': '导入方式', + 'channel.area.import.mode.incremental': '增量导入', + 'channel.area.import.mode.full': '全量导入', + 'channel.area.import.mode.incrementalTip': '保留本分区已有明细;与已有规则完全相同的行会跳过,不会覆盖。', + 'channel.area.import.mode.fullTip': '先清空本分区全部明细,再按 Excel 全量写入(不可恢复,请先导出备份)。', + 'channel.area.import.mode.fullConfirm': '全量导入将先删除本分区全部明细,再写入 Excel 数据,确认继续?', 'channel.area.import.downloadTemplate': '下载模板', 'channel.area.import.submit': '开始导入', 'channel.area.import.downloadSuccess': '模板下载成功', @@ -5613,6 +5622,21 @@ export default { 'channel.area.export.failed': '导出失败', 'channel.area.actions.import': '导入', 'channel.area.actions.export': '导出', + 'channel.area.actions.packVersions': '分区包版本', + 'channel.area.pack.modalTitle': '分区包版本 — {name}', + 'channel.area.pack.tip': '卖价/规则发布时自动钉包;计费按版本加载,包缺失将严格失败。', + 'channel.area.pack.empty': '暂无分区包,发布卖价或规则后生成', + 'channel.area.pack.emptyContent': '(无正文)', + 'channel.area.pack.viewContent': '查看', + 'channel.area.pack.detailTitle': '分区包正文 — {version}', + 'channel.area.pack.loadFailed': '加载分区包列表失败', + 'channel.area.pack.loadDetailFailed': '加载分区包正文失败', + 'channel.area.pack.col.version': '版本号', + 'channel.area.pack.col.status': '状态', + 'channel.area.pack.col.contentHash': '内容哈希', + 'channel.area.pack.col.publishUser': '发布人', + 'channel.area.pack.col.publishTime': '发布时间', + 'channel.area.pack.col.description': '说明', // 渠道配置页通用 'channel.config.emptySelect': '请先在左侧选择配置项', diff --git a/src/utils/memberLevelIcon.ts b/src/utils/memberLevelIcon.ts new file mode 100644 index 0000000..cc43de5 --- /dev/null +++ b/src/utils/memberLevelIcon.ts @@ -0,0 +1,156 @@ +/** + * 会员等级内置图标(V0~V5),与滚动积分体系对齐。 + * iconUrl 有值时优先用远程图;否则按 levelCode / 名称关键词回落本套图标。 + */ + +export type MemberLevelKey = 'V0' | 'V1' | 'V2' | 'V3' | 'V4' | 'V5' + +export interface MemberLevelTone { + key: MemberLevelKey + color: string + bg: string + border: string + labelZh: string +} + +const TONES: Record = { + V0: { + key: 'V0', + color: '#64748b', + bg: 'color-mix(in srgb, #64748b 12%, #fff)', + border: 'color-mix(in srgb, #64748b 28%, #e5e6eb)', + labelZh: '普通会员', + }, + V1: { + key: 'V1', + color: '#b87333', + bg: 'color-mix(in srgb, #b87333 14%, #fff)', + border: 'color-mix(in srgb, #b87333 32%, #e5e6eb)', + labelZh: '铜卡会员', + }, + V2: { + key: 'V2', + color: '#6b7c8f', + bg: 'color-mix(in srgb, #6b7c8f 12%, #fff)', + border: 'color-mix(in srgb, #6b7c8f 30%, #e5e6eb)', + labelZh: '银卡会员', + }, + V3: { + key: 'V3', + color: '#c9a227', + bg: 'color-mix(in srgb, #c9a227 16%, #fff)', + border: 'color-mix(in srgb, #c9a227 36%, #e5e6eb)', + labelZh: '金卡会员', + }, + V4: { + key: 'V4', + color: '#3d7ec9', + bg: 'color-mix(in srgb, #3d7ec9 14%, #fff)', + border: 'color-mix(in srgb, #3d7ec9 34%, #e5e6eb)', + labelZh: '钻石会员', + }, + V5: { + key: 'V5', + color: '#a67c2d', + bg: 'color-mix(in srgb, #a67c2d 16%, #fff)', + border: 'color-mix(in srgb, #a67c2d 38%, #e5e6eb)', + labelZh: '皇冠会员', + }, +} + +/** 简洁几何图标:普通 / 铜 / 银 / 金 / 钻石 / 皇冠 */ +const GLYPHS: Record = { + V0: ``, + V1: ``, + V2: ``, + V3: ``, + V4: ``, + V5: ``, +} + +export function resolveMemberLevelKey( + code?: string | null, + name?: string | null, +): MemberLevelKey | null { + const c = String(code || '') + .trim() + .toUpperCase() + if (/^V[0-5]$/.test(c)) return c as MemberLevelKey + if (c === 'NORMAL' || c === 'DEFAULT') return 'V0' + if (c === 'COPPER' || c === 'BRONZE') return 'V1' + if (c === 'SILVER') return 'V2' + if (c === 'GOLD') return 'V3' + if (c === 'DIAMOND') return 'V4' + if (c === 'CROWN' || c === 'KING') return 'V5' + + const n = String(name || '') + if (!n) return null + if (/皇冠|crown/i.test(n)) return 'V5' + if (/钻石|diamond/i.test(n)) return 'V4' + if (/金卡|黄金|gold/i.test(n)) return 'V3' + if (/银卡|白银|silver/i.test(n)) return 'V2' + if (/铜卡|青铜|copper|bronze/i.test(n)) return 'V1' + if (/普通|标准|normal|basic/i.test(n)) return 'V0' + return null +} + +export function getMemberLevelTone( + code?: string | null, + name?: string | null, +): MemberLevelTone | null { + const key = resolveMemberLevelKey(code, name) + return key ? TONES[key] : null +} + +export function renderMemberLevelGlyphSvg( + key: MemberLevelKey, + size = 16, +): string { + return `` +} + +/** Handsontable 等需要 HTML 字符串的场景 */ +export function renderMemberLevelCellHtml(opts: { + levelCode?: string | null + levelName?: string | null + iconUrl?: string | null + size?: number +}): string { + const name = String(opts.levelName || '').trim() || '—' + const escaped = name + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + const tone = getMemberLevelTone(opts.levelCode, opts.levelName) + const size = opts.size ?? 16 + const iconUrl = String(opts.iconUrl || '').trim() + let glyph = '' + if (iconUrl) { + const safeUrl = iconUrl.replace(/"/g, '') + glyph = `` + } else if (tone) { + glyph = `${renderMemberLevelGlyphSvg(tone.key, size)}` + } else { + glyph = `${escaped.slice(0, 1)}` + } + const color = tone?.color || 'var(--app-primary-color)' + const bg = tone?.bg || 'color-mix(in srgb, var(--app-primary-color) 10%, #fff)' + const border = tone?.border || 'color-mix(in srgb, var(--app-primary-color) 24%, #e5e6eb)' + return `${glyph}${escaped}` +} + +export function pickMemberLevelFromOverview(overview: Record | null | undefined) { + if (!overview) { + return { code: '', name: '', iconUrl: '' } + } + return { + code: String( + overview.memberLevelCode ?? overview.MemberLevelCode ?? overview.levelCode ?? overview.LevelCode ?? '', + ), + name: String( + overview.memberLevelName ?? overview.MemberLevelName ?? overview.levelName ?? overview.LevelName ?? '', + ), + iconUrl: String(overview.iconUrl ?? overview.IconUrl ?? ''), + } +} diff --git a/src/utils/publishSnapshotDiff.ts b/src/utils/publishSnapshotDiff.ts index 5b41775..22b906e 100644 --- a/src/utils/publishSnapshotDiff.ts +++ b/src/utils/publishSnapshotDiff.ts @@ -179,21 +179,58 @@ export function parseSnapshotJson(raw: unknown, depth = 0): unknown { return raw } +/** 超过该长度不做缩进美化,显著降低 stringify / DOM 成本 */ +const MAX_PRETTY_JSON_CHARS = 120_000 + export function formatSnapshotJson(raw: unknown): string { try { const parsed = parseSnapshotJson(raw) const normalized = normalizeSnapshotForDiff(parsed) - return JSON.stringify(normalized ?? parsed ?? {}, null, 2) + const target = normalized ?? parsed ?? {} + const compact = JSON.stringify(target) + if (compact.length > MAX_PRETTY_JSON_CHARS) return compact + return JSON.stringify(target, null, 2) } catch { return typeof raw === 'string' ? raw : '{}' } } +/** 规范化后的深比较:有差异时尽早返回,避免大快照双份 JSON.stringify 占内存/卡主线程 */ +function deepEqualNormalized(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true + if (a === undefined || b === undefined) return a === b + if (a === null || b === null) return a === b + if (typeof a !== typeof b) return false + + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i += 1) { + if (!deepEqualNormalized(a[i], b[i])) return false + } + return true + } + + if (isPlainObject(a) && isPlainObject(b)) { + const aKeys = Object.keys(a) + const bKeys = Object.keys(b) + if (aKeys.length !== bKeys.length) return false + for (const key of aKeys) { + if (!Object.prototype.hasOwnProperty.call(b, key)) return false + if (!deepEqualNormalized(a[key], b[key])) return false + } + return true + } + + return false +} + 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) + const originalNorm = normalizeSnapshotForDiff(parseSnapshotJson(originalRaw)) + const modifiedNorm = normalizeSnapshotForDiff(parseSnapshotJson(modifiedRaw)) + const original = originalNorm === undefined ? {} : originalNorm + const modified = modifiedNorm === undefined ? {} : modifiedNorm + return deepEqualNormalized(original, modified) } catch { return false } @@ -232,7 +269,7 @@ function flattenDiffParts(diff: ReturnType) { } /** 超过该字符量时跳过 diffJson 逐行展开,避免卖价大快照卡死主线程 */ -const MAX_INLINE_DIFF_CHARS = 400_000 +const MAX_INLINE_DIFF_CHARS = 120_000 export function computeSnapshotDiff( originalRaw: unknown, diff --git a/src/views/channel/components/KpiAreaImportModal.vue b/src/views/channel/components/KpiAreaImportModal.vue index 4ac427d..c991cde 100644 --- a/src/views/channel/components/KpiAreaImportModal.vue +++ b/src/views/channel/components/KpiAreaImportModal.vue @@ -12,6 +12,22 @@ >
{{ t('channel.area.import.tip') }}
+
+
{{ t('channel.area.import.modeLabel') }}
+ +
+ {{ + importMode === 'full' + ? t('channel.area.import.mode.fullTip') + : t('channel.area.import.mode.incrementalTip') + }} +
+
@@ -223,4 +343,30 @@ color: var(--color-text-2); border-bottom: 1px solid var(--color-border-2); } + + .pack-modal-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 12px; + } + + .pack-modal-tip { + font-size: 12px; + color: var(--color-text-3); + } + + .pack-detail-pre { + margin: 0; + max-height: 520px; + overflow: auto; + padding: 12px; + font-size: 12px; + line-height: 1.5; + background: #f7f8fa; + border-radius: 4px; + white-space: pre-wrap; + word-break: break-all; + } diff --git a/src/views/channel/composables/useKpiAreaPage.ts b/src/views/channel/composables/useKpiAreaPage.ts index 469565e..b8e6e79 100644 --- a/src/views/channel/composables/useKpiAreaPage.ts +++ b/src/views/channel/composables/useKpiAreaPage.ts @@ -11,6 +11,8 @@ import { postForewarningServiceKpiAreaGetKpiAreas, exportKpiAreaInfoExcel, mapKpiAreaInfoToExcelRow, + getKpiAreaPublishList, + getKpiAreaPublishRecord, } from '@/api/channel/kpiArea'; import { getCountryList } from '@/api/system/country'; import { @@ -594,6 +596,92 @@ export function useKpiAreaPage(areaType: KpiAreaType) { currentId.value = item; currentPage.value = 1; loadTableData(); + packList.value = []; + } + + type PackRow = { + id: number; + version: string; + status: string; + contentHash: string; + publishUser: string; + publishTime: string; + description: string; + areaType: string; + }; + + const packModalVisible = ref(false); + const packLoading = ref(false); + const packList = ref([]); + const packDetailVisible = ref(false); + const packDetailLoading = ref(false); + const packDetailTitle = ref(''); + const packDetailJson = ref(''); + + function mapPackRow(raw: Record): PackRow { + return { + id: Number(raw.id ?? raw.Id ?? 0), + version: String(raw.version ?? raw.Version ?? ''), + status: String(raw.status ?? raw.Status ?? ''), + contentHash: String(raw.contentHash ?? raw.ContentHash ?? ''), + publishUser: String(raw.publishUser ?? raw.PublishUser ?? ''), + publishTime: String(raw.publishTime ?? raw.PublishTime ?? ''), + description: String(raw.description ?? raw.Description ?? ''), + areaType: String(raw.areaType ?? raw.AreaType ?? ''), + }; + } + + function shortHash(hash: string) { + const s = String(hash || '').trim(); + if (s.length <= 12) return s || '--'; + return `${s.slice(0, 8)}…${s.slice(-4)}`; + } + + async function openPackModal() { + if (!guardAreaSelected()) return; + packModalVisible.value = true; + await loadPackList(); + } + + async function loadPackList() { + const areaId = currentId.value?.id; + if (areaId == null) { + packList.value = []; + return; + } + packLoading.value = true; + try { + const items = await getKpiAreaPublishList(areaId); + packList.value = (items || []).map(mapPackRow).filter((x) => x.id > 0); + } catch (e: any) { + packList.value = []; + $baseMessage(e?.message || t('channel.area.pack.loadFailed'), 'error'); + } finally { + packLoading.value = false; + } + } + + async function viewPackDetail(row: PackRow) { + if (!row?.id) return; + packDetailTitle.value = t('channel.area.pack.detailTitle', { version: row.version || row.id }); + packDetailVisible.value = true; + packDetailLoading.value = true; + packDetailJson.value = ''; + try { + const detail = await getKpiAreaPublishRecord(row.id); + const content = detail?.content ?? detail?.Content ?? null; + packDetailJson.value = + content == null + ? '' + : typeof content === 'string' + ? content + : JSON.stringify(content, null, 2); + } catch (e: any) { + packDetailJson.value = ''; + $baseMessage(e?.message || t('channel.area.pack.loadDetailFailed'), 'error'); + } finally { + packDetailLoading.value = false; + } } function guardAreaSelected() { @@ -715,6 +803,15 @@ export function useKpiAreaPage(areaType: KpiAreaType) { }); } + function handleToolbarDelete() { + const row = currentId.value; + if (row?.id == null) { + $baseMessage(t('channel.area.import.needSelectArea'), 'warning'); + return; + } + handleDelData(row.id, row.name); + } + function handleDelRightData(id: number) { $baseConfirm({ content: t('channel.area.modal.deleteConfirmDetail'), @@ -929,6 +1026,7 @@ export function useKpiAreaPage(areaType: KpiAreaType) { returnEditRowParams, returnRightEditRowParams, handleDelData, + handleToolbarDelete, handleDelRightData, handlePageChange, handlePageSizeChange, @@ -940,5 +1038,16 @@ export function useKpiAreaPage(areaType: KpiAreaType) { exporting, handleExport, guardAreaSelected, + packModalVisible, + packLoading, + packList, + packDetailVisible, + packDetailLoading, + packDetailTitle, + packDetailJson, + shortHash, + openPackModal, + loadPackList, + viewPackDetail, }; } diff --git a/src/views/channel/salesProducts/components/PriceListHotTable.vue b/src/views/channel/salesProducts/components/PriceListHotTable.vue index b745e56..68865bb 100644 --- a/src/views/channel/salesProducts/components/PriceListHotTable.vue +++ b/src/views/channel/salesProducts/components/PriceListHotTable.vue @@ -211,16 +211,20 @@ } function applyMergeCells() { - if (!hot) return; + if (!hot || !ready.value) return; rememberFactorValues(); const option = mergeCellsOption(); - if (!option) { - const plugin = hot.getPlugin('mergeCells') as { clearCollections?: () => void } | undefined; - plugin?.clearCollections?.(); + try { + if (!option) { + const plugin = hot.getPlugin?.('mergeCells') as { clearCollections?: () => void } | undefined; + plugin?.clearCollections?.(); + } + hot.updateSettings({ mergeCells: option }, false); + restoreFactorValues(); + hot.render(); + } catch { + /* mergeCells 插件未就绪 */ } - hot.updateSettings({ mergeCells: option }, false); - restoreFactorValues(); - hot.render(); } function decorateRows(rows: any[]): any[] { @@ -273,32 +277,46 @@ } function applyRowSelection(indexes: number[]) { - if (!hot) return; + if (!hot || !ready.value) { + publishSelection(indexes); + return; + } selectionLock += 1; try { if (!indexes.length) { - hot.deselectCell(); + hot.deselectCell?.(); } else { const lastCol = Math.max(hot.countCols() - 1, 0); const specs = groupConsecutive(indexes).map( ([from, to]) => [from, 0, to, lastCol] as [number, number, number, number], ); - hot.selectCells(specs, false, false); + hot.selectCells?.(specs, false, false); } + } catch { + // HT 插件未就绪时仅同步选中态,避免 clear null } finally { selectionLock -= 1; } publishSelection(indexes); - hot.render(); + try { + hot.render?.(); + } catch { + /* ignore */ + } } function selectAll() { + if (!hot || !ready.value) return; const count = props.data?.length || 0; if (!count) return; applyRowSelection(Array.from({ length: count }, (_, index) => index)); } function clearSelection() { + if (!hot || !ready.value) { + publishSelection([]); + return; + } applyRowSelection([]); } diff --git a/src/views/channel/salesProducts/index.vue b/src/views/channel/salesProducts/index.vue index 2575b40..6d10efe 100644 --- a/src/views/channel/salesProducts/index.vue +++ b/src/views/channel/salesProducts/index.vue @@ -14,6 +14,7 @@ getSalesProductPublishRecord, getSalesProductPublishRulesList, getSalesProductPublishPreview, + getSalesProductPublishSyncStatus, publishSalesProduct, rollbackSalesProductPublish, saveSalesProductAllocation, @@ -59,7 +60,6 @@ import { computeSnapshotDiff, formatSnapshotJson, - snapshotsSemanticallyEqual, type SnapshotDiffRow, } from '@/utils/publishSnapshotDiff'; import dayjs from 'dayjs'; @@ -1229,20 +1229,12 @@ .map((r) => `${r.id}:${r.modifyTime}:${r.status ? 1 : 0}`) .join(',')}`; if (sig === pricePublishCheckedSig) return; - // 发布态检测放到空闲时段,避免与表格首屏抢主线程 - const runPublishCheck = () => { - if (seq !== priceTabLoadSeq || selectedProduct.value !== productId) return; - void refreshPricePublishSyncStatus(productId).then(() => { - if (seq === priceTabLoadSeq && selectedProduct.value === productId) { - pricePublishCheckedSig = sig; - } - }); - }; - if (typeof window !== 'undefined' && 'requestIdleCallback' in window) { - (window as any).requestIdleCallback(runPublishCheck, { timeout: 1200 }); - } else { - window.setTimeout(runPublishCheck, 300); - } + // 立刻拉预览/线上快照(网络与表格并行);重 CPU 对比在请求返回后 yield 再跑 + void refreshPricePublishSyncStatus(productId).then(() => { + if (seq === priceTabLoadSeq && selectedProduct.value === productId) { + pricePublishCheckedSig = sig; + } + }); } catch (e) { if (seq !== priceTabLoadSeq) return; console.error(e); @@ -1418,24 +1410,23 @@ async function refreshPricePublishSyncStatus(productId?: number) { const pid = productId ?? selectedProduct.value; if (pid == null || !priceTableData.value.length) { + pricePublishChecking.value = false; pricePublishPending.value = null; return; } + const seq = priceTabLoadSeq; + pricePublishChecking.value = true; try { - const preview = await getSalesProductPublishPreview({ + const status = await getSalesProductPublishSyncStatus({ salesProductId: pid, operatorType: 'price', }); - const online = await loadLatestPublishSnapshot(pid, 'price'); - if (online == null) { - pricePublishPending.value = true; - return; - } - // 仅判断「有没有差异」:禁止走 computeSnapshotDiff/diffJson。 - // 大卖价快照会生成海量 diff 行,主线程直接卡死(与表格组件无关)。 - pricePublishPending.value = !snapshotsSemanticallyEqual(online, preview); + if (seq !== priceTabLoadSeq || selectedProduct.value !== pid) return; + pricePublishPending.value = !!(status?.pending ?? status?.Pending); } catch { - pricePublishPending.value = null; + if (seq === priceTabLoadSeq) pricePublishPending.value = null; + } finally { + if (seq === priceTabLoadSeq) pricePublishChecking.value = false; } } @@ -1583,10 +1574,12 @@ priceTabLoadedProductId = null; pricePublishCheckedSig = ''; pricePublishPending.value = null; + pricePublishChecking.value = false; effectiveVersion.value = '--'; effectiveTime.value = '--'; } else if (activeTab.value === 'price') { pricePublishPending.value = null; + pricePublishChecking.value = false; } if (productChanged && activeTab.value !== 'rules') { rulesTabLoadedProductId = null; @@ -1852,6 +1845,7 @@ const publishDiffLength = ref(0); const publishDiffRows = ref([]); const pricePublishPending = ref(null); + const pricePublishChecking = ref(false); const publishDiffStatus = computed(() => { if (!publishHasOnlineSnapshot.value) return 'first'; @@ -1867,6 +1861,53 @@ publishDiffRows.value = rows; } + function yieldToMain() { + return new Promise((resolve) => { + window.setTimeout(resolve, 0); + }); + } + + let publishJsonFormatTask: Promise | null = null; + let publishJsonFormatGen = 0; + + /** 后台格式化原始 JSON:不阻塞弹窗首屏;展开「高级 JSON」/开 AI 前再确保就绪 */ + function schedulePublishJsonFormat() { + if (publishJsonFormatTask) return publishJsonFormatTask; + const pendingRaw = publishPendingRaw.value; + const onlineRaw = publishOnlineRaw.value; + if (pendingRaw == null && onlineRaw == null) return Promise.resolve(); + const gen = publishJsonFormatGen; + publishJsonFormatTask = (async () => { + try { + await yieldToMain(); + if (gen !== publishJsonFormatGen) return; + if (pendingRaw != null && !modifiedModel.value) { + modifiedModel.value = formatSnapshotJson(pendingRaw); + } + await yieldToMain(); + if (gen !== publishJsonFormatGen) return; + if (onlineRaw != null && !originalValue.value) { + originalValue.value = formatSnapshotJson(onlineRaw); + } + } catch (error) { + console.warn('[salesProducts] format publish json failed', error); + } finally { + if (gen === publishJsonFormatGen) publishJsonFormatTask = null; + } + })(); + return publishJsonFormatTask; + } + + async function ensurePublishJsonFormatted() { + if (modifiedModel.value && originalValue.value) return; + publishModalLoading.value = true; + try { + await schedulePublishJsonFormat(); + } finally { + publishModalLoading.value = false; + } + } + /** 用后端 publish/preview 接口展示真实快照(含黑名单/计重等内嵌明细) */ async function openPublishDiffModal(operatorType: 'rules' | 'price') { const pid = selectedProduct.value; @@ -1875,19 +1916,46 @@ return; } publishOperatorType.value = operatorType; - const preview = await getSalesProductPublishPreview({ - salesProductId: pid, - operatorType, - }); - modifiedModel.value = formatSnapshotJson(preview); - const online = await loadLatestPublishSnapshot(Number(pid), operatorType); - const hasOnline = online != null; - originalValue.value = hasOnline ? formatSnapshotJson(online) : formatSnapshotJson({}); - applyPublishDiffState(online ?? {}, preview, hasOnline); - showOnlyDiff.value = publishDiffLength.value > 0; + publishModalLoading.value = true; + publishJsonFormatGen += 1; + publishJsonFormatTask = null; + publishDiffRows.value = []; + publishDiffLength.value = 0; + publishHasOnlineSnapshot.value = false; + originalValue.value = ''; + modifiedModel.value = ''; + publishOnlineRaw.value = null; + publishPendingRaw.value = null; + pricePublishShowRawJson.value = false; clearPricePublishAiReport(); - await nextTick(); + // 先打开弹窗再算大快照,避免长时间白屏/看起来像卡死 publishModalMethods.openModal(); + await nextTick(); + await yieldToMain(); + + try { + const preview = await getSalesProductPublishPreview({ + salesProductId: pid, + operatorType, + }); + publishPendingRaw.value = preview; + await yieldToMain(); + + const online = await loadLatestPublishSnapshot(Number(pid), operatorType); + const hasOnline = online != null; + publishOnlineRaw.value = hasOnline ? online : {}; + await yieldToMain(); + + applyPublishDiffState(publishOnlineRaw.value ?? {}, preview, hasOnline); + showOnlyDiff.value = publishDiffLength.value > 0; + // JSON 美化放到后台,首屏只展示 diff 摘要 + void schedulePublishJsonFormat(); + } catch (error) { + publishModalMethods.closeModal(); + throw error; + } finally { + publishModalLoading.value = false; + } } const handlePublishPrice = async () => { @@ -1900,6 +1968,7 @@ Message.warning('暂无可发布的费用项'); return; } + if (publishModalLoading.value) return; try { await openPublishDiffModal('price'); } catch (e: any) { @@ -2076,8 +2145,9 @@ pricePublishHighRiskAck.value = false; } - function openPricePublishAiAssist() { + async function openPricePublishAiAssist() { if (publishOperatorType.value !== 'price') return; + await ensurePublishJsonFormatted(); pricePublishAiVisible.value = true; } @@ -2136,14 +2206,22 @@ const [registerPublishModal, publishModalMethods] = useModal() as [any, any]; const modifiedModel = ref(''); const originalValue = ref(''); + /** 未格式化的原始快照,供后台 stringify,避免阻塞弹窗 */ + const publishPendingRaw = ref(null); + const publishOnlineRaw = ref(null); + const publishModalLoading = ref(false); const showOnlyDiff = ref(true); const publishConfirmVisible = ref(false); const publishDescription = ref(''); const publishConfirmDate = ref(dayjs()); - const getOriginalValue = computed(() => formatSnapshotJson(originalValue.value || {})); + // 已在打开弹窗时格式化过,禁止再次 formatSnapshotJson(大快照会二次卡死) + const getOriginalValue = computed(() => originalValue.value || '{}'); + const getModifiedModel = computed(() => modifiedModel.value || '{}'); - const getModifiedModel = computed(() => formatSnapshotJson(modifiedModel.value || {})); + watch(pricePublishShowRawJson, (open) => { + if (open) void ensurePublishJsonFormatted(); + }); const handlePublishConfirm = () => { if (publishDiffStatus.value === 'synced') { @@ -3705,15 +3783,27 @@ function clearPriceListChecked() { priceListSelectedIds.value = []; - priceListHotRef.value?.clearSelection(); + try { + priceListHotRef.value?.clearSelection?.(); + } catch { + // Handsontable / mergeCells 未就绪时忽略 + } } function selectAllPriceListRows() { - priceListHotRef.value?.selectAll(); + try { + priceListHotRef.value?.selectAll?.(); + } catch { + /* ignore */ + } } function clearAllPriceListRowsSelection() { - priceListHotRef.value?.clearSelection(); + try { + priceListHotRef.value?.clearSelection?.(); + } catch { + /* ignore */ + } } const openBatchFillModal = (onlyEmpty = false) => { @@ -3945,7 +4035,8 @@ coercePriceListRowsFactorCells(); normalizePriceListFormulaDisplays(); capturePriceListCustomerCodesSnapshot(); - clearPriceListChecked(); + priceListSelectedIds.value = []; + void nextTick(() => clearPriceListChecked()); if (priceModalVisible.value && priceDrawerActiveTab.value === 'basic') { refreshBillingFactorsTable(); } @@ -3976,12 +4067,12 @@ chargingCondition: record.chargingCondition || '无', }); - // 列表行已带 factors/details,先本地回填并立刻打开弹窗 + // 列表行已带 factors/details,先本地回填并立刻打开弹窗(勿在 HT 挂载前 clear) billingFactorsData.value = normalizeBillingFactorsFromApi(record?.factors); replacePriceListData(mapSalesProductDetailsToPriceListRows(record?.details || [])); coercePriceListRowsFactorCells(); capturePriceListCustomerCodesSnapshot(); - clearPriceListChecked(); + priceListSelectedIds.value = []; // 列表行常无 details:先开基础信息保证即时反馈,数据就绪后再切价格表 priceDrawerActiveTab.value = 'basic'; @@ -4345,6 +4436,7 @@ {{ t('channel.salesProducts.actions.publishPrice') }} @@ -4414,7 +4506,15 @@ {{ t('channel.salesProducts.price.effectiveVersion', { version: effectiveVersion }) }} 生效时间:{{ effectiveTime }} + {{ t('channel.salesProducts.publish.badge.checking') }} + + +
@@ -6073,6 +6178,7 @@
+ 生效时间:{{ effectiveTime }} + {{ t('channel.serviceChannels.publish.badge.checking') }} + + ([]) const pricePublishPending = ref(null) +const pricePublishChecking = ref(false) const publishDiffStatus = computed(() => { if (!publishHasOnlineSnapshot.value) return 'first' @@ -4077,25 +4086,21 @@ const publishDiffStatus = computed(() => { async function refreshPricePublishSyncStatus(serviceChannelId?: number | string) { const cid = serviceChannelId ?? (selectedChannel.value as any)?.id if (cid == null || !priceTableData.value.length) { + pricePublishChecking.value = false pricePublishPending.value = null return } + pricePublishChecking.value = true try { - const preview = await getServiceChannelPublishPreview({ + const status = await getServiceChannelPublishSyncStatus({ 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 - } - // 仅判断有无差异,禁止大快照走 diffJson(会卡死整页) - pricePublishPending.value = !snapshotsSemanticallyEqual(online, preview) + pricePublishPending.value = !!(status?.pending ?? status?.Pending) } catch { pricePublishPending.value = null + } finally { + pricePublishChecking.value = false } } diff --git a/src/views/h5/customer-center/components/H5OpenApiCredentialSheet.vue b/src/views/h5/customer-center/components/H5OpenApiCredentialSheet.vue index c38790f..42a5e03 100644 --- a/src/views/h5/customer-center/components/H5OpenApiCredentialSheet.vue +++ b/src/views/h5/customer-center/components/H5OpenApiCredentialSheet.vue @@ -16,20 +16,38 @@