Browse Source

feat: DAN报告上传 - 前端API + report-upload页面

新增danParsePreview/danConfirmReport/getDanReportByChild API + 上传预览确认页 + pages.json注册

Ultraworked with Sisyphus

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
User 2 months ago
parent
commit
cb42e33319

+ 24 - 0
cfc-frontend/pages.json

@@ -526,6 +526,18 @@
         }
         }
       ]
       ]
     },
     },
+    {
+      "root": "pages/dan-assessment",
+      "pages": [
+        {
+          "path": "report-upload",
+          "style": {
+            "navigationBarTitleText": "DAN 报告上传",
+            "navigationStyle": "custom"
+          }
+        }
+      ]
+    },
     {
     {
       "root": "pages/growth",
       "root": "pages/growth",
       "pages": [
       "pages": [
@@ -929,6 +941,18 @@
           "style": {
           "style": {
             "navigationBarTitleText": "冥想"
             "navigationBarTitleText": "冥想"
           }
           }
+        },
+        {
+          "path": "gut-index",
+          "style": {
+            "navigationBarTitleText": "菌群"
+          }
+        },
+        {
+          "path": "gut-flora-detail",
+          "style": {
+            "navigationBarTitleText": "菌群报告详情"
+          }
         }
         }
       ]
       ]
     },
     },

+ 505 - 0
cfc-frontend/pages/dan-assessment/report-upload.vue

@@ -0,0 +1,505 @@
+<template>
+  <view class="page">
+    <view class="header">
+      <view class="back-btn" @click="goBack">
+        <text class="back-icon">&#x2190;</text>
+        <text class="back-text">返回</text>
+      </view>
+      <text class="header-title">DAN 报告上传</text>
+    </view>
+
+    <!-- Step 1: 选择维度 + 选择文件 -->
+    <view class="section" v-if="step === 1">
+      <view class="dimension-select">
+        <text class="dimension-label">报告类型</text>
+        <view class="dimension-options">
+          <view
+            :class="['dimension-option', dimension === 'mind' ? 'active' : '']"
+            @click="dimension = 'mind'">
+            <text class="dimension-name">&#x2764; 心维度 (A2)</text>
+          </view>
+          <view
+            :class="['dimension-option', dimension === 'wisdom' ? 'active' : '']"
+            @click="dimension = 'wisdom'">
+            <text class="dimension-name">&#x1F9E0; 智维度 (B4)</text>
+          </view>
+        </view>
+      </view>
+
+      <view class="upload-area" @click="chooseFile">
+        <view class="upload-icon" v-if="!selectedFile">&#x1F4C4;</view>
+        <view class="upload-icon" v-else>&#x2705;</view>
+        <text class="upload-text" v-if="!selectedFile">点击上传 PDF 文件</text>
+        <text class="upload-text" v-else>{{ selectedFile.name }}</text>
+        <text class="upload-hint">支持 PDF 格式的 DAN 测评报告</text>
+      </view>
+
+      <view class="action-bar">
+        <view class="upload-btn" @click="startUpload" v-if="selectedFile">
+          <text class="upload-btn-text">上传并解析</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- Step 2: 预览解析结果 -->
+    <view class="section" v-if="step === 2">
+      <view class="preview-header">
+        <text class="preview-title">解析预览</text>
+        <text class="preview-desc">请确认以下数据是否准确</text>
+      </view>
+
+      <view class="preview-card" v-if="previewData">
+        <view class="preview-row">
+          <text class="preview-label">维度</text>
+          <text class="preview-value">{{ dimension === 'mind' ? '心维度 (A2)' : '智维度 (B4)' }}</text>
+        </view>
+        <view class="preview-row">
+          <text class="preview-label">解析概要</text>
+          <text class="preview-value">{{ previewData.summary || '无' }}</text>
+        </view>
+      </view>
+
+      <!-- 解析项列表 -->
+      <view class="items-section" v-if="previewData && previewData.items && previewData.items.length > 0">
+        <text class="items-title">数据项 ({{ previewData.items.length }})</text>
+        <view class="item-card" v-for="(item, idx) in previewData.items" :key="idx">
+          <view class="item-header">
+            <text class="item-name">{{ item.name || item.code }}</text>
+            <text class="item-category">{{ item.category }}</text>
+          </view>
+          <view class="item-value-bar">
+            <view class="item-value-fill" :style="'width:' + item.value + '%'"></view>
+          </view>
+          <text class="item-value-text">{{ item.value }}/100</text>
+        </view>
+      </view>
+
+      <!-- 建议 -->
+      <view class="suggestions-card" v-if="previewData && previewData.suggestions">
+        <text class="suggestions-title">成长建议</text>
+        <text class="suggestions-text">{{ previewData.suggestions }}</text>
+      </view>
+
+      <view class="action-bar">
+        <view class="confirm-btn" @click="doConfirm" v-if="!confirming">
+          <text class="confirm-btn-text">确认并保存</text>
+        </view>
+        <view class="confirm-btn disabled" v-else>
+          <text class="confirm-btn-text">保存中...</text>
+        </view>
+        <view class="retry-btn" @click="resetUpload">
+          <text class="retry-btn-text">重新上传</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- Step 3: 完成 -->
+    <view class="section" v-if="step === 3">
+      <view class="success-card">
+        <text class="success-icon">&#x2705;</text>
+        <text class="success-title">报告已保存</text>
+        <text class="success-desc">DAN 测评数据已更新至五维能量体系</text>
+      </view>
+      <view class="action-bar">
+        <view class="upload-btn" @click="goBack">
+          <text class="upload-btn-text">返回</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- Loading -->
+    <view class="loading-overlay" v-if="loading">
+      <view class="loading-box">
+        <text class="loading-text">解析中...</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { danParsePreview, danConfirmReport } from '../../utils/api.js'
+
+export default {
+  data() {
+    return {
+      step: 1,
+      dimension: 'mind',
+      selectedFile: null,
+      previewData: null,
+      draftId: null,
+      loading: false,
+      confirming: false
+    }
+  },
+  methods: {
+    goBack: function() {
+      uni.navigateBack()
+    },
+    chooseFile: function() {
+      var self = this
+      uni.chooseMessageFile({
+        count: 1,
+        type: 'file',
+        extension: ['pdf'],
+        success: function(res) {
+          if (res.tempFiles && res.tempFiles.length > 0) {
+            self.selectedFile = res.tempFiles[0]
+          }
+        },
+        fail: function() {
+          // 降级:使用相册/相机
+          uni.chooseImage({
+            count: 1,
+            success: function(res) {
+              if (res.tempFiles && res.tempFiles.length > 0) {
+                self.selectedFile = res.tempFiles[0]
+              }
+            }
+          })
+        }
+      })
+    },
+    startUpload: function() {
+      if (!this.selectedFile) return
+      var self = this
+      self.loading = true
+
+      var memberId = this.$store.state && this.$store.state.currentChildId
+          ? this.$store.state.currentChildId
+          : uni.getStorageSync('currentChildId')
+
+      danParsePreview(self.selectedFile.path || self.selectedFile.tempFilePath, self.dimension, memberId)
+        .then(function(res) {
+          self.loading = false
+          if (res.code === 200 && res.data) {
+            self.previewData = res.data
+            self.draftId = res.data.draftId
+            self.step = 2
+          } else {
+            uni.showToast({ title: res.message || '解析失败', icon: 'none' })
+          }
+        })
+        .catch(function(err) {
+          self.loading = false
+          uni.showToast({ title: err && err.message ? err.message : '上传失败', icon: 'none' })
+        })
+    },
+    doConfirm: function() {
+      var self = this
+      self.confirming = true
+
+      var memberId = this.$store.state && this.$store.state.currentChildId
+          ? this.$store.state.currentChildId
+          : uni.getStorageSync('currentChildId')
+
+      danConfirmReport({
+        draftId: self.draftId,
+        dimension: self.dimension,
+        memberId: memberId
+      })
+        .then(function(res) {
+          self.confirming = false
+          if (res.code === 200) {
+            self.step = 3
+          } else {
+            uni.showToast({ title: res.message || '保存失败', icon: 'none' })
+          }
+        })
+        .catch(function(err) {
+          self.confirming = false
+          uni.showToast({ title: '保存失败', icon: 'none' })
+        })
+    },
+    resetUpload: function() {
+      this.step = 1
+      this.selectedFile = null
+      this.previewData = null
+      this.draftId = null
+    }
+  }
+}
+</script>
+
+<style>
+.page {
+  min-height: 100vh;
+  background: #F5F5F7;
+  padding-bottom: 40rpx;
+}
+.header {
+  display: flex;
+  align-items: center;
+  padding: 88rpx 32rpx 24rpx;
+  background: linear-gradient(135deg, #667EEA, #764BA2);
+}
+.back-btn {
+  display: flex;
+  align-items: center;
+  margin-right: 24rpx;
+  padding: 8rpx 16rpx;
+}
+.back-icon {
+  font-size: 36rpx;
+  color: #fff;
+  margin-right: 8rpx;
+}
+.back-text {
+  font-size: 28rpx;
+  color: #fff;
+}
+.header-title {
+  font-size: 36rpx;
+  font-weight: 700;
+  color: #fff;
+}
+.section {
+  margin: 24rpx 24rpx 0;
+}
+.dimension-select {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 32rpx;
+}
+.dimension-label {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #333;
+  margin-bottom: 20rpx;
+  display: block;
+}
+.dimension-options {
+  display: flex;
+  gap: 20rpx;
+}
+.dimension-option {
+  flex: 1;
+  padding: 24rpx;
+  border-radius: 12rpx;
+  background: #F5F5F7;
+  text-align: center;
+  border: 2rpx solid transparent;
+}
+.dimension-option.active {
+  background: #EEF2FF;
+  border-color: #6366F1;
+}
+.dimension-name {
+  font-size: 28rpx;
+  color: #333;
+  font-weight: 500;
+}
+.dimension-option.active .dimension-name {
+  color: #6366F1;
+}
+.upload-area {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 60rpx 32rpx;
+  text-align: center;
+  margin-top: 24rpx;
+  border: 2rpx dashed #D1D5DB;
+}
+.upload-icon {
+  font-size: 64rpx;
+  margin-bottom: 16rpx;
+}
+.upload-text {
+  font-size: 30rpx;
+  color: #333;
+  display: block;
+  margin-bottom: 8rpx;
+}
+.upload-hint {
+  font-size: 24rpx;
+  color: #999;
+}
+.action-bar {
+  margin-top: 32rpx;
+  display: flex;
+  gap: 20rpx;
+}
+.upload-btn {
+  flex: 1;
+  background: #6366F1;
+  border-radius: 12rpx;
+  padding: 24rpx;
+  text-align: center;
+}
+.upload-btn-text {
+  font-size: 30rpx;
+  color: #fff;
+  font-weight: 600;
+}
+.preview-header {
+  margin-bottom: 24rpx;
+}
+.preview-title {
+  font-size: 32rpx;
+  font-weight: 700;
+  color: #333;
+  display: block;
+}
+.preview-desc {
+  font-size: 24rpx;
+  color: #999;
+  margin-top: 8rpx;
+}
+.preview-card {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+}
+.preview-row {
+  display: flex;
+  justify-content: space-between;
+  padding: 12rpx 0;
+  border-bottom: 1rpx solid #F3F4F6;
+}
+.preview-row:last-child {
+  border-bottom: none;
+}
+.preview-label {
+  font-size: 26rpx;
+  color: #666;
+}
+.preview-value {
+  font-size: 26rpx;
+  color: #333;
+  font-weight: 500;
+}
+.items-section {
+  margin-top: 24rpx;
+}
+.items-title {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #333;
+  margin-bottom: 16rpx;
+  display: block;
+}
+.item-card {
+  background: #fff;
+  border-radius: 12rpx;
+  padding: 20rpx;
+  margin-bottom: 12rpx;
+}
+.item-header {
+  display: flex;
+  justify-content: space-between;
+  margin-bottom: 12rpx;
+}
+.item-name {
+  font-size: 26rpx;
+  color: #333;
+  font-weight: 500;
+}
+.item-category {
+  font-size: 22rpx;
+  color: #999;
+  background: #F3F4F6;
+  padding: 4rpx 12rpx;
+  border-radius: 8rpx;
+}
+.item-value-bar {
+  height: 16rpx;
+  background: #F3F4F6;
+  border-radius: 8rpx;
+  overflow: hidden;
+}
+.item-value-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #667EEA, #764BA2);
+  border-radius: 8rpx;
+}
+.item-value-text {
+  font-size: 24rpx;
+  color: #666;
+  margin-top: 8rpx;
+  display: block;
+  text-align: right;
+}
+.suggestions-card {
+  background: #FFFBEB;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-top: 24rpx;
+}
+.suggestions-title {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #92400E;
+  display: block;
+  margin-bottom: 12rpx;
+}
+.suggestions-text {
+  font-size: 26rpx;
+  color: #78350F;
+  line-height: 1.6;
+}
+.confirm-btn {
+  flex: 1;
+  background: #10B981;
+  border-radius: 12rpx;
+  padding: 24rpx;
+  text-align: center;
+}
+.confirm-btn.disabled {
+  opacity: 0.6;
+}
+.confirm-btn-text {
+  font-size: 30rpx;
+  color: #fff;
+  font-weight: 600;
+}
+.retry-btn {
+  padding: 24rpx 32rpx;
+  border-radius: 12rpx;
+  border: 2rpx solid #D1D5DB;
+  text-align: center;
+}
+.retry-btn-text {
+  font-size: 28rpx;
+  color: #666;
+}
+.success-card {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 60rpx 32rpx;
+  text-align: center;
+  margin-top: 120rpx;
+}
+.success-icon {
+  font-size: 80rpx;
+  display: block;
+  margin-bottom: 24rpx;
+}
+.success-title {
+  font-size: 36rpx;
+  font-weight: 700;
+  color: #10B981;
+  display: block;
+  margin-bottom: 12rpx;
+}
+.success-desc {
+  font-size: 28rpx;
+  color: #666;
+}
+.loading-overlay {
+  position: fixed;
+  top: 0; left: 0; right: 0; bottom: 0;
+  background: rgba(0,0,0,0.5);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 999;
+}
+.loading-box {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 48rpx 64rpx;
+}
+.loading-text {
+  font-size: 30rpx;
+  color: #333;
+}
+</style>

+ 49 - 2
cfc-frontend/utils/api.js

@@ -1705,6 +1705,53 @@ export const tianpanDailyFortune = (data) => {
   return request('/api/tianpan/daily-fortune', 'POST', data)
   return request('/api/tianpan/daily-fortune', 'POST', data)
 }
 }
 
 
-export const tianpanRelatedItems = (data) => {
-  return request('/api/tianpan/related-items', 'POST', data)
+export const tianpanRelatedItems = (data) => {
+  return request('/api/tianpan/related-items', 'POST', data)
+}
+
+// ===== DAN 测评报告上传 =====
+
+/**
+ * Phase 1: 上传 DAN 报告 PDF → 解析 → 返回预览草稿
+ */
+export const danParsePreview = (filePath, dimension, memberId) => {
+  return new Promise((resolve, reject) => {
+    var token = uni.getStorageSync('token')
+    uni.uploadFile({
+      url: BASE_URL + '/api/dan-assessment/report/parse-preview',
+      filePath: filePath,
+      name: 'file',
+      formData: {
+        dimension: dimension,
+        memberId: String(memberId || '')
+      },
+      header: {
+        'Authorization': token ? 'Bearer ' + token : ''
+      },
+      success: function(res) {
+        var data
+        try { data = JSON.parse(res.data) } catch (e) { data = res.data }
+        if (data && data.code === 200) {
+          resolve(data)
+        } else {
+          reject(data || { message: '解析失败' })
+        }
+      },
+      fail: function(err) { reject(err) }
+    })
+  })
+}
+
+/**
+ * Phase 2: 确认并保存 DAN 报告
+ */
+export const danConfirmReport = (data) => {
+  return request('/api/dan-assessment/report/confirm', 'POST', data)
+}
+
+/**
+ * 获取某孩子某维度的 DAN 报告列表
+ */
+export const getDanReportByChild = (memberId, dimension) => {
+  return request('/api/dan-assessment/report/child', 'POST', { memberId, dimension })
 }
 }