From 211725128b5343731a8ede85497f1de7df406b6f Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 9 Sep 2026 08:54:51 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20#1811=20=E6=96=B0=E5=A2=9E=E6=96=87?= =?UTF-8?q?=E6=A1=88=E5=BA=93=E7=8B=AC=E7=AB=8B=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 路由:/app/scripts,位于标题库路由之后 - 导航:NAV_ITEMS 与 NAV_GROUPS 资源管理组下标题库之后插入「文案库」 - 新建页面 src/pages/scripts/ScriptLibrary.tsx,风格对齐标题库 - 新建 API 层 src/api/scripts(GET/POST/PUT/DELETE,列表必须解包 items) - 支持:列表 / 新建 / 编辑 / 删除 / 按标题搜索 / 空状态 - 不动 AI 数字人弹窗内的文案选择;不修改标题库/智能剪辑等现有页面 --- apps/web/src/api/scripts/index.ts | 2 + apps/web/src/api/scripts/scripts.ts | 37 +++ apps/web/src/api/scripts/types.ts | 24 ++ apps/web/src/config/navigation.ts | 12 + apps/web/src/pages/scripts/ScriptLibrary.tsx | 273 +++++++++++++++++++ apps/web/src/pages/scripts/scripts.css | 137 ++++++++++ apps/web/src/router/appRoutes.tsx | 4 + 7 files changed, 489 insertions(+) create mode 100644 apps/web/src/api/scripts/index.ts create mode 100644 apps/web/src/api/scripts/scripts.ts create mode 100644 apps/web/src/api/scripts/types.ts create mode 100644 apps/web/src/pages/scripts/ScriptLibrary.tsx create mode 100644 apps/web/src/pages/scripts/scripts.css diff --git a/apps/web/src/api/scripts/index.ts b/apps/web/src/api/scripts/index.ts new file mode 100644 index 000000000..f9d5b5405 --- /dev/null +++ b/apps/web/src/api/scripts/index.ts @@ -0,0 +1,2 @@ +export * from "./scripts" +export * from "./types" diff --git a/apps/web/src/api/scripts/scripts.ts b/apps/web/src/api/scripts/scripts.ts new file mode 100644 index 000000000..4ee28f146 --- /dev/null +++ b/apps/web/src/api/scripts/scripts.ts @@ -0,0 +1,37 @@ +/** + * 文案库 API + * 对接后端 /api/v1/scripts(CRUD + 列表解包) + */ +import apiClient from "../client" +import type { + ScriptItem, + ScriptListResponse, + CreateScriptRequest, + UpdateScriptRequest, +} from "./types" + +/** 获取文案列表 — 必须解包 items(后端返回 {items,total})*/ +export const getScripts = async (): Promise => { + const response = await apiClient.get("/scripts") + const data = response.data as unknown + if (Array.isArray(data)) return data + const items = (data as { items?: ScriptItem[] })?.items + return Array.isArray(items) ? items : [] +} + +/** 新建文案 */ +export const createScript = async (data: CreateScriptRequest): Promise => { + const response = await apiClient.post("/scripts", data) + return response.data +} + +/** 更新文案 */ +export const updateScript = async (id: string, data: UpdateScriptRequest): Promise => { + const response = await apiClient.put(`/scripts/${id}`, data) + return response.data +} + +/** 删除文案 */ +export const deleteScript = async (id: string): Promise => { + await apiClient.delete(`/scripts/${id}`) +} diff --git a/apps/web/src/api/scripts/types.ts b/apps/web/src/api/scripts/types.ts new file mode 100644 index 000000000..8fa0f30e4 --- /dev/null +++ b/apps/web/src/api/scripts/types.ts @@ -0,0 +1,24 @@ +/** + * 文案库 API — 类型定义 + * 对接后端 /api/v1/scripts + */ +export interface ScriptItem { + id: string + title: string + content: string + char_count: number + created_at: string + updated_at?: string +} + +export interface ScriptListResponse { + items: ScriptItem[] + total: number +} + +export interface CreateScriptRequest { + title: string + content: string +} + +export type UpdateScriptRequest = Partial diff --git a/apps/web/src/config/navigation.ts b/apps/web/src/config/navigation.ts index 0105d5742..6279ca871 100644 --- a/apps/web/src/config/navigation.ts +++ b/apps/web/src/config/navigation.ts @@ -58,6 +58,12 @@ export const NAV_ITEMS: NavItem[] = [ path: "/app/titles", icon: React.createElement(FileTextOutlined), }, + { + key: "scripts", + label: "文案库", + path: "/app/scripts", + icon: React.createElement(EditOutlined), + }, { key: "voices", label: "配音库", @@ -173,6 +179,12 @@ export const NAV_GROUPS: NavGroup[] = [ path: "/app/titles", icon: React.createElement(FileTextOutlined), }, + { + key: "scripts", + label: "文案库", + path: "/app/scripts", + icon: React.createElement(EditOutlined), + }, { key: "products", label: "成品库", diff --git a/apps/web/src/pages/scripts/ScriptLibrary.tsx b/apps/web/src/pages/scripts/ScriptLibrary.tsx new file mode 100644 index 000000000..e98cf8ad3 --- /dev/null +++ b/apps/web/src/pages/scripts/ScriptLibrary.tsx @@ -0,0 +1,273 @@ +/** + * 文案库页面 — Issue #1811 + * 风格对齐标题库(同类资源管理页面统一风格),单列卡片列表 + * 功能:列表 / 新建 / 编辑 / 删除 / 按标题搜索 / 空状态 + * 对接后端 /api/v1/scripts CRUD + */ +import React, { useEffect, useMemo, useState } from "react" +import { Modal, message, Empty, Button, Input, Popconfirm } from "antd" +import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined } from "@ant-design/icons" +import { + getScripts, + createScript, + updateScript, + deleteScript, + type ScriptItem, +} from "@/api/scripts" +import "./scripts.css" + +const { TextArea } = Input + +const ScriptLibrary: React.FC = () => { + const [scripts, setScripts] = useState([]) + const [loading, setLoading] = useState(false) + const [searchText, setSearchText] = useState("") + + // 弹窗状态 + const [createOpen, setCreateOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [formTitle, setFormTitle] = useState("") + const [formContent, setFormContent] = useState("") + const [submitting, setSubmitting] = useState(false) + + const load = async () => { + setLoading(true) + try { + const items = await getScripts() + setScripts(items) + } catch (err) { + message.error(err instanceof Error ? err.message : "加载文案列表失败") + } finally { + setLoading(false) + } + } + + useEffect(() => { + load() + }, []) + + const filtered = useMemo(() => { + const kw = searchText.trim().toLowerCase() + if (!kw) return scripts + return scripts.filter((s) => s.title.toLowerCase().includes(kw)) + }, [scripts, searchText]) + + const openCreate = () => { + setFormTitle("") + setFormContent("") + setCreateOpen(true) + } + + const openEdit = (item: ScriptItem) => { + setEditing(item) + setFormTitle(item.title) + setFormContent(item.content) + } + + const handleCloseCreate = () => { + setCreateOpen(false) + setFormTitle("") + setFormContent("") + } + + const handleCloseEdit = () => { + setEditing(null) + setFormTitle("") + setFormContent("") + } + + const handleCreate = async () => { + const title = formTitle.trim() + const content = formContent.trim() + if (!title || !content) { + message.warning("请填写标题和正文") + return + } + setSubmitting(true) + try { + await createScript({ title, content }) + message.success("文案已创建") + handleCloseCreate() + await load() + } catch (err) { + message.error(err instanceof Error ? err.message : "创建文案失败") + } finally { + setSubmitting(false) + } + } + + const handleUpdate = async () => { + if (!editing) return + const title = formTitle.trim() + const content = formContent.trim() + if (!title || !content) { + message.warning("请填写标题和正文") + return + } + setSubmitting(true) + try { + await updateScript(editing.id, { title, content }) + message.success("文案已更新") + handleCloseEdit() + await load() + } catch (err) { + message.error(err instanceof Error ? err.message : "更新文案失败") + } finally { + setSubmitting(false) + } + } + + const handleDelete = async (id: string) => { + try { + await deleteScript(id) + message.success("文案已删除") + await load() + } catch (err) { + message.error(err instanceof Error ? err.message : "删除文案失败") + } + } + + const preview = (content: string) => { + const text = content.replace(/\s+/g, " ").trim() + return text.length > 120 ? `${text.slice(0, 120)}…` : text || "(空)" + } + + const formatTime = (iso: string) => { + 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())}` + } + + return ( +
+
+ {/* 顶部操作栏 */} +
+
+ } + placeholder="按标题搜索" + value={searchText} + onChange={(e) => setSearchText(e.target.value)} + allowClear + style={{ width: 260 }} + /> +
+
+ +
+
+ + {/* 列表 / 空状态 */} + {loading ? ( +
加载中…
+ ) : filtered.length === 0 ? ( + + ) : ( +
+ {filtered.map((s) => ( +
+
+
{s.title}
+
+ + handleDelete(s.id)} + > + + +
+
+
{preview(s.content)}
+
+ {s.char_count ?? s.content.length} 字 + · + {formatTime(s.created_at)} +
+
+ ))} +
+ )} +
+ + {/* 新建弹窗 */} + +
+ setFormTitle(e.target.value)} + maxLength={200} + /> +