hookehuyr

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

- 新增 useCheckinDraft composable,支持草稿的自动保存、恢复和清理
- 在打卡详情页集成草稿功能:自动保存表单内容,进入时提示恢复
- 优化文件上传逻辑,确保草稿恢复后能正确预览附件
- 修复 countValue 初始化顺序导致的 ReferenceError
- 更新环境变量配置,添加 VITE_CHECKIN_DRAFT_CACHE 开关
- 完善相关文档和测试用例
......@@ -18,3 +18,6 @@ VITE_APPID=微信appID
# 是否开启多附件功能
VITE_CHECKIN_MULTI_ATTACHMENT = 0
# 是否开启打卡草稿缓存功能
VITE_CHECKIN_DRAFT_CACHE = 0
......
......@@ -97,7 +97,7 @@ src/
- **统一打卡页** (`/checkin/detail`):
- 支持类型:文本、图片、视频、音频、计数打卡(如感恩日记)。
- 核心逻辑:`useCheckin` 封装上传(七牛云+Hash秒传)、校验、提交逻辑。
- 交互:支持补卡、编辑已提交内容、选择打卡对象(计数模式)。
- 交互:支持补卡、编辑已提交内容、选择打卡对象(计数模式);支持自动保存草稿与断点恢复
- **互动社区**:打卡动态流,支持点赞、评论、查看他人作品。
- **教师端** (`/teacher`):
- **作业管理**:发布新作业,查看作业列表与详情(完成率/出勤率统计)。
......
......@@ -2,6 +2,17 @@
说明:该章节从 README 迁移到本文件,避免 README 过长。后续新增变更建议追加在文件顶部。
## 2026-01-25
- 新增「暂存用户打卡信息」开发规划:[/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)
- 完成「暂存用户打卡信息」功能开发
- 核心逻辑:[/src/composables/useCheckinDraft.js](file:///Users/huyirui/program/itomix/git/mlaj/src/composables/useCheckinDraft.js)
- 页面集成:[/src/views/checkin/CheckinDetailPage.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/views/checkin/CheckinDetailPage.vue)
- 支持自动保存、过期清理、恢复提示
- 修复打卡详情页 `countValue` 初始化顺序导致的 ReferenceError 报错
- 修复附件上传成功后未保存 URL 导致草稿恢复后无法预览的问题
- 优化文件 URL 获取逻辑:移除硬编码默认域名,优先使用接口返回的 URL 或 src,仅在有域名信息时拼接 URL
## 打卡详情页重构(/checkin/detail)
- 统一了文本、媒体上传和计数打卡的入口
......
用户反馈:
# 暂存用户打卡信息
## 背景
用户在“提交作业/打卡”页面输入了较长文字并上传了媒体,但在未点击提交时被中断(误触返回、微信进程被系统回收、来电/切后台等),再次进入页面内容丢失,导致体验断裂。
本规划目标是在不改动后端接口的前提下,在前端提供“草稿暂存(文本 + 已上传媒体信息)”能力,支持一周内自动过期清理,并在用户再次进入时提示恢复或删除。
涉及页面与核心逻辑参考:
- 打卡提交页:[CheckinDetailPage.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/views/checkin/CheckinDetailPage.vue)
- 打卡核心逻辑:[useCheckin.js](file:///Users/huyirui/program/itomix/git/mlaj/src/composables/useCheckin.js)
- 环境变量约定:[.env](file:///Users/huyirui/program/itomix/git/mlaj/.env)
## 需求拆解(逐条对齐)
1. 缓存最多保存一周:写入时记录 saved_at,读写时执行过期清理(>7天删除)。
2. 用户提交后清空缓存:提交成功(code===1)后删除对应草稿。
3. 进入页面若存在未完成信息:弹框提示“继续/删除”,继续则回填,删除则清空。
4. 功能开关放到 .env:新增 VITE_CHECKIN_DRAFT_CACHE(0/1),默认建议 1(也可先默认 0,灰度开启)。
## 范围与不做项(第一版)
- 覆盖:文字内容、已上传成功的媒体(含 url/meta_id/file_type/name)。
- 不覆盖:未上传完成的 File/Blob(localStorage 无法可靠持久化;要支持需 IndexedDB 存 Blob,复杂度与风险较高)。
- 编辑模式(route.query.status===edit):第一版建议默认不启用草稿恢复,避免与“编辑回显(来自后端)”冲突;若要覆盖编辑场景,采用独立 key(见“扩展”)。
## 关键设计
### 1) 存储介质
- 使用 localStorage:实现成本低,满足“一周”与“断网/切后台后仍可恢复”。
- 数据量控制:只存“已上传成功”的附件元数据;不存 File 本体。
### 2) 草稿 Key 设计(避免串号)
建议 key 包含用户与作业上下文,确保不同用户/不同作业互不影响:
- 前缀:CHECKIN_DRAFT_V1
- 维度:user_id、task_id、date、task_type、status
示例:
- CHECKIN_DRAFT_V1:{user_id}:{task_id}:{date}:{task_type}:{status}
其中:
- user_id:来自 currentUser(contexts/auth.js 本地持久化)
- task_id/date/task_type/status:来自路由 query(CheckinDetailPage 已使用 route.query.task_id/date/task_type/status)
### 3) 数据结构(建议)
```json
{
"version": 1,
"saved_at": 1730000000000,
"expires_at": 1730000000000,
"context": {
"user_id": "123",
"task_id": "456",
"date": "2026-01-25",
"task_type": "upload",
"status": "create"
},
"payload": {
"message": "...",
"active_type": "image",
"subtask_id": "789",
"file_list": [
{
"meta_id": "xxx",
"url": "https://...",
"name": "a.jpg",
"file_type": "image"
}
],
"count": {
"gratitude_count": 1,
"gratitude_form_list": []
}
}
}
```
说明:
- file_list:仅保存 useCheckin.afterRead 上传成功后写入的字段(item.status===done 且 meta_id 存在)。
- count:来源于 CheckinDetailPage 的 selectedTargets/countValue(第一版可以先不存,或存但不影响非 count 类型)。
### 4) 触发保存的时机(自动暂存)
- 文本变化:watch(message) debounce 500ms 保存。
- 附件变化:watch(fileList) 深度监听 debounce 500ms 保存(仅保存 done 项)。
- 作业选择变化:watch(selectedTaskValue) debounce 200ms 保存。
- 页面离开兜底:beforeRouteLeave 或 window.pagehide/visibilitychange 时强制保存一次(避免最后一次变更没落盘)。
落盘时机要遵循开关:VITE_CHECKIN_DRAFT_CACHE === '1' 才启用。
### 5) 弹框提示与回填流程
进入 [CheckinDetailPage.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/views/checkin/CheckinDetailPage.vue)(且非 edit 模式)时:
1. 读取 key 对应草稿;若不存在或已过期,直接进入正常流程。
2. 若存在草稿且 payload 有实际内容(message 有非空或 file_list 非空):弹框提示。
3. 用户选择:
- 继续:将草稿回填到 message / activeType / fileList / selectedTaskValue(以及 count 数据如启用),并立刻再保存一次(避免回填后又丢)。
- 删除:删除草稿并保持空表单。
弹框建议用 showConfirmDialog(Vant 4),取消分支需要 catch,避免控制台出现 Uncaught (in promise) cancel(Vant 文档:showConfirmDialog.then/catch)[3](https://develop365.gitlab.io/vant/zh-CN/dialog/)
### 6) 清理策略(“定期清除一周前”)
采用“惰性清理 + 低频全量清理”组合:
- 惰性清理:每次读/写草稿时,如果 expires_at < now 则删除。
- 低频全量:进入打卡页时,扫描 localStorage 中以 CHECKIN_DRAFT_V1: 开头的 key,删除所有过期项。
说明:localStorage 没有内建 TTL,必须业务侧维护 expires_at。
### 7) 提交成功后清空
清空动作必须绑定到“真正提交成功”之后:
- 建议在 useCheckin.onSubmit 中,当 add/edit API 返回 code===1 且后续逻辑准备 router.back 前,删除对应 key。
这样可覆盖“不同入口页复用 onSubmit”以及“提交后立即返回上一页”的场景。
## 开发步骤(可落地的实现顺序)
### 第 0 步:验证手段先行(TDD)
新增 Vitest 用例,先定义以下可验证点:
- 写入后能读取同一 key 的草稿;过期后读取返回空且自动删除。
- 仅保存 status===done 且含 meta_id 的附件。
- 清理函数能删除所有过期 key,不误删其他业务 localStorage。
- 提交成功时会调用清理(可通过 mock API 返回 code===1 验证)。
### 第 1 步:抽离草稿存储模块
位置建议:src/utils/checkinDraftCache.js(纯函数、无 UI 依赖)。
对外 API(示例):
- is_enabled(): boolean(读取 env + 可选 query override)
- build_key(context): string
- save_draft(key, draft)
- read_draft(key): draft|null(含 TTL 处理)
- clear_draft(key)
- cleanup_expired(prefix)
### 第 2 步:在 CheckinDetailPage 接入“检测 + 弹框 + 回填”
- onMounted:初始化后读取草稿并弹框。
- 回填时机:建议在任务详情/子任务列表加载完成后再回填 selectedTaskValue,避免 option 未加载导致显示异常。
### 第 3 步:在 CheckinDetailPage 接入“自动保存”
- 对 message/fileList/selectedTaskValue/countValue/selectedTargets 建立 watch + debounce。
- 页面离开事件兜底(pagehide/visibilitychange)。
### 第 4 步:在 useCheckin.onSubmit 接入“成功清理”
- onSubmit 成功分支清除草稿。
- 失败分支不清除,保留草稿以便重试。
## 边界条件与遗漏点梳理(建议补齐)
1. 多用户切换:key 必须含 user_id,否则会串草稿。
2. 多任务并存:key 必须含 task_id/date/task_type,否则会在不同作业之间误恢复。
3. 附件未上传完成:
- 仅保存已上传成功的项;如果用户退出时仍有 uploading 项,恢复后无法找回该 File。
- 可在保存时统计未保存数量,并在恢复弹框里追加提示“有 X 个附件上传未完成未被暂存”。
4. 关闭开关后的行为:
- 关闭后不再读/写;建议仍执行一次 cleanup_expired,避免历史堆积。
5. 版本升级/数据结构变更:draft.version 不匹配时丢弃并清除,避免解析异常。
6. localStorage 配额:图片多但只存 url/meta_id 一般不会超;仍需 try/catch JSON 与 setItem 异常。
7. 编辑模式:
- 要支持“编辑中断恢复”,建议 key 加 post_id 维度,并在 initEditData 回显后再弹框询问是否覆盖当前表单。
## 环境变量(规划)
[.env](file:///Users/huyirui/program/itomix/git/mlaj/.env) 增加:
- VITE_CHECKIN_DRAFT_CACHE = 1
约定:
- '1' 开启,'0' 关闭
- 可选增加 URL 覆盖用于灰度测试:?enable_draft=1 / ?enable_draft=0(模式同 VITE_CHECKIN_MULTI_ATTACHMENT)
......@@ -179,7 +179,7 @@ describe('useCheckin 提交兼容', () => {
await onSubmit({ subtask_id: 's1' })
expect(addUploadTaskAPI).toHaveBeenCalledTimes(0)
expect(showToast).toHaveBeenCalledWith('当前接口暂不支持多类型附件,请分别提交')
expect(showToast).toHaveBeenCalledWith('当前接口暂不支持多类型附件,请删除其他类型附件之后再提交')
})
it('开启多附件开关时允许 mixed files 提交', async () => {
......
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { useCheckinDraft } from '../useCheckinDraft'
// Mock localStorage
const localStorageMock = (() => {
let store = {}
return {
getItem: vi.fn(key => store[key] || null),
setItem: vi.fn((key, value) => { store[key] = value.toString() }),
removeItem: vi.fn(key => { delete store[key] }),
clear: vi.fn(() => { store = {} }),
key: vi.fn(i => Object.keys(store)[i] || null),
get length() { return Object.keys(store).length }
}
})()
global.localStorage = localStorageMock
describe('useCheckinDraft', () => {
beforeEach(() => {
localStorageMock.clear()
vi.clearAllMocks()
// Mock Date.now
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
const mockContext = {
user_id: 'u123',
task_id: 't456',
date: '2026-01-25',
task_type: 'upload',
status: 'create'
}
const {
build_key,
save_draft,
read_draft,
clear_draft,
cleanup_expired
} = useCheckinDraft()
it('should generate correct key', () => {
const key = build_key(mockContext)
expect(key).toBe('CHECKIN_DRAFT_V1:u123:t456:2026-01-25:upload:create')
})
it('should save and read draft', () => {
const key = build_key(mockContext)
const payload = { message: 'hello', file_list: [] }
save_draft(key, payload)
const saved = read_draft(key)
expect(saved).not.toBeNull()
expect(saved.payload).toEqual(payload)
expect(saved.version).toBe(1)
})
it('should return null for expired draft', () => {
const key = build_key(mockContext)
const payload = { message: 'expired' }
save_draft(key, payload)
// Advance time by 8 days (TTL is 7 days)
vi.setSystemTime(Date.now() + 8 * 24 * 60 * 60 * 1000)
const saved = read_draft(key)
expect(saved).toBeNull()
// Should also remove from storage
expect(localStorage.getItem(key)).toBeNull()
})
it('should cleanup only expired drafts', () => {
const ctx1 = { ...mockContext, task_id: 't1' }
const ctx2 = { ...mockContext, task_id: 't2' }
const key1 = build_key(ctx1)
const key2 = build_key(ctx2)
const otherKey = 'OTHER_KEY'
save_draft(key1, { msg: 'expire soon' })
save_draft(key2, { msg: 'keep' })
localStorage.setItem(otherKey, 'some value')
// Set key1 to expire
// We need to manually manipulate storage to simulate different saved_at
// Or just save key1, advance time, save key2
// Reset storage and start over
localStorageMock.clear()
// Save key1
save_draft(key1, { msg: 'old' })
// Advance 8 days
const eightDays = 8 * 24 * 60 * 60 * 1000
vi.setSystemTime(Date.now() + eightDays)
// Save key2 (fresh)
save_draft(key2, { msg: 'new' })
// Other key
localStorage.setItem(otherKey, 'val')
// Run cleanup
cleanup_expired()
expect(localStorage.getItem(key1)).toBeNull()
expect(localStorage.getItem(key2)).not.toBeNull()
expect(localStorage.getItem(otherKey)).toBe('val')
})
it('should only save "done" files with meta_id', () => {
const key = build_key(mockContext)
const files = [
{ status: 'done', meta_id: 'm1', name: 'f1' },
{ status: 'uploading', name: 'f2' },
{ status: 'failed', name: 'f3' },
{ status: 'done', name: 'f4' } // no meta_id
]
save_draft(key, { file_list: files })
const saved = read_draft(key)
expect(saved.payload.file_list).toHaveLength(1)
expect(saved.payload.file_list[0].meta_id).toBe('m1')
})
})
......@@ -205,9 +205,29 @@ export function useCheckin() {
hash: md5
})
// 根据当前 activeType 或文件类型判断
let currentFileType = activeType.value
if (!['image', 'video', 'audio'].includes(currentFileType)) {
// 如果 activeType 不是这三种(比如 text),尝试从文件类型推断
if (file.file.type.startsWith('image/')) currentFileType = 'image'
else if (file.file.type.startsWith('video/')) currentFileType = 'video'
else if (file.file.type.startsWith('audio/')) currentFileType = 'audio'
else currentFileType = 'file' // 默认
}
// 文件已存在,直接返回
if (tokenResult.data) {
return tokenResult.data
const data = tokenResult.data
let finalUrl = data.url || data.src
// 如果没有URL但有filekey,尝试手动拼接
if (!finalUrl && (data.filekey || data.key || data.filepath) && tokenResult.domain) {
const key = data.filekey || data.key || data.filepath
const domain = tokenResult.domain
const cleanDomain = domain.endsWith('/') ? domain : domain + '/'
const cleanKey = key.startsWith('/') ? key.slice(1) : key
finalUrl = cleanDomain + cleanKey
}
return { ...data, url: finalUrl, file_type: currentFileType } // 确保返回 file_type
}
// 新文件上传
......@@ -215,16 +235,6 @@ export function useCheckin() {
const suffix = /.[^.]+$/.exec(file.file.name) || ''
let fileName = ''
// 根据当前 activeType 或文件类型判断
let currentFileType = activeType.value
if (!['image', 'video', 'audio'].includes(currentFileType)) {
// 如果 activeType 不是这三种(比如 text),尝试从文件类型推断
if (file.file.type.startsWith('image/')) currentFileType = 'image'
else if (file.file.type.startsWith('video/')) currentFileType = 'video'
else if (file.file.type.startsWith('audio/')) currentFileType = 'audio'
else currentFileType = 'file' // 默认
}
if (currentFileType === 'image') {
fileName = `mlaj/upload/checkin/${currentUser.value.mobile}/img/${md5}${suffix}`
} else {
......@@ -252,7 +262,18 @@ export function useCheckin() {
}
const { data } = await saveFileAPI(saveData)
return { ...data, file_type: currentFileType }
// 确保返回URL,如果后端未返回则手动拼接
let finalUrl = data?.url || data?.src
if (!finalUrl && tokenResult.domain) {
// 优先使用接口返回的域名
const domain = tokenResult.domain
const cleanDomain = domain.endsWith('/') ? domain : domain + '/'
const cleanKey = uploadResult.filekey.startsWith('/') ? uploadResult.filekey.slice(1) : uploadResult.filekey
finalUrl = cleanDomain + cleanKey
}
return { ...data, url: finalUrl, file_type: currentFileType }
}
}
return null
......@@ -425,6 +446,7 @@ export function useCheckin() {
/**
* 提交打卡
* @param {Object} extraData - 额外提交数据
* @param {Function} onSuccess - 提交成功回调 (新增)
* @description
* 提交流程:
* 1. 表单校验(内容长度、是否上传文件)
......@@ -433,7 +455,7 @@ export function useCheckin() {
* 4. 调用对应 API (addUploadTaskAPI / editUploadTaskInfoAPI)
* 5. 成功后设置 sessionStorage 刷新标记并返回上一页
*/
const onSubmit = async (extraData = {}) => {
const onSubmit = async (extraData = {}, onSuccess = null) => {
if (uploading.value) return
// 表单验证
......@@ -568,6 +590,11 @@ export function useCheckin() {
if (result?.code === 1) {
showToast('提交成功')
// 执行成功回调(如清理草稿)
if (typeof onSuccess === 'function') {
onSuccess()
}
// 设置刷新标记,用于列表页更新数据
const refreshType = route.query.status === 'edit' ? 'edit' : 'add';
sessionStorage.setItem('checkin_refresh_flag', refreshType);
......
/**
* 打卡草稿缓存逻辑封装
* @module useCheckinDraft
* @description
* 提供打卡草稿的增删改查能力,基于 localStorage。
*
* 核心功能:
* 1. 构建唯一的缓存 Key(用户+任务+日期+状态维度)。
* 2. 保存草稿:自动过滤未完成的附件,只存 meta_id/url 等元数据。
* 3. 读取草稿:支持过期检查(TTL 7天)。
* 4. 自动清理:支持惰性清理和全量扫描清理过期项。
*
* 使用场景:
* - CheckinDetailPage 自动保存与恢复。
*/
import { useRoute } from 'vue-router'
export function useCheckinDraft() {
const DRAFT_PREFIX = 'CHECKIN_DRAFT_V1'
const TTL_MS = 7 * 24 * 60 * 60 * 1000 // 7天过期
/**
* 判断功能是否开启
* @returns {boolean}
*/
const is_enabled = () => {
// 支持 URL 参数覆盖 (CheckinDetailPage 会用到 route,但此处为纯函数逻辑,依赖调用方传入或在组件内判断)
// 这里仅读取环境变量
// 实际使用时建议结合 route.query.enable_draft 判断
return import.meta.env.VITE_CHECKIN_DRAFT_CACHE === '1'
}
/**
* 构建缓存 Key
* @param {Object} context
* @param {string} context.user_id 用户ID
* @param {string} context.task_id 任务ID
* @param {string} context.date 日期
* @param {string} context.task_type 任务类型
* @param {string} context.status 状态 (create/edit)
* @returns {string} Key
*/
const build_key = ({ user_id, task_id, date, task_type, status }) => {
// 确保所有字段都存在,避免生成 undefined
const parts = [
user_id || 'guest',
task_id || 'default',
date || 'nodate',
task_type || 'default',
status || 'create'
]
return `${DRAFT_PREFIX}:${parts.join(':')}`
}
/**
* 保存草稿
* @param {string} key 缓存Key
* @param {Object} payload 业务数据
* @param {string} payload.message 文本
* @param {Array} payload.file_list 文件列表
* @param {string} payload.active_type 当前类型
* @param {string|number} payload.subtask_id 子任务ID
* @param {Array} payload.selected_task_value 选中的任务值
* @param {Object} payload.count 计数打卡数据
*/
const save_draft = (key, payload) => {
if (!key) return
try {
// 过滤附件:只保存已上传成功且有 meta_id 的
const validFiles = (payload.file_list || []).filter(item =>
item.status === 'done' && item.meta_id
).map(item => ({
meta_id: item.meta_id,
url: item.url,
name: item.name,
file_type: item.file_type
}))
const data = {
version: 1,
saved_at: Date.now(),
expires_at: Date.now() + TTL_MS,
payload: {
...payload,
file_list: validFiles
}
}
localStorage.setItem(key, JSON.stringify(data))
console.log(`[草稿缓存] 已保存: ${key}`, data.payload)
} catch (e) {
console.error('[草稿缓存] 保存失败:', e)
}
}
/**
* 读取草稿
* @param {string} key 缓存Key
* @returns {Object|null} 草稿数据或null
*/
const read_draft = (key) => {
if (!key) return null
try {
const raw = localStorage.getItem(key)
if (!raw) return null
const data = JSON.parse(raw)
// 检查版本
if (data.version !== 1) {
console.warn('[草稿缓存] 版本不匹配,丢弃旧数据')
clear_draft(key)
return null
}
// 检查过期
if (data.expires_at && Date.now() > data.expires_at) {
console.log('[草稿缓存] 数据已过期,自动清理')
clear_draft(key)
return null
}
return data
} catch (e) {
console.error('[草稿缓存] 读取失败:', e)
return null
}
}
/**
* 清除指定草稿
* @param {string} key
*/
const clear_draft = (key) => {
if (!key) return
localStorage.removeItem(key)
console.log(`[草稿缓存] 已清除: ${key}`)
}
/**
* 清理所有过期草稿
*/
const cleanup_expired = () => {
try {
const keysToRemove = []
const now = Date.now()
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key && key.startsWith(DRAFT_PREFIX)) {
try {
const raw = localStorage.getItem(key)
const data = JSON.parse(raw)
if (data.expires_at && now > data.expires_at) {
keysToRemove.push(key)
}
} catch (e) {
// 解析失败的脏数据也清理掉
keysToRemove.push(key)
}
}
}
keysToRemove.forEach(key => {
localStorage.removeItem(key)
console.log(`[草稿缓存] 自动清理过期项: ${key}`)
})
} catch (e) {
console.error('[草稿缓存] 批量清理失败:', e)
}
}
return {
is_enabled,
build_key,
save_draft,
read_draft,
clear_draft,
cleanup_expired
}
}
<!--
* @Date: 2025-09-30 17:05
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-24 16:39:39
* @LastEditTime: 2026-01-25 16:42:06
* @FilePath: /mlaj/src/views/checkin/CheckinDetailPage.vue
* @Description: 用户打卡详情页
-->
......@@ -182,21 +182,25 @@
</template>
<script setup>
import { ref, computed, onMounted, nextTick, reactive, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ref, computed, onMounted, nextTick, reactive, watch, onBeforeUnmount } from 'vue'
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
import { getTaskDetailAPI, getUploadTaskInfoAPI, getSubtaskListAPI, reuseGratitudeFormAPI } from "@/api/checkin"
import { useTitle } from '@vueuse/core'
import { useCheckin } from '@/composables/useCheckin'
import { useCheckinDraft } from '@/composables/useCheckinDraft'
import { useAuth } from '@/contexts/auth'
import { normalizeAttachmentTypeConfig } from '@/utils/tools'
import AudioPlayer from '@/components/media/AudioPlayer.vue'
import VideoPlayer from '@/components/media/VideoPlayer.vue'
import AddTargetDialog from '@/components/count/AddTargetDialog.vue'
import CheckinTargetList from '@/components/count/CheckinTargetList.vue'
import { showToast, showLoadingToast, showDialog } from 'vant'
import { showToast, showLoadingToast, showDialog, showConfirmDialog } from 'vant'
import dayjs from 'dayjs'
import { debounce } from 'lodash-es'
const route = useRoute()
const router = useRouter()
const { currentUser } = useAuth()
useTitle('提交作业')
// 使用打卡composable
......@@ -226,6 +230,246 @@ const {
gratitudeFormList
} = useCheckin()
// 使用草稿缓存composable
const {
is_enabled: isDraftEnabled,
build_key: buildDraftKey,
save_draft: saveDraft,
read_draft: readDraft,
clear_draft: clearDraft,
cleanup_expired: cleanupExpiredDrafts
} = useCheckinDraft()
// 草稿Key
const draftKey = computed(() => {
return buildDraftKey({
user_id: currentUser.value?.id,
task_id: route.query.task_id,
date: route.query.date,
task_type: route.query.task_type,
status: route.query.status || 'create'
})
})
// 动态字段文字
const dynamicFieldText = ref('感恩')
// 任务详情数据
const taskDetail = ref({})
// 作业选择相关
const showTaskPicker = ref(false)
const taskOptions = ref([])
// 上次打卡的感恩表单数据
const lastUsedTargetList = ref([])
// 动态表单字段 (默认值,实际会根据选择的作业动态更新)
const dynamicFormFields = ref([])
const personType = ref('') // 动态表单字段中的person_type
// 计数打卡相关逻辑
const countValue = ref(1)
const selectedTargets = ref([])
// Mock 老师数据
const targetList = ref([])
const showAddTargetDialog = ref(false)
const editingTarget = ref(null)
const isConfirmMode = ref(false) // 是否为确认模式(首次点击选中)
// 作品类型选项
const attachmentTypeOptions = ref([])
// 预览相关变量
const audioShow = ref(false)
const audioTitle = ref('')
const audioUrl = ref('')
const videoShow = ref(false)
const videoTitle = ref('')
const videoUrl = ref('')
const videoCover = ref('')
const isVideoPlaying = ref(false)
const videoPlayerRef = ref(null)
const imageShow = ref(false)
const imageList = ref([])
const imageIndex = ref(0)
/**
* 自动保存草稿
* @description 使用debounce防抖,避免频繁写入
*/
const autoSaveDraft = debounce(() => {
if (!isDraftEnabled() || route.query.status === 'edit') return
const payload = {
message: message.value,
active_type: activeType.value,
subtask_id: selectedTaskValue.value?.[0],
selected_task_value: selectedTaskValue.value,
file_list: fileList.value, // save_draft内部会过滤done状态
count: {
gratitude_count: countValue.value,
gratitude_form_list: selectedTargets.value
}
}
saveDraft(draftKey.value, payload)
}, 500)
// 监听数据变化触发自动保存
watch([message, fileList, selectedTaskValue, countValue, selectedTargets], () => {
autoSaveDraft()
}, { deep: true })
// 页面离开前强制保存一次
onBeforeRouteLeave(() => {
autoSaveDraft()
autoSaveDraft.flush() // 立即执行待处理的保存
})
// 页面卸载前保存(兼容刷新/关闭tab)
const handleBeforeUnload = () => {
autoSaveDraft()
autoSaveDraft.flush()
}
window.addEventListener('beforeunload', handleBeforeUnload)
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
})
/**
* 检查并恢复草稿
*/
const checkAndRestoreDraft = async () => {
if (!isDraftEnabled() || route.query.status === 'edit') return
// 先清理过期草稿
cleanupExpiredDrafts()
const draft = readDraft(draftKey.value)
if (!draft || !draft.payload) return
const { payload } = draft
// 检查是否有实质内容
const hasContent = (payload.message && payload.message.trim()) ||
(payload.file_list && payload.file_list.length > 0)
if (!hasContent) return
try {
await showConfirmDialog({
title: '发现未提交的草稿',
message: '上次编辑的内容未提交,是否恢复?',
confirmButtonText: '恢复',
cancelButtonText: '丢弃'
})
// 确认恢复
console.log('[草稿恢复] 开始恢复数据', payload)
if (payload.message) message.value = payload.message
if (payload.active_type) activeType.value = payload.active_type
// 恢复文件列表 (注意:只恢复了已完成上传的元数据,无法恢复File对象,所以无法继续上传/断点续传)
// 恢复后的文件状态设为done
if (payload.file_list && payload.file_list.length > 0) {
fileList.value = payload.file_list.map(f => ({
...f,
status: 'done',
message: '已上传'
}))
}
// 恢复选中的作业
if (payload.selected_task_value && payload.selected_task_value.length > 0) {
selectedTaskValue.value = payload.selected_task_value
// 触发联动逻辑 (如updateDynamicFormFields等),这部分在selectedTaskValue的watcher或onConfirmTask中处理
// 但这里直接赋值可能不会触发onConfirmTask的逻辑,需要手动处理部分联动
// 等待taskOptions加载完毕后再匹配text
const matchOption = () => {
const option = taskOptions.value.find(o => o.value === selectedTaskValue.value[0])
if (option) {
selectedTaskText.value = option.text
isMakeup.value = !!option.is_makeup
personType.value = option.person_type
updateDynamicFormFields(option)
if (option.attachment_type) updateAttachmentTypeOptions(option.attachment_type)
}
}
if (taskOptions.value.length > 0) {
matchOption()
} else {
// 如果选项还没加载完,watch taskOptions
const unwatch = watch(taskOptions, () => {
matchOption()
unwatch()
})
}
}
// 恢复计数
if (payload.count?.gratitude_count) {
countValue.value = payload.count.gratitude_count
}
// 恢复感恩列表(计数对象)
// 必须在恢复 selectedTaskValue 之后执行,因为 fetchTargetList 依赖 subtask_id
if (payload.count?.gratitude_form_list && Array.isArray(payload.count.gratitude_form_list) && payload.count.gratitude_form_list.length > 0) {
const savedList = payload.count.gratitude_form_list
// 如果有作业ID,先获取基础列表
if (selectedTaskValue.value && selectedTaskValue.value.length > 0) {
// 等待 fetchTargetList 完成,然后覆盖默认选中的项
await fetchTargetList(selectedTaskValue.value[0])
// 使用草稿中的列表覆盖 selectedTargets (注意去重或合并策略)
// 这里选择:完全信任草稿中的选中状态。
// 但需要注意:草稿中的对象可能只包含 id/name,而 targetList 中有完整信息。
// 最好是基于 targetList 重新构建 selectedTargets,如果 targetList 中没有(比如新增的),则直接使用草稿中的。
const restoredTargets = []
savedList.forEach(savedItem => {
// 尝试在 targetList 中找到对应项(获取最新状态/引用)
const existingItem = targetList.value.find(t =>
(savedItem.id && t.id && t.id == savedItem.id) ||
(!savedItem.id && savedItem.name === t.name)
)
if (existingItem) {
existingItem.has_confirmed = true // 既然都在草稿里了,肯定是确认过的
restoredTargets.push(existingItem)
} else {
// 如果 targetList 里没有(可能是新增的,或者 targetList 变了),则直接使用草稿项
restoredTargets.push({
...savedItem,
has_confirmed: true
})
// 同时也加到 targetList 里显示出来(如果是新增的)
targetList.value.push(restoredTargets[restoredTargets.length - 1])
}
})
selectedTargets.value = restoredTargets
} else {
// 如果没有作业ID(理论上不应该,因为计数打卡必须选作业),直接恢复
selectedTargets.value = savedList
}
}
showToast('已恢复草稿')
} catch (e) {
// 取消恢复,清除草稿
if (e !== 'cancel') console.error(e)
clearDraft(draftKey.value)
showToast('已丢弃草稿')
}
}
const beforeReadGuard = (file) => {
const files = Array.isArray(file) ? file : [file]
if (activeType.value === 'video') {
......@@ -335,11 +579,9 @@ const displayFileList = computed({
}
})
// 动态字段文字
const dynamicFieldText = ref('感恩')
// 任务详情数据
const taskDetail = ref({})
const maxFileSizeBytes = computed(() => {
const size = Number(maxFileSizeMb.value || 0)
......@@ -360,11 +602,7 @@ const displayTaskNote = computed(() => {
// 打卡类型
const taskType = computed(() => route.query.task_type)
// 作业选择相关
const showTaskPicker = ref(false)
const taskOptions = ref([])
// 上次打卡的感恩表单数据
const lastUsedTargetList = ref([])
const fetchTargetList = async (subtask_id) => {
const { code, data } = await reuseGratitudeFormAPI({ subtask_id })
......@@ -404,9 +642,7 @@ const fetchTargetList = async (subtask_id) => {
}
}
// 动态表单字段 (默认值,实际会根据选择的作业动态更新)
const dynamicFormFields = ref([])
const personType = ref('') // 动态表单字段中的person_type
/**
* 更新动态表单字段
......@@ -485,14 +721,7 @@ const onConfirmTask = async ({ selectedOptions }) => {
// }
// })
// 计数打卡相关逻辑
const countValue = ref(1)
const selectedTargets = ref([])
// Mock 老师数据
const targetList = ref([])
const showAddTargetDialog = ref(false)
const editingTarget = ref(null)
const isConfirmMode = ref(false) // 是否为确认模式(首次点击选中)
const toggleTarget = (item) => {
// 优先使用id匹配,如果id不存在,则使用name匹配
......@@ -670,28 +899,22 @@ const handleSubmit = async () => {
extraData.gratitude_count = countValue.value
}
await onSubmit(extraData)
// 提交成功后的回调,清除草稿
const onSuccess = () => {
if (isDraftEnabled()) {
clearDraft(draftKey.value)
}
}
await onSubmit(extraData, onSuccess)
}
// 作品类型选项
const attachmentTypeOptions = ref([])
// 是否为编辑模式
const isEditMode = computed(() => route.query.status === 'edit')
// 预览相关变量
const audioShow = ref(false)
const audioTitle = ref('')
const audioUrl = ref('')
const videoShow = ref(false)
const videoTitle = ref('')
const videoUrl = ref('')
const videoCover = ref('')
const isVideoPlaying = ref(false)
const videoPlayerRef = ref(null)
const imageShow = ref(false)
const imageList = ref([])
const imageIndex = ref(0)
/**
* 返回上一页
......@@ -1147,6 +1370,11 @@ onMounted(async () => {
countValue.value = val
}
})
// 尝试恢复草稿 (非编辑模式)
if (!isEditMode.value) {
await checkAndRestoreDraft()
}
})
</script>
......