report-list.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569
  1. <template>
  2. <view class="list-container">
  3. <!-- 成员过滤 -->
  4. <view class="member-filter">
  5. <scroll-view class="filter-scroll" scroll-x="true" :show-scrollbar="false">
  6. <view class="filter-chips">
  7. <view class="chip" :class="{ 'chip-active': !selectedMemberId }" @click="selectAllMembers">
  8. <text class="chip-text">全部</text>
  9. </view>
  10. <view
  11. class="chip"
  12. v-for="item in members"
  13. :key="item.id"
  14. :class="{ 'chip-active': selectedMemberId === item._filterId }"
  15. @click="selectMember(item)"
  16. >
  17. <text class="chip-text">{{ item.nickname || '成员' }}</text>
  18. </view>
  19. </view>
  20. </scroll-view>
  21. </view>
  22. <!-- 采集中草稿 -->
  23. <view class="collecting-wrap" v-if="collectingReports.length > 0">
  24. <view class="collecting-card" v-for="item in collectingReports" :key="item.draftId">
  25. <view class="collecting-icon">
  26. <view class="spinner"></view>
  27. </view>
  28. <view class="collecting-info">
  29. <text class="collecting-title">数据采集中</text>
  30. <text class="collecting-file">{{ item.originalFilename || '健康报告' }}</text>
  31. </view>
  32. <text class="collecting-status">解析完成后自动入库</text>
  33. </view>
  34. </view>
  35. <!-- 加载状态 -->
  36. <view class="loading-wrap" v-if="loading">
  37. <text class="loading-text">加载中...</text>
  38. </view>
  39. <!-- 报告列表 -->
  40. <view class="report-list" v-else-if="reports.length > 0">
  41. <view class="swipe-row" v-for="item in reports" :key="item.id">
  42. <!-- 右侧删除按钮(滑动后露出) -->
  43. <view class="swipe-actions">
  44. <view class="swipe-delete" @click.stop="confirmDelete(item)">删除</view>
  45. </view>
  46. <!-- 卡片内容(可左滑) -->
  47. <view class="swipe-content"
  48. :class="{ 'swipe-moving': curId === item.id }"
  49. :style="'transform: translateX(' + (curId === item.id ? moveOffset : (openMap[item.id] ? -80 : 0)) + 'px)'"
  50. @touchstart="onTouchStart($event, item)"
  51. @touchmove="onTouchMove"
  52. @touchend="onTouchEnd"
  53. @click="onRowClick(item)">
  54. <view class="report-card">
  55. <view class="report-card-left">
  56. <view class="report-type-badge" :class="'badge-' + (item.reportType === 'gut_flora' ? 'gut' : item.reportType === 'tongue' ? 'tongue' : 'exam')">
  57. <text>{{ item.reportType === 'gut_flora' ? '肠道检测' : item.reportType === 'tongue' ? '舌诊' : '体检报告' }}</text>
  58. </view>
  59. <text class="report-person" v-if="item.personName">{{ item.personName }}</text>
  60. <text class="report-date">报告日期:{{ formatDate(item.reportDate) }}</text>
  61. <text class="report-upload-date">上传日期:{{ formatDate(item.createdAt) }}</text>
  62. </view>
  63. <view class="report-card-right">
  64. <text class="report-score">{{ item.overallScore || '--' }}<text class="score-unit">分</text></text>
  65. <text class="report-arrow">›</text>
  66. </view>
  67. </view>
  68. </view>
  69. </view>
  70. </view>
  71. <!-- 空状态 -->
  72. <view class="empty-wrap" v-else>
  73. <text class="empty-icon">📋</text>
  74. <text class="empty-title">暂无健康报告</text>
  75. <text class="empty-desc">上传体检报告或肠道检测报告,获取 解读</text>
  76. <view class="empty-btn" @click="goToUpload">上传报告</view>
  77. </view>
  78. <!-- 底部占位 -->
  79. <view class="bottom-spacer"></view>
  80. </view>
  81. </template>
  82. <script>
  83. import { getReportList, deleteReport, getFamilyMemberList, getCollectingReports } from '../../utils/api.js'
  84. import { parseDate } from '../../utils/format.js'
  85. export default {
  86. data() {
  87. return {
  88. reports: [],
  89. members: [],
  90. selectedMemberId: null,
  91. loading: false,
  92. memberId: null,
  93. // 异步采集中的草稿
  94. collectingReports: [],
  95. collectTimer: null,
  96. // 左滑删除
  97. openMap: {},
  98. curId: null,
  99. moveOffset: 0,
  100. startX: 0,
  101. startY: 0,
  102. touchDidMove: false,
  103. baseOffset: 0
  104. }
  105. },
  106. onLoad(options) {
  107. // 导航参数可带初始 memberId(预选对应成员);未传时默认"全部"
  108. this.memberId = options.memberId ? parseInt(options.memberId) : null
  109. this.selectedMemberId = this.memberId
  110. this.loadMembers()
  111. this.loadData()
  112. this.startCollectPolling()
  113. },
  114. onShow() {
  115. // 从报告上传页返回时刷新列表,展示后台异步采集完成/失败的报告
  116. if (this._pageShown) {
  117. this.loadData()
  118. this.loadCollectingReports()
  119. }
  120. this._pageShown = true
  121. },
  122. onUnload() {
  123. if (this.collectTimer) {
  124. clearInterval(this.collectTimer)
  125. this.collectTimer = null
  126. }
  127. },
  128. methods: {
  129. startCollectPolling() {
  130. var self = this
  131. if (self.collectTimer) {
  132. clearInterval(self.collectTimer)
  133. }
  134. self.loadCollectingReports()
  135. self.collectTimer = setInterval(function() {
  136. self.loadCollectingReports()
  137. }, 6000)
  138. },
  139. loadCollectingReports() {
  140. var self = this
  141. var prevCount = self.collectingReports.length
  142. getCollectingReports().then(function(res) {
  143. if (res.code === 200) {
  144. var list = res.data || []
  145. self.collectingReports = list
  146. // 采集卡片消失后刷新列表,让刚入库的报告立刻出现
  147. if (prevCount > 0 && list.length < prevCount) {
  148. self.loadData()
  149. }
  150. }
  151. }).catch(function() {
  152. // 采集进度轮询失败静默,下一轮重试
  153. })
  154. },
  155. loadMembers() {
  156. var self = this
  157. getFamilyMemberList({}).then(function(res) {
  158. if (res.code === 200 && Array.isArray(res.data)) {
  159. // _filterId 预计算,供 :class 内联比较使用(小程序 :class 禁止方法调用)
  160. var list = []
  161. for (var i = 0; i < res.data.length; i++) {
  162. var m = res.data[i]
  163. list.push({
  164. id: m.id,
  165. nickname: m.nickname,
  166. avatar: m.avatar,
  167. _filterId: m.id || m.userId
  168. })
  169. }
  170. self.members = list
  171. }
  172. }).catch(function(e) {
  173. console.log('获取家庭成员失败', e)
  174. })
  175. },
  176. selectAllMembers() {
  177. if (this.selectedMemberId === null) return
  178. this.selectedMemberId = null
  179. this.loadData()
  180. },
  181. selectMember(item) {
  182. var filterId = item._filterId
  183. if (this.selectedMemberId === filterId) return
  184. this.selectedMemberId = filterId
  185. this.loadData()
  186. },
  187. loadData() {
  188. var self = this
  189. self.loading = true
  190. getReportList(self.selectedMemberId).then(function(res) {
  191. self.reports = res.data || []
  192. }).catch(function(e) {
  193. console.log('获取报告列表失败', e)
  194. uni.showToast({ title: '加载失败', icon: 'none' })
  195. }).finally(function() {
  196. self.loading = false
  197. })
  198. },
  199. goToDetail(reportId, reportType) {
  200. var url
  201. if (reportType === 'gut_flora') {
  202. url = '/pages/health/gut-flora-detail?reportId=' + reportId
  203. } else if (reportType === 'dan') {
  204. url = '/pages/dan-assessment/report-result?reportId=' + reportId
  205. } else {
  206. url = '/pages/health/report-detail?reportType=' + (reportType || 'gut_flora') + '&reportId=' + reportId
  207. }
  208. uni.navigateTo({ url: url })
  209. },
  210. onRowClick(item) {
  211. // 刚滑动过,忽略本次点击
  212. if (this.touchDidMove) {
  213. this.touchDidMove = false
  214. return
  215. }
  216. // 行处于打开态,点击先收起
  217. if (this.openMap[item.id]) {
  218. this.$set(this.openMap, item.id, false)
  219. return
  220. }
  221. this.goToDetail(item.id, item.reportType)
  222. },
  223. onTouchStart(e, item) {
  224. // 先收起其它已打开行
  225. if (this.curId !== null && this.curId !== item.id && this.openMap[this.curId]) {
  226. this.$set(this.openMap, this.curId, false)
  227. }
  228. var touch = e.touches[0]
  229. this.curId = item.id
  230. this.startX = touch.clientX
  231. this.startY = touch.clientY
  232. this.touchDidMove = false
  233. this.baseOffset = this.openMap[item.id] ? -80 : 0
  234. this.moveOffset = this.baseOffset
  235. },
  236. onTouchMove(e) {
  237. if (this.curId === null) return
  238. var touch = e.touches[0]
  239. var dx = touch.clientX - this.startX
  240. var dy = touch.clientY - this.startY
  241. // 横向滑动(水平分量大于垂直分量)
  242. if (Math.abs(dx) > Math.abs(dy)) {
  243. this.touchDidMove = Math.abs(dx) > 10
  244. var offset = this.baseOffset + dx
  245. if (offset < -80) offset = -80
  246. if (offset > 0) offset = 0
  247. this.moveOffset = offset
  248. }
  249. },
  250. onTouchEnd() {
  251. if (this.curId === null) return
  252. // 位移超过阈值则打开,否则收起
  253. var shouldOpen = this.moveOffset < -20
  254. this.$set(this.openMap, this.curId, shouldOpen)
  255. this.moveOffset = shouldOpen ? -80 : 0
  256. this.curId = null
  257. },
  258. goToUpload() {
  259. uni.navigateTo({ url: '/pages/health/report-upload' })
  260. },
  261. confirmDelete(item) {
  262. var self = this
  263. uni.showModal({
  264. title: '删除报告',
  265. content: '确定删除该健康报告吗?删除后不可恢复。',
  266. confirmColor: '#F56C6C',
  267. success: function(res) {
  268. if (!res.confirm) return
  269. self.doDelete(item)
  270. }
  271. })
  272. },
  273. doDelete(item) {
  274. var self = this
  275. deleteReport(item.id).then(function(res) {
  276. if (res.code === 200) {
  277. uni.showToast({ title: '已删除', icon: 'success' })
  278. self.loadData()
  279. } else {
  280. uni.showToast({ title: res.message || '删除失败', icon: 'none' })
  281. }
  282. }).catch(function(e) {
  283. console.log('删除报告失败', e)
  284. uni.showToast({ title: '删除失败', icon: 'none' })
  285. })
  286. },
  287. formatDate(dateStr) {
  288. if (!dateStr) return '--'
  289. var d = parseDate(dateStr)
  290. if (!d) return dateStr
  291. var y = d.getFullYear()
  292. var m = ('' + (d.getMonth() + 1)).padStart(2, '0')
  293. var day = ('' + d.getDate()).padStart(2, '0')
  294. return y + '-' + m + '-' + day
  295. }
  296. }
  297. }
  298. </script>
  299. <style scoped>
  300. .list-container {
  301. min-height: 100vh;
  302. background: #F5F7FA;
  303. }
  304. /* ===== 成员过滤 ===== */
  305. .member-filter {
  306. background: #F5F7FA;
  307. padding: 20rpx 30rpx 4rpx;
  308. }
  309. .filter-scroll {
  310. width: 100%;
  311. white-space: nowrap;
  312. }
  313. .filter-chips {
  314. display: flex;
  315. flex-direction: row;
  316. }
  317. .chip {
  318. flex-shrink: 0;
  319. background: #fff;
  320. border: 1rpx solid #E2E8F0;
  321. border-radius: 28rpx;
  322. padding: 10rpx 30rpx;
  323. margin-right: 16rpx;
  324. }
  325. .chip-active {
  326. background: #5B9BD5;
  327. border-color: #5B9BD5;
  328. }
  329. .chip-text {
  330. font-size: 26rpx;
  331. color: #64748B;
  332. }
  333. .chip-active .chip-text {
  334. color: #fff;
  335. }
  336. /* ===== 加载状态 ===== */
  337. .loading-wrap {
  338. display: flex;
  339. justify-content: center;
  340. padding: 80rpx 0;
  341. }
  342. .loading-text {
  343. font-size: 28rpx;
  344. color: #999;
  345. }
  346. /* ===== 采集中草稿 ===== */
  347. .collecting-wrap {
  348. margin: 20rpx 30rpx 0;
  349. }
  350. .collecting-card {
  351. display: flex;
  352. flex-direction: row;
  353. align-items: center;
  354. background: #FFF8E6;
  355. border: 1rpx solid #F5D78E;
  356. border-radius: 16rpx;
  357. padding: 20rpx 24rpx;
  358. margin-bottom: 16rpx;
  359. }
  360. .collecting-icon {
  361. width: 56rpx;
  362. height: 56rpx;
  363. border-radius: 50%;
  364. background: #F0B429;
  365. display: flex;
  366. align-items: center;
  367. justify-content: center;
  368. margin-right: 20rpx;
  369. }
  370. .spinner {
  371. width: 28rpx;
  372. height: 28rpx;
  373. border: 4rpx solid rgba(255,255,255,0.4);
  374. border-top-color: #fff;
  375. border-radius: 50%;
  376. animation: collecting-spin 1s linear infinite;
  377. }
  378. @keyframes collecting-spin {
  379. 0% { transform: rotate(0deg); }
  380. 100% { transform: rotate(360deg); }
  381. }
  382. .collecting-info {
  383. flex: 1;
  384. display: flex;
  385. flex-direction: column;
  386. min-width: 0;
  387. }
  388. .collecting-title {
  389. font-size: 28rpx;
  390. font-weight: 600;
  391. color: #7A5C00;
  392. }
  393. .collecting-file {
  394. font-size: 24rpx;
  395. color: #A08A45;
  396. margin-top: 4rpx;
  397. overflow: hidden;
  398. text-overflow: ellipsis;
  399. white-space: nowrap;
  400. }
  401. .collecting-status {
  402. font-size: 22rpx;
  403. color: #C0A75A;
  404. flex-shrink: 0;
  405. }
  406. /* ===== 报告列表 ===== */
  407. .report-list {
  408. margin: 20rpx 30rpx;
  409. }
  410. .swipe-row {
  411. position: relative;
  412. overflow: hidden;
  413. border-radius: 16rpx;
  414. margin-bottom: 16rpx;
  415. box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
  416. }
  417. .swipe-actions {
  418. position: absolute;
  419. top: 0;
  420. right: 0;
  421. height: 100%;
  422. display: flex;
  423. flex-direction: row;
  424. }
  425. .swipe-delete {
  426. width: 80px;
  427. height: 100%;
  428. background: #F56C6C;
  429. color: #fff;
  430. font-size: 28rpx;
  431. display: flex;
  432. align-items: center;
  433. justify-content: center;
  434. }
  435. .swipe-content {
  436. position: relative;
  437. z-index: 2;
  438. background: #fff;
  439. transition: transform 0.25s ease;
  440. }
  441. .swipe-moving {
  442. transition: none;
  443. }
  444. .report-card {
  445. border-radius: 0;
  446. padding: 28rpx 24rpx;
  447. display: flex;
  448. flex-direction: row;
  449. align-items: center;
  450. justify-content: space-between;
  451. }
  452. .report-card:active {
  453. opacity: 0.8;
  454. }
  455. .report-card-left {
  456. display: flex;
  457. flex-direction: column;
  458. gap: 2rpx;
  459. flex: 1;
  460. min-width: 0;
  461. }
  462. .report-type-badge {
  463. display: inline-block;
  464. font-size: 22rpx;
  465. padding: 4rpx 18rpx;
  466. border-radius: 20rpx;
  467. align-self: flex-start;
  468. }
  469. .badge-exam {
  470. background: #E3F2FD;
  471. color: #1565C0;
  472. }
  473. .badge-gut {
  474. background: #E8F5E9;
  475. color: #2E7D32;
  476. }
  477. .badge-tongue {
  478. background: #FFF3E0;
  479. color: #E65100;
  480. }
  481. .report-person {
  482. font-size: 26rpx;
  483. color: #333;
  484. font-weight: 500;
  485. margin-top: 4rpx;
  486. }
  487. .report-date {
  488. font-size: 22rpx;
  489. color: #888;
  490. margin-top: 4rpx;
  491. }
  492. .report-upload-date {
  493. font-size: 22rpx;
  494. color: #AAA;
  495. margin-top: 2rpx;
  496. }
  497. .report-card-right {
  498. display: flex;
  499. flex-direction: row;
  500. align-items: center;
  501. gap: 12rpx;
  502. }
  503. .report-score {
  504. font-size: 36rpx;
  505. font-weight: bold;
  506. color: #333;
  507. }
  508. .score-unit {
  509. font-size: 22rpx;
  510. color: #999;
  511. font-weight: normal;
  512. }
  513. .report-arrow {
  514. font-size: 36rpx;
  515. color: #CCC;
  516. }
  517. /* ===== 空状态 ===== */
  518. .empty-wrap {
  519. display: flex;
  520. flex-direction: column;
  521. align-items: center;
  522. padding: 120rpx 60rpx;
  523. }
  524. .empty-icon {
  525. font-size: 80rpx;
  526. margin-bottom: 24rpx;
  527. }
  528. .empty-title {
  529. font-size: 32rpx;
  530. font-weight: bold;
  531. color: #333;
  532. margin-bottom: 12rpx;
  533. }
  534. .empty-desc {
  535. font-size: 26rpx;
  536. color: #999;
  537. text-align: center;
  538. margin-bottom: 40rpx;
  539. line-height: 1.5;
  540. }
  541. .empty-btn {
  542. background: #5B9BD5;
  543. color: #fff;
  544. font-size: 28rpx;
  545. padding: 20rpx 60rpx;
  546. border-radius: 40rpx;
  547. }
  548. .empty-btn:active {
  549. opacity: 0.8;
  550. }
  551. /* ===== 底部 ===== */
  552. .bottom-spacer {
  553. height: 60rpx;
  554. }
  555. </style>