hookehuyr

Initial commit

1 +node_modules/
2 +dist/
3 +.DS_Store
4 +.idea/
5 +.vscode/
6 +*.log
7 +npm-debug.log*
8 +yarn-debug.log*
9 +yarn-error.log*
10 +pnpm-debug.log*
11 +.env.local
12 +.env.*.local
13 +unpackage/
1 +node_modules/
2 +dist/
3 +.DS_Store
4 +.idea/
5 +.vscode/
6 +*.log
7 +npm-debug.log*
8 +yarn-debug.log*
9 +yarn-error.log*
10 +pnpm-debug.log*
11 +.env.local
12 +.env.*.local
13 +unpackage/
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. 查看浏览器/小程序控制台错误信息
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
1 +// babel-preset-taro 更多选项和默认值:
2 +// https://github.com/NervJS/taro/blob/next/packages/babel-preset-taro/README.md
3 +module.exports = {
4 + presets: [
5 + ['taro', {
6 + framework: 'vue3',
7 + ts: false,
8 + compiler: 'webpack5',
9 + }]
10 + ]
11 +}
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 +}
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 +})
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 +}
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 +}
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 +}
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 +}
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));
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 +}
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 + */
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));
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 +]
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));
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 +}
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
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 +}
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 +}
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 +}
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>
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 + })
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 +}
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>
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 }}&nbsp;{{ 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>
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 }}&nbsp;{{ 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 +}
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>
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 +}
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 +}
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>
1 +export default {
2 + navigationBarTitleText: '授权页',
3 + usingComponents: {
4 + },
5 +}
1 +.red {
2 + color: red;
3 +}
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>
1 +/*
2 + * @Date: 2025-06-28 10:33:00
3 + * @LastEditors: hookehuyr hookehuyr@gmail.com
4 + * @LastEditTime: 2026-01-06 22:13:24
5 + * @FilePath: /xyxBooking-weapp/src/pages/index/index.config.js
6 + * @Description: 首页配置
7 + */
8 +export default {
9 + navigationBarTitleText: '西园寺预约'
10 +}
1 +/**
2 + * index页面样式
3 + */
4 +.index {
5 + padding: 40rpx;
6 +
7 + .nut-button {
8 + margin-bottom: 40rpx;
9 + }
10 +}
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>
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 +// })
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 +})
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 +});
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 +})
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 +}
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
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 }
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 +}
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 +}
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
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 };
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 +}
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 +}
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 +}
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 +}