From c8779eb8b74158e581bcdd5431f215e2d828f4cd Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 9 Aug 2026 17:02:38 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20AI=20Code=20Review?= =?UTF-8?q?=20=E9=98=BB=E5=A1=9E=E7=BA=A7=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 迁移 seed 数据 ID 改用 UUID hex 格式,与领域模型 CoverTemplate.create_system 一致 - 统一 InMemory 和 SQLAlchemy 排序逻辑(系统模板优先) --- alembic/versions/055_cover_templates.py | 16 +- apps/web/src/api/cover-templates.ts | 53 ++++++ .../components/Step6CoverSettings.tsx | 161 +++++++---------- .../cover-settings/CoverSettingsModal.tsx | 112 ++++++++++++ .../src/pages/generate/hooks/useStep6Cover.ts | 165 ++++++++++++------ .../in_memory/cover_template_repository.py | 2 +- tests/unit/test_cover_templates_crud.py | 4 +- 7 files changed, 350 insertions(+), 163 deletions(-) create mode 100644 apps/web/src/api/cover-templates.ts create mode 100644 apps/web/src/pages/generate/components/cover-settings/CoverSettingsModal.tsx diff --git a/alembic/versions/055_cover_templates.py b/alembic/versions/055_cover_templates.py index e1a9df1c2..f126c255a 100644 --- a/alembic/versions/055_cover_templates.py +++ b/alembic/versions/055_cover_templates.py @@ -22,14 +22,14 @@ branch_labels = None depends_on = None SYSTEM_TEMPLATES = [ - ("sys_default", "默认模板", {}), - ("sys_bold_red", "大胆红", {"background_color": "#ef4444"}), - ("sys_elegant_black", "优雅黑", {"background_color": "#111827"}), - ("sys_gradient_blue", "渐变蓝", {"background_color": "#3b82f6"}), - ("sys_gradient_purple", "渐变紫", {"background_color": "#8b5cf6"}), - ("sys_warm_orange", "暖橙", {"background_color": "#f97316"}), - ("sys_fresh_green", "清新绿", {"background_color": "#22c55e"}), - ("sys_tech_blue", "科技蓝", {"background_color": "#06b6d4"}), + ("a8b0120fd98e44788f5a6590f983d327", "默认模板", {}), + ("6d8c501b11424432b3df3a45ae89b1a9", "大胆红", {"background_color": "#ef4444"}), + ("04937fb57fea4bad95e7883e71a6b246", "优雅黑", {"background_color": "#111827"}), + ("3ff9cc821174437ca53931073e7f536e", "渐变蓝", {"background_color": "#3b82f6"}), + ("db51b3ea8f1a4f4caa94bf2d51f27d11", "渐变紫", {"background_color": "#8b5cf6"}), + ("5027d113432a4f798a3b4ee1644d66af", "暖橙", {"background_color": "#f97316"}), + ("0e10def2b5a148d686416494474726c2", "清新绿", {"background_color": "#22c55e"}), + ("38ea98ac00c04bada064006d880546f0", "科技蓝", {"background_color": "#06b6d4"}), ] diff --git a/apps/web/src/api/cover-templates.ts b/apps/web/src/api/cover-templates.ts new file mode 100644 index 000000000..87ec268fe --- /dev/null +++ b/apps/web/src/api/cover-templates.ts @@ -0,0 +1,53 @@ +/** + * 封面模板 CRUD API + * 后端路由: /api/v1/cover-templates + */ +import apiClient from "./client" +import type { CoverTemplate } from "@/pages/editing-planner/types" + +export interface CoverTemplateListResponse { + items: CoverTemplate[] + total: number +} + +export interface CoverTemplateCreateRequest { + name: string + config?: { + background_enabled?: boolean + background_color?: string + portrait_enabled?: boolean + title_text?: string + subtitle_text?: string + mask_enabled?: boolean + } +} + +export type CoverTemplateUpdateRequest = Partial + +/** 获取封面模板列表 */ +export async function fetchCoverTemplates(): Promise { + const response = await apiClient.get("/cover-templates") + return response.data +} + +/** 创建封面模板 */ +export async function createCoverTemplate( + data: CoverTemplateCreateRequest, +): Promise { + const response = await apiClient.post("/cover-templates", data) + return response.data +} + +/** 更新封面模板 */ +export async function updateCoverTemplate( + id: string, + data: CoverTemplateUpdateRequest, +): Promise { + const response = await apiClient.put(`/cover-templates/${id}`, data) + return response.data +} + +/** 删除封面模板(系统模板不可删) */ +export async function deleteCoverTemplate(id: string): Promise { + await apiClient.delete(`/cover-templates/${id}`) +} diff --git a/apps/web/src/pages/generate/components/Step6CoverSettings.tsx b/apps/web/src/pages/generate/components/Step6CoverSettings.tsx index c0a4e9fef..b112cadff 100755 --- a/apps/web/src/pages/generate/components/Step6CoverSettings.tsx +++ b/apps/web/src/pages/generate/components/Step6CoverSettings.tsx @@ -1,9 +1,9 @@ -import React, { useEffect, useRef } from "react" +import React from "react" import type { CoverConfig } from "../../editing-planner/types" import { useStep6Cover } from "../hooks/useStep6Cover" -import { CoverModeSelector } from "./cover-settings/CoverModeSelector" -import { FrameCoverPicker } from "./cover-settings/FrameCoverPicker" -import { UploadCoverPicker } from "./cover-settings/UploadCoverPicker" +import Button from "@/components/ui/Button" +import CoverSettingsModal from "./cover-settings/CoverSettingsModal" +import CoverEditorModal from "./cover-settings/CoverEditorModal" interface Step6CoverSettingsProps { coverSettings: CoverConfig @@ -18,15 +18,21 @@ interface Step6CoverSettingsProps { const Step6CoverSettings: React.FC = (props) => { const { coverSettings, - formatTime, - toggleEnabled, - setMode, - setFrameTime, - handleUpload, generateAutoCover, - totalDuration, - COVER_MODE_LABELS, - COVER_MODE_ICONS, + showCoverSettings, + setShowCoverSettings, + showCoverEditor, + setShowCoverEditor, + selectedTemplateId, + selectedTemplateName, + editingTemplate, + coverTemplates, + templatesLoading, + templatesError, + handleSelectTemplate, + handleEditTemplate, + handleSaveTemplate, + handleDeleteTemplate, } = useStep6Cover({ coverSettings: props.coverSettings, onCoverSettingsChange: props.onCoverSettingsChange, @@ -35,32 +41,9 @@ const Step6CoverSettings: React.FC = (props) => { selectedTemplate: props.selectedTemplate, }) - // 进入 auto 模式时自动触发智能封面生成 - const autoTriggeredRef = useRef(false) - useEffect(() => { - // 切换模式、禁用封面或素材变更时重置触发标记 - if (coverSettings.mode !== "auto" || !coverSettings.enabled) { - autoTriggeredRef.current = false - return - } - // 有素材且未生成过封面时自动触发 - if ( - coverSettings.mode === "auto" && - !coverSettings.thumbnail_url && - !autoTriggeredRef.current && - props.assetIds && - props.assetIds.length > 0 - ) { - autoTriggeredRef.current = true - generateAutoCover() - } - }, [ - coverSettings.enabled, - coverSettings.mode, - coverSettings.thumbnail_url, - generateAutoCover, - props.assetIds, - ]) + const handleAutoGenerate = () => { + generateAutoCover() + } // 预览图:优先 thumbnail_url,其次 upload_url const previewUrl = coverSettings.thumbnail_url || coverSettings.upload_url @@ -69,70 +52,52 @@ const Step6CoverSettings: React.FC = (props) => {

🖼️ 选择封面

-
- 启用自定义封面 - +
+ +
- {coverSettings.enabled && ( - <> -
封面来源
- +
已选模板: {selectedTemplateName}
- {coverSettings.mode === "auto" && ( -
-
- 🤖 - AI 智能选帧 -
-
- )} - - {coverSettings.mode === "frame" && ( - - )} - - {coverSettings.mode === "upload" && ( - - )} - -
封面预览
-
- {previewUrl ? ( - 封面预览 - ) : ( -
- 🖼️ - - {coverSettings.mode === "auto" - ? "AI 正在选择..." - : coverSettings.mode === "frame" - ? `帧 ${formatTime(coverSettings.frame_time)}` - : "未上传封面"} - -
- )} -
9:16
+
封面预览
+
+ {previewUrl ? ( + 封面预览 + ) : ( +
+ 🖼️ + 点击"自动生成封面"或选择模板
- - )} + )} +
9:16
+
+ + setShowCoverSettings(false)} + templates={coverTemplates} + loading={templatesLoading} + error={templatesError} + selectedTemplateId={selectedTemplateId} + onSelectTemplate={handleSelectTemplate} + onEditTemplate={handleEditTemplate} + onDeleteTemplate={handleDeleteTemplate} + onCreateNew={() => { + setShowCoverSettings(false) + setShowCoverEditor(true) + }} + /> + + setShowCoverEditor(false)} + template={editingTemplate} + onSave={handleSaveTemplate} + />
) } diff --git a/apps/web/src/pages/generate/components/cover-settings/CoverSettingsModal.tsx b/apps/web/src/pages/generate/components/cover-settings/CoverSettingsModal.tsx new file mode 100644 index 000000000..9ca2fa3c6 --- /dev/null +++ b/apps/web/src/pages/generate/components/cover-settings/CoverSettingsModal.tsx @@ -0,0 +1,112 @@ +import React from "react" +import type { CoverTemplate } from "../../../editing-planner/types" +import Modal from "@/components/ui/Modal" +import Button from "@/components/ui/Button" + +interface CoverSettingsModalProps { + open: boolean + onClose: () => void + templates: CoverTemplate[] + loading?: boolean + error?: string | null + selectedTemplateId: string + onSelectTemplate: (id: string) => void + onEditTemplate: (template: CoverTemplate) => void + onDeleteTemplate: (id: string) => void + onCreateNew: () => void +} + +const GRADIENT_MAP: Record = { + default: "linear-gradient(135deg, #e0e0e0, #c0c0c0)", + "bold-red": "linear-gradient(135deg, #ef4444, #b91c1c)", + "elegant-black": "linear-gradient(135deg, #374151, #111827)", + "gradient-blue": "linear-gradient(135deg, #3b82f6, #1d4ed8)", + "gradient-purple": "linear-gradient(135deg, #8b5cf6, #6d28d9)", + "warm-orange": "linear-gradient(135deg, #f97316, #ea580c)", + "fresh-green": "linear-gradient(135deg, #22c55e, #15803d)", + "tech-blue": "linear-gradient(135deg, #06b6d4, #0e7490)", +} + +const CoverSettingsModal: React.FC = ({ + open, + onClose, + templates, + loading = false, + error = null, + selectedTemplateId, + onSelectTemplate, + onEditTemplate, + onDeleteTemplate, + onCreateNew, +}) => { + return ( + +
+ + + +
+ + {loading && ( +
+ 加载中... +
+ )} + + {error && !loading && ( +
{error}
+ )} + + {!loading && !error && ( +
+ {templates.map((tpl) => ( +
onSelectTemplate(tpl.id)} + > +
+ 🖼️ +
+
+
+ {tpl.name} + {tpl.is_system && ✨ 系统模板} +
+
{tpl.created_at}
+
e.stopPropagation()}> + + {!tpl.is_system && ( + + )} + +
+
+
+ ))} +
+ )} +
+ ) +} + +export default CoverSettingsModal diff --git a/apps/web/src/pages/generate/hooks/useStep6Cover.ts b/apps/web/src/pages/generate/hooks/useStep6Cover.ts index 4dd3781cb..b42210b2c 100644 --- a/apps/web/src/pages/generate/hooks/useStep6Cover.ts +++ b/apps/web/src/pages/generate/hooks/useStep6Cover.ts @@ -1,11 +1,16 @@ /** * Step 6 封面设置 Hook - * 封装封面设置的交互逻辑 + * 封装封面设置的交互逻辑,对接后端封面模板 CRUD API */ -import { useCallback, useRef } from "react" -import type { CoverConfig } from "../../editing-planner/types" -import { COVER_MODE_LABELS, COVER_MODE_ICONS, DEFAULT_COVER_SETTINGS } from "../constants" +import { useCallback, useEffect, useRef, useState } from "react" +import type { CoverConfig, CoverTemplate } from "../../editing-planner/types" import { generateCover } from "@/api/template-editor" +import { + fetchCoverTemplates, + createCoverTemplate, + updateCoverTemplate, + deleteCoverTemplate, +} from "@/api/cover-templates" interface UseStep6CoverProps { coverSettings: CoverConfig @@ -26,50 +31,38 @@ export function useStep6Cover({ }: UseStep6CoverProps) { const generatingRef = useRef(false) - const formatTime = useCallback((seconds: number) => { - const m = Math.floor(seconds / 60) - const s = Math.floor(seconds % 60) - const ms = Math.floor((seconds % 1) * 10) - return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}` + // ── 封面设置弹窗状态 ── + const [showCoverSettings, setShowCoverSettings] = useState(false) + const [showCoverEditor, setShowCoverEditor] = useState(false) + const [selectedTemplateId, setSelectedTemplateId] = useState("default") + const [editingTemplate, setEditingTemplate] = useState(null) + const [coverTemplates, setCoverTemplates] = useState([]) + + // ── API 加载状态 ── + const [templatesLoading, setTemplatesLoading] = useState(false) + const [templatesError, setTemplatesError] = useState(null) + + /** 从后端加载封面模板列表 */ + const loadTemplates = useCallback(async () => { + setTemplatesLoading(true) + setTemplatesError(null) + try { + const res = await fetchCoverTemplates() + setCoverTemplates(res.items || []) + } catch (err) { + console.error("[Step6] 加载封面模板失败:", err) + setTemplatesError("加载模板失败,请稍后重试") + } finally { + setTemplatesLoading(false) + } }, []) - const toggleEnabled = useCallback( - (enabled: boolean) => { - onCoverSettingsChange({ ...coverSettings, enabled }) - }, - [coverSettings, onCoverSettingsChange], - ) - - const setMode = useCallback( - (mode: CoverConfig["mode"]) => { - onCoverSettingsChange({ ...coverSettings, mode }) - }, - [coverSettings, onCoverSettingsChange], - ) - - const setFrameTime = useCallback( - (frameTime: number) => { - onCoverSettingsChange({ ...coverSettings, frame_time: frameTime }) - }, - [coverSettings, onCoverSettingsChange], - ) - - const handleUpload = useCallback( - (file: File) => { - const reader = new FileReader() - reader.onload = (ev) => { - const url = ev.target?.result as string - onCoverSettingsChange({ - ...coverSettings, - upload_url: url, - thumbnail_url: url, - mode: "upload", - }) - } - reader.readAsDataURL(file) - }, - [coverSettings, onCoverSettingsChange], - ) + /** 弹窗打开时加载模板列表 */ + useEffect(() => { + if (showCoverSettings) { + loadTemplates() + } + }, [showCoverSettings, loadTemplates]) /** 调用后端智能封面 API,生成封面并更新预览 */ const generateAutoCover = useCallback(async () => { @@ -95,20 +88,84 @@ export function useStep6Cover({ } }, [selectedTemplate, assetIds, coverSettings, onCoverSettingsChange]) + // ── 模板操作方法 ── + const handleSelectTemplate = useCallback((id: string) => { + setSelectedTemplateId(id) + }, []) + + const handleEditTemplate = useCallback((tpl: CoverTemplate) => { + setEditingTemplate(tpl) + setShowCoverEditor(true) + }, []) + + /** 保存模板(创建或更新) */ + const handleSaveTemplate = useCallback( + async (tpl: CoverTemplate) => { + try { + if (tpl.id && coverTemplates.some((t) => t.id === tpl.id)) { + // 更新已有模板 + const updated = await updateCoverTemplate(tpl.id, { + name: tpl.name, + config: tpl.config, + }) + setCoverTemplates((prev) => prev.map((t) => (t.id === tpl.id ? { ...t, ...updated } : t))) + } else { + // 创建新模板 + const created = await createCoverTemplate({ + name: tpl.name, + config: tpl.config, + }) + setCoverTemplates((prev) => [...prev, created]) + } + setShowCoverEditor(false) + } catch (err) { + console.error("[Step6] 保存模板失败:", err) + } + }, + [coverTemplates], + ) + + /** 删除模板 */ + const handleDeleteTemplate = useCallback( + async (id: string) => { + try { + await deleteCoverTemplate(id) + setCoverTemplates((prev) => prev.filter((t) => t.id !== id)) + if (selectedTemplateId === id) { + setSelectedTemplateId("default") + } + } catch (err) { + console.error("[Step6] 删除模板失败:", err) + } + }, + [selectedTemplateId], + ) + + const selectedTemplateName = + coverTemplates.find((t) => t.id === selectedTemplateId)?.name || "默认" + const totalDuration = duration || 30 return { coverSettings, - formatTime, - toggleEnabled, - setMode, - setFrameTime, - handleUpload, generateAutoCover, totalDuration, - COVER_MODE_LABELS, - COVER_MODE_ICONS, - DEFAULT_COVER_SETTINGS, + showCoverSettings, + setShowCoverSettings, + showCoverEditor, + setShowCoverEditor, + selectedTemplateId, + setSelectedTemplateId, + editingTemplate, + coverTemplates, + templatesLoading, + templatesError, + selectedTemplateName, + handleSelectTemplate, + handleEditTemplate, + handleSaveTemplate, + handleDeleteTemplate, + loadTemplates, } } diff --git a/packages/adapters/in_memory/cover_template_repository.py b/packages/adapters/in_memory/cover_template_repository.py index 793511c26..b42659886 100644 --- a/packages/adapters/in_memory/cover_template_repository.py +++ b/packages/adapters/in_memory/cover_template_repository.py @@ -19,7 +19,7 @@ class InMemoryCoverTemplateRepository: def list_for_user(self, user_id: str, skip: int = 0, limit: int = 100) -> list[CoverTemplate]: """列出系统模板 + 用户自己的模板。""" visible = [t for t in self._templates.values() if t.is_system or t.user_id == user_id] - visible.sort(key=lambda t: (not t.is_system, t.created_at), reverse=True) + visible.sort(key=lambda t: (t.is_system, t.created_at), reverse=True) return visible[skip : skip + limit] def count_for_user(self, user_id: str) -> int: diff --git a/tests/unit/test_cover_templates_crud.py b/tests/unit/test_cover_templates_crud.py index 2506ec8a2..c6a61c80c 100644 --- a/tests/unit/test_cover_templates_crud.py +++ b/tests/unit/test_cover_templates_crud.py @@ -170,7 +170,7 @@ class TestCoverTemplateAPIPermissions: mock_repo.get.return_value = tpl # 模拟 API 路由中的权限检查逻辑 - template = mock_repo.get("sys_default") + template = mock_repo.get("a8b0120fd98e44788f5a6590f983d327") assert template is not None assert template.is_system is True # 权限检查应该阻止删除 @@ -184,7 +184,7 @@ class TestCoverTemplateAPIPermissions: mock_repo = MagicMock() mock_repo.get.return_value = tpl - template = mock_repo.get("sys_default") + template = mock_repo.get("a8b0120fd98e44788f5a6590f983d327") assert template.is_system is True with pytest.raises(PermissionError): if template.is_system: