fix(panel): 启停按钮统一为带忙碌态和失败原因的组件,有玩家在线或人数未知时停服先确认

This commit is contained in:
Lemon-miaow committed 2026-09-25 09:38:11 +08:00
1 parent bcf245410a
commit 3a2166ca87
9 files changed
+156 -70

No files matched your search

+107
View File
@@ -0,0 +1,107 @@
import { useState } from "react";
import { Loader2, Play, Square } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { api, humanizeError } from "@/lib/api";
import { cn } from "@/lib/utils";
interface Props {
name: string;
/** A pod is up or on its way (Starting/Running/Stopping): offer Stop. */
live: boolean;
playersOnline?: number;
/** The operator cannot read the player count, so players may be online. */
playerCountUnknown?: boolean;
/** Called after the wake or stop was accepted, so the parent refetches. */
onChanged: () => void;
size?: "sm" | "default";
className?: string;
}
// PowerButton starts or stops one server. It is busy while the call runs (no
// double send), shows why a call was refused (quota, cooldown, a phase that
// moved on), and asks before a stop that would disconnect players: the count
// is in the question, and an unreadable count asks too.
export function PowerButton({
name,
live,
playersOnline,
playerCountUnknown,
onChanged,
size = "sm",
className,
}: Props) {
const { t } = useTranslation("servers");
const [busy, setBusy] = useState(false);
const [confirming, setConfirming] = useState(false);
const [error, setError] = useState<string | null>(null);
async function run(kind: "wake" | "stop") {
if (busy) return;
setBusy(true);
setError(null);
try {
await (kind === "wake" ? api.wake(name) : api.stop(name));
setConfirming(false);
onChanged();
} catch (e) {
setError(humanizeError(e));
} finally {
setBusy(false);
}
}
const players = playersOnline ?? 0;
const askFirst = players > 0 || playerCountUnknown === true;
const spinner = <Loader2 className="animate-spin" />;
let control;
if (!live) {
control = (
<Button size={size} onClick={() => void run("wake")} disabled={busy}>
{busy ? spinner : <Play />}
{busy ? t("waking") : t("wake")}
</Button>
);
} else if (confirming) {
control = (
<div role="alert" className="flex flex-wrap items-center justify-end gap-2">
<span className="text-xs font-medium text-foreground">
{players > 0
? t("stop_confirm_players", { count: players })
: t("stop_confirm_unknown")}
</span>
<Button size={size} variant="ghost" onClick={() => setConfirming(false)} disabled={busy}>
{t("common:cancel")}
</Button>
<Button size={size} variant="destructive" onClick={() => void run("stop")} disabled={busy}>
{busy ? spinner : <Square />}
{busy ? t("stopping") : t("stop")}
</Button>
</div>
);
} else {
control = (
<Button
size={size}
variant="destructive"
onClick={() => (askFirst ? setConfirming(true) : void run("stop"))}
disabled={busy}
>
{busy ? spinner : <Square />}
{busy ? t("stopping") : t("stop")}
</Button>
);
}
return (
<div className={cn("flex flex-col items-end gap-1", className)}>
{control}
{error && (
<p role="alert" className="max-w-xs text-right text-xs text-destructive">
{error}
</p>
)}
</div>
);
}
+13 -23
View File
@@ -1,10 +1,11 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { Play, Square, Hand, Users, ExternalLink, Terminal } from "lucide-react";
import { Hand, Users, ExternalLink, Terminal } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { PhaseBadge } from "@/components/PhaseBadge";
import { PowerButton } from "@/components/PowerButton";
import { api, humanizeError } from "@/lib/api";
import { hostFor, type RuntimeConfig } from "@/lib/config";
import type { ServerInfo } from "@/lib/types";
@@ -18,11 +19,11 @@ interface Props {
export function ServerCard({ server, cfg, onChanged }: Props) {
const { t } = useTranslation("servers");
const [busy, setBusy] = useState<null | "wake" | "stop" | "claim">(null);
const [busy, setBusy] = useState<null | "claim">(null);
const [error, setError] = useState<string | null>(null);
const host = hostFor(server.subdomain, cfg);
async function act(kind: "wake" | "stop" | "claim", fn: () => Promise<unknown>) {
async function act(kind: "claim", fn: () => Promise<unknown>) {
setBusy(kind);
setError(null);
try {
@@ -93,26 +94,15 @@ export function ServerCard({ server, cfg, onChanged }: Props) {
<Hand /> {busy === "claim" ? t("claiming") : t("claim")}
</Button>
)}
{server.owned &&
(running || transitioning ? (
<Button
size="sm"
variant="destructive"
disabled={busy !== null}
onClick={() => act("stop", () => api.stop(server.name))}
>
<Square /> {busy === "stop" ? t("stopping") : t("stop")}
</Button>
) : (
<Button
size="sm"
variant="default"
disabled={busy !== null}
onClick={() => act("wake", () => api.wake(server.name))}
>
<Play /> {busy === "wake" ? t("waking") : t("wake")}
</Button>
))}
{server.owned && (
<PowerButton
name={server.name}
live={running || transitioning}
playersOnline={server.playersOnline}
playerCountUnknown={server.playerCountUnknown}
onChanged={onChanged}
/>
)}
</div>
</Card>
);
+7 -7
View File
@@ -1,7 +1,7 @@
import { Loader2, AlertTriangle, Inbox, ShieldX, Construction, Moon, SearchX } from "lucide-react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { PowerButton } from "@/components/PowerButton";
import { humanizeError } from "@/lib/api";
export function Loading({ label }: { label?: string }) {
@@ -136,11 +136,13 @@ export function NotYours({ title, body, linkTo = "/servers", linkLabel }: NotYou
export interface NotRunningProps {
title: string;
body: string;
wakeLabel: string;
onWake: () => void;
/** The server to wake from here. */
serverName: string;
/** Called once the wake was accepted, so the page refetches its status. */
onWoken: () => void;
}
export function NotRunning({ title, body, wakeLabel, onWake }: NotRunningProps) {
export function NotRunning({ title, body, serverName, onWoken }: NotRunningProps) {
return (
<div className="flex flex-col items-center justify-center gap-4 rounded-lg border border-dashed border-border bg-muted/20 py-16 text-center">
<Moon className="h-8 w-8 text-muted-foreground/70" />
@@ -148,9 +150,7 @@ export function NotRunning({ title, body, wakeLabel, onWake }: NotRunningProps)
<p className="font-medium">{title}</p>
<p className="mx-auto mt-1 max-w-sm text-sm text-muted-foreground">{body}</p>
</div>
<Button size="sm" onClick={onWake}>
{wakeLabel}
</Button>
<PowerButton name={serverName} live={false} onChanged={onWoken} className="items-center" />
</div>
);
}