From dc0a5834f1267457fecdc6e8499f838476f8d4ea Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 15 Sep 2026 16:49:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(scripts):=20#1893=20=E6=96=87=E6=A1=88?= =?UTF-8?q?=E5=BA=93=E5=AF=B9=E6=8E=A5=E4=B8=89=E4=B8=AAAI=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=EF=BC=88=E6=8A=96=E9=9F=B3=E6=8F=90=E5=8F=96/AI?= =?UTF-8?q?=E6=94=B9=E5=86=99/AI=E7=94=9F=E6=88=90=E6=A0=87=E9=A2=98?= =?UTF-8?q?=EF=BC=89=20(#1935)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(scripts): #1893 AI能力对接(抖音提取/AI改写/AI生成标题) --- apps/web/src/api/scripts/index.ts | 1 + apps/web/src/api/scripts/scripts-ai.ts | 87 +++++ apps/web/src/pages/scripts/ScriptLibrary.tsx | 376 ++++++++++++++++++- apps/web/src/pages/scripts/scripts.css | 92 +++++ 4 files changed, 537 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/api/scripts/scripts-ai.ts diff --git a/apps/web/src/api/scripts/index.ts b/apps/web/src/api/scripts/index.ts index f9d5b5405..2cdf16109 100644 --- a/apps/web/src/api/scripts/index.ts +++ b/apps/web/src/api/scripts/index.ts @@ -1,2 +1,3 @@ export * from "./scripts" export * from "./types" +export * from "./scripts-ai" diff --git a/apps/web/src/api/scripts/scripts-ai.ts b/apps/web/src/api/scripts/scripts-ai.ts new file mode 100644 index 000000000..4b8a1f1f5 --- /dev/null +++ b/apps/web/src/api/scripts/scripts-ai.ts @@ -0,0 +1,87 @@ +/** + * 文案库 AI 能力 API(#1893) + * 三个端点均走真实后端,不参与 SCRIPTS_API_MOCK 开关。 + */ +import apiClient from "../client" + +/** ── 1. 从抖音视频提取文案(下载 + ASR) */ +export interface ExtractFromDouyinRequest { + url: string +} +export interface ExtractFromDouyinResponse { + text: string + duration_seconds?: number + source_url?: string +} + +export async function extractScriptFromDouyin( + body: ExtractFromDouyinRequest, + opts?: { signal?: AbortSignal }, +): Promise { + const res = await apiClient.post( + "/scripts/extract-from-douyin", + body, + { + // ASR 可能较慢,给足超时 + timeout: 60_000, + signal: opts?.signal, + }, + ) + return res.data +} + +/** ── 2. AI 改写文案 */ +export type RewriteStyle = "口语化" | "正式" | "活泼" | "治愈" | "励志" + +export const REWRITE_STYLE_OPTIONS: { value: RewriteStyle; label: string }[] = [ + { value: "口语化", label: "口语化" }, + { value: "正式", label: "正式" }, + { value: "活泼", label: "活泼" }, + { value: "治愈", label: "治愈" }, + { value: "励志", label: "励志" }, +] + +export interface AiRewriteRequest { + content: string + style?: RewriteStyle +} +export interface AiRewriteResponse { + original: string + rewritten: string + style: RewriteStyle +} + +export async function aiRewriteScript( + body: AiRewriteRequest, + opts?: { signal?: AbortSignal }, +): Promise { + const res = await apiClient.post("/scripts/ai-rewrite", body, { + timeout: 60_000, + signal: opts?.signal, + }) + return res.data +} + +/** ── 3. AI 生成标题 */ +export interface AiGenerateTitlesRequest { + content: string + count?: number +} +export interface AiGenerateTitlesResponse { + titles: string[] +} + +export async function aiGenerateTitles( + body: AiGenerateTitlesRequest, + opts?: { signal?: AbortSignal }, +): Promise { + const res = await apiClient.post( + "/scripts/ai-generate-titles", + { content: body.content, count: body.count ?? 3 }, + { + timeout: 30_000, + signal: opts?.signal, + }, + ) + return res.data +} diff --git a/apps/web/src/pages/scripts/ScriptLibrary.tsx b/apps/web/src/pages/scripts/ScriptLibrary.tsx index 2642f7997..1cc40c92a 100644 --- a/apps/web/src/pages/scripts/ScriptLibrary.tsx +++ b/apps/web/src/pages/scripts/ScriptLibrary.tsx @@ -1,13 +1,17 @@ /** - * 文案库页面 — Issue #1811(v2 完整版) + * 文案库页面 — 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) + * - 对接 api/scripts CRUD(mock 阶段 SCRIPTS_API_MOCK=true,AI 接口始终走真实 API) * * 风格对齐标题库(.xx-scripts-* 命名,沿用 CSS 变量) */ @@ -19,12 +23,15 @@ import { Form, Input, InputNumber, + List, Modal, Pagination, Popconfirm, Select, Space, + Spin, Tag, + Typography, message, } from "antd" import { @@ -35,6 +42,9 @@ import { PlayCircleOutlined, SearchOutlined, TagsOutlined, + VideoCameraOutlined, + RobotOutlined, + BulbOutlined, } from "@ant-design/icons" import { useNavigate } from "react-router-dom" import { @@ -43,12 +53,23 @@ import { updateScript, deleteScript, duplicateScript, + extractScriptFromDouyin, + aiRewriteScript, + aiGenerateTitles, + REWRITE_STYLE_OPTIONS, +} from "@/api/scripts" +import type { + ScriptItem, + ScriptCategory, + ScriptUpsertRequest, + RewriteStyle, + AiRewriteResponse, } from "@/api/scripts" -import type { ScriptItem, ScriptCategory, ScriptUpsertRequest } 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 }[] = [ @@ -72,6 +93,22 @@ const POSITION_OPTIONS = [ { 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() @@ -82,12 +119,28 @@ const ScriptLibrary: React.FC = () => { 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 { @@ -97,7 +150,6 @@ const ScriptLibrary: React.FC = () => { keyword: keyword.trim() || undefined, category, }) - // 兼容老接口返回数组的兜底 if (Array.isArray(res)) { setItems(res) setTotal(res.length) @@ -106,8 +158,7 @@ const ScriptLibrary: React.FC = () => { setTotal(res.total ?? 0) } } catch (err) { - const e = err as { message?: string } - message.error(e?.message ?? "加载文案列表失败") + message.error(extractErrMsg(err, "加载文案列表失败")) } finally { setLoading(false) } @@ -117,8 +168,7 @@ const ScriptLibrary: React.FC = () => { load() }, [load]) - const openCreate = () => { - setEditing(null) + const resetCreateForm = () => { form.resetFields() form.setFieldsValue({ title: "", @@ -137,6 +187,13 @@ const ScriptLibrary: React.FC = () => { italic: false, }, }) + setRewriteResult(null) + setTitleCandidates([]) + } + + const openCreate = () => { + setEditing(null) + resetCreateForm() setModalOpen(true) } @@ -157,12 +214,16 @@ const ScriptLibrary: React.FC = () => { size: 48, }, }) + setRewriteResult(null) + setTitleCandidates([]) setModalOpen(true) } const closeModal = () => { setModalOpen(false) setEditing(null) + setRewriteResult(null) + setTitleCandidates([]) } /** 提交新建/编辑 */ @@ -189,10 +250,8 @@ const ScriptLibrary: React.FC = () => { closeModal() await load() } catch (err) { - // form 校验失败不弹 message if ((err as { errorFields?: unknown })?.errorFields) return - const e = err as { message?: string } - message.error(e?.message ?? "保存失败") + message.error(extractErrMsg(err, "保存失败")) } finally { setSubmitting(false) } @@ -202,15 +261,13 @@ const ScriptLibrary: React.FC = () => { try { await deleteScript(id) message.success("文案已删除") - // 删除后若当前页空了,回退一页 if (items.length === 1 && page > 1) { setPage(page - 1) } else { await load() } } catch (err) { - const e = err as { message?: string } - message.error(e?.message ?? "删除失败") + message.error(extractErrMsg(err, "删除失败")) } } @@ -221,8 +278,7 @@ const ScriptLibrary: React.FC = () => { setPage(1) await load() } catch (err) { - const e = err as { message?: string } - message.error(e?.message ?? "复制失败") + message.error(extractErrMsg(err, "复制失败")) } } @@ -254,6 +310,118 @@ const ScriptLibrary: React.FC = () => { 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 (
@@ -282,6 +450,9 @@ const ScriptLibrary: React.FC = () => { />
+ @@ -296,7 +467,7 @@ const ScriptLibrary: React.FC = () => { description={ keyword || category !== "all" ? "没有匹配的文案" - : "暂无文案,点击右上角「新建文案」开始创作" + : "暂无文案,点击右上角「新建文案」或「🎬 从抖音提取」开始创作" } /> ) : ( @@ -430,12 +601,49 @@ const ScriptLibrary: React.FC = () => { > + 名称 + + + } rules={[{ required: true, message: "请填写文案名称" }, { max: 200 }]} > + {/* AI 生成标题候选列表 */} + {titleCandidates.length > 0 && ( +
+ + 点击候选直接填入: + +
+ {titleCandidates.map((t, i) => ( + pickTitle(t)} + > + {t} + + ))} +
+
+ )} + {