seaislee1209 aa538443b6
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m10s
feat: v0.12.3 种子值支持 + UI 修复
①Seed 种子值全链路(后端传入/保存火山返回的seed/API返回,详情弹窗显示)
②前端种子值控件暂禁用(样式待调整)
③空页面文案改为品牌彩蛋 Every frame was once just air.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 23:22:22 +08:00

426 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import axios, { AxiosError } from 'axios';
import type {
User, Quota, AuthTokens, AdminStats, AdminUser, AdminUserDetail,
AdminRecord, SystemSettings, ProfileOverview, PaginatedResponse,
BackendTask, TeamInfo, Team, TeamDetail, TeamMember, TeamStats,
AuditLog, AssetTeamSummary, AssetMemberSummary, AssetVideo,
LoginAnomaly, TeamAnomalyConfig, AssetGroup, AssetItem,
} from '../types';
import { reportError } from './logCenter';
const api = axios.create({
baseURL: '/api/v1',
headers: { 'Content-Type': 'application/json' },
});
// Request interceptor: attach access token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Response interceptor: auto-refresh on 401
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
const requestUrl = originalRequest?.url || '';
const authEndpoints = ['/auth/login', '/auth/register', '/auth/token/refresh'];
const isAuthEndpoint = authEndpoints.some(ep => requestUrl.includes(ep));
// Check special ban/kick codes on 401 or 403
const errorCode = error.response?.data?.code || error.response?.data?.detail?.code;
if ((error.response?.status === 401 || error.response?.status === 403) && !isAuthEndpoint) {
if (errorCode === 'session_expired_other_device') {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
alert('您的账号已在其他设备登录,请重新登录');
window.location.href = '/login';
return Promise.reject(error);
}
if (errorCode === 'user_disabled') {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
alert('您的账号已被禁用,请联系团队管理员');
window.location.href = '/login';
return Promise.reject(error);
}
if (errorCode === 'team_disabled') {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
alert('您所在的团队已被禁用,请联系平台管理员');
window.location.href = '/login';
return Promise.reject(error);
}
}
// Auto-refresh on 401 (only for non-ban cases)
if (error.response?.status === 401 && !originalRequest._retry && !isAuthEndpoint) {
originalRequest._retry = true;
const refreshToken = localStorage.getItem('refresh_token');
if (refreshToken) {
try {
const { data } = await axios.post('/api/v1/auth/token/refresh', {
refresh: refreshToken,
});
localStorage.setItem('access_token', data.access);
if (data.refresh) {
localStorage.setItem('refresh_token', data.refresh);
}
originalRequest.headers.Authorization = `Bearer ${data.access}`;
return api(originalRequest);
} catch {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
window.location.href = '/login';
}
} else {
window.location.href = '/login';
}
}
// Report 5xx server errors to Log Center
if (error.response && error.response.status >= 500) {
reportError(error instanceof Error ? error : new Error(String(error)), {
api_url: (error as AxiosError).config?.url,
method: (error as AxiosError).config?.method,
status: error.response.status,
});
}
return Promise.reject(error);
}
);
// Auth APIs
export const authApi = {
login: (username: string, password: string) =>
api.post<{ user: User; tokens: AuthTokens }>('/auth/login', { username, password }),
refreshToken: (refresh: string) =>
api.post<{ access: string; refresh?: string }>('/auth/token/refresh', { refresh }),
getMe: () =>
api.get<User & { quota: Quota; team: TeamInfo | null; team_disabled: boolean }>('/auth/me'),
changePassword: (oldPassword: string, newPassword: string) =>
api.post('/auth/change-password', { old_password: oldPassword, new_password: newPassword }),
logout: () => api.post('/auth/logout'),
};
// Media upload API
export const mediaApi = {
upload: (file: File) => {
const formData = new FormData();
formData.append('file', file);
return api.post<{
url: string;
type: 'image' | 'video';
filename: string;
size: number;
}>('/media/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
},
};
// Video generation API
export const videoApi = {
generate: (data: {
prompt: string;
mode: string;
model: string;
aspect_ratio: string;
duration: number;
references: { url: string; type: string; role: string; label: string; thumb_url?: string }[];
search_mode?: string;
seed?: number;
}) =>
api.post<{
task_id: string;
ark_task_id: string;
status: string;
estimated_time: number;
seconds_consumed: number;
error_message: string;
}>('/video/generate', data),
getTasks: (params?: { page_size?: number; offset?: number }) =>
api.get<{ results: BackendTask[]; total: number; has_more: boolean }>('/video/tasks', { params }),
getTaskStatus: (taskId: string) =>
api.get<BackendTask>(`/video/tasks/${taskId}`),
deleteTask: (taskId: string) =>
api.delete(`/video/tasks/${taskId}`),
toggleFavorite: (taskId: string) =>
api.post<{ is_favorited: boolean }>(`/video/tasks/${taskId}/favorite`),
getAnnouncement: () =>
api.get<{ announcement: string; enabled: boolean }>('/announcement'),
};
// Admin APIs (Super Admin)
export const adminApi = {
getStats: () =>
api.get<AdminStats>('/admin/stats'),
// Team management
getTeams: () =>
api.get<{ results: Team[] }>('/admin/teams'),
createTeam: (data: { name: string; monthly_spending_limit?: number; daily_member_limit_default?: number; expected_regions: string; markup_percentage?: number }) =>
api.post('/admin/teams/create', data),
getTeamDetail: (teamId: number) =>
api.get<TeamDetail>(`/admin/teams/${teamId}`),
updateTeam: (teamId: number, data: { name?: string; monthly_seconds_limit?: number; monthly_spending_limit?: number; daily_member_limit_default?: number; markup_percentage?: number; max_concurrent_tasks?: number; is_active?: boolean; expected_regions?: string; anomaly_config?: Partial<TeamAnomalyConfig> }) =>
api.put(`/admin/teams/${teamId}`, data),
topUpTeam: (teamId: number, amount: number) =>
api.post(`/admin/teams/${teamId}/topup`, { amount }),
setTeamPool: (teamId: number, balance: number) =>
api.put(`/admin/teams/${teamId}/set-pool`, { balance }),
createTeamAdmin: (teamId: number, data: { username: string; email: string; password: string }) =>
api.post(`/admin/teams/${teamId}/admin`, data),
// User management
createUser: (data: {
username: string;
email: string;
password: string;
daily_generation_limit?: number;
monthly_generation_limit?: number;
is_staff?: boolean;
}) =>
api.post('/admin/users/create', data),
getUsers: (params: {
page?: number;
page_size?: number;
search?: string;
status?: string;
team_id?: number;
} = {}) =>
api.get<PaginatedResponse<AdminUser>>('/admin/users', { params }),
getUserDetail: (userId: number) =>
api.get<AdminUserDetail>(`/admin/users/${userId}`),
updateUserQuota: (userId: number, daily: number, monthly: number, spendingLimit?: number) =>
api.put(`/admin/users/${userId}/quota`, {
daily_generation_limit: daily,
monthly_generation_limit: monthly,
...(spendingLimit !== undefined && { spending_limit: spendingLimit }),
}),
updateUserStatus: (userId: number, isActive: boolean) =>
api.patch(`/admin/users/${userId}/status`, { is_active: isActive }),
resetUserPassword: (userId: number, newPassword: string) =>
api.post(`/admin/users/${userId}/reset-password`, { new_password: newPassword }),
getRecords: (params: {
page?: number;
page_size?: number;
search?: string;
start_date?: string;
end_date?: string;
team_id?: number;
} = {}) =>
api.get<PaginatedResponse<AdminRecord>>('/admin/records', { params }),
getSettings: () =>
api.get<SystemSettings>('/admin/settings'),
updateSettings: (settings: SystemSettings) =>
api.put<SystemSettings & { updated_at: string }>('/admin/settings', settings),
// Content Assets
getAssetsOverview: () =>
api.get<{
total_videos: number;
total_seconds: number;
total_teams: number;
teams: AssetTeamSummary[];
no_team: { video_count: number; seconds_consumed: number };
}>('/admin/assets/overview'),
getAssetsTeamMembers: (teamId: number) =>
api.get<{
team_id: number;
team_name: string;
total_videos: number;
total_seconds: number;
member_count: number;
members: AssetMemberSummary[];
}>(`/admin/assets/team/${teamId}/members`),
getAssetsUserVideos: (userId: number, page: number = 1, pageSize: number = 30) =>
api.get<{
user_id: number;
username: string;
total: number;
page: number;
page_size: number;
results: AssetVideo[];
}>(`/admin/assets/user/${userId}/videos`, { params: { page, page_size: pageSize } }),
// Anomaly detection
getLoginAnomalies: (params: {
page?: number;
page_size?: number;
team_id?: number;
rule?: string;
level?: string;
start_date?: string;
end_date?: string;
} = {}) =>
api.get<PaginatedResponse<LoginAnomaly> & { total_pages: number }>('/admin/anomalies', { params }),
testFeishu: (mobile: string) =>
api.post<{ message: string }>('/admin/test-feishu', { mobile }),
testSms: (mobile: string) =>
api.post<{ message: string }>('/admin/test-sms', { mobile }),
teamAutoLearn: (teamId: number, days: number = 30, minCount: number = 3) =>
api.post<{ team_id: number; team_name: string; learned_cities: string[]; days: number; min_count: number; current_expected_regions: string }>(
`/admin/teams/${teamId}/auto-learn`, { days, min_count: minCount }
),
teamApplyLearnedRegions: (teamId: number, cities: string[]) =>
api.post(`/admin/teams/${teamId}/apply-learned-regions`, { cities }),
getLoginRecords: (params: {
page?: number;
page_size?: number;
search?: string;
team_id?: string;
start_date?: string;
end_date?: string;
city?: string;
} = {}) =>
api.get('/admin/login-records', { params }),
getAuditLogs: (params: {
page?: number;
page_size?: number;
action?: string;
operator?: string;
start_date?: string;
end_date?: string;
} = {}) =>
api.get<PaginatedResponse<AuditLog> & { total_pages: number }>('/admin/logs', { params }),
};
// Team Admin APIs
export const teamApi = {
getInfo: () =>
api.get<TeamInfo & { daily_member_limit_default: number; member_count: number }>('/team/info'),
getStats: () =>
api.get<TeamStats>('/team/stats'),
getMembers: () =>
api.get<{ results: TeamMember[] }>('/team/members'),
createMember: (data: { username: string; password: string; daily_generation_limit?: number; monthly_generation_limit?: number }) =>
api.post('/team/members/create', data),
getMemberDetail: (memberId: number) =>
api.get('/team/members/' + memberId),
updateMemberQuota: (memberId: number, daily: number, monthly: number, spendingLimit?: number) =>
api.put(`/team/members/${memberId}/quota`, {
daily_generation_limit: daily,
monthly_generation_limit: monthly,
...(spendingLimit !== undefined && { spending_limit: spendingLimit }),
}),
updateMemberStatus: (memberId: number, isActive: boolean) =>
api.patch(`/team/members/${memberId}/status`, { is_active: isActive }),
// Content Assets
getAssetsOverview: () =>
api.get<{
team_id: number;
team_name: string;
total_videos: number;
total_seconds: number;
member_count: number;
members: AssetMemberSummary[];
}>('/team/assets/overview'),
getAssetsMemberVideos: (memberId: number, page: number = 1, pageSize: number = 30) =>
api.get<{
user_id: number;
username: string;
total: number;
page: number;
page_size: number;
results: AssetVideo[];
}>(`/team/assets/member/${memberId}/videos`, { params: { page, page_size: pageSize } }),
// Consumption Records
getRecords: (params: {
page?: number;
page_size?: number;
search?: string;
start_date?: string;
end_date?: string;
} = {}) =>
api.get<{ total: number; page: number; page_size: number; results: AdminRecord[] }>('/team/records', { params }),
};
// Profile APIs
export const profileApi = {
getOverview: (period: '7d' | '30d' = '7d') =>
api.get<ProfileOverview>('/profile/overview', { params: { period } }),
getRecords: (page: number = 1, pageSize: number = 20) =>
api.get<PaginatedResponse<AdminRecord>>('/profile/records', {
params: { page, page_size: pageSize },
}),
};
export const assetsApi = {
getGroups: (params: { page?: number; page_size?: number } = {}) =>
api.get<{ results: AssetGroup[]; total: number }>('/assets/groups', { params }),
createGroup: (data: FormData) =>
api.post<AssetGroup>('/assets/groups', data, { headers: { 'Content-Type': 'multipart/form-data' } }),
getGroupDetail: (id: number) =>
api.get<AssetGroup & { assets: AssetItem[] }>(`/assets/groups/${id}`),
updateGroup: (id: number, data: { name?: string; description?: string }) =>
api.put(`/assets/groups/${id}`, data),
addAsset: (groupId: number, data: FormData) =>
api.post<AssetItem>(`/assets/groups/${groupId}/assets`, data, { headers: { 'Content-Type': 'multipart/form-data' } }),
updateAsset: (id: number, data: { name: string }) =>
api.put(`/assets/${id}`, data),
search: (q: string) =>
api.get<{ results: AssetGroup[] }>('/assets/search', { params: { q } }),
pollStatus: (id: number) =>
api.get<{ id: number; status: string; url: string; error_message: string }>(`/assets/${id}/status`),
};
/**
* Append TOS image resize parameter to reduce loading size.
* Only applies to TOS image URLs (volces.com with image extensions).
*/
export function tosThumb(url: string | undefined, height: number): string {
if (!url) return '';
// 只对我们自己的 TOS 桶生效airdrama-media不处理火山内部桶ark-media-asset 等)
if (!url.includes('airdrama-media')) return url;
if (!/\.(png|jpg|jpeg|webp|gif)/i.test(url)) return url;
const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}x-tos-process=image/resize,h_${height}`;
}
export default api;