Compare commits
2 Commits
v0.1.1
...
7e102b3b76
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e102b3b76 | |||
| 58555c5043 |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.1.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
593
src/App.tsx
593
src/App.tsx
@@ -1,6 +1,27 @@
|
|||||||
import type { ParentComponent } from "solid-js";
|
import type { JSX, ParentComponent } from "solid-js";
|
||||||
import { createMemo, createResource, createSignal } from "solid-js";
|
import {
|
||||||
|
For,
|
||||||
|
Show,
|
||||||
|
createEffect,
|
||||||
|
createMemo,
|
||||||
|
createResource,
|
||||||
|
createSignal,
|
||||||
|
onCleanup,
|
||||||
|
} from "solid-js";
|
||||||
import { A, useLocation } from "@solidjs/router";
|
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";
|
import { versionApi } from "~/service/wk";
|
||||||
|
|
||||||
const asideList = [
|
const asideList = [
|
||||||
@@ -9,12 +30,138 @@ const asideList = [
|
|||||||
{ label: "设置", url: "/setting" },
|
{ 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 App: ParentComponent = (props) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [version] = createResource(versionApi);
|
const [version] = createResource(versionApi);
|
||||||
const [copyState, setCopyState] = createSignal<"idle" | "done" | "error">(
|
const [copyState, setCopyState] = createSignal<"idle" | "done" | "error">(
|
||||||
"idle",
|
"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) =>
|
const isActive = (url: string) =>
|
||||||
location.pathname === url ||
|
location.pathname === url ||
|
||||||
@@ -45,6 +192,30 @@ const App: ParentComponent = (props) => {
|
|||||||
`Email: ${emailText()}`,
|
`Email: ${emailText()}`,
|
||||||
].join("\n"),
|
].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 () => {
|
const handleCopyVersion = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -56,109 +227,349 @@ const App: ParentComponent = (props) => {
|
|||||||
|
|
||||||
window.setTimeout(() => setCopyState("idle"), 1800);
|
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 (
|
return (
|
||||||
<div class="flex h-screen w-full overflow-hidden bg-[radial-gradient(circle_at_top_left,_rgba(103,232,249,0.24),_transparent_28%),radial-gradient(circle_at_top_right,_rgba(251,191,36,0.16),_transparent_24%),linear-gradient(180deg,_#ecfeff_0%,_#f8fafc_52%,_#eef2ff_100%)] text-zinc-800">
|
<>
|
||||||
<div class="flex min-h-0 w-full flex-col p-3 sm:p-4">
|
<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">
|
||||||
<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-h-0 w-full flex-col p-3 sm:p-4">
|
||||||
<div class="flex min-w-0 items-center gap-3">
|
<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">
|
||||||
<A
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
href="/"
|
|
||||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[linear-gradient(135deg,_#06b6d4,_#22c55e)] text-base font-semibold text-white shadow-lg shadow-cyan-500/20"
|
|
||||||
>
|
|
||||||
WK
|
|
||||||
</A>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<A
|
<A
|
||||||
href="/"
|
href="/"
|
||||||
class="block truncate text-xl font-semibold tracking-wide text-zinc-900"
|
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[linear-gradient(135deg,_#06b6d4,_#22c55e)] text-base font-semibold text-white shadow-lg shadow-cyan-500/20"
|
||||||
>
|
>
|
||||||
网课控制台
|
WK
|
||||||
</A>
|
</A>
|
||||||
<p class="truncate text-xs text-zinc-500 sm:text-sm">
|
<div class="min-w-0">
|
||||||
管理账号、课程记录与任务日志
|
<A
|
||||||
</p>
|
href="/"
|
||||||
|
class="block truncate text-xl font-semibold tracking-wide text-zinc-900"
|
||||||
|
>
|
||||||
|
网课控制台
|
||||||
|
</A>
|
||||||
|
<p class="truncate text-xs text-zinc-500 sm:text-sm">
|
||||||
|
管理账号、课程记录与任务日志
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="mt-4 flex min-h-0 min-w-0 flex-1 gap-4">
|
<div class="mt-4 flex min-h-0 min-w-0 flex-1 flex-col gap-4 xl:flex-row">
|
||||||
<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">
|
<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 rounded-2xl bg-[linear-gradient(135deg,_rgba(6,182,212,0.12),_rgba(34,197,94,0.14))] px-4 py-4">
|
<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">
|
<p class="text-xs font-medium tracking-[0.24em] text-cyan-700/75 uppercase">
|
||||||
Navigation
|
Navigation
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-2 text-sm leading-6 text-zinc-600">
|
<p class="mt-2 text-sm leading-6 text-zinc-600">
|
||||||
在这里切换账号管理、全局日志与系统设置。
|
在这里切换账号管理、全局日志与系统设置。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="flex flex-col gap-2">
|
<nav class="flex gap-2 overflow-x-auto pb-1 xl:flex-col xl:overflow-visible xl:pb-0">
|
||||||
{asideList.map((item) => {
|
{asideList.map((item) => {
|
||||||
const active = isActive(item.url);
|
const active = isActive(item.url);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<A
|
<A
|
||||||
href={item.url}
|
href={item.url}
|
||||||
class={
|
class={
|
||||||
active
|
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"
|
? "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"
|
||||||
: "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-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">
|
||||||
|
<span>{item.label}</span>
|
||||||
|
<span
|
||||||
|
class={
|
||||||
|
active
|
||||||
|
? "text-cyan-700"
|
||||||
|
: "text-zinc-400 transition group-hover:text-zinc-600"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</A>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="mt-3 rounded-2xl border border-zinc-200/80 bg-zinc-50/90 px-4 py-4 xl:mt-auto">
|
||||||
|
<p class="text-sm font-medium text-zinc-800">当前页面</p>
|
||||||
|
<p class="mt-1 text-sm text-zinc-500">
|
||||||
|
{asideList.find((item) => isActive(item.url))?.label ?? "账号"}
|
||||||
|
</p>
|
||||||
|
<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()}
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between">
|
{copyState() === "done"
|
||||||
<span>{item.label}</span>
|
? "已复制"
|
||||||
<span
|
: copyState() === "error"
|
||||||
class={
|
? "复制失败"
|
||||||
active
|
: "复制版本信息"}
|
||||||
? "text-cyan-700"
|
</button>
|
||||||
: "text-zinc-400 transition group-hover:text-zinc-600"
|
<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"}
|
||||||
</span>
|
onClick={() => void performUpdateCheck(true)}
|
||||||
</div>
|
>
|
||||||
</A>
|
{updateCheckState() === "checking" ? "检查中..." : "检查更新"}
|
||||||
);
|
</button>
|
||||||
})}
|
</div>
|
||||||
</nav>
|
{versionErrorText() ? (
|
||||||
|
<p class="mt-2 text-xs text-rose-500">{versionErrorText()}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<div class="mt-auto rounded-2xl border border-zinc-200/80 bg-zinc-50/90 px-4 py-4">
|
<main class="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-[32px] border border-white/80 bg-white/80 p-3 shadow-[0_18px_60px_-24px_rgba(15,23,42,0.22)] backdrop-blur-xl sm:p-4">
|
||||||
<p class="text-sm font-medium text-zinc-800">当前页面</p>
|
{props.children}
|
||||||
<p class="mt-1 text-sm text-zinc-500">
|
</main>
|
||||||
{asideList.find((item) => isActive(item.url))?.label ?? "账号"}
|
</div>
|
||||||
</p>
|
|
||||||
<p class="mt-3 text-xs font-medium tracking-[0.18em] text-cyan-700/75 uppercase">
|
|
||||||
Runtime
|
|
||||||
</p>
|
|
||||||
<p class="mt-2 text-xs text-zinc-500">Version: {versionText()}</p>
|
|
||||||
<p class="mt-1 text-xs text-zinc-500">Commit: {commitText()}</p>
|
|
||||||
<p class="mt-1 text-xs text-zinc-500">Build: {buildText()}</p>
|
|
||||||
<p class="mt-1 text-xs text-zinc-500">Author: {authorText()}</p>
|
|
||||||
<p class="mt-1 text-xs text-zinc-500">Email: {emailText()}</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="mt-3 rounded-lg border border-zinc-200 bg-white px-3 py-1.5 text-xs text-zinc-700 transition hover:bg-zinc-100"
|
|
||||||
onClick={() => void handleCopyVersion()}
|
|
||||||
>
|
|
||||||
{copyState() === "done"
|
|
||||||
? "已复制"
|
|
||||||
: copyState() === "error"
|
|
||||||
? "复制失败"
|
|
||||||
: "复制版本信息"}
|
|
||||||
</button>
|
|
||||||
{versionErrorText() ? (
|
|
||||||
<p class="mt-2 text-xs text-rose-500">{versionErrorText()}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main class="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-[32px] border border-white/80 bg-white/80 p-3 shadow-[0_18px_60px_-24px_rgba(15,23,42,0.22)] backdrop-blur-xl sm:p-4">
|
|
||||||
{props.children}
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -29,21 +29,23 @@ const AccountSidebar = (props: AccountSidebarProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<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={{
|
style={{
|
||||||
width: `${props.sidebarWidth}px`,
|
width: `min(100%, ${props.sidebarWidth}px)`,
|
||||||
"min-width": `${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>
|
<div>
|
||||||
<p class="text-lg font-semibold text-zinc-900">账号信息</p>
|
<p class="text-base font-semibold text-zinc-900">账号信息</p>
|
||||||
<p class="mt-1 text-sm text-zinc-500">选择账号后查看课程与记录</p>
|
<p class="mt-1 text-xs text-zinc-500 sm:text-sm">
|
||||||
|
选择账号后查看课程与记录
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="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}
|
disabled={!props.selectedAccountId || props.isRefreshingAccount}
|
||||||
onClick={props.onRefreshAccount}
|
onClick={props.onRefreshAccount}
|
||||||
>
|
>
|
||||||
@@ -54,8 +56,8 @@ const AccountSidebar = (props: AccountSidebarProps) => {
|
|||||||
<div
|
<div
|
||||||
class={
|
class={
|
||||||
compact()
|
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-2 overflow-y-auto p-2.5"
|
||||||
: "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.5 overflow-y-auto p-3"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<For each={props.accounts}>
|
<For each={props.accounts}>
|
||||||
@@ -77,17 +79,19 @@ const AccountSidebar = (props: AccountSidebarProps) => {
|
|||||||
const courseTypeLabel = selected()
|
const courseTypeLabel = selected()
|
||||||
? `当前筛选:${currentCourseLabel}`
|
? `当前筛选:${currentCourseLabel}`
|
||||||
: `登录类型:${statusLabel}`;
|
: `登录类型:${statusLabel}`;
|
||||||
|
const badgeTypeLabel = selected() ? currentCourseLabel : statusLabel;
|
||||||
|
const badgeCountLabel = `${account.courses.length} 门`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={
|
class={
|
||||||
selected()
|
selected()
|
||||||
? compact()
|
? 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-[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-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-[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()
|
: 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-[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-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-[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">
|
<div class="flex items-start justify-between gap-3">
|
||||||
@@ -101,15 +105,23 @@ const AccountSidebar = (props: AccountSidebarProps) => {
|
|||||||
<p
|
<p
|
||||||
class={
|
class={
|
||||||
compact()
|
compact()
|
||||||
? "truncate text-base font-semibold text-zinc-900"
|
? "truncate text-[15px] font-semibold text-zinc-900"
|
||||||
: "truncate text-lg font-semibold text-zinc-900"
|
: "truncate text-base font-semibold text-zinc-900"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{account.user.name} + {platformLabel}
|
{account.user.name} + {platformLabel}
|
||||||
</p>
|
</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}
|
学号:{account.user.id}
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -123,26 +135,26 @@ const AccountSidebar = (props: AccountSidebarProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={expanded()}>
|
<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.dept}</p>
|
||||||
<p>班级:{account.user.class}</p>
|
<p>班级:{account.user.class}</p>
|
||||||
<p>性别:{account.user.gender}</p>
|
<p>性别:{account.user.gender}</p>
|
||||||
<p>站点:{account.host}</p>
|
<p>站点:{account.host}</p>
|
||||||
<p>账号:{account.username || "-"}</p>
|
<p>账号:{account.username || "-"}</p>
|
||||||
<p>{courseCountLabel}</p>
|
<p>{courseCountLabel}</p>
|
||||||
<p>{courseTypeLabel}</p>
|
<p class="sm:col-span-2">{courseTypeLabel}</p>
|
||||||
|
|
||||||
<div class="mt-2 flex gap-2">
|
<div class="mt-1.5 flex flex-wrap gap-1.5 sm:col-span-2">
|
||||||
<button
|
<button
|
||||||
type="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)}
|
onClick={() => props.onToggleExpand(account.id)}
|
||||||
>
|
>
|
||||||
收起信息
|
收起信息
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="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}
|
disabled={props.loggingOutId === account.id}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { For, Show, createEffect, type JSX } from "solid-js";
|
import {
|
||||||
|
For,
|
||||||
|
Show,
|
||||||
|
createEffect,
|
||||||
|
createMemo,
|
||||||
|
createSignal,
|
||||||
|
type JSX,
|
||||||
|
} from "solid-js";
|
||||||
import type { CourseKind, RecordType } from "~/service/wk";
|
import type { CourseKind, RecordType } from "~/service/wk";
|
||||||
import type { AccountItem } from "~/store/account";
|
import type { AccountItem } from "~/store/account";
|
||||||
import type { CourseType } from "~/types/Course";
|
import type { CourseType } from "~/types/Course";
|
||||||
@@ -12,6 +19,7 @@ type CourseRecordTypeOption = {
|
|||||||
label: string;
|
label: string;
|
||||||
value: CourseKind;
|
value: CourseKind;
|
||||||
};
|
};
|
||||||
|
type RecordFilter = "all" | "unlearned" | "learned";
|
||||||
|
|
||||||
interface CourseWorkspaceProps {
|
interface CourseWorkspaceProps {
|
||||||
selectedAccount: AccountItem | null;
|
selectedAccount: AccountItem | null;
|
||||||
@@ -65,6 +73,7 @@ const stripTimestamp = (message: string) => {
|
|||||||
|
|
||||||
const CourseWorkspace = (props: CourseWorkspaceProps) => {
|
const CourseWorkspace = (props: CourseWorkspaceProps) => {
|
||||||
let logContainerRef: HTMLDivElement | undefined;
|
let logContainerRef: HTMLDivElement | undefined;
|
||||||
|
const [recordFilter, setRecordFilter] = createSignal<RecordFilter>("all");
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
props.studyLogs.length;
|
props.studyLogs.length;
|
||||||
@@ -88,16 +97,44 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const compact = () => props.densityMode === "compact";
|
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 (
|
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)]">
|
<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 items-center justify-between border-b border-zinc-200/80 px-5 py-4">
|
<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>
|
<div>
|
||||||
<p class="text-lg font-semibold text-zinc-900">课程工作台</p>
|
<p class="text-[15px] font-semibold text-zinc-900 sm:text-base">
|
||||||
<p class="mt-1 text-sm text-zinc-500">课程、记录与日志统一查看</p>
|
课程工作台
|
||||||
|
</p>
|
||||||
|
<p class="mt-0.5 text-xs text-zinc-500 sm:text-sm">
|
||||||
|
课程、记录与日志统一查看
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Show when={props.selectedAccount}>
|
<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}
|
{props.selectedAccount?.user.name}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -114,19 +151,21 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
|
|||||||
<div
|
<div
|
||||||
class={
|
class={
|
||||||
compact()
|
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-1.5 p-1.5 lg:grid-cols-[280px_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-2 p-2 lg:grid-cols-[300px_minmax(0,1fr)]"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="flex min-h-0 flex-col overflow-hidden rounded-3xl border border-zinc-200 bg-[linear-gradient(180deg,rgba(248,250,252,0.9),rgba(255,255,255,0.95))]">
|
<div class="flex 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-3 border-b border-zinc-200 px-4 py-3">
|
<div class="flex flex-wrap items-center justify-between gap-1.5 border-b border-zinc-200 px-2.5 py-2">
|
||||||
<div class="border-b border-zinc-200 px-4 py-3">
|
<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>
|
<p class="mt-1 text-xs text-zinc-500">点击课程查看对应记录</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<select
|
<select
|
||||||
class="rounded-xl border border-zinc-200 bg-white px-3 py-2 text-sm transition outline-none focus:border-cyan-400"
|
class="rounded-lg border border-zinc-200 bg-white px-2 py-1 text-xs transition outline-none focus:border-cyan-400"
|
||||||
value={props.courseKind}
|
value={props.courseKind}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
props.onChangeCourseRecordType(
|
props.onChangeCourseRecordType(
|
||||||
@@ -140,7 +179,7 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
|
|||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
type="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.isRefreshingCourseRecords}
|
disabled={props.isRefreshingCourseRecords}
|
||||||
onClick={props.onRefreshCourseRecords}
|
onClick={props.onRefreshCourseRecords}
|
||||||
>
|
>
|
||||||
@@ -149,13 +188,7 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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"}>
|
||||||
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"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Show when={props.selectedCourseList.length === 0}>
|
<Show when={props.selectedCourseList.length === 0}>
|
||||||
<EmptyState>
|
<EmptyState>
|
||||||
当前“{props.currentCourseKindLabel}
|
当前“{props.currentCourseKindLabel}
|
||||||
@@ -173,18 +206,18 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
|
|||||||
class={
|
class={
|
||||||
selected()
|
selected()
|
||||||
? compact()
|
? 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-[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-[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-[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()
|
: 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-[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-[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-[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)}
|
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}
|
{course.name}
|
||||||
</p>
|
</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.id}</p>
|
||||||
<p>老师:{course.teacher}</p>
|
<p>老师:{course.teacher}</p>
|
||||||
<p>进度:{course.progress}</p>
|
<p>进度:{course.progress}</p>
|
||||||
@@ -199,14 +232,16 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
|
|||||||
<div
|
<div
|
||||||
class={
|
class={
|
||||||
compact()
|
compact()
|
||||||
? "grid min-h-0 gap-3 xl:grid-rows-[minmax(0,1fr)_240px]"
|
? "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-4 xl:grid-rows-[minmax(0,1fr)_260px]"
|
: "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 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-3 border-b border-zinc-200 px-4 py-3">
|
<div class="flex flex-wrap items-center justify-between gap-1.5 border-b border-zinc-200 px-2.5 py-2">
|
||||||
<div>
|
<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 class="mt-1 text-xs text-zinc-500">
|
||||||
{props.selectedCourse
|
{props.selectedCourse
|
||||||
? props.selectedCourse.name
|
? props.selectedCourse.name
|
||||||
@@ -221,10 +256,10 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-1.5">
|
||||||
<button
|
<button
|
||||||
type="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={
|
disabled={
|
||||||
!props.selectedCourse || props.isRefreshingRecords
|
!props.selectedCourse || props.isRefreshingRecords
|
||||||
}
|
}
|
||||||
@@ -234,7 +269,7 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<select
|
<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}
|
value={props.recordType}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
props.onChangeRecordType(
|
props.onChangeRecordType(
|
||||||
@@ -252,7 +287,7 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
|
|||||||
<Show when={props.recordType === ""}>
|
<Show when={props.recordType === ""}>
|
||||||
<button
|
<button
|
||||||
type="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={
|
onClick={
|
||||||
props.isRunningStudy
|
props.isRunningStudy
|
||||||
? props.onStopStudy
|
? props.onStopStudy
|
||||||
@@ -264,12 +299,40 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<div
|
||||||
class={
|
class={
|
||||||
compact()
|
compact()
|
||||||
? "min-h-0 flex-1 overflow-y-auto p-2.5"
|
? "min-h-0 flex-1 overflow-y-auto p-1.5"
|
||||||
: "min-h-0 flex-1 overflow-y-auto p-3"
|
: "min-h-0 flex-1 overflow-y-auto p-2"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Show when={props.recordsLoading}>
|
<Show when={props.recordsLoading}>
|
||||||
@@ -297,56 +360,78 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
|
|||||||
!props.recordsLoading &&
|
!props.recordsLoading &&
|
||||||
!props.recordError &&
|
!props.recordError &&
|
||||||
props.selectedCourse &&
|
props.selectedCourse &&
|
||||||
props.records.length === 0
|
filteredRecords().length === 0
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<EmptyState>当前分类下没有记录。</EmptyState>
|
<EmptyState>
|
||||||
|
{props.records.length === 0
|
||||||
|
? "当前分类下没有记录。"
|
||||||
|
: "当前筛选下没有记录。"}
|
||||||
|
</EmptyState>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="flex flex-col gap-3">
|
<div class={compact() ? "flex flex-col gap-1.5" : "flex flex-col gap-2"}>
|
||||||
<For each={props.records}>
|
<For each={filteredRecords()}>
|
||||||
{(record) => (
|
{(record) => {
|
||||||
<div
|
const stateText =
|
||||||
class={
|
props.renderRecordState(record.state) || "未知状态";
|
||||||
compact()
|
const learned =
|
||||||
? "rounded-[20px] border border-zinc-200 bg-white px-3 py-3 shadow-sm"
|
stateText.includes("已学") || record.progress === "1.00";
|
||||||
: "rounded-[22px] border border-zinc-200 bg-white px-4 py-4 shadow-sm"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<p class="truncate text-base font-semibold text-zinc-900">
|
|
||||||
{record.name}
|
|
||||||
</p>
|
|
||||||
<p class="mt-1 text-sm text-zinc-500">
|
|
||||||
记录 ID:{record.id} | 章节:{record.chapterId}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span class="rounded-full bg-zinc-100 px-3 py-1 text-xs font-medium text-zinc-700">
|
|
||||||
{props.renderRecordState(record.state) ||
|
|
||||||
"未知状态"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 grid gap-2 text-sm text-zinc-600 md:grid-cols-2 xl:grid-cols-3">
|
return (
|
||||||
<p>视频时长:{record.videoDuration}</p>
|
<div
|
||||||
<p>学习秒数:{record.duration}</p>
|
class={
|
||||||
<p>学习进度:{record.progress}</p>
|
learned
|
||||||
<p>开始时间:{record.beginTime || "-"}</p>
|
? compact()
|
||||||
<p>完成时间:{record.finalTime || "-"}</p>
|
? "rounded-[16px] border border-emerald-200 bg-emerald-50/35 px-2 py-1.5 shadow-sm"
|
||||||
<p>查看次数:{record.viewCount}</p>
|
: "rounded-[18px] border border-emerald-200 bg-emerald-50/35 px-2.5 py-2.5 shadow-sm"
|
||||||
|
: compact()
|
||||||
|
? "rounded-[16px] border border-amber-200 bg-amber-50/35 px-2 py-1.5 shadow-sm"
|
||||||
|
: "rounded-[18px] border border-amber-200 bg-amber-50/35 px-2.5 py-2.5 shadow-sm"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="truncate text-sm font-semibold text-zinc-900">
|
||||||
|
{record.name}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-zinc-500">
|
||||||
|
记录 ID:{record.id} | 章节:{record.chapterId}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class={
|
||||||
|
learned
|
||||||
|
? "rounded-full bg-emerald-100 px-3 py-1 text-xs font-medium text-emerald-700"
|
||||||
|
: "rounded-full bg-amber-100 px-3 py-1 text-xs font-medium text-amber-700"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{stateText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 grid gap-1 text-xs text-zinc-600 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<p>视频时长:{record.videoDuration}</p>
|
||||||
|
<p>学习秒数:{record.duration}</p>
|
||||||
|
<p>学习进度:{record.progress}</p>
|
||||||
|
<p>开始时间:{record.beginTime || "-"}</p>
|
||||||
|
<p>完成时间:{record.finalTime || "-"}</p>
|
||||||
|
<p>查看次数:{record.viewCount}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
)}
|
}}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex min-h-0 flex-col overflow-hidden rounded-3xl border border-zinc-200 bg-white">
|
<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-4 py-3">
|
<div class="flex items-center justify-between border-b border-zinc-200 px-2.5 py-2">
|
||||||
<div>
|
<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 class="mt-1 text-xs text-zinc-500">
|
||||||
输出会自动滚动到最新位置
|
输出会自动滚动到最新位置
|
||||||
</p>
|
</p>
|
||||||
@@ -355,7 +440,7 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="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}
|
disabled={props.isRefreshingLogs}
|
||||||
onClick={props.onRefreshLogs}
|
onClick={props.onRefreshLogs}
|
||||||
>
|
>
|
||||||
@@ -363,7 +448,7 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="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}
|
onClick={props.onClearLogs}
|
||||||
>
|
>
|
||||||
清空日志
|
清空日志
|
||||||
@@ -373,13 +458,13 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
ref={logContainerRef}
|
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
|
<Show
|
||||||
when={props.studyLogs.length > 0}
|
when={props.studyLogs.length > 0}
|
||||||
fallback={<p>暂无日志输出。</p>}
|
fallback={<p>暂无日志输出。</p>}
|
||||||
>
|
>
|
||||||
<div class="space-y-2">
|
<div class="space-y-1">
|
||||||
<For each={props.studyLogs}>
|
<For each={props.studyLogs}>
|
||||||
{(log, index) => (
|
{(log, index) => (
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ import AddAccountDialog, {
|
|||||||
import CourseWorkspace from "~/components/account/CourseWorkspace";
|
import CourseWorkspace from "~/components/account/CourseWorkspace";
|
||||||
import {
|
import {
|
||||||
createWkClient,
|
createWkClient,
|
||||||
|
hostApi,
|
||||||
loginApi,
|
loginApi,
|
||||||
runStudyQueue,
|
runStudyQueue,
|
||||||
type CourseKind,
|
type CourseKind,
|
||||||
|
type RecordItem,
|
||||||
type RecordType,
|
type RecordType,
|
||||||
} from "~/service/wk";
|
} from "~/service/wk";
|
||||||
import { setUnauthorizedHandler } from "~/service/http";
|
import { setUnauthorizedHandler } from "~/service/http";
|
||||||
@@ -47,13 +49,32 @@ const createDefaultForm = (host: string): LoginForm => ({
|
|||||||
const stripHtml = (value: string) => value.replace(/<[^>]+>/g, "").trim();
|
const stripHtml = (value: string) => value.replace(/<[^>]+>/g, "").trim();
|
||||||
|
|
||||||
const parseDurationToSeconds = (value: string) => {
|
const parseDurationToSeconds = (value: string) => {
|
||||||
const parts = value.split(":").map(Number);
|
const input = value.trim();
|
||||||
if (parts.length !== 3 || parts.some((part) => Number.isNaN(part))) {
|
if (!input) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [hours, minutes, seconds] = parts;
|
const numeric = Number(input);
|
||||||
return hours * 3600 + minutes * 60 + seconds;
|
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 = (
|
const createRecordCacheKey = (
|
||||||
@@ -61,6 +82,8 @@ const createRecordCacheKey = (
|
|||||||
courseId: number,
|
courseId: number,
|
||||||
recordType: RecordType,
|
recordType: RecordType,
|
||||||
) => `${accountId}::${courseId}::${recordType || "course"}`;
|
) => `${accountId}::${courseId}::${recordType || "course"}`;
|
||||||
|
const STUDY_HEARTBEAT_CHECK_INTERVAL_MS = 5000;
|
||||||
|
const STUDY_HEARTBEAT_TIMEOUT_MS = 45000;
|
||||||
|
|
||||||
const Account = () => {
|
const Account = () => {
|
||||||
const [storeState, setStoreState] = createSignal(accountStore.getState());
|
const [storeState, setStoreState] = createSignal(accountStore.getState());
|
||||||
@@ -80,8 +103,67 @@ const Account = () => {
|
|||||||
const [isRefreshingRecords, setIsRefreshingRecords] = createSignal(false);
|
const [isRefreshingRecords, setIsRefreshingRecords] = createSignal(false);
|
||||||
const [isRefreshingCourses, setIsRefreshingCourses] = createSignal(false);
|
const [isRefreshingCourses, setIsRefreshingCourses] = createSignal(false);
|
||||||
const [isRefreshingLogs, setIsRefreshingLogs] = createSignal(false);
|
const [isRefreshingLogs, setIsRefreshingLogs] = createSignal(false);
|
||||||
|
const [isLoadingRemoteHosts, setIsLoadingRemoteHosts] = createSignal(false);
|
||||||
const [hasRestoredRecords, setHasRestoredRecords] = createSignal(false);
|
const [hasRestoredRecords, setHasRestoredRecords] = createSignal(false);
|
||||||
const accountClients = new Map<string, ReturnType<typeof createWkClient>>();
|
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(() => {
|
onMount(() => {
|
||||||
const unsubscribeAccount = accountStore.subscribe((state) => {
|
const unsubscribeAccount = accountStore.subscribe((state) => {
|
||||||
@@ -90,12 +172,19 @@ const Account = () => {
|
|||||||
const unsubscribeSettings = settingsStore.subscribe((state) => {
|
const unsubscribeSettings = settingsStore.subscribe((state) => {
|
||||||
setSettingsState(state);
|
setSettingsState(state);
|
||||||
});
|
});
|
||||||
|
void loadRemoteHostsIfNeeded();
|
||||||
setUnauthorizedHandler(reloginBySession);
|
setUnauthorizedHandler(reloginBySession);
|
||||||
|
runStudyHeartbeatWatchdog();
|
||||||
|
const heartbeatTimer = window.setInterval(
|
||||||
|
runStudyHeartbeatWatchdog,
|
||||||
|
STUDY_HEARTBEAT_CHECK_INTERVAL_MS,
|
||||||
|
);
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
unsubscribeAccount();
|
unsubscribeAccount();
|
||||||
unsubscribeSettings();
|
unsubscribeSettings();
|
||||||
setUnauthorizedHandler(null);
|
setUnauthorizedHandler(null);
|
||||||
|
window.clearInterval(heartbeatTimer);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -189,6 +278,12 @@ const Account = () => {
|
|||||||
accountStore.getState().appendStudyLog(targetAccountId, message);
|
accountStore.getState().appendStudyLog(targetAccountId, message);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cancelPendingRecordRequest = () => {
|
||||||
|
recordRequestToken += 1;
|
||||||
|
setRecordsLoading(false);
|
||||||
|
setIsRefreshingRecords(false);
|
||||||
|
};
|
||||||
|
|
||||||
const getAccountClient = (accountId: string) => {
|
const getAccountClient = (accountId: string) => {
|
||||||
const existingClient = accountClients.get(accountId);
|
const existingClient = accountClients.get(accountId);
|
||||||
if (existingClient) {
|
if (existingClient) {
|
||||||
@@ -313,6 +408,7 @@ const Account = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectAccount = (accountId: string) => {
|
const handleSelectAccount = (accountId: string) => {
|
||||||
|
cancelPendingRecordRequest();
|
||||||
accountStore.getState().setSelectedAccountId(accountId);
|
accountStore.getState().setSelectedAccountId(accountId);
|
||||||
accountStore.getState().setSelectedCourseId(null);
|
accountStore.getState().setSelectedCourseId(null);
|
||||||
accountStore.getState().setRecords([]);
|
accountStore.getState().setRecords([]);
|
||||||
@@ -360,6 +456,7 @@ const Account = () => {
|
|||||||
setErrorMessage(message);
|
setErrorMessage(message);
|
||||||
accountStore.getState().setAccountCourses(accountId, []);
|
accountStore.getState().setAccountCourses(accountId, []);
|
||||||
if (accountStore.getState().selectedAccountId === accountId) {
|
if (accountStore.getState().selectedAccountId === accountId) {
|
||||||
|
cancelPendingRecordRequest();
|
||||||
accountStore.getState().setSelectedCourseId(null);
|
accountStore.getState().setSelectedCourseId(null);
|
||||||
accountStore.getState().setRecords([]);
|
accountStore.getState().setRecords([]);
|
||||||
}
|
}
|
||||||
@@ -437,36 +534,59 @@ const Account = () => {
|
|||||||
if (!account) {
|
if (!account) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const requestToken = ++recordRequestToken;
|
||||||
|
const accountId = account.id;
|
||||||
|
|
||||||
setRecordsLoading(true);
|
setRecordsLoading(true);
|
||||||
setIsRefreshingRecords(true);
|
setIsRefreshingRecords(true);
|
||||||
setRecordError("");
|
setRecordError("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await getAccountClient(account.id).recordApi({
|
const res = await getAccountClient(accountId).recordApi({
|
||||||
course_id: String(courseId),
|
course_id: String(courseId),
|
||||||
page: 0,
|
page: 0,
|
||||||
record_type: nextRecordType,
|
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
|
accountStore
|
||||||
.getState()
|
.getState()
|
||||||
.setRecordCache(
|
.setRecordCache(
|
||||||
createRecordCacheKey(account.id, courseId, nextRecordType),
|
createRecordCacheKey(accountId, courseId, nextRecordType),
|
||||||
res.data.list,
|
list,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (requestToken !== recordRequestToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : "获取记录失败,请稍后重试。";
|
error instanceof Error ? error.message : "获取记录失败,请稍后重试。";
|
||||||
setRecordError(message);
|
setRecordError(message);
|
||||||
accountStore.getState().setRecords([]);
|
accountStore.getState().setRecords([]);
|
||||||
} finally {
|
} finally {
|
||||||
setRecordsLoading(false);
|
if (requestToken === recordRequestToken) {
|
||||||
setIsRefreshingRecords(false);
|
setRecordsLoading(false);
|
||||||
|
setIsRefreshingRecords(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectCourse = (courseId: number) => {
|
const handleSelectCourse = (courseId: number) => {
|
||||||
|
cancelPendingRecordRequest();
|
||||||
accountStore.getState().setSelectedCourseId(courseId);
|
accountStore.getState().setSelectedCourseId(courseId);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -507,18 +627,19 @@ const Account = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const queueItems = records()
|
const queueItems = records()
|
||||||
.filter((item) => item.progress !== "1.00")
|
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
nodeId: item.id,
|
nodeId: item.id,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
currentTime: Number(item.duration || 0),
|
currentTime: parseDurationToSeconds(item.duration),
|
||||||
totalTime: parseDurationToSeconds(item.videoDuration),
|
totalTime: parseDurationToSeconds(item.videoDuration),
|
||||||
progress: item.progress,
|
progress: item.progress,
|
||||||
completed: stripHtml(item.state) === "已学",
|
completed: stripHtml(item.state) === "已学",
|
||||||
}));
|
}))
|
||||||
|
.filter((item) => item.progress !== "1.00" && !item.completed);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
accountStore.getState().setAccountRunningStudy(account.id, true);
|
accountStore.getState().setAccountRunningStudy(account.id, true);
|
||||||
|
touchStudyHeartbeat(account.id);
|
||||||
appendStudyLog(`开始刷课:${course.name}`, account.id);
|
appendStudyLog(`开始刷课:${course.name}`, account.id);
|
||||||
await runStudyQueue({
|
await runStudyQueue({
|
||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
@@ -528,10 +649,14 @@ const Account = () => {
|
|||||||
client: getAccountClient(account.id),
|
client: getAccountClient(account.id),
|
||||||
isRunningStudy: () =>
|
isRunningStudy: () =>
|
||||||
!!accountStore.getState().runningStudyMap[account.id],
|
!!accountStore.getState().runningStudyMap[account.id],
|
||||||
setIsRunningStudy: () =>
|
setIsRunningStudy: () => {
|
||||||
accountStore.getState().setAccountRunningStudy(account.id, false),
|
accountStore.getState().setAccountRunningStudy(account.id, false);
|
||||||
onLog: (message: string, accoundID: string) =>
|
clearStudyHeartbeat(account.id);
|
||||||
appendStudyLog(message, accoundID),
|
},
|
||||||
|
onLog: (message: string, accoundID: string) => {
|
||||||
|
touchStudyHeartbeat(accoundID);
|
||||||
|
appendStudyLog(message, accoundID);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (accountStore.getState().runningStudyMap[account.id]) {
|
if (accountStore.getState().runningStudyMap[account.id]) {
|
||||||
appendStudyLog(`刷课完成:${course.name}`, account.id);
|
appendStudyLog(`刷课完成:${course.name}`, account.id);
|
||||||
@@ -543,6 +668,7 @@ const Account = () => {
|
|||||||
setRecordError(message);
|
setRecordError(message);
|
||||||
} finally {
|
} finally {
|
||||||
accountStore.getState().setAccountRunningStudy(account.id, false);
|
accountStore.getState().setAccountRunningStudy(account.id, false);
|
||||||
|
clearStudyHeartbeat(account.id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -553,12 +679,14 @@ const Account = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
accountStore.getState().setAccountRunningStudy(account.id, false);
|
accountStore.getState().setAccountRunningStudy(account.id, false);
|
||||||
|
clearStudyHeartbeat(account.id);
|
||||||
appendStudyLog(`已发送停止刷课指令:${account.user.name}`, account.id);
|
appendStudyLog(`已发送停止刷课指令:${account.user.name}`, account.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on([selectedAccountId, courseKind], ([accountId, kind]) => {
|
on([selectedAccountId, courseKind], ([accountId, kind]) => {
|
||||||
if (!accountId) {
|
if (!accountId) {
|
||||||
|
cancelPendingRecordRequest();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -570,6 +698,7 @@ const Account = () => {
|
|||||||
on(
|
on(
|
||||||
[selectedAccountId, selectedCourseId, recordType],
|
[selectedAccountId, selectedCourseId, recordType],
|
||||||
([accountId, courseId, type]) => {
|
([accountId, courseId, type]) => {
|
||||||
|
cancelPendingRecordRequest();
|
||||||
if (!accountId || !courseId) {
|
if (!accountId || !courseId) {
|
||||||
accountStore.getState().setRecords([]);
|
accountStore.getState().setRecords([]);
|
||||||
return;
|
return;
|
||||||
@@ -613,12 +742,12 @@ const Account = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex h-full min-h-0 flex-col overflow-hidden">
|
<div class="flex h-full min-h-0 flex-col overflow-hidden">
|
||||||
<div class="flex shrink-0 items-center justify-between gap-3 rounded-[24px] border border-white/80 bg-[linear-gradient(135deg,rgba(236,254,255,0.95),rgba(255,255,255,0.92))] px-4 py-3 shadow-[0_18px_45px_-28px_rgba(8,145,178,0.35)]">
|
<div class="flex shrink-0 items-center justify-between gap-2.5 rounded-[22px] border border-white/80 bg-[linear-gradient(135deg,rgba(236,254,255,0.95),rgba(255,255,255,0.92))] px-3.5 py-2.5 shadow-[0_16px_40px_-28px_rgba(8,145,178,0.35)]">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs font-medium tracking-[0.26em] text-cyan-700/75 uppercase">
|
<p class="text-xs font-medium tracking-[0.26em] text-cyan-700/75 uppercase">
|
||||||
Account Center
|
Account Center
|
||||||
</p>
|
</p>
|
||||||
<h1 class="mt-1 text-xl font-semibold text-zinc-900 sm:text-2xl">
|
<h1 class="mt-1 text-lg font-semibold text-zinc-900 sm:text-xl">
|
||||||
账号管理
|
账号管理
|
||||||
</h1>
|
</h1>
|
||||||
<p class="mt-0.5 text-xs text-zinc-500 sm:text-sm">
|
<p class="mt-0.5 text-xs text-zinc-500 sm:text-sm">
|
||||||
@@ -627,14 +756,14 @@ const Account = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="rounded-xl bg-[linear-gradient(135deg,#06b6d4,#14b8a6)] px-3.5 py-2.5 text-sm font-medium text-white shadow-lg shadow-cyan-500/20 transition hover:-translate-y-px hover:shadow-cyan-500/30"
|
class="rounded-xl bg-[linear-gradient(135deg,#06b6d4,#14b8a6)] px-3 py-2 text-sm font-medium text-white shadow-lg shadow-cyan-500/20 transition hover:-translate-y-px hover:shadow-cyan-500/30"
|
||||||
onClick={openDialog}
|
onClick={openDialog}
|
||||||
>
|
>
|
||||||
添加账号
|
添加账号
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
<AccountSidebar
|
||||||
accounts={accounts()}
|
accounts={accounts()}
|
||||||
selectedAccountId={selectedAccountId()}
|
selectedAccountId={selectedAccountId()}
|
||||||
@@ -682,6 +811,7 @@ const Account = () => {
|
|||||||
accountStore.getState().setRecordType(value)
|
accountStore.getState().setRecordType(value)
|
||||||
}
|
}
|
||||||
onChangeCourseRecordType={(value) => {
|
onChangeCourseRecordType={(value) => {
|
||||||
|
cancelPendingRecordRequest();
|
||||||
accountStore.getState().setCourseKind(value);
|
accountStore.getState().setCourseKind(value);
|
||||||
accountStore.getState().setSelectedCourseId(null);
|
accountStore.getState().setSelectedCourseId(null);
|
||||||
accountStore.getState().setRecords([]);
|
accountStore.getState().setRecords([]);
|
||||||
|
|||||||
@@ -7,9 +7,28 @@ import {
|
|||||||
onMount,
|
onMount,
|
||||||
Show,
|
Show,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
|
import {
|
||||||
|
resolveDebugLogWsUrl,
|
||||||
|
type DebugLogEntry,
|
||||||
|
} from "~/service/debugLog";
|
||||||
import { accountStore } from "~/store/account";
|
import { accountStore } from "~/store/account";
|
||||||
import { settingsStore } from "~/store/settings";
|
import { settingsStore } from "~/store/settings";
|
||||||
|
|
||||||
|
type LogRow = {
|
||||||
|
id: string;
|
||||||
|
accountId: string;
|
||||||
|
accountName: string;
|
||||||
|
host: string;
|
||||||
|
seq: number;
|
||||||
|
timestamp: string;
|
||||||
|
timestampValue: number;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DebugSocketState = "connecting" | "open" | "closed" | "error";
|
||||||
|
|
||||||
|
const MAX_DEBUG_ENTRIES = 1000;
|
||||||
|
|
||||||
const extractTimestamp = (message: string) => {
|
const extractTimestamp = (message: string) => {
|
||||||
const match = message.match(/^\[(\d{2}:\d{2}:\d{2})\]\s*/);
|
const match = message.match(/^\[(\d{2}:\d{2}:\d{2})\]\s*/);
|
||||||
return match?.[1] ?? null;
|
return match?.[1] ?? null;
|
||||||
@@ -25,7 +44,6 @@ const parseTimestampValue = (timestamp: string | null) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [hours, minutes, seconds] = timestamp.split(":").map(Number);
|
const [hours, minutes, seconds] = timestamp.split(":").map(Number);
|
||||||
|
|
||||||
if ([hours, minutes, seconds].some((item) => Number.isNaN(item))) {
|
if ([hours, minutes, seconds].some((item) => Number.isNaN(item))) {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
@@ -33,12 +51,164 @@ const parseTimestampValue = (timestamp: string | null) => {
|
|||||||
return hours * 3600 + minutes * 60 + seconds;
|
return hours * 3600 + minutes * 60 + seconds;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeContent = (message: string) => {
|
||||||
|
const value = stripTimestamp(message).replace(/\s+/g, " ").trim();
|
||||||
|
return value || "-";
|
||||||
|
};
|
||||||
|
|
||||||
|
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 Logs = () => {
|
const Logs = () => {
|
||||||
const [accountState, setAccountState] = createSignal(accountStore.getState());
|
const [accountState, setAccountState] = createSignal(accountStore.getState());
|
||||||
const [settingsState, setSettingsState] = createSignal(
|
const [settingsState, setSettingsState] = createSignal(
|
||||||
settingsStore.getState(),
|
settingsStore.getState(),
|
||||||
);
|
);
|
||||||
|
const [debugEntries, setDebugEntries] = createSignal<DebugLogEntry[]>([]);
|
||||||
|
const [debugSocketState, setDebugSocketState] =
|
||||||
|
createSignal<DebugSocketState>("connecting");
|
||||||
|
const [debugError, setDebugError] = createSignal("");
|
||||||
|
const [selectedDebugEntryId, setSelectedDebugEntryId] = createSignal<number>(0);
|
||||||
let logContainerRef: HTMLDivElement | undefined;
|
let logContainerRef: HTMLDivElement | undefined;
|
||||||
|
let debugLogContainerRef: HTMLDivElement | undefined;
|
||||||
|
let debugSocket: WebSocket | null = null;
|
||||||
|
let reconnectTimer: number | undefined;
|
||||||
|
let manualClose = false;
|
||||||
|
const debugEntryKeySet = new Set<number>();
|
||||||
|
|
||||||
|
const resetDebugEntries = () => {
|
||||||
|
debugEntryKeySet.clear();
|
||||||
|
setDebugEntries([]);
|
||||||
|
setSelectedDebugEntryId(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 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 (debugSocket) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
manualClose = false;
|
||||||
|
setDebugSocketState("connecting");
|
||||||
|
setDebugError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const socket = new WebSocket(resolveDebugLogWsUrl());
|
||||||
|
debugSocket = socket;
|
||||||
|
|
||||||
|
socket.addEventListener("open", () => {
|
||||||
|
resetDebugEntries();
|
||||||
|
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 WS payloads to keep the stream alive.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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(() => {
|
onMount(() => {
|
||||||
const unsubscribeAccount = accountStore.subscribe((state) => {
|
const unsubscribeAccount = accountStore.subscribe((state) => {
|
||||||
@@ -47,10 +217,12 @@ const Logs = () => {
|
|||||||
const unsubscribeSettings = settingsStore.subscribe((state) => {
|
const unsubscribeSettings = settingsStore.subscribe((state) => {
|
||||||
setSettingsState(state);
|
setSettingsState(state);
|
||||||
});
|
});
|
||||||
|
connectDebugSocket();
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
unsubscribeAccount();
|
unsubscribeAccount();
|
||||||
unsubscribeSettings();
|
unsubscribeSettings();
|
||||||
|
disconnectDebugSocket();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,75 +238,74 @@ const Logs = () => {
|
|||||||
) as Record<string, { name: string; host: string }>;
|
) as Record<string, { name: string; host: string }>;
|
||||||
});
|
});
|
||||||
|
|
||||||
const allLogs = createMemo(() => {
|
const logRows = createMemo<LogRow[]>(() => {
|
||||||
return Object.entries(accountState().studyLogsMap)
|
const rows = Object.entries(accountState().studyLogsMap).flatMap(
|
||||||
.flatMap(([accountId, messages]) => {
|
([accountId, messages]) => {
|
||||||
const accountInfo = accountInfoMap()[accountId];
|
const accountInfo = accountInfoMap()[accountId];
|
||||||
const accountName = accountInfo?.name ?? accountId;
|
|
||||||
|
|
||||||
return messages.map((message, index) => {
|
return messages.map((message, index) => {
|
||||||
const timestamp = extractTimestamp(message);
|
const timestamp = extractTimestamp(message);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `${accountId}-${index}`,
|
id: `${accountId}-${index}`,
|
||||||
accountId,
|
accountId,
|
||||||
accountName,
|
accountName: accountInfo?.name ?? accountId,
|
||||||
index: index + 1,
|
host: accountInfo?.host ?? "-",
|
||||||
timestamp,
|
seq: index + 1,
|
||||||
|
timestamp: timestamp ?? "--:--:--",
|
||||||
timestampValue: parseTimestampValue(timestamp),
|
timestampValue: parseTimestampValue(timestamp),
|
||||||
content: stripTimestamp(message),
|
content: normalizeContent(message),
|
||||||
raw: message,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
})
|
},
|
||||||
.sort((left, right) => {
|
|
||||||
if (left.timestampValue !== right.timestampValue) {
|
|
||||||
return left.timestampValue - right.timestampValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (left.index !== right.index) {
|
|
||||||
return left.index - right.index;
|
|
||||||
}
|
|
||||||
|
|
||||||
return left.accountId.localeCompare(right.accountId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const accountSummaries = createMemo(() => {
|
|
||||||
const accountIds = Array.from(
|
|
||||||
new Set([
|
|
||||||
...accountState().accounts.map((account) => account.id),
|
|
||||||
...Object.keys(accountState().studyLogsMap),
|
|
||||||
]),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return accountIds.map((accountId) => {
|
return rows.sort((left, right) => {
|
||||||
const accountInfo = accountInfoMap()[accountId];
|
const leftTs =
|
||||||
const logs = accountState().studyLogsMap[accountId] ?? [];
|
left.timestampValue >= 0 ? left.timestampValue : Number.MAX_SAFE_INTEGER;
|
||||||
const latestMessage = logs[logs.length - 1] ?? "暂无日志";
|
const rightTs =
|
||||||
|
right.timestampValue >= 0
|
||||||
|
? right.timestampValue
|
||||||
|
: Number.MAX_SAFE_INTEGER;
|
||||||
|
|
||||||
return {
|
if (leftTs !== rightTs) {
|
||||||
id: accountId,
|
return leftTs - rightTs;
|
||||||
name: accountInfo?.name ?? accountId,
|
}
|
||||||
host: accountInfo?.host ?? "未知账号来源",
|
if (left.accountName !== right.accountName) {
|
||||||
total: logs.length,
|
return left.accountName.localeCompare(right.accountName, "zh-CN");
|
||||||
latestMessage: stripTimestamp(latestMessage),
|
}
|
||||||
latestTime: extractTimestamp(latestMessage),
|
if (left.seq !== right.seq) {
|
||||||
};
|
return left.seq - right.seq;
|
||||||
|
}
|
||||||
|
return left.accountId.localeCompare(right.accountId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const latestLog = createMemo(() => {
|
const totalAccountsWithLogs = createMemo(() => {
|
||||||
const logs = allLogs();
|
return Object.values(accountState().studyLogsMap).filter(
|
||||||
return logs.length > 0 ? logs[logs.length - 1] : null;
|
(messages) => messages.length > 0,
|
||||||
|
).length;
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalAccountsWithLogs = createMemo(() => {
|
const latestLog = createMemo(() => {
|
||||||
return accountSummaries().filter((item) => item.total > 0).length;
|
const rows = logRows();
|
||||||
|
return rows.length > 0 ? rows[rows.length - 1] : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
});
|
});
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
allLogs().length;
|
logRows().length;
|
||||||
|
|
||||||
if (!settingsState().autoScrollLogs) {
|
if (!settingsState().autoScrollLogs) {
|
||||||
return;
|
return;
|
||||||
@@ -148,16 +319,41 @@ const Logs = () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
debugEntries().length;
|
||||||
|
|
||||||
|
if (!settingsState().autoScrollLogs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const element = debugLogContainerRef;
|
||||||
|
if (element) {
|
||||||
|
element.scrollTo({ top: element.scrollHeight, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (logContainerRef) {
|
if (logContainerRef) {
|
||||||
logContainerRef.style.fontSize = `${Math.max(settingsState().logFontSize, 12)}px`;
|
logContainerRef.style.fontSize = `${Math.max(settingsState().logFontSize, 12)}px`;
|
||||||
}
|
}
|
||||||
|
if (debugLogContainerRef) {
|
||||||
|
debugLogContainerRef.style.fontSize = `${Math.max(settingsState().logFontSize, 12)}px`;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const clearAllLogs = () => {
|
const clearAllLogs = () => {
|
||||||
accountStore.getState().clearAllStudyLogs();
|
accountStore.getState().clearAllStudyLogs();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearDebugLogs = () => {
|
||||||
|
resetDebugEntries();
|
||||||
|
if (debugSocketState() !== "open") {
|
||||||
|
connectDebugSocket();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex h-full min-h-0 flex-col overflow-hidden">
|
<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(255,248,235,0.98),rgba(255,255,255,0.94))] px-5 py-4 shadow-[0_18px_50px_-30px_rgba(180,83,9,0.28)]">
|
<div class="rounded-[26px] border border-white/80 bg-[linear-gradient(135deg,rgba(255,248,235,0.98),rgba(255,255,255,0.94))] px-5 py-4 shadow-[0_18px_50px_-30px_rgba(180,83,9,0.28)]">
|
||||||
@@ -170,7 +366,7 @@ const Logs = () => {
|
|||||||
日志中心
|
日志中心
|
||||||
</h1>
|
</h1>
|
||||||
<p class="mt-1 text-xs leading-5 text-zinc-600 sm:text-sm">
|
<p class="mt-1 text-xs leading-5 text-zinc-600 sm:text-sm">
|
||||||
聚合全部账号日志,优先把空间留给正文。
|
单行并列展示时间、账号、姓名与日志内容。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -180,7 +376,7 @@ const Logs = () => {
|
|||||||
TOTAL LOGS
|
TOTAL LOGS
|
||||||
</span>
|
</span>
|
||||||
<span class="text-sm font-semibold text-zinc-950">
|
<span class="text-sm font-semibold text-zinc-950">
|
||||||
{allLogs().length}
|
{logRows().length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 rounded-full border border-white/80 bg-white/85 px-3 py-1.5 shadow-sm">
|
<div class="flex items-center gap-2 rounded-full border border-white/80 bg-white/85 px-3 py-1.5 shadow-sm">
|
||||||
@@ -191,12 +387,17 @@ const Logs = () => {
|
|||||||
{totalAccountsWithLogs()}
|
{totalAccountsWithLogs()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<Show when={latestLog()}>
|
||||||
|
<div class="rounded-full border border-white/80 bg-white/85 px-3 py-1.5 text-xs text-zinc-600 shadow-sm">
|
||||||
|
最新: {latestLog()?.timestamp} / {latestLog()?.accountName}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
<div class="flex items-center gap-2 rounded-full border border-white/80 bg-white/85 px-3 py-1.5 shadow-sm">
|
<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">
|
<span class="text-[10px] tracking-[0.18em] text-zinc-400 uppercase">
|
||||||
AUTO SCROLL
|
DEBUG STREAM
|
||||||
</span>
|
</span>
|
||||||
<span class="text-sm font-medium text-zinc-900">
|
<span class="text-sm font-semibold text-zinc-950">
|
||||||
{settingsState().autoScrollLogs ? "跟随最新" : "手动查看"}
|
{debugEntries().length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -210,120 +411,225 @@ const Logs = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-3 py-3">
|
<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)]">
|
||||||
<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-white/10 px-4 py-3">
|
||||||
<div class="shrink-0 border-b border-zinc-200/80 px-4 py-3">
|
<div class="flex flex-wrap items-center gap-2 text-xs">
|
||||||
<p class="text-base font-semibold text-zinc-950">账号概览</p>
|
<span class="rounded-full border border-emerald-400/20 bg-emerald-400/10 px-3 py-1 text-emerald-200">
|
||||||
<p class="mt-1 text-xs text-zinc-500">最新输出与日志数量</p>
|
{settingsState().showLogTimestamps ? "显示时间" : "隐藏时间"}
|
||||||
|
</span>
|
||||||
|
<span class="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-slate-300">
|
||||||
|
{settingsState().autoScrollLogs ? "自动滚动中" : "自动滚动关闭"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex min-h-0 shrink-0 flex-row gap-3 space-y-2.5 overflow-y-auto px-3 py-3">
|
<div
|
||||||
<For each={accountSummaries()}>
|
ref={logContainerRef}
|
||||||
{(account) => (
|
class="min-h-0 flex-1 overflow-auto px-3 pt-0 pb-3 font-mono text-emerald-200"
|
||||||
<div class="rounded-2xl border border-zinc-200 bg-white p-4 transition hover:shadow-md">
|
>
|
||||||
{/* 顶部 */}
|
<Show
|
||||||
<div class="flex items-center justify-between">
|
when={logRows().length > 0}
|
||||||
<div class="min-w-0">
|
fallback={
|
||||||
<p class="truncate text-sm font-semibold text-zinc-900">
|
<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">
|
||||||
{account.name}
|
暂无日志输出
|
||||||
</p>
|
|
||||||
<p class="text-xs text-zinc-400">{account.host}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-full bg-amber-100 px-2.5 py-1 text-xs text-amber-700">
|
|
||||||
{account.total}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 最新日志 */}
|
|
||||||
<div class="mt-3 rounded-xl bg-zinc-50 p-3">
|
|
||||||
<div class="flex justify-between text-[11px] text-zinc-400">
|
|
||||||
<span>Latest</span>
|
|
||||||
<span>{account.latestTime ?? "--:--"}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="mt-1 line-clamp-2 text-xs text-zinc-600">
|
|
||||||
{account.latestMessage}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-[26px] border border-zinc-200/80 bg-[linear-gradient(180deg,rgba(11,18,32,0.98),rgba(10,10,16,1))] shadow-[0_24px_60px_-32px_rgba(15,23,42,0.45)]">
|
|
||||||
<div class="shrink-0 border-b border-white/10 px-4 py-3">
|
|
||||||
<div class="flex flex-row items-center justify-between gap-2">
|
|
||||||
<div>
|
|
||||||
<p class="text-base font-semibold text-slate-400">实时日志流</p>
|
|
||||||
<p class="mt-1 text-xs text-slate-400">
|
|
||||||
保留关键信息,把更多高度让给正文
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
<div class="flex flex-wrap items-center gap-2 text-xs">
|
|
||||||
<span class="rounded-full border border-emerald-400/20 bg-emerald-400/10 px-3 py-1 text-emerald-200">
|
|
||||||
{settingsState().showLogTimestamps ? "显示时间" : "隐藏时间"}
|
|
||||||
</span>
|
|
||||||
<span class="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-slate-300">
|
|
||||||
{settingsState().autoScrollLogs
|
|
||||||
? "自动滚动中"
|
|
||||||
: "自动滚动关闭"}
|
|
||||||
</span>
|
|
||||||
<Show when={latestLog()}>
|
|
||||||
<span class="rounded-full border border-amber-400/20 bg-amber-400/10 px-3 py-1 text-amber-200">
|
|
||||||
最近来源 {latestLog()?.accountName}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
ref={logContainerRef}
|
|
||||||
class="min-h-0 flex-1 overflow-y-auto px-4 py-3 font-mono text-emerald-200"
|
|
||||||
>
|
>
|
||||||
<Show
|
<div class="min-w-[980px]">
|
||||||
when={allLogs().length > 0}
|
<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)]">
|
||||||
fallback={
|
<span>时间</span>
|
||||||
<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">
|
<span>姓名</span>
|
||||||
暂无日志输出
|
<span>账号</span>
|
||||||
</div>
|
<span>主机</span>
|
||||||
}
|
<span>序号</span>
|
||||||
>
|
<span>内容</span>
|
||||||
<div class="space-y-1">
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
<Show when={settingsState().showLogTimestamps}>
|
<div class="relative z-0">
|
||||||
<span class="text-emerald-300/80">
|
<For each={logRows()}>
|
||||||
{log.timestamp}
|
{(row) => (
|
||||||
</span>
|
<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">
|
||||||
</Show>
|
<span class="truncate text-emerald-300">
|
||||||
</div>
|
{settingsState().showLogTimestamps
|
||||||
|
? row.timestamp
|
||||||
<span class="text-slate-500">{log.accountId}</span>
|
: "--:--:--"}
|
||||||
</div>
|
</span>
|
||||||
|
<span
|
||||||
{/* 内容 */}
|
class="truncate text-amber-200"
|
||||||
<p class="mt-1 text-sm leading-5 whitespace-pre-wrap text-emerald-200">
|
title={row.accountName}
|
||||||
{log.content}
|
>
|
||||||
</p>
|
{row.accountName}
|
||||||
|
</span>
|
||||||
|
<span class="truncate text-slate-300" title={row.accountId}>
|
||||||
|
{row.accountId}
|
||||||
|
</span>
|
||||||
|
<span class="truncate text-slate-400" title={row.host}>
|
||||||
|
{row.host}
|
||||||
|
</span>
|
||||||
|
<span class="truncate text-cyan-200">#{row.seq}</span>
|
||||||
|
<span class="truncate text-emerald-100" title={row.content}>
|
||||||
|
{row.content}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
Backend Debug Stream
|
||||||
|
</p>
|
||||||
|
<h2 class="mt-1 text-xl font-semibold tracking-tight text-white">
|
||||||
|
后端实时日志
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1 text-xs leading-5 text-slate-400 sm:text-sm">
|
||||||
|
通过 WebSocket 读取后端 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/10 bg-white/5 px-3 py-1 text-xs text-slate-300">
|
||||||
|
Source {debugSourceCount()}
|
||||||
|
</span>
|
||||||
|
<Show when={latestDebugEntry()}>
|
||||||
|
<span class="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-slate-300">
|
||||||
|
最新: {latestDebugEntry()?.time} / {latestDebugEntry()?.source}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<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={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>
|
</div>
|
||||||
</section>
|
<Show when={debugError()}>
|
||||||
</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
31
src/service/debugLog.ts
Normal file
31
src/service/debugLog.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
export type DebugLogEntry = {
|
||||||
|
id: number;
|
||||||
|
time: string;
|
||||||
|
level: string;
|
||||||
|
source: string;
|
||||||
|
message: string;
|
||||||
|
logger?: string;
|
||||||
|
caller?: string;
|
||||||
|
fields?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toWsProtocol = (protocol: string) => {
|
||||||
|
return protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveDebugLogWsUrl = () => {
|
||||||
|
const baseUrl = import.meta.env.VITE_BASE_URL as string | undefined;
|
||||||
|
|
||||||
|
if (baseUrl && /^https?:\/\//.test(baseUrl)) {
|
||||||
|
const url = new URL(baseUrl);
|
||||||
|
url.protocol = toWsProtocol(url.protocol);
|
||||||
|
url.pathname = "/api/debug/ws/logs";
|
||||||
|
url.search = "";
|
||||||
|
url.hash = "";
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL("/api/debug/ws/logs", window.location.origin);
|
||||||
|
url.protocol = toWsProtocol(window.location.protocol);
|
||||||
|
return url.toString();
|
||||||
|
};
|
||||||
@@ -13,16 +13,40 @@ export type HttpClient = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let unauthorizedHandler: UnauthorizedHandler | null = null;
|
let unauthorizedHandler: UnauthorizedHandler | null = null;
|
||||||
|
const reloginTaskMap = new Map<string, Promise<boolean>>();
|
||||||
|
|
||||||
export const setUnauthorizedHandler = (handler: UnauthorizedHandler | null) => {
|
export const setUnauthorizedHandler = (handler: UnauthorizedHandler | null) => {
|
||||||
unauthorizedHandler = handler;
|
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 = (
|
export const createHttpClient = (
|
||||||
resolveSessionId?: SessionResolver,
|
resolveSessionId?: SessionResolver,
|
||||||
): HttpClient => {
|
): HttpClient => {
|
||||||
const instance = axios.create({
|
const instance = axios.create({
|
||||||
baseURL: import.meta.env.VITE_BASE_URL,
|
baseURL: import.meta.env.VITE_BASE_URL,
|
||||||
|
timeout: 15000,
|
||||||
});
|
});
|
||||||
|
|
||||||
instance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
instance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||||
@@ -52,7 +76,7 @@ export const createHttpClient = (
|
|||||||
!url.includes("/api/login")
|
!url.includes("/api/login")
|
||||||
) {
|
) {
|
||||||
config._retry = true;
|
config._retry = true;
|
||||||
const ok = await unauthorizedHandler(sessionId);
|
const ok = await runUnauthorizedHandler(sessionId);
|
||||||
|
|
||||||
if (ok) {
|
if (ok) {
|
||||||
return instance.request(config);
|
return instance.request(config);
|
||||||
|
|||||||
302
src/service/update.ts
Normal file
302
src/service/update.ts
Normal 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}`;
|
||||||
|
};
|
||||||
@@ -193,18 +193,32 @@ const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms));
|
|||||||
|
|
||||||
export const runStudyQueue = async (_payload: StudyRunnerPayload) => {
|
export const runStudyQueue = async (_payload: StudyRunnerPayload) => {
|
||||||
const stopFlag = _payload.isRunningStudy;
|
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) {
|
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 currentTime = 0;
|
||||||
let count = 0;
|
let count = 0;
|
||||||
let study_id = 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()) {
|
if (!stopFlag()) {
|
||||||
_payload.onLog?.("⛔ 已手动停止", _payload.accountId);
|
_payload.onLog?.("⛔ 已手动停止", _payload.accountId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const message = `[${item.name}]: ${currentTime}/${total}`;
|
const message = `[${item.name}]: ${currentTime}/${totalTime}`;
|
||||||
_payload.onLog?.(message, _payload.accountId);
|
_payload.onLog?.(message, _payload.accountId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -212,8 +226,69 @@ export const runStudyQueue = async (_payload: StudyRunnerPayload) => {
|
|||||||
node_id: item.nodeId,
|
node_id: item.nodeId,
|
||||||
study_id: String(study_id),
|
study_id: String(study_id),
|
||||||
study_time: String(currentTime),
|
study_time: String(currentTime),
|
||||||
status: count === 0 ? 1 : currentTime >= total ? 3 : 2,
|
status: count === 0 ? 1 : currentTime >= totalTime ? 3 : 2,
|
||||||
});
|
});
|
||||||
|
const submitMessage = String(resp.data.msg ?? "");
|
||||||
|
const isSubmitFailed =
|
||||||
|
submitMessage.includes("提交学时失败") || resp.data.status === false;
|
||||||
|
|
||||||
|
if (isSubmitFailed) {
|
||||||
|
let submitRetry = 0;
|
||||||
|
let fixedResp = resp;
|
||||||
|
|
||||||
|
while (submitRetry < maxStudySubmitRetry) {
|
||||||
|
submitRetry += 1;
|
||||||
|
_payload.onLog?.(
|
||||||
|
`⚠️ 提交学时失败,${submitRetry}/${maxStudySubmitRetry} 次重试后再提交: ${item.name}`,
|
||||||
|
_payload.accountId,
|
||||||
|
);
|
||||||
|
await sleep(studySubmitRetrySleepMs);
|
||||||
|
|
||||||
|
if (!stopFlag()) {
|
||||||
|
_payload.onLog?.("⛔ 已手动停止", _payload.accountId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fixedResp = await _payload.client.studyApi({
|
||||||
|
node_id: item.nodeId,
|
||||||
|
study_id: String(study_id),
|
||||||
|
study_time: String(currentTime),
|
||||||
|
status: count === 0 ? 1 : currentTime >= totalTime ? 3 : 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextMsg = String(fixedResp.data.msg ?? "");
|
||||||
|
const nextFailed =
|
||||||
|
nextMsg.includes("提交学时失败") || fixedResp.data.status === false;
|
||||||
|
if (!nextFailed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalMsg = String(fixedResp.data.msg ?? "");
|
||||||
|
const stillFailed =
|
||||||
|
finalMsg.includes("提交学时失败") || fixedResp.data.status === false;
|
||||||
|
if (stillFailed) {
|
||||||
|
_payload.onLog?.(
|
||||||
|
`⛔ 提交学时连续重试 ${maxStudySubmitRetry} 次仍失败,停止刷课: ${item.name}`,
|
||||||
|
_payload.accountId,
|
||||||
|
);
|
||||||
|
_payload.setIsRunningStudy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
study_id = fixedResp.data.studyId;
|
||||||
|
retryCount = 0;
|
||||||
|
|
||||||
|
if (currentTime === totalTime) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTime = Math.min(currentTime + stepSeconds, totalTime);
|
||||||
|
count++;
|
||||||
|
await sleep(sleepMs);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (resp.data.state != 0) {
|
if (resp.data.state != 0) {
|
||||||
_payload.onLog?.(`⛔ 自动停止: ${resp.data.msg}`, _payload.accountId);
|
_payload.onLog?.(`⛔ 自动停止: ${resp.data.msg}`, _payload.accountId);
|
||||||
_payload.setIsRunningStudy();
|
_payload.setIsRunningStudy();
|
||||||
@@ -221,17 +296,29 @@ export const runStudyQueue = async (_payload: StudyRunnerPayload) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
study_id = resp.data.studyId;
|
study_id = resp.data.studyId;
|
||||||
|
retryCount = 0;
|
||||||
|
|
||||||
if (currentTime === total) break;
|
if (currentTime === totalTime) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
currentTime = Math.min(currentTime + 5, total);
|
currentTime = Math.min(currentTime + stepSeconds, totalTime);
|
||||||
count++;
|
count++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
retryCount += 1;
|
||||||
const errorMessage = `请求失败: ${error instanceof Error ? error.message : String(error)}`;
|
const errorMessage = `请求失败: ${error instanceof Error ? error.message : String(error)}`;
|
||||||
_payload.onLog?.(errorMessage, _payload.accountId);
|
_payload.onLog?.(errorMessage, _payload.accountId);
|
||||||
|
|
||||||
|
if (retryCount >= maxRetryCount) {
|
||||||
|
_payload.onLog?.(
|
||||||
|
`⚠️ 连续失败 ${maxRetryCount} 次,跳过节点: ${item.name}`,
|
||||||
|
_payload.accountId,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await sleep(5000);
|
await sleep(sleepMs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const withLogTimestamp = (message: string) => {
|
|||||||
|
|
||||||
return `[${timestamp}] ${message}`;
|
return `[${timestamp}] ${message}`;
|
||||||
};
|
};
|
||||||
|
const MAX_STUDY_LOGS_PER_ACCOUNT = 1000;
|
||||||
|
|
||||||
export type AccountAuth = {
|
export type AccountAuth = {
|
||||||
password: string;
|
password: string;
|
||||||
@@ -38,6 +39,45 @@ export type AccountItem = {
|
|||||||
|
|
||||||
export type RecordCacheMap = Record<string, RecordItem[]>;
|
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 = {
|
type AccountState = {
|
||||||
accounts: AccountItem[];
|
accounts: AccountItem[];
|
||||||
selectedAccountId: string;
|
selectedAccountId: string;
|
||||||
@@ -49,6 +89,7 @@ type AccountState = {
|
|||||||
recordCacheMap: RecordCacheMap;
|
recordCacheMap: RecordCacheMap;
|
||||||
studyLogsMap: Record<string, string[]>;
|
studyLogsMap: Record<string, string[]>;
|
||||||
runningStudyMap: Record<string, boolean>;
|
runningStudyMap: Record<string, boolean>;
|
||||||
|
studyHeartbeatMap: Record<string, number>;
|
||||||
setSelectedAccountId: (accountId: string) => void;
|
setSelectedAccountId: (accountId: string) => void;
|
||||||
setExpandedAccountId: (accountId: string) => void;
|
setExpandedAccountId: (accountId: string) => void;
|
||||||
setSelectedCourseId: (courseId: number | null) => void;
|
setSelectedCourseId: (courseId: number | null) => void;
|
||||||
@@ -57,6 +98,9 @@ type AccountState = {
|
|||||||
setRecords: (records: RecordItem[]) => void;
|
setRecords: (records: RecordItem[]) => void;
|
||||||
setRecordCache: (cacheKey: string, records: RecordItem[]) => void;
|
setRecordCache: (cacheKey: string, records: RecordItem[]) => void;
|
||||||
setAccountRunningStudy: (accountId: string, value: boolean) => 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;
|
appendStudyLog: (accountId: string, message: string) => void;
|
||||||
clearStudyLogs: (accountId: string) => void;
|
clearStudyLogs: (accountId: string) => void;
|
||||||
clearAllStudyLogs: () => void;
|
clearAllStudyLogs: () => void;
|
||||||
@@ -78,6 +122,7 @@ export const accountStore = createStore<AccountState>()(
|
|||||||
recordCacheMap: {},
|
recordCacheMap: {},
|
||||||
studyLogsMap: {},
|
studyLogsMap: {},
|
||||||
runningStudyMap: {},
|
runningStudyMap: {},
|
||||||
|
studyHeartbeatMap: {},
|
||||||
setSelectedAccountId: (accountId) =>
|
setSelectedAccountId: (accountId) =>
|
||||||
set({ selectedAccountId: accountId }),
|
set({ selectedAccountId: accountId }),
|
||||||
setExpandedAccountId: (accountId) =>
|
setExpandedAccountId: (accountId) =>
|
||||||
@@ -100,16 +145,39 @@ export const accountStore = createStore<AccountState>()(
|
|||||||
[accountId]: value,
|
[accountId]: value,
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
appendStudyLog: (accountId, message) =>
|
touchStudyHeartbeat: (accountId, timestamp) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
studyLogsMap: {
|
studyHeartbeatMap: {
|
||||||
...state.studyLogsMap,
|
...state.studyHeartbeatMap,
|
||||||
[accountId]: [
|
[accountId]: timestamp ?? Date.now(),
|
||||||
...(state.studyLogsMap[accountId] ?? []),
|
|
||||||
withLogTimestamp(message),
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
|
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) =>
|
clearStudyLogs: (accountId) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
studyLogsMap: {
|
studyLogsMap: {
|
||||||
@@ -171,24 +239,42 @@ export const accountStore = createStore<AccountState>()(
|
|||||||
([key]) => key !== accountId,
|
([key]) => key !== accountId,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
studyHeartbeatMap: Object.fromEntries(
|
||||||
|
Object.entries(state.studyHeartbeatMap).filter(
|
||||||
|
([key]) => key !== accountId,
|
||||||
|
),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "account-storage",
|
name: "account-storage",
|
||||||
storage: createJSONStorage(() => localStorage),
|
storage: createJSONStorage(() => localStorage),
|
||||||
partialize: (state) => ({
|
partialize: (state) => {
|
||||||
accounts: state.accounts,
|
const preferences = resolvePersistPreferences();
|
||||||
selectedAccountId: state.selectedAccountId,
|
const persistedState: Partial<AccountState> = {
|
||||||
expandedAccountId: state.expandedAccountId,
|
courseKind: state.courseKind,
|
||||||
selectedCourseId: state.selectedCourseId,
|
recordType: state.recordType,
|
||||||
courseKind: state.courseKind,
|
};
|
||||||
recordType: state.recordType,
|
|
||||||
records: state.records,
|
if (preferences.persistAccounts) {
|
||||||
recordCacheMap: state.recordCacheMap,
|
persistedState.accounts = state.accounts;
|
||||||
studyLogsMap: state.studyLogsMap,
|
persistedState.selectedAccountId = state.selectedAccountId;
|
||||||
runningStudyMap: state.runningStudyMap,
|
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;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,6 +35,38 @@ type SettingsState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const accountStorageKey = "account-storage";
|
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 uniqueHosts = (hosts: HostOption[]) => {
|
||||||
const map = new Map<string, HostOption>();
|
const map = new Map<string, HostOption>();
|
||||||
@@ -74,26 +106,59 @@ export const settingsStore = createStore<SettingsState>()(
|
|||||||
if (section === "accounts") set({ persistAccounts: value });
|
if (section === "accounts") set({ persistAccounts: value });
|
||||||
if (section === "records") set({ persistRecords: value });
|
if (section === "records") set({ persistRecords: value });
|
||||||
if (section === "logs") set({ persistLogs: value });
|
if (section === "logs") set({ persistLogs: value });
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
queueMicrotask(() => get().clearPersistedSection(section));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
clearPersistedSection: (section) => {
|
clearPersistedSection: (section) => {
|
||||||
if (section === "accounts") {
|
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") {
|
if (section === "records") {
|
||||||
set({ persistRecords: false });
|
patchAccountStorage((state) => {
|
||||||
queueMicrotask(() => set({ persistRecords: true }));
|
const { records, recordCacheMap, selectedCourseId, ...rest } =
|
||||||
|
state;
|
||||||
|
void records;
|
||||||
|
void recordCacheMap;
|
||||||
|
void selectedCourseId;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (section === "logs") {
|
if (section === "logs") {
|
||||||
set({ persistLogs: false });
|
patchAccountStorage((state) => {
|
||||||
queueMicrotask(() => set({ persistLogs: true }));
|
const { studyLogsMap, runningStudyMap, ...rest } = state;
|
||||||
|
void studyLogsMap;
|
||||||
|
void runningStudyMap;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
clearAllPersistedData: () => {
|
clearAllPersistedData: () => {
|
||||||
localStorage.removeItem(accountStorageKey);
|
localStorage.removeItem(accountStorageKey);
|
||||||
get().clearPersistedSection("records");
|
|
||||||
get().clearPersistedSection("logs");
|
|
||||||
},
|
},
|
||||||
setAutoScrollLogs: (value) => set({ autoScrollLogs: value }),
|
setAutoScrollLogs: (value) => set({ autoScrollLogs: value }),
|
||||||
setShowLogTimestamps: (value) => set({ showLogTimestamps: value }),
|
setShowLogTimestamps: (value) => set({ showLogTimestamps: value }),
|
||||||
|
|||||||
@@ -10,4 +10,8 @@ export default defineConfig({
|
|||||||
"~": path.resolve(__dirname, "src"),
|
"~": path.resolve(__dirname, "src"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
server: {
|
||||||
|
host: "local.kmux.cn",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user