1. 排行榜悬浮窗关闭后小按钮消失问题 - 修复面板居中模式关闭时按钮位置错误 - 保存并恢复按钮的居中状态 2. 首页tag按钮按素材数量排序 - 添加按素材数量降序排序逻辑 3. 上传弹窗输入框浅色模式文字不可见 - 添加 text-slate-900 确保浅色模式下文字可见 - 添加 placeholder 样式确保占位符正确显示
396 lines
9.5 KiB
TypeScript
396 lines
9.5 KiB
TypeScript
/**
|
||
* 草稿自动保存工具
|
||
*
|
||
* 功能:
|
||
* - 表单填写时自动保存到localStorage
|
||
* - 页面刷新时提示恢复数据
|
||
* - 提交失败时保留用户输入
|
||
* - 检测多标签页冲突
|
||
* - 自动清理过期草稿
|
||
*/
|
||
|
||
/**
|
||
* 草稿数据结构
|
||
*/
|
||
export interface DraftData<T = Record<string, unknown>> {
|
||
id: string // 草稿唯一标识
|
||
formType: string // 表单类型(如 'material-upload', 'template-edit')
|
||
data: T // 表单数据
|
||
createdAt: string // 创建时间
|
||
updatedAt: string // 最后更新时间
|
||
tabId: string // 创建此草稿的标签页ID
|
||
version: number // 版本号(用于冲突检测)
|
||
}
|
||
|
||
/**
|
||
* 草稿存储配置
|
||
*/
|
||
export interface DraftStorageConfig {
|
||
prefix?: string // localStorage key前缀
|
||
maxAge?: number // 草稿最大保存时间(毫秒),默认7天
|
||
autoSaveInterval?: number // 自动保存间隔(毫秒),默认3秒
|
||
maxDrafts?: number // 最大草稿数量,默认10
|
||
}
|
||
|
||
const DEFAULT_CONFIG: Required<DraftStorageConfig> = {
|
||
prefix: 'flnxi_draft_',
|
||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7天
|
||
autoSaveInterval: 3000, // 3秒
|
||
maxDrafts: 10,
|
||
}
|
||
|
||
/**
|
||
* 生成唯一的标签页ID
|
||
*/
|
||
export function generateTabId(): string {
|
||
// 使用sessionStorage存储标签页ID,确保每个标签页有唯一ID
|
||
const storageKey = 'flnxi_tab_id'
|
||
|
||
if (typeof window === 'undefined') {
|
||
return 'server'
|
||
}
|
||
|
||
let tabId = sessionStorage.getItem(storageKey)
|
||
if (!tabId) {
|
||
tabId = `tab_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`
|
||
sessionStorage.setItem(storageKey, tabId)
|
||
}
|
||
return tabId
|
||
}
|
||
|
||
/**
|
||
* 生成草稿ID
|
||
*/
|
||
export function generateDraftId(formType: string, entityId?: string): string {
|
||
if (entityId) {
|
||
return `${formType}_${entityId}`
|
||
}
|
||
return `${formType}_new_${Date.now()}`
|
||
}
|
||
|
||
/**
|
||
* 获取localStorage key
|
||
*/
|
||
function getStorageKey(draftId: string, config: DraftStorageConfig): string {
|
||
const prefix = config.prefix || DEFAULT_CONFIG.prefix
|
||
return `${prefix}${draftId}`
|
||
}
|
||
|
||
/**
|
||
* 保存草稿
|
||
*/
|
||
export function saveDraft<T>(
|
||
draftId: string,
|
||
formType: string,
|
||
data: T,
|
||
config: DraftStorageConfig = {}
|
||
): { success: boolean; error?: string } {
|
||
if (typeof window === 'undefined') {
|
||
return { success: false, error: '服务端不支持草稿保存' }
|
||
}
|
||
|
||
try {
|
||
const key = getStorageKey(draftId, config)
|
||
const tabId = generateTabId()
|
||
|
||
// 检查是否存在其他标签页的草稿
|
||
const existingDraft = getDraft<T>(draftId, config)
|
||
let version = 1
|
||
|
||
if (existingDraft && existingDraft.tabId !== tabId) {
|
||
// 其他标签页的草稿,增加版本号
|
||
version = existingDraft.version + 1
|
||
} else if (existingDraft) {
|
||
version = existingDraft.version
|
||
}
|
||
|
||
const draft: DraftData<T> = {
|
||
id: draftId,
|
||
formType,
|
||
data,
|
||
createdAt: existingDraft?.createdAt || new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
tabId,
|
||
version,
|
||
}
|
||
|
||
localStorage.setItem(key, JSON.stringify(draft))
|
||
|
||
// 清理过期草稿
|
||
cleanExpiredDrafts(config)
|
||
|
||
return { success: true }
|
||
} catch (error) {
|
||
console.error('[DraftStorage] Save failed:', error)
|
||
return {
|
||
success: false,
|
||
error: error instanceof Error ? error.message : '保存草稿失败'
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取草稿
|
||
*/
|
||
export function getDraft<T>(
|
||
draftId: string,
|
||
config: DraftStorageConfig = {}
|
||
): DraftData<T> | null {
|
||
if (typeof window === 'undefined') {
|
||
return null
|
||
}
|
||
|
||
try {
|
||
const key = getStorageKey(draftId, config)
|
||
const stored = localStorage.getItem(key)
|
||
|
||
if (!stored) {
|
||
return null
|
||
}
|
||
|
||
const draft = JSON.parse(stored) as DraftData<T>
|
||
|
||
// 检查是否过期
|
||
const maxAge = config.maxAge || DEFAULT_CONFIG.maxAge
|
||
const updatedAt = new Date(draft.updatedAt).getTime()
|
||
if (Date.now() - updatedAt > maxAge) {
|
||
// 草稿已过期,删除并返回null
|
||
deleteDraft(draftId, config)
|
||
return null
|
||
}
|
||
|
||
return draft
|
||
} catch (error) {
|
||
console.error('[DraftStorage] Get failed:', error)
|
||
return null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 删除草稿
|
||
*/
|
||
export function deleteDraft(
|
||
draftId: string,
|
||
config: DraftStorageConfig = {}
|
||
): boolean {
|
||
if (typeof window === 'undefined') {
|
||
return false
|
||
}
|
||
|
||
try {
|
||
const key = getStorageKey(draftId, config)
|
||
localStorage.removeItem(key)
|
||
return true
|
||
} catch (error) {
|
||
console.error('[DraftStorage] Delete failed:', error)
|
||
return false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检查是否存在草稿
|
||
*/
|
||
export function hasDraft(
|
||
draftId: string,
|
||
config: DraftStorageConfig = {}
|
||
): boolean {
|
||
return getDraft(draftId, config) !== null
|
||
}
|
||
|
||
/**
|
||
* 检测多标签页冲突
|
||
*/
|
||
export function checkConflict<T>(
|
||
draftId: string,
|
||
config: DraftStorageConfig = {}
|
||
): { hasConflict: boolean; otherTabDraft?: DraftData<T> } {
|
||
if (typeof window === 'undefined') {
|
||
return { hasConflict: false }
|
||
}
|
||
|
||
const draft = getDraft<T>(draftId, config)
|
||
if (!draft) {
|
||
return { hasConflict: false }
|
||
}
|
||
|
||
const currentTabId = generateTabId()
|
||
if (draft.tabId !== currentTabId) {
|
||
return { hasConflict: true, otherTabDraft: draft }
|
||
}
|
||
|
||
return { hasConflict: false }
|
||
}
|
||
|
||
/**
|
||
* 获取所有草稿
|
||
*/
|
||
export function getAllDrafts<T>(
|
||
formType?: string,
|
||
config: DraftStorageConfig = {}
|
||
): DraftData<T>[] {
|
||
if (typeof window === 'undefined') {
|
||
return []
|
||
}
|
||
|
||
const prefix = config.prefix || DEFAULT_CONFIG.prefix
|
||
const drafts: DraftData<T>[] = []
|
||
|
||
try {
|
||
for (let i = 0; i < localStorage.length; i++) {
|
||
const key = localStorage.key(i)
|
||
if (key && key.startsWith(prefix)) {
|
||
const stored = localStorage.getItem(key)
|
||
if (stored) {
|
||
try {
|
||
const draft = JSON.parse(stored) as DraftData<T>
|
||
if (!formType || draft.formType === formType) {
|
||
drafts.push(draft)
|
||
}
|
||
} catch {
|
||
// 忽略解析错误
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('[DraftStorage] GetAll failed:', error)
|
||
}
|
||
|
||
// 按更新时间排序
|
||
return drafts.sort((a, b) =>
|
||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 清理过期草稿
|
||
*/
|
||
export function cleanExpiredDrafts(config: DraftStorageConfig = {}): number {
|
||
if (typeof window === 'undefined') {
|
||
return 0
|
||
}
|
||
|
||
const prefix = config.prefix || DEFAULT_CONFIG.prefix
|
||
const maxAge = config.maxAge || DEFAULT_CONFIG.maxAge
|
||
const maxDrafts = config.maxDrafts || DEFAULT_CONFIG.maxDrafts
|
||
const now = Date.now()
|
||
let cleanedCount = 0
|
||
|
||
try {
|
||
const allDrafts: Array<{ key: string; updatedAt: number }> = []
|
||
|
||
// 收集所有草稿
|
||
for (let i = 0; i < localStorage.length; i++) {
|
||
const key = localStorage.key(i)
|
||
if (key && key.startsWith(prefix)) {
|
||
const stored = localStorage.getItem(key)
|
||
if (stored) {
|
||
try {
|
||
const draft = JSON.parse(stored) as DraftData
|
||
const updatedAt = new Date(draft.updatedAt).getTime()
|
||
|
||
// 删除过期草稿
|
||
if (now - updatedAt > maxAge) {
|
||
localStorage.removeItem(key)
|
||
cleanedCount++
|
||
} else {
|
||
allDrafts.push({ key, updatedAt })
|
||
}
|
||
} catch {
|
||
// 删除无效数据
|
||
localStorage.removeItem(key)
|
||
cleanedCount++
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 如果草稿数量超过限制,删除最旧的
|
||
if (allDrafts.length > maxDrafts) {
|
||
allDrafts.sort((a, b) => b.updatedAt - a.updatedAt)
|
||
const toRemove = allDrafts.slice(maxDrafts)
|
||
for (const item of toRemove) {
|
||
localStorage.removeItem(item.key)
|
||
cleanedCount++
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('[DraftStorage] Clean failed:', error)
|
||
}
|
||
|
||
return cleanedCount
|
||
}
|
||
|
||
/**
|
||
* 清除所有草稿
|
||
*/
|
||
export function clearAllDrafts(
|
||
formType?: string,
|
||
config: DraftStorageConfig = {}
|
||
): number {
|
||
if (typeof window === 'undefined') {
|
||
return 0
|
||
}
|
||
|
||
const prefix = config.prefix || DEFAULT_CONFIG.prefix
|
||
let clearedCount = 0
|
||
|
||
try {
|
||
const keysToRemove: string[] = []
|
||
|
||
for (let i = 0; i < localStorage.length; i++) {
|
||
const key = localStorage.key(i)
|
||
if (key && key.startsWith(prefix)) {
|
||
if (formType) {
|
||
const stored = localStorage.getItem(key)
|
||
if (stored) {
|
||
try {
|
||
const draft = JSON.parse(stored) as DraftData
|
||
if (draft.formType === formType) {
|
||
keysToRemove.push(key)
|
||
}
|
||
} catch {
|
||
keysToRemove.push(key)
|
||
}
|
||
}
|
||
} else {
|
||
keysToRemove.push(key)
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const key of keysToRemove) {
|
||
localStorage.removeItem(key)
|
||
clearedCount++
|
||
}
|
||
} catch (error) {
|
||
console.error('[DraftStorage] ClearAll failed:', error)
|
||
}
|
||
|
||
return clearedCount
|
||
}
|
||
|
||
/**
|
||
* 格式化草稿时间
|
||
*/
|
||
export function formatDraftTime(isoString: string): string {
|
||
const date = new Date(isoString)
|
||
const now = new Date()
|
||
const diffMs = now.getTime() - date.getTime()
|
||
const diffMins = Math.floor(diffMs / 60000)
|
||
const diffHours = Math.floor(diffMs / 3600000)
|
||
const diffDays = Math.floor(diffMs / 86400000)
|
||
|
||
if (diffMins < 1) {
|
||
return '刚刚'
|
||
} else if (diffMins < 60) {
|
||
return `${diffMins}分钟前`
|
||
} else if (diffHours < 24) {
|
||
return `${diffHours}小时前`
|
||
} else if (diffDays < 7) {
|
||
return `${diffDays}天前`
|
||
} else {
|
||
return date.toLocaleDateString('zh-CN')
|
||
}
|
||
}
|