hookehuyr

docs: 完善项目注释与类型标注

- 为环境文件、常量、工具函数、API 模块等添加 JSDoc 注释
- 统一函数参数与返回值的类型标注
- 增强路由、状态管理、微信 JSSDK 等核心模块的可读性
- 修复动态路由 children 处理逻辑,避免生成非数组值
- 更新微信分享标题为动态年份,避免硬编码
- 重构防抖钩子,移除 lodash 依赖,减少打包体积
###
# @Date: 2026-01-22 10:52:04
# @LastEditors: hookehuyr hookehuyr@gmail.com
# @LastEditTime: 2026-01-24 13:15:00
# @FilePath: /xysBooking/.env.development
# @Description: 开发环境配置文件
###
# 资源公共路径
VITE_BASE = /
......
......@@ -40,6 +40,12 @@ setToastDefaultOptions({
className: 'zIndex'
});
/**
* 初始化微信 JSSDK(全局只执行一次)
* - 拉取后端签名参数
* - 调用 wx.config 并等待 wx.ready
* @returns {Promise<boolean>} 是否初始化成功
*/
const init_wx_global = async () => {
try {
const cfg_res = await wxJsAPI()
......@@ -64,6 +70,7 @@ const init_wx_global = async () => {
}
onMounted(async () => {
// 避免重复初始化:把初始化 Promise 挂在 window 上复用
if (!window.__wx_ready_promise) {
window.__wx_ready_promise = init_wx_global()
}
......
......@@ -14,34 +14,31 @@ const Api = {
}
/**
* @description: 发送验证码
* @param {*} phone 手机号码
* @returns
* 发送短信验证码
* @param {{ phone: string }} params 请求参数
* @returns {Promise<Object|false>} 统一返回(成功为后端对象,失败为 false)
*/
export const smsAPI = (params) => fn(fetch.post(Api.SMS, params));
/**
* @description: 获取七牛token
* @param {*} filename 文件名
* @param {*} file 图片base64
* @returns
* 获取七牛上传 token
* @param {{ filename: string, file?: string }} params 请求参数(file 可选,用于部分后端校验)
* @returns {Promise<Object|false>} 统一返回(成功为后端对象,失败为 false)
*/
export const qiniuTokenAPI = (params) => fn(fetch.stringifyPost(Api.TOKEN, params));
/**
* @description: 上传七牛
* @param {*}
* @returns
* 上传到七牛(第三方接口,返回结构与业务接口不同)
* @param {string} url 七牛上传地址
* @param {any} data 上传 body(通常是 FormData)
* @param {Object} config axios 配置
* @returns {Promise<Object|false>} 成功返回七牛数据,失败返回 false
*/
export const qiniuUploadAPI = (url, data, config) => uploadFn(fetch.basePost(url, data, config));
/**
* @description: 保存图片
* @param {*} format
* @param {*} hash
* @param {*} height
* @param {*} width
* @param {*} filekey
* @returns
* 保存七牛文件(通知后端落库/生成访问地址等)
* @param {{ format: string, hash: string, height?: number, width?: number, filekey: string }} params 请求参数
* @returns {Promise<Object|false>} 统一返回(成功为后端对象,失败为 false)
*/
export const saveFileAPI = (params) => fn(fetch.stringifyPost(Api.SAVE_FILE, params));
......
......@@ -3,16 +3,19 @@
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2024-01-25 10:03:20
* @FilePath: /xysBooking/src/api/fn.js
* @Description: 文件描述
* @Description: API 统一返回处理与 GET/POST 统一封装
*/
import axios from '@/utils/axios';
import { showSuccessToast, showFailToast, showToast } from 'vant';
import { showFailToast, showToast } from 'vant';
import qs from 'Qs'
/**
* 网络请求功能函数
* @param {*} api 请求axios接口
* @returns 请求成功后,获取数据
* 统一处理后端接口返回
* - 约定后端返回结构:{ code, data, msg, show }
* - code === 1 视为成功,原样返回 res.data
* - 失败时根据 show 决定是否弹出 Toast
* @param {Promise<import('axios').AxiosResponse>} api axios 请求 Promise
* @returns {Promise<Object|false>} 成功返回后端对象,失败返回 false
*/
export const fn = (api) => {
return api
......@@ -37,9 +40,11 @@ export const fn = (api) => {
}
/**
* 七牛返回格式
* @param {*} api
* @returns
* 七牛上传接口返回处理
* - 七牛成功时常见表现为 res.statusText === 'OK'
* - 与业务接口不同,失败提示使用 showFailToast
* @param {Promise<import('axios').AxiosResponse>} api axios 请求 Promise
* @returns {Promise<Object|false>} 成功返回 res.data,失败返回 false
*/
export const uploadFn = (api) => {
return api
......@@ -62,18 +67,46 @@ export const uploadFn = (api) => {
}
/**
* 统一 GET/POST 不同传参形式
* 统一封装 GET/POST 的传参形式
* - get:以 { params } 形式传递查询参数
* - post:以 JSON 形式传递 body
* - stringifyPost:以 x-www-form-urlencoded 形式传递 body(兼容部分 PHP 接口)
*/
export const fetch = {
/**
* GET 请求封装
* @param {string} api 接口地址
* @param {Object} params 查询参数
* @returns {Promise<import('axios').AxiosResponse>} axios Promise
*/
get: function (api, params) {
return axios.get(api, { params })
},
/**
* POST 请求封装(JSON body)
* @param {string} api 接口地址
* @param {Object} params 请求体
* @returns {Promise<import('axios').AxiosResponse>} axios Promise
*/
post: function (api, params) {
return axios.post(api, params)
},
/**
* POST 请求封装(urlencoded body)
* @param {string} api 接口地址
* @param {Object} params 请求体
* @returns {Promise<import('axios').AxiosResponse>} axios Promise
*/
stringifyPost: function (api, params) {
return axios.post(api, qs.stringify(params))
},
/**
* 透传 POST(用于七牛等第三方上传)
* @param {string} url 完整 URL
* @param {any} data body 数据
* @param {Object} config axios 配置
* @returns {Promise<import('axios').AxiosResponse>} axios Promise
*/
basePost: function (url, data, config) {
return axios.post(url, data, config)
}
......
......@@ -4,7 +4,7 @@
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2022-06-14 14:47:01
* @FilePath: /tswj/src/api/wx/config.js
* @Description:
* @Description: 微信 JSSDK 配置相关接口
*/
import { fn, fetch } from '@/api/fn';
......@@ -13,8 +13,9 @@ const Api = {
}
/**
* @description 获取微信CONFIG配置文件
* @param {*} url
* @returns {*} cfg
* 获取微信 JSSDK config 所需参数
* - 返回数据通常包含 appId/timestamp/nonceStr/signature 等
* @param {{ url?: string }} params 请求参数(部分后端会用 url 参与签名)
* @returns {Promise<Object|false>} 统一返回(成功为后端对象,失败为 false)
*/
export const wxJsAPI = (params) => fn(fetch.get(Api.WX_JSAPI, params));
......
......@@ -3,7 +3,12 @@
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2022-06-13 14:27:21
* @FilePath: /tswj/src/api/wx/jsApiList.js
* @Description: 文件描述
* @Description: 微信 JSSDK 需要注册的能力列表
*/
/**
* 微信 JSSDK 需要注册的 jsApiList
* - 用于 wx.config({ jsApiList }) 申请对应能力
* @type {string[]}
*/
export const apiList = [
"updateAppMessageShareData",
......
/**
* 判断多行省略文本
* @param {*} id 目标dom标签
* @returns
* @Description: 通用 DOM 工具
*/
/**
* 判断指定元素是否发生了多行溢出(常用于判断是否需要“展开/收起”)
* @param {string} id 目标 DOM 的 id
* @returns {boolean} 是否溢出
*/
const hasEllipsis = (id) => {
let oDiv = document.getElementById(id);
......@@ -14,4 +18,4 @@ const hasEllipsis = (id) => {
export default {
hasEllipsis
}
\ No newline at end of file
}
......
import { ref } from 'vue'
import { useBrowserLocation, useEventListener, useTitle, useUrlSearchParams, useWindowScroll, logicAnd } from '@vueuse/core'
// import { useBrowserLocation, useEventListener, useTitle, useUrlSearchParams, useWindowScroll, logicAnd } from '@vueuse/core'
/**
* @Description: vueuse 能力测试/示例(开发调试用)
*/
/**
* vueuse 示例函数(当前未接入业务逻辑)
* @returns {void}
*/
export const fn = () => {
// const location = useBrowserLocation()
// console.warn(location.value);
// useEventListener(window, 'scroll', (evt) => {
// useEventListener(window, 'scroll', (evt) => {
// const { x, y } = useWindowScroll()
// // console.warn(x.value);
// console.warn(y.value);
......@@ -16,5 +25,5 @@ export const fn = () => {
const b = ref(true)
const flag = a.value && b.value
console.warn(flag);
}
......
......@@ -3,7 +3,7 @@
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2023-06-21 15:58:52
* @FilePath: /huizhu/src/composables/useLogin.js
* @Description: 文件描述
* @Description: 登录流程封装(验证码发送、表单校验、提交登录)
*/
import { bLoginAPI } from '@/api/B/login'
import { useRouter } from 'vue-router'
......@@ -13,11 +13,20 @@ import { useCountDown } from '@vant/use';
import { smsAPI } from '@/api/common'
import { showSuccessToast, showFailToast } from 'vant';
/**
* 登录逻辑组合式函数
* - 主要用于 B 端登录(历史代码)
* @returns {Object} 页面所需的响应式状态与方法集合
*/
export const useLogin = () => {
const phone = ref('');
const code = ref('')
const refForm = ref(null);
/**
* 校验表单并触发提交
* @returns {void}
*/
const validateForm = () => {
const valid = refForm.value.validate();
valid
......@@ -41,7 +50,8 @@ export const useLogin = () => {
/**
* 手机号码校验
* 函数返回 true 表示校验通过,false 表示不通过
* @param {*} val
* @param {string} val 输入值
* @returns {boolean} 是否校验通过
*/
const sms_disabled = ref(false);
const phoneValidator = (val) => {
......@@ -62,9 +72,18 @@ export const useLogin = () => {
*/
const keyboard_show = ref(false);
const refPhone = ref(null)
/**
* 弹出数字键盘
* @returns {void}
*/
const showKeyboard = () => { // 弹出数字弹框
keyboard_show.value = true;
};
/**
* 数字键盘失焦回调
* - 关闭键盘并触发手机号校验
* @returns {void}
*/
const keyboardBlur = () => { // 数字键盘失焦回调
keyboard_show.value = false;
refPhone.value.validate();
......@@ -80,6 +99,11 @@ export const useLogin = () => {
}
});
/**
* 发送验证码
* - 启动倒计时,避免频繁触发
* @returns {Promise<void>}
*/
const sendCode = async () => { // 发送验证码
countDown.start();
// 验证码接口
......@@ -90,12 +114,17 @@ export const useLogin = () => {
};
// 过滤输入的数字 只能四位
/**
* 限制验证码输入长度(最多 4 位)
* @param {string} value 输入值
* @returns {string} 截断后的值
*/
const smsFormatter = (value) => value.substring(0, 4);
/**
* 用户登录
* @param {*} phone
* @param {*} pin
* @param {{ phone: string, code: string }} values 表单值
* @returns {Promise<void>}
*/
const $router = useRouter();
const onSubmit = async (values) => {
......
/*
* @Date: 2022-06-13 17:42:32
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2024-02-05 19:05:56
* @LastEditTime: 2026-01-24 13:09:52
* @FilePath: /xysBooking/src/composables/useShare.js
* @Description: 文件描述
* @Description: 微信分享能力封装
*/
import wx from 'weixin-js-sdk';
// import { Toast } from 'vant';
/**
* @description: 微信分享功能
* @param {*} title 标题
* @param {*} desc 描述
* @param {*} imgUrl 图标
* @return {*}
* 配置当前页面的微信分享信息
* - 依赖:页面已完成 wx.config 且 wx.ready
* @param {{ title?: string, desc?: string, imgUrl?: string }} options 分享配置
* @returns {void}
*/
export const sharePage = ({ title = '西园寺2024年春节入寺预约', desc = '除夕21点至初五17点', imgUrl = 'https://cdn.ipadbiz.cn/xys/booking/logo_s.jpg'}) => {
const current_year = new Date().getFullYear();
export const sharePage = ({ title = `西园寺${current_year}年春节入寺预约`, desc = '除夕21点至初五17点', imgUrl = 'https://cdn.ipadbiz.cn/xys/booking/logo_s.jpg'}) => {
const shareData = {
title, // 分享标题
desc, // 分享描述
......
......@@ -2,12 +2,15 @@
* @Author: hookehuyr hookehuyr@gmail.com
* @Date: 2022-05-25 18:34:17
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2023-06-14 09:55:42
* @FilePath: /huizhu/src/constant.js
* @Description:
* @LastEditTime: 2026-01-24 12:50:43
* @FilePath: /xysBooking/src/constant.js
* @Description: 项目常量集合(颜色/枚举等)
*/
// 颜色变量
/**
* 主题颜色变量
* @type {{ baseColor: string, baseFontColor: string }}
*/
export const styleColor = {
baseColor: '#11D2B1',
baseFontColor: '#FFFFFF'
......
......@@ -4,16 +4,18 @@ import { provide, inject } from "vue";
/**
* 创建全局变量
* @param {*} context
* @param {*} key
* - 基于 provide/inject,适合在组件树内部共享上下文
* @param {any} context 要注入的上下文对象
* @param {any} key 注入 key(建议使用 Symbol)
* @returns {void}
*/
export function createContext(context, key) {
provide(key, context)
}
/**
* 使用全局变量
* @param {*} key
* @returns
* @param {any} key 注入 key
* @returns {any} 注入的上下文对象
*/
export function useContext(key) {
return inject(key)
......
......@@ -4,16 +4,58 @@
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2024-01-30 15:26:57
* @FilePath: /xysBooking/src/hooks/useDebounce.js
* @Description:
* @Description: 防抖工具(避免高频触发导致请求/渲染过多)
*/
// import _ from 'lodash';
/**
* 封装lodash防抖
* @param {*} fn 执行函数
* @param {*} timestamp 执行间隔
* @param {*} options 函数配置 - 在延迟开始前调用,在延迟结束后不调用
* @returns 返回新的 debounced(防抖动)函数。
* 防抖函数
* - 默认:leading=true、trailing=false(更适合“搜索框输入后立即请求一次”的场景)
* - 说明:此实现不依赖 lodash,避免引入额外依赖与打包体积波动
* @param {Function} fn 需要防抖执行的函数
* @param {number} timestamp 防抖间隔(毫秒)
* @param {{ leading?: boolean, trailing?: boolean }} options 配置项
* @returns {Function} 防抖后的函数
*/
export const useDebounce = (fn, timestamp = 500, options = { leading: true, trailing: false }) => {
// return _.debounce(fn, timestamp, options);
const cfg = options || {};
const leading = !!cfg.leading;
const trailing = !!cfg.trailing;
let timer = null;
let last_args = null;
let last_this = null;
const run_trailing = () => {
// 结束一次防抖窗口:仅在 trailing=true 且窗口内有新入参时执行
if (trailing && last_args) {
fn.apply(last_this, last_args);
}
last_args = null;
last_this = null;
};
const clear_timer = () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
};
return function (...args) {
last_args = args;
last_this = this;
// 如果当前不在防抖窗口内,且允许 leading,则立即执行一次
if (!timer && leading) {
fn.apply(last_this, last_args);
last_args = null;
last_this = null;
}
clear_timer();
timer = setTimeout(() => {
timer = null;
run_trailing();
}, timestamp);
}
}
......
/**
* @description 封装简化滚动查询列表执行流程
* @param {*} data 接口返回列表数据
* @param {*} list 自定义列表
* @param {*} offset
* @param {*} loading
* @param {*} finished
* @param {*} finishedTextStatus
* @param {*} emptyStatus
* 封装简化滚动查询列表执行流程
* - 统一拼接列表、去重、更新加载状态与空数据状态
* @param {Array<any>} data 接口返回的列表数据
* @param {{ value: Array<any> }} list 需要渲染的列表(ref)
* @param {{ value: number }} offset 已加载数量(ref)
* @param {{ value: boolean }} loading 加载状态(ref)
* @param {{ value: boolean }} finished 是否全部加载完成(ref)
* @param {{ value: boolean }} finishedTextStatus 是否显示“没有更多了”(ref)
* @param {{ value: boolean }} emptyStatus 是否为空列表(ref)
* @returns {void}
*/
// import _ from 'lodash'
export const flowFn = (data, list, offset, loading, finished, finishedTextStatus, emptyStatus) => {
// list.value = _.concat(list.value, data);
// list.value = _.uniqBy(list.value, 'id');
// offset.value = list.value.length;
// loading.value = false;
// // 数据全部加载完成
// if (!data.length) {
// // 加载状态结束
// finished.value = true;
// }
// // 空数据提示
// if (!list.value.length) {
// finishedTextStatus.value = false;
// }
// emptyStatus.value = Object.is(list.value.length, 0);
const next_list = Array.isArray(data) ? data : [];
// 合并数据
const merged = (Array.isArray(list.value) ? list.value : []).concat(next_list);
// 尝试按 id 去重(如果每条数据都具备 id)
const can_dedupe_by_id = merged.length > 0 && merged.every(item => item && typeof item === 'object' && 'id' in item);
const deduped = can_dedupe_by_id
? Array.from(new Map(merged.map(item => [item.id, item])).values())
: merged;
list.value = deduped;
offset.value = deduped.length;
loading.value = false;
// 数据全部加载完成:本次没有拉到数据
if (!next_list.length) {
finished.value = true;
}
// 空数据提示:列表为空时不显示“没有更多了”
if (!deduped.length) {
finishedTextStatus.value = false;
}
emptyStatus.value = Object.is(deduped.length, 0);
}
......
import { useRouter } from 'vue-router';
/**
* 封装路由跳转方便行内调用
* @returns
* 获取路由跳转方法(push)
* @returns {(path: string, query?: Object) => void} 跳转函数
*/
export function useGo () {
let router = useRouter()
/**
* 路由跳转(push)
* @param {string} path 路径
* @param {Object} query query 参数
* @returns {void}
*/
function go (path, query) {
router.push({
path: path,
......@@ -15,8 +21,18 @@ export function useGo () {
return go
}
/**
* 获取路由跳转方法(replace)
* @returns {(path: string, query?: Object) => void} 替换跳转函数
*/
export function useReplace () {
let router = useRouter()
/**
* 路由跳转(replace)
* @param {string} path 路径
* @param {Object} query query 参数
* @returns {void}
*/
function replace (path, query) {
router.replace({
path: path,
......
......@@ -4,7 +4,7 @@
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2024-01-23 13:14:47
* @FilePath: /xysBooking/src/main.js
* @Description:
* @Description: 项目入口(创建应用、注册路由/Pinia、注册 Vant 组件、挂载全局 http)
*/
import { createApp } from 'vue';
import {
......@@ -56,7 +56,8 @@ import '@vant/touch-emulator';
const pinia = createPinia();
const app = createApp(App);
app.config.globalProperties.$http = axios; // 关键语句
// 统一挂载 http 实例:页面里可以通过 this.$http 使用(Options API 场景)
app.config.globalProperties.$http = axios;
app
.use(pinia)
......
......@@ -5,6 +5,11 @@
* @FilePath: /git/xysBooking/src/route.js
* @Description: 路由列表
*/
/**
* 静态路由表
* - 每个页面的 meta.title 用于设置 document.title
* @type {Array<Object>}
*/
export default [
{
path: '/',
......@@ -19,8 +24,8 @@ export default [
meta: {
title: '预约须知',
},
//路由的独享守卫
beforeEnter: (to,from,next) => {
// 路由独享守卫(预留)
beforeEnter: (to, from, next) => {
// console.warn(to, from);
next();
}
......
......@@ -3,7 +3,7 @@
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2022-06-29 21:36:59
* @FilePath: /tswj/src/router.js
* @Description: 文件描述
* @Description: 路由入口(静态路由 + 动态路由注入)
*/
import { createRouter, createWebHashHistory } from 'vue-router';
import RootRoute from './route.js';
......@@ -15,6 +15,7 @@ import generateRoutes from './utils/generateRoute'
* 把项目独有的路由配置到相应的路径,默认路由文件只放公用部分
* 但是 vue 文件内容还是要事先准备好
*/
// Vite: 扫描并一次性引入模块路由(按需可拆分成多文件维护)
const modules = import.meta.globEager('@/router/routes/modules/**/*.js'); // Vite 支持使用特殊的 import.meta.glob 函数从文件系统导入多个模块
const routeModuleList = [];
......@@ -26,6 +27,7 @@ Object.keys(modules).forEach((key) => {
// 创建路由实例并传递 `routes` 配置
const router = createRouter({
// hash 模式:兼容微信环境与静态资源部署路径
history: createWebHashHistory('/index.html'),
routes: [...RootRoute, ...routeModuleList]
});
......@@ -34,13 +36,22 @@ const router = createRouter({
/**
* generateRoute 负责把后台返回数据拼接成项目需要的路由结构,动态添加到路由表里面
*/
/**
* 动态路由注入守卫
* - 以 404 页面作为中转:首次进入时先落到 404,再把动态路由 addRoute 进来后重定向回目标页面
* @param {any} to 目标路由
* @param {any} from 来源路由
* @param {Function} next 放行函数
* @returns {void}
*/
router.beforeEach((to, from, next) => {
// 使用404为中转页面,避免动态路由没有渲染出来,控制台报警告问题
if (to.path == '/404' && to.redirectedFrom != undefined) {
// 模拟异步操作
setTimeout(() => {
if (!asyncRoutesArr.length) return; // 没有动态路由避免报错
const arr = generateRoutes(asyncRoutesArr); // 在路由守卫处生成,避免有子路由时刷新白屏问题。
// 在路由守卫处生成,避免有子路由时刷新白屏问题
const arr = generateRoutes(asyncRoutesArr);
arr.forEach(item => {
router.addRoute(item) // 新增路由
})
......
......@@ -3,24 +3,35 @@
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2024-01-30 15:26:30
* @FilePath: /xysBooking/src/store/index.js
* @Description: 文件描述
* @Description: Pinia 主仓库(全局状态与页面缓存控制)
*/
import { defineStore } from 'pinia';
// import { testStore } from './test'; // 另一个store
// import _ from 'lodash';
import { useRouter } from 'vue-router'
/**
* 主状态仓库
* - auth:授权状态
* - keepPages:用于 keep-alive include 的缓存页面列表
* - appUserInfo:缓存预约人信息(跨页面复用)
* @returns {Function} useStore 方法(调用后获取 store 实例)
*/
export const mainStore = defineStore('main', {
state: () => {
return {
msg: 'Hello world',
count: 0,
auth: false,
keepPages: ['default'], // 很坑爹,空值全部都缓存
auth: false, // 是否已完成授权
keepPages: ['default'], // keep-alive include 为空会全部缓存,这里用默认占位值兜底
appUserInfo: [], // 缓存预约人信息
};
},
getters: {
/**
* 获取缓存页面列表(用于 keep-alive include)
* @returns {string[]} 缓存页面 name 列表
*/
getKeepPages () {
return this.keepPages
},
......@@ -29,12 +40,27 @@ export const mainStore = defineStore('main', {
// }
},
actions: {
/**
* 修改授权状态
* @param {boolean} state 授权状态
* @returns {void}
*/
changeState (state) {
this.auth = state;
},
/**
* 清空所有缓存页
* - 用一个不存在的值覆盖,避免 include 为空导致“全页面缓存”
* @returns {void}
*/
changeKeepPages () { // 清空所有缓存,用一个不存在的值覆盖
this.keepPages = ['default'];
},
/**
* 把当前页面加入缓存列表
* - 依赖路由 meta.name 作为 keep-alive include 的 key
* @returns {void}
*/
keepThisPage () { // 新增缓存页
const $router = useRouter();
const page = $router.currentRoute.value.meta.name;
......@@ -45,6 +71,11 @@ export const mainStore = defineStore('main', {
// const page = $router.currentRoute.value.meta.name;
// _.remove(this.keepPages, item => item === page)
},
/**
* 缓存预约人信息
* @param {Array<any>} info 预约人信息
* @returns {void}
*/
changeUserInfo (info) {
this.appUserInfo = info;
}
......
......@@ -2,9 +2,9 @@
* @Author: hookehuyr hookehuyr@gmail.com
* @Date: 2022-05-28 10:17:40
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2024-02-06 09:44:43
* @LastEditTime: 2026-01-24 12:41:18
* @FilePath: /xysBooking/src/utils/axios.js
* @Description:
* @Description: axios 实例与拦截器(统一参数、统一鉴权跳转、统一错误提示入口)
*/
import axios from 'axios';
import router from '@/router';
......@@ -12,13 +12,18 @@ import router from '@/router';
// import { strExist } from '@/utils/tools'
// import { parseQueryString } from '@/utils/tools'
// 设置所有请求默认携带的公共参数
axios.defaults.params = {
f: 'reserve',
client_name: '智慧西园寺',
};
/**
* @description 请求拦截器
* 请求拦截器
* - GET 默认追加时间戳,避免浏览器/中间层缓存导致数据不更新
* - 统一补齐 config.params,保证公共参数与业务参数可以并存
* @param {import('axios').InternalAxiosRequestConfig} config axios 请求配置
* @returns {import('axios').InternalAxiosRequestConfig} 处理后的请求配置
*/
axios.interceptors.request.use(
config => {
......@@ -36,12 +41,17 @@ axios.interceptors.request.use(
return config;
},
error => {
// 请求错误处理
// 请求错误处理(如:参数序列化异常、网络层拦截等)
return Promise.reject(error);
});
/**
* @description 响应拦截器
* 响应拦截器
* - 约定后端返回 { code, data, msg, show } 结构
* - code === 401 时,进行授权页跳转(避免未授权接口一直报错)
* - 部分业务错误提示需要静默(show === false 或命中特定 msg)
* @param {import('axios').AxiosResponse} response axios 响应对象
* @returns {import('axios').AxiosResponse} 原样返回响应(由上层 fn 统一处理 code/msg)
*/
axios.interceptors.response.use(
response => {
......@@ -51,6 +61,7 @@ axios.interceptors.response.use(
// // C/B 授权拼接头特殊标识,openid_x
// let prefixAPI = router?.currentRoute.value.href?.indexOf('business') > 0 ? 'b' : 'c';
if (response.data.code === 401) {
// 未授权时不弹 Toast,统一跳转到授权页
response.data.show = false;
const request_params = response?.config?.params || {};
const is_redeem_admin = request_params?.f === 'reserve_admin';
......@@ -60,6 +71,7 @@ axios.interceptors.response.use(
}
}
if (['预约ID不存在'].includes(response.data.msg)) {
// 这类错误属于流程中“可预期异常”,不提示用户,交给页面自行兜底
response.data.show = false;
}
// // 拦截B端未登录情况
......
......@@ -3,7 +3,7 @@
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2024-01-30 15:19:10
* @FilePath: /xysBooking/src/utils/generatePackage.js
* @Description: 文件描述
* @Description: 统一导出常用依赖(减少页面重复 import)
*/
import Cookies from 'js-cookie'
// import $ from 'jquery'
......@@ -15,6 +15,7 @@ import { Toast, Dialog } from 'vant';
import { wxInfo, hasEllipsis } from '@/utils/tools';
import { useTitle } from '@vueuse/core'
// TAG: 这里集中导出“项目高频使用”的依赖,页面按需引入即可
export {
Cookies,
// $,
......
......@@ -3,13 +3,13 @@
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2022-06-29 17:00:15
* @FilePath: /tswj/src/utils/generateRoute.js
* @Description: 文件描述
* @Description: 动态路由组装(把后端路由数据转换成 vue-router 结构)
*/
/**
* 根据后台返回的路径,生成页面的组件模版
* @param {*} component
* @returns 模版地址
* 根据后端返回的 component 字段,生成对应页面的动态 import
* @param {string} component views 下的页面文件名(不含 .vue)
* @returns {() => Promise<any>} 组件加载函数
*/
function loadView(component) {
return () => import(`../views/${component}.vue`)
......@@ -17,7 +17,8 @@ function loadView(component) {
/**
* 生成路由结构
* @param {*} routes
* @param {Array<Object>} routes 后端路由数组
* @returns {Array<Object>} vue-router routes 数组
*/
const generateRoutes = (routes) => {
const arr = []
......@@ -39,7 +40,8 @@ const generateRoutes = (routes) => {
router.component = loadView(component)
keepAlive && (router.keepAlive = keepAlive)
meta && (router.meta = meta)
router.children = !Array.isArray(children) || generateRoutes(children);
// children 不是数组时,统一置空,避免生成 true 导致路由结构异常
router.children = Array.isArray(children) ? generateRoutes(children) : [];
arr.push(router)
})
return arr
......
import wx from 'weixin-js-sdk'
import axios from '@/utils/axios';
/**
* 微信分享配置(历史代码,当前主要用于调试/预留)
* @param {{ name?: string }} to 目标路由信息(通常来自 vue-router)
* @returns {void}
*/
const fn = (to) => {
// 路由名
// 路由名(从 hash 中截取)
let ruleName = location.href.split('#/')[1].split('?')[0];
// 分享图标
const icon = 'https://cdn.lifeat.cn/webappgroup/betterLifelogo.png'
// 分享文案映射表
const shareInfoMap = {
'client/index': {
title: '童声无界',
......@@ -82,4 +89,4 @@ export default fn
// }).catch(err => {
// console.info('err:', err)
// })
// }
\ No newline at end of file
// }
......
......@@ -3,18 +3,22 @@
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2024-01-30 15:43:33
* @FilePath: /xysBooking/src/utils/tools.js
* @Description: 文件描述
* @Description: 通用工具函数(时间格式化、终端判断、URL 解析等)
*/
import dayjs from 'dayjs';
// 格式化时间
/**
* 格式化时间(默认到分钟)
* @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
* 判断当前运行环境(Android/iOS/微信)
* @returns {{ isAndroid: boolean, isiOS: boolean, isTable: boolean }} 终端信息
*/
const wxInfo = () => {
let u = navigator.userAgent;
......@@ -30,9 +34,9 @@ const wxInfo = () => {
};
/**
* @description 判断多行省略文本
* @param {*} id 目标dom标签
* @returns
* 判断指定元素是否发生了多行溢出(常用于判断是否需要“展开/收起”)
* @param {string} id 目标 DOM 的 id
* @returns {boolean} 是否溢出
*/
const hasEllipsis = (id) => {
let oDiv = document.getElementById(id);
......@@ -44,9 +48,9 @@ const hasEllipsis = (id) => {
}
/**
* @description 解析URL参数
* @param {*} url
* @returns
* 解析 URL 查询参数
* @param {string} url 完整 URL(包含 ?query)
* @returns {Record<string, string>} 解析后的键值对
*/
const parseQueryString = url => {
var json = {};
......@@ -71,9 +75,21 @@ const strExist = (array, str) => {
return exist.length > 0
}
/**
* 格式化预约时段显示文本
* - 兼容后端返回的 ISO 字符串(带 Z / +08:00)以及空格分隔等形式
* - 若结束时间恰好为次日 00:00,则展示为 24:00(更符合“营业到 24:00”的直觉)
* @param {{ begin_time?: string, end_time?: string }} data 接口返回的时段对象
* @returns {string} 形如:YYYY-MM-DD HH:mm-HH:mm
*/
const formatDatetime = (data) => {
if (!data || !data.begin_time || !data.end_time) return '';
/**
* 规范化时间字符串,尽量喂给 dayjs 可解析格式
* @param {string} timeStr 原始时间字符串
* @returns {string} 规范化后的字符串
*/
const normalize = (timeStr) => {
if (!timeStr) return '';
let clean = timeStr.split('+')[0];
......
......@@ -3,15 +3,19 @@
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2024-02-06 13:04:25
* @FilePath: /xysBooking/src/utils/versionUpdater.js
* @Description:
* @Description: 轮询检测静态资源变更(用于提示用户刷新页面)
*/
/* eslint-disable */
/**
* @description: 版本更新检查
* @param {*} time 阈值
* @return {*}
* 版本更新检查器
* - 思路:定时拉取 index.html,对比其中 <script> 标签内容是否发生变化
* - 适用:Vite 构建的产物文件名带 hash,更新后 script src 会变化
* @class
*/
export class Updater {
/**
* @param {{ time?: number }} options 配置项
*/
constructor(options = {}) {
this.oldScript = [];
this.newScript = [];
......@@ -20,45 +24,75 @@ export class Updater {
this.timing(options.time); //轮询
}
/**
* 初始化:读取当前 html 并记录 script 标签快照
* @returns {Promise<void>}
*/
async init() {
const html = await this.getHtml();
this.oldScript = this.parserScript(html);
}
/**
* 拉取 index.html 文本内容
* @returns {Promise<string>} html 文本
*/
async getHtml() {
// TAG: html的位置需要动态修改
const html = await fetch(import.meta.env.VITE_BASE).then((res) => res.text()); //读取index html
return html;
}
/**
* 解析 html 中的 script 标签字符串数组
* @param {string} html html 文本
* @returns {string[]|null} script 标签数组(match 可能返回 null)
*/
parserScript(html) {
const reg = new RegExp(/<script(?:\s+[^>]*)?>(.*?)<\/script\s*>/gi); //script正则
return html.match(reg); //匹配script标签
}
//发布订阅通知
/**
* 订阅事件
* @param {'no-update'|'update'|string} key 事件名
* @param {Function} fn 回调函数
* @returns {Updater} 当前实例,便于链式调用
*/
on(key, fn) {
(this.dispatch[key] || (this.dispatch[key] = [])).push(fn);
return this;
}
/**
* 对比两次 script 标签快照
* @param {string[]|null} oldArr 旧快照
* @param {string[]|null} newArr 新快照
* @returns {void}
*/
compare(oldArr, newArr) {
const base = oldArr.length;
// 兼容 match 返回 null 的场景,避免 compare 触发运行时异常
const safeOldArr = Array.isArray(oldArr) ? oldArr : [];
const safeNewArr = Array.isArray(newArr) ? newArr : [];
const base = safeOldArr.length;
// 去重
const arr = Array.from(new Set(oldArr.concat(newArr)));
const arr = Array.from(new Set(safeOldArr.concat(safeNewArr)));
//如果新旧length 一样无更新
if (arr.length === base) {
this.dispatch['no-update'].forEach((fn) => {
fn();
});
const fns = Array.isArray(this.dispatch['no-update']) ? this.dispatch['no-update'] : [];
fns.forEach((fn) => fn());
} else {
//否则通知更新
this.dispatch['update'].forEach((fn) => {
fn();
});
const fns = Array.isArray(this.dispatch['update']) ? this.dispatch['update'] : [];
fns.forEach((fn) => fn());
}
}
/**
* 开始轮询
* @param {number} time 轮询间隔(毫秒)
* @returns {void}
*/
timing(time = 10000) {
//轮询
setInterval(async () => {
......
......@@ -72,6 +72,10 @@ const idCode = ref('');
const show_error = ref(false);
const error_message = ref('');
/**
* 校验姓名
* @returns {boolean} 是否通过
*/
const checkUsername = () => { // 检查用户名是否为空
let flag = true;
if (!username.value) {
......@@ -82,6 +86,12 @@ const checkUsername = () => { // 检查用户名是否为空
return flag;
}
/**
* 校验证件号
* - 身份证:18 位使用 cin 校验;15 位不校验(兼容老证件)
* - 其他证件:仅做非空校验
* @returns {boolean} 是否通过
*/
const checkIdCode = () => { // 检查身份证号是否为空
let flag = true;
if (!idCode.value) {
......@@ -103,6 +113,10 @@ const checkIdCode = () => { // 检查身份证号是否为空
return flag;
}
/**
* 保存参观者信息
* @returns {Promise<void>}
*/
const addVisitor = async () => {
// 保存用户信息
if (checkUsername() && checkIdCode()) {
......@@ -117,6 +131,10 @@ const addVisitor = async () => {
}
const showPicker = ref(false);
/**
* 打开/关闭证件类型选择器
* @returns {void}
*/
const idTypeChange = () => {
showPicker.value = !showPicker.value;
}
......@@ -128,6 +146,11 @@ const columns = [
const fieldValue = ref('身份证');
const id_type = ref(1);
/**
* 证件类型选择回调
* @param {{ selectedOptions: Array<{ text: string, value: number }> }} payload 选择结果
* @returns {void}
*/
const onConfirm = ({ selectedOptions }) => { // 切换类型回调
showPicker.value = false;
//
......
......@@ -229,6 +229,13 @@ const infinity_tips_text = computed(() => {
return '暂未开启预约';
});
/**
* 选择时间段
* - 仅余量大于 0 或“不限量”时允许选择
* @param {any} item 时间段数据
* @param {number} index 时间段下标
* @returns {void}
*/
const chooseTime = (item, index) => { // 选择时间段回调
if (item.rest_qty || item.rest_qty === QtyStatus.INFINITY) { // 余量等于-1为不限制数量
checked_time.value = index;
......@@ -237,14 +244,22 @@ const chooseTime = (item, index) => { // 选择时间段回调
};
/**
* @description: 数量状态
* @return {object} {FULL: 0, INFINITY: -1 }
* 数量状态
* - FULL:无余量
* - INFINITY:不限量
* @type {{ FULL: number, INFINITY: number }}
*/
const QtyStatus = {
FULL: 0, // 无余量
INFINITY: -1, // 无限制
}
/**
* 选择日期
* - 可约时查询对应日期的时间段列表
* @param {string} date 日期(YYYY-MM-DD)
* @returns {Promise<void>}
*/
const chooseDay = async (date) => { // 点击日期回调
if (!date) return;
const info = findDatesInfo(date);
......@@ -264,6 +279,10 @@ const chooseDay = async (date) => { // 点击日期回调
};
const showPicker = ref(false);
/**
* 打开月份选择器
* @returns {void}
*/
const chooseDate = () => {
showPicker.value = true;
}
......@@ -275,6 +294,13 @@ const minDate = new Date();
const maxDate = new Date(2050, 11, 1);
const currentDateText = ref((raw_date.getMonth() + 1).toString().padStart(2, '0'));
/**
* 月份选择确认回调
* - 重置当前选择的日期/时段
* - 拉取该月可预约日期列表
* @param {{ selectedValues: number[], selectedOptions: any[] }} payload 选择结果
* @returns {Promise<void>}
*/
const onConfirm = async ({ selectedValues, selectedOptions }) => { // 选择日期回调
showPicker.value = false;
currentDateText.value = selectedValues[1].toString();
......@@ -297,12 +323,21 @@ const onConfirm = async ({ selectedValues, selectedOptions }) => { // 选择日
}
}
/**
* 月份选择取消回调
* @returns {void}
*/
const onCancel = () => {
showPicker.value = false;
}
const show_error = ref(false);
const error_message = ref('');
/**
* 下一步:进入提交页
* - 需要先选择日期与时间段
* @returns {void}
*/
const nextBtn = () => {
if (!checked_day.value || checked_time.value === -1) {
show_error.value = true;
......
......@@ -49,6 +49,7 @@ const finished = ref(false);
const finishedTextStatus = ref(false);
onMounted(async () => {
// 初始化第一页数据
const { code, data } = await billListAPI({ page: page.value, row_num: limit.value });
if (code) {
// 格式化数据
......@@ -60,6 +61,11 @@ onMounted(async () => {
}
});
/**
* 列表触底加载
* - van-list 触发 @load
* @returns {Promise<void>}
*/
const onLoad = async () => {
page.value++;
const { code, data } = await billListAPI({ page: page.value, row_num: limit.value });
......
......@@ -36,8 +36,8 @@
<script setup>
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { onAuthBillInfoAPI, icbcOrderQryAPI } from '@/api/index'
import { Cookies, axios, storeToRefs, mainStore, Toast, useTitle } from '@/utils/generatePackage.js'
import { onAuthBillInfoAPI } from '@/api/index'
import { useTitle } from '@/utils/generatePackage.js'
//import { } from '@/utils/generateModules.js'
//import { } from '@/utils/generateIcons.js'
//import { } from '@/composables'
......@@ -54,8 +54,12 @@ const PAY_STATUS = {
}
const pay_status = ref('0'); // 默认支付完成
//获取url中返回参数
function getQueryString(name) {
/**
* 获取 URL 查询参数
* @param {string} name 参数名
* @returns {string|null} 参数值
*/
function getQueryString(name) {
var query = window.location.search.substring(1);
var vars = query.split("&");
for (var i = 0; i < vars.length; i++) {
......@@ -72,6 +76,12 @@ function getQueryString(name) {
// const out_trade_no = '110286610147000542401290012506';
const out_trade_no = $route.query.out_trade_no;
/**
* 支付回跳处理
* - 读取订单信息并渲染
* - 通知上层支付 iframe:页面已准备完成(自定义协议)
* @returns {Promise<void>}
*/
const callback = async () => {
// 获取订单详情
const { code:code_pay, data:data_pay } = await onAuthBillInfoAPI({ order_id: out_trade_no });
......@@ -86,6 +96,10 @@ const callback = async () => {
}
}
/**
* 通知支付 iframe 跳出回到商户页面(自定义协议)
* @returns {void}
*/
const returnMerchant = () => {
var mchData = {
action: 'jumpOut',
......@@ -95,6 +109,7 @@ const returnMerchant = () => {
var postData = JSON.stringify(mchData)
top.postMessage(postData, "*")
}
// 页面加载后立即拉取订单信息(支付回跳场景)
callback();
onMounted(async () => {
......
......@@ -82,8 +82,10 @@ const toHome = () => { // 跳转到首页
const visitorList = ref([]);
/**
* 生成15位身份证号中间8位替换为*号
* @param {*} inputString
* 生成脱敏后的证件号
* - 仅对长度 >= 15 的字符串进行处理中间 8 位替换
* @param {string} inputString 证件号
* @returns {string} 脱敏后的证件号
*/
function replaceMiddleCharacters(inputString) {
if (inputString.length < 15) {
......@@ -99,10 +101,20 @@ function replaceMiddleCharacters(inputString) {
return replacedString;
}
/**
* 格式化证件号显示(脱敏)
* @param {string} id 证件号
* @returns {string} 脱敏后的证件号
*/
const formatId = (id) => {
return replaceMiddleCharacters(id);
};
/**
* 删除参观者
* @param {any} item 参观者信息
* @returns {void}
*/
const removeItem = (item) => {
showConfirmDialog({
title: '温馨提示',
......@@ -124,6 +136,7 @@ const removeItem = (item) => {
}
onMounted(async () => {
// 初始化参观者列表
const { code, data } = await personListAPI();
if (code) {
visitorList.value = data;
......
......@@ -82,6 +82,12 @@ const id_number = ref('');
const show_error = ref(false);
const error_message = ref('');
/**
* 校验证件号
* - 身份证:18 位使用 cin 校验;15 位不校验(兼容老证件)
* - 其他证件:仅做非空校验
* @returns {boolean} 是否通过
*/
const checkIdCode = () => { // 检查身份证号是否为空
let flag = true;
if (!idCode.value) {
......@@ -103,6 +109,11 @@ const checkIdCode = () => { // 检查身份证号是否为空
return flag;
}
/**
* 进入查询结果页
* - 当前实现仅切换展示组件,由组件内部发起查询
* @returns {Promise<void>}
*/
const searchBtn = async () => {
// 查询用户信息
if (checkIdCode()) {
......@@ -111,14 +122,26 @@ const searchBtn = async () => {
idCode.value = ''
}
}
/**
* 返回查询输入页
* @returns {void}
*/
const goBack = () => {
is_search.value = false;
}
/**
* 返回首页
* @returns {void}
*/
const goToHome = () => {
go('/')
}
const showPicker = ref(false);
/**
* 打开/关闭证件类型选择器
* @returns {void}
*/
const idTypeChange = () => {
showPicker.value = !showPicker.value;
}
......@@ -130,6 +153,11 @@ const columns = [
const fieldValue = ref('身份证');
const id_type = ref(1);
/**
* 证件类型选择回调
* @param {{ selectedOptions: Array<{ text: string, value: number }> }} payload 选择结果
* @returns {void}
*/
const onConfirm = ({ selectedOptions }) => { // 切换类型回调
showPicker.value = false;
fieldValue.value = selectedOptions[0].text;
......
......@@ -67,8 +67,10 @@ const go = useGo();
const visitorList = ref([]);
/**
* 生成15位身份证号中间8位替换为*号
* @param {*} inputString
* 生成脱敏后的证件号
* - 仅对长度 >= 15 的字符串进行处理中间 8 位替换
* @param {string} inputString 证件号
* @returns {string} 脱敏后的证件号
*/
function replaceMiddleCharacters(inputString) {
if (inputString.length < 15) {
......@@ -84,6 +86,11 @@ function replaceMiddleCharacters(inputString) {
return replacedString;
}
/**
* 格式化证件号显示(脱敏)
* @param {string} id 证件号
* @returns {string} 脱敏后的证件号
*/
const formatId = (id) => {
return replaceMiddleCharacters(id);
};
......@@ -93,6 +100,12 @@ const RESERVE_STATUS = {
}
const checked_visitors = ref([]);
/**
* 勾选/取消勾选参观者
* - 已预约的参观者不允许再次勾选
* @param {any} item 参观者信息
* @returns {void}
*/
const addVisitor = (item) => {
if (item.is_reserve === RESERVE_STATUS.ENABLE) { // 今天已经预约
showToast('已预约过参观,请不要重复预约')
......@@ -114,13 +127,27 @@ const total = computed(() => {
return price * checked_visitors.value.length;
})
/**
* 返回预约时段选择页
* @returns {void}
*/
const goToBooking = () => {
go('/booking');
}
/**
* 跳转到参观者管理页
* @returns {void}
*/
const goToVisitor = () => {
go('/addVisitor');
}
/**
* 提交订单并跳转支付
* - 免费订单直接进入成功页
* - 需要支付则跳转到工行支付页面
* @returns {Promise<void>}
*/
const submitBtn = async () => {
if (!checked_visitors.value.length) {
showToast('请先添加参观者')
......@@ -144,6 +171,7 @@ const submitBtn = async () => {
}
onMounted(async () => {
// 初始化参观者列表,并标记“是否已预约”
const { code, data } = await personListAPI({ reserve_date: date, begin_time: time.split('-')[0], end_time: time.split('-')[1], period_type });
if (code) {
visitorList.value = data;
......
......@@ -104,14 +104,27 @@ const status_icon_color = computed(() => {
return '#A67939'
})
/**
* @description 对身份证号做脱敏展示(仅用于前端展示)
* @param {string} id 证件号码
* @returns {string} 脱敏后的证件号码
*/
const format_id_number = (id) => {
if (!id || typeof id !== 'string' || id.length < 10) return id
return id.replace(/^(.{6})(?:\d+)(.{4})$/, '$1********$2')
}
/**
* @description 统一扫码结果格式
* - 条形码可能返回 "codeType,codeValue" 格式,这里取最后一段
* - 二维码可能是 URL,尝试从 query 里解析 qr_code
* @param {unknown} raw 原始扫码结果
* @returns {string} 解析后的核销码(失败返回空字符串)
*/
const normalize_scan_result = (raw) => {
if (!raw) return ''
const text = String(raw)
// 部分机型扫描条形码会返回 "CODE_128,123456" 这种格式
const barcode_split = text.split(',')
const candidate = barcode_split.length > 1 ? barcode_split[barcode_split.length - 1] : text
if (candidate.includes('qr_code=')) {
......@@ -119,6 +132,7 @@ const normalize_scan_result = (raw) => {
const url = new URL(candidate)
return url.searchParams.get('qr_code') || candidate
} catch (e) {
// 兼容不完整 URL 或低版本浏览器不支持 URL 构造的情况
const match = candidate.match(/(?:\?|&)qr_code=([^&]+)/)
if (match && match[1]) return decodeURIComponent(match[1])
}
......@@ -126,6 +140,10 @@ const normalize_scan_result = (raw) => {
return candidate
}
/**
* @description 校验当前账号是否有核销权限,无权限时重定向到义工登录
* @returns {Promise<boolean>} 是否具备核销权限
*/
const ensure_permission = async () => {
const permission_res = await checkRedeemPermissionAPI()
if (!permission_res || permission_res?.code !== 1) {
......@@ -140,6 +158,11 @@ const ensure_permission = async () => {
return true
}
/**
* @description 核销预约码
* @param {string} code 扫码结果或手动输入内容
* @returns {Promise<void>}
*/
const verify_ticket = async (code) => {
const normalized = normalize_scan_result(code)
if (!normalized) return
......@@ -162,7 +185,12 @@ const verify_ticket = async (code) => {
verify_info.value = {}
}
/**
* @description 在微信环境内调起 JSSDK 扫码
* @returns {Promise<string>} 扫码原始结果(失败返回空字符串)
*/
const scan_in_wechat = async () => {
// 依赖 App.vue 全局初始化的 Promise(wx.ready / wx.error 都会 resolve)
const ok = await window.__wx_ready_promise
if (!ok) return ''
return new Promise((resolve) => {
......@@ -176,10 +204,15 @@ const scan_in_wechat = async () => {
})
}
/**
* @description 点击按钮:先做权限校验,再根据环境选择“微信扫码”或“手动核销”
* @returns {Promise<void>}
*/
const start_scan_and_verify = async () => {
const authed = await ensure_permission()
if (!authed) return
// 这里使用 wxInfo 的环境判断结果,避免在非微信环境误调用 JSSDK
const in_wechat = wxInfo().isTable === true
if (in_wechat) {
const result = await scan_in_wechat()
......@@ -201,7 +234,11 @@ const start_scan_and_verify = async () => {
showToast('请在微信内扫码,或手动输入预约码')
}
onMounted(async () => {
/**
* @description 页面初始化:读取 URL 参数并自动核销(适配“扫码后跳转页面”场景)
* @returns {Promise<void>}
*/
const init_from_query = async () => {
const authed = await ensure_permission()
if (!authed) return
const code = $route.query?.result || $route.query?.qr_code || ''
......@@ -210,19 +247,28 @@ onMounted(async () => {
manual_code.value = str_code
await verify_ticket(str_code)
}
})
}
onMounted(init_from_query)
/**
* @description 监听路由参数变化,支持“同页面多次扫码/回传 result”场景
* @param {unknown} next 新的 result 参数
* @returns {Promise<void>}
*/
const watch_route_result = async (next) => {
const code = Array.isArray(next) ? next[0] : String(next || '')
if (!code) return
if (verify_code.value === code) return
const authed = await ensure_permission()
if (!authed) return
manual_code.value = code
await verify_ticket(code)
}
watch(
() => $route.query?.result,
async (next) => {
const code = Array.isArray(next) ? next[0] : String(next || '')
if (!code) return
if (verify_code.value === code) return
const authed = await ensure_permission()
if (!authed) return
manual_code.value = code
await verify_ticket(code)
}
watch_route_result
)
</script>
......
......@@ -66,8 +66,10 @@ const toHome = () => { // 跳转到首页
const visitorList = ref([]);
/**
* 生成15位身份证号中间8位替换为*号
* @param {*} inputString
* 生成脱敏后的证件号
* - 仅对长度 >= 15 的字符串进行处理中间 8 位替换
* @param {string} inputString 证件号
* @returns {string} 脱敏后的证件号
*/
function replaceMiddleCharacters(inputString) {
if (inputString.length < 15) {
......@@ -83,10 +85,20 @@ function replaceMiddleCharacters(inputString) {
return replacedString;
}
/**
* 格式化证件号显示(脱敏)
* @param {string} id 证件号
* @returns {string} 脱敏后的证件号
*/
const formatId = (id) => {
return replaceMiddleCharacters(id);
};
/**
* 删除参观者
* @param {any} item 参观者信息
* @returns {void}
*/
const removeItem = (item) => {
showConfirmDialog({
title: '温馨提示',
......@@ -108,6 +120,7 @@ const removeItem = (item) => {
}
onMounted(async () => {
// 初始化参观者列表
const { code, data } = await personListAPI();
if (code) {
visitorList.value = data;
......
......@@ -33,6 +33,10 @@ const username = ref('')
const password = ref('')
const loading = ref(false)
/**
* @description 进入页面时先做权限校验,已有核销权限则直接跳转核销页
* @returns {Promise<void>}
*/
const check_permission_and_redirect = async () => {
const permission_res = await checkRedeemPermissionAPI()
if (!permission_res) return
......@@ -42,10 +46,15 @@ const check_permission_and_redirect = async () => {
}
}
onMounted(() => {
check_permission_and_redirect()
})
onMounted(check_permission_and_redirect)
/**
* @description 义工登录
* - 先登录获取会话/权限凭证
* - 再调用权限校验接口,确认具备核销权限
* - 成功后跳转核销页
* @returns {Promise<void>}
*/
const handle_login = async () => {
if (!username.value || !password.value) {
showToast('请输入账号密码')
......@@ -61,6 +70,7 @@ const handle_login = async () => {
return
}
// 登录成功后做一次权限校验,避免“登录成功但无核销权限”的误导
const permission_res = await checkRedeemPermissionAPI()
if (!permission_res || permission_res?.code !== 1) {
showToast(permission_res?.msg || '权限校验失败')
......@@ -71,6 +81,7 @@ const handle_login = async () => {
if (permission_res?.data?.can_redeem === true) {
showSuccessToast(permission_res?.msg || login_res?.msg || '登录成功')
// 延迟跳转,确保用户能看到成功提示
setTimeout(() => $router.replace({ path: '/verificationResult' }), 800)
return
}
......
......@@ -57,12 +57,21 @@ const wx_ready_text = computed(() => {
return '未检测'
})
/**
* @description 确保微信 JSSDK 已完成初始化(依赖 App.vue 注入的全局 Promise)
* @returns {Promise<boolean>} 是否已就绪
*/
const ensure_wx_ready = async () => {
// App.vue 在启动时会初始化 wx.config,并把 ready 结果写到 window.__wx_ready_promise
if (!window.__wx_ready_promise) return false
const ok = await window.__wx_ready_promise
return ok === true
}
/**
* @description 调起微信扫码并展示结果(仅微信环境可用)
* @returns {Promise<void>}
*/
const start_scan = async () => {
if (!in_wechat.value) {
showToast('请在微信内打开该页面')
......@@ -78,6 +87,7 @@ const start_scan = async () => {
showToast('wx 初始化失败')
return
}
// JSSDK 扫码:needResult=1 表示直接拿到结果文本,不跳转微信扫码结果页
const result = await new Promise((resolve) => {
wx.scanQRCode({
needResult: 1,
......@@ -94,13 +104,19 @@ const start_scan = async () => {
}
}
onMounted(async () => {
/**
* @description 页面初始化:同步展示“是否在微信内”和“JSSDK 是否就绪”
* @returns {Promise<void>}
*/
const init_scan_test_page = async () => {
if (!in_wechat.value) {
wx_ready.value = false
return
}
wx_ready.value = await ensure_wx_ready()
})
}
onMounted(init_scan_test_page)
</script>
<style lang="less" scoped>
......