feat(admin): 双邀请太阳码与自动拉取;企业/个人 Invite API;用户头像字段;题库工具与抖音测试页调整
Made-with: Cursor
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' => '个人版',
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
})
|
||||
|
||||
82
miniprogram/utils/questionBank.js
Normal file
82
miniprogram/utils/questionBank.js
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user