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:
Ghost
2026-04-01 17:16:20 +08:00
parent 8abc5182af
commit ef19f6860e
63 changed files with 2718 additions and 1190 deletions

View File

@@ -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) {

View 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
}

View File

@@ -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)
}
}
/** 面相报告 DISCS → 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, '无尾熊')
}

View File

@@ -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人脸MBTIDISCPDP 各自独立数据仅保存在当前企业维度留空表示未配置下方上报时机对上述四类均生效免费测评完成后即上报若该类型标价需付费可选付款后才上报或测试完即上报
</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;

View File

@@ -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 {

View File

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

View File

@@ -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(() => {

View File

@@ -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 {