fix(panel): refine login and lobby settings layout

This commit is contained in:
Lemon-miaow committed 2026-10-04 12:56:46 +08:00
1 parent 10bd2ddad0
commit 77c1e7404f
4 files changed
+122 -52

No files matched your search

+8 -1
View File
@@ -81,5 +81,12 @@
"builder_running": "Start the lobby before changing builder access.", "builder_running": "Start the lobby before changing builder access.",
"discard_title": "Discard unsaved settings?", "discard_title": "Discard unsaved settings?",
"discard_hint": "Switching spaces discards your edits. Cancel to save them first.", "discard_hint": "Switching spaces discards your edits. Cancel to save them first.",
"discard": "Discard & switch" "discard": "Discard & switch",
"appearance_description": "Set the welcome message and server menu players see.",
"players_description": "Choose join behavior, game mode and player protection.",
"world_description": "Adjust time, weather and the lobby game rules.",
"loginBook_description": "Edit the guidance players see in the login book.",
"content_tools": "Content & maintenance",
"unsaved": "You have unsaved changes",
"apply_on_start": "Saved settings apply when this space starts"
} }
+8 -1
View File
@@ -81,5 +81,12 @@
"builder_running": "请先启动大厅,才能修改建造权限。", "builder_running": "请先启动大厅,才能修改建造权限。",
"discard_title": "放弃未保存的设置?", "discard_title": "放弃未保存的设置?",
"discard_hint": "切换空间会丢失本次编辑。可以取消后先保存。", "discard_hint": "切换空间会丢失本次编辑。可以取消后先保存。",
"discard": "放弃并切换" "discard": "放弃并切换",
"appearance_description": "设置玩家加入时看到的欢迎信息与菜单。",
"players_description": "设置加入行为、游戏模式与玩家保护。",
"world_description": "调整大厅的时间、天气和游戏规则。",
"loginBook_description": "编辑玩家打开登录书时看到的引导内容。",
"content_tools": "内容与维护",
"unsaved": "有未保存的更改",
"apply_on_start": "保存的设置将在启动后生效"
} }
+1 -1
View File
@@ -50,10 +50,10 @@ export const LOGIN_GROUPS: ExperienceGroup[] = [
{ key: "bookTitle", value: "Felis Login" }, { key: "bookTitle", value: "Felis Login" },
{ key: "bookAuthor", value: "Felis" }, { key: "bookAuthor", value: "Felis" },
{ key: "bookHeading", value: "Felis 登录 / Login" }, { key: "bookHeading", value: "Felis 登录 / Login" },
{ key: "loginTimeoutSeconds", value: 600, min: 30, max: 3600 },
{ key: "bookAction", value: "▶ 点此打开登录页\n▶ Open login page", multiline: true }, { key: "bookAction", value: "▶ 点此打开登录页\n▶ Open login page", multiline: true },
{ key: "bookHelp", value: "在系统浏览器中完成。\nUse your SYSTEM browser —\nnot WeChat / QQ (passkey\nwon't work there).", multiline: true }, { key: "bookHelp", value: "在系统浏览器中完成。\nUse your SYSTEM browser —\nnot WeChat / QQ (passkey\nwon't work there).", multiline: true },
{ key: "openBook", value: true }, { key: "openBook", value: true },
{ key: "loginTimeoutSeconds", value: 600, min: 30, max: 3600 },
] }, ] },
]; ];
+105 -49
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useSearchParams } from "react-router-dom"; import { Link, useSearchParams } from "react-router-dom";
import { DoorOpen, FolderOpen, Map, Package, Save, Shield, Terminal } from "lucide-react"; import { BookOpen, ChevronRight, DoorOpen, FolderOpen, Globe, Map, MessageSquare, Package, Save, Shield, Sun, Terminal, type LucideIcon } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ConfirmDialog } from "@/components/ConfirmDialog"; import { ConfirmDialog } from "@/components/ConfirmDialog";
import { PageHeader } from "@/components/PageHeader"; import { PageHeader } from "@/components/PageHeader";
@@ -16,9 +16,17 @@ import { ErrorState, Loading } from "@/components/States";
import { InlineError, MessageLine } from "@/components/MessageLine"; import { InlineError, MessageLine } from "@/components/MessageLine";
import { api, humanizeError } from "@/lib/api"; import { api, humanizeError } from "@/lib/api";
import { joinAddress } from "@/lib/config"; import { joinAddress } from "@/lib/config";
import { cn } from "@/lib/utils";
import { STATUS_POLL_FAST_MS, useAsync, useConfig, usePolling, useUnsavedGuard } from "@/lib/hooks"; import { STATUS_POLL_FAST_MS, useAsync, useConfig, usePolling, useUnsavedGuard } from "@/lib/hooks";
import { experienceValid, LOGIN_GROUPS, LOBBY_GROUPS, readExperience, writeExperience, type Experience } from "@/lib/experience"; import { experienceValid, LOGIN_GROUPS, LOBBY_GROUPS, readExperience, writeExperience, type Experience } from "@/lib/experience";
const GROUP_ICONS: Record<string, LucideIcon> = {
appearance: MessageSquare,
players: Shield,
world: Sun,
loginBook: BookOpen,
};
function ExperienceSettings({ name, stopped, onDirtyChange }: { name: string; stopped: boolean; onDirtyChange: (dirty: boolean) => void }) { function ExperienceSettings({ name, stopped, onDirtyChange }: { name: string; stopped: boolean; onDirtyChange: (dirty: boolean) => void }) {
const { t } = useTranslation("lobby"); const { t } = useTranslation("lobby");
const groups = name === "login" ? LOGIN_GROUPS : LOBBY_GROUPS; const groups = name === "login" ? LOGIN_GROUPS : LOBBY_GROUPS;
@@ -55,43 +63,66 @@ function ExperienceSettings({ name, stopped, onDirtyChange }: { name: string; st
{!stopped && <p className="rounded-md border border-amber-500/30 bg-amber-500/10 p-4 text-sm">{t(name === "login" ? "stop_login" : "stop_lobby")}</p>} {!stopped && <p className="rounded-md border border-amber-500/30 bg-amber-500/10 p-4 text-sm">{t(name === "login" ? "stop_login" : "stop_lobby")}</p>}
{stopped && query.loading && !draft && <Loading />} {stopped && query.loading && !draft && <Loading />}
{query.error != null && <ErrorState error={t("read_failed", { reason: humanizeError(query.error) })} onRetry={query.reload} />} {query.error != null && <ErrorState error={t("read_failed", { reason: humanizeError(query.error) })} onRetry={query.reload} />}
{draft && groups.map((group) => ( {draft && groups.map((group) => {
<Card key={group.key}> const Icon = GROUP_ICONS[group.key];
<CardHeader><CardTitle>{t(group.key)}</CardTitle></CardHeader> const fields = group.fields.filter((field) => typeof field.value !== "boolean");
<CardContent className="grid gap-4 sm:grid-cols-2"> const switches = group.fields.filter((field) => typeof field.value === "boolean");
{group.fields.map((field) => { return (
const value = draft.values[field.key] ?? field.value; <Card key={group.key} className="overflow-hidden rounded-xl shadow-none">
const id = `experience-${field.key}`; <CardHeader className="flex-row items-center gap-3 space-y-0 border-b border-border/60 bg-muted/20 py-4">
const set = (next: unknown) => setDraft({ ...draft, values: { ...draft.values, [field.key]: next } }); <span className="rounded-lg bg-primary/10 p-2 text-primary"><Icon className="h-4 w-4" /></span>
if (typeof field.value === "boolean") return ( <div className="space-y-1.5">
<label key={id} className="flex min-h-11 items-center gap-3 rounded-md border border-border px-3 text-sm" htmlFor={id}> <CardTitle className="text-sm">{t(group.key)}</CardTitle>
<input id={id} type="checkbox" checked={value as boolean} onChange={(e) => set(e.target.checked)} disabled={!stopped || saving} className="h-4 w-4 accent-primary" /> <p className="text-xs text-muted-foreground">{t(`${group.key}_description`)}</p>
{t(field.key)} </div>
</label> </CardHeader>
); <CardContent className="space-y-5 pt-5">
return ( <div className="grid items-start gap-x-5 gap-y-4 sm:grid-cols-2">
<div key={id} className="grid min-w-0 gap-2"> {fields.map((field) => {
<Label htmlFor={id}>{t(field.key)}</Label> const value = draft.values[field.key] ?? field.value;
{field.choices ? ( const id = `experience-${field.key}`;
<select id={id} value={value as string} onChange={(e) => set(e.target.value)} disabled={!stopped || saving} className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm"> const set = (next: unknown) => setDraft({ ...draft, values: { ...draft.values, [field.key]: next } });
{field.choices.map((choice) => <option key={choice} value={choice}>{t(choice)}</option>)} return (
</select> <div key={id} className="min-w-0 space-y-2">
) : field.multiline ? ( <Label htmlFor={id} className="block text-xs font-medium text-muted-foreground">{t(field.key)}</Label>
<textarea id={id} value={value as string} maxLength={512} onChange={(e) => set(e.target.value)} disabled={!stopped || saving} className="min-h-24 w-full rounded-md border border-input bg-background p-3 text-sm" /> {field.choices ? (
) : ( <select id={id} value={value as string} onChange={(e) => set(e.target.value)} disabled={!stopped || saving} className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50">
<Input id={id} type={typeof field.value === "number" ? "number" : "text"} value={value as string | number} min={field.min} max={field.max} step={1} maxLength={512} onChange={(e) => set(typeof field.value === "number" ? (e.target.value === "" ? "" : Number(e.target.value)) : e.target.value)} disabled={!stopped || saving} /> {field.choices.map((choice) => <option key={choice} value={choice}>{t(choice)}</option>)}
)} </select>
</div> ) : field.multiline ? (
); <textarea id={id} value={value as string} maxLength={512} onChange={(e) => set(e.target.value)} disabled={!stopped || saving} className="block h-32 w-full resize-y rounded-lg border border-input bg-background px-3 py-2.5 text-sm leading-6 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50" />
})} ) : (
</CardContent> <Input id={id} type={typeof field.value === "number" ? "number" : "text"} value={value as string | number} min={field.min} max={field.max} step={1} maxLength={512} onChange={(e) => set(typeof field.value === "number" ? (e.target.value === "" ? "" : Number(e.target.value)) : e.target.value)} disabled={!stopped || saving} className="h-10 rounded-lg bg-background shadow-none" />
</Card> )}
))} </div>
);
})}
</div>
<div className={cn("grid gap-x-6 border-t border-border/60", switches.length > 1 && "sm:grid-cols-2")}>
{switches.map((field) => (
<label key={field.key} htmlFor={`experience-${field.key}`} className="flex min-h-12 cursor-pointer items-center justify-between gap-4 py-3 text-sm">
<span>{t(field.key)}</span>
<span className="relative shrink-0">
<input id={`experience-${field.key}`} type="checkbox" role="switch" checked={(draft.values[field.key] ?? field.value) as boolean} onChange={(e) => setDraft({ ...draft, values: { ...draft.values, [field.key]: e.target.checked } })} disabled={!stopped || saving} className="peer sr-only" />
<span aria-hidden="true" className="block h-5 w-9 rounded-full bg-muted-foreground/25 p-0.5 transition-colors peer-checked:bg-primary peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-disabled:opacity-50 peer-checked:[&>span]:translate-x-4">
<span className="block h-4 w-4 rounded-full bg-white shadow-sm transition-transform motion-reduce:transition-none" />
</span>
</span>
</label>
))}
</div>
</CardContent>
</Card>
);
})}
{draft && <> {draft && <>
<p className="text-sm text-muted-foreground">{t(name === "login" ? "login_settings_hint" : "lobby_settings_hint")}</p> <p className="text-sm text-muted-foreground">{t(name === "login" ? "login_settings_hint" : "lobby_settings_hint")}</p>
{message && <MessageLine kind={message.kind} message={message.text} />} {message && <MessageLine kind={message.kind} message={message.text} />}
{!experienceValid(draft.values, groups) && <InlineError message={t("invalid_values")} />} {!experienceValid(draft.values, groups) && <InlineError message={t("invalid_values")} />}
<Button onClick={() => void save()} disabled={!stopped || !dirty || saving || !experienceValid(draft.values, groups)}><Save className="h-4 w-4" />{t(saving ? "saving" : "save")}</Button> <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border bg-card p-3">
<p className="text-xs text-muted-foreground">{t(dirty ? "unsaved" : "apply_on_start")}</p>
<Button onClick={() => void save()} disabled={!stopped || !dirty || saving || !experienceValid(draft.values, groups)}><Save className="h-4 w-4" />{t(saving ? "saving" : "save")}</Button>
</div>
</>} </>}
</div> </div>
); );
@@ -114,9 +145,9 @@ function BuilderAccess({ ready }: { ready: boolean }) {
setBusy(false); setBusy(false);
} }
} }
return <Card> return <Card className="overflow-hidden rounded-xl shadow-none">
<CardHeader><CardTitle>{t("builder_title")}</CardTitle></CardHeader> <CardHeader className="border-b border-border/60 bg-muted/20 py-4"><CardTitle className="text-sm">{t("builder_title")}</CardTitle></CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3 pt-4">
<p className="text-sm text-muted-foreground">{t("builder_hint")}</p> <p className="text-sm text-muted-foreground">{t("builder_hint")}</p>
<Label htmlFor="builder-player">{t("builder_player")}</Label> <Label htmlFor="builder-player">{t("builder_player")}</Label>
<Input id="builder-player" value={player} onChange={(e) => setPlayer(e.target.value)} placeholder="Steve" /> <Input id="builder-player" value={player} onChange={(e) => setPlayer(e.target.value)} placeholder="Steve" />
@@ -140,6 +171,11 @@ export function LobbyPage() {
const status = useAsync(() => api.status(name), [name]); const status = useAsync(() => api.status(name), [name]);
usePolling(status.reload, STATUS_POLL_FAST_MS); usePolling(status.reload, STATUS_POLL_FAST_MS);
const data = status.data; const data = status.data;
function selectSpace(space: string) {
if (space === name) return;
if (dirty) setNextSpace(space);
else setParams({ space });
}
const links = [ const links = [
{ icon: Map, title: "map_title", hint: name === "login" ? "login_map_hint" : "lobby_map_hint", to: `/servers/${name}/files` }, { icon: Map, title: "map_title", hint: name === "login" ? "login_map_hint" : "lobby_map_hint", to: `/servers/${name}/files` },
{ icon: Package, title: "plugins_title", hint: "plugins_hint", to: `/servers/${name}/files?path=plugins` }, { icon: Package, title: "plugins_title", hint: "plugins_hint", to: `/servers/${name}/files?path=plugins` },
@@ -147,31 +183,51 @@ export function LobbyPage() {
{ icon: Terminal, title: "console_title", hint: name === "login" ? "login_console_hint" : "console_hint", to: `/servers/${name}` }, { icon: Terminal, title: "console_title", hint: name === "login" ? "login_console_hint" : "console_hint", to: `/servers/${name}` },
{ icon: Shield, title: "backups_title", hint: "backups_hint", to: `/servers/${name}/backups` }, { icon: Shield, title: "backups_title", hint: "backups_hint", to: `/servers/${name}/backups` },
]; ];
return <div className="space-y-6"> return <div className="mx-auto max-w-7xl space-y-5">
<PageHeader icon={DoorOpen} title={t("title")} subtitle={t("subtitle")} /> <PageHeader icon={DoorOpen} title={t("title")} subtitle={t("subtitle")} />
<Card><CardContent className="flex flex-wrap items-center justify-between gap-4 pt-5"> <Card className="rounded-xl bg-muted/20 shadow-none"><CardContent className="flex flex-wrap items-start justify-between gap-4 pt-5">
<div className="space-y-1"> <div className="flex min-w-0 gap-3">
<p className="text-sm font-medium">{t("connection_version", { version: cfg?.gameVersion ?? t("unknown_version") })}</p> <Globe className="mt-0.5 h-5 w-5 shrink-0 text-primary" />
{cfg && <CopyAddress address={joinAddress("login", cfg)} />} <div className="space-y-1">
<p className="max-w-2xl text-xs text-muted-foreground">{t("version_hint")}</p> <p className="text-sm font-medium">{t("connection_version", { version: cfg?.gameVersion ?? t("unknown_version") })}</p>
{cfg && <CopyAddress address={joinAddress("login", cfg)} />}
<p className="max-w-2xl text-xs text-muted-foreground">{t("version_hint")}</p>
</div>
</div> </div>
<Link to="/admin/images" className="text-sm text-primary hover:underline">{t("images_link")}</Link> <Link to="/admin/images" className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline">{t("images_link")}<ChevronRight className="h-3.5 w-3.5" /></Link>
</CardContent></Card> </CardContent></Card>
<div className="flex flex-wrap gap-2" aria-label={t("space_label")}> <div className="inline-flex gap-1 rounded-lg bg-muted/60 p-1" role="group" aria-label={t("space_label")}>
{(["lobby", "login"] as const).map((space) => <Button key={space} variant={name === space ? "default" : "outline"} aria-pressed={name === space} onClick={() => { if (space !== name) { if (dirty) setNextSpace(space); else setParams({ space }); } }}>{t(space)}</Button>)} {(["lobby", "login"] as const).map((space) => (
<Button key={space} variant="ghost" aria-pressed={name === space} onClick={() => selectSpace(space)} className={cn("px-4", name === space ? "bg-card text-primary shadow-sm hover:bg-card" : "text-muted-foreground")}>
{space === "lobby" ? <DoorOpen /> : <BookOpen />}{t(space)}
</Button>
))}
</div> </div>
<p className="text-sm text-muted-foreground">{t(`${name}_description`)}</p> <p className="text-sm text-muted-foreground">{t(`${name}_description`)}</p>
{status.loading && !data && <Loading />} {status.loading && !data && <Loading />}
{status.error != null && <ErrorState error={humanizeError(status.error)} onRetry={status.reload} />} {status.error != null && <ErrorState error={humanizeError(status.error)} onRetry={status.reload} />}
{data && <> {data && <>
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-card p-4"> <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border bg-card p-4">
<div className="min-w-0 space-y-2"><div className="flex flex-wrap items-center gap-3"><h2 className="font-semibold">{data.displayName || t(name)}</h2><PhaseBadge phase={shownPhase(data)} /></div><p className="break-all text-xs text-muted-foreground">{data.image}</p></div> <div className="min-w-0 space-y-2"><div className="flex flex-wrap items-center gap-3"><h2 className="font-semibold">{data.displayName || t(name)}</h2><PhaseBadge phase={shownPhase(data)} /></div><p className="break-all text-xs text-muted-foreground">{data.image}</p></div>
<PowerButton name={name} phase={data.phase} desiredState={data.desiredState} failed={startFailure(data) !== null} playersOnline={data.playersOnline} playerCountUnknown={data.playerCountUnknown} onChanged={status.reload} /> <PowerButton name={name} phase={data.phase} desiredState={data.desiredState} failed={startFailure(data) !== null} playersOnline={data.playersOnline} playerCountUnknown={data.playerCountUnknown} onChanged={status.reload} />
</div> </div>
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]"> <div className="grid items-start gap-5 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<ExperienceSettings key={name} name={name} onDirtyChange={setDirty} stopped={data.phase === "Stopped" && data.desiredState === "Stopped"} /> <ExperienceSettings key={name} name={name} onDirtyChange={setDirty} stopped={data.phase === "Stopped" && data.desiredState === "Stopped"} />
<div className="space-y-4"> <div className="space-y-4">
{links.map(({ icon: Icon, title, hint, to }) => <Link key={title} to={to} className="flex gap-3 rounded-lg border border-border bg-card p-4 hover:border-primary/40"><Icon className="mt-0.5 h-5 w-5 shrink-0 text-primary" /><div className="min-w-0"><p className="text-sm font-medium">{t(title)}</p><p className="mt-1 text-xs text-muted-foreground">{t(hint)}</p></div></Link>)} <Card className="overflow-hidden rounded-xl shadow-none">
<CardHeader className="border-b border-border/60 bg-muted/20 py-4"><CardTitle className="text-sm">{t("content_tools")}</CardTitle></CardHeader>
<CardContent className="divide-y divide-border/60 p-0">
{links.map(({ icon: Icon, title, hint, to }) => (
<Link key={title} to={to} className="group flex gap-3 p-4 transition-colors hover:bg-muted/40">
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
<div className="min-w-0 flex-1">
<p className="flex items-center justify-between gap-2 text-sm font-medium">{t(title)}<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" /></p>
<p className="mt-1.5 text-xs leading-relaxed text-muted-foreground">{t(hint)}</p>
</div>
</Link>
))}
</CardContent>
</Card>
<EditServerDialog serverName={name} systemService currentDisplayName={data.displayName} currentPolicy={data.autostartPolicy} currentImage={data.image} currentMemory={data.memory} currentStorage={data.storageSize} currentCpu={data.cpu} currentIdleStopSeconds={0} onUpdated={status.reload} /> <EditServerDialog serverName={name} systemService currentDisplayName={data.displayName} currentPolicy={data.autostartPolicy} currentImage={data.image} currentMemory={data.memory} currentStorage={data.storageSize} currentCpu={data.cpu} currentIdleStopSeconds={0} onUpdated={status.reload} />
{name === "lobby" && <BuilderAccess ready={data.ready} />} {name === "lobby" && <BuilderAccess ready={data.ready} />}
</div> </div>