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

feat(family-coupon): 家庭管理优惠券数量列 + 家庭优惠券三合一页

- backend: Family 实体新增 couponCount 瞬态字段;AdminController.getFamilies
  注入 FamilyCouponMapper 并回填 couponCount(family_coupon 记录数,无 DDL)
- web: Families.vue 新增可点击「优惠券数量」列,4 计数列改为可点击 el-link,
  移除「查看信息」下拉项与三个优惠券弹窗,报告/方案/任务计数打开明细弹窗
- web: 新建 FamilyCouponManage.vue(发放/券列表/使用记录 三 Tab),路由
  /admin/family-coupon 承载,点击优惠券数量跳转
Sisyphus Agent 1 неделя назад
Родитель
Сommit
115460c410

+ 5 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminController.java

@@ -67,6 +67,9 @@ public class AdminController {
     @Resource
     private HealthPlanMapper healthPlanMapper;
 
+    @Resource
+    private FamilyCouponMapper familyCouponMapper;
+
     @Resource
     private TaskTemplateMapper taskTemplateMapper;
 
@@ -826,11 +829,13 @@ public class AdminController {
             Map<Long, Long> reportCnt = countByFamilyIds(healthReportMapper, ids, null);
             Map<Long, Long> planCnt   = countByFamilyIds(healthPlanMapper, ids, null);
             Map<Long, Long> taskCnt   = countByFamilyIds(taskMapper, ids, null);
+            Map<Long, Long> couponCnt = countByFamilyIds(familyCouponMapper, ids, null);
             for (Family f : records) {
                 f.setMemberCount(memberCnt.getOrDefault(f.getId(), 0L));
                 f.setReportCount(reportCnt.getOrDefault(f.getId(), 0L));
                 f.setPlanCount(planCnt.getOrDefault(f.getId(), 0L));
                 f.setTaskCount(taskCnt.getOrDefault(f.getId(), 0L));
+                f.setCouponCount(couponCnt.getOrDefault(f.getId(), 0L));
             }
         }
         return Result.success(result);

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/Family.java

@@ -49,6 +49,9 @@ public class Family implements Serializable {
     @TableField(exist = false)
     private Long taskCount;     // 任务数(tasks)
 
+    @TableField(exist = false)
+    private Long couponCount;   // 优惠券数量(family_coupon 记录总数)
+
     public Long getCreatorId() { return creatorId; }
     public void setCreatorId(Long creatorId) { this.creatorId = creatorId; }
 }

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

@@ -768,6 +768,12 @@ const routes = [
         component: () => import('@/views/admin/CouponGiftTransfers.vue'),
         meta: { title: '会员券转赠记录', perm: 'marketing:coupon' }
       },
+      {
+        path: 'admin/family-coupon',
+        name: 'FamilyCouponManage',
+        component: () => import('@/views/admin/FamilyCouponManage.vue'),
+        meta: { title: '家庭优惠券', perm: 'marketing:coupon' }
+      },
       {
         path: 'discount-rules',
         name: 'DiscountRuleManagement',

+ 175 - 264
cfc-web/src/views/Families.vue

@@ -54,16 +54,34 @@
           </template>
         </el-table-column>
         <el-table-column prop="memberCount" label="当前成员数" width="110">
-          <template slot-scope="scope">{{ scope.row.memberCount || 0 }}</template>
+          <template slot-scope="scope">
+            <el-link v-if="scope.row.memberCount > 0" type="primary" :underline="false" @click.stop="handleViewMembers(scope.row)">{{ scope.row.memberCount }}</el-link>
+            <span v-else class="cell-zero">0</span>
+          </template>
         </el-table-column>
         <el-table-column prop="reportCount" label="上传报告数" width="110">
-          <template slot-scope="scope">{{ scope.row.reportCount || 0 }}</template>
+          <template slot-scope="scope">
+            <el-link v-if="scope.row.reportCount > 0" type="primary" :underline="false" @click.stop="openDetailDialog(scope.row.id, 'reports')">{{ scope.row.reportCount }}</el-link>
+            <span v-else class="cell-zero">0</span>
+          </template>
         </el-table-column>
         <el-table-column prop="planCount" label="方案数" width="100">
-          <template slot-scope="scope">{{ scope.row.planCount || 0 }}</template>
+          <template slot-scope="scope">
+            <el-link v-if="scope.row.planCount > 0" type="primary" :underline="false" @click.stop="openDetailDialog(scope.row.id, 'plans')">{{ scope.row.planCount }}</el-link>
+            <span v-else class="cell-zero">0</span>
+          </template>
         </el-table-column>
         <el-table-column prop="taskCount" label="任务数" width="100">
-          <template slot-scope="scope">{{ scope.row.taskCount || 0 }}</template>
+          <template slot-scope="scope">
+            <el-link v-if="scope.row.taskCount > 0" type="primary" :underline="false" @click.stop="openDetailDialog(scope.row.id, 'tasks')">{{ scope.row.taskCount }}</el-link>
+            <span v-else class="cell-zero">0</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="couponCount" label="优惠券数量" width="100">
+          <template slot-scope="scope">
+            <el-link v-if="scope.row.couponCount > 0" type="primary" :underline="false" @click.stop="goCouponManage(scope.row.id)">{{ scope.row.couponCount }}</el-link>
+            <span v-else class="cell-zero">0</span>
+          </template>
         </el-table-column>
         <el-table-column label="操作" width="250" fixed="right">
           <template slot-scope="{ row }">
@@ -73,11 +91,7 @@
                 更多<i class="el-icon-arrow-down el-icon--right"></i>
               </el-button>
               <el-dropdown-menu slot="dropdown">
-                <el-dropdown-item command="viewOverview" icon="el-icon-view">查看信息</el-dropdown-item>
                 <el-dropdown-item command="viewMembers" icon="el-icon-user">查看成员</el-dropdown-item>
-                <el-dropdown-item command="viewCoupons" icon="el-icon-ticket">优惠券列表</el-dropdown-item>
-                <el-dropdown-item command="viewUsage" icon="el-icon-document">使用记录</el-dropdown-item>
-                <el-dropdown-item command="issueCoupon" icon="el-icon-ticket">发放优惠券</el-dropdown-item>
                 <el-dropdown-item command="delete" icon="el-icon-delete">删除</el-dropdown-item>
               </el-dropdown-menu>
             </el-dropdown>
@@ -181,152 +195,104 @@
       </div>
     </el-dialog>
 
-    <!-- 发放优惠券弹窗 -->
-    <el-dialog
-      title="发放优惠券"
-      :visible.sync="issueDialogVisible"
-      width="500px"
-    >
-      <el-form :model="couponForm" label-width="100px">
-        <el-form-item label="家庭">
-          <el-input :value="couponForm.familyName" disabled></el-input>
-        </el-form-item>
-        <el-form-item label="优惠券" required>
-          <el-select v-model="couponForm.couponId" placeholder="请选择优惠券" class="full-width">
-            <el-option
-              v-for="c in couponOptions"
-              :key="c.id"
-              :label="couponLabel(c)"
-              :value="c.id"
-            ></el-option>
-          </el-select>
-        </el-form-item>
-        <el-form-item label="发放数量">
-          <el-input-number v-model="couponForm.quantity" :min="1" :max="100" :step="1"></el-input-number>
-        </el-form-item>
-      </el-form>
-      <div slot="footer">
-        <el-button @click="issueDialogVisible = false">取消</el-button>
-        <el-button type="primary" @click="confirmIssueCoupon" :loading="couponSubmitting">确认发放</el-button>
-      </div>
-    </el-dialog>
-
-    <!-- 优惠券列表弹窗 -->
+    <!-- 报告/方案/任务明细弹窗 -->
     <el-dialog
-      title="优惠券列表"
-      :visible.sync="couponDialogVisible"
+      :title="detailDialogTitle"
+      :visible.sync="detailDialogVisible"
       width="900px"
+      top="6vh"
+      :close-on-click-modal="false"
     >
-      <el-form :inline="true" class="mb-12">
-        <el-form-item label="状态">
-          <el-select v-model="couponStatusFilter" placeholder="全部" clearable size="small" class="w-120">
-            <el-option label="可用" value="AVAILABLE"></el-option>
-            <el-option label="已使用" value="USED"></el-option>
-          </el-select>
-        </el-form-item>
-        <el-form-item>
-          <el-button size="small" @click="loadCoupons(currentRow.id)">刷新</el-button>
-        </el-form-item>
-      </el-form>
-      <el-table :data="couponListData" border v-loading="couponLoading" class="full-width" max-height="400">
-        <el-table-column prop="name" label="券名称" min-width="140"></el-table-column>
-        <el-table-column prop="type" label="类型" width="80">
-          <template slot-scope="scope">
-            <StatusTag size="mini" :type="scope.row.type === 'DISCOUNT' ? 'primary' : 'success'" :text="scope.row.type === 'DISCOUNT' ? '折扣' : '满减'" />
-          </template>
-        </el-table-column>
-        <el-table-column label="面值/折扣" width="100">
-          <template slot-scope="scope">
-            <span v-if="scope.row.type === 'DISCOUNT' && scope.row.discountRate">{{ (scope.row.discountRate / 100).toFixed(1) }}折</span>
-            <span v-else-if="scope.row.value">{{ (scope.row.value / 100).toFixed(2) }}元</span>
-            <span v-else>-</span>
-          </template>
-        </el-table-column>
-        <el-table-column label="状态" width="80">
-          <template slot-scope="scope">
-            <StatusTag size="mini" :type="scope.row.status === 'AVAILABLE' ? 'success' : 'info'" :text="scope.row.status === 'AVAILABLE' ? '可用' : '已使用'" />
-          </template>
-        </el-table-column>
-        <el-table-column prop="receivedAt" label="领取时间" width="160">
-          <template slot-scope="scope">{{ formatTime(scope.row.receivedAt) }}</template>
-        </el-table-column>
-        <el-table-column prop="usedAt" label="使用时间" width="160">
-          <template slot-scope="scope">{{ formatTime(scope.row.usedAt) }}</template>
-        </el-table-column>
-      <el-pagination
-        class="pagination-right-mt12"
-        @size-change="onCouponSizeChange"
-        @current-change="onCouponPageChange"
-        :current-page="couponPagination.current"
-        :page-sizes="[10, 20, 50]"
-        :page-size="couponPagination.size"
-        :total="couponPagination.total"
-        layout="total, sizes, prev, pager, next"
-      ></el-pagination>
-    </el-dialog>
+      <div v-loading="detailLoading">
+        <template v-if="detailType === 'reports'">
+          <el-table :data="(detailOverview && detailOverview.reports) || []" border class="full-width" max-height="400">
+            <el-table-column label="类型">
+              <template slot-scope="{ row }">
+                <StatusTag :options="detailReportTypeOptions" :value="row.reportType" />
+              </template>
+            </el-table-column>
+            <el-table-column prop="reportNumber" label="报告编号" width="140" />
+            <el-table-column label="日期">
+              <template slot-scope="{ row }">{{ formatDate(row.reportDate) }}</template>
+            </el-table-column>
+            <el-table-column prop="personName" label="姓名" />
+            <el-table-column label="状态">
+              <template slot-scope="{ row }">
+                <StatusTag :options="detailReportStatusOptions" :value="row.status" />
+              </template>
+            </el-table-column>
+            <el-table-column prop="overallScore" label="总分" width="70" />
+            <el-table-column prop="gutHealthScore" label="肠道分" width="80" />
+          </el-table>
+          <el-empty v-if="!detailLoading && (!detailOverview || !detailOverview.reports || !detailOverview.reports.length)" description="暂无数据" :image-size="60" />
+        </template>
 
-    <!-- 使用记录弹窗 -->
-    <el-dialog
-      title="使用记录"
-      :visible.sync="usageDialogVisible"
-      width="800px"
-    >
-      <el-table :data="usageListData" border v-loading="usageLoading" class="full-width" max-height="400">
-        <el-table-column prop="name" label="券名称" min-width="140"></el-table-column>
-        <el-table-column prop="type" label="类型" width="80">
-          <template slot-scope="scope">
-            <StatusTag size="mini" :type="scope.row.type === 'DISCOUNT' ? 'primary' : 'success'" :text="scope.row.type === 'DISCOUNT' ? '折扣' : '满减'" />
-          </template>
-        </el-table-column>
-        <el-table-column label="面值/折扣" width="100">
-          <template slot-scope="scope">
-            <span v-if="scope.row.type === 'DISCOUNT' && scope.row.discountRate">{{ (scope.row.discountRate / 100).toFixed(1) }}折</span>
-            <span v-else-if="scope.row.value">{{ (scope.row.value / 100).toFixed(2) }}元</span>
-            <span v-else>-</span>
-          </template>
-        </el-table-column>
-        <el-table-column prop="orderId" label="订单号" width="120">
-          <template slot-scope="scope">{{ scope.row.orderId || '-' }}</template>
-        </el-table-column>
-        <el-table-column prop="usedAt" label="使用时间" width="160">
-          <template slot-scope="scope">{{ formatTime(scope.row.usedAt) }}</template>
-        </el-table-column>
-      <el-pagination
-        class="pagination-right-mt12"
-        @size-change="onUsageSizeChange"
-        @current-change="onUsagePageChange"
-        :current-page="usagePagination.current"
-        :page-sizes="[10, 20, 50]"
-        :page-size="usagePagination.size"
-        :total="usagePagination.total"
-        layout="total, sizes, prev, pager, next"
-      ></el-pagination>
+        <template v-else-if="detailType === 'plans'">
+          <el-table :data="(detailOverview && detailOverview.plans) || []" border class="full-width" max-height="400">
+            <el-table-column label="类型">
+              <template slot-scope="{ row }">
+                <StatusTag :options="detailPlanTypeOptions" :value="row.planType" />
+              </template>
+            </el-table-column>
+            <el-table-column prop="memberName" label="成员" />
+            <el-table-column prop="goal" label="目标" show-overflow-tooltip />
+            <el-table-column label="状态">
+              <template slot-scope="{ row }">
+                <StatusTag :options="detailPlanStatusOptions" :value="row.status" />
+              </template>
+            </el-table-column>
+            <el-table-column prop="totalDays" label="天数" width="70" />
+            <el-table-column label="创建时间">
+              <template slot-scope="{ row }">{{ formatDate(row.createdAt) }}</template>
+            </el-table-column>
+          </el-table>
+          <el-empty v-if="!detailLoading && (!detailOverview || !detailOverview.plans || !detailOverview.plans.length)" description="暂无数据" :image-size="60" />
+        </template>
+
+        <template v-else-if="detailType === 'tasks'">
+          <el-table :data="detailTasks" border class="full-width" max-height="400">
+            <el-table-column prop="title" label="标题" show-overflow-tooltip />
+            <el-table-column label="状态">
+              <template slot-scope="{ row }">
+                <StatusTag :options="detailTaskStatusOptions" :value="row.status" />
+              </template>
+            </el-table-column>
+            <el-table-column prop="category" label="分类" width="110" />
+            <el-table-column prop="points" label="积分" width="70" />
+            <el-table-column label="截止">
+              <template slot-scope="{ row }">{{ formatDate(row.deadline) }}</template>
+            </el-table-column>
+          </el-table>
+          <el-pagination
+            class="pagination-right-mt12"
+            @current-change="loadTasks"
+            :current-page="detailTaskPage"
+            :page-size="detailTaskSize"
+            :total="detailTaskTotal"
+            layout="total, prev, pager, next"
+          />
+          <el-empty v-if="!detailLoading && (!detailTasks || !detailTasks.length)" description="暂无数据" :image-size="60" />
+        </template>
+      </div>
     </el-dialog>
-  
-  </el-table>
-  
-  </el-table>
-    <!-- 家庭信息总览弹窗 (Task 9) -->
-    <FamilyOverviewDialog :visible.sync="overviewDialogVisible" :family-id="overviewFamilyId" />
   </div>
 </template>
 
 <script>
 import SortMixin from '@/mixins/SortMixin'
 import ListStateMixin from '@/mixins/ListStateMixin'
-import { getFamilyList, getFamilyMembers, updateFamily, deleteFamily, setMemberAdminRole } from '@/api/admin'
-import { getCouponList, issueFamilyCoupon, getFamilyCoupons, getFamilyCouponUsage } from '@/api/coupon'
-import FamilyOverviewDialog from '@/views/admin/components/FamilyOverviewDialog.vue'
+import { getFamilyList, getFamilyMembers, getFamilyOverview, getFamilyTasks, updateFamily, deleteFamily, setMemberAdminRole } from '@/api/admin'
 
 export default {
   name: 'Families',
   mixins: [SortMixin, ListStateMixin],
-  components: {
-    FamilyOverviewDialog
-  },
   computed: {
     tableHeight() {
       return window.innerHeight - 340
+    },
+    detailDialogTitle() {
+      const titles = { reports: '报告列表', plans: '方案列表', tasks: '任务列表' }
+      return titles[this.detailType] || '家庭明细'
     }
   },
   data() {
@@ -358,26 +324,50 @@ export default {
         family: null,
         members: []
       },
-      couponDialogVisible: false,
-      issueDialogVisible: false,
-      couponForm: {
-        familyId: null,
-        couponId: null,
-        quantity: 1
-      },
-      couponOptions: [],
-      couponSubmitting: false,
-      currentRow: null,
-      couponListData: [],
-      couponLoading: false,
-      couponPagination: { current: 1, size: 20, total: 0 },
-      couponStatusFilter: '',
-      usageListData: [],
-      usageLoading: false,
-      usagePagination: { current: 1, size: 20, total: 0 },
-      usageDialogVisible: false,
-      overviewDialogVisible: false,
-      overviewFamilyId: null
+      detailDialogVisible: false,
+      detailType: '',
+      detailFamilyId: null,
+      detailOverview: null,
+      detailLoading: false,
+      detailTasks: [],
+      detailTaskPage: 1,
+      detailTaskSize: 20,
+      detailTaskTotal: 0,
+      detailReportTypeOptions: [
+        { value: 'gut_flora', label: '菌群检测', type: 'success' },
+        { value: 'physical_exam', label: '体检报告', type: 'primary' },
+        { value: 'dan', label: 'DAN测评', type: 'warning' },
+        { value: 'brain_status', label: '脑状态', type: 'primary' },
+        { value: 'aptitude', label: '先天潜能', type: 'primary' },
+        { value: 'insurance', label: '保单', type: 'info' },
+        { value: 'fortune', label: '家庭周报', type: 'danger' }
+      ],
+      detailReportStatusOptions: [
+        { value: 'active', label: '生效', type: 'success' },
+        { value: 'archived', label: '已归档', type: 'warning' },
+        { value: 'deleted', label: '已删除', type: 'info' }
+      ],
+      detailPlanTypeOptions: [
+        { value: 'health_plan', label: '健康方案', type: 'primary' },
+        { value: 'family_attribution_plan', label: '家庭归因方案', type: 'warning' }
+      ],
+      detailPlanStatusOptions: [
+        { value: 'draft', label: '草稿', type: 'info' },
+        { value: 'pending_review', label: '待审核', type: 'warning' },
+        { value: 'published', label: '已发布', type: 'success' },
+        { value: 'rejected', label: '已驳回', type: 'danger' }
+      ],
+      detailTaskStatusOptions: [
+        { value: 'pending', label: '进行中', type: 'warning' },
+        { value: 'pending_review', label: '待审核', type: 'warning' },
+        { value: 'completed', label: '已完成', type: 'success' },
+        { value: 'locked', label: '前置锁定', type: 'info' },
+        { value: 'assigned', label: '待接受', type: 'info' },
+        { value: 'in_progress', label: '进行中', type: 'warning' },
+        { value: 'rejected', label: '已拒绝', type: 'danger' },
+        { value: 'overdue', label: '已过期', type: 'danger' },
+        { value: 'cancelled', label: '已取消', type: 'info' }
+      ]
     }
   },
   mounted() {
@@ -394,11 +384,7 @@ export default {
     },
     handleActionCmd(row, cmd) {
       switch (cmd) {
-        case 'viewOverview': this.handleViewOverview(row); break;
         case 'viewMembers': this.handleViewMembers(row); break;
-        case 'viewCoupons': this.handleViewCoupons(row); break;
-        case 'viewUsage': this.handleViewUsage(row); break;
-        case 'issueCoupon': this.handleIssueCoupon(row); break;
         case 'edit': this.handleEdit(row); break;
         case 'delete': this.handleDelete(row); break;
       }
@@ -482,10 +468,6 @@ export default {
         }
       }).catch(() => {})
     },
-    handleViewOverview(row) {
-      this.overviewFamilyId = row.id
-      this.overviewDialogVisible = true
-    },
     async handleViewMembers(row) {
       await this.handleViewMembersById(row.id)
     },
@@ -515,116 +497,44 @@ export default {
         this.$message.error(error.message || '操作失败')
       }
     },
-    async handleIssueCoupon(row) {
-      this.couponForm = {
-        familyId: row.id,
-        familyName: row.name,
-        couponId: null,
-        quantity: 1
-      }
-      this.issueDialogVisible = true
-      try {
-        const res = await getCouponList({ page: 1, size: 999 })
-        this.couponOptions = res.data.records || res.data.list || res.data || []
-      } catch (error) {
-        this.$message.error(error.message || '加载优惠券列表失败')
-      }
+    goCouponManage(familyId) {
+      this.$router.push({ path: '/admin/family-coupon', query: { familyId } })
     },
-    couponLabel(c) {
-      if (c.type === 'DISCOUNT') {
-        return `${c.name}(${(c.discountRate / 100).toFixed(1)}折)`
-      }
-      return `${c.name}(${(c.value / 100).toFixed(2)}元)`
+    openDetailDialog(familyId, type) {
+      this.detailFamilyId = familyId
+      this.detailType = type
+      this.detailOverview = null
+      this.detailTasks = []
+      this.detailTaskPage = 1
+      this.detailTaskTotal = 0
+      this.detailDialogVisible = true
+      this.loadDetail()
     },
-    async confirmIssueCoupon() {
-      if (!this.couponForm.couponId) {
-        this.$message.warning('请选择优惠券')
-        return
-      }
-      this.couponSubmitting = true
+    async loadDetail() {
+      if (!this.detailFamilyId) return
+      this.detailLoading = true
       try {
-        await issueFamilyCoupon({
-          familyId: this.couponForm.familyId,
-          couponId: this.couponForm.couponId,
-          quantity: this.couponForm.quantity
-        })
-        this.$message.success('发放成功')
-        this.issueDialogVisible = false
-      } catch (error) {
-        this.$message.error(error.message || '发放失败')
-      } finally {
-        this.couponSubmitting = false
-      }
-    },
-
-
-    handleViewCoupons(row) {
-      this.currentRow = row
-      this.couponPagination = { current: 1, size: 20, total: 0 }
-      this.couponStatusFilter = ''
-      this.couponListData = []
-      this.couponDialogVisible = true
-      this.loadCoupons()
-    },
-    async loadCoupons() {
-      if (!this.currentRow) return
-      this.couponLoading = true
-      try {
-        const res = await getFamilyCoupons({
-          familyId: this.currentRow.id,
-          status: this.couponStatusFilter || undefined,
-          page: this.couponPagination.current,
-          size: this.couponPagination.size
-        })
-        this.couponListData = res.data.records || []
-        this.couponPagination.total = res.data.total || 0
+        if (this.detailType === 'tasks') {
+          await this.loadTasks(1)
+        } else {
+          const res = await getFamilyOverview(this.detailFamilyId)
+          this.detailOverview = res.data
+        }
       } catch (error) {
-        this.$message.error(error.message || '加载优惠券列表失败')
+        this.$message.error(error.message || '加载明细失败')
       } finally {
-        this.couponLoading = false
+        this.detailLoading = false
       }
     },
-    onCouponPageChange(val) {
-      this.couponPagination.current = val
-      this.loadCoupons()
-    },
-    onCouponSizeChange(val) {
-      this.couponPagination.size = val
-      this.couponPagination.current = 1
-      this.loadCoupons()
-    },
-    handleViewUsage(row) {
-      this.currentRow = row
-      this.usagePagination = { current: 1, size: 20, total: 0 }
-      this.usageListData = []
-      this.usageDialogVisible = true
-      this.loadUsage()
-    },
-    async loadUsage() {
-      if (!this.currentRow) return
-      this.usageLoading = true
+    async loadTasks(page) {
+      this.detailTaskPage = page
       try {
-        const res = await getFamilyCouponUsage({
-          familyId: this.currentRow.id,
-          page: this.usagePagination.current,
-          size: this.usagePagination.size
-        })
-        this.usageListData = res.data.records || []
-        this.usagePagination.total = res.data.total || 0
+        const res = await getFamilyTasks(this.detailFamilyId, { page, size: this.detailTaskSize })
+        this.detailTasks = res.data.records || []
+        this.detailTaskTotal = res.data.total || 0
       } catch (error) {
-        this.$message.error(error.message || '加载使用记录失败')
-      } finally {
-        this.usageLoading = false
+        this.$message.error(error.message || '加载任务列表失败')
       }
-    },
-    onUsagePageChange(val) {
-      this.usagePagination.current = val
-      this.loadUsage()
-    },
-    onUsageSizeChange(val) {
-      this.usagePagination.size = val
-      this.usagePagination.current = 1
-      this.loadUsage()
     }
   }
 }
@@ -636,6 +546,7 @@ export default {
 .mb-12 { margin-bottom: 12px; }
 .full-width { width: 100%; }
 .w-120 { width: 120px; }
+.cell-zero { color: #999; }
 .pagination-right { margin-top: 24px; text-align: right; }
 .pagination-right-mt12 { margin-top: 12px; text-align: right; }
 </style>

+ 267 - 0
cfc-web/src/views/admin/FamilyCouponManage.vue

@@ -0,0 +1,267 @@
+<template>
+  <div class="family-coupon-manage admin-page">
+    <el-card>
+      <PageHeader slot="header" title="家庭优惠券管理" :subtitle="'家庭 ID:' + familyId" />
+
+      <el-tabs v-model="activeTab" @tab-click="onTabClick">
+        <!-- 发放 -->
+        <el-tab-pane label="发放" name="issue">
+          <el-form :inline="true" :model="issueForm" class="mb-12">
+            <el-form-item label="优惠券" required>
+              <el-select v-model="issueForm.couponId" placeholder="请选择优惠券" class="w-240">
+                <el-option
+                  v-for="c in couponOptions"
+                  :key="c.id"
+                  :label="couponLabel(c)"
+                  :value="c.id"
+                ></el-option>
+              </el-select>
+            </el-form-item>
+            <el-form-item label="发放数量">
+              <el-input-number v-model="issueForm.quantity" :min="1" :max="100" :step="1"></el-input-number>
+            </el-form-item>
+            <el-form-item>
+              <el-button type="primary" @click="confirmIssue" :loading="issueSubmitting">确认发放</el-button>
+            </el-form-item>
+          </el-form>
+        </el-tab-pane>
+
+        <!-- 券列表 -->
+        <el-tab-pane label="券列表" name="list">
+          <el-form :inline="true" class="mb-12">
+            <el-form-item label="状态">
+              <el-select v-model="statusFilter" placeholder="全部" clearable size="small" class="w-120" @change="handleFilterChange">
+                <el-option label="可用" value="AVAILABLE"></el-option>
+                <el-option label="已使用" value="USED"></el-option>
+              </el-select>
+            </el-form-item>
+            <el-form-item>
+              <el-button size="small" @click="loadList">刷新</el-button>
+            </el-form-item>
+          </el-form>
+          <div class="adm-table-wrap">
+            <el-table :data="listData" border v-loading="listLoading" class="full-width" height="100%">
+              <el-table-column prop="name" label="券名称" min-width="140"></el-table-column>
+              <el-table-column prop="type" label="类型" width="80">
+                <template slot-scope="scope">
+                  <StatusTag size="mini" :type="scope.row.type === 'DISCOUNT' ? 'primary' : 'success'" :text="scope.row.type === 'DISCOUNT' ? '折扣' : '满减'" />
+                </template>
+              </el-table-column>
+              <el-table-column label="面值/折扣" width="100">
+                <template slot-scope="scope">
+                  <span v-if="scope.row.type === 'DISCOUNT' && scope.row.discountRate">{{ (scope.row.discountRate / 100).toFixed(1) }}折</span>
+                  <span v-else-if="scope.row.value">{{ (scope.row.value / 100).toFixed(2) }}元</span>
+                  <span v-else>-</span>
+                </template>
+              </el-table-column>
+              <el-table-column label="状态" width="80">
+                <template slot-scope="scope">
+                  <StatusTag size="mini" :type="scope.row.status === 'AVAILABLE' ? 'success' : 'info'" :text="scope.row.status === 'AVAILABLE' ? '可用' : '已使用'" />
+                </template>
+              </el-table-column>
+              <el-table-column prop="receivedAt" label="领取时间" width="160">
+                <template slot-scope="scope">{{ formatTime(scope.row.receivedAt) }}</template>
+              </el-table-column>
+              <el-table-column prop="usedAt" label="使用时间" width="160">
+                <template slot-scope="scope">{{ formatTime(scope.row.usedAt) }}</template>
+              </el-table-column>
+            </el-table>
+          </div>
+          <el-pagination
+            class="pagination-wrap"
+            @size-change="onListSizeChange"
+            @current-change="onListPageChange"
+            :current-page="listPagination.current"
+            :page-sizes="[10, 20, 50]"
+            :page-size="listPagination.size"
+            :total="listPagination.total"
+            layout="total, sizes, prev, pager, next"
+          ></el-pagination>
+        </el-tab-pane>
+
+        <!-- 使用记录 -->
+        <el-tab-pane label="使用记录" name="usage">
+          <div class="adm-table-wrap">
+            <el-table :data="usageData" border v-loading="usageLoading" class="full-width" height="100%">
+              <el-table-column prop="name" label="券名称" min-width="140"></el-table-column>
+              <el-table-column prop="type" label="类型" width="80">
+                <template slot-scope="scope">
+                  <StatusTag size="mini" :type="scope.row.type === 'DISCOUNT' ? 'primary' : 'success'" :text="scope.row.type === 'DISCOUNT' ? '折扣' : '满减'" />
+                </template>
+              </el-table-column>
+              <el-table-column label="面值/折扣" width="100">
+                <template slot-scope="scope">
+                  <span v-if="scope.row.type === 'DISCOUNT' && scope.row.discountRate">{{ (scope.row.discountRate / 100).toFixed(1) }}折</span>
+                  <span v-else-if="scope.row.value">{{ (scope.row.value / 100).toFixed(2) }}元</span>
+                  <span v-else>-</span>
+                </template>
+              </el-table-column>
+              <el-table-column prop="orderId" label="订单号" width="140">
+                <template slot-scope="scope">{{ scope.row.orderId || '-' }}</template>
+              </el-table-column>
+              <el-table-column prop="usedAt" label="使用时间" width="160">
+                <template slot-scope="scope">{{ formatTime(scope.row.usedAt) }}</template>
+              </el-table-column>
+            </el-table>
+          </div>
+          <el-pagination
+            class="pagination-wrap"
+            @size-change="onUsageSizeChange"
+            @current-change="onUsagePageChange"
+            :current-page="usagePagination.current"
+            :page-sizes="[10, 20, 50]"
+            :page-size="usagePagination.size"
+            :total="usagePagination.total"
+            layout="total, sizes, prev, pager, next"
+          ></el-pagination>
+        </el-tab-pane>
+      </el-tabs>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import { getCouponList, issueFamilyCoupon, getFamilyCoupons, getFamilyCouponUsage } from '@/api/coupon'
+
+export default {
+  name: 'FamilyCouponManage',
+  data() {
+    return {
+      familyId: null,
+      activeTab: 'list',
+      couponOptions: [],
+      issueForm: {
+        couponId: null,
+        quantity: 1
+      },
+      issueSubmitting: false,
+      listData: [],
+      listLoading: false,
+      listPagination: { current: 1, size: 20, total: 0 },
+      statusFilter: '',
+      usageData: [],
+      usageLoading: false,
+      usagePagination: { current: 1, size: 20, total: 0 }
+    }
+  },
+  created() {
+    const familyId = this.$route.query.familyId
+    if (!familyId) {
+      this.$message.warning('缺少家庭 ID 参数')
+      this.$router.replace({ path: '/admin/families' })
+      return
+    }
+    this.familyId = parseInt(familyId)
+    this.activeTab = 'list'
+    this.loadCouponOptions()
+    this.loadList()
+  },
+  methods: {
+    onTabClick(tab) {
+      if (tab.name === 'usage') {
+        this.loadUsage()
+      }
+    },
+    couponLabel(c) {
+      if (c.type === 'DISCOUNT') {
+        return `${c.name}(${(c.discountRate / 100).toFixed(1)}折)`
+      }
+      return `${c.name}(${(c.value / 100).toFixed(2)}元)`
+    },
+    async loadCouponOptions() {
+      try {
+        const res = await getCouponList({ page: 1, size: 999 })
+        this.couponOptions = res.data.records || res.data.list || res.data || []
+      } catch (error) {
+        this.$message.error(error.message || '加载优惠券列表失败')
+      }
+    },
+    async confirmIssue() {
+      if (!this.issueForm.couponId) {
+        this.$message.warning('请选择优惠券')
+        return
+      }
+      this.issueSubmitting = true
+      try {
+        await issueFamilyCoupon({
+          familyId: this.familyId,
+          couponId: this.issueForm.couponId,
+          quantity: this.issueForm.quantity
+        })
+        this.$message.success('发放成功')
+        this.issueForm.couponId = null
+        this.issueForm.quantity = 1
+        this.loadList()
+      } catch (error) {
+        this.$message.error(error.message || '发放失败')
+      } finally {
+        this.issueSubmitting = false
+      }
+    },
+    handleFilterChange() {
+      this.listPagination.current = 1
+      this.loadList()
+    },
+    async loadList() {
+      if (!this.familyId) return
+      this.listLoading = true
+      try {
+        const res = await getFamilyCoupons({
+          familyId: this.familyId,
+          status: this.statusFilter || undefined,
+          page: this.listPagination.current,
+          size: this.listPagination.size
+        })
+        this.listData = res.data.records || []
+        this.listPagination.total = res.data.total || 0
+      } catch (error) {
+        this.$message.error(error.message || '加载优惠券列表失败')
+      } finally {
+        this.listLoading = false
+      }
+    },
+    onListPageChange(val) {
+      this.listPagination.current = val
+      this.loadList()
+    },
+    onListSizeChange(val) {
+      this.listPagination.size = val
+      this.listPagination.current = 1
+      this.loadList()
+    },
+    async loadUsage() {
+      if (!this.familyId) return
+      this.usageLoading = true
+      try {
+        const res = await getFamilyCouponUsage({
+          familyId: this.familyId,
+          page: this.usagePagination.current,
+          size: this.usagePagination.size
+        })
+        this.usageData = res.data.records || []
+        this.usagePagination.total = res.data.total || 0
+      } catch (error) {
+        this.$message.error(error.message || '加载使用记录失败')
+      } finally {
+        this.usageLoading = false
+      }
+    },
+    onUsagePageChange(val) {
+      this.usagePagination.current = val
+      this.loadUsage()
+    },
+    onUsageSizeChange(val) {
+      this.usagePagination.size = val
+      this.usagePagination.current = 1
+      this.loadUsage()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.mb-12 { margin-bottom: 12px; }
+.full-width { width: 100%; }
+.w-120 { width: 120px; }
+.w-240 { width: 240px; }
+</style>