高考版优化

审核模式优化
This commit is contained in:
Ghost
2026-04-29 18:01:26 +08:00
parent 59419c4ce9
commit a411bf7b21
134 changed files with 19898 additions and 3371 deletions

2
h5-vue/.env.development Normal file
View File

@@ -0,0 +1,2 @@
# 后端 API 根地址(与小程序合法域名一致)。开发时可用 Vite 代理 /api
VITE_API_BASE=

4
h5-vue/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules
dist
*.local
.DS_Store

40
h5-vue/README.md Normal file
View File

@@ -0,0 +1,40 @@
# MBTI 小程序 → Vue 3 H5迁移工程
## 启动
```bash
cd h5-vue
npm install
npm run dev
```
浏览器打开终端提示的地址(默认 `http://localhost:5174`)。
配置接口地址:复制 `.env.development``.env.local`,设置:
```env
VITE_API_BASE=https://你的API域名
```
若本地后端与 Vite 同源代理,可在 `vite.config.ts` 里配置 `server.proxy`,并令 `VITE_API_BASE` 为空,请求走相对路径 `/api`
## 迁移进度说明
| 维度 | 说明 |
|------|------|
| **路由** | `app.json`**37** 条页面路径已全部注册 |
| **Tab 三页** | 首页 / 拍摄 / 我的:布局与小程序主区块对齐(含底部自定义 Tab |
| **占位页** | 其余 **34** 条为占位页(标题 + 路由名 + 回首页),便于按页迁移 |
| **基建** | Vue Router、Pinia、`/api/config/runtime` 拉取、axios 封装 |
### 完成度百分比(客观口径)
- **路由覆盖率**37 / 37 = **100%**(每条路径均可访问)
- **界面 + 主要交互按小程序复刻**:约 **3 / 37 ≈ 8%**(仅 Tab 内三页;其中拍摄页为能力说明型占位)
- **若计入基建(工程可运行 + Tab 壳 + Runtime**:约 **15%18%**(主观加权,供排期参考)
> 要做到「每个页面 1:1 功能与界面」,需按模块继续把 `.wxml/.wxss/.js` 迁到 `.vue`,并替换 `wx.*`(支付、登录、相机、分享等)。
## 静态资源
首页头图已尝试从 `../miniprogram/images/mbti-team-image.png` 复制到 `public/images/`;若缺失可手动拷贝同名文件。

15
h5-vue/env.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}
interface ImportMetaEnv {
readonly VITE_API_BASE: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

12
h5-vue/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover" />
<title>神仙团队性格测试</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2267
h5-vue/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
h5-vue/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "mbti-h5-vue",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.9",
"pinia": "^2.3.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"sass": "^1.83.4",
"typescript": "~5.7.2",
"vite": "^6.0.7",
"vue-tsc": "^2.2.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

13
h5-vue/src/App.vue Normal file
View File

@@ -0,0 +1,13 @@
<template>
<router-view />
</template>
<script setup lang="ts">
</script>
<style>
html, body, #app {
height: 100%;
margin: 0;
}
</style>

View File

@@ -0,0 +1,184 @@
<template>
<nav class="tab-bar" aria-label="主导航">
<div class="tab-bar-line" />
<div class="tab-bar-inner" :class="'tab-bar-count-' + items.length">
<template v-for="(it, index) in items" :key="it.path">
<button
v-if="it.highlight"
type="button"
class="tab-slot-middle"
:aria-current="selected === index ? 'page' : undefined"
@click="switchTab(it.path)"
>
<div class="middle-fab" :class="{ active: selected === index }">
<div class="center-circle">
<span class="center-svg center-svg--camera" />
</div>
<span class="tab-text tab-text-fab">{{ it.text }}</span>
</div>
</button>
<button
v-else
type="button"
class="tab-item"
:class="[
selected === index ? 'active' : '',
'tab-item--' + it.iconKey,
]"
:aria-current="selected === index ? 'page' : undefined"
@click="switchTab(it.path)"
>
<span class="tab-icon" :class="'tab-icon--' + it.iconKey + (selected === index ? ' tab-icon--active' : '')" />
<span class="tab-text">{{ it.text }}</span>
</button>
</template>
</div>
</nav>
</template>
<script setup lang="ts">
import { computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
/** 与当前 app.json tabBar.list 一致3 项);神仙 AI 已移除 */
const TAB_ITEMS = [
{ path: '/pages/index/index', text: '首页', iconKey: 'home', highlight: false },
{ path: '/pages/index/camera', text: '拍摄', iconKey: 'camera', highlight: true },
{ path: '/pages/profile/index', text: '我的', iconKey: 'profile', highlight: false },
]
const route = useRoute()
const router = useRouter()
const items = computed(() => TAB_ITEMS)
const selected = computed(() => {
const path = route.path.replace(/\/$/, '') || '/pages/index/index'
const i = TAB_ITEMS.findIndex((t) => t.path.replace(/\/$/, '') === path)
return i >= 0 ? i : 0
})
watch(
() => route.fullPath,
() => {},
{ immediate: true }
)
function switchTab(path: string) {
if (route.path.replace(/\/$/, '') === path.replace(/\/$/, '')) return
router.push(path)
}
defineOptions({ name: 'CustomTabBar' })
</script>
<style scoped lang="scss">
@function rpx($n) {
@return calc(#{$n} * 100vw / 750);
}
.tab-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 10000;
padding-bottom: env(safe-area-inset-bottom, 0px);
background: #fff;
box-shadow: 0 -2rpx 16rpx rgba(0, 0, 0, 0.06);
}
.tab-bar-line {
height: 1px;
background: rgba(0, 0, 0, 0.06);
}
.tab-bar-inner {
display: flex;
align-items: flex-end;
justify-content: space-around;
min-height: rpx(112);
padding: 0 rpx(16) rpx(8);
max-width: 100%;
}
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
gap: rpx(4);
padding: rpx(8) 0;
border: none;
background: transparent;
color: #999;
font-size: rpx(22);
}
.tab-item.active {
color: #7c3aed;
}
.tab-icon {
width: rpx(48);
height: rpx(48);
border-radius: rpx(12);
background: #e5e7eb;
}
.tab-item.active .tab-icon--home {
background: linear-gradient(135deg, #a78bfa, #7c3aed);
}
.tab-item.active .tab-icon--profile {
background: linear-gradient(135deg, #a78bfa, #7c3aed);
}
.tab-slot-middle {
flex: 1;
display: flex;
justify-content: center;
align-items: flex-end;
padding-bottom: rpx(4);
border: none;
background: transparent;
}
.middle-fab {
display: flex;
flex-direction: column;
align-items: center;
gap: rpx(4);
}
.center-circle {
width: rpx(112);
height: rpx(112);
margin-top: rpx(-36);
border-radius: 50%;
background: linear-gradient(145deg, #e9d5ff, #7c3aed);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 rpx(8) rpx(24) rgba(124, 58, 237, 0.35);
}
.center-svg--camera {
width: rpx(52);
height: rpx(52);
background: #fff;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='white' d='M9 4L7 6H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-3l-2-2H9zm3 13c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-2c1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3 1.34 3 3 3z'/%3E%3C/svg%3E")
center / contain no-repeat;
}
.tab-text-fab {
font-size: rpx(22);
color: #7c3aed;
font-weight: 600;
}
.tab-text {
font-size: rpx(22);
}
</style>

View File

@@ -0,0 +1,19 @@
<template>
<div class="tab-layout">
<router-view />
<CustomTabBar />
</div>
</template>
<script setup lang="ts">
import CustomTabBar from '@/components/CustomTabBar.vue'
defineOptions({ name: 'TabLayout' })
</script>
<style scoped lang="scss">
.tab-layout {
min-height: 100vh;
position: relative;
}
</style>

17
h5-vue/src/main.ts Normal file
View File

@@ -0,0 +1,17 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { useAppStore } from './stores/app'
import './styles/global.scss'
const app = createApp(App)
app.use(createPinia())
app.use(router)
const appStore = useAppStore()
appStore.initFromStorage()
// 与小程序一致:启动时拉 runtime不阻塞首屏
appStore.fetchRuntime().catch(() => {})
app.mount('#app')

112
h5-vue/src/router/index.ts Normal file
View File

@@ -0,0 +1,112 @@
import { createRouter, createWebHistory } from 'vue-router'
import TabLayout from '@/layouts/TabLayout.vue'
/** 与 miniprogram/app.json pages 数组顺序一致(用于统计迁移进度) */
export const MINI_PAGE_PATHS = [
'pages/index/index',
'pages/index/camera',
'pages/index/upload',
'pages/index/result',
'pages/test-select/index',
'pages/ai-test/index',
'pages/test/mbti',
'pages/test/sbti',
'pages/test/disc',
'pages/test/pdp',
'pages/result/mbti',
'pages/result/sbti',
'pages/result/disc',
'pages/result/pdp',
'pages/result/resume',
'pages/purchase/index',
'pages/recharge/index',
'pages/enterprise/index',
'pages/enterprise/resume-history',
'pages/profile/index',
'pages/user-profile/index',
'pages/history/index',
'pages/order/index',
'pages/phone-auth/index',
'pages/promo/index',
'pages/promo/poster',
'pages/promo/withdrawals',
'pages/match-job/index',
'pages/gaokao/index',
'pages/gaokao/form',
'pages/gaokao/report',
'pages/ai-chat/index',
'pages/ai-chat/report',
'pages/ai-chat/history',
'pages/webview/index',
] as const
export const TAB_PATHS = new Set([
'pages/index/index',
'pages/index/camera',
'pages/profile/index',
])
const IndexHome = () => import('@/views/tab/IndexHome.vue')
const CameraPage = () => import('@/views/tab/CameraPage.vue')
const ProfilePage = () => import('@/views/tab/ProfilePage.vue')
const PageStub = () => import('@/views/stub/PageStub.vue')
const TestSelectPage = () => import('@/views/pages/TestSelectPage.vue')
const MbtiTestPage = () => import('@/views/test/MbtiTestPage.vue')
const STUB_EXCLUDE = new Set<string>(['pages/test-select/index', 'pages/test/mbti'])
const stubRoutes = MINI_PAGE_PATHS.filter(
(p) => !TAB_PATHS.has(p) && !STUB_EXCLUDE.has(p)
).map((path) => ({
path: '/' + path,
name: 'stub-' + path.replace(/\//g, '-'),
component: PageStub,
meta: { miniPath: path, title: path.split('/').pop() || path },
}))
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
component: TabLayout,
redirect: '/pages/index/index',
children: [
{
path: 'pages/index/index',
name: 'home',
component: IndexHome,
meta: { miniPath: 'pages/index/index', tab: true },
},
{
path: 'pages/index/camera',
name: 'camera',
component: CameraPage,
meta: { miniPath: 'pages/index/camera', tab: true },
},
{
path: 'pages/profile/index',
name: 'profile',
component: ProfilePage,
meta: { miniPath: 'pages/profile/index', tab: true },
},
],
},
{
path: '/pages/test-select/index',
name: 'test-select',
component: TestSelectPage,
meta: { miniPath: 'pages/test-select/index', title: '选择测试' },
},
{
path: '/pages/test/mbti',
name: 'test-mbti',
component: MbtiTestPage,
meta: { miniPath: 'pages/test/mbti', title: 'MBTI 测试' },
},
...stubRoutes,
{ path: '/:pathMatch(.*)*', redirect: '/pages/index/index' },
],
})
export default router

98
h5-vue/src/stores/app.ts Normal file
View File

@@ -0,0 +1,98 @@
import axios from 'axios'
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { get, set as idbSet } from '../utils/storage'
const TOKEN_KEY = 'token'
const API_BASE_KEY = 'apiBase'
export const useAppStore = defineStore('app', () => {
const token = ref<string | null>(get(TOKEN_KEY))
const apiBase = ref(
(import.meta.env.VITE_API_BASE as string) || get(API_BASE_KEY) || ''
)
const siteTitle = ref('神仙团队性格测试')
const reviewMode = ref(false)
const maintenanceMode = ref(false)
const miniprogramAuditMode = ref(false)
const textConfig = ref<Record<string, string> | null>(null)
const enterprisePermissions = ref<Record<string, boolean> | null>(null)
const appScope = ref<'personal' | 'enterprise'>('personal')
/** 与小程序 globalData.enterpriseIdFromScene 等对齐,供题库/提交 enterpriseId */
const enterpriseIdFromScene = ref<number | null>(null)
const userEnterpriseId = ref<number | null>(null)
const defaultEnterpriseId = ref<number | null>(null)
/** 与小程序 testQuestionDrawCount抽题上限0 表示不限制) */
const testQuestionDrawCount = ref(0)
const reviewEff = computed(
() => reviewMode.value || maintenanceMode.value || miniprogramAuditMode.value
)
function initFromStorage() {
const t = get(TOKEN_KEY)
if (t) token.value = t
const base = import.meta.env.VITE_API_BASE || get(API_BASE_KEY)
if (base) apiBase.value = String(base).replace(/\/$/, '')
}
function setApiBase(base: string) {
apiBase.value = base.replace(/\/$/, '')
try {
idbSet(API_BASE_KEY, apiBase.value)
} catch {
/* ignore */
}
}
async function fetchRuntime(scope: 'personal' | 'enterprise' = 'personal') {
const base = apiBase.value
if (!base) return null
appScope.value = scope
const url = `${base}/api/config/runtime?scope=${encodeURIComponent(scope)}`
try {
const { data: body } = await axios.get<{ code: number; data?: Record<string, unknown> }>(
url,
{ timeout: 120000 }
)
if (!body || body.code !== 200 || !body.data) return null
const d = body.data as Record<string, unknown>
if (typeof d.siteTitle === 'string') siteTitle.value = d.siteTitle
if (typeof d.reviewMode === 'boolean') reviewMode.value = d.reviewMode
if (typeof d.maintenanceMode === 'boolean') maintenanceMode.value = d.maintenanceMode
if (typeof d.miniprogramAuditMode === 'boolean')
miniprogramAuditMode.value = d.miniprogramAuditMode
if (d.textConfig && typeof d.textConfig === 'object')
textConfig.value = d.textConfig as Record<string, string>
if (d.enterprisePermissions && typeof d.enterprisePermissions === 'object')
enterprisePermissions.value = d.enterprisePermissions as Record<string, boolean>
if (d.testQuestionDrawCount != null) {
const n = parseInt(String(d.testQuestionDrawCount), 10)
testQuestionDrawCount.value = Number.isFinite(n) && n > 0 ? Math.min(500, n) : 0
}
return d
} catch {
return null
}
}
return {
token,
apiBase,
siteTitle,
reviewMode,
maintenanceMode,
miniprogramAuditMode,
textConfig,
enterprisePermissions,
appScope,
enterpriseIdFromScene,
userEnterpriseId,
defaultEnterpriseId,
testQuestionDrawCount,
reviewEff,
initFromStorage,
setApiBase,
fetchRuntime,
}
})

View File

@@ -0,0 +1,28 @@
// 与小程序 750rpx 设计稿对齐100vw = 750rpx
@function rpx($n) {
@return calc(#{$n} * 100vw / 750);
}
:root {
--primary-color: #ff6b8a;
--secondary-color: #8b5cf6;
--text-primary: #1f2937;
--text-secondary: #6b7280;
--bg-secondary: #f3f4f6;
--tabbar-pad: calc(168 * 100vw / 750 + env(safe-area-inset-bottom, 0px) + 36 * 100vw / 750);
--tabbar-height: calc(168 * 100vw / 750);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
sans-serif;
font-size: rpx(28);
color: var(--text-primary);
background: var(--bg-secondary);
-webkit-tap-highlight-color: transparent;
}

10
h5-vue/src/utils/audit.ts Normal file
View File

@@ -0,0 +1,10 @@
/** 与小程序 miniprogramAuditGate.isAuditHideAiMode 一致:审核/维护/提审下隐藏高考与神仙 AI 等 */
export function isAuditHideAiMode(gd: {
reviewMode?: boolean
maintenanceMode?: boolean
miniprogramAuditMode?: boolean
} | null): boolean {
if (!gd) return false
return !!(gd.miniprogramAuditMode || gd.maintenanceMode || gd.reviewMode)
}

View File

@@ -0,0 +1,17 @@
import { useAppStore } from '@/stores/app'
/** 与小程序 enterpriseContext.getEnterpriseIdForApiPayload 对齐H5 暂无 scene eid默认 null */
export function getEnterpriseIdForApiPayload(): number | null {
const store = useAppStore()
const scope = store.appScope || 'personal'
if (scope === 'personal') {
const fromScene = store.enterpriseIdFromScene
if (fromScene != null && Number(fromScene) > 0) return Number(fromScene)
return null
}
const bound = store.userEnterpriseId
if (bound != null && Number(bound) > 0) return Number(bound)
const def = store.defaultEnterpriseId
if (def != null && Number(def) > 0) return Number(def)
return null
}

View File

@@ -0,0 +1,82 @@
import { mbtiDescriptions } from './mbtiDescriptions'
export interface MbtiCalcResult {
mbtiType: string
scores: Record<'E' | 'I' | 'S' | 'N' | 'T' | 'F' | 'J' | 'P', number>
dimensionScores: {
EI: { E: number; I: number; dominant: string; percentage: number }
SN: { S: number; N: number; dominant: string; percentage: number }
TF: { T: number; F: number; dominant: string; percentage: number }
JP: { J: number; P: number; dominant: string; percentage: number }
}
confidence: number
description: (typeof mbtiDescriptions)[string] | Record<string, never>
}
/** 与 miniprogram/pages/test/mbti.js calculateResult 一致 */
export function calculateMbtiResult(answers: Record<string, string>): MbtiCalcResult {
const scores = { E: 0, I: 0, S: 0, N: 0, T: 0, F: 0, J: 0, P: 0 }
Object.values(answers).forEach((value) => {
if (value in scores) {
scores[value as keyof typeof scores]++
}
})
const mbtiType = [
scores.E >= scores.I ? 'E' : 'I',
scores.S >= scores.N ? 'S' : 'N',
scores.T >= scores.F ? 'T' : 'F',
scores.J >= scores.P ? 'J' : 'P',
].join('')
const pct = (a: number, b: number) => {
const s = a + b
if (!s) return 50
return Math.round((Math.max(a, b) / s) * 100)
}
const dimensionScores = {
EI: {
E: scores.E,
I: scores.I,
dominant: scores.E >= scores.I ? 'E' : 'I',
percentage: pct(scores.E, scores.I),
},
SN: {
S: scores.S,
N: scores.N,
dominant: scores.S >= scores.N ? 'S' : 'N',
percentage: pct(scores.S, scores.N),
},
TF: {
T: scores.T,
F: scores.F,
dominant: scores.T >= scores.F ? 'T' : 'F',
percentage: pct(scores.T, scores.F),
},
JP: {
J: scores.J,
P: scores.P,
dominant: scores.J >= scores.P ? 'J' : 'P',
percentage: pct(scores.J, scores.P),
},
}
let confidence = Math.round(
(dimensionScores.EI.percentage +
dimensionScores.SN.percentage +
dimensionScores.TF.percentage +
dimensionScores.JP.percentage) /
4
)
if (!Number.isFinite(confidence)) confidence = 0
return {
mbtiType,
scores,
dimensionScores,
confidence,
description: mbtiDescriptions[mbtiType] || {},
}
}

View File

@@ -0,0 +1,159 @@
/** 与 miniprogram/utils/descriptions.js mbtiDescriptions 一致 */
export const mbtiDescriptions: Record<
string,
{
type: string
name: string
category: string
description: string
strengths: string[]
weaknesses: string[]
careers: string[]
}
> = {
ISTJ: {
type: 'ISTJ',
name: '物流师',
category: '守护者',
description: '务实、负责、可靠的传统主义者',
strengths: ['可靠负责', '注重细节', '有条理'],
weaknesses: ['可能过于固执', '不喜变化'],
careers: ['会计师', '审计师', '项目经理'],
},
ISFJ: {
type: 'ISFJ',
name: '守卫者',
category: '守护者',
description: '安静、友好、负责任的守护者',
strengths: ['忠诚体贴', '观察力强', '耐心'],
weaknesses: ['不善拒绝', '过于谦虚'],
careers: ['护士', '教师', '行政'],
},
INFJ: {
type: 'INFJ',
name: '提倡者',
category: '理想主义者',
description: '寻求意义和联系的理想主义者',
strengths: ['洞察力强', '有远见', '坚定'],
weaknesses: ['过于理想化', '容易疲惫'],
careers: ['心理咨询', '作家', '人力资源'],
},
INTJ: {
type: 'INTJ',
name: '建筑师',
category: '理想主义者',
description: '独立、有战略眼光的思考者',
strengths: ['战略思维', '独立自信', '意志坚定'],
weaknesses: ['可能傲慢', '过于苛刻'],
careers: ['战略顾问', '科学家', '架构师'],
},
ISTP: {
type: 'ISTP',
name: '鉴赏家',
category: '探险家',
description: '灵活、务实的问题解决者',
strengths: ['适应力强', '动手能力', '冷静'],
weaknesses: ['可能冷漠', '不善表达'],
careers: ['工程师', '技术员', '飞行员'],
},
ISFP: {
type: 'ISFP',
name: '艺术家',
category: '探险家',
description: '温和、敏感的艺术家',
strengths: ['创造力', '同理心', '灵活'],
weaknesses: ['过于敏感', '避免冲突'],
careers: ['设计师', '艺术家', '摄影师'],
},
INFP: {
type: 'INFP',
name: '调停者',
category: '理想主义者',
description: '理想主义、忠诚的调解者',
strengths: ['创造力', '同理心', '真诚'],
weaknesses: ['过于理想化', '情绪化'],
careers: ['作家', '心理咨询', '社工'],
},
INTP: {
type: 'INTP',
name: '逻辑学家',
category: '理想主义者',
description: '创新、逻辑的思考者',
strengths: ['逻辑思维', '创新能力', '客观'],
weaknesses: ['可能孤僻', '忽视情感'],
careers: ['程序员', '研究员', '分析师'],
},
ESTP: {
type: 'ESTP',
name: '动力者',
category: '探险家',
description: '精力充沛、务实的行动者',
strengths: ['果断', '务实', '善于应变'],
weaknesses: ['可能冲动', '缺乏耐心'],
careers: ['销售', '企业家', '运动员'],
},
ESFP: {
type: 'ESFP',
name: '表演者',
category: '探险家',
description: '热情、友好的社交达人',
strengths: ['热情友好', '乐观', '灵活'],
weaknesses: ['可能肤浅', '容易分心'],
careers: ['演员', '销售', '主持人'],
},
ENFP: {
type: 'ENFP',
name: '竞选者',
category: '理想主义者',
description: '热情、有创造力的社交者',
strengths: ['创造力', '热情', '善于沟通'],
weaknesses: ['可能不切实际', '缺乏专注'],
careers: ['市场营销', '记者', '顾问'],
},
ENTP: {
type: 'ENTP',
name: '辩论家',
category: '理想主义者',
description: '聪明、好奇的思想家',
strengths: ['创新能力', '辩论能力', '适应力'],
weaknesses: ['可能争辩', '不善执行'],
careers: ['律师', '企业家', '咨询'],
},
ESTJ: {
type: 'ESTJ',
name: '管理者',
category: '守护者',
description: '务实、果断的组织者',
strengths: ['领导力', '组织能力', '务实'],
weaknesses: ['可能专制', '不够灵活'],
careers: ['管理者', '项目经理', '律师'],
},
ESFJ: {
type: 'ESFJ',
name: '执政官',
category: '守护者',
description: '热心、合作的支持者',
strengths: ['关心他人', '负责任', '善于合作'],
weaknesses: ['过于在意评价', '不善拒绝'],
careers: ['教师', '护士', '人力资源'],
},
ENFJ: {
type: 'ENFJ',
name: '主人公',
category: '理想主义者',
description: '有魅力、鼓舞人心的领导者',
strengths: ['领导力', '同理心', '说服力'],
weaknesses: ['过于理想化', '过度付出'],
careers: ['培训师', '顾问', '教师'],
},
ENTJ: {
type: 'ENTJ',
name: '指挥官',
category: '理想主义者',
description: '大胆、有远见的领导者',
strengths: ['领导力', '战略思维', '果断'],
weaknesses: ['可能专制', '不耐烦'],
careers: ['CEO', '企业家', '律师'],
},
}

View File

@@ -0,0 +1,269 @@
// utils/mbtiLocalQuestions.js
// MBTI 本地 fallback 题库 —— 仅当 /api/test/questions 不可达时使用
// 覆盖四维度E/I · S/N · T/F · J/P各 15 题,共 60 题
// 注意:不替代线上题库,仅保底;结果计算走 pages/test/mbti.js 的 calculateResult
//
// 题目结构:{ id, text, dimension: 'EI'|'SN'|'TF'|'JP', options: [{ text, value }] }
// value 必须是 'E' / 'I' / 'S' / 'N' / 'T' / 'F' / 'J' / 'P' 八个字母之一
const QUESTIONS = [
// ==================== E / I 外向 vs 内向15 题)====================
{ id: 'L-EI-01', text: '参加聚会时,你更享受:', dimension: 'EI', options: [
{ text: '与很多人交谈互动', value: 'E' },
{ text: '和少数几个人深聊', value: 'I' }
]},
{ id: 'L-EI-02', text: '经过忙碌的一天,你更倾向于:', dimension: 'EI', options: [
{ text: '出门找朋友放松', value: 'E' },
{ text: '一个人独处充电', value: 'I' }
]},
{ id: 'L-EI-03', text: '遇到问题时,你先:', dimension: 'EI', options: [
{ text: '找人一起讨论', value: 'E' },
{ text: '一个人默默思考', value: 'I' }
]},
{ id: 'L-EI-04', text: '在新环境里,你通常:', dimension: 'EI', options: [
{ text: '主动跟大家打招呼', value: 'E' },
{ text: '先观察再慢慢融入', value: 'I' }
]},
{ id: 'L-EI-05', text: '你更喜欢的工作方式:', dimension: 'EI', options: [
{ text: '多人协作沟通推进', value: 'E' },
{ text: '独立专注深度工作', value: 'I' }
]},
{ id: 'L-EI-06', text: '面对一群陌生人,你会:', dimension: 'EI', options: [
{ text: '感到兴奋,期待结识', value: 'E' },
{ text: '感到疲惫,想早离场', value: 'I' }
]},
{ id: 'L-EI-07', text: '你表达想法时更习惯:', dimension: 'EI', options: [
{ text: '边说边想,口头表达', value: 'E' },
{ text: '先想好再开口或写下', value: 'I' }
]},
{ id: 'L-EI-08', text: '周末你更想:', dimension: 'EI', options: [
{ text: '约上朋友热闹一场', value: 'E' },
{ text: '一个人看书、发呆', value: 'I' }
]},
{ id: 'L-EI-09', text: '在会议中你更容易:', dimension: 'EI', options: [
{ text: '积极发言分享观点', value: 'E' },
{ text: '认真倾听再发言', value: 'I' }
]},
{ id: 'L-EI-10', text: '你的能量来源主要是:', dimension: 'EI', options: [
{ text: '和他人的互动交流', value: 'E' },
{ text: '独处的安静时光', value: 'I' }
]},
{ id: 'L-EI-11', text: '认识新朋友时你更看重:', dimension: 'EI', options: [
{ text: '广泛的社交圈', value: 'E' },
{ text: '少数深度的友谊', value: 'I' }
]},
{ id: 'L-EI-12', text: '演讲或发言机会出现时你:', dimension: 'EI', options: [
{ text: '愿意尝试,享受舞台', value: 'E' },
{ text: '倾向避免,偏好幕后', value: 'I' }
]},
{ id: 'L-EI-13', text: '你更容易:', dimension: 'EI', options: [
{ text: '主动开启对话', value: 'E' },
{ text: '等别人先开口', value: 'I' }
]},
{ id: 'L-EI-14', text: '一天下来话多了你会:', dimension: 'EI', options: [
{ text: '越讲越来劲', value: 'E' },
{ text: '想找个安静角落', value: 'I' }
]},
{ id: 'L-EI-15', text: '沉默的氛围你觉得:', dimension: 'EI', options: [
{ text: '尴尬,想打破', value: 'E' },
{ text: '舒服,享受宁静', value: 'I' }
]},
// ==================== S / N 感觉 vs 直觉15 题)====================
{ id: 'L-SN-01', text: '你更关注:', dimension: 'SN', options: [
{ text: '眼下具体的事实', value: 'S' },
{ text: '未来的可能性', value: 'N' }
]},
{ id: 'L-SN-02', text: '学习新知识时你偏好:', dimension: 'SN', options: [
{ text: '从实际案例入手', value: 'S' },
{ text: '先理解整体框架', value: 'N' }
]},
{ id: 'L-SN-03', text: '做决定时你更依赖:', dimension: 'SN', options: [
{ text: '过往经验与数据', value: 'S' },
{ text: '直觉与潜在趋势', value: 'N' }
]},
{ id: 'L-SN-04', text: '描述事物时你更倾向:', dimension: 'SN', options: [
{ text: '具体细节', value: 'S' },
{ text: '抽象概念', value: 'N' }
]},
{ id: 'L-SN-05', text: '面对项目,你会先:', dimension: 'SN', options: [
{ text: '列清具体步骤', value: 'S' },
{ text: '构思大方向与创意', value: 'N' }
]},
{ id: 'L-SN-06', text: '你更欣赏的书籍:', dimension: 'SN', options: [
{ text: '写实传记、工具书', value: 'S' },
{ text: '哲学、科幻、象征文学', value: 'N' }
]},
{ id: 'L-SN-07', text: '谈论"未来五年",你会:', dimension: 'SN', options: [
{ text: '列出具体目标与路径', value: 'S' },
{ text: '畅想各种可能性', value: 'N' }
]},
{ id: 'L-SN-08', text: '接到任务你先问:', dimension: 'SN', options: [
{ text: '怎么做、截止时间?', value: 'S' },
{ text: '为什么做、意义是?', value: 'N' }
]},
{ id: 'L-SN-09', text: '你喜欢的工作类型:', dimension: 'SN', options: [
{ text: '流程清晰、步骤明确', value: 'S' },
{ text: '需要创新与想象', value: 'N' }
]},
{ id: 'L-SN-10', text: '看风景时你会:', dimension: 'SN', options: [
{ text: '留意植物、天气、光线', value: 'S' },
{ text: '联想到某种心情或意境', value: 'N' }
]},
{ id: 'L-SN-11', text: '聊天中你更容易:', dimension: 'SN', options: [
{ text: '讲具体发生的事', value: 'S' },
{ text: '延伸到理论或类比', value: 'N' }
]},
{ id: 'L-SN-12', text: '解决问题时你更看重:', dimension: 'SN', options: [
{ text: '验证过的成熟方法', value: 'S' },
{ text: '新颖独特的切入点', value: 'N' }
]},
{ id: 'L-SN-13', text: '你的注意力更多放在:', dimension: 'SN', options: [
{ text: '此刻正在发生的事', value: 'S' },
{ text: '事情背后的模式', value: 'N' }
]},
{ id: 'L-SN-14', text: '你相信:', dimension: 'SN', options: [
{ text: '看得见的才是真的', value: 'S' },
{ text: '灵感与预感常常正确', value: 'N' }
]},
{ id: 'L-SN-15', text: '别人说你的特点:', dimension: 'SN', options: [
{ text: '踏实、可靠、注重细节', value: 'S' },
{ text: '想象力丰富、爱畅想', value: 'N' }
]},
// ==================== T / F 思考 vs 情感15 题)====================
{ id: 'L-TF-01', text: '做决定时你更看重:', dimension: 'TF', options: [
{ text: '逻辑与客观事实', value: 'T' },
{ text: '感受与人际影响', value: 'F' }
]},
{ id: 'L-TF-02', text: '朋友向你倾诉时你会:', dimension: 'TF', options: [
{ text: '分析原因、给出建议', value: 'T' },
{ text: '共情、陪伴感受', value: 'F' }
]},
{ id: 'L-TF-03', text: '批评同事工作时你更倾向:', dimension: 'TF', options: [
{ text: '直接指出问题', value: 'T' },
{ text: '委婉给建议', value: 'F' }
]},
{ id: 'L-TF-04', text: '你认为好决策要:', dimension: 'TF', options: [
{ text: '公正合理', value: 'T' },
{ text: '让人感到被尊重', value: 'F' }
]},
{ id: 'L-TF-05', text: '冲突中你更关注:', dimension: 'TF', options: [
{ text: '谁对谁错', value: 'T' },
{ text: '大家的感受', value: 'F' }
]},
{ id: 'L-TF-06', text: '工作评估你更相信:', dimension: 'TF', options: [
{ text: '数据与绩效', value: 'T' },
{ text: '态度与团队氛围', value: 'F' }
]},
{ id: 'L-TF-07', text: '你评价自己是:', dimension: 'TF', options: [
{ text: '理性、客观', value: 'T' },
{ text: '温暖、善解人意', value: 'F' }
]},
{ id: 'L-TF-08', text: '看电影时你更容易:', dimension: 'TF', options: [
{ text: '分析剧情逻辑', value: 'T' },
{ text: '被角色情绪打动', value: 'F' }
]},
{ id: 'L-TF-09', text: '他人失败时你会说:', dimension: 'TF', options: [
{ text: '看看哪里可以改进', value: 'T' },
{ text: '你辛苦了,别太难受', value: 'F' }
]},
{ id: 'L-TF-10', text: '买东西时你最在意:', dimension: 'TF', options: [
{ text: '性价比', value: 'T' },
{ text: '是否让自己或亲人开心', value: 'F' }
]},
{ id: 'L-TF-11', text: '谈判中你更可能:', dimension: 'TF', options: [
{ text: '坚持立场,据理力争', value: 'T' },
{ text: '寻求双方都舒服的方案', value: 'F' }
]},
{ id: 'L-TF-12', text: '接受反馈时你更在意:', dimension: 'TF', options: [
{ text: '反馈是否准确', value: 'T' },
{ text: '对方是否尊重你', value: 'F' }
]},
{ id: 'L-TF-13', text: '面对选择你先问自己:', dimension: 'TF', options: [
{ text: '哪个更合理?', value: 'T' },
{ text: '哪个让我更安心?', value: 'F' }
]},
{ id: 'L-TF-14', text: '你欣赏的领导:', dimension: 'TF', options: [
{ text: '目标清晰、决策果断', value: 'T' },
{ text: '关心员工、能激励人', value: 'F' }
]},
{ id: 'L-TF-15', text: '朋友犯错时你更倾向:', dimension: 'TF', options: [
{ text: '坦诚指出', value: 'T' },
{ text: '先理解再委婉提醒', value: 'F' }
]},
// ==================== J / P 判断 vs 知觉15 题)====================
{ id: 'L-JP-01', text: '你更喜欢:', dimension: 'JP', options: [
{ text: '计划好再行动', value: 'J' },
{ text: '边走边看,保持灵活', value: 'P' }
]},
{ id: 'L-JP-02', text: '你的桌面/房间通常:', dimension: 'JP', options: [
{ text: '整洁有序', value: 'J' },
{ text: '凌乱但自有章法', value: 'P' }
]},
{ id: 'L-JP-03', text: '旅行你更愿意:', dimension: 'JP', options: [
{ text: '提前订好行程', value: 'J' },
{ text: '随性出发,临时决定', value: 'P' }
]},
{ id: 'L-JP-04', text: '截止日期临近你会:', dimension: 'JP', options: [
{ text: '很早开始,提前完成', value: 'J' },
{ text: '最后冲刺,效率最高', value: 'P' }
]},
{ id: 'L-JP-05', text: '你喜欢的待办清单:', dimension: 'JP', options: [
{ text: '打勾一条一条完成', value: 'J' },
{ text: '灵活选择,心情驱动', value: 'P' }
]},
{ id: 'L-JP-06', text: '改变计划时你:', dimension: 'JP', options: [
{ text: '感到不安,希望稳定', value: 'J' },
{ text: '欢迎变化,觉得有趣', value: 'P' }
]},
{ id: 'L-JP-07', text: '你做决定的速度:', dimension: 'JP', options: [
{ text: '果断,能尽快确定', value: 'J' },
{ text: '偏慢,倾向保留选择', value: 'P' }
]},
{ id: 'L-JP-08', text: '你工作中更倾向:', dimension: 'JP', options: [
{ text: '设定目标并推进完成', value: 'J' },
{ text: '探索各种可能再定', value: 'P' }
]},
{ id: 'L-JP-09', text: '收到邀请你会:', dimension: 'JP', options: [
{ text: '马上确认或婉拒', value: 'J' },
{ text: '拖一阵看情况再定', value: 'P' }
]},
{ id: 'L-JP-10', text: '你更喜欢的节奏:', dimension: 'JP', options: [
{ text: '稳定可控的日常', value: 'J' },
{ text: '充满惊喜的变化', value: 'P' }
]},
{ id: 'L-JP-11', text: '面对突发任务你:', dimension: 'JP', options: [
{ text: '感到打乱、需要调整', value: 'J' },
{ text: '随机应变、不太介意', value: 'P' }
]},
{ id: 'L-JP-12', text: '你喜欢的工作方式:', dimension: 'JP', options: [
{ text: '按流程一步一步', value: 'J' },
{ text: '灵感来了就一口气干', value: 'P' }
]},
{ id: 'L-JP-13', text: '别人评价你:', dimension: 'JP', options: [
{ text: '有条理、守时', value: 'J' },
{ text: '随性、有弹性', value: 'P' }
]},
{ id: 'L-JP-14', text: '面对未完成的事你:', dimension: 'JP', options: [
{ text: '强迫自己收尾', value: 'J' },
{ text: '先放一放,换个状态', value: 'P' }
]},
{ id: 'L-JP-15', text: '你的生活哲学:', dimension: 'JP', options: [
{ text: '计划是成功的一半', value: 'J' },
{ text: '变化是唯一的不变', value: 'P' }
]}
]
/** 返回本地 MBTI 题库60 题) */
function getMbtiLocalQuestions() {
return QUESTIONS.map((q) => ({
id: q.id,
text: q.text,
dimension: q.dimension,
options: (q.options || []).map((o) => ({ ...o }))
}))
}
export { getMbtiLocalQuestions }

View File

@@ -0,0 +1,7 @@
import router from '@/router'
/** 与 miniprogram utils/phoneAuth.afterTestSubmitNavigate 对齐:提交后进结果页 */
export function afterTestSubmitNavigate(targetUrl: string): void {
const url = (targetUrl && String(targetUrl).trim()) || '/pages/index/index'
router.replace(url).catch(() => router.replace('/pages/index/index'))
}

View File

@@ -0,0 +1,174 @@
/**
* 与 miniprogram/utils/questionBank.js 对齐:远程拉题 + Fisher-Yates + MBTI 本地降级
*/
import { request } from '@/utils/request'
import { useAppStore } from '@/stores/app'
import { getEnterpriseIdForApiPayload } from '@/utils/enterpriseContext'
import { getMbtiLocalQuestions } from '@/utils/mbtiLocalQuestions'
export interface TestQuestionOption {
text: string
value: string
}
export interface TestQuestion {
id: string | number
question: string
options: TestQuestionOption[]
dimension?: string
}
interface ApiListBody {
code: number
message?: string
data?: { list?: unknown[] }
}
function parseJsonBody(raw: unknown): ApiListBody {
if (raw == null) return {}
if (typeof raw === 'object' && raw !== null && !Array.isArray(raw)) return raw as ApiListBody
if (typeof raw === 'string') {
const s = raw.trim()
if (!s) return {}
try {
return JSON.parse(s) as ApiListBody
} catch {
return { code: -1, message: 'parse error' }
}
}
return {}
}
export function shuffleQuestions(questions: TestQuestion[]): TestQuestion[] {
const arr = (questions || []).map((q) => ({
...q,
options: (q.options || []).slice().sort(() => Math.random() - 0.5),
}))
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[arr[i], arr[j]] = [arr[j], arr[i]]
}
return arr
}
function normalizeQuestion(raw: Record<string, unknown>): TestQuestion | null {
const id = raw.id
if (id == null) return null
const qtext =
(typeof raw.question === 'string' && raw.question) ||
(typeof raw.text === 'string' && raw.text) ||
''
const options = Array.isArray(raw.options) ? raw.options : []
const mapped: TestQuestionOption[] = options
.map((o: unknown) => {
if (!o || typeof o !== 'object') return null
const x = o as Record<string, unknown>
const text = typeof x.text === 'string' ? x.text : ''
const value = typeof x.value === 'string' ? x.value : ''
if (!text || !value) return null
return { text, value }
})
.filter((x): x is TestQuestionOption => x != null)
return {
id: id as string | number,
question: qtext,
options: mapped,
dimension: typeof raw.dimension === 'string' ? raw.dimension : undefined,
}
}
function applyDrawCountAfterShuffle(questions: TestQuestion[]): TestQuestion[] {
const store = useAppStore()
const n = parseInt(String(store.testQuestionDrawCount ?? '0'), 10)
const draw = Number.isFinite(n) && n > 0 ? Math.min(500, n) : 0
if (!draw || questions.length <= draw) return questions
return questions.slice(0, draw)
}
function resolveEnterpriseIdForQuestionBank(opts: { enterpriseId?: number | null }): number | null {
if (Object.prototype.hasOwnProperty.call(opts, 'enterpriseId')) {
const v = opts.enterpriseId
if (v == null || v === '') return null
const num = Number(v)
return Number.isFinite(num) && num > 0 ? num : null
}
return getEnterpriseIdForApiPayload()
}
function loadLocalFallback(type: string): TestQuestion[] | null {
if (type !== 'mbti') return null
try {
const list = getMbtiLocalQuestions() as Record<string, unknown>[]
if (!Array.isArray(list) || !list.length) return null
return list
.map((q) => normalizeQuestion(q))
.filter((x): x is TestQuestion => x != null && x.options.length > 0)
} catch {
return null
}
}
type FetchErr = Error & { statusCode?: number; isNetworkError?: boolean }
async function fetchQuestionBank(type: string, enterpriseId: number | null): Promise<TestQuestion[]> {
const q = [`type=${encodeURIComponent(type)}`]
if (enterpriseId != null && Number(enterpriseId) > 0) {
q.push(`enterpriseId=${Number(enterpriseId)}`)
}
try {
const res = await request<string | ApiListBody>({
url: `/api/test/questions?${q.join('&')}`,
method: 'GET',
needAuth: true,
})
const body = parseJsonBody(res.data)
if (body.code !== 200 || body.data == null) {
const msg = body.message || '拉取题库失败'
const hint =
body.code === 401 || body.code === 403 ? `${msg}(请重新登录或刷新)` : msg
const e = new Error(hint) as FetchErr
e.statusCode = body.code === 401 ? 401 : body.code === 403 ? 403 : undefined
throw e
}
const list = body.data?.list
if (!Array.isArray(list)) throw new Error('题库格式错误')
const mapped = list
.map((item) => normalizeQuestion(item as Record<string, unknown>))
.filter((x): x is TestQuestion => x != null && x.question && x.options.length > 0)
return mapped
} catch (err: unknown) {
const ax = err as { response?: { status?: number }; message?: string }
const status = ax.response?.status
const e = new Error(ax.message || '网络请求失败') as FetchErr
e.statusCode = status
e.isNetworkError = ax.response == null
throw e
}
}
export function loadQuestions(
type: 'mbti' | 'sbti' | 'disc' | 'pdp',
opts: { enterpriseId?: number | null; allowLocalFallback?: boolean } = {}
): Promise<TestQuestion[]> {
const enterpriseId = resolveEnterpriseIdForQuestionBank(opts)
const allowLocal = opts.allowLocalFallback !== false
return fetchQuestionBank(type, enterpriseId)
.then((list) => {
if (!list.length) throw new Error('暂无启用题目')
return applyDrawCountAfterShuffle(shuffleQuestions(list))
})
.catch((err: FetchErr) => {
const status = err.statusCode
const canFallback =
err.isNetworkError ||
(status != null && status >= 500 && status < 600) ||
status === 401
if (!allowLocal || !canFallback) throw err
const local = loadLocalFallback(type)
if (!local || !local.length) throw err
console.warn('[questionBank] 后端不可达,降级本地 fallback 题库:', type, err.message)
return applyDrawCountAfterShuffle(shuffleQuestions(local))
})
}

View File

@@ -0,0 +1,32 @@
import axios, { type AxiosRequestConfig } from 'axios'
import { useAppStore } from '@/stores/app'
export interface RequestOptions extends AxiosRequestConfig {
needAuth?: boolean
}
export async function request<T = unknown>(options: RequestOptions): Promise<{ data: T }> {
const store = useAppStore()
const apiBase = store.apiBase || ''
const path = options.url || ''
const fullUrl = path.startsWith('http')
? path
: `${apiBase.replace(/\/$/, '')}${path.startsWith('/') ? '' : '/'}${path}`
const needAuth = options.needAuth !== false
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
}
if (needAuth && store.token) {
headers.Authorization = `Bearer ${store.token}`
}
const res = await axios.request<T>({
...options,
url: fullUrl,
headers,
timeout: options.timeout ?? 120000,
})
return { data: res.data }
}

View File

@@ -0,0 +1,24 @@
/** H5 替代 wx.setStorageSync */
export function get(key: string): string | null {
try {
return localStorage.getItem(key)
} catch {
return null
}
}
export function set(key: string, val: string): void {
try {
localStorage.setItem(key, val)
} catch {
/* ignore */
}
}
export function remove(key: string): void {
try {
localStorage.removeItem(key)
} catch {
/* ignore */
}
}

View File

@@ -0,0 +1,52 @@
import { request } from '@/utils/request'
import { useAppStore } from '@/stores/app'
import { getEnterpriseIdForApiPayload } from '@/utils/enterpriseContext'
import { set } from '@/utils/storage'
/** 与 miniprogram app.saveTestResult 对齐:本地存结果 + POST /api/test/submit */
export async function saveTestResult(
type: string,
result: Record<string, unknown>
): Promise<{ id?: string | number }> {
const store = useAppStore()
const key = `${type}Result`
try {
set(key, JSON.stringify(result))
} catch {
/* ignore */
}
if (!store.token) {
return {}
}
const enterpriseId = getEnterpriseIdForApiPayload()
try {
const { data: body } = await request<{ code: number; data?: { id?: string | number } }>({
url: '/api/test/submit',
method: 'POST',
needAuth: true,
data: {
testType: type,
answers: result.answers ?? [],
result,
enterpriseId: enterpriseId != null ? enterpriseId : undefined,
testDuration: result.testDuration ?? 0,
timestamp: new Date().toISOString(),
},
})
if (
body &&
body.code === 200 &&
body.data &&
typeof body.data === 'object' &&
body.data.id != null
) {
return { id: body.data.id }
}
return {}
} catch {
return {}
}
}

23
h5-vue/src/utils/toast.ts Normal file
View File

@@ -0,0 +1,23 @@
/** 轻量 Toast替代 wx.showToast */
export function showToast(message: string, durationMs = 2200): void {
const el = document.createElement('div')
el.textContent = message
el.style.cssText = [
'position:fixed',
'left:50%',
'bottom:max(80px,env(safe-area-inset-bottom))',
'transform:translateX(-50%)',
'background:rgba(0,0,0,.78)',
'color:#fff',
'padding:10px 16px',
'border-radius:8px',
'font-size:14px',
'z-index:99999',
'max-width:90vw',
'text-align:center',
'pointer-events:none',
].join(';')
document.body.appendChild(el)
setTimeout(() => el.remove(), durationMs)
}

View File

@@ -0,0 +1,343 @@
<template>
<div class="container">
<div class="card intro-card">
<span class="intro-title">选择一项详细性格测试</span>
<span v-if="auditHideAiFeatures" class="intro-desc">
问卷测评与拍照面相分项进入完成后可获得对应报告
</span>
<span v-else class="intro-desc">
问卷测评高考志愿规划与 AI 对话 / 拍照面相分项进入完成后可获得对应报告
</span>
</div>
<div v-if="permMbti" class="card entry-card" @click="goMBTI">
<div class="entry-icon-wrap mbti"><span class="entry-emoji">🧠</span></div>
<div class="entry-content">
<span class="entry-name">MBTI 性格测试</span>
<span class="entry-brief">16型人格 · 发现你的认知与决策风格</span>
</div>
<span class="entry-arrow"></span>
</div>
<div v-if="permSbti" class="card entry-card" @click="goSBTI">
<div class="entry-icon-wrap sbti"><span class="entry-emoji">🎭</span></div>
<div class="entry-content">
<span class="entry-name">SBTI 性格测试</span>
<span class="entry-brief">15 维等级匹配 · 闸口题与标准计分一致</span>
</div>
<span class="entry-arrow"></span>
</div>
<div v-if="permPdp" class="card entry-card" @click="goPDP">
<div class="entry-icon-wrap pdp"><span class="entry-emoji">🦁</span></div>
<div class="entry-content">
<span class="entry-name">PDP 行为偏好测试</span>
<span class="entry-brief">老虎 / 孔雀 / 无尾熊 / 猫头鹰 / 变色龙 · 行为风格</span>
</div>
<span class="entry-arrow"></span>
</div>
<div v-if="permDisc" class="card entry-card" @click="goDISC">
<div class="entry-icon-wrap disc"><span class="entry-emoji">📊</span></div>
<div class="entry-content">
<span class="entry-name">DISC 性格测试</span>
<span class="entry-brief">D/I/S/C 四维 · 沟通与行为倾向</span>
</div>
<span class="entry-arrow"></span>
</div>
<div v-if="permGaokao" class="card entry-card" @click="goGaokaoHub">
<div class="entry-icon-wrap gaokao"><span class="entry-emoji">🎓</span></div>
<div class="entry-content">
<span class="entry-name">高考志愿任务中心</span>
<span class="entry-brief">
完成 MBTI / PDP / DISC / 拍照面相与信息表单 · 购买报告后生成志愿分析
</span>
</div>
<span class="entry-arrow"></span>
</div>
<div v-if="permAiHub" class="card entry-card" @click="goAIChatInterpretation">
<div class="entry-icon-wrap ai-chat"><span class="entry-emoji">💬</span></div>
<div class="entry-content">
<span class="entry-name">AI 对话解读</span>
<span class="entry-brief">与神仙 AI 对话 · 结合测评画像的深度解读与建议</span>
</div>
<span class="entry-arrow"></span>
</div>
<div v-if="permFace" class="card entry-card" @click="goAIFaceAnalysis">
<div class="entry-icon-wrap ai-face"><span class="entry-emoji">📷</span></div>
<div class="entry-content">
<span class="entry-name">拍照面相分析</span>
<span class="entry-brief">上传正面与侧面照 · 面相 / 骨相与性格报告</span>
</div>
<span class="entry-arrow"></span>
</div>
<div v-if="allTestsDisabled" class="card perm-disabled-hint">
<span class="perm-disabled-text">
当前环境下暂未开放问卷测试请联系管理员或从首页进入
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useAppStore } from '@/stores/app'
import { isAuditHideAiMode } from '@/utils/audit'
import { showToast } from '@/utils/toast'
const router = useRouter()
const store = useAppStore()
const { enterprisePermissions } = storeToRefs(store)
const gd = computed(() => ({
reviewMode: store.reviewMode,
maintenanceMode: store.maintenanceMode,
miniprogramAuditMode: store.miniprogramAuditMode,
}))
const auditHideAiFeatures = computed(() => isAuditHideAiMode(gd.value))
const permFace = computed(() => !enterprisePermissions.value || enterprisePermissions.value.face !== false)
const permMbti = computed(() => !enterprisePermissions.value || enterprisePermissions.value.mbti !== false)
const permSbti = computed(() => !enterprisePermissions.value || enterprisePermissions.value.sbti !== false)
const permPdp = computed(() => !enterprisePermissions.value || enterprisePermissions.value.pdp !== false)
const permDisc = computed(() => !enterprisePermissions.value || enterprisePermissions.value.disc !== false)
const permGaokao = computed(() => {
const p = enterprisePermissions.value
const base = !p || p.gaokao !== false
return base && !auditHideAiFeatures.value
})
const permAiHub = computed(() => {
const p = enterprisePermissions.value
const base = !p || p.aiHub !== false
return base && !auditHideAiFeatures.value
})
const allTestsDisabled = computed(() => {
const p = enterprisePermissions.value
return !!(
p &&
!permMbti.value &&
!permSbti.value &&
!permPdp.value &&
!permDisc.value &&
!permGaokao.value
)
})
function syncPerms() {
store.fetchRuntime().catch(() => {})
}
onMounted(() => {
syncPerms()
})
function goMBTI() {
router.push('/pages/test/mbti')
}
function goSBTI() {
router.push('/pages/test/sbti')
}
function goPDP() {
router.push('/pages/test/pdp')
}
function goDISC() {
router.push('/pages/test/disc')
}
function goGaokaoHub() {
if (isAuditHideAiMode(gd.value)) {
showToast('版本审核中暂不可用')
return
}
if (!permGaokao.value) {
showToast('当前企业未开放高考志愿功能')
return
}
router.push('/pages/gaokao/index')
}
function goAIChatInterpretation() {
if (isAuditHideAiMode(gd.value)) {
showToast('功能升级中')
return
}
router.push('/pages/ai-chat/index?src=test_select')
}
function goAIFaceAnalysis() {
router.push('/pages/index/camera')
}
</script>
<style scoped lang="scss">
@use '@/styles/global.scss' as *;
.container {
min-height: 100vh;
background: linear-gradient(180deg, #fff5f5 0%, #f5f5f5 50%, #ffffff 100%);
padding: rpx(24) rpx(24) rpx(60);
}
.card {
background-color: #fff;
border-radius: rpx(16);
padding: rpx(32);
margin-bottom: rpx(24);
box-shadow: 0 rpx(2) rpx(8) rgba(0, 0, 0, 0.04);
}
.intro-card {
text-align: center;
padding: rpx(40) rpx(32);
}
.intro-title {
display: block;
font-size: rpx(34);
font-weight: 700;
color: #e63946;
margin-bottom: rpx(16);
}
.intro-desc {
display: block;
font-size: rpx(26);
color: #666;
line-height: 1.6;
}
.perm-disabled-hint {
text-align: center;
padding: rpx(32);
}
.perm-disabled-text {
font-size: rpx(28);
color: #888;
line-height: 1.6;
}
.entry-card {
display: flex;
align-items: center;
padding: rpx(28) rpx(32);
cursor: pointer;
transition: opacity 0.2s;
border-radius: rpx(16);
border: rpx(2) solid transparent;
&:active {
opacity: 0.9;
background: linear-gradient(
135deg,
rgba(230, 57, 70, 0.04) 0%,
rgba(255, 107, 157, 0.04) 100%
);
border-color: rgba(230, 57, 70, 0.15);
}
}
.entry-icon-wrap {
width: rpx(88);
height: rpx(88);
border-radius: rpx(20);
display: flex;
align-items: center;
justify-content: center;
margin-right: rpx(24);
flex-shrink: 0;
&.mbti {
background: linear-gradient(
135deg,
rgba(124, 58, 237, 0.15) 0%,
rgba(139, 92, 246, 0.2) 100%
);
}
&.sbti {
background: linear-gradient(135deg, #f2f7f3 0%, #dce8e0 100%);
border: rpx(1) solid #c5d4cc;
}
&.pdp {
background: linear-gradient(
135deg,
rgba(230, 57, 70, 0.12) 0%,
rgba(255, 107, 157, 0.15) 100%
);
}
&.disc {
background: linear-gradient(
135deg,
rgba(59, 130, 246, 0.12) 0%,
rgba(96, 165, 250, 0.18) 100%
);
}
&.gaokao {
background: linear-gradient(
135deg,
rgba(79, 70, 229, 0.18) 0%,
rgba(129, 140, 248, 0.22) 100%
);
border: rpx(1) solid rgba(99, 102, 241, 0.25);
}
&.ai-chat {
background: linear-gradient(
135deg,
rgba(124, 58, 237, 0.16) 0%,
rgba(99, 102, 241, 0.2) 100%
);
}
&.ai-face {
background: linear-gradient(
135deg,
rgba(236, 72, 153, 0.14) 0%,
rgba(244, 114, 182, 0.18) 100%
);
}
}
.entry-emoji {
font-size: rpx(44);
}
.entry-content {
flex: 1;
min-width: 0;
}
.entry-name {
display: block;
font-size: rpx(30);
font-weight: 600;
color: #333;
margin-bottom: rpx(8);
}
.entry-brief {
display: block;
font-size: rpx(24);
color: #888;
line-height: 1.4;
}
.entry-arrow {
font-size: rpx(32);
color: #e63946;
font-weight: 600;
margin-left: rpx(16);
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,103 @@
<template>
<div class="stub-page">
<header class="stub-nav">
<button type="button" class="back" aria-label="返回" @click="goBack"></button>
<h1 class="stub-title">{{ title }}</h1>
</header>
<div class="stub-body">
<p class="path"><code>{{ miniPath }}</code></p>
<p class="tip">
此页对应小程序路由已注册完整 UI / 交互 / 微信支付 / 相机等需在 H5 逐项迁移
</p>
<button type="button" class="primary" @click="$router.push('/pages/index/index')">回首页</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const router = useRouter()
const route = useRoute()
const miniPath = computed(() => (route.meta.miniPath as string) || route.path.replace(/^\//, ''))
const title = computed(() => (route.meta.title as string) || miniPath.value)
function goBack() {
if (typeof window !== 'undefined' && window.history.length > 1) router.back()
else router.push('/pages/index/index')
}
defineOptions({ name: 'PageStub' })
</script>
<style scoped lang="scss">
@function rpx($n) {
@return calc(#{$n} * 100vw / 750);
}
.stub-page {
min-height: 100vh;
background: #f8fafc;
}
.stub-nav {
display: flex;
align-items: center;
gap: rpx(16);
padding: rpx(24) rpx(24) rpx(16);
padding-top: calc(rpx(24) + env(safe-area-inset-top, 0px));
background: #fff;
border-bottom: 1px solid #e2e8f0;
}
.back {
width: rpx(72);
height: rpx(72);
border: none;
border-radius: rpx(36);
background: #f1f5f9;
font-size: rpx(44);
line-height: 1;
cursor: pointer;
}
.stub-title {
margin: 0;
flex: 1;
font-size: rpx(32);
font-weight: 700;
color: #0f172a;
}
.stub-body {
padding: rpx(40);
}
.path code {
font-size: rpx(24);
word-break: break-all;
color: #475569;
}
.tip {
margin-top: rpx(24);
font-size: rpx(28);
color: #64748b;
line-height: 1.6;
}
.primary {
margin-top: rpx(48);
width: 100%;
padding: rpx(28);
border: none;
border-radius: rpx(44);
background: linear-gradient(135deg, #7c3aed, #a78bfa);
color: #fff;
font-size: rpx(30);
font-weight: 600;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,84 @@
<template>
<div class="camera-page tabbar-pad">
<header class="nav">
<h1 class="nav-title">拍摄</h1>
<p class="nav-desc">H5 端相机能力需浏览器权限完整流程请使用微信小程序</p>
</header>
<div class="panel">
<p class="hint">与小程序 <code>pages/index/camera</code> 对齐此处为界面骨架可接入 WebRTC / 上传照片</p>
<button type="button" class="btn" @click="$router.push('/pages/test-select/index')">去选择性格测试</button>
<button type="button" class="btn ghost" @click="$router.push('/pages/index/index')">回首页</button>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'CameraPage' })
</script>
<style scoped lang="scss">
@function rpx($n) {
@return calc(#{$n} * 100vw / 750);
}
.camera-page {
min-height: 100vh;
background: linear-gradient(180deg, #faf9ff, #fff);
padding: rpx(48) rpx(40);
padding-bottom: calc(var(--tabbar-pad) + #{rpx(32)});
}
.nav-title {
margin: 0 0 rpx(16);
font-size: rpx(40);
color: #312e81;
}
.nav-desc {
margin: 0;
font-size: rpx(26);
color: #64748b;
line-height: 1.5;
}
.panel {
margin-top: rpx(48);
padding: rpx(32);
background: rgba(255, 255, 255, 0.9);
border-radius: rpx(24);
border: 1px solid rgba(167, 139, 250, 0.25);
}
.hint {
font-size: rpx(26);
color: #475569;
line-height: 1.6;
}
.btn {
display: block;
width: 100%;
margin-top: rpx(24);
padding: rpx(24);
border: none;
border-radius: rpx(44);
background: linear-gradient(135deg, #7c3aed, #a78bfa);
color: #fff;
font-size: rpx(28);
font-weight: 600;
cursor: pointer;
}
.btn.ghost {
background: #fff;
color: #7c3aed;
border: 2px solid #ddd6fe;
}
code {
font-size: 90%;
background: #f1f5f9;
padding: 2px 6px;
border-radius: 4px;
}
</style>

View File

@@ -0,0 +1,392 @@
<template>
<div class="container tabbar-pad" :style="{ paddingTop: navbarHeightPx + 'px' }">
<header class="custom-navbar" :style="{ paddingTop: statusBarPx + 'px' }">
<div class="navbar-content">
<button
v-if="showEnterpriseEntry && !reviewEff"
type="button"
class="switch-enterprise-btn"
@click="goEnterprise"
>
<span class="enterprise-icon">🏢</span>
<span class="enterprise-text">企业版</span>
</button>
<div v-else class="navbar-placeholder" />
<h1 class="navbar-title">{{ displayTitle }}</h1>
<div class="navbar-placeholder" />
</div>
</header>
<div class="bg-decoration bg-top-right" aria-hidden="true" />
<div class="bg-decoration bg-bottom-left" aria-hidden="true" />
<section class="top-image-section">
<div class="image-container">
<div class="image-wrapper">
<img class="main-image" :src="heroImg" alt="" @error="onImgErr" />
</div>
<template v-if="!reviewEff && permFace">
<span class="float-tag tag-1">面相分析</span>
<span class="float-tag tag-2">骨相分析</span>
<span class="float-tag tag-3">性格测评</span>
</template>
<template v-else-if="reviewEff">
<span class="float-tag tag-1">MBTI</span>
<span class="float-tag tag-2">DISC</span>
<span class="float-tag tag-3">性格测评</span>
</template>
<template v-else>
<span class="float-tag tag-3">性格测评</span>
</template>
</div>
</section>
<section class="process-section">
<h2 class="section-title">测试流程</h2>
<div v-if="!reviewEff && permFace" class="process-steps">
<div class="step-item">
<div class="step-circle active">1</div>
<span class="step-label">STEP1</span>
<span class="step-text">拍摄照片</span>
</div>
<div class="step-line" />
<div class="step-item">
<div class="step-circle">2</div>
<span class="step-label">STEP2</span>
<span class="step-text">{{ aiAnalysisText }}</span>
</div>
<div class="step-line" />
<div class="step-item">
<div class="step-circle">3</div>
<span class="step-label">STEP3</span>
<span class="step-text">生成报告</span>
</div>
</div>
<div v-else class="process-steps">
<div class="step-item">
<div class="step-circle active">1</div>
<span class="step-label">STEP1</span>
<span class="step-text">选择测试</span>
</div>
<div class="step-line" />
<div class="step-item">
<div class="step-circle">2</div>
<span class="step-label">STEP2</span>
<span class="step-text">回答问题</span>
</div>
<div class="step-line" />
<div class="step-item">
<div class="step-circle">3</div>
<span class="step-label">STEP3</span>
<span class="step-text">查看结果</span>
</div>
</div>
</section>
<button type="button" class="start-button" @click="startCamera">
<span class="button-text">{{ startButtonText }}</span>
</button>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app'
const router = useRouter()
const store = useAppStore()
const statusBarPx = ref(0)
const navbarHeightPx = ref(88)
const showEnterpriseEntry = ref(false)
const heroBroken = ref(false)
const heroImg = computed(() =>
heroBroken.value
? 'data:image/svg+xml,' +
encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400"><rect fill="#f0f4ff" width="400" height="400"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="#94a3b8" font-size="18">MBTI</text></svg>`
)
: '/images/mbti-team-image.png'
)
const reviewEff = computed(() => store.reviewEff)
const permFace = computed(() => {
const ep = store.enterprisePermissions
return !ep || ep.face !== false
})
const displayTitle = computed(() => {
const t = store.siteTitle || '神仙团队性格测试'
return reviewEff.value ? t.replace(/AI/gi, '') : t
})
const startButtonText = computed(() => {
if (reviewEff.value) return '开始性格测试'
return store.textConfig?.startButtonText || '30秒测出你的性格'
})
const aiAnalysisText = computed(() => {
if (reviewEff.value) return '分析'
return store.textConfig?.aiAnalysisText || '分析'
})
function onImgErr() {
heroBroken.value = true
}
function startCamera() {
router.push('/pages/index/camera')
}
function goEnterprise() {
router.push('/pages/enterprise/index')
}
onMounted(() => {
if (typeof window !== 'undefined') {
statusBarPx.value = 0
navbarHeightPx.value = 44 + statusBarPx.value
}
store.fetchRuntime().catch(() => {})
})
defineOptions({ name: 'IndexHome' })
</script>
<style scoped lang="scss">
@function rpx($n) {
@return calc(#{$n} * 100vw / 750);
}
.container {
min-height: 100vh;
width: 100vw;
overflow-x: hidden;
background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 50%, #ffffff 100%);
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.tabbar-pad {
padding-bottom: calc(var(--tabbar-pad) + #{rpx(32)}) !important;
}
.bg-decoration {
position: absolute;
width: rpx(600);
height: rpx(600);
border-radius: 50%;
pointer-events: none;
z-index: 0;
}
.bg-top-right {
top: rpx(-200);
right: rpx(-200);
background: radial-gradient(circle, rgba(139, 92, 246, 0.08) 0%, transparent 70%);
}
.bg-bottom-left {
bottom: 0;
left: 0;
background: radial-gradient(circle, rgba(99, 102, 241, 0.06) 0%, transparent 70%);
}
.custom-navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
background: #fff;
z-index: 10000;
}
.navbar-content {
height: rpx(88);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 rpx(40);
position: relative;
}
.navbar-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
margin: 0;
font-size: rpx(36);
font-weight: 700;
color: #e63946;
}
.navbar-placeholder {
width: rpx(140);
flex-shrink: 0;
}
.switch-enterprise-btn {
display: flex;
align-items: center;
gap: rpx(8);
padding: rpx(12) rpx(20);
background: rgba(230, 57, 70, 0.1);
border-radius: rpx(30);
border: 1px solid rgba(230, 57, 70, 0.2);
cursor: pointer;
z-index: 10;
}
.enterprise-text {
font-size: rpx(24);
color: #e63946;
font-weight: 600;
}
.top-image-section {
width: 100%;
padding: rpx(8) rpx(40) rpx(10);
position: relative;
z-index: 1;
margin-top: rpx(-20);
}
.image-container {
position: relative;
width: 100%;
transform: translateY(rpx(-26));
margin-bottom: rpx(-26);
}
.image-wrapper {
position: relative;
width: 100%;
padding-top: 100%;
border-radius: 50%;
overflow: hidden;
background: #fff;
box-shadow: 0 rpx(8) rpx(32) rgba(0, 0, 0, 0.12);
}
.main-image {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 85%;
height: 85%;
object-fit: contain;
}
.float-tag {
position: absolute;
padding: rpx(12) rpx(24);
border-radius: rpx(30);
font-size: rpx(24);
font-weight: 600;
color: #e63946;
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 rpx(4) rpx(16) rgba(230, 57, 70, 0.2);
white-space: nowrap;
z-index: 10;
}
.tag-1 {
top: 15%;
right: rpx(10);
}
.tag-2 {
bottom: 25%;
left: 0;
}
.tag-3 {
bottom: 25%;
right: 0;
}
.process-section {
padding: rpx(8) rpx(40) rpx(20);
position: relative;
z-index: 1;
}
.section-title {
font-size: rpx(32);
font-weight: 700;
color: #e63946;
text-align: center;
margin: 0 0 rpx(25);
}
.process-steps {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 rpx(10);
}
.step-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.step-circle {
width: rpx(56);
height: rpx(56);
border-radius: 50%;
background: #e5e7eb;
color: #9ca3af;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: rpx(26);
}
.step-circle.active {
background: linear-gradient(135deg, #e63946, #ff8e53);
color: #fff;
}
.step-label {
font-size: rpx(20);
color: #9ca3af;
margin-top: rpx(8);
}
.step-text {
font-size: rpx(22);
color: #374151;
margin-top: rpx(4);
}
.step-line {
flex: 0 0 rpx(40);
height: rpx(4);
background: #e5e7eb;
align-self: flex-start;
margin-top: rpx(26);
}
.start-button {
margin: rpx(16) rpx(48) rpx(48);
height: rpx(96);
border: none;
border-radius: rpx(48);
background: linear-gradient(135deg, #e63946 0%, #ff8e53 100%);
color: #fff;
font-size: rpx(32);
font-weight: 700;
cursor: pointer;
box-shadow: 0 rpx(12) rpx(32) rgba(230, 57, 70, 0.35);
}
.button-text {
display: block;
}
</style>

View File

@@ -0,0 +1,289 @@
<template>
<div class="profile-page tabbar-pad">
<header class="topbar">
<span class="topbar-title">我的</span>
</header>
<button type="button" class="user-card" @click="onCard">
<div class="avatar-wrap">
<div class="avatar-ring">
<img
v-if="hasLogin && avatarUrl"
class="avatar-img"
:src="avatarUrl"
alt=""
/>
<div v-else class="avatar-letter-wrap" :style="{ background: avatarBg }">
<span class="avatar-letter">{{ avatarLetter }}</span>
</div>
</div>
<span v-if="hasLogin" class="online-dot" />
</div>
<div class="user-meta">
<template v-if="hasLogin">
<span class="nickname-text">{{ nickname || '点击设置昵称' }}</span>
<div class="tags-row">
<span v-if="mbtiType" class="tag tag-purple">{{ mbtiType }}</span>
<span v-else class="tag tag-gray">暂无测试记录</span>
</div>
</template>
<template v-else>
<span class="user-name">点击登录</span>
<span class="user-sub">登录后查看你的测试结果</span>
</template>
</div>
<span class="chevron"></span>
</button>
<div v-if="hasLogin" class="user-stats">
<button type="button" class="user-stat" @click="goHistory">
<span class="user-stat__v">{{ testCount }}</span>
<span class="user-stat__l">测评记录</span>
</button>
</div>
<section class="menu-section">
<button type="button" class="menu-row" @click="$router.push('/pages/test-select/index')">
<span>详细性格测试</span>
<span class="chevron"></span>
</button>
<button type="button" class="menu-row" @click="$router.push('/pages/order/index')">
<span>我的订单</span>
<span class="chevron"></span>
</button>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app'
const router = useRouter()
const store = useAppStore()
const hasLogin = computed(() => !!store.token)
const avatarUrl = ref('')
const nickname = ref('')
const mbtiType = ref('')
const testCount = ref(0)
const avatarLetter = computed(() => {
const n = nickname.value || '访'
return n.slice(0, 1).toUpperCase()
})
const avatarBg = '#7c3aed'
function onCard() {
if (!hasLogin.value) {
router.push('/pages/phone-auth/index')
return
}
router.push('/pages/user-profile/index')
}
function goHistory() {
router.push('/pages/history/index')
}
defineOptions({ name: 'ProfilePage' })
</script>
<style scoped lang="scss">
@function rpx($n) {
@return calc(#{$n} * 100vw / 750);
}
.profile-page {
min-height: 100vh;
background: linear-gradient(180deg, #faf9ff 0%, #f4f3f9 40%, #ffffff 100%);
padding-bottom: calc(var(--tabbar-pad) + #{rpx(24)});
}
.topbar {
padding: rpx(24) rpx(40) rpx(16);
display: flex;
align-items: center;
justify-content: center;
}
.topbar-title {
font-size: rpx(36);
font-weight: 700;
color: #1e1b4b;
}
.user-card {
margin: 0 rpx(32) rpx(24);
padding: rpx(36) rpx(28);
display: flex;
flex-direction: row;
align-items: center;
gap: rpx(24);
border: none;
border-radius: rpx(28);
background: linear-gradient(135deg, #ffffff 0%, #faf8ff 100%);
box-shadow: 0 rpx(12) rpx(40) rgba(124, 58, 237, 0.12);
text-align: left;
cursor: pointer;
}
.avatar-wrap {
position: relative;
flex-shrink: 0;
}
.avatar-ring {
width: rpx(120);
height: rpx(120);
border-radius: 50%;
overflow: hidden;
border: rpx(4) solid rgba(167, 139, 250, 0.35);
}
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-letter-wrap {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-letter {
font-size: rpx(48);
font-weight: 700;
color: #fff;
}
.online-dot {
position: absolute;
bottom: rpx(4);
right: rpx(4);
width: rpx(20);
height: rpx(20);
border-radius: 50%;
background: #22c55e;
border: rpx(4) solid #fff;
}
.user-meta {
flex: 1;
min-width: 0;
}
.nickname-text {
display: block;
font-size: rpx(34);
font-weight: 700;
color: #1e1b4b;
margin-bottom: rpx(12);
}
.user-name {
display: block;
font-size: rpx(32);
font-weight: 600;
color: #4338ca;
}
.user-sub {
display: block;
font-size: rpx(24);
color: #64748b;
margin-top: rpx(8);
}
.tags-row {
display: flex;
flex-wrap: wrap;
gap: rpx(12);
}
.tag {
padding: rpx(8) rpx(20);
border-radius: rpx(20);
font-size: rpx(22);
font-weight: 600;
}
.tag-purple {
background: #ede9fe;
color: #5b21b6;
}
.tag-gray {
background: #f1f5f9;
color: #64748b;
}
.chevron {
font-size: rpx(44);
color: #cbd5e1;
flex-shrink: 0;
}
.user-stats {
display: flex;
margin: 0 rpx(32) rpx(32);
padding: rpx(28);
background: #fff;
border-radius: rpx(24);
box-shadow: 0 rpx(8) rpx(24) rgba(15, 23, 42, 0.06);
}
.user-stat {
flex: 1;
border: none;
background: transparent;
display: flex;
flex-direction: column;
align-items: center;
gap: rpx(8);
cursor: pointer;
}
.user-stat__v {
font-size: rpx(40);
font-weight: 800;
color: #7c3aed;
}
.user-stat__l {
font-size: rpx(24);
color: #64748b;
}
.menu-section {
margin: 0 rpx(32);
background: #fff;
border-radius: rpx(24);
overflow: hidden;
box-shadow: 0 rpx(8) rpx(24) rgba(15, 23, 42, 0.06);
}
.menu-row {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: rpx(32) rpx(28);
border: none;
border-bottom: 1px solid #f1f5f9;
background: #fff;
font-size: rpx(30);
color: #334155;
cursor: pointer;
}
.menu-row:last-child {
border-bottom: none;
}
</style>

View File

@@ -0,0 +1,599 @@
<template>
<div class="test-page">
<div v-if="loading" class="test-loading">
<span class="test-loading-text">加载题目</span>
</div>
<div v-else-if="loadError" class="test-error-state">
<div class="test-error-ic"></div>
<span class="test-error-title">加载失败</span>
<span class="test-error-msg">{{ loadErrorMsg || '网络异常,请检查后重试' }}</span>
<button type="button" class="test-error-btn" @click="retryLoad">重新加载</button>
<span class="test-error-hint">若持续失败请检查浏览器是否可访问后台 apiBase</span>
</div>
<template v-else-if="currentQuestion">
<div v-if="usingLocalFallback" class="fallback-banner">
<span class="fallback-banner-ic">📶</span>
<span class="fallback-banner-text">
当前使用本地题库网络异常自动切换结果仍会在本机完成
</span>
</div>
<div class="progress-section">
<div class="progress-info">
<span class="question-count">问题 {{ currentIndex + 1 }}/{{ total }}</span>
<span class="time-remaining">剩余时间: {{ formatTime }}</span>
</div>
<div class="progress-bar-container">
<div class="progress-bar" :style="{ width: progress + '%' }" />
</div>
</div>
<div class="content-area">
<div class="question-card">
<span class="question-text">{{ currentQuestion.question }}</span>
<div class="options-container">
<button
v-for="(option, optIdx) in currentQuestion.options"
:key="optIdx"
type="button"
class="option-item"
:class="{ selected: answers[String(currentQuestion.id)] === option.value }"
@click="selectAnswer(option.value)"
>
<span
class="radio-button"
:class="{ checked: answers[String(currentQuestion.id)] === option.value }"
>
<span
v-if="answers[String(currentQuestion.id)] === option.value"
class="radio-inner"
/>
</span>
<span class="option-text">{{ option.text }}</span>
</button>
</div>
</div>
<div v-if="currentIndex === total - 1" class="last-hint">
<span class="last-hint-text">
最后一题选择后约 0.3 秒自动跳转结果页若未跳转请点右下角查看结果
</span>
</div>
</div>
<div class="footer-buttons">
<button
type="button"
class="nav-button secondary"
:class="{ disabled: currentIndex === 0 }"
:disabled="currentIndex === 0"
@click="prevQuestion"
>
<span class="button-text">上一题</span>
</button>
<button
v-if="currentIndex < total - 1"
type="button"
class="nav-button secondary"
@click="nextQuestion"
>
<span class="button-text">跳过</span>
</button>
<button
v-else
type="button"
class="nav-button primary"
:class="{ disabled: isSubmitting }"
:disabled="isSubmitting"
@click="finishTest"
>
<span class="button-text button-text-on-primary">
{{ isSubmitting ? '正在生成…' : '查看结果' }}
</span>
</button>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
import { loadQuestions, type TestQuestion } from '@/utils/questionBank'
import { calculateMbtiResult } from '@/utils/mbtiCalc'
import { saveTestResult } from '@/utils/testSubmit'
import { afterTestSubmitNavigate } from '@/utils/navigateAfterTest'
import { showToast } from '@/utils/toast'
const MBTI_TIME_SEC = 30 * 60
const loading = ref(true)
const loadError = ref(false)
const loadErrorMsg = ref('')
const usingLocalFallback = ref(false)
const questions = ref<TestQuestion[]>([])
const currentIndex = ref(0)
const currentQuestion = ref<TestQuestion | null>(null)
const answers = ref<Record<string, string>>({})
const total = ref(0)
const progress = ref(0)
const timeRemaining = ref(MBTI_TIME_SEC)
const initialSeconds = ref(MBTI_TIME_SEC)
const formatTime = ref('30:00')
const isSubmitting = ref(false)
let timer: ReturnType<typeof setInterval> | null = null
let advanceTimer: ReturnType<typeof setTimeout> | null = null
function stopTimer() {
if (timer) {
clearInterval(timer)
timer = null
}
}
function startTimer() {
stopTimer()
timer = setInterval(() => {
let time = timeRemaining.value - 1
if (time <= 0) {
stopTimer()
submitTest({ allowIncomplete: true })
return
}
const minutes = Math.floor(time / 60)
const seconds = time % 60
timeRemaining.value = time
formatTime.value = `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
}, 1000)
}
function retryLoad() {
startLoadQuestions()
}
function startLoadQuestions() {
loading.value = true
loadError.value = false
loadErrorMsg.value = ''
usingLocalFallback.value = false
loadQuestions('mbti', { allowLocalFallback: true })
.then((list) => {
if (!list.length) {
loading.value = false
loadError.value = true
loadErrorMsg.value = '暂无题目,请稍后再试'
return
}
const tot = list.length
const isLocal =
!!(list[0] && typeof list[0].id === 'string' && String(list[0].id).indexOf('L-') === 0)
questions.value = list
currentQuestion.value = list[0]
currentIndex.value = 0
answers.value = {}
total.value = tot
progress.value = Math.round((1 / tot) * 100)
timeRemaining.value = MBTI_TIME_SEC
initialSeconds.value = MBTI_TIME_SEC
formatTime.value = '30:00'
loading.value = false
loadError.value = false
usingLocalFallback.value = isLocal
startTimer()
})
.catch((err: Error) => {
loading.value = false
loadError.value = true
loadErrorMsg.value = err.message || '加载失败,请检查网络后重试'
})
}
startLoadQuestions()
function selectAnswer(value: string | undefined) {
const cq = currentQuestion.value
if (value == null || !cq || cq.id == null) return
if (advanceTimer) {
clearTimeout(advanceTimer)
advanceTimer = null
}
const questionId = String(cq.id)
const idx = currentIndex.value
const tot = total.value
const nextAnswers = { ...answers.value, [questionId]: value }
answers.value = nextAnswers
const answeredCount = Object.keys(nextAnswers).length
progress.value = tot ? Math.round(((idx + 1) / tot) * 100) : 0
advanceTimer = setTimeout(() => {
advanceTimer = null
if (
currentIndex.value !== idx ||
!currentQuestion.value ||
String(currentQuestion.value.id) !== String(cq.id)
)
return
if (idx < tot - 1) {
nextQuestion()
} else {
submitTest()
}
}, 320)
}
function prevQuestion() {
if (advanceTimer) {
clearTimeout(advanceTimer)
advanceTimer = null
}
if (currentIndex.value > 0) {
const newIndex = currentIndex.value - 1
const newQuestion = questions.value[newIndex]
const tot = total.value
currentIndex.value = newIndex
currentQuestion.value = newQuestion
progress.value = tot ? Math.round(((newIndex + 1) / tot) * 100) : 0
}
}
function nextQuestion() {
if (currentIndex.value < total.value - 1) {
const newIndex = currentIndex.value + 1
const newQuestion = questions.value[newIndex]
const tot = total.value
currentIndex.value = newIndex
currentQuestion.value = newQuestion
progress.value = tot ? Math.round(((newIndex + 1) / tot) * 100) : 0
}
}
function finishTest() {
const q = currentQuestion.value
if (!q) return
const qid = String(q.id)
if (answers.value[qid] == null) {
showToast('请先选择一项')
return
}
const tot = total.value
if (Object.keys(answers.value).length < tot) {
showToast('还有题目未作答,请返回补答')
return
}
submitTest()
}
function submitTest(opt: { allowIncomplete?: boolean } = {}) {
if (isSubmitting.value) return
const allowIncomplete = !!opt.allowIncomplete
stopTimer()
const tot = total.value
const n = Object.keys(answers.value).length
if (!allowIncomplete && n < tot) {
showToast(`还有 ${tot - n} 题未作答`)
startTimer()
return
}
isSubmitting.value = true
let result
try {
result = calculateMbtiResult(answers.value)
} catch {
showToast('计算结果失败,请重试')
isSubmitting.value = false
startTimer()
return
}
const resultData = {
...result,
answers: answers.value,
testDuration: initialSeconds.value - timeRemaining.value,
completedAt: new Date().toISOString(),
timestamp: new Date().toISOString(),
}
try {
localStorage.setItem('mbtiResult', JSON.stringify(resultData))
} catch {
/* ignore */
}
saveTestResult('mbti', resultData as Record<string, unknown>).then((extra) => {
const rid = extra && extra.id
const target = rid
? `/pages/result/mbti?id=${encodeURIComponent(String(rid))}&type=mbti`
: '/pages/result/mbti'
afterTestSubmitNavigate(target)
isSubmitting.value = false
})
}
onUnmounted(() => {
if (advanceTimer) clearTimeout(advanceTimer)
stopTimer()
})
</script>
<style scoped lang="scss">
@use '@/styles/global.scss' as *;
.test-page {
width: 100%;
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: #fff;
}
.test-loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: rpx(80);
}
.test-loading-text {
font-size: rpx(30);
color: #666;
}
.test-error-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: rpx(80) rpx(60);
text-align: center;
}
.test-error-ic {
font-size: rpx(84);
line-height: 1;
margin-bottom: rpx(20);
}
.test-error-title {
font-size: rpx(36);
font-weight: 700;
color: #111827;
margin-bottom: rpx(12);
}
.test-error-msg {
font-size: rpx(26);
color: #6b7280;
line-height: 1.6;
margin-bottom: rpx(40);
max-width: rpx(520);
}
.test-error-btn {
padding: rpx(20) rpx(56);
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: #fff;
font-size: rpx(30);
font-weight: 600;
border-radius: rpx(48);
border: none;
box-shadow: 0 rpx(10) rpx(24) rgba(99, 102, 241, 0.3);
margin-bottom: rpx(28);
cursor: pointer;
}
.test-error-hint {
font-size: rpx(22);
color: #9ca3af;
}
.fallback-banner {
margin: rpx(16) rpx(24) 0;
padding: rpx(14) rpx(20);
background: #fef3c7;
border: rpx(1) solid #fcd34d;
border-radius: rpx(12);
display: flex;
align-items: center;
gap: rpx(10);
}
.fallback-banner-ic {
font-size: rpx(24);
}
.fallback-banner-text {
font-size: rpx(22);
color: #92400e;
line-height: 1.4;
flex: 1;
text-align: left;
}
.progress-section {
padding: rpx(32);
border-bottom: rpx(1) solid #e5e5e5;
flex-shrink: 0;
}
.progress-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: rpx(16);
}
.question-count {
font-size: rpx(28);
font-weight: 500;
color: #333;
}
.time-remaining {
font-size: rpx(28);
color: #999;
}
.progress-bar-container {
width: 100%;
height: rpx(8);
background-color: #e5e5e5;
border-radius: rpx(8);
overflow: hidden;
}
.progress-bar {
height: 100%;
background: linear-gradient(135deg, #ff6b8a 0%, #ff8fa3 100%);
border-radius: rpx(8);
transition: width 0.3s ease;
}
.content-area {
flex: 1;
overflow-y: auto;
padding: rpx(32);
}
.question-card {
background-color: #fff;
border-radius: rpx(24);
padding: rpx(48);
box-shadow: 0 rpx(4) rpx(12) rgba(0, 0, 0, 0.08);
}
.question-text {
display: block;
font-size: rpx(40);
font-weight: 500;
color: #333;
line-height: 1.6;
margin-bottom: rpx(48);
}
.options-container {
display: flex;
flex-direction: column;
gap: rpx(32);
}
.option-item {
display: flex;
align-items: center;
padding: rpx(32);
border: rpx(2) solid #e5e5e5;
border-radius: rpx(16);
transition: all 0.3s ease;
background: #fff;
cursor: pointer;
text-align: left;
width: 100%;
&.selected {
background-color: rgba(255, 107, 138, 0.12);
border-color: #ff6b8a;
}
}
.radio-button {
width: rpx(40);
height: rpx(40);
border-radius: 50%;
border: rpx(2) solid #d1d5db;
display: flex;
align-items: center;
justify-content: center;
margin-right: rpx(24);
flex-shrink: 0;
transition: all 0.3s ease;
&.checked {
background-color: #ff6b8a;
border-color: #ff6b8a;
}
}
.radio-inner {
width: rpx(16);
height: rpx(16);
border-radius: 50%;
background-color: #fff;
}
.option-text {
flex: 1;
font-size: rpx(32);
color: #333;
line-height: 1.5;
}
.footer-buttons {
display: flex;
gap: rpx(24);
padding: rpx(32);
padding-bottom: calc(rpx(32) + env(safe-area-inset-bottom, 0px));
border-top: rpx(1) solid #e5e5e5;
flex-shrink: 0;
}
.nav-button {
flex: 1;
padding: rpx(28);
border-radius: rpx(16);
text-align: center;
cursor: pointer;
border: none;
font: inherit;
&.secondary {
background-color: #fff;
border: rpx(2) solid #ff6b8a;
.button-text {
color: #ff6b8a;
}
}
&.primary {
background: linear-gradient(135deg, #ff6b8a 0%, #ff8fa3 100%);
border: none;
box-shadow: 0 rpx(8) rpx(24) rgba(255, 107, 138, 0.35);
}
&.disabled {
opacity: 0.4;
pointer-events: none;
}
}
.button-text-on-primary {
color: #ffffff !important;
font-weight: 600;
}
.last-hint {
margin-top: rpx(24);
padding: rpx(20) rpx(24);
background: rgba(255, 107, 138, 0.08);
border-radius: rpx(16);
border: rpx(1) solid rgba(255, 107, 138, 0.2);
}
.last-hint-text {
font-size: rpx(26);
color: #be185d;
line-height: 1.5;
}
.button-text {
font-size: rpx(32);
font-weight: 500;
}
</style>

22
h5-vue/tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"paths": { "@/*": ["./src/*"] },
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

10
h5-vue/tsconfig.node.json Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler"
},
"include": ["vite.config.ts"]
}

21
h5-vue/vite.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 5174,
proxy: {
'/api': {
target: process.env.VITE_PROXY_TARGET || 'http://127.0.0.1',
changeOrigin: true,
},
},
},
})