feat: CRM 报告与双端结果页、管理端用户/设置与默认企业配置
1、修复了用户详情与人脸/DISC 展示、手机号与资料校验、相机页与测试选择等体验问题。 2、新增了 SystemDefaultEnterprise、discDisplay、管理端设置扩展接口;CrmReport/Test/Analyze 等 API 与路由能力补强。 3、优化了 resultFormat、descriptions、payment、企业上下文;海报与 PDP/DISC 文案;超管 Commerce/Users 与 admin Settings/Users。 Made-with: Cursor
This commit is contained in:
@@ -178,6 +178,8 @@
|
||||
fit="cover"
|
||||
class="ud-photo-gallery__thumb"
|
||||
:preview-src-list="facePhotos"
|
||||
:initial-index="idx"
|
||||
preview-teleported
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="ud-empty-hint">暂无人脸分析照片</div>
|
||||
@@ -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<string, string> = {
|
||||
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) {
|
||||
|
||||
128
admin/src/utils/discDisplay.ts
Normal file
128
admin/src/utils/discDisplay.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 管理后台 DISC 展示:双字母摘要与「力量 / 活跃 / 和平 / 完美」称谓。
|
||||
* 逻辑对齐 miniprogram/utils/resultFormat.js 与 api PdpDiscResultText::discResolveTwoLetters。
|
||||
*/
|
||||
|
||||
export const DISC_STYLE_NAMES: Record<string, string> = {
|
||||
D: '力量',
|
||||
I: '活跃',
|
||||
S: '和平',
|
||||
C: '完美'
|
||||
}
|
||||
|
||||
const ALLOW = new Set(['D', 'I', 'S', 'C'])
|
||||
|
||||
function discOrderedFromScores(scores: Record<string, unknown> | 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<string, unknown>)
|
||||
a0 = ord[0] || ''
|
||||
b = ord[1] || ''
|
||||
}
|
||||
if (!a0 && !b && data.percentages && typeof data.percentages === 'object') {
|
||||
const ord = discOrderedFromScores(data.percentages as Record<string, unknown>)
|
||||
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
|
||||
}
|
||||
@@ -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, any>): 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<string, any> | 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<string, unknown>
|
||||
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, any>): string[] {
|
||||
const out: string[] = []
|
||||
collectPhotoUrlsFromObject(parsed, out)
|
||||
const inner = parsed?.result
|
||||
if (inner && typeof inner === 'object' && !Array.isArray(inner)) {
|
||||
collectPhotoUrlsFromObject(inner as Record<string, any>, out)
|
||||
}
|
||||
// 保序去重(仅去掉完全相同的 URL,不误删带不同签名的地址)
|
||||
const seen = new Set<string>()
|
||||
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<string, any> | 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, '无尾熊')
|
||||
}
|
||||
|
||||
@@ -47,6 +47,61 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'cunkebao'" class="tab-content" v-loading="cunkebaoLoading">
|
||||
<div class="content-header">
|
||||
<h3>存客宝 Key</h3>
|
||||
<p class="content-description">
|
||||
按测评类型配置企业版与个人版 Key(人脸、MBTI、DISC、PDP 各自独立),数据仅保存在当前企业维度;留空表示未配置。下方「上报时机」对上述四类均生效:免费测评完成后即上报;若该类型标价需付费,可选付款后才上报或测试完即上报。
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<div
|
||||
v-for="row in cunkebaoRows"
|
||||
:key="row.key"
|
||||
class="cunkebao-type-block"
|
||||
>
|
||||
<div class="cunkebao-section-title">{{ row.label }}</div>
|
||||
<div class="cunkebao-two-col">
|
||||
<div class="form-item">
|
||||
<label>企业版 Key</label>
|
||||
<el-input
|
||||
v-model="cunkebaoKeys[row.key].enterprise"
|
||||
clearable
|
||||
:placeholder="`请输入 ${row.label} 企业版 Key`"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>个人版 Key</label>
|
||||
<el-input
|
||||
v-model="cunkebaoKeys[row.key].personal"
|
||||
clearable
|
||||
:placeholder="`请输入 ${row.label} 个人版 Key`"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cunkebao-timing-row">
|
||||
<label>上报时机</label>
|
||||
<el-switch
|
||||
v-model="cunkebaoKeys[row.key].reportTiming"
|
||||
active-value="after_test"
|
||||
inactive-value="after_paid"
|
||||
active-text="测试完即上报"
|
||||
inactive-text="付款后才上报"
|
||||
inline-prompt
|
||||
style="--el-switch-on-color: #7c3aed; --el-switch-off-color: #909399"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="save-actions">
|
||||
<el-button type="primary" color="#7c3aed" class="save-btn" :loading="cunkebaoSaving" @click="saveCunkebaoKeys">
|
||||
保存存客宝 Key
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'account'" class="tab-content">
|
||||
<div class="content-header">
|
||||
<h3>管理员账号设置</h3>
|
||||
@@ -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<Record<string, boolean>>(defaultAdminPermissions())
|
||||
const adminPerms = reactive<Record<string, boolean>>(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<Record<CunkebaoKeyScope, { enterprise: string; personal: string; reportTiming: string }>>({
|
||||
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()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -253,7 +253,10 @@
|
||||
:key="key"
|
||||
class="disc-bar-row"
|
||||
>
|
||||
<span class="disc-bar-label">{{ key }}</span>
|
||||
<span class="disc-bar-label">
|
||||
<span class="disc-bar-letter" :class="'disc-bar-letter-' + key.toLowerCase()">{{ key }}</span>
|
||||
<span class="disc-bar-cn">{{ discStyleName(key) }}</span>
|
||||
</span>
|
||||
<div class="disc-bar-track">
|
||||
<div
|
||||
class="disc-bar-fill"
|
||||
@@ -330,13 +333,19 @@
|
||||
<div v-else-if="(currentTestType === 'face' || currentTestType === 'ai') && faceDetail" class="detail-section test-detail-face">
|
||||
<div class="face-layout">
|
||||
<div class="face-left" v-if="faceDetail.photos && faceDetail.photos.length">
|
||||
<div class="face-photos">
|
||||
<div
|
||||
class="face-photos"
|
||||
:class="'face-photos--' + Math.min(faceDetail.photos.length, 3)"
|
||||
>
|
||||
<el-image
|
||||
v-for="(url, idx) in faceDetail.photos"
|
||||
:key="url + idx"
|
||||
:src="url"
|
||||
fit="contain"
|
||||
fit="cover"
|
||||
:preview-src-list="faceDetail.photos"
|
||||
:initial-index="idx"
|
||||
preview-teleported
|
||||
class="face-photo-thumb"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -353,15 +362,15 @@
|
||||
<div class="test-detail-block flex-1" v-if="faceDetail.disc">
|
||||
<h4 class="detail-subtitle">AI 识别 DISC</h4>
|
||||
<p class="test-desc">
|
||||
主类型:<strong class="text-primary">{{ faceDetail.disc.primary }}</strong>
|
||||
<span v-if="faceDetail.disc.secondary" class="text-secondary">,次:{{ faceDetail.disc.secondary }}</span>
|
||||
主类型:<strong class="text-primary">{{ faceDiscDisplayLabel(faceDetail.disc.primary) }}</strong>
|
||||
<span v-if="faceDetail.disc.secondary" class="text-secondary">,次:{{ faceDiscDisplayLabel(faceDetail.disc.secondary) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="test-detail-block flex-1" v-if="faceDetail.pdp">
|
||||
<h4 class="detail-subtitle">AI 识别 PDP</h4>
|
||||
<p class="test-desc">
|
||||
主类型:<strong class="text-primary">{{ faceDetail.pdp.primary }}</strong>
|
||||
<span v-if="faceDetail.pdp.secondary" class="text-secondary">,次:{{ faceDetail.pdp.secondary }}</span>
|
||||
主类型:<strong class="text-primary">{{ facePdpDisplayLabel(faceDetail.pdp.primary) }}</strong>
|
||||
<span v-if="faceDetail.pdp.secondary" class="text-secondary">,次:{{ facePdpDisplayLabel(faceDetail.pdp.secondary) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -634,10 +643,10 @@
|
||||
|
||||
<div
|
||||
class="detail-section"
|
||||
v-if="currentTestType !== 'resume' && currentTestType !== 'face' && currentTestType !== 'ai' && currentTestDescription"
|
||||
v-if="currentTestType !== 'resume' && currentTestType !== 'face' && currentTestType !== 'ai' && supplementaryNote"
|
||||
>
|
||||
<h4 class="detail-subtitle">补充说明</h4>
|
||||
<p class="test-desc">{{ currentTestDescription }}</p>
|
||||
<p class="test-desc">{{ supplementaryNote }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
@@ -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 {
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
orders-api-path="/superadmin/orders"
|
||||
/>
|
||||
<Finance v-if="activeTab === 'finance'" embedded />
|
||||
<Pricing v-if="activeTab === 'pricing'" embedded enterprise-procurement-only />
|
||||
<!-- 与独立「全局定价」一致:含个人版价格 + 深度服务左右栏(个人/企业) -->
|
||||
<Pricing v-if="activeTab === 'pricing'" embedded />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -185,6 +185,7 @@
|
||||
<div class="form-item form-item-full">
|
||||
<label class="form-label">小程序默认企业</label>
|
||||
<el-select
|
||||
:key="'default-enterprise-select-' + enterpriseSelectRenderKey"
|
||||
v-model="systemConfig.defaultEnterpriseId"
|
||||
class="form-input w-full"
|
||||
placeholder="不设置则小程序无带参入口时不回落企业"
|
||||
@@ -408,7 +409,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, watch, computed } from 'vue'
|
||||
import { ref, reactive, onMounted, watch, computed, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
Setting,
|
||||
@@ -499,6 +500,8 @@ const systemConfig = reactive({
|
||||
|
||||
/** 下拉:企业管理中的企业列表 */
|
||||
const enterpriseOptions = ref<Array<{ id: number; name: string }>>([])
|
||||
/** 与选项加载顺序配合,避免 el-select 在 options 为空时绑定值导致刷新后不显示 */
|
||||
const enterpriseSelectRenderKey = ref(0)
|
||||
|
||||
// 超管凭据
|
||||
const credentials = reactive({
|
||||
@@ -519,7 +522,7 @@ const textConfig = reactive({
|
||||
|
||||
// 提示词配置(faceAnalyze 只存 JSON 返回模板,由后端与固定前缀拼接)
|
||||
const promptsConfig = reactive<Record<string, string>>({
|
||||
faceAnalyze: '{"mbti":"四字母如INTJ","pdp":"老虎/孔雀/考拉/猫头鹰/变色龙其一","disc":"D/I/S/C其一","overview":"一段50字以内的综合描述","faceAnalysis":"面相特点简短描述"}',
|
||||
faceAnalyze: '{"mbti":"四字母如INTJ","pdp":"老虎/孔雀/无尾熊/猫头鹰/变色龙其一","disc":"D/I/S/C其一","overview":"一段50字以内的综合描述","faceAnalysis":"面相特点简短描述"}',
|
||||
reportSummary: '',
|
||||
})
|
||||
|
||||
@@ -536,6 +539,8 @@ const loadSettings = async () => {
|
||||
const de = response.data.system.defaultEnterpriseId
|
||||
systemConfig.defaultEnterpriseId =
|
||||
de != null && de !== '' && Number(de) > 0 ? Number(de) : null
|
||||
await nextTick()
|
||||
enterpriseSelectRenderKey.value++
|
||||
}
|
||||
// 加载小程序文案配置
|
||||
if (response.data.textConfig && typeof response.data.textConfig === 'object') {
|
||||
@@ -579,10 +584,11 @@ async function loadEnterpriseOptions() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
applyRouteTab()
|
||||
loadSettings()
|
||||
loadEnterpriseOptions()
|
||||
// 必须先加载下拉选项,再灌入 defaultEnterpriseId,否则 el-select 刷新后无法反显已选企业
|
||||
await loadEnterpriseOptions()
|
||||
await loadSettings()
|
||||
})
|
||||
|
||||
// 保存配置
|
||||
@@ -608,6 +614,23 @@ const handleSave = async (section: string) => {
|
||||
textConfig
|
||||
})
|
||||
if (response.code === 200) {
|
||||
// 后端返回的是合并后的 system JSON(与 GET 中 system 字段结构一致)
|
||||
const saved = response.data
|
||||
if (saved && typeof saved === 'object') {
|
||||
Object.assign(systemConfig, {
|
||||
siteName: saved.siteName ?? systemConfig.siteName,
|
||||
siteDescription: saved.siteDescription ?? systemConfig.siteDescription,
|
||||
miniprogramName: saved.miniprogramName ?? systemConfig.miniprogramName,
|
||||
maintenanceMode: !!saved.maintenanceMode,
|
||||
maxTestsPerDay: Number(saved.maxTestsPerDay ?? systemConfig.maxTestsPerDay),
|
||||
trialTestCount: Number(saved.trialTestCount ?? systemConfig.trialTestCount)
|
||||
})
|
||||
const de = saved.defaultEnterpriseId
|
||||
systemConfig.defaultEnterpriseId =
|
||||
de != null && de !== '' && Number(de) > 0 ? Number(de) : null
|
||||
await nextTick()
|
||||
enterpriseSelectRenderKey.value++
|
||||
}
|
||||
ElMessage.success('系统配置已保存')
|
||||
saveSuccess.value = section
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -339,7 +339,10 @@
|
||||
:key="key"
|
||||
class="disc-bar-row"
|
||||
>
|
||||
<span class="disc-bar-label">{{ key }}</span>
|
||||
<span class="disc-bar-label">
|
||||
<span class="disc-bar-letter" :class="'disc-bar-letter-' + key.toLowerCase()">{{ key }}</span>
|
||||
<span class="disc-bar-cn">{{ discStyleName(key) }}</span>
|
||||
</span>
|
||||
<div class="disc-bar-track">
|
||||
<div
|
||||
class="disc-bar-fill"
|
||||
@@ -416,13 +419,19 @@
|
||||
<div v-else-if="(currentTestType === 'face' || currentTestType === 'ai') && faceDetail" class="detail-section test-detail-face">
|
||||
<div class="face-layout">
|
||||
<div class="face-left" v-if="faceDetail.photos && faceDetail.photos.length">
|
||||
<div class="face-photos">
|
||||
<div
|
||||
class="face-photos"
|
||||
:class="'face-photos--' + Math.min(faceDetail.photos.length, 3)"
|
||||
>
|
||||
<el-image
|
||||
v-for="(url, idx) in faceDetail.photos"
|
||||
:key="url + idx"
|
||||
:src="url"
|
||||
fit="contain"
|
||||
fit="cover"
|
||||
:preview-src-list="faceDetail.photos"
|
||||
:initial-index="idx"
|
||||
preview-teleported
|
||||
class="face-photo-thumb"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -439,15 +448,15 @@
|
||||
<div class="test-detail-block flex-1" v-if="faceDetail.disc">
|
||||
<h4 class="detail-subtitle">AI 识别 DISC</h4>
|
||||
<p class="test-desc">
|
||||
主类型:<strong class="text-primary">{{ faceDetail.disc.primary }}</strong>
|
||||
<span v-if="faceDetail.disc.secondary" class="text-secondary">,次:{{ faceDetail.disc.secondary }}</span>
|
||||
主类型:<strong class="text-primary">{{ faceDiscDisplayLabel(faceDetail.disc.primary) }}</strong>
|
||||
<span v-if="faceDetail.disc.secondary" class="text-secondary">,次:{{ faceDiscDisplayLabel(faceDetail.disc.secondary) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="test-detail-block flex-1" v-if="faceDetail.pdp">
|
||||
<h4 class="detail-subtitle">AI 识别 PDP</h4>
|
||||
<p class="test-desc">
|
||||
主类型:<strong class="text-primary">{{ faceDetail.pdp.primary }}</strong>
|
||||
<span v-if="faceDetail.pdp.secondary" class="text-secondary">,次:{{ faceDetail.pdp.secondary }}</span>
|
||||
主类型:<strong class="text-primary">{{ facePdpDisplayLabel(faceDetail.pdp.primary) }}</strong>
|
||||
<span v-if="faceDetail.pdp.secondary" class="text-secondary">,次:{{ facePdpDisplayLabel(faceDetail.pdp.secondary) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -713,10 +722,10 @@
|
||||
<!-- 通用补充说明(人脸/AI 已在上方分块展示,避免重复堆砌) -->
|
||||
<div
|
||||
class="detail-section"
|
||||
v-if="currentTestType !== 'resume' && currentTestType !== 'face' && currentTestType !== 'ai' && currentTestDescription"
|
||||
v-if="currentTestType !== 'resume' && currentTestType !== 'face' && currentTestType !== 'ai' && supplementaryNote"
|
||||
>
|
||||
<h4 class="detail-subtitle">补充说明</h4>
|
||||
<p class="test-desc">{{ currentTestDescription }}</p>
|
||||
<p class="test-desc">{{ supplementaryNote }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
@@ -728,8 +737,9 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { Download, Search, View, UserFilled, User, OfficeBuilding, DataLine } 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'
|
||||
|
||||
withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
|
||||
@@ -898,6 +908,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) + '型'
|
||||
@@ -950,9 +962,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 : [],
|
||||
@@ -982,6 +996,26 @@ 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
|
||||
@@ -1696,12 +1730,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;
|
||||
}
|
||||
|
||||
@@ -1732,9 +1772,17 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.disc-type-badge {
|
||||
@extend .mbti-type-badge;
|
||||
background-color: #fee2e2;
|
||||
color: #b91c1c;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background-color: #eef2ff;
|
||||
color: #4f46e5;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
border: 1px solid #e0e7ff;
|
||||
}
|
||||
|
||||
.disc-title {
|
||||
@@ -1754,20 +1802,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 {
|
||||
@@ -1882,36 +1970,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 {
|
||||
|
||||
Reference in New Issue
Block a user