diff --git a/apps/web/src/pages/scripts/ScriptLibrary.tsx b/apps/web/src/pages/scripts/ScriptLibrary.tsx index e98cf8ad3..943352580 100644 --- a/apps/web/src/pages/scripts/ScriptLibrary.tsx +++ b/apps/web/src/pages/scripts/ScriptLibrary.tsx @@ -1,117 +1,204 @@ /** - * 文案库页面 — Issue #1811 - * 风格对齐标题库(同类资源管理页面统一风格),单列卡片列表 - * 功能:列表 / 新建 / 编辑 / 删除 / 按标题搜索 / 空状态 - * 对接后端 /api/v1/scripts CRUD + * 文案库页面 — Issue #1811(v2 完整版) + * 功能: + * - 列表页:卡片列表,搜索(标题/正文)、分类标签筛选、分页 + * 每条卡片展示:title、content 前 100 字摘要、title_text、分类 Tag、tags、使用次数、时间 + * 操作:编辑 / 删除 / 复制 / 使用(跳创作页预填) + * - 新建/编辑弹窗:title、content 多行、segments(按空行自动拆分+手动编辑)、title_text、title_category、 + * title_config(字体/颜色/位置/字号)、tags + * - 删除确认(Popconfirm) + * - 对接 api/scripts CRUD(mock 阶段 SCRIPTS_API_MOCK=true) + * + * 风格对齐标题库(.xx-scripts-* 命名,沿用 CSS 变量) */ -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 React, { useCallback, useEffect, useMemo, useState } from "react" +import { + Button, + Card, + Empty, + Form, + Input, + InputNumber, + Modal, + Pagination, + Popconfirm, + Select, + Space, + Tag, + message, +} from "antd" +import { + PlusOutlined, + EditOutlined, + DeleteOutlined, + CopyOutlined, + PlayCircleOutlined, + SearchOutlined, + TagsOutlined, +} from "@ant-design/icons" +import { useNavigate } from "react-router-dom" import { getScripts, createScript, updateScript, deleteScript, - type ScriptItem, + duplicateScript, } from "@/api/scripts" +import type { + ScriptItem, + ScriptCategory, + ScriptTitleConfig, + ScriptUpsertRequest, +} from "@/api/scripts" +import { SCRIPT_CATEGORY_LABEL } from "@/api/scripts" import "./scripts.css" const { TextArea } = Input +const { Option } = Select + +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 const ScriptLibrary: React.FC = () => { - const [scripts, setScripts] = useState([]) + const navigate = useNavigate() + + const [items, setItems] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) const [loading, setLoading] = useState(false) - const [searchText, setSearchText] = useState("") + const [keyword, setKeyword] = useState("") + const [category, setCategory] = useState("all") // 弹窗状态 - const [createOpen, setCreateOpen] = useState(false) + const [modalOpen, setModalOpen] = useState(false) const [editing, setEditing] = useState(null) - const [formTitle, setFormTitle] = useState("") - const [formContent, setFormContent] = useState("") const [submitting, setSubmitting] = useState(false) + const [form] = Form.useForm() - const load = async () => { + const load = useCallback(async () => { setLoading(true) try { - const items = await getScripts() - setScripts(items) + 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(err instanceof Error ? err.message : "加载文案列表失败") + const e = err as { message?: string } + message.error(e?.message ?? "加载文案列表失败") } finally { setLoading(false) } - } + }, [page, keyword, category]) 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]) + }, [load]) const openCreate = () => { - setFormTitle("") - setFormContent("") - setCreateOpen(true) + setEditing(null) + 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, + }, + }) + setModalOpen(true) } const openEdit = (item: ScriptItem) => { setEditing(item) - setFormTitle(item.title) - setFormContent(item.content) + 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, + }, + }) + setModalOpen(true) } - const handleCloseCreate = () => { - setCreateOpen(false) - setFormTitle("") - setFormContent("") - } - - const handleCloseEdit = () => { + const closeModal = () => { + setModalOpen(false) setEditing(null) - setFormTitle("") - setFormContent("") } - const handleCreate = async () => { - const title = formTitle.trim() - const content = formContent.trim() - if (!title || !content) { - message.warning("请填写标题和正文") - return - } - setSubmitting(true) + /** 提交新建/编辑 */ + const handleSubmit = async () => { try { - await createScript({ title, content }) - message.success("文案已创建") - handleCloseCreate() + 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) { - 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 : "更新文案失败") + // form 校验失败不弹 message + if ((err as { errorFields?: unknown })?.errorFields) return + const e = err as { message?: string } + message.error(e?.message ?? "保存失败") } finally { setSubmitting(false) } @@ -121,41 +208,85 @@ 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 ?? "删除失败") + } + } + + const handleDuplicate = async (id: string) => { + try { + await duplicateScript(id) + message.success("已复制") + setPage(1) await load() } catch (err) { - message.error(err instanceof Error ? err.message : "删除文案失败") + const e = err as { message?: string } + message.error(e?.message ?? "复制失败") } } + /** 使用:跳创作页,query 带 scriptId 预填 */ + const handleUse = (item: ScriptItem) => { + navigate(`/app/generate?scriptId=${encodeURIComponent(item.id)}`) + } + const preview = (content: string) => { const text = content.replace(/\s+/g, " ").trim() - return text.length > 120 ? `${text.slice(0, 120)}…` : text || "(空)" + if (text.length <= 100) return text || "(空)" + return `${text.slice(0, 100)}…` } - const formatTime = (iso: string) => { + 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())}` + 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", } return (
- {/* 顶部操作栏 */} + {/* 顶部筛选栏 */}
-
+ } - placeholder="按标题搜索" - value={searchText} - onChange={(e) => setSearchText(e.target.value)} + placeholder="搜索标题或正文" + value={keyword} + onChange={(e) => { + setKeyword(e.target.value) + setPage(1) + }} allowClear style={{ width: 260 }} /> -
+ setFormTitle(e.target.value)} - maxLength={200} - /> -