- 计费双单价:含视频输入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>
127 lines
3.7 KiB
TypeScript
127 lines
3.7 KiB
TypeScript
import { create } from 'zustand';
|
||
import type { User, Quota, TeamInfo } from '../types';
|
||
import { authApi } from '../lib/api';
|
||
|
||
interface AuthState {
|
||
user: User | null;
|
||
accessToken: string | null;
|
||
refreshToken: string | null;
|
||
isAuthenticated: boolean;
|
||
isLoading: boolean;
|
||
quota: Quota | null;
|
||
team: TeamInfo | null;
|
||
teamDisabled: boolean;
|
||
mustChangePassword: boolean;
|
||
|
||
login: (username: string, password: string) => Promise<void>;
|
||
logout: () => void;
|
||
refreshAccessToken: () => Promise<void>;
|
||
fetchUserInfo: () => Promise<void>;
|
||
initialize: () => Promise<void>;
|
||
clearMustChangePassword: () => void;
|
||
}
|
||
|
||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||
user: null,
|
||
accessToken: localStorage.getItem('access_token'),
|
||
refreshToken: localStorage.getItem('refresh_token'),
|
||
isAuthenticated: !!localStorage.getItem('access_token'),
|
||
isLoading: true,
|
||
quota: null,
|
||
team: null,
|
||
teamDisabled: false,
|
||
mustChangePassword: false,
|
||
|
||
login: async (username, password) => {
|
||
const { data } = await authApi.login(username, password);
|
||
localStorage.setItem('access_token', data.tokens.access);
|
||
localStorage.setItem('refresh_token', data.tokens.refresh);
|
||
set({
|
||
user: data.user,
|
||
accessToken: data.tokens.access,
|
||
refreshToken: data.tokens.refresh,
|
||
isAuthenticated: true,
|
||
mustChangePassword: data.user.must_change_password || false,
|
||
});
|
||
// Fetch quota after login
|
||
await get().fetchUserInfo();
|
||
},
|
||
|
||
logout: () => {
|
||
// 先用当前 token 通知后端清除 ActiveSession,再清本地状态
|
||
const token = localStorage.getItem('access_token');
|
||
if (token) {
|
||
fetch('/api/v1/auth/logout', {
|
||
method: 'POST',
|
||
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||
}).catch(() => {});
|
||
}
|
||
localStorage.removeItem('access_token');
|
||
localStorage.removeItem('refresh_token');
|
||
set({
|
||
user: null,
|
||
accessToken: null,
|
||
refreshToken: null,
|
||
isAuthenticated: false,
|
||
quota: null,
|
||
team: null,
|
||
teamDisabled: false,
|
||
mustChangePassword: false,
|
||
});
|
||
},
|
||
|
||
refreshAccessToken: async () => {
|
||
const refresh = get().refreshToken;
|
||
if (!refresh) throw new Error('No refresh token');
|
||
const { data } = await authApi.refreshToken(refresh);
|
||
localStorage.setItem('access_token', data.access);
|
||
if (data.refresh) {
|
||
localStorage.setItem('refresh_token', data.refresh);
|
||
set({ accessToken: data.access, refreshToken: data.refresh });
|
||
} else {
|
||
set({ accessToken: data.access });
|
||
}
|
||
},
|
||
|
||
fetchUserInfo: async () => {
|
||
const { data } = await authApi.getMe();
|
||
const { quota, team, team_disabled, ...user } = data;
|
||
set({
|
||
user,
|
||
quota,
|
||
team: team || null,
|
||
teamDisabled: team_disabled || false,
|
||
isAuthenticated: true,
|
||
mustChangePassword: user.must_change_password || false,
|
||
});
|
||
},
|
||
|
||
initialize: async () => {
|
||
const token = localStorage.getItem('access_token');
|
||
if (token) {
|
||
// Retry up to 3 times for network errors (e.g. request aborted during page refresh)
|
||
for (let attempt = 0; attempt < 3; attempt++) {
|
||
try {
|
||
await get().fetchUserInfo();
|
||
break;
|
||
} catch (err) {
|
||
const status = (err as { response?: { status?: number } })?.response?.status;
|
||
if (status === 401 || status === 403) {
|
||
get().logout();
|
||
break;
|
||
}
|
||
// Network error / aborted — wait briefly and retry
|
||
if (attempt < 2) {
|
||
await new Promise(r => setTimeout(r, 500));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
set({ isLoading: false });
|
||
},
|
||
|
||
clearMustChangePassword: () => {
|
||
set({ mustChangePassword: false });
|
||
},
|
||
}));
|