"use client"; import { useDeferredValue, useState } from "react"; import { useFormStatus } from "react-dom"; import { deleteProxyGroupAction, saveProxyGroupAction } from "./actions"; type ProxyGroupNodeOption = { slug: string; label: string; meta: string; }; type ProxyGroupEditorProps = { group?: { id: string; name: string; memberSlugs: string[]; }; nodes: ProxyGroupNodeOption[]; }; function normalizeSelectedSlugs(initialSlugs: string[], nodes: ProxyGroupNodeOption[]) { const availableSlugs = new Set(nodes.map((node) => node.slug)); const seen = new Set(); return initialSlugs.filter((slug) => { if (!availableSlugs.has(slug) || seen.has(slug)) { return false; } seen.add(slug); return true; }); } function SaveButton({ isEditing }: { isEditing: boolean }) { const { pending } = useFormStatus(); return ( ); } function DeleteButton() { const { pending } = useFormStatus(); return ( ); } export function ProxyGroupEditor({ group, nodes }: ProxyGroupEditorProps) { const isEditing = Boolean(group); const [query, setQuery] = useState(""); const deferredQuery = useDeferredValue(query); const [selectedSlugs, setSelectedSlugs] = useState(() => normalizeSelectedSlugs(group?.memberSlugs ?? [], nodes), ); const selectedSlugSet = new Set(selectedSlugs); const nodesBySlug = new Map(nodes.map((node) => [node.slug, node])); const selectedNodes = selectedSlugs .map((slug) => nodesBySlug.get(slug)) .filter((node): node is ProxyGroupNodeOption => Boolean(node)); const normalizedQuery = deferredQuery.trim().toLowerCase(); const remainingCount = nodes.length - selectedNodes.length; const availableNodes = nodes.filter((node) => { if (selectedSlugSet.has(node.slug)) { return false; } if (!normalizedQuery) { return true; } return [node.label, node.meta, node.slug].some((value) => value.toLowerCase().includes(normalizedQuery), ); }); function addNode(slug: string) { setSelectedSlugs((current) => { if (current.includes(slug)) { return current; } return [...current, slug]; }); } function removeNode(slug: string) { setSelectedSlugs((current) => current.filter((currentSlug) => currentSlug !== slug)); } function moveNode(slug: string, direction: -1 | 1) { setSelectedSlugs((current) => { const index = current.indexOf(slug); const nextIndex = index + direction; if (index === -1 || nextIndex < 0 || nextIndex >= current.length) { return current; } const next = [...current]; [next[index], next[nextIndex]] = [next[nextIndex], next[index]]; return next; }); } function addAllVisibleNodes() { setSelectedSlugs((current) => { const next = [...current]; const seen = new Set(current); for (const node of availableNodes) { if (seen.has(node.slug)) { continue; } next.push(node.slug); seen.add(node.slug); } return next; }); } function clearSelection() { setSelectedSlugs([]); } return (
{group ? : null}

{isEditing ? group?.name : "新建分组"}

右侧顺序就是最终写入 Clash 的节点顺序。

已选 {selectedNodes.length} 未加入 {remainingCount}
候选节点 筛选后点击添加,新节点会追加到分组末尾。
{availableNodes.length > 0 ? (
{availableNodes.map((node) => ( ))}
) : (

{remainingCount === 0 ? "所有可用节点都已加入当前分组。" : "没有匹配当前筛选条件的节点。"}

)}
已选顺序 上移和下移会直接改变最终输出顺序。
{selectedNodes.length > 0 ? (
    {selectedNodes.map((node, index) => (
  1. {String(index + 1).padStart(2, "0")}
    {node.label} {node.meta}
  2. ))}
) : (

还没有选择节点。先从左侧添加,再在这里调整最终顺序。

)}
保存后会立即更新订阅模板里的 proxy-groups。
{group ? (
) : null}
); }