refactor(template-library): 三阶段重构 TemplateLibrary(716→107行, -85%)(#931)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
This commit is contained in:
@@ -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<string, string> = {
|
||||
口播: "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<string, string> = {
|
||||
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<TemplateDetailModalProps> = ({
|
||||
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 (
|
||||
<div className="xx-template-modal-overlay" onClick={onClose}>
|
||||
<div
|
||||
className="xx-template-modal xx-template-modal-wide"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 关闭按钮 */}
|
||||
<button className="xx-template-modal-close" onClick={onClose} title="关闭">
|
||||
✕
|
||||
</button>
|
||||
|
||||
{/* 预览区域 */}
|
||||
<div
|
||||
className="xx-template-modal-preview"
|
||||
style={{ background: gradientForCategory(template.category) }}
|
||||
>
|
||||
{template.thumbnail_url ? (
|
||||
<img
|
||||
src={template.thumbnail_url}
|
||||
alt={template.name}
|
||||
className="xx-template-modal-thumb-img"
|
||||
/>
|
||||
) : (
|
||||
<div className="xx-template-modal-preview-content">
|
||||
<span className="xx-template-preview-icon">
|
||||
{TEMPLATE_TYPES.find((t) => t.type === template.category)?.icon ?? "📋"}
|
||||
</span>
|
||||
<span className="xx-template-preview-title">{template.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="xx-template-modal-content">
|
||||
{/* 标题行 */}
|
||||
<div className="xx-template-modal-title-row">
|
||||
<h3>{template.name}</h3>
|
||||
<span
|
||||
className="xx-template-modal-type-badge"
|
||||
style={{
|
||||
color: getTypeColor(template.category),
|
||||
background: `${getTypeColor(template.category)}18`,
|
||||
}}
|
||||
>
|
||||
{TEMPLATE_TYPES.find((t) => t.type === template.category)?.icon} {template.category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<p className="xx-template-modal-desc">{template.description}</p>
|
||||
|
||||
{/* 标签 */}
|
||||
{(template.tags?.length ?? 0) > 0 && (
|
||||
<div className="xx-template-modal-tags">
|
||||
{template.tags!.map((tag) => (
|
||||
<span key={tag} className="xx-template-modal-tag">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 基本信息 */}
|
||||
<Descriptions
|
||||
column={2}
|
||||
size="small"
|
||||
className="xx-template-modal-desc-table"
|
||||
items={[
|
||||
{
|
||||
key: "duration",
|
||||
label: "目标时长",
|
||||
children: formatDuration(template.estimated_duration ?? template.target_duration),
|
||||
},
|
||||
{
|
||||
key: "clips",
|
||||
label: "片段数量",
|
||||
children: `${template.clip_count} 个`,
|
||||
},
|
||||
{
|
||||
key: "ratio",
|
||||
label: "视频比例",
|
||||
children: template.aspect_ratio ?? "16:9",
|
||||
},
|
||||
{
|
||||
key: "usage",
|
||||
label: "使用次数",
|
||||
children: `${template.usage_count ?? 0} 次`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 素材规则(片段配置) */}
|
||||
{segments.length > 0 && (
|
||||
<div className="xx-template-modal-section">
|
||||
<h4>🎬 素材规则</h4>
|
||||
<div className="xx-template-modal-clip-list">
|
||||
{segments
|
||||
.sort((a, b) => a.segment_order - b.segment_order)
|
||||
.map((seg: TemplateSegment, idx: number) => (
|
||||
<div key={seg.id ?? idx} className="xx-template-modal-clip-item">
|
||||
<span className="xx-template-modal-clip-order">#{seg.segment_order}</span>
|
||||
<span
|
||||
className="xx-template-modal-clip-badge"
|
||||
style={{
|
||||
color: seg.material_type ? getTypeColor(seg.material_type) : "#64748b",
|
||||
background: seg.material_type
|
||||
? `${getTypeColor(seg.material_type)}18`
|
||||
: "#f1f5f9",
|
||||
}}
|
||||
>
|
||||
{MATERIAL_TYPE_LABELS[seg.material_type ?? "null"] ??
|
||||
seg.material_type ??
|
||||
"不限"}
|
||||
</span>
|
||||
<span className="xx-template-modal-clip-desc">
|
||||
{seg.description || `片段 ${seg.segment_order}`}
|
||||
</span>
|
||||
<Tooltip title={`时长范围: ${seg.duration_min}秒 - ${seg.duration_max}秒`}>
|
||||
<span className="xx-template-modal-clip-duration">
|
||||
{seg.duration_min}-{seg.duration_max}秒
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="xx-template-modal-total-duration">
|
||||
预估总时长:{formatDuration(Math.round(totalSegmentDuration))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 样式配置 */}
|
||||
<div className="xx-template-modal-section">
|
||||
<h4>🎨 样式配置</h4>
|
||||
<div className="xx-template-modal-style-grid">
|
||||
<div className="xx-template-modal-style-item">
|
||||
<span className="xx-template-modal-style-label">字幕样式</span>
|
||||
<span className="xx-template-modal-style-value">
|
||||
{formatConfig(template.subtitle_config)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-template-modal-style-item">
|
||||
<span className="xx-template-modal-style-label">标题样式</span>
|
||||
<span className="xx-template-modal-style-value">
|
||||
{formatConfig(template.title_config)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-template-modal-style-item">
|
||||
<span className="xx-template-modal-style-label">BGM 配置</span>
|
||||
<span className="xx-template-modal-style-value">
|
||||
{formatConfig(template.bgm_config)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-template-modal-style-item">
|
||||
<span className="xx-template-modal-style-label">视频比例</span>
|
||||
<span className="xx-template-modal-style-value">
|
||||
{template.aspect_ratio ?? "16:9"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计信息 */}
|
||||
<div className="xx-template-modal-stats">
|
||||
<span>已使用 {template.usage_count ?? 0} 次</span>
|
||||
<button
|
||||
className={`xx-template-modal-fav-btn${isFavorite ? " is-favorite" : ""}`}
|
||||
onClick={() => onToggleFavorite(template.id)}
|
||||
>
|
||||
{isFavorite ? "★ 已收藏" : "☆ 收藏"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="xx-template-modal-actions">
|
||||
<Button icon={<CopyOutlined />} onClick={() => onCopy(template)}>
|
||||
复制模板
|
||||
</Button>
|
||||
<Button type="primary" icon={<ThunderboltOutlined />} onClick={() => onUse(template)}>
|
||||
使用此模板生成
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 模板卡片组件
|
||||
* ============================================================ */
|
||||
|
||||
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<TemplateCardProps> = ({
|
||||
template,
|
||||
isFavorite,
|
||||
onPreview,
|
||||
onToggleFavorite,
|
||||
onUse,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-template-card" onClick={() => onPreview(template)}>
|
||||
{/* 缩略图 */}
|
||||
<div className="xx-template-thumb">
|
||||
{template.thumbnail_url ? (
|
||||
<img src={template.thumbnail_url} alt={template.name} className="xx-template-thumb-img" />
|
||||
) : (
|
||||
<div
|
||||
className="xx-template-thumb-bg"
|
||||
style={{ background: gradientForCategory(template.category) }}
|
||||
>
|
||||
{(template.description ?? "").slice(0, 80)}
|
||||
{(template.description ?? "").length > 80 ? "..." : ""}
|
||||
</div>
|
||||
)}
|
||||
<div className="xx-template-thumb-overlay" />
|
||||
<div className="xx-template-thumb-name">{template.name}</div>
|
||||
<div className="xx-template-thumb-meta">
|
||||
<span className="xx-template-thumb-duration">
|
||||
{formatDuration(template.estimated_duration ?? template.target_duration)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-template-preview-hint">点击查看详情</div>
|
||||
<button
|
||||
className={`xx-template-fav-btn${isFavorite ? " is-favorite" : ""}`}
|
||||
onClick={(e) => onToggleFavorite(template.id, e)}
|
||||
title={isFavorite ? "取消收藏" : "收藏"}
|
||||
>
|
||||
{isFavorite ? "★" : "☆"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 信息区 */}
|
||||
<div className="xx-template-info">
|
||||
<div className="xx-template-info-top">
|
||||
<span
|
||||
className="xx-template-category-pill"
|
||||
style={{
|
||||
color: getTypeColor(template.category),
|
||||
background: `${getTypeColor(template.category)}18`,
|
||||
}}
|
||||
>
|
||||
{template.category}
|
||||
</span>
|
||||
{(template.tags ?? []).slice(0, 2).map((tag) => (
|
||||
<Tag key={tag} className="xx-template-tag-pill" bordered={false}>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
<p className="xx-template-desc">{template.description ?? ""}</p>
|
||||
<div className="xx-template-meta">
|
||||
<span className="xx-template-usage">已使用 {template.usage_count ?? 0} 次</span>
|
||||
<button
|
||||
className="xx-template-use-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onUse(template)
|
||||
}}
|
||||
>
|
||||
使用此模板
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 主组件
|
||||
* ============================================================ */
|
||||
|
||||
const TemplateLibrary: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// 筛选状态
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [activeType, setActiveType] = useState<EditTemplateType | "全部">("全部")
|
||||
const [durationRange, setDurationRange] = useState<"" | "short" | "medium" | "long">("")
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize] = useState(12)
|
||||
|
||||
// 弹窗状态
|
||||
const [previewTemplate, setPreviewTemplate] = useState<TemplateItem | null>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="xx-templates-page">
|
||||
<div className="xx-templates-empty">
|
||||
<div className="xx-templates-empty-icon">
|
||||
<LoadingOutlined />
|
||||
</div>
|
||||
<h3>加载模板中...</h3>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Error 状态 ──
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="xx-templates-page">
|
||||
<div className="xx-templates-empty">
|
||||
<div className="xx-templates-empty-icon">
|
||||
<ExclamationCircleOutlined />
|
||||
</div>
|
||||
<h3>加载失败</h3>
|
||||
<p>{error?.message || "网络异常,请稍后重试"}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-templates-page">
|
||||
{/* ── 页面头部 ──────────────────────────────────────────── */}
|
||||
<div className="xx-templates-header">
|
||||
<div className="xx-templates-header-text">
|
||||
<h2>模板库</h2>
|
||||
<p>选择模板快速创建,支持自定义修改</p>
|
||||
</div>
|
||||
<Button type="primary" onClick={() => navigate("/app/editing-planner")}>
|
||||
+ 创建模板
|
||||
</Button>
|
||||
</div>
|
||||
{/* 页面头部 */}
|
||||
<TemplateHeader onCreateClick={handleCreate} />
|
||||
|
||||
{/* ── 工具栏:搜索 + 类型按钮组 + 时长筛选 ─────────────── */}
|
||||
<div className="xx-templates-toolbar">
|
||||
<div className="xx-templates-search">
|
||||
<span className="xx-templates-search-icon">
|
||||
<SearchOutlined />
|
||||
</span>
|
||||
<input
|
||||
className="xx-templates-search-input"
|
||||
type="text"
|
||||
placeholder="搜索模板名称、描述或标签..."
|
||||
value={searchText}
|
||||
onChange={handleSearchChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-templates-categories">
|
||||
{TEMPLATE_TYPES.map((cat) => (
|
||||
<button
|
||||
key={cat.type}
|
||||
className={`xx-templates-cat-btn${activeType === cat.type ? " active" : ""}`}
|
||||
onClick={() => handleCategoryChange(cat.type)}
|
||||
>
|
||||
<span className="xx-templates-cat-icon">{cat.icon}</span>
|
||||
{cat.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* 时长筛选 */}
|
||||
<div className="xx-templates-duration-filter">
|
||||
{DURATION_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`xx-templates-duration-btn${durationRange === opt.value ? " active" : ""}`}
|
||||
onClick={() => handleDurationChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* 工具栏:搜索 + 类型按钮组 + 时长筛选 */}
|
||||
<TemplateToolbar
|
||||
searchText={searchText}
|
||||
onSearchChange={handleSearchChange}
|
||||
activeType={activeType}
|
||||
onTypeChange={handleCategoryChange}
|
||||
durationRange={durationRange}
|
||||
onDurationChange={handleDurationChange}
|
||||
/>
|
||||
|
||||
{/* ── 模板展示区 ────────────────────────────────────────── */}
|
||||
{templates.length === 0 ? (
|
||||
<div className="xx-templates-empty">
|
||||
<div className="xx-templates-empty-icon">
|
||||
<InboxOutlined />
|
||||
</div>
|
||||
<h3>
|
||||
{searchText || activeType !== "全部" || durationRange ? "未找到匹配的模板" : "暂无模板"}
|
||||
</h3>
|
||||
<p>
|
||||
{searchText || activeType !== "全部" || durationRange
|
||||
? "试试调整搜索条件或切换类型"
|
||||
: "点击上方「创建模板」开始创作"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="xx-templates-grid">
|
||||
{templates.map((tpl) => (
|
||||
<TemplateCard
|
||||
key={tpl.id}
|
||||
template={tpl}
|
||||
isFavorite={tpl.is_favorite ?? false}
|
||||
onPreview={handlePreview}
|
||||
onToggleFavorite={toggleFavorite}
|
||||
onUse={handleUse}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* 模板展示区 */}
|
||||
<TemplateGrid
|
||||
templates={templates}
|
||||
total={totalTemplates}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
errorMessage={error?.message}
|
||||
searchText={searchText}
|
||||
activeType={activeType}
|
||||
durationRange={durationRange}
|
||||
onPageChange={setPage}
|
||||
onPreview={handlePreview}
|
||||
onToggleFavorite={toggleFavorite}
|
||||
onUse={handleUse}
|
||||
/>
|
||||
|
||||
{/* 分页 */}
|
||||
{totalTemplates > pageSize && (
|
||||
<div className="xx-templates-pagination">
|
||||
<Pagination
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={totalTemplates}
|
||||
showSizeChanger={false}
|
||||
showQuickJumper
|
||||
showTotal={(total) => `共 ${total} 个模板`}
|
||||
onChange={(p) => setPage(p)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── 详情弹窗 ──────────────────────────────────────────── */}
|
||||
{/* 详情弹窗 */}
|
||||
{previewTemplate && (
|
||||
<TemplateDetailModal
|
||||
template={previewTemplate}
|
||||
isFavorite={previewTemplate.is_favorite ?? false}
|
||||
onClose={() => setPreviewTemplate(null)}
|
||||
onToggleFavorite={toggleFavorite}
|
||||
onUse={handleUse}
|
||||
onCopy={handleCopy}
|
||||
onClose={handleClose}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
onUse={handleUseFromDetail}
|
||||
onCopy={handleCopyFromDetail}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 详情加载中的提示(可选覆盖层) */}
|
||||
{/* 详情加载中的提示 */}
|
||||
{detailLoading && previewTemplate && (
|
||||
<div className="xx-template-detail-loading">
|
||||
<LoadingOutlined /> 加载中...
|
||||
</div>
|
||||
<div className="xx-template-detail-loading">加载中...</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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<TemplateCardProps> = ({
|
||||
template,
|
||||
isFavorite,
|
||||
onPreview,
|
||||
onToggleFavorite,
|
||||
onUse,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-template-card" onClick={() => onPreview(template)}>
|
||||
{/* 缩略图 */}
|
||||
<div className="xx-template-thumb">
|
||||
{template.thumbnail_url ? (
|
||||
<img src={template.thumbnail_url} alt={template.name} className="xx-template-thumb-img" />
|
||||
) : (
|
||||
<div
|
||||
className="xx-template-thumb-bg"
|
||||
style={{ background: gradientForCategory(template.category) }}
|
||||
>
|
||||
{(template.description ?? "").slice(0, 80)}
|
||||
{(template.description ?? "").length > 80 ? "..." : ""}
|
||||
</div>
|
||||
)}
|
||||
<div className="xx-template-thumb-overlay" />
|
||||
<div className="xx-template-thumb-name">{template.name}</div>
|
||||
<div className="xx-template-thumb-meta">
|
||||
<span className="xx-template-thumb-duration">
|
||||
{formatDuration(template.estimated_duration ?? template.target_duration)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-template-preview-hint">点击查看详情</div>
|
||||
<button
|
||||
className={`xx-template-fav-btn${isFavorite ? " is-favorite" : ""}`}
|
||||
onClick={(e) => onToggleFavorite(template.id, e)}
|
||||
title={isFavorite ? "取消收藏" : "收藏"}
|
||||
>
|
||||
{isFavorite ? "★" : "☆"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 信息区 */}
|
||||
<div className="xx-template-info">
|
||||
<div className="xx-template-info-top">
|
||||
<span
|
||||
className="xx-template-category-pill"
|
||||
style={{
|
||||
color: getTypeColor(template.category),
|
||||
background: `${getTypeColor(template.category)}18`,
|
||||
}}
|
||||
>
|
||||
{template.category}
|
||||
</span>
|
||||
{(template.tags ?? []).slice(0, 2).map((tag) => (
|
||||
<Tag key={tag} className="xx-template-tag-pill" bordered={false}>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
<p className="xx-template-desc">{template.description ?? ""}</p>
|
||||
<div className="xx-template-meta">
|
||||
<span className="xx-template-usage">已使用 {template.usage_count ?? 0} 次</span>
|
||||
<button
|
||||
className="xx-template-use-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onUse(template)
|
||||
}}
|
||||
>
|
||||
使用此模板
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<TemplateDetailModalProps> = ({
|
||||
template,
|
||||
isFavorite,
|
||||
onClose,
|
||||
onToggleFavorite,
|
||||
onUse,
|
||||
onCopy,
|
||||
}) => {
|
||||
const segments = template.segments ?? []
|
||||
const totalSegmentDuration = calcTotalSegmentDuration(segments)
|
||||
|
||||
return (
|
||||
<div className="xx-template-modal-overlay" onClick={onClose}>
|
||||
<div
|
||||
className="xx-template-modal xx-template-modal-wide"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 关闭按钮 */}
|
||||
<button className="xx-template-modal-close" onClick={onClose} title="关闭">
|
||||
✕
|
||||
</button>
|
||||
|
||||
{/* 预览区域 */}
|
||||
<div
|
||||
className="xx-template-modal-preview"
|
||||
style={{ background: gradientForCategory(template.category) }}
|
||||
>
|
||||
{template.thumbnail_url ? (
|
||||
<img
|
||||
src={template.thumbnail_url}
|
||||
alt={template.name}
|
||||
className="xx-template-modal-thumb-img"
|
||||
/>
|
||||
) : (
|
||||
<div className="xx-template-modal-preview-content">
|
||||
<span className="xx-template-preview-icon">
|
||||
{TEMPLATE_TYPES.find((t) => t.type === template.category)?.icon ?? "📋"}
|
||||
</span>
|
||||
<span className="xx-template-preview-title">{template.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="xx-template-modal-content">
|
||||
{/* 标题行 */}
|
||||
<div className="xx-template-modal-title-row">
|
||||
<h3>{template.name}</h3>
|
||||
<span
|
||||
className="xx-template-modal-type-badge"
|
||||
style={{
|
||||
color: getTypeColor(template.category),
|
||||
background: `${getTypeColor(template.category)}18`,
|
||||
}}
|
||||
>
|
||||
{TEMPLATE_TYPES.find((t) => t.type === template.category)?.icon} {template.category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<p className="xx-template-modal-desc">{template.description}</p>
|
||||
|
||||
{/* 标签 */}
|
||||
{(template.tags?.length ?? 0) > 0 && (
|
||||
<div className="xx-template-modal-tags">
|
||||
{template.tags!.map((tag) => (
|
||||
<span key={tag} className="xx-template-modal-tag">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 基本信息 */}
|
||||
<Descriptions
|
||||
column={2}
|
||||
size="small"
|
||||
className="xx-template-modal-desc-table"
|
||||
items={[
|
||||
{
|
||||
key: "duration",
|
||||
label: "目标时长",
|
||||
children: formatDuration(template.estimated_duration ?? template.target_duration),
|
||||
},
|
||||
{
|
||||
key: "clips",
|
||||
label: "片段数量",
|
||||
children: `${template.clip_count} 个`,
|
||||
},
|
||||
{
|
||||
key: "ratio",
|
||||
label: "视频比例",
|
||||
children: template.aspect_ratio ?? "16:9",
|
||||
},
|
||||
{
|
||||
key: "usage",
|
||||
label: "使用次数",
|
||||
children: `${template.usage_count ?? 0} 次`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 素材规则(片段配置) */}
|
||||
{segments.length > 0 && (
|
||||
<div className="xx-template-modal-section">
|
||||
<h4>🎬 素材规则</h4>
|
||||
<div className="xx-template-modal-clip-list">
|
||||
{segments
|
||||
.sort((a, b) => a.segment_order - b.segment_order)
|
||||
.map((seg: TemplateSegment, idx: number) => (
|
||||
<div key={seg.id ?? idx} className="xx-template-modal-clip-item">
|
||||
<span className="xx-template-modal-clip-order">#{seg.segment_order}</span>
|
||||
<span
|
||||
className="xx-template-modal-clip-badge"
|
||||
style={{
|
||||
color: seg.material_type ? getTypeColor(seg.material_type) : "#64748b",
|
||||
background: seg.material_type
|
||||
? `${getTypeColor(seg.material_type)}18`
|
||||
: "#f1f5f9",
|
||||
}}
|
||||
>
|
||||
{getMaterialTypeLabel(seg.material_type)}
|
||||
</span>
|
||||
<span className="xx-template-modal-clip-desc">
|
||||
{seg.description || `片段 ${seg.segment_order}`}
|
||||
</span>
|
||||
<Tooltip title={`时长范围: ${seg.duration_min}秒 - ${seg.duration_max}秒`}>
|
||||
<span className="xx-template-modal-clip-duration">
|
||||
{seg.duration_min}-{seg.duration_max}秒
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="xx-template-modal-total-duration">
|
||||
预估总时长:{formatDuration(Math.round(totalSegmentDuration))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 样式配置 */}
|
||||
<div className="xx-template-modal-section">
|
||||
<h4>🎨 样式配置</h4>
|
||||
<div className="xx-template-modal-style-grid">
|
||||
<div className="xx-template-modal-style-item">
|
||||
<span className="xx-template-modal-style-label">字幕样式</span>
|
||||
<span className="xx-template-modal-style-value">
|
||||
{formatConfig(template.subtitle_config)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-template-modal-style-item">
|
||||
<span className="xx-template-modal-style-label">标题样式</span>
|
||||
<span className="xx-template-modal-style-value">
|
||||
{formatConfig(template.title_config)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-template-modal-style-item">
|
||||
<span className="xx-template-modal-style-label">BGM 配置</span>
|
||||
<span className="xx-template-modal-style-value">
|
||||
{formatConfig(template.bgm_config)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-template-modal-style-item">
|
||||
<span className="xx-template-modal-style-label">视频比例</span>
|
||||
<span className="xx-template-modal-style-value">
|
||||
{template.aspect_ratio ?? "16:9"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计信息 */}
|
||||
<div className="xx-template-modal-stats">
|
||||
<span>已使用 {template.usage_count ?? 0} 次</span>
|
||||
<button
|
||||
className={`xx-template-modal-fav-btn${isFavorite ? " is-favorite" : ""}`}
|
||||
onClick={() => onToggleFavorite(template.id)}
|
||||
>
|
||||
{isFavorite ? "★ 已收藏" : "☆ 收藏"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="xx-template-modal-actions">
|
||||
<Button icon={<CopyOutlined />} onClick={() => onCopy(template)}>
|
||||
复制模板
|
||||
</Button>
|
||||
<Button type="primary" icon={<ThunderboltOutlined />} onClick={() => onUse(template)}>
|
||||
使用此模板生成
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<TemplateGridProps> = ({
|
||||
templates,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
isLoading,
|
||||
isError,
|
||||
errorMessage,
|
||||
searchText,
|
||||
activeType,
|
||||
durationRange,
|
||||
onPageChange,
|
||||
onPreview,
|
||||
onToggleFavorite,
|
||||
onUse,
|
||||
}) => {
|
||||
// Loading 状态
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="xx-templates-empty">
|
||||
<div className="xx-templates-empty-icon">
|
||||
<LoadingOutlined />
|
||||
</div>
|
||||
<h3>加载模板中...</h3>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Error 状态
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="xx-templates-empty">
|
||||
<div className="xx-templates-empty-icon">
|
||||
<ExclamationCircleOutlined />
|
||||
</div>
|
||||
<h3>加载失败</h3>
|
||||
<p>{errorMessage || "网络异常,请稍后重试"}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 空状态
|
||||
if (templates.length === 0) {
|
||||
const hasFilter = !!searchText || activeType !== "全部" || !!durationRange
|
||||
return (
|
||||
<div className="xx-templates-empty">
|
||||
<div className="xx-templates-empty-icon">
|
||||
<InboxOutlined />
|
||||
</div>
|
||||
<h3>{hasFilter ? "未找到匹配的模板" : "暂无模板"}</h3>
|
||||
<p>{hasFilter ? "试试调整搜索条件或切换类型" : "点击上方「创建模板」开始创作"}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="xx-templates-grid">
|
||||
{templates.map((tpl) => (
|
||||
<TemplateCard
|
||||
key={tpl.id}
|
||||
template={tpl}
|
||||
isFavorite={tpl.is_favorite ?? false}
|
||||
onPreview={onPreview}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onUse={onUse}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{total > pageSize && (
|
||||
<div className="xx-templates-pagination">
|
||||
<Pagination
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
showSizeChanger={false}
|
||||
showQuickJumper
|
||||
showTotal={(t) => `共 ${t} 个模板`}
|
||||
onChange={onPageChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from "react"
|
||||
import { Button } from "antd"
|
||||
|
||||
interface TemplateHeaderProps {
|
||||
onCreateClick: () => void
|
||||
}
|
||||
|
||||
export const TemplateHeader: React.FC<TemplateHeaderProps> = ({ onCreateClick }) => {
|
||||
return (
|
||||
<div className="xx-templates-header">
|
||||
<div className="xx-templates-header-text">
|
||||
<h2>模板库</h2>
|
||||
<p>选择模板快速创建,支持自定义修改</p>
|
||||
</div>
|
||||
<Button type="primary" onClick={onCreateClick}>
|
||||
+ 创建模板
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<HTMLInputElement>) => void
|
||||
activeType: EditTemplateType | "全部"
|
||||
onTypeChange: (type: EditTemplateType | "全部") => void
|
||||
durationRange: DurationRange
|
||||
onDurationChange: (value: DurationRange) => void
|
||||
}
|
||||
|
||||
export const TemplateToolbar: React.FC<TemplateToolbarProps> = ({
|
||||
searchText,
|
||||
onSearchChange,
|
||||
activeType,
|
||||
onTypeChange,
|
||||
durationRange,
|
||||
onDurationChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-templates-toolbar">
|
||||
<div className="xx-templates-search">
|
||||
<span className="xx-templates-search-icon">
|
||||
<SearchOutlined />
|
||||
</span>
|
||||
<input
|
||||
className="xx-templates-search-input"
|
||||
type="text"
|
||||
placeholder="搜索模板名称、描述或标签..."
|
||||
value={searchText}
|
||||
onChange={onSearchChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-templates-categories">
|
||||
{TEMPLATE_TYPES.map((cat) => (
|
||||
<button
|
||||
key={cat.type}
|
||||
className={`xx-templates-cat-btn${activeType === cat.type ? " active" : ""}`}
|
||||
onClick={() => onTypeChange(cat.type)}
|
||||
>
|
||||
<span className="xx-templates-cat-icon">{cat.icon}</span>
|
||||
{cat.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* 时长筛选 */}
|
||||
<div className="xx-templates-duration-filter">
|
||||
{DURATION_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`xx-templates-duration-btn${durationRange === opt.value ? " active" : ""}`}
|
||||
onClick={() => onDurationChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
video: "视频",
|
||||
image: "图片",
|
||||
audio: "音频",
|
||||
voiceover: "配音",
|
||||
subtitle: "字幕",
|
||||
null: "不限",
|
||||
}
|
||||
|
||||
export const DEFAULT_PAGE_SIZE = 12
|
||||
export const CATEGORY_GRADIENT_MAP: Record<string, string> = {
|
||||
口播: "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)"
|
||||
@@ -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<TemplateItem | null>(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,
|
||||
}
|
||||
}
|
||||
@@ -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<EditTemplateType | "全部">("全部")
|
||||
const [durationRange, setDurationRange] = useState<DurationRange>("")
|
||||
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<HTMLInputElement>) => {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user