fix(panel): 服务器地址改为一键复制并带上非默认游戏端口,玩家列表去掉无意义的连接地址列,管理员列改称内部地址

This commit is contained in:
Lemon-miaow committed 2026-09-25 12:51:16 +08:00
1 parent bb9c2168df
commit f156e385f0
20 files changed
+379 -59

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { CopyAddress } from "./CopyAddress";
function stubClipboard(writeText: (text: string) => Promise<void>) {
const fn = vi.fn(writeText);
Object.defineProperty(navigator, "clipboard", { value: { writeText: fn }, configurable: true });
return fn;
}
afterEach(() => {
vi.useRealTimers();
Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true });
window.getSelection()?.removeAllRanges();
});
describe("CopyAddress", () => {
it("shows the address as text, never as a web link", () => {
render(<CopyAddress address="survival.mc.example:25570" />);
expect(screen.getByText("survival.mc.example:25570")).toBeTruthy();
expect(screen.queryByRole("link")).toBeNull();
});
it("copies the address and says so, then settles back", async () => {
vi.useFakeTimers();
const writeText = stubClipboard(async () => {});
render(<CopyAddress address="survival.mc.example:25570" />);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Copy address survival.mc.example:25570" }));
});
expect(writeText).toHaveBeenCalledWith("survival.mc.example:25570");
expect(screen.getByRole("status").textContent).toBe("Copied");
act(() => {
vi.advanceTimersByTime(2000);
});
expect(screen.getByRole("status").textContent).toBe("");
});
it("selects the address for the system menu when the clipboard is refused", async () => {
stubClipboard(async () => {
throw new DOMException("denied", "NotAllowedError");
});
render(<CopyAddress address="survival.mc.example" />);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Copy address survival.mc.example" }));
});
expect(window.getSelection()?.toString()).toBe("survival.mc.example");
expect(screen.getByRole("status").textContent).toBe("Selected — copy it from the menu");
});
it("selects the address where there is no clipboard at all", async () => {
render(<CopyAddress address="survival.mc.example" />);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Copy address survival.mc.example" }));
});
expect(window.getSelection()?.toString()).toBe("survival.mc.example");
});
});
+91
View File
@@ -0,0 +1,91 @@
import { useEffect, useRef, useState } from "react";
import { Check, Copy } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
const FEEDBACK_MS = 2000;
// CopyAddress shows the address a Minecraft client joins and copies it with one
// tap. Minecraft is not a web page, so the address is never a link. When the
// clipboard is refused (an insecure origin, an embedded browser) the text is
// selected instead, ready for the system copy menu.
export function CopyAddress({ address, className }: { address: string; className?: string }) {
const { t } = useTranslation("common");
const [state, setState] = useState<"idle" | "copied" | "failed">("idle");
const textRef = useRef<HTMLElement>(null);
const timer = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearTimeout(timer.current), []);
function selectText() {
const node = textRef.current;
const selection = window.getSelection();
if (!node || !selection) return;
const range = document.createRange();
range.selectNodeContents(node);
selection.removeAllRanges();
selection.addRange(range);
}
async function copy() {
window.clearTimeout(timer.current);
try {
// Without a clipboard (an insecure origin) this throws, and lands below too.
await navigator.clipboard.writeText(address);
setState("copied");
} catch {
selectText();
setState("failed");
}
timer.current = window.setTimeout(() => setState("idle"), FEEDBACK_MS);
}
const feedback =
state === "copied" ? t("address_copied") : state === "failed" ? t("address_copy_failed") : "";
// After a refused copy the whole address shows, selected, with the hint on its
// own line; truncated, the selection would hide what the menu is about to copy.
const failed = state === "failed";
return (
<span
className={cn(
"inline-flex min-w-0 max-w-full flex-wrap items-center gap-x-1 text-xs",
className,
)}
>
<code
ref={textRef}
className={cn(
"min-w-0 select-all font-mono text-muted-foreground",
failed ? "break-all" : "truncate",
)}
title={address}
>
{address}
</code>
<button
type="button"
onClick={() => void copy()}
aria-label={t("copy_address", { address })}
title={t("copy_address", { address })}
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{state === "copied" ? (
<Check className="h-3.5 w-3.5 text-emerald-500" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
<span
role="status"
className={cn(
"shrink-0",
failed ? "basis-full" : "whitespace-nowrap",
state === "copied" ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground",
)}
>
{feedback}
</span>
</span>
);
}