图书馆小程序
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

613 lines
16 KiB

5 months ago
3 weeks ago
4 months ago
1 month ago
4 months ago
1 month ago
4 months ago
5 months ago
4 months ago
1 month ago
1 month ago
4 months ago
1 month ago
4 months ago
5 months ago
1 month ago
5 months ago
4 months ago
1 month ago
5 months ago
4 months ago
1 month ago
4 months ago
1 month ago
5 months ago
4 months ago
5 months ago
4 months ago
1 month ago
4 months ago
1 month ago
4 months ago
1 month ago
1 month ago
4 months ago
1 month ago
1 month ago
1 month ago
5 months ago
4 months ago
5 months ago
4 months ago
5 months ago
4 months ago
1 month ago
4 months ago
5 months ago
1 month ago
5 months ago
1 month ago
4 months ago
5 months ago
1 month ago
  1. <template>
  2. <view class="activity-detail">
  3. <image class="activity-img" :src="detail.coverImage||detail.cover_image" mode="aspectFill"></image>
  4. <view class="activity-base">
  5. <text class="title">{{ detail.title }}</text>
  6. <view class="time">
  7. <uni-icons class="detail-icon" custom-prefix="iconfont" type="icon-shijian" size="15"></uni-icons>
  8. <text>{{ detail.startTime || formatTime(detail.start_time) }} - {{ detail.endTime || formatTime(detail.end_time) }}</text>
  9. </view>
  10. <view class="location">
  11. <uni-icons class="detail-icon" type="location" size="20"></uni-icons>
  12. <text>{{ detail.activityLocation || detail.activity_location }}</text>
  13. </view>
  14. </view>
  15. <view class="rich-content">
  16. <text class="content-title">活动介绍/回顾</text>
  17. <div v-if="detail.localImg" style="text-align:center; margin:12px 0;">
  18. <image :src="require(`@/static/${detail.localImg}`)" mode="widthFix" style="width:90%; border-radius:8px;"></image>
  19. </div>
  20. <rich-text class="rich-text" :nodes="contentNodes"></rich-text>
  21. </view>
  22. <view class="detail-bottom">
  23. <view class="detail-left">
  24. <button open-type="share" class="handle-btn">
  25. <uni-icons custom-prefix="iconfont" type="icon-fenxiang01" size="20"></uni-icons>
  26. <text>分享</text>
  27. </button>
  28. </view>
  29. <button
  30. class="join-btn"
  31. :class="getJoinBtnClass()"
  32. @click="handleJoin"
  33. >
  34. {{ getJoinBtnText() }}
  35. </button>
  36. </view>
  37. <view class="modal-mask" v-if="showJoinModal" @click="closeJoinModal">
  38. <view class="modal-content" @click.stop>
  39. <view class="modal-header">
  40. <text class="modal-title">报名信息</text>
  41. <uni-icons class="close-icon" type="close" size="24" @click="closeJoinModal"></uni-icons>
  42. </view>
  43. <view class="modal-body">
  44. <view class="form-item">
  45. <text class="form-label">姓名</text>
  46. <input class="form-input" v-model="joinForm.name" placeholder="请输入您的姓名" />
  47. </view>
  48. <view class="form-item">
  49. <text class="form-label">联系方式</text>
  50. <view style="display:flex; justify-content: space-between; align-items:center;">
  51. <input class="form-input" v-model="joinForm.phone" placeholder="请输入手机号码" type="number" maxlength="11" />
  52. <button class="get-phone-btn" open-type="getPhoneNumber" @getphonenumber="getPhoneNumber">
  53. 一键获取
  54. </button>
  55. </view>
  56. </view>
  57. <view class="form-item">
  58. <text class="form-label">参加人数</text>
  59. <view class="select-wrapper">
  60. <picker
  61. mode="selector"
  62. :range="rangeText"
  63. :value="joinForm.personCount - 1"
  64. @change="onPickerChange"
  65. class="picker-select"
  66. >
  67. <view class="picker-display">
  68. {{ joinForm.personCountText || '请选择参加人数' }}
  69. </view>
  70. </picker>
  71. </view>
  72. </view>
  73. </view>
  74. <view class="modal-footer">
  75. <button class="cancel-btn" @click="closeJoinModal">取消</button>
  76. <button class="submit-btn" @click="submitJoin">确认报名</button>
  77. </view>
  78. </view>
  79. </view>
  80. </view>
  81. </template>
  82. <script>
  83. // import config from '@/utils/config';
  84. // import { getOpenId } from '@/utils/storage';
  85. import { FetchSessionKey, FetchDecryptPhone } from '@/api/user.js';
  86. import { FetchSignActivity, FetchCancelSignActivity } from '@/api/activity.js';
  87. export default {
  88. data() {
  89. return {
  90. detail: {},
  91. activityId: "",
  92. contentNodes: "",
  93. showJoinModal: false,
  94. joinForm: {
  95. name: "",
  96. phone: "",
  97. personCount: 1,
  98. personCountText: ""
  99. },
  100. personOptions: [
  101. { value: 1, text: "1人" },
  102. { value: 2, text: "2人" },
  103. { value: 3, text: "3人" },
  104. { value: 4, text: "4人" },
  105. { value: 5, text: "5人" },
  106. ],
  107. rangeText: [],
  108. sessionKey: "",
  109. openid: ""
  110. };
  111. },
  112. onLoad(options) {
  113. const item = JSON.parse(decodeURIComponent(options.item));
  114. this.detail = item;
  115. console.log('this.detail',this.detail);
  116. this.activityId = item.id || item.activityId;
  117. this.formatContent();
  118. this.rangeText = this.personOptions.map(item => item.text);
  119. this.joinForm.personCount = this.personOptions[0].value;
  120. this.joinForm.personCountText = this.personOptions[0].text;
  121. this.getSessionKey();
  122. },
  123. methods: {
  124. formatContent() {
  125. let content = this.detail.activityContent || this.detail.activity_content || "";
  126. if (!content) {
  127. this.contentNodes = "";
  128. return;
  129. }
  130. content = content.replace(/&amp;/g, '&');
  131. const imgReg = /<img\s+[^>]*src\s*=\s*["']?([^"'> ]+)["']?[^>]*>/gi;
  132. content = content.replace(imgReg, (match, src) => {
  133. return `<div><img src="${src}" style="width:100%;max-width:750px;display:block;margin:0 auto;"></div>`;
  134. });
  135. // ========== p标签:原有样式保留,缺少line-height自动补充,统一margin ==========
  136. const pHasStyleReg = /<p(\s+[^>]*?)style\s*=\s*["']([^"']+)["']([^>]*)>/gi;
  137. content = content.replace(pHasStyleReg, (match, before, oldStyleStr, after) => {
  138. const hasLineHeight = /line-height\s*:/.test(oldStyleStr);
  139. let append = 'margin:15px 0;';
  140. if (!hasLineHeight) {
  141. append = 'line-height:1.5;margin:15px 0;';
  142. }
  143. const newStyle = oldStyleStr + ';' + append;
  144. return `<p${before}style="${newStyle}"${after}>`;
  145. });
  146. const pNoStyleReg = /<p(?!\s+style\s*=)([^>]*)>/gi;
  147. content = content.replace(pNoStyleReg, '<p$1 style="line-height:1.5;margin:15px 0;">');
  148. // ========== blockquote 引用样式 ==========
  149. content = content.replace(/<blockquote([^>]*)>/gi,
  150. `<blockquote$1 style="background:#f5f7fa;border-left:8px solid #01a4fe;display:block;font-size:100%;line-height:1.5;margin:10px 0;padding:10px;">`);
  151. // ========== 表格基础样式 ==========
  152. content = content.replace(/<table([^>]*)>/gi,
  153. `<table$1 style="width:100% !important;border-collapse:collapse;margin:10px 0;">`);
  154. content = content.replace(/<th([^>]*)>/gi,
  155. `<th$1 style="border:1px solid #dcdfe6;padding:8px 12px;text-align:center;font-size:14px;">`);
  156. content = content.replace(/<td([^>]*)>/gi,
  157. `<td$1 style="border:1px solid #dcdfe6;padding:8px 12px;text-align:center;font-size:14px;">`);
  158. this.contentNodes = content;
  159. },
  160. onPickerChange(e) {
  161. const index = e.detail.value;
  162. this.joinForm.personCount = this.personOptions[index].value;
  163. this.joinForm.personCountText = this.personOptions[index].text;
  164. },
  165. async getSessionKey() {
  166. try {
  167. uni.login({
  168. success: async (loginRes) => {
  169. if (loginRes.code) {
  170. const res = await FetchSessionKey({
  171. libcode: getApp().globalData.libcode,
  172. code: loginRes.code
  173. });
  174. if (res.code === 200) {
  175. this.sessionKey = res.data.session_key;
  176. this.openid = res.data.openid;
  177. }
  178. }
  179. }
  180. });
  181. } catch (error) {
  182. console.error('获取sessionKey失败:', error);
  183. }
  184. },
  185. getPhoneNumber(e) {
  186. if (e.detail.errMsg !== 'getPhoneNumber:ok') {
  187. uni.showToast({ title: '取消授权', icon: 'none' });
  188. return;
  189. }
  190. if (!this.sessionKey) {
  191. uni.showToast({ title: '请稍后再试', icon: 'none' });
  192. return;
  193. }
  194. FetchDecryptPhone({
  195. sessionKey: this.sessionKey,
  196. encryptedData: e.detail.encryptedData,
  197. iv: e.detail.iv
  198. }).then(res => {
  199. if (res.code === 200) {
  200. this.joinForm.phone = res.data.phoneNumber || '';
  201. uni.showToast({ title: '获取成功', icon: 'success' });
  202. } else {
  203. uni.showToast({ title: res.message || '获取失败', icon: 'none' });
  204. }
  205. }).catch(() => {
  206. uni.showToast({ title: '获取手机号失败', icon: 'none' });
  207. });
  208. },
  209. getJoinBtnClass() {
  210. const d = this.detail;
  211. // 已结束 / 已取消 禁用
  212. if (d.status === '已结束' || d.status === '已取消') {
  213. return 'disabled-btn'
  214. }
  215. return ''
  216. },
  217. // 获取按钮文字
  218. getJoinBtnText() {
  219. const d = this.detail;
  220. if(d.status === '已结束') return '活动结束'
  221. if(d.status === '已取消') return '活动取消'
  222. // 未开始,有预约记录,没有取消,显示取消预约
  223. if(d.status === '未开始' && d.isCancel === false && !!d.signId){
  224. return '取消预约'
  225. }
  226. return '我要参加'
  227. },
  228. handleJoin() {
  229. const d = this.detail
  230. // 已结束、已取消直接拦截
  231. if (['已结束', '已取消'].includes(d.status)) {
  232. return;
  233. }
  234. // 未开始、有预约记录、未取消:执行取消报名
  235. if(d.status === '未开始' && d.isCancel === false && !!d.signId){
  236. uni.showModal({
  237. title: '提示',
  238. content: `确定要取消活动【${d.title}】的预约吗?`,
  239. success: async (res) => {
  240. if(res.confirm){
  241. try {
  242. const apiRes = await FetchCancelSignActivity({
  243. id: d.signId
  244. })
  245. if(apiRes.code === 200){
  246. uni.showToast({ title: '取消成功', icon: 'success' })
  247. // 发送全局事件,告诉预约列表页需要刷新
  248. uni.$emit('refreshMySignList')
  249. setTimeout(() => {
  250. uni.navigateBack()
  251. }, 700)
  252. }else{
  253. uni.showToast({ title: apiRes.message || '取消失败', icon: 'none' })
  254. }
  255. } catch (err) {
  256. console.error('取消报名异常', err)
  257. uni.showToast({ title: '网络异常,取消失败', icon: 'none' })
  258. }
  259. }
  260. }
  261. })
  262. return
  263. }
  264. //打开报名弹窗
  265. this.openJoinModal();
  266. },
  267. closeJoinModal() {
  268. this.showJoinModal = false;
  269. },
  270. onPersonChange(e) {
  271. this.joinForm.personCount = e.detail.value + 1;
  272. },
  273. openJoinModal() {
  274. // 每次打开弹窗完整重置表单,包含picker显示文本
  275. this.joinForm = {
  276. name: "",
  277. phone: "",
  278. personCount: 1,
  279. personCountText: "1人"
  280. }
  281. this.showJoinModal = true
  282. },
  283. submitJoin() {
  284. if (!this.joinForm.name) {
  285. uni.showToast({ title: '请输入姓名', icon: 'none' });
  286. return;
  287. }
  288. if (!this.joinForm.phone) {
  289. uni.showToast({ title: '请输入联系方式', icon: 'none' });
  290. return;
  291. }
  292. const phoneReg = /^1[3-9]\d{9}$/;
  293. if (!phoneReg.test(this.joinForm.phone)) {
  294. uni.showToast({ title: '请输入正确的手机号码', icon: 'none' });
  295. return;
  296. }
  297. const params = {
  298. // libcode: getApp().globalData.libcode,
  299. activityId: this.activityId,
  300. signName: this.joinForm.name,
  301. signPhone: this.joinForm.phone,
  302. signNum: this.joinForm.personCount,
  303. openid: this.openid,
  304. // sessionKey: this.sessionKey
  305. }
  306. FetchSignActivity(params).then(res => {
  307. console.log('FetchSignActivity',res)
  308. if (res.code === 200) {
  309. uni.showToast({
  310. title: '报名成功',
  311. icon: 'success'
  312. });
  313. uni.$emit('refreshMySignList')
  314. this.closeJoinModal();
  315. } else {
  316. console.log('error',res)
  317. uni.showToast({
  318. title: res.message || '报名失败',
  319. icon: 'none'
  320. });
  321. }
  322. }).catch((err) => {
  323. console.log('catch err', err)
  324. if(err){
  325. uni.showToast({
  326. title: err,
  327. icon: 'none'
  328. })
  329. }else{
  330. uni.showToast({
  331. title: '报名失败',
  332. icon: 'none'
  333. });
  334. }
  335. });
  336. },
  337. formatTime(timestamp) {
  338. if (!timestamp) return '';
  339. const date = new Date(timestamp);
  340. const y = date.getFullYear();
  341. const m = String(date.getMonth() + 1).padStart(2, '0');
  342. const d = String(date.getDate()).padStart(2, '0');
  343. const h = String(date.getHours()).padStart(2, '0');
  344. const min = String(date.getMinutes()).padStart(2, '0');
  345. return `${y}-${m}-${d} ${h}:${min}`;
  346. },
  347. onShareAppMessage() {
  348. return {
  349. title: this.detail.title,
  350. path: "/subpkg/pages/activity-detail/activity-detail?item=" + encodeURIComponent(JSON.stringify(this.detail)),
  351. imageUrl: this.detail.coverImage
  352. };
  353. }
  354. }
  355. };
  356. </script>
  357. <style lang="scss" scoped>
  358. .activity-detail {
  359. padding-bottom: 60px;
  360. background: #f5f5f5;
  361. }
  362. .activity-img {
  363. width: 100%;
  364. height: 180px;
  365. }
  366. .activity-base {
  367. background: #fff;
  368. padding: 15px;
  369. margin-bottom: 10px;
  370. .title {
  371. font-size: 16px;
  372. font-weight: bold;
  373. padding-bottom: 10px;
  374. }
  375. .time,
  376. .location {
  377. display: flex;
  378. align-items: center;
  379. font-size: 12px;
  380. color: #999;
  381. padding-top: 10px;
  382. .detail-icon {
  383. padding-right: 4px;
  384. }
  385. }
  386. .time{
  387. padding-left: 3px;
  388. }
  389. }
  390. .rich-content {
  391. background: #fff;
  392. padding: 10px;
  393. .content-title{
  394. position: relative;
  395. font-size: 16px;
  396. font-weight: bold;
  397. color: #333;
  398. padding-left: 12px;
  399. margin-bottom: 20px;
  400. &::before{
  401. position: absolute;
  402. left: 0;
  403. top: 50%;
  404. margin-top: -9px;
  405. content: "";
  406. width: 4px;
  407. height: 18px;
  408. background-color: #01a4fe;
  409. }
  410. }
  411. }
  412. .rich-text{
  413. ::v-deep p{
  414. line-height: 1.5;
  415. margin: 15px 0;
  416. }
  417. }
  418. .detail-bottom {
  419. position: fixed;
  420. bottom: 0;
  421. left: 0;
  422. right: 0;
  423. height: 50px;
  424. background: #fff;
  425. display: flex;
  426. align-items: center;
  427. justify-content: space-between;
  428. padding: 0 15px;
  429. box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.05);
  430. }
  431. .detail-left {
  432. display: flex;
  433. gap: 10px;
  434. }
  435. .handle-btn {
  436. display: flex;
  437. align-items: center;
  438. gap: 4px;
  439. background: #f5f5f5;
  440. border-radius: 30px;
  441. padding: 0 12px;
  442. height: 36px;
  443. font-size: 14px;
  444. }
  445. .join-btn {
  446. background: #01a4fe;
  447. color: #fff;
  448. border-radius: 30px;
  449. padding: 0 20px;
  450. height: 36px;
  451. }
  452. .disabled-btn {
  453. background: #ccc !important;
  454. }
  455. .modal-mask {
  456. position: fixed;
  457. top: 0;
  458. left: 0;
  459. right: 0;
  460. bottom: 0;
  461. background: rgba(0, 0, 0, 0.5);
  462. display: flex;
  463. align-items: center;
  464. justify-content: center;
  465. z-index: 1000;
  466. }
  467. .modal-content {
  468. width: 85%;
  469. background: #fff;
  470. border-radius: 8px;
  471. overflow: hidden;
  472. }
  473. .modal-header {
  474. display: flex;
  475. align-items: center;
  476. justify-content: space-between;
  477. padding: 15px;
  478. border-bottom: 1px solid #f0f0f0;
  479. .modal-title {
  480. font-size: 16px;
  481. font-weight: bold;
  482. color: #333;
  483. }
  484. .close-icon {
  485. color: #999;
  486. }
  487. }
  488. .modal-body {
  489. padding: 15px;
  490. }
  491. .form-item {
  492. margin-bottom: 15px;
  493. .form-label {
  494. display: block;
  495. font-size: 14px;
  496. color: #333;
  497. margin-bottom: 8px;
  498. }
  499. .form-input {
  500. width: calc(100% - 25px);
  501. height: 40px;
  502. background: #f8f8f8;
  503. border-radius: 8px;
  504. padding: 0 12px;
  505. font-size: 14px;
  506. }
  507. .select-wrapper {
  508. width: calc(100% - 25px);
  509. height: 40px;
  510. background: #f8f8f8;
  511. border-radius: 8px;
  512. display: flex;
  513. align-items: center;
  514. padding: 0 12px;
  515. font-size: 14px;
  516. ::v-deep .picker-select{
  517. width: 100%;
  518. }
  519. .select-value {
  520. font-size: 14px;
  521. color: #333;
  522. width: 100%;
  523. display: flex;
  524. justify-content: space-between;
  525. align-items: center;
  526. &::after {
  527. content: '';
  528. width: 0;
  529. height: 0;
  530. border-left: 5px solid transparent;
  531. border-right: 5px solid transparent;
  532. border-top: 5px solid #999;
  533. }
  534. }
  535. }
  536. }
  537. .modal-footer {
  538. display: flex;
  539. border-top: 1px solid #f0f0f0;
  540. .cancel-btn,
  541. .submit-btn {
  542. flex: 1;
  543. height: 44px;
  544. line-height: 44px;
  545. text-align: center;
  546. font-size: 15px;
  547. border-radius: 0;
  548. border: none;
  549. margin: 0;
  550. padding: 0;
  551. &::after {
  552. border: none;
  553. border-radius: 0;
  554. }
  555. }
  556. .cancel-btn {
  557. background: #f5f5f5;
  558. color: #666;
  559. // border-right: 1px solid #f0f0f0;
  560. }
  561. .submit-btn {
  562. background: #01a4fe;
  563. color: #fff;
  564. }
  565. }
  566. .get-phone-btn {
  567. font-size: 12px;
  568. color: #fff;
  569. background-color: #01a4fe;
  570. width: 90px;
  571. border-radius: 20px;
  572. padding: 10px;
  573. border: none;
  574. line-height: 1.2;
  575. margin-left: 10px;
  576. &::after {
  577. border: none;
  578. }
  579. }
  580. </style>