This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import { useDispatch, useSelector, type TypedUseSelectorHook } from "react-redux";
|
||||
import sessionReducer from "./sessionSlice";
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
session: sessionReducer,
|
||||
},
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
export const useAppDispatch = () => useDispatch<AppDispatch>();
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||
|
||||
export { default as sessionReducer } from "./sessionSlice";
|
||||
export * from "./sessionSlice";
|
||||
@@ -0,0 +1,156 @@
|
||||
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<SessionState, "status" | "initialized" | "bootstrapRequestId">): 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<string | undefined>) {
|
||||
state.bootstrapRequestId = null;
|
||||
state.status = "unauthenticated";
|
||||
state.admin = null;
|
||||
state.error = action.payload ?? null;
|
||||
},
|
||||
setTimezone(state, action: PayloadAction<string>) {
|
||||
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<Admin>) => {
|
||||
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<Admin>) => {
|
||||
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;
|
||||
Reference in New Issue
Block a user