report-upload.vue 33 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163
  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 || member.id) }"
  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 class="add-member-btn" v-if="parsedInfo && parsedInfo.personName" @click="chooseRelationAndAdd">
  55. <text class="add-member-text">+ 将「{{ parsedInfo.personName }}」添加为新成员</text>
  56. </view>
  57. </view>
  58. <!-- 表单 -->
  59. <view class="form-section">
  60. <!-- 报告类型 -->
  61. <view class="form-item">
  62. <text class="form-label">报告类型</text>
  63. <view class="type-selector">
  64. <view
  65. class="type-option"
  66. :class="{ 'type-selected': form.reportType === 'physical_exam' }"
  67. @click="selectType('physical_exam')"
  68. >
  69. <text class="type-icon">🏥</text>
  70. <text class="type-name">体检报告</text>
  71. </view>
  72. <view
  73. class="type-option"
  74. :class="{ 'type-selected': form.reportType === 'gut_flora' }"
  75. @click="selectType('gut_flora')"
  76. >
  77. <text class="type-icon">🔬</text>
  78. <text class="type-name">肠道检测</text>
  79. </view>
  80. <view
  81. class="type-option"
  82. :class="{ 'type-selected': form.reportType === 'tongue' }"
  83. @click="selectType('tongue')"
  84. >
  85. <text class="type-icon">👅</text>
  86. <text class="type-name">舌诊拍照</text>
  87. </view>
  88. </view>
  89. </view>
  90. <!-- 购买推荐 -->
  91. <view class="purchase-section" v-if="recommendedProducts.length > 0">
  92. <view class="purchase-header">
  93. <text class="purchase-icon">📦</text>
  94. <text class="purchase-title">为家人购买检测服务</text>
  95. </view>
  96. <scroll-view class="purchase-scroll" scroll-x enable-flex show-scrollbar="false">
  97. <view
  98. class="purchase-card"
  99. v-for="item in recommendedProducts"
  100. :key="item.id"
  101. @click="goToProduct(item.id)"
  102. >
  103. <image class="purchase-cover" :src="item.coverImage || '/static/default-product.png'" mode="aspectFill" />
  104. <text class="purchase-name">{{ item.name }}</text>
  105. <text class="purchase-price">{{ formatPriceWithSymbol(item.price) }}</text>
  106. <view class="purchase-btn">
  107. <text class="purchase-btn-text">去购买</text>
  108. </view>
  109. </view>
  110. </scroll-view>
  111. </view>
  112. <!-- 舌诊拍照(仅 tongue 类型显示) -->
  113. <view class="form-item" v-if="isTongue">
  114. <text class="form-label">拍摄舌象</text>
  115. <view class="photo-area">
  116. <view class="photo-placeholder" v-if="!photoPath && !analyzing" @click="takePhoto">
  117. <text class="photo-icon">📷</text>
  118. <text class="photo-text">点击拍摄舌象</text>
  119. <text class="photo-sub">自然光线下拍摄舌面,避免有色光线</text>
  120. </view>
  121. <image class="photo-preview" v-else-if="photoPath && !analyzing" :src="photoPath" mode="aspectFit" @click="takePhoto"></image>
  122. <view class="photo-analyzing" v-else>
  123. <text class="analyzing-text">AI 舌象分析中...</text>
  124. </view>
  125. </view>
  126. <view class="photo-retake" v-if="photoPath && !analyzing">
  127. <text @click="takePhoto">点击重新拍摄</text>
  128. </view>
  129. </view>
  130. <!-- PDF文件上传(非舌诊类型显示) -->
  131. <view class="form-item" v-if="!isTongue">
  132. <text class="form-label">上传PDF文件(选填)</text>
  133. <view class="file-upload-area" @click="pickFile">
  134. <view class="file-upload-placeholder" v-if="!selectedFile">
  135. <text class="file-upload-icon">📄</text>
  136. <text class="file-upload-text">点击选择PDF文件</text>
  137. <text class="file-upload-hint">支持从聊天记录中选择</text>
  138. </view>
  139. <view class="file-uploading" v-else>
  140. <text class="file-upload-icon">⏳</text>
  141. <text class="file-upload-text">正在解析...</text>
  142. </view>
  143. </view>
  144. <!-- 已选择的文件 -->
  145. <view class="file-info" v-if="selectedFile && !parsingPreview">
  146. <text class="file-name">{{ selectedFile.name }}</text>
  147. <text class="file-size">{{ formatFileSize(selectedFile.size) }}</text>
  148. <text class="file-remove" @click.stop="removeFile">移除</text>
  149. </view>
  150. </view>
  151. <!-- 报告日期 -->
  152. <view class="form-item">
  153. <text class="form-label">报告日期</text>
  154. <view class="date-display" @click="showDatePicker = true">
  155. <text>{{ form.reportDate || '请选择日期' }}</text>
  156. <text class="date-arrow">›</text>
  157. </view>
  158. </view>
  159. <!-- 综合评分 -->
  160. <view class="form-item">
  161. <text class="form-label">综合评分(选填)</text>
  162. <view class="score-input-wrap">
  163. <slider
  164. class="score-slider"
  165. min="0"
  166. max="100"
  167. step="1"
  168. :value="form.overallScore"
  169. @changing="onScoreChanging"
  170. @change="onScoreChange"
  171. activeColor="#5B9BD5"
  172. backgroundColor="#E2E8F0"
  173. block-size="16"
  174. show-value
  175. />
  176. <text class="score-value">{{ form.overallScore || 0 }}分</text>
  177. </view>
  178. </view>
  179. <!-- 备注 -->
  180. <view class="form-item">
  181. <text class="form-label">备注(选填)</text>
  182. <textarea
  183. class="note-input"
  184. v-model="form.description"
  185. placeholder="请输入报告摘要或备注信息..."
  186. maxlength="500"
  187. />
  188. </view>
  189. <!-- 提交按钮 -->
  190. <view class="submit-btn" :class="{ 'btn-disabled': !canSubmit }" @click="doSubmit">
  191. <text v-if="analyzing">分析中...</text>
  192. <text v-else-if="isTongue">开始分析</text>
  193. <text v-else-if="submitting">提交中...</text>
  194. <text v-else>提交报告</text>
  195. </view>
  196. </view>
  197. <!-- 底部占位 -->
  198. <view class="bottom-spacer"></view>
  199. </view>
  200. </template>
  201. <script>
  202. import { createHealthReport, parseReportPreview, productList, getFamilyMembers, addFamilyMember } from '../../utils/api.js'
  203. import config from '@/config.js'
  204. // 报告类型 → 维度映射(用于推荐商品)
  205. var REPORT_DOMAIN_MAP = {
  206. physical_exam: 'body',
  207. gut_flora: 'body',
  208. tongue: 'body'
  209. }
  210. export default {
  211. data() {
  212. return {
  213. childId: null,
  214. familyId: null,
  215. form: {
  216. reportType: 'physical_exam',
  217. reportDate: '',
  218. overallScore: 0,
  219. description: ''
  220. },
  221. showDatePicker: false,
  222. submitting: false,
  223. analyzing: false,
  224. selectedFile: null,
  225. parsingPreview: false,
  226. parsedInfo: null,
  227. showMemberSelector: false,
  228. selectedMemberId: null,
  229. familyMembers: [],
  230. // 舌诊
  231. photoPath: null,
  232. tongueResult: null,
  233. // 购买推荐
  234. recommendedProducts: [],
  235. loadingProducts: false
  236. }
  237. },
  238. computed: {
  239. isTongue() {
  240. return this.form.reportType === 'tongue'
  241. },
  242. canSubmit() {
  243. if (this.isTongue) return this.photoPath && !this.analyzing
  244. if (this.selectedFile) {
  245. return this.parsedInfo && this.parsedInfo.draftId && !this.parsedInfo.needManualSelect
  246. }
  247. return this.form.reportType && this.form.reportDate
  248. }
  249. },
  250. onLoad: function(options) {
  251. this.childId = options.childId ? parseInt(options.childId) : null
  252. if (options.reportType && ['physical_exam', 'gut_flora', 'tongue'].indexOf(options.reportType) !== -1) {
  253. this.form.reportType = options.reportType
  254. }
  255. this.loadRecommendProducts(this.form.reportType)
  256. // 不默认填充日期 — 待解析后从报告中提取
  257. this.form.reportDate = ''
  258. },
  259. onShow: function() {
  260. // 每次显示时从store获取familyId和家庭成员
  261. this.loadFamilyInfo()
  262. },
  263. methods: {
  264. selectType(type) {
  265. if (this.form.reportType === type) return
  266. this.form.reportType = type
  267. this.loadRecommendProducts(type)
  268. // 切类型时清理其他类型的状态
  269. if (type === 'tongue') {
  270. this.selectedFile = null
  271. this.parsedInfo = null
  272. } else {
  273. this.photoPath = null
  274. this.tongueResult = null
  275. this.analyzing = false
  276. }
  277. },
  278. loadFamilyInfo() {
  279. var self = this
  280. // 从Vuex获取familyId和家庭成员
  281. var userInfo = uni.getStorageSync('userInfo')
  282. if (userInfo) {
  283. try {
  284. var info = typeof userInfo === 'string' ? JSON.parse(userInfo) : userInfo
  285. this.familyId = info.familyId || null
  286. // 加载家庭成员列表(从本地存储或请求)
  287. var storedMembers = uni.getStorageSync('familyMembers')
  288. if (storedMembers) {
  289. try {
  290. this.familyMembers = typeof storedMembers === 'string' ? JSON.parse(storedMembers) : storedMembers
  291. } catch (e) {
  292. this.familyMembers = []
  293. }
  294. }
  295. } catch (e) {
  296. console.log('解析userInfo失败', e)
  297. }
  298. }
  299. },
  300. // ===== 舌诊拍照 =====
  301. takePhoto() {
  302. var self = this
  303. uni.chooseImage({
  304. count: 1,
  305. sourceType: ['camera', 'album'],
  306. success: function(res) {
  307. self.photoPath = res.tempFilePaths[0]
  308. self.tongueResult = null
  309. }
  310. })
  311. },
  312. analyzeTongue() {
  313. if (!this.photoPath || this.analyzing) return
  314. var self = this
  315. this.analyzing = true
  316. var memberId = this.childId || ''
  317. uni.uploadFile({
  318. url: getApp().globalData.baseUrl + '/api/health/report/parse-preview?type=tongue' + (memberId ? '&memberId=' + memberId : ''),
  319. filePath: this.photoPath,
  320. name: 'file',
  321. header: {
  322. 'Authorization': 'Bearer ' + (uni.getStorageSync('token') || '')
  323. },
  324. success: function(res) {
  325. var data = JSON.parse(res.data)
  326. if (data.code === 200 && data.data) {
  327. self.tongueResult = data.data
  328. // 跳转到确认页
  329. var payload = {
  330. type: 'tongue',
  331. indicators: (data.data.indicators || []).map(function(ind) {
  332. return { code: ind.code, name: ind.name, value: ind.value }
  333. }),
  334. summary: { overallAssessment: data.data.overallAssessment || '' }
  335. }
  336. var params = 'draftId=' + data.data.recordId
  337. + '&payload=' + encodeURIComponent(JSON.stringify(payload))
  338. + '&reportType=tongue'
  339. + (self.childId ? '&childId=' + self.childId : '')
  340. + (self.familyId ? '&familyId=' + self.familyId : '')
  341. uni.navigateTo({ url: '/pages/health/report-confirm?' + params })
  342. } else {
  343. uni.showToast({ title: data.message || '分析失败', icon: 'none' })
  344. }
  345. },
  346. fail: function() {
  347. uni.showToast({ title: '上传失败', icon: 'none' })
  348. },
  349. complete: function() {
  350. self.analyzing = false
  351. }
  352. })
  353. },
  354. onScoreChanging(e) {
  355. this.form.overallScore = e.detail.value
  356. },
  357. onScoreChange(e) {
  358. this.form.overallScore = e.detail.value
  359. },
  360. pickFile() {
  361. var self = this
  362. // 重置解析状态
  363. this.parsedInfo = null
  364. this.showMemberSelector = false
  365. this.selectedMemberId = null
  366. // 从微信聊天记录中选择文件
  367. uni.chooseMessageFile({
  368. count: 1,
  369. type: 'file',
  370. extension: ['pdf'],
  371. success: function(res) {
  372. var files = res.tempFiles
  373. if (files && files.length > 0) {
  374. self.selectedFile = files[0]
  375. // 如果选择了文件且有familyId,先预览解析
  376. if (self.familyId) {
  377. self.previewParse()
  378. }
  379. }
  380. },
  381. fail: function(e) {
  382. console.log('选择文件取消或失败', e)
  383. }
  384. })
  385. },
  386. previewParse() {
  387. var self = this
  388. if (!this.selectedFile || !this.familyId) return
  389. this.parsingPreview = true
  390. parseReportPreview(this.selectedFile.path, this.childId, this.familyId).then(function(res) {
  391. var detail = res.data
  392. self.parsedInfo = {
  393. draftId: detail.draftId,
  394. personName: detail.extractedName || '',
  395. extractedGender: detail.extractedGender || '',
  396. extractedAge: detail.extractedAge || '',
  397. reportNumber: detail.extractedReportNumber,
  398. reportDate: detail.extractedReportDate,
  399. needManualSelect: detail.needBind,
  400. payload: detail.payload || {},
  401. matchedMemberId: detail.matchedMemberId,
  402. confidence: detail.confidence,
  403. matchLabel: detail.matchLabel,
  404. candidates: detail.candidates || []
  405. }
  406. // 从解析结果自动填充报告日期
  407. if (detail.extractedReportDate) {
  408. self.form.reportDate = detail.extractedReportDate
  409. }
  410. if (detail.needBind) {
  411. self.showMemberSelector = true
  412. self.loadFamilyMembers()
  413. // 未匹配到成员时,主动询问是否把解析到的人添加为新成员
  414. if (self.parsedInfo.personName) {
  415. self.promptAddParsedMember()
  416. }
  417. }
  418. // 从解析结果自动填充报告日期
  419. if (detail.extractedReportDate) {
  420. self.form.reportDate = detail.extractedReportDate
  421. }
  422. }).catch(function(e) {
  423. var msg = (e && e.message) || '解析失败'
  424. uni.showToast({ title: msg, icon: 'none' })
  425. }).finally(function() {
  426. self.parsingPreview = false
  427. })
  428. },
  429. loadFamilyMembers() {
  430. var self = this
  431. // 从后端获取真实家庭成员(家长 + 孩子),storage 中并无该数据
  432. getFamilyMembers().then(function(res) {
  433. var data = (res && res.data) || {}
  434. var parents = (data.parents || []).map(function(p) {
  435. return { id: p.id, nickname: p.realName || p.nickname, gender: p.gender, role: 'parent' }
  436. })
  437. var children = (data.children || []).map(function(c) {
  438. return { id: c.id, nickname: c.nickname, gender: c.gender, age: c.age, role: 'child' }
  439. })
  440. self.familyMembers = parents.concat(children)
  441. }).catch(function(e) {
  442. console.log('加载家庭成员失败', e)
  443. self.familyMembers = []
  444. })
  445. },
  446. selectMember(member) {
  447. this.selectedMemberId = member.id || member.userId
  448. this.parsedInfo = this.parsedInfo || {}
  449. // 手动选择的成员通过 matchedMemberId 传递给确认页
  450. this.parsedInfo.matchedMemberId = this.selectedMemberId
  451. this.parsedInfo.needManualSelect = false
  452. this.showMemberSelector = false
  453. },
  454. genderText(gender) {
  455. if (gender === 'male') return '男'
  456. if (gender === 'female') return '女'
  457. return ''
  458. },
  459. // 询问是否将解析到的人添加为家庭成员
  460. promptAddParsedMember() {
  461. var self = this
  462. if (!this.parsedInfo || !this.parsedInfo.personName) return
  463. var name = this.parsedInfo.personName
  464. var desc = name
  465. var extra = []
  466. var g = this.genderText(this.parsedInfo.extractedGender)
  467. if (g) extra.push(g)
  468. if (this.parsedInfo.extractedAge) extra.push(this.parsedInfo.extractedAge + '岁')
  469. if (extra.length > 0) desc = name + '(' + extra.join(',') + ')'
  470. uni.showModal({
  471. title: '未匹配到家庭成员',
  472. content: '是否将解析到的「' + desc + '」添加为家庭成员?',
  473. confirmText: '添加',
  474. cancelText: '手动选择',
  475. success: function(res) {
  476. if (res.confirm) {
  477. self.chooseRelationAndAdd()
  478. }
  479. // 取消则保留下方的手动选择列表
  480. }
  481. })
  482. },
  483. // 选择新成员与家庭的关系(后端要求辈分必填)后添加
  484. chooseRelationAndAdd() {
  485. var self = this
  486. var relOptions = [
  487. { label: '孩子', generationLevel: 'child' },
  488. { label: '配偶', generationLevel: 'peer', peerType: 'spouse' },
  489. { label: '父母', generationLevel: 'parent' },
  490. { label: '兄弟姐妹', generationLevel: 'peer', peerType: 'sibling' },
  491. { label: '祖父母', generationLevel: 'grandparent' }
  492. ]
  493. uni.showActionSheet({
  494. itemList: relOptions.map(function(o) { return o.label }),
  495. success: function(res) {
  496. var rel = relOptions[res.tapIndex]
  497. if (rel) {
  498. self.doAddParsedMember(rel)
  499. }
  500. }
  501. })
  502. },
  503. // 调用后端添加成员,并将其作为本次报告对象
  504. doAddParsedMember(rel) {
  505. var self = this
  506. var name = this.parsedInfo.personName
  507. var dto = {
  508. nickname: name,
  509. gender: this.parsedInfo.extractedGender || '',
  510. generationLevel: rel.generationLevel
  511. }
  512. if (rel.peerType) dto.peerType = rel.peerType
  513. // 由年龄推算大致出生年份(用于展示,用户后续可修改)
  514. var age = parseInt(this.parsedInfo.extractedAge)
  515. if (!isNaN(age) && age > 0 && age < 130) {
  516. dto.birthday = (new Date().getFullYear() - age) + '-01-01'
  517. }
  518. uni.showLoading({ title: '添加成员...' })
  519. addFamilyMember(dto).then(function(res) {
  520. uni.hideLoading()
  521. if (res && res.code === 200) {
  522. var newId = res.data && (res.data.id || res.data.userId)
  523. self.selectedMemberId = newId
  524. self.parsedInfo.matchedMemberId = newId
  525. self.parsedInfo.matchedMemberName = name
  526. self.parsedInfo.needManualSelect = false
  527. self.showMemberSelector = false
  528. self.loadFamilyMembers()
  529. uni.showToast({ title: '已添加并选中', icon: 'success' })
  530. } else {
  531. uni.showToast({ title: (res && res.message) || '添加失败', icon: 'none' })
  532. }
  533. }).catch(function(e) {
  534. uni.hideLoading()
  535. console.log('添加成员失败', e)
  536. uni.showToast({ title: '添加失败,请重试', icon: 'none' })
  537. })
  538. },
  539. removeFile() {
  540. this.selectedFile = null
  541. this.parsedInfo = null
  542. this.showMemberSelector = false
  543. this.selectedMemberId = null
  544. },
  545. loadRecommendProducts: function(reportType) {
  546. var domain = REPORT_DOMAIN_MAP[reportType]
  547. if (!domain) {
  548. this.recommendedProducts = []
  549. return
  550. }
  551. var self = this
  552. self.loadingProducts = true
  553. productList({ domain: domain, productType: 'assessment', size: 4 }).then(function(res) {
  554. if (res && res.data && res.data.records) {
  555. self.recommendedProducts = res.data.records
  556. } else {
  557. self.recommendedProducts = []
  558. }
  559. }).catch(function() {
  560. self.recommendedProducts = []
  561. }).finally(function() {
  562. self.loadingProducts = false
  563. })
  564. },
  565. goToProduct: function(productId) {
  566. uni.navigateTo({ url: '/pages/discover/product-detail/product-detail?id=' + productId })
  567. },
  568. formatPriceWithSymbol: function(cents) {
  569. if (cents === null || cents === undefined) return '¥0'
  570. return '¥' + (cents / 100).toFixed(2)
  571. },
  572. formatFileSize(bytes) {
  573. if (!bytes) return ''
  574. if (bytes < 1024) return bytes + 'B'
  575. if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'KB'
  576. return (bytes / 1024 / 1024).toFixed(1) + 'MB'
  577. },
  578. goBack() {
  579. uni.navigateBack()
  580. },
  581. doSubmit() {
  582. if (this.submitting) return
  583. var self = this
  584. // 舌诊:拍照后分析
  585. if (self.isTongue) {
  586. if (!self.photoPath) {
  587. uni.showToast({ title: '请先拍摄舌象', icon: 'none' })
  588. return
  589. }
  590. self.analyzeTongue()
  591. return
  592. }
  593. if (self.selectedFile) {
  594. if (self.parsedInfo && self.parsedInfo.needManualSelect && !self.selectedMemberId) {
  595. uni.showToast({ title: '请先选择成员', icon: 'none' })
  596. return
  597. }
  598. if (!self.parsedInfo || !self.parsedInfo.draftId) {
  599. uni.showToast({ title: '请先等待解析完成', icon: 'none' })
  600. return
  601. }
  602. var payloadStr = encodeURIComponent(JSON.stringify(self.parsedInfo.payload || {}))
  603. var candidatesStr = (self.parsedInfo.candidates && self.parsedInfo.candidates.length > 0)
  604. ? encodeURIComponent(JSON.stringify(self.parsedInfo.candidates))
  605. : ''
  606. var params = 'draftId=' + self.parsedInfo.draftId
  607. + '&extractedName=' + encodeURIComponent(self.parsedInfo.personName || '')
  608. + '&extractedGender=' + encodeURIComponent(self.parsedInfo.extractedGender || '')
  609. + '&extractedAge=' + (self.parsedInfo.extractedAge || '')
  610. + '&payload=' + payloadStr
  611. + '&needBind=' + (self.parsedInfo.needManualSelect ? 'true' : 'false')
  612. + '&matchConfidence=' + (self.parsedInfo.confidence || '')
  613. + '&matchLabel=' + encodeURIComponent(self.parsedInfo.matchLabel || '')
  614. + '&candidates=' + candidatesStr
  615. + '&extractedReportDate=' + encodeURIComponent(self.parsedInfo.reportDate || '')
  616. if (self.parsedInfo.matchedMemberId) {
  617. params += '&matchedMemberId=' + self.parsedInfo.matchedMemberId
  618. }
  619. if (self.childId) {
  620. params += '&childId=' + self.childId
  621. }
  622. if (self.familyId) {
  623. params += '&familyId=' + self.familyId
  624. }
  625. uni.navigateTo({ url: '/pages/health/report-confirm?' + params })
  626. return
  627. }
  628. self.submitting = true
  629. uni.showLoading({ title: '提交中...' })
  630. createHealthReport({
  631. reportType: self.form.reportType,
  632. reportDate: self.form.reportDate,
  633. overallScore: self.form.overallScore,
  634. description: self.form.description
  635. }).then(function(res) {
  636. uni.hideLoading()
  637. if (res.code === 200) {
  638. uni.showToast({ title: '提交成功', icon: 'success' })
  639. var reportId = res.data && res.data.id
  640. if (reportId) {
  641. setTimeout(function() {
  642. // 需要调研问卷的类型走问卷页
  643. var surveyTypes = ['gut_flora', 'physical_exam', 'blood_test', 'urine_test']
  644. if (surveyTypes.indexOf(self.form.reportType) !== -1) {
  645. uni.redirectTo({
  646. url: '/pages/health/report-survey?reportId=' + reportId + '&childId=' + (self.childId || '')
  647. })
  648. } else {
  649. uni.redirectTo({ url: '/pages/body/health-report?reportId=' + reportId })
  650. }
  651. }, 500)
  652. } else {
  653. setTimeout(function() {
  654. uni.navigateBack()
  655. }, 500)
  656. }
  657. } else {
  658. uni.showToast({ title: res.message || '提交失败', icon: 'none' })
  659. }
  660. }).catch(function(e) {
  661. uni.hideLoading()
  662. uni.showToast({ title: '提交失败,请重试', icon: 'none' })
  663. console.log('创建报告失败', e)
  664. }).finally(function() {
  665. self.submitting = false
  666. })
  667. }
  668. }
  669. }
  670. </script>
  671. <style scoped>
  672. .upload-container {
  673. min-height: 100vh;
  674. background: #F5F7FA;
  675. }
  676. /* ===== 导航栏 ===== */
  677. .nav-bar {
  678. position: sticky;
  679. top: 0;
  680. background: #fff;
  681. display: flex;
  682. flex-direction: row;
  683. align-items: center;
  684. justify-content: space-between;
  685. padding: 20rpx 30rpx;
  686. box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06);
  687. z-index: 100;
  688. }
  689. .nav-back {
  690. font-size: 32rpx;
  691. color: #5B9BD5;
  692. }
  693. .nav-title {
  694. font-size: 32rpx;
  695. font-weight: bold;
  696. color: #333;
  697. }
  698. .nav-placeholder {
  699. width: 80rpx;
  700. }
  701. /* ===== 提示条 ===== */
  702. .tip-banner {
  703. display: flex;
  704. flex-direction: row;
  705. align-items: center;
  706. background: #E3F2FD;
  707. margin: 20rpx 30rpx;
  708. padding: 20rpx 24rpx;
  709. border-radius: 16rpx;
  710. }
  711. .tip-icon {
  712. font-size: 32rpx;
  713. margin-right: 16rpx;
  714. flex-shrink: 0;
  715. }
  716. .tip-text {
  717. font-size: 24rpx;
  718. color: #1565C0;
  719. line-height: 1.5;
  720. }
  721. /* ===== 表单 ===== */
  722. .form-section {
  723. margin: 0 30rpx;
  724. }
  725. .form-item {
  726. background: #fff;
  727. border-radius: 16rpx;
  728. padding: 24rpx;
  729. margin-bottom: 20rpx;
  730. }
  731. .form-label {
  732. font-size: 26rpx;
  733. font-weight: bold;
  734. color: #333;
  735. display: block;
  736. margin-bottom: 16rpx;
  737. }
  738. /* 类型选择 */
  739. .type-selector {
  740. display: flex;
  741. flex-direction: row;
  742. gap: 20rpx;
  743. }
  744. .type-option {
  745. flex: 1;
  746. display: flex;
  747. flex-direction: column;
  748. align-items: center;
  749. padding: 24rpx;
  750. border-radius: 16rpx;
  751. background: #F5F7FA;
  752. border: 2rpx solid transparent;
  753. }
  754. .type-selected {
  755. background: #E3F2FD;
  756. border-color: #5B9BD5;
  757. }
  758. .type-icon {
  759. font-size: 40rpx;
  760. margin-bottom: 8rpx;
  761. }
  762. .type-name {
  763. font-size: 26rpx;
  764. color: #333;
  765. font-weight: 500;
  766. }
  767. /* 文件上传 */
  768. .file-upload-area {
  769. border: 2rpx dashed #CBD5E1;
  770. border-radius: 16rpx;
  771. padding: 32rpx 20rpx;
  772. text-align: center;
  773. background: #FAFBFC;
  774. cursor: pointer;
  775. }
  776. .file-upload-area:active {
  777. background: #F1F5F9;
  778. }
  779. .file-upload-placeholder {
  780. display: flex;
  781. flex-direction: column;
  782. align-items: center;
  783. }
  784. .file-upload-icon {
  785. font-size: 48rpx;
  786. margin-bottom: 12rpx;
  787. }
  788. .file-upload-text {
  789. font-size: 28rpx;
  790. color: #5B9BD5;
  791. font-weight: 500;
  792. }
  793. .file-upload-hint {
  794. font-size: 22rpx;
  795. color: #94A3B8;
  796. margin-top: 8rpx;
  797. }
  798. .file-info {
  799. display: flex;
  800. flex-direction: row;
  801. align-items: center;
  802. background: #E3F2FD;
  803. border-radius: 12rpx;
  804. padding: 16rpx 20rpx;
  805. margin-top: 16rpx;
  806. }
  807. .file-name {
  808. flex: 1;
  809. font-size: 24rpx;
  810. color: #1565C0;
  811. overflow: hidden;
  812. text-overflow: ellipsis;
  813. white-space: nowrap;
  814. }
  815. .file-size {
  816. font-size: 22rpx;
  817. color: #64748B;
  818. margin: 0 16rpx;
  819. flex-shrink: 0;
  820. }
  821. .file-remove {
  822. font-size: 24rpx;
  823. color: #EF4444;
  824. flex-shrink: 0;
  825. padding: 4rpx 8rpx;
  826. }
  827. /* 日期 */
  828. .date-display {
  829. display: flex;
  830. flex-direction: row;
  831. align-items: center;
  832. justify-content: space-between;
  833. padding: 16rpx 20rpx;
  834. background: #F5F7FA;
  835. border-radius: 12rpx;
  836. font-size: 28rpx;
  837. color: #333;
  838. }
  839. .date-arrow {
  840. font-size: 32rpx;
  841. color: #999;
  842. }
  843. /* 评分滑块 */
  844. .score-input-wrap {
  845. display: flex;
  846. flex-direction: row;
  847. align-items: center;
  848. }
  849. .score-slider {
  850. flex: 1;
  851. }
  852. .score-value {
  853. font-size: 28rpx;
  854. font-weight: bold;
  855. color: #5B9BD5;
  856. margin-left: 16rpx;
  857. min-width: 80rpx;
  858. text-align: center;
  859. }
  860. /* 备注 */
  861. .note-input {
  862. width: 100%;
  863. min-height: 160rpx;
  864. background: #F5F7FA;
  865. border-radius: 12rpx;
  866. padding: 20rpx;
  867. font-size: 26rpx;
  868. color: #333;
  869. box-sizing: border-box;
  870. }
  871. /* 提交按钮 */
  872. .submit-btn {
  873. background: linear-gradient(135deg, #5B9BD5, #3A7CC4);
  874. color: #fff;
  875. font-size: 30rpx;
  876. font-weight: bold;
  877. text-align: center;
  878. padding: 28rpx;
  879. border-radius: 16rpx;
  880. margin-top: 10rpx;
  881. }
  882. .submit-btn:active {
  883. opacity: 0.85;
  884. }
  885. .btn-disabled {
  886. opacity: 0.5;
  887. }
  888. /* ===== PDF解析预览 ===== */
  889. .parsed-preview {
  890. background: #E8F5E9;
  891. border-radius: 16rpx;
  892. margin: 0 30rpx 20rpx;
  893. padding: 20rpx 24rpx;
  894. border: 2rpx solid #A5D6A7;
  895. }
  896. .parsed-title {
  897. font-size: 24rpx;
  898. font-weight: bold;
  899. color: #2E7D32;
  900. margin-bottom: 12rpx;
  901. }
  902. .parsed-row {
  903. display: flex;
  904. flex-direction: row;
  905. align-items: center;
  906. margin-bottom: 8rpx;
  907. }
  908. .parsed-label {
  909. font-size: 24rpx;
  910. color: #388E3C;
  911. width: 140rpx;
  912. flex-shrink: 0;
  913. }
  914. .parsed-value {
  915. font-size: 24rpx;
  916. color: #1B5E20;
  917. font-weight: 500;
  918. }
  919. .match-status {
  920. display: flex;
  921. flex-direction: row;
  922. align-items: center;
  923. margin-top: 12rpx;
  924. padding: 12rpx 16rpx;
  925. background: #FFF3E0;
  926. border-radius: 8rpx;
  927. }
  928. .match-status.success {
  929. background: #E8F5E9;
  930. }
  931. .match-icon {
  932. font-size: 24rpx;
  933. margin-right: 8rpx;
  934. }
  935. .match-text {
  936. font-size: 24rpx;
  937. color: #E65100;
  938. }
  939. .match-status.success .match-text {
  940. color: #2E7D32;
  941. }
  942. /* ===== 家庭成员选择器 ===== */
  943. .member-selector {
  944. margin: 0 30rpx 20rpx;
  945. }
  946. .member-list {
  947. display: flex;
  948. flex-direction: row;
  949. flex-wrap: wrap;
  950. gap: 16rpx;
  951. }
  952. .member-item {
  953. display: flex;
  954. flex-direction: column;
  955. align-items: center;
  956. padding: 20rpx 24rpx;
  957. background: #F5F7FA;
  958. border-radius: 16rpx;
  959. border: 2rpx solid transparent;
  960. min-width: 120rpx;
  961. }
  962. .member-selected {
  963. background: #E3F2FD;
  964. border-color: #5B9BD5;
  965. }
  966. .member-avatar {
  967. width: 64rpx;
  968. height: 64rpx;
  969. border-radius: 50%;
  970. background: #5B9BD5;
  971. color: #fff;
  972. font-size: 28rpx;
  973. font-weight: bold;
  974. display: flex;
  975. align-items: center;
  976. justify-content: center;
  977. margin-bottom: 8rpx;
  978. }
  979. .member-name {
  980. font-size: 24rpx;
  981. color: #333;
  982. }
  983. .add-member-btn {
  984. margin-top: 16rpx;
  985. padding: 20rpx;
  986. border: 1rpx dashed #4caf50;
  987. border-radius: 12rpx;
  988. text-align: center;
  989. background: #f6fbf6;
  990. }
  991. .add-member-text {
  992. font-size: 26rpx;
  993. color: #4caf50;
  994. }
  995. /* ===== 舌诊拍照区 ===== */
  996. .photo-area {
  997. display: flex;
  998. justify-content: center;
  999. margin-bottom: 16rpx;
  1000. }
  1001. .photo-placeholder {
  1002. width: 400rpx;
  1003. height: 400rpx;
  1004. border: 4rpx dashed #CBD5E1;
  1005. border-radius: 24rpx;
  1006. display: flex;
  1007. flex-direction: column;
  1008. align-items: center;
  1009. justify-content: center;
  1010. background: #fff;
  1011. }
  1012. .photo-icon {
  1013. font-size: 80rpx;
  1014. margin-bottom: 16rpx;
  1015. }
  1016. .photo-text {
  1017. font-size: 28rpx;
  1018. color: #64748B;
  1019. }
  1020. .photo-sub {
  1021. font-size: 22rpx;
  1022. color: #94A3B8;
  1023. margin-top: 12rpx;
  1024. text-align: center;
  1025. padding: 0 20rpx;
  1026. }
  1027. .photo-preview {
  1028. width: 400rpx;
  1029. height: 400rpx;
  1030. border-radius: 24rpx;
  1031. }
  1032. .photo-analyzing {
  1033. width: 400rpx;
  1034. height: 400rpx;
  1035. border-radius: 24rpx;
  1036. background: #f5f5f5;
  1037. display: flex;
  1038. align-items: center;
  1039. justify-content: center;
  1040. }
  1041. .analyzing-text {
  1042. font-size: 28rpx;
  1043. color: #5B9BD5;
  1044. }
  1045. .photo-retake {
  1046. text-align: center;
  1047. font-size: 24rpx;
  1048. color: #5B9BD5;
  1049. margin-top: 8rpx;
  1050. }
  1051. /* ===== 底部 ===== */
  1052. .bottom-spacer {
  1053. height: 60rpx;
  1054. }
  1055. /* ===== 购买推荐 ===== */
  1056. .purchase-section {
  1057. background: #F0F9FF;
  1058. border-radius: 16rpx;
  1059. padding: 24rpx;
  1060. margin: 0 30rpx 20rpx;
  1061. border: 2rpx solid #BAE6FD;
  1062. }
  1063. .purchase-header {
  1064. display: flex;
  1065. flex-direction: row;
  1066. align-items: center;
  1067. margin-bottom: 16rpx;
  1068. }
  1069. .purchase-icon {
  1070. font-size: 28rpx;
  1071. margin-right: 8rpx;
  1072. }
  1073. .purchase-title {
  1074. font-size: 26rpx;
  1075. font-weight: bold;
  1076. color: #0369A1;
  1077. }
  1078. .purchase-scroll {
  1079. display: flex;
  1080. flex-direction: row;
  1081. white-space: nowrap;
  1082. }
  1083. .purchase-card {
  1084. display: inline-flex;
  1085. flex-direction: column;
  1086. width: 220rpx;
  1087. background: #fff;
  1088. border-radius: 12rpx;
  1089. padding: 16rpx;
  1090. margin-right: 16rpx;
  1091. flex-shrink: 0;
  1092. }
  1093. .purchase-cover {
  1094. width: 188rpx;
  1095. height: 120rpx;
  1096. border-radius: 8rpx;
  1097. background: #F1F5F9;
  1098. margin-bottom: 8rpx;
  1099. }
  1100. .purchase-name {
  1101. font-size: 22rpx;
  1102. color: #333;
  1103. line-height: 1.3;
  1104. overflow: hidden;
  1105. text-overflow: ellipsis;
  1106. white-space: nowrap;
  1107. margin-bottom: 4rpx;
  1108. }
  1109. .purchase-price {
  1110. font-size: 24rpx;
  1111. color: #EF4444;
  1112. font-weight: bold;
  1113. margin-bottom: 8rpx;
  1114. }
  1115. .purchase-btn {
  1116. background: #3B82F6;
  1117. border-radius: 8rpx;
  1118. padding: 8rpx 0;
  1119. text-align: center;
  1120. }
  1121. .purchase-btn-text {
  1122. font-size: 22rpx;
  1123. color: #fff;
  1124. font-weight: 500;
  1125. }
  1126. </style>