Files
xiaoxia-saas/apps/web/src/api/scripts/scripts.ts
T
saas-frontend 741eaca358
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m40s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m47s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m46s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m3s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 2m51s
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
fix(ux): #1894 文案/标题数据源全面切到 /api/v1/scripts
根因:
1. api/scripts 中 SCRIPTS_API_MOCK 被硬编码为 true,文案库/AI数字人/智能剪辑
   都拿 mock 数据,用户在文案库新建的文案、AI数字人手动输入的文案在其他
   页面选不到。
2. AI数字人页(PanelTitleConfig)、智能剪辑 step4(useStep4Title)仍在
   调已废弃的 @/api/titles,标题下拉为空。
3. AI数字人页自维护一份 getScripts 本地实现,返回类型/字段与共享
   @/api/scripts 不一致,且未共享 mock 开关。

修复:
- api/scripts/scripts.ts: SCRIPTS_API_MOCK 改为 env 开关(同 POINTS_API_MOCK
  模式,默认 false 走真实 API)。
- 删除废弃 api/titles/ 整个目录 + 相关单测(test/api/titles.test.ts、
  ai-avatar/components/TitleLibraryModal.tsx 孤儿组件)。
- ai-avatar/api/aiAvatar.ts: 文案库相关方法(getScripts/createScript/
  getScript/deleteScript)统一 re-export from @/api/scripts,不再
  本地重复实现。
- ai-avatar/types.ts: Script 类型改为 import("@/api/scripts").ScriptItem
  别名,保证字段对齐。
- ai-avatar/AiAvatarPage.tsx 内 ScriptSelectModalLazy: getScripts 适配
  { items, total } 分页结构,传 page_size:200 一次拿全。
- ai-avatar/components/PanelScript.tsx: 手动输入 tab 下增加「💾 保存到文案库」
  按钮,一键把当前口播文案以正文前20字为默认标题自动入库,解决主人反馈
  「历史文案在新文案库找不到」的问题;保存成功 toast 1s 自动关闭。
- ai-avatar/components/PanelTitleConfig.tsx: 标题 AutoComplete 数据源改为
  getScripts(),取 scripts[].title 去重作为候选,文案从「📚 标题库」
  改为「📚 文案库标题」。
- generate/hooks/useStep4Title/index.ts: userTitles 数据源改为 getScripts(),
  映射为 { content: s.title },复用下游消费逻辑(ut.content)。
- test/pages/GeneratePage.test.tsx: 把 @/api/titles mock 替换为
  @/api/scripts mock。

待跟进:
- P2 抖音错误提示友好化(等后端 #1963 合入后联调)。
2026-09-17 00:50:12 +08:00

199 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 文案库 API(#1811 v2)
* CRUD + 搜索/分类/分页;后端未就绪时使用 mock 数据(SCRIPTS_API_MOCK=true)
*/
import apiClient from "../client"
import type {
ScriptItem,
ScriptListParams,
ScriptListResponse,
ScriptUpsertRequest,
ScriptCategory,
} from "./types"
/**
* 是否启用 mock(#1894:文案库接口已上线,默认 false 走真实 API;
* 通过 SCRIPTS_API_MOCK=true 环境变量可本地开启 mock 调试,行为同 POINTS_API_MOCK)
*/
export const SCRIPTS_API_MOCK = (process.env.SCRIPTS_API_MOCK as string | undefined) === "true"
// ==================== Mock 数据 ====================
const MOCK_CATEGORIES: ScriptCategory[] = [
"promo",
"vlog",
"knowledge",
"story",
"emotion",
"other",
]
const SAMPLE_TITLES: Record<ScriptCategory, string[]> = {
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<ScriptListResponse> {
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<ScriptListResponse>("/scripts", { params })
return res.data
}
/** 获取单条文案详情 */
export async function getScript(id: string): Promise<ScriptItem> {
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<ScriptItem>(`/scripts/${id}`)
return res.data
}
/** 新建文案 */
export async function createScript(data: ScriptUpsertRequest): Promise<ScriptItem> {
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<ScriptItem>("/scripts", data)
return res.data
}
/** 更新文案 */
export async function updateScript(id: string, data: ScriptUpsertRequest): Promise<ScriptItem> {
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<ScriptItem>(`/scripts/${id}`, data)
return res.data
}
/** 删除文案 */
export async function deleteScript(id: string): Promise<void> {
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<ScriptItem> {
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
}