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

chore: auto bump version and changelog [skip ci]

iwt 2 недель назад
Родитель
Сommit
06a6ed6774

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-93960f8bd39a4398b6241f4e8d1c89719b0088a4
+936416908336efa78a85566e1261ad4dd0efb648

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1773",
+  "version": "1.0.1774",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.1773",
+      "version": "1.0.1774",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1774",
+  "version": "1.0.1775",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 18 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,24 @@
 
 ---
 
+## v1.0.1775 (2026-10-23)
+
+### 测试
+- T15 P0 W1 复核 补 unit 层名额并发/边界用例
+
+### 其他
+- - ActivityRegistrationService.occupyQuota: 条件 UPDATE (approved_count < max) 原子扣减
+- - ActivityRegistrationService.releaseQuota: 条件 UPDATE (approved_count > 0) 回滚
+- - ActivityRegistrationService.cancel: approved 取消 退款+releaseQuota 同事务
+- 新增 unit/ 层 8 个用例补充边界验证:
+- - batchRegister 双报名人抢最后名额,第二个失败
+- - 双线程并发 register 抢最后名额,恰一成一败
+- - cancel approved: 退款成功才回滚名额 / 无订单免费活动直接回滚
+- - cancel pending: 不占名额故不回滚
+- - approve pending: 名额可用→占成功 / 名额已满→拒绝
+- 
+
+
 ## v1.0.1774 (2026-10-23)
 
 ### Bug 修复

+ 19 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1774
+> 当前版本: v1.0.1775
 
 ## 历史版本
 
@@ -8,6 +8,24 @@
 
 ---
 
+## v1.0.1775 (2026-10-23)
+
+### 测试
+- T15 P0 W1 复核 补 unit 层名额并发/边界用例
+
+### 其他
+- - ActivityRegistrationService.occupyQuota: 条件 UPDATE (approved_count < max) 原子扣减
+- - ActivityRegistrationService.releaseQuota: 条件 UPDATE (approved_count > 0) 回滚
+- - ActivityRegistrationService.cancel: approved 取消 退款+releaseQuota 同事务
+- 新增 unit/ 层 8 个用例补充边界验证:
+- - batchRegister 双报名人抢最后名额,第二个失败
+- - 双线程并发 register 抢最后名额,恰一成一败
+- - cancel approved: 退款成功才回滚名额 / 无订单免费活动直接回滚
+- - cancel pending: 不占名额故不回滚
+- - approve pending: 名额可用→占成功 / 名额已满→拒绝
+- 
+
+
 ## v1.0.1774 (2026-10-23)
 
 ### Bug 修复

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

@@ -122,4 +122,38 @@ export function rejectRegistration(data) {
     method: 'post',
     data: data
   })
+}
+
+// 活动报名名册(脱敏版,供活动详情页展示)
+export function getRegistrationRoster(activityId) {
+  return request({
+    url: '/api/activity/registration-roster',
+    method: 'post',
+    data: { activityId: activityId }
+  })
+}
+
+// ===== 签到审核(T12)=====
+export function getActivityCheckins(params) {
+  return request({
+    url: '/api/admin/activity-checkins/list',
+    method: 'post',
+    data: params
+  })
+}
+
+export function addActivityCheckin(data) {
+  return request({
+    url: '/api/admin/activity-checkins/add',
+    method: 'post',
+    data: data
+  })
+}
+
+export function reviewActivityCheckin(data) {
+  return request({
+    url: '/api/admin/activity-checkins/review',
+    method: 'post',
+    data: data
+  })
 }

+ 178 - 3
cfc-web/src/views/admin/ActivityEdit.vue

@@ -30,6 +30,51 @@
         class="mb-16"
       />
 
+      <!-- 报名名册(T03:仅需报名的活动展示,详情页/编辑页均可查看) -->
+      <el-card v-if="showRosterCard" class="roster-card" shadow="never">
+        <div slot="header" class="roster-card-header">
+          <span>报名名册</span>
+          <div class="roster-card-actions">
+            <el-button size="mini" :loading="rosterLoading" @click="loadRoster">刷新</el-button>
+            <el-button size="mini" type="primary" :disabled="!rosterRegistrants.length" @click="exportRosterCsv">导出 CSV</el-button>
+          </div>
+        </div>
+        <div v-loading="rosterLoading" class="roster-body">
+          <el-row :gutter="12" class="roster-stats">
+            <el-col :span="8">
+              <div class="stat-box">
+                <div class="stat-num">{{ roster.approvedCount != null ? roster.approvedCount : '-' }}</div>
+                <div class="stat-label">已通过报名</div>
+              </div>
+            </el-col>
+            <el-col :span="8">
+              <div class="stat-box">
+                <div class="stat-num">{{ roster.pendingCount != null ? roster.pendingCount : '-' }}</div>
+                <div class="stat-label">待审核</div>
+              </div>
+            </el-col>
+            <el-col :span="8">
+              <div class="stat-box">
+                <div class="stat-num" :class="{ 'stat-danger': roster.remaining != null && roster.remaining <= 0 }">
+                  {{ roster.remaining != null ? roster.remaining : '不限' }}
+                </div>
+                <div class="stat-label">剩余名额</div>
+              </div>
+            </el-col>
+          </el-row>
+          <div class="roster-list">
+            <div class="roster-list-title">最近报名人({{ rosterRegistrants.length }})</div>
+            <div v-if="rosterRegistrants.length" class="roster-list-body">
+              <div v-for="(r, idx) in rosterRegistrants" :key="'roster-' + idx" class="roster-item">
+                <span class="roster-name">{{ r.name }}</span>
+                <span class="roster-time">{{ formatTime(r.registeredAt) }}</span>
+              </div>
+            </div>
+            <div v-else class="roster-empty">还没有人报名,快来抢占第一个名额吧</div>
+          </div>
+        </div>
+      </el-card>
+
       <div class="activity-edit-form-wrap">
       <el-form label-width="120px" v-loading="loading" :model="form" :rules="formRules" ref="form">
         <el-form-item label="活动标题" prop="title">
@@ -173,7 +218,7 @@
 
 <script>
 import DimensionWeightPicker from '@/components/DimensionWeightPicker.vue'
-import { getActivityDetail, createActivity, updateActivity, publishActivity, submitActivityReview, reDraftActivity } from '@/api/activity'
+import { getActivityDetail, createActivity, updateActivity, publishActivity, submitActivityReview, reDraftActivity, getRegistrationRoster } from '@/api/activity'
 import { saveDimensionWeights, getDimensionWeights } from '@/api/dimension-weight.js'
 import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
 import '@wangeditor/editor/dist/css/style.css'
@@ -233,10 +278,18 @@ export default {
       },
       editorConfig: null,
       toolbarConfig: {},
-      editorInstance: null
+      editorInstance: null,
+      // T03 报名名册
+      roster: { approvedCount: 0, pendingCount: 0, remaining: null },
+      rosterRegistrants: [],
+      rosterLoading: false
     }
   },
   computed: {
+    showRosterCard() {
+      // 仅需报名的活动,在详情页(readonly)或编辑已发布活动页显示报名名册
+      return this.readonly && this.form.requireRegistration === 1
+    },
     statusLabel() {
       const map = { draft: '当前状态:草稿箱', pending: '当前状态:待审核(等待管理员审核)', rejected: '当前状态:已驳回', published: '当前状态:发布中', withdrawn: '当前状态:已撤回' }
       return map[this.activityStatus] || ''
@@ -333,7 +386,8 @@ export default {
             visibility: d.visibility || 'public',
             visibleScope: d.visibleScope || '',
             visibleTo: d.visibleTo || '',
-            checkinPoints: d.checkinPoints || 0
+            checkinPoints: d.checkinPoints || 0,
+            requireRegistration: d.requireRegistration != null ? d.requireRegistration : 0
           }
           try {
             const dwRes = await getDimensionWeights('activity', d.id)
@@ -342,6 +396,10 @@ export default {
             }
           } catch (e) {
           }
+          // T03:需报名的活动自动拉取名册(详情页/编辑页都展示)
+          if (this.form.requireRegistration === 1) {
+            this.loadRoster()
+          }
         }
       } catch (e) {
         this.$message.error('加载活动详情失败')
@@ -349,6 +407,43 @@ export default {
         this.loading = false
       }
     },
+    // ===== T03 报名名册 =====
+    formatTime(t) {
+      return t ? String(t).replace('T', ' ').substring(0, 19) : '-'
+    },
+    async loadRoster() {
+      if (!this.$route.query.id) return
+      this.rosterLoading = true
+      try {
+        const res = await getRegistrationRoster(this.$route.query.id)
+        if (res && res.data) {
+          this.roster = res.data
+          this.rosterRegistrants = res.data.registrants || []
+        }
+      } catch (e) {
+        this.$message.error('加载报名名册失败')
+      } finally {
+        this.rosterLoading = false
+      }
+    },
+    exportRosterCsv() {
+      if (!this.rosterRegistrants.length) return
+      var rows = ['姓名,报名时间']
+      this.rosterRegistrants.forEach(function(r) {
+        var name = String(r.name || '').replace(/,/g, ',')
+        var time = r.registeredAt ? String(r.registeredAt).replace('T', ' ').substring(0, 19) : ''
+        rows.push(name + ',' + time)
+      })
+      // 汇总行(Excel 友好:UTF-8 BOM)
+      var csv = '\uFEFF' + rows.join('\r\n') + '\r\n'
+      var blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
+      var link = document.createElement('a')
+      link.href = URL.createObjectURL(blob)
+      link.download = '报名名册-' + (this.form.title || '活动') + '.csv'
+      link.click()
+      URL.revokeObjectURL(link.href)
+      this.$message.success('已导出 ' + this.rosterRegistrants.length + ' 条报名记录')
+    },
     beforeUpload(file) {
       const isImage = file.type === 'image/jpeg' || file.type === 'image/png' ||
         file.type === 'image/gif' || file.type === 'image/webp'
@@ -638,4 +733,84 @@ activityId
   padding-right: 8px;
   margin-right: -8px;
 }
+
+/* ===== T03 报名名册 ===== */
+.roster-card {
+  margin-bottom: 16px;
+  flex-shrink: 0;
+}
+.roster-card /deep/ .el-card__header {
+  padding: 10px 20px;
+}
+.roster-card-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 15px;
+  font-weight: 600;
+  color: var(--adm-text-88);
+}
+.roster-card-actions {
+  display: flex;
+  gap: 8px;
+}
+.roster-stats {
+  margin-bottom: 12px;
+}
+.stat-box {
+  padding: 8px 12px;
+  background: var(--adm-bg-header);
+  border-radius: 4px;
+  text-align: center;
+}
+.stat-num {
+  font-size: 20px;
+  font-weight: 700;
+  color: var(--adm-text-88);
+}
+.stat-danger {
+  color: #f56c6c;
+}
+.stat-label {
+  font-size: 12px;
+  color: var(--adm-text-45);
+  margin-top: 4px;
+}
+.roster-list {
+  border-top: 1px solid var(--adm-border-split);
+  padding-top: 12px;
+}
+.roster-list-title {
+  font-size: 13px;
+  font-weight: 600;
+  color: var(--adm-text-strong);
+  margin-bottom: 8px;
+}
+.roster-list-body {
+  max-height: 200px;
+  overflow-y: auto;
+}
+.roster-item {
+  display: flex;
+  justify-content: space-between;
+  padding: 6px 0;
+  border-bottom: 1px solid var(--adm-border-split);
+}
+.roster-item:last-child {
+  border-bottom: none;
+}
+.roster-name {
+  font-size: 13px;
+  color: var(--adm-text-strong);
+}
+.roster-time {
+  font-size: 12px;
+  color: var(--adm-text-45);
+}
+.roster-empty {
+  font-size: 13px;
+  color: var(--adm-text-45);
+  text-align: center;
+  padding: 20px 0;
+}
 </style>