"use client"; import { useDeferredValue, useState, useId, type ReactNode } from "react"; import { DndContext, closestCenter, PointerSensor, KeyboardSensor, useSensor, useSensors, } from "@dnd-kit/core"; import { SortableContext, useSortable, verticalListSortingStrategy, sortableKeyboardCoordinates, arrayMove, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { GripVertical, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ActionForm } from "@/components/action-form"; import { SubmitButton } from "@/components/submit-button"; import { useFormStatus } from "react-dom"; import { deleteProxyGroupAction, saveMainProxyGroupAction, saveProxyGroupAction, } from "./actions"; type ProxyGroupNodeOption = { slug: string; label: string; meta: string; }; type ProxyGroupEditorProps = { mode?: "custom" | "main"; group?: { id: string; name: string; memberSlugs: string[]; hiddenSlugs?: string[]; }; nodes: ProxyGroupNodeOption[]; }; function normalizeSelectedSlugs( initialSlugs: string[], nodes: ProxyGroupNodeOption[], includeMissingNodes = false, ) { const availableSlugs = new Set(nodes.map((node) => node.slug)); const seen = new Set(); const selectedSlugs = initialSlugs.filter((slug) => { if (!availableSlugs.has(slug) || seen.has(slug)) { return false; } seen.add(slug); return true; }); if (!includeMissingNodes) { return selectedSlugs; } for (const node of nodes) { if (!seen.has(node.slug)) { selectedSlugs.push(node.slug); seen.add(node.slug); } } return selectedSlugs; } function EditorFields({ children }: { children: ReactNode }) { const { pending } = useFormStatus(); return (
{children}
); } function SortableNode({ node, index, removable, onRemove, hidden, onToggleHidden, }: { node: ProxyGroupNodeOption; index: number; removable: boolean; onRemove: () => void; hidden?: boolean; onToggleHidden?: () => void; }) { const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging, } = useSortable({ id: node.slug }); return (
  • {index + 1}
    {node.label} {node.meta}
    {onToggleHidden && ( )} {removable && ( )}
  • ); } export function ProxyGroupEditor({ mode = "custom", group, nodes, }: ProxyGroupEditorProps) { const dndId = useId(); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), ); const isMainGroup = mode === "main"; const isEditing = Boolean(group) && !isMainGroup; const [query, setQuery] = useState(""); const deferredQuery = useDeferredValue(query); const [selectedSlugs, setSelectedSlugs] = useState(() => normalizeSelectedSlugs(group?.memberSlugs ?? [], nodes, isMainGroup), ); const [hiddenSlugs, setHiddenSlugs] = useState(group?.hiddenSlugs ?? []); const [name, setName] = useState(group?.name ?? ""); const [saved, setSaved] = useState(() => JSON.stringify([ group?.name ?? "", normalizeSelectedSlugs(group?.memberSlugs ?? [], nodes, isMainGroup), group?.hiddenSlugs ?? [], ]), ); const [feedback, setFeedback] = useState(""); const dirty = saved !== JSON.stringify([name, selectedSlugs, hiddenSlugs]); 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) { setFeedback(""); setSelectedSlugs((current) => { if (current.includes(slug)) { return current; } return [...current, slug]; }); } function removeNode(slug: string) { setFeedback(""); setSelectedSlugs((current) => current.filter((currentSlug) => currentSlug !== slug), ); } function addAllVisibleNodes() { setFeedback(""); 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() { setFeedback(""); setSelectedSlugs([]); } return (
    { setFeedback(""); try { await ( isMainGroup ? saveMainProxyGroupAction : saveProxyGroupAction )(formData); setSaved(JSON.stringify([name, selectedSlugs, hiddenSlugs])); setFeedback("已保存"); } catch { setFeedback("保存失败,请重试。当前修改已保留。"); } }} className="stack-md" > {isMainGroup && ( <>

    隐藏仅影响主分组,节点及其他分组仍保留。新节点默认显示;全部隐藏时主分组回退为 DIRECT。

    {hiddenSlugs.map((slug) => ( ))} )} {group && !isMainGroup ? ( ) : null}

    {isMainGroup ? `默认主分组:${group?.name ?? "FeieProxy"}` : isEditing ? group?.name : "新建分组"}

    已选 {selectedNodes.length} {!isMainGroup ? ( 未加入 {remainingCount} ) : null}
    {!isMainGroup ? ( ) : null}
    {!isMainGroup ? (
    候选节点
    {availableNodes.length > 0 ? (
    {availableNodes.map((node) => ( ))}
    ) : (

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

    )}
    ) : null}
    {isMainGroup ? "节点顺序" : "已选顺序"} 拖动手柄排序 · 支持键盘
    {!isMainGroup ? ( ) : null}
    {selectedNodes.length > 0 ? ( `开始移动 ${nodesBySlug.get(String(active.id))?.label ?? active.id}`, onDragOver: ({ over }) => over ? `目标位置 ${selectedSlugs.indexOf(String(over.id)) + 1}` : "已离开列表", onDragEnd: ({ over }) => over ? `已放到第 ${selectedSlugs.indexOf(String(over.id)) + 1} 位,请保存顺序` : "顺序未变", onDragCancel: () => "已取消排序", }, }} onDragEnd={({ active, over }) => { if (over && active.id !== over.id) { setSelectedSlugs((current) => arrayMove( current, current.indexOf(String(active.id)), current.indexOf(String(over.id)), ), ); setFeedback(""); } }} >
      {selectedNodes.map((node, index) => (
    ) : (

    {isMainGroup ? "暂无可用节点。" : "还没有选择节点。先从左侧添加,再在这里调整最终顺序。"}

    )}
    {feedback || (dirty ? "有未保存的修改" : "")} {dirty && ( )} {isMainGroup ? "保存主分组" : isEditing ? "保存分组" : "新建分组"}
    {group && !isMainGroup ? ( 删除分组 ) : null}
    ); }