refactor(console-web): centralize page lifecycle and UI composition

Declare navigation, page metadata, lazy loaders, and keep-alive policy in one registry. Retain primary iframe pages until a continuous inactive TTL expires and expose typed show, hide, and unload events through the Page SDK.

Move business-aware components out of the UI primitive directory, migrate views and Host dialogs to variant primitives and local Tailwind classes, and remove the global component style layer. Keep native dialogs inside the overlay flex context so they remain centered.

Cover iframe expiration, lifecycle dispatch, UI dependency direction, and global style ownership with focused tests.
This commit is contained in:
Maofeng
2026-07-30 15:16:02 +08:00
parent 23fa6137ff
commit 9ff3223c44
30 changed files with 1136 additions and 872 deletions
@@ -1,7 +1,10 @@
import { History, RefreshCw } from "lucide-solid";
import { createMemo, For, Show } from "solid-js";
import { EmptyState } from "../../components/ui/empty-state";
import { PageHeading } from "../../components/ui/page-heading";
import { createMemo, For, onCleanup, Show } from "solid-js";
import { EmptyState } from "../../components/feedback/empty-state";
import { Page } from "../../components/layout/page";
import { PageHeading } from "../../components/layout/page-heading";
import { IconButton } from "../../components/ui/button";
import { Card } from "../../components/ui/card";
import { eventsFrom } from "../../lib/view-state";
import type { PageProps } from "../types";
@@ -13,6 +16,11 @@ const toneClass = {
};
export default function ActivityPage(props: PageProps) {
// A retained Activity iframe may have been hidden for a while. Request a
// fresh snapshot whenever it becomes visible instead of waiting for the
// Host's next background poll.
onCleanup(props.lifecycle.onShow(() => props.command({ type: "refresh" })));
const events = createMemo(() => {
const query = props.state()?.query.trim().toLowerCase() ?? "";
return eventsFrom(props.state()).filter(
@@ -24,18 +32,22 @@ export default function ActivityPage(props: PageProps) {
});
return (
<main class="page">
<Page>
<PageHeading
eyebrow="EVENTS"
title="调用记录"
description="最近 256 条持久化生命周期与调用事件。"
actions={
<button class="icon-btn" title="刷新" onClick={() => props.command({ type: "refresh" })}>
<IconButton
aria-label="刷新"
title="刷新"
onClick={() => props.command({ type: "refresh" })}
>
<RefreshCw size={16} />
</button>
</IconButton>
}
/>
<section class="panel">
<Card as="section">
<Show
when={events().length > 0}
fallback={
@@ -55,7 +67,7 @@ export default function ActivityPage(props: PageProps) {
</For>
</div>
</Show>
</section>
</main>
</Card>
</Page>
);
}
@@ -1,8 +1,11 @@
import { Cpu, RefreshCw, Server, SquareTerminal } from "lucide-solid";
import { createMemo, For, Show } from "solid-js";
import { EmptyState } from "../../components/ui/empty-state";
import { PageHeading } from "../../components/ui/page-heading";
import { StatusBadge } from "../../components/ui/status-badge";
import { EmptyState } from "../../components/feedback/empty-state";
import { Page } from "../../components/layout/page";
import { PageHeading } from "../../components/layout/page-heading";
import { StatusBadge } from "../../components/services/status-badge";
import { Button, IconButton } from "../../components/ui/button";
import { Card } from "../../components/ui/card";
import { serviceKey } from "../../lib/model";
import { servicesFrom } from "../../lib/view-state";
import type { PageProps } from "../types";
@@ -14,29 +17,33 @@ export default function InstancesPage(props: PageProps) {
);
return (
<main class="page">
<Page>
<PageHeading
eyebrow="ACTORS"
title="运行实例"
description={`${running()} 个 Actor 保持独立 Store 与串行 mailbox。`}
actions={
<button class="icon-btn" title="刷新" onClick={() => props.command({ type: "refresh" })}>
<IconButton
aria-label="刷新"
title="刷新"
onClick={() => props.command({ type: "refresh" })}
>
<RefreshCw size={16} />
</button>
</IconButton>
}
/>
<Show
when={services().length > 0}
fallback={
<div class="panel">
<Card>
<EmptyState icon={Server} title="暂无实例" detail="先注册一个组件包。" />
</div>
</Card>
}
>
<section class="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
<For each={services()}>
{(service) => (
<article class="panel p-5">
<Card as="article" padding="medium">
<div class="flex items-start justify-between gap-3">
<div class="flex min-w-0 items-center gap-3">
<span class="flex size-10 shrink-0 items-center justify-center rounded-md bg-cyan-soft text-cyan-strong">
@@ -44,7 +51,9 @@ export default function InstancesPage(props: PageProps) {
</span>
<div class="min-w-0">
<strong class="block truncate">{service.id}</strong>
<span class="code mt-1 block truncate text-muted">{service.revision}</span>
<span class="mt-1 block truncate font-mono text-xs text-muted">
{service.revision}
</span>
</div>
</div>
<StatusBadge status={service.status} />
@@ -71,8 +80,8 @@ export default function InstancesPage(props: PageProps) {
</dl>
<div class="mt-5 flex justify-end gap-2">
<Show when={service.status === "running"}>
<button
class="btn btn-secondary h-9"
<Button
size="small"
onClick={() =>
props.command({
type: "open-invoke",
@@ -82,10 +91,10 @@ export default function InstancesPage(props: PageProps) {
>
<SquareTerminal size={15} />
</button>
</Button>
</Show>
<button
class="btn btn-secondary h-9"
<Button
size="small"
disabled={props.state()?.serviceAction !== null}
onClick={() =>
props.command({
@@ -96,13 +105,13 @@ export default function InstancesPage(props: PageProps) {
}
>
{service.status === "running" ? "停止" : "启动"}
</button>
</Button>
</div>
</article>
</Card>
)}
</For>
</section>
</Show>
</main>
</Page>
);
}
+12 -18
View File
@@ -1,21 +1,9 @@
import { AlertTriangle, LoaderCircle } from "lucide-solid";
import { lazy, Show, Suspense, type Component } from "solid-js";
import { lazy, Show, Suspense } from "solid-js";
import { render } from "solid-js/web";
import { isView, type View } from "../lib/model";
import { createFrameBridge } from "../primitives/create-frame-bridge";
import "../styles/app.css";
import type { PageProps } from "./types";
// Each view is a dynamic import so a freshly-created iframe evaluates only the
// page it owns. Destroying that iframe releases the chunk's runtime objects.
const pages: Record<View, Component<PageProps>> = {
overview: lazy(() => import("./overview")),
services: lazy(() => import("./services")),
instances: lazy(() => import("./instances")),
"wit-packages": lazy(() => import("./wit-packages")),
activity: lazy(() => import("./activity")),
settings: lazy(() => import("./settings")),
};
import { isView, pageDefinition } from "./registry";
function FramePage() {
const requested = new URLSearchParams(window.location.search).get("view");
@@ -36,17 +24,23 @@ function FramePage() {
>
{(activeView) => {
const bridge = createFrameBridge(activeView());
const Page = pages[activeView()];
document.title = `${activeView()} · Wasmeld`;
const definition = pageDefinition(activeView());
const Page = lazy(definition.load);
document.title = `${definition.label} · Wasmeld`;
return (
<Suspense
fallback={
<main class="flex min-h-dvh items-center justify-center text-muted">
<LoaderCircle class="spin" size={24} />
<LoaderCircle class="animate-spin" size={24} />
</main>
}
>
<Page state={bridge.state} active={bridge.active} command={bridge.command} />
<Page
state={bridge.state}
active={bridge.active}
lifecycle={bridge.lifecycle}
command={bridge.command}
/>
</Suspense>
);
}}
@@ -11,8 +11,11 @@ import {
Zap,
} from "lucide-solid";
import { createMemo, For, Show, type JSXElement } from "solid-js";
import { PageHeading } from "../../components/ui/page-heading";
import { ServiceTable } from "../../components/ui/service-table";
import { Page } from "../../components/layout/page";
import { PageHeading } from "../../components/layout/page-heading";
import { ServiceTable } from "../../components/services/service-table";
import { Button, IconButton } from "../../components/ui/button";
import { Card, CardHeader } from "../../components/ui/card";
import { eventsFrom, servicesFrom } from "../../lib/view-state";
import { formatDuration } from "../../lib/model";
import type { PageProps } from "../types";
@@ -29,7 +32,7 @@ export default function OverviewPage(props: PageProps) {
const errors = createMemo(() => services().reduce((total, service) => total + service.errors, 0));
return (
<main class="page">
<Page>
<Show when={props.state()} fallback={<OverviewSkeleton />}>
{(state) => (
<>
@@ -39,28 +42,28 @@ export default function OverviewPage(props: PageProps) {
description={`${services().length} 个已注册版本,${running()} 个 Actor 正在运行。`}
actions={
<>
<button
class="icon-btn"
type="button"
<IconButton
aria-label="刷新"
title="刷新"
onClick={() => props.command({ type: "refresh" })}
>
<RefreshCw size={16} />
</button>
<button
class="btn btn-primary"
type="button"
</IconButton>
<Button
variant="primary"
onClick={() => props.command({ type: "open-register" })}
>
<CloudUpload size={16} />
</button>
</Button>
</>
}
/>
<section class="metric-grid" aria-label="关键指标">
<section
class="mb-5 grid grid-cols-1 border-l border-t border-line sm:grid-cols-2 xl:grid-cols-4"
aria-label="关键指标"
>
<Metric
icon={<Package size={18} />}
tone="bg-cyan-soft text-cyan-strong"
@@ -91,7 +94,10 @@ export default function OverviewPage(props: PageProps) {
/>
</section>
<section class="panel mb-5 grid lg:grid-cols-[minmax(280px,1.4fr)_repeat(3,minmax(130px,1fr))]">
<Card
as="section"
class="mb-5 grid lg:grid-cols-[minmax(280px,1.4fr)_repeat(3,minmax(130px,1fr))]"
>
<div class="flex items-center gap-3 border-b border-line px-5 py-4 lg:border-b-0 lg:border-r">
<span class="flex size-10 items-center justify-center rounded-md bg-brand-soft text-brand">
<Cpu size={18} />
@@ -108,32 +114,33 @@ export default function OverviewPage(props: PageProps) {
<Show
when={state().snapshot?.runtime.status === "running"}
fallback={
<button
class="icon-btn"
<IconButton
aria-label="启动 Runtime"
title="启动 Runtime"
disabled={state().runtimeAction !== null}
onClick={() => props.command({ type: "runtime-action", action: "start" })}
>
<Play size={15} />
</button>
</IconButton>
}
>
<button
class="icon-btn"
<IconButton
aria-label="重启 Runtime"
title="重启 Runtime"
disabled={state().runtimeAction !== null}
onClick={() => props.command({ type: "runtime-action", action: "restart" })}
>
<RotateCcw size={15} />
</button>
<button
class="icon-btn text-coral-strong"
</IconButton>
<IconButton
tone="danger"
aria-label="停止 Runtime"
title="停止 Runtime"
disabled={state().runtimeAction !== null}
onClick={() => props.command({ type: "runtime-action", action: "stop" })}
>
<CircleStop size={15} />
</button>
</IconButton>
</Show>
</div>
</div>
@@ -156,32 +163,32 @@ export default function OverviewPage(props: PageProps) {
}
detail="当前 Runtime"
/>
</section>
</Card>
<div class="grid gap-5 xl:grid-cols-[minmax(0,1.7fr)_minmax(300px,0.8fr)]">
<section class="panel min-w-0">
<div class="panel-header">
<Card as="section" class="min-w-0">
<CardHeader>
<div>
<h2></h2>
<p> Actor </p>
</div>
<button
class="btn btn-secondary h-9"
<Button
size="small"
onClick={() => props.command({ type: "navigate", view: "services" })}
>
</button>
</div>
</Button>
</CardHeader>
<ServiceTable
services={services().slice(0, 5)}
state={state()}
command={props.command}
compact
/>
</section>
</Card>
<aside class="panel">
<div class="panel-header">
<Card as="aside">
<CardHeader>
<div>
<h2></h2>
<p></p>
@@ -190,7 +197,7 @@ export default function OverviewPage(props: PageProps) {
<span class="size-1.5 rounded-full bg-brand" />
LIVE
</span>
</div>
</CardHeader>
<div class="divide-y divide-line">
<For
each={events().slice(0, 6)}
@@ -207,12 +214,12 @@ export default function OverviewPage(props: PageProps) {
)}
</For>
</div>
</aside>
</Card>
</div>
</>
)}
</Show>
</main>
</Page>
);
}
@@ -224,13 +231,15 @@ function Metric(props: {
note: string;
}) {
return (
<article class="metric">
<div class={`metric-icon ${props.tone}`}>{props.icon}</div>
<div class="metric-copy">
<span>{props.label}</span>
<strong>{props.value}</strong>
<article class="flex min-h-24 items-center gap-3 border-b border-r border-line bg-white px-4 py-3">
<div class={`flex size-10 shrink-0 items-center justify-center rounded-md ${props.tone}`}>
{props.icon}
</div>
<small>{props.note}</small>
<div class="min-w-0 flex-1">
<span class="block text-xs text-muted">{props.label}</span>
<strong class="mt-1 block text-2xl font-bold">{props.value}</strong>
</div>
<small class="self-end whitespace-nowrap pb-1 text-[11px] text-muted">{props.note}</small>
</article>
);
}
@@ -248,8 +257,8 @@ function RuntimeStat(props: { label: string; value: string; detail: string }) {
function OverviewSkeleton() {
return (
<div class="space-y-5 py-5">
<div class="skeleton-line w-36" />
<div class="skeleton-line h-8 w-64" />
<div class="h-3 w-36 animate-pulse rounded-sm bg-[#dfe6e4]" />
<div class="h-8 w-64 animate-pulse rounded-sm bg-[#dfe6e4]" />
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
<For each={[1, 2, 3, 4]}>{() => <div class="h-24 animate-pulse bg-white" />}</For>
</div>
@@ -0,0 +1,106 @@
import {
FileCode2,
History,
LayoutDashboard,
Package,
Server,
Settings,
type LucideProps,
} from "lucide-solid";
import type { Component } from "solid-js";
import type { PageProps } from "./types";
type PageModule = {
default: Component<PageProps>;
};
type PageDefinition = {
id: string;
label: string;
icon: Component<LucideProps>;
/**
* Requests that Host retain this page's iframe after navigation.
*
* Primary navigation pages normally opt in; detail and short-lived workflow
* pages should opt out. This is still a preference rather than ownership of
* the iframe lifetime: Host expires documents that remain hidden beyond its
* inactivity TTL. A retained page must pause background work while
* `active()` is false and release all resources when disposed.
*/
keepAlive: boolean;
/**
* Loads only this page's code inside the iframe document.
*
* Keep this as a dynamic import. Turning it into a static import would make
* every iframe evaluate every page and weaken the document lifecycle
* boundary used to release page-owned memory.
*/
load: () => Promise<PageModule>;
};
/**
* The single source of truth for Wasmeld Console pages.
*
* Add a page here after creating `src/pages/<id>/index.tsx`. Host navigation,
* route validation, iframe titles, keep-alive policy and page module loading
* are all derived from this registry, so they cannot drift independently.
*/
export const PAGE_DEFINITIONS = [
{
id: "overview",
label: "运行概览",
icon: LayoutDashboard,
keepAlive: true,
load: () => import("./overview"),
},
{
id: "services",
label: "服务版本",
icon: Package,
keepAlive: true,
load: () => import("./services"),
},
{
id: "instances",
label: "运行实例",
icon: Server,
keepAlive: true,
load: () => import("./instances"),
},
{
id: "wit-packages",
label: "WIT 包",
icon: FileCode2,
keepAlive: true,
load: () => import("./wit-packages"),
},
{
id: "activity",
label: "调用记录",
icon: History,
keepAlive: true,
load: () => import("./activity"),
},
{
id: "settings",
label: "运行设置",
icon: Settings,
keepAlive: true,
load: () => import("./settings"),
},
] as const satisfies readonly PageDefinition[];
export type View = (typeof PAGE_DEFINITIONS)[number]["id"];
export const VIEWS: readonly View[] = PAGE_DEFINITIONS.map((page) => page.id);
const PAGE_BY_ID = new Map<View, PageDefinition>(PAGE_DEFINITIONS.map((page) => [page.id, page]));
export function isView(value: string | null): value is View {
return value !== null && PAGE_BY_ID.has(value as View);
}
export function pageDefinition(view: View): PageDefinition {
// `View` is derived from PAGE_DEFINITIONS, so every valid value has an entry.
return PAGE_BY_ID.get(view)!;
}
@@ -1,7 +1,11 @@
import { CloudUpload, Filter, RefreshCw } from "lucide-solid";
import { createMemo, createSignal, Show } from "solid-js";
import { PageHeading } from "../../components/ui/page-heading";
import { ServiceTable } from "../../components/ui/service-table";
import { Page } from "../../components/layout/page";
import { PageHeading } from "../../components/layout/page-heading";
import { ServiceTable } from "../../components/services/service-table";
import { Button, IconButton } from "../../components/ui/button";
import { Card, CardHeader } from "../../components/ui/card";
import { Select } from "../../components/ui/select";
import { servicesFrom } from "../../lib/view-state";
import type { ServiceStatus } from "../../lib/model";
import type { PageProps } from "../types";
@@ -22,7 +26,7 @@ export default function ServicesPage(props: PageProps) {
});
return (
<main class="page">
<Page>
<PageHeading
eyebrow="COMPONENTS"
title="服务版本"
@@ -34,8 +38,8 @@ export default function ServicesPage(props: PageProps) {
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted"
size={14}
/>
<select
class="select w-36 pl-9"
<Select
class="w-36 pl-9"
value={status()}
onChange={(event) => setStatus(event.currentTarget.value as "all" | ServiceStatus)}
>
@@ -43,34 +47,35 @@ export default function ServicesPage(props: PageProps) {
<option value="running"></option>
<option value="stopped"></option>
<option value="faulted"></option>
</select>
</Select>
</label>
<button
class="btn btn-primary"
onClick={() => props.command({ type: "open-register" })}
>
<Button variant="primary" onClick={() => props.command({ type: "open-register" })}>
<CloudUpload size={16} />
</button>
</Button>
</>
}
/>
<section class="panel">
<div class="panel-header">
<Card as="section">
<CardHeader>
<div>
<h2></h2>
<p>{filtered().length} </p>
</div>
<button class="icon-btn" title="刷新" onClick={() => props.command({ type: "refresh" })}>
<IconButton
aria-label="刷新"
title="刷新"
onClick={() => props.command({ type: "refresh" })}
>
<RefreshCw size={16} />
</button>
</div>
</IconButton>
</CardHeader>
<Show when={props.state()}>
{(state) => (
<ServiceTable services={filtered()} state={state()} command={props.command} />
)}
</Show>
</section>
</main>
</Card>
</Page>
);
}
@@ -1,7 +1,12 @@
import { Database, Globe2, Save, ShieldCheck } from "lucide-solid";
import { createEffect, createSignal } from "solid-js";
import { displayApiBase } from "../../lib/api";
import { PageHeading } from "../../components/ui/page-heading";
import { Page } from "../../components/layout/page";
import { PageHeading } from "../../components/layout/page-heading";
import { Button } from "../../components/ui/button";
import { Card, CardHeader } from "../../components/ui/card";
import { Field, FieldLabel } from "../../components/ui/field";
import { Input } from "../../components/ui/input";
import type { PageProps } from "../types";
export default function SettingsPage(props: PageProps) {
@@ -16,21 +21,21 @@ export default function SettingsPage(props: PageProps) {
});
return (
<main class="page">
<Page>
<PageHeading
eyebrow="CONFIGURATION"
title="运行设置"
description="配置管理 API 连接;运行边界由后端策略统一控制。"
/>
<div class="grid gap-5 xl:grid-cols-[minmax(0,1.4fr)_minmax(280px,0.7fr)]">
<section class="panel">
<div class="panel-header">
<Card as="section">
<CardHeader>
<div>
<h2> API</h2>
<p>使</p>
</div>
<Globe2 size={18} class="text-cyan-strong" />
</div>
</CardHeader>
<form
class="p-5"
onSubmit={(event) => {
@@ -38,27 +43,26 @@ export default function SettingsPage(props: PageProps) {
props.command({ type: "save-api-base", value: endpoint() });
}}
>
<label class="field">
<span>Endpoint</span>
<input
class="input"
<Field>
<FieldLabel>Endpoint</FieldLabel>
<Input
type="url"
value={endpoint()}
placeholder={window.location.origin}
onInput={(event) => setEndpoint(event.currentTarget.value)}
/>
</label>
</Field>
<p class="mt-2 text-xs text-muted">
{displayApiBase(props.state()?.apiBase ?? "")}
</p>
<div class="mt-5 flex justify-end">
<button class="btn btn-primary" type="submit">
<Button variant="primary" type="submit">
<Save size={15} />
</button>
</Button>
</div>
</form>
</section>
</Card>
<aside class="space-y-3">
<Info
icon={ShieldCheck}
@@ -68,14 +72,14 @@ export default function SettingsPage(props: PageProps) {
<Info icon={Database} title="持久化" detail="控制状态和服务 KV 存储于本地 libSQL。" />
</aside>
</div>
</main>
</Page>
);
}
function Info(props: { icon: typeof ShieldCheck; title: string; detail: string }) {
const Icon = props.icon;
return (
<article class="panel flex gap-3 p-4">
<Card as="article" padding="small" class="flex gap-3">
<span class="flex size-9 shrink-0 items-center justify-center rounded-md bg-brand-soft text-brand">
<Icon size={16} />
</span>
@@ -83,6 +87,6 @@ function Info(props: { icon: typeof ShieldCheck; title: string; detail: string }
<strong class="text-sm">{props.title}</strong>
<p class="mt-1 text-xs leading-5 text-muted">{props.detail}</p>
</div>
</article>
</Card>
);
}
@@ -1,4 +1,5 @@
import type { Accessor } from "solid-js";
import type { PageLifecycle } from "../sdk/lifecycle";
import type { HostSharedState, PageCommand } from "../sdk/protocol";
export type PageProps = {
@@ -8,5 +9,12 @@ export type PageProps = {
* render loops should pause until this accessor becomes true again.
*/
active: Accessor<boolean>;
/**
* Transition events for imperative Page work.
*
* Use `onShow` to refresh stale data, `onHide` to pause background work, and
* `onUnload` for final cleanup. Subscriptions return an unsubscribe function.
*/
lifecycle: PageLifecycle;
command: (command: PageCommand) => void;
};
@@ -1,7 +1,18 @@
import { CloudUpload, Download, FileCode2, Search } from "lucide-solid";
import { createMemo, For, Show } from "solid-js";
import { EmptyState } from "../../components/ui/empty-state";
import { PageHeading } from "../../components/ui/page-heading";
import { EmptyState } from "../../components/feedback/empty-state";
import { Page } from "../../components/layout/page";
import { PageHeading } from "../../components/layout/page-heading";
import { Button, IconLink } from "../../components/ui/button";
import { Card, CardHeader } from "../../components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table";
import { witPackageDownloadUrl } from "../../lib/api";
import type { PageProps } from "../types";
@@ -21,85 +32,83 @@ export default function WitPackagesPage(props: PageProps) {
});
return (
<main class="page">
<Page>
<PageHeading
eyebrow="INTERFACES"
title="WIT 包"
description="管理不可变、版本化的 Component 接口依赖。"
actions={
<button
class="btn btn-primary"
onClick={() => props.command({ type: "open-wit-publish" })}
>
<Button variant="primary" onClick={() => props.command({ type: "open-wit-publish" })}>
<CloudUpload size={16} />
WIT
</button>
</Button>
}
/>
<section class="panel">
<div class="panel-header">
<Card as="section">
<CardHeader>
<div>
<h2>Registry</h2>
<p>{packages().length} </p>
</div>
</div>
</CardHeader>
<Show
when={packages().length > 0}
fallback={
<EmptyState icon={Search} title="没有匹配的 WIT 包" detail="发布或调整搜索关键词。" />
}
>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th>SHA-256</th>
<th>
<span class="sr-only"></span>
</th>
</tr>
</thead>
<tbody>
<For each={packages()}>
{(item) => (
<tr>
<td>
<span class="flex items-center gap-2">
<FileCode2 size={15} class="text-cyan-strong" />
<strong>{item.name}</strong>
</span>
</td>
<td class="code">{item.version}</td>
<td class="text-xs text-muted">
{item.dependencies.length
? item.dependencies
.map((dependency) => `${dependency.name}@${dependency.version}`)
.join(", ")
: "无"}
</td>
<td class="code max-w-64 truncate text-muted" title={item.sha256}>
{item.sha256}
</td>
<td class="text-right">
<a
class="icon-btn"
title="下载"
href={witPackageDownloadUrl(props.state()?.apiBase ?? "", item)}
>
<Download size={15} />
</a>
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>SHA-256</TableHead>
<TableHead>
<span class="sr-only"></span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<For each={packages()}>
{(item) => (
<TableRow>
<TableCell>
<span class="flex items-center gap-2">
<FileCode2 size={15} class="text-cyan-strong" />
<strong>{item.name}</strong>
</span>
</TableCell>
<TableCell class="font-mono text-xs">{item.version}</TableCell>
<TableCell class="text-xs text-muted">
{item.dependencies.length
? item.dependencies
.map((dependency) => `${dependency.name}@${dependency.version}`)
.join(", ")
: "无"}
</TableCell>
<TableCell
class="max-w-64 truncate font-mono text-xs text-muted"
title={item.sha256}
>
{item.sha256}
</TableCell>
<TableCell class="text-right">
<IconLink
aria-label={`下载 ${item.name}@${item.version}`}
title="下载"
href={witPackageDownloadUrl(props.state()?.apiBase ?? "", item)}
>
<Download size={15} />
</IconLink>
</TableCell>
</TableRow>
)}
</For>
</TableBody>
</Table>
</Show>
</section>
</main>
</Card>
</Page>
);
}