hookehuyr

feat: 新增义工登录和核销功能模块

添加义工登录页面和核销结果页面
实现义工登录、权限校验和票务核销API
更新路由配置和axios拦截器逻辑
添加相关依赖和类型检查配置
This diff is collapsed. Click to expand it.
......@@ -79,6 +79,7 @@
"vite": "^2.9.9",
"vite-plugin-style-import": "1.4.1",
"vue-esign": "^1.1.4",
"vue-router": "^4.0.15"
"vue-router": "^4.0.15",
"vue-tsc": "^1.8.27"
}
}
......
/*
* @Date: 2026-01-22 10:02:18
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-22 10:09:55
* @FilePath: /git/xysBooking/src/api/redeem.js
* @Description: 文件描述
*/
import { fn, fetch } from '@/api/fn';
const Api = {
BASE: '/srv/',
};
export const volunteerLoginAPI = (params) => {
return fn(fetch.basePost(Api.BASE, params, { params: { f: 'reserve_admin', a: 'login' } }));
};
export const checkRedeemPermissionAPI = (params) => {
return fn(fetch.get(Api.BASE, { ...(params || {}), f: 'reserve_admin', a: 'user', t: 'check_auth' }));
};
export const verifyTicketAPI = (params) => {
return fn(fetch.basePost(Api.BASE, params, { params: { f: 'reserve_admin', a: 'bill', t: 'redeem' } }));
};
/*
* @Date: 2023-06-13 13:26:46
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2024-02-06 12:05:22
* @FilePath: /xysBooking/src/route.js
* @LastEditTime: 2026-01-22 10:02:16
* @FilePath: /git/xysBooking/src/route.js
* @Description: 路由列表
*/
export default [
......@@ -123,4 +123,18 @@ export default [
title: '我的',
},
},
{
path: '/volunteerLogin',
component: () => import('@/views/volunteerLogin.vue'),
meta: {
title: '义工登录',
},
},
{
path: '/verificationResult',
component: () => import('@/views/verificationResult.vue'),
meta: {
title: '核销',
},
},
];
......
......@@ -51,14 +51,14 @@ axios.interceptors.response.use(
// // C/B 授权拼接头特殊标识,openid_x
// let prefixAPI = router?.currentRoute.value.href?.indexOf('business') > 0 ? 'b' : 'c';
if (response.data.code === 401) {
// 特殊标识-带此标识报错不显示
response.data.show = false;
/**
* 未授权跳转登录页
* 带着上一个页面的信息, 授权完成后 返回当前页面
*/
const request_params = response?.config?.params || {};
const is_redeem_admin = request_params?.f === 'reserve_admin';
const current_path = router?.currentRoute?.value?.path || '';
if (!is_redeem_admin && current_path !== '/auth') {
router.replace({ path: '/auth', query: { href: location.hash } });
}
}
if (['预约ID不存在'].includes(response.data.msg)) {
response.data.show = false;
}
......
<template>
<div class="verify-page">
<div class="status-card">
<div class="status-icon" :class="verify_status">
<van-icon :name="status_icon" size="24" :color="status_icon_color" />
</div>
<div class="status-text">
<div class="title">{{ status_title }}</div>
<div class="desc" v-if="verify_status === 'idle'">扫描预约码二维码进行核销</div>
<div class="desc" v-else>{{ msg }}</div>
</div>
</div>
<div class="info-card">
<div class="card-title">核销记录信息</div>
<template v-if="verify_info && Object.keys(verify_info).length > 0">
<div class="row">
<div class="label">姓名</div>
<div class="value">{{ verify_info.person_name || '-' }}</div>
</div>
<div class="row">
<div class="label">证件号码</div>
<div class="value">{{ format_id_number(verify_info.id_number) || '-' }}</div>
</div>
<div class="row">
<div class="label">状态</div>
<div class="value highlight">{{ verify_info.status || '-' }}</div>
</div>
<div class="row">
<div class="label">预约开始</div>
<div class="value">{{ verify_info.begin_time || '-' }}</div>
</div>
<div class="row last">
<div class="label">预约结束</div>
<div class="value">{{ verify_info.end_time || '-' }}</div>
</div>
</template>
<template v-else-if="verify_status === 'fail'">
<div class="fail-reason">
<div class="label">失败原因</div>
<div class="reason">{{ msg }}</div>
</div>
</template>
<template v-else>
<div class="empty">暂无核销信息</div>
</template>
</div>
<div class="verify-footer">
<van-field v-model="manual_code" placeholder="可粘贴/输入预约码(非微信环境备用)" clearable />
<div class="btn-wrap">
<van-button
block
color="#A67939"
:loading="verify_status === 'verifying'"
:disabled="verify_status === 'verifying'"
@click="start_scan_and_verify"
>
核销
</van-button>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import wx from 'weixin-js-sdk'
import { showToast } from 'vant'
import { apiList } from '@/api/wx/jsApiList'
import { wxJsAPI } from '@/api/wx/config'
import { mainStore } from '@/store'
import { checkRedeemPermissionAPI, verifyTicketAPI } from '@/api/redeem'
import { wxInfo } from '@/utils/tools'
const store = mainStore()
const $route = useRoute()
const $router = useRouter()
const manual_code = ref('')
const verify_code = ref('')
const verify_info = ref({})
const verify_status = ref('idle')
const msg = ref('请点击下方按钮进行核销')
const status_title = computed(() => {
if (verify_status.value === 'verifying') return '核销中'
if (verify_status.value === 'success') return '核销成功'
if (verify_status.value === 'fail') return '核销失败'
return '核销'
})
const status_icon = computed(() => {
if (verify_status.value === 'verifying') return 'clock-o'
if (verify_status.value === 'success') return 'passed'
if (verify_status.value === 'fail') return 'close'
return 'info-o'
})
const status_icon_color = computed(() => {
if (verify_status.value === 'fail') return '#E24A4A'
return '#A67939'
})
const format_id_number = (id) => {
if (!id || typeof id !== 'string' || id.length < 10) return id
return id.replace(/^(.{6})(?:\d+)(.{4})$/, '$1********$2')
}
const normalize_scan_result = (raw) => {
if (!raw) return ''
const text = String(raw)
const barcode_split = text.split(',')
const candidate = barcode_split.length > 1 ? barcode_split[barcode_split.length - 1] : text
if (candidate.includes('qr_code=')) {
try {
const url = new URL(candidate)
return url.searchParams.get('qr_code') || candidate
} catch (e) {
const match = candidate.match(/(?:\?|&)qr_code=([^&]+)/)
if (match && match[1]) return decodeURIComponent(match[1])
}
}
return candidate
}
const ensure_permission = async () => {
const permission_res = await checkRedeemPermissionAPI()
if (!permission_res || permission_res?.code !== 1) {
$router.replace({ path: '/volunteerLogin' })
return false
}
if (permission_res?.data) store.changeUserInfo(permission_res.data)
if (permission_res?.data?.can_redeem !== true) {
$router.replace({ path: '/volunteerLogin' })
return false
}
return true
}
const verify_ticket = async (code) => {
const normalized = normalize_scan_result(code)
if (!normalized) return
if (verify_status.value === 'verifying') return
verify_code.value = normalized
verify_status.value = 'verifying'
msg.value = '核销中...'
const res = await verifyTicketAPI({ qr_code: normalized })
if (res?.code === 1) {
verify_status.value = 'success'
msg.value = res?.msg || '核销成功'
verify_info.value = res?.data || {}
return
}
verify_status.value = 'fail'
msg.value = res?.msg || '核销失败'
verify_info.value = {}
}
const init_wx_scan = async () => {
const cfg_res = await wxJsAPI({ url: window.location.href.split('#')[0] })
if (!cfg_res || cfg_res?.code !== 1 || !cfg_res?.data) return false
const cfg = { ...cfg_res.data, jsApiList: apiList }
return new Promise((resolve) => {
wx.config(cfg)
wx.ready(() => resolve(true))
wx.error(() => resolve(false))
})
}
const scan_in_wechat = async () => {
const ok = await init_wx_scan()
if (!ok) return ''
return new Promise((resolve) => {
wx.scanQRCode({
needResult: 1,
scanType: ['qrCode', 'barCode'],
success: (res) => resolve(res?.resultStr || ''),
fail: () => resolve(''),
cancel: () => resolve(''),
})
})
}
const start_scan_and_verify = async () => {
const authed = await ensure_permission()
if (!authed) return
const in_wechat = wxInfo().isTable === true
if (in_wechat) {
const result = await scan_in_wechat()
const code = normalize_scan_result(result)
if (!code) {
if (manual_code.value) await verify_ticket(manual_code.value)
else showToast('未获取到二维码内容')
return
}
await verify_ticket(code)
return
}
if (manual_code.value) {
await verify_ticket(manual_code.value)
return
}
showToast('请在微信内扫码,或手动输入预约码')
}
onMounted(async () => {
const authed = await ensure_permission()
if (!authed) return
const code = $route.query?.result || $route.query?.qr_code || ''
const str_code = Array.isArray(code) ? code[0] : String(code || '')
if (str_code) {
manual_code.value = str_code
await verify_ticket(str_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)
}
)
</script>
<style lang="less" scoped>
.verify-page {
min-height: 100vh;
background-color: #F6F6F6;
padding: 16px;
padding-bottom: 160px;
box-sizing: border-box;
.status-card {
background: #fff;
border-radius: 12px;
padding: 16px;
display: flex;
align-items: center;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.04);
.status-icon {
width: 48px;
height: 48px;
border-radius: 50%;
background-color: #FFF7ED;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
}
.status-text {
flex: 1;
.title {
font-size: 18px;
font-weight: 600;
color: #111827;
}
.desc {
margin-top: 4px;
font-size: 13px;
color: #6B7280;
word-break: break-all;
}
}
}
.info-card {
margin-top: 12px;
background: #fff;
border-radius: 12px;
padding: 16px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.04);
.card-title {
font-size: 14px;
color: #6B7280;
margin-bottom: 12px;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid #F3F4F6;
.label {
font-size: 14px;
color: #6B7280;
}
.value {
font-size: 16px;
color: #111827;
font-weight: 600;
margin-left: 12px;
word-break: break-all;
text-align: right;
}
.highlight {
color: #A67939;
}
}
.row.last {
border-bottom: 0;
}
.fail-reason {
.label {
font-size: 14px;
color: #6B7280;
margin-bottom: 8px;
}
.reason {
font-size: 16px;
color: #E24A4A;
font-weight: 600;
word-break: break-all;
}
}
.empty {
font-size: 14px;
color: #9CA3AF;
padding: 8px 0;
}
}
.verify-footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
background-color: #fff;
box-shadow: 0 -10px 18px rgba(0, 0, 0, 0.06);
box-sizing: border-box;
.btn-wrap {
margin-top: 10px;
}
}
}
</style>
<template>
<div class="volunteer-login-page">
<div class="logo-section">
<img :src="logo" alt="logo" class="logo" />
<div class="app-name">义工登录</div>
</div>
<div class="login-card">
<van-field v-model="username" label="账号" placeholder="请输入账号" clearable />
<van-field v-model="password" label="密码" type="password" placeholder="请输入密码" clearable />
<div class="btn-wrap">
<van-button block color="#A67939" :loading="loading" :disabled="loading" @click="handle_login">
立即登录
</van-button>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import { mainStore } from '@/store'
import { checkRedeemPermissionAPI, volunteerLoginAPI } from '@/api/redeem'
import logo from '@/assets/images/logo_01.png'
const store = mainStore()
const $router = useRouter()
const username = ref('')
const password = ref('')
const loading = ref(false)
const check_permission_and_redirect = async () => {
const permission_res = await checkRedeemPermissionAPI()
if (!permission_res) return
if (permission_res?.data) store.changeUserInfo(permission_res.data)
if (permission_res?.data?.can_redeem === true) {
$router.replace({ path: '/verificationResult' })
}
}
onMounted(() => {
check_permission_and_redirect()
})
const handle_login = async () => {
if (!username.value || !password.value) {
showToast('请输入账号密码')
return
}
loading.value = true
const login_res = await volunteerLoginAPI({ uuid: username.value, password: password.value })
loading.value = false
if (!login_res || login_res?.code !== 1) {
showToast(login_res?.msg || '登录失败')
return
}
const permission_res = await checkRedeemPermissionAPI()
if (!permission_res || permission_res?.code !== 1) {
showToast(permission_res?.msg || '权限校验失败')
return
}
if (permission_res?.data) store.changeUserInfo(permission_res.data)
if (permission_res?.data?.can_redeem === true) {
showSuccessToast(permission_res?.msg || login_res?.msg || '登录成功')
setTimeout(() => $router.replace({ path: '/verificationResult' }), 800)
return
}
showToast(permission_res?.msg || '暂无核销权限')
}
</script>
<style lang="less" scoped>
.volunteer-login-page {
min-height: 100vh;
background-color: #F6F6F6;
display: flex;
flex-direction: column;
align-items: center;
padding: 80px 16px 16px;
box-sizing: border-box;
.logo-section {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 20px;
.logo {
width: 80px;
height: 80px;
border-radius: 50%;
background-color: #fff;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
object-fit: contain;
}
.app-name {
margin-top: 12px;
font-size: 20px;
font-weight: 600;
color: #333;
letter-spacing: 2px;
}
}
.login-card {
width: 100%;
max-width: 420px;
background: #fff;
border-radius: 12px;
padding: 12px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.04);
box-sizing: border-box;
.btn-wrap {
margin-top: 16px;
}
}
}
</style>
......@@ -54,6 +54,7 @@
"jquery",
],
"allowSyntheticDefaultImports": true, // 允许从没有设置默认导出的模块中默认导入。
"skipLibCheck": true,
/* Source Map Options */
// "sourceRoot": "./", // 指定调试器应该找到 TypeScript 文件而不是源文件的位置
......
This diff is collapsed. Click to expand it.