report-list.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580
  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">上传日期:{{ formatDateTime(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. formatDateTime(dateStr) {
  297. if (!dateStr) return '--'
  298. var d = parseDate(dateStr)
  299. if (!d) return dateStr
  300. var y = d.getFullYear()
  301. var m = ('' + (d.getMonth() + 1)).padStart(2, '0')
  302. var day = ('' + d.getDate()).padStart(2, '0')
  303. var h = ('' + d.getHours()).padStart(2, '0')
  304. var min = ('' + d.getMinutes()).padStart(2, '0')
  305. return y + '-' + m + '-' + day + ' ' + h + ':' + min
  306. }
  307. }
  308. }
  309. </script>
  310. <style scoped>
  311. .list-container {
  312. min-height: 100vh;
  313. background: #F5F7FA;
  314. }
  315. /* ===== 成员过滤 ===== */
  316. .member-filter {
  317. background: #F5F7FA;
  318. padding: 20rpx 30rpx 4rpx;
  319. }
  320. .filter-scroll {
  321. width: 100%;
  322. white-space: nowrap;
  323. }
  324. .filter-chips {
  325. display: flex;
  326. flex-direction: row;
  327. }
  328. .chip {
  329. flex-shrink: 0;
  330. background: #fff;
  331. border: 1rpx solid #E2E8F0;
  332. border-radius: 28rpx;
  333. padding: 10rpx 30rpx;
  334. margin-right: 16rpx;
  335. }
  336. .chip-active {
  337. background: #5B9BD5;
  338. border-color: #5B9BD5;
  339. }
  340. .chip-text {
  341. font-size: 26rpx;
  342. color: #64748B;
  343. }
  344. .chip-active .chip-text {
  345. color: #fff;
  346. }
  347. /* ===== 加载状态 ===== */
  348. .loading-wrap {
  349. display: flex;
  350. justify-content: center;
  351. padding: 80rpx 0;
  352. }
  353. .loading-text {
  354. font-size: 28rpx;
  355. color: #999;
  356. }
  357. /* ===== 采集中草稿 ===== */
  358. .collecting-wrap {
  359. margin: 20rpx 30rpx 0;
  360. }
  361. .collecting-card {
  362. display: flex;
  363. flex-direction: row;
  364. align-items: center;
  365. background: #FFF8E6;
  366. border: 1rpx solid #F5D78E;
  367. border-radius: 16rpx;
  368. padding: 20rpx 24rpx;
  369. margin-bottom: 16rpx;
  370. }
  371. .collecting-icon {
  372. width: 56rpx;
  373. height: 56rpx;
  374. border-radius: 50%;
  375. background: #F0B429;
  376. display: flex;
  377. align-items: center;
  378. justify-content: center;
  379. margin-right: 20rpx;
  380. }
  381. .spinner {
  382. width: 28rpx;
  383. height: 28rpx;
  384. border: 4rpx solid rgba(255,255,255,0.4);
  385. border-top-color: #fff;
  386. border-radius: 50%;
  387. animation: collecting-spin 1s linear infinite;
  388. }
  389. @keyframes collecting-spin {
  390. 0% { transform: rotate(0deg); }
  391. 100% { transform: rotate(360deg); }
  392. }
  393. .collecting-info {
  394. flex: 1;
  395. display: flex;
  396. flex-direction: column;
  397. min-width: 0;
  398. }
  399. .collecting-title {
  400. font-size: 28rpx;
  401. font-weight: 600;
  402. color: #7A5C00;
  403. }
  404. .collecting-file {
  405. font-size: 24rpx;
  406. color: #A08A45;
  407. margin-top: 4rpx;
  408. overflow: hidden;
  409. text-overflow: ellipsis;
  410. white-space: nowrap;
  411. }
  412. .collecting-status {
  413. font-size: 22rpx;
  414. color: #C0A75A;
  415. flex-shrink: 0;
  416. }
  417. /* ===== 报告列表 ===== */
  418. .report-list {
  419. margin: 20rpx 30rpx;
  420. }
  421. .swipe-row {
  422. position: relative;
  423. overflow: hidden;
  424. border-radius: 16rpx;
  425. margin-bottom: 16rpx;
  426. box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
  427. }
  428. .swipe-actions {
  429. position: absolute;
  430. top: 0;
  431. right: 0;
  432. height: 100%;
  433. display: flex;
  434. flex-direction: row;
  435. }
  436. .swipe-delete {
  437. width: 80px;
  438. height: 100%;
  439. background: #F56C6C;
  440. color: #fff;
  441. font-size: 28rpx;
  442. display: flex;
  443. align-items: center;
  444. justify-content: center;
  445. }
  446. .swipe-content {
  447. position: relative;
  448. z-index: 2;
  449. background: #fff;
  450. transition: transform 0.25s ease;
  451. }
  452. .swipe-moving {
  453. transition: none;
  454. }
  455. .report-card {
  456. border-radius: 0;
  457. padding: 28rpx 24rpx;
  458. display: flex;
  459. flex-direction: row;
  460. align-items: center;
  461. justify-content: space-between;
  462. }
  463. .report-card:active {
  464. opacity: 0.8;
  465. }
  466. .report-card-left {
  467. display: flex;
  468. flex-direction: column;
  469. gap: 2rpx;
  470. flex: 1;
  471. min-width: 0;
  472. }
  473. .report-type-badge {
  474. display: inline-block;
  475. font-size: 22rpx;
  476. padding: 4rpx 18rpx;
  477. border-radius: 20rpx;
  478. align-self: flex-start;
  479. }
  480. .badge-exam {
  481. background: #E3F2FD;
  482. color: #1565C0;
  483. }
  484. .badge-gut {
  485. background: #E8F5E9;
  486. color: #2E7D32;
  487. }
  488. .badge-tongue {
  489. background: #FFF3E0;
  490. color: #E65100;
  491. }
  492. .report-person {
  493. font-size: 26rpx;
  494. color: #333;
  495. font-weight: 500;
  496. margin-top: 4rpx;
  497. }
  498. .report-date {
  499. font-size: 22rpx;
  500. color: #888;
  501. margin-top: 4rpx;
  502. }
  503. .report-upload-date {
  504. font-size: 22rpx;
  505. color: #AAA;
  506. margin-top: 2rpx;
  507. }
  508. .report-card-right {
  509. display: flex;
  510. flex-direction: row;
  511. align-items: center;
  512. gap: 12rpx;
  513. }
  514. .report-score {
  515. font-size: 36rpx;
  516. font-weight: bold;
  517. color: #333;
  518. }
  519. .score-unit {
  520. font-size: 22rpx;
  521. color: #999;
  522. font-weight: normal;
  523. }
  524. .report-arrow {
  525. font-size: 36rpx;
  526. color: #CCC;
  527. }
  528. /* ===== 空状态 ===== */
  529. .empty-wrap {
  530. display: flex;
  531. flex-direction: column;
  532. align-items: center;
  533. padding: 120rpx 60rpx;
  534. }
  535. .empty-icon {
  536. font-size: 80rpx;
  537. margin-bottom: 24rpx;
  538. }
  539. .empty-title {
  540. font-size: 32rpx;
  541. font-weight: bold;
  542. color: #333;
  543. margin-bottom: 12rpx;
  544. }
  545. .empty-desc {
  546. font-size: 26rpx;
  547. color: #999;
  548. text-align: center;
  549. margin-bottom: 40rpx;
  550. line-height: 1.5;
  551. }
  552. .empty-btn {
  553. background: #5B9BD5;
  554. color: #fff;
  555. font-size: 28rpx;
  556. padding: 20rpx 60rpx;
  557. border-radius: 40rpx;
  558. }
  559. .empty-btn:active {
  560. opacity: 0.8;
  561. }
  562. /* ===== 底部 ===== */
  563. .bottom-spacer {
  564. height: 60rpx;
  565. }
  566. </style>