This commit is contained in:
2026-05-06 00:10:45 +08:00
parent 2e3eb4b8f8
commit 916a4f5ffc
19 changed files with 2431 additions and 723 deletions
+129 -93
View File
@@ -3,7 +3,11 @@
import { useDeferredValue, useState } from "react";
import { useFormStatus } from "react-dom";
import { deleteProxyGroupAction, saveProxyGroupAction } from "./actions";
import {
deleteProxyGroupAction,
saveMainProxyGroupAction,
saveProxyGroupAction,
} from "./actions";
type ProxyGroupNodeOption = {
slug: string;
@@ -12,6 +16,7 @@ type ProxyGroupNodeOption = {
};
type ProxyGroupEditorProps = {
mode?: "custom" | "main";
group?: {
id: string;
name: string;
@@ -20,11 +25,14 @@ type ProxyGroupEditorProps = {
nodes: ProxyGroupNodeOption[];
};
function normalizeSelectedSlugs(initialSlugs: string[], nodes: ProxyGroupNodeOption[]) {
function normalizeSelectedSlugs(
initialSlugs: string[],
nodes: ProxyGroupNodeOption[],
includeMissingNodes = false,
) {
const availableSlugs = new Set(nodes.map((node) => node.slug));
const seen = new Set<string>();
return initialSlugs.filter((slug) => {
const selectedSlugs = initialSlugs.filter((slug) => {
if (!availableSlugs.has(slug) || seen.has(slug)) {
return false;
}
@@ -32,14 +40,33 @@ function normalizeSelectedSlugs(initialSlugs: string[], nodes: ProxyGroupNodeOpt
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 SaveButton({ isEditing }: { isEditing: boolean }) {
function SaveButton({ isEditing, isMainGroup }: { isEditing: boolean; isMainGroup: boolean }) {
const { pending } = useFormStatus();
const label = isMainGroup ? "保存主组顺序" : isEditing ? "保存分组" : "新建分组";
const pendingLabel = isMainGroup ? "保存中..." : isEditing ? "保存中..." : "创建中...";
return (
<button className={isEditing ? "ghost-button" : "primary-button"} type="submit" disabled={pending}>
{pending ? (isEditing ? "保存中..." : "创建中...") : isEditing ? "保存分组" : "新建分组"}
<button
className={isEditing && !isMainGroup ? "ghost-button" : "primary-button"}
type="submit"
disabled={pending}
>
{pending ? pendingLabel : label}
</button>
);
}
@@ -54,12 +81,13 @@ function DeleteButton() {
);
}
export function ProxyGroupEditor({ group, nodes }: ProxyGroupEditorProps) {
const isEditing = Boolean(group);
export function ProxyGroupEditor({ mode = "custom", group, nodes }: ProxyGroupEditorProps) {
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),
normalizeSelectedSlugs(group?.memberSlugs ?? [], nodes, isMainGroup),
);
const selectedSlugSet = new Set(selectedSlugs);
const nodesBySlug = new Map(nodes.map((node) => [node.slug, node]));
@@ -135,96 +163,103 @@ export function ProxyGroupEditor({ group, nodes }: ProxyGroupEditorProps) {
return (
<article className="instruction-block proxy-group-card">
<form action={saveProxyGroupAction} className="stack-md">
{group ? <input type="hidden" name="id" value={group.id} /> : null}
<form action={isMainGroup ? saveMainProxyGroupAction : saveProxyGroupAction} className="stack-md">
{group && !isMainGroup ? <input type="hidden" name="id" value={group.id} /> : null}
<div className="section-title section-title--top">
<div className="stack-tight">
<h4 className="proxy-group-title">{isEditing ? group?.name : "新建分组"}</h4>
<p className="muted proxy-group-caption">右侧顺序就是最终写入 Clash 的节点顺序。</p>
<h4 className="proxy-group-title">
{isMainGroup ? `默认主分组:${group?.name ?? "FeieProxy"}` : isEditing ? group?.name : "新建分组"}
</h4>
</div>
<div className="badge-row badge-row--end">
<span className="status-pill status-pill--ink">已选 {selectedNodes.length}</span>
<span className="status-pill status-pill--warm">未加入 {remainingCount}</span>
{!isMainGroup ? (
<span className="status-pill status-pill--warm">未加入 {remainingCount}</span>
) : null}
</div>
</div>
<label className="field">
<span>{isEditing ? "组名" : "新建分组名"}</span>
<input name="name" defaultValue={group?.name ?? ""} placeholder="ChatGPT" required />
</label>
{!isMainGroup ? (
<label className="field">
<span>{isEditing ? "组名" : "新建分组名"}</span>
<input name="name" defaultValue={group?.name ?? ""} placeholder="ChatGPT" required />
</label>
) : null}
<div className="proxy-group-builder">
<section className="proxy-group-panel">
<div className="proxy-group-panel__header">
<div className="stack-tight">
<strong>候选节点</strong>
<span className="muted">筛选后点击添加,新节点会追加到分组末尾。</span>
</div>
<div className="proxy-group-toolbar">
<label className="proxy-group-search">
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
}
}}
placeholder="搜索节点名称 / slug"
/>
</label>
<button
className="ghost-button proxy-toolbar-button"
type="button"
onClick={addAllVisibleNodes}
disabled={availableNodes.length === 0}
>
{normalizedQuery ? "添加筛选结果" : "全部添加"}
</button>
</div>
</div>
{availableNodes.length > 0 ? (
<div className="proxy-node-grid">
{availableNodes.map((node) => (
<div className={`proxy-group-builder${isMainGroup ? " proxy-group-builder--main" : ""}`}>
{!isMainGroup ? (
<section className="proxy-group-panel">
<div className="proxy-group-panel__header">
<div className="stack-tight">
<strong>候选节点</strong>
</div>
<div className="proxy-group-toolbar">
<label className="proxy-group-search">
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
}
}}
placeholder="搜索节点名称 / slug"
/>
</label>
<button
key={node.slug}
className="proxy-node-chip"
className="ghost-button proxy-toolbar-button"
type="button"
onClick={() => addNode(node.slug)}
onClick={addAllVisibleNodes}
disabled={availableNodes.length === 0}
>
<span className="proxy-node-chip__copy">
<strong>{node.label}</strong>
<span>{node.meta}</span>
</span>
<span className="proxy-node-chip__action">添加</span>
{normalizedQuery ? "添加筛选结果" : "全部添加"}
</button>
))}
</div>
</div>
) : (
<p className="muted proxy-group-empty">
{remainingCount === 0
? "所有可用节点都已加入当前分组。"
: "没有匹配当前筛选条件的节点。"}
</p>
)}
</section>
{availableNodes.length > 0 ? (
<div className="proxy-node-grid">
{availableNodes.map((node) => (
<button
key={node.slug}
className="proxy-node-chip"
type="button"
onClick={() => addNode(node.slug)}
>
<span className="proxy-node-chip__copy">
<strong>{node.label}</strong>
<span>{node.meta}</span>
</span>
<span className="proxy-node-chip__action">添加</span>
</button>
))}
</div>
) : (
<p className="muted proxy-group-empty">
{remainingCount === 0
? "所有可用节点都已加入当前分组。"
: "没有匹配当前筛选条件的节点。"}
</p>
)}
</section>
) : null}
<section className="proxy-group-panel proxy-group-panel--selected">
<div className="proxy-group-panel__header">
<div className="stack-tight">
<strong>已选顺序</strong>
<span className="muted">上移和下移会直接改变最终输出顺序。</span>
<strong>{isMainGroup ? "节点顺序" : "已选顺序"}</strong>
</div>
<button
className="ghost-button proxy-toolbar-button"
type="button"
onClick={clearSelection}
disabled={selectedNodes.length === 0}
>
清空已选
</button>
{!isMainGroup ? (
<button
className="ghost-button proxy-toolbar-button"
type="button"
onClick={clearSelection}
disabled={selectedNodes.length === 0}
>
清空已选
</button>
) : null}
</div>
{selectedNodes.length > 0 ? (
@@ -255,13 +290,15 @@ export function ProxyGroupEditor({ group, nodes }: ProxyGroupEditorProps) {
>
下移
</button>
<button
className="danger-button proxy-sort-button"
type="button"
onClick={() => removeNode(node.slug)}
>
移除
</button>
{!isMainGroup ? (
<button
className="danger-button proxy-sort-button"
type="button"
onClick={() => removeNode(node.slug)}
>
移除
</button>
) : null}
</div>
<input type="hidden" name="member_slugs" value={node.slug} />
</li>
@@ -269,19 +306,18 @@ export function ProxyGroupEditor({ group, nodes }: ProxyGroupEditorProps) {
</ol>
) : (
<p className="muted proxy-group-empty">
还没有选择节点。先从左侧添加,再在这里调整最终顺序。
{isMainGroup ? "暂无可用节点。" : "还没有选择节点。先从左侧添加,再在这里调整最终顺序。"}
</p>
)}
</section>
</div>
<div className="action-row">
<span className="muted">保存后会立即更新订阅模板里的 proxy-groups。</span>
<SaveButton isEditing={isEditing} />
<div className="action-row action-row--end">
<SaveButton isEditing={isEditing} isMainGroup={isMainGroup} />
</div>
</form>
{group ? (
{group && !isMainGroup ? (
<form action={deleteProxyGroupAction}>
<input type="hidden" name="id" value={group.id} />
<DeleteButton />