Files
yuxingu-Miniprogram-dev/pages/yxg/scale.vue
T
jackyu66gitandCursor b09d82df8d feat: 小程序统一走 Go 后台,咨询与登录切到 /api/v1
去掉协会 WebView 和魔方账密页,微信登录与咨询接口共用同一 token 和拦截器。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 00:26:28 +08:00

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>