hookehuyr

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

新增义工登录页面和核销结果页面,实现以下功能:
1. 义工登录功能,支持账号密码验证
2. 扫码核销功能,支持跳转核销结果页面
3. 添加相关API接口mock数据
4. 在store中添加义工角色判断逻辑
功能点:
2. 跳转到一个义工登录页面, 登录页面需要用户输入用户名和密码, 点击登陆按钮, 登录成功后, 调起扫码核销功能, 如果用户已经登录, 直接调起扫码核销功能.
3. 扫码核销接口调用成功后,跳转到新的核销反馈页面, 并显示核销成功或者失败的提示
4. 暂时没有接口, 可以先mock数据, 随便扫码什么二维码都返回成功.
/*
* @Date: 2023-08-24 09:42:27
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-06 21:08:32
* @LastEditTime: 2026-01-07 17:43:26
* @FilePath: /xyxBooking-weapp/src/api/index.js
* @Description: 文件描述
*/
......@@ -26,6 +26,60 @@ const Api = {
BILL_PAY_STATUS: '/srv/?a=api&t=bill_pay_status',
QUERY_QR_CODE: '/srv/?a=api&t=id_number_query_qr_code',
ICBC_ORDER_QRY: '/srv/?a=icbc_orderqry',
VOLUNTEER_LOGIN: '/srv/?a=api&t=volunteer_login',
VERIFY_TICKET: '/srv/?a=api&t=verify_ticket',
GET_USER_INFO: '/srv/?a=api&t=get_user_info',
};
/**
* @description: 获取用户信息 (Mock)
*/
export const getUserInfoAPI = () => {
return new Promise((resolve) => {
setTimeout(() => {
// 模拟返回义工信息
resolve({
code: 1,
data: {
id: 'v_001',
name: '义工管理员',
role: 'volunteer',
avatar: 'https://img12.360buyimg.com/imagetools/jfs/t1/196130/38/13621/2930/60c73831E00c07f30/526e068832877520.png'
},
msg: '获取成功'
});
}, 300);
});
// 实际对接后应使用: return fn(fetch.get(Api.GET_USER_INFO));
};
/**
* @description: 义工登录 (Mock)
*/
export const volunteerLoginAPI = (params) => {
return new Promise((resolve) => {
setTimeout(() => {
// 简单模拟: 任意非空账号密码即成功,或者指定 admin/123456
if (params.username && params.password) {
resolve({ code: 1, data: { token: 'mock_token' }, msg: '登录成功' });
} else {
resolve({ code: 0, data: null, msg: '账号或密码错误' });
}
}, 500);
});
// 实际对接后应使用: return fn(fetch.post(Api.VOLUNTEER_LOGIN, params));
};
/**
* @description: 核销门票 (Mock)
*/
export const verifyTicketAPI = () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ code: 1, data: { status: 'success' }, msg: '核销成功' });
}, 500);
});
// 实际对接后应使用: return fn(fetch.post(Api.VERIFY_TICKET, params));
};
/**
......
......@@ -22,6 +22,8 @@ export default {
'pages/callback/index',
'pages/search/index',
'pages/visitorList/index',
'pages/volunteerLogin/index',
'pages/verificationResult/index',
],
subpackages: [ // 配置在tabBar中的页面不能分包写到subpackages中去
{
......
export default {
navigationBarTitleText: '核销结果'
}
<template>
<view class="result-page">
<view class="result-content">
<icon type="success" size="64" color="#A67939"/>
<view class="msg">{{ msg }}</view>
<view class="info" v-if="resultContent">扫码内容: {{ resultContent }}</view>
<nut-button color="#A67939" class="back-btn" @tap="continueScan">继续核销</nut-button>
<!-- <nut-button type="default" class="home-btn" @tap="goHome">返回首页</nut-button> -->
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from '@tarojs/taro'
import { useGo } from '@/hooks/useGo'
import { verifyTicketAPI } from '@/api/index'
import Taro, { useDidShow } from '@tarojs/taro'
const router = useRouter()
const go = useGo()
const resultContent = ref('')
const msg = ref('核销成功')
useDidShow(async () => {
resultContent.value = router.params.result || ''
if (resultContent.value) {
Taro.showLoading({ title: '核销中...' })
const res = await verifyTicketAPI({ code: resultContent.value })
Taro.hideLoading()
if (res.code === 1) {
msg.value = res.msg || '核销成功'
} else {
msg.value = res.msg || '核销失败'
Taro.showToast({ title: msg.value, icon: 'none' })
}
}
})
const continueScan = () => {
Taro.scanCode({
success: (res) => {
// Reload current page with new result
// Taro doesn't support easy reload with params change in same page easily without redirect
// So we redirect to self
Taro.redirectTo({
url: `/pages/verificationResult/index?result=${res.result}`
})
},
fail: () => {
// Cancelled
}
})
}
// const goHome = () => {
// Taro.reLaunch({ url: '/pages/index/index' })
// }
</script>
<style lang="less">
.result-page {
padding: 64rpx 32rpx;
text-align: center;
min-height: 100vh;
background-color: #fff;
.result-content {
display: flex;
flex-direction: column;
align-items: center;
}
.msg {
font-size: 40rpx;
margin-top: 32rpx;
font-weight: bold;
color: #333;
}
.info {
margin-top: 20rpx;
color: #999;
font-size: 28rpx;
word-break: break-all;
padding: 0 32rpx;
}
.back-btn {
margin-top: 80rpx;
background-color: #A67939;
color: #fff;
width: 80%;
border-radius: 44rpx;
}
.home-btn {
margin-top: 32rpx;
background-color: #fff;
color: #666;
width: 80%;
border-radius: 44rpx;
border: 1rpx solid #b0b0b0;
}
}
</style>
/*
* @Date: 2026-01-07 17:41:31
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-07 18:04:52
* @FilePath: /xyxBooking-weapp/src/pages/volunteerLogin/index.config.js
* @Description: 文件描述
*/
export default {
navigationBarTitleText: ''
}
<template>
<view class="login-page">
<view class="logo-section">
<image :src="logo" mode="aspectFit" />
<text class="app-name">义工登录</text>
</view>
<view class="login-card">
<view class="title">欢迎回来</view>
<view class="input-group">
<text class="label">账号</text>
<input
v-model="username"
placeholder="请输入账号"
placeholder-class="input-placeholder"
cursorSpacing="40rpx"
/>
</view>
<view class="input-group">
<text class="label">密码</text>
<input
v-model="password"
password
placeholder="请输入密码"
placeholder-class="input-placeholder"
cursorSpacing="40rpx"
/>
</view>
<button class="login-btn" @tap="handleLogin">立即登录</button>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import Taro from '@tarojs/taro'
import { mainStore } from '@/stores/main'
import { volunteerLoginAPI, getUserInfoAPI } from '@/api/index'
import { useGo } from '@/hooks/useGo'
import logo from '@/assets/images/logo.png'
const store = mainStore()
const go = useGo()
const username = ref('')
const password = ref('')
const handleLogin = async () => {
if (!username.value || !password.value) {
Taro.showToast({ title: '请输入账号密码', icon: 'none' })
return
}
Taro.showLoading({ title: '登录中...' })
// 1. 执行登录
const loginRes = await volunteerLoginAPI({ username: username.value, password: password.value })
if (loginRes.code === 1) {
// 2. 登录成功后,获取用户信息来验证和更新状态
const userRes = await getUserInfoAPI()
Taro.hideLoading()
if (userRes.code === 1 && userRes.data) {
// 更新 store 中的用户信息 (isVolunteer 会自动通过 getter 更新)
store.changeUserInfo(userRes.data)
if (store.isVolunteer) {
Taro.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => {
triggerScan()
}, 1500)
} else {
Taro.showToast({ title: '非义工账号', icon: 'none' })
}
} else {
Taro.showToast({ title: '获取用户信息失败', icon: 'none' })
}
} else {
Taro.hideLoading()
Taro.showToast({ title: loginRes.msg || '登录失败', icon: 'none' })
}
}
const triggerScan = () => {
Taro.scanCode({
success: (res) => {
go(`/pages/verificationResult/index?result=${res.result}`)
},
fail: (err) => {
console.log('Scan failed', err)
}
})
}
</script>
<style lang="less">
.login-page {
min-height: 100vh;
background-color: #F6F6F6;
display: flex;
flex-direction: column;
align-items: center;
padding-top: 120rpx;
.logo-section {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 60rpx;
image {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
margin-bottom: 24rpx;
box-shadow: 0 8rpx 24rpx rgba(0,0,0,0.1);
background-color: #fff;
}
.app-name {
font-size: 36rpx;
font-weight: 600;
color: #333;
letter-spacing: 2rpx;
}
}
.login-card {
width: 690rpx;
background: #fff;
border-radius: 24rpx;
padding: 60rpx 40rpx;
box-shadow: 0 12rpx 40rpx rgba(0,0,0,0.04);
box-sizing: border-box;
.title {
font-size: 44rpx;
font-weight: bold;
color: #333;
margin-bottom: 60rpx;
padding-left: 10rpx;
}
.input-group {
background-color: #F7F8FA;
border-radius: 12rpx;
padding: 28rpx 30rpx;
margin-bottom: 32rpx;
display: flex;
align-items: center;
transition: all 0.3s;
.label {
font-size: 30rpx;
color: #333;
font-weight: 500;
width: 90rpx;
margin-right: 20rpx;
}
input {
flex: 1;
font-size: 30rpx;
color: #333;
height: 44rpx;
min-height: 44rpx;
}
.input-placeholder {
color: #C0C4CC;
}
}
.login-btn {
margin-top: 80rpx;
background: #A67939;
color: #fff;
height: 96rpx;
line-height: 96rpx;
border-radius: 48rpx;
font-size: 34rpx;
font-weight: 500;
box-shadow: 0 12rpx 30rpx rgba(166, 121, 57, 0.3);
border: none;
&::after {
border: none;
}
&:active {
opacity: 0.9;
transform: scale(0.99);
}
}
}
}
</style>
......@@ -14,18 +14,22 @@ export const mainStore = defineStore('main', {
count: 0,
auth: false,
// keepPages: ['default'], // 小程序不支持这种 keep-alive 机制
appUserInfo: [], // 缓存预约人信息
appUserInfo: null, // 用户信息
};
},
getters: {
// getKeepPages () {
// return this.keepPages
// },
// 判断是否为义工 (基于 appUserInfo 中的 role 字段)
isVolunteer: (state) => {
return state.appUserInfo && state.appUserInfo.role === 'volunteer';
},
},
actions: {
changeState (state) {
this.auth = state;
},
// setVolunteerStatus(status) {
// this.isVolunteer = status;
// },
// changeKeepPages () {
// this.keepPages = ['default'];
// },
......