feat(web): app shell + mounts admin page

This commit is contained in:
noelorin 2026-09-25 03:29:56 +08:00
parent 2c6bc8818d
commit c2692a0ceb
4 changed files with 240 additions and 0 deletions

View File

@ -0,0 +1,44 @@
import type { ReactNode } from "react";
import { cn } from "~/lib/utils";
const NAV = [
{ hrefKey: "nav.library", href: "library", id: "library" },
{ hrefKey: "nav.browse", href: "browse", id: "browse" },
{ hrefKey: "nav.mounts", href: "mounts", id: "mounts" },
{ hrefKey: "nav.danmaku", href: "danmaku", id: "danmaku" },
] as const;
export function AppShell(props: {
locale: string;
active: (typeof NAV)[number]["id"];
labels: Record<string, string>;
children: ReactNode;
}) {
const base = `/${props.locale}`;
return (
<div className="flex min-h-screen bg-background text-foreground">
<aside className="hidden w-56 shrink-0 border-r border-border bg-muted/40 md:flex md:flex-col md:p-4">
<div className="mb-6 px-2 text-sm font-semibold tracking-wide">
{props.labels["meta.brand"] ?? "弹弹 Web"}
</div>
<nav className="flex flex-col gap-1" aria-label="sidebar">
{NAV.map((item) => (
<a
key={item.id}
href={`${base}/${item.href}`}
className={cn(
"rounded-md px-3 py-2 text-sm transition-colors",
props.active === item.id
? "bg-background font-medium shadow-sm"
: "text-muted-foreground hover:bg-background/60",
)}
>
{props.labels[item.hrefKey] ?? item.href}
</a>
))}
</nav>
</aside>
<main className="flex min-w-0 flex-1 flex-col p-4 md:p-6">{props.children}</main>
</div>
);
}

View File

@ -0,0 +1,138 @@
import { Button, Input } from "@app/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { createTRPCReactClient, trpc } from "~/lib/trpc";
function MountsInner() {
const utils = trpc.useUtils();
const list = trpc.media.mountList.useQuery();
const create = trpc.media.mountCreate.useMutation({
onSuccess: () => utils.media.mountList.invalidate(),
});
const test = trpc.media.mountTest.useMutation();
const del = trpc.media.mountDelete.useMutation({
onSuccess: () => utils.media.mountList.invalidate(),
});
const [form, setForm] = useState({
name: "",
baseUrl: "",
username: "",
password: "",
rootPath: "/",
});
return (
<div className="space-y-6">
<h1 className="text-xl font-semibold">挂载管理</h1>
<p className="text-sm text-muted-foreground">
OpenList 请填写其 WebDAV 地址;凭据仅保存在服务端。
</p>
<form
className="grid gap-3 rounded-lg border border-border bg-card p-4 md:grid-cols-2"
onSubmit={(e) => {
e.preventDefault();
create.mutate({
name: form.name,
type: "webdav",
baseUrl: form.baseUrl,
username: form.username || undefined,
password: form.password || undefined,
rootPath: form.rootPath || "/",
enabled: true,
});
}}
>
<Input
placeholder="名称,如 OpenList"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
/>
<Input
placeholder="https://example.com/dav"
value={form.baseUrl}
onChange={(e) => setForm({ ...form, baseUrl: e.target.value })}
required
/>
<Input
placeholder="用户名(可选)"
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
/>
<Input
type="password"
placeholder="密码 / 令牌"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
/>
<Input
placeholder="根路径 /"
value={form.rootPath}
onChange={(e) => setForm({ ...form, rootPath: e.target.value })}
/>
<div className="flex gap-2">
<Button type="submit" disabled={create.isPending}>
保存
</Button>
<Button
type="button"
variant="outline"
onClick={() =>
test.mutate({
baseUrl: form.baseUrl,
username: form.username || undefined,
password: form.password || undefined,
rootPath: form.rootPath || "/",
})
}
>
测试连接
</Button>
</div>
{test.data && (
<p className={test.data.ok ? "text-green-600" : "text-red-600"}>
{test.data.message}
</p>
)}
</form>
<ul className="divide-y divide-border rounded-lg border border-border">
{(list.data?.mounts ?? []).map((m) => (
<li key={m.id} className="flex items-center justify-between gap-3 p-3">
<div>
<div className="font-medium">{m.name}</div>
<div className="text-xs text-muted-foreground break-all">
{m.baseUrl}
</div>
</div>
<div className="flex gap-2">
<Button
variant="destructive"
size="sm"
onClick={() => del.mutate({ id: m.id })}
>
删除
</Button>
</div>
</li>
))}
{list.data?.mounts.length === 0 && (
<li className="p-4 text-sm text-muted-foreground">尚未添加挂载</li>
)}
</ul>
</div>
);
}
const client = createTRPCReactClient();
const qc = new QueryClient();
export function MountsBody() {
return (
<QueryClientProvider client={qc}>
<trpc.Provider client={client} queryClient={qc}>
<MountsInner />
</trpc.Provider>
</QueryClientProvider>
);
}

View File

@ -0,0 +1,19 @@
---
import { COOKIE_LANG, isLocale, localePath, resolvePreferredLocale, type Locale } from "@app/i18n";
import Mounts from "~/views/mounts.astro";
const raw = Astro.params["locale"];
const normalized = raw === "zh-cn" ? "zh-CN" : raw;
if (!normalized || !isLocale(normalized)) {
const preferred = resolvePreferredLocale(
Astro.cookies.get(COOKIE_LANG)?.value,
Astro.request.headers.get("accept-language"),
);
return Astro.redirect(localePath(preferred, "/mounts"));
}
const locale: Locale = normalized;
---
<Mounts locale={locale} />

View File

@ -0,0 +1,39 @@
---
import { alternatePath, type Locale, t } from "@app/i18n";
import { Toaster } from "@app/ui";
import { AppShell } from "~/components/AppShell";
import { MountsBody } from "~/components/pages/MountsBody";
import AppLayout from "~/layouts/AppLayout.astro";
export interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
const labels: Record<string, string> = {
"meta.brand": t(locale, "meta.brand"),
"nav.library": t(locale, "nav.library"),
"nav.browse": t(locale, "nav.browse"),
"nav.mounts": t(locale, "nav.mounts"),
"nav.danmaku": t(locale, "nav.danmaku"),
};
---
<AppLayout
title={t(locale, "mounts.title")}
locale={locale}
nav={[]}
switchHref={alternatePath(Astro.url.pathname, other)}
switchLabel={t(other, `locale.${other}` as never)}
seo={{
title: `${t(locale, "mounts.title")} · ${t(locale, "meta.brand")}`,
path: Astro.url.pathname,
robots: ["noindex", "nofollow"],
}}
>
<AppShell locale={locale} active="mounts" labels={labels}>
<MountsBody client:load />
</AppShell>
<Toaster client:load />
</AppLayout>