feat: SBTI 测评接入与管理端题目/财务/用户联动

1、修复了用户详情、定价与题目管理、财务与分销等管理端展示与接口问题。

2、新增 SBTI 小程序测评/结果页、sbtiData 引擎、题库 SQL 与脚本;admin sbtiDisplay。

3、优化 AppConfig/CrmReport/Test、历史与个人中心、test-select 与支付相关逻辑。

Made-with: Cursor
This commit is contained in:
Ghost
2026-04-11 16:16:32 +08:00
parent 1a66e77ab7
commit 6fec2965aa
50 changed files with 4189 additions and 1259 deletions

View File

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

View 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 维雷达数值0100
* 优先 `rawScores`(每维两题分和约 26否则用 `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
}

View File

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

View File

@@ -3,7 +3,7 @@
<div v-if="!embedded" class="page-header">
<div class="header-left">
<h2>题库管理</h2>
<p class="subtitle">管理 MBTIDISCPDP 套测试题库的导入导出和验证</p>
<p class="subtitle">管理 MBTISBTIDISCPDP 套测试题库的导入导出和验证</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 数组每题包含 idquestionoptions 字段MBTI 还需包含 dimension 字段EI/SN/TF/JP</p>
<p class="res-tip">提示导入格式为 JSON 数组每题包含 typequestionoptions 字段MBTI dimensionEI/SN/TF/JPSBTI dimension S1DG1DG2 </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;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
<div v-if="!embedded" class="page-header">
<div class="header-left">
<h2>题库管理</h2>
<p class="subtitle">管理全局题库包括 MBTIDISCPDP 套测试题库</p>
<p class="subtitle">管理全局题库包括 MBTISBTIDISCPDP 套测试题库</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 数组每题包含 idquestionoptions 字段MBTI 还需包含 dimension 字段EI/SN/TF/JP</p>
<p class="res-tip">提示导入格式为 JSON 数组每题包含 idquestionoptions 字段MBTI dimensionEI/SN/TF/JPSBTI dimension S1DG1DG2 </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;
}
}

View File

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

View File

@@ -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');

View File

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

View File

@@ -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();
}
}

View File

@@ -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(), '状态更新成功');
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = [];

View File

@@ -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']);
}
}

View File

@@ -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');

View File

@@ -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(), '状态更新成功');
}
}

View File

@@ -58,6 +58,7 @@ class Enterprise extends Model
return [
'face' => true,
'mbti' => true,
'sbti' => true,
'pdp' => true,
'disc' => true,
'distribution' => true,

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

View File

@@ -0,0 +1,210 @@
/**
* 生成 SBTI 题库 INSERT SQLmbti_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);

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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` })

View File

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

View File

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

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

View File

@@ -0,0 +1,7 @@
{
"navigationBarTitleText": "SBTI结果",
"navigationBarBackgroundColor": "#f2f7f3",
"navigationBarTextStyle": "black",
"backgroundColor": "#f2f7f3",
"usingComponents": {}
}

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

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

View File

@@ -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' })
},

View File

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

View File

@@ -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%);
}

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

View File

@@ -0,0 +1,7 @@
{
"navigationBarTitleText": "SBTI测试",
"navigationBarBackgroundColor": "#f2f7f3",
"navigationBarTextStyle": "black",
"backgroundColor": "#f2f7f3",
"usingComponents": {}
}

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

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

View File

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

View File

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

View File

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

View 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: '社交模型' }
}
};

View 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 题、每题 13 分 → 分和约 26据此划 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
}