report-upload.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722
  1. <template>
  2. <view class="upload-container">
  3. <!-- 顶部导航 -->
  4. <view class="nav-bar">
  5. <text class="nav-back" @click="goBack">‹ 返回</text>
  6. <text class="nav-title">上传健康报告</text>
  7. <view class="nav-placeholder"></view>
  8. </view>
  9. <!-- 说明提示 -->
  10. <view class="tip-banner">
  11. <text class="tip-icon">📋</text>
  12. <text class="tip-text">上传体检报告或肠道检测报告,AI 将为您解读并提供个性化营养方案</text>
  13. </view>
  14. <!-- PDF解析预览(提取到信息时显示) -->
  15. <view class="parsed-preview" v-if="parsedInfo">
  16. <view class="parsed-title">PDF解析结果</view>
  17. <view class="parsed-row" v-if="parsedInfo.personName">
  18. <text class="parsed-label">被检测人</text>
  19. <text class="parsed-value">{{ parsedInfo.personName }}</text>
  20. </view>
  21. <view class="parsed-row" v-if="parsedInfo.reportNumber">
  22. <text class="parsed-label">报告编号</text>
  23. <text class="parsed-value">{{ parsedInfo.reportNumber }}</text>
  24. </view>
  25. <view class="parsed-row" v-if="parsedInfo.reportDate">
  26. <text class="parsed-label">报告日期</text>
  27. <text class="parsed-value">{{ parsedInfo.reportDate }}</text>
  28. </view>
  29. <!-- 匹配状态 -->
  30. <view class="match-status" v-if="parsedInfo.needManualSelect">
  31. <text class="match-icon">⚠️</text>
  32. <text class="match-text">无法自动匹配到家庭成员,请手动选择</text>
  33. </view>
  34. <view class="match-status success" v-else-if="parsedInfo.matchedMemberName">
  35. <text class="match-icon">✅</text>
  36. <text class="match-text">已自动匹配: {{ parsedInfo.matchedMemberName }}</text>
  37. </view>
  38. </view>
  39. <!-- 家庭成员选择器(需要手动选择时显示) -->
  40. <view class="form-item member-selector" v-if="showMemberSelector">
  41. <text class="form-label">选择成员</text>
  42. <view class="member-list">
  43. <view
  44. class="member-item"
  45. v-for="member in familyMembers"
  46. :key="member.id"
  47. :class="{ 'member-selected': selectedMemberId === member.userId }"
  48. @click="selectMember(member)"
  49. >
  50. <text class="member-avatar">{{ member.nickname ? member.nickname.substring(0, 1) : '?' }}</text>
  51. <text class="member-name">{{ member.nickname || '未知' }}</text>
  52. </view>
  53. </view>
  54. </view>
  55. <!-- 表单 -->
  56. <view class="form-section">
  57. <!-- 报告类型 -->
  58. <view class="form-item">
  59. <text class="form-label">报告类型</text>
  60. <view class="type-selector">
  61. <view
  62. class="type-option"
  63. :class="{ 'type-selected': form.reportType === 'physical_exam' }"
  64. @click="form.reportType = 'physical_exam'"
  65. >
  66. <text class="type-icon">🏥</text>
  67. <text class="type-name">体检报告</text>
  68. </view>
  69. <view
  70. class="type-option"
  71. :class="{ 'type-selected': form.reportType === 'gut_flora' }"
  72. @click="form.reportType = 'gut_flora'"
  73. >
  74. <text class="type-icon">🔬</text>
  75. <text class="type-name">肠道检测</text>
  76. </view>
  77. </view>
  78. </view>
  79. <!-- 文件上传 -->
  80. <view class="form-item">
  81. <text class="form-label">上传PDF文件(选填)</text>
  82. <view class="file-upload-area" @click="pickFile">
  83. <view class="file-upload-placeholder" v-if="!selectedFile">
  84. <text class="file-upload-icon">📄</text>
  85. <text class="file-upload-text">点击选择PDF文件</text>
  86. <text class="file-upload-hint">支持从聊天记录中选择</text>
  87. </view>
  88. <view class="file-uploading" v-else>
  89. <text class="file-upload-icon">⏳</text>
  90. <text class="file-upload-text">正在解析...</text>
  91. </view>
  92. </view>
  93. <!-- 已选择的文件 -->
  94. <view class="file-info" v-if="selectedFile && !parsingPreview">
  95. <text class="file-name">{{ selectedFile.name }}</text>
  96. <text class="file-size">{{ formatFileSize(selectedFile.size) }}</text>
  97. <text class="file-remove" @click.stop="removeFile">移除</text>
  98. </view>
  99. </view>
  100. <!-- 报告日期 -->
  101. <view class="form-item">
  102. <text class="form-label">报告日期</text>
  103. <view class="date-display" @click="showDatePicker = true">
  104. <text>{{ form.reportDate || '请选择日期' }}</text>
  105. <text class="date-arrow">›</text>
  106. </view>
  107. </view>
  108. <!-- 综合评分 -->
  109. <view class="form-item">
  110. <text class="form-label">综合评分(选填)</text>
  111. <view class="score-input-wrap">
  112. <slider
  113. class="score-slider"
  114. min="0"
  115. max="100"
  116. step="1"
  117. :value="form.overallScore"
  118. @changing="onScoreChanging"
  119. @change="onScoreChange"
  120. activeColor="#5B9BD5"
  121. backgroundColor="#E2E8F0"
  122. block-size="16"
  123. show-value
  124. />
  125. <text class="score-value">{{ form.overallScore || 0 }}分</text>
  126. </view>
  127. </view>
  128. <!-- 备注 -->
  129. <view class="form-item">
  130. <text class="form-label">备注(选填)</text>
  131. <textarea
  132. class="note-input"
  133. v-model="form.description"
  134. placeholder="请输入报告摘要或备注信息..."
  135. maxlength="500"
  136. />
  137. </view>
  138. <!-- 提交按钮 -->
  139. <view class="submit-btn" :class="{ 'btn-disabled': !canSubmit }" @click="doSubmit">
  140. <text v-if="submitting">提交中...</text>
  141. <text v-else>提交报告</text>
  142. </view>
  143. </view>
  144. <!-- 底部占位 -->
  145. <view class="bottom-spacer"></view>
  146. </view>
  147. </template>
  148. <script>
  149. import { createHealthReport, parseReportPreview } from '../../utils/api.js'
  150. import config from '@/config.js'
  151. export default {
  152. data() {
  153. return {
  154. childId: null,
  155. familyId: null,
  156. form: {
  157. reportType: 'physical_exam',
  158. reportDate: '',
  159. overallScore: 0,
  160. description: ''
  161. },
  162. showDatePicker: false,
  163. submitting: false,
  164. selectedFile: null,
  165. parsingPreview: false,
  166. parsedInfo: null,
  167. showMemberSelector: false,
  168. selectedMemberId: null,
  169. familyMembers: []
  170. }
  171. },
  172. computed: {
  173. canSubmit() {
  174. if (this.selectedFile) {
  175. return this.parsedInfo && this.parsedInfo.draftId && !this.parsedInfo.needManualSelect
  176. }
  177. return this.form.reportType && this.form.reportDate
  178. }
  179. },
  180. onLoad: function(options) {
  181. this.childId = options.childId ? parseInt(options.childId) : null
  182. // 默认设为今天
  183. var now = new Date()
  184. var y = now.getFullYear()
  185. var m = ('' + (now.getMonth() + 1)).padStart(2, '0')
  186. var d = ('' + now.getDate()).padStart(2, '0')
  187. this.form.reportDate = y + '-' + m + '-' + d
  188. },
  189. onShow: function() {
  190. // 每次显示时从store获取familyId和家庭成员
  191. this.loadFamilyInfo()
  192. },
  193. methods: {
  194. loadFamilyInfo() {
  195. var self = this
  196. // 从Vuex获取familyId和家庭成员
  197. var userInfo = uni.getStorageSync('userInfo')
  198. if (userInfo) {
  199. try {
  200. var info = typeof userInfo === 'string' ? JSON.parse(userInfo) : userInfo
  201. this.familyId = info.familyId || null
  202. // 加载家庭成员列表(从本地存储或请求)
  203. var storedMembers = uni.getStorageSync('familyMembers')
  204. if (storedMembers) {
  205. try {
  206. this.familyMembers = typeof storedMembers === 'string' ? JSON.parse(storedMembers) : storedMembers
  207. } catch (e) {
  208. this.familyMembers = []
  209. }
  210. }
  211. } catch (e) {
  212. console.log('解析userInfo失败', e)
  213. }
  214. }
  215. },
  216. onScoreChanging(e) {
  217. this.form.overallScore = e.detail.value
  218. },
  219. onScoreChange(e) {
  220. this.form.overallScore = e.detail.value
  221. },
  222. pickFile() {
  223. var self = this
  224. // 重置解析状态
  225. this.parsedInfo = null
  226. this.showMemberSelector = false
  227. this.selectedMemberId = null
  228. // 从微信聊天记录中选择文件
  229. uni.chooseMessageFile({
  230. count: 1,
  231. type: 'file',
  232. extension: ['pdf'],
  233. success: function(res) {
  234. var files = res.tempFiles
  235. if (files && files.length > 0) {
  236. self.selectedFile = files[0]
  237. // 如果选择了文件且有familyId,先预览解析
  238. if (self.familyId) {
  239. self.previewParse()
  240. }
  241. }
  242. },
  243. fail: function(e) {
  244. console.log('选择文件取消或失败', e)
  245. }
  246. })
  247. },
  248. previewParse() {
  249. var self = this
  250. if (!this.selectedFile || !this.familyId) return
  251. this.parsingPreview = true
  252. parseReportPreview(this.selectedFile.path, this.childId, this.familyId).then(function(res) {
  253. var detail = res.data
  254. self.parsedInfo = {
  255. draftId: detail.draftId,
  256. personName: detail.extractedName || detail.matchedMemberName,
  257. reportNumber: detail.extractedReportNumber,
  258. reportDate: detail.extractedReportDate,
  259. needManualSelect: detail.needBind
  260. }
  261. if (detail.needBind) {
  262. self.showMemberSelector = true
  263. self.loadFamilyMembers()
  264. }
  265. }).catch(function(e) {
  266. var msg = (e && e.message) || '解析失败'
  267. uni.showToast({ title: msg, icon: 'none' })
  268. }).finally(function() {
  269. self.parsingPreview = false
  270. })
  271. },
  272. loadFamilyMembers() {
  273. var self = this
  274. // 从Vuex store获取家庭成员
  275. var store = uni.getStorageSync('familyChildren')
  276. if (store) {
  277. try {
  278. this.familyMembers = typeof store === 'string' ? JSON.parse(store) : store
  279. } catch (e) {
  280. this.familyMembers = []
  281. }
  282. }
  283. },
  284. selectMember(member) {
  285. this.selectedMemberId = member.userId || member.id
  286. this.parsedInfo = this.parsedInfo || {}
  287. this.parsedInfo.needManualSelect = false
  288. this.showMemberSelector = false
  289. },
  290. removeFile() {
  291. this.selectedFile = null
  292. this.parsedInfo = null
  293. this.showMemberSelector = false
  294. this.selectedMemberId = null
  295. },
  296. formatFileSize(bytes) {
  297. if (!bytes) return ''
  298. if (bytes < 1024) return bytes + 'B'
  299. if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'KB'
  300. return (bytes / 1024 / 1024).toFixed(1) + 'MB'
  301. },
  302. goBack() {
  303. uni.navigateBack()
  304. },
  305. doSubmit() {
  306. if (this.submitting) return
  307. var self = this
  308. if (self.selectedFile) {
  309. if (self.parsedInfo && self.parsedInfo.needManualSelect && !self.selectedMemberId) {
  310. uni.showToast({ title: '请先选择成员', icon: 'none' })
  311. return
  312. }
  313. if (!self.parsedInfo || !self.parsedInfo.draftId) {
  314. uni.showToast({ title: '请先等待解析完成', icon: 'none' })
  315. return
  316. }
  317. var payloadStr = encodeURIComponent(JSON.stringify(self.parsedInfo.payload || {}))
  318. var params = 'draftId=' + self.parsedInfo.draftId
  319. + '&extractedName=' + encodeURIComponent(self.parsedInfo.personName || '')
  320. + '&payload=' + payloadStr
  321. + '&needBind=' + (self.parsedInfo.needManualSelect ? 'true' : 'false')
  322. if (self.parsedInfo.matchedMemberId) {
  323. params += '&matchedMemberId=' + self.parsedInfo.matchedMemberId
  324. }
  325. if (self.childId) {
  326. params += '&childId=' + self.childId
  327. }
  328. if (self.familyId) {
  329. params += '&familyId=' + self.familyId
  330. }
  331. uni.navigateTo({ url: '/pages/health/report-confirm?' + params })
  332. return
  333. }
  334. self.submitting = true
  335. uni.showLoading({ title: '提交中...' })
  336. createHealthReport({
  337. reportType: self.form.reportType,
  338. reportDate: self.form.reportDate,
  339. overallScore: self.form.overallScore,
  340. description: self.form.description
  341. }).then(function(res) {
  342. uni.hideLoading()
  343. if (res.code === 200) {
  344. uni.showToast({ title: '提交成功', icon: 'success' })
  345. var reportId = res.data && res.data.id
  346. if (reportId) {
  347. setTimeout(function() {
  348. if (self.form.reportType === 'gut_flora') {
  349. uni.redirectTo({
  350. url: '/pages/health/report-survey?reportId=' + reportId + '&childId=' + (self.childId || '')
  351. })
  352. } else {
  353. uni.redirectTo({ url: '/pages/body/health-report?reportId=' + reportId })
  354. }
  355. }, 500)
  356. } else {
  357. setTimeout(function() {
  358. uni.navigateBack()
  359. }, 500)
  360. }
  361. } else {
  362. uni.showToast({ title: res.message || '提交失败', icon: 'none' })
  363. }
  364. }).catch(function(e) {
  365. uni.hideLoading()
  366. uni.showToast({ title: '提交失败,请重试', icon: 'none' })
  367. console.log('创建报告失败', e)
  368. }).finally(function() {
  369. self.submitting = false
  370. })
  371. }
  372. }
  373. }
  374. </script>
  375. <style scoped>
  376. .upload-container {
  377. min-height: 100vh;
  378. background: #F5F7FA;
  379. }
  380. /* ===== 导航栏 ===== */
  381. .nav-bar {
  382. position: sticky;
  383. top: 0;
  384. background: #fff;
  385. display: flex;
  386. flex-direction: row;
  387. align-items: center;
  388. justify-content: space-between;
  389. padding: 20rpx 30rpx;
  390. box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06);
  391. z-index: 100;
  392. }
  393. .nav-back {
  394. font-size: 32rpx;
  395. color: #5B9BD5;
  396. }
  397. .nav-title {
  398. font-size: 32rpx;
  399. font-weight: bold;
  400. color: #333;
  401. }
  402. .nav-placeholder {
  403. width: 80rpx;
  404. }
  405. /* ===== 提示条 ===== */
  406. .tip-banner {
  407. display: flex;
  408. flex-direction: row;
  409. align-items: center;
  410. background: #E3F2FD;
  411. margin: 20rpx 30rpx;
  412. padding: 20rpx 24rpx;
  413. border-radius: 16rpx;
  414. }
  415. .tip-icon {
  416. font-size: 32rpx;
  417. margin-right: 16rpx;
  418. flex-shrink: 0;
  419. }
  420. .tip-text {
  421. font-size: 24rpx;
  422. color: #1565C0;
  423. line-height: 1.5;
  424. }
  425. /* ===== 表单 ===== */
  426. .form-section {
  427. margin: 0 30rpx;
  428. }
  429. .form-item {
  430. background: #fff;
  431. border-radius: 16rpx;
  432. padding: 24rpx;
  433. margin-bottom: 20rpx;
  434. }
  435. .form-label {
  436. font-size: 26rpx;
  437. font-weight: bold;
  438. color: #333;
  439. display: block;
  440. margin-bottom: 16rpx;
  441. }
  442. /* 类型选择 */
  443. .type-selector {
  444. display: flex;
  445. flex-direction: row;
  446. gap: 20rpx;
  447. }
  448. .type-option {
  449. flex: 1;
  450. display: flex;
  451. flex-direction: column;
  452. align-items: center;
  453. padding: 24rpx;
  454. border-radius: 16rpx;
  455. background: #F5F7FA;
  456. border: 2rpx solid transparent;
  457. }
  458. .type-selected {
  459. background: #E3F2FD;
  460. border-color: #5B9BD5;
  461. }
  462. .type-icon {
  463. font-size: 40rpx;
  464. margin-bottom: 8rpx;
  465. }
  466. .type-name {
  467. font-size: 26rpx;
  468. color: #333;
  469. font-weight: 500;
  470. }
  471. /* 文件上传 */
  472. .file-upload-area {
  473. border: 2rpx dashed #CBD5E1;
  474. border-radius: 16rpx;
  475. padding: 32rpx 20rpx;
  476. text-align: center;
  477. background: #FAFBFC;
  478. cursor: pointer;
  479. }
  480. .file-upload-area:active {
  481. background: #F1F5F9;
  482. }
  483. .file-upload-placeholder {
  484. display: flex;
  485. flex-direction: column;
  486. align-items: center;
  487. }
  488. .file-upload-icon {
  489. font-size: 48rpx;
  490. margin-bottom: 12rpx;
  491. }
  492. .file-upload-text {
  493. font-size: 28rpx;
  494. color: #5B9BD5;
  495. font-weight: 500;
  496. }
  497. .file-upload-hint {
  498. font-size: 22rpx;
  499. color: #94A3B8;
  500. margin-top: 8rpx;
  501. }
  502. .file-info {
  503. display: flex;
  504. flex-direction: row;
  505. align-items: center;
  506. background: #E3F2FD;
  507. border-radius: 12rpx;
  508. padding: 16rpx 20rpx;
  509. margin-top: 16rpx;
  510. }
  511. .file-name {
  512. flex: 1;
  513. font-size: 24rpx;
  514. color: #1565C0;
  515. overflow: hidden;
  516. text-overflow: ellipsis;
  517. white-space: nowrap;
  518. }
  519. .file-size {
  520. font-size: 22rpx;
  521. color: #64748B;
  522. margin: 0 16rpx;
  523. flex-shrink: 0;
  524. }
  525. .file-remove {
  526. font-size: 24rpx;
  527. color: #EF4444;
  528. flex-shrink: 0;
  529. padding: 4rpx 8rpx;
  530. }
  531. /* 日期 */
  532. .date-display {
  533. display: flex;
  534. flex-direction: row;
  535. align-items: center;
  536. justify-content: space-between;
  537. padding: 16rpx 20rpx;
  538. background: #F5F7FA;
  539. border-radius: 12rpx;
  540. font-size: 28rpx;
  541. color: #333;
  542. }
  543. .date-arrow {
  544. font-size: 32rpx;
  545. color: #999;
  546. }
  547. /* 评分滑块 */
  548. .score-input-wrap {
  549. display: flex;
  550. flex-direction: row;
  551. align-items: center;
  552. }
  553. .score-slider {
  554. flex: 1;
  555. }
  556. .score-value {
  557. font-size: 28rpx;
  558. font-weight: bold;
  559. color: #5B9BD5;
  560. margin-left: 16rpx;
  561. min-width: 80rpx;
  562. text-align: center;
  563. }
  564. /* 备注 */
  565. .note-input {
  566. width: 100%;
  567. min-height: 160rpx;
  568. background: #F5F7FA;
  569. border-radius: 12rpx;
  570. padding: 20rpx;
  571. font-size: 26rpx;
  572. color: #333;
  573. box-sizing: border-box;
  574. }
  575. /* 提交按钮 */
  576. .submit-btn {
  577. background: linear-gradient(135deg, #5B9BD5, #3A7CC4);
  578. color: #fff;
  579. font-size: 30rpx;
  580. font-weight: bold;
  581. text-align: center;
  582. padding: 28rpx;
  583. border-radius: 16rpx;
  584. margin-top: 10rpx;
  585. }
  586. .submit-btn:active {
  587. opacity: 0.85;
  588. }
  589. .btn-disabled {
  590. opacity: 0.5;
  591. }
  592. /* ===== PDF解析预览 ===== */
  593. .parsed-preview {
  594. background: #E8F5E9;
  595. border-radius: 16rpx;
  596. margin: 0 30rpx 20rpx;
  597. padding: 20rpx 24rpx;
  598. border: 2rpx solid #A5D6A7;
  599. }
  600. .parsed-title {
  601. font-size: 24rpx;
  602. font-weight: bold;
  603. color: #2E7D32;
  604. margin-bottom: 12rpx;
  605. }
  606. .parsed-row {
  607. display: flex;
  608. flex-direction: row;
  609. align-items: center;
  610. margin-bottom: 8rpx;
  611. }
  612. .parsed-label {
  613. font-size: 24rpx;
  614. color: #388E3C;
  615. width: 140rpx;
  616. flex-shrink: 0;
  617. }
  618. .parsed-value {
  619. font-size: 24rpx;
  620. color: #1B5E20;
  621. font-weight: 500;
  622. }
  623. .match-status {
  624. display: flex;
  625. flex-direction: row;
  626. align-items: center;
  627. margin-top: 12rpx;
  628. padding: 12rpx 16rpx;
  629. background: #FFF3E0;
  630. border-radius: 8rpx;
  631. }
  632. .match-status.success {
  633. background: #E8F5E9;
  634. }
  635. .match-icon {
  636. font-size: 24rpx;
  637. margin-right: 8rpx;
  638. }
  639. .match-text {
  640. font-size: 24rpx;
  641. color: #E65100;
  642. }
  643. .match-status.success .match-text {
  644. color: #2E7D32;
  645. }
  646. /* ===== 家庭成员选择器 ===== */
  647. .member-selector {
  648. margin: 0 30rpx 20rpx;
  649. }
  650. .member-list {
  651. display: flex;
  652. flex-direction: row;
  653. flex-wrap: wrap;
  654. gap: 16rpx;
  655. }
  656. .member-item {
  657. display: flex;
  658. flex-direction: column;
  659. align-items: center;
  660. padding: 20rpx 24rpx;
  661. background: #F5F7FA;
  662. border-radius: 16rpx;
  663. border: 2rpx solid transparent;
  664. min-width: 120rpx;
  665. }
  666. .member-selected {
  667. background: #E3F2FD;
  668. border-color: #5B9BD5;
  669. }
  670. .member-avatar {
  671. width: 64rpx;
  672. height: 64rpx;
  673. border-radius: 50%;
  674. background: #5B9BD5;
  675. color: #fff;
  676. font-size: 28rpx;
  677. font-weight: bold;
  678. display: flex;
  679. align-items: center;
  680. justify-content: center;
  681. margin-bottom: 8rpx;
  682. }
  683. .member-name {
  684. font-size: 24rpx;
  685. color: #333;
  686. }
  687. /* ===== 底部 ===== */
  688. .bottom-spacer {
  689. height: 60rpx;
  690. }
  691. </style>