tasks.vue 30 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057
  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. else if (tab === 'today') this.loadTodayTasks()
  287. else if (tab === 'created') this.loadCreatedTasks()
  288. },
  289. async loadInitialData() {
  290. this.loading = true
  291. try {
  292. const membersRes = await getFamilyMemberList({ visibleOnly: true })
  293. var raw = (membersRes && membersRes.data) || []
  294. this.familyMembers = Array.isArray(raw) ? raw : []
  295. this.ensureSelectedMemberFromRaw(this.familyMembers)
  296. if (!this.currentMemberId) {
  297. this.todayTasks = []
  298. this.pendingTasks = []
  299. } else {
  300. this.loadTodayTasks()
  301. this.loadPendingTasks()
  302. }
  303. this.loadCreatedTasks()
  304. } catch (e) {
  305. console.error('加载数据失败', e)
  306. } finally {
  307. this.loading = false
  308. }
  309. },
  310. ensureSelectedMemberFromRaw(raw) {
  311. var members = raw || []
  312. if (members.length === 0) {
  313. this.currentMemberId = null
  314. return
  315. }
  316. if (this.currentMemberId != null) {
  317. var cur = String(this.currentMemberId)
  318. for (var i = 0; i < members.length; i++) {
  319. if (members[i] && String(members[i].id) === cur) {
  320. // 统一成数字,保证 FamilyMemberStrip 选中态 === 比较成立
  321. var n = parseInt(members[i].id, 10)
  322. this.currentMemberId = isNaN(n) ? members[i].id : n
  323. return
  324. }
  325. }
  326. }
  327. var first = members[0]
  328. var fid = first && first.id != null ? first.id : null
  329. if (fid != null) {
  330. var pn = parseInt(fid, 10)
  331. this.currentMemberId = isNaN(pn) ? fid : pn
  332. uni.setStorageSync('currentMemberId', this.currentMemberId)
  333. uni.setStorageSync('currentChildId', this.currentMemberId)
  334. } else {
  335. this.currentMemberId = null
  336. }
  337. },
  338. async loadPendingTasks() {
  339. if (!this.currentMemberId) {
  340. this.pendingTasks = []
  341. return
  342. }
  343. try {
  344. const res = await getMyPendingTasks(this.currentMemberId)
  345. this.pendingTasks = res.data || []
  346. } catch (e) {
  347. console.error('加载待接受任务失败', e)
  348. }
  349. },
  350. async loadTodayTasks() {
  351. if (!this.currentMemberId) {
  352. this.todayTasks = []
  353. return
  354. }
  355. try {
  356. const res = await getTodayTasks(this.currentMemberId)
  357. this.todayTasks = res.data || []
  358. } catch (e) {
  359. console.error('加载今日任务失败', e)
  360. }
  361. },
  362. async loadCreatedTasks() {
  363. try {
  364. const res = await getTodayTasks(this.currentUserId)
  365. this.createdTasks = res.data || []
  366. } catch (e) {
  367. console.error('加载创建的任务失败', e)
  368. }
  369. },
  370. async completeTask(task) {
  371. if (task.memberOnly === 1 && !this.isMember) {
  372. uni.showModal({
  373. title: '会员任务',
  374. content: '该任务是会员专属任务,开通会员后即可完成',
  375. confirmText: '去开通',
  376. success: (res) => {
  377. if (res.confirm) {
  378. uni.navigateTo({ url: '/pages/membership/index' })
  379. }
  380. }
  381. })
  382. return
  383. }
  384. try {
  385. var memberId = this.resolveTaskMemberId(task)
  386. const res = await completeTaskApi(task.id, memberId, '')
  387. if (res.data && res.data.needReview) {
  388. uni.showToast({ title: '已提交,待审核', icon: 'none' })
  389. } else {
  390. uni.showToast({ title: `获得 ${res.data.pointsEarned} 积分`, icon: 'success' })
  391. }
  392. this.loadTodayTasks()
  393. } catch (e) {
  394. console.error('完成任务失败', e)
  395. }
  396. },
  397. /** 按钮文案:按 action_type / start_required / require_input 分派 */
  398. getActionLabel(task) {
  399. if (task.startRequired === 1 && task.status !== 'in_progress') return '开始'
  400. if (task.requireInput === 1) return '填写完成'
  401. var at = task.actionType
  402. if (at === 'minigame') return '开始游戏'
  403. if (at === 'focus') return '开始专注'
  404. if (at === 'survey') return '去填写'
  405. if (at === 'article') return '去阅读'
  406. if (at === 'activity') return '去报名'
  407. if (at === 'cart' || at === 'order') return '去购物'
  408. return '完成'
  409. },
  410. /** 统一任务动作入口:开始/联动跳转/填写/直接完成 */
  411. handleTaskAction(task) {
  412. var self = this
  413. // 前置锁定任务不可操作
  414. if (task.status === 'locked') {
  415. uni.showToast({ title: '需先完成前置任务', icon: 'none' })
  416. return
  417. }
  418. // 两阶段:先开始,再按 action_type 跳转联动页面
  419. if (task.startRequired === 1 && task.status !== 'in_progress') {
  420. uni.showLoading({ title: '开始中...' })
  421. startTaskApi(task.id, this.resolveTaskMemberId(task)).then(function(res) {
  422. uni.hideLoading()
  423. if (res && res.code === 200 && res.data) {
  424. uni.showToast({ title: '已开始', icon: 'success' })
  425. self.navigateByAction(task)
  426. self.loadTodayTasks()
  427. } else {
  428. uni.showToast({ title: (res && res.message) || '开始失败', icon: 'none' })
  429. }
  430. }).catch(function() {
  431. uni.hideLoading()
  432. uni.showToast({ title: '开始失败', icon: 'none' })
  433. })
  434. return
  435. }
  436. // 填写式完成
  437. if (task.requireInput === 1) {
  438. this.activeInputTask = task
  439. this.inputContent = ''
  440. this.showInputModal = true
  441. return
  442. }
  443. // 已开始的两阶段任务或普通任务:直接完成
  444. this.completeTask(task)
  445. },
  446. /** 按 action_type 跳转联动页面 */
  447. navigateByAction(task) {
  448. var at = task.actionType
  449. var taskId = task.id
  450. if (at === 'minigame') {
  451. var cfg = {}
  452. try { cfg = typeof task.actionConfig === 'string' ? JSON.parse(task.actionConfig || '{}') : (task.actionConfig || {}) } catch (e) {}
  453. var gameId = cfg.gameId || ''
  454. uni.navigateTo({ url: '/pages/games/index?taskId=' + taskId + (gameId ? '&gameId=' + gameId : '') })
  455. } else if (at === 'focus') {
  456. uni.navigateTo({ url: '/pages/games/focus-timer?taskId=' + taskId })
  457. } else if (at === 'survey') {
  458. uni.navigateTo({ url: '/pages/survey/index?taskId=' + taskId })
  459. } else if (at === 'article') {
  460. uni.navigateTo({ url: '/pages/article-center/index?taskId=' + taskId })
  461. } else if (at === 'activity') {
  462. uni.navigateTo({ url: '/pages/activity/list?taskId=' + taskId })
  463. } else if (at === 'cart' || at === 'order') {
  464. uni.navigateTo({ url: '/pages/shop/cart?taskId=' + taskId })
  465. }
  466. },
  467. closeInputModal() {
  468. this.showInputModal = false
  469. this.activeInputTask = null
  470. this.inputContent = ''
  471. },
  472. async submitWithInput() {
  473. var task = this.activeInputTask
  474. if (!task || !this.inputContent) return
  475. try {
  476. const res = await completeTaskApi(task.id, this.resolveTaskMemberId(task), '', this.inputContent, 'text')
  477. if (res.data && res.data.needReview) {
  478. uni.showToast({ title: '已提交,待审核', icon: 'none' })
  479. } else {
  480. uni.showToast({ title: `获得 ${res.data.pointsEarned} 积分`, icon: 'success' })
  481. }
  482. this.closeInputModal()
  483. this.loadTodayTasks()
  484. } catch (e) {
  485. console.error('提交填写内容失败', e)
  486. uni.showToast({ title: '提交失败', icon: 'none' })
  487. }
  488. },
  489. acceptTask(task) {
  490. if (task.memberOnly === 1 && !this.isMember) {
  491. uni.showModal({
  492. title: '会员任务',
  493. content: '该任务是会员专属任务,开通会员后即可接受',
  494. confirmText: '去开通',
  495. success: (res) => {
  496. if (res.confirm) {
  497. uni.navigateTo({ url: '/pages/membership/index' })
  498. }
  499. }
  500. })
  501. return
  502. }
  503. uni.showLoading({ title: '接受中...' })
  504. acceptTaskApi(task.id, this.currentMemberId).then(res => {
  505. uni.hideLoading()
  506. uni.showToast({ title: '已接受', icon: 'success' })
  507. this.loadPendingTasks()
  508. this.loadTodayTasks()
  509. }).catch(e => {
  510. uni.hideLoading()
  511. console.error('接受失败', e)
  512. uni.showToast({ title: '接受失败', icon: 'none' })
  513. })
  514. },
  515. showRejectModal(task) {
  516. this.rejectTaskId = task.id
  517. this.showRejectModalFlag = true
  518. },
  519. confirmReject() {
  520. uni.showLoading({ title: '拒绝中...' })
  521. rejectTaskApi(this.rejectTaskId, this.currentMemberId).then(res => {
  522. uni.hideLoading()
  523. this.showRejectModalFlag = false
  524. uni.showToast({ title: '已拒绝', icon: 'success' })
  525. this.loadPendingTasks()
  526. }).catch(e => {
  527. uni.hideLoading()
  528. console.error('拒绝失败', e)
  529. uni.showToast({ title: '拒绝失败', icon: 'none' })
  530. })
  531. },
  532. statusLabel(status) {
  533. const map = {
  534. pending: '待完成',
  535. assigned: '待接受',
  536. in_progress: '进行中',
  537. completed: '已完成',
  538. pending_review: '待审核',
  539. rejected: '已拒绝',
  540. overdue: '已过期',
  541. cancelled: '已取消',
  542. locked: '未解锁'
  543. }
  544. return map[status] || status
  545. },
  546. formatTime(t) {
  547. if (!t) return ''
  548. var d = parseDate(t)
  549. if (!d) return String(t).substring(0, 16)
  550. var y = d.getFullYear()
  551. var m = String(d.getMonth() + 1).padStart(2, '0')
  552. var day = String(d.getDate()).padStart(2, '0')
  553. var h = String(d.getHours()).padStart(2, '0')
  554. var min = String(d.getMinutes()).padStart(2, '0')
  555. return y + '-' + m + '-' + day + ' ' + h + ':' + min
  556. },
  557. getProgressPercent(task) {
  558. var progress = task.progress || 0
  559. var target = task.targetValue || 1
  560. if (target <= 0) return 0
  561. var pct = Math.round((progress / target) * 100)
  562. return pct > 100 ? 100 : pct
  563. },
  564. async createTask() {
  565. if (!this.newTask.title) {
  566. uni.showToast({ title: '请输入标题', icon: 'none' })
  567. return
  568. }
  569. if (!this.newTask.category) {
  570. uni.showToast({ title: '请选择类型', icon: 'none' })
  571. return
  572. }
  573. if (this.newTask.memberIds.length === 0) {
  574. uni.showToast({ title: '请选择分配对象', icon: 'none' })
  575. return
  576. }
  577. try {
  578. await createTaskApi({
  579. title: this.newTask.title,
  580. description: this.newTask.description,
  581. category: this.newTask.category,
  582. points: parseInt(this.newTask.points) || 2,
  583. deadline: this.newTask.deadline ? new Date(this.newTask.deadline).getTime() : null,
  584. executorType: 'member',
  585. memberIds: this.newTask.memberIds.map(id => Number(id))
  586. })
  587. uni.showToast({ title: '创建成功', icon: 'success' })
  588. this.showAddModal = false
  589. this.resetNewTask()
  590. this.loadCreatedTasks()
  591. this.loadPendingTasks()
  592. } catch (e) {
  593. console.error('创建任务失败', e)
  594. uni.showToast({ title: '创建失败', icon: 'none' })
  595. }
  596. },
  597. resetNewTask() {
  598. this.newTask = {
  599. title: '',
  600. description: '',
  601. category: '',
  602. points: 2,
  603. deadline: '',
  604. memberIds: []
  605. }
  606. },
  607. onCategoryChange(e) {
  608. this.newTask.category = this.categories[e.detail.value]
  609. },
  610. onDeadlineChange(e) {
  611. this.newTask.deadline = e.detail.value
  612. },
  613. onFamilyMemberSelectChange(e) {
  614. this.newTask.memberIds = e.detail.value || []
  615. }
  616. }
  617. }
  618. </script>
  619. <style scoped>
  620. .container {
  621. padding: 20rpx 30rpx 120rpx;
  622. background: #f5f7fa;
  623. min-height: 100vh;
  624. }
  625. .tab-bar {
  626. margin-top: 16rpx;
  627. display: flex;
  628. background: #fff;
  629. border-radius: 16rpx;
  630. padding: 10rpx;
  631. margin-bottom: 30rpx;
  632. box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
  633. }
  634. .tab-item {
  635. flex: 1;
  636. display: flex;
  637. align-items: center;
  638. justify-content: center;
  639. padding: 20rpx 0;
  640. border-radius: 12rpx;
  641. position: relative;
  642. }
  643. .tab-item.active {
  644. background: linear-gradient(135deg, #F97316, #FB923C);
  645. }
  646. .tab-text {
  647. font-size: 28rpx;
  648. color: #666;
  649. }
  650. .tab-item.active .tab-text {
  651. color: #fff;
  652. font-weight: 600;
  653. }
  654. .tab-badge {
  655. position: absolute;
  656. top: 8rpx;
  657. right: 20rpx;
  658. background: #EF4444;
  659. color: #fff;
  660. font-size: 20rpx;
  661. min-width: 28rpx;
  662. height: 28rpx;
  663. line-height: 28rpx;
  664. text-align: center;
  665. border-radius: 14rpx;
  666. padding: 0 6rpx;
  667. }
  668. .task-list {
  669. display: flex;
  670. flex-direction: column;
  671. gap: 20rpx;
  672. }
  673. .task-item {
  674. display: flex;
  675. align-items: center;
  676. justify-content: space-between;
  677. background: #fff;
  678. border-radius: 20rpx;
  679. padding: 28rpx;
  680. box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04);
  681. }
  682. .task-item.completed {
  683. opacity: 0.7;
  684. }
  685. .task-item.locked {
  686. opacity: 0.55;
  687. }
  688. .task-item.assigned {
  689. border-left: 6rpx solid #F97316;
  690. }
  691. .task-left {
  692. flex: 1;
  693. min-width: 0;
  694. padding-right: 20rpx;
  695. }
  696. .task-title {
  697. font-size: 30rpx;
  698. color: #333;
  699. font-weight: 500;
  700. margin-bottom: 12rpx;
  701. }
  702. .member-badge {
  703. font-size: 20rpx;
  704. color: #F97316;
  705. background: #FFF7ED;
  706. padding: 2rpx 10rpx;
  707. border-radius: 8rpx;
  708. margin-left: 8rpx;
  709. }
  710. .minigame-badge {
  711. margin-left: 8rpx;
  712. font-size: 24rpx;
  713. }
  714. .task-meta {
  715. display: flex;
  716. flex-wrap: wrap;
  717. gap: 16rpx;
  718. font-size: 24rpx;
  719. color: #999;
  720. }
  721. .points {
  722. color: #F97316;
  723. font-weight: 500;
  724. }
  725. .deadline, .creator, .assignee {
  726. color: #999;
  727. }
  728. .lock-tip {
  729. display: block;
  730. margin-top: 8rpx;
  731. font-size: 22rpx;
  732. color: #9CA3AF;
  733. }
  734. .progress-wrap {
  735. display: flex;
  736. align-items: center;
  737. margin-top: 12rpx;
  738. }
  739. .progress-bar {
  740. flex: 1;
  741. height: 12rpx;
  742. background: #F3F4F6;
  743. border-radius: 6rpx;
  744. overflow: hidden;
  745. margin-right: 12rpx;
  746. }
  747. .progress-fill {
  748. height: 100%;
  749. background: linear-gradient(135deg, #F97316, #FB923C);
  750. border-radius: 6rpx;
  751. transition: width 0.3s ease;
  752. }
  753. .progress-text {
  754. font-size: 22rpx;
  755. color: #F97316;
  756. white-space: nowrap;
  757. }
  758. .task-right {
  759. flex-shrink: 0;
  760. }
  761. .btn-complete {
  762. background: linear-gradient(135deg, #F97316, #FB923C);
  763. color: #fff;
  764. font-size: 26rpx;
  765. padding: 0 28rpx;
  766. height: 64rpx;
  767. line-height: 64rpx;
  768. border-radius: 32rpx;
  769. border: none;
  770. }
  771. .task-actions {
  772. display: flex;
  773. gap: 12rpx;
  774. flex-shrink: 0;
  775. }
  776. .btn-accept {
  777. background: #10B981;
  778. color: #fff;
  779. font-size: 24rpx;
  780. padding: 0 24rpx;
  781. height: 56rpx;
  782. line-height: 56rpx;
  783. border-radius: 28rpx;
  784. border: none;
  785. }
  786. .btn-reject {
  787. background: #F3F4F6;
  788. color: #666;
  789. font-size: 24rpx;
  790. padding: 0 24rpx;
  791. height: 56rpx;
  792. line-height: 56rpx;
  793. border-radius: 28rpx;
  794. border: none;
  795. }
  796. .completed-badge, .review-badge, .lock-badge {
  797. font-size: 24rpx;
  798. padding: 8rpx 16rpx;
  799. border-radius: 12rpx;
  800. flex-shrink: 0;
  801. }
  802. .completed-badge {
  803. background: #ECFDF5;
  804. color: #10B981;
  805. }
  806. .review-badge {
  807. background: #FFF7ED;
  808. color: #F97316;
  809. }
  810. .lock-badge {
  811. background: #F3F4F6;
  812. color: #9CA3AF;
  813. }
  814. .status-tag {
  815. font-size: 22rpx;
  816. padding: 2rpx 10rpx;
  817. border-radius: 8rpx;
  818. background: #F3F4F6;
  819. color: #666;
  820. }
  821. .status-pending { background: #FFF7ED; color: #F97316; }
  822. .status-completed { background: #ECFDF5; color: #10B981; }
  823. .status-pending_review { background: #FEF3C7; color: #D97706; }
  824. .status-assigned { background: #EEF2FF; color: #6366F1; }
  825. .status-in_progress { background: #DBEAFE; color: #2563EB; }
  826. .status-rejected { background: #FEE2E2; color: #EF4444; }
  827. .status-locked { background: #F3F4F6; color: #9CA3AF; }
  828. .empty {
  829. display: flex;
  830. justify-content: center;
  831. align-items: center;
  832. padding: 80rpx 0;
  833. color: #9CA3AF;
  834. font-size: 28rpx;
  835. }
  836. .add-btn {
  837. position: fixed;
  838. left: 40rpx;
  839. right: 40rpx;
  840. bottom: 40rpx;
  841. height: 88rpx;
  842. line-height: 88rpx;
  843. background: linear-gradient(135deg, #F97316, #FB923C);
  844. color: #fff;
  845. font-size: 30rpx;
  846. border-radius: 44rpx;
  847. border: none;
  848. box-shadow: 0 8rpx 24rpx rgba(249, 115, 22, 0.35);
  849. }
  850. .orchestration-btn {
  851. position: fixed;
  852. left: 40rpx;
  853. right: 40rpx;
  854. bottom: 148rpx;
  855. height: 88rpx;
  856. line-height: 88rpx;
  857. background: linear-gradient(135deg, #6366F1, #8B5CF6);
  858. color: #fff;
  859. font-size: 30rpx;
  860. border-radius: 44rpx;
  861. border: none;
  862. box-shadow: 0 8rpx 24rpx rgba(99, 102, 241, 0.35);
  863. }
  864. .modal-mask {
  865. position: fixed;
  866. top: 0;
  867. left: 0;
  868. right: 0;
  869. bottom: 0;
  870. background: rgba(0, 0, 0, 0.45);
  871. display: flex;
  872. align-items: center;
  873. justify-content: center;
  874. z-index: 1000;
  875. }
  876. .modal {
  877. background: #fff;
  878. border-radius: 28rpx;
  879. padding: 40rpx;
  880. width: 85%;
  881. max-height: 80vh;
  882. overflow-y: auto;
  883. box-shadow: 0 8rpx 40rpx rgba(0, 0, 0, 0.12);
  884. }
  885. .modal-large {
  886. width: 90%;
  887. }
  888. .modal-title {
  889. font-size: 34rpx;
  890. font-weight: bold;
  891. margin-bottom: 32rpx;
  892. text-align: center;
  893. color: #333;
  894. }
  895. .modal-content {
  896. font-size: 28rpx;
  897. color: #666;
  898. line-height: 1.6;
  899. margin-bottom: 30rpx;
  900. text-align: center;
  901. }
  902. .input-tip {
  903. display: block;
  904. margin-bottom: 16rpx;
  905. color: #333;
  906. font-weight: 500;
  907. }
  908. .input-textarea {
  909. width: 100%;
  910. min-height: 180rpx;
  911. padding: 20rpx;
  912. background: #F9FAFB;
  913. border-radius: 12rpx;
  914. font-size: 28rpx;
  915. box-sizing: border-box;
  916. }
  917. .modal-btns {
  918. display: flex;
  919. gap: 20rpx;
  920. margin-top: 30rpx;
  921. }
  922. .modal-btns button {
  923. flex: 1;
  924. height: 88rpx;
  925. line-height: 88rpx;
  926. border-radius: 44rpx;
  927. font-size: 28rpx;
  928. border: none;
  929. box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.06);
  930. }
  931. .modal-btns button:first-child {
  932. background: #f5f5f5;
  933. color: #666;
  934. }
  935. .btn-primary {
  936. background: linear-gradient(135deg, #F97316, #FB923C) !important;
  937. color: #fff !important;
  938. }
  939. .form-item {
  940. margin-bottom: 28rpx;
  941. }
  942. .form-label {
  943. font-size: 28rpx;
  944. color: #333;
  945. margin-bottom: 14rpx;
  946. font-weight: 500;
  947. }
  948. .form-label .required {
  949. color: #F97316;
  950. }
  951. .form-item input,
  952. .form-item .picker,
  953. .form-item .textarea {
  954. width: 100%;
  955. padding: 20rpx;
  956. background: #F9FAFB;
  957. border-radius: 12rpx;
  958. font-size: 28rpx;
  959. box-sizing: border-box;
  960. }
  961. .form-item .textarea {
  962. min-height: 140rpx;
  963. }
  964. .form-item checkbox-group {
  965. display: flex;
  966. flex-wrap: wrap;
  967. gap: 16rpx;
  968. }
  969. .form-item label {
  970. display: flex;
  971. align-items: center;
  972. gap: 8rpx;
  973. padding: 12rpx 20rpx;
  974. background: #F9FAFB;
  975. border-radius: 12rpx;
  976. font-size: 26rpx;
  977. }
  978. .form-item label.label-checked {
  979. background: #FFF7ED;
  980. color: #F97316;
  981. }
  982. </style>