tasks.vue 32 KB

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