Files
yingqing/core/frontend/src/api.ts
T
Azmat@qq.com 0bd1db6bf9 模特库标签分页与全能创作收口:藏长视频、选择器分页
角色库导入打标签并支持筛选;模特库与全能创作角色/商品选择改为每页 20 条分页。临时限制成片 ≤60 秒,过滤对话里的超长时长选项,并收拢本地全能创作与后台用户相关修复。
2026-09-21 16:24:37 +08:00

1391 lines
67 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.
import type {
AdminIntegrity,
AdminLedger,
AdminModel,
AdminProjectRow,
AdminProvider,
AdminQualityWord,
AdminQuotaPolicy,
AdminReviewAsset,
AdminTask,
AdminTaskDetail,
AdminTeam,
AdminTeamDetail,
AdminUser,
AITask,
Asset,
AuthPayload,
BillingConfigInfo,
BillingSummary,
BillingTrend,
FreeAssetGroup,
FreeAssetItem,
FreeVideoRef,
FreeVideoTask,
FreeVideoUploadResult,
Ledger,
LoginSession,
Invitation,
ImageConversation,
ImageConversationTrash,
ImageConversationTask,
CreationConversation,
CreationConversationDetail,
CreationMessage,
CreationRef,
ModelConfig,
ModelEntity,
Notification,
NotificationList,
Paginated,
Product,
Project,
RechargeResult,
ScriptTemplate,
ScriptVersion,
Team,
TeamMember,
User,
UserPreference,
VideoDigestHistory,
VideoDigestJob,
VoiceoverInfo
} from "./types";
import type { PresentationFormat, VideoStructure } from "./script-setup";
const API_BASE = import.meta.env.VITE_API_BASE_URL || "";
const TOKEN_KEY = "airshelf_token";
const REMEMBER_KEY = "airshelf_remember"; // { username, expireAt } · 勾选「记住我 7 天」时写
const REMEMBER_DAYS = 7;
export const AUTH_INVALIDATED_EVENT = "airshelf:auth-invalidated";
let authInvalidationNotified = false;
const AUTH_MISSING_MESSAGES = new Set([
"身份认证信息未提供。",
"Authentication credentials were not provided."
]);
const FRIENDLY_AUTH_MISSING_MESSAGE = "登录状态异常,请刷新页面后重新登录;若仍无法操作,请联系管理员。";
export type RememberInfo = { username: string; expireAt: number };
export type QuickCreateJob = {
id: string;
project_id: string;
product_id: string;
product_name: string;
title: string;
product_images: Array<{ asset_id: string; url: string }>;
status: "queued" | "running" | "succeeded" | "failed" | "cancelled";
phase: "product" | "script" | "assets" | "production" | "complete";
phase_index: number;
progress: number;
message: string;
error_message: string;
settings: {
aspect_ratio: string;
resolution: string;
total_duration: number;
video_model_config_id: string;
video_model_name: string;
video_model_label: string;
};
result: null | {
video_url: string;
final_video_url?: string;
poster_url: string;
duration_seconds: number;
aspect_ratio: string;
resolution: string;
video_model: string;
structure: string;
presentation: string;
person: string;
scene: string;
video_segments: Array<{
id: string;
sort_order: number;
duration_seconds: number;
video_url: string;
poster_url: string;
}>;
};
created_at: string;
updated_at: string;
completed_at: string | null;
};
export function getRemember(): RememberInfo | null {
try {
const raw = localStorage.getItem(REMEMBER_KEY);
return raw ? (JSON.parse(raw) as RememberInfo) : null;
} catch {
return null;
}
}
// 登录成功后调用:勾选记住我则存用户名 + 7 天过期点;否则清除
export function setRemember(username: string | null, remember: boolean) {
if (remember && username) {
localStorage.setItem(REMEMBER_KEY, JSON.stringify({ username, expireAt: Date.now() + REMEMBER_DAYS * 86400000 }));
} else {
localStorage.removeItem(REMEMBER_KEY);
}
}
export class ApiError extends Error {
status: number;
/** 解析成功的响应体。409 这类「冲突」要把在跑的任务带回给调用方接上,只有 message 不够用。 */
payload?: Record<string, unknown>;
constructor(status: number, message: string, payload?: Record<string, unknown>) {
super(message);
this.name = "ApiError";
this.status = status;
this.payload = payload;
}
}
export function getToken() {
// 「记住我 7 天」过期则强制重新登录
const remembered = getRemember();
if (remembered && remembered.expireAt && Date.now() > remembered.expireAt) {
localStorage.removeItem(TOKEN_KEY);
sessionStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REMEMBER_KEY);
return null;
}
// 记住我 → localStorage(跨会话);不记住 → sessionStorage(关浏览器即失效)
return localStorage.getItem(TOKEN_KEY) || sessionStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string | null, remember = true) {
localStorage.removeItem(TOKEN_KEY);
sessionStorage.removeItem(TOKEN_KEY);
if (!token) return;
authInvalidationNotified = false;
if (remember) localStorage.setItem(TOKEN_KEY, token);
else sessionStorage.setItem(TOKEN_KEY, token);
}
function notifyAuthInvalidated() {
if (authInvalidationNotified || typeof window === "undefined") return;
authInvalidationNotified = true;
window.dispatchEvent(new CustomEvent(AUTH_INVALIDATED_EVENT));
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken();
const headers = new Headers(options.headers);
if (!(options.body instanceof FormData)) {
headers.set("Content-Type", "application/json");
}
if (token) headers.set("Authorization", `Token ${token}`);
const response = await fetch(`${API_BASE}${path}`, { ...options, headers });
if (!response.ok) {
// 已携带凭证却收到 401,说明 Token 已被服务端吊销。先通知应用壳弹出
// 单设备登录提示,再保留原始错误给具体调用方处理。
if (response.status === 401 && token && path !== "/api/auth/logout/") notifyAuthInvalidated();
const text = await response.text();
// DRF 错误体是 JSON({"detail": "..."} 或 {field: ["..."]}),提取人话给 toast,别把原始 JSON 怼到用户脸上
let message = text || `${response.status} ${response.statusText}`;
let payload: Record<string, unknown> | undefined;
try {
const data = JSON.parse(text) as Record<string, unknown>;
payload = data;
const first = data.detail ?? data.error ?? data.message ?? Object.values(data)[0];
if (typeof first === "string") message = first;
else if (Array.isArray(first) && typeof first[0] === "string") message = first[0];
} catch {
// 非 JSON(如 DEBUG 的整页 HTML 报错栈):绝不把整页怼进 toast(会撑爆 / 白屏),只给状态行
message = `服务器错误 ${response.status} ${response.statusText}`.trim();
}
// 后端默认的“身份认证信息未提供”只描述技术原因,用户无法判断该怎么处理。
// 仅翻译这一种“根本没有携带凭证”的响应;无效 token 等其他认证错误仍保留原始提示,方便区分。
if (response.status === 401 && AUTH_MISSING_MESSAGES.has(message)) {
message = FRIENDLY_AUTH_MISSING_MESSAGE;
}
// 团队接口统一用此 403 表示当前账号不具备操作资格;只翻译精确原文,避免掩盖其他 403 原因。
if (response.status === 403 && message === "permission denied") {
message = "当前账号权限不足";
}
// nginx / 网关在请求进 Django 前拦大文件,响应体是 HTML「413 Request Entity Too Large」。
if (response.status === 413) {
message = "文件太大,服务器没接收。视频请压到 200MB 以内再传";
}
throw new ApiError(response.status, message, payload);
}
if (response.status === 204) return undefined as T;
return response.json() as Promise<T>;
}
export const api = {
register(payload: { username: string; password: string; email?: string; team_name?: string; invite_code: string }) {
return request<AuthPayload>("/api/auth/register/", { method: "POST", body: JSON.stringify(payload) });
},
// 免登录验码:注册页「填码 → 验过才展开表单」。始终 200,以 valid 标志区分。
validateInvite(code: string) {
return request<{ valid: boolean; kind?: "join_team" | "create_team"; role?: string; team_name?: string | null; detail?: string }>(
"/api/auth/validate-invite/",
{ method: "POST", body: JSON.stringify({ code }) }
);
},
login(payload: { username: string; password: string }) {
return request<AuthPayload>("/api/auth/login/", { method: "POST", body: JSON.stringify(payload) });
},
me() {
return request<{ user: User; team: Team; role?: string }>("/api/auth/me/");
},
updateProfile(payload: { name?: string; phone?: string; email?: string }) {
return request<{ user: User; team: Team }>("/api/auth/me/", { method: "PATCH", body: JSON.stringify(payload) });
},
changePassword(payload: { old_password: string; new_password: string }) {
return request<{ token: string }>("/api/auth/me/password/", { method: "POST", body: JSON.stringify(payload) });
},
uploadAvatar(formData: FormData) {
return request<User>("/api/auth/me/avatar/", { method: "POST", body: formData });
},
resetAvatar() {
return request<User>("/api/auth/me/avatar/", { method: "DELETE" });
},
deleteAsset(id: string) {
return request<void>(`/api/assets/${id}/`, { method: "DELETE" });
},
// R108 资产垃圾桶(与商品垃圾桶同构):已软删资产列表 / 恢复回资产库 / 彻底删除(不可恢复)
assetsTrash() {
return request<Paginated<Asset>>("/api/assets/trash/");
},
restoreAsset(id: string) {
return request<Asset>(`/api/assets/${id}/restore/`, { method: "POST" });
},
purgeAsset(id: string) {
return request<void>(`/api/assets/${id}/purge/`, { method: "DELETE" });
},
preferences() {
return request<UserPreference>("/api/auth/me/preferences/");
},
updatePreferences(payload: Partial<UserPreference>) {
return request<UserPreference>("/api/auth/me/preferences/", { method: "PUT", body: JSON.stringify(payload) });
},
loginSessions() {
return request<LoginSession[]>("/api/auth/me/sessions/");
},
revokeSession(id: string) {
// 后端旋转 token 真正吊销被下线设备(单 token 体系),回传新 token 给当前设备保活
return request<{ revoked: number; token?: string }>(`/api/auth/me/sessions/${id}/revoke/`, { method: "POST" });
},
revokeOtherSessions() {
return request<{ token: string }>("/api/auth/me/sessions/revoke-others/", { method: "POST" });
},
logout() {
return request<void>("/api/auth/logout/", { method: "POST" });
},
teamMembers() {
return request<TeamMember[]>("/api/auth/team/members/");
},
// 团队级设置读取(任意成员可读):团队页/消费页取最新月限额,避免依赖登录时的陈旧 /me/ 快照(R72)
teamSettings() {
return request<Team>("/api/auth/team/settings/");
},
// 团队级月限额持久化(超管在团队页设置 · PMC#12);monthly_credit_limit: 数值 / null(清空)
updateTeamSettings(payload: { monthly_credit_limit?: number | string | null }) {
return request<Team>("/api/auth/team/settings/", { method: "PATCH", body: JSON.stringify(payload) });
},
createTeamMember(payload: {
username: string;
password: string;
name?: string;
email?: string;
role?: string;
daily_credit_limit?: number | string;
monthly_credit_limit?: number | string;
total_credit_limit?: number | string;
}) {
return request<TeamMember>("/api/auth/team/members/", { method: "POST", body: JSON.stringify(payload) });
},
updateTeamMember(id: string, payload: { role?: string; daily_credit_limit?: number | string; monthly_credit_limit?: number | string; total_credit_limit?: number | string; name?: string }) {
return request<TeamMember>(`/api/auth/team/members/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
},
removeTeamMember(id: string) {
return request<void>(`/api/auth/team/members/${id}/`, { method: "DELETE" });
},
resetMemberPassword(id: string, password: string) {
return request<void>(`/api/auth/team/members/${id}/password/`, {
method: "POST",
body: JSON.stringify({ password })
});
},
listInvitations() {
return request<Invitation[]>("/api/auth/team/invitations/");
},
createInvitation(payload: { role?: string; email?: string; monthly_credit_limit?: number | string }) {
return request<Invitation>("/api/auth/team/invitations/", { method: "POST", body: JSON.stringify(payload) });
},
revokeInvitation(id: string) {
return request<Invitation>(`/api/auth/team/invitations/${id}/revoke/`, { method: "POST" });
},
products(pageSizeOrParams?: number | { page?: number; pageSize?: number; q?: string }) {
const params = typeof pageSizeOrParams === "number"
? { pageSize: pageSizeOrParams }
: (pageSizeOrParams || {});
const qs = new URLSearchParams();
if (params.page) qs.set("page", String(params.page));
if (params.pageSize) qs.set("page_size", String(params.pageSize));
if (params.q?.trim()) qs.set("search", params.q.trim());
const query = qs.toString();
return request<Paginated<Product>>(`/api/products/${query ? `?${query}` : ""}`);
},
product(id: string) {
return request<Product>(`/api/products/${id}/`);
},
createProduct(payload: {
title?: string;
brand?: string;
business_type?: string;
category?: string;
target_audience?: string;
description?: string;
specs?: Record<string, unknown>;
selling_points?: Array<{ title: string; detail: string; sort_order: number }>;
// upload-first:创建时带上已传进桶的图(cover_asset = 主图 asset id;images = 全部图)
cover_asset?: string;
images?: Array<{ asset: string; sort_order: number; is_primary?: boolean }>;
}) {
return request<Product>("/api/products/", { method: "POST", body: JSON.stringify(payload) });
},
updateProduct(id: string, payload: Partial<Product>) {
return request<Product>(`/api/products/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
},
uploadProductImage(productId: string, formData: FormData) {
return request<Product>(`/api/products/${productId}/images/`, { method: "POST", body: formData });
},
deleteProductImage(productId: string, imageId: string) {
return request<Product>(`/api/products/${productId}/images/${imageId}/`, { method: "DELETE" });
},
deleteProduct(id: string) {
// 软删 = 进垃圾桶(后端 status→archived,可恢复)
return request<void>(`/api/products/${id}/`, { method: "DELETE" });
},
// 商品详情结构化素材(角色/商品图/图片成品/项目包)
productMaterials(id: string) {
return request<import("./types").ProductMaterials>(`/api/products/${id}/materials/`);
},
productsTrash() {
return request<Paginated<Product>>("/api/products/trash/");
},
restoreProduct(id: string) {
return request<Product>(`/api/products/${id}/restore/`, { method: "POST" });
},
purgeProduct(id: string) {
// 彻底删除 = 二级软删除(从垃圾桶隐藏,DB 记录保留)
return request<void>(`/api/products/${id}/purge/`, { method: "DELETE" });
},
projects() {
return request<Paginated<Project>>("/api/projects/");
},
project(id: string) {
return request<Project>(`/api/projects/${id}/`);
},
// 本项目在途的基础资产出图任务(刷新后据此重建「生成中」占位卡 loading)
pendingAssets(id: string) {
return request<{ pending: Array<{ id: string; kind: string; label: string; is_triview: boolean; triview_of?: string; status: string }> }>(
`/api/projects/${id}/pending-assets/`
);
},
createProject(payload: { name: string; product: string; metadata?: Record<string, unknown> }) {
return request<Project>("/api/projects/", { method: "POST", body: JSON.stringify(payload) });
},
startQuickCreate(formData: FormData) {
return request<QuickCreateJob>("/api/projects/quick-create/", { method: "POST", body: formData });
},
quickCreateStatus(jobId: string) {
return request<QuickCreateJob>(`/api/projects/quick-create-status/${jobId}/`);
},
cancelQuickCreate(jobId: string) {
return request<QuickCreateJob>(`/api/projects/quick-create-cancel/${jobId}/`, { method: "POST" });
},
retryQuickCreate(jobId: string) {
return request<QuickCreateJob>(`/api/projects/quick-create-retry/${jobId}/`, { method: "POST" });
},
quickCreateHistory() {
// inflight = 正在跑的那条:刷新/重进页面据此恢复进度,不再显示成空表单
return request<{ count: number; results: QuickCreateJob[]; inflight?: QuickCreateJob | null }>(
"/api/projects/quick-create-history/"
);
},
// 整体替换 metadata —— 调用方务必先展开现有 project.metadata 再合并,别把别的 key 冲掉
updateProject(id: string, payload: { name?: string; metadata?: Record<string, unknown> }) {
return request<Project>(`/api/projects/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
},
deleteProject(id: string) {
return request<void>(`/api/projects/${id}/`, { method: "DELETE" });
},
projectsTrash() {
return request<Paginated<Project>>("/api/projects/trash/");
},
restoreProject(id: string) {
return request<Project>(`/api/projects/${id}/restore/`, { method: "POST" });
},
purgeProject(id: string) {
return request<void>(`/api/projects/${id}/purge/`, { method: "DELETE" });
},
// 上传脚本(docx)取正文:docx 是 zip,交后端拆,前端不引解析库。txt 前端自己 file.text() 即可。
extractScriptFile(projectId: string, formData: FormData) {
return request<{ name: string; chars: number; text: string }>(
`/api/projects/${projectId}/extract-script-file/`,
{ method: "POST", body: formData }
);
},
// 1.11 上传视频提炼:整段视频交给 Gemini → 中文分镜稿。与上传脚本同形状(只回文本不落库),
// 差别是真调模型,慢(约 1–2 分钟)且计一次费,故前端要给等待反馈。
extractScriptVideo(projectId: string, formData: FormData) {
return request<{ name: string; chars: number; text: string; frames: number; duration: number; input?: string }>(
`/api/projects/${projectId}/extract-script-video/`,
{ method: "POST", body: formData }
);
},
// 视频提炼页:不绑项目。POST 秒回任务,worker 抽帧 + Gemini;离开页面也不中断。
extractVideoDigest(formData: FormData, signal?: AbortSignal) {
return request<VideoDigestJob>(
"/api/ai/video-digest/",
{ method: "POST", body: formData, signal }
);
},
extractVideoDigestFromTask(taskId: string, modelConfigId?: string, signal?: AbortSignal) {
return request<VideoDigestJob>(
"/api/ai/video-digest/",
{
method: "POST",
body: JSON.stringify({
reuse_task_id: taskId,
...(modelConfigId ? { model_config_id: modelConfigId } : {}),
}),
signal,
}
);
},
listVideoDigests() {
return request<{ results: VideoDigestHistory[]; total: number; inflight?: VideoDigestJob | null }>("/api/ai/video-digest/");
},
getVideoDigest(id: string) {
return request<VideoDigestJob>(`/api/ai/video-digest/${id}/`);
},
cancelVideoDigest(id: string) {
return request<VideoDigestJob>(`/api/ai/video-digest/${id}/`, { method: "DELETE" });
},
saveVideoDigest(id: string, prompt: string) {
return request<VideoDigestHistory>(`/api/ai/video-digest/${id}/`, {
method: "PATCH",
body: JSON.stringify({ prompt }),
});
},
// ── 套路模板(5.1 存 / 5.2 换商品重跑)· 团队隔离 ──
// 存模板只抽套路不抽文案:后端从这版脚本抽镜数/每镜作用/节奏/商品露出/转化写法。
saveProjectAsTemplate(projectId: string, payload: { name: string; script_version_id?: string }) {
return request<ScriptTemplate>(`/api/projects/${projectId}/save-as-template/`, {
method: "POST",
body: JSON.stringify(payload),
});
},
listScriptTemplates() {
return request<Paginated<ScriptTemplate>>("/api/script-templates/");
},
renameScriptTemplate(id: string, name: string) {
return request<ScriptTemplate>(`/api/script-templates/${id}/`, { method: "PATCH", body: JSON.stringify({ name }) });
},
deleteScriptTemplate(id: string) {
return request<void>(`/api/script-templates/${id}/`, { method: "DELETE" });
},
// 对话式脚本 agent · 流式(SSE)。逐帧回调 onEvent:tool(工具卡)/delta(思考前言)/draft/saved/done/error。
// 用 fetch + ReadableStream 消费 text/event-stream(EventSource 只支持 GET,这里要 POST 带 body)。
async agentScriptStream(
projectId: string,
payload: {
mode?: "auto" | "theme" | "revise";
prompt?: string;
model_config_id?: string;
selling_point_ids?: string[];
persona?: string;
base_version_id?: string;
aspect_ratio?: string;
total_duration?: number;
// 二期:表现形式 × 视频结构 —— 后端按这两个挑套路提示词,不传则回落 口播 × 痛点解决
presentation_format?: PresentationFormat;
video_structure?: VideoStructure;
target_index?: number;
// 哪个入口来的:辅助生成 / 上传脚本 / 上传视频提炼。三者都是 mode=auto,靠它区分并记进脚本「来源」
source?: "ai" | "manual" | "video";
},
onEvent: (evt: { type: string; [k: string]: unknown }) => void,
signal?: AbortSignal
): Promise<void> {
const token = getToken();
const headers = new Headers({ "Content-Type": "application/json", Accept: "text/event-stream" });
if (token) headers.set("Authorization", `Token ${token}`);
const response = await fetch(`${API_BASE}/api/projects/${projectId}/script-agent-stream/`, {
method: "POST",
headers,
body: JSON.stringify(payload),
signal // 停止按钮:abort 这个 signal 即掐断流(后端检测断连会自动释放预扣额度)
});
if (!response.ok || !response.body) {
const text = await response.text().catch(() => "");
throw new ApiError(response.status, text || "脚本流式生成失败");
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let sep: number;
// SSE 帧以空行分隔(\n\n);每帧取 data: 行解析
while ((sep = buffer.indexOf("\n\n")) !== -1) {
const frame = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
const dataLine = frame.split("\n").find((l) => l.startsWith("data:"));
if (!dataLine) continue;
try {
onEvent(JSON.parse(dataLine.slice(5).trim()));
} catch {
/* 跳过解析失败的帧 */
}
}
}
},
// ── 全能创作(契约见仓库根 `全能创作-契约-2026-09-02.md`)
/** @ 检索:输入框打 @ 或用户说了名字时用。返回的整条 Ref 要原样带进 send 的 refs。 */
searchMentions(params: { q?: string; types?: CreationRef["type"][]; limit?: number } = {}) {
const query = new URLSearchParams();
if (params.q) query.set("q", params.q);
if (params.types?.length) query.set("types", params.types.join(","));
if (params.limit) query.set("limit", String(params.limit));
const suffix = query.toString();
return request<{ results: CreationRef[]; type_labels: Record<string, string> }>(
`/api/ai/mentions/${suffix ? `?${suffix}` : ""}`
);
},
listCreations(params: { mode?: "video" | "image"; status?: string } = {}) {
const query = new URLSearchParams();
if (params.mode) query.set("mode", params.mode);
if (params.status) query.set("status", params.status);
const suffix = query.toString();
return request<Paginated<CreationConversation>>(`/api/ai/creations/${suffix ? `?${suffix}` : ""}`);
},
createCreation(payload: {
title?: string;
mode: "video" | "image";
preset?: string;
params?: Record<string, string>;
}) {
return request<CreationConversation>("/api/ai/creations/", {
method: "POST",
body: JSON.stringify(payload)
});
},
getCreation(id: string) {
return request<CreationConversationDetail>(`/api/ai/creations/${id}/`);
},
renameCreation(id: string, title: string) {
return request<CreationConversation>(`/api/ai/creations/${id}/`, {
method: "PATCH",
body: JSON.stringify({ title })
});
},
updateCreation(id: string, payload: { title?: string; params?: Record<string, string> }) {
return request<CreationConversation>(`/api/ai/creations/${id}/`, {
method: "PATCH",
body: JSON.stringify(payload)
});
},
deleteCreation(id: string) {
return request<void>(`/api/ai/creations/${id}/`, { method: "DELETE" });
},
/** 增量拉消息:轮询生成结果时只补 after_seq 之后的,不重拉整条会话。 */
creationMessages(id: string, afterSeq?: number) {
const suffix = afterSeq === undefined ? "" : `?after_seq=${afterSeq}`;
return request<CreationMessage[]>(`/api/ai/creations/${id}/messages/${suffix}`);
},
/**
* 点确认闸门 → 直接出片。**这个不是 SSE**:后端不跑模型,按方案卡存好的
* video_prompt 直接提交,同步返回「生成中」消息,之后靠轮询转成结果。
*/
confirmCreationPlan(id: string, replyTo: string, params?: Record<string, string>) {
return request<{ message?: CreationMessage; regenerate?: boolean; params?: Record<string, string> }>(
`/api/ai/creations/${id}/send/`,
{
method: "POST",
body: JSON.stringify({ kind: "confirm", reply_to: replyTo, params })
}
);
},
/** 分段视频全部完成后,用户明确确认才触发服务端拼接。 */
mergeCreationVideoSegments(id: string, messageId: string) {
return request<{ message: CreationMessage }>(`/api/ai/creations/${id}/merge-video-segments/`, {
method: "POST",
body: JSON.stringify({ message_id: messageId }),
});
},
/**
* 发一条消息 → 异步整理方案。成功 **202** `{conversation_id, agent_status}`
* 闸门短路径可能 **200** 带 `messages`;冲突 **409**(团队已有对话在整理方案)。
* 前端靠 getCreation / messages?after_seq= 轮询进度,离开页面也不中断 Celery。
*/
creationSend(
id: string,
payload: {
kind?: "text" | "elicit_answer";
text?: string;
refs?: CreationRef[];
reply_to?: string;
answers?: Record<string, string | string[]>;
model_config_id?: string;
params?: Record<string, string>;
}
) {
return request<{
conversation_id: string;
agent_status: "idle" | "planning" | "awaiting_user";
messages?: CreationMessage[];
}>(`/api/ai/creations/${id}/send/`, {
method: "POST",
body: JSON.stringify(payload),
});
},
/**
* 终止整理方案(planning)。成功 **200** `{conversation_id, agent_status}`
* 闸门修订/推进中取消时多为 `awaiting_user`(已恢复 step_confirm),否则 `idle`。
* 非 planning **409**。服务端放锁 + Redis 取消标,离开页面再进也生效。
*/
cancelCreationAgent(id: string) {
return request<{
conversation_id: string;
agent_status: "idle" | "planning" | "awaiting_user";
}>(`/api/ai/creations/${id}/cancel/`, {
method: "POST",
body: JSON.stringify({}),
});
},
adoptScript(projectId: string, script_version_id: string) {
return request<ScriptVersion>(`/api/projects/${projectId}/adopt-script/`, {
method: "POST",
body: JSON.stringify({ script_version_id })
});
},
// 独立实体提取(**异步**):只提交任务、秒回 task_id(慢活在 worker 跑,不再 502);已有在途则复用(防重复扣费)。
// 进度/结果用 extractStatus 轮询。商品不提(参考图层用真实主图)。
extractEntities(projectId: string) {
return request<{ task_id: string; status: string; mode?: string; entities?: Array<{ id: string; type: "character" | "scene"; name: string; visual_prompt: string; ref_index: number }> }>(`/api/projects/${projectId}/extract-entities/`, { method: "POST" });
},
// 实体提取进度:running=有在途任务(刷新后据此重建 loading);否则读最近一次成败,成功回带 entities 供「提取+生成」继续出图。
extractStatus(projectId: string) {
return request<{
running: boolean;
status: string;
error?: unknown;
error_message: string;
entities: Array<{ id: string; type: "character" | "scene"; name: string; visual_prompt: string; ref_index: number }>;
}>(`/api/projects/${projectId}/extract-status/`);
},
updateScriptSegment(projectId: string, payload: { segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) {
return request<ScriptVersion>(`/api/projects/${projectId}/update-script-segment/`, { method: "POST", body: JSON.stringify(payload) });
},
addScriptSegment(projectId: string, payload: { after_segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) {
return request<ScriptVersion>(`/api/projects/${projectId}/add-script-segment/`, { method: "POST", body: JSON.stringify(payload) });
},
deleteScriptSegment(projectId: string, payload: { segment_id: string }) {
return request<ScriptVersion>(`/api/projects/${projectId}/delete-script-segment/`, { method: "POST", body: JSON.stringify(payload) });
},
// 单条分镜重跑:后端只重写指定 segment(可带 instruction 微调),返回更新后的 ScriptVersion
rerunScriptSegment(projectId: string, payload: { segment_id: string; instruction?: string }) {
return request<ScriptVersion>(`/api/projects/${projectId}/rerun-script-segment/`, { method: "POST", body: JSON.stringify(payload) });
},
adoptVideoVersion(projectId: string, payload: { video_segment_id: string; version_id: string }) {
return request<Project>(`/api/projects/${projectId}/adopt-video-version/`, { method: "POST", body: JSON.stringify(payload) });
},
generateVoiceover(projectId: string, payload: { items: Array<{ index: number; text: string }>; voice_type?: string; speed_ratio?: number }) {
return request<{ voiceover: VoiceoverInfo }>(`/api/projects/${projectId}/generate-voiceover/`, { method: "POST", body: JSON.stringify(payload) });
},
// 异步:提交后秒回 RESERVED 任务,再用 generateImageStatus 轮询取结果(慢出图在 worker 跑,Web 不被占住)
generateBaseAsset(projectId: string, payload: { kind: "product" | "person" | "scene"; prompt: string; label?: string; reference_asset_id?: string }) {
return request<{ task: { id: string; status: string } }>(`/api/projects/${projectId}/generate-base-asset/`, { method: "POST", body: JSON.stringify(payload) });
},
adoptBaseAsset(projectId: string, payload: { group_id: string; asset_id: string }) {
return request(`/api/projects/${projectId}/adopt-base-asset/`, { method: "POST", body: JSON.stringify(payload) });
},
// 显性采用/未采用某角色/场景组(下游只取已采用)
setBaseAssetAdopt(projectId: string, payload: { group_id: string; state: "adopted" | "unadopted" }) {
return request(`/api/projects/${projectId}/set-base-asset-adopt/`, { method: "POST", body: JSON.stringify(payload) });
},
// 删除某角色/场景资产组(连带其三视图组)
deleteBaseAsset(projectId: string, groupId: string) {
return request(`/api/projects/${projectId}/delete-base-asset/`, { method: "POST", body: JSON.stringify({ group_id: groupId }) });
},
// 流程步骤4 · 用模特库现有形象资产替换角色基础资产卡(挂为候选并采用)
// seed 占位卡还没 group:不传 group_id,改传 kind+label,后端按 label 命中/新建实体组再挂(不出图)
attachBaseAsset(projectId: string, payload: { group_id?: string; asset_id: string; kind?: "product" | "person" | "scene"; label?: string }) {
return request(`/api/projects/${projectId}/attach-base-asset/`, { method: "POST", body: JSON.stringify(payload) });
},
// 流程步骤4 · 据某一版立绘资产生成它配套的三视图(image_edit 锁角色一致性,绑定该立绘 asset)
// 异步:提交后秒回 RESERVED 任务,再用 generateImageStatus 轮询取结果(慢 image_edit 在 worker 跑)
generateTriview(projectId: string, payload: { portrait_asset_id: string }) {
return request<{ task: { id: string; status: string } }>(`/api/projects/${projectId}/generate-triview/`, { method: "POST", body: JSON.stringify(payload) });
},
// 轮询本团队真人资产的火山审核状态(绿盾 active / 红标 failed),刷新基础资产趴徽章
pollReviews(projectId: string) {
return request<{ reviews: Record<string, string> }>(`/api/projects/${projectId}/poll-reviews/`, { method: "POST" });
},
submitVideo(projectId: string, payload: { video_segment_id: string; prompt: string }) {
return request<Project>(`/api/projects/${projectId}/submit-video-segment/`, { method: "POST", body: JSON.stringify(payload) });
},
// 点生成视频前的过审闸:列出未过审的人物立绘。video_segment_id 给定=只校验该段,不给=全部未出片段。
videoReviewPrecheck(projectId: string, videoSegmentId?: string) {
return request<{ blockers: Array<{ video_segment_id: string; sort_order: number; scene_no: number; kind: "person"; name: string; asset_id: string; review_status: string }> }>(
`/api/projects/${projectId}/video-review-precheck/`,
{ method: "POST", body: JSON.stringify(videoSegmentId ? { video_segment_id: videoSegmentId } : {}) },
);
},
pollVideo(projectId: string, video_segment_id: string) {
return request(`/api/projects/${projectId}/poll-video-segment/`, {
method: "POST",
body: JSON.stringify({ video_segment_id })
});
},
submitExport(projectId: string) {
return request(`/api/projects/${projectId}/submit-export/`, { method: "POST" });
},
pollExport(projectId: string) {
return request<import("./types").ExportPoll>(`/api/projects/${projectId}/poll-export/`, { method: "POST" });
},
uploadVideoSegment(projectId: string, segmentId: string, file: File) {
const form = new FormData();
form.append("video_segment_id", segmentId);
form.append("file", file);
return request<Project>(`/api/projects/${projectId}/upload-video-segment/`, { method: "POST", body: form });
},
uploadBgm(projectId: string, file: File, volume?: number) {
const form = new FormData();
form.append("file", file);
if (volume != null) form.append("volume", String(volume));
return request<Project>(`/api/projects/${projectId}/upload-bgm/`, { method: "POST", body: form });
},
saveTimeline(projectId: string, payload: import("./types").TimelineSavePayload) {
return request<Project>(`/api/projects/${projectId}/save-timeline/`, { method: "POST", body: JSON.stringify(payload) });
},
assets() {
return request<Paginated<Asset>>("/api/assets/");
},
// 服务端分页+过滤的资产列表(各页按需懒加载,不再前端取全量再切片)。
assetsPage(params: {
tab?: string; category?: string; source?: string; asset_type?: string;
product?: string; origin_task?: string; q?: string; ordering?: string; page?: number; pageSize?: number;
// in_library: "all" 看全部(含未入库,任务中心用)/ "false" 只看未入库 / 省略=只看已入库
in_library?: string;
meta?: Record<string, string>;
} = {}) {
const qs = new URLSearchParams();
const { meta, pageSize, ...rest } = params;
for (const [k, v] of Object.entries(rest)) if (v !== undefined && v !== "" && v !== null) qs.set(k, String(v));
if (pageSize) qs.set("page_size", String(pageSize));
if (meta) for (const [k, v] of Object.entries(meta)) if (v) qs.set(`m_${k}`, v);
return request<Paginated<Asset>>(`/api/assets/?${qs.toString()}`);
},
// 图片趴(模特上身图/平台套图/自由创作)按生成批次成组分页:每批一张卡(封面+张数),点开看整批图
assetBatches(params: {
tab?: string; source?: string; product?: string; q?: string; ordering?: string; page?: number; pageSize?: number;
meta?: Record<string, string>;
} = {}) {
const qs = new URLSearchParams();
const { meta, pageSize, ...rest } = params;
for (const [k, v] of Object.entries(rest)) if (v !== undefined && v !== "" && v !== null) qs.set(k, String(v));
if (pageSize) qs.set("page_size", String(pageSize));
if (meta) for (const [k, v] of Object.entries(meta)) if (v) qs.set(`m_${k}`, v);
return request<Paginated<import("./types").AssetBatch>>(`/api/assets/batches/?${qs.toString()}`);
},
// 资产库各 tab 计数(tab 徽标用,不必取全量)
assetSummary() {
return request<Record<string, number>>("/api/assets/summary/");
},
// 视频成品:按项目素材包分组(每项目一包,含片段列表)
videoPacks() {
return request<import("./types").VideoPack[]>("/api/assets/video-packs/");
},
// 手动兜底:把单个送审类素材(角色/三视图/分镜)送火山审核(用户点灰盾「待审核」时)。
// forReference:素材要拿去当生成参考(自由创作 @引用三库),此时不看类目白名单一律送审。
submitAssetReview(id: string, forReference = false) {
return request<{ review_status: string; review_error: string }>(`/api/assets/${id}/submit-review/`, {
method: "POST",
body: JSON.stringify({ for_reference: forReference })
});
},
// 置 in_library(功能性模特/角色资料不走该入口;图片成图仍按原规则入库)
setAssetLibrary(id: string, inLibrary: boolean) {
return request<{ id: string; in_library: boolean }>(`/api/assets/${id}/set-library/`, { method: "POST", body: JSON.stringify({ in_library: inLibrary }) });
},
// 某 tab 下真实存在的筛选项(来源/类型/指定 metadata 键取值),供下拉「只列真有的」
async assetFacets(tab?: string, metaKeys: string[] = []) {
const qs = new URLSearchParams();
if (tab) qs.set("tab", tab);
if (metaKeys.length) qs.set("meta_keys", metaKeys.join(","));
const f = await request<{ sources?: string[]; kinds?: string[]; metadata?: Record<string, string[]>; products?: { id: string; title: string }[] }>(`/api/assets/facets/?${qs.toString()}`);
// 旧后端(未部署 products 字段)返回体缺键会让页面 undefined.find 白屏,逐键兜底
return { sources: f.sources ?? [], kinds: f.kinds ?? [], metadata: f.metadata ?? {}, products: f.products ?? [] };
},
// 导出全部:把项目所有已采用视频片段打成一个 zip 下载(后端逐段从 TOS 拉取打包)
async exportProjectClips(projectId: string): Promise<Blob> {
const token = getToken();
const res = await fetch(`${API_BASE}/api/projects/${projectId}/export-clips/`, {
headers: token ? { Authorization: `Token ${token}` } : undefined
});
if (!res.ok) {
let msg = `导出失败 (${res.status})`;
try { msg = (await res.json()).detail || msg; } catch { /* 非 JSON 错误体 */ }
throw new ApiError(res.status, msg);
}
return res.blob();
},
// 经后端同源代理取资产原始文件(TOS 未配 CORS,浏览器抽帧/解码必须同源)
async fetchAssetBlob(id: string): Promise<Blob> {
const token = getToken();
const response = await fetch(`${API_BASE}/api/assets/${id}/raw/`, {
headers: token ? { Authorization: `Token ${token}` } : undefined
});
if (!response.ok) throw new ApiError(response.status, `fetch asset raw failed: ${response.status}`);
return response.blob();
},
// 跟随 DRF 分页 next 取全部资产 —— 商品图/AI 素材/资产库都靠 asset.id 在这份列表里查 preview_url,
// 只取第 1 页(20 条)会让第 20 条之后的资产解析不到图、渲染成空占位。
async allAssets(): Promise<Asset[]> {
// 一次大页(page_size=200)尽量取全;若还有多页,并行取剩余页(不再逐页串行等,根治整页刷新慢)
const SIZE = 200;
const first = await request<Paginated<Asset>>(`/api/assets/?page_size=${SIZE}`);
const out: Asset[] = [...first.results];
const pageSize = first.results.length || SIZE; // 后端若忽略 page_size,按实际页大小算页数,逻辑仍正确
const totalPages = pageSize > 0 ? Math.ceil((first.count || out.length) / pageSize) : 1;
if (totalPages <= 1) return out;
const rest = await Promise.all(
Array.from({ length: totalPages - 1 }, (_, i) =>
request<Paginated<Asset>>(`/api/assets/?page_size=${SIZE}&page=${i + 2}`)
.then((p) => p.results)
.catch(() => [] as Asset[])
)
);
rest.forEach((r) => out.push(...r));
return out;
},
uploadAsset(formData: FormData) {
return request<Asset>("/api/assets/upload/", { method: "POST", body: formData });
},
// 流程步骤4 · 给模特形象/资产改名(添加模特工作台命名 → PATCH 写回 name
updateAsset(id: string, payload: { name?: string; description?: string }) {
return request<Asset>(`/api/assets/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
},
// 模特库:列出(本团队 官方模板);tab=official 只看官方、mine 只看自建
listModels(params: { tab?: "official" | "mine"; q?: string; tags?: string[]; portraitAsset?: string; page?: number; pageSize?: number } = {}) {
const qs = new URLSearchParams();
if (params.tab) qs.set("tab", params.tab);
if (params.q) qs.set("q", params.q);
if (params.portraitAsset) qs.set("portrait_asset", params.portraitAsset);
for (const tag of params.tags || []) qs.append("tag", tag);
if (params.page) qs.set("page", String(params.page));
qs.set("page_size", String(params.pageSize ?? 200));
return request<Paginated<ModelEntity>>(`/api/models/?${qs.toString()}`);
},
listModelTags(params: { tab?: "official" | "mine" } = {}) {
const qs = new URLSearchParams();
if (params.tab) qs.set("tab", params.tab);
const q = qs.toString();
return request<{ results: { name: string; label?: string; count: number }[] }>(`/api/models/tags/${q ? `?${q}` : ""}`);
},
// 真人上传:一张人像图 → 建 model_portrait 资产 + Model 实体
uploadModel(formData: FormData) {
return request<ModelEntity>("/api/models/upload/", { method: "POST", body: formData });
},
// /models 详情:上传并替换当前模特形象图,不创建新模特。
uploadModelPortrait(id: string, formData: FormData) {
return request<ModelEntity>(`/api/models/${id}/upload-portrait/`, { method: "POST", body: formData });
},
// 图片创作「加入模特库」:把一张已生成的图复用为模特形象图建 Model 条目(不重传、不二次扣费;幂等)
enrollModelFromAsset(assetId: string, name?: string) {
return request<ModelEntity>("/api/models/enroll-asset/", { method: "POST", body: JSON.stringify({ asset_id: assetId, name }) });
},
// /models 模特详情编辑:只更新模特实体资料,不回写项目角色或底层图片资产。
updateLibraryModel(id: string, payload: { name?: string; description?: string }) {
return request<ModelEntity>(`/api/models/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
},
modelTriviewQuote(id: string) {
return request<{ points: string }>(`/api/models/${id}/triview-quote/`);
},
generateModelTriview(id: string) {
return request<{ task_id: string; status: string; estimated_cost: string; reused: boolean }>(
`/api/models/${id}/generate-triview/`,
{ method: "POST" },
);
},
modelTriviewStatus(id: string, taskId: string) {
const query = new URLSearchParams({ task_id: taskId });
return request<{
task_id: string;
status: string;
estimated_cost: string;
actual_cost: string;
error?: unknown;
error_message: string;
model: ModelEntity | null;
}>(`/api/models/${id}/triview-status/?${query.toString()}`);
},
// 删除模特(软删;官方模特不可删)
deleteModel(id: string) {
return request<void>(`/api/models/${id}/`, { method: "DELETE" });
},
modelsTrash() {
return request<Paginated<ModelEntity>>("/api/models/trash/");
},
restoreModel(id: string) {
return request<ModelEntity>(`/api/models/${id}/restore/`, { method: "POST" });
},
purgeModel(id: string) {
return request<void>(`/api/models/${id}/purge/`, { method: "DELETE" });
},
billingSummary() {
return request<BillingSummary>("/api/billing/summary/");
},
ledgers(page = 1, pageSize = 10, filters?: { ledger_type?: string; user?: string; month?: string }) {
// 类型/成员/月份筛选传参给后端做全量过滤(count/总页数随筛选变化)
const q = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
if (filters?.ledger_type) q.set("ledger_type", filters.ledger_type);
if (filters?.user) q.set("user", filters.user);
if (filters?.month) q.set("month", filters.month);
return request<{ count: number; page: number; page_size: number; results: Ledger[] }>(`/api/billing/ledgers/?${q.toString()}`);
},
billingTrend(range?: "day" | "week" | "month", month?: string) {
const q = new URLSearchParams();
if (range) q.set("range", range);
if (month) q.set("month", month);
const qs = q.toString();
return request<BillingTrend>(`/api/billing/trend/${qs ? `?${qs}` : ""}`);
},
modelConfigs() {
// 创作页下拉依赖完整 active 目录;加大 page_size 避免默认分页截断
return request<Paginated<ModelConfig>>("/api/ai/models/?page_size=200");
},
aiTasks() {
// 生图工作室的任务中心:只看生图任务(模特上身图/平台套图/图片创作 = person_image /
// product_image),不掺脚本/实体抽取等流水线内部任务。取一大页(后端上限 200)
// 以便 全部/已完成/失败 三个 tab 数按真实总量算。
return request<Paginated<AITask>>("/api/ai/tasks/?page_size=200&task_type=person_image,product_image");
},
// R100:工作台生成记录(模特上身图/平台套图)——从后端持久任务拉取,与任务中心同源,
// 刷新/换浏览器不丢;按 mode(+product)过滤,前端按 batch_id 归批。
workbenchTasks(mode: "model" | "cover" | "image", productId?: string) {
const qs = new URLSearchParams({ mode });
if (productId) qs.set("product", productId);
return request<{ tasks: import("./types").WorkbenchTask[] }>(`/api/ai/tasks/workbench/?${qs.toString()}`);
},
// 工作台图片批次:全成功时沿用资产垃圾桶;含失败/取消任务时进入“图片异常批次”并支持整批恢复。
deleteWorkbenchImageBatch(taskId: string) {
return request<{ storage: "asset" | "exception_batch" }>(`/api/ai/tasks/${taskId}/workbench-batch/`, { method: "DELETE" });
},
imageExceptionBatchesTrash() {
return request<{ results: import("./types").ImageExceptionBatchTrash[] }>("/api/ai/tasks/workbench-exception-batches-trash/");
},
restoreWorkbenchImageBatch(taskId: string) {
return request<{ restored_task_count: number }>(`/api/ai/tasks/${taskId}/restore-workbench-batch/`, { method: "POST" });
},
purgeWorkbenchImageBatch(taskId: string) {
return request<void>(`/api/ai/tasks/${taskId}/purge-workbench-batch/`, { method: "DELETE" });
},
// YYX#row22:未读生成任务汇总(导航胶囊总数 + 按商品分组角标)
aiTasksUnread() {
return request<{ total: number; by_product: Record<string, number> }>("/api/ai/tasks/unread/");
},
// YYX#row22:标记已读 → 清零未读胶囊。不传参 = 全部已读;可按 product_id / batch_id / ids 局部清。
markAiTasksRead(payload?: { product_id?: string; batch_id?: string; ids?: string[] }) {
return request<{ updated: number }>("/api/ai/tasks/mark-read/", { method: "POST", body: JSON.stringify(payload || {}) });
},
// 异步生图:提交后秒级返回任务列表(慢出图在 worker 跑),再用 generateImageStatus 轮询取结果。
// 带 conversation_id 则归属该对话;不带则后端自动开一条新对话并回传其 id。
// 带 batch_id(重跑/补图)则任务归回原批次;响应回传本批 batch_id 供前端存进批次卡。
submitGenerateImage(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean; model_id?: string; model_entity_id?: string; ratio?: string; image_model?: string; platform_id?: string; conversation_id?: string; reference_image_ids?: string[]; batch_id?: string; retry_of_task_id?: string }) {
return request<{ conversation_id: string; batch_id?: string; tasks: { id: string; status: string }[] }>("/api/ai/generate-image/", { method: "POST", body: JSON.stringify(payload) });
},
// 图片创作对话 CRUD —— 左栏会话列表 / 新对话 / 切换 / 重命名 / 删除
listConversations(mode: "image" | "model" | "cover" = "image", productId?: string) {
const params = new URLSearchParams({ mode, page_size: "100" });
if (productId) params.set("product_id", productId);
else params.set("scope", "unbound");
return request<Paginated<ImageConversation>>(`/api/ai/image-conversations/?${params.toString()}`);
},
conversationsTrash(mode: "image" | "model" | "cover" = "image") {
return request<Paginated<ImageConversationTrash>>(`/api/ai/image-conversations/trash/?mode=${mode}&page_size=100`);
},
createConversation(payload: { mode?: "image" | "model" | "cover"; title?: string; product?: string | null }) {
return request<ImageConversation>("/api/ai/image-conversations/", { method: "POST", body: JSON.stringify(payload) });
},
renameConversation(id: string, title: string) {
return request<ImageConversation>(`/api/ai/image-conversations/${id}/`, { method: "PATCH", body: JSON.stringify({ title }) });
},
deleteConversation(id: string) {
return request<void>(`/api/ai/image-conversations/${id}/`, { method: "DELETE" });
},
restoreConversation(id: string) {
return request<ImageConversation>(`/api/ai/image-conversations/${id}/restore/`, { method: "POST" });
},
purgeConversation(id: string) {
return request<void>(`/api/ai/image-conversations/${id}/purge/`, { method: "DELETE" });
},
// 切换对话时回填:该对话历次生成的任务 + 成图
conversationTasks(id: string) {
return request<{ conversation_id: string; tasks: ImageConversationTask[] }>(`/api/ai/image-conversations/${id}/tasks/`);
},
generateImageStatus(ids: string[]) {
return request<{ tasks: { id: string; status: string; error?: unknown; error_message: string; assets: Asset[] }[] }>(`/api/ai/generate-image/?ids=${encodeURIComponent(ids.join(","))}`);
},
// —— 自由创作·视频生成 ——
// 提交秒回(火山 create 同步、慢轮询交给 worker 兜底 + 前端主动 poll);校验失败 400 带中文 detail。
submitFreeVideo(payload: {
prompt: string;
mode: "universal" | "keyframe";
model?: string;
aspect_ratio?: string;
resolution?: string;
duration?: number;
seed?: number;
generate_audio?: boolean;
references?: FreeVideoRef[];
}) {
return request<{ task: FreeVideoTask }>("/api/ai/free-video/", { method: "POST", body: JSON.stringify(payload) });
},
submitVideoReplace(payload: {
replace_mode: "product" | "character";
video_asset_id: string;
product_id?: string;
model_id?: string;
image_asset_ids?: string[];
subject_brief?: string;
model?: string;
aspect_ratio?: string;
resolution?: string;
duration?: number;
}) {
return request<{ task: FreeVideoTask }>("/api/ai/video-replace/", { method: "POST", body: JSON.stringify(payload) });
},
videoReplaceTasks(offset = 0, pageSize = 20) {
// inflight 只在首页返回:刷新/重进页面据此恢复「进行中」,不用自己在列表里翻状态
return request<{ results: FreeVideoTask[]; total: number; has_more: boolean; inflight?: FreeVideoTask | null }>(
`/api/ai/video-replace/?offset=${offset}&page_size=${pageSize}`
);
},
pollVideoReplace(id: string) {
return request<{ task: FreeVideoTask }>(`/api/ai/video-replace/${id}/poll/`, { method: "POST" });
},
freeVideoTasks(offset = 0, pageSize = 20) {
return request<{ results: FreeVideoTask[]; total: number; has_more: boolean }>(
`/api/ai/free-video/?offset=${offset}&page_size=${pageSize}`
);
},
freeVideoTrash(offset = 0, pageSize = 20) {
return request<{ results: FreeVideoTask[]; total: number; has_more: boolean }>(
`/api/ai/free-video/trash/?offset=${offset}&page_size=${pageSize}`
);
},
// web 进程内单次轮询+终态化(幂等):前端渐进轮询打这里,本地无 worker 也能收尾
pollFreeVideo(id: string) {
return request<{ task: FreeVideoTask }>(`/api/ai/free-video/${id}/poll/`, { method: "POST" });
},
toggleFreeVideoFavorite(id: string) {
return request<{ is_favorited: boolean }>(`/api/ai/free-video/${id}/favorite/`, { method: "POST" });
},
deleteFreeVideo(id: string) {
return request<void>(`/api/ai/free-video/${id}/`, { method: "DELETE" });
},
restoreFreeVideo(id: string) {
return request<{ task: FreeVideoTask }>(`/api/ai/free-video/${id}/restore/`, { method: "POST" });
},
purgeFreeVideo(id: string) {
return request<void>(`/api/ai/free-video/${id}/purge/`, { method: "DELETE" });
},
uploadFreeVideoRef(formData: FormData) {
return request<FreeVideoUploadResult>("/api/ai/free-video/upload/", { method: "POST", body: formData });
},
// 自由创作·人物素材库(火山 Assets API asset:// 引用登记)
freeAssetGroups() {
return request<{ results: FreeAssetGroup[] }>("/api/assets/free-groups/");
},
createFreeAssetGroup(payload: { name: string; description?: string }) {
return request<{ group: FreeAssetGroup }>("/api/assets/free-groups/", { method: "POST", body: JSON.stringify(payload) });
},
freeAssetGroup(id: string) {
return request<{ group: FreeAssetGroup; assets: FreeAssetItem[] }>(`/api/assets/free-groups/${id}/`);
},
updateFreeAssetGroup(id: string, payload: { name?: string; description?: string }) {
return request<{ group: FreeAssetGroup }>(`/api/assets/free-groups/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
},
deleteFreeAssetGroup(id: string) {
return request<void>(`/api/assets/free-groups/${id}/`, { method: "DELETE" });
},
uploadFreeAsset(groupId: string, formData: FormData) {
return request<{ asset: FreeAssetItem }>(`/api/assets/free-groups/${groupId}/assets/`, { method: "POST", body: formData });
},
quickUploadFreeAsset(formData: FormData) {
return request<{ group: FreeAssetGroup; asset: FreeAssetItem }>("/api/assets/free-assets/quick-upload/", { method: "POST", body: formData });
},
renameFreeAsset(id: string, name: string) {
return request<{ asset: FreeAssetItem }>(`/api/assets/free-assets/${id}/`, { method: "PATCH", body: JSON.stringify({ name }) });
},
deleteFreeAsset(id: string) {
return request<void>(`/api/assets/free-assets/${id}/`, { method: "DELETE" });
},
pollFreeAsset(id: string) {
return request<{ asset: FreeAssetItem }>(`/api/assets/free-assets/${id}/poll/`, { method: "POST" });
},
recharge(payload: { amount: number | string; bonus_points?: number | string; channel?: string }) {
// amount 是真实支付 ¥,到账积分 = ¥ × points_per_yuan + bonus_points(赠送积分)
return request<RechargeResult>("/api/billing/recharge/", { method: "POST", body: JSON.stringify(payload) });
},
// 计费公共配置(积分汇率/视频毛利/预留 buffer):所有前端预估统一读这里,与后端计价引擎同源
billingConfig() {
return request<BillingConfigInfo>("/api/billing/config/");
},
// 收件箱按页拉取 —— 滚动加载逐页向后端要(tab/搜索 走服务端,计数随响应回来)
listNotifications(params?: { type?: string; unread?: boolean; search?: string; page?: number; pageSize?: number; excludeTypes?: string[] }) {
const query = new URLSearchParams();
if (params?.type && params.type !== "all") query.set("type", params.type);
if (params?.unread) query.set("unread", "1");
if (params?.search) query.set("search", params.search);
if (params?.page) query.set("page", String(params.page));
if (params?.pageSize) query.set("page_size", String(params.pageSize));
for (const type of params?.excludeTypes || []) {
if (type) query.append("exclude_type", type);
}
const qs = query.toString();
return request<NotificationList>(`/api/ops/notifications/${qs ? `?${qs}` : ""}`);
},
// 侧边栏徽标(unread_count)+ 团队动态(只展示最近 6 条)用。只取第 1 页 100 条即足够,
// count 用后端真实总数(给「共 N」显示),不再随消息增长逐页翻全部(原来 O(N) 拖慢每次刷新)。
// 注:不参与收件箱滚动渲染——收件箱走 listNotifications 自己的服务端分页。
async allNotifications(): Promise<NotificationList> {
return request<NotificationList>(`/api/ops/notifications/?page_size=100`);
},
// 侧边栏未读徽标:只要 unread_count,取 1 条即可(不为徽标拉 100 条)
notificationsBadge() {
return request<NotificationList>(`/api/ops/notifications/?page_size=1`);
},
markAllNotificationsRead() {
return request<{ updated: number; unread_count: number }>("/api/ops/notifications/mark-all-read/", {
method: "POST"
});
},
markNotificationRead(id: string) {
return request<Notification>(`/api/ops/notifications/${id}/mark-read/`, { method: "POST" });
},
archiveNotification(id: string) {
// 后端 archive 端点返回 204 无 body
return request<void>(`/api/ops/notifications/${id}/archive/`, { method: "POST" });
}
};
// 平台超管后台 API(/api/admin/*,全程 IsPlatformAdmin)。与团队级 api 分层,便于权限收敛。
export const adminApi = {
invitations(params?: { kind?: string; status?: string; search?: string; page?: number; page_size?: number }) {
const qs = new URLSearchParams();
if (params?.kind) qs.set("kind", params.kind);
if (params?.status) qs.set("status", params.status);
if (params?.search) qs.set("search", params.search);
if (params?.page) qs.set("page", String(params.page));
if (params?.page_size) qs.set("page_size", String(params.page_size));
const q = qs.toString();
return request<Paginated<Invitation>>(`/api/admin/invitations/${q ? `?${q}` : ""}`);
},
createInvite(payload?: { email?: string }) {
return request<Invitation>("/api/admin/invitations/", { method: "POST", body: JSON.stringify(payload || {}) });
},
revokeInvite(id: string) {
return request<Invitation>(`/api/admin/invitations/${id}/revoke/`, { method: "POST" });
},
teams(params?: { status?: string; search?: string; page?: number; page_size?: number }) {
const qs = new URLSearchParams();
if (params?.status) qs.set("status", params.status);
if (params?.search) qs.set("search", params.search);
if (params?.page) qs.set("page", String(params.page));
if (params?.page_size) qs.set("page_size", String(params.page_size));
const q = qs.toString();
return request<Paginated<AdminTeam>>(`/api/admin/teams/${q ? `?${q}` : ""}`);
},
teamDetail(id: string) {
return request<AdminTeamDetail>(`/api/admin/teams/${id}/`);
},
setTeamPricing(id: string, priceMultiplier: string) {
return request<AdminTeam>(`/api/admin/teams/${id}/pricing/`, { method: "POST", body: JSON.stringify({ price_multiplier: priceMultiplier }) });
},
toggleTeam(id: string) {
return request<AdminTeam>(`/api/admin/teams/${id}/toggle/`, { method: "POST" });
},
users(params?: { status?: string; search?: string; page?: number; page_size?: number }) {
const qs = new URLSearchParams();
if (params?.status) qs.set("status", params.status);
if (params?.search) qs.set("search", params.search);
if (params?.page) qs.set("page", String(params.page));
if (params?.page_size) qs.set("page_size", String(params.page_size));
const q = qs.toString();
return request<Paginated<AdminUser>>(`/api/admin/users/${q ? `?${q}` : ""}`);
},
createUser(payload: { username: string; display_name: string; password: string; initial_credits?: string }) {
return request<AdminUser>("/api/admin/users/", { method: "POST", body: JSON.stringify(payload) });
},
adjustUserCredit(id: string, payload: { amount: string; reason?: string }) {
return request<AdminUser>(`/api/admin/users/${id}/credits/`, { method: "POST", body: JSON.stringify(payload) });
},
toggleUser(id: string) {
return request<AdminUser>(`/api/admin/users/${id}/toggle/`, { method: "POST" });
},
resetUserPassword(id: string, password: string) {
return request<void>(`/api/admin/users/${id}/reset-password/`, { method: "POST", body: JSON.stringify({ password }) });
},
qualityWords() {
return request<AdminQualityWord[]>("/api/admin/quality-words/");
},
createQualityWord(payload: { stage: string; text: string; sort?: number; slot?: string }) {
return request<AdminQualityWord>("/api/admin/quality-words/", { method: "POST", body: JSON.stringify(payload) });
},
updateQualityWord(id: string, payload: { text?: string; sort?: number; enabled?: boolean }) {
return request<AdminQualityWord>(`/api/admin/quality-words/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
},
deleteQualityWord(id: string) {
return request<void>(`/api/admin/quality-words/${id}/`, { method: "DELETE" });
},
// 生图/视频提示词模板(视频线 6 条):列表 + 改正文/比例/启用
promptTemplates() {
return request<import("./types").AdminPromptTemplate[]>("/api/admin/prompt-templates/");
},
updatePromptTemplate(id: string, payload: { template?: string; ratio?: string; enabled?: boolean }) {
return request<import("./types").AdminPromptTemplate>(`/api/admin/prompt-templates/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
},
assetReviews(params?: { review_status?: string; search?: string; page?: number; page_size?: number }) {
const qs = new URLSearchParams();
if (params?.review_status) qs.set("review_status", params.review_status);
if (params?.search) qs.set("search", params.search);
if (params?.page) qs.set("page", String(params.page));
if (params?.page_size) qs.set("page_size", String(params.page_size));
const q = qs.toString();
return request<Paginated<AdminReviewAsset>>(`/api/admin/asset-reviews/${q ? `?${q}` : ""}`);
},
submitReviews(assetIds: string[]) {
return request<{ submitted: number; statuses: Record<string, string> }>(
"/api/admin/asset-reviews/submit/",
{ method: "POST", body: JSON.stringify({ asset_ids: assetIds }) }
);
},
pollReviews(assetIds?: string[]) {
return request<{ polled: number; submitted?: number; statuses: Record<string, string> }>(
"/api/admin/asset-reviews/poll/",
{ method: "POST", body: JSON.stringify(assetIds ? { asset_ids: assetIds } : {}) }
);
},
tasks(params?: { status?: string; task_type?: string; category?: "omni_create"; team?: string; anomaly?: string; page?: number; page_size?: number }) {
const qs = new URLSearchParams();
if (params?.status) qs.set("status", params.status);
if (params?.task_type) qs.set("task_type", params.task_type);
if (params?.category) qs.set("category", params.category);
if (params?.team) qs.set("team", params.team);
if (params?.anomaly) qs.set("anomaly", params.anomaly);
if (params?.page) qs.set("page", String(params.page));
if (params?.page_size) qs.set("page_size", String(params.page_size));
const q = qs.toString();
return request<Paginated<AdminTask>>(`/api/admin/tasks/${q ? `?${q}` : ""}`);
},
taskDetail(id: string) {
return request<AdminTaskDetail>(`/api/admin/tasks/${id}/`);
},
retryTask(id: string) {
return request<{ retried: boolean; task_id: string }>(`/api/admin/tasks/${id}/retry/`, { method: "POST" });
},
pollTasks(taskIds?: string[]) {
return request<{ polled: number; statuses: Record<string, string> }>(
"/api/admin/tasks/poll/",
{ method: "POST", body: JSON.stringify(taskIds ? { task_ids: taskIds } : {}) }
);
},
reapTask(id: string) {
return request<AdminTask>(`/api/admin/tasks/${id}/reap/`, { method: "POST" });
},
ledgers(params?: { ledger_type?: string; team?: string; page?: number; page_size?: number }) {
const qs = new URLSearchParams();
if (params?.ledger_type) qs.set("ledger_type", params.ledger_type);
if (params?.team) qs.set("team", params.team);
if (params?.page) qs.set("page", String(params.page));
if (params?.page_size) qs.set("page_size", String(params.page_size));
const q = qs.toString();
return request<Paginated<AdminLedger>>(`/api/admin/ledgers/${q ? `?${q}` : ""}`);
},
billingConfig() {
return request<BillingConfigInfo & { updated_at: string }>("/api/admin/billing-config/");
},
updateBillingConfig(payload: { points_per_yuan?: string; video_margin_multiplier?: string; video_reserve_buffer?: string }) {
return request<BillingConfigInfo & { updated_at: string }>("/api/admin/billing-config/", { method: "PATCH", body: JSON.stringify(payload) });
},
adjustCredit(payload: { team: string; amount: string; reason: string }) {
return request<AdminLedger>("/api/admin/ledgers/adjust/", { method: "POST", body: JSON.stringify(payload) });
},
quotaPolicies(params?: { team?: string; page?: number; page_size?: number }) {
const qs = new URLSearchParams();
if (params?.team) qs.set("team", params.team);
if (params?.page_size) qs.set("page_size", String(params.page_size));
const q = qs.toString();
return request<Paginated<AdminQuotaPolicy>>(`/api/admin/quota-policies/${q ? `?${q}` : ""}`);
},
createQuotaPolicy(payload: { team: string; monthly_limit?: string | null; project_limit?: string | null; per_task_limit?: string | null; is_active?: boolean }) {
return request<AdminQuotaPolicy>("/api/admin/quota-policies/", { method: "POST", body: JSON.stringify(payload) });
},
updateQuotaPolicy(id: string, payload: { monthly_limit?: string | null; project_limit?: string | null; per_task_limit?: string | null; is_active?: boolean }) {
return request<AdminQuotaPolicy>(`/api/admin/quota-policies/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
},
deleteQuotaPolicy(id: string) {
return request<void>(`/api/admin/quota-policies/${id}/`, { method: "DELETE" });
},
providers() {
return request<AdminProvider[]>("/api/admin/providers/");
},
createProvider(payload: { name: string; display_name: string; base_url?: string; api_key?: string; status?: string }) {
return request<AdminProvider>("/api/admin/providers/", { method: "POST", body: JSON.stringify(payload) });
},
updateProvider(id: string, payload: Record<string, unknown>) {
return request<AdminProvider>(`/api/admin/providers/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
},
deleteProvider(id: string) {
return request<void>(`/api/admin/providers/${id}/`, { method: "DELETE" });
},
models(params?: { provider?: string; capability?: string }) {
const qs = new URLSearchParams();
if (params?.provider) qs.set("provider", params.provider);
if (params?.capability) qs.set("capability", params.capability);
const q = qs.toString();
return request<AdminModel[]>(`/api/admin/models/${q ? `?${q}` : ""}`);
},
createModel(payload: { provider: string; name: string; display_name: string; capability: string; endpoint?: string; unit_price?: string; status?: string; metadata?: Record<string, unknown> }) {
return request<AdminModel>("/api/admin/models/", { method: "POST", body: JSON.stringify(payload) });
},
updateModel(id: string, payload: Record<string, unknown>) {
return request<AdminModel>(`/api/admin/models/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
},
deleteModel(id: string) {
return request<void>(`/api/admin/models/${id}/`, { method: "DELETE" });
},
setDefaultModel(id: string) {
return request<AdminModel>(`/api/admin/models/${id}/set-default/`, { method: "POST" });
},
adminProjects(params?: { status?: string; search?: string; page?: number; page_size?: number }) {
const qs = new URLSearchParams();
if (params?.status) qs.set("status", params.status);
if (params?.search) qs.set("search", params.search);
if (params?.page) qs.set("page", String(params.page));
if (params?.page_size) qs.set("page_size", String(params.page_size));
const q = qs.toString();
return request<Paginated<AdminProjectRow>>(`/api/admin/projects/${q ? `?${q}` : ""}`);
},
integrity() {
return request<AdminIntegrity>("/api/admin/integrity/");
}
};