Files
meijiaka-zy/tauri-app/src/store/authStore.ts
T
小鱼开发 923ff63a3d feat: 密码登录功能(验证码/密码双模式 + 忘记密码 + 设置密码)
后端:
- security.py: 新增 bcrypt 密码哈希/校验工具
- auth_service.py: 新增 login_with_password、reset_password_with_sms
- auth.py: 新增 /login-password、/has-password、/set-password、/reset-password 接口
- schemas/auth.py: 新增 PasswordLoginRequest、SetPasswordRequest、ResetPasswordRequest、CheckPasswordResponse
- crud/user.py: 新增 update_password

前端:
- Login.tsx: 支持验证码/密码切换登录,密码模式下显示忘记密码入口
- Login.css: 新增登录方式切换标签、密码输入框样式
- authStore.ts: 新增 loginWithPassword
- Settings.tsx: 新增账号安全区块,显示密码状态,打开设置/修改密码弹窗
- SetPasswordModal.tsx: 设置/修改密码弹窗(旧密码校验、密码显示切换、表单验证)
- ResetPasswordModal.tsx: 忘记密码弹窗(手机号+验证码+新密码重置)

兼容:
- 零数据库迁移,password_hash 字段已存在(nullable)
- 现有接口不变,完全向后兼容旧版本
2026-06-09 23:26:50 +08:00

380 lines
11 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.
/**
* Auth Store - Zustand + Tauri 原生文件存储
* ==========================================
*
* 认证状态持久化到 Tauri 原生文件存储
* 路径:应用配置目录 / auth.json
*
* 迁移:首次运行自动从 localStorage 迁移,迁移成功后删除旧数据
*/
import { create } from 'zustand';
import { invoke } from '@tauri-apps/api/core';
import { getVersion } from '@tauri-apps/api/app';
import { client, clearAuthCache, PYTHON_API_BASE_URL, setOnTokenRefreshed, setOnAuthFailed } from '../api/client';
import { isTauri } from '../utils/env';
import { useVoiceStore } from './voiceStore';
import { useCoverAvatarStore } from './coverAvatarStore';
import { useProgressStore } from './progressStore';
import { useProjectStore } from './projectStore';
// uiStore 不再直接导入,弹窗由 React 组件通过状态驱动渲染
interface UserInfo {
id: string;
nickname: string;
avatar: string;
}
interface AuthState {
isAuthenticated: boolean;
accessToken: string | null;
refreshToken: string | null;
user: UserInfo | null;
isLoading: boolean;
showKickModal: boolean;
kickMessage: string;
}
interface AuthActions {
login: (_phone: string, _code: string) => Promise<void>;
loginWithPassword: (_phone: string, _password: string) => Promise<void>;
logout: () => Promise<void>;
checkAuth: () => boolean;
loadFromStorage: () => Promise<void>;
closeKickModal: () => void;
}
type AuthStore = AuthState & AuthActions;
const initialState: AuthState = {
isAuthenticated: false,
accessToken: null,
refreshToken: null,
user: null,
isLoading: true,
showKickModal: false,
kickMessage: '',
};
const AUTH_LOCAL_STORAGE_KEY = 'ai-video-auth';
// 登录防重入标志
let isLoggingIn = false;
// SSE 连接(模块级,不放入 Zustand state,因为 EventSource 不可序列化)
let _sseConnection: EventSource | null = null;
function connectSSE(accessToken: string) {
disconnectSSE();
if (!accessToken || !PYTHON_API_BASE_URL) {return;}
const url = `${PYTHON_API_BASE_URL}/events?token=${encodeURIComponent(accessToken)}`;
const es = new EventSource(url);
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'kick') {
const state = useAuthStore.getState();
if (!state.isAuthenticated || state.showKickModal) {return;}
// 先弹窗,不清除状态;用户点击确认后再清除
useAuthStore.setState({
showKickModal: true,
kickMessage: '当前账号在其他设备登录,即将退出',
});
}
} catch (e) {
console.error('[SSE] 消息解析失败:', e);
}
};
es.onerror = () => {
console.error('[SSE] 连接错误,3秒后尝试重连');
es.close();
_sseConnection = null;
setTimeout(() => {
const { accessToken: currentToken } = useAuthStore.getState();
if (currentToken) {connectSSE(currentToken);}
}, 3000);
};
_sseConnection = es;
}
function disconnectSSE() {
if (_sseConnection) {
_sseConnection.close();
_sseConnection = null;
}
}
/**
* 清理本地认证状态(被踢或 Token 失效时调用,不通知后端)
*/
async function clearAuthState(): Promise<void> {
disconnectSSE();
clearAuthCache();
if (isTauri()) {
await invoke('clear_auth_state').catch(() => {});
} else {
localStorage.removeItem(AUTH_LOCAL_STORAGE_KEY);
}
}
/**
* 保存认证状态(Tauri 文件存储优先,浏览器回退 localStorage
*/
async function saveAuthState(state: AuthState): Promise<void> {
const payload = {
isAuthenticated: state.isAuthenticated,
accessToken: state.accessToken,
refreshToken: state.refreshToken,
user: state.user,
};
if (isTauri()) {
await invoke('save_auth_state', { state: payload });
} else {
localStorage.setItem(AUTH_LOCAL_STORAGE_KEY, JSON.stringify({ state: payload }));
}
}
// 生成设备 IDdevice_id 保持在 localStorage,因为它不需要太大空间
const generateDeviceId = () => {
const stored = localStorage.getItem('device_id');
if (stored) {
return stored;
}
const newId = `web_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
localStorage.setItem('device_id', newId);
return newId;
};
// 缓存应用版本号,避免每次登录都调用 IPC
let appVersionCache: string | null = null;
async function getAppVersion(): Promise<string> {
if (appVersionCache) {
return appVersionCache;
}
try {
appVersionCache = await getVersion();
} catch {
appVersionCache = 'unknown';
}
return appVersionCache;
}
// 注册 Token 刷新回调:client.ts 的 doRefreshToken 成功后会触发此回调,
// 确保新 Token 被持久化到 Tauri 文件存储 / localStorage
setOnTokenRefreshed((tokens) => {
const state = useAuthStore.getState();
if (state.isAuthenticated) {
const newState: AuthState = {
...state,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
};
useAuthStore.setState(newState);
saveAuthState(newState).catch((e) => console.error('[authStore] 保存刷新后的 Token 失败:', e));
}
});
// 注册认证失效回调:Refresh Token 失效(401 设备已失效)时先弹窗
// 注意:不能调用 logout(),因为 logout() 内部会请求 /auth/logout,又可能触发 401 循环
setOnAuthFailed(() => {
const state = useAuthStore.getState();
if (!state.isAuthenticated || state.showKickModal) {return;}
// 先弹窗,不清除状态;用户点击确认后再清除
useAuthStore.setState({
showKickModal: true,
kickMessage: '当前账号在其他设备登录,即将退出',
});
});
export const useAuthStore = create<AuthStore>((set, get) => ({
...initialState,
/**
* 从存储加载认证状态(Tauri 文件存储优先,浏览器回退 localStorage
*/
loadFromStorage: async () => {
set({ isLoading: true });
try {
if (!isTauri()) {
// 浏览器环境:回退到 localStorage
const data = localStorage.getItem(AUTH_LOCAL_STORAGE_KEY);
if (data) {
const parsed = JSON.parse(data);
const state = parsed.state as AuthState | undefined;
if (state?.isAuthenticated && state.accessToken) {
set({ ...initialState, ...state, isLoading: false });
// 浏览器环境重连 SSE
connectSSE(state.accessToken);
return;
}
}
set({ ...initialState, isLoading: false });
return;
}
// Tauri 环境:从原生文件加载
const result = await invoke<{ code: number; data?: AuthState }>('load_auth_state');
if (result.code === 200 && result.data) {
const loadedState: AuthState = {
...initialState,
isAuthenticated: result.data.isAuthenticated ?? false,
accessToken: result.data.accessToken ?? null,
refreshToken: result.data.refreshToken ?? null,
user: result.data.user ?? null,
isLoading: false,
};
set(loadedState);
clearAuthCache();
// 若已登录,重连 SSE 以接收单设备踢人通知
if (loadedState.isAuthenticated && loadedState.accessToken) {
connectSSE(loadedState.accessToken);
}
} else {
set({ ...initialState, isLoading: false });
}
} catch (e) {
console.error('[authStore] 加载认证状态失败:', e);
set({ ...initialState, isLoading: false });
}
},
// 手机号验证码登录
login: async (phone: string, code: string) => {
if (isLoggingIn) {return;}
isLoggingIn = true;
try {
const data = await client.post<{ accessToken: string; refreshToken: string; user: UserInfo }>('/auth/login', {
mobile: phone,
code,
deviceId: generateDeviceId(),
deviceName: '美家卡智影桌面端',
osInfo: navigator.userAgent,
appVersion: await getAppVersion(),
});
const newState = {
isAuthenticated: true,
accessToken: data.accessToken,
refreshToken: data.refreshToken,
user: data.user,
isLoading: false,
showKickModal: false,
kickMessage: '',
};
set(newState);
await saveAuthState(newState);
clearAuthCache();
connectSSE(data.accessToken);
} catch (error) {
console.error('[authStore] 登录失败:', error);
isLoggingIn = false;
throw error;
} finally {
isLoggingIn = false;
}
},
// 手机号密码登录
loginWithPassword: async (phone: string, password: string) => {
if (isLoggingIn) {return;}
isLoggingIn = true;
try {
const data = await client.post<{ accessToken: string; refreshToken: string; user: UserInfo }>('/auth/login-password', {
mobile: phone,
password,
deviceId: generateDeviceId(),
deviceName: '美家卡智影桌面端',
osInfo: navigator.userAgent,
appVersion: await getAppVersion(),
});
const newState = {
isAuthenticated: true,
accessToken: data.accessToken,
refreshToken: data.refreshToken,
user: data.user,
isLoading: false,
showKickModal: false,
kickMessage: '',
};
set(newState);
await saveAuthState(newState);
clearAuthCache();
connectSSE(data.accessToken);
} catch (error) {
console.error('[authStore] 密码登录失败:', error);
isLoggingIn = false;
throw error;
} finally {
isLoggingIn = false;
}
},
closeKickModal: () => {
clearAuthState();
set({ ...initialState, isLoading: false, showKickModal: false, kickMessage: '' });
// 清除其他 Store 的内存状态,防止被踢后重新登录时数据残留
try {
useVoiceStore.getState().reset();
useCoverAvatarStore.getState().reset();
useProgressStore.getState().reset();
useProjectStore.getState().reset();
} catch (e) {
console.error('[Auth] 清除其他 Store 状态失败:', e);
}
},
logout: async () => {
// 断开 SSE 连接
disconnectSSE();
// 先调用后端登出 API(清理设备记录)
try {
await client.post('/auth/logout');
} catch {
// 后端登出失败不影响前端状态清理
}
// 重置状态
set({ ...initialState, isLoading: false });
// 清除内存缓存
clearAuthCache();
// 清除存储中的认证数据
try {
if (isTauri()) {
await invoke('clear_auth_state');
} else {
localStorage.removeItem(AUTH_LOCAL_STORAGE_KEY);
}
} catch (e) {
console.error('[Auth] 清除认证状态失败:', e);
}
// 清除其他 Store 的内存状态,防止切换账号后数据残留
try {
useVoiceStore.getState().reset();
useCoverAvatarStore.getState().reset();
useProgressStore.getState().reset();
useProjectStore.getState().reset();
} catch (e) {
console.error('[Auth] 清除其他 Store 状态失败:', e);
}
},
checkAuth: () => {
const { isAuthenticated, accessToken } = get();
return isAuthenticated && !!accessToken;
},
}));