- ops/views.py: 通知 metadata 写入真实 timeline(项目/资产/计费/欢迎,真时间戳),create_once 给旧通知补齐 timeline - messages.tsx/css: 新增「处理记录」区块(读 metadata.timeline)+ 补回 .msg-timeline/.msg-step/.msg-log 样式;搜索框结构回设计稿 .msg-search;mono 字号对齐 10.5/10px;空态图标色 token 化 - 归档按钮接活:api.archiveNotification + App 装配 onArchive + 详情页脚归档(后端落 archived_at + 本地乐观移除/扣计数) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
492 lines
23 KiB
TypeScript
492 lines
23 KiB
TypeScript
import type {
|
|
AITask,
|
|
Asset,
|
|
AuthPayload,
|
|
BillingSummary,
|
|
BillingTrend,
|
|
Ledger,
|
|
LoginSession,
|
|
ModelConfig,
|
|
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(如网关 HTML)保持原文 */
|
|
}
|
|
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 }) {
|
|
return request<AuthPayload>("/api/auth/register/", { method: "POST", body: JSON.stringify(payload) });
|
|
},
|
|
login(payload: { username: string; password: string }) {
|
|
return request<AuthPayload>("/api/auth/login/", { method: "POST", body: JSON.stringify(payload) });
|
|
},
|
|
me() {
|
|
return request<{ user: User; team: Team }>("/api/auth/me/");
|
|
},
|
|
updateProfile(payload: { name?: string; phone?: string; email?: string }) {
|
|
return request<{ user: User; team: Team }>("/api/auth/me/", { method: "PATCH", body: JSON.stringify(payload) });
|
|
},
|
|
changePassword(payload: { old_password: string; new_password: string }) {
|
|
return request<{ token: string }>("/api/auth/me/password/", { method: "POST", body: JSON.stringify(payload) });
|
|
},
|
|
uploadAvatar(formData: FormData) {
|
|
return request<User>("/api/auth/me/avatar/", { method: "POST", body: formData });
|
|
},
|
|
resetAvatar() {
|
|
return request<User>("/api/auth/me/avatar/", { method: "DELETE" });
|
|
},
|
|
deleteAsset(id: string) {
|
|
return request<void>(`/api/assets/${id}/`, { method: "DELETE" });
|
|
},
|
|
preferences() {
|
|
return request<UserPreference>("/api/auth/me/preferences/");
|
|
},
|
|
updatePreferences(payload: Partial<UserPreference>) {
|
|
return request<UserPreference>("/api/auth/me/preferences/", { method: "PUT", body: JSON.stringify(payload) });
|
|
},
|
|
loginSessions() {
|
|
return request<LoginSession[]>("/api/auth/me/sessions/");
|
|
},
|
|
revokeSession(id: string) {
|
|
return request<{ revoked: number }>(`/api/auth/me/sessions/${id}/revoke/`, { method: "POST" });
|
|
},
|
|
revokeOtherSessions() {
|
|
return request<{ token: string }>("/api/auth/me/sessions/revoke-others/", { method: "POST" });
|
|
},
|
|
logout() {
|
|
return request<void>("/api/auth/logout/", { method: "POST" });
|
|
},
|
|
teamMembers() {
|
|
return request<TeamMember[]>("/api/auth/team/members/");
|
|
},
|
|
createTeamMember(payload: {
|
|
username: string;
|
|
password: string;
|
|
name?: string;
|
|
email?: string;
|
|
role?: string;
|
|
monthly_credit_limit?: number | string;
|
|
}) {
|
|
return request<TeamMember>("/api/auth/team/members/", { method: "POST", body: JSON.stringify(payload) });
|
|
},
|
|
updateTeamMember(id: string, payload: { role?: string; monthly_credit_limit?: number | string; name?: string }) {
|
|
return request<TeamMember>(`/api/auth/team/members/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
|
|
},
|
|
removeTeamMember(id: string) {
|
|
return request<void>(`/api/auth/team/members/${id}/`, { method: "DELETE" });
|
|
},
|
|
resetMemberPassword(id: string, password: string) {
|
|
return request<void>(`/api/auth/team/members/${id}/password/`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ password })
|
|
});
|
|
},
|
|
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 }>;
|
|
}) {
|
|
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) {
|
|
return request<void>(`/api/products/${id}/`, { 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" });
|
|
},
|
|
generateScript(projectId: string, payload: { prompt: string; source?: string; selling_point_ids?: string[] }) {
|
|
return request<ScriptVersion>(`/api/projects/${projectId}/generate-script/`, {
|
|
method: "POST",
|
|
body: JSON.stringify(payload)
|
|
});
|
|
},
|
|
// 对话式脚本 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
|
|
): 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)
|
|
});
|
|
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 })
|
|
});
|
|
},
|
|
updateScriptSegment(projectId: string, payload: { segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) {
|
|
return request<ScriptVersion>(`/api/projects/${projectId}/update-script-segment/`, { method: "POST", body: JSON.stringify(payload) });
|
|
},
|
|
addScriptSegment(projectId: string, payload: { after_segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) {
|
|
return request<ScriptVersion>(`/api/projects/${projectId}/add-script-segment/`, { method: "POST", body: JSON.stringify(payload) });
|
|
},
|
|
deleteScriptSegment(projectId: string, payload: { segment_id: string }) {
|
|
return request<ScriptVersion>(`/api/projects/${projectId}/delete-script-segment/`, { method: "POST", body: JSON.stringify(payload) });
|
|
},
|
|
// 单条分镜重跑:后端只重写指定 segment(可带 instruction 微调),返回更新后的 ScriptVersion
|
|
rerunScriptSegment(projectId: string, payload: { segment_id: string; instruction?: string }) {
|
|
return request<ScriptVersion>(`/api/projects/${projectId}/rerun-script-segment/`, { method: "POST", body: JSON.stringify(payload) });
|
|
},
|
|
adoptVideoVersion(projectId: string, payload: { video_segment_id: string; version_id: string }) {
|
|
return request<Project>(`/api/projects/${projectId}/adopt-video-version/`, { method: "POST", body: JSON.stringify(payload) });
|
|
},
|
|
generateVoiceover(projectId: string, payload: { items: Array<{ index: number; text: string }>; voice_type?: string; speed_ratio?: number }) {
|
|
return request<{ voiceover: VoiceoverInfo }>(`/api/projects/${projectId}/generate-voiceover/`, { method: "POST", body: JSON.stringify(payload) });
|
|
},
|
|
// 异步:提交后秒回 RESERVED 任务,再用 generateImageStatus 轮询取结果(慢出图在 worker 跑,Web 不被占住)
|
|
generateBaseAsset(projectId: string, payload: { kind: "product" | "person" | "scene"; prompt: string; label?: string }) {
|
|
return request<{ task: { id: string; status: string } }>(`/api/projects/${projectId}/generate-base-asset/`, { method: "POST", body: JSON.stringify(payload) });
|
|
},
|
|
adoptBaseAsset(projectId: string, payload: { group_id: string; asset_id: string }) {
|
|
return request(`/api/projects/${projectId}/adopt-base-asset/`, { method: "POST", body: JSON.stringify(payload) });
|
|
},
|
|
// 流程步骤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" });
|
|
},
|
|
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) });
|
|
},
|
|
pollVideo(projectId: string, video_segment_id: string) {
|
|
return request(`/api/projects/${projectId}/poll-video-segment/`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ video_segment_id })
|
|
});
|
|
},
|
|
submitExport(projectId: string) {
|
|
return request(`/api/projects/${projectId}/submit-export/`, { method: "POST" });
|
|
},
|
|
pollExport(projectId: string) {
|
|
return request<import("./types").ExportPoll>(`/api/projects/${projectId}/poll-export/`, { method: "POST" });
|
|
},
|
|
uploadVideoSegment(projectId: string, segmentId: string, file: File) {
|
|
const form = new FormData();
|
|
form.append("video_segment_id", segmentId);
|
|
form.append("file", file);
|
|
return request<Project>(`/api/projects/${projectId}/upload-video-segment/`, { method: "POST", body: form });
|
|
},
|
|
uploadBgm(projectId: string, file: File, volume?: number) {
|
|
const form = new FormData();
|
|
form.append("file", file);
|
|
if (volume != null) form.append("volume", String(volume));
|
|
return request<Project>(`/api/projects/${projectId}/upload-bgm/`, { method: "POST", body: form });
|
|
},
|
|
saveTimeline(projectId: string, payload: import("./types").TimelineSavePayload) {
|
|
return request<Project>(`/api/projects/${projectId}/save-timeline/`, { method: "POST", body: JSON.stringify(payload) });
|
|
},
|
|
assets() {
|
|
return request<Paginated<Asset>>("/api/assets/");
|
|
},
|
|
// 服务端分页+过滤的资产列表(各页按需懒加载,不再前端取全量再切片)。
|
|
assetsPage(params: {
|
|
tab?: string; category?: string; source?: string; asset_type?: string;
|
|
product?: string; q?: string; ordering?: string; page?: number; pageSize?: number;
|
|
meta?: Record<string, string>;
|
|
} = {}) {
|
|
const qs = new URLSearchParams();
|
|
const { meta, pageSize, ...rest } = params;
|
|
for (const [k, v] of Object.entries(rest)) if (v !== undefined && v !== "" && v !== null) qs.set(k, String(v));
|
|
if (pageSize) qs.set("page_size", String(pageSize));
|
|
if (meta) for (const [k, v] of Object.entries(meta)) if (v) qs.set(`m_${k}`, v);
|
|
return request<Paginated<Asset>>(`/api/assets/?${qs.toString()}`);
|
|
},
|
|
// 资产库各 tab 计数(tab 徽标用,不必取全量)
|
|
assetSummary() {
|
|
return request<Record<string, number>>("/api/assets/summary/");
|
|
},
|
|
// 某 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()}`);
|
|
},
|
|
// 经后端同源代理取资产原始文件(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) });
|
|
},
|
|
billingSummary() {
|
|
return request<BillingSummary>("/api/billing/summary/");
|
|
},
|
|
ledgers(page = 1, pageSize = 10) {
|
|
return request<{ count: number; page: number; page_size: number; results: Ledger[] }>(`/api/billing/ledgers/?page=${page}&page_size=${pageSize}`);
|
|
},
|
|
billingTrend(range?: "day" | "week" | "month") {
|
|
return request<BillingTrend>(`/api/billing/trend/${range ? `?range=${range}` : ""}`);
|
|
},
|
|
modelConfigs() {
|
|
return request<Paginated<ModelConfig>>("/api/ai/models/");
|
|
},
|
|
aiTasks() {
|
|
return request<Paginated<AITask>>("/api/ai/tasks/");
|
|
},
|
|
// 异步生图:提交后秒级返回任务列表(慢出图在 worker 跑),再用 generateImageStatus 轮询取结果
|
|
submitGenerateImage(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean }) {
|
|
return request<{ tasks: { id: string; status: string }[] }>("/api/ai/generate-image/", { method: "POST", body: JSON.stringify(payload) });
|
|
},
|
|
generateImageStatus(ids: string[]) {
|
|
return request<{ tasks: { id: string; status: string; error_message: string; assets: Asset[] }[] }>(`/api/ai/generate-image/?ids=${encodeURIComponent(ids.join(","))}`);
|
|
},
|
|
recharge(payload: { amount: number | string; bonus?: number | string; channel?: string }) {
|
|
return request<RechargeResult>("/api/billing/recharge/", { method: "POST", body: JSON.stringify(payload) });
|
|
},
|
|
// 收件箱按页拉取 —— 滚动加载逐页向后端要(tab/搜索 走服务端,计数随响应回来)
|
|
listNotifications(params?: { type?: string; unread?: boolean; search?: string; page?: number; pageSize?: number }) {
|
|
const query = new URLSearchParams();
|
|
if (params?.type && params.type !== "all") query.set("type", params.type);
|
|
if (params?.unread) query.set("unread", "1");
|
|
if (params?.search) query.set("search", params.search);
|
|
if (params?.page) query.set("page", String(params.page));
|
|
if (params?.pageSize) query.set("page_size", String(params.pageSize));
|
|
const qs = query.toString();
|
|
return request<NotificationList>(`/api/ops/notifications/${qs ? `?${qs}` : ""}`);
|
|
},
|
|
// 侧边栏徽标(unread_count)+ 团队动态(只展示最近 6 条)用。只取第 1 页 100 条即足够,
|
|
// count 用后端真实总数(给「共 N」显示),不再随消息增长逐页翻全部(原来 O(N) 拖慢每次刷新)。
|
|
// 注:不参与收件箱滚动渲染——收件箱走 listNotifications 自己的服务端分页。
|
|
async allNotifications(): Promise<NotificationList> {
|
|
return request<NotificationList>(`/api/ops/notifications/?page_size=100`);
|
|
},
|
|
// 侧边栏未读徽标:只要 unread_count,取 1 条即可(不为徽标拉 100 条)
|
|
notificationsBadge() {
|
|
return request<NotificationList>(`/api/ops/notifications/?page_size=1`);
|
|
},
|
|
markAllNotificationsRead() {
|
|
return request<{ updated: number; unread_count: number }>("/api/ops/notifications/mark-all-read/", {
|
|
method: "POST"
|
|
});
|
|
},
|
|
markNotificationRead(id: string) {
|
|
return request<Notification>(`/api/ops/notifications/${id}/mark-read/`, { method: "POST" });
|
|
},
|
|
archiveNotification(id: string) {
|
|
// 后端 archive 端点返回 204 无 body
|
|
return request<void>(`/api/ops/notifications/${id}/archive/`, { method: "POST" });
|
|
}
|
|
};
|