hookehuyr

feat(checkin): 新增打卡草稿缓存功能

- 新增 useCheckinDraft composable,支持草稿的自动保存、恢复和清理
- 在打卡详情页集成草稿功能:自动保存表单内容,进入时提示恢复
- 优化文件上传逻辑,确保草稿恢复后能正确预览附件
- 修复 countValue 初始化顺序导致的 ReferenceError
- 更新环境变量配置,添加 VITE_CHECKIN_DRAFT_CACHE 开关
- 完善相关文档和测试用例
...@@ -18,3 +18,6 @@ VITE_APPID=微信appID ...@@ -18,3 +18,6 @@ VITE_APPID=微信appID
18 18
19 # 是否开启多附件功能 19 # 是否开启多附件功能
20 VITE_CHECKIN_MULTI_ATTACHMENT = 0 20 VITE_CHECKIN_MULTI_ATTACHMENT = 0
21 +
22 +# 是否开启打卡草稿缓存功能
23 +VITE_CHECKIN_DRAFT_CACHE = 0
......
...@@ -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 - 统一了文本、媒体上传和计数打卡的入口
......
1 +用户反馈:
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);
......
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
......