消息中心:全量渲染 → 真·后端分页滚动加载 - backend(ops/views): NotificationPagination(10/页,page_size 可覆盖)+ 响应回 type_counts(按收件人绝对计数,不受分页/搜索影响) - frontend(messages): 自管分页,滚到底加载下一批;tab/搜索走服务端并重置到第1页; 代号作废在途旧请求防切换卡空白;乐观标已读;「已加载 X / Y」分母用当前筛选总数 - api/App/types: listNotifications 支持 page/page_size/search;allNotifications 携带 type_counts 命令面板(侧边栏搜索):修复点开后 UI 错位 - app-shell: 遮罩 className 漏了基类 shell-command-bg(只有 .show)致无定位塌到左下; 补回基类 + header 类名对齐 .shell-command-h - messages-page.css: 工作台收进视口高度,收件箱在面板内滚动 本次提交一并带入此前若干未提交 WIP(account/ai-tools/library/pipeline/products/settings + accounts/ai/assets/billing/projects 后端),按用户要求整体推 dev。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
305 lines
12 KiB
TypeScript
305 lines
12 KiB
TypeScript
import type {
|
|
AITask,
|
|
Asset,
|
|
AuthPayload,
|
|
BillingSummary,
|
|
BillingTrend,
|
|
Ledger,
|
|
LoginSession,
|
|
ModelConfig,
|
|
Notification,
|
|
NotificationList,
|
|
Paginated,
|
|
Product,
|
|
Project,
|
|
RechargeResult,
|
|
ScriptVersion,
|
|
Team,
|
|
TeamMember,
|
|
User,
|
|
UserPreference
|
|
} from "./types";
|
|
|
|
const API_BASE = import.meta.env.VITE_API_BASE_URL || "";
|
|
const TOKEN_KEY = "airshelf_token";
|
|
|
|
export class ApiError extends Error {
|
|
status: number;
|
|
|
|
constructor(status: number, message: string) {
|
|
super(message);
|
|
this.name = "ApiError";
|
|
this.status = status;
|
|
}
|
|
}
|
|
|
|
export function getToken() {
|
|
return localStorage.getItem(TOKEN_KEY);
|
|
}
|
|
|
|
export function setToken(token: string | null) {
|
|
if (token) localStorage.setItem(TOKEN_KEY, token);
|
|
else localStorage.removeItem(TOKEN_KEY);
|
|
}
|
|
|
|
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();
|
|
throw new ApiError(response.status, text || `${response.status} ${response.statusText}`);
|
|
}
|
|
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}/`);
|
|
},
|
|
createProject(payload: { name: string; product: string; metadata?: Record<string, unknown> }) {
|
|
return request<Project>("/api/projects/", { method: "POST", body: JSON.stringify(payload) });
|
|
},
|
|
deleteProject(id: string) {
|
|
return request<void>(`/api/projects/${id}/`, { method: "DELETE" });
|
|
},
|
|
generateScript(projectId: string, payload: { prompt: string; selling_point_ids?: string[] }) {
|
|
return request<ScriptVersion>(`/api/projects/${projectId}/generate-script/`, {
|
|
method: "POST",
|
|
body: JSON.stringify(payload)
|
|
});
|
|
},
|
|
adoptScript(projectId: string, script_version_id: string) {
|
|
return request<ScriptVersion>(`/api/projects/${projectId}/adopt-script/`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ script_version_id })
|
|
});
|
|
},
|
|
generateBaseAsset(projectId: string, payload: { kind: "product" | "person" | "scene"; prompt: string }) {
|
|
return request(`/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) });
|
|
},
|
|
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/");
|
|
},
|
|
// 跟随 DRF 分页 next 取全部资产 —— 商品图/AI 素材/资产库都靠 asset.id 在这份列表里查 preview_url,
|
|
// 只取第 1 页(20 条)会让第 20 条之后的资产解析不到图、渲染成空占位。
|
|
async allAssets(): Promise<Asset[]> {
|
|
const out: Asset[] = [];
|
|
let path = "/api/assets/";
|
|
for (let guard = 0; guard < 50 && path; guard += 1) {
|
|
const page: Paginated<Asset> = await request<Paginated<Asset>>(path);
|
|
out.push(...page.results);
|
|
path = page.next ? new URL(page.next).pathname + new URL(page.next).search : "";
|
|
}
|
|
return out;
|
|
},
|
|
uploadAsset(formData: FormData) {
|
|
return request<Asset>("/api/assets/upload/", { method: "POST", body: formData });
|
|
},
|
|
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/");
|
|
},
|
|
generateImage(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number }) {
|
|
return request<{ assets: Asset[] }>("/api/ai/generate-image/", { method: "POST", body: JSON.stringify(payload) });
|
|
},
|
|
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}` : ""}`);
|
|
},
|
|
// 跟着 next 把所有消息取全(给侧边栏徽标 + 团队动态用,不参与收件箱滚动渲染),与 allAssets 同套路
|
|
async allNotifications(): Promise<NotificationList> {
|
|
const out: Notification[] = [];
|
|
let path = "/api/ops/notifications/?page_size=100";
|
|
let unreadCount = 0;
|
|
let typeCounts: NotificationList["type_counts"];
|
|
for (let guard = 0; guard < 100 && path; guard += 1) {
|
|
const page = await request<NotificationList>(path);
|
|
out.push(...page.results);
|
|
unreadCount = page.unread_count;
|
|
typeCounts = page.type_counts ?? typeCounts;
|
|
path = page.next ? new URL(page.next).pathname + new URL(page.next).search : "";
|
|
}
|
|
return { count: out.length, next: null, previous: null, results: out, unread_count: unreadCount, type_counts: typeCounts };
|
|
},
|
|
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" });
|
|
}
|
|
};
|