fix(panel): 服务器地址改为一键复制并带上非默认游戏端口,玩家列表去掉无意义的连接地址列,管理员列改称内部地址
This commit is contained in:
20 files changed
+379
-59
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user