CI/CD Pipeline / UI Contracts (source-structure gates) (push) Canceled after 0s
CI/CD Pipeline / E2E (live stack) (push) Canceled after 0s
CI/CD Pipeline / Backend Build & Test (push) Canceled after 0s
CI/CD Pipeline / Admin Web Build (push) Canceled after 0s
CI/CD Pipeline / Mini Program Compile Check (push) Canceled after 0s
CI/CD Pipeline / Migration Validation (push) Canceled after 0s
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Canceled after 0s
204 lines
7.0 KiB
Vue
204 lines
7.0 KiB
Vue
<template>
|
||
<div class="page-container">
|
||
<el-card shadow="never">
|
||
<template #header>
|
||
<div class="card-header">
|
||
<span>活动签到工作台</span>
|
||
<el-tag v-if="stats" type="info" size="small">
|
||
已通过 {{ stats.totalApproved }} · 已签到 {{ stats.checkedInCount }} · 未到场
|
||
{{ stats.notCheckedInCount }}
|
||
</el-tag>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 活动选择:签到必须绑定活动,避免跨活动误签 -->
|
||
<el-form :inline="true" class="pick-form">
|
||
<el-form-item label="当前活动">
|
||
<el-select v-model="activityId" filterable placeholder="选择活动" style="width: 320px"
|
||
@change="loadStats">
|
||
<el-option v-for="a in activities" :key="a.id" :label="a.title" :value="a.id">
|
||
<span>{{ a.title }}</span>
|
||
<span class="opt-type">{{ typeText(a.activityType) }}</span>
|
||
</el-option>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button :disabled="!activityId" @click="loadStats">刷新统计</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
|
||
<el-alert v-if="!activityId" type="warning" :closable="false" show-icon
|
||
title="请先选择活动:签到码的作用域由活动决定,不可跨活动复用。" />
|
||
|
||
<el-row :gutter="16" v-else>
|
||
<!-- 扫码 / 粘贴签到码 -->
|
||
<el-col :span="12">
|
||
<el-card shadow="never" class="inner-card">
|
||
<template #header><span>扫码签到</span></template>
|
||
<el-input v-model="token" type="textarea" :rows="4"
|
||
placeholder="粘贴用户小程序上的签到码(一次性,有效期 30 分钟)" />
|
||
<div class="btn-row">
|
||
<el-button type="primary" :loading="scanning" :disabled="!token"
|
||
@click="scan">确认签到</el-button>
|
||
<el-button @click="token = ''">清空</el-button>
|
||
</div>
|
||
</el-card>
|
||
</el-col>
|
||
|
||
<!-- 手工签到 -->
|
||
<el-col :span="12">
|
||
<el-card shadow="never" class="inner-card">
|
||
<template #header><span>手工签到(特殊情形)</span></template>
|
||
<el-input v-model="manualUserNumber" placeholder="输入13位用户编号" />
|
||
<div class="btn-row">
|
||
<el-button type="primary" :loading="saving" :disabled="!manualUserNumber"
|
||
@click="manual">手工签到</el-button>
|
||
</div>
|
||
<div class="form-hint">
|
||
仅可操作被指派给自己的活动;主办/协办组织关系不授予签到权限。
|
||
</div>
|
||
</el-card>
|
||
</el-col>
|
||
</el-row>
|
||
|
||
<!-- 签到结果 -->
|
||
<el-card v-if="lastResult" shadow="never" class="inner-card result-card">
|
||
<template #header><span>最近一次签到</span></template>
|
||
<el-descriptions :column="2" border size="small">
|
||
<el-descriptions-item label="用户编号">{{ lastResult.userDisplayNumber }}</el-descriptions-item>
|
||
<el-descriptions-item label="活动编号">{{ lastResult.activityDisplayId }}</el-descriptions-item>
|
||
<el-descriptions-item label="本次是否为重复签到">
|
||
<el-tag :type="lastResult.alreadyCheckedIn ? 'warning' : 'success'" size="small">
|
||
{{ lastResult.alreadyCheckedIn ? '是(已幂等,未重复记录)' : '否' }}
|
||
</el-tag>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="签到时间">{{ formatTime(lastResult.checkedInAt) }}</el-descriptions-item>
|
||
</el-descriptions>
|
||
</el-card>
|
||
</el-card>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { getAccessToken } from '@/utils/token'
|
||
/**
|
||
* U32 活动签到工作台(Phase 15)
|
||
*
|
||
* 后端端点:
|
||
* - POST /api/v1/activities/operator/checkin/scan 扫码签到(一次性 token)
|
||
* - POST /api/v1/activities/operator/{id}/checkin/manual 手工签到
|
||
* - GET /api/v1/activities/operator/{id}/checkin-stats 签到统计
|
||
*/
|
||
import { ref, onMounted } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { get, post } from '@/api/client'
|
||
|
||
const activities = ref<any[]>([])
|
||
const activityId = ref('')
|
||
const token = ref('')
|
||
const manualUserNumber = ref('')
|
||
const stats = ref<any>(null)
|
||
const lastResult = ref<any>(null)
|
||
const scanning = ref(false)
|
||
const saving = ref(false)
|
||
|
||
const TYPE_TEXT: Record<string, string> = {
|
||
matchmaking: '公益联谊',
|
||
interest_social: '兴趣社交',
|
||
relationship_education: '婚恋课堂',
|
||
premarital_guidance: '婚前辅导',
|
||
marriage_custom_culture: '婚俗文化',
|
||
anti_fraud: '婚恋反诈',
|
||
}
|
||
const typeText = (t: string) => TYPE_TEXT[t] || t || '未分类'
|
||
|
||
const authHeader = () => ({ Authorization: `Bearer ${getAccessToken() || ''}` })
|
||
|
||
const formatTime = (v: any) => {
|
||
if (!v) return '-'
|
||
const d = new Date(v)
|
||
return Number.isNaN(d.getTime()) ? String(v) : d.toLocaleString('zh-CN', { hour12: false })
|
||
}
|
||
|
||
async function loadActivities() {
|
||
const res: any = await get('/operator/activities', {}, authHeader())
|
||
activities.value = res.data || []
|
||
}
|
||
|
||
async function loadStats() {
|
||
stats.value = null
|
||
if (!activityId.value) return
|
||
try {
|
||
const res: any = await get(`/activities/operator/${activityId.value}/checkin-stats`, {}, authHeader())
|
||
stats.value = res.data
|
||
} catch (e) {
|
||
stats.value = null
|
||
}
|
||
}
|
||
|
||
async function scan() {
|
||
if (!token.value.trim()) return
|
||
scanning.value = true
|
||
try {
|
||
const res: any = await post('/activities/operator/checkin/scan', { token: token.value.trim() }, authHeader())
|
||
lastResult.value = res.data
|
||
token.value = ''
|
||
ElMessage.success(res.data?.alreadyCheckedIn ? '该用户已签到(重复扫码已幂等处理)' : '签到成功')
|
||
await loadStats()
|
||
} finally {
|
||
scanning.value = false
|
||
}
|
||
}
|
||
|
||
async function manual() {
|
||
if (!activityId.value || !manualUserNumber.value.trim()) return
|
||
saving.value = true
|
||
try {
|
||
const res: any = await post(
|
||
`/activities/operator/${activityId.value}/checkin/manual`,
|
||
{ userId: manualUserNumber.value.trim() },
|
||
authHeader())
|
||
lastResult.value = { ...res.data, userDisplayNumber: res.data?.userDisplayNumber || manualUserNumber.value.trim(), activityDisplayId: activities.value.find((item: any) => item.id === activityId.value)?.displayId }
|
||
manualUserNumber.value = ''
|
||
ElMessage.success(res.data?.alreadyCheckedIn ? '该用户已签到(未重复记录)' : '手工签到成功')
|
||
await loadStats()
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(loadActivities)
|
||
</script>
|
||
|
||
<style scoped>
|
||
.pick-form {
|
||
margin-bottom: 8px;
|
||
}
|
||
.inner-card {
|
||
margin-top: 12px;
|
||
}
|
||
.btn-row {
|
||
margin-top: 10px;
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
.form-hint {
|
||
margin-top: 8px;
|
||
color: var(--color-text-placeholder);
|
||
font-size: var(--font-size-xs);
|
||
}
|
||
.opt-type {
|
||
float: right;
|
||
color: var(--color-text-placeholder);
|
||
font-size: var(--font-size-xs);
|
||
}
|
||
.result-card {
|
||
margin-top: 12px;
|
||
}
|
||
.card-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
</style>
|