Browse Source

feat(web): 营养师管理页面与登录页优化

- 登录页太极图美学优化
- 营养师健康报告/指标管理页面
- 新增营养映射管理页面(NutritionMappings)
- 路由配置更新支持营养功能
- Layout布局适配
Sisyphus 2 tháng trước cách đây
mục cha
commit
d0fda44a83

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

@@ -352,6 +352,12 @@ const routes = [
         component: () => import('@/views/nutritionist/HealthCheckins.vue'),
         meta: { title: '健康打卡', perm: 'health:checkins' }
       },
+      {
+        path: 'nutrition-mappings',
+        name: 'NutritionMappings',
+        component: () => import('@/views/nutritionist/NutritionMappings.vue'),
+        meta: { title: '营养素映射管理', perm: 'health:mappings' }
+      },
       {
         path: 'activities',
         name: 'Activities',

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

@@ -186,6 +186,7 @@ export default {
             { path: '/health-reports', label: '健康报告', icon: 'el-icon-document', perm: 'health:reports' },
             { path: '/health-indicators', label: '健康指标', icon: 'el-icon-data-line', perm: 'health:indicators' },
             { path: '/health-checkins', label: '健康打卡', icon: 'el-icon-s-order', perm: 'health:checkins' },
+            { path: '/nutrition-mappings', label: '营养素映射', icon: 'el-icon-connection', perm: 'health:mappings' },
           ]},
         { path: '/energy-sandbox', label: '五维能量', icon: 'el-icon-data-line', perm: 'energy' },
 

+ 17 - 2
cfc-web/src/views/Login.vue

@@ -152,8 +152,13 @@
               <text x="70" y="187" text-anchor="middle" fill="rgba(255,255,255,0.45)" font-size="9">火</text>
             </g>
 
-            <!-- 中心五行文字 -->
-            <text x="170" y="188" text-anchor="middle" fill="rgba(255,255,255,0.07)" font-size="13" letter-spacing="5">五行 · 相生相克</text>
+            <!-- 中心太极(替代原有五行文字) -->
+            <svg x="130" y="150" width="80" height="80" viewBox="0 0 120 120" class="login-taiji">
+              <circle cx="60" cy="60" r="56" fill="none" stroke="rgba(255,255,255,0.15)" stroke-width="1.2" />
+              <path d="M60 4 A56 56 0 0 1 60 116 A28 28 0 0 0 60 60 A28 28 0 0 1 60 4" fill="rgba(255,255,255,0.15)" />
+              <circle cx="60" cy="32" r="10" fill="rgba(255,255,255,0.6)" />
+              <circle cx="60" cy="88" r="10" fill="rgba(255,255,255,0.15)" />
+            </svg>
 
             <!-- 图例 -->
             <g transform="translate(170, 345)">
@@ -408,6 +413,16 @@ export default {
   100% { stroke-dashoffset: -18; }
 }
 
+/* ===== 中心太极 · 缓慢旋转 ===== */
+.sand-table-svg .login-taiji {
+  animation: taijiSpin 24s linear infinite;
+  transform-origin: 170px 190px;
+}
+
+@keyframes taijiSpin {
+  to { transform: rotate(360deg); }
+}
+
 /* ===== 相克线 · 脉冲 ===== */
 .sand-table-svg .line-ove {
   animation: pulseLine 2.2s ease-in-out infinite;

+ 40 - 2
cfc-web/src/views/nutritionist/HealthIndicators.vue

@@ -2,11 +2,35 @@
   <div class="health-indicators">
     <el-card>
       <div slot="header"><span>健康指标</span></div>
-      <el-table :data="indicators" v-loading="loading" border stripe>
+      <el-table :data="indicators" v-loading="loading" border stripe :cell-style="cellStyle">
         <el-table-column prop="id" label="ID" width="80" />
         <el-table-column prop="childName" label="孩子" width="120" />
-        <el-table-column prop="indicatorName" label="指标名称" width="120" />
+        <el-table-column prop="indicatorName" label="指标名称" width="120">
+          <template slot-scope="{ row }">
+            <span :style="row.status && row.status !== '正常' ? 'color: #DC2626; font-weight: bold' : ''">
+              {{ row.indicatorName }}
+            </span>
+          </template>
+        </el-table-column>
         <el-table-column prop="value" label="数值" width="100" />
+        <el-table-column prop="status" label="状态" width="80">
+          <template slot-scope="{ row }">
+            <el-tag v-if="row.status && row.status !== '正常'" :type="statusTagType(row.status)" size="small">
+              {{ row.status }}
+            </el-tag>
+            <span v-else>{{ row.status || '正常' }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="关联营养素" width="120">
+          <template slot-scope="{ row }">
+            <template v-if="row.linkedNutrients && row.linkedNutrients.length > 0">
+              <el-tag v-for="nutrient in row.linkedNutrients" :key="nutrient" size="mini" style="margin-right: 4px">
+                {{ nutrient }}
+              </el-tag>
+            </template>
+            <span v-else style="color: #999; font-size: 12px">—</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="recordedAt" label="记录时间" width="180" />
       </el-table>
     </el-card>
@@ -34,6 +58,20 @@ export default {
       } finally {
         this.loading = false
       }
+    },
+    cellStyle({ row, columnIndex }) {
+      if (columnIndex === 1 && row.status && row.status !== '正常') {
+        return 'background: #FEF2F2'
+      }
+      return ''
+    },
+    statusTagType(status) {
+      if (!status) return 'info'
+      var s = status.toLowerCase()
+      if (s.indexOf('低') >= 0 || s.indexOf('缺乏') >= 0 || s.indexOf('不足') >= 0 || s === 'low') return 'danger'
+      if (s.indexOf('高') >= 0 || s.indexOf('过量') >= 0 || s === 'high') return 'warning'
+      if (s.indexOf('临界') >= 0 || s.indexOf('边缘') >= 0 || s === 'borderline') return 'warning'
+      return 'info'
     }
   }
 }

+ 32 - 1
cfc-web/src/views/nutritionist/HealthReports.vue

@@ -8,6 +8,17 @@
         <el-table-column prop="reportType" label="报告类型" width="120" />
         <el-table-column prop="summary" label="摘要" />
         <el-table-column prop="createdAt" label="报告日期" width="180" />
+        <el-table-column label="营养缺乏" width="150" fixed="right">
+          <template slot-scope="{ row }">
+            <el-tag v-if="row.deficiencyCount > 0" type="danger" size="small">
+              {{ row.deficiencyCount }}项缺乏
+            </el-tag>
+            <el-tag v-else type="success" size="small">无缺乏</el-tag>
+            <el-button size="mini" type="text" @click="triggerAnalysis(row)" style="margin-left: 8px">
+              {{ row.deficiencyCount > 0 ? '重新分析' : '分析' }}
+            </el-button>
+          </template>
+        </el-table-column>
       </el-table>
     </el-card>
   </div>
@@ -28,12 +39,32 @@ export default {
         const res = await axios.post(baseURL + '/api/health/report/list', {}, {
           headers: { Authorization: 'Bearer ' + token }
         })
-        this.reports = res.data?.data || []
+        this.reports = (res.data?.data || []).map(r => {
+          r.deficiencyCount = r.deficiencyCount || 0
+          return r
+        })
       } catch (e) {
         this.$message?.error?.('加载健康报告失败')
       } finally {
         this.loading = false
       }
+    },
+    async triggerAnalysis(row) {
+      try {
+        const baseURL = process.env.VUE_APP_BASE_API || ''
+        const token = localStorage.getItem('token')
+        const axios = this.$axios || (await import('axios')).default
+        await axios.post(baseURL + '/api/health/deficiency/analyze', {
+          reportId: row.id,
+          childId: row.childId || row.userId
+        }, {
+          headers: { Authorization: 'Bearer ' + token }
+        })
+        this.$message?.success?.('分析完成')
+        this.loadReports()
+      } catch (e) {
+        this.$message?.error?.('分析失败')
+      }
     }
   }
 }

+ 142 - 0
cfc-web/src/views/nutritionist/NutritionMappings.vue

@@ -0,0 +1,142 @@
+<template>
+  <div class="nutrition-mappings">
+    <el-card>
+      <div slot="header">
+        <span>营养素映射管理</span>
+        <el-button type="primary" size="mini" style="float:right" @click="openCreateDialog">新增映射</el-button>
+      </div>
+      <el-table :data="mappings" v-loading="loading" border stripe>
+        <el-table-column prop="id" label="ID" width="60" />
+        <el-table-column prop="indicatorNamePattern" label="指标名模式" width="180" />
+        <el-table-column prop="nutrient" label="营养素" width="100" />
+        <el-table-column prop="severityWeight" label="权重" width="80" />
+        <el-table-column prop="deficiencySymptoms" label="缺乏症状" min-width="150" show-overflow-tooltip />
+        <el-table-column prop="foodSuggestions" label="推荐食物" min-width="180" show-overflow-tooltip />
+        <el-table-column prop="sortOrder" label="排序" width="80" />
+        <el-table-column label="操作" width="150" fixed="right">
+          <template slot-scope="{ row }">
+            <el-button size="mini" @click="openEditDialog(row)">编辑</el-button>
+            <el-button size="mini" type="danger" @click="handleDelete(row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+
+    <el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="600px">
+      <el-form :model="form" label-width="120px">
+        <el-form-item label="指标名模式">
+          <el-input v-model="form.indicatorNamePattern" placeholder="如: 铁蛋白, 维生素D%" />
+        </el-form-item>
+        <el-form-item label="营养素">
+          <el-input v-model="form.nutrient" placeholder="如: 铁, 维生素D" />
+        </el-form-item>
+        <el-form-item label="权重(0-1)">
+          <el-input-number v-model="form.severityWeight" :min="0" :max="1" :step="0.1" />
+        </el-form-item>
+        <el-form-item label="缺乏症状">
+          <el-input v-model="form.deficiencySymptoms" type="textarea" :rows="2" />
+        </el-form-item>
+        <el-form-item label="过量症状">
+          <el-input v-model="form.excessSymptoms" type="textarea" :rows="2" />
+        </el-form-item>
+        <el-form-item label="推荐食物">
+          <el-input v-model="form.foodSuggestions" placeholder="逗号分隔,如: 动物肝脏,红肉,菠菜" />
+        </el-form-item>
+        <el-form-item label="排序">
+          <el-input-number v-model="form.sortOrder" :min="0" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleSave">保存</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'NutritionMappings',
+  data() {
+    return {
+      loading: false,
+      mappings: [],
+      dialogVisible: false,
+      dialogTitle: '新增映射',
+      form: {
+        id: null,
+        indicatorNamePattern: '',
+        nutrient: '',
+        severityWeight: 1.0,
+        deficiencySymptoms: '',
+        excessSymptoms: '',
+        foodSuggestions: '',
+        sortOrder: 10
+      }
+    }
+  },
+  created() {
+    this.loadMappings()
+  },
+  methods: {
+    async loadMappings() {
+      this.loading = true
+      try {
+        const baseURL = process.env.VUE_APP_BASE_API || ''
+        const token = localStorage.getItem('token')
+        const axios = this.$axios || (await import('axios')).default
+        const res = await axios.post(baseURL + '/api/health/nutrition/mappings', {}, {
+          headers: { Authorization: 'Bearer ' + token }
+        })
+        this.mappings = res.data?.data || []
+      } catch (e) {
+        this.$message?.error?.('加载映射列表失败')
+      } finally {
+        this.loading = false
+      }
+    },
+    openCreateDialog() {
+      this.form = { id: null, indicatorNamePattern: '', nutrient: '', severityWeight: 1.0, deficiencySymptoms: '', excessSymptoms: '', foodSuggestions: '', sortOrder: 10 }
+      this.dialogTitle = '新增映射'
+      this.dialogVisible = true
+    },
+    openEditDialog(row) {
+      this.form = { ...row }
+      this.dialogTitle = '编辑映射'
+      this.dialogVisible = true
+    },
+    async handleSave() {
+      try {
+        const baseURL = process.env.VUE_APP_BASE_API || ''
+        const token = localStorage.getItem('token')
+        const axios = this.$axios || (await import('axios')).default
+        const url = this.form.id ? '/api/health/nutrition/mapping/update' : '/api/health/nutrition/mapping/create'
+        await axios.post(baseURL + url, this.form, {
+          headers: { Authorization: 'Bearer ' + token }
+        })
+        this.$message?.success?.('保存成功')
+        this.dialogVisible = false
+        this.loadMappings()
+      } catch (e) {
+        this.$message?.error?.('保存失败')
+      }
+    },
+    async handleDelete(row) {
+      this.$confirm('确认删除映射 "' + row.nutrient + '" 吗?', '提示', { type: 'warning' }).then(async () => {
+        try {
+          const baseURL = process.env.VUE_APP_BASE_API || ''
+          const token = localStorage.getItem('token')
+          const axios = this.$axios || (await import('axios')).default
+          await axios.post(baseURL + '/api/health/nutrition/mapping/delete', { id: row.id }, {
+            headers: { Authorization: 'Bearer ' + token }
+          })
+          this.$message?.success?.('删除成功')
+          this.loadMappings()
+        } catch (e) {
+          this.$message?.error?.('删除失败')
+        }
+      }).catch(() => {})
+    }
+  }
+}
+</script>