first commit

This commit is contained in:
2026-03-17 14:52:07 +08:00
parent a23b829323
commit ec25e6e617
104 changed files with 35840 additions and 0 deletions

33
.eslintrc.js Normal file
View File

@@ -0,0 +1,33 @@
module.exports = {
root: true,
env: {
browser: true,
es2021: true,
node: true
},
extends: [
'eslint:recommended',
'plugin:vue/vue3-recommended',
'plugin:prettier/recommended'
],
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module'
},
globals: {
uni: 'readonly',
wx: 'readonly',
plus: 'readonly'
},
rules: {
'vue/multi-word-component-names': 'off',
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'prettier/prettier': [
'error',
{
endOfLine: 'auto'
}
]
}
}

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
dist/
unpackage/
.DS_Store
*.log
.env.local
.env.*.local

4
.husky/pre-commit Normal file
View File

@@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged

8
.prettierrc.js Normal file
View File

@@ -0,0 +1,8 @@
module.exports = {
semi: false,
singleQuote: true,
printWidth: 100,
trailingComma: 'none',
arrowParens: 'avoid',
endOfLine: 'auto'
}

151
docs/uView使用指南.md Normal file
View File

@@ -0,0 +1,151 @@
# uView-Plus 使用指南
## 已完成集成步骤
1. ✅ 安装依赖:`npm install uview-plus --save`
2. ✅ 在 `main.js` 中引入并注册
3. ✅ 创建 `src/uni.scss` 并引入 uView 主题变量
4. ✅ 在 `vite.config.js` 中配置 SCSS 预处理器
5. ✅ 在 `pages.json` 中配置 easycom 自动引入
## 配置说明
### uni.scss
```scss
/* uView-Plus 主题变量配置 */
@import 'uview-plus/theme.scss';
```
### vite.config.js
```javascript
css: {
preprocessorOptions: {
scss: {
additionalData: '@import "@/uni.scss"; @import "@/styles/variables.scss"; @import "@/styles/mixins.scss";',
api: 'modern-compiler',
silenceDeprecations: ['legacy-js-api', 'import']
}
}
}
```
### pages.json
```json
"easycom": {
"autoscan": true,
"custom": {
"^u-(.*)": "uview-plus/components/u-$1/u-$1.vue"
}
}
```
## 常用组件示例
### 1. 按钮 (u-button)
```vue
<u-button type="primary" text="主要按钮"></u-button>
<u-button type="success" text="成功按钮"></u-button>
<u-button type="warning" text="警告按钮"></u-button>
```
### 2. 加载动画 (u-loading-icon)
```vue
<u-loading-icon mode="spinner"></u-loading-icon>
<u-loading-icon mode="circle"></u-loading-icon>
```
### 3. 弹窗 (u-popup)
```vue
<u-popup v-model:show="show" mode="bottom" :round="10">
<view class="content">弹窗内容</view>
</u-popup>
```
### 4. 输入框 (u-input)
```vue
<u-input v-model="value" placeholder="请输入内容"></u-input>
```
### 5. 表单 (u-form)
```vue
<u-form :model="form" ref="formRef">
<u-form-item label="姓名" prop="name">
<u-input v-model="form.name" placeholder="请输入姓名"></u-input>
</u-form-item>
</u-form>
```
### 6. Toast 提示
```javascript
// 在组件中使用
this.$u.toast('提示内容')
```
### 7. 日历 (u-calendar)
```vue
<u-calendar v-model:show="show" @confirm="confirm"></u-calendar>
```
### 8. 时间选择器 (u-datetime-picker)
```vue
<u-datetime-picker
v-model:show="show"
v-model="currentDate"
mode="datetime"
@confirm="confirm"
></u-datetime-picker>
```
## 可以优化的现有组件
### LoadingOverlay.vue
可以使用 `u-loading-icon` 替代自定义加载动画:
```vue
<template>
<u-overlay :show="visible">
<view class="loading-box">
<u-loading-icon mode="spinner" size="40" color="#ffffff"></u-loading-icon>
</view>
</u-overlay>
</template>
```
### BookingPopup.vue
可以使用 `u-popup` 替代自定义弹窗:
```vue
<u-popup
v-model:show="visible"
mode="bottom"
:round="16"
:closeable="true"
@close="handleClose"
>
<!-- 弹窗内容 -->
</u-popup>
```
可以使用 `u-form``u-form-item` 优化表单:
```vue
<u-form :model="formData" ref="formRef" :rules="rules">
<u-form-item label="姓名" prop="name" required>
<u-input v-model="formData.name" placeholder="请填写您的姓名"></u-input>
</u-form-item>
<u-form-item label="电话" prop="phone" required>
<u-input v-model="formData.phone" type="number" maxlength="11"></u-input>
</u-form-item>
</u-form>
```
可以使用 `u-calendar` 替代自定义日期选择器
## 文档链接
- 官方文档https://uview-plus.jiangruyi.com/
- 组件列表https://uview-plus.jiangruyi.com/components/intro.html
## 注意事项
1. uview-plus 是专为 Vue3 + uni-app 设计的组件库
2. 组件通过 easycom 自动引入,无需手动 import
3. 所有组件以 `u-` 开头
4. 支持主题定制和暗黑模式

403
docs/多端适配方案.md Normal file
View File

@@ -0,0 +1,403 @@
# 多端适配方案
## 一、平台差异处理
### 1.1 条件编译
使用 UniApp 的条件编译处理不同平台的差异:
```javascript
// #ifdef MP-WEIXIN
// 微信小程序特有代码
wx.login()
// #endif
// #ifdef H5
// H5特有代码
window.location.href = '/login'
// #endif
// #ifdef APP-PLUS
// App特有代码
plus.runtime.restart()
// #endif
```
### 1.2 平台判断工具
使用封装的平台判断工具:
```javascript
import { getPlatform, isMiniProgram, isH5, isApp } from '@/utils/platform'
if (isMiniProgram()) {
// 小程序端逻辑
} else if (isH5()) {
// H5端逻辑
} else if (isApp()) {
// App端逻辑
}
```
## 二、API 适配
### 2.1 统一 API 封装
对不同平台的 API 进行统一封装:
```javascript
// 跨端导航
export const navigateTo = (url, params = {}) => {
const query = Object.keys(params)
.map(key => `${key}=${encodeURIComponent(params[key])}`)
.join('&')
const fullUrl = query ? `${url}?${query}` : url
uni.navigateTo({ url: fullUrl })
}
// 跨端存储
export const setStorage = (key, value) => {
// #ifdef MP-WEIXIN
wx.setStorageSync(key, value)
// #endif
// #ifdef H5
localStorage.setItem(key, JSON.stringify(value))
// #endif
// #ifdef APP-PLUS
plus.storage.setItem(key, JSON.stringify(value))
// #endif
}
```
### 2.2 第三方登录适配
不同平台的第三方登录实现:
```javascript
// 微信登录
export const wechatLogin = () => {
return new Promise((resolve, reject) => {
// #ifdef MP-WEIXIN
wx.login({
success: (res) => {
resolve(res.code)
},
fail: reject
})
// #endif
// #ifdef H5
// H5微信授权登录
const appId = 'YOUR_APP_ID'
const redirectUri = encodeURIComponent(window.location.href)
window.location.href = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${appId}&redirect_uri=${redirectUri}&response_type=code&scope=snsapi_userinfo#wechat_redirect`
// #endif
})
}
```
## 三、样式适配
### 3.1 尺寸单位适配
- **小程序端**:使用 `rpx`750rpx = 屏幕宽度)
- **H5端**:使用 `rem``vw`
- **App端**:使用 `rpx``upx`
```scss
// 统一使用 rpxUniApp 会自动转换
.container {
width: 750rpx;
padding: 30rpx;
font-size: 28rpx;
}
```
### 3.2 安全区域适配
处理刘海屏、底部安全区域:
```scss
.page {
// 顶部安全区域
padding-top: constant(safe-area-inset-top);
padding-top: env(safe-area-inset-top);
// 底部安全区域
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
```
### 3.3 状态栏高度适配
```javascript
// 获取状态栏高度
const systemInfo = uni.getSystemInfoSync()
const statusBarHeight = systemInfo.statusBarHeight
// 在样式中使用
const navBarHeight = statusBarHeight + 44 // 44为导航栏高度
```
## 四、功能适配
### 4.1 分享功能
```javascript
// 小程序分享
// #ifdef MP-WEIXIN
onShareAppMessage() {
return {
title: '分享标题',
path: '/pages/index/index',
imageUrl: '/static/share.png'
}
}
// #endif
// H5分享
// #ifdef H5
const shareToWechat = () => {
// 调用微信 JS-SDK
wx.updateAppMessageShareData({
title: '分享标题',
desc: '分享描述',
link: window.location.href,
imgUrl: 'https://example.com/share.png'
})
}
// #endif
```
### 4.2 支付功能
```javascript
// 统一支付接口
export const pay = (orderInfo) => {
return new Promise((resolve, reject) => {
// #ifdef MP-WEIXIN
wx.requestPayment({
timeStamp: orderInfo.timeStamp,
nonceStr: orderInfo.nonceStr,
package: orderInfo.package,
signType: 'MD5',
paySign: orderInfo.paySign,
success: resolve,
fail: reject
})
// #endif
// #ifdef H5
// H5微信支付
WeixinJSBridge.invoke('getBrandWCPayRequest', orderInfo, (res) => {
if (res.err_msg === 'get_brand_wcpay_request:ok') {
resolve(res)
} else {
reject(res)
}
})
// #endif
// #ifdef APP-PLUS
// App支付
plus.payment.request('wxpay', orderInfo, resolve, reject)
// #endif
})
}
```
### 4.3 定位功能
```javascript
// 获取位置
export const getLocation = () => {
return new Promise((resolve, reject) => {
uni.getLocation({
type: 'gcj02',
success: (res) => {
resolve({
latitude: res.latitude,
longitude: res.longitude
})
},
fail: (err) => {
// #ifdef H5
// H5使用浏览器定位API
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(position) => {
resolve({
latitude: position.coords.latitude,
longitude: position.coords.longitude
})
},
reject
)
} else {
reject(new Error('浏览器不支持定位'))
}
// #endif
// #ifndef H5
reject(err)
// #endif
}
})
})
}
```
## 五、性能适配
### 5.1 小程序分包
```json
{
"pages": [
"pages/index/index",
"pages/user/index"
],
"subPackages": [
{
"root": "subPackages/order",
"pages": [
"list/index",
"detail/index"
]
},
{
"root": "subPackages/product",
"pages": [
"list/index",
"detail/index"
]
}
],
"preloadRule": {
"pages/index/index": {
"network": "all",
"packages": ["subPackages/order"]
}
}
}
```
### 5.2 图片懒加载
```vue
<template>
<!-- 小程序端使用 lazy-load -->
<image
:src="imageSrc"
lazy-load
mode="aspectFill"
/>
<!-- H5端使用 Intersection Observer -->
<img
v-lazy="imageSrc"
alt="图片"
/>
</template>
```
### 5.3 长列表优化
```vue
<template>
<!-- 使用虚拟列表 -->
<recycle-list :list="dataList">
<template v-slot="{ item }">
<view class="list-item">
{{ item.title }}
</view>
</template>
</recycle-list>
</template>
```
## 六、兼容性处理
### 6.1 API 兼容性检查
```javascript
// 检查API是否支持
if (uni.canIUse('getSystemInfoSync')) {
const systemInfo = uni.getSystemInfoSync()
}
// 版本号比较
const compareVersion = (v1, v2) => {
const arr1 = v1.split('.')
const arr2 = v2.split('.')
const len = Math.max(arr1.length, arr2.length)
for (let i = 0; i < len; i++) {
const num1 = parseInt(arr1[i] || 0)
const num2 = parseInt(arr2[i] || 0)
if (num1 > num2) return 1
if (num1 < num2) return -1
}
return 0
}
// 使用示例
const systemInfo = uni.getSystemInfoSync()
if (compareVersion(systemInfo.SDKVersion, '2.10.0') >= 0) {
// 支持新API
}
```
### 6.2 样式兼容性
```scss
// 使用 CSS 变量实现主题切换
:root {
--primary-color: #1890ff;
--text-color: #333;
}
.button {
background-color: var(--primary-color);
color: var(--text-color);
}
// 使用 autoprefixer 自动添加前缀
.flex-container {
display: flex;
// 自动添加 -webkit-flex
}
```
## 七、调试技巧
### 7.1 多端调试
- **微信小程序**:使用微信开发者工具
- **H5**:使用浏览器开发者工具
- **App**:使用 HBuilderX 真机调试
### 7.2 日志输出
```javascript
// 开发环境输出日志
if (process.env.NODE_ENV === 'development') {
console.log('调试信息:', data)
}
// 使用条件编译输出平台信息
// #ifdef MP-WEIXIN
console.log('当前平台:微信小程序')
// #endif
```
### 7.3 错误监控
```javascript
// 全局错误捕获
uni.onError((error) => {
console.error('全局错误:', error)
// 上报到错误监控平台
})
// 网络错误监控
uni.onNetworkStatusChange((res) => {
if (!res.isConnected) {
uni.showToast({
title: '网络已断开',
icon: 'none'
})
}
})
```

300
docs/开发规范.md Normal file
View File

@@ -0,0 +1,300 @@
# 开发规范文档
## 一、命名规范
### 1.1 文件命名
- **页面文件**:使用小写字母,多个单词用短横线连接,如 `user-profile.vue`
- **组件文件**:使用大驼峰命名,如 `UserCard.vue``LoadingView.vue`
- **工具类文件**:使用小写字母,多个单词用短横线连接,如 `request.js``storage.js`
- **常量文件**:使用小写字母,如 `constants.js``env.js`
### 1.2 变量命名
- **普通变量**:使用小驼峰命名,如 `userName``userInfo`
- **常量**:使用全大写字母,单词间用下划线连接,如 `API_BASE_URL``MAX_RETRY_COUNT`
- **私有变量**:以下划线开头,如 `_privateMethod`
- **布尔值**:以 `is``has``can` 等开头,如 `isLogin``hasPermission`
### 1.3 函数命名
- **普通函数**:使用小驼峰命名,动词开头,如 `getUserInfo``handleClick`
- **事件处理函数**:以 `handle``on` 开头,如 `handleSubmit``onLoad`
- **工具函数**:使用动词开头,如 `formatDate``validatePhone`
### 1.4 组件命名
- **全局组件**:使用大驼峰命名,至少两个单词,如 `LoadingView``EmptyView`
- **页面组件**:使用大驼峰命名,如 `LoginPage``UserProfile`
- **业务组件**:使用大驼峰命名,体现业务含义,如 `UserCard``OrderList`
## 二、代码注释规范
### 2.1 文件注释
每个文件开头必须包含文件说明注释:
```javascript
/**
* 用户相关接口
* @author 张三
* @date 2024-01-01
*/
```
### 2.2 函数注释
关键函数必须添加注释,说明功能、参数、返回值:
```javascript
/**
* 用户登录
* @param {object} data 登录数据
* @param {string} data.phone 手机号
* @param {string} data.password 密码
* @returns {Promise<object>} 登录结果
*/
export const login = (data) => {
return request.post('/auth/login', data)
}
```
### 2.3 复杂逻辑注释
对于复杂的业务逻辑,必须添加行内注释:
```javascript
// 检查Token是否过期
if (tokenExpireTime < Date.now()) {
// Token已过期刷新Token
await refreshToken()
}
```
### 2.4 TODO注释
待完成的功能使用 TODO 标记:
```javascript
// TODO: 添加图片压缩功能
// FIXME: 修复在iOS端的兼容性问题
```
## 三、代码风格规范
### 3.1 缩进与空格
- 使用 2 个空格缩进
- 运算符前后加空格
- 逗号后加空格
- 对象属性冒号后加空格
### 3.2 引号使用
- JavaScript 统一使用单引号
- HTML 属性统一使用双引号
### 3.3 分号使用
- 语句结尾不使用分号(根据 Prettier 配置)
### 3.4 代码长度
- 单行代码不超过 100 个字符
- 函数代码行数不超过 50 行
- 文件代码行数不超过 500 行
## 四、Vue 组件规范
### 4.1 组件结构顺序
```vue
<template>
<!-- 模板内容 -->
</template>
<script>
// 1. 导入
import { ref } from 'vue'
// 2. 组件定义
export default {
name: 'ComponentName',
components: {},
props: {},
emits: [],
setup() {
// 3. 响应式数据
// 4. 计算属性
// 5. 方法
// 6. 生命周期
// 7. 返回
}
}
</script>
<style lang="scss" scoped>
/* 样式内容 */
</style>
```
### 4.2 Props 定义
Props 必须定义类型、默认值和校验:
```javascript
props: {
title: {
type: String,
required: true
},
count: {
type: Number,
default: 0,
validator: (value) => value >= 0
}
}
```
### 4.3 事件命名
- 使用短横线命名:`@update-value`
- 使用动词开头:`@click``@change``@submit`
## 五、Git 提交规范
### 5.1 提交信息格式
```
<type>(<scope>): <subject>
<body>
<footer>
```
### 5.2 Type 类型
- `feat`: 新功能
- `fix`: 修复 Bug
- `docs`: 文档更新
- `style`: 代码格式调整
- `refactor`: 重构代码
- `perf`: 性能优化
- `test`: 测试相关
- `chore`: 构建/工具链相关
### 5.3 提交示例
```
feat(login): 添加手机号验证码登录功能
- 实现验证码发送接口
- 添加验证码输入组件
- 完成登录逻辑
Closes #123
```
## 六、API 接口规范
### 6.1 接口文件组织
按业务模块划分接口文件:
```
api/
├── modules/
│ ├── auth.js # 认证相关
│ ├── user.js # 用户相关
│ └── order.js # 订单相关
└── index.js # 统一导出
```
### 6.2 接口命名
- 使用动词开头:`getUserInfo``updateUserInfo`
- RESTful 风格:`getList``getDetail``create``update``delete`
### 6.3 接口注释
每个接口必须添加注释说明:
```javascript
/**
* 获取用户信息
* @param {string} userId 用户ID
* @returns {Promise<object>} 用户信息
*/
export const getUserInfo = (userId) => {
return request.get(`/user/${userId}`)
}
```
## 七、样式规范
### 7.1 选择器命名
- 使用短横线命名:`.user-card``.login-form`
- 避免使用 ID 选择器
- 避免使用标签选择器
### 7.2 样式组织
```scss
.component-name {
// 1. 定位属性
position: relative;
// 2. 盒模型属性
display: flex;
width: 100%;
padding: 20rpx;
// 3. 文本属性
font-size: 28rpx;
color: #333;
// 4. 其他属性
background-color: #fff;
// 5. 嵌套选择器
.child-element {
// ...
}
}
```
### 7.3 单位使用
- 小程序端:使用 `rpx`
- H5 端:使用 `rem``vw`
- 固定尺寸:使用 `px`
## 八、性能优化规范
### 8.1 图片优化
- 使用合适的图片格式WebP、JPEG、PNG
- 压缩图片大小
- 使用图片懒加载
- 使用雪碧图合并小图标
### 8.2 代码优化
- 避免在循环中进行复杂计算
- 使用防抖和节流
- 合理使用计算属性和侦听器
- 及时清理定时器和事件监听
### 8.3 网络优化
- 合并接口请求
- 使用请求缓存
- 实现请求重试机制
- 避免重复请求
## 九、安全规范
### 9.1 数据安全
- 敏感信息加密存储
- Token 定期刷新
- 防止 XSS 攻击
- 防止 CSRF 攻击
### 9.2 权限控制
- 实现路由权限控制
- 实现接口权限控制
- 实现按钮级权限控制
## 十、测试规范
### 10.1 单元测试
- 工具函数必须编写单元测试
- 测试覆盖率不低于 80%
- 使用 Jest 测试框架
### 10.2 集成测试
- 关键业务流程必须编写集成测试
- 使用 uni-automator 进行自动化测试
### 10.3 测试命名
```javascript
describe('工具函数测试', () => {
test('应该正确验证手机号', () => {
expect(isPhone('13800138000')).toBe(true)
})
})
```

531
docs/性能优化清单.md Normal file
View File

@@ -0,0 +1,531 @@
# 性能优化清单
## 一、首屏加载优化
### 1.1 代码分割
```javascript
// 路由懒加载
const UserProfile = () => import('@/pages/user/profile')
// 组件懒加载
components: {
HeavyComponent: () => import('@/components/HeavyComponent.vue')
}
```
### 1.2 小程序分包加载
```json
{
"subPackages": [
{
"root": "subPackages/order",
"pages": ["list/index", "detail/index"]
}
],
"preloadRule": {
"pages/index/index": {
"network": "all",
"packages": ["subPackages/order"]
}
}
}
```
### 1.3 资源预加载
```javascript
// 预加载关键资源
onLoad() {
// 预加载下一页数据
this.preloadNextPage()
// 预加载图片
uni.preloadImage({
src: '/static/images/banner.jpg'
})
}
```
### 1.4 骨架屏
```vue
<template>
<view v-if="loading" class="skeleton">
<view class="skeleton-avatar" />
<view class="skeleton-line" />
<view class="skeleton-line" />
</view>
<view v-else class="content">
<!-- 实际内容 -->
</view>
</template>
```
## 二、渲染性能优化
### 2.1 虚拟列表
对于长列表使用虚拟滚动:
```vue
<template>
<recycle-list
:list="dataList"
:item-height="100"
>
<template v-slot="{ item }">
<view class="list-item">
{{ item.title }}
</view>
</template>
</recycle-list>
</template>
```
### 2.2 图片懒加载
```vue
<template>
<!-- 小程序端 -->
<image
:src="imageSrc"
lazy-load
mode="aspectFill"
/>
<!-- H5端使用 Intersection Observer -->
<img
v-lazy="imageSrc"
@load="handleImageLoad"
/>
</template>
```
### 2.3 防抖与节流
```javascript
import { debounce, throttle } from '@/utils/performance'
export default {
methods: {
// 搜索输入防抖
handleSearch: debounce(function(keyword) {
this.search(keyword)
}, 300),
// 滚动事件节流
handleScroll: throttle(function(e) {
this.updateScrollPosition(e)
}, 100)
}
}
```
### 2.4 条件渲染优化
```vue
<template>
<!-- 使用 v-show 代替 v-if频繁切换 -->
<view v-show="isVisible">内容</view>
<!-- 使用 v-if 代替 v-show不常切换 -->
<view v-if="hasPermission">内容</view>
<!-- 使用 v-once 渲染静态内容 -->
<view v-once>{{ staticContent }}</view>
</template>
```
## 三、网络请求优化
### 3.1 请求合并
```javascript
// 合并多个接口请求
async loadPageData() {
const [bannerData, menuData, contentData] = await Promise.all([
api.getBanner(),
api.getMenu(),
api.getContent()
])
this.bannerList = bannerData
this.menuList = menuData
this.contentList = contentData
}
```
### 3.2 请求缓存
```javascript
class RequestCache {
constructor() {
this.cache = new Map()
this.cacheTime = 5 * 60 * 1000 // 5分钟
}
get(key) {
const item = this.cache.get(key)
if (!item) return null
if (Date.now() - item.timestamp > this.cacheTime) {
this.cache.delete(key)
return null
}
return item.data
}
set(key, data) {
this.cache.set(key, {
data,
timestamp: Date.now()
})
}
}
// 使用缓存
const cache = new RequestCache()
export const getUserInfo = async (userId) => {
const cacheKey = `user_${userId}`
const cached = cache.get(cacheKey)
if (cached) return cached
const data = await request.get(`/user/${userId}`)
cache.set(cacheKey, data)
return data
}
```
### 3.3 请求重试
```javascript
async function requestWithRetry(config, retryCount = 0) {
try {
return await request(config)
} catch (error) {
if (retryCount < 3) {
await new Promise(resolve => setTimeout(resolve, 1000))
return requestWithRetry(config, retryCount + 1)
}
throw error
}
}
```
### 3.4 请求取消
```javascript
class RequestManager {
constructor() {
this.pendingRequests = new Map()
}
addRequest(key, requestTask) {
// 取消之前的相同请求
if (this.pendingRequests.has(key)) {
this.pendingRequests.get(key).abort()
}
this.pendingRequests.set(key, requestTask)
}
removeRequest(key) {
this.pendingRequests.delete(key)
}
cancelAll() {
this.pendingRequests.forEach(task => task.abort())
this.pendingRequests.clear()
}
}
```
## 四、内存优化
### 4.1 及时清理
```javascript
export default {
data() {
return {
timer: null,
observer: null
}
},
beforeUnmount() {
// 清理定时器
if (this.timer) {
clearInterval(this.timer)
this.timer = null
}
// 清理观察器
if (this.observer) {
this.observer.disconnect()
this.observer = null
}
// 清理事件监听
uni.offNetworkStatusChange()
}
}
```
### 4.2 大数据处理
```javascript
// 分批处理大数据
function processBigData(data, batchSize = 100) {
const batches = []
for (let i = 0; i < data.length; i += batchSize) {
batches.push(data.slice(i, i + batchSize))
}
return batches.reduce((promise, batch) => {
return promise.then(() => {
return new Promise(resolve => {
setTimeout(() => {
processBatch(batch)
resolve()
}, 0)
})
})
}, Promise.resolve())
}
```
### 4.3 WeakMap 使用
```javascript
// 使用 WeakMap 避免内存泄漏
const cache = new WeakMap()
function cacheData(obj, data) {
cache.set(obj, data)
}
function getCachedData(obj) {
return cache.get(obj)
}
```
## 五、图片优化
### 5.1 图片压缩
```javascript
// 上传前压缩图片
function compressImage(filePath) {
return new Promise((resolve, reject) => {
uni.compressImage({
src: filePath,
quality: 80,
success: (res) => resolve(res.tempFilePath),
fail: reject
})
})
}
```
### 5.2 图片格式选择
- **照片**:使用 JPEG 格式
- **图标**:使用 PNG 或 SVG 格式
- **动画**:使用 GIF 或 WebP 格式
- **透明背景**:使用 PNG 格式
### 5.3 响应式图片
```vue
<template>
<image
:src="getImageUrl()"
mode="aspectFill"
/>
</template>
<script>
export default {
methods: {
getImageUrl() {
const systemInfo = uni.getSystemInfoSync()
const dpr = systemInfo.pixelRatio
// 根据设备像素比选择图片
if (dpr >= 3) {
return this.image + '@3x.jpg'
} else if (dpr >= 2) {
return this.image + '@2x.jpg'
}
return this.image + '.jpg'
}
}
}
</script>
```
### 5.4 图片预加载
```javascript
function preloadImages(urls) {
return Promise.all(
urls.map(url => {
return new Promise((resolve, reject) => {
uni.getImageInfo({
src: url,
success: resolve,
fail: reject
})
})
})
)
}
```
## 六、小程序特定优化
### 6.1 setData 优化
```javascript
// 避免频繁 setData
let updateTimer = null
let pendingData = {}
function batchUpdate(data) {
Object.assign(pendingData, data)
if (updateTimer) return
updateTimer = setTimeout(() => {
this.setData(pendingData)
pendingData = {}
updateTimer = null
}, 16) // 约60fps
}
// 避免 setData 传递大数据
// 不好的做法
this.setData({
list: this.data.list.concat(newList)
})
// 好的做法
this.setData({
[`list[${this.data.list.length}]`]: newItem
})
```
### 6.2 自定义组件
```javascript
// 使用自定义组件代替模板
Component({
options: {
// 启用纯数据字段
pureDataPattern: /^_/,
// 启用多slot支持
multipleSlots: true
},
properties: {
title: String
},
data: {
_privateData: 'private' // 纯数据字段,不参与渲染
}
})
```
### 6.3 分包预下载
```json
{
"preloadRule": {
"pages/index/index": {
"network": "all",
"packages": ["subPackages/order"]
}
}
}
```
## 七、H5 特定优化
### 7.1 PWA 支持
```javascript
// 注册 Service Worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
}
// sw.js
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open('v1').then((cache) => {
return cache.addAll([
'/',
'/static/css/main.css',
'/static/js/main.js'
])
})
)
})
```
### 7.2 CDN 加速
```javascript
// 静态资源使用 CDN
const CDN_URL = 'https://cdn.example.com'
export const getStaticUrl = (path) => {
return `${CDN_URL}${path}`
}
```
### 7.3 Gzip 压缩
```javascript
// vite.config.js
import viteCompression from 'vite-plugin-compression'
export default {
plugins: [
viteCompression({
algorithm: 'gzip',
ext: '.gz'
})
]
}
```
## 八、性能监控
### 8.1 性能指标收集
```javascript
// 页面加载时间
const pageLoadTime = performance.timing.loadEventEnd - performance.timing.navigationStart
// 首屏渲染时间
const firstPaintTime = performance.getEntriesByType('paint')[0].startTime
// 接口请求时间
const apiTime = performance.getEntriesByType('resource')
.filter(item => item.initiatorType === 'xmlhttprequest')
```
### 8.2 性能上报
```javascript
function reportPerformance(data) {
// 上报到监控平台
uni.request({
url: 'https://monitor.example.com/report',
method: 'POST',
data: {
type: 'performance',
...data
}
})
}
```
## 九、优化检查清单
- [ ] 启用代码分割和懒加载
- [ ] 实现图片懒加载
- [ ] 使用虚拟列表处理长列表
- [ ] 添加请求缓存机制
- [ ] 实现请求重试机制
- [ ] 合并并发请求
- [ ] 压缩图片资源
- [ ] 使用 CDN 加速静态资源
- [ ] 启用 Gzip 压缩
- [ ] 小程序分包加载
- [ ] 优化 setData 调用
- [ ] 及时清理定时器和监听器
- [ ] 使用防抖和节流
- [ ] 添加骨架屏
- [ ] 实现性能监控

12556
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

49
package.json Normal file
View File

@@ -0,0 +1,49 @@
{
"name": "uniapp-enterprise",
"version": "1.0.0",
"description": "企业级UNIAPP多端应用",
"scripts": {
"dev:mp-weixin": "uni -p mp-weixin",
"dev:h5": "uni",
"dev:app": "uni -p app",
"build:mp-weixin": "uni build -p mp-weixin",
"build:h5": "uni build",
"build:app": "uni build -p app",
"lint": "eslint --ext .js,.vue src",
"lint:fix": "eslint --ext .js,.vue src --fix"
},
"dependencies": {
"@dcloudio/uni-app": "3.0.0-3070320230222002",
"@dcloudio/uni-app-plus": "3.0.0-3070320230222002",
"@dcloudio/uni-components": "3.0.0-3070320230222002",
"@dcloudio/uni-h5": "3.0.0-3070320230222002",
"@dcloudio/uni-mp-weixin": "3.0.0-3070320230222002",
"pinia": "2.0.30",
"uview-plus": "^3.7.13",
"vue": "^3.2.47"
},
"devDependencies": {
"@dcloudio/types": "^3.3.2",
"@dcloudio/uni-automator": "3.0.0-3070320230222002",
"@dcloudio/uni-cli-shared": "3.0.0-3070320230222002",
"@dcloudio/vite-plugin-uni": "3.0.0-3070320230222002",
"eslint": "^8.36.0",
"eslint-config-prettier": "^8.7.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-vue": "^9.10.0",
"husky": "^8.0.3",
"lint-staged": "^13.2.0",
"prettier": "^2.8.4",
"sass": "^1.59.2",
"vite": "^4.0.3"
},
"lint-staged": {
"*.{js,vue}": [
"eslint --fix",
"prettier --write"
]
},
"engines": {
"node": ">=16.0.0"
}
}

296
pencli/AIBot.pen Normal file
View File

@@ -0,0 +1,296 @@
{
"version": "2.8",
"children": [
{
"type": "frame",
"id": "r2NUL",
"x": -205,
"y": -31,
"name": "Frame 1000006912",
"stroke": {
"align": "inside",
"thickness": 1
},
"gap": 10,
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "25K0s",
"name": "Frame 47508",
"width": 74,
"height": 62,
"fill": "#ffffff1a",
"cornerRadius": 10,
"stroke": {
"align": "inside",
"thickness": 1.1739130020141602
},
"effect": {
"type": "background_blur",
"radius": 51.35869216918945,
"enabled": false
},
"gap": 11.739130020141602,
"padding": [
7.043478012084961,
20,
7.043478012084961,
18.782608032226562
],
"justifyContent": "center",
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "qZF5M",
"name": "Frame 47507",
"width": 39.91304397583008,
"stroke": {
"align": "inside",
"thickness": 0.907114565372467
},
"layout": "vertical",
"gap": 0.907114565372467,
"justifyContent": "center",
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "9PnCW",
"name": "Frame 47506",
"clip": true,
"width": "fill_container",
"height": 34.47035598754883,
"stroke": {
"align": "inside",
"thickness": 0.907114565372467
},
"layout": "none",
"children": [
{
"type": "frame",
"id": "iNseR",
"x": 4.535750865936279,
"y": 3.62839674949646,
"name": "Component 38",
"clip": true,
"width": 30.841896057128906,
"height": 30.841896057128906,
"fill": {
"type": "color",
"color": "#ffffffff",
"enabled": false
},
"stroke": {
"align": "inside",
"thickness": 0.907114565372467
},
"layout": "none",
"children": [
{
"type": "path",
"id": "lG4t1",
"x": 0.03220232203602791,
"y": 0.9397241473197937,
"name": "Union",
"geometry": "M23.25 0c5.50312 0 9.96429 4.46118 9.96429 9.96429 0 2.04882-0.61991 3.95219-1.68018 5.53571 1.06027 1.58352 1.68018 3.4869 1.68018 5.53571 0 5.50311-4.46116 9.96429-9.96429 9.96429-2.55289 0-4.87978-0.96205-6.64286-2.5408-1.76308 1.57876-4.08996 2.54081-6.64285 2.5408-5.50312 0-9.96429-4.46118-9.96429-9.96429 0-2.04839 0.61815-3.95239 1.67801-5.53571-1.05986-1.58332-1.67801-3.48733-1.67801-5.53571 0-5.50311 4.46116-9.96429 9.96429-9.96429 2.55238 0 4.8799 0.96046 6.64285 2.53864 1.76295-1.57818 4.09048-2.53864 6.64286-2.53864z",
"fill": "#ffe984ff",
"width": 30.12916374206543,
"height": 28.12055206298828,
"stroke": {
"align": "inside",
"thickness": 1.2336758375167847
}
},
{
"type": "group",
"id": "vC5pd",
"x": 10.431817501783371,
"y": 10.885374784469604,
"name": "Group 1000006920",
"children": [
{
"type": "path",
"id": "dW8XA",
"x": 0,
"y": 0,
"name": "Ellipse 81",
"rotation": -3.1805546814635168e-15,
"geometry": "M4 0c0 1.10457-0.89543 2-2 2-1.10457 0-2-0.89543-2-2",
"width": 3.628458261489868,
"height": 1.814229130744934,
"stroke": {
"align": "center",
"thickness": 1.360671877861023,
"cap": "round",
"fill": "#3f3409ff"
}
},
{
"type": "path",
"id": "yrlkn",
"x": 6.047725707292557,
"y": 0,
"name": "Ellipse 82",
"rotation": -3.1805546814635168e-15,
"geometry": "M14 0c0 3.86599-3.13401 7-7 7-3.86599 0-7-3.13401-7-7",
"width": 3.628458261489868,
"height": 1.814229130744934,
"stroke": {
"align": "center",
"thickness": 1.360671877861023,
"cap": "round",
"fill": "#3f3409ff"
}
}
]
},
{
"type": "path",
"id": "yTGwQ",
"x": 6.803359031677246,
"y": 13.606718063354492,
"name": "Ellipse 83",
"geometry": "M0 0c3.30066 2.13599 8.32668 3.5 13.95633 3.5 5.62965 0 10.65567-1.36401 13.95634-3.5",
"width": 18.142292022705078,
"height": 3.628458261489868,
"stroke": {
"align": "center",
"thickness": 1.360671877861023,
"cap": "round",
"fill": "#3f3409ff"
}
}
]
}
]
},
{
"type": "text",
"id": "CVmZe",
"name": "心情日记",
"fill": "#ffffffff",
"textGrowth": "fixed-width",
"width": "fill_container",
"content": "心情日记",
"fontFamily": "PingFang SC",
"fontSize": 9.978260040283203,
"fontWeight": "normal"
}
]
}
]
},
{
"type": "frame",
"id": "Ur153",
"name": "Frame 47509",
"width": 74,
"height": 62,
"fill": "#ffffff1a",
"cornerRadius": 10,
"stroke": {
"align": "inside",
"thickness": 1.1739130020141602
},
"effect": {
"type": "background_blur",
"radius": 51.35869216918945,
"enabled": false
},
"gap": 11.739130020141602,
"padding": [
7.043478012084961,
20,
7.043478012084961,
18.782608032226562
],
"justifyContent": "center",
"alignItems": "center"
},
{
"type": "frame",
"id": "Ekb6E",
"name": "Frame 47510",
"width": 74,
"height": 62,
"fill": "#ffffff1a",
"cornerRadius": 10,
"stroke": {
"align": "inside",
"thickness": 1.1739130020141602
},
"effect": {
"type": "background_blur",
"radius": 51.35869216918945,
"enabled": false
},
"gap": 11.739130020141602,
"padding": [
7.043478012084961,
20,
7.043478012084961,
18.782608032226562
],
"justifyContent": "center",
"alignItems": "center"
},
{
"type": "frame",
"id": "VtRQZ",
"name": "Frame 47512",
"width": 74,
"height": 62,
"fill": "#ffffff1a",
"cornerRadius": 10,
"stroke": {
"align": "inside",
"thickness": 1.1739130020141602
},
"effect": {
"type": "background_blur",
"radius": 51.35869216918945,
"enabled": false
},
"gap": 11.739130020141602,
"padding": [
7.043478012084961,
20,
7.043478012084961,
18.782608032226562
],
"justifyContent": "center",
"alignItems": "center"
},
{
"type": "frame",
"id": "3s1WR",
"name": "Frame 47511",
"width": 74,
"height": 62,
"fill": "#ffffff1a",
"cornerRadius": 10,
"stroke": {
"align": "inside",
"thickness": 1.1739130020141602
},
"effect": {
"type": "background_blur",
"radius": 51.35869216918945,
"enabled": false
},
"gap": 11.739130020141602,
"padding": [
7.043478012084961,
20,
7.043478012084961,
18.782608032226562
],
"justifyContent": "center",
"alignItems": "center"
}
]
}
]
}

4672
pencli/message-dist.pen Normal file

File diff suppressed because it is too large Load Diff

4
pencli/message.pen Normal file
View File

@@ -0,0 +1,4 @@
{
"version": "2.8",
"children": []
}

4672
pencli/test.pen Normal file

File diff suppressed because one or more lines are too long

56
src/App.vue Normal file
View File

@@ -0,0 +1,56 @@
<script>
import { useAppStore } from '@/store/modules/app'
export default {
onLaunch() {
console.log('App Launch')
// 初始化应用状态
const appStore = useAppStore()
appStore.initSystemInfo()
// 监听网络状态
uni.onNetworkStatusChange((res) => {
appStore.setNetworkType(res.networkType)
})
// 全局错误捕获
this.setupErrorHandler()
},
onShow() {
console.log('App Show')
},
onHide() {
console.log('App Hide')
},
methods: {
/**
* 设置全局错误处理
*/
setupErrorHandler() {
// Vue错误处理
const app = getCurrentInstance()
if (app) {
app.appContext.config.errorHandler = (err, instance, info) => {
console.error('Vue Error:', err, info)
// 可以上报到日志系统
}
}
// Promise错误处理
window.addEventListener('unhandledrejection', (event) => {
console.error('Unhandled Promise Rejection:', event.reason)
// 可以上报到日志系统
})
}
}
}
</script>
<style lang="scss">
@import './styles/common.scss';
@import './styles/tabbar.scss';
</style>

13
src/api/index.js Normal file
View File

@@ -0,0 +1,13 @@
/**
* API统一导出
*/
import * as auth from './modules/auth'
import * as user from './modules/user'
import * as consultant from './modules/consultant'
export default {
auth,
user,
consultant
}

68
src/api/modules/auth.js Normal file
View File

@@ -0,0 +1,68 @@
/**
* 认证相关接口
*/
import request from '@/utils/request'
/**
* 手机号密码登录
*/
export const loginByPassword = (data) => {
return request.post('/auth/login', data)
}
/**
* 手机号验证码登录
*/
export const loginByCode = (data) => {
return request.post('/auth/login/code', data)
}
/**
* 发送验证码
*/
export const sendVerifyCode = (phone) => {
return request.post('/auth/code/send', { phone })
}
/**
* 注册
*/
export const register = (data) => {
return request.post('/auth/register', data)
}
/**
* 登出
*/
export const logout = () => {
return request.post('/auth/logout')
}
/**
* 刷新Token
*/
export const refreshToken = () => {
return request.post('/auth/refresh')
}
/**
* 获取用户信息
*/
export const getUserInfo = () => {
return request.get('/auth/userinfo')
}
/**
* 修改密码
*/
export const changePassword = (data) => {
return request.post('/auth/password/change', data)
}
/**
* 重置密码
*/
export const resetPassword = (data) => {
return request.post('/auth/password/reset', data)
}

View File

@@ -0,0 +1,59 @@
/**
* 咨询师相关接口
* @author AI
* @date 2026-03-06
*/
import request from '@/utils/request'
/**
* 获取咨询师列表
* @param {object} params 查询参数
* @param {string} params.keyword 搜索关键词
* @param {string} params.city 城市
* @param {string} params.category 分类
* @param {number} params.minPrice 最低价格
* @param {number} params.maxPrice 最高价格
* @param {string} params.sort 排序方式
* @param {number} params.page 页码
* @param {number} params.pageSize 每页数量
* @returns {Promise<object>} 咨询师列表
*/
export const getConsultantList = (params) => {
return request.get('/consultant/list', params)
}
/**
* 获取咨询师详情
* @param {string} id 咨询师ID
* @returns {Promise<object>} 咨询师详情
*/
export const getConsultantDetail = (id) => {
return request.get(`/consultant/${id}`)
}
/**
* 获取Banner列表
* @returns {Promise<object>} Banner列表
*/
export const getBannerList = () => {
return request.get('/consultant/banner')
}
/**
* 获取分类列表
* @returns {Promise<object>} 分类列表
*/
export const getCategoryList = () => {
return request.get('/consultant/category')
}
/**
* 预约咨询
* @param {object} data 预约数据
* @param {string} data.consultantId 咨询师ID
* @param {string} data.time 预约时间
* @returns {Promise<object>} 预约结果
*/
export const bookConsultant = (data) => {
return request.post('/consultant/book', data)
}

40
src/api/modules/user.js Normal file
View File

@@ -0,0 +1,40 @@
/**
* 用户相关接口
*/
import request from '@/utils/request'
/**
* 更新用户信息
*/
export const updateUserInfo = (data) => {
return request.put('/user/info', data)
}
/**
* 上传头像
*/
export const uploadAvatar = (filePath) => {
return request.upload('/user/avatar', filePath)
}
/**
* 获取用户详情
*/
export const getUserDetail = (userId) => {
return request.get(`/user/${userId}`)
}
/**
* 绑定手机号
*/
export const bindPhone = (data) => {
return request.post('/user/phone/bind', data)
}
/**
* 解绑手机号
*/
export const unbindPhone = () => {
return request.post('/user/phone/unbind')
}

View File

@@ -0,0 +1,379 @@
<template>
<view v-if="visible" class="coupon-popup-wrapper">
<!-- 遮罩层 -->
<view class="popup-overlay" @tap="handleClose"></view>
<!-- 弹窗内容 -->
<view class="coupon-popup" @tap.stop>
<!-- 标题栏 -->
<view class="popup-header">
<text class="popup-title">选择优惠券</text>
<view class="close-btn" @tap="handleClose">
<text class="close-icon"></text>
</view>
</view>
<!-- 选项卡 -->
<view class="tabs-container">
<view
class="tab-item"
:class="{ active: activeTab === 'available' }"
@tap="switchTab('available')"
>
<text class="tab-text">可用优惠券</text>
<view class="tab-indicator" v-if="activeTab === 'available'"></view>
</view>
<view
class="tab-item"
:class="{ active: activeTab === 'unavailable' }"
@tap="switchTab('unavailable')"
>
<text class="tab-text">不可用优惠券</text>
<view class="tab-indicator" v-if="activeTab === 'unavailable'"></view>
</view>
</view>
<!-- 优惠券列表 -->
<view class="coupon-list">
<view
v-for="(coupon, index) in currentCoupons"
:key="index"
class="coupon-item"
:class="{ disabled: activeTab === 'unavailable', expired: coupon.type === 'expired' }"
@tap="selectCoupon(coupon)"
>
<view class="coupon-left">
<view class="coupon-amount">
<text class="currency">¥</text>
<text class="amount">{{ coupon.amount }}</text>
</view>
<text class="coupon-condition">{{ coupon.condition }}</text>
</view>
<view class="coupon-right">
<view class="coupon-info">
<text class="coupon-title">{{ coupon.title }}</text>
<text class="coupon-desc">{{ coupon.desc }}</text>
<text class="coupon-expire">有效期至 {{ coupon.expireDate }}</text>
</view>
<view class="use-btn" v-if="activeTab === 'available'">
<text class="use-text">去使用</text>
</view>
</view>
<!-- 过期印章 -->
<view class="expired-stamp" v-if="coupon.type === 'expired'">
<image src="/static/icons/Expired.png" class="expired-image" mode="aspectFit" />
</view>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'CouponPopup',
props: {
visible: {
type: Boolean,
default: false
}
},
data() {
return {
activeTab: 'available',
availableCoupons: [
{
amount: 10,
condition: '满100使用',
title: '新人10元大礼包',
desc: '仅限首次预约使用',
expireDate: '2026.11.10'
},
{
amount: 10,
condition: '满100使用',
title: '新人10元大礼包',
desc: '仅限首次预约使用',
expireDate: '2026.11.10'
}
],
unavailableCoupons: [
{
amount: 20,
condition: '满200使用',
title: '专享20元优惠券',
desc: '本次订单不满足使用条件',
expireDate: '2026.12.31',
type: 'unavailable'
},
{
amount: 15,
condition: '满150使用',
title: '限时15元优惠券',
desc: '优惠券已过期',
expireDate: '2025.12.31',
type: 'expired'
}
]
}
},
computed: {
currentCoupons() {
return this.activeTab === 'available' ? this.availableCoupons : this.unavailableCoupons
}
},
methods: {
switchTab(tab) {
this.activeTab = tab
},
selectCoupon(coupon) {
if (this.activeTab === 'available') {
this.$emit('select', coupon)
this.handleClose()
}
},
handleClose() {
this.$emit('close')
}
}
}
</script>
<style lang="scss" scoped>
.coupon-popup-wrapper {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
}
.popup-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(39, 39, 42, 0.5);
}
.coupon-popup {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background-color: #f8f8f8;
border-radius: 40rpx 40rpx 0 0;
height: 70vh;
overflow: hidden;
display: flex;
flex-direction: column;
// 标题栏
.popup-header {
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx 40rpx 20rpx;
position: relative;
.popup-title {
font-size: 36rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #000000;
line-height: 1.22;
}
.close-btn {
position: absolute;
right: 40rpx;
top: 50%;
transform: translateY(-50%);
width: 54rpx;
height: 54rpx;
background-color: #f2f2f2;
border-radius: 27rpx;
display: flex;
align-items: center;
justify-content: center;
.close-icon {
font-size: 32rpx;
color: #3f3f46;
line-height: 1;
}
}
}
// 选项卡
.tabs-container {
display: flex;
justify-content: center;
gap: 80rpx;
padding: 0 40rpx 40rpx;
.tab-item {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
.tab-text {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #71717a;
line-height: 1.375;
}
.tab-indicator {
width: 56rpx;
height: 6rpx;
background-color: #9d5d00;
border-radius: 3rpx;
}
&.active {
.tab-text {
color: #09090b;
font-weight: normal;
}
}
}
}
// 优惠券列表
.coupon-list {
flex: 1;
padding: 0 40rpx 40rpx;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 20rpx;
.coupon-item {
background-color: #ffffff;
border-radius: 20rpx;
padding: 32rpx;
display: flex;
align-items: center;
gap: 24rpx;
box-shadow: 0 8rpx 16rpx rgba(244, 243, 239, 0.25);
position: relative;
&.disabled {
opacity: 0.6;
}
&.expired {
opacity: 0.6;
}
.coupon-left {
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
.coupon-amount {
display: flex;
align-items: baseline;
.currency {
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #ff4444;
line-height: 1;
}
.amount {
font-size: 48rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: bold;
color: #ff4444;
line-height: 1;
}
}
.coupon-condition {
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #ff4444;
line-height: 1.5;
}
}
.coupon-right {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
.coupon-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 8rpx;
.coupon-title {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #18181b;
line-height: 1.25;
}
.coupon-desc {
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #71717a;
line-height: 1.5;
}
.coupon-expire {
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #a1a1aa;
line-height: 1.5;
}
}
.use-btn {
border: 1rpx solid #ff786f;
border-radius: 20rpx;
padding: 8rpx 18rpx;
.use-text {
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #fb3123;
line-height: 1.14;
}
}
}
}
// 过期印章
.expired-stamp {
position: absolute;
bottom: 0;
right: 0;
z-index: 10;
.expired-image {
width: 120rpx;
height: 120rpx;
}
}
}
}
</style>

View File

@@ -0,0 +1,300 @@
<template>
<view v-if="visible" class="edit-popup-wrapper">
<!-- 遮罩层 -->
<view class="popup-overlay" @tap="handleClose"></view>
<!-- 弹窗内容 -->
<view class="edit-popup" @tap.stop>
<!-- 标题栏 -->
<view class="popup-header">
<text class="popup-title">
{{ editType === 'name' ? '修改姓名' : editType === 'phone' ? '修改手机号' : '修改年龄' }}
</text>
<view class="close-btn" @tap="handleClose">
<u-icon name="close" color="#3f3f46" size="16"></u-icon>
</view>
</view>
<!-- 输入区域 -->
<view class="input-section">
<view class="input-item">
<text class="input-label">
{{ editType === 'name' ? '姓名' : editType === 'phone' ? '手机号' : '年龄' }}
</text>
<input
class="input-field"
v-model="inputValue"
:type="editType === 'phone' || editType === 'age' ? 'number' : 'text'"
:maxlength="editType === 'phone' ? 11 : editType === 'age' ? 3 : 20"
:placeholder="editType === 'name' ? '请输入您的姓名' : editType === 'phone' ? '请输入您的手机号' : '请输入您的年龄'"
placeholder-class="input-placeholder"
@input="handleInput"
/>
</view>
<!-- 错误提示 -->
<view class="error-tip" v-if="errorMessage">
<text class="error-text">{{ errorMessage }}</text>
</view>
</view>
<!-- 底部按钮 -->
<view class="button-section">
<view class="cancel-btn" @tap="handleClose">
<text class="cancel-text">取消</text>
</view>
<view class="confirm-btn" @tap="handleConfirm">
<text class="confirm-text">确定</text>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'EditInfoPopup',
props: {
visible: {
type: Boolean,
default: false
},
editType: {
type: String,
default: 'name', // 'name' | 'phone' | 'age'
validator: value => ['name', 'phone', 'age'].includes(value)
},
currentValue: {
type: String,
default: ''
}
},
data() {
return {
inputValue: '',
errorMessage: ''
}
},
watch: {
visible(newVal) {
if (newVal) {
this.inputValue = this.currentValue
this.errorMessage = ''
}
},
currentValue(newVal) {
if (this.visible) {
this.inputValue = newVal
}
}
},
methods: {
handleInput() {
this.errorMessage = ''
},
validateInput() {
if (!this.inputValue || this.inputValue.trim() === '') {
this.errorMessage = this.editType === 'name' ? '请输入姓名' :
this.editType === 'phone' ? '请输入手机号' : '请输入年龄'
return false
}
if (this.editType === 'phone') {
const phoneReg = /^1[3-9]\d{9}$/
if (!phoneReg.test(this.inputValue)) {
this.errorMessage = '请输入正确的手机号码'
return false
}
} else if (this.editType === 'name') {
if (this.inputValue.trim().length < 2) {
this.errorMessage = '姓名至少需要2个字符'
return false
}
} else if (this.editType === 'age') {
const age = parseInt(this.inputValue)
if (isNaN(age) || age < 1 || age > 150) {
this.errorMessage = '请输入正确的年龄数字'
return false
}
}
return true
},
handleConfirm() {
if (this.validateInput()) {
this.$emit('confirm', {
type: this.editType,
value: this.inputValue.trim()
})
this.handleClose()
}
},
handleClose() {
this.inputValue = ''
this.errorMessage = ''
this.$emit('close')
}
}
}
</script>
<style lang="scss" scoped>
.edit-popup-wrapper {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
}
.popup-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 10001;
}
.edit-popup {
background-color: #ffffff;
border-radius: 24rpx;
width: 600rpx;
max-width: 90vw;
overflow: hidden;
position: relative;
z-index: 10002;
// 标题栏
.popup-header {
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx 40rpx 20rpx;
position: relative;
border-bottom: 1rpx solid #f4f4f5;
.popup-title {
font-size: 36rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #18181b;
line-height: 1.22;
}
.close-btn {
position: absolute;
right: 40rpx;
top: 50%;
transform: translateY(-50%);
width: 48rpx;
height: 48rpx;
background-color: #f4f4f5;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
}
}
// 输入区域
.input-section {
padding: 40rpx;
.input-item {
display: flex;
flex-direction: column;
gap: 16rpx;
.input-label {
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #27272a;
line-height: 1.43;
}
.input-field {
height: 88rpx;
background-color: #f8f8f8;
border-radius: 12rpx;
padding: 0 24rpx;
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #18181b;
line-height: 1.25;
border: 2rpx solid transparent;
transition: border-color 0.2s ease;
&:focus {
border-color: #9d5d00;
background-color: #ffffff;
}
}
.input-placeholder {
color: #a1a1aa;
}
}
.error-tip {
margin-top: 16rpx;
.error-text {
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #ef4444;
line-height: 1.5;
}
}
}
// 底部按钮
.button-section {
display: flex;
gap: 24rpx;
padding: 0 40rpx 40rpx;
.cancel-btn,
.confirm-btn {
flex: 1;
height: 88rpx;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
}
.cancel-btn {
background-color: #f4f4f5;
.cancel-text {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #71717a;
line-height: 1.25;
}
}
.confirm-btn {
background-color: #18181b;
.confirm-text {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #ffffff;
line-height: 1.25;
}
}
}
}
</style>

View File

@@ -0,0 +1,69 @@
<template>
<view class="empty-view">
<image v-if="image" :src="image" class="empty-image" mode="aspectFit" />
<text class="empty-text">{{ text }}</text>
<view v-if="showButton" class="empty-button" @click="handleClick">
{{ buttonText }}
</view>
</view>
</template>
<script>
export default {
name: 'EmptyView',
props: {
image: {
type: String,
default: ''
},
text: {
type: String,
default: '暂无数据'
},
showButton: {
type: Boolean,
default: false
},
buttonText: {
type: String,
default: '重新加载'
}
},
emits: ['click'],
methods: {
handleClick() {
this.$emit('click')
}
}
}
</script>
<style lang="scss" scoped>
.empty-view {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 40rpx;
.empty-image {
width: 300rpx;
height: 300rpx;
margin-bottom: 40rpx;
}
.empty-text {
font-size: 28rpx;
color: #999;
margin-bottom: 40rpx;
}
.empty-button {
padding: 20rpx 60rpx;
background-color: $primary-color;
color: #fff;
font-size: 28rpx;
border-radius: 8rpx;
}
}
</style>

View File

@@ -0,0 +1,57 @@
<template>
<view class="error-view">
<image src="/static/images/error.png" class="error-image" mode="aspectFit" />
<text class="error-text">{{ message }}</text>
<view class="error-button" @click="handleRetry">
重试
</view>
</view>
</template>
<script>
export default {
name: 'ErrorView',
props: {
message: {
type: String,
default: '加载失败,请重试'
}
},
emits: ['retry'],
methods: {
handleRetry() {
this.$emit('retry')
}
}
}
</script>
<style lang="scss" scoped>
.error-view {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 40rpx;
.error-image {
width: 300rpx;
height: 300rpx;
margin-bottom: 40rpx;
}
.error-text {
font-size: 28rpx;
color: #999;
margin-bottom: 40rpx;
}
.error-button {
padding: 20rpx 60rpx;
background-color: $primary-color;
color: #fff;
font-size: 28rpx;
border-radius: 8rpx;
}
}
</style>

View File

@@ -0,0 +1,70 @@
<template>
<view v-if="visible" class="loading-overlay">
<view class="loading-box">
<view class="loading-icon">
<view class="spinner"></view>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'LoadingOverlay',
props: {
visible: {
type: Boolean,
default: false
}
}
}
</script>
<style lang="scss" scoped>
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
.loading-box {
width: 174rpx;
height: 174rpx;
background-color: rgba(39, 39, 42, 0.71);
border-radius: 30rpx;
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx;
.loading-icon {
width: 72rpx;
height: 72rpx;
position: relative;
.spinner {
width: 100%;
height: 100%;
border: 4rpx solid #f2f2f2;
border-top-color: transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
}
}
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,40 @@
<template>
<u-overlay :show="visible" :z-index="9999">
<view class="loading-wrapper">
<view class="loading-box">
<u-loading-icon mode="spinner" size="36" color="#ffffff"></u-loading-icon>
</view>
</view>
</u-overlay>
</template>
<script>
export default {
name: 'LoadingOverlayUview',
props: {
visible: {
type: Boolean,
default: false
}
}
}
</script>
<style lang="scss" scoped>
.loading-wrapper {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
.loading-box {
width: 174rpx;
height: 174rpx;
background-color: rgba(39, 39, 42, 0.71);
border-radius: 30rpx;
display: flex;
align-items: center;
justify-content: center;
}
}
</style>

View File

@@ -0,0 +1,69 @@
<template>
<view v-if="visible" class="loading-view">
<view class="loading-content">
<view class="loading-spinner" />
<text class="loading-text">{{ text }}</text>
</view>
</view>
</template>
<script>
export default {
name: 'LoadingView',
props: {
visible: {
type: Boolean,
default: false
},
text: {
type: String,
default: '加载中...'
}
}
}
</script>
<style lang="scss" scoped>
.loading-view {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.5);
z-index: 9999;
.loading-content {
display: flex;
flex-direction: column;
align-items: center;
padding: 40rpx;
background-color: rgba(0, 0, 0, 0.7);
border-radius: 16rpx;
}
.loading-spinner {
width: 60rpx;
height: 60rpx;
border: 4rpx solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.loading-text {
margin-top: 20rpx;
font-size: 28rpx;
color: #fff;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,146 @@
<!--
* Banner组件
* @author AI
* @date 2026-03-06
-->
<template>
<view class="banner">
<swiper
v-if="banners.length > 0"
class="banner-swiper"
:indicator-dots="false"
:autoplay="autoplay"
:interval="interval"
:duration="duration"
:circular="circular"
@change="handleChange"
>
<swiper-item v-for="(item, index) in banners" :key="index">
<view class="banner-item" @click="handleBannerClick(item)">
<image :src="item.image" class="banner-image" mode="aspectFill" />
</view>
</swiper-item>
</swiper>
<!-- 无数据时显示占位 -->
<view v-else class="banner-placeholder">
<!-- 纯白色占位卡片 -->
</view>
<view v-if="showDots && banners.length > 1" class="custom-dots">
<view
v-for="(item, index) in banners"
:key="index"
class="dot"
:class="{ active: currentIndex === index }"
/>
</view>
</view>
</template>
<script>
export default {
name: 'Banner',
props: {
banners: {
type: Array,
default: () => []
},
indicatorDots: {
type: Boolean,
default: false
},
autoplay: {
type: Boolean,
default: true
},
interval: {
type: Number,
default: 3000
},
duration: {
type: Number,
default: 500
},
circular: {
type: Boolean,
default: true
},
showDots: {
type: Boolean,
default: true
}
},
data() {
return {
currentIndex: 0
}
},
methods: {
handleChange(e) {
this.currentIndex = e.detail.current
this.$emit('change', this.currentIndex)
},
handleBannerClick(item) {
this.$emit('click', item)
}
}
}
</script>
<style lang="scss" scoped>
.banner {
position: relative;
width: 686rpx;
height: 228rpx;
margin: 0 auto;
border-radius: 32rpx;
overflow: hidden;
.banner-swiper {
width: 100%;
height: 100%;
.banner-item {
width: 100%;
height: 100%;
.banner-image {
width: 100%;
height: 100%;
}
}
}
.banner-placeholder {
width: 100%;
height: 100%;
background-color: #feffffff;
border: 2rpx solid #fffdf9;
border-radius: 32rpx;
}
.custom-dots {
position: absolute;
bottom: 20rpx;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 12rpx;
.dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.5);
transition: all 0.3s;
&.active {
width: 24rpx;
border-radius: 6rpx;
background-color: #fff;
}
}
}
}
</style>

View File

@@ -0,0 +1,835 @@
<template>
<view v-if="visible" class="booking-popup-wrapper">
<!-- 遮罩层 -->
<view class="popup-overlay" @tap="handleClose"></view>
<!-- 弹窗内容 -->
<view class="booking-popup" @tap.stop>
<!-- 标题栏 -->
<view class="popup-header">
<text class="popup-title">填写预约信息</text>
<view class="close-btn" @tap="handleClose">
<text class="close-icon"></text>
</view>
</view>
<!-- 咨询师信息和选择咨询方式 -->
<view class="consultant-section">
<!-- 装饰性背景图案 -->
<view class="bg-decoration">
<view class="decoration-shape"></view>
</view>
<!-- 咨询师信息卡片 -->
<view class="consultant-info">
<image :src="consultant.avatar" class="avatar" mode="aspectFill" />
<view class="info-text">
<text class="info-label">当前预约心理咨询师</text>
<view class="name-price">
<text class="name">{{ consultant.name }}</text>
<text class="price">¥{{ consultant.price }}/小时</text>
</view>
</view>
</view>
<!-- 选择咨询方式 -->
<view class="consult-methods-card">
<view class="section-title">
<view class="title-bar"></view>
<text class="title-text">选择咨询方式</text>
</view>
<view class="methods-list">
<view
v-for="(method, index) in consultMethods"
:key="index"
class="method-item"
:class="{ active: selectedMethod === method.value }"
@tap="selectMethod(method.value)"
>
<view class="method-icon">
<image :src="method.icon" mode="aspectFit" />
</view>
<view class="method-info">
<text class="method-name">{{ method.label }}</text>
<text class="method-price">¥{{ consultant.price }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 选择预约日期和时间 -->
<view class="datetime-section">
<!-- 选择预约日期 -->
<view class="date-section">
<view class="section-title">
<view class="title-bar"></view>
<text class="title-text">选择预约日期</text>
</view>
<view class="date-list">
<view
v-for="(date, index) in dateList"
:key="index"
class="date-item"
:class="{ active: selectedDate === date.value }"
@tap="selectDate(date.value)"
>
<text class="date-month">{{ date.month }}</text>
<text class="date-week">{{ date.week }}</text>
<view class="date-day-wrapper">
<text class="date-day">{{ date.day }}</text>
</view>
</view>
</view>
</view>
<!-- 选择预约时间 -->
<view class="time-section">
<view class="section-title">
<view class="title-bar"></view>
<text class="title-text">选择预约时间</text>
</view>
<view class="time-grid">
<view
v-for="(time, index) in timeList"
:key="index"
class="time-item"
:class="{ active: selectedTime === time }"
@tap="selectTime(time)"
>
<text class="time-text">{{ time }}</text>
</view>
</view>
</view>
</view>
<!-- 填写个人信息 -->
<view class="info-section">
<view class="section-title">
<view class="title-bar"></view>
<text class="title-text">填写信息</text>
</view>
<view class="form-list">
<view class="form-item">
<text class="form-label required">姓名</text>
<input
class="form-input"
v-model="formData.name"
placeholder="请填写您的姓名"
placeholder-class="input-placeholder"
/>
</view>
<view class="form-item">
<text class="form-label required">电话</text>
<input
class="form-input"
v-model="formData.phone"
type="number"
maxlength="11"
placeholder="请填写您的电话"
placeholder-class="input-placeholder"
/>
</view>
<view class="form-item">
<text class="form-label">年龄</text>
<input
class="form-input"
v-model="formData.age"
type="number"
maxlength="3"
placeholder="请填写您的年龄"
placeholder-class="input-placeholder"
/>
</view>
<view class="form-item">
<text class="form-label">备注信息</text>
<input
class="form-input"
v-model="formData.remark"
maxlength="200"
placeholder="请填写您的备注信息"
placeholder-class="input-placeholder"
/>
</view>
</view>
</view>
<!-- 底部按钮 -->
<view class="footer-section">
<view class="submit-btn" @tap="handleSubmit">
<text class="submit-text">开始预约</text>
</view>
</view>
</view>
<!-- 加载动画 -->
<LoadingOverlay :visible="isLoading" />
</view>
</template>
<script>
import LoadingOverlay from '../common/LoadingOverlay.vue'
export default {
name: 'BookingPopup',
components: {
LoadingOverlay
},
props: {
visible: {
type: Boolean,
default: false
},
consultant: {
type: Object,
default: () => ({
avatar: '',
name: '',
price: 0
})
}
},
data() {
return {
consultMethods: [
{ label: '线下咨询', value: 'offline', icon: '/static/icons/Offline-Message.png' },
{ label: '视频咨询', value: 'video', icon: '/static/icons/Video-Message.png' },
{ label: '语音咨询', value: 'audio', icon: '/static/icons/Voice-Message.png' }
],
selectedMethod: 'video',
dateList: [],
selectedDate: '',
timeList: [
'09:00-10:30',
'10:30-11:30',
'13:30-15:00',
'15:00-16:30',
'16:30-17:30'
],
selectedTime: '10:30-11:30',
formData: {
name: '',
phone: '',
age: '',
remark: ''
},
isLoading: false
}
},
mounted() {
this.initDateList()
},
methods: {
initDateList() {
const dates = []
const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
const months = ['01月', '02月', '03月', '04月', '05月', '06月', '07月', '08月', '09月', '10月', '11月', '12月']
const today = new Date()
for (let i = 0; i < 7; i++) {
const date = new Date(today)
date.setDate(today.getDate() + i)
dates.push({
month: months[date.getMonth()],
week: weekDays[date.getDay()],
day: date.getDate(),
value: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
})
}
this.dateList = dates
this.selectedDate = dates[1]?.value || ''
},
selectMethod(value) {
this.selectedMethod = value
},
selectDate(value) {
this.selectedDate = value
},
selectTime(value) {
this.selectedTime = value
},
handleClose() {
this.$emit('close')
},
handleSubmit() {
// 验证必填项
if (!this.formData.name) {
uni.showToast({
title: '请填写姓名',
icon: 'none'
})
return
}
if (!this.formData.phone) {
uni.showToast({
title: '请填写电话',
icon: 'none'
})
return
}
// 验证电话号码格式
const phoneReg = /^1[3-9]\d{9}$/
if (!phoneReg.test(this.formData.phone)) {
uni.showToast({
title: '请输入正确的手机号码',
icon: 'none'
})
return
}
// 验证年龄格式(如果填写了)
if (this.formData.age && this.formData.age.trim() !== '') {
const age = parseInt(this.formData.age)
if (isNaN(age) || age < 1 || age > 150) {
uni.showToast({
title: '请输入正确的年龄数字',
icon: 'none'
})
return
}
}
// 验证备注信息长度(如果填写了)
if (this.formData.remark && this.formData.remark.trim().length > 200) {
uni.showToast({
title: '备注信息不能超过200个字',
icon: 'none'
})
return
}
// 显示加载动画
this.isLoading = true
// 构建预约信息
const bookingData = {
consultantId: this.consultant.id,
consultantName: this.consultant.name,
consultantAvatar: this.consultant.avatar,
method: this.selectedMethod,
methodLabel: this.consultMethods.find(m => m.value === this.selectedMethod)?.label || '',
date: this.selectedDate,
time: this.selectedTime,
price: this.consultant.price,
name: this.formData.name,
phone: this.formData.phone,
age: this.formData.age && this.formData.age.trim() !== '' ? parseInt(this.formData.age) : null,
remark: this.formData.remark
}
console.log('预约信息:', bookingData)
// 模拟网络请求延迟
setTimeout(() => {
this.isLoading = false
// 关闭弹窗
this.handleClose()
// 跳转到预约确认页面
uni.navigateTo({
url: `/pages/booking/confirm?data=${encodeURIComponent(JSON.stringify(bookingData))}`
})
}, 800)
// TODO: 或者调用预约接口后再跳转
// this.$emit('submit', bookingData)
}
}
}
</script>
<style lang="scss" scoped>
.booking-popup-wrapper {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
}
.popup-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
}
.booking-popup {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background-color: #f8f8f8;
border-radius: 32rpx 32rpx 0 0;
max-height: 85vh;
overflow-y: auto;
display: flex;
flex-direction: column;
padding-bottom: 160rpx; // 为底部按钮预留空间
// 标题栏
.popup-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx;
.popup-title {
font-size: 40rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #000000;
line-height: 1.1;
}
.close-btn {
width: 54rpx;
height: 54rpx;
background-color: #f2f2f2;
border-radius: 27rpx;
display: flex;
align-items: center;
justify-content: center;
.close-icon {
font-size: 32rpx;
color: #3f3f46;
line-height: 1;
}
}
}
// 咨询师信息和选择咨询方式
.consultant-section {
background: linear-gradient(260deg, #ffddb1 0%, #fff3e4 100%);
border: 1rpx solid #ffffff;
border-radius: 20rpx;
margin: 0 32rpx 20rpx;
padding: 0;
position: relative;
overflow: visible;
min-height: 476rpx;
// 装饰性背景图案
.bg-decoration {
position: absolute;
top: -72rpx;
right: 0;
width: 304rpx;
height: 304rpx;
opacity: 0.8;
pointer-events: none;
z-index: 0;
.decoration-shape {
width: 100%;
height: 100%;
background: linear-gradient(180deg, #fff9f6 0%, rgba(255, 240, 230, 0) 100%);
border-radius: 50%;
transform: rotate(-15deg);
}
}
// 咨询师信息
.consultant-info {
display: flex;
align-items: center;
gap: 28rpx;
padding: 26rpx 34rpx 20rpx;
position: relative;
z-index: 1;
.avatar {
width: 102rpx;
height: 102rpx;
border-radius: 50%;
border: 2.5rpx solid #fff8f2;
flex-shrink: 0;
}
.info-text {
flex: 1;
.info-label {
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #8c7e6b;
line-height: 1.5;
display: block;
margin-bottom: 8rpx;
}
.name-price {
display: flex;
align-items: center;
justify-content: space-between;
.name {
font-size: 40rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #4e3107;
line-height: 1.3;
}
.price {
font-size: 36rpx;
font-family: 'MiSans VF', 'PingFang SC', sans-serif;
font-weight: normal;
color: #9d5d00;
line-height: 1.22;
}
}
}
}
// 选择咨询方式卡片
.consult-methods-card {
background-color: #ffffff;
border-radius: 20rpx;
padding: 32rpx;
margin: 0;
position: relative;
z-index: 1;
.section-title {
display: flex;
align-items: center;
gap: 8rpx;
margin-bottom: 20rpx;
.title-bar {
width: 4rpx;
height: 20rpx;
background-color: #9d5d00;
border-radius: 16rpx;
}
.title-text {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #18181b;
line-height: 1.25;
}
}
.methods-list {
display: flex;
gap: 8rpx;
.method-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: #fafafa;
border-radius: 20rpx;
padding: 20rpx 10rpx;
border: 1rpx solid transparent;
.method-icon {
width: 64rpx;
height: 64rpx;
background-color: #ffffff;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12rpx;
image {
width: 48rpx;
height: 48rpx;
}
}
.method-info {
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
.method-name {
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #52525b;
line-height: 1.43;
}
.method-price {
font-size: 28rpx;
font-family: 'MiSans VF', 'PingFang SC', sans-serif;
font-weight: normal;
color: #9d5d00;
line-height: 1.43;
}
}
&.active {
background-color: #f9f7f4;
border-color: #3f3f46;
}
}
}
}
}
// 选择预约日期和时间
.datetime-section {
background-color: #ffffff;
border-radius: 20rpx;
margin: 0 32rpx 20rpx;
padding: 40rpx 32rpx;
.date-section {
margin-bottom: 40rpx;
}
.section-title {
display: flex;
align-items: center;
gap: 8rpx;
margin-bottom: 32rpx;
.title-bar {
width: 4rpx;
height: 20rpx;
background-color: #9d5d00;
border-radius: 16rpx;
}
.title-text {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #18181b;
line-height: 1.25;
}
}
// 日期列表
.date-list {
display: flex;
gap: 10rpx;
.date-item {
width: 80rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
flex-shrink: 0;
.date-month {
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #a1a1aa;
line-height: 1.5;
text-align: center;
display: block;
}
.date-week {
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #a1a1aa;
line-height: 1.5;
text-align: center;
display: block;
}
.date-day-wrapper {
width: 100%;
height: 88rpx;
background-color: #f4f4f5;
border-radius: 8rpx;
display: flex;
align-items: center;
justify-content: center;
border: 1rpx solid transparent;
box-sizing: border-box;
.date-day {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #a1a1aa;
line-height: 1.375;
text-align: center;
}
}
&.active {
.date-month,
.date-week {
color: #3f3f46;
font-weight: 500;
}
.date-day-wrapper {
background-color: #f9f7f4;
border-color: #3f3f46;
.date-day {
color: #3f3f46;
font-weight: 500;
}
}
}
}
}
// 时间网格
.time-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10rpx;
.time-item {
height: 80rpx;
background-color: #f4f4f5;
border-radius: 8rpx;
display: flex;
align-items: center;
justify-content: center;
border: 1rpx solid transparent;
.time-text {
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #a1a1aa;
line-height: 1.67;
text-align: center;
}
&.active {
background-color: #f9f7f4;
border-color: #3f3f46;
.time-text {
color: #70553e;
}
}
}
}
}
// 填写个人信息
.info-section {
background-color: #ffffff;
border-radius: 20rpx;
margin: 0 32rpx 20rpx;
padding: 40rpx 32rpx;
.section-title {
display: flex;
align-items: center;
gap: 8rpx;
margin-bottom: 32rpx;
.title-bar {
width: 4rpx;
height: 20rpx;
background-color: #9d5d00;
border-radius: 16rpx;
}
.title-text {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #18181b;
line-height: 1.25;
}
}
.form-list {
display: flex;
flex-direction: column;
gap: 32rpx;
.form-item {
display: flex;
align-items: center;
justify-content: space-between;
.form-label {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #27272a;
line-height: 1.25;
flex-shrink: 0;
&.required::before {
content: '*';
color: #ef4444;
margin-right: 4rpx;
}
}
.form-input {
flex: 1;
text-align: right;
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #18181b;
line-height: 1.43;
margin-left: 20rpx;
}
.input-placeholder {
color: #a1a1aa;
}
}
}
}
// 底部按钮
.footer-section {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background-color: #ffffff;
padding: 32rpx;
padding-bottom: calc(32rpx + env(safe-area-inset-bottom));
box-shadow: 0 -2rpx 16rpx rgba(0, 0, 0, 0.05);
z-index: 10;
.submit-btn {
width: 100%;
height: 96rpx;
background-color: #18181b;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
.submit-text {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #ffffff;
line-height: 1.25;
}
}
}
}
</style>

View File

@@ -0,0 +1,87 @@
<!--
* 分类标签组件
* @author AI
* @date 2026-03-06
-->
<template>
<view class="category-tabs">
<view
v-for="(item, index) in categories"
:key="index"
class="tab-item"
:class="{ active: currentIndex === index }"
@click="handleTabClick(index)"
>
<text class="tab-text">{{ item }}</text>
</view>
</view>
</template>
<script>
export default {
name: 'CategoryTabs',
props: {
categories: {
type: Array,
default: () => ['焦虑抑郁', '婚姻关系', '原生家庭', '职业困扰', '成长迷茫']
},
current: {
type: Number,
default: 0
}
},
data() {
return {
currentIndex: this.current
}
},
watch: {
current(val) {
this.currentIndex = val
}
},
methods: {
handleTabClick(index) {
this.currentIndex = index
this.$emit('change', index, this.categories[index])
}
}
}
</script>
<style lang="scss" scoped>
.category-tabs {
display: flex;
gap: 17rpx;
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
// 隐藏滚动条
&::-webkit-scrollbar {
display: none;
}
.tab-item {
flex-shrink: 0;
padding: 8rpx 16rpx;
background-color: #f4f4f5;
border-radius: 8rpx;
.tab-text {
font-size: 24rpx;
color: #a1a1aa;
white-space: nowrap;
}
&.active {
background-color: #fff;
.tab-text {
color: #70553e;
}
}
}
}
</style>

View File

@@ -0,0 +1,636 @@
<!--
* 城市筛选组件三级联动
* @author AI
* @date 2026-03-06
-->
<template>
<view v-if="show" class="city-filter-mask" @tap="handleMaskClick">
<view class="city-filter-panel" @tap.stop>
<!-- 热门城市 -->
<view class="hot-cities">
<view class="section-title">热门城市</view>
<view class="hot-city-list">
<view
v-for="city in hotCities"
:key="city.id"
class="hot-city-item"
:class="{ active: selectedProvince === city.province && selectedCity === city.city }"
@tap="handleHotCitySelect(city)"
>
<text class="city-text">{{ city.name }}</text>
</view>
</view>
</view>
<!-- 三级联动选择器 -->
<view class="cascader-container">
<!-- 省份列表 -->
<scroll-view class="cascader-column" scroll-y>
<view
v-for="province in provinces"
:key="province.id"
class="cascader-item"
:class="{ active: selectedProvince === province.id }"
@tap="handleProvinceSelect(province)"
>
<text class="item-text">{{ province.name }}</text>
</view>
</scroll-view>
<!-- 城市列表 -->
<scroll-view class="cascader-column" scroll-y>
<view
v-for="city in cities"
:key="city.id"
class="cascader-item"
:class="{ active: selectedCity === city.id }"
@tap="handleCitySelect(city)"
>
<text class="item-text">{{ city.name }}</text>
</view>
</scroll-view>
<!-- 区域列表 -->
<scroll-view class="cascader-column" scroll-y>
<view
v-for="district in districts"
:key="district.id"
class="cascader-item"
:class="{ active: selectedDistrict === district.id }"
@tap="handleDistrictSelect(district)"
>
<text class="item-text">{{ district.name }}</text>
</view>
</scroll-view>
</view>
<!-- 底部按钮 -->
<view class="footer-buttons">
<view class="btn-reset" @tap="handleReset">
<text class="btn-text">重置</text>
</view>
<view class="btn-confirm" @tap="handleConfirm">
<text class="btn-text">确定</text>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'CityFilter',
props: {
show: {
type: Boolean,
default: false
},
value: {
type: Object,
default: () => ({})
}
},
data() {
return {
selectedProvince: '',
selectedCity: '',
selectedDistrict: '',
// 热门城市
hotCities: [
{ id: 1, name: '全部', province: '', city: '', district: '' },
{ id: 2, name: '北京市', province: 'beijing', city: 'beijing', district: '' },
{ id: 3, name: '海外', province: 'overseas', city: 'overseas', district: '' }
],
// 省份数据
provinces: [
{ id: 'beijing', name: '北京市' },
{ id: 'shanghai', name: '上海市' },
{ id: 'tianjin', name: '天津市' },
{ id: 'chongqing', name: '重庆市' },
{ id: 'hebei', name: '河北省' },
{ id: 'shanxi', name: '山西省' },
{ id: 'liaoning', name: '辽宁省' },
{ id: 'jilin', name: '吉林省' },
{ id: 'heilongjiang', name: '黑龙江省' },
{ id: 'jiangsu', name: '江苏省' },
{ id: 'zhejiang', name: '浙江省' },
{ id: 'anhui', name: '安徽省' },
{ id: 'fujian', name: '福建省' },
{ id: 'jiangxi', name: '江西省' },
{ id: 'shandong', name: '山东省' },
{ id: 'henan', name: '河南省' },
{ id: 'hubei', name: '湖北省' },
{ id: 'hunan', name: '湖南省' },
{ id: 'guangdong', name: '广东省' },
{ id: 'guangxi', name: '广西壮族自治区' },
{ id: 'hainan', name: '海南省' },
{ id: 'sichuan', name: '四川省' },
{ id: 'guizhou', name: '贵州省' },
{ id: 'yunnan', name: '云南省' },
{ id: 'shaanxi', name: '陕西省' },
{ id: 'gansu', name: '甘肃省' },
{ id: 'qinghai', name: '青海省' },
{ id: 'taiwan', name: '台湾省' },
{ id: 'neimenggu', name: '内蒙古自治区' },
{ id: 'xinjiang', name: '新疆维吾尔自治区' },
{ id: 'xizang', name: '西藏自治区' },
{ id: 'ningxia', name: '宁夏回族自治区' },
{ id: 'hongkong', name: '香港特别行政区' },
{ id: 'macao', name: '澳门特别行政区' },
{ id: 'overseas', name: '海外' }
],
// 城市数据(根据省份动态变化)
cityData: {
beijing: [
{ id: 'beijing_city', name: '北京市', provinceId: 'beijing' }
],
shanghai: [
{ id: 'shanghai_city', name: '上海市', provinceId: 'shanghai' }
],
tianjin: [
{ id: 'tianjin_city', name: '天津市', provinceId: 'tianjin' }
],
chongqing: [
{ id: 'chongqing_city', name: '重庆市', provinceId: 'chongqing' }
],
zhejiang: [
{ id: 'hangzhou', name: '杭州市', provinceId: 'zhejiang' },
{ id: 'ningbo', name: '宁波市', provinceId: 'zhejiang' },
{ id: 'wenzhou', name: '温州市', provinceId: 'zhejiang' },
{ id: 'jiaxing', name: '嘉兴市', provinceId: 'zhejiang' },
{ id: 'huzhou', name: '湖州市', provinceId: 'zhejiang' },
{ id: 'shaoxing', name: '绍兴市', provinceId: 'zhejiang' },
{ id: 'jinhua', name: '金华市', provinceId: 'zhejiang' },
{ id: 'quzhou', name: '衢州市', provinceId: 'zhejiang' },
{ id: 'zhoushan', name: '舟山市', provinceId: 'zhejiang' },
{ id: 'taizhou_zj', name: '台州市', provinceId: 'zhejiang' },
{ id: 'lishui', name: '丽水市', provinceId: 'zhejiang' }
],
guangdong: [
{ id: 'guangzhou', name: '广州市', provinceId: 'guangdong' },
{ id: 'shenzhen', name: '深圳市', provinceId: 'guangdong' },
{ id: 'zhuhai', name: '珠海市', provinceId: 'guangdong' },
{ id: 'shantou', name: '汕头市', provinceId: 'guangdong' },
{ id: 'foshan', name: '佛山市', provinceId: 'guangdong' },
{ id: 'shaoguan', name: '韶关市', provinceId: 'guangdong' },
{ id: 'zhanjiang', name: '湛江市', provinceId: 'guangdong' },
{ id: 'zhaoqing', name: '肇庆市', provinceId: 'guangdong' },
{ id: 'jiangmen', name: '江门市', provinceId: 'guangdong' },
{ id: 'maoming', name: '茂名市', provinceId: 'guangdong' },
{ id: 'huizhou', name: '惠州市', provinceId: 'guangdong' },
{ id: 'meizhou', name: '梅州市', provinceId: 'guangdong' },
{ id: 'shanwei', name: '汕尾市', provinceId: 'guangdong' },
{ id: 'heyuan', name: '河源市', provinceId: 'guangdong' },
{ id: 'yangjiang', name: '阳江市', provinceId: 'guangdong' },
{ id: 'qingyuan', name: '清远市', provinceId: 'guangdong' },
{ id: 'dongguan', name: '东莞市', provinceId: 'guangdong' },
{ id: 'zhongshan', name: '中山市', provinceId: 'guangdong' },
{ id: 'chaozhou', name: '潮州市', provinceId: 'guangdong' },
{ id: 'jieyang', name: '揭阳市', provinceId: 'guangdong' },
{ id: 'yunfu', name: '云浮市', provinceId: 'guangdong' }
],
jiangsu: [
{ id: 'nanjing', name: '南京市', provinceId: 'jiangsu' },
{ id: 'wuxi', name: '无锡市', provinceId: 'jiangsu' },
{ id: 'xuzhou', name: '徐州市', provinceId: 'jiangsu' },
{ id: 'changzhou', name: '常州市', provinceId: 'jiangsu' },
{ id: 'suzhou', name: '苏州市', provinceId: 'jiangsu' },
{ id: 'nantong', name: '南通市', provinceId: 'jiangsu' },
{ id: 'lianyungang', name: '连云港市', provinceId: 'jiangsu' },
{ id: 'huaian', name: '淮安市', provinceId: 'jiangsu' },
{ id: 'yancheng', name: '盐城市', provinceId: 'jiangsu' },
{ id: 'yangzhou', name: '扬州市', provinceId: 'jiangsu' },
{ id: 'zhenjiang', name: '镇江市', provinceId: 'jiangsu' },
{ id: 'taizhou_js', name: '泰州市', provinceId: 'jiangsu' },
{ id: 'suqian', name: '宿迁市', provinceId: 'jiangsu' }
],
anhui: [
{ id: 'hefei', name: '合肥市', provinceId: 'anhui' },
{ id: 'wuhu', name: '芜湖市', provinceId: 'anhui' },
{ id: 'bengbu', name: '蚌埠市', provinceId: 'anhui' },
{ id: 'huainan', name: '淮南市', provinceId: 'anhui' },
{ id: 'maanshan', name: '马鞍山市', provinceId: 'anhui' },
{ id: 'huaibei', name: '淮北市', provinceId: 'anhui' },
{ id: 'tongling', name: '铜陵市', provinceId: 'anhui' },
{ id: 'anqing', name: '安庆市', provinceId: 'anhui' },
{ id: 'huangshan', name: '黄山市', provinceId: 'anhui' },
{ id: 'chuzhou', name: '滁州市', provinceId: 'anhui' },
{ id: 'fuyang', name: '阜阳市', provinceId: 'anhui' },
{ id: 'suzhou_ah', name: '宿州市', provinceId: 'anhui' },
{ id: 'luan', name: '六安市', provinceId: 'anhui' },
{ id: 'bozhou', name: '亳州市', provinceId: 'anhui' },
{ id: 'chizhou', name: '池州市', provinceId: 'anhui' },
{ id: 'xuancheng', name: '宣城市', provinceId: 'anhui' }
],
shandong: [
{ id: 'jinan', name: '济南市', provinceId: 'shandong' },
{ id: 'qingdao', name: '青岛市', provinceId: 'shandong' },
{ id: 'zibo', name: '淄博市', provinceId: 'shandong' },
{ id: 'zaozhuang', name: '枣庄市', provinceId: 'shandong' },
{ id: 'dongying', name: '东营市', provinceId: 'shandong' },
{ id: 'yantai', name: '烟台市', provinceId: 'shandong' },
{ id: 'weifang', name: '潍坊市', provinceId: 'shandong' },
{ id: 'jining', name: '济宁市', provinceId: 'shandong' },
{ id: 'taian', name: '泰安市', provinceId: 'shandong' },
{ id: 'weihai', name: '威海市', provinceId: 'shandong' },
{ id: 'rizhao', name: '日照市', provinceId: 'shandong' },
{ id: 'linyi', name: '临沂市', provinceId: 'shandong' },
{ id: 'dezhou', name: '德州市', provinceId: 'shandong' },
{ id: 'liaocheng', name: '聊城市', provinceId: 'shandong' },
{ id: 'binzhou', name: '滨州市', provinceId: 'shandong' },
{ id: 'heze', name: '菏泽市', provinceId: 'shandong' }
],
overseas: [
{ id: 'overseas_city', name: '海外', provinceId: 'overseas' }
]
},
// 区域数据(根据城市动态变化)
districtData: {
beijing_city: [
{ id: 'dongcheng', name: '东城区', cityId: 'beijing_city' },
{ id: 'xicheng', name: '西城区', cityId: 'beijing_city' },
{ id: 'chaoyang', name: '朝阳区', cityId: 'beijing_city' },
{ id: 'fengtai', name: '丰台区', cityId: 'beijing_city' },
{ id: 'shijingshan', name: '石景山区', cityId: 'beijing_city' },
{ id: 'haidian', name: '海淀区', cityId: 'beijing_city' },
{ id: 'mentougou', name: '门头沟区', cityId: 'beijing_city' },
{ id: 'fangshan', name: '房山区', cityId: 'beijing_city' },
{ id: 'tongzhou', name: '通州区', cityId: 'beijing_city' },
{ id: 'shunyi', name: '顺义区', cityId: 'beijing_city' },
{ id: 'changping', name: '昌平区', cityId: 'beijing_city' },
{ id: 'daxing', name: '大兴区', cityId: 'beijing_city' },
{ id: 'huairou', name: '怀柔区', cityId: 'beijing_city' },
{ id: 'pinggu', name: '平谷区', cityId: 'beijing_city' },
{ id: 'miyun', name: '密云区', cityId: 'beijing_city' },
{ id: 'yanqing', name: '延庆区', cityId: 'beijing_city' }
],
shanghai_city: [
{ id: 'huangpu', name: '黄浦区', cityId: 'shanghai_city' },
{ id: 'xuhui', name: '徐汇区', cityId: 'shanghai_city' },
{ id: 'changning', name: '长宁区', cityId: 'shanghai_city' },
{ id: 'jingan', name: '静安区', cityId: 'shanghai_city' },
{ id: 'putuo', name: '普陀区', cityId: 'shanghai_city' },
{ id: 'hongkou', name: '虹口区', cityId: 'shanghai_city' },
{ id: 'yangpu', name: '杨浦区', cityId: 'shanghai_city' },
{ id: 'minhang', name: '闵行区', cityId: 'shanghai_city' },
{ id: 'baoshan', name: '宝山区', cityId: 'shanghai_city' },
{ id: 'jiading', name: '嘉定区', cityId: 'shanghai_city' },
{ id: 'pudong', name: '浦东新区', cityId: 'shanghai_city' },
{ id: 'jinshan', name: '金山区', cityId: 'shanghai_city' },
{ id: 'songjiang', name: '松江区', cityId: 'shanghai_city' },
{ id: 'qingpu', name: '青浦区', cityId: 'shanghai_city' },
{ id: 'fengxian', name: '奉贤区', cityId: 'shanghai_city' },
{ id: 'chongming', name: '崇明区', cityId: 'shanghai_city' }
],
hangzhou: [
{ id: 'shangcheng', name: '上城区', cityId: 'hangzhou' },
{ id: 'gongshu', name: '拱墅区', cityId: 'hangzhou' },
{ id: 'xihu', name: '西湖区', cityId: 'hangzhou' },
{ id: 'binjiang', name: '滨江区', cityId: 'hangzhou' },
{ id: 'xiaoshan', name: '萧山区', cityId: 'hangzhou' },
{ id: 'yuhang', name: '余杭区', cityId: 'hangzhou' },
{ id: 'fuyang', name: '富阳区', cityId: 'hangzhou' },
{ id: 'linan', name: '临安区', cityId: 'hangzhou' },
{ id: 'linping', name: '临平区', cityId: 'hangzhou' },
{ id: 'qiantang', name: '钱塘区', cityId: 'hangzhou' },
{ id: 'tonglu', name: '桐庐县', cityId: 'hangzhou' },
{ id: 'chunan', name: '淳安县', cityId: 'hangzhou' },
{ id: 'jiande', name: '建德市', cityId: 'hangzhou' }
],
guangzhou: [
{ id: 'yuexiu', name: '越秀区', cityId: 'guangzhou' },
{ id: 'liwan', name: '荔湾区', cityId: 'guangzhou' },
{ id: 'haizhu', name: '海珠区', cityId: 'guangzhou' },
{ id: 'tianhe', name: '天河区', cityId: 'guangzhou' },
{ id: 'baiyun', name: '白云区', cityId: 'guangzhou' },
{ id: 'huangpu_gz', name: '黄埔区', cityId: 'guangzhou' },
{ id: 'panyu', name: '番禺区', cityId: 'guangzhou' },
{ id: 'huadu', name: '花都区', cityId: 'guangzhou' },
{ id: 'nansha', name: '南沙区', cityId: 'guangzhou' },
{ id: 'conghua', name: '从化区', cityId: 'guangzhou' },
{ id: 'zengcheng', name: '增城区', cityId: 'guangzhou' }
],
shenzhen: [
{ id: 'luohu', name: '罗湖区', cityId: 'shenzhen' },
{ id: 'futian', name: '福田区', cityId: 'shenzhen' },
{ id: 'nanshan', name: '南山区', cityId: 'shenzhen' },
{ id: 'bao_an', name: '宝安区', cityId: 'shenzhen' },
{ id: 'longgang', name: '龙岗区', cityId: 'shenzhen' },
{ id: 'yantian', name: '盐田区', cityId: 'shenzhen' },
{ id: 'longhua', name: '龙华区', cityId: 'shenzhen' },
{ id: 'pingshan', name: '坪山区', cityId: 'shenzhen' },
{ id: 'guangming', name: '光明区', cityId: 'shenzhen' },
{ id: 'dapeng', name: '大鹏新区', cityId: 'shenzhen' }
],
nanjing: [
{ id: 'xuanwu', name: '玄武区', cityId: 'nanjing' },
{ id: 'qinhuai', name: '秦淮区', cityId: 'nanjing' },
{ id: 'jianye', name: '建邺区', cityId: 'nanjing' },
{ id: 'gulou', name: '鼓楼区', cityId: 'nanjing' },
{ id: 'pukou', name: '浦口区', cityId: 'nanjing' },
{ id: 'qixia', name: '栖霞区', cityId: 'nanjing' },
{ id: 'yuhuatai', name: '雨花台区', cityId: 'nanjing' },
{ id: 'jiangning', name: '江宁区', cityId: 'nanjing' },
{ id: 'liuhe', name: '六合区', cityId: 'nanjing' },
{ id: 'lishui', name: '溧水区', cityId: 'nanjing' },
{ id: 'gaochun', name: '高淳区', cityId: 'nanjing' }
],
suzhou: [
{ id: 'gusu', name: '姑苏区', cityId: 'suzhou' },
{ id: 'wuzhong', name: '吴中区', cityId: 'suzhou' },
{ id: 'xiangcheng', name: '相城区', cityId: 'suzhou' },
{ id: 'huqiu', name: '虎丘区', cityId: 'suzhou' },
{ id: 'wujiang', name: '吴江区', cityId: 'suzhou' },
{ id: 'changshu', name: '常熟市', cityId: 'suzhou' },
{ id: 'zhangjiagang', name: '张家港市', cityId: 'suzhou' },
{ id: 'kunshan', name: '昆山市', cityId: 'suzhou' },
{ id: 'taicang', name: '太仓市', cityId: 'suzhou' }
]
}
}
},
computed: {
cities() {
if (!this.selectedProvince) return []
return this.cityData[this.selectedProvince] || []
},
districts() {
if (!this.selectedCity) return []
return this.districtData[this.selectedCity] || []
}
},
watch: {
value: {
handler(val) {
if (val) {
this.selectedProvince = val.province || ''
this.selectedCity = val.city || ''
this.selectedDistrict = val.district || ''
}
},
immediate: true
}
},
methods: {
/**
* 选择热门城市
*/
handleHotCitySelect(city) {
this.selectedProvince = city.province
this.selectedCity = city.city
this.selectedDistrict = city.district
},
/**
* 选择省份
*/
handleProvinceSelect(province) {
this.selectedProvince = province.id
this.selectedCity = ''
this.selectedDistrict = ''
},
/**
* 选择城市
*/
handleCitySelect(city) {
this.selectedCity = city.id
this.selectedDistrict = ''
},
/**
* 选择区域
*/
handleDistrictSelect(district) {
this.selectedDistrict = district.id
},
/**
* 重置
*/
handleReset() {
this.selectedProvince = ''
this.selectedCity = ''
this.selectedDistrict = ''
},
/**
* 确定
*/
handleConfirm() {
console.log('CityFilter - handleConfirm 被调用')
const result = {
province: this.selectedProvince,
city: this.selectedCity,
district: this.selectedDistrict,
// 获取选中的名称
provinceName: this.getProvinceName(),
cityName: this.getCityName(),
districtName: this.getDistrictName()
}
console.log('CityFilter - 发出 confirm 事件:', result)
this.$emit('confirm', result)
// 发出 update:show 事件关闭弹窗
console.log('CityFilter - 发出 update:show 事件: false')
this.$emit('update:show', false)
},
/**
* 获取省份名称
*/
getProvinceName() {
const province = this.provinces.find(p => p.id === this.selectedProvince)
return province ? province.name : ''
},
/**
* 获取城市名称
*/
getCityName() {
const city = this.cities.find(c => c.id === this.selectedCity)
return city ? city.name : ''
},
/**
* 获取区域名称
*/
getDistrictName() {
const district = this.districts.find(d => d.id === this.selectedDistrict)
return district ? district.name : ''
},
/**
* 点击遮罩关闭
*/
handleMaskClick() {
this.$emit('update:show', false)
}
}
}
</script>
<style lang="scss" scoped>
.city-filter-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 999;
.city-filter-panel {
background-color: #fff;
max-height: 80vh;
display: flex;
flex-direction: column;
animation: slideDown 0.3s ease-out;
margin-top: calc(var(--status-bar-height) + 88rpx);
// 热门城市
.hot-cities {
padding: 32rpx 32rpx 24rpx;
border-bottom: 1rpx solid #f4f4f5;
.section-title {
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #d97706;
margin-bottom: 24rpx;
}
.hot-city-list {
display: flex;
gap: 24rpx;
.hot-city-item {
flex: 1;
padding: 16rpx 24rpx;
background-color: #f4f4f5;
border-radius: 8rpx;
text-align: center;
.city-text {
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #52525b;
}
&.active {
background-color: #fff5eb;
.city-text {
color: #d97706;
font-weight: 500;
}
}
}
}
}
// 三级联动容器
.cascader-container {
flex: 1;
display: flex;
overflow: hidden;
.cascader-column {
flex: 1;
height: 50vh;
background-color: #fff;
&:nth-child(1) {
background-color: #f9fafb;
}
&:nth-child(2) {
background-color: #f4f4f5;
}
&:nth-child(3) {
background-color: #fff;
}
.cascader-item {
padding: 24rpx 32rpx;
border-bottom: 1rpx solid #f4f4f5;
.item-text {
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #52525b;
}
&.active {
background-color: #fff;
.item-text {
color: #d97706;
font-weight: 500;
}
}
}
}
}
// 底部按钮
.footer-buttons {
display: flex;
gap: 24rpx;
padding: 24rpx 32rpx;
background-color: #fff;
border-top: 1rpx solid #f4f4f5;
.btn-reset,
.btn-confirm {
flex: 1;
height: 88rpx;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
.btn-text {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
}
}
.btn-reset {
background-color: #fff;
border: 2rpx solid #e5e5e5;
.btn-text {
color: #52525b;
}
}
.btn-confirm {
background-color: #18181b;
.btn-text {
color: #fff;
}
}
}
}
}
// 下拉动画
@keyframes slideDown {
from {
transform: translateY(-100%);
}
to {
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,379 @@
<!--
* 咨询师卡片组件
* @author AI
* @date 2026-03-06
-->
<template>
<view class="consultant-card" @click="handleClick">
<!-- 头像带加载动画 -->
<view class="avatar-wrapper">
<image
:src="avatar"
class="avatar"
:class="{ 'avatar-loaded': avatarLoaded }"
mode="aspectFill"
@load="handleAvatarLoad"
@error="handleAvatarError"
/>
<view v-if="!avatarLoaded" class="avatar-loading">
<view class="loading-circle"></view>
</view>
</view>
<!-- 内容区 -->
<view class="content">
<!-- 第一行姓名 + 等级徽章 + 时间状态 -->
<view class="row-1">
<view class="name-badge">
<text class="name">{{ name }}</text>
<!-- 等级徽章图标 -->
<image
v-if="levelIcon"
:src="levelIcon"
class="level-badge"
mode="aspectFit"
/>
</view>
<view class="time-status">
<text class="time">明天{{ availableTime }}</text>
<view class="divider"></view>
<text class="status">{{ status }}</text>
</view>
</view>
<!-- 第二行职称描述单行超出省略 -->
<view class="row-2">
<text class="title-text">{{ title }}</text>
</view>
<!-- 第三行擅长领域单行超出省略 -->
<view class="row-3">
<text class="specialty-text">擅长{{ specialties }}</text>
</view>
<!-- 第四行标签列表最多5个超出隐藏 -->
<view class="row-4">
<view v-for="(tag, index) in displayTags" :key="index" class="tag">
<text class="tag-text">{{ tag }}</text>
</view>
</view>
<!-- 第五行地点 + 价格 -->
<view class="row-5">
<view class="location">
<image
src="/static/icons/map-pin-line.png"
class="location-icon"
mode="aspectFit"
/>
<text class="location-text">{{ city }}</text>
</view>
<text class="price">¥{{ price }}/</text>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'ConsultantCard',
props: {
id: {
type: [String, Number],
required: true
},
avatar: {
type: String,
default: ''
},
name: {
type: String,
required: true
},
levelIcon: {
type: String,
default: ''
},
availableTime: {
type: String,
default: ''
},
status: {
type: String,
default: ''
},
title: {
type: String,
default: ''
},
specialties: {
type: String,
default: ''
},
tags: {
type: Array,
default: () => []
},
city: {
type: String,
default: ''
},
price: {
type: Number,
required: true
}
},
data() {
return {
avatarLoaded: false
}
},
computed: {
displayTags() {
// 最多显示4个标签
return this.tags.slice(0, 4)
}
},
methods: {
handleClick() {
this.$emit('click', this.id)
},
handleAvatarLoad() {
this.avatarLoaded = true
},
handleAvatarError() {
this.avatarLoaded = true
console.error('头像加载失败:', this.avatar)
}
}
}
</script>
<style lang="scss" scoped>
.consultant-card {
display: flex;
gap: 24rpx;
padding: 24rpx;
background-color: #fff;
border-radius: 20rpx;
// 头像容器
.avatar-wrapper {
position: relative;
width: 140rpx;
height: 140rpx;
flex-shrink: 0;
.avatar {
width: 100%;
height: 100%;
border-radius: 50%;
opacity: 0;
transition: opacity 0.3s ease-in-out;
&.avatar-loaded {
opacity: 1;
}
}
// 加载动画
.avatar-loading {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 50%;
background-color: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
.loading-circle {
width: 40rpx;
height: 40rpx;
border: 4rpx solid #e5e5e5;
border-top-color: #a78e78;
border-radius: 50%;
animation: avatar-spin 0.8s linear infinite;
}
}
}
.content {
flex: 1;
display: flex;
flex-direction: column;
gap: 12rpx;
min-width: 0;
// 第一行:姓名 + 等级 + 时间状态
.row-1 {
display: flex;
justify-content: space-between;
align-items: center;
.name-badge {
display: flex;
align-items: center;
gap: 8rpx;
.name {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #09090b;
line-height: 1.375;
}
// 等级徽章(高级名师图标)
.level-badge {
width: 110rpx;
height: 28rpx;
flex-shrink: 0;
}
}
.time-status {
display: flex;
align-items: center;
gap: 8rpx;
padding: 4rpx 16rpx;
background-color: #f9f7f4;
border-radius: 6rpx;
flex-shrink: 0;
.time {
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #70553e;
line-height: 1.5;
}
.divider {
width: 1rpx;
height: 20rpx;
background-color: #e3dad3;
}
.status {
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #70553e;
line-height: 1.5;
}
}
}
// 第二行:职称(单行省略)
.row-2 {
.title-text {
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #6b7280;
line-height: 1.83;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-all;
}
}
// 第三行:擅长(单行省略)
.row-3 {
.specialty-text {
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #6b7280;
line-height: 1.83;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-all;
}
}
// 第四行标签最多5个
.row-4 {
display: flex;
flex-wrap: nowrap;
gap: 14rpx;
overflow: hidden;
.tag {
padding: 2rpx 12rpx;
background-color: rgba(216, 207, 199, 0.2);
border: 2rpx solid #d8cfc7;
border-radius: 8rpx;
flex-shrink: 0;
backdrop-filter: blur(21.7px);
height: 32rpx;
display: flex;
align-items: center;
justify-content: center;
.tag-text {
font-size: 18rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #a78e78;
line-height: 1.56;
white-space: nowrap;
}
}
}
// 第五行:地点 + 价格
.row-5 {
display: flex;
justify-content: space-between;
align-items: center;
.location {
display: flex;
align-items: center;
gap: 4rpx;
.location-icon {
width: 24rpx;
height: 24rpx;
}
.location-text {
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #52525b;
line-height: 1.57;
}
}
.price {
font-size: 28rpx;
font-family: 'MiSans VF', 'PingFang SC', sans-serif;
font-weight: 500;
color: #6f4522;
line-height: 1.57;
}
}
}
}
// 头像加载动画
@keyframes avatar-spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,152 @@
<!--
* 咨询师卡片骨架屏组件
* @author AI
* @date 2026-03-06
-->
<template>
<view class="consultant-card-skeleton">
<!-- 头像骨架 -->
<view class="skeleton-avatar skeleton-animate"></view>
<!-- 内容区骨架 -->
<view class="skeleton-content">
<!-- 第一行 -->
<view class="skeleton-row-1">
<view class="skeleton-name skeleton-animate"></view>
<view class="skeleton-time skeleton-animate"></view>
</view>
<!-- 第二行 -->
<view class="skeleton-row-2">
<view class="skeleton-line skeleton-animate"></view>
</view>
<!-- 第三行 -->
<view class="skeleton-row-3">
<view class="skeleton-line skeleton-animate"></view>
</view>
<!-- 第四行 -->
<view class="skeleton-row-4">
<view class="skeleton-tag skeleton-animate"></view>
<view class="skeleton-tag skeleton-animate"></view>
<view class="skeleton-tag skeleton-animate"></view>
</view>
<!-- 第五行 -->
<view class="skeleton-row-5">
<view class="skeleton-location skeleton-animate"></view>
<view class="skeleton-price skeleton-animate"></view>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'ConsultantCardSkeleton'
}
</script>
<style lang="scss" scoped>
.consultant-card-skeleton {
display: flex;
gap: 24rpx;
padding: 24rpx;
background-color: #fff;
border-radius: 20rpx;
.skeleton-avatar {
width: 140rpx;
height: 140rpx;
border-radius: 50%;
background-color: #f5f5f5;
flex-shrink: 0;
}
.skeleton-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 12rpx;
.skeleton-row-1 {
display: flex;
justify-content: space-between;
align-items: center;
.skeleton-name {
width: 120rpx;
height: 40rpx;
background-color: #f5f5f5;
border-radius: 4rpx;
}
.skeleton-time {
width: 140rpx;
height: 32rpx;
background-color: #f5f5f5;
border-radius: 6rpx;
}
}
.skeleton-row-2,
.skeleton-row-3 {
.skeleton-line {
width: 100%;
height: 28rpx;
background-color: #f5f5f5;
border-radius: 4rpx;
}
}
.skeleton-row-4 {
display: flex;
gap: 14rpx;
.skeleton-tag {
width: 100rpx;
height: 32rpx;
background-color: #f5f5f5;
border-radius: 4rpx;
}
}
.skeleton-row-5 {
display: flex;
justify-content: space-between;
align-items: center;
.skeleton-location {
width: 80rpx;
height: 32rpx;
background-color: #f5f5f5;
border-radius: 4rpx;
}
.skeleton-price {
width: 100rpx;
height: 32rpx;
background-color: #f5f5f5;
border-radius: 4rpx;
}
}
}
}
// 骨架屏动画
.skeleton-animate {
animation: skeleton-loading 1.5s ease-in-out infinite;
background: linear-gradient(90deg, #f5f5f5 25%, #e5e5e5 50%, #f5f5f5 75%);
background-size: 200% 100%;
}
@keyframes skeleton-loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
</style>

View File

@@ -0,0 +1,89 @@
<!--
* 筛选栏组件
* @author AI
* @date 2026-03-06
-->
<template>
<view class="filter-bar">
<view
v-for="item in filters"
:key="item.key"
class="filter-item"
:class="{ active: item.active }"
@click="handleFilterClick(item)"
>
<text class="filter-text">{{ item.displayLabel || item.label }}</text>
<view class="filter-icon">
<!-- 预留下拉箭头图标位置 -->
<text class="icon-placeholder"></text>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'FilterBar',
props: {
filters: {
type: Array,
default: () => [
{ key: 'city', label: '城市' },
{ key: 'issue', label: '困扰' },
{ key: 'price', label: '价格' },
{ key: 'more', label: '更多' },
{ key: 'sort', label: '排序' }
]
}
},
methods: {
handleFilterClick(item) {
this.$emit('filter-click', item.key)
}
}
}
</script>
<style lang="scss" scoped>
.filter-bar {
display: flex;
justify-content: space-between;
align-items: center;
.filter-item {
display: flex;
align-items: center;
gap: 4rpx;
.filter-text {
font-size: 28rpx;
font-weight: 500;
color: #71717a;
transition: color 0.3s;
}
.filter-icon {
display: flex;
align-items: center;
justify-content: center;
.icon-placeholder {
font-size: 20rpx;
color: #71717a;
transition: color 0.3s;
}
}
// 激活状态(有筛选条件时)
&.active {
.filter-text {
color: #d97706;
}
.icon-placeholder {
color: #d97706;
}
}
}
}
</style>

View File

@@ -0,0 +1,243 @@
<!--
* 通用筛选弹窗组件
* @author AI
* @date 2026-03-06
-->
<template>
<view v-if="show" class="filter-popup-mask" @tap="handleMaskClick">
<view class="filter-popup-panel" @tap.stop>
<!-- 标题 -->
<view v-if="title" class="panel-title">
<text class="title-text">{{ title }}</text>
</view>
<!-- 选项列表 -->
<view class="options-list">
<view
v-for="option in options"
:key="option.id"
class="option-item"
:class="{ active: isSelected(option.value) }"
@tap="handleOptionSelect(option)"
>
<text class="option-text">{{ option.label }}</text>
</view>
</view>
<!-- 底部按钮 -->
<view class="footer-buttons">
<view class="btn-reset" @tap="handleReset">
<text class="btn-text">重置</text>
</view>
<view class="btn-confirm" @tap="handleConfirm">
<text class="btn-text">确定</text>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'FilterPopup',
props: {
show: {
type: Boolean,
default: false
},
title: {
type: String,
default: ''
},
options: {
type: Array,
default: () => []
},
value: {
type: [String, Number, Array],
default: ''
},
multiple: {
type: Boolean,
default: false
}
},
data() {
return {
selectedValue: this.multiple ? (Array.isArray(this.value) ? [...this.value] : []) : this.value
}
},
watch: {
value(val) {
this.selectedValue = this.multiple ? (Array.isArray(val) ? [...val] : []) : val
}
},
methods: {
/**
* 判断是否选中
*/
isSelected(value) {
if (this.multiple) {
return this.selectedValue.includes(value)
}
return this.selectedValue === value
},
/**
* 选择选项
*/
handleOptionSelect(option) {
if (this.multiple) {
const index = this.selectedValue.indexOf(option.value)
if (index > -1) {
this.selectedValue.splice(index, 1)
} else {
this.selectedValue.push(option.value)
}
} else {
this.selectedValue = option.value
}
},
/**
* 重置
*/
handleReset() {
this.selectedValue = this.multiple ? [] : ''
},
/**
* 确定
*/
handleConfirm() {
this.$emit('confirm', this.selectedValue)
// 发出 update:show 事件关闭弹窗
this.$emit('update:show', false)
},
/**
* 点击遮罩关闭
*/
handleMaskClick() {
this.$emit('update:show', false)
}
}
}
</script>
<style lang="scss" scoped>
.filter-popup-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 999;
.filter-popup-panel {
background-color: #fff;
max-height: 80vh;
display: flex;
flex-direction: column;
animation: slideDown 0.3s ease-out;
margin-top: calc(var(--status-bar-height) + 88rpx);
.panel-title {
padding: 32rpx 32rpx 24rpx;
border-bottom: 1rpx solid #f4f4f5;
.title-text {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #18181b;
}
}
.options-list {
padding: 32rpx;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24rpx 32rpx;
max-height: 60vh;
overflow-y: auto;
.option-item {
padding: 16rpx 24rpx;
background-color: #f4f4f5;
border-radius: 8rpx;
text-align: center;
.option-text {
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #52525b;
}
&.active {
background-color: #fff5eb;
border: 1rpx solid #d97706;
.option-text {
color: #d97706;
font-weight: 500;
}
}
}
}
.footer-buttons {
display: flex;
gap: 24rpx;
padding: 24rpx 32rpx;
background-color: #fff;
border-top: 1rpx solid #f4f4f5;
.btn-reset,
.btn-confirm {
flex: 1;
height: 88rpx;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
.btn-text {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
}
}
.btn-reset {
background-color: #fff;
border: 2rpx solid #e5e5e5;
.btn-text {
color: #52525b;
}
}
.btn-confirm {
background-color: #18181b;
.btn-text {
color: #fff;
}
}
}
}
}
// 下拉动画
@keyframes slideDown {
from {
transform: translateY(-100%);
}
to {
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,481 @@
<!--
* 困扰筛选组件多级分类
* @author AI
* @date 2026-03-06
-->
<template>
<view v-if="show" class="issue-filter-mask" @tap="handleMaskClick">
<view class="issue-filter-panel" @tap.stop>
<!-- 标题 -->
<view class="panel-header">
<text class="header-title">请选择您的困扰多选</text>
</view>
<!-- 主体内容 -->
<view class="panel-body">
<!-- 左侧一级分类 -->
<scroll-view class="category-list" scroll-y>
<view
v-for="category in categories"
:key="category.id"
class="category-item"
:class="{ active: currentCategory === category.id }"
@tap="handleCategorySelect(category.id)"
>
<text class="category-text">{{ category.name }}</text>
<view v-if="getCategorySelectedCount(category.id) > 0" class="category-badge">
<text class="badge-text">{{ getCategorySelectedCount(category.id) }}</text>
</view>
</view>
</scroll-view>
<!-- 右侧二级标签 -->
<scroll-view class="tags-container" scroll-y>
<view class="tags-header">
<text class="tags-title">{{ currentCategoryName }}</text>
<text class="expand-text" @tap="toggleExpand">{{ isExpanded ? '收起' : '展开' }}</text>
</view>
<view class="tags-list">
<view
v-for="tag in currentTags"
:key="tag.id"
class="tag-item"
:class="{ active: isTagSelected(tag.id) }"
@tap="handleTagSelect(tag)"
>
<text class="tag-text">{{ tag.name }}</text>
</view>
</view>
</scroll-view>
</view>
<!-- 底部按钮 -->
<view class="footer-buttons">
<view class="btn-reset" @tap="handleReset">
<text class="btn-text">重置</text>
</view>
<view class="btn-confirm" @tap="handleConfirm">
<text class="btn-text">确定</text>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'IssueFilter',
props: {
show: {
type: Boolean,
default: false
},
value: {
type: Array,
default: () => []
}
},
data() {
return {
currentCategory: 'mental',
isExpanded: true,
selectedTags: [],
// 分类数据
categories: [
{ id: 'mental', name: '心理健康' },
{ id: 'relationship', name: '婚烟/亲密关系' },
{ id: 'family', name: '家庭/养育' },
{ id: 'career', name: '学业/发展' },
{ id: 'work', name: '职场相关' },
{ id: 'interpersonal', name: '人际关系' },
{ id: 'growth', name: '个人成长' },
{ id: 'life', name: '生活事件' }
],
// 标签数据
tagsData: {
mental: [
{ id: 'mental_all', name: '全部', categoryId: 'mental' },
{ id: 'mental_depression', name: '抑郁', categoryId: 'mental' },
{ id: 'mental_anxiety', name: '焦虑', categoryId: 'mental' },
{ id: 'mental_loss', name: '丧失与哀伤', categoryId: 'mental' },
{ id: 'mental_sleep', name: '睡眠问题', categoryId: 'mental' },
{ id: 'mental_bipolar', name: '双相情感障碍', categoryId: 'mental' },
{ id: 'mental_compulsive', name: '强迫症状及相关问题', categoryId: 'mental' }
],
relationship: [
{ id: 'rel_all', name: '全部', categoryId: 'relationship' },
{ id: 'rel_crisis', name: '亲密关系危机', categoryId: 'relationship' },
{ id: 'rel_communication', name: '伴侣沟通问题', categoryId: 'relationship' },
{ id: 'rel_breakup', name: '失恋', categoryId: 'relationship' },
{ id: 'rel_sex', name: '性议题', categoryId: 'relationship' }
],
family: [
{ id: 'fam_all', name: '全部', categoryId: 'family' },
{ id: 'fam_conflict', name: '家庭冲突', categoryId: 'family' },
{ id: 'fam_communication', name: '亲子沟通', categoryId: 'family' },
{ id: 'fam_child', name: '童男轻女', categoryId: 'family' },
{ id: 'fam_pressure', name: '熟龄者压力', categoryId: 'family' },
{ id: 'fam_parenting', name: '产后抚育', categoryId: 'family' },
{ id: 'fam_adolescent', name: '青儿分歧', categoryId: 'family' },
{ id: 'fam_violence', name: '家庭暴力', categoryId: 'family' },
{ id: 'fam_violence2', name: '家庭暴力', categoryId: 'family' }
],
career: [
{ id: 'career_all', name: '全部', categoryId: 'career' },
{ id: 'career_planning', name: '职业规划', categoryId: 'career' },
{ id: 'career_stress', name: '学业压力', categoryId: 'career' }
],
work: [
{ id: 'work_all', name: '全部', categoryId: 'work' },
{ id: 'work_stress', name: '工作压力', categoryId: 'work' },
{ id: 'work_burnout', name: '职业倦怠', categoryId: 'work' }
],
interpersonal: [
{ id: 'inter_all', name: '全部', categoryId: 'interpersonal' },
{ id: 'inter_social', name: '社交困难', categoryId: 'interpersonal' }
],
growth: [
{ id: 'growth_all', name: '全部', categoryId: 'growth' },
{ id: 'growth_self', name: '自我探索', categoryId: 'growth' }
],
life: [
{ id: 'life_all', name: '全部', categoryId: 'life' },
{ id: 'life_trauma', name: '创伤事件', categoryId: 'life' }
]
}
}
},
computed: {
currentCategoryName() {
const category = this.categories.find(c => c.id === this.currentCategory)
return category ? category.name : ''
},
currentTags() {
return this.tagsData[this.currentCategory] || []
}
},
watch: {
value: {
handler(val) {
this.selectedTags = Array.isArray(val) ? [...val] : []
},
immediate: true
}
},
methods: {
/**
* 选择一级分类
*/
handleCategorySelect(categoryId) {
this.currentCategory = categoryId
},
/**
* 获取分类下已选择的标签数量
*/
getCategorySelectedCount(categoryId) {
const tags = this.tagsData[categoryId] || []
return tags.filter(tag => this.selectedTags.includes(tag.id) && tag.id !== `${categoryId}_all`).length
},
/**
* 判断标签是否选中
*/
isTagSelected(tagId) {
return this.selectedTags.includes(tagId)
},
/**
* 选择标签
*/
handleTagSelect(tag) {
const index = this.selectedTags.indexOf(tag.id)
// 如果是"全部"标签
if (tag.id.endsWith('_all')) {
if (index > -1) {
// 取消选中"全部",清空该分类下的所有选择
this.selectedTags = this.selectedTags.filter(id => {
const selectedTag = this.findTagById(id)
return selectedTag && selectedTag.categoryId !== tag.categoryId
})
} else {
// 选中"全部",清空该分类下的其他选择,只保留"全部"
this.selectedTags = this.selectedTags.filter(id => {
const selectedTag = this.findTagById(id)
return selectedTag && selectedTag.categoryId !== tag.categoryId
})
this.selectedTags.push(tag.id)
}
} else {
// 普通标签
if (index > -1) {
// 取消选中
this.selectedTags.splice(index, 1)
} else {
// 选中,同时取消该分类的"全部"
const allTagId = `${tag.categoryId}_all`
const allIndex = this.selectedTags.indexOf(allTagId)
if (allIndex > -1) {
this.selectedTags.splice(allIndex, 1)
}
this.selectedTags.push(tag.id)
}
}
},
/**
* 根据ID查找标签
*/
findTagById(tagId) {
for (const categoryId in this.tagsData) {
const tag = this.tagsData[categoryId].find(t => t.id === tagId)
if (tag) return tag
}
return null
},
/**
* 切换展开/收起
*/
toggleExpand() {
this.isExpanded = !this.isExpanded
},
/**
* 重置
*/
handleReset() {
this.selectedTags = []
},
/**
* 确定
*/
handleConfirm() {
this.$emit('confirm', this.selectedTags)
this.$emit('update:show', false)
},
/**
* 点击遮罩关闭
*/
handleMaskClick() {
this.$emit('update:show', false)
}
}
}
</script>
<style lang="scss" scoped>
.issue-filter-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 999;
.issue-filter-panel {
background-color: #fff;
max-height: 80vh;
display: flex;
flex-direction: column;
animation: slideDown 0.3s ease-out;
margin-top: calc(var(--status-bar-height) + 88rpx);
// 标题
.panel-header {
padding: 32rpx 32rpx 24rpx;
border-bottom: 1rpx solid #f4f4f5;
.header-title {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #18181b;
}
}
// 主体内容
.panel-body {
flex: 1;
display: flex;
overflow: hidden;
// 左侧分类列表
.category-list {
width: 230rpx;
background-color: #f9fafb;
.category-item {
position: relative;
padding: 32rpx 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
.category-text {
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #52525b;
line-height: 1.43;
}
.category-badge {
width: 32rpx;
height: 32rpx;
background-color: #d97706;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
.badge-text {
font-size: 20rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #fff;
}
}
&.active {
background-color: #fff;
.category-text {
color: #d97706;
font-weight: 500;
}
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6rpx;
background-color: #d97706;
}
}
}
}
// 右侧标签容器
.tags-container {
flex: 1;
background-color: #fff;
.tags-header {
padding: 24rpx 32rpx;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1rpx solid #f4f4f5;
.tags-title {
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #18181b;
}
.expand-text {
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #a1a1aa;
}
}
.tags-list {
padding: 24rpx 32rpx;
display: flex;
flex-wrap: wrap;
gap: 20rpx;
.tag-item {
padding: 12rpx 24rpx;
background-color: #f4f4f5;
border-radius: 8rpx;
.tag-text {
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #52525b;
line-height: 1.43;
}
&.active {
background-color: #fff5eb;
border: 1rpx solid #d97706;
.tag-text {
color: #d97706;
font-weight: 500;
}
}
}
}
}
}
// 底部按钮
.footer-buttons {
display: flex;
gap: 24rpx;
padding: 24rpx 32rpx;
background-color: #fff;
border-top: 1rpx solid #f4f4f5;
.btn-reset,
.btn-confirm {
flex: 1;
height: 88rpx;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
.btn-text {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
}
}
.btn-reset {
background-color: #fff;
border: 2rpx solid #e5e5e5;
.btn-text {
color: #52525b;
}
}
.btn-confirm {
background-color: #18181b;
.btn-text {
color: #fff;
}
}
}
}
}
// 下拉动画
@keyframes slideDown {
from {
transform: translateY(-100%);
}
to {
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,341 @@
<template>
<view class="schedule-calendar">
<!-- 标题 -->
<view class="calendar-header">
<view class="header-indicator"></view>
<text class="header-title">可约时间</text>
</view>
<!-- 日期列表 - 横向滚动 -->
<scroll-view class="date-scroll" scroll-x :show-scrollbar="false">
<view class="date-list">
<view
v-for="(item, index) in scheduleData"
:key="index"
class="date-item"
:class="{ 'has-available': item.available }"
@tap="selectDate(item)"
>
<!-- 日期头部月份 + 星期 -->
<view class="date-header">
<text class="month-text">{{ item.month }}</text>
<text class="weekday-text">{{ item.weekday }}</text>
</view>
<!-- 日期内容日期 + 状态 -->
<view
class="date-content"
:class="{
'is-today': item.isToday,
'is-available': item.available,
'is-full': !item.available
}"
>
<text class="day-text">{{ item.day }}</text>
<text class="status-text">{{ item.statusText }}</text>
</view>
</view>
</view>
</scroll-view>
<!-- 空状态 -->
<view v-if="scheduleData.length === 0" class="empty-state">
<text class="empty-text">暂无可预约时间</text>
</view>
</view>
</template>
<script>
export default {
name: 'ScheduleCalendar',
props: {
consultantId: {
type: String,
default: ''
}
},
data() {
return {
scheduleData: []
}
},
mounted() {
this.loadScheduleData()
},
methods: {
/**
* 加载可预约时间数据
*/
async loadScheduleData() {
try {
// TODO: 调用真实接口
// const res = await getConsultantSchedule(this.consultantId)
// this.scheduleData = this.formatScheduleData(res.data)
// Mock 数据
const today = new Date()
this.scheduleData = [
{
month: '02月',
weekday: '周五',
day: '今',
statusText: '满',
isToday: true,
available: false,
date: this.formatDate(today)
},
{
month: '02月',
weekday: '周六',
day: '28',
statusText: '剩1',
isToday: false,
available: true,
date: this.formatDate(new Date(today.getTime() + 86400000))
},
{
month: '03月',
weekday: '周天',
day: '01',
statusText: '满',
isToday: false,
available: false,
date: this.formatDate(new Date(today.getTime() + 86400000 * 2))
},
{
month: '03月',
weekday: '周一',
day: '02',
statusText: '满',
isToday: false,
available: false,
date: this.formatDate(new Date(today.getTime() + 86400000 * 3))
},
{
month: '03月',
weekday: '周二',
day: '03',
statusText: '剩2',
isToday: false,
available: true,
date: this.formatDate(new Date(today.getTime() + 86400000 * 4))
},
{
month: '03月',
weekday: '周三',
day: '04',
statusText: '剩1',
isToday: false,
available: true,
date: this.formatDate(new Date(today.getTime() + 86400000 * 5))
},
{
month: '03月',
weekday: '周四',
day: '05',
statusText: '满',
isToday: false,
available: false,
date: this.formatDate(new Date(today.getTime() + 86400000 * 6))
}
]
} catch (error) {
console.error('加载可预约时间失败:', error)
this.scheduleData = []
}
},
/**
* 格式化日期
*/
formatDate(date) {
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}`
},
/**
* 选择日期
*/
selectDate(dateItem) {
if (!dateItem.available) {
uni.showToast({
title: '该日期已约满',
icon: 'none'
})
return
}
this.$emit('time-select', {
date: dateItem.date,
month: dateItem.month,
weekday: dateItem.weekday,
day: dateItem.day
})
}
}
}
</script>
<style lang="scss" scoped>
.schedule-calendar {
display: flex;
flex-direction: column;
gap: 32rpx;
// 标题
.calendar-header {
display: flex;
align-items: center;
gap: 8rpx;
.header-indicator {
width: 4rpx;
height: 20rpx;
background-color: #9d5d00;
border-radius: 16rpx;
transform: rotate(180deg);
}
.header-title {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #18181b;
line-height: 1.25;
}
}
// 横向滚动容器
.date-scroll {
width: 100%;
white-space: nowrap;
}
// 日期列表
.date-list {
display: inline-flex;
gap: 12rpx;
padding-right: 32rpx;
.date-item {
display: inline-flex;
flex-direction: column;
gap: 8rpx;
width: 80rpx;
flex-shrink: 0;
// 日期头部
.date-header {
display: flex;
flex-direction: column;
align-items: center;
.month-text {
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #a1a1aa;
line-height: 1.5;
text-align: center;
}
.weekday-text {
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #a1a1aa;
line-height: 1.5;
text-align: center;
}
}
// 日期内容
.date-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 88rpx;
border-radius: 8rpx;
background-color: #f4f4f5;
padding: 4rpx 16rpx;
.day-text {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #a1a1aa;
line-height: 1.375;
text-align: center;
}
.status-text {
font-size: 22rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #a1a1aa;
line-height: 1.545;
text-align: center;
}
// 今天 - 灰色不可用
&.is-today {
background-color: #f4f4f5;
.day-text,
.status-text {
color: #a1a1aa;
}
}
// 可用状态 - 米黄色
&.is-available {
background-color: #f9f7f4;
.day-text,
.status-text {
color: #70553e;
}
}
// 已满状态 - 灰色
&.is-full {
background-color: #f4f4f5;
.day-text,
.status-text {
color: #a1a1aa;
}
}
}
// 可用日期的头部文字颜色
&.has-available {
.date-header {
.month-text,
.weekday-text {
color: #3f3f46;
}
}
}
}
}
// 空状态
.empty-state {
padding: 80rpx 0;
display: flex;
align-items: center;
justify-content: center;
.empty-text {
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #a1a1aa;
}
}
}
</style>

View File

@@ -0,0 +1,125 @@
<!--
* 搜索栏组件
* @author AI
* @date 2026-03-06
-->
<template>
<view class="search-bar">
<view class="search-input-wrapper">
<view class="search-icon">
<!-- 预留搜索图标位置可以使用 image iconfont -->
<view class="icon-search-placeholder"></view>
</view>
<input
v-model="searchValue"
class="search-input"
:placeholder="placeholder"
@input="handleInput"
@confirm="handleConfirm"
/>
</view>
<view v-if="showButton" class="search-button" @click="handleSearch">
<text class="button-text">搜索</text>
</view>
</view>
</template>
<script>
export default {
name: 'SearchBar',
props: {
placeholder: {
type: String,
default: '搜索咨询师'
},
showButton: {
type: Boolean,
default: true
},
value: {
type: String,
default: ''
}
},
data() {
return {
searchValue: this.value
}
},
watch: {
value(val) {
this.searchValue = val
}
},
methods: {
handleInput(e) {
this.searchValue = e.detail.value
this.$emit('input', this.searchValue)
},
handleConfirm() {
this.$emit('confirm', this.searchValue)
},
handleSearch() {
this.$emit('search', this.searchValue)
}
}
}
</script>
<style lang="scss" scoped>
.search-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
padding: 20rpx 32rpx;
background-color: #fff;
border-radius: 64rpx;
border: 2rpx solid #fff;
.search-input-wrapper {
flex: 1;
display: flex;
align-items: center;
gap: 20rpx;
.search-icon {
width: 32rpx;
height: 32rpx;
display: flex;
align-items: center;
justify-content: center;
// 预留搜索图标位置
.icon-search-placeholder {
width: 32rpx;
height: 32rpx;
// 可以使用 background-image 或 image 标签填充图标
// background-image: url('/static/icons/search.png');
// background-size: contain;
}
}
.search-input {
flex: 1;
font-size: 28rpx;
color: #18181b;
&::placeholder {
color: #a1a1aa;
}
}
}
.search-button {
padding: 8rpx 20rpx;
background-color: #5f504a;
border-radius: 34rpx;
.button-text {
font-size: 28rpx;
color: #fff;
}
}
}
</style>

42
src/config/constants.js Normal file
View File

@@ -0,0 +1,42 @@
/**
* 全局常量配置
*/
// 存储键名
export const STORAGE_KEYS = {
TOKEN: 'token',
USER_INFO: 'userInfo',
LANGUAGE: 'language',
THEME: 'theme'
}
// HTTP状态码
export const HTTP_STATUS = {
SUCCESS: 200,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
SERVER_ERROR: 500
}
// 业务状态码
export const BIZ_CODE = {
SUCCESS: 0,
FAIL: -1,
TOKEN_EXPIRED: 10001,
NO_PERMISSION: 10002
}
// 页面路径
export const PAGE_PATH = {
LOGIN: '/pages/login/index',
INDEX: '/pages/index/index',
USER: '/pages/user/index'
}
// 用户角色
export const USER_ROLE = {
ADMIN: 'admin',
USER: 'user',
GUEST: 'guest'
}

41
src/config/env.js Normal file
View File

@@ -0,0 +1,41 @@
/**
* 环境配置文件
* 根据不同环境配置不同的API地址、日志级别等
*/
// 环境类型
const ENV_TYPE = {
DEV: 'development',
TEST: 'test',
PROD: 'production'
}
// 当前环境(可通过构建参数动态设置)
const currentEnv = process.env.NODE_ENV || ENV_TYPE.DEV
// 环境配置映射
const envConfig = {
[ENV_TYPE.DEV]: {
baseURL: 'https://dev-api.example.com',
timeout: 10000,
enableLog: true,
enableMock: false
},
[ENV_TYPE.TEST]: {
baseURL: 'https://test-api.example.com',
timeout: 10000,
enableLog: true,
enableMock: false
},
[ENV_TYPE.PROD]: {
baseURL: 'https://api.example.com',
timeout: 15000,
enableLog: false,
enableMock: false
}
}
export default {
...envConfig[currentEnv],
env: currentEnv
}

18
src/main.js Normal file
View File

@@ -0,0 +1,18 @@
import { createSSRApp } from 'vue'
import App from './App.vue'
import pinia from './store'
import uviewPlus from 'uview-plus'
export function createApp() {
const app = createSSRApp(App)
// 使用Pinia
app.use(pinia)
// 使用uview-plus
app.use(uviewPlus)
return {
app
}
}

98
src/manifest.json Normal file
View File

@@ -0,0 +1,98 @@
{
"name": "uniapp-enterprise",
"appid": "wxe040c9e8f865fb17",
"description": "大白心理PSY",
"versionName": "1.0.0",
"versionCode": "100",
"transformPx": false,
"app-plus": {
"usingComponents": true,
"nvueStyleCompiler": "uni-app",
"compilerVersion": 3,
"splashscreen": {
"alwaysShowBeforeRender": true,
"waiting": true,
"autoclose": true,
"delay": 0
},
"modules": {},
"distribute": {
"android": {
"permissions": [
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
]
},
"ios": {},
"sdkConfigs": {}
}
},
"quickapp": {},
"mp-weixin": {
"appid": "",
"setting": {
"urlCheck": false,
"es6": true,
"postcss": true,
"minified": true,
"useStaticServer": true
},
"usingComponents": true,
"lazyCodeLoading": "requiredComponents",
"useExtendedLib": {
"weui": true
},
"permission": {
"scope.userLocation": {
"desc": "你的位置信息将用于小程序位置接口的效果展示"
}
},
"optimization": {
"subPackages": true
},
"requiredPrivateInfos": [
"getLocation"
],
"__warning__": "需要在微信小程序后台配置以下域名白名单: test-1302947942.cos.ap-nanjing.myqcloud.com, weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com"
},
"mp-alipay": {
"usingComponents": true
},
"mp-baidu": {
"usingComponents": true
},
"mp-toutiao": {
"usingComponents": true
},
"h5": {
"title": "企业级应用",
"template": "index.html",
"router": {
"mode": "hash",
"base": "./"
},
"optimization": {
"treeShaking": {
"enable": true
}
},
"devServer": {
"port": 3000,
"https": false
}
},
"vueVersion": "3"
}

98
src/mixins/listMixin.js Normal file
View File

@@ -0,0 +1,98 @@
/**
* 列表页面混入
* 封装下拉刷新、上拉加载逻辑
*/
export default {
data() {
return {
list: [],
page: 1,
pageSize: 10,
total: 0,
loading: false,
finished: false,
refreshing: false
}
},
computed: {
hasMore() {
return this.list.length < this.total
},
isEmpty() {
return !this.loading && this.list.length === 0
}
},
methods: {
/**
* 加载列表数据(需在页面中实现)
*/
async loadList() {
throw new Error('loadList method must be implemented')
},
/**
* 初始化列表
*/
async initList() {
this.page = 1
this.list = []
this.finished = false
await this.loadList()
},
/**
* 下拉刷新
*/
async onRefresh() {
this.refreshing = true
this.page = 1
this.list = []
this.finished = false
try {
await this.loadList()
} finally {
this.refreshing = false
uni.stopPullDownRefresh()
}
},
/**
* 上拉加载
*/
async onLoadMore() {
if (this.loading || this.finished) return
this.page++
await this.loadList()
},
/**
* 处理列表数据
*/
handleListData(data) {
const { list, total } = data
if (this.page === 1) {
this.list = list
} else {
this.list = [...this.list, ...list]
}
this.total = total
this.finished = this.list.length >= total
}
},
onPullDownRefresh() {
this.onRefresh()
},
onReachBottom() {
this.onLoadMore()
}
}

77
src/mixins/pageMixin.js Normal file
View File

@@ -0,0 +1,77 @@
/**
* 页面通用混入
* 提供页面级别的通用功能
*/
import { useAppStore } from '@/store/modules/app'
import { useUserStore } from '@/store/modules/user'
export default {
data() {
return {
pageLoading: false
}
},
computed: {
appStore() {
return useAppStore()
},
userStore() {
return useUserStore()
},
isLogin() {
return this.userStore.hasLogin
}
},
methods: {
/**
* 显示页面加载
*/
showPageLoading() {
this.pageLoading = true
},
/**
* 隐藏页面加载
*/
hidePageLoading() {
this.pageLoading = false
},
/**
* 检查登录状态
*/
checkLogin() {
if (!this.isLogin) {
uni.navigateTo({ url: '/pages/login/index' })
return false
}
return true
},
/**
* 检查权限
*/
checkPermission(permission) {
if (!this.userStore.hasPermission(permission)) {
uni.showToast({
title: '暂无权限',
icon: 'none'
})
return false
}
return true
}
},
onShow() {
// 监听网络状态
uni.onNetworkStatusChange((res) => {
this.appStore.setNetworkType(res.networkType)
})
}
}

121
src/pages.json Normal file
View File

@@ -0,0 +1,121 @@
{
"pages": [
{
"path": "pages/aibot/index",
"style": {
"navigationBarTitleText": "AI助手",
"navigationStyle": "custom",
"enablePullDownRefresh": true
}
},
{
"path": "pages/message/index",
"style": {
"navigationBarTitleText": "心理咨询",
"navigationStyle": "custom",
"enablePullDownRefresh": true,
"backgroundTextStyle": "dark"
}
},
{
"path": "pages/consultant/detail",
"style": {
"navigationBarTitleText": "咨询师主页",
"navigationStyle": "custom"
}
},
{
"path": "pages/booking/confirm",
"style": {
"navigationBarTitleText": "预约确认",
"navigationStyle": "custom"
}
},
{
"path": "pages/target/index",
"style": {
"navigationBarTitleText": "心理测评",
"navigationStyle": "custom"
}
},
{
"path": "pages/library/index",
"style": {
"navigationBarTitleText": "课程"
}
},
{
"path": "pages/cloud/index",
"style": {
"navigationBarTitleText": "我的"
}
},
{
"path": "pages/login/index",
"style": {
"navigationBarTitleText": "登录",
"navigationStyle": "custom"
}
}
],
"tabBar": {
"color": "#A1A1AA",
"selectedColor": "#27272A",
"backgroundColor": "#ffffff",
"borderStyle": "black",
"fontSize": "11px",
"list": [
{
"pagePath": "pages/aibot/index",
"text": "AI助手",
"iconPath": "static/tabbar/AIBot.png",
"selectedIconPath": "static/tabbar/AIBot-a.png"
},
{
"pagePath": "pages/message/index",
"text": "咨询",
"iconPath": "static/tabbar/message.png",
"selectedIconPath": "static/tabbar/message-a.png"
},
{
"pagePath": "pages/target/index",
"text": "测评",
"iconPath": "static/tabbar/target.png",
"selectedIconPath": "static/tabbar/target-a.png"
},
{
"pagePath": "pages/library/index",
"text": "课程",
"iconPath": "static/tabbar/library.png",
"selectedIconPath": "static/tabbar/library-a.png"
},
{
"pagePath": "pages/cloud/index",
"text": "我的",
"iconPath": "static/tabbar/cloud.png",
"selectedIconPath": "static/tabbar/cloud-a.png"
}
]
},
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "AI学习助手",
"navigationBarBackgroundColor": "#ffffff",
"backgroundColor": "#f5f5f5"
},
"easycom": {
"autoscan": true,
"custom": {
"^u-(.*)": "uview-plus/components/u-$1/u-$1.vue"
}
},
"condition": {
"current": 0,
"list": [
{
"name": "登录页",
"path": "pages/login/index"
}
]
}
}

460
src/pages/aibot/index.vue Normal file
View File

@@ -0,0 +1,460 @@
<template>
<view class="aibot-page">
<!-- 静态背景图 -->
<image
class="static-bg"
:class="{ 'bg-hidden': !isVideoLoading }"
src="https://test-1302947942.cos.ap-nanjing.myqcloud.com/3D_model/AIBot-bg.png"
mode="aspectFill"
/>
<!-- 视频背景 -->
<video
v-if="!videoError"
id="main-video"
:src="videoSrc"
class="bg-video"
:class="{ 'video-ready': !isVideoLoading }"
:autoplay="false"
:loop="false"
:muted="true"
:show-center-play-btn="false"
:show-play-btn="false"
:controls="false"
:enable-progress-gesture="false"
:enable-play-gesture="false"
object-fit="cover"
preload="none"
@timeupdate="onTimeUpdate"
@ended="onVideoEnded"
@error="onVideoError"
@canplay="onVideoCanPlay"
@play="onVideoPlay"
/>
<!-- 内容区域 -->
<view class="content-wrapper">
<!-- 主要内容区 -->
<view class="main-content">
<view class="welcome-section">
<text class="welcome-title">你好我是AI心理师</text>
<text class="welcome-subtitle">有什么可以帮助你的吗</text>
</view>
</view>
</view>
<!-- 底部功能卡片区域 -->
<view class="function-cards-wrapper" @touchmove.stop>
<!-- 卡片容器背景 -->
<view class="cards-container-bg">
<scroll-view
class="cards-scroll"
scroll-x
show-scrollbar="{{false}}"
@touchmove.stop
>
<view class="cards-container">
<view
class="card-item"
v-for="(item, index) in functionCards"
:key="index"
@click="handleCardClick(item)"
>
<view class="card-icon">
<image v-if="item.icon" :src="item.icon" mode="aspectFit" />
<text v-else class="icon-placeholder">{{ item.emoji }}</text>
</view>
<text class="card-title">{{ item.title }}</text>
</view>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script>
import pageMixin from '@/mixins/pageMixin'
export default {
name: 'AibotPage',
mixins: [pageMixin],
data() {
return {
// 视频相关状态
videoSrc: 'https://test-1302947942.cos.ap-nanjing.myqcloud.com/%E5%A4%A7%E7%99%BD%E6%96%87%E4%BB%B6/%E9%A6%96%E9%A1%B5%E5%8A%A8%E7%94%BB%E8%A7%86%E9%A2%91/video%281%29.mp4',
currentSegment: 0,
videoContext: null,
isVideoLoading: true,
videoError: false,
// 视频段落配置基于13秒总时长
videoSegments: [
{
name: '闪现',
startTime: 0,
endTime: 4, // 闪现视频0-4秒
loop: false
},
{
name: '打招呼',
startTime: 4,
endTime: 9, // 打招呼视频4-9秒
loop: false
},
{
name: '呼吸',
startTime: 9,
endTime: 13, // 呼吸视频9-13秒
loop: true
}
],
// 视频资源配置(保留原始视频用于降级)
videoSequence: [
{
url: 'https://test-1302947942.cos.ap-nanjing.myqcloud.com/%E5%A4%A7%E7%99%BD%E6%96%87%E4%BB%B6/%E9%A6%96%E9%A1%B5%E5%8A%A8%E7%94%BB%E8%A7%86%E9%A2%91/%E9%97%AA%E7%8E%B0.mp4',
name: '闪现',
loop: false,
poster: 'https://test-1302947942.cos.ap-nanjing.myqcloud.com/3D_model/AIBot-bg.png'
},
{
url: 'https://test-1302947942.cos.ap-nanjing.myqcloud.com/%E5%A4%A7%E7%99%BD%E6%96%87%E4%BB%B6/%E9%A6%96%E9%A1%B5%E5%8A%A8%E7%94%BB%E8%A7%86%E9%A2%91/%E6%89%93%E6%8B%9B%E5%91%BC.mp4',
name: '打招呼',
loop: false,
poster: 'https://test-1302947942.cos.ap-nanjing.myqcloud.com/3D_model/AIBot-bg.png'
},
{
url: 'https://test-1302947942.cos.ap-nanjing.myqcloud.com/%E5%A4%A7%E7%99%BD%E6%96%87%E4%BB%B6/%E9%A6%96%E9%A1%B5%E5%8A%A8%E7%94%BB%E8%A7%86%E9%A2%91/%E5%91%BC%E5%90%B8.mp4',
name: '呼吸',
loop: true,
poster: 'https://test-1302947942.cos.ap-nanjing.myqcloud.com/3D_model/AIBot-bg.png'
}
],
functionCards: [
{
title: '心情日记',
icon: '',
emoji: '📝'
},
{
title: '情绪分析',
icon: '',
emoji: '😊'
},
{
title: '压力测评',
icon: '',
emoji: '<27>'
},
{
title: '冥想放松',
icon: '',
emoji: '🧘'
},
{
title: '睡眠助手',
icon: '',
emoji: '<27>'
}
]
}
},
onLoad() {
this.videoContext = uni.createVideoContext('main-video', this)
// 延迟尝试加载视频,避免开发工具限制
setTimeout(() => {
this.tryLoadVideo()
}, 1000)
},
methods: {
// 尝试加载视频
tryLoadVideo() {
if (this.videoContext && !this.videoError) {
try {
this.videoContext.play()
} catch (error) {
console.warn('视频播放失败:', error)
this.videoError = true
}
}
},
// 视频时间更新
onTimeUpdate(e) {
const currentTime = e.detail.currentTime
const currentSeg = this.videoSegments[this.currentSegment]
// 检查是否到达当前段落结束时间
if (currentTime >= currentSeg.endTime) {
if (currentSeg.loop) {
// 循环播放当前段落(呼吸视频)
this.videoContext.seek(currentSeg.startTime)
} else {
// 切换到下一段落
this.switchToNextSegment()
}
}
},
// 切换到下一个段落
switchToNextSegment() {
this.currentSegment++
if (this.currentSegment >= this.videoSegments.length) {
// 所有段落播放完毕,开始循环播放呼吸段落
this.currentSegment = 2 // 呼吸段落索引
}
const nextSeg = this.videoSegments[this.currentSegment]
this.videoContext.seek(nextSeg.startTime)
},
// 视频播放结束
onVideoEnded() {
// 重新开始循环播放呼吸段落
this.currentSegment = 2
const breathingSeg = this.videoSegments[2]
this.videoContext.seek(breathingSeg.startTime)
},
// 视频开始加载
onVideoLoadStart() {
// 视频开始加载时不做任何操作,保持静态背景显示
},
// 视频可以播放
onVideoCanPlay() {
// 视频准备好后立即开始播放和切换
if (this.videoContext && !this.videoError) {
this.videoContext.play()
}
},
// 视频开始播放
onVideoPlay() {
// 视频开始播放时,平滑切换到视频背景
this.isVideoLoading = false
},
// 视频错误处理
onVideoError() {
// 视频加载失败,标记错误状态,保持静态背景
this.videoError = true
this.isVideoLoading = true
},
handleCardClick(item) {
uni.showToast({
title: `点击了${item.title}`,
icon: 'none'
})
}
}
}
</script>
<style lang="scss" scoped>
.aibot-page {
position: relative;
min-height: 100vh;
background-color: #f6f8fe;
overflow: hidden;
// 静态背景图
.static-bg {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
opacity: 1;
transition: opacity 1s ease;
&.bg-hidden {
opacity: 0;
pointer-events: none;
}
}
// 视频背景
.bg-video {
position: fixed !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
z-index: 0;
pointer-events: none;
opacity: 0;
transition: opacity 1s ease;
// 防止video组件初始化时的尺寸跳动
transform: translateZ(0);
-webkit-transform: translateZ(0);
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
&.video-ready {
opacity: 1;
}
}
.content-wrapper {
position: relative;
z-index: 1;
min-height: 100vh;
display: flex;
flex-direction: column;
// 主要内容区
.main-content {
flex: 1;
padding: 80rpx 30rpx 20rpx;
.welcome-section {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 200rpx;
.welcome-title {
font-size: 48rpx;
font-weight: 600;
color: #ffffff;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
margin-bottom: 20rpx;
}
.welcome-subtitle {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.9);
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.08);
}
}
}
}
// 底部功能卡片区域(移到外层)
.function-cards-wrapper {
position: fixed !important;
bottom: 50px !important;
left: 50% !important;
transform: translateX(-50%) !important;
max-width: 750rpx;
width: 100%;
padding: 0 20rpx;
z-index: 10;
pointer-events: auto;
// 卡片容器背景(根据设计稿 BrS7x 节点)
.cards-container-bg {
width: 710rpx;
max-width: 100%;
height: 172rpx;
margin: 0 auto;
background: #f6f0e3;
border-radius: 40rpx;
padding: 20rpx;
backdrop-filter: blur(8.75rpx);
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.08);
overflow: hidden;
.cards-scroll {
width: 100%;
height: 100%;
white-space: nowrap;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
&::-webkit-scrollbar {
display: none;
}
.cards-container {
display: inline-flex;
height: 100%;
align-items: center;
.card-item {
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 152rpx;
height: 132rpx;
margin-right: 16rpx;
padding: 16rpx;
background: rgba(255, 255, 255, 0.9);
border-radius: 20rpx;
backdrop-filter: blur(17.5rpx);
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
flex-shrink: 0;
&:last-child {
margin-right: 0;
}
&:active {
transform: scale(0.95);
background: rgba(255, 255, 255, 0.95);
}
.card-icon {
width: 64rpx;
height: 64rpx;
margin-bottom: 8rpx;
display: flex;
align-items: center;
justify-content: center;
image {
width: 100%;
height: 100%;
}
.icon-placeholder {
font-size: 48rpx;
line-height: 1;
}
}
.card-title {
font-size: 22rpx;
font-weight: 500;
color: #703804;
text-align: center;
font-family: 'PingFang SC', -apple-system, BlinkMacSystemFont, sans-serif;
line-height: 1.2;
}
}
}
}
}
}
}
// 小程序端适配 - 底部安全区域
/* #ifdef MP-WEIXIN */
.aibot-page .function-cards-wrapper {
bottom: 84px !important;
}
/* #endif */
// iPad 和大屏适配
@media screen and (min-width: 768px) {
.aibot-page .function-cards-wrapper {
max-width: 750rpx;
.cards-container-bg {
width: 710rpx;
}
}
}
</style>

View File

@@ -0,0 +1,760 @@
<template>
<view class="booking-confirm-page">
<!-- 自定义导航栏 -->
<view class="custom-navbar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="navbar-content" :style="{ height: navBarHeight + 'px' }">
<view class="navbar-back" @tap="handleBack">
<image src="/static/icons/Vector.png" class="back-icon" mode="aspectFit" />
</view>
<view class="navbar-title">预约确认</view>
</view>
</view>
<view class="page-content" :style="{ paddingTop: (statusBarHeight + navBarHeight) + 'px' }">
<!-- 预约信息卡片挂历样式 -->
<view class="calendar-wrapper">
<!-- 挂钩装饰 -->
<view class="calendar-hooks">
<image src="/static/icons/Hook.png" class="hook-item" mode="aspectFit" />
<image src="/static/icons/Hook.png" class="hook-item" mode="aspectFit" />
</view>
<!-- 卡片内容 -->
<view class="booking-info-card">
<!-- 咨询师信息 -->
<view class="consultant-section">
<view class="consultant-info">
<image :src="bookingData.consultantAvatar" class="avatar" mode="aspectFill" />
<view class="info-text">
<text class="info-label">当前预约心理咨询师</text>
<view class="name-price">
<text class="name">{{ bookingData.consultantName }}</text>
<text class="price">¥{{ bookingData.price }}/小时</text>
</view>
</view>
</view>
</view>
<!-- 预约详情 -->
<view class="booking-details">
<view class="detail-item">
<!-- <view class="detail-header">
<text class="label">咨询时间</text>
<view class="modify-btn" @tap="handleModify">
<text class="modify-text">修改</text>
<text class="arrow"></text>
</view>
</view> -->
<text class="label">咨询时间</text>
<text class="value">{{ formatDateTime(bookingData.date, bookingData.time) }}</text>
</view>
<view class="detail-item">
<text class="label">咨询方式</text>
<text class="value">{{ bookingData.methodLabel }}</text>
</view>
<view class="detail-item">
<text class="label">咨询费用</text>
<view class="price-row">
<text class="value price">¥{{ bookingData.price }}</text>
<view class="coupon-btn" @tap="handleCoupon">
<text class="coupon-text">有可使用优惠券</text>
<text class="arrow"></text>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 预约人信息 -->
<view class="user-info">
<view class="section-title">预约人信息</view>
<view class="info-list">
<view class="info-item editable">
<text class="label">预约人姓名</text>
<view class="value-with-arrow" @tap="handleEditName">
<text class="value">{{ bookingData.name || '张三' }}</text>
<text class="arrow"></text>
</view>
</view>
<view class="info-item editable">
<text class="label">预约人年龄</text>
<view class="value-with-arrow" @tap="handleEditAge">
<text class="value">{{ bookingData.age || '18' }}</text>
<text class="arrow"></text>
</view>
</view>
<view class="info-item">
<text class="label">联系电话</text>
<view class="value-with-arrow" @tap="handleEditPhone">
<text class="value">{{ bookingData.phone }}</text>
<text class="arrow"></text>
</view>
</view>
<view class="info-item" v-if="bookingData.remark">
<text class="label">备注信息</text>
<text class="value">{{ bookingData.remark }}</text>
</view>
</view>
</view>
<!-- 支付方式 -->
<view class="payment-method">
<view class="section-title">支付方式</view>
<view class="payment-options">
<view class="payment-item selected">
<view class="payment-info">
<view class="payment-icon wechat">
<image src="/static/icons/wechat-pay-fill.png" class="icon-image" mode="aspectFit" />
</view>
<text class="payment-name">微信支付</text>
</view>
<view class="radio-checked">
<u-icon name="checkmark" color="#ffffff" size="16"></u-icon>
</view>
</view>
</view>
</view>
<!-- 支付方式 -->
<view class="payment-section">
<view class="payment-content">
<view class="total">
<text class="label">合计</text>
<text class="amount">¥{{ bookingData.price }}</text>
</view>
<view class="submit-btn" @tap="handlePay">
<text class="btn-text">去支付</text>
</view>
</view>
</view>
</view>
<!-- 优惠券弹窗 -->
<CouponPopup
:visible="showCouponPopup"
@close="showCouponPopup = false"
@select="handleCouponSelect"
/>
<!-- 修改信息弹窗 -->
<EditInfoPopup
:visible="showEditPopup"
:editType="editType"
:currentValue="currentEditValue"
@close="showEditPopup = false"
@confirm="handleEditConfirm"
/>
</view>
</template>
<script>
import CouponPopup from '@/components/common/CouponPopup.vue'
import EditInfoPopup from '@/components/common/EditInfoPopup.vue'
export default {
name: 'BookingConfirm',
components: {
CouponPopup,
EditInfoPopup
},
data() {
return {
statusBarHeight: 0,
navBarHeight: 54,
menuButtonInfo: {},
showCouponPopup: false,
selectedCoupon: null,
showEditPopup: false,
editType: 'name', // 'name' | 'phone' | 'age'
currentEditValue: '',
bookingData: {
consultantId: '',
consultantName: '',
consultantAvatar: '',
method: '',
methodLabel: '',
date: '',
time: '',
price: 0,
name: '',
phone: '',
age: '',
remark: ''
}
}
},
onLoad(options) {
this.getStatusBarHeight()
if (options.data) {
try {
this.bookingData = JSON.parse(decodeURIComponent(options.data))
} catch (error) {
console.error('解析预约数据失败:', error)
uni.showToast({
title: '数据加载失败',
icon: 'none'
})
}
}
},
methods: {
getStatusBarHeight() {
const systemInfo = uni.getSystemInfoSync()
this.statusBarHeight = systemInfo.statusBarHeight || 0
// 获取胶囊按钮信息
// #ifdef MP-WEIXIN
const menuButtonInfo = uni.getMenuButtonBoundingClientRect()
this.menuButtonInfo = menuButtonInfo
// 计算导航栏高度:胶囊按钮高度 + (胶囊按钮top - 状态栏高度) * 2
this.navBarHeight = menuButtonInfo.height + (menuButtonInfo.top - this.statusBarHeight) * 2
// #endif
// #ifndef MP-WEIXIN
this.navBarHeight = 44 // 默认导航栏高度
// #endif
},
formatDateTime(date, time) {
// 将日期格式化为 2026.03.03周二 14:00-14:50
if (!date || !time) return ''
const dateObj = new Date(date)
const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
const year = dateObj.getFullYear()
const month = String(dateObj.getMonth() + 1).padStart(2, '0')
const day = String(dateObj.getDate()).padStart(2, '0')
const week = weekDays[dateObj.getDay()]
return `${year}.${month}.${day}${week} ${time}`
},
handleBack() {
uni.navigateBack()
},
handleModify() {
uni.showToast({
title: '修改功能开发中',
icon: 'none'
})
},
handleCoupon() {
this.showCouponPopup = true
},
handleCouponSelect(coupon) {
this.selectedCoupon = coupon
uni.showToast({
title: `已选择${coupon.title}`,
icon: 'none'
})
},
handleEditName() {
this.editType = 'name'
this.currentEditValue = this.bookingData.name || '张三'
this.showEditPopup = true
},
handleEditAge() {
this.editType = 'age'
this.currentEditValue = this.bookingData.age || '18'
this.showEditPopup = true
},
handleEditPhone() {
this.editType = 'phone'
this.currentEditValue = this.bookingData.phone || ''
this.showEditPopup = true
},
handleEditConfirm(data) {
if (data.type === 'name') {
this.bookingData.name = data.value
uni.showToast({
title: '姓名修改成功',
icon: 'none'
})
} else if (data.type === 'phone') {
this.bookingData.phone = data.value
uni.showToast({
title: '手机号修改成功',
icon: 'none'
})
} else if (data.type === 'age') {
this.bookingData.age = data.value
uni.showToast({
title: '年龄修改成功',
icon: 'none'
})
}
},
handlePay() {
// TODO: 调用支付接口
uni.showToast({
title: '支付功能开发中',
icon: 'none'
})
}
}
}
</script>
<style lang="scss" scoped>
.booking-confirm-page {
min-height: 100vh;
background: linear-gradient(180deg, #fcf4e9 0%, #f8f8f8 100%);
position: relative;
// 自定义导航栏
.custom-navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
background-color: transparent;
.navbar-content {
display: flex;
align-items: center;
justify-content: center;
position: relative;
.navbar-back {
position: absolute;
left: 28rpx;
top: 50%;
transform: translateY(-50%);
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
.back-icon {
width: 16rpx;
height: 26rpx;
}
}
.navbar-title {
font-size: 36rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #3f3f46;
line-height: 1.22;
}
}
}
.page-content {
padding: 32rpx;
padding-bottom: 200rpx;
}
// 挂历包装器
.calendar-wrapper {
position: relative;
margin-bottom: 20rpx;
padding-top: 40rpx;
// 挂钩装饰
.calendar-hooks {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
justify-content: space-between;
padding: 0 40rpx;
z-index: 2;
.hook-item {
width: 32rpx;
height: 80rpx;
}
}
}
// 预约信息卡片
.booking-info-card {
background-color: #ffffff;
border-radius: 20rpx;
margin-bottom: 20rpx;
overflow: hidden;
// 咨询师信息区域
.consultant-section {
padding: 32rpx 32rpx 20rpx;
border-bottom: 1rpx solid #f4f4f5;
.consultant-info {
display: flex;
align-items: center;
gap: 28rpx;
.avatar {
width: 102rpx;
height: 102rpx;
border-radius: 50%;
border: 2.5rpx solid #fff8f2;
flex-shrink: 0;
}
.info-text {
flex: 1;
.info-label {
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #8c7e6b;
line-height: 1.5;
display: block;
margin-bottom: 8rpx;
}
.name-price {
display: flex;
align-items: center;
justify-content: space-between;
.name {
font-size: 40rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #4e3107;
line-height: 1.3;
}
.price {
font-size: 36rpx;
font-family: 'MiSans VF', 'PingFang SC', sans-serif;
font-weight: normal;
color: #9d5d00;
line-height: 1.22;
}
}
}
}
}
// 预约详情区域
.booking-details {
padding: 32rpx;
display: flex;
flex-direction: column;
gap: 32rpx;
.detail-item {
display: flex;
flex-direction: column;
gap: 12rpx;
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
.modify-btn {
display: flex;
align-items: center;
gap: 4rpx;
.modify-text {
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #a1a1aa;
line-height: 1.43;
}
.arrow {
font-size: 32rpx;
color: #a1a1aa;
line-height: 1;
transform: rotate(0deg);
}
}
}
.label {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #a1a1aa;
line-height: 1.375;
}
.value {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #27272a;
line-height: 1.375;
&.price {
color: #27272a;
font-size: 32rpx;
font-weight: 500;
}
}
.price-row {
display: flex;
align-items: center;
justify-content: space-between;
.coupon-btn {
display: flex;
align-items: center;
gap: 4rpx;
.coupon-text {
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #9d5d00;
line-height: 1.43;
}
.arrow {
font-size: 32rpx;
color: #9d5d00;
line-height: 1;
transform: rotate(0deg);
}
}
}
}
}
}
// 预约人信息
.user-info {
background-color: #ffffff;
border-radius: 20rpx;
padding: 32rpx;
margin-bottom: 20rpx;
.section-title {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #27272a;
line-height: 1.375;
margin-bottom: 32rpx;
}
.info-list {
display: flex;
flex-direction: column;
gap: 32rpx;
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
min-height: 44rpx;
.label {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #a1a1aa;
line-height: 1.375;
flex-shrink: 0;
width: 160rpx;
}
.value {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #27272a;
line-height: 1.375;
text-align: right;
flex: 1;
word-break: break-all;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
.value-with-arrow {
display: flex;
align-items: center;
gap: 20rpx;
flex: 1;
justify-content: flex-end;
.value {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #27272a;
line-height: 1.375;
text-align: right;
max-width: 400rpx;
word-break: break-all;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 1;
line-clamp: 1;
-webkit-box-orient: vertical;
}
.arrow {
font-size: 32rpx;
color: #a1a1aa;
line-height: 1;
transform: rotate(0deg);
flex-shrink: 0;
}
}
}
}
// 支付方式
.payment-method {
background-color: #ffffff;
border-radius: 20rpx;
padding: 32rpx;
margin-bottom: 20rpx;
.section-title {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #27272a;
line-height: 1.375;
margin-bottom: 32rpx;
}
.payment-options {
display: flex;
flex-direction: column;
gap: 32rpx;
}
.payment-item {
display: flex;
align-items: center;
justify-content: space-between;
.payment-info {
display: flex;
align-items: center;
gap: 20rpx;
.payment-icon {
width: 48rpx;
height: 48rpx;
border-radius: 8rpx;
display: flex;
align-items: center;
justify-content: center;
&.wechat {
background-color: #56c213;
.icon-image {
width: 32rpx;
height: 32rpx;
}
}
}
.payment-name {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #27272a;
line-height: 1.375;
}
}
.radio-checked {
width: 30rpx;
height: 30rpx;
border-radius: 50%;
background-color: #70553e;
display: flex;
align-items: center;
justify-content: center;
}
}
}
.payment-section {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background-color: #ffffff;
padding: 8rpx 40rpx;
padding-bottom: calc(8rpx + env(safe-area-inset-bottom));
box-shadow: 0 -1rpx 8rpx rgba(226, 224, 219, 0.25);
height: 112rpx;
display: flex;
align-items: center;
justify-content: center;
.payment-content {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 8rpx;
}
.section-title {
display: none;
}
.total {
display: flex;
align-items: center;
gap: -6rpx;
margin-bottom: 0;
.label {
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #a1a1aa;
line-height: 1.42;
}
.amount {
font-size: 40rpx;
font-family: 'MiSans VF', 'PingFang SC', sans-serif;
font-weight: 700;
color: #9d5d00;
line-height: 0.85;
margin-left: 0;
}
}
.submit-btn {
width: 400rpx;
height: 96rpx;
background-color: #18181b;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
.btn-text {
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #ffffff;
line-height: 1.43;
}
}
}
}
</style>

226
src/pages/cloud/index.vue Normal file
View File

@@ -0,0 +1,226 @@
<template>
<view class="cloud-page">
<!-- 用户信息 -->
<view class="user-header">
<view class="user-info">
<image :src="userInfo.avatar || defaultAvatar" class="user-avatar" @click="handleAvatarClick" />
<view class="user-detail">
<text class="user-name">{{ userInfo.username || '未登录' }}</text>
<text class="user-desc">{{ userInfo.signature || '点击登录' }}</text>
</view>
</view>
</view>
<!-- 学习数据 -->
<view class="user-stats">
<view class="stat-item">
<text class="stat-value">{{ userStats.courses }}</text>
<text class="stat-label">学习课程</text>
</view>
<view class="stat-item">
<text class="stat-value">{{ userStats.hours }}</text>
<text class="stat-label">学习时长</text>
</view>
<view class="stat-item">
<text class="stat-value">{{ userStats.tests }}</text>
<text class="stat-label">完成测评</text>
</view>
</view>
<!-- 功能菜单 -->
<view class="menu-section">
<view
v-for="item in menuList"
:key="item.id"
class="menu-item"
@click="handleMenuClick(item)"
>
<view class="menu-left">
<image :src="item.icon" class="menu-icon" />
<text class="menu-text">{{ item.name }}</text>
</view>
<text class="menu-arrow"></text>
</view>
</view>
<!-- 退出登录 -->
<view v-if="isLogin" class="logout-btn" @click="handleLogout">
退出登录
</view>
</view>
</template>
<script>
import { useUserStore } from '@/store/modules/user'
import { showModal } from '@/utils/platform'
import pageMixin from '@/mixins/pageMixin'
export default {
name: 'CloudPage',
mixins: [pageMixin],
data() {
return {
defaultAvatar: '/static/images/default-avatar.png',
userStats: {
courses: 12,
hours: 156,
tests: 8
},
menuList: [
{ id: 1, name: '我的课程', icon: '/static/icons/my-course.png', path: '/pages/my-course/index' },
{ id: 2, name: '学习记录', icon: '/static/icons/record.png', path: '/pages/record/index' },
{ id: 3, name: '我的收藏', icon: '/static/icons/favorite.png', path: '/pages/favorite/index' },
{ id: 4, name: '设置', icon: '/static/icons/setting.png', path: '/pages/setting/index' }
]
}
},
computed: {
userInfo() {
return this.userStore.userInfo || {}
}
},
methods: {
handleAvatarClick() {
if (!this.isLogin) {
uni.navigateTo({ url: '/pages/login/index' })
}
},
handleMenuClick(item) {
if (!this.checkLogin()) return
if (item.path) {
uni.navigateTo({ url: item.path })
}
},
async handleLogout() {
const confirm = await showModal({
title: '提示',
content: '确定要退出登录吗?'
})
if (confirm) {
this.userStore.logout()
uni.reLaunch({ url: '/pages/aibot/index' })
}
}
}
}
</script>
<style lang="scss" scoped>
.cloud-page {
min-height: 100vh;
background-color: $bg-color;
.user-header {
padding: 60rpx 40rpx;
background: linear-gradient(135deg, $primary-color 0%, #40a9ff 100%);
.user-info {
display: flex;
align-items: center;
.user-avatar {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
margin-right: 30rpx;
border: 4rpx solid rgba(255, 255, 255, 0.3);
}
.user-detail {
flex: 1;
.user-name {
display: block;
font-size: 36rpx;
font-weight: bold;
color: #fff;
margin-bottom: 10rpx;
}
.user-desc {
display: block;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.8);
}
}
}
}
.user-stats {
display: flex;
background-color: #fff;
padding: 40rpx 0;
margin-bottom: 20rpx;
.stat-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
.stat-value {
font-size: 36rpx;
font-weight: bold;
color: $text-color;
margin-bottom: 10rpx;
}
.stat-label {
font-size: 24rpx;
color: $text-color-secondary;
}
}
}
.menu-section {
background-color: #fff;
margin-bottom: 20rpx;
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 30rpx 40rpx;
border-bottom: 1rpx solid $border-color-light;
&:last-child {
border-bottom: none;
}
.menu-left {
display: flex;
align-items: center;
.menu-icon {
width: 40rpx;
height: 40rpx;
margin-right: 20rpx;
}
.menu-text {
font-size: 28rpx;
color: $text-color;
}
}
.menu-arrow {
font-size: 40rpx;
color: $text-color-placeholder;
}
}
}
.logout-btn {
margin: 40rpx;
padding: 30rpx;
text-align: center;
background-color: #fff;
color: $error-color;
font-size: 28rpx;
border-radius: $border-radius-base;
}
}
</style>

View File

@@ -0,0 +1,998 @@
<template>
<view class="consultant-detail-page">
<!-- 背景图层 -->
<view class="bg-layer"></view>
<!-- 自定义导航栏 -->
<view class="custom-navbar" :style="{ paddingTop: statusBarHeight + 'px', backgroundColor: navbarBgColor }">
<view class="navbar-content" :style="{ height: navBarHeight + 'px' }">
<view class="navbar-back" @tap="handleBack">
<image src="/static/icons/Vector.png" class="back-icon" mode="aspectFit" />
</view>
<view class="navbar-title">咨询师主页</view>
</view>
</view>
<!-- 1. 咨询师基本信息 -->
<view class="consultant-header" :style="{ marginTop: (statusBarHeight + navBarHeight) + 'px' }">
<!-- 右侧头像 -->
<view class="avatar-section">
<image :src="consultant.avatar" class="avatar" mode="aspectFill" />
</view>
<view class="info-left">
<!-- 姓名和等级 -->
<view class="name-row">
<text class="name">{{ consultant.name }}</text>
<image v-if="consultant.levelIcon" :src="consultant.levelIcon" class="level-badge" mode="aspectFit" />
</view>
<!-- 职称 -->
<text class="title">{{ consultant.title }}</text>
<!-- 简介 -->
<view class="intro-section">
<text class="intro-label">简介</text>
<view class="divider"></view>
<view class="intro-content">
<text
class="intro-text"
:class="{ expanded: isIntroExpanded }"
>{{ consultant.briefIntro }}</text>
<text class="intro-more" @tap="toggleIntro">{{ isIntroExpanded ? '收起' : '全部' }}</text>
</view>
</view>
</view>
</view>
<!-- 擅长领域标签 - 独立容器 -->
<view class="specialty-container">
<view class="specialty-section">
<text class="specialty-label">擅长</text>
<view class="divider"></view>
<view class="specialty-tags">
<view
v-for="(tag, index) in consultant.specialtyTags"
:key="index"
class="specialty-tag"
>
<text class="specialty-tag-text">{{ tag }}</text>
</view>
</view>
</view>
</view>
<!-- 2. 咨询师信息卡片 -->
<view class="info-card">
<!-- 统计数据 -->
<view class="stats-row">
<view class="stat-item">
<view class="stat-value-row">
<text class="stat-number">{{ consultant.serviceHours }}</text>
<text class="stat-unit">小时</text>
</view>
<text class="stat-label">服务时长</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<view class="stat-value-row">
<text class="stat-number">{{ consultant.workYears }}</text>
<text class="stat-unit"></text>
</view>
<text class="stat-label">从业年限</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<view class="stat-value-row">
<text class="stat-number">{{ consultant.trainingCount }}</text>
<text class="stat-unit"></text>
</view>
<text class="stat-label">培训经历</text>
</view>
</view>
<!-- 分隔线 -->
<view class="card-divider"></view>
<!-- 地址和咨询方式 -->
<view class="location-row">
<view class="location-info">
<image src="/static/icons/map-pin-line.png" class="location-icon" mode="aspectFit" />
<text class="location-text">{{ consultant.location }}</text>
</view>
<text class="consult-methods">{{ consultant.consultMethods }}</text>
</view>
<!-- 预约时间和价格 -->
<view class="booking-row">
<view class="booking-time">
<text class="booking-text">{{ consultant.availableTime }}</text>
<text class="booking-arrow"></text>
</view>
<text class="booking-price">¥{{ consultant.price }}/</text>
</view>
</view>
<!-- 3. 个人简介/可约时间卡片 -->
<view class="profile-card">
<!-- 标签切换栏 -->
<view class="tabs-section">
<view class="tab-item" :class="{ active: activeTab === 'profile' }" @tap="switchTab('profile')">
<text class="tab-text">个人简介</text>
<view class="tab-indicator"></view>
</view>
<view class="tab-item" :class="{ active: activeTab === 'schedule' }" @tap="switchTab('schedule')">
<text class="tab-text">可约时间</text>
<view class="tab-indicator"></view>
</view>
</view>
<!-- 内容区域 -->
<view class="content-section">
<!-- 个人简介标签页 -->
<view v-if="activeTab === 'profile'" class="profile-content">
<!-- 个人介绍 -->
<view class="info-block">
<view class="block-title">
<view class="title-bar"></view>
<text class="title-text">个人介绍</text>
</view>
<text class="block-content">{{ consultant.introduction }}</text>
</view>
<!-- 擅长领域 -->
<view class="info-block">
<view class="block-title">
<view class="title-bar"></view>
<text class="title-text">擅长领域</text>
</view>
<text class="block-content">{{ consultant.specialtyDescription }}</text>
</view>
<!-- 个人头衔 -->
<view class="info-block">
<view class="block-title">
<view class="title-bar"></view>
<text class="title-text">个人头衔</text>
</view>
<text class="block-content">{{ consultant.titles }}</text>
</view>
</view>
<!-- 可约时间标签页 -->
<view v-if="activeTab === 'schedule'" class="schedule-content">
<ScheduleCalendar
:consultantId="consultantId"
@time-select="handleTimeSelect"
/>
</view>
</view>
</view>
<!-- 底部预约按钮 -->
<view class="footer-bar">
<view class="footer-content">
<view class="follow-btn" @tap="handleFollow">
<view class="follow-icon-wrapper">
<text class="follow-icon">{{ isFollowed ? '♥' : '♡' }}</text>
</view>
<text class="follow-text">{{ isFollowed ? '已关注' : '关注' }}</text>
</view>
<view class="btn-book" @tap="handleBook">
<text class="btn-text">立即预约</text>
</view>
</view>
</view>
<!-- 预约弹窗 -->
<BookingPopup
:visible="showBookingPopup"
:consultant="consultant"
@close="handleCloseBooking"
/>
</view>
</template>
<script>
import ScheduleCalendar from '../../components/consultant/ScheduleCalendar.vue'
import BookingPopup from '../../components/consultant/BookingPopup.vue'
export default {
name: 'ConsultantDetail',
components: {
ScheduleCalendar,
BookingPopup
},
data() {
return {
statusBarHeight: 0,
navBarHeight: 0,
menuButtonInfo: {},
consultantId: '',
isIntroExpanded: false, // 简介是否展开
activeTab: 'profile', // 当前激活的标签profile-个人简介schedule-可约时间
navbarBgColor: 'transparent', // 导航栏背景色
isFollowed: false, // 是否已关注
showBookingPopup: false, // 是否显示预约弹窗
consultant: {
id: '',
avatar: '',
name: '',
levelIcon: '',
title: '',
briefIntro: '',
specialtyTags: [],
serviceHours: 0,
workYears: 0,
trainingCount: 0,
location: '',
consultMethods: '',
availableTime: '',
introduction: '',
specialtyDescription: '',
titles: '',
consultHours: 0,
rating: 0,
serviceCount: 0,
specialties: [],
certifications: [],
consultType: '',
duration: 0,
price: 0
}
}
},
onLoad(options) {
this.getStatusBarHeight()
if (options.id) {
this.consultantId = options.id
} else {
// 如果没有传 id使用默认 id 用于测试
this.consultantId = 'test-consultant-001'
}
this.loadConsultantDetail()
},
onPageScroll(e) {
// 当滚动超过50px时显示背景色
if (e.scrollTop > 50) {
this.navbarBgColor = '#FEF2E9'
} else {
this.navbarBgColor = 'transparent'
}
},
methods: {
/**
* 获取状态栏高度
*/
getStatusBarHeight() {
const systemInfo = uni.getSystemInfoSync()
this.statusBarHeight = systemInfo.statusBarHeight || 0
// 获取胶囊按钮信息
// #ifdef MP-WEIXIN
const menuButtonInfo = uni.getMenuButtonBoundingClientRect()
this.menuButtonInfo = menuButtonInfo
// 计算导航栏高度:胶囊按钮高度 + (胶囊按钮top - 状态栏高度) * 2
this.navBarHeight = menuButtonInfo.height + (menuButtonInfo.top - this.statusBarHeight) * 2
// #endif
// #ifndef MP-WEIXIN
this.navBarHeight = 44 // 默认导航栏高度
// #endif
},
/**
* 返回上一页
*/
handleBack() {
uni.navigateBack()
},
/**
* 切换简介展开/收起
*/
toggleIntro() {
this.isIntroExpanded = !this.isIntroExpanded
},
/**
* 切换标签
*/
switchTab(tab) {
this.activeTab = tab
},
/**
* 加载咨询师详情
*/
async loadConsultantDetail() {
try {
// TODO: 调用真实接口
// const res = await getConsultantDetail(this.consultantId)
// this.consultant = res.data
// Mock 数据
this.consultant = {
id: this.consultantId,
avatar: 'https://test-1302947942.cos.ap-nanjing.myqcloud.com/models/b2c519f435804eaa9005318112c1a346.jpg',
name: '李书萍',
levelIcon: '/static/icons/Senior-Teacher.png',
title: '国家二级心理咨询师',
briefIntro: '大白心理创始人,在心理咨询、家庭教育、大学生心理健康等领域有丰富的实践经验。致力于帮助来访者解决情绪困扰、人际关系问题,实现个人成长与心理健康。',
specialtyTags: ['个人成长', '亲子关系', '亲密关系', '职场发展', '焦虑抑郁'],
serviceHours: 2300,
workYears: 16,
trainingCount: 7,
location: '杭州市钱塘区',
consultMethods: '视频/语音/线下咨询',
availableTime: '可预约明天8:00',
introduction: '大白心理创始人在心理咨询、家庭教育、大学生心理教学工作等方面有着丰富的经验近年来与富士康、部队、消防队、学校、公交公司等企业或单位进行深度合作将心理健康AI智能化服务广泛推广和应用。',
specialtyDescription: '抑郁、焦虑、强迫行为/想法、睡眠问题、丧失与哀伤辅导、广场恐惧/社交焦虑',
titles: '*国家二级心理咨询师\n*美国先锋理工学院管理心理学硕士研究生\n*北京大学心理系应用心理学硕士研究生\n*富士康、比亚迪心理服务中心负责人',
consultHours: 1200,
rating: 98,
serviceCount: 350,
specialties: ['个人成长', '情绪管理', '焦虑抑郁', '亲子教育', '职场困扰'],
introduction: '从事心理咨询工作5年累计咨询时长1200+小时。擅长运用认知行为疗法、人本主义疗法等多种咨询技术,帮助来访者解决情绪困扰、人际关系问题、个人成长等议题。\n\n我相信每个人都有自我疗愈的能力咨询师的角色是陪伴和引导。在咨询中我会为您提供一个安全、温暖、不被评判的空间让您可以自由地表达和探索。',
certifications: [
'国家二级心理咨询师',
'中国心理学会会员',
'认知行为治疗师CBT',
'家庭治疗师',
'持续接受个人体验和督导'
],
consultType: '视频咨询',
duration: 50,
price: 600
}
} catch (error) {
console.error('加载咨询师详情失败:', error)
uni.showToast({
title: '加载失败',
icon: 'none'
})
}
},
/**
* 关注/取消关注
*/
handleFollow() {
this.isFollowed = !this.isFollowed
uni.showToast({
title: this.isFollowed ? '关注成功' : '已取消关注',
icon: 'none'
})
// TODO: 调用关注接口
},
/**
* 立即预约
*/
handleBook() {
this.showBookingPopup = true
},
/**
* 关闭预约弹窗
*/
handleCloseBooking() {
this.showBookingPopup = false
},
/**
* 处理时间段选择
*/
handleTimeSelect(selection) {
console.log('选择时间:', selection)
uni.showToast({
title: `已选择 ${selection.date} ${selection.time}`,
icon: 'none'
})
// TODO: 可以在这里处理预约逻辑或跳转到确认页面
}
},
/**
* 分享配置
*/
onShareAppMessage() {
return {
title: `${this.consultant.name} - ${this.consultant.title}`,
path: `/pages/consultant/detail?id=${this.consultantId}`,
imageUrl: this.consultant.avatar
}
},
/**
* 分享到朋友圈
*/
onShareTimeline() {
return {
title: `${this.consultant.name} - ${this.consultant.title}`,
query: `id=${this.consultantId}`,
imageUrl: this.consultant.avatar
}
}
}
</script>
<style lang="scss" scoped>
.consultant-detail-page {
min-height: 100vh;
position: relative;
padding-bottom: 120rpx;
// 背景图层
.bg-layer {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: -1;
background-image: url('https://test-1302947942.cos.ap-nanjing.myqcloud.com/%E5%A4%A7%E7%99%BD%E6%96%87%E4%BB%B6/%E5%92%A8%E8%AF%A2%E5%B8%88%E8%AF%A6%E6%83%85%E9%A1%B5-png/bg.png');
background-size: cover;
background-position: top center;
background-repeat: no-repeat;
}
// 自定义导航栏
.custom-navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
transition: background-color 0.3s ease;
.navbar-content {
display: flex;
align-items: center;
justify-content: center;
position: relative;
.navbar-back {
position: absolute;
left: 28rpx;
top: 50%;
transform: translateY(-50%);
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
.back-icon {
width: 16rpx;
height: 26rpx;
}
}
.navbar-title {
font-size: 36rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #3f3f46;
line-height: 1.2;
}
}
}
// 1. 咨询师基本信息
.consultant-header {
padding: 32rpx 32rpx 16rpx;
position: relative;
overflow: visible;
// 右侧头像 - 移到前面,使用绝对定位
.avatar-section {
position: absolute;
top: 32rpx;
right: 32rpx;
z-index: 1;
.avatar {
width: 200rpx;
height: 200rpx;
border-radius: 50%;
}
}
.info-left {
padding-right: 224rpx;
display: flex;
flex-direction: column;
gap: 16rpx;
// 姓名和等级
.name-row {
display: flex;
align-items: center;
gap: 16rpx;
.name {
font-size: 48rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #3d2314;
line-height: 1.2;
}
.level-badge {
width: 110rpx;
height: 36rpx;
}
}
// 职称
.title {
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #4e3107;
line-height: 1;
}
// 简介
.intro-section {
display: flex;
align-items: flex-start;
gap: 8rpx;
margin-top: 8rpx;
.intro-label {
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #000000;
line-height: 1.6;
flex-shrink: 0;
}
.divider {
width: 2rpx;
height: 45rpx;
background-color: #e2d0c4;
flex-shrink: 0;
}
.intro-content {
flex: 1;
min-width: 0;
position: relative;
.intro-text {
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #a89e96;
line-height: 1.6;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
word-break: break-all;
padding-right: 80rpx;
&.expanded {
display: block;
-webkit-line-clamp: unset;
padding-right: 0;
}
}
.intro-more {
position: absolute;
right: 0;
bottom: 0;
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #09090b;
line-height: 1.6;
background: linear-gradient(to right, transparent, #fef2e9 20%, #fef2e9);
padding-left: 20rpx;
}
}
}
}
}
// 擅长领域 - 独立容器
.specialty-container {
padding: 0 32rpx 16rpx;
.specialty-section {
display: flex;
align-items: center;
gap: 8rpx;
.specialty-label {
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #000000;
line-height: 1.6;
flex-shrink: 0;
}
.divider {
width: 2rpx;
height: 38rpx;
background-color: #e2d0c4;
flex-shrink: 0;
}
.specialty-tags {
flex: 1;
min-width: 0;
display: flex;
gap: 12rpx;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
// 隐藏滚动条
&::-webkit-scrollbar {
display: none;
}
.specialty-tag {
flex-shrink: 0;
padding: 8rpx 16rpx;
background-color: #ffffff;
border-radius: 8rpx;
height: 38rpx;
display: flex;
align-items: center;
justify-content: center;
.specialty-tag-text {
font-size: 20rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #70553e;
line-height: 1.6;
white-space: nowrap;
}
}
}
}
}
// 2. 咨询师信息卡片
.info-card {
margin: 32rpx;
padding: 40rpx;
background: linear-gradient(180deg, #fff4ed 0%, #fffcfa 38.29%);
border: 1rpx solid #ffffff;
border-radius: 20rpx;
// 统计数据行
.stats-row {
display: flex;
align-items: center;
justify-content: space-between;
.stat-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
.stat-value-row {
display: flex;
align-items: baseline;
gap: 4rpx;
.stat-number {
font-size: 40rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #18181b;
line-height: 1;
}
.stat-unit {
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #18181b;
line-height: 1;
}
}
.stat-label {
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #6b7280;
line-height: 1;
}
}
.stat-divider {
width: 1rpx;
height: 74rpx;
background-color: #e4e4e7;
}
}
// 分隔线
.card-divider {
height: 1rpx;
background-color: #f4f4f5;
margin: 20rpx 0;
}
// 地址和咨询方式
.location-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
.location-info {
display: flex;
align-items: center;
gap: 8rpx;
.location-icon {
width: 28rpx;
height: 28rpx;
}
.location-text {
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #52525b;
line-height: 1;
}
}
.consult-methods {
padding: 4rpx 16rpx;
background-color: #f9f7f4;
border-radius: 6rpx;
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #70553e;
line-height: 1.5;
}
}
// 预约时间和价格
.booking-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 40rpx;
height: 106rpx;
background-color: #fff8f3;
border-radius: 0 0 20rpx 20rpx;
margin: 0 -40rpx -40rpx;
.booking-time {
display: flex;
align-items: center;
gap: 4rpx;
.booking-text {
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #a4674b;
line-height: 1.43;
}
.booking-arrow {
font-size: 32rpx;
font-weight: 300;
color: #a4674b;
line-height: 1;
}
}
.booking-price {
font-size: 36rpx;
font-family: 'MiSans VF', 'PingFang SC', sans-serif;
font-weight: 400;
color: #9d5d00;
line-height: 1.22;
}
}
}
// 3. 个人简介/可约时间卡片
.profile-card {
margin: 32rpx;
background: linear-gradient(180deg, #fffbf8 0%, #ffffff 100%);
border: 1rpx solid #ffffff;
border-radius: 20rpx;
box-shadow: 0 2rpx 33.6rpx rgba(229, 229, 229, 0.38);
overflow: hidden;
// 标签切换栏
.tabs-section {
display: flex;
align-items: center;
gap: 32rpx;
padding: 24rpx 40rpx;
background-color: transparent;
.tab-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
position: relative;
.tab-text {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #71717a;
line-height: 1.375;
}
.tab-indicator {
width: 6rpx;
height: 56rpx;
border-radius: 20rpx;
background-color: transparent;
transform: rotate(-90deg);
}
&.active {
.tab-text {
color: #09090b;
}
.tab-indicator {
background-color: #9d5d00;
}
}
}
}
// 内容区域
.content-section {
padding: 0 40rpx 40rpx;
// 个人简介内容
.profile-content {
display: flex;
flex-direction: column;
gap: 40rpx;
.info-block {
display: flex;
flex-direction: column;
gap: 20rpx;
.block-title {
display: flex;
align-items: center;
gap: 8rpx;
.title-bar {
width: 4rpx;
height: 20rpx;
background-color: #9d5d00;
border-radius: 16rpx;
}
.title-text {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #18181b;
line-height: 1.25;
}
}
.block-content {
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #6b7280;
line-height: 1.57;
white-space: pre-wrap;
}
}
}
// 可约时间内容
.schedule-content {
display: flex;
flex-direction: column;
.debug-text {
font-size: 28rpx;
color: #ff0000;
padding: 20rpx;
text-align: center;
}
}
}
}
// 底部预约栏
.footer-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: #ffffff;
box-shadow: 0 -1rpx 0 0 rgba(226, 224, 219, 0.25);
z-index: 100;
height: 112rpx;
.footer-content {
display: flex;
align-items: center;
justify-content: space-between;
height: 100%;
padding: 8rpx 40rpx;
padding-bottom: calc(8rpx + env(safe-area-inset-bottom));
gap: 8rpx;
.follow-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 0;
background-color: transparent;
.follow-icon-wrapper {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
.follow-icon {
font-size: 48rpx;
color: #71717a;
line-height: 1;
}
}
.follow-text {
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #71717a;
line-height: 1.5;
text-align: center;
}
}
.btn-book {
width: 558rpx;
height: 96rpx;
display: flex;
align-items: center;
justify-content: center;
background-color: #18181b;
border-radius: 12rpx;
padding: 8rpx;
flex-shrink: 0;
.btn-text {
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #ffffff;
line-height: 1.43;
}
}
}
}
}
</style>

288
src/pages/index/index.vue Normal file
View File

@@ -0,0 +1,288 @@
<template>
<view class="index-page">
<view class="page-header">
<text class="header-title">首页</text>
</view>
<view class="page-content">
<!-- Banner轮播 -->
<view class="banner-section">
<swiper class="banner-swiper" indicator-dots autoplay circular>
<swiper-item v-for="(item, index) in bannerList" :key="index">
<image :src="item.image" class="banner-image" mode="aspectFill" />
</swiper-item>
</swiper>
</view>
<!-- 功能入口 -->
<view class="menu-section">
<view
v-for="item in menuList"
:key="item.id"
class="menu-item"
@click="handleMenuClick(item)"
>
<image :src="item.icon" class="menu-icon" />
<text class="menu-text">{{ item.name }}</text>
</view>
</view>
<!-- 内容列表 -->
<view class="content-section">
<view class="section-title">推荐内容</view>
<view v-for="item in contentList" :key="item.id" class="content-item card">
<image :src="item.cover" class="content-cover" mode="aspectFill" />
<view class="content-info">
<text class="content-title">{{ item.title }}</text>
<text class="content-desc">{{ item.description }}</text>
<view class="content-meta">
<text class="meta-text">{{ item.author }}</text>
<text class="meta-text">{{ item.createTime }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 加载状态 -->
<LoadingView :visible="loading" />
<EmptyView v-if="isEmpty" text="暂无内容" />
</view>
</template>
<script>
import pageMixin from '@/mixins/pageMixin'
import LoadingView from '@/components/common/LoadingView.vue'
import EmptyView from '@/components/common/EmptyView.vue'
export default {
name: 'IndexPage',
components: {
LoadingView,
EmptyView
},
mixins: [pageMixin],
data() {
return {
loading: false,
bannerList: [],
menuList: [
{ id: 1, name: '功能1', icon: '/static/icons/menu1.png', path: '/pages/demo/index' },
{ id: 2, name: '功能2', icon: '/static/icons/menu2.png', path: '/pages/demo/index' },
{ id: 3, name: '功能3', icon: '/static/icons/menu3.png', path: '/pages/demo/index' },
{ id: 4, name: '功能4', icon: '/static/icons/menu4.png', path: '/pages/demo/index' }
],
contentList: []
}
},
computed: {
isEmpty() {
return !this.loading && this.contentList.length === 0
}
},
onLoad() {
this.loadData()
},
onPullDownRefresh() {
this.loadData().then(() => {
uni.stopPullDownRefresh()
})
},
methods: {
/**
* 加载数据
*/
async loadData() {
this.loading = true
try {
// 模拟数据加载
await this.loadBanner()
await this.loadContent()
} finally {
this.loading = false
}
},
/**
* 加载Banner
*/
async loadBanner() {
// 模拟接口请求
return new Promise((resolve) => {
setTimeout(() => {
this.bannerList = [
{ id: 1, image: '/static/images/banner1.jpg' },
{ id: 2, image: '/static/images/banner2.jpg' },
{ id: 3, image: '/static/images/banner3.jpg' }
]
resolve()
}, 500)
})
},
/**
* 加载内容
*/
async loadContent() {
// 模拟接口请求
return new Promise((resolve) => {
setTimeout(() => {
this.contentList = [
{
id: 1,
title: '示例内容1',
description: '这是一段示例描述文字',
cover: '/static/images/cover1.jpg',
author: '作者1',
createTime: '2024-01-01'
},
{
id: 2,
title: '示例内容2',
description: '这是一段示例描述文字',
cover: '/static/images/cover2.jpg',
author: '作者2',
createTime: '2024-01-02'
}
]
resolve()
}, 500)
})
},
/**
* 菜单点击
*/
handleMenuClick(item) {
if (item.path) {
uni.navigateTo({ url: item.path })
}
}
}
}
</script>
<style lang="scss" scoped>
.index-page {
min-height: 100vh;
background-color: $bg-color;
.page-header {
padding: 40rpx;
background-color: $primary-color;
color: #fff;
.header-title {
font-size: 36rpx;
font-weight: bold;
}
}
.page-content {
padding-bottom: 40rpx;
}
.banner-section {
margin-bottom: 40rpx;
.banner-swiper {
width: 100%;
height: 360rpx;
}
.banner-image {
width: 100%;
height: 100%;
}
}
.menu-section {
display: flex;
flex-wrap: wrap;
padding: 0 40rpx;
margin-bottom: 40rpx;
.menu-item {
width: 25%;
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 40rpx;
.menu-icon {
width: 80rpx;
height: 80rpx;
margin-bottom: 16rpx;
}
.menu-text {
font-size: 24rpx;
color: $text-color-secondary;
}
}
}
.content-section {
padding: 0 40rpx;
.section-title {
font-size: 32rpx;
font-weight: bold;
margin-bottom: 30rpx;
}
.content-item {
display: flex;
margin-bottom: 30rpx;
.content-cover {
width: 200rpx;
height: 150rpx;
border-radius: $border-radius-base;
margin-right: 20rpx;
}
.content-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
.content-title {
font-size: 30rpx;
font-weight: bold;
@include multi-ellipsis(2);
}
.content-desc {
font-size: 24rpx;
color: $text-color-secondary;
@include ellipsis;
}
.content-meta {
display: flex;
justify-content: space-between;
.meta-text {
font-size: 22rpx;
color: $text-color-placeholder;
}
}
}
}
}
}
</style>
}
}
}
}
}
</style>
}
}
}
}
}
</style>

226
src/pages/library/index.vue Normal file
View File

@@ -0,0 +1,226 @@
<template>
<view class="library-page">
<view class="search-bar">
<input
v-model="searchText"
class="search-input"
placeholder="搜索课程"
/>
</view>
<view class="category-tabs">
<view
v-for="(cat, index) in categories"
:key="index"
class="tab-item"
:class="{ active: currentCategory === index }"
@click="currentCategory = index"
>
{{ cat }}
</view>
</view>
<view class="course-list">
<view
v-for="course in courseList"
:key="course.id"
class="course-item"
@click="handleCourseClick(course)"
>
<image :src="course.cover" class="course-cover" mode="aspectFill" />
<view class="course-info">
<text class="course-title">{{ course.title }}</text>
<text class="course-teacher">{{ course.teacher }}</text>
<view class="course-meta">
<text class="meta-item">{{ course.students }}人学习</text>
<text class="meta-item">{{ course.lessons }}课时</text>
</view>
<view class="course-footer">
<text class="course-price">¥{{ course.price }}</text>
<view class="course-tag" :class="course.tag">
{{ course.tagText }}
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import pageMixin from '@/mixins/pageMixin'
export default {
name: 'LibraryPage',
mixins: [pageMixin],
data() {
return {
searchText: '',
currentCategory: 0,
categories: ['全部', '数学', '英语', '物理', '化学'],
courseList: [
{
id: 1,
title: '高中数学核心知识点精讲',
teacher: '张老师',
cover: '/static/images/course1.jpg',
students: 1280,
lessons: 48,
price: 299,
tag: 'hot',
tagText: '热门'
},
{
id: 2,
title: '英语语法系统课程',
teacher: '李老师',
cover: '/static/images/course2.jpg',
students: 856,
lessons: 36,
price: 199,
tag: 'new',
tagText: '新课'
},
{
id: 3,
title: '物理实验与应用',
teacher: '王老师',
cover: '/static/images/course3.jpg',
students: 642,
lessons: 24,
price: 159,
tag: '',
tagText: ''
}
]
}
},
methods: {
handleCourseClick(course) {
uni.navigateTo({
url: `/pages/course-detail/index?id=${course.id}`
})
}
}
}
</script>
<style lang="scss" scoped>
.library-page {
min-height: 100vh;
background-color: $bg-color;
.search-bar {
padding: 20rpx 30rpx;
background-color: #fff;
.search-input {
height: 70rpx;
padding: 0 30rpx;
background-color: $bg-color;
border-radius: 35rpx;
font-size: 28rpx;
}
}
.category-tabs {
display: flex;
padding: 20rpx 30rpx;
background-color: #fff;
border-bottom: 1rpx solid $border-color-light;
overflow-x: auto;
.tab-item {
padding: 10rpx 30rpx;
margin-right: 20rpx;
font-size: 28rpx;
color: $text-color-secondary;
white-space: nowrap;
border-radius: 30rpx;
&.active {
background-color: $primary-color;
color: #fff;
}
}
}
.course-list {
padding: 30rpx;
.course-item {
display: flex;
background-color: #fff;
border-radius: $border-radius-base;
margin-bottom: 20rpx;
overflow: hidden;
.course-cover {
width: 240rpx;
height: 180rpx;
flex-shrink: 0;
}
.course-info {
flex: 1;
padding: 20rpx;
display: flex;
flex-direction: column;
.course-title {
font-size: 30rpx;
font-weight: bold;
margin-bottom: 10rpx;
@include multi-ellipsis(2);
}
.course-teacher {
font-size: 24rpx;
color: $text-color-secondary;
margin-bottom: 10rpx;
}
.course-meta {
display: flex;
gap: 20rpx;
margin-bottom: 10rpx;
.meta-item {
font-size: 22rpx;
color: $text-color-placeholder;
}
}
.course-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: auto;
.course-price {
font-size: 32rpx;
font-weight: bold;
color: $error-color;
}
.course-tag {
padding: 4rpx 16rpx;
font-size: 20rpx;
border-radius: 4rpx;
&.hot {
background-color: #fff3e0;
color: #ff6f00;
}
&.new {
background-color: #e3f2fd;
color: #1976d2;
}
}
}
}
}
}
}
</style>

293
src/pages/login/index.vue Normal file
View File

@@ -0,0 +1,293 @@
<template>
<view class="login-page">
<view class="login-header">
<text class="login-title">欢迎登录</text>
<text class="login-subtitle">企业级多端应用</text>
</view>
<view class="login-form">
<view class="form-item">
<input
v-model="formData.phone"
class="form-input"
type="number"
maxlength="11"
placeholder="请输入手机号"
/>
</view>
<view v-if="loginType === 'password'" class="form-item">
<input
v-model="formData.password"
class="form-input"
type="password"
placeholder="请输入密码"
/>
</view>
<view v-else class="form-item form-item-code">
<input
v-model="formData.code"
class="form-input"
type="number"
maxlength="6"
placeholder="请输入验证码"
/>
<view class="code-btn" :class="{ disabled: countdown > 0 }" @click="handleSendCode">
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
</view>
</view>
<view class="login-type" @click="toggleLoginType">
{{ loginType === 'password' ? '验证码登录' : '密码登录' }}
</view>
<view class="login-btn" @click="handleLogin">
登录
</view>
<view class="login-footer">
<text class="footer-link" @click="handleRegister">注册账号</text>
<text class="footer-link" @click="handleForgetPassword">忘记密码</text>
</view>
</view>
</view>
</template>
<script>
import { useUserStore } from '@/store/modules/user'
import { loginByPassword, loginByCode, sendVerifyCode } from '@/api/modules/auth'
import { isPhone, isNotEmpty } from '@/utils/validator'
import { showToast } from '@/utils/platform'
export default {
name: 'LoginPage',
data() {
return {
loginType: 'password', // password | code
formData: {
phone: '',
password: '',
code: ''
},
countdown: 0,
timer: null
}
},
computed: {
userStore() {
return useUserStore()
}
},
methods: {
/**
* 切换登录方式
*/
toggleLoginType() {
this.loginType = this.loginType === 'password' ? 'code' : 'password'
this.formData.password = ''
this.formData.code = ''
},
/**
* 发送验证码
*/
async handleSendCode() {
if (this.countdown > 0) return
if (!isPhone(this.formData.phone)) {
showToast('请输入正确的手机号')
return
}
try {
await sendVerifyCode(this.formData.phone)
showToast('验证码已发送', 'success')
this.startCountdown()
} catch (error) {
console.error('发送验证码失败:', error)
}
},
/**
* 开始倒计时
*/
startCountdown() {
this.countdown = 60
this.timer = setInterval(() => {
this.countdown--
if (this.countdown <= 0) {
clearInterval(this.timer)
}
}, 1000)
},
/**
* 登录
*/
async handleLogin() {
// 校验手机号
if (!isPhone(this.formData.phone)) {
showToast('请输入正确的手机号')
return
}
// 校验密码或验证码
if (this.loginType === 'password') {
if (!isNotEmpty(this.formData.password)) {
showToast('请输入密码')
return
}
} else {
if (!isNotEmpty(this.formData.code)) {
showToast('请输入验证码')
return
}
}
try {
let result
if (this.loginType === 'password') {
result = await loginByPassword({
phone: this.formData.phone,
password: this.formData.password
})
} else {
result = await loginByCode({
phone: this.formData.phone,
code: this.formData.code
})
}
// 保存登录信息
this.userStore.login(result)
showToast('登录成功', 'success')
// 跳转到首页
setTimeout(() => {
uni.switchTab({ url: '/pages/index/index' })
}, 1500)
} catch (error) {
console.error('登录失败:', error)
}
},
/**
* 注册
*/
handleRegister() {
uni.navigateTo({ url: '/pages/register/index' })
},
/**
* 忘记密码
*/
handleForgetPassword() {
uni.navigateTo({ url: '/pages/forget-password/index' })
}
},
beforeUnmount() {
if (this.timer) {
clearInterval(this.timer)
}
}
}
</script>
<style lang="scss" scoped>
.login-page {
min-height: 100vh;
background: linear-gradient(135deg, $primary-color 0%, #40a9ff 100%);
padding: 100rpx 60rpx;
.login-header {
text-align: center;
margin-bottom: 100rpx;
.login-title {
display: block;
font-size: 48rpx;
font-weight: bold;
color: #fff;
margin-bottom: 20rpx;
}
.login-subtitle {
display: block;
font-size: 28rpx;
color: rgba(255, 255, 255, 0.8);
}
}
.login-form {
.form-item {
margin-bottom: 40rpx;
&.form-item-code {
display: flex;
align-items: center;
.form-input {
flex: 1;
margin-right: 20rpx;
}
.code-btn {
padding: 0 30rpx;
height: 88rpx;
line-height: 88rpx;
background-color: rgba(255, 255, 255, 0.2);
color: #fff;
font-size: 24rpx;
border-radius: $border-radius-base;
white-space: nowrap;
&.disabled {
opacity: 0.5;
}
}
}
}
.form-input {
width: 100%;
height: 88rpx;
padding: 0 30rpx;
background-color: rgba(255, 255, 255, 0.9);
border-radius: $border-radius-base;
font-size: $font-size-base;
}
.login-type {
text-align: right;
font-size: 26rpx;
color: #fff;
margin-bottom: 60rpx;
}
.login-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
text-align: center;
background-color: #fff;
color: $primary-color;
font-size: 32rpx;
font-weight: bold;
border-radius: $border-radius-base;
margin-bottom: 40rpx;
}
.login-footer {
display: flex;
justify-content: space-between;
.footer-link {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.8);
}
}
}
}
</style>

840
src/pages/message/index.vue Normal file
View File

@@ -0,0 +1,840 @@
<template>
<view class="consultant-page">
<!-- 自定义导航栏 -->
<view class="custom-navbar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="navbar-content">
<view class="navbar-title">心理咨询</view>
</view>
</view>
<!-- 1. 搜索栏 -->
<view class="search-wrapper">
<SearchBar
v-model="searchText"
placeholder="搜索咨询师"
@search="handleSearch"
/>
</view>
<!-- 2. Banner -->
<view class="banner-wrapper">
<Banner :banners="bannerList" @click="handleBannerClick" />
</view>
<!-- 3. 筛选区域 -->
<view class="filter-wrapper">
<FilterBar :filters="filterList" @filter-click="handleFilterClick" />
<view class="category-wrapper">
<CategoryTabs
:categories="categoryList"
:current="currentCategory"
@change="handleCategoryChange"
/>
</view>
<!-- 已选择的筛选标签 -->
<view v-if="hasActiveFilters" class="active-filters">
<view
v-for="(tag, index) in activeFilterTags"
:key="index"
class="filter-tag"
@tap.stop="handleRemoveFilter(tag)"
>
<text class="tag-text">{{ tag.label }}</text>
<text class="tag-close">×</text>
</view>
</view>
</view>
<!-- 4. 数据卡片区域 -->
<view class="consultant-list">
<!-- 骨架屏 -->
<template v-if="loading && consultantList.length === 0">
<ConsultantCardSkeleton v-for="i in 3" :key="'skeleton-' + i" />
</template>
<!-- 真实数据 -->
<ConsultantCard
v-for="item in consultantList"
v-else
:key="item.id"
:id="item.id"
:avatar="item.avatar"
:name="item.name"
:level-icon="item.levelIcon"
:available-time="item.availableTime"
:status="item.status"
:title="item.title"
:specialties="item.specialties"
:tags="item.tags"
:city="item.city"
:price="item.price"
@click="handleConsultantClick"
/>
<!-- 加载更多 -->
<view v-if="loadingMore" class="loading-more">
<text class="loading-text">加载中...</text>
</view>
<!-- 没有更多 -->
<view v-if="noMore && consultantList.length > 0" class="no-more">
<text class="no-more-text">没有更多了</text>
</view>
</view>
<EmptyView v-if="isEmpty" text="暂无咨询师" />
<LoadingView v-if="loading" />
<!-- 筛选弹窗 -->
<CityFilter
:show="showCityFilter"
:value="filters.cityData"
@update:show="showCityFilter = $event"
@confirm="handleCityConfirm"
/>
<IssueFilter
:show="showIssueFilter"
:value="filters.issue"
@update:show="showIssueFilter = $event"
@confirm="handleIssueConfirm"
/>
<FilterPopup
:show="showPriceFilter"
title="价格"
:options="priceOptions"
:value="filters.price"
@update:show="showPriceFilter = $event"
@confirm="handlePriceConfirm"
/>
<FilterPopup
:show="showMoreFilter"
title="更多筛选"
:options="moreOptions"
:value="filters.more"
:multiple="true"
@update:show="showMoreFilter = $event"
@confirm="handleMoreConfirm"
/>
<FilterPopup
:show="showSortFilter"
title="排序"
:options="sortOptions"
:value="filters.sort"
@update:show="showSortFilter = $event"
@confirm="handleSortConfirm"
/>
</view>
</template>
<script>
import pageMixin from '@/mixins/pageMixin'
import EmptyView from '@/components/common/EmptyView.vue'
import LoadingView from '@/components/common/LoadingView.vue'
import Banner from '@/components/consultant/Banner.vue'
import SearchBar from '@/components/consultant/SearchBar.vue'
import FilterBar from '@/components/consultant/FilterBar.vue'
import CategoryTabs from '@/components/consultant/CategoryTabs.vue'
import ConsultantCard from '@/components/consultant/ConsultantCard.vue'
import ConsultantCardSkeleton from '@/components/consultant/ConsultantCardSkeleton.vue'
import CityFilter from '@/components/consultant/CityFilter.vue'
import IssueFilter from '@/components/consultant/IssueFilter.vue'
import FilterPopup from '@/components/consultant/FilterPopup.vue'
import { debounce, throttle } from '@/utils/performance'
export default {
name: 'ConsultantPage',
components: {
EmptyView,
LoadingView,
Banner,
SearchBar,
FilterBar,
CategoryTabs,
ConsultantCard,
ConsultantCardSkeleton,
CityFilter,
IssueFilter,
FilterPopup
},
mixins: [pageMixin],
data() {
return {
statusBarHeight: 0,
searchText: '',
loading: false,
loadingMore: false,
noMore: false,
currentCategory: 0,
page: 1,
pageSize: 10,
// 筛选弹窗显示状态
showCityFilter: false,
showIssueFilter: false,
showPriceFilter: false,
showMoreFilter: false,
showSortFilter: false,
// 筛选条件
filters: {
city: '',
cityData: {}, // 城市三级联动数据
issue: [], // 困扰改为数组,支持多选
issueLabel: '', // 困扰显示文本
price: '',
priceLabel: '', // 价格显示文本
more: [],
moreLabel: '', // 更多筛选显示文本
sort: '',
sortLabel: '' // 排序显示文本
},
// 困扰选项
issueOptions: [
{ id: 1, label: '焦虑抑郁', value: 'anxiety' },
{ id: 2, label: '婚姻关系', value: 'marriage' },
{ id: 3, label: '原生家庭', value: 'family' },
{ id: 4, label: '职业困扰', value: 'career' },
{ id: 5, label: '成长迷茫', value: 'growth' },
{ id: 6, label: '情绪管理', value: 'emotion' }
],
// 价格选项
priceOptions: [
{ id: 1, label: '不限', value: '' },
{ id: 2, label: '0-300元', value: '0-300' },
{ id: 3, label: '300-500元', value: '300-500' },
{ id: 4, label: '500-800元', value: '500-800' },
{ id: 5, label: '800元以上', value: '800+' }
],
// 更多筛选选项
moreOptions: [
{ id: 1, label: '认证咨询师', value: 'certified' },
{ id: 2, label: '高级名师', value: 'senior' },
{ id: 3, label: '1000+小时', value: '1000h' },
{ id: 4, label: '在线咨询', value: 'online' }
],
// 排序选项
sortOptions: [
{ id: 1, label: '综合排序', value: 'default' },
{ id: 2, label: '价格从低到高', value: 'price_asc' },
{ id: 3, label: '价格从高到低', value: 'price_desc' },
{ id: 4, label: '好评优先', value: 'rating' }
],
// Mock数据 - Banner列表
bannerList: [
{
id: 1,
image: 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/48b8a2d81b0349229e9818cd40b20dae.png',
link: ''
},
{
id: 2,
image: 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/dfa9a94106ee4054a35e9997a9f4f84b.png',
link: ''
}
],
// 筛选项(动态计算)
filterList: [],
// 分类列表
categoryList: ['焦虑抑郁', '婚姻关系', '原生家庭', '职业困扰', '成长迷茫'],
// Mock数据 - 咨询师列表
consultantList: [
{
id: 1,
avatar: 'https://test-1302947942.cos.ap-nanjing.myqcloud.com/models/b2c519f435804eaa9005318112c1a346.jpg',
name: '冯云',
levelIcon: '/static/icons/Senior-Teacher.png', // 高级名师
availableTime: '10:00',
status: '可约',
title: '国家二级咨询师/高校心理老师/硕士学历/持续',
specialties: '个人成长/亲子教育/情绪管理/焦虑抑郁',
tags: ['1000+小时经验', '从业5年', '个人成长', '情绪管理'],
city: '杭州',
price: 600
},
{
id: 2,
avatar: 'https://test-1302947942.cos.ap-nanjing.myqcloud.com/models/b2c519f435804eaa9005318112c1a346.jpg',
name: '李梅发',
levelIcon: '', // 普通咨询师,无等级徽章
availableTime: '16:00',
status: '可约',
title: '国家二级咨询师/临床心理学硕士/青少年心理咨询',
specialties: '个人成长/亲子关系/家庭/情绪管理/职场困扰',
tags: ['1000+小时经验', '认证咨询', '个人成长', '职场管理'],
city: '杭州',
price: 600
},
{
id: 3,
avatar: 'https://test-1302947942.cos.ap-nanjing.myqcloud.com/models/b2c519f435804eaa9005318112c1a346.jpg',
name: '王芳',
levelIcon: '/static/icons/Senior-Teacher.png', // 高级名师
availableTime: '14:00',
status: '可约',
title: '国家二级咨询师/临床心理学硕士/青少年心理咨询',
specialties: '个人成长/亲子关系/家庭/情绪管理/职场困扰',
tags: ['1000+小时经验', '认证咨询', '个人成长', '职场管理'],
city: '杭州',
price: 600
},
{
id: 4,
avatar: 'https://test-1302947942.cos.ap-nanjing.myqcloud.com/models/b2c519f435804eaa9005318112c1a346.jpg',
name: '张伟',
levelIcon: '', // 普通咨询师,无等级徽章
availableTime: '18:00',
status: '可约',
title: '国家二级咨询师/临床心理学硕士/青少年心理咨询',
specialties: '个人成长/亲子关系/家庭/情绪管理/职场困扰',
tags: ['1000+小时经验', '认证咨询', '个人成长', '职场管理'],
city: '杭州',
price: 600
}
]
}
},
computed: {
isEmpty() {
return !this.loading && this.consultantList.length === 0
},
// 是否有激活的筛选条件
hasActiveFilters() {
return this.activeFilterTags.length > 0
},
// 激活的筛选标签列表
activeFilterTags() {
const tags = []
if (this.filters.city) {
tags.push({ key: 'city', label: this.filters.city })
}
if (this.filters.issue.length > 0) {
tags.push({ key: 'issue', label: this.filters.issueLabel || `困扰(${this.filters.issue.length})` })
}
if (this.filters.priceLabel && this.filters.price) {
tags.push({ key: 'price', label: this.filters.priceLabel })
}
if (this.filters.more.length > 0) {
// 显示每个选中的"更多"选项
this.filters.more.forEach(value => {
const option = this.moreOptions.find(opt => opt.value === value)
if (option) {
tags.push({ key: 'more', value: value, label: option.label })
}
})
}
if (this.filters.sortLabel && this.filters.sort) {
tags.push({ key: 'sort', label: this.filters.sortLabel })
}
return tags
}
},
watch: {
filters: {
handler() {
this.updateFilterList()
},
deep: true,
immediate: true
}
},
onLoad() {
this.getStatusBarHeight()
this.loadData()
},
onReachBottom() {
// 触底加载更多(节流)
this.handleLoadMore()
},
onPullDownRefresh() {
// 下拉刷新
this.handleRefresh()
},
created() {
// 创建防抖和节流函数
this.debouncedSearch = debounce(this.performSearch, 500)
this.throttledLoadMore = throttle(this.loadMore, 1000)
},
methods: {
/**
* 更新筛选列表显示
*/
updateFilterList() {
this.filterList = [
{
key: 'city',
label: '城市',
displayLabel: this.filters.city || '城市',
active: !!this.filters.city
},
{
key: 'issue',
label: '困扰',
displayLabel: this.filters.issueLabel || '困扰',
active: this.filters.issue.length > 0
},
{
key: 'price',
label: '价格',
displayLabel: this.filters.priceLabel || '价格',
active: !!this.filters.price
},
{
key: 'more',
label: '更多',
displayLabel: this.filters.moreLabel || '更多',
active: this.filters.more.length > 0
},
{
key: 'sort',
label: '排序',
displayLabel: this.filters.sortLabel || '排序',
active: !!this.filters.sort
}
]
},
/**
* 获取状态栏高度
*/
getStatusBarHeight() {
const systemInfo = uni.getSystemInfoSync()
this.statusBarHeight = systemInfo.statusBarHeight || 0
},
/**
* 加载数据
*/
async loadData() {
this.loading = true
try {
// TODO: 调用真实接口
// const res = await getConsultantList()
// this.consultantList = res.data
// 模拟接口延迟
await new Promise(resolve => setTimeout(resolve, 500))
} catch (error) {
console.error('加载数据失败:', error)
uni.showToast({
title: '加载失败',
icon: 'none'
})
} finally {
this.loading = false
}
},
/**
* Banner点击
*/
handleBannerClick(item) {
console.log('Banner点击:', item)
if (item.link) {
uni.navigateTo({
url: item.link
})
}
},
/**
* 搜索(使用防抖)
*/
handleSearch(keyword) {
console.log('搜索:', keyword)
this.debouncedSearch(keyword)
},
/**
* 执行搜索
*/
performSearch(keyword) {
this.page = 1
this.consultantList = []
this.noMore = false
this.loadData({ keyword })
},
/**
* 下拉刷新
*/
async handleRefresh() {
this.page = 1
this.consultantList = []
this.noMore = false
await this.loadData()
uni.stopPullDownRefresh()
},
/**
* 触底加载更多
*/
handleLoadMore() {
if (this.loadingMore || this.noMore) return
this.throttledLoadMore()
},
/**
* 加载更多数据
*/
async loadMore() {
if (this.loadingMore || this.noMore) return
this.page++
this.loadingMore = true
try {
// TODO: 调用真实接口
await new Promise(resolve => setTimeout(resolve, 1000))
// Mock: 模拟没有更多数据
if (this.page > 2) {
this.noMore = true
} else {
// 添加更多数据
const moreData = [
{
id: this.consultantList.length + 1,
avatar: 'https://test-1302947942.cos.ap-nanjing.myqcloud.com/models/b2c519f435804eaa9005318112c1a346.jpg',
name: '咨询师' + (this.consultantList.length + 1),
levelIcon: '/static/icons/Senior-Teacher.png',
availableTime: '10:00',
status: '可约',
title: '国家二级咨询师/高校心理老师/硕士学历/持续',
specialties: '个人成长/亲子教育/情绪管理/焦虑抑郁',
tags: ['1000+小时经验', '从业5年', '个人成长', '情绪管理'],
city: '杭州',
price: 600
}
]
this.consultantList.push(...moreData)
}
} catch (error) {
console.error('加载更多失败:', error)
this.page--
} finally {
this.loadingMore = false
}
},
/**
* 移除筛选条件
*/
handleRemoveFilter(tag) {
if (tag.key === 'city') {
this.filters.city = ''
this.filters.cityData = {}
} else if (tag.key === 'issue') {
this.filters.issue = []
this.filters.issueLabel = ''
} else if (tag.key === 'price') {
this.filters.price = ''
this.filters.priceLabel = ''
} else if (tag.key === 'more') {
// 移除特定的"更多"选项
if (tag.value) {
const index = this.filters.more.indexOf(tag.value)
if (index > -1) {
this.filters.more.splice(index, 1)
}
} else {
// 清空所有"更多"选项
this.filters.more = []
}
// 更新显示文本
if (this.filters.more.length > 0) {
this.filters.moreLabel = `更多(${this.filters.more.length})`
} else {
this.filters.moreLabel = '更多'
}
} else if (tag.key === 'sort') {
this.filters.sort = ''
this.filters.sortLabel = ''
}
this.refreshData()
},
/**
* 筛选点击
*/
handleFilterClick(key) {
console.log('筛选点击:', key)
switch (key) {
case 'city':
this.showCityFilter = true
break
case 'issue':
this.showIssueFilter = true
break
case 'price':
this.showPriceFilter = true
break
case 'more':
this.showMoreFilter = true
break
case 'sort':
this.showSortFilter = true
break
}
},
/**
* 城市筛选确认
*/
handleCityConfirm(cityData) {
console.log('主页面 - handleCityConfirm 被调用:', cityData)
this.filters.cityData = cityData
// 组合显示文本
const parts = []
if (cityData.districtName) {
parts.push(cityData.districtName)
} else if (cityData.cityName) {
parts.push(cityData.cityName)
} else if (cityData.provinceName) {
parts.push(cityData.provinceName)
}
this.filters.city = parts.join('')
console.log('选择城市:', cityData)
this.refreshData()
},
/**
* 困扰筛选确认
*/
handleIssueConfirm(issueIds) {
this.filters.issue = issueIds
// 生成显示文本(显示选中的数量)
if (issueIds.length > 0) {
this.filters.issueLabel = `困扰(${issueIds.length})`
} else {
this.filters.issueLabel = '困扰'
}
console.log('选择困扰:', issueIds)
this.refreshData()
},
/**
* 价格筛选确认
*/
handlePriceConfirm(price) {
this.filters.price = price
// 查找对应的标签文本
const option = this.priceOptions.find(opt => opt.value === price)
this.filters.priceLabel = option ? option.label : '价格'
console.log('选择价格:', price)
this.refreshData()
},
/**
* 更多筛选确认
*/
handleMoreConfirm(more) {
this.filters.more = more
// 显示选中数量
if (more.length > 0) {
this.filters.moreLabel = `更多(${more.length})`
} else {
this.filters.moreLabel = '更多'
}
console.log('更多筛选:', more)
this.refreshData()
},
/**
* 排序确认
*/
handleSortConfirm(sort) {
this.filters.sort = sort
// 查找对应的标签文本
const option = this.sortOptions.find(opt => opt.value === sort)
this.filters.sortLabel = option ? option.label : '排序'
console.log('选择排序:', sort)
this.refreshData()
},
/**
* 刷新数据
*/
refreshData() {
this.page = 1
this.consultantList = []
this.noMore = false
this.loadData()
},
/**
* 分类切换
*/
handleCategoryChange(index, category) {
console.log('分类切换:', index, category)
this.currentCategory = index
// TODO: 根据分类加载数据
},
/**
* 咨询师卡片点击
*/
handleConsultantClick(id) {
console.log('咨询师点击:', id)
uni.navigateTo({
url: `/pages/consultant/detail?id=${id}`
})
}
},
/**
* 分享配置
*/
onShareAppMessage() {
return {
title: '大白心理 - 专业心理咨询服务',
path: '/pages/message/index',
imageUrl: '/static/images/share-cover.png'
}
},
/**
* 分享到朋友圈
*/
onShareTimeline() {
return {
title: '大白心理 - 专业心理咨询服务',
query: '',
imageUrl: '/static/images/share-cover.png'
}
}
}
</script>
<style lang="scss" scoped>
.consultant-page {
min-height: 100vh;
background: linear-gradient(180deg, #fcf4e9 0%, #f8f8f8 100%);
padding-bottom: 20rpx;
// 自定义导航栏
.custom-navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
background: linear-gradient(180deg, #fcf4e9 0%, #f8f8f8 100%);
.navbar-content {
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
.navbar-title {
font-size: 36rpx;
font-family: 'PingFang SC', -apple-system, BlinkMacSystemFont, sans-serif;
font-weight: 400;
color: #3f3f46;
line-height: 1.2;
}
}
}
// 1. 搜索栏
.search-wrapper {
padding: 20rpx 32rpx;
// 为固定导航栏预留空间
margin-top: calc(var(--status-bar-height) + 88rpx);
}
// 2. Banner
.banner-wrapper {
padding: 0 32rpx 20rpx;
}
// 3. 筛选区域
.filter-wrapper {
background-color: #fbf6ee;
padding: 20rpx 32rpx;
.category-wrapper {
margin-top: 24rpx;
}
// 已选择的筛选标签
.active-filters {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
margin-top: 20rpx;
.filter-tag {
display: flex;
align-items: center;
gap: 8rpx;
padding: 8rpx 16rpx;
background-color: #fff5eb;
border-radius: 32rpx;
border: 1rpx solid #d97706;
.tag-text {
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #d97706;
line-height: 1.5;
}
.tag-close {
font-size: 32rpx;
color: #d97706;
line-height: 1;
font-weight: 300;
}
}
}
}
// 4. 数据卡片区域
.consultant-list {
padding: 20rpx 32rpx;
display: flex;
flex-direction: column;
gap: 20rpx;
.loading-more,
.no-more {
padding: 20rpx 0;
text-align: center;
.loading-text,
.no-more-text {
font-size: 24rpx;
color: #a1a1aa;
}
}
}
}
</style>

1098
src/pages/target/index.vue Normal file

File diff suppressed because it is too large Load Diff

263
src/pages/user/index.vue Normal file
View File

@@ -0,0 +1,263 @@
<template>
<view class="user-page">
<!-- 用户信息 -->
<view class="user-header">
<view class="user-info">
<image :src="userInfo.avatar || defaultAvatar" class="user-avatar" @click="handleAvatarClick" />
<view class="user-detail">
<text class="user-name">{{ userInfo.username || '未登录' }}</text>
<text class="user-desc">{{ userInfo.signature || '这个人很懒,什么都没留下' }}</text>
</view>
</view>
</view>
<!-- 数据统计 -->
<view class="user-stats">
<view class="stat-item">
<text class="stat-value">{{ userStats.follow }}</text>
<text class="stat-label">关注</text>
</view>
<view class="stat-item">
<text class="stat-value">{{ userStats.fans }}</text>
<text class="stat-label">粉丝</text>
</view>
<view class="stat-item">
<text class="stat-value">{{ userStats.likes }}</text>
<text class="stat-label">获赞</text>
</view>
</view>
<!-- 功能菜单 -->
<view class="menu-section">
<view
v-for="item in menuList"
:key="item.id"
class="menu-item"
@click="handleMenuClick(item)"
>
<view class="menu-left">
<image :src="item.icon" class="menu-icon" />
<text class="menu-text">{{ item.name }}</text>
</view>
<text class="menu-arrow"></text>
</view>
</view>
<!-- 退出登录 -->
<view v-if="isLogin" class="logout-btn" @click="handleLogout">
退出登录
</view>
</view>
</template>
<script>
import { useUserStore } from '@/store/modules/user'
import { showModal } from '@/utils/platform'
import pageMixin from '@/mixins/pageMixin'
export default {
name: 'UserPage',
mixins: [pageMixin],
data() {
return {
defaultAvatar: '/static/images/default-avatar.png',
userStats: {
follow: 0,
fans: 0,
likes: 0
},
menuList: [
{ id: 1, name: '个人资料', icon: '/static/icons/profile.png', path: '/pages/profile/index' },
{ id: 2, name: '我的订单', icon: '/static/icons/order.png', path: '/pages/order/index' },
{ id: 3, name: '我的收藏', icon: '/static/icons/favorite.png', path: '/pages/favorite/index' },
{ id: 4, name: '设置', icon: '/static/icons/setting.png', path: '/pages/setting/index' }
]
}
},
computed: {
userInfo() {
return this.userStore.userInfo || {}
}
},
onShow() {
if (this.isLogin) {
this.loadUserStats()
}
},
methods: {
/**
* 加载用户统计
*/
async loadUserStats() {
// 模拟接口请求
this.userStats = {
follow: 128,
fans: 256,
likes: 512
}
},
/**
* 头像点击
*/
handleAvatarClick() {
if (!this.isLogin) {
uni.navigateTo({ url: '/pages/login/index' })
return
}
// 选择图片上传
uni.chooseImage({
count: 1,
success: (res) => {
// 上传头像逻辑
console.log('选择的图片:', res.tempFilePaths[0])
}
})
},
/**
* 菜单点击
*/
handleMenuClick(item) {
if (!this.checkLogin()) return
if (item.path) {
uni.navigateTo({ url: item.path })
}
},
/**
* 退出登录
*/
async handleLogout() {
const confirm = await showModal({
title: '提示',
content: '确定要退出登录吗?'
})
if (confirm) {
this.userStore.logout()
uni.reLaunch({ url: '/pages/index/index' })
}
}
}
}
</script>
<style lang="scss" scoped>
.user-page {
min-height: 100vh;
background-color: $bg-color;
.user-header {
padding: 60rpx 40rpx;
background: linear-gradient(135deg, $primary-color 0%, #40a9ff 100%);
.user-info {
display: flex;
align-items: center;
.user-avatar {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
margin-right: 30rpx;
border: 4rpx solid rgba(255, 255, 255, 0.3);
}
.user-detail {
flex: 1;
.user-name {
display: block;
font-size: 36rpx;
font-weight: bold;
color: #fff;
margin-bottom: 10rpx;
}
.user-desc {
display: block;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.8);
}
}
}
}
.user-stats {
display: flex;
background-color: #fff;
padding: 40rpx 0;
margin-bottom: 20rpx;
.stat-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
.stat-value {
font-size: 36rpx;
font-weight: bold;
color: $text-color;
margin-bottom: 10rpx;
}
.stat-label {
font-size: 24rpx;
color: $text-color-secondary;
}
}
}
.menu-section {
background-color: #fff;
margin-bottom: 20rpx;
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 30rpx 40rpx;
border-bottom: 1rpx solid $border-color-light;
&:last-child {
border-bottom: none;
}
.menu-left {
display: flex;
align-items: center;
.menu-icon {
width: 40rpx;
height: 40rpx;
margin-right: 20rpx;
}
.menu-text {
font-size: 28rpx;
color: $text-color;
}
}
.menu-arrow {
font-size: 40rpx;
color: $text-color-placeholder;
}
}
}
.logout-btn {
margin: 40rpx;
padding: 30rpx;
text-align: center;
background-color: #fff;
color: $error-color;
font-size: 28rpx;
border-radius: $border-radius-base;
}
}
</style>

View File

@@ -0,0 +1,2 @@
# 图标资源目录
# 将图标文件放在此目录下

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

BIN
src/static/icons/Hook.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 801 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
src/static/icons/Vector.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 871 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 513 B

BIN
src/static/icons/calm.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

BIN
src/static/icons/happy.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 749 B

BIN
src/static/icons/sad.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 879 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 716 B

BIN
src/static/icons/tired.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 647 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -0,0 +1,2 @@
# 图片资源目录
# 将图片文件放在此目录下

View File

@@ -0,0 +1,39 @@
# TabBar 图标目录
将以下图标文件放在此目录下:
## 需要的图标文件
### AI助手
- `aibot.png` - 未选中状态(灰色)
- `aibot-active.png` - 选中状态(蓝色)
### 咨询
- `message.png` - 未选中状态(灰色)
- `message-active.png` - 选中状态(蓝色)
### 测评
- `target.png` - 未选中状态(灰色)
- `target-active.png` - 选中状态(蓝色)
### 课程
- `library.png` - 未选中状态(灰色)
- `library-active.png` - 选中状态(蓝色)
### 我的
- `cloud.png` - 未选中状态(灰色)
- `cloud-active.png` - 选中状态(蓝色)
## 图标规格
- 尺寸81px × 81px推荐
- 格式PNG支持透明背景
- 未选中:#999999灰色
- 选中:#1890ff蓝色
## 临时方案
如果暂时没有图标,可以:
1. 使用在线图标生成工具
2. 或者先用纯色占位图
3. 项目运行后再替换真实图标

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
src/static/tabbar/AIBot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
src/static/tabbar/cloud.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

9
src/store/index.js Normal file
View File

@@ -0,0 +1,9 @@
/**
* Pinia状态管理入口
*/
import { createPinia } from 'pinia'
const pinia = createPinia()
export default pinia

98
src/store/modules/app.js Normal file
View File

@@ -0,0 +1,98 @@
/**
* 应用全局状态管理模块
*/
import { defineStore } from 'pinia'
import { getSystemInfo } from '@/utils/platform'
export const useAppStore = defineStore('app', {
state: () => ({
systemInfo: null,
networkType: 'unknown',
isOnline: true,
theme: 'light',
language: 'zh-CN'
}),
getters: {
/**
* 获取状态栏高度
*/
statusBarHeight: (state) => state.systemInfo?.statusBarHeight || 0,
/**
* 获取导航栏高度
*/
navBarHeight: (state) => {
const statusBarHeight = state.systemInfo?.statusBarHeight || 0
// #ifdef MP-WEIXIN
return statusBarHeight + 44
// #endif
// #ifdef H5
return statusBarHeight + 44
// #endif
// #ifdef APP-PLUS
return statusBarHeight + 44
// #endif
return 44
},
/**
* 获取屏幕宽度
*/
screenWidth: (state) => state.systemInfo?.screenWidth || 375,
/**
* 获取屏幕高度
*/
screenHeight: (state) => state.systemInfo?.screenHeight || 667,
/**
* 是否为暗黑模式
*/
isDark: (state) => state.theme === 'dark'
},
actions: {
/**
* 初始化系统信息
*/
async initSystemInfo() {
try {
const info = await getSystemInfo()
this.systemInfo = info
} catch (e) {
console.error('获取系统信息失败:', e)
}
},
/**
* 设置网络状态
*/
setNetworkType(type) {
this.networkType = type
this.isOnline = type !== 'none'
},
/**
* 设置主题
*/
setTheme(theme) {
this.theme = theme
},
/**
* 切换主题
*/
toggleTheme() {
this.theme = this.theme === 'light' ? 'dark' : 'light'
},
/**
* 设置语言
*/
setLanguage(language) {
this.language = language
}
}
})

100
src/store/modules/user.js Normal file
View File

@@ -0,0 +1,100 @@
/**
* 用户状态管理模块
*/
import { defineStore } from 'pinia'
import storage from '@/utils/storage'
import { STORAGE_KEYS } from '@/config/constants'
export const useUserStore = defineStore('user', {
state: () => ({
token: storage.get(STORAGE_KEYS.TOKEN) || '',
userInfo: storage.get(STORAGE_KEYS.USER_INFO) || null,
isLogin: false
}),
getters: {
/**
* 获取用户ID
*/
userId: (state) => state.userInfo?.id || '',
/**
* 获取用户名
*/
username: (state) => state.userInfo?.username || '',
/**
* 获取用户角色
*/
userRole: (state) => state.userInfo?.role || 'guest',
/**
* 是否已登录
*/
hasLogin: (state) => !!state.token && !!state.userInfo
},
actions: {
/**
* 设置Token
*/
setToken(token) {
this.token = token
storage.set(STORAGE_KEYS.TOKEN, token)
},
/**
* 设置用户信息
*/
setUserInfo(userInfo) {
this.userInfo = userInfo
storage.set(STORAGE_KEYS.USER_INFO, userInfo)
this.isLogin = true
},
/**
* 登录
*/
login(loginData) {
this.setToken(loginData.token)
this.setUserInfo(loginData.userInfo)
},
/**
* 登出
*/
logout() {
this.token = ''
this.userInfo = null
this.isLogin = false
storage.remove(STORAGE_KEYS.TOKEN)
storage.remove(STORAGE_KEYS.USER_INFO)
},
/**
* 更新用户信息
*/
updateUserInfo(userInfo) {
this.userInfo = { ...this.userInfo, ...userInfo }
storage.set(STORAGE_KEYS.USER_INFO, this.userInfo)
},
/**
* 检查权限
*/
hasPermission(permission) {
if (!this.userInfo || !this.userInfo.permissions) {
return false
}
return this.userInfo.permissions.includes(permission)
},
/**
* 检查角色
*/
hasRole(role) {
return this.userRole === role
}
}
})

192
src/styles/common.scss Normal file
View File

@@ -0,0 +1,192 @@
/**
* 全局通用样式
*/
// 重置样式
page {
background-color: $bg-color;
font-size: $font-size-base;
color: $text-color;
line-height: 1.5;
}
// 通用布局
.container {
min-height: 100vh;
background-color: $bg-color;
}
.page-content {
padding: $spacing-base;
}
// Flex布局
.flex {
display: flex;
}
.flex-center {
@include center;
}
.flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
.flex-column {
display: flex;
flex-direction: column;
}
.flex-1 {
flex: 1;
}
// 文本样式
.text-primary {
color: $primary-color;
}
.text-success {
color: $success-color;
}
.text-warning {
color: $warning-color;
}
.text-error {
color: $error-color;
}
.text-secondary {
color: $text-color-secondary;
}
.text-placeholder {
color: $text-color-placeholder;
}
.text-center {
text-align: center;
}
.text-right {
text-align: right;
}
.text-ellipsis {
@include ellipsis;
}
.text-ellipsis-2 {
@include multi-ellipsis(2);
}
// 间距
.mt-xs {
margin-top: $spacing-xs;
}
.mt-sm {
margin-top: $spacing-sm;
}
.mt-base {
margin-top: $spacing-base;
}
.mt-lg {
margin-top: $spacing-lg;
}
.mb-xs {
margin-bottom: $spacing-xs;
}
.mb-sm {
margin-bottom: $spacing-sm;
}
.mb-base {
margin-bottom: $spacing-base;
}
.mb-lg {
margin-bottom: $spacing-lg;
}
.ml-xs {
margin-left: $spacing-xs;
}
.ml-sm {
margin-left: $spacing-sm;
}
.mr-xs {
margin-right: $spacing-xs;
}
.mr-sm {
margin-right: $spacing-sm;
}
.p-base {
padding: $spacing-base;
}
.p-sm {
padding: $spacing-sm;
}
// 卡片
.card {
background-color: $bg-color-white;
border-radius: $border-radius-base;
padding: $spacing-base;
margin-bottom: $spacing-base;
box-shadow: $box-shadow-sm;
}
// 按钮
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 20rpx 40rpx;
font-size: $font-size-base;
border-radius: $border-radius-base;
transition: all 0.3s;
&.btn-primary {
background-color: $primary-color;
color: #fff;
}
&.btn-success {
background-color: $success-color;
color: #fff;
}
&.btn-warning {
background-color: $warning-color;
color: #fff;
}
&.btn-error {
background-color: $error-color;
color: #fff;
}
&.btn-block {
width: 100%;
}
&.btn-disabled {
opacity: 0.6;
pointer-events: none;
}
}

46
src/styles/iconfont.scss Normal file
View File

@@ -0,0 +1,46 @@
/**
* 图标字体样式
* 可以使用 iconfont.cn 生成的字体文件
*/
@font-face {
font-family: 'iconfont';
/* 项目中需要引入实际的字体文件 */
src: url('//at.alicdn.com/t/font_xxx.woff2') format('woff2'),
url('//at.alicdn.com/t/font_xxx.woff') format('woff'),
url('//at.alicdn.com/t/font_xxx.ttf') format('truetype');
}
.iconfont {
font-family: 'iconfont' !important;
font-size: 32rpx;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* 搜索图标 */
.icon-search::before {
content: '\e6dd';
}
/* 箭头向下 */
.icon-arrow-down::before {
content: '\e6e0';
}
/* 更多 */
.icon-more::before {
content: '\e6e1';
}
/* 临时使用 SVG 背景图标作为替代方案 */
.icon-search {
display: inline-block;
width: 32rpx;
height: 32rpx;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23a1a1aa'%3E%3Cpath d='M18.031 16.617l4.283 4.282-1.415 1.415-4.282-4.283A8.96 8.96 0 0 1 11 20c-4.968 0-9-4.032-9-9s4.032-9 9-9 9 4.032 9 9a8.96 8.96 0 0 1-1.969 5.617zm-2.006-.742A6.977 6.977 0 0 0 18 11c0-3.868-3.133-7-7-7-3.868 0-7 3.132-7 7 0 3.867 3.132 7 7 7a6.977 6.977 0 0 0 4.875-1.975l.15-.15z'/%3E%3C/svg%3E");
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}

93
src/styles/mixins.scss Normal file
View File

@@ -0,0 +1,93 @@
/**
* 全局样式混入
*/
// 单行文本省略
@mixin ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// 多行文本省略
@mixin multi-ellipsis($lines: 2) {
display: -webkit-box;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: $lines;
-webkit-box-orient: vertical;
}
// 清除浮动
@mixin clearfix {
&::after {
content: '';
display: table;
clear: both;
}
}
// 水平垂直居中
@mixin center {
display: flex;
align-items: center;
justify-content: center;
}
// 绝对定位居中
@mixin absolute-center {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
// 1px边框
@mixin hairline($direction: all, $color: $border-color) {
position: relative;
&::after {
content: '';
position: absolute;
pointer-events: none;
box-sizing: border-box;
@if $direction == all {
top: 0;
left: 0;
width: 200%;
height: 200%;
border: 1px solid $color;
transform: scale(0.5);
transform-origin: 0 0;
} @else if $direction == top {
top: 0;
left: 0;
width: 100%;
height: 1px;
background-color: $color;
transform: scaleY(0.5);
} @else if $direction == bottom {
bottom: 0;
left: 0;
width: 100%;
height: 1px;
background-color: $color;
transform: scaleY(0.5);
} @else if $direction == left {
top: 0;
left: 0;
width: 1px;
height: 100%;
background-color: $color;
transform: scaleX(0.5);
} @else if $direction == right {
top: 0;
right: 0;
width: 1px;
height: 100%;
background-color: $color;
transform: scaleX(0.5);
}
}
}

43
src/styles/tabbar.scss Normal file
View File

@@ -0,0 +1,43 @@
/**
* TabBar 自定义样式
* 注意:部分样式可能需要在 manifest.json 中配置才能生效
*/
// TabBar 字体配置
// 在 pages.json 中已配置:
// - 字体PingFang SC
// - 字重600 (Semibold)
// - 字号11px
// - 行高100%
// - 未选中颜色:#A1A1AA
// - 选中颜色:#27272A
/*
如果需要在 H5 端进一步自定义 TabBar 样式,可以使用以下选择器:
*/
// #ifdef H5
.uni-tabbar {
font-family: 'PingFang SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
.uni-tabbar__label {
font-size: 11px !important;
font-weight: 600 !important;
line-height: 100% !important;
}
.uni-tabbar__item {
&:not(.uni-tabbar__item--active) {
.uni-tabbar__label {
color: #A1A1AA !important;
}
}
&.uni-tabbar__item--active {
.uni-tabbar__label {
color: #27272A !important;
}
}
}
}
// #endif

60
src/styles/variables.scss Normal file
View File

@@ -0,0 +1,60 @@
/**
* 全局样式变量
*/
// 主题色
$primary-color: #1890ff;
$success-color: #52c41a;
$warning-color: #faad14;
$error-color: #f5222d;
$info-color: #1890ff;
// 文字色
$text-color: #27272A;
$text-color-secondary: #666;
$text-color-placeholder: #A1A1AA;
$text-color-disabled: #ccc;
// TabBar 颜色
$tabbar-color: #A1A1AA;
$tabbar-selected-color: #27272A;
// 背景色
$bg-color: #f5f5f5;
$bg-color-white: #fff;
$bg-color-gray: #f0f0f0;
// 边框色
$border-color: #e8e8e8;
$border-color-light: #f0f0f0;
// 字体大小
$font-size-xs: 20rpx;
$font-size-sm: 24rpx;
$font-size-base: 28rpx;
$font-size-lg: 32rpx;
$font-size-xl: 36rpx;
// 间距
$spacing-xs: 8rpx;
$spacing-sm: 16rpx;
$spacing-base: 24rpx;
$spacing-lg: 32rpx;
$spacing-xl: 40rpx;
// 圆角
$border-radius-sm: 4rpx;
$border-radius-base: 8rpx;
$border-radius-lg: 16rpx;
$border-radius-circle: 50%;
// 阴影
$box-shadow-sm: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
$box-shadow-base: 0 4rpx 12rpx rgba(0, 0, 0, 0.15);
$box-shadow-lg: 0 8rpx 24rpx rgba(0, 0, 0, 0.2);
// 层级
$z-index-base: 1;
$z-index-popup: 1000;
$z-index-modal: 2000;
$z-index-toast: 3000;

2
src/uni.scss Normal file
View File

@@ -0,0 +1,2 @@
/* uView-Plus 主题变量配置 */
@import 'uview-plus/theme.scss';

102
src/utils/performance.js Normal file
View File

@@ -0,0 +1,102 @@
/**
* 性能优化工具类
*/
/**
* 防抖函数
* @param {Function} func 需要防抖的函数
* @param {number} wait 等待时间(毫秒)
* @param {boolean} immediate 是否立即执行
*/
export function debounce(func, wait = 300, immediate = false) {
let timeout
return function executedFunction(...args) {
const context = this
const later = () => {
timeout = null
if (!immediate) func.apply(context, args)
}
const callNow = immediate && !timeout
clearTimeout(timeout)
timeout = setTimeout(later, wait)
if (callNow) func.apply(context, args)
}
}
/**
* 节流函数
* @param {Function} func 需要节流的函数
* @param {number} wait 等待时间(毫秒)
*/
export function throttle(func, wait = 300) {
let timeout
let previous = 0
return function executedFunction(...args) {
const context = this
const now = Date.now()
if (!previous) previous = now
const remaining = wait - (now - previous)
if (remaining <= 0 || remaining > wait) {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
previous = now
func.apply(context, args)
} else if (!timeout) {
timeout = setTimeout(() => {
previous = Date.now()
timeout = null
func.apply(context, args)
}, remaining)
}
}
}
/**
* 图片预加载
*/
export function preloadImages(urls) {
return Promise.all(
urls.map(
(url) =>
new Promise((resolve, reject) => {
uni.getImageInfo({
src: url,
success: resolve,
fail: reject
})
})
)
)
}
/**
* 分批处理数据
*/
export function processBatch(data, batchSize = 100, processor) {
const batches = []
for (let i = 0; i < data.length; i += batchSize) {
batches.push(data.slice(i, i + batchSize))
}
return batches.reduce((promise, batch) => {
return promise.then(() => {
return new Promise((resolve) => {
setTimeout(() => {
processor(batch)
resolve()
}, 0)
})
})
}, Promise.resolve())
}

138
src/utils/platform.js Normal file
View File

@@ -0,0 +1,138 @@
/**
* 平台适配工具类
* 处理不同端的API差异
*/
// 获取当前平台
export const getPlatform = () => {
// #ifdef MP-WEIXIN
return 'mp-weixin'
// #endif
// #ifdef H5
return 'h5'
// #endif
// #ifdef APP-PLUS
return 'app'
// #endif
return 'unknown'
}
// 判断是否为小程序
export const isMiniProgram = () => {
return getPlatform() === 'mp-weixin'
}
// 判断是否为H5
export const isH5 = () => {
return getPlatform() === 'h5'
}
// 判断是否为App
export const isApp = () => {
return getPlatform() === 'app'
}
// 跨端导航
export const navigateTo = (url, params = {}) => {
const query = Object.keys(params)
.map(key => `${key}=${encodeURIComponent(params[key])}`)
.join('&')
const fullUrl = query ? `${url}?${query}` : url
uni.navigateTo({ url: fullUrl })
}
// 跨端返回
export const navigateBack = (delta = 1) => {
uni.navigateBack({ delta })
}
// 跨端重定向
export const redirectTo = (url) => {
uni.redirectTo({ url })
}
// 跨端切换Tab
export const switchTab = (url) => {
uni.switchTab({ url })
}
// 跨端提示
export const showToast = (title, icon = 'none', duration = 2000) => {
uni.showToast({
title,
icon,
duration
})
}
// 跨端加载提示
export const showLoading = (title = '加载中...') => {
uni.showLoading({ title, mask: true })
}
export const hideLoading = () => {
uni.hideLoading()
}
// 跨端模态框
export const showModal = (options) => {
return new Promise((resolve) => {
uni.showModal({
title: options.title || '提示',
content: options.content || '',
showCancel: options.showCancel !== false,
confirmText: options.confirmText || '确定',
cancelText: options.cancelText || '取消',
success: (res) => {
resolve(res.confirm)
}
})
})
}
// 获取系统信息
export const getSystemInfo = () => {
return new Promise((resolve) => {
uni.getSystemInfo({
success: (res) => resolve(res)
})
})
}
// 设置导航栏标题
export const setNavigationBarTitle = (title) => {
uni.setNavigationBarTitle({ title })
}
// 获取存储(跨端兼容)
export const getStorageSync = (key) => {
try {
return uni.getStorageSync(key)
} catch (e) {
console.error('getStorageSync error:', e)
return null
}
}
// 设置存储(跨端兼容)
export const setStorageSync = (key, data) => {
try {
uni.setStorageSync(key, data)
return true
} catch (e) {
console.error('setStorageSync error:', e)
return false
}
}
// 移除存储
export const removeStorageSync = (key) => {
try {
uni.removeStorageSync(key)
return true
} catch (e) {
console.error('removeStorageSync error:', e)
return false
}
}

249
src/utils/request.js Normal file
View File

@@ -0,0 +1,249 @@
/**
* 网络请求封装
* 统一处理请求拦截、响应拦截、错误处理、重试机制
*/
import envConfig from '@/config/env'
import { BIZ_CODE, STORAGE_KEYS } from '@/config/constants'
import storage from './storage'
import { showToast, showLoading, hideLoading } from './platform'
class Request {
constructor() {
this.baseURL = envConfig.baseURL
this.timeout = envConfig.timeout
this.retryCount = 3 // 重试次数
this.retryDelay = 1000 // 重试延迟(毫秒)
}
/**
* 请求拦截器
*/
interceptRequest(config) {
// 添加token
const token = storage.get(STORAGE_KEYS.TOKEN)
if (token) {
config.header = {
...config.header,
Authorization: `Bearer ${token}`
}
}
// 添加通用header
config.header = {
'Content-Type': 'application/json',
...config.header
}
// 显示加载提示
if (config.loading !== false) {
showLoading(config.loadingText || '加载中...')
}
return config
}
/**
* 响应拦截器
*/
interceptResponse(response, config) {
// 隐藏加载提示
if (config.loading !== false) {
hideLoading()
}
const { statusCode, data } = response
// HTTP状态码检查
if (statusCode !== 200) {
this.handleHttpError(statusCode)
return Promise.reject(response)
}
// 业务状态码检查
if (data.code !== BIZ_CODE.SUCCESS) {
this.handleBizError(data)
return Promise.reject(data)
}
return data.data
}
/**
* HTTP错误处理
*/
handleHttpError(statusCode) {
const errorMap = {
400: '请求参数错误',
401: '未授权,请重新登录',
403: '拒绝访问',
404: '请求资源不存在',
500: '服务器错误',
502: '网关错误',
503: '服务不可用',
504: '网关超时'
}
const message = errorMap[statusCode] || `请求失败(${statusCode})`
showToast(message, 'none')
}
/**
* 业务错误处理
*/
handleBizError(data) {
const { code, message } = data
// Token过期跳转登录
if (code === BIZ_CODE.TOKEN_EXPIRED) {
storage.remove(STORAGE_KEYS.TOKEN)
storage.remove(STORAGE_KEYS.USER_INFO)
uni.reLaunch({ url: '/pages/login/index' })
return
}
// 显示错误提示
showToast(message || '请求失败', 'none')
}
/**
* 请求重试
*/
async retry(config, retryCount = 0) {
if (retryCount >= this.retryCount) {
return Promise.reject(new Error('请求失败,已达最大重试次数'))
}
// 延迟后重试
await new Promise(resolve => setTimeout(resolve, this.retryDelay))
return this.request(config, retryCount + 1)
}
/**
* 核心请求方法
*/
request(config, retryCount = 0) {
// 合并配置
const finalConfig = {
url: this.baseURL + config.url,
method: config.method || 'GET',
data: config.data || {},
header: config.header || {},
timeout: config.timeout || this.timeout,
...config
}
// 请求拦截
const interceptedConfig = this.interceptRequest(finalConfig)
return new Promise((resolve, reject) => {
uni.request({
...interceptedConfig,
success: (response) => {
this.interceptResponse(response, finalConfig)
.then(resolve)
.catch((error) => {
// 网络错误重试
if (finalConfig.retry !== false && retryCount < this.retryCount) {
this.retry(finalConfig, retryCount).then(resolve).catch(reject)
} else {
reject(error)
}
})
},
fail: (error) => {
hideLoading()
// 网络错误重试
if (finalConfig.retry !== false && retryCount < this.retryCount) {
this.retry(finalConfig, retryCount).then(resolve).catch(reject)
} else {
showToast('网络请求失败', 'none')
reject(error)
}
}
})
})
}
/**
* GET请求
*/
get(url, params = {}, config = {}) {
return this.request({
url,
method: 'GET',
data: params,
...config
})
}
/**
* POST请求
*/
post(url, data = {}, config = {}) {
return this.request({
url,
method: 'POST',
data,
...config
})
}
/**
* PUT请求
*/
put(url, data = {}, config = {}) {
return this.request({
url,
method: 'PUT',
data,
...config
})
}
/**
* DELETE请求
*/
delete(url, data = {}, config = {}) {
return this.request({
url,
method: 'DELETE',
data,
...config
})
}
/**
* 文件上传
*/
upload(url, filePath, config = {}) {
const token = storage.get(STORAGE_KEYS.TOKEN)
return new Promise((resolve, reject) => {
uni.uploadFile({
url: this.baseURL + url,
filePath,
name: config.name || 'file',
header: {
Authorization: `Bearer ${token}`,
...config.header
},
formData: config.formData || {},
success: (response) => {
const data = JSON.parse(response.data)
if (data.code === BIZ_CODE.SUCCESS) {
resolve(data.data)
} else {
this.handleBizError(data)
reject(data)
}
},
fail: reject
})
})
}
}
export default new Request()

Some files were not shown because too many files have changed in this diff Show More