diff --git a/package.json b/package.json
index e989a58..ddb674e 100644
--- a/package.json
+++ b/package.json
@@ -13,7 +13,8 @@
"@tailwindcss/vite": "^4.2.2",
"axios": "^1.13.6",
"solid-js": "^1.9.11",
- "tailwindcss": "^4.2.2"
+ "tailwindcss": "^4.2.2",
+ "zustand": "^5.0.12"
},
"devDependencies": {
"@types/node": "^24.12.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 159be8a..68f8fb8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -23,6 +23,9 @@ importers:
tailwindcss:
specifier: ^4.2.2
version: 4.2.2
+ zustand:
+ specifier: ^5.0.12
+ version: 5.0.12
devDependencies:
'@types/node':
specifier: ^24.12.0
@@ -863,6 +866,24 @@ packages:
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
+ zustand@5.0.12:
+ resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==}
+ engines: {node: '>=12.20.0'}
+ peerDependencies:
+ '@types/react': '>=18.0.0'
+ immer: '>=9.0.6'
+ react: '>=18.0.0'
+ use-sync-external-store: '>=1.2.0'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ immer:
+ optional: true
+ react:
+ optional: true
+ use-sync-external-store:
+ optional: true
+
snapshots:
'@babel/code-frame@7.29.0':
@@ -1522,3 +1543,5 @@ snapshots:
vite: 8.0.2(@types/node@24.12.0)(jiti@2.6.1)
yallist@3.1.1: {}
+
+ zustand@5.0.12: {}
diff --git a/src/App.tsx b/src/App.tsx
index c2113f0..f7daf80 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,69 +1,103 @@
import type { ParentComponent } from "solid-js";
import { A, useLocation } from "@solidjs/router";
-import { createSignal } from "solid-js";
const asideList = [
{ label: "账号", url: "/account" },
{ label: "设置", url: "/setting" },
];
-const userInfo = {
- name: "张三",
- id: "123456",
- dept: "大数据学院",
- class: "3班",
- gender: "男",
-};
-const infoList = [
- ["名字", userInfo.name],
- ["学号", userInfo.id],
- ["学院", userInfo.dept],
- ["班级", userInfo.class],
- ["性别", userInfo.gender],
-];
const App: ParentComponent = (props) => {
const location = useLocation();
+ const isActive = (url: string) =>
+ location.pathname === url ||
+ (location.pathname === "/" && url === "/account");
+
return (
-
-
-
-
- 个人信息
-
- {infoList.map(([label, value]) => (
-
- {label}: {value}
-
- ))}
-
-
-
-
-
+ }
+ >
+
+
+
+
+
+
+ {(course) => {
+ const selected = course.id === props.selectedCourseId;
+
+ return (
+
+ );
+ }}
+
+
+
+
+
+
+
+
+
记录列表
+
+ {props.selectedCourse
+ ? props.selectedCourse.name
+ : "请选择课程"}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 正在加载记录...
+
+
+
+
+ {props.recordError}
+
+
+
+
+ 点击左侧课程后,在这里查看对应记录。
+
+
+
+ 当前分类下没有记录。
+
+
+
+
+ {(record) => (
+
+
+
+
+ {record.name}
+
+
+ 记录 ID:{record.id} | 章节:{record.chapterId}
+
+
+
+ {props.renderRecordState(record.state) ||
+ "未知状态"}
+
+
+
+
+
视频时长:{record.videoDuration}
+
学习秒数:{record.duration}
+
学习进度:{record.progress}
+
开始时间:{record.beginTime || "-"}
+
完成时间:{record.finalTime || "-"}
+
查看次数:{record.viewCount}
+
+
+ )}
+
+
+
+
+
+
+
+
+
运行日志
+
+ 输出会自动滚动到最新位置
+
+
+
+
+
+
+
+
+
+
+
0}
+ fallback={暂无日志输出。
}
+ >
+
+
+ {(log, index) => (
+
+ [{index() + 1}]{" "}
+ {props.showLogTimestamps
+ ? `${new Date().toLocaleTimeString()} `
+ : ""}
+ {log}
+
+ )}
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default CourseWorkspace;
diff --git a/src/components/courseList/CourseList.tsx b/src/components/courseList/CourseList.tsx
new file mode 100644
index 0000000..a41c018
--- /dev/null
+++ b/src/components/courseList/CourseList.tsx
@@ -0,0 +1,39 @@
+import type { CourseType } from "~/types/Course";
+import type { Accessor } from "solid-js";
+
+const courseLabel: [string, keyof CourseType][] = [
+ ["课程", "name"],
+ ["课程号", "id"],
+ ["老师", "teacher"],
+ ["进度", "progress"],
+ ["开始时间", "start_time"],
+ ["结束时间", "stop_time"],
+ ["学分", "credit"],
+ ["类型", "type"],
+];
+
+interface CourseListProps {
+ courseList: Accessor
;
+}
+
+const CourseList = (props: CourseListProps) => {
+ return (
+
+ {props.courseList().map((course) => {
+ return (
+
+ {courseLabel.map(([labelText, field]) => {
+ return (
+
+ {labelText}: {course[field]}
+
+ );
+ })}
+
+ );
+ })}
+
+ );
+};
+
+export default CourseList;
diff --git a/src/components/dialog/Dialog.tsx b/src/components/dialog/Dialog.tsx
new file mode 100644
index 0000000..055db57
--- /dev/null
+++ b/src/components/dialog/Dialog.tsx
@@ -0,0 +1,86 @@
+import {
+ Show,
+ onCleanup,
+ onMount,
+ type Accessor,
+ type JSX,
+ type ParentComponent,
+} from "solid-js";
+import { Portal } from "solid-js/web";
+
+interface DialogProps {
+ open: Accessor;
+ onClose: () => void;
+ title?: string;
+ children: JSX.Element;
+ footer?: JSX.Element;
+ widthClass?: string;
+ closeOnOverlay?: boolean;
+}
+
+const Dialog: ParentComponent = (props) => {
+ const handleKeydown = (event: KeyboardEvent) => {
+ if (event.key === "Escape" && props.open()) {
+ props.onClose();
+ }
+ };
+
+ onMount(() => {
+ document.addEventListener("keydown", handleKeydown);
+ });
+
+ onCleanup(() => {
+ document.removeEventListener("keydown", handleKeydown);
+ });
+
+ const handleOverlayClick = () => {
+ if (props.closeOnOverlay !== false) {
+ props.onClose();
+ }
+ };
+
+ return (
+
+
+
+
+
+
event.stopPropagation()}
+ >
+
+
+ {props.title ?? "提示"}
+
+
+
+
+
+ {props.children}
+
+
+
+
+ {props.footer}
+
+
+
+
+
+
+ );
+};
+
+export default Dialog;
diff --git a/src/pages/accouts/Account.tsx b/src/pages/accouts/Account.tsx
index df66c0a..a0548f2 100644
--- a/src/pages/accouts/Account.tsx
+++ b/src/pages/accouts/Account.tsx
@@ -1,5 +1,572 @@
+import {
+ createEffect,
+ createMemo,
+ createSignal,
+ onCleanup,
+ onMount,
+} from "solid-js";
+import AccountSidebar from "~/components/account/AccountSidebar";
+import AddAccountDialog, {
+ type LoginForm,
+} from "~/components/account/AddAccountDialog";
+import CourseWorkspace from "~/components/account/CourseWorkspace";
+import {
+ loginApi,
+ logoutApi,
+ recordApi,
+ runStudyQueue,
+ type CourseKind,
+ type RecordType,
+} from "~/service/wk";
+import { setUnauthorizedHandler } from "~/service/http";
+import { accountStore, type AccountItem } from "~/store/account";
+import { getMergedHosts, settingsStore } from "~/store/settings";
+import type { CourseType } from "~/types/Course";
+
+const statusOptions: { label: string; value: CourseKind }[] = [
+ { label: "我的课程", value: "run" },
+ { label: "已结束", value: "finish" },
+ { label: "报名中", value: "sign" },
+];
+
+const recordTypeOptions: { label: string; value: RecordType }[] = [
+ { label: "课程", value: "" },
+ { label: "作业", value: "/work" },
+ { label: "考试", value: "/exam" },
+ { label: "讨论", value: "/discuss" },
+];
+
+const createDefaultForm = (host: string): LoginForm => ({
+ username: "",
+ password: "",
+ token: "",
+ status: "run",
+ host,
+});
+
+const stripHtml = (value: string) => value.replace(/<[^>]+>/g, "").trim();
+
+const parseDurationToSeconds = (value: string) => {
+ const parts = value.split(":").map(Number);
+ if (parts.length !== 3 || parts.some((part) => Number.isNaN(part))) {
+ return 0;
+ }
+
+ const [hours, minutes, seconds] = parts;
+ return hours * 3600 + minutes * 60 + seconds;
+};
+
const Account = () => {
- return <>Account>;
+ const [storeState, setStoreState] = createSignal(accountStore.getState());
+ const [settingsState, setSettingsState] = createSignal(
+ settingsStore.getState(),
+ );
+ const [showDialog, setShowDialog] = createSignal(false);
+ const [isSubmitting, setIsSubmitting] = createSignal(false);
+ const [loggingOutId, setLoggingOutId] = createSignal("");
+ const [isRefreshingAccount, setIsRefreshingAccount] = createSignal(false);
+ const [errorMessage, setErrorMessage] = createSignal("");
+ const [form, setForm] = createSignal(
+ createDefaultForm("cqcst.leykeji.com"),
+ );
+ const [recordsLoading, setRecordsLoading] = createSignal(false);
+ const [recordError, setRecordError] = createSignal("");
+ const [isRefreshingRecords, setIsRefreshingRecords] = createSignal(false);
+ const [isRefreshingLogs, setIsRefreshingLogs] = createSignal(false);
+ const [hasRestoredRecords, setHasRestoredRecords] = createSignal(false);
+
+ onMount(() => {
+ const unsubscribeAccount = accountStore.subscribe((state) => {
+ setStoreState(state);
+ });
+ const unsubscribeSettings = settingsStore.subscribe((state) => {
+ setSettingsState(state);
+ });
+ setUnauthorizedHandler(reloginBySession);
+
+ onCleanup(() => {
+ unsubscribeAccount();
+ unsubscribeSettings();
+ setUnauthorizedHandler(null);
+ });
+ });
+
+ const accounts = createMemo(() => storeState().accounts);
+ const selectedAccountId = createMemo(() => storeState().selectedAccountId);
+ const expandedAccountId = createMemo(() => storeState().expandedAccountId);
+ const selectedCourseId = createMemo(() => storeState().selectedCourseId);
+ const recordType = createMemo(() => storeState().recordType);
+ const records = createMemo(() => storeState().records);
+ const studyLogsMap = createMemo(() => storeState().studyLogsMap);
+ const runningStudyMap = createMemo(() => storeState().runningStudyMap);
+ const mergedHostOptions = createMemo(() =>
+ getMergedHosts(settingsState().localHosts, settingsState().remoteHosts),
+ );
+ const hostLabels = createMemo(
+ () =>
+ Object.fromEntries(
+ mergedHostOptions().map((item) => [item.host, item.label]),
+ ) as Record,
+ );
+ const defaultHost = createMemo(
+ () => mergedHostOptions()[0]?.host ?? "cqcst.leykeji.com",
+ );
+
+ const selectedAccount = createMemo(() => {
+ return accounts().find((item) => item.id === selectedAccountId()) ?? null;
+ });
+
+ const selectedCourseList = createMemo(() => {
+ return selectedAccount()?.courses ?? [];
+ });
+
+ const selectedCourse = createMemo(() => {
+ return (
+ selectedCourseList().find((item) => item.id === selectedCourseId()) ??
+ null
+ );
+ });
+
+ const isRunningStudy = createMemo(() => {
+ const account = selectedAccount();
+ if (!account) {
+ return false;
+ }
+
+ return !!runningStudyMap()[account.id];
+ });
+
+ const studyLogs = createMemo(() => {
+ const account = selectedAccount();
+ if (!account) {
+ return [];
+ }
+
+ return studyLogsMap()[account.id] ?? [];
+ });
+
+ const updateForm = (
+ key: K,
+ value: LoginForm[K],
+ ) => {
+ setForm((prev) => ({ ...prev, [key]: value }));
+ };
+
+ const appendStudyLog = (message: string, accountId?: string) => {
+ const targetAccountId = accountId ?? selectedAccount()?.id;
+ if (!targetAccountId) {
+ return;
+ }
+
+ accountStore.getState().appendStudyLog(targetAccountId, message);
+ };
+
+ const reloginBySession = async () => {
+ const sessionId = sessionStorage.getItem("session_id");
+ if (!sessionId) {
+ return false;
+ }
+
+ const target = accountStore
+ .getState()
+ .accounts.find((item) => item.sessionId === sessionId);
+
+ if (!target) {
+ return false;
+ }
+
+ try {
+ const res = await loginApi({
+ username: target.username,
+ password: target.auth.password,
+ token: target.auth.token,
+ status: target.status,
+ host: target.host,
+ });
+
+ accountStore.getState().upsertAccount({
+ ...target,
+ sessionId: res.data.session_id,
+ user: res.data.user,
+ courses: res.data.courses,
+ });
+ appendStudyLog(`会话失效,已重新登录:${target.user.name}`, target.id);
+ return true;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "重新登录失败";
+ setErrorMessage(message);
+ appendStudyLog(`重新登录失败:${message}`, target.id);
+ return false;
+ }
+ };
+
+ const applySessionId = (sessionId: string) => {
+ sessionStorage.setItem("session_id", sessionId);
+ };
+
+ const openDialog = () => {
+ setErrorMessage("");
+ setForm(createDefaultForm(defaultHost()));
+ setShowDialog(true);
+ };
+
+ const closeDialog = () => {
+ if (isSubmitting()) {
+ return;
+ }
+
+ setShowDialog(false);
+ setErrorMessage("");
+ };
+
+ const handleAddAccount = async () => {
+ const payload = form();
+ const hasAccount =
+ payload.username.trim() !== "" && payload.password.trim() !== "";
+ const hasCookie = payload.token.trim() !== "";
+
+ if (!hasAccount && !hasCookie) {
+ setErrorMessage("请填写账号和密码,或者填写 Cookie。");
+ return;
+ }
+
+ setIsSubmitting(true);
+ setErrorMessage("");
+
+ try {
+ const res = await loginApi({
+ username: payload.username.trim(),
+ password: payload.password.trim(),
+ token: payload.token.trim(),
+ status: payload.status,
+ host: payload.host,
+ });
+
+ const accountId = `${res.data.user.id}-${payload.host}-${payload.status}`;
+ const nextAccount: AccountItem = {
+ id: accountId,
+ username: payload.username.trim() || res.data.user.id,
+ host: payload.host,
+ status: payload.status,
+ sessionId: res.data.session_id,
+ auth: {
+ password: payload.password.trim(),
+ token: payload.token.trim(),
+ },
+ user: res.data.user,
+ courses: res.data.courses,
+ };
+
+ accountStore.getState().upsertAccount(nextAccount);
+ accountStore.getState().setSelectedCourseId(null);
+ accountStore.getState().setRecords([]);
+ setShowDialog(false);
+ setForm(createDefaultForm(defaultHost()));
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "登录失败,请检查输入信息。";
+ setErrorMessage(message);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const handleSelectAccount = (accountId: string) => {
+ accountStore.getState().setSelectedAccountId(accountId);
+ accountStore.getState().setSelectedCourseId(null);
+ accountStore.getState().setRecords([]);
+ setRecordError("");
+ };
+
+ const handleToggleExpand = (accountId: string) => {
+ const nextId = expandedAccountId() === accountId ? "" : accountId;
+ accountStore.getState().setExpandedAccountId(nextId);
+ };
+
+ const handleRefreshAccount = async () => {
+ const account = selectedAccount();
+ if (!account) {
+ setErrorMessage("请先选择账号。");
+ return;
+ }
+
+ setIsRefreshingAccount(true);
+ setErrorMessage("");
+
+ try {
+ const res = await loginApi({
+ username: account.username,
+ password: account.auth.password,
+ token: account.auth.token,
+ status: account.status,
+ host: account.host,
+ });
+
+ accountStore.getState().upsertAccount({
+ ...account,
+ sessionId: res.data.session_id,
+ user: res.data.user,
+ courses: res.data.courses,
+ });
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "刷新账号失败,请稍后重试。";
+ setErrorMessage(message);
+ } finally {
+ setIsRefreshingAccount(false);
+ }
+ };
+
+ const handleLogout = async (accountId: string) => {
+ setLoggingOutId(accountId);
+
+ try {
+ const target = accounts().find((item) => item.id === accountId);
+ if (!target) {
+ return;
+ }
+
+ applySessionId(target.sessionId);
+ await logoutApi();
+
+ accountStore.getState().removeAccount(accountId);
+ if (selectedAccountId() === accountId) {
+ accountStore.getState().setSelectedCourseId(null);
+ accountStore.getState().setRecords([]);
+ }
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "退出登录失败,请稍后重试。";
+ setErrorMessage(message);
+ } finally {
+ setLoggingOutId("");
+ }
+ };
+
+ const loadCourseRecords = async (
+ courseId: number,
+ nextRecordType = recordType(),
+ ) => {
+ const account = selectedAccount();
+ if (!account) {
+ return;
+ }
+
+ setRecordsLoading(true);
+ setIsRefreshingRecords(true);
+ setRecordError("");
+
+ try {
+ applySessionId(account.sessionId);
+ const res = await recordApi({
+ course_id: String(courseId),
+ page: 0,
+ record_type: nextRecordType,
+ });
+ accountStore.getState().setRecords(res.data.list);
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "获取记录失败,请稍后重试。";
+ setRecordError(message);
+ accountStore.getState().setRecords([]);
+ } finally {
+ setRecordsLoading(false);
+ setIsRefreshingRecords(false);
+ }
+ };
+
+ const handleSelectCourse = async (courseId: number) => {
+ accountStore.getState().setSelectedCourseId(courseId);
+ await loadCourseRecords(courseId);
+ };
+
+ const handleRefreshRecords = async () => {
+ const courseId = selectedCourseId();
+ if (!courseId) {
+ setRecordError("请先选择课程。");
+ return;
+ }
+
+ await loadCourseRecords(courseId);
+ };
+
+ const handleClearLogs = () => {
+ const account = selectedAccount();
+ if (!account) {
+ return;
+ }
+
+ accountStore.getState().clearStudyLogs(account.id);
+ };
+
+ const handleRefreshLogs = () => {
+ setIsRefreshingLogs(true);
+ appendStudyLog("手动刷新日志面板");
+ queueMicrotask(() => {
+ setIsRefreshingLogs(false);
+ });
+ };
+
+ const handleStartStudy = async () => {
+ const account = selectedAccount();
+ const course = selectedCourse();
+
+ if (!account || !course) {
+ setRecordError("请先选择账号和课程。");
+ return;
+ }
+
+ const queueItems = records()
+ .filter((item) => item.progress !== "1.00")
+ .map((item) => ({
+ nodeId: item.id,
+ name: item.name,
+ currentTime: Number(item.duration || 0),
+ totalTime: parseDurationToSeconds(item.videoDuration),
+ progress: item.progress,
+ completed: stripHtml(item.state) === "已学",
+ }));
+
+ try {
+ accountStore.getState().setAccountRunningStudy(account.id, true);
+ appendStudyLog(`开始刷课:${course.name}`, account.id);
+ await runStudyQueue({
+ accountId: account.id,
+ courseId: course.id,
+ intervalSeconds: 5,
+ items: queueItems,
+ isRunningStudy: () =>
+ !!accountStore.getState().runningStudyMap[account.id],
+ onLog: (message: string) => appendStudyLog(message, account.id),
+ });
+ if (accountStore.getState().runningStudyMap[account.id]) {
+ appendStudyLog(`刷课完成:${course.name}`, account.id);
+ }
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "刷课流程执行失败。";
+ appendStudyLog(`刷课失败:${message}`, account.id);
+ setRecordError(message);
+ } finally {
+ accountStore.getState().setAccountRunningStudy(account.id, false);
+ }
+ };
+
+ const handleStopStudy = () => {
+ const account = selectedAccount();
+ if (!account) {
+ return;
+ }
+
+ accountStore.getState().setAccountRunningStudy(account.id, false);
+ appendStudyLog(`已发送停止刷课指令:${account.user.name}`, account.id);
+ };
+
+ createEffect(() => {
+ const courseId = selectedCourseId();
+ const account = selectedAccount();
+ const type = recordType();
+
+ if (!hasRestoredRecords()) {
+ setHasRestoredRecords(true);
+
+ if (courseId && account && records().length > 0) {
+ return;
+ }
+ }
+
+ if (!courseId || !account) {
+ return;
+ }
+
+ void loadCourseRecords(courseId, type);
+ });
+
+ return (
+
+
+
+
+ Account Center
+
+
账号管理
+
+ 管理账号登录、课程记录与运行日志
+
+
+
+
+
+
+
+
void handleRefreshAccount()}
+ onSelectAccount={handleSelectAccount}
+ onToggleExpand={handleToggleExpand}
+ onLogout={(accountId) => void handleLogout(accountId)}
+ />
+
+ void handleSelectCourse(courseId)}
+ onRefreshRecords={() => void handleRefreshRecords()}
+ onChangeRecordType={(value) =>
+ accountStore.getState().setRecordType(value)
+ }
+ onStartStudy={() => void handleStartStudy()}
+ onStopStudy={handleStopStudy}
+ onRefreshLogs={handleRefreshLogs}
+ onClearLogs={handleClearLogs}
+ renderRecordState={stripHtml}
+ />
+
+
+
void handleAddAccount()}
+ isSubmitting={isSubmitting()}
+ errorMessage={errorMessage()}
+ form={form()}
+ statusOptions={statusOptions}
+ hostOptions={mergedHostOptions().map((item) => ({
+ label: item.label,
+ host: item.host,
+ }))}
+ updateForm={updateForm}
+ />
+
+ );
};
export default Account;
diff --git a/src/pages/settings/Setting.tsx b/src/pages/settings/Setting.tsx
index ea4d030..574c272 100644
--- a/src/pages/settings/Setting.tsx
+++ b/src/pages/settings/Setting.tsx
@@ -1,5 +1,439 @@
+import { createMemo, createSignal, For, onCleanup, onMount } from "solid-js";
+import { hostApi } from "~/service/wk";
+import { getMergedHosts, settingsStore } from "~/store/settings";
+
const Setting = () => {
- return <>Setting>;
+ const [state, setState] = createSignal(settingsStore.getState());
+ const [hostLabel, setHostLabel] = createSignal("");
+ const [hostValue, setHostValue] = createSignal("");
+ const [isLoadingRemoteHosts, setIsLoadingRemoteHosts] = createSignal(false);
+ const [hostError, setHostError] = createSignal("");
+
+ onMount(() => {
+ const unsubscribe = settingsStore.subscribe((nextState) => {
+ setState(nextState);
+ });
+
+ onCleanup(() => {
+ unsubscribe();
+ });
+ });
+
+ const mergedHosts = createMemo(() =>
+ getMergedHosts(state().localHosts, state().remoteHosts),
+ );
+
+ const loadRemoteHosts = async () => {
+ setIsLoadingRemoteHosts(true);
+ setHostError("");
+
+ try {
+ const res = await hostApi();
+ settingsStore.getState().setRemoteHosts(
+ res.data.list.map((item) => ({
+ label: item.name,
+ host: item.host,
+ })),
+ );
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "获取远程 Host 失败。";
+ setHostError(message);
+ } finally {
+ setIsLoadingRemoteHosts(false);
+ }
+ };
+
+ const addLocalHost = () => {
+ if (!hostLabel().trim() || !hostValue().trim()) {
+ return;
+ }
+
+ settingsStore.getState().addLocalHost({
+ label: hostLabel().trim(),
+ host: hostValue().trim(),
+ });
+ setHostLabel("");
+ setHostValue("");
+ };
+
+ onMount(() => {
+ if (state().remoteHosts.length === 0) {
+ void loadRemoteHosts();
+ }
+ });
+
+ return (
+
+
+
+
+ Settings Center
+
+
偏好设置
+
+ 管理本地缓存、界面偏好和 Host 来源策略
+
+
+
+
+
本地优先
+
+ 远端 Host 获取后会与本地列表合并去重
+
+
+
+
+
+
+
+
+
+
本地数据
+
+ 控制哪些数据会保存在本地,以及如何清理缓存
+
+
+
+
+
+
+
+
+
+
账号缓存
+
+ 保留账号、课程和登录相关信息
+
+
+
+ settingsStore
+ .getState()
+ .setPersistSection(
+ "accounts",
+ event.currentTarget.checked,
+ )
+ }
+ />
+
+
+
+
+
+
+
+
记录缓存
+
+ 保留当前课程记录和筛选类型
+
+
+
+ settingsStore
+ .getState()
+ .setPersistSection(
+ "records",
+ event.currentTarget.checked,
+ )
+ }
+ />
+
+
+
+
+
+
+
+
日志缓存
+
+ 保留任务日志历史,刷新后继续查看
+
+
+
+ settingsStore
+ .getState()
+ .setPersistSection("logs", event.currentTarget.checked)
+ }
+ />
+
+
+
+
+
+
+
+
+
界面偏好
+
+ 根据你的使用习惯调整侧栏、日志和展示密度
+
+
+
+
+
+
+
+
日志自动滚动
+
+ 新日志出现时自动滚动到最底部
+
+
+
+ settingsStore
+ .getState()
+ .setAutoScrollLogs(event.currentTarget.checked)
+ }
+ />
+
+
+
+
+
+
+
显示日志时间戳
+
+ 后续日志输出可以追加格式化时间
+
+
+
+ settingsStore
+ .getState()
+ .setShowLogTimestamps(event.currentTarget.checked)
+ }
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Host 配置策略
+
+ 远端 Host 后续通过接口请求,本地 Host
+ 手动添加,最终合并并去重,优先保留本地配置。
+
+
+
+
+
+
添加本地 Host
+
+
+
+ setHostLabel(event.currentTarget.value)}
+ placeholder="名称,如:校内测试"
+ />
+ setHostValue(event.currentTarget.value)}
+ placeholder="Host,如:example.com"
+ />
+
+
+
+ {hostError() ? (
+
+ {hostError()}
+
+ ) : null}
+
+
+
+
+
本地 Host
+
+
+ {(item) => (
+
+
+
+
+ {item.label}
+
+
+ {item.host}
+
+
+
+
+
+ )}
+
+
+
+
+
+
合并结果
+
+
+ {(item) => (
+
+
{item.label}
+
{item.host}
+
+ 来源:{item.source === "local" ? "本地优先" : "远端"}
+
+
+ )}
+
+
+
+
+
+
+
+
+ );
};
export default Setting;
diff --git a/src/service/http.ts b/src/service/http.ts
new file mode 100644
index 0000000..93c7c55
--- /dev/null
+++ b/src/service/http.ts
@@ -0,0 +1,67 @@
+import axios from "axios";
+import type { AxiosRequestConfig, InternalAxiosRequestConfig } from "axios";
+
+type UnauthorizedHandler = () => Promise;
+
+let unauthorizedHandler: UnauthorizedHandler | null = null;
+
+export const setUnauthorizedHandler = (handler: UnauthorizedHandler | null) => {
+ unauthorizedHandler = handler;
+};
+
+const instance = axios.create({
+ baseURL: "http://127.0.0.1:8080",
+});
+
+instance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
+ const sessionID = sessionStorage.getItem("session_id");
+ if (sessionID) {
+ config.headers["X-Session-Id"] = sessionID;
+ }
+ return config;
+});
+
+instance.interceptors.response.use(
+ (response) => response.data,
+ async (error) => {
+ const config = error.config as
+ | (AxiosRequestConfig & { _retry?: boolean })
+ | undefined;
+ const status = error.response?.status;
+ const url = config?.url ?? "";
+
+ if (
+ status === 401 &&
+ config &&
+ !config._retry &&
+ unauthorizedHandler &&
+ !url.includes("/api/login")
+ ) {
+ config._retry = true;
+ const ok = await unauthorizedHandler();
+
+ if (ok) {
+ return instance.request(config);
+ }
+ }
+
+ return Promise.reject(error);
+ },
+);
+
+const http = {
+ get(url: string, config?: AxiosRequestConfig) {
+ return instance.get(url, config);
+ },
+ post(url: string, data?: unknown, config?: AxiosRequestConfig) {
+ return instance.post(url, data, config);
+ },
+ put(url: string, data?: unknown, config?: AxiosRequestConfig) {
+ return instance.put(url, data, config);
+ },
+ delete(url: string, config?: AxiosRequestConfig) {
+ return instance.delete(url, config);
+ },
+};
+
+export default http;
diff --git a/src/service/wk.ts b/src/service/wk.ts
new file mode 100644
index 0000000..4581c56
--- /dev/null
+++ b/src/service/wk.ts
@@ -0,0 +1,205 @@
+import type { Accessor } from "solid-js";
+import http from "~/service/http";
+import type { CourseType } from "~/types/Course";
+import type { userInfoType } from "~/types/Userinfo";
+
+export type CourseKind = "run" | "finish" | "sign";
+
+export type RecordType = "" | "/work" | "/exam" | "/discuss";
+
+export type StudyStatus = 1 | 2 | 3;
+
+type ApiResponse = {
+ code: number;
+ message: string;
+ data: T;
+};
+
+export type LoginReq = {
+ username: string;
+ password: string;
+ token: string;
+ status: CourseKind;
+ host: string;
+};
+
+export type LoginData = {
+ courses: CourseType[];
+ session_id: string;
+ user: userInfoType;
+};
+
+export type LoginRes = ApiResponse;
+
+export type RecordItem = {
+ id: string;
+ name: string;
+ type: string | null;
+ chapterId: string;
+ courseId: string;
+ videoFile: string;
+ videoDuration: string;
+ votingPath: string | null;
+ tabVideo: string;
+ tabFile: string;
+ tabVote: string;
+ tabWork: string;
+ tabExam: string;
+ sort: string;
+ videoMode: string;
+ localFile: string;
+ schoolId: string;
+ lock: string;
+ unlockTime: string;
+ bid: string;
+ duration: string;
+ progress: string;
+ state: string;
+ viewCount: string;
+ finalTime: string;
+ error: number;
+ errorMessage: string;
+ beginTime: string;
+ viewedDuration: string;
+ url: string;
+};
+
+export type PageInfo = {
+ keyName: string;
+ page: number;
+ pageCount: number;
+ recordsCount: number;
+ onlyCount: number;
+ pageSize: number;
+};
+
+export type RecordData = {
+ list: RecordItem[];
+ page_info: PageInfo;
+};
+
+export type RecordRes = ApiResponse;
+
+export type StudyData = {
+ state: number;
+ studyId: number;
+ status: boolean;
+ msg: string;
+};
+
+export type StudyRes = ApiResponse;
+
+export type LogoutRes = {
+ code: number;
+ message: string;
+};
+
+export type HostItem = {
+ host: string;
+ name: string;
+};
+
+export type HostRes = ApiResponse<{
+ list: HostItem[];
+}>;
+
+export type RecordReq = {
+ course_id: string;
+ page: number;
+ record_type?: RecordType;
+};
+
+export type StudyReq = {
+ node_id: string;
+ study_id: string;
+ study_time: string;
+ status: StudyStatus;
+};
+
+export type StudyRunnerItem = {
+ nodeId: string;
+ name: string;
+ currentTime: number;
+ totalTime: number;
+ progress: string;
+ completed: boolean;
+};
+
+export type StudyRunnerPayload = {
+ accountId: string;
+ courseId: number;
+ intervalSeconds: number;
+ items: StudyRunnerItem[];
+ isRunningStudy: Accessor;
+ onLog?: (message: string) => void;
+};
+
+export const loginApi = async (payload: LoginReq) => {
+ const res = await http.post("/api/login", payload);
+
+ if (res.data?.session_id) {
+ sessionStorage.setItem("session_id", res.data.session_id);
+ }
+
+ return res;
+};
+
+export const recordApi = async (payload: RecordReq) => {
+ return await http.post("/api/v2/record", payload);
+};
+
+export const studyApi = async (payload: StudyReq) => {
+ return await http.post("/api/v2/study", payload);
+};
+
+const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms));
+
+export const runStudyQueue = async (_payload: StudyRunnerPayload) => {
+ const stopFlag = _payload.isRunningStudy;
+ for (const item of _payload.items) {
+ let currentTime = 0;
+ let count = 0;
+ let study_id = 0;
+
+ while (currentTime <= item.totalTime) {
+ if (!stopFlag()) {
+ _payload.onLog?.("⛔ 已手动停止");
+ return;
+ }
+ const message = `[${item.name}]: ${currentTime}/${item.totalTime}`;
+ console.log(message);
+ _payload.onLog?.(message);
+
+ try {
+ const resp = await studyApi({
+ node_id: item.nodeId,
+ study_id: String(study_id),
+ study_time: String(currentTime),
+ status: count === 0 ? 1 : currentTime >= item.totalTime ? 3 : 2,
+ });
+ study_id = resp.data.studyId;
+
+ if (currentTime === item.totalTime) break;
+
+ currentTime = Math.min(currentTime + 5, item.totalTime);
+ count++;
+ } catch (error) {
+ const errorMessage = `请求失败: ${error instanceof Error ? error.message : String(error)}`;
+ console.log(errorMessage);
+ _payload.onLog?.(errorMessage);
+ }
+
+ await sleep(5000);
+ }
+ }
+};
+
+export const logoutApi = async () => {
+ const res = await http.post("/api/v2/logout");
+ sessionStorage.removeItem("session_id");
+ return res;
+};
+
+export const hostApi = async () => {
+ return await http.get("/api/v1/host");
+};
diff --git a/src/store/account.ts b/src/store/account.ts
new file mode 100644
index 0000000..85de2f4
--- /dev/null
+++ b/src/store/account.ts
@@ -0,0 +1,140 @@
+import { createStore } from "zustand/vanilla";
+import { persist, createJSONStorage } from "zustand/middleware";
+import type { CourseKind, RecordItem, RecordType } from "~/service/wk";
+import type { CourseType } from "~/types/Course";
+import type { userInfoType } from "~/types/Userinfo";
+
+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[];
+};
+
+type AccountState = {
+ accounts: AccountItem[];
+ selectedAccountId: string;
+ expandedAccountId: string;
+ selectedCourseId: number | null;
+ recordType: RecordType;
+ records: RecordItem[];
+ studyLogsMap: Record;
+ runningStudyMap: Record;
+ setSelectedAccountId: (accountId: string) => void;
+ setExpandedAccountId: (accountId: string) => void;
+ setSelectedCourseId: (courseId: number | null) => void;
+ setRecordType: (recordType: RecordType) => void;
+ setRecords: (records: RecordItem[]) => void;
+ setAccountRunningStudy: (accountId: string, value: boolean) => void;
+ appendStudyLog: (accountId: string, message: string) => void;
+ clearStudyLogs: (accountId: string) => void;
+ upsertAccount: (account: AccountItem) => void;
+ removeAccount: (accountId: string) => void;
+};
+
+export const accountStore = createStore()(
+ persist(
+ (set) => ({
+ accounts: [],
+ selectedAccountId: "",
+ expandedAccountId: "",
+ selectedCourseId: null,
+ recordType: "",
+ records: [],
+ studyLogsMap: {},
+ runningStudyMap: {},
+ setSelectedAccountId: (accountId) =>
+ set({ selectedAccountId: accountId }),
+ setExpandedAccountId: (accountId) =>
+ set({ expandedAccountId: accountId }),
+ setSelectedCourseId: (courseId) => set({ selectedCourseId: courseId }),
+ setRecordType: (recordType) => set({ recordType }),
+ setRecords: (records) => set({ records }),
+ setAccountRunningStudy: (accountId, value) =>
+ set((state) => ({
+ runningStudyMap: {
+ ...state.runningStudyMap,
+ [accountId]: value,
+ },
+ })),
+ appendStudyLog: (accountId, message) =>
+ set((state) => ({
+ studyLogsMap: {
+ ...state.studyLogsMap,
+ [accountId]: [...(state.studyLogsMap[accountId] ?? []), message],
+ },
+ })),
+ clearStudyLogs: (accountId) =>
+ set((state) => ({
+ studyLogsMap: {
+ ...state.studyLogsMap,
+ [accountId]: [],
+ },
+ })),
+ upsertAccount: (account) =>
+ set((state) => ({
+ accounts: [
+ account,
+ ...state.accounts.filter((item) => item.id !== account.id),
+ ],
+ selectedAccountId: account.id,
+ expandedAccountId: account.id,
+ })),
+ 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,
+ studyLogsMap: Object.fromEntries(
+ Object.entries(state.studyLogsMap).filter(
+ ([key]) => key !== accountId,
+ ),
+ ),
+ runningStudyMap: Object.fromEntries(
+ Object.entries(state.runningStudyMap).filter(
+ ([key]) => key !== accountId,
+ ),
+ ),
+ };
+ }),
+ }),
+ {
+ name: "account-storage",
+ storage: createJSONStorage(() => localStorage),
+ partialize: (state) => ({
+ accounts: state.accounts,
+ selectedAccountId: state.selectedAccountId,
+ expandedAccountId: state.expandedAccountId,
+ selectedCourseId: state.selectedCourseId,
+ recordType: state.recordType,
+ records: state.records,
+ studyLogsMap: state.studyLogsMap,
+ runningStudyMap: state.runningStudyMap,
+ }),
+ },
+ ),
+);
diff --git a/src/store/settings.ts b/src/store/settings.ts
new file mode 100644
index 0000000..834f804
--- /dev/null
+++ b/src/store/settings.ts
@@ -0,0 +1,127 @@
+import { createStore } from "zustand/vanilla";
+import { createJSONStorage, persist } from "zustand/middleware";
+
+export type HostOption = {
+ label: string;
+ host: string;
+ source: "local" | "remote";
+};
+
+type CacheSection = "accounts" | "records" | "logs";
+type DensityMode = "comfortable" | "compact";
+
+type SettingsState = {
+ persistAccounts: boolean;
+ persistRecords: boolean;
+ persistLogs: boolean;
+ autoScrollLogs: boolean;
+ showLogTimestamps: boolean;
+ densityMode: DensityMode;
+ logFontSize: number;
+ sidebarWidth: number;
+ localHosts: HostOption[];
+ remoteHosts: HostOption[];
+ setPersistSection: (section: CacheSection, value: boolean) => void;
+ clearPersistedSection: (section: CacheSection) => void;
+ clearAllPersistedData: () => void;
+ setAutoScrollLogs: (value: boolean) => void;
+ setShowLogTimestamps: (value: boolean) => void;
+ setDensityMode: (value: DensityMode) => void;
+ setLogFontSize: (value: number) => void;
+ setSidebarWidth: (value: number) => void;
+ addLocalHost: (host: Omit) => void;
+ removeLocalHost: (host: string) => void;
+ setRemoteHosts: (hosts: Omit[]) => void;
+};
+
+const accountStorageKey = "account-storage";
+
+const uniqueHosts = (hosts: HostOption[]) => {
+ const map = new Map();
+
+ for (const item of hosts) {
+ if (!map.has(item.host)) {
+ map.set(item.host, item);
+ }
+ }
+
+ return Array.from(map.values());
+};
+
+export const getMergedHosts = (
+ localHosts: HostOption[],
+ remoteHosts: HostOption[],
+) => {
+ return uniqueHosts([...localHosts, ...remoteHosts]);
+};
+
+export const settingsStore = createStore()(
+ persist(
+ (set, get) => ({
+ persistAccounts: true,
+ persistRecords: true,
+ persistLogs: true,
+ autoScrollLogs: true,
+ showLogTimestamps: false,
+ densityMode: "comfortable",
+ logFontSize: 12,
+ sidebarWidth: 320,
+ localHosts: [
+ { label: "默认站点", host: "cqcst.leykeji.com", source: "local" },
+ ],
+ remoteHosts: [],
+ setPersistSection: (section, value) => {
+ if (section === "accounts") set({ persistAccounts: value });
+ if (section === "records") set({ persistRecords: value });
+ if (section === "logs") set({ persistLogs: value });
+ },
+ clearPersistedSection: (section) => {
+ if (section === "accounts") {
+ localStorage.removeItem(accountStorageKey);
+ }
+
+ if (section === "records") {
+ set({ persistRecords: false });
+ queueMicrotask(() => set({ persistRecords: true }));
+ }
+
+ if (section === "logs") {
+ set({ persistLogs: false });
+ queueMicrotask(() => set({ persistLogs: true }));
+ }
+ },
+ clearAllPersistedData: () => {
+ localStorage.removeItem(accountStorageKey);
+ get().clearPersistedSection("records");
+ get().clearPersistedSection("logs");
+ },
+ setAutoScrollLogs: (value) => set({ autoScrollLogs: value }),
+ setShowLogTimestamps: (value) => set({ showLogTimestamps: value }),
+ setDensityMode: (value) => set({ densityMode: value }),
+ setLogFontSize: (value) => set({ logFontSize: value }),
+ setSidebarWidth: (value) => set({ sidebarWidth: value }),
+ addLocalHost: (host) =>
+ set((state) => ({
+ localHosts: uniqueHosts([
+ { ...host, source: "local" },
+ ...state.localHosts,
+ ...state.remoteHosts,
+ ]).filter((item) => item.source === "local"),
+ })),
+ removeLocalHost: (host) =>
+ set((state) => ({
+ localHosts: state.localHosts.filter((item) => item.host !== host),
+ })),
+ setRemoteHosts: (hosts) =>
+ set({
+ remoteHosts: uniqueHosts(
+ hosts.map((item) => ({ ...item, source: "remote" as const })),
+ ),
+ }),
+ }),
+ {
+ name: "settings-storage",
+ storage: createJSONStorage(() => localStorage),
+ },
+ ),
+);
diff --git a/src/types/Course.ts b/src/types/Course.ts
new file mode 100644
index 0000000..00c7ca7
--- /dev/null
+++ b/src/types/Course.ts
@@ -0,0 +1,10 @@
+export type CourseType = {
+ name: string;
+ id: number;
+ teacher: string;
+ progress: string;
+ start_time: string;
+ stop_time: string;
+ credit: number;
+ type: string;
+};
diff --git a/src/types/Userinfo.ts b/src/types/Userinfo.ts
new file mode 100644
index 0000000..78adeee
--- /dev/null
+++ b/src/types/Userinfo.ts
@@ -0,0 +1,7 @@
+export type userInfoType = {
+ id: string;
+ name: string;
+ dept: string;
+ class: string;
+ gender: string;
+};
diff --git a/tsconfig.app.json b/tsconfig.app.json
index 4de71f3..a4cc298 100644
--- a/tsconfig.app.json
+++ b/tsconfig.app.json
@@ -1,5 +1,10 @@
{
"compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "~/*": ["src/*"]
+ },
+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
diff --git a/vite.config.ts b/vite.config.ts
index c65931b..ba2b265 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,7 +1,13 @@
import { defineConfig } from "vite";
import solid from "vite-plugin-solid";
import tailwindcss from "@tailwindcss/vite";
+import path from "path";
export default defineConfig({
plugins: [solid(), tailwindcss()],
+ resolve: {
+ alias: {
+ "~": path.resolve(__dirname, "src"),
+ },
+ },
});