去掉协会 WebView 和魔方账密页,微信登录与咨询接口共用同一 token 和拦截器。 Co-authored-by: Cursor <cursoragent@cursor.com>
208 lines
6.9 KiB
Vue
208 lines
6.9 KiB
Vue
<template>
|
|
<yxg-page :title="title || '测评'">
|
|
<text v-if="loadingScale" class="hint">加载中…</text>
|
|
<text v-else-if="loadError" class="err">{{ loadError }}</text>
|
|
<template v-else>
|
|
<view v-if="phase === 'intro'" class="card">
|
|
<text class="h1">{{ title }}</text>
|
|
<text class="desc">{{ description }}</text>
|
|
<text class="meta">约 {{ estMinutes }} 分钟 · {{ questions.length }} 题</text>
|
|
<text v-if="locked" class="err">该测评需开通成长会员</text>
|
|
<view v-if="locked" class="btn" @click="yxgGo('/membership')"><text>去开通</text></view>
|
|
<view v-else class="btn" @click="startTest"><text>{{ draftRestored ? '继续作答' : '开始测评' }}</text></view>
|
|
</view>
|
|
|
|
<view v-else-if="phase === 'answering' && currentQuestion" class="card">
|
|
<text class="prog">{{ currentIdx + 1 }} / {{ questions.length }}</text>
|
|
<view class="bar"><view class="bar-in" :style="{ width: progressPct + '%' }" /></view>
|
|
<text class="prompt">{{ currentQuestion.body.prompt }}</text>
|
|
<view
|
|
v-for="opt in currentQuestion.body.options || []"
|
|
:key="opt.key"
|
|
class="opt"
|
|
:class="{ on: answers[currentQuestion.id] === opt.key }"
|
|
@click="pick(opt.key)"
|
|
>
|
|
<text>{{ opt.text }}</text>
|
|
</view>
|
|
<view class="navs">
|
|
<view v-if="currentIdx > 0" class="btn ghost" @click="currentIdx--"><text>上一题</text></view>
|
|
<view
|
|
class="btn"
|
|
:class="{ off: !answers[currentQuestion.id] || submitting }"
|
|
@click="onNext"
|
|
>
|
|
<text>{{ submitting ? '提交中…' : isLast ? '提交' : '下一题' }}</text>
|
|
</view>
|
|
</view>
|
|
<text v-if="submitError" class="err">{{ submitError }}</text>
|
|
</view>
|
|
|
|
<view v-else-if="phase === 'result'" class="card">
|
|
<text class="h1">{{ resultLabel }}</text>
|
|
<text class="desc">{{ shareLine || resultRaw?.summary || '' }}</text>
|
|
<text v-if="resultRaw?.overview" class="body">{{ resultRaw.overview }}</text>
|
|
<view class="navs">
|
|
<view class="btn ghost" @click="retake"><text>再测一次</text></view>
|
|
<view class="btn" @click="yxgGo('/reports')"><text>我的报告</text></view>
|
|
</view>
|
|
</view>
|
|
</template>
|
|
</yxg-page>
|
|
</template>
|
|
|
|
<script setup>
|
|
import YxgPage from '@/components/yxg/yxg-page.vue'
|
|
import { yxgApi } from '@/util/yxgApi.js'
|
|
import { ensureAccount, findSelfProfile } from '@/util/yxgAuth.js'
|
|
import { yxgGo } from '@/util/yxgNav.js'
|
|
|
|
const DRAFT_KEY = 'yxg_scale_draft_'
|
|
const slug = ref('')
|
|
const title = ref('')
|
|
const description = ref('')
|
|
const questions = ref([])
|
|
const answers = ref({})
|
|
const loadingScale = ref(true)
|
|
const submitting = ref(false)
|
|
const loadError = ref('')
|
|
const submitError = ref('')
|
|
const locked = ref(false)
|
|
const phase = ref('intro')
|
|
const resultLabel = ref('')
|
|
const shareLine = ref('')
|
|
const resultRaw = ref(null)
|
|
const draftRestored = ref(false)
|
|
const currentIdx = ref(0)
|
|
|
|
const currentQuestion = computed(() => questions.value[currentIdx.value] || null)
|
|
const isLast = computed(() => currentIdx.value >= questions.value.length - 1)
|
|
const progressPct = computed(() => {
|
|
if (!questions.value.length) return 0
|
|
return Math.round(((currentIdx.value + 1) / questions.value.length) * 100)
|
|
})
|
|
const estMinutes = computed(() => Math.max(1, Math.ceil(questions.value.length / 3)))
|
|
|
|
function pick(key) {
|
|
answers.value = { ...answers.value, [currentQuestion.value.id]: key }
|
|
uni.setStorageSync(DRAFT_KEY + slug.value, answers.value)
|
|
}
|
|
|
|
function startTest() {
|
|
if (locked.value) return
|
|
phase.value = 'answering'
|
|
if (!draftRestored.value) currentIdx.value = 0
|
|
}
|
|
|
|
function retake() {
|
|
uni.removeStorageSync(DRAFT_KEY + slug.value)
|
|
answers.value = {}
|
|
draftRestored.value = false
|
|
resultRaw.value = null
|
|
currentIdx.value = 0
|
|
phase.value = 'intro'
|
|
}
|
|
|
|
async function onNext() {
|
|
if (!answers.value[currentQuestion.value.id] || submitting.value) return
|
|
if (!isLast.value) {
|
|
currentIdx.value++
|
|
return
|
|
}
|
|
submitting.value = true
|
|
submitError.value = ''
|
|
try {
|
|
const self = await findSelfProfile()
|
|
if (!self) {
|
|
submitError.value = '请先完善自己的生日档案'
|
|
yxgGo('/profile')
|
|
return
|
|
}
|
|
const out = await yxgApi.submitScale(slug.value, self.id, answers.value)
|
|
uni.removeStorageSync(DRAFT_KEY + slug.value)
|
|
resultRaw.value = out.result || out
|
|
resultLabel.value = String(resultRaw.value.label || '探索结果')
|
|
shareLine.value = String(resultRaw.value.share_line || '')
|
|
phase.value = 'result'
|
|
} catch (e) {
|
|
submitError.value = e instanceof Error ? e.message : '提交失败'
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
async function load() {
|
|
loadingScale.value = true
|
|
loadError.value = ''
|
|
if (!(await ensureAccount(`/scales/${slug.value}`))) {
|
|
loadingScale.value = false
|
|
return
|
|
}
|
|
try {
|
|
const d = await yxgApi.getScale(slug.value)
|
|
title.value = d.title
|
|
description.value = d.description
|
|
locked.value = !!d.locked
|
|
questions.value = (d.questions || []).map((q) => ({
|
|
id: q.id,
|
|
body: typeof q.body === 'string' ? JSON.parse(q.body) : q.body,
|
|
}))
|
|
if (locked.value) {
|
|
phase.value = 'intro'
|
|
return
|
|
}
|
|
try {
|
|
const latest = await yxgApi.getScaleResult(slug.value)
|
|
resultRaw.value = latest.result || latest
|
|
resultLabel.value = String(resultRaw.value.label || '探索结果')
|
|
shareLine.value = String(resultRaw.value.share_line || '')
|
|
phase.value = 'result'
|
|
return
|
|
} catch { /* no result */ }
|
|
const draft = uni.getStorageSync(DRAFT_KEY + slug.value)
|
|
if (draft && typeof draft === 'object') {
|
|
answers.value = draft
|
|
draftRestored.value = Object.keys(draft).length > 0
|
|
}
|
|
phase.value = 'intro'
|
|
} catch (e) {
|
|
loadError.value = e instanceof Error ? e.message : '加载失败'
|
|
} finally {
|
|
loadingScale.value = false
|
|
}
|
|
}
|
|
|
|
onLoad((q) => {
|
|
slug.value = q.slug || ''
|
|
load()
|
|
})
|
|
</script>
|
|
|
|
<style>
|
|
page { background-color: #ffd1c7; }
|
|
</style>
|
|
<style scoped>
|
|
.hint, .err { display: block; padding: 16px; font-size: 13px; color: #999; }
|
|
.err { color: #e54d42; }
|
|
.card {
|
|
margin: 12px 16px 0; padding: 18px 16px; background: #fff; border-radius: 18px;
|
|
}
|
|
.h1 { display: block; font-size: 20px; font-weight: 700; color: #333; }
|
|
.desc, .body, .meta { display: block; margin-top: 8px; font-size: 13px; color: #666; line-height: 1.6; }
|
|
.prog { font-size: 12px; color: #999; }
|
|
.bar { height: 6px; background: #f3ece8; border-radius: 99px; margin: 8px 0 14px; overflow: hidden; }
|
|
.bar-in { height: 100%; background: #e54d42; }
|
|
.prompt { display: block; font-size: 16px; font-weight: 650; color: #222; margin-bottom: 12px; line-height: 1.5; }
|
|
.opt {
|
|
padding: 12px; border-radius: 12px; background: #fdfaf8; margin-bottom: 8px; font-size: 14px; color: #333;
|
|
}
|
|
.opt.on { background: #ffe4e4; color: #e54d42; font-weight: 650; }
|
|
.navs { display: flex; gap: 10px; margin-top: 12px; }
|
|
.btn {
|
|
flex: 1; height: 42px; border-radius: 999px; background: #e54d42; color: #fff;
|
|
display: flex; align-items: center; justify-content: center; font-weight: 700;
|
|
}
|
|
.btn.ghost { background: #f7f2ef; color: #8a4a3a; }
|
|
.btn.off { opacity: 0.45; }
|
|
</style>
|