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

feat: teacher dashboard + activity admin API enhancements

Sisyphus 2 месяцев назад
Родитель
Сommit
d961501b7e

+ 26 - 0
cfc-backend/config/application-dev.yml

@@ -0,0 +1,26 @@
+# 开发环境配置
+# 使用本地 MySQL 127.0.0.1:3306/zxyj,账号 root/cfc123
+spring:
+  datasource:
+    driver-class-name: com.mysql.cj.jdbc.Driver
+    url: jdbc:mysql://127.0.0.1:3306/zxyj?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true
+    username: root
+    password: cfc123
+
+sfms:
+  datasource:
+    enabled: false
+    driver-class-name: com.mysql.cj.jdbc.Driver
+    url: jdbc:mysql://127.0.0.1:3306/ax?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true
+    username: zxyj
+    password: zxyj@123
+
+logging:
+  level:
+    com.etotem.cfc: debug
+
+admin:
+  skip-captcha: true
+
+wechat:
+  test-mode: true

+ 26 - 0
cfc-backend/config/application-prod.yml

@@ -0,0 +1,26 @@
+# 生产环境配置
+# 使用 251 服务器 MySQL,账号 zxyj/zxyj@123
+spring:
+  datasource:
+    driver-class-name: com.mysql.cj.jdbc.Driver
+    url: jdbc:mysql://192.168.16.251:3306/zxyj?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true
+    username: zxyj
+    password: zxyj@123
+
+sfms:
+  datasource:
+    enabled: false
+    driver-class-name: com.mysql.cj.jdbc.Driver
+    url: jdbc:mysql://bianwoyou.mysql.rds.aliyuncs.com:3305/sfms?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true
+    username: zxyj
+    password: zxyj@123
+
+logging:
+  level:
+    com.etotem.cfc: warn
+
+admin:
+  skip-captcha: false
+
+wechat:
+  test-mode: false

+ 26 - 0
cfc-backend/config/application-test.yml

@@ -0,0 +1,26 @@
+# 测试环境配置
+# 使用 251 服务器 MySQL,账号 zxyj/zxyj@123
+spring:
+  datasource:
+    driver-class-name: com.mysql.cj.jdbc.Driver
+    url: jdbc:mysql://192.168.16.251:3306/zxyj?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true
+    username: zxyj
+    password: zxyj@123
+
+sfms:
+  datasource:
+    enabled: false
+    driver-class-name: com.mysql.cj.jdbc.Driver
+    url: jdbc:mysql://192.168.16.251:3306/ax?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true
+    username: zxyj
+    password: zxyj@123
+
+logging:
+  level:
+    com.etotem.cfc: info
+
+admin:
+  skip-captcha: true
+
+wechat:
+  test-mode: true

+ 13 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminActivityController.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.controller.admin;
 
 import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.Activity;
 import com.etotem.cfc.service.ActivityAdminService;
 import org.springframework.web.bind.annotation.*;
 
@@ -36,4 +37,16 @@ public class AdminActivityController {
     public Result<String> review(@RequestBody Map<String, Object> params) {
         return activityAdminService.review(params);
     }
+
+    /**
+     * 获取活动详情
+     */
+    @PostMapping("/detail")
+    public Result<Activity> detail(@RequestBody Map<String, Object> params) {
+        Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
+        if (id == null) return Result.error("id不能为空");
+        Activity activity = activityAdminService.getDetail(id);
+        if (activity == null) return Result.error("活动不存在");
+        return Result.success(activity);
+    }
 }

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ActivityAdminService.java

@@ -50,6 +50,13 @@ public class ActivityAdminService {
         return Result.success(data);
     }
 
+    /**
+     * 获取活动详情
+     */
+    public Activity getDetail(Long id) {
+        return activityMapper.selectById(id);
+    }
+
     /**
      * 审核活动
      * @param params action: "publish" | "reject", id: Long

+ 63 - 0
cfc-backend/startup.sh

@@ -0,0 +1,63 @@
+#!/bin/bash
+#
+# cfc-backend 启动脚本
+# 用法: ./startup.sh [dev|test|prod]
+#   dev   - 本地开发环境,使用 127.0.0.1 MySQL
+#   test  - 251 测试环境,使用 192.168.16.251 MySQL
+#   prod  - 251 生产环境,使用 192.168.16.251 MySQL
+#
+# 外部配置文件优先级高于 JAR 内置配置,
+# 通过 --spring.config.location 显式加载 config/ 目录下的环境配置。
+#
+
+set -e
+
+# ===== 配置 =====
+PROFILE="${1:-prod}"
+JAR_NAME="cfc-backend-1.0.0.jar"
+CONFIG_DIR="$(cd "$(dirname "$0")" && pwd)/config"
+
+# JAR 路径:与 startup.sh 同目录
+APP_DIR="$(cd "$(dirname "$0")" && pwd)"
+JAR_FILE="$APP_DIR/$JAR_NAME"
+
+# 日志文件
+LOG_FILE="$APP_DIR/logs/cfc-backend.log"
+
+# ===== 验证 =====
+if [[ ! -f "$JAR_FILE" ]]; then
+    echo "ERROR: JAR 文件不存在: $JAR_FILE"
+    exit 1
+fi
+
+if [[ ! -d "$CONFIG_DIR" ]]; then
+    echo "ERROR: 配置目录不存在: $CONFIG_DIR"
+    exit 1
+fi
+
+CONFIG_FILE="$CONFIG_DIR/application-$PROFILE.yml"
+if [[ ! -f "$CONFIG_FILE" ]]; then
+    echo "ERROR: 配置文件不存在: $CONFIG_FILE"
+    echo "可用环境: dev | test | prod"
+    exit 1
+fi
+
+# ===== 日志目录 =====
+mkdir -p "$APP_DIR/logs"
+
+# ===== 启动 =====
+echo "========================================"
+echo "  CFC Backend 启动"
+echo "  环境: $PROFILE"
+echo "  配置文件: $CONFIG_FILE"
+echo "  JAR: $JAR_FILE"
+echo "  日志: $LOG_FILE"
+echo "========================================"
+
+# spring.config.location 末尾的 / 非常重要:表示作为目录搜索,
+# Spring Boot 会同时加载 application.yml 和 application-{profile}.yml。
+# 外部文件覆盖 JAR 内置文件,相同属性以外部文件为准。
+exec java -jar "$JAR_FILE" \
+    --spring.profiles.active="$PROFILE" \
+    --spring.config.location="$CONFIG_DIR/" \
+    >> "$LOG_FILE" 2>&1

+ 5 - 5
cfc-web/src/components/DimensionWeightPicker.vue

@@ -55,11 +55,11 @@ export default {
   data() {
     return {
       dimensions: [
-        { code: 'body', name: '身·土', color: '#FF8C42', currentValue: 0 },
-        { code: 'mind', name: '智·金', color: '#6366F1', currentValue: 0 },
-        { code: 'action', name: '行·木', color: '#10B981', currentValue: 0 },
-        { code: 'wealth', name: '富·水', color: '#F59E0B', currentValue: 0 },
-        { code: 'heart', name: '心·火', color: '#FF6B9D', currentValue: 0 }
+        { code: 'body', name: '身·土', color: '#FF8C42', currentValue: 20 },
+        { code: 'mind', name: '智·金', color: '#6366F1', currentValue: 20 },
+        { code: 'action', name: '行·木', color: '#10B981', currentValue: 20 },
+        { code: 'wealth', name: '富·水', color: '#F59E0B', currentValue: 20 },
+        { code: 'heart', name: '心·火', color: '#FF6B9D', currentValue: 20 }
       ],
       presets: [
         { label: '均衡', weights: { body: 20, mind: 20, action: 20, wealth: 20, heart: 20 } },

+ 3 - 14
cfc-web/src/router/index.js

@@ -487,7 +487,7 @@ router.beforeEach(async (to, from, next) => {
   if (to.path === '/login') {
     // 如果已有token和role,直接跳转到对应首页
     if (token && role === 'teacher') {
-      return next('/teacher-dashboard')
+      return next('/dashboard')
     }
     return next()
   }
@@ -514,18 +514,7 @@ router.beforeEach(async (to, from, next) => {
     return next('/login')
   }
 
-  // 4. 登录后首页重定向(多角色)
-  const roleHomeMap = {
-    'teacher': '/teacher-dashboard',
-    'nutritionist': '/dashboard',
-    'activity_admin': '/dashboard',
-    'article_admin': '/dashboard'
-  }
-  if (to.path === '/dashboard' && roleHomeMap[role]) {
-    return next({ path: roleHomeMap[role], replace: true })
-  }
-
-  // 5. 权限检查:如果路由有 perm 要求,检查用户权限
+  // 4. 权限检查:如果路由有 perm 要求,检查用户权限
   const requiredPerm = to.meta && to.meta.perm
   if (requiredPerm) {
     const rolesStr = localStorage.getItem('roles')
@@ -533,7 +522,7 @@ router.beforeEach(async (to, from, next) => {
     const perms = getEffectivePermissions(roles)
     if (!hasPermission(perms, requiredPerm)) {
       console.warn('Access denied: insufficient permissions for', to.name)
-      return next({ path: roleHomeMap[role] || '/dashboard', query: { accessDenied: 'no-permission' }, replace: true })
+      return next({ path: '/', query: { accessDenied: 'no-permission' }, replace: true })
     }
   }
   return next()

+ 335 - 3
cfc-web/src/views/Dashboard.vue

@@ -1,5 +1,6 @@
 <template>
-  <div class="dashboard">
+  <div class="dashboard" :class="{ 'teacher-mode': isTeacher }">
+    <div v-if="!isTeacher" class="admin-dashboard">
     <!-- 欢迎区域 -->
     <div class="welcome-banner">
       <div class="welcome-content">
@@ -178,11 +179,227 @@
         </el-card>
       </el-col>
     </el-row>
+    </div>
+
+  <div v-else class="teacher-dashboard-wrapper">
+    <!-- 统计卡片 -->
+    <el-row :gutter="24" class="td-stat-row">
+      <el-col :span="6">
+        <div class="td-stat-card">
+          <div class="td-stat-icon" style="background: linear-gradient(135deg, #3B82F6 0%, #60A5FA 100%)">
+            <i class="el-icon-user-solid"></i>
+          </div>
+          <div class="td-stat-content">
+            <div class="td-stat-value">{{ teacherStats.serviceFamilyCount }}</div>
+            <div class="td-stat-label">服务家庭</div>
+          </div>
+        </div>
+      </el-col>
+      <el-col :span="6">
+        <div class="td-stat-card">
+          <div class="td-stat-icon" style="background: linear-gradient(135deg, #10B981 0%, #34D399 100%)">
+            <i class="el-icon-s-data"></i>
+          </div>
+          <div class="td-stat-content">
+            <div class="td-stat-value">{{ teacherStats.activeFamilyCount }}</div>
+            <div class="td-stat-label">活跃家庭</div>
+          </div>
+        </div>
+      </el-col>
+      <el-col :span="6">
+        <div class="td-stat-card">
+          <div class="td-stat-icon" style="background: linear-gradient(135deg, #F59E0B 0%, #FBBF24 100%)">
+            <i class="el-icon-coin"></i>
+          </div>
+          <div class="td-stat-content">
+            <div class="td-stat-value">{{ formatPriceWithSymbol(teacherStats.monthlySales) }}</div>
+            <div class="td-stat-label">本月销售额</div>
+          </div>
+        </div>
+      </el-col>
+      <el-col :span="6">
+        <div class="td-stat-card">
+          <div class="td-stat-icon" style="background: linear-gradient(135deg, #8B5CF6 0%, #A78BFA 100%)">
+            <i class="el-icon-wallet"></i>
+          </div>
+          <div class="td-stat-content">
+            <div class="td-stat-value">{{ formatPriceWithSymbol(teacherStats.myCommission) }}</div>
+            <div class="td-stat-label">我的佣金</div>
+          </div>
+        </div>
+      </el-col>
+    </el-row>
+
+    <!-- 快捷操作 + 今日概览 -->
+    <el-row :gutter="24" class="td-section-row">
+      <el-col :span="8">
+        <el-card class="td-quick-card" shadow="hover">
+          <div slot="header" class="td-card-header">
+            <span class="td-card-title">快捷操作</span>
+          </div>
+          <div class="td-quick-actions">
+            <div class="td-action-item" @click="$router.push('/teacher-families')">
+              <div class="td-action-icon" style="background: #EBF5FF">
+                <i class="el-icon-user" style="color: #3B82F6"></i>
+              </div>
+              <span>管理家庭</span>
+            </div>
+            <div class="td-action-item" @click="$router.push('/teacher-messages')">
+              <div class="td-action-icon" style="background: #ECFDF5">
+                <i class="el-icon-chat-line-round" style="color: #10B981"></i>
+              </div>
+              <span>消息中心</span>
+            </div>
+            <div class="td-action-item" @click="$router.push('/teacher-orders')">
+              <div class="td-action-icon" style="background: #FFFBEB">
+                <i class="el-icon-document" style="color: #F59E0B"></i>
+              </div>
+              <span>查看订单</span>
+            </div>
+            <div class="td-action-item" @click="$router.push('/teacher-assessment')">
+              <div class="td-action-icon" style="background: #F5F3FF">
+                <i class="el-icon-edit-outline" style="color: #8B5CF6"></i>
+              </div>
+              <span>DAN测评</span>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="16">
+        <el-card class="td-overview-card" shadow="hover">
+          <div slot="header" class="td-card-header">
+            <span class="td-card-title">今日概览</span>
+            <span class="td-card-date">{{ todayDate }}</span>
+          </div>
+          <div class="td-today-stats">
+            <div class="td-today-stat-item">
+              <div class="td-today-stat-value" style="color: #3B82F6">{{ todayStats.newFamilies }}</div>
+              <div class="td-today-stat-label">新增家庭</div>
+            </div>
+            <div class="td-today-stat-item">
+              <div class="td-today-stat-value" style="color: #10B981">{{ todayStats.completedTasks }}</div>
+              <div class="td-today-stat-label">完成任务</div>
+            </div>
+            <div class="td-today-stat-item">
+              <div class="td-today-stat-value" style="color: #F59E0B">{{ todayStats.newOrders }}</div>
+              <div class="td-today-stat-label">新订单</div>
+            </div>
+            <div class="td-today-stat-item">
+              <div class="td-today-stat-value" style="color: #8B5CF6">{{ todayStats.pendingReviews }}</div>
+              <div class="td-today-stat-label">待审核</div>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <!-- 我的家庭 + 我的团队 -->
+    <el-row :gutter="24" class="td-section-row">
+      <el-col :span="12">
+        <el-card class="td-list-card" shadow="hover">
+          <div slot="header" class="td-card-header">
+            <span class="td-card-title">我的家庭</span>
+            <el-button type="text" @click="$router.push('/teacher-families')">查看全部 <i class="el-icon-arrow-right"></i></el-button>
+          </div>
+          <div class="td-family-list">
+            <div v-for="family in families" :key="family.id" class="td-family-item">
+              <div class="td-family-avatar">
+                <i class="el-icon-house"></i>
+              </div>
+              <div class="td-family-info">
+                <div class="td-family-name">{{ family.familyName || '家庭' + family.familyId }}</div>
+                <div class="td-family-meta">
+                  <span class="td-meta-tag"><i class="el-icon-user"></i> {{ family.childrenCount || 0 }}个孩子</span>
+                </div>
+              </div>
+              <div class="td-family-stats">
+                <el-progress type="circle" :percentage="family.taskCompletionRate || 0" :width="50" :stroke-width="4"></el-progress>
+              </div>
+            </div>
+            <div v-if="families.length === 0" class="td-empty-state">
+              <i class="el-icon-folder-delete"></i>
+              <p>暂无服务家庭</p>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="12">
+        <el-card class="td-list-card" shadow="hover">
+          <div slot="header" class="td-card-header">
+            <span class="td-card-title">我的团队</span>
+            <el-button type="text" @click="$router.push('/teacher-team')">查看全部 <i class="el-icon-arrow-right"></i></el-button>
+          </div>
+          <div class="td-team-list">
+            <div v-for="member in teamMembers" :key="member.id" class="td-team-item">
+              <div class="td-member-avatar">
+                <i class="el-icon-user"></i>
+              </div>
+              <div class="td-member-info">
+                <div class="td-member-name">{{ member.nickname || '规划师' + member.id }}</div>
+                <div class="td-member-level">
+                  <el-tag size="mini" :type="getLevelType(member.level)" effect="dark">{{ member.level || '初级' }}</el-tag>
+                </div>
+              </div>
+              <div class="td-member-performance">
+                <div class="td-performance-value">{{ formatPriceWithSymbol(member.monthlySales || 0) }}</div>
+                <div class="td-performance-label">本月业绩</div>
+              </div>
+            </div>
+            <div v-if="teamMembers.length === 0" class="td-empty-state">
+              <i class="el-icon-user-solid"></i>
+              <p>暂无团队成员</p>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <!-- 最近订单 -->
+    <el-row :gutter="24" class="td-section-row">
+      <el-col :span="24">
+        <el-card class="td-orders-card" shadow="hover">
+          <div slot="header" class="td-card-header">
+            <span class="td-card-title">最近订单</span>
+            <el-button type="text" @click="$router.push('/teacher-orders')">查看全部 <i class="el-icon-arrow-right"></i></el-button>
+          </div>
+          <el-table :data="recentOrders" stripe style="width: 100%" class="td-orders-table">
+            <el-table-column prop="orderNo" label="订单号" width="200">
+              <template slot-scope="scope">
+                <span class="td-order-no">{{ scope.row.orderNo }}</span>
+              </template>
+            </el-table-column>
+            <el-table-column prop="familyName" label="家庭">
+              <template slot-scope="scope">
+                <div class="td-family-cell"><i class="el-icon-house"></i> {{ scope.row.familyName }}</div>
+              </template>
+            </el-table-column>
+            <el-table-column prop="packageName" label="套餐">
+              <template slot-scope="scope">
+                <el-tag size="small" effect="plain">{{ scope.row.packageName }}</el-tag>
+              </template>
+            </el-table-column>
+            <el-table-column prop="amount" label="金额" width="120">
+              <template slot-scope="scope">
+                <span class="td-amount-value">{{ formatPriceWithSymbol(scope.row.amount) }}</span>
+              </template>
+            </el-table-column>
+            <el-table-column prop="status" label="状态" width="100">
+              <template slot-scope="scope">
+                <el-tag :type="getStatusType(scope.row.status)" size="small" effect="dark">{{ scope.row.status }}</el-tag>
+              </template>
+            </el-table-column>
+            <el-table-column prop="createdAt" label="时间" width="160"></el-table-column>
+          </el-table>
+        </el-card>
+      </el-col>
+    </el-row>
+  </div>
   </div>
 </template>
 
 <script>
 import { getDashboardStats } from '@/api/admin'
+import { getBoundFamilies, getTeamMembers, getGuideOrders, getGuideDashboardStats } from '@/api/guide'
 
 export default {
   name: 'Dashboard',
@@ -197,10 +414,28 @@ export default {
         pendingGuideCount: 0,
         pendingPackageCount: 0
       },
-      recentTasks: []
+      recentTasks: [],
+      teacherStats: {
+        serviceFamilyCount: 0,
+        activeFamilyCount: 0,
+        monthlySales: 0,
+        myCommission: 0
+      },
+      families: [],
+      teamMembers: [],
+      recentOrders: [],
+      todayStats: {
+        newFamilies: 0,
+        completedTasks: 0,
+        newOrders: 0,
+        pendingReviews: 0
+      }
     }
   },
   computed: {
+    isTeacher() {
+      return localStorage.getItem('role') === 'teacher'
+    },
     todayDate() {
       const now = new Date()
       const months = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '十一', '十二']
@@ -208,7 +443,11 @@ export default {
     }
   },
   mounted() {
-    this.loadDashboardData()
+    if (this.isTeacher) {
+      this.loadTeacherData()
+    } else {
+      this.loadDashboardData()
+    }
   },
   methods: {
     async loadDashboardData() {
@@ -230,6 +469,54 @@ export default {
       } catch (error) {
         console.error('加载仪表盘数据失败:', error)
       }
+    },
+    async loadTeacherData() {
+      try {
+        const results = await Promise.all([
+          getBoundFamilies().catch(err => { console.error('获取家庭列表失败:', err); return { data: [] } }),
+          getTeamMembers().catch(err => { console.error('获取团队成员失败:', err); return { data: [] } }),
+          getGuideOrders().catch(err => { console.error('获取订单失败:', err); return { data: { records: [] } } }),
+          getGuideDashboardStats().catch(err => { console.error('获取统计数据失败:', err); return { data: {} } })
+        ])
+        const [familyRes, teamRes, orderRes, statsRes] = results
+        if (familyRes.data) {
+          this.families = familyRes.data.slice(0, 5)
+          this.teacherStats.serviceFamilyCount = familyRes.data.length
+          this.teacherStats.activeFamilyCount = familyRes.data.filter(f => f.status === 'active').length
+        }
+        if (teamRes.data) {
+          this.teamMembers = teamRes.data.slice(0, 5).map(m => ({ ...m, trend: 0 }))
+        }
+        if (orderRes.data && orderRes.data.records) {
+          this.recentOrders = orderRes.data.records.slice(0, 10)
+          const totalSales = orderRes.data.records.reduce((sum, o) => sum + (o.amount || 0), 0)
+          this.teacherStats.monthlySales = totalSales
+          this.teacherStats.myCommission = Math.round(totalSales * 0.1)
+          this.todayStats.newOrders = orderRes.data.records.filter(o => {
+            const today = new Date().toDateString()
+            return new Date(o.createdAt).toDateString() === today
+          }).length
+        }
+        if (statsRes.data) {
+          this.todayStats.newFamilies = statsRes.data.totalFamilies || 0
+          this.todayStats.completedTasks = statsRes.data.todayCompletedTasks || 0
+          this.todayStats.pendingReviews = statsRes.data.pendingReviewTasks || 0
+        }
+      } catch (e) {
+        console.error('加载教师工作台数据失败:', e)
+      }
+    },
+    formatPriceWithSymbol(price) {
+      if (price === null || price === undefined) return '¥0'
+      return '¥' + Number(price).toLocaleString()
+    },
+    getLevelType(level) {
+      const map = { '初级': '', '中级': 'success', '高级': 'warning' }
+      return map[level] || ''
+    },
+    getStatusType(status) {
+      const map = { '已完成': 'success', '待支付': 'warning', '已取消': 'info' }
+      return map[status] || 'info'
     }
   }
 }
@@ -459,4 +746,49 @@ export default {
   font-size: 12px;
   color: #64748B;
 }
+
+/* ===== Teacher Dashboard Styles ===== */
+.teacher-dashboard-wrapper { padding: 0; }
+.td-stat-row { margin-bottom: 24px; }
+.td-stat-card { display: flex; align-items: center; padding: 20px 24px; background: #FFFFFF; border-radius: 16px; box-shadow: 0 2px 12px rgba(59,130,246,0.08); border: none; transition: all 0.3s; }
+.td-stat-card:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(59,130,246,0.12); }
+.td-stat-icon { width: 56px; height: 56px; border-radius: 14px; display: flex; align-items: center; justify-content: center; margin-right: 16px; font-size: 24px; color: #FFFFFF; flex-shrink: 0; }
+.td-stat-value { font-size: 28px; font-weight: 700; color: #1E293B; line-height: 1.2; }
+.td-stat-label { font-size: 14px; color: #64748B; margin-top: 4px; }
+.td-section-row { margin-bottom: 24px; }
+.td-card-header { display: flex; justify-content: space-between; align-items: center; }
+.td-card-title { font-size: 16px; font-weight: 600; color: #1E293B; }
+.td-card-date { font-size: 13px; color: #64748B; }
+.td-quick-card, .td-overview-card, .td-list-card, .td-orders-card { border-radius: 16px; border: none; box-shadow: 0 2px 12px rgba(59,130,246,0.06); }
+.td-quick-card >>> .el-card__header, .td-overview-card >>> .el-card__header, .td-list-card >>> .el-card__header, .td-orders-card >>> .el-card__header { border-bottom: 1px solid #F1F5F9; padding: 16px 20px; }
+.td-quick-actions { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
+.td-action-item { display: flex; flex-direction: column; align-items: center; padding: 16px; background: #F8FAFC; border-radius: 12px; cursor: pointer; transition: all 0.2s; }
+.td-action-item:hover { background: #F1F5F9; transform: translateY(-2px); }
+.td-action-icon { width: 44px; height: 44px; border-radius: 12px; display: flex; align-items: center; justify-content: center; margin-bottom: 8px; font-size: 20px; }
+.td-action-item span { font-size: 13px; color: #64748B; }
+.td-today-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
+.td-today-stat-item { text-align: center; padding: 12px; background: #F8FAFC; border-radius: 12px; }
+.td-today-stat-value { font-size: 28px; font-weight: 700; line-height: 1.2; }
+.td-today-stat-label { font-size: 13px; color: #64748B; margin-top: 4px; }
+.td-family-list, .td-team-list { max-height: 400px; overflow-y: auto; }
+.td-family-item, .td-team-item { display: flex; align-items: center; padding: 12px 0; border-bottom: 1px solid #F1F5F9; }
+.td-family-item:last-child, .td-team-item:last-child { border-bottom: none; }
+.td-family-avatar, .td-member-avatar { width: 40px; height: 40px; border-radius: 10px; display: flex; align-items: center; justify-content: center; background: #EBF5FF; color: #3B82F6; font-size: 20px; margin-right: 12px; flex-shrink: 0; }
+.td-family-info, .td-member-info { flex: 1; min-width: 0; }
+.td-family-name, .td-member-name { font-size: 14px; font-weight: 600; color: #1E293B; margin-bottom: 4px; }
+.td-family-meta { display: flex; gap: 12px; }
+.td-meta-tag { font-size: 12px; color: #64748B; }
+.td-meta-tag i { margin-right: 2px; }
+.td-family-stats { text-align: center; margin-left: 12px; }
+.td-member-level { margin-top: 2px; }
+.td-member-performance { text-align: right; margin-left: 12px; }
+.td-performance-value { font-size: 14px; font-weight: 600; color: #1E293B; }
+.td-performance-label { font-size: 11px; color: #64748B; margin-top: 2px; }
+.td-empty-state { text-align: center; padding: 40px 0; color: #94A3B8; }
+.td-empty-state i { font-size: 40px; margin-bottom: 8px; }
+.td-empty-state p { font-size: 14px; margin: 0; }
+.td-orders-table >>> .el-table__header th { background: #F8FAFC; color: #64748B; font-weight: 600; }
+.td-order-no { color: #3B82F6; font-weight: 500; }
+.td-family-cell i { margin-right: 4px; color: #64748B; }
+.td-amount-value { font-weight: 600; color: #1E293B; }
 </style>

+ 26 - 12
cfc-web/src/views/Layout.vue

@@ -11,26 +11,40 @@
           active-text-color="#6366F1"
           @select="handleMenuSelect"
         >
-          <template v-for="item in menuItems">
-            <el-submenu v-if="item.children && hasPerm(item.perm)" :key="item.title" :index="item.title">
+<template v-for="item in menuItems">
+            <el-submenu v-if="item.children && hasPerm(item.perm)" :key="item.title || item.path" :index="item.title || item.path">
               <template slot="title">
                 <i :class="item.icon"></i>
                 <span>{{ item.label || item.title }}</span>
               </template>
-              <el-menu-item
-                v-for="child in item.children"
-                :key="child.path"
-                :index="child.path"
-              >
-                <i :class="child.icon"></i>
-                <span slot="title">{{ child.label }}</span>
-              </el-menu-item>
+              <template v-for="child in item.children">
+                <!-- 子项有 children = 三级菜单 -->
+                <el-submenu v-if="child.children && hasPerm(child.perm)" :key="child.title" :index="child.title">
+                  <template slot="title">
+                    <i :class="child.icon"></i>
+                    <span>{{ child.title }}</span>
+                  </template>
+                  <el-menu-item
+                    v-for="grandson in child.children"
+                    :key="grandson.path"
+                    :index="grandson.path"
+                  >
+                    <i :class="grandson.icon"></i>
+                    <span slot="title">{{ grandson.label }}</span>
+                  </el-menu-item>
+                </el-submenu>
+                <!-- 普通二级菜单项 -->
+                <el-menu-item v-else-if="hasPerm(child.perm)" :key="child.path" :index="child.path">
+                  <i :class="child.icon"></i>
+                  <span slot="title">{{ child.label }}</span>
+                </el-menu-item>
+              </template>
             </el-submenu>
             <el-menu-item v-else-if="hasPerm(item.perm)" :key="item.path" :index="item.path">
               <i :class="item.icon"></i>
               <span slot="title">{{ item.label }}</span>
-          </el-menu-item>
-        </template>
+            </el-menu-item>
+          </template>
         </el-menu>
       </el-scrollbar>
     </el-aside>

+ 6 - 5
cfc-web/src/views/teacher/TeacherDashboard.vue

@@ -277,12 +277,13 @@ export default {
   methods: {
     async loadData() {
       try {
-        const [familyRes, teamRes, orderRes, statsRes] = await Promise.all([
-          getBoundFamilies(),
-          getTeamMembers(),
-          getGuideOrders(),
-          getGuideDashboardStats()
+        const results = await Promise.all([
+          getBoundFamilies().catch(err => { console.error('获取家庭列表失败:', err); return { data: [] } }),
+          getTeamMembers().catch(err => { console.error('获取团队成员失败:', err); return { data: [] } }),
+          getGuideOrders().catch(err => { console.error('获取订单失败:', err); return { data: { records: [] } } }),
+          getGuideDashboardStats().catch(err => { console.error('获取统计数据失败:', err); return { data: {} } })
         ])
+        const [familyRes, teamRes, orderRes, statsRes] = results
         if (familyRes.data) {
           this.families = familyRes.data.slice(0, 5)
           this.stats.serviceFamilyCount = familyRes.data.length