20261008-ide1.0发布 #1

Merged
zzh merged 24 commits from test into master 2026-10-08 09:19:21 +08:00
24 changed files with 1617 additions and 211 deletions
Showing only changes of commit aaf94f2e5a - Show all commits

View File

@ -244,13 +244,17 @@ export function mapKpiAreaInfoToExcelRow(item: Record<string, unknown>): 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<KpiAreaInfoImportResult> {
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<any>(res.data)
const items = Array.isArray(data) ? data : data?.items ?? data?.Items ?? []
return items as Array<Record<string, any>>
}
/** 单条分区包(含正文) */
export async function getKpiAreaPublishRecord(id: number | string) {
const res = await axiosInstance.get(omsUrl('/channel/kpi-area/publish/get'), {
params: { id },
})
return unwrapData<Record<string, any>>(res.data) ?? res.data?.data ?? res.data
}

View File

@ -199,6 +199,24 @@ export async function getSalesProductPublishPreview(params: {
return unwrapData<Record<string, unknown>>(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

View File

@ -279,6 +279,26 @@ export async function getServiceChannelPublishPreview(params: {
return (body?.data ?? body?.Data ?? body) as Record<string, unknown>
}
/** 发布同步态(轻量,不含快照正文),与产品卖价对称 */
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

1
src/components.d.ts vendored
View File

@ -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']

View File

@ -0,0 +1,134 @@
<template>
<span
v-if="displayName"
class="member-level-badge"
:class="[`is-${size}`, tone ? `is-${tone.key}` : 'is-fallback']"
:style="badgeStyle"
:title="displayName"
>
<img
v-if="resolvedIconUrl"
class="member-level-badge__img"
:src="resolvedIconUrl"
alt=""
draggable="false"
/>
<span
v-else-if="tone"
class="member-level-badge__glyph"
aria-hidden="true"
v-html="glyphHtml"
/>
<span v-else class="member-level-badge__glyph member-level-badge__glyph--text" aria-hidden="true">
{{ displayName.slice(0, 1) }}
</span>
<span class="member-level-badge__name">{{ displayName }}</span>
</span>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import {
getMemberLevelTone,
renderMemberLevelGlyphSvg,
} from '@/utils/memberLevelIcon'
const props = withDefaults(
defineProps<{
levelCode?: string | null
levelName?: string | null
iconUrl?: string | null
size?: 'sm' | 'md'
}>(),
{
levelCode: '',
levelName: '',
iconUrl: '',
size: 'sm',
},
)
const displayName = computed(() => String(props.levelName || '').trim())
const tone = computed(() => getMemberLevelTone(props.levelCode, props.levelName))
const resolvedIconUrl = computed(() => String(props.iconUrl || '').trim())
const glyphHtml = computed(() =>
tone.value ? renderMemberLevelGlyphSvg(tone.value.key, props.size === 'md' ? 16 : 14) : '',
)
const badgeStyle = computed(() => {
if (!tone.value) return undefined
return {
'--mlb-color': tone.value.color,
'--mlb-bg': tone.value.bg,
'--mlb-border': tone.value.border,
} as Record<string, string>
})
</script>
<style scoped lang="less">
.member-level-badge {
display: inline-flex;
align-items: center;
gap: 5px;
max-width: 100%;
min-height: 22px;
padding: 2px 8px 2px 6px;
border-radius: 999px;
border: 1px solid var(--mlb-border, var(--color-border-2));
background: var(--mlb-bg, var(--color-fill-2));
color: var(--mlb-color, var(--app-primary-color));
vertical-align: middle;
line-height: 1.2;
white-space: nowrap;
}
.member-level-badge.is-md {
min-height: 26px;
gap: 6px;
padding: 3px 10px 3px 7px;
}
.member-level-badge__img,
.member-level-badge__glyph {
flex-shrink: 0;
width: 14px;
height: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.member-level-badge.is-md .member-level-badge__img,
.member-level-badge.is-md .member-level-badge__glyph {
width: 16px;
height: 16px;
}
.member-level-badge__img {
object-fit: contain;
border-radius: 50%;
}
.member-level-badge__glyph :deep(svg) {
display: block;
width: 100%;
height: 100%;
}
.member-level-badge__glyph--text {
font-size: 10px;
font-weight: 800;
}
.member-level-badge__name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.02em;
}
.member-level-badge.is-md .member-level-badge__name {
font-size: 13px;
}
</style>

View File

@ -158,7 +158,13 @@
class="publish-diff-synced-panel"
>
<slot name="synced">
<pre class="diff-pre synced-preview">{{ modifiedJson }}</pre>
<pre
v-if="(modifiedJson?.length || 0) <= 120000"
class="diff-pre synced-preview"
>{{ modifiedJson }}</pre>
<p v-else class="raw-json-placeholder">
快照较大(约 {{ Math.round((modifiedJson?.length || 0) / 1024) }} KB),已省略全文预览以免卡顿。
</p>
</slot>
</div>
@ -234,7 +240,8 @@
@toggle="onRawToggle"
>
<summary>{{ rawSummaryText }}</summary>
<div class="diff-boxes">
<!-- 大快照仅在展开后挂载,避免未展开就把 MB 级文本塞进 DOM 卡死 -->
<div v-if="rawDetailsOpen" class="diff-boxes">
<div class="diff-box">
<div class="diff-box-title">{{ originalTitle }}</div>
<pre class="diff-pre">{{ originalJson || '{}' }}</pre>
@ -244,6 +251,7 @@
<pre class="diff-pre">{{ modifiedJson || '{}' }}</pre>
</div>
</div>
<p v-else class="raw-json-placeholder">展开后加载原始 JSON…</p>
</details>
<div v-else-if="rawMode === 'always'" class="diff-boxes">
@ -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;

View File

@ -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',

View File

@ -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': '请先在左侧选择配置项',

View File

@ -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<MemberLevelKey, MemberLevelTone> = {
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<MemberLevelKey, string> = {
V0: `<circle cx="12" cy="12" r="7.2" fill="none" stroke="currentColor" stroke-width="1.8"/><circle cx="12" cy="12" r="2.2" fill="currentColor"/>`,
V1: `<path d="M12 3.2 18.4 6.4v5.2c0 4.1-2.7 7.4-6.4 8.8-3.7-1.4-6.4-4.7-6.4-8.8V6.4L12 3.2z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="M9.2 12.2h5.6M12 9.4v5.6" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>`,
V2: `<path d="M12 3.4 18.2 6.6v5c0 3.9-2.6 7.1-6.2 8.5-3.6-1.4-6.2-4.6-6.2-8.5v-5L12 3.4z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="M9.4 12.6 12 10.2l2.6 2.4-1 3.2H10.4l-1-3.2z" fill="currentColor"/>`,
V3: `<path d="M12 3.2 14.2 8.4l5.6.5-4.2 3.7 1.3 5.4L12 15.4 7.1 18l1.3-5.4-4.2-3.7 5.6-.5L12 3.2z" fill="currentColor"/>`,
V4: `<path d="M12 3.6 19.2 12 12 20.4 4.8 12 12 3.6z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="M12 7.4 15.8 12 12 16.6 8.2 12 12 7.4z" fill="currentColor"/>`,
V5: `<path d="M4.6 9.2 7.8 16.4h8.4l3.2-7.2-3.6 2-2.4-4.4L12 10l-1.4-3.4-2.4 4.4-3.6-2z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="M7.8 16.4h8.4V18H7.8z" fill="currentColor"/>`,
}
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 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" aria-hidden="true">${GLYPHS[key]}</svg>`
}
/** 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
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 = `<img class="ml-level-glyph" src="${safeUrl}" alt="" width="${size}" height="${size}" />`
} else if (tone) {
glyph = `<span class="ml-level-glyph" style="color:${tone.color}">${renderMemberLevelGlyphSvg(tone.key, size)}</span>`
} else {
glyph = `<span class="ml-level-glyph ml-level-glyph--fallback">${escaped.slice(0, 1)}</span>`
}
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 `<span class="ml-level-cell" style="--ml-color:${color};--ml-bg:${bg};--ml-border:${border}">${glyph}<span class="ml-level-name">${escaped}</span></span>`
}
export function pickMemberLevelFromOverview(overview: Record<string, unknown> | 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 ?? ''),
}
}

View File

@ -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<typeof diffJson>) {
}
/** 超过该字符量时跳过 diffJson 逐行展开,避免卖价大快照卡死主线程 */
const MAX_INLINE_DIFF_CHARS = 400_000
const MAX_INLINE_DIFF_CHARS = 120_000
export function computeSnapshotDiff(
originalRaw: unknown,

View File

@ -12,6 +12,22 @@
>
<div class="kpi-area-import-modal">
<div class="import-tip">{{ t('channel.area.import.tip') }}</div>
<div class="import-mode">
<div class="import-mode-label">{{ t('channel.area.import.modeLabel') }}</div>
<SlidingTabs
v-model="importMode"
size="mini"
:aria-label="t('channel.area.import.modeLabel')"
:items="importModeTabs"
/>
<div class="import-mode-tip">
{{
importMode === 'full'
? t('channel.area.import.mode.fullTip')
: t('channel.area.import.mode.incrementalTip')
}}
</div>
</div>
<div class="import-actions">
<a-button class="app-btn-primary-soft" :loading="downloading" @click="handleDownloadTemplate">
<template #icon>
@ -50,14 +66,16 @@
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { FileItem } from '@arco-design/web-vue';
import { Message } from '@arco-design/web-vue';
import { Message, Modal } from '@arco-design/web-vue';
import { IconDownload } from '@arco-design/web-vue/es/icon';
import SlidingTabs from '@/components/SlidingTabs/index.vue';
import {
downloadKpiAreaInfoImportTemplate,
importKpiAreaInfo,
type KpiAreaInfoImportMode,
type KpiAreaInfoImportResult,
} from '@/api/channel/kpiArea';
@ -77,13 +95,19 @@
const selectedFile = ref<File | null>(null);
const downloading = ref(false);
const importing = ref(false);
const importMode = ref<KpiAreaInfoImportMode>('incremental');
const importResult = ref<KpiAreaInfoImportResult | null>(null);
const importModeTabs = computed(() => [
{ key: 'incremental', label: t('channel.area.import.mode.incremental') },
{ key: 'full', label: t('channel.area.import.mode.full') },
]);
function open(id: number) {
areaId.value = id;
fileList.value = [];
selectedFile.value = null;
importResult.value = null;
importMode.value = 'incremental';
visible.value = true;
}
@ -105,6 +129,20 @@
}
}
function confirmFullImport(): Promise<boolean> {
return new Promise((resolve) => {
Modal.warning({
title: t('channel.area.modal.warningTitle'),
content: t('channel.area.import.mode.fullConfirm'),
hideCancel: false,
okText: t('channel.area.import.submit'),
cancelText: '取消',
onOk: () => resolve(true),
onCancel: () => resolve(false),
});
});
}
async function handleBeforeOk() {
if (!areaId.value) {
return false;
@ -114,9 +152,16 @@
return false;
}
if (importMode.value === 'full') {
const confirmed = await confirmFullImport();
if (!confirmed) {
return false;
}
}
importing.value = true;
try {
const result = await importKpiAreaInfo(areaId.value, selectedFile.value);
const result = await importKpiAreaInfo(areaId.value, selectedFile.value, importMode.value);
importResult.value = result;
if (result.successCount > 0 || result.skipCount > 0) {
emit('success');
@ -160,6 +205,24 @@
line-height: 1.6;
}
.import-mode {
display: flex;
flex-direction: column;
gap: 8px;
}
.import-mode-label {
font-size: 13px;
color: var(--color-text-1);
font-weight: 500;
}
.import-mode-tip {
font-size: 12px;
color: var(--color-text-3);
line-height: 1.5;
}
.import-actions {
display: flex;
gap: 8px;

View File

@ -84,6 +84,16 @@
<a-button :disabled="!currentId?.id" :loading="exporting" @click="handleExport">
{{ t('channel.area.actions.export') }}
</a-button>
<a-button
status="danger"
:disabled="!currentId?.id"
@click="handleToolbarDelete"
>
{{ t('channel.area.actions.delete') }}
</a-button>
<a-button :disabled="!currentId?.id" @click="openPackModal">
{{ t('channel.area.actions.packVersions') }}
</a-button>
</div>
</template>
@ -140,6 +150,104 @@
:area-type="importAreaType"
@success="onImportSuccess"
/>
<a-modal
v-model:visible="packModalVisible"
:title="t('channel.area.pack.modalTitle', { name: selectedAreaName || '--' })"
:width="880"
:footer="false"
unmount-on-close
>
<div class="pack-modal-toolbar table-toolbar">
<div class="toolbar-left">
<span class="pack-modal-tip">{{ t('channel.area.pack.tip') }}</span>
</div>
<div class="toolbar-right">
<a-button size="small" :loading="packLoading" @click="loadPackList">
{{ t('channel.area.search.refresh') }}
</a-button>
</div>
</div>
<a-table
row-key="id"
:loading="packLoading"
:data="packList"
:pagination="false"
:bordered="{ cell: true }"
:scroll="{ y: 420 }"
>
<template #columns>
<a-table-column
:title="t('channel.area.pack.col.version')"
data-index="version"
:width="160"
/>
<a-table-column
:title="t('channel.area.pack.col.status')"
data-index="status"
:width="88"
/>
<a-table-column
:title="t('channel.area.pack.col.contentHash')"
data-index="contentHash"
:width="140"
>
<template #cell="{ record }">
<a-typography-text :copyable="{ text: record.contentHash }">
{{ shortHash(record.contentHash) }}
</a-typography-text>
</template>
</a-table-column>
<a-table-column
:title="t('channel.area.pack.col.publishUser')"
data-index="publishUser"
:width="100"
/>
<a-table-column
:title="t('channel.area.pack.col.publishTime')"
data-index="publishTime"
:width="170"
/>
<a-table-column
:title="t('channel.area.pack.col.description')"
data-index="description"
ellipsis
tooltip
/>
<a-table-column
:title="t('common.actions')"
:width="88"
align="center"
fixed="right"
>
<template #cell="{ record }">
<a-button type="text" size="mini" @click="viewPackDetail(record)">
{{ t('channel.area.pack.viewContent') }}
</a-button>
</template>
</a-table-column>
</template>
<template #empty>
<a-empty :description="t('channel.area.pack.empty')" />
</template>
</a-table>
</a-modal>
<a-modal
v-model:visible="packDetailVisible"
:title="packDetailTitle"
:width="720"
:footer="false"
unmount-on-close
>
<a-spin :loading="packDetailLoading" style="width: 100%">
<pre class="pack-detail-pre">{{
packDetailLoading
? ''
: packDetailJson || t('channel.area.pack.emptyContent')
}}</pre>
</a-spin>
</a-modal>
</template>
</BasicLayoutLeftRight>
</div>
@ -199,6 +307,7 @@
returnEditRowParams,
returnRightEditRowParams,
handleDelData,
handleToolbarDelete,
handleDelRightData,
handlePageChange,
handlePageSizeChange,
@ -209,6 +318,17 @@
onImportSuccess,
exporting,
handleExport,
packModalVisible,
packLoading,
packList,
packDetailVisible,
packDetailLoading,
packDetailTitle,
packDetailJson,
shortHash,
openPackModal,
loadPackList,
viewPackDetail,
} = useKpiAreaPage(props.areaType);
</script>
@ -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;
}
</style>

View File

@ -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<PackRow[]>([]);
const packDetailVisible = ref(false);
const packDetailLoading = ref(false);
const packDetailTitle = ref('');
const packDetailJson = ref('');
function mapPackRow(raw: Record<string, any>): 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,
};
}

View File

@ -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([]);
}

View File

@ -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<SnapshotDiffRow[]>([]);
const pricePublishPending = ref<boolean | null>(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<void>((resolve) => {
window.setTimeout(resolve, 0);
});
}
let publishJsonFormatTask: Promise<void> | 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<string>('');
const originalValue = ref<string | undefined>('');
/** 未格式化的原始快照,供后台 stringify,避免阻塞弹窗 */
const publishPendingRaw = ref<unknown>(null);
const publishOnlineRaw = ref<unknown>(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 @@
<a-button
type="primary"
size="small"
:loading="publishModalLoading"
@click="handlePublishPrice"
>
{{ t('channel.salesProducts.actions.publishPrice') }}
@ -4414,7 +4506,15 @@
{{ t('channel.salesProducts.price.effectiveVersion', { version: effectiveVersion }) }}
<span style="margin-left: 16px; color: var(--color-text-3);">生效时间:{{ effectiveTime }}</span>
<a-tag
v-if="pricePublishPending === true"
v-if="pricePublishChecking && pricePublishPending == null"
color="gray"
size="small"
class="price-sync-tag"
>
{{ t('channel.salesProducts.publish.badge.checking') }}
</a-tag>
<a-tag
v-else-if="pricePublishPending === true"
color="orangered"
size="small"
class="price-sync-tag"
@ -5778,6 +5878,11 @@
:body-style="publishModalBodyStyle"
@register="registerPublishModal"
>
<a-spin
:loading="publishModalLoading"
tip="正在加载发布快照,大价格表可能需要几秒…"
class="publish-modal-spin"
>
<div class="release-content slim">
<!-- 价格:流程卡片 -->
<div v-if="isPricePublishFlow && publishDiffLength > 0" class="publish-flow">
@ -6073,6 +6178,7 @@
</div>
</div>
</div>
</a-spin>
</ModalPro>
<PricePublishAiAssistDrawer
@ -7031,13 +7137,16 @@
flex: 1;
min-height: 0;
width: 100%;
display: block;
overflow: hidden;
display: flex !important;
flex-direction: column;
:deep(.arco-spin-children) {
height: 100%;
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0;
}
}
@ -7247,7 +7356,14 @@
.publish-record-content-spin {
display: block;
height: 100%;
width: 100%;
/* 高度随正文,由外层 .code-content 滚动,避免 spin 把面板撑破 */
height: auto;
min-height: 80px;
:deep(.arco-spin-children) {
height: auto;
}
}
.publish-records-toolbar {
@ -7269,6 +7385,7 @@
.publish-records-container {
flex: 1;
min-height: 0;
height: 100%;
display: flex;
gap: 0;
border: 1px solid var(--color-border-2, #e8e8e8);
@ -7280,6 +7397,8 @@
.publish-records-left {
width: 320px;
flex-shrink: 0;
min-height: 0;
align-self: stretch;
border-right: 1px solid var(--color-border-2, #e8e8e8);
display: flex;
flex-direction: column;
@ -7290,6 +7409,7 @@
.version-list {
flex: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
display: flex;
flex-direction: column;
@ -7414,6 +7534,8 @@
.publish-records-right {
flex: 1;
min-width: 0;
min-height: 0;
align-self: stretch;
display: flex;
flex-direction: column;
padding: 16px 20px;
@ -7567,6 +7689,18 @@
}
// 发布弹窗样式
.publish-modal-spin {
display: block;
width: 100%;
min-height: 240px;
:deep(.arco-spin-container),
:deep(.arco-spin) {
width: 100%;
height: 100%;
}
}
.release-content.slim {
display: flex;
flex-direction: column;

View File

@ -51,7 +51,7 @@ const SKILL_CORE = `你是 OMS「销售产品卖价发布」风险分析助手
4. factors / factorValues / factorValuesJson 维度错位或漏维
5. customerGroupValues 客户分组价漏改、串组、整组清空
6. chargingCondition / costUnit / costItem 语义变化
7. priceArea / priceAreaId / priceAreaInfos 分区错绑
7. priceArea / priceAreaId / priceAreaRefs 分区包引用错绑
8. 首次发布(无线上快照)时,检查待发布是否像「未完成草稿」
9. 相对生效版的「大面积替换」感(大量行删除+新增)
@ -120,7 +120,7 @@ function slimPriceSnapshot(raw: unknown): unknown {
salesProductId: obj.salesProductId ?? obj.SalesProductId,
priceAreaId: obj.priceAreaId ?? obj.PriceAreaId,
priceAreaName: obj.priceAreaName ?? obj.PriceAreaName,
priceAreaInfos: obj.priceAreaInfos ?? obj.PriceAreaInfos,
priceAreaRefs: obj.priceAreaRefs ?? obj.PriceAreaRefs,
prices: slimPrices,
}
}

View File

@ -287,7 +287,15 @@
{{ t('channel.salesProducts.price.effectiveVersion', { version: effectiveVersion }) }}
<span style="margin-left: 16px; color: var(--color-text-3);">生效时间:{{ effectiveTime }}</span>
<a-tag
v-if="pricePublishPending === true"
v-if="pricePublishChecking && pricePublishPending == null"
color="gray"
size="small"
class="price-sync-tag"
>
{{ t('channel.serviceChannels.publish.badge.checking') }}
</a-tag>
<a-tag
v-else-if="pricePublishPending === true"
color="orangered"
size="small"
class="price-sync-tag"
@ -1419,7 +1427,6 @@ import PriceListHotTable, {
} from '@/views/channel/salesProducts/components/PriceListHotTable.vue'
import {
computeSnapshotDiff,
snapshotsSemanticallyEqual,
formatSnapshotJson,
type SnapshotDiffRow,
} from '@/utils/publishSnapshotDiff'
@ -1450,6 +1457,7 @@ import {
getServiceChannelPrice,
getServiceChannelPriceList,
getServiceChannelPublishPreview,
getServiceChannelPublishSyncStatus,
getServiceChannelPublishPriceList,
getServiceChannelPublishRulesList,
publishServiceChannel,
@ -4066,6 +4074,7 @@ const publishHasOnlineSnapshot = ref(false)
const publishDiffLength = ref(0)
const publishDiffRows = ref<SnapshotDiffRow[]>([])
const pricePublishPending = ref<boolean | null>(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<string, any> | 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
}
}

View File

@ -16,20 +16,38 @@
<div class="h5-openapi__handle" aria-hidden="true" />
<header class="h5-openapi__head">
<h3>{{ t('h5.me.openapi') }}</h3>
<button type="button" class="h5-openapi__close" @click="close">
<icon-close />
</button>
<div class="h5-openapi__tools">
<button
v-if="openapiHasCredential"
type="button"
class="h5-openapi__tool"
:disabled="openapiBusy"
:title="t('user.customer.openapi.resetSecret')"
:aria-label="t('user.customer.openapi.resetSecret')"
@click="openapiPanelRef?.resetSecret()"
>
<icon-refresh />
</button>
<button type="button" class="h5-openapi__close" @click="close">
<icon-close />
</button>
</div>
</header>
<div class="h5-openapi__body">
<CustomerOpenApiCredentialPanel v-if="visible" />
<CustomerOpenApiCredentialPanel
v-if="visible"
ref="openapiPanelRef"
@status="onOpenapiStatus"
/>
</div>
</div>
</a-drawer>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { IconClose } from '@arco-design/web-vue/es/icon'
import { IconClose, IconRefresh } from '@arco-design/web-vue/es/icon'
import CustomerOpenApiCredentialPanel from '@/views/user/customer/components/CustomerOpenApiCredentialPanel.vue'
defineOptions({ name: 'H5OpenApiCredentialSheet' })
@ -43,6 +61,14 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const openapiPanelRef = ref<{ resetSecret: () => void } | null>(null)
const openapiHasCredential = ref(false)
const openapiBusy = ref(false)
function onOpenapiStatus(payload: { hasCredential: boolean; busy: boolean }) {
openapiHasCredential.value = payload.hasCredential
openapiBusy.value = payload.busy
}
const drawerStyle = {
borderRadius: '20px 20px 0 0',
@ -81,11 +107,38 @@ function close() {
font-weight: 600;
}
.h5-openapi__tools {
display: inline-flex;
align-items: center;
gap: 2px;
}
.h5-openapi__tool,
.h5-openapi__close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
margin: 0;
border: none;
border-radius: 8px;
background: transparent;
padding: 4px;
padding: 0;
color: var(--color-text-2);
font-size: 16px;
cursor: pointer;
}
.h5-openapi__tool:hover:not(:disabled),
.h5-openapi__close:hover {
color: var(--app-primary-color);
background: color-mix(in srgb, var(--app-primary-color) 8%, transparent);
}
.h5-openapi__tool:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.h5-openapi__body {

View File

@ -9,7 +9,22 @@
<template v-else>
<section class="h5-me-hero h5-enter" :class="{ 'is-low': isLowBalance }">
<span v-if="levelBadge" class="h5-me-vip">{{ levelBadge }}</span>
<span v-if="levelBadge" class="h5-me-vip" :class="levelToneClass" :style="levelToneStyle">
<img
v-if="memberLevel.iconUrl"
class="h5-me-vip__icon"
:src="memberLevel.iconUrl"
alt=""
draggable="false"
/>
<span
v-else-if="levelGlyphHtml"
class="h5-me-vip__icon"
aria-hidden="true"
v-html="levelGlyphHtml"
/>
<em class="h5-me-vip__name">{{ levelBadge }}</em>
</span>
<div class="h5-me-hero__top">
<img :src="displayAvatar" alt="" class="h5-me-avatar" />
@ -241,6 +256,11 @@ import {
} from '@/api/customer/resolve';
import { getPickupOrderList } from '@/api/order/pickupOrder';
import { getMyCoupons, getMemberOverview } from '@/api/marketing/portal';
import {
getMemberLevelTone,
pickMemberLevelFromOverview,
renderMemberLevelGlyphSvg,
} from '@/utils/memberLevelIcon';
import { isLogin } from '@/utils/auth';
import { resolveAvatarUrl } from '@/utils/avatar';
import { getMapIdFromAccessToken } from '@/utils/jwt';
@ -412,10 +432,26 @@ const customerGroupLabel = computed(() => {
return label !== key ? label : '';
});
const memberLevelName = computed(() =>
String(memberOverview.value?.memberLevelName ?? memberOverview.value?.MemberLevelName ?? ''),
);
const memberLevel = computed(() => pickMemberLevelFromOverview(memberOverview.value));
const memberLevelName = computed(() => memberLevel.value.name);
const levelBadge = computed(() => memberLevelName.value || customerGroupLabel.value);
const levelTone = computed(() =>
getMemberLevelTone(memberLevel.value.code, levelBadge.value),
);
const levelToneClass = computed(() => (levelTone.value ? `is-${levelTone.value.key}` : ''));
const levelToneStyle = computed(() => {
if (!levelTone.value) return undefined;
const c = levelTone.value.color;
return {
'--me-vip-accent': c,
'--me-vip-from': `color-mix(in srgb, ${c} 78%, #1a1a1a)`,
'--me-vip-to': c,
'--me-vip-glow': `color-mix(in srgb, ${c} 35%, transparent)`,
} as Record<string, string>;
});
const levelGlyphHtml = computed(() =>
levelTone.value ? renderMemberLevelGlyphSvg(levelTone.value.key, 12) : '',
);
const totalPoints = computed(() =>
Number(
@ -755,19 +791,22 @@ onMounted(() => {
margin-top: 6px;
}
/* 右上角错落等级角标:允许探出边框 */
/* 右上角错落等级角标:底色跟等级色走,不再被墨色冲淡 */
.h5-me-vip {
position: absolute;
z-index: 3;
top: -8px;
right: -2px;
max-width: 48%;
padding: 6px 14px 7px 12px;
display: inline-flex;
align-items: center;
gap: 5px;
max-width: 56%;
padding: 6px 14px 7px 10px;
border-radius: 10px 4px 4px 12px;
background: linear-gradient(
135deg,
var(--me-ink) 0%,
color-mix(in srgb, var(--me-ink) 72%, var(--me-teal)) 100%
var(--me-vip-from, var(--me-ink)) 0%,
var(--me-vip-to, var(--me-teal)) 100%
);
color: #fff;
font-size: 11px;
@ -779,10 +818,36 @@ onMounted(() => {
text-overflow: ellipsis;
transform: none;
box-shadow:
0 4px 12px rgba(20, 38, 55, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.18);
0 4px 12px var(--me-vip-glow, rgba(20, 38, 55, 0.2)),
inset 0 1px 0 rgba(255, 255, 255, 0.22);
pointer-events: none;
.h5-me-vip__icon {
flex-shrink: 0;
width: 12px;
height: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
object-fit: contain;
color: #fff;
filter: drop-shadow(0 1px 0 rgba(0, 0, 0, 0.12));
:deep(svg) {
display: block;
width: 100%;
height: 100%;
}
}
.h5-me-vip__name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-style: normal;
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.12);
}
&::before {
content: '';
position: absolute;

View File

@ -8,6 +8,14 @@
<p class="banner-hello__line">
<span class="banner-hello__greet">{{ greetText }}</span>
<span class="banner-hello__name">{{ displayName }}</span>
<MemberLevelBadge
v-if="showBalance && memberLevel.name"
class="banner-hello__level"
:level-code="memberLevel.code"
:level-name="memberLevel.name"
:icon-url="memberLevel.iconUrl"
size="sm"
/>
</p>
<div v-if="!compact" class="banner-local-clock" aria-live="polite">
<span class="banner-local-clock__label">{{ t('workplace.clock.local') }}</span>
@ -35,15 +43,18 @@
</template>
<script lang="ts" setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useUserStore } from '@/store';
import { useRbacUserStore } from '@/store/modules/rbac-user';
import { resolveCurrentCustomer, peekCachedCurrentCustomer } from '@/api/customer/resolve';
import { CUSTOMER_BALANCE_CHANGED_EVENT } from '@/api/customer/accountFlow';
import { getMemberOverview } from '@/api/marketing/portal';
import { getMapIdFromAccessToken } from '@/utils/jwt';
import { isValidAvatarUrl, resolveAvatarUrl } from '@/utils/avatar';
import { pickMemberLevelFromOverview } from '@/utils/memberLevelIcon';
import MemberLevelBadge from '@/components/MemberLevelBadge/index.vue';
defineProps<{
/** 员工工作台:一行问候,避免与关照重复 */
@ -61,6 +72,7 @@
const customerBalance = ref<number | null>(null);
const balanceLoading = ref(false);
const wechatAvatarRaw = ref('');
const memberLevel = reactive({ code: '', name: '', iconUrl: '' });
const now = ref(new Date());
let timerId: number | undefined;
@ -129,10 +141,31 @@
wechatAvatarRaw.value = isValidAvatarUrl(next) ? String(next).trim() : '';
}
function applyMemberLevel(overview: Record<string, unknown> | null) {
const next = pickMemberLevelFromOverview(overview);
memberLevel.code = next.code;
memberLevel.name = next.name;
memberLevel.iconUrl = next.iconUrl;
}
async function loadMemberLevel() {
if (!rbacUserStore.isCustomerPortal() || !rbacUserStore.isLoggedIn) {
applyMemberLevel(null);
return;
}
try {
const overview = (await getMemberOverview()) as Record<string, unknown>;
applyMemberLevel(overview);
} catch {
applyMemberLevel(null);
}
}
async function loadCustomerProfile() {
if (!rbacUserStore.isCustomerPortal() || !rbacUserStore.isLoggedIn) {
customerBalance.value = null;
wechatAvatarRaw.value = '';
applyMemberLevel(null);
return;
}
const mapId = getMapIdFromAccessToken(rbacUserStore.accessToken);
@ -148,6 +181,7 @@
}
balanceLoading.value = true;
void loadMemberLevel();
try {
const data = await resolveCurrentCustomer({ mapId, account, force: true });
applyWechatAvatar(data);
@ -249,12 +283,17 @@
.banner-hello__line {
display: flex;
align-items: baseline;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin: 0;
min-width: 0;
}
.banner-hello__level {
flex-shrink: 0;
}
.banner-hello__greet {
flex-shrink: 0;
color: var(--color-text-3);

View File

@ -91,8 +91,17 @@
}}
</div>
<div class="ml-preview__main">
<div class="ml-preview__icon" aria-hidden="true">
<div
class="ml-preview__icon"
:style="previewToneStyle"
aria-hidden="true"
>
<img v-if="formState.iconUrl" :src="formState.iconUrl" alt="" />
<span
v-else-if="previewGlyphHtml"
class="ml-preview__glyph"
v-html="previewGlyphHtml"
/>
<span v-else>{{ previewInitial }}</span>
</div>
<div class="ml-preview__meta">
@ -299,6 +308,11 @@ import {
getMemberLevelList,
updateMemberLevel,
} from '@/api/marketing/memberLevel'
import {
getMemberLevelTone,
renderMemberLevelCellHtml,
renderMemberLevelGlyphSvg,
} from '@/utils/memberLevelIcon'
import MarketingPageShell from '../components/MarketingPageShell.vue'
import MarketingPageTipIcon from '../components/MarketingPageTipIcon.vue'
@ -363,6 +377,17 @@ const previewInitial = computed(() => {
return code ? code.slice(0, 1).toUpperCase() : 'M'
})
const previewTone = computed(() =>
getMemberLevelTone(formState.levelCode, formState.levelName),
)
const previewToneStyle = computed(() => {
if (!previewTone.value) return undefined
return { color: '#fff' }
})
const previewGlyphHtml = computed(() =>
previewTone.value ? renderMemberLevelGlyphSvg(previewTone.value.key, 28) : '',
)
const previewDiscountText = computed(() => formatFreightOff(formState.extraDiscountRate))
function formatFreightOff(rate: unknown) {
@ -414,7 +439,18 @@ function mapRow(item: any): MemberLevelRow {
const hotColumns = computed(() => [
{ key: 'levelCode', label: t('marketing.memberLevel.form.levelCode'), width: 100 },
{ key: 'levelName', label: t('marketing.memberLevel.form.levelName'), width: 120 },
{
key: 'levelName',
label: t('marketing.memberLevel.form.levelName'),
width: 160,
renderer: (row: MemberLevelRow) =>
renderMemberLevelCellHtml({
levelCode: row.levelCode,
levelName: row.levelName,
iconUrl: row.iconUrl,
size: 16,
}),
},
{ key: 'minPoints', label: t('marketing.memberLevel.form.minPoints'), width: 100 },
{
key: 'extraDiscountRate',
@ -699,6 +735,7 @@ onMounted(() => loadList())
font-size: 20px;
font-weight: 700;
overflow: hidden;
color: #fff;
img {
width: 100%;
@ -707,6 +744,18 @@ onMounted(() => loadList())
}
}
.ml-preview__glyph {
display: inline-flex;
width: 28px;
height: 28px;
:deep(svg) {
display: block;
width: 100%;
height: 100%;
}
}
.ml-preview__name {
margin: 0;
font-size: 18px;
@ -848,4 +897,49 @@ onMounted(() => loadList())
overflow: hidden;
}
}
.ml-level-cell {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
min-height: 24px;
padding: 2px 8px 2px 4px;
border-radius: 999px;
border: 1px solid var(--ml-border);
background: var(--ml-bg);
color: var(--ml-color);
vertical-align: middle;
line-height: 1.2;
}
.ml-level-glyph {
flex-shrink: 0;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
object-fit: contain;
svg {
display: block;
width: 100%;
height: 100%;
}
}
.ml-level-glyph--fallback {
font-size: 10px;
font-weight: 800;
}
.ml-level-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
font-weight: 700;
}
</style>

View File

@ -1,10 +1,6 @@
<template>
<div class="openapi-cred-panel">
<a-spin :loading="loading" class="openapi-cred-spin">
<a-alert type="info" class="openapi-cred-alert">
{{ t('user.customer.openapi.tip') }}
</a-alert>
<template v-if="!credential.hasCredential">
<p class="openapi-cred-empty">{{ t('user.customer.openapi.empty') }}</p>
<a-button
@ -21,9 +17,15 @@
<a-form-item :label="t('user.customer.openapi.appKey')">
<a-input-group>
<a-input :model-value="credential.appKey || ''" readonly />
<a-button @click="copyText(credential.appKey)">
{{ t('user.customer.openapi.copy') }}
</a-button>
<button
type="button"
class="openapi-icon-btn"
:title="t('user.customer.openapi.copy')"
:aria-label="t('user.customer.openapi.copy')"
@click="copyText(credential.appKey)"
>
<icon-copy />
</button>
</a-input-group>
</a-form-item>
@ -34,12 +36,16 @@
readonly
:type="credential.secretRevealed ? 'text' : 'password'"
/>
<a-button
<button
v-if="credential.secretRevealed"
type="button"
class="openapi-icon-btn"
:title="t('user.customer.openapi.copy')"
:aria-label="t('user.customer.openapi.copy')"
@click="copyText(credential.appSecret)"
>
{{ t('user.customer.openapi.copy') }}
</a-button>
<icon-copy />
</button>
</a-input-group>
<template #extra>
<span v-if="credential.secretRevealed" class="openapi-cred-warn">
@ -71,12 +77,6 @@
</span>
</a-form-item>
</a-form>
<a-space>
<a-button :loading="busy" @click="handleReset">
{{ t('user.customer.openapi.resetSecret') }}
</a-button>
</a-space>
</template>
</a-spin>
</div>
@ -86,6 +86,7 @@
import { onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Message, Modal } from '@arco-design/web-vue'
import { IconCopy } from '@arco-design/web-vue/es/icon'
import type { CustomerOpenApiCredentialDto } from '@/api/customer'
import { CustomerService } from '@/api/customer'
import { useRbacUserStore } from '@/store/modules/rbac-user'
@ -97,6 +98,10 @@ const props = defineProps<{
customerId?: string | number | null
}>()
const emit = defineEmits<{
status: [payload: { hasCredential: boolean; busy: boolean }]
}>()
const { t } = useI18n()
const loading = ref(false)
const busy = ref(false)
@ -116,6 +121,13 @@ function canCallMineApi() {
return rbacUserStore.isCustomerPortal()
}
function emitStatus() {
emit('status', {
hasCredential: Boolean(credential.hasCredential),
busy: busy.value,
})
}
function applyCredential(data: CustomerOpenApiCredentialDto) {
Object.assign(credential, {
id: data.id,
@ -130,6 +142,7 @@ function applyCredential(data: CustomerOpenApiCredentialDto) {
createTime: data.createTime,
updateTime: data.updateTime,
})
emitStatus()
}
async function load() {
@ -225,11 +238,14 @@ watch(
},
)
watch(busy, () => emitStatus())
onMounted(() => {
emitStatus()
void load()
})
defineExpose({ reload: load })
defineExpose({ reload: load, resetSecret: handleReset })
</script>
<style scoped>
@ -241,17 +257,41 @@ defineExpose({ reload: load })
width: 100%;
}
.openapi-cred-alert {
margin-bottom: 16px;
}
.openapi-cred-empty {
margin: 0 0 12px;
color: var(--color-text-3);
}
.openapi-cred-form {
margin-bottom: 12px;
margin-bottom: 0;
}
.openapi-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 32px;
height: 32px;
margin: 0;
padding: 0;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--color-text-3);
font-size: 14px;
cursor: pointer;
transition: color 0.15s ease, background 0.15s ease;
}
.openapi-icon-btn:hover:not(:disabled) {
color: var(--app-primary-color);
background: color-mix(in srgb, var(--app-primary-color) 8%, transparent);
}
.openapi-icon-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.openapi-cred-warn {

View File

@ -9,6 +9,20 @@
<div class="level-main">
<p class="level-eyebrow">{{ t('marketing.portal.member.currentLevel') }}</p>
<div class="level-name-row">
<img
v-if="memberLevel.iconUrl"
class="level-icon"
:src="memberLevel.iconUrl"
alt=""
draggable="false"
/>
<span
v-else-if="levelGlyphHtml"
class="level-icon"
:style="{ color: levelTone?.color }"
aria-hidden="true"
v-html="levelGlyphHtml"
/>
<h2 class="level-name">{{ levelName }}</h2>
</div>
</div>
@ -118,6 +132,11 @@ import { useRouter } from 'vue-router'
import { Message } from '@arco-design/web-vue'
import PaginationPro from '@/components/PaginationPro/index.vue'
import { getMemberOverview, getMemberPointsFlow } from '@/api/marketing/portal'
import {
getMemberLevelTone,
pickMemberLevelFromOverview,
renderMemberLevelGlyphSvg,
} from '@/utils/memberLevelIcon'
import PortalHowTo from '../components/PortalHowTo.vue'
import {
PORTAL_DRAWER_KEY,
@ -160,10 +179,15 @@ const howSteps = computed(() => [
},
])
const memberLevel = computed(() => pickMemberLevelFromOverview(overview.value))
const levelName = computed(
() =>
String(overview.value?.memberLevelName ?? overview.value?.MemberLevelName ?? '') ||
t('marketing.portal.member.defaultLevel'),
() => memberLevel.value.name || t('marketing.portal.member.defaultLevel'),
)
const levelTone = computed(() =>
getMemberLevelTone(memberLevel.value.code, levelName.value),
)
const levelGlyphHtml = computed(() =>
levelTone.value ? renderMemberLevelGlyphSvg(levelTone.value.key, 22) : '',
)
const rollingPoints = computed(() =>
Number(
@ -318,6 +342,22 @@ onMounted(async () => {
min-width: 0;
}
.level-icon {
flex-shrink: 0;
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
object-fit: contain;
:deep(svg) {
display: block;
width: 100%;
height: 100%;
}
}
.level-name {
margin: 0;
font-size: 22px;

View File

@ -19,29 +19,66 @@
<h2 class="identity-name">
{{ profile.customerName || profile.wechatNickname || t('profile.defaultName') }}
</h2>
<span v-if="customerGroupLabel" class="vip-badge">{{ customerGroupLabel }}</span>
<MemberLevelBadge
v-if="memberLevel.name"
:level-code="memberLevel.code"
:level-name="memberLevel.name"
:icon-url="memberLevel.iconUrl"
size="sm"
/>
<span v-else-if="customerGroupLabel" class="vip-badge">{{ customerGroupLabel }}</span>
</div>
<p class="welcome-title">{{ greetingText }}</p>
<div v-if="profile.customerCode" class="code-row">
<button
v-if="profile.customerCode"
type="button"
class="code-row"
:title="profile.customerCode"
@click="copyText(profile.customerCode)"
>
<span>{{ profile.customerCode }}</span>
<a-button type="text" size="mini" class="copy-btn" @click="copyText(profile.customerCode)">
<icon-copy />
</a-button>
</div>
<icon-copy class="code-row__icon" />
</button>
</div>
<div class="profile-hero__tools">
<button
type="button"
class="profile-icon-btn"
:title="t('profile.edit')"
:aria-label="t('profile.edit')"
@click="openEditModal"
>
<icon-edit />
</button>
<button
type="button"
class="profile-icon-btn"
:title="t('profile.resetPassword')"
:aria-label="t('profile.resetPassword')"
@click="openResetPasswordModal"
>
<icon-lock />
</button>
</div>
</div>
<div class="metric-block metric-block--clickable" @click="goRecharge">
<span class="metric-label">{{ t('user.customer.fields.accountBalance') }}</span>
<span class="metric-value">¥{{ formatMoney(profile.accountBalance ?? profile.balance) }}</span>
<a-button size="mini" type="outline" class="metric-action" @click.stop="goRecharge">
{{ t('nav.balance.recharge') }}
</a-button>
<p v-if="isLowBalance" class="metric-tip">{{ t('profile.lowBalanceTip') }}</p>
</div>
<div class="identity-actions">
<a-button size="small" type="primary" @click="openEditModal">{{ t('profile.edit') }}</a-button>
<a-button size="small" type="outline" @click="openResetPasswordModal">{{ t('profile.resetPassword') }}</a-button>
</div>
<button
type="button"
class="balance-focus"
:class="{ 'is-low': isLowBalance }"
:title="t('nav.balance.recharge')"
@click="goRecharge"
>
<div class="balance-focus__main">
<span class="balance-focus__label">{{ t('user.customer.fields.accountBalance') }}</span>
<strong class="balance-focus__value">
¥{{ formatMoney(profile.accountBalance ?? profile.balance) }}
</strong>
<span v-if="isLowBalance" class="balance-focus__tip">{{ t('profile.lowBalanceTip') }}</span>
</div>
<span class="profile-icon-btn profile-icon-btn--accent" aria-hidden="true">
<icon-plus />
</span>
</button>
</section>
<div class="profile-grid">
@ -122,9 +159,23 @@
<section v-if="showOpenApiCredential" class="profile-card profile-card--full">
<div class="card-header">
<h3 class="card-title">{{ t('user.customer.openapi.title') }}</h3>
<button
v-if="openapiHasCredential"
type="button"
class="profile-icon-btn"
:disabled="openapiBusy"
:title="t('user.customer.openapi.resetSecret')"
:aria-label="t('user.customer.openapi.resetSecret')"
@click="openapiPanelRef?.resetSecret()"
>
<icon-refresh />
</button>
</div>
<div class="card-body">
<CustomerOpenApiCredentialPanel />
<CustomerOpenApiCredentialPanel
ref="openapiPanelRef"
@status="onOpenapiStatus"
/>
</div>
</section>
</div>
@ -197,7 +248,11 @@ import { useI18n } from 'vue-i18n'
import { Message } from '@arco-design/web-vue'
import {
IconCopy,
IconEdit,
IconExclamationCircleFill,
IconLock,
IconPlus,
IconRefresh,
} from '@arco-design/web-vue/es/icon'
import type { CustomerDto } from '@/api/customer'
import { CustomerService } from '@/api/customer'
@ -208,6 +263,9 @@ import { AuthService } from '@/api/rbac'
import { resolveAvatarUrl } from '@/utils/avatar'
import { getMapIdFromAccessToken } from '@/utils/jwt'
import { resolvePostLogoutLogin } from '@/utils/login-portal'
import { pickMemberLevelFromOverview } from '@/utils/memberLevelIcon'
import { getMemberOverview } from '@/api/marketing/portal'
import MemberLevelBadge from '@/components/MemberLevelBadge/index.vue'
import {
PORTAL_DRAWER_KEY,
type PortalDrawerContext,
@ -230,6 +288,14 @@ const rbacUserStore = useRbacUserStore()
const profile = ref<CustomerDto>({})
const loading = ref(true)
const loadError = ref('')
const openapiPanelRef = ref<{ resetSecret: () => void } | null>(null)
const openapiHasCredential = ref(false)
const openapiBusy = ref(false)
function onOpenapiStatus(payload: { hasCredential: boolean; busy: boolean }) {
openapiHasCredential.value = payload.hasCredential
openapiBusy.value = payload.busy
}
const isCustomerPortal = computed(() => rbacUserStore.isCustomerPortal())
@ -263,17 +329,6 @@ const displayAvatar = computed(() =>
resolveAvatarUrl(profile.value.wechatAvatarUrl ?? profile.value.wechatAvatar)
)
const displayName = computed(
() => profile.value.customerName || profile.value.wechatNickname || t('profile.defaultName')
)
const greetingText = computed(() => {
const hour = new Date().getHours()
if (hour < 12) return t('profile.greetingMorning', { name: displayName.value })
if (hour < 18) return t('profile.greetingAfternoon', { name: displayName.value })
return t('profile.greetingEvening', { name: displayName.value })
})
const customerGroupLabel = computed(() => {
const g = profile.value.customerGroup
if (g == null) return ''
@ -282,6 +337,8 @@ const customerGroupLabel = computed(() => {
return label !== key ? label : ''
})
const memberLevel = reactive({ code: '', name: '', iconUrl: '' })
const memberSince = computed(() => profile.value.createdAt || profile.value.createTime || '')
const accountBalance = computed(() => Number(profile.value.accountBalance ?? profile.value.balance ?? 0))
@ -433,6 +490,20 @@ async function handleResetPasswordSubmit() {
}
}
async function loadMemberLevel() {
try {
const overview = (await getMemberOverview()) as Record<string, unknown>
const next = pickMemberLevelFromOverview(overview)
memberLevel.code = next.code
memberLevel.name = next.name
memberLevel.iconUrl = next.iconUrl
} catch {
memberLevel.code = ''
memberLevel.name = ''
memberLevel.iconUrl = ''
}
}
async function loadProfile() {
loading.value = true
loadError.value = ''
@ -444,6 +515,7 @@ async function loadProfile() {
return
}
try {
void loadMemberLevel()
const data = await resolveCurrentCustomer({ mapId, account })
if (!data) {
throw new Error(t('profile.notFound'))
@ -515,9 +587,9 @@ onMounted(() => {
.profile-hero {
display: flex;
flex-direction: column;
gap: 14px;
gap: 12px;
margin-bottom: 12px;
padding: 16px;
padding: 14px 16px;
background: var(--app-card-bg);
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.78);
@ -532,13 +604,117 @@ onMounted(() => {
gap: 12px;
}
.code-row {
display: flex;
.profile-hero__tools {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 4px;
flex-shrink: 0;
margin-left: auto;
}
.profile-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
margin: 0;
padding: 0;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--color-text-3);
font-size: 16px;
cursor: pointer;
transition: color 0.15s ease, background 0.15s ease;
&:hover {
color: var(--app-primary-color);
background: color-mix(in srgb, var(--app-primary-color) 10%, transparent);
}
&--accent {
color: var(--app-primary-color);
background: color-mix(in srgb, var(--app-primary-color) 12%, #fff);
}
}
.code-row {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 4px 0 0;
padding: 0;
border: 0;
background: none;
font-size: 12px;
color: var(--color-text-3);
cursor: pointer;
&:hover {
color: var(--app-primary-color);
}
.code-row__icon {
font-size: 12px;
}
}
.balance-focus {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
margin: 0;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--app-primary-color) 16%, var(--color-border-2));
background: color-mix(in srgb, var(--app-primary-color) 7%, var(--app-card-bg));
text-align: left;
cursor: pointer;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
&:hover {
border-color: var(--app-primary-color);
box-shadow: 0 2px 8px color-mix(in srgb, var(--app-primary-color) 14%, transparent);
}
&.is-low {
border-color: color-mix(in srgb, #ff7d00 35%, var(--color-border-2));
background: color-mix(in srgb, #ff7d00 8%, var(--app-card-bg));
}
}
.balance-focus__main {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.balance-focus__label {
font-size: 12px;
color: var(--color-text-3);
}
.balance-focus__value {
font-size: 24px;
font-weight: 700;
line-height: 1.2;
letter-spacing: -0.02em;
color: var(--color-text-1);
}
.balance-focus.is-low .balance-focus__value {
color: #d97808;
}
.balance-focus__tip {
margin-top: 2px;
font-size: 11px;
line-height: 1.35;
color: #ff7d00;
}
.profile-card {
@ -555,6 +731,10 @@ onMounted(() => {
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 14px 16px;
border-bottom: 1px solid var(--color-border-2);
flex-shrink: 0;
@ -587,12 +767,12 @@ onMounted(() => {
}
.avatar-img {
width: 56px;
height: 56px;
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
border: 3px solid color-mix(in srgb, var(--app-primary-color) 20%, var(--app-card-bg));
border: 2px solid color-mix(in srgb, var(--app-primary-color) 18%, var(--app-card-bg));
}
.identity-brief {
@ -600,13 +780,6 @@ onMounted(() => {
flex: 1;
}
.welcome-title {
margin: 4px 0 0;
font-size: 12px;
font-weight: 400;
color: var(--color-text-3);
}
.identity-name-row {
display: flex;
align-items: center;
@ -618,8 +791,9 @@ onMounted(() => {
.identity-name {
margin: 0;
font-size: 16px;
font-weight: 600;
font-weight: 700;
color: var(--color-text-1);
line-height: 1.25;
}
.identity-nickname {
@ -642,17 +816,6 @@ onMounted(() => {
flex: 1;
}
.identity-actions {
display: flex;
gap: 8px;
margin-top: auto;
padding-top: 4px;
:deep(.arco-btn) {
flex: 1;
}
}
.account-body {
gap: 12px;
}
@ -661,20 +824,6 @@ onMounted(() => {
display: flex;
flex-direction: column;
gap: 6px;
&--clickable {
padding: 12px;
border-radius: 8px;
background: color-mix(in srgb, var(--app-primary-color) 8%, var(--app-card-bg));
border: 1px solid color-mix(in srgb, var(--app-primary-color) 18%, var(--color-border-2));
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s;
&:hover {
border-color: var(--app-primary-color);
box-shadow: 0 2px 8px color-mix(in srgb, var(--app-primary-color) 16%, transparent);
}
}
}
.metric-label {
@ -689,11 +838,6 @@ onMounted(() => {
line-height: 1.2;
}
.metric-action {
align-self: flex-start;
margin-top: 2px;
}
.metric-tip {
margin: 0;
font-size: 11px;