hookehuyr

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

添加项目配置文件、核心工具模块、基础页面组件和依赖管理
- 配置 Taro 4 + Vue 3 + NutUI 技术栈,支持微信小程序和 H5 开发
- 实现完整的微信登录认证流程,包括静默授权和 401 自动刷新
- 添加支付测试功能,支持 WebView 桥接和微信支付调用
- 提供弱网/离线支持,包含缓存管理和统一文案
- 设置双设计宽度体系(NutUI 375px / 其他 750px)
- 配置路径别名、ESLint 代码规范和开发构建脚本
Showing 67 changed files with 5020 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>
This diff is collapsed. Click to expand it.
<!--
* @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
}
}
This diff is collapsed. Click to expand it.
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 = ''
},
},
})
This diff is collapsed. Click to expand it.
/*
* @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 };
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.