diff --git a/webui/src/features/nodes/NodesPage.tsx b/webui/src/features/nodes/NodesPage.tsx index a1f3b99a..74f6052b 100644 --- a/webui/src/features/nodes/NodesPage.tsx +++ b/webui/src/features/nodes/NodesPage.tsx @@ -1,6 +1,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { createColumnHelper } from "@tanstack/react-table"; -import { AlertTriangle, Eraser, Globe, RefreshCw, Sparkles, X, Zap } from "lucide-react"; +import { AlertTriangle, Eraser, Globe, Layers, RefreshCw, Sparkles, X, Zap } from "lucide-react"; import { useEffect, useMemo, useState, type CSSProperties } from "react"; import { useLocation } from "react-router-dom"; import { Badge } from "../../components/ui/Badge"; @@ -15,7 +15,7 @@ import { useToast } from "../../hooks/useToast"; import { useI18n } from "../../i18n"; import { formatApiErrorMessage } from "../../lib/error-message"; import { formatDateTime, formatRelativeTime } from "../../lib/time"; -import { listPlatforms } from "../platforms/api"; +import { createPlatform, listPlatforms } from "../platforms/api"; import type { Platform } from "../platforms/types"; import { listSubscriptions } from "../subscriptions/api"; import { getNode, listNodes, probeEgress, probeLatency } from "./api"; @@ -187,6 +187,14 @@ function firstTag(node: { display_tag?: string; tags: { tag: string }[] }): stri return node.tags[0].tag; } +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function sanitizePlatformName(tag: string): string { + return "Node-" + tag.slice(0, 20).replace(/[.:|/\\@?#%~\s]+/g, "-").replace(/-+$/, ""); +} + function hasReferenceLatency(node: NodeSummary): node is NodeSummary & { reference_latency_ms: number } { return typeof node.reference_latency_ms === "number"; } @@ -416,6 +424,17 @@ export function NodesPage() { await probeLatencyMutation.mutateAsync(hash); }; + const createDirectPlatformMutation = useMutation({ + mutationFn: createPlatform, + onSuccess: async (created) => { + await queryClient.invalidateQueries({ queryKey: ["platforms"] }); + showToast("success", t("已创建专属平台: {{name}}", { name: created.name })); + }, + onError: (error) => { + showToast("error", formatApiErrorMessage(error, t)); + }, + }); + const handleFilterChange = (key: keyof NodeFilterDraft, value: string) => { setDraftFilters((prev) => { const next = { ...prev, [key]: value }; @@ -586,6 +605,22 @@ export function NodesPage() { > + ); }, diff --git a/webui/src/features/platforms/PlatformPage.tsx b/webui/src/features/platforms/PlatformPage.tsx index 5770b940..447f2348 100644 --- a/webui/src/features/platforms/PlatformPage.tsx +++ b/webui/src/features/platforms/PlatformPage.tsx @@ -1,6 +1,6 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { AlertTriangle, Info, Plus, RefreshCw, Search, Sparkles } from "lucide-react"; +import { AlertTriangle, Info, Link, Plus, RefreshCw, Search, Sparkles } from "lucide-react"; import { useState } from "react"; import { useForm } from "react-hook-form"; import { useNavigate } from "react-router-dom"; @@ -38,6 +38,21 @@ const ZERO_UUID = "00000000-0000-0000-0000-000000000000"; const EMPTY_PLATFORMS: Platform[] = []; const PAGE_SIZE_OPTIONS = [12, 24, 48, 96] as const; +function copyToClipboard(text: string): Promise { + if (navigator.clipboard?.writeText) { + return navigator.clipboard.writeText(text); + } + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); + return Promise.resolve(); +} + export function PlatformPage() { const { t } = useI18n(); const navigate = useNavigate(); @@ -45,6 +60,12 @@ export function PlatformPage() { const [page, setPage] = useState(0); const [pageSize, setPageSize] = useState(24); const [createModalOpen, setCreateModalOpen] = useState(false); + const [proxyHost, setProxyHost] = useState(() => + localStorage.getItem("resin.webui.proxy_host") || `${window.location.hostname}:${window.location.port || "2260"}` + ); + const [proxyToken, setProxyToken] = useState(() => + localStorage.getItem("resin.webui.proxy_token") ?? "" + ); const { toasts, showToast, dismissToast } = useToast(); const queryClient = useQueryClient(); @@ -120,7 +141,28 @@ export function PlatformPage() {

{t("平台列表")}

{t("共 {{count}} 个平台", { count: totalPlatforms })}

-
+
+
+ { + setProxyHost(e.target.value); + localStorage.setItem("resin.webui.proxy_host", e.target.value); + }} + style={{ maxWidth: 170, padding: "6px 10px", borderRadius: 8, fontSize: "0.85rem" }} + /> + { + setProxyToken(e.target.value); + localStorage.setItem("resin.webui.proxy_token", e.target.value); + }} + style={{ maxWidth: 130, padding: "6px 10px", borderRadius: 8, fontSize: "0.85rem" }} + /> +