From ef19f6860e3e4ac1c54203625e989e5e04898335 Mon Sep 17 00:00:00 2001 From: Ghost <106998207@qq.com> Date: Wed, 1 Apr 2026 17:16:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20CRM=20=E6=8A=A5=E5=91=8A=E4=B8=8E?= =?UTF-8?q?=E5=8F=8C=E7=AB=AF=E7=BB=93=E6=9E=9C=E9=A1=B5=E3=80=81=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E7=AB=AF=E7=94=A8=E6=88=B7/=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E4=B8=8E=E9=BB=98=E8=AE=A4=E4=BC=81=E4=B8=9A=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1、修复了用户详情与人脸/DISC 展示、手机号与资料校验、相机页与测试选择等体验问题。 2、新增了 SystemDefaultEnterprise、discDisplay、管理端设置扩展接口;CrmReport/Test/Analyze 等 API 与路由能力补强。 3、优化了 resultFormat、descriptions、payment、企业上下文;海报与 PDP/DISC 文案;超管 Commerce/Users 与 admin Settings/Users。 Made-with: Cursor --- admin/src/components/UserDetailDialog.vue | 22 +- admin/src/utils/discDisplay.ts | 128 +++ admin/src/utils/faceResultDetail.ts | 85 +- admin/src/views/admin/Settings.vue | 179 +++- admin/src/views/admin/Users.vue | 165 +++- admin/src/views/superadmin/CommerceHub.vue | 3 +- admin/src/views/superadmin/Settings.vue | 33 +- admin/src/views/superadmin/Users.vue | 175 +++- api/app/common/PdpDiscResultText.php | 2 +- api/app/common/SystemDefaultEnterprise.php | 40 + api/app/common/service/PosterService.php | 2 +- api/app/controller/admin/Settings.php | 109 +++ api/app/controller/api/Analyze.php | 58 +- api/app/controller/api/CrmReport.php | 828 +++++++++++++----- api/app/controller/api/Test.php | 74 +- api/app/controller/superadmin/Settings.php | 16 +- api/app/model/SystemConfig.php | 8 +- api/route/api.php | 2 + douyin-miniprogram/pages/index/camera.js | 9 +- douyin-miniprogram/pages/index/camera.ttml | 7 - douyin-miniprogram/pages/index/camera.ttss | 25 - douyin-miniprogram/pages/result/disc.js | 38 +- douyin-miniprogram/pages/result/disc.ttml | 16 +- douyin-miniprogram/pages/result/mbti.js | 24 +- douyin-miniprogram/pages/result/mbti.ttml | 18 +- douyin-miniprogram/pages/result/pdp.js | 27 +- douyin-miniprogram/pages/result/pdp.ttml | 16 +- douyin-miniprogram/pages/result/resume.js | 58 +- douyin-miniprogram/pages/result/resume.ttml | 19 +- .../pages/test-select/index.ttml | 2 +- douyin-miniprogram/pages/test/disc.ttml | 9 +- douyin-miniprogram/pages/test/mbti.js | 2 +- douyin-miniprogram/pages/test/mbti.ttml | 9 +- douyin-miniprogram/pages/test/pdp.ttml | 9 +- .../pages/user-profile/index.js | 26 +- douyin-miniprogram/utils/descriptions.js | 88 +- douyin-miniprogram/utils/enterpriseContext.js | 3 +- douyin-miniprogram/utils/payment.js | 40 + douyin-miniprogram/utils/phoneAuth.js | 6 + douyin-miniprogram/utils/resultFormat.js | 510 +++++------ miniprogram/pages/index/camera.js | 32 +- miniprogram/pages/index/camera.wxml | 8 - miniprogram/pages/index/camera.wxss | 25 - miniprogram/pages/index/result.wxss | 14 +- miniprogram/pages/result/disc.js | 36 +- miniprogram/pages/result/disc.wxml | 16 +- miniprogram/pages/result/mbti.js | 22 +- miniprogram/pages/result/mbti.wxml | 16 +- miniprogram/pages/result/pdp.js | 27 +- miniprogram/pages/result/pdp.wxml | 18 +- miniprogram/pages/result/resume.js | 58 +- miniprogram/pages/result/resume.wxml | 18 +- miniprogram/pages/test-select/index.wxml | 2 +- miniprogram/pages/test/disc.wxml | 9 +- miniprogram/pages/test/mbti.js | 2 +- miniprogram/pages/test/mbti.wxml | 9 +- miniprogram/pages/test/pdp.wxml | 9 +- miniprogram/pages/user-profile/index.js | 26 +- miniprogram/utils/descriptions.js | 88 +- miniprogram/utils/enterpriseContext.js | 3 +- miniprogram/utils/payment.js | 30 + miniprogram/utils/phoneAuth.js | 14 + miniprogram/utils/resultFormat.js | 536 ++++++------ 63 files changed, 2718 insertions(+), 1190 deletions(-) create mode 100644 admin/src/utils/discDisplay.ts create mode 100644 api/app/common/SystemDefaultEnterprise.php diff --git a/admin/src/components/UserDetailDialog.vue b/admin/src/components/UserDetailDialog.vue index 72b3e91..8a67b2a 100644 --- a/admin/src/components/UserDetailDialog.vue +++ b/admin/src/components/UserDetailDialog.vue @@ -178,6 +178,8 @@ fit="cover" class="ud-photo-gallery__thumb" :preview-src-list="facePhotos" + :initial-index="idx" + preview-teleported />
暂无人脸分析照片
@@ -218,6 +220,8 @@ import { RadarComponent } from 'echarts/components' import VChart from 'vue-echarts' +import { discTopTwoLabel, discCompactLabel } from '@/utils/discDisplay' +import { buildFaceDetailFromParsed } from '@/utils/faceResultDetail' use([CanvasRenderer, RadarChart, GridComponent, TooltipComponent, LegendComponent, RadarComponent]) @@ -327,6 +331,8 @@ function extractTestSummary(test: any): string { const type = (test?.testType || '').toLowerCase() if (type === 'mbti') return String(data.mbtiType ?? data.type ?? data.result ?? '') if (type === 'disc') { + const two = discTopTwoLabel(data) + if (two) return two const desc = data.description?.type if (typeof desc === 'string' && desc) return desc if (data.dominantType) return String(data.dominantType) + '型' @@ -452,7 +458,7 @@ const PDP_LABELS: Record = { Tiger: '虎', Peacock: '孔雀', Owl: '猫头鹰', - Koala: '考拉', + Koala: '无尾熊', Chameleon: '变色龙' } @@ -464,7 +470,7 @@ const pdpRadarOption = computed(() => { if (vals.every(v => v === 0)) { const dom = String(p.dominantType ?? p.description?.type ?? '') if (!dom) return null - const idx = ['老虎', '孔雀', '猫头鹰', '考拉', '变色龙'].findIndex(x => dom.includes(x)) + const idx = ['老虎', '孔雀', '猫头鹰', '无尾熊', '考拉', '变色龙'].findIndex(x => dom.includes(x)) if (idx < 0) return null const v2 = [15, 15, 15, 15, 15] v2[idx] = 55 @@ -500,10 +506,10 @@ const discRadarOption = computed(() => { color: ['#2563eb'], radar: { indicator: [ - { name: 'D', max: 100 }, - { name: 'I', max: 100 }, - { name: 'S', max: 100 }, - { name: 'C', max: 100 } + { name: discCompactLabel('D'), max: 100 }, + { name: discCompactLabel('I'), max: 100 }, + { name: discCompactLabel('S'), max: 100 }, + { name: discCompactLabel('C'), max: 100 } ], radius: 58, axisName: { fontSize: 11, color: '#6b7280' } @@ -535,8 +541,8 @@ const roleFitList = computed(() => { const facePhotos = computed(() => { const f = latestFace.value - const urls = f?.photoUrls - return Array.isArray(urls) ? urls : [] + const detail = f ? buildFaceDetailFromParsed(f) : null + return detail?.photos?.length ? detail.photos : [] }) function testIcon(testType: string) { diff --git a/admin/src/utils/discDisplay.ts b/admin/src/utils/discDisplay.ts new file mode 100644 index 0000000..f8a7bb3 --- /dev/null +++ b/admin/src/utils/discDisplay.ts @@ -0,0 +1,128 @@ +/** + * 管理后台 DISC 展示:双字母摘要与「力量 / 活跃 / 和平 / 完美」称谓。 + * 逻辑对齐 miniprogram/utils/resultFormat.js 与 api PdpDiscResultText::discResolveTwoLetters。 + */ + +export const DISC_STYLE_NAMES: Record = { + D: '力量', + I: '活跃', + S: '和平', + C: '完美' +} + +const ALLOW = new Set(['D', 'I', 'S', 'C']) + +function discOrderedFromScores(scores: Record | null | undefined): [string, string] { + if (!scores || typeof scores !== 'object') return ['', ''] + const pairs: [string, number][] = [] + for (const k of Object.keys(scores)) { + const u = String(k).trim().toUpperCase().charAt(0) + if (!ALLOW.has(u)) continue + pairs.push([u, Number(scores[k]) || 0]) + } + pairs.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + return [pairs[0]?.[0] ?? '', pairs[1]?.[0] ?? ''] +} + +export function discPrimaryLetter(data: any): string { + if (!data || typeof data !== 'object') return '' + const dType = data.description?.type + if (typeof dType === 'string' && dType) { + const noXing = String(dType).trim().replace(/型$/, '') + if (noXing.length === 1) { + const u = noXing.toUpperCase() + if (ALLOW.has(u)) return u + } + } + if (data.dominantType) { + const u = String(data.dominantType).trim().toUpperCase().charAt(0) + if (ALLOW.has(u)) return u + } + if (data.disc) { + const noXing = String(data.disc).trim().replace(/型$/, '') + if (noXing.length === 1) { + const u = noXing.toUpperCase() + if (ALLOW.has(u)) return u + } + } + return '' +} + +export function discResolveTwoLetters(data: any): [string, string] { + let f = discPrimaryLetter(data) + let s = '' + if (data.secondaryType) { + const u = String(data.secondaryType).trim().toUpperCase().charAt(0) + if (ALLOW.has(u)) s = u + } + let a0 = '' + let b = '' + if (data.scores && typeof data.scores === 'object') { + const ord = discOrderedFromScores(data.scores as Record) + a0 = ord[0] || '' + b = ord[1] || '' + } + if (!a0 && !b && data.percentages && typeof data.percentages === 'object') { + const ord = discOrderedFromScores(data.percentages as Record) + a0 = ord[0] || '' + b = ord[1] || '' + } + if (!f && a0) f = a0 + if (!s || s === f) { + if (b && b !== f) s = b + else s = '' + } + return [f, s] +} + +export function discNormalizeLegacyDualType(desc: unknown): string { + if (typeof desc !== 'string' || !desc) return '' + const t = desc.replace(/\s+/g, '').replace(/\uFF0B/g, '+') + let m = t.match(/^([DISC])型\+([DISC])型$/i) + if (m) return m[1].toUpperCase() + '+' + m[2].toUpperCase() + '型' + m = t.match(/^([DISC])型$/i) + if (m) return m[1].toUpperCase() + '型' + return '' +} + +export function discTopTwoLabel(data: any): string { + if (!data || typeof data !== 'object') return '' + const [fL, sL] = discResolveTwoLetters(data) + if (fL || sL) { + if (!fL) return sL ? sL + '型' : '' + if (!sL || sL === fL) return fL + '型' + return fL + '+' + sL + '型' + } + return discNormalizeLegacyDualType(data.description?.type) +} + +/** 副标题:单型「完美」,双型「完美 · 力量」 */ +export function discStyleSubtitle(data: any): string { + if (!data || typeof data !== 'object') return '' + const [f, s] = discResolveTwoLetters(data) + const nf = f ? DISC_STYLE_NAMES[f] ?? '' : '' + const ns = s ? DISC_STYLE_NAMES[s] ?? '' : '' + if (!nf && !ns) return '' + if (!ns || s === f) return nf + return `${nf} · ${ns}` +} + +/** 柱状图左侧:D(力量) */ +export function discDimensionLabel(letter: string): string { + const u = String(letter).trim().toUpperCase().charAt(0) + const name = DISC_STYLE_NAMES[u] + return name ? `${u}(${name})` : u || letter +} + +/** 仅风格名,用于与字母分开展示 */ +export function discStyleName(letter: string): string { + const u = String(letter).trim().toUpperCase().charAt(0) + return DISC_STYLE_NAMES[u] ?? '' +} + +/** 雷达轴等紧凑单行:D·力量 */ +export function discCompactLabel(letter: string): string { + const u = String(letter).trim().toUpperCase().charAt(0) + const n = DISC_STYLE_NAMES[u] + return n ? `${u}·${n}` : u || letter +} diff --git a/admin/src/utils/faceResultDetail.ts b/admin/src/utils/faceResultDetail.ts index 190ae9a..37bee20 100644 --- a/admin/src/utils/faceResultDetail.ts +++ b/admin/src/utils/faceResultDetail.ts @@ -2,6 +2,8 @@ * 人脸 / AI 分析结果:与微信小程序 pages/index/result 展示字段对齐,供后台「测试详情」使用 */ +import { discStyleName } from './discDisplay' + export interface FaceFeatureItem { label: string description: string @@ -40,26 +42,72 @@ export interface FaceDetailView { careers: string[] } -function collectPhotoUrls(parsed: Record): string[] { - const out: string[] = [] +function normalizeImageUrl(s: string): string | null { + let t = s.trim() + if (!t) return null + if (t.startsWith('//')) t = 'https:' + t + if (t.startsWith('http://') || t.startsWith('https://')) return t + return null +} + +function collectPhotoUrlsFromObject(obj: Record | null | undefined, out: string[]): void { + if (!obj || typeof obj !== 'object') return + const pushUrl = (s: string) => { - const t = s.trim() - if (t && (t.startsWith('http://') || t.startsWith('https://'))) out.push(t) + const n = normalizeImageUrl(s) + if (n) out.push(n) } + const pushArr = (v: unknown) => { + if (v == null) return + if (typeof v === 'string') { + const t = v.trim() + if (t.startsWith('[')) { + try { + const arr = JSON.parse(t) as unknown + pushArr(arr) + } catch { + /* ignore */ + } + } + return + } if (!Array.isArray(v)) return for (const x of v) { if (typeof x === 'string') pushUrl(x) + else if (x && typeof x === 'object' && !Array.isArray(x)) { + const o = x as Record + const u = o.url ?? o.src ?? o.imageUrl + if (typeof u === 'string') pushUrl(u) + } } } - pushArr(parsed.photoUrls) - pushArr(parsed.photos) - pushArr(parsed.imageUrls) + + pushArr(obj.photoUrls) + pushArr(obj.photos) + pushArr(obj.imageUrls) for (const k of ['photoUrl', 'imageUrl', 'faceImageUrl'] as const) { - const u = parsed[k] + const u = obj[k] if (typeof u === 'string') pushUrl(u) } - return [...new Set(out)] +} + +function collectPhotoUrls(parsed: Record): string[] { + const out: string[] = [] + collectPhotoUrlsFromObject(parsed, out) + const inner = parsed?.result + if (inner && typeof inner === 'object' && !Array.isArray(inner)) { + collectPhotoUrlsFromObject(inner as Record, out) + } + // 保序去重(仅去掉完全相同的 URL,不误删带不同签名的地址) + const seen = new Set() + const deduped: string[] = [] + for (const u of out) { + if (seen.has(u)) continue + seen.add(u) + deduped.push(u) + } + return deduped } function isPlaceholderTitle(s: string): boolean { @@ -203,3 +251,22 @@ export function buildFaceDetailFromParsed(parsed: Record | null | u careers: strArr(parsed.careers) } } + +/** 面相报告 DISC:S → S(和平) */ +export function faceDiscDisplayLabel(raw: string | undefined | null): string { + if (raw == null || raw === '') return '' + const s = String(raw).trim() + const noXing = s.replace(/型$/u, '').trim() + const ch = noXing.charAt(0).toUpperCase() + if (ch && ['D', 'I', 'S', 'C'].includes(ch)) { + const cn = discStyleName(ch) + if (cn) return `${ch}(${cn})` + } + return s +} + +/** 面相报告 PDP:考拉 → 无尾熊(与其它端展示一致) */ +export function facePdpDisplayLabel(raw: string | undefined | null): string { + if (raw == null || raw === '') return '' + return String(raw).trim().replace(/考拉/g, '无尾熊') +} diff --git a/admin/src/views/admin/Settings.vue b/admin/src/views/admin/Settings.vue index 94112df..ab2ef3c 100644 --- a/admin/src/views/admin/Settings.vue +++ b/admin/src/views/admin/Settings.vue @@ -47,6 +47,61 @@ +
+
+

存客宝 Key

+

+ 按测评类型配置企业版与个人版 Key(人脸、MBTI、DISC、PDP 各自独立),数据仅保存在当前企业维度;留空表示未配置。下方「上报时机」对上述四类均生效:免费测评完成后即上报;若该类型标价需付费,可选付款后才上报或测试完即上报。 +

+
+
+
+
{{ row.label }}
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + 保存存客宝 Key + +
+
+
+

管理员账号设置

@@ -115,7 +170,7 @@ import { request } from '@/utils/request' import { getAdminRole } from '@/utils/authStorage' import Finance from './Finance.vue' -const TAB_IDS = ['account', 'features', 'finance'] as const +const TAB_IDS = ['account', 'features', 'cunkebao', 'finance'] as const type TabId = (typeof TAB_IDS)[number] function isTabId(s: string): s is TabId { @@ -129,11 +184,20 @@ const loading = ref(false) const isEnterpriseAdmin = () => getAdminRole() === 'enterprise_admin' +/** 企业后台可操作存客宝 Key 的账号(企业管理员或已绑定企业的 admin) */ +const canConfigureCunkebaoKeys = () => { + const r = getAdminRole() + return r === 'enterprise_admin' || r === 'admin' +} + const tabs = computed(() => { const rows: { label: string; value: TabId }[] = [{ label: '账号设置', value: 'account' }] if (isEnterpriseAdmin()) { rows.push({ label: '功能开关', value: 'features' }) } + if (canConfigureCunkebaoKeys()) { + rows.push({ label: '存客宝key', value: 'cunkebao' }) + } rows.push({ label: '企业余额', value: 'finance' }) return rows }) @@ -143,6 +207,8 @@ const applyRouteTab = () => { if (typeof t === 'string' && isTabId(t)) { if (t === 'features' && !isEnterpriseAdmin()) { activeTab.value = 'account' + } else if (t === 'cunkebao' && !canConfigureCunkebaoKeys()) { + activeTab.value = 'account' } else { activeTab.value = t } @@ -188,6 +254,68 @@ const permSaving = ref(false) const adminPermsCeiling = ref>(defaultAdminPermissions()) const adminPerms = reactive>(defaultAdminPermissions()) +type CunkebaoKeyScope = 'face' | 'pdp' | 'disc' | 'mbti' + +const cunkebaoRows: { key: CunkebaoKeyScope; label: string }[] = [ + { key: 'face', label: '人脸测试' }, + { key: 'pdp', label: 'PDP' }, + { key: 'disc', label: 'DISC' }, + { key: 'mbti', label: 'MBTI' } +] + +const cunkebaoKeys = reactive>({ + face: { enterprise: '', personal: '', reportTiming: 'after_paid' }, + pdp: { enterprise: '', personal: '', reportTiming: 'after_paid' }, + disc: { enterprise: '', personal: '', reportTiming: 'after_paid' }, + mbti: { enterprise: '', personal: '', reportTiming: 'after_paid' } +}) + +const cunkebaoLoading = ref(false) +const cunkebaoSaving = ref(false) + +const loadCunkebaoKeys = async () => { + if (!canConfigureCunkebaoKeys()) return + cunkebaoLoading.value = true + try { + const res: any = await request.get('/admin/settings/cunkebao-keys') + if (res.code === 200 && res.data?.cunkebaoKeys && typeof res.data.cunkebaoKeys === 'object') { + const ck = res.data.cunkebaoKeys + cunkebaoRows.forEach(({ key }) => { + const row = ck[key] + if (row && typeof row === 'object') { + cunkebaoKeys[key].enterprise = String(row.enterprise ?? '') + cunkebaoKeys[key].personal = String(row.personal ?? '') + cunkebaoKeys[key].reportTiming = row.reportTiming === 'after_test' ? 'after_test' : 'after_paid' + } + }) + } + } catch (e: any) { + console.error(e) + ElMessage.error(e?.message || '加载存客宝 Key 失败') + } finally { + cunkebaoLoading.value = false + } +} + +const saveCunkebaoKeys = async () => { + if (!canConfigureCunkebaoKeys()) return + cunkebaoSaving.value = true + try { + const res: any = await request.put('/admin/settings/cunkebao-keys', { + cunkebaoKeys: { ...cunkebaoKeys } + }) + if (res.code === 200) { + ElMessage.success('存客宝 Key 已保存') + } else { + ElMessage.error(res.message || res.msg || '保存失败') + } + } catch (e: any) { + ElMessage.error(e?.message || '保存失败') + } finally { + cunkebaoSaving.value = false + } +} + const visibleAdminPermItems = computed(() => permItems.filter((p) => adminPermsCeiling.value[p.key] !== false) ) @@ -287,6 +415,9 @@ watch( if (tab === 'features' && isEnterpriseAdmin()) { loadAdminPermissions() } + if (tab === 'cunkebao' && canConfigureCunkebaoKeys()) { + loadCunkebaoKeys() + } } ) @@ -296,6 +427,9 @@ onMounted(() => { if (activeTab.value === 'features' && isEnterpriseAdmin()) { loadAdminPermissions() } + if (activeTab.value === 'cunkebao' && canConfigureCunkebaoKeys()) { + loadCunkebaoKeys() + } }) @@ -330,6 +464,49 @@ onMounted(() => { margin: 0; } +.cunkebao-type-block { + margin-bottom: 28px; + padding-bottom: 20px; + border-bottom: 1px solid #f3f4f6; + + &:last-of-type { + border-bottom: none; + margin-bottom: 8px; + } +} + +.cunkebao-section-title { + font-size: 15px; + font-weight: 600; + color: #111827; + margin-bottom: 14px; +} + +.cunkebao-two-col { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} + +.cunkebao-timing-row { + display: flex; + align-items: center; + gap: 12px; + margin-top: 12px; + + > label { + font-size: 13px; + color: #374151; + white-space: nowrap; + } +} + +@media (max-width: 900px) { + .cunkebao-two-col { + grid-template-columns: 1fr; + } +} + .perm-admin-row { display: flex; align-items: center; diff --git a/admin/src/views/admin/Users.vue b/admin/src/views/admin/Users.vue index 8bcb833..fe50310 100644 --- a/admin/src/views/admin/Users.vue +++ b/admin/src/views/admin/Users.vue @@ -253,7 +253,10 @@ :key="key" class="disc-bar-row" > - {{ key }} + + {{ key }} + {{ discStyleName(key) }} +
-
+
@@ -353,15 +362,15 @@

AI 识别 DISC

- 主类型:{{ faceDetail.disc.primary }} - ,次:{{ faceDetail.disc.secondary }} + 主类型:{{ faceDiscDisplayLabel(faceDetail.disc.primary) }} + ,次:{{ faceDiscDisplayLabel(faceDetail.disc.secondary) }}

AI 识别 PDP

- 主类型:{{ faceDetail.pdp.primary }} - ,次:{{ faceDetail.pdp.secondary }} + 主类型:{{ facePdpDisplayLabel(faceDetail.pdp.primary) }} + ,次:{{ facePdpDisplayLabel(faceDetail.pdp.secondary) }}

@@ -634,10 +643,10 @@

补充说明

-

{{ currentTestDescription }}

+

{{ supplementaryNote }}

@@ -649,8 +658,9 @@ import { ref, computed, onMounted } from 'vue' import { Download, Search, View } from '@element-plus/icons-vue' import { ElMessage } from 'element-plus' import { request } from '@/utils/request' -import { buildFaceDetailFromParsed } from '@/utils/faceResultDetail' +import { buildFaceDetailFromParsed, faceDiscDisplayLabel, facePdpDisplayLabel } from '@/utils/faceResultDetail' import { parseTestResultPayload } from '@/utils/testResultParse' +import { discTopTwoLabel, discStyleSubtitle, discStyleName } from '@/utils/discDisplay' import UserDetailDialog from '@/components/UserDetailDialog.vue' const loading = ref(false) @@ -767,6 +777,8 @@ function extractTestSummary(test: any): string { } if (type === 'disc') { + const two = discTopTwoLabel(data) + if (two) return two const desc = data.description?.type if (typeof desc === 'string' && desc) return desc if (data.dominantType) return String(data.dominantType) + '型' @@ -819,9 +831,11 @@ const discDetail = computed(() => { if (!parsed || currentTestType.value !== 'disc') return null const desc = parsed.description || {} const percentages = parsed.percentages || {} + const typeLine = discTopTwoLabel(parsed) || String(desc.type ?? currentTestSummary.value ?? '') + const titleLine = discStyleSubtitle(parsed) || String(desc.title ?? '') return { - type: desc.type ?? currentTestSummary.value, - title: desc.title ?? '', + type: typeLine, + title: titleLine, description: desc.description ?? '', strengths: Array.isArray(desc.strengths) ? desc.strengths : [], weaknesses: Array.isArray(desc.weaknesses) ? desc.weaknesses : [], @@ -851,6 +865,25 @@ const pdpDetail = computed(() => { } }) +const supplementaryNote = computed(() => { + const t = String(currentTestDescription.value || '').trim() + if (!t) return '' + const typ = (currentTestType.value || '').toLowerCase() + if (typ === 'disc' && discDetail.value) { + const main = String(discDetail.value.description || '').trim() + if (main && main === t) return '' + } + if (typ === 'mbti' && mbtiDetail.value) { + const main = String(mbtiDetail.value.description || '').trim() + if (main && main === t) return '' + } + if (typ === 'pdp' && pdpDetail.value) { + const main = String(pdpDetail.value.description || '').trim() + if (main && main === t) return '' + } + return t +}) + const faceDetail = computed(() => { const parsed = currentTestParsed.value if (!parsed || (currentTestType.value !== 'face' && currentTestType.value !== 'ai')) return null @@ -1306,12 +1339,18 @@ onMounted(() => { margin-top: 8px; } +.test-detail-disc .test-detail-main-card { + flex-wrap: wrap; + align-items: flex-start; +} + .test-detail-main-left { flex: 1; } .test-detail-main-right { - width: 260px; + width: min(100%, 300px); + min-width: 0; flex-shrink: 0; } @@ -1347,11 +1386,12 @@ onMounted(() => { justify-content: center; padding: 4px 10px; border-radius: 999px; - background-color: #fee2e2; - color: #b91c1c; + background-color: #eef2ff; + color: #4f46e5; font-weight: 700; font-size: 14px; margin-bottom: 4px; + border: 1px solid #e0e7ff; } .disc-title { @@ -1371,20 +1411,60 @@ onMounted(() => { margin-top: 6px; display: flex; flex-direction: column; - gap: 4px; + gap: 8px; } .disc-bar-row { display: flex; align-items: center; - gap: 6px; + gap: 10px; + min-height: 22px; } .disc-bar-label { - width: 16px; + display: flex; + flex-direction: row; + align-items: center; + gap: 6px; + flex-shrink: 0; + min-width: 72px; +} + +.disc-bar-letter { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 6px; font-size: 12px; - font-weight: 600; + font-weight: 800; + line-height: 1; + color: #fff; +} + +.disc-bar-letter-d { + background: linear-gradient(135deg, #f87171 0%, #ef4444 100%); +} + +.disc-bar-letter-i { + background: linear-gradient(135deg, #fbbf24 0%, #eab308 100%); +} + +.disc-bar-letter-s { + background: linear-gradient(135deg, #4ade80 0%, #22c55e 100%); +} + +.disc-bar-letter-c { + background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%); +} + +.disc-bar-cn { + font-size: 12px; + font-weight: 500; color: #4b5563; + white-space: nowrap; + letter-spacing: 0.02em; } .disc-bar-track { @@ -1496,36 +1576,53 @@ onMounted(() => { } .face-left { - width: 280px; + width: min(100%, 340px); flex-shrink: 0; position: sticky; top: 0; + align-self: flex-start; height: fit-content; } .face-photos { - display: flex; - flex-direction: column; - gap: 10px; + display: grid; + gap: 8px; + width: 100%; - :deep(.el-image) { - display: flex; - justify-content: center; - align-items: center; + :deep(.face-photo-thumb.el-image) { + display: block; width: 100%; + aspect-ratio: 3 / 4; + max-height: 220px; border-radius: 8px; overflow: hidden; background: #f1f5f9; + cursor: zoom-in; } - :deep(.el-image__inner) { - display: block; - max-width: 100%; - max-height: min(65vh, 560px); - width: auto !important; - height: auto !important; - object-fit: contain; + :deep(.face-photo-thumb .el-image__inner) { + width: 100% !important; + height: 100% !important; + object-fit: cover; } + + :deep(.face-photo-thumb .el-image__error) { + min-height: 120px; + font-size: 12px; + } +} + +.face-photos--1 { + grid-template-columns: 1fr; + max-width: 280px; +} + +.face-photos--2 { + grid-template-columns: repeat(2, 1fr); +} + +.face-photos--3 { + grid-template-columns: repeat(3, 1fr); } .face-right { diff --git a/admin/src/views/superadmin/CommerceHub.vue b/admin/src/views/superadmin/CommerceHub.vue index 3b2d187..3270395 100644 --- a/admin/src/views/superadmin/CommerceHub.vue +++ b/admin/src/views/superadmin/CommerceHub.vue @@ -27,7 +27,8 @@ orders-api-path="/superadmin/orders" /> - + +
diff --git a/admin/src/views/superadmin/Settings.vue b/admin/src/views/superadmin/Settings.vue index 80072b3..7794418 100644 --- a/admin/src/views/superadmin/Settings.vue +++ b/admin/src/views/superadmin/Settings.vue @@ -185,6 +185,7 @@