- R6 商品图删除不生效: 封面asset移交+绕开prefetch缓存, 前端封面缩图补删除入口 - R48 消息中心长文本与UI框错位: 胶囊单行省略+优先级标签禁压缩 - R70 账单流水筛选: 后端加ledger_type过滤, count随筛选变化, 切筛选重置页码 - R72 月限额改后不刷新: 团队页/消费页自取最新teamSettings, 两页同源 - R83/R86 平台套图提示词框加大+模型胶囊嵌入输入框(复用Pill组件) - R96 未读角标: 修KeyTextTransform注解过滤触发MySQL 3141致接口500 - R100 工作台记录丢失: 新增GET /api/ai/tasks/workbench/ 同源恢复, 废除localStorage方案 - R104 卖点添加按钮移出输入框, 新建抽屉+编辑态统一 - R106 商品库宽屏锁5列, 10个/页铺满两行 - R108 资产删除改软删+单张删除+回收站二次确认; trash页商品/资产双分区+restore/purge端点 - R109 生成图自动入资产库(person演员立绘除外), 迁移0008存量回填, 移除全部加入资产库入口 - R65 无需改动(22268d6已修, 测旧部署所致) 验证: tsc/build/check/makemigrations --check 通过, 后端测试210/210, perf-probe PASS Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
875 lines
44 KiB
TypeScript
875 lines
44 KiB
TypeScript
import type {
|
||
AdminIntegrity,
|
||
AdminLedger,
|
||
AdminModel,
|
||
AdminProjectRow,
|
||
AdminProvider,
|
||
AdminQualityWord,
|
||
AdminQuotaPolicy,
|
||
AdminReviewAsset,
|
||
AdminTask,
|
||
AdminTaskDetail,
|
||
AdminTeam,
|
||
AdminTeamDetail,
|
||
AdminUser,
|
||
AITask,
|
||
Asset,
|
||
AuthPayload,
|
||
BillingSummary,
|
||
BillingTrend,
|
||
Ledger,
|
||
LoginSession,
|
||
Invitation,
|
||
ImageConversation,
|
||
ImageConversationTask,
|
||
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; 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() {
|
||
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; 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" });
|
||
},
|
||
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; 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; 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(现仅演员库「保存人物」用:立绘暂存 → 入库;R109 后图片生成成图已自动入库)
|
||
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 });
|
||
},
|
||
// 图片创作「加入模特库」:把一张已生成的图复用为模特形象图建 Model 条目(不重传、不二次扣费;幂等)
|
||
enrollModelFromAsset(assetId: string, name?: string) {
|
||
return request<ModelEntity>("/api/models/enroll-asset/", { method: "POST", body: JSON.stringify({ asset_id: assetId, name }) });
|
||
},
|
||
// 删除模特(软删;官方模特不可删)
|
||
deleteModel(id: string) {
|
||
return request<void>(`/api/models/${id}/`, { method: "DELETE" });
|
||
},
|
||
billingSummary() {
|
||
return request<BillingSummary>("/api/billing/summary/");
|
||
},
|
||
ledgers(page = 1, pageSize = 10, filters?: { ledger_type?: string; user?: string }) {
|
||
// 类型/成员筛选传参给后端做全量过滤(count/总页数随筛选变化 · R70)
|
||
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);
|
||
return request<{ count: number; page: number; page_size: number; results: Ledger[] }>(`/api/billing/ledgers/?${q.toString()}`);
|
||
},
|
||
billingTrend(range?: "day" | "week" | "month") {
|
||
return request<BillingTrend>(`/api/billing/trend/${range ? `?range=${range}` : ""}`);
|
||
},
|
||
modelConfigs() {
|
||
return request<Paginated<ModelConfig>>("/api/ai/models/");
|
||
},
|
||
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()}`);
|
||
},
|
||
// 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。
|
||
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[] }) {
|
||
return request<{ conversation_id: string; tasks: { id: string; status: string }[] }>("/api/ai/generate-image/", { method: "POST", body: JSON.stringify(payload) });
|
||
},
|
||
// 图片创作对话 CRUD —— 左栏会话列表 / 新对话 / 切换 / 重命名 / 删除
|
||
listConversations(mode: "image" | "model" | "cover" = "image") {
|
||
return request<Paginated<ImageConversation>>(`/api/ai/image-conversations/?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" });
|
||
},
|
||
// 切换对话时回填:该对话历次生成的任务 + 成图
|
||
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_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/");
|
||
}
|
||
};
|