first commit
33
.eslintrc.js
Normal 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
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
unpackage/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
4
.husky/pre-commit
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
. "$(dirname "$0")/_/husky.sh"
|
||||||
|
|
||||||
|
npx lint-staged
|
||||||
8
.prettierrc.js
Normal 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
@@ -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
@@ -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
|
||||||
|
// 统一使用 rpx,UniApp 会自动转换
|
||||||
|
.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
@@ -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
@@ -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
49
package.json
Normal 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
@@ -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
4
pencli/message.pen
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"version": "2.8",
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
4672
pencli/test.pen
Normal file
56
src/App.vue
Normal 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
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
59
src/api/modules/consultant.js
Normal 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
@@ -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')
|
||||||
|
}
|
||||||
379
src/components/common/CouponPopup.vue
Normal 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>
|
||||||
300
src/components/common/EditInfoPopup.vue
Normal 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>
|
||||||
69
src/components/common/EmptyView.vue
Normal 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>
|
||||||
57
src/components/common/ErrorView.vue
Normal 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>
|
||||||
70
src/components/common/LoadingOverlay.vue
Normal 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>
|
||||||
40
src/components/common/LoadingOverlayUview.vue
Normal 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>
|
||||||
69
src/components/common/LoadingView.vue
Normal 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>
|
||||||
146
src/components/consultant/Banner.vue
Normal 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>
|
||||||
835
src/components/consultant/BookingPopup.vue
Normal 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>
|
||||||
87
src/components/consultant/CategoryTabs.vue
Normal 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>
|
||||||
636
src/components/consultant/CityFilter.vue
Normal 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>
|
||||||
379
src/components/consultant/ConsultantCard.vue
Normal 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>
|
||||||
152
src/components/consultant/ConsultantCardSkeleton.vue
Normal 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>
|
||||||
89
src/components/consultant/FilterBar.vue
Normal 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>
|
||||||
243
src/components/consultant/FilterPopup.vue
Normal 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>
|
||||||
481
src/components/consultant/IssueFilter.vue
Normal 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>
|
||||||
341
src/components/consultant/ScheduleCalendar.vue
Normal 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>
|
||||||
125
src/components/consultant/SearchBar.vue
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
760
src/pages/booking/confirm.vue
Normal 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
@@ -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>
|
||||||
998
src/pages/consultant/detail.vue
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
263
src/pages/user/index.vue
Normal 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>
|
||||||
2
src/static/icons/.gitkeep
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# 图标资源目录
|
||||||
|
# 将图标文件放在此目录下
|
||||||
BIN
src/static/icons/Expired.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
src/static/icons/Hook.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src/static/icons/Offline-Message.png
Normal file
|
After Width: | Height: | Size: 801 B |
BIN
src/static/icons/Senior-Teacher.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
src/static/icons/Vector.png
Normal file
|
After Width: | Height: | Size: 391 B |
BIN
src/static/icons/Video-Message.png
Normal file
|
After Width: | Height: | Size: 485 B |
BIN
src/static/icons/Voice-Message.png
Normal file
|
After Width: | Height: | Size: 871 B |
BIN
src/static/icons/anxious.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
src/static/icons/arrow-down-s-line.png
Normal file
|
After Width: | Height: | Size: 312 B |
BIN
src/static/icons/atom-02.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
src/static/icons/calendar.png
Normal file
|
After Width: | Height: | Size: 513 B |
BIN
src/static/icons/calm.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
src/static/icons/happy.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
src/static/icons/irritable.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
src/static/icons/map-pin-line.png
Normal file
|
After Width: | Height: | Size: 749 B |
BIN
src/static/icons/sad.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
src/static/icons/search-2-line.png
Normal file
|
After Width: | Height: | Size: 879 B |
BIN
src/static/icons/text-style.png
Normal file
|
After Width: | Height: | Size: 716 B |
BIN
src/static/icons/tired.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
src/static/icons/wechat-pay-fill.png
Normal file
|
After Width: | Height: | Size: 647 B |
BIN
src/static/icons/白熊蹲下扒桌子.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
2
src/static/images/.gitkeep
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# 图片资源目录
|
||||||
|
# 将图片文件放在此目录下
|
||||||
39
src/static/tabbar/.gitkeep
Normal 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. 项目运行后再替换真实图标
|
||||||
BIN
src/static/tabbar/AIBot-a.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
src/static/tabbar/AIBot.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
src/static/tabbar/cloud-a.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src/static/tabbar/cloud.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src/static/tabbar/library-a.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/static/tabbar/library.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src/static/tabbar/message-a.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src/static/tabbar/message.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/static/tabbar/target-a.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src/static/tabbar/target.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
9
src/store/index.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Pinia状态管理入口
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
|
||||||
|
const pinia = createPinia()
|
||||||
|
|
||||||
|
export default pinia
|
||||||
98
src/store/modules/app.js
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,2 @@
|
|||||||
|
/* uView-Plus 主题变量配置 */
|
||||||
|
@import 'uview-plus/theme.scss';
|
||||||
102
src/utils/performance.js
Normal 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
@@ -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
@@ -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()
|
||||||