feat: build account dashboard and settings workspace
This commit is contained in:
@@ -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<LoginForm>(
|
||||
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<string, string>,
|
||||
);
|
||||
const defaultHost = createMemo(
|
||||
() => mergedHostOptions()[0]?.host ?? "cqcst.leykeji.com",
|
||||
);
|
||||
|
||||
const selectedAccount = createMemo(() => {
|
||||
return accounts().find((item) => item.id === selectedAccountId()) ?? null;
|
||||
});
|
||||
|
||||
const selectedCourseList = createMemo<CourseType[]>(() => {
|
||||
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 = <K extends keyof LoginForm>(
|
||||
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 (
|
||||
<div class="flex h-full min-h-0 flex-col overflow-hidden">
|
||||
<div class="flex shrink-0 items-center justify-between gap-4 rounded-[28px] border border-white/80 bg-[linear-gradient(135deg,_rgba(236,254,255,0.95),_rgba(255,255,255,0.92))] px-5 py-4 shadow-[0_18px_45px_-28px_rgba(8,145,178,0.35)]">
|
||||
<div>
|
||||
<p class="text-xs font-medium tracking-[0.26em] text-cyan-700/75 uppercase">
|
||||
Account Center
|
||||
</p>
|
||||
<h1 class="mt-2 text-2xl font-semibold text-zinc-900">账号管理</h1>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
管理账号登录、课程记录与运行日志
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="rounded-2xl bg-[linear-gradient(135deg,_#06b6d4,_#14b8a6)] px-4 py-3 text-sm font-medium text-white shadow-lg shadow-cyan-500/20 transition hover:translate-y-[-1px] hover:shadow-cyan-500/30"
|
||||
onClick={openDialog}
|
||||
>
|
||||
添加账号
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex min-h-0 flex-1 flex-col gap-4 xl:flex-row">
|
||||
<AccountSidebar
|
||||
accounts={accounts()}
|
||||
selectedAccountId={selectedAccountId()}
|
||||
expandedAccountId={expandedAccountId()}
|
||||
statusOptions={statusOptions}
|
||||
hostLabels={hostLabels()}
|
||||
isRefreshingAccount={isRefreshingAccount()}
|
||||
loggingOutId={loggingOutId()}
|
||||
densityMode={settingsState().densityMode}
|
||||
sidebarWidth={settingsState().sidebarWidth}
|
||||
onRefreshAccount={() => void handleRefreshAccount()}
|
||||
onSelectAccount={handleSelectAccount}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
onLogout={(accountId) => void handleLogout(accountId)}
|
||||
/>
|
||||
|
||||
<CourseWorkspace
|
||||
selectedAccount={selectedAccount()}
|
||||
selectedCourseList={selectedCourseList()}
|
||||
selectedCourseId={selectedCourseId()}
|
||||
selectedCourse={selectedCourse()}
|
||||
recordType={recordType()}
|
||||
recordTypeOptions={recordTypeOptions}
|
||||
records={records()}
|
||||
studyLogs={studyLogs()}
|
||||
recordsLoading={recordsLoading()}
|
||||
recordError={recordError()}
|
||||
isRefreshingRecords={isRefreshingRecords()}
|
||||
isRunningStudy={isRunningStudy()}
|
||||
isRefreshingLogs={isRefreshingLogs()}
|
||||
autoScrollLogs={settingsState().autoScrollLogs}
|
||||
showLogTimestamps={settingsState().showLogTimestamps}
|
||||
densityMode={settingsState().densityMode}
|
||||
logFontSize={settingsState().logFontSize}
|
||||
onSelectCourse={(courseId) => void handleSelectCourse(courseId)}
|
||||
onRefreshRecords={() => void handleRefreshRecords()}
|
||||
onChangeRecordType={(value) =>
|
||||
accountStore.getState().setRecordType(value)
|
||||
}
|
||||
onStartStudy={() => void handleStartStudy()}
|
||||
onStopStudy={handleStopStudy}
|
||||
onRefreshLogs={handleRefreshLogs}
|
||||
onClearLogs={handleClearLogs}
|
||||
renderRecordState={stripHtml}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AddAccountDialog
|
||||
open={showDialog}
|
||||
onClose={closeDialog}
|
||||
onSubmit={() => void handleAddAccount()}
|
||||
isSubmitting={isSubmitting()}
|
||||
errorMessage={errorMessage()}
|
||||
form={form()}
|
||||
statusOptions={statusOptions}
|
||||
hostOptions={mergedHostOptions().map((item) => ({
|
||||
label: item.label,
|
||||
host: item.host,
|
||||
}))}
|
||||
updateForm={updateForm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Account;
|
||||
|
||||
@@ -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 (
|
||||
<div class="flex h-full min-h-0 flex-col overflow-hidden">
|
||||
<div class="flex shrink-0 items-center justify-between gap-4 rounded-[28px] border border-white/80 bg-[linear-gradient(135deg,_rgba(255,255,255,0.92),_rgba(240,249,255,0.96))] px-5 py-4 shadow-[0_18px_45px_-28px_rgba(15,23,42,0.18)]">
|
||||
<div>
|
||||
<p class="text-xs font-medium tracking-[0.26em] text-cyan-700/75 uppercase">
|
||||
Settings Center
|
||||
</p>
|
||||
<h1 class="mt-2 text-2xl font-semibold text-zinc-900">偏好设置</h1>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
管理本地缓存、界面偏好和 Host 来源策略
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white/90 px-4 py-3 text-right shadow-sm">
|
||||
<p class="text-sm font-medium text-zinc-800">本地优先</p>
|
||||
<p class="mt-1 text-xs text-zinc-500">
|
||||
远端 Host 获取后会与本地列表合并去重
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid min-h-0 flex-1 gap-4 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
|
||||
<section class="flex min-h-0 flex-col gap-4 overflow-y-auto pr-1">
|
||||
<div class="rounded-[28px] border border-white/80 bg-white/85 p-5 shadow-[0_18px_50px_-28px_rgba(15,23,42,0.25)]">
|
||||
<div class="flex items-center justify-between gap-4 border-b border-zinc-200 pb-4">
|
||||
<div>
|
||||
<p class="text-lg font-semibold text-zinc-900">本地数据</p>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
控制哪些数据会保存在本地,以及如何清理缓存
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-600 transition hover:bg-rose-100"
|
||||
onClick={() => settingsStore.getState().clearAllPersistedData()}
|
||||
>
|
||||
清空全部缓存
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-4">
|
||||
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="font-medium text-zinc-900">账号缓存</p>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
保留账号、课程和登录相关信息
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state().persistAccounts}
|
||||
onChange={(event) =>
|
||||
settingsStore
|
||||
.getState()
|
||||
.setPersistSection(
|
||||
"accounts",
|
||||
event.currentTarget.checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700 transition hover:bg-zinc-100"
|
||||
onClick={() =>
|
||||
settingsStore.getState().clearPersistedSection("accounts")
|
||||
}
|
||||
>
|
||||
清空账号缓存
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="font-medium text-zinc-900">记录缓存</p>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
保留当前课程记录和筛选类型
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state().persistRecords}
|
||||
onChange={(event) =>
|
||||
settingsStore
|
||||
.getState()
|
||||
.setPersistSection(
|
||||
"records",
|
||||
event.currentTarget.checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700 transition hover:bg-zinc-100"
|
||||
onClick={() =>
|
||||
settingsStore.getState().clearPersistedSection("records")
|
||||
}
|
||||
>
|
||||
清空记录缓存
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="font-medium text-zinc-900">日志缓存</p>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
保留任务日志历史,刷新后继续查看
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state().persistLogs}
|
||||
onChange={(event) =>
|
||||
settingsStore
|
||||
.getState()
|
||||
.setPersistSection("logs", event.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700 transition hover:bg-zinc-100"
|
||||
onClick={() =>
|
||||
settingsStore.getState().clearPersistedSection("logs")
|
||||
}
|
||||
>
|
||||
清空日志缓存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-[28px] border border-white/80 bg-white/85 p-5 shadow-[0_18px_50px_-28px_rgba(15,23,42,0.25)]">
|
||||
<div class="border-b border-zinc-200 pb-4">
|
||||
<p class="text-lg font-semibold text-zinc-900">界面偏好</p>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
根据你的使用习惯调整侧栏、日志和展示密度
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-4">
|
||||
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="font-medium text-zinc-900">日志自动滚动</p>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
新日志出现时自动滚动到最底部
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state().autoScrollLogs}
|
||||
onChange={(event) =>
|
||||
settingsStore
|
||||
.getState()
|
||||
.setAutoScrollLogs(event.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="font-medium text-zinc-900">显示日志时间戳</p>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
后续日志输出可以追加格式化时间
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state().showLogTimestamps}
|
||||
onChange={(event) =>
|
||||
settingsStore
|
||||
.getState()
|
||||
.setShowLogTimestamps(event.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
|
||||
<label class="block">
|
||||
<p class="font-medium text-zinc-900">界面密度</p>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
选择更舒适或更紧凑的展示方式
|
||||
</p>
|
||||
<select
|
||||
class="mt-3 w-full rounded-xl border border-zinc-200 bg-white px-4 py-3 transition outline-none focus:border-cyan-400"
|
||||
value={state().densityMode}
|
||||
onChange={(event) =>
|
||||
settingsStore
|
||||
.getState()
|
||||
.setDensityMode(
|
||||
event.currentTarget.value as
|
||||
| "comfortable"
|
||||
| "compact",
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="comfortable">舒适</option>
|
||||
<option value="compact">紧凑</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
|
||||
<label class="block">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="font-medium text-zinc-900">日志字号</p>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
当前:{state().logFontSize}px
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
class="mt-4 w-full"
|
||||
type="range"
|
||||
min="11"
|
||||
max="16"
|
||||
value={state().logFontSize}
|
||||
onInput={(event) =>
|
||||
settingsStore
|
||||
.getState()
|
||||
.setLogFontSize(Number(event.currentTarget.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
|
||||
<label class="block">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="font-medium text-zinc-900">侧栏宽度</p>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
当前:{state().sidebarWidth}px
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
class="mt-4 w-full"
|
||||
type="range"
|
||||
min="280"
|
||||
max="380"
|
||||
step="10"
|
||||
value={state().sidebarWidth}
|
||||
onInput={(event) =>
|
||||
settingsStore
|
||||
.getState()
|
||||
.setSidebarWidth(Number(event.currentTarget.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="flex min-h-0 flex-col gap-4 overflow-y-auto pr-1">
|
||||
<div class="rounded-[28px] border border-white/80 bg-white/85 p-5 shadow-[0_18px_50px_-28px_rgba(15,23,42,0.25)]">
|
||||
<div class="border-b border-zinc-200 pb-4">
|
||||
<p class="text-lg font-semibold text-zinc-900">Host 配置策略</p>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
远端 Host 后续通过接口请求,本地 Host
|
||||
手动添加,最终合并并去重,优先保留本地配置。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="font-medium text-zinc-900">添加本地 Host</p>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-xl border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700 transition hover:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={isLoadingRemoteHosts()}
|
||||
onClick={() => void loadRemoteHosts()}
|
||||
>
|
||||
{isLoadingRemoteHosts() ? "获取中..." : "获取远程 Host"}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-3 grid gap-3 md:grid-cols-2">
|
||||
<input
|
||||
class="rounded-xl border border-zinc-200 bg-white px-4 py-3 transition outline-none focus:border-cyan-400"
|
||||
value={hostLabel()}
|
||||
onInput={(event) => setHostLabel(event.currentTarget.value)}
|
||||
placeholder="名称,如:校内测试"
|
||||
/>
|
||||
<input
|
||||
class="rounded-xl border border-zinc-200 bg-white px-4 py-3 transition outline-none focus:border-cyan-400"
|
||||
value={hostValue()}
|
||||
onInput={(event) => setHostValue(event.currentTarget.value)}
|
||||
placeholder="Host,如:example.com"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 rounded-xl bg-cyan-500 px-4 py-2 text-sm text-white transition hover:bg-cyan-600"
|
||||
onClick={addLocalHost}
|
||||
>
|
||||
添加本地 Host
|
||||
</button>
|
||||
|
||||
{hostError() ? (
|
||||
<div class="mt-3 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-600">
|
||||
{hostError()}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-4 xl:grid-cols-2">
|
||||
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
|
||||
<p class="font-medium text-zinc-900">本地 Host</p>
|
||||
<div class="mt-3 flex flex-col gap-3">
|
||||
<For each={state().localHosts}>
|
||||
{(item) => (
|
||||
<div class="rounded-xl border border-zinc-200 bg-white px-4 py-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="font-medium text-zinc-900">
|
||||
{item.label}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
{item.host}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-rose-200 px-3 py-1 text-xs text-rose-600 transition hover:bg-rose-50"
|
||||
onClick={() =>
|
||||
settingsStore
|
||||
.getState()
|
||||
.removeLocalHost(item.host)
|
||||
}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
|
||||
<p class="font-medium text-zinc-900">合并结果</p>
|
||||
<div class="mt-3 flex flex-col gap-3">
|
||||
<For each={mergedHosts()}>
|
||||
{(item) => (
|
||||
<div class="rounded-xl border border-zinc-200 bg-white px-4 py-3">
|
||||
<p class="font-medium text-zinc-900">{item.label}</p>
|
||||
<p class="mt-1 text-sm text-zinc-500">{item.host}</p>
|
||||
<p class="mt-2 text-xs text-cyan-700">
|
||||
来源:{item.source === "local" ? "本地优先" : "远端"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Setting;
|
||||
|
||||
Reference in New Issue
Block a user