Explorar o código

feat(cfc-web): split admin/teacher dashboards, enrich admin with 8 stat cards, ECharts charts, system health panel, 8 quick actions

- Extract TeacherDashboard.vue (353 lines) as standalone teacher component
- Rewrite Dashboard.vue to admin-only (875 lines) with expanded stats
- Add getDashboardOverview() API for /api/stats/overview
- Add ECharts trend chart (user/family growth) and membership pie chart
- Replace system info with enriched health panel (status, version, refresh)
- Expand quick actions from 4 to 8 (4x2 gradient icon grid)
- Register /teacher-dashboard route, update router guard for teacher redirect
- Make sidebar '首页' menu path dynamic based on role
- Update AGENTS.md with new dashboard architecture
Xiaogang Liao hai 3 meses
pai
achega
fc89bd3133

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-87854c612a8d9beb2b2920e161cd7f27f6d7f1f5
+084e41af926c9ae5454d3dc87b548a4e67ab82d6

+ 2 - 0
cfc-web/AGENTS.md

@@ -18,6 +18,8 @@ Vue.js 2 Web管理端,Element UI组件库,用于后台管理。
 ```
 src/
 ├── views/           # 页面视图组件
+│   ├── Dashboard.vue          # 管理员首页(8统计卡片+趋势图+系统健康+快捷操作)
+│   ├── TeacherDashboard.vue   # 规划师首页(独立角色仪表板)
 │   ├── admin/       # 管理员页面
 │   └── teacher/     # 规划师页面 (7个)
 ├── api/             # API接口

+ 18 - 0
cfc-web/CHANGELOG.md

@@ -1,6 +1,24 @@
 # 更新日志 (Changelog)
 
 此文件记录所有构建版本的变更。
+## v1.0.68 (2026-07-09)
+
+### Bug 修复
+- convert content-bearing VARCHAR columns to TEXT to prevent Data too long errors
+
+### 其他
+-   audit_reason, summary, advice, overall_assessment, config_value fields
+-   from VARCHAR(255/500/200) to TEXT across ~40 tables
+- - Added idempotent modifyColumnToText() migrations in DatabaseInitializer
+-   (~55 ALTER TABLE MODIFY COLUMN calls) for production database sync
+- - Primary fix: activities.description was the original crash site but
+-   many other columns had the same truncation risk
+- 
+
+### 工程化
+- auto bump version and changelog [skip ci]
+
+
 ## v1.0.67 (2026-07-09)
 
 ### Bug 修复

+ 1 - 1
cfc-web/package.json

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

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

@@ -413,6 +413,13 @@ export function getDashboardStats() {
   })
 }
 
+export function getDashboardOverview() {
+  return request({
+    url: '/api/stats/overview',
+    method: 'post'
+  })
+}
+
 export function getRevenueStats() {
   return request({
     url: '/api/stats/revenue',

+ 12 - 1
cfc-web/src/router/index.js

@@ -29,6 +29,12 @@ const routes = [
         component: () => import('@/views/Dashboard.vue'),
         meta: { title: '首页', perm: 'dashboard' }
       },
+      {
+        path: 'teacher-dashboard',
+        name: 'TeacherDashboard',
+        component: () => import('@/views/TeacherDashboard.vue'),
+        meta: { title: '规划师首页', requiresTeacher: true, perm: 'dashboard' }
+      },
       {
         path: 'admin/dashboard',
         name: 'AdminDashboard',
@@ -576,7 +582,7 @@ router.beforeEach(async (to, from, next) => {
   if (to.path === '/login') {
     // 如果已有token和role,直接跳转到对应首页
     if (token && role === 'teacher') {
-      return next('/dashboard')
+      return next('/teacher-dashboard')
     }
     return next()
   }
@@ -603,6 +609,11 @@ router.beforeEach(async (to, from, next) => {
     return next('/login')
   }
 
+  // 3.5 规划师访问 /dashboard → 跳转规划师仪表板
+  if (role === 'teacher' && to.path === '/dashboard') {
+    return next('/teacher-dashboard')
+  }
+
   // 4. 权限检查:如果路由有 perm 要求,检查用户权限
   const requiredPerm = to.meta && to.meta.perm
   if (requiredPerm) {

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 451 - 543
cfc-web/src/views/Dashboard.vue


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

@@ -40,7 +40,7 @@
                 </el-menu-item>
               </template>
             </el-submenu>
-            <el-menu-item v-else-if="hasPerm(item.perm)" :key="item.path" :index="item.path">
+            <el-menu-item v-else-if="hasPerm(item.perm)" :key="item.path" :index="getMenuIndex(item)">
               <i :class="item.icon"></i>
               <span slot="title">{{ item.label }}</span>
             </el-menu-item>
@@ -385,6 +385,11 @@ export default {
     hasPerm(required) {
       return hasPermission(this.effectivePerms, required)
     },
+    getMenuIndex(item) {
+      return item.path === '/dashboard' && this.currentRole === 'teacher'
+        ? '/teacher-dashboard'
+        : item.path
+    },
     handleMenuSelect(index) {
       if (index && index.startsWith('/')) {
         this.$router.push(index)

+ 353 - 0
cfc-web/src/views/TeacherDashboard.vue

@@ -0,0 +1,353 @@
+<template>
+  <div 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="parseFloat(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>
+</template>
+
+<script>
+import { getBoundFamilies, getTeamMembers, getGuideOrders, getGuideDashboardStats } from '@/api/guide'
+
+export default {
+  name: 'TeacherDashboard',
+  data() {
+    return {
+      teacherStats: {
+        serviceFamilyCount: 0,
+        activeFamilyCount: 0,
+        monthlySales: 0,
+        myCommission: 0
+      },
+      families: [],
+      teamMembers: [],
+      recentOrders: [],
+      todayStats: {
+        newFamilies: 0,
+        completedTasks: 0,
+        newOrders: 0,
+        pendingReviews: 0
+      }
+    }
+  },
+  computed: {
+    todayDate() {
+      const now = new Date()
+      const months = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '十一', '十二']
+      return `${months[now.getMonth()]}月${now.getDate()}日 星期${['日', '一', '二', '三', '四', '五', '六'][now.getDay()]}`
+    }
+  },
+  mounted() {
+    this.loadTeacherData()
+  },
+  methods: {
+    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
+
+        // Guard against malformed responses
+        var familyList = familyRes && familyRes.data ? familyRes.data : []
+        var teamList = teamRes && teamRes.data ? teamRes.data : []
+        var orderData = orderRes && orderRes.data ? orderRes.data : { records: [] }
+        var statsData = statsRes && statsRes.data ? statsRes.data : {}
+
+        this.families = (Array.isArray(familyList) ? familyList : []).slice(0, 5)
+        this.teacherStats.serviceFamilyCount = this.families.length
+        this.teacherStats.activeFamilyCount = this.families.filter(function(f) { return f.status === 'active' }).length
+
+        this.teamMembers = (Array.isArray(teamList) ? teamList : []).slice(0, 5).map(function(m) { return Object.assign({}, m, { trend: 0 }) })
+
+        var records = Array.isArray(orderData.records) ? orderData.records : []
+        this.recentOrders = records.slice(0, 10)
+        var totalSales = records.reduce(function(sum, o) { return sum + (o.amount || 0) }, 0)
+        this.teacherStats.monthlySales = totalSales
+        this.teacherStats.myCommission = Math.round(totalSales * 0.1)
+        var self = this
+        this.todayStats.newOrders = records.filter(function(o) {
+          var today = new Date().toDateString()
+          return o.createdAt && new Date(o.createdAt).toDateString() === today
+        }).length
+
+        this.todayStats.newFamilies = statsData.totalFamilies || 0
+        this.todayStats.completedTasks = statsData.todayCompletedTasks || 0
+        this.todayStats.pendingReviews = statsData.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'
+    }
+  }
+}
+</script>
+
+<style scoped>
+/* ===== 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: flex; flex-wrap: wrap; gap: 16px; }
+.td-action-item { flex: 1 1 calc(50% - 8px); min-width: 120px; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 16px; background: #F8FAFC; border-radius: 12px; cursor: pointer; transition: all 0.2s; box-sizing: border-box; }
+.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: flex; flex-wrap: wrap; gap: 16px; }
+.td-today-stat-item { flex: 1 1 calc(25% - 12px); min-width: 100px; text-align: center; padding: 12px; background: #F8FAFC; border-radius: 12px; box-sizing: border-box; }
+.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>

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio