西藏巴青项目

index.vue 16KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580
  1. <template>
  2. <!-- 我的报名:布局参考农技课堂-实战培训;见 doc/我的报名.md -->
  3. <view :class="pageRootClass" class="tab-page me-page" :style="pageStyle">
  4. <view class="me-top">
  5. <view class="me-chips" role="tablist">
  6. <view
  7. v-for="chip in statusChips"
  8. :key="chip.key"
  9. class="me-chip"
  10. :class="{ 'me-chip--on': selectedStatus === chip.key }"
  11. role="button"
  12. @click="onPickStatus(chip.key)"
  13. >
  14. <text class="text-body me-chip__txt">{{ $t(chip.labelKey) }}</text>
  15. </view>
  16. </view>
  17. </view>
  18. <view class="me-body">
  19. <view v-if="listLoading && !pairedRows.length" class="me-empty">
  20. <text class="text-body me-empty__txt">{{ $t('myEnrollmentPage.loading') }}</text>
  21. </view>
  22. <view v-else-if="!pairedRows.length" class="me-empty">
  23. <text class="text-body me-empty__txt">{{ $t('myEnrollmentPage.empty') }}</text>
  24. </view>
  25. <view v-else class="me-list-wrap">
  26. <up-virtual-list
  27. ref="meVList"
  28. class="me-vlist"
  29. :list-data="pairedRows"
  30. :item-height="slotHeightPx"
  31. :height="listHeightPx"
  32. :buffer="4"
  33. key-field="id"
  34. :scroll-top="vScrollTop"
  35. @update:scrollTop="vScrollTop = $event"
  36. @scroll="onVirtualListScroll"
  37. >
  38. <template #default="{ item }">
  39. <view class="me-pair-cell">
  40. <view class="me-pair" :style="{ height: rowBodyPx + 'px' }">
  41. <view class="me-pair__col">
  42. <view v-if="item.left" class="me-card" role="button" @click="openTrainingDetail(item.left)">
  43. <up-lazy-load
  44. class="me-card__img"
  45. :image="cardCover(item.left)"
  46. height="120"
  47. :border-radius="12"
  48. img-mode="aspectFill"
  49. :threshold="200"
  50. :index="item.left.id"
  51. @click.stop="() => onPreviewCover(item.left)"
  52. />
  53. <text class="me-card__title">{{ item.left.title }}</text>
  54. <view class="me-card__foot">
  55. <text class="text-body me-card__meta">{{ statusLabel(item.left) }}</text>
  56. <up-icon name="account-fill" color="#22C55E" :size="16" />
  57. <text class="text-body me-card__meta me-card__meta--num">
  58. {{ item.left.enrolled }}/{{ item.left.total }}
  59. </text>
  60. </view>
  61. </view>
  62. </view>
  63. <view class="me-pair__col">
  64. <view v-if="item.right" class="me-card" role="button" @click="openTrainingDetail(item.right)">
  65. <up-lazy-load
  66. class="me-card__img"
  67. :image="cardCover(item.right)"
  68. height="120"
  69. :border-radius="12"
  70. img-mode="aspectFill"
  71. :threshold="200"
  72. :index="item.right.id"
  73. @click.stop="() => onPreviewCover(item.right)"
  74. />
  75. <text class="me-card__title">{{ item.right.title }}</text>
  76. <view class="me-card__foot">
  77. <text class="text-body me-card__meta">{{ statusLabel(item.right) }}</text>
  78. <up-icon name="account-fill" color="#22C55E" :size="16" />
  79. <text class="text-body me-card__meta me-card__meta--num">
  80. {{ item.right.enrolled }}/{{ item.right.total }}
  81. </text>
  82. </view>
  83. </view>
  84. </view>
  85. </view>
  86. </view>
  87. </template>
  88. </up-virtual-list>
  89. </view>
  90. </view>
  91. </view>
  92. </template>
  93. <script>
  94. import UIcon from 'uview-plus/components/u-icon/u-icon.vue'
  95. import ULazyLoad from 'uview-plus/components/u-lazy-load/u-lazy-load.vue'
  96. import UVirtualList from 'uview-plus/components/u-virtual-list/u-virtual-list.vue'
  97. import tabPage from '@/mixins/tabPage'
  98. import { resolveResourceUrl } from '@/utils/resourceUrl'
  99. import { listMyEnroll } from '@/api/practicalTraining'
  100. const TRAINING_STATUS_CODES = ['006001', '006002', '006003', '006004']
  101. const CHIP_ALL = 'ALL'
  102. const LIST_PAGE_SIZE = 20
  103. const ROW_BODY_RPX = 320
  104. const ROW_GAP_RPX = 20
  105. const ME_BODY_PAD_RPX = 40
  106. const COVER = '/static/ai/hero.png'
  107. const TRAINING_DETAIL_PATH = '/package-a/training-detail/index'
  108. function formatDateTime(val) {
  109. if (val == null || val === '') return ''
  110. const s = String(val)
  111. return s.length >= 19 ? s.slice(0, 19) : s.slice(0, 10)
  112. }
  113. export default {
  114. components: {
  115. 'up-icon': UIcon,
  116. 'up-lazy-load': ULazyLoad,
  117. 'up-virtual-list': UVirtualList
  118. },
  119. mixins: [tabPage],
  120. data() {
  121. return {
  122. navTitleKey: 'myEnrollmentPage.navTitle',
  123. selectedStatus: CHIP_ALL,
  124. allCards: [],
  125. listLoading: false,
  126. listLoadingMore: false,
  127. listTotal: 0,
  128. pageNum: 1,
  129. pageSize: LIST_PAGE_SIZE,
  130. loadMoreTimer: null,
  131. rowBodyPx: 140,
  132. marginPx: 10,
  133. slotHeightPx: 150,
  134. listHeightPx: 400,
  135. pageHeightPx: 0,
  136. vScrollTop: 0,
  137. coverSrc: COVER
  138. }
  139. },
  140. computed: {
  141. statusChips() {
  142. return [
  143. { key: CHIP_ALL, labelKey: 'myEnrollmentPage.chipAll' },
  144. ...TRAINING_STATUS_CODES.map((code, idx) => ({
  145. key: code,
  146. labelKey: `agriClassroomPage.trainingSubs.s${idx}`
  147. }))
  148. ]
  149. },
  150. filteredCards() {
  151. if (this.selectedStatus === CHIP_ALL) {
  152. return this.allCards
  153. }
  154. return this.allCards.filter((c) => c.typeCode === this.selectedStatus)
  155. },
  156. listNoMore() {
  157. return this.listTotal > 0 && this.allCards.length >= this.listTotal
  158. },
  159. pageStyle() {
  160. if (this.pageHeightPx > 0) {
  161. return { height: `${this.pageHeightPx}px` }
  162. }
  163. return {}
  164. },
  165. pairedRows() {
  166. const cards = this.filteredCards
  167. const rows = []
  168. for (let i = 0; i < cards.length; i += 2) {
  169. const L = cards[i]
  170. const R = cards[i + 1] || null
  171. rows.push({
  172. id: `${L.id}_${R ? R.id : 'x'}`,
  173. left: L,
  174. right: R
  175. })
  176. }
  177. return rows
  178. }
  179. },
  180. watch: {
  181. selectedStatus() {
  182. this.vScrollTop = 0
  183. this.$nextTick(() => this.tryAutoLoadMoreAfterFilter())
  184. }
  185. },
  186. onShow() {
  187. this.loadEnrollList(true)
  188. },
  189. onReady() {
  190. try {
  191. this.marginPx = Math.ceil(uni.upx2px(ROW_GAP_RPX))
  192. this.rowBodyPx = Math.max(120, Math.ceil(uni.upx2px(ROW_BODY_RPX)))
  193. this.slotHeightPx = this.rowBodyPx + this.marginPx
  194. } catch (e) {
  195. this.marginPx = 10
  196. this.rowBodyPx = 140
  197. this.slotHeightPx = 150
  198. }
  199. this.applyListHeightFallback()
  200. this.$nextTick(() => this.calcLayoutHeights())
  201. },
  202. onUnload() {
  203. if (this.loadMoreTimer) clearTimeout(this.loadMoreTimer)
  204. },
  205. methods: {
  206. statusLabel(card) {
  207. if (card && card.statusName) {
  208. return card.statusName
  209. }
  210. const typeCode = card && card.typeCode
  211. const idx = TRAINING_STATUS_CODES.indexOf(typeCode)
  212. const i = idx >= 0 ? idx : 0
  213. return this.$t(`agriClassroomPage.trainingSubs.s${i}`)
  214. },
  215. onPickStatus(key) {
  216. if (this.selectedStatus === key) return
  217. this.selectedStatus = key
  218. },
  219. mapEnrollRow(row) {
  220. return {
  221. id: String(row.id != null ? row.id : ''),
  222. typeCode: row.trainingStatus || '',
  223. statusName: row.trainingStatusName || '',
  224. title: row.trainingTopic || '',
  225. introduction: row.trainingIntro || '',
  226. coverFileUrl: row.coverFileUrl || '',
  227. enrolled: row.actualEnrolledCount != null ? Number(row.actualEnrolledCount) : 0,
  228. total: row.plannedHeadCount != null ? Number(row.plannedHeadCount) : 0,
  229. trainingTime: formatDateTime(row.trainingTime),
  230. registrationEndTime: formatDateTime(row.registrationEndTime),
  231. enrollTime: formatDateTime(row.enrollTime)
  232. }
  233. },
  234. loadEnrollList(reset = false) {
  235. if (!reset) {
  236. if (this.listLoading || this.listLoadingMore || this.listNoMore) {
  237. return Promise.resolve()
  238. }
  239. this.pageNum += 1
  240. this.listLoadingMore = true
  241. } else {
  242. this.pageNum = 1
  243. this.listLoading = true
  244. }
  245. return listMyEnroll({ pageNum: this.pageNum, pageSize: this.pageSize })
  246. .then((res) => {
  247. const rows = res.rows || []
  248. this.listTotal = res.total != null ? Number(res.total) : 0
  249. const mapped = rows.map((row) => this.mapEnrollRow(row))
  250. this.allCards = reset ? mapped : this.allCards.concat(mapped)
  251. this.$nextTick(() => {
  252. this.calcLayoutHeights()
  253. this.tryAutoLoadMoreAfterFilter()
  254. })
  255. })
  256. .catch(() => {
  257. if (reset) {
  258. this.allCards = []
  259. this.listTotal = 0
  260. } else {
  261. this.pageNum -= 1
  262. }
  263. })
  264. .finally(() => {
  265. if (reset) {
  266. this.listLoading = false
  267. } else {
  268. this.listLoadingMore = false
  269. }
  270. })
  271. },
  272. tryAutoLoadMoreAfterFilter() {
  273. if (this.filteredCards.length > 0 || this.listNoMore || this.listLoading || this.listLoadingMore) {
  274. return
  275. }
  276. this.loadEnrollList(false)
  277. },
  278. tryAutoLoadMore() {
  279. if (this.listLoading || this.listLoadingMore || this.listNoMore) return
  280. const totalH = this.pairedRows.length * this.slotHeightPx
  281. const viewH = this.listHeightPx
  282. if (totalH > 0 && totalH <= viewH) {
  283. this.loadEnrollList(false)
  284. }
  285. },
  286. onVirtualListScroll(scrollTop) {
  287. if (this.loadMoreTimer) clearTimeout(this.loadMoreTimer)
  288. this.loadMoreTimer = setTimeout(() => {
  289. this.loadMoreTimer = null
  290. this.tryLoadMoreOnScroll(typeof scrollTop === 'number' ? scrollTop : 0)
  291. }, 180)
  292. },
  293. tryLoadMoreOnScroll(scrollTop) {
  294. if (this.listLoading || this.listLoadingMore || this.listNoMore) return
  295. const totalH = this.pairedRows.length * this.slotHeightPx
  296. const viewH = this.listHeightPx
  297. const threshold = Math.max(this.slotHeightPx * 2, 120)
  298. if (totalH <= viewH) {
  299. this.loadEnrollList(false)
  300. return
  301. }
  302. if (scrollTop + viewH >= totalH - threshold) {
  303. this.loadEnrollList(false)
  304. }
  305. },
  306. getNavigationBarHeight() {
  307. const sys = uni.getSystemInfoSync()
  308. const statusBar = sys.statusBarHeight || 0
  309. // #ifdef MP-WEIXIN
  310. try {
  311. const menu = uni.getMenuButtonBoundingClientRect()
  312. if (menu && menu.height) {
  313. return statusBar + (menu.top - statusBar) * 2 + menu.height
  314. }
  315. } catch (e) {
  316. /* noop */
  317. }
  318. // #endif
  319. return statusBar + 44
  320. },
  321. getViewportContentHeight() {
  322. const sys = uni.getSystemInfoSync()
  323. const navH = this.getNavigationBarHeight()
  324. // #ifdef H5
  325. if (typeof window !== 'undefined' && window.innerHeight) {
  326. return Math.max(320, window.innerHeight - navH)
  327. }
  328. // #endif
  329. const screenH = sys.screenHeight || sys.windowHeight || 600
  330. return Math.max(320, screenH - navH)
  331. },
  332. applyListHeightFallback() {
  333. const contentH = this.getViewportContentHeight()
  334. const topFallback = Math.ceil(uni.upx2px(120))
  335. const bodyPad = Math.ceil(uni.upx2px(ME_BODY_PAD_RPX))
  336. this.pageHeightPx = contentH
  337. this.listHeightPx = Math.max(240, contentH - topFallback - bodyPad)
  338. },
  339. calcLayoutHeights() {
  340. const contentH = this.getViewportContentHeight()
  341. const bodyPad = Math.ceil(uni.upx2px(ME_BODY_PAD_RPX))
  342. this.pageHeightPx = contentH
  343. uni.createSelectorQuery()
  344. .in(this)
  345. .select('.me-top')
  346. .boundingClientRect((topRect) => {
  347. const topH =
  348. topRect && topRect.height > 0 ? Math.ceil(topRect.height) : Math.ceil(uni.upx2px(120))
  349. this.listHeightPx = Math.max(240, contentH - topH - bodyPad)
  350. this.$nextTick(() => {
  351. this.$refs.meVList?.measureContainerHeight?.()
  352. this.tryAutoLoadMore()
  353. })
  354. })
  355. .exec()
  356. },
  357. cardCover(card) {
  358. if (card.coverFileUrl) {
  359. return resolveResourceUrl(card.coverFileUrl)
  360. }
  361. return this.coverSrc
  362. },
  363. onPreviewCover(card) {
  364. const url = this.cardCover(card)
  365. if (!url) return
  366. uni.previewImage({ urls: [url], current: 0 })
  367. },
  368. openTrainingDetail(card) {
  369. if (!card) return
  370. const q = [
  371. `id=${encodeURIComponent(card.id)}`,
  372. `title=${encodeURIComponent(card.title || '')}`,
  373. `type=${encodeURIComponent(card.typeCode || '')}`,
  374. `enrolled=${encodeURIComponent(card.enrolled)}`,
  375. `total=${encodeURIComponent(card.total)}`,
  376. `introduction=${encodeURIComponent(card.introduction || '')}`,
  377. `trainingTime=${encodeURIComponent(card.trainingTime || '')}`,
  378. `cover=${encodeURIComponent(card.coverFileUrl || '')}`,
  379. `regEnd=${encodeURIComponent(card.registrationEndTime || '')}`
  380. ].join('&')
  381. uni.navigateTo({ url: `${TRAINING_DETAIL_PATH}?${q}` })
  382. }
  383. }
  384. }
  385. </script>
  386. <style lang="scss" scoped>
  387. @import '@/styles/morandi.scss';
  388. @import '@/styles/tab-page.scss';
  389. .me-page {
  390. display: flex;
  391. flex-direction: column;
  392. min-width: 0;
  393. width: 100%;
  394. height: 100%;
  395. min-height: 100%;
  396. overflow: hidden;
  397. box-sizing: border-box;
  398. background: $morandi-bg-page;
  399. }
  400. .me-top {
  401. flex-shrink: 0;
  402. min-width: 0;
  403. padding: 20rpx 24rpx 16rpx;
  404. box-sizing: border-box;
  405. background: $morandi-bg-page;
  406. border-bottom: 1rpx solid $morandi-border-soft;
  407. }
  408. .me-chips {
  409. display: flex;
  410. flex-direction: row;
  411. flex-wrap: wrap;
  412. min-width: 0;
  413. gap: 12rpx;
  414. }
  415. .me-chip {
  416. padding: 2rpx 30rpx;
  417. box-sizing: border-box;
  418. border-radius: 999rpx;
  419. border: 1rpx solid $morandi-border-strong;
  420. background: $morandi-bg-card-inner;
  421. }
  422. .me-chip--on {
  423. border-color: #22c55e;
  424. background: rgba(34, 197, 94, 0.08);
  425. }
  426. .me-chip__txt {
  427. font-size: 24rpx;
  428. color: $morandi-text-secondary;
  429. text-align: center;
  430. word-break: break-word;
  431. overflow-wrap: anywhere;
  432. }
  433. .me-chip--on .me-chip__txt {
  434. color: #15803d;
  435. font-weight: 600;
  436. }
  437. .me-body {
  438. flex: 1;
  439. min-height: 0;
  440. height: 0;
  441. min-width: 0;
  442. display: flex;
  443. flex-direction: column;
  444. padding: 16rpx 24rpx 24rpx;
  445. box-sizing: border-box;
  446. overflow: hidden;
  447. }
  448. .me-list-wrap {
  449. flex: 1;
  450. min-height: 0;
  451. display: flex;
  452. flex-direction: column;
  453. min-width: 0;
  454. }
  455. .me-vlist {
  456. flex: 1;
  457. min-height: 0;
  458. width: 100%;
  459. height: 100%;
  460. }
  461. .me-empty {
  462. flex: 1;
  463. display: flex;
  464. align-items: center;
  465. justify-content: center;
  466. min-height: 0;
  467. padding: 48rpx 24rpx;
  468. box-sizing: border-box;
  469. }
  470. .me-empty__txt {
  471. color: $morandi-text-muted;
  472. text-align: center;
  473. }
  474. .me-pair-cell {
  475. height: 100%;
  476. box-sizing: border-box;
  477. display: flex;
  478. flex-direction: column;
  479. justify-content: flex-start;
  480. }
  481. .me-pair {
  482. display: flex;
  483. flex-direction: row;
  484. align-items: stretch;
  485. gap: 20rpx;
  486. min-width: 0;
  487. box-sizing: border-box;
  488. }
  489. .me-pair__col {
  490. flex: 1;
  491. min-width: 0;
  492. display: flex;
  493. flex-direction: column;
  494. }
  495. .me-card {
  496. flex: 1;
  497. min-height: 0;
  498. min-width: 0;
  499. display: flex;
  500. flex-direction: column;
  501. gap: 12rpx;
  502. padding: 12rpx;
  503. box-sizing: border-box;
  504. border-radius: 16rpx;
  505. background: $morandi-bg-card;
  506. border: 1rpx solid $morandi-border;
  507. }
  508. .me-card__img {
  509. width: 100%;
  510. display: block;
  511. }
  512. .me-card__title {
  513. font-size: 30rpx;
  514. font-weight: 600;
  515. line-height: 1.4;
  516. color: #111827;
  517. word-break: break-word;
  518. overflow-wrap: anywhere;
  519. }
  520. .me-card__foot {
  521. display: flex;
  522. flex-direction: row;
  523. flex-wrap: wrap;
  524. align-items: center;
  525. gap: 8rpx 12rpx;
  526. min-width: 0;
  527. }
  528. .me-card__meta {
  529. font-size: 22rpx;
  530. line-height: 1.45;
  531. color: $morandi-accent-soft;
  532. }
  533. .me-card__meta--num {
  534. color: #15803d;
  535. font-weight: 600;
  536. }
  537. .me-page.lang-bo {
  538. .me-card__title {
  539. font-size: 26rpx;
  540. line-height: 1.65;
  541. letter-spacing: 2rpx;
  542. font-family: 'Noto Sans Tibetan', 'PingFang SC', 'Microsoft YaHei', sans-serif;
  543. }
  544. .me-chip__txt {
  545. letter-spacing: 2rpx;
  546. font-family: 'Noto Sans Tibetan', 'PingFang SC', 'Microsoft YaHei', sans-serif;
  547. }
  548. }
  549. </style>