Просмотр исходного кода

feat(activity-checkin): T12 管理端活动签到审核页

后端
- 新增 ActivityCheckinVO(含 userName/userPhone/checkInByName/activityTitle 冗余展示字段,字段对齐 activity_checkins)
- ActivityCheckinService 新增 pageByActivity(支持 reviewStatus/checkInType 筛选+分页 total)与 exportByActivity(全量导出)
- AdminActivityCheckinController:list 支持筛选并返回 total;新增 /export 端点

前端(cfc-web)
- 新增 ActivityCheckinManage.vue 管理页:活动筛选、已签到/未签到 Tab、通过/拒绝审核、手动补录(操作人+时间留痕)、CSV 导出
- api/activity.js 增加 getCheckinList/exportCheckins/addCheckin/reviewCheckin
- router/Layout.vue 增加路由与菜单项
E2E Test Bot 2 недель назад
Родитель
Сommit
b1309f8683

+ 29 - 9
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminActivityCheckinController.java

@@ -2,7 +2,7 @@ package com.etotem.cfc.controller.admin;
 
 import com.etotem.cfc.common.AdminRoles;
 import com.etotem.cfc.common.Result;
-import com.etotem.cfc.entity.ActivityCheckin;
+import com.etotem.cfc.dto.ActivityCheckinVO;
 import com.etotem.cfc.service.ActivityCheckinService;
 import com.etotem.cfc.service.PlannerOfflineCourseService;
 import io.swagger.v3.oas.annotations.Operation;
@@ -10,12 +10,11 @@ import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.web.bind.annotation.*;
 
 import javax.annotation.Resource;
-import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
 /**
- * 管理端:活动签到记录查询/手动补录
+ * 管理端:活动签到记录查询 / 手动补录 / 导出
  */
 @Tag(name = "管理端-活动签到管理", description = "活动签到记录查询 / 手动补录(供培训课时桥接)")
 @RestController
@@ -28,7 +27,7 @@ public class AdminActivityCheckinController {
     @Resource
     private PlannerOfflineCourseService offlineCourseService;
 
-    @Operation(summary = "按活动查询签到记录")
+    @Operation(summary = "按活动分页查询签到记录(支持审核状态/签到类型筛选)")
     @PostMapping("/list")
     public Result<Map<String, Object>> list(@RequestBody Map<String, Object> params,
                                             @RequestAttribute("role") String role,
@@ -37,16 +36,37 @@ public class AdminActivityCheckinController {
             return Result.error(403, "无权限");
         }
         Long activityId = com.etotem.cfc.util.ParamUtils.getLong(params.get("activityId"));
+        if (activityId == null) {
+            return Result.error("activityId不能为空");
+        }
+        String reviewStatus = params.get("reviewStatus") != null ? params.get("reviewStatus").toString() : null;
+        String checkInType = params.get("checkInType") != null ? params.get("checkInType").toString() : null;
         Integer page = params.get("page") != null ? Integer.parseInt(params.get("page").toString()) : 1;
         Integer size = params.get("size") != null ? Integer.parseInt(params.get("size").toString()) : 10;
+        com.baomidou.mybatisplus.extension.plugins.pagination.Page<ActivityCheckinVO> p =
+                checkinService.pageByActivity(activityId, reviewStatus, checkInType, page, size);
+        Map<String, Object> result = new java.util.LinkedHashMap<>();
+        result.put("total", p.getTotal());
+        result.put("records", p.getRecords());
+        return Result.success(result);
+    }
+
+    @Operation(summary = "按活动导出签到名单(JSON 列表,前端生成 CSV)")
+    @PostMapping("/export")
+    public Result<List<ActivityCheckinVO>> export(@RequestBody Map<String, Object> params,
+                                                  @RequestAttribute("role") String role,
+                                                  @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        if (!AdminRoles.BACKEND_ROLES.contains(role)) {
+            return Result.error(403, "无权限");
+        }
+        Long activityId = com.etotem.cfc.util.ParamUtils.getLong(params.get("activityId"));
         if (activityId == null) {
             return Result.error("activityId不能为空");
         }
-        List<ActivityCheckin> records = checkinService.listByActivity(activityId, page, size);
-        Map<String, Object> result = new HashMap<>();
-        result.put("records", records);
-        result.put("count", records.size());
-        return Result.success(result);
+        String reviewStatus = params.get("reviewStatus") != null ? params.get("reviewStatus").toString() : null;
+        String checkInType = params.get("checkInType") != null ? params.get("checkInType").toString() : null;
+        List<ActivityCheckinVO> list = checkinService.exportByActivity(activityId, reviewStatus, checkInType);
+        return Result.success(list);
     }
 
     @Operation(summary = "手动补录签到(admin,幂等)")

+ 48 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ActivityCheckinVO.java

@@ -0,0 +1,48 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 管理端活动签到记录视图(字段对齐 activity_checkins + 冗余展示字段)
+ */
+@Data
+public class ActivityCheckinVO implements Serializable {
+
+    private Long id;
+
+    private Long activityId;
+
+    /** 活动标题(冗余,便于列表展示) */
+    private String activityTitle;
+
+    /** 签到人(规划师=userId) */
+    private Long userId;
+
+    /** 签到人昵称/姓名(冗余) */
+    private String userName;
+
+    /** 签到人手机号(冗余) */
+    private String userPhone;
+
+    /** 关联报名记录 */
+    private Long registrationId;
+
+    private Date checkInAt;
+
+    /** qr扫码/admin手动/gps */
+    private String checkInType;
+
+    /** admin手动补录人ID */
+    private Long checkInBy;
+
+    /** 补录操作人姓名(冗余) */
+    private String checkInByName;
+
+    /** approved/pending/rejected */
+    private String reviewStatus;
+
+    private Date createdAt;
+}

+ 105 - 4
cfc-backend/src/main/java/com/etotem/cfc/service/ActivityCheckinService.java

@@ -1,12 +1,23 @@
 package com.etotem.cfc.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.dto.ActivityCheckinVO;
+import com.etotem.cfc.entity.Activity;
 import com.etotem.cfc.entity.ActivityCheckin;
+import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.ActivityCheckinMapper;
+import com.etotem.cfc.mapper.ActivityMapper;
+import com.etotem.cfc.mapper.UserMapper;
 import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
 
 import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 
 @Service
 public class ActivityCheckinService {
@@ -14,17 +25,21 @@ public class ActivityCheckinService {
     @Resource
     private ActivityCheckinMapper activityCheckinMapper;
 
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private ActivityMapper activityMapper;
+
     public List<ActivityCheckin> listByActivity(Long activityId, Integer page, Integer size) {
-        com.baomidou.mybatisplus.extension.plugins.pagination.Page<ActivityCheckin> p =
-                new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page, size);
+        Page<ActivityCheckin> p = new Page<>(page, size);
         return activityCheckinMapper.selectPage(p,
                 new LambdaQueryWrapper<ActivityCheckin>().eq(ActivityCheckin::getActivityId, activityId))
                 .getRecords();
     }
 
     public List<ActivityCheckin> listByUser(Integer userId, Integer page, Integer size) {
-        com.baomidou.mybatisplus.extension.plugins.pagination.Page<ActivityCheckin> p =
-                new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page, size);
+        Page<ActivityCheckin> p = new Page<>(page, size);
         return activityCheckinMapper.selectPage(p,
                 new LambdaQueryWrapper<ActivityCheckin>().eq(ActivityCheckin::getUserId, userId))
                 .getRecords();
@@ -40,4 +55,90 @@ public class ActivityCheckinService {
         rec.setReviewStatus(reviewStatus);
         return activityCheckinMapper.updateById(rec) > 0;
     }
+
+    /**
+     * 管理端分页查询:按活动 + 审核状态 + 签到类型筛选,返回带姓名/活动标题的 VO 分页。
+     */
+    public Page<ActivityCheckinVO> pageByActivity(Long activityId, String reviewStatus, String checkInType,
+                                                  Integer page, Integer size) {
+        Page<ActivityCheckin> p = new Page<>(page, size);
+        LambdaQueryWrapper<ActivityCheckin> q = new LambdaQueryWrapper<ActivityCheckin>()
+                .eq(ActivityCheckin::getActivityId, activityId)
+                .eq(StringUtils.hasText(reviewStatus), ActivityCheckin::getReviewStatus, reviewStatus)
+                .eq(StringUtils.hasText(checkInType), ActivityCheckin::getCheckInType, checkInType)
+                .orderByDesc(ActivityCheckin::getCreatedAt);
+        Page<ActivityCheckin> result = activityCheckinMapper.selectPage(p, q);
+        return toVOPage(result, page, size);
+    }
+
+    /**
+     * 管理端导出:不分页全量拉取(按同一套筛选条件),供前端生成 CSV。
+     */
+    public List<ActivityCheckinVO> exportByActivity(Long activityId, String reviewStatus, String checkInType) {
+        LambdaQueryWrapper<ActivityCheckin> q = new LambdaQueryWrapper<ActivityCheckin>()
+                .eq(ActivityCheckin::getActivityId, activityId)
+                .eq(StringUtils.hasText(reviewStatus), ActivityCheckin::getReviewStatus, reviewStatus)
+                .eq(StringUtils.hasText(checkInType), ActivityCheckin::getCheckInType, checkInType)
+                .orderByDesc(ActivityCheckin::getCreatedAt);
+        List<ActivityCheckin> list = activityCheckinMapper.selectList(q);
+        return toVOs(list, activityId);
+    }
+
+    /** 实体 → VO(批量补全 userId/补录人姓名、活动标题,避免前端 N+1) */
+    private List<ActivityCheckinVO> toVOs(List<ActivityCheckin> list, Long activityId) {
+        if (list == null || list.isEmpty()) return new ArrayList<>();
+
+        Map<Long, String> nameMap = new HashMap<>();
+        Map<Long, String> phoneMap = new HashMap<>();
+        List<Long> userIds = new ArrayList<>();
+        List<Long> checkInBys = new ArrayList<>();
+        for (ActivityCheckin c : list) {
+            if (c.getUserId() != null && !userIds.contains(c.getUserId())) userIds.add(c.getUserId());
+            if (c.getCheckInBy() != null && !checkInBys.contains(c.getCheckInBy())) checkInBys.add(c.getCheckInBy());
+        }
+        List<Long> allIds = new ArrayList<>(userIds);
+        allIds.addAll(checkInBys);
+        if (!allIds.isEmpty()) {
+            for (User u : userMapper.selectBatchIds(allIds)) {
+                if (u == null || u.getId() == null) continue;
+                String name = u.getNickname() != null ? u.getNickname()
+                        : (u.getRealName() != null ? u.getRealName() : ("用户" + u.getId()));
+                nameMap.put(u.getId(), name);
+                phoneMap.put(u.getId(), u.getPhone());
+            }
+        }
+
+        String activityTitle = null;
+        if (activityId != null) {
+            Activity activity = activityMapper.selectById(activityId);
+            activityTitle = activity != null ? activity.getTitle() : null;
+        }
+
+        List<ActivityCheckinVO> vos = new ArrayList<>(list.size());
+        for (ActivityCheckin c : list) {
+            ActivityCheckinVO vo = new ActivityCheckinVO();
+            vo.setId(c.getId());
+            vo.setActivityId(c.getActivityId());
+            vo.setActivityTitle(activityTitle);
+            vo.setUserId(c.getUserId());
+            vo.setUserName(nameMap.get(c.getUserId()));
+            vo.setUserPhone(phoneMap.get(c.getUserId()));
+            vo.setRegistrationId(c.getRegistrationId());
+            vo.setCheckInAt(c.getCheckInAt());
+            vo.setCheckInType(c.getCheckInType());
+            vo.setCheckInBy(c.getCheckInBy());
+            vo.setCheckInByName(c.getCheckInBy() != null ? nameMap.get(c.getCheckInBy()) : null);
+            vo.setReviewStatus(c.getReviewStatus());
+            vo.setCreatedAt(c.getCreatedAt());
+            vos.add(vo);
+        }
+        return vos;
+    }
+
+    private Page<ActivityCheckinVO> toVOPage(Page<ActivityCheckin> result, Integer page, Integer size) {
+        Page<ActivityCheckinVO> voPage = new Page<>(page, size, result.getTotal());
+        voPage.setRecords(toVOs(result.getRecords(), result.getRecords().isEmpty() ? null :
+                result.getRecords().get(0).getActivityId()));
+        return voPage;
+    }
 }

+ 38 - 0
cfc-web/src/api/activity.js

@@ -97,6 +97,44 @@ export function endActivity(id) {
   })
 }
 
+// ===== 活动签到管理 =====
+
+// 按活动分页查询签到记录(支持审核状态/签到类型筛选)
+export function getCheckinList(params) {
+  return request({
+    url: '/api/admin/activity-checkins/list',
+    method: 'post',
+    data: params
+  })
+}
+
+// 导出签到名单(JSON 列表,前端生成 CSV)
+export function exportCheckins(params) {
+  return request({
+    url: '/api/admin/activity-checkins/export',
+    method: 'post',
+    data: params
+  })
+}
+
+// 手动补录签到(admin,幂等)
+export function addCheckin(data) {
+  return request({
+    url: '/api/admin/activity-checkins/add',
+    method: 'post',
+    data
+  })
+}
+
+// 修改签到审核状态
+export function reviewCheckin(data) {
+  return request({
+    url: '/api/admin/activity-checkins/review',
+    method: 'post',
+    data
+  })
+}
+
 // 活动报名列表
 export function getRegistrationList(params) {
   return request({

+ 6 - 0
cfc-web/src/router/index.js

@@ -713,6 +713,12 @@ const routes = [
         component: () => import('@/views/admin/ActivityRegistrationReview.vue'),
         meta: { title: '活动报名审核', perm: 'activity:review' }
       },
+      {
+        path: 'activity-checkin-manage',
+        name: 'ActivityCheckinManage',
+        component: () => import('@/views/admin/ActivityCheckinManage.vue'),
+        meta: { title: '活动签到审核', perm: 'activity:checkin' }
+      },
       {
         path: 'poster-template-manage',
         name: 'PosterTemplateManage',

+ 1 - 0
cfc-web/src/views/Layout.vue

@@ -242,6 +242,7 @@ export default {
         { title: '活动管理', icon: 'el-icon-date', perm: 'activity:*', children: [
           { path: '/activities', label: '活动列表', icon: 'el-icon-date', perm: 'activity:list,activity:self' },
           { path: '/activity-registration-review', label: '活动报名审核', icon: 'el-icon-document-checked', perm: 'activity:registration' },
+          { path: '/activity-checkin-manage', label: '活动签到审核', icon: 'el-icon-time', perm: 'activity:checkin' },
           { path: '/poster-template-manage', label: '海报模板管理', icon: 'el-icon-picture', perm: 'activity:poster_template' },
         ]},
 

+ 489 - 0
cfc-web/src/views/admin/ActivityCheckinManage.vue

@@ -0,0 +1,489 @@
+<template>
+  <div class="activity-checkin-manage admin-page">
+    <PageHeader title="活动签到审核">
+      <div class="filters">
+        <el-select v-model="activityId" placeholder="选择活动" filterable clearable style="width: 220px;" @change="loadCheckinList">
+          <el-option v-for="a in activityList" :key="a.id" :label="a.title" :value="a.id" />
+        </el-select>
+        <el-select v-model="checkInTypeFilter" placeholder="签到类型" clearable style="width: 130px;" @change="loadCheckinList">
+          <el-option label="全部类型" value="" />
+          <el-option label="扫码签到" value="qr" />
+          <el-option label="手动补录" value="admin" />
+          <el-option label="GPS签到" value="gps" />
+        </el-select>
+        <el-select v-model="reviewStatusFilter" placeholder="审核状态" clearable style="width: 130px;" @change="loadCheckinList">
+          <el-option label="全部状态" value="" />
+          <el-option label="待审核" value="pending" />
+          <el-option label="已通过" value="approved" />
+          <el-option label="已拒绝" value="rejected" />
+        </el-select>
+        <el-input v-model="keyword" placeholder="搜索姓名/手机号/用户ID" prefix-icon="el-icon-search" style="width: 220px;" clearable @keyup.enter.native="loadCheckinList" @clear="loadCheckinList" />
+        <el-button type="primary" size="mini" icon="el-icon-download" :disabled="!activityId || exporting" @click="handleExport">导出名单</el-button>
+        <el-button type="success" size="mini" icon="el-icon-plus" :disabled="!activityId" @click="showAddDialog">手动补录</el-button>
+      </div>
+    </PageHeader>
+
+    <el-tabs v-model="activeTab" type="card" @tab-click="handleTabClick">
+      <el-tab-pane label="已签到记录" name="checked" />
+      <el-tab-pane label="未签到人员" name="unchecked" />
+    </el-tabs>
+
+    <div class="adm-table-wrap">
+      <el-table v-show="activeTab === 'checked'" height="100%" :data="filteredRecords" v-loading="loading" border stripe>
+        <el-table-column prop="id" label="ID" width="70">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="签到人" min-width="130">
+          <template slot-scope="{ row }">
+            <div class="user-cell">
+              <span class="user-name">{{ row.userName || ('用户' + row.userId) }}</span>
+              <span class="user-meta">ID: {{ row.userId }}</span>
+              <span v-if="row.userPhone" class="user-meta">{{ row.userPhone }}</span>
+            </div>
+          </template>
+        </el-table-column>
+        <el-table-column prop="checkInAt" label="签到时间" width="160">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('checkInAt', $event)">签到时间<span v-if="sortIndex('checkInAt') !== null" class="sort-index" :class="sortClass('checkInAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('checkInAt') }}</span>{{ sortIcon('checkInAt') }}</span>
+          </template>
+          <template slot-scope="{ row }">{{ fmtLocalTime(row.checkInAt) }}</template>
+        </el-table-column>
+        <el-table-column label="签到类型" width="110">
+          <template slot-scope="{ row }">
+            <el-tag size="mini" :type="checkInTypeTagType(row.checkInType)" effect="plain">{{ checkInTypeLabel(row.checkInType) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="审核状态" width="100">
+          <template slot-scope="{ row }">
+            <el-tag size="mini" :type="reviewStatusTagType(row.reviewStatus)">{{ reviewStatusLabel(row.reviewStatus) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="补录操作人" width="130">
+          <template slot-scope="{ row }">
+            <span v-if="row.checkInBy">{{ row.checkInByName || ('操作人' + row.checkInBy) }}</span>
+            <span v-else class="text-muted">—</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="registrationId" label="关联报名ID" width="110">
+          <template slot-scope="{ row }">{{ row.registrationId || '—' }}</template>
+        </el-table-column>
+        <el-table-column prop="createdAt" label="创建时间" width="160">
+          <template slot-scope="{ row }">{{ fmtLocalTime(row.createdAt) }}</template>
+        </el-table-column>
+        <el-table-column label="操作" width="150" fixed="right">
+          <template slot-scope="{ row }">
+            <el-button v-if="row.reviewStatus !== 'approved'" type="success" size="mini" @click="handleReview(row, 'approved')">通过</el-button>
+            <el-button v-if="row.reviewStatus !== 'rejected'" type="danger" size="mini" @click="handleReview(row, 'rejected')">拒绝</el-button>
+            <span v-if="row.reviewStatus === 'approved'" class="text-muted">已通过</span>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <el-table v-show="activeTab === 'unchecked'" height="100%" :data="uncheckedList" v-loading="loading" border stripe>
+        <el-table-column prop="userId" label="用户ID" width="90" />
+        <el-table-column label="报名人" min-width="160">
+          <template slot-scope="{ row }">
+            <div class="user-cell">
+              <span class="user-name">{{ row.registrantName || placeholderName(row) }}</span>
+              <span v-if="row.registrantPhone" class="user-meta">{{ row.registrantPhone }}</span>
+              <span v-else-if="isSelfRegistration(row)" class="user-meta">本人报名</span>
+            </div>
+          </template>
+        </el-table-column>
+        <el-table-column prop="status" label="报名状态" width="110">
+          <template slot-scope="{ row }">
+            <el-tag size="mini" :type="regStatusTagType(row.status)">{{ regStatusLabel(row.status) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="registeredAt" label="报名时间" width="170">
+          <template slot-scope="{ row }">{{ fmtLocalTime(row.registeredAt) }}</template>
+        </el-table-column>
+        <el-table-column label="操作" width="120" fixed="right">
+          <template slot-scope="{ row }">
+            <el-button type="primary" size="mini" @click="showAddDialog(row)">补录签到</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </div>
+
+    <div class="pagination-wrap">
+      <el-pagination
+        v-show="activeTab === 'checked'"
+        background
+        layout="total, sizes, prev, pager, next, jumper"
+        :total="total"
+        :current-page="page"
+        :page-size="size"
+        :page-sizes="[10, 20, 50, 100]"
+        @current-change="handlePageChange"
+        @size-change="handleSizeChange"
+      />
+    </div>
+
+    <el-dialog title="手动补录签到" :visible.sync="addDialogVisible" width="460px">
+      <el-form label-width="90px">
+        <el-form-item label="活动">
+          <div class="readonly-field">{{ currentActivityTitle || ('活动 #' + activityId) }}</div>
+        </el-form-item>
+        <el-form-item label="签到人" required>
+          <el-select
+            v-model="addForm.userId"
+            filterable
+            remote
+            clearable
+            reserve-keyword
+            placeholder="输入姓名/手机号/用户ID搜索"
+            :remote-method="searchUsers"
+            :loading="searchLoading"
+            style="width: 100%;"
+          >
+            <el-option
+              v-for="u in userOptions"
+              :key="u.id"
+              :label="userLabel(u)"
+              :value="u.id"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="签到时间">
+          <div class="readonly-field">提交时由系统记录(当前操作时间)</div>
+        </el-form-item>
+      </el-form>
+      <div class="dialog-tip">补录后记录 check_in_type=admin、check_in_by=当前操作人,审核状态默认为「已通过」。</div>
+      <span slot="footer">
+        <el-button @click="addDialogVisible = false">取消</el-button>
+        <el-button type="primary" :loading="submitting" @click="handleAdd">确认补录</el-button>
+      </span>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getActivityList, getCheckinList, exportCheckins, addCheckin, reviewCheckin, getRegistrationList } from '@/api/activity'
+import { searchUsers } from '@/api/admin'
+import SortMixin from '@/mixins/SortMixin'
+
+const CHECKIN_TYPE_MAP = {
+  qr: { label: '扫码签到', type: 'success' },
+  admin: { label: '手动补录', type: 'warning' },
+  gps: { label: 'GPS签到', type: 'primary' }
+}
+
+const REVIEW_STATUS_MAP = {
+  pending: { label: '待审核', type: 'warning' },
+  approved: { label: '已通过', type: 'success' },
+  rejected: { label: '已拒绝', type: 'danger' }
+}
+
+const REG_STATUS_MAP = {
+  pending: { label: '待审核', type: 'warning' },
+  approved: { label: '已通过', type: 'success' },
+  rejected: { label: '已拒绝', type: 'danger' },
+  cancelled: { label: '已取消', type: 'info' }
+}
+
+export default {
+  name: 'ActivityCheckinManage',
+  mixins: [SortMixin],
+  data() {
+    return {
+      sortableColumns: { id: 'ID', checkInAt: '签到时间' },
+      activityList: [],
+      activityId: null,
+      currentActivityTitle: '',
+      checkInTypeFilter: '',
+      reviewStatusFilter: '',
+      keyword: '',
+      activeTab: 'checked',
+      loading: false,
+      exporting: false,
+      records: [],
+      total: 0,
+      page: 1,
+      size: 10,
+      registrationList: [],
+      addDialogVisible: false,
+      submitting: false,
+      addForm: { userId: null },
+      userOptions: [],
+      searchLoading: false
+    }
+  },
+  computed: {
+    filteredRecords() {
+      var kw = this.keyword.trim().toLowerCase()
+      var list = this.records
+      if (kw) {
+        list = this.records.filter(function (row) {
+          return (row.userName && String(row.userName).toLowerCase().indexOf(kw) !== -1) ||
+                 (row.userPhone && String(row.userPhone).indexOf(kw) !== -1) ||
+                 (row.userId !== null && row.userId !== undefined && String(row.userId).indexOf(kw) !== -1)
+        })
+      }
+      // 表头排序(sortState 由 SortMixin 维护,客户端生效)
+      if (this.sortState && this.sortState.length > 0) {
+        var copy = list.slice()
+        var sortState = this.sortState
+        copy.sort(function (a, b) {
+          for (var i = 0; i < sortState.length; i++) {
+            var s = sortState[i]
+            var av = a[s.column]
+            var bv = b[s.column]
+            if (av === null || av === undefined) av = ''
+            if (bv === null || bv === undefined) bv = ''
+            if (av < bv) return s.order === 'asc' ? -1 : 1
+            if (av > bv) return s.order === 'asc' ? 1 : -1
+          }
+          return 0
+        })
+        list = copy
+      }
+      return list
+    },
+    // 已报名但未签到的名单:以 activity_registrations 为基数减去同一 userId 的签到记录
+    uncheckedList() {
+      const checkedUserIds = new Set(this.records.map(function (r) { return r.userId }))
+      return this.registrationList.filter(function (reg) {
+        if (reg.status === 'rejected' || reg.status === 'cancelled') return false
+        return !checkedUserIds.has(reg.userId)
+      })
+    }
+  },
+  mounted() {
+    this.loadActivities()
+  },
+  methods: {
+    checkInTypeTagType(type) {
+      return (CHECKIN_TYPE_MAP[type] && CHECKIN_TYPE_MAP[type].type) || 'info'
+    },
+    checkInTypeLabel(type) {
+      return (CHECKIN_TYPE_MAP[type] && CHECKIN_TYPE_MAP[type].label) || type || '—'
+    },
+    reviewStatusTagType(status) {
+      return (REVIEW_STATUS_MAP[status] && REVIEW_STATUS_MAP[status].type) || 'info'
+    },
+    reviewStatusLabel(status) {
+      return (REVIEW_STATUS_MAP[status] && REVIEW_STATUS_MAP[status].label) || status || '—'
+    },
+    regStatusTagType(status) {
+      return (REG_STATUS_MAP[status] && REG_STATUS_MAP[status].type) || 'info'
+    },
+    regStatusLabel(status) {
+      return (REG_STATUS_MAP[status] && REG_STATUS_MAP[status].label) || status || '—'
+    },
+    isSelfRegistration(row) {
+      return row.userId !== null && row.userId !== undefined
+    },
+    placeholderName(row) {
+      const fallback = row.userId != null ? '用户' + row.userId : (row.memberId != null ? '成员' + row.memberId : '—')
+      return fallback
+    },
+    userLabel(u) {
+      var name = u.nickname || u.realName || ('用户' + u.id)
+      return u.phone ? name + '(' + u.phone + ')' : name
+    },
+    // 后端把 java.util.Date 序列化为 UTC 偏移串(如 2026-10-25T01:00:00.000+00:00),
+    // 统一转 Asia/Shanghai 本地墙钟时间展示
+    fmtLocalTime(val) {
+      if (val === null || val === undefined || val === '') return '—'
+      var d = new Date(val)
+      if (isNaN(d.getTime())) return String(val)
+      var bj = new Date(d.getTime() + d.getTimezoneOffset() * 60000 + 8 * 3600000)
+      var pad = function (n) { return n < 10 ? '0' + n : '' + n }
+      return bj.getFullYear() + '-' + pad(bj.getMonth() + 1) + '-' + pad(bj.getDate()) + ' ' +
+        pad(bj.getHours()) + ':' + pad(bj.getMinutes()) + ':' + pad(bj.getSeconds())
+    },
+    async loadActivities() {
+      try {
+        const res = await getActivityList({ page: 1, size: 200 })
+        if (res.code === 200) {
+          this.activityList = (res.data && res.data.records) || []
+          if (this.activityList.length === 1) {
+            this.activityId = this.activityList[0].id
+            this.currentActivityTitle = this.activityList[0].title
+            this.loadCheckinList()
+          }
+        }
+      } catch (e) { console.error(e) }
+    },
+    async loadCheckinList() {
+      if (!this.activityId) {
+        this.records = []
+        this.total = 0
+        return
+      }
+      this.loading = true
+      try {
+        let selected = null
+        for (let i = 0; i < this.activityList.length; i++) {
+          if (this.activityList[i].id === this.activityId) selected = this.activityList[i]
+        }
+        this.currentActivityTitle = selected ? selected.title : ''
+        const params = { activityId: this.activityId, page: this.page, size: this.size }
+        if (this.checkInTypeFilter) params.checkInType = this.checkInTypeFilter
+        if (this.reviewStatusFilter) params.reviewStatus = this.reviewStatusFilter
+        if (this.sortState.length > 0) this.applySortToParams(params)
+        const res = await getCheckinList(params)
+        if (res.code === 200) {
+          this.records = (res.data && res.data.records) || []
+          this.total = (res.data && res.data.total) || 0
+        }
+      } catch (e) {
+        console.error(e)
+      } finally {
+        this.loading = false
+      }
+    },
+    async handleTabClick(tab) {
+      if (tab.name === 'unchecked') {
+        await this.loadRegistrations()
+      } else {
+        this.loadCheckinList()
+      }
+    },
+    async loadRegistrations() {
+      if (!this.activityId) return
+      this.loading = true
+      try {
+        const res = await getRegistrationList({ activityId: this.activityId })
+        if (res.code === 200) this.registrationList = res.data || []
+      } catch (e) {
+        console.error(e)
+      } finally {
+        this.loading = false
+      }
+    },
+    handlePageChange(p) {
+      this.page = p
+      this.loadCheckinList()
+    },
+    handleSizeChange(s) {
+      this.size = s
+      this.page = 1
+      this.loadCheckinList()
+    },
+    async handleReview(row, reviewStatus) {
+      try {
+        const res = await reviewCheckin({ id: row.id, reviewStatus: reviewStatus })
+        if (res.code === 200) {
+          this.$message.success(reviewStatus === 'approved' ? '已通过' : '已拒绝')
+          this.loadCheckinList()
+        } else {
+          this.$message.error(res.message || '操作失败')
+        }
+      } catch (e) {
+        this.$message.error('操作失败')
+      }
+    },
+    showAddDialog(row) {
+      this.addForm = {
+        userId: row ? row.userId : null
+      }
+      this.userOptions = []
+      this.addDialogVisible = true
+    },
+    async searchUsers(query) {
+      this.searchLoading = true
+      try {
+        const res = await searchUsers({ keyword: query })
+        if (res.code === 200) this.userOptions = res.data || []
+      } catch (e) {
+        console.error(e)
+      } finally {
+        this.searchLoading = false
+      }
+    },
+    async handleAdd() {
+      if (!this.activityId || !this.addForm.userId) {
+        this.$message.warning('请选择签到人')
+        return
+      }
+      this.submitting = true
+      try {
+        const res = await addCheckin({
+          activityId: this.activityId,
+          userId: this.addForm.userId
+        })
+        if (res.code === 200) {
+          this.$message.success('补录成功,操作人与时间已记录')
+          this.addDialogVisible = false
+          this.loadCheckinList()
+          if (this.registrationList.length) this.loadRegistrations()
+        } else {
+          this.$message.error(res.message || '补录失败')
+        }
+      } catch (e) {
+        this.$message.error('补录失败')
+      } finally {
+        this.submitting = false
+      }
+    },
+    handleExport() {
+      if (!this.activityId) {
+        this.$message.warning('请先选择活动')
+        return
+      }
+      this.$confirm('将按当前筛选条件导出签到名单(CSV),是否继续?', '导出确认', {
+        confirmButtonText: '导出',
+        cancelButtonText: '取消',
+        type: 'info'
+      }).then(async () => {
+        this.exporting = true
+        try {
+          const params = { activityId: this.activityId }
+          if (this.checkInTypeFilter) params.checkInType = this.checkInTypeFilter
+          if (this.reviewStatusFilter) params.reviewStatus = this.reviewStatusFilter
+          const res = await exportCheckins(params)
+          if (res.code === 200) {
+            this.downloadCsv(res.data || [])
+          } else {
+            this.$message.error(res.message || '导出失败')
+          }
+        } catch (e) {
+          this.$message.error('导出失败')
+        } finally {
+          this.exporting = false
+        }
+      }).catch(function () {})
+    },
+    downloadCsv(rows) {
+      var headers = ['签到ID', '活动ID', '活动标题', '签到人ID', '签到人姓名', '手机号', '关联报名ID', '签到时间', '签到类型', '审核状态', '补录操作人', '创建时间']
+      var lines = [headers.join(',')]
+      rows.forEach(function (r) {
+        var cells = [r.id, r.activityId, r.activityTitle, r.userId, r.userName, r.userPhone, r.registrationId, r.checkInAt, r.checkInType, r.reviewStatus, r.checkInByName, r.createdAt]
+        lines.push(cells.map(function (c) { return c === null || c === undefined ? '' : String(c) }).join(','))
+      })
+      var activityName = this.currentActivityTitle ? this.currentActivityTitle.replace(/[\\/:*?"<>|]/g, '') : ('activity_' + this.activityId)
+      var fileName = '活动签到名单_' + activityName + '_' + this.today() + '.csv'
+      // 带 BOM,避免 Excel 打开中文乱码
+      var blob = new Blob(['\ufeff' + lines.join('\r\n')], { type: 'text/csv;charset=utf-8;' })
+      var url = URL.createObjectURL(blob)
+      var link = document.createElement('a')
+      link.href = url
+      link.download = fileName
+      document.body.appendChild(link)
+      link.click()
+      document.body.removeChild(link)
+      URL.revokeObjectURL(url)
+      this.$message.success('已导出 ' + rows.length + ' 条记录')
+    },
+    today() {
+      var d = new Date()
+      var pad = function (n) { return n < 10 ? '0' + n : '' + n }
+      return d.getFullYear() + pad(d.getMonth() + 1) + pad(d.getDate())
+    }
+  }
+}
+</script>
+
+<style scoped>
+.filters { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
+.user-cell { display: flex; flex-direction: column; line-height: 1.4; }
+.user-name { font-weight: 500; }
+.user-meta { font-size: 12px; color: var(--adm-text-45); }
+.text-muted { color: var(--adm-text-45); }
+.readonly-field { color: var(--adm-text-65); }
+.dialog-tip { margin: 0 0 6px 90px; font-size: 12px; color: var(--adm-text-45); }
+</style>