/** * 文案库页面 — Issue #1811(#1894 方向修正后) * 功能: * - 列表页:卡片列表,搜索(标题/正文)、分类标签筛选、分页 * 每条卡片展示:title(标题)、content 前 100 字摘要、分类 Tag、tags、使用次数、时间 * 操作:编辑 / 删除 / 复制 / 使用(跳创作页预填) * - 新建/编辑弹窗:标题(原"名称")、正文(含 AI 改写)、分类、标签 * - #1893/#1894 AI 能力: * - 顶部「🎬 从抖音提取」按钮 → 粘贴分享文案/链接(后端自动提取URL) → ASR 提取文案 → 自动填充到新建弹窗 * - 正文下方「✨ AI 改写」按钮 → 点击直接执行(美化 loading spinner + "正在改写..."), * 成功自动替换正文并 toast「改写成功」1s 自动关闭;失败 toast 错误 * - 标题旁「✨ AI 生成标题」按钮 → 候选列表一键填入 * - 删除确认(Popconfirm) * - 对接 api/scripts CRUD(mock 阶段 SCRIPTS_API_MOCK=true,AI 接口始终走真实 API) */ import React, { useCallback, useEffect, useState } from "react" import { Button, Card, Empty, Form, Input, 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 } 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, })), ] /** 提取后端返回的错误 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 改写(#1894: 点击直接执行,美化 loading + 1s 自动关闭 toast,不弹确认弹窗) const [rewriteStyle, setRewriteStyle] = useState("口语化") const [rewriteLoading, setRewriteLoading] = useState(false) // 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_category: "other", }) 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_category: item.title_category ?? "other", }) setTitleCandidates([]) setModalOpen(true) } const closeModal = () => { setModalOpen(false) setEditing(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_category: values.title_category, // #1894: 配套标题 / 标题样式配置字段已从 UI 移除,后端即将删除,不再传 } 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) } /** #1894:执行抖音提取。前端不再做 URL 前缀校验,直接把用户粘贴的原文(含分享文案+链接)交给后端 _extract_url_from_text 自动提取。后端 400 错误(未找到链接/非抖音域名等)直接透传给用户。 */ const handleDouyinExtract = async () => { const raw = douyinUrl.trim() if (!raw) { message.warning("请粘贴抖音视频链接或分享文案") return } setDouyinLoading(true) try { const res = await extractScriptFromDouyin({ url: raw }) message.success(`提取成功${res.duration_seconds ? `(时长 ${res.duration_seconds}s)` : ""}`) setDouyinModalOpen(false) setDouyinUrl("") // 关闭抖音弹窗,打开新建弹窗预填 content setEditing(null) resetCreateForm() form.setFieldsValue({ title: "", content: res.text, tags: [], title_category: "other", }) setModalOpen(true) } catch (err) { // 后端 400(未找到有效链接/仅支持抖音域名等)直接透传错误信息 message.error(extractErrMsg(err, "抖音文案提取失败")) } finally { setDouyinLoading(false) } } /** #1894: 执行 AI 改写,完成后自动替换正文并 toast 1 秒关闭 */ const handleAiRewrite = async (style: RewriteStyle) => { const content = form.getFieldValue("content") as string | undefined if (!content || !content.trim()) { message.warning("请先填写文案正文再改写") return } setRewriteLoading(true) try { const res = await aiRewriteScript({ content, style }) form.setFieldsValue({ content: res.rewritten }) message.success({ content: "改写成功", duration: 1 }) } catch (err) { message.error(extractErrMsg(err, "AI 改写失败")) } finally { setRewriteLoading(false) } } /** 执行 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} ))}
)}