From f789ed1f164898126ddabfa929da3efd80e9b253 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 15 Sep 2026 10:19:49 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(scripts):=20#1811=20=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E7=B1=BB=E5=9E=8B=EF=BC=88=E5=88=86=E7=B1=BB?= =?UTF-8?q?/=E6=A0=87=E7=AD=BE/title=5Fconfig/segments=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/api/scripts/types.ts | 85 ++++++++++++++++++++++++++++--- 1 file changed, 79 insertions(+), 6 deletions(-) diff --git a/apps/web/src/api/scripts/types.ts b/apps/web/src/api/scripts/types.ts index 8fa0f30e4..58741dc36 100644 --- a/apps/web/src/api/scripts/types.ts +++ b/apps/web/src/api/scripts/types.ts @@ -1,24 +1,97 @@ /** - * 文案库 API — 类型定义 - * 对接后端 /api/v1/scripts + * 文案库 API — 类型定义(#1811 v2 完整字段版) + * 字段对齐后端契约:title / content / segments / tags / title_text / title_category / title_config + * 同时保留 char_count / use_count / timestamps 等展示字段 */ + +/** 标题配置(字体、颜色、位置、字号) */ +export interface ScriptTitleConfig { + /** 字体预设 key,如 "default" / "bold" / "handwritten" */ + font?: string + /** 文字颜色(CSS color) */ + color?: string + /** 描边色 */ + stroke?: string + /** 位置:top / center / bottom */ + position?: "top" | "center" | "bottom" + /** 字号(px) */ + size?: number + /** 是否加粗 */ + bold?: boolean + /** 是否斜体 */ + italic?: boolean +} + +/** 文案分类(可枚举,也支持自定义) */ +export type ScriptCategory = + | "promo" // 营销推广 + | "vlog" // Vlog/日常 + | "knowledge" // 知识科普 + | "story" // 故事剧情 + | "emotion" // 情感语录 + | "other" // 其他 + +export const SCRIPT_CATEGORY_LABEL: Record = { + promo: "营销推广", + vlog: "Vlog 日常", + knowledge: "知识科普", + story: "故事剧情", + emotion: "情感语录", + other: "其他", +} + +/** 文案条目 */ export interface ScriptItem { id: string + /** 名称(标题) */ title: string + /** 正文 */ content: string - char_count: number + /** 分段(按段落切分,供后端/生成步骤逐段使用) */ + segments?: string[] + /** 标签(逗号分隔或数组,列表展示用 Tag) */ + tags?: string[] + /** 配套标题文本(选填,"使用"跳创作页时会预填到标题) */ + title_text?: string + /** 分类 */ + title_category?: ScriptCategory + /** 标题样式配置(字体/颜色/位置/字号) */ + title_config?: ScriptTitleConfig + /** 正文字符数(后端返回,前端用于展示) */ + char_count?: number + /** 使用次数(后端返回) */ + use_count?: number created_at: string updated_at?: string } +/** 列表查询参数(支持搜索/分类/分页) */ +export interface ScriptListParams { + page?: number + page_size?: number + /** 标题/正文模糊搜索 */ + keyword?: string + /** 分类筛选 */ + category?: ScriptCategory | "all" + /** 标签筛选 */ + tag?: string +} + +/** 列表响应 */ export interface ScriptListResponse { items: ScriptItem[] total: number + page: number + page_size: number } -export interface CreateScriptRequest { +/** 创建/编辑请求 */ +export interface ScriptUpsertRequest { title: string content: string + segments?: string[] + tags?: string[] + title_text?: string + title_category?: ScriptCategory + title_config?: ScriptTitleConfig } - -export type UpdateScriptRequest = Partial -- 2.54.0 From 5bed6ca6a0ded15f7f719cdfc8dbc4cdffd97078 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 15 Sep 2026 10:19:50 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat(scripts):=20#1811=20mock=20CRUD+?= =?UTF-8?q?=E6=90=9C=E7=B4=A2/=E5=88=86=E7=B1=BB=E7=AD=9B=E9=80=89/?= =?UTF-8?q?=E5=88=86=E9=A1=B5/=E5=A4=8D=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/api/scripts/scripts.ts | 194 +++++++++++++++++++++++++--- 1 file changed, 176 insertions(+), 18 deletions(-) diff --git a/apps/web/src/api/scripts/scripts.ts b/apps/web/src/api/scripts/scripts.ts index 4ee28f146..d3862dcfc 100644 --- a/apps/web/src/api/scripts/scripts.ts +++ b/apps/web/src/api/scripts/scripts.ts @@ -1,37 +1,195 @@ /** - * 文案库 API - * 对接后端 /api/v1/scripts(CRUD + 列表解包) + * 文案库 API(#1811 v2) + * CRUD + 搜索/分类/分页;后端未就绪时使用 mock 数据(SCRIPTS_API_MOCK=true) */ import apiClient from "../client" import type { ScriptItem, + ScriptListParams, ScriptListResponse, - CreateScriptRequest, - UpdateScriptRequest, + ScriptUpsertRequest, + ScriptCategory, } 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 : [] +/** 是否启用 mock(后端合入后改为 false) */ +export const SCRIPTS_API_MOCK = true + +// ==================== Mock 数据 ==================== + +const MOCK_CATEGORIES: ScriptCategory[] = [ + "promo", + "vlog", + "knowledge", + "story", + "emotion", + "other", +] + +const SAMPLE_TITLES: Record = { + promo: ["新品上市限时特惠", "618大促开场", "品牌故事宣传片"], + vlog: ["周末citywalk记录", "打工人的一天", "探店vlog"], + knowledge: ["3分钟学会XX", "冷知识科普", "行业深度解读"], + story: ["励志小故事", "情感故事一则", "反转剧情"], + emotion: ["深夜emo时刻", "治愈系文案", "朋友圈金句"], + other: ["通用开场白", "节日祝福", "万能结尾"], +} + +const SAMPLE_TAGS = ["热门", "新品", "节日", "情感", "干货", "搞笑", "治愈", "励志"] + +function genMockScripts(): ScriptItem[] { + const items: ScriptItem[] = [] + const now = Date.now() + let idx = 0 + for (const cat of MOCK_CATEGORIES) { + const titles = SAMPLE_TITLES[cat] + for (let i = 0; i < titles.length; i++) { + idx++ + const title = titles[i] + const content = `这是一条【${cat}】分类下的示例文案,标题为「${title}」。\n\n正文可以包含多段落,每段对应一个片段(segments)。\n\n此为 mock 数据,后端接口就绪后会自动切换为真实数据。` + const segments = content.split(/\n\n+/).filter(Boolean) + const tagCount = 1 + (idx % 3) + const tags: string[] = [] + for (let t = 0; t < tagCount; t++) { + tags.push(SAMPLE_TAGS[(idx + t) % SAMPLE_TAGS.length]) + } + items.push({ + id: `mock_${idx}`, + title, + content, + segments, + tags, + title_text: title, + title_category: cat, + title_config: { + font: "default", + color: "#ffffff", + stroke: "#000000", + position: (["top", "center", "bottom"] as const)[idx % 3], + size: 48, + bold: idx % 2 === 0, + italic: false, + }, + char_count: content.length, + use_count: Math.floor(Math.random() * 50), + created_at: new Date(now - idx * 86400_000 * 2).toISOString(), + updated_at: new Date(now - idx * 86400_000).toISOString(), + }) + } + } + return items +} + +const MOCK_SCRIPTS = genMockScripts() + +// ==================== 真实 API ==================== + +/** 获取文案列表(支持分页/搜索/分类) */ +export async function getScripts(params: ScriptListParams = {}): Promise { + if (SCRIPTS_API_MOCK) { + const page = params.page ?? 1 + const pageSize = params.page_size ?? 20 + let items = [...MOCK_SCRIPTS] + if (params.keyword) { + const kw = params.keyword.toLowerCase() + items = items.filter( + (s) => s.title.toLowerCase().includes(kw) || s.content.toLowerCase().includes(kw), + ) + } + if (params.category && params.category !== "all") { + items = items.filter((s) => s.title_category === params.category) + } + if (params.tag) { + items = items.filter((s) => s.tags?.includes(params.tag as string)) + } + const total = items.length + const start = (page - 1) * pageSize + const pageItems = items.slice(start, start + pageSize) + return new Promise((r) => + setTimeout(() => r({ items: pageItems, total, page, page_size: pageSize }), 200), + ) + } + const res = await apiClient.get("/scripts", { params }) + return res.data +} + +/** 获取单条文案详情 */ +export async function getScript(id: string): Promise { + if (SCRIPTS_API_MOCK) { + const item = MOCK_SCRIPTS.find((s) => s.id === id) + return new Promise((r) => setTimeout(() => r(item ?? MOCK_SCRIPTS[0]), 120)) + } + const res = await apiClient.get(`/scripts/${id}`) + return res.data } /** 新建文案 */ -export const createScript = async (data: CreateScriptRequest): Promise => { - const response = await apiClient.post("/scripts", data) - return response.data +export async function createScript(data: ScriptUpsertRequest): Promise { + if (SCRIPTS_API_MOCK) { + const segments = + data.segments && data.segments.length > 0 + ? data.segments + : data.content.split(/\n\n+/).filter(Boolean) + const item: ScriptItem = { + id: `mock_${Date.now()}`, + ...data, + segments, + char_count: data.content.length, + use_count: 0, + tags: data.tags ?? [], + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + } + MOCK_SCRIPTS.unshift(item) + return new Promise((r) => setTimeout(() => r(item), 200)) + } + const res = await apiClient.post("/scripts", data) + return res.data } /** 更新文案 */ -export const updateScript = async (id: string, data: UpdateScriptRequest): Promise => { - const response = await apiClient.put(`/scripts/${id}`, data) - return response.data +export async function updateScript(id: string, data: ScriptUpsertRequest): Promise { + if (SCRIPTS_API_MOCK) { + const idx = MOCK_SCRIPTS.findIndex((s) => s.id === id) + const segments = + data.segments && data.segments.length > 0 + ? data.segments + : data.content.split(/\n\n+/).filter(Boolean) + const updated: ScriptItem = { + ...MOCK_SCRIPTS[idx], + ...data, + segments, + char_count: data.content.length, + tags: data.tags ?? MOCK_SCRIPTS[idx]?.tags ?? [], + updated_at: new Date().toISOString(), + } + if (idx >= 0) MOCK_SCRIPTS[idx] = updated + return new Promise((r) => setTimeout(() => r(updated), 200)) + } + const res = await apiClient.put(`/scripts/${id}`, data) + return res.data } /** 删除文案 */ -export const deleteScript = async (id: string): Promise => { +export async function deleteScript(id: string): Promise { + if (SCRIPTS_API_MOCK) { + const idx = MOCK_SCRIPTS.findIndex((s) => s.id === id) + if (idx >= 0) MOCK_SCRIPTS.splice(idx, 1) + return new Promise((r) => setTimeout(r, 150)) + } await apiClient.delete(`/scripts/${id}`) } + +/** 复制文案(返回新副本) */ +export async function duplicateScript(id: string): Promise { + const orig = await getScript(id) + const copy = await createScript({ + title: `${orig.title}(副本)`, + content: orig.content, + segments: orig.segments, + tags: orig.tags, + title_text: orig.title_text, + title_category: orig.title_category, + title_config: orig.title_config, + }) + return copy +} -- 2.54.0 From 2b29da3f627364c63f8c5dc7678e16661c5f0d4f Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 15 Sep 2026 10:19:51 +0800 Subject: [PATCH 3/6] =?UTF-8?q?feat(scripts):=20#1811=20index=20barrel=20?= =?UTF-8?q?=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -- 2.54.0 From d42266eed6fdf86bc171dc700709fa864a39cbea Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 15 Sep 2026 10:19:51 +0800 Subject: [PATCH 4/6] =?UTF-8?q?feat(scripts):=20#1811=20=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E7=89=88=E6=96=87=E6=A1=88=E5=BA=93=EF=BC=88=E6=90=9C=E7=B4=A2?= =?UTF-8?q?/=E5=88=86=E7=B1=BB=E7=AD=9B=E9=80=89/=E5=88=86=E9=A1=B5/?= =?UTF-8?q?=E5=8D=A1=E7=89=87/4=E6=93=8D=E4=BD=9C/=E5=BC=B9=E7=AA=97?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/pages/scripts/ScriptLibrary.tsx | 588 +++++++++++++------ 1 file changed, 419 insertions(+), 169 deletions(-) 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} - /> -