7 Commits

Author SHA1 Message Date
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
a061123e36 fix: 修复刷课判断错误 2026-03-31 21:35:21 +08:00
9e7131f210 fix: 修复问题
- 修复刷课错误不会停止
- 添加课程刷新
- 添加课程列表、记录列表缓存
- 显示当前版本
2026-03-28 19:22:31 +08:00
6325b84ca0 feat: 添加生产环境与开发环境配置 2026-03-28 18:01:39 +08:00
369cd6a6e3 fix: 修改后端接口 2026-03-27 20:09:53 +08:00
19 changed files with 2448 additions and 409 deletions

1
.env.development Normal file
View File

@@ -0,0 +1 @@
VITE_BASE_URL="http://localhost:8080"

1
.env.production Normal file
View File

@@ -0,0 +1 @@
VITE_BASE_URL="http://local.kmux.cn:8080"

9
env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_BASE_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

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

View File

@@ -1,54 +1,363 @@
import type { ParentComponent } from "solid-js";
import type { JSX, ParentComponent } from "solid-js";
import {
For,
Show,
createEffect,
createMemo,
createResource,
createSignal,
onCleanup,
} from "solid-js";
import { A, useLocation } from "@solidjs/router";
import Dialog from "~/components/dialog/Dialog";
import {
RELEASES_PAGE_URL,
type LatestRelease,
type ReleaseAsset,
type RuntimeTarget,
detectRuntimeTarget,
downloadReleaseAsset,
fetchLatestRelease,
isRemoteVersionNewer,
resolveAssetForRuntime,
resolveReleaseLink,
} from "~/service/update";
import { versionApi } from "~/service/wk";
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("");
let updateAbortController: AbortController | null = null;
const isActive = (url: string) =>
location.pathname === url ||
(location.pathname === "/" && url === "/account");
const versionText = createMemo(() => version()?.data.Version ?? "unknown");
const commitText = createMemo(() => {
const commit = version()?.data.GitCommit ?? "unknown";
return commit === "unknown" ? commit : commit.slice(0, 7);
});
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(
() => modeText().toLowerCase() === "debug",
);
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) {
return "";
}
return error instanceof Error ? error.message : "版本信息获取失败";
});
const versionPayloadText = createMemo(() =>
[
`Version: ${versionText()}`,
`Mode: ${modeText()}`,
`Commit: ${commitText()}`,
`Build: ${buildText()}`,
`Author: ${authorText()}`,
`Email: ${emailText()}`,
].join("\n"),
);
const updateSummaryText = createMemo(() => {
if (updateCheckState() === "checking") {
return "更新检查中...";
}
if (updateCheckState() === "available") {
return `发现新版本:${latestRelease()?.tag_name ?? "-"}`;
}
if (updateCheckState() === "latest") {
return "已是最新版本";
}
if (updateCheckState() === "error") {
return updateCheckError() || "更新检查失败";
}
return "未检查更新";
});
const releaseNotesBlocks = createMemo(() =>
parseMarkdownBlocks(latestRelease()?.body ?? ""),
);
const runtimeTargetText = createMemo(
() => `${runtimeTarget().os} / ${runtimeTarget().arch}`,
);
const releaseLink = createMemo(
() => latestRelease()?.html_url || RELEASES_PAGE_URL,
);
const handleCopyVersion = async () => {
try {
await navigator.clipboard.writeText(versionPayloadText());
setCopyState("done");
} catch {
setCopyState("error");
}
window.setTimeout(() => setCopyState("idle"), 1800);
};
const performUpdateCheck = async (manual = false) => {
if (updateCheckState() === "checking") {
return;
}
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);
if (!hasNewVersion) {
setUpdateCheckState("latest");
if (manual) {
setLatestRelease(release);
setMatchedAsset(resolveAssetForRuntime(release.assets, target));
}
return;
}
setLatestRelease(release);
setMatchedAsset(resolveAssetForRuntime(release.assets, target));
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);
});
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 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 justify-between gap-4 rounded-[28px] border border-white/70 bg-white/75 px-5 py-4 shadow-[0_18px_50px_-22px_rgba(14,116,144,0.35)] backdrop-blur-xl">
<div class="flex min-w-0 items-center gap-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-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-[linear-gradient(135deg,_#06b6d4,_#22c55e)] text-lg font-semibold text-white shadow-lg shadow-cyan-500/20"
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">
<A
href="/"
class="block truncate text-2xl font-semibold tracking-wide text-zinc-900"
class="block truncate text-xl font-semibold tracking-wide text-zinc-900"
>
</A>
<p class="truncate text-sm text-zinc-500">
<p class="truncate text-xs text-zinc-500 sm:text-sm">
</p>
</div>
</div>
<div class="rounded-2xl border border-zinc-200/80 bg-zinc-50/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">
/ /
</p>
</div>
</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">
<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>
@@ -57,8 +366,8 @@ const App: ParentComponent = (props) => {
</p>
</div>
<nav class="flex flex-col gap-2">
{asideList.map((item) => {
<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 (
@@ -66,8 +375,8 @@ const App: ParentComponent = (props) => {
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"
? "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 hover:border-zinc-200 hover:bg-zinc-50/80 hover:text-zinc-900"
}
>
<div class="flex items-center justify-between">
@@ -87,11 +396,53 @@ const App: ParentComponent = (props) => {
})}
</nav>
<div class="mt-auto rounded-2xl border border-zinc-200/80 bg-zinc-50/90 px-4 py-4">
<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 ?? "账号"}
{asideList().find((item) => isActive(item.url))?.label ?? "账号"}
</p>
<p class="mt-1 text-xs text-zinc-500">
: {modeText()}
</p>
<p class="mt-3 text-xs font-medium tracking-[0.18em] text-cyan-700/75 uppercase">
Runtime
</p>
<div class="mt-2 grid gap-1 text-xs text-zinc-500 xl:block">
<p>Version: {versionText()}</p>
<p>Commit: {commitText()}</p>
<p>Build: {buildText()}</p>
<p>Author: {authorText()}</p>
<p>Email: {emailText()}</p>
</div>
<p
class={`mt-2 text-xs ${updateCheckState() === "error" ? "text-rose-500" : "text-zinc-500"}`}
>
: {updateSummaryText()}
</p>
<div class="mt-3 flex flex-wrap items-center gap-2">
<button
type="button"
class="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>
<button
type="button"
class="rounded-lg border border-cyan-200 bg-cyan-50 px-3 py-1.5 text-xs text-cyan-700 transition hover:bg-cyan-100 disabled:cursor-not-allowed disabled:opacity-60"
disabled={updateCheckState() === "checking"}
onClick={() => void performUpdateCheck(true)}
>
{updateCheckState() === "checking" ? "检查中..." : "检查更新"}
</button>
</div>
{versionErrorText() ? (
<p class="mt-2 text-xs text-rose-500">{versionErrorText()}</p>
) : null}
</div>
</aside>
@@ -101,6 +452,139 @@ const App: ParentComponent = (props) => {
</div>
</div>
</div>
<Dialog
open={updateDialogOpen}
onClose={() => {
if (downloadState() === "downloading") {
return;
}
setUpdateDialogOpen(false);
}}
title={`发现更新 ${latestRelease()?.tag_name ?? ""}`}
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 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 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 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

@@ -12,6 +12,7 @@ interface AccountSidebarProps {
selectedAccountId: string;
expandedAccountId: string;
statusOptions: StatusOption[];
currentCourseKind: CourseKind;
hostLabels: Record<string, string>;
isRefreshingAccount: boolean;
loggingOutId: string;
@@ -28,21 +29,23 @@ 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"
class="self-start rounded-xl border border-zinc-200 bg-white px-3 py-1.5 text-xs text-zinc-700 transition hover:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-60 sm:self-auto sm:text-sm"
disabled={!props.selectedAccountId || props.isRefreshingAccount}
onClick={props.onRefreshAccount}
>
@@ -53,8 +56,8 @@ const AccountSidebar = (props: AccountSidebarProps) => {
<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}>
@@ -66,17 +69,29 @@ const AccountSidebar = (props: AccountSidebarProps) => {
const statusLabel =
props.statusOptions.find((item) => item.value === account.status)
?.label ?? account.status;
const currentCourseLabel =
props.statusOptions.find(
(item) => item.value === props.currentCourseKind,
)?.label ?? props.currentCourseKind;
const courseCountLabel = selected()
? `当前筛选课程数:${account.courses.length}`
: `缓存课程数:${account.courses.length}`;
const courseTypeLabel = selected()
? `当前筛选:${currentCourseLabel}`
: `登录类型:${statusLabel}`;
const badgeTypeLabel = selected() ? currentCourseLabel : statusLabel;
const badgeCountLabel = `${account.courses.length}`;
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">
@@ -90,15 +105,23 @@ 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>
@@ -112,26 +135,26 @@ 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>{account.courses.length}</p>
<p>{statusLabel}</p>
<p>{courseCountLabel}</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-cyan-200 bg-white px-2.5 py-1 text-xs text-cyan-700 transition hover:bg-cyan-50"
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 disabled:cursor-not-allowed disabled:opacity-60"
disabled={props.loggingOutId === account.id}
onClick={(event) => {
event.stopPropagation();

View File

@@ -1,5 +1,12 @@
import { For, Show, createEffect, type JSX } from "solid-js";
import type { RecordType } from "~/service/wk";
import {
For,
Show,
createEffect,
createMemo,
createSignal,
type JSX,
} from "solid-js";
import type { CourseKind, RecordType } from "~/service/wk";
import type { AccountItem } from "~/store/account";
import type { CourseType } from "~/types/Course";
import type { RecordItem } from "~/service/wk";
@@ -8,6 +15,11 @@ type RecordTypeOption = {
label: string;
value: RecordType;
};
type CourseRecordTypeOption = {
label: string;
value: CourseKind;
};
type RecordFilter = "all" | "unlearned" | "learned";
interface CourseWorkspaceProps {
selectedAccount: AccountItem | null;
@@ -15,12 +27,17 @@ interface CourseWorkspaceProps {
selectedCourseId: number | null;
selectedCourse: CourseType | null;
recordType: RecordType;
courseKind: CourseKind;
currentCourseKindLabel: string;
showingCachedRecords: boolean;
recordTypeOptions: RecordTypeOption[];
courseRecordTypeOptions: CourseRecordTypeOption[];
records: RecordItem[];
studyLogs: string[];
recordsLoading: boolean;
recordError: string;
isRefreshingRecords: boolean;
isRefreshingCourseRecords: boolean;
isRunningStudy: boolean;
isRefreshingLogs: boolean;
autoScrollLogs: boolean;
@@ -29,7 +46,9 @@ interface CourseWorkspaceProps {
logFontSize: number;
onSelectCourse: (courseId: number) => void;
onRefreshRecords: () => void;
onRefreshCourseRecords: () => void;
onChangeRecordType: (value: RecordType) => void;
onChangeCourseRecordType: (value: CourseKind) => void;
onStartStudy: () => void;
onStopStudy: () => void;
onRefreshLogs: () => void;
@@ -54,6 +73,7 @@ const stripTimestamp = (message: string) => {
const CourseWorkspace = (props: CourseWorkspaceProps) => {
let logContainerRef: HTMLDivElement | undefined;
const [recordFilter, setRecordFilter] = createSignal<RecordFilter>("all");
createEffect(() => {
props.studyLogs.length;
@@ -77,16 +97,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>
@@ -103,23 +151,51 @@ 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="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={
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="flex flex-wrap items-center gap-2">
<select
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(
event.currentTarget.value as CourseKind,
)
}
>
<For each={props.courseRecordTypeOptions}>
{(item) => <option value={item.value}>{item.label}</option>}
</For>
</select>
<button
type="button"
class="rounded-lg border border-zinc-200 bg-white px-2 py-1 text-xs text-zinc-700 transition hover:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-60"
disabled={props.isRefreshingCourseRecords}
onClick={props.onRefreshCourseRecords}
>
{props.isRefreshingCourseRecords ? "刷新中..." : "刷新记录"}
</button>
</div>
</div>
<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}
</EmptyState>
</Show>
<For each={props.selectedCourseList}>
{(course) => {
const selected = () => course.id === props.selectedCourseId;
@@ -130,18 +206,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>
@@ -156,25 +232,34 @@ 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)_176px] xl:grid-rows-[minmax(0,1fr)_188px]"
: "grid min-h-0 gap-2 grid-rows-[minmax(0,1fr)_188px] xl:grid-rows-[minmax(0,1fr)_208px]"
}
>
<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
: "请选择课程"}
</p>
<Show
when={props.showingCachedRecords && !props.recordsLoading}
>
<p class="mt-1 text-xs text-amber-600">
</p>
</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 disabled:cursor-not-allowed disabled:opacity-60"
disabled={
!props.selectedCourse || props.isRefreshingRecords
}
@@ -184,7 +269,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(
@@ -202,7 +287,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"
onClick={
props.isRunningStudy
? props.onStopStudy
@@ -214,12 +299,40 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
</Show>
</div>
</div>
<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" : "rounded-full border border-zinc-200 bg-white px-2.5 py-0.5 text-zinc-600 transition hover:bg-zinc-100"}
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" : "rounded-full border border-amber-200 bg-white px-2.5 py-0.5 text-amber-700 transition hover:bg-amber-50"}
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" : "rounded-full border border-emerald-200 bg-white px-2.5 py-0.5 text-emerald-700 transition hover:bg-emerald-50"}
onClick={() => setRecordFilter("learned")}
>
{recordStats().learned}
</button>
</div>
<p class="text-xs text-zinc-500">
{recordFilter() === "all" ? "全部" : recordFilter() === "unlearned" ? "未学" : "已学"}
</p>
</div>
<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}>
@@ -247,38 +360,57 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
!props.recordsLoading &&
!props.recordError &&
props.selectedCourse &&
props.records.length === 0
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() ? "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={
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"
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-3">
<div class="flex flex-wrap items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<p class="truncate text-base font-semibold text-zinc-900">
<p class="truncate text-sm font-semibold text-zinc-900">
{record.name}
</p>
<p class="mt-1 text-sm text-zinc-500">
<p class="mt-1 text-xs 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
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-4 grid gap-2 text-sm text-zinc-600 md:grid-cols-2 xl:grid-cols-3">
<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>
@@ -287,16 +419,19 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
<p>{record.viewCount}</p>
</div>
</div>
)}
);
}}
</For>
</div>
</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>
@@ -305,7 +440,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 disabled:cursor-not-allowed disabled:opacity-60"
disabled={props.isRefreshingLogs}
onClick={props.onRefreshLogs}
>
@@ -313,7 +448,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"
onClick={props.onClearLogs}
>
@@ -323,13 +458,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

@@ -13,16 +13,20 @@ render(
<Route path="/" component={App}>
<Route
path=""
component={lazy(() => import("./pages/accouts/Account.tsx"))}
component={lazy(() => import("./pages/accounts/Account.tsx"))}
/>
<Route
path="account"
component={lazy(() => import("./pages/accouts/Account.tsx"))}
component={lazy(() => import("./pages/accounts/Account.tsx"))}
/>
<Route
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

@@ -2,6 +2,7 @@ import {
createEffect,
createMemo,
createSignal,
on,
onCleanup,
onMount,
} from "solid-js";
@@ -12,9 +13,11 @@ import AddAccountDialog, {
import CourseWorkspace from "~/components/account/CourseWorkspace";
import {
createWkClient,
hostApi,
loginApi,
runStudyQueue,
type CourseKind,
type RecordItem,
type RecordType,
} from "~/service/wk";
import { setUnauthorizedHandler } from "~/service/http";
@@ -46,15 +49,42 @@ 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 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 = (
accountId: string,
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());
const [settingsState, setSettingsState] = createSignal(
@@ -71,9 +101,69 @@ const Account = () => {
const [recordsLoading, setRecordsLoading] = createSignal(false);
const [recordError, setRecordError] = createSignal("");
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) => {
@@ -82,12 +172,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);
});
});
@@ -95,8 +192,10 @@ const Account = () => {
const selectedAccountId = createMemo(() => storeState().selectedAccountId);
const expandedAccountId = createMemo(() => storeState().expandedAccountId);
const selectedCourseId = createMemo(() => storeState().selectedCourseId);
const courseKind = createMemo(() => storeState().courseKind);
const recordType = createMemo(() => storeState().recordType);
const records = createMemo(() => storeState().records);
const recordCacheMap = createMemo(() => storeState().recordCacheMap);
const studyLogsMap = createMemo(() => storeState().studyLogsMap);
const runningStudyMap = createMemo(() => storeState().runningStudyMap);
const mergedHostOptions = createMemo(() =>
@@ -111,6 +210,24 @@ const Account = () => {
const defaultHost = createMemo(
() => mergedHostOptions()[0]?.host ?? "cqcst.leykeji.com",
);
const currentCourseKindLabel = createMemo(
() =>
statusOptions.find((item) => item.value === courseKind())?.label ??
courseKind(),
);
const showingCachedRecords = createMemo(() => {
const accountId = selectedAccountId();
const courseId = selectedCourseId();
if (!accountId || !courseId || recordsLoading() || isRefreshingRecords()) {
return false;
}
return (
createRecordCacheKey(accountId, courseId, recordType()) in
recordCacheMap()
);
});
const selectedAccount = createMemo(() => {
return accounts().find((item) => item.id === selectedAccountId()) ?? null;
@@ -161,6 +278,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) {
@@ -203,8 +326,9 @@ const Account = () => {
...target,
sessionId: res.data.session_id,
user: res.data.user,
courses: res.data.courses,
courses: res.data.courses ?? target.courses,
});
await loadCourses(target.id, target.status);
appendStudyLog(`会话失效,已重新登录:${target.user.name}`, target.id);
return true;
} catch (error) {
@@ -265,10 +389,11 @@ const Account = () => {
token: payload.token.trim(),
},
user: res.data.user,
courses: res.data.courses,
courses: res.data.courses ?? [],
};
accountStore.getState().upsertAccount(nextAccount);
await loadCourses(nextAccount.id, nextAccount.status);
accountStore.getState().setSelectedCourseId(null);
accountStore.getState().setRecords([]);
setShowDialog(false);
@@ -283,6 +408,7 @@ const Account = () => {
};
const handleSelectAccount = (accountId: string) => {
cancelPendingRecordRequest();
accountStore.getState().setSelectedAccountId(accountId);
accountStore.getState().setSelectedCourseId(null);
accountStore.getState().setRecords([]);
@@ -294,6 +420,51 @@ const Account = () => {
accountStore.getState().setExpandedAccountId(nextId);
};
const loadCourses = async (
accountId = selectedAccount()?.id,
status = courseKind(),
) => {
if (!accountId) {
return;
}
setIsRefreshingCourses(true);
setErrorMessage("");
try {
const res = await getAccountClient(accountId).courseApi({ status });
const courses = res.data.courses ?? [];
const account = accountStore
.getState()
.accounts.find((item) => item.id === accountId);
accountStore.getState().setAccountCourses(accountId, courses);
const currentCourseId = accountStore.getState().selectedCourseId;
if (account?.id === accountStore.getState().selectedAccountId) {
const hasSelectedCourse = courses.some(
(item) => item.id === currentCourseId,
);
if (!hasSelectedCourse) {
accountStore.getState().setSelectedCourseId(courses[0]?.id ?? null);
accountStore.getState().setRecords([]);
}
}
} catch (error) {
const message =
error instanceof Error ? error.message : "获取课程失败,请稍后重试。";
setErrorMessage(message);
accountStore.getState().setAccountCourses(accountId, []);
if (accountStore.getState().selectedAccountId === accountId) {
cancelPendingRecordRequest();
accountStore.getState().setSelectedCourseId(null);
accountStore.getState().setRecords([]);
}
} finally {
setIsRefreshingCourses(false);
}
};
const handleRefreshAccount = async () => {
const account = selectedAccount();
if (!account) {
@@ -317,8 +488,9 @@ const Account = () => {
...account,
sessionId: res.data.session_id,
user: res.data.user,
courses: res.data.courses,
courses: res.data.courses ?? account.courses,
});
await loadCourses(account.id, account.status);
} catch (error) {
const message =
error instanceof Error ? error.message : "刷新账号失败,请稍后重试。";
@@ -362,32 +534,60 @@ 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({
const res = await getAccountClient(accountId).recordApi({
course_id: String(courseId),
page: 0,
record_type: nextRecordType,
});
accountStore.getState().setRecords(res.data.list);
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()
.setRecordCache(
createRecordCacheKey(accountId, courseId, nextRecordType),
list,
);
} catch (error) {
if (requestToken !== recordRequestToken) {
return;
}
const message =
error instanceof Error ? error.message : "获取记录失败,请稍后重试。";
setRecordError(message);
accountStore.getState().setRecords([]);
} finally {
if (requestToken === recordRequestToken) {
setRecordsLoading(false);
setIsRefreshingRecords(false);
}
}
};
const handleSelectCourse = async (courseId: number) => {
const handleSelectCourse = (courseId: number) => {
cancelPendingRecordRequest();
accountStore.getState().setSelectedCourseId(courseId);
await loadCourseRecords(courseId);
};
const handleRefreshRecords = async () => {
@@ -427,18 +627,19 @@ 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);
appendStudyLog(`开始刷课:${course.name}`, account.id);
await runStudyQueue({
accountId: account.id,
@@ -448,8 +649,14 @@ const Account = () => {
client: getAccountClient(account.id),
isRunningStudy: () =>
!!accountStore.getState().runningStudyMap[account.id],
onLog: (message: string, accoundID: string) =>
appendStudyLog(message, accoundID),
setIsRunningStudy: () => {
accountStore.getState().setAccountRunningStudy(account.id, false);
clearStudyHeartbeat(account.id);
},
onLog: (message: string, accoundID: string) => {
touchStudyHeartbeat(accoundID);
appendStudyLog(message, accoundID);
},
});
if (accountStore.getState().runningStudyMap[account.id]) {
appendStudyLog(`刷课完成:${course.name}`, account.id);
@@ -461,6 +668,7 @@ const Account = () => {
setRecordError(message);
} finally {
accountStore.getState().setAccountRunningStudy(account.id, false);
clearStudyHeartbeat(account.id);
}
};
@@ -471,18 +679,52 @@ const Account = () => {
}
accountStore.getState().setAccountRunningStudy(account.id, false);
clearStudyHeartbeat(account.id);
appendStudyLog(`已发送停止刷课指令:${account.user.name}`, account.id);
};
createEffect(
on([selectedAccountId, courseKind], ([accountId, kind]) => {
if (!accountId) {
cancelPendingRecordRequest();
return;
}
void loadCourses(accountId, kind);
}),
);
createEffect(
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);
},
),
);
createEffect(() => {
const courseId = selectedCourseId();
const account = selectedAccount();
const type = recordType();
const cacheKey = account
? createRecordCacheKey(account.id, courseId ?? 0, type)
: "";
const hasCachedRecords = cacheKey ? cacheKey in recordCacheMap() : false;
if (!hasRestoredRecords()) {
setHasRestoredRecords(true);
if (courseId && account && records().length > 0) {
if (courseId && account && (records().length > 0 || hasCachedRecords)) {
return;
}
}
@@ -491,36 +733,43 @@ const Account = () => {
return;
}
if (hasCachedRecords) {
return;
}
void loadCourseRecords(courseId, type);
});
return (
<div class="flex h-full min-h-0 flex-col overflow-hidden">
<div class="flex shrink-0 items-center justify-between gap-4 rounded-[28px] border border-white/80 bg-[linear-gradient(135deg,rgba(236,254,255,0.95),rgba(255,255,255,0.92))] px-5 py-4 shadow-[0_18px_45px_-28px_rgba(8,145,178,0.35)]">
<div 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-2 text-2xl font-semibold text-zinc-900"></h1>
<p class="mt-1 text-sm text-zinc-500">
<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">
</p>
</div>
<button
class="rounded-2xl bg-[linear-gradient(135deg,#06b6d4,#14b8a6)] px-4 py-3 text-sm font-medium text-white shadow-lg shadow-cyan-500/20 transition hover:-translate-y-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()}
expandedAccountId={expandedAccountId()}
statusOptions={statusOptions}
currentCourseKind={courseKind()}
hostLabels={hostLabels()}
isRefreshingAccount={isRefreshingAccount()}
loggingOutId={loggingOutId()}
@@ -538,12 +787,17 @@ const Account = () => {
selectedCourseId={selectedCourseId()}
selectedCourse={selectedCourse()}
recordType={recordType()}
courseKind={courseKind()}
currentCourseKindLabel={currentCourseKindLabel()}
showingCachedRecords={showingCachedRecords()}
recordTypeOptions={recordTypeOptions}
courseRecordTypeOptions={statusOptions}
records={records()}
studyLogs={studyLogs()}
recordsLoading={recordsLoading()}
recordError={recordError()}
isRefreshingRecords={isRefreshingRecords()}
isRefreshingCourseRecords={isRefreshingCourses()}
isRunningStudy={isRunningStudy()}
isRefreshingLogs={isRefreshingLogs()}
autoScrollLogs={settingsState().autoScrollLogs}
@@ -552,9 +806,16 @@ const Account = () => {
logFontSize={settingsState().logFontSize}
onSelectCourse={(courseId) => void handleSelectCourse(courseId)}
onRefreshRecords={() => void handleRefreshRecords()}
onRefreshCourseRecords={() => void loadCourses()}
onChangeRecordType={(value) =>
accountStore.getState().setRecordType(value)
}
onChangeCourseRecordType={(value) => {
cancelPendingRecordRequest();
accountStore.getState().setCourseKind(value);
accountStore.getState().setSelectedCourseId(null);
accountStore.getState().setRecords([]);
}}
onStartStudy={() => void handleStartStudy()}
onStopStudy={handleStopStudy}
onRefreshLogs={handleRefreshLogs}

View File

@@ -0,0 +1,477 @@
import {
createEffect,
createMemo,
createResource,
createSignal,
For,
onCleanup,
onMount,
Show,
} from "solid-js";
import { useNavigate } from "@solidjs/router";
import { versionApi } from "~/service/wk";
import {
fetchDebugLogSnapshot,
resolveDebugLogDownloadUrl,
resolveDebugLogWsUrl,
type DebugLogEntry,
} from "~/service/debugLog";
import { settingsStore } from "~/store/settings";
type DebugSocketState = "connecting" | "open" | "closed" | "error";
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 summarizeDebugFields = (fields?: Record<string, unknown>) => {
const value = stringifyDebugFields(fields);
if (!value) {
return "-";
}
return value.replace(/\s+/g, " ").slice(0, 180);
};
const DebugLogs = () => {
const navigate = useNavigate();
const [version] = createResource(versionApi);
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);
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 (version()?.data.Mode ?? "").toLowerCase() === "debug";
});
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 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("调试日志流连接失败,请确认后端处于 debug 模式。");
});
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 (version.loading) {
return;
}
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`;
}
});
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">
debug
</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">
debug
</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"
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"
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"
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"
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"
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></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"
}`}
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={stringifyDebugFields(entry.fields)}
>
{summarizeDebugFields(entry.fields)}
</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="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>
<Show when={selectedDebugEntry()?.caller}>
<p>: {selectedDebugEntry()?.caller}</p>
</Show>
<Show when={selectedDebugEntry()?.logger}>
<p>Logger: {selectedDebugEntry()?.logger}</p>
</Show>
</div>
<div class="rounded-[18px] border border-white/10 bg-zinc-950/90 p-3">
<pre class="whitespace-pre-wrap break-all text-xs leading-6 text-emerald-200">
{stringifyDebugFields(selectedDebugEntry()?.fields)}
</pre>
</div>
</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 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;
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 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 {
id: accountId,
name: accountInfo?.name ?? accountId,
host: accountInfo?.host ?? "未知账号来源",
total: logs.length,
latestMessage: stripTimestamp(latestMessage),
latestTime: extractTimestamp(latestMessage),
};
});
const totalAccountsWithLogs = createMemo(() => {
return Object.values(accountState().studyLogsMap).filter(
(messages) => messages.length > 0,
).length;
});
const latestLog = createMemo(() => {
const logs = allLogs();
return logs.length > 0 ? logs[logs.length - 1] : null;
});
const totalAccountsWithLogs = createMemo(() => {
return accountSummaries().filter((item) => item.total > 0).length;
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,14 +191,11 @@ 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>
<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"
@@ -210,121 +207,71 @@ 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>
</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)]">
<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-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>
<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
? "自动滚动中"
: "自动滚动关闭"}
{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"
class="min-h-0 flex-1 overflow-auto px-3 pt-0 pb-3 font-mono text-emerald-200"
>
<Show
when={allLogs().length > 0}
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="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}
<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>
</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>
)}
</For>
</div>
</div>
</Show>
</div>
</section>
</div>
</div>
);
};

View File

@@ -65,7 +65,7 @@ const Setting = () => {
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(135deg,rgba(255,255,255,0.92),rgba(240,249,255,0.96))] px-5 py-4 shadow-[0_18px_45px_-28px_rgba(15,23,42,0.18)]">
<div>
<p class="text-xs font-medium tracking-[0.26em] text-cyan-700/75 uppercase">
Settings Center

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

@@ -0,0 +1,63 @@
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[];
};
};
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 ?? [];
};

View File

@@ -13,16 +13,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: "http://127.0.0.1:8080",
baseURL: import.meta.env.VITE_BASE_URL,
timeout: 15000,
});
instance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
@@ -52,7 +76,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);

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

@@ -24,7 +24,7 @@ export type LoginReq = {
};
export type LoginData = {
courses: CourseType[];
courses?: CourseType[];
session_id: string;
user: userInfoType;
};
@@ -103,12 +103,33 @@ export type HostRes = ApiResponse<{
list: HostItem[];
}>;
export type VersionData = {
BuildAt: string;
GitAuthor: string;
GitCommit: string;
GitEmail: string;
Mode: string;
Version: string;
};
export type VersionRes = ApiResponse<VersionData>;
export type RecordReq = {
course_id: string;
page: number;
record_type?: RecordType;
};
export type CourseReq = {
status: CourseKind;
};
export type CourseData = {
courses: CourseType[];
};
export type CourseRes = ApiResponse<CourseData>;
export type StudyReq = {
node_id: string;
study_id: string;
@@ -131,11 +152,13 @@ export type StudyRunnerPayload = {
intervalSeconds: number;
items: StudyRunnerItem[];
isRunningStudy: Accessor<boolean>;
setIsRunningStudy: () => void;
client: WkClient;
onLog?: (message: string, accoundID: string) => void;
};
export type WkClient = {
courseApi: (payload: CourseReq) => Promise<CourseRes>;
recordApi: (payload: RecordReq) => Promise<RecordRes>;
studyApi: (payload: StudyReq) => Promise<StudyRes>;
logoutApi: () => Promise<LogoutRes>;
@@ -147,6 +170,9 @@ export const loginApi = async (payload: LoginReq) => {
};
const createWkClientFromHttp = (client: HttpClient): WkClient => ({
courseApi(payload) {
return client.post<CourseRes>("/api/v2/course", payload);
},
recordApi(payload) {
return client.post<RecordRes>("/api/v2/record", payload);
},
@@ -168,18 +194,32 @@ 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 {
@@ -187,20 +227,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,
});
study_id = resp.data.studyId;
const submitMessage = String(resp.data.msg ?? "");
const isSubmitFailed =
submitMessage.includes("提交学时失败") || resp.data.status === false;
if (currentTime === total) break;
if (isSubmitFailed) {
let submitRetry = 0;
let fixedResp = resp;
currentTime = Math.min(currentTime + 5, total);
count++;
} catch (error) {
const errorMessage = `请求失败: ${error instanceof Error ? error.message : String(error)}`;
_payload.onLog?.(errorMessage, _payload.accountId);
while (submitRetry < maxStudySubmitRetry) {
submitRetry += 1;
_payload.onLog?.(
`⚠️ 提交学时失败,${submitRetry}/${maxStudySubmitRetry} 次重试后再提交: ${item.name}`,
_payload.accountId,
);
await sleep(studySubmitRetrySleepMs);
if (!stopFlag()) {
_payload.onLog?.("⛔ 已手动停止", _payload.accountId);
return;
}
await sleep(5000);
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 === totalTime) {
break;
}
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(sleepMs);
}
}
};
@@ -208,3 +327,7 @@ export const runStudyQueue = async (_payload: StudyRunnerPayload) => {
export const hostApi = async () => {
return await http.get<HostRes>("/api/v1/host");
};
export const versionApi = async () => {
return await http.get<VersionRes>("/api/version");
};

View File

@@ -19,6 +19,7 @@ const withLogTimestamp = (message: string) => {
return `[${timestamp}] ${message}`;
};
const MAX_STUDY_LOGS_PER_ACCOUNT = 1000;
export type AccountAuth = {
password: string;
@@ -36,25 +37,75 @@ export type AccountItem = {
courses: CourseType[];
};
export type RecordCacheMap = Record<string, RecordItem[]>;
type PersistPreferences = {
persistAccounts: boolean;
persistRecords: boolean;
persistLogs: boolean;
};
const settingsStorageKey = "settings-storage";
const defaultPersistPreferences: PersistPreferences = {
persistAccounts: true,
persistRecords: true,
persistLogs: true,
};
const resolvePersistPreferences = (): PersistPreferences => {
try {
const rawValue = localStorage.getItem(settingsStorageKey);
if (!rawValue) {
return defaultPersistPreferences;
}
const parsedValue = JSON.parse(rawValue) as {
state?: Partial<PersistPreferences>;
};
const persistedState = parsedValue?.state;
return {
persistAccounts:
persistedState?.persistAccounts ??
defaultPersistPreferences.persistAccounts,
persistRecords:
persistedState?.persistRecords ?? defaultPersistPreferences.persistRecords,
persistLogs:
persistedState?.persistLogs ?? defaultPersistPreferences.persistLogs,
};
} catch {
return defaultPersistPreferences;
}
};
type AccountState = {
accounts: AccountItem[];
selectedAccountId: string;
expandedAccountId: string;
selectedCourseId: number | null;
courseKind: CourseKind;
recordType: RecordType;
records: RecordItem[];
recordCacheMap: RecordCacheMap;
studyLogsMap: Record<string, string[]>;
runningStudyMap: Record<string, boolean>;
studyHeartbeatMap: Record<string, number>;
setSelectedAccountId: (accountId: string) => void;
setExpandedAccountId: (accountId: string) => void;
setSelectedCourseId: (courseId: number | null) => void;
setCourseKind: (courseKind: CourseKind) => void;
setRecordType: (recordType: RecordType) => void;
setRecords: (records: RecordItem[]) => void;
setRecordCache: (cacheKey: string, records: RecordItem[]) => void;
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;
};
@@ -65,17 +116,28 @@ export const accountStore = createStore<AccountState>()(
selectedAccountId: "",
expandedAccountId: "",
selectedCourseId: null,
courseKind: "run",
recordType: "",
records: [],
recordCacheMap: {},
studyLogsMap: {},
runningStudyMap: {},
studyHeartbeatMap: {},
setSelectedAccountId: (accountId) =>
set({ selectedAccountId: accountId }),
setExpandedAccountId: (accountId) =>
set({ expandedAccountId: accountId }),
setSelectedCourseId: (courseId) => set({ selectedCourseId: courseId }),
setCourseKind: (courseKind) => set({ courseKind }),
setRecordType: (recordType) => set({ recordType }),
setRecords: (records) => set({ records }),
setRecordCache: (cacheKey, records) =>
set((state) => ({
recordCacheMap: {
...state.recordCacheMap,
[cacheKey]: records,
},
})),
setAccountRunningStudy: (accountId, value) =>
set((state) => ({
runningStudyMap: {
@@ -83,16 +145,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: {
@@ -113,6 +198,12 @@ export const accountStore = createStore<AccountState>()(
selectedAccountId: account.id,
expandedAccountId: account.id,
})),
setAccountCourses: (accountId, courses) =>
set((state) => ({
accounts: state.accounts.map((item) =>
item.id === accountId ? { ...item, courses } : item,
),
})),
removeAccount: (accountId) =>
set((state) => {
const nextAccounts = state.accounts.filter(
@@ -133,6 +224,11 @@ export const accountStore = createStore<AccountState>()(
? null
: state.selectedCourseId,
records: state.selectedAccountId === accountId ? [] : state.records,
recordCacheMap: Object.fromEntries(
Object.entries(state.recordCacheMap).filter(
([key]) => !key.startsWith(`${accountId}::`),
),
),
studyLogsMap: Object.fromEntries(
Object.entries(state.studyLogsMap).filter(
([key]) => key !== accountId,
@@ -143,22 +239,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,
partialize: (state) => {
const preferences = resolvePersistPreferences();
const persistedState: Partial<AccountState> = {
courseKind: state.courseKind,
recordType: state.recordType,
records: state.records,
studyLogsMap: state.studyLogsMap,
runningStudyMap: state.runningStudyMap,
}),
};
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

@@ -35,6 +35,38 @@ type SettingsState = {
};
const accountStorageKey = "account-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>();
@@ -74,26 +106,59 @@ 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) => {
if (section === "accounts") {
localStorage.removeItem(accountStorageKey);
patchAccountStorage((state) => {
const {
accounts,
selectedAccountId,
expandedAccountId,
selectedCourseId,
records,
recordCacheMap,
studyLogsMap,
runningStudyMap,
...rest
} = state;
void accounts;
void selectedAccountId;
void expandedAccountId;
void selectedCourseId;
void records;
void recordCacheMap;
void studyLogsMap;
void runningStudyMap;
return rest;
});
}
if (section === "records") {
set({ persistRecords: false });
queueMicrotask(() => set({ persistRecords: true }));
patchAccountStorage((state) => {
const { records, recordCacheMap, selectedCourseId, ...rest } =
state;
void records;
void recordCacheMap;
void selectedCourseId;
return rest;
});
}
if (section === "logs") {
set({ persistLogs: false });
queueMicrotask(() => set({ persistLogs: true }));
patchAccountStorage((state) => {
const { studyLogsMap, runningStudyMap, ...rest } = state;
void studyLogsMap;
void runningStudyMap;
return rest;
});
}
},
clearAllPersistedData: () => {
localStorage.removeItem(accountStorageKey);
get().clearPersistedSection("records");
get().clearPersistedSection("logs");
},
setAutoScrollLogs: (value) => set({ autoScrollLogs: value }),
setShowLogTimestamps: (value) => set({ showLogTimestamps: value }),

View File

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