feat(admin): 双邀请太阳码与自动拉取;企业/个人 Invite API;用户头像字段;题库工具与抖音测试页调整

Made-with: Cursor
This commit is contained in:
Ghost
2026-03-30 11:31:48 +08:00
parent de3a3fd637
commit f589b5d50c
6 changed files with 541 additions and 359 deletions

View File

@@ -110,15 +110,24 @@
<div class="panel-head row">
<div>
<h2 class="panel-title">邀请小程序码</h2>
<p class="panel-desc">员工 / 客户扫码进入企业测评</p>
<p class="panel-desc">企业版进企业测评个人版进小程序首页</p>
</div>
<el-button size="small" type="primary" @click="loadInviteQrcode" :loading="inviteLoading">
{{ inviteQrcode ? '刷新' : '生成' }}
{{ inviteQrcodeEnterprise || inviteQrcodePersonal ? '刷新' : '生成' }}
</el-button>
</div>
<div class="invite-body">
<img v-if="inviteQrcode" :src="inviteQrcode" alt="邀请码" class="invite-img" />
<span v-else class="invite-placeholder">点击生成</span>
<template v-if="inviteQrcodeEnterprise || inviteQrcodePersonal">
<div v-if="inviteQrcodeEnterprise" class="invite-card">
<span class="invite-label">企业版</span>
<img :src="inviteQrcodeEnterprise" alt="企业版太阳码" class="invite-img" />
</div>
<div v-if="inviteQrcodePersonal" class="invite-card">
<span class="invite-label">个人版</span>
<img :src="inviteQrcodePersonal" alt="个人版太阳码" class="invite-img" />
</div>
</template>
<span v-else class="invite-placeholder">{{ inviteLoadError || '进入页面将自动加载,也可点击生成' }}</span>
</div>
</div>
</aside>
@@ -189,7 +198,9 @@ const faceSubtypeHints = ref<{
const loading = ref(false)
const inviteLoading = ref(false)
const inviteQrcode = ref<string>('')
const inviteQrcodeEnterprise = ref<string>('')
const inviteQrcodePersonal = ref<string>('')
const inviteLoadError = ref<string>('')
/** 侧栏表格最大高度:单屏内滚动,不撑开整页 */
const tableMaxH = 220
@@ -381,21 +392,32 @@ const loadData = async () => {
onMounted(() => {
loadData()
loadInviteQrcode()
})
const loadInviteQrcode = async () => {
if (inviteLoading.value) return
inviteLoading.value = true
inviteLoadError.value = ''
try {
const res: any = await request.get('/admin/invite/qrcode')
const qrcode = res?.data?.qrcode
if (qrcode && typeof qrcode === 'string') {
inviteQrcode.value = qrcode
} else {
ElMessage.error(res?.message || res?.msg || '生成失败,请确认企业绑定')
const d = res?.data
const ent = d?.enterprise?.qrcode ?? d?.qrcode
const per = d?.personal?.qrcode
if (typeof ent === 'string' && ent) inviteQrcodeEnterprise.value = ent
else inviteQrcodeEnterprise.value = ''
if (typeof per === 'string' && per) inviteQrcodePersonal.value = per
else inviteQrcodePersonal.value = ''
if (!inviteQrcodeEnterprise.value && !inviteQrcodePersonal.value) {
const msg = res?.message || res?.msg || '生成失败,请确认企业绑定'
inviteLoadError.value = msg
ElMessage.error(msg)
}
} catch (error: any) {
ElMessage.error(error?.message || '生成失败')
const msg = error?.message || '生成失败'
inviteLoadError.value = msg
ElMessage.error(msg)
} finally {
inviteLoading.value = false
}
@@ -807,11 +829,26 @@ const loadInviteQrcode = async () => {
.invite-body {
display: flex;
align-items: center;
flex-wrap: wrap;
align-items: flex-start;
justify-content: center;
gap: 16px;
min-height: 112px;
}
.invite-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.invite-label {
font-size: 12px;
font-weight: 600;
color: #374151;
}
.invite-img {
width: 112px;
height: 112px;

View File

@@ -35,7 +35,20 @@
<el-table-column label="用户信息" min-width="180">
<template #default="{ row }">
<div class="user-info-cell">
<div class="user-avatar">{{ (row.username || '?')[0] }}</div>
<img
v-if="displayAvatarUrl(row.avatar)"
:src="displayAvatarUrl(row.avatar)"
class="user-avatar user-avatar-img"
referrerpolicy="no-referrer"
alt=""
/>
<div
v-else
class="user-avatar user-avatar-letter"
:style="{ backgroundColor: avatarBgColor(row) }"
>
{{ avatarLetter(row) }}
</div>
<div class="user-details">
<div class="username">{{ row.username || '未设置昵称' }}</div>
<div class="openid-line">{{ row.openid || '—' }}</div>
@@ -685,6 +698,27 @@ async function loadUsers() {
}
}
/** 有效头像地址(接口字段 avatar过滤空白 */
function displayAvatarUrl(avatar: string | null | undefined) {
const u = avatar != null ? String(avatar).trim() : ''
return u || ''
}
/** 无头像时文字占位:昵称首字 */
function avatarLetter(row: { username?: string; nickname?: string }) {
const name = (row?.username || row?.nickname || '?').trim()
const ch = name.charAt(0) || '?'
return /[a-zA-Z]/.test(ch) ? ch.toUpperCase() : ch
}
const AVATAR_PALETTE = ['#6366f1', '#8b5cf6', '#ec4899', '#f43f5e', '#14b8a6', '#0ea5e9', '#3b82f6', '#eab308']
function avatarBgColor(row: { username?: string; nickname?: string }) {
const name = (row?.username || row?.nickname || '?').trim()
let hash = 0
for (let i = 0; i < name.length; i++) hash += name.charCodeAt(i)
return AVATAR_PALETTE[Math.abs(hash) % AVATAR_PALETTE.length]
}
function formatPhone(phone: string) {
if (!phone) return '-'
if (phone.length === 11) return phone.substring(0, 3) + '****' + phone.substring(7)
@@ -1072,6 +1106,16 @@ onMounted(() => {
width: 36px;
height: 36px;
border-radius: 50%;
flex-shrink: 0;
}
.user-avatar-img {
object-fit: cover;
border: 1px solid #e5e7eb;
background: #f3f4f6;
}
.user-avatar-letter {
background-color: #7c3aed;
color: #fff;
display: flex;
@@ -1079,7 +1123,6 @@ onMounted(() => {
justify-content: center;
font-size: 16px;
font-weight: 600;
flex-shrink: 0;
}
.user-details {

View File

@@ -6,16 +6,15 @@ use app\common\service\WechatService;
use think\facade\Db;
/**
* 管理端 - 小程序邀请二维码(企业参数
* 管理端 - 小程序邀请二维码(企业版 + 个人版
*/
class Invite extends BaseController
{
/**
* 生成专属邀请小程序码scene 带企业 ID,扫码进入 pages/enterprise/index 可解析
* 同时生成企业测评码e_企业ID → enterprise 页与个人首页码scene=p → index
* GET /api/v1/admin/invite/qrcode
* 可选:?enterpriseId=1 仅普通管理员指定企业时传;企业管理员用自身 enterpriseId
*
* 返回 data:image/png;base64,... 形式的图片地址
* 兼容旧前端:仍返回顶层 qrcode / scene / page 指向企业版。
*/
public function qrcode()
{
@@ -38,26 +37,47 @@ class Invite extends BaseController
return error('无法确定企业,仅企业管理员或指定 enterpriseId 可生成邀请码', 400);
}
// 场景值e_企业ID小程序 onLoad(options.scene) 可解析
$scene = 'e_' . $enterpriseId;
$page = 'pages/enterprise/index';
$sceneEnterprise = 'e_' . $enterpriseId;
$pageEnterprise = 'pages/enterprise/index';
$result = WechatService::getWxacodeUnlimited($scene, $page, 430);
if (isset($result['errcode'])) {
return error('获取小程序码失败:' . ($result['errmsg'] ?? ''), 500);
$resultEnt = WechatService::getWxacodeUnlimited($sceneEnterprise, $pageEnterprise, 430);
if (isset($resultEnt['errcode'])) {
return error('企业版小程序码失败:' . ($resultEnt['errmsg'] ?? ''), 500);
}
$binary = $result['binary'] ?? '';
if ($binary === '') {
return error('小程序码生成失败', 500);
$binEnt = $resultEnt['binary'] ?? '';
if ($binEnt === '') {
return error('企业版小程序码生成失败', 500);
}
$b64Ent = 'data:image/png;base64,' . base64_encode($binEnt);
$base64 = 'data:image/png;base64,' . base64_encode($binary);
$scenePersonal = 'p';
$pagePersonal = 'pages/index/index';
$resultPer = WechatService::getWxacodeUnlimited($scenePersonal, $pagePersonal, 430);
if (isset($resultPer['errcode'])) {
return error('个人版小程序码失败:' . ($resultPer['errmsg'] ?? ''), 500);
}
$binPer = $resultPer['binary'] ?? '';
if ($binPer === '') {
return error('个人版小程序码生成失败', 500);
}
$b64Per = 'data:image/png;base64,' . base64_encode($binPer);
return success([
'qrcode' => $base64,
'scene' => $scene,
'page' => $page,
'qrcode' => $b64Ent,
'scene' => $sceneEnterprise,
'page' => $pageEnterprise,
'enterprise' => [
'qrcode' => $b64Ent,
'scene' => $sceneEnterprise,
'page' => $pageEnterprise,
'label' => '企业版',
],
'personal' => [
'qrcode' => $b64Per,
'scene' => $scenePersonal,
'page' => $pagePersonal,
'label' => '个人版',
],
]);
}
}

View File

@@ -1,162 +1,162 @@
// pages/test/disc.js
const { discQuestions, shuffleQuestions } = require('../../utils/questions')
const { discDescriptions } = require('../../utils/descriptions')
const app = getApp()
Page({
data: {
questions: [],
currentIndex: 0,
currentQuestion: null,
answers: {},
selectedAnswer: null,
total: discQuestions.length,
answeredCount: 0,
progress: 0,
timeRemaining: 15 * 60,
formatTime: '15:00',
isSubmitting: false
},
timer: null,
onLoad() {
const questions = shuffleQuestions(discQuestions)
this.setData({ questions, currentQuestion: questions[0] })
try { require('../../utils/analytics').track('test_start', { type: 'disc', total: questions.length }) } catch (e) {}
this.startTimer()
},
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()
return
}
const minutes = Math.floor(time / 60)
const seconds = time % 60
this.setData({
timeRemaining: time,
formatTime: `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
})
}, 1000)
},
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 tot = this.data.total
const answers = { ...this.data.answers }
answers[questionId] = value
const answeredCount = Object.keys(answers).length
this.setData(
{
selectedAnswer: value,
answers,
answeredCount,
progress: tot ? (answeredCount / tot) * 100 : 0
},
() => {
this._advanceTimer = setTimeout(() => {
this._advanceTimer = null
const d = this.data
if (d.currentIndex !== idx || !d.currentQuestion || d.currentQuestion.id !== questionId) return
if (idx < tot - 1) {
this.nextQuestion()
} else {
this.submitTest()
}
}, 300)
}
)
},
prevQuestion() {
if (this._advanceTimer) {
clearTimeout(this._advanceTimer)
this._advanceTimer = null
}
if (this.data.currentIndex > 0) {
const newIndex = this.data.currentIndex - 1
const newQuestion = this.data.questions[newIndex]
this.setData({
currentIndex: newIndex,
currentQuestion: newQuestion,
selectedAnswer: this.data.answers[newQuestion.id] || null
})
}
},
nextQuestion() {
if (this.data.currentIndex < this.data.total - 1) {
const newIndex = this.data.currentIndex + 1
const newQuestion = this.data.questions[newIndex]
this.setData({
currentIndex: newIndex,
currentQuestion: newQuestion,
selectedAnswer: this.data.answers[newQuestion.id] || null
})
}
},
submitTest() {
if (this.data.isSubmitting) return
this.setData({ isSubmitting: true })
const scores = { D: 0, I: 0, S: 0, C: 0 }
Object.values(this.data.answers).forEach(value => {
if (scores.hasOwnProperty(value)) scores[value]++
})
const total = Object.values(scores).reduce((sum, v) => sum + v, 0)
const percentages = {
D: Math.round((scores.D / total) * 100),
I: Math.round((scores.I / total) * 100),
S: Math.round((scores.S / total) * 100),
C: Math.round((scores.C / total) * 100)
}
const dominantType = Object.entries(scores).sort((a, b) => b[1] - a[1])[0][0]
const secondaryType = Object.entries(scores).sort((a, b) => b[1] - a[1])[1][0]
const resultData = {
scores,
percentages,
dominantType,
secondaryType,
description: discDescriptions[dominantType],
testDuration: 15 * 60 - this.data.timeRemaining,
completedAt: new Date().toISOString(),
// 便于后端留存完整答题过程
answers: this.data.answers
}
// 本地缓存 + 全局缓存
tt.setStorageSync('discResult', resultData)
try { require('../../utils/analytics').track('test_complete', { type: 'disc', result: dominantType, duration: resultData.testDuration }) } catch (e) {}
if (app && typeof app.saveTestResult === 'function') {
app.saveTestResult('disc', resultData)
}
tt.redirectTo({ url: '/pages/result/disc' })
}
})
// pages/test/disc.js
const { discQuestions, shuffleQuestions } = require('../../utils/questions')
const { discDescriptions } = require('../../utils/descriptions')
const app = getApp()
Page({
data: {
questions: [],
currentIndex: 0,
currentQuestion: null,
answers: {},
selectedAnswer: null,
total: discQuestions.length,
answeredCount: 0,
progress: 0,
timeRemaining: 15 * 60,
formatTime: '15:00',
isSubmitting: false
},
timer: null,
onLoad() {
const questions = shuffleQuestions(discQuestions)
this.setData({ questions, currentQuestion: questions[0] })
try { require('../../utils/analytics').track('test_start', { type: 'disc', total: questions.length }) } catch (e) {}
this.startTimer()
},
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()
return
}
const minutes = Math.floor(time / 60)
const seconds = time % 60
this.setData({
timeRemaining: time,
formatTime: `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
})
}, 1000)
},
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 tot = this.data.total
const answers = { ...this.data.answers }
answers[questionId] = value
const answeredCount = Object.keys(answers).length
this.setData(
{
selectedAnswer: value,
answers,
answeredCount,
progress: tot ? (answeredCount / tot) * 100 : 0
},
() => {
this._advanceTimer = setTimeout(() => {
this._advanceTimer = null
const d = this.data
if (d.currentIndex !== idx || !d.currentQuestion || d.currentQuestion.id !== questionId) return
if (idx < tot - 1) {
this.nextQuestion()
} else {
this.submitTest()
}
}, 300)
}
)
},
prevQuestion() {
if (this._advanceTimer) {
clearTimeout(this._advanceTimer)
this._advanceTimer = null
}
if (this.data.currentIndex > 0) {
const newIndex = this.data.currentIndex - 1
const newQuestion = this.data.questions[newIndex]
this.setData({
currentIndex: newIndex,
currentQuestion: newQuestion,
selectedAnswer: this.data.answers[newQuestion.id] || null
})
}
},
nextQuestion() {
if (this.data.currentIndex < this.data.total - 1) {
const newIndex = this.data.currentIndex + 1
const newQuestion = this.data.questions[newIndex]
this.setData({
currentIndex: newIndex,
currentQuestion: newQuestion,
selectedAnswer: this.data.answers[newQuestion.id] || null
})
}
},
submitTest() {
if (this.data.isSubmitting) return
this.setData({ isSubmitting: true })
const scores = { D: 0, I: 0, S: 0, C: 0 }
Object.values(this.data.answers).forEach(value => {
if (scores.hasOwnProperty(value)) scores[value]++
})
const total = Object.values(scores).reduce((sum, v) => sum + v, 0)
const percentages = {
D: Math.round((scores.D / total) * 100),
I: Math.round((scores.I / total) * 100),
S: Math.round((scores.S / total) * 100),
C: Math.round((scores.C / total) * 100)
}
const dominantType = Object.entries(scores).sort((a, b) => b[1] - a[1])[0][0]
const secondaryType = Object.entries(scores).sort((a, b) => b[1] - a[1])[1][0]
const resultData = {
scores,
percentages,
dominantType,
secondaryType,
description: discDescriptions[dominantType],
testDuration: 15 * 60 - this.data.timeRemaining,
completedAt: new Date().toISOString(),
// 便于后端留存完整答题过程
answers: this.data.answers
}
// 本地缓存 + 全局缓存
tt.setStorageSync('discResult', resultData)
try { require('../../utils/analytics').track('test_complete', { type: 'disc', result: dominantType, duration: resultData.testDuration }) } catch (e) {}
if (app && typeof app.saveTestResult === 'function') {
app.saveTestResult('disc', resultData)
}
tt.redirectTo({ url: '/pages/result/disc' })
}
})

View File

@@ -1,165 +1,165 @@
// pages/test/pdp.js
const { pdpQuestions, shuffleQuestions } = require('../../utils/questions')
const { pdpDescriptions } = require('../../utils/descriptions')
const app = getApp()
Page({
data: {
questions: [],
currentIndex: 0,
currentQuestion: null,
answers: {},
selectedAnswer: null,
total: pdpQuestions.length,
answeredCount: 0,
progress: 0,
timeRemaining: 15 * 60,
formatTime: '15:00',
isSubmitting: false
},
timer: null,
onLoad() {
const questions = shuffleQuestions(pdpQuestions)
this.setData({ questions, currentQuestion: questions[0] })
try { require('../../utils/analytics').track('test_start', { type: 'pdp', total: questions.length }) } catch (e) {}
this.startTimer()
},
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()
return
}
const minutes = Math.floor(time / 60)
const seconds = time % 60
this.setData({
timeRemaining: time,
formatTime: `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
})
}, 1000)
},
getEmoji(value) {
const emojis = { Tiger: '🐅', Peacock: '🦚', Koala: '🐨', Owl: '🦉', Chameleon: '🦎' }
return emojis[value] || '🔹'
},
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 tot = this.data.total
const answers = { ...this.data.answers }
answers[questionId] = value
const answeredCount = Object.keys(answers).length
this.setData(
{
selectedAnswer: value,
answers,
answeredCount,
progress: tot ? (answeredCount / tot) * 100 : 0
},
() => {
this._advanceTimer = setTimeout(() => {
this._advanceTimer = null
const d = this.data
if (d.currentIndex !== idx || !d.currentQuestion || d.currentQuestion.id !== questionId) return
if (idx < tot - 1) {
this.nextQuestion()
} else {
this.submitTest()
}
}, 300)
}
)
},
prevQuestion() {
if (this._advanceTimer) {
clearTimeout(this._advanceTimer)
this._advanceTimer = null
}
if (this.data.currentIndex > 0) {
const newIndex = this.data.currentIndex - 1
const newQuestion = this.data.questions[newIndex]
this.setData({
currentIndex: newIndex,
currentQuestion: newQuestion,
selectedAnswer: this.data.answers[newQuestion.id] || null
})
}
},
nextQuestion() {
if (this.data.currentIndex < this.data.total - 1) {
const newIndex = this.data.currentIndex + 1
const newQuestion = this.data.questions[newIndex]
this.setData({
currentIndex: newIndex,
currentQuestion: newQuestion,
selectedAnswer: this.data.answers[newQuestion.id] || null
})
}
},
submitTest() {
if (this.data.isSubmitting) return
this.setData({ isSubmitting: true })
const scores = { Tiger: 0, Peacock: 0, Koala: 0, Owl: 0, Chameleon: 0 }
Object.values(this.data.answers).forEach(value => {
if (scores.hasOwnProperty(value)) scores[value]++
})
const total = Object.values(scores).reduce((sum, v) => sum + v, 0)
const percentages = {}
Object.keys(scores).forEach(key => {
percentages[key] = Math.round((scores[key] / total) * 100)
})
const dominantType = Object.entries(scores).sort((a, b) => b[1] - a[1])[0][0]
const secondaryType = Object.entries(scores).sort((a, b) => b[1] - a[1])[1][0]
const resultData = {
scores,
percentages,
dominantType,
secondaryType,
description: pdpDescriptions[dominantType],
testDuration: 15 * 60 - this.data.timeRemaining,
completedAt: new Date().toISOString(),
// 便于后端留存完整答题过程
answers: this.data.answers
}
// 本地缓存 + 全局缓存
tt.setStorageSync('pdpResult', resultData)
try { require('../../utils/analytics').track('test_complete', { type: 'pdp', result: dominantType, duration: resultData.testDuration }) } catch (e) {}
if (app && typeof app.saveTestResult === 'function') {
app.saveTestResult('pdp', resultData)
}
tt.redirectTo({ url: '/pages/result/pdp' })
}
})
// pages/test/pdp.js
const { pdpQuestions, shuffleQuestions } = require('../../utils/questions')
const { pdpDescriptions } = require('../../utils/descriptions')
const app = getApp()
Page({
data: {
questions: [],
currentIndex: 0,
currentQuestion: null,
answers: {},
selectedAnswer: null,
total: pdpQuestions.length,
answeredCount: 0,
progress: 0,
timeRemaining: 15 * 60,
formatTime: '15:00',
isSubmitting: false
},
timer: null,
onLoad() {
const questions = shuffleQuestions(pdpQuestions)
this.setData({ questions, currentQuestion: questions[0] })
try { require('../../utils/analytics').track('test_start', { type: 'pdp', total: questions.length }) } catch (e) {}
this.startTimer()
},
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()
return
}
const minutes = Math.floor(time / 60)
const seconds = time % 60
this.setData({
timeRemaining: time,
formatTime: `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
})
}, 1000)
},
getEmoji(value) {
const emojis = { Tiger: '🐅', Peacock: '🦚', Koala: '🐨', Owl: '🦉', Chameleon: '🦎' }
return emojis[value] || '🔹'
},
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 tot = this.data.total
const answers = { ...this.data.answers }
answers[questionId] = value
const answeredCount = Object.keys(answers).length
this.setData(
{
selectedAnswer: value,
answers,
answeredCount,
progress: tot ? (answeredCount / tot) * 100 : 0
},
() => {
this._advanceTimer = setTimeout(() => {
this._advanceTimer = null
const d = this.data
if (d.currentIndex !== idx || !d.currentQuestion || d.currentQuestion.id !== questionId) return
if (idx < tot - 1) {
this.nextQuestion()
} else {
this.submitTest()
}
}, 300)
}
)
},
prevQuestion() {
if (this._advanceTimer) {
clearTimeout(this._advanceTimer)
this._advanceTimer = null
}
if (this.data.currentIndex > 0) {
const newIndex = this.data.currentIndex - 1
const newQuestion = this.data.questions[newIndex]
this.setData({
currentIndex: newIndex,
currentQuestion: newQuestion,
selectedAnswer: this.data.answers[newQuestion.id] || null
})
}
},
nextQuestion() {
if (this.data.currentIndex < this.data.total - 1) {
const newIndex = this.data.currentIndex + 1
const newQuestion = this.data.questions[newIndex]
this.setData({
currentIndex: newIndex,
currentQuestion: newQuestion,
selectedAnswer: this.data.answers[newQuestion.id] || null
})
}
},
submitTest() {
if (this.data.isSubmitting) return
this.setData({ isSubmitting: true })
const scores = { Tiger: 0, Peacock: 0, Koala: 0, Owl: 0, Chameleon: 0 }
Object.values(this.data.answers).forEach(value => {
if (scores.hasOwnProperty(value)) scores[value]++
})
const total = Object.values(scores).reduce((sum, v) => sum + v, 0)
const percentages = {}
Object.keys(scores).forEach(key => {
percentages[key] = Math.round((scores[key] / total) * 100)
})
const dominantType = Object.entries(scores).sort((a, b) => b[1] - a[1])[0][0]
const secondaryType = Object.entries(scores).sort((a, b) => b[1] - a[1])[1][0]
const resultData = {
scores,
percentages,
dominantType,
secondaryType,
description: pdpDescriptions[dominantType],
testDuration: 15 * 60 - this.data.timeRemaining,
completedAt: new Date().toISOString(),
// 便于后端留存完整答题过程
answers: this.data.answers
}
// 本地缓存 + 全局缓存
tt.setStorageSync('pdpResult', resultData)
try { require('../../utils/analytics').track('test_complete', { type: 'pdp', result: dominantType, duration: resultData.testDuration }) } catch (e) {}
if (app && typeof app.saveTestResult === 'function') {
app.saveTestResult('pdp', resultData)
}
tt.redirectTo({ url: '/pages/result/pdp' })
}
})

View File

@@ -0,0 +1,82 @@
/**
* 从服务端拉取启用题库,失败或为空时回落本地 questions.js顺序由 shuffleQuestions 随机。
*/
const { requestPromise } = require('./request')
const { shuffleQuestions } = require('./questions')
function getAppSafe() {
try {
return getApp()
} catch (e) {
return null
}
}
/** 与 runtime 一致:>0 时在乱序后截取前 N 题 */
function getTestQuestionDrawCount() {
const app = getAppSafe()
const n = app && app.globalData && app.globalData.testQuestionDrawCount
const num = parseInt(String(n == null || n === '' ? '0' : n), 10)
if (!Number.isFinite(num) || num <= 0) {
return 0
}
return Math.min(500, num)
}
function applyDrawCountAfterShuffle(questions) {
const n = getTestQuestionDrawCount()
if (!n || questions.length <= n) {
return questions
}
return questions.slice(0, n)
}
/**
* @param {'mbti'|'disc'|'pdp'} type
* @param {number|null|undefined} enterpriseId
* @returns {Promise<Array>}
*/
function fetchQuestionBank(type, enterpriseId) {
const q = [`type=${encodeURIComponent(type)}`]
if (enterpriseId != null && Number(enterpriseId) > 0) {
q.push(`enterpriseId=${Number(enterpriseId)}`)
}
return requestPromise({
url: `/api/test/questions?${q.join('&')}`,
method: 'GET',
needAuth: true
}).then((res) => {
const body = res.data || {}
if (body.code !== 200 || body.data == null) {
throw new Error(body.message || '拉取题库失败')
}
const list = body.data.list
if (!Array.isArray(list)) {
throw new Error('题库格式错误')
}
return list
})
}
/**
* @param {'mbti'|'disc'|'pdp'} type
* @param {Array} localQuestions
* @param {{ enterpriseId?: number|null }} opts
* @returns {Promise<Array>}
*/
function loadQuestionsWithFallback(type, localQuestions, opts = {}) {
const { enterpriseId } = opts
return fetchQuestionBank(type, enterpriseId)
.then((list) => {
if (!list.length) {
return applyDrawCountAfterShuffle(shuffleQuestions(localQuestions))
}
return shuffleQuestions(list)
})
.catch(() => applyDrawCountAfterShuffle(shuffleQuestions(localQuestions)))
}
module.exports = {
fetchQuestionBank,
loadQuestionsWithFallback
}