hookehuyr

feat(volunteer): 添加义工登录和核销功能

新增义工登录页面和核销结果页面,实现以下功能:
1. 义工登录功能,支持账号密码验证
2. 扫码核销功能,支持跳转核销结果页面
3. 添加相关API接口mock数据
4. 在store中添加义工角色判断逻辑
1 +功能点:
2 +2. 跳转到一个义工登录页面, 登录页面需要用户输入用户名和密码, 点击登陆按钮, 登录成功后, 调起扫码核销功能, 如果用户已经登录, 直接调起扫码核销功能.
3 +3. 扫码核销接口调用成功后,跳转到新的核销反馈页面, 并显示核销成功或者失败的提示
4 +4. 暂时没有接口, 可以先mock数据, 随便扫码什么二维码都返回成功.
1 /* 1 /*
2 * @Date: 2023-08-24 09:42:27 2 * @Date: 2023-08-24 09:42:27
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2026-01-06 21:08:32 4 + * @LastEditTime: 2026-01-07 17:43:26
5 * @FilePath: /xyxBooking-weapp/src/api/index.js 5 * @FilePath: /xyxBooking-weapp/src/api/index.js
6 * @Description: 文件描述 6 * @Description: 文件描述
7 */ 7 */
...@@ -26,6 +26,60 @@ const Api = { ...@@ -26,6 +26,60 @@ const Api = {
26 BILL_PAY_STATUS: '/srv/?a=api&t=bill_pay_status', 26 BILL_PAY_STATUS: '/srv/?a=api&t=bill_pay_status',
27 QUERY_QR_CODE: '/srv/?a=api&t=id_number_query_qr_code', 27 QUERY_QR_CODE: '/srv/?a=api&t=id_number_query_qr_code',
28 ICBC_ORDER_QRY: '/srv/?a=icbc_orderqry', 28 ICBC_ORDER_QRY: '/srv/?a=icbc_orderqry',
29 + VOLUNTEER_LOGIN: '/srv/?a=api&t=volunteer_login',
30 + VERIFY_TICKET: '/srv/?a=api&t=verify_ticket',
31 + GET_USER_INFO: '/srv/?a=api&t=get_user_info',
32 +};
33 +
34 +/**
35 + * @description: 获取用户信息 (Mock)
36 + */
37 +export const getUserInfoAPI = () => {
38 + return new Promise((resolve) => {
39 + setTimeout(() => {
40 + // 模拟返回义工信息
41 + resolve({
42 + code: 1,
43 + data: {
44 + id: 'v_001',
45 + name: '义工管理员',
46 + role: 'volunteer',
47 + avatar: 'https://img12.360buyimg.com/imagetools/jfs/t1/196130/38/13621/2930/60c73831E00c07f30/526e068832877520.png'
48 + },
49 + msg: '获取成功'
50 + });
51 + }, 300);
52 + });
53 + // 实际对接后应使用: return fn(fetch.get(Api.GET_USER_INFO));
54 +};
55 +
56 +/**
57 + * @description: 义工登录 (Mock)
58 + */
59 +export const volunteerLoginAPI = (params) => {
60 + return new Promise((resolve) => {
61 + setTimeout(() => {
62 + // 简单模拟: 任意非空账号密码即成功,或者指定 admin/123456
63 + if (params.username && params.password) {
64 + resolve({ code: 1, data: { token: 'mock_token' }, msg: '登录成功' });
65 + } else {
66 + resolve({ code: 0, data: null, msg: '账号或密码错误' });
67 + }
68 + }, 500);
69 + });
70 + // 实际对接后应使用: return fn(fetch.post(Api.VOLUNTEER_LOGIN, params));
71 +};
72 +
73 +/**
74 + * @description: 核销门票 (Mock)
75 + */
76 +export const verifyTicketAPI = () => {
77 + return new Promise((resolve) => {
78 + setTimeout(() => {
79 + resolve({ code: 1, data: { status: 'success' }, msg: '核销成功' });
80 + }, 500);
81 + });
82 + // 实际对接后应使用: return fn(fetch.post(Api.VERIFY_TICKET, params));
29 }; 83 };
30 84
31 /** 85 /**
......
...@@ -22,6 +22,8 @@ export default { ...@@ -22,6 +22,8 @@ export default {
22 'pages/callback/index', 22 'pages/callback/index',
23 'pages/search/index', 23 'pages/search/index',
24 'pages/visitorList/index', 24 'pages/visitorList/index',
25 + 'pages/volunteerLogin/index',
26 + 'pages/verificationResult/index',
25 ], 27 ],
26 subpackages: [ // 配置在tabBar中的页面不能分包写到subpackages中去 28 subpackages: [ // 配置在tabBar中的页面不能分包写到subpackages中去
27 { 29 {
......
1 +export default {
2 + navigationBarTitleText: '核销结果'
3 +}
1 +<template>
2 + <view class="result-page">
3 + <view class="result-content">
4 + <icon type="success" size="64" color="#A67939"/>
5 + <view class="msg">{{ msg }}</view>
6 + <view class="info" v-if="resultContent">扫码内容: {{ resultContent }}</view>
7 + <nut-button color="#A67939" class="back-btn" @tap="continueScan">继续核销</nut-button>
8 + <!-- <nut-button type="default" class="home-btn" @tap="goHome">返回首页</nut-button> -->
9 + </view>
10 + </view>
11 +</template>
12 +
13 +<script setup>
14 +import { ref } from 'vue'
15 +import { useRouter } from '@tarojs/taro'
16 +import { useGo } from '@/hooks/useGo'
17 +import { verifyTicketAPI } from '@/api/index'
18 +import Taro, { useDidShow } from '@tarojs/taro'
19 +
20 +const router = useRouter()
21 +const go = useGo()
22 +const resultContent = ref('')
23 +const msg = ref('核销成功')
24 +
25 +useDidShow(async () => {
26 + resultContent.value = router.params.result || ''
27 + if (resultContent.value) {
28 + Taro.showLoading({ title: '核销中...' })
29 + const res = await verifyTicketAPI({ code: resultContent.value })
30 + Taro.hideLoading()
31 + if (res.code === 1) {
32 + msg.value = res.msg || '核销成功'
33 + } else {
34 + msg.value = res.msg || '核销失败'
35 + Taro.showToast({ title: msg.value, icon: 'none' })
36 + }
37 + }
38 +})
39 +
40 +const continueScan = () => {
41 + Taro.scanCode({
42 + success: (res) => {
43 + // Reload current page with new result
44 + // Taro doesn't support easy reload with params change in same page easily without redirect
45 + // So we redirect to self
46 + Taro.redirectTo({
47 + url: `/pages/verificationResult/index?result=${res.result}`
48 + })
49 + },
50 + fail: () => {
51 + // Cancelled
52 + }
53 + })
54 +}
55 +
56 +// const goHome = () => {
57 +// Taro.reLaunch({ url: '/pages/index/index' })
58 +// }
59 +</script>
60 +
61 +<style lang="less">
62 +.result-page {
63 + padding: 64rpx 32rpx;
64 + text-align: center;
65 + min-height: 100vh;
66 + background-color: #fff;
67 +
68 + .result-content {
69 + display: flex;
70 + flex-direction: column;
71 + align-items: center;
72 + }
73 +
74 + .msg {
75 + font-size: 40rpx;
76 + margin-top: 32rpx;
77 + font-weight: bold;
78 + color: #333;
79 + }
80 + .info {
81 + margin-top: 20rpx;
82 + color: #999;
83 + font-size: 28rpx;
84 + word-break: break-all;
85 + padding: 0 32rpx;
86 + }
87 + .back-btn {
88 + margin-top: 80rpx;
89 + background-color: #A67939;
90 + color: #fff;
91 + width: 80%;
92 + border-radius: 44rpx;
93 + }
94 + .home-btn {
95 + margin-top: 32rpx;
96 + background-color: #fff;
97 + color: #666;
98 + width: 80%;
99 + border-radius: 44rpx;
100 + border: 1rpx solid #b0b0b0;
101 + }
102 +}
103 +</style>
1 +/*
2 + * @Date: 2026-01-07 17:41:31
3 + * @LastEditors: hookehuyr hookehuyr@gmail.com
4 + * @LastEditTime: 2026-01-07 18:04:52
5 + * @FilePath: /xyxBooking-weapp/src/pages/volunteerLogin/index.config.js
6 + * @Description: 文件描述
7 + */
8 +export default {
9 + navigationBarTitleText: ''
10 +}
1 +<template>
2 + <view class="login-page">
3 + <view class="logo-section">
4 + <image :src="logo" mode="aspectFit" />
5 + <text class="app-name">义工登录</text>
6 + </view>
7 +
8 + <view class="login-card">
9 + <view class="title">欢迎回来</view>
10 +
11 + <view class="input-group">
12 + <text class="label">账号</text>
13 + <input
14 + v-model="username"
15 + placeholder="请输入账号"
16 + placeholder-class="input-placeholder"
17 + cursorSpacing="40rpx"
18 + />
19 + </view>
20 +
21 + <view class="input-group">
22 + <text class="label">密码</text>
23 + <input
24 + v-model="password"
25 + password
26 + placeholder="请输入密码"
27 + placeholder-class="input-placeholder"
28 + cursorSpacing="40rpx"
29 + />
30 + </view>
31 +
32 + <button class="login-btn" @tap="handleLogin">立即登录</button>
33 + </view>
34 + </view>
35 +</template>
36 +
37 +<script setup>
38 +import { ref } from 'vue'
39 +import Taro from '@tarojs/taro'
40 +import { mainStore } from '@/stores/main'
41 +import { volunteerLoginAPI, getUserInfoAPI } from '@/api/index'
42 +import { useGo } from '@/hooks/useGo'
43 +import logo from '@/assets/images/logo.png'
44 +
45 +const store = mainStore()
46 +const go = useGo()
47 +const username = ref('')
48 +const password = ref('')
49 +
50 +const handleLogin = async () => {
51 + if (!username.value || !password.value) {
52 + Taro.showToast({ title: '请输入账号密码', icon: 'none' })
53 + return
54 + }
55 +
56 + Taro.showLoading({ title: '登录中...' })
57 + // 1. 执行登录
58 + const loginRes = await volunteerLoginAPI({ username: username.value, password: password.value })
59 +
60 + if (loginRes.code === 1) {
61 + // 2. 登录成功后,获取用户信息来验证和更新状态
62 + const userRes = await getUserInfoAPI()
63 + Taro.hideLoading()
64 +
65 + if (userRes.code === 1 && userRes.data) {
66 + // 更新 store 中的用户信息 (isVolunteer 会自动通过 getter 更新)
67 + store.changeUserInfo(userRes.data)
68 +
69 + if (store.isVolunteer) {
70 + Taro.showToast({ title: '登录成功', icon: 'success' })
71 + setTimeout(() => {
72 + triggerScan()
73 + }, 1500)
74 + } else {
75 + Taro.showToast({ title: '非义工账号', icon: 'none' })
76 + }
77 + } else {
78 + Taro.showToast({ title: '获取用户信息失败', icon: 'none' })
79 + }
80 + } else {
81 + Taro.hideLoading()
82 + Taro.showToast({ title: loginRes.msg || '登录失败', icon: 'none' })
83 + }
84 +}
85 +
86 +const triggerScan = () => {
87 + Taro.scanCode({
88 + success: (res) => {
89 + go(`/pages/verificationResult/index?result=${res.result}`)
90 + },
91 + fail: (err) => {
92 + console.log('Scan failed', err)
93 + }
94 + })
95 +}
96 +</script>
97 +
98 +<style lang="less">
99 +.login-page {
100 + min-height: 100vh;
101 + background-color: #F6F6F6;
102 + display: flex;
103 + flex-direction: column;
104 + align-items: center;
105 + padding-top: 120rpx;
106 +
107 + .logo-section {
108 + display: flex;
109 + flex-direction: column;
110 + align-items: center;
111 + margin-bottom: 60rpx;
112 + image {
113 + width: 120rpx;
114 + height: 120rpx;
115 + border-radius: 50%;
116 + margin-bottom: 24rpx;
117 + box-shadow: 0 8rpx 24rpx rgba(0,0,0,0.1);
118 + background-color: #fff;
119 + }
120 + .app-name {
121 + font-size: 36rpx;
122 + font-weight: 600;
123 + color: #333;
124 + letter-spacing: 2rpx;
125 + }
126 + }
127 +
128 + .login-card {
129 + width: 690rpx;
130 + background: #fff;
131 + border-radius: 24rpx;
132 + padding: 60rpx 40rpx;
133 + box-shadow: 0 12rpx 40rpx rgba(0,0,0,0.04);
134 + box-sizing: border-box;
135 +
136 + .title {
137 + font-size: 44rpx;
138 + font-weight: bold;
139 + color: #333;
140 + margin-bottom: 60rpx;
141 + padding-left: 10rpx;
142 + }
143 +
144 + .input-group {
145 + background-color: #F7F8FA;
146 + border-radius: 12rpx;
147 + padding: 28rpx 30rpx;
148 + margin-bottom: 32rpx;
149 + display: flex;
150 + align-items: center;
151 + transition: all 0.3s;
152 +
153 + .label {
154 + font-size: 30rpx;
155 + color: #333;
156 + font-weight: 500;
157 + width: 90rpx;
158 + margin-right: 20rpx;
159 + }
160 +
161 + input {
162 + flex: 1;
163 + font-size: 30rpx;
164 + color: #333;
165 + height: 44rpx;
166 + min-height: 44rpx;
167 + }
168 +
169 + .input-placeholder {
170 + color: #C0C4CC;
171 + }
172 + }
173 +
174 + .login-btn {
175 + margin-top: 80rpx;
176 + background: #A67939;
177 + color: #fff;
178 + height: 96rpx;
179 + line-height: 96rpx;
180 + border-radius: 48rpx;
181 + font-size: 34rpx;
182 + font-weight: 500;
183 + box-shadow: 0 12rpx 30rpx rgba(166, 121, 57, 0.3);
184 + border: none;
185 +
186 + &::after {
187 + border: none;
188 + }
189 +
190 + &:active {
191 + opacity: 0.9;
192 + transform: scale(0.99);
193 + }
194 + }
195 + }
196 +}
197 +</style>
...@@ -14,18 +14,22 @@ export const mainStore = defineStore('main', { ...@@ -14,18 +14,22 @@ export const mainStore = defineStore('main', {
14 count: 0, 14 count: 0,
15 auth: false, 15 auth: false,
16 // keepPages: ['default'], // 小程序不支持这种 keep-alive 机制 16 // keepPages: ['default'], // 小程序不支持这种 keep-alive 机制
17 - appUserInfo: [], // 缓存预约人信息 17 + appUserInfo: null, // 用户信息
18 }; 18 };
19 }, 19 },
20 getters: { 20 getters: {
21 - // getKeepPages () { 21 + // 判断是否为义工 (基于 appUserInfo 中的 role 字段)
22 - // return this.keepPages 22 + isVolunteer: (state) => {
23 - // }, 23 + return state.appUserInfo && state.appUserInfo.role === 'volunteer';
24 + },
24 }, 25 },
25 actions: { 26 actions: {
26 changeState (state) { 27 changeState (state) {
27 this.auth = state; 28 this.auth = state;
28 }, 29 },
30 + // setVolunteerStatus(status) {
31 + // this.isVolunteer = status;
32 + // },
29 // changeKeepPages () { 33 // changeKeepPages () {
30 // this.keepPages = ['default']; 34 // this.keepPages = ['default'];
31 // }, 35 // },
......