Quellcode durchsuchen

feat(frontend): 规划师端代上传健康报告页面

E2E Test Bot vor 2 Monaten
Ursprung
Commit
236b051dc0
3 geänderte Dateien mit 387 neuen und 0 gelöschten Zeilen
  1. 48 0
      cfc-web/src/api/admin.js
  2. 6 0
      cfc-web/src/router/index.js
  3. 333 0
      cfc-web/src/views/teacher/ProxyReport.vue

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

@@ -1841,6 +1841,14 @@ export function getManualOutboundList(params) {
   })
 }
 
+
+export function getFamilyMemberList(params) {
+  return request({
+    url: '/api/family/member/list',
+    method: 'post',
+    data: params
+  })
+}
 export function updateOrderExpress(data) {
   return request({
     url: '/api/admin/product/order/update-express',
@@ -1848,3 +1856,43 @@ export function updateOrderExpress(data) {
     data
   })
 }
+
+// ========== 健康报告代上传 ==========
+
+export function proxyParseReport(data) {
+  const formData = new FormData()
+  formData.append('file', data.file)
+  formData.append('familyId', data.familyId)
+  if (data.subjectId) formData.append('subjectId', data.subjectId)
+  if (data.note) formData.append('note', data.note)
+  return request({
+    url: '/api/health/report/proxy-parse',
+    method: 'post',
+    data: formData,
+    headers: { 'Content-Type': 'multipart/form-data' }
+  })
+}
+
+export function proxyConfirmReport(data) {
+  return request({
+    url: '/api/health/report/proxy-confirm',
+    method: 'post',
+    data
+  })
+}
+
+export function listHealthReports(params) {
+  return request({
+    url: '/api/health/report/list',
+    method: 'post',
+    data: params
+  })
+}
+
+export function getHealthReportDetail(reportId) {
+  return request({
+    url: '/api/health/report/detail',
+    method: 'post',
+    data: { reportId }
+  })
+}

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

@@ -186,6 +186,12 @@ const routes = [
         component: () => import('@/views/teacher/TeacherAssessment.vue'),
         meta: { title: 'DAN测评管理', requiresTeacher: true, perm: 'assessment:dan' }
       },
+      {
+        path: 'teacher-proxy-report',
+        name: 'TeacherProxyReport',
+        component: () => import('@/views/teacher/ProxyReport.vue'),
+        meta: { title: '代上传报告', requiresTeacher: true, perm: 'health:reports' }
+      },
       {
         path: 'assessment-admin',
         name: 'AssessmentAdmin',

+ 333 - 0
cfc-web/src/views/teacher/ProxyReport.vue

@@ -0,0 +1,333 @@
+<template>
+  <div class="proxy-report admin-page">
+    <el-card>
+      <div slot="header" class="card-header">
+        <span class="card-title">📋 代上传健康报告</span>
+      </div>
+
+      <!-- 上传区域 -->
+      <el-form ref="uploadForm" :model="uploadForm" label-width="100px" class="upload-form">
+        <el-form-item label="选择家庭" prop="familyId" required>
+          <el-select v-model="uploadForm.familyId" placeholder="请选择家庭" filterable style="width:100%" @change="onFamilyChange">
+            <el-option v-for="f in familyList" :key="f.id" :label="f.name" :value="f.id" />
+          </el-select>
+        </el-form-item>
+
+        <el-form-item label="报告对象" prop="subjectId" required>
+          <el-select v-model="uploadForm.subjectId" placeholder="请选择家庭成员" filterable style="width:100%">
+            <el-option v-for="m in memberList" :key="m.id" :label="m.nickname || m.realName" :value="m.id" />
+          </el-select>
+        </el-form-item>
+
+        <el-form-item label="报告文件" prop="file" required>
+          <el-upload
+            ref="upload"
+            action=""
+            :auto-upload="false"
+            :limit="1"
+            accept=".pdf"
+            :on-change="handleFileChange"
+            :on-exceed="handleExceed"
+          >
+            <el-button size="small" type="primary">选择PDF文件</el-button>
+            <div slot="tip" class="el-upload__tip">仅支持PDF格式,大小不超过50MB</div>
+          </el-upload>
+        </el-form-item>
+
+        <el-form-item label="代上传备注">
+          <el-input v-model="uploadForm.note" type="textarea" :rows="2" placeholder="选填,备注上传原因" />
+        </el-form-item>
+
+        <el-form-item>
+          <el-button type="primary" :loading="uploading" @click="handleUpload">上传并解析</el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <!-- 解析结果预览 -->
+    <el-card v-if="previewResult" style="margin-top:20px">
+      <div slot="header" class="card-header">
+        <span class="card-title">📊 解析结果预览</span>
+        <el-tag :type="previewResult.needBind ? 'warning' : 'success'" size="small">
+          {{ previewResult.needBind ? '待绑定成员' : '已匹配成员' }}
+        </el-tag>
+      </div>
+
+      <el-descriptions :column="2" border>
+        <el-descriptions-item label="被检测人">{{ previewResult.extractedName || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="报告编号">{{ previewResult.extractedReportNumber || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="报告日期">{{ previewResult.extractedReportDate || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="健康总分">{{ previewResult.payload && previewResult.payload.overallScore ? previewResult.payload.overallScore : '-' }}</el-descriptions-item>
+        <el-descriptions-item label="菌群健康分">{{ previewResult.payload && previewResult.payload.gutHealthScore ? previewResult.payload.gutHealthScore : '-' }}</el-descriptions-item>
+        <el-descriptions-item label="肠型">{{ previewResult.payload && previewResult.payload.gutType ? previewResult.payload.gutType : '-' }}</el-descriptions-item>
+      </el-descriptions>
+
+      <div style="margin-top:20px;text-align:right">
+        <el-button @click="previewResult=null">取消</el-button>
+        <el-button type="primary" :loading="confirming" @click="handleConfirm">确认入库</el-button>
+      </div>
+    </el-card>
+
+    <!-- AI解读结果 -->
+    <el-card v-if="analysisResult" style="margin-top:20px">
+      <div slot="header" class="card-header">
+        <span class="card-title">🤖 AI解读结果</span>
+        <el-tag type="success" size="small">已完成</el-tag>
+      </div>
+      <div v-html="analysisResult" class="analysis-content"></div>
+    </el-card>
+
+    <!-- 历史报告列表 -->
+    <el-card style="margin-top:20px">
+      <div slot="header" class="card-header">
+        <span class="card-title">📁 代上传历史记录</span>
+      </div>
+      <el-table :data="reportList" border stripe v-loading="listLoading" :max-height="tableHeight">
+        <el-table-column prop="id" label="ID" width="60" />
+        <el-table-column label="报告对象" width="120">
+          <template slot-scope="{ row }">{{ row.subjectName || row.userName || '-' }}</template>
+        </el-table-column>
+        <el-table-column prop="reportType" label="类型" width="100">
+          <template slot-scope="{ row }">{{ row.reportType === 'gut_flora' ? '菌群检测' : row.reportType === 'physical_exam' ? '体检报告' : row.reportType }}</template>
+        </el-table-column>
+        <el-table-column prop="overallScore" label="健康分" width="80">
+          <template slot-scope="{ row }">{{ row.overallScore || '-' }}</template>
+        </el-table-column>
+        <el-table-column label="代上传人" width="100">
+          <template slot-scope="{ row }">{{ row.proxyUploaderName || '-' }}</template>
+        </el-table-column>
+        <el-table-column prop="remark" label="备注" show-overflow-tooltip />
+        <el-table-column prop="createdAt" label="上传时间" width="160">
+          <template slot-scope="{ row }">{{ formatTime(row.createdAt) }}</template>
+        </el-table-column>
+        <el-table-column label="操作" width="120" fixed="right">
+          <template slot-scope="{ row }">
+            <el-button size="mini" type="text" @click="viewReport(row)">查看</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <el-pagination
+        @size-change="onSizeChange"
+        @current-change="onPageChange"
+        :current-page="page"
+        :page-size="size"
+        :total="total"
+        layout="total, prev, pager, next"
+        class="pagination-wrap"
+      />
+    </el-card>
+
+    <!-- 报告详情弹窗 -->
+    <el-dialog title="报告详情" :visible.sync="detailDialogVisible" width="700px" top="5vh">
+      <el-descriptions v-if="detailReport" :column="2" border>
+        <el-descriptions-item label="报告ID">{{ detailReport.id }}</el-descriptions-item>
+        <el-descriptions-item label="报告类型">{{ detailReport.reportType }}</el-descriptions-item>
+        <el-descriptions-item label="报告编号">{{ detailReport.reportNumber || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="报告日期">{{ detailReport.reportDate || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="健康总分">{{ detailReport.overallScore || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="菌群健康分">{{ detailReport.gutHealthScore || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="肠型">{{ detailReport.gutType || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="肠道年龄">{{ detailReport.gutAge || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="健康评级">{{ detailReport.healthRating || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="代上传人">{{ detailReport.proxyUploaderName || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="备注" :span="2">{{ detailReport.remark || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="AI解读" :span="2">
+          <div v-html="detailReport.aiAnalysis" class="analysis-content" style="max-height:300px;overflow:auto"></div>
+        </el-descriptions-item>
+      </el-descriptions>
+      <span slot="footer">
+        <el-button @click="detailDialogVisible = false">关闭</el-button>
+      </span>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { proxyParseReport, proxyConfirmReport, listHealthReports, getHealthReportDetail } from '@/api/admin'
+import { getFamilyList } from '@/api/admin'
+import { getFamilyMemberList } from '@/api/admin'
+
+export default {
+  name: 'ProxyReport',
+  data() {
+    return {
+      uploading: false,
+      confirming: false,
+      listLoading: false,
+      familyList: [],
+      memberList: [],
+      reportList: [],
+      previewResult: null,
+      analysisResult: null,
+      detailReport: null,
+      detailDialogVisible: false,
+      page: 1,
+      size: 20,
+      total: 0,
+      uploadForm: {
+        familyId: null,
+        subjectId: null,
+        file: null,
+        note: ''
+      }
+    }
+  },
+  computed: {
+    tableHeight() {
+      return window.innerHeight - 420
+    }
+  },
+  mounted() {
+    this.loadFamilies()
+    this.loadReports()
+  },
+  methods: {
+    async loadFamilies() {
+      try {
+        const res = await getFamilyList({ page: 1, size: 200 })
+        this.familyList = res.data && res.data.records ? res.data.records : (res.data || [])
+      } catch (e) {
+        console.error(e)
+      }
+    },
+    async onFamilyChange(familyId) {
+      if (!familyId) { this.memberList = []; return }
+      try {
+        const res = await getFamilyMemberList({ familyId })
+        this.memberList = res.data || []
+        this.uploadForm.subjectId = null
+      } catch (e) {
+        this.memberList = []
+      }
+    },
+    handleFileChange(file) {
+      this.uploadForm.file = file.raw
+    },
+    handleExceed() {
+      this.$message.warning('只能上传一个文件')
+    },
+    async handleUpload() {
+      if (!this.uploadForm.familyId) {
+        this.$message.warning('请选择家庭')
+        return
+      }
+      if (!this.uploadForm.subjectId) {
+        this.$message.warning('请选择报告对象')
+        return
+      }
+      if (!this.uploadForm.file) {
+        this.$message.warning('请选择PDF文件')
+        return
+      }
+      this.uploading = true
+      try {
+        const res = await proxyParseReport({
+          familyId: this.uploadForm.familyId,
+          subjectId: this.uploadForm.subjectId,
+          file: this.uploadForm.file,
+          note: this.uploadForm.note
+        })
+        if (res.code === 200) {
+          this.previewResult = res.data
+          this.analysisResult = null
+        } else {
+          this.$message.error(res.message || '解析失败')
+        }
+      } catch (e) {
+        this.$message.error('上传失败: ' + (e.message || ''))
+      } finally {
+        this.uploading = false
+      }
+    },
+    async handleConfirm() {
+      if (!this.previewResult || !this.previewResult.draftId) {
+        this.$message.warning('没有可确认的草稿')
+        return
+      }
+      this.confirming = true
+      try {
+        const res = await proxyConfirmReport({
+          draftId: this.previewResult.draftId,
+          subjectId: this.uploadForm.subjectId
+        })
+        if (res.code === 200) {
+          this.$message.success('报告入库成功')
+          this.previewResult = null
+          this.analysisResult = res.data && res.data.aiAnalysis ? res.data.aiAnalysis : null
+          this.loadReports()
+        } else {
+          this.$message.error(res.message || '确认失败')
+        }
+      } catch (e) {
+        this.$message.error('确认失败: ' + (e.message || ''))
+      } finally {
+        this.confirming = false
+      }
+    },
+    async loadReports() {
+      this.listLoading = true
+      try {
+        const res = await listHealthReports({ page: this.page, size: this.size })
+        if (res.code === 200) {
+          this.reportList = res.data && res.data.records ? res.data.records : (res.data || [])
+          this.total = res.data && res.data.total ? res.data.total : this.reportList.length
+        }
+      } catch (e) {
+        console.error(e)
+      } finally {
+        this.listLoading = false
+      }
+    },
+    async viewReport(row) {
+      try {
+        const res = await getHealthReportDetail(row.id)
+        if (res.code === 200) {
+          this.detailReport = res.data
+          this.detailDialogVisible = true
+        }
+      } catch (e) {
+        this.$message.error('加载详情失败')
+      }
+    },
+    onSizeChange(size) {
+      this.size = size
+      this.loadReports()
+    },
+    onPageChange(page) {
+      this.page = page
+      this.loadReports()
+    },
+    formatTime(t) {
+      if (!t) return '-'
+      return t.replace('T', ' ').substring(0, 19)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+.card-title {
+  font-size: 16px;
+  font-weight: bold;
+}
+.upload-form {
+  max-width: 600px;
+}
+.analysis-content {
+  font-size: 14px;
+  line-height: 1.8;
+  color: #333;
+}
+.analysis-content :deep(p) {
+  margin: 8px 0;
+}
+.pagination-wrap {
+  margin-top: 20px;
+  text-align: right;
+}
+</style>