西藏巴青项目

index.vue 15KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549
  1. <template>
  2. <view :class="pageRootClass" class="tab-page msg-page">
  3. <view class="msg-head">
  4. <view class="msg-search">
  5. <up-icon class="msg-search__icon" name="search" color="#9A9590" :size="18" />
  6. <input
  7. v-model="query"
  8. class="msg-search__input"
  9. type="text"
  10. :placeholder="$t('messagePage.searchPlaceholder')"
  11. placeholder-class="msg-search__ph"
  12. confirm-type="search"
  13. />
  14. </view>
  15. </view>
  16. <view class="msg-scroll" :style="scrollWrapStyle">
  17. <view v-if="listLoading && !sessions.length" class="msg-empty">
  18. <text class="msg-empty__txt">{{ $t('messagePage.loading') }}</text>
  19. </view>
  20. <view v-else-if="!displayRows.length" class="msg-empty">
  21. <text class="msg-empty__txt">{{ emptyHint }}</text>
  22. </view>
  23. <up-virtual-list
  24. v-else
  25. class="msg-vlist"
  26. :list-data="displayRows"
  27. :item-height="slotHeightPx"
  28. :height="scrollAreaPx"
  29. :buffer="8"
  30. key-field="id"
  31. :scroll-top="vScrollTop"
  32. @update:scrollTop="vScrollTop = $event"
  33. @scroll="onVirtualListScroll"
  34. >
  35. <template #default="{ item }">
  36. <view class="msg-vlist__cell">
  37. <view
  38. class="msg-row"
  39. :class="{ 'msg-row--hl': highlightId === item.id }"
  40. :style="{ height: rowBodyPx + 'px' }"
  41. role="button"
  42. @tap="onRowTap(item)"
  43. @longpress.stop="onRowLongPress(item)"
  44. >
  45. <up-avatar
  46. v-if="avatarUrl(item)"
  47. class="msg-row__avatar"
  48. shape="circle"
  49. :src="avatarUrl(item)"
  50. size="44"
  51. font-size="16"
  52. />
  53. <up-avatar
  54. v-else
  55. class="msg-row__avatar"
  56. shape="circle"
  57. :text="avatarLetter(item)"
  58. size="44"
  59. font-size="16"
  60. :bg-color="avatarBg(item)"
  61. color="#fdfcfa"
  62. />
  63. <view class="msg-row__mid">
  64. <text class="text-body msg-row__title">{{ vetTitle(item) }}</text>
  65. <text class="text-body msg-row__preview">{{ previewText(item) }}</text>
  66. </view>
  67. <text class="msg-row__date">{{ formatDate(item.ts) }}</text>
  68. </view>
  69. </view>
  70. </template>
  71. </up-virtual-list>
  72. </view>
  73. </view>
  74. </template>
  75. <script>
  76. /**
  77. * 消息 Tab:对接 GET /app/consult/vet/session/list(M3)、POST .../hide(M4)
  78. */
  79. import UIcon from 'uview-plus/components/u-icon/u-icon.vue'
  80. import UAvatar from 'uview-plus/components/u-avatar/u-avatar.vue'
  81. import UVirtualList from 'uview-plus/components/u-virtual-list/u-virtual-list.vue'
  82. import tabPage from '@/mixins/tabPage'
  83. import { resolveResourceUrl } from '@/utils/resourceUrl'
  84. import { ensureApiToken } from '@/utils/apiAuth'
  85. import {
  86. listAskerConsultSessions,
  87. hideAskerConsultSession,
  88. loadVetProfileCache
  89. } from '@/api/onlineConsult'
  90. import { loadBookingResourceCache } from '@/api/bookingService'
  91. const ROW_BODY_RPX = 168
  92. const ROW_GAP_RPX = 20
  93. const PAGE_SIZE = 24
  94. const CONSULT_DETAIL_PATH = '/package-a/consult-detail/index'
  95. const HIDDEN_SESSIONS_KEY = 'asker_hidden_consult_sessions'
  96. export default {
  97. components: {
  98. 'up-icon': UIcon,
  99. 'up-avatar': UAvatar,
  100. 'up-virtual-list': UVirtualList
  101. },
  102. mixins: [tabPage],
  103. data() {
  104. return {
  105. navTitleKey: 'nav.message',
  106. query: '',
  107. sessions: [],
  108. listLoading: false,
  109. listLoadingMore: false,
  110. listTotal: 0,
  111. pageNum: 1,
  112. pageSize: PAGE_SIZE,
  113. rowBodyPx: 76,
  114. marginPx: 10,
  115. slotHeightPx: 86,
  116. scrollAreaPx: 400,
  117. highlightId: '',
  118. headHeightPx: 0,
  119. vScrollTop: 0,
  120. loadMoreTimer: null,
  121. hiddenSessionIds: []
  122. }
  123. },
  124. computed: {
  125. queryTrim() {
  126. return (this.query || '').trim()
  127. },
  128. listNoMore() {
  129. return this.listTotal > 0 && this.sessions.length >= this.listTotal
  130. },
  131. visibleSessions() {
  132. const hidden = new Set(this.hiddenSessionIds)
  133. return this.sessions.filter((row) => !hidden.has(row.id))
  134. },
  135. filteredFull() {
  136. const q = this.queryTrim.toLowerCase()
  137. if (!q) return this.visibleSessions
  138. return this.visibleSessions.filter((row) => {
  139. const t = `${this.vetTitle(row)} ${this.previewText(row)}`.toLowerCase()
  140. return t.includes(q)
  141. })
  142. },
  143. displayRows() {
  144. return this.filteredFull
  145. },
  146. emptyHint() {
  147. if (this.queryTrim) {
  148. return this.$t('messagePage.empty')
  149. }
  150. return this.$t('messagePage.emptyList')
  151. },
  152. scrollWrapStyle() {
  153. const h = this.scrollAreaPx
  154. const top = this.headHeightPx
  155. return {
  156. height: `${h}px`,
  157. marginTop: `${top}px`
  158. }
  159. }
  160. },
  161. watch: {
  162. queryTrim() {
  163. this.vScrollTop = 0
  164. }
  165. },
  166. onShow() {
  167. this.loadHiddenSessionIds()
  168. if (ensureApiToken(false)) {
  169. this.loadSessionList(true)
  170. }
  171. },
  172. onReady() {
  173. try {
  174. this.marginPx = Math.ceil(uni.upx2px(ROW_GAP_RPX))
  175. this.rowBodyPx = Math.max(64, Math.ceil(uni.upx2px(ROW_BODY_RPX)))
  176. this.slotHeightPx = this.rowBodyPx + this.marginPx
  177. } catch (e) {
  178. this.marginPx = 10
  179. this.rowBodyPx = 76
  180. this.slotHeightPx = 86
  181. }
  182. this.headHeightPx = Math.ceil(uni.upx2px(132))
  183. this.applyScrollLayout()
  184. this.$nextTick(() => {
  185. uni.createSelectorQuery()
  186. .in(this)
  187. .select('.msg-head')
  188. .boundingClientRect((rect) => {
  189. if (rect && rect.height) {
  190. this.headHeightPx = Math.ceil(rect.height)
  191. this.applyScrollLayout()
  192. }
  193. })
  194. .exec()
  195. })
  196. },
  197. onUnload() {
  198. if (this.loadMoreTimer) clearTimeout(this.loadMoreTimer)
  199. },
  200. methods: {
  201. loadHiddenSessionIds() {
  202. try {
  203. const raw = uni.getStorageSync(HIDDEN_SESSIONS_KEY)
  204. if (!raw) {
  205. this.hiddenSessionIds = []
  206. return
  207. }
  208. const arr = typeof raw === 'string' ? JSON.parse(raw) : raw
  209. this.hiddenSessionIds = Array.isArray(arr) ? arr.map(String) : []
  210. } catch (e) {
  211. this.hiddenSessionIds = []
  212. }
  213. },
  214. saveHiddenSessionIds() {
  215. try {
  216. uni.setStorageSync(HIDDEN_SESSIONS_KEY, JSON.stringify(this.hiddenSessionIds))
  217. } catch (e) {
  218. /* noop */
  219. }
  220. },
  221. applyScrollLayout() {
  222. const sys = uni.getSystemInfoSync()
  223. const winH = sys.windowHeight || 600
  224. const head = this.headHeightPx || Math.ceil(uni.upx2px(132))
  225. this.scrollAreaPx = Math.max(200, winH - head - 30)
  226. },
  227. mapSessionRow(row) {
  228. const name = row.receiverName || ''
  229. const ts = row.lastMessageTime ? new Date(row.lastMessageTime).getTime() : 0
  230. return {
  231. id: String(row.id != null ? row.id : ''),
  232. sessionId: String(row.id != null ? row.id : ''),
  233. vetResourceId: row.receiverProviderId != null ? String(row.receiverProviderId) : '',
  234. receiverName: name,
  235. lastMessagePreview: row.lastMessagePreview || '',
  236. lastMessageTime: row.lastMessageTime || '',
  237. ts: Number.isFinite(ts) ? ts : 0,
  238. avatarText: name.slice(0, 1) || '?',
  239. raw: row
  240. }
  241. },
  242. loadSessionList(reset = false) {
  243. if (!ensureApiToken()) return Promise.resolve()
  244. if (!reset) {
  245. if (this.listLoading || this.listLoadingMore || this.listNoMore) {
  246. return Promise.resolve()
  247. }
  248. this.pageNum += 1
  249. this.listLoadingMore = true
  250. } else {
  251. this.pageNum = 1
  252. this.listLoading = true
  253. }
  254. return listAskerConsultSessions({
  255. pageNum: this.pageNum,
  256. pageSize: this.pageSize
  257. })
  258. .then((res) => {
  259. const rows = (res.rows || []).map((row) => this.mapSessionRow(row))
  260. this.listTotal = res.total != null ? Number(res.total) : 0
  261. this.sessions = reset ? rows : this.sessions.concat(rows)
  262. this.$nextTick(() => this.tryAutoLoadMore())
  263. })
  264. .catch(() => {
  265. if (reset) {
  266. this.sessions = []
  267. this.listTotal = 0
  268. } else {
  269. this.pageNum -= 1
  270. }
  271. })
  272. .finally(() => {
  273. if (reset) {
  274. this.listLoading = false
  275. } else {
  276. this.listLoadingMore = false
  277. }
  278. })
  279. },
  280. tryAutoLoadMore() {
  281. if (this.listLoading || this.listLoadingMore || this.listNoMore) return
  282. const totalH = this.displayRows.length * this.slotHeightPx
  283. const viewH = this.scrollAreaPx
  284. if (totalH > 0 && totalH <= viewH) {
  285. this.loadSessionList(false)
  286. }
  287. },
  288. vetTitle(row) {
  289. if (row.receiverName) {
  290. return row.receiverName
  291. }
  292. return this.$t('messagePage.vetName', { n: '?' })
  293. },
  294. previewText(row) {
  295. const p = (row.lastMessagePreview || '').trim()
  296. return p || this.$t('messagePage.noPreview')
  297. },
  298. formatDate(ts) {
  299. if (!ts) return ''
  300. const d = new Date(ts)
  301. if (isNaN(d.getTime())) return ''
  302. const m = d.getMonth() + 1
  303. const day = d.getDate()
  304. if (this.lang === 'bo') {
  305. return `${m}/${day}`
  306. }
  307. return `${m}月${day}日`
  308. },
  309. avatarUrl(item) {
  310. if (!item || !item.vetResourceId) return ''
  311. const id = item.vetResourceId
  312. const fromProfile = loadVetProfileCache(id)
  313. const fromBooking = loadBookingResourceCache('004001', id)
  314. const url = (fromProfile && fromProfile.photoFileUrl) || (fromBooking && fromBooking.photoFileUrl)
  315. return url ? resolveResourceUrl(url) : ''
  316. },
  317. avatarBg(row) {
  318. const colors = ['#B8C5B8', '#C9B8A8', '#A8B4C9', '#C4B8C8', '#B5A896']
  319. const seed = row.vetResourceId || row.id || '0'
  320. let n = 0
  321. for (let i = 0; i < seed.length; i++) {
  322. n += seed.charCodeAt(i)
  323. }
  324. return colors[n % colors.length]
  325. },
  326. avatarLetter(row) {
  327. const t = row.avatarText || this.vetTitle(row)
  328. if (!t) return '?'
  329. return t.charAt(0)
  330. },
  331. onVirtualListScroll(scrollTop) {
  332. const stitle = typeof scrollTop === 'number' ? scrollTop : 0
  333. if (this.loadMoreTimer) clearTimeout(this.loadMoreTimer)
  334. this.loadMoreTimer = setTimeout(() => {
  335. this.loadMoreTimer = null
  336. this.tryLoadMoreOnScroll(st)
  337. }, 180)
  338. },
  339. tryLoadMoreOnScroll(scrollTop) {
  340. if (this.listLoading || this.listLoadingMore || this.listNoMore) return
  341. const totalH = this.displayRows.length * this.slotHeightPx
  342. const viewH = this.scrollAreaPx
  343. const threshold = Math.max(this.slotHeightPx * 2, 120)
  344. if (totalH <= viewH) {
  345. this.loadSessionList(false)
  346. return
  347. }
  348. if (scrollTop + viewH >= totalH - threshold) {
  349. this.loadSessionList(false)
  350. }
  351. },
  352. onRowTap(item) {
  353. if (!item || !item.sessionId) return
  354. const q = [
  355. `sessionId=${encodeURIComponent(item.sessionId)}`,
  356. `vetResourceId=${encodeURIComponent(item.vetResourceId || '')}`,
  357. `id=${encodeURIComponent(item.vetResourceId || '')}`,
  358. `name=${encodeURIComponent(item.receiverName || '')}`
  359. ].join('&')
  360. uni.navigateTo({
  361. url: `${CONSULT_DETAIL_PATH}?${q}`
  362. })
  363. },
  364. onRowLongPress(row) {
  365. this.highlightId = row.id
  366. uni.showActionSheet({
  367. itemList: [this.$t('messagePage.delete')],
  368. success: (res) => {
  369. if (res.tapIndex === 0) {
  370. this.removeConversation(row)
  371. }
  372. this.highlightId = ''
  373. },
  374. fail: () => {
  375. this.highlightId = ''
  376. }
  377. })
  378. },
  379. removeConversation(row) {
  380. if (!row || !row.sessionId) return
  381. hideAskerConsultSession(row.sessionId)
  382. .then(() => {
  383. const sid = String(row.sessionId)
  384. if (!this.hiddenSessionIds.includes(sid)) {
  385. this.hiddenSessionIds.push(sid)
  386. this.saveHiddenSessionIds()
  387. }
  388. const i = this.sessions.findIndex((x) => x.id === row.id)
  389. if (i !== -1) {
  390. this.sessions.splice(i, 1)
  391. }
  392. this.vScrollTop = 0
  393. })
  394. .catch(() => {
  395. uni.showToast({ title: this.$t('messagePage.deleteFail'), icon: 'none' })
  396. })
  397. }
  398. }
  399. }
  400. </script>
  401. <style lang="scss" scoped>
  402. @import '@/styles/morandi.scss';
  403. @import '@/styles/tab-page.scss';
  404. .msg-page {
  405. display: flex;
  406. flex-direction: column;
  407. min-width: 0;
  408. min-height: 100%;
  409. box-sizing: border-box;
  410. background: $morandi-bg-page;
  411. }
  412. .msg-head {
  413. position: fixed;
  414. top: 80rpx;
  415. left: 0;
  416. right: 0;
  417. z-index: 100;
  418. flex-shrink: 0;
  419. padding: 24rpx 24rpx 20rpx;
  420. box-sizing: border-box;
  421. background: $morandi-bg-page;
  422. box-shadow: 0 4rpx 16rpx rgba(74, 69, 66, 0.06);
  423. }
  424. .msg-search {
  425. display: flex;
  426. flex-direction: row;
  427. align-items: center;
  428. gap: 16rpx;
  429. min-width: 0;
  430. padding: 18rpx 22rpx;
  431. border-radius: 20rpx;
  432. background: $morandi-bg-card-inner;
  433. border: 1rpx solid $morandi-border-soft;
  434. }
  435. .msg-search__icon {
  436. flex-shrink: 0;
  437. }
  438. .msg-search__input {
  439. flex: 1;
  440. min-width: 0;
  441. font-size: 28rpx;
  442. line-height: 1.4;
  443. color: $morandi-text-secondary;
  444. }
  445. .msg-search__ph {
  446. color: $morandi-text-muted;
  447. font-size: 26rpx;
  448. }
  449. .msg-scroll {
  450. flex: 1;
  451. min-height: 0;
  452. min-width: 0;
  453. padding: 0 16rpx 16rpx;
  454. box-sizing: border-box;
  455. }
  456. .msg-vlist {
  457. width: 100%;
  458. }
  459. .msg-vlist__cell {
  460. height: 100%;
  461. box-sizing: border-box;
  462. display: flex;
  463. flex-direction: column;
  464. justify-content: flex-start;
  465. }
  466. .msg-row {
  467. display: flex;
  468. flex-direction: row;
  469. align-items: center;
  470. gap: 20rpx;
  471. min-width: 0;
  472. padding: 22rpx 20rpx;
  473. box-sizing: border-box;
  474. border-radius: 16rpx;
  475. background: $morandi-bg-card;
  476. border: 1rpx solid $morandi-border;
  477. flex-shrink: 0;
  478. }
  479. .msg-row--hl {
  480. background: $morandi-highlight;
  481. border-color: $morandi-highlight-border;
  482. }
  483. .msg-row__avatar {
  484. flex-shrink: 0;
  485. }
  486. .msg-row__mid {
  487. flex: 1;
  488. min-width: 0;
  489. display: flex;
  490. flex-direction: column;
  491. gap: 10rpx;
  492. min-height: 0;
  493. }
  494. .msg-row__title {
  495. font-size: 32rpx;
  496. font-weight: 600;
  497. line-height: 1.35;
  498. color: $morandi-text;
  499. word-break: break-word;
  500. overflow-wrap: anywhere;
  501. }
  502. .msg-row__preview {
  503. font-size: 24rpx;
  504. line-height: 1.45;
  505. color: $morandi-text-muted;
  506. word-break: break-word;
  507. overflow-wrap: anywhere;
  508. }
  509. .msg-row__date {
  510. flex-shrink: 0;
  511. align-self: flex-start;
  512. margin-top: 4rpx;
  513. font-size: 22rpx;
  514. color: $morandi-text-soft;
  515. }
  516. .msg-empty {
  517. padding: 80rpx 24rpx;
  518. display: flex;
  519. justify-content: center;
  520. }
  521. .msg-empty__txt {
  522. font-size: 26rpx;
  523. color: $morandi-text-muted;
  524. }
  525. </style>