diff --git a/apps/web/src/pages/scripts/ScriptLibrary.tsx b/apps/web/src/pages/scripts/ScriptLibrary.tsx index b74dc7296..123e4a5ef 100644 --- a/apps/web/src/pages/scripts/ScriptLibrary.tsx +++ b/apps/web/src/pages/scripts/ScriptLibrary.tsx @@ -15,7 +15,7 @@ * * 风格对齐标题库(.xx-scripts-* 命名,沿用 CSS 变量) */ -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useState } from "react" import { Button, Card, @@ -34,7 +34,7 @@ import { Tooltip, Typography, message, -} from "antd"; +} from "antd" import { PlusOutlined, EditOutlined, @@ -46,8 +46,8 @@ import { VideoCameraOutlined, RobotOutlined, BulbOutlined, -} from "@ant-design/icons"; -import { useNavigate } from "react-router-dom"; +} from "@ant-design/icons" +import { useNavigate } from "react-router-dom" import { getScripts, createScript, @@ -58,119 +58,119 @@ import { aiRewriteScript, aiGenerateTitles, REWRITE_STYLE_OPTIONS, -} from "@/api/scripts"; +} from "@/api/scripts" import type { ScriptItem, ScriptCategory, ScriptUpsertRequest, RewriteStyle, AiRewriteResponse, -} from "@/api/scripts"; -import { SCRIPT_CATEGORY_LABEL } from "@/api/scripts"; -import "./scripts.css"; +} from "@/api/scripts" +import { SCRIPT_CATEGORY_LABEL } from "@/api/scripts" +import "./scripts.css" -const { TextArea } = Input; -const { Paragraph, Text } = Typography; +const { TextArea } = Input +const { Paragraph, Text } = Typography -const PAGE_SIZE = 12; +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; +] 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; + response?: { data?: { detail?: string | { message?: string }; message?: string } } + message?: string } - if (data?.message && typeof data.message === "string") return data.message; - if (e?.message) return e.message; - return fallback; + 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 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 [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(); + 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); + 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); + 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 [titleGenLoading, setTitleGenLoading] = useState(false) + const [titleCandidates, setTitleCandidates] = useState([]) const load = useCallback(async () => { - setLoading(true); + 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); + setItems(res) + setTotal(res.length) } else { - setItems(res.items ?? []); - setTotal(res.total ?? 0); + setItems(res.items ?? []) + setTotal(res.total ?? 0) } } catch (err) { - message.error(extractErrMsg(err, "加载文案列表失败")); + message.error(extractErrMsg(err, "加载文案列表失败")) } finally { - setLoading(false); + setLoading(false) } - }, [page, keyword, category]); + }, [page, keyword, category]) useEffect(() => { - load(); - }, [load]); + load() + }, [load]) const resetCreateForm = () => { - form.resetFields(); + form.resetFields() form.setFieldsValue({ title: "", content: "", @@ -187,19 +187,19 @@ const ScriptLibrary: React.FC = () => { bold: true, italic: false, }, - }); - setRewriteResult(null); - setTitleCandidates([]); - }; + }) + setRewriteResult(null) + setTitleCandidates([]) + } const openCreate = () => { - setEditing(null); - resetCreateForm(); - setModalOpen(true); - }; + setEditing(null) + resetCreateForm() + setModalOpen(true) + } const openEdit = (item: ScriptItem) => { - setEditing(item); + setEditing(item) form.setFieldsValue({ title: item.title, content: item.content, @@ -214,24 +214,24 @@ const ScriptLibrary: React.FC = () => { position: "center", size: 48, }, - }); - setRewriteResult(null); - setTitleCandidates([]); - setModalOpen(true); - }; + }) + setRewriteResult(null) + setTitleCandidates([]) + setModalOpen(true) + } const closeModal = () => { - setModalOpen(false); - setEditing(null); - setRewriteResult(null); - setTitleCandidates([]); - }; + setModalOpen(false) + setEditing(null) + setRewriteResult(null) + setTitleCandidates([]) + } /** 提交新建/编辑 */ const handleSubmit = async () => { try { - const values = await form.validateFields(); - setSubmitting(true); + const values = await form.validateFields() + setSubmitting(true) const payload: ScriptUpsertRequest = { title: values.title.trim(), content: values.content, @@ -240,67 +240,67 @@ const ScriptLibrary: React.FC = () => { 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(); + 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, "保存失败")); + if ((err as { errorFields?: unknown })?.errorFields) return + message.error(extractErrMsg(err, "保存失败")) } finally { - setSubmitting(false); + setSubmitting(false) } - }; + } const handleDelete = async (id: string) => { try { - await deleteScript(id); - message.success("文案已删除"); + await deleteScript(id) + message.success("文案已删除") if (items.length === 1 && page > 1) { - setPage(page - 1); + setPage(page - 1) } else { - await load(); + await load() } } catch (err) { - message.error(extractErrMsg(err, "删除失败")); + message.error(extractErrMsg(err, "删除失败")) } - }; + } const handleDuplicate = async (id: string) => { try { - await duplicateScript(id); - message.success("已复制"); - setPage(1); - await load(); + await duplicateScript(id) + message.success("已复制") + setPage(1) + await load() } catch (err) { - message.error(extractErrMsg(err, "复制失败")); + message.error(extractErrMsg(err, "复制失败")) } - }; + } /** 使用:跳创作页,query 带 scriptId 预填 */ const handleUse = (item: ScriptItem) => { - navigate(`/app/generate?scriptId=${encodeURIComponent(item.id)}`); - }; + 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 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())}`; - }; + 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", @@ -309,36 +309,36 @@ const ScriptLibrary: React.FC = () => { story: "purple", emotion: "magenta", other: "default", - }; + } // ── #1893 AI 操作 ── /** 打开抖音提取弹窗 */ const openDouyinModal = () => { - setDouyinUrl(""); - setDouyinModalOpen(true); - }; + setDouyinUrl("") + setDouyinModalOpen(true) + } /** 执行抖音提取,成功后打开新建弹窗并预填 content */ const handleDouyinExtract = async () => { - const url = douyinUrl.trim(); + const url = douyinUrl.trim() if (!url) { - message.warning("请粘贴抖音视频链接"); - return; + message.warning("请粘贴抖音视频链接") + return } if (!/^https?:\/\//i.test(url)) { - message.warning("请输入以 http(s):// 开头的完整链接"); - return; + message.warning("请输入以 http(s):// 开头的完整链接") + return } - setDouyinLoading(true); + setDouyinLoading(true) try { - const res = await extractScriptFromDouyin({ url }); - message.success(`提取成功${res.duration_seconds ? `(时长 ${res.duration_seconds}s)` : ""}`); - setDouyinModalOpen(false); - setDouyinUrl(""); + const res = await extractScriptFromDouyin({ url }) + message.success(`提取成功${res.duration_seconds ? `(时长 ${res.duration_seconds}s)` : ""}`) + setDouyinModalOpen(false) + setDouyinUrl("") // 关闭抖音弹窗,打开新建弹窗预填 content - setEditing(null); - resetCreateForm(); + setEditing(null) + resetCreateForm() form.setFieldsValue({ title: "", content: res.text, @@ -354,74 +354,74 @@ const ScriptLibrary: React.FC = () => { bold: true, italic: false, }, - }); - setModalOpen(true); + }) + setModalOpen(true) } catch (err) { - message.error(extractErrMsg(err, "抖音文案提取失败")); + message.error(extractErrMsg(err, "抖音文案提取失败")) } finally { - setDouyinLoading(false); + setDouyinLoading(false) } - }; + } /** 执行 AI 改写,结果写入 rewriteResult 让用户对比确认 */ const handleAiRewrite = async () => { - const content = form.getFieldValue("content") as string | undefined; + const content = form.getFieldValue("content") as string | undefined if (!content || !content.trim()) { - message.warning("请先填写文案正文再改写"); - return; + message.warning("请先填写文案正文再改写") + return } - setRewriteLoading(true); - setRewriteResult(null); + setRewriteLoading(true) + setRewriteResult(null) try { - const res = await aiRewriteScript({ content, style: rewriteStyle }); - setRewriteResult(res); + const res = await aiRewriteScript({ content, style: rewriteStyle }) + setRewriteResult(res) } catch (err) { - message.error(extractErrMsg(err, "AI 改写失败")); + message.error(extractErrMsg(err, "AI 改写失败")) } finally { - setRewriteLoading(false); + setRewriteLoading(false) } - }; + } /** 应用改写结果:替换 content 字段,关闭改写弹窗 */ const applyRewrite = () => { - if (!rewriteResult) return; - form.setFieldsValue({ content: rewriteResult.rewritten }); - setRewriteResult(null); - setRewriteModalOpen(false); - message.success("已应用改写结果"); - }; + 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; + const content = form.getFieldValue("content") as string | undefined if (!content || !content.trim()) { - message.warning("请先填写文案内容"); - return; + message.warning("请先填写文案内容") + return } - setTitleGenLoading(true); - setTitleCandidates([]); + setTitleGenLoading(true) + setTitleCandidates([]) try { - const res = await aiGenerateTitles({ content, count: 3 }); + const res = await aiGenerateTitles({ content, count: 3 }) if (!res.titles || res.titles.length === 0) { - message.info("AI 未返回可用标题,请稍后再试"); - return; + message.info("AI 未返回可用标题,请稍后再试") + return } - setTitleCandidates(res.titles); + setTitleCandidates(res.titles) } catch (err) { - message.error(extractErrMsg(err, "AI 生成标题失败")); + message.error(extractErrMsg(err, "AI 生成标题失败")) } finally { - setTitleGenLoading(false); + setTitleGenLoading(false) } - }; + } /** 点击候选标题直接填入 title 字段 */ const pickTitle = (t: string) => { - form.setFieldsValue({ title: t }); - }; + form.setFieldsValue({ title: t }) + } // 监听 content 字段,用于禁用生成标题按钮(content 为空时) - const watchedContent = Form.useWatch("content", form); - const contentEmpty = !watchedContent || !String(watchedContent).trim(); + const watchedContent = Form.useWatch("content", form) + const contentEmpty = !watchedContent || !String(watchedContent).trim() return (
@@ -434,8 +434,8 @@ const ScriptLibrary: React.FC = () => { placeholder="搜索标题或正文" value={keyword} onChange={(e) => { - setKeyword(e.target.value); - setPage(1); + setKeyword(e.target.value) + setPage(1) }} allowClear style={{ width: 260 }} @@ -443,8 +443,8 @@ const ScriptLibrary: React.FC = () => {