seaislee1209 f3f8d08b56 feat: v0.14.1 视频参考双单价 + Token刷新防抖 + CSV导出上限
- 计费双单价:含视频输入28元/百万tokens,不含视频输入46元/百万tokens
- QuotaConfig 加 base_token_price_video 字段,系统设置页两个并排输入框
- 预估费用和实际结算按参考素材类型自动选择单价
- Token 刷新加锁:同页面内并发 401 共用一次 refresh 请求
- 关闭 BLACKLIST_AFTER_ROTATION:防止快速刷新导致误登出
- ProtectedRoute 容错:请求中断时自动重试,不误跳转
- CSV 导出上限从 100 提升到 10000

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

447 lines
15 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;
});
// Token refresh lock: prevent concurrent refresh requests
let refreshPromise: Promise<string> | null = null;
function doRefresh(): Promise<string> {
const refreshToken = localStorage.getItem('refresh_token');
if (!refreshToken) return Promise.reject(new Error('no_refresh_token'));
return axios.post('/api/v1/auth/token/refresh', { refresh: refreshToken })
.then(({ data }) => {
localStorage.setItem('access_token', data.access);
if (data.refresh) {
localStorage.setItem('refresh_token', data.refresh);
}
return data.access as string;
});
}
function refreshAccessToken(): Promise<string> {
if (refreshPromise) return refreshPromise;
refreshPromise = doRefresh().finally(() => { refreshPromise = null; });
return refreshPromise;
}
// 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;
try {
const newAccess = await refreshAccessToken();
originalRequest.headers.Authorization = `Bearer ${newAccess}`;
return api(originalRequest);
} catch {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
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; is_read: boolean; updated_at?: string }>('/announcement'),
readAnnouncement: () =>
api.post('/announcement/read'),
};
// 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),
setMemberRole: (teamId: number, memberId: number, isTeamAdmin: boolean) =>
api.patch(`/admin/teams/${teamId}/members/${memberId}/role`, { is_team_admin: isTeamAdmin }),
// 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 }),
setMemberRole: (memberId: number, isTeamAdmin: boolean) =>
api.patch(`/team/members/${memberId}/role`, { is_team_admin: isTeamAdmin }),
// 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;