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

127 lines
3.7 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 { 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 });
},
}));