90e1460a1b
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 9s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 47s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m6s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 38s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m5s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m48s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 3m8s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m43s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m16s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 58s
CI/CD Pipeline / Integration Tests (push) Successful in 4m39s
CI/CD Pipeline / Validate - Style (push) Successful in 5m20s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m43s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m49s
AI Code Review / AI Code Review (pull_request) Successful in 6m58s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m14s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m50s
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 10m31s
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Unit Tests (push) Successful in 11m34s
CI/CD Pipeline / Validate - Security (push) Successful in 20m18s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
214 lines
6.1 KiB
TypeScript
214 lines
6.1 KiB
TypeScript
/**
|
||
* 标题模板 CRUD Hook(#2003)
|
||
*
|
||
* - 内存态 + localStorage 持久化
|
||
* - 系统模板(来自 TITLE_PRESETS)始终前置、不可删除/编辑源
|
||
* - 用户模板 CRUD:新增/复制/更新/删除/导出/导入
|
||
*/
|
||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||
import { TITLE_PRESETS } from "./constants"
|
||
import type { TitleStyleConfig } from "./types"
|
||
import type { TitleTemplate } from "./template-types"
|
||
|
||
const STORAGE_KEY = "xiaoxia.title.templates.v1"
|
||
|
||
function uid(): string {
|
||
return "usr:" + Math.random().toString(36).slice(2, 10) + Date.now().toString(36)
|
||
}
|
||
|
||
/** 把 TITLE_PRESETS 转为内置 TitleTemplate 列表 */
|
||
function buildBuiltinTemplates(): TitleTemplate[] {
|
||
const now = "2026-09-22T00:00:00+08:00"
|
||
return TITLE_PRESETS.map((p) => ({
|
||
id: `sys:${p.key}`,
|
||
name: p.label,
|
||
description: "系统内置模板",
|
||
isBuiltin: true,
|
||
emoji: p.emoji,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
style: { ...p.style },
|
||
}))
|
||
}
|
||
|
||
function loadUserTemplates(): TitleTemplate[] {
|
||
if (typeof window === "undefined") return []
|
||
try {
|
||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||
if (!raw) return []
|
||
const parsed = JSON.parse(raw)
|
||
if (!Array.isArray(parsed)) return []
|
||
return parsed.filter((t) => t && !t.isBuiltin && t.id.startsWith("usr:"))
|
||
} catch {
|
||
return []
|
||
}
|
||
}
|
||
|
||
function saveUserTemplates(list: TitleTemplate[]) {
|
||
if (typeof window === "undefined") return
|
||
try {
|
||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(list))
|
||
} catch {
|
||
/* ignore quota */
|
||
}
|
||
}
|
||
|
||
export interface UseTitleTemplatesResult {
|
||
templates: TitleTemplate[]
|
||
builtin: TitleTemplate[]
|
||
custom: TitleTemplate[]
|
||
/** 新建自定义模板:传入样式(snake_case),返回新建模板 id */
|
||
createTemplate: (input: {
|
||
name: string
|
||
description?: string
|
||
emoji?: string
|
||
style: Partial<TitleStyleConfig>
|
||
}) => TitleTemplate
|
||
/** 复制模板(内置模板也可复制,自动加"副本"后缀) */
|
||
duplicateTemplate: (id: string) => TitleTemplate | null
|
||
/** 更新自定义模板(系统模板不可改) */
|
||
updateTemplate: (id: string, patch: Partial<Omit<TitleTemplate, "id" | "isBuiltin">>) => void
|
||
/** 删除自定义模板 */
|
||
deleteTemplate: (id: string) => void
|
||
/** 导出单个模板为 JSON 字符串 */
|
||
exportTemplate: (id: string) => string | null
|
||
/** 导入 JSON 字符串作为新模板,返回新建模板 */
|
||
importTemplate: (json: string) => TitleTemplate | null
|
||
/** 根据 id 查询模板 */
|
||
getById: (id: string) => TitleTemplate | undefined
|
||
}
|
||
|
||
export function useTitleTemplates(): UseTitleTemplatesResult {
|
||
const [custom, setCustom] = useState<TitleTemplate[]>(() => loadUserTemplates())
|
||
|
||
// 跨 tab 同步
|
||
useEffect(() => {
|
||
const onStorage = (e: StorageEvent) => {
|
||
if (e.key === STORAGE_KEY) setCustom(loadUserTemplates())
|
||
}
|
||
window.addEventListener("storage", onStorage)
|
||
return () => window.removeEventListener("storage", onStorage)
|
||
}, [])
|
||
|
||
const builtin = useMemo(() => buildBuiltinTemplates(), [])
|
||
const templates = useMemo(() => [...builtin, ...custom], [builtin, custom])
|
||
|
||
const persist = useCallback((next: TitleTemplate[]) => {
|
||
setCustom(next)
|
||
saveUserTemplates(next)
|
||
}, [])
|
||
|
||
const getById = useCallback((id: string) => templates.find((t) => t.id === id), [templates])
|
||
|
||
const createTemplate = useCallback<UseTitleTemplatesResult["createTemplate"]>(
|
||
({ name, description, emoji, style }) => {
|
||
const now = new Date().toISOString()
|
||
const t: TitleTemplate = {
|
||
id: uid(),
|
||
name: name.trim() || "未命名模板",
|
||
description: description?.trim() || undefined,
|
||
emoji: emoji || "✨",
|
||
isBuiltin: false,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
style: { ...style },
|
||
}
|
||
persist([...custom, t])
|
||
return t
|
||
},
|
||
[custom, persist],
|
||
)
|
||
|
||
const duplicateTemplate = useCallback<UseTitleTemplatesResult["duplicateTemplate"]>(
|
||
(id) => {
|
||
const src = templates.find((t) => t.id === id)
|
||
if (!src) return null
|
||
const now = new Date().toISOString()
|
||
const t: TitleTemplate = {
|
||
id: uid(),
|
||
name: `${src.name} 副本`,
|
||
description: src.description,
|
||
emoji: src.emoji,
|
||
isBuiltin: false,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
style: { ...src.style },
|
||
}
|
||
persist([...custom, t])
|
||
return t
|
||
},
|
||
[templates, custom, persist],
|
||
)
|
||
|
||
const updateTemplate = useCallback<UseTitleTemplatesResult["updateTemplate"]>(
|
||
(id, patch) => {
|
||
if (id.startsWith("sys:")) return
|
||
const next = custom.map((t) =>
|
||
t.id === id
|
||
? { ...t, ...patch, id: t.id, isBuiltin: false, updatedAt: new Date().toISOString() }
|
||
: t,
|
||
)
|
||
persist(next)
|
||
},
|
||
[custom, persist],
|
||
)
|
||
|
||
const deleteTemplate = useCallback<UseTitleTemplatesResult["deleteTemplate"]>(
|
||
(id) => {
|
||
if (id.startsWith("sys:")) return
|
||
persist(custom.filter((t) => t.id !== id))
|
||
},
|
||
[custom, persist],
|
||
)
|
||
|
||
const exportTemplate = useCallback<UseTitleTemplatesResult["exportTemplate"]>(
|
||
(id) => {
|
||
const t = templates.find((x) => x.id === id)
|
||
if (!t) return null
|
||
return JSON.stringify(
|
||
{
|
||
name: t.name,
|
||
description: t.description,
|
||
emoji: t.emoji,
|
||
style: t.style,
|
||
exportedAt: new Date().toISOString(),
|
||
},
|
||
null,
|
||
2,
|
||
)
|
||
},
|
||
[templates],
|
||
)
|
||
|
||
const importTemplate = useCallback<UseTitleTemplatesResult["importTemplate"]>(
|
||
(json) => {
|
||
try {
|
||
const data = JSON.parse(json)
|
||
if (!data || typeof data !== "object" || !data.style) return null
|
||
return createTemplate({
|
||
name: data.name || "导入模板",
|
||
description: data.description,
|
||
emoji: data.emoji || "✨",
|
||
style: data.style,
|
||
})
|
||
} catch {
|
||
return null
|
||
}
|
||
},
|
||
[createTemplate],
|
||
)
|
||
|
||
return {
|
||
templates,
|
||
builtin,
|
||
custom,
|
||
createTemplate,
|
||
duplicateTemplate,
|
||
updateTemplate,
|
||
deleteTemplate,
|
||
exportTemplate,
|
||
importTemplate,
|
||
getById,
|
||
}
|
||
}
|