create-task.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504
  1. <template>
  2. <view class="container" :style="{ '--font-scale': fontScale }">
  3. <view class="form-container">
  4. <view class="form-item">
  5. <text class="label">任务名称</text>
  6. <input class="input" v-model="form.title" placeholder="请输入任务名称" />
  7. </view>
  8. <view class="form-item">
  9. <text class="label">任务描述</text>
  10. <textarea class="textarea" v-model="form.description" placeholder="请输入任务描述" />
  11. </view>
  12. <view class="form-item">
  13. <text class="label">选择成员(可多选)</text>
  14. <checkbox-group @change="onChildrenChange">
  15. <label class="child-checkbox" v-for="child in children" :key="child.id">
  16. <checkbox :value="String(child.id)" :checked="selectedChildIds.includes(String(child.id))" />
  17. <text class="child-name">{{ child.nickname }}</text>
  18. </label>
  19. </checkbox-group>
  20. </view>
  21. <view class="form-item">
  22. <text class="label">积分</text>
  23. <input class="input" type="number" v-model="form.points" placeholder="请输入积分 (1-10)" />
  24. </view>
  25. <view class="form-item">
  26. <text class="label">分类</text>
  27. <picker :range="categories" @change="onCategoryChange">
  28. <view class="picker">
  29. {{ form.category || '请选择分类' }}
  30. </view>
  31. </picker>
  32. </view>
  33. <!-- 小游戏选择(当选择"小游戏类"时显示) -->
  34. <view class="form-item" v-if="form.category === '小游戏类'">
  35. <text class="label">选择小游戏</text>
  36. <picker :range="miniGames" range-key="gameName" @change="onMinigameChange">
  37. <view class="picker">
  38. {{ selectedMinigame ? selectedMinigame.gameName : '请选择小游戏' }}
  39. </view>
  40. </picker>
  41. </view>
  42. <!-- TASK-003: 截止时间设置 -->
  43. <view class="form-item">
  44. <text class="label">截止时间</text>
  45. <picker mode="multiSelector" :range="dateTimeRange" :value="dateTimeIndex" @change="onDateTimeChange" @columnchange="onColumnChange">
  46. <view class="picker">
  47. {{ deadlineText || '请选择截止时间' }}
  48. </view>
  49. </picker>
  50. </view>
  51. <!-- TASK-004: 重复设置 -->
  52. <view class="form-item">
  53. <text class="label">重复设置</text>
  54. <picker :range="repeatTypes" range-key="label" @change="onRepeatChange">
  55. <view class="picker">
  56. {{ repeatTypeLabel || '不重复' }}
  57. </view>
  58. </picker>
  59. </view>
  60. <view class="form-item">
  61. <text class="label">需要审核</text>
  62. <switch :checked="form.needReview" @change="onNeedReviewChange" />
  63. </view>
  64. <!-- TASK-SYS-248: 联动动作 -->
  65. <view class="form-item">
  66. <text class="label">联动动作(可选)</text>
  67. <picker :range="actionTypes" range-key="label" @change="onActionTypeChange">
  68. <view class="picker">
  69. {{ actionTypeLabel || '无(手动完成)' }}
  70. </view>
  71. </picker>
  72. <text class="form-hint">选择后任务按钮将跳转对应功能页</text>
  73. </view>
  74. <view class="form-item" v-if="form.actionType === 'cart' || form.actionType === 'order'">
  75. <text class="label">商品ID(购物任务)</text>
  76. <input class="input" type="number" v-model="form.actionConfig" placeholder="请输入商品ID,如 123" />
  77. <text class="form-hint">购物任务:点开始自动加购并跳转购物车</text>
  78. </view>
  79. <view class="form-item">
  80. <text class="label">需先开始再完成(两阶段)</text>
  81. <switch :checked="form.startRequired" @change="onStartRequiredChange" />
  82. <text class="form-hint">小游戏/专注/购物等任务建议开启</text>
  83. </view>
  84. <view class="form-item">
  85. <text class="label">需填写内容后完成</text>
  86. <switch :checked="form.requireInput" @change="onRequireInputChange" />
  87. </view>
  88. <view class="form-item">
  89. <text class="label">预计时长(分钟)</text>
  90. <input class="input" type="number" v-model="form.duration" placeholder="请输入预计完成时长" />
  91. </view>
  92. <button class="btn-primary" :loading="submitting" :disabled="submitting" @click="submit">创建任务</button>
  93. <!-- TASK-SYS-248: 前置/后置任务(流程任务) -->
  94. <view class="form-item form-item--flow">
  95. <text class="label label--pre">前置任务(可选,多选)</text>
  96. <text class="form-hint form-hint--tight">选择本任务执行前必须完成的任务</text>
  97. <view class="task-picker-scroll">
  98. <view v-for="task in prerequisiteTasks" :key="task.id" class="task-checkbox-row" @click="togglePrerequisite(task.id)">
  99. <view class="task-checkbox" :class="{ checked: form.prerequisiteTaskIds.indexOf(task.id) > -1 }">
  100. <text v-if="form.prerequisiteTaskIds.indexOf(task.id) > -1" class="check-icon">✓</text>
  101. </view>
  102. <text class="task-checkbox-label">{{ task.title }}</text>
  103. </view>
  104. <text v-if="prerequisiteTaskList.length === 0" class="form-hint">暂无可用任务</text>
  105. </view>
  106. </view>
  107. <view class="form-item">
  108. <text class="label label--post">后置任务(可选,多选)</text>
  109. <text class="form-hint form-hint--tight">选择本任务完成后自动解锁的任务(任意一个完成即可)</text>
  110. <view class="task-picker-scroll">
  111. <view v-for="task in successorTasks" :key="task.id" class="task-checkbox-row" @click="toggleSuccessor(task.id)">
  112. <view class="task-checkbox" :class="{ checked: form.successorTaskIds.indexOf(task.id) > -1 }">
  113. <text v-if="form.successorTaskIds.indexOf(task.id) > -1" class="check-icon">✓</text>
  114. </view>
  115. <text class="task-checkbox-label">{{ task.title }}</text>
  116. </view>
  117. <text v-if="successorTaskList.length === 0" class="form-hint">暂无可用任务</text>
  118. </view>
  119. </view>
  120. <view class="form-item" v-if="form.prerequisiteTaskIds.length > 0 || form.successorTaskIds.length > 0">
  121. <text class="label">流程任务</text>
  122. <text class="flow-hint">自动标记为流程任务</text>
  123. <text class="form-hint">有前置或后置任务时,本任务将自动保存为流程任务</text>
  124. </view>
  125. </view>
  126. </view>
  127. </template>
  128. <script>
  129. import config from '@/config.js'
  130. import { getChildren, createTask, getTaskListForPicker } from '../../utils/api.js'
  131. import fontScaleMixin from '@/mixins/font-scale.js'
  132. export default {
  133. mixins: [fontScaleMixin],
  134. data() {
  135. return {
  136. form: {
  137. title: '',
  138. description: '',
  139. memberIds: [],
  140. points: 2,
  141. category: '',
  142. minigameCode: '',
  143. deadline: null,
  144. repeatType: 'none',
  145. needReview: false,
  146. duration: null,
  147. actionType: '',
  148. actionConfig: '',
  149. requireInput: false,
  150. startRequired: false,
  151. prerequisiteTaskIds: [],
  152. successorTaskIds: []
  153. },
  154. children: [],
  155. selectedChildIds: [],
  156. categories: ['学习类', '生活类', '运动类', '小游戏类', '其他'],
  157. miniGames: [],
  158. selectedMinigame: null,
  159. prerequisiteTaskList: [],
  160. successorTaskList: [],
  161. repeatTypes: [
  162. { value: 'none', label: '不重复' },
  163. { value: 'daily', label: '每天' },
  164. { value: 'weekly', label: '每周' },
  165. { value: 'every_other_day', label: '隔天' },
  166. { value: 'monthly', label: '每月' }
  167. ],
  168. actionTypes: [
  169. { value: '', label: '无(手动完成)' },
  170. { value: 'minigame', label: '小游戏' },
  171. { value: 'focus', label: '专注训练' },
  172. { value: 'survey', label: '问卷/测评' },
  173. { value: 'article', label: '文章阅读' },
  174. { value: 'activity', label: '活动报名' },
  175. { value: 'cart', label: '购物(加购)' },
  176. { value: 'order', label: '购物(支付/收货)' },
  177. { value: 'diet', label: '饮食打卡' },
  178. { value: 'sleep', label: '睡眠打卡' },
  179. { value: 'exercise', label: '运动打卡' }
  180. ],
  181. dateTimeRange: [[], [], [], [], []],
  182. dateTimeIndex: [0, 0, 0, 0, 0],
  183. deadlineText: '',
  184. submitting: false
  185. }
  186. },
  187. computed: {
  188. repeatTypeLabel() {
  189. const found = this.repeatTypes.find(r => r.value === this.form.repeatType)
  190. return found ? found.label : ''
  191. },
  192. actionTypeLabel() {
  193. const found = this.actionTypes.find(a => a.value === this.form.actionType)
  194. return found ? found.label : ''
  195. }
  196. },
  197. onLoad(options) {
  198. this.loadChildren(options.memberId)
  199. this.loadMiniGames()
  200. this.initDateTimePicker()
  201. },
  202. methods: {
  203. async loadChildren(preselectedChildId) {
  204. try {
  205. const res = await getChildren()
  206. this.children = res.data || []
  207. // 如果有 preselect memberId,自动选中该成员
  208. if (preselectedChildId && this.children.length > 0) {
  209. const matched = this.children.find(c => String(c.id) === String(preselectedChildId))
  210. if (matched) {
  211. const idNum = Number(matched.id)
  212. if (!this.form.memberIds.includes(idNum)) {
  213. this.form.memberIds.push(idNum)
  214. this.selectedChildIds.push(String(idNum))
  215. }
  216. }
  217. }
  218. } catch (e) {
  219. console.error(e)
  220. }
  221. },
  222. async loadMiniGames() {
  223. try {
  224. const res = await uni.request({
  225. url: config.api('/api/tasks/minigame-options'),
  226. method: 'POST',
  227. header: {
  228. 'Authorization': `Bearer ${uni.getStorageSync('token')}`
  229. }
  230. })
  231. if (res.data && res.data.code === 200) {
  232. this.miniGames = res.data.data || []
  233. }
  234. } catch (e) {
  235. console.error(e)
  236. }
  237. },
  238. initDateTimePicker() {
  239. // 初始化日期时间选择器
  240. const now = new Date()
  241. const year = now.getFullYear()
  242. const month = now.getMonth() + 1
  243. const day = now.getDate()
  244. const hour = now.getHours()
  245. const minute = now.getMinutes()
  246. // 年(当前年份+未来2年)
  247. const years = []
  248. for (let i = year; i <= year + 2; i++) {
  249. years.push(i + '年')
  250. }
  251. // 月
  252. const months = []
  253. for (let i = 1; i <= 12; i++) {
  254. months.push(i + '月')
  255. }
  256. // 日
  257. const days = []
  258. const daysInMonth = new Date(year, month, 0).getDate()
  259. for (let i = 1; i <= daysInMonth; i++) {
  260. days.push(i + '日')
  261. }
  262. // 时
  263. const hours = []
  264. for (let i = 0; i < 24; i++) {
  265. hours.push(i.toString().padStart(2, '0') + '时')
  266. }
  267. // 分
  268. const minutes = []
  269. for (let i = 0; i < 60; i += 5) {
  270. minutes.push(i.toString().padStart(2, '0') + '分')
  271. }
  272. this.dateTimeRange = [years, months, days, hours, minutes]
  273. // 默认选中当前日期时间
  274. this.dateTimeIndex = [0, month - 1, day - 1, hour, Math.floor(minute / 5)]
  275. },
  276. onChildrenChange(e) {
  277. this.selectedChildIds = e.detail.value
  278. this.form.memberIds = e.detail.value.map(Number)
  279. },
  280. onCategoryChange(e) {
  281. this.form.category = this.categories[e.detail.value]
  282. // 清空小游戏选择
  283. if (this.form.category !== '小游戏类') {
  284. this.form.minigameCode = ''
  285. this.selectedMinigame = null
  286. }
  287. },
  288. onMinigameChange(e) {
  289. this.selectedMinigame = this.miniGames[e.detail.value]
  290. this.form.minigameCode = this.selectedMinigame.gameCode
  291. // 自动填充任务名称
  292. if (!this.form.title) {
  293. this.form.title = this.selectedMinigame.gameName
  294. }
  295. // 自动填充积分
  296. if (this.selectedMinigame.defaultPoints) {
  297. this.form.points = this.selectedMinigame.defaultPoints
  298. }
  299. },
  300. onDateTimeChange(e) {
  301. const vals = e.detail.value
  302. this.dateTimeIndex = vals
  303. const now = new Date()
  304. const year = now.getFullYear() + vals[0]
  305. const month = vals[1] + 1
  306. const day = vals[2] + 1
  307. const hour = vals[3]
  308. const minute = vals[4] * 5
  309. this.deadlineText = `${year}年${month}月${day}日 ${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`
  310. this.form.deadline = new Date(year, month - 1, day, hour, minute)
  311. },
  312. onColumnChange(e) {
  313. const column = e.detail.column
  314. this.$set(this.dateTimeIndex, column, e.detail.value)
  315. // 年、月变化时更新当月天数(含闰年处理)
  316. if (column === 0 || column === 1) {
  317. const now = new Date()
  318. const year = now.getFullYear() + this.dateTimeIndex[0]
  319. const month = this.dateTimeIndex[1] + 1
  320. const daysInMonth = new Date(year, month, 0).getDate()
  321. const days = []
  322. for (let i = 1; i <= daysInMonth; i++) {
  323. days.push(i + '日')
  324. }
  325. this.$set(this.dateTimeRange, 2, days)
  326. // 若选中的日超出新月天数,回退到最后一天
  327. if (this.dateTimeIndex[2] >= daysInMonth) {
  328. this.$set(this.dateTimeIndex, 2, daysInMonth - 1)
  329. }
  330. }
  331. },
  332. onRepeatChange(e) {
  333. const selected = this.repeatTypes[e.detail.value]
  334. this.form.repeatType = selected.value
  335. },
  336. onNeedReviewChange(e) {
  337. this.form.needReview = e.detail.value
  338. },
  339. onActionTypeChange(e) {
  340. const selected = this.actionTypes[e.detail.value]
  341. this.form.actionType = selected.value
  342. if (selected.value !== 'cart' && selected.value !== 'order') {
  343. this.form.actionConfig = ''
  344. }
  345. },
  346. onStartRequiredChange(e) {
  347. this.form.startRequired = e.detail.value
  348. },
  349. onRequireInputChange(e) {
  350. this.form.requireInput = e.detail.value
  351. },
  352. async loadRelatedTasks() {
  353. try {
  354. var res = await getTaskListForPicker({
  355. memberId: this.form.memberIds.length > 0 ? this.form.memberIds[0] : null,
  356. titleKeyword: this.form.category
  357. })
  358. if (res && res.code === 200) {
  359. // 过滤掉已经在表单中选择的任务 ID(排除当前正在创建的任务本身)
  360. const allTasks = res.data || []
  361. this.prerequisiteTaskList = allTasks.filter(t => !this.form.prerequisiteTaskIds.includes(t.id))
  362. this.successorTaskList = allTasks.filter(t => !this.form.successorTaskIds.includes(t.id))
  363. }
  364. } catch (e) {
  365. console.error('加载关联任务列表失败:', e)
  366. }
  367. },
  368. togglePrerequisite(taskId) {
  369. if (this.form.prerequisiteTaskIds.includes(taskId)) {
  370. this.form.prerequisiteTaskIds = this.form.prerequisiteTaskIds.filter(id => id !== taskId)
  371. } else {
  372. this.form.prerequisiteTaskIds.push(taskId)
  373. }
  374. },
  375. toggleSuccessor(taskId) {
  376. if (this.form.successorTaskIds.includes(taskId)) {
  377. this.form.successorTaskIds = this.form.successorTaskIds.filter(id => id !== taskId)
  378. } else {
  379. this.form.successorTaskIds.push(taskId)
  380. }
  381. },
  382. async submit() {
  383. if (this.submitting) return
  384. if (!this.form.title) {
  385. uni.showToast({ title: '请输入任务名称', icon: 'none' })
  386. return
  387. }
  388. if (this.form.memberIds.length === 0) {
  389. uni.showToast({ title: '请选择至少一个成员', icon: 'none' })
  390. return
  391. }
  392. if (!this.form.deadline) {
  393. uni.showToast({ title: '请选择截止时间', icon: 'none' })
  394. return
  395. }
  396. // 小游戏类必须选择具体游戏
  397. if (this.form.category === '小游戏类' && !this.form.minigameCode) {
  398. uni.showToast({ title: '请选择具体小游戏', icon: 'none' })
  399. return
  400. }
  401. this.submitting = true
  402. uni.showLoading({ title: '创建任务中...' })
  403. const taskData = {
  404. title: this.form.title,
  405. description: this.form.description,
  406. points: parseInt(this.form.points) || 2,
  407. category: this.form.category || '其他',
  408. minigameCode: this.form.minigameCode,
  409. deadline: this.form.deadline.toISOString(),
  410. repeatType: this.form.repeatType,
  411. frequency: this.form.repeatType,
  412. needReview: this.form.needReview ? 1 : 0,
  413. duration: parseInt(this.form.duration) || 0,
  414. // TASK-SYS-248: 行为调度中心字段
  415. actionType: this.form.actionType || '',
  416. actionConfig: this.form.actionConfig || '',
  417. requireInput: this.form.requireInput ? 1 : 0,
  418. startRequired: this.form.startRequired ? 1 : 0,
  419. prerequisiteTaskId: this.form.prerequisiteTaskIds.length > 0 ? this.form.prerequisiteTaskIds[0] : null,
  420. successorTaskIds: this.form.successorTaskIds.length > 0 ? this.form.successorTaskIds : null
  421. }
  422. let successCount = 0
  423. let failCount = 0
  424. try {
  425. for (const memberId of this.form.memberIds) {
  426. try {
  427. await createTask({ ...taskData, memberId })
  428. successCount++
  429. } catch (e) {
  430. failCount++
  431. }
  432. }
  433. } finally {
  434. this.submitting = false
  435. uni.hideLoading()
  436. }
  437. if (failCount === 0) {
  438. uni.showToast({ title: `已为${successCount}个成员创建任务`, icon: 'success' })
  439. setTimeout(() => uni.navigateBack(), 1500)
  440. } else if (successCount > 0) {
  441. uni.showToast({ title: `${successCount}个成功,${failCount}个失败`, icon: 'none' })
  442. } else {
  443. uni.showToast({ title: '创建失败', icon: 'none' })
  444. }
  445. }
  446. }
  447. }
  448. </script>
  449. <style scoped lang="scss">
  450. .container { padding: $uni-space-lg; }
  451. .form-container { background: $app-surface; border-radius: 24rpx; padding: $uni-space-lg; }
  452. .form-item { margin-bottom: $uni-space-lg; }
  453. .form-item--flow { margin-top: 20rpx; padding-top: 20rpx; border-top: 1rpx solid $facade-hairline-2; }
  454. .label { display: block; font-size: calc(28rpx * var(--font-scale, 1)); color: $facade-text-strong; margin-bottom: 10rpx; }
  455. .label--pre { color: $brand-primary; }
  456. .label--post { color: $dim-wisdom; }
  457. .task-picker-scroll { max-height: 200rpx; overflow-y: auto; }
  458. .flow-hint { font-size: calc(24rpx * var(--font-scale, 1)); color: $brand-primary; }
  459. .input, .textarea { border: 1rpx solid $facade-border-soft; border-radius: 8rpx; font-size: calc(28rpx * var(--font-scale, 1)); width: 100%; box-sizing: border-box; }
  460. /* 输入框高度统一 108rpx;line-height 与高度一致,保证微信 input 文字垂直居中 */
  461. .input { height: 108rpx; line-height: 108rpx; padding: 0 24rpx; }
  462. .textarea { height: 150rpx; line-height: 1.5; padding: 20rpx 24rpx; }
  463. .picker { border: 1rpx solid $facade-border-soft; border-radius: 8rpx; font-size: calc(28rpx * var(--font-scale, 1)); color: $facade-text-body; box-sizing: border-box; height: 108rpx; display: flex; align-items: center; padding: 0 24rpx; }
  464. .form-hint { display: block; font-size: calc(22rpx * var(--font-scale, 1)); color: $facade-text-muted; margin-top: 8rpx; }
  465. .form-hint--tight { margin-bottom: 10rpx; }
  466. .child-checkbox { display: flex; align-items: center; padding: 16rpx 0; border-bottom: 1rpx solid $facade-fill; }
  467. .child-checkbox:last-child { border-bottom: none; }
  468. .child-name { font-size: calc(28rpx * var(--font-scale, 1)); color: $facade-text-strong; margin-left: 10rpx; }
  469. /* 创建按钮:显式 height + line-height 同高,微信 button 文字才能上下居中 */
  470. .btn-primary { background: $brand-primary; color: $app-surface; border-radius: 24rpx; margin-top: 40rpx; width: 100%; height: 96rpx; line-height: 96rpx; font-size: calc(32rpx * var(--font-scale, 1)); border: none; padding: 0; }
  471. /* 清除微信 button 组件默认的 ::after 1px 边框(否则按钮文字与边框视觉不居中) */
  472. .btn-primary::after { border: none; }
  473. </style>