- Add active: state feedback to all buttons across the app - Fix cache clearing to update Zustand store (not just localStorage) - Remove checkboxes from settings cache section, compact layout - Settings page: single outer scroll instead of dual-column scroll - CourseWorkspace: elastic log panel height, work/exam record counts - Integrate WorkList/ExamList types and display in UI - Delete unused CourseList.tsx component - Fix wk.ts: strict equality, remove unused import Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
337 lines
10 KiB
TypeScript
337 lines
10 KiB
TypeScript
import { createStore } from "zustand/vanilla";
|
|
import { persist, createJSONStorage } from "zustand/middleware";
|
|
import type { CourseKind, RecordItem, RecordType, WorkListItem, ExamListItem } from "~/service/wk";
|
|
import type { CourseType } from "~/types/Course";
|
|
import type { userInfoType } from "~/types/Userinfo";
|
|
|
|
const withLogTimestamp = (message: string) => {
|
|
if (/^\[\d{2}:\d{2}:\d{2}\]\s/.test(message)) {
|
|
return message;
|
|
}
|
|
|
|
const now = new Date();
|
|
const timestamp = now.toLocaleTimeString("zh-CN", {
|
|
hour12: false,
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
});
|
|
|
|
return `[${timestamp}] ${message}`;
|
|
};
|
|
const MAX_STUDY_LOGS_PER_ACCOUNT = 1000;
|
|
|
|
export type AccountAuth = {
|
|
password: string;
|
|
token: string;
|
|
};
|
|
|
|
export type AccountItem = {
|
|
id: string;
|
|
username: string;
|
|
host: string;
|
|
status: CourseKind;
|
|
sessionId: string;
|
|
auth: AccountAuth;
|
|
user: userInfoType;
|
|
courses: CourseType[];
|
|
};
|
|
|
|
export type RecordCacheMap = Record<string, RecordItem[]>;
|
|
|
|
export type WorkExamCacheMap = Record<string, WorkListItem[] | ExamListItem[]>;
|
|
|
|
type PersistPreferences = {
|
|
persistAccounts: boolean;
|
|
persistRecords: boolean;
|
|
persistLogs: boolean;
|
|
};
|
|
|
|
const settingsStorageKey = "settings-storage";
|
|
const defaultPersistPreferences: PersistPreferences = {
|
|
persistAccounts: true,
|
|
persistRecords: true,
|
|
persistLogs: true,
|
|
};
|
|
|
|
const resolvePersistPreferences = (): PersistPreferences => {
|
|
try {
|
|
const rawValue = localStorage.getItem(settingsStorageKey);
|
|
if (!rawValue) {
|
|
return defaultPersistPreferences;
|
|
}
|
|
|
|
const parsedValue = JSON.parse(rawValue) as {
|
|
state?: Partial<PersistPreferences>;
|
|
};
|
|
const persistedState = parsedValue?.state;
|
|
|
|
return {
|
|
persistAccounts:
|
|
persistedState?.persistAccounts ??
|
|
defaultPersistPreferences.persistAccounts,
|
|
persistRecords:
|
|
persistedState?.persistRecords ?? defaultPersistPreferences.persistRecords,
|
|
persistLogs:
|
|
persistedState?.persistLogs ?? defaultPersistPreferences.persistLogs,
|
|
};
|
|
} catch {
|
|
return defaultPersistPreferences;
|
|
}
|
|
};
|
|
|
|
type AccountState = {
|
|
accounts: AccountItem[];
|
|
selectedAccountId: string;
|
|
expandedAccountId: string;
|
|
selectedCourseId: number | null;
|
|
courseKind: CourseKind;
|
|
recordType: RecordType;
|
|
records: RecordItem[];
|
|
recordCacheMap: RecordCacheMap;
|
|
workList: WorkListItem[];
|
|
examList: ExamListItem[];
|
|
workExamCacheMap: WorkExamCacheMap;
|
|
studyLogsMap: Record<string, string[]>;
|
|
runningStudyMap: Record<string, boolean>;
|
|
studyHeartbeatMap: Record<string, number>;
|
|
setSelectedAccountId: (accountId: string) => void;
|
|
setExpandedAccountId: (accountId: string) => void;
|
|
setSelectedCourseId: (courseId: number | null) => void;
|
|
setCourseKind: (courseKind: CourseKind) => void;
|
|
setRecordType: (recordType: RecordType) => void;
|
|
setRecords: (records: RecordItem[]) => void;
|
|
setRecordCache: (cacheKey: string, records: RecordItem[]) => void;
|
|
setWorkList: (list: WorkListItem[]) => void;
|
|
setExamList: (list: ExamListItem[]) => void;
|
|
setWorkExamCache: (cacheKey: string, data: WorkListItem[] | ExamListItem[]) => void;
|
|
setAccountRunningStudy: (accountId: string, value: boolean) => void;
|
|
touchStudyHeartbeat: (accountId: string, timestamp?: number) => void;
|
|
clearStudyHeartbeat: (accountId: string) => void;
|
|
clearAllStudyHeartbeat: () => void;
|
|
appendStudyLog: (accountId: string, message: string) => void;
|
|
clearStudyLogs: (accountId: string) => void;
|
|
clearAllStudyLogs: () => void;
|
|
upsertAccount: (account: AccountItem) => void;
|
|
setAccountCourses: (accountId: string, courses: CourseType[]) => void;
|
|
removeAccount: (accountId: string) => void;
|
|
clearAllData: () => void;
|
|
clearRecordsData: () => void;
|
|
clearAccountsData: () => void;
|
|
};
|
|
|
|
export const accountStore = createStore<AccountState>()(
|
|
persist(
|
|
(set) => ({
|
|
accounts: [],
|
|
selectedAccountId: "",
|
|
expandedAccountId: "",
|
|
selectedCourseId: null,
|
|
courseKind: "run",
|
|
recordType: "",
|
|
records: [],
|
|
recordCacheMap: {},
|
|
workList: [],
|
|
examList: [],
|
|
workExamCacheMap: {},
|
|
studyLogsMap: {},
|
|
runningStudyMap: {},
|
|
studyHeartbeatMap: {},
|
|
setSelectedAccountId: (accountId) =>
|
|
set({ selectedAccountId: accountId }),
|
|
setExpandedAccountId: (accountId) =>
|
|
set({ expandedAccountId: accountId }),
|
|
setSelectedCourseId: (courseId) => set({ selectedCourseId: courseId }),
|
|
setCourseKind: (courseKind) => set({ courseKind }),
|
|
setRecordType: (recordType) => set({ recordType }),
|
|
setRecords: (records) => set({ records }),
|
|
setRecordCache: (cacheKey, records) =>
|
|
set((state) => ({
|
|
recordCacheMap: {
|
|
...state.recordCacheMap,
|
|
[cacheKey]: records,
|
|
},
|
|
})),
|
|
setWorkList: (list) => set({ workList: list }),
|
|
setExamList: (list) => set({ examList: list }),
|
|
setWorkExamCache: (cacheKey, data) =>
|
|
set((state) => ({
|
|
workExamCacheMap: {
|
|
...state.workExamCacheMap,
|
|
[cacheKey]: data,
|
|
},
|
|
})),
|
|
setAccountRunningStudy: (accountId, value) =>
|
|
set((state) => ({
|
|
runningStudyMap: {
|
|
...state.runningStudyMap,
|
|
[accountId]: value,
|
|
},
|
|
})),
|
|
touchStudyHeartbeat: (accountId, timestamp) =>
|
|
set((state) => ({
|
|
studyHeartbeatMap: {
|
|
...state.studyHeartbeatMap,
|
|
[accountId]: timestamp ?? Date.now(),
|
|
},
|
|
})),
|
|
clearStudyHeartbeat: (accountId) =>
|
|
set((state) => ({
|
|
studyHeartbeatMap: Object.fromEntries(
|
|
Object.entries(state.studyHeartbeatMap).filter(
|
|
([key]) => key !== accountId,
|
|
),
|
|
),
|
|
})),
|
|
clearAllStudyHeartbeat: () =>
|
|
set({
|
|
studyHeartbeatMap: {},
|
|
}),
|
|
appendStudyLog: (accountId, message) =>
|
|
set((state) => {
|
|
const nextLogs = [
|
|
...(state.studyLogsMap[accountId] ?? []),
|
|
withLogTimestamp(message),
|
|
].slice(-MAX_STUDY_LOGS_PER_ACCOUNT);
|
|
|
|
return {
|
|
studyLogsMap: {
|
|
...state.studyLogsMap,
|
|
[accountId]: nextLogs,
|
|
},
|
|
};
|
|
}),
|
|
clearStudyLogs: (accountId) =>
|
|
set((state) => ({
|
|
studyLogsMap: {
|
|
...state.studyLogsMap,
|
|
[accountId]: [],
|
|
},
|
|
})),
|
|
clearAllStudyLogs: () =>
|
|
set({
|
|
studyLogsMap: {},
|
|
}),
|
|
clearAllData: () =>
|
|
set({
|
|
accounts: [],
|
|
selectedAccountId: "",
|
|
expandedAccountId: "",
|
|
selectedCourseId: null,
|
|
courseKind: "run" as CourseKind,
|
|
recordType: "" as RecordType,
|
|
records: [],
|
|
recordCacheMap: {},
|
|
workList: [],
|
|
examList: [],
|
|
workExamCacheMap: {},
|
|
studyLogsMap: {},
|
|
runningStudyMap: {},
|
|
studyHeartbeatMap: {},
|
|
}),
|
|
clearRecordsData: () =>
|
|
set({
|
|
records: [],
|
|
recordCacheMap: {},
|
|
workList: [],
|
|
examList: [],
|
|
workExamCacheMap: {},
|
|
selectedCourseId: null,
|
|
}),
|
|
clearAccountsData: () =>
|
|
set({
|
|
accounts: [],
|
|
selectedAccountId: "",
|
|
expandedAccountId: "",
|
|
selectedCourseId: null,
|
|
}),
|
|
upsertAccount: (account) =>
|
|
set((state) => ({
|
|
accounts: [
|
|
account,
|
|
...state.accounts.filter((item) => item.id !== account.id),
|
|
],
|
|
selectedAccountId: account.id,
|
|
expandedAccountId: account.id,
|
|
})),
|
|
setAccountCourses: (accountId, courses) =>
|
|
set((state) => ({
|
|
accounts: state.accounts.map((item) =>
|
|
item.id === accountId ? { ...item, courses } : item,
|
|
),
|
|
})),
|
|
removeAccount: (accountId) =>
|
|
set((state) => {
|
|
const nextAccounts = state.accounts.filter(
|
|
(item) => item.id !== accountId,
|
|
);
|
|
return {
|
|
accounts: nextAccounts,
|
|
selectedAccountId:
|
|
state.selectedAccountId === accountId
|
|
? ""
|
|
: state.selectedAccountId,
|
|
expandedAccountId:
|
|
state.expandedAccountId === accountId
|
|
? ""
|
|
: state.expandedAccountId,
|
|
selectedCourseId:
|
|
state.selectedAccountId === accountId
|
|
? null
|
|
: state.selectedCourseId,
|
|
records: state.selectedAccountId === accountId ? [] : state.records,
|
|
recordCacheMap: Object.fromEntries(
|
|
Object.entries(state.recordCacheMap).filter(
|
|
([key]) => !key.startsWith(`${accountId}::`),
|
|
),
|
|
),
|
|
studyLogsMap: Object.fromEntries(
|
|
Object.entries(state.studyLogsMap).filter(
|
|
([key]) => key !== accountId,
|
|
),
|
|
),
|
|
runningStudyMap: Object.fromEntries(
|
|
Object.entries(state.runningStudyMap).filter(
|
|
([key]) => key !== accountId,
|
|
),
|
|
),
|
|
studyHeartbeatMap: Object.fromEntries(
|
|
Object.entries(state.studyHeartbeatMap).filter(
|
|
([key]) => key !== accountId,
|
|
),
|
|
),
|
|
};
|
|
}),
|
|
}),
|
|
{
|
|
name: "account-storage",
|
|
storage: createJSONStorage(() => localStorage),
|
|
partialize: (state) => {
|
|
const preferences = resolvePersistPreferences();
|
|
const persistedState: Partial<AccountState> = {
|
|
courseKind: state.courseKind,
|
|
recordType: state.recordType,
|
|
};
|
|
|
|
if (preferences.persistAccounts) {
|
|
persistedState.accounts = state.accounts;
|
|
persistedState.selectedAccountId = state.selectedAccountId;
|
|
persistedState.expandedAccountId = state.expandedAccountId;
|
|
persistedState.selectedCourseId = state.selectedCourseId;
|
|
}
|
|
|
|
if (preferences.persistRecords) {
|
|
persistedState.records = state.records;
|
|
persistedState.recordCacheMap = state.recordCacheMap;
|
|
}
|
|
|
|
if (preferences.persistLogs) {
|
|
persistedState.studyLogsMap = state.studyLogsMap;
|
|
}
|
|
|
|
return persistedState;
|
|
},
|
|
},
|
|
),
|
|
);
|