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
根因:
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 合入后联调)。
199 lines
6.5 KiB
TypeScript
199 lines
6.5 KiB
TypeScript
/**
|
||
* 文案库 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
|
||
}
|