hookehuyr

feat: 初始化觉林寺小程序项目基础架构

添加项目配置文件、核心工具模块、基础页面组件和依赖管理
- 配置 Taro 4 + Vue 3 + NutUI 技术栈,支持微信小程序和 H5 开发
- 实现完整的微信登录认证流程,包括静默授权和 401 自动刷新
- 添加支付测试功能,支持 WebView 桥接和微信支付调用
- 提供弱网/离线支持,包含缓存管理和统一文案
- 设置双设计宽度体系(NutUI 375px / 其他 750px)
- 配置路径别名、ESLint 代码规范和开发构建脚本
Showing 67 changed files with 6351 additions and 0 deletions
const babelParserPath = require.resolve('@babel/eslint-parser', {
paths: [require.resolve('eslint-config-taro')],
})
const vuePluginPath = require.resolve('eslint-plugin-vue')
const vueParserPath = require.resolve('vue-eslint-parser')
module.exports = {
root: true,
env: {
es6: true,
node: true,
browser: true,
commonjs: true,
es2021: true,
},
extends: ['eslint:recommended'],
parser: babelParserPath,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
requireConfigFile: false,
babelOptions: {
configFile: false,
},
},
globals: {
wx: 'readonly',
getCurrentPages: 'readonly',
defineProps: 'readonly',
defineEmits: 'readonly',
defineExpose: 'readonly',
defineOptions: 'readonly',
defineSlots: 'readonly',
withDefaults: 'readonly',
},
rules: {
'no-console': ['error', { allow: ['warn', 'error'] }],
},
overrides: [
{
files: ['**/*.vue'],
parser: vueParserPath,
plugins: ['vue'],
parserOptions: {
parser: babelParserPath,
ecmaVersion: 'latest',
sourceType: 'module',
requireConfigFile: false,
babelOptions: {
configFile: false,
},
},
rules: {
'vue/script-setup-uses-vars': 'error',
},
settings: {
'import/resolver': {
node: true,
},
},
},
],
settings: {
vue: {
version: '3.0',
},
},
}
node_modules
.history
.swc
dist
# 仓库指南
## 项目结构与模块组织
源码位于 `src/`。路由页面放在 `src/pages/<page>/`,每个页面通常包含 `index.vue``index.less``index.config.js`。通用组件放在 `src/components/`,复用逻辑放在 `src/composables/``src/hooks/`,接口封装放在 `src/api/`,公共工具放在 `src/utils/`,全局状态放在 `src/stores/`。构建配置集中在 `config/`,应用入口为 `src/app.js``src/app.config.js`
## 构建、调试与开发命令
使用 `pnpm install` 安装依赖。开发微信小程序时运行 `pnpm dev:weapp`,调试 H5 时运行 `pnpm dev:h5`。生产构建常用 `pnpm build:weapp`,如有多端需求,也可使用 `pnpm build:h5``pnpm build:alipay` 等命令。提交前必须执行 `pnpm lint`,当前仓库已配置的自动检查主要就是 ESLint。
## 代码风格与命名约定
项目基于 JavaScript、Vue 3 单文件组件、Less 和 Taro 4。请遵循现有风格:`.vue` 文件统一使用 2 空格缩进,业务逻辑保持简洁,优先使用组合式写法。可复用组件使用 PascalCase 命名,例如 `PosterBuilder`;组合式函数和 hooks 使用 `useXxx.js` 命名,例如 `useGo.js`;接口方法统一使用 `xxxAPI` 后缀,例如 `getUserInfoAPI`。导入路径优先使用 `@/utils``@/components` 等别名,避免过深的相对路径。
## 测试要求
当前 `package.json` 中没有独立的单元测试框架,因此默认验证方式为 `pnpm lint` 加手工联调。修改页面、路由、认证或请求逻辑后,请至少在微信开发者工具中走通相关流程。若变更涉及 `src/utils/authRedirect.js``src/utils/request.js``src/api/`,需要重点检查登录态、页面回跳和接口请求是否正常。后续如新增自动化测试,建议贴近功能放置,并使用 `*.spec.js` 命名。
## 授权与支付链路约定
授权逻辑的核心在 `src/app.js``src/utils/authRedirect.js``src/utils/request.js``src/pages/auth/index`。应用启动时会优先尝试静默授权,`sessionid` 统一写入 Taro 本地缓存,并由请求拦截器动态注入到请求头;接口返回 `401` 时,会先尝试 `refreshSession` 静默续期并重放原请求,失败后再降级跳转授权页。因此除非明确重构整条链路,否则不要随意改动 `sessionid` 的存取方式、`saveCurrentPagePath` / `returnToOriginalPage` 的回跳机制、`navigateToAuth` 的防重逻辑,也不要跳过 `src/pages/auth/index` 直接在业务页硬编码授权流程。若修改分享进入、启动授权、401 重试或来源页回填逻辑,需至少手工验证一次“未授权进入页面 -> 自动或手动授权 -> 成功回跳原页面”的完整闭环。
支付链路给业务侧的精简描述可以理解为:`map-demo` 的 H5 页面先通过项目里的 `pages/webview-preview/index` 进入小程序 `WebView`,当 H5 侧需要发起支付时,再把 `order_id` 传给小程序里的 `pages/pay-bridge/index`;桥页负责检查授权状态、必要时补做静默授权,然后通过 `src/composables/useWechatMiniPay.js` 调用 `/srv/?a=pay` 向后端换取微信支付参数,最后由小程序侧执行 `Taro.requestPayment` 拉起正式支付。支付完成后,桥页会根据成功、取消、失败三种结果给出状态提示,并自动返回上一页,回到原来的 `WebView/H5` 场景。
仓库实现上,测试/桥接链路核心在 `src/composables/useWechatMiniPay.js``src/api/index.js``src/pages/pay-test/index.vue``src/pages/pay-bridge/index.vue`;另一条是通用支付封装,位于 `src/utils/wechatPay.js``src/api/wx/pay.js`,通过 `pay_id` 调用 `/srv/?a=icbc_pay_wxamp`。修改支付逻辑时务必先确认当前页面接的是哪一条接口链路,不要混用 `order_id``pay_id`,也不要在未拿到后端有效支付参数时直接调用 `requestPayment`。涉及 H5/WebView 唤起支付时,应优先保持 `pages/pay-bridge` 的桥接职责与返回参数约定稳定;涉及支付结果处理时,需区分成功、取消、失败三类状态,并至少在微信开发者工具或真机中验证一次“授权状态检查 -> 拉起支付 -> 返回结果展示/回跳”的流程。
## 提交与合并请求规范
当前 Git 历史以简短中文提交为主,例如 `初始化觉林寺小程序项目`。后续提交信息也请保持中文、简洁、祈使语气,并聚焦单一改动。提交 PR 时请附上:变更背景与解决方案、关联任务或问题、影响的页面或模块、手工验证步骤;涉及界面改动时,需补充截图或录屏。
## 文档与描述语言要求
本仓库默认使用中文沟通与书写。今后新增或修改的说明性文字请统一使用中文,包括但不限于文档、注释说明、提交说明、PR 描述、变更摘要和协作备注;如必须保留英文术语,请同时提供中文语义,避免只写英文描述。
## 安全与配置提示
不要提交真实的 AppID、令牌或生产环境域名。首次接手项目时,请优先检查 `src/utils/config.js``project.config.json`。授权与支付测试环境当前会复用既有后端配置,调整域名、`client_id`、支付接口地址或 WebView 跳转地址前,必须先确认不会影响 `openid`、会话续期和微信支付参数生成。除非明确要重构认证链路,否则不要随意删除或绕过 `src/pages/auth/index` 认证页。
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 常用命令
```bash
pnpm dev:weapp # 开发模式(微信小程序,watch)
pnpm build:weapp # 生产构建(微信小程序)
pnpm dev:h5 # 开发模式(H5)
pnpm lint # ESLint 检查
```
包管理器:pnpm。构建产物输出到 `dist/`,微信开发者工具指向此目录。
## 技术栈
Taro 4.1.9 + Vue 3 (Composition API `<script setup>`) + NutUI 4.x + Pinia + axios-miniprogram + Less + TailwindCSS + Webpack 5
## 架构要点
### 双设计宽度体系
`config/index.js``designWidth` 函数根据文件路径判断基准:
- NutUI 组件文件(含 `@nutui`)→ 375px
- 其他所有页面/组件 → 750px
编写样式时注意:NutUI 组件按 375 设计稿,自定义组件按 750 设计稿。
### 认证流程
启动链路(`src/app.js` onLaunch):
1. `saveCurrentPagePath()` 保存当前路径到 routerStore
2. 跳过 auth 页本身
3. 若无 sessionid → `silentAuth()` 静默授权(Taro.login 获取 code → 后端 `openid_wxapp` 换 cookie)
请求链路(`src/utils/request.js` 拦截器):
1. 请求拦截:注入 cookie header、合并默认参数、GET 加时间戳
2. 响应拦截:`code === 401``refreshSession()` 静默续期 → 成功则重放原请求(`__is_retry` 防循环)→ 失败则 `navigateToAuth()` 跳授权页
3. 授权页完成后 `returnToOriginalPage()` 回跳来源页
`auth_promise` 单例锁防止并发重复授权。`navigating_to_auth` + 时间戳防重复跳转。
### HTTP 客户端
`src/utils/request.js`:axios-miniprogram 实例,baseURL 来自 `src/utils/config.js`(按 NODE_ENV 切换),超时 5s。
`src/api/fn.js`:统一请求封装。
- `fn(api)` — 标准化后端返回 `{ code, data, msg }``code === 1` 成功,失败自动 toast
- `fetch.get / fetch.post / fetch.stringifyPost / fetch.basePost` — 封装不同请求方式
### API 定义模式
```javascript
// src/api/index.js — 用 buildApiUrl(action, params) 生成 URL
// buildApiUrl 拼接:${BASE_URL}/srv/?a=${action}&f=...&client_name=...&params...
export const xxxAPI = (params) => buildApiUrl('action_name', params)
// 调用方式
import { fn, fetch } from '@/api/fn'
import { xxxAPI } from '@/api'
const res = await fn(fetch.get(xxxAPI({ key: value })))
```
### 导航
`src/hooks/useGo.js`
- `useGo()` → 返回 `go(path, query)` 函数,支持短路径(`'notice'` 自动补全为 `pages/notice/index`),自动处理 tabbar 降级
- `useReplace()` → 返回 `replace(path, query)` 函数,使用 `redirectTo`
## 路径别名
```
@/utils → src/utils @/components → src/components
@/api → src/api @/stores → src/stores
@/hooks → src/hooks @/composables → src/composables
@/images → src/assets/images @/assets → src/assets
```
## 项目结构
```
src/
├── api/ # 接口定义(index.js 用 buildApiUrl,fn.js 封装请求)
├── assets/ # 静态资源(images/、styles/、css/)
├── components/ # 通用组件
├── composables/ # Composition API hooks
├── hooks/ # useGo 等导航/工具 hooks
├── pages/ # 页面(pages/auth/index 为授权页,必须保留)
├── stores/ # Pinia stores(router.js 管理回跳路径)
└── utils/ # 核心工具(authRedirect.js、request.js、config.js)
```
## 关键约定
- 后端返回格式:`{ code: 1, data, msg }``code === 1` 为成功
- sessionid 存储在 Taro Storage,key 为 `sessionid`
- 公共请求参数(`f``client_name`)在 `src/utils/config.js``REQUEST_DEFAULT_PARAMS` 中配置
- NutUI 组件已配置自动导入(unplugin-vue-components),无需手动 import
- TailwindCSS 已禁用 preflight(`corePlugins.preflight: false`),避免与小程序冲突
- 新增页面必须同时在 `src/app.config.js` 的 pages 数组中注册
- 微信小程序 appid:`wxf667494219504780`
### 授权流程
完整链路(`src/utils/authRedirect.js`):
1. **启动**`app.js` onLaunch → `silentAuth()``Taro.login` 获取 code → 后端 `/srv/?a=openid` 换 cookie → 写入 `sessionid`
2. **请求 401**`request.js` 响应拦截器 → `refreshSession()` 续期 → 成功重放原请求(`__is_retry` 防循环)→ 失败 `navigateToAuth()` 跳授权页
3. **授权页**`pages/auth/index``silentAuth()``returnToOriginalPage()` 回跳
4. **防重入**`auth_promise` 单例锁防并发,`navigating_to_auth` + 时间戳防重复跳转
5. **sessionid** 存储在 `Taro Storage`,key 为 `sessionid``hasAuth()` 判断是否已授权
### 支付流程
`src/composables/useWechatMiniPay.js` 统一封装:
1. **手动测试**`pages/pay-test` → 输入 order_id → `pay_by_order_id()` → 获取支付参数 → `Taro.requestPayment`
2. **WebView 桥接**`pages/webview-preview` 内嵌 H5 → H5 跳 `pages/pay-bridge?order_id=xxx` → 自动授权 + 拉起支付 → 支付结束自动返回
3. **API**`getWechatPayParamsAPI({ order_id })` → POST `/srv/?a=pay` 获取微信支付参数(timeStamp/nonceStr/package/signType/paySign)
4. **自动授权**`pay_by_order_id``auto_auth` 选项(默认 true),未授权时先 `refresh_auth()` 再支付
5. **返回格式**`{ code, status: 'success'|'cancel'|'fail'|'auth_required'|..., msg, data }`
# 快速开始指南
## 第一步:修改项目配置
### 1.1 修改 package.json
```bash
# 修改项目名称
"name": "jls-weapp" # 按需调整项目名称
# 修改描述
"description": "觉林寺微信小程序" # 按需调整项目描述
```
### 1.2 修改服务器配置
编辑 `src/utils/config.js`
```javascript
const BASE_URL = process.env.NODE_ENV === 'production'
? 'https://your-production-domain.com' // 🔧 改为生产环境域名
: 'https://your-dev-domain.com' // 🔧 改为开发环境域名
export const REQUEST_DEFAULT_PARAMS = {
f: 'YOUR_MODULE', // 🔧 改为业务模块标识
client_name: 'YOUR_APP', // 🔧 改为应用名称
}
```
### 1.3 修改微信小程序 AppID
编辑 `project.config.json`
```json
{
"appid": "your-appid" // 🔧 改为您的微信小程序 AppID
}
```
## 第二步:安装依赖
```bash
# 进入项目目录
cd jls_weapp
# 安装依赖(推荐使用 pnpm)
pnpm install
# 或使用 npm
npm install
```
## 第三步:定义您的 API 接口
编辑 `src/api/index.js`,添加业务接口:
```javascript
import { buildApiUrl } from '@/utils/tools'
// 示例:获取用户信息
export const getUserInfoAPI = (params) => {
return buildApiUrl('getUserInfo', params)
}
// 示例:提交表单
export const submitFormAPI = (params) => {
return buildApiUrl('submitForm', params)
}
```
## 第四步:配置页面路由
编辑 `src/app.config.js`,添加您的页面:
```javascript
export default {
pages: [
'pages/index/index', // 首页
'pages/auth/index', // 认证页(必须保留)
'pages/your-page/index', // 🔧 添加您的页面
],
tabBar: {
color: '#999',
selectedColor: '#007AFF',
backgroundColor: '#fff',
list: [
{
pagePath: 'pages/index/index',
text: '首页',
iconPath: 'assets/images/home.png',
selectedIconPath: 'assets/images/home-active.png'
},
// 🔧 添加更多 tabbar 页面
]
}
}
```
## 第五步:启动开发
```bash
# 微信小程序开发
pnpm dev:weapp
# H5 开发
pnpm dev:h5
```
## 第六步:微信开发者工具
1. 打开微信开发者工具
2. 导入项目,选择 `dist` 目录
3. AppID 使用测试号或您的 AppID
## 核心功能说明
### 🔐 认证系统
模板已内置完整的微信登录认证流程:
- **静默认证**:应用启动时自动执行
- **401 自动刷新**:接口返回 401 时自动刷新会话
- **授权页回跳**:认证完成后自动返回原页面
**重要**:后端需提供 `/srv/?a=openid_wxapp` 接口
### 🌐 网络请求
使用统一的 HTTP 客户端(`src/utils/request.js`):
```javascript
import request from '@/api/fn'
// GET 请求
export const getListAPI = (params) => {
return request.get('/your-action', params)
}
// POST 请求
export const submitDataAPI = (data) => {
return request.post('/your-action', data)
}
```
### 🧭 导航跳转
使用 `useGo` hook 进行页面跳转:
```javascript
import { useGo } from '@/hooks/useGo'
const go = useGo()
// 跳转到页面(自动补全路径)
go('/your-page')
// 带参数跳转
go('/your-page?id=123')
// TabBar 页面跳转
go('/index') // 自动使用 switchTab
```
### 💾 状态管理
使用 Pinia 进行状态管理:
```javascript
// src/stores/user.js
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
userInfo: null
}),
actions: {
setUserInfo(info) {
this.userInfo = info
}
}
})
```
## 常见问题
### Q: 如何去除可选功能?
如果不需要以下功能,可以直接删除对应文件:
- **二维码组件**`src/components/qrCode.vue``qrCodeSearch.vue`
- **海报生成器**`src/components/PosterBuilder/`
- **微信支付**`src/utils/wechatPay.js``src/api/wx/`
- **离线缓存**`src/composables/useOfflineBookingCache.js``useOfflineBookingCachePolling.js`
- **时间选择器**`src/components/time-picker-data/`
### Q: 如何修改设计稿尺寸?
当前配置为双设计宽度体系:
- NutUI 组件:375px
- 其他页面:750px
如需修改,编辑 `config/index.js` 中的 `designWidth` 配置。
### Q: 认证流程不工作?
1. 检查后端 `/srv/?a=openid_wxapp` 接口是否正常
2. 检查 `src/utils/config.js` 中的 `BASE_URL` 是否正确
3. 查看微信开发者工具控制台错误信息
## 下一步
- 📖 阅读完整文档:`README.md`
- 🎨 查看 NutUI 组件库:https://nutui.jd.com/taro/
- 📚 Taro 官方文档:https://taro-docs.jd.com/
## 需要帮助?
遇到问题可以:
1. 查看 `README.md` 详细文档
2. 检查 `src/utils/config.js` 配置
3. 查看浏览器/小程序控制台错误信息
# jls-weapp
觉林寺微信小程序,基于 Taro 4 + Vue 3 + NutUI 搭建
## 🚀 快速开始
### 安装依赖
```bash
pnpm install
```
### 开发模式
```bash
# 微信小程序
pnpm dev:weapp
# H5
pnpm dev:h5
# 支付宝小程序
pnpm dev:alipay
```
### 生产构建
```bash
pnpm build:weapp
```
## 📁 项目结构
```
src/
├── api/ # API 接口层
│ ├── index.js # 业务接口定义(需根据业务修改)
│ ├── fn.js # HTTP 请求封装
│ └── wx/ # 微信相关接口(可选)
├── assets/ # 静态资源
│ ├── images/ # 图片资源
│ ├── styles/ # 全局样式
│ └── css/ # CSS 文件
├── components/ # 通用组件
│ ├── PosterBuilder/ # 海报生成器(可选)
│ ├── time-picker-data/ # 时间选择器
│ ├── indexNav.vue # 底部导航
│ └── qrCode.vue # 二维码组件(可选)
├── composables/ # Composition API hooks
│ ├── useOfflineBookingCache.js # 离线缓存 hook
│ └── useOfflineBookingCachePolling.js # 轮询刷新 hook
├── hooks/ # 自定义 hooks
│ └── useGo.js # 导航辅助 hook
├── pages/ # 页面组件
│ ├── index/ # 首页(示例页面)
│ └── auth/ # 认证页(必须保留)
├── stores/ # Pinia 状态管理
│ ├── router.js # 路由状态(用于认证回跳)
│ ├── main.js # 主 store
│ ├── host.js # 配置 store
│ └── counter.js # 示例 store
├── utils/ # 工具函数
│ ├── authRedirect.js # 认证流程核心(必须)
│ ├── request.js # HTTP 客户端核心(必须)
│ ├── network.js # 网络状态监测
│ ├── config.js # 环境配置(⚠️ 需修改)
│ ├── tools.js # 通用工具
│ ├── uiText.js # 文案管理
│ ├── wechatPay.js # 微信支付(可选)
│ ├── mixin.js # Vue mixin
│ ├── polyfill.js # 浏览器兼容
│ └── weapp.js # 小程序工具
├── app.js # 应用入口
├── app.config.js # 页面路由配置
└── app.less # 全局样式
```
## ⚙️ 配置说明
### 1. 修改服务器配置
编辑 `src/utils/config.js`
```javascript
const BASE_URL = process.env.NODE_ENV === 'production'
? 'https://your-production-domain.com' // 生产环境域名
: 'https://your-dev-domain.com' // 开发环境域名
export const REQUEST_DEFAULT_PARAMS = {
f: 'YOUR_MODULE', // 业务模块标识
client_name: 'YOUR_APP', // 应用名称
}
```
### 2. 定义 API 接口
编辑 `src/api/index.js`,添加您的业务接口:
```javascript
import { buildApiUrl } from '@/utils/tools'
export const yourAPI = (params) => {
return buildApiUrl('your_action', params)
}
```
### 3. 配置页面路由
编辑 `src/app.config.js`,添加您的页面:
```javascript
export default {
pages: [
'pages/index/index',
'pages/auth/index',
'pages/your-page/index', // 添加您的页面
],
// ...
}
```
### 4. 双设计宽度体系
项目采用双设计宽度体系:
- **NutUI 组件**:基准宽度 375px
- **其他所有页面**:基准宽度 750px
此配置已在 `config/index.js` 中设置,请确保遵循此规范。
## 🔐 认证流程
项目内置完整的微信登录认证系统:
1. **静默认证**:应用启动时自动执行静默认证
2. **401 自动刷新**:当接口返回 401 时,自动刷新会话并重试请求
3. **授权页回跳**:认证完成后自动跳转回原页面
**核心文件**
- `src/utils/authRedirect.js` - 认证流程管理
- `src/utils/request.js` - HTTP 请求拦截器
**重要**:后端需提供 `/srv/?a=openid_wxapp` 接口用于微信登录。
## 🌐 弱网/离线支持
项目内置弱网和离线支持:
1. **请求超时处理**:自动检测网络超时并降级处理
2. **离线缓存**:支持离线数据缓存和读取
3. **弱网提示**:统一的弱网提示文案
**相关文件**
- `src/composables/useOfflineBookingCache.js`
- `src/composables/useOfflineBookingCachePolling.js`
- `src/utils/uiText.js`
## 📦 技术栈
- **框架**:Taro 4.x
- **UI 库**:Vue 3 + NutUI 4.x
- **状态管理**:Pinia
- **HTTP**:axios-miniprogram
- **样式**:Less + TailwindCSS
- **构建工具**:Webpack 5
## 🎯 路径别名
已配置的路径别名:
```javascript
@/utils -> src/utils
@/components -> src/components
@/images -> src/assets/images
@/assets -> src/assets
@/composables-> src/composables
@/api -> src/api
@/stores -> src/stores
@/hooks -> src/hooks
```
## 📝 开发规范
### 组件编写
- 使用 Vue 3 Composition API
- 组件统一放在 `src/components/` 目录
- Props 定义清晰,注释详细
### API 调用
- 接口统一在 `src/api/index.js` 定义
- 使用 `xxxAPI(params)` 命名格式
- 请求方法统一使用 `src/api/fn.js` 中的封装
### 状态管理
- 使用 Pinia 进行状态管理
- Store 文件统一放在 `src/stores/` 目录
- 复杂逻辑使用 composables 封装
### 样式编写
- 通用样式使用 TailwindCSS 工具类
- 组件样式使用 Less
- NutUI 组件使用 375px 设计稿,其他使用 750px
## 🔧 可选功能
以下功能可以根据项目需求选择使用或移除:
1. **二维码组件**`src/components/qrCode.vue`
2. **海报生成器**`src/components/PosterBuilder/`
3. **微信支付**`src/utils/wechatPay.js``src/api/wx/`
4. **时间选择器**`src/components/time-picker-data/`
5. **离线缓存**`src/composables/useOfflineBookingCache.js`
## 📚 相关文档
- [Taro 官方文档](https://taro-docs.jd.com/)
- [NutUI 文档](https://nutui.jd.com/taro/)
- [Vue 3 文档](https://cn.vuejs.org/)
- [Pinia 文档](https://pinia.vuejs.org/zh/)
## ⚠️ 注意事项
1. 小程序启动会自动执行静默认证,确保后端接口正常
2. 请求超时默认 5 秒,可在 `src/utils/request.js` 中修改
3. NutUI 组件已配置自动导入,无需手动引入
4. TailwindCSS 已禁用 preflight,避免与小程序样式冲突
5. 认证失败会自动跳转到 `/pages/auth/index`
## 📄 License
MIT
// babel-preset-taro 更多选项和默认值:
// https://github.com/NervJS/taro/blob/next/packages/babel-preset-taro/README.md
module.exports = {
presets: [
['taro', {
framework: 'vue3',
ts: false,
compiler: 'webpack5',
}]
]
}
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
declare module 'vue' {
export interface GlobalComponents {
IndexNav: typeof import('./src/components/indexNav.vue')['default']
Picker: typeof import('./src/components/time-picker-data/picker.vue')['default']
PosterBuilder: typeof import('./src/components/PosterBuilder/index.vue')['default']
QrCode: typeof import('./src/components/qrCode.vue')['default']
QrCodeSearch: typeof import('./src/components/qrCodeSearch.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
}
/*
* @Date: 2025-06-28 10:33:00
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-06-28 10:45:27
* @FilePath: /myApp/config/dev.js
* @Description: 文件描述
*/
export default {
env: {
NODE_ENV: '"development"'
},
logger: {
quiet: false,
stats: true
},
mini: {},
h5: {}
}
import { defineConfig } from '@tarojs/cli'
import devConfig from './dev'
import prodConfig from './prod'
import NutUIResolver from '@nutui/auto-import-resolver'
import Components from 'unplugin-vue-components/webpack'
const path = require('path')
const { UnifiedWebpackPluginV5 } = require('weapp-tailwindcss/webpack')
// https://taro-docs.jd.com/docs/next/config#defineconfig-辅助函数
export default defineConfig(async (merge) => {
const baseConfig = {
projectName: 'myApp',
date: '2025-6-28',
designWidth (input) {
// 配置 NutUI 375 尺寸
if (input?.file?.replace(/\\+/g, '/').indexOf('@nutui') > -1) {
return 375
}
// 全局使用 Taro 默认的 750 尺寸
return 750
},
deviceRatio: {
640: 2.34 / 2,
750: 1,
375: 2,
828: 1.81 / 2
},
alias: { // 配置目录别名
"@/utils": path.resolve(__dirname, "../src/utils"),
"@/components": path.resolve(__dirname, "../src/components"),
"@/images": path.resolve(__dirname, "../src/assets/images"),
"@/assets": path.resolve(__dirname, "../src/assets"),
"@/composables": path.resolve(__dirname, "../src/composables"),
"@/api": path.resolve(__dirname, "../src/api"),
"@/stores": path.resolve(__dirname, "../src/stores"),
"@/hooks": path.resolve(__dirname, "../src/hooks"),
},
sourceRoot: 'src',
outputRoot: 'dist',
plugins: ['@tarojs/plugin-html', 'taro-plugin-pinia',],
defineConstants: {
},
copy: {
patterns: [
],
options: {
}
},
framework: 'vue3',
compiler: {
type: 'webpack5',
prebundle: {
enable: false
}
},
cache: {
enable: false // Webpack 持久化缓存配置,建议开启。默认配置请参考:https://docs.taro.zone/docs/config-detail#cache
},
sass:{
data: `@import "@nutui/nutui-taro/dist/styles/variables.scss";`
},
mini: {
miniCssExtractPluginOption: {
ignoreOrder: true
},
postcss: {
pxtransform: {
enable: true,
config: {
}
},
// url: {
// enable: true,
// config: {
// limit: 1024 // 设定转换尺寸上限
// }
// },
cssModules: {
enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true
config: {
namingPattern: 'module', // 转换模式,取值为 global/module
generateScopedName: '[name]__[local]___[hash:base64:5]'
}
}
},
webpackChain(chain) {
chain.plugin('unplugin-vue-components').use(Components({
resolvers: [NutUIResolver({taro: true})]
}))
chain.merge({
plugin: {
install: {
plugin: UnifiedWebpackPluginV5,
args: [{
appType: 'taro',
// 下面个配置,会开启 rem -> rpx 的转化
rem2rpx: true,
injectAdditionalCssVarScope: true
}]
}
}
})
}
},
h5: {
publicPath: '/',
staticDirectory: 'static',
// esnextModules: ['nutui-taro', 'icons-vue-taro'],
output: {
filename: 'js/[name].[hash:8].js',
chunkFilename: 'js/[name].[chunkhash:8].js'
},
miniCssExtractPluginOption: {
ignoreOrder: true,
filename: 'css/[name].[hash].css',
chunkFilename: 'css/[name].[chunkhash].css'
},
postcss: {
autoprefixer: {
enable: true,
config: {}
},
cssModules: {
enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true
config: {
namingPattern: 'module', // 转换模式,取值为 global/module
generateScopedName: '[name]__[local]___[hash:base64:5]'
}
}
},
webpackChain(chain) {
chain.plugin('unplugin-vue-components').use(Components({
resolvers: [NutUIResolver({taro: true})]
}))
}
},
rn: {
appName: 'taroDemo',
postcss: {
cssModules: {
enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true
}
}
}
}
if (process.env.NODE_ENV === 'development') {
// 本地开发构建配置(不混淆压缩)
return merge({}, baseConfig, devConfig)
}
// 生产构建配置(默认开启压缩混淆等)
return merge({}, baseConfig, prodConfig)
})
/*
* @Date: 2025-06-28 10:33:00
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-06-28 11:00:57
* @FilePath: /myApp/config/prod.js
* @Description: 文件描述
*/
export default {
env: {
NODE_ENV: '"production"'
},
mini: {},
h5: {
/**
* WebpackChain 插件配置
* @docs https://github.com/neutrinojs/webpack-chain
*/
// webpackChain (chain) {
// /**
// * 如果 h5 端编译后体积过大,可以使用 webpack-bundle-analyzer 插件对打包体积进行分析。
// * @docs https://github.com/webpack-contrib/webpack-bundle-analyzer
// */
// chain.plugin('analyzer')
// .use(require('webpack-bundle-analyzer').BundleAnalyzerPlugin, [])
// /**
// * 如果 h5 端首屏加载时间过长,可以使用 prerender-spa-plugin 插件预加载首页。
// * @docs https://github.com/chrisvfritz/prerender-spa-plugin
// */
// const path = require('path')
// const Prerender = require('prerender-spa-plugin')
// const staticDir = path.join(__dirname, '..', 'dist')
// chain
// .plugin('prerender')
// .use(new Prerender({
// staticDir,
// routes: [ '/pages/index/index' ],
// postProcess: (context) => ({ ...context, outputPath: path.join(staticDir, 'index.html') })
// }))
// }
}
}
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@/utils/*": ["src/utils/*"],
"@/components/*": ["src/components/*"],
"@/images/*": ["src/assets/images/*"],
"@/assets/*": ["src/assets/*"],
"@/composables/*": ["src/composables/*"],
"@/api/*": ["src/api/*"],
"@/stores/*": ["src/stores/*"],
"@/hooks/*": ["src/hooks/*"]
}
},
"include": ["src/**/*", "config/**/*"]
}
{
"name": "jls-weapp",
"version": "1.0.0",
"private": true,
"description": "觉林寺微信小程序",
"templateInfo": {
"name": "vue3-NutUI",
"typescript": false,
"css": "Less",
"framework": "Vue3"
},
"scripts": {
"build:weapp": "taro build --type weapp",
"build:swan": "taro build --type swan",
"build:alipay": "taro build --type alipay",
"build:tt": "taro build --type tt",
"build:h5": "taro build --type h5",
"build:rn": "taro build --type rn",
"build:qq": "taro build --type qq",
"build:quickapp": "taro build --type quickapp",
"dev:weapp": "NODE_ENV=development taro build --type weapp --watch",
"dev:swan": "NODE_ENV=development taro build --type swan --watch",
"dev:alipay": "NODE_ENV=development taro build --type alipay --watch",
"dev:tt": "NODE_ENV=development taro build --type tt --watch",
"dev:h5": "NODE_ENV=development taro build --type h5 --watch",
"dev:rn": "NODE_ENV=development taro build --type rn --watch",
"dev:qq": "NODE_ENV=development taro build --type qq --watch",
"dev:quickapp": "NODE_ENV=development taro build --type quickapp --watch",
"postinstall": "weapp-tw patch",
"lint": "eslint --ext .js,.vue src"
},
"browserslist": [
"last 3 versions",
"Android >= 4.1",
"ios >= 8"
],
"author": "",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.7.7",
"@nutui/icons-vue-taro": "^0.0.9",
"@nutui/nutui-taro": "^4.3.13",
"@tarojs/components": "4.1.9",
"@tarojs/helper": "4.1.9",
"@tarojs/plugin-framework-vue3": "4.1.9",
"@tarojs/plugin-html": "4.1.9",
"@tarojs/plugin-platform-alipay": "4.1.9",
"@tarojs/plugin-platform-h5": "4.1.9",
"@tarojs/plugin-platform-jd": "4.1.9",
"@tarojs/plugin-platform-qq": "4.1.9",
"@tarojs/plugin-platform-swan": "4.1.9",
"@tarojs/plugin-platform-tt": "4.1.9",
"@tarojs/plugin-platform-weapp": "4.1.9",
"@tarojs/runtime": "4.1.9",
"@tarojs/shared": "4.1.9",
"@tarojs/taro": "4.1.9",
"axios-miniprogram": "^2.7.2",
"dayjs": "^1.11.19",
"pinia": "^3.0.3",
"qrcode": "^1.5.4",
"qs": "^6.14.1",
"taro-plugin-pinia": "^1.0.0",
"vue": "^3.3.0",
"xst-solar2lunar": "^2.1.0"
},
"devDependencies": {
"@babel/core": "^7.26.0",
"@nutui/auto-import-resolver": "^1.0.0",
"@tarojs/cli": "4.1.9",
"@tarojs/taro-loader": "4.1.9",
"@tarojs/webpack5-runner": "4.1.9",
"@types/webpack-env": "^1.13.6",
"@vue/babel-plugin-jsx": "^1.0.6",
"@vue/compiler-sfc": "^3.0.0",
"autoprefixer": "^10.4.21",
"babel-preset-taro": "4.1.9",
"css-loader": "3.4.2",
"eslint": "^8.12.0",
"eslint-config-taro": "4.1.9",
"less": "^4.2.0",
"postcss": "^8.5.6",
"sass": "^1.78.0",
"style-loader": "1.3.0",
"tailwindcss": "^3.4.0",
"unplugin-vue-components": "^0.26.0",
"vue-loader": "^17.0.0",
"weapp-tailwindcss": "^4.1.10",
"webpack": "5.91.0"
},
"pnpm": {
"onlyBuiltDependencies": [
"@parcel/watcher",
"@swc/core",
"@tarojs/binding",
"@tarojs/cli",
"core-js",
"core-js-pure",
"esbuild",
"weapp-tailwindcss"
]
}
}
This diff could not be displayed because it is too large.
/*
* @Date: 2025-06-30 13:27:35
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-06-30 13:27:42
* @FilePath: /myApp/postcss.config.js
* @Description: 文件描述
*/
// postcss 插件以 object 方式注册的话,是按照由上到下的顺序执行的
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
{
"miniprogramRoot": "dist/",
"projectname": "jls-weapp",
"description": "觉林寺微信小程序",
"appid": "wxf667494219504780",
"setting": {
"urlCheck": false,
"es6": false,
"enhance": false,
"compileHotReLoad": false,
"postcss": false,
"minified": false,
"nodeModules": false
},
"compileType": "miniprogram"
}
/*
* @Date: 2022-06-17 14:54:29
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2022-06-18 22:18:46
* @FilePath: /tswj/src/api/common.js
* @Description: 通用接口
*/
import { fn, fetch, uploadFn } from '@/api/fn';
const Api = {
SMS: '/srv/?a=sms',
TOKEN: '/srv/?a=upload',
SAVE_FILE: '/srv/?a=upload&t=save_file',
}
/**
* @description: 发送验证码
* @param {Object} params 请求参数
* @param {string} params.phone 手机号
* @returns {Promise<{code:number,data:any,msg:string}>} 标准返回
*/
export const smsAPI = (params) => fn(fetch.post(Api.SMS, params));
/**
* @description: 获取七牛token
* @param {Object} params 请求参数
* @param {string} params.filename 文件名
* @param {string} params.file 图片 base64
* @returns {Promise<{code:number,data:any,msg:string}>} 标准返回(data 为上传 token 等信息)
*/
export const qiniuTokenAPI = (params) => fn(fetch.stringifyPost(Api.TOKEN, params));
/**
* @description: 上传七牛
* @param {string} url 七牛上传地址
* @param {any} data 上传数据
* @param {Object} config axios 配置
* @returns {Promise<any|false>} 成功返回七牛响应数据,失败返回 false
*/
export const qiniuUploadAPI = (url, data, config) => uploadFn(fetch.basePost(url, data, config));
/**
* @description: 保存图片
* @param {Object} params 请求参数
* @param {string} params.format 文件格式
* @param {string} params.hash 文件 hash
* @param {string|number} params.height 图片高
* @param {string|number} params.width 图片宽
* @param {string} params.filekey 文件 key
* @returns {Promise<{code:number,data:any,msg:string}>} 标准返回
*/
export const saveFileAPI = (params) => fn(fetch.stringifyPost(Api.SAVE_FILE, params));
/*
* @Date: 2022-05-18 22:56:08
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-24 12:52:06
* @FilePath: /xyxBooking-weapp/src/api/fn.js
* @Description: 统一后端返回格式(强制 { code, data, msg })
*/
import axios from '@/utils/request';
import Taro from '@tarojs/taro'
import qs from 'qs'
/**
* @description 统一后端返回格式(强制 { code, data, msg })
* - code === 1 表示成功
* - 失败时统一 toast 提示(可通过 res.data.show=false 禁用提示)
* @param {Promise<any>} api axios 请求 Promise
* @returns {Promise<{code:number,data:any,msg:string,show?:boolean}>} 标准化后的返回对象
*/
export const fn = (api) => {
return api
.then(res => {
// 约定:后端 code === 1 为成功
const res_data = res && res.data ? res.data : null
if (res_data && String(res_data.code) === '1') {
return res_data
}
// 失败兜底:优先返回后端响应,同时做 toast 提示
console.warn('接口请求失败:', res)
if (res_data && res_data.show === false) return res_data
Taro.showToast({
title: (res_data && res_data.msg) ? res_data.msg : '请求失败',
icon: 'none',
duration: 2000
})
return res_data || { code: 0, data: null, msg: '请求失败' }
})
.catch(err => {
console.error('接口请求异常:', err);
return { code: 0, data: null, msg: (err && (err.msg || err.message || err.errMsg)) ? (err.msg || err.message || err.errMsg) : '网络异常' }
})
.finally(() => { // 最终执行
})
}
/**
* @description 七牛上传返回格式适配
* @param {Promise<any>} api axios 请求 Promise
* @returns {Promise<any|false>} 成功返回七牛响应数据,失败返回 false
*/
export const uploadFn = (api) => {
return api
.then(res => {
if (res.statusText === 'OK') {
return res.data || true;
} else {
console.warn('七牛上传失败:', res);
if (!res.data.show) return false;
Taro.showToast({
title: res.data.msg,
icon: 'none',
duration: 2000
});
return false;
}
})
.catch(err => {
console.error('七牛上传异常:', err);
return false;
})
}
/**
* @description 统一 GET/POST 传参形式
* - get/post:直接透传 axios
* - stringifyPost:表单提交(x-www-form-urlencoded)
* - basePost:保留自定义 config 的扩展位
*/
export const fetch = {
/**
* @description GET 请求
* @param {string} api 接口地址
* @param {Object} params 查询参数
* @returns {Promise<any>} axios Promise
*/
get: function (api, params) {
return axios.get(api, params)
},
/**
* @description POST 请求(JSON)
* @param {string} api 接口地址
* @param {Object} params 请求体
* @returns {Promise<any>} axios Promise
*/
post: function (api, params) {
return axios.post(api, params)
},
/**
* @description POST 请求(表单序列化)
* @param {string} api 接口地址
* @param {Object} params 请求体
* @returns {Promise<any>} axios Promise
*/
stringifyPost: function (api, params) {
return axios.post(api, qs.stringify(params), {
headers: {
'content-type': 'application/x-www-form-urlencoded'
}
})
},
/**
* @description POST 请求(自定义 config)
* @param {string} url 接口地址
* @param {any} data 请求体
* @param {Object} config axios 配置
* @returns {Promise<any>} axios Promise
*/
basePost: function (url, data, config) {
return axios.post(url, data, config)
}
}
import { fn, fetch } from './fn'
const Api = {
PAY_TEST: '/srv/?a=pay',
}
/**
* @description 获取微信支付参数(对齐 meihuaApp 的支付接口)
* @param {Object} params 请求参数
* @param {string} params.order_id 测试订单 ID
* @returns {Promise<{code:number,data:any,msg:string}>}
*/
export const getWechatPayParamsAPI = (params) => fn(fetch.post(Api.PAY_TEST, params))
/*
* @Author: hookehuyr hookehuyr@gmail.com
* @Date: 2022-06-09 13:32:44
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2022-06-14 14:47:01
* @FilePath: /tswj/src/api/wx/config.js
* @Description:
*/
import { fn, fetch } from '@/api/fn';
const Api = {
WX_JSAPI: '/srv/?a=wx_share',
}
/**
* @description 获取微信分享配置(wx.config 所需参数)
* @param {Object} params 请求参数
* @param {string} params.url 当前页面 URL(用于签名)
* @returns {Promise<{code:number,data:any,msg:string}>} 标准返回
*/
export const wxJsAPI = (params) => fn(fetch.get(Api.WX_JSAPI, params));
/*
* @Date: 2022-06-13 14:18:57
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2022-06-13 14:27:21
* @FilePath: /tswj/src/api/wx/jsApiList.js
* @Description: 文件描述
*/
/**
* @description 微信 JSSDK 需要注入的 API 列表
* - 该列表用于 wx.config 的 jsApiList 字段
* @type {Array<string>}
*/
export const apiList = [
"updateAppMessageShareData",
"updateTimelineShareData",
"onMenuShareTimeline",
"onMenuShareAppMessage",
"onMenuShareQQ",
"onMenuShareWeibo",
"onMenuShareQZone",
"startRecord",
"stopRecord",
"onVoiceRecordEnd",
"playVoice",
"pauseVoice",
"stopVoice",
"onVoicePlayEnd",
"uploadVoice",
"downloadVoice",
"chooseImage",
"previewImage",
"uploadImage",
"downloadImage",
"translateVoice",
"getNetworkType",
"openLocation",
"getLocation",
"hideOptionMenu",
"showOptionMenu",
"hideMenuItems",
"showMenuItems",
"hideAllNonBaseMenuItem",
"showAllNonBaseMenuItem",
"closeWindow",
"scanQRCode",
"chooseWXPay",
"openProductSpecificView",
"addCard",
"chooseCard",
"openCard"
]
/*
* @Author: hookehuyr hookehuyr@gmail.com
* @Date: 2022-06-09 13:32:44
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2022-06-09 13:42:06
* @FilePath: /tswj/src/api/wx/config.js
* @Description:
*/
import { fn, fetch } from '@/api/fn';
const Api = {
WX_PAY: '/srv/?a=icbc_pay_wxamp',
}
/**
* @description: 微信支付接口
* @param {Object} params 请求参数
* @param {string} params.pay_id 预约单支付凭证
* @returns {Promise<{code:number,data:any,msg:string}>} 标准返回(data 为微信支付参数)
*/
export const wxPayAPI = (params) => fn(fetch.post(Api.WX_PAY, params));
export default {
pages: [
'pages/index/index',
'pages/pay-test/index',
'pages/pay-bridge/index',
'pages/webview-preview/index',
'pages/auth/index',
],
window: {
backgroundTextStyle: 'light',
navigationBarBackgroundColor: '#fff',
navigationBarTitleText: '觉林寺',
navigationBarTextStyle: 'black',
},
}
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './utils/polyfill'
import './app.less'
import { saveCurrentPagePath, hasAuth, silentAuth, navigateToAuth } from '@/utils/authRedirect'
const App = createApp({
async onLaunch(options) {
const path = options?.path || ''
const query = options?.query || {}
const query_string = Object.keys(query)
.map((key) => `${key}=${encodeURIComponent(query[key])}`)
.join('&')
const full_path = query_string ? `${path}?${query_string}` : path
if (full_path) {
saveCurrentPagePath(full_path)
}
if (path === 'pages/auth/index') return
if (!hasAuth()) {
try {
await silentAuth()
} catch (error) {
console.error('静默授权失败:', error)
navigateToAuth(full_path || undefined)
}
}
},
onShow() {
},
})
App.use(createPinia())
export default App
/**
* @Date: 2025-06-28 10:33:00
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-08 20:14:14
* @FilePath: /xyxBooking-weapp/src/app.less
* @Description: 全局样式
*/
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--nut-primary-color: #A67939;
}
.modify-top {
z-index: 36;
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 20rpx;
background-image: url('http://gyzs.onwall.cn/top-xian%402x.png');
background-size: contain;
}
.content-bg {
/**
* background-color and background-image 共存,不能使用渐变色
* 图片铺平当时精度提高看看效果
* 直接用渐变色
* 不使用渐变色背景
*/
height: 100%;
min-height: 100vh;
// background-image: url('@images/bg-yellow-duan@2x.png');
background-image: url('http://gyzs.onwall.cn/bg-yellow-duan%402x.png');
// background-size: cover;
// background: linear-gradient(360deg, #FDD347 0%, #FFED6D 100%) ;
}
@namespace: 'tswj';
/* ============ 颜色 ============ */
// 主色调
@base-color: #11D2B1;
// 文字颜色
@base-font-color: #FFFFFF;
// 定义一个映射
#colors() {
base-color: @base-color;
base-font-color: @base-font-color;
}
// 混合
.width100 {
width: 100%;
}
<template>
<canvas
type="2d"
:id="canvasId"
:style="`height: ${height}rpx; width:${width}rpx;
position: absolute;
${debug ? '' : 'transform:translate3d(-9999rpx, 0, 0)'}`"
/>
</template>
<script>
import Taro from "@tarojs/taro"
import { defineComponent, onMounted, ref } from "vue"
import { drawImage, drawText, drawBlock, drawLine } from "./utils/draw.js"
import {
toPx,
toRpx,
getRandomId,
getImageInfo,
getLinearColor,
} from "./utils/tools.js"
export default defineComponent({
name: "PosterBuilder",
props: {
showLoading: {
type: Boolean,
default: false,
},
config: {
type: Object,
default: () => ({}),
},
},
emits: ["success", "fail"],
setup(props, context) {
const count = ref(1)
const {
width,
height,
backgroundColor,
texts = [],
blocks = [],
lines = [],
debug = false,
} = props.config || {}
const canvasId = getRandomId()
/**
* step1: 初始化图片资源
* @param {Array} images = imgTask
* @return {Promise} downloadImagePromise
*/
const initImages = (images) => {
const imagesTemp = images.filter((item) => item.url)
const drawList = imagesTemp.map((item, index) =>
getImageInfo(item, index)
)
return Promise.all(drawList)
}
/**
* step2: 初始化 canvas && 获取其 dom 节点和实例
* @return {Promise} resolve 里返回其 dom 和实例
*/
const initCanvas = () =>
new Promise((resolve) => {
setTimeout(() => {
const pageInstance = Taro.getCurrentInstance()?.page || {} // 拿到当前页面实例
const query = Taro.createSelectorQuery().in(pageInstance) // 确定在当前页面内匹配子元素
query
.select(`#${canvasId}`)
.fields({ node: true, size: true, context: true }, (res) => {
const canvas = res.node
const ctx = canvas.getContext("2d")
resolve({ ctx, canvas })
})
.exec()
}, 300)
})
/**
* @description 保存绘制的图片
* @param { object } config
*/
const getTempFile = (canvas) => {
Taro.canvasToTempFilePath(
{
canvas,
success: (result) => {
Taro.hideLoading()
context.emit("success", result)
},
fail: (error) => {
const { errMsg } = error
if (errMsg === "canvasToTempFilePath:fail:create bitmap failed") {
count.value += 1
if (count.value <= 3) {
getTempFile(canvas)
} else {
Taro.hideLoading()
Taro.showToast({
icon: "none",
title: errMsg || "绘制海报失败",
})
context.emit("fail", errMsg)
}
}
},
},
context
)
}
/**
* step2: 开始绘制任务
* @param { Array } drawTasks 待绘制任务
*/
const startDrawing = async (drawTasks) => {
// TODO: check
// const configHeight = getHeight(config)
const { ctx, canvas } = await initCanvas()
canvas.width = width
canvas.height = height
// 设置画布底色
if (backgroundColor) {
ctx.save() // 保存绘图上下文
const grd = getLinearColor(ctx, backgroundColor, 0, 0, width, height)
ctx.fillStyle = grd // 设置填充颜色
ctx.fillRect(0, 0, width, height) // 填充一个矩形
ctx.restore() // 恢复之前保存的绘图上下文
}
// 将要画的方块、文字、线条放进队列数组
const queue = drawTasks
.concat(
texts.map((item) => {
item.type = "text"
item.zIndex = item.zIndex || 0
return item
})
)
.concat(
blocks.map((item) => {
item.type = "block"
item.zIndex = item.zIndex || 0
return item
})
)
.concat(
lines.map((item) => {
item.type = "line"
item.zIndex = item.zIndex || 0
return item
})
)
queue.sort((a, b) => a.zIndex - b.zIndex) // 按照层叠顺序由低至高排序, 先画低的,再画高的
for (let i = 0; i < queue.length; i++) {
const drawOptions = {
canvas,
ctx,
toPx,
toRpx,
}
if (queue[i].type === "image") {
await drawImage(queue[i], drawOptions)
} else if (queue[i].type === "text") {
drawText(queue[i], drawOptions)
} else if (queue[i].type === "block") {
drawBlock(queue[i], drawOptions)
} else if (queue[i].type === "line") {
drawLine(queue[i], drawOptions)
}
}
setTimeout(() => {
getTempFile(canvas) // 需要做延时才能能正常加载图片
}, 300)
}
// start: 初始化 canvas 实例 && 下载图片资源
const init = () => {
if (props.showLoading)
Taro.showLoading({ mask: true, title: "生成中..." })
if (props.config?.images?.length) {
initImages(props.config.images)
.then((result) => {
// 1. 下载图片资源
startDrawing(result)
})
.catch((err) => {
Taro.hideLoading()
Taro.showToast({
icon: "none",
title: err.errMsg || "下载图片失败",
})
context.emit("fail", err)
})
} else {
startDrawing([])
}
}
onMounted(() => {
init()
})
return {
canvasId,
debug,
width,
height,
}
},
})
</script>
import { getLinearColor, getTextX, toPx } from './tools'
const drawRadiusRect = ({ x, y, w, h, r }, { ctx }) => {
const minSize = Math.min(w, h)
if (r > minSize / 2) r = minSize / 2
ctx.beginPath()
ctx.moveTo(x + r, y)
ctx.arcTo(x + w, y, x + w, y + h, r)
ctx.arcTo(x + w, y + h, x, y + h, r)
ctx.arcTo(x, y + h, x, y, r)
ctx.arcTo(x, y, x + w, y, r)
ctx.closePath()
}
const drawRadiusGroupRect = ({ x, y, w, h, g }, { ctx }) => {
const [
borderTopLeftRadius,
borderTopRightRadius,
borderBottomRightRadius,
borderBottomLeftRadius
] = g
ctx.beginPath()
ctx.arc(
x + w - borderBottomRightRadius,
y + h - borderBottomRightRadius,
borderBottomRightRadius,
0,
Math.PI * 0.5
)
ctx.lineTo(x + borderBottomLeftRadius, y + h)
ctx.arc(
x + borderBottomLeftRadius,
y + h - borderBottomLeftRadius,
borderBottomLeftRadius,
Math.PI * 0.5,
Math.PI
)
ctx.lineTo(x, y + borderTopLeftRadius)
ctx.arc(
x + borderTopLeftRadius,
y + borderTopLeftRadius,
borderTopLeftRadius,
Math.PI,
Math.PI * 1.5
)
ctx.lineTo(x + w - borderTopRightRadius, y)
ctx.arc(
x + w - borderTopRightRadius,
y + borderTopRightRadius,
borderTopRightRadius,
Math.PI * 1.5,
Math.PI * 2
)
ctx.lineTo(x + w, y + h - borderBottomRightRadius)
ctx.closePath()
}
const getTextWidth = (text, drawOptions) => {
const { ctx } = drawOptions
let texts = []
if (Object.prototype.toString.call(text) === '[object Object]') {
texts.push(text)
} else {
texts = text
}
let width = 0
texts.forEach(
({
fontSize,
text: textStr,
fontStyle = 'normal',
fontWeight = 'normal',
fontFamily = 'sans-serif',
marginLeft = 0,
marginRight = 0
}) => {
ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px ${fontFamily}`
width += ctx.measureText(textStr).width + marginLeft + marginRight
}
)
return width
}
const drawSingleText = (drawData, drawOptions) => {
const {
x = 0,
y = 0,
text,
color,
width,
fontSize = 28,
baseLine = 'top',
textAlign = 'left',
opacity = 1,
textDecoration = 'none',
lineNum = 1,
lineHeight = 0,
fontWeight = 'normal',
fontStyle = 'normal',
fontFamily = 'sans-serif'
} = drawData
const { ctx } = drawOptions
ctx.save()
ctx.beginPath()
ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px ${fontFamily}`
ctx.globalAlpha = opacity
ctx.fillStyle = color
ctx.textBaseline = baseLine
ctx.textAlign = textAlign
let textWidth = ctx.measureText(text).width
const textArr = []
if (textWidth > width) {
let fillText = ''
let line = 1
for (let i = 0; i <= text.length - 1; i++) {
fillText += text[i]
const nextText = i < text.length - 1 ? fillText + text[i + 1] : fillText
const restWidth = width - ctx.measureText(nextText).width
if (restWidth < 0) {
if (line === lineNum) {
if (
restWidth + ctx.measureText(text[i + 1]).width >
ctx.measureText('...').width
) {
fillText = `${fillText}...`
} else {
fillText = `${fillText.substr(0, fillText.length - 1)}...`
}
textArr.push(fillText)
break
} else {
textArr.push(fillText)
line++
fillText = ''
}
} else if (i === text.length - 1) {
textArr.push(fillText)
}
}
textWidth = width
} else {
textArr.push(text)
}
textArr.forEach((item, index) =>
ctx.fillText(
item,
getTextX(textAlign, x, width),
y + (lineHeight || fontSize) * index
)
)
ctx.restore()
if (textDecoration !== 'none') {
let lineY = y
if (textDecoration === 'line-through') {
lineY = y
}
ctx.save()
ctx.moveTo(x, lineY)
ctx.lineTo(x + textWidth, lineY)
ctx.strokeStyle = color
ctx.stroke()
ctx.restore()
}
return textWidth
}
export function drawText(params, drawOptions) {
const { x = 0, y = 0, text, baseLine } = params
if (Object.prototype.toString.call(text) === '[object Array]') {
const preText = { x, y, baseLine }
text.forEach((item) => {
preText.x += item.marginLeft || 0
const textWidth = drawSingleText(
Object.assign(item, { ...preText, y: y + (item.marginTop || 0) }),
drawOptions
)
preText.x += textWidth + (item.marginRight || 0)
})
} else {
drawSingleText(params, drawOptions)
}
}
export function drawLine(drawData, drawOptions) {
const { startX, startY, endX, endY, color, width } = drawData
const { ctx } = drawOptions
if (!width) return
ctx.save()
ctx.beginPath()
ctx.strokeStyle = color
ctx.lineWidth = width
ctx.moveTo(startX, startY)
ctx.lineTo(endX, endY)
ctx.stroke()
ctx.closePath()
ctx.restore()
}
export function drawBlock(data, drawOptions) {
const {
x,
y,
text,
width = 0,
height,
opacity = 1,
paddingLeft = 0,
paddingRight = 0,
borderWidth,
backgroundColor,
borderColor,
borderRadius = 0,
borderRadiusGroup = null
} = data || {}
const { ctx } = drawOptions
ctx.save()
ctx.globalAlpha = opacity
let blockWidth = 0
let textX = 0
let textY = 0
if (text) {
const textWidth = getTextWidth(
typeof text.text === 'string' ? text : text.text,
drawOptions
)
blockWidth = textWidth > width ? textWidth : width
blockWidth += paddingLeft + paddingLeft
const { textAlign = 'left' } = text
textY = y
textX = x + paddingLeft
if (textAlign === 'center') {
textX = blockWidth / 2 + x
} else if (textAlign === 'right') {
textX = x + blockWidth - paddingRight
}
drawText(Object.assign(text, { x: textX, y: textY }), drawOptions)
} else {
blockWidth = width
}
if (backgroundColor) {
const grd = getLinearColor(ctx, backgroundColor, x, y, blockWidth, height)
ctx.fillStyle = grd
if (borderRadius > 0) {
const drawData = {
x,
y,
w: blockWidth,
h: height,
r: borderRadius
}
drawRadiusRect(drawData, drawOptions)
ctx.fill()
} else if (borderRadiusGroup) {
const drawData = {
x,
y,
w: blockWidth,
h: height,
g: borderRadiusGroup
}
drawRadiusGroupRect(drawData, drawOptions)
ctx.fill()
} else {
ctx.fillRect(x, y, blockWidth, height)
}
}
if (borderWidth && borderRadius > 0) {
ctx.strokeStyle = borderColor
ctx.lineWidth = borderWidth
if (borderRadius > 0) {
const drawData = {
x,
y,
w: blockWidth,
h: height,
r: borderRadius
}
drawRadiusRect(drawData, drawOptions)
ctx.stroke()
} else {
ctx.strokeRect(x, y, blockWidth, height)
}
}
ctx.restore()
}
export const drawImage = (data, drawOptions) =>
new Promise((resolve) => {
const { canvas, ctx } = drawOptions
const {
x,
y,
w,
h,
sx,
sy,
sw,
sh,
imgPath,
borderRadius = 0,
borderWidth = 0,
borderColor,
borderRadiusGroup = null
} = data
ctx.save()
if (borderRadius > 0) {
drawRadiusRect(
{
x,
y,
w,
h,
r: borderRadius
},
drawOptions
)
ctx.clip()
ctx.fill()
const img = canvas.createImage()
img.src = imgPath
img.onload = () => {
ctx.drawImage(img, toPx(sx), toPx(sy), toPx(sw), toPx(sh), x, y, w, h)
if (borderWidth > 0) {
ctx.strokeStyle = borderColor
ctx.lineWidth = borderWidth
ctx.stroke()
}
resolve()
ctx.restore()
}
} else if (borderRadiusGroup) {
drawRadiusGroupRect(
{
x,
y,
w,
h,
g: borderRadiusGroup
},
drawOptions
)
ctx.clip()
ctx.fill()
const img = canvas.createImage()
img.src = imgPath
img.onload = () => {
ctx.drawImage(img, toPx(sx), toPx(sy), toPx(sw), toPx(sh), x, y, w, h)
resolve()
ctx.restore()
}
} else {
const img = canvas.createImage()
img.src = imgPath
img.onload = () => {
ctx.drawImage(img, toPx(sx), toPx(sy), toPx(sw), toPx(sh), x, y, w, h)
resolve()
ctx.restore()
}
}
})
import Taro from '@tarojs/taro'
/**
* @description 生成随机字符串(递归补齐长度)
* @param {number} length 目标长度
* @returns {string} 随机字符串
*/
export function randomString(length) {
let str = Math.random().toString(36).substr(2)
if (str.length >= length) {
return str.substr(0, length)
}
str += randomString(length - str.length)
return str
}
/**
* @description 生成随机 id(常用于 canvasId)
* @param {string} prefix 前缀
* @param {number} length 随机段长度
* @returns {string} 随机 id
*/
export function getRandomId(prefix = 'canvas', length = 10) {
return prefix + randomString(length)
}
/**
* @description 将 http 链接转换为 https(小程序部分场景要求 https)
* @param {string} rawUrl 原始 url
* @returns {string} 处理后的 url
*/
export function mapHttpToHttps(rawUrl) {
if (rawUrl.indexOf(':') < 0 || rawUrl.startsWith('http://tmp')) {
return rawUrl
}
const urlComponent = rawUrl.split(':')
if (urlComponent.length === 2) {
if (urlComponent[0] === 'http') {
urlComponent[0] = 'https'
return `${urlComponent[0]}:${urlComponent[1]}`
}
}
return rawUrl
}
/**
* @description 获取 rpx 与 px 的换算系数(以 750 设计稿为基准)
* @returns {number} 系数(screenWidth / 750)
*/
export const getFactor = () => {
const sysInfo = Taro.getSystemInfoSync()
const { screenWidth } = sysInfo
return screenWidth / 750
}
/**
* @description rpx 转 px
* @param {number} rpx rpx 值
* @param {number} factor 换算系数
* @returns {number} px 值(整数)
*/
export const toPx = (rpx, factor = getFactor()) =>
parseInt(String(rpx * factor), 10)
/**
* @description px 转 rpx
* @param {number} px px 值
* @param {number} factor 换算系数
* @returns {number} rpx 值(整数)
*/
export const toRpx = (px, factor = getFactor()) =>
parseInt(String(px / factor), 10)
/**
* @description 下载图片到本地临时路径(避免跨域/协议限制)
* - 已是本地路径/用户数据路径时直接返回
* @param {string} url 图片地址
* @returns {Promise<string>} 本地可用的图片路径
*/
export function downImage(url) {
return new Promise((resolve, reject) => {
const wx_user_data_path =
(typeof wx !== 'undefined' && wx && wx.env && wx.env.USER_DATA_PATH)
? wx.env.USER_DATA_PATH
: ''
const is_local_user_path = wx_user_data_path
? new RegExp(wx_user_data_path).test(url)
: false
if (/^http/.test(url) && !is_local_user_path) {
Taro.downloadFile({
url: mapHttpToHttps(url),
success: (res) => {
if (res.statusCode === 200) {
resolve(res.tempFilePath)
} else {
reject(res)
}
},
fail(err) {
reject(err)
}
})
} else {
resolve(url)
}
})
}
/**
* @description 获取图片信息并计算裁剪参数(居中裁剪)
* @param {Object} item 图片配置
* @param {number} index 渲染顺序(默认 zIndex)
* @returns {Promise<Object>} 标准化后的图片绘制参数
*/
export const getImageInfo = (item, index) =>
new Promise((resolve, reject) => {
const { x, y, width, height, url, zIndex } = item
downImage(url).then((imgPath) =>
Taro.getImageInfo({ src: imgPath })
.then((imgInfo) => {
let sx
let sy
const borderRadius = item.borderRadius || 0
const imgWidth = toRpx(imgInfo.width)
const imgHeight = toRpx(imgInfo.height)
if (imgWidth / imgHeight <= width / height) {
sx = 0
sy = (imgHeight - (imgWidth / width) * height) / 2
} else {
sy = 0
sx = (imgWidth - (imgHeight / height) * width) / 2
}
const result = {
type: 'image',
borderRadius,
borderWidth: item.borderWidth,
borderColor: item.borderColor,
borderRadiusGroup: item.borderRadiusGroup,
zIndex: typeof zIndex !== 'undefined' ? zIndex : index,
imgPath: url,
sx,
sy,
sw: imgWidth - sx * 2,
sh: imgHeight - sy * 2,
x,
y,
w: width,
h: height
}
resolve(result)
})
.catch((err) => {
reject(err)
})
)
})
/**
* @description 解析 linear-gradient 字符串为 canvas 渐变色
* @param {CanvasRenderingContext2D} ctx canvas 上下文
* @param {string} color 颜色字符串(支持 linear-gradient(...))
* @param {number} startX 起点 x
* @param {number} startY 起点 y
* @param {number} w 宽度
* @param {number} h 高度
* @returns {any} 普通颜色字符串或渐变对象
*/
export function getLinearColor(ctx, color, startX, startY, w, h) {
if (
typeof startX !== 'number' ||
typeof startY !== 'number' ||
typeof w !== 'number' ||
typeof h !== 'number'
) {
return color
}
let grd = color
if (color.includes('linear-gradient')) {
const colorList = color.match(/\((\d+)deg,\s(.+)\s\d+%,\s(.+)\s\d+%/)
const radian = colorList[1]
const color1 = colorList[2]
const color2 = colorList[3]
const L = Math.sqrt(w * w + h * h)
const x = Math.ceil(Math.sin(180 - radian) * L)
const y = Math.ceil(Math.cos(180 - radian) * L)
if (Number(radian) === 180 || Number(radian) === 0) {
if (Number(radian) === 180) {
grd = ctx.createLinearGradient(startX, startY, startX, startY + h)
}
if (Number(radian) === 0) {
grd = ctx.createLinearGradient(startX, startY + h, startX, startY)
}
} else if (radian > 0 && radian < 180) {
grd = ctx.createLinearGradient(startX, startY, x + startX, y + startY)
} else {
throw new Error('只支持0 <= 颜色弧度 <= 180')
}
grd.addColorStop(0, color1)
grd.addColorStop(1, color2)
}
return grd
}
/**
* @description 根据 textAlign 计算文本绘制起点 x
* @param {'left'|'center'|'right'} textAlign 对齐方式
* @param {number} x 原始 x
* @param {number} width 容器宽
* @returns {number} 计算后的 x
*/
export function getTextX(textAlign, x, width) {
let newX = x
if (textAlign === 'center') {
newX = width / 2 + x
} else if (textAlign === 'right') {
newX = width + x
}
return newX
}
<template>
<view class="index-nav" :class="[`is-${position}`]">
<view class="nav-logo is-home" :class="{ 'is-active': active === 'home' }" @tap="() => on_select('home')">
<view class="nav-icon-wrap">
<image class="nav-icon" :src="icons?.home" mode="aspectFit" />
</view>
<text class="nav-text">首页</text>
</view>
<view
class="nav-logo is-code"
:class="[{ 'is-active': active === 'code' }, { 'is-center-raised': center_variant === 'raised' }]"
@tap="() => on_select('code')"
>
<view class="nav-icon-wrap">
<image
class="nav-icon"
:class="{ 'nav-icon--raised': center_variant === 'raised' }"
:src="icons?.code"
mode="aspectFit"
/>
</view>
<text class="nav-text">预约码</text>
</view>
<view class="nav-logo is-me" :class="{ 'is-active': active === 'me' }" @tap="() => on_select('me')">
<view class="nav-icon-wrap">
<image class="nav-icon" :src="icons?.me" mode="aspectFit" />
</view>
<text class="nav-text">我的</text>
</view>
</view>
</template>
<script setup>
const props = defineProps({
icons: {
type: Object,
default: () => ({})
},
active: {
type: String,
default: ''
},
position: {
type: String,
default: 'fixed'
},
center_variant: {
type: String,
default: 'normal'
},
allow_active_tap: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['select'])
const on_select = (key) => {
if (!props.allow_active_tap && props.active && key === props.active) return
emit('select', key)
}
</script>
<style lang="less">
.index-nav {
left: 0;
bottom: 0;
width: 750rpx;
height: calc(134rpx + constant(safe-area-inset-bottom));
height: calc(134rpx + env(safe-area-inset-bottom));
padding-bottom: calc(0rpx + constant(safe-area-inset-bottom));
padding-bottom: calc(0rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
background: #FFFFFF;
box-shadow: 0 -8rpx 8rpx 0 rgba(0, 0, 0, 0.1);
display: flex;
align-items: flex-end;
justify-content: space-around;
color: #A67939;
z-index: 99;
&.is-fixed {
position: fixed;
}
&.is-absolute {
position: absolute;
}
.nav-logo {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
}
.nav-icon-wrap {
position: relative;
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
}
.nav-icon {
width: 56rpx;
height: 56rpx;
display: block;
&.nav-icon--raised {
width: 140rpx;
height: 140rpx;
position: absolute;
top: -100rpx;
left: 50%;
transform: translateX(-50%);
}
}
.nav-logo.is-home,
.nav-logo.is-me {
.nav-icon {
width: 56rpx;
height: 56rpx;
}
}
.nav-text {
font-size: 26rpx;
margin-top: 12rpx;
line-height: 1;
}
}
</style>
<!--
* @Date: 2024-01-16 10:06:47
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-24 14:12:30
* @FilePath: /xyxBooking-weapp/src/components/qrCode.vue
* @Description: 预约码卡组件
-->
<template>
<view class="qr-code-page">
<view v-if="userList.length" class="show-qrcode">
<view class="qrcode-content">
<view class="user-info">{{ userinfo.name }}&nbsp;{{ userinfo.id }}</view>
<view class="user-qrcode">
<view class="left" @tap="prevCode">
<image src="https://cdn.ipadbiz.cn/xys/booking/%E5%B7%A6@2x.png" />
</view>
<view class="center">
<image :src="currentQrCodeUrl" mode="aspectFit" />
<view v-if="useStatus === STATUS_CODE.CANCELED || useStatus === STATUS_CODE.USED" class="qrcode-used">
<view class="overlay"></view>
<text class="status-text">二维码{{ get_qrcode_status_text(useStatus) }}</text>
</view>
</view>
<view class="right" @tap="nextCode">
<image src="https://cdn.ipadbiz.cn/xys/booking/%E5%8F%B3@2x.png" />
</view>
</view>
<view style="color: red; margin-top: 32rpx;">{{ userinfo.datetime }}</view>
</view>
<view class="user-list">
<view
@tap="selectUser(index)"
v-for="(item, index) in userList"
:key="index"
:class="[
'user-item',
select_index === index ? 'checked' : '',
userList.length > 1 && item.sort ? 'border' : '',
]">
{{ item.name }}
</view>
</view>
</view>
<view v-else class="no-qrcode">
<image src="https://cdn.ipadbiz.cn/xys/booking/%E6%9A%82%E6%97%A0@2x.png" style="width: 320rpx; height: 320rpx;" />
<view class="no-qrcode-title">今天没有预约记录</view>
<view style="text-align: center; color: #A67939; margin-top: 16rpx;">查看我的“<text @tap="toRecord" style="text-decoration: underline; color: #ED9820;">预约记录</text>”</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { formatDatetime, mask_id_number, get_qrcode_status_text } from '@/utils/tools';
import { qrcodeListAPI, qrcodeStatusAPI, billPersonAPI } from '@/api/index'
import { useGo } from '@/hooks/useGo'
import BASE_URL from '@/utils/config';
const go = useGo();
const props = defineProps({
status: {
type: String,
default: ''
},
type: {
type: String,
default: ''
},
payId: { // 接收 payId
type: String,
default: ''
}
});
const select_index = ref(0);
const userList = ref([]);
/**
* @description 切换到上一张二维码(循环)
* @returns {void} 无返回值
*/
const prevCode = () => {
select_index.value = select_index.value - 1;
if (select_index.value < 0) {
select_index.value = userList.value.length - 1;
}
};
/**
* @description 切换到下一张二维码(循环)
* @returns {void} 无返回值
*/
const nextCode = () => {
select_index.value = select_index.value + 1;
if (select_index.value > userList.value.length - 1) {
select_index.value = 0;
}
};
watch(
() => select_index.value,
() => {
refreshBtn();
}
)
watch(
() => props.payId,
(val) => {
if (val) {
init();
}
},
{ immediate: true }
)
const formatId = (id) => mask_id_number(id)
const userinfo = computed(() => {
return {
name: userList.value[select_index.value]?.name,
id: formatId(userList.value[select_index.value]?.id_number),
datetime: userList.value[select_index.value]?.datetime,
};
});
const currentQrCodeUrl = computed(() => {
const url = userList.value[select_index.value]?.qr_code_url;
if (url && url.startsWith('/')) {
return BASE_URL + url;
}
return url;
})
const useStatus = ref('0');
const STATUS_CODE = {
APPLY: '1',
SUCCESS: '3',
CANCELED: '5',
USED: '7',
};
/**
* @description 刷新当前选中二维码状态
* - 仅在当前选中用户存在时请求
* @returns {Promise<void>} 无返回值
*/
const refreshBtn = async () => {
if (!userList.value[select_index.value]) return;
const { code, data } = await qrcodeStatusAPI({ qr_code: userList.value[select_index.value].qr_code });
if (code) {
useStatus.value = data.status;
}
}
/**
* @description 选择指定参观者的二维码
* @param {number} index 下标
* @returns {void} 无返回值
*/
const selectUser = (index) => {
select_index.value = index;
}
/**
* @description 按 pay_id 分组标记(用于展示分隔线)
* - 首个 pay_id 出现处标记 sort=1,其余为 sort=0
* @param {Array<Object>} data 二维码列表
* @returns {Array<Object>} 处理后的列表
*/
const formatGroup = (data) => {
let lastPayId = null;
for (let i = 0; i < data.length; i++) {
if (data[i].pay_id !== lastPayId) {
data[i].sort = 1;
lastPayId = data[i].pay_id;
} else {
data[i].sort = 0;
}
}
return data;
}
/**
* @description 初始化二维码列表
* - 不传 type:拉取“我的当日二维码列表”
* - 传入 type + payId:按订单查询二维码人员列表
* @returns {Promise<void>} 无返回值
*/
const init = async () => {
if (!props.type) {
try {
const { code, data } = await qrcodeListAPI();
if (code) {
data.forEach(item => {
item.qr_code_url = '/admin?m=srv&a=get_qrcode&key=' + item.qr_code;
item.datetime = formatDatetime({ begin_time: item.begin_time, end_time: item.end_time })
item.sort = 0;
});
// 剔除qr_code为空的二维码
const validData = data.filter(item => item.qr_code !== '');
if (validData.length > 0) {
userList.value = formatGroup(validData);
refreshBtn();
} else {
userList.value = [];
}
}
} catch (err) {
console.error('Fetch QR List Failed:', err);
}
} else {
if (props.payId) {
const { code, data } = await billPersonAPI({ pay_id: props.payId });
if (code) {
data.forEach(item => {
item.qr_code_url = '/admin?m=srv&a=get_qrcode&key=' + item.qr_code;
item.sort = 0;
// billPersonAPI 返回的数据可能没有 datetime 字段,需要检查
// 如果没有,可能需要从外部传入或者假设是当天的?
// H5 代码没有处理 datetime,但在 template 里用了。
// 这里暂且不做处理,如果没有 datetime 就不显示
});
const validData = data.filter(item => item.qr_code !== '');
if (validData.length > 0) {
userList.value = validData;
refreshBtn();
} else {
userList.value = [];
}
}
}
}
};
onMounted(() => {
init();
start_polling();
});
/**
* @description 轮询刷新二维码状态
* - 仅在“待使用”状态下轮询,避免无意义请求
* @returns {Promise<void>} 无返回值
*/
const poll = async () => {
if (userList.value.length && useStatus.value === STATUS_CODE.SUCCESS) {
if (userList.value[select_index.value]) {
const { code, data } = await qrcodeStatusAPI({ qr_code: userList.value[select_index.value].qr_code });
if (code) {
useStatus.value = data.status;
}
}
}
};
const interval_id = ref(null)
/**
* @description 启动轮询
* - 仅在当前选中用户存在时轮询
* @returns {void} 无返回值
*/
const start_polling = () => {
if (interval_id.value) return
interval_id.value = setInterval(poll, 3000)
}
/**
* @description 停止轮询
* - 组件卸载时调用,避免内存泄漏
* @returns {void} 无返回值
*/
const stop_polling = () => {
if (!interval_id.value) return
clearInterval(interval_id.value)
interval_id.value = null
}
onUnmounted(() => {
stop_polling();
});
defineExpose({ start_polling, stop_polling })
/**
* @description 跳转预约记录列表页
* @returns {void} 无返回值
*/
const toRecord = () => {
go('/bookingList');
}
</script>
<style lang="less">
.qr-code-page {
.qrcode-content {
padding: 32rpx 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: #FFF;
border-radius: 16rpx;
box-shadow: 0 0 29rpx 0 rgba(106,106,106,0.27);
.user-info {
color: #A6A6A6;
font-size: 37rpx;
margin-top: 16rpx;
margin-bottom: 16rpx;
}
.user-qrcode {
display: flex;
align-items: center;
.left {
image {
width: 56rpx; height: 56rpx; margin-right: 16rpx;
}
}
.center {
border: 2rpx solid #D1D1D1;
border-radius: 40rpx;
padding: 46rpx;
position: relative;
image {
width: 400rpx; height: 400rpx;
}
.qrcode-used {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 40rpx;
overflow: hidden;
.overlay {
width: 100%;
height: 100%;
background-image: url('https://cdn.ipadbiz.cn/xys/booking/southeast.jpeg');
background-size: contain;
opacity: 0.9;
}
.status-text {
color: #A67939;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 38rpx;
white-space: nowrap;
font-weight: bold;
z-index: 10;
}
}
}
.right {
image {
width: 56rpx; height: 56rpx;
margin-left: 16rpx;
}
}
}
}
.user-list {
display: flex;
padding: 32rpx;
align-items: center;
flex-wrap: wrap;
.user-item {
position: relative;
padding: 8rpx 16rpx;
border: 2rpx solid #A67939;
margin: 8rpx;
border-radius: 10rpx;
color: #A67939;
&.checked {
color: #FFF;
background-color: #A67939;
}
&.border {
margin-right: 16rpx;
&::after {
position: absolute;
right: -16rpx;
top: calc(50% - 16rpx);
content: '';
height: 32rpx;
border-right: 2rpx solid #A67939;
}
}
}
}
.no-qrcode {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
margin-bottom: 32rpx;
.no-qrcode-title {
color: #A67939;
font-size: 34rpx;
}
}
}
</style>
<!--
* @Date: 2024-01-16 10:06:47
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-14 21:57:40
* @FilePath: /xyxBooking-weapp/src/components/qrCodeSearch.vue
* @Description: 预约码卡组件
-->
<template>
<view class="qr-code-page">
<view v-if="userinfo.qr_code" class="show-qrcode">
<view class="qrcode-content">
<view class="user-info">{{ userinfo.name }}&nbsp;{{ userinfo.id }}</view>
<view class="user-qrcode">
<view class="left">
<!-- <image src="https://cdn.ipadbiz.cn/xys/booking/%E5%B7%A6@2x.png"> -->
</view>
<view class="center">
<image :src="userinfo.qr_code_url" mode="aspectFit" />
<view v-if="useStatus === STATUS_CODE.CANCELED || useStatus === STATUS_CODE.USED" class="qrcode-used">
<view class="overlay"></view>
<text class="status-text">二维码{{ qr_code_status[useStatus] }}</text>
</view>
</view>
<view class="right">
<!-- <image src="https://cdn.ipadbiz.cn/xys/booking/%E5%8F%B3@2x.png"> -->
</view>
</view>
<view style="color: red; margin-top: 32rpx;">{{ userinfo.datetime }}</view>
</view>
</view>
<view v-else class="no-qrcode">
<image src="https://cdn.ipadbiz.cn/xys/booking/%E6%9A%82%E6%97%A0@2x.png" style="width: 320rpx; height: 320rpx;" />
<view class="no-qrcode-title">您还没有预约过今天参观</view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted, watch, onUnmounted } from 'vue'
import { formatDatetime } from '@/utils/tools';
import { qrcodeStatusAPI, queryQrCodeAPI } from '@/api/index'
import BASE_URL from '@/utils/config';
const props = defineProps({
id: {
type: String,
default: ''
},
id_type: {
type: Number,
default: 1
}
});
const userinfo = ref({});
const replaceMiddleCharacters = (input_string) => {
if (!input_string || input_string.length < 15) {
return input_string;
}
const start = Math.floor((input_string.length - 8) / 2);
const end = start + 8;
const replacement = '*'.repeat(8);
return input_string.substring(0, start) + replacement + input_string.substring(end);
}
const formatId = (id) => replaceMiddleCharacters(id);
const useStatus = ref('0');
const is_loading = ref(false)
let is_destroyed = false
const qr_code_status = {
'1': '未激活',
'3': '待使用',
'5': '被取消',
'7': '已使用',
};
const STATUS_CODE = {
APPLY: '1',
SUCCESS: '3',
CANCELED: '5',
USED: '7',
};
const build_qr_code_url = (qr_code) => {
if (!qr_code) return ''
return `${BASE_URL}/admin?m=srv&a=get_qrcode&key=${encodeURIComponent(String(qr_code))}`
}
/**
* @description: 格式化预约码卡数据
* @param {*} raw 原始数据
* @return {*} 格式化后的数据
*/
const normalize_item = (raw) => {
if (!raw || typeof raw !== 'object') return null
const qr_code = raw.qr_code ? String(raw.qr_code) : ''
const id_number = raw.id_number ? String(raw.id_number) : ''
return {
...raw,
qr_code,
qr_code_url: build_qr_code_url(qr_code),
datetime: formatDatetime({ begin_time: raw.begin_time, end_time: raw.end_time }),
id: formatId(id_number),
}
}
/**
* @description: 重置状态
*/
const reset_state = () => {
userinfo.value = {}
useStatus.value = '0'
}
/**
* @description: 加载预约码卡状态
* @param {*} qr_code 预约码
* @return {*} 状态码
*/
const load_qr_code_status = async (qr_code) => {
if (!qr_code) return
const res = await qrcodeStatusAPI({ qr_code })
if (is_destroyed) return
if (!res || res.code !== 1) return
const status = res?.data?.status
if (status === undefined || status === null) return
useStatus.value = String(status)
}
/**
* @description: 加载预约码卡信息
* @param {*} id_number 身份证号
* @return {*} 预约码卡信息
*/
const load_qr_code_info = async (id_number) => {
const id = String(id_number || '').trim()
if (!id) {
reset_state()
return
}
is_loading.value = true
const params = { id_number: id }
if (props.id_type) params.id_type = props.id_type
const res = await queryQrCodeAPI(params)
if (is_destroyed) return
is_loading.value = false
if (!res || res.code !== 1 || !res.data) {
reset_state()
return
}
const raw = Array.isArray(res.data) ? res.data[0] : res.data
const item = normalize_item(raw)
if (!item || !item.qr_code) {
reset_state()
return
}
userinfo.value = item
await load_qr_code_status(item.qr_code)
}
onUnmounted(() => {
is_destroyed = true
})
onMounted(() => {
load_qr_code_info(props.id)
})
watch(
() => [props.id, props.id_type],
([val]) => {
if (is_loading.value) return
load_qr_code_info(val)
}
)
</script>
<style lang="less">
.qr-code-page {
.qrcode-content {
padding: 32rpx 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: #FFF;
border-radius: 16rpx;
box-shadow: 0 0 29rpx 0 rgba(106,106,106,0.27);
.user-info {
color: #A6A6A6;
font-size: 37rpx;
margin-top: 16rpx;
margin-bottom: 16rpx;
}
.user-qrcode {
display: flex;
align-items: center;
.center {
border: 2rpx solid #D1D1D1;
border-radius: 40rpx;
padding: 16rpx;
position: relative;
image {
width: 480rpx; height: 480rpx;
}
.qrcode-used {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 40rpx;
overflow: hidden;
.overlay {
width: 100%;
height: 100%;
background-image: url('https://cdn.ipadbiz.cn/xys/booking/southeast.jpeg');
background-size: contain;
opacity: 0.9;
}
.status-text {
color: #A67939;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 38rpx;
white-space: nowrap;
font-weight: bold;
z-index: 10;
}
}
}
}
}
.no-qrcode {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
margin-bottom: 32rpx;
.no-qrcode-title {
color: #A67939;
font-size: 34rpx;
}
}
}
</style>
var getDaysInOneMonth = function (year, month) {
let _month = parseInt(month, 10);
let d = new Date(year, _month, 0);
return d.getDate();
}
var dateDate = function (date) {
let year = date && date.getFullYear();
let month = date && date.getMonth() + 1;
let day = date && date.getDate();
let hours = date && date.getHours();
let minutes = date && date.getMinutes();
return {
year, month, day, hours, minutes
}
}
var dateTimePicker = function (startyear, endyear) {
// 获取date time 年份,月份,天数,小时,分钟推后30分
const years = [];
const months = [];
const hours = [];
const minutes = [];
for (let i = startyear; i <= endyear; i++) {
years.push({
name: i + '年',
id: i
});
}
//获取月份
for (let i = 1; i <= 12; i++) {
if (i < 10) {
i = "0" + i;
}
months.push({
name: i + '月',
id: i
});
}
//获取小时
for (let i = 0; i < 24; i++) {
if (i < 10) {
i = "0" + i;
}
hours.push({
name: i + '时',
id: i
});
}
//获取分钟
for (let i = 0; i < 60; i++) {
if (i < 10) {
i = "0" + i;
}
minutes.push({
name: i + '分',
id: i
});
}
return function (_year, _month) {
const days = [];
_year = parseInt(_year);
_month = parseInt(_month);
//获取日期
for (let i = 1; i <= getDaysInOneMonth(_year, _month); i++) {
if (i < 10) {
i = "0" + i;
}
days.push({
name: i + '日',
id: i
});
}
return [years, months, days, hours, minutes];
}
}
export {
dateTimePicker,
getDaysInOneMonth,
dateDate
}
<template>
<picker mode="multiSelector" :range-key="'name'" :value="timeIndex" :range="activityArray" :disabled="disabled"
@change="bindMultiPickerChange" @columnChange="bindMultiPickerColumnChange">
<slot />
</picker>
</template>
<script>
import { dateTimePicker, dateDate } from "./dateTimePicker.js";
export default {
name: "TimePickerDataPicker",
props: {
startTime: {
type: [Object, Date],
default: new Date(),
},
endTime: {
type: [Object, Date],
default: new Date(),
},
defaultTime: {
type: [Object, Date],
default: new Date(),
},
disabled: {
type: Boolean,
default: false,
},
},
data() {
return {
timeIndex: [0, 0, 0, 0, 0],
activityArray: [],
year: 0,
month: 1,
day: 1,
hour: 0,
minute: 0,
datePicker: "",
defaultIndex: [0, 0, 0, 0, 0],
startIndex: [0, 0, 0, 0, 0],
endIndex: [0, 0, 0, 0, 0],
};
},
computed: {
timeDate() {
const { startTime, endTime } = this;
return { startTime, endTime };
},
},
watch: {
timeDate() {
this.initData();
},
defaultTime () {
this.initData();
}
},
created() {
this.initData();
},
methods: {
initData() {
let startTime = this.startTime;
let endTime = this.endTime;
this.datePicker = dateTimePicker(
startTime.getFullYear(),
endTime.getFullYear()
);
this.setDateData(this.defaultTime);
this.getKeyIndex(this.startTime, "startIndex");
// 截止时间索引
this.getKeyIndex(this.endTime, "endIndex");
// 默认索引
this.getKeyIndex(this.defaultTime, "defaultIndex");
this.timeIndex = this.defaultIndex;
// 初始时间
this.initTime();
},
getKeyIndex(time, key) {
let Arr = dateDate(time);
let _index = this.getIndex(Arr);
this[key] = _index;
},
getIndex(arr) {
let timeIndex = [];
let indexKey = ["year", "month", "day", "hours", "minutes"];
this.activityArray.forEach((element, index) => {
let _index = element.findIndex(
(item) => parseInt(item.id) === parseInt(arr[indexKey[index]])
);
timeIndex[index] = _index >= 0 ? _index : 0;
});
return timeIndex;
},
initTime() {
let _index = this.timeIndex;
this.year = this.activityArray[0][_index[0]].id;
this.month = this.activityArray[1].length && this.activityArray[1][_index[1]].id;
this.day = this.activityArray[2].length && this.activityArray[2][_index[2]].id;
this.hour = this.activityArray[3].length && this.activityArray[3][_index[3]].id;
this.minute = this.activityArray[4].length && this.activityArray[4][_index[4]].id;
},
setDateData(_date) {
let _data = dateDate(_date);
this.activityArray = this.datePicker(_data.year, _data.month);
},
bindMultiPickerChange(e) {
let activityArray = JSON.parse(JSON.stringify(this.activityArray)),
{ value } = e.detail,
_result = [];
for (let i = 0; i < value.length; i++) {
_result[i] = activityArray[i][value[i]].id;
}
this.$emit("result", _result);
},
bindMultiPickerColumnChange(e) {
let _data = JSON.parse(JSON.stringify(this.activityArray)),
timeIndex = JSON.parse(JSON.stringify(this.timeIndex)),
{ startIndex, endIndex } = this,
{ column, value } = e.detail,
_value = _data[column][value].id,
_start = dateDate(this.startTime),
_end = dateDate(this.endTime);
switch (e.detail.column) {
case 0:
if (_value <= _start.year) {
timeIndex = startIndex;
this.year = _start.year;
this.setDateData(this.startTime);
} else if (_value >= _end.year) {
this.year = _end.year;
timeIndex = [endIndex[0], 0, 0, 0, 0];
this.setDateData(this.endTime);
} else {
this.year = _value;
timeIndex = [value, 0, 0, 0, 0];
this.activityArray = this.datePicker(_value, 1);
}
timeIndex = this.timeIndex = JSON.parse(JSON.stringify(timeIndex));
this.timeIndex = timeIndex;
break;
case 1:
if (this.year == _start.year && value <= startIndex[1]) {
timeIndex = startIndex;
this.month = _start.month;
this.setDateData(this.startTime);
} else if (this.year == _end.year && value >= endIndex[1]) {
timeIndex = endIndex;
this.month = _end.month;
this.setDateData(this.endTime);
} else {
this.month = _value;
_data[2] = this.datePicker(this.year, this.month)[2];
timeIndex = [timeIndex[0], value, 0, 0, 0];
this.activityArray = _data;
}
this.timeIndex = JSON.parse(JSON.stringify(timeIndex));
break;
case 2:
if (
this.year == _start.year &&
this.month == _start.month &&
value <= startIndex[2]
) {
this.day = _start.day;
timeIndex = startIndex;
} else if (
this.year == _end.year &&
this.month == _end.month &&
value >= endIndex[2]
) {
this.day = _end.day;
timeIndex = endIndex;
} else {
this.day = _value;
timeIndex = [timeIndex[0], timeIndex[1], value, 0, 0];
}
this.timeIndex = JSON.parse(JSON.stringify(timeIndex));
break;
case 3:
if (
this.year == _start.year &&
this.month == _start.month &&
this.day == _start.day &&
value <= startIndex[3]
) {
this.hour = _start.hours;
timeIndex = startIndex;
} else if (
this.year == _end.year &&
this.month == _end.month &&
this.day == _end.day &&
value >= endIndex[3]
) {
this.hour = _end.hours;
timeIndex = endIndex;
} else {
this.hour = _value;
timeIndex[3] = value;
timeIndex[4] = 0;
}
this.timeIndex = JSON.parse(JSON.stringify(timeIndex));
break;
case 4:
timeIndex[4] = value;
if (
this.year == _start.year &&
this.month == _start.month &&
this.day == _start.day &&
this.hour == _start.hours &&
value <= startIndex[4]
) {
timeIndex = startIndex;
} else if (
this.year == _end.year &&
this.month == _end.month &&
this.day == _end.day &&
this.hour == _end.hours &&
value >= endIndex[4]
) {
timeIndex = endIndex;
}
this.timeIndex = JSON.parse(JSON.stringify(timeIndex));
break;
}
},
},
};
</script>
/**
* 刷新离线预约记录缓存
* - 仅在有授权且网络可用时调用
* - 成功后将数据存储到本地缓存(key: OFFLINE_BOOKING_DATA)
* @param {boolean} force - 是否强制刷新,默认为 false
* @returns {Promise<void>}
*/
import Taro from '@tarojs/taro'
import { billOfflineAllAPI } from '@/api/index'
import { hasAuth } from '@/utils/authRedirect'
import { formatDatetime } from '@/utils/tools'
import { is_usable_network, get_network_type } from '@/utils/network'
export const OFFLINE_BOOKING_CACHE_KEY = 'OFFLINE_BOOKING_DATA'
let refresh_promise = null
/**
* @description 兼容不同后端结构:从预约记录中提取可用数据载荷
* - 部分接口会把字段塞到 bill.list 对象里,这里做一次展开合并
* @param {Object} bill 原始预约记录
* @returns {Object} 扁平化后的预约记录对象
*/
const extract_bill_payload = (bill) => {
if (!bill) return {}
const data = { ...bill }
const list = data.list
if (list && typeof list === 'object' && !Array.isArray(list)) {
return { ...list, ...data }
}
return data
}
/**
* @description 从预约记录中提取人员列表
* - 兼容不同字段名(person_list/bill_person_list/persons/qrcode_list/qr_list/detail_list)
* - 保证返回数组类型
* @param {Object} bill 预约记录
* @returns {Array} 人员列表
*/
const extract_person_list = (bill) => {
if (!bill) return []
/**
* 从预约记录中提取人员列表
* - 考虑不同字段名的情况(如 person_list, bill_person_list, persons, qrcode_list, qr_list, detail_list)
* - 确保返回的是数组类型
*/
const candidate =
(Array.isArray(bill.list) ? bill.list : null) ||
(Array.isArray(bill?.list?.list) ? bill.list.list : null) ||
bill.person_list ||
bill.bill_person_list ||
bill.persons ||
bill.qrcode_list ||
bill.qr_list ||
bill.detail_list ||
[]
return Array.isArray(candidate) ? candidate : []
}
/**
* @description 格式化预约记录项(统一字段与展示用时间)
* @param {Object} item 原始预约记录项
* @returns {Object} 格式化后的预约记录项
*/
const normalize_bill_item = (item) => {
const data = extract_bill_payload(item)
data.datetime = data.datetime || formatDatetime(data)
data.booking_time = data.booking_time || data.datetime
data.order_time = data.order_time || (data.created_time ? data.created_time.slice(0, -3) : '')
if (!data.person_name) {
const person_list = extract_person_list(item)
const first = person_list[0]
const name = first?.name || first?.person_name
if (name) data.person_name = name
}
return data
}
/**
* 获取离线预约记录缓存
* @returns {Array} 格式化后的预约记录项列表
*/
export const get_offline_booking_cache = () => {
try {
const data = Taro.getStorageSync(OFFLINE_BOOKING_CACHE_KEY)
return Array.isArray(data) ? data : []
} catch (e) {
return []
}
}
/**
* 检查是否存在离线预约记录缓存
* @returns {boolean} 是否存在缓存且非空
*/
export const has_offline_booking_cache = () => {
const list = get_offline_booking_cache()
return Array.isArray(list) && list.length > 0
}
/**
* 根据支付ID获取离线预约记录
* @param {*} pay_id 支付ID
* @returns {Object|null} 匹配的预约记录项或 null
*/
export const get_offline_booking_by_pay_id = (pay_id) => {
const list = get_offline_booking_cache()
const target_pay_id = String(pay_id || '')
return list.find((item) => String(item?.pay_id || '') === target_pay_id) || null
}
/**
* 获取预约记录中的人员列表
* @param {Object} bill - 预约记录项
* @returns {Array} 人员列表(包含姓名、身份证号、二维码等信息)
*/
export const get_offline_bill_person_list = (bill) => {
return extract_person_list(bill)
}
/**
* 构建预约记录中的二维码列表
* @param {Object} bill - 预约记录项
* @returns {Array} 二维码列表(包含姓名、身份证号、二维码、预约时间等信息)
*/
export const build_offline_qr_list = (bill) => {
const list = get_offline_bill_person_list(bill)
const datetime = bill?.datetime || formatDatetime(bill || {})
return list
.filter((item) => item && (item.qr_code || item.qrcode || item.qrCode) && (item.qr_code || item.qrcode || item.qrCode) !== '')
.map((item) => {
const begin_time = item.begin_time || bill?.begin_time
const end_time = item.end_time || bill?.end_time
const qr_code = item.qr_code || item.qrcode || item.qrCode
const name = item.name || item.person_name || item.real_name
const id_number = item.id_number || item.idcard || item.idCard || item.id
return {
name,
id_number,
qr_code,
begin_time,
end_time,
datetime: item.datetime || (begin_time && end_time ? formatDatetime({ begin_time, end_time }) : datetime),
pay_id: bill?.pay_id,
sort: 0,
}
})
}
/**
* 刷新离线预约记录缓存
* - 仅在有授权且网络可用时调用
* - 成功后将数据存储到本地缓存(key: OFFLINE_BOOKING_DATA)
* @param {boolean} force - 是否强制刷新,默认为 false. force 参数的核心作用是控制是否忽略 “正在进行的缓存请求”, 管的是 “是否允许重复发起请求”,不管 “请求能不能成功执行缓存”。
* @returns 不同情况返回值不一样
* - 成功时包含格式化后的预约记录项列表
* - 失败时包含错误信息(如网络错误、授权失败等)
*/
export const refresh_offline_booking_cache = async ({ force = false } = {}) => {
// 1. 检查是否有正在进行的刷新请求
// 2. 如果有,且 force 为 false,则直接返回该 Promise
// 3. 如果没有,或 force 为 true,则继续执行刷新逻辑
// 4. 刷新完成后,将结果存储到本地缓存(key: OFFLINE_BOOKING_CACHE_KEY)
// 5. 返回刷新结果 Promise
if (!hasAuth()) return { code: 0, data: null, msg: '未授权' }
if (refresh_promise && !force) return refresh_promise
// 核心逻辑:
// 1. 立刻触发异步逻辑,同时捕获 Promise 状态
// 2. 保证 refresh_promise 始终是 Promise 类型,适配 await
// 3. 隔离作用域,避免变量污染
// 加 () 是为了 “让异步逻辑立刻跑起来”,并把 “跑的结果(Promise)” 存起来,供后续复用和等待。
refresh_promise = (async () => {
const network_type = await get_network_type()
if (!is_usable_network(network_type)) {
return { code: 0, data: null, msg: '网络不可用' }
}
const { code, data, msg } = await billOfflineAllAPI()
if (code && Array.isArray(data)) {
// 过滤出状态为3(已完成)的记录
const normalized = data.map(normalize_bill_item).filter((item) => item && item.pay_id && item.status == 3)
if (normalized.length > 0) {
// TAG: 核心逻辑:将过滤后的记录存储到本地缓存
Taro.setStorageSync(OFFLINE_BOOKING_CACHE_KEY, normalized)
}
}
return { code, data, msg }
})()
try {
return await refresh_promise
} finally {
refresh_promise = null
}
}
/**
* @description: 轮询离线预约缓存
*/
import Taro from '@tarojs/taro'
import { refresh_offline_booking_cache } from '@/composables/useOfflineBookingCache'
import { get_network_type, is_usable_network } from '@/utils/network'
/**
* @description: 轮询状态
* @typedef {Object} PollingState
* @property {Number} timer_id 轮询定时器id
* @property {Boolean} running 是否正在轮询
* @property {Boolean} in_flight 是否正在刷新
* @property {Number} ref_count 引用计数
* @property {Boolean} app_enabled 是否启用应用
* @property {Object} last_options 最后一次选项
* @property {Boolean} network_usable 网络可用性
* @property {Boolean} has_network_listener 是否已注册网络监听器
* @property {Function} network_listener 网络监听器
* @property {Promise} network_listener_promise 网络监听器Promise
*
* 状态同步规则(app_enabled 与 ref_count):
* - app_enabled = true 时,ref_count >= 1(至少有一个使用者)
* - app_enabled = false 时,ref_count = 0(无使用者)
* - enable_offline_booking_cache_polling 会设置 app_enabled = true
* - disable_offline_booking_cache_polling 会设置 app_enabled = false
* - acquire_polling_ref 会增加 ref_count
* - release_polling_ref 会减少 ref_count,降为0时触发清理
*/
/** @type {PollingState} */
const polling_state = {
timer_id: null, // 轮询定时器id
running: false, // 是否正在轮询
in_flight: false, // 是否正在刷新
ref_count: 0, // 引用计数
app_enabled: false, // 是否启用应用
last_options: null, // 最后一次选项
network_usable: null, // 网络可用性
has_network_listener: false, // 是否已注册网络监听器
network_listener: null, // 网络监听器
network_listener_promise: null, // 网络监听器Promise
}
/**
* @description: 规范化选项参数(纯函数,无副作用)
* @param {Object} options 选项
* @return {Object} 规范化后的选项
*/
const normalize_options = (options) => {
return options || {}
}
/**
* @description: 保存最后一次选项(用于网络恢复时重启轮询)
* @param {Object} options 选项
* @return {Object} 保存后的选项
*/
const save_last_options = (options) => {
if (options) polling_state.last_options = options
return polling_state.last_options
}
/**
* 这是异步编程中典型的飞行状态锁(In-Flight Lock) 模式,是异步防重的核心思维落地方式;
* 核心逻辑:执行前 “上锁” 标记 → 执行异步操作 → 无论成败都 “解锁” 重置标记,从根源避免重复执行;
* finally 块是关键保障:防止异步操作报错导致 “永久上锁”,确保后续调用能正常执行。
*/
/**
* @description: 刷新离线预约缓存一次
* @param {Object} options 选项
* @param {Boolean} options.force 是否强制刷新
*/
const run_refresh_once = async (options) => {
// 前置检查:不满足轮询条件时直接返回(网络不可用或无引用)
if (!should_run_polling()) return
// 核心防重复——如果正在刷新,直接返回
if (polling_state.in_flight) return
// 标记为"正在刷新"
polling_state.in_flight = true
try {
await refresh_offline_booking_cache({ force: !!options?.force })
} finally {
// 刷新完成后,标记为"刷新完成"
polling_state.in_flight = false
}
}
/**
* @description: 更新网络可用性
*/
const update_network_usable = async () => {
const type = await get_network_type()
polling_state.network_usable = is_usable_network(type)
}
/**
* @description: 判断是否需要运行轮询
* @return {Boolean} 是否需要运行轮询
*
* 返回 false 的条件:
* 1. ref_count <= 0:无使用者,无需轮询
* 2. network_usable === false:网络不可用,无需轮询
* 3. network_usable === null:网络状态未初始化,避免在无监听器时误判
*
* 返回 true 的条件:
* 1. ref_count > 0:至少有一个使用者
* 2. network_usable === true:网络可用
*/
const should_run_polling = () => {
if (polling_state.ref_count <= 0) return false
if (polling_state.network_usable === false) return false
if (polling_state.network_usable === null) return false
return true
}
/**
* @description: 确保网络监听器已注册
* @return {Promise<Boolean>} 是否注册成功(true=成功,false=失败)
*/
const ensure_network_listener = async () => {
/**
* 代码优先通过两个条件判断避免重复执行监听器逻辑
* 1. 有已注册的监听器直接返回
* 2. 有未完成的注册 Promise 则直接返回
*/
if (polling_state.has_network_listener) {
await update_network_usable()
return true
}
if (polling_state.network_listener_promise) {
await polling_state.network_listener_promise
// 等待注册完成后检查是否成功
return polling_state.has_network_listener
}
// 立即执行异步的监听器注册流程(标记状态→更新网络可用性→定义回调→注册监听)
polling_state.network_listener_promise = (async () => {
// 标记已注册网络监听器
polling_state.has_network_listener = true
// 初始化时更新网络可用性
await update_network_usable()
// 网络状态变化监听器, 网络状态变化时的处理逻辑,此时只是定义,不会立即执行
polling_state.network_listener = (res) => {
const is_connected = res?.isConnected !== false
const type = res?.networkType || 'unknown'
polling_state.network_usable = is_connected && is_usable_network(type)
// 改进:不再主动停止轮询,由 run_refresh_once 中的 should_run_polling() 前置检查控制
// 优势:
// 1. 避免网络恢复时需要额外的重启逻辑
// 2. 保持定时器稳定,避免频繁启动/停止
// 3. 网络不可用时,刷新操作会在 run_refresh_once 中被前置检查过滤掉
// 网络恢复时,确保轮询正在运行(处理之前因网络不可用而可能停止的轮询)
if (polling_state.network_usable && should_run_polling()) {
// 传入 restart: true,支持重启逻辑
// 使用 normalize_options 显式处理 null/undefined,语义更清晰
start_offline_booking_cache_polling({
...normalize_options(polling_state.last_options),
restart: true
})
}
}
try {
// 注册网络状态变化监听器
Taro.onNetworkStatusChange(polling_state.network_listener)
} catch (e) {
polling_state.has_network_listener = false
polling_state.network_listener = null
polling_state.network_usable = null
console.error('注册网络监听失败:', e)
}
})()
try {
// 等待网络监听器初始化完成
await polling_state.network_listener_promise
} finally {
// 等待注册流程完成后,强制清空 Promise 缓存(finally 块),保证下次执行逻辑时状态干净
polling_state.network_listener_promise = null
}
// 返回注册是否成功
return polling_state.has_network_listener
}
/**
* @description: 注销网络监听器
* 涉及字段:
* - has_network_listener:是否有注册网络监听器
* - ref_count:引用计数
* - network_listener:网络状态变化监听器
* - network_usable:网络可用性状态
*/
const teardown_network_listener = () => {
// 1. 前置校验:避免无效执行
// 如果没有注册网络监听器,直接返回
if (!polling_state.has_network_listener) return
// 如果有引用计数,说明有其他地方在使用轮询,不能注销监听器
if (polling_state.ref_count > 0) return
// 标记监听器已注销(核心状态更新)
polling_state.has_network_listener = false
// 解绑框架层面的监听器
if (polling_state.network_listener && typeof Taro.offNetworkStatusChange === 'function') {
try {
Taro.offNetworkStatusChange(polling_state.network_listener)
} catch (e) {
// 捕获解绑失败的异常(比如监听器已被手动解绑)
console.warn('注销网络监听器失败:', e)
}
}
// 手动清空本地引用(关键!无论解绑成功/失败都要做)
// 注销后,清空网络监听器引用,确保后续调用能正常工作
polling_state.network_listener = null
/**
* 核心目的:清空 network_usable = null 是为了让状态和监听器的生命周期完全同步 —— 监听器注销后,其产生的网络状态也必须失效,避免 “无监听器却有状态” 的矛盾;
* 关键作用:通过让 should_run_polling() 直接返回 false,杜绝基于过期状态启动轮询的可能;
* 设计思维:体现了 “状态闭环” 的工程化思想 —— 任何状态都要有明确的产生、更新、销毁逻辑,不残留 “脏数据” 干扰后续流程。
*/
// 清空网络可用性状态,确保后续判断逻辑能正常工作
// 清空衍生状态,避免脏数据
polling_state.network_usable = null
}
/**
* @description: 启动离线预约缓存轮询
* @param {Object} options 选项
* @param {Number} options.interval_ms 轮询间隔,单位毫秒
* @param {Boolean} options.immediate 是否立即刷新一次
* @param {Boolean} options.force 是否强制刷新(透传给 refresh_offline_booking_cache)
* @param {Boolean} options.restart 是否为重启操作(网络恢复时调用)
*/
const start_offline_booking_cache_polling = (options) => {
options = normalize_options(options)
if (!should_run_polling()) return // 不满足轮询条件直接返回
const interval_ms = Number(options?.interval_ms || 60000)
const is_restart = options?.restart === true
// 改进:区分首次启动和重启的防重逻辑
// 首次启动时,如果已经在轮询则直接返回(防重复启动)
// 重启时,需要清除旧定时器并重新建立(支持网络恢复时重启)
if (polling_state.running && !is_restart) return
// 如果是重启或定时器已存在,先清除旧定时器
if (is_restart && polling_state.timer_id) {
clearInterval(polling_state.timer_id)
polling_state.timer_id = null
}
polling_state.running = true // 标记为"正在轮询"
// 立即刷新一次,确保轮询开始时数据是最新的
if (options?.immediate !== false) {
run_refresh_once(options)
}
// 启动轮询定时器,按照指定间隔执行刷新操作
polling_state.timer_id = setInterval(() => {
run_refresh_once(options)
}, interval_ms)
}
/**
* @description: 停止离线预约缓存轮询
*/
const stop_offline_booking_cache_polling = () => {
if (polling_state.timer_id) {
clearInterval(polling_state.timer_id)
polling_state.timer_id = null
}
polling_state.running = false
}
/**
* 引用计数的核心作用
* 这两个函数实现了轮询功能的 “引用计数式资源管理”,本质是追踪有多少 “使用者 / 场景” 依赖这个轮询功能,从而决定是否启动 / 维持 / 停止轮询、注册 / 注销网络监听器,
* 核心目的是:
* - 避免轮询被重复启动、错误停止
* - 防止无使用者时仍占用资源(定时器、网络监听器)
* - 保证多场景共用轮询时的逻辑一致性
*/
/**
* @description: 增加轮询引用计数
* 核心动作:将全局的 ref_count 加 1,代表 "又多了一个场景需要使用轮询功能"。
* @param {Object} options 选项
*/
const acquire_polling_ref = (options) => {
save_last_options(options)
polling_state.ref_count += 1
// 改进:检查网络监听器注册结果,只有成功后才启动轮询
ensure_network_listener().then((success) => {
if (success && polling_state.last_options) {
start_offline_booking_cache_polling(polling_state.last_options)
}
})
}
/**
* @description: 减少轮询引用计数
* 核心动作:将 ref_count 减 1(且保证不会为负数),代表 “有一个场景不再需要轮询功能”。
*/
const release_polling_ref = () => {
polling_state.ref_count = Math.max(0, polling_state.ref_count - 1)
if (polling_state.ref_count === 0) {
// 引用计数降为0时,停止轮询并注销网络监听器
stop_offline_booking_cache_polling()
teardown_network_listener()
}
}
/**
* @description: 启用离线预约缓存轮询
* @param {Object} options 选项
* @param {Number} options.interval_ms 轮询间隔,单位毫秒
* @param {Boolean} options.immediate 是否立即刷新一次
* @param {Boolean} options.force 是否强制刷新(透传给 refresh_offline_booking_cache)
*/
export const enable_offline_booking_cache_polling = (options) => {
save_last_options(options)
/**
* 核心目的:对 app_enabled=true 的场景做兜底,确保轮询在 "已启用但异常停止" 时能被主动恢复,而非被动等待网络变化;
* 执行逻辑:先保证网络监听器(轮询的依赖)就绪,再尝试启动轮询,且利用 start_offline_booking_cache_polling 的幂等性避免重复;
* 设计思维:体现了 "主动调用需即时生效" 的用户体验考量,以及 "依赖前置检查" 的工程化思维 —— 先保证依赖(监听器)就绪,再执行核心操作(启动轮询)。
*/
if (polling_state.app_enabled) {
ensure_network_listener().then((success) => {
if (success && polling_state.last_options) {
start_offline_booking_cache_polling(polling_state.last_options)
}
})
return
}
polling_state.app_enabled = true
acquire_polling_ref(polling_state.last_options || {})
}
/**
* @description: 禁用离线预约缓存轮询
*/
export const disable_offline_booking_cache_polling = () => {
if (!polling_state.app_enabled) return
polling_state.app_enabled = false
release_polling_ref()
}
import { computed, ref } from 'vue'
import Taro from '@tarojs/taro'
import { getWechatPayParamsAPI } from '@/api'
import { clearSessionId } from '@/utils/request'
import { hasAuth, silentAuth } from '@/utils/authRedirect'
/**
* @description 微信小程序支付能力封装
* - 可复用于测试页、WebView 容器页、后续其他按钮点击场景
* - 统一处理:授权、获取支付参数、拉起 requestPayment、状态回传
*/
export const useWechatMiniPay = () => {
const auth_loading = ref(false)
const pay_loading = ref(false)
const sessionid = ref('')
const last_result_text = ref('等待开始测试')
const is_authed = computed(() => !!sessionid.value)
const sessionid_preview = computed(() => {
if (!sessionid.value) return '暂无'
if (sessionid.value.length <= 18) return sessionid.value
return `${sessionid.value.slice(0, 8)}...${sessionid.value.slice(-8)}`
})
const update_result_text = (text, options = {}) => {
last_result_text.value = text
if (typeof options.on_status === 'function') {
options.on_status(text)
}
}
const sync_auth_state = () => {
const current_sessionid = Taro.getStorageSync('sessionid') || ''
sessionid.value = current_sessionid
return current_sessionid
}
const refresh_auth = async (options = {}) => {
auth_loading.value = true
try {
if (options.force_refresh) {
clearSessionId()
}
await silentAuth(null, null, {
show_loading: options.show_loading !== false,
})
sync_auth_state()
update_result_text('静默授权成功,可以继续测试支付。', options)
return { code: 1, msg: '静默授权成功' }
} catch (error) {
sync_auth_state()
const message = error?.message || '静默授权失败'
update_result_text(message, options)
return { code: 0, msg: message, data: error || null }
} finally {
auth_loading.value = false
}
}
const pay_by_order_id = async (raw_order_id, options = {}) => {
const normalized_order_id = String(raw_order_id || '').trim()
if (!normalized_order_id) {
const message = '请先输入订单 ID'
Taro.showToast({
title: message,
icon: 'none',
})
update_result_text(message, options)
return { code: 0, status: 'invalid', msg: message, data: null }
}
if (!hasAuth()) {
if (options.auto_auth !== false) {
const auth_res = await refresh_auth({
...options,
force_refresh: false,
show_loading: true,
})
if (!auth_res?.code) {
return {
code: 0,
status: 'auth_fail',
msg: auth_res?.msg || '授权失败',
data: auth_res?.data || null,
}
}
} else {
const message = '当前未授权,请先重新授权'
Taro.showToast({
title: message,
icon: 'none',
})
update_result_text(message, options)
return { code: 0, status: 'auth_required', msg: message, data: null }
}
} else {
sync_auth_state()
}
pay_loading.value = true
update_result_text('正在请求支付参数...', options)
try {
const pay_res = await getWechatPayParamsAPI({ order_id: normalized_order_id })
if (!pay_res?.code || !pay_res?.data) {
const message = pay_res?.msg || '获取支付参数失败'
update_result_text(message, options)
return { code: 0, status: 'params_fail', msg: message, data: pay_res?.data || null }
}
const pay_data = pay_res.data
update_result_text('已获取支付参数,准备拉起微信支付弹框...', options)
const pay_result = await new Promise((resolve) => {
Taro.requestPayment({
timeStamp: pay_data.timeStamp,
nonceStr: pay_data.nonceStr,
package: pay_data.package,
signType: pay_data.signType,
paySign: pay_data.paySign,
success: (res) => resolve({ ok: true, res }),
fail: (err) => resolve({ ok: false, err }),
})
})
if (pay_result?.ok) {
const message = '支付流程已提交成功,微信支付返回 success。'
update_result_text(message, options)
return { code: 1, status: 'success', msg: message, data: pay_result.res || null }
}
const err_msg = pay_result?.err?.errMsg || '支付未完成'
const message = `微信支付已拉起,结果:${err_msg}`
update_result_text(message, options)
const is_cancelled = String(err_msg).toLowerCase().includes('cancel')
return {
code: 0,
status: is_cancelled ? 'cancel' : 'fail',
msg: message,
data: pay_result?.err || null,
}
} catch (error) {
const message = error?.message || '拉起支付失败'
update_result_text(message, options)
return { code: 0, status: 'exception', msg: message, data: error || null }
} finally {
pay_loading.value = false
}
}
sync_auth_state()
return {
auth_loading,
pay_loading,
sessionid,
is_authed,
sessionid_preview,
last_result_text,
sync_auth_state,
refresh_auth,
pay_by_order_id,
}
}
/*
* @Date: 2026-01-06 20:47:00
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-13 11:42:00
* @FilePath: /xyxBooking-weapp/src/hooks/useGo.js
* @Description: 封装路由跳转方便行内调用
*/
import Taro from '@tarojs/taro';
/**
* @description 获取页面跳转方法(navigateTo)
* - 支持短路径:notice / /notice
* - 自动补全:pages/notice/index
* @returns {(path:string, query?:Object)=>void} go 跳转函数
*/
export function useGo () {
/**
* @description 路由跳转
* @param {string} path 目标页面路径,支持 / 开头与短路径
* @param {Object} query 查询参数(键值对)
* @returns {void} 无返回值
*/
function go (path, query = {}) {
// 补全路径,如果是 / 开头,去掉 /
let url = path.startsWith('/') ? path.substring(1) : path;
// 检查是否是 tabbar 页面 (目前没有配置 tabbar,所以都是普通跳转)
// 如果是页面,加上 pages/ 前缀 (假设都在 pages 下,且目录名和 path 一致)
// H5 path 是 /notice,小程序是 pages/notice/index
if (!url.startsWith('pages/')) {
url = `pages/${url}/index`; // 适配 pages/notice/index 结构
}
// 构建 query string
let queryString = Object.keys(query).map(key => key + '=' + query[key]).join('&');
if (queryString) {
url += '?' + queryString;
}
Taro.navigateTo({
url: '/' + url,
fail: (err) => {
// 如果是 tabbar 页面,尝试 switchTab
if (err.errMsg && err.errMsg.indexOf('tabbar') !== -1) {
Taro.switchTab({ url: '/' + url });
} else {
console.error('页面跳转失败:', err);
}
}
})
}
return go
}
/**
* @description 获取页面替换方法(redirectTo)
* - 支持短路径:notice / /notice
* - 自动补全:pages/notice/index
* @returns {(path:string, query?:Object)=>void} replace 替换函数
*/
export function useReplace () {
/**
* @description 路由替换
* @param {string} path 目标页面路径,支持 / 开头与短路径
* @param {Object} query 查询参数(键值对)
* @returns {void} 无返回值
*/
function replace (path, query = {}) {
let url = path.startsWith('/') ? path.substring(1) : path;
if (!url.startsWith('pages/')) {
url = `pages/${url}/index`;
}
let queryString = Object.keys(query).map(key => key + '=' + query[key]).join('&');
if (queryString) {
url += '?' + queryString;
}
Taro.redirectTo({
url: '/' + url
})
}
return replace
}
<!DOCTYPE html>
<html>
<head>
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
<meta content="width=device-width,initial-scale=1,user-scalable=no" name="viewport">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-touch-fullscreen" content="yes">
<meta name="format-detection" content="telephone=no,address=no">
<meta name="apple-mobile-web-app-status-bar-style" content="white">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" >
<title>myApp</title>
<script><%= htmlWebpackPlugin.options.script %></script>
</head>
<body>
<div id="app"></div>
</body>
</html>
export default {
navigationBarTitleText: '授权页',
usingComponents: {
},
}
.red {
color: red;
}
<!--
* @Date: 2022-09-19 14:11:06
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-13 00:18:41
* @FilePath: /xyxBooking-weapp/src/pages/auth/index.vue
* @Description: 授权页
-->
<template>
<view class="auth-page">
<view class="loading">
<view>正在授权登录...</view>
</view>
</view>
</template>
<script setup>
import Taro, { useDidShow } from '@tarojs/taro'
import { silentAuth, returnToOriginalPage } from '@/utils/authRedirect'
let last_try_at = 0
let has_shown_fail_modal = false
let has_failed = false
useDidShow(() => {
if (has_failed) return
const now = Date.now()
if (now - last_try_at < 1200) return
last_try_at = now
/**
* 尝试静默授权
* - 授权成功后回跳到来源页
* - 授权失败则跳转至授权页面
*/
silentAuth()
.then(() => returnToOriginalPage())
.catch(async (error) => {
has_failed = true
if (has_shown_fail_modal) return
has_shown_fail_modal = true
await Taro.showModal({
title: '提示',
content: error?.message || '授权失败,请稍后再尝试',
showCancel: false,
confirmText: '我知道了',
})
})
})
</script>
<style lang="less">
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
.loading {
text-align: center;
color: #999;
}
}
</style>
/*
* @Date: 2025-06-28 10:33:00
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-06 22:13:24
* @FilePath: /xyxBooking-weapp/src/pages/index/index.config.js
* @Description: 首页配置
*/
export default {
navigationBarTitleText: '觉林寺测试'
}
/**
* index页面样式
*/
.index {
padding: 40rpx;
.nut-button {
margin-bottom: 40rpx;
}
}
<template>
<view class="index-page">
<view class="index-header">
<text class="title">觉林寺</text>
</view>
<view class="index-body">
<text class="tip">授权和支付最小测试入口</text>
<view class="status-card">
<text class="status-label">当前授权状态</text>
<text class="status-value" :class="{ authed: is_authed }">
{{ is_authed ? '已授权' : '未授权' }}
</text>
</view>
<button class="primary-btn" @tap="goToPayTest">进入支付测试页</button>
<button class="secondary-btn" @tap="goToWebviewPreview">打开 WebView 预览页</button>
<button class="secondary-btn" @tap="refreshAuthStatus">刷新授权状态</button>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import Taro, { useDidShow } from '@tarojs/taro'
import { hasAuth } from '@/utils/authRedirect'
Taro.setNavigationBarTitle({ title: '首页' })
const is_authed = ref(false)
const refreshAuthStatus = () => {
is_authed.value = hasAuth()
}
const goToPayTest = () => {
Taro.navigateTo({
url: '/pages/pay-test/index',
})
}
const goToWebviewPreview = () => {
Taro.navigateTo({
url: '/pages/webview-preview/index',
})
}
useDidShow(() => {
refreshAuthStatus()
})
</script>
<style lang="less">
.index-page {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
background-color: #f5f5f5;
.index-header {
width: 100%;
padding: 80rpx 0 40rpx;
display: flex;
justify-content: center;
.title {
font-size: 48rpx;
font-weight: bold;
color: #333;
}
}
.index-body {
flex: 1;
display: flex;
width: 100%;
padding: 0 48rpx 80rpx;
box-sizing: border-box;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24rpx;
.tip {
font-size: 28rpx;
color: #999;
}
.status-card {
width: 100%;
padding: 32rpx;
border-radius: 24rpx;
background: #fff;
display: flex;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
box-shadow: 0 16rpx 40rpx rgba(0, 0, 0, 0.04);
.status-label {
font-size: 28rpx;
color: #666;
}
.status-value {
font-size: 30rpx;
font-weight: 600;
color: #d9485f;
}
.authed {
color: #2d8c4d;
}
}
.primary-btn,
.secondary-btn {
width: 100%;
border-radius: 999rpx;
font-size: 30rpx;
line-height: 88rpx;
}
.primary-btn {
color: #fff;
background: linear-gradient(135deg, #111827, #374151);
}
.secondary-btn {
color: #374151;
background: #fff;
border: 2rpx solid #d1d5db;
}
}
}
</style>
export default {
navigationBarTitleText: '支付处理中',
}
<template>
<view class="pay-bridge-page">
<view class="status-card">
<text class="eyebrow">支付桥页</text>
<text class="title">{{ page_title }}</text>
<text class="desc">
{{ page_desc }}
</text>
</view>
<view class="info-card">
<text class="label">订单 ID</text>
<text class="value">{{ order_id || '暂无' }}</text>
<text class="label">授权状态</text>
<text class="value" :class="{ success: is_authed }">
{{ is_authed ? '已授权' : '未授权' }}
</text>
<text class="label">当前状态</text>
<text class="value">{{ result_text }}</text>
<text class="label">结果类型</text>
<text class="value">{{ result_status }}</text>
</view>
<view v-if="should_auto_back" class="info-card">
<text class="label">返回提示</text>
<text class="value">
{{ back_countdown > 0 ? `${back_countdown} 秒后自动返回上一页` : '正在返回上一页...' }}
</text>
</view>
<button class="ghost-btn" @tap="goBackNow">立即返回上一页</button>
</view>
</template>
<script setup>
import { ref, watch, onBeforeUnmount } from 'vue'
import Taro, { useDidShow, useLoad } from '@tarojs/taro'
import { useWechatMiniPay } from '@/composables/useWechatMiniPay'
const order_id = ref('')
const result_text = ref('等待接收支付参数')
const result_status = ref('pending')
const has_started = ref(false)
const should_auto_back = ref(true)
const back_countdown = ref(0)
const back_delay_seconds = ref(2)
const back_mode = ref('navigateBack')
const back_url = ref('/pages/webview-preview/index')
const page_title = ref('正在准备微信支付')
const page_desc = ref('该页面用于承接 WebView/H5 发起的小程序支付请求,支付结束后会返回上一页。')
const {
is_authed,
last_result_text,
pay_by_order_id,
} = useWechatMiniPay()
let back_timer = null
watch(last_result_text, (value) => {
result_text.value = value
})
const clearBackTimer = () => {
if (!back_timer) return
clearInterval(back_timer)
back_timer = null
}
const goBackNow = async () => {
clearBackTimer()
try {
if (back_mode.value === 'redirectTo' && back_url.value) {
await Taro.redirectTo({ url: back_url.value })
return
}
if (back_mode.value === 'reLaunch' && back_url.value) {
await Taro.reLaunch({ url: back_url.value })
return
}
await Taro.navigateBack()
} catch (error) {
await Taro.reLaunch({
url: back_url.value || '/pages/webview-preview/index',
})
}
}
const startAutoBack = () => {
if (!should_auto_back.value) return
clearBackTimer()
back_countdown.value = back_delay_seconds.value
back_timer = setInterval(() => {
back_countdown.value -= 1
if (back_countdown.value <= 0) {
clearBackTimer()
goBackNow()
}
}, 1000)
}
const startPay = async () => {
if (!order_id.value || has_started.value) return
has_started.value = true
const pay_res = await pay_by_order_id(order_id.value, {
auto_auth: true,
on_status: (text) => {
result_text.value = text
},
})
result_status.value = pay_res?.status || 'unknown'
if (pay_res?.status === 'success') {
result_text.value = '支付成功,准备返回上一页。'
page_title.value = '支付已完成'
page_desc.value = '支付桥页已经完成本次支付,你可以等待自动返回,或手动立即返回。'
} else if (pay_res?.status === 'cancel') {
result_text.value = '你已取消支付,准备返回上一页。'
page_title.value = '支付已取消'
page_desc.value = '你刚刚取消了本次支付,如需重试,可以重新从 WebView 发起。'
} else {
result_text.value = '支付未完成,准备返回上一页。'
page_title.value = '支付未完成'
page_desc.value = '本次支付没有完成,你可以返回上一页重新发起,或保留在当前页查看状态。'
}
startAutoBack()
}
useLoad((options) => {
order_id.value = String(options?.order_id || '').trim()
should_auto_back.value = String(options?.auto_back || '1') !== '0'
back_mode.value = String(options?.back_mode || 'navigateBack')
back_url.value = String(options?.back_url || '/pages/webview-preview/index')
const parsed_back_delay = Number(options?.back_delay || 2)
back_delay_seconds.value = Number.isFinite(parsed_back_delay) && parsed_back_delay > 0
? parsed_back_delay
: 2
if (!order_id.value) {
result_text.value = '缺少订单 ID,无法发起支付。'
result_status.value = 'invalid'
page_title.value = '缺少支付参数'
page_desc.value = '当前页面没有拿到有效的订单 ID,无法继续发起支付。'
startAutoBack()
}
})
useDidShow(() => {
startPay()
})
onBeforeUnmount(() => {
clearBackTimer()
})
</script>
<style lang="less">
.pay-bridge-page {
min-height: 100vh;
padding: 32rpx 24rpx 48rpx;
box-sizing: border-box;
background:
radial-gradient(circle at top right, rgba(15, 118, 110, 0.16), transparent 30%),
linear-gradient(180deg, #f5fbfa 0%, #edf5f4 100%);
}
.status-card,
.info-card {
background: rgba(255, 255, 255, 0.94);
border-radius: 28rpx;
padding: 32rpx;
box-sizing: border-box;
border: 2rpx solid rgba(15, 23, 42, 0.05);
box-shadow: 0 18rpx 50rpx rgba(15, 23, 42, 0.06);
}
.status-card {
margin-bottom: 24rpx;
}
.info-card {
margin-bottom: 24rpx;
}
.eyebrow {
display: inline-block;
padding: 8rpx 18rpx;
border-radius: 999rpx;
background: #d1fae5;
color: #065f46;
font-size: 24rpx;
font-weight: 600;
}
.title {
display: block;
margin-top: 18rpx;
font-size: 40rpx;
font-weight: 700;
color: #111827;
}
.desc {
display: block;
margin-top: 16rpx;
font-size: 26rpx;
line-height: 1.7;
color: #6b7280;
}
.label {
display: block;
margin-top: 12rpx;
font-size: 24rpx;
color: #6b7280;
}
.value {
display: block;
margin-top: 8rpx;
font-size: 30rpx;
line-height: 1.7;
color: #111827;
word-break: break-all;
}
.success {
color: #047857;
}
.ghost-btn {
width: 100%;
border-radius: 999rpx;
font-size: 30rpx;
line-height: 88rpx;
color: #134e4a;
background: #ffffff;
border: 2rpx solid #cbd5e1;
}
</style>
export default {
navigationBarTitleText: '支付测试',
}
<template>
<view class="pay-test-page">
<view class="hero-card">
<text class="hero-title">微信支付最小测试页</text>
<text class="hero-desc">
这里仅验证授权是否可用,以及点击按钮后能否成功拉起微信支付弹框。
</text>
</view>
<view class="panel">
<view class="panel-head">
<text class="panel-title">授权状态</text>
<text class="auth-tag" :class="{ authed: is_authed }">
{{ is_authed ? '已授权' : '未授权' }}
</text>
</view>
<text class="panel-tip">
当前 sessionid:{{ sessionid_preview }}
</text>
<button class="outline-btn" :loading="auth_loading" @tap="handleRefreshAuth">
重新静默授权
</button>
</view>
<view class="panel">
<view class="panel-head">
<text class="panel-title">支付测试</text>
</view>
<text class="field-label">测试订单 ID</text>
<input
class="pay-input"
type="text"
:value="order_id"
placeholder="请输入 meihuaApp 后端里的测试订单 ID"
@input="handleOrderIdInput"
/>
<text class="panel-tip">
说明:这里只要求拉起微信支付弹框,不要求支付成功;请使用后端可生成支付参数的未支付订单。
</text>
<button class="primary-btn" :loading="pay_loading" @tap="handlePay">
点击测试拉起微信支付
</button>
</view>
<view class="panel">
<view class="panel-head">
<text class="panel-title">最近结果</text>
</view>
<text class="result-text">{{ result_text }}</text>
</view>
</view>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useDidShow, useLoad } from '@tarojs/taro'
import { useWechatMiniPay } from '@/composables/useWechatMiniPay'
const order_id = ref('')
const should_auto_pay = ref(false)
const has_auto_started = ref(false)
const {
auth_loading,
pay_loading,
is_authed,
sessionid_preview,
last_result_text,
sync_auth_state,
refresh_auth,
pay_by_order_id,
} = useWechatMiniPay()
const result_text = ref(last_result_text.value)
watch(last_result_text, (value) => {
result_text.value = value
})
const handleOrderIdInput = (event) => {
order_id.value = event?.detail?.value || ''
}
const handleRefreshAuth = async () => {
await refresh_auth({
force_refresh: true,
show_loading: true,
on_status: (text) => {
result_text.value = text
},
})
}
const handlePay = async () => {
await pay_by_order_id(order_id.value, {
auto_auth: false,
on_status: (text) => {
result_text.value = text
},
})
}
useLoad((options) => {
const route_order_id = String(options?.order_id || '').trim()
if (route_order_id) {
order_id.value = route_order_id
result_text.value = '已接收到来自 H5/WebView 的订单参数,准备测试支付。'
}
should_auto_pay.value = String(options?.auto_pay || '') === '1'
})
useDidShow(() => {
sync_auth_state()
if (should_auto_pay.value && order_id.value && !has_auto_started.value) {
has_auto_started.value = true
setTimeout(async () => {
await handlePay()
}, 300)
}
})
</script>
<style lang="less">
.pay-test-page {
min-height: 100vh;
padding: 32rpx 24rpx 48rpx;
box-sizing: border-box;
background:
radial-gradient(circle at top left, rgba(255, 214, 165, 0.45), transparent 36%),
linear-gradient(180deg, #fff9f0 0%, #f4f6fb 100%);
.hero-card,
.panel {
background: rgba(255, 255, 255, 0.92);
border: 2rpx solid rgba(17, 24, 39, 0.05);
border-radius: 28rpx;
padding: 32rpx;
box-sizing: border-box;
box-shadow: 0 20rpx 60rpx rgba(15, 23, 42, 0.06);
backdrop-filter: blur(10rpx);
}
.hero-card {
margin-bottom: 24rpx;
}
.hero-title {
display: block;
font-size: 40rpx;
font-weight: 700;
color: #111827;
}
.hero-desc {
display: block;
margin-top: 16rpx;
font-size: 26rpx;
line-height: 1.7;
color: #6b7280;
}
.panel {
margin-bottom: 24rpx;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
}
.panel-title {
font-size: 32rpx;
font-weight: 600;
color: #111827;
}
.auth-tag {
padding: 8rpx 18rpx;
border-radius: 999rpx;
font-size: 24rpx;
color: #b45309;
background: #fef3c7;
}
.authed {
color: #166534;
background: #dcfce7;
}
.field-label {
display: block;
margin-bottom: 16rpx;
font-size: 26rpx;
color: #4b5563;
}
.panel-tip,
.result-text {
display: block;
font-size: 24rpx;
line-height: 1.7;
color: #6b7280;
}
.panel-tip {
margin-top: 16rpx;
}
.pay-input {
width: 100%;
height: 88rpx;
padding: 0 24rpx;
border-radius: 20rpx;
background: #f9fafb;
border: 2rpx solid #e5e7eb;
box-sizing: border-box;
font-size: 28rpx;
color: #111827;
}
.primary-btn,
.outline-btn {
margin-top: 24rpx;
border-radius: 999rpx;
font-size: 30rpx;
line-height: 88rpx;
}
.primary-btn {
color: #fff;
background: linear-gradient(135deg, #0f766e, #115e59);
}
.outline-btn {
color: #0f172a;
background: #fff;
border: 2rpx solid #d1d5db;
}
}
</style>
export default {
navigationBarTitleText: 'WebView 预览',
}
<template>
<web-view :src="preview_url" />
</template>
<script setup>
const preview_url = 'https://oa-dev.onwall.cn/f/map/#/weapp-pay-bridge'
</script>
// Pinia 入门文档:https://pinia.vuejs.org/introduction.html
import { defineStore } from 'pinia'
/**
* @description 计数器示例 Store(模板保留)
*/
export const useCounterStore = defineStore('counter', {
state: () => {
return { count: 0 }
},
// 也可以写成:state: () => ({ count: 0 })
actions: {
/**
* @description 计数 +1
* @returns {void} 无返回值
*/
increment() {
this.count++
},
},
})
// 也可以用函数式(类似组件 setup)定义 Store,适合更复杂场景:
// export const useCounterStore = defineStore('counter', () => {
// const count = ref(0)
//
// function increment() {
// count.value++
// }
//
// return {count, increment}
// })
/*
* @Date: 2022-10-28 14:34:22
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2022-10-28 15:12:55
* @FilePath: /swx/src/stores/host.js
* @Description: 缓存主办方ID
*/
import { defineStore } from 'pinia'
/**
* @description 主办方相关缓存
* - 用于保存主办方 id / join_id 等页面间共享参数
*/
export const hostStore = defineStore('host', {
state: () => {
return {
id: '',
join_id: ''
}
},
actions: {
/**
* @description 设置主办方 id
* @param {string} id 主办方 id
* @returns {void} 无返回值
*/
add (id) {
this.id = id
},
/**
* @description 设置 join_id
* @param {string} id join_id
* @returns {void} 无返回值
*/
addJoin (id) {
this.join_id = id
},
},
})
/*
* @Date: 2022-04-18 15:59:42
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-14 20:43:54
* @FilePath: /xyxBooking-weapp/src/stores/main.js
* @Description: 文件描述
*/
import { defineStore } from 'pinia';
/**
* @description 全局主状态
* - 存储用户信息与授权态等全局数据
*/
export const mainStore = defineStore('main', {
state: () => {
return {
msg: 'Hello world',
count: 0,
auth: false,
// keepPages: ['default'], // 小程序不支持这种 keep-alive 机制
appUserInfo: null, // 用户信息
};
},
getters: {
/**
* @description 是否具备义工核销权限
* @returns {boolean} true=义工,false=非义工
*/
isVolunteer: (state) => {
return !!(state.appUserInfo && (state.appUserInfo.can_redeem === true));
},
},
actions: {
/**
* @description 更新授权状态
* @param {boolean} state 是否已授权
* @returns {void} 无返回值
*/
changeState (state) {
this.auth = state;
},
// setVolunteerStatus(status) {
// this.isVolunteer = status;
// },
// changeKeepPages () {
// this.keepPages = ['default'];
// },
// keepThisPage () {
// // 小程序路由缓存由框架控制
// },
// removeThisPage () {
// },
/**
* @description 更新全局用户信息
* @param {Object|null} info 用户信息对象
* @returns {void} 无返回值
*/
changeUserInfo (info) {
this.appUserInfo = info;
}
},
});
/*
* @Date: 2022-10-28 14:34:22
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2022-11-01 13:27:09
* @FilePath: /swx/src/stores/router.js
* @Description: 缓存路由信息
*/
import { defineStore } from 'pinia'
/**
* @description 路由回跳信息(用于授权完成后的返回)
* - authRedirect.saveCurrentPagePath 会写入 url
* - authRedirect.returnToOriginalPage 会消费并清空 url
*/
export const routerStore = defineStore('router', {
state: () => {
return {
url: '',
}
},
actions: {
/**
* @description 记录回跳路径
* @param {string} path 页面路径(可带 query)
* @returns {void} 无返回值
*/
add (path) {
this.url = path
},
/**
* @description 清空回跳路径
* @returns {void} 无返回值
*/
remove () {
this.url = ''
},
},
})
import Taro from '@tarojs/taro'
import { routerStore } from '@/stores/router'
import { buildApiUrl } from './tools'
// 改进:添加全局状态变量注释
/**
* 上一次跳转到授权页的时间戳,用于防抖(避免短时间内重复跳转)
* @type {number}
*/
let last_navigate_auth_at = 0
/**
* 是否正在跳转到授权页,用于防重复(避免并发跳转)
* @type {boolean}
*/
let navigating_to_auth = false
/**
* 授权与回跳相关工具
* - 统一管理:保存来源页、静默授权、跳转授权页、授权后回跳
* - 约定:sessionid 存在于本地缓存 key 为 sessionid
* - 说明:refreshSession/silentAuth 使用单例 Promise,避免并发重复授权
*/
/**
* 获取当前页完整路径(含 query)
* @returns {string} 当前页路径,示例:pages/index/index?a=1;获取失败返回空字符串
*/
export const getCurrentPageFullPath = () => {
const pages = Taro.getCurrentPages()
if (!pages || pages.length === 0) return ''
const current_page = pages[pages.length - 1]
const route = current_page.route
const options = current_page.options || {}
// 改进:key 也需要编码,避免特殊字符导致 URL 解析错误
const query_params = Object.keys(options)
.map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(options[key])}`)
.join('&')
return query_params ? `${route}?${query_params}` : route
}
/**
* 保存当前页路径(用于授权成功后回跳)
* @param {string} custom_path 自定义路径,不传则取当前页完整路径
* @returns {void} 无返回值
*/
export const saveCurrentPagePath = (custom_path) => {
const router = routerStore()
const path = custom_path || getCurrentPageFullPath()
router.add(path)
}
/**
* 判断是否已授权
* @returns {boolean} true=已存在 sessionid,false=需要授权
*/
export const hasAuth = () => {
try {
const sessionid = Taro.getStorageSync('sessionid')
return !!sessionid && sessionid !== ''
} catch (error) {
console.error('检查授权状态失败:', error)
return false
}
}
let auth_promise = null
/**
* 从响应中提取 cookie
* 兼容小程序端和 H5 端的不同返回格式
* @param {object} response Taro.request 响应对象
* @returns {string|null} cookie 字符串或 null
*/
const extractCookie = (response) => {
// 小程序端优先从 response.cookies 取
if (response.cookies?.[0]) return response.cookies[0]
// H5 端从 header 取(兼容不同大小写)
const cookie = response.header?.['Set-Cookie'] || response.header?.['set-cookie']
if (Array.isArray(cookie)) return cookie[0]
return cookie || null
}
/**
* 刷新会话:通过 Taro.login 获取 code,换取后端会话 cookie 并写入缓存
* - 被 request.js 的 401 拦截器调用,用于自动“静默续期 + 原请求重放”
* - 复用 auth_promise,防止多个接口同时 401 时并发触发多次登录
* @param {object} options 可选项
* @param {boolean} options.show_loading 是否展示 loading,默认 true
* @returns {Promise<{code:number,msg?:string,data?:any,cookie?:string}>} 授权结果(会把 cookie 写入 storage 的 sessionid)
*/
export const refreshSession = async (options) => {
const show_loading = options?.show_loading !== false
// 已有授权进行中时,直接复用同一个 Promise
if (auth_promise) return auth_promise
auth_promise = (async () => {
try {
if (show_loading) {
Taro.showLoading({
title: '加载中...',
mask: true,
})
}
// 调用微信登录获取临时 code
const login_result = await new Promise((resolve, reject) => {
Taro.login({
success: resolve,
fail: reject,
})
})
if (!login_result || !login_result.code) {
throw new Error('获取微信登录code失败')
}
const request_data = {
code: login_result.code,
}
// 换取后端会话(服务端通过 Set-Cookie 返回会话信息)
const response = await Taro.request({
url: buildApiUrl('openid'),
method: 'POST',
data: request_data,
})
if (!response?.data || response.data.code !== 1) {
throw new Error(response?.data?.msg || '授权失败')
}
// 改进:使用 extractCookie 函数统一处理 cookie 提取逻辑
const cookie = extractCookie(response)
if (!cookie) {
throw new Error('授权失败:没有获取到有效的会话信息')
}
// NOTE: 写入本地缓存:后续请求会从缓存取 sessionid 并带到请求头
Taro.setStorageSync('sessionid', cookie)
/**
* refreshSession() 的返回值当前没有任何业务消费点:在 request.js 里只是 await refreshSession() ,不解构、不使用;其他地方也没直接调用它
* 所以 return { ...response.data, cookie } 目前属于“严谨保留”:方便未来需要拿 cookie / code / msg 做埋点、提示、分支处理时直接用(例如授权页显示更细错误、统计刷新成功率等)。
*/
return {
...response.data,
cookie,
}
} finally {
if (show_loading) {
Taro.hideLoading()
}
}
})().finally(() => {
auth_promise = null
})
return auth_promise
}
/**
* 执行静默授权:检查是否已授权,若否则调用 refreshSession 刷新会话
* @param {boolean} show_loading 是否展示 loading,默认 true
* @returns {Promise<{code:number,msg?:string,data?:any,cookie?:string}>} 授权结果
*
* 改进:使用下划线前缀表示私有函数,仅供 silentAuth 内部使用
*/
const _do_silent_auth = async (show_loading) => {
// 已有 sessionid 时直接视为已授权
if (hasAuth()) {
return { code: 1, msg: '已授权' }
}
// 需要授权时,走刷新会话逻辑
return await refreshSession({ show_loading })
}
/**
* 静默授权:用于启动阶段/分享页/授权页发起授权
* - 与 refreshSession 共用 auth_promise,避免并发重复调用
* @param {(result: any) => void} on_success 成功回调(可选)
* @param {(error: {message:string, original:Error}) => void} on_error 失败回调(可选,入参为错误对象)
* @param {object} options 可选项
* @param {boolean} options.show_loading 是否展示 loading,默认 true
* @returns {Promise<any>} 授权结果(成功 resolve,失败 reject)
*/
export const silentAuth = async (on_success, on_error, options) => {
const show_loading = options?.show_loading !== false
try {
// 未有授权进行中时才发起一次授权,并复用 Promise
if (!auth_promise) {
/**
* 用 auth_promise 做"单例锁",把同一时刻并发触发的多次授权合并成一次。
* 把正在执行的授权 Promise 存起来;后面如果又有人调用 silentAuth() ,
* 看到 auth_promise 不为空,就直接 await 同一个 Promise,避免同时发起多次 Taro.login / 换会话请求
* ---------------------------------------------------------------------------------------
* .finally(() => { auth_promise = null }) 不管授权成功还是失败(resolve/reject),都把"锁"释放掉。
* 不用 finally 的问题:如果授权失败抛错了,而你只在 .then 里清空,那么 auth_promise 会一直卡着旧的 rejected Promise;
* 后续再调用 silentAuth() 会复用这个失败的 Promise,导致永远失败、且永远不会重新发起授权。
* 用 finally :保证成功/失败都会清空,下一次调用才有机会重新走授权流程。
*/
auth_promise = _do_silent_auth(show_loading)
.finally(() => {
auth_promise = null
})
}
const result = await auth_promise
if (on_success) on_success(result)
/**
* 当前返回值 没有实际消费点 :全项目只在 3 处调用,全部都 不使用返回值 。
* - 启动预加载: await silentAuth() 仅等待,不用结果, app.js
* - 授权页: silentAuth().then(() => returnToOriginalPage()) then 里也没接 res , auth/index.vue
* - 分享场景: await silentAuth(successCb, errorCb) 只看成功/失败分支,不用返回值, handleSharePageAuth
* 所以这行 return result 的作用目前是 语义完整 + 未来扩展位 :
* 如果以后要在调用处根据 code/msg/cookie 做分支或埋点,返回值就能直接用;现在等价于"只用 resolve/reject 表达成功失败"。
*/
return result
} catch (error) {
// 改进:统一传递完整错误对象,包含 message 和 original error
const error_obj = {
message: error?.message || '授权失败,请稍后重试',
original: error
}
if (on_error) on_error(error_obj)
throw error
}
}
/**
* 防重复跳转冷却时间 (毫秒)
* @type {number}
*/
const NAVIGATE_AUTH_COOLDOWN_MS = 1200
/**
* 导航状态重置延迟时间 (毫秒)
* @type {number}
*/
const NAVIGATING_RESET_DELAY_MS = 300
/**
* 跳转到授权页(降级方案)
* - 会先保存回跳路径(默认当前页),授权成功后在 auth 页回跳
* @param {string} return_path 指定回跳路径(可选)
* @returns {Promise<void>} 无返回值
*/
export const navigateToAuth = async (return_path) => {
const pages = Taro.getCurrentPages()
const current_page = pages[pages.length - 1]
const current_route = current_page?.route
if (current_route === 'pages/auth/index') {
return
}
const now = Date.now()
if (navigating_to_auth) return
if (now - last_navigate_auth_at < NAVIGATE_AUTH_COOLDOWN_MS) return
last_navigate_auth_at = now
navigating_to_auth = true
if (return_path) {
saveCurrentPagePath(return_path)
} else {
saveCurrentPagePath()
}
// 改进:使用 try-finally 明确状态恢复逻辑,确保无论成功失败都会重置状态
try {
await Taro.navigateTo({ url: '/pages/auth/index' })
} catch (error) {
// 改进:添加错误日志,方便追踪降级场景
console.warn('navigateTo 失败,降级使用 redirectTo:', error)
await Taro.redirectTo({ url: '/pages/auth/index' })
} finally {
setTimeout(() => {
navigating_to_auth = false
}, NAVIGATING_RESET_DELAY_MS)
}
}
/**
* 授权成功后回跳到来源页
* - 优先使用 routerStore 里保存的路径
* - 失败降级:redirectTo -> reLaunch
* @param {string} default_path 未保存来源页时的默认回跳路径
* @returns {Promise<void>} 回跳完成
*/
export const returnToOriginalPage = async (default_path = '/pages/index/index') => {
const router = routerStore()
const saved_path = router.url
try {
router.remove()
const pages = Taro.getCurrentPages()
const current_page = pages[pages.length - 1]
const current_route = current_page?.route
let target_path = default_path
if (saved_path && saved_path !== '') {
target_path = saved_path.startsWith('/') ? saved_path : `/${saved_path}`
}
const target_route = target_path.split('?')[0].replace(/^\//, '')
if (current_route === target_route) {
return
}
try {
await Taro.redirectTo({ url: target_path })
} catch (error) {
// 改进:添加错误日志,方便追踪降级场景
console.warn('redirectTo 失败,降级使用 reLaunch:', error)
await Taro.reLaunch({ url: target_path })
}
} catch (error) {
console.error('returnToOriginalPage 执行出错:', error)
try {
await Taro.reLaunch({ url: default_path })
} catch (final_error) {
console.error('最终降级方案也失败了:', final_error)
}
}
}
/**
* 判断是否来自分享场景
* @param {object} options 页面 options
* @returns {boolean} true=来自分享场景,false=非分享场景
*/
export const isFromShare = (options) => {
return options && (options.from_share === '1' || options.scene)
}
/**
* 分享页进入时的授权处理
* - 来自分享且未授权:保存当前页路径,授权成功后回跳
* - 授权失败:返回 false,由调用方决定是否继续降级处理
* @param {object} options 页面 options
* @param {Function} callback 授权成功后的继续逻辑(可选)
* @returns {Promise<boolean>} true=授权已完成/无需授权,false=授权失败
*/
export const handleSharePageAuth = async (options, callback) => {
if (hasAuth()) {
if (typeof callback === 'function') callback()
return true
}
if (isFromShare(options)) {
saveCurrentPagePath()
}
try {
await silentAuth(
() => {
if (typeof callback === 'function') callback()
},
() => {
navigateToAuth()
}
)
return true
} catch (error) {
navigateToAuth()
return false
}
}
/**
* 为路径追加分享标记
* @param {string} path 原路径
* @returns {string} 追加后的路径
*/
export const addShareFlag = (path) => {
const separator = path.includes('?') ? '&' : '?'
return `${path}${separator}from_share=1`
}
/*
* @Description: 服务器环境配置
* @Note: 对齐 meihuaApp 的最小授权/支付测试环境
*/
/**
* @description 接口基础域名
* - 授权与支付测试均复用 meihuaApp 所在后端
* @type {string}
*/
const BASE_URL = 'https://oa.onwall.cn'
/**
* @description 接口默认公共参数
* - f/client_id 与 meihuaApp 保持一致,确保 openid 与 pay 接口可用
*/
export const REQUEST_DEFAULT_PARAMS = {
f: 'room',
client_id: '772428',
}
export default BASE_URL
/*
* @Date: 2022-10-13 22:36:08
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-24 12:53:30
* @FilePath: /xyxBooking-weapp/src/utils/mixin.js
* @Description: 全局 mixin(兼容保留)
*/
import { getSessionId, setSessionId, clearSessionId } from './request';
/**
* @description 全局 mixin(兼容保留)
* - 早期版本用于手动管理 sessionid
* - 当前项目 sessionid 由 request.js 拦截器自动注入
* - 该文件主要作为“旧代码兼容层”保留
*/
export default {
// 初始化入口(如需全局混入逻辑可写在这里)
init: {
created () {
// 说明:sessionid 现在由 request.js 的拦截器自动管理
// 如需在组件创建时做通用初始化,可在此补充
}
}
};
/**
* @description 导出 sessionid 管理工具(供极端场景手动处理)
* - 正常业务不建议直接调用
*/
export { getSessionId, setSessionId, clearSessionId }
/*
* @Date: 2026-01-13 15:34:47
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-13 15:36:37
* @FilePath: /xyxBooking-weapp/src/utils/network.js
* @Description: 网络相关工具函数
*/
import Taro from '@tarojs/taro'
/**
* @description: 判断网络是否可用(wifi, 4g, 5g, 3g)
* @param {string} network_type - 网络类型
* @returns {boolean} 是否可用
*/
export const is_usable_network = (network_type) => {
return ['wifi', '4g', '5g', '3g'].includes(network_type)
}
/**
* @description: 获取当前网络类型
* @returns {Promise<string>} 网络类型(wifi, 4g, 5g, 3g, unknown)
*/
export const get_network_type = async () => {
try {
const result = await new Promise((resolve, reject) => {
Taro.getNetworkType({
success: resolve,
fail: reject,
})
})
return result?.networkType || 'unknown'
} catch (e) {
return 'unknown'
}
}
/*
* @Date: 2026-01-07 21:19:46
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-24 12:44:01
* @FilePath: /xyxBooking-weapp/src/utils/polyfill.js
* @Description: 文件描述
*/
/**
* @description 小程序环境 Polyfill:TextEncoder/TextDecoder
* - 部分运行时(尤其是低版本基础库)可能缺少 TextEncoder/TextDecoder
* - NFC/二维码等场景会用到 TextDecoder/TextEncoder(例如解析 NDEF)
*/
if (typeof TextEncoder === 'undefined') {
class TextEncoder {
/**
* @description 将字符串编码为 UTF-8 字节数组
* @param {string} str 待编码字符串
* @returns {Uint8Array} UTF-8 字节数组
*/
encode(str) {
const len = str.length;
const res = [];
for (let i = 0; i < len; i++) {
let point = str.charCodeAt(i);
if (point <= 0x007f) {
res.push(point);
} else if (point <= 0x07ff) {
res.push(0xc0 | (point >>> 6));
res.push(0x80 | (0x3f & point));
} else if (point <= 0xffff) {
res.push(0xe0 | (point >>> 12));
res.push(0x80 | (0x3f & (point >>> 6)));
res.push(0x80 | (0x3f & point));
} else {
point = 0x10000 + ((point - 0xd800) << 10) + (str.charCodeAt(++i) - 0xdc00);
res.push(0xf0 | (point >>> 18));
res.push(0x80 | (0x3f & (point >>> 12)));
res.push(0x80 | (0x3f & (point >>> 6)));
res.push(0x80 | (0x3f & point));
}
}
return new Uint8Array(res);
}
}
if (typeof globalThis !== 'undefined') {
globalThis.TextEncoder = TextEncoder;
}
if (typeof global !== 'undefined') {
global.TextEncoder = TextEncoder;
}
if (typeof window !== 'undefined') {
window.TextEncoder = TextEncoder;
}
}
if (typeof TextDecoder === 'undefined') {
class TextDecoder {
/**
* @description 将字节数据解码为字符串(简化 UTF-8 解码)
* @param {ArrayBuffer|Uint8Array} view 字节数据
* @param {Object} options 预留参数(与标准接口对齐)
* @returns {string} 解码后的字符串
*/
decode(view, options) {
void options;
if (!view) {
return '';
}
let string = '';
const arr = new Uint8Array(view);
for (let i = 0; i < arr.length; i++) {
string += String.fromCharCode(arr[i]);
}
try {
// 简单的 UTF-8 解码尝试
return decodeURIComponent(escape(string));
} catch (e) {
return string;
}
}
}
if (typeof globalThis !== 'undefined') {
globalThis.TextDecoder = TextDecoder;
}
if (typeof global !== 'undefined') {
global.TextDecoder = TextDecoder;
}
if (typeof window !== 'undefined') {
window.TextDecoder = TextDecoder;
}
}
/*
* @Date: 2022-09-19 14:11:06
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-13 21:29:43
* @FilePath: /xyxBooking-weapp/src/utils/request.js
* @Description: 简单axios封装,后续按实际处理
*/
// import axios from 'axios'
import axios from 'axios-miniprogram';
import Taro from '@tarojs/taro'
// import qs from 'qs'
// import { strExist } from './tools'
import { refreshSession, saveCurrentPagePath, navigateToAuth } from './authRedirect'
import { get_weak_network_modal_no_cache_options } from '@/utils/uiText'
import { parseQueryString } from './tools'
// import { ProgressStart, ProgressEnd } from '@/components/axios-progress/progress';
// import store from '@/store'
// import { getToken } from '@/utils/auth'
import BASE_URL, { REQUEST_DEFAULT_PARAMS } from './config';
/**
* @description 获取 sessionid 的工具函数
* - sessionid 由 authRedirect.refreshSession 写入
* - 每次请求前动态读取,避免旧会话导致的 401
* @returns {string|null} sessionid或null
*/
export const getSessionId = () => {
try {
return Taro.getStorageSync("sessionid") || null;
} catch (error) {
console.error('获取sessionid失败:', error);
return null;
}
};
/**
* @description 设置 sessionid(一般不需要手动调用)
* - 正常情况下由 authRedirect.refreshSession 写入
* - 保留该方法用于极端场景的手动修复/兼容旧逻辑
* @param {string} sessionid cookie 字符串
* @returns {void} 无返回值
*/
export const setSessionId = (sessionid) => {
try {
if (!sessionid) return
Taro.setStorageSync('sessionid', sessionid)
} catch (error) {
console.error('设置sessionid失败:', error)
}
}
/**
* @description 清空 sessionid(一般不需要手动调用)
* @returns {void} 无返回值
*/
export const clearSessionId = () => {
try {
Taro.removeStorageSync('sessionid')
} catch (error) {
console.error('清空sessionid失败:', error)
}
}
// const isPlainObject = (value) => {
// if (value === null || typeof value !== 'object') return false
// return Object.prototype.toString.call(value) === '[object Object]'
// }
/**
* @description axios 实例(axios-miniprogram)
* - 统一 baseURL / timeout
* - 通过拦截器处理:默认参数、cookie 注入、401 自动续期、弱网降级
*/
const service = axios.create({
baseURL: BASE_URL, // url = base url + request url
// withCredentials: true, // send cookies when cross-domain requests
timeout: 5000, // request timeout
})
// service.defaults.params = {
// ...REQUEST_DEFAULT_PARAMS,
// };
let has_shown_timeout_modal = false
/**
* @description 判断是否为超时错误
* @param {Error} error 请求错误对象
* @returns {boolean} true=超时,false=非超时
*/
const is_timeout_error = (error) => {
const msg = String(error?.message || error?.errMsg || '')
if (error?.code === 'ECONNABORTED') return true
return msg.toLowerCase().includes('timeout')
}
/**
* @description 判断是否为网络错误(断网/弱网/请求失败等)
* @param {Error} error 请求错误对象
* @returns {boolean} true=网络错误,false=非网络错误
*/
const is_network_error = (error) => {
const msg = String(error?.message || error?.errMsg || '')
const raw = (() => {
try {
return JSON.stringify(error) || ''
} catch (e) {
return ''
}
})()
const lower = (msg + ' ' + raw).toLowerCase()
if (lower.includes('request:fail')) return true
if (lower.includes('request fail')) return true
if (lower.includes('network error')) return true
if (lower.includes('failed to fetch')) return true
if (lower.includes('the internet connection appears to be offline')) return true
if (lower.includes('err_blocked_by_client')) return true
if (lower.includes('blocked_by_client')) return true
return false
}
/**
* @description 是否需要触发弱网/断网降级逻辑
* - 超时:直接触发
* - 网络错误:直接触发(避免 wifi 但无网场景漏判)
* @param {Error} error 请求错误对象
* @returns {Promise<boolean>} true=需要降级,false=不需要
*/
const should_handle_bad_network = async (error) => {
if (is_timeout_error(error)) return true
return is_network_error(error)
}
/**
* @description 处理请求超时/弱网错误
* - 优先:若存在离线预约记录缓存,直接跳转离线预约列表页
* - 否则:弹出弱网提示(统一文案由 uiText 管理)
* @returns {Promise<void>} 无返回值
*/
const handle_request_timeout = async () => {
if (has_shown_timeout_modal) return
has_shown_timeout_modal = true
try {
await Taro.showModal(get_weak_network_modal_no_cache_options())
} catch (e) {
console.error('show weak network modal failed:', e)
}
}
// 请求拦截器:合并默认参数 / 注入 cookie
service.interceptors.request.use(
config => {
// console.warn(config)
// console.warn(store)
// 解析 URL 参数并合并
const url = config.url || ''
let url_params = {}
if (url.includes('?')) {
url_params = parseQueryString(url)
config.url = url.split('?')[0]
}
// 优先级:调用传参 > URL参数 > 默认参数
config.params = {
...REQUEST_DEFAULT_PARAMS,
...url_params,
...(config.params || {})
}
/**
* 动态获取 sessionid 并设置到请求头
* - 确保每个请求都带上最新的 sessionid
* - 注意:axios-miniprogram 的 headers 可能不存在,需要先兜底
*/
const sessionid = getSessionId();
if (sessionid) {
config.headers = config.headers || {}
config.headers.cookie = sessionid;
}
// 增加时间戳
if (config.method === 'get') {
config.params = { ...config.params, timestamp: (new Date()).valueOf() }
}
// if ((config.method || '').toLowerCase() === 'post') {
// const url = config.url || ''
// const headers = config.headers || {}
// const contentType = headers['content-type'] || headers['Content-Type']
// const shouldUrlEncode =
// !contentType || String(contentType).includes('application/x-www-form-urlencoded')
// if (shouldUrlEncode && !strExist(['upload.qiniup.com'], url) && isPlainObject(config.data)) {
// config.headers = {
// ...headers,
// 'content-type': 'application/x-www-form-urlencoded'
// }
// config.data = qs.stringify(config.data)
// }
// }
return config
},
error => {
console.error('请求拦截器异常:', error)
return Promise.reject(error)
}
)
// 响应拦截器:401 自动续期 / 弱网降级
service.interceptors.response.use(
/**
* 响应拦截器说明
* - 这里统一处理后端自定义 code(例如 401 未授权)
* - 如需拿到 headers/status 等原始信息,直接返回 response 即可
*/
async response => {
const res = response.data
// 401 未授权处理
if (res.code === 401) {
const config = response?.config || {}
/**
* 避免死循环/重复重试:
* - __is_retry:本次请求是 401 后的重试请求,如果仍 401,不再继续重试
*/
if (config.__is_retry) {
return response
}
/**
* 记录来源页:用于授权成功后回跳
* - 避免死循环:如果已经在 auth 页则不重复记录/跳转
*/
const pages = Taro.getCurrentPages();
const currentPage = pages[pages.length - 1];
if (currentPage && currentPage.route !== 'pages/auth/index') {
saveCurrentPagePath()
}
try {
// 优先走静默续期:成功后重放原请求
await refreshSession()
const retry_config = { ...config, __is_retry: true }
return await service(retry_config)
} catch (error) {
// 静默续期失败:降级跳转到授权页(由授权页完成授权并回跳)
const pages_retry = Taro.getCurrentPages();
const current_page_retry = pages_retry[pages_retry.length - 1];
if (current_page_retry && current_page_retry.route !== 'pages/auth/index') {
navigateToAuth()
}
return response
}
}
return response
},
async error => {
// Taro.showToast({
// title: error.message,
// icon: 'none',
// duration: 2000
// })
if (await should_handle_bad_network(error)) {
handle_request_timeout()
}
return Promise.reject(error)
}
)
export default service
/*
* @Date: 2022-04-18 15:59:42
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-23 15:18:16
* @FilePath: /git/xyxBooking-weapp/src/utils/tools.js
* @Description: 工具函数库
*/
import dayjs from 'dayjs';
import Taro from '@tarojs/taro';
import BASE_URL, { REQUEST_DEFAULT_PARAMS } from './config'
/**
* @description 格式化时间
* @param {string|number|Date} date 时间入参
* @returns {string} 格式化后的时间字符串(YYYY-MM-DD HH:mm)
*/
const formatDate = (date) => {
return dayjs(date).format('YYYY-MM-DD HH:mm');
};
/**
* @description 判断设备信息
* @returns {Object} 设备信息对象,包含是否为 Android、iOS、是否为平板等属性
*/
const wxInfo = () => {
const info = Taro.getSystemInfoSync();
const isAndroid = info.platform === 'android';
const isiOS = info.platform === 'ios';
// 说明:当前项目只用到 Android/iOS 区分;平板能力按需补充
return {
isAndroid,
isiOS,
isTable: false // 小程序通常不是 tablet 模式,或者可以根据 screenWidth 判断
};
};
/**
* @description 解析 URL 参数
* @param {string} url 完整 URL 或带 query 的路径
* @returns {Object} URL 参数对象(键值对)
*/
const parseQueryString = url => {
if (!url) return {};
var json = {};
var arr = url.indexOf('?') >= 0 ? url.substr(url.indexOf('?') + 1).split('&') : [];
arr.forEach(item => {
var tmp = item.split('=');
json[tmp[0]] = tmp[1];
});
return json;
}
/**
* @description 判断字符串是否包含数组中的任意子串
* @param {Array<string>} array 子串数组
* @param {string} str 目标字符串
* @returns {boolean} true=包含任意一个子串,false=都不包含
*/
const strExist = (array, str) => {
if (!str) return false;
const exist = array.filter(arr => {
if (str.indexOf(arr) >= 0) return str;
})
return exist.length > 0
}
/**
* 格式化日期时间字符串,提取开始/结束时间并拼接为「日期 开始时间-结束时间」格式
* @description 处理包含 begin_time/end_time 的数据对象,截取时间戳最后6位(毫秒/时区等冗余部分),
* 最终拼接为 "YYYY-MM-DD HH:mm:ss-HH:mm:ss" 格式的字符串
* @param {Object} data - 包含开始/结束时间的数据源对象
* @param {string} [data.begin_time] - 开始时间字符串(格式示例:2026-01-13T12:30:45.123456+08:00)
* @param {string} [data.end_time] - 结束时间字符串(格式同 begin_time)
* @returns {string} 格式化后的日期时间字符串,格式为「日期 开始时间-结束时间」;若入参无效返回空字符串
* @example
* 输入示例
* const timeData = {
* begin_time: '2026-01-13T10:00:00.987654+08:00',
* end_time: '2026-01-13T18:30:00.123456+08:00'
* };
* 调用函数
* formatDatetime(timeData); // 返回 "2026-01-13 10:00:00-18:30:00"
*
* @example
* 入参为空的情况
* formatDatetime(null); // 返回 ""
* formatDatetime({}); // 返回 ""
*
* @note 1. 入参时间字符串需保证前19位为有效格式(YYYY-MM-DDTHH:mm:ss),否则截取后可能出现异常;
* 2. 若 begin_time/end_time 缺失,拼接后可能出现 "undefined-undefined" 等异常,需保证入参完整性;
* 3. 该函数默认截取时间字符串前19位(slice(0, -6)),需根据实际时间格式调整截取长度
*/
const formatDatetime = (data) => {
if (!data || !data.begin_time || !data.end_time) return '';
const normalize = (timeStr) => {
if (!timeStr) return '';
let clean = timeStr.split('+')[0];
clean = clean.split('Z')[0];
clean = clean.trim().replace(/\s+/, 'T');
return clean;
};
const start = dayjs(normalize(data.begin_time));
const end = dayjs(normalize(data.end_time));
if (!start.isValid() || !end.isValid()) return '';
const isNextDayMidnight =
end.diff(start, 'day') === 1 &&
end.hour() === 0 &&
end.minute() === 0 &&
end.second() === 0;
const endTimeText = isNextDayMidnight ? '24:00' : end.format('HH:mm');
return `${start.format('YYYY-MM-DD')} ${start.format('HH:mm')}-${endTimeText}`;
};
/**
* @description 证件号脱敏
* @param {string} id_number 证件号
* @param {Object} [options] 脱敏配置
* @param {number} [options.keep_start] 保留前几位(传了则按“前后保留”模式脱敏)
* @param {number} [options.keep_end] 保留后几位(传了则按“前后保留”模式脱敏)
* @param {number} [options.mask_count=8] 中间替换为 * 的位数(默认 8)
* @returns {string} 脱敏后的证件号
*/
const mask_id_number = (id_number, options = {}) => {
const raw = String(id_number || '')
if (!raw) return ''
const has_keep_start = Number.isFinite(options.keep_start)
const has_keep_end = Number.isFinite(options.keep_end)
const keep_start = has_keep_start ? options.keep_start : 0
const keep_end = has_keep_end ? options.keep_end : 0
const mask_count = Number.isFinite(options.mask_count) ? options.mask_count : 8
if (has_keep_start && has_keep_end) {
if (raw.length <= keep_start + keep_end) return raw
const prefix = raw.slice(0, keep_start)
const suffix = raw.slice(raw.length - keep_end)
const middle_len = Math.max(1, raw.length - keep_start - keep_end)
return `${prefix}${'*'.repeat(middle_len)}${suffix}`
}
if (raw.length < 15) return raw
const safe_mask_count = Math.min(Math.max(1, mask_count), raw.length)
const start = Math.floor((raw.length - safe_mask_count) / 2)
const end = start + safe_mask_count
if (start < 0 || end > raw.length) return raw
return raw.substring(0, start) + '*'.repeat(safe_mask_count) + raw.substring(end)
}
/**
* @description 二维码状态文案
* @param {string|number} status 状态值
* @returns {string} 状态文案
*/
const get_qrcode_status_text = (status) => {
const key = String(status || '')
if (key === '1') return '未激活'
if (key === '3') return '待使用'
if (key === '5') return '被取消'
if (key === '7') return '已使用'
return '未知状态'
}
/**
* @description 订单状态文案
* @param {string|number} status 状态值
* @returns {string} 状态文案
*/
const get_bill_status_text = (status) => {
const key = String(status || '')
if (key === '3') return '预约成功'
if (key === '5') return '已取消'
if (key === '9') return '已使用'
if (key === '11') return '退款中'
return '未知状态'
}
/**
* @description 构建 API 请求 URL(带默认公共参数)
* @param {string} action 接口动作名称(例如:openid)
* @param {Object} [params={}] 额外 query 参数
* @returns {string} 完整请求 URL(BASE_URL + /srv/?a=...&f=...&client_id=...)
*/
const buildApiUrl = (action, params = {}) => {
const base_params = {
a: action,
...REQUEST_DEFAULT_PARAMS,
...params,
}
const queryParams = new URLSearchParams(base_params)
return `${BASE_URL}/srv/?${queryParams.toString()}`
}
export { formatDate, wxInfo, parseQueryString, strExist, formatDatetime, mask_id_number, get_qrcode_status_text, get_bill_status_text, buildApiUrl };
/*
* @Date: 2026-01-13 21:28:45
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-15 19:24:57
* @FilePath: /xyxBooking-weapp/src/utils/uiText.js
* @Description: 弱网络提示文本
*/
/**
* @description 弱网/断网统一文案
* - toast/modal/banner 等入口统一引用,避免多处硬编码
*/
export const weak_network_text = {
title: '网络连接不畅',
toast_title: '网络连接不畅',
banner_desc: '网络开小差啦!请检查网络设置,或更换位置后重新进入小程序~',
offline_page_desc: '当前网络信号较弱,已自动为您切换至离线模式',
modal_no_cache_content: '当前网络信号较弱,暂无法使用小程序,请检查网络设置,或更换位置后重新进入小程序~',
modal_use_cache_content: '当前网络信号较弱,可使用已缓存的预约记录进入离线模式',
modal_go_offline_records_content: '当前网络信号较弱,是否进入离线预约记录?',
offline_mode_no_booking_toast: '当前为离线模式,无法预约',
confirm_ok: '知道了',
confirm_booking_records: '预约记录',
confirm_offline_records: '离线记录',
}
/**
* @description: 获取弱网络提示弹窗配置(无缓存)
* @returns {Object}
*/
export const get_weak_network_modal_no_cache_options = () => {
return {
title: weak_network_text.title,
content: weak_network_text.modal_no_cache_content,
confirmText: weak_network_text.confirm_ok,
showCancel: false,
}
}
/**
* @description: 获取弱网络提示弹窗配置(有缓存)
* @returns {Object}
*/
export const get_weak_network_modal_use_cache_options = () => {
return {
title: weak_network_text.title,
content: weak_network_text.modal_use_cache_content,
confirmText: weak_network_text.confirm_booking_records,
cancelText: weak_network_text.confirm_ok,
}
}
/**
* @description: 获取弱网络提示弹窗配置(进入离线预约记录)
* @returns {Object}
*/
export const get_weak_network_modal_go_offline_records_options = () => {
return {
title: weak_network_text.title,
content: weak_network_text.modal_go_offline_records_content,
confirmText: weak_network_text.confirm_offline_records,
cancelText: weak_network_text.confirm_ok,
}
}
/**
* @description 获取当前页路由(不含 query)
* @returns {string} 当前页 route,例如:pages/index/index
*/
const getCurrentPageUrl = () => {
// 获取加载的页面栈
let pages = getCurrentPages()
// 获取当前页面对象
let currentPage = pages[pages.length - 1]
// 当前页面 route(不含 query)
let url = currentPage.route
return url
}
/**
* @description 获取当前页 query 参数
* @returns {Object} 当前页 options(query 参数对象)
*/
const getCurrentPageParam = () => {
// 获取加载的页面栈
let pages = getCurrentPages()
// 获取当前页面对象
let currentPage = pages[pages.length - 1]
// 当前页面 query 参数对象
let options = currentPage.options
return options
}
export {
getCurrentPageUrl,
getCurrentPageParam
}
/*
* @Date: 2026-01-16 19:41:09
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-20 11:40:46
* @FilePath: /xyxBooking-weapp/src/utils/wechatPay.js
* @Description: 微信支付工具函数
*/
import Taro from '@tarojs/taro'
import { wxPayAPI } from '@/api/wx/pay'
/**
* @description 微信支付
* @param {*} pay_id 订单号
* @returns {*} 支付结果
*/
export const wechat_pay = async ({ pay_id }) => {
const normalized_pay_id = String(pay_id || '')
if (!normalized_pay_id) {
return { code: 0, data: null, msg: '缺少订单号' }
}
Taro.showLoading({ title: '支付准备中...' })
let pay_params_res = null
try {
pay_params_res = await wxPayAPI({ pay_id: normalized_pay_id })
} finally {
Taro.hideLoading()
}
if (!pay_params_res || pay_params_res.code != 1) {
return { code: 0, data: null, msg: pay_params_res?.msg || '获取支付信息失败,请稍后再试' }
}
const pay_params = pay_params_res?.data || {}
const pay_result = await new Promise((resolve) => {
Taro.requestPayment({
timeStamp: pay_params.timeStamp,
nonceStr: pay_params.nonceStr,
package: pay_params.package,
signType: pay_params.signType,
paySign: pay_params.paySign,
success: (res) => resolve({ ok: true, res }),
fail: (err) => resolve({ ok: false, err }),
})
})
if (pay_result?.ok) {
return { code: 1, data: pay_result.res || null, msg: '支付成功' }
}
return { code: 0, data: pay_result?.err || null, msg: pay_result?.err?.errMsg || '支付未完成' }
}
/*
* @Date: 2025-06-30 13:27:50
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-06-30 13:27:56
* @FilePath: /myApp/tailwind.config.js
* @Description: 文件描述
*/
/** @type {import('tailwindcss').Config} */
module.exports = {
// 这里给出了一份 taro 通用示例,具体要根据你自己项目的目录结构进行配置
// 比如你使用 vue3 项目,你就需要把 vue 这个格式也包括进来
// 不在 content glob 表达式中包括的文件,在里面编写 tailwindcss class,是不会生成对应的 css 工具类的
content: ['./public/index.html', './src/**/*.{html,js,ts,jsx,tsx,vue}'],
theme: {
extend: {},
},
plugins: [],
corePlugins: {
// 小程序不需要 preflight,因为这主要是给 h5 的,如果你要同时开发多端,你应该使用 process.env.TARO_ENV 环境变量来控制它
preflight: false,
},
}