Files
wk-frontend/src/App.tsx
zhilv a182c64f82 🔖 release(v0.1.4): bump version and UI optimizations
- Remove unused version display logic and update summary
- Add silent audio playback to prevent browser tab throttling
- Update CourseWorkspace and Setting components
- Bump version from 0.1.3 to 0.1.4

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-26 20:45:39 +08:00

589 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;