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