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