Showing
57 changed files
with
5953 additions
and
0 deletions
.gitignore
0 → 100644
.history/.gitignore_20260127113815
0 → 100644
File mode changed
.history/.gitignore_20260127113816
0 → 100644
QUICKSTART.md
0 → 100644
| 1 | +# 快速开始指南 | ||
| 2 | + | ||
| 3 | +## 第一步:修改项目配置 | ||
| 4 | + | ||
| 5 | +### 1.1 修改 package.json | ||
| 6 | + | ||
| 7 | +```bash | ||
| 8 | +# 修改项目名称 | ||
| 9 | +"name": "manulife-weapp" # 改为您的项目名称 | ||
| 10 | + | ||
| 11 | +# 修改描述 | ||
| 12 | +"description": "基于Taro 4 + Vue 3 + NutUI的微信小程序模板" # 改为您的项目描述 | ||
| 13 | +``` | ||
| 14 | + | ||
| 15 | +### 1.2 修改服务器配置 | ||
| 16 | + | ||
| 17 | +编辑 `src/utils/config.js`: | ||
| 18 | + | ||
| 19 | +```javascript | ||
| 20 | +const BASE_URL = process.env.NODE_ENV === 'production' | ||
| 21 | + ? 'https://your-production-domain.com' // 🔧 改为生产环境域名 | ||
| 22 | + : 'https://your-dev-domain.com' // 🔧 改为开发环境域名 | ||
| 23 | + | ||
| 24 | +export const REQUEST_DEFAULT_PARAMS = { | ||
| 25 | + f: 'YOUR_MODULE', // 🔧 改为业务模块标识 | ||
| 26 | + client_name: 'YOUR_APP', // 🔧 改为应用名称 | ||
| 27 | +} | ||
| 28 | +``` | ||
| 29 | + | ||
| 30 | +### 1.3 修改微信小程序 AppID | ||
| 31 | + | ||
| 32 | +编辑 `project.config.json`: | ||
| 33 | + | ||
| 34 | +```json | ||
| 35 | +{ | ||
| 36 | + "appid": "your-appid" // 🔧 改为您的微信小程序 AppID | ||
| 37 | +} | ||
| 38 | +``` | ||
| 39 | + | ||
| 40 | +## 第二步:安装依赖 | ||
| 41 | + | ||
| 42 | +```bash | ||
| 43 | +# 进入项目目录 | ||
| 44 | +cd manulife-weapp | ||
| 45 | + | ||
| 46 | +# 安装依赖(推荐使用 pnpm) | ||
| 47 | +pnpm install | ||
| 48 | + | ||
| 49 | +# 或使用 npm | ||
| 50 | +npm install | ||
| 51 | +``` | ||
| 52 | + | ||
| 53 | +## 第三步:定义您的 API 接口 | ||
| 54 | + | ||
| 55 | +编辑 `src/api/index.js`,添加业务接口: | ||
| 56 | + | ||
| 57 | +```javascript | ||
| 58 | +import { buildApiUrl } from '@/utils/tools' | ||
| 59 | + | ||
| 60 | +// 示例:获取用户信息 | ||
| 61 | +export const getUserInfoAPI = (params) => { | ||
| 62 | + return buildApiUrl('getUserInfo', params) | ||
| 63 | +} | ||
| 64 | + | ||
| 65 | +// 示例:提交表单 | ||
| 66 | +export const submitFormAPI = (params) => { | ||
| 67 | + return buildApiUrl('submitForm', params) | ||
| 68 | +} | ||
| 69 | +``` | ||
| 70 | + | ||
| 71 | +## 第四步:配置页面路由 | ||
| 72 | + | ||
| 73 | +编辑 `src/app.config.js`,添加您的页面: | ||
| 74 | + | ||
| 75 | +```javascript | ||
| 76 | +export default { | ||
| 77 | + pages: [ | ||
| 78 | + 'pages/index/index', // 首页 | ||
| 79 | + 'pages/auth/index', // 认证页(必须保留) | ||
| 80 | + 'pages/your-page/index', // 🔧 添加您的页面 | ||
| 81 | + ], | ||
| 82 | + tabBar: { | ||
| 83 | + color: '#999', | ||
| 84 | + selectedColor: '#007AFF', | ||
| 85 | + backgroundColor: '#fff', | ||
| 86 | + list: [ | ||
| 87 | + { | ||
| 88 | + pagePath: 'pages/index/index', | ||
| 89 | + text: '首页', | ||
| 90 | + iconPath: 'assets/images/home.png', | ||
| 91 | + selectedIconPath: 'assets/images/home-active.png' | ||
| 92 | + }, | ||
| 93 | + // 🔧 添加更多 tabbar 页面 | ||
| 94 | + ] | ||
| 95 | + } | ||
| 96 | +} | ||
| 97 | +``` | ||
| 98 | + | ||
| 99 | +## 第五步:启动开发 | ||
| 100 | + | ||
| 101 | +```bash | ||
| 102 | +# 微信小程序开发 | ||
| 103 | +pnpm dev:weapp | ||
| 104 | + | ||
| 105 | +# H5 开发 | ||
| 106 | +pnpm dev:h5 | ||
| 107 | +``` | ||
| 108 | + | ||
| 109 | +## 第六步:微信开发者工具 | ||
| 110 | + | ||
| 111 | +1. 打开微信开发者工具 | ||
| 112 | +2. 导入项目,选择 `dist` 目录 | ||
| 113 | +3. AppID 使用测试号或您的 AppID | ||
| 114 | + | ||
| 115 | +## 核心功能说明 | ||
| 116 | + | ||
| 117 | +### 🔐 认证系统 | ||
| 118 | + | ||
| 119 | +模板已内置完整的微信登录认证流程: | ||
| 120 | + | ||
| 121 | +- **静默认证**:应用启动时自动执行 | ||
| 122 | +- **401 自动刷新**:接口返回 401 时自动刷新会话 | ||
| 123 | +- **授权页回跳**:认证完成后自动返回原页面 | ||
| 124 | + | ||
| 125 | +**重要**:后端需提供 `/srv/?a=openid_wxapp` 接口 | ||
| 126 | + | ||
| 127 | +### 🌐 网络请求 | ||
| 128 | + | ||
| 129 | +使用统一的 HTTP 客户端(`src/utils/request.js`): | ||
| 130 | + | ||
| 131 | +```javascript | ||
| 132 | +import request from '@/api/fn' | ||
| 133 | + | ||
| 134 | +// GET 请求 | ||
| 135 | +export const getListAPI = (params) => { | ||
| 136 | + return request.get('/your-action', params) | ||
| 137 | +} | ||
| 138 | + | ||
| 139 | +// POST 请求 | ||
| 140 | +export const submitDataAPI = (data) => { | ||
| 141 | + return request.post('/your-action', data) | ||
| 142 | +} | ||
| 143 | +``` | ||
| 144 | + | ||
| 145 | +### 🧭 导航跳转 | ||
| 146 | + | ||
| 147 | +使用 `useGo` hook 进行页面跳转: | ||
| 148 | + | ||
| 149 | +```javascript | ||
| 150 | +import { useGo } from '@/hooks/useGo' | ||
| 151 | + | ||
| 152 | +const go = useGo() | ||
| 153 | + | ||
| 154 | +// 跳转到页面(自动补全路径) | ||
| 155 | +go('/your-page') | ||
| 156 | + | ||
| 157 | +// 带参数跳转 | ||
| 158 | +go('/your-page?id=123') | ||
| 159 | + | ||
| 160 | +// TabBar 页面跳转 | ||
| 161 | +go('/index') // 自动使用 switchTab | ||
| 162 | +``` | ||
| 163 | + | ||
| 164 | +### 💾 状态管理 | ||
| 165 | + | ||
| 166 | +使用 Pinia 进行状态管理: | ||
| 167 | + | ||
| 168 | +```javascript | ||
| 169 | +// src/stores/user.js | ||
| 170 | +import { defineStore } from 'pinia' | ||
| 171 | + | ||
| 172 | +export const useUserStore = defineStore('user', { | ||
| 173 | + state: () => ({ | ||
| 174 | + userInfo: null | ||
| 175 | + }), | ||
| 176 | + actions: { | ||
| 177 | + setUserInfo(info) { | ||
| 178 | + this.userInfo = info | ||
| 179 | + } | ||
| 180 | + } | ||
| 181 | +}) | ||
| 182 | +``` | ||
| 183 | + | ||
| 184 | +## 常见问题 | ||
| 185 | + | ||
| 186 | +### Q: 如何去除可选功能? | ||
| 187 | + | ||
| 188 | +如果不需要以下功能,可以直接删除对应文件: | ||
| 189 | + | ||
| 190 | +- **二维码组件**:`src/components/qrCode.vue`、`qrCodeSearch.vue` | ||
| 191 | +- **海报生成器**:`src/components/PosterBuilder/` | ||
| 192 | +- **微信支付**:`src/utils/wechatPay.js`、`src/api/wx/` | ||
| 193 | +- **离线缓存**:`src/composables/useOfflineBookingCache.js`、`useOfflineBookingCachePolling.js` | ||
| 194 | +- **时间选择器**:`src/components/time-picker-data/` | ||
| 195 | + | ||
| 196 | +### Q: 如何修改设计稿尺寸? | ||
| 197 | + | ||
| 198 | +当前配置为双设计宽度体系: | ||
| 199 | +- NutUI 组件:375px | ||
| 200 | +- 其他页面:750px | ||
| 201 | + | ||
| 202 | +如需修改,编辑 `config/index.js` 中的 `designWidth` 配置。 | ||
| 203 | + | ||
| 204 | +### Q: 认证流程不工作? | ||
| 205 | + | ||
| 206 | +1. 检查后端 `/srv/?a=openid_wxapp` 接口是否正常 | ||
| 207 | +2. 检查 `src/utils/config.js` 中的 `BASE_URL` 是否正确 | ||
| 208 | +3. 查看微信开发者工具控制台错误信息 | ||
| 209 | + | ||
| 210 | +## 下一步 | ||
| 211 | + | ||
| 212 | +- 📖 阅读完整文档:`README.md` | ||
| 213 | +- 🎨 查看 NutUI 组件库:https://nutui.jd.com/taro/ | ||
| 214 | +- 📚 Taro 官方文档:https://taro-docs.jd.com/ | ||
| 215 | + | ||
| 216 | +## 需要帮助? | ||
| 217 | + | ||
| 218 | +遇到问题可以: | ||
| 219 | +1. 查看 `README.md` 详细文档 | ||
| 220 | +2. 检查 `src/utils/config.js` 配置 | ||
| 221 | +3. 查看浏览器/小程序控制台错误信息 |
README.md
0 → 100644
| 1 | +# manulife-weapp | ||
| 2 | + | ||
| 3 | +基于 Taro 4 + Vue 3 + NutUI 的微信小程序模板项目 | ||
| 4 | + | ||
| 5 | +## 🚀 快速开始 | ||
| 6 | + | ||
| 7 | +### 安装依赖 | ||
| 8 | +```bash | ||
| 9 | +pnpm install | ||
| 10 | +``` | ||
| 11 | + | ||
| 12 | +### 开发模式 | ||
| 13 | +```bash | ||
| 14 | +# 微信小程序 | ||
| 15 | +pnpm dev:weapp | ||
| 16 | + | ||
| 17 | +# H5 | ||
| 18 | +pnpm dev:h5 | ||
| 19 | + | ||
| 20 | +# 支付宝小程序 | ||
| 21 | +pnpm dev:alipay | ||
| 22 | +``` | ||
| 23 | + | ||
| 24 | +### 生产构建 | ||
| 25 | +```bash | ||
| 26 | +pnpm build:weapp | ||
| 27 | +``` | ||
| 28 | + | ||
| 29 | +## 📁 项目结构 | ||
| 30 | + | ||
| 31 | +``` | ||
| 32 | +src/ | ||
| 33 | +├── api/ # API 接口层 | ||
| 34 | +│ ├── index.js # 业务接口定义(需根据业务修改) | ||
| 35 | +│ ├── fn.js # HTTP 请求封装 | ||
| 36 | +│ └── wx/ # 微信相关接口(可选) | ||
| 37 | +├── assets/ # 静态资源 | ||
| 38 | +│ ├── images/ # 图片资源 | ||
| 39 | +│ ├── styles/ # 全局样式 | ||
| 40 | +│ └── css/ # CSS 文件 | ||
| 41 | +├── components/ # 通用组件 | ||
| 42 | +│ ├── PosterBuilder/ # 海报生成器(可选) | ||
| 43 | +│ ├── time-picker-data/ # 时间选择器 | ||
| 44 | +│ ├── indexNav.vue # 底部导航 | ||
| 45 | +│ └── qrCode.vue # 二维码组件(可选) | ||
| 46 | +├── composables/ # Composition API hooks | ||
| 47 | +│ ├── useOfflineBookingCache.js # 离线缓存 hook | ||
| 48 | +│ └── useOfflineBookingCachePolling.js # 轮询刷新 hook | ||
| 49 | +├── hooks/ # 自定义 hooks | ||
| 50 | +│ └── useGo.js # 导航辅助 hook | ||
| 51 | +├── pages/ # 页面组件 | ||
| 52 | +│ ├── index/ # 首页(示例页面) | ||
| 53 | +│ └── auth/ # 认证页(必须保留) | ||
| 54 | +├── stores/ # Pinia 状态管理 | ||
| 55 | +│ ├── router.js # 路由状态(用于认证回跳) | ||
| 56 | +│ ├── main.js # 主 store | ||
| 57 | +│ ├── host.js # 配置 store | ||
| 58 | +│ └── counter.js # 示例 store | ||
| 59 | +├── utils/ # 工具函数 | ||
| 60 | +│ ├── authRedirect.js # 认证流程核心(必须) | ||
| 61 | +│ ├── request.js # HTTP 客户端核心(必须) | ||
| 62 | +│ ├── network.js # 网络状态监测 | ||
| 63 | +│ ├── config.js # 环境配置(⚠️ 需修改) | ||
| 64 | +│ ├── tools.js # 通用工具 | ||
| 65 | +│ ├── uiText.js # 文案管理 | ||
| 66 | +│ ├── wechatPay.js # 微信支付(可选) | ||
| 67 | +│ ├── mixin.js # Vue mixin | ||
| 68 | +│ ├── polyfill.js # 浏览器兼容 | ||
| 69 | +│ └── weapp.js # 小程序工具 | ||
| 70 | +├── app.js # 应用入口 | ||
| 71 | +├── app.config.js # 页面路由配置 | ||
| 72 | +└── app.less # 全局样式 | ||
| 73 | +``` | ||
| 74 | + | ||
| 75 | +## ⚙️ 配置说明 | ||
| 76 | + | ||
| 77 | +### 1. 修改服务器配置 | ||
| 78 | + | ||
| 79 | +编辑 `src/utils/config.js`: | ||
| 80 | + | ||
| 81 | +```javascript | ||
| 82 | +const BASE_URL = process.env.NODE_ENV === 'production' | ||
| 83 | + ? 'https://your-production-domain.com' // 生产环境域名 | ||
| 84 | + : 'https://your-dev-domain.com' // 开发环境域名 | ||
| 85 | + | ||
| 86 | +export const REQUEST_DEFAULT_PARAMS = { | ||
| 87 | + f: 'YOUR_MODULE', // 业务模块标识 | ||
| 88 | + client_name: 'YOUR_APP', // 应用名称 | ||
| 89 | +} | ||
| 90 | +``` | ||
| 91 | + | ||
| 92 | +### 2. 定义 API 接口 | ||
| 93 | + | ||
| 94 | +编辑 `src/api/index.js`,添加您的业务接口: | ||
| 95 | + | ||
| 96 | +```javascript | ||
| 97 | +import { buildApiUrl } from '@/utils/tools' | ||
| 98 | + | ||
| 99 | +export const yourAPI = (params) => { | ||
| 100 | + return buildApiUrl('your_action', params) | ||
| 101 | +} | ||
| 102 | +``` | ||
| 103 | + | ||
| 104 | +### 3. 配置页面路由 | ||
| 105 | + | ||
| 106 | +编辑 `src/app.config.js`,添加您的页面: | ||
| 107 | + | ||
| 108 | +```javascript | ||
| 109 | +export default { | ||
| 110 | + pages: [ | ||
| 111 | + 'pages/index/index', | ||
| 112 | + 'pages/auth/index', | ||
| 113 | + 'pages/your-page/index', // 添加您的页面 | ||
| 114 | + ], | ||
| 115 | + // ... | ||
| 116 | +} | ||
| 117 | +``` | ||
| 118 | + | ||
| 119 | +### 4. 双设计宽度体系 | ||
| 120 | + | ||
| 121 | +项目采用双设计宽度体系: | ||
| 122 | +- **NutUI 组件**:基准宽度 375px | ||
| 123 | +- **其他所有页面**:基准宽度 750px | ||
| 124 | + | ||
| 125 | +此配置已在 `config/index.js` 中设置,请确保遵循此规范。 | ||
| 126 | + | ||
| 127 | +## 🔐 认证流程 | ||
| 128 | + | ||
| 129 | +项目内置完整的微信登录认证系统: | ||
| 130 | + | ||
| 131 | +1. **静默认证**:应用启动时自动执行静默认证 | ||
| 132 | +2. **401 自动刷新**:当接口返回 401 时,自动刷新会话并重试请求 | ||
| 133 | +3. **授权页回跳**:认证完成后自动跳转回原页面 | ||
| 134 | + | ||
| 135 | +**核心文件**: | ||
| 136 | +- `src/utils/authRedirect.js` - 认证流程管理 | ||
| 137 | +- `src/utils/request.js` - HTTP 请求拦截器 | ||
| 138 | + | ||
| 139 | +**重要**:后端需提供 `/srv/?a=openid_wxapp` 接口用于微信登录。 | ||
| 140 | + | ||
| 141 | +## 🌐 弱网/离线支持 | ||
| 142 | + | ||
| 143 | +项目内置弱网和离线支持: | ||
| 144 | + | ||
| 145 | +1. **请求超时处理**:自动检测网络超时并降级处理 | ||
| 146 | +2. **离线缓存**:支持离线数据缓存和读取 | ||
| 147 | +3. **弱网提示**:统一的弱网提示文案 | ||
| 148 | + | ||
| 149 | +**相关文件**: | ||
| 150 | +- `src/composables/useOfflineBookingCache.js` | ||
| 151 | +- `src/composables/useOfflineBookingCachePolling.js` | ||
| 152 | +- `src/utils/uiText.js` | ||
| 153 | + | ||
| 154 | +## 📦 技术栈 | ||
| 155 | + | ||
| 156 | +- **框架**:Taro 4.x | ||
| 157 | +- **UI 库**:Vue 3 + NutUI 4.x | ||
| 158 | +- **状态管理**:Pinia | ||
| 159 | +- **HTTP**:axios-miniprogram | ||
| 160 | +- **样式**:Less + TailwindCSS | ||
| 161 | +- **构建工具**:Webpack 5 | ||
| 162 | + | ||
| 163 | +## 🎯 路径别名 | ||
| 164 | + | ||
| 165 | +已配置的路径别名: | ||
| 166 | + | ||
| 167 | +```javascript | ||
| 168 | +@/utils -> src/utils | ||
| 169 | +@/components -> src/components | ||
| 170 | +@/images -> src/assets/images | ||
| 171 | +@/assets -> src/assets | ||
| 172 | +@/composables-> src/composables | ||
| 173 | +@/api -> src/api | ||
| 174 | +@/stores -> src/stores | ||
| 175 | +@/hooks -> src/hooks | ||
| 176 | +``` | ||
| 177 | + | ||
| 178 | +## 📝 开发规范 | ||
| 179 | + | ||
| 180 | +### 组件编写 | ||
| 181 | +- 使用 Vue 3 Composition API | ||
| 182 | +- 组件统一放在 `src/components/` 目录 | ||
| 183 | +- Props 定义清晰,注释详细 | ||
| 184 | + | ||
| 185 | +### API 调用 | ||
| 186 | +- 接口统一在 `src/api/index.js` 定义 | ||
| 187 | +- 使用 `xxxAPI(params)` 命名格式 | ||
| 188 | +- 请求方法统一使用 `src/api/fn.js` 中的封装 | ||
| 189 | + | ||
| 190 | +### 状态管理 | ||
| 191 | +- 使用 Pinia 进行状态管理 | ||
| 192 | +- Store 文件统一放在 `src/stores/` 目录 | ||
| 193 | +- 复杂逻辑使用 composables 封装 | ||
| 194 | + | ||
| 195 | +### 样式编写 | ||
| 196 | +- 通用样式使用 TailwindCSS 工具类 | ||
| 197 | +- 组件样式使用 Less | ||
| 198 | +- NutUI 组件使用 375px 设计稿,其他使用 750px | ||
| 199 | + | ||
| 200 | +## 🔧 可选功能 | ||
| 201 | + | ||
| 202 | +以下功能可以根据项目需求选择使用或移除: | ||
| 203 | + | ||
| 204 | +1. **二维码组件**:`src/components/qrCode.vue` | ||
| 205 | +2. **海报生成器**:`src/components/PosterBuilder/` | ||
| 206 | +3. **微信支付**:`src/utils/wechatPay.js`、`src/api/wx/` | ||
| 207 | +4. **时间选择器**:`src/components/time-picker-data/` | ||
| 208 | +5. **离线缓存**:`src/composables/useOfflineBookingCache.js` | ||
| 209 | + | ||
| 210 | +## 📚 相关文档 | ||
| 211 | + | ||
| 212 | +- [Taro 官方文档](https://taro-docs.jd.com/) | ||
| 213 | +- [NutUI 文档](https://nutui.jd.com/taro/) | ||
| 214 | +- [Vue 3 文档](https://cn.vuejs.org/) | ||
| 215 | +- [Pinia 文档](https://pinia.vuejs.org/zh/) | ||
| 216 | + | ||
| 217 | +## ⚠️ 注意事项 | ||
| 218 | + | ||
| 219 | +1. 小程序启动会自动执行静默认证,确保后端接口正常 | ||
| 220 | +2. 请求超时默认 5 秒,可在 `src/utils/request.js` 中修改 | ||
| 221 | +3. NutUI 组件已配置自动导入,无需手动引入 | ||
| 222 | +4. TailwindCSS 已禁用 preflight,避免与小程序样式冲突 | ||
| 223 | +5. 认证失败会自动跳转到 `/pages/auth/index` | ||
| 224 | + | ||
| 225 | +## 📄 License | ||
| 226 | + | ||
| 227 | +MIT |
babel.config.js
0 → 100644
config/dev.js
0 → 100644
| 1 | +/* | ||
| 2 | + * @Date: 2025-06-28 10:33:00 | ||
| 3 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | + * @LastEditTime: 2025-06-28 10:45:27 | ||
| 5 | + * @FilePath: /myApp/config/dev.js | ||
| 6 | + * @Description: 文件描述 | ||
| 7 | + */ | ||
| 8 | +export default { | ||
| 9 | + env: { | ||
| 10 | + NODE_ENV: '"development"' | ||
| 11 | + }, | ||
| 12 | + logger: { | ||
| 13 | + quiet: false, | ||
| 14 | + stats: true | ||
| 15 | + }, | ||
| 16 | + mini: {}, | ||
| 17 | + h5: {} | ||
| 18 | +} |
config/index.js
0 → 100644
| 1 | +import { defineConfig } from '@tarojs/cli' | ||
| 2 | + | ||
| 3 | +import devConfig from './dev' | ||
| 4 | +import prodConfig from './prod' | ||
| 5 | +import NutUIResolver from '@nutui/auto-import-resolver' | ||
| 6 | +import Components from 'unplugin-vue-components/webpack' | ||
| 7 | + | ||
| 8 | +const path = require('path') | ||
| 9 | +const { UnifiedWebpackPluginV5 } = require('weapp-tailwindcss/webpack') | ||
| 10 | + | ||
| 11 | +// https://taro-docs.jd.com/docs/next/config#defineconfig-辅助函数 | ||
| 12 | +export default defineConfig(async (merge) => { | ||
| 13 | + const baseConfig = { | ||
| 14 | + projectName: 'myApp', | ||
| 15 | + date: '2025-6-28', | ||
| 16 | + designWidth (input) { | ||
| 17 | + // 配置 NutUI 375 尺寸 | ||
| 18 | + if (input?.file?.replace(/\\+/g, '/').indexOf('@nutui') > -1) { | ||
| 19 | + return 375 | ||
| 20 | + } | ||
| 21 | + // 全局使用 Taro 默认的 750 尺寸 | ||
| 22 | + return 750 | ||
| 23 | + }, | ||
| 24 | + deviceRatio: { | ||
| 25 | + 640: 2.34 / 2, | ||
| 26 | + 750: 1, | ||
| 27 | + 375: 2, | ||
| 28 | + 828: 1.81 / 2 | ||
| 29 | + }, | ||
| 30 | + alias: { // 配置目录别名 | ||
| 31 | + "@/utils": path.resolve(__dirname, "../src/utils"), | ||
| 32 | + "@/components": path.resolve(__dirname, "../src/components"), | ||
| 33 | + "@/images": path.resolve(__dirname, "../src/assets/images"), | ||
| 34 | + "@/assets": path.resolve(__dirname, "../src/assets"), | ||
| 35 | + "@/composables": path.resolve(__dirname, "../src/composables"), | ||
| 36 | + "@/api": path.resolve(__dirname, "../src/api"), | ||
| 37 | + "@/stores": path.resolve(__dirname, "../src/stores"), | ||
| 38 | + "@/hooks": path.resolve(__dirname, "../src/hooks"), | ||
| 39 | + }, | ||
| 40 | + sourceRoot: 'src', | ||
| 41 | + outputRoot: 'dist', | ||
| 42 | + plugins: ['@tarojs/plugin-html', 'taro-plugin-pinia',], | ||
| 43 | + defineConstants: { | ||
| 44 | + }, | ||
| 45 | + copy: { | ||
| 46 | + patterns: [ | ||
| 47 | + ], | ||
| 48 | + options: { | ||
| 49 | + } | ||
| 50 | + }, | ||
| 51 | + framework: 'vue3', | ||
| 52 | + compiler: { | ||
| 53 | + type: 'webpack5', | ||
| 54 | + prebundle: { | ||
| 55 | + enable: false | ||
| 56 | + } | ||
| 57 | + }, | ||
| 58 | + cache: { | ||
| 59 | + enable: false // Webpack 持久化缓存配置,建议开启。默认配置请参考:https://docs.taro.zone/docs/config-detail#cache | ||
| 60 | + }, | ||
| 61 | + sass:{ | ||
| 62 | + data: `@import "@nutui/nutui-taro/dist/styles/variables.scss";` | ||
| 63 | + }, | ||
| 64 | + mini: { | ||
| 65 | + miniCssExtractPluginOption: { | ||
| 66 | + ignoreOrder: true | ||
| 67 | + }, | ||
| 68 | + postcss: { | ||
| 69 | + pxtransform: { | ||
| 70 | + enable: true, | ||
| 71 | + config: { | ||
| 72 | + | ||
| 73 | + } | ||
| 74 | + }, | ||
| 75 | + // url: { | ||
| 76 | + // enable: true, | ||
| 77 | + // config: { | ||
| 78 | + // limit: 1024 // 设定转换尺寸上限 | ||
| 79 | + // } | ||
| 80 | + // }, | ||
| 81 | + cssModules: { | ||
| 82 | + enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true | ||
| 83 | + config: { | ||
| 84 | + namingPattern: 'module', // 转换模式,取值为 global/module | ||
| 85 | + generateScopedName: '[name]__[local]___[hash:base64:5]' | ||
| 86 | + } | ||
| 87 | + } | ||
| 88 | + }, | ||
| 89 | + webpackChain(chain) { | ||
| 90 | + | ||
| 91 | + chain.plugin('unplugin-vue-components').use(Components({ | ||
| 92 | + resolvers: [NutUIResolver({taro: true})] | ||
| 93 | + })) | ||
| 94 | + | ||
| 95 | + chain.merge({ | ||
| 96 | + plugin: { | ||
| 97 | + install: { | ||
| 98 | + plugin: UnifiedWebpackPluginV5, | ||
| 99 | + args: [{ | ||
| 100 | + appType: 'taro', | ||
| 101 | + // 下面个配置,会开启 rem -> rpx 的转化 | ||
| 102 | + rem2rpx: true, | ||
| 103 | + injectAdditionalCssVarScope: true | ||
| 104 | + }] | ||
| 105 | + } | ||
| 106 | + } | ||
| 107 | + }) | ||
| 108 | + } | ||
| 109 | + }, | ||
| 110 | + h5: { | ||
| 111 | + publicPath: '/', | ||
| 112 | + staticDirectory: 'static', | ||
| 113 | + // esnextModules: ['nutui-taro', 'icons-vue-taro'], | ||
| 114 | + output: { | ||
| 115 | + filename: 'js/[name].[hash:8].js', | ||
| 116 | + chunkFilename: 'js/[name].[chunkhash:8].js' | ||
| 117 | + }, | ||
| 118 | + miniCssExtractPluginOption: { | ||
| 119 | + ignoreOrder: true, | ||
| 120 | + filename: 'css/[name].[hash].css', | ||
| 121 | + chunkFilename: 'css/[name].[chunkhash].css' | ||
| 122 | + }, | ||
| 123 | + postcss: { | ||
| 124 | + autoprefixer: { | ||
| 125 | + enable: true, | ||
| 126 | + config: {} | ||
| 127 | + }, | ||
| 128 | + cssModules: { | ||
| 129 | + enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true | ||
| 130 | + config: { | ||
| 131 | + namingPattern: 'module', // 转换模式,取值为 global/module | ||
| 132 | + generateScopedName: '[name]__[local]___[hash:base64:5]' | ||
| 133 | + } | ||
| 134 | + } | ||
| 135 | + }, | ||
| 136 | + webpackChain(chain) { | ||
| 137 | + | ||
| 138 | + chain.plugin('unplugin-vue-components').use(Components({ | ||
| 139 | + resolvers: [NutUIResolver({taro: true})] | ||
| 140 | + })) | ||
| 141 | + } | ||
| 142 | + }, | ||
| 143 | + rn: { | ||
| 144 | + appName: 'taroDemo', | ||
| 145 | + postcss: { | ||
| 146 | + cssModules: { | ||
| 147 | + enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true | ||
| 148 | + } | ||
| 149 | + } | ||
| 150 | + } | ||
| 151 | + } | ||
| 152 | + if (process.env.NODE_ENV === 'development') { | ||
| 153 | + // 本地开发构建配置(不混淆压缩) | ||
| 154 | + return merge({}, baseConfig, devConfig) | ||
| 155 | + } | ||
| 156 | + // 生产构建配置(默认开启压缩混淆等) | ||
| 157 | + return merge({}, baseConfig, prodConfig) | ||
| 158 | +}) |
config/prod.js
0 → 100644
| 1 | +/* | ||
| 2 | + * @Date: 2025-06-28 10:33:00 | ||
| 3 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | + * @LastEditTime: 2025-06-28 11:00:57 | ||
| 5 | + * @FilePath: /myApp/config/prod.js | ||
| 6 | + * @Description: 文件描述 | ||
| 7 | + */ | ||
| 8 | +export default { | ||
| 9 | + env: { | ||
| 10 | + NODE_ENV: '"production"' | ||
| 11 | + }, | ||
| 12 | + mini: {}, | ||
| 13 | + h5: { | ||
| 14 | + /** | ||
| 15 | + * WebpackChain 插件配置 | ||
| 16 | + * @docs https://github.com/neutrinojs/webpack-chain | ||
| 17 | + */ | ||
| 18 | + // webpackChain (chain) { | ||
| 19 | + // /** | ||
| 20 | + // * 如果 h5 端编译后体积过大,可以使用 webpack-bundle-analyzer 插件对打包体积进行分析。 | ||
| 21 | + // * @docs https://github.com/webpack-contrib/webpack-bundle-analyzer | ||
| 22 | + // */ | ||
| 23 | + // chain.plugin('analyzer') | ||
| 24 | + // .use(require('webpack-bundle-analyzer').BundleAnalyzerPlugin, []) | ||
| 25 | + // /** | ||
| 26 | + // * 如果 h5 端首屏加载时间过长,可以使用 prerender-spa-plugin 插件预加载首页。 | ||
| 27 | + // * @docs https://github.com/chrisvfritz/prerender-spa-plugin | ||
| 28 | + // */ | ||
| 29 | + // const path = require('path') | ||
| 30 | + // const Prerender = require('prerender-spa-plugin') | ||
| 31 | + // const staticDir = path.join(__dirname, '..', 'dist') | ||
| 32 | + // chain | ||
| 33 | + // .plugin('prerender') | ||
| 34 | + // .use(new Prerender({ | ||
| 35 | + // staticDir, | ||
| 36 | + // routes: [ '/pages/index/index' ], | ||
| 37 | + // postProcess: (context) => ({ ...context, outputPath: path.join(staticDir, 'index.html') }) | ||
| 38 | + // })) | ||
| 39 | + // } | ||
| 40 | + } | ||
| 41 | +} |
docs/臻奇智荟圈小程序项目需求20260123a.docx
0 → 100644
No preview for this file type
jsconfig.json
0 → 100644
| 1 | +{ | ||
| 2 | + "compilerOptions": { | ||
| 3 | + "baseUrl": ".", | ||
| 4 | + "paths": { | ||
| 5 | + "@/*": ["src/*"], | ||
| 6 | + "@/utils/*": ["src/utils/*"], | ||
| 7 | + "@/components/*": ["src/components/*"], | ||
| 8 | + "@/images/*": ["src/assets/images/*"], | ||
| 9 | + "@/assets/*": ["src/assets/*"], | ||
| 10 | + "@/composables/*": ["src/composables/*"], | ||
| 11 | + "@/api/*": ["src/api/*"], | ||
| 12 | + "@/stores/*": ["src/stores/*"], | ||
| 13 | + "@/hooks/*": ["src/hooks/*"] | ||
| 14 | + } | ||
| 15 | + }, | ||
| 16 | + "include": ["src/**/*", "config/**/*"] | ||
| 17 | +} |
package.json
0 → 100644
| 1 | +{ | ||
| 2 | + "name": "manulife-weapp", | ||
| 3 | + "version": "1.0.0", | ||
| 4 | + "private": true, | ||
| 5 | + "description": "基于Taro 4 + Vue 3 + NutUI的微信小程序模板", | ||
| 6 | + "templateInfo": { | ||
| 7 | + "name": "vue3-NutUI", | ||
| 8 | + "typescript": false, | ||
| 9 | + "css": "Less", | ||
| 10 | + "framework": "Vue3" | ||
| 11 | + }, | ||
| 12 | + "scripts": { | ||
| 13 | + "build:weapp": "taro build --type weapp", | ||
| 14 | + "build:swan": "taro build --type swan", | ||
| 15 | + "build:alipay": "taro build --type alipay", | ||
| 16 | + "build:tt": "taro build --type tt", | ||
| 17 | + "build:h5": "taro build --type h5", | ||
| 18 | + "build:rn": "taro build --type rn", | ||
| 19 | + "build:qq": "taro build --type qq", | ||
| 20 | + "build:quickapp": "taro build --type quickapp", | ||
| 21 | + "dev:weapp": "NODE_ENV=development taro build --type weapp --watch", | ||
| 22 | + "dev:swan": "NODE_ENV=development taro build --type swan --watch", | ||
| 23 | + "dev:alipay": "NODE_ENV=development taro build --type alipay --watch", | ||
| 24 | + "dev:tt": "NODE_ENV=development taro build --type tt --watch", | ||
| 25 | + "dev:h5": "NODE_ENV=development taro build --type h5 --watch", | ||
| 26 | + "dev:rn": "NODE_ENV=development taro build --type rn --watch", | ||
| 27 | + "dev:qq": "NODE_ENV=development taro build --type qq --watch", | ||
| 28 | + "dev:quickapp": "NODE_ENV=development taro build --type quickapp --watch", | ||
| 29 | + "postinstall": "weapp-tw patch", | ||
| 30 | + "lint": "eslint --ext .js,.vue src" | ||
| 31 | + }, | ||
| 32 | + "browserslist": [ | ||
| 33 | + "last 3 versions", | ||
| 34 | + "Android >= 4.1", | ||
| 35 | + "ios >= 8" | ||
| 36 | + ], | ||
| 37 | + "author": "", | ||
| 38 | + "license": "MIT", | ||
| 39 | + "dependencies": { | ||
| 40 | + "@babel/runtime": "^7.7.7", | ||
| 41 | + "@nutui/icons-vue-taro": "^0.0.9", | ||
| 42 | + "@nutui/nutui-taro": "^4.3.13", | ||
| 43 | + "@tarojs/components": "4.1.9", | ||
| 44 | + "@tarojs/helper": "4.1.9", | ||
| 45 | + "@tarojs/plugin-framework-vue3": "4.1.9", | ||
| 46 | + "@tarojs/plugin-html": "4.1.9", | ||
| 47 | + "@tarojs/plugin-platform-alipay": "4.1.9", | ||
| 48 | + "@tarojs/plugin-platform-h5": "4.1.9", | ||
| 49 | + "@tarojs/plugin-platform-jd": "4.1.9", | ||
| 50 | + "@tarojs/plugin-platform-qq": "4.1.9", | ||
| 51 | + "@tarojs/plugin-platform-swan": "4.1.9", | ||
| 52 | + "@tarojs/plugin-platform-tt": "4.1.9", | ||
| 53 | + "@tarojs/plugin-platform-weapp": "4.1.9", | ||
| 54 | + "@tarojs/runtime": "4.1.9", | ||
| 55 | + "@tarojs/shared": "4.1.9", | ||
| 56 | + "@tarojs/taro": "4.1.9", | ||
| 57 | + "axios-miniprogram": "^2.7.2", | ||
| 58 | + "dayjs": "^1.11.19", | ||
| 59 | + "pinia": "^3.0.3", | ||
| 60 | + "qrcode": "^1.5.4", | ||
| 61 | + "qs": "^6.14.1", | ||
| 62 | + "taro-plugin-pinia": "^1.0.0", | ||
| 63 | + "vue": "^3.3.0", | ||
| 64 | + "xst-solar2lunar": "^2.1.0" | ||
| 65 | + }, | ||
| 66 | + "devDependencies": { | ||
| 67 | + "@babel/core": "^7.26.0", | ||
| 68 | + "@nutui/auto-import-resolver": "^1.0.0", | ||
| 69 | + "@tarojs/cli": "4.1.9", | ||
| 70 | + "@tarojs/taro-loader": "4.1.9", | ||
| 71 | + "@tarojs/webpack5-runner": "4.1.9", | ||
| 72 | + "@types/webpack-env": "^1.13.6", | ||
| 73 | + "@vue/babel-plugin-jsx": "^1.0.6", | ||
| 74 | + "@vue/compiler-sfc": "^3.0.0", | ||
| 75 | + "autoprefixer": "^10.4.21", | ||
| 76 | + "babel-preset-taro": "4.1.9", | ||
| 77 | + "css-loader": "3.4.2", | ||
| 78 | + "eslint": "^8.12.0", | ||
| 79 | + "eslint-config-taro": "4.1.9", | ||
| 80 | + "less": "^4.2.0", | ||
| 81 | + "postcss": "^8.5.6", | ||
| 82 | + "sass": "^1.78.0", | ||
| 83 | + "style-loader": "1.3.0", | ||
| 84 | + "tailwindcss": "^3.4.0", | ||
| 85 | + "unplugin-vue-components": "^0.26.0", | ||
| 86 | + "vue-loader": "^17.0.0", | ||
| 87 | + "weapp-tailwindcss": "^4.1.10", | ||
| 88 | + "webpack": "5.91.0" | ||
| 89 | + }, | ||
| 90 | + "pnpm": { | ||
| 91 | + "onlyBuiltDependencies": [ | ||
| 92 | + "@parcel/watcher", | ||
| 93 | + "@swc/core", | ||
| 94 | + "@tarojs/binding", | ||
| 95 | + "@tarojs/cli", | ||
| 96 | + "core-js", | ||
| 97 | + "core-js-pure", | ||
| 98 | + "esbuild", | ||
| 99 | + "weapp-tailwindcss" | ||
| 100 | + ] | ||
| 101 | + } | ||
| 102 | +} |
postcss.config.js
0 → 100644
| 1 | +/* | ||
| 2 | + * @Date: 2025-06-30 13:27:35 | ||
| 3 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | + * @LastEditTime: 2025-06-30 13:27:42 | ||
| 5 | + * @FilePath: /myApp/postcss.config.js | ||
| 6 | + * @Description: 文件描述 | ||
| 7 | + */ | ||
| 8 | +// postcss 插件以 object 方式注册的话,是按照由上到下的顺序执行的 | ||
| 9 | +module.exports = { | ||
| 10 | + plugins: { | ||
| 11 | + tailwindcss: {}, | ||
| 12 | + autoprefixer: {}, | ||
| 13 | + }, | ||
| 14 | +} |
src/api/common.js
0 → 100644
| 1 | +/* | ||
| 2 | + * @Date: 2022-06-17 14:54:29 | ||
| 3 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | + * @LastEditTime: 2022-06-18 22:18:46 | ||
| 5 | + * @FilePath: /tswj/src/api/common.js | ||
| 6 | + * @Description: 通用接口 | ||
| 7 | + */ | ||
| 8 | +import { fn, fetch, uploadFn } from '@/api/fn'; | ||
| 9 | + | ||
| 10 | +const Api = { | ||
| 11 | + SMS: '/srv/?a=sms', | ||
| 12 | + TOKEN: '/srv/?a=upload', | ||
| 13 | + SAVE_FILE: '/srv/?a=upload&t=save_file', | ||
| 14 | +} | ||
| 15 | + | ||
| 16 | +/** | ||
| 17 | + * @description: 发送验证码 | ||
| 18 | + * @param {Object} params 请求参数 | ||
| 19 | + * @param {string} params.phone 手机号 | ||
| 20 | + * @returns {Promise<{code:number,data:any,msg:string}>} 标准返回 | ||
| 21 | + */ | ||
| 22 | +export const smsAPI = (params) => fn(fetch.post(Api.SMS, params)); | ||
| 23 | + | ||
| 24 | +/** | ||
| 25 | + * @description: 获取七牛token | ||
| 26 | + * @param {Object} params 请求参数 | ||
| 27 | + * @param {string} params.filename 文件名 | ||
| 28 | + * @param {string} params.file 图片 base64 | ||
| 29 | + * @returns {Promise<{code:number,data:any,msg:string}>} 标准返回(data 为上传 token 等信息) | ||
| 30 | + */ | ||
| 31 | +export const qiniuTokenAPI = (params) => fn(fetch.stringifyPost(Api.TOKEN, params)); | ||
| 32 | + | ||
| 33 | +/** | ||
| 34 | + * @description: 上传七牛 | ||
| 35 | + * @param {string} url 七牛上传地址 | ||
| 36 | + * @param {any} data 上传数据 | ||
| 37 | + * @param {Object} config axios 配置 | ||
| 38 | + * @returns {Promise<any|false>} 成功返回七牛响应数据,失败返回 false | ||
| 39 | + */ | ||
| 40 | +export const qiniuUploadAPI = (url, data, config) => uploadFn(fetch.basePost(url, data, config)); | ||
| 41 | + | ||
| 42 | +/** | ||
| 43 | + * @description: 保存图片 | ||
| 44 | + * @param {Object} params 请求参数 | ||
| 45 | + * @param {string} params.format 文件格式 | ||
| 46 | + * @param {string} params.hash 文件 hash | ||
| 47 | + * @param {string|number} params.height 图片高 | ||
| 48 | + * @param {string|number} params.width 图片宽 | ||
| 49 | + * @param {string} params.filekey 文件 key | ||
| 50 | + * @returns {Promise<{code:number,data:any,msg:string}>} 标准返回 | ||
| 51 | + */ | ||
| 52 | +export const saveFileAPI = (params) => fn(fetch.stringifyPost(Api.SAVE_FILE, params)); |
src/api/fn.js
0 → 100644
| 1 | +/* | ||
| 2 | + * @Date: 2022-05-18 22:56:08 | ||
| 3 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | + * @LastEditTime: 2026-01-24 12:52:06 | ||
| 5 | + * @FilePath: /xyxBooking-weapp/src/api/fn.js | ||
| 6 | + * @Description: 统一后端返回格式(强制 { code, data, msg }) | ||
| 7 | + */ | ||
| 8 | +import axios from '@/utils/request'; | ||
| 9 | +import Taro from '@tarojs/taro' | ||
| 10 | +import qs from 'qs' | ||
| 11 | + | ||
| 12 | +/** | ||
| 13 | + * @description 统一后端返回格式(强制 { code, data, msg }) | ||
| 14 | + * - code === 1 表示成功 | ||
| 15 | + * - 失败时统一 toast 提示(可通过 res.data.show=false 禁用提示) | ||
| 16 | + * @param {Promise<any>} api axios 请求 Promise | ||
| 17 | + * @returns {Promise<{code:number,data:any,msg:string,show?:boolean}>} 标准化后的返回对象 | ||
| 18 | + */ | ||
| 19 | +export const fn = (api) => { | ||
| 20 | + return api | ||
| 21 | + .then(res => { | ||
| 22 | + // 约定:后端 code === 1 为成功 | ||
| 23 | + const res_data = res && res.data ? res.data : null | ||
| 24 | + if (res_data && String(res_data.code) === '1') { | ||
| 25 | + return res_data | ||
| 26 | + } | ||
| 27 | + // 失败兜底:优先返回后端响应,同时做 toast 提示 | ||
| 28 | + console.warn('接口请求失败:', res) | ||
| 29 | + if (res_data && res_data.show === false) return res_data | ||
| 30 | + Taro.showToast({ | ||
| 31 | + title: (res_data && res_data.msg) ? res_data.msg : '请求失败', | ||
| 32 | + icon: 'none', | ||
| 33 | + duration: 2000 | ||
| 34 | + }) | ||
| 35 | + return res_data || { code: 0, data: null, msg: '请求失败' } | ||
| 36 | + }) | ||
| 37 | + .catch(err => { | ||
| 38 | + console.error('接口请求异常:', err); | ||
| 39 | + return { code: 0, data: null, msg: (err && (err.msg || err.message || err.errMsg)) ? (err.msg || err.message || err.errMsg) : '网络异常' } | ||
| 40 | + }) | ||
| 41 | + .finally(() => { // 最终执行 | ||
| 42 | + }) | ||
| 43 | +} | ||
| 44 | + | ||
| 45 | +/** | ||
| 46 | + * @description 七牛上传返回格式适配 | ||
| 47 | + * @param {Promise<any>} api axios 请求 Promise | ||
| 48 | + * @returns {Promise<any|false>} 成功返回七牛响应数据,失败返回 false | ||
| 49 | + */ | ||
| 50 | +export const uploadFn = (api) => { | ||
| 51 | + return api | ||
| 52 | + .then(res => { | ||
| 53 | + if (res.statusText === 'OK') { | ||
| 54 | + return res.data || true; | ||
| 55 | + } else { | ||
| 56 | + console.warn('七牛上传失败:', res); | ||
| 57 | + if (!res.data.show) return false; | ||
| 58 | + Taro.showToast({ | ||
| 59 | + title: res.data.msg, | ||
| 60 | + icon: 'none', | ||
| 61 | + duration: 2000 | ||
| 62 | + }); | ||
| 63 | + return false; | ||
| 64 | + } | ||
| 65 | + }) | ||
| 66 | + .catch(err => { | ||
| 67 | + console.error('七牛上传异常:', err); | ||
| 68 | + return false; | ||
| 69 | + }) | ||
| 70 | +} | ||
| 71 | + | ||
| 72 | +/** | ||
| 73 | + * @description 统一 GET/POST 传参形式 | ||
| 74 | + * - get/post:直接透传 axios | ||
| 75 | + * - stringifyPost:表单提交(x-www-form-urlencoded) | ||
| 76 | + * - basePost:保留自定义 config 的扩展位 | ||
| 77 | + */ | ||
| 78 | +export const fetch = { | ||
| 79 | + /** | ||
| 80 | + * @description GET 请求 | ||
| 81 | + * @param {string} api 接口地址 | ||
| 82 | + * @param {Object} params 查询参数 | ||
| 83 | + * @returns {Promise<any>} axios Promise | ||
| 84 | + */ | ||
| 85 | + get: function (api, params) { | ||
| 86 | + return axios.get(api, params) | ||
| 87 | + }, | ||
| 88 | + /** | ||
| 89 | + * @description POST 请求(JSON) | ||
| 90 | + * @param {string} api 接口地址 | ||
| 91 | + * @param {Object} params 请求体 | ||
| 92 | + * @returns {Promise<any>} axios Promise | ||
| 93 | + */ | ||
| 94 | + post: function (api, params) { | ||
| 95 | + return axios.post(api, params) | ||
| 96 | + }, | ||
| 97 | + /** | ||
| 98 | + * @description POST 请求(表单序列化) | ||
| 99 | + * @param {string} api 接口地址 | ||
| 100 | + * @param {Object} params 请求体 | ||
| 101 | + * @returns {Promise<any>} axios Promise | ||
| 102 | + */ | ||
| 103 | + stringifyPost: function (api, params) { | ||
| 104 | + return axios.post(api, qs.stringify(params), { | ||
| 105 | + headers: { | ||
| 106 | + 'content-type': 'application/x-www-form-urlencoded' | ||
| 107 | + } | ||
| 108 | + }) | ||
| 109 | + }, | ||
| 110 | + /** | ||
| 111 | + * @description POST 请求(自定义 config) | ||
| 112 | + * @param {string} url 接口地址 | ||
| 113 | + * @param {any} data 请求体 | ||
| 114 | + * @param {Object} config axios 配置 | ||
| 115 | + * @returns {Promise<any>} axios Promise | ||
| 116 | + */ | ||
| 117 | + basePost: function (url, data, config) { | ||
| 118 | + return axios.post(url, data, config) | ||
| 119 | + } | ||
| 120 | +} |
src/api/index.js
0 → 100644
| 1 | +/** | ||
| 2 | + * @description API 接口定义 | ||
| 3 | + * @Template: 在此定义您的业务 API 接口地址 | ||
| 4 | + */ | ||
| 5 | + | ||
| 6 | +import { buildApiUrl } from '@/utils/tools' | ||
| 7 | + | ||
| 8 | +// ==================== 业务 API 接口示例 ==================== | ||
| 9 | +// 请根据实际业务需求修改或添加接口 | ||
| 10 | + | ||
| 11 | +/** | ||
| 12 | + * 示例:获取用户信息 | ||
| 13 | + * @param {object} params 请求参数 | ||
| 14 | + * @returns {string} 完整的 API URL | ||
| 15 | + */ | ||
| 16 | +export const getUserInfoAPI = (params) => { | ||
| 17 | + return buildApiUrl('getUserInfo', params) | ||
| 18 | +} | ||
| 19 | + | ||
| 20 | +/** | ||
| 21 | + * 示例:提交表单 | ||
| 22 | + * @param {object} params 表单数据 | ||
| 23 | + * @returns {string} 完整的 API URL | ||
| 24 | + */ | ||
| 25 | +export const submitFormAPI = (params) => { | ||
| 26 | + return buildApiUrl('submitForm', params) | ||
| 27 | +} | ||
| 28 | + | ||
| 29 | +// ==================== 微信相关接口 ==================== | ||
| 30 | +// 如果项目需要微信支付,可以保留以下接口 | ||
| 31 | + | ||
| 32 | +/** | ||
| 33 | + * 获取微信支付配置(可选) | ||
| 34 | + * @param {object} params 支付参数 | ||
| 35 | + * @returns {string} 完整的 API URL | ||
| 36 | + */ | ||
| 37 | +export const getWxPayConfigAPI = (params) => { | ||
| 38 | + return buildApiUrl('wx_pay_config', params) | ||
| 39 | +} | ||
| 40 | + | ||
| 41 | +// ==================== 说明 ==================== | ||
| 42 | +/** | ||
| 43 | + * 接口命名规范: | ||
| 44 | + * - 统一使用 xxxAPI(params) 格式 | ||
| 45 | + * - buildApiUrl 第一个参数是接口的 action 名称 | ||
| 46 | + * - 第二个参数是请求参数对象 | ||
| 47 | + * | ||
| 48 | + * 示例: | ||
| 49 | + * export const yourAPI = (params) => buildApiUrl('your_action', params) | ||
| 50 | + */ |
src/api/wx/config.js
0 → 100644
| 1 | +/* | ||
| 2 | + * @Author: hookehuyr hookehuyr@gmail.com | ||
| 3 | + * @Date: 2022-06-09 13:32:44 | ||
| 4 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 5 | + * @LastEditTime: 2022-06-14 14:47:01 | ||
| 6 | + * @FilePath: /tswj/src/api/wx/config.js | ||
| 7 | + * @Description: | ||
| 8 | + */ | ||
| 9 | +import { fn, fetch } from '@/api/fn'; | ||
| 10 | + | ||
| 11 | +const Api = { | ||
| 12 | + WX_JSAPI: '/srv/?a=wx_share', | ||
| 13 | +} | ||
| 14 | + | ||
| 15 | +/** | ||
| 16 | + * @description 获取微信分享配置(wx.config 所需参数) | ||
| 17 | + * @param {Object} params 请求参数 | ||
| 18 | + * @param {string} params.url 当前页面 URL(用于签名) | ||
| 19 | + * @returns {Promise<{code:number,data:any,msg:string}>} 标准返回 | ||
| 20 | + */ | ||
| 21 | +export const wxJsAPI = (params) => fn(fetch.get(Api.WX_JSAPI, params)); |
src/api/wx/jsApiList.js
0 → 100644
| 1 | +/* | ||
| 2 | + * @Date: 2022-06-13 14:18:57 | ||
| 3 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | + * @LastEditTime: 2022-06-13 14:27:21 | ||
| 5 | + * @FilePath: /tswj/src/api/wx/jsApiList.js | ||
| 6 | + * @Description: 文件描述 | ||
| 7 | + */ | ||
| 8 | +/** | ||
| 9 | + * @description 微信 JSSDK 需要注入的 API 列表 | ||
| 10 | + * - 该列表用于 wx.config 的 jsApiList 字段 | ||
| 11 | + * @type {Array<string>} | ||
| 12 | + */ | ||
| 13 | +export const apiList = [ | ||
| 14 | + "updateAppMessageShareData", | ||
| 15 | + "updateTimelineShareData", | ||
| 16 | + "onMenuShareTimeline", | ||
| 17 | + "onMenuShareAppMessage", | ||
| 18 | + "onMenuShareQQ", | ||
| 19 | + "onMenuShareWeibo", | ||
| 20 | + "onMenuShareQZone", | ||
| 21 | + "startRecord", | ||
| 22 | + "stopRecord", | ||
| 23 | + "onVoiceRecordEnd", | ||
| 24 | + "playVoice", | ||
| 25 | + "pauseVoice", | ||
| 26 | + "stopVoice", | ||
| 27 | + "onVoicePlayEnd", | ||
| 28 | + "uploadVoice", | ||
| 29 | + "downloadVoice", | ||
| 30 | + "chooseImage", | ||
| 31 | + "previewImage", | ||
| 32 | + "uploadImage", | ||
| 33 | + "downloadImage", | ||
| 34 | + "translateVoice", | ||
| 35 | + "getNetworkType", | ||
| 36 | + "openLocation", | ||
| 37 | + "getLocation", | ||
| 38 | + "hideOptionMenu", | ||
| 39 | + "showOptionMenu", | ||
| 40 | + "hideMenuItems", | ||
| 41 | + "showMenuItems", | ||
| 42 | + "hideAllNonBaseMenuItem", | ||
| 43 | + "showAllNonBaseMenuItem", | ||
| 44 | + "closeWindow", | ||
| 45 | + "scanQRCode", | ||
| 46 | + "chooseWXPay", | ||
| 47 | + "openProductSpecificView", | ||
| 48 | + "addCard", | ||
| 49 | + "chooseCard", | ||
| 50 | + "openCard" | ||
| 51 | +] |
src/api/wx/pay.js
0 → 100644
| 1 | +/* | ||
| 2 | + * @Author: hookehuyr hookehuyr@gmail.com | ||
| 3 | + * @Date: 2022-06-09 13:32:44 | ||
| 4 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 5 | + * @LastEditTime: 2022-06-09 13:42:06 | ||
| 6 | + * @FilePath: /tswj/src/api/wx/config.js | ||
| 7 | + * @Description: | ||
| 8 | + */ | ||
| 9 | +import { fn, fetch } from '@/api/fn'; | ||
| 10 | + | ||
| 11 | +const Api = { | ||
| 12 | + WX_PAY: '/srv/?a=icbc_pay_wxamp', | ||
| 13 | +} | ||
| 14 | + | ||
| 15 | +/** | ||
| 16 | + * @description: 微信支付接口 | ||
| 17 | + * @param {Object} params 请求参数 | ||
| 18 | + * @param {string} params.pay_id 预约单支付凭证 | ||
| 19 | + * @returns {Promise<{code:number,data:any,msg:string}>} 标准返回(data 为微信支付参数) | ||
| 20 | + */ | ||
| 21 | +export const wxPayAPI = (params) => fn(fetch.post(Api.WX_PAY, params)); |
src/app.config.js
0 → 100644
| 1 | +/* | ||
| 2 | + * @Date: 2025-06-28 10:33:00 | ||
| 3 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | + * @LastEditTime: 2026-01-14 21:35:58 | ||
| 5 | + * @FilePath: /xyxBooking-weapp/src/app.config.js | ||
| 6 | + * @Description: 小程序配置文件 | ||
| 7 | + */ | ||
| 8 | +const pages = [ | ||
| 9 | + 'pages/index/index', | ||
| 10 | + 'pages/auth/index', | ||
| 11 | + 'pages/notice/index', | ||
| 12 | + 'pages/booking/index', | ||
| 13 | + 'pages/submit/index', | ||
| 14 | + 'pages/addVisitor/index', | ||
| 15 | + 'pages/success/index', | ||
| 16 | + 'pages/bookingCode/index', | ||
| 17 | + 'pages/bookingList/index', | ||
| 18 | + 'pages/bookingDetail/index', | ||
| 19 | + 'pages/me/index', | ||
| 20 | + 'pages/search/index', | ||
| 21 | + 'pages/visitorList/index', | ||
| 22 | + 'pages/volunteerLogin/index', | ||
| 23 | + 'pages/verificationResult/index', | ||
| 24 | + 'pages/weakNetwork/index', | ||
| 25 | + 'pages/offlineBookingCode/index', | ||
| 26 | + 'pages/offlineBookingList/index', | ||
| 27 | + 'pages/offlineBookingDetail/index', | ||
| 28 | +] | ||
| 29 | + | ||
| 30 | +if (process.env.NODE_ENV === 'development') { | ||
| 31 | + pages.push('pages/nfcTest/index') | ||
| 32 | + pages.push('pages/tailwindTest/index') | ||
| 33 | +} | ||
| 34 | + | ||
| 35 | +const subpackages = process.env.NODE_ENV === 'development' | ||
| 36 | + ? [ | ||
| 37 | + { | ||
| 38 | + root: 'pages/demo', | ||
| 39 | + pages: ['index'], | ||
| 40 | + }, | ||
| 41 | + ] | ||
| 42 | + : [] | ||
| 43 | + | ||
| 44 | +export default { | ||
| 45 | + pages, | ||
| 46 | + subpackages, | ||
| 47 | + window: { | ||
| 48 | + backgroundTextStyle: 'light', | ||
| 49 | + navigationBarBackgroundColor: '#fff', | ||
| 50 | + navigationBarTitleText: '西园寺预约', | ||
| 51 | + navigationBarTextStyle: 'black', | ||
| 52 | + }, | ||
| 53 | +} |
src/app.js
0 → 100644
| 1 | +/* | ||
| 2 | + * @Date: 2025-06-28 10:33:00 | ||
| 3 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | + * @LastEditTime: 2026-01-17 12:14:16 | ||
| 5 | + * @FilePath: /xyxBooking-weapp/src/app.js | ||
| 6 | + * @Description: 应用入口文件 | ||
| 7 | + */ | ||
| 8 | +import { createApp } from 'vue' | ||
| 9 | +import { createPinia } from 'pinia' | ||
| 10 | +import './utils/polyfill' | ||
| 11 | +import './app.less' | ||
| 12 | +import { saveCurrentPagePath, hasAuth, silentAuth, navigateToAuth } from '@/utils/authRedirect' | ||
| 13 | +import Taro from '@tarojs/taro' | ||
| 14 | +import { refresh_offline_booking_cache, has_offline_booking_cache } from '@/composables/useOfflineBookingCache' | ||
| 15 | +import { is_usable_network, get_network_type } from '@/utils/network' | ||
| 16 | +import { enable_offline_booking_cache_polling } from '@/composables/useOfflineBookingCachePolling' | ||
| 17 | +import { weak_network_text, get_weak_network_modal_use_cache_options } from '@/utils/uiText' | ||
| 18 | + | ||
| 19 | +// 记录是否已展示过网络异常提示弹窗 | ||
| 20 | +let has_shown_network_modal = false | ||
| 21 | +// 记录上一次网络是否可用,用于识别“从可用变为不可用”的场景 | ||
| 22 | +let last_network_usable = null | ||
| 23 | + | ||
| 24 | +const App = createApp({ | ||
| 25 | + // 对应 onLaunch | ||
| 26 | + async onLaunch(options) { | ||
| 27 | + const path = options?.path || '' | ||
| 28 | + const query = options?.query || {} | ||
| 29 | + | ||
| 30 | + const query_string = Object.keys(query) | ||
| 31 | + .map((key) => `${key}=${encodeURIComponent(query[key])}`) | ||
| 32 | + .join('&') | ||
| 33 | + const full_path = query_string ? `${path}?${query_string}` : path | ||
| 34 | + | ||
| 35 | + // 保存当前页面路径,用于授权后跳转回原页面 | ||
| 36 | + if (full_path) { | ||
| 37 | + saveCurrentPagePath(full_path) | ||
| 38 | + } | ||
| 39 | + | ||
| 40 | + /** | ||
| 41 | + * @description 预加载离线预约记录数据(列表+详情) | ||
| 42 | + * - 仅在有授权且网络可用时调用 | ||
| 43 | + * - 成功后将数据存储到本地缓存(key: OFFLINE_BOOKING_DATA) | ||
| 44 | + * @returns {Promise<void>} 无返回值 | ||
| 45 | + */ | ||
| 46 | + const preloadBookingData = async () => { | ||
| 47 | + try { | ||
| 48 | + await refresh_offline_booking_cache() | ||
| 49 | + } catch (e) { | ||
| 50 | + console.error('Preload booking cache failed', e) | ||
| 51 | + } | ||
| 52 | + } | ||
| 53 | + | ||
| 54 | + /** | ||
| 55 | + * @description 判断是否应该跳过网络异常提示弹窗 | ||
| 56 | + * - 仅在当前页面为离线预约列表/详情/核销码页时返回 true | ||
| 57 | + * @returns {boolean} true=跳过提示,false=不跳过 | ||
| 58 | + */ | ||
| 59 | + | ||
| 60 | + const should_skip_network_prompt = () => { | ||
| 61 | + const pages = Taro.getCurrentPages ? Taro.getCurrentPages() : [] | ||
| 62 | + const current_page = pages && pages.length ? pages[pages.length - 1] : null | ||
| 63 | + const current_route = String(current_page?.route || '') | ||
| 64 | + if (!current_route) return false | ||
| 65 | + if (current_route.includes('pages/offlineBookingList/index')) return true | ||
| 66 | + if (current_route.includes('pages/offlineBookingDetail/index')) return true | ||
| 67 | + if (current_route.includes('pages/offlineBookingCode/index')) return true | ||
| 68 | + return false | ||
| 69 | + } | ||
| 70 | + | ||
| 71 | + /** | ||
| 72 | + * @description 处理不良网络情况 | ||
| 73 | + * - 仅在当前页面未跳过提示弹窗时调用 | ||
| 74 | + * - 若有离线预约缓存,则展示弹窗询问是否使用缓存数据 | ||
| 75 | + * - 否则展示简单提示 toast | ||
| 76 | + * @param {string} network_type 网络类型(wifi/4g/5g/3g/none/unknown) | ||
| 77 | + * @returns {Promise<boolean>} true=需要中断后续启动流程,false=继续 | ||
| 78 | + */ | ||
| 79 | + | ||
| 80 | + const handle_bad_network = async (network_type) => { | ||
| 81 | + if (has_shown_network_modal) return false | ||
| 82 | + if (should_skip_network_prompt()) return false | ||
| 83 | + | ||
| 84 | + const is_none_network = network_type === 'none' | ||
| 85 | + const is_weak_network = !is_usable_network(network_type) | ||
| 86 | + if (!is_weak_network) return false | ||
| 87 | + | ||
| 88 | + has_shown_network_modal = true | ||
| 89 | + | ||
| 90 | + if (has_offline_booking_cache()) { | ||
| 91 | + try { | ||
| 92 | + const modal_res = await Taro.showModal(get_weak_network_modal_use_cache_options()) | ||
| 93 | + if (modal_res?.confirm) { | ||
| 94 | + await Taro.reLaunch({ url: '/pages/offlineBookingList/index' }) | ||
| 95 | + return true | ||
| 96 | + } | ||
| 97 | + } catch (e) { | ||
| 98 | + return is_none_network | ||
| 99 | + } | ||
| 100 | + } else { | ||
| 101 | + try { | ||
| 102 | + await Taro.showToast({ title: weak_network_text.toast_title, icon: 'none', duration: 2000 }) | ||
| 103 | + } catch (e) { | ||
| 104 | + return is_none_network | ||
| 105 | + } | ||
| 106 | + } | ||
| 107 | + | ||
| 108 | + return is_none_network | ||
| 109 | + } | ||
| 110 | + | ||
| 111 | + /** | ||
| 112 | + * 监听网络状态变化 | ||
| 113 | + * - 当网络连接且有授权时,预加载离线预约记录数据 | ||
| 114 | + */ | ||
| 115 | + Taro.onNetworkStatusChange((res) => { | ||
| 116 | + const is_connected = res?.isConnected !== false | ||
| 117 | + const network_type = res?.networkType || 'none' | ||
| 118 | + const network_usable = is_connected && is_usable_network(network_type) | ||
| 119 | + | ||
| 120 | + if (network_usable) { | ||
| 121 | + has_shown_network_modal = false | ||
| 122 | + last_network_usable = true | ||
| 123 | + if (hasAuth()) preloadBookingData() | ||
| 124 | + return | ||
| 125 | + } | ||
| 126 | + | ||
| 127 | + const should_prompt = last_network_usable === true || last_network_usable === null | ||
| 128 | + last_network_usable = false | ||
| 129 | + if (should_prompt) { | ||
| 130 | + handle_bad_network(network_type) | ||
| 131 | + } | ||
| 132 | + return | ||
| 133 | + }) | ||
| 134 | + | ||
| 135 | + /** | ||
| 136 | + * @description 处理启动时的不良网络情况(只在启动阶段检查一次) | ||
| 137 | + * - 网络不可用且有离线缓存:询问是否进入离线模式 | ||
| 138 | + * - 网络不可用且无缓存:toast 提示网络不佳 | ||
| 139 | + * @returns {Promise<boolean>} true=进入离线模式并中断启动,false=继续启动 | ||
| 140 | + */ | ||
| 141 | + const handle_bad_network_on_launch = async () => { | ||
| 142 | + /** | ||
| 143 | + * 避免重复提示用户 | ||
| 144 | + * - 仅在首次启动时检查网络情况 | ||
| 145 | + * - 如果用户已展示过提示弹窗,则直接返回 false | ||
| 146 | + */ | ||
| 147 | + if (has_shown_network_modal) return false | ||
| 148 | + | ||
| 149 | + const network_type = await get_network_type() | ||
| 150 | + last_network_usable = is_usable_network(network_type) | ||
| 151 | + return handle_bad_network(network_type) | ||
| 152 | + } | ||
| 153 | + | ||
| 154 | + /** | ||
| 155 | + * @description 尝试在网络可用时预加载离线预约记录数据 | ||
| 156 | + * - 仅在有授权时调用 | ||
| 157 | + * @returns {void} 无返回值 | ||
| 158 | + */ | ||
| 159 | + const try_preload_when_online = () => { | ||
| 160 | + if (!hasAuth()) return | ||
| 161 | + Taro.getNetworkType({ | ||
| 162 | + success: (res) => { | ||
| 163 | + if (is_usable_network(res.networkType)) { | ||
| 164 | + preloadBookingData() | ||
| 165 | + } | ||
| 166 | + } | ||
| 167 | + }) | ||
| 168 | + } | ||
| 169 | + | ||
| 170 | + /** | ||
| 171 | + * @description 授权成功后的共用启动逻辑 | ||
| 172 | + * - 尝试在网络可用时预加载离线预约数据 | ||
| 173 | + * - 启动离线预约缓存轮询(会自行处理网络可用性与引用计数) | ||
| 174 | + * @returns {void} 无返回值 | ||
| 175 | + */ | ||
| 176 | + | ||
| 177 | + const bootstrap_after_auth = () => { | ||
| 178 | + try_preload_when_online() | ||
| 179 | + enable_offline_booking_cache_polling({ interval_ms: 2 * 1000 * 60 }) | ||
| 180 | + } | ||
| 181 | + | ||
| 182 | + // 处理在启动时出现的不良网络情况 | ||
| 183 | + const should_stop = await handle_bad_network_on_launch() | ||
| 184 | + // 如果用户选择进入离线模式,则直接返回 | ||
| 185 | + if (should_stop) return | ||
| 186 | + | ||
| 187 | + /** | ||
| 188 | + * 尝试在有授权时预加载离线预约记录数据 | ||
| 189 | + * - 若无授权,则尝试静默授权 | ||
| 190 | + * - 授权成功后调用 bootstrap_after_auth 启动共用逻辑 | ||
| 191 | + * - 授权失败则跳转至授权页面 | ||
| 192 | + */ | ||
| 193 | + | ||
| 194 | + // 如果用户已授权,则直接调用 bootstrap_after_auth 启动共用逻辑 | ||
| 195 | + if (hasAuth()) { | ||
| 196 | + bootstrap_after_auth() | ||
| 197 | + return | ||
| 198 | + } | ||
| 199 | + | ||
| 200 | + if (path === 'pages/auth/index') return | ||
| 201 | + | ||
| 202 | + try { | ||
| 203 | + // 尝试静默授权 | ||
| 204 | + await silentAuth() | ||
| 205 | + // 授权成功后调用 bootstrap_after_auth 启动共用逻辑 | ||
| 206 | + bootstrap_after_auth() | ||
| 207 | + } catch (error) { | ||
| 208 | + console.error('静默授权失败:', error) | ||
| 209 | + // 授权失败则跳转至授权页面 | ||
| 210 | + navigateToAuth(full_path || undefined) | ||
| 211 | + } | ||
| 212 | + | ||
| 213 | + return | ||
| 214 | + }, | ||
| 215 | + onShow() { | ||
| 216 | + }, | ||
| 217 | +}); | ||
| 218 | + | ||
| 219 | +App.use(createPinia()) | ||
| 220 | + | ||
| 221 | +export default App |
src/app.less
0 → 100644
| 1 | +/** | ||
| 2 | + * @Date: 2025-06-28 10:33:00 | ||
| 3 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | + * @LastEditTime: 2026-01-08 20:14:14 | ||
| 5 | + * @FilePath: /xyxBooking-weapp/src/app.less | ||
| 6 | + * @Description: 全局样式 | ||
| 7 | + */ | ||
| 8 | + | ||
| 9 | +@tailwind base; | ||
| 10 | +@tailwind components; | ||
| 11 | +@tailwind utilities; | ||
| 12 | + | ||
| 13 | +:root { | ||
| 14 | + --nut-primary-color: #A67939; | ||
| 15 | +} |
src/assets/css/content-bg.less
0 → 100644
| 1 | + | ||
| 2 | +.modify-top { | ||
| 3 | + z-index: 36; | ||
| 4 | + position: absolute; | ||
| 5 | + left: 0; | ||
| 6 | + top: 0; | ||
| 7 | + width: 100%; | ||
| 8 | + height: 20rpx; | ||
| 9 | + background-image: url('http://gyzs.onwall.cn/top-xian%402x.png'); | ||
| 10 | + background-size: contain; | ||
| 11 | +} | ||
| 12 | +.content-bg { | ||
| 13 | + /** | ||
| 14 | + * background-color and background-image 共存,不能使用渐变色 | ||
| 15 | + * 图片铺平当时精度提高看看效果 | ||
| 16 | + * 直接用渐变色 | ||
| 17 | + * 不使用渐变色背景 | ||
| 18 | + */ | ||
| 19 | + height: 100%; | ||
| 20 | + min-height: 100vh; | ||
| 21 | + // background-image: url('@images/bg-yellow-duan@2x.png'); | ||
| 22 | + background-image: url('http://gyzs.onwall.cn/bg-yellow-duan%402x.png'); | ||
| 23 | + // background-size: cover; | ||
| 24 | + // background: linear-gradient(360deg, #FDD347 0%, #FFED6D 100%) ; | ||
| 25 | +} |
src/assets/styles/base.less
0 → 100644
| 1 | +@namespace: 'tswj'; | ||
| 2 | + | ||
| 3 | +/* ============ 颜色 ============ */ | ||
| 4 | + | ||
| 5 | +// 主色调 | ||
| 6 | +@base-color: #11D2B1; | ||
| 7 | +// 文字颜色 | ||
| 8 | +@base-font-color: #FFFFFF; | ||
| 9 | + | ||
| 10 | +// 定义一个映射 | ||
| 11 | +#colors() { | ||
| 12 | + base-color: @base-color; | ||
| 13 | + base-font-color: @base-font-color; | ||
| 14 | +} | ||
| 15 | + | ||
| 16 | +// 混合 | ||
| 17 | +.width100 { | ||
| 18 | + width: 100%; | ||
| 19 | +} |
src/components/PosterBuilder/index.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <canvas | ||
| 3 | + type="2d" | ||
| 4 | + :id="canvasId" | ||
| 5 | + :style="`height: ${height}rpx; width:${width}rpx; | ||
| 6 | + position: absolute; | ||
| 7 | + ${debug ? '' : 'transform:translate3d(-9999rpx, 0, 0)'}`" | ||
| 8 | + /> | ||
| 9 | +</template> | ||
| 10 | +<script> | ||
| 11 | +import Taro from "@tarojs/taro" | ||
| 12 | +import { defineComponent, onMounted, ref } from "vue" | ||
| 13 | +import { drawImage, drawText, drawBlock, drawLine } from "./utils/draw.js" | ||
| 14 | +import { | ||
| 15 | + toPx, | ||
| 16 | + toRpx, | ||
| 17 | + getRandomId, | ||
| 18 | + getImageInfo, | ||
| 19 | + getLinearColor, | ||
| 20 | +} from "./utils/tools.js" | ||
| 21 | + | ||
| 22 | +export default defineComponent({ | ||
| 23 | + name: "PosterBuilder", | ||
| 24 | + props: { | ||
| 25 | + showLoading: { | ||
| 26 | + type: Boolean, | ||
| 27 | + default: false, | ||
| 28 | + }, | ||
| 29 | + config: { | ||
| 30 | + type: Object, | ||
| 31 | + default: () => ({}), | ||
| 32 | + }, | ||
| 33 | + }, | ||
| 34 | + emits: ["success", "fail"], | ||
| 35 | + setup(props, context) { | ||
| 36 | + const count = ref(1) | ||
| 37 | + const { | ||
| 38 | + width, | ||
| 39 | + height, | ||
| 40 | + backgroundColor, | ||
| 41 | + texts = [], | ||
| 42 | + blocks = [], | ||
| 43 | + lines = [], | ||
| 44 | + debug = false, | ||
| 45 | + } = props.config || {} | ||
| 46 | + | ||
| 47 | + const canvasId = getRandomId() | ||
| 48 | + | ||
| 49 | + /** | ||
| 50 | + * step1: 初始化图片资源 | ||
| 51 | + * @param {Array} images = imgTask | ||
| 52 | + * @return {Promise} downloadImagePromise | ||
| 53 | + */ | ||
| 54 | + const initImages = (images) => { | ||
| 55 | + const imagesTemp = images.filter((item) => item.url) | ||
| 56 | + const drawList = imagesTemp.map((item, index) => | ||
| 57 | + getImageInfo(item, index) | ||
| 58 | + ) | ||
| 59 | + return Promise.all(drawList) | ||
| 60 | + } | ||
| 61 | + | ||
| 62 | + /** | ||
| 63 | + * step2: 初始化 canvas && 获取其 dom 节点和实例 | ||
| 64 | + * @return {Promise} resolve 里返回其 dom 和实例 | ||
| 65 | + */ | ||
| 66 | + const initCanvas = () => | ||
| 67 | + new Promise((resolve) => { | ||
| 68 | + setTimeout(() => { | ||
| 69 | + const pageInstance = Taro.getCurrentInstance()?.page || {} // 拿到当前页面实例 | ||
| 70 | + const query = Taro.createSelectorQuery().in(pageInstance) // 确定在当前页面内匹配子元素 | ||
| 71 | + query | ||
| 72 | + .select(`#${canvasId}`) | ||
| 73 | + .fields({ node: true, size: true, context: true }, (res) => { | ||
| 74 | + const canvas = res.node | ||
| 75 | + const ctx = canvas.getContext("2d") | ||
| 76 | + resolve({ ctx, canvas }) | ||
| 77 | + }) | ||
| 78 | + .exec() | ||
| 79 | + }, 300) | ||
| 80 | + }) | ||
| 81 | + | ||
| 82 | + /** | ||
| 83 | + * @description 保存绘制的图片 | ||
| 84 | + * @param { object } config | ||
| 85 | + */ | ||
| 86 | + const getTempFile = (canvas) => { | ||
| 87 | + Taro.canvasToTempFilePath( | ||
| 88 | + { | ||
| 89 | + canvas, | ||
| 90 | + success: (result) => { | ||
| 91 | + Taro.hideLoading() | ||
| 92 | + context.emit("success", result) | ||
| 93 | + }, | ||
| 94 | + fail: (error) => { | ||
| 95 | + const { errMsg } = error | ||
| 96 | + if (errMsg === "canvasToTempFilePath:fail:create bitmap failed") { | ||
| 97 | + count.value += 1 | ||
| 98 | + if (count.value <= 3) { | ||
| 99 | + getTempFile(canvas) | ||
| 100 | + } else { | ||
| 101 | + Taro.hideLoading() | ||
| 102 | + Taro.showToast({ | ||
| 103 | + icon: "none", | ||
| 104 | + title: errMsg || "绘制海报失败", | ||
| 105 | + }) | ||
| 106 | + context.emit("fail", errMsg) | ||
| 107 | + } | ||
| 108 | + } | ||
| 109 | + }, | ||
| 110 | + }, | ||
| 111 | + context | ||
| 112 | + ) | ||
| 113 | + } | ||
| 114 | + | ||
| 115 | + /** | ||
| 116 | + * step2: 开始绘制任务 | ||
| 117 | + * @param { Array } drawTasks 待绘制任务 | ||
| 118 | + */ | ||
| 119 | + const startDrawing = async (drawTasks) => { | ||
| 120 | + // TODO: check | ||
| 121 | + // const configHeight = getHeight(config) | ||
| 122 | + const { ctx, canvas } = await initCanvas() | ||
| 123 | + | ||
| 124 | + canvas.width = width | ||
| 125 | + canvas.height = height | ||
| 126 | + | ||
| 127 | + // 设置画布底色 | ||
| 128 | + if (backgroundColor) { | ||
| 129 | + ctx.save() // 保存绘图上下文 | ||
| 130 | + const grd = getLinearColor(ctx, backgroundColor, 0, 0, width, height) | ||
| 131 | + ctx.fillStyle = grd // 设置填充颜色 | ||
| 132 | + ctx.fillRect(0, 0, width, height) // 填充一个矩形 | ||
| 133 | + ctx.restore() // 恢复之前保存的绘图上下文 | ||
| 134 | + } | ||
| 135 | + // 将要画的方块、文字、线条放进队列数组 | ||
| 136 | + const queue = drawTasks | ||
| 137 | + .concat( | ||
| 138 | + texts.map((item) => { | ||
| 139 | + item.type = "text" | ||
| 140 | + item.zIndex = item.zIndex || 0 | ||
| 141 | + return item | ||
| 142 | + }) | ||
| 143 | + ) | ||
| 144 | + .concat( | ||
| 145 | + blocks.map((item) => { | ||
| 146 | + item.type = "block" | ||
| 147 | + item.zIndex = item.zIndex || 0 | ||
| 148 | + return item | ||
| 149 | + }) | ||
| 150 | + ) | ||
| 151 | + .concat( | ||
| 152 | + lines.map((item) => { | ||
| 153 | + item.type = "line" | ||
| 154 | + item.zIndex = item.zIndex || 0 | ||
| 155 | + return item | ||
| 156 | + }) | ||
| 157 | + ) | ||
| 158 | + | ||
| 159 | + queue.sort((a, b) => a.zIndex - b.zIndex) // 按照层叠顺序由低至高排序, 先画低的,再画高的 | ||
| 160 | + for (let i = 0; i < queue.length; i++) { | ||
| 161 | + const drawOptions = { | ||
| 162 | + canvas, | ||
| 163 | + ctx, | ||
| 164 | + toPx, | ||
| 165 | + toRpx, | ||
| 166 | + } | ||
| 167 | + if (queue[i].type === "image") { | ||
| 168 | + await drawImage(queue[i], drawOptions) | ||
| 169 | + } else if (queue[i].type === "text") { | ||
| 170 | + drawText(queue[i], drawOptions) | ||
| 171 | + } else if (queue[i].type === "block") { | ||
| 172 | + drawBlock(queue[i], drawOptions) | ||
| 173 | + } else if (queue[i].type === "line") { | ||
| 174 | + drawLine(queue[i], drawOptions) | ||
| 175 | + } | ||
| 176 | + } | ||
| 177 | + | ||
| 178 | + setTimeout(() => { | ||
| 179 | + getTempFile(canvas) // 需要做延时才能能正常加载图片 | ||
| 180 | + }, 300) | ||
| 181 | + } | ||
| 182 | + | ||
| 183 | + // start: 初始化 canvas 实例 && 下载图片资源 | ||
| 184 | + const init = () => { | ||
| 185 | + if (props.showLoading) | ||
| 186 | + Taro.showLoading({ mask: true, title: "生成中..." }) | ||
| 187 | + if (props.config?.images?.length) { | ||
| 188 | + initImages(props.config.images) | ||
| 189 | + .then((result) => { | ||
| 190 | + // 1. 下载图片资源 | ||
| 191 | + startDrawing(result) | ||
| 192 | + }) | ||
| 193 | + .catch((err) => { | ||
| 194 | + Taro.hideLoading() | ||
| 195 | + Taro.showToast({ | ||
| 196 | + icon: "none", | ||
| 197 | + title: err.errMsg || "下载图片失败", | ||
| 198 | + }) | ||
| 199 | + context.emit("fail", err) | ||
| 200 | + }) | ||
| 201 | + } else { | ||
| 202 | + startDrawing([]) | ||
| 203 | + } | ||
| 204 | + } | ||
| 205 | + | ||
| 206 | + onMounted(() => { | ||
| 207 | + init() | ||
| 208 | + }) | ||
| 209 | + | ||
| 210 | + return { | ||
| 211 | + canvasId, | ||
| 212 | + debug, | ||
| 213 | + width, | ||
| 214 | + height, | ||
| 215 | + } | ||
| 216 | + }, | ||
| 217 | +}) | ||
| 218 | +</script> |
src/components/PosterBuilder/utils/draw.js
0 → 100644
| 1 | +import { getLinearColor, getTextX, toPx } from './tools' | ||
| 2 | + | ||
| 3 | +const drawRadiusRect = ({ x, y, w, h, r }, { ctx }) => { | ||
| 4 | + const minSize = Math.min(w, h) | ||
| 5 | + if (r > minSize / 2) r = minSize / 2 | ||
| 6 | + ctx.beginPath() | ||
| 7 | + ctx.moveTo(x + r, y) | ||
| 8 | + ctx.arcTo(x + w, y, x + w, y + h, r) | ||
| 9 | + ctx.arcTo(x + w, y + h, x, y + h, r) | ||
| 10 | + ctx.arcTo(x, y + h, x, y, r) | ||
| 11 | + ctx.arcTo(x, y, x + w, y, r) | ||
| 12 | + ctx.closePath() | ||
| 13 | +} | ||
| 14 | + | ||
| 15 | +const drawRadiusGroupRect = ({ x, y, w, h, g }, { ctx }) => { | ||
| 16 | + const [ | ||
| 17 | + borderTopLeftRadius, | ||
| 18 | + borderTopRightRadius, | ||
| 19 | + borderBottomRightRadius, | ||
| 20 | + borderBottomLeftRadius | ||
| 21 | + ] = g | ||
| 22 | + ctx.beginPath() | ||
| 23 | + ctx.arc( | ||
| 24 | + x + w - borderBottomRightRadius, | ||
| 25 | + y + h - borderBottomRightRadius, | ||
| 26 | + borderBottomRightRadius, | ||
| 27 | + 0, | ||
| 28 | + Math.PI * 0.5 | ||
| 29 | + ) | ||
| 30 | + ctx.lineTo(x + borderBottomLeftRadius, y + h) | ||
| 31 | + ctx.arc( | ||
| 32 | + x + borderBottomLeftRadius, | ||
| 33 | + y + h - borderBottomLeftRadius, | ||
| 34 | + borderBottomLeftRadius, | ||
| 35 | + Math.PI * 0.5, | ||
| 36 | + Math.PI | ||
| 37 | + ) | ||
| 38 | + ctx.lineTo(x, y + borderTopLeftRadius) | ||
| 39 | + ctx.arc( | ||
| 40 | + x + borderTopLeftRadius, | ||
| 41 | + y + borderTopLeftRadius, | ||
| 42 | + borderTopLeftRadius, | ||
| 43 | + Math.PI, | ||
| 44 | + Math.PI * 1.5 | ||
| 45 | + ) | ||
| 46 | + ctx.lineTo(x + w - borderTopRightRadius, y) | ||
| 47 | + ctx.arc( | ||
| 48 | + x + w - borderTopRightRadius, | ||
| 49 | + y + borderTopRightRadius, | ||
| 50 | + borderTopRightRadius, | ||
| 51 | + Math.PI * 1.5, | ||
| 52 | + Math.PI * 2 | ||
| 53 | + ) | ||
| 54 | + ctx.lineTo(x + w, y + h - borderBottomRightRadius) | ||
| 55 | + ctx.closePath() | ||
| 56 | +} | ||
| 57 | + | ||
| 58 | +const getTextWidth = (text, drawOptions) => { | ||
| 59 | + const { ctx } = drawOptions | ||
| 60 | + let texts = [] | ||
| 61 | + if (Object.prototype.toString.call(text) === '[object Object]') { | ||
| 62 | + texts.push(text) | ||
| 63 | + } else { | ||
| 64 | + texts = text | ||
| 65 | + } | ||
| 66 | + let width = 0 | ||
| 67 | + texts.forEach( | ||
| 68 | + ({ | ||
| 69 | + fontSize, | ||
| 70 | + text: textStr, | ||
| 71 | + fontStyle = 'normal', | ||
| 72 | + fontWeight = 'normal', | ||
| 73 | + fontFamily = 'sans-serif', | ||
| 74 | + marginLeft = 0, | ||
| 75 | + marginRight = 0 | ||
| 76 | + }) => { | ||
| 77 | + ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px ${fontFamily}` | ||
| 78 | + width += ctx.measureText(textStr).width + marginLeft + marginRight | ||
| 79 | + } | ||
| 80 | + ) | ||
| 81 | + return width | ||
| 82 | +} | ||
| 83 | + | ||
| 84 | +const drawSingleText = (drawData, drawOptions) => { | ||
| 85 | + const { | ||
| 86 | + x = 0, | ||
| 87 | + y = 0, | ||
| 88 | + text, | ||
| 89 | + color, | ||
| 90 | + width, | ||
| 91 | + fontSize = 28, | ||
| 92 | + baseLine = 'top', | ||
| 93 | + textAlign = 'left', | ||
| 94 | + opacity = 1, | ||
| 95 | + textDecoration = 'none', | ||
| 96 | + lineNum = 1, | ||
| 97 | + lineHeight = 0, | ||
| 98 | + fontWeight = 'normal', | ||
| 99 | + fontStyle = 'normal', | ||
| 100 | + fontFamily = 'sans-serif' | ||
| 101 | + } = drawData | ||
| 102 | + const { ctx } = drawOptions | ||
| 103 | + ctx.save() | ||
| 104 | + ctx.beginPath() | ||
| 105 | + ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px ${fontFamily}` | ||
| 106 | + ctx.globalAlpha = opacity | ||
| 107 | + ctx.fillStyle = color | ||
| 108 | + ctx.textBaseline = baseLine | ||
| 109 | + ctx.textAlign = textAlign | ||
| 110 | + let textWidth = ctx.measureText(text).width | ||
| 111 | + const textArr = [] | ||
| 112 | + | ||
| 113 | + if (textWidth > width) { | ||
| 114 | + let fillText = '' | ||
| 115 | + let line = 1 | ||
| 116 | + for (let i = 0; i <= text.length - 1; i++) { | ||
| 117 | + fillText += text[i] | ||
| 118 | + const nextText = i < text.length - 1 ? fillText + text[i + 1] : fillText | ||
| 119 | + const restWidth = width - ctx.measureText(nextText).width | ||
| 120 | + | ||
| 121 | + if (restWidth < 0) { | ||
| 122 | + if (line === lineNum) { | ||
| 123 | + if ( | ||
| 124 | + restWidth + ctx.measureText(text[i + 1]).width > | ||
| 125 | + ctx.measureText('...').width | ||
| 126 | + ) { | ||
| 127 | + fillText = `${fillText}...` | ||
| 128 | + } else { | ||
| 129 | + fillText = `${fillText.substr(0, fillText.length - 1)}...` | ||
| 130 | + } | ||
| 131 | + textArr.push(fillText) | ||
| 132 | + break | ||
| 133 | + } else { | ||
| 134 | + textArr.push(fillText) | ||
| 135 | + line++ | ||
| 136 | + fillText = '' | ||
| 137 | + } | ||
| 138 | + } else if (i === text.length - 1) { | ||
| 139 | + textArr.push(fillText) | ||
| 140 | + } | ||
| 141 | + } | ||
| 142 | + textWidth = width | ||
| 143 | + } else { | ||
| 144 | + textArr.push(text) | ||
| 145 | + } | ||
| 146 | + | ||
| 147 | + textArr.forEach((item, index) => | ||
| 148 | + ctx.fillText( | ||
| 149 | + item, | ||
| 150 | + getTextX(textAlign, x, width), | ||
| 151 | + y + (lineHeight || fontSize) * index | ||
| 152 | + ) | ||
| 153 | + ) | ||
| 154 | + ctx.restore() | ||
| 155 | + | ||
| 156 | + if (textDecoration !== 'none') { | ||
| 157 | + let lineY = y | ||
| 158 | + if (textDecoration === 'line-through') { | ||
| 159 | + lineY = y | ||
| 160 | + } | ||
| 161 | + ctx.save() | ||
| 162 | + ctx.moveTo(x, lineY) | ||
| 163 | + ctx.lineTo(x + textWidth, lineY) | ||
| 164 | + ctx.strokeStyle = color | ||
| 165 | + ctx.stroke() | ||
| 166 | + ctx.restore() | ||
| 167 | + } | ||
| 168 | + return textWidth | ||
| 169 | +} | ||
| 170 | + | ||
| 171 | +export function drawText(params, drawOptions) { | ||
| 172 | + const { x = 0, y = 0, text, baseLine } = params | ||
| 173 | + if (Object.prototype.toString.call(text) === '[object Array]') { | ||
| 174 | + const preText = { x, y, baseLine } | ||
| 175 | + text.forEach((item) => { | ||
| 176 | + preText.x += item.marginLeft || 0 | ||
| 177 | + const textWidth = drawSingleText( | ||
| 178 | + Object.assign(item, { ...preText, y: y + (item.marginTop || 0) }), | ||
| 179 | + drawOptions | ||
| 180 | + ) | ||
| 181 | + preText.x += textWidth + (item.marginRight || 0) | ||
| 182 | + }) | ||
| 183 | + } else { | ||
| 184 | + drawSingleText(params, drawOptions) | ||
| 185 | + } | ||
| 186 | +} | ||
| 187 | + | ||
| 188 | +export function drawLine(drawData, drawOptions) { | ||
| 189 | + const { startX, startY, endX, endY, color, width } = drawData | ||
| 190 | + const { ctx } = drawOptions | ||
| 191 | + if (!width) return | ||
| 192 | + ctx.save() | ||
| 193 | + ctx.beginPath() | ||
| 194 | + ctx.strokeStyle = color | ||
| 195 | + ctx.lineWidth = width | ||
| 196 | + ctx.moveTo(startX, startY) | ||
| 197 | + ctx.lineTo(endX, endY) | ||
| 198 | + ctx.stroke() | ||
| 199 | + ctx.closePath() | ||
| 200 | + ctx.restore() | ||
| 201 | +} | ||
| 202 | + | ||
| 203 | +export function drawBlock(data, drawOptions) { | ||
| 204 | + const { | ||
| 205 | + x, | ||
| 206 | + y, | ||
| 207 | + text, | ||
| 208 | + width = 0, | ||
| 209 | + height, | ||
| 210 | + opacity = 1, | ||
| 211 | + paddingLeft = 0, | ||
| 212 | + paddingRight = 0, | ||
| 213 | + borderWidth, | ||
| 214 | + backgroundColor, | ||
| 215 | + borderColor, | ||
| 216 | + borderRadius = 0, | ||
| 217 | + borderRadiusGroup = null | ||
| 218 | + } = data || {} | ||
| 219 | + const { ctx } = drawOptions | ||
| 220 | + ctx.save() | ||
| 221 | + ctx.globalAlpha = opacity | ||
| 222 | + | ||
| 223 | + let blockWidth = 0 | ||
| 224 | + let textX = 0 | ||
| 225 | + let textY = 0 | ||
| 226 | + | ||
| 227 | + if (text) { | ||
| 228 | + const textWidth = getTextWidth( | ||
| 229 | + typeof text.text === 'string' ? text : text.text, | ||
| 230 | + drawOptions | ||
| 231 | + ) | ||
| 232 | + blockWidth = textWidth > width ? textWidth : width | ||
| 233 | + blockWidth += paddingLeft + paddingLeft | ||
| 234 | + | ||
| 235 | + const { textAlign = 'left' } = text | ||
| 236 | + textY = y | ||
| 237 | + textX = x + paddingLeft | ||
| 238 | + | ||
| 239 | + if (textAlign === 'center') { | ||
| 240 | + textX = blockWidth / 2 + x | ||
| 241 | + } else if (textAlign === 'right') { | ||
| 242 | + textX = x + blockWidth - paddingRight | ||
| 243 | + } | ||
| 244 | + drawText(Object.assign(text, { x: textX, y: textY }), drawOptions) | ||
| 245 | + } else { | ||
| 246 | + blockWidth = width | ||
| 247 | + } | ||
| 248 | + | ||
| 249 | + if (backgroundColor) { | ||
| 250 | + const grd = getLinearColor(ctx, backgroundColor, x, y, blockWidth, height) | ||
| 251 | + ctx.fillStyle = grd | ||
| 252 | + | ||
| 253 | + if (borderRadius > 0) { | ||
| 254 | + const drawData = { | ||
| 255 | + x, | ||
| 256 | + y, | ||
| 257 | + w: blockWidth, | ||
| 258 | + h: height, | ||
| 259 | + r: borderRadius | ||
| 260 | + } | ||
| 261 | + drawRadiusRect(drawData, drawOptions) | ||
| 262 | + ctx.fill() | ||
| 263 | + } else if (borderRadiusGroup) { | ||
| 264 | + const drawData = { | ||
| 265 | + x, | ||
| 266 | + y, | ||
| 267 | + w: blockWidth, | ||
| 268 | + h: height, | ||
| 269 | + g: borderRadiusGroup | ||
| 270 | + } | ||
| 271 | + drawRadiusGroupRect(drawData, drawOptions) | ||
| 272 | + ctx.fill() | ||
| 273 | + } else { | ||
| 274 | + ctx.fillRect(x, y, blockWidth, height) | ||
| 275 | + } | ||
| 276 | + } | ||
| 277 | + | ||
| 278 | + if (borderWidth && borderRadius > 0) { | ||
| 279 | + ctx.strokeStyle = borderColor | ||
| 280 | + ctx.lineWidth = borderWidth | ||
| 281 | + if (borderRadius > 0) { | ||
| 282 | + const drawData = { | ||
| 283 | + x, | ||
| 284 | + y, | ||
| 285 | + w: blockWidth, | ||
| 286 | + h: height, | ||
| 287 | + r: borderRadius | ||
| 288 | + } | ||
| 289 | + drawRadiusRect(drawData, drawOptions) | ||
| 290 | + ctx.stroke() | ||
| 291 | + } else { | ||
| 292 | + ctx.strokeRect(x, y, blockWidth, height) | ||
| 293 | + } | ||
| 294 | + } | ||
| 295 | + ctx.restore() | ||
| 296 | +} | ||
| 297 | + | ||
| 298 | +export const drawImage = (data, drawOptions) => | ||
| 299 | + new Promise((resolve) => { | ||
| 300 | + const { canvas, ctx } = drawOptions | ||
| 301 | + const { | ||
| 302 | + x, | ||
| 303 | + y, | ||
| 304 | + w, | ||
| 305 | + h, | ||
| 306 | + sx, | ||
| 307 | + sy, | ||
| 308 | + sw, | ||
| 309 | + sh, | ||
| 310 | + imgPath, | ||
| 311 | + borderRadius = 0, | ||
| 312 | + borderWidth = 0, | ||
| 313 | + borderColor, | ||
| 314 | + borderRadiusGroup = null | ||
| 315 | + } = data | ||
| 316 | + | ||
| 317 | + ctx.save() | ||
| 318 | + if (borderRadius > 0) { | ||
| 319 | + drawRadiusRect( | ||
| 320 | + { | ||
| 321 | + x, | ||
| 322 | + y, | ||
| 323 | + w, | ||
| 324 | + h, | ||
| 325 | + r: borderRadius | ||
| 326 | + }, | ||
| 327 | + drawOptions | ||
| 328 | + ) | ||
| 329 | + ctx.clip() | ||
| 330 | + ctx.fill() | ||
| 331 | + const img = canvas.createImage() | ||
| 332 | + img.src = imgPath | ||
| 333 | + img.onload = () => { | ||
| 334 | + ctx.drawImage(img, toPx(sx), toPx(sy), toPx(sw), toPx(sh), x, y, w, h) | ||
| 335 | + if (borderWidth > 0) { | ||
| 336 | + ctx.strokeStyle = borderColor | ||
| 337 | + ctx.lineWidth = borderWidth | ||
| 338 | + ctx.stroke() | ||
| 339 | + } | ||
| 340 | + resolve() | ||
| 341 | + ctx.restore() | ||
| 342 | + } | ||
| 343 | + } else if (borderRadiusGroup) { | ||
| 344 | + drawRadiusGroupRect( | ||
| 345 | + { | ||
| 346 | + x, | ||
| 347 | + y, | ||
| 348 | + w, | ||
| 349 | + h, | ||
| 350 | + g: borderRadiusGroup | ||
| 351 | + }, | ||
| 352 | + drawOptions | ||
| 353 | + ) | ||
| 354 | + ctx.clip() | ||
| 355 | + ctx.fill() | ||
| 356 | + const img = canvas.createImage() | ||
| 357 | + img.src = imgPath | ||
| 358 | + img.onload = () => { | ||
| 359 | + ctx.drawImage(img, toPx(sx), toPx(sy), toPx(sw), toPx(sh), x, y, w, h) | ||
| 360 | + resolve() | ||
| 361 | + ctx.restore() | ||
| 362 | + } | ||
| 363 | + } else { | ||
| 364 | + const img = canvas.createImage() | ||
| 365 | + img.src = imgPath | ||
| 366 | + img.onload = () => { | ||
| 367 | + ctx.drawImage(img, toPx(sx), toPx(sy), toPx(sw), toPx(sh), x, y, w, h) | ||
| 368 | + resolve() | ||
| 369 | + ctx.restore() | ||
| 370 | + } | ||
| 371 | + } | ||
| 372 | + }) |
src/components/PosterBuilder/utils/tools.js
0 → 100644
| 1 | +import Taro from '@tarojs/taro' | ||
| 2 | + | ||
| 3 | +/** | ||
| 4 | + * @description 生成随机字符串(递归补齐长度) | ||
| 5 | + * @param {number} length 目标长度 | ||
| 6 | + * @returns {string} 随机字符串 | ||
| 7 | + */ | ||
| 8 | +export function randomString(length) { | ||
| 9 | + let str = Math.random().toString(36).substr(2) | ||
| 10 | + if (str.length >= length) { | ||
| 11 | + return str.substr(0, length) | ||
| 12 | + } | ||
| 13 | + str += randomString(length - str.length) | ||
| 14 | + return str | ||
| 15 | +} | ||
| 16 | + | ||
| 17 | +/** | ||
| 18 | + * @description 生成随机 id(常用于 canvasId) | ||
| 19 | + * @param {string} prefix 前缀 | ||
| 20 | + * @param {number} length 随机段长度 | ||
| 21 | + * @returns {string} 随机 id | ||
| 22 | + */ | ||
| 23 | +export function getRandomId(prefix = 'canvas', length = 10) { | ||
| 24 | + return prefix + randomString(length) | ||
| 25 | +} | ||
| 26 | + | ||
| 27 | +/** | ||
| 28 | + * @description 将 http 链接转换为 https(小程序部分场景要求 https) | ||
| 29 | + * @param {string} rawUrl 原始 url | ||
| 30 | + * @returns {string} 处理后的 url | ||
| 31 | + */ | ||
| 32 | +export function mapHttpToHttps(rawUrl) { | ||
| 33 | + if (rawUrl.indexOf(':') < 0 || rawUrl.startsWith('http://tmp')) { | ||
| 34 | + return rawUrl | ||
| 35 | + } | ||
| 36 | + const urlComponent = rawUrl.split(':') | ||
| 37 | + if (urlComponent.length === 2) { | ||
| 38 | + if (urlComponent[0] === 'http') { | ||
| 39 | + urlComponent[0] = 'https' | ||
| 40 | + return `${urlComponent[0]}:${urlComponent[1]}` | ||
| 41 | + } | ||
| 42 | + } | ||
| 43 | + return rawUrl | ||
| 44 | +} | ||
| 45 | + | ||
| 46 | +/** | ||
| 47 | + * @description 获取 rpx 与 px 的换算系数(以 750 设计稿为基准) | ||
| 48 | + * @returns {number} 系数(screenWidth / 750) | ||
| 49 | + */ | ||
| 50 | +export const getFactor = () => { | ||
| 51 | + const sysInfo = Taro.getSystemInfoSync() | ||
| 52 | + const { screenWidth } = sysInfo | ||
| 53 | + return screenWidth / 750 | ||
| 54 | +} | ||
| 55 | + | ||
| 56 | +/** | ||
| 57 | + * @description rpx 转 px | ||
| 58 | + * @param {number} rpx rpx 值 | ||
| 59 | + * @param {number} factor 换算系数 | ||
| 60 | + * @returns {number} px 值(整数) | ||
| 61 | + */ | ||
| 62 | +export const toPx = (rpx, factor = getFactor()) => | ||
| 63 | + parseInt(String(rpx * factor), 10) | ||
| 64 | + | ||
| 65 | +/** | ||
| 66 | + * @description px 转 rpx | ||
| 67 | + * @param {number} px px 值 | ||
| 68 | + * @param {number} factor 换算系数 | ||
| 69 | + * @returns {number} rpx 值(整数) | ||
| 70 | + */ | ||
| 71 | +export const toRpx = (px, factor = getFactor()) => | ||
| 72 | + parseInt(String(px / factor), 10) | ||
| 73 | + | ||
| 74 | +/** | ||
| 75 | + * @description 下载图片到本地临时路径(避免跨域/协议限制) | ||
| 76 | + * - 已是本地路径/用户数据路径时直接返回 | ||
| 77 | + * @param {string} url 图片地址 | ||
| 78 | + * @returns {Promise<string>} 本地可用的图片路径 | ||
| 79 | + */ | ||
| 80 | +export function downImage(url) { | ||
| 81 | + return new Promise((resolve, reject) => { | ||
| 82 | + const wx_user_data_path = | ||
| 83 | + (typeof wx !== 'undefined' && wx && wx.env && wx.env.USER_DATA_PATH) | ||
| 84 | + ? wx.env.USER_DATA_PATH | ||
| 85 | + : '' | ||
| 86 | + const is_local_user_path = wx_user_data_path | ||
| 87 | + ? new RegExp(wx_user_data_path).test(url) | ||
| 88 | + : false | ||
| 89 | + | ||
| 90 | + if (/^http/.test(url) && !is_local_user_path) { | ||
| 91 | + Taro.downloadFile({ | ||
| 92 | + url: mapHttpToHttps(url), | ||
| 93 | + success: (res) => { | ||
| 94 | + if (res.statusCode === 200) { | ||
| 95 | + resolve(res.tempFilePath) | ||
| 96 | + } else { | ||
| 97 | + reject(res) | ||
| 98 | + } | ||
| 99 | + }, | ||
| 100 | + fail(err) { | ||
| 101 | + reject(err) | ||
| 102 | + } | ||
| 103 | + }) | ||
| 104 | + } else { | ||
| 105 | + resolve(url) | ||
| 106 | + } | ||
| 107 | + }) | ||
| 108 | +} | ||
| 109 | + | ||
| 110 | +/** | ||
| 111 | + * @description 获取图片信息并计算裁剪参数(居中裁剪) | ||
| 112 | + * @param {Object} item 图片配置 | ||
| 113 | + * @param {number} index 渲染顺序(默认 zIndex) | ||
| 114 | + * @returns {Promise<Object>} 标准化后的图片绘制参数 | ||
| 115 | + */ | ||
| 116 | +export const getImageInfo = (item, index) => | ||
| 117 | + new Promise((resolve, reject) => { | ||
| 118 | + const { x, y, width, height, url, zIndex } = item | ||
| 119 | + downImage(url).then((imgPath) => | ||
| 120 | + Taro.getImageInfo({ src: imgPath }) | ||
| 121 | + .then((imgInfo) => { | ||
| 122 | + let sx | ||
| 123 | + let sy | ||
| 124 | + const borderRadius = item.borderRadius || 0 | ||
| 125 | + const imgWidth = toRpx(imgInfo.width) | ||
| 126 | + const imgHeight = toRpx(imgInfo.height) | ||
| 127 | + if (imgWidth / imgHeight <= width / height) { | ||
| 128 | + sx = 0 | ||
| 129 | + sy = (imgHeight - (imgWidth / width) * height) / 2 | ||
| 130 | + } else { | ||
| 131 | + sy = 0 | ||
| 132 | + sx = (imgWidth - (imgHeight / height) * width) / 2 | ||
| 133 | + } | ||
| 134 | + const result = { | ||
| 135 | + type: 'image', | ||
| 136 | + borderRadius, | ||
| 137 | + borderWidth: item.borderWidth, | ||
| 138 | + borderColor: item.borderColor, | ||
| 139 | + borderRadiusGroup: item.borderRadiusGroup, | ||
| 140 | + zIndex: typeof zIndex !== 'undefined' ? zIndex : index, | ||
| 141 | + imgPath: url, | ||
| 142 | + sx, | ||
| 143 | + sy, | ||
| 144 | + sw: imgWidth - sx * 2, | ||
| 145 | + sh: imgHeight - sy * 2, | ||
| 146 | + x, | ||
| 147 | + y, | ||
| 148 | + w: width, | ||
| 149 | + h: height | ||
| 150 | + } | ||
| 151 | + resolve(result) | ||
| 152 | + }) | ||
| 153 | + .catch((err) => { | ||
| 154 | + reject(err) | ||
| 155 | + }) | ||
| 156 | + ) | ||
| 157 | + }) | ||
| 158 | + | ||
| 159 | +/** | ||
| 160 | + * @description 解析 linear-gradient 字符串为 canvas 渐变色 | ||
| 161 | + * @param {CanvasRenderingContext2D} ctx canvas 上下文 | ||
| 162 | + * @param {string} color 颜色字符串(支持 linear-gradient(...)) | ||
| 163 | + * @param {number} startX 起点 x | ||
| 164 | + * @param {number} startY 起点 y | ||
| 165 | + * @param {number} w 宽度 | ||
| 166 | + * @param {number} h 高度 | ||
| 167 | + * @returns {any} 普通颜色字符串或渐变对象 | ||
| 168 | + */ | ||
| 169 | +export function getLinearColor(ctx, color, startX, startY, w, h) { | ||
| 170 | + if ( | ||
| 171 | + typeof startX !== 'number' || | ||
| 172 | + typeof startY !== 'number' || | ||
| 173 | + typeof w !== 'number' || | ||
| 174 | + typeof h !== 'number' | ||
| 175 | + ) { | ||
| 176 | + return color | ||
| 177 | + } | ||
| 178 | + let grd = color | ||
| 179 | + if (color.includes('linear-gradient')) { | ||
| 180 | + const colorList = color.match(/\((\d+)deg,\s(.+)\s\d+%,\s(.+)\s\d+%/) | ||
| 181 | + const radian = colorList[1] | ||
| 182 | + const color1 = colorList[2] | ||
| 183 | + const color2 = colorList[3] | ||
| 184 | + | ||
| 185 | + const L = Math.sqrt(w * w + h * h) | ||
| 186 | + const x = Math.ceil(Math.sin(180 - radian) * L) | ||
| 187 | + const y = Math.ceil(Math.cos(180 - radian) * L) | ||
| 188 | + | ||
| 189 | + if (Number(radian) === 180 || Number(radian) === 0) { | ||
| 190 | + if (Number(radian) === 180) { | ||
| 191 | + grd = ctx.createLinearGradient(startX, startY, startX, startY + h) | ||
| 192 | + } | ||
| 193 | + if (Number(radian) === 0) { | ||
| 194 | + grd = ctx.createLinearGradient(startX, startY + h, startX, startY) | ||
| 195 | + } | ||
| 196 | + } else if (radian > 0 && radian < 180) { | ||
| 197 | + grd = ctx.createLinearGradient(startX, startY, x + startX, y + startY) | ||
| 198 | + } else { | ||
| 199 | + throw new Error('只支持0 <= 颜色弧度 <= 180') | ||
| 200 | + } | ||
| 201 | + grd.addColorStop(0, color1) | ||
| 202 | + grd.addColorStop(1, color2) | ||
| 203 | + } | ||
| 204 | + return grd | ||
| 205 | +} | ||
| 206 | + | ||
| 207 | +/** | ||
| 208 | + * @description 根据 textAlign 计算文本绘制起点 x | ||
| 209 | + * @param {'left'|'center'|'right'} textAlign 对齐方式 | ||
| 210 | + * @param {number} x 原始 x | ||
| 211 | + * @param {number} width 容器宽 | ||
| 212 | + * @returns {number} 计算后的 x | ||
| 213 | + */ | ||
| 214 | +export function getTextX(textAlign, x, width) { | ||
| 215 | + let newX = x | ||
| 216 | + if (textAlign === 'center') { | ||
| 217 | + newX = width / 2 + x | ||
| 218 | + } else if (textAlign === 'right') { | ||
| 219 | + newX = width + x | ||
| 220 | + } | ||
| 221 | + return newX | ||
| 222 | +} |
src/components/indexNav.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <view class="index-nav" :class="[`is-${position}`]"> | ||
| 3 | + <view class="nav-logo is-home" :class="{ 'is-active': active === 'home' }" @tap="() => on_select('home')"> | ||
| 4 | + <view class="nav-icon-wrap"> | ||
| 5 | + <image class="nav-icon" :src="icons?.home" mode="aspectFit" /> | ||
| 6 | + </view> | ||
| 7 | + <text class="nav-text">首页</text> | ||
| 8 | + </view> | ||
| 9 | + | ||
| 10 | + <view | ||
| 11 | + class="nav-logo is-code" | ||
| 12 | + :class="[{ 'is-active': active === 'code' }, { 'is-center-raised': center_variant === 'raised' }]" | ||
| 13 | + @tap="() => on_select('code')" | ||
| 14 | + > | ||
| 15 | + <view class="nav-icon-wrap"> | ||
| 16 | + <image | ||
| 17 | + class="nav-icon" | ||
| 18 | + :class="{ 'nav-icon--raised': center_variant === 'raised' }" | ||
| 19 | + :src="icons?.code" | ||
| 20 | + mode="aspectFit" | ||
| 21 | + /> | ||
| 22 | + </view> | ||
| 23 | + <text class="nav-text">预约码</text> | ||
| 24 | + </view> | ||
| 25 | + | ||
| 26 | + <view class="nav-logo is-me" :class="{ 'is-active': active === 'me' }" @tap="() => on_select('me')"> | ||
| 27 | + <view class="nav-icon-wrap"> | ||
| 28 | + <image class="nav-icon" :src="icons?.me" mode="aspectFit" /> | ||
| 29 | + </view> | ||
| 30 | + <text class="nav-text">我的</text> | ||
| 31 | + </view> | ||
| 32 | + </view> | ||
| 33 | +</template> | ||
| 34 | + | ||
| 35 | +<script setup> | ||
| 36 | +const props = defineProps({ | ||
| 37 | + icons: { | ||
| 38 | + type: Object, | ||
| 39 | + default: () => ({}) | ||
| 40 | + }, | ||
| 41 | + active: { | ||
| 42 | + type: String, | ||
| 43 | + default: '' | ||
| 44 | + }, | ||
| 45 | + position: { | ||
| 46 | + type: String, | ||
| 47 | + default: 'fixed' | ||
| 48 | + }, | ||
| 49 | + center_variant: { | ||
| 50 | + type: String, | ||
| 51 | + default: 'normal' | ||
| 52 | + }, | ||
| 53 | + allow_active_tap: { | ||
| 54 | + type: Boolean, | ||
| 55 | + default: false | ||
| 56 | + } | ||
| 57 | +}) | ||
| 58 | + | ||
| 59 | +const emit = defineEmits(['select']) | ||
| 60 | + | ||
| 61 | +const on_select = (key) => { | ||
| 62 | + if (!props.allow_active_tap && props.active && key === props.active) return | ||
| 63 | + emit('select', key) | ||
| 64 | +} | ||
| 65 | +</script> | ||
| 66 | + | ||
| 67 | +<style lang="less"> | ||
| 68 | +.index-nav { | ||
| 69 | + left: 0; | ||
| 70 | + bottom: 0; | ||
| 71 | + width: 750rpx; | ||
| 72 | + height: calc(134rpx + constant(safe-area-inset-bottom)); | ||
| 73 | + height: calc(134rpx + env(safe-area-inset-bottom)); | ||
| 74 | + padding-bottom: calc(0rpx + constant(safe-area-inset-bottom)); | ||
| 75 | + padding-bottom: calc(0rpx + env(safe-area-inset-bottom)); | ||
| 76 | + box-sizing: border-box; | ||
| 77 | + background: #FFFFFF; | ||
| 78 | + box-shadow: 0 -8rpx 8rpx 0 rgba(0, 0, 0, 0.1); | ||
| 79 | + display: flex; | ||
| 80 | + align-items: flex-end; | ||
| 81 | + justify-content: space-around; | ||
| 82 | + color: #A67939; | ||
| 83 | + z-index: 99; | ||
| 84 | + | ||
| 85 | + &.is-fixed { | ||
| 86 | + position: fixed; | ||
| 87 | + } | ||
| 88 | + | ||
| 89 | + &.is-absolute { | ||
| 90 | + position: absolute; | ||
| 91 | + } | ||
| 92 | + | ||
| 93 | + .nav-logo { | ||
| 94 | + position: relative; | ||
| 95 | + display: flex; | ||
| 96 | + flex-direction: column; | ||
| 97 | + align-items: center; | ||
| 98 | + } | ||
| 99 | + | ||
| 100 | + .nav-icon-wrap { | ||
| 101 | + position: relative; | ||
| 102 | + width: 56rpx; | ||
| 103 | + height: 56rpx; | ||
| 104 | + display: flex; | ||
| 105 | + align-items: center; | ||
| 106 | + justify-content: center; | ||
| 107 | + } | ||
| 108 | + | ||
| 109 | + .nav-icon { | ||
| 110 | + width: 56rpx; | ||
| 111 | + height: 56rpx; | ||
| 112 | + display: block; | ||
| 113 | + | ||
| 114 | + &.nav-icon--raised { | ||
| 115 | + width: 140rpx; | ||
| 116 | + height: 140rpx; | ||
| 117 | + position: absolute; | ||
| 118 | + top: -100rpx; | ||
| 119 | + left: 50%; | ||
| 120 | + transform: translateX(-50%); | ||
| 121 | + } | ||
| 122 | + } | ||
| 123 | + | ||
| 124 | + .nav-logo.is-home, | ||
| 125 | + .nav-logo.is-me { | ||
| 126 | + .nav-icon { | ||
| 127 | + width: 56rpx; | ||
| 128 | + height: 56rpx; | ||
| 129 | + } | ||
| 130 | + } | ||
| 131 | + | ||
| 132 | + .nav-text { | ||
| 133 | + font-size: 26rpx; | ||
| 134 | + margin-top: 12rpx; | ||
| 135 | + line-height: 1; | ||
| 136 | + } | ||
| 137 | +} | ||
| 138 | +</style> |
src/components/qrCode.vue
0 → 100644
| 1 | +<!-- | ||
| 2 | + * @Date: 2024-01-16 10:06:47 | ||
| 3 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | + * @LastEditTime: 2026-01-24 14:12:30 | ||
| 5 | + * @FilePath: /xyxBooking-weapp/src/components/qrCode.vue | ||
| 6 | + * @Description: 预约码卡组件 | ||
| 7 | +--> | ||
| 8 | +<template> | ||
| 9 | + <view class="qr-code-page"> | ||
| 10 | + <view v-if="userList.length" class="show-qrcode"> | ||
| 11 | + <view class="qrcode-content"> | ||
| 12 | + <view class="user-info">{{ userinfo.name }} {{ userinfo.id }}</view> | ||
| 13 | + <view class="user-qrcode"> | ||
| 14 | + <view class="left" @tap="prevCode"> | ||
| 15 | + <image src="https://cdn.ipadbiz.cn/xys/booking/%E5%B7%A6@2x.png" /> | ||
| 16 | + </view> | ||
| 17 | + <view class="center"> | ||
| 18 | + <image :src="currentQrCodeUrl" mode="aspectFit" /> | ||
| 19 | + <view v-if="useStatus === STATUS_CODE.CANCELED || useStatus === STATUS_CODE.USED" class="qrcode-used"> | ||
| 20 | + <view class="overlay"></view> | ||
| 21 | + <text class="status-text">二维码{{ get_qrcode_status_text(useStatus) }}</text> | ||
| 22 | + </view> | ||
| 23 | + </view> | ||
| 24 | + <view class="right" @tap="nextCode"> | ||
| 25 | + <image src="https://cdn.ipadbiz.cn/xys/booking/%E5%8F%B3@2x.png" /> | ||
| 26 | + </view> | ||
| 27 | + </view> | ||
| 28 | + <view style="color: red; margin-top: 32rpx;">{{ userinfo.datetime }}</view> | ||
| 29 | + </view> | ||
| 30 | + <view class="user-list"> | ||
| 31 | + <view | ||
| 32 | + @tap="selectUser(index)" | ||
| 33 | + v-for="(item, index) in userList" | ||
| 34 | + :key="index" | ||
| 35 | + :class="[ | ||
| 36 | + 'user-item', | ||
| 37 | + select_index === index ? 'checked' : '', | ||
| 38 | + userList.length > 1 && item.sort ? 'border' : '', | ||
| 39 | + ]"> | ||
| 40 | + {{ item.name }} | ||
| 41 | + </view> | ||
| 42 | + </view> | ||
| 43 | + </view> | ||
| 44 | + <view v-else class="no-qrcode"> | ||
| 45 | + <image src="https://cdn.ipadbiz.cn/xys/booking/%E6%9A%82%E6%97%A0@2x.png" style="width: 320rpx; height: 320rpx;" /> | ||
| 46 | + <view class="no-qrcode-title">今天没有预约记录</view> | ||
| 47 | + <view style="text-align: center; color: #A67939; margin-top: 16rpx;">查看我的“<text @tap="toRecord" style="text-decoration: underline; color: #ED9820;">预约记录</text>”</view> | ||
| 48 | + </view> | ||
| 49 | + </view> | ||
| 50 | +</template> | ||
| 51 | + | ||
| 52 | +<script setup> | ||
| 53 | +import { ref, computed, watch, onMounted, onUnmounted } from 'vue' | ||
| 54 | +import Taro from '@tarojs/taro' | ||
| 55 | +import { formatDatetime, mask_id_number, get_qrcode_status_text } from '@/utils/tools'; | ||
| 56 | +import { qrcodeListAPI, qrcodeStatusAPI, billPersonAPI } from '@/api/index' | ||
| 57 | +import { useGo } from '@/hooks/useGo' | ||
| 58 | +import BASE_URL from '@/utils/config'; | ||
| 59 | + | ||
| 60 | +const go = useGo(); | ||
| 61 | + | ||
| 62 | +const props = defineProps({ | ||
| 63 | + status: { | ||
| 64 | + type: String, | ||
| 65 | + default: '' | ||
| 66 | + }, | ||
| 67 | + type: { | ||
| 68 | + type: String, | ||
| 69 | + default: '' | ||
| 70 | + }, | ||
| 71 | + payId: { // 接收 payId | ||
| 72 | + type: String, | ||
| 73 | + default: '' | ||
| 74 | + } | ||
| 75 | +}); | ||
| 76 | + | ||
| 77 | +const select_index = ref(0); | ||
| 78 | +const userList = ref([]); | ||
| 79 | + | ||
| 80 | +/** | ||
| 81 | + * @description 切换到上一张二维码(循环) | ||
| 82 | + * @returns {void} 无返回值 | ||
| 83 | + */ | ||
| 84 | +const prevCode = () => { | ||
| 85 | + select_index.value = select_index.value - 1; | ||
| 86 | + if (select_index.value < 0) { | ||
| 87 | + select_index.value = userList.value.length - 1; | ||
| 88 | + } | ||
| 89 | +}; | ||
| 90 | + | ||
| 91 | +/** | ||
| 92 | + * @description 切换到下一张二维码(循环) | ||
| 93 | + * @returns {void} 无返回值 | ||
| 94 | + */ | ||
| 95 | +const nextCode = () => { | ||
| 96 | + select_index.value = select_index.value + 1; | ||
| 97 | + if (select_index.value > userList.value.length - 1) { | ||
| 98 | + select_index.value = 0; | ||
| 99 | + } | ||
| 100 | +}; | ||
| 101 | + | ||
| 102 | +watch( | ||
| 103 | + () => select_index.value, | ||
| 104 | + () => { | ||
| 105 | + refreshBtn(); | ||
| 106 | + } | ||
| 107 | +) | ||
| 108 | + | ||
| 109 | +watch( | ||
| 110 | + () => props.payId, | ||
| 111 | + (val) => { | ||
| 112 | + if (val) { | ||
| 113 | + init(); | ||
| 114 | + } | ||
| 115 | + }, | ||
| 116 | + { immediate: true } | ||
| 117 | +) | ||
| 118 | + | ||
| 119 | +const formatId = (id) => mask_id_number(id) | ||
| 120 | + | ||
| 121 | +const userinfo = computed(() => { | ||
| 122 | + return { | ||
| 123 | + name: userList.value[select_index.value]?.name, | ||
| 124 | + id: formatId(userList.value[select_index.value]?.id_number), | ||
| 125 | + datetime: userList.value[select_index.value]?.datetime, | ||
| 126 | + }; | ||
| 127 | +}); | ||
| 128 | + | ||
| 129 | +const currentQrCodeUrl = computed(() => { | ||
| 130 | + const url = userList.value[select_index.value]?.qr_code_url; | ||
| 131 | + if (url && url.startsWith('/')) { | ||
| 132 | + return BASE_URL + url; | ||
| 133 | + } | ||
| 134 | + return url; | ||
| 135 | +}) | ||
| 136 | + | ||
| 137 | +const useStatus = ref('0'); | ||
| 138 | + | ||
| 139 | +const STATUS_CODE = { | ||
| 140 | + APPLY: '1', | ||
| 141 | + SUCCESS: '3', | ||
| 142 | + CANCELED: '5', | ||
| 143 | + USED: '7', | ||
| 144 | +}; | ||
| 145 | + | ||
| 146 | +/** | ||
| 147 | + * @description 刷新当前选中二维码状态 | ||
| 148 | + * - 仅在当前选中用户存在时请求 | ||
| 149 | + * @returns {Promise<void>} 无返回值 | ||
| 150 | + */ | ||
| 151 | +const refreshBtn = async () => { | ||
| 152 | + if (!userList.value[select_index.value]) return; | ||
| 153 | + const { code, data } = await qrcodeStatusAPI({ qr_code: userList.value[select_index.value].qr_code }); | ||
| 154 | + if (code) { | ||
| 155 | + useStatus.value = data.status; | ||
| 156 | + } | ||
| 157 | +} | ||
| 158 | + | ||
| 159 | +/** | ||
| 160 | + * @description 选择指定参观者的二维码 | ||
| 161 | + * @param {number} index 下标 | ||
| 162 | + * @returns {void} 无返回值 | ||
| 163 | + */ | ||
| 164 | +const selectUser = (index) => { | ||
| 165 | + select_index.value = index; | ||
| 166 | +} | ||
| 167 | + | ||
| 168 | +/** | ||
| 169 | + * @description 按 pay_id 分组标记(用于展示分隔线) | ||
| 170 | + * - 首个 pay_id 出现处标记 sort=1,其余为 sort=0 | ||
| 171 | + * @param {Array<Object>} data 二维码列表 | ||
| 172 | + * @returns {Array<Object>} 处理后的列表 | ||
| 173 | + */ | ||
| 174 | +const formatGroup = (data) => { | ||
| 175 | + let lastPayId = null; | ||
| 176 | + for (let i = 0; i < data.length; i++) { | ||
| 177 | + if (data[i].pay_id !== lastPayId) { | ||
| 178 | + data[i].sort = 1; | ||
| 179 | + lastPayId = data[i].pay_id; | ||
| 180 | + } else { | ||
| 181 | + data[i].sort = 0; | ||
| 182 | + } | ||
| 183 | + } | ||
| 184 | + return data; | ||
| 185 | +} | ||
| 186 | + | ||
| 187 | +/** | ||
| 188 | + * @description 初始化二维码列表 | ||
| 189 | + * - 不传 type:拉取“我的当日二维码列表” | ||
| 190 | + * - 传入 type + payId:按订单查询二维码人员列表 | ||
| 191 | + * @returns {Promise<void>} 无返回值 | ||
| 192 | + */ | ||
| 193 | +const init = async () => { | ||
| 194 | + if (!props.type) { | ||
| 195 | + try { | ||
| 196 | + const { code, data } = await qrcodeListAPI(); | ||
| 197 | + | ||
| 198 | + if (code) { | ||
| 199 | + data.forEach(item => { | ||
| 200 | + item.qr_code_url = '/admin?m=srv&a=get_qrcode&key=' + item.qr_code; | ||
| 201 | + item.datetime = formatDatetime({ begin_time: item.begin_time, end_time: item.end_time }) | ||
| 202 | + item.sort = 0; | ||
| 203 | + }); | ||
| 204 | + // 剔除qr_code为空的二维码 | ||
| 205 | + const validData = data.filter(item => item.qr_code !== ''); | ||
| 206 | + | ||
| 207 | + if (validData.length > 0) { | ||
| 208 | + userList.value = formatGroup(validData); | ||
| 209 | + refreshBtn(); | ||
| 210 | + } else { | ||
| 211 | + userList.value = []; | ||
| 212 | + } | ||
| 213 | + } | ||
| 214 | + } catch (err) { | ||
| 215 | + console.error('Fetch QR List Failed:', err); | ||
| 216 | + } | ||
| 217 | + } else { | ||
| 218 | + if (props.payId) { | ||
| 219 | + const { code, data } = await billPersonAPI({ pay_id: props.payId }); | ||
| 220 | + if (code) { | ||
| 221 | + data.forEach(item => { | ||
| 222 | + item.qr_code_url = '/admin?m=srv&a=get_qrcode&key=' + item.qr_code; | ||
| 223 | + item.sort = 0; | ||
| 224 | + // billPersonAPI 返回的数据可能没有 datetime 字段,需要检查 | ||
| 225 | + // 如果没有,可能需要从外部传入或者假设是当天的? | ||
| 226 | + // H5 代码没有处理 datetime,但在 template 里用了。 | ||
| 227 | + // 这里暂且不做处理,如果没有 datetime 就不显示 | ||
| 228 | + }); | ||
| 229 | + const validData = data.filter(item => item.qr_code !== ''); | ||
| 230 | + if (validData.length > 0) { | ||
| 231 | + userList.value = validData; | ||
| 232 | + refreshBtn(); | ||
| 233 | + } else { | ||
| 234 | + userList.value = []; | ||
| 235 | + } | ||
| 236 | + } | ||
| 237 | + } | ||
| 238 | + } | ||
| 239 | +}; | ||
| 240 | + | ||
| 241 | +onMounted(() => { | ||
| 242 | + init(); | ||
| 243 | + start_polling(); | ||
| 244 | +}); | ||
| 245 | + | ||
| 246 | +/** | ||
| 247 | + * @description 轮询刷新二维码状态 | ||
| 248 | + * - 仅在“待使用”状态下轮询,避免无意义请求 | ||
| 249 | + * @returns {Promise<void>} 无返回值 | ||
| 250 | + */ | ||
| 251 | +const poll = async () => { | ||
| 252 | + if (userList.value.length && useStatus.value === STATUS_CODE.SUCCESS) { | ||
| 253 | + if (userList.value[select_index.value]) { | ||
| 254 | + const { code, data } = await qrcodeStatusAPI({ qr_code: userList.value[select_index.value].qr_code }); | ||
| 255 | + if (code) { | ||
| 256 | + useStatus.value = data.status; | ||
| 257 | + } | ||
| 258 | + } | ||
| 259 | + } | ||
| 260 | +}; | ||
| 261 | + | ||
| 262 | +const interval_id = ref(null) | ||
| 263 | +/** | ||
| 264 | + * @description 启动轮询 | ||
| 265 | + * - 仅在当前选中用户存在时轮询 | ||
| 266 | + * @returns {void} 无返回值 | ||
| 267 | + */ | ||
| 268 | + | ||
| 269 | +const start_polling = () => { | ||
| 270 | + if (interval_id.value) return | ||
| 271 | + interval_id.value = setInterval(poll, 3000) | ||
| 272 | +} | ||
| 273 | + | ||
| 274 | +/** | ||
| 275 | + * @description 停止轮询 | ||
| 276 | + * - 组件卸载时调用,避免内存泄漏 | ||
| 277 | + * @returns {void} 无返回值 | ||
| 278 | + */ | ||
| 279 | + | ||
| 280 | +const stop_polling = () => { | ||
| 281 | + if (!interval_id.value) return | ||
| 282 | + clearInterval(interval_id.value) | ||
| 283 | + interval_id.value = null | ||
| 284 | +} | ||
| 285 | + | ||
| 286 | +onUnmounted(() => { | ||
| 287 | + stop_polling(); | ||
| 288 | +}); | ||
| 289 | + | ||
| 290 | +defineExpose({ start_polling, stop_polling }) | ||
| 291 | + | ||
| 292 | +/** | ||
| 293 | + * @description 跳转预约记录列表页 | ||
| 294 | + * @returns {void} 无返回值 | ||
| 295 | + */ | ||
| 296 | +const toRecord = () => { | ||
| 297 | + go('/bookingList'); | ||
| 298 | +} | ||
| 299 | +</script> | ||
| 300 | + | ||
| 301 | +<style lang="less"> | ||
| 302 | +.qr-code-page { | ||
| 303 | + .qrcode-content { | ||
| 304 | + padding: 32rpx 0; | ||
| 305 | + display: flex; | ||
| 306 | + flex-direction: column; | ||
| 307 | + justify-content: center; | ||
| 308 | + align-items: center; | ||
| 309 | + background-color: #FFF; | ||
| 310 | + border-radius: 16rpx; | ||
| 311 | + box-shadow: 0 0 29rpx 0 rgba(106,106,106,0.27); | ||
| 312 | + | ||
| 313 | + .user-info { | ||
| 314 | + color: #A6A6A6; | ||
| 315 | + font-size: 37rpx; | ||
| 316 | + margin-top: 16rpx; | ||
| 317 | + margin-bottom: 16rpx; | ||
| 318 | + } | ||
| 319 | + .user-qrcode { | ||
| 320 | + display: flex; | ||
| 321 | + align-items: center; | ||
| 322 | + .left { | ||
| 323 | + image { | ||
| 324 | + width: 56rpx; height: 56rpx; margin-right: 16rpx; | ||
| 325 | + } | ||
| 326 | + } | ||
| 327 | + .center { | ||
| 328 | + border: 2rpx solid #D1D1D1; | ||
| 329 | + border-radius: 40rpx; | ||
| 330 | + padding: 46rpx; | ||
| 331 | + position: relative; | ||
| 332 | + image { | ||
| 333 | + width: 400rpx; height: 400rpx; | ||
| 334 | + } | ||
| 335 | + .qrcode-used { | ||
| 336 | + position: absolute; | ||
| 337 | + top: 0; | ||
| 338 | + left: 0; | ||
| 339 | + right: 0; | ||
| 340 | + bottom: 0; | ||
| 341 | + border-radius: 40rpx; | ||
| 342 | + overflow: hidden; | ||
| 343 | + | ||
| 344 | + .overlay { | ||
| 345 | + width: 100%; | ||
| 346 | + height: 100%; | ||
| 347 | + background-image: url('https://cdn.ipadbiz.cn/xys/booking/southeast.jpeg'); | ||
| 348 | + background-size: contain; | ||
| 349 | + opacity: 0.9; | ||
| 350 | + } | ||
| 351 | + | ||
| 352 | + .status-text { | ||
| 353 | + color: #A67939; | ||
| 354 | + position: absolute; | ||
| 355 | + top: 50%; | ||
| 356 | + left: 50%; | ||
| 357 | + transform: translate(-50%, -50%); | ||
| 358 | + font-size: 38rpx; | ||
| 359 | + white-space: nowrap; | ||
| 360 | + font-weight: bold; | ||
| 361 | + z-index: 10; | ||
| 362 | + } | ||
| 363 | + } | ||
| 364 | + } | ||
| 365 | + .right { | ||
| 366 | + image { | ||
| 367 | + width: 56rpx; height: 56rpx; | ||
| 368 | + margin-left: 16rpx; | ||
| 369 | + } | ||
| 370 | + } | ||
| 371 | + } | ||
| 372 | + } | ||
| 373 | + .user-list { | ||
| 374 | + display: flex; | ||
| 375 | + padding: 32rpx; | ||
| 376 | + align-items: center; | ||
| 377 | + flex-wrap: wrap; | ||
| 378 | + .user-item { | ||
| 379 | + position: relative; | ||
| 380 | + padding: 8rpx 16rpx; | ||
| 381 | + border: 2rpx solid #A67939; | ||
| 382 | + margin: 8rpx; | ||
| 383 | + border-radius: 10rpx; | ||
| 384 | + color: #A67939; | ||
| 385 | + &.checked { | ||
| 386 | + color: #FFF; | ||
| 387 | + background-color: #A67939; | ||
| 388 | + } | ||
| 389 | + &.border { | ||
| 390 | + margin-right: 16rpx; | ||
| 391 | + &::after { | ||
| 392 | + position: absolute; | ||
| 393 | + right: -16rpx; | ||
| 394 | + top: calc(50% - 16rpx); | ||
| 395 | + content: ''; | ||
| 396 | + height: 32rpx; | ||
| 397 | + border-right: 2rpx solid #A67939; | ||
| 398 | + } | ||
| 399 | + } | ||
| 400 | + } | ||
| 401 | + } | ||
| 402 | + | ||
| 403 | + .no-qrcode { | ||
| 404 | + display: flex; | ||
| 405 | + justify-content: center; | ||
| 406 | + align-items: center; | ||
| 407 | + flex-direction: column; | ||
| 408 | + margin-bottom: 32rpx; | ||
| 409 | + | ||
| 410 | + .no-qrcode-title { | ||
| 411 | + color: #A67939; | ||
| 412 | + font-size: 34rpx; | ||
| 413 | + } | ||
| 414 | + } | ||
| 415 | +} | ||
| 416 | +</style> |
src/components/qrCodeSearch.vue
0 → 100644
| 1 | +<!-- | ||
| 2 | + * @Date: 2024-01-16 10:06:47 | ||
| 3 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | + * @LastEditTime: 2026-01-14 21:57:40 | ||
| 5 | + * @FilePath: /xyxBooking-weapp/src/components/qrCodeSearch.vue | ||
| 6 | + * @Description: 预约码卡组件 | ||
| 7 | +--> | ||
| 8 | +<template> | ||
| 9 | + <view class="qr-code-page"> | ||
| 10 | + <view v-if="userinfo.qr_code" class="show-qrcode"> | ||
| 11 | + <view class="qrcode-content"> | ||
| 12 | + <view class="user-info">{{ userinfo.name }} {{ userinfo.id }}</view> | ||
| 13 | + <view class="user-qrcode"> | ||
| 14 | + <view class="left"> | ||
| 15 | + <!-- <image src="https://cdn.ipadbiz.cn/xys/booking/%E5%B7%A6@2x.png"> --> | ||
| 16 | + </view> | ||
| 17 | + <view class="center"> | ||
| 18 | + <image :src="userinfo.qr_code_url" mode="aspectFit" /> | ||
| 19 | + <view v-if="useStatus === STATUS_CODE.CANCELED || useStatus === STATUS_CODE.USED" class="qrcode-used"> | ||
| 20 | + <view class="overlay"></view> | ||
| 21 | + <text class="status-text">二维码{{ qr_code_status[useStatus] }}</text> | ||
| 22 | + </view> | ||
| 23 | + </view> | ||
| 24 | + <view class="right"> | ||
| 25 | + <!-- <image src="https://cdn.ipadbiz.cn/xys/booking/%E5%8F%B3@2x.png"> --> | ||
| 26 | + </view> | ||
| 27 | + </view> | ||
| 28 | + <view style="color: red; margin-top: 32rpx;">{{ userinfo.datetime }}</view> | ||
| 29 | + </view> | ||
| 30 | + </view> | ||
| 31 | + <view v-else class="no-qrcode"> | ||
| 32 | + <image src="https://cdn.ipadbiz.cn/xys/booking/%E6%9A%82%E6%97%A0@2x.png" style="width: 320rpx; height: 320rpx;" /> | ||
| 33 | + <view class="no-qrcode-title">您还没有预约过今天参观</view> | ||
| 34 | + </view> | ||
| 35 | + </view> | ||
| 36 | +</template> | ||
| 37 | + | ||
| 38 | +<script setup> | ||
| 39 | +import { ref, onMounted, watch, onUnmounted } from 'vue' | ||
| 40 | +import { formatDatetime } from '@/utils/tools'; | ||
| 41 | +import { qrcodeStatusAPI, queryQrCodeAPI } from '@/api/index' | ||
| 42 | +import BASE_URL from '@/utils/config'; | ||
| 43 | + | ||
| 44 | +const props = defineProps({ | ||
| 45 | + id: { | ||
| 46 | + type: String, | ||
| 47 | + default: '' | ||
| 48 | + }, | ||
| 49 | + id_type: { | ||
| 50 | + type: Number, | ||
| 51 | + default: 1 | ||
| 52 | + } | ||
| 53 | +}); | ||
| 54 | + | ||
| 55 | +const userinfo = ref({}); | ||
| 56 | + | ||
| 57 | +const replaceMiddleCharacters = (input_string) => { | ||
| 58 | + if (!input_string || input_string.length < 15) { | ||
| 59 | + return input_string; | ||
| 60 | + } | ||
| 61 | + const start = Math.floor((input_string.length - 8) / 2); | ||
| 62 | + const end = start + 8; | ||
| 63 | + const replacement = '*'.repeat(8); | ||
| 64 | + return input_string.substring(0, start) + replacement + input_string.substring(end); | ||
| 65 | +} | ||
| 66 | + | ||
| 67 | +const formatId = (id) => replaceMiddleCharacters(id); | ||
| 68 | + | ||
| 69 | +const useStatus = ref('0'); | ||
| 70 | +const is_loading = ref(false) | ||
| 71 | +let is_destroyed = false | ||
| 72 | + | ||
| 73 | +const qr_code_status = { | ||
| 74 | + '1': '未激活', | ||
| 75 | + '3': '待使用', | ||
| 76 | + '5': '被取消', | ||
| 77 | + '7': '已使用', | ||
| 78 | +}; | ||
| 79 | + | ||
| 80 | +const STATUS_CODE = { | ||
| 81 | + APPLY: '1', | ||
| 82 | + SUCCESS: '3', | ||
| 83 | + CANCELED: '5', | ||
| 84 | + USED: '7', | ||
| 85 | +}; | ||
| 86 | + | ||
| 87 | +const build_qr_code_url = (qr_code) => { | ||
| 88 | + if (!qr_code) return '' | ||
| 89 | + return `${BASE_URL}/admin?m=srv&a=get_qrcode&key=${encodeURIComponent(String(qr_code))}` | ||
| 90 | +} | ||
| 91 | + | ||
| 92 | +/** | ||
| 93 | + * @description: 格式化预约码卡数据 | ||
| 94 | + * @param {*} raw 原始数据 | ||
| 95 | + * @return {*} 格式化后的数据 | ||
| 96 | + */ | ||
| 97 | + | ||
| 98 | +const normalize_item = (raw) => { | ||
| 99 | + if (!raw || typeof raw !== 'object') return null | ||
| 100 | + const qr_code = raw.qr_code ? String(raw.qr_code) : '' | ||
| 101 | + const id_number = raw.id_number ? String(raw.id_number) : '' | ||
| 102 | + return { | ||
| 103 | + ...raw, | ||
| 104 | + qr_code, | ||
| 105 | + qr_code_url: build_qr_code_url(qr_code), | ||
| 106 | + datetime: formatDatetime({ begin_time: raw.begin_time, end_time: raw.end_time }), | ||
| 107 | + id: formatId(id_number), | ||
| 108 | + } | ||
| 109 | +} | ||
| 110 | + | ||
| 111 | +/** | ||
| 112 | + * @description: 重置状态 | ||
| 113 | + */ | ||
| 114 | + | ||
| 115 | +const reset_state = () => { | ||
| 116 | + userinfo.value = {} | ||
| 117 | + useStatus.value = '0' | ||
| 118 | +} | ||
| 119 | + | ||
| 120 | +/** | ||
| 121 | + * @description: 加载预约码卡状态 | ||
| 122 | + * @param {*} qr_code 预约码 | ||
| 123 | + * @return {*} 状态码 | ||
| 124 | + */ | ||
| 125 | + | ||
| 126 | +const load_qr_code_status = async (qr_code) => { | ||
| 127 | + if (!qr_code) return | ||
| 128 | + const res = await qrcodeStatusAPI({ qr_code }) | ||
| 129 | + if (is_destroyed) return | ||
| 130 | + if (!res || res.code !== 1) return | ||
| 131 | + const status = res?.data?.status | ||
| 132 | + if (status === undefined || status === null) return | ||
| 133 | + useStatus.value = String(status) | ||
| 134 | +} | ||
| 135 | + | ||
| 136 | +/** | ||
| 137 | + * @description: 加载预约码卡信息 | ||
| 138 | + * @param {*} id_number 身份证号 | ||
| 139 | + * @return {*} 预约码卡信息 | ||
| 140 | + */ | ||
| 141 | + | ||
| 142 | +const load_qr_code_info = async (id_number) => { | ||
| 143 | + const id = String(id_number || '').trim() | ||
| 144 | + if (!id) { | ||
| 145 | + reset_state() | ||
| 146 | + return | ||
| 147 | + } | ||
| 148 | + | ||
| 149 | + is_loading.value = true | ||
| 150 | + const params = { id_number: id } | ||
| 151 | + if (props.id_type) params.id_type = props.id_type | ||
| 152 | + const res = await queryQrCodeAPI(params) | ||
| 153 | + if (is_destroyed) return | ||
| 154 | + is_loading.value = false | ||
| 155 | + | ||
| 156 | + if (!res || res.code !== 1 || !res.data) { | ||
| 157 | + reset_state() | ||
| 158 | + return | ||
| 159 | + } | ||
| 160 | + | ||
| 161 | + const raw = Array.isArray(res.data) ? res.data[0] : res.data | ||
| 162 | + const item = normalize_item(raw) | ||
| 163 | + if (!item || !item.qr_code) { | ||
| 164 | + reset_state() | ||
| 165 | + return | ||
| 166 | + } | ||
| 167 | + | ||
| 168 | + userinfo.value = item | ||
| 169 | + await load_qr_code_status(item.qr_code) | ||
| 170 | +} | ||
| 171 | + | ||
| 172 | +onUnmounted(() => { | ||
| 173 | + is_destroyed = true | ||
| 174 | +}) | ||
| 175 | + | ||
| 176 | +onMounted(() => { | ||
| 177 | + load_qr_code_info(props.id) | ||
| 178 | +}) | ||
| 179 | + | ||
| 180 | +watch( | ||
| 181 | + () => [props.id, props.id_type], | ||
| 182 | + ([val]) => { | ||
| 183 | + if (is_loading.value) return | ||
| 184 | + load_qr_code_info(val) | ||
| 185 | + } | ||
| 186 | +) | ||
| 187 | +</script> | ||
| 188 | + | ||
| 189 | +<style lang="less"> | ||
| 190 | +.qr-code-page { | ||
| 191 | + .qrcode-content { | ||
| 192 | + padding: 32rpx 0; | ||
| 193 | + display: flex; | ||
| 194 | + flex-direction: column; | ||
| 195 | + justify-content: center; | ||
| 196 | + align-items: center; | ||
| 197 | + background-color: #FFF; | ||
| 198 | + border-radius: 16rpx; | ||
| 199 | + box-shadow: 0 0 29rpx 0 rgba(106,106,106,0.27); | ||
| 200 | + | ||
| 201 | + .user-info { | ||
| 202 | + color: #A6A6A6; | ||
| 203 | + font-size: 37rpx; | ||
| 204 | + margin-top: 16rpx; | ||
| 205 | + margin-bottom: 16rpx; | ||
| 206 | + } | ||
| 207 | + .user-qrcode { | ||
| 208 | + display: flex; | ||
| 209 | + align-items: center; | ||
| 210 | + .center { | ||
| 211 | + border: 2rpx solid #D1D1D1; | ||
| 212 | + border-radius: 40rpx; | ||
| 213 | + padding: 16rpx; | ||
| 214 | + position: relative; | ||
| 215 | + image { | ||
| 216 | + width: 480rpx; height: 480rpx; | ||
| 217 | + } | ||
| 218 | + .qrcode-used { | ||
| 219 | + position: absolute; | ||
| 220 | + top: 0; | ||
| 221 | + left: 0; | ||
| 222 | + right: 0; | ||
| 223 | + bottom: 0; | ||
| 224 | + border-radius: 40rpx; | ||
| 225 | + overflow: hidden; | ||
| 226 | + | ||
| 227 | + .overlay { | ||
| 228 | + width: 100%; | ||
| 229 | + height: 100%; | ||
| 230 | + background-image: url('https://cdn.ipadbiz.cn/xys/booking/southeast.jpeg'); | ||
| 231 | + background-size: contain; | ||
| 232 | + opacity: 0.9; | ||
| 233 | + } | ||
| 234 | + | ||
| 235 | + .status-text { | ||
| 236 | + color: #A67939; | ||
| 237 | + position: absolute; | ||
| 238 | + top: 50%; | ||
| 239 | + left: 50%; | ||
| 240 | + transform: translate(-50%, -50%); | ||
| 241 | + font-size: 38rpx; | ||
| 242 | + white-space: nowrap; | ||
| 243 | + font-weight: bold; | ||
| 244 | + z-index: 10; | ||
| 245 | + } | ||
| 246 | + } | ||
| 247 | + } | ||
| 248 | + } | ||
| 249 | + } | ||
| 250 | + | ||
| 251 | + .no-qrcode { | ||
| 252 | + display: flex; | ||
| 253 | + justify-content: center; | ||
| 254 | + align-items: center; | ||
| 255 | + flex-direction: column; | ||
| 256 | + margin-bottom: 32rpx; | ||
| 257 | + | ||
| 258 | + .no-qrcode-title { | ||
| 259 | + color: #A67939; | ||
| 260 | + font-size: 34rpx; | ||
| 261 | + } | ||
| 262 | + } | ||
| 263 | +} | ||
| 264 | +</style> |
| 1 | +var getDaysInOneMonth = function (year, month) { | ||
| 2 | + let _month = parseInt(month, 10); | ||
| 3 | + let d = new Date(year, _month, 0); | ||
| 4 | + return d.getDate(); | ||
| 5 | +} | ||
| 6 | +var dateDate = function (date) { | ||
| 7 | + let year = date && date.getFullYear(); | ||
| 8 | + let month = date && date.getMonth() + 1; | ||
| 9 | + let day = date && date.getDate(); | ||
| 10 | + let hours = date && date.getHours(); | ||
| 11 | + let minutes = date && date.getMinutes(); | ||
| 12 | + return { | ||
| 13 | + year, month, day, hours, minutes | ||
| 14 | + } | ||
| 15 | +} | ||
| 16 | +var dateTimePicker = function (startyear, endyear) { | ||
| 17 | + // 获取date time 年份,月份,天数,小时,分钟推后30分 | ||
| 18 | + const years = []; | ||
| 19 | + const months = []; | ||
| 20 | + const hours = []; | ||
| 21 | + const minutes = []; | ||
| 22 | + for (let i = startyear; i <= endyear; i++) { | ||
| 23 | + years.push({ | ||
| 24 | + name: i + '年', | ||
| 25 | + id: i | ||
| 26 | + }); | ||
| 27 | + } | ||
| 28 | + //获取月份 | ||
| 29 | + for (let i = 1; i <= 12; i++) { | ||
| 30 | + if (i < 10) { | ||
| 31 | + i = "0" + i; | ||
| 32 | + } | ||
| 33 | + months.push({ | ||
| 34 | + name: i + '月', | ||
| 35 | + id: i | ||
| 36 | + }); | ||
| 37 | + } | ||
| 38 | + //获取小时 | ||
| 39 | + for (let i = 0; i < 24; i++) { | ||
| 40 | + if (i < 10) { | ||
| 41 | + i = "0" + i; | ||
| 42 | + } | ||
| 43 | + hours.push({ | ||
| 44 | + name: i + '时', | ||
| 45 | + id: i | ||
| 46 | + }); | ||
| 47 | + } | ||
| 48 | + //获取分钟 | ||
| 49 | + for (let i = 0; i < 60; i++) { | ||
| 50 | + if (i < 10) { | ||
| 51 | + i = "0" + i; | ||
| 52 | + } | ||
| 53 | + minutes.push({ | ||
| 54 | + name: i + '分', | ||
| 55 | + id: i | ||
| 56 | + }); | ||
| 57 | + } | ||
| 58 | + return function (_year, _month) { | ||
| 59 | + const days = []; | ||
| 60 | + _year = parseInt(_year); | ||
| 61 | + _month = parseInt(_month); | ||
| 62 | + //获取日期 | ||
| 63 | + for (let i = 1; i <= getDaysInOneMonth(_year, _month); i++) { | ||
| 64 | + if (i < 10) { | ||
| 65 | + i = "0" + i; | ||
| 66 | + } | ||
| 67 | + days.push({ | ||
| 68 | + name: i + '日', | ||
| 69 | + id: i | ||
| 70 | + }); | ||
| 71 | + } | ||
| 72 | + return [years, months, days, hours, minutes]; | ||
| 73 | + } | ||
| 74 | +} | ||
| 75 | +export { | ||
| 76 | + dateTimePicker, | ||
| 77 | + getDaysInOneMonth, | ||
| 78 | + dateDate | ||
| 79 | +} |
src/components/time-picker-data/picker.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <picker mode="multiSelector" :range-key="'name'" :value="timeIndex" :range="activityArray" :disabled="disabled" | ||
| 3 | + @change="bindMultiPickerChange" @columnChange="bindMultiPickerColumnChange"> | ||
| 4 | + <slot /> | ||
| 5 | + </picker> | ||
| 6 | +</template> | ||
| 7 | +<script> | ||
| 8 | +import { dateTimePicker, dateDate } from "./dateTimePicker.js"; | ||
| 9 | +export default { | ||
| 10 | + name: "TimePickerDataPicker", | ||
| 11 | + props: { | ||
| 12 | + startTime: { | ||
| 13 | + type: [Object, Date], | ||
| 14 | + default: new Date(), | ||
| 15 | + }, | ||
| 16 | + endTime: { | ||
| 17 | + type: [Object, Date], | ||
| 18 | + default: new Date(), | ||
| 19 | + }, | ||
| 20 | + defaultTime: { | ||
| 21 | + type: [Object, Date], | ||
| 22 | + default: new Date(), | ||
| 23 | + }, | ||
| 24 | + disabled: { | ||
| 25 | + type: Boolean, | ||
| 26 | + default: false, | ||
| 27 | + }, | ||
| 28 | + }, | ||
| 29 | + data() { | ||
| 30 | + return { | ||
| 31 | + timeIndex: [0, 0, 0, 0, 0], | ||
| 32 | + activityArray: [], | ||
| 33 | + year: 0, | ||
| 34 | + month: 1, | ||
| 35 | + day: 1, | ||
| 36 | + hour: 0, | ||
| 37 | + minute: 0, | ||
| 38 | + datePicker: "", | ||
| 39 | + defaultIndex: [0, 0, 0, 0, 0], | ||
| 40 | + startIndex: [0, 0, 0, 0, 0], | ||
| 41 | + endIndex: [0, 0, 0, 0, 0], | ||
| 42 | + }; | ||
| 43 | + }, | ||
| 44 | + computed: { | ||
| 45 | + timeDate() { | ||
| 46 | + const { startTime, endTime } = this; | ||
| 47 | + return { startTime, endTime }; | ||
| 48 | + }, | ||
| 49 | + }, | ||
| 50 | + watch: { | ||
| 51 | + timeDate() { | ||
| 52 | + this.initData(); | ||
| 53 | + }, | ||
| 54 | + defaultTime () { | ||
| 55 | + this.initData(); | ||
| 56 | + } | ||
| 57 | + }, | ||
| 58 | + created() { | ||
| 59 | + this.initData(); | ||
| 60 | + }, | ||
| 61 | + methods: { | ||
| 62 | + initData() { | ||
| 63 | + let startTime = this.startTime; | ||
| 64 | + let endTime = this.endTime; | ||
| 65 | + this.datePicker = dateTimePicker( | ||
| 66 | + startTime.getFullYear(), | ||
| 67 | + endTime.getFullYear() | ||
| 68 | + ); | ||
| 69 | + this.setDateData(this.defaultTime); | ||
| 70 | + this.getKeyIndex(this.startTime, "startIndex"); | ||
| 71 | + // 截止时间索引 | ||
| 72 | + this.getKeyIndex(this.endTime, "endIndex"); | ||
| 73 | + // 默认索引 | ||
| 74 | + this.getKeyIndex(this.defaultTime, "defaultIndex"); | ||
| 75 | + this.timeIndex = this.defaultIndex; | ||
| 76 | + // 初始时间 | ||
| 77 | + this.initTime(); | ||
| 78 | + }, | ||
| 79 | + getKeyIndex(time, key) { | ||
| 80 | + let Arr = dateDate(time); | ||
| 81 | + let _index = this.getIndex(Arr); | ||
| 82 | + this[key] = _index; | ||
| 83 | + }, | ||
| 84 | + getIndex(arr) { | ||
| 85 | + let timeIndex = []; | ||
| 86 | + let indexKey = ["year", "month", "day", "hours", "minutes"]; | ||
| 87 | + this.activityArray.forEach((element, index) => { | ||
| 88 | + let _index = element.findIndex( | ||
| 89 | + (item) => parseInt(item.id) === parseInt(arr[indexKey[index]]) | ||
| 90 | + ); | ||
| 91 | + timeIndex[index] = _index >= 0 ? _index : 0; | ||
| 92 | + }); | ||
| 93 | + return timeIndex; | ||
| 94 | + }, | ||
| 95 | + initTime() { | ||
| 96 | + let _index = this.timeIndex; | ||
| 97 | + this.year = this.activityArray[0][_index[0]].id; | ||
| 98 | + this.month = this.activityArray[1].length && this.activityArray[1][_index[1]].id; | ||
| 99 | + this.day = this.activityArray[2].length && this.activityArray[2][_index[2]].id; | ||
| 100 | + this.hour = this.activityArray[3].length && this.activityArray[3][_index[3]].id; | ||
| 101 | + this.minute = this.activityArray[4].length && this.activityArray[4][_index[4]].id; | ||
| 102 | + }, | ||
| 103 | + setDateData(_date) { | ||
| 104 | + let _data = dateDate(_date); | ||
| 105 | + this.activityArray = this.datePicker(_data.year, _data.month); | ||
| 106 | + }, | ||
| 107 | + bindMultiPickerChange(e) { | ||
| 108 | + console.log("picker发送选择改变,携带值为", e.detail.value); | ||
| 109 | + let activityArray = JSON.parse(JSON.stringify(this.activityArray)), | ||
| 110 | + { value } = e.detail, | ||
| 111 | + _result = []; | ||
| 112 | + for (let i = 0; i < value.length; i++) { | ||
| 113 | + _result[i] = activityArray[i][value[i]].id; | ||
| 114 | + } | ||
| 115 | + this.$emit("result", _result); | ||
| 116 | + }, | ||
| 117 | + bindMultiPickerColumnChange(e) { | ||
| 118 | + console.log("修改的列为", e.detail.column, ",值为", e.detail.value); | ||
| 119 | + let _data = JSON.parse(JSON.stringify(this.activityArray)), | ||
| 120 | + timeIndex = JSON.parse(JSON.stringify(this.timeIndex)), | ||
| 121 | + { startIndex, endIndex } = this, | ||
| 122 | + { column, value } = e.detail, | ||
| 123 | + _value = _data[column][value].id, | ||
| 124 | + _start = dateDate(this.startTime), | ||
| 125 | + _end = dateDate(this.endTime); | ||
| 126 | + switch (e.detail.column) { | ||
| 127 | + case 0: | ||
| 128 | + if (_value <= _start.year) { | ||
| 129 | + timeIndex = startIndex; | ||
| 130 | + this.year = _start.year; | ||
| 131 | + this.setDateData(this.startTime); | ||
| 132 | + } else if (_value >= _end.year) { | ||
| 133 | + this.year = _end.year; | ||
| 134 | + timeIndex = [endIndex[0], 0, 0, 0, 0]; | ||
| 135 | + this.setDateData(this.endTime); | ||
| 136 | + } else { | ||
| 137 | + this.year = _value; | ||
| 138 | + timeIndex = [value, 0, 0, 0, 0]; | ||
| 139 | + this.activityArray = this.datePicker(_value, 1); | ||
| 140 | + } | ||
| 141 | + timeIndex = this.timeIndex = JSON.parse(JSON.stringify(timeIndex)); | ||
| 142 | + this.timeIndex = timeIndex; | ||
| 143 | + break; | ||
| 144 | + case 1: | ||
| 145 | + if (this.year == _start.year && value <= startIndex[1]) { | ||
| 146 | + timeIndex = startIndex; | ||
| 147 | + this.month = _start.month; | ||
| 148 | + this.setDateData(this.startTime); | ||
| 149 | + } else if (this.year == _end.year && value >= endIndex[1]) { | ||
| 150 | + timeIndex = endIndex; | ||
| 151 | + this.month = _end.month; | ||
| 152 | + this.setDateData(this.endTime); | ||
| 153 | + } else { | ||
| 154 | + this.month = _value; | ||
| 155 | + _data[2] = this.datePicker(this.year, this.month)[2]; | ||
| 156 | + timeIndex = [timeIndex[0], value, 0, 0, 0]; | ||
| 157 | + this.activityArray = _data; | ||
| 158 | + } | ||
| 159 | + this.timeIndex = JSON.parse(JSON.stringify(timeIndex)); | ||
| 160 | + break; | ||
| 161 | + case 2: | ||
| 162 | + if ( | ||
| 163 | + this.year == _start.year && | ||
| 164 | + this.month == _start.month && | ||
| 165 | + value <= startIndex[2] | ||
| 166 | + ) { | ||
| 167 | + this.day = _start.day; | ||
| 168 | + timeIndex = startIndex; | ||
| 169 | + } else if ( | ||
| 170 | + this.year == _end.year && | ||
| 171 | + this.month == _end.month && | ||
| 172 | + value >= endIndex[2] | ||
| 173 | + ) { | ||
| 174 | + this.day = _end.day; | ||
| 175 | + timeIndex = endIndex; | ||
| 176 | + } else { | ||
| 177 | + this.day = _value; | ||
| 178 | + timeIndex = [timeIndex[0], timeIndex[1], value, 0, 0]; | ||
| 179 | + } | ||
| 180 | + this.timeIndex = JSON.parse(JSON.stringify(timeIndex)); | ||
| 181 | + break; | ||
| 182 | + case 3: | ||
| 183 | + if ( | ||
| 184 | + this.year == _start.year && | ||
| 185 | + this.month == _start.month && | ||
| 186 | + this.day == _start.day && | ||
| 187 | + value <= startIndex[3] | ||
| 188 | + ) { | ||
| 189 | + this.hour = _start.hours; | ||
| 190 | + timeIndex = startIndex; | ||
| 191 | + } else if ( | ||
| 192 | + this.year == _end.year && | ||
| 193 | + this.month == _end.month && | ||
| 194 | + this.day == _end.day && | ||
| 195 | + value >= endIndex[3] | ||
| 196 | + ) { | ||
| 197 | + this.hour = _end.hours; | ||
| 198 | + timeIndex = endIndex; | ||
| 199 | + } else { | ||
| 200 | + this.hour = _value; | ||
| 201 | + timeIndex[3] = value; | ||
| 202 | + timeIndex[4] = 0; | ||
| 203 | + } | ||
| 204 | + this.timeIndex = JSON.parse(JSON.stringify(timeIndex)); | ||
| 205 | + break; | ||
| 206 | + case 4: | ||
| 207 | + timeIndex[4] = value; | ||
| 208 | + if ( | ||
| 209 | + this.year == _start.year && | ||
| 210 | + this.month == _start.month && | ||
| 211 | + this.day == _start.day && | ||
| 212 | + this.hour == _start.hours && | ||
| 213 | + value <= startIndex[4] | ||
| 214 | + ) { | ||
| 215 | + timeIndex = startIndex; | ||
| 216 | + } else if ( | ||
| 217 | + this.year == _end.year && | ||
| 218 | + this.month == _end.month && | ||
| 219 | + this.day == _end.day && | ||
| 220 | + this.hour == _end.hours && | ||
| 221 | + value >= endIndex[4] | ||
| 222 | + ) { | ||
| 223 | + timeIndex = endIndex; | ||
| 224 | + } | ||
| 225 | + this.timeIndex = JSON.parse(JSON.stringify(timeIndex)); | ||
| 226 | + break; | ||
| 227 | + } | ||
| 228 | + }, | ||
| 229 | + }, | ||
| 230 | +}; | ||
| 231 | +</script> |
src/composables/useOfflineBookingCache.js
0 → 100644
| 1 | +/** | ||
| 2 | + * 刷新离线预约记录缓存 | ||
| 3 | + * - 仅在有授权且网络可用时调用 | ||
| 4 | + * - 成功后将数据存储到本地缓存(key: OFFLINE_BOOKING_DATA) | ||
| 5 | + * @param {boolean} force - 是否强制刷新,默认为 false | ||
| 6 | + * @returns {Promise<void>} | ||
| 7 | + */ | ||
| 8 | + | ||
| 9 | +import Taro from '@tarojs/taro' | ||
| 10 | +import { billOfflineAllAPI } from '@/api/index' | ||
| 11 | +import { hasAuth } from '@/utils/authRedirect' | ||
| 12 | +import { formatDatetime } from '@/utils/tools' | ||
| 13 | +import { is_usable_network, get_network_type } from '@/utils/network' | ||
| 14 | + | ||
| 15 | +export const OFFLINE_BOOKING_CACHE_KEY = 'OFFLINE_BOOKING_DATA' | ||
| 16 | + | ||
| 17 | +let refresh_promise = null | ||
| 18 | + | ||
| 19 | +/** | ||
| 20 | + * @description 兼容不同后端结构:从预约记录中提取可用数据载荷 | ||
| 21 | + * - 部分接口会把字段塞到 bill.list 对象里,这里做一次展开合并 | ||
| 22 | + * @param {Object} bill 原始预约记录 | ||
| 23 | + * @returns {Object} 扁平化后的预约记录对象 | ||
| 24 | + */ | ||
| 25 | +const extract_bill_payload = (bill) => { | ||
| 26 | + if (!bill) return {} | ||
| 27 | + | ||
| 28 | + const data = { ...bill } | ||
| 29 | + const list = data.list | ||
| 30 | + | ||
| 31 | + if (list && typeof list === 'object' && !Array.isArray(list)) { | ||
| 32 | + return { ...list, ...data } | ||
| 33 | + } | ||
| 34 | + | ||
| 35 | + return data | ||
| 36 | +} | ||
| 37 | + | ||
| 38 | +/** | ||
| 39 | + * @description 从预约记录中提取人员列表 | ||
| 40 | + * - 兼容不同字段名(person_list/bill_person_list/persons/qrcode_list/qr_list/detail_list) | ||
| 41 | + * - 保证返回数组类型 | ||
| 42 | + * @param {Object} bill 预约记录 | ||
| 43 | + * @returns {Array} 人员列表 | ||
| 44 | + */ | ||
| 45 | +const extract_person_list = (bill) => { | ||
| 46 | + if (!bill) return [] | ||
| 47 | + | ||
| 48 | + /** | ||
| 49 | + * 从预约记录中提取人员列表 | ||
| 50 | + * - 考虑不同字段名的情况(如 person_list, bill_person_list, persons, qrcode_list, qr_list, detail_list) | ||
| 51 | + * - 确保返回的是数组类型 | ||
| 52 | + */ | ||
| 53 | + const candidate = | ||
| 54 | + (Array.isArray(bill.list) ? bill.list : null) || | ||
| 55 | + (Array.isArray(bill?.list?.list) ? bill.list.list : null) || | ||
| 56 | + bill.person_list || | ||
| 57 | + bill.bill_person_list || | ||
| 58 | + bill.persons || | ||
| 59 | + bill.qrcode_list || | ||
| 60 | + bill.qr_list || | ||
| 61 | + bill.detail_list || | ||
| 62 | + [] | ||
| 63 | + | ||
| 64 | + return Array.isArray(candidate) ? candidate : [] | ||
| 65 | +} | ||
| 66 | + | ||
| 67 | +/** | ||
| 68 | + * @description 格式化预约记录项(统一字段与展示用时间) | ||
| 69 | + * @param {Object} item 原始预约记录项 | ||
| 70 | + * @returns {Object} 格式化后的预约记录项 | ||
| 71 | + */ | ||
| 72 | +const normalize_bill_item = (item) => { | ||
| 73 | + const data = extract_bill_payload(item) | ||
| 74 | + | ||
| 75 | + data.datetime = data.datetime || formatDatetime(data) | ||
| 76 | + data.booking_time = data.booking_time || data.datetime | ||
| 77 | + data.order_time = data.order_time || (data.created_time ? data.created_time.slice(0, -3) : '') | ||
| 78 | + | ||
| 79 | + if (!data.person_name) { | ||
| 80 | + const person_list = extract_person_list(item) | ||
| 81 | + const first = person_list[0] | ||
| 82 | + const name = first?.name || first?.person_name | ||
| 83 | + if (name) data.person_name = name | ||
| 84 | + } | ||
| 85 | + | ||
| 86 | + return data | ||
| 87 | +} | ||
| 88 | + | ||
| 89 | +/** | ||
| 90 | + * 获取离线预约记录缓存 | ||
| 91 | + * @returns {Array} 格式化后的预约记录项列表 | ||
| 92 | + */ | ||
| 93 | +export const get_offline_booking_cache = () => { | ||
| 94 | + try { | ||
| 95 | + const data = Taro.getStorageSync(OFFLINE_BOOKING_CACHE_KEY) | ||
| 96 | + return Array.isArray(data) ? data : [] | ||
| 97 | + } catch (e) { | ||
| 98 | + return [] | ||
| 99 | + } | ||
| 100 | +} | ||
| 101 | + | ||
| 102 | +/** | ||
| 103 | + * 检查是否存在离线预约记录缓存 | ||
| 104 | + * @returns {boolean} 是否存在缓存且非空 | ||
| 105 | + */ | ||
| 106 | +export const has_offline_booking_cache = () => { | ||
| 107 | + const list = get_offline_booking_cache() | ||
| 108 | + return Array.isArray(list) && list.length > 0 | ||
| 109 | +} | ||
| 110 | + | ||
| 111 | +/** | ||
| 112 | + * 根据支付ID获取离线预约记录 | ||
| 113 | + * @param {*} pay_id 支付ID | ||
| 114 | + * @returns {Object|null} 匹配的预约记录项或 null | ||
| 115 | + */ | ||
| 116 | +export const get_offline_booking_by_pay_id = (pay_id) => { | ||
| 117 | + const list = get_offline_booking_cache() | ||
| 118 | + const target_pay_id = String(pay_id || '') | ||
| 119 | + return list.find((item) => String(item?.pay_id || '') === target_pay_id) || null | ||
| 120 | +} | ||
| 121 | + | ||
| 122 | +/** | ||
| 123 | + * 获取预约记录中的人员列表 | ||
| 124 | + * @param {Object} bill - 预约记录项 | ||
| 125 | + * @returns {Array} 人员列表(包含姓名、身份证号、二维码等信息) | ||
| 126 | + */ | ||
| 127 | +export const get_offline_bill_person_list = (bill) => { | ||
| 128 | + return extract_person_list(bill) | ||
| 129 | +} | ||
| 130 | + | ||
| 131 | +/** | ||
| 132 | + * 构建预约记录中的二维码列表 | ||
| 133 | + * @param {Object} bill - 预约记录项 | ||
| 134 | + * @returns {Array} 二维码列表(包含姓名、身份证号、二维码、预约时间等信息) | ||
| 135 | + */ | ||
| 136 | +export const build_offline_qr_list = (bill) => { | ||
| 137 | + const list = get_offline_bill_person_list(bill) | ||
| 138 | + const datetime = bill?.datetime || formatDatetime(bill || {}) | ||
| 139 | + | ||
| 140 | + return list | ||
| 141 | + .filter((item) => item && (item.qr_code || item.qrcode || item.qrCode) && (item.qr_code || item.qrcode || item.qrCode) !== '') | ||
| 142 | + .map((item) => { | ||
| 143 | + const begin_time = item.begin_time || bill?.begin_time | ||
| 144 | + const end_time = item.end_time || bill?.end_time | ||
| 145 | + const qr_code = item.qr_code || item.qrcode || item.qrCode | ||
| 146 | + const name = item.name || item.person_name || item.real_name | ||
| 147 | + const id_number = item.id_number || item.idcard || item.idCard || item.id | ||
| 148 | + return { | ||
| 149 | + name, | ||
| 150 | + id_number, | ||
| 151 | + qr_code, | ||
| 152 | + begin_time, | ||
| 153 | + end_time, | ||
| 154 | + datetime: item.datetime || (begin_time && end_time ? formatDatetime({ begin_time, end_time }) : datetime), | ||
| 155 | + pay_id: bill?.pay_id, | ||
| 156 | + sort: 0, | ||
| 157 | + } | ||
| 158 | + }) | ||
| 159 | +} | ||
| 160 | + | ||
| 161 | +/** | ||
| 162 | + * 刷新离线预约记录缓存 | ||
| 163 | + * - 仅在有授权且网络可用时调用 | ||
| 164 | + * - 成功后将数据存储到本地缓存(key: OFFLINE_BOOKING_DATA) | ||
| 165 | + * @param {boolean} force - 是否强制刷新,默认为 false. force 参数的核心作用是控制是否忽略 “正在进行的缓存请求”, 管的是 “是否允许重复发起请求”,不管 “请求能不能成功执行缓存”。 | ||
| 166 | + * @returns 不同情况返回值不一样 | ||
| 167 | + * - 成功时包含格式化后的预约记录项列表 | ||
| 168 | + * - 失败时包含错误信息(如网络错误、授权失败等) | ||
| 169 | + */ | ||
| 170 | + | ||
| 171 | +export const refresh_offline_booking_cache = async ({ force = false } = {}) => { | ||
| 172 | + // 1. 检查是否有正在进行的刷新请求 | ||
| 173 | + // 2. 如果有,且 force 为 false,则直接返回该 Promise | ||
| 174 | + // 3. 如果没有,或 force 为 true,则继续执行刷新逻辑 | ||
| 175 | + // 4. 刷新完成后,将结果存储到本地缓存(key: OFFLINE_BOOKING_CACHE_KEY) | ||
| 176 | + // 5. 返回刷新结果 Promise | ||
| 177 | + | ||
| 178 | + if (!hasAuth()) return { code: 0, data: null, msg: '未授权' } | ||
| 179 | + | ||
| 180 | + if (refresh_promise && !force) return refresh_promise | ||
| 181 | + | ||
| 182 | + // 核心逻辑: | ||
| 183 | + // 1. 立刻触发异步逻辑,同时捕获 Promise 状态 | ||
| 184 | + // 2. 保证 refresh_promise 始终是 Promise 类型,适配 await | ||
| 185 | + // 3. 隔离作用域,避免变量污染 | ||
| 186 | + // 加 () 是为了 “让异步逻辑立刻跑起来”,并把 “跑的结果(Promise)” 存起来,供后续复用和等待。 | ||
| 187 | + refresh_promise = (async () => { | ||
| 188 | + const network_type = await get_network_type() | ||
| 189 | + if (!is_usable_network(network_type)) { | ||
| 190 | + return { code: 0, data: null, msg: '网络不可用' } | ||
| 191 | + } | ||
| 192 | + | ||
| 193 | + const { code, data, msg } = await billOfflineAllAPI() | ||
| 194 | + if (code && Array.isArray(data)) { | ||
| 195 | + // 过滤出状态为3(已完成)的记录 | ||
| 196 | + const normalized = data.map(normalize_bill_item).filter((item) => item && item.pay_id && item.status == 3) | ||
| 197 | + if (normalized.length > 0) { | ||
| 198 | + // TAG: 核心逻辑:将过滤后的记录存储到本地缓存 | ||
| 199 | + Taro.setStorageSync(OFFLINE_BOOKING_CACHE_KEY, normalized) | ||
| 200 | + } | ||
| 201 | + } | ||
| 202 | + return { code, data, msg } | ||
| 203 | + })() | ||
| 204 | + | ||
| 205 | + try { | ||
| 206 | + return await refresh_promise | ||
| 207 | + } finally { | ||
| 208 | + refresh_promise = null | ||
| 209 | + } | ||
| 210 | +} |
| 1 | +/** | ||
| 2 | + * @description: 轮询离线预约缓存 | ||
| 3 | + */ | ||
| 4 | + | ||
| 5 | +import Taro from '@tarojs/taro' | ||
| 6 | +import { refresh_offline_booking_cache } from '@/composables/useOfflineBookingCache' | ||
| 7 | +import { get_network_type, is_usable_network } from '@/utils/network' | ||
| 8 | + | ||
| 9 | +/** | ||
| 10 | + * @description: 轮询状态 | ||
| 11 | + * @typedef {Object} PollingState | ||
| 12 | + * @property {Number} timer_id 轮询定时器id | ||
| 13 | + * @property {Boolean} running 是否正在轮询 | ||
| 14 | + * @property {Boolean} in_flight 是否正在刷新 | ||
| 15 | + * @property {Number} ref_count 引用计数 | ||
| 16 | + * @property {Boolean} app_enabled 是否启用应用 | ||
| 17 | + * @property {Object} last_options 最后一次选项 | ||
| 18 | + * @property {Boolean} network_usable 网络可用性 | ||
| 19 | + * @property {Boolean} has_network_listener 是否已注册网络监听器 | ||
| 20 | + * @property {Function} network_listener 网络监听器 | ||
| 21 | + * @property {Promise} network_listener_promise 网络监听器Promise | ||
| 22 | + * | ||
| 23 | + * 状态同步规则(app_enabled 与 ref_count): | ||
| 24 | + * - app_enabled = true 时,ref_count >= 1(至少有一个使用者) | ||
| 25 | + * - app_enabled = false 时,ref_count = 0(无使用者) | ||
| 26 | + * - enable_offline_booking_cache_polling 会设置 app_enabled = true | ||
| 27 | + * - disable_offline_booking_cache_polling 会设置 app_enabled = false | ||
| 28 | + * - acquire_polling_ref 会增加 ref_count | ||
| 29 | + * - release_polling_ref 会减少 ref_count,降为0时触发清理 | ||
| 30 | + */ | ||
| 31 | + | ||
| 32 | +/** @type {PollingState} */ | ||
| 33 | +const polling_state = { | ||
| 34 | + timer_id: null, // 轮询定时器id | ||
| 35 | + running: false, // 是否正在轮询 | ||
| 36 | + in_flight: false, // 是否正在刷新 | ||
| 37 | + ref_count: 0, // 引用计数 | ||
| 38 | + app_enabled: false, // 是否启用应用 | ||
| 39 | + last_options: null, // 最后一次选项 | ||
| 40 | + network_usable: null, // 网络可用性 | ||
| 41 | + has_network_listener: false, // 是否已注册网络监听器 | ||
| 42 | + network_listener: null, // 网络监听器 | ||
| 43 | + network_listener_promise: null, // 网络监听器Promise | ||
| 44 | +} | ||
| 45 | + | ||
| 46 | +/** | ||
| 47 | + * @description: 规范化选项参数(纯函数,无副作用) | ||
| 48 | + * @param {Object} options 选项 | ||
| 49 | + * @return {Object} 规范化后的选项 | ||
| 50 | + */ | ||
| 51 | +const normalize_options = (options) => { | ||
| 52 | + return options || {} | ||
| 53 | +} | ||
| 54 | + | ||
| 55 | +/** | ||
| 56 | + * @description: 保存最后一次选项(用于网络恢复时重启轮询) | ||
| 57 | + * @param {Object} options 选项 | ||
| 58 | + * @return {Object} 保存后的选项 | ||
| 59 | + */ | ||
| 60 | +const save_last_options = (options) => { | ||
| 61 | + if (options) polling_state.last_options = options | ||
| 62 | + return polling_state.last_options | ||
| 63 | +} | ||
| 64 | + | ||
| 65 | +/** | ||
| 66 | + * 这是异步编程中典型的飞行状态锁(In-Flight Lock) 模式,是异步防重的核心思维落地方式; | ||
| 67 | + * 核心逻辑:执行前 “上锁” 标记 → 执行异步操作 → 无论成败都 “解锁” 重置标记,从根源避免重复执行; | ||
| 68 | + * finally 块是关键保障:防止异步操作报错导致 “永久上锁”,确保后续调用能正常执行。 | ||
| 69 | + */ | ||
| 70 | + | ||
| 71 | +/** | ||
| 72 | + * @description: 刷新离线预约缓存一次 | ||
| 73 | + * @param {Object} options 选项 | ||
| 74 | + * @param {Boolean} options.force 是否强制刷新 | ||
| 75 | + */ | ||
| 76 | +const run_refresh_once = async (options) => { | ||
| 77 | + // 前置检查:不满足轮询条件时直接返回(网络不可用或无引用) | ||
| 78 | + if (!should_run_polling()) return | ||
| 79 | + // 核心防重复——如果正在刷新,直接返回 | ||
| 80 | + if (polling_state.in_flight) return | ||
| 81 | + // 标记为"正在刷新" | ||
| 82 | + polling_state.in_flight = true | ||
| 83 | + try { | ||
| 84 | + await refresh_offline_booking_cache({ force: !!options?.force }) | ||
| 85 | + } finally { | ||
| 86 | + // 刷新完成后,标记为"刷新完成" | ||
| 87 | + polling_state.in_flight = false | ||
| 88 | + } | ||
| 89 | +} | ||
| 90 | + | ||
| 91 | +/** | ||
| 92 | + * @description: 更新网络可用性 | ||
| 93 | + */ | ||
| 94 | + | ||
| 95 | +const update_network_usable = async () => { | ||
| 96 | + const type = await get_network_type() | ||
| 97 | + polling_state.network_usable = is_usable_network(type) | ||
| 98 | +} | ||
| 99 | + | ||
| 100 | +/** | ||
| 101 | + * @description: 判断是否需要运行轮询 | ||
| 102 | + * @return {Boolean} 是否需要运行轮询 | ||
| 103 | + * | ||
| 104 | + * 返回 false 的条件: | ||
| 105 | + * 1. ref_count <= 0:无使用者,无需轮询 | ||
| 106 | + * 2. network_usable === false:网络不可用,无需轮询 | ||
| 107 | + * 3. network_usable === null:网络状态未初始化,避免在无监听器时误判 | ||
| 108 | + * | ||
| 109 | + * 返回 true 的条件: | ||
| 110 | + * 1. ref_count > 0:至少有一个使用者 | ||
| 111 | + * 2. network_usable === true:网络可用 | ||
| 112 | + */ | ||
| 113 | +const should_run_polling = () => { | ||
| 114 | + if (polling_state.ref_count <= 0) return false | ||
| 115 | + if (polling_state.network_usable === false) return false | ||
| 116 | + if (polling_state.network_usable === null) return false | ||
| 117 | + return true | ||
| 118 | +} | ||
| 119 | + | ||
| 120 | +/** | ||
| 121 | + * @description: 确保网络监听器已注册 | ||
| 122 | + * @return {Promise<Boolean>} 是否注册成功(true=成功,false=失败) | ||
| 123 | + */ | ||
| 124 | +const ensure_network_listener = async () => { | ||
| 125 | + /** | ||
| 126 | + * 代码优先通过两个条件判断避免重复执行监听器逻辑 | ||
| 127 | + * 1. 有已注册的监听器直接返回 | ||
| 128 | + * 2. 有未完成的注册 Promise 则直接返回 | ||
| 129 | + */ | ||
| 130 | + | ||
| 131 | + if (polling_state.has_network_listener) { | ||
| 132 | + await update_network_usable() | ||
| 133 | + return true | ||
| 134 | + } | ||
| 135 | + | ||
| 136 | + if (polling_state.network_listener_promise) { | ||
| 137 | + await polling_state.network_listener_promise | ||
| 138 | + // 等待注册完成后检查是否成功 | ||
| 139 | + return polling_state.has_network_listener | ||
| 140 | + } | ||
| 141 | + | ||
| 142 | + // 立即执行异步的监听器注册流程(标记状态→更新网络可用性→定义回调→注册监听) | ||
| 143 | + polling_state.network_listener_promise = (async () => { | ||
| 144 | + // 标记已注册网络监听器 | ||
| 145 | + polling_state.has_network_listener = true | ||
| 146 | + // 初始化时更新网络可用性 | ||
| 147 | + await update_network_usable() | ||
| 148 | + | ||
| 149 | + // 网络状态变化监听器, 网络状态变化时的处理逻辑,此时只是定义,不会立即执行 | ||
| 150 | + polling_state.network_listener = (res) => { | ||
| 151 | + const is_connected = res?.isConnected !== false | ||
| 152 | + const type = res?.networkType || 'unknown' | ||
| 153 | + polling_state.network_usable = is_connected && is_usable_network(type) | ||
| 154 | + | ||
| 155 | + // 改进:不再主动停止轮询,由 run_refresh_once 中的 should_run_polling() 前置检查控制 | ||
| 156 | + // 优势: | ||
| 157 | + // 1. 避免网络恢复时需要额外的重启逻辑 | ||
| 158 | + // 2. 保持定时器稳定,避免频繁启动/停止 | ||
| 159 | + // 3. 网络不可用时,刷新操作会在 run_refresh_once 中被前置检查过滤掉 | ||
| 160 | + | ||
| 161 | + // 网络恢复时,确保轮询正在运行(处理之前因网络不可用而可能停止的轮询) | ||
| 162 | + if (polling_state.network_usable && should_run_polling()) { | ||
| 163 | + // 传入 restart: true,支持重启逻辑 | ||
| 164 | + // 使用 normalize_options 显式处理 null/undefined,语义更清晰 | ||
| 165 | + start_offline_booking_cache_polling({ | ||
| 166 | + ...normalize_options(polling_state.last_options), | ||
| 167 | + restart: true | ||
| 168 | + }) | ||
| 169 | + } | ||
| 170 | + } | ||
| 171 | + | ||
| 172 | + try { | ||
| 173 | + // 注册网络状态变化监听器 | ||
| 174 | + Taro.onNetworkStatusChange(polling_state.network_listener) | ||
| 175 | + } catch (e) { | ||
| 176 | + polling_state.has_network_listener = false | ||
| 177 | + polling_state.network_listener = null | ||
| 178 | + polling_state.network_usable = null | ||
| 179 | + console.error('注册网络监听失败:', e) | ||
| 180 | + } | ||
| 181 | + })() | ||
| 182 | + | ||
| 183 | + try { | ||
| 184 | + // 等待网络监听器初始化完成 | ||
| 185 | + await polling_state.network_listener_promise | ||
| 186 | + } finally { | ||
| 187 | + // 等待注册流程完成后,强制清空 Promise 缓存(finally 块),保证下次执行逻辑时状态干净 | ||
| 188 | + polling_state.network_listener_promise = null | ||
| 189 | + } | ||
| 190 | + | ||
| 191 | + // 返回注册是否成功 | ||
| 192 | + return polling_state.has_network_listener | ||
| 193 | +} | ||
| 194 | + | ||
| 195 | +/** | ||
| 196 | + * @description: 注销网络监听器 | ||
| 197 | + * 涉及字段: | ||
| 198 | + * - has_network_listener:是否有注册网络监听器 | ||
| 199 | + * - ref_count:引用计数 | ||
| 200 | + * - network_listener:网络状态变化监听器 | ||
| 201 | + * - network_usable:网络可用性状态 | ||
| 202 | + */ | ||
| 203 | + | ||
| 204 | +const teardown_network_listener = () => { | ||
| 205 | + // 1. 前置校验:避免无效执行 | ||
| 206 | + // 如果没有注册网络监听器,直接返回 | ||
| 207 | + if (!polling_state.has_network_listener) return | ||
| 208 | + // 如果有引用计数,说明有其他地方在使用轮询,不能注销监听器 | ||
| 209 | + if (polling_state.ref_count > 0) return | ||
| 210 | + // 标记监听器已注销(核心状态更新) | ||
| 211 | + polling_state.has_network_listener = false | ||
| 212 | + // 解绑框架层面的监听器 | ||
| 213 | + if (polling_state.network_listener && typeof Taro.offNetworkStatusChange === 'function') { | ||
| 214 | + try { | ||
| 215 | + Taro.offNetworkStatusChange(polling_state.network_listener) | ||
| 216 | + } catch (e) { | ||
| 217 | + // 捕获解绑失败的异常(比如监听器已被手动解绑) | ||
| 218 | + console.warn('注销网络监听器失败:', e) | ||
| 219 | + } | ||
| 220 | + } | ||
| 221 | + // 手动清空本地引用(关键!无论解绑成功/失败都要做) | ||
| 222 | + // 注销后,清空网络监听器引用,确保后续调用能正常工作 | ||
| 223 | + polling_state.network_listener = null | ||
| 224 | + /** | ||
| 225 | + * 核心目的:清空 network_usable = null 是为了让状态和监听器的生命周期完全同步 —— 监听器注销后,其产生的网络状态也必须失效,避免 “无监听器却有状态” 的矛盾; | ||
| 226 | + * 关键作用:通过让 should_run_polling() 直接返回 false,杜绝基于过期状态启动轮询的可能; | ||
| 227 | + * 设计思维:体现了 “状态闭环” 的工程化思想 —— 任何状态都要有明确的产生、更新、销毁逻辑,不残留 “脏数据” 干扰后续流程。 | ||
| 228 | + */ | ||
| 229 | + // 清空网络可用性状态,确保后续判断逻辑能正常工作 | ||
| 230 | + // 清空衍生状态,避免脏数据 | ||
| 231 | + polling_state.network_usable = null | ||
| 232 | +} | ||
| 233 | + | ||
| 234 | +/** | ||
| 235 | + * @description: 启动离线预约缓存轮询 | ||
| 236 | + * @param {Object} options 选项 | ||
| 237 | + * @param {Number} options.interval_ms 轮询间隔,单位毫秒 | ||
| 238 | + * @param {Boolean} options.immediate 是否立即刷新一次 | ||
| 239 | + * @param {Boolean} options.force 是否强制刷新(透传给 refresh_offline_booking_cache) | ||
| 240 | + * @param {Boolean} options.restart 是否为重启操作(网络恢复时调用) | ||
| 241 | + */ | ||
| 242 | +const start_offline_booking_cache_polling = (options) => { | ||
| 243 | + options = normalize_options(options) | ||
| 244 | + if (!should_run_polling()) return // 不满足轮询条件直接返回 | ||
| 245 | + | ||
| 246 | + const interval_ms = Number(options?.interval_ms || 60000) | ||
| 247 | + const is_restart = options?.restart === true | ||
| 248 | + | ||
| 249 | + // 改进:区分首次启动和重启的防重逻辑 | ||
| 250 | + // 首次启动时,如果已经在轮询则直接返回(防重复启动) | ||
| 251 | + // 重启时,需要清除旧定时器并重新建立(支持网络恢复时重启) | ||
| 252 | + if (polling_state.running && !is_restart) return | ||
| 253 | + | ||
| 254 | + // 如果是重启或定时器已存在,先清除旧定时器 | ||
| 255 | + if (is_restart && polling_state.timer_id) { | ||
| 256 | + clearInterval(polling_state.timer_id) | ||
| 257 | + polling_state.timer_id = null | ||
| 258 | + } | ||
| 259 | + | ||
| 260 | + polling_state.running = true // 标记为"正在轮询" | ||
| 261 | + | ||
| 262 | + // 立即刷新一次,确保轮询开始时数据是最新的 | ||
| 263 | + if (options?.immediate !== false) { | ||
| 264 | + run_refresh_once(options) | ||
| 265 | + } | ||
| 266 | + | ||
| 267 | + // 启动轮询定时器,按照指定间隔执行刷新操作 | ||
| 268 | + polling_state.timer_id = setInterval(() => { | ||
| 269 | + run_refresh_once(options) | ||
| 270 | + }, interval_ms) | ||
| 271 | +} | ||
| 272 | + | ||
| 273 | +/** | ||
| 274 | + * @description: 停止离线预约缓存轮询 | ||
| 275 | + */ | ||
| 276 | + | ||
| 277 | +const stop_offline_booking_cache_polling = () => { | ||
| 278 | + if (polling_state.timer_id) { | ||
| 279 | + clearInterval(polling_state.timer_id) | ||
| 280 | + polling_state.timer_id = null | ||
| 281 | + } | ||
| 282 | + polling_state.running = false | ||
| 283 | +} | ||
| 284 | + | ||
| 285 | +/** | ||
| 286 | + * 引用计数的核心作用 | ||
| 287 | + * 这两个函数实现了轮询功能的 “引用计数式资源管理”,本质是追踪有多少 “使用者 / 场景” 依赖这个轮询功能,从而决定是否启动 / 维持 / 停止轮询、注册 / 注销网络监听器, | ||
| 288 | + * 核心目的是: | ||
| 289 | + * - 避免轮询被重复启动、错误停止 | ||
| 290 | + * - 防止无使用者时仍占用资源(定时器、网络监听器) | ||
| 291 | + * - 保证多场景共用轮询时的逻辑一致性 | ||
| 292 | + */ | ||
| 293 | + | ||
| 294 | +/** | ||
| 295 | + * @description: 增加轮询引用计数 | ||
| 296 | + * 核心动作:将全局的 ref_count 加 1,代表 "又多了一个场景需要使用轮询功能"。 | ||
| 297 | + * @param {Object} options 选项 | ||
| 298 | + */ | ||
| 299 | +const acquire_polling_ref = (options) => { | ||
| 300 | + save_last_options(options) | ||
| 301 | + polling_state.ref_count += 1 | ||
| 302 | + // 改进:检查网络监听器注册结果,只有成功后才启动轮询 | ||
| 303 | + ensure_network_listener().then((success) => { | ||
| 304 | + if (success && polling_state.last_options) { | ||
| 305 | + start_offline_booking_cache_polling(polling_state.last_options) | ||
| 306 | + } | ||
| 307 | + }) | ||
| 308 | +} | ||
| 309 | + | ||
| 310 | +/** | ||
| 311 | + * @description: 减少轮询引用计数 | ||
| 312 | + * 核心动作:将 ref_count 减 1(且保证不会为负数),代表 “有一个场景不再需要轮询功能”。 | ||
| 313 | + */ | ||
| 314 | + | ||
| 315 | +const release_polling_ref = () => { | ||
| 316 | + polling_state.ref_count = Math.max(0, polling_state.ref_count - 1) | ||
| 317 | + if (polling_state.ref_count === 0) { | ||
| 318 | + // 引用计数降为0时,停止轮询并注销网络监听器 | ||
| 319 | + stop_offline_booking_cache_polling() | ||
| 320 | + teardown_network_listener() | ||
| 321 | + } | ||
| 322 | +} | ||
| 323 | + | ||
| 324 | +/** | ||
| 325 | + * @description: 启用离线预约缓存轮询 | ||
| 326 | + * @param {Object} options 选项 | ||
| 327 | + * @param {Number} options.interval_ms 轮询间隔,单位毫秒 | ||
| 328 | + * @param {Boolean} options.immediate 是否立即刷新一次 | ||
| 329 | + * @param {Boolean} options.force 是否强制刷新(透传给 refresh_offline_booking_cache) | ||
| 330 | + */ | ||
| 331 | +export const enable_offline_booking_cache_polling = (options) => { | ||
| 332 | + save_last_options(options) | ||
| 333 | + /** | ||
| 334 | + * 核心目的:对 app_enabled=true 的场景做兜底,确保轮询在 "已启用但异常停止" 时能被主动恢复,而非被动等待网络变化; | ||
| 335 | + * 执行逻辑:先保证网络监听器(轮询的依赖)就绪,再尝试启动轮询,且利用 start_offline_booking_cache_polling 的幂等性避免重复; | ||
| 336 | + * 设计思维:体现了 "主动调用需即时生效" 的用户体验考量,以及 "依赖前置检查" 的工程化思维 —— 先保证依赖(监听器)就绪,再执行核心操作(启动轮询)。 | ||
| 337 | + */ | ||
| 338 | + if (polling_state.app_enabled) { | ||
| 339 | + ensure_network_listener().then((success) => { | ||
| 340 | + if (success && polling_state.last_options) { | ||
| 341 | + start_offline_booking_cache_polling(polling_state.last_options) | ||
| 342 | + } | ||
| 343 | + }) | ||
| 344 | + return | ||
| 345 | + } | ||
| 346 | + polling_state.app_enabled = true | ||
| 347 | + acquire_polling_ref(polling_state.last_options || {}) | ||
| 348 | +} | ||
| 349 | + | ||
| 350 | +/** | ||
| 351 | + * @description: 禁用离线预约缓存轮询 | ||
| 352 | + */ | ||
| 353 | + | ||
| 354 | +export const disable_offline_booking_cache_polling = () => { | ||
| 355 | + if (!polling_state.app_enabled) return | ||
| 356 | + polling_state.app_enabled = false | ||
| 357 | + release_polling_ref() | ||
| 358 | +} |
src/hooks/useGo.js
0 → 100644
| 1 | +/* | ||
| 2 | + * @Date: 2026-01-06 20:47:00 | ||
| 3 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | + * @LastEditTime: 2026-01-13 11:42:00 | ||
| 5 | + * @FilePath: /xyxBooking-weapp/src/hooks/useGo.js | ||
| 6 | + * @Description: 封装路由跳转方便行内调用 | ||
| 7 | + */ | ||
| 8 | +import Taro from '@tarojs/taro'; | ||
| 9 | + | ||
| 10 | +/** | ||
| 11 | + * @description 获取页面跳转方法(navigateTo) | ||
| 12 | + * - 支持短路径:notice / /notice | ||
| 13 | + * - 自动补全:pages/notice/index | ||
| 14 | + * @returns {(path:string, query?:Object)=>void} go 跳转函数 | ||
| 15 | + */ | ||
| 16 | +export function useGo () { | ||
| 17 | + /** | ||
| 18 | + * @description 路由跳转 | ||
| 19 | + * @param {string} path 目标页面路径,支持 / 开头与短路径 | ||
| 20 | + * @param {Object} query 查询参数(键值对) | ||
| 21 | + * @returns {void} 无返回值 | ||
| 22 | + */ | ||
| 23 | + function go (path, query = {}) { | ||
| 24 | + // 补全路径,如果是 / 开头,去掉 / | ||
| 25 | + let url = path.startsWith('/') ? path.substring(1) : path; | ||
| 26 | + // 检查是否是 tabbar 页面 (目前没有配置 tabbar,所以都是普通跳转) | ||
| 27 | + // 如果是页面,加上 pages/ 前缀 (假设都在 pages 下,且目录名和 path 一致) | ||
| 28 | + // H5 path 是 /notice,小程序是 pages/notice/index | ||
| 29 | + if (!url.startsWith('pages/')) { | ||
| 30 | + url = `pages/${url}/index`; // 适配 pages/notice/index 结构 | ||
| 31 | + } | ||
| 32 | + | ||
| 33 | + // 构建 query string | ||
| 34 | + let queryString = Object.keys(query).map(key => key + '=' + query[key]).join('&'); | ||
| 35 | + if (queryString) { | ||
| 36 | + url += '?' + queryString; | ||
| 37 | + } | ||
| 38 | + | ||
| 39 | + Taro.navigateTo({ | ||
| 40 | + url: '/' + url, | ||
| 41 | + fail: (err) => { | ||
| 42 | + // 如果是 tabbar 页面,尝试 switchTab | ||
| 43 | + if (err.errMsg && err.errMsg.indexOf('tabbar') !== -1) { | ||
| 44 | + Taro.switchTab({ url: '/' + url }); | ||
| 45 | + } else { | ||
| 46 | + console.error('页面跳转失败:', err); | ||
| 47 | + } | ||
| 48 | + } | ||
| 49 | + }) | ||
| 50 | + } | ||
| 51 | + return go | ||
| 52 | +} | ||
| 53 | + | ||
| 54 | +/** | ||
| 55 | + * @description 获取页面替换方法(redirectTo) | ||
| 56 | + * - 支持短路径:notice / /notice | ||
| 57 | + * - 自动补全:pages/notice/index | ||
| 58 | + * @returns {(path:string, query?:Object)=>void} replace 替换函数 | ||
| 59 | + */ | ||
| 60 | +export function useReplace () { | ||
| 61 | + /** | ||
| 62 | + * @description 路由替换 | ||
| 63 | + * @param {string} path 目标页面路径,支持 / 开头与短路径 | ||
| 64 | + * @param {Object} query 查询参数(键值对) | ||
| 65 | + * @returns {void} 无返回值 | ||
| 66 | + */ | ||
| 67 | + function replace (path, query = {}) { | ||
| 68 | + let url = path.startsWith('/') ? path.substring(1) : path; | ||
| 69 | + if (!url.startsWith('pages/')) { | ||
| 70 | + url = `pages/${url}/index`; | ||
| 71 | + } | ||
| 72 | + | ||
| 73 | + let queryString = Object.keys(query).map(key => key + '=' + query[key]).join('&'); | ||
| 74 | + if (queryString) { | ||
| 75 | + url += '?' + queryString; | ||
| 76 | + } | ||
| 77 | + | ||
| 78 | + Taro.redirectTo({ | ||
| 79 | + url: '/' + url | ||
| 80 | + }) | ||
| 81 | + } | ||
| 82 | + return replace | ||
| 83 | +} |
src/index.html
0 → 100644
| 1 | +<!DOCTYPE html> | ||
| 2 | +<html> | ||
| 3 | +<head> | ||
| 4 | + <meta content="text/html; charset=utf-8" http-equiv="Content-Type"> | ||
| 5 | + <meta content="width=device-width,initial-scale=1,user-scalable=no" name="viewport"> | ||
| 6 | + <meta name="apple-mobile-web-app-capable" content="yes"> | ||
| 7 | + <meta name="apple-touch-fullscreen" content="yes"> | ||
| 8 | + <meta name="format-detection" content="telephone=no,address=no"> | ||
| 9 | + <meta name="apple-mobile-web-app-status-bar-style" content="white"> | ||
| 10 | + <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" > | ||
| 11 | + <title>myApp</title> | ||
| 12 | + <script><%= htmlWebpackPlugin.options.script %></script> | ||
| 13 | +</head> | ||
| 14 | +<body> | ||
| 15 | + <div id="app"></div> | ||
| 16 | +</body> | ||
| 17 | +</html> |
src/pages/auth/index.config.js
0 → 100755
src/pages/auth/index.less
0 → 100644
src/pages/auth/index.vue
0 → 100644
| 1 | +<!-- | ||
| 2 | + * @Date: 2022-09-19 14:11:06 | ||
| 3 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | + * @LastEditTime: 2026-01-13 00:18:41 | ||
| 5 | + * @FilePath: /xyxBooking-weapp/src/pages/auth/index.vue | ||
| 6 | + * @Description: 授权页 | ||
| 7 | +--> | ||
| 8 | +<template> | ||
| 9 | + <view class="auth-page"> | ||
| 10 | + <view class="loading"> | ||
| 11 | + <view>正在授权登录...</view> | ||
| 12 | + </view> | ||
| 13 | + </view> | ||
| 14 | +</template> | ||
| 15 | + | ||
| 16 | +<script setup> | ||
| 17 | +import Taro, { useDidShow } from '@tarojs/taro' | ||
| 18 | +import { silentAuth, returnToOriginalPage } from '@/utils/authRedirect' | ||
| 19 | + | ||
| 20 | +let last_try_at = 0 | ||
| 21 | +let has_shown_fail_modal = false | ||
| 22 | +let has_failed = false | ||
| 23 | + | ||
| 24 | +useDidShow(() => { | ||
| 25 | + if (has_failed) return | ||
| 26 | + const now = Date.now() | ||
| 27 | + if (now - last_try_at < 1200) return | ||
| 28 | + last_try_at = now | ||
| 29 | + | ||
| 30 | + /** | ||
| 31 | + * 尝试静默授权 | ||
| 32 | + * - 授权成功后回跳到来源页 | ||
| 33 | + * - 授权失败则跳转至授权页面 | ||
| 34 | + */ | ||
| 35 | + silentAuth() | ||
| 36 | + .then(() => returnToOriginalPage()) | ||
| 37 | + .catch(async (error) => { | ||
| 38 | + has_failed = true | ||
| 39 | + if (has_shown_fail_modal) return | ||
| 40 | + has_shown_fail_modal = true | ||
| 41 | + await Taro.showModal({ | ||
| 42 | + title: '提示', | ||
| 43 | + content: error?.message || '授权失败,请稍后再尝试', | ||
| 44 | + showCancel: false, | ||
| 45 | + confirmText: '我知道了', | ||
| 46 | + }) | ||
| 47 | + }) | ||
| 48 | +}) | ||
| 49 | +</script> | ||
| 50 | + | ||
| 51 | +<style lang="less"> | ||
| 52 | +.auth-page { | ||
| 53 | + min-height: 100vh; | ||
| 54 | + display: flex; | ||
| 55 | + align-items: center; | ||
| 56 | + justify-content: center; | ||
| 57 | + .loading { | ||
| 58 | + text-align: center; | ||
| 59 | + color: #999; | ||
| 60 | + } | ||
| 61 | +} | ||
| 62 | +</style> |
src/pages/index/index.config.js
0 → 100644
src/pages/index/index.less
0 → 100644
src/pages/index/index.vue
0 → 100644
| 1 | +<!-- | ||
| 2 | + * @Date: 2023-06-21 10:23:09 | ||
| 3 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | + * @LastEditTime: 2026-01-16 09:24:14 | ||
| 5 | + * @FilePath: /xyxBooking-weapp/src/pages/index/index.vue | ||
| 6 | + * @Description: 预约页首页 | ||
| 7 | +--> | ||
| 8 | +<template> | ||
| 9 | + <view class="index-page" :class="{ 'is-offline': is_offline }" :style="page_style"> | ||
| 10 | + <view v-if="is_offline" class="offline-banner mx-6 mt-4 rounded-xl px-4 py-3"> | ||
| 11 | + <view class="flex items-center"> | ||
| 12 | + <IconFont name="tips" size="14" /> | ||
| 13 | + <text class="ml-2 font-medium">离线提示</text> | ||
| 14 | + </view> | ||
| 15 | + <view class="mt-1 text-sm opacity-80">{{ weak_network_banner_desc }}</view> | ||
| 16 | + </view> | ||
| 17 | + <view class="index-content"> | ||
| 18 | + <view style="height: 28vh;"> | ||
| 19 | + <swiper class="my-swipe" :autoplay="true" :interval="3000" indicator-dots indicator-color="white" :circular="true"> | ||
| 20 | + <swiper-item> | ||
| 21 | + <image style="height: 28vh; width: 100vw;" :src="banner_url" /> | ||
| 22 | + </swiper-item> | ||
| 23 | + </swiper> | ||
| 24 | + </view> | ||
| 25 | + | ||
| 26 | + <view ref="root" class="index-circular"> | ||
| 27 | + <view class="booking-wrapper"> | ||
| 28 | + <view class="booking" @tap="toBooking"> | ||
| 29 | + <view><image :src="icon_1" style="width: 96rpx; height: 96rpx;" /></view> | ||
| 30 | + <view style="color: #FFF;">开始预约</view> | ||
| 31 | + </view> | ||
| 32 | + </view> | ||
| 33 | + </view> | ||
| 34 | + <view class="logo" :style="logo_style"></view> | ||
| 35 | + </view> | ||
| 36 | + <indexNav | ||
| 37 | + :icons="nav_icons" | ||
| 38 | + active="home" | ||
| 39 | + position="absolute" | ||
| 40 | + center_variant="raised" | ||
| 41 | + @select="on_nav_select" | ||
| 42 | + /> | ||
| 43 | + </view> | ||
| 44 | +</template> | ||
| 45 | + | ||
| 46 | +<script setup> | ||
| 47 | +import Taro, { getCurrentInstance, useDidShow, useShareAppMessage } from '@tarojs/taro' | ||
| 48 | +import { IconFont } from '@nutui/icons-vue-taro' | ||
| 49 | +import { ref, onMounted, onUnmounted, computed } from 'vue' | ||
| 50 | +import { useGo } from '@/hooks/useGo' | ||
| 51 | +import { get_network_type, is_usable_network } from '@/utils/network' | ||
| 52 | +import { weak_network_text } from '@/utils/uiText' | ||
| 53 | +import indexNav from '@/components/indexNav.vue' | ||
| 54 | + | ||
| 55 | +import icon_1 from '@/assets/images/立即预约@2x.png' | ||
| 56 | +import icon_3 from '@/assets/images/首页02@2x.png' | ||
| 57 | +import icon_4 from '@/assets/images/二维码icon.png' | ||
| 58 | +import icon_5 from '@/assets/images/我的01@2x.png' | ||
| 59 | + | ||
| 60 | +const go = useGo(); | ||
| 61 | +const is_offline = ref(false) | ||
| 62 | +const weak_network_banner_desc = weak_network_text.banner_desc | ||
| 63 | +// 背景图版本号, 用于刷新背景图 | ||
| 64 | +const initial_t = Number(getCurrentInstance()?.router?.params?._t) | ||
| 65 | +const bg_version = ref(Number.isFinite(initial_t) ? initial_t : 0) | ||
| 66 | +let is_reloading = false | ||
| 67 | + | ||
| 68 | +const reload_page = () => { | ||
| 69 | + if (is_reloading) return | ||
| 70 | + is_reloading = true | ||
| 71 | + Taro.reLaunch({ | ||
| 72 | + url: `/pages/index/index?_t=${Date.now()}`, | ||
| 73 | + }) | ||
| 74 | +} | ||
| 75 | + | ||
| 76 | +const banner_url = computed(() => { | ||
| 77 | + return `https://cdn.ipadbiz.cn/xys/booking/banner01.png?imageMogr2/thumbnail/500x/strip/quality/100&v=${bg_version.value}` | ||
| 78 | +}) | ||
| 79 | +const normal_bg_url = computed(() => { | ||
| 80 | + return `https://cdn.ipadbiz.cn/xys/booking/bg.jpg?imageMogr2/thumbnail/200x/strip/quality/50&v=${bg_version.value}` | ||
| 81 | +}) | ||
| 82 | +const logo_url = computed(() => { | ||
| 83 | + return `https://cdn.ipadbiz.cn/xys/booking/logo.png?imageMogr2/thumbnail/50x/strip/quality/50&v=${bg_version.value}` | ||
| 84 | +}) | ||
| 85 | + | ||
| 86 | +/** | ||
| 87 | + * 页面样式 | ||
| 88 | + * - 离线状态: 背景颜色 + 背景图片 (包含渐变) | ||
| 89 | + * - 在线状态: 背景颜色 + 背景图片 | ||
| 90 | + */ | ||
| 91 | + | ||
| 92 | +const page_style = computed(() => { | ||
| 93 | + if (is_offline.value) { | ||
| 94 | + return { | ||
| 95 | + backgroundColor: '#F3EEE3', | ||
| 96 | + backgroundImage: `linear-gradient(180deg, rgba(166, 121, 57, 0.10) 0%, rgba(255, 255, 255, 0.90) 60%, rgba(243, 238, 227, 1) 100%), url('${normal_bg_url.value}')`, | ||
| 97 | + } | ||
| 98 | + } | ||
| 99 | + return { | ||
| 100 | + backgroundColor: '#F3EEE3', | ||
| 101 | + backgroundImage: `url('${normal_bg_url.value}')`, | ||
| 102 | + } | ||
| 103 | +}) | ||
| 104 | + | ||
| 105 | +const logo_style = computed(() => { | ||
| 106 | + return { | ||
| 107 | + backgroundImage: `url('${logo_url.value}')`, | ||
| 108 | + } | ||
| 109 | +}) | ||
| 110 | + | ||
| 111 | +/** | ||
| 112 | + * 应用离线状态 | ||
| 113 | + * - 检查当前状态是否需要刷新 | ||
| 114 | + * - 更新 is_offline 状态 | ||
| 115 | + */ | ||
| 116 | + | ||
| 117 | +const apply_offline_state = (next_offline) => { | ||
| 118 | + if (is_offline.value === true && next_offline === false) { | ||
| 119 | + reload_page() | ||
| 120 | + return true | ||
| 121 | + } | ||
| 122 | + is_offline.value = next_offline | ||
| 123 | + return false | ||
| 124 | +} | ||
| 125 | + | ||
| 126 | +/** | ||
| 127 | + * 刷新离线状态 | ||
| 128 | + * - 检查当前网络类型是否可用 | ||
| 129 | + */ | ||
| 130 | +const refresh_offline_state = async () => { | ||
| 131 | + try { | ||
| 132 | + const network_type = await get_network_type() | ||
| 133 | + const next_offline = !is_usable_network(network_type) | ||
| 134 | + apply_offline_state(next_offline) | ||
| 135 | + } catch (e) { | ||
| 136 | + console.error('refresh_offline_state failed:', e) | ||
| 137 | + } | ||
| 138 | +} | ||
| 139 | + | ||
| 140 | +let has_network_listener = false | ||
| 141 | +let network_listener = null | ||
| 142 | + | ||
| 143 | +/** | ||
| 144 | + * 设置网络状态变更监听器 | ||
| 145 | + * - 监听网络状态变化 | ||
| 146 | + * - 更新 is_offline 状态 | ||
| 147 | + */ | ||
| 148 | + | ||
| 149 | +const setup_network_listener = () => { | ||
| 150 | + if (has_network_listener) return | ||
| 151 | + has_network_listener = true | ||
| 152 | + network_listener = (res) => { | ||
| 153 | + try { | ||
| 154 | + const is_connected = res?.isConnected !== false | ||
| 155 | + const network_type = res?.networkType | ||
| 156 | + if (typeof network_type === 'string' && network_type) { | ||
| 157 | + // 有网, 检查是否可用 | ||
| 158 | + const next_offline = !(is_connected && is_usable_network(network_type)) | ||
| 159 | + // 检查是否需要刷新 | ||
| 160 | + const is_handled = apply_offline_state(next_offline) | ||
| 161 | + if (is_handled) return | ||
| 162 | + } | ||
| 163 | + // 还没有网, 再次刷新 | ||
| 164 | + refresh_offline_state() | ||
| 165 | + } catch (e) { | ||
| 166 | + console.error('network_listener failed:', e) | ||
| 167 | + } | ||
| 168 | + } | ||
| 169 | + Taro.onNetworkStatusChange(network_listener) | ||
| 170 | +} | ||
| 171 | + | ||
| 172 | +/** | ||
| 173 | + * 移除网络状态变更监听器 | ||
| 174 | + * - 移除网络状态变化监听 | ||
| 175 | + */ | ||
| 176 | + | ||
| 177 | +const teardown_network_listener = () => { | ||
| 178 | + if (!has_network_listener) return | ||
| 179 | + has_network_listener = false | ||
| 180 | + if (network_listener && typeof Taro.offNetworkStatusChange === 'function') { | ||
| 181 | + try { | ||
| 182 | + Taro.offNetworkStatusChange(network_listener) | ||
| 183 | + } catch (e) { | ||
| 184 | + console.error('offNetworkStatusChange failed:', e) | ||
| 185 | + } | ||
| 186 | + } | ||
| 187 | + network_listener = null | ||
| 188 | +} | ||
| 189 | + | ||
| 190 | +useDidShow(() => { | ||
| 191 | + refresh_offline_state() | ||
| 192 | +}) | ||
| 193 | + | ||
| 194 | +onMounted(() => { | ||
| 195 | + setup_network_listener() | ||
| 196 | +}) | ||
| 197 | + | ||
| 198 | +onUnmounted(() => { | ||
| 199 | + teardown_network_listener() | ||
| 200 | +}) | ||
| 201 | + | ||
| 202 | +const toBooking = () => { // 跳转到预约须知 | ||
| 203 | + // 如果是离线模式,不跳转 | ||
| 204 | + if (is_offline.value) { | ||
| 205 | + Taro.showToast({ | ||
| 206 | + title: weak_network_text.offline_mode_no_booking_toast, | ||
| 207 | + icon: 'none' | ||
| 208 | + }) | ||
| 209 | + return | ||
| 210 | + } | ||
| 211 | + go('/notice'); | ||
| 212 | +} | ||
| 213 | + | ||
| 214 | +const toCode = () => { // 跳转到预约码 | ||
| 215 | + Taro.redirectTo({ | ||
| 216 | + url: '/pages/bookingCode/index' | ||
| 217 | + }) | ||
| 218 | +} | ||
| 219 | + | ||
| 220 | +const toMy = () => { // 跳转到我的 | ||
| 221 | + Taro.redirectTo({ | ||
| 222 | + url: '/pages/me/index' | ||
| 223 | + }) | ||
| 224 | +} | ||
| 225 | + | ||
| 226 | +const nav_icons = { home: icon_3, code: icon_4, me: icon_5 } | ||
| 227 | + | ||
| 228 | +const on_nav_select = (key) => { | ||
| 229 | + if (key === 'code') return toCode() | ||
| 230 | + if (key === 'me') return toMy() | ||
| 231 | +} | ||
| 232 | + | ||
| 233 | +useShareAppMessage(() => { | ||
| 234 | + return { | ||
| 235 | + title: '西园寺预约', | ||
| 236 | + path: '/pages/index/index' | ||
| 237 | + } | ||
| 238 | +}) | ||
| 239 | + | ||
| 240 | +</script> | ||
| 241 | + | ||
| 242 | +<style lang="less"> | ||
| 243 | +.index-page { | ||
| 244 | + position: relative; | ||
| 245 | + min-height: 100vh; | ||
| 246 | + background-repeat: no-repeat; | ||
| 247 | + background-position: center; | ||
| 248 | + background-size: cover; /* 确保背景覆盖 */ | ||
| 249 | + | ||
| 250 | + &.is-offline { | ||
| 251 | + background-color: #F3EEE3; | ||
| 252 | + } | ||
| 253 | + | ||
| 254 | + .offline-banner { | ||
| 255 | + position: absolute; | ||
| 256 | + top: 24rpx; | ||
| 257 | + left: 24rpx; | ||
| 258 | + right: 24rpx; | ||
| 259 | + z-index: 10; | ||
| 260 | + background: rgba(255, 255, 255, 0.88); | ||
| 261 | + color: #A67939; | ||
| 262 | + border: 2rpx solid rgba(166, 121, 57, 0.25); | ||
| 263 | + box-shadow: 0 12rpx 30rpx rgba(166, 121, 57, 0.12); | ||
| 264 | + backdrop-filter: blur(6px); | ||
| 265 | + } | ||
| 266 | + .index-content { | ||
| 267 | + height: calc(100vh - 134rpx - constant(safe-area-inset-bottom)); | ||
| 268 | + height: calc(100vh - 134rpx - env(safe-area-inset-bottom)); | ||
| 269 | + .index-control { | ||
| 270 | + position: relative; | ||
| 271 | + display: flex; | ||
| 272 | + flex-direction: column; | ||
| 273 | + align-items: center; | ||
| 274 | + justify-content: center; | ||
| 275 | + margin-top: 130rpx; | ||
| 276 | + // font-weight: bold; | ||
| 277 | + font-size: 37rpx; | ||
| 278 | + .booking { | ||
| 279 | + display: flex; | ||
| 280 | + justify-content: center; | ||
| 281 | + align-items: center; | ||
| 282 | + background-color: #A67939; | ||
| 283 | + border-radius: 14rpx; | ||
| 284 | + color: #FFFFFF; | ||
| 285 | + padding: 22rpx 128rpx; | ||
| 286 | + border: 2rpx solid #A67939; | ||
| 287 | + } | ||
| 288 | + .record { | ||
| 289 | + display: flex; | ||
| 290 | + justify-content: center; | ||
| 291 | + align-items: center; | ||
| 292 | + color: #A67939; | ||
| 293 | + border-radius: 14rpx; | ||
| 294 | + padding: 22rpx 128rpx; | ||
| 295 | + border: 2rpx solid #A67939; | ||
| 296 | + margin-top: 48rpx; | ||
| 297 | + } | ||
| 298 | + .search { | ||
| 299 | + display: flex; | ||
| 300 | + justify-content: center; | ||
| 301 | + align-items: center; | ||
| 302 | + color: #A67939; | ||
| 303 | + border-radius: 14rpx; | ||
| 304 | + padding: 22rpx 128rpx; | ||
| 305 | + border: 2rpx solid #A67939; | ||
| 306 | + margin-top: 48rpx; | ||
| 307 | + } | ||
| 308 | + } | ||
| 309 | + .index-circular { | ||
| 310 | + position: relative; | ||
| 311 | + display: flex; | ||
| 312 | + align-items: center; | ||
| 313 | + justify-content: center; | ||
| 314 | + margin-top: 130rpx; | ||
| 315 | + // font-weight: bold; | ||
| 316 | + font-size: 35rpx; | ||
| 317 | + | ||
| 318 | + .booking-wrapper { | ||
| 319 | + height: 260rpx; | ||
| 320 | + width: 260rpx; | ||
| 321 | + border-radius: 50%; | ||
| 322 | + background-color: rgba(166, 121, 57, 0.26); | ||
| 323 | + display: flex; | ||
| 324 | + align-items: center; | ||
| 325 | + justify-content: center; | ||
| 326 | + .booking { | ||
| 327 | + height: 230rpx; | ||
| 328 | + width: 230rpx; | ||
| 329 | + border-radius: 50%; | ||
| 330 | + background-color: #A67939; | ||
| 331 | + display: flex; | ||
| 332 | + align-items: center; | ||
| 333 | + justify-content: center; | ||
| 334 | + flex-direction: column; | ||
| 335 | + } | ||
| 336 | + } | ||
| 337 | + } | ||
| 338 | + .logo { | ||
| 339 | + position: absolute; | ||
| 340 | + right: 0; | ||
| 341 | + bottom: 200rpx; | ||
| 342 | + height: 400rpx; | ||
| 343 | + width: 150rpx; | ||
| 344 | + background-repeat: no-repeat; | ||
| 345 | + background-size: contain; | ||
| 346 | + background-position: center; | ||
| 347 | + } | ||
| 348 | + } | ||
| 349 | + .my-swipe { | ||
| 350 | + height: 400rpx; | ||
| 351 | + swiper-item { /* Taro swiper-item 编译后 */ | ||
| 352 | + height: 400rpx; | ||
| 353 | + width: 750rpx; | ||
| 354 | + background-size: cover; | ||
| 355 | + background-repeat: no-repeat; | ||
| 356 | + background-position: center; | ||
| 357 | + } | ||
| 358 | + } | ||
| 359 | +} | ||
| 360 | +</style> |
src/stores/counter.js
0 → 100755
| 1 | +// Pinia 入门文档:https://pinia.vuejs.org/introduction.html | ||
| 2 | +import { defineStore } from 'pinia' | ||
| 3 | + | ||
| 4 | +/** | ||
| 5 | + * @description 计数器示例 Store(模板保留) | ||
| 6 | + */ | ||
| 7 | +export const useCounterStore = defineStore('counter', { | ||
| 8 | + state: () => { | ||
| 9 | + return { count: 0 } | ||
| 10 | + }, | ||
| 11 | + // 也可以写成:state: () => ({ count: 0 }) | ||
| 12 | + actions: { | ||
| 13 | + /** | ||
| 14 | + * @description 计数 +1 | ||
| 15 | + * @returns {void} 无返回值 | ||
| 16 | + */ | ||
| 17 | + increment() { | ||
| 18 | + this.count++ | ||
| 19 | + }, | ||
| 20 | + }, | ||
| 21 | +}) | ||
| 22 | + | ||
| 23 | +// 也可以用函数式(类似组件 setup)定义 Store,适合更复杂场景: | ||
| 24 | +// export const useCounterStore = defineStore('counter', () => { | ||
| 25 | +// const count = ref(0) | ||
| 26 | +// | ||
| 27 | +// function increment() { | ||
| 28 | +// count.value++ | ||
| 29 | +// } | ||
| 30 | +// | ||
| 31 | +// return {count, increment} | ||
| 32 | +// }) |
src/stores/host.js
0 → 100755
| 1 | +/* | ||
| 2 | + * @Date: 2022-10-28 14:34:22 | ||
| 3 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | + * @LastEditTime: 2022-10-28 15:12:55 | ||
| 5 | + * @FilePath: /swx/src/stores/host.js | ||
| 6 | + * @Description: 缓存主办方ID | ||
| 7 | + */ | ||
| 8 | +import { defineStore } from 'pinia' | ||
| 9 | + | ||
| 10 | +/** | ||
| 11 | + * @description 主办方相关缓存 | ||
| 12 | + * - 用于保存主办方 id / join_id 等页面间共享参数 | ||
| 13 | + */ | ||
| 14 | +export const hostStore = defineStore('host', { | ||
| 15 | + state: () => { | ||
| 16 | + return { | ||
| 17 | + id: '', | ||
| 18 | + join_id: '' | ||
| 19 | + } | ||
| 20 | + }, | ||
| 21 | + actions: { | ||
| 22 | + /** | ||
| 23 | + * @description 设置主办方 id | ||
| 24 | + * @param {string} id 主办方 id | ||
| 25 | + * @returns {void} 无返回值 | ||
| 26 | + */ | ||
| 27 | + add (id) { | ||
| 28 | + this.id = id | ||
| 29 | + }, | ||
| 30 | + /** | ||
| 31 | + * @description 设置 join_id | ||
| 32 | + * @param {string} id join_id | ||
| 33 | + * @returns {void} 无返回值 | ||
| 34 | + */ | ||
| 35 | + addJoin (id) { | ||
| 36 | + this.join_id = id | ||
| 37 | + }, | ||
| 38 | + }, | ||
| 39 | +}) |
src/stores/main.js
0 → 100644
| 1 | +/* | ||
| 2 | + * @Date: 2022-04-18 15:59:42 | ||
| 3 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | + * @LastEditTime: 2026-01-14 20:43:54 | ||
| 5 | + * @FilePath: /xyxBooking-weapp/src/stores/main.js | ||
| 6 | + * @Description: 文件描述 | ||
| 7 | + */ | ||
| 8 | +import { defineStore } from 'pinia'; | ||
| 9 | + | ||
| 10 | +/** | ||
| 11 | + * @description 全局主状态 | ||
| 12 | + * - 存储用户信息与授权态等全局数据 | ||
| 13 | + */ | ||
| 14 | +export const mainStore = defineStore('main', { | ||
| 15 | + state: () => { | ||
| 16 | + return { | ||
| 17 | + msg: 'Hello world', | ||
| 18 | + count: 0, | ||
| 19 | + auth: false, | ||
| 20 | + // keepPages: ['default'], // 小程序不支持这种 keep-alive 机制 | ||
| 21 | + appUserInfo: null, // 用户信息 | ||
| 22 | + }; | ||
| 23 | + }, | ||
| 24 | + getters: { | ||
| 25 | + /** | ||
| 26 | + * @description 是否具备义工核销权限 | ||
| 27 | + * @returns {boolean} true=义工,false=非义工 | ||
| 28 | + */ | ||
| 29 | + isVolunteer: (state) => { | ||
| 30 | + return !!(state.appUserInfo && (state.appUserInfo.can_redeem === true)); | ||
| 31 | + }, | ||
| 32 | + }, | ||
| 33 | + actions: { | ||
| 34 | + /** | ||
| 35 | + * @description 更新授权状态 | ||
| 36 | + * @param {boolean} state 是否已授权 | ||
| 37 | + * @returns {void} 无返回值 | ||
| 38 | + */ | ||
| 39 | + changeState (state) { | ||
| 40 | + this.auth = state; | ||
| 41 | + }, | ||
| 42 | + // setVolunteerStatus(status) { | ||
| 43 | + // this.isVolunteer = status; | ||
| 44 | + // }, | ||
| 45 | + // changeKeepPages () { | ||
| 46 | + // this.keepPages = ['default']; | ||
| 47 | + // }, | ||
| 48 | + // keepThisPage () { | ||
| 49 | + // // 小程序路由缓存由框架控制 | ||
| 50 | + // }, | ||
| 51 | + // removeThisPage () { | ||
| 52 | + // }, | ||
| 53 | + /** | ||
| 54 | + * @description 更新全局用户信息 | ||
| 55 | + * @param {Object|null} info 用户信息对象 | ||
| 56 | + * @returns {void} 无返回值 | ||
| 57 | + */ | ||
| 58 | + changeUserInfo (info) { | ||
| 59 | + this.appUserInfo = info; | ||
| 60 | + } | ||
| 61 | + }, | ||
| 62 | +}); |
src/stores/router.js
0 → 100755
| 1 | +/* | ||
| 2 | + * @Date: 2022-10-28 14:34:22 | ||
| 3 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | + * @LastEditTime: 2022-11-01 13:27:09 | ||
| 5 | + * @FilePath: /swx/src/stores/router.js | ||
| 6 | + * @Description: 缓存路由信息 | ||
| 7 | + */ | ||
| 8 | +import { defineStore } from 'pinia' | ||
| 9 | + | ||
| 10 | +/** | ||
| 11 | + * @description 路由回跳信息(用于授权完成后的返回) | ||
| 12 | + * - authRedirect.saveCurrentPagePath 会写入 url | ||
| 13 | + * - authRedirect.returnToOriginalPage 会消费并清空 url | ||
| 14 | + */ | ||
| 15 | +export const routerStore = defineStore('router', { | ||
| 16 | + state: () => { | ||
| 17 | + return { | ||
| 18 | + url: '', | ||
| 19 | + } | ||
| 20 | + }, | ||
| 21 | + actions: { | ||
| 22 | + /** | ||
| 23 | + * @description 记录回跳路径 | ||
| 24 | + * @param {string} path 页面路径(可带 query) | ||
| 25 | + * @returns {void} 无返回值 | ||
| 26 | + */ | ||
| 27 | + add (path) { | ||
| 28 | + this.url = path | ||
| 29 | + }, | ||
| 30 | + /** | ||
| 31 | + * @description 清空回跳路径 | ||
| 32 | + * @returns {void} 无返回值 | ||
| 33 | + */ | ||
| 34 | + remove () { | ||
| 35 | + this.url = '' | ||
| 36 | + }, | ||
| 37 | + }, | ||
| 38 | +}) |
src/utils/authRedirect.js
0 → 100644
| 1 | +import Taro from '@tarojs/taro' | ||
| 2 | +import { routerStore } from '@/stores/router' | ||
| 3 | +import { buildApiUrl } from './tools' | ||
| 4 | + | ||
| 5 | +// 改进:添加全局状态变量注释 | ||
| 6 | +/** | ||
| 7 | + * 上一次跳转到授权页的时间戳,用于防抖(避免短时间内重复跳转) | ||
| 8 | + * @type {number} | ||
| 9 | + */ | ||
| 10 | +let last_navigate_auth_at = 0 | ||
| 11 | + | ||
| 12 | +/** | ||
| 13 | + * 是否正在跳转到授权页,用于防重复(避免并发跳转) | ||
| 14 | + * @type {boolean} | ||
| 15 | + */ | ||
| 16 | +let navigating_to_auth = false | ||
| 17 | + | ||
| 18 | +/** | ||
| 19 | + * 授权与回跳相关工具 | ||
| 20 | + * - 统一管理:保存来源页、静默授权、跳转授权页、授权后回跳 | ||
| 21 | + * - 约定:sessionid 存在于本地缓存 key 为 sessionid | ||
| 22 | + * - 说明:refreshSession/silentAuth 使用单例 Promise,避免并发重复授权 | ||
| 23 | + */ | ||
| 24 | + | ||
| 25 | +/** | ||
| 26 | + * 获取当前页完整路径(含 query) | ||
| 27 | + * @returns {string} 当前页路径,示例:pages/index/index?a=1;获取失败返回空字符串 | ||
| 28 | + */ | ||
| 29 | +export const getCurrentPageFullPath = () => { | ||
| 30 | + const pages = Taro.getCurrentPages() | ||
| 31 | + if (!pages || pages.length === 0) return '' | ||
| 32 | + | ||
| 33 | + const current_page = pages[pages.length - 1] | ||
| 34 | + const route = current_page.route | ||
| 35 | + const options = current_page.options || {} | ||
| 36 | + | ||
| 37 | + // 改进:key 也需要编码,避免特殊字符导致 URL 解析错误 | ||
| 38 | + const query_params = Object.keys(options) | ||
| 39 | + .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(options[key])}`) | ||
| 40 | + .join('&') | ||
| 41 | + | ||
| 42 | + return query_params ? `${route}?${query_params}` : route | ||
| 43 | +} | ||
| 44 | + | ||
| 45 | +/** | ||
| 46 | + * 保存当前页路径(用于授权成功后回跳) | ||
| 47 | + * @param {string} custom_path 自定义路径,不传则取当前页完整路径 | ||
| 48 | + * @returns {void} 无返回值 | ||
| 49 | + */ | ||
| 50 | +export const saveCurrentPagePath = (custom_path) => { | ||
| 51 | + const router = routerStore() | ||
| 52 | + const path = custom_path || getCurrentPageFullPath() | ||
| 53 | + router.add(path) | ||
| 54 | +} | ||
| 55 | + | ||
| 56 | +/** | ||
| 57 | + * 判断是否已授权 | ||
| 58 | + * @returns {boolean} true=已存在 sessionid,false=需要授权 | ||
| 59 | + */ | ||
| 60 | +export const hasAuth = () => { | ||
| 61 | + try { | ||
| 62 | + const sessionid = Taro.getStorageSync('sessionid') | ||
| 63 | + return !!sessionid && sessionid !== '' | ||
| 64 | + } catch (error) { | ||
| 65 | + console.error('检查授权状态失败:', error) | ||
| 66 | + return false | ||
| 67 | + } | ||
| 68 | +} | ||
| 69 | + | ||
| 70 | +let auth_promise = null | ||
| 71 | + | ||
| 72 | +/** | ||
| 73 | + * 从响应中提取 cookie | ||
| 74 | + * 兼容小程序端和 H5 端的不同返回格式 | ||
| 75 | + * @param {object} response Taro.request 响应对象 | ||
| 76 | + * @returns {string|null} cookie 字符串或 null | ||
| 77 | + */ | ||
| 78 | +const extractCookie = (response) => { | ||
| 79 | + // 小程序端优先从 response.cookies 取 | ||
| 80 | + if (response.cookies?.[0]) return response.cookies[0] | ||
| 81 | + // H5 端从 header 取(兼容不同大小写) | ||
| 82 | + const cookie = response.header?.['Set-Cookie'] || response.header?.['set-cookie'] | ||
| 83 | + if (Array.isArray(cookie)) return cookie[0] | ||
| 84 | + return cookie || null | ||
| 85 | +} | ||
| 86 | + | ||
| 87 | +/** | ||
| 88 | + * 刷新会话:通过 Taro.login 获取 code,换取后端会话 cookie 并写入缓存 | ||
| 89 | + * - 被 request.js 的 401 拦截器调用,用于自动“静默续期 + 原请求重放” | ||
| 90 | + * - 复用 auth_promise,防止多个接口同时 401 时并发触发多次登录 | ||
| 91 | + * @param {object} options 可选项 | ||
| 92 | + * @param {boolean} options.show_loading 是否展示 loading,默认 true | ||
| 93 | + * @returns {Promise<{code:number,msg?:string,data?:any,cookie?:string}>} 授权结果(会把 cookie 写入 storage 的 sessionid) | ||
| 94 | + */ | ||
| 95 | +export const refreshSession = async (options) => { | ||
| 96 | + const show_loading = options?.show_loading !== false | ||
| 97 | + | ||
| 98 | + // 已有授权进行中时,直接复用同一个 Promise | ||
| 99 | + if (auth_promise) return auth_promise | ||
| 100 | + | ||
| 101 | + auth_promise = (async () => { | ||
| 102 | + try { | ||
| 103 | + if (show_loading) { | ||
| 104 | + Taro.showLoading({ | ||
| 105 | + title: '加载中...', | ||
| 106 | + mask: true, | ||
| 107 | + }) | ||
| 108 | + } | ||
| 109 | + | ||
| 110 | + // 调用微信登录获取临时 code | ||
| 111 | + const login_result = await new Promise((resolve, reject) => { | ||
| 112 | + Taro.login({ | ||
| 113 | + success: resolve, | ||
| 114 | + fail: reject, | ||
| 115 | + }) | ||
| 116 | + }) | ||
| 117 | + | ||
| 118 | + if (!login_result || !login_result.code) { | ||
| 119 | + throw new Error('获取微信登录code失败') | ||
| 120 | + } | ||
| 121 | + | ||
| 122 | + const request_data = { | ||
| 123 | + code: login_result.code, | ||
| 124 | + } | ||
| 125 | + | ||
| 126 | + // 换取后端会话(服务端通过 Set-Cookie 返回会话信息) | ||
| 127 | + const response = await Taro.request({ | ||
| 128 | + url: buildApiUrl('openid_wxapp'), | ||
| 129 | + method: 'POST', | ||
| 130 | + data: request_data, | ||
| 131 | + }) | ||
| 132 | + | ||
| 133 | + if (!response?.data || response.data.code !== 1) { | ||
| 134 | + throw new Error(response?.data?.msg || '授权失败') | ||
| 135 | + } | ||
| 136 | + | ||
| 137 | + // 改进:使用 extractCookie 函数统一处理 cookie 提取逻辑 | ||
| 138 | + const cookie = extractCookie(response) | ||
| 139 | + if (!cookie) { | ||
| 140 | + throw new Error('授权失败:没有获取到有效的会话信息') | ||
| 141 | + } | ||
| 142 | + | ||
| 143 | + // NOTE: 写入本地缓存:后续请求会从缓存取 sessionid 并带到请求头 | ||
| 144 | + Taro.setStorageSync('sessionid', cookie) | ||
| 145 | + | ||
| 146 | + /** | ||
| 147 | + * refreshSession() 的返回值当前没有任何业务消费点:在 request.js 里只是 await refreshSession() ,不解构、不使用;其他地方也没直接调用它 | ||
| 148 | + * 所以 return { ...response.data, cookie } 目前属于“严谨保留”:方便未来需要拿 cookie / code / msg 做埋点、提示、分支处理时直接用(例如授权页显示更细错误、统计刷新成功率等)。 | ||
| 149 | + */ | ||
| 150 | + | ||
| 151 | + return { | ||
| 152 | + ...response.data, | ||
| 153 | + cookie, | ||
| 154 | + } | ||
| 155 | + } finally { | ||
| 156 | + if (show_loading) { | ||
| 157 | + Taro.hideLoading() | ||
| 158 | + } | ||
| 159 | + } | ||
| 160 | + })().finally(() => { | ||
| 161 | + auth_promise = null | ||
| 162 | + }) | ||
| 163 | + | ||
| 164 | + return auth_promise | ||
| 165 | +} | ||
| 166 | + | ||
| 167 | +/** | ||
| 168 | + * 执行静默授权:检查是否已授权,若否则调用 refreshSession 刷新会话 | ||
| 169 | + * @param {boolean} show_loading 是否展示 loading,默认 true | ||
| 170 | + * @returns {Promise<{code:number,msg?:string,data?:any,cookie?:string}>} 授权结果 | ||
| 171 | + * | ||
| 172 | + * 改进:使用下划线前缀表示私有函数,仅供 silentAuth 内部使用 | ||
| 173 | + */ | ||
| 174 | +const _do_silent_auth = async (show_loading) => { | ||
| 175 | + // 已有 sessionid 时直接视为已授权 | ||
| 176 | + if (hasAuth()) { | ||
| 177 | + return { code: 1, msg: '已授权' } | ||
| 178 | + } | ||
| 179 | + | ||
| 180 | + // 需要授权时,走刷新会话逻辑 | ||
| 181 | + return await refreshSession({ show_loading }) | ||
| 182 | +} | ||
| 183 | + | ||
| 184 | +/** | ||
| 185 | + * 静默授权:用于启动阶段/分享页/授权页发起授权 | ||
| 186 | + * - 与 refreshSession 共用 auth_promise,避免并发重复调用 | ||
| 187 | + * @param {(result: any) => void} on_success 成功回调(可选) | ||
| 188 | + * @param {(error: {message:string, original:Error}) => void} on_error 失败回调(可选,入参为错误对象) | ||
| 189 | + * @param {object} options 可选项 | ||
| 190 | + * @param {boolean} options.show_loading 是否展示 loading,默认 true | ||
| 191 | + * @returns {Promise<any>} 授权结果(成功 resolve,失败 reject) | ||
| 192 | + */ | ||
| 193 | +export const silentAuth = async (on_success, on_error, options) => { | ||
| 194 | + const show_loading = options?.show_loading !== false | ||
| 195 | + | ||
| 196 | + try { | ||
| 197 | + // 未有授权进行中时才发起一次授权,并复用 Promise | ||
| 198 | + if (!auth_promise) { | ||
| 199 | + /** | ||
| 200 | + * 用 auth_promise 做"单例锁",把同一时刻并发触发的多次授权合并成一次。 | ||
| 201 | + * 把正在执行的授权 Promise 存起来;后面如果又有人调用 silentAuth() , | ||
| 202 | + * 看到 auth_promise 不为空,就直接 await 同一个 Promise,避免同时发起多次 Taro.login / 换会话请求 | ||
| 203 | + * --------------------------------------------------------------------------------------- | ||
| 204 | + * .finally(() => { auth_promise = null }) 不管授权成功还是失败(resolve/reject),都把"锁"释放掉。 | ||
| 205 | + * 不用 finally 的问题:如果授权失败抛错了,而你只在 .then 里清空,那么 auth_promise 会一直卡着旧的 rejected Promise; | ||
| 206 | + * 后续再调用 silentAuth() 会复用这个失败的 Promise,导致永远失败、且永远不会重新发起授权。 | ||
| 207 | + * 用 finally :保证成功/失败都会清空,下一次调用才有机会重新走授权流程。 | ||
| 208 | + */ | ||
| 209 | + auth_promise = _do_silent_auth(show_loading) | ||
| 210 | + .finally(() => { | ||
| 211 | + auth_promise = null | ||
| 212 | + }) | ||
| 213 | + } | ||
| 214 | + const result = await auth_promise | ||
| 215 | + if (on_success) on_success(result) | ||
| 216 | + | ||
| 217 | + /** | ||
| 218 | + * 当前返回值 没有实际消费点 :全项目只在 3 处调用,全部都 不使用返回值 。 | ||
| 219 | + * - 启动预加载: await silentAuth() 仅等待,不用结果, app.js | ||
| 220 | + * - 授权页: silentAuth().then(() => returnToOriginalPage()) then 里也没接 res , auth/index.vue | ||
| 221 | + * - 分享场景: await silentAuth(successCb, errorCb) 只看成功/失败分支,不用返回值, handleSharePageAuth | ||
| 222 | + * 所以这行 return result 的作用目前是 语义完整 + 未来扩展位 : | ||
| 223 | + * 如果以后要在调用处根据 code/msg/cookie 做分支或埋点,返回值就能直接用;现在等价于"只用 resolve/reject 表达成功失败"。 | ||
| 224 | + */ | ||
| 225 | + | ||
| 226 | + return result | ||
| 227 | + } catch (error) { | ||
| 228 | + // 改进:统一传递完整错误对象,包含 message 和 original error | ||
| 229 | + const error_obj = { | ||
| 230 | + message: error?.message || '授权失败,请稍后重试', | ||
| 231 | + original: error | ||
| 232 | + } | ||
| 233 | + if (on_error) on_error(error_obj) | ||
| 234 | + throw error | ||
| 235 | + } | ||
| 236 | +} | ||
| 237 | + | ||
| 238 | +/** | ||
| 239 | + * 防重复跳转冷却时间 (毫秒) | ||
| 240 | + * @type {number} | ||
| 241 | + */ | ||
| 242 | +const NAVIGATE_AUTH_COOLDOWN_MS = 1200 | ||
| 243 | + | ||
| 244 | +/** | ||
| 245 | + * 导航状态重置延迟时间 (毫秒) | ||
| 246 | + * @type {number} | ||
| 247 | + */ | ||
| 248 | +const NAVIGATING_RESET_DELAY_MS = 300 | ||
| 249 | + | ||
| 250 | +/** | ||
| 251 | + * 跳转到授权页(降级方案) | ||
| 252 | + * - 会先保存回跳路径(默认当前页),授权成功后在 auth 页回跳 | ||
| 253 | + * @param {string} return_path 指定回跳路径(可选) | ||
| 254 | + * @returns {Promise<void>} 无返回值 | ||
| 255 | + */ | ||
| 256 | +export const navigateToAuth = async (return_path) => { | ||
| 257 | + const pages = Taro.getCurrentPages() | ||
| 258 | + const current_page = pages[pages.length - 1] | ||
| 259 | + const current_route = current_page?.route | ||
| 260 | + if (current_route === 'pages/auth/index') { | ||
| 261 | + return | ||
| 262 | + } | ||
| 263 | + | ||
| 264 | + const now = Date.now() | ||
| 265 | + if (navigating_to_auth) return | ||
| 266 | + if (now - last_navigate_auth_at < NAVIGATE_AUTH_COOLDOWN_MS) return | ||
| 267 | + | ||
| 268 | + last_navigate_auth_at = now | ||
| 269 | + navigating_to_auth = true | ||
| 270 | + | ||
| 271 | + if (return_path) { | ||
| 272 | + saveCurrentPagePath(return_path) | ||
| 273 | + } else { | ||
| 274 | + saveCurrentPagePath() | ||
| 275 | + } | ||
| 276 | + | ||
| 277 | + // 改进:使用 try-finally 明确状态恢复逻辑,确保无论成功失败都会重置状态 | ||
| 278 | + try { | ||
| 279 | + await Taro.navigateTo({ url: '/pages/auth/index' }) | ||
| 280 | + } catch (error) { | ||
| 281 | + // 改进:添加错误日志,方便追踪降级场景 | ||
| 282 | + console.warn('navigateTo 失败,降级使用 redirectTo:', error) | ||
| 283 | + await Taro.redirectTo({ url: '/pages/auth/index' }) | ||
| 284 | + } finally { | ||
| 285 | + setTimeout(() => { | ||
| 286 | + navigating_to_auth = false | ||
| 287 | + }, NAVIGATING_RESET_DELAY_MS) | ||
| 288 | + } | ||
| 289 | +} | ||
| 290 | + | ||
| 291 | +/** | ||
| 292 | + * 授权成功后回跳到来源页 | ||
| 293 | + * - 优先使用 routerStore 里保存的路径 | ||
| 294 | + * - 失败降级:redirectTo -> reLaunch | ||
| 295 | + * @param {string} default_path 未保存来源页时的默认回跳路径 | ||
| 296 | + * @returns {Promise<void>} 回跳完成 | ||
| 297 | + */ | ||
| 298 | +export const returnToOriginalPage = async (default_path = '/pages/index/index') => { | ||
| 299 | + const router = routerStore() | ||
| 300 | + const saved_path = router.url | ||
| 301 | + | ||
| 302 | + try { | ||
| 303 | + router.remove() | ||
| 304 | + | ||
| 305 | + const pages = Taro.getCurrentPages() | ||
| 306 | + const current_page = pages[pages.length - 1] | ||
| 307 | + const current_route = current_page?.route | ||
| 308 | + | ||
| 309 | + let target_path = default_path | ||
| 310 | + if (saved_path && saved_path !== '') { | ||
| 311 | + target_path = saved_path.startsWith('/') ? saved_path : `/${saved_path}` | ||
| 312 | + } | ||
| 313 | + | ||
| 314 | + const target_route = target_path.split('?')[0].replace(/^\//, '') | ||
| 315 | + | ||
| 316 | + if (current_route === target_route) { | ||
| 317 | + return | ||
| 318 | + } | ||
| 319 | + | ||
| 320 | + try { | ||
| 321 | + await Taro.redirectTo({ url: target_path }) | ||
| 322 | + } catch (error) { | ||
| 323 | + // 改进:添加错误日志,方便追踪降级场景 | ||
| 324 | + console.warn('redirectTo 失败,降级使用 reLaunch:', error) | ||
| 325 | + await Taro.reLaunch({ url: target_path }) | ||
| 326 | + } | ||
| 327 | + } catch (error) { | ||
| 328 | + console.error('returnToOriginalPage 执行出错:', error) | ||
| 329 | + try { | ||
| 330 | + await Taro.reLaunch({ url: default_path }) | ||
| 331 | + } catch (final_error) { | ||
| 332 | + console.error('最终降级方案也失败了:', final_error) | ||
| 333 | + } | ||
| 334 | + } | ||
| 335 | +} | ||
| 336 | + | ||
| 337 | +/** | ||
| 338 | + * 判断是否来自分享场景 | ||
| 339 | + * @param {object} options 页面 options | ||
| 340 | + * @returns {boolean} true=来自分享场景,false=非分享场景 | ||
| 341 | + */ | ||
| 342 | +export const isFromShare = (options) => { | ||
| 343 | + return options && (options.from_share === '1' || options.scene) | ||
| 344 | +} | ||
| 345 | + | ||
| 346 | +/** | ||
| 347 | + * 分享页进入时的授权处理 | ||
| 348 | + * - 来自分享且未授权:保存当前页路径,授权成功后回跳 | ||
| 349 | + * - 授权失败:返回 false,由调用方决定是否继续降级处理 | ||
| 350 | + * @param {object} options 页面 options | ||
| 351 | + * @param {Function} callback 授权成功后的继续逻辑(可选) | ||
| 352 | + * @returns {Promise<boolean>} true=授权已完成/无需授权,false=授权失败 | ||
| 353 | + */ | ||
| 354 | +export const handleSharePageAuth = async (options, callback) => { | ||
| 355 | + if (hasAuth()) { | ||
| 356 | + if (typeof callback === 'function') callback() | ||
| 357 | + return true | ||
| 358 | + } | ||
| 359 | + | ||
| 360 | + if (isFromShare(options)) { | ||
| 361 | + saveCurrentPagePath() | ||
| 362 | + } | ||
| 363 | + | ||
| 364 | + try { | ||
| 365 | + await silentAuth( | ||
| 366 | + () => { | ||
| 367 | + if (typeof callback === 'function') callback() | ||
| 368 | + }, | ||
| 369 | + () => { | ||
| 370 | + navigateToAuth() | ||
| 371 | + } | ||
| 372 | + ) | ||
| 373 | + return true | ||
| 374 | + } catch (error) { | ||
| 375 | + navigateToAuth() | ||
| 376 | + return false | ||
| 377 | + } | ||
| 378 | +} | ||
| 379 | + | ||
| 380 | +/** | ||
| 381 | + * 为路径追加分享标记 | ||
| 382 | + * @param {string} path 原路径 | ||
| 383 | + * @returns {string} 追加后的路径 | ||
| 384 | + */ | ||
| 385 | +export const addShareFlag = (path) => { | ||
| 386 | + const separator = path.includes('?') ? '&' : '?' | ||
| 387 | + return `${path}${separator}from_share=1` | ||
| 388 | +} |
src/utils/config.js
0 → 100644
| 1 | +/* | ||
| 2 | + * @Description: 服务器环境配置 | ||
| 3 | + * @Template: 请根据实际项目修改 BASE_URL 和 REQUEST_DEFAULT_PARAMS | ||
| 4 | + */ | ||
| 5 | + | ||
| 6 | +/** | ||
| 7 | + * @description 接口基础域名 | ||
| 8 | + * - 小程序端由 taro 构建时注入 NODE_ENV | ||
| 9 | + * - 请根据实际项目修改开发/生产环境地址 | ||
| 10 | + * @type {string} | ||
| 11 | + */ | ||
| 12 | +const BASE_URL = process.env.NODE_ENV === 'production' | ||
| 13 | + ? 'https://your-production-domain.com' // 🔧 修改为生产环境域名 | ||
| 14 | + : 'https://your-dev-domain.com' // 🔧 修改为开发环境域名 | ||
| 15 | + | ||
| 16 | +/** | ||
| 17 | + * 接口默认公共参数(避免在多个文件里硬编码) | ||
| 18 | + * - f:业务模块标识(请修改为您的业务模块) | ||
| 19 | + * - client_name:客户端标识(请修改为您的应用名称) | ||
| 20 | + */ | ||
| 21 | +export const REQUEST_DEFAULT_PARAMS = { | ||
| 22 | + f: 'YOUR_MODULE', // 🔧 修改为业务模块标识 | ||
| 23 | + client_name: 'YOUR_APP', // 🔧 修改为应用名称 | ||
| 24 | +} | ||
| 25 | + | ||
| 26 | +export default BASE_URL |
src/utils/mixin.js
0 → 100644
| 1 | +/* | ||
| 2 | + * @Date: 2022-10-13 22:36:08 | ||
| 3 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | + * @LastEditTime: 2026-01-24 12:53:30 | ||
| 5 | + * @FilePath: /xyxBooking-weapp/src/utils/mixin.js | ||
| 6 | + * @Description: 全局 mixin(兼容保留) | ||
| 7 | + */ | ||
| 8 | +import { getSessionId, setSessionId, clearSessionId } from './request'; | ||
| 9 | + | ||
| 10 | +/** | ||
| 11 | + * @description 全局 mixin(兼容保留) | ||
| 12 | + * - 早期版本用于手动管理 sessionid | ||
| 13 | + * - 当前项目 sessionid 由 request.js 拦截器自动注入 | ||
| 14 | + * - 该文件主要作为“旧代码兼容层”保留 | ||
| 15 | + */ | ||
| 16 | +export default { | ||
| 17 | + // 初始化入口(如需全局混入逻辑可写在这里) | ||
| 18 | + init: { | ||
| 19 | + created () { | ||
| 20 | + // 说明:sessionid 现在由 request.js 的拦截器自动管理 | ||
| 21 | + // 如需在组件创建时做通用初始化,可在此补充 | ||
| 22 | + } | ||
| 23 | + } | ||
| 24 | +}; | ||
| 25 | + | ||
| 26 | +/** | ||
| 27 | + * @description 导出 sessionid 管理工具(供极端场景手动处理) | ||
| 28 | + * - 正常业务不建议直接调用 | ||
| 29 | + */ | ||
| 30 | +export { getSessionId, setSessionId, clearSessionId } |
src/utils/network.js
0 → 100644
| 1 | +/* | ||
| 2 | + * @Date: 2026-01-13 15:34:47 | ||
| 3 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | + * @LastEditTime: 2026-01-13 15:36:37 | ||
| 5 | + * @FilePath: /xyxBooking-weapp/src/utils/network.js | ||
| 6 | + * @Description: 网络相关工具函数 | ||
| 7 | + */ | ||
| 8 | +import Taro from '@tarojs/taro' | ||
| 9 | + | ||
| 10 | +/** | ||
| 11 | + * @description: 判断网络是否可用(wifi, 4g, 5g, 3g) | ||
| 12 | + * @param {string} network_type - 网络类型 | ||
| 13 | + * @returns {boolean} 是否可用 | ||
| 14 | + */ | ||
| 15 | +export const is_usable_network = (network_type) => { | ||
| 16 | + return ['wifi', '4g', '5g', '3g'].includes(network_type) | ||
| 17 | +} | ||
| 18 | + | ||
| 19 | +/** | ||
| 20 | + * @description: 获取当前网络类型 | ||
| 21 | + * @returns {Promise<string>} 网络类型(wifi, 4g, 5g, 3g, unknown) | ||
| 22 | + */ | ||
| 23 | +export const get_network_type = async () => { | ||
| 24 | + try { | ||
| 25 | + const result = await new Promise((resolve, reject) => { | ||
| 26 | + Taro.getNetworkType({ | ||
| 27 | + success: resolve, | ||
| 28 | + fail: reject, | ||
| 29 | + }) | ||
| 30 | + }) | ||
| 31 | + return result?.networkType || 'unknown' | ||
| 32 | + } catch (e) { | ||
| 33 | + return 'unknown' | ||
| 34 | + } | ||
| 35 | +} |
src/utils/polyfill.js
0 → 100644
| 1 | +/* | ||
| 2 | + * @Date: 2026-01-07 21:19:46 | ||
| 3 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | + * @LastEditTime: 2026-01-24 12:44:01 | ||
| 5 | + * @FilePath: /xyxBooking-weapp/src/utils/polyfill.js | ||
| 6 | + * @Description: 文件描述 | ||
| 7 | + */ | ||
| 8 | + | ||
| 9 | +/** | ||
| 10 | + * @description 小程序环境 Polyfill:TextEncoder/TextDecoder | ||
| 11 | + * - 部分运行时(尤其是低版本基础库)可能缺少 TextEncoder/TextDecoder | ||
| 12 | + * - NFC/二维码等场景会用到 TextDecoder/TextEncoder(例如解析 NDEF) | ||
| 13 | + */ | ||
| 14 | + | ||
| 15 | +if (typeof TextEncoder === 'undefined') { | ||
| 16 | + class TextEncoder { | ||
| 17 | + /** | ||
| 18 | + * @description 将字符串编码为 UTF-8 字节数组 | ||
| 19 | + * @param {string} str 待编码字符串 | ||
| 20 | + * @returns {Uint8Array} UTF-8 字节数组 | ||
| 21 | + */ | ||
| 22 | + encode(str) { | ||
| 23 | + const len = str.length; | ||
| 24 | + const res = []; | ||
| 25 | + for (let i = 0; i < len; i++) { | ||
| 26 | + let point = str.charCodeAt(i); | ||
| 27 | + if (point <= 0x007f) { | ||
| 28 | + res.push(point); | ||
| 29 | + } else if (point <= 0x07ff) { | ||
| 30 | + res.push(0xc0 | (point >>> 6)); | ||
| 31 | + res.push(0x80 | (0x3f & point)); | ||
| 32 | + } else if (point <= 0xffff) { | ||
| 33 | + res.push(0xe0 | (point >>> 12)); | ||
| 34 | + res.push(0x80 | (0x3f & (point >>> 6))); | ||
| 35 | + res.push(0x80 | (0x3f & point)); | ||
| 36 | + } else { | ||
| 37 | + point = 0x10000 + ((point - 0xd800) << 10) + (str.charCodeAt(++i) - 0xdc00); | ||
| 38 | + res.push(0xf0 | (point >>> 18)); | ||
| 39 | + res.push(0x80 | (0x3f & (point >>> 12))); | ||
| 40 | + res.push(0x80 | (0x3f & (point >>> 6))); | ||
| 41 | + res.push(0x80 | (0x3f & point)); | ||
| 42 | + } | ||
| 43 | + } | ||
| 44 | + return new Uint8Array(res); | ||
| 45 | + } | ||
| 46 | + } | ||
| 47 | + | ||
| 48 | + if (typeof globalThis !== 'undefined') { | ||
| 49 | + globalThis.TextEncoder = TextEncoder; | ||
| 50 | + } | ||
| 51 | + if (typeof global !== 'undefined') { | ||
| 52 | + global.TextEncoder = TextEncoder; | ||
| 53 | + } | ||
| 54 | + if (typeof window !== 'undefined') { | ||
| 55 | + window.TextEncoder = TextEncoder; | ||
| 56 | + } | ||
| 57 | +} | ||
| 58 | + | ||
| 59 | +if (typeof TextDecoder === 'undefined') { | ||
| 60 | + class TextDecoder { | ||
| 61 | + /** | ||
| 62 | + * @description 将字节数据解码为字符串(简化 UTF-8 解码) | ||
| 63 | + * @param {ArrayBuffer|Uint8Array} view 字节数据 | ||
| 64 | + * @param {Object} options 预留参数(与标准接口对齐) | ||
| 65 | + * @returns {string} 解码后的字符串 | ||
| 66 | + */ | ||
| 67 | + decode(view, options) { | ||
| 68 | + void options; | ||
| 69 | + if (!view) { | ||
| 70 | + return ''; | ||
| 71 | + } | ||
| 72 | + let string = ''; | ||
| 73 | + const arr = new Uint8Array(view); | ||
| 74 | + for (let i = 0; i < arr.length; i++) { | ||
| 75 | + string += String.fromCharCode(arr[i]); | ||
| 76 | + } | ||
| 77 | + try { | ||
| 78 | + // 简单的 UTF-8 解码尝试 | ||
| 79 | + return decodeURIComponent(escape(string)); | ||
| 80 | + } catch (e) { | ||
| 81 | + return string; | ||
| 82 | + } | ||
| 83 | + } | ||
| 84 | + } | ||
| 85 | + if (typeof globalThis !== 'undefined') { | ||
| 86 | + globalThis.TextDecoder = TextDecoder; | ||
| 87 | + } | ||
| 88 | + if (typeof global !== 'undefined') { | ||
| 89 | + global.TextDecoder = TextDecoder; | ||
| 90 | + } | ||
| 91 | + if (typeof window !== 'undefined') { | ||
| 92 | + window.TextDecoder = TextDecoder; | ||
| 93 | + } | ||
| 94 | +} |
src/utils/request.js
0 → 100644
| 1 | +/* | ||
| 2 | + * @Date: 2022-09-19 14:11:06 | ||
| 3 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | + * @LastEditTime: 2026-01-13 21:29:43 | ||
| 5 | + * @FilePath: /xyxBooking-weapp/src/utils/request.js | ||
| 6 | + * @Description: 简单axios封装,后续按实际处理 | ||
| 7 | + */ | ||
| 8 | +// import axios from 'axios' | ||
| 9 | +import axios from 'axios-miniprogram'; | ||
| 10 | +import Taro from '@tarojs/taro' | ||
| 11 | +// import qs from 'qs' | ||
| 12 | +// import { strExist } from './tools' | ||
| 13 | +import { refreshSession, saveCurrentPagePath, navigateToAuth } from './authRedirect' | ||
| 14 | +import { has_offline_booking_cache } from '@/composables/useOfflineBookingCache' | ||
| 15 | +import { get_weak_network_modal_no_cache_options } from '@/utils/uiText' | ||
| 16 | +import { parseQueryString } from './tools' | ||
| 17 | + | ||
| 18 | +// import { ProgressStart, ProgressEnd } from '@/components/axios-progress/progress'; | ||
| 19 | +// import store from '@/store' | ||
| 20 | +// import { getToken } from '@/utils/auth' | ||
| 21 | +import BASE_URL, { REQUEST_DEFAULT_PARAMS } from './config'; | ||
| 22 | + | ||
| 23 | +/** | ||
| 24 | + * @description 获取 sessionid 的工具函数 | ||
| 25 | + * - sessionid 由 authRedirect.refreshSession 写入 | ||
| 26 | + * - 每次请求前动态读取,避免旧会话导致的 401 | ||
| 27 | + * @returns {string|null} sessionid或null | ||
| 28 | + */ | ||
| 29 | +export const getSessionId = () => { | ||
| 30 | + try { | ||
| 31 | + return Taro.getStorageSync("sessionid") || null; | ||
| 32 | + } catch (error) { | ||
| 33 | + console.error('获取sessionid失败:', error); | ||
| 34 | + return null; | ||
| 35 | + } | ||
| 36 | +}; | ||
| 37 | + | ||
| 38 | +/** | ||
| 39 | + * @description 设置 sessionid(一般不需要手动调用) | ||
| 40 | + * - 正常情况下由 authRedirect.refreshSession 写入 | ||
| 41 | + * - 保留该方法用于极端场景的手动修复/兼容旧逻辑 | ||
| 42 | + * @param {string} sessionid cookie 字符串 | ||
| 43 | + * @returns {void} 无返回值 | ||
| 44 | + */ | ||
| 45 | +export const setSessionId = (sessionid) => { | ||
| 46 | + try { | ||
| 47 | + if (!sessionid) return | ||
| 48 | + Taro.setStorageSync('sessionid', sessionid) | ||
| 49 | + } catch (error) { | ||
| 50 | + console.error('设置sessionid失败:', error) | ||
| 51 | + } | ||
| 52 | +} | ||
| 53 | + | ||
| 54 | +/** | ||
| 55 | + * @description 清空 sessionid(一般不需要手动调用) | ||
| 56 | + * @returns {void} 无返回值 | ||
| 57 | + */ | ||
| 58 | +export const clearSessionId = () => { | ||
| 59 | + try { | ||
| 60 | + Taro.removeStorageSync('sessionid') | ||
| 61 | + } catch (error) { | ||
| 62 | + console.error('清空sessionid失败:', error) | ||
| 63 | + } | ||
| 64 | +} | ||
| 65 | + | ||
| 66 | +// const isPlainObject = (value) => { | ||
| 67 | +// if (value === null || typeof value !== 'object') return false | ||
| 68 | +// return Object.prototype.toString.call(value) === '[object Object]' | ||
| 69 | +// } | ||
| 70 | + | ||
| 71 | +/** | ||
| 72 | + * @description axios 实例(axios-miniprogram) | ||
| 73 | + * - 统一 baseURL / timeout | ||
| 74 | + * - 通过拦截器处理:默认参数、cookie 注入、401 自动续期、弱网降级 | ||
| 75 | + */ | ||
| 76 | +const service = axios.create({ | ||
| 77 | + baseURL: BASE_URL, // url = base url + request url | ||
| 78 | + // withCredentials: true, // send cookies when cross-domain requests | ||
| 79 | + timeout: 5000, // request timeout | ||
| 80 | +}) | ||
| 81 | + | ||
| 82 | +// service.defaults.params = { | ||
| 83 | +// ...REQUEST_DEFAULT_PARAMS, | ||
| 84 | +// }; | ||
| 85 | + | ||
| 86 | +let has_shown_timeout_modal = false | ||
| 87 | + | ||
| 88 | +/** | ||
| 89 | + * @description 判断是否为超时错误 | ||
| 90 | + * @param {Error} error 请求错误对象 | ||
| 91 | + * @returns {boolean} true=超时,false=非超时 | ||
| 92 | + */ | ||
| 93 | + | ||
| 94 | +const is_timeout_error = (error) => { | ||
| 95 | + const msg = String(error?.message || error?.errMsg || '') | ||
| 96 | + if (error?.code === 'ECONNABORTED') return true | ||
| 97 | + return msg.toLowerCase().includes('timeout') | ||
| 98 | +} | ||
| 99 | + | ||
| 100 | +/** | ||
| 101 | + * @description 判断是否为网络错误(断网/弱网/请求失败等) | ||
| 102 | + * @param {Error} error 请求错误对象 | ||
| 103 | + * @returns {boolean} true=网络错误,false=非网络错误 | ||
| 104 | + */ | ||
| 105 | +const is_network_error = (error) => { | ||
| 106 | + const msg = String(error?.message || error?.errMsg || '') | ||
| 107 | + const raw = (() => { | ||
| 108 | + try { | ||
| 109 | + return JSON.stringify(error) || '' | ||
| 110 | + } catch (e) { | ||
| 111 | + return '' | ||
| 112 | + } | ||
| 113 | + })() | ||
| 114 | + const lower = (msg + ' ' + raw).toLowerCase() | ||
| 115 | + if (lower.includes('request:fail')) return true | ||
| 116 | + if (lower.includes('request fail')) return true | ||
| 117 | + if (lower.includes('network error')) return true | ||
| 118 | + if (lower.includes('failed to fetch')) return true | ||
| 119 | + if (lower.includes('the internet connection appears to be offline')) return true | ||
| 120 | + if (lower.includes('err_blocked_by_client')) return true | ||
| 121 | + if (lower.includes('blocked_by_client')) return true | ||
| 122 | + return false | ||
| 123 | +} | ||
| 124 | + | ||
| 125 | +/** | ||
| 126 | + * @description 是否需要触发弱网/断网降级逻辑 | ||
| 127 | + * - 超时:直接触发 | ||
| 128 | + * - 网络错误:直接触发(避免 wifi 但无网场景漏判) | ||
| 129 | + * @param {Error} error 请求错误对象 | ||
| 130 | + * @returns {Promise<boolean>} true=需要降级,false=不需要 | ||
| 131 | + */ | ||
| 132 | +const should_handle_bad_network = async (error) => { | ||
| 133 | + if (is_timeout_error(error)) return true | ||
| 134 | + return is_network_error(error) | ||
| 135 | +} | ||
| 136 | + | ||
| 137 | +/** | ||
| 138 | + * @description 处理请求超时/弱网错误 | ||
| 139 | + * - 优先:若存在离线预约记录缓存,直接跳转离线预约列表页 | ||
| 140 | + * - 否则:弹出弱网提示(统一文案由 uiText 管理) | ||
| 141 | + * @returns {Promise<void>} 无返回值 | ||
| 142 | + */ | ||
| 143 | +const handle_request_timeout = async () => { | ||
| 144 | + if (has_shown_timeout_modal) return | ||
| 145 | + has_shown_timeout_modal = true | ||
| 146 | + | ||
| 147 | + const pages = Taro.getCurrentPages ? Taro.getCurrentPages() : [] | ||
| 148 | + const current_page = pages && pages.length ? pages[pages.length - 1] : null | ||
| 149 | + const current_route = current_page?.route || '' | ||
| 150 | + if (String(current_route).includes('pages/offlineBookingList/index')) return | ||
| 151 | + | ||
| 152 | + // 若有离线预约记录缓存,则跳转至离线预约列表页 | ||
| 153 | + if (has_offline_booking_cache()) { | ||
| 154 | + try { | ||
| 155 | + await Taro.reLaunch({ url: '/pages/offlineBookingList/index' }) | ||
| 156 | + } catch (e) { | ||
| 157 | + console.error('reLaunch offlineBookingList failed:', e) | ||
| 158 | + } | ||
| 159 | + return | ||
| 160 | + } | ||
| 161 | + | ||
| 162 | + // 否则提示用户检查网络连接 | ||
| 163 | + try { | ||
| 164 | + await Taro.showModal(get_weak_network_modal_no_cache_options()) | ||
| 165 | + } catch (e) { | ||
| 166 | + console.error('show weak network modal failed:', e) | ||
| 167 | + } | ||
| 168 | +} | ||
| 169 | + | ||
| 170 | +// 请求拦截器:合并默认参数 / 注入 cookie | ||
| 171 | +service.interceptors.request.use( | ||
| 172 | + config => { | ||
| 173 | + // console.warn(config) | ||
| 174 | + // console.warn(store) | ||
| 175 | + | ||
| 176 | + // 解析 URL 参数并合并 | ||
| 177 | + const url = config.url || '' | ||
| 178 | + let url_params = {} | ||
| 179 | + if (url.includes('?')) { | ||
| 180 | + url_params = parseQueryString(url) | ||
| 181 | + config.url = url.split('?')[0] | ||
| 182 | + } | ||
| 183 | + | ||
| 184 | + // 优先级:调用传参 > URL参数 > 默认参数 | ||
| 185 | + config.params = { | ||
| 186 | + ...REQUEST_DEFAULT_PARAMS, | ||
| 187 | + ...url_params, | ||
| 188 | + ...(config.params || {}) | ||
| 189 | + } | ||
| 190 | + | ||
| 191 | + /** | ||
| 192 | + * 动态获取 sessionid 并设置到请求头 | ||
| 193 | + * - 确保每个请求都带上最新的 sessionid | ||
| 194 | + * - 注意:axios-miniprogram 的 headers 可能不存在,需要先兜底 | ||
| 195 | + */ | ||
| 196 | + const sessionid = getSessionId(); | ||
| 197 | + if (sessionid) { | ||
| 198 | + config.headers = config.headers || {} | ||
| 199 | + config.headers.cookie = sessionid; | ||
| 200 | + } | ||
| 201 | + | ||
| 202 | + // 增加时间戳 | ||
| 203 | + if (config.method === 'get') { | ||
| 204 | + config.params = { ...config.params, timestamp: (new Date()).valueOf() } | ||
| 205 | + } | ||
| 206 | + | ||
| 207 | + // if ((config.method || '').toLowerCase() === 'post') { | ||
| 208 | + // const url = config.url || '' | ||
| 209 | + // const headers = config.headers || {} | ||
| 210 | + // const contentType = headers['content-type'] || headers['Content-Type'] | ||
| 211 | + // const shouldUrlEncode = | ||
| 212 | + // !contentType || String(contentType).includes('application/x-www-form-urlencoded') | ||
| 213 | + | ||
| 214 | + // if (shouldUrlEncode && !strExist(['upload.qiniup.com'], url) && isPlainObject(config.data)) { | ||
| 215 | + // config.headers = { | ||
| 216 | + // ...headers, | ||
| 217 | + // 'content-type': 'application/x-www-form-urlencoded' | ||
| 218 | + // } | ||
| 219 | + // config.data = qs.stringify(config.data) | ||
| 220 | + // } | ||
| 221 | + // } | ||
| 222 | + | ||
| 223 | + return config | ||
| 224 | + }, | ||
| 225 | + error => { | ||
| 226 | + console.error('请求拦截器异常:', error) | ||
| 227 | + return Promise.reject(error) | ||
| 228 | + } | ||
| 229 | +) | ||
| 230 | + | ||
| 231 | +// 响应拦截器:401 自动续期 / 弱网降级 | ||
| 232 | +service.interceptors.response.use( | ||
| 233 | + /** | ||
| 234 | + * 响应拦截器说明 | ||
| 235 | + * - 这里统一处理后端自定义 code(例如 401 未授权) | ||
| 236 | + * - 如需拿到 headers/status 等原始信息,直接返回 response 即可 | ||
| 237 | + */ | ||
| 238 | + async response => { | ||
| 239 | + const res = response.data | ||
| 240 | + | ||
| 241 | + // 401 未授权处理 | ||
| 242 | + if (res.code === 401) { | ||
| 243 | + const config = response?.config || {} | ||
| 244 | + /** | ||
| 245 | + * 避免死循环/重复重试: | ||
| 246 | + * - __is_retry:本次请求是 401 后的重试请求,如果仍 401,不再继续重试 | ||
| 247 | + */ | ||
| 248 | + if (config.__is_retry) { | ||
| 249 | + return response | ||
| 250 | + } | ||
| 251 | + | ||
| 252 | + /** | ||
| 253 | + * 记录来源页:用于授权成功后回跳 | ||
| 254 | + * - 避免死循环:如果已经在 auth 页则不重复记录/跳转 | ||
| 255 | + */ | ||
| 256 | + const pages = Taro.getCurrentPages(); | ||
| 257 | + const currentPage = pages[pages.length - 1]; | ||
| 258 | + if (currentPage && currentPage.route !== 'pages/auth/index') { | ||
| 259 | + saveCurrentPagePath() | ||
| 260 | + } | ||
| 261 | + | ||
| 262 | + try { | ||
| 263 | + // 优先走静默续期:成功后重放原请求 | ||
| 264 | + await refreshSession() | ||
| 265 | + const retry_config = { ...config, __is_retry: true } | ||
| 266 | + return await service(retry_config) | ||
| 267 | + } catch (error) { | ||
| 268 | + // 静默续期失败:降级跳转到授权页(由授权页完成授权并回跳) | ||
| 269 | + const pages_retry = Taro.getCurrentPages(); | ||
| 270 | + const current_page_retry = pages_retry[pages_retry.length - 1]; | ||
| 271 | + if (current_page_retry && current_page_retry.route !== 'pages/auth/index') { | ||
| 272 | + navigateToAuth() | ||
| 273 | + } | ||
| 274 | + return response | ||
| 275 | + } | ||
| 276 | + } | ||
| 277 | + | ||
| 278 | + if (['预约ID不存在'].includes(res.msg)) { | ||
| 279 | + res.show = false; | ||
| 280 | + } | ||
| 281 | + | ||
| 282 | + return response | ||
| 283 | + }, | ||
| 284 | + async error => { | ||
| 285 | + // Taro.showToast({ | ||
| 286 | + // title: error.message, | ||
| 287 | + // icon: 'none', | ||
| 288 | + // duration: 2000 | ||
| 289 | + // }) | ||
| 290 | + if (await should_handle_bad_network(error)) { | ||
| 291 | + handle_request_timeout() | ||
| 292 | + } | ||
| 293 | + return Promise.reject(error) | ||
| 294 | + } | ||
| 295 | +) | ||
| 296 | + | ||
| 297 | +export default service |
src/utils/tools.js
0 → 100644
| 1 | +/* | ||
| 2 | + * @Date: 2022-04-18 15:59:42 | ||
| 3 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | + * @LastEditTime: 2026-01-23 15:18:16 | ||
| 5 | + * @FilePath: /git/xyxBooking-weapp/src/utils/tools.js | ||
| 6 | + * @Description: 工具函数库 | ||
| 7 | + */ | ||
| 8 | +import dayjs from 'dayjs'; | ||
| 9 | +import Taro from '@tarojs/taro'; | ||
| 10 | +import BASE_URL, { REQUEST_DEFAULT_PARAMS } from './config' | ||
| 11 | + | ||
| 12 | +/** | ||
| 13 | + * @description 格式化时间 | ||
| 14 | + * @param {string|number|Date} date 时间入参 | ||
| 15 | + * @returns {string} 格式化后的时间字符串(YYYY-MM-DD HH:mm) | ||
| 16 | + */ | ||
| 17 | +const formatDate = (date) => { | ||
| 18 | + return dayjs(date).format('YYYY-MM-DD HH:mm'); | ||
| 19 | +}; | ||
| 20 | + | ||
| 21 | +/** | ||
| 22 | + * @description 判断设备信息 | ||
| 23 | + * @returns {Object} 设备信息对象,包含是否为 Android、iOS、是否为平板等属性 | ||
| 24 | + */ | ||
| 25 | +const wxInfo = () => { | ||
| 26 | + const info = Taro.getSystemInfoSync(); | ||
| 27 | + const isAndroid = info.platform === 'android'; | ||
| 28 | + const isiOS = info.platform === 'ios'; | ||
| 29 | + // 说明:当前项目只用到 Android/iOS 区分;平板能力按需补充 | ||
| 30 | + return { | ||
| 31 | + isAndroid, | ||
| 32 | + isiOS, | ||
| 33 | + isTable: false // 小程序通常不是 tablet 模式,或者可以根据 screenWidth 判断 | ||
| 34 | + }; | ||
| 35 | +}; | ||
| 36 | + | ||
| 37 | +/** | ||
| 38 | + * @description 解析 URL 参数 | ||
| 39 | + * @param {string} url 完整 URL 或带 query 的路径 | ||
| 40 | + * @returns {Object} URL 参数对象(键值对) | ||
| 41 | + */ | ||
| 42 | +const parseQueryString = url => { | ||
| 43 | + if (!url) return {}; | ||
| 44 | + var json = {}; | ||
| 45 | + var arr = url.indexOf('?') >= 0 ? url.substr(url.indexOf('?') + 1).split('&') : []; | ||
| 46 | + arr.forEach(item => { | ||
| 47 | + var tmp = item.split('='); | ||
| 48 | + json[tmp[0]] = tmp[1]; | ||
| 49 | + }); | ||
| 50 | + return json; | ||
| 51 | +} | ||
| 52 | + | ||
| 53 | +/** | ||
| 54 | + * @description 判断字符串是否包含数组中的任意子串 | ||
| 55 | + * @param {Array<string>} array 子串数组 | ||
| 56 | + * @param {string} str 目标字符串 | ||
| 57 | + * @returns {boolean} true=包含任意一个子串,false=都不包含 | ||
| 58 | + */ | ||
| 59 | +const strExist = (array, str) => { | ||
| 60 | + if (!str) return false; | ||
| 61 | + const exist = array.filter(arr => { | ||
| 62 | + if (str.indexOf(arr) >= 0) return str; | ||
| 63 | + }) | ||
| 64 | + return exist.length > 0 | ||
| 65 | +} | ||
| 66 | + | ||
| 67 | +/** | ||
| 68 | + * 格式化日期时间字符串,提取开始/结束时间并拼接为「日期 开始时间-结束时间」格式 | ||
| 69 | + * @description 处理包含 begin_time/end_time 的数据对象,截取时间戳最后6位(毫秒/时区等冗余部分), | ||
| 70 | + * 最终拼接为 "YYYY-MM-DD HH:mm:ss-HH:mm:ss" 格式的字符串 | ||
| 71 | + * @param {Object} data - 包含开始/结束时间的数据源对象 | ||
| 72 | + * @param {string} [data.begin_time] - 开始时间字符串(格式示例:2026-01-13T12:30:45.123456+08:00) | ||
| 73 | + * @param {string} [data.end_time] - 结束时间字符串(格式同 begin_time) | ||
| 74 | + * @returns {string} 格式化后的日期时间字符串,格式为「日期 开始时间-结束时间」;若入参无效返回空字符串 | ||
| 75 | + * @example | ||
| 76 | + * 输入示例 | ||
| 77 | + * const timeData = { | ||
| 78 | + * begin_time: '2026-01-13T10:00:00.987654+08:00', | ||
| 79 | + * end_time: '2026-01-13T18:30:00.123456+08:00' | ||
| 80 | + * }; | ||
| 81 | + * 调用函数 | ||
| 82 | + * formatDatetime(timeData); // 返回 "2026-01-13 10:00:00-18:30:00" | ||
| 83 | + * | ||
| 84 | + * @example | ||
| 85 | + * 入参为空的情况 | ||
| 86 | + * formatDatetime(null); // 返回 "" | ||
| 87 | + * formatDatetime({}); // 返回 "" | ||
| 88 | + * | ||
| 89 | + * @note 1. 入参时间字符串需保证前19位为有效格式(YYYY-MM-DDTHH:mm:ss),否则截取后可能出现异常; | ||
| 90 | + * 2. 若 begin_time/end_time 缺失,拼接后可能出现 "undefined-undefined" 等异常,需保证入参完整性; | ||
| 91 | + * 3. 该函数默认截取时间字符串前19位(slice(0, -6)),需根据实际时间格式调整截取长度 | ||
| 92 | + */ | ||
| 93 | +const formatDatetime = (data) => { | ||
| 94 | + if (!data || !data.begin_time || !data.end_time) return ''; | ||
| 95 | + | ||
| 96 | + const normalize = (timeStr) => { | ||
| 97 | + if (!timeStr) return ''; | ||
| 98 | + let clean = timeStr.split('+')[0]; | ||
| 99 | + clean = clean.split('Z')[0]; | ||
| 100 | + clean = clean.trim().replace(/\s+/, 'T'); | ||
| 101 | + return clean; | ||
| 102 | + }; | ||
| 103 | + | ||
| 104 | + const start = dayjs(normalize(data.begin_time)); | ||
| 105 | + const end = dayjs(normalize(data.end_time)); | ||
| 106 | + | ||
| 107 | + if (!start.isValid() || !end.isValid()) return ''; | ||
| 108 | + | ||
| 109 | + const isNextDayMidnight = | ||
| 110 | + end.diff(start, 'day') === 1 && | ||
| 111 | + end.hour() === 0 && | ||
| 112 | + end.minute() === 0 && | ||
| 113 | + end.second() === 0; | ||
| 114 | + | ||
| 115 | + const endTimeText = isNextDayMidnight ? '24:00' : end.format('HH:mm'); | ||
| 116 | + | ||
| 117 | + return `${start.format('YYYY-MM-DD')} ${start.format('HH:mm')}-${endTimeText}`; | ||
| 118 | +}; | ||
| 119 | + | ||
| 120 | +/** | ||
| 121 | + * @description 证件号脱敏 | ||
| 122 | + * @param {string} id_number 证件号 | ||
| 123 | + * @param {Object} [options] 脱敏配置 | ||
| 124 | + * @param {number} [options.keep_start] 保留前几位(传了则按“前后保留”模式脱敏) | ||
| 125 | + * @param {number} [options.keep_end] 保留后几位(传了则按“前后保留”模式脱敏) | ||
| 126 | + * @param {number} [options.mask_count=8] 中间替换为 * 的位数(默认 8) | ||
| 127 | + * @returns {string} 脱敏后的证件号 | ||
| 128 | + */ | ||
| 129 | +const mask_id_number = (id_number, options = {}) => { | ||
| 130 | + const raw = String(id_number || '') | ||
| 131 | + if (!raw) return '' | ||
| 132 | + | ||
| 133 | + const has_keep_start = Number.isFinite(options.keep_start) | ||
| 134 | + const has_keep_end = Number.isFinite(options.keep_end) | ||
| 135 | + const keep_start = has_keep_start ? options.keep_start : 0 | ||
| 136 | + const keep_end = has_keep_end ? options.keep_end : 0 | ||
| 137 | + const mask_count = Number.isFinite(options.mask_count) ? options.mask_count : 8 | ||
| 138 | + | ||
| 139 | + if (has_keep_start && has_keep_end) { | ||
| 140 | + if (raw.length <= keep_start + keep_end) return raw | ||
| 141 | + const prefix = raw.slice(0, keep_start) | ||
| 142 | + const suffix = raw.slice(raw.length - keep_end) | ||
| 143 | + const middle_len = Math.max(1, raw.length - keep_start - keep_end) | ||
| 144 | + return `${prefix}${'*'.repeat(middle_len)}${suffix}` | ||
| 145 | + } | ||
| 146 | + | ||
| 147 | + if (raw.length < 15) return raw | ||
| 148 | + | ||
| 149 | + const safe_mask_count = Math.min(Math.max(1, mask_count), raw.length) | ||
| 150 | + const start = Math.floor((raw.length - safe_mask_count) / 2) | ||
| 151 | + const end = start + safe_mask_count | ||
| 152 | + if (start < 0 || end > raw.length) return raw | ||
| 153 | + | ||
| 154 | + return raw.substring(0, start) + '*'.repeat(safe_mask_count) + raw.substring(end) | ||
| 155 | +} | ||
| 156 | + | ||
| 157 | +/** | ||
| 158 | + * @description 二维码状态文案 | ||
| 159 | + * @param {string|number} status 状态值 | ||
| 160 | + * @returns {string} 状态文案 | ||
| 161 | + */ | ||
| 162 | +const get_qrcode_status_text = (status) => { | ||
| 163 | + const key = String(status || '') | ||
| 164 | + if (key === '1') return '未激活' | ||
| 165 | + if (key === '3') return '待使用' | ||
| 166 | + if (key === '5') return '被取消' | ||
| 167 | + if (key === '7') return '已使用' | ||
| 168 | + return '未知状态' | ||
| 169 | +} | ||
| 170 | + | ||
| 171 | +/** | ||
| 172 | + * @description 订单状态文案 | ||
| 173 | + * @param {string|number} status 状态值 | ||
| 174 | + * @returns {string} 状态文案 | ||
| 175 | + */ | ||
| 176 | +const get_bill_status_text = (status) => { | ||
| 177 | + const key = String(status || '') | ||
| 178 | + if (key === '3') return '预约成功' | ||
| 179 | + if (key === '5') return '已取消' | ||
| 180 | + if (key === '9') return '已使用' | ||
| 181 | + if (key === '11') return '退款中' | ||
| 182 | + return '未知状态' | ||
| 183 | +} | ||
| 184 | + | ||
| 185 | +/** | ||
| 186 | + * @description 构建 API 请求 URL(带默认公共参数) | ||
| 187 | + * @param {string} action 接口动作名称(例如:openid_wxapp) | ||
| 188 | + * @param {Object} [params={}] 额外 query 参数 | ||
| 189 | + * @returns {string} 完整请求 URL(BASE_URL + /srv/?a=...&f=...&client_name=...) | ||
| 190 | + */ | ||
| 191 | +const buildApiUrl = (action, params = {}) => { | ||
| 192 | + const queryParams = new URLSearchParams({ | ||
| 193 | + a: action, | ||
| 194 | + f: REQUEST_DEFAULT_PARAMS.f, | ||
| 195 | + client_name: REQUEST_DEFAULT_PARAMS.client_name, | ||
| 196 | + ...params, | ||
| 197 | + }) | ||
| 198 | + return `${BASE_URL}/srv/?${queryParams.toString()}` | ||
| 199 | +} | ||
| 200 | + | ||
| 201 | +export { formatDate, wxInfo, parseQueryString, strExist, formatDatetime, mask_id_number, get_qrcode_status_text, get_bill_status_text, buildApiUrl }; |
src/utils/uiText.js
0 → 100644
| 1 | +/* | ||
| 2 | + * @Date: 2026-01-13 21:28:45 | ||
| 3 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | + * @LastEditTime: 2026-01-15 19:24:57 | ||
| 5 | + * @FilePath: /xyxBooking-weapp/src/utils/uiText.js | ||
| 6 | + * @Description: 弱网络提示文本 | ||
| 7 | + */ | ||
| 8 | +/** | ||
| 9 | + * @description 弱网/断网统一文案 | ||
| 10 | + * - toast/modal/banner 等入口统一引用,避免多处硬编码 | ||
| 11 | + */ | ||
| 12 | +export const weak_network_text = { | ||
| 13 | + title: '网络连接不畅', | ||
| 14 | + toast_title: '网络连接不畅', | ||
| 15 | + banner_desc: '网络开小差啦!请检查网络设置,或更换位置后重新进入小程序~', | ||
| 16 | + offline_page_desc: '当前网络信号较弱,已自动为您切换至离线模式', | ||
| 17 | + modal_no_cache_content: '当前网络信号较弱,暂无法使用小程序,请检查网络设置,或更换位置后重新进入小程序~', | ||
| 18 | + modal_use_cache_content: '当前网络信号较弱,可使用已缓存的预约记录进入离线模式', | ||
| 19 | + modal_go_offline_records_content: '当前网络信号较弱,是否进入离线预约记录?', | ||
| 20 | + offline_mode_no_booking_toast: '当前为离线模式,无法预约', | ||
| 21 | + confirm_ok: '知道了', | ||
| 22 | + confirm_booking_records: '预约记录', | ||
| 23 | + confirm_offline_records: '离线记录', | ||
| 24 | +} | ||
| 25 | + | ||
| 26 | +/** | ||
| 27 | + * @description: 获取弱网络提示弹窗配置(无缓存) | ||
| 28 | + * @returns {Object} | ||
| 29 | + */ | ||
| 30 | +export const get_weak_network_modal_no_cache_options = () => { | ||
| 31 | + return { | ||
| 32 | + title: weak_network_text.title, | ||
| 33 | + content: weak_network_text.modal_no_cache_content, | ||
| 34 | + confirmText: weak_network_text.confirm_ok, | ||
| 35 | + showCancel: false, | ||
| 36 | + } | ||
| 37 | +} | ||
| 38 | + | ||
| 39 | +/** | ||
| 40 | + * @description: 获取弱网络提示弹窗配置(有缓存) | ||
| 41 | + * @returns {Object} | ||
| 42 | + */ | ||
| 43 | +export const get_weak_network_modal_use_cache_options = () => { | ||
| 44 | + return { | ||
| 45 | + title: weak_network_text.title, | ||
| 46 | + content: weak_network_text.modal_use_cache_content, | ||
| 47 | + confirmText: weak_network_text.confirm_booking_records, | ||
| 48 | + cancelText: weak_network_text.confirm_ok, | ||
| 49 | + } | ||
| 50 | +} | ||
| 51 | + | ||
| 52 | +/** | ||
| 53 | + * @description: 获取弱网络提示弹窗配置(进入离线预约记录) | ||
| 54 | + * @returns {Object} | ||
| 55 | + */ | ||
| 56 | +export const get_weak_network_modal_go_offline_records_options = () => { | ||
| 57 | + return { | ||
| 58 | + title: weak_network_text.title, | ||
| 59 | + content: weak_network_text.modal_go_offline_records_content, | ||
| 60 | + confirmText: weak_network_text.confirm_offline_records, | ||
| 61 | + cancelText: weak_network_text.confirm_ok, | ||
| 62 | + } | ||
| 63 | +} |
src/utils/weapp.js
0 → 100644
| 1 | +/** | ||
| 2 | + * @description 获取当前页路由(不含 query) | ||
| 3 | + * @returns {string} 当前页 route,例如:pages/index/index | ||
| 4 | + */ | ||
| 5 | +const getCurrentPageUrl = () => { | ||
| 6 | + // 获取加载的页面栈 | ||
| 7 | + let pages = getCurrentPages() | ||
| 8 | + // 获取当前页面对象 | ||
| 9 | + let currentPage = pages[pages.length - 1] | ||
| 10 | + // 当前页面 route(不含 query) | ||
| 11 | + let url = currentPage.route | ||
| 12 | + return url | ||
| 13 | +} | ||
| 14 | +/** | ||
| 15 | + * @description 获取当前页 query 参数 | ||
| 16 | + * @returns {Object} 当前页 options(query 参数对象) | ||
| 17 | + */ | ||
| 18 | +const getCurrentPageParam = () => { | ||
| 19 | + // 获取加载的页面栈 | ||
| 20 | + let pages = getCurrentPages() | ||
| 21 | + // 获取当前页面对象 | ||
| 22 | + let currentPage = pages[pages.length - 1] | ||
| 23 | + // 当前页面 query 参数对象 | ||
| 24 | + let options = currentPage.options | ||
| 25 | + return options | ||
| 26 | +} | ||
| 27 | + | ||
| 28 | +export { | ||
| 29 | + getCurrentPageUrl, | ||
| 30 | + getCurrentPageParam | ||
| 31 | +} |
src/utils/wechatPay.js
0 → 100644
| 1 | +/* | ||
| 2 | + * @Date: 2026-01-16 19:41:09 | ||
| 3 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | + * @LastEditTime: 2026-01-20 11:40:46 | ||
| 5 | + * @FilePath: /xyxBooking-weapp/src/utils/wechatPay.js | ||
| 6 | + * @Description: 微信支付工具函数 | ||
| 7 | + */ | ||
| 8 | +import Taro from '@tarojs/taro' | ||
| 9 | +import { wxPayAPI } from '@/api/wx/pay' | ||
| 10 | + | ||
| 11 | +/** | ||
| 12 | + * @description 微信支付 | ||
| 13 | + * @param {*} pay_id 订单号 | ||
| 14 | + * @returns {*} 支付结果 | ||
| 15 | + */ | ||
| 16 | + | ||
| 17 | +export const wechat_pay = async ({ pay_id }) => { | ||
| 18 | + const normalized_pay_id = String(pay_id || '') | ||
| 19 | + if (!normalized_pay_id) { | ||
| 20 | + return { code: 0, data: null, msg: '缺少订单号' } | ||
| 21 | + } | ||
| 22 | + | ||
| 23 | + Taro.showLoading({ title: '支付准备中...' }) | ||
| 24 | + let pay_params_res = null | ||
| 25 | + try { | ||
| 26 | + pay_params_res = await wxPayAPI({ pay_id: normalized_pay_id }) | ||
| 27 | + } finally { | ||
| 28 | + Taro.hideLoading() | ||
| 29 | + } | ||
| 30 | + | ||
| 31 | + if (!pay_params_res || pay_params_res.code != 1) { | ||
| 32 | + return { code: 0, data: null, msg: pay_params_res?.msg || '获取支付信息失败,请稍后再试' } | ||
| 33 | + } | ||
| 34 | + | ||
| 35 | + const pay_params = pay_params_res?.data || {} | ||
| 36 | + | ||
| 37 | + const pay_result = await new Promise((resolve) => { | ||
| 38 | + Taro.requestPayment({ | ||
| 39 | + timeStamp: pay_params.timeStamp, | ||
| 40 | + nonceStr: pay_params.nonceStr, | ||
| 41 | + package: pay_params.package, | ||
| 42 | + signType: pay_params.signType, | ||
| 43 | + paySign: pay_params.paySign, | ||
| 44 | + success: (res) => resolve({ ok: true, res }), | ||
| 45 | + fail: (err) => resolve({ ok: false, err }), | ||
| 46 | + }) | ||
| 47 | + }) | ||
| 48 | + | ||
| 49 | + if (pay_result?.ok) { | ||
| 50 | + return { code: 1, data: pay_result.res || null, msg: '支付成功' } | ||
| 51 | + } | ||
| 52 | + | ||
| 53 | + return { code: 0, data: pay_result?.err || null, msg: pay_result?.err?.errMsg || '支付未完成' } | ||
| 54 | +} |
tailwind.config.js
0 → 100644
| 1 | +/* | ||
| 2 | + * @Date: 2025-06-30 13:27:50 | ||
| 3 | + * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | + * @LastEditTime: 2025-06-30 13:27:56 | ||
| 5 | + * @FilePath: /myApp/tailwind.config.js | ||
| 6 | + * @Description: 文件描述 | ||
| 7 | + */ | ||
| 8 | +/** @type {import('tailwindcss').Config} */ | ||
| 9 | +module.exports = { | ||
| 10 | + // 这里给出了一份 taro 通用示例,具体要根据你自己项目的目录结构进行配置 | ||
| 11 | + // 比如你使用 vue3 项目,你就需要把 vue 这个格式也包括进来 | ||
| 12 | + // 不在 content glob 表达式中包括的文件,在里面编写 tailwindcss class,是不会生成对应的 css 工具类的 | ||
| 13 | + content: ['./public/index.html', './src/**/*.{html,js,ts,jsx,tsx,vue}'], | ||
| 14 | + theme: { | ||
| 15 | + extend: {}, | ||
| 16 | + }, | ||
| 17 | + plugins: [], | ||
| 18 | + corePlugins: { | ||
| 19 | + // 小程序不需要 preflight,因为这主要是给 h5 的,如果你要同时开发多端,你应该使用 process.env.TARO_ENV 环境变量来控制它 | ||
| 20 | + preflight: false, | ||
| 21 | + }, | ||
| 22 | +} |
-
Please register or login to post a comment