Files
yingqing/core/frontend/src/api.ts
T
zycandClaude Opus 4.8 26577b99b1 feat: 生图模型可选(火山/gpt-image) + 工作台图「加入资产库」收纳 + 任务中心排序修复
- AI 工作室生图支持显式选模型:resolve_image_model 解析 volcano(Seedream 图生图,
  无 image_edit 时走 image_generation 带参考图)/ gpt-image(image_edit 多图编辑),
  未选回落系统默认;enqueue_standalone_images 透传 image_model
- 资产库收纳:Asset 加 in_library 字段(迁移 0007,既有资产 db_default=True 不动);
  工作台生成图默认 in_library=False,只在工作台展示,用户「加入资产库」后才进库列表;
  assets 视图/序列化器/library 页/types 配套
- 任务中心修复:AITaskViewSet 默认 order_by(-created_at),前端 aiTasks 取 page_size=200。
  原因:列表无排序时 MySQL 按 UUID 主键乱序返回,把一批旧失败记录顶到首页,
  前端又只取首页 20 条算 全部/已完成/失败 → 误显示「全部失败」(实为 421 成功/少量失败)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 10:34:12 +08:00

809 lines
40 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,
BillingSummary,
BillingTrend,
Ledger,
LoginSession,
Invitation,
ModelConfig,
ModelEntity,
Notification,
NotificationList,
Paginated,
Product,
Project,
RechargeResult,
ScriptVersion,
Team,
TeamMember,
User,
UserPreference,
VoiceoverInfo
} from "./types";
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 type RememberInfo = { username: string; expireAt: number };
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;
constructor(status: number, message: string) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
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;
if (remember) localStorage.setItem(TOKEN_KEY, token);
else sessionStorage.setItem(TOKEN_KEY, token);
}
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) {
const text = await response.text();
// DRF 错误体是 JSON({"detail": "..."} 或 {field: ["..."]}),提取人话给 toast,别把原始 JSON 怼到用户脸上
let message = text || `${response.status} ${response.statusText}`;
try {
const data = JSON.parse(text) as Record<string, unknown>;
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();
}
throw new ApiError(response.status, message);
}
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 }>("/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" });
},
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) {
return request<{ revoked: number }>(`/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/");
},
createTeamMember(payload: {
username: string;
password: string;
name?: string;
email?: string;
role?: string;
monthly_credit_limit?: number | string;
}) {
return request<TeamMember>("/api/auth/team/members/", { method: "POST", body: JSON.stringify(payload) });
},
updateTeamMember(id: string, payload: { role?: string; monthly_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() {
return request<Paginated<Product>>("/api/products/");
},
product(id: string) {
return request<Product>(`/api/products/${id}/`);
},
createProduct(payload: {
title?: string;
brand?: 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) {
// 彻底删除(不可恢复)
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; 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) });
},
// 整体替换 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" });
},
// 对话式脚本 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[];
base_version_id?: string;
aspect_ratio?: string;
total_duration?: number;
target_index?: number;
},
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 {
/* 跳过解析失败的帧 */
}
}
}
},
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 }>(`/api/projects/${projectId}/extract-entities/`, { method: "POST" });
},
// 实体提取进度:running=有在途任务(刷新后据此重建 loading);否则读最近一次成败,成功回带 entities 供「提取+生成」继续出图。
extractStatus(projectId: string) {
return request<{
running: boolean;
status: string;
error: 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 }) {
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" });
},
generateStoryboard(projectId: string, payload: { prompt: string }) {
return request(`/api/projects/${projectId}/generate-storyboard/`, { method: "POST", body: JSON.stringify(payload) });
},
pollStoryboard(projectId: string) {
return request<{ status: "generating" | "succeeded" | "failed"; done: number; total: number; version_id: string; error?: string }>(
`/api/projects/${projectId}/poll-storyboard/`,
{ method: "POST" }
);
},
skipStoryboard(projectId: string) {
return request<Project>(`/api/projects/${projectId}/skip-storyboard/`, { method: "POST" });
},
// 单场重跑:只重出该 shot 的分镜图(新增一条历史版本并采用)
rerunStoryboardShot(projectId: string, shotId: string, prompt?: string) {
return request(`/api/projects/${projectId}/rerun-storyboard-shot/`, { method: "POST", body: JSON.stringify({ shot_id: shotId, prompt: prompt || "" }) });
},
// 采用某场的某个历史版本(切换该场分镜图)
adoptStoryboardShotVersion(projectId: string, shotId: string, versionId: string) {
return request<Project>(`/api/projects/${projectId}/adopt-storyboard-shot-version/`, { method: "POST", body: JSON.stringify({ shot_id: shotId, version_id: versionId }) });
},
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" | "storyboard"; 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; 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<Asset>>(`/api/assets/?${qs.toString()}`);
},
// 图片趴(模特上身图/平台套图/自由创作)按生成批次成组分页:每批一张卡(封面+张数),点开看整批图
assetBatches(params: {
tab?: string; source?: 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/");
},
// 手动兜底:把单个送审类素材(角色/三视图/分镜)送火山审核(用户点灰盾「待审核」时)
submitAssetReview(id: string) {
return request<{ review_status: string; review_error: string }>(`/api/assets/${id}/submit-review/`, { method: "POST" });
},
// 单图「加入资产库 / 取消加入」:置 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 键取值),供下拉「只列真有的」
assetFacets(tab?: string, metaKeys: string[] = []) {
const qs = new URLSearchParams();
if (tab) qs.set("tab", tab);
if (metaKeys.length) qs.set("meta_keys", metaKeys.join(","));
return request<{ sources: string[]; kinds: string[]; metadata: Record<string, string[]> }>(`/api/assets/facets/?${qs.toString()}`);
},
// 导出全部:把项目所有已采用视频片段打成一个 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; pageSize?: number } = {}) {
const qs = new URLSearchParams();
if (params.tab) qs.set("tab", params.tab);
if (params.q) qs.set("q", params.q);
qs.set("page_size", String(params.pageSize ?? 200));
return request<Paginated<ModelEntity>>(`/api/models/?${qs.toString()}`);
},
// 真人上传:一张人像图 → 建 model_portrait 资产 + Model 实体
uploadModel(formData: FormData) {
return request<ModelEntity>("/api/models/upload/", { method: "POST", body: formData });
},
// 删除模特(软删;官方模特不可删)
deleteModel(id: string) {
return request<void>(`/api/models/${id}/`, { method: "DELETE" });
},
billingSummary() {
return request<BillingSummary>("/api/billing/summary/");
},
ledgers(page = 1, pageSize = 10) {
return request<{ count: number; page: number; page_size: number; results: Ledger[] }>(`/api/billing/ledgers/?page=${page}&page_size=${pageSize}`);
},
billingTrend(range?: "day" | "week" | "month") {
return request<BillingTrend>(`/api/billing/trend/${range ? `?range=${range}` : ""}`);
},
modelConfigs() {
return request<Paginated<ModelConfig>>("/api/ai/models/");
},
aiTasks() {
// 取一大页(后端上限 200):任务中心要按真实总量算 全部/已完成/失败 三个 tab 数,
// 只取默认首页(20 条)会把统计算偏,甚至误显示「全部失败」。
return request<Paginated<AITask>>("/api/ai/tasks/?page_size=200");
},
// 异步生图:提交后秒级返回任务列表(慢出图在 worker 跑),再用 generateImageStatus 轮询取结果
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 }) {
return request<{ tasks: { id: string; status: string }[] }>("/api/ai/generate-image/", { method: "POST", body: JSON.stringify(payload) });
},
generateImageStatus(ids: string[]) {
return request<{ tasks: { id: string; status: string; error_message: string; assets: Asset[] }[] }>(`/api/ai/generate-image/?ids=${encodeURIComponent(ids.join(","))}`);
},
recharge(payload: { amount: number | string; bonus?: number | string; channel?: string }) {
return request<RechargeResult>("/api/billing/recharge/", { method: "POST", body: JSON.stringify(payload) });
},
// 收件箱按页拉取 —— 滚动加载逐页向后端要(tab/搜索 走服务端,计数随响应回来)
listNotifications(params?: { type?: string; unread?: boolean; search?: string; page?: number; pageSize?: number }) {
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));
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}/`);
},
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}` : ""}`);
},
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; 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; 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?.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" });
},
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}` : ""}`);
},
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_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 }) {
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/");
}
};