Browse Source

Merge feature/family-workbench-redesign (家庭运营工作台重构: /families 列表精简+家庭工作台统计指标) into cfclub

Agnes Agent 1 day ago
parent
commit
174b73c74

+ 32 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminFamilyOverviewController.java

@@ -4,7 +4,9 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.FamilyOverviewDTO;
 import com.etotem.cfc.dto.TaskItemVO;
+import com.etotem.cfc.service.CfBalanceService;
 import com.etotem.cfc.service.FamilyAdminOverviewService;
+import com.etotem.cfc.service.TeacherMessageService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.web.bind.annotation.*;
@@ -16,6 +18,8 @@ import java.util.Map;
 @RequestMapping("/api/admin")
 public class AdminFamilyOverviewController {
     @Resource private FamilyAdminOverviewService service;
+    @Resource private CfBalanceService cfService;
+    @Resource private TeacherMessageService messageService;
 
     @Operation(summary = "家庭信息概览")
     @PostMapping("/families/{id}/overview")
@@ -39,4 +43,32 @@ public class AdminFamilyOverviewController {
         long size = params != null && params.get("size") != null ? ((Number) params.get("size")).longValue() : 20L;
         return Result.success(service.pageTasks(id, page, size));
     }
+
+    /**
+     * 家庭 CF 值汇总:成员余额 + 合计 + 最近日志(分页)
+     */
+    @Operation(summary = "家庭 CF 值汇总")
+    @PostMapping("/families/{id}/cf-summary")
+    public Result<Map<String, Object>> getCfSummary(@PathVariable Long id,
+                                                    @RequestBody(required = false) Map<String, Object> params,
+                                                    @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) return Result.error("无权限");
+        long page = params != null && params.get("page") != null ? ((Number) params.get("page")).longValue() : 1L;
+        long size = params != null && params.get("size") != null ? ((Number) params.get("size")).longValue() : 20L;
+        return Result.success(cfService.getCfSummary(id, page, size));
+    }
+
+    /**
+     * 家庭咨询消息列表(分页,按 familyId 过滤)
+     */
+    @Operation(summary = "家庭咨询消息列表")
+    @PostMapping("/families/{id}/messages")
+    public Result<Map<String, Object>> getFamilyMessages(@PathVariable Long id,
+                                                         @RequestBody(required = false) Map<String, Object> params,
+                                                         @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) return Result.error("无权限");
+        Integer page = params != null && params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
+        Integer size = params != null && params.get("size") != null ? ((Number) params.get("size")).intValue() : 10;
+        return Result.success(messageService.getFamilyMessages(id, page, size));
+    }
 }

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

@@ -34,4 +34,7 @@ public class TeacherMessage implements Serializable {
 
     @TableField(exist = false)
     private String teacherName;   // 规划师昵称(回显用,不入库)
+
+    @TableField(exist = false)
+    private String parentName;    // 家庭主账号昵称(回显用,不入库)
 }

+ 5 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CfBalanceService.java

@@ -102,4 +102,9 @@ public interface CfBalanceService {
      * @return 发放结果(未发放时 earned=false 且带跳过原因)
      */
     CfEarnResult earnByRule(Long memberId, String ruleCode, Long refId, Map<String, Object> ctx);
+
+    /**
+     * 家庭 CF 汇总:成员余额列表 + 合计 + 近 N 条日志(分页,所有成员合并后按时间倒序)。
+     */
+    Map<String, Object> getCfSummary(Long familyId, long page, long size);
 }

+ 35 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/TeacherMessageService.java

@@ -5,12 +5,16 @@ import com.etotem.cfc.service.api.TeacherMessageServiceInterface;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.entity.TeacherMessage;
+import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.TeacherMessageMapper;
+import com.etotem.cfc.mapper.UserMapper;
 import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
 import java.util.Date;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 
 @Service
 public class TeacherMessageService {
@@ -18,6 +22,9 @@ public class TeacherMessageService {
     @Resource
     private TeacherMessageMapper teacherMessageMapper;
 
+    @Resource
+    private UserMapper userMapper;
+
     /**
      * 家庭主账号发送咨询消息
      */
@@ -75,4 +82,32 @@ public class TeacherMessageService {
                 .eq(TeacherMessage::getParentId, parentId)
                 .orderByDesc(TeacherMessage::getCreatedAt));
     }
+
+    /**
+     * 管理端:按 familyId 分页查询该家庭的咨询消息,回填家长/老师昵称。
+     */
+    public Map<String, Object> getFamilyMessages(Long familyId, Integer page, Integer size) {
+        Page<TeacherMessage> pageParam = new Page<>(page, size);
+        Page<TeacherMessage> result = teacherMessageMapper.selectPage(pageParam,
+                new LambdaQueryWrapper<TeacherMessage>()
+                        .eq(TeacherMessage::getFamilyId, familyId)
+                        .orderByDesc(TeacherMessage::getCreatedAt));
+        // 回填家长昵称
+        for (TeacherMessage msg : result.getRecords()) {
+            if (msg.getParentId() != null) {
+                User parent = userMapper.selectById(msg.getParentId());
+                if (parent != null) msg.setParentName(parent.getNickname());
+            }
+            if (msg.getTeacherId() != null) {
+                User teacher = userMapper.selectById(msg.getTeacherId());
+                if (teacher != null) msg.setTeacherName(teacher.getNickname());
+            }
+        }
+        Map<String, Object> out = new HashMap<>();
+        out.put("records", result.getRecords());
+        out.put("total", result.getTotal());
+        out.put("pages", result.getPages());
+        out.put("current", result.getCurrent());
+        return out;
+    }
 }

+ 70 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/CfBalanceServiceImpl.java

@@ -31,11 +31,16 @@ import org.springframework.context.annotation.Lazy;
 import org.springframework.dao.DuplicateKeyException;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.CollectionUtils;
 
 import javax.annotation.Resource;
+import java.util.ArrayList;
 import java.util.Calendar;
+import java.util.Collections;
+import java.util.Comparator;
 import java.util.Date;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.function.Function;
 
@@ -914,4 +919,69 @@ public class CfBalanceServiceImpl implements CfBalanceService {
     private int safeInt(Integer value) {
         return value == null ? 0 : value;
     }
+
+    /**
+     * 家庭 CF 汇总:成员余额列表 + 合计 + 最近日志(分页,所有成员合并后按 createdAt 倒序)。
+     */
+    @Override
+    public Map<String, Object> getCfSummary(Long familyId, long page, long size) {
+        // 1. 取家庭成员(排除回收箱)
+        List<FamilyMember> members = familyMemberMapper.selectList(
+                new LambdaQueryWrapper<FamilyMember>()
+                        .eq(FamilyMember::getFamilyId, familyId)
+                        .ne(FamilyMember::getStatus, "recycled"));
+        if (CollectionUtils.isEmpty(members)) {
+            Map<String, Object> empty = new HashMap<>();
+            empty.put("familyId", familyId);
+            empty.put("members", Collections.emptyList());
+            empty.put("totalAvailable", 0);
+            empty.put("totalFrozen", 0);
+            empty.put("logs", Collections.emptyList());
+            return empty;
+        }
+
+        // 2. 逐成员查余额
+        int totalAvailable = 0;
+        int totalFrozen = 0;
+        List<Map<String, Object>> memberViews = new ArrayList<>();
+        for (FamilyMember fm : members) {
+            CfBalanceView bv = getBalance(fm.getId());
+            totalAvailable += bv.getAvailable();
+            totalFrozen += bv.getFrozen();
+            Map<String, Object> mv = new HashMap<>();
+            mv.put("memberId", fm.getId());
+            mv.put("userId", fm.getUserId());
+            mv.put("name", fm.getNickname() != null ? fm.getNickname() : "");
+            mv.put("available", bv.getAvailable());
+            mv.put("frozen", bv.getFrozen());
+            mv.put("totalEarned", bv.getTotalEarned());
+            memberViews.add(mv);
+        }
+
+        // 3. 合并日志:每个有 userId 的成员查最近 N 条,合并后排序取分页
+        List<CfBalanceLog> allLogs = new ArrayList<>();
+        for (FamilyMember fm : members) {
+            if (fm.getUserId() == null) continue;
+            Page<CfBalanceLog> p = getLogs(fm.getId(), 1, MAX_PAGE_SIZE);
+            if (p != null && p.getRecords() != null) allLogs.addAll(p.getRecords());
+        }
+        allLogs.sort(Comparator.comparing((CfBalanceLog l) -> l.getCreatedAt() != null ? l.getCreatedAt().getTime() : 0L).reversed()
+                .thenComparingLong(l -> l.getId() != null ? -l.getId() : 0L));
+
+        // 简单分页(内存切片)——家庭规模通常 <100 人、每人若干条,可接受
+        long totalLogs = allLogs.size();
+        long fromIndex = Math.min((page - 1) * size, totalLogs);
+        long toIndex = Math.min(fromIndex + size, totalLogs);
+        List<CfBalanceLog> pagedLogs = fromIndex < totalLogs ? allLogs.subList((int) fromIndex, (int) toIndex) : Collections.emptyList();
+
+        // 4. 构建响应(与 Plan §3.2 建议结构一致)
+        Map<String, Object> result = new HashMap<>();
+        result.put("familyId", familyId);
+        result.put("members", memberViews);
+        result.put("totalAvailable", totalAvailable);
+        result.put("totalFrozen", totalFrozen);
+        result.put("logs", pagedLogs);
+        result.put("logTotal", totalLogs);
+        return result;
+    }
 }

+ 8 - 0
cfc-web/src/api/admin.js

@@ -142,6 +142,14 @@ export function setMemberAdminRole(familyId, memberId, isAdmin) {
   })
 }
 
+// 家庭工作台:CF 值汇总 / 咨询消息(新增)
+export function getFamilyCfSummary(id, params) {
+  return request({ url: `/api/admin/families/${id}/cf-summary`, method: 'post', data: params || {} })
+}
+export function getFamilyMessages(id, params) {
+  return request({ url: `/api/admin/families/${id}/messages`, method: 'post', data: params || {} })
+}
+
 // 家庭数据汇总方式配置(五维家庭汇总:合计 / 平均 / 汇集)
 export function getFamilyMetricAggList() {
   return request({

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

@@ -49,6 +49,12 @@ const routes = [
         component: () => import('@/views/Families.vue'),
         meta: { title: '家庭列表', perm: 'family:list' }
       },
+      {
+        path: 'family-workbench',
+        name: 'FamilyWorkbench',
+        component: () => import('@/views/FamilyWorkbench.vue'),
+        meta: { title: '家庭工作台', perm: 'family:list' }
+      },
       {
         path: 'children',
         name: 'Children',

+ 14 - 228
cfc-web/src/views/Families.vue

@@ -4,7 +4,7 @@
     <div class="admin-page-header">
       <div>
         <span class="admin-page-title">家庭运营工作台</span>
-        <span class="admin-page-subtitle">家庭管理 · 运营总览(从管理方便角度重设计)</span>
+        <span class="admin-page-subtitle">家庭管理 · 运营总览(跨家庭)</span>
       </div>
       <div class="admin-page-actions">
         <el-button type="primary" size="small" @click="handleRefresh" :loading="loading">刷新</el-button>
@@ -37,10 +37,6 @@
         <div class="stat-label">优惠券数<span v-if="statsDegraded">(本页合计)</span></div>
         <div class="stat-value adm-num">{{ displayStats.coupons }}</div>
       </div>
-      <div class="stat-card">
-        <div class="stat-label">家庭总收益</div>
-        <div class="stat-value adm-num" :title="displayStats.earningsAmount == null ? '待收益台账接入:家庭总收益口径未确认,暂不展示数值' : ''">{{ displayStats.earningsAmount == null ? '—' : displayStats.earningsAmount }}</div>
-      </div>
     </div>
 
     <!-- ② 快捷入口网格:一屏触达家庭运营全部模块 -->
@@ -160,25 +156,16 @@
             </template>
           </el-table-column>
           <el-table-column prop="reportCount" label="上传报告数" width="110">
-            <template slot-scope="scope">
-              <el-link type="primary" :underline="false" @click.stop="openDetailDialog(scope.row.id, 'reports')">{{ scope.row.reportCount }}</el-link>
-            </template>
+            <template slot-scope="scope">{{ scope.row.reportCount }}</template>
           </el-table-column>
           <el-table-column prop="planCount" label="方案数" width="100">
-            <template slot-scope="scope">
-              <el-link type="primary" :underline="false" @click.stop="openDetailDialog(scope.row.id, 'plans')">{{ scope.row.planCount }}</el-link>
-            </template>
+            <template slot-scope="scope">{{ scope.row.planCount }}</template>
           </el-table-column>
           <el-table-column prop="taskCount" label="任务数" width="100">
-            <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>
+            <template slot-scope="scope"><span v-if="scope.row.taskCount > 0">{{ scope.row.taskCount }}</span><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 type="primary" :underline="false" @click.stop="goCouponManage(scope.row.id)">{{ scope.row.couponCount }}</el-link>
-            </template>
+            <template slot-scope="scope">{{ scope.row.couponCount }}</template>
           </el-table-column>
           <!-- 启用状态:后端返回 row.status 时显示真实状态;缺省视为启用(待后端补充 enabled/status 字段) -->
           <el-table-column label="启用状态" width="100">
@@ -186,23 +173,10 @@
               <span class="adm-status" :class="row.status === 'disabled' ? 'adm-status--warning' : 'adm-status--success'">{{ row.status === 'disabled' ? '停用' : '启用' }}</span>
             </template>
           </el-table-column>
-          <el-table-column label="操作" width="260" fixed="right">
+          <el-table-column label="操作" width="160" fixed="right">
             <template slot-scope="{ row }">
-              <el-button size="mini" type="primary" @click="handleEdit(row)">编辑</el-button>
-              <el-dropdown trigger="hover" @command="(cmd) => handleMoreCmd(row, cmd)">
-                <el-button size="mini">
-                  更多<i class="el-icon-arrow-down el-icon--right"></i>
-                </el-button>
-                <el-dropdown-menu slot="dropdown">
-                  <el-dropdown-item command="members">查看成员</el-dropdown-item>
-                  <el-dropdown-item command="reports">报告明细</el-dropdown-item>
-                  <el-dropdown-item command="plans">方案明细</el-dropdown-item>
-                  <el-dropdown-item command="tasks">任务明细</el-dropdown-item>
-                  <el-dropdown-item command="coupon">优惠券</el-dropdown-item>
-                  <el-dropdown-item command="earnings">收益</el-dropdown-item>
-                  <el-dropdown-item command="config">配置</el-dropdown-item>
-                </el-dropdown-menu>
-              </el-dropdown>
+              <el-button size="mini" type="primary" @click="goWorkbench(row)">进入工作台</el-button>
+              <el-button size="mini" @click="handleEdit(row)">编辑</el-button>
             </template>
           </el-table-column>
         </el-table>
@@ -299,100 +273,13 @@
         <el-empty v-else description="暂无成员" :image-size="60"></el-empty>
       </div>
     </el-dialog>
-
-    <!-- 报告/方案/任务明细弹窗 -->
-    <el-dialog
-      :title="detailDialogTitle"
-      :visible.sync="detailDialogVisible"
-      width="900px"
-      top="6vh"
-      :close-on-click-modal="false"
-    >
-      <div v-loading="detailLoading">
-        <template v-if="detailType === 'reports'">
-          <div class="mb-12">
-            <el-button type="primary" size="mini" @click="goProxyReportUpload">代传报告</el-button>
-          </div>
-          <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>
-
-        <template v-else-if="detailType === 'plans'">
-          <div class="mb-12">
-            <el-button type="primary" size="mini" @click="openPlanGen">代生成方案</el-button>
-          </div>
-          <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>
   </div>
 </template>
 
 <script>
 import SortMixin from '@/mixins/SortMixin'
 import ListStateMixin from '@/mixins/ListStateMixin'
-import { getFamilyList, getFamilyMembers, getFamilyOverview, getFamilyTasks, getFamilyStats, updateFamily, deleteFamily, setMemberAdminRole } from '@/api/admin'
+import { getFamilyList, getFamilyMembers, getFamilyStats, updateFamily, deleteFamily, setMemberAdminRole } from '@/api/admin'
 
 export default {
   name: 'Families',
@@ -401,10 +288,6 @@ export default {
     tableHeight() {
       return window.innerHeight - 340
     },
-    detailDialogTitle() {
-      const titles = { reports: '报告列表', plans: '方案列表', tasks: '任务列表' }
-      return titles[this.detailType] || '家庭明细'
-    },
     currentPath() {
       return this.$route.path
     },
@@ -470,7 +353,7 @@ export default {
         size: 10,
         total: 0
       },
-      // 快捷入口:家庭运营全部模块,一屏触达
+      // 快捷入口:家庭运营全部模块,一屏触达(含 CF 值管理)
       quickEntries: [
         { label: '家庭列表', path: '/families', icon: 'el-icon-s-home' },
         { label: '孩子管理', path: '/children', icon: 'el-icon-user-solid' },
@@ -478,6 +361,7 @@ export default {
         { label: '积分记录', path: '/points-log', icon: 'el-icon-document' },
         { label: '心愿管理', path: '/wishes', icon: 'el-icon-star-off' },
         { label: '奖励管理', path: '/rewards', icon: 'el-icon-s-goods' },
+        { label: 'CF值管理', path: '/cf-value', icon: 'el-icon-s-coins' },
         { label: '汇总方式配置', path: '/family-metric-agg-config', icon: 'el-icon-s-operation' },
         { label: '家庭优惠券', path: '/admin/family-coupon', icon: 'el-icon-tickets' },
         { label: '家庭收益', path: '/family-earnings', icon: 'el-icon-s-money' },
@@ -498,50 +382,7 @@ export default {
         family: null,
         members: []
       },
-      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' }
-      ]
+      editDialogVisible: false,
     }
   },
   mounted() {
@@ -600,17 +441,6 @@ export default {
     batchToggle() {
       this.$message.info('批量启停功能待后端接口支持')
     },
-    handleMoreCmd(row, cmd) {
-      switch (cmd) {
-        case 'members': this.handleViewMembers(row); break
-        case 'reports': this.openDetailDialog(row.id, 'reports'); break
-        case 'plans': this.openDetailDialog(row.id, 'plans'); break
-        case 'tasks': this.openDetailDialog(row.id, 'tasks'); break
-        case 'coupon': this.goCouponManage(row.id); break
-        case 'earnings': this.$router.push({ path: '/family-earnings', query: { familyId: row.id } }); break
-        case 'config': this.$router.push({ path: '/family-metric-agg-config' }); break
-      }
-    },
     async loadFamilies() {
       this.loading = true
       try {
@@ -730,52 +560,8 @@ export default {
         this.$message.error(error.message || '操作失败')
       }
     },
-    goCouponManage(familyId) {
-      this.$router.push({ path: '/admin/family-coupon', query: { familyId } })
-    },
-    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 loadDetail() {
-      if (!this.detailFamilyId) return
-      this.detailLoading = true
-      try {
-        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 || '加载明细失败')
-      } finally {
-        this.detailLoading = false
-      }
-    },
-    async loadTasks(page) {
-      this.detailTaskPage = page
-      try {
-        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 || '加载任务列表失败')
-      }
-    },
-    goProxyReportUpload() {
-      if (!this.detailFamilyId) return
-      this.$router.push({ path: '/report-proxy-upload', query: { familyId: this.detailFamilyId } })
-    },
-    openPlanGen() {
-      if (!this.detailFamilyId) return
-      this.$router.push({ path: '/health-plan-manage', query: { familyId: this.detailFamilyId } })
+    goWorkbench(row) {
+      this.$router.push({ path: '/family-workbench', query: { familyId: row.id } })
     }
   }
 }

+ 515 - 0
cfc-web/src/views/FamilyWorkbench.vue

@@ -0,0 +1,515 @@
+<template>
+  <div class="page-container">
+    <!-- 页头:返回 + 家庭信息 -->
+    <div class="admin-page-header">
+      <div style="display:flex;align-items:center;gap:12px;">
+        <el-button icon="el-icon-arrow-left" size="small" @click="$router.back()">返回列表</el-button>
+        <span class="admin-page-title">{{ familyName }}</span>
+        <span v-if="familyData.inviteCode" class="adm-tag">邀请码 {{ familyData.inviteCode }}</span>
+        <span v-if="familyData.createdAt" class="adm-sub">{{ formatDate(familyData.createdAt) }}</span>
+        <span v-if="memberCount != null" class="adm-sub">共 {{ memberCount }} 位成员</span>
+      </div>
+      <div class="admin-page-actions">
+        <el-button type="primary" size="small" @click="refresh" :loading="loading">刷新</el-button>
+      </div>
+    </div>
+
+    <!-- 统计卡片带(点击切换 tab) -->
+    <div class="stat-cards">
+      <div
+        v-for="c in cardList"
+        :key="c.key"
+        class="stat-card stat-card--clickable"
+        :class="{ 'is-active': activeTab === c.key }"
+        @click="activeTab = c.key"
+      >
+        <div class="stat-label">{{ c.label }}</div>
+        <div class="stat-value adm-num">{{ cardData[c.key] }}</div>
+        <span v-if="c.badge && cardBadge[c.key] != null" class="badge">{{ cardBadge[c.key] }}</span>
+      </div>
+    </div>
+
+    <!-- ECharts 指标曲线区 -->
+    <el-card class="mb-16">
+      <div slot="header" class="adm-card-header">指标趋势</div>
+      <div ref="reportChart" class="chart-wrap"></div>
+      <div ref="cfChart" class="chart-wrap chart-wrap--mt"></div>
+    </el-card>
+
+    <!-- Tab 明细区 -->
+    <el-card>
+      <div slot="header" class="adm-card-header">
+        <span>{{ tabTitle }}</span>
+      </div>
+      <!-- 报告 tab -->
+      <template v-if="activeTab === 'reports'">
+        <div class="mb-12"><el-button type="primary" size="mini" @click="goProxyReportUpload">代传报告</el-button></div>
+        <el-table :data="reports" border class="full-width" max-height="420" v-loading="tableLoading">
+          <el-table-column label="类型"><template slot-scope="{ row }"><StatusTag :options="reportTypeOptions" :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="reportStatusOptions" :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="!tableLoading && reports.length === 0" description="暂无报告" :image-size="60" />
+      </template>
+
+      <!-- 方案 tab -->
+      <template v-if="activeTab === 'plans'">
+        <div class="mb-12"><el-button type="primary" size="mini" @click="goPlanManage">方案管理/审核</el-button></div>
+        <el-table :data="plans" border class="full-width" max-height="420" v-loading="tableLoading">
+          <el-table-column label="类型"><template slot-scope="{ row }"><StatusTag :options="planTypeOptions" :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="planStatusOptions" :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="!tableLoading && plans.length === 0" description="暂无方案" :image-size="60" />
+      </template>
+
+      <!-- 任务 tab -->
+      <template v-if="activeTab === 'tasks'">
+        <div class="mb-12"><el-button type="primary" size="mini" @click="goTasks">创建任务</el-button></div>
+        <el-table :data="tasks" border class="full-width" max-height="420" v-loading="tableLoading">
+          <el-table-column prop="title" label="标题" show-overflow-tooltip />
+          <el-table-column label="状态"><template slot-scope="{ row }"><StatusTag :options="taskStatusOptions" :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="(p) => loadTasks(p)" :current-page="taskPage" :page-size="taskSize" :total="taskTotal" layout="total, prev, pager, next" />
+        <el-empty v-if="!tableLoading && tasks.length === 0" description="暂无任务" :image-size="60" />
+      </template>
+      <!-- CF值 tab -->
+      <template v-if="activeTab === 'cf'">
+        <div class="cf-summary mb-12" v-loading="tableLoading">
+          <div class="cf-member-row" v-for="m in cfMembers" :key="m.memberId">
+            <span class="name">{{ m.name }}</span>
+            <span class="num">可用 {{ m.available || 0 }}</span>
+            <span class="num frozen">冻结 {{ m.frozen || 0 }}</span>
+            <span class="num earned">累计 {{ m.totalEarned || 0 }}</span>
+          </div>
+          <div class="cf-total">
+            <strong>合计</strong> 可用 {{ cfTotalAvailable }} · 冻结 {{ cfTotalFrozen }}
+          </div>
+        </div>
+        <div class="cf-logs-header">最近流水</div>
+        <el-table :data="cfLogs" border class="full-width" max-height="300">
+          <el-table-column prop="userName" label="用户" width="120" />
+          <el-table-column label="类型" width="90"><template slot-scope="{ row }">{{ cfLogTypeLabel(row.type) }}</template></el-table-column>
+          <el-table-column label="变动"><template slot-scope="{ row }"><span :class="row.amount >= 0 ? 'pos' : 'neg'">{{ row.amount > 0 ? '+' : '' }}{{ row.amount }}</span></template></el-table-column>
+          <el-table-column prop="balanceAfter" label="变动后余额" width="110" />
+          <el-table-column prop="remark" label="备注" show-overflow-tooltip />
+          <el-table-column label="时间"><template slot-scope="{ row }">{{ formatTime(row.createdAt) }}</template></el-table-column>
+        </el-table>
+        <el-pagination class="pagination-right-mt12" @current-change="(p) => loadCfSummary(p)" :current-page="cfLogPage" :page-size="cfLogSize" :total="cfLogTotal" layout="total, prev, pager, next" />
+        <el-empty v-if="!tableLoading && cfLogs.length === 0" description="暂无流水" :image-size="60" />
+      </template>
+
+      <!-- 奖励 tab -->
+      <template v-if="activeTab === 'rewards'">
+        <div class="mb-12"><el-button type="primary" size="mini" @click="goRewards">去奖励管理</el-button></div>
+        <el-table :data="rewards" border class="full-width" max-height="420" v-loading="tableLoading">
+          <el-table-column prop="title" label="标题" show-overflow-tooltip />
+          <el-table-column label="状态"><template slot-scope="{ row }"><StatusTag :options="rewardStatusOptions" :value="row.status" /></template></el-table-column>
+          <el-table-column prop="points" label="积分" width="80" />
+          <el-table-column label="创建时间"><template slot-scope="{ row }">{{ formatTime(row.createdAt) }}</template></el-table-column>
+        </el-table>
+        <el-pagination class="pagination-right-mt12" @current-change="(p) => loadRewards(p)" :current-page="rewardPage" :page-size="rewardSize" :total="rewardTotal" layout="total, prev, pager, next" />
+        <el-empty v-if="!tableLoading && rewards.length === 0" description="暂无奖励" :image-size="60" />
+      </template>
+
+      <!-- 心愿 tab -->
+      <template v-if="activeTab === 'wishes'">
+        <div class="mb-12"><el-button type="primary" size="mini" @click="goWishes">去心愿管理</el-button></div>
+        <el-table :data="wishes" border class="full-width" max-height="420" v-loading="tableLoading">
+          <el-table-column prop="title" label="标题" show-overflow-tooltip />
+          <el-table-column label="状态"><template slot-scope="{ row }"><StatusTag :options="wishStatusOptions" :value="row.status" /></template></el-table-column>
+          <el-table-column prop="pointsRequired" label="所需积分" width="100" />
+          <el-table-column label="创建时间"><template slot-scope="{ row }">{{ formatTime(row.createdAt) }}</template></el-table-column>
+        </el-table>
+        <el-pagination class="pagination-right-mt12" @current-change="(p) => loadWishes(p)" :current-page="wishPage" :page-size="wishSize" :total="wishTotal" layout="total, prev, pager, next" />
+        <el-empty v-if="!tableLoading && wishes.length === 0" description="暂无心愿" :image-size="60" />
+      </template>
+<script>
+import * as echarts from 'echarts'
+import { getFamilyOverview, getFamilyTasks, getFamilyMembers, setMemberAdminRole, getFamilyCfSummary, getFamilyMessages, getRewardsList, getWishesList, getPendingWishes } from '@/api/admin'
+
+export default {
+  name: 'FamilyWorkbench',
+  data() {
+    return {
+      loading: false,
+      tableLoading: false,
+      familyId: null,
+      familyData: {},
+      memberCount: null,
+      activeTab: 'reports',
+      reports: [],
+      plans: [],
+      tasks: [],
+      taskPage: 1,
+      taskSize: 20,
+      taskTotal: 0,
+      cfMembers: [],
+      cfLogs: [],
+      cfLogPage: 1,
+      cfLogSize: 20,
+      cfLogTotal: 0,
+      rewards: [],
+      rewardPage: 1,
+      rewardSize: 20,
+      rewardTotal: 0,
+      wishes: [],
+      wishPage: 1,
+      wishSize: 20,
+      wishTotal: 0,
+      messages: [],
+      msgPage: 1,
+      msgSize: 10,
+      msgTotal: 0,
+      members: [],
+      reportChart: null,
+      cfChart: null
+    }
+  },
+  computed: {
+    familyName() {
+      return this.familyData.name || '家庭工作台'
+    },
+    cardList() {
+      return [
+        { key: 'members', label: '成员数', badge: null },
+        { key: 'reports', label: '报告数', badge: 'pending' },
+        { key: 'plans', label: '方案数', badge: 'pending_review' },
+        { key: 'tasks', label: '任务数', badge: 'in_progress' },
+        { key: 'cf', label: 'CF值合计' },
+        { key: 'rewards', label: '奖励' },
+        { key: 'wishes', label: '心愿' },
+        { key: 'messages', label: '咨询消息' }
+      ]
+    },
+    cardData() {
+      const c = {}
+      c.members = this.memberCount != null ? this.memberCount : 0
+      c.reports = this.reports.length
+      c.plans = this.plans.length
+      c.tasks = this.taskTotal
+      if (this.cfTotalAvailable != null) c.cf = this.cfTotalAvailable
+      else c.cf = 0
+      c.rewards = this.rewardTotal
+      c.wishes = this.wishTotal
+      c.messages = this.msgTotal
+      return c
+    },
+    cardBadge() {
+      const b = {}
+      // 待审核方案角标
+      b.plans = this.plans.filter(p => p.status === 'pending_review').length || null
+      // 待办进行中任务角标
+      b.tasks = this.tasks.filter(t => t.status === 'in_progress' || t.status === 'pending').length || null
+      return b
+    },
+    tabTitle() {
+      const map = {
+        reports: '报告明细', plans: '方案明细', tasks: '任务明细',
+        cf: 'CF值汇总', rewards: '奖励列表', wishes: '心愿列表',
+        messages: '咨询消息', members: '成员列表'
+      }
+      return map[this.activeTab] || ''
+    },
+    cfTotalAvailable() {
+      if (!this.cfMembers.length) return null
+      return this.cfMembers.reduce((s, m) => s + (m.available || 0), 0)
+    },
+    cfTotalFrozen() {
+      if (!this.cfMembers.length) return null
+      return this.cfMembers.reduce((s, m) => s + (m.frozen || 0), 0)
+    }
+  },
+  mounted() {
+    this.familyId = this.$route.query.familyId
+    if (!this.familyId) { this.$message.error('缺少 familyId'); return }
+    this.loadAll()
+  },
+  methods: {
+    async refresh() { await this.loadAll() },
+      <!-- 咨询消息 tab -->
+      <template v-if="activeTab === 'messages'">
+        <div class="mb-12"><el-button type="primary" size="mini" @click="goMessages">去消息管理</el-button></div>
+        <el-table :data="messages" border class="full-width" max-height="420" v-loading="tableLoading">
+          <el-table-column label="家长" width="120"><template slot-scope="{ row }">{{ row.parentName || row.parentId || '-' }}</template></el-table-column>
+          <el-table-column label="规划师" width="120"><template slot-scope="{ row }">{{ row.teacherName || row.teacherId || '-' }}</template></el-table-column>
+          <el-table-column prop="content" label="内容" show-overflow-tooltip />
+          <el-table-column label="状态" width="90"><template slot-scope="{ row }"><StatusTag :value="row.status" :options="messageStatusOptions" /></template></el-table-column>
+          <el-table-column label="时间" width="160"><template slot-scope="{ row }">{{ formatTime(row.createdAt) }}</template></el-table-column>
+        </el-table>
+        <el-pagination class="pagination-right-mt12" @current-change="(p) => loadMessages(p)" :current-page="msgPage" :page-size="msgSize" :total="msgTotal" layout="total, prev, pager, next" />
+        <el-empty v-if="!tableLoading && messages.length === 0" description="暂无咨询" :image-size="60" />
+      </template>
+
+      <!-- 成员 tab -->
+      <template v-if="activeTab === 'members'">
+        <el-table :data="members" border class="full-width" max-height="420" v-loading="tableLoading">
+          <el-table-column prop="nickname" label="昵称" width="150" />
+          <el-table-column prop="phone" label="手机号" width="150" />
+          <el-table-column prop="gender" label="性别" width="80"><template slot-scope="{ row }">{{ row.gender === 'male' ? '男' : row.gender === 'female' ? '女' : '-' }}</template></el-table-column>
+          <el-table-column prop="age" label="年龄" width="80"><template slot-scope="{ row }">{{ row.age || '-' }}</template></el-table-column>
+          <el-table-column prop="totalPoints" label="总积分" width="90"><template slot-scope="{ row }"><StatusTag type="success" :text="String(row.totalPoints || 0)" /></template></el-table-column>
+          <el-table-column label="角色" width="100"><template slot-scope="{ row }"><StatusTag size="small" :type="row.role === 'admin' ? 'danger' : 'info'" :text="row.role === 'admin' ? '管理员' : '成员'" /></template></el-table-column>
+          <el-table-column label="操作" width="140"><template slot-scope="{ row }"><el-button size="mini" :type="row.role !== 'admin' ? 'primary' : ''" @click="toggleAdmin(row)">{{ row.role === 'admin' ? '撤销管理员' : '设为管理员' }}</el-button></template></el-table-column>
+        </el-table>
+        <el-empty v-if="!tableLoading && members.length === 0" description="暂无成员" :image-size="60" />
+      </template>
+    </el-card>
+  </div>
+</template>
+    async loadAll() {
+      this.loading = true
+      try {
+        const [ov, membersRes] = await Promise.all([
+          getFamilyOverview(this.familyId),
+          getFamilyMembers(this.familyId)
+        ])
+        this.familyData = ov.data.family || {}
+        const members = (membersRes.data && membersRes.data.members) || []
+        this.members = members
+        this.memberCount = members.length
+        this.reports = (ov.data.reports) || []
+        this.plans = (ov.data.plans) || []
+        await Promise.all([
+          this.loadTasks(1),
+          this.loadCfSummary(1),
+          this.loadRewards(1),
+          this.loadWishes(1),
+          this.loadMessages(1)
+        ])
+        this.renderReportChart()
+        this.renderCfChart()
+      } catch (e) {
+        this.$message.error(e.message || '加载失败')
+      } finally {
+        this.loading = false
+      }
+    },
+    async loadTasks(page) {
+      this.taskPage = page
+      this.tableLoading = true
+      try {
+        const res = await getFamilyTasks(this.familyId, { page, size: this.taskSize })
+        this.tasks = res.data.records || []
+        this.taskTotal = res.data.total || 0
+      } catch (e) { this.$message.error(e.message || '加载任务失败') } finally { this.tableLoading = false }
+    },
+    async loadCfSummary(page) {
+      this.cfLogPage = page
+      this.tableLoading = true
+      try {
+        const res = await getFamilyCfSummary(this.familyId, { page, size: this.cfLogSize })
+        const data = res.data || {}
+        this.cfMembers = data.members || []
+        this.cfLogs = data.logs || []
+        this.cfLogTotal = data.logTotal != null ? data.logTotal : this.cfLogs.length
+      } catch (e) { this.$message.error(e.message || '加载CF汇总失败') } finally { this.tableLoading = false }
+    },
+    async loadRewards(page) {
+      this.rewardPage = page
+      this.tableLoading = true
+      try {
+        const res = await getRewardsList({ familyId: this.familyId, page, size: this.rewardSize })
+        this.rewards = res.data.records || []
+        this.rewardTotal = res.data.total || 0
+      } catch (e) { this.$message.error(e.message || '加载奖励失败') } finally { this.tableLoading = false }
+    },
+    async loadWishes(page) {
+      this.wishPage = page
+      this.tableLoading = true
+      try {
+        const res = await getWishesList({ familyId: this.familyId, page, size: this.wishSize })
+        this.wishes = res.data.records || []
+        this.wishTotal = res.data.total || 0
+        // 待处理心愿数(用于角标,可选)
+        try {
+          const p = await getPendingWishes(this.familyId)
+          this._pendingWishes = p.data
+        } catch (_) {}
+      } catch (e) { this.$message.error(e.message || '加载心愿失败') } finally { this.tableLoading = false }
+    },
+    async loadMessages(page) {
+      this.msgPage = page
+      this.tableLoading = true
+      try {
+        const res = await getFamilyMessages(this.familyId, { page, size: this.msgSize })
+        this.messages = res.data.records || []
+        this.msgTotal = res.data.total || 0
+      } catch (e) { this.$message.error(e.message || '加载消息失败') } finally { this.tableLoading = false }
+    },
+    cfLogTypeLabel(t) {
+      const map = { earn: '赚取', spend: '消费', freeze: '冻结', unfreeze: '解冻', withdraw: '提现', adjust: '调整', skip: '跳过' }
+      return map[t] || t || '-'
+    },
+    toggleAdmin(row) {
+      const targetIsAdmin = row.role !== 'admin'
+      setMemberAdminRole(this.familyId, row.id, targetIsAdmin).then(() => {
+        this.$message.success(targetIsAdmin ? '已设为管理员' : '已撤销管理员')
+        this.loadAll()
+      }).catch(e => { this.$message.error(e.message || '操作失败') })
+    },
+    goProxyReportUpload() { this.$router.push({ path: '/report-proxy-upload', query: { familyId: this.familyId } }) },
+    goPlanManage() { this.$router.push({ path: '/health-plan-manage', query: { familyId: this.familyId } }) },
+    goTasks() { this.$router.push({ path: '/tasks', query: { familyId: this.familyId } }) },
+    goRewards() { this.$router.push('/rewards') },
+    goWishes() { this.$router.push('/wishes') },
+    goMessages() { this.$router.push('/teacher-messages') },
+
+    renderReportChart() {
+      if (!this.$refs.reportChart) return
+      // 按 reportDate 聚合数量
+      const dateMap = {}
+      ;(this.reports || []).forEach(r => {
+        const d = r.reportDate || ''
+        dateMap[d] = (dateMap[d] || 0) + 1
+      })
+      const dates = Object.keys(dateMap).sort()
+      const values = dates.map(d => dateMap[d])
+      if (!this.reportChart) this.reportChart = echarts.init(this.$refs.reportChart)
+      this.reportChart.setOption({
+        title: { text: '报告趋势', left: 'center', textStyle: { fontSize: 13, color: '#666' } },
+        tooltip: { trigger: 'axis' },
+        grid: { left: 40, right: 20, top: 50, bottom: 30 },
+        xAxis: { type: 'category', data: dates, axisLabel: { rotate: 30, fontSize: 10 } },
+        yAxis: { type: 'value', minInterval: 1 },
+        series: [{ type: 'line', smooth: true, data: values, areaStyle: {}, itemStyle: { color: '#409eff' } }]
+      })
+    },
+    renderCfChart() {
+      if (!this.$refs.cfChart) return
+      const logs = (this.cfLogs || [])
+      if (!logs.length) {
+        if (this.cfChart) this.cfChart.clear()
+        return
+      }
+      const sorted = [...logs].sort((a, b) => {
+        const da = a.createdAt ? new Date(a.createdAt).getTime() : 0
+        const db = b.createdAt ? new Date(b.createdAt).getTime() : 0
+        return da - db
+      })
+      const timestamps = sorted.map(l => l.createdAt)
+      let cum = 0
+      const cumulative = sorted.map(l => { cum += (l.amount || 0); return cum })
+      if (!this.cfChart) this.cfChart = echarts.init(this.$refs.cfChart)
+      this.cfChart.setOption({
+        title: { text: 'CF 值变动趋势', left: 'center', textStyle: { fontSize: 13, color: '#666' } },
+        tooltip: { trigger: 'axis' },
+        grid: { left: 40, right: 20, top: 50, bottom: 30 },
+        xAxis: { type: 'category', data: timestamps, axisLabel: { rotate: 30, fontSize: 10, formatter: v => (v || '').slice(5, 10) } },
+        yAxis: { type: 'value' },
+        series: [{ type: 'line', smooth: true, data: cumulative, areaStyle: {}, itemStyle: { color: '#67c23a' } }]
+      })
+    },
+
+    formatTime(t) { return t ? t.replace('T', ' ').substring(0, 19) : '-' },
+    formatDate(d) { return d ? d.substring(0, 10) : '-' },
+
+    reportTypeOptions: [
+      { 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' }
+    ],
+    reportStatusOptions: [
+      { value: 'active', label: '生效', type: 'success' },
+      { value: 'archived', label: '已归档', type: 'warning' },
+      { value: 'deleted', label: '已删除', type: 'info' }
+    ],
+    planTypeOptions: [
+      { value: 'health_plan', label: '健康方案', type: 'primary' },
+      { value: 'family_attribution_plan', label: '家庭归因方案', type: 'warning' }
+    ],
+    planStatusOptions: [
+      { value: 'draft', label: '草稿', type: 'info' },
+      { value: 'pending_review', label: '待审核', type: 'warning' },
+      { value: 'published', label: '已发布', type: 'success' },
+      { value: 'rejected', label: '已驳回', type: 'danger' }
+    ],
+    taskStatusOptions: [
+      { value: 'pending', label: '进行中', type: 'warning' },
+      { value: 'in_progress', label: '进行中', type: 'warning' },
+      { value: 'completed', label: '已完成', type: 'success' },
+      { value: 'locked', label: '前置锁定', type: 'info' },
+      { value: 'assigned', label: '待接受', type: 'info' },
+      { value: 'rejected', label: '已拒绝', type: 'danger' },
+      { value: 'overdue', label: '已过期', type: 'danger' },
+      { value: 'cancelled', label: '已取消', type: 'info' }
+    ],
+    rewardStatusOptions: [
+      { value: 'pending', label: '待发放', type: 'warning' },
+      { value: 'sent', label: '已发放', type: 'success' },
+      { value: 'expired', label: '已过期', type: 'info' },
+      { value: 'rejected', label: '已驳回', type: 'danger' }
+    ],
+    wishStatusOptions: [
+      { value: 'pending', label: '待审批', type: 'warning' },
+      { value: 'approved', label: '已通过', type: 'success' },
+      { value: 'rejected', label: '已拒绝', type: 'danger' },
+      { value: 'fulfilled', label: '已兑换', type: 'primary' },
+      { value: 'cancelled', label: '已取消', type: 'info' }
+    ],
+    messageStatusOptions: [
+      { value: 'pending', label: '待回复', type: 'warning' },
+      { value: 'replied', label: '已回复', type: 'success' }
+    ]
+  }
+}
+</script>
+<style scoped>
+.page-container { padding: 24px; }
+.mb-12 { margin-bottom: 12px; }
+.mb-16 { margin-bottom: 16px; }
+.full-width { width: 100%; }
+.pagination-right-mt12 { margin-top: 12px; text-align: right; }
+
+/* 页头 */
+.admin-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; flex-shrink: 0; }
+.admin-page-title { font-size: 20px; font-weight: 600; line-height: 1.4; color: var(--adm-text-88); }
+.adm-sub { font-size: 12px; color: var(--adm-text-45); margin-left: 8px; }
+.adm-tag { display: inline-block; font-size: 11px; color: #409eff; background: var(--adm-info-bg); border: 1px solid var(--adm-info-border); border-radius: 4px; padding: 1px 6px; margin-left: 8px; }
+.adm-card-header { font-weight: 500; color: var(--adm-text-88); display: flex; align-items: center; justify-content: space-between; }
+.tab-action { font-size: 12px; color: var(--adm-text-45); }
+
+/* 统计卡片带 */
+.stat-cards { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; flex-shrink: 0; }
+.stat-card {
+  flex: 1 1 130px; min-width: 130px;
+  background: var(--color-bg-card); border: 1px solid var(--adm-border-split);
+  border-radius: var(--adm-radius-card); padding: 14px 16px; position: relative;
+}
+.stat-card--clickable { cursor: pointer; transition: border-color .15s, box-shadow .15s; }
+.stat-card--clickable:hover { border-color: var(--adm-primary); box-shadow: var(--adm-shadow-popup); }
+.stat-card.is-active { border-color: var(--adm-primary); background: var(--adm-info-bg); }
+.stat-label { font-size: 12px; color: var(--adm-text-45); margin-bottom: 8px; }
+.stat-value { font-size: 22px; font-weight: 600; color: var(--adm-text-88); }
+.badge { position: absolute; top: 8px; right: 8px; font-size: 11px; background: #f56c6c; color: #fff; border-radius: 10px; padding: 0 6px; line-height: 16px; }
+
+/* ECharts 图表 */
+.chart-wrap { width: 100%; height: 220px; }
+.chart-wrap--mt { margin-top: 16px; }
+
+/* CF 汇总 */
+.cf-summary { margin-bottom: 12px; }
+.cf-member-row { display: flex; align-items: center; gap: 16px; padding: 6px 0; border-bottom: 1px solid var(--adm-border-split); }
+.cf-member-row .name { flex: 1; font-size: 13px; color: var(--adm-text-88); }
+.cf-member-row .num { font-size: 13px; color: var(--adm-text-65); }
+.cf-member-row .num.frozen { color: #e6a23c; }
+.cf-member-row .num.earned { color: var(--adm-success); }
+.cf-total { font-size: 13px; color: var(--adm-text-88); padding: 8px 0; }
+.cf-logs-header { font-size: 13px; color: var(--adm-text-45); margin-bottom: 8px; }
+.pos { color: var(--adm-success); }
+.neg { color: #f56c6c; }
+</style>

+ 97 - 0
docs/design/2026-11-13-family-workbench-redesign.md

@@ -0,0 +1,97 @@
+# 家庭运营工作台 × 家庭工作台 重构方案(2026-11-13)
+
+> 状态:方案定稿,开发会话按此实现
+> 归属模块:cfc-web(管理端)为主,cfc-backend 少量只读查询端点补充
+> 分支:`feature/family-workbench-redesign`(当前 worktree `E:/tmp/family-workbench`,基线 origin/cfclub 29eba26d0)
+
+## 1. 目标与分类口径(唯一原则)
+
+| 层 | 页面 | 放什么 | 原则 |
+|---|---|---|---|
+| **家庭运营工作台** | 现有 `/families`(Families.vue,重构) | **所有家庭**相关的信息:全局运营统计、跨家庭管理模块快捷入口、家庭列表 | 运营视角/跨家庭 |
+| **家庭工作台** | 新增 `/family-workbench?familyId=`(FamilyWorkbench.vue,新建) | **只放与当前家庭有关**的信息:该家庭统计数字 + 指标曲线 + 各模块明细列表与操作 | 单家庭视角 |
+
+**分类处理的具体动作**:
+- 家庭列表每行操作**只保留【进入工作台】【编辑】**;其余功能(查看成员、报告明细、方案明细、任务明细、CF值、奖励、心愿、消息、优惠券、收益、配置)**全部移到家庭工作台**。
+- 家庭运营工作台"上半部分"(统计带 + 快捷入口)只保留**跨家庭/全局**内容;凡属单家庭的点击直达明细(如行内报告数/方案数/任务数/优惠券数链接、"更多"下拉)全部移除。
+
+## 2. 家庭运营工作台(Families.vue 重构)
+
+保留并整理:
+1. **页头**:标题/副标题「家庭管理 · 运营总览(跨家庭)」+ 刷新。
+2. **运营统计带(全局/跨家庭口径)**:家庭总数、成员总数、报告总数、方案总数、任务总数(保持现有"本页合计"的实现即可,标注清楚);**移除「家庭总收益 —」(无接口的占位)**。
+3. **快捷入口网格(跨家庭模块,全部保留)**:家庭列表、孩子管理、积分管理、积分记录、心愿管理、奖励管理、CF值管理(现有 quickEntries 缺 CF 值入口、可补 `/cf-value`)、收益提现审核、家庭收益、家庭优惠券、汇总方式配置。
+4. **家庭列表表格**:
+   - 移除:行内「上传报告数/方案数/任务数/优惠券数量」的点击链接(改为纯文本即可,保留列)、“启用状态”保留;
+   - 操作列:只保留【进入工作台】(type=primary,新跳转)与【编辑】;
+   - **删除「更多」下拉**与 `handleMoreCmd`、`openDetailDialog`、`goCouponManage`、`goProxyReportUpload`、`openPlanGen` 等单家庭明细逻辑;`getFamilyOverview`/`getFamilyTasks` 相关 import 若不再使用需一并清理;
+   - **保留**:多条件检索、批量导出、批量启停(占位)、成员数字链接→查看成员弹窗(可保留在列表,也可仅保留“进入工作台”后查看;建议保留成员数字链接点击查看成员弹窗,属于轻量查看,不影响分类原则)、分页、排序。
+5. **「进入工作台」跳转**:`this.$router.push({ path: '/family-workbench', query: { familyId: row.id } })`。
+
+## 3. 家庭工作台(新 FamilyWorkbench.vue,路由 `/family-workbench`)
+
+### 3.1 页面结构
+1. **页头**:返回列表按钮 + 家庭名称 + 邀请码 + 创建时间 + 成员数概览。
+2. **统计卡片带(可点击,点击切换下方 tab)**:
+   - 成员数(→ 成员 tab)
+   - 报告数(→ 报告 tab;角标:待解析/PENDING 数可选)
+   - 方案数(→ 方案 tab;角标:待审核 pending_review 数,**必做**,对应"方案审核")
+   - 任务数(→ 任务 tab;角标:待办进行中数,对应"创建任务")
+   - CF 值合计(→ CF值 tab;来自新接口 cf-summary)
+   - 奖励(→ 奖励 tab;getRewardsList familyId 过滤的 total)
+   - 心愿(→ 心愿 tab;getWishesList familyId 过滤的 total)
+   - 咨询消息(→ 消息 tab;新接口 messages 的 total)
+3. **指标曲线区(ECharts,`echarts@6.1.0` 已在依赖)**:
+   - 曲线一「报告趋势」:基于 overview.reports 按 reportDate 聚合数量(前端计算即可);无 ECharts 用 `import * as echarts from 'echarts'`。
+   - 曲线二「CF 值变动趋势」:基于新接口 cf-summary 返回的近 N 条日志(按 createdAt 聚合/累计);数据为空时展示空态提示。
+4. **tab 明细区(每个 tab 只展示该家庭数据 + 操作按钮)**:
+
+| tab | 列表数据源(已核实复用) | 操作/跳转 |
+|---|---|---|
+| 报告 | `getFamilyOverview(familyId).reports` 全量 | 顶部按钮「代传报告」→ `/report-proxy-upload?familyId=`(该页用下拉选家庭,保持现状即可) |
+| 方案 | `getFamilyOverview(familyId).plans` 全量(筛选活页内可过滤状态) | 顶部按钮「方案管理/审核」→ `/health-plan-manage`(带 familyId query 善后) |
+| 任务 | `getFamilyTasks(familyId, {page,size})` 分页 | 顶部按钮「创建任务」→ `/tasks`(该页 familyId 为行内筛选,跳转后运营人员可在任务首页筛选) |
+| CF值 | **新接口** `POST /api/admin/families/{id}/cf-summary` | 成员 CF 余额表 + 合计 + 最近日志 |
+| 奖励 | `getRewardsList({familyId, page, size})` 分页 | 「去奖励管理」→ `/rewards` |
+| 心愿 | `getWishesList({familyId, status, page, size})` 分页;待处理数用 `getPendingWishes(familyId)` | 「去心愿管理」→ `/wishes` |
+| 咨询消息 | **新接口** `POST /api/admin/families/{id}/messages` 分页 | 「去消息管理」→ `/admin/teacher-messages`(按现状路由名确认) |
+| 成员 | `getFamilyMembers(familyId)` | 沿用现有查看成员弹窗的表格(昵称/手机/性别/年龄/积分/角色)+ 设为管理员/撤销按钮(setMemberAdminRole) |
+
+### 3.2 后端新增(轻量只读,2 个端点,遵循 AGENTS.md 流程)
+> 新增前先查 `docs/superpowers/api/API_REFERENCE.md` 确认无重复;新增路由后用
+> `grep -rn '@Mapping' cfc-backend/src/.../controller/` 检查路由重复;不动 schema、不加迁移。
+
+1. `POST /api/admin/families/{id}/cf-summary`
+   - 入参:`page/size`(日志分页,默认 1/20)
+   - 返回结构建议:`{ familyId, members: [{memberId, name, available, frozen, totalEarned, ...}], totalAvailable, totalFrozen, logs: [{id, userId, userName, amount, balanceAfter, refType, remark, createdAt}] }`
+   - 实现要点:`buildOverview` 同款成员解析(family_members 关联 User);CF 余额走 `UserPlatformBalanceMapper`(按 userId in 成员 userIds);日志走 `PlatformBalanceLog` 按 userId in 查询 createdAt 倒序(若 mapper 已有按 userId 查询则逐人查再合并,量小可接受;需自行评估后端已有 service 能力,`PlatformPointsService` 已有 getBalance(userId)/getLogs(userId,page,size),优先复用,逐个成员调用合并)。
+   - 角色校验:`@RequestAttribute("role")` 必须 admin,与 `AdminFamilyOverviewController` 一致。
+2. `POST /api/admin/families/{id}/messages`
+   - 入参:`page/size`
+   - 返回:分页 `TeacherMessage`(按 familyId 过滤 + createdAt 倒序),参考 `TeacherMessageController.getMessages` 的补全昵称模式回填家长姓名/老师姓名(`TeacherMessageService` 已有 mapper 能力,改造或新写 service 方法,注意 `startTx`/事务无关、纯查询)。
+   - 角色校验:admin。
+
+### 3.3 前端新文件/改动
+- 新建 `cfc-web/src/views/FamilyWorkbench.vue`(组件复用:`StatusTag`、`SortMixin`/`ListStateMixin` 如需要、`formatDate`;样式沿用 admin 变量:`--adm-*`)。
+- 修改 `cfc-web/src/router/index.js`:注册 `/family-workbench`,`meta: { title: '家庭工作台', perm: 'family:list' }`(与家庭列表同权限;如存在 family:read 权限考虑之,取现有家庭列表权限即可)。**不追加 Layout 菜单项**(从家庭列表进入即可)。
+- 修改 `cfc-web/src/api/admin.js`:新增 `getFamilyCfSummary(id, params)` 与 `getFamilyMessages(id, params)`。
+- 修改 `cfc-web/src/views/Families.vue`(见 §2)。
+
+## 4. 实现纪律(必须遵守)
+
+1. **只在本 worktree `E:/tmp/family-workbench` 内工作**;严禁对 `E:/cfc` 主工作树执行任何 git 写操作(禁 `reset --hard`/`checkout -- .`/`clean`/`stash`/`add -A`)。
+2. git 操作只在这条特性分支:`git add` 精确到目标文件 → `git commit`(消息含 `feat(family-workbench)` 前缀)→ `git push -u origin feature/family-workbench-redesign`。CRLF/LF:本项目文件大概率 LF,不要批量改写行尾,避免假 diff。
+3. 提交前:`git diff --cached --name-only | wc -l` 确认变更数合理(预期 ≤ 8 个文件);`git status --porcelain` 除目标文件外无其他改动。
+4. 后端改动必须 `mvn clean compile`(cfc-backend 模块,Java 8/Spring Boot 2.7);前端改动 `cd cfc-web && npm run lint`(或项目现有 lint 脚本,见 cfc-web/package.json scripts)与 build 不影响其他模块。若 build 耗时过长,至少保证语法/编译级验证(vue-cli-service lint 优先)。
+5. 完成 = 已 `git push` 远端 + worktree 干净(除未跟踪文件外) + 编译/lint 通过;完成后在会话内逐条汇报:改了哪几个文件、每个文件改了什么、后端编译是否通过、前端 lint/build 结果、push 的 commit sha。
+
+## 5. 验收清单
+
+- [ ] `/families` 操作列只有【进入工作台】【编辑】,无「更多」下拉、无行内明细链接
+- [ ] 统计带移除「家庭总收益 —」占位;快捷入口完整(含 CF 值管理)
+- [ ] `/family-workbench?familyId=N` 可打开,统计卡片数字真实(来自 overview/各列表接口),点击切 tab
+- [ ] 报告 tab 有「代传报告」按钮;方案 tab 有待审核角标与审核入口;任务 tab 有「创建任务」入口
+- [ ] CF值 tab 显示成员余额+合计+趋势曲线;消息 tab 显示该家庭咨询
+- [ ] 指标曲线至少渲染「报告趋势」(有数据时),CF 趋势有数据渲染、无数据显示空态
+- [ ] 后端 2 端点 mvn clean compile 通过;前端 lint 通过
+- [ ] commit 已 push 到 `feature/family-workbench-redesign`