高考版优化
审核模式优化
This commit is contained in:
2
h5-vue/.env.development
Normal file
2
h5-vue/.env.development
Normal file
@@ -0,0 +1,2 @@
|
||||
# 后端 API 根地址(与小程序合法域名一致)。开发时可用 Vite 代理 /api
|
||||
VITE_API_BASE=
|
||||
4
h5-vue/.gitignore
vendored
Normal file
4
h5-vue/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
*.local
|
||||
.DS_Store
|
||||
40
h5-vue/README.md
Normal file
40
h5-vue/README.md
Normal 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
15
h5-vue/env.d.ts
vendored
Normal 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
12
h5-vue/index.html
Normal 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
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
24
h5-vue/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
h5-vue/public/images/mbti-team-image.png
Normal file
BIN
h5-vue/public/images/mbti-team-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 264 KiB |
13
h5-vue/src/App.vue
Normal file
13
h5-vue/src/App.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<style>
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
184
h5-vue/src/components/CustomTabBar.vue
Normal file
184
h5-vue/src/components/CustomTabBar.vue
Normal 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>
|
||||
19
h5-vue/src/layouts/TabLayout.vue
Normal file
19
h5-vue/src/layouts/TabLayout.vue
Normal 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
17
h5-vue/src/main.ts
Normal 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
112
h5-vue/src/router/index.ts
Normal 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
98
h5-vue/src/stores/app.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
28
h5-vue/src/styles/global.scss
Normal file
28
h5-vue/src/styles/global.scss
Normal 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
10
h5-vue/src/utils/audit.ts
Normal 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)
|
||||
}
|
||||
17
h5-vue/src/utils/enterpriseContext.ts
Normal file
17
h5-vue/src/utils/enterpriseContext.ts
Normal 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
|
||||
}
|
||||
82
h5-vue/src/utils/mbtiCalc.ts
Normal file
82
h5-vue/src/utils/mbtiCalc.ts
Normal 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] || {},
|
||||
}
|
||||
}
|
||||
159
h5-vue/src/utils/mbtiDescriptions.ts
Normal file
159
h5-vue/src/utils/mbtiDescriptions.ts
Normal 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', '企业家', '律师'],
|
||||
},
|
||||
}
|
||||
269
h5-vue/src/utils/mbtiLocalQuestions.ts
Normal file
269
h5-vue/src/utils/mbtiLocalQuestions.ts
Normal 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 }
|
||||
7
h5-vue/src/utils/navigateAfterTest.ts
Normal file
7
h5-vue/src/utils/navigateAfterTest.ts
Normal 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'))
|
||||
}
|
||||
174
h5-vue/src/utils/questionBank.ts
Normal file
174
h5-vue/src/utils/questionBank.ts
Normal 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))
|
||||
})
|
||||
}
|
||||
32
h5-vue/src/utils/request.ts
Normal file
32
h5-vue/src/utils/request.ts
Normal 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 }
|
||||
}
|
||||
24
h5-vue/src/utils/storage.ts
Normal file
24
h5-vue/src/utils/storage.ts
Normal 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 */
|
||||
}
|
||||
}
|
||||
52
h5-vue/src/utils/testSubmit.ts
Normal file
52
h5-vue/src/utils/testSubmit.ts
Normal 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
23
h5-vue/src/utils/toast.ts
Normal 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)
|
||||
}
|
||||
343
h5-vue/src/views/pages/TestSelectPage.vue
Normal file
343
h5-vue/src/views/pages/TestSelectPage.vue
Normal 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>
|
||||
103
h5-vue/src/views/stub/PageStub.vue
Normal file
103
h5-vue/src/views/stub/PageStub.vue
Normal 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>
|
||||
84
h5-vue/src/views/tab/CameraPage.vue
Normal file
84
h5-vue/src/views/tab/CameraPage.vue
Normal 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>
|
||||
392
h5-vue/src/views/tab/IndexHome.vue
Normal file
392
h5-vue/src/views/tab/IndexHome.vue
Normal 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>
|
||||
289
h5-vue/src/views/tab/ProfilePage.vue
Normal file
289
h5-vue/src/views/tab/ProfilePage.vue
Normal 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>
|
||||
599
h5-vue/src/views/test/MbtiTestPage.vue
Normal file
599
h5-vue/src/views/test/MbtiTestPage.vue
Normal 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
22
h5-vue/tsconfig.json
Normal 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
10
h5-vue/tsconfig.node.json
Normal 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
21
h5-vue/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user