From 8711b5dadec25fe293e969030242db467aa084c1 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 26 Jul 2026 15:50:16 +0800 Subject: [PATCH] =?UTF-8?q?refactor(template-library):=20=E4=B8=89?= =?UTF-8?q?=E9=98=B6=E6=AE=B5=E9=87=8D=E6=9E=84=20TemplateLibrary=EF=BC=88?= =?UTF-8?q?716=E2=86=92107=E8=A1=8C,=20-85%=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 - 抽类型/常量/工具函数: - types/templateLibrary.ts: EditTemplateType, DurationRange, ConfigDisplayFields - constants/templateLibrary.ts: TEMPLATE_TYPES, DURATION_OPTIONS, MATERIAL_TYPE_LABELS, 渐变色映射 - utils/templateLibrary.ts: getTypeColor, gradientForCategory, formatDuration, formatConfig, getMaterialTypeLabel, calcTotalSegmentDuration Phase 2 - 抽 5 个 UI 组件: - TemplateCard: 模板卡片(缩略图+信息+操作按钮) - TemplateDetailModal: 模板详情弹窗(预览+素材规则+样式配置) - TemplateHeader: 页面头部(标题+创建按钮) - TemplateToolbar: 工具栏(搜索+分类+时长筛选) - TemplateGrid: 模板网格 + 空状态 + 分页 + Loading/Error Phase 3 - 抽 2 个业务 Hook: - useTemplateLibrary: 数据获取、筛选、分页、收藏/复制 mutations - useTemplateDetail: 详情弹窗逻辑(预览加载、收藏乐观更新) 主文件 716→107 行,共新增 10 个文件 --- .../src/pages/templates/TemplateLibrary.tsx | 751 ++---------------- .../template-library/TemplateCard.tsx | 87 ++ .../template-library/TemplateDetailModal.tsx | 219 +++++ .../template-library/TemplateGrid.tsx | 110 +++ .../template-library/TemplateHeader.tsx | 20 + .../template-library/TemplateToolbar.tsx | 63 ++ .../templates/constants/templateLibrary.ts | 46 ++ .../templates/hooks/useTemplateDetail.ts | 62 ++ .../templates/hooks/useTemplateLibrary.ts | 147 ++++ .../pages/templates/types/templateLibrary.ts | 15 + .../pages/templates/utils/templateLibrary.ts | 54 ++ .../src/test/pages/TemplateLibrary.test.tsx | 10 + 12 files changed, 904 insertions(+), 680 deletions(-) create mode 100644 apps/web/src/pages/templates/components/template-library/TemplateCard.tsx create mode 100644 apps/web/src/pages/templates/components/template-library/TemplateDetailModal.tsx create mode 100644 apps/web/src/pages/templates/components/template-library/TemplateGrid.tsx create mode 100644 apps/web/src/pages/templates/components/template-library/TemplateHeader.tsx create mode 100644 apps/web/src/pages/templates/components/template-library/TemplateToolbar.tsx create mode 100644 apps/web/src/pages/templates/constants/templateLibrary.ts create mode 100644 apps/web/src/pages/templates/hooks/useTemplateDetail.ts create mode 100644 apps/web/src/pages/templates/hooks/useTemplateLibrary.ts create mode 100644 apps/web/src/pages/templates/types/templateLibrary.ts create mode 100644 apps/web/src/pages/templates/utils/templateLibrary.ts diff --git a/apps/web/src/pages/templates/TemplateLibrary.tsx b/apps/web/src/pages/templates/TemplateLibrary.tsx index 9be26dafc..394038428 100644 --- a/apps/web/src/pages/templates/TemplateLibrary.tsx +++ b/apps/web/src/pages/templates/TemplateLibrary.tsx @@ -6,708 +6,99 @@ * - 复制模板 / 从模板生成 * - 卡片网格布局 + 类型筛选 + 搜索 + 收藏 */ -import React, { useState, useMemo, useCallback } from "react" -import { useNavigate } from "react-router-dom" -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" -import { Button, message, Pagination, Tooltip, Tag, Descriptions } from "antd" -import { - LoadingOutlined, - ExclamationCircleOutlined, - InboxOutlined, - SearchOutlined, - CopyOutlined, - ThunderboltOutlined, -} from "@ant-design/icons" -import { - getTemplates, - getTemplate, - toggleFavoriteTemplate, - copyTemplate, - type TemplateItem, - type TemplateListParams, - type TemplateSegment, -} from "@/api/templates" +import React from "react" +import { useTemplateLibrary } from "./hooks/useTemplateLibrary" +import { useTemplateDetail } from "./hooks/useTemplateDetail" +import { TemplateHeader } from "./components/template-library/TemplateHeader" +import { TemplateToolbar } from "./components/template-library/TemplateToolbar" +import { TemplateGrid } from "./components/template-library/TemplateGrid" +import { TemplateDetailModal } from "./components/template-library/TemplateDetailModal" import "./templates.css" -/* ============================================================ - * 类型定义 - * ============================================================ */ - -/** 模板类型 */ -type EditTemplateType = "口播" | "种草" | "产品" | "品牌" | "混剪" | "Vlog" - -/* ============================================================ - * 模板类型配置 - * ============================================================ */ - -const TEMPLATE_TYPES: Array<{ - type: EditTemplateType | "全部" - label: string - icon: string - color: string -}> = [ - { type: "全部", label: "全部", icon: "📋", color: "#6366f1" }, - { type: "口播", label: "口播", icon: "🎙️", color: "#6366f1" }, - { type: "种草", label: "种草", icon: "🌱", color: "#10b981" }, - { type: "产品", label: "产品", icon: "📦", color: "#0ea5e9" }, - { type: "品牌", label: "品牌", icon: "🏷️", color: "#f59e0b" }, - { type: "混剪", label: "混剪", icon: "🎬", color: "#8b5cf6" }, - { type: "Vlog", label: "Vlog", icon: "📹", color: "#ec4899" }, -] - -/** 时长筛选选项 */ -const DURATION_OPTIONS: Array<{ - value: "" | "short" | "medium" | "long" - label: string -}> = [ - { value: "", label: "全部时长" }, - { value: "short", label: "30秒以内" }, - { value: "medium", label: "30秒-2分钟" }, - { value: "long", label: "2分钟以上" }, -] - -/* ============================================================ - * 辅助函数 - * ============================================================ */ - -/** 获取类型对应颜色 */ -const getTypeColor = (type: string): string => { - const found = TEMPLATE_TYPES.find((t) => t.type === type) - return found?.color ?? "#6366f1" -} - -/** 根据 category 生成占位渐变色 */ -const gradientForCategory = (category: string): string => { - const gradients: Record = { - 口播: "linear-gradient(135deg, #6366f1, #8b5cf6)", - 种草: "linear-gradient(135deg, #10b981, #059669)", - 产品: "linear-gradient(135deg, #0ea5e9, #0284c7)", - 品牌: "linear-gradient(135deg, #f59e0b, #d97706)", - 混剪: "linear-gradient(135deg, #8b5cf6, #6d28d9)", - Vlog: "linear-gradient(135deg, #ec4899, #db2777)", - } - return gradients[category] ?? "linear-gradient(135deg, #6366f1, #8b5cf6)" -} - -/** 格式化时长 */ -const formatDuration = (seconds: number | undefined | null): string => { - if (!seconds || seconds <= 0) return "0秒" - const totalSec = Math.round(seconds) - const m = Math.floor(totalSec / 60) - const s = totalSec % 60 - if (m === 0) return `${s}秒` - return `${m}分${s > 0 ? `${s}秒` : ""}` -} - -/** 配置展示字段(formatConfig 提取通用配置的可读属性) */ -interface ConfigDisplayFields { - font_size?: string | number - font_family?: string - color?: string - position?: string - volume?: string | number - name?: string -} - -/** 格式化配置对象为可读文本 */ -const formatConfig = (config?: object): string => { - if (!config || Object.keys(config).length === 0) return "默认" - const c = config as ConfigDisplayFields - const parts: string[] = [] - if (c.font_size) parts.push(`字号: ${c.font_size}`) - if (c.font_family) parts.push(`字体: ${c.font_family}`) - if (c.color) parts.push(`颜色: ${c.color}`) - if (c.position) parts.push(`位置: ${c.position}`) - if (c.volume !== undefined) parts.push(`音量: ${c.volume}%`) - if (c.name) parts.push(String(c.name)) - return parts.length > 0 ? parts.join(" / ") : JSON.stringify(config) -} - -/** 素材类型标签 */ -const MATERIAL_TYPE_LABELS: Record = { - video: "视频", - image: "图片", - audio: "音频", - voiceover: "配音", - subtitle: "字幕", - null: "不限", -} - -/* ============================================================ - * 模板详情弹窗组件 - * ============================================================ */ - -interface TemplateDetailModalProps { - template: TemplateItem - isFavorite: boolean - onClose: () => void - onToggleFavorite: (id: string) => void - onUse: (template: TemplateItem) => void - onCopy: (template: TemplateItem) => void -} - -const TemplateDetailModal: React.FC = ({ - template, - isFavorite, - onClose, - onToggleFavorite, - onUse, - onCopy, -}) => { - const segments = template.segments ?? [] - const totalSegmentDuration = segments.reduce( - (sum, s) => sum + (s.duration_min + s.duration_max) / 2, - 0, - ) - - return ( -
-
e.stopPropagation()} - > - {/* 关闭按钮 */} - - - {/* 预览区域 */} -
- {template.thumbnail_url ? ( - {template.name} - ) : ( -
- - {TEMPLATE_TYPES.find((t) => t.type === template.category)?.icon ?? "📋"} - - {template.name} -
- )} -
- - {/* 内容区域 */} -
- {/* 标题行 */} -
-

{template.name}

- - {TEMPLATE_TYPES.find((t) => t.type === template.category)?.icon} {template.category} - -
- - {/* 描述 */} -

{template.description}

- - {/* 标签 */} - {(template.tags?.length ?? 0) > 0 && ( -
- {template.tags!.map((tag) => ( - - #{tag} - - ))} -
- )} - - {/* 基本信息 */} - - - {/* 素材规则(片段配置) */} - {segments.length > 0 && ( -
-

🎬 素材规则

-
- {segments - .sort((a, b) => a.segment_order - b.segment_order) - .map((seg: TemplateSegment, idx: number) => ( -
- #{seg.segment_order} - - {MATERIAL_TYPE_LABELS[seg.material_type ?? "null"] ?? - seg.material_type ?? - "不限"} - - - {seg.description || `片段 ${seg.segment_order}`} - - - - {seg.duration_min}-{seg.duration_max}秒 - - -
- ))} -
-
- 预估总时长:{formatDuration(Math.round(totalSegmentDuration))} -
-
- )} - - {/* 样式配置 */} -
-

🎨 样式配置

-
-
- 字幕样式 - - {formatConfig(template.subtitle_config)} - -
-
- 标题样式 - - {formatConfig(template.title_config)} - -
-
- BGM 配置 - - {formatConfig(template.bgm_config)} - -
-
- 视频比例 - - {template.aspect_ratio ?? "16:9"} - -
-
-
- - {/* 统计信息 */} -
- 已使用 {template.usage_count ?? 0} 次 - -
- - {/* 操作按钮 */} -
- - -
-
-
-
- ) -} - -/* ============================================================ - * 模板卡片组件 - * ============================================================ */ - -interface TemplateCardProps { - template: TemplateItem - isFavorite: boolean - onPreview: (template: TemplateItem) => void - onToggleFavorite: (id: string, e: React.MouseEvent) => void - onUse: (template: TemplateItem) => void -} - -const TemplateCard: React.FC = ({ - template, - isFavorite, - onPreview, - onToggleFavorite, - onUse, -}) => { - return ( -
onPreview(template)}> - {/* 缩略图 */} -
- {template.thumbnail_url ? ( - {template.name} - ) : ( -
- {(template.description ?? "").slice(0, 80)} - {(template.description ?? "").length > 80 ? "..." : ""} -
- )} -
-
{template.name}
-
- - {formatDuration(template.estimated_duration ?? template.target_duration)} - -
-
点击查看详情
- -
- - {/* 信息区 */} -
-
- - {template.category} - - {(template.tags ?? []).slice(0, 2).map((tag) => ( - - {tag} - - ))} -
-

{template.description ?? ""}

-
- 已使用 {template.usage_count ?? 0} 次 - -
-
-
- ) -} - -/* ============================================================ - * 主组件 - * ============================================================ */ - const TemplateLibrary: React.FC = () => { - const navigate = useNavigate() - const queryClient = useQueryClient() - - // 筛选状态 - const [searchText, setSearchText] = useState("") - const [activeType, setActiveType] = useState("全部") - const [durationRange, setDurationRange] = useState<"" | "short" | "medium" | "long">("") - const [page, setPage] = useState(1) - const [pageSize] = useState(12) - - // 弹窗状态 - const [previewTemplate, setPreviewTemplate] = useState(null) - const [detailLoading, setDetailLoading] = useState(false) - - // ── 构建查询参数 ── - const queryParams: TemplateListParams = useMemo(() => { - const params: TemplateListParams = { - page, - page_size: pageSize, - } - if (activeType !== "全部") params.category = activeType - if (searchText.trim()) params.keyword = searchText.trim() - if (durationRange) params.duration_range = durationRange - return params - }, [page, pageSize, activeType, searchText, durationRange]) - - // ── 获取模板列表(后端分页 + 筛选) ── const { - data: templateData, + templates, + totalTemplates, isLoading, isError, error, - } = useQuery({ - queryKey: ["templates", queryParams], - queryFn: () => getTemplates(queryParams), - staleTime: 30_000, + searchText, + activeType, + durationRange, + page, + pageSize, + setPage, + toggleFavorite, + handleCopy, + handleUse, + handleCreate, + handleSearchChange, + handleCategoryChange, + handleDurationChange, + } = useTemplateLibrary() + + const { + previewTemplate, + detailLoading, + handlePreview, + handleClose, + handleToggleFavorite, + handleUse: handleUseFromDetail, + handleCopy: handleCopyFromDetail, + } = useTemplateDetail({ + onToggleFavorite: (id) => toggleFavorite(id), + onUse: handleUse, + onCopy: handleCopy, }) - const templates = templateData?.items ?? [] - const totalTemplates = templateData?.total ?? 0 - - // ── 收藏 mutation ── - const favMutation = useMutation({ - mutationFn: toggleFavoriteTemplate, - onSuccess: (_data, templateId) => { - queryClient.invalidateQueries({ queryKey: ["templates"] }) - if (previewTemplate && previewTemplate.id === templateId) { - setPreviewTemplate((prev) => (prev ? { ...prev, is_favorite: !prev.is_favorite } : prev)) - } - }, - }) - - // ── 复制模板 mutation ── - const copyMutation = useMutation({ - mutationFn: copyTemplate, - onSuccess: (data) => { - message.success(`模板「${data.name}」已复制到「我的模板」`) - queryClient.invalidateQueries({ queryKey: ["templates"] }) - }, - onError: () => { - message.error("复制模板失败,请稍后重试") - }, - }) - - /** 切换收藏 */ - const toggleFavorite = useCallback( - (id: string, e?: React.MouseEvent) => { - e?.stopPropagation() - favMutation.mutate(id) - }, - [favMutation], - ) - - /** 点击卡片 → 获取详情并展示弹窗 */ - const handlePreview = useCallback(async (template: TemplateItem) => { - setDetailLoading(true) - setPreviewTemplate(template) - try { - const detail = await getTemplate(template.id) - setPreviewTemplate(detail) - } catch { - // 详情加载失败时使用列表数据 - message.warning("模板详情加载失败,显示摘要信息") - } finally { - setDetailLoading(false) - } - }, []) - - /** 复制模板 */ - const handleCopy = useCallback( - (template: TemplateItem) => { - copyMutation.mutate(template.id) - }, - [copyMutation], - ) - - /** 使用模板 → 进入剪辑编辑器配置 */ - const handleUse = useCallback( - (template: TemplateItem) => { - navigate(`/app/editing-planner?templateId=${template.id}`) - }, - [navigate], - ) - - /** 搜索防抖处理 */ - const handleSearchChange = useCallback((e: React.ChangeEvent) => { - setSearchText(e.target.value) - setPage(1) - }, []) - - /** 切换分类 */ - const handleCategoryChange = useCallback((type: EditTemplateType | "全部") => { - setActiveType(type) - setPage(1) - }, []) - - /** 切换时长筛选 */ - const handleDurationChange = useCallback((value: "" | "short" | "medium" | "long") => { - setDurationRange(value) - setPage(1) - }, []) - - // ── Loading 状态 ── - if (isLoading) { - return ( -
-
-
- -
-

加载模板中...

-
-
- ) - } - - // ── Error 状态 ── - if (isError) { - return ( -
-
-
- -
-

加载失败

-

{error?.message || "网络异常,请稍后重试"}

-
-
- ) - } - return (
- {/* ── 页面头部 ──────────────────────────────────────────── */} -
-
-

模板库

-

选择模板快速创建,支持自定义修改

-
- -
+ {/* 页面头部 */} + - {/* ── 工具栏:搜索 + 类型按钮组 + 时长筛选 ─────────────── */} -
-
- - - - -
-
- {TEMPLATE_TYPES.map((cat) => ( - - ))} -
- {/* 时长筛选 */} -
- {DURATION_OPTIONS.map((opt) => ( - - ))} -
-
+ {/* 工具栏:搜索 + 类型按钮组 + 时长筛选 */} + - {/* ── 模板展示区 ────────────────────────────────────────── */} - {templates.length === 0 ? ( -
-
- -
-

- {searchText || activeType !== "全部" || durationRange ? "未找到匹配的模板" : "暂无模板"} -

-

- {searchText || activeType !== "全部" || durationRange - ? "试试调整搜索条件或切换类型" - : "点击上方「创建模板」开始创作"} -

-
- ) : ( - <> -
- {templates.map((tpl) => ( - - ))} -
+ {/* 模板展示区 */} + - {/* 分页 */} - {totalTemplates > pageSize && ( -
- `共 ${total} 个模板`} - onChange={(p) => setPage(p)} - /> -
- )} - - )} - - {/* ── 详情弹窗 ──────────────────────────────────────────── */} + {/* 详情弹窗 */} {previewTemplate && ( setPreviewTemplate(null)} - onToggleFavorite={toggleFavorite} - onUse={handleUse} - onCopy={handleCopy} + onClose={handleClose} + onToggleFavorite={handleToggleFavorite} + onUse={handleUseFromDetail} + onCopy={handleCopyFromDetail} /> )} - {/* 详情加载中的提示(可选覆盖层) */} + {/* 详情加载中的提示 */} {detailLoading && previewTemplate && ( -
- 加载中... -
+
加载中...
)}
) diff --git a/apps/web/src/pages/templates/components/template-library/TemplateCard.tsx b/apps/web/src/pages/templates/components/template-library/TemplateCard.tsx new file mode 100644 index 000000000..c7abfe5e9 --- /dev/null +++ b/apps/web/src/pages/templates/components/template-library/TemplateCard.tsx @@ -0,0 +1,87 @@ +import React from "react" +import { Tag } from "antd" +import type { TemplateItem } from "@/api/templates" +import { gradientForCategory, getTypeColor, formatDuration } from "../../utils/templateLibrary" + +interface TemplateCardProps { + template: TemplateItem + isFavorite: boolean + onPreview: (template: TemplateItem) => void + onToggleFavorite: (id: string, e: React.MouseEvent) => void + onUse: (template: TemplateItem) => void +} + +export const TemplateCard: React.FC = ({ + template, + isFavorite, + onPreview, + onToggleFavorite, + onUse, +}) => { + return ( +
onPreview(template)}> + {/* 缩略图 */} +
+ {template.thumbnail_url ? ( + {template.name} + ) : ( +
+ {(template.description ?? "").slice(0, 80)} + {(template.description ?? "").length > 80 ? "..." : ""} +
+ )} +
+
{template.name}
+
+ + {formatDuration(template.estimated_duration ?? template.target_duration)} + +
+
点击查看详情
+ +
+ + {/* 信息区 */} +
+
+ + {template.category} + + {(template.tags ?? []).slice(0, 2).map((tag) => ( + + {tag} + + ))} +
+

{template.description ?? ""}

+
+ 已使用 {template.usage_count ?? 0} 次 + +
+
+
+ ) +} diff --git a/apps/web/src/pages/templates/components/template-library/TemplateDetailModal.tsx b/apps/web/src/pages/templates/components/template-library/TemplateDetailModal.tsx new file mode 100644 index 000000000..3856607ff --- /dev/null +++ b/apps/web/src/pages/templates/components/template-library/TemplateDetailModal.tsx @@ -0,0 +1,219 @@ +import React from "react" +import { Button, Descriptions, Tooltip } from "antd" +import { CopyOutlined, ThunderboltOutlined } from "@ant-design/icons" +import type { TemplateItem, TemplateSegment } from "@/api/templates" +import { + gradientForCategory, + getTypeColor, + formatDuration, + formatConfig, + getMaterialTypeLabel, + calcTotalSegmentDuration, +} from "../../utils/templateLibrary" +import { TEMPLATE_TYPES } from "../../constants/templateLibrary" + +interface TemplateDetailModalProps { + template: TemplateItem + isFavorite: boolean + onClose: () => void + onToggleFavorite: (id: string) => void + onUse: (template: TemplateItem) => void + onCopy: (template: TemplateItem) => void +} + +export const TemplateDetailModal: React.FC = ({ + template, + isFavorite, + onClose, + onToggleFavorite, + onUse, + onCopy, +}) => { + const segments = template.segments ?? [] + const totalSegmentDuration = calcTotalSegmentDuration(segments) + + return ( +
+
e.stopPropagation()} + > + {/* 关闭按钮 */} + + + {/* 预览区域 */} +
+ {template.thumbnail_url ? ( + {template.name} + ) : ( +
+ + {TEMPLATE_TYPES.find((t) => t.type === template.category)?.icon ?? "📋"} + + {template.name} +
+ )} +
+ + {/* 内容区域 */} +
+ {/* 标题行 */} +
+

{template.name}

+ + {TEMPLATE_TYPES.find((t) => t.type === template.category)?.icon} {template.category} + +
+ + {/* 描述 */} +

{template.description}

+ + {/* 标签 */} + {(template.tags?.length ?? 0) > 0 && ( +
+ {template.tags!.map((tag) => ( + + #{tag} + + ))} +
+ )} + + {/* 基本信息 */} + + + {/* 素材规则(片段配置) */} + {segments.length > 0 && ( +
+

🎬 素材规则

+
+ {segments + .sort((a, b) => a.segment_order - b.segment_order) + .map((seg: TemplateSegment, idx: number) => ( +
+ #{seg.segment_order} + + {getMaterialTypeLabel(seg.material_type)} + + + {seg.description || `片段 ${seg.segment_order}`} + + + + {seg.duration_min}-{seg.duration_max}秒 + + +
+ ))} +
+
+ 预估总时长:{formatDuration(Math.round(totalSegmentDuration))} +
+
+ )} + + {/* 样式配置 */} +
+

🎨 样式配置

+
+
+ 字幕样式 + + {formatConfig(template.subtitle_config)} + +
+
+ 标题样式 + + {formatConfig(template.title_config)} + +
+
+ BGM 配置 + + {formatConfig(template.bgm_config)} + +
+
+ 视频比例 + + {template.aspect_ratio ?? "16:9"} + +
+
+
+ + {/* 统计信息 */} +
+ 已使用 {template.usage_count ?? 0} 次 + +
+ + {/* 操作按钮 */} +
+ + +
+
+
+
+ ) +} diff --git a/apps/web/src/pages/templates/components/template-library/TemplateGrid.tsx b/apps/web/src/pages/templates/components/template-library/TemplateGrid.tsx new file mode 100644 index 000000000..072307a12 --- /dev/null +++ b/apps/web/src/pages/templates/components/template-library/TemplateGrid.tsx @@ -0,0 +1,110 @@ +import React from "react" +import { Pagination } from "antd" +import { InboxOutlined, LoadingOutlined, ExclamationCircleOutlined } from "@ant-design/icons" +import { TemplateCard } from "./TemplateCard" +import type { TemplateItem } from "@/api/templates" + +interface TemplateGridProps { + templates: TemplateItem[] + total: number + page: number + pageSize: number + isLoading: boolean + isError: boolean + errorMessage?: string + searchText: string + activeType: string + durationRange: string + onPageChange: (page: number) => void + onPreview: (template: TemplateItem) => void + onToggleFavorite: (id: string, e: React.MouseEvent) => void + onUse: (template: TemplateItem) => void +} + +export const TemplateGrid: React.FC = ({ + templates, + total, + page, + pageSize, + isLoading, + isError, + errorMessage, + searchText, + activeType, + durationRange, + onPageChange, + onPreview, + onToggleFavorite, + onUse, +}) => { + // Loading 状态 + if (isLoading) { + return ( +
+
+ +
+

加载模板中...

+
+ ) + } + + // Error 状态 + if (isError) { + return ( +
+
+ +
+

加载失败

+

{errorMessage || "网络异常,请稍后重试"}

+
+ ) + } + + // 空状态 + if (templates.length === 0) { + const hasFilter = !!searchText || activeType !== "全部" || !!durationRange + return ( +
+
+ +
+

{hasFilter ? "未找到匹配的模板" : "暂无模板"}

+

{hasFilter ? "试试调整搜索条件或切换类型" : "点击上方「创建模板」开始创作"}

+
+ ) + } + + return ( + <> +
+ {templates.map((tpl) => ( + + ))} +
+ + {/* 分页 */} + {total > pageSize && ( +
+ `共 ${t} 个模板`} + onChange={onPageChange} + /> +
+ )} + + ) +} diff --git a/apps/web/src/pages/templates/components/template-library/TemplateHeader.tsx b/apps/web/src/pages/templates/components/template-library/TemplateHeader.tsx new file mode 100644 index 000000000..748f021ed --- /dev/null +++ b/apps/web/src/pages/templates/components/template-library/TemplateHeader.tsx @@ -0,0 +1,20 @@ +import React from "react" +import { Button } from "antd" + +interface TemplateHeaderProps { + onCreateClick: () => void +} + +export const TemplateHeader: React.FC = ({ onCreateClick }) => { + return ( +
+
+

模板库

+

选择模板快速创建,支持自定义修改

+
+ +
+ ) +} diff --git a/apps/web/src/pages/templates/components/template-library/TemplateToolbar.tsx b/apps/web/src/pages/templates/components/template-library/TemplateToolbar.tsx new file mode 100644 index 000000000..40aac6b28 --- /dev/null +++ b/apps/web/src/pages/templates/components/template-library/TemplateToolbar.tsx @@ -0,0 +1,63 @@ +import React from "react" +import { SearchOutlined } from "@ant-design/icons" +import type { EditTemplateType, DurationRange } from "../../types/templateLibrary" +import { TEMPLATE_TYPES, DURATION_OPTIONS } from "../../constants/templateLibrary" + +interface TemplateToolbarProps { + searchText: string + onSearchChange: (e: React.ChangeEvent) => void + activeType: EditTemplateType | "全部" + onTypeChange: (type: EditTemplateType | "全部") => void + durationRange: DurationRange + onDurationChange: (value: DurationRange) => void +} + +export const TemplateToolbar: React.FC = ({ + searchText, + onSearchChange, + activeType, + onTypeChange, + durationRange, + onDurationChange, +}) => { + return ( +
+
+ + + + +
+
+ {TEMPLATE_TYPES.map((cat) => ( + + ))} +
+ {/* 时长筛选 */} +
+ {DURATION_OPTIONS.map((opt) => ( + + ))} +
+
+ ) +} diff --git a/apps/web/src/pages/templates/constants/templateLibrary.ts b/apps/web/src/pages/templates/constants/templateLibrary.ts new file mode 100644 index 000000000..fd79ed0bc --- /dev/null +++ b/apps/web/src/pages/templates/constants/templateLibrary.ts @@ -0,0 +1,46 @@ +import type { EditTemplateType, DurationRange } from "../types/templateLibrary" + +export const TEMPLATE_TYPES: Array<{ + type: EditTemplateType | "全部" + label: string + icon: string + color: string +}> = [ + { type: "全部", label: "全部", icon: "📋", color: "#6366f1" }, + { type: "口播", label: "口播", icon: "🎙️", color: "#6366f1" }, + { type: "种草", label: "种草", icon: "🌱", color: "#10b981" }, + { type: "产品", label: "产品", icon: "📦", color: "#0ea5e9" }, + { type: "品牌", label: "品牌", icon: "🏷️", color: "#f59e0b" }, + { type: "混剪", label: "混剪", icon: "🎬", color: "#8b5cf6" }, + { type: "Vlog", label: "Vlog", icon: "📹", color: "#ec4899" }, +] + +export const DURATION_OPTIONS: Array<{ + value: DurationRange + label: string +}> = [ + { value: "", label: "全部时长" }, + { value: "short", label: "30秒以内" }, + { value: "medium", label: "30秒-2分钟" }, + { value: "long", label: "2分钟以上" }, +] + +export const MATERIAL_TYPE_LABELS: Record = { + video: "视频", + image: "图片", + audio: "音频", + voiceover: "配音", + subtitle: "字幕", + null: "不限", +} + +export const DEFAULT_PAGE_SIZE = 12 +export const CATEGORY_GRADIENT_MAP: Record = { + 口播: "linear-gradient(135deg, #6366f1, #8b5cf6)", + 种草: "linear-gradient(135deg, #10b981, #059669)", + 产品: "linear-gradient(135deg, #0ea5e9, #0284c7)", + 品牌: "linear-gradient(135deg, #f59e0b, #d97706)", + 混剪: "linear-gradient(135deg, #8b5cf6, #6d28d9)", + Vlog: "linear-gradient(135deg, #ec4899, #db2777)", +} +export const DEFAULT_GRADIENT = "linear-gradient(135deg, #6366f1, #8b5cf6)" diff --git a/apps/web/src/pages/templates/hooks/useTemplateDetail.ts b/apps/web/src/pages/templates/hooks/useTemplateDetail.ts new file mode 100644 index 000000000..62be75ece --- /dev/null +++ b/apps/web/src/pages/templates/hooks/useTemplateDetail.ts @@ -0,0 +1,62 @@ +import { useState, useCallback } from "react" +import { useQueryClient } from "@tanstack/react-query" +import { message } from "antd" +import { getTemplate, type TemplateItem } from "@/api/templates" + +interface UseTemplateDetailProps { + onToggleFavorite: (id: string) => void + onUse: (template: TemplateItem) => void + onCopy: (template: TemplateItem) => void +} + +export const useTemplateDetail = ({ onToggleFavorite, onUse, onCopy }: UseTemplateDetailProps) => { + const queryClient = useQueryClient() + + /* 弹窗状态 */ + const [previewTemplate, setPreviewTemplate] = useState(null) + const [detailLoading, setDetailLoading] = useState(false) + + /* 点击卡片 → 获取详情并展示弹窗 */ + const handlePreview = useCallback(async (template: TemplateItem) => { + setDetailLoading(true) + setPreviewTemplate(template) + try { + const detail = await getTemplate(template.id) + setPreviewTemplate(detail) + } catch { + message.warning("模板详情加载失败,显示摘要信息") + } finally { + setDetailLoading(false) + } + }, []) + + /* 关闭弹窗 */ + const handleClose = useCallback(() => { + setPreviewTemplate(null) + setDetailLoading(false) + }, []) + + /* 收藏切换(同时更新预览模板的状态) */ + const handleToggleFavorite = useCallback( + (id: string) => { + onToggleFavorite(id) + /* 乐观更新详情弹窗的收藏状态 */ + setPreviewTemplate((prev) => + prev && prev.id === id ? { ...prev, is_favorite: !prev.is_favorite } : prev, + ) + /* 刷新列表缓存 */ + queryClient.invalidateQueries({ queryKey: ["templates"] }) + }, + [onToggleFavorite, queryClient], + ) + + return { + previewTemplate, + detailLoading, + handlePreview, + handleClose, + handleToggleFavorite, + handleUse: onUse, + handleCopy: onCopy, + } +} diff --git a/apps/web/src/pages/templates/hooks/useTemplateLibrary.ts b/apps/web/src/pages/templates/hooks/useTemplateLibrary.ts new file mode 100644 index 000000000..826ad2357 --- /dev/null +++ b/apps/web/src/pages/templates/hooks/useTemplateLibrary.ts @@ -0,0 +1,147 @@ +import { useState, useMemo, useCallback } from "react" +import { useNavigate } from "react-router-dom" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { message } from "antd" +import { + getTemplates, + toggleFavoriteTemplate, + copyTemplate, + type TemplateItem, + type TemplateListParams, +} from "@/api/templates" +import type { EditTemplateType, DurationRange } from "../types/templateLibrary" +import { DEFAULT_PAGE_SIZE } from "../constants/templateLibrary" + +export const useTemplateLibrary = () => { + const navigate = useNavigate() + const queryClient = useQueryClient() + + /* 筛选状态 */ + const [searchText, setSearchText] = useState("") + const [activeType, setActiveType] = useState("全部") + const [durationRange, setDurationRange] = useState("") + const [page, setPage] = useState(1) + const [pageSize] = useState(DEFAULT_PAGE_SIZE) + + /* 构建查询参数 */ + const queryParams: TemplateListParams = useMemo(() => { + const params: TemplateListParams = { + page, + page_size: pageSize, + } + if (activeType !== "全部") params.category = activeType + if (searchText.trim()) params.keyword = searchText.trim() + if (durationRange) params.duration_range = durationRange + return params + }, [page, pageSize, activeType, searchText, durationRange]) + + /* 获取模板列表 */ + const { + data: templateData, + isLoading, + isError, + error, + } = useQuery({ + queryKey: ["templates", queryParams], + queryFn: () => getTemplates(queryParams), + staleTime: 30_000, + }) + + const templates = templateData?.items ?? [] + const totalTemplates = templateData?.total ?? 0 + + /* 收藏 mutation */ + const favMutation = useMutation({ + mutationFn: toggleFavoriteTemplate, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["templates"] }) + }, + }) + + /* 复制模板 mutation */ + const copyMutation = useMutation({ + mutationFn: copyTemplate, + onSuccess: (data) => { + message.success(`模板「${data.name}」已复制到「我的模板」`) + queryClient.invalidateQueries({ queryKey: ["templates"] }) + }, + onError: () => { + message.error("复制模板失败,请稍后重试") + }, + }) + + /* 操作:切换收藏 */ + const toggleFavorite = useCallback( + (id: string, e?: React.MouseEvent) => { + e?.stopPropagation() + favMutation.mutate(id) + }, + [favMutation], + ) + + /* 操作:复制模板 */ + const handleCopy = useCallback( + (template: TemplateItem) => { + copyMutation.mutate(template.id) + }, + [copyMutation], + ) + + /* 操作:使用模板 → 跳转剪辑编辑器 */ + const handleUse = useCallback( + (template: TemplateItem) => { + navigate(`/app/editing-planner?templateId=${template.id}`) + }, + [navigate], + ) + + /* 操作:创建模板 */ + const handleCreate = useCallback(() => { + navigate("/app/editing-planner") + }, [navigate]) + + /* 搜索 */ + const handleSearchChange = useCallback((e: React.ChangeEvent) => { + setSearchText(e.target.value) + setPage(1) + }, []) + + /* 切换分类 */ + const handleCategoryChange = useCallback((type: EditTemplateType | "全部") => { + setActiveType(type) + setPage(1) + }, []) + + /* 切换时长筛选 */ + const handleDurationChange = useCallback((value: DurationRange) => { + setDurationRange(value) + setPage(1) + }, []) + + return { + /* 状态 */ + templates, + totalTemplates, + isLoading, + isError, + error, + searchText, + activeType, + durationRange, + page, + pageSize, + /* mutations */ + favMutation, + copyMutation, + /* setters */ + setPage, + /* handlers */ + toggleFavorite, + handleCopy, + handleUse, + handleCreate, + handleSearchChange, + handleCategoryChange, + handleDurationChange, + } +} diff --git a/apps/web/src/pages/templates/types/templateLibrary.ts b/apps/web/src/pages/templates/types/templateLibrary.ts new file mode 100644 index 000000000..fb5e60b0c --- /dev/null +++ b/apps/web/src/pages/templates/types/templateLibrary.ts @@ -0,0 +1,15 @@ +/** 模板类型 */ +export type EditTemplateType = "口播" | "种草" | "产品" | "品牌" | "混剪" | "Vlog" + +/** 时长筛选值 */ +export type DurationRange = "" | "short" | "medium" | "long" + +/** 配置展示字段 */ +export interface ConfigDisplayFields { + font_size?: string | number + font_family?: string + color?: string + position?: string + volume?: string | number + name?: string +} diff --git a/apps/web/src/pages/templates/utils/templateLibrary.ts b/apps/web/src/pages/templates/utils/templateLibrary.ts new file mode 100644 index 000000000..f5c619239 --- /dev/null +++ b/apps/web/src/pages/templates/utils/templateLibrary.ts @@ -0,0 +1,54 @@ +import type { ConfigDisplayFields } from "../types/templateLibrary" +import { + TEMPLATE_TYPES, + CATEGORY_GRADIENT_MAP, + DEFAULT_GRADIENT, + MATERIAL_TYPE_LABELS, +} from "../constants/templateLibrary" +import type { TemplateSegment } from "@/api/templates" + +/** 获取类型对应颜色 */ +export const getTypeColor = (type: string): string => { + const found = TEMPLATE_TYPES.find((t) => t.type === type) + return found?.color ?? "#6366f1" +} + +/** 根据 category 生成占位渐变色 */ +export const gradientForCategory = (category: string): string => { + return CATEGORY_GRADIENT_MAP[category] ?? DEFAULT_GRADIENT +} + +/** 格式化时长 */ +export const formatDuration = (seconds: number | undefined | null): string => { + if (!seconds || seconds <= 0) return "0秒" + const totalSec = Math.round(seconds) + const m = Math.floor(totalSec / 60) + const s = totalSec % 60 + if (m === 0) return `${s}秒` + return `${m}分${s > 0 ? `${s}秒` : ""}` +} + +/** 格式化配置对象为可读文本 */ +export const formatConfig = (config?: object): string => { + if (!config || Object.keys(config).length === 0) return "默认" + const c = config as ConfigDisplayFields + const parts: string[] = [] + if (c.font_size) parts.push(`字号: ${c.font_size}`) + if (c.font_family) parts.push(`字体: ${c.font_family}`) + if (c.color) parts.push(`颜色: ${c.color}`) + if (c.position) parts.push(`位置: ${c.position}`) + if (c.volume !== undefined) parts.push(`音量: ${c.volume}%`) + if (c.name) parts.push(String(c.name)) + return parts.length > 0 ? parts.join(" / ") : JSON.stringify(config) +} + +/** 获取素材类型标签文本 */ +export const getMaterialTypeLabel = (materialType: string | null | undefined): string => { + if (!materialType) return "不限" + return MATERIAL_TYPE_LABELS[materialType] ?? materialType +} + +/** 计算片段总时长(取每个片段 min/max 的平均值) */ +export const calcTotalSegmentDuration = (segments: TemplateSegment[]): number => { + return segments.reduce((sum, s) => sum + (s.duration_min + s.duration_max) / 2, 0) +} diff --git a/apps/web/src/test/pages/TemplateLibrary.test.tsx b/apps/web/src/test/pages/TemplateLibrary.test.tsx index 9ea5d984c..a9b7fd861 100644 --- a/apps/web/src/test/pages/TemplateLibrary.test.tsx +++ b/apps/web/src/test/pages/TemplateLibrary.test.tsx @@ -115,6 +115,16 @@ vi.mock("@/api/templates", () => ({ vi.mock("@/pages/templates/TemplateLibrary.css", () => ({})) import TemplateLibrary from "@/pages/templates/TemplateLibrary" +import "@/pages/templates/types/templateLibrary" +import "@/pages/templates/constants/templateLibrary" +import "@/pages/templates/utils/templateLibrary" +import "@/pages/templates/hooks/useTemplateLibrary" +import "@/pages/templates/hooks/useTemplateDetail" +import "@/pages/templates/components/template-library/TemplateCard" +import "@/pages/templates/components/template-library/TemplateDetailModal" +import "@/pages/templates/components/template-library/TemplateHeader" +import "@/pages/templates/components/template-library/TemplateToolbar" +import "@/pages/templates/components/template-library/TemplateGrid" describe("TemplateLibrary Page", () => { it("should render without crashing", () => { -- 2.54.0