feat: SBTI 测评接入与管理端题目/财务/用户联动
1、修复了用户详情、定价与题目管理、财务与分销等管理端展示与接口问题。 2、新增 SBTI 小程序测评/结果页、sbtiData 引擎、题库 SQL 与脚本;admin sbtiDisplay。 3、优化 AppConfig/CrmReport/Test、历史与个人中心、test-select 与支付相关逻辑。 Made-with: Cursor
This commit is contained in:
@@ -45,6 +45,11 @@
|
||||
<el-tooltip content="DISC" placement="top">
|
||||
<div class="ud-quick-stats__tile"><el-icon><PieChart /></el-icon>{{ shortOrDash(user.discType, 16) }}</div>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="SBTI" placement="top">
|
||||
<div class="ud-quick-stats__tile ud-quick-stats__tile--sbti">
|
||||
<el-icon><Grid /></el-icon>{{ shortOrDash(user.sbtiType, 18) }}
|
||||
</div>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div class="ud-dimension-tags" v-if="profileTags.length">
|
||||
<div class="ud-dimension-tags__title">维度标签</div>
|
||||
@@ -73,6 +78,26 @@
|
||||
<VChart v-if="discRadarOption" class="ud-chart-echart" :option="discRadarOption" autoresize />
|
||||
<div v-else class="ud-chart-placeholder">暂无 DISC 测评</div>
|
||||
</div>
|
||||
<div class="ud-chart-panel ud-chart-panel--sbti">
|
||||
<div class="ud-chart-heading ud-chart-heading--sbti"><el-icon><Grid /></el-icon> SBTI</div>
|
||||
<VChart
|
||||
v-if="sbtiRadarOption"
|
||||
class="ud-chart-echart ud-chart-echart--sbti"
|
||||
:option="sbtiRadarOption"
|
||||
autoresize
|
||||
/>
|
||||
<div
|
||||
v-else-if="latestSbti"
|
||||
class="ud-chart-placeholder ud-chart-placeholder--sbti-fallback ud-sbti-placeholder-text"
|
||||
>
|
||||
{{ sbtiPanelSummary || '—' }}
|
||||
<span class="ud-sbti-radar-missing">(无 15 维 rawScores/levels,无法绘图)</span>
|
||||
</div>
|
||||
<div v-else class="ud-chart-placeholder">暂无 SBTI 测评</div>
|
||||
<div v-if="sbtiPanelSummary && sbtiRadarOption" class="ud-chart-footnote ud-chart-footnote--sbti">
|
||||
{{ sbtiPanelSummary }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ud-dual-cards">
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
119
admin/src/utils/sbtiDisplay.ts
Normal file
119
admin/src/utils/sbtiDisplay.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* SBTI 展示与类型图 URL(与小程序 miniprogram/utils/sbtiData.js TYPE_IMAGES 一致)
|
||||
*/
|
||||
export const SBTI_TYPE_IMAGES: Record<string, string> = {
|
||||
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<string, any> | 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<string, any> | 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<string, any> | 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<string, any> | null | undefined): number[] | null {
|
||||
if (!parsed || typeof parsed !== 'object') return null
|
||||
const raw = parsed.rawScores as Record<string, unknown> | undefined
|
||||
const levels = parsed.levels as Record<string, unknown> | 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
|
||||
}
|
||||
@@ -42,6 +42,10 @@
|
||||
<label>PDP测试价格 (元/次)</label>
|
||||
<el-input-number v-model="personal.pdp" :min="0" :precision="2" :controls="false" class="w-full" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>SBTI测试价格 (元/次)</label>
|
||||
<el-input-number v-model="personal.sbti" :min="0" :precision="2" :controls="false" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isUsingSuperAdminPersonalConfig" class="notice-box">
|
||||
<el-icon class="notice-icon"><InfoFilled /></el-icon>
|
||||
@@ -75,6 +79,10 @@
|
||||
<label>PDP测试价格 (元/次)</label>
|
||||
<el-input-number v-model="enterprise.pdp" :min="0" :precision="2" :controls="false" class="w-full" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>SBTI测试价格 (元/次)</label>
|
||||
<el-input-number v-model="enterprise.sbti" :min="0" :precision="2" :controls="false" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isUsingSuperAdminEnterpriseConfig" class="notice-box">
|
||||
<el-icon class="notice-icon"><InfoFilled /></el-icon>
|
||||
@@ -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) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div v-if="!embedded" class="page-header">
|
||||
<div class="header-left">
|
||||
<h2>题库管理</h2>
|
||||
<p class="subtitle">管理 MBTI、DISC、PDP 三套测试题库的导入、导出和验证</p>
|
||||
<p class="subtitle">管理 MBTI、SBTI、DISC、PDP 四套测试题库的导入、导出和验证</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button variant="outline" @click="refresh">
|
||||
@@ -131,6 +131,7 @@
|
||||
<el-form-item label="题型" v-if="!isEditing">
|
||||
<el-radio-group v-model="editForm.type">
|
||||
<el-radio-button value="mbti">MBTI</el-radio-button>
|
||||
<el-radio-button value="sbti">SBTI</el-radio-button>
|
||||
<el-radio-button value="disc">DISC</el-radio-button>
|
||||
<el-radio-button value="pdp">PDP</el-radio-button>
|
||||
</el-radio-group>
|
||||
@@ -141,7 +142,7 @@
|
||||
<el-input v-model="editForm.question" type="textarea" :rows="3" placeholder="请输入题目内容" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 维度(仅 MBTI) -->
|
||||
<!-- 维度(MBTI / SBTI) -->
|
||||
<el-form-item label="所属维度" v-if="editForm.type === 'mbti'">
|
||||
<el-select v-model="editForm.dimension" placeholder="请选择维度">
|
||||
<el-option label="EI(内外向)" value="EI" />
|
||||
@@ -150,6 +151,11 @@
|
||||
<el-option label="JP(判断感知)" value="JP" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="所属维度" v-else-if="editForm.type === 'sbti'">
|
||||
<el-select v-model="editForm.dimension" placeholder="请选择 SBTI 维度" filterable>
|
||||
<el-option v-for="d in sbtiDimensionValues" :key="d" :label="d" :value="d" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 选项列表 -->
|
||||
<el-form-item label="选项">
|
||||
@@ -183,7 +189,7 @@
|
||||
<div class="resources mt-6">
|
||||
<p class="res-title"><el-icon class="mr-1"><InfoFilled /></el-icon>题库参考资源</p>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8" v-for="bank in questionBanks" :key="bank.type">
|
||||
<el-col :xs="24" :sm="12" :lg="6" v-for="bank in questionBanks" :key="bank.type">
|
||||
<div :class="['res-card', bank.color as string]">
|
||||
<p class="name">{{ bank.resourceName }}</p>
|
||||
<p v-for="(link, idx) in bank.resources" :key="idx" class="link">{{ link }}</p>
|
||||
@@ -191,7 +197,7 @@
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<p class="res-tip">提示:导入格式为 JSON 数组,每题包含 id、question、options 字段。MBTI 还需包含 dimension 字段(EI/SN/TF/JP)。</p>
|
||||
<p class="res-tip">提示:导入格式为 JSON 数组,每题包含 type、question、options 字段。MBTI 需 dimension(EI/SN/TF/JP);SBTI 需 dimension(如 S1、DG1、DG2 等)。</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -199,11 +205,15 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Refresh, Cpu, Aim, MagicStick, Upload, Download, CircleCheck, Search, View, InfoFilled, Plus, Edit, Delete } from '@element-plus/icons-vue'
|
||||
import { Refresh, Cpu, Aim, MagicStick, ChatDotRound, Upload, Download, CircleCheck, Search, View, InfoFilled, Plus, Edit, Delete } from '@element-plus/icons-vue'
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
|
||||
|
||||
const sbtiDimensionValues = [
|
||||
'S1', 'S2', 'S3', 'E1', 'E2', 'E3', 'A1', 'A2', 'A3', 'Ac1', 'Ac2', 'Ac3', 'So1', 'So2', 'So3', 'DG1', 'DG2'
|
||||
] as const
|
||||
|
||||
const search = ref('')
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const showOptions = ref(false)
|
||||
@@ -265,6 +275,21 @@ const questionBanks = reactive([
|
||||
],
|
||||
resourceStat: '当前: 0题',
|
||||
questions: [] as any[]
|
||||
},
|
||||
{
|
||||
type: 'sbti',
|
||||
name: 'SBTI',
|
||||
desc: '15 维等级 + 闸口题',
|
||||
count: 0,
|
||||
color: 'amber',
|
||||
icon: ChatDotRound,
|
||||
dimensions: [] as string[],
|
||||
lastUpdate: '',
|
||||
fileSize: '0KB',
|
||||
resourceName: 'SBTI 标准题库',
|
||||
resources: ['维度:S/E/A/Ac/So + DG1/DG2', '与 aisbti 公开页计分一致'],
|
||||
resourceStat: '当前: 0题',
|
||||
questions: [] as any[]
|
||||
}
|
||||
])
|
||||
|
||||
@@ -375,8 +400,8 @@ const loadQuestions = async () => {
|
||||
bank.count = response.data.total || 0
|
||||
bank.resourceStat = `当前: ${bank.count}题`
|
||||
|
||||
// 计算维度统计(仅MBTI)
|
||||
if (selectedBank.value === 'mbti') {
|
||||
// 计算维度统计(MBTI / SBTI)
|
||||
if (selectedBank.value === 'mbti' || selectedBank.value === 'sbti') {
|
||||
const dimensionCounts: Record<string, number> = {}
|
||||
questions.forEach((q: any) => {
|
||||
if (q.dimension) {
|
||||
@@ -458,7 +483,9 @@ const handleImport = async (event: Event) => {
|
||||
if (!q.type) errors.push(`第${index + 1}题:缺少type字段`)
|
||||
if (!q.question) errors.push(`第${index + 1}题:缺少question字段`)
|
||||
if (!q.options || !Array.isArray(q.options)) errors.push(`第${index + 1}题:options必须是数组`)
|
||||
if (q.type === 'mbti' && !q.dimension) errors.push(`第${index + 1}题:MBTI类型必须包含dimension字段`)
|
||||
if ((q.type === 'mbti' || q.type === 'sbti') && !q.dimension) {
|
||||
errors.push(`第${index + 1}题:MBTI/SBTI 必须包含 dimension 字段`)
|
||||
}
|
||||
})
|
||||
|
||||
if (errors.length > 0) {
|
||||
@@ -552,7 +579,7 @@ const handleValidate = async () => {
|
||||
if (!q.options || !Array.isArray(q.options) || q.options.length === 0) {
|
||||
errors.push(`第${index + 1}题:选项格式错误或为空`)
|
||||
}
|
||||
if (selectedBank.value === 'mbti' && !q.dimension) {
|
||||
if ((selectedBank.value === 'mbti' || selectedBank.value === 'sbti') && !q.dimension) {
|
||||
errors.push(`第${index + 1}题:缺少维度信息`)
|
||||
}
|
||||
if (q.dimension) {
|
||||
@@ -649,6 +676,10 @@ const saveQuestion = async () => {
|
||||
ElMessage.error('选项文字不能为空')
|
||||
return
|
||||
}
|
||||
if ((editForm.type === 'mbti' || editForm.type === 'sbti') && !String(editForm.dimension || '').trim()) {
|
||||
ElMessage.error('请选择所属维度')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const payload: any = {
|
||||
@@ -657,7 +688,7 @@ const saveQuestion = async () => {
|
||||
options: editForm.options,
|
||||
sort: editForm.sort
|
||||
}
|
||||
if (editForm.type === 'mbti') payload.dimension = editForm.dimension
|
||||
if (editForm.type === 'mbti' || editForm.type === 'sbti') payload.dimension = editForm.dimension
|
||||
|
||||
if (isEditing.value) {
|
||||
await request.put(`/admin/questions/${editForm.id}`, payload)
|
||||
@@ -741,9 +772,10 @@ onMounted(async () => {
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.test-type-card {
|
||||
@@ -788,6 +820,7 @@ onMounted(async () => {
|
||||
&.purple { background-color: #faf5ff; color: #a855f7; }
|
||||
&.blue { background-color: #eff6ff; color: #3b82f6; }
|
||||
&.green { background-color: #f0fdf4; color: #22c55e; }
|
||||
&.amber { background-color: #fffbeb; color: #d97706; }
|
||||
}
|
||||
.text {
|
||||
.name { font-weight: 700; color: #111827; margin: 0; font-size: 15px; }
|
||||
@@ -841,6 +874,7 @@ onMounted(async () => {
|
||||
.purple-text { color: #a855f7; }
|
||||
.blue-text { color: #3b82f6; }
|
||||
.green-text { color: #22c55e; }
|
||||
.amber-text { color: #d97706; }
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
@@ -1025,6 +1059,7 @@ onMounted(async () => {
|
||||
&.purple { background-color: #faf5ff; .name { color: #7e22ce; } .link, .stat { color: #9333ea; } }
|
||||
&.blue { background-color: #eff6ff; .name { color: #1d4ed8; } .link, .stat { color: #2563eb; } }
|
||||
&.green { background-color: #f0fdf4; .name { color: #15803d; } .link, .stat { color: #16a34a; } }
|
||||
&.amber { background-color: #fffbeb; .name { color: #b45309; } .link, .stat { color: #d97706; } }
|
||||
|
||||
.name { font-size: 15px; font-weight: 700; margin: 0 0 8px; }
|
||||
.link { font-size: 13px; margin: 2px 0; opacity: 0.9; line-height: 1.6; }
|
||||
@@ -1059,6 +1094,18 @@ onMounted(async () => {
|
||||
.mt-6 { margin-top: 24px; }
|
||||
.mr-1 { margin-right: 4px; }
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.page-container.is-embedded {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
@@ -221,13 +221,14 @@ const accountConfig = reactive({
|
||||
const permItems = [
|
||||
{ key: 'face', label: '人脸分析' },
|
||||
{ key: 'mbti', label: 'MBTI' },
|
||||
{ key: 'sbti', label: 'SBTI' },
|
||||
{ key: 'pdp', label: 'PDP' },
|
||||
{ key: 'disc', label: 'DISC' },
|
||||
{ key: 'distribution', label: '分销推广' }
|
||||
] as const
|
||||
|
||||
const defaultAdminPermissions = () =>
|
||||
({ face: true, mbti: true, pdp: true, disc: true, distribution: true }) as Record<string, boolean>
|
||||
({ face: true, mbti: true, sbti: true, pdp: true, disc: true, distribution: true }) as Record<string, boolean>
|
||||
|
||||
const permLoading = ref(false)
|
||||
const permSaving = ref(false)
|
||||
|
||||
@@ -329,6 +329,42 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SBTI 图文详情 -->
|
||||
<div v-else-if="currentTestType === 'sbti' && sbtiDetail" class="detail-section test-detail-sbti">
|
||||
<div class="test-detail-main-card test-detail-main-card--sbti">
|
||||
<div class="test-detail-main-left">
|
||||
<div class="sbti-type-line">
|
||||
<span class="sbti-code-badge">{{ sbtiDetail.code || '—' }}</span>
|
||||
<span v-if="sbtiDetail.cn" class="sbti-cn">· {{ sbtiDetail.cn }}</span>
|
||||
</div>
|
||||
<p v-if="sbtiDetail.intro" class="sbti-intro">{{ sbtiDetail.intro }}</p>
|
||||
<p v-if="sbtiDetail.desc" class="sbti-long-desc">{{ sbtiDetail.desc }}</p>
|
||||
</div>
|
||||
<div v-if="sbtiDetail.imageUrl" class="test-detail-sbti-right">
|
||||
<el-image
|
||||
class="sbti-result-img"
|
||||
:src="sbtiDetail.imageUrl"
|
||||
fit="contain"
|
||||
referrerpolicy="no-referrer"
|
||||
>
|
||||
<template #error>
|
||||
<div class="sbti-img-fallback">{{ sbtiDetail.summaryLine }}</div>
|
||||
</template>
|
||||
</el-image>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="sbtiDetail.levelsList?.length" class="test-detail-grid">
|
||||
<div class="test-detail-block">
|
||||
<h4 class="detail-subtitle">15 维等级</h4>
|
||||
<div class="sbti-level-tags">
|
||||
<el-tag v-for="row in sbtiDetail.levelsList" :key="row.key" size="small" class="sbti-level-tag">
|
||||
{{ row.key }}:{{ row.val }}<template v-if="row.raw != null"> · {{ row.raw }}分</template>
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI 人脸分析 图文详情 -->
|
||||
<div v-else-if="(currentTestType === 'face' || currentTestType === 'ai') && faceDetail" class="detail-section test-detail-face">
|
||||
<div class="face-layout">
|
||||
@@ -643,7 +679,13 @@
|
||||
|
||||
<div
|
||||
class="detail-section"
|
||||
v-if="currentTestType !== 'resume' && currentTestType !== 'face' && currentTestType !== 'ai' && supplementaryNote"
|
||||
v-if="
|
||||
currentTestType !== 'resume' &&
|
||||
currentTestType !== 'face' &&
|
||||
currentTestType !== 'ai' &&
|
||||
currentTestType !== 'sbti' &&
|
||||
supplementaryNote
|
||||
"
|
||||
>
|
||||
<h4 class="detail-subtitle">补充说明</h4>
|
||||
<p class="test-desc">{{ supplementaryNote }}</p>
|
||||
@@ -661,6 +703,7 @@ import { request } from '@/utils/request'
|
||||
import { buildFaceDetailFromParsed, faceDiscDisplayLabel, facePdpDisplayLabel } from '@/utils/faceResultDetail'
|
||||
import { parseTestResultPayload } from '@/utils/testResultParse'
|
||||
import { discTopTwoLabel, discStyleSubtitle, discStyleName } from '@/utils/discDisplay'
|
||||
import { formatSbtiSummary, getSbtiCode, getSbtiCn, sbtiTypeImageUrl } from '@/utils/sbtiDisplay'
|
||||
import UserDetailDialog from '@/components/UserDetailDialog.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -760,6 +803,7 @@ function formatTestType(testType: string) {
|
||||
if (t === 'pdp') return 'PDP'
|
||||
if (t === 'face' || t === 'ai') return 'AI 人脸分析'
|
||||
if (t === 'resume') return '简历综合分析'
|
||||
if (t === 'sbti') return 'SBTI'
|
||||
return testType
|
||||
}
|
||||
|
||||
@@ -801,6 +845,11 @@ function extractTestSummary(test: any): string {
|
||||
return c ? c.substring(0, 30).replace(/\n/g, ' ') + (c.length > 30 ? '...' : '') : '简历综合分析'
|
||||
}
|
||||
|
||||
if (type === 'sbti') {
|
||||
const line = formatSbtiSummary(data)
|
||||
if (line) return line
|
||||
}
|
||||
|
||||
return String(data.type ?? data.result ?? '')
|
||||
}
|
||||
|
||||
@@ -865,6 +914,37 @@ const pdpDetail = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const sbtiDetail = computed(() => {
|
||||
const parsed = currentTestParsed.value
|
||||
if (!parsed || currentTestType.value !== 'sbti') return null
|
||||
const code = getSbtiCode(parsed)
|
||||
const cn = getSbtiCn(parsed)
|
||||
const ft = parsed.finalType && typeof parsed.finalType === 'object' ? parsed.finalType : null
|
||||
const intro = String(parsed.intro ?? ft?.intro ?? '')
|
||||
const desc = String(parsed.desc ?? ft?.desc ?? '')
|
||||
const levels = parsed.levels && typeof parsed.levels === 'object' ? parsed.levels : {}
|
||||
const rawScores = parsed.rawScores && typeof parsed.rawScores === 'object' ? parsed.rawScores : {}
|
||||
const levelsList = Object.entries(levels).map(([key, val]) => {
|
||||
const r = (rawScores as Record<string, unknown>)[key]
|
||||
let raw: number | null = null
|
||||
if (r != null && r !== '') {
|
||||
const n = Number(r)
|
||||
if (Number.isFinite(n)) raw = n
|
||||
}
|
||||
return { key, val: String(val), raw }
|
||||
})
|
||||
const imageUrl = sbtiTypeImageUrl(code)
|
||||
return {
|
||||
code,
|
||||
cn,
|
||||
intro,
|
||||
desc,
|
||||
levelsList,
|
||||
imageUrl,
|
||||
summaryLine: formatSbtiSummary(parsed)
|
||||
}
|
||||
})
|
||||
|
||||
const supplementaryNote = computed(() => {
|
||||
const t = String(currentTestDescription.value || '').trim()
|
||||
if (!t) return ''
|
||||
@@ -881,6 +961,10 @@ const supplementaryNote = computed(() => {
|
||||
const main = String(pdpDetail.value.description || '').trim()
|
||||
if (main && main === t) return ''
|
||||
}
|
||||
if (typ === 'sbti' && sbtiDetail.value) {
|
||||
const main = String(sbtiDetail.value.desc || '').trim()
|
||||
if (main && main === t) return ''
|
||||
}
|
||||
return t
|
||||
})
|
||||
|
||||
@@ -915,6 +999,11 @@ function extractTestDescription(parsed: any, testType: string): string {
|
||||
return String(parsed.description?.description ?? '')
|
||||
}
|
||||
|
||||
if (t === 'sbti') {
|
||||
const ft = parsed.finalType && typeof parsed.finalType === 'object' ? parsed.finalType : null
|
||||
return String(parsed.desc ?? ft?.desc ?? '')
|
||||
}
|
||||
|
||||
if (t === 'face' || t === 'ai') {
|
||||
const parts = [
|
||||
parsed.relationship,
|
||||
@@ -957,6 +1046,7 @@ function normalizeDetailUser(payload: any) {
|
||||
merged.mbtiType = merged.mbtiType ?? user.mbtiType
|
||||
merged.pdpType = merged.pdpType ?? user.pdpType
|
||||
merged.discType = merged.discType ?? user.discType
|
||||
merged.sbtiType = merged.sbtiType ?? user.sbtiType
|
||||
merged.faceType = merged.faceType ?? user.faceType
|
||||
return merged
|
||||
}
|
||||
@@ -1354,6 +1444,80 @@ onMounted(() => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.test-detail-main-card--sbti {
|
||||
background-color: #f2f7f3;
|
||||
border: 1px solid #e3ebe6;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.sbti-type-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sbti-code-badge {
|
||||
display: inline-flex;
|
||||
padding: 4px 10px;
|
||||
border-radius: 8px;
|
||||
background: #e8efe9;
|
||||
color: #5a7268;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sbti-cn {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #374c40;
|
||||
}
|
||||
|
||||
.sbti-intro {
|
||||
font-size: 13px;
|
||||
color: #5a7268;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.sbti-long-desc {
|
||||
font-size: 13px;
|
||||
color: #4b5563;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.test-detail-sbti-right {
|
||||
width: 160px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sbti-result-img {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
}
|
||||
|
||||
.sbti-img-fallback {
|
||||
font-size: 12px;
|
||||
color: #5a7268;
|
||||
padding: 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.sbti-level-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sbti-level-tag {
|
||||
--el-tag-bg-color: #f2f7f3;
|
||||
--el-tag-border-color: #dbe8e0;
|
||||
--el-tag-text-color: #5a7268;
|
||||
}
|
||||
|
||||
.mbti-type-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -917,12 +917,13 @@ const statusOptions = [
|
||||
const permItems = [
|
||||
{ key: 'face', label: '人脸分析' },
|
||||
{ key: 'mbti', label: 'MBTI' },
|
||||
{ key: 'sbti', label: 'SBTI' },
|
||||
{ key: 'pdp', label: 'PDP' },
|
||||
{ key: 'disc', label: 'DISC' },
|
||||
{ key: 'distribution', label: '分销' },
|
||||
]
|
||||
|
||||
const defaultPermissions = () => ({ face: true, mbti: true, pdp: true, disc: true, distribution: true })
|
||||
const defaultPermissions = () => ({ face: true, mbti: true, sbti: true, pdp: true, disc: true, distribution: true })
|
||||
|
||||
/** 列表/展示:超管授权上限(兼容未返回 permissionsCeiling 的旧接口) */
|
||||
const permCeilingVal = (row: Record<string, any>, key: string) => {
|
||||
|
||||
@@ -75,6 +75,16 @@
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>SBTI测试价格 (元/次)</label>
|
||||
<el-input-number
|
||||
v-model="personal.sbti"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:controls="false"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="save-actions">
|
||||
<el-button type="primary" color="#ef4444" class="save-btn" @click="savePersonal">
|
||||
@@ -128,6 +138,16 @@
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>SBTI测试价格 (元/次)</label>
|
||||
<el-input-number
|
||||
v-model="enterprise.sbti"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:controls="false"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>最低充值金额 (元)</label>
|
||||
<el-input-number
|
||||
@@ -443,7 +463,8 @@ const personal = reactive({
|
||||
face: 9.9,
|
||||
mbti: 9.9,
|
||||
disc: 9.9,
|
||||
pdp: 9.9
|
||||
pdp: 9.9,
|
||||
sbti: 9.9
|
||||
})
|
||||
|
||||
const enterprise = reactive({
|
||||
@@ -451,6 +472,7 @@ const enterprise = reactive({
|
||||
mbti: 8.0,
|
||||
pdp: 8.0,
|
||||
disc: 8.0,
|
||||
sbti: 8.0,
|
||||
minRecharge: 1000.0
|
||||
})
|
||||
|
||||
@@ -617,6 +639,7 @@ const saveEnterprise = async () => {
|
||||
mbti: enterprise.mbti,
|
||||
pdp: enterprise.pdp,
|
||||
disc: enterprise.disc,
|
||||
sbti: enterprise.sbti,
|
||||
minRecharge: enterprise.minRecharge
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div v-if="!embedded" class="page-header">
|
||||
<div class="header-left">
|
||||
<h2>题库管理</h2>
|
||||
<p class="subtitle">管理全局题库,包括 MBTI、DISC、PDP 三套测试题库</p>
|
||||
<p class="subtitle">管理全局题库,包括 MBTI、SBTI、DISC、PDP 四套测试题库</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button variant="outline" @click="refresh">
|
||||
@@ -154,6 +154,7 @@
|
||||
<el-form-item label="题型" v-if="!isEditing">
|
||||
<el-radio-group v-model="editForm.type">
|
||||
<el-radio-button value="mbti">MBTI</el-radio-button>
|
||||
<el-radio-button value="sbti">SBTI</el-radio-button>
|
||||
<el-radio-button value="disc">DISC</el-radio-button>
|
||||
<el-radio-button value="pdp">PDP</el-radio-button>
|
||||
</el-radio-group>
|
||||
@@ -169,6 +170,11 @@
|
||||
<el-option label="JP(判断感知)" value="JP" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="所属维度" v-else-if="editForm.type === 'sbti'">
|
||||
<el-select v-model="editForm.dimension" placeholder="请选择 SBTI 维度" filterable>
|
||||
<el-option v-for="d in sbtiDimensionValues" :key="d" :label="d" :value="d" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="选项">
|
||||
<div class="option-editor">
|
||||
<div v-for="(opt, idx) in editForm.options" :key="idx" class="option-row">
|
||||
@@ -211,7 +217,7 @@
|
||||
<p class="stat">{{ bank.resourceStat }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="res-tip">提示:导入格式为 JSON 数组,每题包含 id、question、options 字段。MBTI 还需包含 dimension 字段(EI/SN/TF/JP)。</p>
|
||||
<p class="res-tip">提示:导入格式为 JSON 数组,每题包含 id、question、options 字段。MBTI 需 dimension(EI/SN/TF/JP);SBTI 需 dimension(如 S1、DG1、DG2 等)。</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -219,11 +225,16 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Refresh, Cpu, Aim, MagicStick, Upload, Download, CircleCheck, Search, View, InfoFilled, Plus, Edit, Delete } from '@element-plus/icons-vue'
|
||||
import { Refresh, Cpu, Aim, MagicStick, ChatDotRound, Upload, Download, CircleCheck, Search, View, InfoFilled, Plus, Edit, Delete } from '@element-plus/icons-vue'
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
|
||||
|
||||
/** SBTI 题库维度(与入库脚本一致) */
|
||||
const sbtiDimensionValues = [
|
||||
'S1', 'S2', 'S3', 'E1', 'E2', 'E3', 'A1', 'A2', 'A3', 'Ac1', 'Ac2', 'Ac3', 'So1', 'So2', 'So3', 'DG1', 'DG2'
|
||||
] as const
|
||||
|
||||
const search = ref('')
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const showOptions = ref(false)
|
||||
@@ -288,6 +299,21 @@ const questionBanks = reactive([
|
||||
],
|
||||
resourceStat: '当前: 0题',
|
||||
questions: [] as any[]
|
||||
},
|
||||
{
|
||||
type: 'sbti',
|
||||
name: 'SBTI',
|
||||
desc: '15 维等级 + 闸口题',
|
||||
count: 0,
|
||||
color: 'amber',
|
||||
icon: ChatDotRound,
|
||||
dimensions: [] as string[],
|
||||
lastUpdate: '',
|
||||
fileSize: '0KB',
|
||||
resourceName: 'SBTI 标准题库',
|
||||
resources: ['维度:S/E/A/Ac/So + DG1/DG2', '与 aisbti 公开页计分一致'],
|
||||
resourceStat: '当前: 0题',
|
||||
questions: [] as any[]
|
||||
}
|
||||
])
|
||||
|
||||
@@ -398,8 +424,8 @@ const loadQuestions = async () => {
|
||||
bank.count = response.data.total || 0
|
||||
bank.resourceStat = `当前: ${bank.count}题`
|
||||
|
||||
// 计算维度统计(仅MBTI)
|
||||
if (selectedBank.value === 'mbti') {
|
||||
// 计算维度统计(MBTI / SBTI)
|
||||
if (selectedBank.value === 'mbti' || selectedBank.value === 'sbti') {
|
||||
const dimensionCounts: Record<string, number> = {}
|
||||
questions.forEach((q: any) => {
|
||||
if (q.dimension) {
|
||||
@@ -472,8 +498,8 @@ const handleImport = async (event: Event) => {
|
||||
status: item.status !== undefined ? item.status : 1
|
||||
}
|
||||
|
||||
// MBTI类型需要dimension
|
||||
if (selectedBank.value === 'mbti') {
|
||||
// MBTI / SBTI 需要 dimension
|
||||
if (selectedBank.value === 'mbti' || selectedBank.value === 'sbti') {
|
||||
question.dimension = item.dimension || ''
|
||||
}
|
||||
|
||||
@@ -569,7 +595,7 @@ const handleValidate = () => {
|
||||
errors.push(`第${index + 1}题:选项为空`)
|
||||
}
|
||||
|
||||
if (selectedBank.value === 'mbti' && !q.dimension) {
|
||||
if ((selectedBank.value === 'mbti' || selectedBank.value === 'sbti') && !q.dimension) {
|
||||
isValid = false
|
||||
errors.push(`第${index + 1}题:缺少维度信息`)
|
||||
}
|
||||
@@ -664,6 +690,10 @@ const saveQuestion = async () => {
|
||||
ElMessage.error('选项文字不能为空')
|
||||
return
|
||||
}
|
||||
if ((editForm.type === 'mbti' || editForm.type === 'sbti') && !String(editForm.dimension || '').trim()) {
|
||||
ElMessage.error('请选择所属维度')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const payload: any = {
|
||||
@@ -672,7 +702,7 @@ const saveQuestion = async () => {
|
||||
options: editForm.options,
|
||||
sort: editForm.sort
|
||||
}
|
||||
if (editForm.type === 'mbti') payload.dimension = editForm.dimension
|
||||
if (editForm.type === 'mbti' || editForm.type === 'sbti') payload.dimension = editForm.dimension
|
||||
|
||||
if (isEditing.value) {
|
||||
await request.put(`/superadmin/questions/${editForm.id}`, payload)
|
||||
@@ -733,11 +763,13 @@ const deleteQuestion = async (q: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
/* 卡片均分:大屏四等分,中屏两等分,小屏单列 */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.test-type-card {
|
||||
@@ -791,6 +823,7 @@ const deleteQuestion = async (q: any) => {
|
||||
&.purple { background-color: #faf5ff; color: #a855f7; }
|
||||
&.blue { background-color: #eff6ff; color: #3b82f6; }
|
||||
&.green { background-color: #f0fdf4; color: #22c55e; }
|
||||
&.amber { background-color: #fffbeb; color: #d97706; }
|
||||
}
|
||||
|
||||
.text {
|
||||
@@ -855,6 +888,10 @@ const deleteQuestion = async (q: any) => {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.amber-text {
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
@@ -1035,9 +1072,10 @@ const deleteQuestion = async (q: any) => {
|
||||
|
||||
.res-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.res-card {
|
||||
@@ -1048,6 +1086,7 @@ const deleteQuestion = async (q: any) => {
|
||||
&.purple { background-color: #faf5ff; border-color: #e9d5ff; }
|
||||
&.blue { background-color: #eff6ff; border-color: #dbeafe; }
|
||||
&.green { background-color: #f0fdf4; border-color: #bbf7d0; }
|
||||
&.amber { background-color: #fffbeb; border-color: #fde68a; }
|
||||
|
||||
.name {
|
||||
font-size: 15px;
|
||||
@@ -1110,8 +1149,16 @@ const deleteQuestion = async (q: any) => {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.stats-grid, .res-grid {
|
||||
@media (max-width: 1024px) {
|
||||
.stats-grid,
|
||||
.resources .res-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.stats-grid,
|
||||
.resources .res-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,6 +415,42 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SBTI 图文详情 -->
|
||||
<div v-else-if="currentTestType === 'sbti' && sbtiDetail" class="detail-section test-detail-sbti">
|
||||
<div class="test-detail-main-card test-detail-main-card--sbti">
|
||||
<div class="test-detail-main-left">
|
||||
<div class="sbti-type-line">
|
||||
<span class="sbti-code-badge">{{ sbtiDetail.code || '—' }}</span>
|
||||
<span v-if="sbtiDetail.cn" class="sbti-cn">· {{ sbtiDetail.cn }}</span>
|
||||
</div>
|
||||
<p v-if="sbtiDetail.intro" class="sbti-intro">{{ sbtiDetail.intro }}</p>
|
||||
<p v-if="sbtiDetail.desc" class="sbti-long-desc">{{ sbtiDetail.desc }}</p>
|
||||
</div>
|
||||
<div v-if="sbtiDetail.imageUrl" class="test-detail-sbti-right">
|
||||
<el-image
|
||||
class="sbti-result-img"
|
||||
:src="sbtiDetail.imageUrl"
|
||||
fit="contain"
|
||||
referrerpolicy="no-referrer"
|
||||
>
|
||||
<template #error>
|
||||
<div class="sbti-img-fallback">{{ sbtiDetail.summaryLine }}</div>
|
||||
</template>
|
||||
</el-image>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="sbtiDetail.levelsList?.length" class="test-detail-grid">
|
||||
<div class="test-detail-block">
|
||||
<h4 class="detail-subtitle">15 维等级</h4>
|
||||
<div class="sbti-level-tags">
|
||||
<el-tag v-for="row in sbtiDetail.levelsList" :key="row.key" size="small" class="sbti-level-tag">
|
||||
{{ row.key }}:{{ row.val }}<template v-if="row.raw != null"> · {{ row.raw }}分</template>
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI 人脸分析 图文详情 -->
|
||||
<div v-else-if="(currentTestType === 'face' || currentTestType === 'ai') && faceDetail" class="detail-section test-detail-face">
|
||||
<div class="face-layout">
|
||||
@@ -722,7 +758,13 @@
|
||||
<!-- 通用补充说明(人脸/AI 已在上方分块展示,避免重复堆砌) -->
|
||||
<div
|
||||
class="detail-section"
|
||||
v-if="currentTestType !== 'resume' && currentTestType !== 'face' && currentTestType !== 'ai' && supplementaryNote"
|
||||
v-if="
|
||||
currentTestType !== 'resume' &&
|
||||
currentTestType !== 'face' &&
|
||||
currentTestType !== 'ai' &&
|
||||
currentTestType !== 'sbti' &&
|
||||
supplementaryNote
|
||||
"
|
||||
>
|
||||
<h4 class="detail-subtitle">补充说明</h4>
|
||||
<p class="test-desc">{{ supplementaryNote }}</p>
|
||||
@@ -740,6 +782,7 @@ import { request } from '@/utils/request'
|
||||
import { buildFaceDetailFromParsed, faceDiscDisplayLabel, facePdpDisplayLabel } from '@/utils/faceResultDetail'
|
||||
import { parseTestResultPayload } from '@/utils/testResultParse'
|
||||
import { discTopTwoLabel, discStyleSubtitle, discStyleName } from '@/utils/discDisplay'
|
||||
import { formatSbtiSummary, getSbtiCode, getSbtiCn, sbtiTypeImageUrl } from '@/utils/sbtiDisplay'
|
||||
import UserDetailDialog from '@/components/UserDetailDialog.vue'
|
||||
|
||||
withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
|
||||
@@ -891,6 +934,7 @@ function formatTestType(testType: string) {
|
||||
if (t === 'pdp') return 'PDP'
|
||||
if (t === 'face' || t === 'ai') return 'AI 人脸分析'
|
||||
if (t === 'resume') return '简历综合分析'
|
||||
if (t === 'sbti') return 'SBTI'
|
||||
return testType
|
||||
}
|
||||
|
||||
@@ -932,6 +976,11 @@ function extractTestSummary(test: any): string {
|
||||
return c ? c.substring(0, 30).replace(/\n/g, ' ') + (c.length > 30 ? '...' : '') : '简历综合分析'
|
||||
}
|
||||
|
||||
if (type === 'sbti') {
|
||||
const line = formatSbtiSummary(data)
|
||||
if (line) return line
|
||||
}
|
||||
|
||||
return String(data.type ?? data.result ?? '')
|
||||
}
|
||||
|
||||
@@ -996,6 +1045,37 @@ const pdpDetail = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const sbtiDetail = computed(() => {
|
||||
const parsed = currentTestParsed.value
|
||||
if (!parsed || currentTestType.value !== 'sbti') return null
|
||||
const code = getSbtiCode(parsed)
|
||||
const cn = getSbtiCn(parsed)
|
||||
const ft = parsed.finalType && typeof parsed.finalType === 'object' ? parsed.finalType : null
|
||||
const intro = String(parsed.intro ?? ft?.intro ?? '')
|
||||
const desc = String(parsed.desc ?? ft?.desc ?? '')
|
||||
const levels = parsed.levels && typeof parsed.levels === 'object' ? parsed.levels : {}
|
||||
const rawScores = parsed.rawScores && typeof parsed.rawScores === 'object' ? parsed.rawScores : {}
|
||||
const levelsList = Object.entries(levels).map(([key, val]) => {
|
||||
const r = (rawScores as Record<string, unknown>)[key]
|
||||
let raw: number | null = null
|
||||
if (r != null && r !== '') {
|
||||
const n = Number(r)
|
||||
if (Number.isFinite(n)) raw = n
|
||||
}
|
||||
return { key, val: String(val), raw }
|
||||
})
|
||||
const imageUrl = sbtiTypeImageUrl(code)
|
||||
return {
|
||||
code,
|
||||
cn,
|
||||
intro,
|
||||
desc,
|
||||
levelsList,
|
||||
imageUrl,
|
||||
summaryLine: formatSbtiSummary(parsed)
|
||||
}
|
||||
})
|
||||
|
||||
/** 与主卡片简介重复时不展示「补充说明」块 */
|
||||
const supplementaryNote = computed(() => {
|
||||
const t = String(currentTestDescription.value || '').trim()
|
||||
@@ -1013,6 +1093,10 @@ const supplementaryNote = computed(() => {
|
||||
const main = String(pdpDetail.value.description || '').trim()
|
||||
if (main && main === t) return ''
|
||||
}
|
||||
if (typ === 'sbti' && sbtiDetail.value) {
|
||||
const main = String(sbtiDetail.value.desc || '').trim()
|
||||
if (main && main === t) return ''
|
||||
}
|
||||
return t
|
||||
})
|
||||
|
||||
@@ -1046,6 +1130,11 @@ function extractTestDescription(parsed: any, testType: string): string {
|
||||
return String(parsed.description?.description ?? '')
|
||||
}
|
||||
|
||||
if (t === 'sbti') {
|
||||
const ft = parsed.finalType && typeof parsed.finalType === 'object' ? parsed.finalType : null
|
||||
return String(parsed.desc ?? ft?.desc ?? '')
|
||||
}
|
||||
|
||||
if (t === 'face' || t === 'ai') {
|
||||
const parts = [
|
||||
parsed.relationship,
|
||||
@@ -1098,6 +1187,7 @@ function normalizeDetailUser(payload: any) {
|
||||
merged.mbtiType = merged.mbtiType ?? user.mbtiType
|
||||
merged.pdpType = merged.pdpType ?? user.pdpType
|
||||
merged.discType = merged.discType ?? user.discType
|
||||
merged.sbtiType = merged.sbtiType ?? user.sbtiType
|
||||
merged.faceType = merged.faceType ?? user.faceType
|
||||
|
||||
return merged
|
||||
@@ -1745,6 +1835,80 @@ onMounted(() => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.test-detail-main-card--sbti {
|
||||
background-color: #f2f7f3;
|
||||
border: 1px solid #e3ebe6;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.sbti-type-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sbti-code-badge {
|
||||
display: inline-flex;
|
||||
padding: 4px 10px;
|
||||
border-radius: 8px;
|
||||
background: #e8efe9;
|
||||
color: #5a7268;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sbti-cn {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #374c40;
|
||||
}
|
||||
|
||||
.sbti-intro {
|
||||
font-size: 13px;
|
||||
color: #5a7268;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.sbti-long-desc {
|
||||
font-size: 13px;
|
||||
color: #4b5563;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.test-detail-sbti-right {
|
||||
width: 160px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sbti-result-img {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
}
|
||||
|
||||
.sbti-img-fallback {
|
||||
font-size: 12px;
|
||||
color: #5a7268;
|
||||
padding: 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.sbti-level-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sbti-level-tag {
|
||||
--el-tag-bg-color: #f2f7f3;
|
||||
--el-tag-border-color: #dbe8e0;
|
||||
--el-tag-text-color: #5a7268;
|
||||
}
|
||||
|
||||
.mbti-type-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -227,6 +227,7 @@ class AppUser extends BaseController
|
||||
$row['mbtiType'] = $this->extractResultType($testsForUser, 'mbti');
|
||||
$row['pdpType'] = $this->extractResultType($testsForUser, 'pdp');
|
||||
$row['discType'] = $this->extractResultType($testsForUser, 'disc');
|
||||
$row['sbtiType'] = $this->extractResultType($testsForUser, 'sbti');
|
||||
$row['faceType'] = $this->extractResultType($testsForUser, 'face');
|
||||
$row['faceMbtiType'] = $this->extractFaceSubType($testsForUser, 'mbti');
|
||||
$row['faceDiscType'] = $this->extractFaceSubType($testsForUser, 'disc');
|
||||
@@ -315,6 +316,7 @@ class AppUser extends BaseController
|
||||
$data['mbtiType'] = $this->extractResultType($tests, 'mbti');
|
||||
$data['pdpType'] = $this->extractResultType($tests, 'pdp');
|
||||
$data['discType'] = $this->extractResultType($tests, 'disc');
|
||||
$data['sbtiType'] = $this->extractResultType($tests, 'sbti');
|
||||
$data['faceType'] = $this->extractResultType($tests, 'face');
|
||||
$data['faceMbtiType'] = $this->extractFaceSubType($tests, 'mbti');
|
||||
$data['faceDiscType'] = $this->extractFaceSubType($tests, 'disc');
|
||||
|
||||
@@ -1,204 +1,204 @@
|
||||
<?php
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\service\WechatService;
|
||||
use think\facade\Db;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
* 企业财务控制器(企业管理端)
|
||||
*/
|
||||
class Finance extends BaseController
|
||||
{
|
||||
/**
|
||||
* 财务概览
|
||||
*/
|
||||
public function overview()
|
||||
{
|
||||
$enterpriseId = $this->resolveEnterpriseId();
|
||||
if (!$enterpriseId) {
|
||||
return error('未获取到企业信息', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$enterprise = Db::name('enterprises')
|
||||
->where('id', $enterpriseId)
|
||||
->field('id, name, balance')
|
||||
->find();
|
||||
if (!$enterprise) {
|
||||
return error('企业不存在', 404);
|
||||
}
|
||||
|
||||
$todayStart = strtotime(date('Y-m-d 00:00:00'));
|
||||
$monthStart = strtotime(date('Y-m-01 00:00:00'));
|
||||
|
||||
$baseOrderQuery = Db::name('orders')
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->whereIn('status', ['paid', 'completed'])
|
||||
->whereIn('productType', ['face', 'mbti', 'disc', 'pdp']);
|
||||
|
||||
$totalIncomeFen = (int) ((clone $baseOrderQuery)->sum('amount') ?? 0);
|
||||
$todayIncomeFen = (int) ((clone $baseOrderQuery)->where('payTime', '>=', $todayStart)->sum('amount') ?? 0);
|
||||
$monthIncomeFen = (int) ((clone $baseOrderQuery)->where('payTime', '>=', $monthStart)->sum('amount') ?? 0);
|
||||
$paidOrderCount = (int) ((clone $baseOrderQuery)->count());
|
||||
|
||||
$manualRechargeFen = (int) (Db::name('finance_records')
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->where('type', 'recharge')
|
||||
->whereNull('orderId')
|
||||
->sum('amount') ?? 0);
|
||||
|
||||
$frozenCommissionFen = (int) (Db::name('commission_records')
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->where('status', 'frozen')
|
||||
->sum('commissionFen') ?? 0);
|
||||
|
||||
return success([
|
||||
'enterpriseId' => $enterpriseId,
|
||||
'enterpriseName' => $enterprise['name'] ?? '',
|
||||
'balanceFen' => (int) ($enterprise['balance'] ?? 0),
|
||||
'totalIncomeFen' => $totalIncomeFen,
|
||||
'todayIncomeFen' => $todayIncomeFen,
|
||||
'monthIncomeFen' => $monthIncomeFen,
|
||||
'manualRechargeFen' => $manualRechargeFen,
|
||||
'frozenCommissionFen' => $frozenCommissionFen,
|
||||
'paidOrderCount' => $paidOrderCount,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return error('获取企业财务概览失败:' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 财务流水
|
||||
*/
|
||||
public function records()
|
||||
{
|
||||
$enterpriseId = $this->resolveEnterpriseId();
|
||||
if (!$enterpriseId) {
|
||||
return error('未获取到企业信息', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$page = max(1, (int) Request::param('page', 1));
|
||||
$pageSize = min(100, max(1, (int) Request::param('pageSize', 20)));
|
||||
|
||||
$query = Db::name('finance_records')
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->order('createdAt', 'desc')
|
||||
->order('id', 'desc');
|
||||
|
||||
$total = (int) (clone $query)->count();
|
||||
$list = (clone $query)
|
||||
->page($page, $pageSize)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$result = array_map(function ($row) {
|
||||
$type = (string) ($row['type'] ?? '');
|
||||
$orderId = isset($row['orderId']) ? (int) $row['orderId'] : 0;
|
||||
$direction = $type === 'consume' ? 'out' : 'in';
|
||||
$description = (string) ($row['description'] ?? '');
|
||||
$typeLabel = $type === 'consume'
|
||||
? '佣金扣减'
|
||||
: (strpos($description, '企业余额充值') !== false ? '余额充值' : ($orderId > 0 ? '测试收入' : '余额充值'));
|
||||
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'type' => $type,
|
||||
'typeLabel' => $typeLabel,
|
||||
'direction' => $direction,
|
||||
'amountFen' => (int) ($row['amount'] ?? 0),
|
||||
'balanceBeforeFen' => (int) ($row['balanceBefore'] ?? 0),
|
||||
'balanceAfterFen' => (int) ($row['balanceAfter'] ?? 0),
|
||||
'description' => $description,
|
||||
'orderId' => $orderId ?: null,
|
||||
'createdAt' => (int) ($row['createdAt'] ?? 0),
|
||||
];
|
||||
}, $list);
|
||||
|
||||
return success([
|
||||
'list' => $result,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'pageSize' => $pageSize,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return error('获取财务流水失败:' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 企业手动充值
|
||||
*/
|
||||
public function rechargeQrcode()
|
||||
{
|
||||
$enterpriseId = $this->resolveEnterpriseId();
|
||||
if (!$enterpriseId) {
|
||||
return error('未获取到企业信息', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$amountFen = (int) Request::param('amountFen', 0);
|
||||
if ($amountFen <= 0) {
|
||||
return error('充值金额必须大于 0', 400);
|
||||
}
|
||||
$enterprise = Db::name('enterprises')
|
||||
->where('id', $enterpriseId)
|
||||
->field('id, name')
|
||||
->find();
|
||||
if (!$enterprise) {
|
||||
return error('企业不存在', 404);
|
||||
}
|
||||
|
||||
// scene 长度要尽量短,避免超过微信限制
|
||||
$scene = 'eid=' . $enterpriseId . '&a=' . $amountFen . '&r=1';
|
||||
$page = 'pages/recharge/index';
|
||||
$result = WechatService::getWxacodeUnlimited($scene, $page, 430);
|
||||
if (isset($result['errcode'])) {
|
||||
return error('获取充值小程序码失败:' . ($result['errmsg'] ?? ''), 500);
|
||||
}
|
||||
|
||||
$binary = $result['binary'] ?? '';
|
||||
if ($binary === '') {
|
||||
return error('充值小程序码生成失败', 500);
|
||||
}
|
||||
|
||||
return success([
|
||||
'enterpriseId' => $enterpriseId,
|
||||
'enterpriseName' => (string) ($enterprise['name'] ?? ''),
|
||||
'amountFen' => $amountFen,
|
||||
'amountYuan' => number_format($amountFen / 100, 2, '.', ''),
|
||||
'scene' => $scene,
|
||||
'page' => $page,
|
||||
'qrcode' => 'data:image/png;base64,' . base64_encode($binary),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return error('生成充值二维码失败:' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析当前管理账号所属企业
|
||||
*/
|
||||
protected function resolveEnterpriseId(): ?int
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || !in_array($user['role'] ?? '', ['admin', 'enterprise_admin'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$enterpriseId = (int) ($user['enterpriseId'] ?? 0);
|
||||
if ($enterpriseId > 0) {
|
||||
return $enterpriseId;
|
||||
}
|
||||
|
||||
$adminId = (int) ($user['userId'] ?? 0);
|
||||
if ($adminId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) (Db::name('users')->where('id', $adminId)->value('enterpriseId') ?? 0) ?: null;
|
||||
}
|
||||
}
|
||||
<?php
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\service\WechatService;
|
||||
use think\facade\Db;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
* 企业财务控制器(企业管理端)
|
||||
*/
|
||||
class Finance extends BaseController
|
||||
{
|
||||
/**
|
||||
* 财务概览
|
||||
*/
|
||||
public function overview()
|
||||
{
|
||||
$enterpriseId = $this->resolveEnterpriseId();
|
||||
if (!$enterpriseId) {
|
||||
return error('未获取到企业信息', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$enterprise = Db::name('enterprises')
|
||||
->where('id', $enterpriseId)
|
||||
->field('id, name, balance')
|
||||
->find();
|
||||
if (!$enterprise) {
|
||||
return error('企业不存在', 404);
|
||||
}
|
||||
|
||||
$todayStart = strtotime(date('Y-m-d 00:00:00'));
|
||||
$monthStart = strtotime(date('Y-m-01 00:00:00'));
|
||||
|
||||
$baseOrderQuery = Db::name('orders')
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->whereIn('status', ['paid', 'completed'])
|
||||
->whereIn('productType', ['face', 'mbti', 'sbti', 'disc', 'pdp']);
|
||||
|
||||
$totalIncomeFen = (int) ((clone $baseOrderQuery)->sum('amount') ?? 0);
|
||||
$todayIncomeFen = (int) ((clone $baseOrderQuery)->where('payTime', '>=', $todayStart)->sum('amount') ?? 0);
|
||||
$monthIncomeFen = (int) ((clone $baseOrderQuery)->where('payTime', '>=', $monthStart)->sum('amount') ?? 0);
|
||||
$paidOrderCount = (int) ((clone $baseOrderQuery)->count());
|
||||
|
||||
$manualRechargeFen = (int) (Db::name('finance_records')
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->where('type', 'recharge')
|
||||
->whereNull('orderId')
|
||||
->sum('amount') ?? 0);
|
||||
|
||||
$frozenCommissionFen = (int) (Db::name('commission_records')
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->where('status', 'frozen')
|
||||
->sum('commissionFen') ?? 0);
|
||||
|
||||
return success([
|
||||
'enterpriseId' => $enterpriseId,
|
||||
'enterpriseName' => $enterprise['name'] ?? '',
|
||||
'balanceFen' => (int) ($enterprise['balance'] ?? 0),
|
||||
'totalIncomeFen' => $totalIncomeFen,
|
||||
'todayIncomeFen' => $todayIncomeFen,
|
||||
'monthIncomeFen' => $monthIncomeFen,
|
||||
'manualRechargeFen' => $manualRechargeFen,
|
||||
'frozenCommissionFen' => $frozenCommissionFen,
|
||||
'paidOrderCount' => $paidOrderCount,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return error('获取企业财务概览失败:' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 财务流水
|
||||
*/
|
||||
public function records()
|
||||
{
|
||||
$enterpriseId = $this->resolveEnterpriseId();
|
||||
if (!$enterpriseId) {
|
||||
return error('未获取到企业信息', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$page = max(1, (int) Request::param('page', 1));
|
||||
$pageSize = min(100, max(1, (int) Request::param('pageSize', 20)));
|
||||
|
||||
$query = Db::name('finance_records')
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->order('createdAt', 'desc')
|
||||
->order('id', 'desc');
|
||||
|
||||
$total = (int) (clone $query)->count();
|
||||
$list = (clone $query)
|
||||
->page($page, $pageSize)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$result = array_map(function ($row) {
|
||||
$type = (string) ($row['type'] ?? '');
|
||||
$orderId = isset($row['orderId']) ? (int) $row['orderId'] : 0;
|
||||
$direction = $type === 'consume' ? 'out' : 'in';
|
||||
$description = (string) ($row['description'] ?? '');
|
||||
$typeLabel = $type === 'consume'
|
||||
? '佣金扣减'
|
||||
: (strpos($description, '企业余额充值') !== false ? '余额充值' : ($orderId > 0 ? '测试收入' : '余额充值'));
|
||||
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'type' => $type,
|
||||
'typeLabel' => $typeLabel,
|
||||
'direction' => $direction,
|
||||
'amountFen' => (int) ($row['amount'] ?? 0),
|
||||
'balanceBeforeFen' => (int) ($row['balanceBefore'] ?? 0),
|
||||
'balanceAfterFen' => (int) ($row['balanceAfter'] ?? 0),
|
||||
'description' => $description,
|
||||
'orderId' => $orderId ?: null,
|
||||
'createdAt' => (int) ($row['createdAt'] ?? 0),
|
||||
];
|
||||
}, $list);
|
||||
|
||||
return success([
|
||||
'list' => $result,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'pageSize' => $pageSize,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return error('获取财务流水失败:' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 企业手动充值
|
||||
*/
|
||||
public function rechargeQrcode()
|
||||
{
|
||||
$enterpriseId = $this->resolveEnterpriseId();
|
||||
if (!$enterpriseId) {
|
||||
return error('未获取到企业信息', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$amountFen = (int) Request::param('amountFen', 0);
|
||||
if ($amountFen <= 0) {
|
||||
return error('充值金额必须大于 0', 400);
|
||||
}
|
||||
$enterprise = Db::name('enterprises')
|
||||
->where('id', $enterpriseId)
|
||||
->field('id, name')
|
||||
->find();
|
||||
if (!$enterprise) {
|
||||
return error('企业不存在', 404);
|
||||
}
|
||||
|
||||
// scene 长度要尽量短,避免超过微信限制
|
||||
$scene = 'eid=' . $enterpriseId . '&a=' . $amountFen . '&r=1';
|
||||
$page = 'pages/recharge/index';
|
||||
$result = WechatService::getWxacodeUnlimited($scene, $page, 430);
|
||||
if (isset($result['errcode'])) {
|
||||
return error('获取充值小程序码失败:' . ($result['errmsg'] ?? ''), 500);
|
||||
}
|
||||
|
||||
$binary = $result['binary'] ?? '';
|
||||
if ($binary === '') {
|
||||
return error('充值小程序码生成失败', 500);
|
||||
}
|
||||
|
||||
return success([
|
||||
'enterpriseId' => $enterpriseId,
|
||||
'enterpriseName' => (string) ($enterprise['name'] ?? ''),
|
||||
'amountFen' => $amountFen,
|
||||
'amountYuan' => number_format($amountFen / 100, 2, '.', ''),
|
||||
'scene' => $scene,
|
||||
'page' => $page,
|
||||
'qrcode' => 'data:image/png;base64,' . base64_encode($binary),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return error('生成充值二维码失败:' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析当前管理账号所属企业
|
||||
*/
|
||||
protected function resolveEnterpriseId(): ?int
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || !in_array($user['role'] ?? '', ['admin', 'enterprise_admin'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$enterpriseId = (int) ($user['enterpriseId'] ?? 0);
|
||||
if ($enterpriseId > 0) {
|
||||
return $enterpriseId;
|
||||
}
|
||||
|
||||
$adminId = (int) ($user['userId'] ?? 0);
|
||||
if ($adminId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) (Db::name('users')->where('id', $adminId)->value('enterpriseId') ?? 0) ?: null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,185 +1,185 @@
|
||||
<?php
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\BaseController;
|
||||
use app\model\PricingConfig as PricingConfigModel;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 定价管理控制器(普通管理员)
|
||||
* 支持同时配置个人版和企业版定价:
|
||||
* - 个人版:type=admin_personal + enterpriseId(企业管理员)或 enterpriseId=NULL(普通管理员)
|
||||
* - 企业版:type=admin_enterprise + enterpriseId(企业管理员)
|
||||
* 无自定义配置时回落到超管全局定价
|
||||
*/
|
||||
class Pricing extends BaseController
|
||||
{
|
||||
/**
|
||||
* 获取定价配置(个人版 + 企业版)
|
||||
* GET /api/v1/admin/pricing
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
if (!in_array($user['role'], ['admin', 'enterprise_admin'])) {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
try {
|
||||
$enterpriseId = $this->resolveEnterpriseId($user);
|
||||
|
||||
// ── 个人版定价 ──
|
||||
$adminPersonalConfig = $this->queryConfig('admin_personal', $enterpriseId);
|
||||
$superPersonalConfig = PricingConfigModel::where('type', 'personal')->whereNull('enterpriseId')->find();
|
||||
$personalConfig = $adminPersonalConfig
|
||||
? $adminPersonalConfig->config
|
||||
: ($superPersonalConfig ? $superPersonalConfig->config : []);
|
||||
$isUsingSuperAdminPersonalConfig = !$adminPersonalConfig;
|
||||
|
||||
// ── 企业版定价 ──
|
||||
$adminEnterpriseConfig = $enterpriseId
|
||||
? $this->queryConfig('admin_enterprise', $enterpriseId)
|
||||
: null;
|
||||
$superEnterpriseConfig = PricingConfigModel::where('type', 'enterprise')->whereNull('enterpriseId')->find();
|
||||
$enterpriseConfig = $adminEnterpriseConfig
|
||||
? $adminEnterpriseConfig->config
|
||||
: ($superEnterpriseConfig ? $superEnterpriseConfig->config : []);
|
||||
$isUsingSuperAdminEnterpriseConfig = !$adminEnterpriseConfig;
|
||||
|
||||
return success([
|
||||
'personal' => $personalConfig,
|
||||
'enterprise' => $enterpriseConfig,
|
||||
'isUsingSuperAdminConfig' => $isUsingSuperAdminPersonalConfig,
|
||||
'isUsingSuperAdminPersonalConfig' => $isUsingSuperAdminPersonalConfig,
|
||||
'isUsingSuperAdminEnterpriseConfig' => $isUsingSuperAdminEnterpriseConfig,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return error('获取定价配置失败:' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新定价配置(个人版 + 企业版)
|
||||
* PUT /api/v1/admin/pricing
|
||||
* Body: { personalConfig: {...}, enterpriseConfig: {...} }
|
||||
* 兼容旧格式:{ config: {...} } → 仅更新个人版
|
||||
*/
|
||||
public function update()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
if (!in_array($user['role'], ['admin', 'enterprise_admin'])) {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
$rawBody = $this->request->getContent();
|
||||
if (empty($rawBody)) {
|
||||
$rawBody = file_get_contents('php://input');
|
||||
}
|
||||
$input = $rawBody ? json_decode($rawBody, true) : null;
|
||||
if (!is_array($input)) {
|
||||
$input = [];
|
||||
}
|
||||
|
||||
// 兼容旧版仅传 config 的情况
|
||||
$personalConfig = $input['personalConfig'] ?? $input['config'] ?? null;
|
||||
$enterpriseConfig = $input['enterpriseConfig'] ?? null;
|
||||
|
||||
if ($personalConfig === null && $enterpriseConfig === null) {
|
||||
return error('配置数据不能为空', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$enterpriseId = $this->resolveEnterpriseId($user);
|
||||
|
||||
$result = [];
|
||||
|
||||
// ── 保存个人版定价 ──
|
||||
if ($personalConfig !== null) {
|
||||
if (!is_array($personalConfig)) {
|
||||
return error('个人版定价格式错误', 400);
|
||||
}
|
||||
foreach (['face', 'mbti', 'disc', 'pdp'] as $field) {
|
||||
if (!array_key_exists($field, $personalConfig)) {
|
||||
return error("个人版定价缺少字段:{$field}", 400);
|
||||
}
|
||||
}
|
||||
$cfg = $this->queryConfig('admin_personal', $enterpriseId);
|
||||
if (!$cfg) {
|
||||
$cfg = PricingConfigModel::create([
|
||||
'type' => 'admin_personal',
|
||||
'enterpriseId' => $enterpriseId,
|
||||
'config' => $personalConfig,
|
||||
]);
|
||||
} else {
|
||||
$cfg->config = $personalConfig;
|
||||
$cfg->save();
|
||||
}
|
||||
$result['personal'] = $cfg->config;
|
||||
}
|
||||
|
||||
// ── 保存企业版定价(仅企业管理员)──
|
||||
if ($enterpriseConfig !== null) {
|
||||
if (!$enterpriseId) {
|
||||
return error('仅企业管理员可设置企业版定价', 403);
|
||||
}
|
||||
if (!is_array($enterpriseConfig)) {
|
||||
return error('企业版定价格式错误', 400);
|
||||
}
|
||||
foreach (['face', 'mbti', 'disc', 'pdp'] as $field) {
|
||||
if (!array_key_exists($field, $enterpriseConfig)) {
|
||||
return error("企业版定价缺少字段:{$field}", 400);
|
||||
}
|
||||
}
|
||||
$cfg = $this->queryConfig('admin_enterprise', $enterpriseId);
|
||||
if (!$cfg) {
|
||||
$cfg = PricingConfigModel::create([
|
||||
'type' => 'admin_enterprise',
|
||||
'enterpriseId' => $enterpriseId,
|
||||
'config' => $enterpriseConfig,
|
||||
]);
|
||||
} else {
|
||||
$cfg->config = $enterpriseConfig;
|
||||
$cfg->save();
|
||||
}
|
||||
$result['enterprise'] = $cfg->config;
|
||||
}
|
||||
|
||||
return success($result, '定价配置已保存');
|
||||
} catch (\Exception $e) {
|
||||
return error('保存失败:' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 JWT 用户信息中解析 enterpriseId
|
||||
*/
|
||||
private function resolveEnterpriseId(array $user): ?int
|
||||
{
|
||||
if (($user['role'] ?? '') !== 'enterprise_admin') {
|
||||
return null;
|
||||
}
|
||||
$adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
|
||||
$eid = $adminRow['enterpriseId'] ?? null;
|
||||
return $eid ? (int) $eid : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 type + enterpriseId 查询定价配置
|
||||
*/
|
||||
private function queryConfig(string $type, ?int $enterpriseId): ?PricingConfigModel
|
||||
{
|
||||
$q = PricingConfigModel::where('type', $type);
|
||||
if ($enterpriseId) {
|
||||
$q->where('enterpriseId', $enterpriseId);
|
||||
} else {
|
||||
$q->whereNull('enterpriseId');
|
||||
}
|
||||
return $q->find();
|
||||
}
|
||||
}
|
||||
<?php
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\BaseController;
|
||||
use app\model\PricingConfig as PricingConfigModel;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 定价管理控制器(普通管理员)
|
||||
* 支持同时配置个人版和企业版定价:
|
||||
* - 个人版:type=admin_personal + enterpriseId(企业管理员)或 enterpriseId=NULL(普通管理员)
|
||||
* - 企业版:type=admin_enterprise + enterpriseId(企业管理员)
|
||||
* 无自定义配置时回落到超管全局定价
|
||||
*/
|
||||
class Pricing extends BaseController
|
||||
{
|
||||
/**
|
||||
* 获取定价配置(个人版 + 企业版)
|
||||
* GET /api/v1/admin/pricing
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
if (!in_array($user['role'], ['admin', 'enterprise_admin'])) {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
try {
|
||||
$enterpriseId = $this->resolveEnterpriseId($user);
|
||||
|
||||
// ── 个人版定价 ──
|
||||
$adminPersonalConfig = $this->queryConfig('admin_personal', $enterpriseId);
|
||||
$superPersonalConfig = PricingConfigModel::where('type', 'personal')->whereNull('enterpriseId')->find();
|
||||
$personalConfig = $adminPersonalConfig
|
||||
? $adminPersonalConfig->config
|
||||
: ($superPersonalConfig ? $superPersonalConfig->config : []);
|
||||
$isUsingSuperAdminPersonalConfig = !$adminPersonalConfig;
|
||||
|
||||
// ── 企业版定价 ──
|
||||
$adminEnterpriseConfig = $enterpriseId
|
||||
? $this->queryConfig('admin_enterprise', $enterpriseId)
|
||||
: null;
|
||||
$superEnterpriseConfig = PricingConfigModel::where('type', 'enterprise')->whereNull('enterpriseId')->find();
|
||||
$enterpriseConfig = $adminEnterpriseConfig
|
||||
? $adminEnterpriseConfig->config
|
||||
: ($superEnterpriseConfig ? $superEnterpriseConfig->config : []);
|
||||
$isUsingSuperAdminEnterpriseConfig = !$adminEnterpriseConfig;
|
||||
|
||||
return success([
|
||||
'personal' => $personalConfig,
|
||||
'enterprise' => $enterpriseConfig,
|
||||
'isUsingSuperAdminConfig' => $isUsingSuperAdminPersonalConfig,
|
||||
'isUsingSuperAdminPersonalConfig' => $isUsingSuperAdminPersonalConfig,
|
||||
'isUsingSuperAdminEnterpriseConfig' => $isUsingSuperAdminEnterpriseConfig,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return error('获取定价配置失败:' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新定价配置(个人版 + 企业版)
|
||||
* PUT /api/v1/admin/pricing
|
||||
* Body: { personalConfig: {...}, enterpriseConfig: {...} }
|
||||
* 兼容旧格式:{ config: {...} } → 仅更新个人版
|
||||
*/
|
||||
public function update()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
if (!in_array($user['role'], ['admin', 'enterprise_admin'])) {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
$rawBody = $this->request->getContent();
|
||||
if (empty($rawBody)) {
|
||||
$rawBody = file_get_contents('php://input');
|
||||
}
|
||||
$input = $rawBody ? json_decode($rawBody, true) : null;
|
||||
if (!is_array($input)) {
|
||||
$input = [];
|
||||
}
|
||||
|
||||
// 兼容旧版仅传 config 的情况
|
||||
$personalConfig = $input['personalConfig'] ?? $input['config'] ?? null;
|
||||
$enterpriseConfig = $input['enterpriseConfig'] ?? null;
|
||||
|
||||
if ($personalConfig === null && $enterpriseConfig === null) {
|
||||
return error('配置数据不能为空', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$enterpriseId = $this->resolveEnterpriseId($user);
|
||||
|
||||
$result = [];
|
||||
|
||||
// ── 保存个人版定价 ──
|
||||
if ($personalConfig !== null) {
|
||||
if (!is_array($personalConfig)) {
|
||||
return error('个人版定价格式错误', 400);
|
||||
}
|
||||
foreach (['face', 'mbti', 'disc', 'pdp', 'sbti'] as $field) {
|
||||
if (!array_key_exists($field, $personalConfig)) {
|
||||
return error("个人版定价缺少字段:{$field}", 400);
|
||||
}
|
||||
}
|
||||
$cfg = $this->queryConfig('admin_personal', $enterpriseId);
|
||||
if (!$cfg) {
|
||||
$cfg = PricingConfigModel::create([
|
||||
'type' => 'admin_personal',
|
||||
'enterpriseId' => $enterpriseId,
|
||||
'config' => $personalConfig,
|
||||
]);
|
||||
} else {
|
||||
$cfg->config = $personalConfig;
|
||||
$cfg->save();
|
||||
}
|
||||
$result['personal'] = $cfg->config;
|
||||
}
|
||||
|
||||
// ── 保存企业版定价(仅企业管理员)──
|
||||
if ($enterpriseConfig !== null) {
|
||||
if (!$enterpriseId) {
|
||||
return error('仅企业管理员可设置企业版定价', 403);
|
||||
}
|
||||
if (!is_array($enterpriseConfig)) {
|
||||
return error('企业版定价格式错误', 400);
|
||||
}
|
||||
foreach (['face', 'mbti', 'disc', 'pdp', 'sbti'] as $field) {
|
||||
if (!array_key_exists($field, $enterpriseConfig)) {
|
||||
return error("企业版定价缺少字段:{$field}", 400);
|
||||
}
|
||||
}
|
||||
$cfg = $this->queryConfig('admin_enterprise', $enterpriseId);
|
||||
if (!$cfg) {
|
||||
$cfg = PricingConfigModel::create([
|
||||
'type' => 'admin_enterprise',
|
||||
'enterpriseId' => $enterpriseId,
|
||||
'config' => $enterpriseConfig,
|
||||
]);
|
||||
} else {
|
||||
$cfg->config = $enterpriseConfig;
|
||||
$cfg->save();
|
||||
}
|
||||
$result['enterprise'] = $cfg->config;
|
||||
}
|
||||
|
||||
return success($result, '定价配置已保存');
|
||||
} catch (\Exception $e) {
|
||||
return error('保存失败:' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 JWT 用户信息中解析 enterpriseId
|
||||
*/
|
||||
private function resolveEnterpriseId(array $user): ?int
|
||||
{
|
||||
if (($user['role'] ?? '') !== 'enterprise_admin') {
|
||||
return null;
|
||||
}
|
||||
$adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
|
||||
$eid = $adminRow['enterpriseId'] ?? null;
|
||||
return $eid ? (int) $eid : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 type + enterpriseId 查询定价配置
|
||||
*/
|
||||
private function queryConfig(string $type, ?int $enterpriseId): ?PricingConfigModel
|
||||
{
|
||||
$q = PricingConfigModel::where('type', $type);
|
||||
if ($enterpriseId) {
|
||||
$q->where('enterpriseId', $enterpriseId);
|
||||
} else {
|
||||
$q->whereNull('enterpriseId');
|
||||
}
|
||||
return $q->find();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,461 +1,459 @@
|
||||
<?php
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\BaseController;
|
||||
use app\model\Question as QuestionModel;
|
||||
use think\facade\Request;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 题库管理控制器(企业管理员和普通管理员)
|
||||
* 企业管理员只能管理自己企业的题库,如果没有则使用超管题库
|
||||
*/
|
||||
class Question extends BaseController
|
||||
{
|
||||
/**
|
||||
* 获取题库列表
|
||||
* 如果企业没有自己的题库,返回超管题库
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
// 确定企业ID
|
||||
$enterpriseId = null;
|
||||
if ($user['role'] === 'enterprise_admin') {
|
||||
// 企业管理员:使用自己的企业ID
|
||||
$userModel = Db::name('users')->where('id', $user['userId'])->find();
|
||||
$enterpriseId = $userModel['enterpriseId'] ?? null;
|
||||
} elseif ($user['role'] === 'admin') {
|
||||
// 普通管理员:可以查看所有企业的题库,但优先显示超管题库
|
||||
$enterpriseId = Request::param('enterpriseId', null);
|
||||
} else {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
$page = Request::param('page', 1);
|
||||
$pageSize = Request::param('pageSize', 20);
|
||||
$type = Request::param('type', ''); // mbti/disc/pdp
|
||||
$status = Request::param('status', ''); // 1启用/0禁用
|
||||
|
||||
$where = [];
|
||||
|
||||
// 如果指定了企业ID,优先查询企业题库
|
||||
// 如果没有企业题库,则查询超管题库(enterpriseId = NULL)
|
||||
if ($enterpriseId !== null) {
|
||||
// 先检查企业是否有自己的题库(未指定 type 时需统计 mbti/disc/pdp 三类)
|
||||
$countQuery = QuestionModel::where('enterpriseId', $enterpriseId);
|
||||
if ($type !== '') {
|
||||
$countQuery->where('type', $type);
|
||||
} else {
|
||||
$countQuery->whereIn('type', ['mbti', 'disc', 'pdp']);
|
||||
}
|
||||
$enterpriseQuestionCount = $countQuery->count();
|
||||
|
||||
if ($enterpriseQuestionCount > 0) {
|
||||
// 使用企业题库
|
||||
$where['enterpriseId'] = $enterpriseId;
|
||||
} else {
|
||||
// 使用超管题库
|
||||
$where['enterpriseId'] = null;
|
||||
}
|
||||
} else {
|
||||
// 普通管理员查看超管题库
|
||||
$where['enterpriseId'] = null;
|
||||
}
|
||||
|
||||
// 类型筛选
|
||||
if ($type) {
|
||||
$where['type'] = $type;
|
||||
}
|
||||
|
||||
// 状态筛选
|
||||
if ($status !== '') {
|
||||
$where['status'] = $status;
|
||||
}
|
||||
|
||||
// 查询题库列表
|
||||
$list = QuestionModel::where($where)
|
||||
->order('sort', 'asc')
|
||||
->order('id', 'asc')
|
||||
->page($page, $pageSize)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
// 处理 options 字段,确保返回数组格式
|
||||
foreach ($list as &$item) {
|
||||
if (isset($item['options'])) {
|
||||
// 如果是对象格式(stdClass),先转换为数组
|
||||
if (is_object($item['options'])) {
|
||||
$item['options'] = json_decode(json_encode($item['options']), true);
|
||||
}
|
||||
// 如果是关联数组(不是索引数组),转换为索引数组
|
||||
if (is_array($item['options']) && !isset($item['options'][0])) {
|
||||
$item['options'] = array_values($item['options']);
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($item);
|
||||
|
||||
// 总数
|
||||
$total = QuestionModel::where($where)->count();
|
||||
|
||||
// 标识当前使用的是企业题库还是超管题库
|
||||
$isUsingSuperAdminBank = ($where['enterpriseId'] === null);
|
||||
|
||||
return success([
|
||||
'list' => $list,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'pageSize' => $pageSize,
|
||||
'isUsingSuperAdminBank' => $isUsingSuperAdminBank,
|
||||
'enterpriseId' => $enterpriseId
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取题目详情
|
||||
* @param int $id
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function detail($id)
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
// 确定企业ID
|
||||
$enterpriseId = null;
|
||||
if ($user['role'] === 'enterprise_admin') {
|
||||
$userModel = Db::name('users')->where('id', $user['userId'])->find();
|
||||
$enterpriseId = $userModel['enterpriseId'] ?? null;
|
||||
} elseif ($user['role'] === 'admin') {
|
||||
$enterpriseId = Request::param('enterpriseId', null);
|
||||
} else {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
// 先查询企业题库,如果没有则查询超管题库
|
||||
$question = null;
|
||||
if ($enterpriseId !== null) {
|
||||
$question = QuestionModel::where('id', $id)
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->find();
|
||||
}
|
||||
|
||||
if (!$question) {
|
||||
$question = QuestionModel::where('id', $id)
|
||||
->where('enterpriseId', null)
|
||||
->find();
|
||||
}
|
||||
|
||||
if (!$question) {
|
||||
return error('题目不存在', 404);
|
||||
}
|
||||
|
||||
$data = $question->toArray();
|
||||
|
||||
// 处理 options 字段,确保返回数组格式
|
||||
if (isset($data['options'])) {
|
||||
// 如果是对象格式(stdClass),先转换为数组
|
||||
if (is_object($data['options'])) {
|
||||
$data['options'] = json_decode(json_encode($data['options']), true);
|
||||
}
|
||||
// 如果是关联数组(不是索引数组),转换为索引数组
|
||||
if (is_array($data['options']) && !isset($data['options'][0])) {
|
||||
$data['options'] = array_values($data['options']);
|
||||
}
|
||||
}
|
||||
|
||||
return success($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建题目(企业管理员只能创建自己企业的题目)
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
// 只有企业管理员可以创建题目
|
||||
if ($user['role'] !== 'enterprise_admin') {
|
||||
return error('只有企业管理员可以创建题目', 403);
|
||||
}
|
||||
|
||||
// 获取企业ID
|
||||
$userModel = Db::name('users')->where('id', $user['userId'])->find();
|
||||
$enterpriseId = $userModel['enterpriseId'] ?? null;
|
||||
|
||||
if (!$enterpriseId) {
|
||||
return error('企业信息不存在', 400);
|
||||
}
|
||||
|
||||
$data = Request::only(['type', 'question', 'options', 'dimension', 'sort', 'status']);
|
||||
|
||||
// 验证必填字段
|
||||
if (empty($data['type']) || empty($data['question']) || empty($data['options'])) {
|
||||
return error('题目类型、题目内容和选项不能为空', 400);
|
||||
}
|
||||
|
||||
// 验证类型
|
||||
if (!in_array($data['type'], ['mbti', 'disc', 'pdp'])) {
|
||||
return error('题目类型必须是 mbti、disc 或 pdp', 400);
|
||||
}
|
||||
|
||||
// 验证选项格式
|
||||
if (!is_array($data['options'])) {
|
||||
return error('选项必须是数组格式', 400);
|
||||
}
|
||||
|
||||
// MBTI类型需要dimension字段
|
||||
if ($data['type'] === 'mbti' && empty($data['dimension'])) {
|
||||
return error('MBTI类型题目必须指定维度(EI/SN/TF/JP)', 400);
|
||||
}
|
||||
|
||||
// 设置企业ID
|
||||
$data['enterpriseId'] = $enterpriseId;
|
||||
|
||||
// 设置默认值
|
||||
$data['sort'] = $data['sort'] ?? 0;
|
||||
$data['status'] = $data['status'] ?? 1;
|
||||
|
||||
// 创建题目
|
||||
$question = QuestionModel::create($data);
|
||||
|
||||
return success($question->toArray(), '创建成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新题目(企业管理员只能更新自己企业的题目)
|
||||
* @param int $id
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function update($id)
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
// 只有企业管理员可以更新题目
|
||||
if ($user['role'] !== 'enterprise_admin') {
|
||||
return error('只有企业管理员可以更新题目', 403);
|
||||
}
|
||||
|
||||
// 获取企业ID
|
||||
$userModel = Db::name('users')->where('id', $user['userId'])->find();
|
||||
$enterpriseId = $userModel['enterpriseId'] ?? null;
|
||||
|
||||
if (!$enterpriseId) {
|
||||
return error('企业信息不存在', 400);
|
||||
}
|
||||
|
||||
// 只能更新自己企业的题目
|
||||
$question = QuestionModel::where('id', $id)
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->find();
|
||||
|
||||
if (!$question) {
|
||||
return error('题目不存在或无权限修改', 404);
|
||||
}
|
||||
|
||||
$data = Request::only(['type', 'question', 'options', 'dimension', 'sort', 'status']);
|
||||
|
||||
// 验证类型
|
||||
if (isset($data['type']) && !in_array($data['type'], ['mbti', 'disc', 'pdp'])) {
|
||||
return error('题目类型必须是 mbti、disc 或 pdp', 400);
|
||||
}
|
||||
|
||||
// 验证选项格式
|
||||
if (isset($data['options']) && !is_array($data['options'])) {
|
||||
return error('选项必须是数组格式', 400);
|
||||
}
|
||||
|
||||
// MBTI类型需要dimension字段
|
||||
if (($data['type'] ?? $question->type) === 'mbti' && empty($data['dimension'] ?? $question->dimension)) {
|
||||
return error('MBTI类型题目必须指定维度(EI/SN/TF/JP)', 400);
|
||||
}
|
||||
|
||||
// 更新题目
|
||||
$question->save($data);
|
||||
|
||||
return success($question->toArray(), '更新成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除题目(软删除,企业管理员只能删除自己企业的题目)
|
||||
* @param int $id
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function delete($id)
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
// 只有企业管理员可以删除题目
|
||||
if ($user['role'] !== 'enterprise_admin') {
|
||||
return error('只有企业管理员可以删除题目', 403);
|
||||
}
|
||||
|
||||
// 获取企业ID
|
||||
$userModel = Db::name('users')->where('id', $user['userId'])->find();
|
||||
$enterpriseId = $userModel['enterpriseId'] ?? null;
|
||||
|
||||
if (!$enterpriseId) {
|
||||
return error('企业信息不存在', 400);
|
||||
}
|
||||
|
||||
// 只能删除自己企业的题目
|
||||
$question = QuestionModel::where('id', $id)
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->find();
|
||||
|
||||
if (!$question) {
|
||||
return error('题目不存在或无权限删除', 404);
|
||||
}
|
||||
|
||||
// 执行软删除
|
||||
$question->delete();
|
||||
|
||||
return success(null, '删除成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量导入题目(企业管理员)
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function batchImport()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
// 只有企业管理员可以导入题目
|
||||
if ($user['role'] !== 'enterprise_admin') {
|
||||
return error('只有企业管理员可以导入题目', 403);
|
||||
}
|
||||
|
||||
// 获取企业ID
|
||||
$userModel = Db::name('users')->where('id', $user['userId'])->find();
|
||||
$enterpriseId = $userModel['enterpriseId'] ?? null;
|
||||
|
||||
if (!$enterpriseId) {
|
||||
return error('企业信息不存在', 400);
|
||||
}
|
||||
|
||||
$questions = Request::param('questions', []);
|
||||
|
||||
if (empty($questions) || !is_array($questions)) {
|
||||
return error('题目数据不能为空', 400);
|
||||
}
|
||||
|
||||
$successCount = 0;
|
||||
$failCount = 0;
|
||||
$errors = [];
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
foreach ($questions as $index => $q) {
|
||||
// 验证必填字段
|
||||
if (empty($q['type']) || empty($q['question']) || empty($q['options'])) {
|
||||
$failCount++;
|
||||
$errors[] = "第" . ($index + 1) . "题:题目类型、题目内容和选项不能为空";
|
||||
continue;
|
||||
}
|
||||
|
||||
// 验证类型
|
||||
if (!in_array($q['type'], ['mbti', 'disc', 'pdp'])) {
|
||||
$failCount++;
|
||||
$errors[] = "第" . ($index + 1) . "题:题目类型必须是 mbti、disc 或 pdp";
|
||||
continue;
|
||||
}
|
||||
|
||||
// MBTI类型需要dimension字段
|
||||
if ($q['type'] === 'mbti' && empty($q['dimension'])) {
|
||||
$failCount++;
|
||||
$errors[] = "第" . ($index + 1) . "题:MBTI类型题目必须指定维度";
|
||||
continue;
|
||||
}
|
||||
|
||||
// 设置企业ID
|
||||
$q['enterpriseId'] = $enterpriseId;
|
||||
$q['sort'] = $q['sort'] ?? ($index + 1);
|
||||
$q['status'] = $q['status'] ?? 1;
|
||||
|
||||
QuestionModel::create($q);
|
||||
$successCount++;
|
||||
}
|
||||
|
||||
Db::commit();
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
return error('批量导入失败:' . $e->getMessage(), 500);
|
||||
}
|
||||
|
||||
return success([
|
||||
'successCount' => $successCount,
|
||||
'failCount' => $failCount,
|
||||
'errors' => $errors
|
||||
], "成功导入 {$successCount} 题,失败 {$failCount} 题");
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换题目状态
|
||||
* @param int $id
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function toggleStatus($id)
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
// 只有企业管理员可以切换状态
|
||||
if ($user['role'] !== 'enterprise_admin') {
|
||||
return error('只有企业管理员可以切换题目状态', 403);
|
||||
}
|
||||
|
||||
// 获取企业ID
|
||||
$userModel = Db::name('users')->where('id', $user['userId'])->find();
|
||||
$enterpriseId = $userModel['enterpriseId'] ?? null;
|
||||
|
||||
if (!$enterpriseId) {
|
||||
return error('企业信息不存在', 400);
|
||||
}
|
||||
|
||||
// 只能操作自己企业的题目
|
||||
$question = QuestionModel::where('id', $id)
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->find();
|
||||
|
||||
if (!$question) {
|
||||
return error('题目不存在或无权限操作', 404);
|
||||
}
|
||||
|
||||
$question->status = $question->status == 1 ? 0 : 1;
|
||||
$question->save();
|
||||
|
||||
return success($question->toArray(), '状态更新成功');
|
||||
}
|
||||
}
|
||||
|
||||
<?php
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\BaseController;
|
||||
use app\model\Question as QuestionModel;
|
||||
use think\facade\Request;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 题库管理控制器(企业管理员和普通管理员)
|
||||
* 企业管理员只能管理自己企业的题库,如果没有则使用超管题库
|
||||
*/
|
||||
class Question extends BaseController
|
||||
{
|
||||
/**
|
||||
* 获取题库列表
|
||||
* 如果企业没有自己的题库,返回超管题库
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
// 确定企业ID
|
||||
$enterpriseId = null;
|
||||
if ($user['role'] === 'enterprise_admin') {
|
||||
// 企业管理员:使用自己的企业ID
|
||||
$userModel = Db::name('users')->where('id', $user['userId'])->find();
|
||||
$enterpriseId = $userModel['enterpriseId'] ?? null;
|
||||
} elseif ($user['role'] === 'admin') {
|
||||
// 普通管理员:可以查看所有企业的题库,但优先显示超管题库
|
||||
$enterpriseId = Request::param('enterpriseId', null);
|
||||
} else {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
$page = Request::param('page', 1);
|
||||
$pageSize = Request::param('pageSize', 20);
|
||||
$type = Request::param('type', ''); // mbti/disc/pdp
|
||||
$status = Request::param('status', ''); // 1启用/0禁用
|
||||
|
||||
$where = [];
|
||||
|
||||
// 如果指定了企业ID,优先查询企业题库
|
||||
// 如果没有企业题库,则查询超管题库(enterpriseId = NULL)
|
||||
if ($enterpriseId !== null) {
|
||||
// 先检查企业是否有自己的题库(未指定 type 时需统计 mbti/disc/pdp 三类)
|
||||
$countQuery = QuestionModel::where('enterpriseId', $enterpriseId);
|
||||
if ($type !== '') {
|
||||
$countQuery->where('type', $type);
|
||||
} else {
|
||||
$countQuery->whereIn('type', ['mbti', 'sbti', 'disc', 'pdp']);
|
||||
}
|
||||
$enterpriseQuestionCount = $countQuery->count();
|
||||
|
||||
if ($enterpriseQuestionCount > 0) {
|
||||
// 使用企业题库
|
||||
$where['enterpriseId'] = $enterpriseId;
|
||||
} else {
|
||||
// 使用超管题库
|
||||
$where['enterpriseId'] = null;
|
||||
}
|
||||
} else {
|
||||
// 普通管理员查看超管题库
|
||||
$where['enterpriseId'] = null;
|
||||
}
|
||||
|
||||
// 类型筛选
|
||||
if ($type) {
|
||||
$where['type'] = $type;
|
||||
}
|
||||
|
||||
// 状态筛选
|
||||
if ($status !== '') {
|
||||
$where['status'] = $status;
|
||||
}
|
||||
|
||||
// 查询题库列表
|
||||
$list = QuestionModel::where($where)
|
||||
->order('sort', 'asc')
|
||||
->order('id', 'asc')
|
||||
->page($page, $pageSize)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
// 处理 options 字段,确保返回数组格式
|
||||
foreach ($list as &$item) {
|
||||
if (isset($item['options'])) {
|
||||
// 如果是对象格式(stdClass),先转换为数组
|
||||
if (is_object($item['options'])) {
|
||||
$item['options'] = json_decode(json_encode($item['options']), true);
|
||||
}
|
||||
// 如果是关联数组(不是索引数组),转换为索引数组
|
||||
if (is_array($item['options']) && !isset($item['options'][0])) {
|
||||
$item['options'] = array_values($item['options']);
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($item);
|
||||
|
||||
// 总数
|
||||
$total = QuestionModel::where($where)->count();
|
||||
|
||||
// 标识当前使用的是企业题库还是超管题库
|
||||
$isUsingSuperAdminBank = ($where['enterpriseId'] === null);
|
||||
|
||||
return success([
|
||||
'list' => $list,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'pageSize' => $pageSize,
|
||||
'isUsingSuperAdminBank' => $isUsingSuperAdminBank,
|
||||
'enterpriseId' => $enterpriseId
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取题目详情
|
||||
* @param int $id
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function detail($id)
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
// 确定企业ID
|
||||
$enterpriseId = null;
|
||||
if ($user['role'] === 'enterprise_admin') {
|
||||
$userModel = Db::name('users')->where('id', $user['userId'])->find();
|
||||
$enterpriseId = $userModel['enterpriseId'] ?? null;
|
||||
} elseif ($user['role'] === 'admin') {
|
||||
$enterpriseId = Request::param('enterpriseId', null);
|
||||
} else {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
// 先查询企业题库,如果没有则查询超管题库
|
||||
$question = null;
|
||||
if ($enterpriseId !== null) {
|
||||
$question = QuestionModel::where('id', $id)
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->find();
|
||||
}
|
||||
|
||||
if (!$question) {
|
||||
$question = QuestionModel::where('id', $id)
|
||||
->where('enterpriseId', null)
|
||||
->find();
|
||||
}
|
||||
|
||||
if (!$question) {
|
||||
return error('题目不存在', 404);
|
||||
}
|
||||
|
||||
$data = $question->toArray();
|
||||
|
||||
// 处理 options 字段,确保返回数组格式
|
||||
if (isset($data['options'])) {
|
||||
// 如果是对象格式(stdClass),先转换为数组
|
||||
if (is_object($data['options'])) {
|
||||
$data['options'] = json_decode(json_encode($data['options']), true);
|
||||
}
|
||||
// 如果是关联数组(不是索引数组),转换为索引数组
|
||||
if (is_array($data['options']) && !isset($data['options'][0])) {
|
||||
$data['options'] = array_values($data['options']);
|
||||
}
|
||||
}
|
||||
|
||||
return success($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建题目(企业管理员只能创建自己企业的题目)
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
// 只有企业管理员可以创建题目
|
||||
if ($user['role'] !== 'enterprise_admin') {
|
||||
return error('只有企业管理员可以创建题目', 403);
|
||||
}
|
||||
|
||||
// 获取企业ID
|
||||
$userModel = Db::name('users')->where('id', $user['userId'])->find();
|
||||
$enterpriseId = $userModel['enterpriseId'] ?? null;
|
||||
|
||||
if (!$enterpriseId) {
|
||||
return error('企业信息不存在', 400);
|
||||
}
|
||||
|
||||
$data = Request::only(['type', 'question', 'options', 'dimension', 'sort', 'status']);
|
||||
|
||||
// 验证必填字段
|
||||
if (empty($data['type']) || empty($data['question']) || empty($data['options'])) {
|
||||
return error('题目类型、题目内容和选项不能为空', 400);
|
||||
}
|
||||
|
||||
// 验证类型
|
||||
if (!in_array($data['type'], ['mbti', 'sbti', 'disc', 'pdp'])) {
|
||||
return error('题目类型必须是 mbti、sbti、disc 或 pdp', 400);
|
||||
}
|
||||
|
||||
// 验证选项格式
|
||||
if (!is_array($data['options'])) {
|
||||
return error('选项必须是数组格式', 400);
|
||||
}
|
||||
|
||||
if (in_array($data['type'], ['mbti', 'sbti'], true) && empty($data['dimension'])) {
|
||||
return error($data['type'] === 'sbti' ? 'SBTI 题目必须指定维度' : 'MBTI类型题目必须指定维度(EI/SN/TF/JP)', 400);
|
||||
}
|
||||
|
||||
// 设置企业ID
|
||||
$data['enterpriseId'] = $enterpriseId;
|
||||
|
||||
// 设置默认值
|
||||
$data['sort'] = $data['sort'] ?? 0;
|
||||
$data['status'] = $data['status'] ?? 1;
|
||||
|
||||
// 创建题目
|
||||
$question = QuestionModel::create($data);
|
||||
|
||||
return success($question->toArray(), '创建成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新题目(企业管理员只能更新自己企业的题目)
|
||||
* @param int $id
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function update($id)
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
// 只有企业管理员可以更新题目
|
||||
if ($user['role'] !== 'enterprise_admin') {
|
||||
return error('只有企业管理员可以更新题目', 403);
|
||||
}
|
||||
|
||||
// 获取企业ID
|
||||
$userModel = Db::name('users')->where('id', $user['userId'])->find();
|
||||
$enterpriseId = $userModel['enterpriseId'] ?? null;
|
||||
|
||||
if (!$enterpriseId) {
|
||||
return error('企业信息不存在', 400);
|
||||
}
|
||||
|
||||
// 只能更新自己企业的题目
|
||||
$question = QuestionModel::where('id', $id)
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->find();
|
||||
|
||||
if (!$question) {
|
||||
return error('题目不存在或无权限修改', 404);
|
||||
}
|
||||
|
||||
$data = Request::only(['type', 'question', 'options', 'dimension', 'sort', 'status']);
|
||||
|
||||
// 验证类型
|
||||
if (isset($data['type']) && !in_array($data['type'], ['mbti', 'sbti', 'disc', 'pdp'])) {
|
||||
return error('题目类型必须是 mbti、sbti、disc 或 pdp', 400);
|
||||
}
|
||||
|
||||
// 验证选项格式
|
||||
if (isset($data['options']) && !is_array($data['options'])) {
|
||||
return error('选项必须是数组格式', 400);
|
||||
}
|
||||
|
||||
$effType = $data['type'] ?? $question->type;
|
||||
if (in_array($effType, ['mbti', 'sbti'], true) && empty($data['dimension'] ?? $question->dimension)) {
|
||||
return error($effType === 'sbti' ? 'SBTI 题目必须指定维度' : 'MBTI类型题目必须指定维度(EI/SN/TF/JP)', 400);
|
||||
}
|
||||
|
||||
// 更新题目
|
||||
$question->save($data);
|
||||
|
||||
return success($question->toArray(), '更新成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除题目(软删除,企业管理员只能删除自己企业的题目)
|
||||
* @param int $id
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function delete($id)
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
// 只有企业管理员可以删除题目
|
||||
if ($user['role'] !== 'enterprise_admin') {
|
||||
return error('只有企业管理员可以删除题目', 403);
|
||||
}
|
||||
|
||||
// 获取企业ID
|
||||
$userModel = Db::name('users')->where('id', $user['userId'])->find();
|
||||
$enterpriseId = $userModel['enterpriseId'] ?? null;
|
||||
|
||||
if (!$enterpriseId) {
|
||||
return error('企业信息不存在', 400);
|
||||
}
|
||||
|
||||
// 只能删除自己企业的题目
|
||||
$question = QuestionModel::where('id', $id)
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->find();
|
||||
|
||||
if (!$question) {
|
||||
return error('题目不存在或无权限删除', 404);
|
||||
}
|
||||
|
||||
// 执行软删除
|
||||
$question->delete();
|
||||
|
||||
return success(null, '删除成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量导入题目(企业管理员)
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function batchImport()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
// 只有企业管理员可以导入题目
|
||||
if ($user['role'] !== 'enterprise_admin') {
|
||||
return error('只有企业管理员可以导入题目', 403);
|
||||
}
|
||||
|
||||
// 获取企业ID
|
||||
$userModel = Db::name('users')->where('id', $user['userId'])->find();
|
||||
$enterpriseId = $userModel['enterpriseId'] ?? null;
|
||||
|
||||
if (!$enterpriseId) {
|
||||
return error('企业信息不存在', 400);
|
||||
}
|
||||
|
||||
$questions = Request::param('questions', []);
|
||||
|
||||
if (empty($questions) || !is_array($questions)) {
|
||||
return error('题目数据不能为空', 400);
|
||||
}
|
||||
|
||||
$successCount = 0;
|
||||
$failCount = 0;
|
||||
$errors = [];
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
foreach ($questions as $index => $q) {
|
||||
// 验证必填字段
|
||||
if (empty($q['type']) || empty($q['question']) || empty($q['options'])) {
|
||||
$failCount++;
|
||||
$errors[] = "第" . ($index + 1) . "题:题目类型、题目内容和选项不能为空";
|
||||
continue;
|
||||
}
|
||||
|
||||
// 验证类型
|
||||
if (!in_array($q['type'], ['mbti', 'sbti', 'disc', 'pdp'])) {
|
||||
$failCount++;
|
||||
$errors[] = "第" . ($index + 1) . "题:题目类型必须是 mbti、sbti、disc 或 pdp";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($q['type'], ['mbti', 'sbti'], true) && empty($q['dimension'])) {
|
||||
$failCount++;
|
||||
$errors[] = "第" . ($index + 1) . "题:MBTI/SBTI 题目必须指定维度";
|
||||
continue;
|
||||
}
|
||||
|
||||
// 设置企业ID
|
||||
$q['enterpriseId'] = $enterpriseId;
|
||||
$q['sort'] = $q['sort'] ?? ($index + 1);
|
||||
$q['status'] = $q['status'] ?? 1;
|
||||
|
||||
QuestionModel::create($q);
|
||||
$successCount++;
|
||||
}
|
||||
|
||||
Db::commit();
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
return error('批量导入失败:' . $e->getMessage(), 500);
|
||||
}
|
||||
|
||||
return success([
|
||||
'successCount' => $successCount,
|
||||
'failCount' => $failCount,
|
||||
'errors' => $errors
|
||||
], "成功导入 {$successCount} 题,失败 {$failCount} 题");
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换题目状态
|
||||
* @param int $id
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function toggleStatus($id)
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
// 只有企业管理员可以切换状态
|
||||
if ($user['role'] !== 'enterprise_admin') {
|
||||
return error('只有企业管理员可以切换题目状态', 403);
|
||||
}
|
||||
|
||||
// 获取企业ID
|
||||
$userModel = Db::name('users')->where('id', $user['userId'])->find();
|
||||
$enterpriseId = $userModel['enterpriseId'] ?? null;
|
||||
|
||||
if (!$enterpriseId) {
|
||||
return error('企业信息不存在', 400);
|
||||
}
|
||||
|
||||
// 只能操作自己企业的题目
|
||||
$question = QuestionModel::where('id', $id)
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->find();
|
||||
|
||||
if (!$question) {
|
||||
return error('题目不存在或无权限操作', 404);
|
||||
}
|
||||
|
||||
$question->status = $question->status == 1 ? 0 : 1;
|
||||
$question->save();
|
||||
|
||||
return success($question->toArray(), '状态更新成功');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,6 +102,29 @@ trait ExtractsTestResults
|
||||
return $this->coerceResultLabel($dec['pdp'] ?? null);
|
||||
}
|
||||
|
||||
if ($targetType === 'sbti') {
|
||||
$code = $this->coerceResultLabel($dec['sbtiType'] ?? null);
|
||||
if ($code === '' && isset($dec['finalType']) && is_array($dec['finalType'])) {
|
||||
$code = $this->coerceResultLabel($dec['finalType']['code'] ?? null);
|
||||
}
|
||||
$cn = '';
|
||||
if (!empty($dec['sbtiCn']) && is_string($dec['sbtiCn'])) {
|
||||
$cn = trim($dec['sbtiCn']);
|
||||
} elseif (isset($dec['finalType']) && is_array($dec['finalType']) && !empty($dec['finalType']['cn']) && is_string($dec['finalType']['cn'])) {
|
||||
$cn = trim((string) $dec['finalType']['cn']);
|
||||
}
|
||||
if ($code !== '' && $cn !== '') {
|
||||
return $code . '(' . $cn . ')';
|
||||
}
|
||||
if ($code !== '') {
|
||||
return $code;
|
||||
}
|
||||
if ($cn !== '') {
|
||||
return $cn;
|
||||
}
|
||||
return $this->coerceResultLabel($dec['code'] ?? null);
|
||||
}
|
||||
|
||||
$fallback = $this->coerceResultLabel($dec['type'] ?? null);
|
||||
if ($fallback !== '') {
|
||||
return $fallback;
|
||||
|
||||
@@ -103,8 +103,8 @@ class AppConfig extends BaseController
|
||||
}
|
||||
|
||||
// 报告付费开关:完全根据定价配置判断(价格 > 0 视为需要付费)
|
||||
$reportRequiresPayment = ['face' => 0, 'mbti' => 0, 'disc' => 0, 'pdp' => 0];
|
||||
foreach (['face', 'mbti', 'disc', 'pdp'] as $k) {
|
||||
$reportRequiresPayment = ['face' => 0, 'mbti' => 0, 'disc' => 0, 'pdp' => 0, 'sbti' => 0];
|
||||
foreach (['face', 'mbti', 'disc', 'pdp', 'sbti'] as $k) {
|
||||
$key = $k === 'team_analysis' ? 'teamAnalysis' : $k;
|
||||
if (isset($pricing[$key]) && (float) $pricing[$key] > 0) {
|
||||
$reportRequiresPayment[$k] = 1;
|
||||
|
||||
@@ -134,7 +134,7 @@ class CrmReport extends BaseController
|
||||
int $testResultId,
|
||||
int $contextEnterpriseId
|
||||
): ?string {
|
||||
$allowed = ['face', 'mbti', 'pdp', 'disc'];
|
||||
$allowed = ['face', 'mbti', 'sbti', 'pdp', 'disc'];
|
||||
if (!in_array($testType, $allowed, true)) {
|
||||
return null;
|
||||
}
|
||||
@@ -187,7 +187,7 @@ class CrmReport extends BaseController
|
||||
|
||||
return $e !== '' ? $e : $p;
|
||||
}
|
||||
foreach (['face', 'mbti', 'pdp', 'disc'] as $t) {
|
||||
foreach (['face', 'mbti', 'sbti', 'pdp', 'disc'] as $t) {
|
||||
if (!isset($decoded[$t]) || !is_array($decoded[$t])) {
|
||||
continue;
|
||||
}
|
||||
@@ -221,6 +221,7 @@ class CrmReport extends BaseController
|
||||
$map = [
|
||||
'face' => 'AI人脸性格分析',
|
||||
'mbti' => 'MBTI性格测试',
|
||||
'sbti' => 'SBTI人格测试',
|
||||
'pdp' => 'PDP动物性格测试',
|
||||
'disc' => 'DISC行为风格测试',
|
||||
];
|
||||
@@ -275,6 +276,14 @@ class CrmReport extends BaseController
|
||||
$t = $data['mbtiType'] ?? $data['mbti'] ?? '';
|
||||
|
||||
return is_string($t) ? trim($t) : '';
|
||||
case 'sbti':
|
||||
$code = (string) ($data['sbtiType'] ?? $data['finalType']['code'] ?? '');
|
||||
$cn = (string) ($data['sbtiCn'] ?? $data['finalType']['cn'] ?? '');
|
||||
if ($code === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $cn !== '' ? $code . '(' . $cn . ')' : $code;
|
||||
case 'face':
|
||||
$fa = $data['faceAnalysis'] ?? '';
|
||||
if (is_string($fa) && $fa !== '') {
|
||||
@@ -350,7 +359,7 @@ class CrmReport extends BaseController
|
||||
string $testScope = 'personal'
|
||||
): void {
|
||||
try {
|
||||
$allowed = ['face', 'mbti', 'pdp', 'disc'];
|
||||
$allowed = ['face', 'mbti', 'sbti', 'pdp', 'disc'];
|
||||
if (!in_array($testType, $allowed, true) || $userId <= 0 || $testResultId <= 0) {
|
||||
return;
|
||||
}
|
||||
@@ -429,7 +438,7 @@ class CrmReport extends BaseController
|
||||
|
||||
return in_array($t, ['after_paid', 'after_test'], true) ? $t : '';
|
||||
}
|
||||
foreach (['face', 'mbti', 'pdp', 'disc'] as $tKey) {
|
||||
foreach (['face', 'mbti', 'sbti', 'pdp', 'disc'] as $tKey) {
|
||||
if (!isset($decoded[$tKey]['reportTiming'])) {
|
||||
continue;
|
||||
}
|
||||
@@ -438,7 +447,7 @@ class CrmReport extends BaseController
|
||||
return 'after_test';
|
||||
}
|
||||
}
|
||||
foreach (['face', 'mbti', 'pdp', 'disc'] as $tKey) {
|
||||
foreach (['face', 'mbti', 'sbti', 'pdp', 'disc'] as $tKey) {
|
||||
if (!isset($decoded[$tKey]['reportTiming'])) {
|
||||
continue;
|
||||
}
|
||||
@@ -515,7 +524,7 @@ class CrmReport extends BaseController
|
||||
// 一次查出所有相关类型的最新记录(按时间倒序)
|
||||
$rows = Db::name('test_results')
|
||||
->where('userId', $userId)
|
||||
->whereIn('testType', ['mbti', 'disc', 'pdp'])
|
||||
->whereIn('testType', ['mbti', 'sbti', 'disc', 'pdp'])
|
||||
->field('testType, resultData, createdAt')
|
||||
->order('createdAt', 'desc')
|
||||
->select()
|
||||
@@ -545,6 +554,12 @@ class CrmReport extends BaseController
|
||||
$val = $data['description']['type'] ?? $data['pdp'] ?? '';
|
||||
if ($val !== '') $found['pdp'] = (string) $val;
|
||||
break;
|
||||
case 'sbti':
|
||||
$val = $data['sbtiType'] ?? $data['finalType']['code'] ?? '';
|
||||
if ($val !== '') {
|
||||
$found['sbti'] = (string) $val;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -867,7 +867,9 @@ class Distribution extends BaseController
|
||||
|
||||
$inviteeId = (int) $order['userId'];
|
||||
$orderAmount = (int) $order['amount'];
|
||||
$enterpriseId = !empty($order['enterpriseId']) ? (int) $order['enterpriseId'] : null;
|
||||
// 订单所属企业(资金应从该账户扣,不因绑定回退 personal 而丢失)
|
||||
$orderEnterpriseId = !empty($order['enterpriseId']) ? (int) $order['enterpriseId'] : null;
|
||||
$enterpriseId = $orderEnterpriseId;
|
||||
$scope = $enterpriseId ? 'enterprise' : 'personal';
|
||||
$now = time();
|
||||
|
||||
@@ -887,7 +889,7 @@ class Distribution extends BaseController
|
||||
->find();
|
||||
|
||||
// 【第二步】回退匹配:精确未命中时,查找任意有效的 personal 绑定
|
||||
// 跨 scope 场景(如企业版订单但推荐人仅持有 personal 绑定),以个人版配置+平台资金结算
|
||||
// 跨 scope 时仍用 personal 佣金配置,但资金仍从订单 enterpriseId 扣(见 distribution-design 2.2)
|
||||
$fallbackScope = $scope;
|
||||
$fallbackEnterpriseId = $enterpriseId;
|
||||
if (!$binding && $scope === 'enterprise') {
|
||||
@@ -902,9 +904,12 @@ class Distribution extends BaseController
|
||||
return;
|
||||
}
|
||||
|
||||
// 实际用于结算的 scope/enterpriseId(可能已回退为 personal)
|
||||
// 实际用于结算的 scope/绑定侧 enterprise(可能已回退为 personal 且为 null)
|
||||
$scope = $fallbackScope;
|
||||
$enterpriseId = $fallbackEnterpriseId;
|
||||
// 扣款企业:始终为订单上的企业;佣金配置优先用订单企业读 testSettings
|
||||
$billingEnterpriseId = $orderEnterpriseId;
|
||||
$configEnterpriseId = $orderEnterpriseId !== null ? $orderEnterpriseId : $enterpriseId;
|
||||
|
||||
$inviterId = (int) $binding['inviterId'];
|
||||
|
||||
@@ -916,7 +921,7 @@ class Distribution extends BaseController
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
// 读取佣金配置(优先 per-test testSettings,回退全局)
|
||||
list($rate, $amountFen) = self::getTestCommissionConfig($testType, $scope, $enterpriseId);
|
||||
list($rate, $amountFen) = self::getTestCommissionConfig($testType, $scope, $configEnterpriseId);
|
||||
$commissionFen = 0;
|
||||
if ($amountFen > 0) {
|
||||
$commissionFen = $amountFen;
|
||||
@@ -940,10 +945,10 @@ class Distribution extends BaseController
|
||||
try {
|
||||
$commissionStatus = 'pending';
|
||||
|
||||
if ($enterpriseId) {
|
||||
// 企业上下文:优先从企业余额扣款,余额不足则冻结
|
||||
if ($billingEnterpriseId) {
|
||||
// 企业订单:从订单所属企业余额扣款,余额不足则冻结
|
||||
$enterprise = Db::name('enterprises')
|
||||
->where('id', $enterpriseId)
|
||||
->where('id', $billingEnterpriseId)
|
||||
->field('id, balance')
|
||||
->lock(true)
|
||||
->find();
|
||||
@@ -954,14 +959,14 @@ class Distribution extends BaseController
|
||||
// 余额充足,直接结算
|
||||
$newBalanceFen = $balanceFen - $commissionFen;
|
||||
Db::name('enterprises')
|
||||
->where('id', $enterpriseId)
|
||||
->where('id', $billingEnterpriseId)
|
||||
->update([
|
||||
'balance' => $newBalanceFen,
|
||||
'updatedAt' => $now,
|
||||
]);
|
||||
|
||||
Db::name('finance_records')->insert([
|
||||
'enterpriseId' => $enterpriseId,
|
||||
'enterpriseId' => $billingEnterpriseId,
|
||||
'type' => 'consume',
|
||||
'amount' => $commissionFen,
|
||||
'balanceBefore' => $balanceFen,
|
||||
@@ -1004,7 +1009,7 @@ class Distribution extends BaseController
|
||||
'agentId' => $inviterId,
|
||||
'orderId' => $orderId,
|
||||
'scope' => $scope,
|
||||
'enterpriseId' => $enterpriseId,
|
||||
'enterpriseId' => $billingEnterpriseId,
|
||||
'inviterId' => $inviterId,
|
||||
'inviteeId' => $inviteeId,
|
||||
'bindingId' => (int) $binding['id'],
|
||||
@@ -1300,6 +1305,7 @@ class Distribution extends BaseController
|
||||
$map = [
|
||||
'face' => '人脸',
|
||||
'mbti' => 'MBTI',
|
||||
'sbti' => 'SBTI',
|
||||
'disc' => 'DISC',
|
||||
'pdp' => 'PDP',
|
||||
];
|
||||
@@ -1330,7 +1336,7 @@ class Distribution extends BaseController
|
||||
public static function settleTestCommission(int $testResultId, int $inviteeId, string $testType): void
|
||||
{
|
||||
// 仅支持指定测试类型
|
||||
$allowedTypes = ['face', 'mbti', 'disc', 'pdp'];
|
||||
$allowedTypes = ['face', 'mbti', 'sbti', 'disc', 'pdp'];
|
||||
// face/ai 统一归类为 face
|
||||
$normalizedType = ($testType === 'ai') ? 'face' : $testType;
|
||||
if (!in_array($normalizedType, $allowedTypes, true)) {
|
||||
|
||||
@@ -68,6 +68,7 @@ class Payment extends BaseController
|
||||
$testTypeMap = [
|
||||
'face' => 'face',
|
||||
'mbti' => 'mbti',
|
||||
'sbti' => 'sbti',
|
||||
'disc' => 'disc',
|
||||
'pdp' => 'pdp',
|
||||
'resume' => 'resume',
|
||||
@@ -191,6 +192,7 @@ class Payment extends BaseController
|
||||
$testTypeMap = [
|
||||
'face' => 'face',
|
||||
'mbti' => 'mbti',
|
||||
'sbti' => 'sbti',
|
||||
'disc' => 'disc',
|
||||
'pdp' => 'pdp',
|
||||
'resume' => 'resume',
|
||||
@@ -571,7 +573,7 @@ class Payment extends BaseController
|
||||
return;
|
||||
}
|
||||
|
||||
if (!in_array($productType, ['face', 'mbti', 'disc', 'pdp', 'resume', 'recharge'], true)) {
|
||||
if (!in_array($productType, ['face', 'mbti', 'sbti', 'disc', 'pdp', 'resume', 'recharge'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -665,7 +667,7 @@ class Payment extends BaseController
|
||||
$quantity = $quantity > 0 ? $quantity : 1;
|
||||
|
||||
// 1)测试类产品:定价配置中为元,转为分(企业用户按企业ID取价)
|
||||
$testProductTypes = ['face', 'mbti', 'disc', 'pdp', 'resume', 'report', 'team_analysis'];
|
||||
$testProductTypes = ['face', 'mbti', 'sbti', 'disc', 'pdp', 'resume', 'report', 'team_analysis'];
|
||||
if (in_array($productType, $testProductTypes, true)) {
|
||||
$pricingConfig = PricingConfigModel::getByTypeAndEnterprise($pricingType, $pricingEnterpriseId ?? $enterpriseId);
|
||||
$config = [];
|
||||
|
||||
@@ -32,7 +32,7 @@ class Test extends BaseController
|
||||
return error('未登录', 401);
|
||||
}
|
||||
|
||||
$type = Request::param('type', 'all'); // all|mbti|disc|pdp|face|ai|resume
|
||||
$type = Request::param('type', 'all'); // all|mbti|sbti|disc|pdp|face|ai|resume
|
||||
$scope = Request::param('scope', 'all'); // all|personal|enterprise
|
||||
$page = max(1, (int) Request::param('page', 1));
|
||||
$pageSize = (int) Request::param('pageSize', 0);
|
||||
@@ -186,6 +186,27 @@ class Test extends BaseController
|
||||
'data' => null,
|
||||
], $paymentFields);
|
||||
break;
|
||||
case 'sbti':
|
||||
$sbtiTxt = '未知';
|
||||
if (is_array($data)) {
|
||||
$sbtiTxt = (string) ($data['sbtiType'] ?? $data['finalType']['code'] ?? $data['code'] ?? '未知');
|
||||
if (($data['sbtiCn'] ?? '') !== '') {
|
||||
$sbtiTxt .= '(' . $data['sbtiCn'] . ')';
|
||||
} elseif (isset($data['finalType']['cn']) && $data['finalType']['cn'] !== '') {
|
||||
$sbtiTxt .= '(' . $data['finalType']['cn'] . ')';
|
||||
}
|
||||
}
|
||||
$list[] = array_merge([
|
||||
'id' => $id,
|
||||
'type' => 'sbti',
|
||||
'key' => 'sbti_' . $id,
|
||||
'emoji' => '🎭',
|
||||
'typeName' => 'SBTI 人格测试',
|
||||
'resultText'=> $sbtiTxt,
|
||||
'testTime' => $timeLabel,
|
||||
'data' => null,
|
||||
], $paymentFields);
|
||||
break;
|
||||
case 'face':
|
||||
case 'ai':
|
||||
$mbtiShort = '';
|
||||
@@ -261,7 +282,7 @@ class Test extends BaseController
|
||||
|
||||
$allowedAll = $this->wechatAllowedTestTypes($userId);
|
||||
// 「我的」卡片不含简历,但 totalCount 与列表需与 history 权限一致
|
||||
$allowedForRecent = array_values(array_intersect($allowedAll, ['mbti', 'pdp', 'disc', 'face', 'ai']));
|
||||
$allowedForRecent = array_values(array_intersect($allowedAll, ['mbti', 'sbti', 'pdp', 'disc', 'face', 'ai']));
|
||||
if ($allowedForRecent === []) {
|
||||
return success([
|
||||
'records' => new \stdClass(),
|
||||
@@ -291,7 +312,7 @@ class Test extends BaseController
|
||||
// face 和 ai 视为同一种类型
|
||||
$effectiveType = in_array($type, ['face', 'ai']) ? 'ai' : $type;
|
||||
|
||||
if (!isset($foundTypes[$effectiveType]) && in_array($effectiveType, ['mbti', 'disc', 'pdp', 'ai'])) {
|
||||
if (!isset($foundTypes[$effectiveType]) && in_array($effectiveType, ['mbti', 'sbti', 'disc', 'pdp', 'ai'])) {
|
||||
$records[$effectiveType] = $this->_formatRecentRow($row);
|
||||
$foundTypes[$effectiveType] = true;
|
||||
}
|
||||
@@ -342,6 +363,9 @@ class Test extends BaseController
|
||||
if (!empty($enterprisePerms['disc'])) {
|
||||
$allowed[] = 'disc';
|
||||
}
|
||||
if (!empty($enterprisePerms['sbti'])) {
|
||||
$allowed[] = 'sbti';
|
||||
}
|
||||
if (!empty($enterprisePerms['face']) && !$reviewMode) {
|
||||
$allowed[] = 'face';
|
||||
$allowed[] = 'ai';
|
||||
@@ -437,6 +461,16 @@ class Test extends BaseController
|
||||
$emoji = $data['description']['emoji'] ?? '🦁';
|
||||
$typeName = 'PDP行为';
|
||||
break;
|
||||
case 'sbti':
|
||||
$resultText = (string) ($data['sbtiType'] ?? $data['finalType']['code'] ?? '未知');
|
||||
if (!empty($data['sbtiCn'])) {
|
||||
$resultText .= '(' . $data['sbtiCn'] . ')';
|
||||
} elseif (!empty($data['finalType']['cn'])) {
|
||||
$resultText .= '(' . $data['finalType']['cn'] . ')';
|
||||
}
|
||||
$emoji = '🎭';
|
||||
$typeName = 'SBTI';
|
||||
break;
|
||||
case 'face':
|
||||
case 'ai':
|
||||
$mbtiShort = '';
|
||||
@@ -481,6 +515,12 @@ class Test extends BaseController
|
||||
'description' => $data['description'] ?? null,
|
||||
'pdp' => $data['pdp'] ?? null,
|
||||
];
|
||||
} elseif ($testType === 'sbti') {
|
||||
$resultMeta = [
|
||||
'sbtiType' => $data['sbtiType'] ?? null,
|
||||
'sbtiCn' => $data['sbtiCn'] ?? null,
|
||||
'levels' => $data['levels'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
$out = [
|
||||
@@ -609,7 +649,7 @@ class Test extends BaseController
|
||||
}
|
||||
|
||||
// 仅允许已知类型,避免脏数据
|
||||
if (!in_array($testType, ['mbti', 'disc', 'pdp', 'face', 'ai'], true)) {
|
||||
if (!in_array($testType, ['mbti', 'sbti', 'disc', 'pdp', 'face', 'ai'], true)) {
|
||||
return error('不支持的测试类型', 400);
|
||||
}
|
||||
|
||||
@@ -796,6 +836,13 @@ class Test extends BaseController
|
||||
'locked' => true,
|
||||
];
|
||||
}
|
||||
if ($testType === 'sbti') {
|
||||
return [
|
||||
'sbtiType' => $data['sbtiType'] ?? $data['finalType']['code'] ?? '',
|
||||
'sbtiCn' => $data['sbtiCn'] ?? $data['finalType']['cn'] ?? '',
|
||||
'locked' => true,
|
||||
];
|
||||
}
|
||||
if ($testType === 'resume') {
|
||||
$preview = '';
|
||||
if (!empty($data['content']) && is_string($data['content'])) {
|
||||
@@ -948,7 +995,7 @@ class Test extends BaseController
|
||||
|
||||
/**
|
||||
* 小程序拉取做题题库(仅启用题):企业本题库有题则用企业,否则用超管 enterpriseId 为空
|
||||
* GET /api/test/questions?type=mbti|disc|pdp&enterpriseId=可选
|
||||
* GET /api/test/questions?type=mbti|sbti|disc|pdp&enterpriseId=可选
|
||||
*/
|
||||
public function questions()
|
||||
{
|
||||
@@ -958,8 +1005,8 @@ class Test extends BaseController
|
||||
}
|
||||
|
||||
$type = (string) Request::param('type', '');
|
||||
if (!in_array($type, ['mbti', 'disc', 'pdp'], true)) {
|
||||
return error('type 须为 mbti、disc 或 pdp', 400);
|
||||
if (!in_array($type, ['mbti', 'sbti', 'disc', 'pdp'], true)) {
|
||||
return error('type 须为 mbti、sbti、disc 或 pdp', 400);
|
||||
}
|
||||
|
||||
$rawEid = Request::param('enterpriseId', null);
|
||||
@@ -1018,7 +1065,8 @@ class Test extends BaseController
|
||||
}
|
||||
}
|
||||
unset($opt);
|
||||
if ($type !== 'mbti') {
|
||||
// MBTI / SBTI 前端组卷与计分依赖 dimension,其余题型不对外返回该字段
|
||||
if (!in_array($type, ['mbti', 'sbti'], true)) {
|
||||
unset($item['dimension']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,6 +477,7 @@ class AppUser extends BaseController
|
||||
$row['mbtiType'] = $this->extractResultType($testsForUser, 'mbti');
|
||||
$row['pdpType'] = $this->extractResultType($testsForUser, 'pdp');
|
||||
$row['discType'] = $this->extractResultType($testsForUser, 'disc');
|
||||
$row['sbtiType'] = $this->extractResultType($testsForUser, 'sbti');
|
||||
$row['faceType'] = $this->extractResultType($testsForUser, 'face');
|
||||
$row['faceMbtiType'] = $this->extractFaceSubType($testsForUser, 'mbti');
|
||||
$row['faceDiscType'] = $this->extractFaceSubType($testsForUser, 'disc');
|
||||
@@ -547,6 +548,7 @@ class AppUser extends BaseController
|
||||
$data['mbtiType'] = $this->extractResultType($tests, 'mbti');
|
||||
$data['pdpType'] = $this->extractResultType($tests, 'pdp');
|
||||
$data['discType'] = $this->extractResultType($tests, 'disc');
|
||||
$data['sbtiType'] = $this->extractResultType($tests, 'sbti');
|
||||
$data['faceType'] = $this->extractResultType($tests, 'face');
|
||||
$data['faceMbtiType'] = $this->extractFaceSubType($tests, 'mbti');
|
||||
$data['faceDiscType'] = $this->extractFaceSubType($tests, 'disc');
|
||||
|
||||
@@ -1,332 +1,331 @@
|
||||
<?php
|
||||
namespace app\controller\superadmin;
|
||||
|
||||
use app\BaseController;
|
||||
use app\model\Question as QuestionModel;
|
||||
use think\facade\Request;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 题库管理控制器(超管专用)
|
||||
* 管理超管题库(enterpriseId = NULL)
|
||||
*/
|
||||
class Question extends BaseController
|
||||
{
|
||||
/**
|
||||
* 获取题库列表
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
// 验证是否为超级管理员
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || $user['role'] !== 'superadmin') {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
$page = Request::param('page', 1);
|
||||
$pageSize = Request::param('pageSize', 20);
|
||||
$type = Request::param('type', ''); // mbti/disc/pdp
|
||||
$status = Request::param('status', ''); // 1启用/0禁用
|
||||
|
||||
$where = [];
|
||||
|
||||
// 只查询超管题库(enterpriseId = NULL)
|
||||
$where['enterpriseId'] = null;
|
||||
|
||||
// 类型筛选
|
||||
if ($type) {
|
||||
$where['type'] = $type;
|
||||
}
|
||||
|
||||
// 状态筛选
|
||||
if ($status !== '') {
|
||||
$where['status'] = $status;
|
||||
}
|
||||
|
||||
// 查询题库列表
|
||||
$list = QuestionModel::where($where)
|
||||
->order('sort', 'asc')
|
||||
->order('id', 'asc')
|
||||
->page($page, $pageSize)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
// 处理 options 字段,确保返回数组格式
|
||||
foreach ($list as &$item) {
|
||||
if (isset($item['options'])) {
|
||||
// 如果是对象格式(stdClass),先转换为数组
|
||||
if (is_object($item['options'])) {
|
||||
$item['options'] = json_decode(json_encode($item['options']), true);
|
||||
}
|
||||
// 如果是关联数组(不是索引数组),转换为索引数组
|
||||
if (is_array($item['options']) && !isset($item['options'][0])) {
|
||||
$item['options'] = array_values($item['options']);
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($item);
|
||||
|
||||
// 总数
|
||||
$total = QuestionModel::where($where)->count();
|
||||
|
||||
return success([
|
||||
'list' => $list,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'pageSize' => $pageSize
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取题目详情
|
||||
* @param int $id
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function detail($id)
|
||||
{
|
||||
// 验证是否为超级管理员
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || $user['role'] !== 'superadmin') {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
$question = QuestionModel::where('id', $id)
|
||||
->where('enterpriseId', null) // 只能查看超管题库
|
||||
->find();
|
||||
|
||||
if (!$question) {
|
||||
return error('题目不存在', 404);
|
||||
}
|
||||
|
||||
$data = $question->toArray();
|
||||
|
||||
// 处理 options 字段,确保返回数组格式
|
||||
if (isset($data['options'])) {
|
||||
// 如果是对象格式(stdClass),先转换为数组
|
||||
if (is_object($data['options'])) {
|
||||
$data['options'] = json_decode(json_encode($data['options']), true);
|
||||
}
|
||||
// 如果是关联数组(不是索引数组),转换为索引数组
|
||||
if (is_array($data['options']) && !isset($data['options'][0])) {
|
||||
$data['options'] = array_values($data['options']);
|
||||
}
|
||||
}
|
||||
|
||||
return success($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建题目
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
// 验证是否为超级管理员
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || $user['role'] !== 'superadmin') {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
$data = Request::only(['type', 'question', 'options', 'dimension', 'sort', 'status']);
|
||||
|
||||
// 验证必填字段
|
||||
if (empty($data['type']) || empty($data['question']) || empty($data['options'])) {
|
||||
return error('题目类型、题目内容和选项不能为空', 400);
|
||||
}
|
||||
|
||||
// 验证类型
|
||||
if (!in_array($data['type'], ['mbti', 'disc', 'pdp'])) {
|
||||
return error('题目类型必须是 mbti、disc 或 pdp', 400);
|
||||
}
|
||||
|
||||
// 验证选项格式
|
||||
if (!is_array($data['options'])) {
|
||||
return error('选项必须是数组格式', 400);
|
||||
}
|
||||
|
||||
// MBTI类型需要dimension字段
|
||||
if ($data['type'] === 'mbti' && empty($data['dimension'])) {
|
||||
return error('MBTI类型题目必须指定维度(EI/SN/TF/JP)', 400);
|
||||
}
|
||||
|
||||
// 设置超管题库标识(enterpriseId = NULL)
|
||||
$data['enterpriseId'] = null;
|
||||
|
||||
// 设置默认值
|
||||
$data['sort'] = $data['sort'] ?? 0;
|
||||
$data['status'] = $data['status'] ?? 1;
|
||||
|
||||
// 创建题目
|
||||
$question = QuestionModel::create($data);
|
||||
|
||||
return success($question->toArray(), '创建成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新题目
|
||||
* @param int $id
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function update($id)
|
||||
{
|
||||
// 验证是否为超级管理员
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || $user['role'] !== 'superadmin') {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
$question = QuestionModel::where('id', $id)
|
||||
->where('enterpriseId', null) // 只能更新超管题库
|
||||
->find();
|
||||
|
||||
if (!$question) {
|
||||
return error('题目不存在', 404);
|
||||
}
|
||||
|
||||
$data = Request::only(['type', 'question', 'options', 'dimension', 'sort', 'status']);
|
||||
|
||||
// 验证类型
|
||||
if (isset($data['type']) && !in_array($data['type'], ['mbti', 'disc', 'pdp'])) {
|
||||
return error('题目类型必须是 mbti、disc 或 pdp', 400);
|
||||
}
|
||||
|
||||
// 验证选项格式
|
||||
if (isset($data['options']) && !is_array($data['options'])) {
|
||||
return error('选项必须是数组格式', 400);
|
||||
}
|
||||
|
||||
// MBTI类型需要dimension字段
|
||||
if (($data['type'] ?? $question->type) === 'mbti' && empty($data['dimension'] ?? $question->dimension)) {
|
||||
return error('MBTI类型题目必须指定维度(EI/SN/TF/JP)', 400);
|
||||
}
|
||||
|
||||
// 更新题目
|
||||
$question->save($data);
|
||||
|
||||
return success($question->toArray(), '更新成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除题目(软删除)
|
||||
* @param int $id
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function delete($id)
|
||||
{
|
||||
// 验证是否为超级管理员
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || $user['role'] !== 'superadmin') {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
$question = QuestionModel::where('id', $id)
|
||||
->where('enterpriseId', null) // 只能删除超管题库
|
||||
->find();
|
||||
|
||||
if (!$question) {
|
||||
return error('题目不存在', 404);
|
||||
}
|
||||
|
||||
// 执行软删除
|
||||
$question->delete();
|
||||
|
||||
return success(null, '删除成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量导入题目
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function batchImport()
|
||||
{
|
||||
// 验证是否为超级管理员
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || $user['role'] !== 'superadmin') {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
$questions = Request::param('questions', []);
|
||||
|
||||
if (empty($questions) || !is_array($questions)) {
|
||||
return error('题目数据不能为空', 400);
|
||||
}
|
||||
|
||||
$successCount = 0;
|
||||
$failCount = 0;
|
||||
$errors = [];
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
foreach ($questions as $index => $q) {
|
||||
// 验证必填字段
|
||||
if (empty($q['type']) || empty($q['question']) || empty($q['options'])) {
|
||||
$failCount++;
|
||||
$errors[] = "第" . ($index + 1) . "题:题目类型、题目内容和选项不能为空";
|
||||
continue;
|
||||
}
|
||||
|
||||
// 验证类型
|
||||
if (!in_array($q['type'], ['mbti', 'disc', 'pdp'])) {
|
||||
$failCount++;
|
||||
$errors[] = "第" . ($index + 1) . "题:题目类型必须是 mbti、disc 或 pdp";
|
||||
continue;
|
||||
}
|
||||
|
||||
// MBTI类型需要dimension字段
|
||||
if ($q['type'] === 'mbti' && empty($q['dimension'])) {
|
||||
$failCount++;
|
||||
$errors[] = "第" . ($index + 1) . "题:MBTI类型题目必须指定维度";
|
||||
continue;
|
||||
}
|
||||
|
||||
// 设置超管题库标识
|
||||
$q['enterpriseId'] = null;
|
||||
$q['sort'] = $q['sort'] ?? ($index + 1);
|
||||
$q['status'] = $q['status'] ?? 1;
|
||||
|
||||
QuestionModel::create($q);
|
||||
$successCount++;
|
||||
}
|
||||
|
||||
Db::commit();
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
return error('批量导入失败:' . $e->getMessage(), 500);
|
||||
}
|
||||
|
||||
return success([
|
||||
'successCount' => $successCount,
|
||||
'failCount' => $failCount,
|
||||
'errors' => $errors
|
||||
], "成功导入 {$successCount} 题,失败 {$failCount} 题");
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换题目状态
|
||||
* @param int $id
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function toggleStatus($id)
|
||||
{
|
||||
// 验证是否为超级管理员
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || $user['role'] !== 'superadmin') {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
$question = QuestionModel::where('id', $id)
|
||||
->where('enterpriseId', null) // 只能操作超管题库
|
||||
->find();
|
||||
|
||||
if (!$question) {
|
||||
return error('题目不存在', 404);
|
||||
}
|
||||
|
||||
$question->status = $question->status == 1 ? 0 : 1;
|
||||
$question->save();
|
||||
|
||||
return success($question->toArray(), '状态更新成功');
|
||||
}
|
||||
}
|
||||
|
||||
<?php
|
||||
namespace app\controller\superadmin;
|
||||
|
||||
use app\BaseController;
|
||||
use app\model\Question as QuestionModel;
|
||||
use think\facade\Request;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 题库管理控制器(超管专用)
|
||||
* 管理超管题库(enterpriseId = NULL)
|
||||
*/
|
||||
class Question extends BaseController
|
||||
{
|
||||
/**
|
||||
* 获取题库列表
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
// 验证是否为超级管理员
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || $user['role'] !== 'superadmin') {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
$page = Request::param('page', 1);
|
||||
$pageSize = Request::param('pageSize', 20);
|
||||
$type = Request::param('type', ''); // mbti/disc/pdp
|
||||
$status = Request::param('status', ''); // 1启用/0禁用
|
||||
|
||||
$where = [];
|
||||
|
||||
// 只查询超管题库(enterpriseId = NULL)
|
||||
$where['enterpriseId'] = null;
|
||||
|
||||
// 类型筛选
|
||||
if ($type) {
|
||||
$where['type'] = $type;
|
||||
}
|
||||
|
||||
// 状态筛选
|
||||
if ($status !== '') {
|
||||
$where['status'] = $status;
|
||||
}
|
||||
|
||||
// 查询题库列表
|
||||
$list = QuestionModel::where($where)
|
||||
->order('sort', 'asc')
|
||||
->order('id', 'asc')
|
||||
->page($page, $pageSize)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
// 处理 options 字段,确保返回数组格式
|
||||
foreach ($list as &$item) {
|
||||
if (isset($item['options'])) {
|
||||
// 如果是对象格式(stdClass),先转换为数组
|
||||
if (is_object($item['options'])) {
|
||||
$item['options'] = json_decode(json_encode($item['options']), true);
|
||||
}
|
||||
// 如果是关联数组(不是索引数组),转换为索引数组
|
||||
if (is_array($item['options']) && !isset($item['options'][0])) {
|
||||
$item['options'] = array_values($item['options']);
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($item);
|
||||
|
||||
// 总数
|
||||
$total = QuestionModel::where($where)->count();
|
||||
|
||||
return success([
|
||||
'list' => $list,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'pageSize' => $pageSize
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取题目详情
|
||||
* @param int $id
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function detail($id)
|
||||
{
|
||||
// 验证是否为超级管理员
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || $user['role'] !== 'superadmin') {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
$question = QuestionModel::where('id', $id)
|
||||
->where('enterpriseId', null) // 只能查看超管题库
|
||||
->find();
|
||||
|
||||
if (!$question) {
|
||||
return error('题目不存在', 404);
|
||||
}
|
||||
|
||||
$data = $question->toArray();
|
||||
|
||||
// 处理 options 字段,确保返回数组格式
|
||||
if (isset($data['options'])) {
|
||||
// 如果是对象格式(stdClass),先转换为数组
|
||||
if (is_object($data['options'])) {
|
||||
$data['options'] = json_decode(json_encode($data['options']), true);
|
||||
}
|
||||
// 如果是关联数组(不是索引数组),转换为索引数组
|
||||
if (is_array($data['options']) && !isset($data['options'][0])) {
|
||||
$data['options'] = array_values($data['options']);
|
||||
}
|
||||
}
|
||||
|
||||
return success($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建题目
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
// 验证是否为超级管理员
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || $user['role'] !== 'superadmin') {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
$data = Request::only(['type', 'question', 'options', 'dimension', 'sort', 'status']);
|
||||
|
||||
// 验证必填字段
|
||||
if (empty($data['type']) || empty($data['question']) || empty($data['options'])) {
|
||||
return error('题目类型、题目内容和选项不能为空', 400);
|
||||
}
|
||||
|
||||
// 验证类型
|
||||
if (!in_array($data['type'], ['mbti', 'sbti', 'disc', 'pdp'])) {
|
||||
return error('题目类型必须是 mbti、sbti、disc 或 pdp', 400);
|
||||
}
|
||||
|
||||
// 验证选项格式
|
||||
if (!is_array($data['options'])) {
|
||||
return error('选项必须是数组格式', 400);
|
||||
}
|
||||
|
||||
// MBTI / SBTI 需 dimension
|
||||
if (in_array($data['type'], ['mbti', 'sbti'], true) && empty($data['dimension'])) {
|
||||
return error($data['type'] === 'sbti' ? 'SBTI 题目必须指定维度(如 S1、DG1)' : 'MBTI类型题目必须指定维度(EI/SN/TF/JP)', 400);
|
||||
}
|
||||
|
||||
// 设置超管题库标识(enterpriseId = NULL)
|
||||
$data['enterpriseId'] = null;
|
||||
|
||||
// 设置默认值
|
||||
$data['sort'] = $data['sort'] ?? 0;
|
||||
$data['status'] = $data['status'] ?? 1;
|
||||
|
||||
// 创建题目
|
||||
$question = QuestionModel::create($data);
|
||||
|
||||
return success($question->toArray(), '创建成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新题目
|
||||
* @param int $id
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function update($id)
|
||||
{
|
||||
// 验证是否为超级管理员
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || $user['role'] !== 'superadmin') {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
$question = QuestionModel::where('id', $id)
|
||||
->where('enterpriseId', null) // 只能更新超管题库
|
||||
->find();
|
||||
|
||||
if (!$question) {
|
||||
return error('题目不存在', 404);
|
||||
}
|
||||
|
||||
$data = Request::only(['type', 'question', 'options', 'dimension', 'sort', 'status']);
|
||||
|
||||
// 验证类型
|
||||
if (isset($data['type']) && !in_array($data['type'], ['mbti', 'sbti', 'disc', 'pdp'])) {
|
||||
return error('题目类型必须是 mbti、sbti、disc 或 pdp', 400);
|
||||
}
|
||||
|
||||
// 验证选项格式
|
||||
if (isset($data['options']) && !is_array($data['options'])) {
|
||||
return error('选项必须是数组格式', 400);
|
||||
}
|
||||
|
||||
$effType = $data['type'] ?? $question->type;
|
||||
if (in_array($effType, ['mbti', 'sbti'], true) && empty($data['dimension'] ?? $question->dimension)) {
|
||||
return error($effType === 'sbti' ? 'SBTI 题目必须指定维度' : 'MBTI类型题目必须指定维度(EI/SN/TF/JP)', 400);
|
||||
}
|
||||
|
||||
// 更新题目
|
||||
$question->save($data);
|
||||
|
||||
return success($question->toArray(), '更新成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除题目(软删除)
|
||||
* @param int $id
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function delete($id)
|
||||
{
|
||||
// 验证是否为超级管理员
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || $user['role'] !== 'superadmin') {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
$question = QuestionModel::where('id', $id)
|
||||
->where('enterpriseId', null) // 只能删除超管题库
|
||||
->find();
|
||||
|
||||
if (!$question) {
|
||||
return error('题目不存在', 404);
|
||||
}
|
||||
|
||||
// 执行软删除
|
||||
$question->delete();
|
||||
|
||||
return success(null, '删除成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量导入题目
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function batchImport()
|
||||
{
|
||||
// 验证是否为超级管理员
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || $user['role'] !== 'superadmin') {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
$questions = Request::param('questions', []);
|
||||
|
||||
if (empty($questions) || !is_array($questions)) {
|
||||
return error('题目数据不能为空', 400);
|
||||
}
|
||||
|
||||
$successCount = 0;
|
||||
$failCount = 0;
|
||||
$errors = [];
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
foreach ($questions as $index => $q) {
|
||||
// 验证必填字段
|
||||
if (empty($q['type']) || empty($q['question']) || empty($q['options'])) {
|
||||
$failCount++;
|
||||
$errors[] = "第" . ($index + 1) . "题:题目类型、题目内容和选项不能为空";
|
||||
continue;
|
||||
}
|
||||
|
||||
// 验证类型
|
||||
if (!in_array($q['type'], ['mbti', 'sbti', 'disc', 'pdp'])) {
|
||||
$failCount++;
|
||||
$errors[] = "第" . ($index + 1) . "题:题目类型必须是 mbti、sbti、disc 或 pdp";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($q['type'], ['mbti', 'sbti'], true) && empty($q['dimension'])) {
|
||||
$failCount++;
|
||||
$errors[] = "第" . ($index + 1) . "题:MBTI/SBTI 题目必须指定维度";
|
||||
continue;
|
||||
}
|
||||
|
||||
// 设置超管题库标识
|
||||
$q['enterpriseId'] = null;
|
||||
$q['sort'] = $q['sort'] ?? ($index + 1);
|
||||
$q['status'] = $q['status'] ?? 1;
|
||||
|
||||
QuestionModel::create($q);
|
||||
$successCount++;
|
||||
}
|
||||
|
||||
Db::commit();
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
return error('批量导入失败:' . $e->getMessage(), 500);
|
||||
}
|
||||
|
||||
return success([
|
||||
'successCount' => $successCount,
|
||||
'failCount' => $failCount,
|
||||
'errors' => $errors
|
||||
], "成功导入 {$successCount} 题,失败 {$failCount} 题");
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换题目状态
|
||||
* @param int $id
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function toggleStatus($id)
|
||||
{
|
||||
// 验证是否为超级管理员
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || $user['role'] !== 'superadmin') {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
$question = QuestionModel::where('id', $id)
|
||||
->where('enterpriseId', null) // 只能操作超管题库
|
||||
->find();
|
||||
|
||||
if (!$question) {
|
||||
return error('题目不存在', 404);
|
||||
}
|
||||
|
||||
$question->status = $question->status == 1 ? 0 : 1;
|
||||
$question->save();
|
||||
|
||||
return success($question->toArray(), '状态更新成功');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ class Enterprise extends Model
|
||||
return [
|
||||
'face' => true,
|
||||
'mbti' => true,
|
||||
'sbti' => true,
|
||||
'pdp' => true,
|
||||
'disc' => true,
|
||||
'distribution' => true,
|
||||
|
||||
35
api/database/extract_sbti_bundle.js
Normal file
35
api/database/extract_sbti_bundle.js
Normal file
@@ -0,0 +1,35 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', '..', '_sbti_src.html'), 'utf8') // api/database -> project root
|
||||
function grab(name) {
|
||||
const re = new RegExp(`const ${name} = ([\\s\\S]*?);\\s*const `)
|
||||
const m = html.match(re)
|
||||
if (!m) throw new Error('missing ' + name)
|
||||
return m[1].trim()
|
||||
}
|
||||
const parts = ['TYPE_LIBRARY', 'TYPE_IMAGES', 'NORMAL_TYPES', 'DIM_EXPLANATIONS'].map((n) => `${n}: ${grab(n)}`)
|
||||
const dest = path.join(__dirname, '..', '..', 'miniprogram', 'utils', 'sbtiData.js')
|
||||
const head = `// 从 aisbti.com 测试页提取,算法与官方一致;勿手改数据结构\nmodule.exports = {\n`
|
||||
const tail = `,
|
||||
dimensionOrder: ['S1','S2','S3','E1','E2','E3','A1','A2','A3','Ac1','Ac2','Ac3','So1','So2','So3'],
|
||||
dimensionMeta: {
|
||||
S1: { name: 'S1 自尊自信', model: '自我模型' },
|
||||
S2: { name: 'S2 自我清晰度', model: '自我模型' },
|
||||
S3: { name: 'S3 核心价值', model: '自我模型' },
|
||||
E1: { name: 'E1 依恋安全感', model: '情感模型' },
|
||||
E2: { name: 'E2 情感投入度', model: '情感模型' },
|
||||
E3: { name: 'E3 边界与依赖', model: '情感模型' },
|
||||
A1: { name: 'A1 世界观倾向', model: '态度模型' },
|
||||
A2: { name: 'A2 规则与灵活度', model: '态度模型' },
|
||||
A3: { name: 'A3 人生意义感', model: '态度模型' },
|
||||
Ac1: { name: 'Ac1 动机导向', model: '行动驱力模型' },
|
||||
Ac2: { name: 'Ac2 决策风格', model: '行动驱力模型' },
|
||||
Ac3: { name: 'Ac3 执行模式', model: '行动驱力模型' },
|
||||
So1: { name: 'So1 社交主动性', model: '社交模型' },
|
||||
So2: { name: 'So2 人际边界感', model: '社交模型' },
|
||||
So3: { name: 'So3 表达与真实度', model: '社交模型' }
|
||||
}
|
||||
};
|
||||
`
|
||||
fs.writeFileSync(dest, head + parts.join(',\n') + tail, 'utf8')
|
||||
console.log('written', dest, fs.statSync(dest).size)
|
||||
210
api/database/gen_sbti_questions_sql.js
Normal file
210
api/database/gen_sbti_questions_sql.js
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* 生成 SBTI 题库 INSERT SQL(mbti_questions 表)
|
||||
* 运行: node gen_sbti_questions_sql.js > sbti_questions_data.sql
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const rows = [];
|
||||
|
||||
function add(dim, sort, question, options) {
|
||||
rows.push({ dim, sort, question, options });
|
||||
}
|
||||
|
||||
// —— 30 道计分题(与 aisbti.com 同源逻辑,value 1/2/3)——
|
||||
add('S1', 1, '我不仅是屌丝,我还是joker,我还是咸鱼,这辈子没谈过一场恋爱,胆怯又自卑,我的青春就是一场又一场的意淫,每一天幻想着我也能有一个女孩子和我一起压马路,一起逛街,一起玩,现实却是爆了父母金币,读了个烂学校,混日子之后找班上,没有理想,没有目标,没有能力的三无人员,每次看到你能在网上开屌丝的玩笑,我都想哭,我就是地底下的老鼠,透过下水井的缝隙,窥探地上的各种美好,每一次看到这种都是对我心灵的一次伤害,对我生存空间的一次压缩,求求哥们给我们这种小丑一点活路吧,我真的不想在白天把枕巾哭湿一大片', [
|
||||
{ value: 1, text: '我哭了。。' },
|
||||
{ value: 2, text: '这是什么。。' },
|
||||
{ value: 3, text: '这不是我!' },
|
||||
]);
|
||||
add('S1', 2, '我不够好,周围的人都比我优秀', [
|
||||
{ value: 1, text: '确实' },
|
||||
{ value: 2, text: '有时' },
|
||||
{ value: 3, text: '不是' },
|
||||
]);
|
||||
add('S2', 3, '我很清楚真正的自己是什么样的', [
|
||||
{ value: 1, text: '不认同' },
|
||||
{ value: 2, text: '中立' },
|
||||
{ value: 3, text: '认同' },
|
||||
]);
|
||||
add('S2', 4, '我内心有真正追求的东西', [
|
||||
{ value: 1, text: '不认同' },
|
||||
{ value: 2, text: '中立' },
|
||||
{ value: 3, text: '认同' },
|
||||
]);
|
||||
add('S3', 5, '我一定要不断往上爬、变得更厉害', [
|
||||
{ value: 1, text: '不认同' },
|
||||
{ value: 2, text: '中立' },
|
||||
{ value: 3, text: '认同' },
|
||||
]);
|
||||
add('S3', 6, '外人的评价对我来说无所吊谓。', [
|
||||
{ value: 1, text: '不认同' },
|
||||
{ value: 2, text: '中立' },
|
||||
{ value: 3, text: '认同' },
|
||||
]);
|
||||
add('E1', 7, '对象超过5小时没回消息,说自己窜稀了,你会怎么想?', [
|
||||
{ value: 1, text: '拉稀不可能5小时,也许ta隐瞒了我。' },
|
||||
{ value: 2, text: '在信任和怀疑之间摇摆。' },
|
||||
{ value: 3, text: '也许今天ta真的不太舒服。' },
|
||||
]);
|
||||
add('E1', 8, '我在感情里经常担心被对方抛弃', [
|
||||
{ value: 1, text: '是的' },
|
||||
{ value: 2, text: '偶尔' },
|
||||
{ value: 3, text: '不是' },
|
||||
]);
|
||||
add('E2', 9, '我对天发誓,我对待每一份感情都是认真的!', [
|
||||
{ value: 1, text: '并没有' },
|
||||
{ value: 2, text: '也许?' },
|
||||
{ value: 3, text: '是的!(问心无愧骄傲脸)' },
|
||||
]);
|
||||
add('E2', 10, '你的恋爱对象是一个尊老爱幼,温柔敦厚,洁身自好,光明磊落,大义凛然,能言善辩,口才流利,观察入微,见多识广,博学多才,诲人不倦,和蔼可亲,平易近人,心地善良,慈眉善目,积极进取,意气风发,玉树临风,国色天香,倾国倾城,花容月貌的人,此时你会?', [
|
||||
{ value: 1, text: '就算ta再优秀我也不会陷入太深。' },
|
||||
{ value: 2, text: '会介于A和C之间。' },
|
||||
{ value: 3, text: '会非常珍惜ta,也许会变成恋爱脑。' },
|
||||
]);
|
||||
add('E3', 11, '恋爱后,对象非常黏人,你作何感想?', [
|
||||
{ value: 1, text: '那很爽了' },
|
||||
{ value: 2, text: '都行无所谓' },
|
||||
{ value: 3, text: '我更喜欢保留独立空间' },
|
||||
]);
|
||||
add('E3', 12, '我在任何关系里都很重视个人空间', [
|
||||
{ value: 1, text: '我更喜欢依赖与被依赖' },
|
||||
{ value: 2, text: '看情况' },
|
||||
{ value: 3, text: '是的!(斩钉截铁地说道)' },
|
||||
]);
|
||||
add('A1', 13, '大多数人是善良的', [
|
||||
{ value: 1, text: '其实邪恶的人心比世界上的痔疮更多。' },
|
||||
{ value: 2, text: '也许吧。' },
|
||||
{ value: 3, text: '是的,我愿相信好人更多。' },
|
||||
]);
|
||||
add('A1', 14, '你走在街上,一位萌萌的小女孩蹦蹦跳跳地朝你走来(正脸、侧脸看都萌,用vivo、苹果、华为、OPPO手机看都萌,实在是非常萌的那种),她递给你一根棒棒糖,此时你作何感想?', [
|
||||
{ value: 1, text: '这也许是一种新型诈骗?还是走开为好。' },
|
||||
{ value: 2, text: '一脸懵逼,作挠头状' },
|
||||
{ value: 3, text: '呜呜她真好真可爱!居然给我棒棒糖!' },
|
||||
]);
|
||||
add('A2', 15, '快考试了,学校规定必须上晚自习,请假会扣分,但今晚你约了女/男神一起玩《绝地求生:刺激战场》(一款刺激的游戏),你怎么办?', [
|
||||
{ value: 1, text: '翘了!反正就一次!' },
|
||||
{ value: 2, text: '干脆请个假吧。' },
|
||||
{ value: 3, text: '都快考试了还去啥。' },
|
||||
]);
|
||||
add('A2', 16, '我喜欢打破常规,不喜欢被束缚', [
|
||||
{ value: 1, text: '认同' },
|
||||
{ value: 2, text: '保持中立' },
|
||||
{ value: 3, text: '不认同' },
|
||||
]);
|
||||
add('A3', 17, '我做事通常有目标。', [
|
||||
{ value: 1, text: '不认同' },
|
||||
{ value: 2, text: '中立' },
|
||||
{ value: 3, text: '认同' },
|
||||
]);
|
||||
add('A3', 18, '突然某一天,我意识到人生哪有什么他妈的狗屁意义,人不过是和动物一样被各种欲望支配着,纯纯是被激素控制的东西,饿了就吃,困了就睡,一发情就想交配,我们简直和猪狗一样没什么区别。', [
|
||||
{ value: 1, text: '是这样的。' },
|
||||
{ value: 2, text: '也许是,也许不是。' },
|
||||
{ value: 3, text: '这简直是胡扯' },
|
||||
]);
|
||||
add('Ac1', 19, '我做事主要为了取得成果和进步,而不是避免麻烦和风险。', [
|
||||
{ value: 1, text: '不认同' },
|
||||
{ value: 2, text: '中立' },
|
||||
{ value: 3, text: '认同' },
|
||||
]);
|
||||
add('Ac1', 20, '你因便秘坐在马桶上(已长达30分钟),拉不出很难受。此时你更像', [
|
||||
{ value: 1, text: '再坐三十分钟看看,说不定就有了。' },
|
||||
{ value: 2, text: '用力拍打自己的屁股并说:“死屁股,快拉啊!”' },
|
||||
{ value: 3, text: '使用开塞露,快点拉出来才好。' },
|
||||
]);
|
||||
add('Ac2', 21, '我做决定比较果断,不喜欢犹豫', [
|
||||
{ value: 1, text: '不认同' },
|
||||
{ value: 2, text: '中立' },
|
||||
{ value: 3, text: '认同' },
|
||||
]);
|
||||
add('Ac2', 22, '此题没有题目,请盲选', [
|
||||
{ value: 1, text: '反复思考后感觉应该选A?' },
|
||||
{ value: 2, text: '啊,要不选B?' },
|
||||
{ value: 3, text: '不会就选C?' },
|
||||
]);
|
||||
add('Ac3', 23, '别人说你“执行力强”,你内心更接近哪句?', [
|
||||
{ value: 1, text: '我被逼到最后确实执行力超强。。。' },
|
||||
{ value: 2, text: '啊,有时候吧。' },
|
||||
{ value: 3, text: '是的,事情本来就该被推进' },
|
||||
]);
|
||||
add('Ac3', 24, '我做事常常有计划,____', [
|
||||
{ value: 1, text: '然而计划不如变化快。' },
|
||||
{ value: 2, text: '有时能完成,有时不能。' },
|
||||
{ value: 3, text: '我讨厌被打破计划。' },
|
||||
]);
|
||||
add('So1', 25, '你因玩《第五人格》(一款刺激的游戏)而结识许多网友,并被邀请线下见面,你的想法是?', [
|
||||
{ value: 1, text: '网上口嗨下就算了,真见面还是有点忐忑。' },
|
||||
{ value: 2, text: '见网友也挺好,反正谁来聊我就聊两句。' },
|
||||
{ value: 3, text: '我会打扮一番并热情聊天,万一呢,我是说万一呢?' },
|
||||
]);
|
||||
add('So1', 26, '朋友带了ta的朋友一起来玩,你最可能的状态是', [
|
||||
{ value: 1, text: '对“朋友的朋友”天然有点距离感,怕影响二人关系' },
|
||||
{ value: 2, text: '看对方,能玩就玩。' },
|
||||
{ value: 3, text: '朋友的朋友应该也算我的朋友!要热情聊天' },
|
||||
]);
|
||||
add('So2', 27, '我和人相处主打一个电子围栏,靠太近会自动报警。', [
|
||||
{ value: 1, text: '不认同' },
|
||||
{ value: 2, text: '中立' },
|
||||
{ value: 3, text: '认同' },
|
||||
]);
|
||||
add('So2', 28, '我渴望和我信任的人关系密切,熟得像失散多年的亲戚。', [
|
||||
{ value: 1, text: '认同' },
|
||||
{ value: 2, text: '中立' },
|
||||
{ value: 3, text: '不认同' },
|
||||
]);
|
||||
add('So3', 29, '有时候你明明对一件事有不同的、负面的看法,但最后没说出来。多数情况下原因是:', [
|
||||
{ value: 1, text: '这种情况较少。' },
|
||||
{ value: 2, text: '可能碍于情面或者关系。' },
|
||||
{ value: 3, text: '不想让别人知道自己是个阴暗的人。' },
|
||||
]);
|
||||
add('So3', 30, '我在不同人面前会表现出不一样的自己', [
|
||||
{ value: 1, text: '不认同' },
|
||||
{ value: 2, text: '中立' },
|
||||
{ value: 3, text: '认同' },
|
||||
]);
|
||||
|
||||
// 闸口 / 条件题(不计入 15 维得分,前端按 dimension 识别)
|
||||
add('DG1', 31, '您平时有什么爱好?', [
|
||||
{ value: 1, text: '吃喝拉撒' },
|
||||
{ value: 2, text: '艺术爱好' },
|
||||
{ value: 3, text: '饮酒' },
|
||||
{ value: 4, text: '健身' },
|
||||
]);
|
||||
add('DG2', 32, '您对饮酒的态度是?', [
|
||||
{ value: 1, text: '小酌怡情,喝不了太多。' },
|
||||
{ value: 2, text: '我习惯将白酒灌在保温杯,当白开水喝,酒精令我信服。' },
|
||||
]);
|
||||
|
||||
function sqlEscape(str) {
|
||||
return String(str).replace(/\\/g, '\\\\').replace(/'/g, "''");
|
||||
}
|
||||
|
||||
const out = [];
|
||||
out.push('-- ============================================');
|
||||
out.push('-- SBTI 题库导入(mbti_questions)');
|
||||
out.push('-- 来源:Silly Big Personality Test 公开页面逻辑');
|
||||
out.push('-- type=sbti;计分题 dimension=S1..So3;闸口 DG1/DG2');
|
||||
out.push('-- 执行前请确认已扩展 API 支持 type=sbti(当前 questions 接口默认仅 mbti/disc/pdp)');
|
||||
out.push('-- ============================================');
|
||||
out.push('');
|
||||
out.push('SET NAMES utf8mb4;');
|
||||
out.push('SET FOREIGN_KEY_CHECKS = 0;');
|
||||
out.push('');
|
||||
out.push('-- 清空超管同名题库(可按需注释)');
|
||||
out.push("DELETE FROM `mbti_questions` WHERE `type` = 'sbti' AND `enterpriseId` IS NULL;");
|
||||
out.push('');
|
||||
|
||||
for (const r of rows) {
|
||||
const optJson = JSON.stringify(r.options);
|
||||
const line =
|
||||
'INSERT INTO `mbti_questions` (`type`, `question`, `options`, `dimension`, `enterpriseId`, `sort`, `status`, `createdAt`, `updatedAt`) VALUES ' +
|
||||
`('sbti', '${sqlEscape(r.question)}', '${sqlEscape(optJson)}', '${sqlEscape(r.dim)}', NULL, ${r.sort}, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());`;
|
||||
out.push(line);
|
||||
}
|
||||
|
||||
out.push('');
|
||||
out.push('SET FOREIGN_KEY_CHECKS = 1;');
|
||||
|
||||
const dest = path.join(__dirname, 'sbti_questions_data.sql');
|
||||
fs.writeFileSync(dest, out.join('\n'), 'utf8');
|
||||
console.log('Written:', dest, 'lines:', rows.length);
|
||||
47
api/database/sbti_questions_data.sql
Normal file
47
api/database/sbti_questions_data.sql
Normal file
@@ -0,0 +1,47 @@
|
||||
-- ============================================
|
||||
-- SBTI 题库导入(mbti_questions)
|
||||
-- 来源:Silly Big Personality Test 公开页面逻辑
|
||||
-- type=sbti;计分题 dimension=S1..So3;闸口 DG1/DG2
|
||||
-- 执行前请确认已扩展 API 支持 type=sbti(当前 questions 接口默认仅 mbti/disc/pdp)
|
||||
-- ============================================
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
|
||||
-- 清空超管同名题库(可按需注释)
|
||||
DELETE FROM `mbti_questions` WHERE `type` = 'sbti' AND `enterpriseId` IS NULL;
|
||||
|
||||
INSERT INTO `mbti_questions` (`type`, `question`, `options`, `dimension`, `enterpriseId`, `sort`, `status`, `createdAt`, `updatedAt`) VALUES ('sbti', '我不仅是屌丝,我还是joker,我还是咸鱼,这辈子没谈过一场恋爱,胆怯又自卑,我的青春就是一场又一场的意淫,每一天幻想着我也能有一个女孩子和我一起压马路,一起逛街,一起玩,现实却是爆了父母金币,读了个烂学校,混日子之后找班上,没有理想,没有目标,没有能力的三无人员,每次看到你能在网上开屌丝的玩笑,我都想哭,我就是地底下的老鼠,透过下水井的缝隙,窥探地上的各种美好,每一次看到这种都是对我心灵的一次伤害,对我生存空间的一次压缩,求求哥们给我们这种小丑一点活路吧,我真的不想在白天把枕巾哭湿一大片', '[{"value":1,"text":"我哭了。。"},{"value":2,"text":"这是什么。。"},{"value":3,"text":"这不是我!"}]', 'S1', NULL, 1, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
INSERT INTO `mbti_questions` (`type`, `question`, `options`, `dimension`, `enterpriseId`, `sort`, `status`, `createdAt`, `updatedAt`) VALUES ('sbti', '我不够好,周围的人都比我优秀', '[{"value":1,"text":"确实"},{"value":2,"text":"有时"},{"value":3,"text":"不是"}]', 'S1', NULL, 2, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
INSERT INTO `mbti_questions` (`type`, `question`, `options`, `dimension`, `enterpriseId`, `sort`, `status`, `createdAt`, `updatedAt`) VALUES ('sbti', '我很清楚真正的自己是什么样的', '[{"value":1,"text":"不认同"},{"value":2,"text":"中立"},{"value":3,"text":"认同"}]', 'S2', NULL, 3, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
INSERT INTO `mbti_questions` (`type`, `question`, `options`, `dimension`, `enterpriseId`, `sort`, `status`, `createdAt`, `updatedAt`) VALUES ('sbti', '我内心有真正追求的东西', '[{"value":1,"text":"不认同"},{"value":2,"text":"中立"},{"value":3,"text":"认同"}]', 'S2', NULL, 4, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
INSERT INTO `mbti_questions` (`type`, `question`, `options`, `dimension`, `enterpriseId`, `sort`, `status`, `createdAt`, `updatedAt`) VALUES ('sbti', '我一定要不断往上爬、变得更厉害', '[{"value":1,"text":"不认同"},{"value":2,"text":"中立"},{"value":3,"text":"认同"}]', 'S3', NULL, 5, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
INSERT INTO `mbti_questions` (`type`, `question`, `options`, `dimension`, `enterpriseId`, `sort`, `status`, `createdAt`, `updatedAt`) VALUES ('sbti', '外人的评价对我来说无所吊谓。', '[{"value":1,"text":"不认同"},{"value":2,"text":"中立"},{"value":3,"text":"认同"}]', 'S3', NULL, 6, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
INSERT INTO `mbti_questions` (`type`, `question`, `options`, `dimension`, `enterpriseId`, `sort`, `status`, `createdAt`, `updatedAt`) VALUES ('sbti', '对象超过5小时没回消息,说自己窜稀了,你会怎么想?', '[{"value":1,"text":"拉稀不可能5小时,也许ta隐瞒了我。"},{"value":2,"text":"在信任和怀疑之间摇摆。"},{"value":3,"text":"也许今天ta真的不太舒服。"}]', 'E1', NULL, 7, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
INSERT INTO `mbti_questions` (`type`, `question`, `options`, `dimension`, `enterpriseId`, `sort`, `status`, `createdAt`, `updatedAt`) VALUES ('sbti', '我在感情里经常担心被对方抛弃', '[{"value":1,"text":"是的"},{"value":2,"text":"偶尔"},{"value":3,"text":"不是"}]', 'E1', NULL, 8, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
INSERT INTO `mbti_questions` (`type`, `question`, `options`, `dimension`, `enterpriseId`, `sort`, `status`, `createdAt`, `updatedAt`) VALUES ('sbti', '我对天发誓,我对待每一份感情都是认真的!', '[{"value":1,"text":"并没有"},{"value":2,"text":"也许?"},{"value":3,"text":"是的!(问心无愧骄傲脸)"}]', 'E2', NULL, 9, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
INSERT INTO `mbti_questions` (`type`, `question`, `options`, `dimension`, `enterpriseId`, `sort`, `status`, `createdAt`, `updatedAt`) VALUES ('sbti', '你的恋爱对象是一个尊老爱幼,温柔敦厚,洁身自好,光明磊落,大义凛然,能言善辩,口才流利,观察入微,见多识广,博学多才,诲人不倦,和蔼可亲,平易近人,心地善良,慈眉善目,积极进取,意气风发,玉树临风,国色天香,倾国倾城,花容月貌的人,此时你会?', '[{"value":1,"text":"就算ta再优秀我也不会陷入太深。"},{"value":2,"text":"会介于A和C之间。"},{"value":3,"text":"会非常珍惜ta,也许会变成恋爱脑。"}]', 'E2', NULL, 10, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
INSERT INTO `mbti_questions` (`type`, `question`, `options`, `dimension`, `enterpriseId`, `sort`, `status`, `createdAt`, `updatedAt`) VALUES ('sbti', '恋爱后,对象非常黏人,你作何感想?', '[{"value":1,"text":"那很爽了"},{"value":2,"text":"都行无所谓"},{"value":3,"text":"我更喜欢保留独立空间"}]', 'E3', NULL, 11, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
INSERT INTO `mbti_questions` (`type`, `question`, `options`, `dimension`, `enterpriseId`, `sort`, `status`, `createdAt`, `updatedAt`) VALUES ('sbti', '我在任何关系里都很重视个人空间', '[{"value":1,"text":"我更喜欢依赖与被依赖"},{"value":2,"text":"看情况"},{"value":3,"text":"是的!(斩钉截铁地说道)"}]', 'E3', NULL, 12, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
INSERT INTO `mbti_questions` (`type`, `question`, `options`, `dimension`, `enterpriseId`, `sort`, `status`, `createdAt`, `updatedAt`) VALUES ('sbti', '大多数人是善良的', '[{"value":1,"text":"其实邪恶的人心比世界上的痔疮更多。"},{"value":2,"text":"也许吧。"},{"value":3,"text":"是的,我愿相信好人更多。"}]', 'A1', NULL, 13, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
INSERT INTO `mbti_questions` (`type`, `question`, `options`, `dimension`, `enterpriseId`, `sort`, `status`, `createdAt`, `updatedAt`) VALUES ('sbti', '你走在街上,一位萌萌的小女孩蹦蹦跳跳地朝你走来(正脸、侧脸看都萌,用vivo、苹果、华为、OPPO手机看都萌,实在是非常萌的那种),她递给你一根棒棒糖,此时你作何感想?', '[{"value":1,"text":"这也许是一种新型诈骗?还是走开为好。"},{"value":2,"text":"一脸懵逼,作挠头状"},{"value":3,"text":"呜呜她真好真可爱!居然给我棒棒糖!"}]', 'A1', NULL, 14, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
INSERT INTO `mbti_questions` (`type`, `question`, `options`, `dimension`, `enterpriseId`, `sort`, `status`, `createdAt`, `updatedAt`) VALUES ('sbti', '快考试了,学校规定必须上晚自习,请假会扣分,但今晚你约了女/男神一起玩《绝地求生:刺激战场》(一款刺激的游戏),你怎么办?', '[{"value":1,"text":"翘了!反正就一次!"},{"value":2,"text":"干脆请个假吧。"},{"value":3,"text":"都快考试了还去啥。"}]', 'A2', NULL, 15, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
INSERT INTO `mbti_questions` (`type`, `question`, `options`, `dimension`, `enterpriseId`, `sort`, `status`, `createdAt`, `updatedAt`) VALUES ('sbti', '我喜欢打破常规,不喜欢被束缚', '[{"value":1,"text":"认同"},{"value":2,"text":"保持中立"},{"value":3,"text":"不认同"}]', 'A2', NULL, 16, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
INSERT INTO `mbti_questions` (`type`, `question`, `options`, `dimension`, `enterpriseId`, `sort`, `status`, `createdAt`, `updatedAt`) VALUES ('sbti', '我做事通常有目标。', '[{"value":1,"text":"不认同"},{"value":2,"text":"中立"},{"value":3,"text":"认同"}]', 'A3', NULL, 17, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
INSERT INTO `mbti_questions` (`type`, `question`, `options`, `dimension`, `enterpriseId`, `sort`, `status`, `createdAt`, `updatedAt`) VALUES ('sbti', '突然某一天,我意识到人生哪有什么他妈的狗屁意义,人不过是和动物一样被各种欲望支配着,纯纯是被激素控制的东西,饿了就吃,困了就睡,一发情就想交配,我们简直和猪狗一样没什么区别。', '[{"value":1,"text":"是这样的。"},{"value":2,"text":"也许是,也许不是。"},{"value":3,"text":"这简直是胡扯"}]', 'A3', NULL, 18, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
INSERT INTO `mbti_questions` (`type`, `question`, `options`, `dimension`, `enterpriseId`, `sort`, `status`, `createdAt`, `updatedAt`) VALUES ('sbti', '我做事主要为了取得成果和进步,而不是避免麻烦和风险。', '[{"value":1,"text":"不认同"},{"value":2,"text":"中立"},{"value":3,"text":"认同"}]', 'Ac1', NULL, 19, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
INSERT INTO `mbti_questions` (`type`, `question`, `options`, `dimension`, `enterpriseId`, `sort`, `status`, `createdAt`, `updatedAt`) VALUES ('sbti', '你因便秘坐在马桶上(已长达30分钟),拉不出很难受。此时你更像', '[{"value":1,"text":"再坐三十分钟看看,说不定就有了。"},{"value":2,"text":"用力拍打自己的屁股并说:“死屁股,快拉啊!”"},{"value":3,"text":"使用开塞露,快点拉出来才好。"}]', 'Ac1', NULL, 20, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
INSERT INTO `mbti_questions` (`type`, `question`, `options`, `dimension`, `enterpriseId`, `sort`, `status`, `createdAt`, `updatedAt`) VALUES ('sbti', '我做决定比较果断,不喜欢犹豫', '[{"value":1,"text":"不认同"},{"value":2,"text":"中立"},{"value":3,"text":"认同"}]', 'Ac2', NULL, 21, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
INSERT INTO `mbti_questions` (`type`, `question`, `options`, `dimension`, `enterpriseId`, `sort`, `status`, `createdAt`, `updatedAt`) VALUES ('sbti', '此题没有题目,请盲选', '[{"value":1,"text":"反复思考后感觉应该选A?"},{"value":2,"text":"啊,要不选B?"},{"value":3,"text":"不会就选C?"}]', 'Ac2', NULL, 22, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
INSERT INTO `mbti_questions` (`type`, `question`, `options`, `dimension`, `enterpriseId`, `sort`, `status`, `createdAt`, `updatedAt`) VALUES ('sbti', '别人说你“执行力强”,你内心更接近哪句?', '[{"value":1,"text":"我被逼到最后确实执行力超强。。。"},{"value":2,"text":"啊,有时候吧。"},{"value":3,"text":"是的,事情本来就该被推进"}]', 'Ac3', NULL, 23, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
INSERT INTO `mbti_questions` (`type`, `question`, `options`, `dimension`, `enterpriseId`, `sort`, `status`, `createdAt`, `updatedAt`) VALUES ('sbti', '我做事常常有计划,____', '[{"value":1,"text":"然而计划不如变化快。"},{"value":2,"text":"有时能完成,有时不能。"},{"value":3,"text":"我讨厌被打破计划。"}]', 'Ac3', NULL, 24, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
INSERT INTO `mbti_questions` (`type`, `question`, `options`, `dimension`, `enterpriseId`, `sort`, `status`, `createdAt`, `updatedAt`) VALUES ('sbti', '你因玩《第五人格》(一款刺激的游戏)而结识许多网友,并被邀请线下见面,你的想法是?', '[{"value":1,"text":"网上口嗨下就算了,真见面还是有点忐忑。"},{"value":2,"text":"见网友也挺好,反正谁来聊我就聊两句。"},{"value":3,"text":"我会打扮一番并热情聊天,万一呢,我是说万一呢?"}]', 'So1', NULL, 25, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
INSERT INTO `mbti_questions` (`type`, `question`, `options`, `dimension`, `enterpriseId`, `sort`, `status`, `createdAt`, `updatedAt`) VALUES ('sbti', '朋友带了ta的朋友一起来玩,你最可能的状态是', '[{"value":1,"text":"对“朋友的朋友”天然有点距离感,怕影响二人关系"},{"value":2,"text":"看对方,能玩就玩。"},{"value":3,"text":"朋友的朋友应该也算我的朋友!要热情聊天"}]', 'So1', NULL, 26, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
INSERT INTO `mbti_questions` (`type`, `question`, `options`, `dimension`, `enterpriseId`, `sort`, `status`, `createdAt`, `updatedAt`) VALUES ('sbti', '我和人相处主打一个电子围栏,靠太近会自动报警。', '[{"value":1,"text":"不认同"},{"value":2,"text":"中立"},{"value":3,"text":"认同"}]', 'So2', NULL, 27, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
INSERT INTO `mbti_questions` (`type`, `question`, `options`, `dimension`, `enterpriseId`, `sort`, `status`, `createdAt`, `updatedAt`) VALUES ('sbti', '我渴望和我信任的人关系密切,熟得像失散多年的亲戚。', '[{"value":1,"text":"认同"},{"value":2,"text":"中立"},{"value":3,"text":"不认同"}]', 'So2', NULL, 28, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
INSERT INTO `mbti_questions` (`type`, `question`, `options`, `dimension`, `enterpriseId`, `sort`, `status`, `createdAt`, `updatedAt`) VALUES ('sbti', '有时候你明明对一件事有不同的、负面的看法,但最后没说出来。多数情况下原因是:', '[{"value":1,"text":"这种情况较少。"},{"value":2,"text":"可能碍于情面或者关系。"},{"value":3,"text":"不想让别人知道自己是个阴暗的人。"}]', 'So3', NULL, 29, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
INSERT INTO `mbti_questions` (`type`, `question`, `options`, `dimension`, `enterpriseId`, `sort`, `status`, `createdAt`, `updatedAt`) VALUES ('sbti', '我在不同人面前会表现出不一样的自己', '[{"value":1,"text":"不认同"},{"value":2,"text":"中立"},{"value":3,"text":"认同"}]', 'So3', NULL, 30, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
INSERT INTO `mbti_questions` (`type`, `question`, `options`, `dimension`, `enterpriseId`, `sort`, `status`, `createdAt`, `updatedAt`) VALUES ('sbti', '您平时有什么爱好?', '[{"value":1,"text":"吃喝拉撒"},{"value":2,"text":"艺术爱好"},{"value":3,"text":"饮酒"},{"value":4,"text":"健身"}]', 'DG1', NULL, 31, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
INSERT INTO `mbti_questions` (`type`, `question`, `options`, `dimension`, `enterpriseId`, `sort`, `status`, `createdAt`, `updatedAt`) VALUES ('sbti', '您对饮酒的态度是?', '[{"value":1,"text":"小酌怡情,喝不了太多。"},{"value":2,"text":"我习惯将白酒灌在保温杯,当白开水喝,酒精令我信服。"}]', 'DG2', NULL, 32, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
@@ -57,6 +57,7 @@ App({
|
||||
unlockedTests: [],
|
||||
// 测试结果缓存
|
||||
mbtiResult: null,
|
||||
sbtiResult: null,
|
||||
discResult: null,
|
||||
pdpResult: null,
|
||||
aiResult: null,
|
||||
@@ -204,6 +205,7 @@ App({
|
||||
|
||||
// 测试结果
|
||||
this.globalData.mbtiResult = wx.getStorageSync('mbtiResult') || null
|
||||
this.globalData.sbtiResult = wx.getStorageSync('sbtiResult') || null
|
||||
this.globalData.discResult = wx.getStorageSync('discResult') || null
|
||||
this.globalData.pdpResult = wx.getStorageSync('pdpResult') || null
|
||||
this.globalData.aiResult = wx.getStorageSync('aiResult') || null
|
||||
|
||||
@@ -6,9 +6,11 @@
|
||||
"pages/index/result",
|
||||
"pages/test-select/index",
|
||||
"pages/test/mbti",
|
||||
"pages/test/sbti",
|
||||
"pages/test/disc",
|
||||
"pages/test/pdp",
|
||||
"pages/result/mbti",
|
||||
"pages/result/sbti",
|
||||
"pages/result/disc",
|
||||
"pages/result/pdp",
|
||||
"pages/result/resume",
|
||||
|
||||
@@ -15,6 +15,7 @@ Page({
|
||||
isEnterprise: false,
|
||||
reviewMode: false,
|
||||
permMbti: true,
|
||||
permSbti: true,
|
||||
permPdp: true,
|
||||
permDisc: true,
|
||||
permFace: true
|
||||
@@ -31,18 +32,20 @@ Page({
|
||||
_syncPermsAndTab() {
|
||||
const p = app.globalData.enterprisePermissions
|
||||
const permMbti = !p || p.mbti !== false
|
||||
const permSbti = !p || p.sbti !== false
|
||||
const permPdp = !p || p.pdp !== false
|
||||
const permDisc = !p || p.disc !== false
|
||||
const permFace = !p || p.face !== false
|
||||
const reviewMode = !!app.globalData.reviewMode
|
||||
const names = { all: '', mbti: 'MBTI', pdp: 'PDP', disc: 'DISC', ai: '面相', resume: '简历' }
|
||||
const names = { all: '', mbti: 'MBTI', sbti: 'SBTI', pdp: 'PDP', disc: 'DISC', ai: '面相', resume: '简历' }
|
||||
let { activeTab } = this.data
|
||||
if (activeTab === 'mbti' && !permMbti) activeTab = 'all'
|
||||
if (activeTab === 'sbti' && !permSbti) activeTab = 'all'
|
||||
if (activeTab === 'pdp' && !permPdp) activeTab = 'all'
|
||||
if (activeTab === 'disc' && !permDisc) activeTab = 'all'
|
||||
if (activeTab === 'ai' && (!permFace || reviewMode)) activeTab = 'all'
|
||||
this.setData({
|
||||
permMbti, permPdp, permDisc, permFace, reviewMode,
|
||||
permMbti, permSbti, permPdp, permDisc, permFace, reviewMode,
|
||||
activeTab,
|
||||
tabName: names[activeTab] || ''
|
||||
})
|
||||
@@ -138,8 +141,8 @@ Page({
|
||||
},
|
||||
|
||||
formatList(rawList) {
|
||||
const typeNames = { mbti: 'MBTI性格测试', disc: 'DISC性格测试', pdp: 'PDP行为偏好测试', ai: '面相分析', resume: '简历综合分析' }
|
||||
const emojis = { mbti: '🧠', disc: '📊', pdp: '🦁', ai: '👁️', resume: '📋' }
|
||||
const typeNames = { mbti: 'MBTI性格测试', sbti: 'SBTI性格测试', disc: 'DISC性格测试', pdp: 'PDP行为偏好测试', ai: '面相分析', resume: '简历综合分析' }
|
||||
const emojis = { mbti: '🧠', sbti: '🎭', disc: '📊', pdp: '🦁', ai: '👁️', resume: '📋' }
|
||||
|
||||
return rawList.map((item, idx) => {
|
||||
if (item.typeName) {
|
||||
@@ -162,13 +165,27 @@ Page({
|
||||
|
||||
// 本地缓存回退
|
||||
loadFromStorage() {
|
||||
const { permMbti, permPdp, permDisc, permFace, reviewMode } = this.data
|
||||
const { permMbti, permSbti, permPdp, permDisc, permFace, reviewMode } = this.data
|
||||
const mbtiResult = wx.getStorageSync('mbtiResult')
|
||||
const sbtiResult = wx.getStorageSync('sbtiResult')
|
||||
const discResult = wx.getStorageSync('discResult')
|
||||
const pdpResult = wx.getStorageSync('pdpResult')
|
||||
const aiResult = wx.getStorageSync('aiResult')
|
||||
const list = []
|
||||
if (mbtiResult && permMbti) list.push({ type: 'mbti', key: 'mbti', emoji: '🧠', typeName: 'MBTI性格测试', resultText: mbtiResult.mbtiType || '未知', testTime: this.formatTime(mbtiResult.timestamp), data: mbtiResult })
|
||||
if (sbtiResult && permSbti) {
|
||||
const rt = sbtiResult.sbtiType || (sbtiResult.finalType && sbtiResult.finalType.code) || '未知'
|
||||
const cn = sbtiResult.sbtiCn || (sbtiResult.finalType && sbtiResult.finalType.cn) || ''
|
||||
list.push({
|
||||
type: 'sbti',
|
||||
key: 'sbti',
|
||||
emoji: '🎭',
|
||||
typeName: 'SBTI性格测试',
|
||||
resultText: cn ? `${rt}(${cn})` : rt,
|
||||
testTime: this.formatTime(sbtiResult.timestamp || sbtiResult.completedAt),
|
||||
data: sbtiResult
|
||||
})
|
||||
}
|
||||
if (pdpResult && permPdp) list.push({ type: 'pdp', key: 'pdp', emoji: pdpResult.description?.emoji || '🦁', typeName: 'PDP行为偏好测试', resultText: pdpResult.description?.type || '未知', testTime: this.formatTime(pdpResult.timestamp || pdpResult.completedAt), data: pdpResult })
|
||||
if (discResult && permDisc) list.push({ type: 'disc', key: 'disc', emoji: '📊', typeName: 'DISC性格测试', resultText: (discResult.dominantType || '未知') + '型', testTime: this.formatTime(discResult.timestamp || discResult.completedAt), data: discResult })
|
||||
if (aiResult && permFace && !reviewMode) list.push({ type: 'ai', key: 'ai', emoji: '👁️', typeName: '面相分析', resultText: aiResult.mbti || '未知', testTime: this.formatTime(aiResult.timestamp || aiResult.completedAt), data: aiResult })
|
||||
@@ -177,7 +194,7 @@ Page({
|
||||
|
||||
changeTab(e) {
|
||||
const tab = e.currentTarget.dataset.tab
|
||||
const names = { all: '', mbti: 'MBTI', pdp: 'PDP', disc: 'DISC', ai: '面相', resume: '简历' }
|
||||
const names = { all: '', mbti: 'MBTI', sbti: 'SBTI', pdp: 'PDP', disc: 'DISC', ai: '面相', resume: '简历' }
|
||||
this.setData({ activeTab: tab, tabName: names[tab] || '' })
|
||||
this.loadAll()
|
||||
},
|
||||
@@ -198,6 +215,7 @@ Page({
|
||||
const id = e.currentTarget.dataset.id
|
||||
const routes = {
|
||||
mbti: '/pages/result/mbti',
|
||||
sbti: '/pages/result/sbti',
|
||||
disc: '/pages/result/disc',
|
||||
pdp: '/pages/result/pdp',
|
||||
ai: '/pages/index/result',
|
||||
@@ -213,6 +231,7 @@ Page({
|
||||
goToTest() {
|
||||
const routes = {
|
||||
mbti: '/pages/test/mbti',
|
||||
sbti: '/pages/test/sbti',
|
||||
disc: '/pages/test/disc',
|
||||
pdp: '/pages/test/pdp',
|
||||
ai: '/pages/index/camera',
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
<view wx:if="{{permMbti}}" class="tab-item {{activeTab === 'mbti' ? 'active' : ''}}" bindtap="changeTab" data-tab="mbti">
|
||||
<text class="tab-text">MBTI</text>
|
||||
</view>
|
||||
<view wx:if="{{permSbti}}" class="tab-item {{activeTab === 'sbti' ? 'active' : ''}}" bindtap="changeTab" data-tab="sbti">
|
||||
<text class="tab-text">SBTI</text>
|
||||
</view>
|
||||
<view wx:if="{{permPdp}}" class="tab-item {{activeTab === 'pdp' ? 'active' : ''}}" bindtap="changeTab" data-tab="pdp">
|
||||
<text class="tab-text">PDP</text>
|
||||
</view>
|
||||
|
||||
@@ -112,6 +112,7 @@
|
||||
}
|
||||
|
||||
.icon-mbti { background-color: #f3e8ff; }
|
||||
.icon-sbti { background-color: #f2f7f3; border: 1rpx solid #dce8e0; }
|
||||
.icon-pdp { background-color: #dbeafe; }
|
||||
.icon-disc { background-color: #dcfce7; }
|
||||
.icon-ai { background-color: #fce7f3; }
|
||||
@@ -152,6 +153,9 @@
|
||||
.badge-mbti { background-color: #f3e8ff; }
|
||||
.badge-mbti .badge-text { color: #7c3aed; }
|
||||
|
||||
.badge-sbti { background-color: #f2f7f3; }
|
||||
.badge-sbti .badge-text { color: #5a7268; }
|
||||
|
||||
.badge-pdp { background-color: #dbeafe; }
|
||||
.badge-pdp .badge-text { color: #2563eb; }
|
||||
|
||||
|
||||
@@ -22,18 +22,21 @@ Page({
|
||||
testCount: 0,
|
||||
hasResults: false,
|
||||
mbtiType: '',
|
||||
sbtiType: '',
|
||||
discType: '',
|
||||
pdpType: '',
|
||||
aiType: '',
|
||||
/** 面相记录中的盖洛普前三摘要(/api/test/recent 或本地 aiResult) */
|
||||
gallupPreview: '',
|
||||
mbtiTime: '',
|
||||
sbtiTime: '',
|
||||
discTime: '',
|
||||
pdpTime: '',
|
||||
aiTime: '',
|
||||
reviewMode: false,
|
||||
/** 最近记录的数据库 ID,用于跳转时传参 */
|
||||
mbtiResultId: null,
|
||||
sbtiResultId: null,
|
||||
discResultId: null,
|
||||
pdpResultId: null,
|
||||
aiResultId: null,
|
||||
@@ -55,6 +58,7 @@ Page({
|
||||
// 企业功能权限:默认全开(个人版 / 未配置时)
|
||||
permFace: true,
|
||||
permMbti: true,
|
||||
permSbti: true,
|
||||
permPdp: true,
|
||||
permDisc: true,
|
||||
permDistribution: true,
|
||||
@@ -67,13 +71,18 @@ Page({
|
||||
_computeShowLatestTestRow(d) {
|
||||
const rm = !!(d.reviewMode)
|
||||
if (rm) {
|
||||
return !!(d.permMbti || d.permPdp || d.permDisc)
|
||||
return !!(d.permMbti || d.permSbti || d.permPdp || d.permDisc)
|
||||
}
|
||||
return !!(d.permMbti || d.permPdp || d.permDisc || d.permFace)
|
||||
return !!(d.permMbti || d.permSbti || d.permPdp || d.permDisc || d.permFace)
|
||||
},
|
||||
|
||||
_computeShowEmptyPersonalityTags(d) {
|
||||
return !((d.mbtiType && d.permMbti) || (d.discType && d.permDisc) || (d.pdpType && d.permPdp))
|
||||
return !(
|
||||
(d.mbtiType && d.permMbti) ||
|
||||
(d.sbtiType && d.permSbti) ||
|
||||
(d.discType && d.permDisc) ||
|
||||
(d.pdpType && d.permPdp)
|
||||
)
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
@@ -85,6 +94,7 @@ Page({
|
||||
const next = {
|
||||
permFace: !p || p.face !== false,
|
||||
permMbti: !p || p.mbti !== false,
|
||||
permSbti: !p || p.sbti !== false,
|
||||
permPdp: !p || p.pdp !== false,
|
||||
permDisc: !p || p.disc !== false,
|
||||
permDistribution: !p || p.distribution !== false,
|
||||
@@ -214,21 +224,25 @@ Page({
|
||||
const r = records
|
||||
|
||||
const discType = summaryFromRecentRecord(r.disc, 'disc')
|
||||
const sbtiType = summaryFromRecentRecord(r.sbti, 'sbti')
|
||||
|
||||
const gallupPreview = (r.ai && r.ai.gallupPreview) ? String(r.ai.gallupPreview) : ''
|
||||
const patch = {
|
||||
testCount: totalCount,
|
||||
hasResults: !!(r.mbti || r.disc || r.pdp || r.ai),
|
||||
hasResults: !!(r.mbti || r.sbti || r.disc || r.pdp || r.ai),
|
||||
mbtiType: r.mbti ? r.mbti.resultText : '',
|
||||
sbtiType,
|
||||
discType,
|
||||
pdpType: summaryFromRecentRecord(r.pdp, 'pdp'),
|
||||
aiType: r.ai ? r.ai.resultText : '',
|
||||
gallupPreview,
|
||||
mbtiTime: r.mbti ? r.mbti.testTime : '',
|
||||
sbtiTime: r.sbti ? r.sbti.testTime : '',
|
||||
discTime: r.disc ? r.disc.testTime : '',
|
||||
pdpTime: r.pdp ? r.pdp.testTime : '',
|
||||
aiTime: r.ai ? r.ai.testTime : '',
|
||||
mbtiResultId: r.mbti ? r.mbti.id : null,
|
||||
sbtiResultId: r.sbti ? r.sbti.id : null,
|
||||
discResultId: r.disc ? r.disc.id : null,
|
||||
pdpResultId: r.pdp ? r.pdp.id : null,
|
||||
aiResultId: r.ai ? r.ai.id : null,
|
||||
@@ -270,6 +284,7 @@ Page({
|
||||
/** 降级:从 localStorage 读最近记录(兼容离线或 API 失败) */
|
||||
_loadRecentFromStorage() {
|
||||
const mbtiResult = wx.getStorageSync('mbtiResult')
|
||||
const sbtiResult = wx.getStorageSync('sbtiResult')
|
||||
const discResult = wx.getStorageSync('discResult')
|
||||
const pdpResult = wx.getStorageSync('pdpResult')
|
||||
const aiResult = wx.getStorageSync('aiResult')
|
||||
@@ -281,6 +296,7 @@ Page({
|
||||
|
||||
let testCount = 0
|
||||
if (mbtiResult) testCount++
|
||||
if (sbtiResult) testCount++
|
||||
if (discResult) testCount++
|
||||
if (pdpResult) testCount++
|
||||
if (aiResult) testCount++
|
||||
@@ -296,15 +312,18 @@ Page({
|
||||
testCount,
|
||||
hasResults: testCount > 0,
|
||||
mbtiType: mbtiResult ? getTypeOnly(mbtiResult, 'mbti') : '',
|
||||
sbtiType: sbtiResult ? getTypeOnly(sbtiResult, 'sbti') : '',
|
||||
discType: discResult ? getTypeOnly(discResult, 'disc') : '',
|
||||
pdpType: pdpResult ? getTypeOnly(pdpResult, 'pdp') : '',
|
||||
aiType: aiResult ? (aiResult.mbti || aiResult.mbtiType || aiResult.type || '') : '',
|
||||
gallupPreview,
|
||||
mbtiTime: _fmt(mbtiResult && (mbtiResult.createdAt || mbtiResult.timestamp || mbtiResult.testTime)),
|
||||
sbtiTime: _fmt(sbtiResult && (sbtiResult.createdAt || sbtiResult.timestamp || sbtiResult.completedAt || sbtiResult.testTime)),
|
||||
discTime: _fmt(discResult && (discResult.createdAt || discResult.timestamp || discResult.testTime)),
|
||||
pdpTime: _fmt(pdpResult && (pdpResult.createdAt || pdpResult.timestamp || pdpResult.testTime)),
|
||||
aiTime: _fmt(aiResult && (aiResult.createdAt || aiResult.timestamp || aiResult.testTime)),
|
||||
mbtiResultId: null,
|
||||
sbtiResultId: null,
|
||||
discResultId: null,
|
||||
pdpResultId: null,
|
||||
aiResultId: null,
|
||||
@@ -370,6 +389,11 @@ Page({
|
||||
if (id) wx.navigateTo({ url: `/pages/result/mbti?id=${id}&type=mbti` })
|
||||
else wx.navigateTo({ url: '/pages/test/mbti' })
|
||||
},
|
||||
viewSBTI() {
|
||||
const id = this.data.sbtiResultId
|
||||
if (id) wx.navigateTo({ url: `/pages/result/sbti?id=${id}&type=sbti` })
|
||||
else wx.navigateTo({ url: '/pages/test/sbti' })
|
||||
},
|
||||
viewDISC() {
|
||||
const id = this.data.discResultId
|
||||
if (id) wx.navigateTo({ url: `/pages/result/disc?id=${id}&type=disc` })
|
||||
|
||||
@@ -52,6 +52,9 @@
|
||||
<view class="tag tag-purple" wx:if="{{mbtiType && permMbti}}">
|
||||
<text class="tag-text">{{mbtiType}}</text>
|
||||
</view>
|
||||
<view class="tag tag-sbti" wx:if="{{sbtiType && permSbti}}">
|
||||
<text class="tag-text">{{sbtiType}}</text>
|
||||
</view>
|
||||
<view class="tag tag-blue" wx:if="{{discType && permDisc}}">
|
||||
<text class="tag-text">{{discType}}</text>
|
||||
</view>
|
||||
@@ -90,6 +93,15 @@
|
||||
<text class="card-value">{{mbtiType || '未测评'}}</text>
|
||||
<text class="card-time">{{mbtiTime || '—'}}</text>
|
||||
</view>
|
||||
<view class="result-card card-sbti {{sbtiType ? '' : 'result-card--placeholder'}}" bindtap="viewSBTI" wx:if="{{permSbti}}">
|
||||
<view class="card-deco"></view>
|
||||
<view class="card-icon-wrap card-icon-sbti">
|
||||
<text class="card-icon">🎭</text>
|
||||
</view>
|
||||
<text class="card-label">SBTI</text>
|
||||
<text class="card-value">{{sbtiType || '未测评'}}</text>
|
||||
<text class="card-time">{{sbtiTime || '—'}}</text>
|
||||
</view>
|
||||
<view class="result-card card-orange {{pdpType ? '' : 'result-card--placeholder'}}" bindtap="viewPDP" wx:if="{{permPdp}}">
|
||||
<view class="card-deco"></view>
|
||||
<view class="card-icon-wrap card-icon-orange">
|
||||
@@ -133,7 +145,7 @@
|
||||
</view>
|
||||
<view class="depth-inner-divider"></view>
|
||||
<view class="menu-item menu-item--flat" bindtap="goToTestSelect">
|
||||
<view class="menu-icon-wrap menu-icon-amber">
|
||||
<view class="menu-icon-wrap menu-icon-test-select">
|
||||
<text class="menu-icon">🧠</text>
|
||||
</view>
|
||||
<view class="menu-content">
|
||||
|
||||
@@ -228,6 +228,15 @@ custom-tab-bar {
|
||||
color: #D97706;
|
||||
}
|
||||
|
||||
/* SBTI 主题 #f2f7f3 + 强调 #5a7268 */
|
||||
.tag-sbti {
|
||||
background: #f2f7f3;
|
||||
border: 1rpx solid #dce8e0;
|
||||
}
|
||||
.tag-sbti .tag-text {
|
||||
color: #5a7268;
|
||||
}
|
||||
|
||||
.tag-gray {
|
||||
background: #F3F4F6;
|
||||
}
|
||||
@@ -431,6 +440,7 @@ custom-tab-bar {
|
||||
}
|
||||
|
||||
.card-purple .card-deco { background: #DDD6FE; }
|
||||
.card-sbti .card-deco { background: #c5d4cc; }
|
||||
.card-blue .card-deco { background: #BFDBFE; }
|
||||
.card-orange .card-deco { background: #FDE68A; }
|
||||
.card-rose .card-deco { background: #FECDD3; }
|
||||
@@ -446,6 +456,7 @@ custom-tab-bar {
|
||||
}
|
||||
|
||||
.card-icon-purple { background: #EDE9FE; }
|
||||
.card-icon-sbti { background: #f2f7f3; border: 1rpx solid #dce8e0; }
|
||||
.card-icon-blue { background: #DBEAFE; }
|
||||
.card-icon-orange { background: #FEF3C7; }
|
||||
.card-icon-rose { background: #FFE4E6; }
|
||||
@@ -477,6 +488,7 @@ custom-tab-bar {
|
||||
}
|
||||
|
||||
.card-purple .card-value { color: #7C3AED; }
|
||||
.card-sbti .card-value { color: #5a7268; }
|
||||
.card-blue .card-value { color: #2563EB; }
|
||||
.card-orange .card-value { color: #D97706; }
|
||||
.card-rose .card-value { color: #E11D48; }
|
||||
@@ -612,7 +624,7 @@ custom-tab-bar {
|
||||
|
||||
.menu-icon-red { background: #FEF2F2; }
|
||||
.menu-icon-indigo { background: #EEF2FF; }
|
||||
.menu-icon-amber { background: #FFFBEB; }
|
||||
.menu-icon-test-select { background: #eef2ff; }
|
||||
.menu-icon-emerald { background: #ECFDF5; }
|
||||
.menu-icon-purple { background: #EDE9FE; }
|
||||
|
||||
|
||||
263
miniprogram/pages/result/sbti.js
Normal file
263
miniprogram/pages/result/sbti.js
Normal file
@@ -0,0 +1,263 @@
|
||||
// pages/result/sbti.js — SBTI 结果页
|
||||
const app = getApp()
|
||||
const payment = require('../../utils/payment')
|
||||
const { hasPhone, bindPhoneByCode, isReportProfileComplete } = require('../../utils/phoneAuth.js')
|
||||
const { TYPE_IMAGES } = require('../../utils/sbtiEngine.js')
|
||||
|
||||
/** 根据结果类型代码取展示图(与 sbtiData.TYPE_IMAGES 一致) */
|
||||
/** 旧版结果仅有 badge / bestNormal,补全 matchPercent、hitDimCount */
|
||||
function normalizeSbtiResultForDisplay(result) {
|
||||
if (!result || typeof result !== 'object') return result
|
||||
const out = { ...result }
|
||||
const bn = out.bestNormal
|
||||
if (out.matchPercent == null && bn != null && typeof bn.similarity === 'number') {
|
||||
out.matchPercent = bn.similarity
|
||||
}
|
||||
if (out.hitDimCount == null && bn != null && typeof bn.exact === 'number') {
|
||||
out.hitDimCount = bn.exact
|
||||
}
|
||||
if (out.special && out.sbtiType === 'DRUNK') {
|
||||
if (out.matchPercent == null) out.matchPercent = 100
|
||||
if (out.hitDimCount == null) out.hitDimCount = 15
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function resolveSbtiTypeImageUrl(result) {
|
||||
if (!result || typeof result !== 'object') return ''
|
||||
const code = result.sbtiType || (result.finalType && result.finalType.code) || ''
|
||||
if (!code || !TYPE_IMAGES) return ''
|
||||
const url = TYPE_IMAGES[code]
|
||||
return typeof url === 'string' ? url : ''
|
||||
}
|
||||
|
||||
function toProfileLockedSbti(full) {
|
||||
if (!full) return full
|
||||
const code = full.sbtiType || full.finalType?.code || ''
|
||||
return { sbtiType: code, sbtiCn: full.sbtiCn || full.finalType?.cn || '', locked: true }
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
result: null,
|
||||
typeImageUrl: '',
|
||||
/** 为 true 时展示顶部文字备用(无图地址或图片加载失败) */
|
||||
typeImageLoadFailed: false,
|
||||
/** 类型图是否已成功加载(有 URL 且 bindload 触发后为 true,用于隐藏与图重复的顶部三行字) */
|
||||
typeImageLoaded: false,
|
||||
dimExplainList: [],
|
||||
payInfo: {
|
||||
requiresPayment: false,
|
||||
isPaid: false,
|
||||
amountYuan: 0
|
||||
},
|
||||
testResultId: null,
|
||||
hasReloadedAfterPay: false,
|
||||
hasPhone: false
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
const id = options && options.id
|
||||
const type = options && options.type
|
||||
|
||||
if (id && type === 'sbti') {
|
||||
this.setData({ testResultId: id })
|
||||
this.loadDetail(id)
|
||||
return
|
||||
}
|
||||
|
||||
const raw = wx.getStorageSync('sbtiResult')
|
||||
if (raw) {
|
||||
const result = isReportProfileComplete() ? raw : toProfileLockedSbti(raw)
|
||||
this.applyResult(result)
|
||||
this.initPayInfoFromRuntime('sbti')
|
||||
} else {
|
||||
wx.showToast({ title: '暂无测试结果', icon: 'none' })
|
||||
setTimeout(() => wx.navigateBack(), 1500)
|
||||
}
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.setData({ hasPhone: hasPhone() })
|
||||
if (this.data.testResultId) return
|
||||
const raw = wx.getStorageSync('sbtiResult')
|
||||
if (raw) {
|
||||
const result = isReportProfileComplete() ? raw : toProfileLockedSbti(raw)
|
||||
this.applyResult(result)
|
||||
}
|
||||
},
|
||||
|
||||
goCompleteProfile() {
|
||||
wx.navigateTo({ url: '/pages/user-profile/index' })
|
||||
},
|
||||
|
||||
loadDetail(id) {
|
||||
const apiBase = app.globalData?.apiBase || ''
|
||||
const token = app.globalData?.token || wx.getStorageSync('token') || ''
|
||||
if (!apiBase) {
|
||||
wx.showToast({ title: '配置异常', icon: 'none' })
|
||||
return
|
||||
}
|
||||
wx.showLoading({ title: '加载中...' })
|
||||
wx.request({
|
||||
url: `${apiBase}/api/test/detail`,
|
||||
method: 'GET',
|
||||
header: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
data: { id },
|
||||
success: (res) => {
|
||||
if (res.statusCode === 200 && res.data && res.data.code === 200) {
|
||||
const payload = res.data.data || {}
|
||||
const data = payload.data || payload
|
||||
const isPaid = !!payload.isPaid
|
||||
const paidAmount = payload.paidAmount != null ? Number(payload.paidAmount) : 0
|
||||
const amountYuan = payload.amountYuan != null ? Number(payload.amountYuan) : (paidAmount > 0 ? paidAmount / 100 : 0)
|
||||
const needPaymentToUnlock = payload.needPaymentToUnlock === true || (!!payload.requiresPayment && !isPaid && paidAmount > 0)
|
||||
this.applyResult(data)
|
||||
const payInfo = {
|
||||
requiresPayment: needPaymentToUnlock,
|
||||
isPaid,
|
||||
amountYuan: needPaymentToUnlock ? amountYuan : 0
|
||||
}
|
||||
this.setData({ payInfo })
|
||||
} else {
|
||||
wx.showToast({ title: res.data?.message || '加载失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
fail: () => wx.showToast({ title: '网络错误', icon: 'none' }),
|
||||
complete: () => wx.hideLoading()
|
||||
})
|
||||
},
|
||||
|
||||
applyResult(result) {
|
||||
if (!result) return
|
||||
const normalized = normalizeSbtiResultForDisplay(result)
|
||||
const dimExplainList = (!normalized.locked && normalized.dimExplainList) ? normalized.dimExplainList : []
|
||||
const typeImageUrl = resolveSbtiTypeImageUrl(normalized)
|
||||
this.setData({
|
||||
result: normalized,
|
||||
dimExplainList,
|
||||
typeImageUrl,
|
||||
typeImageLoadFailed: false,
|
||||
typeImageLoaded: false
|
||||
})
|
||||
},
|
||||
|
||||
onTypeImageLoad() {
|
||||
this.setData({ typeImageLoadFailed: false, typeImageLoaded: true })
|
||||
},
|
||||
|
||||
onTypeImageError() {
|
||||
this.setData({ typeImageLoadFailed: true, typeImageLoaded: false })
|
||||
},
|
||||
|
||||
initPayInfoFromRuntime(testType) {
|
||||
app.getRuntimeConfig()
|
||||
.then((cfg) => {
|
||||
const pricing = cfg.pricing || {}
|
||||
const reportRequires = cfg.reportRequiresPayment || {}
|
||||
const requiresPayment = !!(reportRequires && reportRequires[testType])
|
||||
const amountYuan = Number(pricing[testType]) || (requiresPayment ? 1.98 : 0)
|
||||
this.setData({
|
||||
payInfo: {
|
||||
requiresPayment,
|
||||
isPaid: false,
|
||||
amountYuan
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
this.setData({
|
||||
payInfo: { requiresPayment: false, isPaid: false, amountYuan: 0 }
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
unlockFullReport() {
|
||||
const { payInfo, testResultId, hasReloadedAfterPay } = this.data
|
||||
if (!payInfo.requiresPayment || payInfo.isPaid) return
|
||||
app.ensureLogin && app.ensureLogin().then((logged) => {
|
||||
if (!logged) {
|
||||
wx.showToast({ title: '请先登录', icon: 'none' })
|
||||
return
|
||||
}
|
||||
payment.purchaseSbtiTest({
|
||||
testResultId: testResultId || undefined,
|
||||
success: () => {
|
||||
wx.showToast({ title: '已解锁完整报告', icon: 'success' })
|
||||
this.setData({ 'payInfo.isPaid': true })
|
||||
if (testResultId && !hasReloadedAfterPay) {
|
||||
this.setData({ hasReloadedAfterPay: true })
|
||||
setTimeout(() => this.loadDetail(testResultId), 500)
|
||||
}
|
||||
},
|
||||
fail: () => {}
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
onGetPhoneNumberForSbtiPay(e) {
|
||||
const { code, errMsg } = e.detail || {}
|
||||
if (errMsg && errMsg.indexOf('getPhoneNumber:fail') === 0) {
|
||||
if (!hasPhone()) {
|
||||
wx.showToast({ title: '需要授权手机号才能继续', icon: 'none' })
|
||||
return
|
||||
}
|
||||
this.unlockFullReport()
|
||||
return
|
||||
}
|
||||
if (!code) {
|
||||
if (hasPhone()) {
|
||||
this.unlockFullReport()
|
||||
} else {
|
||||
wx.showToast({ title: '获取手机号失败', icon: 'none' })
|
||||
}
|
||||
return
|
||||
}
|
||||
bindPhoneByCode(code)
|
||||
.then(() => {
|
||||
this.setData({ hasPhone: true })
|
||||
this.unlockFullReport()
|
||||
})
|
||||
.catch(() => {})
|
||||
},
|
||||
|
||||
retakeTest() {
|
||||
if (!this.data.testResultId) {
|
||||
wx.removeStorageSync('sbtiResult')
|
||||
}
|
||||
wx.navigateTo({ url: '/pages/test/sbti' })
|
||||
},
|
||||
|
||||
goHome() {
|
||||
const scope = (getApp().globalData && getApp().globalData.appScope) || 'personal'
|
||||
if (scope === 'enterprise') {
|
||||
wx.navigateTo({ url: '/pages/enterprise/index' })
|
||||
} else {
|
||||
wx.switchTab({ url: '/pages/index/index' })
|
||||
}
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
const result = this.data.result
|
||||
const { getSharePathByScope } = require('../../utils/share')
|
||||
const label = result?.sbtiCn || result?.finalType?.cn || 'SBTI'
|
||||
const code = result?.sbtiType || result?.finalType?.code || ''
|
||||
const img = this.data.typeImageUrl || '/images/share-mbti.png'
|
||||
return {
|
||||
title: `我的 SBTI 类型是 ${code}(${label}),来测测你的吧!`,
|
||||
path: getSharePathByScope('/pages/index/index'),
|
||||
imageUrl: img
|
||||
}
|
||||
},
|
||||
|
||||
onShareTimeline() {
|
||||
const result = this.data.result
|
||||
const { buildShareQuery } = require('../../utils/share')
|
||||
const label = result?.sbtiCn || result?.finalType?.cn || 'SBTI'
|
||||
const code = result?.sbtiType || result?.finalType?.code || ''
|
||||
return {
|
||||
title: `我的 SBTI 类型是 ${code}(${label}),来测测你的吧!`,
|
||||
query: buildShareQuery()
|
||||
}
|
||||
}
|
||||
})
|
||||
7
miniprogram/pages/result/sbti.json
Normal file
7
miniprogram/pages/result/sbti.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"navigationBarTitleText": "SBTI结果",
|
||||
"navigationBarBackgroundColor": "#f2f7f3",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#f2f7f3",
|
||||
"usingComponents": {}
|
||||
}
|
||||
110
miniprogram/pages/result/sbti.wxml
Normal file
110
miniprogram/pages/result/sbti.wxml
Normal file
@@ -0,0 +1,110 @@
|
||||
<!--pages/result/sbti.wxml-->
|
||||
<view class="result-page">
|
||||
<scroll-view class="content-scroll" scroll-y>
|
||||
<view class="content-container">
|
||||
|
||||
<!-- 主类型卡片:有图且图加载成功时隐藏顶部三行(与图内文案重复);无图/失败/加载中仍显示 -->
|
||||
<view class="card main-type-card">
|
||||
<view
|
||||
class="type-header-text-fallback"
|
||||
wx:if="{{!typeImageUrl || typeImageLoadFailed || !typeImageLoaded}}"
|
||||
>
|
||||
<text class="section-label">你的人格类型是:</text>
|
||||
<view class="type-title-area">
|
||||
<text class="type-main-cn">{{result.sbtiCn}}</text>
|
||||
<text class="type-main-code">{{result.sbtiType}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<image
|
||||
class="type-avatar-image"
|
||||
src="{{typeImageUrl}}"
|
||||
mode="widthFix"
|
||||
show-menu-by-longpress="{{true}}"
|
||||
bindload="onTypeImageLoad"
|
||||
binderror="onTypeImageError"
|
||||
wx:if="{{typeImageUrl && !typeImageLoadFailed}}"
|
||||
/>
|
||||
<view class="type-avatar-fallback" wx:if="{{!typeImageUrl || typeImageLoadFailed}}">
|
||||
<text>{{result.sbtiType}}</text>
|
||||
</view>
|
||||
<text class="type-intro">{{result.intro}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 你的主类型卡片 -->
|
||||
<view class="card your-main-type-card">
|
||||
<text class="card-title">你的主类型</text>
|
||||
<view class="main-type-summary">
|
||||
<text class="main-type-text">{{result.sbtiType}} ({{result.sbtiCn}})</text>
|
||||
<text class="main-type-desc">维度命中度较高,当前结果可视为你的第一人格画像。</text>
|
||||
</view>
|
||||
<view class="match-info">
|
||||
<text class="match-percent">匹配度 {{result.matchPercent}}%</text>
|
||||
<text class="match-dim">精准命中 {{result.hitDimCount}}/15 维</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 付费墙(完整报告部分) -->
|
||||
<view class="card paywall-card" wx:if="{{payInfo.requiresPayment && !payInfo.isPaid}}">
|
||||
<view class="paywall-content">
|
||||
<view class="paywall-blur">
|
||||
<text class="paywall-fake-title">完整维度解读</text>
|
||||
<text class="paywall-fake-line">• 15 维等级说明</text>
|
||||
<text class="paywall-fake-line">• 匹配度与兜底说明</text>
|
||||
</view>
|
||||
<view class="paywall-mask"></view>
|
||||
<button
|
||||
class="paywall-btn"
|
||||
wx:if="{{!hasPhone}}"
|
||||
open-type="getPhoneNumber"
|
||||
bindgetphonenumber="onGetPhoneNumberForSbtiPay"
|
||||
>
|
||||
<text class="paywall-btn-main">解锁完整报告</text>
|
||||
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
|
||||
</button>
|
||||
<button
|
||||
class="paywall-btn"
|
||||
wx:elif="{{hasPhone}}"
|
||||
bindtap="unlockFullReport"
|
||||
>
|
||||
<text class="paywall-btn-main">解锁完整报告</text>
|
||||
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 强制完善资料卡片 -->
|
||||
<view class="card paywall-card" wx:elif="{{result && result.locked}}">
|
||||
<view class="paywall-content">
|
||||
<text class="paywall-fake-title">完整报告需完善资料</text>
|
||||
<text class="paywall-fake-line">请补全头像、昵称并绑定手机号后查看完整解读。</text>
|
||||
<button class="paywall-btn" bindtap="goCompleteProfile">
|
||||
<text class="paywall-btn-main">去完善资料</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{!result.locked && (!payInfo.requiresPayment || payInfo.isPaid)}}">
|
||||
<!-- 人格描述 -->
|
||||
<view class="card description-card">
|
||||
<text class="card-title">人格描述</text>
|
||||
<text class="description-text">{{result.desc}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 十五维度详解 -->
|
||||
<view class="card dimension-detail-card">
|
||||
<text class="card-title">十五维度详解</text>
|
||||
<view class="dim-list">
|
||||
<view class="dim-item" wx:for="{{dimExplainList}}" wx:key="dim">
|
||||
<view class="dim-header">
|
||||
<text class="dim-name">{{item.name}}</text>
|
||||
<text class="dim-score">{{item.level}} / 6分<text wx:if="{{item.raw != null && item.raw !== ''}}"> · {{item.raw}}分</text></text>
|
||||
</view>
|
||||
<text class="dim-text">{{item.text}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
270
miniprogram/pages/result/sbti.wxss
Normal file
270
miniprogram/pages/result/sbti.wxss
Normal file
@@ -0,0 +1,270 @@
|
||||
/* pages/result/sbti.wxss */
|
||||
.result-page {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
background-color: #f2f7f3;
|
||||
}
|
||||
|
||||
.content-scroll {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.content-container {
|
||||
padding: 32rpx;
|
||||
padding-bottom: 64rpx;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: #fff;
|
||||
border-radius: 24rpx;
|
||||
padding: 48rpx;
|
||||
margin-bottom: 32rpx;
|
||||
box-shadow: 0 4rpx 12rpx rgba(45, 61, 53, 0.06);
|
||||
border: 1rpx solid #dce8e0;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: block;
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
/* 主类型卡片 */
|
||||
.main-type-card {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
color: #6b7f72;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.type-title-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.type-main-cn {
|
||||
font-size: 64rpx;
|
||||
font-weight: bold;
|
||||
color: #374151;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.type-main-code {
|
||||
font-size: 36rpx;
|
||||
font-weight: 500;
|
||||
color: #5a7268;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.type-avatar-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 20rpx;
|
||||
margin: 0 auto;
|
||||
background-color: #f7fcf9;
|
||||
}
|
||||
|
||||
.type-avatar-fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 80%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 20rpx;
|
||||
margin: 24rpx auto;
|
||||
box-sizing: border-box;
|
||||
background-color: #f7fcf9;
|
||||
border: 1rpx solid #e3ebe6;
|
||||
font-size: 48rpx;
|
||||
font-weight: bold;
|
||||
color: #5a7268;
|
||||
}
|
||||
|
||||
.type-intro {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
/* 你的主类型卡片 */
|
||||
.your-main-type-card {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.main-type-summary {
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.main-type-text {
|
||||
display: block;
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.main-type-desc {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.match-info {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 32rpx;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.match-percent,
|
||||
.match-dim {
|
||||
font-size: 28rpx;
|
||||
color: #5a7268;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100% !important;
|
||||
padding: 28rpx;
|
||||
border-radius: 16rpx;
|
||||
margin-bottom: 20rpx;
|
||||
border: none;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 消除微信 button 伪元素自带边框 */
|
||||
.btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #5a7268 0%, #6f8a7e 100%);
|
||||
box-shadow: 0 8rpx 24rpx rgba(90, 114, 104, 0.28);
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 付费墙 */
|
||||
.paywall-card {
|
||||
margin-top: 24rpx;
|
||||
border-radius: 24rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
.paywall-content { position: relative; min-height: 360rpx; }
|
||||
.paywall-blur {
|
||||
padding: 32rpx 24rpx 200rpx;
|
||||
border-radius: 24rpx;
|
||||
background: rgba(255, 255, 255, 0.97);
|
||||
backdrop-filter: blur(6rpx);
|
||||
}
|
||||
.paywall-mask {
|
||||
position: absolute;
|
||||
left: 0; top: 0; right: 0; bottom: 0;
|
||||
border-radius: 24rpx;
|
||||
z-index: 1;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.08) 0%,
|
||||
rgba(255, 255, 255, 0.28) 38%,
|
||||
rgba(255, 255, 255, 0.58) 100%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
.paywall-fake-title { display: block; font-size: 30rpx; font-weight: 600; color: #444; margin-bottom: 16rpx; }
|
||||
.paywall-fake-line { display: block; font-size: 26rpx; color: #888; line-height: 1.8; }
|
||||
.paywall-btn {
|
||||
position: absolute;
|
||||
left: 5%;
|
||||
right: 5%;
|
||||
width: 90%;
|
||||
bottom: 132rpx;
|
||||
z-index: 3;
|
||||
padding: 20rpx 0;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(135deg, #5a7268 0%, #6f8a7e 100%);
|
||||
box-shadow: 0 8rpx 24rpx rgba(90, 114, 104, 0.28);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
.paywall-btn-main { font-size: 30rpx; color: #fff; font-weight: 600; }
|
||||
.paywall-btn-price { font-size: 24rpx; color: #dce8e0; }
|
||||
|
||||
/* 人格描述卡片 */
|
||||
.description-card .description-text {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap; /* 保持后端返回的换行 */
|
||||
}
|
||||
|
||||
/* 十五维度详解:白底卡片 + 左标题 / 右「H / 6分」+ 灰说明(与定稿稿式一致) */
|
||||
.dimension-detail-card .dim-list {
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.dim-item {
|
||||
background-color: #fff;
|
||||
border: 1rpx solid #e8e8e8;
|
||||
border-radius: 16rpx;
|
||||
padding: 28rpx 24rpx;
|
||||
margin-bottom: 20rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.dim-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.dim-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.dim-score {
|
||||
flex-shrink: 0;
|
||||
font-size: 28rpx;
|
||||
color: #5a7268;
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dim-text {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
color: #6b7280;
|
||||
font-weight: 400;
|
||||
line-height: 1.65;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ Page({
|
||||
data: {
|
||||
permFace: true,
|
||||
permMbti: true,
|
||||
permSbti: true,
|
||||
permPdp: true,
|
||||
permDisc: true
|
||||
},
|
||||
@@ -25,6 +26,7 @@ Page({
|
||||
this.setData({
|
||||
permFace: !p || p.face !== false,
|
||||
permMbti: !p || p.mbti !== false,
|
||||
permSbti: !p || p.sbti !== false,
|
||||
permPdp: !p || p.pdp !== false,
|
||||
permDisc: !p || p.disc !== false
|
||||
})
|
||||
@@ -34,6 +36,10 @@ Page({
|
||||
wx.navigateTo({ url: '/pages/test/mbti' })
|
||||
},
|
||||
|
||||
goSBTI() {
|
||||
wx.navigateTo({ url: '/pages/test/sbti' })
|
||||
},
|
||||
|
||||
goPDP() {
|
||||
wx.navigateTo({ url: '/pages/test/pdp' })
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!--pages/test-select/index.wxml - 详细性格测试入口(MBTI / PDP / DISC)-->
|
||||
<!--pages/test-select/index.wxml - 详细性格测试入口(MBTI / SBTI / PDP / DISC)-->
|
||||
<view class="container">
|
||||
<view class="card intro-card">
|
||||
<text class="intro-title">选择一项详细性格测试</text>
|
||||
@@ -16,6 +16,17 @@
|
||||
<text class="entry-arrow">→</text>
|
||||
</view>
|
||||
|
||||
<view class="card entry-card" bindtap="goSBTI" wx:if="{{permSbti}}">
|
||||
<view class="entry-icon-wrap sbti">
|
||||
<text class="entry-emoji">🎭</text>
|
||||
</view>
|
||||
<view class="entry-content">
|
||||
<text class="entry-name">SBTI 性格测试</text>
|
||||
<text class="entry-brief">15 维等级匹配 · 闸口题(与标准计分一致)</text>
|
||||
</view>
|
||||
<text class="entry-arrow">→</text>
|
||||
</view>
|
||||
|
||||
<view class="card entry-card" bindtap="goPDP" wx:if="{{permPdp}}">
|
||||
<view class="entry-icon-wrap pdp">
|
||||
<text class="entry-emoji">🦁</text>
|
||||
|
||||
@@ -66,6 +66,11 @@
|
||||
background: linear-gradient(135deg, rgba(124, 58, 237, 0.15) 0%, rgba(139, 92, 246, 0.2) 100%);
|
||||
}
|
||||
|
||||
.entry-icon-wrap.sbti {
|
||||
background: linear-gradient(135deg, #f2f7f3 0%, #dce8e0 100%);
|
||||
border: 1rpx solid #c5d4cc;
|
||||
}
|
||||
|
||||
.entry-icon-wrap.pdp {
|
||||
background: linear-gradient(135deg, rgba(230, 57, 70, 0.12) 0%, rgba(255, 107, 157, 0.15) 100%);
|
||||
}
|
||||
|
||||
249
miniprogram/pages/test/sbti.js
Normal file
249
miniprogram/pages/test/sbti.js
Normal file
@@ -0,0 +1,249 @@
|
||||
// pages/test/sbti.js — SBTI:服务端全量拉题 + 组卷(闸口 DG1/DG2)+ sbtiEngine 计分
|
||||
const { fetchQuestionBank } = require('../../utils/questionBank')
|
||||
const { buildShuffledPaper, getVisibleQuestions, computeSbtiResult } = require('../../utils/sbtiEngine')
|
||||
const app = getApp()
|
||||
|
||||
const SBTI_TIME_SEC = 45 * 60
|
||||
|
||||
Page({
|
||||
data: {
|
||||
loading: true,
|
||||
currentIndex: 0,
|
||||
currentQuestion: null,
|
||||
answers: {},
|
||||
total: 0,
|
||||
progress: 0,
|
||||
timeRemaining: SBTI_TIME_SEC,
|
||||
_initialSeconds: SBTI_TIME_SEC,
|
||||
formatTime: '45:00',
|
||||
isSubmitting: false
|
||||
},
|
||||
|
||||
timer: null,
|
||||
_paper: null,
|
||||
_advanceTimer: null,
|
||||
|
||||
onLoad(options) {
|
||||
try {
|
||||
require('../../utils/thirdPartyContext.js').ingestThirdPartyOnPageLoad(options || {}, app)
|
||||
} catch (e) {}
|
||||
fetchQuestionBank('sbti', {})
|
||||
.then((all) => {
|
||||
if (!all || !all.length) {
|
||||
wx.showToast({ title: '暂无题目', icon: 'none' })
|
||||
this.setData({ loading: false })
|
||||
return
|
||||
}
|
||||
const paper = buildShuffledPaper(all)
|
||||
this._paper = paper
|
||||
const visible = getVisibleQuestions(paper.ordered, {}, paper.dg2)
|
||||
const total = visible.length
|
||||
if (!total) {
|
||||
wx.showToast({ title: '组卷失败', icon: 'none' })
|
||||
this.setData({ loading: false })
|
||||
return
|
||||
}
|
||||
this.setData({
|
||||
loading: false,
|
||||
currentIndex: 0,
|
||||
currentQuestion: visible[0],
|
||||
total,
|
||||
progress: total ? Math.round((1 / total) * 100) : 0,
|
||||
timeRemaining: SBTI_TIME_SEC,
|
||||
_initialSeconds: SBTI_TIME_SEC,
|
||||
formatTime: '45:00'
|
||||
})
|
||||
try {
|
||||
require('../../utils/analytics').track('test_start', { type: 'sbti', total })
|
||||
} catch (e) {}
|
||||
this.startTimer()
|
||||
})
|
||||
.catch((err) => {
|
||||
this.setData({ loading: false })
|
||||
wx.showToast({ title: (err && err.message) || '加载失败', icon: 'none' })
|
||||
})
|
||||
},
|
||||
|
||||
onUnload() {
|
||||
if (this._advanceTimer) {
|
||||
clearTimeout(this._advanceTimer)
|
||||
this._advanceTimer = null
|
||||
}
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer)
|
||||
}
|
||||
},
|
||||
|
||||
startTimer() {
|
||||
this.timer = setInterval(() => {
|
||||
let time = this.data.timeRemaining - 1
|
||||
if (time <= 0) {
|
||||
clearInterval(this.timer)
|
||||
this.submitTest({ allowIncomplete: true })
|
||||
return
|
||||
}
|
||||
const minutes = Math.floor(time / 60)
|
||||
const seconds = time % 60
|
||||
this.setData({
|
||||
timeRemaining: time,
|
||||
formatTime: `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
|
||||
})
|
||||
}, 1000)
|
||||
},
|
||||
|
||||
_rebuildVisible() {
|
||||
const paper = this._paper
|
||||
if (!paper) return []
|
||||
return getVisibleQuestions(paper.ordered, this.data.answers, paper.dg2)
|
||||
},
|
||||
|
||||
selectAnswer(e) {
|
||||
if (this._advanceTimer) {
|
||||
clearTimeout(this._advanceTimer)
|
||||
this._advanceTimer = null
|
||||
}
|
||||
const value = e.currentTarget.dataset.value
|
||||
const cq = this.data.currentQuestion
|
||||
if (value == null || !cq || cq.id == null) return
|
||||
|
||||
const questionId = cq.id
|
||||
const idx = this.data.currentIndex
|
||||
const answers = { ...this.data.answers, [questionId]: value }
|
||||
|
||||
const visible = getVisibleQuestions(this._paper.ordered, answers, this._paper.dg2)
|
||||
const tot = visible.length
|
||||
const curPos = visible.findIndex((q) => q.id === questionId)
|
||||
const nextIdx = curPos >= 0 ? curPos + 1 : idx + 1
|
||||
|
||||
this.setData({ answers }, () => {
|
||||
this._advanceTimer = setTimeout(() => {
|
||||
this._advanceTimer = null
|
||||
const d = this.data
|
||||
if (d.currentIndex !== idx || !d.currentQuestion || d.currentQuestion.id !== questionId) return
|
||||
if (nextIdx < visible.length) {
|
||||
const nq = visible[nextIdx]
|
||||
this.setData({
|
||||
currentIndex: nextIdx,
|
||||
currentQuestion: nq,
|
||||
total: tot,
|
||||
progress: tot ? Math.round(((nextIdx + 1) / tot) * 100) : 0
|
||||
})
|
||||
} else {
|
||||
this.submitTest()
|
||||
}
|
||||
}, 320)
|
||||
})
|
||||
},
|
||||
|
||||
prevQuestion() {
|
||||
if (this._advanceTimer) {
|
||||
clearTimeout(this._advanceTimer)
|
||||
this._advanceTimer = null
|
||||
}
|
||||
if (this.data.currentIndex <= 0) return
|
||||
const visible = this._rebuildVisible()
|
||||
const newIndex = this.data.currentIndex - 1
|
||||
const nq = visible[newIndex]
|
||||
if (!nq) return
|
||||
const tot = visible.length
|
||||
this.setData({
|
||||
currentIndex: newIndex,
|
||||
currentQuestion: nq,
|
||||
total: tot,
|
||||
progress: tot ? Math.round(((newIndex + 1) / tot) * 100) : 0
|
||||
})
|
||||
},
|
||||
|
||||
nextQuestion() {
|
||||
const visible = this._rebuildVisible()
|
||||
if (this.data.currentIndex < visible.length - 1) {
|
||||
const newIndex = this.data.currentIndex + 1
|
||||
const nq = visible[newIndex]
|
||||
const tot = visible.length
|
||||
this.setData({
|
||||
currentIndex: newIndex,
|
||||
currentQuestion: nq,
|
||||
total: tot,
|
||||
progress: tot ? Math.round(((newIndex + 1) / tot) * 100) : 0
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
finishTest() {
|
||||
const q = this.data.currentQuestion
|
||||
if (!q) return
|
||||
if (this.data.answers[q.id] == null) {
|
||||
wx.showToast({ title: '请先选择一项', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const visible = this._rebuildVisible()
|
||||
const missing = visible.filter((x) => this.data.answers[x.id] == null || this.data.answers[x.id] === '')
|
||||
if (missing.length) {
|
||||
wx.showToast({ title: '还有题目未作答', icon: 'none' })
|
||||
return
|
||||
}
|
||||
this.submitTest()
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {{ allowIncomplete?: boolean }} opt 计时结束允许未答完也提交
|
||||
*/
|
||||
submitTest(opt = {}) {
|
||||
if (this.data.isSubmitting) return
|
||||
const allowIncomplete = !!opt.allowIncomplete
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
|
||||
const visible = this._rebuildVisible()
|
||||
const answers = this.data.answers
|
||||
if (!allowIncomplete) {
|
||||
const missing = visible.filter((x) => answers[x.id] == null || answers[x.id] === '')
|
||||
if (missing.length) {
|
||||
wx.showToast({ title: `还有 ${missing.length} 题未作答`, icon: 'none' })
|
||||
this.startTimer()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
this.setData({ isSubmitting: true })
|
||||
|
||||
let result
|
||||
try {
|
||||
const paper = this._paper
|
||||
const qs = paper ? paper.ordered.slice() : []
|
||||
if (paper && paper.dg2 && !qs.some((q) => q.id === paper.dg2.id)) {
|
||||
qs.push(paper.dg2)
|
||||
}
|
||||
result = computeSbtiResult(qs, answers)
|
||||
} catch (err) {
|
||||
console.error('computeSbtiResult', err)
|
||||
wx.showToast({ title: '计算结果失败,请重试', icon: 'none' })
|
||||
this.setData({ isSubmitting: false })
|
||||
this.startTimer()
|
||||
return
|
||||
}
|
||||
|
||||
const resultData = {
|
||||
...result,
|
||||
answers,
|
||||
testDuration: (this.data._initialSeconds || SBTI_TIME_SEC) - this.data.timeRemaining,
|
||||
completedAt: new Date().toISOString(),
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
wx.setStorageSync('sbtiResult', resultData)
|
||||
app.saveTestResult('sbti', resultData)
|
||||
try {
|
||||
require('../../utils/analytics').track('test_complete', {
|
||||
type: 'sbti',
|
||||
result: result.sbtiType,
|
||||
duration: resultData.testDuration
|
||||
})
|
||||
} catch (e) {}
|
||||
|
||||
wx.redirectTo({
|
||||
url: '/pages/result/sbti'
|
||||
})
|
||||
}
|
||||
})
|
||||
7
miniprogram/pages/test/sbti.json
Normal file
7
miniprogram/pages/test/sbti.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"navigationBarTitleText": "SBTI测试",
|
||||
"navigationBarBackgroundColor": "#f2f7f3",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#f2f7f3",
|
||||
"usingComponents": {}
|
||||
}
|
||||
54
miniprogram/pages/test/sbti.wxml
Normal file
54
miniprogram/pages/test/sbti.wxml
Normal file
@@ -0,0 +1,54 @@
|
||||
<!--pages/test/sbti.wxml - SBTI 测试(闸口题由逻辑动态插入)-->
|
||||
<view class="test-page">
|
||||
<view wx:if="{{loading}}" class="test-loading">
|
||||
<text class="test-loading-text">加载题目…</text>
|
||||
</view>
|
||||
<block wx:elif="{{currentQuestion}}">
|
||||
<view class="progress-section">
|
||||
<view class="progress-info">
|
||||
<text class="question-count">问题 {{currentIndex + 1}}/{{total}}</text>
|
||||
<text class="time-remaining">剩余时间: {{formatTime}}</text>
|
||||
</view>
|
||||
<view class="progress-bar-container">
|
||||
<view class="progress-bar" style="width: {{progress}}%"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="content-area">
|
||||
<view class="question-card">
|
||||
<text class="question-text">{{currentQuestion.question}}</text>
|
||||
<view class="options-container">
|
||||
<view
|
||||
class="option-item {{answers[currentQuestion.id] === option.value ? 'selected' : ''}}"
|
||||
wx:for="{{currentQuestion.options}}"
|
||||
wx:for-item="option"
|
||||
wx:for-index="optIdx"
|
||||
wx:key="optIdx"
|
||||
bindtap="selectAnswer"
|
||||
data-value="{{option.value}}"
|
||||
>
|
||||
<view class="radio-button {{answers[currentQuestion.id] === option.value ? 'checked' : ''}}">
|
||||
<view wx:if="{{answers[currentQuestion.id] === option.value}}" class="radio-inner"></view>
|
||||
</view>
|
||||
<text class="option-text">{{option.text}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view wx:if="{{currentIndex === total - 1}}" class="last-hint">
|
||||
<text class="last-hint-text">最后一题:选择后约 0.3 秒自动跳转结果页;若未跳转,请点右下角「查看结果」。</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="footer-buttons">
|
||||
<view class="nav-button secondary {{currentIndex === 0 ? 'disabled' : ''}}" bindtap="prevQuestion">
|
||||
<text class="button-text">上一题</text>
|
||||
</view>
|
||||
<view wx:if="{{currentIndex < total - 1}}" class="nav-button secondary" bindtap="nextQuestion">
|
||||
<text class="button-text">跳过</text>
|
||||
</view>
|
||||
<view wx:else class="nav-button primary {{isSubmitting ? 'disabled' : ''}}" bindtap="finishTest">
|
||||
<text class="button-text button-text-on-primary">{{isSubmitting ? '正在生成…' : '查看结果'}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
197
miniprogram/pages/test/sbti.wxss
Normal file
197
miniprogram/pages/test/sbti.wxss
Normal file
@@ -0,0 +1,197 @@
|
||||
/* pages/test/sbti.wxss — 主题底 #f2f7f3,强调 #5a7268 */
|
||||
.test-page {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #f2f7f3;
|
||||
}
|
||||
|
||||
.test-loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80rpx;
|
||||
}
|
||||
|
||||
.test-loading-text {
|
||||
font-size: 30rpx;
|
||||
color: #5a7268;
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
padding: 32rpx;
|
||||
border-bottom: 1rpx solid #dce8e0;
|
||||
flex-shrink: 0;
|
||||
background: #f2f7f3;
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.question-count {
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
color: #2d3d35;
|
||||
}
|
||||
|
||||
.time-remaining {
|
||||
font-size: 28rpx;
|
||||
color: #6b7f72;
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
width: 100%;
|
||||
height: 8rpx;
|
||||
background-color: #dce8e0;
|
||||
border-radius: 8rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #5a7268 0%, #7d9488 100%);
|
||||
border-radius: 8rpx;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 32rpx;
|
||||
}
|
||||
|
||||
.question-card {
|
||||
background-color: #fff;
|
||||
border-radius: 24rpx;
|
||||
padding: 48rpx;
|
||||
box-shadow: 0 4rpx 12rpx rgba(45, 61, 53, 0.06);
|
||||
border: 1rpx solid #dce8e0;
|
||||
}
|
||||
|
||||
.question-text {
|
||||
display: block;
|
||||
font-size: 40rpx;
|
||||
font-weight: 500;
|
||||
color: #2d3d35;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 48rpx;
|
||||
}
|
||||
|
||||
.options-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32rpx;
|
||||
}
|
||||
|
||||
.option-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 32rpx;
|
||||
border: 2rpx solid #dce8e0;
|
||||
border-radius: 16rpx;
|
||||
transition: all 0.3s ease;
|
||||
background: #fafcfb;
|
||||
}
|
||||
|
||||
.option-item.selected {
|
||||
background-color: rgba(90, 114, 104, 0.1);
|
||||
border-color: #5a7268;
|
||||
}
|
||||
|
||||
.radio-button {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
border-radius: 50%;
|
||||
border: 2rpx solid #b8c9c0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 24rpx;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.radio-button.checked {
|
||||
background-color: #5a7268;
|
||||
border-color: #5a7268;
|
||||
}
|
||||
|
||||
.radio-inner {
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
border-radius: 50%;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.option-text {
|
||||
flex: 1;
|
||||
font-size: 32rpx;
|
||||
color: #2d3d35;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.footer-buttons {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
padding: 32rpx;
|
||||
border-top: 1rpx solid #dce8e0;
|
||||
flex-shrink: 0;
|
||||
background: #f2f7f3;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
flex: 1;
|
||||
padding: 28rpx;
|
||||
border-radius: 16rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nav-button.secondary {
|
||||
background-color: #fff;
|
||||
border: 2rpx solid #5a7268;
|
||||
}
|
||||
|
||||
.nav-button.secondary .button-text {
|
||||
color: #5a7268;
|
||||
}
|
||||
|
||||
.nav-button.primary {
|
||||
background: linear-gradient(135deg, #5a7268 0%, #6f8a7e 100%);
|
||||
border: none;
|
||||
box-shadow: 0 8rpx 24rpx rgba(90, 114, 104, 0.28);
|
||||
}
|
||||
|
||||
.button-text-on-primary {
|
||||
color: #ffffff !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-button.disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.last-hint {
|
||||
margin-top: 24rpx;
|
||||
padding: 20rpx 24rpx;
|
||||
background: rgba(90, 114, 104, 0.08);
|
||||
border-radius: 16rpx;
|
||||
border: 1rpx solid #c5d4cc;
|
||||
}
|
||||
|
||||
.last-hint-text {
|
||||
font-size: 26rpx;
|
||||
color: #3d5248;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.button-text {
|
||||
font-size: 32rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -466,6 +466,13 @@ function purchasePdpTest(arg1, arg2) {
|
||||
purchaseByPricing('pdp', 'PDP动物性格测试付费版', { testResultId, success, fail })
|
||||
}
|
||||
|
||||
// SBTI 测试付费版
|
||||
function purchaseSbtiTest(arg1, arg2) {
|
||||
const opts = typeof arg1 === 'function' || arg1 == null ? { success: arg1, fail: arg2 } : (arg1 || {})
|
||||
const { testResultId, success, fail } = opts
|
||||
purchaseByPricing('sbti', 'SBTI 性格测试付费版', { testResultId, success, fail })
|
||||
}
|
||||
|
||||
// 简历综合分析付费版(支持 purchaseResumeAnalysis({ testResultId, success, fail }))
|
||||
function purchaseResumeAnalysis(arg1, arg2) {
|
||||
const opts = typeof arg1 === 'function' || arg1 == null ? { success: arg1, fail: arg2 } : (arg1 || {})
|
||||
@@ -672,6 +679,7 @@ module.exports = {
|
||||
purchaseMbtiTest,
|
||||
purchaseDiscTest,
|
||||
purchasePdpTest,
|
||||
purchaseSbtiTest,
|
||||
purchaseResumeAnalysis,
|
||||
purchaseFullReport,
|
||||
purchaseTeamAnalysis,
|
||||
|
||||
@@ -70,7 +70,7 @@ function applyDrawCountAfterShuffle(questions) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {'mbti'|'disc'|'pdp'} type
|
||||
* @param {'mbti'|'sbti'|'disc'|'pdp'} type
|
||||
* @param {number|null|undefined} enterpriseId
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
@@ -97,7 +97,7 @@ function fetchQuestionBank(type, enterpriseId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {'mbti'|'disc'|'pdp'} type
|
||||
* @param {'mbti'|'sbti'|'disc'|'pdp'} type
|
||||
* @param {{ enterpriseId?: number|null }} opts
|
||||
* @returns {Promise<Array>} 乱序后的题目;接口无题或失败则 reject
|
||||
*/
|
||||
|
||||
@@ -227,6 +227,18 @@ function formatTestSummary(data, testType) {
|
||||
return label || ''
|
||||
}
|
||||
|
||||
if (t === 'sbti') {
|
||||
const code = data.sbtiType || data.finalType?.code || ''
|
||||
const cn = data.sbtiCn || data.finalType?.cn || ''
|
||||
const sim = data.bestNormal != null ? data.bestNormal.similarity : null
|
||||
if (code && cn) {
|
||||
return sim != null && Number.isFinite(Number(sim))
|
||||
? `${code}(${cn}) 匹配${toIntPercent(sim)}%`
|
||||
: `${code}(${cn})`
|
||||
}
|
||||
return String(code || cn || '')
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -256,6 +268,12 @@ function getTypeOnly(data, testType) {
|
||||
}
|
||||
return String(data.pdp ?? '')
|
||||
}
|
||||
if (t === 'sbti') {
|
||||
const cn = data.sbtiCn || data.finalType?.cn || ''
|
||||
const code = data.sbtiType || data.finalType?.code || ''
|
||||
if (cn && code) return `${code}(${cn})`
|
||||
return String(cn || code || '')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
|
||||
393
miniprogram/utils/sbtiData.js
Normal file
393
miniprogram/utils/sbtiData.js
Normal file
@@ -0,0 +1,393 @@
|
||||
// 从 aisbti.com 测试页提取,算法与官方一致;勿手改数据结构
|
||||
module.exports = {
|
||||
TYPE_LIBRARY: {
|
||||
"CTRL": {
|
||||
"code": "CTRL",
|
||||
"cn": "拿捏者",
|
||||
"intro": "怎么样,被我拿捏了吧?",
|
||||
"desc": "恭喜您,您测出了全中国最为罕见的人格,您是宇宙熵增定律的天然反抗者!全世界所谓成功人士里,99.99%都是您的拙劣模仿者。CTRL人格,是行走的人形自走任务管理器,普通人眼中的“规则”,在您这里只是出厂的基础参数设置;凡人所谓的“计划”,对您而言不过是心血来潮的随手涂鸦。拥有一个CTRL朋友意味着什么?意味着你的人生导航系统会变得更加精准、高效。因为CTRL最会拿捏了。CTRL会在你人生列车即将脱轨的前一秒,用一个“Ctrl+S”帮你硬核存档,再用一套无法拒绝的逻辑把你强行拽回正轨。他们是你混乱生活最后的备份盘,是宇宙崩塌前唯一还亮着的那个重启键。"
|
||||
},
|
||||
"ATM-er": {
|
||||
"code": "ATM-er",
|
||||
"cn": "送钱者",
|
||||
"intro": "你以为我很有钱吗?",
|
||||
"desc": "恭喜您,您竟然测出了这个世界上最稀有的人格。您或将成为金融界的未解之谜——是的,ATM-er不一定真的“送钱”,但可能永远在“支付”。支付时间、支付精力、支付耐心、支付一个本该安宁的夜晚。因此像一部老旧但坚固的ATM机,插进去的是别人的焦虑和麻烦,吐出来的是“没事,有我”的安心保证。您的人生就是一场盛大的、无人喝彩的单人付账秀。您竟用磐石般的可靠,承受了瀑布般的索取,偶尔夜深人静才会对着账单——可能是精神上的——发出一声叹息:我这该死的、无处安放的责任心啊。"
|
||||
},
|
||||
"Dior-s": {
|
||||
"code": "Dior-s",
|
||||
"cn": "屌丝",
|
||||
"intro": "等着我屌丝逆袭。",
|
||||
"desc": "恭喜!您并非屌丝,您是犬儒主义先贤第欧根尼失散多年的精神传人,因为屌丝的全称是 Diogenes' Original Realist - sage。Dior-s人格,是对当代消费主义陷阱和成功学PUA最彻底的蔑视。他们不是“不求上进”,而是早已看穿一切“上进”的尽头不过是更高级的牢房。屌丝有着大智慧。当别人在追逐风口,被时代的巨浪拍得七荤八素时,Dior-s早已在自己的精神木桶里晒着太阳,达到了“人桶合一”的至高境界。他们信奉的不是空话,是经过亿万次实践检验的物理法则与生物本能:一、躺着比站着舒服;二、饭点到了就得干饭。"
|
||||
},
|
||||
"BOSS": {
|
||||
"code": "BOSS",
|
||||
"cn": "领导者",
|
||||
"intro": "方向盘给我,我来开。",
|
||||
"desc": "BOSS是一个手里永远拿着方向盘的人。哪怕油箱已经亮了红灯,哪怕导航在胡说八道,你都会面无表情地说一句:我来开。然后真的把车开到了目的地。该人格拥有独立的物理法则——永恒向上定律。BOSS人格看世界,就像玩通关了的玩家在看新手教程。效率是他们的信仰,秩序是他们的呼吸。他们不是“自带领袖气场”,他们本身就是人形的气场发生器,方圆五米内,空气都会自动变得严肃而高效。他们眼中的“自我突破”,约等于普通人眼中的“自虐”。今天掌握一门新语言,明天考下一个专业证书,后天就计划殖民火星。你说这太卷了,他会用一种看弱鸡的眼神看着你:不是我太狠,是你太松。"
|
||||
},
|
||||
"THAN-K": {
|
||||
"code": "THAN-K",
|
||||
"cn": "感恩者",
|
||||
"intro": "我感谢苍天!我感谢大地!",
|
||||
"desc": "恭喜您,您测出了全中国最为罕见的人格。您应当感谢我!感谢您在此刻拥有了生命的滋润!倘若您上班路上堵车了?您也应当说一句:我感谢这次堵车,它让我有更多时间聆听这首美妙的歌曲,并欣赏窗外每一张因焦虑而扭曲的脸庞,让我更珍惜内心的平静。是的,THAN-K拥有温润如玉的性格和海纳百川的胸怀。他们眼中的世界没有完全的坏人,只有“尚未被感恩光芒照耀到的朋友”。拥有一个THAN-K朋友,就像身边多了一个永不枯竭的正能量发射塔。TA甚至能帮你从墙角的霉斑里发现一幅梵高风格的星空图。"
|
||||
},
|
||||
"OH-NO": {
|
||||
"code": "OH-NO",
|
||||
"cn": "哦不人",
|
||||
"intro": "哦不!我怎么会是这个人格?!",
|
||||
"desc": "“哦不!”并非恐惧的尖叫,而是一种顶级的智慧。当普通人看到一个杯子放在桌沿,哦不人看到的是一场由“水渍-短路-火灾-全楼疏散-经济损失-蝴蝶效应-世界末日”构成的灾难史诗。于是,伴随着一声发自灵魂深处的 Oh, no!,他们会以迅雷不及掩耳之势把杯子挪到桌子正中央,然后再垫上一张吸水杯垫。哦不人对“边界”有一种近乎偏执的尊重:你的就是你的,我的就是我的。所有意外和风险都已经在他的“Oh, no!”声中,被扼杀在了萌芽状态。他们是秩序的守护神,是混乱世界里最后那批神经绷得很直的体面人。"
|
||||
},
|
||||
"GOGO": {
|
||||
"code": "GOGO",
|
||||
"cn": "行者",
|
||||
"intro": "gogogo~出发咯",
|
||||
"desc": "经研究发现,GOGO人格的大脑构造与常人有根本性不同。GOGO活在一个极致的“所见即所得”世界里,人生信条简单粗暴到令人发指:只要我闭上眼睛,天就是黑的;只要我把钱都花了,我就没有钱了;只要我站在斑马线上,我现在就是行人了。逻辑完美闭环,根本无法反驳。别人还在为“先有鸡还是先有蛋”而辩论,GOGO行者已经把鸡和蛋一起做成了一盘“鸡生蛋,蛋生鸡之终极奥义盖浇饭”。他们不是在“解决问题”,他们是在“清除待办事项”。对他们来说,世界上只有两种状态:已完成,和即将被我完成。"
|
||||
},
|
||||
"SEXY": {
|
||||
"code": "SEXY",
|
||||
"cn": "尤物",
|
||||
"intro": "您就是天生的尤物!",
|
||||
"desc": "当您走进一个房间,照明系统会自动将您识别为天生的尤物,并自觉调暗亮度,以避免能源浪费。当您微笑时,您就变成了微笑着的尤物,周围的空气湿度也会显著下降,因为水蒸气都凝结成了人眼中的爱心。无论是谁,都容易对您的存在产生一种超标的注意力。传说,如果有足够多的SEXY人格聚集在一起开派对,其释放出的综合魅力能量足以暂时扭曲时空结构,让参加者产生“时间变慢了”的幸福错觉。他们不需要卖力表达,很多时候,单是存在本身就已经很像一篇华丽到过分的赋。"
|
||||
},
|
||||
"LOVE-R": {
|
||||
"code": "LOVE-R",
|
||||
"cn": "多情者",
|
||||
"intro": "爱意太满,现实显得有点贫瘠。",
|
||||
"desc": "LOVE-R人格像远古神话时代幸存至今的珍稀物种,其存在概率比你在马桶里钓到作者胳膊的概率还低。您简直是这个钢铁森林时代最后的、也是最不合时宜的吟游诗人。因为您的情感处理器不是二进制的,而是彩虹制的。一片落叶,在常人眼里只是“秋天来了”,在LOVE-R眼中,则是一场关于轮回、牺牲与无言之爱的十三幕悲喜剧。您内心世界像一座永不关门的主题公园,一生都在寻找那个能看懂园区地图、并愿意陪你坐旋转木马直到宇宙尽头的灵魂伴侣。"
|
||||
},
|
||||
"MUM": {
|
||||
"code": "MUM",
|
||||
"cn": "妈妈",
|
||||
"intro": "或许...我可以叫你妈妈吗....?",
|
||||
"desc": "恭喜您,您测出了全中国最稀有的妈妈人格。是的,在混沌未开、时间尚无姓名之前,在第一颗恒星打出第一个嗝之前,就已经有了妈妈。妈妈人格的底色是温柔,擅长感知情绪,具有超强共情力,知道什么时候该停下来,什么时候该对自己说一句“算了”。妈妈像一个医生,治愈了别人的不开心。只可惜,当妈妈落泪时,TA给自己的药,剂量总是比给别人小一号。MUM对自己的温柔,常常打了折。"
|
||||
},
|
||||
"FAKE": {
|
||||
"code": "FAKE",
|
||||
"cn": "伪人",
|
||||
"intro": "已经,没有人类了。",
|
||||
"desc": "SCP基金会紧急报告:项目编号 SCP-CN-████ “伪人”。在社交场合,伪人是八面玲珑的存在,因为他们切换人格面具比切换手机输入法还快。上一秒还是推心置腹的铁哥们模式,下一秒领导来了,瞬间切换成沉稳可靠好员工模式,连脸上的光泽度和卷曲度都会发生微调。你以为你交到了一个真心懂你的朋友?醒醒。你只是幸运地遇到了一个善于伪装、高性能的仿生人罢了。夜深人静时,伪人把面具一层层摘下来,最后才发现,面具下空得很,正是这些面具构成了自己。"
|
||||
},
|
||||
"OJBK": {
|
||||
"code": "OJBK",
|
||||
"cn": "无所谓人",
|
||||
"intro": "我说随便,是真的随便。",
|
||||
"desc": "让我们直面这个词的粗犷本质:OJBK。这已经不是一种人格,而是一种统治哲学。当凡人面临“中午吃米饭还是面条”的世纪抉择时,大脑在激烈燃烧卡路里;而OJBK人格,会用一种批阅奏章般的淡然,轻飘飘地吐出两个字:都行。这不是没主见,这是在告诉你:尔等凡俗的选择,于朕而言,皆为蝼蚁。为什么不争执?因为跟草履虫辩论宇宙的未来毫无意义。为什么不较真?因为帝王不会在意脚下的尘埃是往左飘还是往右飘。"
|
||||
},
|
||||
"MALO": {
|
||||
"code": "MALO",
|
||||
"cn": "吗喽",
|
||||
"intro": "人生是个副本,而我只是一只吗喽。",
|
||||
"desc": "朋友,你不是“童心未泯”,你压根就没进化。你的灵魂还停留在那个挂在树上荡秋千、看见香蕉就两眼放光的快乐时代。当人类祖先决定从树上下来、学会直立行走、穿上西装打领带时,吗喽人格的祖先在旁边的大树上看着他们,挠了挠屁股,嘴里发出一声不屑的“吱”。他们看透了一切:所谓的“文明”,不过是一场最无聊、最不好玩的付费游戏。规则偶尔是可以打破的,天花板是用来倒挂的,会议室是用来表演后空翻的。MALO本身就是一个从巨大脑洞里掉出来、忘了关门的奇思妙想。"
|
||||
},
|
||||
"JOKE-R": {
|
||||
"code": "JOKE-R",
|
||||
"cn": "小丑",
|
||||
"intro": "原来我们都是小丑。",
|
||||
"desc": "请注意,JOKE-R人格不是一个“人”,更像一个把笑话穿在身上的小丑。你打开一层,是个笑话;再打开一层,是个段子;你一层层打开,直到最后,你发现最里面……是空的,只剩下一点微弱的回声在说:哈,没想到吧。JOKE-R是社交场合的气氛组组长兼唯一指定火力输出。有他们在,场子就不会冷。所有人前仰后合地笑着,而笑得最开心的,往往也是他们自己——用最大的笑声,盖住心碎的声音。"
|
||||
},
|
||||
"WOC!": {
|
||||
"code": "WOC!",
|
||||
"cn": "握草人",
|
||||
"intro": "卧槽,我怎么是这个人格?",
|
||||
"desc": "我们发现了一种神奇的生物——WOC!人。他们拥有两种完全独立的操作系统:一个叫“表面系统”,负责发出“我操”“牛逼”“啊?”等一系列大惊小怪的拟声词;另一个叫“后台系统”,负责冷静分析:嗯,果然不出我所料。WOC!人只会卧槽,不会多管闲事,因为他们深知,给傻逼讲道理,就像扶着烂泥上墙,不仅浪费体力,还弄自己一手屎。所以他们选择,握着一根智慧的大草,用一声饱含深情的“WOC!”来为这个疯狂的世界献上最高敬意。"
|
||||
},
|
||||
"THIN-K": {
|
||||
"code": "THIN-K",
|
||||
"cn": "思考者",
|
||||
"intro": "已深度思考100s。",
|
||||
"desc": "经研究发现,THIN-K人格的大脑构造与常人有根本性不同。正如名称所示,您的大脑长时间处于思考状态。您十分会审判信息,注重论点、论据、逻辑推理、潜在偏见,乃至“作者本人三代以内思想背景调查报告”的全套材料。在这个信息爆炸的时代,您绝不会轻易盲从,会在关系中衡量利弊,也十分捍卫自己的自我空间。当别人看到您独处时在发呆?愚蠢,那不是发呆,那是您的大脑正在对今天接收到的所有信息进行分类、归档和销毁。"
|
||||
},
|
||||
"SHIT": {
|
||||
"code": "SHIT",
|
||||
"cn": "愤世者",
|
||||
"intro": "这个世界,构石一坨。",
|
||||
"desc": "恭喜您,SHIT人格是宇宙中已知的唯一一种稀有人格。所谓狗屎,并不是在抱怨,而是在进行一种神秘仪式。SHIT的行为模式是一场惊天动地的悖论戏剧。嘴上:这个项目简直是屎。手上:默默打开 Excel,开始建构函数模型和甘特图。嘴上:这帮同事都是 shit。手上:在同事搞砸之后,一边烦着,一边熬夜把烂摊子收拾得明明白白。嘴上:这个世界就是一坨 shit,赶紧毁灭吧。手上:第二天早上七点准时起床,挤上 shit 一样的地铁,去干那份 shit 一样的工作。别怕,那不是世界末日的警报,那是他马上要开始拯救世界的冲锋号。"
|
||||
},
|
||||
"ZZZZ": {
|
||||
"code": "ZZZZ",
|
||||
"cn": "装死者",
|
||||
"intro": "我没死,我只是在睡觉。",
|
||||
"desc": "恭喜您,您测出了全中国最稀有的装死人格。群里99+条消息您可以视而不见,但当有人发出“@全体成员 还有半小时就截止了”的最后通牒时,您也许会像刚从千年古墓里苏醒一样,缓缓地敲出一个“收到”,然后在29分钟内,交出一份虽然及格的答卷。是的,直到“死线”这个唯一的、最高权限的指令出现,您就真正爆发了,不鸣则已,一鸣惊人。您向宇宙证明了一个真理:有时什么都不做,就不会做错。"
|
||||
},
|
||||
"POOR": {
|
||||
"code": "POOR",
|
||||
"cn": "贫困者",
|
||||
"intro": "我穷,但我很专。",
|
||||
"desc": "恭喜您,您测出了【POOR - 贫困者】。这个“贫困”不是钱包余额的判决书,更像一种欲望断舍离后的资源再分配。别人把精力撒成漫天二维码,你把精力压成一束激光,照哪儿,哪儿就开始冒烟。POOR的世界很简单:不重要的东西一律降噪,重要的东西狠狠干到底。热闹、社交、虚荣、到处刷存在感?抱歉,没空。你不是资源少,你是把资源全部灌进了一个坑里,所以看起来像贫困,实际上像矿井。一旦某件事被你认定值得钻,外界再吵也只是背景杂音。"
|
||||
},
|
||||
"MONK": {
|
||||
"code": "MONK",
|
||||
"cn": "僧人",
|
||||
"intro": "没有那种世俗的欲望。",
|
||||
"desc": "当别人在KTV里参悟爱与恨的纠缠,MONK人格选择在家中参悟一份大道。MONK已然看破红尘,不希望闲人来扰其清修、破其道行。MONK的个人空间,是他们的结界,是他们的须弥山,是他们的绝对领域,神圣不可侵犯。踏入者,会感受到一种来自灵魂深处的窒息感。MONK们不黏不缠,因为在他们的世界观里,万物皆有其独立轨道。行星与行星之间保持着亿万公里的距离,才构成和谐宇宙,人与人之间为什么不行?"
|
||||
},
|
||||
"IMSB": {
|
||||
"code": "IMSB",
|
||||
"cn": "傻者",
|
||||
"intro": "认真的么?我真的是傻逼么?",
|
||||
"desc": "恭喜您!您根本不在人类范畴内!您测出了百万年一遇的【IMSB】人格。IMSB人格的大脑里住着两个不死不休的究极战士:一个叫“我他妈冲了!”,另一个叫“我是个傻逼!”。当IMSB面对一个有好感的人时,前者会说:冲啊!去要微信!去约饭!爱要大声说出来!后者接着说:人家凭什么看得上你?你去了就是自取其辱!最终结果:盯着对方背影直到消失,然后掏出手机搜索“如何克服社交恐惧症”。IMSB不是真的傻,只是您的内心戏,可能比漫威宇宙所有电影加起来都长。"
|
||||
},
|
||||
"SOLO": {
|
||||
"code": "SOLO",
|
||||
"cn": "孤儿",
|
||||
"intro": "我哭了,我怎么会是孤儿?",
|
||||
"desc": "恭喜您,您测出了全中国最稀有的【SOLO - 孤儿】人格。别急着哭,国王的加冕仪式,通常都是一个人。孤儿的自我价值感偏低,因此有时主动疏远他人,孤儿们在自己的灵魂外围建起了一座名为“莫挨老子”的万里长城。每一块砖,都是过去的一道伤口。孤儿就像一只把所有软肋都藏起来,然后用最硬的刺对着世界的刺猬。那满身的尖刺不是攻击,那是一句句说不出口的“别过来,我怕你也受伤”和“求求你,别离开”。"
|
||||
},
|
||||
"FUCK": {
|
||||
"code": "FUCK",
|
||||
"cn": "草者",
|
||||
"intro": "操!这是什么人格?",
|
||||
"desc": "恭喜您!您根本不在人类范畴内!您测出了百万年一遇的【FUCK】人格。人类文明城市里,出现了一株无法被任何除草剂杀死的、具有超级生命力的人形野草——那就是草者人格。它的学名,就叫 FUCK。在FUCK的世界观里,世俗规则简直毫无意义,并且FUCK的情绪开关是物理拨片式的:FUCK YEAH 和 FUCK OFF。FUCK追求的不只是当下快感,也在追求一种在体内横冲直撞的生命力。当所有人都被驯化成了温顺家禽,FUCK则是荒野上最后那一声狼嚎。"
|
||||
},
|
||||
"DEAD": {
|
||||
"code": "DEAD",
|
||||
"cn": "死者",
|
||||
"intro": "我,还活着吗?",
|
||||
"desc": "恭喜您,您测出了全中国最为罕见的人格,只是“死者”这个名字实在有点晦气,所以也可以叫:Don't Expect Any Drives。死者已经看透了那些无意义的哲学思考,因此显得对一切“失去”了兴趣。死者们看世界的眼神,就像一位顶级玩家通关了所有主线、支线、隐藏任务,删档重开了999次之后,终于发现:这游戏压根就没意思。死者是超越了欲望和目标的终极贤者。他们的存在,就是对这个喧嚣世界最沉默也最彻底的抗议。"
|
||||
},
|
||||
"IMFW": {
|
||||
"code": "IMFW",
|
||||
"cn": "废物",
|
||||
"intro": "我真的...是废物吗?",
|
||||
"desc": "恭喜您,您测出的不是一个普通人格,您是一种极其珍稀的、仅占世界人口0.0001%的——【废物】。废物们的自尊通常有些脆弱,缺乏安全感,偶尔也会缺乏主见,因此这种人格能精确地感知到周围最强的那个 WiFi 信号——也就是他们心里最可靠的人。走进【废物】人格的生活,就像走进了一个顶级兰花温室:需要精确控制温度、湿度,以及每天定时进行“我爱你”的言语光合作用。给废物一颗糖,他们会还你一个完全信任你、亮晶晶的眼神。你未必是废物,你只是太没防备,太容易认真。"
|
||||
},
|
||||
"HHHH": {
|
||||
"code": "HHHH",
|
||||
"cn": "傻乐者",
|
||||
"intro": "哈哈哈哈哈哈。",
|
||||
"desc": "恭喜您!由于您的思维回路过于清奇,标准人格库已全面崩溃。第一人格匹配率只有60%以下时,系统才会为您强制匹配这个人格——【HHHH - 傻乐者】。这个人格有什么特质?哈哈哈哈哈哈哈哈哈哈哈哈!对不起,这就是全部的特质了。您可以查看十五维度进行不专业的评估,实在是抱歉!作者设置人格时没有考虑全面,因此才会出现这样的状况。哈哈哈哈哈哈……笑着笑着,我便哭了出来。怎么会有人的脑回路这么新奇。"
|
||||
},
|
||||
"DRUNK": {
|
||||
"code": "DRUNK",
|
||||
"cn": "酒鬼",
|
||||
"intro": "烈酒烧喉,不得不醉。",
|
||||
"desc": "您为什么走路摇摇晃晃?您为什么总是情绪高涨?您为什么看东西是重影的?因为您体内流淌的不是血液,是美味的五粮液!是国窖1573!是江小白!是陕西五粮液!哦,美味的白酒,每一滴都在燃烧,都在沸腾。您是否已经习惯了将白酒灌入保温杯,当作白开水一饮而下?多么伟大的白酒!它让您在饭桌上谈笑风生,在厕所里抱着马桶忏悔人生;它让您觉得自己是夜场诗人,是宇宙中心那团不灭的火,直到第二天上午十点,您的头像裂开的核桃,嘴角挂着食物残渣,灵魂缩在角落里。您终于明白,昨晚那个高谈阔论、拍桌怒吼的人,已经成为了一个酒鬼。"
|
||||
}
|
||||
},
|
||||
TYPE_IMAGES: {
|
||||
"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"
|
||||
},
|
||||
NORMAL_TYPES: [
|
||||
{
|
||||
"code": "CTRL",
|
||||
"pattern": "HHH-HMH-MHH-HHH-MHM"
|
||||
},
|
||||
{
|
||||
"code": "ATM-er",
|
||||
"pattern": "HHH-HHM-HHH-HMH-MHL"
|
||||
},
|
||||
{
|
||||
"code": "Dior-s",
|
||||
"pattern": "MHM-MMH-MHM-HMH-LHL"
|
||||
},
|
||||
{
|
||||
"code": "BOSS",
|
||||
"pattern": "HHH-HMH-MMH-HHH-LHL"
|
||||
},
|
||||
{
|
||||
"code": "THAN-K",
|
||||
"pattern": "MHM-HMM-HHM-MMH-MHL"
|
||||
},
|
||||
{
|
||||
"code": "OH-NO",
|
||||
"pattern": "HHL-LMH-LHH-HHM-LHL"
|
||||
},
|
||||
{
|
||||
"code": "GOGO",
|
||||
"pattern": "HHM-HMH-MMH-HHH-MHM"
|
||||
},
|
||||
{
|
||||
"code": "SEXY",
|
||||
"pattern": "HMH-HHL-HMM-HMM-HLH"
|
||||
},
|
||||
{
|
||||
"code": "LOVE-R",
|
||||
"pattern": "MLH-LHL-HLH-MLM-MLH"
|
||||
},
|
||||
{
|
||||
"code": "MUM",
|
||||
"pattern": "MMH-MHL-HMM-LMM-HLL"
|
||||
},
|
||||
{
|
||||
"code": "FAKE",
|
||||
"pattern": "HLM-MML-MLM-MLM-HLH"
|
||||
},
|
||||
{
|
||||
"code": "OJBK",
|
||||
"pattern": "MMH-MMM-HML-LMM-MML"
|
||||
},
|
||||
{
|
||||
"code": "MALO",
|
||||
"pattern": "MLH-MHM-MLH-MLH-LMH"
|
||||
},
|
||||
{
|
||||
"code": "JOKE-R",
|
||||
"pattern": "LLH-LHL-LML-LLL-MLM"
|
||||
},
|
||||
{
|
||||
"code": "WOC!",
|
||||
"pattern": "HHL-HMH-MMH-HHM-LHH"
|
||||
},
|
||||
{
|
||||
"code": "THIN-K",
|
||||
"pattern": "HHL-HMH-MLH-MHM-LHH"
|
||||
},
|
||||
{
|
||||
"code": "SHIT",
|
||||
"pattern": "HHL-HLH-LMM-HHM-LHH"
|
||||
},
|
||||
{
|
||||
"code": "ZZZZ",
|
||||
"pattern": "MHL-MLH-LML-MML-LHM"
|
||||
},
|
||||
{
|
||||
"code": "POOR",
|
||||
"pattern": "HHL-MLH-LMH-HHH-LHL"
|
||||
},
|
||||
{
|
||||
"code": "MONK",
|
||||
"pattern": "HHL-LLH-LLM-MML-LHM"
|
||||
},
|
||||
{
|
||||
"code": "IMSB",
|
||||
"pattern": "LLM-LMM-LLL-LLL-MLM"
|
||||
},
|
||||
{
|
||||
"code": "SOLO",
|
||||
"pattern": "LML-LLH-LHL-LML-LHM"
|
||||
},
|
||||
{
|
||||
"code": "FUCK",
|
||||
"pattern": "MLL-LHL-LLM-MLL-HLH"
|
||||
},
|
||||
{
|
||||
"code": "DEAD",
|
||||
"pattern": "LLL-LLM-LML-LLL-LHM"
|
||||
},
|
||||
{
|
||||
"code": "IMFW",
|
||||
"pattern": "LLH-LHL-LML-LLL-MLL"
|
||||
}
|
||||
],
|
||||
DIM_EXPLANATIONS: {
|
||||
"S1": {
|
||||
"L": "对自己下手比别人还狠,夸你两句你都想先验明真伪。",
|
||||
"M": "自信值随天气波动,顺风能飞,逆风先缩。",
|
||||
"H": "心里对自己大致有数,不太会被路人一句话打散。"
|
||||
},
|
||||
"S2": {
|
||||
"L": "内心频道雪花较多,常在“我是谁”里循环缓存。",
|
||||
"M": "平时还能认出自己,偶尔也会被情绪临时换号。",
|
||||
"H": "对自己的脾气、欲望和底线都算门儿清。"
|
||||
},
|
||||
"S3": {
|
||||
"L": "更在意舒服和安全,没必要天天给人生开冲刺模式。",
|
||||
"M": "想上进,也想躺会儿,价值排序经常内部开会。",
|
||||
"H": "很容易被目标、成长或某种重要信念推着往前。"
|
||||
},
|
||||
"E1": {
|
||||
"L": "感情里警报器灵敏,已读不回都能脑补到大结局。",
|
||||
"M": "一半信任,一半试探,感情里常在心里拉锯。",
|
||||
"H": "更愿意相信关系本身,不会被一点风吹草动吓散。"
|
||||
},
|
||||
"E2": {
|
||||
"L": "感情投入偏克制,心门不是没开,是门禁太严。",
|
||||
"M": "会投入,但会给自己留后手,不至于全盘梭哈。",
|
||||
"H": "一旦认定就容易认真,情绪和精力都给得很足。"
|
||||
},
|
||||
"E3": {
|
||||
"L": "容易黏人也容易被黏,关系里的温度感很重要。",
|
||||
"M": "亲密和独立都要一点,属于可调节型依赖。",
|
||||
"H": "空间感很重要,再爱也得留一块属于自己的地。"
|
||||
},
|
||||
"A1": {
|
||||
"L": "看世界自带防御滤镜,先怀疑,再靠近。",
|
||||
"M": "既不天真也不彻底阴谋论,观望是你的本能。",
|
||||
"H": "更愿意相信人性和善意,遇事不急着把世界判死刑。"
|
||||
},
|
||||
"A2": {
|
||||
"L": "规则能绕就绕,舒服和自由往往排在前面。",
|
||||
"M": "该守的时候守,该变通的时候也不死磕。",
|
||||
"H": "秩序感较强,能按流程来就不爱即兴炸场。"
|
||||
},
|
||||
"A3": {
|
||||
"L": "意义感偏低,容易觉得很多事都像在走过场。",
|
||||
"M": "偶尔有目标,偶尔也想摆烂,人生观处于半开机。",
|
||||
"H": "做事更有方向,知道自己大概要往哪边走。"
|
||||
},
|
||||
"Ac1": {
|
||||
"L": "做事先考虑别翻车,避险系统比野心更先启动。",
|
||||
"M": "有时想赢,有时只想别麻烦,动机比较混合。",
|
||||
"H": "更容易被成果、成长和推进感点燃。"
|
||||
},
|
||||
"Ac2": {
|
||||
"L": "做决定前容易多转几圈,脑内会议常常超时。",
|
||||
"M": "会想,但不至于想死机,属于正常犹豫。",
|
||||
"H": "拍板速度快,决定一下就不爱回头磨叽。"
|
||||
},
|
||||
"Ac3": {
|
||||
"L": "执行力和死线有深厚感情,越晚越像要觉醒。",
|
||||
"M": "能做,但状态看时机,偶尔稳偶尔摆。",
|
||||
"H": "推进欲比较强,事情不落地心里都像卡了根刺。"
|
||||
},
|
||||
"So1": {
|
||||
"L": "社交启动慢热,主动出击这事通常得攒半天气。",
|
||||
"M": "有人来就接,没人来也不硬凑,社交弹性一般。",
|
||||
"H": "更愿意主动打开场子,在人群里不太怕露头。"
|
||||
},
|
||||
"So2": {
|
||||
"L": "关系里更想亲近和融合,熟了就容易把人划进内圈。",
|
||||
"M": "既想亲近又想留缝,边界感看对象调节。",
|
||||
"H": "边界感偏强,靠太近会先本能性后退半步。"
|
||||
},
|
||||
"So3": {
|
||||
"L": "表达更直接,心里有啥基本不爱绕。",
|
||||
"M": "会看气氛说话,真实和体面通常各留一点。",
|
||||
"H": "对不同场景的自我切换更熟练,真实感会分层发放。"
|
||||
}
|
||||
},
|
||||
dimensionOrder: ['S1','S2','S3','E1','E2','E3','A1','A2','A3','Ac1','Ac2','Ac3','So1','So2','So3'],
|
||||
dimensionMeta: {
|
||||
S1: { name: 'S1 自尊自信', model: '自我模型' },
|
||||
S2: { name: 'S2 自我清晰度', model: '自我模型' },
|
||||
S3: { name: 'S3 核心价值', model: '自我模型' },
|
||||
E1: { name: 'E1 依恋安全感', model: '情感模型' },
|
||||
E2: { name: 'E2 情感投入度', model: '情感模型' },
|
||||
E3: { name: 'E3 边界与依赖', model: '情感模型' },
|
||||
A1: { name: 'A1 世界观倾向', model: '态度模型' },
|
||||
A2: { name: 'A2 规则与灵活度', model: '态度模型' },
|
||||
A3: { name: 'A3 人生意义感', model: '态度模型' },
|
||||
Ac1: { name: 'Ac1 动机导向', model: '行动驱力模型' },
|
||||
Ac2: { name: 'Ac2 决策风格', model: '行动驱力模型' },
|
||||
Ac3: { name: 'Ac3 执行模式', model: '行动驱力模型' },
|
||||
So1: { name: 'So1 社交主动性', model: '社交模型' },
|
||||
So2: { name: 'So2 人际边界感', model: '社交模型' },
|
||||
So3: { name: 'So3 表达与真实度', model: '社交模型' }
|
||||
}
|
||||
};
|
||||
172
miniprogram/utils/sbtiEngine.js
Normal file
172
miniprogram/utils/sbtiEngine.js
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* SBTI 计分与匹配(与 aisbti.com 公开页逻辑一致)
|
||||
* @param {Array<{id:number,dimension:string,question:string,options:Array}>} questions 题库全部行(含 DG1/DG2)
|
||||
* @param {Record<number|string, number>} answers questionId -> 选项 value
|
||||
*/
|
||||
const data = require('./sbtiData.js')
|
||||
|
||||
/** 每维 2 题、每题 1~3 分 → 分和约 2~6;据此划 L/M/H(非「满分 6 分」) */
|
||||
function sumToLevel(score) {
|
||||
if (score <= 3) return 'L'
|
||||
if (score === 4) return 'M'
|
||||
return 'H'
|
||||
}
|
||||
|
||||
function levelNum(level) {
|
||||
return { L: 1, M: 2, H: 3 }[level] || 1
|
||||
}
|
||||
|
||||
function parsePattern(pattern) {
|
||||
return pattern.replace(/-/g, '').split('')
|
||||
}
|
||||
|
||||
function shuffle(arr) {
|
||||
const a = arr.slice()
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
;[a[i], a[j]] = [a[j], a[i]]
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
/**
|
||||
* 组卷:打乱计分题,随机插入爱好闸口;饮酒时出现 DG2(由页面在 DG1=3 时插入)
|
||||
*/
|
||||
function buildShuffledPaper(allQuestions) {
|
||||
const scoring = allQuestions.filter((q) => q.dimension && !['DG1', 'DG2'].includes(q.dimension))
|
||||
const dg1 = allQuestions.find((q) => q.dimension === 'DG1')
|
||||
const dg2 = allQuestions.find((q) => q.dimension === 'DG2')
|
||||
const shuffled = shuffle(scoring)
|
||||
const insertIndex = Math.floor(Math.random() * shuffled.length) + 1
|
||||
const ordered = [...shuffled.slice(0, insertIndex)]
|
||||
if (dg1) ordered.push(dg1)
|
||||
ordered.push(...shuffled.slice(insertIndex))
|
||||
return { ordered, dg1, dg2 }
|
||||
}
|
||||
|
||||
/** 当前可见题序:选「饮酒」后在闸口题后插入 DG2 */
|
||||
function getVisibleQuestions(ordered, answers, dg2) {
|
||||
const visible = ordered.slice()
|
||||
if (!dg2) return visible
|
||||
if (visible.some((q) => q.id === dg2.id)) return visible
|
||||
const gateIdx = visible.findIndex((q) => q.dimension === 'DG1')
|
||||
if (gateIdx !== -1 && Number(answers[visible[gateIdx].id]) === 3) {
|
||||
visible.splice(gateIdx + 1, 0, dg2)
|
||||
}
|
||||
return visible
|
||||
}
|
||||
|
||||
function computeSbtiResult(questions, answers) {
|
||||
const { TYPE_LIBRARY, NORMAL_TYPES, DIM_EXPLANATIONS, dimensionOrder } = data
|
||||
const rawScores = {}
|
||||
dimensionOrder.forEach((dim) => {
|
||||
rawScores[dim] = 0
|
||||
})
|
||||
|
||||
questions.forEach((q) => {
|
||||
const dim = q.dimension
|
||||
if (!dim || dim === 'DG1' || dim === 'DG2') return
|
||||
if (!dimensionOrder.includes(dim)) return
|
||||
const v = answers[q.id]
|
||||
if (v == null || v === '') return
|
||||
rawScores[dim] += Number(v) || 0
|
||||
})
|
||||
|
||||
const levels = {}
|
||||
Object.entries(rawScores).forEach(([dim, score]) => {
|
||||
levels[dim] = sumToLevel(score)
|
||||
})
|
||||
|
||||
const userVector = dimensionOrder.map((dim) => levelNum(levels[dim]))
|
||||
const ranked = NORMAL_TYPES.map((type) => {
|
||||
const vector = parsePattern(type.pattern).map(levelNum)
|
||||
let distance = 0
|
||||
let exact = 0
|
||||
for (let i = 0; i < vector.length; i++) {
|
||||
const diff = Math.abs(userVector[i] - vector[i])
|
||||
distance += diff
|
||||
if (diff === 0) exact += 1
|
||||
}
|
||||
const similarity = Math.max(0, Math.round((1 - distance / 30) * 100))
|
||||
return { ...type, ...TYPE_LIBRARY[type.code], distance, exact, similarity }
|
||||
}).sort((a, b) => {
|
||||
if (a.distance !== b.distance) return a.distance - b.distance
|
||||
if (b.exact !== a.exact) return b.exact - a.exact
|
||||
return b.similarity - a.similarity
|
||||
})
|
||||
|
||||
const bestNormal = ranked[0]
|
||||
let dg2Id = null
|
||||
questions.forEach((q) => {
|
||||
if (q.dimension === 'DG2') dg2Id = q.id
|
||||
})
|
||||
const drunkTriggered = dg2Id != null && Number(answers[dg2Id]) === 2
|
||||
|
||||
let finalType
|
||||
let modeKicker = '你的主类型'
|
||||
let badge = `匹配度 ${bestNormal.similarity}% · 精准命中 ${bestNormal.exact}/15 维`
|
||||
let sub = '维度命中度较高,当前结果可视为你的第一人格画像。'
|
||||
let special = false
|
||||
let secondaryType = null
|
||||
|
||||
if (drunkTriggered) {
|
||||
finalType = { ...TYPE_LIBRARY.DRUNK }
|
||||
secondaryType = bestNormal
|
||||
modeKicker = '隐藏人格已激活'
|
||||
badge = '匹配度 100% · 酒精异常因子已接管'
|
||||
sub = '乙醇亲和性过强,系统已直接跳过常规人格审判。'
|
||||
special = true
|
||||
} else if (bestNormal.similarity < 60) {
|
||||
finalType = { ...TYPE_LIBRARY.HHHH }
|
||||
modeKicker = '系统强制兜底'
|
||||
badge = `标准人格库最高匹配仅 ${bestNormal.similarity}%`
|
||||
sub = '标准人格库对你的脑回路集体罢工了,于是系统把你强制分配给了 HHHH。'
|
||||
special = true
|
||||
} else {
|
||||
finalType = { ...bestNormal }
|
||||
}
|
||||
|
||||
/** 供结果页「匹配度 / 精准命中」展示(与 badge 文案一致) */
|
||||
let matchPercent = bestNormal.similarity
|
||||
let hitDimCount = bestNormal.exact
|
||||
if (drunkTriggered) {
|
||||
matchPercent = 100
|
||||
hitDimCount = 15
|
||||
}
|
||||
|
||||
const dimExplainList = dimensionOrder.map((dim) => ({
|
||||
dim,
|
||||
name: data.dimensionMeta[dim].name,
|
||||
model: data.dimensionMeta[dim].model,
|
||||
level: levels[dim],
|
||||
raw: rawScores[dim],
|
||||
text: DIM_EXPLANATIONS[dim][levels[dim]]
|
||||
}))
|
||||
|
||||
return {
|
||||
rawScores,
|
||||
levels,
|
||||
ranked,
|
||||
bestNormal,
|
||||
finalType,
|
||||
modeKicker,
|
||||
badge,
|
||||
sub,
|
||||
special,
|
||||
secondaryType,
|
||||
dimExplainList,
|
||||
sbtiType: finalType.code,
|
||||
sbtiCn: finalType.cn,
|
||||
intro: finalType.intro,
|
||||
desc: finalType.desc,
|
||||
matchPercent,
|
||||
hitDimCount
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
computeSbtiResult,
|
||||
buildShuffledPaper,
|
||||
getVisibleQuestions,
|
||||
TYPE_IMAGES: data.TYPE_IMAGES
|
||||
}
|
||||
Reference in New Issue
Block a user