西藏巴青项目

index.vue 16KB

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