8 Commits

Author SHA1 Message Date
a182c64f82 🔖 release(v0.1.4): bump version and UI optimizations
- Remove unused version display logic and update summary
- Add silent audio playback to prevent browser tab throttling
- Update CourseWorkspace and Setting components
- Bump version from 0.1.3 to 0.1.4

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-26 20:45:39 +08:00
0c0d2a0292 feat: add silent audio playback to prevent browser tab throttling during study
Play a nearly inaudible Web Audio API signal when study starts, stop it
when study completes, is stopped, or fails. This prevents browsers from
throttling timers and network requests in background tabs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-26 17:55:27 +08:00
5d4e0f493c chore: add .claude/ to .gitignore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-26 17:39:50 +08:00
a1911573d1 feat: UI optimizations - button feedback, layout fixes, cache clearing, work/exam records
- 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>
2026-04-26 17:36:02 +08:00
13f0be162b release: v0.1.3 2026-04-03 14:20:26 +08:00
1396592141 feat: split backend debug logs into dedicated page 2026-04-02 23:51:01 +08:00
7e102b3b76 feat: add backend debug log panel 2026-04-02 23:27:11 +08:00
58555c5043 feat(release): bump version to 0.1.2
## 详细信息
- 升级项目版本号到 0.1.2
- 增强刷课稳定性(失败重试、心跳检测、状态自动纠正)
- 优化账号页与工作台紧凑布局,增加已学/未学区分与记录筛选
- 新增首次进入更新检查、更新日志弹窗与在线下载/回退 Release 跳转
2026-04-02 22:33:04 +08:00
21 changed files with 3353 additions and 859 deletions

3
.gitignore vendored
View File

@@ -23,3 +23,6 @@ dist-ssr
*.sln
*.sw?
doc
# Claude Code
.claude/

View File

@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"version": "0.1.4",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,20 +1,163 @@
import type { ParentComponent } from "solid-js";
import { createMemo, createResource, createSignal } from "solid-js";
import type { JSX, ParentComponent } from "solid-js";
import {
For,
Show,
createEffect,
createMemo,
createResource,
createSignal,
onMount,
onCleanup,
} from "solid-js";
import { A, useLocation } from "@solidjs/router";
import Dialog from "~/components/dialog/Dialog";
import { updateDebugConfig } from "~/service/debugLog";
import {
RELEASES_PAGE_URL,
type LatestRelease,
type ReleaseAsset,
type RuntimeTarget,
detectRuntimeTarget,
downloadReleaseAsset,
fetchLatestRelease,
isRemoteVersionNewer,
resolveAssetForRuntime,
resolveReleaseLink,
} from "~/service/update";
import { versionApi } from "~/service/wk";
import { settingsStore } from "~/store/settings";
const asideList = [
{ label: "账号", url: "/account" },
{ label: "日志", url: "/logs" },
{ label: "设置", url: "/setting" },
];
type DownloadState = "idle" | "downloading" | "done" | "error";
type UpdateCheckState =
| "idle"
| "checking"
| "latest"
| "available"
| "error";
type MarkdownBlock =
| { type: "heading"; level: 1 | 2 | 3 | 4 | 5 | 6; text: string }
| { type: "paragraph"; text: string }
| { type: "list"; items: string[] };
const normalizeMarkdown = (value: string) => value.replace(/\r\n?/g, "\n");
const parseMarkdownBlocks = (markdown: string): MarkdownBlock[] => {
const lines = normalizeMarkdown(markdown).split("\n");
const blocks: MarkdownBlock[] = [];
let index = 0;
while (index < lines.length) {
const line = lines[index].trim();
if (!line) {
index += 1;
continue;
}
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
if (headingMatch) {
const level = Math.min(
6,
Math.max(1, headingMatch[1].length),
) as 1 | 2 | 3 | 4 | 5 | 6;
blocks.push({
type: "heading",
level,
text: headingMatch[2].trim(),
});
index += 1;
continue;
}
if (line.startsWith("- ")) {
const items: string[] = [];
while (index < lines.length) {
const nextLine = lines[index].trim();
if (!nextLine.startsWith("- ")) {
break;
}
items.push(nextLine.slice(2).trim());
index += 1;
}
blocks.push({ type: "list", items });
continue;
}
const paragraphLines: string[] = [line];
index += 1;
while (index < lines.length) {
const nextLine = lines[index].trim();
if (!nextLine || nextLine.startsWith("- ") || /^#{1,6}\s+/.test(nextLine)) {
break;
}
paragraphLines.push(nextLine);
index += 1;
}
blocks.push({
type: "paragraph",
text: paragraphLines.join(" ").trim(),
});
}
return blocks;
};
const renderInlineLinks = (text: string): JSX.Element[] => {
const parts: JSX.Element[] = [];
const regex = /\[([^\]]+)\]\(([^)]+)\)/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
const label = match[1] || "链接";
const href = resolveReleaseLink(match[2] || "");
parts.push(
<a
href={href}
target="_blank"
rel="noopener noreferrer"
class="text-cyan-700 underline decoration-cyan-300 underline-offset-2 hover:text-cyan-800"
>
{label}
</a>,
);
lastIndex = regex.lastIndex;
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts.length > 0 ? parts : [text];
};
const App: ParentComponent = (props) => {
const location = useLocation();
const [version] = createResource(versionApi);
const [copyState, setCopyState] = createSignal<"idle" | "done" | "error">(
"idle",
const [updateDialogOpen, setUpdateDialogOpen] = createSignal(false);
const [updateCheckState, setUpdateCheckState] =
createSignal<UpdateCheckState>("idle");
const [hasAutoCheckedUpdate, setHasAutoCheckedUpdate] = createSignal(false);
const [latestRelease, setLatestRelease] = createSignal<LatestRelease | null>(
null,
);
const [runtimeTarget, setRuntimeTarget] = createSignal<RuntimeTarget>({
os: "unknown",
arch: "unknown",
});
const [matchedAsset, setMatchedAsset] = createSignal<ReleaseAsset | null>(
null,
);
const [updateCheckError, setUpdateCheckError] = createSignal("");
const [downloadState, setDownloadState] = createSignal<DownloadState>("idle");
const [downloadProgress, setDownloadProgress] = createSignal(0);
const [downloadError, setDownloadError] = createSignal("");
const [settingsState, setSettingsState] = createSignal(settingsStore.getState());
let updateAbortController: AbortController | null = null;
let lastDebugSyncValue: boolean | undefined;
const isActive = (url: string) =>
location.pathname === url ||
@@ -28,6 +171,21 @@ const App: ParentComponent = (props) => {
const buildText = createMemo(() => version()?.data.BuildAt ?? "unknown");
const authorText = createMemo(() => version()?.data.GitAuthor ?? "unknown");
const emailText = createMemo(() => version()?.data.GitEmail ?? "unknown");
const modeText = createMemo(() => version()?.data.Mode ?? "unknown");
const isDebugMode = createMemo(() => settingsState().debugEnabled);
const asideList = createMemo(() => {
const items = [
{ label: "账号", url: "/account" },
{ label: "日志", url: "/logs" },
];
if (isDebugMode()) {
items.push({ label: "后端日志", url: "/debug-logs" });
}
items.push({ label: "设置", url: "/setting" });
return items;
});
const versionErrorText = createMemo(() => {
const error = version.error;
if (!error) {
@@ -36,129 +194,394 @@ const App: ParentComponent = (props) => {
return error instanceof Error ? error.message : "版本信息获取失败";
});
const versionPayloadText = createMemo(() =>
[
`Version: ${versionText()}`,
`Commit: ${commitText()}`,
`Build: ${buildText()}`,
`Author: ${authorText()}`,
`Email: ${emailText()}`,
].join("\n"),
const releaseNotesBlocks = createMemo(() =>
parseMarkdownBlocks(latestRelease()?.body ?? ""),
);
const runtimeTargetText = createMemo(
() => `${runtimeTarget().os} / ${runtimeTarget().arch}`,
);
const releaseLink = createMemo(
() => latestRelease()?.html_url || RELEASES_PAGE_URL,
);
const safeValue = (value: string) => (value === "unknown" ? "-" : value);
const hasUpdateBadge = createMemo(() => updateCheckState() === "available");
const updateDialogTitle = createMemo(() => {
if (updateCheckState() === "available") {
return `发现更新 ${latestRelease()?.tag_name ?? ""}`;
}
return "更新信息";
});
const handleCopyVersion = async () => {
try {
await navigator.clipboard.writeText(versionPayloadText());
setCopyState("done");
} catch {
setCopyState("error");
onMount(() => {
const unsubscribe = settingsStore.subscribe((state) => {
setSettingsState(state);
});
onCleanup(() => {
unsubscribe();
});
});
const performUpdateCheck = async (manual = false) => {
if (updateCheckState() === "checking") {
return;
}
window.setTimeout(() => setCopyState("idle"), 1800);
setUpdateCheckState("checking");
setUpdateCheckError("");
setDownloadState("idle");
setDownloadProgress(0);
setDownloadError("");
if (updateAbortController) {
updateAbortController.abort();
}
updateAbortController = new AbortController();
try {
const [release, target] = await Promise.all([
fetchLatestRelease(updateAbortController.signal),
detectRuntimeTarget(),
]);
setRuntimeTarget(target);
const hasNewVersion = isRemoteVersionNewer(versionText(), release.tag_name);
setLatestRelease(release);
setMatchedAsset(resolveAssetForRuntime(release.assets, target));
if (!hasNewVersion) {
setUpdateCheckState("latest");
if (manual) {
setUpdateDialogOpen(true);
}
return;
}
setUpdateCheckState("available");
setUpdateDialogOpen(true);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
return;
}
const message = error instanceof Error ? error.message : "更新检查失败";
setUpdateCheckError(message);
setUpdateCheckState("error");
}
};
const openReleasePage = () => {
window.open(releaseLink(), "_blank", "noopener,noreferrer");
};
const handleDownloadUpdate = async () => {
const asset = matchedAsset();
if (!asset) {
openReleasePage();
return;
}
setDownloadState("downloading");
setDownloadProgress(0);
setDownloadError("");
try {
await downloadReleaseAsset(asset, (progress) => {
setDownloadProgress(progress);
});
setDownloadState("done");
} catch (error) {
const message = error instanceof Error ? error.message : "下载失败";
setDownloadError(message);
setDownloadState("error");
openReleasePage();
}
};
createEffect(() => {
version.loading;
if (hasAutoCheckedUpdate() || version.loading) {
return;
}
setHasAutoCheckedUpdate(true);
void performUpdateCheck(false);
});
createEffect(() => {
const enabled = settingsState().debugEnabled;
if (lastDebugSyncValue === enabled) {
return;
}
lastDebugSyncValue = enabled;
void updateDebugConfig(enabled).catch(() => {
// Keep the local preference and let settings page surface sync errors.
});
});
onCleanup(() => {
if (updateAbortController) {
updateAbortController.abort();
updateAbortController = null;
}
});
return (
<div class="flex h-screen w-full overflow-hidden bg-[radial-gradient(circle_at_top_left,_rgba(103,232,249,0.24),_transparent_28%),radial-gradient(circle_at_top_right,_rgba(251,191,36,0.16),_transparent_24%),linear-gradient(180deg,_#ecfeff_0%,_#f8fafc_52%,_#eef2ff_100%)] text-zinc-800">
<div class="flex min-h-0 w-full flex-col p-3 sm:p-4">
<header class="flex shrink-0 items-center rounded-[24px] border border-white/70 bg-white/75 px-4 py-3 shadow-[0_18px_50px_-22px_rgba(14,116,144,0.35)] backdrop-blur-xl">
<div class="flex min-w-0 items-center gap-3">
<A
href="/"
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[linear-gradient(135deg,_#06b6d4,_#22c55e)] text-base font-semibold text-white shadow-lg shadow-cyan-500/20"
>
WK
</A>
<div class="min-w-0">
<>
<div class="flex h-dvh w-full overflow-hidden bg-[radial-gradient(circle_at_top_left,_rgba(103,232,249,0.24),_transparent_28%),radial-gradient(circle_at_top_right,_rgba(251,191,36,0.16),_transparent_24%),linear-gradient(180deg,_#ecfeff_0%,_#f8fafc_52%,_#eef2ff_100%)] text-zinc-800">
<div class="flex min-h-0 w-full flex-col p-3 sm:p-4">
<header class="flex shrink-0 items-center rounded-[24px] border border-white/70 bg-white/75 px-4 py-3 shadow-[0_18px_50px_-22px_rgba(14,116,144,0.35)] backdrop-blur-xl">
<div class="flex min-w-0 items-center gap-3">
<A
href="/"
class="block truncate text-xl font-semibold tracking-wide text-zinc-900"
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[linear-gradient(135deg,_#06b6d4,_#22c55e)] text-base font-semibold text-white shadow-lg shadow-cyan-500/20"
>
WK
</A>
<p class="truncate text-xs text-zinc-500 sm:text-sm">
</p>
<div class="min-w-0">
<A
href="/"
class="block truncate text-xl font-semibold tracking-wide text-zinc-900"
>
</A>
<p class="truncate text-xs text-zinc-500 sm:text-sm">
</p>
</div>
</div>
</div>
</header>
</header>
<div class="mt-4 flex min-h-0 min-w-0 flex-1 gap-4">
<aside class="flex w-64 shrink-0 flex-col rounded-[28px] border border-white/70 bg-white/70 p-3 shadow-[0_18px_50px_-22px_rgba(15,23,42,0.18)] backdrop-blur-xl">
<div class="mb-4 rounded-2xl bg-[linear-gradient(135deg,_rgba(6,182,212,0.12),_rgba(34,197,94,0.14))] px-4 py-4">
<p class="text-xs font-medium tracking-[0.24em] text-cyan-700/75 uppercase">
Navigation
</p>
<p class="mt-2 text-sm leading-6 text-zinc-600">
</p>
</div>
<div class="mt-4 flex min-h-0 min-w-0 flex-1 flex-col gap-4 xl:flex-row">
<aside class="flex shrink-0 flex-col rounded-[28px] border border-white/70 bg-white/70 p-3 shadow-[0_18px_50px_-22px_rgba(15,23,42,0.18)] backdrop-blur-xl xl:w-64">
<div class="mb-4 hidden rounded-2xl bg-[linear-gradient(135deg,_rgba(6,182,212,0.12),_rgba(34,197,94,0.14))] px-4 py-4 xl:block">
<p class="text-xs font-medium tracking-[0.24em] text-cyan-700/75 uppercase">
Navigation
</p>
<p class="mt-2 text-sm leading-6 text-zinc-600">
</p>
</div>
<nav class="flex flex-col gap-2">
{asideList.map((item) => {
const active = isActive(item.url);
<nav class="flex gap-2 overflow-x-auto pb-1 xl:flex-col xl:overflow-visible xl:pb-0">
{asideList().map((item) => {
const active = isActive(item.url);
return (
<A
href={item.url}
class={
active
? "rounded-2xl border border-cyan-200 bg-[linear-gradient(135deg,_rgba(34,211,238,0.18),_rgba(34,197,94,0.18))] px-4 py-3 text-sm font-medium text-zinc-900 shadow-sm"
: "rounded-2xl border border-transparent px-4 py-3 text-sm font-medium text-zinc-600 transition hover:border-zinc-200 hover:bg-zinc-50/80 hover:text-zinc-900"
}
return (
<A
href={item.url}
class={
active
? "min-w-fit whitespace-nowrap rounded-2xl border border-cyan-200 bg-[linear-gradient(135deg,_rgba(34,211,238,0.18),_rgba(34,197,94,0.18))] px-4 py-3 text-sm font-medium text-zinc-900 shadow-sm"
: "min-w-fit whitespace-nowrap rounded-2xl border border-transparent px-4 py-3 text-sm font-medium text-zinc-600 transition active:scale-[0.98] hover:border-zinc-200 hover:bg-zinc-50/80 hover:text-zinc-900"
}
>
<div class="flex items-center justify-between">
<span>{item.label}</span>
<span
class={
active
? "text-cyan-700"
: "text-zinc-400 transition group-hover:text-zinc-600"
}
>
</span>
</div>
</A>
);
})}
</nav>
<div class="mt-3 rounded-2xl border border-zinc-200/80 bg-zinc-50/90 px-4 py-4 xl:mt-auto">
<p class="text-sm font-medium text-zinc-800"></p>
<p class="mt-1 text-sm text-zinc-500">
{asideList().find((item) => isActive(item.url))?.label ?? "账号"}
</p>
<div class="mt-3 border-t border-zinc-200/80 pt-3">
<button
type="button"
class="flex w-full min-w-0 items-center gap-2 rounded-lg border border-zinc-200 bg-white/80 px-2.5 py-1.5 text-left text-xs text-zinc-500 transition hover:bg-white disabled:cursor-not-allowed disabled:opacity-60"
disabled={updateCheckState() === "checking"}
onClick={() => void performUpdateCheck(true)}
title="点击查看更新内容"
>
<div class="flex items-center justify-between">
<span>{item.label}</span>
<span class="text-zinc-400"></span>
<span class="shrink-0 rounded-full border border-zinc-200 bg-zinc-100 px-2 py-0.5 text-[11px] font-medium text-zinc-700">
{safeValue(versionText())}
</span>
{hasUpdateBadge() ? (
<span
class={
active
? "text-cyan-700"
: "text-zinc-400 transition group-hover:text-zinc-600"
}
>
</span>
class="h-2 w-2 shrink-0 rounded-full bg-rose-500"
title={`新版本:${latestRelease()?.tag_name ?? "-"}`}
/>
) : null}
<span class="ml-auto text-[11px] text-zinc-400">
{updateCheckState() === "checking" ? "检查中..." : "查看更新"}
</span>
</button>
<details class="mt-2 rounded-lg border border-zinc-200/80 bg-white/70 px-2.5 py-2">
<summary class="cursor-pointer select-none text-[11px] text-zinc-500">
</summary>
<div class="mt-2 space-y-1 text-xs">
<p class={isDebugMode() ? "text-amber-600" : "text-zinc-600"}>
Mode: {safeValue(modeText())}
</p>
<p class="truncate text-zinc-600">Commit: {safeValue(commitText())}</p>
<p class="truncate text-zinc-600">Build: {safeValue(buildText())}</p>
<p class="truncate text-zinc-600">Author: {safeValue(authorText())}</p>
<p class="truncate text-zinc-600">Email: {safeValue(emailText())}</p>
</div>
</A>
);
})}
</nav>
</details>
</div>
{updateCheckState() === "error" ? (
<p class="mt-2 text-xs text-rose-500">
{updateCheckError() || "更新检查失败"}
</p>
) : null}
{versionErrorText() ? (
<p class="mt-2 text-xs text-rose-500">{versionErrorText()}</p>
) : null}
</div>
</aside>
<div class="mt-auto rounded-2xl border border-zinc-200/80 bg-zinc-50/90 px-4 py-4">
<p class="text-sm font-medium text-zinc-800"></p>
<p class="mt-1 text-sm text-zinc-500">
{asideList.find((item) => isActive(item.url))?.label ?? "账号"}
</p>
<p class="mt-3 text-xs font-medium tracking-[0.18em] text-cyan-700/75 uppercase">
Runtime
</p>
<p class="mt-2 text-xs text-zinc-500">Version: {versionText()}</p>
<p class="mt-1 text-xs text-zinc-500">Commit: {commitText()}</p>
<p class="mt-1 text-xs text-zinc-500">Build: {buildText()}</p>
<p class="mt-1 text-xs text-zinc-500">Author: {authorText()}</p>
<p class="mt-1 text-xs text-zinc-500">Email: {emailText()}</p>
<button
type="button"
class="mt-3 rounded-lg border border-zinc-200 bg-white px-3 py-1.5 text-xs text-zinc-700 transition hover:bg-zinc-100"
onClick={() => void handleCopyVersion()}
>
{copyState() === "done"
? "已复制"
: copyState() === "error"
? "复制失败"
: "复制版本信息"}
</button>
{versionErrorText() ? (
<p class="mt-2 text-xs text-rose-500">{versionErrorText()}</p>
) : null}
</div>
</aside>
<main class="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-[32px] border border-white/80 bg-white/80 p-3 shadow-[0_18px_60px_-24px_rgba(15,23,42,0.22)] backdrop-blur-xl sm:p-4">
{props.children}
</main>
<main class="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-[32px] border border-white/80 bg-white/80 p-3 shadow-[0_18px_60px_-24px_rgba(15,23,42,0.22)] backdrop-blur-xl sm:p-4">
{props.children}
</main>
</div>
</div>
</div>
</div>
<Dialog
open={updateDialogOpen}
onClose={() => {
if (downloadState() === "downloading") {
return;
}
setUpdateDialogOpen(false);
}}
title={updateDialogTitle()}
widthClass="max-w-3xl"
closeOnOverlay={downloadState() !== "downloading"}
footer={
<>
<button
type="button"
class="rounded-xl border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700 transition active:bg-zinc-200 hover:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-60"
disabled={downloadState() === "downloading"}
onClick={() => setUpdateDialogOpen(false)}
>
</button>
<button
type="button"
class="rounded-xl border border-cyan-200 bg-cyan-50 px-3 py-2 text-sm text-cyan-700 transition active:bg-cyan-200 hover:bg-cyan-100"
onClick={openReleasePage}
>
Release
</button>
<button
type="button"
class="rounded-xl bg-[linear-gradient(135deg,#06b6d4,#14b8a6)] px-3 py-2 text-sm text-white transition active:scale-[0.97] hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-60"
disabled={downloadState() === "downloading"}
onClick={() => void handleDownloadUpdate()}
>
{matchedAsset()
? downloadState() === "downloading"
? `下载中 ${downloadProgress()}%`
: "在线下载"
: "前往 Release 下载"}
</button>
</>
}
>
<div class="grid gap-4">
<div class="grid gap-2 rounded-2xl border border-zinc-200 bg-zinc-50/85 p-4 text-sm text-zinc-700 md:grid-cols-2">
<p>
<span class="font-semibold text-zinc-900">{versionText()}</span>
</p>
<p>
<span class="font-semibold text-zinc-900">
{latestRelease()?.tag_name ?? "-"}
</span>
</p>
<p>
<span class="font-semibold text-zinc-900">
{latestRelease()?.name ?? "-"}
</span>
</p>
<p>
<span class="font-semibold text-zinc-900">{runtimeTargetText()}</span>
</p>
<p class="md:col-span-2">
<span class="font-semibold text-zinc-900">
{matchedAsset()?.name ?? "未识别当前系统架构,请点击“打开 Release”手动下载"}
</span>
</p>
</div>
<Show when={downloadState() !== "idle"}>
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/85 p-4">
<p class="text-sm font-medium text-zinc-800">
{downloadState() === "downloading"
? "下载进度"
: downloadState() === "done"
? "下载完成"
: "下载失败"}
</p>
<div class="mt-2 h-2.5 overflow-hidden rounded-full bg-zinc-200">
<div
class={`h-full rounded-full transition-all ${downloadState() === "error" ? "bg-rose-400" : "bg-cyan-500"}`}
style={{
width: `${downloadState() === "error" ? 100 : downloadProgress()}%`,
}}
/>
</div>
<p class="mt-2 text-xs text-zinc-600">
{downloadState() === "downloading"
? `已下载 ${downloadProgress()}%`
: downloadState() === "done"
? "安装包已下载到浏览器默认下载目录,请替换本地程序后重启。"
: downloadError() || "下载失败,请改为 Release 页面手动下载。"}
</p>
</div>
</Show>
<div class="rounded-2xl border border-zinc-200 bg-white p-4">
<p class="text-sm font-semibold text-zinc-900"></p>
<div class="mt-3 space-y-2 text-sm leading-6 text-zinc-700">
<For each={releaseNotesBlocks()}>
{(block) => {
if (block.type === "heading") {
return (
<h3
class={`font-semibold text-zinc-900 ${block.level <= 2 ? "text-base" : "text-sm"}`}
>
{renderInlineLinks(block.text)}
</h3>
);
}
if (block.type === "list") {
return (
<ul class="list-disc space-y-1 pl-5">
<For each={block.items}>
{(item) => <li>{renderInlineLinks(item)}</li>}
</For>
</ul>
);
}
return <p>{renderInlineLinks(block.text)}</p>;
}}
</For>
</div>
</div>
</div>
</Dialog>
</>
);
};

View File

@@ -14,11 +14,11 @@ interface AccountSidebarProps {
statusOptions: StatusOption[];
currentCourseKind: CourseKind;
hostLabels: Record<string, string>;
isRefreshingAccount: boolean;
refreshingAccountId: string;
loggingOutId: string;
densityMode: "comfortable" | "compact";
sidebarWidth: number;
onRefreshAccount: () => void;
onRefreshAccount: (accountId: string) => void;
onSelectAccount: (accountId: string) => void;
onToggleExpand: (accountId: string) => void;
onLogout: (accountId: string) => void;
@@ -29,33 +29,26 @@ const AccountSidebar = (props: AccountSidebarProps) => {
return (
<section
class="flex min-h-0 flex-col overflow-hidden rounded-[28px] border border-white/80 bg-white/85 shadow-[0_18px_50px_-28px_rgba(15,23,42,0.3)] xl:shrink-0"
class="flex min-h-0 w-full flex-col overflow-hidden rounded-[24px] border border-white/80 bg-white/85 shadow-[0_14px_40px_-26px_rgba(15,23,42,0.28)] xl:shrink-0"
style={{
width: `${props.sidebarWidth}px`,
"min-width": `${props.sidebarWidth}px`,
width: `min(100%, ${props.sidebarWidth}px)`,
"min-width": "0px",
}}
>
<div class="flex items-center justify-between border-b border-zinc-200/80 px-5 py-4">
<div class="flex flex-col gap-2.5 border-b border-zinc-200/80 px-4 py-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<p class="text-lg font-semibold text-zinc-900"></p>
<p class="mt-1 text-sm text-zinc-500"></p>
<p class="text-base font-semibold text-zinc-900"></p>
<p class="mt-1 text-xs text-zinc-500 sm:text-sm">
</p>
</div>
<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={!props.selectedAccountId || props.isRefreshingAccount}
onClick={props.onRefreshAccount}
>
{props.isRefreshingAccount ? "刷新中..." : "刷新账号"}
</button>
</div>
<div
class={
compact()
? "flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto p-3"
: "flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto p-4"
? "flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto p-2.5"
: "flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto p-3"
}
>
<For each={props.accounts}>
@@ -77,23 +70,26 @@ const AccountSidebar = (props: AccountSidebarProps) => {
const courseTypeLabel = selected()
? `当前筛选:${currentCourseLabel}`
: `登录类型:${statusLabel}`;
const badgeTypeLabel = selected() ? currentCourseLabel : statusLabel;
const badgeCountLabel = `${account.courses.length}`;
const isRefreshing = () => props.refreshingAccountId === account.id;
return (
<div
class={
selected()
? compact()
? "rounded-[22px] border border-cyan-300 bg-[linear-gradient(145deg,rgba(236,254,255,0.95),rgba(240,253,244,0.95))] px-3 py-3 shadow-sm"
: "rounded-3xl border border-cyan-300 bg-[linear-gradient(145deg,rgba(236,254,255,0.95),rgba(240,253,244,0.95))] px-4 py-4 shadow-sm"
? "rounded-[20px] border border-cyan-300 bg-[linear-gradient(145deg,rgba(236,254,255,0.95),rgba(240,253,244,0.95))] px-3 py-2.5 shadow-sm"
: "rounded-[22px] border border-cyan-300 bg-[linear-gradient(145deg,rgba(236,254,255,0.95),rgba(240,253,244,0.95))] px-3.5 py-3 shadow-sm"
: compact()
? "rounded-[22px] border border-zinc-200 bg-[linear-gradient(180deg,rgba(250,250,250,0.9),rgba(255,255,255,0.95))] px-3 py-3 shadow-sm transition hover:border-cyan-200 hover:bg-white"
: "rounded-3xl border border-zinc-200 bg-[linear-gradient(180deg,rgba(250,250,250,0.9),rgba(255,255,255,0.95))] px-4 py-4 shadow-sm transition hover:border-cyan-200 hover:bg-white"
? "rounded-[20px] border border-zinc-200 bg-[linear-gradient(180deg,rgba(250,250,250,0.9),rgba(255,255,255,0.95))] px-3 py-2.5 shadow-sm transition hover:border-cyan-200 hover:bg-white"
: "rounded-[22px] border border-zinc-200 bg-[linear-gradient(180deg,rgba(250,250,250,0.9),rgba(255,255,255,0.95))] px-3.5 py-3 shadow-sm transition hover:border-cyan-200 hover:bg-white"
}
>
<div class="flex items-start justify-between gap-3">
<button
type="button"
class="min-w-0 flex-1 text-left"
class="min-w-0 flex-1 text-left transition active:scale-[0.98]"
onClick={() => props.onSelectAccount(account.id)}
>
<div class="flex items-start justify-between gap-3">
@@ -101,21 +97,29 @@ const AccountSidebar = (props: AccountSidebarProps) => {
<p
class={
compact()
? "truncate text-base font-semibold text-zinc-900"
: "truncate text-lg font-semibold text-zinc-900"
? "truncate text-[15px] font-semibold text-zinc-900"
: "truncate text-base font-semibold text-zinc-900"
}
>
{account.user.name} + {platformLabel}
</p>
<p class="mt-1 text-sm text-zinc-500">
<p class="mt-1 text-xs text-zinc-500 sm:text-sm">
{account.user.id}
</p>
<div class="mt-1.5 flex flex-wrap items-center gap-1">
<span class="rounded-full border border-cyan-200 bg-cyan-50 px-2 py-0.5 text-[11px] text-cyan-700">
{badgeTypeLabel}
</span>
<span class="rounded-full border border-zinc-200 bg-zinc-100 px-2 py-0.5 text-[11px] text-zinc-600">
{badgeCountLabel}
</span>
</div>
</div>
</div>
</button>
<button
type="button"
class="shrink-0 text-sm text-zinc-400 transition hover:text-zinc-600"
class="shrink-0 text-sm text-zinc-400 transition hover:text-zinc-600 active:text-zinc-800"
onClick={() => props.onToggleExpand(account.id)}
>
{expanded() ? "收起" : "展开"}
@@ -123,26 +127,37 @@ const AccountSidebar = (props: AccountSidebarProps) => {
</div>
<Show when={expanded()}>
<div class="mt-4 grid gap-2 border-t border-cyan-100 pt-4 text-sm text-zinc-600">
<div class="mt-2.5 grid gap-1 border-t border-cyan-100 pt-2.5 text-xs text-zinc-600 sm:grid-cols-2 sm:gap-1.5">
<p>{account.user.dept}</p>
<p>{account.user.class}</p>
<p>{account.user.gender}</p>
<p>{account.host}</p>
<p>{account.username || "-"}</p>
<p>{courseCountLabel}</p>
<p>{courseTypeLabel}</p>
<p class="sm:col-span-2">{courseTypeLabel}</p>
<div class="mt-2 flex gap-2">
<div class="mt-1.5 flex flex-wrap gap-1.5 sm:col-span-2">
<button
type="button"
class="rounded-xl border border-cyan-200 bg-white px-3 py-2 text-sm text-cyan-700 transition hover:bg-cyan-50"
class="rounded-xl border border-emerald-200 bg-white px-2.5 py-1 text-xs text-emerald-700 transition hover:bg-emerald-50 active:bg-emerald-100 disabled:cursor-not-allowed disabled:opacity-60"
disabled={isRefreshing()}
onClick={(event) => {
event.stopPropagation();
props.onRefreshAccount(account.id);
}}
>
{isRefreshing() ? "刷新中..." : "刷新账号"}
</button>
<button
type="button"
class="rounded-xl border border-cyan-200 bg-white px-2.5 py-1 text-xs text-cyan-700 transition hover:bg-cyan-50 active:bg-cyan-100"
onClick={() => props.onToggleExpand(account.id)}
>
</button>
<button
type="button"
class="rounded-xl border border-rose-200 bg-white px-3 py-2 text-sm text-rose-500 transition hover:bg-rose-50 disabled:cursor-not-allowed disabled:opacity-60"
class="rounded-xl border border-rose-200 bg-white px-2.5 py-1 text-xs text-rose-500 transition hover:bg-rose-50 active:bg-rose-100 disabled:cursor-not-allowed disabled:opacity-60"
disabled={props.loggingOutId === account.id}
onClick={(event) => {
event.stopPropagation();

View File

@@ -43,14 +43,14 @@ const AddAccountDialog = (props: AddAccountDialogProps) => {
<>
<button
type="button"
class="rounded-xl border border-zinc-200 px-4 py-2 text-zinc-700 transition hover:bg-zinc-100"
class="rounded-xl border border-zinc-200 px-4 py-2 text-zinc-700 transition hover:bg-zinc-100 active:bg-zinc-200"
onClick={props.onClose}
>
</button>
<button
type="button"
class="rounded-xl bg-cyan-500 px-4 py-2 text-white transition hover:bg-cyan-600 disabled:cursor-not-allowed disabled:bg-cyan-300"
class="rounded-xl bg-cyan-500 px-4 py-2 text-white transition hover:bg-cyan-600 active:bg-cyan-700 disabled:cursor-not-allowed disabled:bg-cyan-300"
disabled={props.isSubmitting}
onClick={props.onSubmit}
>

View File

@@ -1,9 +1,18 @@
import { For, Show, createEffect, type JSX } from "solid-js";
import type { CourseKind, RecordType } from "~/service/wk";
import {
For,
Show,
createEffect,
createMemo,
createSignal,
type JSX,
} from "solid-js";
import type { CourseKind, RecordType, WorkListItem, ExamListItem } from "~/service/wk";
import type { AccountItem } from "~/store/account";
import type { CourseType } from "~/types/Course";
import type { RecordItem } from "~/service/wk";
const stripHtml = (value: string) => value.replace(/<[^>]+>/g, "").trim();
type RecordTypeOption = {
label: string;
value: RecordType;
@@ -12,6 +21,7 @@ type CourseRecordTypeOption = {
label: string;
value: CourseKind;
};
type RecordFilter = "all" | "unlearned" | "learned";
interface CourseWorkspaceProps {
selectedAccount: AccountItem | null;
@@ -25,6 +35,8 @@ interface CourseWorkspaceProps {
recordTypeOptions: RecordTypeOption[];
courseRecordTypeOptions: CourseRecordTypeOption[];
records: RecordItem[];
workList: WorkListItem[];
examList: ExamListItem[];
studyLogs: string[];
recordsLoading: boolean;
recordError: string;
@@ -65,6 +77,7 @@ const stripTimestamp = (message: string) => {
const CourseWorkspace = (props: CourseWorkspaceProps) => {
let logContainerRef: HTMLDivElement | undefined;
const [recordFilter, setRecordFilter] = createSignal<RecordFilter>("all");
createEffect(() => {
props.studyLogs.length;
@@ -88,16 +101,44 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
});
const compact = () => props.densityMode === "compact";
const recordStats = createMemo(() => {
const learned = props.records.filter((record) => {
const stateText = props.renderRecordState(record.state) || "未知状态";
return stateText.includes("已学") || record.progress === "1.00";
}).length;
const total = props.records.length;
return {
total,
learned,
unlearned: Math.max(0, total - learned),
};
});
const filteredRecords = createMemo(() => {
if (recordFilter() === "all") {
return props.records;
}
return props.records.filter((record) => {
const stateText = props.renderRecordState(record.state) || "未知状态";
const learned = stateText.includes("已学") || record.progress === "1.00";
return recordFilter() === "learned" ? learned : !learned;
});
});
return (
<section class="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-[28px] border border-white/80 bg-white/85 shadow-[0_18px_50px_-28px_rgba(15,23,42,0.3)]">
<div class="flex items-center justify-between border-b border-zinc-200/80 px-5 py-4">
<section class="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-[22px] border border-white/80 bg-white/85 shadow-[0_12px_34px_-24px_rgba(15,23,42,0.26)]">
<div class="flex flex-col gap-1.5 border-b border-zinc-200/80 px-3 py-2.5 sm:flex-row sm:items-center sm:justify-between">
<div>
<p class="text-lg font-semibold text-zinc-900"></p>
<p class="mt-1 text-sm text-zinc-500"></p>
<p class="text-[15px] font-semibold text-zinc-900 sm:text-base">
</p>
<p class="mt-0.5 text-xs text-zinc-500 sm:text-sm">
</p>
</div>
<Show when={props.selectedAccount}>
<div class="rounded-full bg-cyan-50 px-3 py-1 text-sm text-cyan-700">
<div class="rounded-full bg-cyan-50 px-2 py-0.5 text-xs text-cyan-700">
{props.selectedAccount?.user.name}
</div>
</Show>
@@ -114,19 +155,21 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
<div
class={
compact()
? "grid min-h-0 flex-1 gap-3 p-3 xl:grid-cols-[320px_minmax(0,1fr)]"
: "grid min-h-0 flex-1 gap-4 p-4 xl:grid-cols-[340px_minmax(0,1fr)]"
? "grid min-h-0 flex-1 gap-1.5 p-1.5 lg:grid-cols-[280px_minmax(0,1fr)]"
: "grid min-h-0 flex-1 gap-2 p-2 lg:grid-cols-[300px_minmax(0,1fr)]"
}
>
<div class="flex min-h-0 flex-col overflow-hidden rounded-3xl border border-zinc-200 bg-[linear-gradient(180deg,rgba(248,250,252,0.9),rgba(255,255,255,0.95))]">
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-zinc-200 px-4 py-3">
<div class="border-b border-zinc-200 px-4 py-3">
<p class="text-sm font-semibold text-zinc-800"></p>
<div class="flex min-h-0 flex-col overflow-hidden rounded-[18px] border border-zinc-200 bg-[linear-gradient(180deg,rgba(248,250,252,0.9),rgba(255,255,255,0.95))]">
<div class="flex flex-wrap items-center justify-between gap-1.5 border-b border-zinc-200 px-2.5 py-2">
<div>
<p class="text-xs font-semibold text-zinc-800 sm:text-sm">
</p>
<p class="mt-1 text-xs text-zinc-500"></p>
</div>
<div class="flex flex-wrap items-center gap-2">
<select
class="rounded-xl border border-zinc-200 bg-white px-3 py-2 text-sm transition outline-none focus:border-cyan-400"
class="rounded-lg border border-zinc-200 bg-white px-2 py-1 text-xs transition outline-none focus:border-cyan-400"
value={props.courseKind}
onChange={(event) =>
props.onChangeCourseRecordType(
@@ -140,7 +183,7 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
</select>
<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"
class="rounded-lg border border-zinc-200 bg-white px-2 py-1 text-xs text-zinc-700 transition hover:bg-zinc-100 active:bg-zinc-200 disabled:cursor-not-allowed disabled:opacity-60"
disabled={props.isRefreshingCourseRecords}
onClick={props.onRefreshCourseRecords}
>
@@ -149,13 +192,7 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
</div>
</div>
<div
class={
compact()
? "flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto p-2.5"
: "flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto p-3"
}
>
<div class={compact() ? "flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto p-1.5" : "flex min-h-0 flex-1 flex-col gap-1.5 overflow-y-auto p-2"}>
<Show when={props.selectedCourseList.length === 0}>
<EmptyState>
{props.currentCourseKindLabel}
@@ -173,18 +210,18 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
class={
selected()
? compact()
? "rounded-[20px] border border-cyan-300 bg-[linear-gradient(145deg,rgba(236,254,255,0.95),rgba(240,253,244,0.95))] px-3 py-3 text-left shadow-sm"
: "rounded-[22px] border border-cyan-300 bg-[linear-gradient(145deg,rgba(236,254,255,0.95),rgba(240,253,244,0.95))] px-4 py-4 text-left shadow-sm"
? "rounded-[16px] border border-cyan-300 bg-[linear-gradient(145deg,rgba(236,254,255,0.95),rgba(240,253,244,0.95))] px-2 py-2 text-left shadow-sm"
: "rounded-[18px] border border-cyan-300 bg-[linear-gradient(145deg,rgba(236,254,255,0.95),rgba(240,253,244,0.95))] px-2.5 py-2.5 text-left shadow-sm"
: compact()
? "rounded-[20px] border border-zinc-200 bg-white px-3 py-3 text-left shadow-sm transition hover:border-cyan-200 hover:bg-cyan-50/30"
: "rounded-[22px] border border-zinc-200 bg-white px-4 py-4 text-left shadow-sm transition hover:border-cyan-200 hover:bg-cyan-50/30"
? "rounded-[16px] border border-zinc-200 bg-white px-2 py-2 text-left shadow-sm transition hover:border-cyan-200 hover:bg-cyan-50/30"
: "rounded-[18px] border border-zinc-200 bg-white px-2.5 py-2.5 text-left shadow-sm transition hover:border-cyan-200 hover:bg-cyan-50/30"
}
onClick={() => props.onSelectCourse(course.id)}
>
<p class="truncate text-base font-semibold text-zinc-900">
<p class="truncate text-sm font-semibold text-zinc-900">
{course.name}
</p>
<div class="mt-3 grid gap-1 text-sm text-zinc-600">
<div class="mt-1 grid gap-0.5 text-xs text-zinc-600">
<p>{course.id}</p>
<p>{course.teacher}</p>
<p>{course.progress}</p>
@@ -199,14 +236,16 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
<div
class={
compact()
? "grid min-h-0 gap-3 xl:grid-rows-[minmax(0,1fr)_240px]"
: "grid min-h-0 gap-4 xl:grid-rows-[minmax(0,1fr)_260px]"
? "grid min-h-0 gap-1.5 grid-rows-[minmax(0,1fr)_minmax(140px,30vh)]"
: "grid min-h-0 gap-2 grid-rows-[minmax(0,1fr)_minmax(140px,30vh)]"
}
>
<div class="flex min-h-0 flex-col overflow-hidden rounded-3xl border border-zinc-200 bg-[linear-gradient(180deg,rgba(248,250,252,0.9),rgba(255,255,255,0.95))]">
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-zinc-200 px-4 py-3">
<div class="flex min-h-0 flex-col overflow-hidden rounded-[18px] border border-zinc-200 bg-[linear-gradient(180deg,rgba(248,250,252,0.9),rgba(255,255,255,0.95))]">
<div class="flex flex-wrap items-center justify-between gap-1.5 border-b border-zinc-200 px-2.5 py-2">
<div>
<p class="text-sm font-semibold text-zinc-800"></p>
<p class="text-xs font-semibold text-zinc-800 sm:text-sm">
</p>
<p class="mt-1 text-xs text-zinc-500">
{props.selectedCourse
? props.selectedCourse.name
@@ -221,10 +260,10 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
</Show>
</div>
<div class="flex flex-wrap items-center gap-2">
<div class="flex flex-wrap items-center gap-1.5">
<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"
class="rounded-lg border border-zinc-200 bg-white px-2 py-1 text-xs text-zinc-700 transition hover:bg-zinc-100 active:bg-zinc-200 disabled:cursor-not-allowed disabled:opacity-60"
disabled={
!props.selectedCourse || props.isRefreshingRecords
}
@@ -234,7 +273,7 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
</button>
<select
class="rounded-xl border border-zinc-200 bg-white px-3 py-2 text-sm transition outline-none focus:border-cyan-400"
class="rounded-lg border border-zinc-200 bg-white px-2 py-1 text-xs transition outline-none focus:border-cyan-400"
value={props.recordType}
onChange={(event) =>
props.onChangeRecordType(
@@ -244,7 +283,13 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
>
<For each={props.recordTypeOptions}>
{(item) => (
<option value={item.value}>{item.label}</option>
<option
value={item.value}
disabled={item.value === "/discuss"}
class={item.value === "/discuss" ? "text-zinc-400" : ""}
>
{item.label}
</option>
)}
</For>
</select>
@@ -252,7 +297,7 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
<Show when={props.recordType === ""}>
<button
type="button"
class="rounded-xl border border-cyan-200 bg-cyan-50 px-3 py-2 text-sm text-cyan-700 transition hover:bg-cyan-100"
class="rounded-lg border border-cyan-200 bg-cyan-50 px-2 py-1 text-xs text-cyan-700 transition hover:bg-cyan-100 active:bg-cyan-200"
onClick={
props.isRunningStudy
? props.onStopStudy
@@ -264,12 +309,52 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
</Show>
</div>
</div>
<Show when={props.recordType === ""}>
<div class="flex flex-wrap items-center justify-between gap-1.5 border-b border-zinc-200/70 px-2.5 py-1.5">
<div class="flex flex-wrap items-center gap-1.5 text-xs">
<button
type="button"
class={recordFilter() === "all" ? "rounded-full border border-zinc-200 bg-zinc-100 px-2.5 py-0.5 font-medium text-zinc-700 active:bg-zinc-200" : "rounded-full border border-zinc-200 bg-white px-2.5 py-0.5 text-zinc-600 transition hover:bg-zinc-100 active:bg-zinc-200"}
onClick={() => setRecordFilter("all")}
>
{recordStats().total}
</button>
<button
type="button"
class={recordFilter() === "unlearned" ? "rounded-full border border-amber-200 bg-amber-100 px-2.5 py-0.5 font-medium text-amber-700 active:bg-amber-200" : "rounded-full border border-amber-200 bg-white px-2.5 py-0.5 text-amber-700 transition hover:bg-amber-50 active:bg-amber-100"}
onClick={() => setRecordFilter("unlearned")}
>
{recordStats().unlearned}
</button>
<button
type="button"
class={recordFilter() === "learned" ? "rounded-full border border-emerald-200 bg-emerald-100 px-2.5 py-0.5 font-medium text-emerald-700 active:bg-emerald-200" : "rounded-full border border-emerald-200 bg-white px-2.5 py-0.5 text-emerald-700 transition hover:bg-emerald-50 active:bg-emerald-100"}
onClick={() => setRecordFilter("learned")}
>
{recordStats().learned}
</button>
</div>
<p class="text-xs text-zinc-500">
{recordFilter() === "all" ? "全部" : recordFilter() === "unlearned" ? "未学" : "已学"}
</p>
</div>
</Show>
<Show when={props.recordType === "/work" && props.workList.length > 0}>
<div class="flex items-center gap-1.5 border-b border-zinc-200/70 px-2.5 py-1.5 text-xs text-zinc-500">
{props.workList.length}
</div>
</Show>
<Show when={props.recordType === "/exam" && props.examList.length > 0}>
<div class="flex items-center gap-1.5 border-b border-zinc-200/70 px-2.5 py-1.5 text-xs text-zinc-500">
{props.examList.length}
</div>
</Show>
<div
class={
compact()
? "min-h-0 flex-1 overflow-y-auto p-2.5"
: "min-h-0 flex-1 overflow-y-auto p-3"
? "min-h-0 flex-1 overflow-y-auto p-1.5"
: "min-h-0 flex-1 overflow-y-auto p-2"
}
>
<Show when={props.recordsLoading}>
@@ -297,56 +382,215 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
!props.recordsLoading &&
!props.recordError &&
props.selectedCourse &&
props.records.length === 0
props.recordType === "" &&
filteredRecords().length === 0
}
>
<EmptyState></EmptyState>
<EmptyState>
{props.records.length === 0
? "当前分类下没有记录。"
: "当前筛选下没有记录。"}
</EmptyState>
</Show>
<div class="flex flex-col gap-3">
<For each={props.records}>
{(record) => (
<div
class={
compact()
? "rounded-[20px] border border-zinc-200 bg-white px-3 py-3 shadow-sm"
: "rounded-[22px] border border-zinc-200 bg-white px-4 py-4 shadow-sm"
}
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<p class="truncate text-base font-semibold text-zinc-900">
{record.name}
</p>
<p class="mt-1 text-sm text-zinc-500">
ID{record.id} | {record.chapterId}
</p>
</div>
<span class="rounded-full bg-zinc-100 px-3 py-1 text-xs font-medium text-zinc-700">
{props.renderRecordState(record.state) ||
"未知状态"}
</span>
</div>
<Show
when={
!props.recordsLoading &&
!props.recordError &&
props.selectedCourse &&
props.recordType === "/work" &&
props.workList.length === 0
}
>
<EmptyState></EmptyState>
</Show>
<div class="mt-4 grid gap-2 text-sm text-zinc-600 md:grid-cols-2 xl:grid-cols-3">
<p>{record.videoDuration}</p>
<p>{record.duration}</p>
<p>{record.progress}</p>
<p>{record.beginTime || "-"}</p>
<p>{record.finalTime || "-"}</p>
<p>{record.viewCount}</p>
</div>
</div>
)}
</For>
</div>
<Show
when={
!props.recordsLoading &&
!props.recordError &&
props.selectedCourse &&
props.recordType === "/exam" &&
props.examList.length === 0
}
>
<EmptyState></EmptyState>
</Show>
<Show when={props.recordType === ""}>
<div class={compact() ? "flex flex-col gap-1.5" : "flex flex-col gap-2"}>
<For each={filteredRecords()}>
{(record) => {
const stateText =
props.renderRecordState(record.state) || "未知状态";
const learned =
stateText.includes("已学") || record.progress === "1.00";
return (
<div
class={
learned
? compact()
? "rounded-[16px] border border-emerald-200 bg-emerald-50/35 px-2 py-1.5 shadow-sm"
: "rounded-[18px] border border-emerald-200 bg-emerald-50/35 px-2.5 py-2.5 shadow-sm"
: compact()
? "rounded-[16px] border border-amber-200 bg-amber-50/35 px-2 py-1.5 shadow-sm"
: "rounded-[18px] border border-amber-200 bg-amber-50/35 px-2.5 py-2.5 shadow-sm"
}
>
<div class="flex flex-wrap items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-zinc-900">
{record.name}
</p>
<p class="mt-1 text-xs text-zinc-500">
ID{record.id} | {record.chapterId}
</p>
</div>
<span
class={
learned
? "rounded-full bg-emerald-100 px-3 py-1 text-xs font-medium text-emerald-700"
: "rounded-full bg-amber-100 px-3 py-1 text-xs font-medium text-amber-700"
}
>
{stateText}
</span>
</div>
<div class="mt-2 grid gap-1 text-xs text-zinc-600 md:grid-cols-2 xl:grid-cols-3">
<p>{record.videoDuration}</p>
<p>{record.duration}</p>
<p>{record.progress}</p>
<p>{record.beginTime || "-"}</p>
<p>{record.finalTime || "-"}</p>
<p>{record.viewCount}</p>
</div>
</div>
);
}}
</For>
</div>
</Show>
<Show when={props.recordType === "/work"}>
<div class={compact() ? "flex flex-col gap-1.5" : "flex flex-col gap-2"}>
<For each={props.workList}>
{(work) => {
const stateRaw = stripHtml(work.state);
const stateText = stateRaw || "未做";
const done = stateRaw.includes("已阅") || stateRaw.includes("已提交") || stateRaw.includes("已完成");
return (
<div
class={
done
? compact()
? "rounded-[16px] border border-emerald-200 bg-emerald-50/35 px-2 py-1.5 shadow-sm"
: "rounded-[18px] border border-emerald-200 bg-emerald-50/35 px-2.5 py-2.5 shadow-sm"
: compact()
? "rounded-[16px] border border-amber-200 bg-amber-50/35 px-2 py-1.5 shadow-sm"
: "rounded-[18px] border border-amber-200 bg-amber-50/35 px-2.5 py-2.5 shadow-sm"
}
>
<div class="flex flex-wrap items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-zinc-900">
{work.title || work.name}
</p>
<p class="mt-1 text-xs text-zinc-500">
ID{work.id} | {work.chapterId}
</p>
</div>
<span
class={
done
? "rounded-full bg-emerald-100 px-3 py-1 text-xs font-medium text-emerald-700"
: "rounded-full bg-amber-100 px-3 py-1 text-xs font-medium text-amber-700"
}
>
{stateText}
</span>
</div>
<div class="mt-2 grid gap-1 text-xs text-zinc-600 md:grid-cols-2 xl:grid-cols-3">
<p>{work.typeName || work.type || "-"}</p>
<p>{work.score || "-"}</p>
<p>{stripHtml(work.finalScore) || "-"}</p>
<p>{work.topicNumber || "-"}</p>
<p>{work.addTime || "-"}</p>
<p>{work.finishTime !== "-" ? work.finishTime : "-"}</p>
</div>
</div>
);
}}
</For>
</div>
</Show>
<Show when={props.recordType === "/exam"}>
<div class={compact() ? "flex flex-col gap-1.5" : "flex flex-col gap-2"}>
<For each={props.examList}>
{(exam) => {
const stateRaw = stripHtml(exam.state);
const stateText = stateRaw || "未做";
const done = stateRaw.includes("已阅") || stateRaw.includes("已提交") || stateRaw.includes("已完成");
return (
<div
class={
done
? compact()
? "rounded-[16px] border border-emerald-200 bg-emerald-50/35 px-2 py-1.5 shadow-sm"
: "rounded-[18px] border border-emerald-200 bg-emerald-50/35 px-2.5 py-2.5 shadow-sm"
: compact()
? "rounded-[16px] border border-amber-200 bg-amber-50/35 px-2 py-1.5 shadow-sm"
: "rounded-[18px] border border-amber-200 bg-amber-50/35 px-2.5 py-2.5 shadow-sm"
}
>
<div class="flex flex-wrap items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-zinc-900">
{exam.title || exam.name}
</p>
<p class="mt-1 text-xs text-zinc-500">
ID{exam.id} | {exam.chapterId}
</p>
</div>
<span
class={
done
? "rounded-full bg-emerald-100 px-3 py-1 text-xs font-medium text-emerald-700"
: "rounded-full bg-amber-100 px-3 py-1 text-xs font-medium text-amber-700"
}
>
{stateText}
</span>
</div>
<div class="mt-2 grid gap-1 text-xs text-zinc-600 md:grid-cols-2 xl:grid-cols-3">
<p>{exam.limitedTime ? `${exam.limitedTime}分钟` : "-"}</p>
<p>{exam.score || "-"}</p>
<p>{stripHtml(exam.finalScore) || "-"}</p>
<p>{exam.topicNumber || "-"}</p>
<p>{exam.addTime || "-"}</p>
<p>{exam.finishTime !== "-" ? exam.finishTime : "-"}</p>
</div>
</div>
);
}}
</For>
</div>
</Show>
</div>
</div>
<div class="flex min-h-0 flex-col overflow-hidden rounded-3xl border border-zinc-200 bg-white">
<div class="flex items-center justify-between border-b border-zinc-200 px-4 py-3">
<div class="flex min-h-0 flex-col overflow-hidden rounded-[18px] border border-zinc-200 bg-white">
<div class="flex items-center justify-between border-b border-zinc-200 px-2.5 py-2">
<div>
<p class="text-sm font-semibold text-zinc-800"></p>
<p class="text-xs font-semibold text-zinc-800 sm:text-sm">
</p>
<p class="mt-1 text-xs text-zinc-500">
</p>
@@ -355,7 +599,7 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
<div class="flex items-center gap-2">
<button
type="button"
class="rounded-lg border border-zinc-200 px-3 py-1 text-xs text-zinc-700 transition hover:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-60"
class="rounded-lg border border-zinc-200 px-2 py-0.5 text-xs text-zinc-700 transition hover:bg-zinc-100 active:bg-zinc-200 disabled:cursor-not-allowed disabled:opacity-60"
disabled={props.isRefreshingLogs}
onClick={props.onRefreshLogs}
>
@@ -363,7 +607,7 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
</button>
<button
type="button"
class="rounded-lg border border-zinc-200 px-3 py-1 text-xs text-zinc-700 transition hover:bg-zinc-100"
class="rounded-lg border border-zinc-200 px-2 py-0.5 text-xs text-zinc-700 transition hover:bg-zinc-100 active:bg-zinc-200"
onClick={props.onClearLogs}
>
@@ -373,13 +617,13 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
<div
ref={logContainerRef}
class="min-h-0 flex-1 overflow-y-auto bg-zinc-950 px-4 py-3 font-mono text-emerald-300"
class="min-h-0 flex-1 overflow-y-auto bg-zinc-950 px-2 py-1.5 font-mono text-emerald-300"
>
<Show
when={props.studyLogs.length > 0}
fallback={<p></p>}
>
<div class="space-y-2">
<div class="space-y-1">
<For each={props.studyLogs}>
{(log, index) => (
<p>

View File

@@ -1,39 +0,0 @@
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<CourseType[]>;
}
const CourseList = (props: CourseListProps) => {
return (
<div class="m4-2 flex min-h-0 flex-1 flex-col gap-y-4 overflow-y-auto p-5">
{props.courseList().map((course) => {
return (
<div class="grid grid-cols-3 rounded-xl border bg-zinc-200 p-4 py-2 shadow-sm">
{courseLabel.map(([labelText, field]) => {
return (
<p class="text-base leading-7">
{labelText}: <span>{course[field]}</span>
</p>
);
})}
</div>
);
})}
</div>
);
};
export default CourseList;

View File

@@ -60,7 +60,7 @@ const Dialog: ParentComponent<DialogProps> = (props) => {
</h2>
<button
type="button"
class="rounded-lg px-3 py-1 text-zinc-500 transition hover:bg-zinc-100 hover:text-zinc-900"
class="rounded-lg px-3 py-1 text-zinc-500 transition hover:bg-zinc-100 hover:text-zinc-900 active:bg-zinc-200"
onClick={props.onClose}
>

View File

@@ -23,6 +23,10 @@ render(
path="logs"
component={lazy(() => import("./pages/logs/Logs.tsx"))}
/>
<Route
path="debug-logs"
component={lazy(() => import("./pages/debug-logs/DebugLogs.tsx"))}
/>
<Route
path="setting"
component={lazy(() => import("./pages/settings/Setting.tsx"))}

View File

@@ -13,12 +13,18 @@ import AddAccountDialog, {
import CourseWorkspace from "~/components/account/CourseWorkspace";
import {
createWkClient,
createSessionWkClient,
hostApi,
loginApi,
runStudyQueue,
type CourseKind,
type RecordItem,
type RecordType,
type WorkListItem,
type ExamListItem,
} from "~/service/wk";
import { setUnauthorizedHandler } from "~/service/http";
import { startSilentAudio, stopSilentAudio } from "~/service/silentAudio";
import { accountStore, type AccountItem } from "~/store/account";
import { getMergedHosts, settingsStore } from "~/store/settings";
import type { CourseType } from "~/types/Course";
@@ -47,13 +53,32 @@ const createDefaultForm = (host: string): LoginForm => ({
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))) {
const input = value.trim();
if (!input) {
return 0;
}
const [hours, minutes, seconds] = parts;
return hours * 3600 + minutes * 60 + seconds;
const numeric = Number(input);
if (!Number.isNaN(numeric)) {
return Math.max(0, Math.floor(numeric));
}
const parts = input.split(":").map(Number);
if (parts.some((part) => Number.isNaN(part))) {
return 0;
}
if (parts.length === 3) {
const [hours, minutes, seconds] = parts;
return hours * 3600 + minutes * 60 + seconds;
}
if (parts.length === 2) {
const [minutes, seconds] = parts;
return minutes * 60 + seconds;
}
return 0;
};
const createRecordCacheKey = (
@@ -61,6 +86,8 @@ const createRecordCacheKey = (
courseId: number,
recordType: RecordType,
) => `${accountId}::${courseId}::${recordType || "course"}`;
const STUDY_HEARTBEAT_CHECK_INTERVAL_MS = 5000;
const STUDY_HEARTBEAT_TIMEOUT_MS = 45000;
const Account = () => {
const [storeState, setStoreState] = createSignal(accountStore.getState());
@@ -70,7 +97,7 @@ const Account = () => {
const [showDialog, setShowDialog] = createSignal(false);
const [isSubmitting, setIsSubmitting] = createSignal(false);
const [loggingOutId, setLoggingOutId] = createSignal("");
const [isRefreshingAccount, setIsRefreshingAccount] = createSignal(false);
const [refreshingAccountId, setRefreshingAccountId] = createSignal("");
const [errorMessage, setErrorMessage] = createSignal("");
const [form, setForm] = createSignal<LoginForm>(
createDefaultForm("cqcst.leykeji.com"),
@@ -80,8 +107,67 @@ const Account = () => {
const [isRefreshingRecords, setIsRefreshingRecords] = createSignal(false);
const [isRefreshingCourses, setIsRefreshingCourses] = createSignal(false);
const [isRefreshingLogs, setIsRefreshingLogs] = createSignal(false);
const [isLoadingRemoteHosts, setIsLoadingRemoteHosts] = createSignal(false);
const [hasRestoredRecords, setHasRestoredRecords] = createSignal(false);
const accountClients = new Map<string, ReturnType<typeof createWkClient>>();
let recordRequestToken = 0;
const touchStudyHeartbeat = (accountId: string) => {
accountStore.getState().touchStudyHeartbeat(accountId);
};
const clearStudyHeartbeat = (accountId: string) => {
accountStore.getState().clearStudyHeartbeat(accountId);
};
const runStudyHeartbeatWatchdog = () => {
const now = Date.now();
const snapshot = accountStore.getState();
for (const [accountId, running] of Object.entries(snapshot.runningStudyMap)) {
if (!running) {
continue;
}
const lastHeartbeat = snapshot.studyHeartbeatMap[accountId] ?? 0;
if (now - lastHeartbeat <= STUDY_HEARTBEAT_TIMEOUT_MS) {
continue;
}
snapshot.setAccountRunningStudy(accountId, false);
snapshot.clearStudyHeartbeat(accountId);
snapshot.appendStudyLog(
accountId,
`检测到刷课任务超时(超过 ${Math.floor(
STUDY_HEARTBEAT_TIMEOUT_MS / 1000,
)} 秒无活动),已自动重置状态`,
);
}
};
const loadRemoteHostsIfNeeded = async () => {
if (isLoadingRemoteHosts()) {
return;
}
if (settingsStore.getState().remoteHosts.length > 0) {
return;
}
setIsLoadingRemoteHosts(true);
try {
const res = await hostApi();
settingsStore.getState().setRemoteHosts(
res.data.list.map((item) => ({
label: item.name,
host: item.host,
})),
);
} catch {
// Ignore host bootstrap errors in account page to avoid blocking main flow.
} finally {
setIsLoadingRemoteHosts(false);
}
};
onMount(() => {
const unsubscribeAccount = accountStore.subscribe((state) => {
@@ -90,12 +176,19 @@ const Account = () => {
const unsubscribeSettings = settingsStore.subscribe((state) => {
setSettingsState(state);
});
void loadRemoteHostsIfNeeded();
setUnauthorizedHandler(reloginBySession);
runStudyHeartbeatWatchdog();
const heartbeatTimer = window.setInterval(
runStudyHeartbeatWatchdog,
STUDY_HEARTBEAT_CHECK_INTERVAL_MS,
);
onCleanup(() => {
unsubscribeAccount();
unsubscribeSettings();
setUnauthorizedHandler(null);
window.clearInterval(heartbeatTimer);
});
});
@@ -106,7 +199,10 @@ const Account = () => {
const courseKind = createMemo(() => storeState().courseKind);
const recordType = createMemo(() => storeState().recordType);
const records = createMemo(() => storeState().records);
const workList = createMemo(() => storeState().workList);
const examList = createMemo(() => storeState().examList);
const recordCacheMap = createMemo(() => storeState().recordCacheMap);
const workExamCacheMap = createMemo(() => storeState().workExamCacheMap);
const studyLogsMap = createMemo(() => storeState().studyLogsMap);
const runningStudyMap = createMemo(() => storeState().runningStudyMap);
const mergedHostOptions = createMemo(() =>
@@ -189,6 +285,12 @@ const Account = () => {
accountStore.getState().appendStudyLog(targetAccountId, message);
};
const cancelPendingRecordRequest = () => {
recordRequestToken += 1;
setRecordsLoading(false);
setIsRefreshingRecords(false);
};
const getAccountClient = (accountId: string) => {
const existingClient = accountClients.get(accountId);
if (existingClient) {
@@ -205,6 +307,12 @@ const Account = () => {
return client;
};
const fetchUserInfoBySession = async (sessionId: string) => {
const client = createSessionWkClient(sessionId);
const res = await client.userInfoApi();
return res.data.user;
};
const reloginBySession = async (sessionId: string) => {
if (!sessionId) {
return false;
@@ -226,14 +334,14 @@ const Account = () => {
status: target.status,
host: target.host,
});
const user = await fetchUserInfoBySession(res.data.session_id);
accountStore.getState().upsertAccount({
replaceAccountPreservingView({
...target,
sessionId: res.data.session_id,
user: res.data.user,
courses: res.data.courses ?? target.courses,
user,
courses: target.courses,
});
await loadCourses(target.id, target.status);
appendStudyLog(`会话失效,已重新登录:${target.user.name}`, target.id);
return true;
} catch (error) {
@@ -281,11 +389,11 @@ const Account = () => {
status: payload.status,
host: payload.host,
});
const accountId = `${res.data.user.id}-${payload.host}-${payload.status}`;
const user = await fetchUserInfoBySession(res.data.session_id);
const accountId = `${user.id}-${payload.host}-${payload.status}`;
const nextAccount: AccountItem = {
id: accountId,
username: payload.username.trim() || res.data.user.id,
username: payload.username.trim() || user.id,
host: payload.host,
status: payload.status,
sessionId: res.data.session_id,
@@ -293,12 +401,11 @@ const Account = () => {
password: payload.password.trim(),
token: payload.token.trim(),
},
user: res.data.user,
courses: res.data.courses ?? [],
user,
courses: [],
};
accountStore.getState().upsertAccount(nextAccount);
await loadCourses(nextAccount.id, nextAccount.status);
accountStore.getState().setSelectedCourseId(null);
accountStore.getState().setRecords([]);
setShowDialog(false);
@@ -313,6 +420,7 @@ const Account = () => {
};
const handleSelectAccount = (accountId: string) => {
cancelPendingRecordRequest();
accountStore.getState().setSelectedAccountId(accountId);
accountStore.getState().setSelectedCourseId(null);
accountStore.getState().setRecords([]);
@@ -324,6 +432,24 @@ const Account = () => {
accountStore.getState().setExpandedAccountId(nextId);
};
const replaceAccountPreservingView = (nextAccount: AccountItem) => {
const snapshot = accountStore.getState();
const previousSelectedAccountId = snapshot.selectedAccountId;
const previousExpandedAccountId = snapshot.expandedAccountId;
const previousSelectedCourseId = snapshot.selectedCourseId;
snapshot.upsertAccount(nextAccount);
if (previousSelectedAccountId !== nextAccount.id) {
snapshot.setSelectedAccountId(previousSelectedAccountId);
snapshot.setSelectedCourseId(previousSelectedCourseId);
}
if (previousExpandedAccountId !== nextAccount.id) {
snapshot.setExpandedAccountId(previousExpandedAccountId);
}
};
const loadCourses = async (
accountId = selectedAccount()?.id,
status = courseKind(),
@@ -360,6 +486,7 @@ const Account = () => {
setErrorMessage(message);
accountStore.getState().setAccountCourses(accountId, []);
if (accountStore.getState().selectedAccountId === accountId) {
cancelPendingRecordRequest();
accountStore.getState().setSelectedCourseId(null);
accountStore.getState().setRecords([]);
}
@@ -368,14 +495,14 @@ const Account = () => {
}
};
const handleRefreshAccount = async () => {
const account = selectedAccount();
const handleRefreshAccount = async (accountId: string) => {
const account = accounts().find((item) => item.id === accountId);
if (!account) {
setErrorMessage("请先选择账号。");
return;
}
setIsRefreshingAccount(true);
setRefreshingAccountId(accountId);
setErrorMessage("");
try {
@@ -386,20 +513,21 @@ const Account = () => {
status: account.status,
host: account.host,
});
const user = await fetchUserInfoBySession(res.data.session_id);
accountStore.getState().upsertAccount({
replaceAccountPreservingView({
...account,
sessionId: res.data.session_id,
user: res.data.user,
courses: res.data.courses ?? account.courses,
user,
courses: account.courses,
});
await loadCourses(account.id, account.status);
await loadCourses(accountId, account.status);
} catch (error) {
const message =
error instanceof Error ? error.message : "刷新账号失败,请稍后重试。";
setErrorMessage(message);
} finally {
setIsRefreshingAccount(false);
setRefreshingAccountId("");
}
};
@@ -437,36 +565,111 @@ const Account = () => {
if (!account) {
return;
}
const requestToken = ++recordRequestToken;
const accountId = account.id;
setRecordsLoading(true);
setIsRefreshingRecords(true);
setRecordError("");
try {
const res = await getAccountClient(account.id).recordApi({
course_id: String(courseId),
page: 0,
record_type: nextRecordType,
});
accountStore.getState().setRecords(res.data.list);
accountStore
.getState()
.setRecordCache(
createRecordCacheKey(account.id, courseId, nextRecordType),
res.data.list,
);
const cacheKey = createRecordCacheKey(accountId, courseId, nextRecordType);
const client = getAccountClient(accountId);
if (nextRecordType === "/work") {
const res = await client.workListApi({
course_id: String(courseId),
page: 0,
record_type: "/work",
});
if (requestToken !== recordRequestToken) return;
const rawList = (res as { data?: { list?: unknown } })?.data?.list;
const list = (Array.isArray(rawList) ? rawList : []) as WorkListItem[];
const snapshot = accountStore.getState();
if (
snapshot.selectedAccountId !== accountId ||
snapshot.selectedCourseId !== courseId ||
snapshot.recordType !== nextRecordType
) {
return;
}
accountStore.getState().setWorkList(list);
accountStore.getState().setRecords([]);
accountStore.getState().setExamList([]);
accountStore.getState().setWorkExamCache(cacheKey, list);
} else if (nextRecordType === "/exam") {
const res = await client.examListApi({
course_id: String(courseId),
page: 0,
record_type: "/exam",
});
if (requestToken !== recordRequestToken) return;
const rawList = (res as { data?: { list?: unknown } })?.data?.list;
const list = (Array.isArray(rawList) ? rawList : []) as ExamListItem[];
const snapshot = accountStore.getState();
if (
snapshot.selectedAccountId !== accountId ||
snapshot.selectedCourseId !== courseId ||
snapshot.recordType !== nextRecordType
) {
return;
}
accountStore.getState().setExamList(list);
accountStore.getState().setRecords([]);
accountStore.getState().setWorkList([]);
accountStore.getState().setWorkExamCache(cacheKey, list);
} else {
const res = await client.recordApi({
course_id: String(courseId),
page: 0,
record_type: nextRecordType,
});
if (requestToken !== recordRequestToken) {
return;
}
const rawList = (res as { data?: { list?: unknown } })?.data?.list;
const list = (Array.isArray(rawList) ? rawList : []) as RecordItem[];
const snapshot = accountStore.getState();
if (
snapshot.selectedAccountId !== accountId ||
snapshot.selectedCourseId !== courseId ||
snapshot.recordType !== nextRecordType
) {
return;
}
accountStore.getState().setRecords(list);
accountStore.getState().setWorkList([]);
accountStore.getState().setExamList([]);
accountStore
.getState()
.setRecordCache(cacheKey, list);
}
} catch (error) {
if (requestToken !== recordRequestToken) {
return;
}
const message =
error instanceof Error ? error.message : "获取记录失败,请稍后重试。";
setRecordError(message);
accountStore.getState().setRecords([]);
accountStore.getState().setWorkList([]);
accountStore.getState().setExamList([]);
} finally {
setRecordsLoading(false);
setIsRefreshingRecords(false);
if (requestToken === recordRequestToken) {
setRecordsLoading(false);
setIsRefreshingRecords(false);
}
}
};
const handleSelectCourse = (courseId: number) => {
cancelPendingRecordRequest();
accountStore.getState().setSelectedCourseId(courseId);
};
@@ -507,18 +710,20 @@ const Account = () => {
}
const queueItems = records()
.filter((item) => item.progress !== "1.00")
.map((item) => ({
nodeId: item.id,
name: item.name,
currentTime: Number(item.duration || 0),
currentTime: parseDurationToSeconds(item.duration),
totalTime: parseDurationToSeconds(item.videoDuration),
progress: item.progress,
completed: stripHtml(item.state) === "已学",
}));
}))
.filter((item) => item.progress !== "1.00" && !item.completed);
try {
accountStore.getState().setAccountRunningStudy(account.id, true);
touchStudyHeartbeat(account.id);
startSilentAudio();
appendStudyLog(`开始刷课:${course.name}`, account.id);
await runStudyQueue({
accountId: account.id,
@@ -528,10 +733,15 @@ const Account = () => {
client: getAccountClient(account.id),
isRunningStudy: () =>
!!accountStore.getState().runningStudyMap[account.id],
setIsRunningStudy: () =>
accountStore.getState().setAccountRunningStudy(account.id, false),
onLog: (message: string, accoundID: string) =>
appendStudyLog(message, accoundID),
setIsRunningStudy: () => {
accountStore.getState().setAccountRunningStudy(account.id, false);
clearStudyHeartbeat(account.id);
stopSilentAudio();
},
onLog: (message: string, accoundID: string) => {
touchStudyHeartbeat(accoundID);
appendStudyLog(message, accoundID);
},
});
if (accountStore.getState().runningStudyMap[account.id]) {
appendStudyLog(`刷课完成:${course.name}`, account.id);
@@ -543,6 +753,8 @@ const Account = () => {
setRecordError(message);
} finally {
accountStore.getState().setAccountRunningStudy(account.id, false);
clearStudyHeartbeat(account.id);
stopSilentAudio();
}
};
@@ -553,12 +765,15 @@ const Account = () => {
}
accountStore.getState().setAccountRunningStudy(account.id, false);
clearStudyHeartbeat(account.id);
stopSilentAudio();
appendStudyLog(`已发送停止刷课指令:${account.user.name}`, account.id);
};
createEffect(
on([selectedAccountId, courseKind], ([accountId, kind]) => {
if (!accountId) {
cancelPendingRecordRequest();
return;
}
@@ -570,15 +785,29 @@ const Account = () => {
on(
[selectedAccountId, selectedCourseId, recordType],
([accountId, courseId, type]) => {
cancelPendingRecordRequest();
if (!accountId || !courseId) {
accountStore.getState().setRecords([]);
return;
}
const cachedRecords =
recordCacheMap()[createRecordCacheKey(accountId, courseId, type)] ??
[];
accountStore.getState().setRecords(cachedRecords);
const cacheKey = createRecordCacheKey(accountId, courseId, type);
if (type === "/work" || type === "/exam") {
const cachedData = workExamCacheMap()[cacheKey] ?? [];
if (type === "/work") {
accountStore.getState().setWorkList(cachedData as WorkListItem[]);
accountStore.getState().setExamList([]);
} else {
accountStore.getState().setExamList(cachedData as ExamListItem[]);
accountStore.getState().setWorkList([]);
}
accountStore.getState().setRecords([]);
} else {
const cachedRecords = recordCacheMap()[cacheKey] ?? [];
accountStore.getState().setRecords(cachedRecords);
accountStore.getState().setWorkList([]);
accountStore.getState().setExamList([]);
}
},
),
);
@@ -590,12 +819,19 @@ const Account = () => {
const cacheKey = account
? createRecordCacheKey(account.id, courseId ?? 0, type)
: "";
const hasCachedRecords = cacheKey ? cacheKey in recordCacheMap() : false;
const hasCachedRecords = cacheKey
? cacheKey in recordCacheMap() || cacheKey in workExamCacheMap()
: false;
if (!hasRestoredRecords()) {
setHasRestoredRecords(true);
if (courseId && account && (records().length > 0 || hasCachedRecords)) {
const hasAnyData =
records().length > 0 ||
workList().length > 0 ||
examList().length > 0 ||
hasCachedRecords;
if (courseId && account && hasAnyData) {
return;
}
}
@@ -613,12 +849,12 @@ const Account = () => {
return (
<div class="flex h-full min-h-0 flex-col overflow-hidden">
<div class="flex shrink-0 items-center justify-between gap-3 rounded-[24px] border border-white/80 bg-[linear-gradient(135deg,rgba(236,254,255,0.95),rgba(255,255,255,0.92))] px-4 py-3 shadow-[0_18px_45px_-28px_rgba(8,145,178,0.35)]">
<div class="flex shrink-0 items-center justify-between gap-2.5 rounded-[22px] border border-white/80 bg-[linear-gradient(135deg,rgba(236,254,255,0.95),rgba(255,255,255,0.92))] px-3.5 py-2.5 shadow-[0_16px_40px_-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-1 text-xl font-semibold text-zinc-900 sm:text-2xl">
<h1 class="mt-1 text-lg font-semibold text-zinc-900 sm:text-xl">
</h1>
<p class="mt-0.5 text-xs text-zinc-500 sm:text-sm">
@@ -627,14 +863,14 @@ const Account = () => {
</div>
<button
class="rounded-xl bg-[linear-gradient(135deg,#06b6d4,#14b8a6)] px-3.5 py-2.5 text-sm font-medium text-white shadow-lg shadow-cyan-500/20 transition hover:-translate-y-px hover:shadow-cyan-500/30"
class="rounded-xl bg-[linear-gradient(135deg,#06b6d4,#14b8a6)] px-3 py-2 text-sm font-medium text-white shadow-lg shadow-cyan-500/20 transition hover:-translate-y-px 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">
<div class="mt-3 flex min-h-0 flex-1 flex-col gap-3 xl:flex-row">
<AccountSidebar
accounts={accounts()}
selectedAccountId={selectedAccountId()}
@@ -642,11 +878,11 @@ const Account = () => {
statusOptions={statusOptions}
currentCourseKind={courseKind()}
hostLabels={hostLabels()}
isRefreshingAccount={isRefreshingAccount()}
refreshingAccountId={refreshingAccountId()}
loggingOutId={loggingOutId()}
densityMode={settingsState().densityMode}
sidebarWidth={settingsState().sidebarWidth}
onRefreshAccount={() => void handleRefreshAccount()}
onRefreshAccount={(accountId) => void handleRefreshAccount(accountId)}
onSelectAccount={handleSelectAccount}
onToggleExpand={handleToggleExpand}
onLogout={(accountId) => void handleLogout(accountId)}
@@ -664,6 +900,8 @@ const Account = () => {
recordTypeOptions={recordTypeOptions}
courseRecordTypeOptions={statusOptions}
records={records()}
workList={workList()}
examList={examList()}
studyLogs={studyLogs()}
recordsLoading={recordsLoading()}
recordError={recordError()}
@@ -682,9 +920,12 @@ const Account = () => {
accountStore.getState().setRecordType(value)
}
onChangeCourseRecordType={(value) => {
cancelPendingRecordRequest();
accountStore.getState().setCourseKind(value);
accountStore.getState().setSelectedCourseId(null);
accountStore.getState().setRecords([]);
accountStore.getState().setWorkList([]);
accountStore.getState().setExamList([]);
}}
onStartStudy={() => void handleStartStudy()}
onStopStudy={handleStopStudy}

View File

@@ -0,0 +1,715 @@
import {
createEffect,
createMemo,
createSignal,
For,
onCleanup,
onMount,
Show,
} from "solid-js";
import { useNavigate } from "@solidjs/router";
import {
fetchDebugLogSnapshot,
resolveDebugLogDownloadUrl,
resolveDebugLogWsUrl,
type DebugLogEntry,
} from "~/service/debugLog";
import { settingsStore } from "~/store/settings";
type DebugSocketState = "connecting" | "open" | "closed" | "error";
type DebugDetailTab = "overview" | "request" | "response" | "raw";
const MAX_DEBUG_ENTRIES = 1000;
const debugStatusLabelMap: Record<DebugSocketState, string> = {
connecting: "连接中",
open: "已连接",
closed: "已断开",
error: "连接异常",
};
const debugStatusClassMap: Record<DebugSocketState, string> = {
connecting: "border-amber-400/20 bg-amber-400/10 text-amber-100",
open: "border-emerald-400/20 bg-emerald-400/10 text-emerald-100",
closed: "border-white/10 bg-white/5 text-slate-300",
error: "border-rose-400/20 bg-rose-400/10 text-rose-100",
};
const stringifyDebugFields = (fields?: Record<string, unknown>) => {
if (!fields || Object.keys(fields).length === 0) {
return "";
}
return JSON.stringify(fields, null, 2);
};
const stringifyDebugValue = (value: unknown) => {
if (
value === null ||
value === undefined ||
(typeof value === "string" && value.trim() === "")
) {
return "";
}
if (typeof value === "string") {
return value;
}
return JSON.stringify(value, null, 2);
};
const getField = (value: unknown, path: string[]) => {
let current = value;
for (const key of path) {
if (!current || typeof current !== "object" || !(key in current)) {
return undefined;
}
current = (current as Record<string, unknown>)[key];
}
return current;
};
const joinPathAndQuery = (path: string, query: string) => {
if (!path) {
return query ? `?${query}` : "";
}
return query ? `${path}?${query}` : path;
};
const resolveEntrySummary = (entry: DebugLogEntry) => {
const fields = entry.fields;
const requestUri = stringifyDebugValue(getField(fields, ["request", "uri"]));
if (requestUri) {
return requestUri;
}
const requestHost = stringifyDebugValue(getField(fields, ["request", "host"]));
if (requestHost) {
return requestHost;
}
const path = stringifyDebugValue(fields?.path);
const rawQuery = stringifyDebugValue(fields?.rawQuery);
const url = joinPathAndQuery(path, rawQuery);
if (url) {
return url;
}
return entry.message || "-";
};
const resolveEntryMethod = (entry: DebugLogEntry) => {
return (
stringifyDebugValue(getField(entry.fields, ["request", "method"])) ||
stringifyDebugValue(entry.fields?.method) ||
"-"
);
};
const resolveRequestMeta = (entry: DebugLogEntry) => {
const fields = entry.fields;
const path = stringifyDebugValue(fields?.path);
const rawQuery = stringifyDebugValue(fields?.rawQuery);
const url = resolveEntrySummary(entry);
return {
method: resolveEntryMethod(entry),
url,
host: stringifyDebugValue(getField(fields, ["request", "host"])),
path: path ? joinPathAndQuery(path, rawQuery) : "",
proto:
stringifyDebugValue(getField(fields, ["request", "proto"])) ||
stringifyDebugValue(fields?.proto),
attempt: stringifyDebugValue(getField(fields, ["request", "attempt"])),
clientIP: stringifyDebugValue(fields?.clientIP),
handler: stringifyDebugValue(fields?.handler),
headers:
stringifyDebugValue(getField(fields, ["request", "header"])) ||
stringifyDebugValue(fields?.requestHeader),
body:
stringifyDebugValue(getField(fields, ["request", "body"])) ||
stringifyDebugValue(fields?.requestBody),
};
};
const resolveResponseMeta = (entry: DebugLogEntry) => {
const fields = entry.fields;
return {
status:
stringifyDebugValue(getField(fields, ["response", "status"])) ||
stringifyDebugValue(fields?.status),
statusCode:
stringifyDebugValue(getField(fields, ["response", "statusCode"])) ||
stringifyDebugValue(fields?.status),
proto: stringifyDebugValue(getField(fields, ["response", "proto"])),
durationMs:
stringifyDebugValue(getField(fields, ["response", "durationMs"])) ||
stringifyDebugValue(fields?.durationMs),
size:
stringifyDebugValue(getField(fields, ["response", "size"])) ||
stringifyDebugValue(fields?.responseSize),
receivedAt: stringifyDebugValue(getField(fields, ["response", "receivedAt"])),
abortWithErrors: stringifyDebugValue(fields?.abortWithErrors),
headers:
stringifyDebugValue(getField(fields, ["response", "header"])) ||
stringifyDebugValue(fields?.responseHeader),
body:
stringifyDebugValue(getField(fields, ["response", "body"])) ||
stringifyDebugValue(fields?.responseBody),
};
};
const detailTabs: { id: DebugDetailTab; label: string }[] = [
{ id: "overview", label: "概览" },
{ id: "request", label: "请求" },
{ id: "response", label: "响应" },
{ id: "raw", label: "原始字段" },
];
const DetailCodeBlock = (props: { title: string; value: string }) => {
return (
<div class="rounded-[18px] border border-white/10 bg-zinc-950/90 p-3">
<p class="mb-2 text-xs font-medium tracking-[0.2em] text-slate-400 uppercase">
{props.title}
</p>
<pre class="whitespace-pre-wrap break-all text-xs leading-6 text-emerald-200">
{props.value || "-"}
</pre>
</div>
);
};
const DebugLogs = () => {
const navigate = useNavigate();
const [settingsState, setSettingsState] = createSignal(
settingsStore.getState(),
);
const [debugEntries, setDebugEntries] = createSignal<DebugLogEntry[]>([]);
const [debugSocketState, setDebugSocketState] =
createSignal<DebugSocketState>("connecting");
const [debugError, setDebugError] = createSignal("");
const [selectedDebugEntryId, setSelectedDebugEntryId] = createSignal(0);
const [selectedDetailTab, setSelectedDetailTab] =
createSignal<DebugDetailTab>("overview");
let debugLogContainerRef: HTMLDivElement | undefined;
let debugSocket: WebSocket | null = null;
let reconnectTimer: number | undefined;
let manualClose = false;
const debugEntryKeySet = new Set<number>();
const isDebugMode = createMemo(() => {
return settingsState().debugEnabled;
});
const latestDebugEntry = createMemo(() => {
const rows = debugEntries();
return rows.length > 0 ? rows[rows.length - 1] : null;
});
const selectedDebugEntry = createMemo(() => {
const currentId = selectedDebugEntryId();
return debugEntries().find((item) => item.id === currentId) ?? null;
});
const selectedRequestMeta = createMemo(() => {
const entry = selectedDebugEntry();
return entry ? resolveRequestMeta(entry) : null;
});
const selectedResponseMeta = createMemo(() => {
const entry = selectedDebugEntry();
return entry ? resolveResponseMeta(entry) : null;
});
const debugSourceCount = createMemo(() => {
return new Set(debugEntries().map((item) => item.source)).size;
});
const resetDebugEntries = () => {
debugEntryKeySet.clear();
setDebugEntries([]);
setSelectedDebugEntryId(0);
};
const replaceDebugEntries = (entries: DebugLogEntry[]) => {
const nextEntries = entries.slice(-MAX_DEBUG_ENTRIES);
debugEntryKeySet.clear();
for (const entry of nextEntries) {
debugEntryKeySet.add(entry.id);
}
setDebugEntries(nextEntries);
setSelectedDebugEntryId((prev) => {
if (prev && nextEntries.some((entry) => entry.id === prev)) {
return prev;
}
return nextEntries[nextEntries.length - 1]?.id ?? 0;
});
};
const appendDebugEntry = (entry: DebugLogEntry) => {
if (debugEntryKeySet.has(entry.id)) {
return;
}
debugEntryKeySet.add(entry.id);
setDebugEntries((prev) => {
const next = [...prev, entry];
while (next.length > MAX_DEBUG_ENTRIES) {
const removed = next.shift();
if (removed) {
debugEntryKeySet.delete(removed.id);
}
}
return next;
});
if (!selectedDebugEntryId()) {
setSelectedDebugEntryId(entry.id);
}
};
const loadDebugSnapshot = async () => {
try {
const entries = await fetchDebugLogSnapshot();
replaceDebugEntries(entries);
setDebugError("");
} catch (error) {
setDebugError(
error instanceof Error ? error.message : "获取调试日志快照失败",
);
}
};
const scheduleReconnect = () => {
if (manualClose || reconnectTimer) {
return;
}
reconnectTimer = window.setTimeout(() => {
reconnectTimer = undefined;
connectDebugSocket();
}, 1800);
};
const disconnectDebugSocket = () => {
manualClose = true;
if (reconnectTimer) {
window.clearTimeout(reconnectTimer);
reconnectTimer = undefined;
}
if (debugSocket) {
debugSocket.close();
debugSocket = null;
}
setDebugSocketState("closed");
};
const connectDebugSocket = () => {
if (!isDebugMode() || debugSocket) {
return;
}
manualClose = false;
setDebugSocketState("connecting");
setDebugError("");
try {
const socket = new WebSocket(resolveDebugLogWsUrl());
debugSocket = socket;
socket.addEventListener("open", () => {
setDebugSocketState("open");
setDebugError("");
});
socket.addEventListener("message", (event) => {
try {
const payload = JSON.parse(event.data) as DebugLogEntry;
if (!payload || typeof payload.id !== "number") {
return;
}
appendDebugEntry(payload);
} catch {
// Ignore malformed payloads.
}
});
socket.addEventListener("error", () => {
setDebugSocketState("error");
setDebugError("调试日志流连接失败,请确认设置页中的调试开关已开启。");
});
socket.addEventListener("close", () => {
debugSocket = null;
if (manualClose) {
setDebugSocketState("closed");
return;
}
setDebugSocketState("closed");
scheduleReconnect();
});
} catch (error) {
debugSocket = null;
setDebugSocketState("error");
setDebugError(error instanceof Error ? error.message : "连接失败");
scheduleReconnect();
}
};
onMount(() => {
const unsubscribeSettings = settingsStore.subscribe((state) => {
setSettingsState(state);
});
onCleanup(() => {
unsubscribeSettings();
disconnectDebugSocket();
});
});
createEffect(() => {
if (!isDebugMode()) {
disconnectDebugSocket();
resetDebugEntries();
void navigate("/logs", { replace: true });
return;
}
void loadDebugSnapshot();
connectDebugSocket();
});
createEffect(() => {
debugEntries().length;
if (!settingsState().autoScrollLogs) {
return;
}
requestAnimationFrame(() => {
const element = debugLogContainerRef;
if (element) {
element.scrollTo({ top: element.scrollHeight, behavior: "smooth" });
}
});
});
createEffect(() => {
if (debugLogContainerRef) {
debugLogContainerRef.style.fontSize = `${Math.max(settingsState().logFontSize, 12)}px`;
}
});
createEffect(() => {
selectedDebugEntryId();
setSelectedDetailTab("overview");
});
const clearDebugLogs = () => {
resetDebugEntries();
};
const downloadDebugLogs = () => {
window.open(resolveDebugLogDownloadUrl(), "_blank", "noopener,noreferrer");
};
return (
<div class="flex h-full min-h-0 flex-col overflow-hidden">
<div class="rounded-[26px] border border-white/80 bg-[linear-gradient(135deg,rgba(236,254,255,0.95),rgba(255,255,255,0.94))] px-5 py-4 shadow-[0_18px_50px_-30px_rgba(8,145,178,0.22)]">
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<div class="max-w-xl">
<p class="text-[10px] font-medium tracking-[0.28em] text-cyan-700/80 uppercase">
Backend Observer
</p>
<h1 class="mt-1.5 text-2xl font-semibold tracking-tight text-zinc-950">
</h1>
<p class="mt-1 text-xs leading-5 text-zinc-600 sm:text-sm">
</p>
</div>
<div class="flex flex-wrap items-center gap-2 xl:justify-end">
<span
class={`rounded-full border px-3 py-1 text-xs ${debugStatusClassMap[debugSocketState()]}`}
>
{debugStatusLabelMap[debugSocketState()]}
</span>
<span class="rounded-full border border-white/80 bg-white/85 px-3 py-1 text-xs text-zinc-700">
TOTAL {debugEntries().length}
</span>
<span class="rounded-full border border-white/80 bg-white/85 px-3 py-1 text-xs text-zinc-700">
SOURCE {debugSourceCount()}
</span>
<Show when={latestDebugEntry()}>
<span class="rounded-full border border-white/80 bg-white/85 px-3 py-1 text-xs text-zinc-600">
: {latestDebugEntry()?.time} / {latestDebugEntry()?.source}
</span>
</Show>
</div>
</div>
</div>
<Show
when={isDebugMode()}
fallback={
<div class="mt-3 flex flex-1 items-center justify-center rounded-[26px] border border-dashed border-zinc-300 bg-white/60 px-6 text-zinc-500">
</div>
}
>
<section class="mt-3 flex min-h-0 flex-1 flex-col overflow-hidden rounded-[26px] border border-zinc-200/80 bg-[linear-gradient(180deg,rgba(8,15,30,0.98),rgba(15,23,42,1))] shadow-[0_24px_60px_-32px_rgba(15,23,42,0.45)]">
<div class="shrink-0 border-b border-white/10 px-4 py-3">
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<div>
<p class="text-[10px] font-medium tracking-[0.28em] text-cyan-300/80 uppercase">
Debug Stream
</p>
<p class="mt-1 text-xs leading-5 text-slate-400 sm:text-sm">
WebSocket
</p>
</div>
<div class="flex flex-wrap items-center gap-2 xl:justify-end">
<button
type="button"
class="rounded-full border border-cyan-300/25 bg-cyan-400/10 px-3 py-1.5 text-xs font-medium text-cyan-100 transition hover:bg-cyan-400/20 active:bg-cyan-400/30"
onClick={connectDebugSocket}
disabled={
debugSocketState() === "open" ||
debugSocketState() === "connecting"
}
>
{debugSocketState() === "connecting" ? "连接中..." : "重新连接"}
</button>
<button
type="button"
class="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-slate-200 transition hover:bg-white/10 active:bg-white/20"
onClick={() => void loadDebugSnapshot()}
>
</button>
<button
type="button"
class="rounded-full border border-cyan-300/25 bg-cyan-400/10 px-3 py-1.5 text-xs font-medium text-cyan-100 transition hover:bg-cyan-400/20 active:bg-cyan-400/30"
onClick={downloadDebugLogs}
>
</button>
<button
type="button"
class="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-slate-200 transition hover:bg-white/10 active:bg-white/20"
onClick={disconnectDebugSocket}
disabled={debugSocketState() !== "open"}
>
</button>
<button
type="button"
class="rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-1.5 text-xs font-medium text-rose-100 transition hover:bg-rose-400/20 active:bg-rose-400/30"
onClick={clearDebugLogs}
>
</button>
</div>
</div>
<Show when={debugError()}>
<p class="mt-2 text-xs text-rose-300">{debugError()}</p>
</Show>
</div>
<div class="grid min-h-0 flex-1 gap-3 p-3 xl:grid-cols-[minmax(0,1.35fr)_minmax(320px,0.95fr)]">
<div class="flex min-h-0 flex-col overflow-hidden rounded-[22px] border border-white/10 bg-white/4">
<div class="grid grid-cols-[108px_88px_92px_minmax(240px,1fr)_minmax(220px,1fr)] gap-3 border-b border-white/10 bg-black/20 px-3 py-2 text-[11px] tracking-wide text-slate-300 uppercase">
<span></span>
<span></span>
<span></span>
<span></span>
<span>URL / Path</span>
</div>
<div
ref={debugLogContainerRef}
class="min-h-0 flex-1 overflow-auto px-2 py-2 font-mono text-slate-100"
>
<Show
when={debugEntries().length > 0}
fallback={
<div class="flex h-full items-center justify-center rounded-[18px] border border-dashed border-white/10 bg-white/3 px-6 text-slate-500">
</div>
}
>
<div class="min-w-[980px] space-y-1">
<For each={debugEntries()}>
{(entry) => (
<button
type="button"
class={`grid w-full grid-cols-[108px_88px_92px_minmax(240px,1fr)_minmax(220px,1fr)] items-start gap-3 rounded-[16px] border px-3 py-2 text-left transition ${
selectedDebugEntryId() === entry.id
? "border-cyan-300/35 bg-cyan-400/10"
: "border-transparent bg-white/3 hover:border-white/10 hover:bg-white/6 active:bg-white/10"
}`}
onClick={() => setSelectedDebugEntryId(entry.id)}
>
<span class="truncate text-cyan-100">{entry.time}</span>
<span class="truncate text-amber-100">{entry.source}</span>
<span class="truncate text-emerald-200">
{entry.level.toUpperCase()}
</span>
<span class="truncate text-slate-100" title={entry.message}>
{entry.message}
</span>
<span
class="truncate text-slate-400"
title={resolveEntrySummary(entry)}
>
{resolveEntrySummary(entry)}
</span>
</button>
)}
</For>
</div>
</Show>
</div>
</div>
<div class="flex min-h-0 flex-col overflow-hidden rounded-[22px] border border-white/10 bg-black/20">
<div class="border-b border-white/10 px-4 py-3">
<p class="text-sm font-semibold text-white"></p>
<p class="mt-1 text-xs text-slate-400">
</p>
</div>
<div class="min-h-0 flex-1 overflow-auto px-4 py-3 font-mono text-sm text-slate-200">
<Show
when={selectedDebugEntry()}
fallback={<p class="text-slate-500"></p>}
>
<div class="space-y-3">
<div class="flex flex-wrap gap-2">
<For each={detailTabs}>
{(tab) => (
<button
type="button"
class={`rounded-full border px-3 py-1.5 text-xs transition ${
selectedDetailTab() === tab.id
? "border-cyan-300/35 bg-cyan-400/12 text-cyan-100"
: "border-white/10 bg-white/5 text-slate-300 hover:bg-white/10 active:bg-white/20"
}`}
onClick={() => setSelectedDetailTab(tab.id)}
>
{tab.label}
</button>
)}
</For>
</div>
<Show when={selectedDetailTab() === "overview"}>
<div class="grid gap-3">
<div class="grid gap-2 rounded-[18px] border border-white/10 bg-white/5 px-4 py-3 text-xs text-slate-300">
<p>ID: {selectedDebugEntry()?.id}</p>
<p>: {selectedDebugEntry()?.time}</p>
<p>: {selectedDebugEntry()?.source}</p>
<p>: {selectedDebugEntry()?.level}</p>
<p>: {selectedDebugEntry()?.message}</p>
<p>URL / Path: {resolveEntrySummary(selectedDebugEntry()!)}</p>
<p>Method: {selectedRequestMeta()?.method || "-"}</p>
<Show when={selectedResponseMeta()?.status}>
<p>: {selectedResponseMeta()?.status}</p>
</Show>
<Show when={selectedResponseMeta()?.durationMs}>
<p>: {selectedResponseMeta()?.durationMs} ms</p>
</Show>
<Show when={selectedDebugEntry()?.caller}>
<p>: {selectedDebugEntry()?.caller}</p>
</Show>
<Show when={selectedDebugEntry()?.logger}>
<p>Logger: {selectedDebugEntry()?.logger}</p>
</Show>
</div>
</div>
</Show>
<Show when={selectedDetailTab() === "request"}>
<div class="space-y-3">
<div class="grid gap-2 rounded-[18px] border border-white/10 bg-white/5 px-4 py-3 text-xs text-slate-300">
<p>Method: {selectedRequestMeta()?.method || "-"}</p>
<p>URL: {selectedRequestMeta()?.url || "-"}</p>
<Show when={selectedRequestMeta()?.path}>
<p>Path: {selectedRequestMeta()?.path}</p>
</Show>
<Show when={selectedRequestMeta()?.host}>
<p>Host: {selectedRequestMeta()?.host}</p>
</Show>
<Show when={selectedRequestMeta()?.proto}>
<p>Protocol: {selectedRequestMeta()?.proto}</p>
</Show>
<Show when={selectedRequestMeta()?.attempt}>
<p>Attempt: {selectedRequestMeta()?.attempt}</p>
</Show>
<Show when={selectedRequestMeta()?.clientIP}>
<p>Client IP: {selectedRequestMeta()?.clientIP}</p>
</Show>
<Show when={selectedRequestMeta()?.handler}>
<p>Handler: {selectedRequestMeta()?.handler}</p>
</Show>
</div>
<DetailCodeBlock
title="请求头"
value={selectedRequestMeta()?.headers || ""}
/>
<DetailCodeBlock
title="请求体"
value={selectedRequestMeta()?.body || ""}
/>
</div>
</Show>
<Show when={selectedDetailTab() === "response"}>
<div class="space-y-3">
<div class="grid gap-2 rounded-[18px] border border-white/10 bg-white/5 px-4 py-3 text-xs text-slate-300">
<p>: {selectedResponseMeta()?.status || "-"}</p>
<Show when={selectedResponseMeta()?.statusCode}>
<p>Status Code: {selectedResponseMeta()?.statusCode}</p>
</Show>
<Show when={selectedResponseMeta()?.proto}>
<p>Protocol: {selectedResponseMeta()?.proto}</p>
</Show>
<Show when={selectedResponseMeta()?.durationMs}>
<p>: {selectedResponseMeta()?.durationMs} ms</p>
</Show>
<Show when={selectedResponseMeta()?.size}>
<p>: {selectedResponseMeta()?.size}</p>
</Show>
<Show when={selectedResponseMeta()?.receivedAt}>
<p>: {selectedResponseMeta()?.receivedAt}</p>
</Show>
<Show when={selectedResponseMeta()?.abortWithErrors}>
<p>: {selectedResponseMeta()?.abortWithErrors}</p>
</Show>
</div>
<DetailCodeBlock
title="响应头"
value={selectedResponseMeta()?.headers || ""}
/>
<DetailCodeBlock
title="响应体"
value={selectedResponseMeta()?.body || ""}
/>
</div>
</Show>
<Show when={selectedDetailTab() === "raw"}>
<DetailCodeBlock
title="原始字段"
value={stringifyDebugFields(selectedDebugEntry()?.fields)}
/>
</Show>
</div>
</Show>
</div>
</div>
</div>
</section>
</Show>
</div>
);
};
export default DebugLogs;

View File

@@ -10,6 +10,17 @@ import {
import { accountStore } from "~/store/account";
import { settingsStore } from "~/store/settings";
type LogRow = {
id: string;
accountId: string;
accountName: string;
host: string;
seq: number;
timestamp: string;
timestampValue: number;
content: string;
};
const extractTimestamp = (message: string) => {
const match = message.match(/^\[(\d{2}:\d{2}:\d{2})\]\s*/);
return match?.[1] ?? null;
@@ -25,7 +36,6 @@ const parseTimestampValue = (timestamp: string | null) => {
}
const [hours, minutes, seconds] = timestamp.split(":").map(Number);
if ([hours, minutes, seconds].some((item) => Number.isNaN(item))) {
return -1;
}
@@ -33,6 +43,11 @@ const parseTimestampValue = (timestamp: string | null) => {
return hours * 3600 + minutes * 60 + seconds;
};
const normalizeContent = (message: string) => {
const value = stripTimestamp(message).replace(/\s+/g, " ").trim();
return value || "-";
};
const Logs = () => {
const [accountState, setAccountState] = createSignal(accountStore.getState());
const [settingsState, setSettingsState] = createSignal(
@@ -66,75 +81,60 @@ const Logs = () => {
) as Record<string, { name: string; host: string }>;
});
const allLogs = createMemo(() => {
return Object.entries(accountState().studyLogsMap)
.flatMap(([accountId, messages]) => {
const logRows = createMemo<LogRow[]>(() => {
const rows = Object.entries(accountState().studyLogsMap).flatMap(
([accountId, messages]) => {
const accountInfo = accountInfoMap()[accountId];
const accountName = accountInfo?.name ?? accountId;
return messages.map((message, index) => {
const timestamp = extractTimestamp(message);
return {
id: `${accountId}-${index}`,
accountId,
accountName,
index: index + 1,
timestamp,
accountName: accountInfo?.name ?? accountId,
host: accountInfo?.host ?? "-",
seq: index + 1,
timestamp: timestamp ?? "--:--:--",
timestampValue: parseTimestampValue(timestamp),
content: stripTimestamp(message),
raw: message,
content: normalizeContent(message),
};
});
})
.sort((left, right) => {
if (left.timestampValue !== right.timestampValue) {
return left.timestampValue - right.timestampValue;
}
if (left.index !== right.index) {
return left.index - right.index;
}
return left.accountId.localeCompare(right.accountId);
});
});
const accountSummaries = createMemo(() => {
const accountIds = Array.from(
new Set([
...accountState().accounts.map((account) => account.id),
...Object.keys(accountState().studyLogsMap),
]),
},
);
return accountIds.map((accountId) => {
const accountInfo = accountInfoMap()[accountId];
const logs = accountState().studyLogsMap[accountId] ?? [];
const latestMessage = logs[logs.length - 1] ?? "暂无日志";
return rows.sort((left, right) => {
const leftTs =
left.timestampValue >= 0 ? left.timestampValue : Number.MAX_SAFE_INTEGER;
const rightTs =
right.timestampValue >= 0
? right.timestampValue
: Number.MAX_SAFE_INTEGER;
return {
id: accountId,
name: accountInfo?.name ?? accountId,
host: accountInfo?.host ?? "未知账号来源",
total: logs.length,
latestMessage: stripTimestamp(latestMessage),
latestTime: extractTimestamp(latestMessage),
};
if (leftTs !== rightTs) {
return leftTs - rightTs;
}
if (left.accountName !== right.accountName) {
return left.accountName.localeCompare(right.accountName, "zh-CN");
}
if (left.seq !== right.seq) {
return left.seq - right.seq;
}
return left.accountId.localeCompare(right.accountId);
});
});
const latestLog = createMemo(() => {
const logs = allLogs();
return logs.length > 0 ? logs[logs.length - 1] : null;
const totalAccountsWithLogs = createMemo(() => {
return Object.values(accountState().studyLogsMap).filter(
(messages) => messages.length > 0,
).length;
});
const totalAccountsWithLogs = createMemo(() => {
return accountSummaries().filter((item) => item.total > 0).length;
const latestLog = createMemo(() => {
const rows = logRows();
return rows.length > 0 ? rows[rows.length - 1] : null;
});
createEffect(() => {
allLogs().length;
logRows().length;
if (!settingsState().autoScrollLogs) {
return;
@@ -164,13 +164,13 @@ const Logs = () => {
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<div class="max-w-xl">
<p class="text-[10px] font-medium tracking-[0.28em] text-amber-700/80 uppercase">
Unified Stream
Study Stream
</p>
<h1 class="mt-1.5 text-2xl font-semibold tracking-tight text-zinc-950">
</h1>
<p class="mt-1 text-xs leading-5 text-zinc-600 sm:text-sm">
</p>
</div>
@@ -180,7 +180,7 @@ const Logs = () => {
TOTAL LOGS
</span>
<span class="text-sm font-semibold text-zinc-950">
{allLogs().length}
{logRows().length}
</span>
</div>
<div class="flex items-center gap-2 rounded-full border border-white/80 bg-white/85 px-3 py-1.5 shadow-sm">
@@ -191,17 +191,14 @@ const Logs = () => {
{totalAccountsWithLogs()}
</span>
</div>
<div class="flex items-center gap-2 rounded-full border border-white/80 bg-white/85 px-3 py-1.5 shadow-sm">
<span class="text-[10px] tracking-[0.18em] text-zinc-400 uppercase">
AUTO SCROLL
</span>
<span class="text-sm font-medium text-zinc-900">
{settingsState().autoScrollLogs ? "跟随最新" : "手动查看"}
</span>
</div>
<Show when={latestLog()}>
<div class="rounded-full border border-white/80 bg-white/85 px-3 py-1.5 text-xs text-zinc-600 shadow-sm">
: {latestLog()?.timestamp} / {latestLog()?.accountName}
</div>
</Show>
<button
type="button"
class="rounded-full border border-rose-200 bg-[linear-gradient(135deg,rgba(255,241,242,0.95),rgba(255,255,255,0.9))] px-3.5 py-1.5 text-sm font-medium text-rose-600 transition hover:-translate-y-px hover:bg-rose-50"
class="rounded-full border border-rose-200 bg-[linear-gradient(135deg,rgba(255,241,242,0.95),rgba(255,255,255,0.9))] px-3.5 py-1.5 text-sm font-medium text-rose-600 transition hover:-translate-y-px hover:bg-rose-50 active:bg-rose-100 active:scale-[0.97]"
onClick={clearAllLogs}
>
@@ -210,120 +207,70 @@ const Logs = () => {
</div>
</div>
<div class="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-3 py-3">
<section class="flex min-h-0 flex-col overflow-hidden rounded-[26px] border border-white/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.92),rgba(248,250,252,0.9))] shadow-[0_18px_48px_-30px_rgba(15,23,42,0.24)]">
<div class="shrink-0 border-b border-zinc-200/80 px-4 py-3">
<p class="text-base font-semibold text-zinc-950"></p>
<p class="mt-1 text-xs text-zinc-500"></p>
<section class="mt-3 flex min-h-0 flex-1 flex-col overflow-hidden rounded-[26px] border border-zinc-200/80 bg-[linear-gradient(180deg,rgba(11,18,32,0.98),rgba(10,10,16,1))] shadow-[0_24px_60px_-32px_rgba(15,23,42,0.45)]">
<div class="shrink-0 border-b border-white/10 px-4 py-3">
<div class="flex flex-wrap items-center gap-2 text-xs">
<span class="rounded-full border border-emerald-400/20 bg-emerald-400/10 px-3 py-1 text-emerald-200">
{settingsState().showLogTimestamps ? "显示时间" : "隐藏时间"}
</span>
<span class="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-slate-300">
{settingsState().autoScrollLogs ? "自动滚动中" : "自动滚动关闭"}
</span>
</div>
</div>
<div class="flex min-h-0 shrink-0 flex-row gap-3 space-y-2.5 overflow-y-auto px-3 py-3">
<For each={accountSummaries()}>
{(account) => (
<div class="rounded-2xl border border-zinc-200 bg-white p-4 transition hover:shadow-md">
{/* 顶部 */}
<div class="flex items-center justify-between">
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-zinc-900">
{account.name}
</p>
<p class="text-xs text-zinc-400">{account.host}</p>
</div>
<div class="rounded-full bg-amber-100 px-2.5 py-1 text-xs text-amber-700">
{account.total}
</div>
</div>
{/* 最新日志 */}
<div class="mt-3 rounded-xl bg-zinc-50 p-3">
<div class="flex justify-between text-[11px] text-zinc-400">
<span>Latest</span>
<span>{account.latestTime ?? "--:--"}</span>
</div>
<p class="mt-1 line-clamp-2 text-xs text-zinc-600">
{account.latestMessage}
</p>
</div>
</div>
)}
</For>
</div>
</section>
<section class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-[26px] border border-zinc-200/80 bg-[linear-gradient(180deg,rgba(11,18,32,0.98),rgba(10,10,16,1))] shadow-[0_24px_60px_-32px_rgba(15,23,42,0.45)]">
<div class="shrink-0 border-b border-white/10 px-4 py-3">
<div class="flex flex-row items-center justify-between gap-2">
<div>
<p class="text-base font-semibold text-slate-400"></p>
<p class="mt-1 text-xs text-slate-400">
</p>
<div
ref={logContainerRef}
class="min-h-0 flex-1 overflow-auto px-3 pt-0 pb-3 font-mono text-emerald-200"
>
<Show
when={logRows().length > 0}
fallback={
<div class="flex h-full items-center justify-center rounded-[26px] border border-dashed border-white/10 bg-white/3 px-6 text-slate-500">
</div>
<div class="flex flex-wrap items-center gap-2 text-xs">
<span class="rounded-full border border-emerald-400/20 bg-emerald-400/10 px-3 py-1 text-emerald-200">
{settingsState().showLogTimestamps ? "显示时间" : "隐藏时间"}
</span>
<span class="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-slate-300">
{settingsState().autoScrollLogs
? "自动滚动中"
: "自动滚动关闭"}
</span>
<Show when={latestLog()}>
<span class="rounded-full border border-amber-400/20 bg-amber-400/10 px-3 py-1 text-amber-200">
{latestLog()?.accountName}
</span>
</Show>
</div>
</div>
</div>
<div
ref={logContainerRef}
class="min-h-0 flex-1 overflow-y-auto px-4 py-3 font-mono text-emerald-200"
}
>
<Show
when={allLogs().length > 0}
fallback={
<div class="flex h-full items-center justify-center rounded-[26px] border border-dashed border-white/10 bg-white/3 px-6 text-slate-500">
</div>
}
>
<div class="space-y-1">
<For each={allLogs()}>
{(log) => (
<div class="rounded-lg px-3 py-2 transition hover:bg-white/4">
{/* 头部 */}
<div class="flex items-center justify-between text-[11px] text-slate-400">
<div class="flex items-center gap-2">
<span class="text-amber-300">{log.accountName}</span>
<span>#{log.index}</span>
<div class="min-w-[980px]">
<div class="sticky top-0 z-20 grid grid-cols-[100px_140px_220px_180px_80px_minmax(320px,1fr)] gap-3 border-b border-white/10 bg-zinc-950 px-3 py-2 text-[11px] tracking-wide text-slate-300 uppercase shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
<Show when={settingsState().showLogTimestamps}>
<span class="text-emerald-300/80">
{log.timestamp}
</span>
</Show>
</div>
<span class="text-slate-500">{log.accountId}</span>
</div>
{/* 内容 */}
<p class="mt-1 text-sm leading-5 whitespace-pre-wrap text-emerald-200">
{log.content}
</p>
<div class="relative z-0">
<For each={logRows()}>
{(row) => (
<div class="grid grid-cols-[100px_140px_220px_180px_80px_minmax(320px,1fr)] items-center gap-3 border-b border-white/5 px-3 py-2 hover:bg-white/4">
<span class="truncate text-emerald-300">
{settingsState().showLogTimestamps
? row.timestamp
: "--:--:--"}
</span>
<span class="truncate text-amber-200" title={row.accountName}>
{row.accountName}
</span>
<span class="truncate text-slate-300" title={row.accountId}>
{row.accountId}
</span>
<span class="truncate text-slate-400" title={row.host}>
{row.host}
</span>
<span class="truncate text-cyan-200">#{row.seq}</span>
<span class="truncate text-emerald-100" title={row.content}>
{row.content}
</span>
</div>
)}
</For>
</div>
</Show>
</div>
</section>
</div>
</div>
</Show>
</div>
</section>
</div>
);
};

View File

@@ -1,4 +1,5 @@
import { createMemo, createSignal, For, onCleanup, onMount } from "solid-js";
import { createMemo, createSignal, For, onCleanup, onMount } from "solid-js";
import { fetchDebugConfig, updateDebugConfig } from "~/service/debugLog";
import { hostApi } from "~/service/wk";
import { getMergedHosts, settingsStore } from "~/store/settings";
@@ -8,6 +9,15 @@ const Setting = () => {
const [hostValue, setHostValue] = createSignal("");
const [isLoadingRemoteHosts, setIsLoadingRemoteHosts] = createSignal(false);
const [hostError, setHostError] = createSignal("");
const [debugSyncing, setDebugSyncing] = createSignal(false);
const [debugError, setDebugError] = createSignal("");
const [backendDebugState, setBackendDebugState] = createSignal<{
enabled: boolean;
proxy: string;
skipSSLVerify: boolean;
buildMode: string;
proxyConfigured: boolean;
} | null>(null);
onMount(() => {
const unsubscribe = settingsStore.subscribe((nextState) => {
@@ -44,6 +54,49 @@ const Setting = () => {
}
};
const refreshDebugConfig = async () => {
try {
const res = await fetchDebugConfig();
setBackendDebugState({
enabled: res.data.enabled,
proxy: res.data.proxy,
skipSSLVerify: res.data.skip_ssl_verify,
buildMode: res.data.build_mode,
proxyConfigured: res.data.proxy_configured,
});
setDebugError("");
} catch (error) {
const message =
error instanceof Error ? error.message : "获取调试配置失败。";
setDebugError(message);
}
};
const handleDebugToggle = async (enabled: boolean) => {
const previous = state().debugEnabled;
settingsStore.getState().setDebugEnabled(enabled);
setDebugSyncing(true);
setDebugError("");
try {
const res = await updateDebugConfig(enabled);
setBackendDebugState({
enabled: res.data.enabled,
proxy: res.data.proxy,
skipSSLVerify: res.data.skip_ssl_verify,
buildMode: res.data.build_mode,
proxyConfigured: res.data.proxy_configured,
});
} catch (error) {
settingsStore.getState().setDebugEnabled(previous);
const message =
error instanceof Error ? error.message : "更新调试开关失败。";
setDebugError(message);
} finally {
setDebugSyncing(false);
}
};
const addLocalHost = () => {
if (!hostLabel().trim() || !hostValue().trim()) {
return;
@@ -61,376 +114,370 @@ const Setting = () => {
if (state().remoteHosts.length === 0) {
void loadRemoteHosts();
}
void refreshDebugConfig();
});
const panelClass =
"rounded-[28px] border border-white/80 bg-white/85 p-5 shadow-[0_18px_50px_-28px_rgba(15,23,42,0.25)]";
const sectionCardClass = "rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4";
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 class="flex shrink-0 items-center justify-between gap-4 rounded-[28px] border border-white/80 bg-[linear-gradient(125deg,rgba(255,255,255,0.96),rgba(240,249,255,0.95)_55%,rgba(236,254,255,0.9))] px-6 py-5 shadow-[0_22px_50px_-32px_rgba(15,23,42,0.22)]">
<div>
<p class="text-xs font-medium tracking-[0.26em] text-cyan-700/75 uppercase">
Settings Center
<p class="text-xs font-semibold tracking-[0.28em] text-cyan-700/80 uppercase">
Preference Center
</p>
<h1 class="mt-2 text-2xl font-semibold text-zinc-900"></h1>
<p class="mt-1 text-sm text-zinc-500">
Host
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">
<div class="rounded-2xl border border-zinc-200/80 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 leading-5 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>
<div class="mt-5 min-h-0 flex-1 overflow-hidden">
<div class="grid h-full min-h-full w-full gap-5 lg:grid-cols-2">
<div class="flex min-h-0 h-full flex-col gap-5 overflow-y-auto pr-1">
<section class={panelClass}>
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-lg font-semibold text-zinc-900"></p>
<p class="mt-1 text-xs text-zinc-500"></p>
</div>
<button
type="button"
class="rounded-lg border border-rose-200 bg-rose-50 px-3 py-1.5 text-xs text-rose-600 transition hover:bg-rose-100 active:bg-rose-200"
onClick={() => settingsStore.getState().clearAllPersistedData()}
>
</button>
</div>
<div class="mt-4 grid gap-3">
<div class={sectionCardClass}>
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-sm font-medium text-zinc-900"></p>
<p class="mt-1 text-xs text-zinc-500"></p>
</div>
<button
type="button"
class="rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-xs text-zinc-700 transition hover:bg-zinc-100 active:bg-zinc-200"
onClick={() =>
settingsStore.getState().clearPersistedSection("accounts")
}
>
</button>
</div>
</div>
<div class={sectionCardClass}>
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-sm font-medium text-zinc-900"></p>
<p class="mt-1 text-xs text-zinc-500"></p>
</div>
<button
type="button"
class="rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-xs text-zinc-700 transition hover:bg-zinc-100 active:bg-zinc-200"
onClick={() =>
settingsStore.getState().clearPersistedSection("records")
}
>
</button>
</div>
</div>
<div class={sectionCardClass}>
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-sm font-medium text-zinc-900"></p>
<p class="mt-1 text-xs text-zinc-500"></p>
</div>
<button
type="button"
class="rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-xs text-zinc-700 transition hover:bg-zinc-100 active:bg-zinc-200"
onClick={() =>
settingsStore.getState().clearPersistedSection("logs")
}
>
</button>
</div>
</div>
</div>
</section>
<section class={panelClass}>
<div class="border-b border-zinc-200/70 pb-4">
<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">
<div class="mt-4 grid gap-4 md:grid-cols-2">
<div class={`${sectionCardClass} md:col-span-2`}>
<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">
/ SSL
</p>
</div>
<input
type="checkbox"
checked={state().debugEnabled}
disabled={debugSyncing()}
onChange={(event) =>
void handleDebugToggle(event.currentTarget.checked)
}
/>
</div>
<div class="mt-3 rounded-xl border border-zinc-200 bg-white px-4 py-3 text-sm text-zinc-600">
<p>{backendDebugState()?.enabled ? "已开启" : "已关闭"}</p>
<p class="mt-1">{backendDebugState()?.buildMode ?? "-"}</p>
<p class="mt-1">
{backendDebugState()?.proxyConfigured
? backendDebugState()?.proxy
: "未配置"}
</p>
<p class="mt-1">
SSL
{backendDebugState()?.skipSSLVerify ? "已配置" : "未配置"}
</p>
</div>
<input
type="checkbox"
checked={state().persistAccounts}
onChange={(event) =>
settingsStore
.getState()
.setPersistSection(
"accounts",
event.currentTarget.checked,
)
}
/>
{debugError() ? (
<div class="mt-3 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-600">
{debugError()}
</div>
) : null}
</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 class={sectionCardClass}>
<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>
<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 class={sectionCardClass}>
<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>
<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 class={sectionCardClass}>
<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={sectionCardClass}>
<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={`${sectionCardClass} md:col-span-2`}>
<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>
</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 class="flex min-h-0 h-full flex-col gap-5 overflow-y-auto pr-1">
<section class={panelClass}>
<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="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
<div class={`${sectionCardClass} mt-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",
)
}
<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 active:bg-zinc-200 disabled:cursor-not-allowed disabled:opacity-60"
disabled={isLoadingRemoteHosts()}
onClick={() => void loadRemoteHosts()}
>
<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>
{isLoadingRemoteHosts() ? "获取中..." : "获取远程 Host"}
</button>
</div>
<div class="mt-3 grid gap-3 md:grid-cols-2">
<input
class="mt-4 w-full"
type="range"
min="11"
max="16"
value={state().logFontSize}
onInput={(event) =>
settingsStore
.getState()
.setLogFontSize(Number(event.currentTarget.value))
}
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="名称,如:校内测试"
/>
</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))
}
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="Hostexample.com"
/>
</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>
</div>
<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()}
class="mt-3 rounded-xl bg-cyan-500 px-4 py-2 text-sm text-white transition hover:bg-cyan-600 active:bg-cyan-700"
onClick={addLocalHost}
>
{isLoadingRemoteHosts() ? "获取中..." : "获取远程 Host"}
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="Hostexample.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>
{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>
</section>
<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>
<section class={panelClass}>
<div class="flex items-center justify-between border-b border-zinc-200 pb-4">
<p class="text-lg font-semibold text-zinc-900">Host </p>
<p class="text-xs text-zinc-500"></p>
</div>
<div class="mt-4 grid gap-4 xl:grid-cols-2">
<div class={sectionCardClass}>
<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 active:bg-rose-100"
onClick={() =>
settingsStore.getState().removeLocalHost(item.host)
}
>
</button>
</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>
)}
</For>
</div>
</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 class={sectionCardClass}>
<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>
</section>
</div>
</div>
</div>
);

87
src/service/debugLog.ts Normal file
View File

@@ -0,0 +1,87 @@
import http from "~/service/http";
export type DebugLogEntry = {
id: number;
time: string;
level: string;
source: string;
message: string;
logger?: string;
caller?: string;
fields?: Record<string, unknown>;
};
type DebugLogListResponse = {
code: number;
message: string;
data: {
list: DebugLogEntry[];
};
};
type DebugConfigResponse = {
code: number;
message: string;
data: {
enabled: boolean;
proxy: string;
skip_ssl_verify: boolean;
build_mode: string;
proxy_configured: boolean;
};
};
const toWsProtocol = (protocol: string) => {
return protocol === "https:" ? "wss:" : "ws:";
};
const resolveDebugLogUrl = (pathname: string) => {
const baseUrl = import.meta.env.VITE_BASE_URL as string | undefined;
if (baseUrl && /^https?:\/\//.test(baseUrl)) {
const url = new URL(baseUrl);
url.pathname = pathname;
url.search = "";
url.hash = "";
return url.toString();
}
const url = new URL(pathname, window.location.origin);
return url.toString();
};
export const resolveDebugLogWsUrl = () => {
const url = new URL(resolveDebugLogUrl("/api/debug/logs/ws"));
url.protocol = toWsProtocol(url.protocol);
return url.toString();
};
export const resolveDebugLogDownloadUrl = () => {
return resolveDebugLogUrl("/api/debug/logs/download");
};
export const fetchDebugLogSnapshot = async () => {
const response = await fetch(resolveDebugLogUrl("/api/debug/logs"), {
method: "GET",
headers: {
accept: "application/json",
},
});
if (!response.ok) {
throw new Error(`获取调试日志失败: HTTP ${response.status}`);
}
const payload = (await response.json()) as DebugLogListResponse;
return payload.data.list ?? [];
};
export const fetchDebugConfig = async () => {
return await http.get<DebugConfigResponse>("/api/debug/config");
};
export const updateDebugConfig = async (enabled: boolean) => {
return await http.post<DebugConfigResponse>("/api/debug/config", {
enabled,
});
};

View File

@@ -5,6 +5,8 @@ type UnauthorizedHandler = (sessionId: string) => Promise<boolean>;
type SessionResolver = () => string | undefined;
const DEFAULT_HTTP_TIMEOUT_MS = 15000;
export type HttpClient = {
get<T>(url: string, config?: AxiosRequestConfig): Promise<T>;
post<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T>;
@@ -13,16 +15,40 @@ export type HttpClient = {
};
let unauthorizedHandler: UnauthorizedHandler | null = null;
const reloginTaskMap = new Map<string, Promise<boolean>>();
export const setUnauthorizedHandler = (handler: UnauthorizedHandler | null) => {
unauthorizedHandler = handler;
};
const runUnauthorizedHandler = (sessionId: string) => {
const pendingTask = reloginTaskMap.get(sessionId);
if (pendingTask) {
return pendingTask;
}
const task = (async () => {
if (!unauthorizedHandler) {
return false;
}
try {
return await unauthorizedHandler(sessionId);
} finally {
reloginTaskMap.delete(sessionId);
}
})();
reloginTaskMap.set(sessionId, task);
return task;
};
export const createHttpClient = (
resolveSessionId?: SessionResolver,
): HttpClient => {
const instance = axios.create({
baseURL: import.meta.env.VITE_BASE_URL,
timeout: DEFAULT_HTTP_TIMEOUT_MS,
});
instance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
@@ -52,7 +78,7 @@ export const createHttpClient = (
!url.includes("/api/login")
) {
config._retry = true;
const ok = await unauthorizedHandler(sessionId);
const ok = await runUnauthorizedHandler(sessionId);
if (ok) {
return instance.request(config);
@@ -82,3 +108,4 @@ export const createHttpClient = (
const http = createHttpClient();
export default http;
export { DEFAULT_HTTP_TIMEOUT_MS };

View File

@@ -0,0 +1,57 @@
/**
* Silent audio playback to prevent browser tab throttling during long-running tasks.
*
* Uses the Web Audio API to produce a nearly inaudible signal that keeps the
* browser from suspending the tab's timers and network requests.
*/
let audioContext: AudioContext | null = null;
let oscillatorNode: OscillatorNode | null = null;
let gainNode: GainNode | null = null;
/**
* Start playing silent audio. Safe to call multiple times — duplicate calls
* are ignored if audio is already playing.
*/
export const startSilentAudio = () => {
if (oscillatorNode) {
return;
}
try {
audioContext = new AudioContext();
gainNode = audioContext.createGain();
gainNode.gain.value = 0.001; // Nearly silent
oscillatorNode = audioContext.createOscillator();
oscillatorNode.type = "sine";
oscillatorNode.frequency.value = 1; // Sub-bass, inaudible
oscillatorNode.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillatorNode.start();
} catch {
// AudioContext may be unavailable in some environments; degrade silently.
oscillatorNode = null;
gainNode = null;
audioContext = null;
}
};
/**
* Stop playing silent audio and release resources.
*/
export const stopSilentAudio = () => {
try {
oscillatorNode?.stop();
} catch {
// Already stopped or never started
}
oscillatorNode?.disconnect();
gainNode?.disconnect();
audioContext?.close();
oscillatorNode = null;
gainNode = null;
audioContext = null;
};

302
src/service/update.ts Normal file
View File

@@ -0,0 +1,302 @@
export type ReleaseAsset = {
id: number;
name: string;
size: number;
download_count: number;
browser_download_url: string;
};
export type LatestRelease = {
id: number;
tag_name: string;
name: string;
body: string;
html_url: string;
published_at: string;
assets: ReleaseAsset[];
};
export type RuntimeOS = "windows" | "linux" | "darwin" | "unknown";
export type RuntimeArch = "amd64" | "arm64" | "unknown";
export type RuntimeTarget = {
os: RuntimeOS;
arch: RuntimeArch;
};
export const UPDATE_API_URL =
"https://gitea.kmux.cn/api/v1/repos/cqcst/wk-backend/releases/latest";
export const RELEASES_PAGE_URL =
"https://gitea.kmux.cn/cqcst/wk-backend/releases";
const RELEASE_HOST_ORIGIN = "https://gitea.kmux.cn";
type UADataHighEntropy = {
architecture?: string;
bitness?: string;
platform?: string;
};
type UAData = {
platform?: string;
getHighEntropyValues?: (
hints: string[],
) => Promise<UADataHighEntropy>;
};
const normalizeSemver = (value: string) => {
const match = value.match(/(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:\.(\d+))?/);
if (!match) {
return null;
}
return [1, 2, 3, 4].map((index) => Number(match[index] ?? 0));
};
export const isRemoteVersionNewer = (currentVersion: string, tagName: string) => {
const current = normalizeSemver(currentVersion);
const remote = normalizeSemver(tagName);
if (!remote) {
return false;
}
if (!current) {
return true;
}
for (let i = 0; i < Math.max(current.length, remote.length); i += 1) {
const left = current[i] ?? 0;
const right = remote[i] ?? 0;
if (right > left) {
return true;
}
if (right < left) {
return false;
}
}
return false;
};
const detectOSFromString = (value: string) => {
const lower = value.toLowerCase();
if (lower.includes("win")) {
return "windows" as const;
}
if (lower.includes("mac") || lower.includes("darwin")) {
return "darwin" as const;
}
if (lower.includes("linux") || lower.includes("x11")) {
return "linux" as const;
}
return "unknown" as const;
};
const detectArchFromString = (value: string) => {
const lower = value.toLowerCase();
if (
lower.includes("arm64") ||
lower.includes("aarch64") ||
lower.includes("armv8")
) {
return "arm64" as const;
}
if (
lower.includes("amd64") ||
lower.includes("x86_64") ||
lower.includes("x64") ||
lower.includes("win64") ||
lower.includes("x86-64")
) {
return "amd64" as const;
}
return "unknown" as const;
};
export const detectRuntimeTarget = async (): Promise<RuntimeTarget> => {
const userAgent = navigator.userAgent || "";
const platform = navigator.platform || "";
const uaData = (navigator as Navigator & { userAgentData?: UAData })
.userAgentData;
let os = detectOSFromString(platform || userAgent);
let arch = detectArchFromString(userAgent);
if (uaData?.platform) {
const uaDataOs = detectOSFromString(uaData.platform);
if (uaDataOs !== "unknown") {
os = uaDataOs;
}
}
if (uaData?.getHighEntropyValues) {
try {
const high = await uaData.getHighEntropyValues([
"architecture",
"bitness",
"platform",
]);
const highArch = detectArchFromString(
`${high.architecture ?? ""} ${high.bitness ?? ""}`,
);
if (highArch !== "unknown") {
arch = highArch;
} else if (high.architecture === "x86" && high.bitness === "64") {
arch = "amd64";
}
if (high.platform) {
const highOs = detectOSFromString(high.platform);
if (highOs !== "unknown") {
os = highOs;
}
}
} catch {
// Ignore UA high-entropy detection errors and keep fallbacks.
}
}
return { os, arch };
};
const assetMatchesOS = (name: string, os: RuntimeOS) => {
if (os === "windows") {
return /\bwindows\b|\bwin\b|\.exe$/.test(name);
}
if (os === "linux") {
return /\blinux\b/.test(name);
}
if (os === "darwin") {
return /\bdarwin\b|\bmac\b|\bmacos\b|\bosx\b/.test(name);
}
return false;
};
const assetMatchesArch = (name: string, arch: RuntimeArch) => {
if (arch === "amd64") {
return /\bamd64\b|\bx86_64\b|\bx64\b/.test(name);
}
if (arch === "arm64") {
return /\barm64\b|\baarch64\b/.test(name);
}
return false;
};
export const resolveAssetForRuntime = (
assets: ReleaseAsset[],
target: RuntimeTarget,
) => {
if (target.os === "unknown") {
return null;
}
const mapped = assets.map((asset) => {
const lower = asset.name.toLowerCase();
const osScore = assetMatchesOS(lower, target.os) ? 2 : 0;
const archScore =
target.arch === "unknown"
? 0
: assetMatchesArch(lower, target.arch)
? 2
: 0;
const exactPenalty =
target.arch !== "unknown" && archScore === 0 ? -2 : 0;
return {
asset,
score: osScore + archScore + exactPenalty,
};
});
const matched = mapped
.filter((item) => item.score > 0)
.sort((left, right) => right.score - left.score);
return matched[0]?.asset ?? null;
};
const triggerBrowserDownload = (blob: Blob, filename: string) => {
const link = document.createElement("a");
const blobUrl = URL.createObjectURL(blob);
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
};
export const fetchLatestRelease = async (signal?: AbortSignal) => {
const response = await fetch(UPDATE_API_URL, {
method: "GET",
headers: {
accept: "application/json",
},
signal,
});
if (!response.ok) {
throw new Error(`检查更新失败: HTTP ${response.status}`);
}
return (await response.json()) as LatestRelease;
};
export const downloadReleaseAsset = async (
asset: ReleaseAsset,
onProgress?: (progress: number) => void,
) => {
const response = await fetch(asset.browser_download_url, {
method: "GET",
});
if (!response.ok) {
throw new Error(`下载失败: HTTP ${response.status}`);
}
const totalSize = Number(response.headers.get("content-length") ?? 0);
if (!response.body || !Number.isFinite(totalSize) || totalSize <= 0) {
const blob = await response.blob();
triggerBrowserDownload(blob, asset.name);
onProgress?.(100);
return;
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let received = 0;
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
if (!value) {
continue;
}
chunks.push(value);
received += value.length;
const progress = Math.min(99, Math.round((received / totalSize) * 100));
onProgress?.(progress);
}
const merged = new Uint8Array(received);
let offset = 0;
for (const chunk of chunks) {
merged.set(chunk, offset);
offset += chunk.length;
}
const blob = new Blob([merged.buffer]);
triggerBrowserDownload(blob, asset.name);
onProgress?.(100);
};
export const resolveReleaseLink = (url: string) => {
if (url.startsWith("http://") || url.startsWith("https://")) {
return url;
}
if (url.startsWith("/")) {
return `${RELEASE_HOST_ORIGIN}${url}`;
}
return `${RELEASE_HOST_ORIGIN}/${url}`;
};

View File

@@ -1,5 +1,8 @@
import type { Accessor } from "solid-js";
import http, { createHttpClient, type HttpClient } from "~/service/http";
import http, {
createHttpClient,
type HttpClient,
} from "~/service/http";
import type { CourseType } from "~/types/Course";
import type { userInfoType } from "~/types/Userinfo";
@@ -9,6 +12,80 @@ export type RecordType = "" | "/work" | "/exam" | "/discuss";
export type StudyStatus = 1 | 2 | 3;
export type WorkListItem = {
id: string;
userId: string | number | null;
title: string;
topicNumber: string;
score: string;
type: string;
remarks: string;
addTime: string;
sequence: string;
nodeId: string;
courseId: string;
startTime: string;
endTime: string;
paperId: string;
createUserId: string;
isPrivate: string;
classList: string;
teacherType: string;
allow: string;
frequency: string;
scoringRules: string;
hasCollect: string;
lock: string | number | null;
schoolId: string;
parsing: string;
addDate: string;
name: string;
chapterId: string;
state: string;
submitTime: string;
finalScore: string;
typeName: string;
finishTime: string;
url: string;
};
export type ExamListItem = {
id: string;
userId: string | number | null;
title: string;
topicNumber: string;
score: string;
addTime: string;
nodeId: string;
courseId: string;
limitedTime: string;
sequence: string;
remarks: string;
paperId: string;
startTime: string;
endTime: string;
createUserId: string;
classList: string;
isPrivate: string;
teacherType: string;
allow: string;
frequency: string;
hasCollect: string;
schoolId: string;
parsing: string;
addDate: string;
random: string;
randData: unknown;
randNumber: string;
name: string;
chapterId: string;
state: string;
submitTime: string;
finalScore: string;
finishTime: string;
url: string;
};
type ApiResponse<T> = {
code: number;
message: string;
@@ -24,9 +101,7 @@ export type LoginReq = {
};
export type LoginData = {
courses?: CourseType[];
session_id: string;
user: userInfoType;
};
export type LoginRes = ApiResponse<LoginData>;
@@ -104,11 +179,12 @@ export type HostRes = ApiResponse<{
}>;
export type VersionData = {
BuildAt: string;
GitAuthor: string;
GitCommit: string;
GitEmail: string;
Version: string;
BuildAt: string;
GitAuthor: string;
GitCommit: string;
GitEmail: string;
Mode: string;
Version: string;
};
export type VersionRes = ApiResponse<VersionData>;
@@ -129,6 +205,12 @@ export type CourseData = {
export type CourseRes = ApiResponse<CourseData>;
export type UserInfoData = {
user: userInfoType;
};
export type UserInfoRes = ApiResponse<UserInfoData>;
export type StudyReq = {
node_id: string;
study_id: string;
@@ -156,24 +238,68 @@ export type StudyRunnerPayload = {
onLog?: (message: string, accoundID: string) => void;
};
export type WorkListData = {
list: WorkListItem[];
page_info: PageInfo;
};
export type WorkListRes = ApiResponse<WorkListData>;
export type ExamListData = {
list: ExamListItem[];
page_info: PageInfo;
};
export type ExamListRes = ApiResponse<ExamListData>;
export type WkClient = {
userInfoApi: () => Promise<UserInfoRes>;
courseApi: (payload: CourseReq) => Promise<CourseRes>;
recordApi: (payload: RecordReq) => Promise<RecordRes>;
workListApi: (payload: RecordReq) => Promise<WorkListRes>;
examListApi: (payload: RecordReq) => Promise<ExamListRes>;
studyApi: (payload: StudyReq) => Promise<StudyRes>;
logoutApi: () => Promise<LogoutRes>;
};
const RECORD_API_TIMEOUT_MS = 60000;
// Course list can be slow on large accounts, use a longer timeout than default
const COURSE_API_TIMEOUT_MS = 30000;
export const loginApi = async (payload: LoginReq) => {
const res = await http.post<LoginRes>("/api/login", payload);
return res;
};
const createWkClientFromHttp = (client: HttpClient): WkClient => ({
userInfoApi() {
return client.post<UserInfoRes>("/api/v2/userinfo");
},
courseApi(payload) {
return client.post<CourseRes>("/api/v2/course", payload);
return client.post<CourseRes>("/api/v2/course", payload, {
timeout: COURSE_API_TIMEOUT_MS,
});
},
recordApi(payload) {
return client.post<RecordRes>("/api/v2/record", payload);
return client.post<RecordRes>("/api/v2/record", payload, {
timeout: RECORD_API_TIMEOUT_MS,
});
},
workListApi(payload) {
return client.post<WorkListRes>("/api/v2/record", {
...payload,
record_type: "/work",
}, {
timeout: RECORD_API_TIMEOUT_MS,
});
},
examListApi(payload) {
return client.post<ExamListRes>("/api/v2/record", {
...payload,
record_type: "/exam",
}, {
timeout: RECORD_API_TIMEOUT_MS,
});
},
studyApi(payload) {
return client.post<StudyRes>("/api/v2/study", payload);
@@ -189,22 +315,40 @@ export const createWkClient = (
return createWkClientFromHttp(createHttpClient(resolveSessionId));
};
export const createSessionWkClient = (sessionId: string): WkClient => {
return createWkClient(() => sessionId);
};
const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms));
export const runStudyQueue = async (_payload: StudyRunnerPayload) => {
const stopFlag = _payload.isRunningStudy;
const stepSeconds = Math.max(1, Math.floor(_payload.intervalSeconds || 5));
const sleepMs = stepSeconds * 1000;
const maxRetryCount = 3;
const maxStudySubmitRetry = 3;
const studySubmitRetrySleepMs = 5000;
for (const item of _payload.items) {
const learnedTime = Math.max(0, Math.floor(item.currentTime || 0));
const rawTotalTime = Math.max(0, Math.floor(item.totalTime || 0));
const totalTime = Math.max(0, rawTotalTime - learnedTime);
let currentTime = 0;
let count = 0;
let study_id = 0;
let total = item.totalTime - item.currentTime;
let retryCount = 0;
while (currentTime <= total) {
if (totalTime <= 0) {
_payload.onLog?.(`跳过已完成节点: ${item.name}`, _payload.accountId);
continue;
}
while (currentTime <= totalTime) {
if (!stopFlag()) {
_payload.onLog?.("⛔ 已手动停止", _payload.accountId);
return;
}
const message = `[${item.name}]: ${currentTime}/${total}`;
const message = `[${item.name}]: ${currentTime}/${totalTime}`;
_payload.onLog?.(message, _payload.accountId);
try {
@@ -212,26 +356,99 @@ export const runStudyQueue = async (_payload: StudyRunnerPayload) => {
node_id: item.nodeId,
study_id: String(study_id),
study_time: String(currentTime),
status: count === 0 ? 1 : currentTime >= total ? 3 : 2,
status: count === 0 ? 1 : currentTime >= totalTime ? 3 : 2,
});
if (resp.data.state != 0) {
const submitMessage = String(resp.data.msg ?? "");
const isSubmitFailed =
submitMessage.includes("提交学时失败") || resp.data.status === false;
if (isSubmitFailed) {
let submitRetry = 0;
let fixedResp = resp;
while (submitRetry < maxStudySubmitRetry) {
submitRetry += 1;
_payload.onLog?.(
`⚠️ 提交学时失败,${submitRetry}/${maxStudySubmitRetry} 次重试后再提交: ${item.name}`,
_payload.accountId,
);
await sleep(studySubmitRetrySleepMs);
if (!stopFlag()) {
_payload.onLog?.("⛔ 已手动停止", _payload.accountId);
return;
}
fixedResp = await _payload.client.studyApi({
node_id: item.nodeId,
study_id: String(study_id),
study_time: String(currentTime),
status: count === 0 ? 1 : currentTime >= totalTime ? 3 : 2,
});
const nextMsg = String(fixedResp.data.msg ?? "");
const nextFailed =
nextMsg.includes("提交学时失败") || fixedResp.data.status === false;
if (!nextFailed) {
break;
}
}
const finalMsg = String(fixedResp.data.msg ?? "");
const stillFailed =
finalMsg.includes("提交学时失败") || fixedResp.data.status === false;
if (stillFailed) {
_payload.onLog?.(
`⛔ 提交学时连续重试 ${maxStudySubmitRetry} 次仍失败,停止刷课: ${item.name}`,
_payload.accountId,
);
_payload.setIsRunningStudy();
return;
}
study_id = fixedResp.data.studyId;
retryCount = 0;
if (currentTime === totalTime) {
break;
}
currentTime = Math.min(currentTime + stepSeconds, totalTime);
count++;
await sleep(sleepMs);
continue;
}
if (resp.data.state !== 0) {
_payload.onLog?.(`⛔ 自动停止: ${resp.data.msg}`, _payload.accountId);
_payload.setIsRunningStudy();
return;
}
study_id = resp.data.studyId;
retryCount = 0;
if (currentTime === total) break;
if (currentTime === totalTime) {
break;
}
currentTime = Math.min(currentTime + 5, total);
currentTime = Math.min(currentTime + stepSeconds, totalTime);
count++;
} catch (error) {
retryCount += 1;
const errorMessage = `请求失败: ${error instanceof Error ? error.message : String(error)}`;
_payload.onLog?.(errorMessage, _payload.accountId);
if (retryCount >= maxRetryCount) {
_payload.onLog?.(
`⚠️ 连续失败 ${maxRetryCount} 次,跳过节点: ${item.name}`,
_payload.accountId,
);
break;
}
}
await sleep(5000);
await sleep(sleepMs);
}
}
};

View File

@@ -1,6 +1,6 @@
import { createStore } from "zustand/vanilla";
import { persist, createJSONStorage } from "zustand/middleware";
import type { CourseKind, RecordItem, RecordType } from "~/service/wk";
import type { CourseKind, RecordItem, RecordType, WorkListItem, ExamListItem } from "~/service/wk";
import type { CourseType } from "~/types/Course";
import type { userInfoType } from "~/types/Userinfo";
@@ -19,6 +19,7 @@ const withLogTimestamp = (message: string) => {
return `[${timestamp}] ${message}`;
};
const MAX_STUDY_LOGS_PER_ACCOUNT = 1000;
export type AccountAuth = {
password: string;
@@ -38,6 +39,47 @@ export type AccountItem = {
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;
@@ -47,8 +89,12 @@ type AccountState = {
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;
@@ -56,13 +102,22 @@ type AccountState = {
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>()(
@@ -76,8 +131,12 @@ export const accountStore = createStore<AccountState>()(
recordType: "",
records: [],
recordCacheMap: {},
workList: [],
examList: [],
workExamCacheMap: {},
studyLogsMap: {},
runningStudyMap: {},
studyHeartbeatMap: {},
setSelectedAccountId: (accountId) =>
set({ selectedAccountId: accountId }),
setExpandedAccountId: (accountId) =>
@@ -93,6 +152,15 @@ export const accountStore = createStore<AccountState>()(
[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: {
@@ -100,16 +168,39 @@ export const accountStore = createStore<AccountState>()(
[accountId]: value,
},
})),
appendStudyLog: (accountId, message) =>
touchStudyHeartbeat: (accountId, timestamp) =>
set((state) => ({
studyLogsMap: {
...state.studyLogsMap,
[accountId]: [
...(state.studyLogsMap[accountId] ?? []),
withLogTimestamp(message),
],
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: {
@@ -121,6 +212,39 @@ export const accountStore = createStore<AccountState>()(
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: [
@@ -171,24 +295,42 @@ export const accountStore = createStore<AccountState>()(
([key]) => key !== accountId,
),
),
studyHeartbeatMap: Object.fromEntries(
Object.entries(state.studyHeartbeatMap).filter(
([key]) => key !== accountId,
),
),
};
}),
}),
{
name: "account-storage",
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
accounts: state.accounts,
selectedAccountId: state.selectedAccountId,
expandedAccountId: state.expandedAccountId,
selectedCourseId: state.selectedCourseId,
courseKind: state.courseKind,
recordType: state.recordType,
records: state.records,
recordCacheMap: state.recordCacheMap,
studyLogsMap: state.studyLogsMap,
runningStudyMap: state.runningStudyMap,
}),
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;
},
},
),
);

View File

@@ -1,5 +1,6 @@
import { createStore } from "zustand/vanilla";
import { createJSONStorage, persist } from "zustand/middleware";
import { accountStore } from "~/store/account";
export type HostOption = {
label: string;
@@ -14,6 +15,7 @@ type SettingsState = {
persistAccounts: boolean;
persistRecords: boolean;
persistLogs: boolean;
debugEnabled: boolean;
autoScrollLogs: boolean;
showLogTimestamps: boolean;
densityMode: DensityMode;
@@ -24,6 +26,7 @@ type SettingsState = {
setPersistSection: (section: CacheSection, value: boolean) => void;
clearPersistedSection: (section: CacheSection) => void;
clearAllPersistedData: () => void;
setDebugEnabled: (value: boolean) => void;
setAutoScrollLogs: (value: boolean) => void;
setShowLogTimestamps: (value: boolean) => void;
setDensityMode: (value: DensityMode) => void;
@@ -35,6 +38,39 @@ type SettingsState = {
};
const accountStorageKey = "account-storage";
const settingsStorageKey = "settings-storage";
type PersistedStorage = {
state?: Record<string, unknown>;
version?: number;
};
const patchAccountStorage = (
patcher: (state: Record<string, unknown>) => Record<string, unknown>,
) => {
const rawValue = localStorage.getItem(accountStorageKey);
if (!rawValue) {
return;
}
try {
const parsedValue = JSON.parse(rawValue) as PersistedStorage;
const currentState =
parsedValue.state && typeof parsedValue.state === "object"
? parsedValue.state
: {};
const nextState = patcher(currentState);
localStorage.setItem(
accountStorageKey,
JSON.stringify({
...parsedValue,
state: nextState,
}),
);
} catch {
localStorage.removeItem(accountStorageKey);
}
};
const uniqueHosts = (hosts: HostOption[]) => {
const map = new Map<string, HostOption>();
@@ -61,6 +97,7 @@ export const settingsStore = createStore<SettingsState>()(
persistAccounts: true,
persistRecords: true,
persistLogs: true,
debugEnabled: false,
autoScrollLogs: true,
showLogTimestamps: false,
densityMode: "comfortable",
@@ -74,27 +111,48 @@ export const settingsStore = createStore<SettingsState>()(
if (section === "accounts") set({ persistAccounts: value });
if (section === "records") set({ persistRecords: value });
if (section === "logs") set({ persistLogs: value });
if (!value) {
queueMicrotask(() => get().clearPersistedSection(section));
}
},
clearPersistedSection: (section) => {
const store = accountStore.getState();
if (section === "accounts") {
store.clearAccountsData();
localStorage.removeItem(accountStorageKey);
}
if (section === "records") {
set({ persistRecords: false });
queueMicrotask(() => set({ persistRecords: true }));
store.clearRecordsData();
patchAccountStorage((state) => {
const { records, recordCacheMap, workList, examList, workExamCacheMap, ...rest } = state;
void records;
void recordCacheMap;
void workList;
void examList;
void workExamCacheMap;
return rest;
});
}
if (section === "logs") {
set({ persistLogs: false });
queueMicrotask(() => set({ persistLogs: true }));
store.clearAllStudyLogs();
patchAccountStorage((state) => {
const { studyLogsMap, ...rest } = state;
void studyLogsMap;
return rest;
});
}
},
clearAllPersistedData: () => {
accountStore.getState().clearAllData();
localStorage.removeItem(accountStorageKey);
get().clearPersistedSection("records");
get().clearPersistedSection("logs");
localStorage.removeItem(settingsStorageKey);
window.location.reload();
},
setDebugEnabled: (value) => set({ debugEnabled: value }),
setAutoScrollLogs: (value) => set({ autoScrollLogs: value }),
setShowLogTimestamps: (value) => set({ showLogTimestamps: value }),
setDensityMode: (value) => set({ densityMode: value }),

View File

@@ -10,4 +10,8 @@ export default defineConfig({
"~": path.resolve(__dirname, "src"),
},
},
server: {
host: "local.kmux.cn",
},
});