西藏巴青项目

index.vue 12KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  1. <template>
  2. <!-- 培训详情:上懒加载图;中白底信息;下白底仅展示一个 up-button(随状态切换) -->
  3. <view :class="pageRootClass" class="tab-page td-page">
  4. <scroll-view scroll-y class="td-scroll" enable-back-to-top>
  5. <view class="td-inner">
  6. <view class="td-top">
  7. <up-lazy-load
  8. class="td-cover"
  9. :image="coverSrc"
  10. height="360"
  11. :border-radius="16"
  12. img-mode="aspectFill"
  13. :threshold="120"
  14. :index="courseId || 'td'"
  15. @click="onPreviewCover"
  16. />
  17. </view>
  18. <view class="td-panel td-panel--info">
  19. <text class="td-title">{{ displayTitle }}</text>
  20. <text class="text-body td-line">{{ $t('trainingDetailPage.labelRegisterTime') }}:{{ registerRange }}</text>
  21. <text class="text-body td-line">{{ $t('trainingDetailPage.labelEnrolled') }}:{{ enrolledDisplay }}</text>
  22. <text class="text-body td-line">{{ $t('trainingDetailPage.labelPlace') }}:{{ placeText }}</text>
  23. <text class="text-body td-line td-line--block">{{ $t('trainingDetailPage.labelIntro') }}:{{ introText }}</text>
  24. <text class="text-body td-line td-line--block">{{ $t('trainingDetailPage.labelOutcome') }}:{{ outcomeText }}</text>
  25. </view>
  26. <view class="td-panel td-panel--actions">
  27. <up-button
  28. class="td-btn"
  29. :type="actionBtn.buttonType"
  30. :plain="actionBtn.plain"
  31. :hairline="actionBtn.hairline"
  32. :text="actionBtn.text"
  33. :disabled="actionBtn.disabled"
  34. @click="onActionBtn"
  35. />
  36. </view>
  37. </view>
  38. </scroll-view>
  39. </view>
  40. </template>
  41. <script>
  42. import UButton from 'uview-plus/components/u-button/u-button.vue'
  43. import ULazyLoad from 'uview-plus/components/u-lazy-load/u-lazy-load.vue'
  44. import tabPage from '@/mixins/tabPage'
  45. import { resolveResourceUrl } from '@/utils/resourceUrl'
  46. import {
  47. getMyEnrollDetail,
  48. enrollPracticalTraining,
  49. cancelEnrollPracticalTraining
  50. } from '@/api/practicalTraining'
  51. const COVER = '/static/ai/hero.png'
  52. /** 006001–006004 → 按钮状态索引(未开始/报名中/已报满/已截止) */
  53. const TRAINING_STATUS_INDEX = {
  54. '006001': 0,
  55. '006002': 1,
  56. '006003': 2,
  57. '006004': 3
  58. }
  59. function formatDateTime(val) {
  60. if (val == null || val === '') return ''
  61. const s = String(val)
  62. return s.length >= 19 ? s.slice(0, 19) : s.slice(0, 10)
  63. }
  64. export default {
  65. components: {
  66. 'up-button': UButton,
  67. 'up-lazy-load': ULazyLoad
  68. },
  69. mixins: [tabPage],
  70. data() {
  71. return {
  72. navTitleKey: 'trainingDetailPage.navTitle',
  73. courseId: '',
  74. displayTitleRaw: '',
  75. typeCode: '',
  76. introduction: '',
  77. expectedOutcome: '',
  78. trainingTime: '',
  79. regStart: '',
  80. regEnd: '',
  81. enrolled: 0,
  82. total: 0,
  83. coverSrc: COVER,
  84. /** 当前用户是否已报名 */
  85. hasSigned: false,
  86. /** 接口返回:是否允许取消报名 */
  87. canCancelEnroll: false,
  88. detailLoading: false,
  89. actionSubmitting: false
  90. }
  91. },
  92. computed: {
  93. displayTitle() {
  94. return this.displayTitleRaw || this.$t('trainingDetailPage.titleFallback')
  95. },
  96. /** 0 未开始 1 报名中 2 已报满 3 已截止 */
  97. statusIndex() {
  98. if (TRAINING_STATUS_INDEX[this.typeCode] != null) {
  99. return TRAINING_STATUS_INDEX[this.typeCode]
  100. }
  101. return 1
  102. },
  103. registerRange() {
  104. if (this.regStart && this.regEnd) {
  105. return `${this.regStart} - ${this.regEnd}`
  106. }
  107. if (this.regStart) {
  108. return this.regStart
  109. }
  110. if (this.regEnd) {
  111. return this.regEnd
  112. }
  113. return this.$t('trainingDetailPage.noRegisterTime')
  114. },
  115. enrolledDisplay() {
  116. const cap = this.total > 0 ? this.total : 0
  117. const e = this.enrolled
  118. if (cap > 0) {
  119. return `${e}/${cap}`
  120. }
  121. return String(e)
  122. },
  123. placeText() {
  124. return this.trainingTime || this.$t('trainingDetailPage.noPlace')
  125. },
  126. introText() {
  127. return this.introduction || this.$t('trainingDetailPage.noIntro')
  128. },
  129. outcomeText() {
  130. return this.expectedOutcome || this.$t('trainingDetailPage.noOutcome')
  131. },
  132. canJoin() {
  133. return (
  134. this.statusIndex === 1 &&
  135. (this.total <= 0 || this.enrolled < this.total) &&
  136. !this.hasSigned &&
  137. !this.detailLoading &&
  138. !this.actionSubmitting
  139. )
  140. },
  141. canCancel() {
  142. return this.hasSigned && this.canCancelEnroll && !this.actionSubmitting
  143. },
  144. actionBtn() {
  145. const txt = (key) => this.$t(`trainingDetailPage.${key}`)
  146. const gray = (kind, key) => ({
  147. kind,
  148. text: txt(key),
  149. disabled: true,
  150. plain: true,
  151. hairline: true,
  152. buttonType: 'info'
  153. })
  154. if (this.detailLoading || this.actionSubmitting) {
  155. return gray('loading', 'btnLoading')
  156. }
  157. if (this.hasSigned) {
  158. if (this.canCancelEnroll) {
  159. return {
  160. kind: 'cancel',
  161. text: txt('btnCancel'),
  162. disabled: false,
  163. plain: true,
  164. hairline: false,
  165. buttonType: 'default'
  166. }
  167. }
  168. return gray('closed', 'btnClosed')
  169. }
  170. if (this.statusIndex === 0) {
  171. return gray('not_start', 'btnNotStart')
  172. }
  173. if (this.statusIndex === 3) {
  174. return gray('closed', 'btnClosed')
  175. }
  176. const isFull =
  177. this.statusIndex === 2 ||
  178. (this.statusIndex === 1 && this.total > 0 && this.enrolled >= this.total)
  179. if (isFull) {
  180. return gray('full', 'btnFull')
  181. }
  182. if (this.statusIndex === 1 && this.canJoin) {
  183. return {
  184. kind: 'join',
  185. text: txt('btnJoin'),
  186. disabled: false,
  187. plain: false,
  188. hairline: false,
  189. buttonType: 'primary'
  190. }
  191. }
  192. return gray('full', 'btnFull')
  193. }
  194. },
  195. onLoad(query) {
  196. const q = query || {}
  197. this.courseId = this.decodeQuery(q, 'id')
  198. this.displayTitleRaw = this.decodeQuery(q, 'title')
  199. this.typeCode = this.decodeQuery(q, 'type')
  200. this.introduction = this.decodeQuery(q, 'introduction')
  201. this.expectedOutcome = this.decodeQuery(q, 'expectedOutcome')
  202. const en = parseInt(this.decodeQuery(q, 'enrolled'), 10)
  203. const tot = parseInt(this.decodeQuery(q, 'total'), 10)
  204. this.enrolled = Number.isFinite(en) && en >= 0 ? en : 0
  205. this.total = Number.isFinite(tot) && tot >= 0 ? tot : 0
  206. const cover = this.decodeQuery(q, 'cover')
  207. this.coverSrc = cover ? resolveResourceUrl(cover) : COVER
  208. this.applyQueryDates(q)
  209. },
  210. onShow() {
  211. const p = uni.setNavigationBarTitle({
  212. title: this.displayTitleRaw || this.$t(this.navTitleKey)
  213. })
  214. if (p && typeof p.catch === 'function') {
  215. p.catch(() => {})
  216. }
  217. this.loadEnrollDetail()
  218. },
  219. methods: {
  220. applyQueryDates(q) {
  221. this.regStart = formatDateTime(this.decodeQuery(q, 'regStart'))
  222. this.regEnd = formatDateTime(this.decodeQuery(q, 'regEnd'))
  223. this.trainingTime = formatDateTime(this.decodeQuery(q, 'trainingTime'))
  224. },
  225. loadEnrollDetail() {
  226. if (!this.courseId) return Promise.resolve()
  227. this.detailLoading = true
  228. return getMyEnrollDetail(this.courseId)
  229. .then((res) => {
  230. const d = res.data
  231. if (d) {
  232. this.applyDetailVo(d)
  233. this.hasSigned = true
  234. this.canCancelEnroll = d.canCancel === true
  235. }
  236. })
  237. .catch(() => {
  238. this.hasSigned = false
  239. this.canCancelEnroll = false
  240. })
  241. .finally(() => {
  242. this.detailLoading = false
  243. })
  244. },
  245. applyDetailVo(d) {
  246. if (d.trainingTopic) {
  247. this.displayTitleRaw = d.trainingTopic
  248. }
  249. if (d.trainingStatus) {
  250. this.typeCode = d.trainingStatus
  251. }
  252. this.introduction = d.trainingIntro || this.introduction || ''
  253. this.expectedOutcome = d.expectedOutcome || this.expectedOutcome || ''
  254. const timeText = formatDateTime(d.trainingTime)
  255. const locationText = d.trainingLocation || ''
  256. this.trainingTime = timeText || locationText || this.trainingTime
  257. this.regStart = formatDateTime(d.registrationStartTime) || this.regStart
  258. this.regEnd = formatDateTime(d.registrationEndTime) || this.regEnd
  259. if (d.actualEnrolledCount != null) {
  260. this.enrolled = Number(d.actualEnrolledCount)
  261. }
  262. if (d.plannedHeadCount != null) {
  263. this.total = Number(d.plannedHeadCount)
  264. }
  265. if (d.coverFileUrl) {
  266. this.coverSrc = resolveResourceUrl(d.coverFileUrl)
  267. }
  268. uni.setNavigationBarTitle({
  269. title: this.displayTitleRaw || this.$t(this.navTitleKey)
  270. })
  271. },
  272. decodeQuery(q, key) {
  273. const raw = q && q[key]
  274. if (raw == null || raw === '') {
  275. return ''
  276. }
  277. try {
  278. return decodeURIComponent(String(raw))
  279. } catch (e) {
  280. return String(raw)
  281. }
  282. },
  283. onPreviewCover() {
  284. if (!this.coverSrc) return
  285. uni.previewImage({ urls: [this.coverSrc], current: 0 })
  286. },
  287. onActionBtn() {
  288. if (this.actionBtn.disabled) return
  289. const k = this.actionBtn.kind
  290. if (k === 'join') this.onJoin()
  291. else if (k === 'cancel') this.onCancelJoin()
  292. },
  293. onJoin() {
  294. if (!this.canJoin || !this.courseId) return
  295. this.actionSubmitting = true
  296. enrollPracticalTraining(this.courseId)
  297. .then(() => {
  298. uni.showToast({ title: this.$t('trainingDetailPage.toastJoined'), icon: 'none' })
  299. return this.loadEnrollDetail()
  300. })
  301. .finally(() => {
  302. this.actionSubmitting = false
  303. })
  304. },
  305. onCancelJoin() {
  306. if (!this.canCancel || !this.courseId) return
  307. this.actionSubmitting = true
  308. cancelEnrollPracticalTraining(this.courseId)
  309. .then(() => {
  310. uni.showToast({ title: this.$t('trainingDetailPage.toastCanceled'), icon: 'none' })
  311. this.hasSigned = false
  312. this.canCancelEnroll = false
  313. if (this.enrolled > 0) {
  314. this.enrolled -= 1
  315. }
  316. if (this.typeCode === '006003' && this.total > 0 && this.enrolled < this.total) {
  317. this.typeCode = '006002'
  318. }
  319. })
  320. .finally(() => {
  321. this.actionSubmitting = false
  322. })
  323. }
  324. }
  325. }
  326. </script>
  327. <style lang="scss" scoped>
  328. @import '@/styles/morandi.scss';
  329. @import '@/styles/tab-page.scss';
  330. .td-page {
  331. display: flex;
  332. flex-direction: column;
  333. min-width: 0;
  334. min-height: 100%;
  335. box-sizing: border-box;
  336. background: $morandi-bg-page;
  337. }
  338. .td-scroll {
  339. flex: 1;
  340. min-height: 0;
  341. min-width: 0;
  342. }
  343. .td-inner {
  344. display: flex;
  345. flex-direction: column;
  346. gap: 24rpx;
  347. min-width: 0;
  348. padding: 24rpx 24rpx 48rpx;
  349. box-sizing: border-box;
  350. }
  351. .td-top {
  352. width: 100%;
  353. min-width: 0;
  354. border-radius: 16rpx;
  355. overflow: hidden;
  356. border: 1rpx solid $morandi-border;
  357. background: $morandi-bg-card-inner;
  358. }
  359. .td-cover {
  360. width: 100%;
  361. display: block;
  362. }
  363. .td-panel {
  364. min-width: 0;
  365. padding: 28rpx 24rpx;
  366. box-sizing: border-box;
  367. border-radius: 16rpx;
  368. background: #ffffff;
  369. border: 1rpx solid $morandi-border-soft;
  370. }
  371. .td-panel--info {
  372. display: flex;
  373. flex-direction: column;
  374. gap: 16rpx;
  375. }
  376. .td-title {
  377. font-size: 34rpx;
  378. font-weight: 700;
  379. line-height: 1.45;
  380. color: #111827;
  381. word-break: break-word;
  382. overflow-wrap: anywhere;
  383. }
  384. .td-line {
  385. font-size: 26rpx;
  386. line-height: 1.5;
  387. color: $morandi-text-secondary;
  388. word-break: break-word;
  389. overflow-wrap: anywhere;
  390. }
  391. .td-line--block {
  392. line-height: 1.55;
  393. }
  394. .td-panel--actions {
  395. display: flex;
  396. flex-direction: column;
  397. min-width: 0;
  398. }
  399. .td-btn {
  400. width: 100%;
  401. }
  402. .td-page.lang-bo {
  403. .td-title {
  404. font-size: 30rpx;
  405. line-height: 1.75;
  406. letter-spacing: 2rpx;
  407. font-family: 'Noto Sans Tibetan', 'PingFang SC', 'Microsoft YaHei', sans-serif;
  408. }
  409. .td-line {
  410. font-size: 24rpx;
  411. line-height: 1.75;
  412. letter-spacing: 2rpx;
  413. font-family: 'Noto Sans Tibetan', 'PingFang SC', 'Microsoft YaHei', sans-serif;
  414. }
  415. }
  416. </style>