From 6fec2965aa81d34e06bbf2657354f05f3819f827 Mon Sep 17 00:00:00 2001 From: Ghost <106998207@qq.com> Date: Sat, 11 Apr 2026 16:16:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20SBTI=20=E6=B5=8B=E8=AF=84=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E4=B8=8E=E7=AE=A1=E7=90=86=E7=AB=AF=E9=A2=98=E7=9B=AE?= =?UTF-8?q?/=E8=B4=A2=E5=8A=A1/=E7=94=A8=E6=88=B7=E8=81=94=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1、修复了用户详情、定价与题目管理、财务与分销等管理端展示与接口问题。 2、新增 SBTI 小程序测评/结果页、sbtiData 引擎、题库 SQL 与脚本;admin sbtiDisplay。 3、优化 AppConfig/CrmReport/Test、历史与个人中心、test-select 与支付相关逻辑。 Made-with: Cursor --- admin/src/components/UserDetailDialog.vue | 121 ++- admin/src/utils/sbtiDisplay.ts | 119 +++ admin/src/views/admin/Pricing.vue | 15 +- admin/src/views/admin/Questions.vue | 69 +- admin/src/views/admin/Settings.vue | 3 +- admin/src/views/admin/Users.vue | 166 +++- admin/src/views/superadmin/Enterprises.vue | 3 +- admin/src/views/superadmin/Pricing.vue | 25 +- admin/src/views/superadmin/Questions.vue | 73 +- admin/src/views/superadmin/Users.vue | 166 +++- api/app/controller/admin/AppUser.php | 2 + api/app/controller/admin/Finance.php | 408 ++++---- api/app/controller/admin/Pricing.php | 370 +++---- api/app/controller/admin/Question.php | 920 +++++++++--------- .../admin/concern/ExtractsTestResults.php | 23 + api/app/controller/api/AppConfig.php | 4 +- api/app/controller/api/CrmReport.php | 27 +- api/app/controller/api/Distribution.php | 28 +- api/app/controller/api/Payment.php | 6 +- api/app/controller/api/Test.php | 64 +- api/app/controller/superadmin/AppUser.php | 2 + api/app/controller/superadmin/Question.php | 663 +++++++------ api/app/model/Enterprise.php | 1 + api/database/extract_sbti_bundle.js | 35 + api/database/gen_sbti_questions_sql.js | 210 ++++ api/database/sbti_questions_data.sql | 47 + miniprogram/app.js | 2 + miniprogram/app.json | 2 + miniprogram/pages/history/index.js | 31 +- miniprogram/pages/history/index.wxml | 3 + miniprogram/pages/history/index.wxss | 4 + miniprogram/pages/profile/index.js | 32 +- miniprogram/pages/profile/index.wxml | 14 +- miniprogram/pages/profile/index.wxss | 14 +- miniprogram/pages/result/sbti.js | 263 +++++ miniprogram/pages/result/sbti.json | 7 + miniprogram/pages/result/sbti.wxml | 110 +++ miniprogram/pages/result/sbti.wxss | 270 +++++ miniprogram/pages/test-select/index.js | 6 + miniprogram/pages/test-select/index.wxml | 13 +- miniprogram/pages/test-select/index.wxss | 5 + miniprogram/pages/test/sbti.js | 249 +++++ miniprogram/pages/test/sbti.json | 7 + miniprogram/pages/test/sbti.wxml | 54 + miniprogram/pages/test/sbti.wxss | 197 ++++ miniprogram/utils/payment.js | 8 + miniprogram/utils/questionBank.js | 4 +- miniprogram/utils/resultFormat.js | 18 + miniprogram/utils/sbtiData.js | 393 ++++++++ miniprogram/utils/sbtiEngine.js | 172 ++++ 50 files changed, 4189 insertions(+), 1259 deletions(-) create mode 100644 admin/src/utils/sbtiDisplay.ts create mode 100644 api/database/extract_sbti_bundle.js create mode 100644 api/database/gen_sbti_questions_sql.js create mode 100644 api/database/sbti_questions_data.sql create mode 100644 miniprogram/pages/result/sbti.js create mode 100644 miniprogram/pages/result/sbti.json create mode 100644 miniprogram/pages/result/sbti.wxml create mode 100644 miniprogram/pages/result/sbti.wxss create mode 100644 miniprogram/pages/test/sbti.js create mode 100644 miniprogram/pages/test/sbti.json create mode 100644 miniprogram/pages/test/sbti.wxml create mode 100644 miniprogram/pages/test/sbti.wxss create mode 100644 miniprogram/utils/sbtiData.js create mode 100644 miniprogram/utils/sbtiEngine.js diff --git a/admin/src/components/UserDetailDialog.vue b/admin/src/components/UserDetailDialog.vue index 8a67b2a..25ba7d3 100644 --- a/admin/src/components/UserDetailDialog.vue +++ b/admin/src/components/UserDetailDialog.vue @@ -45,6 +45,11 @@
{{ shortOrDash(user.discType, 16) }}
+ +
+ {{ shortOrDash(user.sbtiType, 18) }} +
+
维度标签
@@ -73,6 +78,26 @@
暂无 DISC 测评
+
+
SBTI
+ +
+ {{ sbtiPanelSummary || '—' }} + (无 15 维 rawScores/levels,无法绘图) +
+
暂无 SBTI 测评
+
+ {{ sbtiPanelSummary }} +
+
@@ -200,6 +225,7 @@ import { DataAnalysis, DataLine, Document, + Grid, Key, OfficeBuilding, Phone, @@ -222,6 +248,7 @@ import { import VChart from 'vue-echarts' import { discTopTwoLabel, discCompactLabel } from '@/utils/discDisplay' import { buildFaceDetailFromParsed } from '@/utils/faceResultDetail' +import { SBTI_RADAR_DIMENSION_ORDER, buildSbtiRadarValues, formatSbtiSummary } from '@/utils/sbtiDisplay' use([CanvasRenderer, RadarChart, GridComponent, TooltipComponent, LegendComponent, RadarComponent]) @@ -315,6 +342,7 @@ function formatTestType(testType: string) { if (t === 'pdp') return 'PDP' if (t === 'face' || t === 'ai') return '人脸' if (t === 'resume') return '简历' + if (t === 'sbti') return 'SBTI' return testType } @@ -349,6 +377,10 @@ function extractTestSummary(test: any): string { const c = String(data.content ?? '') return c ? c.substring(0, 24).replace(/\n/g, ' ') + (c.length > 24 ? '…' : '') : '简历' } + if (type === 'sbti') { + const line = formatSbtiSummary(data) + if (line) return line + } return String(data.type ?? data.result ?? '') } @@ -374,6 +406,33 @@ function latestTest(type: string) { const latestMbti = computed(() => parseTestResult(latestTest('mbti'))) const latestPdp = computed(() => parseTestResult(latestTest('pdp'))) const latestDisc = computed(() => parseTestResult(latestTest('disc'))) +const latestSbti = computed(() => parseTestResult(latestTest('sbti'))) +const sbtiPanelSummary = computed(() => formatSbtiSummary(latestSbti.value)) + +const sbtiRadarOption = computed(() => { + const p = latestSbti.value + if (!p) return null + const values = buildSbtiRadarValues(p) + if (!values?.length) return null + const indicator = SBTI_RADAR_DIMENSION_ORDER.map(code => ({ name: code, max: 100 })) + return { + color: ['#5a7268'], + radar: { + indicator, + radius: 58, + splitNumber: 4, + axisName: { fontSize: 9, color: '#5a7268' } + }, + series: [ + { + type: 'radar', + data: [{ value: values, name: 'SBTI' }], + areaStyle: { opacity: 0.14 } + } + ], + tooltip: { trigger: 'item' } + } +}) const latestFace = computed(() => parseTestResult(latestTest('face')) || parseTestResult(latestTest('ai'))) const latestResume = computed(() => parseTestResult(latestTest('resume'))) @@ -391,6 +450,7 @@ const profileTags = computed(() => { if (u.mbtiType) tags.push('MBTI-' + u.mbtiType) if (u.pdpType) tags.push('PDP-' + u.pdpType) if (u.discType) tags.push('DISC-' + u.discType) + if (u.sbtiType) tags.push('SBTI-' + u.sbtiType) if (u.faceMbtiType) tags.push('面相MBTI') return tags }) @@ -550,6 +610,7 @@ function testIcon(testType: string) { if (t === 'mbti') return Aim if (t === 'pdp') return TrendCharts if (t === 'disc') return PieChart + if (t === 'sbti') return Grid if (t === 'face' || t === 'ai') return Picture if (t === 'resume') return Document return DataLine @@ -560,6 +621,7 @@ function testIconClass(testType: string) { if (t === 'mbti') return 'tic-mbti' if (t === 'pdp') return 'tic-pdp' if (t === 'disc') return 'tic-disc' + if (t === 'sbti') return 'tic-sbti' if (t === 'face' || t === 'ai') return 'tic-face' return 'tic-other' } @@ -679,6 +741,15 @@ function openMail(email: string) { } } +.ud-quick-stats__tile--sbti { + border-color: #dbe8e0; + background: #f2f7f3; + + .el-icon { + color: #5a7268; + } +} + .ud-dimension-tags { margin-top: 12px; } @@ -727,10 +798,16 @@ function openMail(email: string) { .ud-chart-row { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; } +@media (max-width: 1200px) { + .ud-chart-row { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + .ud-chart-panel { background: #fafafa; border-radius: 8px; @@ -758,6 +835,11 @@ function openMail(email: string) { width: 100%; } +/* 15 维 SBTI 雷达略增高,轴标签较多 */ +.ud-chart-echart--sbti { + height: 200px; +} + .ud-chart-placeholder { height: 120px; display: flex; @@ -767,6 +849,40 @@ function openMail(email: string) { color: #9ca3af; } +.ud-chart-placeholder--sbti-fallback { + flex-direction: column; +} + +.ud-chart-panel--sbti { + background: #f2f7f3; + border: 1px solid #e3ebe6; +} + +.ud-chart-heading--sbti .el-icon { + color: #5a7268 !important; +} + +.ud-sbti-placeholder-text { + font-size: 11px; + color: #5a7268; + line-height: 1.4; + padding: 8px; + text-align: center; + word-break: break-all; +} + +.ud-sbti-radar-missing { + display: block; + font-size: 10px; + color: #9ca3af; + margin-top: 6px; + font-weight: 400; +} + +.ud-chart-footnote--sbti { + color: #5a7268; +} + .ud-chart-footnote { font-size: 11px; color: #6b7280; @@ -952,6 +1068,9 @@ function openMail(email: string) { .tic-disc { color: #2563eb; } +.tic-sbti { + color: #5a7268; +} .tic-face { color: #059669; } diff --git a/admin/src/utils/sbtiDisplay.ts b/admin/src/utils/sbtiDisplay.ts new file mode 100644 index 0000000..fdf61e8 --- /dev/null +++ b/admin/src/utils/sbtiDisplay.ts @@ -0,0 +1,119 @@ +/** + * SBTI 展示与类型图 URL(与小程序 miniprogram/utils/sbtiData.js TYPE_IMAGES 一致) + */ +export const SBTI_TYPE_IMAGES: Record = { + IMSB: 'https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/IMSB.png', + BOSS: 'https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/BOSS.png', + MUM: 'https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/MUM.png', + FAKE: 'https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/FAKE.png', + 'Dior-s': 'https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/Dior-s.jpg', + DEAD: 'https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/DEAD.png', + ZZZZ: 'https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/ZZZZ.png', + GOGO: 'https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/GOGO.png', + FUCK: 'https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/FUCK.png', + CTRL: 'https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/CTRL.png', + HHHH: 'https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/HHHH.png', + SEXY: 'https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/SEXY.png', + OJBK: 'https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/OJBK.png', + 'JOKE-R': 'https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/JOKE-R.jpg', + POOR: 'https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/POOR.png', + 'OH-NO': 'https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/OH-NO.png', + MONK: 'https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/MONK.png', + SHIT: 'https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/SHIT.png', + 'THAN-K': 'https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/THAN-K.png', + MALO: 'https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/MALO.png', + 'ATM-er': 'https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/ATM-er.png', + 'THIN-K': 'https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/THIN-K.png', + SOLO: 'https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/SOLO.png', + 'LOVE-R': 'https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/LOVE-R.png', + 'WOC!': 'https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/WOC.png', + DRUNK: 'https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/DRUNK.png', + IMFW: 'https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/IMFW.png' +} + +export function getSbtiCode(data: Record | null | undefined): string { + if (!data || typeof data !== 'object') return '' + const direct = String(data.sbtiType ?? '').trim() + if (direct) return direct + const ft = data.finalType + if (ft && typeof ft === 'object' && ft.code != null && String(ft.code).trim() !== '') { + return String(ft.code).trim() + } + return String(data.code ?? '').trim() +} + +export function getSbtiCn(data: Record | null | undefined): string { + if (!data || typeof data !== 'object') return '' + const cn = String(data.sbtiCn ?? '').trim() + if (cn) return cn + const ft = data.finalType + if (ft && typeof ft === 'object' && ft.cn != null && String(ft.cn).trim() !== '') { + return String(ft.cn).trim() + } + return '' +} + +/** 列表摘要、弹窗标题:CODE(中文名) */ +export function formatSbtiSummary(data: Record | null | undefined): string { + const code = getSbtiCode(data) + const cn = getSbtiCn(data) + if (code && cn) return `${code}(${cn})` + if (code) return code + if (cn) return cn + return '' +} + +export function sbtiTypeImageUrl(code: string): string { + if (!code) return '' + return SBTI_TYPE_IMAGES[code] || '' +} + +/** 与小程序 `sbtiData.dimensionOrder` 一致,用于雷达图 15 轴 */ +export const SBTI_RADAR_DIMENSION_ORDER = [ + 'S1', + 'S2', + 'S3', + 'E1', + 'E2', + 'E3', + 'A1', + 'A2', + 'A3', + 'Ac1', + 'Ac2', + 'Ac3', + 'So1', + 'So2', + 'So3' +] as const + +/** + * 从 SBTI 结果 JSON 生成 15 维雷达数值(0~100)。 + * 优先 `rawScores`(每维两题分和约 2~6);否则用 `levels` 的 L/M/H 映射。 + */ +export function buildSbtiRadarValues(parsed: Record | null | undefined): number[] | null { + if (!parsed || typeof parsed !== 'object') return null + const raw = parsed.rawScores as Record | undefined + const levels = parsed.levels as Record | undefined + const vals: number[] = [] + for (const dim of SBTI_RADAR_DIMENSION_ORDER) { + let v: number | null = null + if (raw && raw[dim] != null && raw[dim] !== '') { + const s = Number(raw[dim]) + if (Number.isFinite(s)) { + v = Math.round(((s - 2) / 4) * 100) + v = Math.max(0, Math.min(100, v)) + } + } + if (v == null && levels && levels[dim] != null && levels[dim] !== '') { + const lv = String(levels[dim]).toUpperCase() + if (lv === 'L') v = 34 + else if (lv === 'M') v = 67 + else if (lv === 'H') v = 100 + else v = 50 + } + if (v == null) return null + vals.push(v) + } + return vals +} diff --git a/admin/src/views/admin/Pricing.vue b/admin/src/views/admin/Pricing.vue index d6d28ee..195b6cb 100644 --- a/admin/src/views/admin/Pricing.vue +++ b/admin/src/views/admin/Pricing.vue @@ -42,6 +42,10 @@
+
+ + +
@@ -75,6 +79,10 @@
+
+ + +
@@ -107,8 +115,8 @@ const tabs = [ ] const activeTab = ref('personal') -const personal = reactive({ face: 0, mbti: 0, disc: 0, pdp: 0 }) -const enterprise = reactive({ face: 0, mbti: 0, disc: 0, pdp: 0 }) +const personal = reactive({ face: 0, mbti: 0, disc: 0, pdp: 0, sbti: 0 }) +const enterprise = reactive({ face: 0, mbti: 0, disc: 0, pdp: 0, sbti: 0 }) const loading = ref(false) const isUsingSuperAdminPersonalConfig = ref(false) @@ -161,7 +169,8 @@ const saveEnterprise = async () => { face: enterprise.face, mbti: enterprise.mbti, disc: enterprise.disc, - pdp: enterprise.pdp + pdp: enterprise.pdp, + sbti: enterprise.sbti } }) if (response.code === 200) { diff --git a/admin/src/views/admin/Questions.vue b/admin/src/views/admin/Questions.vue index d5c913f..7a8260b 100644 --- a/admin/src/views/admin/Questions.vue +++ b/admin/src/views/admin/Questions.vue @@ -3,7 +3,7 @@