release: 1.1.0
TallyNote release / linux-x64 (push) Successful in 6m24s

This commit is contained in:
Qiufeng
2026-08-31 20:11:34 +08:00
parent cbe0d65320
commit 12495fb6a4
66 changed files with 4315 additions and 77 deletions
+17
View File
@@ -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";
+156
View File
@@ -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;