feat(checkin): 新增打卡草稿缓存功能
- 新增 useCheckinDraft composable,支持草稿的自动保存、恢复和清理 - 在打卡详情页集成草稿功能:自动保存表单内容,进入时提示恢复 - 优化文件上传逻辑,确保草稿恢复后能正确预览附件 - 修复 countValue 初始化顺序导致的 ReferenceError - 更新环境变量配置,添加 VITE_CHECKIN_DRAFT_CACHE 开关 - 完善相关文档和测试用例
Showing
10 changed files
with
831 additions
and
55 deletions
| ... | @@ -97,7 +97,7 @@ src/ | ... | @@ -97,7 +97,7 @@ src/ |
| 97 | - **统一打卡页** (`/checkin/detail`): | 97 | - **统一打卡页** (`/checkin/detail`): |
| 98 | - 支持类型:文本、图片、视频、音频、计数打卡(如感恩日记)。 | 98 | - 支持类型:文本、图片、视频、音频、计数打卡(如感恩日记)。 |
| 99 | - 核心逻辑:`useCheckin` 封装上传(七牛云+Hash秒传)、校验、提交逻辑。 | 99 | - 核心逻辑:`useCheckin` 封装上传(七牛云+Hash秒传)、校验、提交逻辑。 |
| 100 | - - 交互:支持补卡、编辑已提交内容、选择打卡对象(计数模式)。 | 100 | + - 交互:支持补卡、编辑已提交内容、选择打卡对象(计数模式);支持自动保存草稿与断点恢复。 |
| 101 | - **互动社区**:打卡动态流,支持点赞、评论、查看他人作品。 | 101 | - **互动社区**:打卡动态流,支持点赞、评论、查看他人作品。 |
| 102 | - **教师端** (`/teacher`): | 102 | - **教师端** (`/teacher`): |
| 103 | - **作业管理**:发布新作业,查看作业列表与详情(完成率/出勤率统计)。 | 103 | - **作业管理**:发布新作业,查看作业列表与详情(完成率/出勤率统计)。 | ... | ... |
| ... | @@ -2,6 +2,17 @@ | ... | @@ -2,6 +2,17 @@ |
| 2 | 2 | ||
| 3 | 说明:该章节从 README 迁移到本文件,避免 README 过长。后续新增变更建议追加在文件顶部。 | 3 | 说明:该章节从 README 迁移到本文件,避免 README 过长。后续新增变更建议追加在文件顶部。 |
| 4 | 4 | ||
| 5 | +## 2026-01-25 | ||
| 6 | + | ||
| 7 | +- 新增「暂存用户打卡信息」开发规划:[/docs/plan/暂存用户打卡信息.md](file:///Users/huyirui/program/itomix/git/mlaj/docs/plan/%E6%9A%82%E5%AD%98%E7%94%A8%E6%88%B7%E6%89%93%E5%8D%A1%E4%BF%A1%E6%81%AF.md) | ||
| 8 | +- 完成「暂存用户打卡信息」功能开发 | ||
| 9 | + - 核心逻辑:[/src/composables/useCheckinDraft.js](file:///Users/huyirui/program/itomix/git/mlaj/src/composables/useCheckinDraft.js) | ||
| 10 | + - 页面集成:[/src/views/checkin/CheckinDetailPage.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/views/checkin/CheckinDetailPage.vue) | ||
| 11 | + - 支持自动保存、过期清理、恢复提示 | ||
| 12 | +- 修复打卡详情页 `countValue` 初始化顺序导致的 ReferenceError 报错 | ||
| 13 | +- 修复附件上传成功后未保存 URL 导致草稿恢复后无法预览的问题 | ||
| 14 | +- 优化文件 URL 获取逻辑:移除硬编码默认域名,优先使用接口返回的 URL 或 src,仅在有域名信息时拼接 URL | ||
| 15 | + | ||
| 5 | ## 打卡详情页重构(/checkin/detail) | 16 | ## 打卡详情页重构(/checkin/detail) |
| 6 | 17 | ||
| 7 | - 统一了文本、媒体上传和计数打卡的入口 | 18 | - 统一了文本、媒体上传和计数打卡的入口 | ... | ... |
docs/TODO.md
0 → 100644
| 1 | +用户反馈: |
docs/plan/暂存用户打卡信息.md
0 → 100644
| 1 | +# 暂存用户打卡信息 | ||
| 2 | + | ||
| 3 | +## 背景 | ||
| 4 | + | ||
| 5 | +用户在“提交作业/打卡”页面输入了较长文字并上传了媒体,但在未点击提交时被中断(误触返回、微信进程被系统回收、来电/切后台等),再次进入页面内容丢失,导致体验断裂。 | ||
| 6 | + | ||
| 7 | +本规划目标是在不改动后端接口的前提下,在前端提供“草稿暂存(文本 + 已上传媒体信息)”能力,支持一周内自动过期清理,并在用户再次进入时提示恢复或删除。 | ||
| 8 | + | ||
| 9 | +涉及页面与核心逻辑参考: | ||
| 10 | + | ||
| 11 | +- 打卡提交页:[CheckinDetailPage.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/views/checkin/CheckinDetailPage.vue) | ||
| 12 | +- 打卡核心逻辑:[useCheckin.js](file:///Users/huyirui/program/itomix/git/mlaj/src/composables/useCheckin.js) | ||
| 13 | +- 环境变量约定:[.env](file:///Users/huyirui/program/itomix/git/mlaj/.env) | ||
| 14 | + | ||
| 15 | +## 需求拆解(逐条对齐) | ||
| 16 | + | ||
| 17 | +1. 缓存最多保存一周:写入时记录 saved_at,读写时执行过期清理(>7天删除)。 | ||
| 18 | +2. 用户提交后清空缓存:提交成功(code===1)后删除对应草稿。 | ||
| 19 | +3. 进入页面若存在未完成信息:弹框提示“继续/删除”,继续则回填,删除则清空。 | ||
| 20 | +4. 功能开关放到 .env:新增 VITE_CHECKIN_DRAFT_CACHE(0/1),默认建议 1(也可先默认 0,灰度开启)。 | ||
| 21 | + | ||
| 22 | +## 范围与不做项(第一版) | ||
| 23 | + | ||
| 24 | +- 覆盖:文字内容、已上传成功的媒体(含 url/meta_id/file_type/name)。 | ||
| 25 | +- 不覆盖:未上传完成的 File/Blob(localStorage 无法可靠持久化;要支持需 IndexedDB 存 Blob,复杂度与风险较高)。 | ||
| 26 | +- 编辑模式(route.query.status===edit):第一版建议默认不启用草稿恢复,避免与“编辑回显(来自后端)”冲突;若要覆盖编辑场景,采用独立 key(见“扩展”)。 | ||
| 27 | + | ||
| 28 | +## 关键设计 | ||
| 29 | + | ||
| 30 | +### 1) 存储介质 | ||
| 31 | + | ||
| 32 | +- 使用 localStorage:实现成本低,满足“一周”与“断网/切后台后仍可恢复”。 | ||
| 33 | +- 数据量控制:只存“已上传成功”的附件元数据;不存 File 本体。 | ||
| 34 | + | ||
| 35 | +### 2) 草稿 Key 设计(避免串号) | ||
| 36 | + | ||
| 37 | +建议 key 包含用户与作业上下文,确保不同用户/不同作业互不影响: | ||
| 38 | + | ||
| 39 | +- 前缀:CHECKIN_DRAFT_V1 | ||
| 40 | +- 维度:user_id、task_id、date、task_type、status | ||
| 41 | + | ||
| 42 | +示例: | ||
| 43 | + | ||
| 44 | +- CHECKIN_DRAFT_V1:{user_id}:{task_id}:{date}:{task_type}:{status} | ||
| 45 | + | ||
| 46 | +其中: | ||
| 47 | + | ||
| 48 | +- user_id:来自 currentUser(contexts/auth.js 本地持久化) | ||
| 49 | +- task_id/date/task_type/status:来自路由 query(CheckinDetailPage 已使用 route.query.task_id/date/task_type/status) | ||
| 50 | + | ||
| 51 | +### 3) 数据结构(建议) | ||
| 52 | + | ||
| 53 | +```json | ||
| 54 | +{ | ||
| 55 | + "version": 1, | ||
| 56 | + "saved_at": 1730000000000, | ||
| 57 | + "expires_at": 1730000000000, | ||
| 58 | + "context": { | ||
| 59 | + "user_id": "123", | ||
| 60 | + "task_id": "456", | ||
| 61 | + "date": "2026-01-25", | ||
| 62 | + "task_type": "upload", | ||
| 63 | + "status": "create" | ||
| 64 | + }, | ||
| 65 | + "payload": { | ||
| 66 | + "message": "...", | ||
| 67 | + "active_type": "image", | ||
| 68 | + "subtask_id": "789", | ||
| 69 | + "file_list": [ | ||
| 70 | + { | ||
| 71 | + "meta_id": "xxx", | ||
| 72 | + "url": "https://...", | ||
| 73 | + "name": "a.jpg", | ||
| 74 | + "file_type": "image" | ||
| 75 | + } | ||
| 76 | + ], | ||
| 77 | + "count": { | ||
| 78 | + "gratitude_count": 1, | ||
| 79 | + "gratitude_form_list": [] | ||
| 80 | + } | ||
| 81 | + } | ||
| 82 | +} | ||
| 83 | +``` | ||
| 84 | + | ||
| 85 | +说明: | ||
| 86 | + | ||
| 87 | +- file_list:仅保存 useCheckin.afterRead 上传成功后写入的字段(item.status===done 且 meta_id 存在)。 | ||
| 88 | +- count:来源于 CheckinDetailPage 的 selectedTargets/countValue(第一版可以先不存,或存但不影响非 count 类型)。 | ||
| 89 | + | ||
| 90 | +### 4) 触发保存的时机(自动暂存) | ||
| 91 | + | ||
| 92 | +- 文本变化:watch(message) debounce 500ms 保存。 | ||
| 93 | +- 附件变化:watch(fileList) 深度监听 debounce 500ms 保存(仅保存 done 项)。 | ||
| 94 | +- 作业选择变化:watch(selectedTaskValue) debounce 200ms 保存。 | ||
| 95 | +- 页面离开兜底:beforeRouteLeave 或 window.pagehide/visibilitychange 时强制保存一次(避免最后一次变更没落盘)。 | ||
| 96 | + | ||
| 97 | +落盘时机要遵循开关:VITE_CHECKIN_DRAFT_CACHE === '1' 才启用。 | ||
| 98 | + | ||
| 99 | +### 5) 弹框提示与回填流程 | ||
| 100 | + | ||
| 101 | +进入 [CheckinDetailPage.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/views/checkin/CheckinDetailPage.vue)(且非 edit 模式)时: | ||
| 102 | + | ||
| 103 | +1. 读取 key 对应草稿;若不存在或已过期,直接进入正常流程。 | ||
| 104 | +2. 若存在草稿且 payload 有实际内容(message 有非空或 file_list 非空):弹框提示。 | ||
| 105 | +3. 用户选择: | ||
| 106 | + - 继续:将草稿回填到 message / activeType / fileList / selectedTaskValue(以及 count 数据如启用),并立刻再保存一次(避免回填后又丢)。 | ||
| 107 | + - 删除:删除草稿并保持空表单。 | ||
| 108 | + | ||
| 109 | +弹框建议用 showConfirmDialog(Vant 4),取消分支需要 catch,避免控制台出现 Uncaught (in promise) cancel(Vant 文档:showConfirmDialog.then/catch)[3](https://develop365.gitlab.io/vant/zh-CN/dialog/)。 | ||
| 110 | + | ||
| 111 | +### 6) 清理策略(“定期清除一周前”) | ||
| 112 | + | ||
| 113 | +采用“惰性清理 + 低频全量清理”组合: | ||
| 114 | + | ||
| 115 | +- 惰性清理:每次读/写草稿时,如果 expires_at < now 则删除。 | ||
| 116 | +- 低频全量:进入打卡页时,扫描 localStorage 中以 CHECKIN_DRAFT_V1: 开头的 key,删除所有过期项。 | ||
| 117 | + | ||
| 118 | +说明:localStorage 没有内建 TTL,必须业务侧维护 expires_at。 | ||
| 119 | + | ||
| 120 | +### 7) 提交成功后清空 | ||
| 121 | + | ||
| 122 | +清空动作必须绑定到“真正提交成功”之后: | ||
| 123 | + | ||
| 124 | +- 建议在 useCheckin.onSubmit 中,当 add/edit API 返回 code===1 且后续逻辑准备 router.back 前,删除对应 key。 | ||
| 125 | + | ||
| 126 | +这样可覆盖“不同入口页复用 onSubmit”以及“提交后立即返回上一页”的场景。 | ||
| 127 | + | ||
| 128 | +## 开发步骤(可落地的实现顺序) | ||
| 129 | + | ||
| 130 | +### 第 0 步:验证手段先行(TDD) | ||
| 131 | + | ||
| 132 | +新增 Vitest 用例,先定义以下可验证点: | ||
| 133 | + | ||
| 134 | +- 写入后能读取同一 key 的草稿;过期后读取返回空且自动删除。 | ||
| 135 | +- 仅保存 status===done 且含 meta_id 的附件。 | ||
| 136 | +- 清理函数能删除所有过期 key,不误删其他业务 localStorage。 | ||
| 137 | +- 提交成功时会调用清理(可通过 mock API 返回 code===1 验证)。 | ||
| 138 | + | ||
| 139 | +### 第 1 步:抽离草稿存储模块 | ||
| 140 | + | ||
| 141 | +位置建议:src/utils/checkinDraftCache.js(纯函数、无 UI 依赖)。 | ||
| 142 | + | ||
| 143 | +对外 API(示例): | ||
| 144 | + | ||
| 145 | +- is_enabled(): boolean(读取 env + 可选 query override) | ||
| 146 | +- build_key(context): string | ||
| 147 | +- save_draft(key, draft) | ||
| 148 | +- read_draft(key): draft|null(含 TTL 处理) | ||
| 149 | +- clear_draft(key) | ||
| 150 | +- cleanup_expired(prefix) | ||
| 151 | + | ||
| 152 | +### 第 2 步:在 CheckinDetailPage 接入“检测 + 弹框 + 回填” | ||
| 153 | + | ||
| 154 | +- onMounted:初始化后读取草稿并弹框。 | ||
| 155 | +- 回填时机:建议在任务详情/子任务列表加载完成后再回填 selectedTaskValue,避免 option 未加载导致显示异常。 | ||
| 156 | + | ||
| 157 | +### 第 3 步:在 CheckinDetailPage 接入“自动保存” | ||
| 158 | + | ||
| 159 | +- 对 message/fileList/selectedTaskValue/countValue/selectedTargets 建立 watch + debounce。 | ||
| 160 | +- 页面离开事件兜底(pagehide/visibilitychange)。 | ||
| 161 | + | ||
| 162 | +### 第 4 步:在 useCheckin.onSubmit 接入“成功清理” | ||
| 163 | + | ||
| 164 | +- onSubmit 成功分支清除草稿。 | ||
| 165 | +- 失败分支不清除,保留草稿以便重试。 | ||
| 166 | + | ||
| 167 | +## 边界条件与遗漏点梳理(建议补齐) | ||
| 168 | + | ||
| 169 | +1. 多用户切换:key 必须含 user_id,否则会串草稿。 | ||
| 170 | +2. 多任务并存:key 必须含 task_id/date/task_type,否则会在不同作业之间误恢复。 | ||
| 171 | +3. 附件未上传完成: | ||
| 172 | + - 仅保存已上传成功的项;如果用户退出时仍有 uploading 项,恢复后无法找回该 File。 | ||
| 173 | + - 可在保存时统计未保存数量,并在恢复弹框里追加提示“有 X 个附件上传未完成未被暂存”。 | ||
| 174 | +4. 关闭开关后的行为: | ||
| 175 | + - 关闭后不再读/写;建议仍执行一次 cleanup_expired,避免历史堆积。 | ||
| 176 | +5. 版本升级/数据结构变更:draft.version 不匹配时丢弃并清除,避免解析异常。 | ||
| 177 | +6. localStorage 配额:图片多但只存 url/meta_id 一般不会超;仍需 try/catch JSON 与 setItem 异常。 | ||
| 178 | +7. 编辑模式: | ||
| 179 | + - 要支持“编辑中断恢复”,建议 key 加 post_id 维度,并在 initEditData 回显后再弹框询问是否覆盖当前表单。 | ||
| 180 | + | ||
| 181 | +## 环境变量(规划) | ||
| 182 | + | ||
| 183 | +在 [.env](file:///Users/huyirui/program/itomix/git/mlaj/.env) 增加: | ||
| 184 | + | ||
| 185 | +- VITE_CHECKIN_DRAFT_CACHE = 1 | ||
| 186 | + | ||
| 187 | +约定: | ||
| 188 | + | ||
| 189 | +- '1' 开启,'0' 关闭 | ||
| 190 | +- 可选增加 URL 覆盖用于灰度测试:?enable_draft=1 / ?enable_draft=0(模式同 VITE_CHECKIN_MULTI_ATTACHMENT) |
| ... | @@ -179,7 +179,7 @@ describe('useCheckin 提交兼容', () => { | ... | @@ -179,7 +179,7 @@ describe('useCheckin 提交兼容', () => { |
| 179 | await onSubmit({ subtask_id: 's1' }) | 179 | await onSubmit({ subtask_id: 's1' }) |
| 180 | 180 | ||
| 181 | expect(addUploadTaskAPI).toHaveBeenCalledTimes(0) | 181 | expect(addUploadTaskAPI).toHaveBeenCalledTimes(0) |
| 182 | - expect(showToast).toHaveBeenCalledWith('当前接口暂不支持多类型附件,请分别提交') | 182 | + expect(showToast).toHaveBeenCalledWith('当前接口暂不支持多类型附件,请删除其他类型附件之后再提交') |
| 183 | }) | 183 | }) |
| 184 | 184 | ||
| 185 | it('开启多附件开关时允许 mixed files 提交', async () => { | 185 | it('开启多附件开关时允许 mixed files 提交', async () => { | ... | ... |
| 1 | +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' | ||
| 2 | +import { useCheckinDraft } from '../useCheckinDraft' | ||
| 3 | + | ||
| 4 | +// Mock localStorage | ||
| 5 | +const localStorageMock = (() => { | ||
| 6 | + let store = {} | ||
| 7 | + return { | ||
| 8 | + getItem: vi.fn(key => store[key] || null), | ||
| 9 | + setItem: vi.fn((key, value) => { store[key] = value.toString() }), | ||
| 10 | + removeItem: vi.fn(key => { delete store[key] }), | ||
| 11 | + clear: vi.fn(() => { store = {} }), | ||
| 12 | + key: vi.fn(i => Object.keys(store)[i] || null), | ||
| 13 | + get length() { return Object.keys(store).length } | ||
| 14 | + } | ||
| 15 | +})() | ||
| 16 | + | ||
| 17 | +global.localStorage = localStorageMock | ||
| 18 | + | ||
| 19 | +describe('useCheckinDraft', () => { | ||
| 20 | + beforeEach(() => { | ||
| 21 | + localStorageMock.clear() | ||
| 22 | + vi.clearAllMocks() | ||
| 23 | + // Mock Date.now | ||
| 24 | + vi.useFakeTimers() | ||
| 25 | + }) | ||
| 26 | + | ||
| 27 | + afterEach(() => { | ||
| 28 | + vi.useRealTimers() | ||
| 29 | + }) | ||
| 30 | + | ||
| 31 | + const mockContext = { | ||
| 32 | + user_id: 'u123', | ||
| 33 | + task_id: 't456', | ||
| 34 | + date: '2026-01-25', | ||
| 35 | + task_type: 'upload', | ||
| 36 | + status: 'create' | ||
| 37 | + } | ||
| 38 | + | ||
| 39 | + const { | ||
| 40 | + build_key, | ||
| 41 | + save_draft, | ||
| 42 | + read_draft, | ||
| 43 | + clear_draft, | ||
| 44 | + cleanup_expired | ||
| 45 | + } = useCheckinDraft() | ||
| 46 | + | ||
| 47 | + it('should generate correct key', () => { | ||
| 48 | + const key = build_key(mockContext) | ||
| 49 | + expect(key).toBe('CHECKIN_DRAFT_V1:u123:t456:2026-01-25:upload:create') | ||
| 50 | + }) | ||
| 51 | + | ||
| 52 | + it('should save and read draft', () => { | ||
| 53 | + const key = build_key(mockContext) | ||
| 54 | + const payload = { message: 'hello', file_list: [] } | ||
| 55 | + | ||
| 56 | + save_draft(key, payload) | ||
| 57 | + | ||
| 58 | + const saved = read_draft(key) | ||
| 59 | + expect(saved).not.toBeNull() | ||
| 60 | + expect(saved.payload).toEqual(payload) | ||
| 61 | + expect(saved.version).toBe(1) | ||
| 62 | + }) | ||
| 63 | + | ||
| 64 | + it('should return null for expired draft', () => { | ||
| 65 | + const key = build_key(mockContext) | ||
| 66 | + const payload = { message: 'expired' } | ||
| 67 | + | ||
| 68 | + save_draft(key, payload) | ||
| 69 | + | ||
| 70 | + // Advance time by 8 days (TTL is 7 days) | ||
| 71 | + vi.setSystemTime(Date.now() + 8 * 24 * 60 * 60 * 1000) | ||
| 72 | + | ||
| 73 | + const saved = read_draft(key) | ||
| 74 | + expect(saved).toBeNull() | ||
| 75 | + // Should also remove from storage | ||
| 76 | + expect(localStorage.getItem(key)).toBeNull() | ||
| 77 | + }) | ||
| 78 | + | ||
| 79 | + it('should cleanup only expired drafts', () => { | ||
| 80 | + const ctx1 = { ...mockContext, task_id: 't1' } | ||
| 81 | + const ctx2 = { ...mockContext, task_id: 't2' } | ||
| 82 | + const key1 = build_key(ctx1) | ||
| 83 | + const key2 = build_key(ctx2) | ||
| 84 | + const otherKey = 'OTHER_KEY' | ||
| 85 | + | ||
| 86 | + save_draft(key1, { msg: 'expire soon' }) | ||
| 87 | + save_draft(key2, { msg: 'keep' }) | ||
| 88 | + localStorage.setItem(otherKey, 'some value') | ||
| 89 | + | ||
| 90 | + // Set key1 to expire | ||
| 91 | + // We need to manually manipulate storage to simulate different saved_at | ||
| 92 | + // Or just save key1, advance time, save key2 | ||
| 93 | + | ||
| 94 | + // Reset storage and start over | ||
| 95 | + localStorageMock.clear() | ||
| 96 | + | ||
| 97 | + // Save key1 | ||
| 98 | + save_draft(key1, { msg: 'old' }) | ||
| 99 | + | ||
| 100 | + // Advance 8 days | ||
| 101 | + const eightDays = 8 * 24 * 60 * 60 * 1000 | ||
| 102 | + vi.setSystemTime(Date.now() + eightDays) | ||
| 103 | + | ||
| 104 | + // Save key2 (fresh) | ||
| 105 | + save_draft(key2, { msg: 'new' }) | ||
| 106 | + | ||
| 107 | + // Other key | ||
| 108 | + localStorage.setItem(otherKey, 'val') | ||
| 109 | + | ||
| 110 | + // Run cleanup | ||
| 111 | + cleanup_expired() | ||
| 112 | + | ||
| 113 | + expect(localStorage.getItem(key1)).toBeNull() | ||
| 114 | + expect(localStorage.getItem(key2)).not.toBeNull() | ||
| 115 | + expect(localStorage.getItem(otherKey)).toBe('val') | ||
| 116 | + }) | ||
| 117 | + | ||
| 118 | + it('should only save "done" files with meta_id', () => { | ||
| 119 | + const key = build_key(mockContext) | ||
| 120 | + const files = [ | ||
| 121 | + { status: 'done', meta_id: 'm1', name: 'f1' }, | ||
| 122 | + { status: 'uploading', name: 'f2' }, | ||
| 123 | + { status: 'failed', name: 'f3' }, | ||
| 124 | + { status: 'done', name: 'f4' } // no meta_id | ||
| 125 | + ] | ||
| 126 | + | ||
| 127 | + save_draft(key, { file_list: files }) | ||
| 128 | + | ||
| 129 | + const saved = read_draft(key) | ||
| 130 | + expect(saved.payload.file_list).toHaveLength(1) | ||
| 131 | + expect(saved.payload.file_list[0].meta_id).toBe('m1') | ||
| 132 | + }) | ||
| 133 | +}) |
| ... | @@ -205,9 +205,29 @@ export function useCheckin() { | ... | @@ -205,9 +205,29 @@ export function useCheckin() { |
| 205 | hash: md5 | 205 | hash: md5 |
| 206 | }) | 206 | }) |
| 207 | 207 | ||
| 208 | + // 根据当前 activeType 或文件类型判断 | ||
| 209 | + let currentFileType = activeType.value | ||
| 210 | + if (!['image', 'video', 'audio'].includes(currentFileType)) { | ||
| 211 | + // 如果 activeType 不是这三种(比如 text),尝试从文件类型推断 | ||
| 212 | + if (file.file.type.startsWith('image/')) currentFileType = 'image' | ||
| 213 | + else if (file.file.type.startsWith('video/')) currentFileType = 'video' | ||
| 214 | + else if (file.file.type.startsWith('audio/')) currentFileType = 'audio' | ||
| 215 | + else currentFileType = 'file' // 默认 | ||
| 216 | + } | ||
| 217 | + | ||
| 208 | // 文件已存在,直接返回 | 218 | // 文件已存在,直接返回 |
| 209 | if (tokenResult.data) { | 219 | if (tokenResult.data) { |
| 210 | - return tokenResult.data | 220 | + const data = tokenResult.data |
| 221 | + let finalUrl = data.url || data.src | ||
| 222 | + // 如果没有URL但有filekey,尝试手动拼接 | ||
| 223 | + if (!finalUrl && (data.filekey || data.key || data.filepath) && tokenResult.domain) { | ||
| 224 | + const key = data.filekey || data.key || data.filepath | ||
| 225 | + const domain = tokenResult.domain | ||
| 226 | + const cleanDomain = domain.endsWith('/') ? domain : domain + '/' | ||
| 227 | + const cleanKey = key.startsWith('/') ? key.slice(1) : key | ||
| 228 | + finalUrl = cleanDomain + cleanKey | ||
| 229 | + } | ||
| 230 | + return { ...data, url: finalUrl, file_type: currentFileType } // 确保返回 file_type | ||
| 211 | } | 231 | } |
| 212 | 232 | ||
| 213 | // 新文件上传 | 233 | // 新文件上传 |
| ... | @@ -215,16 +235,6 @@ export function useCheckin() { | ... | @@ -215,16 +235,6 @@ export function useCheckin() { |
| 215 | const suffix = /.[^.]+$/.exec(file.file.name) || '' | 235 | const suffix = /.[^.]+$/.exec(file.file.name) || '' |
| 216 | let fileName = '' | 236 | let fileName = '' |
| 217 | 237 | ||
| 218 | - // 根据当前 activeType 或文件类型判断 | ||
| 219 | - let currentFileType = activeType.value | ||
| 220 | - if (!['image', 'video', 'audio'].includes(currentFileType)) { | ||
| 221 | - // 如果 activeType 不是这三种(比如 text),尝试从文件类型推断 | ||
| 222 | - if (file.file.type.startsWith('image/')) currentFileType = 'image' | ||
| 223 | - else if (file.file.type.startsWith('video/')) currentFileType = 'video' | ||
| 224 | - else if (file.file.type.startsWith('audio/')) currentFileType = 'audio' | ||
| 225 | - else currentFileType = 'file' // 默认 | ||
| 226 | - } | ||
| 227 | - | ||
| 228 | if (currentFileType === 'image') { | 238 | if (currentFileType === 'image') { |
| 229 | fileName = `mlaj/upload/checkin/${currentUser.value.mobile}/img/${md5}${suffix}` | 239 | fileName = `mlaj/upload/checkin/${currentUser.value.mobile}/img/${md5}${suffix}` |
| 230 | } else { | 240 | } else { |
| ... | @@ -252,7 +262,18 @@ export function useCheckin() { | ... | @@ -252,7 +262,18 @@ export function useCheckin() { |
| 252 | } | 262 | } |
| 253 | 263 | ||
| 254 | const { data } = await saveFileAPI(saveData) | 264 | const { data } = await saveFileAPI(saveData) |
| 255 | - return { ...data, file_type: currentFileType } | 265 | + |
| 266 | + // 确保返回URL,如果后端未返回则手动拼接 | ||
| 267 | + let finalUrl = data?.url || data?.src | ||
| 268 | + if (!finalUrl && tokenResult.domain) { | ||
| 269 | + // 优先使用接口返回的域名 | ||
| 270 | + const domain = tokenResult.domain | ||
| 271 | + const cleanDomain = domain.endsWith('/') ? domain : domain + '/' | ||
| 272 | + const cleanKey = uploadResult.filekey.startsWith('/') ? uploadResult.filekey.slice(1) : uploadResult.filekey | ||
| 273 | + finalUrl = cleanDomain + cleanKey | ||
| 274 | + } | ||
| 275 | + | ||
| 276 | + return { ...data, url: finalUrl, file_type: currentFileType } | ||
| 256 | } | 277 | } |
| 257 | } | 278 | } |
| 258 | return null | 279 | return null |
| ... | @@ -425,6 +446,7 @@ export function useCheckin() { | ... | @@ -425,6 +446,7 @@ export function useCheckin() { |
| 425 | /** | 446 | /** |
| 426 | * 提交打卡 | 447 | * 提交打卡 |
| 427 | * @param {Object} extraData - 额外提交数据 | 448 | * @param {Object} extraData - 额外提交数据 |
| 449 | + * @param {Function} onSuccess - 提交成功回调 (新增) | ||
| 428 | * @description | 450 | * @description |
| 429 | * 提交流程: | 451 | * 提交流程: |
| 430 | * 1. 表单校验(内容长度、是否上传文件) | 452 | * 1. 表单校验(内容长度、是否上传文件) |
| ... | @@ -433,7 +455,7 @@ export function useCheckin() { | ... | @@ -433,7 +455,7 @@ export function useCheckin() { |
| 433 | * 4. 调用对应 API (addUploadTaskAPI / editUploadTaskInfoAPI) | 455 | * 4. 调用对应 API (addUploadTaskAPI / editUploadTaskInfoAPI) |
| 434 | * 5. 成功后设置 sessionStorage 刷新标记并返回上一页 | 456 | * 5. 成功后设置 sessionStorage 刷新标记并返回上一页 |
| 435 | */ | 457 | */ |
| 436 | - const onSubmit = async (extraData = {}) => { | 458 | + const onSubmit = async (extraData = {}, onSuccess = null) => { |
| 437 | if (uploading.value) return | 459 | if (uploading.value) return |
| 438 | 460 | ||
| 439 | // 表单验证 | 461 | // 表单验证 |
| ... | @@ -568,6 +590,11 @@ export function useCheckin() { | ... | @@ -568,6 +590,11 @@ export function useCheckin() { |
| 568 | if (result?.code === 1) { | 590 | if (result?.code === 1) { |
| 569 | showToast('提交成功') | 591 | showToast('提交成功') |
| 570 | 592 | ||
| 593 | + // 执行成功回调(如清理草稿) | ||
| 594 | + if (typeof onSuccess === 'function') { | ||
| 595 | + onSuccess() | ||
| 596 | + } | ||
| 597 | + | ||
| 571 | // 设置刷新标记,用于列表页更新数据 | 598 | // 设置刷新标记,用于列表页更新数据 |
| 572 | const refreshType = route.query.status === 'edit' ? 'edit' : 'add'; | 599 | const refreshType = route.query.status === 'edit' ? 'edit' : 'add'; |
| 573 | sessionStorage.setItem('checkin_refresh_flag', refreshType); | 600 | sessionStorage.setItem('checkin_refresh_flag', refreshType); | ... | ... |
src/composables/useCheckinDraft.js
0 → 100644
| 1 | +/** | ||
| 2 | + * 打卡草稿缓存逻辑封装 | ||
| 3 | + * @module useCheckinDraft | ||
| 4 | + * @description | ||
| 5 | + * 提供打卡草稿的增删改查能力,基于 localStorage。 | ||
| 6 | + * | ||
| 7 | + * 核心功能: | ||
| 8 | + * 1. 构建唯一的缓存 Key(用户+任务+日期+状态维度)。 | ||
| 9 | + * 2. 保存草稿:自动过滤未完成的附件,只存 meta_id/url 等元数据。 | ||
| 10 | + * 3. 读取草稿:支持过期检查(TTL 7天)。 | ||
| 11 | + * 4. 自动清理:支持惰性清理和全量扫描清理过期项。 | ||
| 12 | + * | ||
| 13 | + * 使用场景: | ||
| 14 | + * - CheckinDetailPage 自动保存与恢复。 | ||
| 15 | + */ | ||
| 16 | +import { useRoute } from 'vue-router' | ||
| 17 | + | ||
| 18 | +export function useCheckinDraft() { | ||
| 19 | + const DRAFT_PREFIX = 'CHECKIN_DRAFT_V1' | ||
| 20 | + const TTL_MS = 7 * 24 * 60 * 60 * 1000 // 7天过期 | ||
| 21 | + | ||
| 22 | + /** | ||
| 23 | + * 判断功能是否开启 | ||
| 24 | + * @returns {boolean} | ||
| 25 | + */ | ||
| 26 | + const is_enabled = () => { | ||
| 27 | + // 支持 URL 参数覆盖 (CheckinDetailPage 会用到 route,但此处为纯函数逻辑,依赖调用方传入或在组件内判断) | ||
| 28 | + // 这里仅读取环境变量 | ||
| 29 | + // 实际使用时建议结合 route.query.enable_draft 判断 | ||
| 30 | + return import.meta.env.VITE_CHECKIN_DRAFT_CACHE === '1' | ||
| 31 | + } | ||
| 32 | + | ||
| 33 | + /** | ||
| 34 | + * 构建缓存 Key | ||
| 35 | + * @param {Object} context | ||
| 36 | + * @param {string} context.user_id 用户ID | ||
| 37 | + * @param {string} context.task_id 任务ID | ||
| 38 | + * @param {string} context.date 日期 | ||
| 39 | + * @param {string} context.task_type 任务类型 | ||
| 40 | + * @param {string} context.status 状态 (create/edit) | ||
| 41 | + * @returns {string} Key | ||
| 42 | + */ | ||
| 43 | + const build_key = ({ user_id, task_id, date, task_type, status }) => { | ||
| 44 | + // 确保所有字段都存在,避免生成 undefined | ||
| 45 | + const parts = [ | ||
| 46 | + user_id || 'guest', | ||
| 47 | + task_id || 'default', | ||
| 48 | + date || 'nodate', | ||
| 49 | + task_type || 'default', | ||
| 50 | + status || 'create' | ||
| 51 | + ] | ||
| 52 | + return `${DRAFT_PREFIX}:${parts.join(':')}` | ||
| 53 | + } | ||
| 54 | + | ||
| 55 | + /** | ||
| 56 | + * 保存草稿 | ||
| 57 | + * @param {string} key 缓存Key | ||
| 58 | + * @param {Object} payload 业务数据 | ||
| 59 | + * @param {string} payload.message 文本 | ||
| 60 | + * @param {Array} payload.file_list 文件列表 | ||
| 61 | + * @param {string} payload.active_type 当前类型 | ||
| 62 | + * @param {string|number} payload.subtask_id 子任务ID | ||
| 63 | + * @param {Array} payload.selected_task_value 选中的任务值 | ||
| 64 | + * @param {Object} payload.count 计数打卡数据 | ||
| 65 | + */ | ||
| 66 | + const save_draft = (key, payload) => { | ||
| 67 | + if (!key) return | ||
| 68 | + | ||
| 69 | + try { | ||
| 70 | + // 过滤附件:只保存已上传成功且有 meta_id 的 | ||
| 71 | + const validFiles = (payload.file_list || []).filter(item => | ||
| 72 | + item.status === 'done' && item.meta_id | ||
| 73 | + ).map(item => ({ | ||
| 74 | + meta_id: item.meta_id, | ||
| 75 | + url: item.url, | ||
| 76 | + name: item.name, | ||
| 77 | + file_type: item.file_type | ||
| 78 | + })) | ||
| 79 | + | ||
| 80 | + const data = { | ||
| 81 | + version: 1, | ||
| 82 | + saved_at: Date.now(), | ||
| 83 | + expires_at: Date.now() + TTL_MS, | ||
| 84 | + payload: { | ||
| 85 | + ...payload, | ||
| 86 | + file_list: validFiles | ||
| 87 | + } | ||
| 88 | + } | ||
| 89 | + | ||
| 90 | + localStorage.setItem(key, JSON.stringify(data)) | ||
| 91 | + console.log(`[草稿缓存] 已保存: ${key}`, data.payload) | ||
| 92 | + } catch (e) { | ||
| 93 | + console.error('[草稿缓存] 保存失败:', e) | ||
| 94 | + } | ||
| 95 | + } | ||
| 96 | + | ||
| 97 | + /** | ||
| 98 | + * 读取草稿 | ||
| 99 | + * @param {string} key 缓存Key | ||
| 100 | + * @returns {Object|null} 草稿数据或null | ||
| 101 | + */ | ||
| 102 | + const read_draft = (key) => { | ||
| 103 | + if (!key) return null | ||
| 104 | + | ||
| 105 | + try { | ||
| 106 | + const raw = localStorage.getItem(key) | ||
| 107 | + if (!raw) return null | ||
| 108 | + | ||
| 109 | + const data = JSON.parse(raw) | ||
| 110 | + | ||
| 111 | + // 检查版本 | ||
| 112 | + if (data.version !== 1) { | ||
| 113 | + console.warn('[草稿缓存] 版本不匹配,丢弃旧数据') | ||
| 114 | + clear_draft(key) | ||
| 115 | + return null | ||
| 116 | + } | ||
| 117 | + | ||
| 118 | + // 检查过期 | ||
| 119 | + if (data.expires_at && Date.now() > data.expires_at) { | ||
| 120 | + console.log('[草稿缓存] 数据已过期,自动清理') | ||
| 121 | + clear_draft(key) | ||
| 122 | + return null | ||
| 123 | + } | ||
| 124 | + | ||
| 125 | + return data | ||
| 126 | + } catch (e) { | ||
| 127 | + console.error('[草稿缓存] 读取失败:', e) | ||
| 128 | + return null | ||
| 129 | + } | ||
| 130 | + } | ||
| 131 | + | ||
| 132 | + /** | ||
| 133 | + * 清除指定草稿 | ||
| 134 | + * @param {string} key | ||
| 135 | + */ | ||
| 136 | + const clear_draft = (key) => { | ||
| 137 | + if (!key) return | ||
| 138 | + localStorage.removeItem(key) | ||
| 139 | + console.log(`[草稿缓存] 已清除: ${key}`) | ||
| 140 | + } | ||
| 141 | + | ||
| 142 | + /** | ||
| 143 | + * 清理所有过期草稿 | ||
| 144 | + */ | ||
| 145 | + const cleanup_expired = () => { | ||
| 146 | + try { | ||
| 147 | + const keysToRemove = [] | ||
| 148 | + const now = Date.now() | ||
| 149 | + | ||
| 150 | + for (let i = 0; i < localStorage.length; i++) { | ||
| 151 | + const key = localStorage.key(i) | ||
| 152 | + if (key && key.startsWith(DRAFT_PREFIX)) { | ||
| 153 | + try { | ||
| 154 | + const raw = localStorage.getItem(key) | ||
| 155 | + const data = JSON.parse(raw) | ||
| 156 | + if (data.expires_at && now > data.expires_at) { | ||
| 157 | + keysToRemove.push(key) | ||
| 158 | + } | ||
| 159 | + } catch (e) { | ||
| 160 | + // 解析失败的脏数据也清理掉 | ||
| 161 | + keysToRemove.push(key) | ||
| 162 | + } | ||
| 163 | + } | ||
| 164 | + } | ||
| 165 | + | ||
| 166 | + keysToRemove.forEach(key => { | ||
| 167 | + localStorage.removeItem(key) | ||
| 168 | + console.log(`[草稿缓存] 自动清理过期项: ${key}`) | ||
| 169 | + }) | ||
| 170 | + } catch (e) { | ||
| 171 | + console.error('[草稿缓存] 批量清理失败:', e) | ||
| 172 | + } | ||
| 173 | + } | ||
| 174 | + | ||
| 175 | + return { | ||
| 176 | + is_enabled, | ||
| 177 | + build_key, | ||
| 178 | + save_draft, | ||
| 179 | + read_draft, | ||
| 180 | + clear_draft, | ||
| 181 | + cleanup_expired | ||
| 182 | + } | ||
| 183 | +} |
| 1 | <!-- | 1 | <!-- |
| 2 | * @Date: 2025-09-30 17:05 | 2 | * @Date: 2025-09-30 17:05 |
| 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com | 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com |
| 4 | - * @LastEditTime: 2026-01-24 16:39:39 | 4 | + * @LastEditTime: 2026-01-25 16:42:06 |
| 5 | * @FilePath: /mlaj/src/views/checkin/CheckinDetailPage.vue | 5 | * @FilePath: /mlaj/src/views/checkin/CheckinDetailPage.vue |
| 6 | * @Description: 用户打卡详情页 | 6 | * @Description: 用户打卡详情页 |
| 7 | --> | 7 | --> |
| ... | @@ -182,21 +182,25 @@ | ... | @@ -182,21 +182,25 @@ |
| 182 | </template> | 182 | </template> |
| 183 | 183 | ||
| 184 | <script setup> | 184 | <script setup> |
| 185 | -import { ref, computed, onMounted, nextTick, reactive, watch } from 'vue' | 185 | +import { ref, computed, onMounted, nextTick, reactive, watch, onBeforeUnmount } from 'vue' |
| 186 | -import { useRoute, useRouter } from 'vue-router' | 186 | +import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router' |
| 187 | import { getTaskDetailAPI, getUploadTaskInfoAPI, getSubtaskListAPI, reuseGratitudeFormAPI } from "@/api/checkin" | 187 | import { getTaskDetailAPI, getUploadTaskInfoAPI, getSubtaskListAPI, reuseGratitudeFormAPI } from "@/api/checkin" |
| 188 | import { useTitle } from '@vueuse/core' | 188 | import { useTitle } from '@vueuse/core' |
| 189 | import { useCheckin } from '@/composables/useCheckin' | 189 | import { useCheckin } from '@/composables/useCheckin' |
| 190 | +import { useCheckinDraft } from '@/composables/useCheckinDraft' | ||
| 191 | +import { useAuth } from '@/contexts/auth' | ||
| 190 | import { normalizeAttachmentTypeConfig } from '@/utils/tools' | 192 | import { normalizeAttachmentTypeConfig } from '@/utils/tools' |
| 191 | import AudioPlayer from '@/components/media/AudioPlayer.vue' | 193 | import AudioPlayer from '@/components/media/AudioPlayer.vue' |
| 192 | import VideoPlayer from '@/components/media/VideoPlayer.vue' | 194 | import VideoPlayer from '@/components/media/VideoPlayer.vue' |
| 193 | import AddTargetDialog from '@/components/count/AddTargetDialog.vue' | 195 | import AddTargetDialog from '@/components/count/AddTargetDialog.vue' |
| 194 | import CheckinTargetList from '@/components/count/CheckinTargetList.vue' | 196 | import CheckinTargetList from '@/components/count/CheckinTargetList.vue' |
| 195 | -import { showToast, showLoadingToast, showDialog } from 'vant' | 197 | +import { showToast, showLoadingToast, showDialog, showConfirmDialog } from 'vant' |
| 196 | import dayjs from 'dayjs' | 198 | import dayjs from 'dayjs' |
| 199 | +import { debounce } from 'lodash-es' | ||
| 197 | 200 | ||
| 198 | const route = useRoute() | 201 | const route = useRoute() |
| 199 | const router = useRouter() | 202 | const router = useRouter() |
| 203 | +const { currentUser } = useAuth() | ||
| 200 | useTitle('提交作业') | 204 | useTitle('提交作业') |
| 201 | 205 | ||
| 202 | // 使用打卡composable | 206 | // 使用打卡composable |
| ... | @@ -226,6 +230,246 @@ const { | ... | @@ -226,6 +230,246 @@ const { |
| 226 | gratitudeFormList | 230 | gratitudeFormList |
| 227 | } = useCheckin() | 231 | } = useCheckin() |
| 228 | 232 | ||
| 233 | +// 使用草稿缓存composable | ||
| 234 | +const { | ||
| 235 | + is_enabled: isDraftEnabled, | ||
| 236 | + build_key: buildDraftKey, | ||
| 237 | + save_draft: saveDraft, | ||
| 238 | + read_draft: readDraft, | ||
| 239 | + clear_draft: clearDraft, | ||
| 240 | + cleanup_expired: cleanupExpiredDrafts | ||
| 241 | +} = useCheckinDraft() | ||
| 242 | + | ||
| 243 | +// 草稿Key | ||
| 244 | +const draftKey = computed(() => { | ||
| 245 | + return buildDraftKey({ | ||
| 246 | + user_id: currentUser.value?.id, | ||
| 247 | + task_id: route.query.task_id, | ||
| 248 | + date: route.query.date, | ||
| 249 | + task_type: route.query.task_type, | ||
| 250 | + status: route.query.status || 'create' | ||
| 251 | + }) | ||
| 252 | +}) | ||
| 253 | + | ||
| 254 | +// 动态字段文字 | ||
| 255 | +const dynamicFieldText = ref('感恩') | ||
| 256 | + | ||
| 257 | +// 任务详情数据 | ||
| 258 | +const taskDetail = ref({}) | ||
| 259 | + | ||
| 260 | +// 作业选择相关 | ||
| 261 | +const showTaskPicker = ref(false) | ||
| 262 | +const taskOptions = ref([]) | ||
| 263 | +// 上次打卡的感恩表单数据 | ||
| 264 | +const lastUsedTargetList = ref([]) | ||
| 265 | + | ||
| 266 | +// 动态表单字段 (默认值,实际会根据选择的作业动态更新) | ||
| 267 | +const dynamicFormFields = ref([]) | ||
| 268 | +const personType = ref('') // 动态表单字段中的person_type | ||
| 269 | + | ||
| 270 | +// 计数打卡相关逻辑 | ||
| 271 | +const countValue = ref(1) | ||
| 272 | +const selectedTargets = ref([]) | ||
| 273 | +// Mock 老师数据 | ||
| 274 | +const targetList = ref([]) | ||
| 275 | +const showAddTargetDialog = ref(false) | ||
| 276 | +const editingTarget = ref(null) | ||
| 277 | +const isConfirmMode = ref(false) // 是否为确认模式(首次点击选中) | ||
| 278 | + | ||
| 279 | +// 作品类型选项 | ||
| 280 | +const attachmentTypeOptions = ref([]) | ||
| 281 | + | ||
| 282 | +// 预览相关变量 | ||
| 283 | +const audioShow = ref(false) | ||
| 284 | +const audioTitle = ref('') | ||
| 285 | +const audioUrl = ref('') | ||
| 286 | +const videoShow = ref(false) | ||
| 287 | +const videoTitle = ref('') | ||
| 288 | +const videoUrl = ref('') | ||
| 289 | +const videoCover = ref('') | ||
| 290 | +const isVideoPlaying = ref(false) | ||
| 291 | +const videoPlayerRef = ref(null) | ||
| 292 | +const imageShow = ref(false) | ||
| 293 | +const imageList = ref([]) | ||
| 294 | +const imageIndex = ref(0) | ||
| 295 | + | ||
| 296 | +/** | ||
| 297 | + * 自动保存草稿 | ||
| 298 | + * @description 使用debounce防抖,避免频繁写入 | ||
| 299 | + */ | ||
| 300 | +const autoSaveDraft = debounce(() => { | ||
| 301 | + if (!isDraftEnabled() || route.query.status === 'edit') return | ||
| 302 | + | ||
| 303 | + const payload = { | ||
| 304 | + message: message.value, | ||
| 305 | + active_type: activeType.value, | ||
| 306 | + subtask_id: selectedTaskValue.value?.[0], | ||
| 307 | + selected_task_value: selectedTaskValue.value, | ||
| 308 | + file_list: fileList.value, // save_draft内部会过滤done状态 | ||
| 309 | + count: { | ||
| 310 | + gratitude_count: countValue.value, | ||
| 311 | + gratitude_form_list: selectedTargets.value | ||
| 312 | + } | ||
| 313 | + } | ||
| 314 | + | ||
| 315 | + saveDraft(draftKey.value, payload) | ||
| 316 | +}, 500) | ||
| 317 | + | ||
| 318 | +// 监听数据变化触发自动保存 | ||
| 319 | +watch([message, fileList, selectedTaskValue, countValue, selectedTargets], () => { | ||
| 320 | + autoSaveDraft() | ||
| 321 | +}, { deep: true }) | ||
| 322 | + | ||
| 323 | +// 页面离开前强制保存一次 | ||
| 324 | +onBeforeRouteLeave(() => { | ||
| 325 | + autoSaveDraft() | ||
| 326 | + autoSaveDraft.flush() // 立即执行待处理的保存 | ||
| 327 | +}) | ||
| 328 | + | ||
| 329 | +// 页面卸载前保存(兼容刷新/关闭tab) | ||
| 330 | +const handleBeforeUnload = () => { | ||
| 331 | + autoSaveDraft() | ||
| 332 | + autoSaveDraft.flush() | ||
| 333 | +} | ||
| 334 | +window.addEventListener('beforeunload', handleBeforeUnload) | ||
| 335 | +onBeforeUnmount(() => { | ||
| 336 | + window.removeEventListener('beforeunload', handleBeforeUnload) | ||
| 337 | +}) | ||
| 338 | + | ||
| 339 | +/** | ||
| 340 | + * 检查并恢复草稿 | ||
| 341 | + */ | ||
| 342 | +const checkAndRestoreDraft = async () => { | ||
| 343 | + if (!isDraftEnabled() || route.query.status === 'edit') return | ||
| 344 | + | ||
| 345 | + // 先清理过期草稿 | ||
| 346 | + cleanupExpiredDrafts() | ||
| 347 | + | ||
| 348 | + const draft = readDraft(draftKey.value) | ||
| 349 | + if (!draft || !draft.payload) return | ||
| 350 | + | ||
| 351 | + const { payload } = draft | ||
| 352 | + | ||
| 353 | + // 检查是否有实质内容 | ||
| 354 | + const hasContent = (payload.message && payload.message.trim()) || | ||
| 355 | + (payload.file_list && payload.file_list.length > 0) | ||
| 356 | + | ||
| 357 | + if (!hasContent) return | ||
| 358 | + | ||
| 359 | + try { | ||
| 360 | + await showConfirmDialog({ | ||
| 361 | + title: '发现未提交的草稿', | ||
| 362 | + message: '上次编辑的内容未提交,是否恢复?', | ||
| 363 | + confirmButtonText: '恢复', | ||
| 364 | + cancelButtonText: '丢弃' | ||
| 365 | + }) | ||
| 366 | + | ||
| 367 | + // 确认恢复 | ||
| 368 | + console.log('[草稿恢复] 开始恢复数据', payload) | ||
| 369 | + | ||
| 370 | + if (payload.message) message.value = payload.message | ||
| 371 | + if (payload.active_type) activeType.value = payload.active_type | ||
| 372 | + | ||
| 373 | + // 恢复文件列表 (注意:只恢复了已完成上传的元数据,无法恢复File对象,所以无法继续上传/断点续传) | ||
| 374 | + // 恢复后的文件状态设为done | ||
| 375 | + if (payload.file_list && payload.file_list.length > 0) { | ||
| 376 | + fileList.value = payload.file_list.map(f => ({ | ||
| 377 | + ...f, | ||
| 378 | + status: 'done', | ||
| 379 | + message: '已上传' | ||
| 380 | + })) | ||
| 381 | + } | ||
| 382 | + | ||
| 383 | + // 恢复选中的作业 | ||
| 384 | + if (payload.selected_task_value && payload.selected_task_value.length > 0) { | ||
| 385 | + selectedTaskValue.value = payload.selected_task_value | ||
| 386 | + // 触发联动逻辑 (如updateDynamicFormFields等),这部分在selectedTaskValue的watcher或onConfirmTask中处理 | ||
| 387 | + // 但这里直接赋值可能不会触发onConfirmTask的逻辑,需要手动处理部分联动 | ||
| 388 | + | ||
| 389 | + // 等待taskOptions加载完毕后再匹配text | ||
| 390 | + const matchOption = () => { | ||
| 391 | + const option = taskOptions.value.find(o => o.value === selectedTaskValue.value[0]) | ||
| 392 | + if (option) { | ||
| 393 | + selectedTaskText.value = option.text | ||
| 394 | + isMakeup.value = !!option.is_makeup | ||
| 395 | + personType.value = option.person_type | ||
| 396 | + updateDynamicFormFields(option) | ||
| 397 | + if (option.attachment_type) updateAttachmentTypeOptions(option.attachment_type) | ||
| 398 | + } | ||
| 399 | + } | ||
| 400 | + | ||
| 401 | + if (taskOptions.value.length > 0) { | ||
| 402 | + matchOption() | ||
| 403 | + } else { | ||
| 404 | + // 如果选项还没加载完,watch taskOptions | ||
| 405 | + const unwatch = watch(taskOptions, () => { | ||
| 406 | + matchOption() | ||
| 407 | + unwatch() | ||
| 408 | + }) | ||
| 409 | + } | ||
| 410 | + } | ||
| 411 | + | ||
| 412 | + // 恢复计数 | ||
| 413 | + if (payload.count?.gratitude_count) { | ||
| 414 | + countValue.value = payload.count.gratitude_count | ||
| 415 | + } | ||
| 416 | + | ||
| 417 | + // 恢复感恩列表(计数对象) | ||
| 418 | + // 必须在恢复 selectedTaskValue 之后执行,因为 fetchTargetList 依赖 subtask_id | ||
| 419 | + if (payload.count?.gratitude_form_list && Array.isArray(payload.count.gratitude_form_list) && payload.count.gratitude_form_list.length > 0) { | ||
| 420 | + const savedList = payload.count.gratitude_form_list | ||
| 421 | + | ||
| 422 | + // 如果有作业ID,先获取基础列表 | ||
| 423 | + if (selectedTaskValue.value && selectedTaskValue.value.length > 0) { | ||
| 424 | + // 等待 fetchTargetList 完成,然后覆盖默认选中的项 | ||
| 425 | + await fetchTargetList(selectedTaskValue.value[0]) | ||
| 426 | + | ||
| 427 | + // 使用草稿中的列表覆盖 selectedTargets (注意去重或合并策略) | ||
| 428 | + // 这里选择:完全信任草稿中的选中状态。 | ||
| 429 | + // 但需要注意:草稿中的对象可能只包含 id/name,而 targetList 中有完整信息。 | ||
| 430 | + // 最好是基于 targetList 重新构建 selectedTargets,如果 targetList 中没有(比如新增的),则直接使用草稿中的。 | ||
| 431 | + | ||
| 432 | + const restoredTargets = [] | ||
| 433 | + | ||
| 434 | + savedList.forEach(savedItem => { | ||
| 435 | + // 尝试在 targetList 中找到对应项(获取最新状态/引用) | ||
| 436 | + const existingItem = targetList.value.find(t => | ||
| 437 | + (savedItem.id && t.id && t.id == savedItem.id) || | ||
| 438 | + (!savedItem.id && savedItem.name === t.name) | ||
| 439 | + ) | ||
| 440 | + | ||
| 441 | + if (existingItem) { | ||
| 442 | + existingItem.has_confirmed = true // 既然都在草稿里了,肯定是确认过的 | ||
| 443 | + restoredTargets.push(existingItem) | ||
| 444 | + } else { | ||
| 445 | + // 如果 targetList 里没有(可能是新增的,或者 targetList 变了),则直接使用草稿项 | ||
| 446 | + restoredTargets.push({ | ||
| 447 | + ...savedItem, | ||
| 448 | + has_confirmed: true | ||
| 449 | + }) | ||
| 450 | + // 同时也加到 targetList 里显示出来(如果是新增的) | ||
| 451 | + targetList.value.push(restoredTargets[restoredTargets.length - 1]) | ||
| 452 | + } | ||
| 453 | + }) | ||
| 454 | + | ||
| 455 | + selectedTargets.value = restoredTargets | ||
| 456 | + } else { | ||
| 457 | + // 如果没有作业ID(理论上不应该,因为计数打卡必须选作业),直接恢复 | ||
| 458 | + selectedTargets.value = savedList | ||
| 459 | + } | ||
| 460 | + } | ||
| 461 | + | ||
| 462 | + showToast('已恢复草稿') | ||
| 463 | + | ||
| 464 | + } catch (e) { | ||
| 465 | + // 取消恢复,清除草稿 | ||
| 466 | + if (e !== 'cancel') console.error(e) | ||
| 467 | + clearDraft(draftKey.value) | ||
| 468 | + showToast('已丢弃草稿') | ||
| 469 | + } | ||
| 470 | +} | ||
| 471 | + | ||
| 472 | + | ||
| 229 | const beforeReadGuard = (file) => { | 473 | const beforeReadGuard = (file) => { |
| 230 | const files = Array.isArray(file) ? file : [file] | 474 | const files = Array.isArray(file) ? file : [file] |
| 231 | if (activeType.value === 'video') { | 475 | if (activeType.value === 'video') { |
| ... | @@ -335,11 +579,9 @@ const displayFileList = computed({ | ... | @@ -335,11 +579,9 @@ const displayFileList = computed({ |
| 335 | } | 579 | } |
| 336 | }) | 580 | }) |
| 337 | 581 | ||
| 338 | -// 动态字段文字 | ||
| 339 | -const dynamicFieldText = ref('感恩') | ||
| 340 | 582 | ||
| 341 | -// 任务详情数据 | 583 | + |
| 342 | -const taskDetail = ref({}) | 584 | + |
| 343 | 585 | ||
| 344 | const maxFileSizeBytes = computed(() => { | 586 | const maxFileSizeBytes = computed(() => { |
| 345 | const size = Number(maxFileSizeMb.value || 0) | 587 | const size = Number(maxFileSizeMb.value || 0) |
| ... | @@ -360,11 +602,7 @@ const displayTaskNote = computed(() => { | ... | @@ -360,11 +602,7 @@ const displayTaskNote = computed(() => { |
| 360 | // 打卡类型 | 602 | // 打卡类型 |
| 361 | const taskType = computed(() => route.query.task_type) | 603 | const taskType = computed(() => route.query.task_type) |
| 362 | 604 | ||
| 363 | -// 作业选择相关 | 605 | + |
| 364 | -const showTaskPicker = ref(false) | ||
| 365 | -const taskOptions = ref([]) | ||
| 366 | -// 上次打卡的感恩表单数据 | ||
| 367 | -const lastUsedTargetList = ref([]) | ||
| 368 | 606 | ||
| 369 | const fetchTargetList = async (subtask_id) => { | 607 | const fetchTargetList = async (subtask_id) => { |
| 370 | const { code, data } = await reuseGratitudeFormAPI({ subtask_id }) | 608 | const { code, data } = await reuseGratitudeFormAPI({ subtask_id }) |
| ... | @@ -404,9 +642,7 @@ const fetchTargetList = async (subtask_id) => { | ... | @@ -404,9 +642,7 @@ const fetchTargetList = async (subtask_id) => { |
| 404 | } | 642 | } |
| 405 | } | 643 | } |
| 406 | 644 | ||
| 407 | -// 动态表单字段 (默认值,实际会根据选择的作业动态更新) | 645 | + |
| 408 | -const dynamicFormFields = ref([]) | ||
| 409 | -const personType = ref('') // 动态表单字段中的person_type | ||
| 410 | 646 | ||
| 411 | /** | 647 | /** |
| 412 | * 更新动态表单字段 | 648 | * 更新动态表单字段 |
| ... | @@ -485,14 +721,7 @@ const onConfirmTask = async ({ selectedOptions }) => { | ... | @@ -485,14 +721,7 @@ const onConfirmTask = async ({ selectedOptions }) => { |
| 485 | // } | 721 | // } |
| 486 | // }) | 722 | // }) |
| 487 | 723 | ||
| 488 | -// 计数打卡相关逻辑 | 724 | + |
| 489 | -const countValue = ref(1) | ||
| 490 | -const selectedTargets = ref([]) | ||
| 491 | -// Mock 老师数据 | ||
| 492 | -const targetList = ref([]) | ||
| 493 | -const showAddTargetDialog = ref(false) | ||
| 494 | -const editingTarget = ref(null) | ||
| 495 | -const isConfirmMode = ref(false) // 是否为确认模式(首次点击选中) | ||
| 496 | 725 | ||
| 497 | const toggleTarget = (item) => { | 726 | const toggleTarget = (item) => { |
| 498 | // 优先使用id匹配,如果id不存在,则使用name匹配 | 727 | // 优先使用id匹配,如果id不存在,则使用name匹配 |
| ... | @@ -670,28 +899,22 @@ const handleSubmit = async () => { | ... | @@ -670,28 +899,22 @@ const handleSubmit = async () => { |
| 670 | extraData.gratitude_count = countValue.value | 899 | extraData.gratitude_count = countValue.value |
| 671 | } | 900 | } |
| 672 | 901 | ||
| 673 | - await onSubmit(extraData) | 902 | + // 提交成功后的回调,清除草稿 |
| 903 | + const onSuccess = () => { | ||
| 904 | + if (isDraftEnabled()) { | ||
| 905 | + clearDraft(draftKey.value) | ||
| 906 | + } | ||
| 907 | + } | ||
| 908 | + | ||
| 909 | + await onSubmit(extraData, onSuccess) | ||
| 674 | } | 910 | } |
| 675 | 911 | ||
| 676 | -// 作品类型选项 | 912 | + |
| 677 | -const attachmentTypeOptions = ref([]) | ||
| 678 | 913 | ||
| 679 | // 是否为编辑模式 | 914 | // 是否为编辑模式 |
| 680 | const isEditMode = computed(() => route.query.status === 'edit') | 915 | const isEditMode = computed(() => route.query.status === 'edit') |
| 681 | 916 | ||
| 682 | -// 预览相关变量 | 917 | + |
| 683 | -const audioShow = ref(false) | ||
| 684 | -const audioTitle = ref('') | ||
| 685 | -const audioUrl = ref('') | ||
| 686 | -const videoShow = ref(false) | ||
| 687 | -const videoTitle = ref('') | ||
| 688 | -const videoUrl = ref('') | ||
| 689 | -const videoCover = ref('') | ||
| 690 | -const isVideoPlaying = ref(false) | ||
| 691 | -const videoPlayerRef = ref(null) | ||
| 692 | -const imageShow = ref(false) | ||
| 693 | -const imageList = ref([]) | ||
| 694 | -const imageIndex = ref(0) | ||
| 695 | 918 | ||
| 696 | /** | 919 | /** |
| 697 | * 返回上一页 | 920 | * 返回上一页 |
| ... | @@ -1147,6 +1370,11 @@ onMounted(async () => { | ... | @@ -1147,6 +1370,11 @@ onMounted(async () => { |
| 1147 | countValue.value = val | 1370 | countValue.value = val |
| 1148 | } | 1371 | } |
| 1149 | }) | 1372 | }) |
| 1373 | + | ||
| 1374 | + // 尝试恢复草稿 (非编辑模式) | ||
| 1375 | + if (!isEditMode.value) { | ||
| 1376 | + await checkAndRestoreDraft() | ||
| 1377 | + } | ||
| 1150 | }) | 1378 | }) |
| 1151 | </script> | 1379 | </script> |
| 1152 | 1380 | ... | ... |
-
Please register or login to post a comment