Compare commits

...

1 Commits

Author SHA1 Message Date
小应 577f0c58c5 fix(title): 标题模板 v3 全面审计修复——解决选模板预览不变/新建保存变白等问题
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
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 / 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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 45s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 40s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 54s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 58s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1m1s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 5m0s
AI Code Review / AI Code Review (pull_request) Successful in 6m41s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 7m10s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 7m36s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 32s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 59s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 16m12s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
核心问题:
1. TitleTemplateEditor 独立调用 useTitleTemplates(),与 TitleStylePanel 内的 hook 实例形成双实例状态孤岛——保存时只更新 editor 实例的 state 和 localStorage,同 tab 下 storage 事件不触发,导致 TitleStylePanel 的 templates state 陈旧,卡片显示旧样式(新建模板选黄色保存后卡片变白)
2. TITLE_PRESETS 的 BASE 对象缺 position 字段,选预设后 position 被 DEFAULT_TITLE_SETTINGS_FULL.position="top" 覆盖,与预期默认位置 bottom 不一致
3. ColorPicker 的原生 color input 始终 width:0/height:0,即使展开自定义颜色也无法点击

修复内容:
- 移除 TitleTemplateEditor 中的 useTitleTemplates 调用,改为 onSave(data) 回调 props;所有 CRUD 统一由 TitleStylePanel 的 hook 实例完成,彻底解决双实例不同步
- handleRequestCreate 改为只构造草稿(空 id,不写入 localStorage),保存时再 createTemplate,避免打开编辑器即产生脏数据
- TITLE_PRESETS BASE 加 position: "bottom",预设默认位置一致
- ColorPicker 展开自定义时原生 input 设为 28x28 可点击,选色后自动收起
- 补充 package.json 中缺失的 classnames/dayjs 显式依赖(被代码引用但未声明)

验证:
- tsc --noEmit 零错误
- eslint 零警告
- prettier 格式化
2026-09-23 15:44:30 +08:00
5 changed files with 1722 additions and 3150 deletions
+2
View File
@@ -23,6 +23,8 @@
"@tanstack/react-query": "^5.45.0",
"antd": "^5.18.0",
"axios": "^1.7.2",
"classnames": "^2.5.1",
"dayjs": "^1.11.23",
"mp4box": "^2.4.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+1642 -3087
View File
File diff suppressed because it is too large Load Diff
@@ -16,21 +16,21 @@ import { FONT_OPTIONS } from "./constants"
import type { TitleSettings } from "../../pages/generate/types"
import { DEFAULT_TITLE_SETTINGS_FULL } from "../../pages/generate/types"
import { titleStyleConfigToCamel, camelToTitleStyleConfig } from "./utils"
import { useTitleTemplates } from "./useTitleTemplates"
import type { TitleTemplate } from "./template-types"
import type { TitleStyleConfig } from "./types"
interface Props {
open: boolean
template: TitleTemplate
onClose: () => void
onSaved: (template: TitleTemplate) => void
/** 用户点击保存:将编辑结果回调给父组件(父组件统一做 CRUD,避免双 hook 实例不同步) */
onSave: (data: { name: string; emoji: string; style: Partial<TitleStyleConfig> }) => void
}
/** 编辑器预览用的背景图(复用卡片池第一张) */
const EDITOR_BG = "/title-templates/portrait1.jpg"
const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSaved }) => {
const { updateTemplate, createTemplate } = useTitleTemplates()
const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSave }) => {
const [settings, setSettings] = useState<TitleSettings>(() => ({
...DEFAULT_TITLE_SETTINGS_FULL,
...titleStyleConfigToCamel(template.style || {}),
@@ -63,19 +63,7 @@ const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSaved
setSaving(true)
try {
const snake = camelToTitleStyleConfig(settings)
if (template.isBuiltin) {
const t = createTemplate({ name, emoji: formEmoji, style: snake })
onSaved(t)
} else {
updateTemplate(template.id, { name, emoji: formEmoji, style: snake })
onSaved({
...template,
name,
emoji: formEmoji,
style: snake,
updatedAt: new Date().toISOString(),
})
}
onSave({ name, emoji: formEmoji, style: snake })
} finally {
setSaving(false)
}
@@ -89,7 +77,13 @@ const TitleTemplateEditor: React.FC<Props> = ({ open, template, onClose, onSaved
return (
<Modal
title={template.isBuiltin ? `基于「${template.name}」创建模板` : `编辑模板:${template.name}`}
title={
!template.id
? "新建模板"
: template.isBuiltin
? `基于「${template.name}」创建模板`
: `编辑模板:${template.name}`
}
open={open}
onCancel={onClose}
width={860}
@@ -127,6 +127,7 @@ export interface TitlePreset {
}
const BASE: Partial<TitleStyleConfig> = {
position: "bottom",
line_overrides: [],
cover_title_config: null,
}
@@ -34,9 +34,14 @@ import { buildPresetPreviewSettings } from "@/components/title/utils"
import TitleMiniPreview from "./TitleMiniPreview"
import TitleTemplateEditor from "@/components/title/TitleTemplateEditor"
import { useTitleTemplates } from "@/components/title/useTitleTemplates"
import { titleStyleConfigToCamel, templateToPreviewSettings } from "@/components/title/utils"
import {
titleStyleConfigToCamel,
templateToPreviewSettings,
camelToTitleStyleConfig,
} from "@/components/title/utils"
import { DEFAULT_TITLE_SETTINGS_FULL } from "../../types"
import type { TitleTemplate } from "@/components/title/template-types"
import type { TitleStyleConfig } from "@/components/title/types"
import "./TitleStylePanel.css"
import "@/components/title/TitleTemplate.css"
@@ -146,13 +151,20 @@ const ColorPicker: React.FC<{
type="color"
className="ts-color-native"
value={value.startsWith("rgba") ? "#000000" : value}
onChange={(e) => onChange(e.target.value)}
onChange={(e) => {
onChange(e.target.value)
setCustomOpen(false)
}}
style={{
opacity: customOpen ? 1 : 0,
position: customOpen ? "static" : "absolute",
pointerEvents: customOpen ? "auto" : "none",
width: 0,
height: 0,
width: customOpen ? 28 : 0,
height: customOpen ? 28 : 0,
border: "none",
padding: 0,
cursor: "pointer",
background: "transparent",
}}
/>
</div>
@@ -231,8 +243,14 @@ const TitleStylePanel: React.FC<TitleStylePanelProps> = ({
const [coverOpen, setCoverOpen] = useState(!!settings.coverTitle)
/* ── 模板相关 hook + 编辑器 state ── */
const { templates, createTemplate, duplicateTemplate, deleteTemplate, exportTemplate } =
useTitleTemplates()
const {
templates,
createTemplate,
duplicateTemplate,
updateTemplate,
deleteTemplate,
exportTemplate,
} = useTitleTemplates()
const [editingTemplate, setEditingTemplate] = useState<TitleTemplate | null>(null)
const [editorOpen, setEditorOpen] = useState(false)
@@ -246,6 +264,7 @@ const TitleStylePanel: React.FC<TitleStylePanelProps> = ({
const handleRequestCreate = useCallback(() => {
// 新建模板:若已有选中模板,以其样式为起点;否则以当前 settings 为起点
// 注意:这里只构造「草稿」对象(不写入 localStorage),保存时再由 handleEditorSave 真正 createTemplate
let base: TitleSettings = settings
if (selectedTemplateId) {
const sel = templates.find((t) => t.id === selectedTemplateId)
@@ -259,40 +278,21 @@ const TitleStylePanel: React.FC<TitleStylePanelProps> = ({
}
}
}
const t = createTemplate({
const draft: TitleTemplate = {
id: "", // 空 id 表示新建草稿
name: "我的标题模板",
style: {
font: base.font,
size: base.size,
color: base.color,
bold: base.bold,
italic: base.italic,
stroke: base.stroke,
stroke_width: base.strokeWidth ?? 4,
stroke_color: base.strokeColor ?? "#000000",
shadow: base.shadow,
shadow_offset_x: base.shadowOffsetX ?? 2,
shadow_offset_y: base.shadowOffsetY ?? 2,
shadow_blur: base.shadowBlur ?? 4,
shadow_color: base.shadowColor ?? "rgba(0,0,0,0.8)",
bg_enabled: base.bgEnabled,
bg_color: base.bgColor,
bg_padding: base.bgPadding,
bg_radius: base.bgRadius,
line_height: base.lineHeight,
margin_top: base.marginTop,
max_chars_per_line: base.maxCharsPerLine,
position: (base.position === "custom" ? "bottom" : base.position) as
"top" | "center" | "bottom" | "custom",
pos_x: base.posX ?? undefined,
pos_y: base.posY ?? undefined,
line_overrides: [],
cover_title_config: null,
},
})
setEditingTemplate(t)
emoji: "✨",
isBuiltin: false,
style: camelToTitleStyleConfig({
...base,
position: base.position === "custom" ? "bottom" : base.position,
} as TitleSettings),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
setEditingTemplate(draft)
setEditorOpen(true)
}, [createTemplate, settings, selectedTemplateId, templates])
}, [settings, selectedTemplateId, templates])
const handleRequestEdit = useCallback((tpl: TitleTemplate) => {
setEditingTemplate(tpl)
@@ -342,14 +342,34 @@ const TitleStylePanel: React.FC<TitleStylePanelProps> = ({
[onApplyTemplate, settings],
)
const handleEditorSaved = useCallback(
(tpl: TitleTemplate) => {
const handleEditorSave = useCallback(
(data: { name: string; emoji: string; style: Partial<TitleStyleConfig> }) => {
if (!editingTemplate) return
let saved: TitleTemplate
if (editingTemplate.isBuiltin || !editingTemplate.id) {
// 系统模板编辑 → 创建副本;新草稿(空 id)→ 新建
saved = createTemplate({ name: data.name, emoji: data.emoji, style: data.style })
} else {
// 已有自定义模板 → 更新
updateTemplate(editingTemplate.id, {
name: data.name,
emoji: data.emoji,
style: data.style,
})
saved = {
...editingTemplate,
name: data.name,
emoji: data.emoji,
style: data.style,
updatedAt: new Date().toISOString(),
}
}
setEditorOpen(false)
setEditingTemplate(null)
message.success(`已保存:${tpl.name}`)
handleSelectTemplate(tpl)
message.success(`已保存:${data.name}`)
handleSelectTemplate(saved)
},
[handleSelectTemplate],
[editingTemplate, createTemplate, updateTemplate, handleSelectTemplate],
)
/* ── 渲染模板大卡片(v3:图片背景 + 透明 Canvas 叠字 + 底栏始终可见操作按钮) ── */
@@ -811,7 +831,7 @@ const TitleStylePanel: React.FC<TitleStylePanelProps> = ({
setEditorOpen(false)
setEditingTemplate(null)
}}
onSaved={handleEditorSaved}
onSave={handleEditorSave}
/>
)}
</div>
@@ -854,7 +874,7 @@ const TitleStylePanel: React.FC<TitleStylePanelProps> = ({
setEditorOpen(false)
setEditingTemplate(null)
}}
onSaved={handleEditorSaved}
onSave={handleEditorSave}
/>
)}
</div>