tasks.vue 30 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054
  1. <template>
  2. <view class="container">
  3. <!-- 顶部:家庭成员选择(选择谁就展示谁的任务) -->
  4. <FamilyMemberStrip
  5. v-if="familyMembers.length > 0"
  6. :members="familyMembers"
  7. :selectedMemberId="currentMemberId"
  8. :isParent="isParent"
  9. @select="onFamilyMemberSelect"
  10. />
  11. <!-- Tab 切换 -->
  12. <view class="tab-bar">
  13. <view class="tab-item" :class="{ active: activeTab === 'pending' }" @click="switchTab('pending')">
  14. <text class="tab-text">待接受</text>
  15. <text class="tab-badge" v-if="pendingTasks.length > 0">{{ pendingTasks.length }}</text>
  16. </view>
  17. <view class="tab-item" :class="{ active: activeTab === 'today' }" @click="switchTab('today')">
  18. <text class="tab-text">今日任务</text>
  19. </view>
  20. <view class="tab-item" :class="{ active: activeTab === 'created' }" @click="switchTab('created')">
  21. <text class="tab-text">我创建的</text>
  22. </view>
  23. </view>
  24. <!-- 待接受任务 -->
  25. <view class="task-list" v-if="activeTab === 'pending'">
  26. <view class="task-item assigned" v-for="task in pendingTasks" :key="task.id">
  27. <view class="task-left">
  28. <view class="task-title">
  29. {{ task.title }}
  30. <text v-if="task.memberOnly === 1" class="member-badge">会员任务</text>
  31. </view>
  32. <view class="task-meta">
  33. <text class="points">+{{ task.points }}分</text>
  34. <text class="creator">创建人:{{ task.creatorName || '家人' }}</text>
  35. <text class="deadline">截止 {{ formatTime(task.deadline) }}</text>
  36. </view>
  37. </view>
  38. <view class="task-actions">
  39. <button class="btn-accept" @click="acceptTask(task)">接受</button>
  40. <button class="btn-reject" @click="showRejectModal(task)">拒绝</button>
  41. </view>
  42. </view>
  43. <view class="empty" v-if="!loading && pendingTasks.length === 0">
  44. <text>暂无待接受任务</text>
  45. </view>
  46. </view>
  47. <!-- 今日任务 -->
  48. <view class="task-list" v-if="activeTab === 'today'">
  49. <view class="task-item" v-for="task in todayTasks" :key="task.id" :class="{ completed: task.status === 'completed', locked: task.status === 'locked' }">
  50. <view class="task-left">
  51. <view class="task-title">
  52. {{ task.title }}
  53. <text v-if="task.memberOnly === 1" class="member-badge">会员任务</text>
  54. <text v-if="task.category === '小游戏类'" class="minigame-badge">🎮</text>
  55. <text v-if="task.actionType === 'cart' || task.actionType === 'order'" class="minigame-badge">🛒</text>
  56. <text v-if="task.actionType === 'focus'" class="minigame-badge">⏱️</text>
  57. <text v-if="task.actionType === 'survey'" class="minigame-badge">📝</text>
  58. <text v-if="task.actionType === 'article'" class="minigame-badge">📖</text>
  59. <text v-if="task.actionType === 'activity'" class="minigame-badge">🎯</text>
  60. <text v-if="task.requireInput === 1" class="minigame-badge">✍️</text>
  61. </view>
  62. <view class="task-meta">
  63. <text class="points">+{{ task.points }}分</text>
  64. <text class="deadline">截止 {{ formatTime(task.deadline) }}</text>
  65. </view>
  66. <!-- 进度型任务:展示进度条 -->
  67. <view class="progress-wrap" v-if="task.isDailyProgress === 1">
  68. <view class="progress-bar">
  69. <view class="progress-fill" :style="{ width: getProgressPercent(task) + '%' }"></view>
  70. </view>
  71. <text class="progress-text">{{ task.progress || 0 }}/{{ task.targetValue || 1 }}</text>
  72. </view>
  73. <text v-if="task.status === 'locked'" class="lock-tip">🔒 需先完成前置任务后解锁</text>
  74. </view>
  75. <view class="task-right" v-if="task.status === 'pending' || task.status === 'in_progress'">
  76. <button class="btn-complete" @click="handleTaskAction(task)">{{ getActionLabel(task) }}</button>
  77. </view>
  78. <view class="completed-badge" v-else-if="task.status === 'completed'">已完成</view>
  79. <view class="review-badge" v-else-if="task.status === 'pending_review'">待审核</view>
  80. <view class="lock-badge" v-else>🔒 未解锁</view>
  81. </view>
  82. <view class="empty" v-if="!loading && todayTasks.length === 0">
  83. <text>暂无今日任务</text>
  84. </view>
  85. </view>
  86. <!-- 我创建的任务 -->
  87. <view class="task-list" v-if="activeTab === 'created'">
  88. <view class="task-item" v-for="task in createdTasks" :key="task.id">
  89. <view class="task-left">
  90. <view class="task-title">
  91. {{ task.title }}
  92. <text v-if="task.memberOnly === 1" class="member-badge">会员任务</text>
  93. </view>
  94. <view class="task-meta">
  95. <text class="points">+{{ task.points }}分</text>
  96. <text class="assignee">{{ task.executorName || '未分配' }}</text>
  97. <text class="status-tag" :class="'status-' + task.status">{{ statusLabel(task.status) }}</text>
  98. </view>
  99. </view>
  100. </view>
  101. <view class="empty" v-if="!loading && createdTasks.length === 0">
  102. <text>暂无创建的任务</text>
  103. </view>
  104. </view>
  105. <!-- 添加任务按钮 -->
  106. <button v-if="activeTab === 'today' && isParent" class="add-btn" @click="showAddModal = true">
  107. + 添加任务
  108. </button>
  109. <!-- 流程编排入口 -->
  110. <button v-if="activeTab === 'today' && isParent" class="orchestration-btn" @click="goToOrchestration">
  111. 📋 流程编排
  112. </button>
  113. <!-- 拒绝确认弹窗 -->
  114. <view class="modal-mask" v-if="showRejectModalFlag" @click="showRejectModalFlag = false">
  115. <view class="modal" @click.stop>
  116. <view class="modal-title">确认拒绝</view>
  117. <view class="modal-content">
  118. <text>拒绝后,创建人对你的信任度和亲密度将各减1分,沟通质量各加1分。</text>
  119. </view>
  120. <view class="modal-btns">
  121. <button @click="showRejectModalFlag = false">取消</button>
  122. <button class="btn-primary" @click="confirmReject">确认拒绝</button>
  123. </view>
  124. </view>
  125. </view>
  126. <!-- 填写式完成弹窗 -->
  127. <view class="modal-mask" v-if="showInputModal" @click="closeInputModal">
  128. <view class="modal modal-large" @click.stop>
  129. <view class="modal-title">完成任务</view>
  130. <view class="modal-content">
  131. <text class="input-tip">{{ activeInputTask && activeInputTask.title }}</text>
  132. <textarea
  133. v-model="inputContent"
  134. class="input-textarea"
  135. placeholder="请填写完成内容(如:今天完成了...)"
  136. :maxlength="500"
  137. ></textarea>
  138. </view>
  139. <view class="modal-btns">
  140. <button @click="closeInputModal">取消</button>
  141. <button class="btn-primary" :disabled="!inputContent" @click="submitWithInput">提交完成</button>
  142. </view>
  143. </view>
  144. </view>
  145. <!-- 创建任务弹窗 -->
  146. <view class="modal-mask" v-if="showAddModal" @click="showAddModal = false">
  147. <view class="modal modal-large" @click.stop>
  148. <view class="modal-title">创建任务</view>
  149. <view class="form-item">
  150. <view class="form-label">任务标题 <text class="required">*</text></view>
  151. <input v-model="newTask.title" placeholder="请输入任务标题" />
  152. </view>
  153. <view class="form-item">
  154. <view class="form-label">任务内容</view>
  155. <textarea v-model="newTask.description" placeholder="请输入任务描述" class="textarea" />
  156. </view>
  157. <view class="form-item">
  158. <view class="form-label">类型 <text class="required">*</text></view>
  159. <picker mode="selector" :range="categories" @change="onCategoryChange">
  160. <view class="picker">{{ newTask.category || '请选择类型' }}</view>
  161. </picker>
  162. </view>
  163. <view class="form-item">
  164. <view class="form-label">积分 <text class="required">*</text></view>
  165. <input v-model="newTask.points" type="number" placeholder="1-10" />
  166. </view>
  167. <view class="form-item">
  168. <view class="form-label">截止时间 <text class="required">*</text></view>
  169. <picker mode="datetime" :value="newTask.deadline" @change="onDeadlineChange">
  170. <view class="picker">{{ newTask.deadline || '选择截止时间' }}</view>
  171. </picker>
  172. </view>
  173. <view class="form-item">
  174. <view class="form-label">分配给 <text class="required">*</text></view>
  175. <checkbox-group @change="onFamilyMemberSelectChange">
  176. <label v-for="member in familyMembers" :key="member.id" :class="{ 'label-checked': newTask.memberIds.includes(String(member.id)) }">
  177. <checkbox :value="String(member.id)" :checked="newTask.memberIds.includes(String(member.id))" />
  178. <text>{{ member.nickname }}</text>
  179. </label>
  180. </checkbox-group>
  181. </view>
  182. <view class="modal-btns">
  183. <button @click="showAddModal = false">取消</button>
  184. <button class="btn-primary" @click="createTask">创建</button>
  185. </view>
  186. </view>
  187. </view>
  188. </view>
  189. </template>
  190. <script>
  191. import { getMyPendingTasks, acceptTask as acceptTaskApi, rejectTask as rejectTaskApi, getTodayTasks, completeTask as completeTaskApi, startTask as startTaskApi, createTask as createTaskApi, getFamilyMemberList, getMyMembership, reportAppEnter
  192. } from '../../utils/api.js'
  193. import { parseDate } from '../../utils/format.js'
  194. import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'
  195. export default {
  196. components: { FamilyMemberStrip },
  197. data() {
  198. return {
  199. activeTab: 'today',
  200. loading: false,
  201. pendingTasks: [],
  202. todayTasks: [],
  203. createdTasks: [],
  204. isParent: false,
  205. isMember: false,
  206. currentMemberId: null,
  207. currentUserId: null,
  208. familyMembers: [],
  209. showAddModal: false,
  210. showRejectModalFlag: false,
  211. rejectTaskId: null,
  212. showInputModal: false,
  213. activeInputTask: null,
  214. inputContent: '',
  215. newTask: {
  216. title: '',
  217. description: '',
  218. category: '',
  219. points: 2,
  220. deadline: '',
  221. memberIds: []
  222. },
  223. categories: ['学习类', '生活类', '运动类', '其他']
  224. }
  225. },
  226. onLoad(options) {
  227. reportAppEnter('task');
  228. if (options && options.tab) {
  229. this.activeTab = options.tab
  230. }
  231. if (options && options.memberId) {
  232. var mid = parseInt(options.memberId, 10)
  233. if (!isNaN(mid)) {
  234. this.currentMemberId = mid
  235. uni.setStorageSync('currentMemberId', mid)
  236. uni.setStorageSync('currentChildId', mid)
  237. }
  238. }
  239. },
  240. onShow() {
  241. const role = uni.getStorageSync('currentRole') || uni.getStorageSync('role') || 'parent'
  242. this.isParent = role === 'parent'
  243. this.currentUserId = uni.getStorageSync('userId')
  244. if (this.currentMemberId == null) {
  245. var cached = uni.getStorageSync('currentMemberId') || uni.getStorageSync('currentChildId')
  246. if (cached !== '' && cached != null) {
  247. var n = parseInt(cached, 10)
  248. this.currentMemberId = isNaN(n) ? cached : n
  249. }
  250. }
  251. this.checkMembership()
  252. this.loadInitialData()
  253. },
  254. methods: {
  255. onFamilyMemberSelect(member) {
  256. if (!member || member.id == null) return
  257. var id = typeof member.id === 'number' ? member.id : parseInt(member.id, 10)
  258. if (isNaN(id)) id = member.id
  259. if (this.currentMemberId === id) return
  260. this.currentMemberId = id
  261. uni.setStorageSync('currentMemberId', id)
  262. uni.setStorageSync('currentChildId', id)
  263. this.loadTodayTasks()
  264. this.loadPendingTasks()
  265. },
  266. resolveTaskMemberId(task) {
  267. if (task && task.familyMemberId) return task.familyMemberId
  268. if (task && task.childId) return task.childId
  269. return this.currentMemberId
  270. },
  271. checkMembership() {
  272. getMyMembership().then(res => {
  273. const m = res.data || {}
  274. this.isMember = !!(m.levelCode && m.levelCode !== 'FREE')
  275. }).catch(() => {})
  276. },
  277. switchTab(tab) {
  278. this.activeTab = tab
  279. if (tab === 'pending') this.loadPendingTasks()
  280. else if (tab === 'today') this.loadTodayTasks()
  281. else if (tab === 'created') this.loadCreatedTasks()
  282. },
  283. goToOrchestration() {
  284. uni.navigateTo({ url: '/pages/orchestration/index' })
  285. },
  286. async loadInitialData() {
  287. this.loading = true
  288. try {
  289. const membersRes = await getFamilyMemberList({ visibleOnly: true })
  290. var raw = (membersRes && membersRes.data) || []
  291. this.familyMembers = Array.isArray(raw) ? raw : []
  292. this.ensureSelectedMemberFromRaw(this.familyMembers)
  293. if (!this.currentMemberId) {
  294. this.todayTasks = []
  295. this.pendingTasks = []
  296. } else {
  297. this.loadTodayTasks()
  298. this.loadPendingTasks()
  299. }
  300. this.loadCreatedTasks()
  301. } catch (e) {
  302. console.error('加载数据失败', e)
  303. } finally {
  304. this.loading = false
  305. }
  306. },
  307. ensureSelectedMemberFromRaw(raw) {
  308. var members = raw || []
  309. if (members.length === 0) {
  310. this.currentMemberId = null
  311. return
  312. }
  313. if (this.currentMemberId != null) {
  314. var cur = String(this.currentMemberId)
  315. for (var i = 0; i < members.length; i++) {
  316. if (members[i] && String(members[i].id) === cur) {
  317. // 统一成数字,保证 FamilyMemberStrip 选中态 === 比较成立
  318. var n = parseInt(members[i].id, 10)
  319. this.currentMemberId = isNaN(n) ? members[i].id : n
  320. return
  321. }
  322. }
  323. }
  324. var first = members[0]
  325. var fid = first && first.id != null ? first.id : null
  326. if (fid != null) {
  327. var pn = parseInt(fid, 10)
  328. this.currentMemberId = isNaN(pn) ? fid : pn
  329. uni.setStorageSync('currentMemberId', this.currentMemberId)
  330. uni.setStorageSync('currentChildId', this.currentMemberId)
  331. } else {
  332. this.currentMemberId = null
  333. }
  334. },
  335. async loadPendingTasks() {
  336. if (!this.currentMemberId) {
  337. this.pendingTasks = []
  338. return
  339. }
  340. try {
  341. const res = await getMyPendingTasks(this.currentMemberId)
  342. this.pendingTasks = res.data || []
  343. } catch (e) {
  344. console.error('加载待接受任务失败', e)
  345. }
  346. },
  347. async loadTodayTasks() {
  348. if (!this.currentMemberId) {
  349. this.todayTasks = []
  350. return
  351. }
  352. try {
  353. const res = await getTodayTasks(this.currentMemberId)
  354. this.todayTasks = res.data || []
  355. } catch (e) {
  356. console.error('加载今日任务失败', e)
  357. }
  358. },
  359. async loadCreatedTasks() {
  360. try {
  361. const res = await getTodayTasks(this.currentUserId)
  362. this.createdTasks = res.data || []
  363. } catch (e) {
  364. console.error('加载创建的任务失败', e)
  365. }
  366. },
  367. async completeTask(task) {
  368. if (task.memberOnly === 1 && !this.isMember) {
  369. uni.showModal({
  370. title: '会员任务',
  371. content: '该任务是会员专属任务,开通会员后即可完成',
  372. confirmText: '去开通',
  373. success: (res) => {
  374. if (res.confirm) {
  375. uni.navigateTo({ url: '/pages/membership/index' })
  376. }
  377. }
  378. })
  379. return
  380. }
  381. try {
  382. var memberId = this.resolveTaskMemberId(task)
  383. const res = await completeTaskApi(task.id, memberId, '')
  384. if (res.data && res.data.needReview) {
  385. uni.showToast({ title: '已提交,待审核', icon: 'none' })
  386. } else {
  387. uni.showToast({ title: `获得 ${res.data.pointsEarned} 积分`, icon: 'success' })
  388. }
  389. this.loadTodayTasks()
  390. } catch (e) {
  391. console.error('完成任务失败', e)
  392. }
  393. },
  394. /** 按钮文案:按 action_type / start_required / require_input 分派 */
  395. getActionLabel(task) {
  396. if (task.startRequired === 1 && task.status !== 'in_progress') return '开始'
  397. if (task.requireInput === 1) return '填写完成'
  398. var at = task.actionType
  399. if (at === 'minigame') return '开始游戏'
  400. if (at === 'focus') return '开始专注'
  401. if (at === 'survey') return '去填写'
  402. if (at === 'article') return '去阅读'
  403. if (at === 'activity') return '去报名'
  404. if (at === 'cart' || at === 'order') return '去购物'
  405. return '完成'
  406. },
  407. /** 统一任务动作入口:开始/联动跳转/填写/直接完成 */
  408. handleTaskAction(task) {
  409. var self = this
  410. // 前置锁定任务不可操作
  411. if (task.status === 'locked') {
  412. uni.showToast({ title: '需先完成前置任务', icon: 'none' })
  413. return
  414. }
  415. // 两阶段:先开始,再按 action_type 跳转联动页面
  416. if (task.startRequired === 1 && task.status !== 'in_progress') {
  417. uni.showLoading({ title: '开始中...' })
  418. startTaskApi(task.id, this.resolveTaskMemberId(task)).then(function(res) {
  419. uni.hideLoading()
  420. if (res && res.code === 200 && res.data) {
  421. uni.showToast({ title: '已开始', icon: 'success' })
  422. self.navigateByAction(task)
  423. self.loadTodayTasks()
  424. } else {
  425. uni.showToast({ title: (res && res.message) || '开始失败', icon: 'none' })
  426. }
  427. }).catch(function() {
  428. uni.hideLoading()
  429. uni.showToast({ title: '开始失败', icon: 'none' })
  430. })
  431. return
  432. }
  433. // 填写式完成
  434. if (task.requireInput === 1) {
  435. this.activeInputTask = task
  436. this.inputContent = ''
  437. this.showInputModal = true
  438. return
  439. }
  440. // 已开始的两阶段任务或普通任务:直接完成
  441. this.completeTask(task)
  442. },
  443. /** 按 action_type 跳转联动页面 */
  444. navigateByAction(task) {
  445. var at = task.actionType
  446. var taskId = task.id
  447. if (at === 'minigame') {
  448. var cfg = {}
  449. try { cfg = typeof task.actionConfig === 'string' ? JSON.parse(task.actionConfig || '{}') : (task.actionConfig || {}) } catch (e) {}
  450. var gameId = cfg.gameId || ''
  451. uni.navigateTo({ url: '/pages/games/index?taskId=' + taskId + (gameId ? '&gameId=' + gameId : '') })
  452. } else if (at === 'focus') {
  453. uni.navigateTo({ url: '/pages/games/focus-timer?taskId=' + taskId })
  454. } else if (at === 'survey') {
  455. uni.navigateTo({ url: '/pages/survey/index?taskId=' + taskId })
  456. } else if (at === 'article') {
  457. uni.navigateTo({ url: '/pages/article-center/index?taskId=' + taskId })
  458. } else if (at === 'activity') {
  459. uni.navigateTo({ url: '/pages/activity/list?taskId=' + taskId })
  460. } else if (at === 'cart' || at === 'order') {
  461. uni.navigateTo({ url: '/pages/shop/cart?taskId=' + taskId })
  462. }
  463. },
  464. closeInputModal() {
  465. this.showInputModal = false
  466. this.activeInputTask = null
  467. this.inputContent = ''
  468. },
  469. async submitWithInput() {
  470. var task = this.activeInputTask
  471. if (!task || !this.inputContent) return
  472. try {
  473. const res = await completeTaskApi(task.id, this.resolveTaskMemberId(task), '', this.inputContent, 'text')
  474. if (res.data && res.data.needReview) {
  475. uni.showToast({ title: '已提交,待审核', icon: 'none' })
  476. } else {
  477. uni.showToast({ title: `获得 ${res.data.pointsEarned} 积分`, icon: 'success' })
  478. }
  479. this.closeInputModal()
  480. this.loadTodayTasks()
  481. } catch (e) {
  482. console.error('提交填写内容失败', e)
  483. uni.showToast({ title: '提交失败', icon: 'none' })
  484. }
  485. },
  486. acceptTask(task) {
  487. if (task.memberOnly === 1 && !this.isMember) {
  488. uni.showModal({
  489. title: '会员任务',
  490. content: '该任务是会员专属任务,开通会员后即可接受',
  491. confirmText: '去开通',
  492. success: (res) => {
  493. if (res.confirm) {
  494. uni.navigateTo({ url: '/pages/membership/index' })
  495. }
  496. }
  497. })
  498. return
  499. }
  500. uni.showLoading({ title: '接受中...' })
  501. acceptTaskApi(task.id, this.currentMemberId).then(res => {
  502. uni.hideLoading()
  503. uni.showToast({ title: '已接受', icon: 'success' })
  504. this.loadPendingTasks()
  505. this.loadTodayTasks()
  506. }).catch(e => {
  507. uni.hideLoading()
  508. console.error('接受失败', e)
  509. uni.showToast({ title: '接受失败', icon: 'none' })
  510. })
  511. },
  512. showRejectModal(task) {
  513. this.rejectTaskId = task.id
  514. this.showRejectModalFlag = true
  515. },
  516. confirmReject() {
  517. uni.showLoading({ title: '拒绝中...' })
  518. rejectTaskApi(this.rejectTaskId, this.currentMemberId).then(res => {
  519. uni.hideLoading()
  520. this.showRejectModalFlag = false
  521. uni.showToast({ title: '已拒绝', icon: 'success' })
  522. this.loadPendingTasks()
  523. }).catch(e => {
  524. uni.hideLoading()
  525. console.error('拒绝失败', e)
  526. uni.showToast({ title: '拒绝失败', icon: 'none' })
  527. })
  528. },
  529. statusLabel(status) {
  530. const map = {
  531. pending: '待完成',
  532. assigned: '待接受',
  533. in_progress: '进行中',
  534. completed: '已完成',
  535. pending_review: '待审核',
  536. rejected: '已拒绝',
  537. overdue: '已过期',
  538. cancelled: '已取消',
  539. locked: '未解锁'
  540. }
  541. return map[status] || status
  542. },
  543. formatTime(t) {
  544. if (!t) return ''
  545. var d = parseDate(t)
  546. if (!d) return String(t).substring(0, 16)
  547. var y = d.getFullYear()
  548. var m = String(d.getMonth() + 1).padStart(2, '0')
  549. var day = String(d.getDate()).padStart(2, '0')
  550. var h = String(d.getHours()).padStart(2, '0')
  551. var min = String(d.getMinutes()).padStart(2, '0')
  552. return y + '-' + m + '-' + day + ' ' + h + ':' + min
  553. },
  554. getProgressPercent(task) {
  555. var progress = task.progress || 0
  556. var target = task.targetValue || 1
  557. if (target <= 0) return 0
  558. var pct = Math.round((progress / target) * 100)
  559. return pct > 100 ? 100 : pct
  560. },
  561. async createTask() {
  562. if (!this.newTask.title) {
  563. uni.showToast({ title: '请输入标题', icon: 'none' })
  564. return
  565. }
  566. if (!this.newTask.category) {
  567. uni.showToast({ title: '请选择类型', icon: 'none' })
  568. return
  569. }
  570. if (this.newTask.memberIds.length === 0) {
  571. uni.showToast({ title: '请选择分配对象', icon: 'none' })
  572. return
  573. }
  574. try {
  575. await createTaskApi({
  576. title: this.newTask.title,
  577. description: this.newTask.description,
  578. category: this.newTask.category,
  579. points: parseInt(this.newTask.points) || 2,
  580. deadline: this.newTask.deadline ? new Date(this.newTask.deadline).getTime() : null,
  581. executorType: 'member',
  582. memberIds: this.newTask.memberIds.map(id => Number(id))
  583. })
  584. uni.showToast({ title: '创建成功', icon: 'success' })
  585. this.showAddModal = false
  586. this.resetNewTask()
  587. this.loadCreatedTasks()
  588. this.loadPendingTasks()
  589. } catch (e) {
  590. console.error('创建任务失败', e)
  591. uni.showToast({ title: '创建失败', icon: 'none' })
  592. }
  593. },
  594. resetNewTask() {
  595. this.newTask = {
  596. title: '',
  597. description: '',
  598. category: '',
  599. points: 2,
  600. deadline: '',
  601. memberIds: []
  602. }
  603. },
  604. onCategoryChange(e) {
  605. this.newTask.category = this.categories[e.detail.value]
  606. },
  607. onDeadlineChange(e) {
  608. this.newTask.deadline = e.detail.value
  609. },
  610. onFamilyMemberSelectChange(e) {
  611. this.newTask.memberIds = e.detail.value || []
  612. }
  613. }
  614. }
  615. </script>
  616. <style scoped>
  617. .container {
  618. padding: 20rpx 30rpx 120rpx;
  619. background: #f5f7fa;
  620. min-height: 100vh;
  621. }
  622. .tab-bar {
  623. margin-top: 16rpx;
  624. display: flex;
  625. background: #fff;
  626. border-radius: 16rpx;
  627. padding: 10rpx;
  628. margin-bottom: 30rpx;
  629. box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
  630. }
  631. .tab-item {
  632. flex: 1;
  633. display: flex;
  634. align-items: center;
  635. justify-content: center;
  636. padding: 20rpx 0;
  637. border-radius: 12rpx;
  638. position: relative;
  639. }
  640. .tab-item.active {
  641. background: linear-gradient(135deg, #F97316, #FB923C);
  642. }
  643. .tab-text {
  644. font-size: 28rpx;
  645. color: #666;
  646. }
  647. .tab-item.active .tab-text {
  648. color: #fff;
  649. font-weight: 600;
  650. }
  651. .tab-badge {
  652. position: absolute;
  653. top: 8rpx;
  654. right: 20rpx;
  655. background: #EF4444;
  656. color: #fff;
  657. font-size: 20rpx;
  658. min-width: 28rpx;
  659. height: 28rpx;
  660. line-height: 28rpx;
  661. text-align: center;
  662. border-radius: 14rpx;
  663. padding: 0 6rpx;
  664. }
  665. .task-list {
  666. display: flex;
  667. flex-direction: column;
  668. gap: 20rpx;
  669. }
  670. .task-item {
  671. display: flex;
  672. align-items: center;
  673. justify-content: space-between;
  674. background: #fff;
  675. border-radius: 20rpx;
  676. padding: 28rpx;
  677. box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04);
  678. }
  679. .task-item.completed {
  680. opacity: 0.7;
  681. }
  682. .task-item.locked {
  683. opacity: 0.55;
  684. }
  685. .task-item.assigned {
  686. border-left: 6rpx solid #F97316;
  687. }
  688. .task-left {
  689. flex: 1;
  690. min-width: 0;
  691. padding-right: 20rpx;
  692. }
  693. .task-title {
  694. font-size: 30rpx;
  695. color: #333;
  696. font-weight: 500;
  697. margin-bottom: 12rpx;
  698. }
  699. .member-badge {
  700. font-size: 20rpx;
  701. color: #F97316;
  702. background: #FFF7ED;
  703. padding: 2rpx 10rpx;
  704. border-radius: 8rpx;
  705. margin-left: 8rpx;
  706. }
  707. .minigame-badge {
  708. margin-left: 8rpx;
  709. font-size: 24rpx;
  710. }
  711. .task-meta {
  712. display: flex;
  713. flex-wrap: wrap;
  714. gap: 16rpx;
  715. font-size: 24rpx;
  716. color: #999;
  717. }
  718. .points {
  719. color: #F97316;
  720. font-weight: 500;
  721. }
  722. .deadline, .creator, .assignee {
  723. color: #999;
  724. }
  725. .lock-tip {
  726. display: block;
  727. margin-top: 8rpx;
  728. font-size: 22rpx;
  729. color: #9CA3AF;
  730. }
  731. .progress-wrap {
  732. display: flex;
  733. align-items: center;
  734. margin-top: 12rpx;
  735. }
  736. .progress-bar {
  737. flex: 1;
  738. height: 12rpx;
  739. background: #F3F4F6;
  740. border-radius: 6rpx;
  741. overflow: hidden;
  742. margin-right: 12rpx;
  743. }
  744. .progress-fill {
  745. height: 100%;
  746. background: linear-gradient(135deg, #F97316, #FB923C);
  747. border-radius: 6rpx;
  748. transition: width 0.3s ease;
  749. }
  750. .progress-text {
  751. font-size: 22rpx;
  752. color: #F97316;
  753. white-space: nowrap;
  754. }
  755. .task-right {
  756. flex-shrink: 0;
  757. }
  758. .btn-complete {
  759. background: linear-gradient(135deg, #F97316, #FB923C);
  760. color: #fff;
  761. font-size: 26rpx;
  762. padding: 0 28rpx;
  763. height: 64rpx;
  764. line-height: 64rpx;
  765. border-radius: 32rpx;
  766. border: none;
  767. }
  768. .task-actions {
  769. display: flex;
  770. gap: 12rpx;
  771. flex-shrink: 0;
  772. }
  773. .btn-accept {
  774. background: #10B981;
  775. color: #fff;
  776. font-size: 24rpx;
  777. padding: 0 24rpx;
  778. height: 56rpx;
  779. line-height: 56rpx;
  780. border-radius: 28rpx;
  781. border: none;
  782. }
  783. .btn-reject {
  784. background: #F3F4F6;
  785. color: #666;
  786. font-size: 24rpx;
  787. padding: 0 24rpx;
  788. height: 56rpx;
  789. line-height: 56rpx;
  790. border-radius: 28rpx;
  791. border: none;
  792. }
  793. .completed-badge, .review-badge, .lock-badge {
  794. font-size: 24rpx;
  795. padding: 8rpx 16rpx;
  796. border-radius: 12rpx;
  797. flex-shrink: 0;
  798. }
  799. .completed-badge {
  800. background: #ECFDF5;
  801. color: #10B981;
  802. }
  803. .review-badge {
  804. background: #FFF7ED;
  805. color: #F97316;
  806. }
  807. .lock-badge {
  808. background: #F3F4F6;
  809. color: #9CA3AF;
  810. }
  811. .status-tag {
  812. font-size: 22rpx;
  813. padding: 2rpx 10rpx;
  814. border-radius: 8rpx;
  815. background: #F3F4F6;
  816. color: #666;
  817. }
  818. .status-pending { background: #FFF7ED; color: #F97316; }
  819. .status-completed { background: #ECFDF5; color: #10B981; }
  820. .status-pending_review { background: #FEF3C7; color: #D97706; }
  821. .status-assigned { background: #EEF2FF; color: #6366F1; }
  822. .status-in_progress { background: #DBEAFE; color: #2563EB; }
  823. .status-rejected { background: #FEE2E2; color: #EF4444; }
  824. .status-locked { background: #F3F4F6; color: #9CA3AF; }
  825. .empty {
  826. display: flex;
  827. justify-content: center;
  828. align-items: center;
  829. padding: 80rpx 0;
  830. color: #9CA3AF;
  831. font-size: 28rpx;
  832. }
  833. .add-btn {
  834. position: fixed;
  835. left: 40rpx;
  836. right: 40rpx;
  837. bottom: 40rpx;
  838. height: 88rpx;
  839. line-height: 88rpx;
  840. background: linear-gradient(135deg, #F97316, #FB923C);
  841. color: #fff;
  842. font-size: 30rpx;
  843. border-radius: 44rpx;
  844. border: none;
  845. box-shadow: 0 8rpx 24rpx rgba(249, 115, 22, 0.35);
  846. }
  847. .orchestration-btn {
  848. position: fixed;
  849. left: 40rpx;
  850. right: 40rpx;
  851. bottom: 148rpx;
  852. height: 88rpx;
  853. line-height: 88rpx;
  854. background: linear-gradient(135deg, #6366F1, #8B5CF6);
  855. color: #fff;
  856. font-size: 30rpx;
  857. border-radius: 44rpx;
  858. border: none;
  859. box-shadow: 0 8rpx 24rpx rgba(99, 102, 241, 0.35);
  860. }
  861. .modal-mask {
  862. position: fixed;
  863. top: 0;
  864. left: 0;
  865. right: 0;
  866. bottom: 0;
  867. background: rgba(0, 0, 0, 0.45);
  868. display: flex;
  869. align-items: center;
  870. justify-content: center;
  871. z-index: 1000;
  872. }
  873. .modal {
  874. background: #fff;
  875. border-radius: 28rpx;
  876. padding: 40rpx;
  877. width: 85%;
  878. max-height: 80vh;
  879. overflow-y: auto;
  880. box-shadow: 0 8rpx 40rpx rgba(0, 0, 0, 0.12);
  881. }
  882. .modal-large {
  883. width: 90%;
  884. }
  885. .modal-title {
  886. font-size: 34rpx;
  887. font-weight: bold;
  888. margin-bottom: 32rpx;
  889. text-align: center;
  890. color: #333;
  891. }
  892. .modal-content {
  893. font-size: 28rpx;
  894. color: #666;
  895. line-height: 1.6;
  896. margin-bottom: 30rpx;
  897. text-align: center;
  898. }
  899. .input-tip {
  900. display: block;
  901. margin-bottom: 16rpx;
  902. color: #333;
  903. font-weight: 500;
  904. }
  905. .input-textarea {
  906. width: 100%;
  907. min-height: 180rpx;
  908. padding: 20rpx;
  909. background: #F9FAFB;
  910. border-radius: 12rpx;
  911. font-size: 28rpx;
  912. box-sizing: border-box;
  913. }
  914. .modal-btns {
  915. display: flex;
  916. gap: 20rpx;
  917. margin-top: 30rpx;
  918. }
  919. .modal-btns button {
  920. flex: 1;
  921. height: 88rpx;
  922. line-height: 88rpx;
  923. border-radius: 44rpx;
  924. font-size: 28rpx;
  925. border: none;
  926. box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.06);
  927. }
  928. .modal-btns button:first-child {
  929. background: #f5f5f5;
  930. color: #666;
  931. }
  932. .btn-primary {
  933. background: linear-gradient(135deg, #F97316, #FB923C) !important;
  934. color: #fff !important;
  935. }
  936. .form-item {
  937. margin-bottom: 28rpx;
  938. }
  939. .form-label {
  940. font-size: 28rpx;
  941. color: #333;
  942. margin-bottom: 14rpx;
  943. font-weight: 500;
  944. }
  945. .form-label .required {
  946. color: #F97316;
  947. }
  948. .form-item input,
  949. .form-item .picker,
  950. .form-item .textarea {
  951. width: 100%;
  952. padding: 20rpx;
  953. background: #F9FAFB;
  954. border-radius: 12rpx;
  955. font-size: 28rpx;
  956. box-sizing: border-box;
  957. }
  958. .form-item .textarea {
  959. min-height: 140rpx;
  960. }
  961. .form-item checkbox-group {
  962. display: flex;
  963. flex-wrap: wrap;
  964. gap: 16rpx;
  965. }
  966. .form-item label {
  967. display: flex;
  968. align-items: center;
  969. gap: 8rpx;
  970. padding: 12rpx 20rpx;
  971. background: #F9FAFB;
  972. border-radius: 12rpx;
  973. font-size: 26rpx;
  974. }
  975. .form-item label.label-checked {
  976. background: #FFF7ED;
  977. color: #F97316;
  978. }
  979. </style>