create.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677
  1. <template>
  2. <view class="container">
  3. <view class="form-card">
  4. <!-- 活动标题 -->
  5. <view class="form-item">
  6. <text class="form-label">活动标题 *</text>
  7. <input class="form-input" v-model="form.title" placeholder="请输入活动标题" maxlength="50" />
  8. </view>
  9. <!-- 活动类型 -->
  10. <view class="form-item">
  11. <text class="form-label">活动类型 *</text>
  12. <picker class="form-picker" :value="typeIdx" :range="typeOptions" range-key="label" @change="onTypeChange">
  13. <text class="picker-text">{{ form.activityType ? typeLabel(form.activityType) : '请选择类型' }}</text>
  14. </picker>
  15. </view>
  16. <!-- 开始时间 -->
  17. <view class="form-item">
  18. <text class="form-label">开始时间 *</text>
  19. <picker class="form-picker" mode="date" :value="datePart" @change="onStartDateChange">
  20. <text class="picker-text">{{ datePart || '选择日期' }}</text>
  21. </picker>
  22. <picker class="form-picker" mode="time" :value="timePart" @change="onStartTimeChange" style="margin-top: 16rpx;">
  23. <text class="picker-text">{{ timePart || '选择时间' }}</text>
  24. </picker>
  25. </view>
  26. <!-- 结束时间 -->
  27. <view class="form-item">
  28. <text class="form-label">结束时间</text>
  29. <picker class="form-picker" mode="date" :value="endDatePart" @change="onEndDateChange">
  30. <text class="picker-text">{{ endDatePart || '选择日期(可选)' }}</text>
  31. </picker>
  32. <picker class="form-picker" mode="time" :value="endTimePart" @change="onEndTimeChange" style="margin-top: 16rpx;">
  33. <text class="picker-text">{{ endTimePart || '选择时间(可选)' }}</text>
  34. </picker>
  35. </view>
  36. <!-- 报名截止时间 -->
  37. <view class="form-item">
  38. <text class="form-label">报名截止时间</text>
  39. <picker class="form-picker" mode="date" :value="regDatePart" @change="onRegDateChange">
  40. <text class="picker-text">{{ regDatePart || '选择日期(不填=活动开始前)' }}</text>
  41. </picker>
  42. <picker class="form-picker" mode="time" :value="regTimePart" @change="onRegTimeChange" style="margin-top: 16rpx;">
  43. <text class="picker-text">{{ regTimePart || '选择时间' }}</text>
  44. </picker>
  45. </view>
  46. <!-- 活动地点 -->
  47. <view class="form-item">
  48. <text class="form-label">活动地点</text>
  49. <input class="form-input" v-model="form.location" placeholder="请输入活动地点" maxlength="100" />
  50. </view>
  51. <!-- 人数 + 价格 -->
  52. <view class="form-item-row">
  53. <view class="form-item half">
  54. <text class="form-label">最大人数</text>
  55. <input class="form-input" type="number" v-model="maxParticipantsStr" placeholder="0=不限" />
  56. </view>
  57. <view class="form-item half">
  58. <text class="form-label">孩子费用(元)</text>
  59. <input class="form-input" type="digit" v-model="childFeeYuan" placeholder="0=免费" />
  60. </view>
  61. </view>
  62. <view class="form-item-row">
  63. <view class="form-item half">
  64. <text class="form-label">孩子会员费(元)</text>
  65. <input class="form-input" type="digit" v-model="childMemberFeeYuan" placeholder="0=免费" />
  66. </view>
  67. <view class="form-item half">
  68. <text class="form-label">家长费用(元)</text>
  69. <input class="form-input" type="digit" v-model="parentFeeYuan" placeholder="0=免费" />
  70. </view>
  71. </view>
  72. <view class="form-item">
  73. <text class="form-label">家长会员费(元)</text>
  74. <input class="form-input" type="digit" v-model="parentMemberFeeYuan" placeholder="0=免费" />
  75. </view>
  76. <!-- 封面图片 -->
  77. <view class="form-item">
  78. <text class="form-label">封面图片</text>
  79. <view class="cover-upload" @click="chooseImage">
  80. <image v-if="form.coverImage" class="preview-img" :src="getCoverUrl(form.coverImage)" mode="aspectFill" @click.stop />
  81. <view v-else class="upload-placeholder">
  82. <text class="upload-icon">📷</text>
  83. <text class="upload-text">点击上传封面图</text>
  84. </view>
  85. </view>
  86. <view class="cover-actions" v-if="form.coverImage">
  87. <text class="cover-action" @click="chooseImage">重新选择</text>
  88. <text class="cover-action-danger" @click="removeCover">移除</text>
  89. </view>
  90. </view>
  91. <!-- 活动描述 -->
  92. <view class="form-item">
  93. <text class="form-label">活动描述</text>
  94. <textarea class="form-textarea" v-model="form.description" placeholder="请输入活动详细介绍" maxlength="500"
  95. auto-height />
  96. </view>
  97. <!-- 操作按钮 -->
  98. <view class="form-actions">
  99. <view class="btn-save" @click="handleSave">
  100. <text>保存为草稿</text>
  101. </view>
  102. <view class="btn-publish" @click="handlePublish">
  103. <text>{{ isAdmin ? '保存并发布' : '保存并提交审核' }}</text>
  104. </view>
  105. </view>
  106. </view>
  107. <view class="loading" v-if="loading">
  108. <view class="loading-spinner"></view>
  109. <text>加载中...</text>
  110. </view>
  111. </view>
  112. </template>
  113. <script>
  114. import { adminCreateActivity, adminUpdateActivity, adminPublishActivity, submitActivityForReview, getActivityDetail } from '../../utils/api.js'
  115. import config from '../../config.js'
  116. import { parseDate } from '../../utils/format.js'
  117. import { hasActivityManageRole } from '../../utils/activity-role.js'
  118. import { pickImage } from '../../utils/image-picker.js'
  119. var BASE_URL = config.api('')
  120. export default {
  121. computed: {
  122. // 后台活动管理员可直接发布;服务商账号只能保存后提交审核
  123. isAdmin: function() {
  124. return hasActivityManageRole()
  125. }
  126. },
  127. data() {
  128. return {
  129. isNew: true,
  130. activityId: null,
  131. loading: false,
  132. memberId: '',
  133. form: {
  134. title: '',
  135. description: '',
  136. coverImage: '',
  137. dimensionCode: '',
  138. activityType: '',
  139. status: 'draft',
  140. startTime: null,
  141. endTime: null,
  142. registrationDeadline: null,
  143. location: '',
  144. maxParticipants: 0,
  145. childFee: 0,
  146. childMemberFee: 0,
  147. parentFee: 0,
  148. parentMemberFee: 0,
  149. visibility: 'public',
  150. requireRegistration: 1,
  151. requireCheckinReview: 0,
  152. checkinPoints: 0
  153. },
  154. childFeeYuan: '0',
  155. childMemberFeeYuan: '0',
  156. parentFeeYuan: '0',
  157. parentMemberFeeYuan: '0',
  158. maxParticipantsStr: '0',
  159. datePart: '',
  160. timePart: '',
  161. endDatePart: '',
  162. endTimePart: '',
  163. regDatePart: '',
  164. regTimePart: '',
  165. typeIdx: 0,
  166. typeOptions: [
  167. { key: 'offline', label: '线下活动' },
  168. { key: 'online', label: '线上活动' },
  169. { key: 'campaign', label: '营销活动' }
  170. ]
  171. }
  172. },
  173. onLoad(options) {
  174. if (options.id) {
  175. this.activityId = parseInt(options.id)
  176. this.isNew = false
  177. uni.setNavigationBarTitle({ title: '编辑活动' })
  178. this.loadDetail()
  179. } else {
  180. uni.setNavigationBarTitle({ title: '创建活动' })
  181. }
  182. },
  183. methods: {
  184. loadDetail() {
  185. var self = this
  186. self.loading = true
  187. getActivityDetail(self.activityId).then(function(res) {
  188. self.loading = false
  189. if (res && res.code === 200 && res.data) {
  190. var d = res.data
  191. self.form.title = d.title || ''
  192. self.form.description = d.description || ''
  193. self.form.coverImage = d.coverImage || ''
  194. self.form.dimensionCode = d.dimensionCode || ''
  195. self.form.activityType = d.activityType || ''
  196. self.form.location = d.location || ''
  197. self.form.maxParticipants = d.maxParticipants || 0
  198. self.form.childFee = d.childFee || 0
  199. self.form.childMemberFee = d.childMemberFee || 0
  200. self.form.parentFee = d.parentFee || 0
  201. self.form.parentMemberFee = d.parentMemberFee || 0
  202. self.form.status = d.status || 'draft'
  203. self.form.visibility = d.visibility || 'public'
  204. self.maxParticipantsStr = String(self.form.maxParticipants)
  205. self.childFeeYuan = self.form.childFee ? (self.form.childFee / 100).toString() : '0'
  206. self.childMemberFeeYuan = self.form.childMemberFee ? (self.form.childMemberFee / 100).toString() : '0'
  207. self.parentFeeYuan = self.form.parentFee ? (self.form.parentFee / 100).toString() : '0'
  208. self.parentMemberFeeYuan = self.form.parentMemberFee ? (self.form.parentMemberFee / 100).toString() : '0'
  209. if (d.startTime) {
  210. var sd = parseDate(d.startTime)
  211. if (sd) {
  212. self.datePart = self.fmtDate(sd)
  213. self.timePart = self.fmtTime(sd)
  214. self.form.startTime = sd.toISOString()
  215. }
  216. }
  217. if (d.endTime) {
  218. var ed = parseDate(d.endTime)
  219. if (ed) {
  220. self.endDatePart = self.fmtDate(ed)
  221. self.endTimePart = self.fmtTime(ed)
  222. self.form.endTime = ed.toISOString()
  223. }
  224. }
  225. if (d.registrationDeadline) {
  226. var rd = parseDate(d.registrationDeadline)
  227. if (rd) {
  228. self.regDatePart = self.fmtDate(rd)
  229. self.regTimePart = self.fmtTime(rd)
  230. self.form.registrationDeadline = rd.toISOString()
  231. }
  232. }
  233. // sync type picker
  234. for (var j = 0; j < self.typeOptions.length; j++) {
  235. if (self.typeOptions[j].key === self.form.activityType) self.typeIdx = j
  236. }
  237. }
  238. }).catch(function() { self.loading = false })
  239. },
  240. onTypeChange(e) {
  241. this.typeIdx = e.detail.value
  242. this.form.activityType = this.typeOptions[this.typeIdx].key
  243. },
  244. onStartDateChange(e) {
  245. this.datePart = e.detail.value
  246. this.syncStartTime()
  247. },
  248. onStartTimeChange(e) {
  249. this.timePart = e.detail.value
  250. this.syncStartTime()
  251. },
  252. onEndDateChange(e) {
  253. this.endDatePart = e.detail.value
  254. this.syncEndTime()
  255. },
  256. onEndTimeChange(e) {
  257. this.endTimePart = e.detail.value
  258. this.syncEndTime()
  259. },
  260. onRegDateChange(e) {
  261. this.regDatePart = e.detail.value
  262. this.syncRegDeadline()
  263. },
  264. onRegTimeChange(e) {
  265. this.regTimePart = e.detail.value
  266. this.syncRegDeadline()
  267. },
  268. syncStartTime() {
  269. if (this.datePart && this.timePart) {
  270. this.form.startTime = this.datePart + 'T' + this.timePart + ':00'
  271. }
  272. },
  273. syncEndTime() {
  274. if (this.endDatePart && this.endTimePart) {
  275. this.form.endTime = this.endDatePart + 'T' + this.endTimePart + ':00'
  276. } else {
  277. this.form.endTime = null
  278. }
  279. },
  280. syncRegDeadline() {
  281. if (this.regDatePart && this.regTimePart) {
  282. this.form.registrationDeadline = this.regDatePart + 'T' + this.regTimePart + ':00'
  283. } else {
  284. this.form.registrationDeadline = null
  285. }
  286. },
  287. chooseImage() {
  288. var self = this
  289. pickImage({
  290. count: 1,
  291. sizeType: ['compressed'],
  292. success: function(res) {
  293. self.uploadCover(res.tempFilePaths[0])
  294. }
  295. })
  296. },
  297. uploadCover(filePath) {
  298. var self = this
  299. uni.uploadFile({
  300. url: BASE_URL + '/api/admin/articles/upload/image',
  301. filePath: filePath,
  302. name: 'file',
  303. header: { 'Authorization': 'Bearer ' + (uni.getStorageSync('token') || '') },
  304. success: function(res) {
  305. try {
  306. var data = JSON.parse(res.data)
  307. if (data.code === 200 && data.data) {
  308. self.form.coverImage = data.data
  309. } else {
  310. uni.showToast({ title: '上传失败', icon: 'none' })
  311. }
  312. } catch (e) {
  313. uni.showToast({ title: '上传失败', icon: 'none' })
  314. }
  315. },
  316. fail: function() {
  317. uni.showToast({ title: '上传失败', icon: 'none' })
  318. }
  319. })
  320. },
  321. onImgError() {
  322. this.form.coverImage = ''
  323. },
  324. getCoverUrl(path) {
  325. if (!path) return ''
  326. var s = String(path).trim()
  327. if (!s || s === 'null' || s === '/null') return ''
  328. if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) return s
  329. return BASE_URL + (s.charAt(0) === '/' ? s : '/' + s)
  330. },
  331. removeCover() {
  332. this.form.coverImage = ''
  333. },
  334. validate() {
  335. if (!this.form.title) {
  336. uni.showToast({ title: '请输入活动标题', icon: 'none' })
  337. return false
  338. }
  339. if (!this.form.activityType) {
  340. uni.showToast({ title: '请选择活动类型', icon: 'none' })
  341. return false
  342. }
  343. if (!this.datePart || !this.timePart) {
  344. uni.showToast({ title: '请选择开始时间', icon: 'none' })
  345. return false
  346. }
  347. return true
  348. },
  349. buildPayload() {
  350. var payload = Object.assign({}, this.form)
  351. payload.maxParticipants = parseInt(this.maxParticipantsStr) || 0
  352. payload.childFee = Math.round((parseFloat(this.childFeeYuan) || 0) * 100)
  353. payload.childMemberFee = Math.round((parseFloat(this.childMemberFeeYuan) || 0) * 100)
  354. payload.parentFee = Math.round((parseFloat(this.parentFeeYuan) || 0) * 100)
  355. payload.parentMemberFee = Math.round((parseFloat(this.parentMemberFeeYuan) || 0) * 100)
  356. if (this.activityId) payload.id = this.activityId
  357. // 维度由审核时设置,创建/编辑不提交空维度,避免误清空已有维度
  358. if (!payload.dimensionCode) delete payload.dimensionCode
  359. return payload
  360. },
  361. handleSave() {
  362. if (!this.validate()) return
  363. this.submit(false)
  364. },
  365. handlePublish() {
  366. if (!this.validate()) return
  367. this.submit(true)
  368. },
  369. submit(publishAfter) {
  370. var self = this
  371. self.loading = true
  372. var payload = self.buildPayload()
  373. var isAdmin = self.isAdmin
  374. var doPublish = function(id) {
  375. if (isAdmin) {
  376. adminPublishActivity(id).then(function(r) {
  377. self.loading = false
  378. if (r && r.code === 200) {
  379. uni.showToast({ title: '已发布', icon: 'success' })
  380. setTimeout(function() {
  381. uni.navigateBack()
  382. }, 1200)
  383. } else {
  384. uni.showToast({ title: (r && r.message) || '发布失败', icon: 'none' })
  385. }
  386. }).catch(function() {
  387. self.loading = false
  388. uni.showToast({ title: '发布失败', icon: 'none' })
  389. })
  390. } else {
  391. submitActivityForReview(id).then(function(r) {
  392. self.loading = false
  393. if (r && r.code === 200) {
  394. uni.showToast({ title: '已提交审核', icon: 'success' })
  395. setTimeout(function() {
  396. uni.navigateBack()
  397. }, 1200)
  398. } else {
  399. uni.showToast({ title: (r && r.message) || '提交审核失败', icon: 'none' })
  400. }
  401. }).catch(function() {
  402. self.loading = false
  403. uni.showToast({ title: '提交审核失败', icon: 'none' })
  404. })
  405. }
  406. }
  407. if (self.isNew) {
  408. adminCreateActivity(payload).then(function(res) {
  409. if (res && res.code === 200) {
  410. var newId = res.data.id || res.data
  411. if (publishAfter) {
  412. doPublish(newId)
  413. } else {
  414. self.loading = false
  415. uni.showToast({ title: '已保存草稿', icon: 'success' })
  416. setTimeout(function() { uni.navigateBack() }, 1200)
  417. }
  418. } else {
  419. self.loading = false
  420. uni.showToast({ title: (res && res.message) || '保存失败', icon: 'none' })
  421. }
  422. }).catch(function() {
  423. self.loading = false
  424. uni.showToast({ title: '网络异常', icon: 'none' })
  425. })
  426. } else {
  427. adminUpdateActivity(payload).then(function(res) {
  428. if (res && res.code === 200) {
  429. if (publishAfter && (self.form.status === 'draft' || self.form.status === 'rejected')) {
  430. doPublish(self.activityId)
  431. } else {
  432. self.loading = false
  433. uni.showToast({ title: '已保存', icon: 'success' })
  434. setTimeout(function() { uni.navigateBack() }, 1200)
  435. }
  436. } else {
  437. self.loading = false
  438. uni.showToast({ title: (res && res.message) || '保存失败', icon: 'none' })
  439. }
  440. }).catch(function() {
  441. self.loading = false
  442. uni.showToast({ title: '网络异常', icon: 'none' })
  443. })
  444. }
  445. },
  446. typeLabel(key) {
  447. for (var i = 0; i < this.typeOptions.length; i++) {
  448. if (this.typeOptions[i].key === key) return this.typeOptions[i].label
  449. }
  450. return '请选择类型'
  451. },
  452. fmtDate(d) {
  453. var y = d.getFullYear()
  454. var m = ('0' + (d.getMonth() + 1)).slice(-2)
  455. var day = ('0' + d.getDate()).slice(-2)
  456. return y + '-' + m + '-' + day
  457. },
  458. fmtTime(d) {
  459. var h = ('0' + d.getHours()).slice(-2)
  460. var mi = ('0' + d.getMinutes()).slice(-2)
  461. return h + ':' + mi
  462. }
  463. }
  464. }
  465. </script>
  466. <style scoped>
  467. .container {
  468. min-height: 100vh;
  469. background: #F5F7FA;
  470. padding: 24rpx;
  471. }
  472. .form-card {
  473. background: #fff;
  474. border-radius: 20rpx;
  475. padding: 32rpx 28rpx;
  476. box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
  477. }
  478. .form-title {
  479. font-size: 36rpx;
  480. font-weight: bold;
  481. color: #1E293B;
  482. margin-bottom: 24rpx;
  483. }
  484. .form-item {
  485. margin-bottom: 28rpx;
  486. }
  487. .form-item-row {
  488. display: flex;
  489. flex-direction: row;
  490. gap: 20rpx;
  491. margin-bottom: 28rpx;
  492. }
  493. .form-item.half {
  494. flex: 1;
  495. }
  496. .form-label {
  497. font-size: 26rpx;
  498. color: #475569;
  499. font-weight: 600;
  500. margin-bottom: 12rpx;
  501. display: block;
  502. }
  503. .form-input {
  504. width: 100%;
  505. height: 80rpx;
  506. border: 1rpx solid #E2E8F0;
  507. border-radius: 12rpx;
  508. padding: 0 20rpx;
  509. font-size: 28rpx;
  510. color: #1E293B;
  511. box-sizing: border-box;
  512. }
  513. .form-picker {
  514. width: 100%;
  515. height: 80rpx;
  516. border: 1rpx solid #E2E8F0;
  517. border-radius: 12rpx;
  518. padding: 0 20rpx;
  519. font-size: 28rpx;
  520. color: #1E293B;
  521. box-sizing: border-box;
  522. display: flex;
  523. align-items: center;
  524. }
  525. .picker-text {
  526. font-size: 28rpx;
  527. color: #1E293B;
  528. }
  529. .form-textarea {
  530. width: 100%;
  531. min-height: 160rpx;
  532. border: 1rpx solid #E2E8F0;
  533. border-radius: 12rpx;
  534. padding: 16rpx 20rpx;
  535. font-size: 28rpx;
  536. color: #1E293B;
  537. box-sizing: border-box;
  538. }
  539. .cover-upload {
  540. width: 100%;
  541. height: 320rpx;
  542. border: 2rpx dashed #CBD5E1;
  543. border-radius: 12rpx;
  544. display: flex;
  545. align-items: center;
  546. justify-content: center;
  547. overflow: hidden;
  548. }
  549. .preview-img {
  550. width: 100%;
  551. height: 100%;
  552. }
  553. .upload-placeholder {
  554. display: flex;
  555. flex-direction: column;
  556. align-items: center;
  557. }
  558. .upload-icon {
  559. font-size: 64rpx;
  560. color: #94A3B8;
  561. }
  562. .upload-text {
  563. font-size: 24rpx;
  564. color: #94A3B8;
  565. margin-top: 8rpx;
  566. }
  567. .cover-actions {
  568. display: flex;
  569. flex-direction: row;
  570. gap: 24rpx;
  571. margin-top: 12rpx;
  572. }
  573. .cover-action {
  574. font-size: 24rpx;
  575. color: #475569;
  576. text-decoration: underline;
  577. }
  578. .cover-action-danger {
  579. font-size: 24rpx;
  580. color: #ef4444;
  581. text-decoration: underline;
  582. }
  583. .form-actions {
  584. display: flex;
  585. flex-direction: row;
  586. gap: 20rpx;
  587. margin-top: 40rpx;
  588. }
  589. .btn-save,
  590. .btn-publish {
  591. flex: 1;
  592. height: 88rpx;
  593. border-radius: 44rpx;
  594. display: flex;
  595. align-items: center;
  596. justify-content: center;
  597. font-size: 30rpx;
  598. font-weight: 600;
  599. }
  600. .btn-save {
  601. background: #fff;
  602. color: #F97316;
  603. border: 2rpx solid #F97316;
  604. }
  605. .btn-publish {
  606. background: linear-gradient(135deg, #F97316, #FB923C);
  607. color: #fff;
  608. }
  609. .loading {
  610. position: fixed;
  611. top: 0;
  612. left: 0;
  613. right: 0;
  614. bottom: 0;
  615. background: rgba(255, 255, 255, 0.6);
  616. display: flex;
  617. flex-direction: column;
  618. align-items: center;
  619. justify-content: center;
  620. z-index: 999;
  621. }
  622. .loading-spinner {
  623. width: 60rpx;
  624. height: 60rpx;
  625. border: 4rpx solid #FED7AA;
  626. border-top: 4rpx solid #F97316;
  627. border-radius: 50%;
  628. animation: spin 0.8s linear infinite;
  629. margin-bottom: 16rpx;
  630. }
  631. @keyframes spin {
  632. to { transform: rotate(360deg); }
  633. }
  634. </style>