feat(task-2.13): 升级模板库页面 — 类型分组展示 + 预览弹窗 + EditPlan 入口 #140

Closed
xiaoxia wants to merge 1 commits from feature/task-2.13-template-library-upgrade into develop
2 changed files with 980 additions and 144 deletions
+644 -121
View File
@@ -1,154 +1,661 @@
/**
* 模板库页面 — V21 设计系统
* 页面头部 + 工具栏(搜索+分类胶囊按钮组)+ 4列模板卡片网格 + 空状态
* 使用 mock 数据,CSS 变量,V21 组件
* 模板库页面(升级版)— V21 设计系统
* 任务 2.13:模板类型分类展示、模板预览功能、创建 EditPlan 入口
*
* - 按 EditTemplate 类型分组展示
* - 缩略图 + 预览弹窗
* - 创建 EditPlan 入口 UI
* - Mock 数据 + 预留 API 对接接口
*/
import React, { useState } from "react";
import React, { useState, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui";
import "./templates.css";
/* ============================================================
* Mock 数据
* 类型定义(对齐后端 EditTemplate / EditPlan / TemplateClipConfig
* ============================================================ */
type TemplateCategory = "口播" | "种草" | "产品" | "品牌";
interface TemplateItem {
/** 模板片段配置 */
interface TemplateClipConfig {
id: string;
name: string;
category: TemplateCategory;
usageCount: number;
isFavorite: boolean;
gradient: string;
codeSnippet: string;
order: number;
clipType: string;
description: string;
duration: number; // 秒
}
const categories: Array<"全部" | TemplateCategory> = [
"全部",
"口播",
"种草",
"产品",
"品牌",
/** 模板类型 */
type EditTemplateType =
| "口播"
| "种草"
| "产品"
| "品牌"
| "混剪"
| "Vlog";
/** 模板数据 */
interface EditTemplate {
id: string;
name: string;
type: EditTemplateType;
description: string;
usageCount: number;
isFavorite: boolean;
thumbnailGradient: string;
scriptContent: string;
clipConfigs: TemplateClipConfig[];
recommendedDuration: number; // 秒
tags: string[];
}
/** 创建 EditPlan 参数 */
interface CreateEditPlanParams {
templateId: string;
planName: string;
}
/* ============================================================
* API 占位函数(预留后端对接)
* ============================================================ */
/** 获取模板列表 */
export async function fetchTemplates(
type?: EditTemplateType,
): Promise<EditTemplate[]> {
// TODO: 对接后端 GET /api/templates?type=xxx
void type;
return mockTemplates;
}
/** 获取模板详情 */
export async function fetchTemplateById(
id: string,
): Promise<EditTemplate | null> {
// TODO: 对接后端 GET /api/templates/:id
return mockTemplates.find((t) => t.id === id) ?? null;
}
/** 创建 EditPlan */
export async function createEditPlan(
params: CreateEditPlanParams,
): Promise<{ planId: string }> {
// TODO: 对接后端 POST /api/edit-plans
console.log("[Mock] createEditPlan", params);
return { planId: `plan-${Date.now()}` };
}
/* ============================================================
* 模板类型配置
* ============================================================ */
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 mockTemplates: TemplateItem[] = [
/* ============================================================
* Mock 数据
* ============================================================ */
const mockTemplates: EditTemplate[] = [
{
id: "tpl-1",
name: "商品口播模板",
category: "口播",
type: "口播",
description: "适用于电商商品介绍的口播视频模板,包含开场、产品介绍、卖点展示、结尾引导等完整结构。",
usageCount: 128,
isFavorite: true,
gradient: "linear-gradient(135deg, #6366f1, #8b5cf6)",
codeSnippet:
"# 商品口播脚本\n## 开场白\n大家好,今天给大家推荐...\n## 产品介绍\n这款产品的特点是...",
thumbnailGradient: "linear-gradient(135deg, #6366f1, #8b5cf6)",
scriptContent: "# 商品口播脚本\n## 开场白\n大家好,今天给大家推荐...\n## 产品介绍\n这款产品的特点是...\n## 使用演示\n我们来看实际效果...\n## 总结\n赶紧下单吧!",
clipConfigs: [
{ id: "c1", order: 1, clipType: "开场", description: "吸引注意力的开场白", duration: 5 },
{ id: "c2", order: 2, clipType: "产品展示", description: "产品外观和功能展示", duration: 15 },
{ id: "c3", order: 3, clipType: "卖点讲解", description: "核心卖点详细说明", duration: 20 },
{ id: "c4", order: 4, clipType: "结尾", description: "引导下单的结尾", duration: 5 },
],
recommendedDuration: 45,
tags: ["电商", "口播", "商品介绍"],
},
{
id: "tpl-2",
name: "种草笔记视频模板",
category: "种草",
type: "种草",
description: "适合美妆、护肤、生活方式等种草类内容,真实体验分享风格。",
usageCount: 96,
isFavorite: false,
gradient: "linear-gradient(135deg, #10b981, #059669)",
codeSnippet:
"# 种草视频\n## 使用场景\n早上出门前的护肤步骤...\n## 产品亮点\n温和不刺激,适合敏感肌...",
thumbnailGradient: "linear-gradient(135deg, #10b981, #059669)",
scriptContent: "# 种草视频\n## 使用场景\n早上出门前的护肤步骤...\n## 产品亮点\n温和不刺激,适合敏感肌...\n## 使用心得\n用了一个月后的感受...",
clipConfigs: [
{ id: "c1", order: 1, clipType: "场景引入", description: "日常场景带入", duration: 8 },
{ id: "c2", order: 2, clipType: "产品体验", description: "使用过程展示", duration: 20 },
{ id: "c3", order: 3, clipType: "效果对比", description: "使用前后对比", duration: 10 },
{ id: "c4", order: 4, clipType: "总结推荐", description: "使用心得总结", duration: 7 },
],
recommendedDuration: 45,
tags: ["种草", "美妆", "体验分享"],
},
{
id: "tpl-3",
name: "新品发布展示模板",
category: "产品",
type: "产品",
description: "适合新品发布、产品升级等场景,突出产品亮点和技术优势。",
usageCount: 74,
isFavorite: false,
gradient: "linear-gradient(135deg, #0ea5e9, #0284c7)",
codeSnippet:
"# 新品发布\n## 产品概览\n全新升级,性能提升50%...\n## 核心功能\nAI智能识别,一键优化...",
thumbnailGradient: "linear-gradient(135deg, #0ea5e9, #0284c7)",
scriptContent: "# 新品发布\n## 产品概览\n全新升级,性能提升50%...\n## 核心功能\nAI智能识别,一键优化...\n## 技术参数\n详细技术规格展示...",
clipConfigs: [
{ id: "c1", order: 1, clipType: "悬念开场", description: "产品悬念引入", duration: 5 },
{ id: "c2", order: 2, clipType: "产品全景", description: "产品360度展示", duration: 10 },
{ id: "c3", order: 3, clipType: "功能演示", description: "核心功能实操", duration: 25 },
{ id: "c4", order: 4, clipType: "技术规格", description: "参数对比展示", duration: 10 },
{ id: "c5", order: 5, clipType: "结尾", description: "购买引导", duration: 5 },
],
recommendedDuration: 55,
tags: ["产品", "发布", "科技"],
},
{
id: "tpl-4",
name: "品牌故事宣传片",
category: "品牌",
type: "品牌",
description: "讲述品牌故事,传递品牌理念,适合品牌形象建设和宣传。",
usageCount: 52,
isFavorite: true,
gradient: "linear-gradient(135deg, #f59e0b, #d97706)",
codeSnippet:
"# 品牌故事\n## 品牌起源\n2020年,我们从一个想法开始...\n## 品牌理念\n让创作更简单...",
thumbnailGradient: "linear-gradient(135deg, #f59e0b, #d97706)",
scriptContent: "# 品牌故事\n## 品牌起源\n2020年,我们从一个想法开始...\n## 品牌理念\n让创作更简单...\n## 团队风采\n我们的团队...",
clipConfigs: [
{ id: "c1", order: 1, clipType: "品牌起源", description: "创业故事引入", duration: 15 },
{ id: "c2", order: 2, clipType: "发展历程", description: "里程碑事件", duration: 15 },
{ id: "c3", order: 3, clipType: "核心理念", description: "品牌价值主张", duration: 10 },
{ id: "c4", order: 4, clipType: "未来展望", description: "品牌愿景", duration: 10 },
],
recommendedDuration: 50,
tags: ["品牌", "宣传", "故事"],
},
{
id: "tpl-5",
name: "知识分享口播模板",
category: "口播",
type: "口播",
description: "适合知识博主、教程类内容,结构清晰,信息密度高。",
usageCount: 215,
isFavorite: false,
gradient: "linear-gradient(135deg, #8b5cf6, #7c3aed)",
codeSnippet:
"# 知识分享\n## 主题引入\n今天我们来聊一个很多人问的问题...\n## 核心内容\n第一点,第二点,第三点...",
thumbnailGradient: "linear-gradient(135deg, #8b5cf6, #7c3aed)",
scriptContent: "# 知识分享\n## 主题引入\n今天我们来聊一个很多人问的问题...\n## 核心内容\n第一点,第二点,第三点...\n## 总结\n希望对你有帮助...",
clipConfigs: [
{ id: "c1", order: 1, clipType: "开场", description: "话题引入", duration: 5 },
{ id: "c2", order: 2, clipType: "知识点1", description: "第一个要点", duration: 15 },
{ id: "c3", order: 3, clipType: "知识点2", description: "第二个要点", duration: 15 },
{ id: "c4", order: 4, clipType: "总结", description: "内容回顾", duration: 5 },
],
recommendedDuration: 40,
tags: ["知识", "教程", "口播"],
},
{
id: "tpl-6",
name: "好物推荐种草模板",
category: "种草",
type: "种草",
description: "以痛点引入,展示解决方案,适合日常好物推荐。",
usageCount: 183,
isFavorite: true,
gradient: "linear-gradient(135deg, #059669, #047857)",
codeSnippet:
"# 好物推荐\n## 痛点引入\n你是不是也有这样的困扰...\n## 解决方案\n直到我发现了这个神器...",
thumbnailGradient: "linear-gradient(135deg, #059669, #047857)",
scriptContent: "# 好物推荐\n## 痛点引入\n你是不是也有这样的困扰...\n## 解决方案\n直到我发现了这个神器...\n## 使用效果\n一个月后的变化...",
clipConfigs: [
{ id: "c1", order: 1, clipType: "痛点", description: "用户痛点共鸣", duration: 8 },
{ id: "c2", order: 2, clipType: "产品引入", description: "产品出场", duration: 10 },
{ id: "c3", order: 3, clipType: "使用展示", description: "实际使用过程", duration: 15 },
{ id: "c4", order: 4, clipType: "效果", description: "使用效果展示", duration: 10 },
],
recommendedDuration: 43,
tags: ["种草", "好物", "推荐"],
},
{
id: "tpl-7",
name: "产品对比评测模板",
category: "产品",
type: "产品",
description: "多维度对比评测,客观公正,适合数码、家电等产品。",
usageCount: 67,
isFavorite: false,
gradient: "linear-gradient(135deg, #0284c7, #0369a1)",
codeSnippet:
"# 产品对比\n## 对比维度\n外观、性能、价格、体验...\n## 总结推荐\n综合来看,A适合...B适合...",
thumbnailGradient: "linear-gradient(135deg, #0284c7, #0369a1)",
scriptContent: "# 产品对比\n## 对比维度\n外观、性能、价格、体验...\n## 详细对比\n逐项分析...\n## 总结推荐\n综合来看,A适合...B适合...",
clipConfigs: [
{ id: "c1", order: 1, clipType: "产品亮相", description: "两款产品展示", duration: 8 },
{ id: "c2", order: 2, clipType: "外观对比", description: "外观设计对比", duration: 12 },
{ id: "c3", order: 3, clipType: "性能测试", description: "性能数据对比", duration: 15 },
{ id: "c4", order: 4, clipType: "总结", description: "购买建议", duration: 10 },
],
recommendedDuration: 45,
tags: ["评测", "对比", "产品"],
},
{
id: "tpl-8",
name: "品牌活动预热模板",
category: "品牌",
type: "品牌",
description: "适合品牌活动、促销预热,制造期待感。",
usageCount: 41,
isFavorite: false,
gradient: "linear-gradient(135deg, #d97706, #b45309)",
codeSnippet:
"# 活动预热\n## 悬念引入\n倒计时3天,惊喜即将揭晓...\n## 活动亮点\n福利一、福利二、福利三...",
thumbnailGradient: "linear-gradient(135deg, #d97706, #b45309)",
scriptContent: "# 活动预热\n## 悬念引入\n倒计时3天,惊喜即将揭晓...\n## 活动亮点\n福利一、福利二、福利三...\n## 参与方式\n如何参与活动...",
clipConfigs: [
{ id: "c1", order: 1, clipType: "悬念", description: "倒计时悬念", duration: 5 },
{ id: "c2", order: 2, clipType: "亮点", description: "活动亮点展示", duration: 15 },
{ id: "c3", order: 3, clipType: "福利", description: "优惠福利说明", duration: 10 },
{ id: "c4", order: 4, clipType: "引导", description: "参与方式引导", duration: 5 },
],
recommendedDuration: 35,
tags: ["品牌", "活动", "预热"],
},
{
id: "tpl-9",
name: "多素材混剪模板",
type: "混剪",
description: "适合多段素材拼接,自动匹配节奏,适合混剪类视频。",
usageCount: 89,
isFavorite: false,
thumbnailGradient: "linear-gradient(135deg, #8b5cf6, #6d28d9)",
scriptContent: "# 混剪模板\n## 素材1\n开场高能片段...\n## 素材2\n过渡衔接...\n## 素材3\n高潮部分...\n## 结尾\n精彩回顾...",
clipConfigs: [
{ id: "c1", order: 1, clipType: "高能开场", description: "吸引眼球的开场", duration: 5 },
{ id: "c2", order: 2, clipType: "素材A", description: "第一段素材", duration: 10 },
{ id: "c3", order: 3, clipType: "过渡", description: "转场衔接", duration: 3 },
{ id: "c4", order: 4, clipType: "素材B", description: "第二段素材", duration: 10 },
{ id: "c5", order: 5, clipType: "高潮", description: "高潮片段", duration: 8 },
{ id: "c6", order: 6, clipType: "结尾", description: "精彩回顾", duration: 4 },
],
recommendedDuration: 40,
tags: ["混剪", "多素材", "节奏"],
},
{
id: "tpl-10",
name: "日常Vlog模板",
type: "Vlog",
description: "记录日常生活,轻松自然的Vlog风格模板。",
usageCount: 156,
isFavorite: true,
thumbnailGradient: "linear-gradient(135deg, #ec4899, #db2777)",
scriptContent: "# 日常Vlog\n## 早安\n今天又是美好的一天...\n## 出门\n准备出门啦...\n## 日常\n记录精彩瞬间...\n## 晚安\n今天也很充实...",
clipConfigs: [
{ id: "c1", order: 1, clipType: "早安", description: "起床日常", duration: 8 },
{ id: "c2", order: 2, clipType: "出门", description: "准备出门", duration: 10 },
{ id: "c3", order: 3, clipType: "日常", description: "日常活动记录", duration: 20 },
{ id: "c4", order: 4, clipType: "晚安", description: "一天总结", duration: 7 },
],
recommendedDuration: 45,
tags: ["Vlog", "日常", "生活"],
},
];
/* ============================================================
* 组件
* 辅助函数
* ============================================================ */
/** 获取类型对应颜色 */
const getTypeColor = (type: EditTemplateType): string => {
const found = TEMPLATE_TYPES.find((t) => t.type === type);
return found?.color ?? "#6366f1";
};
/** 获取片段类型标签 */
const getClipTypeLabel = (clipType: string): string => clipType;
/** 获取片段类型颜色 */
const getClipTypeColor = (clipType: string): string => {
const colorMap: Record<string, string> = {
: "#6366f1",
: "#0ea5e9",
: "#10b981",
: "#f59e0b",
: "#8b5cf6",
: "#ec4899",
: "#14b8a6",
: "#f59e0b",
: "#6366f1",
: "#0ea5e9",
: "#10b981",
: "#64748b",
: "#f59e0b",
: "#0ea5e9",
: "#8b5cf6",
: "#10b981",
1: "#6366f1",
2: "#8b5cf6",
: "#ef4444",
: "#10b981",
使: "#0ea5e9",
: "#f59e0b",
: "#6366f1",
: "#0ea5e9",
: "#10b981",
: "#f59e0b",
: "#6366f1",
: "#10b981",
: "#f59e0b",
: "#0ea5e9",
: "#ef4444",
: "#64748b",
: "#ec4899",
: "#f59e0b",
: "#10b981",
: "#0ea5e9",
: "#8b5cf6",
};
return colorMap[clipType] ?? "#6366f1";
};
/** 格式化时长 */
const formatDuration = (seconds: number): string => {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
if (m === 0) return `${s}`;
return `${m}${s > 0 ? `${s}` : ""}`;
};
/* ============================================================
* 预览弹窗组件
* ============================================================ */
interface TemplatePreviewModalProps {
template: EditTemplate;
isFavorite: boolean;
onClose: () => void;
onToggleFavorite: (id: string) => void;
onUse: (template: EditTemplate) => void;
}
const TemplatePreviewModal: React.FC<TemplatePreviewModalProps> = ({
template,
isFavorite,
onClose,
onToggleFavorite,
onUse,
}) => {
const totalDuration = template.clipConfigs.reduce(
(sum, c) => sum + c.duration,
0,
);
return (
<div className="xx-template-modal-overlay" onClick={onClose}>
<div
className="xx-template-modal"
onClick={(e) => e.stopPropagation()}
>
{/* 关闭按钮 */}
<button
className="xx-template-modal-close"
onClick={onClose}
title="关闭"
>
</button>
{/* 预览区域 */}
<div
className="xx-template-modal-preview"
style={{ background: template.thumbnailGradient }}
>
<div className="xx-template-modal-preview-content">
<span style={{ fontSize: 48 }}>
{TEMPLATE_TYPES.find((t) => t.type === template.type)?.icon ?? "📋"}
</span>
<span style={{ fontSize: 18, fontWeight: 600, color: "#fff", marginTop: 8 }}>
{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.type),
background: `${getTypeColor(template.type)}18`,
}}
>
{TEMPLATE_TYPES.find((t) => t.type === template.type)?.icon}{" "}
{template.type}
</span>
</div>
{/* 描述 */}
<p className="xx-template-modal-desc">{template.description}</p>
{/* 标签 */}
<div className="xx-template-modal-tags">
{template.tags.map((tag) => (
<span key={tag} className="xx-template-modal-tag">
#{tag}
</span>
))}
</div>
{/* 脚本内容 */}
<div className="xx-template-modal-section">
<h4>📝 </h4>
<pre className="xx-template-modal-script">
{template.scriptContent}
</pre>
</div>
{/* 视频结构 */}
<div className="xx-template-modal-section">
<h4>🎬 </h4>
<div className="xx-template-modal-clip-list">
{template.clipConfigs
.sort((a, b) => a.order - b.order)
.map((clip) => (
<div key={clip.id} className="xx-template-modal-clip-item">
<span
className="xx-template-modal-clip-badge"
style={{
color: getClipTypeColor(clip.clipType),
background: `${getClipTypeColor(clip.clipType)}18`,
}}
>
{getClipTypeLabel(clip.clipType)}
</span>
<span className="xx-template-modal-clip-desc">
{clip.description}
</span>
<span className="xx-template-modal-clip-duration">
{clip.duration}
</span>
</div>
))}
</div>
<div className="xx-template-modal-total-duration">
{formatDuration(totalDuration)}
{template.recommendedDuration !== totalDuration && (
<span>
{" "}
· {formatDuration(template.recommendedDuration)}
</span>
)}
</div>
</div>
{/* 统计信息 */}
<div className="xx-template-modal-stats">
<span>使 {template.usageCount} </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 buttonType="ghost" buttonSize="md" onClick={onClose}>
</Button>
<Button
buttonType="primary"
buttonSize="md"
onClick={() => onUse(template)}
>
使
</Button>
</div>
</div>
</div>
</div>
);
};
/* ============================================================
* 模板卡片组件
* ============================================================ */
interface TemplateCardProps {
template: EditTemplate;
isFavorite: boolean;
onPreview: (template: EditTemplate) => void;
onToggleFavorite: (id: string, e: React.MouseEvent) => void;
onUse: (template: EditTemplate) => 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">
<div
className="xx-template-thumb-bg"
style={{ background: template.thumbnailGradient }}
>
{template.scriptContent.slice(0, 80)}...
</div>
<div className="xx-template-thumb-overlay" />
<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">
<h4 className="xx-template-name">{template.name}</h4>
<span
className="xx-template-category-pill"
style={{
color: getTypeColor(template.type),
background: `${getTypeColor(template.type)}18`,
}}
>
{template.type}
</span>
</div>
<p className="xx-template-desc">{template.description}</p>
<div className="xx-template-meta">
<span className="xx-template-usage">
使 {template.usageCount}
</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 [searchText, setSearchText] = useState("");
const [activeCategory, setActiveCategory] = useState<
"全部" | TemplateCategory
>("全部");
const [activeType, setActiveType] = useState<EditTemplateType | "全部">("全部");
const [previewTemplate, setPreviewTemplate] = useState<EditTemplate | null>(null);
const [favorites, setFavorites] = useState<Set<string>>(
() => new Set(mockTemplates.filter((t) => t.isFavorite).map((t) => t.id)),
);
/** 切换收藏 */
const toggleFavorite = (id: string, e: React.MouseEvent) => {
e.stopPropagation();
const toggleFavorite = (id: string, e?: React.MouseEvent) => {
e?.stopPropagation();
setFavorites((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
/** 过滤模板 */
const filtered = mockTemplates.filter((t) => {
const matchSearch =
!searchText ||
t.name.toLowerCase().includes(searchText.toLowerCase()) ||
t.codeSnippet.toLowerCase().includes(searchText.toLowerCase());
const matchCategory =
activeCategory === "全部" || t.category === activeCategory;
return matchSearch && matchCategory;
});
const filtered = useMemo(() => {
return mockTemplates.filter((t) => {
const matchSearch =
!searchText ||
t.name.toLowerCase().includes(searchText.toLowerCase()) ||
t.description.toLowerCase().includes(searchText.toLowerCase()) ||
t.tags.some((tag) =>
tag.toLowerCase().includes(searchText.toLowerCase()),
);
const matchType = activeType === "全部" || t.type === activeType;
return matchSearch && matchType;
});
}, [searchText, activeType]);
/** 按类型分组 */
const groupedTemplates = useMemo(() => {
const groups: Record<string, EditTemplate[]> = {};
for (const tpl of filtered) {
if (!groups[tpl.type]) groups[tpl.type] = [];
groups[tpl.type].push(tpl);
}
return groups;
}, [filtered]);
/** 使用模板 → 创建 EditPlan */
const handleUseTemplate = async (template: EditTemplate) => {
try {
const result = await createEditPlan({
templateId: template.id,
planName: `基于「${template.name}」的剪辑计划`,
});
console.log("[TemplateLibrary] EditPlan created:", result.planId);
navigate("/app/editing-planner");
} catch (err) {
console.error("[TemplateLibrary] createEditPlan failed:", err);
}
};
return (
<div className="xx-templates-page">
@@ -156,96 +663,112 @@ const TemplateLibrary: React.FC = () => {
<div className="xx-templates-header">
<div className="xx-templates-header-text">
<h2></h2>
<p></p>
<p></p>
</div>
<Button buttonType="primary" buttonSize="md">
+
</Button>
</div>
{/* ── 工具栏:搜索 + 类按钮组 ─────────────────────────── */}
{/* ── 工具栏:搜索 + 类按钮组 ─────────────────────────── */}
<div className="xx-templates-toolbar">
<div className="xx-templates-search">
<span className="xx-templates-search-icon">🔍</span>
<input
className="xx-templates-search-input"
type="text"
placeholder="搜索模板名称或内容..."
placeholder="搜索模板名称、描述或标签..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
</div>
<div className="xx-templates-categories">
{categories.map((cat) => (
{TEMPLATE_TYPES.map((cat) => (
<button
key={cat}
className={`xx-templates-cat-btn${activeCategory === cat ? " active" : ""}`}
onClick={() => setActiveCategory(cat)}
key={cat.type}
className={`xx-templates-cat-btn${activeType === cat.type ? " active" : ""}`}
onClick={() => setActiveType(cat.type)}
>
{cat}
<span className="xx-templates-cat-icon">{cat.icon}</span>
{cat.label}
</button>
))}
</div>
</div>
{/* ── 模板卡片网格 ──────────────────────────────────────── */}
{/* ── 模板展示区 ────────────────────────────────────────── */}
{filtered.length === 0 ? (
<div className="xx-templates-empty">
<div className="xx-templates-empty-icon">📭</div>
<h3>
{searchText || activeCategory !== "全部"
{searchText || activeType !== "全部"
? "未找到匹配的模板"
: "暂无模板"}
</h3>
<p>
{searchText || activeCategory !== "全部"
? "试试调整搜索条件或切换类"
{searchText || activeType !== "全部"
? "试试调整搜索条件或切换类"
: "点击上方「创建模板」开始创作"}
</p>
</div>
) : activeType === "全部" ? (
/* 全部类型 → 按类型分组展示 */
<div className="xx-templates-grouped">
{Object.entries(groupedTemplates).map(([type, templates]) => {
const typeConfig = TEMPLATE_TYPES.find((t) => t.type === type);
return (
<div key={type} className="xx-templates-group">
<div className="xx-templates-group-header">
<span className="xx-templates-group-icon">
{typeConfig?.icon ?? "📋"}
</span>
<h3>{type}</h3>
<span className="xx-templates-group-count">
{templates.length}
</span>
</div>
<div className="xx-templates-grid">
{templates.map((tpl) => (
<TemplateCard
key={tpl.id}
template={tpl}
isFavorite={favorites.has(tpl.id)}
onPreview={setPreviewTemplate}
onToggleFavorite={toggleFavorite}
onUse={handleUseTemplate}
/>
))}
</div>
</div>
);
})}
</div>
) : (
/* 单类型 → 平铺网格 */
<div className="xx-templates-grid">
{filtered.map((tpl) => (
<div key={tpl.id} className="xx-template-card">
{/* 缩略图 */}
<div className="xx-template-thumb">
<div
className="xx-template-thumb-bg"
style={{ background: tpl.gradient }}
>
{tpl.codeSnippet}
</div>
<div className="xx-template-thumb-overlay" />
<button
className={`xx-template-fav-btn${favorites.has(tpl.id) ? " is-favorite" : ""}`}
onClick={(e) => toggleFavorite(tpl.id, e)}
title={favorites.has(tpl.id) ? "取消收藏" : "收藏"}
>
{favorites.has(tpl.id) ? "★" : "☆"}
</button>
</div>
{/* 信息区 */}
<div className="xx-template-info">
<div className="xx-template-info-top">
<h4 className="xx-template-name">{tpl.name}</h4>
<span
className={`xx-template-category-pill xx-template-category-pill--${tpl.category}`}
>
{tpl.category}
</span>
</div>
<div className="xx-template-meta">
<span className="xx-template-usage">
使 {tpl.usageCount}
</span>
<button className="xx-template-use-btn">使</button>
</div>
</div>
</div>
<TemplateCard
key={tpl.id}
template={tpl}
isFavorite={favorites.has(tpl.id)}
onPreview={setPreviewTemplate}
onToggleFavorite={toggleFavorite}
onUse={handleUseTemplate}
/>
))}
</div>
)}
{/* ── 预览弹窗 ──────────────────────────────────────────── */}
{previewTemplate && (
<TemplatePreviewModal
template={previewTemplate}
isFavorite={favorites.has(previewTemplate.id)}
onClose={() => setPreviewTemplate(null)}
onToggleFavorite={toggleFavorite}
onUse={handleUseTemplate}
/>
)}
</div>
);
};
+336 -23
View File
@@ -1,6 +1,6 @@
/**
* 模板库页面 - V21 设计系统样式
* 页面头部 + 工具栏(搜索+分类按钮组)+ 4列模板卡片网格 + 空状态
* 模板库页面 - V21 设计系统样式(升级版)
* 任务 2.13:新增分组展示、预览弹窗、卡片描述等样式
* 统一使用 CSS 变量,支持深色/浅色主题
*/
@import "../../styles/global.css";
@@ -106,6 +106,9 @@
cursor: pointer;
transition: var(--transition-all);
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 4px;
}
.xx-templates-cat-btn:hover {
@@ -120,6 +123,43 @@
font-weight: 600;
}
.xx-templates-cat-icon {
font-size: 14px;
}
/* ============================================================
分组展示
============================================================ */
.xx-templates-grouped {
display: flex;
flex-direction: column;
gap: var(--space-xl);
}
.xx-templates-group-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: var(--space-md);
}
.xx-templates-group-icon {
font-size: 20px;
}
.xx-templates-group-header h3 {
margin: 0;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.xx-templates-group-count {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-left: 4px;
}
/* ============================================================
模板卡片网格
============================================================ */
@@ -183,6 +223,27 @@
pointer-events: none;
}
/* 预览提示(hover 显示) */
.xx-template-preview-hint {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(2px);
color: #fff;
font-size: 14px;
font-weight: 500;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 1;
}
.xx-template-card:hover .xx-template-preview-hint {
opacity: 1;
}
/* 收藏按钮 */
.xx-template-fav-btn {
position: absolute;
@@ -224,7 +285,7 @@
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
margin-bottom: 8px;
}
.xx-template-name {
@@ -239,6 +300,18 @@
min-width: 0;
}
/* 卡片描述 */
.xx-template-desc {
margin: 0 0 10px;
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 分类药丸 */
.xx-template-category-pill {
display: inline-flex;
@@ -251,26 +324,6 @@
flex-shrink: 0;
}
.xx-template-category-pill--口播 {
color: var(--primary-color);
background: var(--primary-soft);
}
.xx-template-category-pill--种草 {
color: var(--success-color);
background: var(--success-soft);
}
.xx-template-category-pill--产品 {
color: var(--info-color, #0ea5e9);
background: var(--info-soft, #e0f2fe);
}
.xx-template-category-pill--品牌 {
color: var(--warning-color);
background: var(--warning-soft);
}
/* 使用次数 */
.xx-template-meta {
display: flex;
@@ -333,6 +386,253 @@
color: var(--text-secondary);
}
/* ============================================================
预览弹窗
============================================================ */
.xx-template-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.xx-template-modal {
background: var(--bg-primary);
border-radius: var(--radius-lg);
max-width: 720px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
position: relative;
animation: slideUp 0.25s ease;
}
@keyframes slideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.xx-template-modal-close {
position: absolute;
top: 12px;
right: 12px;
width: 32px;
height: 32px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px);
color: #fff;
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
transition: var(--transition-all);
}
.xx-template-modal-close:hover {
background: rgba(0, 0, 0, 0.5);
transform: scale(1.1);
}
/* 预览区域 */
.xx-template-modal-preview {
aspect-ratio: 16 / 9;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
overflow: hidden;
}
.xx-template-modal-preview-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
}
/* 内容区域 */
.xx-template-modal-content {
padding: 24px;
}
.xx-template-modal-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.xx-template-modal-title-row h3 {
margin: 0;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
}
.xx-template-modal-type-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
}
.xx-template-modal-desc {
margin: 0 0 12px;
font-size: var(--font-size-sm);
color: var(--text-secondary);
line-height: 1.6;
}
.xx-template-modal-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 16px;
}
.xx-template-modal-tag {
padding: 2px 8px;
border-radius: 4px;
background: var(--bg-surface, var(--bg-secondary));
color: var(--text-secondary);
font-size: 11px;
}
/* 脚本内容 */
.xx-template-modal-section {
margin-bottom: 16px;
}
.xx-template-modal-section h4 {
margin: 0 0 8px;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.xx-template-modal-script {
margin: 0;
padding: 12px;
background: var(--bg-surface, var(--bg-secondary));
border-radius: var(--radius-sm);
font-family: "SF Mono", "Fira Code", monospace;
font-size: 12px;
line-height: 1.6;
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-word;
max-height: 160px;
overflow-y: auto;
}
/* 片段列表 */
.xx-template-modal-clip-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.xx-template-modal-clip-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--bg-surface, var(--bg-secondary));
border-radius: var(--radius-sm);
}
.xx-template-modal-clip-badge {
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
white-space: nowrap;
}
.xx-template-modal-clip-desc {
flex: 1;
font-size: 12px;
color: var(--text-primary);
}
.xx-template-modal-clip-duration {
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
}
.xx-template-modal-total-duration {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border-color);
font-size: 12px;
color: var(--text-secondary);
text-align: right;
}
/* 统计信息 */
.xx-template-modal-stats {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-top: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
margin-bottom: 16px;
font-size: 13px;
color: var(--text-secondary);
}
.xx-template-modal-fav-btn {
padding: 4px 12px;
border-radius: 999px;
border: 1px solid var(--border-color);
background: transparent;
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: var(--transition-all);
}
.xx-template-modal-fav-btn:hover {
border-color: #fbbf24;
color: #fbbf24;
}
.xx-template-modal-fav-btn.is-favorite {
border-color: #fbbf24;
color: #fbbf24;
background: #fbbf2418;
}
/* 操作按钮 */
.xx-template-modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
}
/* ============================================================
响应式
============================================================ */
@@ -368,6 +668,19 @@
.xx-templates-categories {
justify-content: flex-start;
}
/* 弹窗移动端适配 */
.xx-template-modal-content {
padding: 16px;
}
.xx-template-modal-actions {
flex-direction: column-reverse;
}
.xx-template-modal-actions button {
width: 100%;
}
}
@media (max-width: 480px) {