提交代码

This commit is contained in:
zpc 2026-02-20 14:57:43 +08:00
parent edc91995fe
commit 0ffe59377a
21 changed files with 8335 additions and 202 deletions

View File

@ -0,0 +1,15 @@
{
"enabled": true,
"name": "Figma Component Code Connect",
"description": "Check if UI component should be connected to Figma design",
"version": "1",
"when": {
"type": "fileEdited",
"patterns": ["uniapp/**/*.vue", "uniapp/components/**/*"]
},
"then": {
"type": "askAgent",
"prompt": "When a new component file is created or updated, ask the user if they would like to confirm if the code has been correctly attached to the Figma component of the same name. If the user approves: first run the get code connect map tool for the last Figma URL provided by the user. You can prompt them to provide it again if it's unavailable. If the response is empty, run the add code connect map tool, otherwise tell the user they already have code mapped to that component. If the user rejects: Do not run any additional tools."
},
"shortName": "figma-code-connect"
}

View File

@ -97,8 +97,8 @@
- 确保登录流程正常
- 确保页面数据正确加载
- [ ] 8. 测评-信息填写页
- [ ] 8.1 创建 pages/assessment/info/index.vue
- [x] 8. 测评-信息填写页
- [x] 8.1 创建 pages/assessment/info/index.vue
- **设计图**: `docs/设计图/测评-个人信息填写.png`、`测评-个人信息填写2.png`、`测评-个人信息填写3.png`、`测评-个人信息填写4.png`
- 按设计图实现顶部测评介绍区域
- 按设计图实现表单样式:姓名、手机号、性别、年龄、学业阶段、省市区
@ -110,8 +110,8 @@
- 未填写完整时按钮灰色不可点击
- 按设计图实现邀请码弹窗样式
- [ ] 9. 测评-答题页
- [ ] 9.1 创建 pages/assessment/questions/index.vue
- [x] 9. 测评-答题页
- [x] 9.1 创建 pages/assessment/questions/index.vue
- **设计图**: `docs/设计图/测评-题目.png`、`测评-提交题目检验空题.png`、`测评-提交题目检验空题(1).png`、`测评-提交题目检验空题(2).png`
- 按设计图实现导航栏样式
- 按设计图实现题目卡片样式
@ -121,16 +121,16 @@
- 调用 GET /api/assessment/getQuestionList 获取题目
- 调用 POST /api/assessment/submitAnswers 提交答案
- [ ] 10. 测评-生成中页
- [ ] 10.1 创建 pages/assessment/loading/index.vue
- [x] 10. 测评-生成中页
- [x] 10.1 创建 pages/assessment/loading/index.vue
- **设计图**: `docs/设计图/测评-等待测评.png`、`docs/设计图/测评-测评等待.png`
- 按设计图实现加载动画样式
- 按设计图实现提示文字样式
- 轮询调用 GET /api/assessment/getResultStatus3秒间隔
- 生成完成自动跳转结果页
- [ ] 11. 测评-结果页
- [ ] 11.1 创建 pages/assessment/result/index.vue
- [x] 11. 测评-结果页
- [x] 11.1 创建 pages/assessment/result/index.vue
- **设计图**: 暂无参考需求文档第五章第4节
- 自定义导航栏,顶部"保存到本地"按钮
- 基本信息展示
@ -140,15 +140,15 @@
- 其他分析模块展示
- 调用 GET /api/assessment/getResult 获取报告数据
- [ ] 12. Checkpoint - 测评流程验证
- [x] 12. Checkpoint - 测评流程验证
- 确保完整测评流程可走通
- 确保各页面与设计图一致
- 信息填写 → 答题 → 生成中 → 结果
### 第三阶段P1 重要页面2-3天
- [ ] 13. 个人资料页
- [ ] 13.1 创建 pages/mine/profile/index.vue
- [x] 13. 个人资料页
- [x] 13.1 创建 pages/mine/profile/index.vue
- **设计图**: `docs/设计图/个人资料.png`
- 按设计图实现页面布局
- 头像展示和修改(选择图片、上传)
@ -156,15 +156,15 @@
- UID 展示(不可修改)
- 调用 GET /api/user/getProfile、POST /api/user/updateProfile、POST /api/user/updateAvatar
- [ ] 14. 业务详情页
- [ ] 14.1 创建 pages/business/detail/index.vue
- [x] 14. 业务详情页
- [x] 14.1 创建 pages/business/detail/index.vue
- **设计图**: `docs/设计图/业务详情页.png`
- 按设计图实现背景长图展示
- 按设计图实现底部"点击参与"按钮样式
- 调用 GET /api/business/getDetail
- [ ] 15. 我的订单页
- [ ] 15.1 创建 pages/order/list/index.vue
- [x] 15. 我的订单页
- [x] 15.1 创建 pages/order/list/index.vue
- **设计图**: `docs/设计图/我的订单.png`、`我的订单(1).png`、`我的订单-空状态.png`
- 按设计图实现订单卡片样式
- 订单信息:日期、编号、项目、金额、状态
@ -173,30 +173,30 @@
- 调用 GET /api/order/getList
- 下拉刷新、上拉加载
- [ ] 16. 往期测评页
- [ ] 16.1 创建 pages/assessment/history/index.vue
- [x] 16. 往期测评页
- [x] 16.1 创建 pages/assessment/history/index.vue
- **设计图**: `docs/设计图/往期测评-空状态.png`
- 按设计图实现测评记录卡片样式
- 按设计图实现空状态样式
- 调用 GET /api/assessment/getHistoryList
- 下拉刷新、上拉加载
- [ ] 17. Checkpoint - P1 页面验证
- [x] 17. Checkpoint - P1 页面验证
- 确保各页面与设计图一致
- 确保个人资料修改正常
- 确保订单列表和操作正常
### 第四阶段P2/P3 扩展页面2-3天
- [ ] 18. 规划师选择页
- [ ] 18.1 创建 pages/planner/list/index.vue
- [x] 18. 规划师选择页
- [x] 18.1 创建 pages/planner/list/index.vue
- **设计图**: `docs/设计图/学业规划.png`
- 按设计图实现规划师卡片样式
- 展示:照片、姓名、介绍、价格
- 调用 GET /api/planner/getList
- [ ] 19. 规划预约页
- [ ] 19.1 创建 pages/planner/book/index.vue
- [x] 19. 规划预约页
- [x] 19.1 创建 pages/planner/book/index.vue
- **设计图**: `docs/设计图/学业规划2.png`、`学业规划3.png`、`学业规划4.png`
- 按设计图实现日期时间选择样式
- 按设计图实现表单样式
@ -204,8 +204,8 @@
- 按设计图实现预约成功弹窗样式
- 调用 POST /api/order/create、POST /api/order/pay
- [ ] 20. 邀请新用户页
- [ ] 20.1 创建 pages/invite/index.vue
- [x] 20. 邀请新用户页
- [x] 20.1 创建 pages/invite/index.vue
- **设计图**: `docs/设计图/邀请新用户.png`、`邀请新用户-二维码.png`、`邀请新用户-提现金额.png`、`邀请新用户-提现记录.png`、`邀请新用户-提现记录(1).png`
- 权限检查(仅合伙人可见)
- 按设计图实现邀请规则说明弹窗
@ -216,26 +216,26 @@
- 按设计图实现邀请记录列表
- 调用分销相关接口
- [ ] 21. 关于页
- [ ] 21.1 创建 pages/about/index.vue
- [x] 21. 关于页
- [x] 21.1 创建 pages/about/index.vue
- **设计图**: `docs/设计图/关于.png`
- 按设计图实现 Logo 展示
- 按设计图实现版本号展示
- 调用 GET /api/system/getAbout
- [ ] 22. 用户协议页
- [ ] 22.1 创建 pages/agreement/user/index.vue
- [x] 22. 用户协议页
- [x] 22.1 创建 pages/agreement/user/index.vue
- **设计图**: `docs/设计图/用户/隐私协议.png`
- 按设计图实现协议内容展示样式
- 调用 GET /api/system/getAgreement
- [ ] 23. 隐私政策页
- [ ] 23.1 创建 pages/agreement/privacy/index.vue
- [x] 23. 隐私政策页
- [x] 23.1 创建 pages/agreement/privacy/index.vue
- **设计图**: `docs/设计图/用户/隐私协议.png`
- 按设计图实现政策内容展示样式
- 调用 GET /api/system/getPrivacy
- [ ] 24. Final Checkpoint - 全部页面验证
- [x] 24. Final Checkpoint - 全部页面验证
- 确保所有 18 个页面与设计图一致
- 确保所有交互流程正常
- 确保登录态在各页面正确处理
@ -277,9 +277,9 @@
| 阶段 | 任务 | 状态 | 完成日期 |
|------|------|------|----------|
| 第一阶段 | 基础框架 | ⬜ 待开发 | |
| 第二阶段 | P0 核心页面 | ⬜ 待开发 | |
| 第三阶段 | P1 重要页面 | ⬜ 待开发 | |
| 第四阶段 | P2/P3 扩展页面 | ⬜ 待开发 | |
| 第一阶段 | 基础框架 | ✅ 已完成 | 2026-02-10 |
| 第二阶段 | P0 核心页面 | ✅ 已完成 | 2026-02-10 |
| 第三阶段 | P1 重要页面 | ✅ 已完成 | 2026-02-10 |
| 第四阶段 | P2/P3 扩展页面 | ✅ 已完成 | 2026-02-10 |
**状态说明**:⬜ 待开发 | 🔄 开发中 | ✅ 已完成

View File

@ -0,0 +1,334 @@
---
inclusion: always
---
# MiAssessment 设计系统规则
本文档定义了学业邑规划小程序的设计系统规范,用于 Figma 设计稿到代码的转换。
## 1. 技术栈
- **框架**: UniApp + Vue 3
- **样式**: SCSS
- **状态管理**: Pinia
- **构建工具**: Vite
## 2. 设计令牌 (Design Tokens)
### 2.1 颜色系统
```scss
// 主色调
$primary-color: #4A90E2;
$primary-light: #6BA3E8;
$primary-dark: #3A7BC8;
// 功能色
$success-color: #52C41A;
$warning-color: #FAAD14;
$error-color: #FF4D4F;
$info-color: #1890FF;
// 文字颜色
$text-color: #333333;
$text-secondary: #666666;
$text-placeholder: #999999;
$text-disabled: #CCCCCC;
$text-white: #FFFFFF;
// 背景颜色
$bg-color: #F5F5F5;
$bg-white: #FFFFFF;
$bg-gray: #F8F8F8;
// 边框颜色
$border-color: #E8E8E8;
$border-light: #F0F0F0;
```
### 2.2 间距系统
```scss
$spacing-xs: 8rpx; // 4px
$spacing-sm: 16rpx; // 8px
$spacing-md: 24rpx; // 12px
$spacing-lg: 32rpx; // 16px
$spacing-xl: 48rpx; // 24px
```
### 2.3 字体系统
```scss
$font-size-xs: 22rpx; // 11px
$font-size-sm: 24rpx; // 12px
$font-size-md: 28rpx; // 14px
$font-size-lg: 32rpx; // 16px
$font-size-xl: 36rpx; // 18px
$font-size-xxl: 40rpx; // 20px
$font-weight-normal: 400;
$font-weight-medium: 500;
$font-weight-bold: 600;
```
### 2.4 圆角系统
```scss
$border-radius-xs: 4rpx;
$border-radius-sm: 8rpx;
$border-radius-md: 12rpx;
$border-radius-lg: 16rpx;
$border-radius-xl: 24rpx;
$border-radius-round: 9999rpx;
```
### 2.5 阴影系统
```scss
$shadow-sm: 0 2rpx 8rpx rgba(0, 0, 0, 0.08);
$shadow-md: 0 4rpx 16rpx rgba(0, 0, 0, 0.1);
$shadow-lg: 0 8rpx 24rpx rgba(0, 0, 0, 0.12);
```
## 3. 组件库
### 3.1 可用组件
| 组件 | 路径 | 用途 |
|------|------|------|
| Popup | `@/components/Popup/index.vue` | 弹窗组件 |
| Empty | `@/components/Empty/index.vue` | 空状态组件 |
| Loading | `@/components/Loading/index.vue` | 加载组件 |
| EmojiPicker | `@/components/EmojiPicker/index.vue` | 表情选择器 |
| VoiceRecorder | `@/components/VoiceRecorder/index.vue` | 语音录制组件 |
| Navbar | `@/components/Navbar/index.vue` | 自定义导航栏 |
### 3.2 组件使用示例
```vue
<script setup>
import { Popup, Empty, Loading, Navbar } from '@/components'
</script>
```
## 4. 样式工具类
项目提供了丰富的工具类,位于 `@/styles/common.scss`
### 4.1 Flex 布局
```scss
.flex, .flex-row, .flex-col
.flex-wrap, .flex-1, .flex-shrink-0
.items-center, .items-start, .items-end
.justify-center, .justify-between, .justify-around, .justify-end
```
### 4.2 文字样式
```scss
.text-xs, .text-sm, .text-md, .text-lg, .text-xl
.text-primary, .text-secondary, .text-placeholder, .text-white
.text-success, .text-warning, .text-error
.text-center, .text-left, .text-right
.font-medium, .font-bold
.text-ellipsis, .text-ellipsis-2
```
### 4.3 间距样式
```scss
// Margin: .m-xs, .mt-sm, .mb-md, .ml-lg, .mr-xl
// Padding: .p-xs, .pt-sm, .pb-md, .pl-lg, .pr-xl
```
### 4.4 按钮样式
```scss
.btn, .btn-primary, .btn-outline, .btn-disabled
.btn-sm, .btn-lg
```
### 4.5 卡片和边框
```scss
.card, .border, .border-top, .border-bottom
.rounded-sm, .rounded-md, .rounded-lg, .rounded-full
```
## 5. Figma 转换规则
### 5.1 颜色映射
| Figma 颜色 | 项目变量 |
|------------|----------|
| #4A90E2 | $primary-color |
| #333333 | $text-color |
| #666666 | $text-secondary |
| #999999 | $text-placeholder |
| #F5F5F5 | $bg-color |
| #FFFFFF | $bg-white |
### 5.2 单位转换
- Figma px → UniApp rpx (1px = 2rpx)
- 使用 rpx 作为主要单位,确保多端适配
### 5.3 代码风格
```vue
<script setup>
/**
* 组件描述
*/
import { ref, onMounted } from 'vue'
// Props
const props = defineProps({
title: {
type: String,
default: ''
}
})
// Emits
const emit = defineEmits(['click'])
// State
const loading = ref(false)
// Methods
function handleClick() {
emit('click')
}
// Lifecycle
onMounted(() => {
// 初始化逻辑
})
</script>
<template>
<view class="component-name">
<!-- 内容 -->
</view>
</template>
<style scoped lang="scss">
@import '@/styles/variables.scss';
.component-name {
// 样式
}
</style>
```
## 6. 图标和资源
### 6.1 静态资源路径
- 图标: `@/static/`
- TabBar 图标: `@/static/tabbar/`
- 切图资源: `docs/切图/`
### 6.2 图片使用
```vue
<image src="/static/logo.png" mode="aspectFit" />
```
## 7. 页面结构
### 7.1 页面目录
```
uniapp/pages/
├── index/ # 首页
├── mine/ # 我的
├── message/ # 消息
├── login/ # 登录
├── assessment/ # 测评相关
├── order/ # 订单相关
├── invite/ # 邀请
└── ...
```
### 7.2 页面模板
```vue
<script setup>
/**
* 页面名称
*/
import { ref, onMounted } from 'vue'
import { Navbar } from '@/components'
// State
const loading = ref(false)
const dataList = ref([])
// Methods
async function fetchData() {
loading.value = true
try {
// API 调用
} finally {
loading.value = false
}
}
// Lifecycle
onMounted(() => {
fetchData()
})
</script>
<template>
<view class="page-container">
<Navbar title="页面标题" />
<view class="page-content">
<!-- 页面内容 -->
</view>
</view>
</template>
<style scoped lang="scss">
@import '@/styles/variables.scss';
.page-container {
min-height: 100vh;
background-color: $bg-color;
}
.page-content {
padding: $spacing-lg;
}
</style>
```
## 8. API 请求
### 8.1 请求封装
```javascript
import { get, post } from '@/api/request'
// GET 请求
export function getList(params) {
return get('/api/list', params)
}
// POST 请求
export function createItem(data) {
return post('/api/create', data)
}
```
## 9. 注意事项
1. **优先使用项目变量**: 不要硬编码颜色、间距等值
2. **复用现有组件**: 检查 `@/components` 是否有可用组件
3. **遵循命名规范**: 使用 kebab-case 命名 CSS 类
4. **响应式设计**: 使用 rpx 单位确保多端适配
5. **代码注释**: 为复杂逻辑添加中文注释

View File

@ -11,17 +11,17 @@ export interface UploadSetting {
/** 存储类型 1本地 2阿里云 3腾讯云 */
type: string
/** 腾讯云AppId */
AppId?: string
appId?: string
/** 存储桶名称 */
Bucket?: string
bucket?: string
/** 地域 */
Region?: string
region?: string
/** SecretId */
AccessKeyId?: string
accessKeyId?: string
/** SecretKey */
AccessKeySecret?: string
accessKeySecret?: string
/** 访问域名 */
Domain?: string
domain?: string
}
/**

View File

@ -61,27 +61,27 @@
请前往腾讯云控制台获取相关配置信息确保存储桶已开启跨域访问(CORS)
</el-alert>
<el-form-item label="AppId" prop="AppId">
<el-form-item label="AppId" prop="appId">
<el-input
v-model="state.formData.AppId"
v-model="state.formData.appId"
placeholder="请输入腾讯云AppId"
clearable
/>
<div class="form-item-tip">腾讯云账号的AppId可在账号信息中查看</div>
</el-form-item>
<el-form-item label="SecretId" prop="AccessKeyId">
<el-form-item label="SecretId" prop="accessKeyId">
<el-input
v-model="state.formData.AccessKeyId"
v-model="state.formData.accessKeyId"
placeholder="请输入SecretId"
clearable
/>
<div class="form-item-tip">API密钥的SecretId</div>
</el-form-item>
<el-form-item label="SecretKey" prop="AccessKeySecret">
<el-form-item label="SecretKey" prop="accessKeySecret">
<el-input
v-model="state.formData.AccessKeySecret"
v-model="state.formData.accessKeySecret"
placeholder="请输入SecretKey"
type="password"
show-password
@ -90,17 +90,17 @@
<div class="form-item-tip">API密钥的SecretKey请妥善保管</div>
</el-form-item>
<el-form-item label="存储桶名称" prop="Bucket">
<el-form-item label="存储桶名称" prop="bucket">
<el-input
v-model="state.formData.Bucket"
v-model="state.formData.bucket"
placeholder="请输入存储桶名称,如 my-bucket-1250000000"
clearable
/>
<div class="form-item-tip">完整的存储桶名称包含AppId后缀</div>
</el-form-item>
<el-form-item label="地域" prop="Region">
<el-select v-model="state.formData.Region" placeholder="请选择地域" clearable>
<el-form-item label="地域" prop="region">
<el-select v-model="state.formData.region" placeholder="请选择地域" clearable>
<el-option label="北京 (ap-beijing)" value="ap-beijing" />
<el-option label="上海 (ap-shanghai)" value="ap-shanghai" />
<el-option label="广州 (ap-guangzhou)" value="ap-guangzhou" />
@ -113,9 +113,9 @@
<div class="form-item-tip">存储桶所在地域</div>
</el-form-item>
<el-form-item label="访问域名" prop="Domain">
<el-form-item label="访问域名" prop="domain">
<el-input
v-model="state.formData.Domain"
v-model="state.formData.domain"
placeholder="请输入CDN加速域名或存储桶域名"
clearable
>
@ -174,12 +174,12 @@ const state = reactive<UploadConfigState>({
saving: false,
formData: {
type: '1',
AppId: '',
Bucket: '',
Region: '',
AccessKeyId: '',
AccessKeySecret: '',
Domain: ''
appId: '',
bucket: '',
region: '',
accessKeyId: '',
accessKeySecret: '',
domain: ''
}
})
@ -192,12 +192,12 @@ const formRules = computed<FormRules>(() => {
// COS
if (state.formData.type === '3') {
rules.AppId = [{ required: true, message: '请输入AppId', trigger: 'blur' }]
rules.AccessKeyId = [{ required: true, message: '请输入SecretId', trigger: 'blur' }]
rules.AccessKeySecret = [{ required: true, message: '请输入SecretKey', trigger: 'blur' }]
rules.Bucket = [{ required: true, message: '请输入存储桶名称', trigger: 'blur' }]
rules.Region = [{ required: true, message: '请选择地域', trigger: 'change' }]
rules.Domain = [{ required: true, message: '请输入访问域名', trigger: 'blur' }]
rules.appId = [{ required: true, message: '请输入AppId', trigger: 'blur' }]
rules.accessKeyId = [{ required: true, message: '请输入SecretId', trigger: 'blur' }]
rules.accessKeySecret = [{ required: true, message: '请输入SecretKey', trigger: 'blur' }]
rules.bucket = [{ required: true, message: '请输入存储桶名称', trigger: 'blur' }]
rules.region = [{ required: true, message: '请选择地域', trigger: 'change' }]
rules.domain = [{ required: true, message: '请输入访问域名', trigger: 'blur' }]
}
return rules
@ -212,12 +212,12 @@ async function loadConfig() {
if (res.code === 0 && res.data) {
state.formData = {
type: res.data.type || '1',
AppId: res.data.AppId || '',
Bucket: res.data.Bucket || '',
Region: res.data.Region || '',
AccessKeyId: res.data.AccessKeyId || '',
AccessKeySecret: res.data.AccessKeySecret || '',
Domain: res.data.Domain || ''
appId: res.data.appId || '',
bucket: res.data.bucket || '',
region: res.data.region || '',
accessKeyId: res.data.accessKeyId || '',
accessKeySecret: res.data.accessKeySecret || '',
domain: res.data.domain || ''
}
}
} catch (error) {

View File

@ -12,11 +12,33 @@ export async function getUserDetail(userId) {
return response
}
/**
* 获取用户资料
* @returns {Promise<Object>}
*/
export async function getProfile() {
const response = await get('/user/getProfile')
return response
}
/**
* 更新用户资料
* @param {Object} data - 用户资料
* @param {string} [data.nickname] - 昵称
* @returns {Promise<Object>}
*/
export async function updateProfile(data) {
const response = await post('/user/updateProfile', data)
return response
}
/**
* 更新用户头像
* @param {string} avatar - 头像URL
* @returns {Promise<Object>}
*/
export async function updateAvatar(avatar) {
const response = await post('/users/avatar', { avatar })
const response = await post('/user/updateAvatar', { avatar })
return response
}
@ -30,6 +52,8 @@ export async function updateNickname(nickname) {
export default {
getUserDetail,
getProfile,
updateProfile,
updateAvatar,
updateNickname
}

View File

@ -38,6 +38,7 @@
{
"path": "pages/assessment/info/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "测评信息"
}
},
@ -65,13 +66,15 @@
{
"path": "pages/assessment/history/index",
"style": {
"navigationBarTitleText": "往期测评"
"navigationBarTitleText": "往期测评",
"enablePullDownRefresh": true
}
},
{
"path": "pages/order/list/index",
"style": {
"navigationBarTitleText": "我的订单"
"navigationBarTitleText": "我的订单",
"enablePullDownRefresh": true
}
},
{
@ -84,7 +87,9 @@
{
"path": "pages/planner/list/index",
"style": {
"navigationBarTitleText": "学业规划"
"navigationStyle": "custom",
"navigationBarTitleText": "学业规划",
"enablePullDownRefresh": true
}
},
{
@ -96,7 +101,9 @@
{
"path": "pages/invite/index",
"style": {
"navigationBarTitleText": "邀请新用户"
"navigationStyle": "custom",
"navigationBarTitleText": "邀请新用户",
"enablePullDownRefresh": true
}
},
{

View File

@ -1,27 +1,158 @@
<script setup>
/**
* 关于页面
* 展示应用 Logo 和版本号
*/
import { ref, onMounted } from 'vue'
import { getAbout } from '@/api/system.js'
//
const loading = ref(true)
const aboutInfo = ref({
logo: '/static/logo.png',
appName: '学业邑规划',
version: '1.0.0',
description: ''
})
/**
* 获取关于信息
*/
async function fetchAboutInfo() {
loading.value = true
try {
const res = await getAbout()
if (res.code === 0 && res.data) {
//
aboutInfo.value = {
...aboutInfo.value,
...res.data
}
}
} catch (error) {
console.error('获取关于信息失败:', error)
} finally {
loading.value = false
}
}
/**
* 页面加载
*/
onMounted(() => {
fetchAboutInfo()
})
</script>
<template>
<view class="about-page">
<view class="placeholder">关于页面</view>
<!-- 内容区域 -->
<view class="about-content">
<!-- Logo 区域 -->
<view class="logo-section">
<image
class="logo"
:src="aboutInfo.logo || '/static/logo.png'"
mode="aspectFit"
/>
</view>
<!-- 应用名称 -->
<view class="app-name">
<text>{{ aboutInfo.appName }}</text>
</view>
<!-- 版本号 -->
<view class="version">
<text>版本号 {{ aboutInfo.version }}</text>
</view>
<!-- 描述信息如果有 -->
<view v-if="aboutInfo.description" class="description">
<text>{{ aboutInfo.description }}</text>
</view>
</view>
<!-- 底部版权信息 -->
<view class="footer">
<text class="copyright">© 2025 学业邑规划</text>
</view>
</view>
</template>
<style scoped lang="scss">
<style lang="scss" scoped>
@import '@/styles/variables.scss';
.about-page {
min-height: 100vh;
background-color: #f5f5f5;
background-color: $bg-white;
display: flex;
flex-direction: column;
}
.placeholder {
//
.about-content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
font-size: 32rpx;
color: #999;
padding: $spacing-xl;
}
// Logo
.logo-section {
margin-bottom: $spacing-lg;
.logo {
width: 180rpx;
height: 180rpx;
border-radius: $border-radius-lg;
}
}
//
.app-name {
margin-bottom: $spacing-md;
text {
font-size: $font-size-xl;
font-weight: $font-weight-bold;
color: $text-color;
}
}
//
.version {
margin-bottom: $spacing-lg;
text {
font-size: $font-size-md;
color: $text-secondary;
}
}
//
.description {
max-width: 600rpx;
text-align: center;
text {
font-size: $font-size-sm;
color: $text-placeholder;
line-height: 1.6;
}
}
//
.footer {
padding: $spacing-xl;
padding-bottom: calc(#{$spacing-xl} + env(safe-area-inset-bottom));
text-align: center;
.copyright {
font-size: $font-size-xs;
color: $text-placeholder;
}
}
</style>

View File

@ -1,27 +1,210 @@
<script setup>
/**
* 隐私政策页面
* 展示隐私政策内容
*/
import { ref, onMounted } from 'vue'
import { getPrivacy } from '@/api/system.js'
//
const loading = ref(true)
const content = ref('')
const title = ref('隐私政策')
const updateTime = ref('')
/**
* 获取隐私政策内容
*/
async function fetchPrivacy() {
loading.value = true
try {
const res = await getPrivacy()
if (res.code === 0 && res.data) {
content.value = res.data.content || ''
title.value = res.data.title || '隐私政策'
updateTime.value = res.data.updateTime || ''
}
} catch (error) {
console.error('获取隐私政策失败:', error)
uni.showToast({
title: '加载失败,请重试',
icon: 'none'
})
} finally {
loading.value = false
}
}
/**
* 页面加载
*/
onMounted(() => {
fetchPrivacy()
})
</script>
<template>
<view class="agreement-privacy-page">
<view class="placeholder">隐私政策页面</view>
<view class="privacy-page">
<!-- 加载状态 -->
<view v-if="loading" class="loading-container">
<view class="loading-spinner"></view>
<text class="loading-text">加载中...</text>
</view>
<!-- 隐私政策内容 -->
<view v-else class="privacy-content">
<!-- 标题 -->
<view class="privacy-header">
<text class="privacy-title">{{ title }}</text>
<text v-if="updateTime" class="update-time">更新时间{{ updateTime }}</text>
</view>
<!-- 正文内容 -->
<view class="privacy-body">
<rich-text v-if="content" :nodes="content" class="rich-content"></rich-text>
<view v-else class="empty-content">
<text>暂无内容</text>
</view>
</view>
</view>
</view>
</template>
<style scoped lang="scss">
.agreement-privacy-page {
<style lang="scss" scoped>
@import '@/styles/variables.scss';
.privacy-page {
min-height: 100vh;
background-color: #f5f5f5;
background-color: $bg-white;
}
.placeholder {
//
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
font-size: 32rpx;
color: #999;
height: 60vh;
.loading-spinner {
width: 60rpx;
height: 60rpx;
border: 4rpx solid $border-color;
border-top-color: $primary-color;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: $spacing-md;
}
.loading-text {
font-size: $font-size-md;
color: $text-placeholder;
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
//
.privacy-content {
padding: $spacing-lg;
padding-bottom: calc(#{$spacing-xl} + env(safe-area-inset-bottom));
}
//
.privacy-header {
margin-bottom: $spacing-lg;
padding-bottom: $spacing-lg;
border-bottom: 1rpx solid $border-light;
.privacy-title {
display: block;
font-size: $font-size-xl;
font-weight: $font-weight-bold;
color: $text-color;
text-align: center;
margin-bottom: $spacing-sm;
}
.update-time {
display: block;
font-size: $font-size-sm;
color: $text-placeholder;
text-align: center;
}
}
//
.privacy-body {
.rich-content {
font-size: $font-size-md;
color: $text-color;
line-height: 1.8;
//
:deep(p) {
margin-bottom: $spacing-md;
}
:deep(h1),
:deep(h2),
:deep(h3),
:deep(h4) {
font-weight: $font-weight-bold;
color: $text-color;
margin-top: $spacing-lg;
margin-bottom: $spacing-md;
}
:deep(h1) {
font-size: $font-size-xl;
}
:deep(h2) {
font-size: $font-size-lg;
}
:deep(h3),
:deep(h4) {
font-size: $font-size-md;
}
:deep(ul),
:deep(ol) {
padding-left: $spacing-lg;
margin-bottom: $spacing-md;
}
:deep(li) {
margin-bottom: $spacing-xs;
}
:deep(a) {
color: $primary-color;
}
:deep(strong),
:deep(b) {
font-weight: $font-weight-bold;
}
}
.empty-content {
display: flex;
align-items: center;
justify-content: center;
height: 200rpx;
text {
font-size: $font-size-md;
color: $text-placeholder;
}
}
}
</style>

View File

@ -1,27 +1,210 @@
<script setup>
/**
* 用户协议页面
* 展示用户协议内容
*/
import { ref, onMounted } from 'vue'
import { getAgreement } from '@/api/system.js'
//
const loading = ref(true)
const content = ref('')
const title = ref('用户协议')
const updateTime = ref('')
/**
* 获取用户协议内容
*/
async function fetchAgreement() {
loading.value = true
try {
const res = await getAgreement()
if (res.code === 0 && res.data) {
content.value = res.data.content || ''
title.value = res.data.title || '用户协议'
updateTime.value = res.data.updateTime || ''
}
} catch (error) {
console.error('获取用户协议失败:', error)
uni.showToast({
title: '加载失败,请重试',
icon: 'none'
})
} finally {
loading.value = false
}
}
/**
* 页面加载
*/
onMounted(() => {
fetchAgreement()
})
</script>
<template>
<view class="agreement-user-page">
<view class="placeholder">用户协议页面</view>
<view class="agreement-page">
<!-- 加载状态 -->
<view v-if="loading" class="loading-container">
<view class="loading-spinner"></view>
<text class="loading-text">加载中...</text>
</view>
<!-- 协议内容 -->
<view v-else class="agreement-content">
<!-- 标题 -->
<view class="agreement-header">
<text class="agreement-title">{{ title }}</text>
<text v-if="updateTime" class="update-time">更新时间{{ updateTime }}</text>
</view>
<!-- 正文内容 -->
<view class="agreement-body">
<rich-text v-if="content" :nodes="content" class="rich-content"></rich-text>
<view v-else class="empty-content">
<text>暂无内容</text>
</view>
</view>
</view>
</view>
</template>
<style scoped lang="scss">
.agreement-user-page {
<style lang="scss" scoped>
@import '@/styles/variables.scss';
.agreement-page {
min-height: 100vh;
background-color: #f5f5f5;
background-color: $bg-white;
}
.placeholder {
//
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
font-size: 32rpx;
color: #999;
height: 60vh;
.loading-spinner {
width: 60rpx;
height: 60rpx;
border: 4rpx solid $border-color;
border-top-color: $primary-color;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: $spacing-md;
}
.loading-text {
font-size: $font-size-md;
color: $text-placeholder;
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
//
.agreement-content {
padding: $spacing-lg;
padding-bottom: calc(#{$spacing-xl} + env(safe-area-inset-bottom));
}
//
.agreement-header {
margin-bottom: $spacing-lg;
padding-bottom: $spacing-lg;
border-bottom: 1rpx solid $border-light;
.agreement-title {
display: block;
font-size: $font-size-xl;
font-weight: $font-weight-bold;
color: $text-color;
text-align: center;
margin-bottom: $spacing-sm;
}
.update-time {
display: block;
font-size: $font-size-sm;
color: $text-placeholder;
text-align: center;
}
}
//
.agreement-body {
.rich-content {
font-size: $font-size-md;
color: $text-color;
line-height: 1.8;
//
:deep(p) {
margin-bottom: $spacing-md;
}
:deep(h1),
:deep(h2),
:deep(h3),
:deep(h4) {
font-weight: $font-weight-bold;
color: $text-color;
margin-top: $spacing-lg;
margin-bottom: $spacing-md;
}
:deep(h1) {
font-size: $font-size-xl;
}
:deep(h2) {
font-size: $font-size-lg;
}
:deep(h3),
:deep(h4) {
font-size: $font-size-md;
}
:deep(ul),
:deep(ol) {
padding-left: $spacing-lg;
margin-bottom: $spacing-md;
}
:deep(li) {
margin-bottom: $spacing-xs;
}
:deep(a) {
color: $primary-color;
}
:deep(strong),
:deep(b) {
font-weight: $font-weight-bold;
}
}
.empty-content {
display: flex;
align-items: center;
justify-content: center;
height: 200rpx;
text {
font-size: $font-size-md;
color: $text-placeholder;
}
}
}
</style>

View File

@ -1,27 +1,362 @@
<script setup>
/**
* 往期测评页面
* 展示用户的历史测评记录
*/
import { ref, computed, onMounted } from 'vue'
import { onShow, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import { useUserStore } from '@/store/user.js'
import { useAuth } from '@/composables/useAuth.js'
import { getHistoryList } from '@/api/assessment.js'
import Empty from '@/components/Empty/index.vue'
import Loading from '@/components/Loading/index.vue'
const userStore = useUserStore()
const { checkLogin } = useAuth()
//
const ASSESSMENT_STATUS = {
GENERATING: 1, //
COMPLETED: 2, //
FAILED: 3 //
}
//
const loading = ref(false)
const refreshing = ref(false)
const historyList = ref([])
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const noMore = ref(false)
//
const isEmpty = computed(() => !loading.value && historyList.value.length === 0)
const hasMore = computed(() => historyList.value.length < total.value)
/**
* 获取测评状态文本
*/
function getStatusText(status) {
const statusMap = {
[ASSESSMENT_STATUS.GENERATING]: '生成中',
[ASSESSMENT_STATUS.COMPLETED]: '已完成',
[ASSESSMENT_STATUS.FAILED]: '生成失败'
}
return statusMap[status] || '未知状态'
}
/**
* 获取测评状态样式类
*/
function getStatusClass(status) {
const classMap = {
[ASSESSMENT_STATUS.GENERATING]: 'status-generating',
[ASSESSMENT_STATUS.COMPLETED]: 'status-completed',
[ASSESSMENT_STATUS.FAILED]: 'status-failed'
}
return classMap[status] || ''
}
/**
* 格式化日期
*/
function formatDate(dateStr) {
if (!dateStr) return ''
const date = new Date(dateStr)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
/**
* 加载测评历史列表
*/
async function loadHistoryList(isRefresh = false) {
if (loading.value) return
if (isRefresh) {
page.value = 1
noMore.value = false
}
loading.value = true
try {
const res = await getHistoryList({
page: page.value,
pageSize: pageSize.value
})
if (res.code === 0 && res.data) {
const list = res.data.list || []
total.value = res.data.total || 0
if (isRefresh) {
historyList.value = list
} else {
historyList.value = [...historyList.value, ...list]
}
//
noMore.value = historyList.value.length >= total.value
} else {
uni.showToast({
title: res.message || '获取测评记录失败',
icon: 'none'
})
}
} catch (error) {
console.error('获取测评历史失败:', error)
uni.showToast({
title: '网络错误,请重试',
icon: 'none'
})
} finally {
loading.value = false
refreshing.value = false
uni.stopPullDownRefresh()
}
}
/**
* 下拉刷新
*/
onPullDownRefresh(() => {
refreshing.value = true
loadHistoryList(true)
})
/**
* 上拉加载更多
*/
onReachBottom(() => {
if (!noMore.value && !loading.value) {
page.value++
loadHistoryList()
}
})
/**
* 查看测评结果
*/
function viewResult(record) {
if (record.status === ASSESSMENT_STATUS.GENERATING) {
uni.showToast({
title: '报告生成中,请稍后查看',
icon: 'none'
})
return
}
if (record.status === ASSESSMENT_STATUS.FAILED) {
uni.showToast({
title: '报告生成失败,请联系客服',
icon: 'none'
})
return
}
uni.navigateTo({
url: `/pages/assessment/result/index?recordId=${record.id}`
})
}
/**
* 判断是否显示查看结果按钮
*/
function showViewResultBtn(status) {
return status === ASSESSMENT_STATUS.COMPLETED
}
/**
* 页面显示时检查登录状态并加载数据
*/
onShow(() => {
userStore.restoreFromStorage()
if (checkLogin()) {
loadHistoryList(true)
}
})
/**
* 页面加载
*/
onMounted(() => {
userStore.restoreFromStorage()
})
</script>
<template>
<view class="assessment-history-page">
<view class="placeholder">往期测评页面</view>
<view class="history-page">
<!-- 页面加载中 -->
<Loading type="page" :loading="loading && historyList.length === 0" />
<!-- 测评记录列表 -->
<view class="history-list" v-if="!isEmpty">
<view
class="history-card"
v-for="record in historyList"
:key="record.id"
@click="viewResult(record)"
>
<!-- 卡片头部 -->
<view class="card-header">
<view class="assessment-name">{{ record.assessmentName || '多元智能测评' }}</view>
<view class="assessment-status" :class="getStatusClass(record.status)">
{{ getStatusText(record.status) }}
</view>
</view>
<!-- 卡片内容 -->
<view class="card-content">
<view class="info-row">
<text class="info-label">测评人</text>
<text class="info-value">{{ record.userName || '--' }}</text>
</view>
<view class="info-row">
<text class="info-label">测评日期</text>
<text class="info-value">{{ formatDate(record.createTime) }}</text>
</view>
</view>
<!-- 卡片底部操作 -->
<view class="card-footer" v-if="showViewResultBtn(record.status)">
<view class="view-btn">
<text>查看报告</text>
<view class="arrow-icon"></view>
</view>
</view>
</view>
<!-- 加载更多 -->
<Loading
type="more"
:loading="loading && historyList.length > 0"
:noMore="noMore"
noMoreText="没有更多测评记录了"
/>
</view>
<!-- 空状态 -->
<Empty
v-if="isEmpty"
text="暂无测评记录"
:showButton="true"
buttonText="去测评"
buttonUrl="/pages/index/index"
/>
</view>
</template>
<style scoped lang="scss">
.assessment-history-page {
<style lang="scss" scoped>
@import '@/styles/variables.scss';
.history-page {
min-height: 100vh;
background-color: #f5f5f5;
background-color: $bg-color;
padding: $spacing-lg;
padding-bottom: calc(#{$spacing-lg} + env(safe-area-inset-bottom));
}
.placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
font-size: 32rpx;
color: #999;
//
.history-list {
.history-card {
background-color: $bg-white;
border-radius: $border-radius-lg;
margin-bottom: $spacing-lg;
overflow: hidden;
box-shadow: $shadow-sm;
//
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: $spacing-md $spacing-lg;
border-bottom: 1rpx solid $border-light;
.assessment-name {
font-size: $font-size-lg;
font-weight: $font-weight-medium;
color: $text-color;
}
.assessment-status {
font-size: $font-size-sm;
padding: 4rpx 16rpx;
border-radius: $border-radius-sm;
// - 绿
&.status-completed {
color: $success-color;
background-color: rgba(82, 196, 26, 0.1);
}
// -
&.status-generating {
color: $primary-color;
background-color: rgba(74, 144, 226, 0.1);
}
// -
&.status-failed {
color: $error-color;
background-color: rgba(255, 77, 79, 0.1);
}
}
}
//
.card-content {
padding: $spacing-md $spacing-lg;
.info-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: $spacing-xs 0;
.info-label {
font-size: $font-size-md;
color: $text-secondary;
}
.info-value {
font-size: $font-size-md;
color: $text-color;
}
}
}
//
.card-footer {
display: flex;
justify-content: flex-end;
padding: $spacing-md $spacing-lg;
border-top: 1rpx solid $border-light;
.view-btn {
display: flex;
align-items: center;
color: $primary-color;
font-size: $font-size-md;
.arrow-icon {
width: 12rpx;
height: 12rpx;
border-right: 3rpx solid $primary-color;
border-bottom: 3rpx solid $primary-color;
transform: rotate(-45deg);
margin-left: 8rpx;
}
&:active {
opacity: 0.7;
}
}
}
}
}
</style>

View File

@ -1,27 +1,775 @@
<script setup>
/**
* 测评信息填写页面
*
* 功能
* - 顶部测评介绍区域
* - 表单填写姓名手机号性别年龄学业阶段省市区
* - 支付测评和邀请码免费测评两个入口
* - 邀请码验证弹窗
*/
import { ref, computed, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useUserStore } from '@/store/user.js'
import { useAuth } from '@/composables/useAuth.js'
import { usePayment } from '@/composables/usePayment.js'
import { getIntro, verifyInviteCode } from '@/api/assessment.js'
import { isPhone } from '@/utils/validate.js'
import Navbar from '@/components/Navbar/index.vue'
const userStore = useUserStore()
const { checkLogin } = useAuth()
const { processPayment } = usePayment()
//
const typeId = ref(0)
const typeName = ref('')
//
const introData = ref({
title: '',
description: '',
imageUrl: '',
price: 0
})
//
const formData = ref({
name: '',
phone: '',
gender: '',
age: '',
educationStage: '',
province: '',
city: '',
district: ''
})
//
const genderOptions = ['男', '女']
const ageOptions = Array.from({ length: 41 }, (_, i) => `${i + 10}`)
const educationOptions = ['小学及以下', '初中', '高中', '大专', '本科', '研究生及以上']
//
const genderIndex = ref(-1)
const ageIndex = ref(-1)
const educationIndex = ref(-1)
//
const regionValue = ref([])
//
const showInvitePopup = ref(false)
const inviteCode = ref('')
const inviteLoading = ref(false)
//
const pageLoading = ref(true)
/**
* 表单是否填写完整
*/
const isFormComplete = computed(() => {
return (
formData.value.name.trim() !== '' &&
formData.value.phone.trim() !== '' &&
formData.value.gender !== '' &&
formData.value.age !== '' &&
formData.value.educationStage !== '' &&
formData.value.province !== '' &&
formData.value.city !== '' &&
formData.value.district !== ''
)
})
/**
* 加载测评介绍
*/
async function loadIntro() {
pageLoading.value = true
try {
const res = await getIntro(typeId.value)
if (res && res.code === 0 && res.data) {
introData.value = {
title: res.data.title || typeName.value || '多元智能测评',
description: res.data.description || '',
imageUrl: res.data.imageUrl || '',
price: res.data.price || 0
}
}
} catch (error) {
console.error('加载测评介绍失败:', error)
} finally {
pageLoading.value = false
}
}
/**
* 性别选择
*/
function onGenderChange(e) {
genderIndex.value = e.detail.value
formData.value.gender = genderOptions[e.detail.value]
}
/**
* 年龄选择
*/
function onAgeChange(e) {
ageIndex.value = e.detail.value
formData.value.age = ageOptions[e.detail.value]
}
/**
* 学业阶段选择
*/
function onEducationChange(e) {
educationIndex.value = e.detail.value
formData.value.educationStage = educationOptions[e.detail.value]
}
/**
* 省市区选择
*/
function onRegionChange(e) {
const value = e.detail.value
regionValue.value = value
formData.value.province = value[0] || ''
formData.value.city = value[1] || ''
formData.value.district = value[2] || ''
}
/**
* 验证表单
*/
function validateForm() {
//
if (!isPhone(formData.value.phone)) {
uni.showModal({
title: '提示',
content: '手机号格式有误',
showCancel: false
})
return false
}
//
if (!formData.value.district) {
uni.showModal({
title: '提示',
content: '请选择所在城市区县',
showCancel: false
})
return false
}
return true
}
/**
* 支付测评
*/
async function handlePayAssessment() {
if (!isFormComplete.value) return
//
if (!checkLogin()) return
//
if (!validateForm()) return
try {
uni.showLoading({ title: '创建订单中...' })
//
const result = await processPayment({
productType: 1, //
productId: typeId.value,
userInfo: {
name: formData.value.name,
phone: formData.value.phone,
gender: formData.value.gender,
age: formData.value.age,
educationStage: formData.value.educationStage,
province: formData.value.province,
city: formData.value.city,
district: formData.value.district
}
})
uni.hideLoading()
if (result.success) {
//
uni.redirectTo({
url: `/pages/assessment/questions/index?typeId=${typeId.value}&orderId=${result.orderId}`
})
} else if (result.error) {
uni.showToast({
title: result.error,
icon: 'none'
})
}
} catch (error) {
uni.hideLoading()
console.error('支付失败:', error)
uni.showToast({
title: '支付失败,请重试',
icon: 'none'
})
}
}
/**
* 打开邀请码弹窗
*/
function openInvitePopup() {
if (!isFormComplete.value) return
//
if (!checkLogin()) return
//
if (!validateForm()) return
inviteCode.value = ''
showInvitePopup.value = true
}
/**
* 关闭邀请码弹窗
*/
function closeInvitePopup() {
showInvitePopup.value = false
inviteCode.value = ''
}
/**
* 邀请码输入处理自动转大写
*/
function onInviteCodeInput(e) {
inviteCode.value = e.detail.value.toUpperCase()
}
/**
* 提交邀请码
*/
async function submitInviteCode() {
if (!inviteCode.value.trim()) {
uni.showToast({
title: '请输入邀请码',
icon: 'none'
})
return
}
inviteLoading.value = true
try {
const res = await verifyInviteCode(inviteCode.value.trim())
if (res && res.code === 0) {
//
closeInvitePopup()
uni.redirectTo({
url: `/pages/assessment/questions/index?typeId=${typeId.value}&inviteCode=${inviteCode.value}`
})
} else {
//
const errorMsg = res?.message || '邀请码有误,请重新输入'
uni.showToast({
title: errorMsg,
icon: 'none'
})
}
} catch (error) {
console.error('验证邀请码失败:', error)
uni.showToast({
title: '验证失败,请重试',
icon: 'none'
})
} finally {
inviteLoading.value = false
}
}
/**
* 页面加载
*/
onLoad((options) => {
typeId.value = Number(options.typeId) || 1
typeName.value = decodeURIComponent(options.typeName || '')
//
userStore.restoreFromStorage()
//
if (userStore.isLoggedIn && userStore.phone) {
formData.value.phone = userStore.phone
}
loadIntro()
})
</script>
<template>
<view class="assessment-info-page">
<view class="placeholder">测评信息填写页面</view>
<!-- 导航栏 -->
<Navbar title="测评信息" :showBack="true" />
<!-- 页面内容 -->
<view class="page-content">
<!-- 测评介绍区域 -->
<view class="intro-section">
<image
v-if="introData.imageUrl"
:src="introData.imageUrl"
mode="widthFix"
class="intro-image"
/>
<view v-else class="intro-text">
<view class="intro-title">{{ introData.title }}</view>
<view class="intro-desc" v-if="introData.description">{{ introData.description }}</view>
</view>
</view>
<!-- 表单区域 -->
<view class="form-section">
<!-- 姓名 -->
<view class="form-item">
<view class="form-label">
<text class="required">*</text>
<text>姓名</text>
</view>
<input
class="form-input"
type="text"
placeholder="请输入姓名"
v-model="formData.name"
maxlength="20"
/>
</view>
<!-- 手机号 -->
<view class="form-item">
<view class="form-label">
<text class="required">*</text>
<text>手机号</text>
</view>
<input
class="form-input"
type="number"
placeholder="请输入手机号"
v-model="formData.phone"
maxlength="11"
/>
</view>
<!-- 性别 -->
<view class="form-item">
<view class="form-label">
<text class="required">*</text>
<text>性别</text>
</view>
<picker
mode="selector"
:range="genderOptions"
@change="onGenderChange"
>
<view class="form-picker">
<text :class="{ 'placeholder': !formData.gender }">
{{ formData.gender || '请选择性别' }}
</text>
<view class="picker-arrow"></view>
</view>
</picker>
</view>
<!-- 年龄 -->
<view class="form-item">
<view class="form-label">
<text class="required">*</text>
<text>年龄</text>
</view>
<picker
mode="selector"
:range="ageOptions"
@change="onAgeChange"
>
<view class="form-picker">
<text :class="{ 'placeholder': !formData.age }">
{{ formData.age || '请选择年龄' }}
</text>
<view class="picker-arrow"></view>
</view>
</picker>
</view>
<!-- 学业阶段 -->
<view class="form-item">
<view class="form-label">
<text class="required">*</text>
<text>学业阶段</text>
</view>
<picker
mode="selector"
:range="educationOptions"
@change="onEducationChange"
>
<view class="form-picker">
<text :class="{ 'placeholder': !formData.educationStage }">
{{ formData.educationStage || '请选择学业阶段' }}
</text>
<view class="picker-arrow"></view>
</view>
</picker>
</view>
<!-- 省市区 -->
<view class="form-item">
<view class="form-label">
<text class="required">*</text>
<text>所在城市</text>
</view>
<picker
mode="region"
@change="onRegionChange"
>
<view class="form-picker">
<text :class="{ 'placeholder': !formData.province }">
{{ formData.province ? `${formData.province} ${formData.city} ${formData.district}` : '请选择省市区' }}
</text>
<view class="picker-arrow"></view>
</view>
</picker>
</view>
</view>
<!-- 底部按钮区域 -->
<view class="bottom-section">
<!-- 支付测评按钮 -->
<view
class="btn-pay"
:class="{ 'btn-disabled': !isFormComplete }"
@click="handlePayAssessment"
>
<text>支付{{ introData.price || 0 }} 开始测评</text>
</view>
<!-- 邀请码测评按钮 -->
<view
class="btn-invite"
:class="{ 'btn-disabled': !isFormComplete }"
@click="openInvitePopup"
>
<text>邀请码免费测评</text>
</view>
</view>
</view>
<!-- 邀请码弹窗 -->
<view v-if="showInvitePopup" class="popup-mask" @click="closeInvitePopup">
<view class="popup-container" @click.stop>
<view class="popup-header">
<text class="popup-title">填写测评邀请码</text>
<view class="popup-close" @click="closeInvitePopup">
<text>×</text>
</view>
</view>
<view class="popup-body">
<input
class="invite-input"
type="text"
placeholder="请输入5位邀请码"
:value="inviteCode"
@input="onInviteCodeInput"
maxlength="5"
/>
</view>
<view class="popup-footer">
<view
class="popup-btn"
:class="{ 'btn-loading': inviteLoading }"
@click="submitInviteCode"
>
<text>{{ inviteLoading ? '验证中...' : '提交' }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<style scoped lang="scss">
<style lang="scss" scoped>
@import '@/styles/variables.scss';
.assessment-info-page {
min-height: 100vh;
background-color: #f5f5f5;
background-color: $bg-color;
}
.placeholder {
.page-content {
padding: $spacing-lg;
padding-bottom: 200rpx;
}
//
.intro-section {
background-color: $bg-white;
border-radius: $border-radius-lg;
overflow: hidden;
margin-bottom: $spacing-lg;
.intro-image {
width: 100%;
display: block;
}
.intro-text {
padding: $spacing-lg;
.intro-title {
font-size: $font-size-xl;
font-weight: $font-weight-bold;
color: $text-color;
margin-bottom: $spacing-sm;
}
.intro-desc {
font-size: $font-size-md;
color: $text-secondary;
line-height: 1.6;
}
}
}
//
.form-section {
background-color: $bg-white;
border-radius: $border-radius-lg;
padding: 0 $spacing-lg;
}
.form-item {
display: flex;
align-items: center;
padding: $spacing-lg 0;
border-bottom: 1rpx solid $border-light;
&:last-child {
border-bottom: none;
}
}
.form-label {
width: 160rpx;
flex-shrink: 0;
font-size: $font-size-md;
color: $text-color;
.required {
color: $error-color;
margin-right: 4rpx;
}
}
.form-input {
flex: 1;
height: 48rpx;
font-size: $font-size-md;
color: $text-color;
&::placeholder {
color: $text-placeholder;
}
}
.form-picker {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
height: 48rpx;
font-size: $font-size-md;
color: $text-color;
.placeholder {
color: $text-placeholder;
}
.picker-arrow {
width: 16rpx;
height: 16rpx;
border-right: 3rpx solid $text-placeholder;
border-bottom: 3rpx solid $text-placeholder;
transform: rotate(-45deg);
margin-left: $spacing-sm;
}
}
//
.bottom-section {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background-color: $bg-white;
padding: $spacing-lg;
padding-bottom: calc(#{$spacing-lg} + env(safe-area-inset-bottom));
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.05);
}
.btn-pay {
height: 88rpx;
background: linear-gradient(135deg, #FF6B6B 0%, #FF5252 100%);
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
font-size: 32rpx;
color: #999;
margin-bottom: $spacing-md;
text {
font-size: $font-size-lg;
font-weight: $font-weight-medium;
color: $text-white;
}
&:active {
opacity: 0.8;
}
}
.btn-invite {
height: 88rpx;
background-color: $bg-white;
border: 2rpx solid $primary-color;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
text {
font-size: $font-size-lg;
font-weight: $font-weight-medium;
color: $primary-color;
}
&:active {
opacity: 0.8;
}
}
.btn-disabled {
background: #CCCCCC !important;
border-color: #CCCCCC !important;
pointer-events: none;
text {
color: $text-white !important;
}
}
//
.popup-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: $bg-mask;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.popup-container {
width: 600rpx;
background-color: $bg-white;
border-radius: $border-radius-xl;
overflow: hidden;
}
.popup-header {
position: relative;
padding: $spacing-lg;
text-align: center;
border-bottom: 1rpx solid $border-light;
.popup-title {
font-size: $font-size-lg;
font-weight: $font-weight-medium;
color: $text-color;
}
.popup-close {
position: absolute;
top: $spacing-md;
right: $spacing-md;
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
text {
font-size: 48rpx;
color: $text-placeholder;
line-height: 1;
}
}
}
.popup-body {
padding: $spacing-xl $spacing-lg;
}
.invite-input {
width: 100%;
height: 88rpx;
background-color: $bg-gray;
border-radius: $border-radius-md;
padding: 0 $spacing-lg;
font-size: $font-size-lg;
color: $text-color;
text-align: center;
letter-spacing: 8rpx;
&::placeholder {
color: $text-placeholder;
letter-spacing: 0;
}
}
.popup-footer {
padding: 0 $spacing-lg $spacing-xl;
}
.popup-btn {
height: 88rpx;
background: linear-gradient(135deg, #FF6B6B 0%, #FF5252 100%);
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
text {
font-size: $font-size-lg;
font-weight: $font-weight-medium;
color: $text-white;
}
&:active {
opacity: 0.8;
}
&.btn-loading {
opacity: 0.7;
pointer-events: none;
}
}
</style>

View File

@ -1,27 +1,520 @@
<script setup>
/**
* 测评生成中页面
*
* 功能
* - 显示加载动画
* - 显示提示文字
* - 轮询查询报告生成状态3秒间隔
* - 生成完成自动跳转结果页
*/
import { ref, onMounted, onUnmounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useUserStore } from '@/store/user.js'
import { getResultStatus } from '@/api/assessment.js'
import Navbar from '@/components/Navbar/index.vue'
const userStore = useUserStore()
//
const recordId = ref('')
//
let pollTimer = null
//
const POLL_INTERVAL = 3000
//
const MAX_POLL_COUNT = 100
//
const pollCount = ref(0)
//
const loadingTips = [
'正在分析您的测评数据...',
'正在生成智能分析报告...',
'正在整理八大智能评估...',
'正在计算个人特质分析...',
'正在生成细分能力报告...',
'报告即将生成完成...'
]
//
const currentTipIndex = ref(0)
//
let tipTimer = null
/**
* 开始轮询查询状态
*/
function startPolling() {
//
stopPolling()
//
checkStatus()
//
pollTimer = setInterval(() => {
checkStatus()
}, POLL_INTERVAL)
}
/**
* 停止轮询
*/
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
/**
* 查询报告生成状态
*/
async function checkStatus() {
if (!recordId.value) {
console.error('缺少测评记录ID')
return
}
//
pollCount.value++
if (pollCount.value > MAX_POLL_COUNT) {
stopPolling()
uni.showModal({
title: '提示',
content: '报告生成时间较长,请稍后在"往期测评"中查看',
showCancel: false,
success: () => {
uni.switchTab({
url: '/pages/mine/index'
})
}
})
return
}
try {
const res = await getResultStatus(recordId.value)
if (res && res.code === 0 && res.data) {
const status = res.data.status
// 1- 2- 3-
if (status === 2) {
//
stopPolling()
stopTipRotation()
uni.redirectTo({
url: `/pages/assessment/result/index?recordId=${recordId.value}`
})
} else if (status === 3) {
//
stopPolling()
stopTipRotation()
uni.showModal({
title: '提示',
content: res.data.message || '报告生成失败,请重新测评',
showCancel: false,
success: () => {
uni.switchTab({
url: '/pages/index/index'
})
}
})
}
// status === 1
}
} catch (error) {
console.error('查询状态失败:', error)
//
}
}
/**
* 开始提示文字轮换
*/
function startTipRotation() {
tipTimer = setInterval(() => {
currentTipIndex.value = (currentTipIndex.value + 1) % loadingTips.length
}, 2500)
}
/**
* 停止提示文字轮换
*/
function stopTipRotation() {
if (tipTimer) {
clearInterval(tipTimer)
tipTimer = null
}
}
/**
* 页面加载
*/
onLoad((options) => {
recordId.value = options.recordId || ''
//
userStore.restoreFromStorage()
//
startPolling()
//
startTipRotation()
})
/**
* 页面卸载时清理定时器
*/
onUnmounted(() => {
stopPolling()
stopTipRotation()
})
</script>
<template>
<view class="assessment-loading-page">
<view class="placeholder">测评生成中页面</view>
<!-- 导航栏 -->
<Navbar title="生成报告" :showBack="false" />
<!-- 加载内容区域 -->
<view class="loading-content">
<!-- 加载动画 -->
<view class="loading-animation">
<!-- 外圈旋转 -->
<view class="loading-circle loading-circle--outer">
<view class="circle-dot circle-dot--1"></view>
<view class="circle-dot circle-dot--2"></view>
<view class="circle-dot circle-dot--3"></view>
<view class="circle-dot circle-dot--4"></view>
</view>
<!-- 内圈旋转 -->
<view class="loading-circle loading-circle--inner">
<view class="circle-dot circle-dot--1"></view>
<view class="circle-dot circle-dot--2"></view>
<view class="circle-dot circle-dot--3"></view>
</view>
<!-- 中心图标 -->
<view class="loading-center">
<view class="center-icon">
<view class="icon-bar icon-bar--1"></view>
<view class="icon-bar icon-bar--2"></view>
<view class="icon-bar icon-bar--3"></view>
<view class="icon-bar icon-bar--4"></view>
<view class="icon-bar icon-bar--5"></view>
</view>
</view>
</view>
<!-- 加载文字 -->
<view class="loading-text">
<text class="loading-title">报告生成中</text>
<text class="loading-tip">{{ loadingTips[currentTipIndex] }}</text>
</view>
<!-- 进度提示 -->
<view class="loading-progress">
<view class="progress-dots">
<view class="progress-dot progress-dot--1"></view>
<view class="progress-dot progress-dot--2"></view>
<view class="progress-dot progress-dot--3"></view>
</view>
</view>
<!-- 底部提示 -->
<view class="loading-footer">
<text class="footer-text">请耐心等待报告生成需要一点时间</text>
<text class="footer-subtext">生成完成后将自动跳转</text>
</view>
</view>
</view>
</template>
<style scoped lang="scss">
<style lang="scss" scoped>
@import '@/styles/variables.scss';
.assessment-loading-page {
min-height: 100vh;
background-color: #f5f5f5;
background: linear-gradient(180deg, #F8FAFF 0%, #EEF4FF 100%);
}
.placeholder {
.loading-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: calc(100vh - 200rpx);
padding: $spacing-xl;
}
//
.loading-animation {
position: relative;
width: 280rpx;
height: 280rpx;
margin-bottom: $spacing-xl;
}
//
.loading-circle {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border-radius: 50%;
&--outer {
width: 280rpx;
height: 280rpx;
animation: rotate 3s linear infinite;
}
&--inner {
width: 200rpx;
height: 200rpx;
animation: rotate-reverse 2s linear infinite;
}
}
//
.circle-dot {
position: absolute;
width: 20rpx;
height: 20rpx;
background: linear-gradient(135deg, $primary-color 0%, $primary-light 100%);
border-radius: 50%;
&--1 {
top: 0;
left: 50%;
transform: translateX(-50%);
}
&--2 {
top: 50%;
right: 0;
transform: translateY(-50%);
}
&--3 {
bottom: 0;
left: 50%;
transform: translateX(-50%);
}
&--4 {
top: 50%;
left: 0;
transform: translateY(-50%);
}
}
.loading-circle--inner .circle-dot {
width: 16rpx;
height: 16rpx;
background: linear-gradient(135deg, #FF6B6B 0%, #FF8E8E 100%);
&--1 {
top: 0;
left: 50%;
transform: translateX(-50%);
}
&--2 {
bottom: 25%;
right: 10%;
}
&--3 {
bottom: 25%;
left: 10%;
}
}
//
.loading-center {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 120rpx;
height: 120rpx;
background-color: $bg-white;
border-radius: 50%;
box-shadow: 0 8rpx 32rpx rgba(74, 144, 226, 0.2);
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
font-size: 32rpx;
color: #999;
}
//
.center-icon {
display: flex;
align-items: center;
justify-content: center;
gap: 6rpx;
height: 48rpx;
}
.icon-bar {
width: 8rpx;
background: linear-gradient(180deg, $primary-color 0%, $primary-light 100%);
border-radius: 4rpx;
animation: wave 1s ease-in-out infinite;
&--1 {
height: 24rpx;
animation-delay: 0s;
}
&--2 {
height: 36rpx;
animation-delay: 0.1s;
}
&--3 {
height: 48rpx;
animation-delay: 0.2s;
}
&--4 {
height: 36rpx;
animation-delay: 0.3s;
}
&--5 {
height: 24rpx;
animation-delay: 0.4s;
}
}
//
.loading-text {
text-align: center;
margin-bottom: $spacing-xl;
}
.loading-title {
display: block;
font-size: $font-size-xxl;
font-weight: $font-weight-bold;
color: $text-color;
margin-bottom: $spacing-md;
}
.loading-tip {
display: block;
font-size: $font-size-md;
color: $text-secondary;
min-height: 40rpx;
transition: opacity 0.3s ease;
}
//
.loading-progress {
margin-bottom: $spacing-xl * 2;
}
.progress-dots {
display: flex;
align-items: center;
justify-content: center;
gap: $spacing-sm;
}
.progress-dot {
width: 16rpx;
height: 16rpx;
background-color: $primary-color;
border-radius: 50%;
animation: bounce 1.4s ease-in-out infinite;
&--1 {
animation-delay: 0s;
}
&--2 {
animation-delay: 0.2s;
}
&--3 {
animation-delay: 0.4s;
}
}
//
.loading-footer {
text-align: center;
position: absolute;
bottom: 120rpx;
left: 0;
right: 0;
padding: 0 $spacing-xl;
}
.footer-text {
display: block;
font-size: $font-size-sm;
color: $text-placeholder;
margin-bottom: $spacing-xs;
}
.footer-subtext {
display: block;
font-size: $font-size-xs;
color: $text-disabled;
}
//
@keyframes rotate {
from {
transform: translate(-50%, -50%) rotate(0deg);
}
to {
transform: translate(-50%, -50%) rotate(360deg);
}
}
@keyframes rotate-reverse {
from {
transform: translate(-50%, -50%) rotate(360deg);
}
to {
transform: translate(-50%, -50%) rotate(0deg);
}
}
@keyframes wave {
0%, 100% {
transform: scaleY(0.5);
}
50% {
transform: scaleY(1);
}
}
@keyframes bounce {
0%, 80%, 100% {
transform: scale(0.6);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
</style>

View File

@ -1,27 +1,715 @@
<script setup>
/**
* 测评答题页面
*
* 功能
* - 展示所有测评题目和选项
* - 每题10个选项单选
* - 提交时检测未答题目
* - 未答题弹窗提示
*/
import { ref, computed, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useUserStore } from '@/store/user.js'
import { getQuestionList, submitAnswers } from '@/api/assessment.js'
import Navbar from '@/components/Navbar/index.vue'
const userStore = useUserStore()
//
const typeId = ref(0)
const orderId = ref('')
const inviteCode = ref('')
//
const questions = ref([])
// { questionId: selectedIndex }
const answers = ref({})
//
const pageLoading = ref(true)
const submitting = ref(false)
//
const showUnansweredPopup = ref(false)
const unansweredQuestions = ref([])
//
const scoreOptions = [
{ score: 1, label: '极弱', desc: '完全不符合' },
{ score: 2, label: '很弱', desc: '几乎不符合' },
{ score: 3, label: '较弱', desc: '偶尔符合' },
{ score: 4, label: '偏弱', desc: '有时候符合' },
{ score: 5, label: '中等', desc: '普通一般' },
{ score: 6, label: '略强', desc: '多数情况符合' },
{ score: 7, label: '偏强', desc: '大多数情况符合' },
{ score: 8, label: '较强', desc: '绝大多数情况符合' },
{ score: 9, label: '很强', desc: '偶尔不符合' },
{ score: 10, label: '极强', desc: '完全符合' }
]
/**
* 已答题数量
*/
const answeredCount = computed(() => {
return Object.keys(answers.value).length
})
/**
* 总题目数量
*/
const totalCount = computed(() => {
return questions.value.length
})
/**
* 进度百分比
*/
const progressPercent = computed(() => {
if (totalCount.value === 0) return 0
return Math.round((answeredCount.value / totalCount.value) * 100)
})
/**
* 加载题目列表
*/
async function loadQuestions() {
pageLoading.value = true
try {
const res = await getQuestionList(typeId.value)
if (res && res.code === 0 && res.data) {
questions.value = res.data.list || res.data || []
} else {
// API使
questions.value = generateMockQuestions()
}
} catch (error) {
console.error('加载题目失败:', error)
// 使
questions.value = generateMockQuestions()
} finally {
pageLoading.value = false
}
}
/**
* 生成模拟题目数据开发测试用
*/
function generateMockQuestions() {
const mockQuestions = [
'注重细节,主动比较不同环境中动物、植物的适应性特征(干旱、雨季、雷雨等),对比并分析这些不同特征对动植物的影响。',
'精细操作类游戏或活动表现好(如转笔、游戏操作)或在美术课中能够画出细节丰富的作品或能否熟练使用某种乐器或喜欢组装复杂的手工制作或模型。',
'在超市买水果时,喜欢主动在心里按颜色、形状或软硬程度给它们分类或整理个人收藏(如树叶标本、玩具)时,习惯按自定的标准进行系统排列。',
'喜欢自然科学类的课程或喜欢进行自主的自然观察和记录。',
'能理解并讨论音乐作品的情感和意义或能演奏一种或多种乐器或能识别和分析音乐中的和声和节奏变化。'
]
return mockQuestions.map((content, index) => ({
id: index + 1,
questionNo: index + 1,
content: content,
category: '测评题目'
}))
}
/**
* 选择答案
*/
function selectAnswer(questionId, scoreIndex) {
answers.value[questionId] = scoreIndex
}
/**
* 检查是否选中
*/
function isSelected(questionId, scoreIndex) {
return answers.value[questionId] === scoreIndex
}
/**
* 提交答案
*/
async function handleSubmit() {
//
const unanswered = []
questions.value.forEach((q, index) => {
const qId = q.id || index + 1
if (answers.value[qId] === undefined) {
unanswered.push(q.questionNo || index + 1)
}
})
if (unanswered.length > 0) {
unansweredQuestions.value = unanswered
showUnansweredPopup.value = true
return
}
//
submitting.value = true
try {
//
const answerList = questions.value.map((q, index) => {
const qId = q.id || index + 1
const scoreIndex = answers.value[qId]
return {
questionId: qId,
score: scoreOptions[scoreIndex].score
}
})
const res = await submitAnswers({
typeId: typeId.value,
orderId: orderId.value,
inviteCode: inviteCode.value,
answers: answerList
})
if (res && res.code === 0) {
//
const recordId = res.data?.recordId || res.data?.id || ''
uni.redirectTo({
url: `/pages/assessment/loading/index?recordId=${recordId}`
})
} else {
uni.showToast({
title: res?.message || '提交失败,请重试',
icon: 'none'
})
}
} catch (error) {
console.error('提交答案失败:', error)
uni.showToast({
title: '提交失败,请重试',
icon: 'none'
})
} finally {
submitting.value = false
}
}
/**
* 关闭未答题弹窗
*/
function closeUnansweredPopup() {
showUnansweredPopup.value = false
}
/**
* 滚动到未答题目
*/
function scrollToQuestion(questionNo) {
closeUnansweredPopup()
//
uni.pageScrollTo({
selector: `#question-${questionNo}`,
duration: 300
})
}
/**
* 页面加载
*/
onLoad((options) => {
typeId.value = Number(options.typeId) || 1
orderId.value = options.orderId || ''
inviteCode.value = options.inviteCode || ''
//
userStore.restoreFromStorage()
loadQuestions()
})
</script>
<template>
<view class="assessment-questions-page">
<view class="placeholder">测评答题页面</view>
<!-- 导航栏 -->
<Navbar title="测评答题" :showBack="true" />
<!-- 进度条 -->
<view class="progress-bar">
<view class="progress-info">
<text class="progress-text">答题进度</text>
<text class="progress-count">{{ answeredCount }}/{{ totalCount }}</text>
</view>
<view class="progress-track">
<view class="progress-fill" :style="{ width: progressPercent + '%' }"></view>
</view>
</view>
<!-- 加载状态 -->
<view v-if="pageLoading" class="loading-container">
<view class="loading-spinner"></view>
<text class="loading-text">加载题目中...</text>
</view>
<!-- 题目列表 -->
<view v-else class="questions-container">
<!-- 评分说明 -->
<view class="score-guide">
<view class="guide-title">评分标准</view>
<view class="guide-desc">请根据符合程度选择对应分值1-极弱 10-极强</view>
</view>
<!-- 题目卡片 -->
<view
v-for="(question, index) in questions"
:key="question.id || index"
:id="'question-' + (question.questionNo || index + 1)"
class="question-card"
>
<!-- 题目标题 -->
<view class="question-header">
<view class="question-no">{{ question.questionNo || index + 1 }}</view>
<view class="question-content">{{ question.content }}</view>
</view>
<!-- 选项列表 -->
<view class="options-list">
<view
v-for="(option, optIndex) in scoreOptions"
:key="optIndex"
class="option-item"
:class="{ 'option-selected': isSelected(question.id || index + 1, optIndex) }"
@click="selectAnswer(question.id || index + 1, optIndex)"
>
<view class="option-radio">
<view v-if="isSelected(question.id || index + 1, optIndex)" class="radio-inner"></view>
</view>
<view class="option-score">{{ option.score }}</view>
<view class="option-label">{{ option.label }}</view>
<view class="option-desc">{{ option.desc }}</view>
</view>
</view>
</view>
<!-- 底部提交按钮 -->
<view class="submit-section">
<view
class="submit-btn"
:class="{ 'btn-loading': submitting }"
@click="handleSubmit"
>
<text>{{ submitting ? '提交中...' : '提交' }}</text>
</view>
</view>
</view>
<!-- 未答题弹窗 -->
<view v-if="showUnansweredPopup" class="popup-mask" @click="closeUnansweredPopup">
<view class="popup-container" @click.stop>
<view class="popup-header">
<text class="popup-title">提示</text>
<view class="popup-close" @click="closeUnansweredPopup">
<text>×</text>
</view>
</view>
<view class="popup-body">
<view class="popup-message">以下题目尚未作答请完成后再提交</view>
<scroll-view class="unanswered-list" scroll-y>
<view
v-for="qNo in unansweredQuestions"
:key="qNo"
class="unanswered-item"
@click="scrollToQuestion(qNo)"
>
<text> {{ qNo }} </text>
<view class="goto-icon">></view>
</view>
</scroll-view>
</view>
<view class="popup-footer">
<view class="popup-btn" @click="closeUnansweredPopup">
<text>我知道了</text>
</view>
</view>
</view>
</view>
</view>
</template>
<style scoped lang="scss">
<style lang="scss" scoped>
@import '@/styles/variables.scss';
.assessment-questions-page {
min-height: 100vh;
background-color: #f5f5f5;
background-color: $bg-color;
padding-bottom: env(safe-area-inset-bottom);
}
.placeholder {
//
.progress-bar {
position: sticky;
top: 0;
z-index: 100;
background-color: $bg-white;
padding: $spacing-md $spacing-lg;
box-shadow: $shadow-sm;
.progress-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-sm;
}
.progress-text {
font-size: $font-size-sm;
color: $text-secondary;
}
.progress-count {
font-size: $font-size-sm;
color: $primary-color;
font-weight: $font-weight-medium;
}
.progress-track {
height: 8rpx;
background-color: $border-light;
border-radius: 4rpx;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, $primary-color 0%, $primary-light 100%);
border-radius: 4rpx;
transition: width 0.3s ease;
}
}
//
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 200rpx 0;
.loading-spinner {
width: 60rpx;
height: 60rpx;
border: 4rpx solid $border-color;
border-top-color: $primary-color;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.loading-text {
margin-top: $spacing-md;
font-size: $font-size-md;
color: $text-secondary;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
//
.questions-container {
padding: $spacing-lg;
}
//
.score-guide {
background-color: #FFF9E6;
border-radius: $border-radius-lg;
padding: $spacing-lg;
margin-bottom: $spacing-lg;
border: 1rpx solid #FFE4B5;
.guide-title {
font-size: $font-size-md;
font-weight: $font-weight-medium;
color: #B8860B;
margin-bottom: $spacing-xs;
}
.guide-desc {
font-size: $font-size-sm;
color: #DAA520;
line-height: 1.5;
}
}
//
.question-card {
background-color: $bg-white;
border-radius: $border-radius-lg;
padding: $spacing-lg;
margin-bottom: $spacing-lg;
box-shadow: $shadow-sm;
}
.question-header {
display: flex;
margin-bottom: $spacing-lg;
.question-no {
width: 48rpx;
height: 48rpx;
background: linear-gradient(135deg, $primary-color 0%, $primary-light 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: $font-size-sm;
font-weight: $font-weight-bold;
color: $text-white;
flex-shrink: 0;
margin-right: $spacing-md;
}
.question-content {
flex: 1;
font-size: $font-size-md;
color: $text-color;
line-height: 1.6;
}
}
//
.options-list {
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
.option-item {
display: flex;
align-items: center;
padding: $spacing-md $spacing-lg;
background-color: $bg-gray;
border-radius: $border-radius-md;
border: 2rpx solid transparent;
transition: all 0.2s ease;
&:active {
opacity: 0.8;
}
&.option-selected {
background-color: rgba($primary-color, 0.08);
border-color: $primary-color;
.option-radio {
border-color: $primary-color;
}
.option-score {
color: $primary-color;
}
.option-label {
color: $primary-color;
}
}
}
.option-radio {
width: 36rpx;
height: 36rpx;
border: 3rpx solid $border-color;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
font-size: 32rpx;
color: #999;
flex-shrink: 0;
margin-right: $spacing-md;
transition: border-color 0.2s ease;
.radio-inner {
width: 20rpx;
height: 20rpx;
background-color: $primary-color;
border-radius: 50%;
}
}
.option-score {
width: 80rpx;
font-size: $font-size-md;
font-weight: $font-weight-medium;
color: $text-color;
flex-shrink: 0;
}
.option-label {
width: 80rpx;
font-size: $font-size-sm;
color: $text-secondary;
flex-shrink: 0;
}
.option-desc {
flex: 1;
font-size: $font-size-xs;
color: $text-placeholder;
text-align: right;
}
//
.submit-section {
padding: $spacing-xl 0;
padding-bottom: calc(#{$spacing-xl} + 100rpx);
}
.submit-btn {
height: 96rpx;
background: linear-gradient(135deg, #FF6B6B 0%, #FF5252 100%);
border-radius: 48rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(255, 82, 82, 0.3);
text {
font-size: $font-size-xl;
font-weight: $font-weight-bold;
color: $text-white;
}
&:active {
opacity: 0.9;
transform: scale(0.98);
}
&.btn-loading {
opacity: 0.7;
pointer-events: none;
}
}
//
.popup-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: $bg-mask;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.popup-container {
width: 600rpx;
max-height: 80vh;
background-color: $bg-white;
border-radius: $border-radius-xl;
overflow: hidden;
display: flex;
flex-direction: column;
}
.popup-header {
position: relative;
padding: $spacing-lg;
text-align: center;
border-bottom: 1rpx solid $border-light;
flex-shrink: 0;
.popup-title {
font-size: $font-size-lg;
font-weight: $font-weight-medium;
color: $text-color;
}
.popup-close {
position: absolute;
top: $spacing-md;
right: $spacing-md;
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
text {
font-size: 48rpx;
color: $text-placeholder;
line-height: 1;
}
}
}
.popup-body {
padding: $spacing-lg;
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
.popup-message {
font-size: $font-size-md;
color: $text-secondary;
margin-bottom: $spacing-md;
flex-shrink: 0;
}
}
.unanswered-list {
flex: 1;
max-height: 400rpx;
}
.unanswered-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: $spacing-md $spacing-lg;
background-color: $bg-gray;
border-radius: $border-radius-md;
margin-bottom: $spacing-sm;
text {
font-size: $font-size-md;
color: $error-color;
}
.goto-icon {
font-size: $font-size-md;
color: $text-placeholder;
}
&:active {
background-color: $border-light;
}
}
.popup-footer {
padding: $spacing-lg;
flex-shrink: 0;
}
.popup-btn {
height: 88rpx;
background: linear-gradient(135deg, #FF6B6B 0%, #FF5252 100%);
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
text {
font-size: $font-size-lg;
font-weight: $font-weight-medium;
color: $text-white;
}
&:active {
opacity: 0.8;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -1,27 +1,342 @@
<script setup>
/**
* 业务详情页面
* 展示业务详情长图和参与按钮
*/
import { ref, computed, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useNavbar } from '@/composables/useNavbar.js'
import { getBusinessDetail } from '@/api/business.js'
import Loading from '@/components/Loading/index.vue'
const { statusBarHeight, navbarHeight, totalNavbarHeight } = useNavbar()
//
const businessId = ref(null)
//
const pageLoading = ref(true)
const businessDetail = ref(null)
//
const navbarStyle = computed(() => ({
paddingTop: statusBarHeight.value + 'px',
height: totalNavbarHeight.value + 'px'
}))
/**
* 加载业务详情数据
*/
async function loadBusinessDetail() {
if (!businessId.value) {
uni.showToast({
title: '参数错误',
icon: 'none'
})
return
}
pageLoading.value = true
try {
const res = await getBusinessDetail(businessId.value)
if (res && res.code === 0 && res.data) {
businessDetail.value = res.data
} else {
uni.showToast({
title: res?.message || '加载失败',
icon: 'none'
})
}
} catch (error) {
console.error('加载业务详情失败:', error)
uni.showToast({
title: '加载失败,请稍后重试',
icon: 'none'
})
} finally {
pageLoading.value = false
}
}
/**
* 处理返回
*/
function handleBack() {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
} else {
uni.switchTab({
url: '/pages/index/index'
})
}
}
/**
* 处理点击参与
*/
function handleParticipate() {
if (!businessDetail.value) return
const detail = businessDetail.value
//
if (detail.linkType === 'assessment') {
//
uni.navigateTo({
url: `/pages/assessment/info/index?typeId=${detail.linkId || ''}&typeName=${encodeURIComponent(detail.name || '')}`
})
} else if (detail.linkType === 'planner') {
//
uni.navigateTo({
url: '/pages/planner/list/index'
})
} else if (detail.linkUrl) {
//
uni.navigateTo({
url: detail.linkUrl,
fail: () => {
uni.switchTab({
url: detail.linkUrl
})
}
})
} else {
//
uni.showToast({
title: '功能开发中',
icon: 'none'
})
}
}
/**
* 页面加载
*/
onLoad((options) => {
businessId.value = options.id || options.businessId
loadBusinessDetail()
})
</script>
<template>
<view class="business-detail-page">
<view class="placeholder">业务详情页面</view>
<!-- 自定义导航栏透明背景 -->
<view class="custom-navbar" :style="navbarStyle">
<view class="navbar-content" :style="{ height: navbarHeight + 'px' }">
<!-- 返回按钮 -->
<view class="navbar-back" @click="handleBack">
<view class="back-icon"></view>
</view>
<!-- 标题 -->
<view class="navbar-title-wrap">
<text class="navbar-title">{{ businessDetail?.name || '业务详情' }}</text>
</view>
<!-- 右侧占位 -->
<view class="navbar-right"></view>
</view>
</view>
<!-- 导航栏占位 -->
<view class="navbar-placeholder" :style="{ height: totalNavbarHeight + 'px' }"></view>
<!-- 页面内容 -->
<view class="page-content">
<!-- 加载状态 -->
<view v-if="pageLoading" class="loading-wrap">
<Loading type="page" :loading="true" />
</view>
<!-- 业务详情内容 -->
<template v-else-if="businessDetail">
<!-- 背景长图 -->
<view class="detail-image-wrap">
<image
v-if="businessDetail.imageUrl"
:src="businessDetail.imageUrl"
mode="widthFix"
class="detail-image"
@error="handleImageError"
/>
<!-- 多张图片支持 -->
<template v-if="businessDetail.images && businessDetail.images.length > 0">
<image
v-for="(img, index) in businessDetail.images"
:key="index"
:src="img.imageUrl || img"
mode="widthFix"
class="detail-image"
@error="handleImageError"
/>
</template>
</view>
</template>
<!-- 空状态 -->
<view v-else class="empty-state">
<image
src="/static/ic_empty.png"
mode="aspectFit"
class="empty-icon"
/>
<text class="empty-text">暂无业务详情</text>
</view>
</view>
<!-- 底部按钮区域 -->
<view v-if="businessDetail && !pageLoading" class="bottom-action">
<view class="action-btn" @click="handleParticipate">
<text class="btn-text">点击参与</text>
</view>
<!-- 底部安全区域 -->
<view class="safe-area-bottom"></view>
</view>
</view>
</template>
<style scoped lang="scss">
<style lang="scss" scoped>
@import '@/styles/variables.scss';
.business-detail-page {
min-height: 100vh;
background-color: #f5f5f5;
background-color: $bg-white;
padding-bottom: 160rpx; //
}
.placeholder {
//
.custom-navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
background-color: $bg-white;
z-index: 999;
.navbar-content {
display: flex;
align-items: center;
padding: 0 $spacing-md;
}
.navbar-back {
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: flex-start;
.back-icon {
width: 20rpx;
height: 20rpx;
border-left: 4rpx solid $text-color;
border-bottom: 4rpx solid $text-color;
transform: rotate(45deg);
margin-left: 8rpx;
}
}
.navbar-title-wrap {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
.navbar-title {
font-size: 34rpx;
font-weight: $font-weight-medium;
color: $text-color;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.navbar-right {
width: 80rpx;
}
}
.navbar-placeholder {
width: 100%;
}
//
.page-content {
min-height: calc(100vh - 160rpx);
}
//
.loading-wrap {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
font-size: 32rpx;
color: #999;
min-height: 60vh;
}
//
.detail-image-wrap {
width: 100%;
.detail-image {
width: 100%;
display: block;
}
}
//
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 200rpx 0;
.empty-icon {
width: 200rpx;
height: 200rpx;
margin-bottom: $spacing-lg;
}
.empty-text {
font-size: $font-size-md;
color: $text-placeholder;
}
}
//
.bottom-action {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background-color: $bg-white;
padding: $spacing-md $spacing-lg;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.05);
z-index: 100;
.action-btn {
width: 100%;
height: 96rpx;
background: linear-gradient(135deg, #4A90E2 0%, #357ABD 100%);
border-radius: 48rpx;
display: flex;
align-items: center;
justify-content: center;
&:active {
opacity: 0.9;
}
.btn-text {
font-size: 34rpx;
font-weight: $font-weight-medium;
color: $text-white;
}
}
.safe-area-bottom {
height: env(safe-area-inset-bottom);
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -1,27 +1,497 @@
<script setup>
/**
* 个人资料页面
*/
</script>
<template>
<view class="profile-page">
<view class="placeholder">个人资料页面</view>
<!-- 页面内容 -->
<view class="page-content">
<!-- 头像区域 -->
<view class="profile-item avatar-item" @click="handleChangeAvatar">
<text class="item-label">头像</text>
<view class="item-value">
<image
class="avatar"
:src="userInfo.avatar || '/static/logo.png'"
mode="aspectFill"
/>
<text class="arrow"></text>
</view>
</view>
<!-- 昵称区域 -->
<view class="profile-item" @click="showNicknamePopup">
<text class="item-label">昵称</text>
<view class="item-value">
<text class="value-text">{{ userInfo.nickname || '未设置' }}</text>
<text class="arrow"></text>
</view>
</view>
<!-- UID区域不可修改 -->
<view class="profile-item uid-item">
<text class="item-label">UID</text>
<view class="item-value">
<text class="value-text uid-text">{{ userInfo.uid || '--' }}</text>
</view>
</view>
</view>
<!-- 修改昵称弹窗 -->
<view v-if="nicknamePopupVisible" class="popup-mask" @click="hideNicknamePopup">
<view class="popup-container nickname-popup" @click.stop>
<view class="popup-header">
<text class="popup-title">修改昵称</text>
</view>
<view class="popup-body">
<input
class="nickname-input"
type="text"
v-model="newNickname"
placeholder="请输入昵称"
maxlength="20"
:focus="nicknamePopupVisible"
/>
</view>
<view class="popup-footer">
<view class="popup-btn cancel" @click="hideNicknamePopup">
<text>取消</text>
</view>
<view class="popup-btn confirm" @click="handleUpdateNickname">
<text>确定</text>
</view>
</view>
</view>
</view>
<!-- 加载中 -->
<view v-if="loading" class="loading-mask">
<view class="loading-content">
<view class="loading-spinner"></view>
<text class="loading-text">{{ loadingText }}</text>
</view>
</view>
</view>
</template>
<style scoped lang="scss">
.profile-page {
min-height: 100vh;
background-color: #f5f5f5;
<script setup>
/**
* 个人资料页面
* 展示和修改用户头像昵称
* UID 仅展示不可修改
*/
import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { useUserStore } from '@/store/user.js'
import { getProfile, updateProfile, updateAvatar } from '@/api/user.js'
import config from '@/config/index.js'
const userStore = useUserStore()
//
const loading = ref(false)
const loadingText = ref('加载中...')
const nicknamePopupVisible = ref(false)
const newNickname = ref('')
//
const userInfo = computed(() => ({
userId: userStore.userId,
uid: userStore.uid,
nickname: userStore.nickname,
avatar: userStore.avatar
}))
/**
* 获取用户资料
*/
async function fetchProfile() {
try {
loading.value = true
loadingText.value = '加载中...'
const res = await getProfile()
if (res.code === 0 && res.data) {
userStore.updateUserInfo(res.data)
}
} catch (error) {
console.error('获取用户资料失败:', error)
uni.showToast({
title: '获取资料失败',
icon: 'none'
})
} finally {
loading.value = false
}
}
.placeholder {
/**
* 选择并上传头像
*/
function handleChangeAvatar() {
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: async (res) => {
const tempFilePath = res.tempFilePaths[0]
await uploadAvatar(tempFilePath)
},
fail: (err) => {
if (err.errMsg && !err.errMsg.includes('cancel')) {
uni.showToast({
title: '选择图片失败',
icon: 'none'
})
}
}
})
}
/**
* 上传头像
* @param {string} filePath - 图片临时路径
*/
async function uploadAvatar(filePath) {
try {
loading.value = true
loadingText.value = '上传中...'
//
const uploadRes = await new Promise((resolve, reject) => {
uni.uploadFile({
url: `${config.API_BASE_URL}/upload/image`,
filePath: filePath,
name: 'file',
header: {
'Authorization': `Bearer ${userStore.token}`
},
success: (res) => {
if (res.statusCode === 200) {
try {
const data = JSON.parse(res.data)
resolve(data)
} catch (e) {
reject(new Error('解析响应失败'))
}
} else {
reject(new Error('上传失败'))
}
},
fail: (err) => reject(err)
})
})
if (uploadRes.code === 0 && uploadRes.data) {
//
const avatarUrl = uploadRes.data.url || uploadRes.data
const updateRes = await updateAvatar(avatarUrl)
if (updateRes.code === 0) {
userStore.updateUserInfo({ avatar: avatarUrl })
uni.showToast({
title: '头像更新成功',
icon: 'success'
})
} else {
throw new Error(updateRes.message || '更新头像失败')
}
} else {
throw new Error(uploadRes.message || '上传失败')
}
} catch (error) {
console.error('上传头像失败:', error)
uni.showToast({
title: error.message || '上传失败',
icon: 'none'
})
} finally {
loading.value = false
}
}
/**
* 显示修改昵称弹窗
*/
function showNicknamePopup() {
newNickname.value = userInfo.value.nickname || ''
nicknamePopupVisible.value = true
}
/**
* 隐藏修改昵称弹窗
*/
function hideNicknamePopup() {
nicknamePopupVisible.value = false
newNickname.value = ''
}
/**
* 更新昵称
*/
async function handleUpdateNickname() {
const nickname = newNickname.value.trim()
if (!nickname) {
uni.showToast({
title: '请输入昵称',
icon: 'none'
})
return
}
if (nickname === userInfo.value.nickname) {
hideNicknamePopup()
return
}
try {
loading.value = true
loadingText.value = '保存中...'
hideNicknamePopup()
const res = await updateProfile({ nickname })
if (res.code === 0) {
userStore.updateUserInfo({ nickname })
uni.showToast({
title: '昵称更新成功',
icon: 'success'
})
} else {
throw new Error(res.message || '更新失败')
}
} catch (error) {
console.error('更新昵称失败:', error)
uni.showToast({
title: error.message || '更新失败',
icon: 'none'
})
} finally {
loading.value = false
}
}
/**
* 页面显示时刷新数据
*/
onShow(() => {
userStore.restoreFromStorage()
})
/**
* 页面加载
*/
onMounted(() => {
userStore.restoreFromStorage()
fetchProfile()
})
</script>
<style lang="scss" scoped>
@import '@/styles/variables.scss';
.profile-page {
min-height: 100vh;
background-color: $bg-color;
}
//
.page-content {
padding: $spacing-lg;
}
//
.profile-item {
display: flex;
align-items: center;
justify-content: space-between;
background-color: $bg-white;
padding: $spacing-lg;
margin-bottom: 2rpx;
&:first-child {
border-radius: $border-radius-lg $border-radius-lg 0 0;
}
&:last-child {
border-radius: 0 0 $border-radius-lg $border-radius-lg;
margin-bottom: 0;
}
&:active {
background-color: $bg-gray;
}
.item-label {
font-size: $font-size-md;
color: $text-color;
}
.item-value {
display: flex;
align-items: center;
.value-text {
font-size: $font-size-md;
color: $text-secondary;
margin-right: $spacing-sm;
}
.uid-text {
color: $text-placeholder;
}
.arrow {
font-size: 36rpx;
color: $text-placeholder;
}
}
//
&.avatar-item {
.avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
margin-right: $spacing-sm;
background-color: $bg-gray;
}
}
// UID
&.uid-item {
&:active {
background-color: $bg-white;
}
}
}
//
.popup-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: $bg-mask;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
font-size: 32rpx;
color: #999;
z-index: 1000;
}
.popup-container {
width: 600rpx;
background-color: $bg-white;
border-radius: $border-radius-lg;
overflow: hidden;
}
.nickname-popup {
.popup-header {
padding: $spacing-lg;
text-align: center;
border-bottom: 1rpx solid $border-light;
.popup-title {
font-size: $font-size-lg;
font-weight: $font-weight-medium;
color: $text-color;
}
}
.popup-body {
padding: $spacing-lg;
.nickname-input {
width: 100%;
height: 80rpx;
padding: 0 $spacing-md;
font-size: $font-size-md;
color: $text-color;
background-color: $bg-gray;
border-radius: $border-radius-md;
box-sizing: border-box;
}
}
.popup-footer {
display: flex;
border-top: 1rpx solid $border-light;
.popup-btn {
flex: 1;
height: 100rpx;
display: flex;
align-items: center;
justify-content: center;
&:active {
background-color: $bg-gray;
}
text {
font-size: $font-size-lg;
}
&.cancel {
border-right: 1rpx solid $border-light;
text {
color: $text-secondary;
}
}
&.confirm {
text {
color: $primary-color;
}
}
}
}
}
//
.loading-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
z-index: 1001;
}
.loading-content {
display: flex;
flex-direction: column;
align-items: center;
padding: $spacing-xl;
background-color: $bg-white;
border-radius: $border-radius-lg;
.loading-spinner {
width: 60rpx;
height: 60rpx;
border: 4rpx solid $border-color;
border-top-color: $primary-color;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.loading-text {
margin-top: $spacing-md;
font-size: $font-size-sm;
color: $text-secondary;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@ -1,27 +1,438 @@
<script setup>
/**
* 我的订单页面
* 展示用户所有已支付或退款的订单
*/
import { ref, computed, onMounted } from 'vue'
import { onShow, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import { useUserStore } from '@/store/user.js'
import { useAuth } from '@/composables/useAuth.js'
import { getOrderList } from '@/api/order.js'
import Empty from '@/components/Empty/index.vue'
import Loading from '@/components/Loading/index.vue'
const userStore = useUserStore()
const { checkLogin } = useAuth()
//
const ORDER_STATUS = {
PENDING_PAYMENT: 1, //
PAID: 2, //
COMPLETED: 3, //
REFUNDING: 4, // 退
REFUNDED: 5, // 退
CANCELLED: 6, //
GENERATING: 7, //
PENDING_ASSESSMENT: 8 //
}
//
const loading = ref(false)
const refreshing = ref(false)
const orderList = ref([])
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const noMore = ref(false)
//
const isEmpty = computed(() => !loading.value && orderList.value.length === 0)
const hasMore = computed(() => orderList.value.length < total.value)
/**
* 获取订单状态文本
*/
function getStatusText(status) {
const statusMap = {
[ORDER_STATUS.PENDING_PAYMENT]: '待支付',
[ORDER_STATUS.PAID]: '已支付',
[ORDER_STATUS.COMPLETED]: '已测评',
[ORDER_STATUS.REFUNDING]: '退款中',
[ORDER_STATUS.REFUNDED]: '已退款',
[ORDER_STATUS.CANCELLED]: '已取消',
[ORDER_STATUS.GENERATING]: '测评生成中',
[ORDER_STATUS.PENDING_ASSESSMENT]: '待测评'
}
return statusMap[status] || '未知状态'
}
/**
* 获取订单状态样式类
*/
function getStatusClass(status) {
const classMap = {
[ORDER_STATUS.PENDING_PAYMENT]: 'status-pending',
[ORDER_STATUS.PAID]: 'status-paid',
[ORDER_STATUS.COMPLETED]: 'status-completed',
[ORDER_STATUS.REFUNDING]: 'status-refunding',
[ORDER_STATUS.REFUNDED]: 'status-refunded',
[ORDER_STATUS.CANCELLED]: 'status-cancelled',
[ORDER_STATUS.GENERATING]: 'status-generating',
[ORDER_STATUS.PENDING_ASSESSMENT]: 'status-pending-assessment'
}
return classMap[status] || ''
}
/**
* 格式化日期
*/
function formatDate(dateStr) {
if (!dateStr) return ''
const date = new Date(dateStr)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
/**
* 格式化金额
*/
function formatAmount(amount) {
if (amount === undefined || amount === null) return '0.00'
return Number(amount).toFixed(2)
}
/**
* 加载订单列表
*/
async function loadOrderList(isRefresh = false) {
if (loading.value) return
if (isRefresh) {
page.value = 1
noMore.value = false
}
loading.value = true
try {
const res = await getOrderList({
page: page.value,
pageSize: pageSize.value
})
if (res.code === 0 && res.data) {
const list = res.data.list || []
total.value = res.data.total || 0
if (isRefresh) {
orderList.value = list
} else {
orderList.value = [...orderList.value, ...list]
}
//
noMore.value = orderList.value.length >= total.value
} else {
uni.showToast({
title: res.message || '获取订单失败',
icon: 'none'
})
}
} catch (error) {
console.error('获取订单列表失败:', error)
uni.showToast({
title: '网络错误,请重试',
icon: 'none'
})
} finally {
loading.value = false
refreshing.value = false
uni.stopPullDownRefresh()
}
}
/**
* 下拉刷新
*/
onPullDownRefresh(() => {
refreshing.value = true
loadOrderList(true)
})
/**
* 上拉加载更多
*/
onReachBottom(() => {
if (!noMore.value && !loading.value) {
page.value++
loadOrderList()
}
})
/**
* 查看测评结果
*/
function viewResult(order) {
uni.navigateTo({
url: `/pages/assessment/result/index?recordId=${order.recordId}`
})
}
/**
* 开始测评
*/
function startAssessment(order) {
uni.navigateTo({
url: `/pages/assessment/questions/index?orderId=${order.id}&typeId=${order.productId}`
})
}
/**
* 判断是否显示查看结果按钮
*/
function showViewResultBtn(status) {
return status === ORDER_STATUS.COMPLETED
}
/**
* 判断是否显示开始测评按钮
*/
function showStartBtn(status) {
return status === ORDER_STATUS.PENDING_ASSESSMENT
}
/**
* 页面显示时检查登录状态并加载数据
*/
onShow(() => {
userStore.restoreFromStorage()
if (checkLogin()) {
loadOrderList(true)
}
})
/**
* 页面加载
*/
onMounted(() => {
userStore.restoreFromStorage()
})
</script>
<template>
<view class="order-list-page">
<view class="placeholder">我的订单页面</view>
<!-- 页面加载中 -->
<Loading type="page" :loading="loading && orderList.length === 0" />
<!-- 订单列表 -->
<view class="order-list" v-if="!isEmpty">
<view
class="order-card"
v-for="order in orderList"
:key="order.id"
>
<!-- 订单头部 -->
<view class="order-header">
<view class="order-date">{{ formatDate(order.createTime) }}</view>
<view class="order-status" :class="getStatusClass(order.status)">
{{ getStatusText(order.status) }}
</view>
</view>
<!-- 订单内容 -->
<view class="order-content">
<view class="order-info-row">
<text class="info-label">订单编号</text>
<text class="info-value">{{ order.orderNo || '--' }}</text>
</view>
<view class="order-info-row">
<text class="info-label">测评项目</text>
<text class="info-value">{{ order.productName || '--' }}</text>
</view>
<view class="order-info-row">
<text class="info-label">测评金额</text>
<text class="info-value amount">¥{{ formatAmount(order.amount) }}</text>
</view>
</view>
<!-- 订单操作按钮 -->
<view class="order-actions" v-if="showViewResultBtn(order.status) || showStartBtn(order.status)">
<view
class="action-btn primary"
v-if="showViewResultBtn(order.status)"
@click="viewResult(order)"
>
查看测评结果
</view>
<view
class="action-btn primary"
v-if="showStartBtn(order.status)"
@click="startAssessment(order)"
>
开始测评
</view>
</view>
</view>
<!-- 加载更多 -->
<Loading
type="more"
:loading="loading && orderList.length > 0"
:noMore="noMore"
noMoreText="没有更多订单了"
/>
</view>
<!-- 空状态 -->
<Empty
v-if="isEmpty"
text="暂无订单记录"
:showButton="false"
/>
</view>
</template>
<style scoped lang="scss">
<style lang="scss" scoped>
@import '@/styles/variables.scss';
.order-list-page {
min-height: 100vh;
background-color: #f5f5f5;
background-color: $bg-color;
padding: $spacing-lg;
padding-bottom: calc(#{$spacing-lg} + env(safe-area-inset-bottom));
}
.placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
font-size: 32rpx;
color: #999;
//
.order-list {
.order-card {
background-color: $bg-white;
border-radius: $border-radius-lg;
margin-bottom: $spacing-lg;
overflow: hidden;
//
.order-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: $spacing-md $spacing-lg;
border-bottom: 1rpx solid $border-light;
.order-date {
font-size: $font-size-md;
color: $text-color;
}
.order-status {
font-size: $font-size-sm;
padding: 4rpx 16rpx;
border-radius: $border-radius-sm;
// - 绿
&.status-completed {
color: $success-color;
background-color: rgba(82, 196, 26, 0.1);
}
// -
&.status-generating {
color: $primary-color;
background-color: rgba(74, 144, 226, 0.1);
}
// -
&.status-pending-assessment {
color: $warning-color;
background-color: rgba(250, 173, 20, 0.1);
}
// -
&.status-paid {
color: $primary-color;
background-color: rgba(74, 144, 226, 0.1);
}
// -
&.status-pending {
color: $text-placeholder;
background-color: rgba(153, 153, 153, 0.1);
}
// 退 -
&.status-refunding {
color: $warning-color;
background-color: rgba(250, 173, 20, 0.1);
}
// 退 -
&.status-refunded {
color: $text-secondary;
background-color: rgba(102, 102, 102, 0.1);
}
// -
&.status-cancelled {
color: $text-placeholder;
background-color: rgba(153, 153, 153, 0.1);
}
}
}
//
.order-content {
padding: $spacing-md $spacing-lg;
.order-info-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: $spacing-xs 0;
.info-label {
font-size: $font-size-md;
color: $text-secondary;
}
.info-value {
font-size: $font-size-md;
color: $text-color;
&.amount {
color: $error-color;
font-weight: $font-weight-medium;
}
}
}
}
//
.order-actions {
display: flex;
justify-content: flex-end;
padding: $spacing-md $spacing-lg;
border-top: 1rpx solid $border-light;
.action-btn {
min-width: 180rpx;
height: 64rpx;
line-height: 64rpx;
text-align: center;
font-size: $font-size-sm;
border-radius: 32rpx;
margin-left: $spacing-md;
&.primary {
background-color: $primary-color;
color: $text-white;
&:active {
opacity: 0.8;
}
}
&.outline {
border: 1rpx solid $primary-color;
color: $primary-color;
background-color: transparent;
&:active {
background-color: rgba(74, 144, 226, 0.1);
}
}
}
}
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -1,27 +1,316 @@
<script setup>
/**
* 规划师列表页面
*/
</script>
<template>
<view class="planner-list-page">
<view class="placeholder">规划师列表页面</view>
<!-- 自定义导航栏 -->
<Navbar title="学业规划" :showBack="true" />
<!-- 页面内容 -->
<view class="page-content">
<!-- 加载状态 -->
<Loading v-if="pageLoading" type="page" :loading="true" />
<!-- 规划师列表 -->
<view v-else class="planner-list">
<!-- 有数据时显示列表 -->
<template v-if="plannerList.length > 0">
<view
class="planner-card"
v-for="(item, index) in plannerList"
:key="item.id || index"
@click="handlePlannerClick(item)"
>
<!-- 规划师头像 -->
<view class="planner-avatar">
<image
:src="item.avatar || item.photo || '/static/ic_empty.png'"
mode="aspectFill"
class="avatar-image"
@error="handleAvatarError(index)"
/>
</view>
<!-- 规划师信息 -->
<view class="planner-info">
<!-- 姓名 -->
<view class="planner-name">{{ item.name || '规划师' }}</view>
<!-- 介绍 -->
<view class="planner-intro">{{ item.intro || item.introduction || '暂无介绍' }}</view>
<!-- 价格 -->
<view class="planner-price">
<text class="price-symbol">¥</text>
<text class="price-value">{{ formatPrice(item.price) }}</text>
<text class="price-unit">/</text>
</view>
</view>
<!-- 预约按钮 -->
<view class="planner-action">
<view class="book-btn">预约</view>
</view>
</view>
</template>
<!-- 无数据时显示空状态 -->
<view v-else class="empty-state">
<image
src="/static/ic_empty.png"
mode="aspectFit"
class="empty-icon"
/>
<text class="empty-text">暂无规划师</text>
</view>
</view>
<!-- 底部安全区域 -->
<view class="safe-bottom"></view>
</view>
</view>
</template>
<style scoped lang="scss">
.planner-list-page {
min-height: 100vh;
background-color: #f5f5f5;
<script setup>
/**
* 规划师列表页面
* 展示规划师卡片照片姓名介绍价格
*/
import { ref, onMounted } from 'vue'
import { onPullDownRefresh } from '@dcloudio/uni-app'
import { getPlannerList } from '@/api/planner.js'
import Navbar from '@/components/Navbar/index.vue'
import Loading from '@/components/Loading/index.vue'
//
const pageLoading = ref(true)
const plannerList = ref([])
/**
* 加载规划师列表
*/
async function loadPlannerList() {
pageLoading.value = true
try {
const res = await getPlannerList()
if (res && res.code === 0 && res.data) {
//
if (Array.isArray(res.data)) {
plannerList.value = res.data
} else if (res.data.list && Array.isArray(res.data.list)) {
plannerList.value = res.data.list
} else {
plannerList.value = []
}
}
} catch (error) {
console.error('加载规划师列表失败:', error)
uni.showToast({
title: '加载失败,请稍后重试',
icon: 'none'
})
} finally {
pageLoading.value = false
}
}
.placeholder {
/**
* 格式化价格
* @param {number|string} price - 价格
* @returns {string} 格式化后的价格
*/
function formatPrice(price) {
if (price === undefined || price === null) return '0'
const num = Number(price)
if (isNaN(num)) return '0'
//
if (Number.isInteger(num)) return num.toString()
//
return num.toFixed(2)
}
/**
* 处理头像加载失败
*/
function handleAvatarError(index) {
console.error(`规划师头像 ${index + 1} 加载失败`)
}
/**
* 处理规划师点击
*/
function handlePlannerClick(item) {
//
uni.navigateTo({
url: `/pages/planner/book/index?plannerId=${item.id}&plannerName=${encodeURIComponent(item.name || '')}`
})
}
/**
* 下拉刷新
*/
onPullDownRefresh(async () => {
await loadPlannerList()
uni.stopPullDownRefresh()
})
/**
* 页面加载
*/
onMounted(() => {
loadPlannerList()
})
</script>
<style lang="scss" scoped>
@import '@/styles/variables.scss';
.planner-list-page {
min-height: 100vh;
background-color: $bg-color;
}
//
.page-content {
padding: $spacing-lg;
padding-bottom: env(safe-area-inset-bottom);
}
//
.planner-list {
display: flex;
flex-direction: column;
gap: $spacing-md;
}
//
.planner-card {
display: flex;
align-items: center;
background-color: $bg-white;
border-radius: $border-radius-lg;
padding: $spacing-lg;
box-shadow: $shadow-sm;
&:active {
opacity: 0.9;
}
}
//
.planner-avatar {
flex-shrink: 0;
width: 160rpx;
height: 160rpx;
border-radius: $border-radius-md;
overflow: hidden;
margin-right: $spacing-md;
.avatar-image {
width: 100%;
height: 100%;
}
}
//
.planner-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: $spacing-xs;
}
//
.planner-name {
font-size: $font-size-lg;
font-weight: $font-weight-medium;
color: $text-color;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
//
.planner-intro {
font-size: $font-size-sm;
color: $text-secondary;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
line-height: 1.5;
}
//
.planner-price {
display: flex;
align-items: baseline;
margin-top: $spacing-xs;
.price-symbol {
font-size: $font-size-sm;
color: $error-color;
font-weight: $font-weight-medium;
}
.price-value {
font-size: $font-size-xl;
color: $error-color;
font-weight: $font-weight-bold;
margin-left: 2rpx;
}
.price-unit {
font-size: $font-size-xs;
color: $text-placeholder;
margin-left: 4rpx;
}
}
//
.planner-action {
flex-shrink: 0;
margin-left: $spacing-md;
.book-btn {
display: flex;
align-items: center;
justify-content: center;
width: 120rpx;
height: 64rpx;
background-color: $primary-color;
color: $text-white;
font-size: $font-size-md;
font-weight: $font-weight-medium;
border-radius: $border-radius-round;
&:active {
background-color: $primary-dark;
}
}
}
//
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
font-size: 32rpx;
color: #999;
padding: 200rpx 0;
.empty-icon {
width: 200rpx;
height: 200rpx;
margin-bottom: $spacing-lg;
}
.empty-text {
font-size: $font-size-md;
color: $text-placeholder;
}
}
//
.safe-bottom {
height: 40rpx;
padding-bottom: env(safe-area-inset-bottom);
}
</style>