create.vue 37 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100
  1. <template>
  2. <view class="container" :style="{ '--font-scale': fontScale }">
  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="multiSelector" :range="[hourOptions, minuteOptions]" :value="[hourIdx, minuteIdx]" @change="onStartTimeColChange" 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="multiSelector" :range="[hourOptions, minuteOptions]" :value="[endHourIdx, endMinuteIdx]" @change="onEndTimeColChange" 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="multiSelector" :range="[hourOptions, minuteOptions]" :value="[regHourIdx, regMinuteIdx]" @change="onRegTimeColChange" 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>
  58. <!-- A2 §2.1 参加范围(全部用户 / 仅会员参加) -->
  59. <view class="form-item">
  60. <text class="form-label">参加范围 *</text>
  61. <view class="radio-group">
  62. <label class="radio-item" v-for="opt in scopeOptions" :key="opt.value" @tap="selectScope(opt.value)">
  63. <view class="radio-icon" :class="{ active: form.audienceScope === opt.value }">
  64. <view class="radio-inner" v-if="form.audienceScope === opt.value"></view>
  65. </view>
  66. <text class="radio-label">{{ opt.label }}</text>
  67. </label>
  68. </view>
  69. </view>
  70. <!-- A2 §2.1 参与人群(成人恒选,可叠加儿童 / 长者) -->
  71. <view class="form-item">
  72. <text class="form-label">参与人群</text>
  73. <view class="checkbox-group">
  74. <label class="checkbox-item" v-for="opt in audienceTypeChoices" :key="opt.value" @tap="toggleAudienceType(opt.value)">
  75. <view class="checkbox-box" :class="{ active: opt.checked }">
  76. <text class="checkbox-check" v-if="opt.checked">✓</text>
  77. </view>
  78. <text class="radio-label">{{ opt.label }}</text>
  79. </label>
  80. </view>
  81. <text class="form-hint">成人始终可参加;勾选「含儿童」后需填写儿童费用</text>
  82. </view>
  83. <!-- A2 §2.2 成人档价格 -->
  84. <view class="form-item-row">
  85. <view class="form-item half">
  86. <text class="form-label">价格(元)</text>
  87. <input class="form-input" type="digit" v-model="priceYuan" placeholder="0=免费" />
  88. </view>
  89. <view class="form-item half">
  90. <text class="form-label">会员价(元)</text>
  91. <input class="form-input" type="digit" v-model="memberPriceYuan" placeholder="留空=同普通价" />
  92. </view>
  93. </view>
  94. <!-- A2 §2.3 儿童档价格:仅在参与人群含儿童时显示 -->
  95. <view class="form-item-row" v-if="audienceChild">
  96. <view class="form-item half">
  97. <text class="form-label">儿童费用(元)</text>
  98. <input class="form-input" type="digit" v-model="childFeeYuan" placeholder="0=免费" />
  99. </view>
  100. <view class="form-item half">
  101. <text class="form-label">儿童会员费(元)</text>
  102. <input class="form-input" type="digit" v-model="childMemberFeeYuan" placeholder="0=免费" />
  103. </view>
  104. </view>
  105. <!-- 主办方 -->
  106. <view class="form-item">
  107. <text class="form-label">主办方</text>
  108. <input class="form-input" v-model="form.vendorName" placeholder="请输入主办方名称(可选)" maxlength="50" />
  109. </view>
  110. <!-- 封面图片 -->
  111. <view class="form-item">
  112. <text class="form-label">封面图片</text>
  113. <view class="cover-upload" @click="chooseImage">
  114. <image v-if="form.coverImage" class="preview-img" :src="getCoverUrl(form.coverImage)" mode="aspectFill" @click.stop lazy-load />
  115. <view v-else class="upload-placeholder">
  116. <text class="upload-icon">📷</text>
  117. <text class="upload-text">点击上传封面图</text>
  118. </view>
  119. </view>
  120. <view class="cover-actions" v-if="form.coverImage">
  121. <text class="cover-action" @click="chooseImage">重新选择</text>
  122. <text class="cover-action-danger" @click="removeCover">移除</text>
  123. </view>
  124. </view>
  125. <!-- 活动简介(列表卡片与分享海报优先展示,一句到两句为宜) -->
  126. <view class="form-item">
  127. <text class="form-label">活动简介</text>
  128. <textarea class="form-textarea" v-model="form.intro" placeholder="一句话说明这个活动是什么(可选,建议不超过 60 字)" maxlength="100"
  129. auto-height />
  130. </view>
  131. <!-- 活动描述 -->
  132. <view class="form-item">
  133. <text class="form-label">活动描述</text>
  134. <textarea class="form-textarea" v-model="form.description" placeholder="请输入活动详细介绍" maxlength="500"
  135. auto-height />
  136. </view>
  137. <!-- 操作按钮 -->
  138. <view class="form-actions">
  139. <view class="btn-save" @click="handleSave">
  140. <text>保存为草稿</text>
  141. </view>
  142. <view class="btn-publish" @click="handlePublish">
  143. <text>{{ isAdmin ? '保存并发布' : '保存并提交审核' }}</text>
  144. </view>
  145. </view>
  146. </view>
  147. <!-- 海报模板选择 + 预览 -->
  148. <view class="form-card" v-if="posterTemplates.length > 0">
  149. <view class="form-title">活动海报模板(可选)</view>
  150. <view class="poster-tpl-list">
  151. <view
  152. v-for="tpl in posterTemplates"
  153. :key="tpl.id"
  154. class="poster-tpl-card"
  155. :class="{ 'poster-tpl-active': form.posterTemplateId === tpl.id }"
  156. @click="selectPosterTemplate(tpl)"
  157. >
  158. <view class="tpl-colors">
  159. <view class="tpl-dot tpl-main" :style="{ background: tpl.mainColor || BrandColors.primary }"></view>
  160. <view class="tpl-dot tpl-dark" :style="{ background: tpl.darkColor || BrandColors.orangeDeep }"></view>
  161. <view class="tpl-dot tpl-light" :style="{ background: tpl.lightColor || paleColor }"></view>
  162. </view>
  163. <text class="tpl-name">{{ tpl.name }}</text>
  164. </view>
  165. </view>
  166. <view class="poster-preview-row" v-if="form.posterTemplateId">
  167. <text class="poster-preview-hint">已选择海报模板,点击「预览」查看效果</text>
  168. <view class="btn-preview" @click="openPosterPreview">预览</view>
  169. </view>
  170. </view>
  171. <!-- 海报预览弹层 -->
  172. <content-share-poster
  173. v-if="showPosterPreview"
  174. :show="showPosterPreview"
  175. :title="form.title || '活动海报'"
  176. :cover-image="form.coverImage"
  177. :dimension-code="form.dimensionCode"
  178. :template="previewTemplateKey"
  179. :templates="posterTemplates"
  180. :poster-qr-code="''"
  181. @close="showPosterPreview = false"
  182. @update:template="previewTemplateKey = $event"
  183. @update:bgColor="previewBgColor = $event"
  184. ></content-share-poster>
  185. <view class="loading" v-if="loading">
  186. <view class="loading-spinner"></view>
  187. <text>加载中...</text>
  188. </view>
  189. </view>
  190. </template>
  191. <script>
  192. import { BrandColors } from '../../utils/brand-colors.js'
  193. import { adminCreateActivity, adminUpdateActivity, adminPublishActivity, submitActivityForReview, getActivityDetail, getPosterTemplates } from '../../utils/api.js'
  194. import config from '../../config.js'
  195. import { parseDate } from '../../utils/format.js'
  196. import { hasActivityManageRole } from '../../utils/activity-role.js'
  197. import { pickImage } from '../../utils/image-picker.js'
  198. import ContentSharePoster from '../../components/ContentSharePoster.vue'
  199. var BASE_URL = config.api('')
  200. import fontScaleMixin from '@/mixins/font-scale.js'
  201. export default {
  202. mixins: [fontScaleMixin],
  203. components: { ContentSharePoster },
  204. computed: {
  205. // 后台活动管理员可直接发布;服务商账号只能保存后提交审核
  206. isAdmin: function() {
  207. return hasActivityManageRole()
  208. },
  209. // A2 §2.1 参与人群勾选视图(成人恒选,不允许取消;勾选状态映射到 audienceChild/audienceElder)
  210. audienceTypeChoices: function() {
  211. var self = this
  212. return this.audienceTypeOptions.map(function(opt) {
  213. var checked = opt.value === 'adult' || self.isAudienceTypeChecked(opt.value)
  214. return { value: opt.value, label: opt.label, checked: checked }
  215. })
  216. },
  217. // A2 §2.1 提交给后端的 audience_type 合并取值
  218. audienceTypeValue: function() {
  219. if (this.audienceChild && this.audienceElder) return 'include_child_elder'
  220. if (this.audienceChild) return 'include_child'
  221. if (this.audienceElder) return 'include_elder'
  222. return 'adult'
  223. }
  224. },
  225. data() {
  226. return {
  227. paleColor: BrandColors.primaryPale,
  228. isNew: true,
  229. activityId: null,
  230. loading: false,
  231. memberId: '',
  232. form: {
  233. title: '',
  234. intro: '',
  235. description: '',
  236. intro: '',
  237. coverImage: '',
  238. dimensionCode: '',
  239. activityType: '',
  240. status: 'draft',
  241. startTime: null,
  242. endTime: null,
  243. registrationDeadline: null,
  244. location: '',
  245. maxParticipants: 0,
  246. childFee: 0,
  247. childMemberFee: 0,
  248. // A2 §2.1 适用人群:audienceScope=全部用户/仅会员参加;audienceType=参与人群构成
  249. audienceScope: 'all',
  250. audienceType: 'adult',
  251. visibility: 'public',
  252. requireRegistration: 1,
  253. requireCheckinReview: 0,
  254. checkinPoints: 0
  255. },
  256. // A2 §2.2 成人档价格输入(元);空 = 走默认/别名回退
  257. priceYuan: '',
  258. memberPriceYuan: '',
  259. // 存量活动的 memberPrice 为 NULL(从未写入过),提交时需保持 NULL 以继续沿用 parentMemberFee 别名
  260. memberPriceWasNull: false,
  261. // 存量活动已有的儿童收费(用于「未勾选含儿童」时不误清零存量收费)
  262. legacyChildFee: 0,
  263. legacyChildMemberFee: 0,
  264. // A2 §2.1 参与人群勾选状态(成人恒选,不提供取消)
  265. audienceChild: false,
  266. audienceElder: false,
  267. scopeOptions: [
  268. { value: 'all', label: '全部用户' },
  269. { value: 'member_only', label: '仅会员参加' }
  270. ],
  271. audienceTypeOptions: [
  272. { value: 'adult', label: '成人' },
  273. { value: 'include_child', label: '含儿童' },
  274. { value: 'include_elder', label: '含长者' }
  275. ],
  276. childFeeYuan: '0',
  277. childMemberFeeYuan: '0',
  278. maxParticipantsStr: '0',
  279. datePart: '',
  280. timePart: '',
  281. endDatePart: '',
  282. endTimePart: '',
  283. regDatePart: '',
  284. regTimePart: '',
  285. // 时间选择器(multiSelector 双列):时 24 项 00..23,分 12 项严格 5 分钟粒度
  286. hourOptions: ['00', '01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12', '13', '14', '15', '16', '17', '18', '19', '20', '21', '22', '23'],
  287. minuteOptions: ['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55'],
  288. hourIdx: 0,
  289. minuteIdx: 0,
  290. endHourIdx: 0,
  291. endMinuteIdx: 0,
  292. regHourIdx: 0,
  293. regMinuteIdx: 0,
  294. typeIdx: 0,
  295. typeOptions: [
  296. { key: 'offline', label: '线下活动' },
  297. { key: 'online', label: '线上活动' },
  298. { key: 'campaign', label: '营销活动' }
  299. ],
  300. // 海报模板
  301. posterTemplates: [],
  302. selectedPosterTemplate: null,
  303. showPosterPreview: false,
  304. previewTemplateKey: 'gradient',
  305. previewBgColor: ''
  306. }
  307. },
  308. onLoad(options) {
  309. if (options.id) {
  310. this.activityId = parseInt(options.id)
  311. this.isNew = false
  312. uni.setNavigationBarTitle({ title: '编辑活动' })
  313. this.loadDetail()
  314. } else {
  315. uni.setNavigationBarTitle({ title: '创建活动' })
  316. }
  317. // 加载海报模板列表(未登录也会返回空列表,不阻塞创建)
  318. this.loadPosterTemplates()
  319. },
  320. methods: {
  321. loadDetail() {
  322. var self = this
  323. self.loading = true
  324. getActivityDetail(self.activityId).then(function(res) {
  325. self.loading = false
  326. if (res && res.code === 200 && res.data) {
  327. var d = res.data
  328. self.form.title = d.title || ''
  329. self.form.intro = d.intro || ''
  330. self.form.description = d.description || ''
  331. self.form.intro = d.intro || ''
  332. self.form.coverImage = d.coverImage || ''
  333. self.form.vendorName = d.vendorName || ''
  334. self.form.posterTemplateId = d.posterTemplateId || null
  335. self.form.dimensionCode = d.dimensionCode || ''
  336. self.form.activityType = d.activityType || ''
  337. self.form.location = d.location || ''
  338. self.form.maxParticipants = d.maxParticipants || 0
  339. self.form.childFee = d.childFee || 0
  340. self.form.childMemberFee = d.childMemberFee || 0
  341. // A2:不再回填 parentFee/parentMemberFee 到 form(历史别名,不再提交),仅按需取旧值兜底价格显示
  342. self.form.audienceScope = d.audienceScope || 'all'
  343. self.form.audienceType = d.audienceType || 'adult'
  344. self.form.status = d.status || 'draft'
  345. self.form.visibility = d.visibility || 'public'
  346. self.maxParticipantsStr = String(self.form.maxParticipants)
  347. self.childFeeYuan = self.form.childFee ? (self.form.childFee / 100).toString() : '0'
  348. self.childMemberFeeYuan = self.form.childMemberFee ? (self.form.childMemberFee / 100).toString() : '0'
  349. // A2 §2.3 存量活动已有的儿童收费:未勾选「含儿童」时不误清零,先暂存
  350. self.legacyChildFee = self.form.childFee || 0
  351. self.legacyChildMemberFee = self.form.childMemberFee || 0
  352. // A2 §2.2 成人档价格回填:用 basePrice(原始普通价)而非 price(会员已解析展示值),
  353. // 否则会员编辑时误把会员价当成普通价回写;basePrice 为 0/空时兜底历史别名 parentFee
  354. var basePrice = d.basePrice || 0
  355. var legacyPrice = (basePrice > 0) ? basePrice : (d.parentFee || 0)
  356. self.priceYuan = legacyPrice ? (legacyPrice / 100).toString() : ''
  357. // memberPrice 为 NULL 表示存量活动从未写入过(结算走 parentMemberFee 别名),需保留该状态
  358. self.memberPriceWasNull = (d.memberPrice === null || d.memberPrice === undefined)
  359. var legacyMemberPrice = 0
  360. if (d.memberPrice && d.memberPrice > 0) {
  361. legacyMemberPrice = d.memberPrice
  362. } else if (self.memberPriceWasNull && d.parentMemberFee && d.parentMemberFee > 0) {
  363. legacyMemberPrice = d.parentMemberFee
  364. }
  365. self.memberPriceYuan = legacyMemberPrice ? (legacyMemberPrice / 100).toString() : ''
  366. // A2 §2.1 参与人群勾选状态回填
  367. var at = d.audienceType || 'adult'
  368. self.audienceChild = (at === 'include_child' || at === 'include_child_elder')
  369. self.audienceElder = (at === 'include_elder' || at === 'include_child_elder')
  370. if (d.startTime) {
  371. var sd = parseDate(d.startTime)
  372. if (sd) {
  373. self.datePart = self.fmtDate(sd)
  374. self.timePart = self.fmtTime(sd)
  375. // 反算时/分列索引,picker 回填显示与提交值保持一致(均为 5 分钟粒度)
  376. self.applyTimeParts(self.timePart, 'hourIdx', 'minuteIdx')
  377. // 走 sync 统一组装:timePart 已被 fmtTime 收敛到 5 的倍数,保证"显示即提交"
  378. self.syncStartTime()
  379. }
  380. }
  381. if (d.endTime) {
  382. var ed = parseDate(d.endTime)
  383. if (ed) {
  384. self.endDatePart = self.fmtDate(ed)
  385. self.endTimePart = self.fmtTime(ed)
  386. self.applyTimeParts(self.endTimePart, 'endHourIdx', 'endMinuteIdx')
  387. self.syncEndTime()
  388. }
  389. }
  390. if (d.registrationDeadline) {
  391. var rd = parseDate(d.registrationDeadline)
  392. if (rd) {
  393. self.regDatePart = self.fmtDate(rd)
  394. self.regTimePart = self.fmtTime(rd)
  395. self.applyTimeParts(self.regTimePart, 'regHourIdx', 'regMinuteIdx')
  396. self.syncRegDeadline()
  397. }
  398. }
  399. // sync type picker
  400. for (var j = 0; j < self.typeOptions.length; j++) {
  401. if (self.typeOptions[j].key === self.form.activityType) self.typeIdx = j
  402. }
  403. }
  404. }).catch(function() { self.loading = false })
  405. },
  406. onTypeChange(e) {
  407. this.typeIdx = e.detail.value
  408. this.form.activityType = this.typeOptions[this.typeIdx].key
  409. },
  410. // A2 §2.1 选择参加范围(全部用户 / 仅会员参加)
  411. selectScope(value) {
  412. this.form.audienceScope = value
  413. },
  414. // A2 §2.1 勾选参与人群(成人恒选,不允许取消)
  415. toggleAudienceType(value) {
  416. if (value === 'adult') return
  417. if (value === 'include_child') this.audienceChild = !this.audienceChild
  418. if (value === 'include_elder') this.audienceElder = !this.audienceElder
  419. },
  420. // A2 §2.1 判断某参与人群选项是否已勾选
  421. isAudienceTypeChecked(value) {
  422. if (value === 'adult') return true
  423. if (value === 'include_child') return this.audienceChild
  424. if (value === 'include_elder') return this.audienceElder
  425. return false
  426. },
  427. onStartDateChange(e) {
  428. this.datePart = e.detail.value
  429. this.syncStartTime()
  430. },
  431. onStartTimeColChange(e) {
  432. // multiSelector 双列:时/分列索引直取 options 值,选择过程中即 5 分钟粒度,无需事后取整
  433. var v = e.detail.value
  434. this.hourIdx = v[0]
  435. this.minuteIdx = v[1]
  436. this.timePart = this.hourOptions[v[0]] + ':' + this.minuteOptions[v[1]]
  437. this.syncStartTime()
  438. },
  439. onEndDateChange(e) {
  440. this.endDatePart = e.detail.value
  441. this.syncEndTime()
  442. },
  443. onEndTimeColChange(e) {
  444. var v = e.detail.value
  445. this.endHourIdx = v[0]
  446. this.endMinuteIdx = v[1]
  447. this.endTimePart = this.hourOptions[v[0]] + ':' + this.minuteOptions[v[1]]
  448. this.syncEndTime()
  449. },
  450. onRegDateChange(e) {
  451. this.regDatePart = e.detail.value
  452. this.syncRegDeadline()
  453. },
  454. onRegTimeColChange(e) {
  455. var v = e.detail.value
  456. this.regHourIdx = v[0]
  457. this.regMinuteIdx = v[1]
  458. this.regTimePart = this.hourOptions[v[0]] + ':' + this.minuteOptions[v[1]]
  459. this.syncRegDeadline()
  460. },
  461. // 活动时间统一为 5 分钟粒度:分钟向下取整到 5 的倍数(14:07 → 14:05、14:59 → 14:55)
  462. // 兜底保留:历史脏数据、后台/接口写入非 5 倍数分钟时仍能收敛(选择器本身已无法选中非 5 倍数)
  463. normalizeTimeTo5Min: function(t) {
  464. if (!t) return t
  465. var parts = String(t).split(':')
  466. if (parts.length < 2) return t
  467. var h = parseInt(parts[0], 10)
  468. var mi = parseInt(parts[1], 10)
  469. if (isNaN(h) || isNaN(mi)) return t
  470. mi = Math.floor(mi / 5) * 5
  471. return ('0' + h).slice(-2) + ':' + ('0' + mi).slice(-2)
  472. },
  473. // 把 HH:MM 字符串同步到 picker 时/分列索引(先归一化到 5 分钟粒度,保证"显示即提交")
  474. // 供初始化、历史回填与列变更回写共用;hourIdxRef/minuteIdxRef 为 data 字段名
  475. applyTimeParts: function(t, hourIdxRef, minuteIdxRef) {
  476. var s = this.normalizeTimeTo5Min(t)
  477. if (!s) return
  478. var parts = String(s).split(':')
  479. this[hourIdxRef] = parseInt(parts[0], 10)
  480. this[minuteIdxRef] = Math.floor(parseInt(parts[1], 10) / 5)
  481. },
  482. syncStartTime() {
  483. if (this.datePart && this.timePart) {
  484. this.form.startTime = this.datePart + 'T' + this.timePart + ':00'
  485. }
  486. },
  487. syncEndTime() {
  488. if (this.endDatePart && this.endTimePart) {
  489. this.form.endTime = this.endDatePart + 'T' + this.endTimePart + ':00'
  490. } else {
  491. this.form.endTime = null
  492. }
  493. },
  494. syncRegDeadline() {
  495. if (this.regDatePart && this.regTimePart) {
  496. this.form.registrationDeadline = this.regDatePart + 'T' + this.regTimePart + ':00'
  497. } else {
  498. this.form.registrationDeadline = null
  499. }
  500. },
  501. chooseImage() {
  502. var self = this
  503. pickImage({
  504. count: 1,
  505. sizeType: ['compressed'],
  506. success: function(res) {
  507. self.uploadCover(res.tempFilePaths[0])
  508. }
  509. })
  510. },
  511. uploadCover(filePath) {
  512. var self = this
  513. uni.uploadFile({
  514. url: BASE_URL + '/api/admin/articles/upload/image',
  515. filePath: filePath,
  516. name: 'file',
  517. header: { 'Authorization': 'Bearer ' + (uni.getStorageSync('token') || '') },
  518. success: function(res) {
  519. try {
  520. var data = JSON.parse(res.data)
  521. if (data.code === 200 && data.data) {
  522. self.form.coverImage = data.data
  523. } else {
  524. uni.showToast({ title: '上传失败', icon: 'none' })
  525. }
  526. } catch (e) {
  527. uni.showToast({ title: '上传失败', icon: 'none' })
  528. }
  529. },
  530. fail: function() {
  531. uni.showToast({ title: '上传失败', icon: 'none' })
  532. }
  533. })
  534. },
  535. onImgError() {
  536. this.form.coverImage = ''
  537. },
  538. getCoverUrl(path) {
  539. if (!path) return ''
  540. var s = String(path).trim()
  541. if (!s || s === 'null' || s === '/null') return ''
  542. if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) return s
  543. return BASE_URL + (s.charAt(0) === '/' ? s : '/' + s)
  544. },
  545. removeCover() {
  546. this.form.coverImage = ''
  547. },
  548. async loadPosterTemplates() {
  549. try {
  550. var res = await getPosterTemplates()
  551. if (res && res.code === 200) {
  552. this.posterTemplates = res.data || []
  553. }
  554. } catch (e) { /* 模板拉取失败不影响创建 */ }
  555. },
  556. selectPosterTemplate(tpl) {
  557. this.form.posterTemplateId = tpl.id
  558. this.selectedPosterTemplate = tpl
  559. this.previewTemplateKey = tpl.templateKey || 'gradient'
  560. // 默认背景色取模板主色
  561. this.previewBgColor = tpl.mainColor || ''
  562. uni.showToast({ title: '已选择:' + tpl.name, icon: 'none' })
  563. },
  564. openPosterPreview() {
  565. if (!this.form.posterTemplateId) {
  566. uni.showToast({ title: '请先选择海报模板', icon: 'none' })
  567. return
  568. }
  569. this.showPosterPreview = true
  570. },
  571. validate() {
  572. if (!this.form.title) {
  573. uni.showToast({ title: '请输入活动标题', icon: 'none' })
  574. return false
  575. }
  576. if (!this.form.activityType) {
  577. uni.showToast({ title: '请选择活动类型', icon: 'none' })
  578. return false
  579. }
  580. if (!this.datePart || !this.timePart) {
  581. uni.showToast({ title: '请选择开始时间', icon: 'none' })
  582. return false
  583. }
  584. return true
  585. },
  586. buildPayload() {
  587. var payload = Object.assign({}, this.form)
  588. payload.maxParticipants = parseInt(this.maxParticipantsStr) || 0
  589. // A2 §2.1 适用人群:参加范围 + 参与人群构成
  590. payload.audienceScope = this.form.audienceScope || 'all'
  591. payload.audienceType = this.audienceTypeValue
  592. // A2 §2.2 成人档价格:price 为普通价;memberPrice 留空 = 同普通价
  593. payload.price = Math.round((parseFloat(this.priceYuan) || 0) * 100)
  594. if (this.memberPriceYuan) {
  595. payload.memberPrice = Math.round((parseFloat(this.memberPriceYuan) || 0) * 100)
  596. } else if (this.memberPriceWasNull) {
  597. // 存量活动 memberPrice 从未写入过(NULL):保持 NULL,继续沿用 parentMemberFee 历史别名
  598. delete payload.memberPrice
  599. } else {
  600. payload.memberPrice = 0
  601. }
  602. // A2 §2.3 儿童档价格:仅在参与人群含儿童时提交
  603. if (this.audienceChild) {
  604. payload.childFee = Math.round((parseFloat(this.childFeeYuan) || 0) * 100)
  605. payload.childMemberFee = Math.round((parseFloat(this.childMemberFeeYuan) || 0) * 100)
  606. } else if (this.legacyChildFee || this.legacyChildMemberFee) {
  607. // 存量活动已有儿童收费但本次未勾选「含儿童」:不改存量收费,避免误清零
  608. delete payload.childFee
  609. delete payload.childMemberFee
  610. } else {
  611. payload.childFee = 0
  612. payload.childMemberFee = 0
  613. }
  614. // A2 §2.2 parentFee/parentMemberFee 降级为历史别名,表单不再提交(null 字段不参与 updateById)
  615. delete payload.parentFee
  616. delete payload.parentMemberFee
  617. if (this.activityId) payload.id = this.activityId
  618. // 维度由审核时设置,创建/编辑不提交空维度,避免误清空已有维度
  619. if (!payload.dimensionCode) delete payload.dimensionCode
  620. // 主办方和海报模板仅非空时提交
  621. if (!payload.vendorName) delete payload.vendorName
  622. if (!payload.posterTemplateId) delete payload.posterTemplateId
  623. // 简介为空时不提交,保留后端已有值
  624. if (!payload.intro) delete payload.intro
  625. return payload
  626. },
  627. handleSave() {
  628. if (!this.validate()) return
  629. this.submit(false)
  630. },
  631. handlePublish() {
  632. if (!this.validate()) return
  633. this.submit(true)
  634. },
  635. submit(publishAfter) {
  636. var self = this
  637. self.loading = true
  638. var payload = self.buildPayload()
  639. var isAdmin = self.isAdmin
  640. var doPublish = function(id) {
  641. if (isAdmin) {
  642. adminPublishActivity(id).then(function(r) {
  643. self.loading = false
  644. if (r && r.code === 200) {
  645. uni.showToast({ title: '已发布', icon: 'success' })
  646. setTimeout(function() {
  647. uni.navigateBack()
  648. }, 1200)
  649. } else {
  650. uni.showToast({ title: (r && r.message) || '发布失败', icon: 'none' })
  651. }
  652. }).catch(function() {
  653. self.loading = false
  654. uni.showToast({ title: '发布失败', icon: 'none' })
  655. })
  656. } else {
  657. submitActivityForReview(id).then(function(r) {
  658. self.loading = false
  659. if (r && r.code === 200) {
  660. uni.showToast({ title: '已提交审核', icon: 'success' })
  661. setTimeout(function() {
  662. uni.navigateBack()
  663. }, 1200)
  664. } else {
  665. uni.showToast({ title: (r && r.message) || '提交审核失败', icon: 'none' })
  666. }
  667. }).catch(function() {
  668. self.loading = false
  669. uni.showToast({ title: '提交审核失败', icon: 'none' })
  670. })
  671. }
  672. }
  673. if (self.isNew) {
  674. adminCreateActivity(payload).then(function(res) {
  675. if (res && res.code === 200) {
  676. var newId = res.data.id || res.data
  677. if (publishAfter) {
  678. doPublish(newId)
  679. } else {
  680. self.loading = false
  681. uni.showToast({ title: '已保存草稿', icon: 'success' })
  682. setTimeout(function() { uni.navigateBack() }, 1200)
  683. }
  684. } else {
  685. self.loading = false
  686. uni.showToast({ title: (res && res.message) || '保存失败', icon: 'none' })
  687. }
  688. }).catch(function() {
  689. self.loading = false
  690. uni.showToast({ title: '网络异常', icon: 'none' })
  691. })
  692. } else {
  693. adminUpdateActivity(payload).then(function(res) {
  694. if (res && res.code === 200) {
  695. if (publishAfter && (self.form.status === 'draft' || self.form.status === 'rejected')) {
  696. doPublish(self.activityId)
  697. } else {
  698. self.loading = false
  699. uni.showToast({ title: '已保存', icon: 'success' })
  700. setTimeout(function() { uni.navigateBack() }, 1200)
  701. }
  702. } else {
  703. self.loading = false
  704. uni.showToast({ title: (res && res.message) || '保存失败', icon: 'none' })
  705. }
  706. }).catch(function() {
  707. self.loading = false
  708. uni.showToast({ title: '网络异常', icon: 'none' })
  709. })
  710. }
  711. },
  712. typeLabel(key) {
  713. for (var i = 0; i < this.typeOptions.length; i++) {
  714. if (this.typeOptions[i].key === key) return this.typeOptions[i].label
  715. }
  716. return '请选择类型'
  717. },
  718. fmtDate(d) {
  719. var y = d.getFullYear()
  720. var m = ('0' + (d.getMonth() + 1)).slice(-2)
  721. var day = ('0' + d.getDate()).slice(-2)
  722. return y + '-' + m + '-' + day
  723. },
  724. fmtTime(d) {
  725. var h = ('0' + d.getHours()).slice(-2)
  726. var mi = ('0' + d.getMinutes()).slice(-2)
  727. // 回填同样收敛到 5 分钟粒度,避免历史非 5 倍数分钟再次保存前产生"选了 7 分却变 5 分"的困惑
  728. return this.normalizeTimeTo5Min(h + ':' + mi)
  729. }
  730. }
  731. }
  732. </script>
  733. <style scoped lang="scss">
  734. .container {
  735. min-height: 100vh;
  736. background: $app-bg;
  737. padding: 24rpx;
  738. }
  739. .form-card {
  740. background: $app-surface;
  741. border-radius: 24rpx;
  742. padding: 32rpx 28rpx;
  743. box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
  744. }
  745. .form-title {
  746. font-size: calc(36rpx * var(--font-scale, 1));
  747. font-weight: bold;
  748. color: $uni-text-color;
  749. margin-bottom: 24rpx;
  750. }
  751. .form-item {
  752. margin-bottom: 28rpx;
  753. }
  754. .form-item-row {
  755. display: flex;
  756. flex-direction: row;
  757. gap: 20rpx;
  758. margin-bottom: 28rpx;
  759. }
  760. .form-item.half {
  761. flex: 1;
  762. }
  763. .form-label {
  764. font-size: calc(26rpx * var(--font-scale, 1));
  765. color: $facade-text-slate-deep;
  766. font-weight: 600;
  767. margin-bottom: 12rpx;
  768. display: block;
  769. }
  770. .form-input {
  771. width: 100%;
  772. height: 80rpx;
  773. border: 1rpx solid $uni-border-color-light;
  774. border-radius: 16rpx;
  775. padding: 0 20rpx;
  776. font-size: calc(28rpx * var(--font-scale, 1));
  777. color: $uni-text-color;
  778. box-sizing: border-box;
  779. }
  780. /* A2 §2.1 参加范围单选(样式对齐 components/GenerationPicker.vue) */
  781. .radio-group {
  782. display: flex;
  783. flex-direction: row;
  784. gap: 24rpx;
  785. }
  786. .radio-item {
  787. display: flex;
  788. flex-direction: row;
  789. align-items: center;
  790. }
  791. .radio-icon {
  792. width: 36rpx;
  793. height: 36rpx;
  794. border: 2rpx solid $facade-border-soft;
  795. border-radius: 50%;
  796. margin-right: 12rpx;
  797. display: flex;
  798. align-items: center;
  799. justify-content: center;
  800. }
  801. .radio-icon.active {
  802. border-color: $brand-primary;
  803. }
  804. .radio-inner {
  805. width: 18rpx;
  806. height: 18rpx;
  807. background: $brand-primary;
  808. border-radius: 50%;
  809. }
  810. .radio-label {
  811. font-size: calc(28rpx * var(--font-scale, 1));
  812. color: $facade-text-strong;
  813. }
  814. /* A2 §2.1 参与人群多选 */
  815. .checkbox-group {
  816. display: flex;
  817. flex-direction: row;
  818. flex-wrap: wrap;
  819. gap: 20rpx;
  820. }
  821. .checkbox-item {
  822. display: flex;
  823. flex-direction: row;
  824. align-items: center;
  825. }
  826. .checkbox-box {
  827. width: 36rpx;
  828. height: 36rpx;
  829. border: 2rpx solid $facade-border-soft;
  830. border-radius: 8rpx;
  831. margin-right: 12rpx;
  832. display: flex;
  833. align-items: center;
  834. justify-content: center;
  835. }
  836. .checkbox-box.active {
  837. border-color: $brand-primary;
  838. background: $brand-primary;
  839. }
  840. .checkbox-check {
  841. color: #FFFFFF;
  842. font-size: 24rpx;
  843. line-height: 1;
  844. }
  845. /* A2 §2.1 表单提示文案 */
  846. .form-hint {
  847. display: block;
  848. font-size: 24rpx;
  849. color: $facade-text-slate;
  850. margin-top: 12rpx;
  851. }
  852. .form-picker {
  853. width: 100%;
  854. height: 80rpx;
  855. border: 1rpx solid $uni-border-color-light;
  856. border-radius: 16rpx;
  857. padding: 0 20rpx;
  858. font-size: calc(28rpx * var(--font-scale, 1));
  859. color: $uni-text-color;
  860. box-sizing: border-box;
  861. display: flex;
  862. align-items: center;
  863. }
  864. .picker-text {
  865. font-size: calc(28rpx * var(--font-scale, 1));
  866. color: $uni-text-color;
  867. }
  868. .form-textarea {
  869. width: 100%;
  870. min-height: 160rpx;
  871. border: 1rpx solid $uni-border-color-light;
  872. border-radius: 16rpx;
  873. padding: 16rpx 20rpx;
  874. font-size: calc(28rpx * var(--font-scale, 1));
  875. color: $uni-text-color;
  876. box-sizing: border-box;
  877. }
  878. .cover-upload {
  879. width: 100%;
  880. height: 320rpx;
  881. border: 2rpx dashed $uni-border-color;
  882. border-radius: 16rpx;
  883. display: flex;
  884. align-items: center;
  885. justify-content: center;
  886. overflow: hidden;
  887. }
  888. .preview-img {
  889. width: 100%;
  890. height: 100%;
  891. }
  892. .upload-placeholder {
  893. display: flex;
  894. flex-direction: column;
  895. align-items: center;
  896. }
  897. .upload-icon {
  898. font-size: calc($uni-font-size-4xl * var(--font-scale, 1));
  899. color: $app-text-subtle;
  900. }
  901. .upload-text {
  902. font-size: calc(24rpx * var(--font-scale, 1));
  903. color: $app-text-subtle;
  904. margin-top: 8rpx;
  905. }
  906. .cover-actions {
  907. display: flex;
  908. flex-direction: row;
  909. gap: 24rpx;
  910. margin-top: 12rpx;
  911. }
  912. .cover-action {
  913. font-size: calc(24rpx * var(--font-scale, 1));
  914. color: $facade-text-slate-deep;
  915. text-decoration: underline;
  916. }
  917. .cover-action-danger {
  918. font-size: calc(24rpx * var(--font-scale, 1));
  919. color: $status-danger;
  920. text-decoration: underline;
  921. }
  922. /* 海报模板选择 */
  923. .poster-tpl-list {
  924. display: flex;
  925. flex-direction: row;
  926. flex-wrap: wrap;
  927. gap: 16rpx;
  928. margin-top: 20rpx;
  929. }
  930. .poster-tpl-card {
  931. width: 200rpx;
  932. border: 2rpx solid $uni-border-color-light;
  933. border-radius: 16rpx;
  934. padding: 20rpx 16rpx;
  935. display: flex;
  936. flex-direction: column;
  937. align-items: center;
  938. background: $app-surface;
  939. }
  940. .poster-tpl-active {
  941. border-color: $brand-primary;
  942. background: $uni-primary-pale;
  943. }
  944. .tpl-colors {
  945. display: flex;
  946. flex-direction: row;
  947. gap: 8rpx;
  948. margin-bottom: 12rpx;
  949. }
  950. .tpl-dot {
  951. width: 32rpx;
  952. height: 32rpx;
  953. border-radius: 50%;
  954. }
  955. .tpl-name {
  956. font-size: calc(24rpx * var(--font-scale, 1));
  957. color: $uni-text-color;
  958. text-align: center;
  959. }
  960. .poster-preview-row {
  961. display: flex;
  962. flex-direction: row;
  963. align-items: center;
  964. justify-content: space-between;
  965. margin-top: 20rpx;
  966. }
  967. .poster-preview-hint {
  968. font-size: calc(24rpx * var(--font-scale, 1));
  969. color: $uni-text-color-secondary;
  970. }
  971. .btn-preview {
  972. padding: 12rpx 32rpx;
  973. border-radius: 24rpx;
  974. background: $brand-primary;
  975. color: $app-surface;
  976. font-size: calc(26rpx * var(--font-scale, 1));
  977. }
  978. .form-actions {
  979. display: flex;
  980. flex-direction: row;
  981. gap: 20rpx;
  982. margin-top: 40rpx;
  983. }
  984. .btn-save,
  985. .btn-publish {
  986. flex: 1;
  987. height: 88rpx;
  988. border-radius: 24rpx;
  989. display: flex;
  990. align-items: center;
  991. justify-content: center;
  992. font-size: calc(30rpx * var(--font-scale, 1));
  993. font-weight: 600;
  994. }
  995. .btn-save {
  996. background: $app-surface;
  997. color: $brand-primary;
  998. border: 2rpx solid $brand-primary;
  999. }
  1000. .btn-publish {
  1001. background: linear-gradient(135deg, $brand-primary, $brand-primary-light);
  1002. color: $app-surface;
  1003. }
  1004. .loading {
  1005. position: fixed;
  1006. top: 0;
  1007. left: 0;
  1008. right: 0;
  1009. bottom: 0;
  1010. background: rgba(255, 255, 255, 0.6);
  1011. display: flex;
  1012. flex-direction: column;
  1013. align-items: center;
  1014. justify-content: center;
  1015. z-index: 999;
  1016. }
  1017. .loading-spinner {
  1018. width: 60rpx;
  1019. height: 60rpx;
  1020. border: 4rpx solid $brand-primary-light-2;
  1021. border-top: 4rpx solid $brand-primary;
  1022. border-radius: 50%;
  1023. animation: spin 0.8s linear infinite;
  1024. margin-bottom: 16rpx;
  1025. }
  1026. @keyframes spin {
  1027. to { transform: rotate(360deg); }
  1028. }
  1029. </style>