feat: 高考志愿与定价分销链路更新(管理端/API/小程序)

- 新增高考志愿核心服务、模型、控制器与迁移脚本

- 同步管理端定价/分销/设置与小程序入口、历史、测试选择和支付逻辑

- 补充相关开发文档

Made-with: Cursor
This commit is contained in:
Ghost
2026-04-25 16:19:08 +08:00
parent 513093a5e0
commit c4ac0f8a08
56 changed files with 6100 additions and 68 deletions

View File

@@ -0,0 +1,387 @@
const gaokaoApi = require('../../utils/gaokao')
/** 科类/选科备选项:首项为占位,不可作为有效保存值 */
const SUBJECT_PLACEHOLDER = '请选择科类/选科'
const SUBJECT_CHOICES = [
SUBJECT_PLACEHOLDER,
'文科',
'理科',
'物化生',
'物化地',
'物化政',
'物生地',
'物生政',
'物政地',
'化生地',
'化政地',
'生政地',
'史政地',
'史化政',
'史化生',
'物化技',
'物生技',
'史地技',
'艺术类(物理向)',
'艺术类(历史向)',
'体育类(物理向)',
'体育类(历史向)',
'中职/对口/单招'
]
/** 在意向专业中展示/保存的选项:首项表示不填 */
const MAJOR_PLACEHOLDER = '(可选)不填'
const MAJOR_CHOICES = [
MAJOR_PLACEHOLDER,
'哲学',
'经济学 / 金融',
'法学',
'教育学 / 师范',
'文学',
'外语 / 新传',
'理学',
'工学 / 工程',
'计算机 / 软件 / 人工智能',
'电子 / 通信 / 信息',
'医学 / 临床 / 公卫 / 中医',
'农学 / 林学 / 生科',
'历史学',
'管理学 / 商学',
'艺术学',
'交叉学科 / 暂未确定'
]
/** 将微信 region 结果格式化为只到「市」的文案(不含区) */
function regionToCityText(v) {
if (!v || !v.length) return ''
const p = (v[0] || '').trim()
const c = (v[1] || '').trim()
if (p && c) return p + ' ' + c
return p || c
}
/** 展示用:只取省+市,直辖市 p===c 时只显示一个 */
function formatRegionLine(v) {
if (!v || !v.length) return ''
const p = (v[0] || '').trim()
const c = (v[1] || '').trim()
if (!p && !c) return ''
if (c && c !== p) return p + ' · ' + c
return p || c
}
/** 从 bindchange 取 value部分环境 detail 结构异常时兜底) */
function regionValueFromEvent(e) {
const d = (e && e.detail) || {}
let v = d.value
if (Array.isArray(v) && v.length) return v
return []
}
Page({
data: {
form: {
name: '',
province: '',
/** 高考生源地所在市(与省同一套 region 选择结果,用于展示/扩展) */
city: '',
streamOrSubjects: '',
/** 与微信 region picker 联动level=city 时为 [省,市];老数据或省级可能为 3 项 */
region: [],
/**
* 意向地区:仅省为 [p];老数据 可能 为 [p,c];与 preferredRegions 对应
*/
preferredRegion: [],
estimatedScore: '',
scoreText: '',
wishListText: '',
preferredRegions: '',
preferredFields: ''
},
/** 高考生源:与 level=city 一致,为 [省,市] */
regionPickerValue: [],
/** 意向:只省 */
intendedProvPicker: [],
/** 意向:省+市 */
intendedCityPicker: [],
/** 0=只到省份 1=到省+市 */
intendedModeOptions: ['只到省份', '到省+市'],
intendedModeIndex: 1,
intendedModeLine: '到省+市',
/** 地区展示文案(不依赖 wxml 里对 length 的比较,避免真机/模拟器不渲染) */
regionLine: '',
intendedRegionLine: '',
subjectOptions: SUBJECT_CHOICES,
streamOrSubjectsIndex: 0,
majorOptions: MAJOR_CHOICES,
preferredFieldsIndex: 0,
saving: false
},
/** 每次页面展示拉取(含从上级页返回),避免栈内页面不触发 onLoad 时看不到已保存内容 */
onShow() {
this.loadFormFromServer()
},
loadFormFromServer() {
gaokaoApi
.getForm()
.then((res) => {
const form = res.form || {}
const pr = (() => {
const a = form.preferredRegion
if (!Array.isArray(a) || a.length < 1) return []
return a
})()
const baseForm = {
...this.data.form,
...form,
city: (form.city != null && form.city !== '') ? String(form.city) : (this.data.form.city || ''),
region: (() => {
const a = form.region
if (!Array.isArray(a) || a.length < 2) return []
return a
})(),
preferredRegion: pr,
preferredRegions: (() => {
if (pr && pr.length >= 2) {
return regionToCityText([pr[0], pr[1]])
}
if (pr && pr.length === 1) {
return (pr[0] || '').trim()
}
return form.preferredRegions != null ? String(form.preferredRegions) : ''
})(),
estimatedScore: form.estimatedScore != null ? String(form.estimatedScore) : ''
}
const rpv = baseForm.region
if (rpv && rpv.length >= 2) {
baseForm.province = rpv[0] || baseForm.province
baseForm.city = rpv[1] || baseForm.city
}
const rForPicker = (() => {
if (Array.isArray(baseForm.region) && baseForm.region.length >= 2) {
const a0 = (baseForm.region[0] || '').trim()
const a1 = (baseForm.region[1] || a0).trim()
return [a0, a1]
}
if (baseForm.province) {
const a0 = String(baseForm.province).trim()
const a1 = (baseForm.city && String(baseForm.city).trim()) || a0
return [a0, a1]
}
return []
})()
const regionLine = rForPicker.length
? formatRegionLine(rForPicker)
: ''
const intendedModeOptions = this.data.intendedModeOptions
const modeIdx = (() => {
if (pr && pr.length >= 2) return 1
if (pr && pr.length === 1) return 0
return 1
})()
const intendedModeLine = intendedModeOptions[modeIdx] || '到省+市'
const intendedProvPicker = (() => {
if (pr && pr.length >= 1) return [(String(pr[0] || '')).trim()]
return []
})()
const intendedCityPicker = (() => {
if (pr && pr.length >= 2) {
return [
(String(pr[0] || '')).trim(),
(String(pr[1] || '')).trim()
]
}
if (pr && pr.length === 1 && modeIdx === 1) {
const p0 = (String(pr[0] || '')).trim()
return p0 ? [p0, p0] : []
}
return []
})()
let intendedRegionLine = (() => {
if (pr && pr.length >= 2) {
return formatRegionLine([(pr[0] || '').trim(), (pr[1] || '').trim()])
}
if (pr && pr.length === 1) {
return (pr[0] || '').trim()
}
if (form.preferredRegions) return String(form.preferredRegions)
return ''
})()
const stream = baseForm.streamOrSubjects || ''
const opts = (() => {
if (stream && SUBJECT_CHOICES.indexOf(stream) < 0) {
return [SUBJECT_CHOICES[0], stream, ...SUBJECT_CHOICES.slice(1)]
}
return SUBJECT_CHOICES
})()
let sIdx = opts.indexOf(stream)
if (sIdx < 0) sIdx = 0
const pField = baseForm.preferredFields || ''
const mOpts = (() => {
if (pField && MAJOR_CHOICES.indexOf(pField) < 0) {
return [MAJOR_CHOICES[0], pField, ...MAJOR_CHOICES.slice(1)]
}
return MAJOR_CHOICES
})()
let mIdx = mOpts.indexOf(pField)
if (mIdx < 0) mIdx = 0
this.setData({
form: baseForm,
regionPickerValue: rForPicker,
regionLine,
intendedModeIndex: modeIdx,
intendedModeLine,
intendedProvPicker,
intendedCityPicker,
intendedRegionLine,
subjectOptions: opts,
streamOrSubjectsIndex: sIdx,
majorOptions: mOpts,
preferredFieldsIndex: mIdx
})
})
.catch(() => {
wx.showToast({ title: '加载表单失败', icon: 'none' })
})
},
onInput(e) {
const key = e.currentTarget.dataset.key
this.setData({ [`form.${key}`]: e.detail.value })
},
onRegionChange(e) {
const v = regionValueFromEvent(e)
if (!v || !v.length) return
const p = (v[0] || '').trim()
if (!p) return
const c2 = v[1] != null && v[1] !== '' ? String(v[1]).trim() : ''
const pair = [p, c2 || p]
this.setData({
regionPickerValue: pair,
regionLine: formatRegionLine(pair),
'form.province': p,
'form.city': c2 || p,
'form.region': pair
})
},
onSubjectChange(e) {
const idx = parseInt(e.detail.value, 10) || 0
const opts = this.data.subjectOptions
const raw = opts[idx] || ''
const val =
raw && raw !== SUBJECT_PLACEHOLDER
? raw
: ''
this.setData({
streamOrSubjectsIndex: idx,
'form.streamOrSubjects': val
})
},
onIntendedModeChange(e) {
const idx = parseInt(e.detail.value, 10) || 0
const opts = this.data.intendedModeOptions
const pr = this.data.form.preferredRegion
const arr = Array.isArray(pr) ? pr : []
if (idx === 0) {
const p = arr[0] ? String(arr[0]).trim() : ''
const next = p ? [p] : []
this.setData({
intendedModeIndex: idx,
intendedModeLine: opts[idx],
intendedProvPicker: next,
intendedRegionLine: p,
'form.preferredRegion': next,
'form.preferredRegions': p
})
return
}
let cityPick = []
if (arr.length >= 2) {
cityPick = [String(arr[0] || '').trim(), String(arr[1] || '').trim()]
} else if (arr.length === 1) {
const p0 = String(arr[0] || '').trim()
cityPick = p0 ? [p0, p0] : []
}
const hasPair = arr.length >= 2
this.setData({
intendedModeIndex: idx,
intendedModeLine: opts[idx],
intendedCityPicker: cityPick
})
if (hasPair) {
const line = formatRegionLine(cityPick)
this.setData({
intendedRegionLine: line,
'form.preferredRegion': [cityPick[0], cityPick[1]],
'form.preferredRegions': regionToCityText(cityPick)
})
} else {
this.setData({
intendedRegionLine: arr[0] ? String(arr[0]).trim() : '',
'form.preferredRegion': arr
})
}
},
onIntendedRegionProv(e) {
const v = regionValueFromEvent(e)
if (!v || !v.length) return
const p = (v[0] || '').trim()
if (!p) return
this.setData({
intendedProvPicker: [p],
intendedRegionLine: p,
'form.preferredRegion': [p],
'form.preferredRegions': p
})
},
onIntendedRegionCity(e) {
const v = regionValueFromEvent(e)
if (!v || !v.length) return
const p = (v[0] || '').trim()
if (!p) return
const c2 = v[1] != null && v[1] !== '' ? String(v[1]).trim() : ''
const pair = [p, c2 || p]
this.setData({
intendedCityPicker: pair,
intendedRegionLine: formatRegionLine(pair),
'form.preferredRegion': pair,
'form.preferredRegions': regionToCityText(pair)
})
},
onPreferredFieldChange(e) {
const idx = parseInt(e.detail.value, 10) || 0
const opts = this.data.majorOptions
const raw = opts[idx] || ''
const val = raw && raw !== MAJOR_PLACEHOLDER ? raw : ''
this.setData({
preferredFieldsIndex: idx,
'form.preferredFields': val
})
},
onSave() {
const f = this.data.form
if (!f.name || !f.province || !f.streamOrSubjects) {
wx.showToast({ title: '请选择姓名、所在地区与科类/选科', icon: 'none' })
return
}
this.setData({ saving: true })
gaokaoApi.saveForm({
...f,
estimatedScore: f.estimatedScore ? Number(f.estimatedScore) : null
}).then(() => {
wx.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => wx.navigateBack(), 400)
}).catch((e) => {
wx.showToast({ title: e.message || '保存失败', icon: 'none' })
}).finally(() => this.setData({ saving: false }))
}
})

View File

@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "高考信息表单"
}

View File

@@ -0,0 +1,72 @@
<view class="container">
<view class="card">
<view class="item">
<view class="label">姓名</view>
<input class="input" data-key="name" value="{{form.name}}" bindinput="onInput" placeholder="请输入姓名" />
</view>
<view class="item">
<view class="label">所在地区</view>
<picker mode="region" level="city" value="{{regionPickerValue}}" bindchange="onRegionChange">
<view class="input input-picker {{!regionLine ? 'input-picker-empty' : ''}}">
<text>{{regionLine ? regionLine : '点选高考生源省、市'}}</text>
</view>
</picker>
</view>
<view class="item">
<view class="label">科类/选科</view>
<picker mode="selector" range="{{subjectOptions}}" value="{{streamOrSubjectsIndex}}" bindchange="onSubjectChange">
<view class="input input-picker">
<text wx:if="{{form.streamOrSubjects}}">{{form.streamOrSubjects}}</text>
<text wx:else class="ph">请点选科类或选科组合</text>
</view>
</picker>
</view>
<view class="item">
<view class="label">估分</view>
<input class="input" type="number" data-key="estimatedScore" value="{{form.estimatedScore}}" bindinput="onInput" placeholder="选填" />
</view>
<view class="item">
<view class="label">成绩说明</view>
<textarea class="textarea" data-key="scoreText" value="{{form.scoreText}}" bindinput="onInput" placeholder="可选,填写模考成绩说明"></textarea>
</view>
<view class="item">
<view class="label">志愿草表</view>
<textarea class="textarea" data-key="wishListText" value="{{form.wishListText}}" bindinput="onInput" placeholder="可选,填写当前志愿方案"></textarea>
</view>
<view class="item item-intended">
<view class="label">意向地区</view>
<picker class="intended-mode-picker" mode="selector" range="{{intendedModeOptions}}" value="{{intendedModeIndex}}" bindchange="onIntendedModeChange">
<view class="input input-picker input-sub">
<text>粒度:{{intendedModeLine}}</text>
</view>
</picker>
<view class="intended-region-pickers">
<block wx:if="{{intendedModeIndex == 0}}">
<picker mode="region" level="province" value="{{intendedProvPicker}}" bindchange="onIntendedRegionProv">
<view class="input input-picker {{!intendedRegionLine ? 'input-picker-empty' : ''}}">
<text>{{intendedRegionLine ? intendedRegionLine : '点选省份(可只到省)'}}</text>
</view>
</picker>
</block>
<block wx:else>
<picker mode="region" level="city" value="{{intendedCityPicker}}" bindchange="onIntendedRegionCity">
<view class="input input-picker {{!intendedRegionLine ? 'input-picker-empty' : ''}}">
<text>{{intendedRegionLine ? intendedRegionLine : '点选省、市(含直辖市)'}}</text>
</view>
</picker>
</block>
</view>
</view>
<view class="item">
<view class="label">意向专业</view>
<picker mode="selector" range="{{majorOptions}}" value="{{preferredFieldsIndex}}" bindchange="onPreferredFieldChange">
<view class="input input-picker">
<text wx:if="{{form.preferredFields}}">{{form.preferredFields}}</text>
<text wx:else class="ph">可选,点选专业方向</text>
</view>
</picker>
</view>
</view>
<button class="save-btn" loading="{{saving}}" bindtap="onSave">保存表单</button>
</view>

View File

@@ -0,0 +1,86 @@
.container {
min-height: 100vh;
background: #f7f8fc;
padding: 24rpx;
box-sizing: border-box;
}
.card {
background: #fff;
border-radius: 16rpx;
padding: 20rpx;
}
.item {
margin-bottom: 18rpx;
}
.label {
font-size: 26rpx;
color: #333;
margin-bottom: 8rpx;
}
.input {
height: 74rpx;
border: 1rpx solid #e6e8ee;
border-radius: 12rpx;
padding: 0 20rpx;
font-size: 26rpx;
}
.input-picker {
line-height: 72rpx;
box-sizing: border-box;
color: #1f2937;
}
/* 避免内联 picker 与下一行表单项左右串版(如市名跑到「科类」旁) */
.item > picker,
.item picker,
.intended-region-pickers picker {
display: block;
width: 100%;
}
.input-picker-empty text {
color: #9ca3af;
}
.input-picker .ph {
color: #9ca3af;
}
.textarea {
min-height: 140rpx;
border: 1rpx solid #e6e8ee;
border-radius: 12rpx;
padding: 16rpx 20rpx;
font-size: 26rpx;
width: 100%;
box-sizing: border-box;
}
/* 意向地区:先选粒度,再选省或省+市;第二行与第一行间留白 */
.intended-mode-picker {
display: block;
width: 100%;
}
.item-intended .intended-region-pickers {
margin-top: 12rpx;
}
.input-sub text {
font-size: 24rpx;
color: #6b7280;
}
.save-btn {
margin-top: 24rpx;
background: #7c3aed;
color: #fff;
border-radius: 999rpx;
font-size: 30rpx;
}

View File

@@ -0,0 +1,107 @@
const gaokaoApi = require('../../utils/gaokao')
Page({
data: {
loading: false,
tasks: {
mbti: { code: 'mbti', status: 'todo', resultText: '', testResultId: 0, id: 0, testType: 'mbti', typeName: 'MBTI性格', emoji: '🧠', testTime: '' },
pdp: { code: 'pdp', status: 'todo', resultText: '', testResultId: 0, id: 0, testType: 'pdp', typeName: 'PDP行为', emoji: '🦁', testTime: '' },
disc: { code: 'disc', status: 'todo', resultText: '', testResultId: 0, id: 0, testType: 'disc', typeName: 'DISC测评', emoji: '📊', testTime: '' },
face: { code: 'face', status: 'todo', resultText: '', testResultId: 0, id: 0, testType: 'ai', typeName: '拍照面相', emoji: '📷', testTime: '', recordTestType: 'ai' },
form: { code: 'form', status: 'todo', resultText: '', testResultId: 0, id: 0, testType: 'form', typeName: '高考信息表单', emoji: '📝', testTime: '' }
},
canAnalyze: false,
missingItems: [],
analyzing: false
},
onShow() {
this.refreshStatus()
},
refreshStatus() {
this.setData({ loading: true })
gaokaoApi
.getTaskStatus({ scene: 'gaokao_hub' })
.then((data) => {
this.setData({
tasks: data.tasks || this.data.tasks,
canAnalyze: !!data.canAnalyze,
missingItems: data.missingItems || []
})
})
.catch((e) => {
wx.showToast({ title: e.message || '加载失败', icon: 'none' })
})
.finally(() => this.setData({ loading: false }))
},
goTask(e) {
const code = e.currentTarget.dataset.code
const resultId = Number(e.currentTarget.dataset.resultId || 0)
const done = (e.currentTarget.dataset.status || '') === 'done'
if (code === 'mbti') {
if (done && resultId > 0) {
wx.navigateTo({ url: `/pages/result/mbti?id=${resultId}&type=mbti` })
} else {
wx.navigateTo({ url: '/pages/test/mbti' })
}
return
}
if (code === 'pdp') {
if (done && resultId > 0) {
wx.navigateTo({ url: `/pages/result/pdp?id=${resultId}&type=pdp` })
} else {
wx.navigateTo({ url: '/pages/test/pdp' })
}
return
}
if (code === 'disc') {
if (done && resultId > 0) {
wx.navigateTo({ url: `/pages/result/disc?id=${resultId}&type=disc` })
} else {
wx.navigateTo({ url: '/pages/test/disc' })
}
return
}
if (code === 'face') {
const recType = String(e.currentTarget.dataset.recordType || 'ai').toLowerCase()
const typeParam = recType === 'face' ? 'face' : 'ai'
if (done && resultId > 0) {
wx.navigateTo({ url: `/pages/index/result?id=${resultId}&type=${typeParam}` })
} else {
wx.switchTab({ url: '/pages/index/camera' })
}
return
}
if (code === 'form') {
wx.navigateTo({ url: '/pages/gaokao/form' })
}
},
onAnalyzeTap() {
if (!this.data.canAnalyze) {
const nameMap = {
mbti: 'MBTI测试',
pdp: 'PDP测试',
disc: 'DISC测试',
face: '拍照面相',
form: '高考信息表单'
}
const msg = (this.data.missingItems || []).map((k) => nameMap[k] || k).join('、')
wx.showToast({
title: msg ? `请先完成:${msg}` : '请先完成全部任务',
icon: 'none',
duration: 2500
})
return
}
this.setData({ analyzing: true })
wx.navigateTo({
url: '/pages/gaokao/report?pendingAnalyze=1',
complete: () => {
this.setData({ analyzing: false })
}
})
}
})

View File

@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "高考志愿"
}

View File

@@ -0,0 +1,90 @@
<view class="page">
<view class="container">
<view class="header">
<view class="title">高考志愿任务中心</view>
<view class="desc">完成 MBTI、PDP、DISC、拍照面相与高考信息表单后即可生成综合分析若定价非 0请在报告页解锁全文与问卷类测评一致。</view>
</view>
<view class="task-list">
<view class="task-card" data-code="mbti" data-status="{{tasks.mbti.status}}" data-result-id="{{tasks.mbti.testResultId}}" bindtap="goTask">
<view class="left">
<view class="task-title-row">
<text class="task-emoji">{{tasks.mbti.emoji}}</text>
<text class="name">{{tasks.mbti.typeName}}</text>
</view>
<view class="result result--value" wx:if="{{tasks.mbti.status==='done'}}">{{tasks.mbti.resultText || '已完成'}}</view>
<view class="result todo" wx:else>未完成,请去测试</view>
<view class="task-time" wx:if="{{tasks.mbti.status==='done' && tasks.mbti.testTime}}">{{tasks.mbti.testTime}}</view>
</view>
<view class="status {{tasks.mbti.status==='done'?'done':''}}">{{tasks.mbti.status==='done'?'查看结果':'去完成'}}</view>
</view>
<view class="task-card" data-code="pdp" data-status="{{tasks.pdp.status}}" data-result-id="{{tasks.pdp.testResultId}}" bindtap="goTask">
<view class="left">
<view class="task-title-row">
<text class="task-emoji">{{tasks.pdp.emoji}}</text>
<text class="name">{{tasks.pdp.typeName}}</text>
</view>
<view class="result result--value" wx:if="{{tasks.pdp.status==='done'}}">{{tasks.pdp.resultText || '已完成'}}</view>
<view class="result todo" wx:else>未完成,请去测试</view>
<view class="task-time" wx:if="{{tasks.pdp.status==='done' && tasks.pdp.testTime}}">{{tasks.pdp.testTime}}</view>
</view>
<view class="status {{tasks.pdp.status==='done'?'done':''}}">{{tasks.pdp.status==='done'?'查看结果':'去完成'}}</view>
</view>
<view class="task-card" data-code="disc" data-status="{{tasks.disc.status}}" data-result-id="{{tasks.disc.testResultId}}" bindtap="goTask">
<view class="left">
<view class="task-title-row">
<text class="task-emoji">{{tasks.disc.emoji}}</text>
<text class="name">{{tasks.disc.typeName}}</text>
</view>
<view class="result result--value" wx:if="{{tasks.disc.status==='done'}}">{{tasks.disc.resultText || '已完成'}}</view>
<view class="result todo" wx:else>未完成,请去测试</view>
<view class="task-time" wx:if="{{tasks.disc.status==='done' && tasks.disc.testTime}}">{{tasks.disc.testTime}}</view>
</view>
<view class="status {{tasks.disc.status==='done'?'done':''}}">{{tasks.disc.status==='done'?'查看结果':'去完成'}}</view>
</view>
<view class="task-card" data-code="face" data-status="{{tasks.face.status}}" data-result-id="{{tasks.face.testResultId}}" data-record-type="{{tasks.face.recordTestType}}" bindtap="goTask">
<view class="left">
<view class="task-title-row">
<text class="task-emoji">{{tasks.face.emoji}}</text>
<text class="name">{{tasks.face.typeName}}</text>
</view>
<view class="result result--value" wx:if="{{tasks.face.status==='done'}}">{{tasks.face.resultText || '已完成'}}</view>
<view class="result todo" wx:else>未完成,请去拍摄</view>
<view class="task-time" wx:if="{{tasks.face.status==='done' && tasks.face.testTime}}">{{tasks.face.testTime}}</view>
</view>
<view class="status {{tasks.face.status==='done'?'done':''}}">{{tasks.face.status==='done'?'查看结果':'去完成'}}</view>
</view>
<view class="task-card" data-code="form" data-status="{{tasks.form.status}}" data-result-id="{{tasks.form.testResultId}}" bindtap="goTask">
<view class="left">
<view class="task-title-row">
<text class="task-emoji">{{tasks.form.emoji}}</text>
<text class="name">{{tasks.form.typeName}}</text>
</view>
<view class="result result--value" wx:if="{{tasks.form.status==='done'}}">
<block wx:if="{{tasks.form.resultText}}">已填写:{{tasks.form.resultText}}</block>
<block wx:else>已完成</block>
</view>
<view class="result todo" wx:else>未完成,请先填写</view>
<view class="task-time" wx:if="{{tasks.form.status==='done' && tasks.form.testTime}}">{{tasks.form.testTime}}</view>
</view>
<view class="status {{tasks.form.status==='done'?'done':''}}">{{tasks.form.status==='done'?'查看结果':'去完成'}}</view>
</view>
</view>
</view>
<view class="bottom-actions">
<button
class="action-btn {{canAnalyze ? 'action-btn--primary' : 'action-btn--disabled'}}"
loading="{{analyzing}}"
bindtap="onAnalyzeTap"
disabled="{{analyzing}}"
>
{{canAnalyze ? '开始综合分析' : '综合分析(未完成)'}}
</button>
</view>
</view>

View File

@@ -0,0 +1,142 @@
.page {
min-height: 100vh;
background: #f7f8fc;
box-sizing: border-box;
}
.container {
min-height: 100%;
background: #f7f8fc;
padding: 32rpx 24rpx;
/* 底部固定操作条占位(单主按钮 + 安全区) */
padding-bottom: calc(140rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.header {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
}
.title {
font-size: 36rpx;
font-weight: 700;
color: #222;
}
.desc {
font-size: 24rpx;
color: #666;
margin-top: 8rpx;
}
.task-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.task-card {
background: #fff;
border-radius: 14rpx;
padding: 22rpx 20rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.task-title-row {
display: flex;
align-items: center;
gap: 12rpx;
}
.task-emoji {
font-size: 36rpx;
line-height: 1;
}
.name {
font-size: 30rpx;
color: #222;
font-weight: 600;
}
.task-time {
margin-top: 6rpx;
font-size: 22rpx;
color: #9ca3af;
}
.result {
margin-top: 8rpx;
font-size: 24rpx;
color: #666;
}
.result--value {
font-size: 30rpx;
font-weight: 600;
color: #4f46e5;
}
.result.todo {
color: #d04848;
}
.status {
font-size: 24rpx;
color: #7c3aed;
}
.status.done {
color: #1f9d55;
}
/* 底部悬浮:全宽主操作 */
.bottom-actions {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 200;
width: 100%;
box-sizing: border-box;
padding: 16rpx 24rpx;
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 -8rpx 32rpx rgba(15, 23, 42, 0.08);
border-top: 1rpx solid #eef0f4;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.action-btn {
width: 100% !important;
margin: 0;
padding: 24rpx 32rpx;
line-height: 1.35;
border-radius: 16rpx;
font-size: 30rpx;
font-weight: 700;
border: none;
box-sizing: border-box;
}
.action-btn::after {
border: none;
}
.action-btn--primary {
background: #7c3aed;
color: #fff;
}
.action-btn--disabled {
background: #e5e7eb;
color: #9ca3af;
}

View File

@@ -0,0 +1,690 @@
const app = getApp()
const gaokaoApi = require('../../utils/gaokao')
const { requestPromise } = require('../../utils/request')
const payment = require('../../utils/payment')
const {
hasPhone,
bindPhoneByCode,
needsResultProfileGate,
navigateToCompleteProfileAfterPhoneIfNeeded
} = require('../../utils/phoneAuth.js')
const unlockGate = require('../../utils/unlockGate.js')
const inviteCodeGate = require('../../utils/inviteCodeGate.js')
const { openTimelineShareHint } = require('../../utils/resultProfileGate.js')
const { computeJourney, markShared } = require('../../utils/gaokaoJourneyState.js')
const { getEnterpriseIdForApiPayload } = require('../../utils/enterpriseContext.js')
/** GET /api/test/detail | share-detail 返回体 -> mergeApiReport 入参 */
function mapTestDetailToReportPayload(detail) {
if (!detail || typeof detail !== 'object') {
return null
}
if (String(detail.testType || '').toLowerCase() !== 'gaokao') {
return null
}
const d = detail.data
if (!d || typeof d !== 'object') {
return null
}
const locked = !!d.locked
let report = d.report
if (typeof report === 'string') {
try {
report = JSON.parse(report)
} catch (e) {
report = null
}
}
const inputSnap =
d.inputSnapshot && typeof d.inputSnapshot === 'object' && !Array.isArray(d.inputSnapshot)
? d.inputSnapshot
: {}
if (locked) {
const ov =
typeof d.overview === 'string' && d.overview !== ''
? d.overview
: String(d.overview || '')
report = {
overview: ov,
personalityReason: '',
disclaimers: '',
majorRecommend: [],
schoolRecommend: {},
inputEcho: {
name: String(inputSnap.name || ''),
province: String(inputSnap.province || ''),
streamSubjects: String(inputSnap.streamSubjects || ''),
estimatedScore: inputSnap.estimatedScore != null ? Number(inputSnap.estimatedScore) : 0,
mbti: String(inputSnap.mbti || ''),
pdp: String(inputSnap.pdp || ''),
disc: String(inputSnap.disc || '')
},
locked: true
}
} else if (!report || typeof report !== 'object' || Array.isArray(report)) {
return null
}
const topOv = typeof d.overview === 'string' ? d.overview : ''
return {
id: detail.id,
createdAt: detail.createdAt,
overview: topOv,
report
}
}
/**
* 接口返回: { id, createdAt, overview, report: reportJson }
* 展示用合并为一层,便于 wxml 绑定
*/
function mergeApiReport(payload) {
if (!payload || typeof payload !== 'object') {
return null
}
let br = payload.report
if (typeof br === 'string') {
try {
br = JSON.parse(br)
} catch (e) {
br = null
}
}
const block = br && typeof br === 'object' && !Array.isArray(br) ? br : {}
const hasBlock = Object.keys(block).length > 0
const hasOverview = typeof payload.overview === 'string' && payload.overview !== ''
if (!hasBlock && !hasOverview && !(payload.id > 0)) {
return null
}
const overview = hasOverview ? payload.overview : block.overview || ''
return Object.assign({}, block, { overview })
}
function normalizeSchoolRow(x, band) {
if (!x || typeof x !== 'object') {
return null
}
return {
band: band || '',
schoolName: String(x.schoolName || x.name || '').trim() || '未命名院校',
city: String(x.city || '').trim(),
level: String(x.level || '').trim(),
reason: String(x.reason || x.desc || '').trim()
}
}
function buildSchoolListFlat(rawSr) {
if (Array.isArray(rawSr)) {
return rawSr.map((x) => normalizeSchoolRow(x, '')).filter(Boolean)
}
if (rawSr && typeof rawSr === 'object' && !Array.isArray(rawSr)) {
const chong = Array.isArray(rawSr.chong) ? rawSr.chong : []
const wen = Array.isArray(rawSr.wen) ? rawSr.wen : []
const bao = Array.isArray(rawSr.bao) ? rawSr.bao : []
return [
...chong.map((x) => normalizeSchoolRow(x, '冲')),
...wen.map((x) => normalizeSchoolRow(x, '稳')),
...bao.map((x) => normalizeSchoolRow(x, '保'))
].filter(Boolean)
}
return []
}
function buildViewModel(payload) {
const report = mergeApiReport(payload)
if (!report) {
return {
report: null,
inputEcho: {},
majorList: [],
schoolListFlat: [],
schoolChongCount: 0,
schoolWenCount: 0,
schoolBaoCount: 0,
hasSchoolFlat: false,
hasNoMajors: true
}
}
const rawSr = report.schoolRecommend
const schoolListFlat = buildSchoolListFlat(rawSr)
let schoolChongCount = 0
let schoolWenCount = 0
let schoolBaoCount = 0
if (rawSr && typeof rawSr === 'object' && !Array.isArray(rawSr)) {
schoolChongCount = Array.isArray(rawSr.chong) ? rawSr.chong.length : 0
schoolWenCount = Array.isArray(rawSr.wen) ? rawSr.wen.length : 0
schoolBaoCount = Array.isArray(rawSr.bao) ? rawSr.bao.length : 0
}
const majors = Array.isArray(report.majorRecommend) ? report.majorRecommend : []
const inputEcho = report.inputEcho || {}
return {
report,
inputEcho,
majorList: majors.map((m) => {
const rawName =
m &&
(m.majorName ||
m.name ||
m.title ||
m.major ||
m.major_name ||
m.majorChinese ||
m['专业'] ||
m['专业名称'])
const name = rawName != null && rawName !== '' ? String(rawName).trim() : ''
const displayName = name || '未命名专业'
const score = m && (m.fitScore != null ? m.fitScore : m.matchScore)
const fitLabel = score != null && score !== '' ? '(' + String(score) + ')' : ''
return { name: displayName, fitLabel }
}),
schoolListFlat,
schoolChongCount,
schoolWenCount,
schoolBaoCount,
hasSchoolFlat: schoolListFlat.length > 0,
hasNoMajors: majors.length === 0
}
}
function payInfoFromDetail(detail) {
const isPaid = !!(detail && (detail.isPaid === 1 || detail.isPaid === true))
const paidAmount = detail && detail.paidAmount != null ? Number(detail.paidAmount) : 0
const amountYuan =
detail && detail.amountYuan != null
? Number(detail.amountYuan)
: paidAmount > 0
? paidAmount / 100
: 0
const needPaymentToUnlock =
detail &&
(detail.needPaymentToUnlock === true ||
(!!detail.requiresPayment && !isPaid && paidAmount > 0))
return {
requiresPayment: needPaymentToUnlock,
isPaid,
amountYuan: needPaymentToUnlock ? amountYuan : 0
}
}
Page({
data: {
report: null,
inputEcho: {},
majorList: [],
schoolListFlat: [],
schoolChongCount: 0,
schoolWenCount: 0,
schoolBaoCount: 0,
hasSchoolFlat: false,
hasNoMajors: true,
journey: { step1Unlocked: false, step2Unlocked: false, activeStep: 1 },
payInfo: {
requiresPayment: false,
isPaid: false,
amountYuan: 0
},
testResultId: '',
shareToken: '',
hasReloadedAfterPay: false,
hasPhone: false,
fromShare: false,
profileGate: false,
showInviteCodeDialog: false,
isPendingAnalyze: false,
analyzingTitle: '正在生成高考志愿分析报告',
analyzingTip: '',
analyzeProgress: 0
},
onLoad(options) {
try {
wx.showShareMenu({ withShareTicket: true, menus: ['shareAppMessage', 'shareTimeline'] })
} catch (e) {}
const fromShareFs = options && (String(options.fs) === '1' || options.from === 'share')
const sid = options && options.id != null && options.id !== '' ? String(options.id) : ''
const st = options && options.st ? String(options.st).trim() : ''
if (sid && st) {
this.setData({ fromShare: true })
this.loadShareDetail(sid, st)
return
}
const ec =
typeof this.getOpenerEventChannel === 'function' ? this.getOpenerEventChannel() : null
if (ec && typeof ec.once === 'function') {
ec.once('gaokaoAnalyzeReport', (payload) => {
if (payload && payload.report) {
this.applyPayloadOnly({
id: payload.id,
createdAt: payload.createdAt || 0,
overview: payload.overview || '',
report: payload.report
})
}
})
}
const pendingAnalyze = options && String(options.pendingAnalyze) === '1'
if (pendingAnalyze) {
this._pendingAnalyze = true
return
}
const rid = options && options.id != null ? parseInt(String(options.id), 10) : 0
this._detailReportId = rid > 0 && !Number.isNaN(rid) ? rid : 0
if (fromShareFs) {
this.setData({ fromShare: true })
}
const delay = options && options.fromAnalyze === '1' ? 400 : 0
setTimeout(() => this.load(), delay)
},
onReady() {
if (this._pendingAnalyze) {
this._pendingAnalyze = false
this.beginAnalyzeFlow()
}
},
onUnload() {
this._clearAnalyzeTimers()
},
_clearAnalyzeTimers() {
if (this._analyzeProgressTimer) {
clearInterval(this._analyzeProgressTimer)
this._analyzeProgressTimer = null
}
},
beginAnalyzeFlow() {
this._clearAnalyzeTimers()
const tips = [
'正在读取您的 MBTI 与测评数据…',
'正在匹配专业维度与性格倾向…',
'正在根据分数与省份生成志愿建议…',
'正在润色报告与安全合规校验…',
'生成综合报告…'
]
let progress = 0
let tipIndex = 0
this.setData({
isPendingAnalyze: true,
analyzingTitle: '正在生成高考志愿分析报告',
analyzingTip: tips[0],
analyzeProgress: 0
})
this._analyzeProgressTimer = setInterval(() => {
progress += 3
if (progress > 95) progress = 95
if (progress > (tipIndex + 1) * 18 && tipIndex < tips.length - 1) tipIndex++
this.setData({
analyzeProgress: Math.floor(progress),
analyzingTip: tips[tipIndex]
})
}, 200)
gaokaoApi
.analyze()
.then((res) => {
this._clearAnalyzeTimers()
const rawId = res && (res.reportId != null ? res.reportId : res.id)
const numId = parseInt(String(rawId), 10)
if (!rawId || Number.isNaN(numId) || numId <= 0) {
throw new Error('未返回报告')
}
this._detailReportId = numId
this.setData({
analyzeProgress: 100,
analyzingTip: '分析完成!'
})
return new Promise((r) => setTimeout(r, 400)).then(() =>
this.loadDetail(numId, { silent: true }).catch(() => {
const rep = res && res.report
if (rep && typeof rep === 'object') {
this.applyPayloadOnly({
id: numId,
createdAt: res.createdAt || 0,
overview:
(typeof res.overview === 'string' && res.overview) ||
(rep.overview && String(rep.overview)) ||
'',
report: rep
})
return
}
return Promise.reject(new Error('报告已生成,但加载详情失败'))
})
)
})
.then(() => {
this.setData({ isPendingAnalyze: false })
})
.catch((e) => {
this._clearAnalyzeTimers()
this.setData({ isPendingAnalyze: false, analyzeProgress: 0 })
wx.showToast({ title: (e && e.message) || '分析失败', icon: 'none' })
setTimeout(() => {
wx.navigateBack({ delta: 1 })
}, 1600)
})
},
onShow() {
this.setData({ hasPhone: hasPhone() })
if (this.data.report && !this.data.fromShare) {
const profileGate = needsResultProfileGate(!!this.data.fromShare)
this.setData({ profileGate })
this._syncJourney()
}
// 切换个人/企业 Tab 后回到报告页:静默重拉详情以同步 paidAmount与当前 Tab 定价一致)
if (!this.data.fromShare && !this.data.isPendingAnalyze) {
const rid = parseInt(String(this.data.testResultId || ''), 10)
if (rid > 0) {
this.loadDetail(rid, { silent: true }).catch(() => {})
}
}
},
onShareAppMessage() {
const id = this.data.testResultId
const st = this.data.shareToken
if (!id || !st) {
return { title: '高考志愿分析报告', path: '/pages/gaokao/index' }
}
return {
title: '高考志愿分析报告',
path: `/pages/gaokao/report?id=${encodeURIComponent(id)}&st=${encodeURIComponent(st)}&fs=1`
}
},
_syncJourney() {
const j = computeJourney(
{
profileGate: !!this.data.profileGate,
payRequired: !!(this.data.payInfo && this.data.payInfo.requiresPayment),
isPaid: !!(this.data.payInfo && this.data.payInfo.isPaid)
},
this.data.testResultId || '0'
)
this.setData({ journey: j })
},
_reportPaywallOnce(payInfo) {
if (!payInfo || !payInfo.requiresPayment || payInfo.isPaid) return
if (this._paywallReported) return
this._paywallReported = true
try {
require('../../utils/analytics').track('paywall_view', {
type: 'gaokao',
amountYuan: payInfo.amountYuan
})
} catch (e) {}
},
applyPayloadOnly(payload) {
const vm = buildViewModel(payload)
const profileGate = needsResultProfileGate(!!this.data.fromShare)
const patch = Object.assign(vm, { profileGate })
if (payload && payload.id != null) {
patch.testResultId = String(payload.id)
}
this.setData(patch)
this._syncJourney()
},
applyDetailPayload(detail) {
const mapped = mapTestDetailToReportPayload(detail)
if (!mapped) {
wx.showToast({ title: '报告数据无效', icon: 'none' })
this.setData(buildViewModel(null))
return
}
const payInfo = payInfoFromDetail(detail)
const profileGate = needsResultProfileGate(!!this.data.fromShare)
const vm = buildViewModel(mapped)
const patch = Object.assign(vm, {
payInfo,
profileGate,
shareToken: (detail && detail.shareToken) || '',
testResultId: detail.id != null ? String(detail.id) : ''
})
this.setData(patch)
this._reportPaywallOnce(payInfo)
this._syncJourney()
},
loadShareDetail(id, st) {
wx.showLoading({ title: '加载中...' })
requestPromise({
url: `/api/test/share-detail?id=${encodeURIComponent(id)}&st=${encodeURIComponent(st)}`,
method: 'GET'
})
.then((res) => {
const body = res.data || {}
if (body.code !== 200) {
throw new Error(body.message || '加载失败')
}
this.applyDetailPayload(body.data || {})
})
.catch((e) => {
wx.showToast({ title: (e && e.message) || '加载失败', icon: 'none' })
this.setData(buildViewModel(null))
})
.finally(() => wx.hideLoading())
},
loadDetail(id, opts) {
const silent = !!(opts && opts.silent)
const numId = parseInt(String(id), 10)
if (!numId || Number.isNaN(numId)) {
return Promise.resolve()
}
if (!silent) wx.showLoading({ title: '加载中...' })
const gd = app.globalData || {}
const pricingScope = gd.appScope === 'enterprise' ? 'enterprise' : 'personal'
let detailUrl = `/api/test/detail?id=${encodeURIComponent(numId)}&pricingScope=${encodeURIComponent(pricingScope)}`
try {
const eid = getEnterpriseIdForApiPayload()
if (eid != null && Number(eid) > 0) {
detailUrl += `&enterpriseId=${encodeURIComponent(String(eid))}`
}
} catch (e) {}
return requestPromise({
url: detailUrl,
method: 'GET'
})
.then((res) => {
const body = res.data || {}
if (body.code !== 200) {
throw new Error(body.message || '加载失败')
}
this.applyDetailPayload(body.data || {})
})
.catch((e) => {
if (!silent) {
wx.showToast({ title: (e && e.message) || '加载失败', icon: 'none' })
this.setData(buildViewModel(null))
}
return Promise.reject(e)
})
.finally(() => {
if (!silent) wx.hideLoading()
})
},
load() {
const rid = this._detailReportId || 0
if (rid > 0) {
this.loadDetail(rid)
return
}
gaokaoApi
.latestReport()
.then((payload) => {
const id = payload && payload.id
if (!id) {
throw new Error('暂无报告')
}
return this.loadDetail(id)
})
.catch((e) => {
wx.showToast({ title: e.message || '暂无报告', icon: 'none' })
this.setData(buildViewModel(null))
})
},
goCompleteProfile() {
try {
require('../../utils/analytics').track('tap_complete_profile', { from: 'gaokao_report' })
} catch (e) {}
wx.navigateTo({ url: '/pages/user-profile/index' })
},
goWantTest() {
wx.switchTab({ url: '/pages/index/index' })
},
goReadFullFromShare() {
wx.switchTab({ url: '/pages/profile/index' })
},
onTapReadFull() {
try {
require('../../utils/analytics').track('tap_read_full', { type: 'gaokao' })
} catch (e) {}
if (this.data.profileGate) {
unlockGate.scrollToUnlockAnchor(this)
wx.showToast({
title: this.data.hasPhone ? '请先完善头像与昵称' : '请在上滑区域内完成手机号授权',
icon: 'none'
})
return
}
if (this.data.payInfo.requiresPayment && !this.data.payInfo.isPaid) {
this.unlockFullReport()
return
}
wx.showToast({ title: '当前已是完整报告', icon: 'none' })
},
onTapShareMoment() {
try {
require('../../utils/analytics').track('tap_share_moment', { type: 'gaokao' })
} catch (e) {}
if (!this.data.journey.step1Unlocked) {
wx.showToast({ title: '请先解锁全文', icon: 'none' })
this.onTapReadFull()
return
}
markShared(this.data.testResultId || '0')
this._syncJourney()
openTimelineShareHint()
},
unlockFullReport() {
const { payInfo, testResultId, hasReloadedAfterPay } = this.data
if (!payInfo.requiresPayment || payInfo.isPaid) return
try {
require('../../utils/analytics').track('tap_unlock_full', {
type: 'gaokao',
amountYuan: payInfo.amountYuan
})
} catch (e) {}
const run =
typeof app.ensureLogin === 'function'
? app.ensureLogin()
: Promise.resolve(!!(app.globalData && app.globalData.token) || !!wx.getStorageSync('token'))
run.then((logged) => {
if (!logged) {
wx.showToast({ title: '请先登录', icon: 'none' })
return
}
unlockGate.ensureUnlockPrerequisitesBeforePay(this).then((ok) => {
if (!ok) return
inviteCodeGate.ensureInviteCodeGate(this).then((go) => {
if (!go) return
payment.purchaseGaokaoReport({
testResultId: testResultId ? parseInt(String(testResultId), 10) || undefined : undefined,
success: () => {
wx.showToast({ title: '已解锁完整报告', icon: 'success' })
this.setData({ 'payInfo.isPaid': true })
this._syncJourney()
if (testResultId && !hasReloadedAfterPay) {
this.setData({ hasReloadedAfterPay: true })
setTimeout(() => this.loadDetail(testResultId), 500)
}
},
fail: () => {}
})
})
})
})
},
onGetPhoneNumberForGaokaoPay(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: hasPhone() })
const profileGate = needsResultProfileGate(!!this.data.fromShare)
this.setData({ profileGate })
navigateToCompleteProfileAfterPhoneIfNeeded()
this._syncJourney()
this.unlockFullReport()
})
.catch(() => {})
},
onPhoneLoginForResultGate(e) {
const { code, errMsg } = e.detail || {}
if (errMsg && errMsg.indexOf('getPhoneNumber:fail') === 0) {
wx.showToast({ title: '需要授权手机号才能查看完整报告', icon: 'none' })
return
}
if (!code) {
wx.showToast({ title: '获取手机号失败', icon: 'none' })
return
}
bindPhoneByCode(code)
.then(() => {
this.setData({ hasPhone: hasPhone() })
const profileGate = needsResultProfileGate(!!this.data.fromShare)
this.setData({ profileGate })
navigateToCompleteProfileAfterPhoneIfNeeded()
this._syncJourney()
})
.catch(() => {})
},
onInviteCodeSkip() {
inviteCodeGate.finishInviteCodeGate(this, true)
},
onInviteCodeSuccess() {
inviteCodeGate.finishInviteCodeGate(this, true)
}
})

View File

@@ -0,0 +1,6 @@
{
"navigationBarTitleText": "高考分析报告",
"usingComponents": {
"invite-code-dialog": "/components/invite-code-dialog/invite-code-dialog"
}
}

View File

@@ -0,0 +1,262 @@
<view class="gaokao-report-page">
<!-- 综合分析:与人脸结果页类似的加载态 -->
<view class="analyzing-modal" wx:if="{{isPendingAnalyze}}">
<view class="analyzing-content">
<view class="analyzing-icon">
<view class="analyzing-spinner"></view>
</view>
<text class="analyzing-title">{{analyzingTitle}}</text>
<text class="analyzing-desc">{{analyzingTip}}</text>
<view class="analyzing-bar">
<view class="analyzing-bar-fill" style="width: {{analyzeProgress}}%"></view>
</view>
<text class="analyzing-hint">AI 生成可能需要 30 秒1 分钟,请稍候…</text>
</view>
</view>
<view
class="container {{report && !fromShare ? 'container--with-bottom-tools' : ''}} {{report && fromShare ? 'container--with-share-footer' : ''}}"
wx:if="{{report}}"
>
<view class="header-card">
<view class="header-hero">
<view class="header-top">
<view class="user-info">
<view class="user-name">{{inputEcho.name || '同学'}}</view>
<view class="user-tags">
<text class="tag tag-province" wx:if="{{inputEcho.province}}">{{inputEcho.province}}</text>
<text class="tag tag-stream" wx:if="{{inputEcho.streamSubjects}}">{{inputEcho.streamSubjects}}</text>
</view>
</view>
<view class="score-box">
<text class="score-num">{{inputEcho.estimatedScore || 0}}</text>
<text class="score-unit">分</text>
</view>
</view>
</view>
<view class="header-body" wx:if="{{inputEcho.mbti || inputEcho.pdp || inputEcho.disc}}">
<view class="personality-tags">
<view class="p-tag" wx:if="{{inputEcho.mbti}}">
<text class="p-label">MBTI</text>
<text class="p-value">{{inputEcho.mbti}}</text>
</view>
<view class="p-tag" wx:if="{{inputEcho.pdp}}">
<text class="p-label">PDP</text>
<text class="p-value">{{inputEcho.pdp}}</text>
</view>
<view class="p-tag" wx:if="{{inputEcho.disc}}">
<text class="p-label">DISC</text>
<text class="p-value">{{inputEcho.disc}}</text>
</view>
</view>
</view>
</view>
<view class="card paywall-card" wx:if="{{payInfo.requiresPayment && !payInfo.isPaid && !fromShare}}">
<view class="paywall-content paywall-content--preview40">
<view class="paywall-blur">
<text class="paywall-fake-title">完整高考志愿分析报告</text>
<text class="paywall-fake-line paywall-fake-line--compact">解锁后可查看冲稳保院校、专业推荐与性格解读全文。</text>
<view class="paywall-preview-wrap" wx:if="{{report.overview}}">
<text class="paywall-preview-label">综合总评(预览)</text>
<view class="paywall-preview-inner">
<text class="paywall-preview-text">{{report.overview}}</text>
<view class="paywall-preview-fade"></view>
</view>
</view>
<block wx:else>
<text class="paywall-fake-line">• 冲稳保院校与推荐理由</text>
<text class="paywall-fake-line">• 专业推荐与匹配说明</text>
<text class="paywall-fake-line">• 性格与志愿方向解读</text>
</block>
</view>
<view class="paywall-mask paywall-mask--from40"></view>
<view id="unlock-gate-anchor" class="unlock-gate-anchor-btn-wrap">
<button
class="paywall-btn"
wx:if="{{!hasPhone}}"
open-type="getPhoneNumber"
bindgetphonenumber="onGetPhoneNumberForGaokaoPay"
>
<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>
<view class="card paywall-card" wx:elif="{{report.locked && !fromShare && !profileGate}}">
<view class="paywall-content">
<text class="paywall-fake-title">完整报告暂不可查看</text>
<text class="paywall-fake-line">请稍后在「我的」重试或联系客服;若刚授权手机,可下拉刷新本页。</text>
<button class="paywall-btn paywall-btn--inline-profile" bindtap="goCompleteProfile">
<text class="paywall-btn-main">去个人资料</text>
</button>
</view>
</view>
<view
wx:if="{{profileGate && report && (!payInfo.requiresPayment || payInfo.isPaid) && !fromShare}}"
id="unlock-gate-anchor"
class="unlock-gate-profile-block"
>
<view class="preview-teaser-card" wx:if="{{report.overview}}">
<text class="preview-teaser-title">综合总评(预览)</text>
<text class="preview-teaser-desc">{{report.overview}}</text>
<text class="preview-teaser-hint">授权手机号并设置头像、昵称后即可查看全文。</text>
</view>
<button
wx:if="{{!hasPhone}}"
class="paywall-btn paywall-btn--inline-profile"
open-type="getPhoneNumber"
bindgetphonenumber="onPhoneLoginForResultGate"
>
<text class="paywall-btn-main">登录解锁全文</text>
</button>
<button wx:else class="paywall-btn paywall-btn--inline-profile" bindtap="goCompleteProfile">
<text class="paywall-btn-main">完善资料 · 查看全文</text>
</button>
</view>
<view id="unlock-scroll-tail"></view>
<view class="card" wx:if="{{!report.locked}}">
<view class="title-wrap">
<view class="title-icon">📊</view>
<view class="title">综合总评</view>
</view>
<view class="content">{{report.overview}}</view>
</view>
<view class="card" wx:if="{{!report.locked}}">
<view class="title-wrap">
<view class="title-icon">🎯</view>
<view class="title">冲稳保建议</view>
</view>
<block wx:if="{{hasSchoolFlat}}">
<view class="sub-counts" wx:if="{{schoolChongCount || schoolWenCount || schoolBaoCount}}">
<view class="count-tag count-chong">冲 {{schoolChongCount}}所</view>
<view class="count-tag count-wen">稳 {{schoolWenCount}}所</view>
<view class="count-tag count-bao">保 {{schoolBaoCount}}所</view>
</view>
<view class="sub-counts" wx:else>
<view class="count-tag count-total">院校参考(共 {{schoolListFlat.length}} 所)</view>
</view>
<view class="school-list">
<block wx:for="{{schoolListFlat}}" wx:for-item="sch" wx:for-index="sidx" wx:key="sidx">
<view class="school-item">
<view class="school-header">
<view
class="school-band-tag band-{{sch.band === '冲' ? 'chong' : (sch.band === '稳' ? 'wen' : (sch.band === '保' ? 'bao' : 'none'))}}"
wx:if="{{sch.band}}"
>{{sch.band}}</view>
<view class="school-name">{{sch.schoolName}}</view>
</view>
<view class="school-meta-row" wx:if="{{sch.city || sch.level}}">
<text class="meta-item" wx:if="{{sch.city}}"><text class="meta-icon">📍</text>{{sch.city}}</text>
<text class="meta-item" wx:if="{{sch.level}}"><text class="meta-icon">🎓</text>{{sch.level}}</text>
</view>
<view class="school-reason" wx:if="{{sch.reason}}">
<text class="quote-mark">“</text>
{{sch.reason}}
</view>
</view>
</block>
</view>
</block>
<view wx:else class="sub-counts">
<view class="count-tag count-chong">冲 {{schoolChongCount}}所</view>
<view class="count-tag count-wen">稳 {{schoolWenCount}}所</view>
<view class="count-tag count-bao">保 {{schoolBaoCount}}所</view>
</view>
</view>
<view class="card" wx:if="{{!report.locked}}">
<view class="title-wrap">
<view class="title-icon">💼</view>
<view class="title">专业建议</view>
</view>
<view class="major-list" wx:if="{{!hasNoMajors}}">
<block wx:for="{{majorList}}" wx:for-item="row" wx:for-index="idx" wx:key="idx">
<view class="major-item">
<view class="major-rank rank-{{idx < 3 ? idx + 1 : 'other'}}">{{idx + 1}}</view>
<view class="major-name">{{row.name}}</view>
<view class="major-score" wx:if="{{row.fitLabel}}">{{row.fitLabel}}</view>
</view>
</block>
</view>
<view wx:if="{{hasNoMajors}}" class="sub">暂无专业推荐</view>
</view>
<view class="card" wx:if="{{!report.locked}}">
<view class="title-wrap">
<view class="title-icon">🧠</view>
<view class="title">性格匹配说明</view>
</view>
<view class="content">{{report.personalityReason}}</view>
</view>
<view class="card disclaimer-card" wx:if="{{!report.locked}}">
<view class="title-wrap">
<view class="title-icon">⚠️</view>
<view class="title">免责声明</view>
</view>
<view class="content disclaimer-text">{{report.disclaimers}}</view>
</view>
</view>
<view class="empty" wx:elif="{{!isPendingAnalyze}}">
<view class="empty-icon">📄</view>
<view class="empty-text">暂无报告,请先完成综合分析</view>
</view>
<view class="result-bottom-tools" wx:if="{{report && !fromShare}}">
<view class="journey-stepper journey-stepper--two">
<view class="journey-stepper__item">
<view
class="journey-stepper__dot {{journey.step1Unlocked ? 'journey-stepper__dot--done' : (journey.activeStep===1 ? 'journey-stepper__dot--active' : '')}}"
>1</view>
<text class="journey-stepper__label {{journey.activeStep===1 ? 'journey-stepper__label--active' : ''}}">看全文</text>
</view>
<view class="journey-stepper__bar {{journey.step1Unlocked ? 'journey-stepper__bar--done' : ''}}"></view>
<view class="journey-stepper__item">
<view
class="journey-stepper__dot {{journey.step2Unlocked ? 'journey-stepper__dot--done' : (journey.activeStep===2 ? 'journey-stepper__dot--active' : '')}}"
>2</view>
<text class="journey-stepper__label {{journey.activeStep===2 ? 'journey-stepper__label--active' : ''}}">分享朋友圈</text>
</view>
</view>
<view class="result-bottom-tools__row result-bottom-tools__row--two">
<view class="result-tool-btn result-tool-btn--primary" bindtap="onTapReadFull">
<text>{{journey.step1Unlocked ? '查看全文' : '① 解锁全文'}}</text>
<text class="result-tool-sub">{{journey.step1Unlocked ? '已解锁' : (profileGate ? (hasPhone ? '完善资料后解锁' : '登录并完善资料') : '点击解锁')}}</text>
</view>
<view
class="result-tool-btn {{journey.step1Unlocked ? 'result-tool-btn--rose' : 'result-tool-btn--locked'}}"
bindtap="onTapShareMoment"
>
<text><text wx:if="{{!journey.step1Unlocked}}" class="result-tool-btn__lock">🔒</text>朋友圈</text>
<text class="result-tool-sub">{{journey.step1Unlocked ? (journey.step2Unlocked ? '已分享' : '点击分享') : '先解锁全文'}}</text>
</view>
</view>
</view>
<view class="result-share-footer result-share-footer--stack" wx:if="{{report && fromShare}}">
<button class="result-share-footer-btn" bindtap="goWantTest">我也要测试</button>
<view class="result-share-footer-row2">
<button class="result-share-footer-btn result-share-footer-btn--ghost" bindtap="goReadFullFromShare">看全文</button>
<button class="result-share-footer-btn result-share-footer-btn--share" open-type="share">分享给好友</button>
</view>
</view>
<invite-code-dialog
visible="{{showInviteCodeDialog}}"
bind:skip="onInviteCodeSkip"
bind:success="onInviteCodeSuccess"
/>
</view>

View File

@@ -0,0 +1,698 @@
@import '../../styles/result-page-dashboard.wxss';
.gaokao-report-page {
min-height: 100vh;
background: #f4f6f9;
}
.container {
min-height: 100vh;
background: #f4f6f9;
padding: 24rpx;
padding-bottom: calc(48rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.container.container--with-bottom-tools {
padding-bottom: calc(120rpx + 400rpx + env(safe-area-inset-bottom));
}
.container.container--with-share-footer {
padding-bottom: calc(220rpx + env(safe-area-inset-bottom));
}
.journey-stepper--two {
max-width: 520rpx;
margin-left: auto;
margin-right: auto;
}
.result-bottom-tools__row--two .result-tool-btn:nth-child(1)::before {
content: '01';
}
.result-bottom-tools__row--two .result-tool-btn:nth-child(2)::before {
content: '02';
}
/* 付费墙(与 MBTI 结果页同款布局,配色贴近高考页主色) */
.paywall-card {
margin-bottom: 24rpx;
border-radius: 24rpx;
overflow: hidden;
}
.paywall-content {
position: relative;
min-height: 360rpx;
}
/* 有总评预览时拉高容器,便于展示约 40vh 正文 */
.paywall-content--preview40 {
min-height: 48vh;
}
.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;
}
/* 顶部约 40% 区域不压暗,以下渐强引导至解锁按钮 */
.paywall-mask--from40 {
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0) 40%,
rgba(255, 255, 255, 0.35) 58%,
rgba(255, 255, 255, 0.72) 78%,
rgba(255, 255, 255, 0.94) 100%
);
}
.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-fake-line--compact {
margin-bottom: 20rpx;
font-size: 24rpx;
color: #64748b;
}
.paywall-preview-wrap {
margin-top: 8rpx;
}
.paywall-preview-label {
display: block;
font-size: 24rpx;
font-weight: 600;
color: #6366f1;
margin-bottom: 12rpx;
}
.paywall-preview-inner {
position: relative;
max-height: 40vh;
overflow: hidden;
border-radius: 16rpx;
background: #f8fafc;
padding: 20rpx 20rpx 48rpx;
box-sizing: border-box;
}
.paywall-preview-text {
display: block;
font-size: 28rpx;
color: #334155;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
}
.paywall-preview-fade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 100rpx;
background: linear-gradient(to bottom, rgba(248, 250, 252, 0), rgba(248, 250, 252, 1));
pointer-events: none;
}
.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, #6366f1 0%, #8b5cf6 100%);
box-shadow: 0 8rpx 24rpx rgba(99, 102, 241, 0.35);
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: #e0e7ff;
}
.paywall-btn--inline-profile {
position: relative !important;
left: auto !important;
right: auto !important;
bottom: auto !important;
width: 100% !important;
margin-top: 28rpx;
z-index: 1;
}
.result-share-footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 200;
box-sizing: border-box;
width: 100%;
padding: 20rpx 32rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
background: #f5f5f5;
box-shadow: 0 -8rpx 24rpx rgba(0, 0, 0, 0.06);
display: flex;
flex-direction: column;
gap: 16rpx;
}
.result-share-footer-btn {
width: 100% !important;
box-sizing: border-box;
display: block;
margin: 0;
padding: 0;
min-height: 96rpx;
line-height: 96rpx;
border-radius: 999rpx;
font-size: 30rpx;
font-weight: 600;
color: #fff;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
border: none;
}
.result-share-footer-btn::after {
border: none;
}
.result-share-footer-row2 .result-share-footer-btn {
flex: 1;
min-height: 88rpx;
line-height: 88rpx;
}
.sub {
font-size: 26rpx;
color: #94a3b8;
}
/* Header Card渐变区随内容增高避免省/科类标签落到白底上被「裁切」看不见 */
.header-card {
position: relative;
background: #fff;
border-radius: 24rpx;
margin-bottom: 24rpx;
overflow: hidden;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.04);
}
.header-hero {
background: linear-gradient(135deg, #6366f1, #8b5cf6);
padding: 32rpx 32rpx 36rpx;
}
.header-body {
padding: 24rpx 32rpx 32rpx;
background: #fff;
}
.header-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20rpx;
}
.user-info {
flex: 1;
min-width: 0;
}
.user-name {
font-size: 36rpx;
font-weight: bold;
color: #fff;
margin-bottom: 16rpx;
line-height: 1.35;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
}
.user-tags {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.tag {
font-size: 22rpx;
padding: 8rpx 18rpx;
line-height: 1.3;
border-radius: 100rpx;
font-weight: 500;
}
.tag-province {
background: rgba(255, 255, 255, 0.2);
color: #fff;
border: 1rpx solid rgba(255, 255, 255, 0.4);
}
.tag-stream {
background: rgba(255, 255, 255, 0.2);
color: #fff;
border: 1rpx solid rgba(255, 255, 255, 0.4);
}
.score-box {
background: #fff;
padding: 16rpx 24rpx;
border-radius: 20rpx;
box-shadow: 0 8rpx 16rpx rgba(99, 102, 241, 0.15);
display: flex;
align-items: baseline;
flex-shrink: 0;
}
.score-num {
font-size: 48rpx;
font-weight: 800;
color: #6366f1;
line-height: 1;
}
.score-unit {
font-size: 24rpx;
color: #666;
margin-left: 4rpx;
font-weight: 500;
}
.personality-tags {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
padding-top: 4rpx;
border-top: 1rpx dashed #e2e8f0;
}
.p-tag {
display: flex;
flex-wrap: wrap;
align-items: stretch;
background: #f8fafc;
border-radius: 12rpx;
overflow: visible;
border: 1rpx solid #e2e8f0;
max-width: 100%;
}
.p-label {
background: #e2e8f0;
color: #475569;
font-size: 20rpx;
font-weight: bold;
padding: 6rpx 12rpx;
}
.p-value {
color: #334155;
font-size: 22rpx;
font-weight: 600;
padding: 6rpx 16rpx;
line-height: 1.45;
white-space: normal;
word-break: break-word;
max-width: 460rpx;
box-sizing: border-box;
}
/* Common Card */
.card {
background: #fff;
border-radius: 24rpx;
padding: 32rpx;
margin-bottom: 24rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.03);
}
.title-wrap {
display: flex;
align-items: center;
margin-bottom: 24rpx;
}
.title-icon {
font-size: 32rpx;
margin-right: 12rpx;
}
.title {
font-size: 32rpx;
font-weight: bold;
color: #1e293b;
}
.content {
font-size: 28rpx;
color: #475569;
line-height: 1.7;
text-align: justify;
}
/* Sub Counts (冲稳保) */
.sub-counts {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
margin-bottom: 24rpx;
}
.count-tag {
font-size: 24rpx;
font-weight: 600;
padding: 8rpx 20rpx;
border-radius: 12rpx;
}
.count-chong {
background: #fff1f2;
color: #e11d48;
}
.count-wen {
background: #eff6ff;
color: #2563eb;
}
.count-bao {
background: #f0fdf4;
color: #16a34a;
}
.count-total {
background: #f1f5f9;
color: #475569;
}
/* School List */
.school-list {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.school-item {
background: #f8fafc;
border-radius: 16rpx;
padding: 24rpx;
border: 1rpx solid #f1f5f9;
}
.school-header {
display: flex;
align-items: center;
margin-bottom: 12rpx;
}
.school-band-tag {
font-size: 22rpx;
font-weight: bold;
padding: 4rpx 12rpx;
border-radius: 8rpx;
margin-right: 16rpx;
flex-shrink: 0;
}
.band-chong {
background: #ffe4e6;
color: #e11d48;
}
.band-wen {
background: #dbeafe;
color: #2563eb;
}
.band-bao {
background: #dcfce7;
color: #16a34a;
}
.band-none {
background: #e2e8f0;
color: #475569;
}
.school-name {
font-size: 30rpx;
font-weight: bold;
color: #1e293b;
flex: 1;
}
.school-meta-row {
display: flex;
flex-wrap: wrap;
gap: 24rpx;
margin-bottom: 16rpx;
}
.meta-item {
font-size: 24rpx;
color: #64748b;
display: flex;
align-items: center;
}
.meta-icon {
margin-right: 6rpx;
font-size: 24rpx;
}
.school-reason {
font-size: 26rpx;
color: #475569;
line-height: 1.6;
background: #fff;
padding: 16rpx 20rpx;
border-radius: 12rpx;
position: relative;
}
.quote-mark {
color: #cbd5e1;
font-size: 40rpx;
font-family: serif;
position: absolute;
top: 8rpx;
left: 12rpx;
line-height: 1;
}
.school-reason {
padding-left: 40rpx;
}
/* Major List */
.major-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.major-item {
display: flex;
align-items: center;
background: #f8fafc;
padding: 20rpx 24rpx;
border-radius: 16rpx;
}
.major-rank {
width: 44rpx;
height: 44rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
font-weight: bold;
margin-right: 20rpx;
flex-shrink: 0;
}
.rank-1 {
background: #fef08a;
color: #b45309;
}
.rank-2 {
background: #e2e8f0;
color: #475569;
}
.rank-3 {
background: #ffedd5;
color: #9a3412;
}
.rank-other {
background: #f1f5f9;
color: #64748b;
}
.major-name {
font-size: 28rpx;
font-weight: 600;
color: #334155;
flex: 1;
}
.major-score {
font-size: 24rpx;
color: #64748b;
background: #fff;
padding: 4rpx 12rpx;
border-radius: 100rpx;
}
/* Disclaimer */
.disclaimer-card {
background: #fef2f2;
}
.disclaimer-card .title {
color: #9f1239;
}
.disclaimer-text {
color: #be123c;
font-size: 24rpx;
}
/* Empty State */
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 200rpx;
}
.empty-icon {
font-size: 100rpx;
margin-bottom: 32rpx;
opacity: 0.5;
}
.empty-text {
font-size: 30rpx;
color: #64748b;
}
/* 综合分析加载(与人脸结果页同款结构,配色贴近高考页) */
.analyzing-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(15, 23, 42, 0.72);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.analyzing-content {
background-color: #fff;
border-radius: 24rpx;
padding: 80rpx 56rpx;
width: 560rpx;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 16rpx 48rpx rgba(99, 102, 241, 0.2);
}
.analyzing-icon {
width: 120rpx;
height: 120rpx;
margin-bottom: 40rpx;
display: flex;
align-items: center;
justify-content: center;
}
.analyzing-spinner {
width: 120rpx;
height: 120rpx;
border: 8rpx solid #e2e8f0;
border-top-color: #6366f1;
border-radius: 50%;
animation: gaokao-analyzing-spin 0.9s linear infinite;
}
@keyframes gaokao-analyzing-spin {
to {
transform: rotate(360deg);
}
}
.analyzing-title {
font-size: 36rpx;
font-weight: 600;
color: #1e293b;
margin-bottom: 16rpx;
}
.analyzing-desc {
font-size: 28rpx;
color: #6366f1;
margin-bottom: 24rpx;
text-align: center;
font-weight: 500;
}
.analyzing-bar {
width: 400rpx;
height: 12rpx;
background: #e2e8f0;
border-radius: 6rpx;
overflow: hidden;
margin-bottom: 24rpx;
}
.analyzing-bar-fill {
height: 100%;
background: linear-gradient(90deg, #6366f1, #a855f7);
border-radius: 6rpx;
transition: width 0.3s ease;
}
.analyzing-hint {
font-size: 24rpx;
color: #94a3b8;
text-align: center;
line-height: 1.6;
}