import { createAsyncThunk, createSlice, type PayloadAction } from "@reduxjs/toolkit"; import { ApiError } from "../services/api"; import { changePassword as changePasswordRequest, getAuthStatus, getSession, login as loginRequest, logout as logoutRequest } from "../services/auth"; import type { Admin, AuthStatus, ChangePasswordInput, LoginCredentials } from "../types/auth"; export type SessionStatus = "idle" | "loading" | "authenticated" | "unauthenticated" | "error"; export interface SessionState { status: SessionStatus; admin: Admin | null; initialized: boolean | null; timezone: string; error: string | null; bootstrapRequestId: string | null; } const initialState: SessionState = { status: "idle", admin: null, initialized: null, timezone: "Asia/Shanghai", error: null, bootstrapRequestId: null, }; /** Only the initial session check should replace the auth surface with a * full-page bootstrap indicator. Login and password requests keep their form * mounted so their controls can show progress in place. */ export function isSessionBootstrapping(state: Pick): boolean { return state.status === "idle" || (state.status === "loading" && (state.initialized === null || state.bootstrapRequestId !== null)); } export const bootstrapSession = createAsyncThunk("session/bootstrap", async () => { const authStatus = await getAuthStatus(); try { const session = await getSession(); return { ...authStatus, admin: session.admin }; } catch (error) { if (error instanceof ApiError && error.status === 401) return { ...authStatus, admin: null }; throw error; } }); export const loginThunk = createAsyncThunk("session/login", async (credentials: LoginCredentials) => { const result = await loginRequest(credentials); return result.admin; }); export const changePasswordThunk = createAsyncThunk("session/changePassword", async (input: ChangePasswordInput) => { const result = await changePasswordRequest(input); return result.admin; }); export const logoutThunk = createAsyncThunk("session/logout", async () => { await logoutRequest(); }); // Friendly aliases keep page code terse while the *Thunk suffix remains explicit at call sites. export const restoreSession = bootstrapSession; export const login = loginThunk; export const changePassword = changePasswordThunk; export const logout = logoutThunk; const sessionSlice = createSlice({ name: "session", initialState, reducers: { clearSessionError(state) { state.error = null; }, sessionExpired(state, action: PayloadAction) { state.bootstrapRequestId = null; state.status = "unauthenticated"; state.admin = null; state.error = action.payload ?? null; }, setTimezone(state, action: PayloadAction) { state.timezone = action.payload; }, }, extraReducers: (builder) => { builder .addCase(bootstrapSession.pending, (state, action) => { state.status = "loading"; state.error = null; state.bootstrapRequestId = action.meta.requestId; }) .addCase(bootstrapSession.fulfilled, (state, action) => { if (state.bootstrapRequestId !== action.meta.requestId) return; state.bootstrapRequestId = null; state.status = action.payload.admin ? "authenticated" : "unauthenticated"; state.admin = action.payload.admin; state.initialized = action.payload.initialized; state.timezone = action.payload.timezone || state.timezone; }) .addCase(bootstrapSession.rejected, (state, action) => { if (state.bootstrapRequestId !== action.meta.requestId) return; state.bootstrapRequestId = null; state.status = "error"; state.error = action.error.message ?? "无法读取登录状态"; }) .addCase(loginThunk.pending, (state) => { state.bootstrapRequestId = null; state.status = "loading"; state.error = null; }) .addCase(loginThunk.fulfilled, (state, action: PayloadAction) => { state.bootstrapRequestId = null; state.status = "authenticated"; state.admin = action.payload; state.initialized = true; state.error = null; }) .addCase(loginThunk.rejected, (state, action) => { state.bootstrapRequestId = null; state.status = "unauthenticated"; state.error = action.error.message ?? "登录失败"; }) .addCase(changePasswordThunk.pending, (state) => { state.bootstrapRequestId = null; state.status = "loading"; state.error = null; }) .addCase(changePasswordThunk.fulfilled, (state, action: PayloadAction) => { state.bootstrapRequestId = null; state.status = "authenticated"; state.admin = action.payload; state.error = null; }) .addCase(changePasswordThunk.rejected, (state, action) => { state.bootstrapRequestId = null; state.status = "authenticated"; state.error = action.error.message ?? "密码更新失败"; }) .addCase(logoutThunk.pending, (state) => { state.bootstrapRequestId = null; state.status = "loading"; }) .addCase(logoutThunk.fulfilled, (state) => { state.bootstrapRequestId = null; state.status = "unauthenticated"; state.admin = null; state.error = null; }) .addCase(logoutThunk.rejected, (state, action) => { state.bootstrapRequestId = null; state.status = "unauthenticated"; state.admin = null; state.error = action.error.message ?? "退出登录失败"; }); }, }); export const { clearSessionError, sessionExpired, setTimezone } = sessionSlice.actions; export default sessionSlice.reducer;