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

feat(cfc-web): enhance health reports page and add draft audit UI

- HealthReports.vue: switch to admin-list endpoint with pagination and score color tags
- HealthReportAudit.vue: new draft audit page with detail dialog (indicators/gut flora/disease risk tabs)
- Router + sidebar: register /health-report-audit route and menu entry

Ultraworked with Sisyphus (https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Xiaogang Liao 2 месяцев назад
Родитель
Сommit
34d2b3dad3

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

@@ -423,6 +423,12 @@ const routes = [
         component: () => import('@/views/nutritionist/HealthReports.vue'),
         meta: { title: '健康报告', perm: 'health:reports' }
       },
+      {
+        path: 'health-report-audit',
+        name: 'HealthReportAudit',
+        component: () => import('@/views/health/HealthReportAudit.vue'),
+        meta: { title: '报告草稿审核', perm: 'health:reports' }
+      },
       {
         path: 'health-indicators',
         name: 'HealthIndicators',

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

@@ -210,6 +210,7 @@ export default {
         { title: '健康饮食', icon: 'el-icon-first-aid-kit', perm: 'health',
           children: [
             { path: '/health-reports', label: '健康报告', icon: 'el-icon-document', perm: 'health:reports' },
+            { path: '/health-report-audit', label: '报告审核', icon: 'el-icon-document-checked', 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: '/emotion-alert', label: '情绪告警', icon: 'el-icon-warning', perm: 'health:checkins' },

+ 249 - 0
cfc-web/src/views/health/HealthReportAudit.vue

@@ -0,0 +1,249 @@
+<template>
+  <div class="health-report-audit admin-page">
+    <div class="header admin-page-header">
+      <h2 class="admin-page-title">报告草稿审核</h2>
+    </div>
+
+    <el-table :data="list" v-loading="loading" border stripe>
+      <el-table-column prop="id" label="草稿ID" width="80" />
+      <el-table-column label="上传用户" width="120">
+        <template slot-scope="{ row }">{{ row.userId }}</template>
+      </el-table-column>
+      <el-table-column prop="reportType" label="报告类型" width="120" />
+      <el-table-column prop="originalFilename" label="文件名" min-width="200" show-overflow-tooltip />
+      <el-table-column label="状态" width="100">
+        <template slot-scope="{ row }">
+          <el-tag type="warning">待审核</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="上传时间" width="160">
+        <template slot-scope="{ row }">{{ formatTime(row.createdAt) }}</template>
+      </el-table-column>
+      <el-table-column label="过期时间" width="160">
+        <template slot-scope="{ row }">{{ formatTime(row.expireAt) }}</template>
+      </el-table-column>
+      <el-table-column label="操作" width="240">
+        <template slot-scope="{ row }">
+          <el-button size="mini" type="primary" @click="viewDetail(row)">查看详情</el-button>
+          <el-button size="mini" type="success" @click="handleConfirm(row)">确认</el-button>
+          <el-button size="mini" type="danger" @click="handleDiscard(row)">丢弃</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <el-pagination
+      v-if="total > size"
+      @current-change="onPageChange"
+      :current-page="page"
+      :page-size="size"
+      :total="total"
+      layout="total, prev, pager, next"
+      class="pagination-wrap"
+    />
+
+    <!-- 详情弹窗 -->
+    <el-dialog title="草稿详情" :visible.sync="detailVisible" width="800px">
+      <div v-if="detail">
+        <el-descriptions :column="2" border>
+          <el-descriptions-item label="草稿ID">{{ detail.id }}</el-descriptions-item>
+          <el-descriptions-item label="上传用户ID">{{ detail.userId }}</el-descriptions-item>
+          <el-descriptions-item label="报告类型">{{ detail.reportType }}</el-descriptions-item>
+          <el-descriptions-item label="文件名">{{ detail.originalFilename }}</el-descriptions-item>
+          <el-descriptions-item label="上传时间">{{ formatTime(detail.createdAt) }}</el-descriptions-item>
+          <el-descriptions-item label="过期时间">{{ formatTime(detail.expireAt) }}</el-descriptions-item>
+        </el-descriptions>
+
+        <el-divider content-position="left">解析数据</el-divider>
+        <div v-if="parsedPayload">
+          <el-row :gutter="20" style="margin-bottom: 16px">
+            <el-col :span="6" v-for="(val, key) in scoreFields" :key="key">
+              <div class="score-card" :style="{ borderColor: scoreColor(val.value) }">
+                <div class="score-label">{{ val.label }}</div>
+                <div class="score-value" :style="{ color: scoreColor(val.value) }">
+                  {{ val.value ?? '-' }}
+                </div>
+              </div>
+            </el-col>
+          </el-row>
+
+          <el-tabs v-model="activeTab">
+            <el-tab-pane label="指标" name="indicators">
+              <el-table :data="parsedPayload.indicators || []" border size="small" max-height="300">
+                <el-table-column prop="category" label="类别" width="100" />
+                <el-table-column prop="indicatorName" label="指标名称" width="160" />
+                <el-table-column prop="indicatorValue" label="检测值" width="100" />
+                <el-table-column prop="unit" label="单位" width="80" />
+                <el-table-column prop="refRange" label="参考范围" width="140" />
+                <el-table-column prop="status" label="状态" width="80">
+                  <template slot-scope="{ row }">
+                    <el-tag :type="row.status === '异常' ? 'danger' : 'success'" size="small">
+                      {{ row.status || '正常' }}
+                    </el-tag>
+                  </template>
+                </el-table-column>
+              </el-table>
+            </el-tab-pane>
+            <el-tab-pane label="菌群" name="gutFlora">
+              <el-table :data="parsedPayload.gutFlora || []" border size="small" max-height="300">
+                <el-table-column prop="bacteriaName" label="菌种" width="160" />
+                <el-table-column prop="bacteriaValue" label="检测值" width="100" />
+                <el-table-column prop="normalRange" label="正常范围" width="140" />
+                <el-table-column prop="populationLevel" label="水平" width="80" />
+                <el-table-column prop="level" label="级别" width="80" />
+              </el-table>
+            </el-tab-pane>
+            <el-tab-pane label="疾病风险" name="diseaseRisks">
+              <el-table :data="parsedPayload.diseaseRisks || []" border size="small" max-height="300">
+                <el-table-column prop="diseaseName" label="疾病" width="200" />
+                <el-table-column prop="riskValue" label="风险值" width="100" />
+                <el-table-column prop="riskLevel" label="风险等级" width="100" />
+              </el-table>
+            </el-tab-pane>
+          </el-tabs>
+        </div>
+        <div v-else>
+          <el-alert title="无法解析负载数据" type="warning" :closable="false" />
+        </div>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'HealthReportAudit',
+  data() {
+    return {
+      list: [],
+      loading: false,
+      page: 1,
+      size: 20,
+      total: 0,
+      detailVisible: false,
+      detail: null,
+      activeTab: 'indicators'
+    }
+  },
+  computed: {
+    parsedPayload() {
+      if (!this.detail || !this.detail.payloadJson) return null
+      try {
+        const p = JSON.parse(this.detail.payloadJson)
+        return typeof p === 'string' ? JSON.parse(p) : p
+      } catch { return null }
+    },
+    scoreFields() {
+      if (!this.parsedPayload) return []
+      const p = this.parsedPayload
+      return [
+        { label: '综合评分', value: p.overallScore, key: 'overallScore' },
+        { label: '肠道健康', value: p.gutHealthScore, key: 'gutHealthScore' },
+        { label: '慢病风险', value: p.chronicDiseaseScore, key: 'chronicDiseaseScore' },
+        { label: '营养评分', value: p.nutritionScore, key: 'nutritionScore' }
+      ]
+    }
+  },
+  created() { this.loadList() },
+  methods: {
+    async loadList() {
+      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/report/audit/pending', {
+          page: this.page, size: this.size
+        }, {
+          headers: { Authorization: 'Bearer ' + token }
+        })
+        const records = res.data?.data || []
+        this.list = records
+        this.total = records.length
+      } catch (e) {
+        this.$message?.error?.('加载草稿列表失败')
+      } finally {
+        this.loading = false
+      }
+    },
+    onPageChange(val) {
+      this.page = val
+      this.loadList()
+    },
+    viewDetail(row) {
+      this.detail = row
+      this.activeTab = 'indicators'
+      this.detailVisible = true
+    },
+    async handleConfirm(row) {
+      try {
+        await this.$confirm(`确认草稿 #${row.id}?确认后将生成正式报告。`, '提示', { type: 'warning' })
+        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/report/audit/confirm', {
+          draftId: row.id
+        }, {
+          headers: { Authorization: 'Bearer ' + token }
+        })
+        this.$message?.success?.('审核确认完成')
+        this.loadList()
+      } catch (e) {
+        if (e !== 'cancel') this.$message?.error?.('确认失败')
+      }
+    },
+    async handleDiscard(row) {
+      try {
+        await this.$confirm(`确认丢弃草稿 #${row.id}?丢弃后不可恢复。`, '警告', { type: 'warning' })
+        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/report/audit/discard', {
+          draftId: row.id
+        }, {
+          headers: { Authorization: 'Bearer ' + token }
+        })
+        this.$message?.success?.('已丢弃')
+        this.loadList()
+      } catch (e) {
+        if (e !== 'cancel') this.$message?.error?.('操作失败')
+      }
+    },
+    formatTime(t) {
+      if (!t) return '-'
+      return t.replace ? t.replace('T', ' ').substring(0, 19) : t
+    },
+    scoreColor(score) {
+      if (score == null) return '#909399'
+      if (score >= 80) return '#67c23a'
+      if (score >= 60) return '#e6a23c'
+      return '#f56c6c'
+    }
+  }
+}
+</script>
+
+<style scoped>
+.health-report-audit {
+  padding: 20px;
+}
+.score-card {
+  border: 2px solid #ebeef5;
+  border-radius: 8px;
+  padding: 12px;
+  text-align: center;
+  background: #fafafa;
+}
+.score-label {
+  font-size: 13px;
+  color: #909399;
+  margin-bottom: 6px;
+}
+.score-value {
+  font-size: 24px;
+  font-weight: bold;
+}
+.pagination-wrap {
+  margin-top: 16px;
+  text-align: right;
+}
+</style>

+ 54 - 18
cfc-web/src/views/nutritionist/HealthReports.vue

@@ -1,35 +1,57 @@
 <template>
   <div class="health-reports">
-  <el-card>
-    <div slot="header"><span>健康报告</span></div>
-    <div style="overflow:auto;max-height:calc(100vh - 300px);">
-    <el-table :data="reports" v-loading="loading" border stripe style="max-height:calc(100vh - 300px);">
-        <el-table-column prop="id" label="ID" width="80" />
-        <el-table-column prop="childName" label="孩子" width="120" />
-        <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">
+    <el-card>
+      <div slot="header">
+        <span>健康报告</span>
+      </div>
+      <el-table :data="reports" v-loading="loading" border stripe>
+        <el-table-column prop="id" label="ID" width="70" />
+        <el-table-column prop="childName" label="孩子" width="100" />
+        <el-table-column prop="reportType" label="报告类型" width="100" />
+        <el-table-column prop="overallScore" label="综合评分" width="100">
+          <template slot-scope="{ row }">
+            <el-tag :type="scoreType(row.overallScore)" size="small">
+              {{ row.overallScore ?? '-' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="gutHealthScore" label="肠道" width="80" />
+        <el-table-column prop="nutritionScore" label="营养" width="80" />
+        <el-table-column prop="chronicDiseaseScore" label="慢病" width="80" />
+        <el-table-column prop="gutAge" label="肠道年龄" width="80" />
+        <el-table-column prop="gutType" label="肠道类型" width="100" />
+        <el-table-column prop="reportDate" label="报告日期" width="160" />
+        <el-table-column label="营养缺乏" width="150">
           <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">
+            <el-button size="mini" type="text" @click="triggerAnalysis(row)" style="margin-left: 4px">
               {{ row.deficiencyCount > 0 ? '重新分析' : '分析' }}
             </el-button>
           </template>
         </el-table-column>
-    </el-table>
-    </div>
-  </el-card>
-</div>
+      </el-table>
+      <el-pagination
+        v-if="total > size"
+        @current-change="onPageChange"
+        :current-page="page"
+        :page-size="size"
+        :total="total"
+        layout="total, prev, pager, next"
+        class="pagination-wrap"
+      />
+    </el-card>
+  </div>
 </template>
 
 <script>
 export default {
   name: 'HealthReports',
-  data() { return { loading: false, reports: [] } },
+  data() {
+    return { loading: false, reports: [], page: 1, size: 20, total: 0 }
+  },
   created() { this.loadReports() },
   methods: {
     async loadReports() {
@@ -38,13 +60,17 @@ export default {
         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/report/list', {}, {
+        const res = await axios.post(baseURL + '/api/health/report/admin-list', {
+          page: this.page, size: this.size
+        }, {
           headers: { Authorization: 'Bearer ' + token }
         })
-        this.reports = (res.data?.data || []).map(r => {
+        const data = res.data?.data || {}
+        this.reports = (data.records || []).map(r => {
           r.deficiencyCount = r.deficiencyCount || 0
           return r
         })
+        this.total = data.total || 0
       } catch (e) {
         this.$message?.error?.('加载健康报告失败')
       } finally {
@@ -67,6 +93,16 @@ export default {
       } catch (e) {
         this.$message?.error?.('分析失败')
       }
+    },
+    onPageChange(val) {
+      this.page = val
+      this.loadReports()
+    },
+    scoreType(score) {
+      if (score == null) return 'info'
+      if (score >= 80) return 'success'
+      if (score >= 60) return 'warning'
+      return 'danger'
     }
   }
 }