Files
wk-frontend/src/App.tsx
zhilv 58555c5043 feat(release): bump version to 0.1.2
## 详细信息
- 升级项目版本号到 0.1.2
- 增强刷课稳定性(失败重试、心跳检测、状态自动纠正)
- 优化账号页与工作台紧凑布局,增加已学/未学区分与记录筛选
- 新增首次进入更新检查、更新日志弹窗与在线下载/回退 Release 跳转
2026-04-02 22:33:04 +08:00

577 lines
20 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,
onCleanup,
} from "solid-js";
import { A, useLocation } from "@solidjs/router";
import Dialog from "~/components/dialog/Dialog";
import {
RELEASES_PAGE_URL,
type LatestRelease,
type ReleaseAsset,
type RuntimeTarget,
detectRuntimeTarget,
downloadReleaseAsset,
fetchLatestRelease,
isRemoteVersionNewer,
resolveAssetForRuntime,
resolveReleaseLink,
} from "~/service/update";
import { versionApi } from "~/service/wk";
const asideList = [
{ label: "账号", url: "/account" },
{ label: "日志", url: "/logs" },
{ label: "设置", url: "/setting" },
];
type DownloadState = "idle" | "downloading" | "done" | "error";
type UpdateCheckState =
| "idle"
| "checking"
| "latest"
| "available"
| "error";
type MarkdownBlock =
| { type: "heading"; level: 1 | 2 | 3 | 4 | 5 | 6; text: string }
| { type: "paragraph"; text: string }
| { type: "list"; items: string[] };
const normalizeMarkdown = (value: string) => value.replace(/\r\n?/g, "\n");
const parseMarkdownBlocks = (markdown: string): MarkdownBlock[] => {
const lines = normalizeMarkdown(markdown).split("\n");
const blocks: MarkdownBlock[] = [];
let index = 0;
while (index < lines.length) {
const line = lines[index].trim();
if (!line) {
index += 1;
continue;
}
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
if (headingMatch) {
const level = Math.min(
6,
Math.max(1, headingMatch[1].length),
) as 1 | 2 | 3 | 4 | 5 | 6;
blocks.push({
type: "heading",
level,
text: headingMatch[2].trim(),
});
index += 1;
continue;
}
if (line.startsWith("- ")) {
const items: string[] = [];
while (index < lines.length) {
const nextLine = lines[index].trim();
if (!nextLine.startsWith("- ")) {
break;
}
items.push(nextLine.slice(2).trim());
index += 1;
}
blocks.push({ type: "list", items });
continue;
}
const paragraphLines: string[] = [line];
index += 1;
while (index < lines.length) {
const nextLine = lines[index].trim();
if (!nextLine || nextLine.startsWith("- ") || /^#{1,6}\s+/.test(nextLine)) {
break;
}
paragraphLines.push(nextLine);
index += 1;
}
blocks.push({
type: "paragraph",
text: paragraphLines.join(" ").trim(),
});
}
return blocks;
};
const renderInlineLinks = (text: string): JSX.Element[] => {
const parts: JSX.Element[] = [];
const regex = /\[([^\]]+)\]\(([^)]+)\)/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
const label = match[1] || "链接";
const href = resolveReleaseLink(match[2] || "");
parts.push(
<a
href={href}
target="_blank"
rel="noopener noreferrer"
class="text-cyan-700 underline decoration-cyan-300 underline-offset-2 hover:text-cyan-800"
>
{label}
</a>,
);
lastIndex = regex.lastIndex;
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts.length > 0 ? parts : [text];
};
const App: ParentComponent = (props) => {
const location = useLocation();
const [version] = createResource(versionApi);
const [copyState, setCopyState] = createSignal<"idle" | "done" | "error">(
"idle",
);
const [updateDialogOpen, setUpdateDialogOpen] = createSignal(false);
const [updateCheckState, setUpdateCheckState] =
createSignal<UpdateCheckState>("idle");
const [hasAutoCheckedUpdate, setHasAutoCheckedUpdate] = createSignal(false);
const [latestRelease, setLatestRelease] = createSignal<LatestRelease | null>(
null,
);
const [runtimeTarget, setRuntimeTarget] = createSignal<RuntimeTarget>({
os: "unknown",
arch: "unknown",
});
const [matchedAsset, setMatchedAsset] = createSignal<ReleaseAsset | null>(
null,
);
const [updateCheckError, setUpdateCheckError] = createSignal("");
const [downloadState, setDownloadState] = createSignal<DownloadState>("idle");
const [downloadProgress, setDownloadProgress] = createSignal(0);
const [downloadError, setDownloadError] = createSignal("");
let updateAbortController: AbortController | null = null;
const isActive = (url: string) =>
location.pathname === url ||
(location.pathname === "/" && url === "/account");
const versionText = createMemo(() => version()?.data.Version ?? "unknown");
const commitText = createMemo(() => {
const commit = version()?.data.GitCommit ?? "unknown";
return commit === "unknown" ? commit : commit.slice(0, 7);
});
const buildText = createMemo(() => version()?.data.BuildAt ?? "unknown");
const authorText = createMemo(() => version()?.data.GitAuthor ?? "unknown");
const emailText = createMemo(() => version()?.data.GitEmail ?? "unknown");
const versionErrorText = createMemo(() => {
const error = version.error;
if (!error) {
return "";
}
return error instanceof Error ? error.message : "版本信息获取失败";
});
const versionPayloadText = createMemo(() =>
[
`Version: ${versionText()}`,
`Commit: ${commitText()}`,
`Build: ${buildText()}`,
`Author: ${authorText()}`,
`Email: ${emailText()}`,
].join("\n"),
);
const updateSummaryText = createMemo(() => {
if (updateCheckState() === "checking") {
return "更新检查中...";
}
if (updateCheckState() === "available") {
return `发现新版本:${latestRelease()?.tag_name ?? "-"}`;
}
if (updateCheckState() === "latest") {
return "已是最新版本";
}
if (updateCheckState() === "error") {
return updateCheckError() || "更新检查失败";
}
return "未检查更新";
});
const releaseNotesBlocks = createMemo(() =>
parseMarkdownBlocks(latestRelease()?.body ?? ""),
);
const runtimeTargetText = createMemo(
() => `${runtimeTarget().os} / ${runtimeTarget().arch}`,
);
const releaseLink = createMemo(
() => latestRelease()?.html_url || RELEASES_PAGE_URL,
);
const handleCopyVersion = async () => {
try {
await navigator.clipboard.writeText(versionPayloadText());
setCopyState("done");
} catch {
setCopyState("error");
}
window.setTimeout(() => setCopyState("idle"), 1800);
};
const performUpdateCheck = async (manual = false) => {
if (updateCheckState() === "checking") {
return;
}
setUpdateCheckState("checking");
setUpdateCheckError("");
setDownloadState("idle");
setDownloadProgress(0);
setDownloadError("");
if (updateAbortController) {
updateAbortController.abort();
}
updateAbortController = new AbortController();
try {
const [release, target] = await Promise.all([
fetchLatestRelease(updateAbortController.signal),
detectRuntimeTarget(),
]);
setRuntimeTarget(target);
const hasNewVersion = isRemoteVersionNewer(versionText(), release.tag_name);
if (!hasNewVersion) {
setUpdateCheckState("latest");
if (manual) {
setLatestRelease(release);
setMatchedAsset(resolveAssetForRuntime(release.assets, target));
}
return;
}
setLatestRelease(release);
setMatchedAsset(resolveAssetForRuntime(release.assets, target));
setUpdateCheckState("available");
setUpdateDialogOpen(true);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
return;
}
const message = error instanceof Error ? error.message : "更新检查失败";
setUpdateCheckError(message);
setUpdateCheckState("error");
}
};
const openReleasePage = () => {
window.open(releaseLink(), "_blank", "noopener,noreferrer");
};
const handleDownloadUpdate = async () => {
const asset = matchedAsset();
if (!asset) {
openReleasePage();
return;
}
setDownloadState("downloading");
setDownloadProgress(0);
setDownloadError("");
try {
await downloadReleaseAsset(asset, (progress) => {
setDownloadProgress(progress);
});
setDownloadState("done");
} catch (error) {
const message = error instanceof Error ? error.message : "下载失败";
setDownloadError(message);
setDownloadState("error");
openReleasePage();
}
};
createEffect(() => {
version.loading;
if (hasAutoCheckedUpdate() || version.loading) {
return;
}
setHasAutoCheckedUpdate(true);
void performUpdateCheck(false);
});
onCleanup(() => {
if (updateAbortController) {
updateAbortController.abort();
updateAbortController = null;
}
});
return (
<>
<div class="flex h-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 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()}
>
{copyState() === "done"
? "已复制"
: copyState() === "error"
? "复制失败"
: "复制版本信息"}
</button>
<button
type="button"
class="rounded-lg border border-cyan-200 bg-cyan-50 px-3 py-1.5 text-xs text-cyan-700 transition hover:bg-cyan-100 disabled:cursor-not-allowed disabled:opacity-60"
disabled={updateCheckState() === "checking"}
onClick={() => void performUpdateCheck(true)}
>
{updateCheckState() === "checking" ? "检查中..." : "检查更新"}
</button>
</div>
{versionErrorText() ? (
<p class="mt-2 text-xs text-rose-500">{versionErrorText()}</p>
) : null}
</div>
</aside>
<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={`发现更新 ${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>
</>
);
};
export default App;