create-task.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489
  1. <template>
  2. <view class="container">
  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" style="margin-top: 20rpx; padding-top: 20rpx; border-top: 1rpx solid #eee;">
  95. <text class="label" style="color: #F97316;">前置任务(可选,多选)</text>
  96. <text class="form-hint" style="margin-bottom: 10rpx;">选择本任务执行前必须完成的任务</text>
  97. <view style="max-height: 200rpx; overflow-y: auto;">
  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" style="color: #6366F1;">后置任务(可选,多选)</text>
  109. <text class="form-hint" style="margin-bottom: 10rpx;">选择本任务完成后自动解锁的任务(任意一个完成即可)</text>
  110. <view style="max-height: 200rpx; overflow-y: auto;">
  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 style="font-size: 24rpx; color: #F97316;">自动标记为流程任务</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. export default {
  132. data() {
  133. return {
  134. form: {
  135. title: '',
  136. description: '',
  137. memberIds: [],
  138. points: 2,
  139. category: '',
  140. minigameCode: '',
  141. deadline: null,
  142. repeatType: 'none',
  143. needReview: false,
  144. duration: null,
  145. actionType: '',
  146. actionConfig: '',
  147. requireInput: false,
  148. startRequired: false,
  149. prerequisiteTaskIds: [],
  150. successorTaskIds: []
  151. },
  152. children: [],
  153. selectedChildIds: [],
  154. categories: ['学习类', '生活类', '运动类', '小游戏类', '其他'],
  155. miniGames: [],
  156. selectedMinigame: null,
  157. prerequisiteTaskList: [],
  158. successorTaskList: [],
  159. repeatTypes: [
  160. { value: 'none', label: '不重复' },
  161. { value: 'daily', label: '每天' },
  162. { value: 'weekly', label: '每周' },
  163. { value: 'every_other_day', label: '隔天' },
  164. { value: 'monthly', label: '每月' }
  165. ],
  166. actionTypes: [
  167. { value: '', label: '无(手动完成)' },
  168. { value: 'minigame', label: '小游戏' },
  169. { value: 'focus', label: '专注训练' },
  170. { value: 'survey', label: '问卷/测评' },
  171. { value: 'article', label: '文章阅读' },
  172. { value: 'activity', label: '活动报名' },
  173. { value: 'cart', label: '购物(加购)' },
  174. { value: 'order', label: '购物(支付/收货)' }
  175. ],
  176. dateTimeRange: [[], [], [], [], []],
  177. dateTimeIndex: [0, 0, 0, 0, 0],
  178. deadlineText: '',
  179. submitting: false
  180. }
  181. },
  182. computed: {
  183. repeatTypeLabel() {
  184. const found = this.repeatTypes.find(r => r.value === this.form.repeatType)
  185. return found ? found.label : ''
  186. },
  187. actionTypeLabel() {
  188. const found = this.actionTypes.find(a => a.value === this.form.actionType)
  189. return found ? found.label : ''
  190. }
  191. },
  192. onLoad(options) {
  193. this.loadChildren(options.memberId)
  194. this.loadMiniGames()
  195. this.initDateTimePicker()
  196. },
  197. methods: {
  198. async loadChildren(preselectedChildId) {
  199. try {
  200. const res = await getChildren()
  201. this.children = res.data || []
  202. // 如果有 preselect memberId,自动选中该孩子
  203. if (preselectedChildId && this.children.length > 0) {
  204. const matched = this.children.find(c => String(c.id) === String(preselectedChildId))
  205. if (matched) {
  206. const idNum = Number(matched.id)
  207. if (!this.form.memberIds.includes(idNum)) {
  208. this.form.memberIds.push(idNum)
  209. this.selectedChildIds.push(String(idNum))
  210. }
  211. }
  212. }
  213. } catch (e) {
  214. console.error(e)
  215. }
  216. },
  217. async loadMiniGames() {
  218. try {
  219. const res = await uni.request({
  220. url: config.api('/api/tasks/minigame-options'),
  221. method: 'POST',
  222. header: {
  223. 'Authorization': `Bearer ${uni.getStorageSync('token')}`
  224. }
  225. })
  226. if (res.data && res.data.code === 200) {
  227. this.miniGames = res.data.data || []
  228. }
  229. } catch (e) {
  230. console.error(e)
  231. }
  232. },
  233. initDateTimePicker() {
  234. // 初始化日期时间选择器
  235. const now = new Date()
  236. const year = now.getFullYear()
  237. const month = now.getMonth() + 1
  238. const day = now.getDate()
  239. const hour = now.getHours()
  240. const minute = now.getMinutes()
  241. // 年(当前年份+未来2年)
  242. const years = []
  243. for (let i = year; i <= year + 2; i++) {
  244. years.push(i + '年')
  245. }
  246. // 月
  247. const months = []
  248. for (let i = 1; i <= 12; i++) {
  249. months.push(i + '月')
  250. }
  251. // 日
  252. const days = []
  253. const daysInMonth = new Date(year, month, 0).getDate()
  254. for (let i = 1; i <= daysInMonth; i++) {
  255. days.push(i + '日')
  256. }
  257. // 时
  258. const hours = []
  259. for (let i = 0; i < 24; i++) {
  260. hours.push(i.toString().padStart(2, '0') + '时')
  261. }
  262. // 分
  263. const minutes = []
  264. for (let i = 0; i < 60; i += 5) {
  265. minutes.push(i.toString().padStart(2, '0') + '分')
  266. }
  267. this.dateTimeRange = [years, months, days, hours, minutes]
  268. // 默认选中当前日期时间
  269. this.dateTimeIndex = [0, month - 1, day - 1, hour, Math.floor(minute / 5)]
  270. },
  271. onChildrenChange(e) {
  272. this.selectedChildIds = e.detail.value
  273. this.form.memberIds = e.detail.value.map(Number)
  274. },
  275. onCategoryChange(e) {
  276. this.form.category = this.categories[e.detail.value]
  277. // 清空小游戏选择
  278. if (this.form.category !== '小游戏类') {
  279. this.form.minigameCode = ''
  280. this.selectedMinigame = null
  281. }
  282. },
  283. onMinigameChange(e) {
  284. this.selectedMinigame = this.miniGames[e.detail.value]
  285. this.form.minigameCode = this.selectedMinigame.gameCode
  286. // 自动填充任务名称
  287. if (!this.form.title) {
  288. this.form.title = this.selectedMinigame.gameName
  289. }
  290. // 自动填充积分
  291. if (this.selectedMinigame.defaultPoints) {
  292. this.form.points = this.selectedMinigame.defaultPoints
  293. }
  294. },
  295. onDateTimeChange(e) {
  296. const vals = e.detail.value
  297. this.dateTimeIndex = vals
  298. const now = new Date()
  299. const year = now.getFullYear() + vals[0]
  300. const month = vals[1] + 1
  301. const day = vals[2] + 1
  302. const hour = vals[3]
  303. const minute = vals[4] * 5
  304. this.deadlineText = `${year}年${month}月${day}日 ${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`
  305. this.form.deadline = new Date(year, month - 1, day, hour, minute)
  306. },
  307. onColumnChange(e) {
  308. const column = e.detail.column
  309. this.$set(this.dateTimeIndex, column, e.detail.value)
  310. // 年、月变化时更新当月天数(含闰年处理)
  311. if (column === 0 || column === 1) {
  312. const now = new Date()
  313. const year = now.getFullYear() + this.dateTimeIndex[0]
  314. const month = this.dateTimeIndex[1] + 1
  315. const daysInMonth = new Date(year, month, 0).getDate()
  316. const days = []
  317. for (let i = 1; i <= daysInMonth; i++) {
  318. days.push(i + '日')
  319. }
  320. this.$set(this.dateTimeRange, 2, days)
  321. // 若选中的日超出新月天数,回退到最后一天
  322. if (this.dateTimeIndex[2] >= daysInMonth) {
  323. this.$set(this.dateTimeIndex, 2, daysInMonth - 1)
  324. }
  325. }
  326. },
  327. onRepeatChange(e) {
  328. const selected = this.repeatTypes[e.detail.value]
  329. this.form.repeatType = selected.value
  330. },
  331. onNeedReviewChange(e) {
  332. this.form.needReview = e.detail.value
  333. },
  334. onActionTypeChange(e) {
  335. const selected = this.actionTypes[e.detail.value]
  336. this.form.actionType = selected.value
  337. if (selected.value !== 'cart' && selected.value !== 'order') {
  338. this.form.actionConfig = ''
  339. }
  340. },
  341. onStartRequiredChange(e) {
  342. this.form.startRequired = e.detail.value
  343. },
  344. onRequireInputChange(e) {
  345. this.form.requireInput = e.detail.value
  346. },
  347. async loadRelatedTasks() {
  348. try {
  349. var res = await getTaskListForPicker({
  350. memberId: this.form.memberIds.length > 0 ? this.form.memberIds[0] : null,
  351. titleKeyword: this.form.category
  352. })
  353. if (res && res.code === 200) {
  354. // 过滤掉已经在表单中选择的任务 ID(排除当前正在创建的任务本身)
  355. const allTasks = res.data || []
  356. this.prerequisiteTaskList = allTasks.filter(t => !this.form.prerequisiteTaskIds.includes(t.id))
  357. this.successorTaskList = allTasks.filter(t => !this.form.successorTaskIds.includes(t.id))
  358. }
  359. } catch (e) {
  360. console.error('加载关联任务列表失败:', e)
  361. }
  362. },
  363. togglePrerequisite(taskId) {
  364. if (this.form.prerequisiteTaskIds.includes(taskId)) {
  365. this.form.prerequisiteTaskIds = this.form.prerequisiteTaskIds.filter(id => id !== taskId)
  366. } else {
  367. this.form.prerequisiteTaskIds.push(taskId)
  368. }
  369. },
  370. toggleSuccessor(taskId) {
  371. if (this.form.successorTaskIds.includes(taskId)) {
  372. this.form.successorTaskIds = this.form.successorTaskIds.filter(id => id !== taskId)
  373. } else {
  374. this.form.successorTaskIds.push(taskId)
  375. }
  376. },
  377. async submit() {
  378. if (this.submitting) return
  379. if (!this.form.title) {
  380. uni.showToast({ title: '请输入任务名称', icon: 'none' })
  381. return
  382. }
  383. if (this.form.memberIds.length === 0) {
  384. uni.showToast({ title: '请选择至少一个孩子', icon: 'none' })
  385. return
  386. }
  387. if (!this.form.deadline) {
  388. uni.showToast({ title: '请选择截止时间', icon: 'none' })
  389. return
  390. }
  391. // 小游戏类必须选择具体游戏
  392. if (this.form.category === '小游戏类' && !this.form.minigameCode) {
  393. uni.showToast({ title: '请选择具体小游戏', icon: 'none' })
  394. return
  395. }
  396. this.submitting = true
  397. uni.showLoading({ title: '创建任务中...' })
  398. const taskData = {
  399. title: this.form.title,
  400. description: this.form.description,
  401. points: parseInt(this.form.points) || 2,
  402. category: this.form.category || '其他',
  403. minigameCode: this.form.minigameCode,
  404. deadline: this.form.deadline.toISOString(),
  405. repeatType: this.form.repeatType,
  406. frequency: this.form.repeatType,
  407. needReview: this.form.needReview ? 1 : 0,
  408. duration: parseInt(this.form.duration) || 0,
  409. // TASK-SYS-248: 行为调度中心字段
  410. actionType: this.form.actionType || '',
  411. actionConfig: this.form.actionConfig || '',
  412. requireInput: this.form.requireInput ? 1 : 0,
  413. startRequired: this.form.startRequired ? 1 : 0,
  414. prerequisiteTaskId: this.form.prerequisiteTaskIds.length > 0 ? this.form.prerequisiteTaskIds[0] : null,
  415. successorTaskIds: this.form.successorTaskIds.length > 0 ? this.form.successorTaskIds : null
  416. }
  417. let successCount = 0
  418. let failCount = 0
  419. try {
  420. for (const memberId of this.form.memberIds) {
  421. try {
  422. await createTask({ ...taskData, memberId })
  423. successCount++
  424. } catch (e) {
  425. failCount++
  426. }
  427. }
  428. } finally {
  429. this.submitting = false
  430. uni.hideLoading()
  431. }
  432. if (failCount === 0) {
  433. uni.showToast({ title: `已为${successCount}个孩子创建任务`, icon: 'success' })
  434. setTimeout(() => uni.navigateBack(), 1500)
  435. } else if (successCount > 0) {
  436. uni.showToast({ title: `${successCount}个成功,${failCount}个失败`, icon: 'none' })
  437. } else {
  438. uni.showToast({ title: '创建失败', icon: 'none' })
  439. }
  440. }
  441. }
  442. }
  443. </script>
  444. <style scoped>
  445. .container { padding: 30rpx; }
  446. .form-container { background: #fff; border-radius: 20rpx; padding: 30rpx; }
  447. .form-item { margin-bottom: 30rpx; }
  448. .label { display: block; font-size: 28rpx; color: #333; margin-bottom: 10rpx; }
  449. .input, .textarea { border: 1rpx solid #ddd; border-radius: 10rpx; padding: 20rpx; font-size: 28rpx; width: 100%; box-sizing: border-box; }
  450. .input { height: 96rpx; }
  451. .textarea { height: 150rpx; }
  452. .picker { border: 1rpx solid #ddd; border-radius: 10rpx; padding: 20rpx; font-size: 28rpx; color: #666; }
  453. .form-hint { display: block; font-size: 22rpx; color: #999; margin-top: 8rpx; }
  454. .child-checkbox { display: flex; align-items: center; padding: 16rpx 0; border-bottom: 1rpx solid #f0f0f0; }
  455. .child-checkbox:last-child { border-bottom: none; }
  456. .child-name { font-size: 28rpx; color: #333; margin-left: 10rpx; }
  457. .btn-primary { background: #F97316; color: #fff; border-radius: 20rpx; margin-top: 40rpx; }
  458. </style>