/** * 文案库页面 — Issue #1811(v2 完整版) + #1893 AI 能力 * 功能: * - 列表页:卡片列表,搜索(标题/正文)、分类标签筛选、分页 * 每条卡片展示:title、content 前 100 字摘要、title_text、分类 Tag、tags、使用次数、时间 * 操作:编辑 / 删除 / 复制 / 使用(跳创作页预填) * - 新建/编辑弹窗:title、content 多行、segments(按空行自动拆分+手动编辑)、title_text、title_category、 * title_config(字体/颜色/位置/字号)、tags * - #1893 AI 能力: * - 顶部「🎬 从抖音提取」按钮 → 输入抖音链接 → ASR 提取文案 → 自动填充到新建弹窗 * - 新建/编辑弹窗中 content 下方「✨ AI 改写」按钮(带风格选择) → 对比弹窗让用户确认 * - title 旁「✨ AI 生成标题」按钮 → 候选列表一键填入 * - 删除确认(Popconfirm) * - 对接 api/scripts CRUD(mock 阶段 SCRIPTS_API_MOCK=true,AI 接口始终走真实 API) * * 风格对齐标题库(.xx-scripts-* 命名,沿用 CSS 变量) */ import React, { useCallback, useEffect, useState } from "react" import { Button, Card, Empty, Form, Input, InputNumber, List, Modal, Pagination, Popconfirm, Select, Space, Spin, Tag, Tooltip, Typography, message, } from "antd" import { PlusOutlined, EditOutlined, DeleteOutlined, CopyOutlined, PlayCircleOutlined, SearchOutlined, TagsOutlined, VideoCameraOutlined, RobotOutlined, BulbOutlined, } from "@ant-design/icons" import { useNavigate } from "react-router-dom" import { getScripts, createScript, updateScript, deleteScript, duplicateScript, extractScriptFromDouyin, aiRewriteScript, aiGenerateTitles, REWRITE_STYLE_OPTIONS, } from "@/api/scripts" import type { ScriptItem, ScriptCategory, ScriptUpsertRequest, RewriteStyle, AiRewriteResponse, } from "@/api/scripts" import { SCRIPT_CATEGORY_LABEL } from "@/api/scripts" import "./scripts.css" const { TextArea } = Input const { Paragraph, Text } = Typography const PAGE_SIZE = 12 const CATEGORY_OPTIONS: { value: ScriptCategory | "all"; label: string }[] = [ { value: "all", label: "全部分类" }, ...Object.entries(SCRIPT_CATEGORY_LABEL).map(([value, label]) => ({ value: value as ScriptCategory, label, })), ] const FONT_OPTIONS = [ { value: "default", label: "默认" }, { value: "bold", label: "粗体" }, { value: "handwritten", label: "手写" }, { value: "serif", label: "衬线" }, ] const POSITION_OPTIONS = [ { value: "top", label: "顶部" }, { value: "center", label: "居中" }, { value: "bottom", label: "底部" }, ] as const /** 提取后端返回的错误 detail(全局拦截器可能已弹 toast,但这里再兜一层) */ function extractErrMsg(err: unknown, fallback: string): string { const e = err as { response?: { data?: { detail?: string | { message?: string }; message?: string } } message?: string } const data = e?.response?.data if (data?.detail) { if (typeof data.detail === "string") return data.detail if (typeof data.detail.message === "string") return data.detail.message } if (data?.message && typeof data.message === "string") return data.message if (e?.message) return e.message return fallback } const ScriptLibrary: React.FC = () => { const navigate = useNavigate() const [items, setItems] = useState([]) const [total, setTotal] = useState(0) const [page, setPage] = useState(1) const [loading, setLoading] = useState(false) const [keyword, setKeyword] = useState("") const [category, setCategory] = useState("all") // 主弹窗状态 const [modalOpen, setModalOpen] = useState(false) const [editing, setEditing] = useState(null) const [submitting, setSubmitting] = useState(false) const [form] = Form.useForm() // ── #1893 AI 能力状态 ── // 抖音提取 const [douyinModalOpen, setDouyinModalOpen] = useState(false) const [douyinUrl, setDouyinUrl] = useState("") const [douyinLoading, setDouyinLoading] = useState(false) // AI 改写 const [rewriteModalOpen, setRewriteModalOpen] = useState(false) const [rewriteStyle, setRewriteStyle] = useState("口语化") const [rewriteLoading, setRewriteLoading] = useState(false) const [rewriteResult, setRewriteResult] = useState(null) // AI 生成标题 const [titleGenLoading, setTitleGenLoading] = useState(false) const [titleCandidates, setTitleCandidates] = useState([]) const load = useCallback(async () => { setLoading(true) try { const res = await getScripts({ page, page_size: PAGE_SIZE, keyword: keyword.trim() || undefined, category, }) if (Array.isArray(res)) { setItems(res) setTotal(res.length) } else { setItems(res.items ?? []) setTotal(res.total ?? 0) } } catch (err) { message.error(extractErrMsg(err, "加载文案列表失败")) } finally { setLoading(false) } }, [page, keyword, category]) useEffect(() => { load() }, [load]) const resetCreateForm = () => { form.resetFields() form.setFieldsValue({ title: "", content: "", segments: [], tags: [], title_text: "", title_category: "other", title_config: { font: "default", color: "#ffffff", stroke: "#000000", position: "center", size: 48, bold: true, italic: false, }, }) setRewriteResult(null) setTitleCandidates([]) } const openCreate = () => { setEditing(null) resetCreateForm() setModalOpen(true) } const openEdit = (item: ScriptItem) => { setEditing(item) form.setFieldsValue({ title: item.title, content: item.content, segments: item.segments ?? item.content.split(/\n\n+/).filter(Boolean), tags: item.tags ?? [], title_text: item.title_text ?? "", title_category: item.title_category ?? "other", title_config: item.title_config ?? { font: "default", color: "#ffffff", stroke: "#000000", position: "center", size: 48, }, }) setRewriteResult(null) setTitleCandidates([]) setModalOpen(true) } const closeModal = () => { setModalOpen(false) setEditing(null) setRewriteResult(null) setTitleCandidates([]) } /** 提交新建/编辑 */ const handleSubmit = async () => { try { const values = await form.validateFields() setSubmitting(true) const payload: ScriptUpsertRequest = { title: values.title.trim(), content: values.content, segments: values.segments?.filter(Boolean) ?? values.content.split(/\n\n+/).filter(Boolean), tags: values.tags ?? [], title_text: values.title_text?.trim() || undefined, title_category: values.title_category, title_config: values.title_config, } if (editing) { await updateScript(editing.id, payload) message.success("文案已更新") } else { await createScript(payload) message.success("文案已创建") } closeModal() await load() } catch (err) { if ((err as { errorFields?: unknown })?.errorFields) return message.error(extractErrMsg(err, "保存失败")) } finally { setSubmitting(false) } } const handleDelete = async (id: string) => { try { await deleteScript(id) message.success("文案已删除") if (items.length === 1 && page > 1) { setPage(page - 1) } else { await load() } } catch (err) { message.error(extractErrMsg(err, "删除失败")) } } const handleDuplicate = async (id: string) => { try { await duplicateScript(id) message.success("已复制") setPage(1) await load() } catch (err) { message.error(extractErrMsg(err, "复制失败")) } } /** 使用:跳创作页,query 带 scriptId 预填 */ const handleUse = (item: ScriptItem) => { navigate(`/app/generate?scriptId=${encodeURIComponent(item.id)}`) } const preview = (content: string) => { const text = content.replace(/\s+/g, " ").trim() if (text.length <= 100) return text || "(空)" return `${text.slice(0, 100)}…` } const formatTime = (iso?: string) => { if (!iso) return "-" const d = new Date(iso) if (Number.isNaN(d.getTime())) return iso const pad = (n: number) => String(n).padStart(2, "0") return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}` } const categoryColor: Record = { promo: "red", vlog: "blue", knowledge: "green", story: "purple", emotion: "magenta", other: "default", } // ── #1893 AI 操作 ── /** 打开抖音提取弹窗 */ const openDouyinModal = () => { setDouyinUrl("") setDouyinModalOpen(true) } /** 执行抖音提取,成功后打开新建弹窗并预填 content */ const handleDouyinExtract = async () => { const url = douyinUrl.trim() if (!url) { message.warning("请粘贴抖音视频链接") return } if (!/^https?:\/\//i.test(url)) { message.warning("请输入以 http(s):// 开头的完整链接") return } setDouyinLoading(true) try { const res = await extractScriptFromDouyin({ url }) message.success(`提取成功${res.duration_seconds ? `(时长 ${res.duration_seconds}s)` : ""}`) setDouyinModalOpen(false) setDouyinUrl("") // 关闭抖音弹窗,打开新建弹窗预填 content setEditing(null) resetCreateForm() form.setFieldsValue({ title: "", content: res.text, tags: [], title_text: "", title_category: "other", title_config: { font: "default", color: "#ffffff", stroke: "#000000", position: "center", size: 48, bold: true, italic: false, }, }) setModalOpen(true) } catch (err) { message.error(extractErrMsg(err, "抖音文案提取失败")) } finally { setDouyinLoading(false) } } /** 执行 AI 改写,结果写入 rewriteResult 让用户对比确认 */ const handleAiRewrite = async () => { const content = form.getFieldValue("content") as string | undefined if (!content || !content.trim()) { message.warning("请先填写文案正文再改写") return } setRewriteLoading(true) setRewriteResult(null) try { const res = await aiRewriteScript({ content, style: rewriteStyle }) setRewriteResult(res) } catch (err) { message.error(extractErrMsg(err, "AI 改写失败")) } finally { setRewriteLoading(false) } } /** 应用改写结果:替换 content 字段,关闭改写弹窗 */ const applyRewrite = () => { if (!rewriteResult) return form.setFieldsValue({ content: rewriteResult.rewritten }) setRewriteResult(null) setRewriteModalOpen(false) message.success("已应用改写结果") } /** 执行 AI 生成标题,生成候选 */ const handleGenerateTitles = async () => { const content = form.getFieldValue("content") as string | undefined if (!content || !content.trim()) { message.warning("请先填写文案内容") return } setTitleGenLoading(true) setTitleCandidates([]) try { const res = await aiGenerateTitles({ content, count: 3 }) if (!res.titles || res.titles.length === 0) { message.info("AI 未返回可用标题,请稍后再试") return } setTitleCandidates(res.titles) } catch (err) { message.error(extractErrMsg(err, "AI 生成标题失败")) } finally { setTitleGenLoading(false) } } /** 点击候选标题直接填入 title 字段 */ const pickTitle = (t: string) => { form.setFieldsValue({ title: t }) } // 监听 content 字段,用于禁用生成标题按钮(content 为空时) const watchedContent = Form.useWatch("content", form) const contentEmpty = !watchedContent || !String(watchedContent).trim() return (
{/* 顶部筛选栏 */}
} placeholder="搜索标题或正文" value={keyword} onChange={(e) => { setKeyword(e.target.value) setPage(1) }} allowClear style={{ width: 260 }} /> {/* AI 生成标题候选列表 */} {titleCandidates.length > 0 && (
点击候选直接填入:
{titleCandidates.map((t, i) => ( pickTitle(t)} > {t} ))}
)}