hookehuyr

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

添加义工登录页面和核销结果页面
实现义工登录、权限校验和票务核销API
更新路由配置和axios拦截器逻辑
添加相关依赖和类型检查配置
This diff is collapsed. Click to expand it.
...@@ -79,6 +79,7 @@ ...@@ -79,6 +79,7 @@
79 "vite": "^2.9.9", 79 "vite": "^2.9.9",
80 "vite-plugin-style-import": "1.4.1", 80 "vite-plugin-style-import": "1.4.1",
81 "vue-esign": "^1.1.4", 81 "vue-esign": "^1.1.4",
82 - "vue-router": "^4.0.15" 82 + "vue-router": "^4.0.15",
83 + "vue-tsc": "^1.8.27"
83 } 84 }
84 } 85 }
......
1 +/*
2 + * @Date: 2026-01-22 10:02:18
3 + * @LastEditors: hookehuyr hookehuyr@gmail.com
4 + * @LastEditTime: 2026-01-22 10:09:55
5 + * @FilePath: /git/xysBooking/src/api/redeem.js
6 + * @Description: 文件描述
7 + */
8 +import { fn, fetch } from '@/api/fn';
9 +
10 +const Api = {
11 + BASE: '/srv/',
12 +};
13 +
14 +export const volunteerLoginAPI = (params) => {
15 + return fn(fetch.basePost(Api.BASE, params, { params: { f: 'reserve_admin', a: 'login' } }));
16 +};
17 +
18 +export const checkRedeemPermissionAPI = (params) => {
19 + return fn(fetch.get(Api.BASE, { ...(params || {}), f: 'reserve_admin', a: 'user', t: 'check_auth' }));
20 +};
21 +
22 +export const verifyTicketAPI = (params) => {
23 + return fn(fetch.basePost(Api.BASE, params, { params: { f: 'reserve_admin', a: 'bill', t: 'redeem' } }));
24 +};
1 /* 1 /*
2 * @Date: 2023-06-13 13:26:46 2 * @Date: 2023-06-13 13:26:46
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2024-02-06 12:05:22 4 + * @LastEditTime: 2026-01-22 10:02:16
5 - * @FilePath: /xysBooking/src/route.js 5 + * @FilePath: /git/xysBooking/src/route.js
6 * @Description: 路由列表 6 * @Description: 路由列表
7 */ 7 */
8 export default [ 8 export default [
...@@ -123,4 +123,18 @@ export default [ ...@@ -123,4 +123,18 @@ export default [
123 title: '我的', 123 title: '我的',
124 }, 124 },
125 }, 125 },
126 + {
127 + path: '/volunteerLogin',
128 + component: () => import('@/views/volunteerLogin.vue'),
129 + meta: {
130 + title: '义工登录',
131 + },
132 + },
133 + {
134 + path: '/verificationResult',
135 + component: () => import('@/views/verificationResult.vue'),
136 + meta: {
137 + title: '核销',
138 + },
139 + },
126 ]; 140 ];
......
...@@ -51,14 +51,14 @@ axios.interceptors.response.use( ...@@ -51,14 +51,14 @@ axios.interceptors.response.use(
51 // // C/B 授权拼接头特殊标识,openid_x 51 // // C/B 授权拼接头特殊标识,openid_x
52 // let prefixAPI = router?.currentRoute.value.href?.indexOf('business') > 0 ? 'b' : 'c'; 52 // let prefixAPI = router?.currentRoute.value.href?.indexOf('business') > 0 ? 'b' : 'c';
53 if (response.data.code === 401) { 53 if (response.data.code === 401) {
54 - // 特殊标识-带此标识报错不显示
55 response.data.show = false; 54 response.data.show = false;
56 - /** 55 + const request_params = response?.config?.params || {};
57 - * 未授权跳转登录页 56 + const is_redeem_admin = request_params?.f === 'reserve_admin';
58 - * 带着上一个页面的信息, 授权完成后 返回当前页面 57 + const current_path = router?.currentRoute?.value?.path || '';
59 - */ 58 + if (!is_redeem_admin && current_path !== '/auth') {
60 router.replace({ path: '/auth', query: { href: location.hash } }); 59 router.replace({ path: '/auth', query: { href: location.hash } });
61 } 60 }
61 + }
62 if (['预约ID不存在'].includes(response.data.msg)) { 62 if (['预约ID不存在'].includes(response.data.msg)) {
63 response.data.show = false; 63 response.data.show = false;
64 } 64 }
......
1 +<template>
2 + <div class="verify-page">
3 + <div class="status-card">
4 + <div class="status-icon" :class="verify_status">
5 + <van-icon :name="status_icon" size="24" :color="status_icon_color" />
6 + </div>
7 + <div class="status-text">
8 + <div class="title">{{ status_title }}</div>
9 + <div class="desc" v-if="verify_status === 'idle'">扫描预约码二维码进行核销</div>
10 + <div class="desc" v-else>{{ msg }}</div>
11 + </div>
12 + </div>
13 +
14 + <div class="info-card">
15 + <div class="card-title">核销记录信息</div>
16 +
17 + <template v-if="verify_info && Object.keys(verify_info).length > 0">
18 + <div class="row">
19 + <div class="label">姓名</div>
20 + <div class="value">{{ verify_info.person_name || '-' }}</div>
21 + </div>
22 + <div class="row">
23 + <div class="label">证件号码</div>
24 + <div class="value">{{ format_id_number(verify_info.id_number) || '-' }}</div>
25 + </div>
26 + <div class="row">
27 + <div class="label">状态</div>
28 + <div class="value highlight">{{ verify_info.status || '-' }}</div>
29 + </div>
30 + <div class="row">
31 + <div class="label">预约开始</div>
32 + <div class="value">{{ verify_info.begin_time || '-' }}</div>
33 + </div>
34 + <div class="row last">
35 + <div class="label">预约结束</div>
36 + <div class="value">{{ verify_info.end_time || '-' }}</div>
37 + </div>
38 + </template>
39 +
40 + <template v-else-if="verify_status === 'fail'">
41 + <div class="fail-reason">
42 + <div class="label">失败原因</div>
43 + <div class="reason">{{ msg }}</div>
44 + </div>
45 + </template>
46 +
47 + <template v-else>
48 + <div class="empty">暂无核销信息</div>
49 + </template>
50 + </div>
51 +
52 + <div class="verify-footer">
53 + <van-field v-model="manual_code" placeholder="可粘贴/输入预约码(非微信环境备用)" clearable />
54 + <div class="btn-wrap">
55 + <van-button
56 + block
57 + color="#A67939"
58 + :loading="verify_status === 'verifying'"
59 + :disabled="verify_status === 'verifying'"
60 + @click="start_scan_and_verify"
61 + >
62 + 核销
63 + </van-button>
64 + </div>
65 + </div>
66 + </div>
67 +</template>
68 +
69 +<script setup>
70 +import { computed, onMounted, ref, watch } from 'vue'
71 +import { useRoute, useRouter } from 'vue-router'
72 +import wx from 'weixin-js-sdk'
73 +import { showToast } from 'vant'
74 +import { apiList } from '@/api/wx/jsApiList'
75 +import { wxJsAPI } from '@/api/wx/config'
76 +import { mainStore } from '@/store'
77 +import { checkRedeemPermissionAPI, verifyTicketAPI } from '@/api/redeem'
78 +import { wxInfo } from '@/utils/tools'
79 +
80 +const store = mainStore()
81 +const $route = useRoute()
82 +const $router = useRouter()
83 +
84 +const manual_code = ref('')
85 +const verify_code = ref('')
86 +const verify_info = ref({})
87 +const verify_status = ref('idle')
88 +const msg = ref('请点击下方按钮进行核销')
89 +
90 +const status_title = computed(() => {
91 + if (verify_status.value === 'verifying') return '核销中'
92 + if (verify_status.value === 'success') return '核销成功'
93 + if (verify_status.value === 'fail') return '核销失败'
94 + return '核销'
95 +})
96 +
97 +const status_icon = computed(() => {
98 + if (verify_status.value === 'verifying') return 'clock-o'
99 + if (verify_status.value === 'success') return 'passed'
100 + if (verify_status.value === 'fail') return 'close'
101 + return 'info-o'
102 +})
103 +
104 +const status_icon_color = computed(() => {
105 + if (verify_status.value === 'fail') return '#E24A4A'
106 + return '#A67939'
107 +})
108 +
109 +const format_id_number = (id) => {
110 + if (!id || typeof id !== 'string' || id.length < 10) return id
111 + return id.replace(/^(.{6})(?:\d+)(.{4})$/, '$1********$2')
112 +}
113 +
114 +const normalize_scan_result = (raw) => {
115 + if (!raw) return ''
116 + const text = String(raw)
117 + const barcode_split = text.split(',')
118 + const candidate = barcode_split.length > 1 ? barcode_split[barcode_split.length - 1] : text
119 + if (candidate.includes('qr_code=')) {
120 + try {
121 + const url = new URL(candidate)
122 + return url.searchParams.get('qr_code') || candidate
123 + } catch (e) {
124 + const match = candidate.match(/(?:\?|&)qr_code=([^&]+)/)
125 + if (match && match[1]) return decodeURIComponent(match[1])
126 + }
127 + }
128 + return candidate
129 +}
130 +
131 +const ensure_permission = async () => {
132 + const permission_res = await checkRedeemPermissionAPI()
133 + if (!permission_res || permission_res?.code !== 1) {
134 + $router.replace({ path: '/volunteerLogin' })
135 + return false
136 + }
137 + if (permission_res?.data) store.changeUserInfo(permission_res.data)
138 + if (permission_res?.data?.can_redeem !== true) {
139 + $router.replace({ path: '/volunteerLogin' })
140 + return false
141 + }
142 + return true
143 +}
144 +
145 +const verify_ticket = async (code) => {
146 + const normalized = normalize_scan_result(code)
147 + if (!normalized) return
148 + if (verify_status.value === 'verifying') return
149 +
150 + verify_code.value = normalized
151 + verify_status.value = 'verifying'
152 + msg.value = '核销中...'
153 +
154 + const res = await verifyTicketAPI({ qr_code: normalized })
155 + if (res?.code === 1) {
156 + verify_status.value = 'success'
157 + msg.value = res?.msg || '核销成功'
158 + verify_info.value = res?.data || {}
159 + return
160 + }
161 +
162 + verify_status.value = 'fail'
163 + msg.value = res?.msg || '核销失败'
164 + verify_info.value = {}
165 +}
166 +
167 +const init_wx_scan = async () => {
168 + const cfg_res = await wxJsAPI({ url: window.location.href.split('#')[0] })
169 + if (!cfg_res || cfg_res?.code !== 1 || !cfg_res?.data) return false
170 + const cfg = { ...cfg_res.data, jsApiList: apiList }
171 + return new Promise((resolve) => {
172 + wx.config(cfg)
173 + wx.ready(() => resolve(true))
174 + wx.error(() => resolve(false))
175 + })
176 +}
177 +
178 +const scan_in_wechat = async () => {
179 + const ok = await init_wx_scan()
180 + if (!ok) return ''
181 + return new Promise((resolve) => {
182 + wx.scanQRCode({
183 + needResult: 1,
184 + scanType: ['qrCode', 'barCode'],
185 + success: (res) => resolve(res?.resultStr || ''),
186 + fail: () => resolve(''),
187 + cancel: () => resolve(''),
188 + })
189 + })
190 +}
191 +
192 +const start_scan_and_verify = async () => {
193 + const authed = await ensure_permission()
194 + if (!authed) return
195 +
196 + const in_wechat = wxInfo().isTable === true
197 + if (in_wechat) {
198 + const result = await scan_in_wechat()
199 + const code = normalize_scan_result(result)
200 + if (!code) {
201 + if (manual_code.value) await verify_ticket(manual_code.value)
202 + else showToast('未获取到二维码内容')
203 + return
204 + }
205 + await verify_ticket(code)
206 + return
207 + }
208 +
209 + if (manual_code.value) {
210 + await verify_ticket(manual_code.value)
211 + return
212 + }
213 +
214 + showToast('请在微信内扫码,或手动输入预约码')
215 +}
216 +
217 +onMounted(async () => {
218 + const authed = await ensure_permission()
219 + if (!authed) return
220 + const code = $route.query?.result || $route.query?.qr_code || ''
221 + const str_code = Array.isArray(code) ? code[0] : String(code || '')
222 + if (str_code) {
223 + manual_code.value = str_code
224 + await verify_ticket(str_code)
225 + }
226 +})
227 +
228 +watch(
229 + () => $route.query?.result,
230 + async (next) => {
231 + const code = Array.isArray(next) ? next[0] : String(next || '')
232 + if (!code) return
233 + if (verify_code.value === code) return
234 + const authed = await ensure_permission()
235 + if (!authed) return
236 + manual_code.value = code
237 + await verify_ticket(code)
238 + }
239 +)
240 +</script>
241 +
242 +<style lang="less" scoped>
243 +.verify-page {
244 + min-height: 100vh;
245 + background-color: #F6F6F6;
246 + padding: 16px;
247 + padding-bottom: 160px;
248 + box-sizing: border-box;
249 +
250 + .status-card {
251 + background: #fff;
252 + border-radius: 12px;
253 + padding: 16px;
254 + display: flex;
255 + align-items: center;
256 + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.04);
257 +
258 + .status-icon {
259 + width: 48px;
260 + height: 48px;
261 + border-radius: 50%;
262 + background-color: #FFF7ED;
263 + display: flex;
264 + align-items: center;
265 + justify-content: center;
266 + margin-right: 12px;
267 + }
268 +
269 + .status-text {
270 + flex: 1;
271 + .title {
272 + font-size: 18px;
273 + font-weight: 600;
274 + color: #111827;
275 + }
276 + .desc {
277 + margin-top: 4px;
278 + font-size: 13px;
279 + color: #6B7280;
280 + word-break: break-all;
281 + }
282 + }
283 + }
284 +
285 + .info-card {
286 + margin-top: 12px;
287 + background: #fff;
288 + border-radius: 12px;
289 + padding: 16px;
290 + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.04);
291 +
292 + .card-title {
293 + font-size: 14px;
294 + color: #6B7280;
295 + margin-bottom: 12px;
296 + }
297 +
298 + .row {
299 + display: flex;
300 + align-items: center;
301 + justify-content: space-between;
302 + padding: 10px 0;
303 + border-bottom: 1px solid #F3F4F6;
304 +
305 + .label {
306 + font-size: 14px;
307 + color: #6B7280;
308 + }
309 + .value {
310 + font-size: 16px;
311 + color: #111827;
312 + font-weight: 600;
313 + margin-left: 12px;
314 + word-break: break-all;
315 + text-align: right;
316 + }
317 + .highlight {
318 + color: #A67939;
319 + }
320 + }
321 +
322 + .row.last {
323 + border-bottom: 0;
324 + }
325 +
326 + .fail-reason {
327 + .label {
328 + font-size: 14px;
329 + color: #6B7280;
330 + margin-bottom: 8px;
331 + }
332 + .reason {
333 + font-size: 16px;
334 + color: #E24A4A;
335 + font-weight: 600;
336 + word-break: break-all;
337 + }
338 + }
339 +
340 + .empty {
341 + font-size: 14px;
342 + color: #9CA3AF;
343 + padding: 8px 0;
344 + }
345 + }
346 +
347 + .verify-footer {
348 + position: fixed;
349 + left: 0;
350 + right: 0;
351 + bottom: 0;
352 + padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
353 + background-color: #fff;
354 + box-shadow: 0 -10px 18px rgba(0, 0, 0, 0.06);
355 + box-sizing: border-box;
356 +
357 + .btn-wrap {
358 + margin-top: 10px;
359 + }
360 + }
361 +}
362 +</style>
1 +<template>
2 + <div class="volunteer-login-page">
3 + <div class="logo-section">
4 + <img :src="logo" alt="logo" class="logo" />
5 + <div class="app-name">义工登录</div>
6 + </div>
7 +
8 + <div class="login-card">
9 + <van-field v-model="username" label="账号" placeholder="请输入账号" clearable />
10 + <van-field v-model="password" label="密码" type="password" placeholder="请输入密码" clearable />
11 +
12 + <div class="btn-wrap">
13 + <van-button block color="#A67939" :loading="loading" :disabled="loading" @click="handle_login">
14 + 立即登录
15 + </van-button>
16 + </div>
17 + </div>
18 + </div>
19 +</template>
20 +
21 +<script setup>
22 +import { onMounted, ref } from 'vue'
23 +import { useRouter } from 'vue-router'
24 +import { showToast, showSuccessToast } from 'vant'
25 +import { mainStore } from '@/store'
26 +import { checkRedeemPermissionAPI, volunteerLoginAPI } from '@/api/redeem'
27 +import logo from '@/assets/images/logo_01.png'
28 +
29 +const store = mainStore()
30 +const $router = useRouter()
31 +
32 +const username = ref('')
33 +const password = ref('')
34 +const loading = ref(false)
35 +
36 +const check_permission_and_redirect = async () => {
37 + const permission_res = await checkRedeemPermissionAPI()
38 + if (!permission_res) return
39 + if (permission_res?.data) store.changeUserInfo(permission_res.data)
40 + if (permission_res?.data?.can_redeem === true) {
41 + $router.replace({ path: '/verificationResult' })
42 + }
43 +}
44 +
45 +onMounted(() => {
46 + check_permission_and_redirect()
47 +})
48 +
49 +const handle_login = async () => {
50 + if (!username.value || !password.value) {
51 + showToast('请输入账号密码')
52 + return
53 + }
54 +
55 + loading.value = true
56 + const login_res = await volunteerLoginAPI({ uuid: username.value, password: password.value })
57 + loading.value = false
58 +
59 + if (!login_res || login_res?.code !== 1) {
60 + showToast(login_res?.msg || '登录失败')
61 + return
62 + }
63 +
64 + const permission_res = await checkRedeemPermissionAPI()
65 + if (!permission_res || permission_res?.code !== 1) {
66 + showToast(permission_res?.msg || '权限校验失败')
67 + return
68 + }
69 +
70 + if (permission_res?.data) store.changeUserInfo(permission_res.data)
71 +
72 + if (permission_res?.data?.can_redeem === true) {
73 + showSuccessToast(permission_res?.msg || login_res?.msg || '登录成功')
74 + setTimeout(() => $router.replace({ path: '/verificationResult' }), 800)
75 + return
76 + }
77 +
78 + showToast(permission_res?.msg || '暂无核销权限')
79 +}
80 +</script>
81 +
82 +<style lang="less" scoped>
83 +.volunteer-login-page {
84 + min-height: 100vh;
85 + background-color: #F6F6F6;
86 + display: flex;
87 + flex-direction: column;
88 + align-items: center;
89 + padding: 80px 16px 16px;
90 + box-sizing: border-box;
91 +
92 + .logo-section {
93 + display: flex;
94 + flex-direction: column;
95 + align-items: center;
96 + margin-bottom: 20px;
97 +
98 + .logo {
99 + width: 80px;
100 + height: 80px;
101 + border-radius: 50%;
102 + background-color: #fff;
103 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
104 + object-fit: contain;
105 + }
106 +
107 + .app-name {
108 + margin-top: 12px;
109 + font-size: 20px;
110 + font-weight: 600;
111 + color: #333;
112 + letter-spacing: 2px;
113 + }
114 + }
115 +
116 + .login-card {
117 + width: 100%;
118 + max-width: 420px;
119 + background: #fff;
120 + border-radius: 12px;
121 + padding: 12px;
122 + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.04);
123 + box-sizing: border-box;
124 +
125 + .btn-wrap {
126 + margin-top: 16px;
127 + }
128 + }
129 +}
130 +</style>
...@@ -54,6 +54,7 @@ ...@@ -54,6 +54,7 @@
54 "jquery", 54 "jquery",
55 ], 55 ],
56 "allowSyntheticDefaultImports": true, // 允许从没有设置默认导出的模块中默认导入。 56 "allowSyntheticDefaultImports": true, // 允许从没有设置默认导出的模块中默认导入。
57 + "skipLibCheck": true,
57 58
58 /* Source Map Options */ 59 /* Source Map Options */
59 // "sourceRoot": "./", // 指定调试器应该找到 TypeScript 文件而不是源文件的位置 60 // "sourceRoot": "./", // 指定调试器应该找到 TypeScript 文件而不是源文件的位置
......
This diff is collapsed. Click to expand it.