Files
danyang-match/admin-web/src/modules/activity/ActivityCheckinView.vue
T
WorkBuddy 3c2b967274
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
release: freeze 2026-10-15 validated release baseline
2026-10-15 09:47:31 +08:00

204 lines
7.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>