09a4dbe04c
CI/CD Pipeline / Frontend Lint (push) Successful in 36s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 1m36s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m4s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2s
CI/CD Pipeline / Integration Tests (push) Successful in 1m10s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 1m58s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
全局搜索发现 4 处 formatDuration 函数中 seconds % 60 未做取整, 当后端返回浮点数 total_duration 时直接拼接显示。 修复:先 Math.round(seconds) 再取模,影响页面: - 剪辑计划列表页(EditPlans.tsx) - 模板库页面(TemplateLibrary.tsx) - 查重详情页(DuplicationDetail.tsx) - 查重结果页(DuplicationResults.tsx)
765 lines
25 KiB
TypeScript
765 lines
25 KiB
TypeScript
/**
|
||
* 模板库页面(升级版)— V21 设计系统
|
||
* 对接后端模板管理 API:
|
||
* - 分页查询(page/page_size/category/keyword/duration_range)
|
||
* - 模板详情(素材规则、字幕样式、BGM、比例等参数配置)
|
||
* - 复制模板 / 从模板生成剪辑计划
|
||
* - 卡片网格布局 + 类型筛选 + 搜索 + 收藏
|
||
*/
|
||
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 "./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,
|
||
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: (_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>
|
||
|
||
{/* ── 工具栏:搜索 + 类型按钮组 + 时长筛选 ─────────────── */}
|
||
<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>
|
||
|
||
{/* ── 模板展示区 ────────────────────────────────────────── */}
|
||
{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>
|
||
|
||
{/* 分页 */}
|
||
{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}
|
||
/>
|
||
)}
|
||
|
||
{/* 详情加载中的提示(可选覆盖层) */}
|
||
{detailLoading && previewTemplate && (
|
||
<div className="xx-template-detail-loading">
|
||
<LoadingOutlined /> 加载中...
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default TemplateLibrary;
|