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; 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; constructor(status: number, message: string, payload?: Record) { 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; if (remember) localStorage.setItem(TOKEN_KEY, token); else sessionStorage.setItem(TOKEN_KEY, token); } async function request(path: string, options: RequestInit = {}): Promise { 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}`; let payload: Record | undefined; try { const data = JSON.parse(text) as Record; 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; } export const api = { register(payload: { username: string; password: string; email?: string; team_name?: string; invite_code: string }) { return request("/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("/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("/api/auth/me/avatar/", { method: "POST", body: formData }); }, resetAvatar() { return request("/api/auth/me/avatar/", { method: "DELETE" }); }, deleteAsset(id: string) { return request(`/api/assets/${id}/`, { method: "DELETE" }); }, // R108 资产垃圾桶(与商品垃圾桶同构):已软删资产列表 / 恢复回资产库 / 彻底删除(不可恢复) assetsTrash() { return request>("/api/assets/trash/"); }, restoreAsset(id: string) { return request(`/api/assets/${id}/restore/`, { method: "POST" }); }, purgeAsset(id: string) { return request(`/api/assets/${id}/purge/`, { method: "DELETE" }); }, preferences() { return request("/api/auth/me/preferences/"); }, updatePreferences(payload: Partial) { return request("/api/auth/me/preferences/", { method: "PUT", body: JSON.stringify(payload) }); }, loginSessions() { return request("/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("/api/auth/logout/", { method: "POST" }); }, teamMembers() { return request("/api/auth/team/members/"); }, // 团队级设置读取(任意成员可读):团队页/消费页取最新月限额,避免依赖登录时的陈旧 /me/ 快照(R72) teamSettings() { return request("/api/auth/team/settings/"); }, // 团队级月限额持久化(超管在团队页设置 · PMC#12);monthly_credit_limit: 数值 / null(清空) updateTeamSettings(payload: { monthly_credit_limit?: number | string | null }) { return request("/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("/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(`/api/auth/team/members/${id}/`, { method: "PATCH", body: JSON.stringify(payload) }); }, removeTeamMember(id: string) { return request(`/api/auth/team/members/${id}/`, { method: "DELETE" }); }, resetMemberPassword(id: string, password: string) { return request(`/api/auth/team/members/${id}/password/`, { method: "POST", body: JSON.stringify({ password }) }); }, listInvitations() { return request("/api/auth/team/invitations/"); }, createInvitation(payload: { role?: string; email?: string; monthly_credit_limit?: number | string }) { return request("/api/auth/team/invitations/", { method: "POST", body: JSON.stringify(payload) }); }, revokeInvitation(id: string) { return request(`/api/auth/team/invitations/${id}/revoke/`, { method: "POST" }); }, products(pageSize?: number) { const query = pageSize ? `?page_size=${pageSize}` : ""; return request>(`/api/products/${query}`); }, product(id: string) { return request(`/api/products/${id}/`); }, createProduct(payload: { title?: string; brand?: string; business_type?: string; category?: string; target_audience?: string; description?: string; specs?: Record; 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("/api/products/", { method: "POST", body: JSON.stringify(payload) }); }, updateProduct(id: string, payload: Partial) { return request(`/api/products/${id}/`, { method: "PATCH", body: JSON.stringify(payload) }); }, uploadProductImage(productId: string, formData: FormData) { return request(`/api/products/${productId}/images/`, { method: "POST", body: formData }); }, deleteProductImage(productId: string, imageId: string) { return request(`/api/products/${productId}/images/${imageId}/`, { method: "DELETE" }); }, deleteProduct(id: string) { // 软删 = 进垃圾桶(后端 status→archived,可恢复) return request(`/api/products/${id}/`, { method: "DELETE" }); }, // 商品详情结构化素材(角色/商品图/图片成品/项目包) productMaterials(id: string) { return request(`/api/products/${id}/materials/`); }, productsTrash() { return request>("/api/products/trash/"); }, restoreProduct(id: string) { return request(`/api/products/${id}/restore/`, { method: "POST" }); }, purgeProduct(id: string) { // 彻底删除 = 二级软删除(从垃圾桶隐藏,DB 记录保留) return request(`/api/products/${id}/purge/`, { method: "DELETE" }); }, projects() { return request>("/api/projects/"); }, project(id: string) { return request(`/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 }) { return request("/api/projects/", { method: "POST", body: JSON.stringify(payload) }); }, startQuickCreate(formData: FormData) { return request("/api/projects/quick-create/", { method: "POST", body: formData }); }, quickCreateStatus(jobId: string) { return request(`/api/projects/quick-create-status/${jobId}/`); }, cancelQuickCreate(jobId: string) { return request(`/api/projects/quick-create-cancel/${jobId}/`, { method: "POST" }); }, retryQuickCreate(jobId: string) { return request(`/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 }) { return request(`/api/projects/${id}/`, { method: "PATCH", body: JSON.stringify(payload) }); }, deleteProject(id: string) { return request(`/api/projects/${id}/`, { method: "DELETE" }); }, projectsTrash() { return request>("/api/projects/trash/"); }, restoreProject(id: string) { return request(`/api/projects/${id}/restore/`, { method: "POST" }); }, purgeProject(id: string) { return request(`/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( "/api/ai/video-digest/", { method: "POST", body: formData, signal } ); }, extractVideoDigestFromTask(taskId: string, modelConfigId?: string, signal?: AbortSignal) { return request( "/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(`/api/ai/video-digest/${id}/`); }, cancelVideoDigest(id: string) { return request(`/api/ai/video-digest/${id}/`, { method: "DELETE" }); }, saveVideoDigest(id: string, prompt: string) { return request(`/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(`/api/projects/${projectId}/save-as-template/`, { method: "POST", body: JSON.stringify(payload), }); }, listScriptTemplates() { return request>("/api/script-templates/"); }, renameScriptTemplate(id: string, name: string) { return request(`/api/script-templates/${id}/`, { method: "PATCH", body: JSON.stringify({ name }) }); }, deleteScriptTemplate(id: string) { return request(`/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 { 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 }>( `/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>(`/api/ai/creations/${suffix ? `?${suffix}` : ""}`); }, createCreation(payload: { title?: string; mode: "video" | "image"; preset?: string; params?: Record; }) { return request("/api/ai/creations/", { method: "POST", body: JSON.stringify(payload) }); }, getCreation(id: string) { return request(`/api/ai/creations/${id}/`); }, renameCreation(id: string, title: string) { return request(`/api/ai/creations/${id}/`, { method: "PATCH", body: JSON.stringify({ title }) }); }, updateCreation(id: string, payload: { title?: string; params?: Record }) { return request(`/api/ai/creations/${id}/`, { method: "PATCH", body: JSON.stringify(payload) }); }, deleteCreation(id: string) { return request(`/api/ai/creations/${id}/`, { method: "DELETE" }); }, /** 增量拉消息:轮询生成结果时只补 after_seq 之后的,不重拉整条会话。 */ creationMessages(id: string, afterSeq?: number) { const suffix = afterSeq === undefined ? "" : `?after_seq=${afterSeq}`; return request(`/api/ai/creations/${id}/messages/${suffix}`); }, /** * 点确认闸门 → 直接出片。**这个不是 SSE**:后端不跑模型,按方案卡存好的 * video_prompt 直接提交,同步返回「生成中」消息,之后靠轮询转成结果。 */ confirmCreationPlan(id: string, replyTo: string, params?: Record) { return request<{ message: CreationMessage }>(`/api/ai/creations/${id}/send/`, { method: "POST", body: JSON.stringify({ kind: "confirm", reply_to: replyTo, params }) }); }, /** * 发一条消息 → SSE 流。事件:tool / reasoning / delta / message / task / credits / done / error。 * 和 agentScriptStream 同一套 fetch + ReadableStream(EventSource 只支持 GET,这里要 POST 带 body)。 */ async creationSendStream( id: string, payload: { kind?: "text" | "elicit_answer"; text?: string; refs?: CreationRef[]; reply_to?: string; answers?: Record; model_config_id?: string; params?: Record; }, onEvent: (evt: { type: string; [k: string]: unknown }) => void, signal?: AbortSignal ): Promise { 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/ai/creations/${id}/send/`, { method: "POST", headers, body: JSON.stringify(payload), signal }); if (!response.ok || !response.body) { const text = await response.text().catch(() => ""); let message = text || "发送失败"; try { const data = JSON.parse(text) as Record; if (typeof data.detail === "string") message = data.detail; } catch { /* 非 JSON 错误体,用原文 */ } throw new ApiError(response.status, message); } 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; 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(`/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?: 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(`/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(`/api/projects/${projectId}/add-script-segment/`, { method: "POST", body: JSON.stringify(payload) }); }, deleteScriptSegment(projectId: string, payload: { segment_id: string }) { return request(`/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(`/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(`/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 }>(`/api/projects/${projectId}/poll-reviews/`, { method: "POST" }); }, submitVideo(projectId: string, payload: { video_segment_id: string; prompt: string }) { return request(`/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(`/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(`/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(`/api/projects/${projectId}/upload-bgm/`, { method: "POST", body: form }); }, saveTimeline(projectId: string, payload: import("./types").TimelineSavePayload) { return request(`/api/projects/${projectId}/save-timeline/`, { method: "POST", body: JSON.stringify(payload) }); }, assets() { return request>("/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; } = {}) { 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>(`/api/assets/?${qs.toString()}`); }, // 图片趴(模特上身图/平台套图/自由创作)按生成批次成组分页:每批一张卡(封面+张数),点开看整批图 assetBatches(params: { tab?: string; source?: string; product?: string; q?: string; ordering?: string; page?: number; pageSize?: number; meta?: Record; } = {}) { 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>(`/api/assets/batches/?${qs.toString()}`); }, // 资产库各 tab 计数(tab 徽标用,不必取全量) assetSummary() { return request>("/api/assets/summary/"); }, // 视频成品:按项目素材包分组(每项目一包,含片段列表) videoPacks() { return request("/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; 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 { 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 { 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 { // 一次大页(page_size=200)尽量取全;若还有多页,并行取剩余页(不再逐页串行等,根治整页刷新慢) const SIZE = 200; const first = await request>(`/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>(`/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("/api/assets/upload/", { method: "POST", body: formData }); }, // 流程步骤4 · 给模特形象/资产改名(添加模特工作台命名 → PATCH 写回 name) updateAsset(id: string, payload: { name?: string; description?: string }) { return request(`/api/assets/${id}/`, { method: "PATCH", body: JSON.stringify(payload) }); }, // 模特库:列出(本团队 ∪ 官方模板);tab=official 只看官方、mine 只看自建 listModels(params: { tab?: "official" | "mine"; q?: string; portraitAsset?: string; 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); qs.set("page_size", String(params.pageSize ?? 200)); return request>(`/api/models/?${qs.toString()}`); }, // 真人上传:一张人像图 → 建 model_portrait 资产 + Model 实体 uploadModel(formData: FormData) { return request("/api/models/upload/", { method: "POST", body: formData }); }, // /models 详情:上传并替换当前模特形象图,不创建新模特。 uploadModelPortrait(id: string, formData: FormData) { return request(`/api/models/${id}/upload-portrait/`, { method: "POST", body: formData }); }, // 图片创作「加入模特库」:把一张已生成的图复用为模特形象图建 Model 条目(不重传、不二次扣费;幂等) enrollModelFromAsset(assetId: string, name?: string) { return request("/api/models/enroll-asset/", { method: "POST", body: JSON.stringify({ asset_id: assetId, name }) }); }, // /models 模特详情编辑:只更新模特实体资料,不回写项目角色或底层图片资产。 updateLibraryModel(id: string, payload: { name?: string; description?: string }) { return request(`/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(`/api/models/${id}/`, { method: "DELETE" }); }, modelsTrash() { return request>("/api/models/trash/"); }, restoreModel(id: string) { return request(`/api/models/${id}/restore/`, { method: "POST" }); }, purgeModel(id: string) { return request(`/api/models/${id}/purge/`, { method: "DELETE" }); }, billingSummary() { return request("/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(`/api/billing/trend/${range ? `?range=${range}` : ""}`); }, modelConfigs() { return request>("/api/ai/models/"); }, aiTasks() { // 生图工作室的任务中心:只看生图任务(模特上身图/平台套图/图片创作 = person_image / // product_image),不掺脚本/实体抽取等流水线内部任务。取一大页(后端上限 200) // 以便 全部/已完成/失败 三个 tab 数按真实总量算。 return request>("/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(`/api/ai/tasks/${taskId}/purge-workbench-batch/`, { method: "DELETE" }); }, // YYX#row22:未读生成任务汇总(导航胶囊总数 + 按商品分组角标) aiTasksUnread() { return request<{ total: number; by_product: Record }>("/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>(`/api/ai/image-conversations/?${params.toString()}`); }, conversationsTrash(mode: "image" | "model" | "cover" = "image") { return request>(`/api/ai/image-conversations/trash/?mode=${mode}&page_size=100`); }, createConversation(payload: { mode?: "image" | "model" | "cover"; title?: string; product?: string | null }) { return request("/api/ai/image-conversations/", { method: "POST", body: JSON.stringify(payload) }); }, renameConversation(id: string, title: string) { return request(`/api/ai/image-conversations/${id}/`, { method: "PATCH", body: JSON.stringify({ title }) }); }, deleteConversation(id: string) { return request(`/api/ai/image-conversations/${id}/`, { method: "DELETE" }); }, restoreConversation(id: string) { return request(`/api/ai/image-conversations/${id}/restore/`, { method: "POST" }); }, purgeConversation(id: string) { return request(`/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(`/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(`/api/ai/free-video/${id}/purge/`, { method: "DELETE" }); }, uploadFreeVideoRef(formData: FormData) { return request("/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(`/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(`/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("/api/billing/recharge/", { method: "POST", body: JSON.stringify(payload) }); }, // 计费公共配置(积分汇率/视频毛利/预留 buffer):所有前端预估统一读这里,与后端计价引擎同源 billingConfig() { return request("/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(`/api/ops/notifications/${qs ? `?${qs}` : ""}`); }, // 侧边栏徽标(unread_count)+ 团队动态(只展示最近 6 条)用。只取第 1 页 100 条即足够, // count 用后端真实总数(给「共 N」显示),不再随消息增长逐页翻全部(原来 O(N) 拖慢每次刷新)。 // 注:不参与收件箱滚动渲染——收件箱走 listNotifications 自己的服务端分页。 async allNotifications(): Promise { return request(`/api/ops/notifications/?page_size=100`); }, // 侧边栏未读徽标:只要 unread_count,取 1 条即可(不为徽标拉 100 条) notificationsBadge() { return request(`/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(`/api/ops/notifications/${id}/mark-read/`, { method: "POST" }); }, archiveNotification(id: string) { // 后端 archive 端点返回 204 无 body return request(`/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>(`/api/admin/invitations/${q ? `?${q}` : ""}`); }, createInvite(payload?: { email?: string }) { return request("/api/admin/invitations/", { method: "POST", body: JSON.stringify(payload || {}) }); }, revokeInvite(id: string) { return request(`/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>(`/api/admin/teams/${q ? `?${q}` : ""}`); }, teamDetail(id: string) { return request(`/api/admin/teams/${id}/`); }, setTeamPricing(id: string, priceMultiplier: string) { return request(`/api/admin/teams/${id}/pricing/`, { method: "POST", body: JSON.stringify({ price_multiplier: priceMultiplier }) }); }, toggleTeam(id: string) { return request(`/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>(`/api/admin/users/${q ? `?${q}` : ""}`); }, toggleUser(id: string) { return request(`/api/admin/users/${id}/toggle/`, { method: "POST" }); }, resetUserPassword(id: string, password: string) { return request(`/api/admin/users/${id}/reset-password/`, { method: "POST", body: JSON.stringify({ password }) }); }, qualityWords() { return request("/api/admin/quality-words/"); }, createQualityWord(payload: { stage: string; text: string; sort?: number; slot?: string }) { return request("/api/admin/quality-words/", { method: "POST", body: JSON.stringify(payload) }); }, updateQualityWord(id: string, payload: { text?: string; sort?: number; enabled?: boolean }) { return request(`/api/admin/quality-words/${id}/`, { method: "PATCH", body: JSON.stringify(payload) }); }, deleteQualityWord(id: string) { return request(`/api/admin/quality-words/${id}/`, { method: "DELETE" }); }, // 生图/视频提示词模板(视频线 6 条):列表 + 改正文/比例/启用 promptTemplates() { return request("/api/admin/prompt-templates/"); }, updatePromptTemplate(id: string, payload: { template?: string; ratio?: string; enabled?: boolean }) { return request(`/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>(`/api/admin/asset-reviews/${q ? `?${q}` : ""}`); }, submitReviews(assetIds: string[]) { return request<{ submitted: number; statuses: Record }>( "/api/admin/asset-reviews/submit/", { method: "POST", body: JSON.stringify({ asset_ids: assetIds }) } ); }, pollReviews(assetIds?: string[]) { return request<{ polled: number; submitted?: number; statuses: Record }>( "/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>(`/api/admin/tasks/${q ? `?${q}` : ""}`); }, taskDetail(id: string) { return request(`/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 }>( "/api/admin/tasks/poll/", { method: "POST", body: JSON.stringify(taskIds ? { task_ids: taskIds } : {}) } ); }, reapTask(id: string) { return request(`/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>(`/api/admin/ledgers/${q ? `?${q}` : ""}`); }, billingConfig() { return request("/api/admin/billing-config/"); }, updateBillingConfig(payload: { points_per_yuan?: string; video_margin_multiplier?: string; video_reserve_buffer?: string }) { return request("/api/admin/billing-config/", { method: "PATCH", body: JSON.stringify(payload) }); }, adjustCredit(payload: { team: string; amount: string; reason: string }) { return request("/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>(`/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("/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(`/api/admin/quota-policies/${id}/`, { method: "PATCH", body: JSON.stringify(payload) }); }, deleteQuotaPolicy(id: string) { return request(`/api/admin/quota-policies/${id}/`, { method: "DELETE" }); }, providers() { return request("/api/admin/providers/"); }, createProvider(payload: { name: string; display_name: string; base_url?: string; api_key?: string; status?: string }) { return request("/api/admin/providers/", { method: "POST", body: JSON.stringify(payload) }); }, updateProvider(id: string, payload: Record) { return request(`/api/admin/providers/${id}/`, { method: "PATCH", body: JSON.stringify(payload) }); }, deleteProvider(id: string) { return request(`/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(`/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("/api/admin/models/", { method: "POST", body: JSON.stringify(payload) }); }, updateModel(id: string, payload: Record) { return request(`/api/admin/models/${id}/`, { method: "PATCH", body: JSON.stringify(payload) }); }, deleteModel(id: string) { return request(`/api/admin/models/${id}/`, { method: "DELETE" }); }, setDefaultModel(id: string) { return request(`/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>(`/api/admin/projects/${q ? `?${q}` : ""}`); }, integrity() { return request("/api/admin/integrity/"); } };