Skip to content

Commit f2aa108

Browse files
committed
fix: restore rule order confirmation gate
1 parent 228875a commit f2aa108

11 files changed

Lines changed: 135 additions & 13 deletions

File tree

packages/core/src/generator/rules.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ describe("rule generator", () => {
5656
});
5757
const texts = entries.map((entry) => entry.text);
5858
const customIpEntry = entries.find((entry) => entry.key === "custom-rule:ip-rule");
59+
const customRuleSetEntry = entries.find((entry) => entry.key === "custom-rule-set:media-rule");
60+
const appleTvPlusEntry = entries.find((entry) => entry.key === "module:streaming-west:apple-tvplus");
5961

6062
expect(resolveModuleName("cn", { cn: "CN Direct" })).toBe("🔒 CN Direct");
6163
expect(texts).toContain("DOMAIN-SUFFIX,example.com,DIRECT");
@@ -66,9 +68,12 @@ describe("rule generator", () => {
6668
});
6769
expect(texts).toContain("RULE-SET,media-rule,Media,no-resolve");
6870
expect(texts).toContain("RULE-SET,apple-tvplus,📺 Streaming");
69-
expect(entries.find((entry) => entry.text === "RULE-SET,apple-tvplus,📺 Streaming")).toMatchObject({
71+
expect(customIpEntry).toMatchObject({ editable: true });
72+
expect(customRuleSetEntry).toMatchObject({ editable: true });
73+
expect(appleTvPlusEntry).toMatchObject({
7074
key: "module:streaming-west:apple-tvplus",
7175
kind: "module",
76+
editable: false,
7277
});
7378
expect(entries.some((entry) => entry.key === "special:apple-tvplus")).toBe(false);
7479
expect(texts).toContain("RULE-SET,cn,🔒 CN Direct");

packages/core/src/generator/rules.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ function buildModuleRuleEntry(
132132
summary: rule.name,
133133
target,
134134
noResolve,
135-
editable: true,
135+
editable: false,
136136
enabled: edit?.enabled !== false,
137137
};
138138
}

packages/ui/src/product/converter/advanced-mode/sections/rules-management-section.test.ts

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ function renderSection(overrides: Record<string, unknown> = {}) {
9292
experimentalCnUseCnRuleSet: true,
9393
ruleOrder: ["module:geo", "custom:one"],
9494
setRuleOrder: vi.fn(),
95+
allRulesOrderEditingEnabled: false,
96+
setAllRulesOrderEditingEnabled: vi.fn(),
9597
...overrides,
9698
};
9799
stateMock.setter.mockClear();
@@ -138,15 +140,17 @@ describe("RulesManagementSection", () => {
138140
const tree = renderSection();
139141
const text = collectText(tree);
140142
const header = collectElements(tree, (element) => element.props.title === "规则管理")[0];
143+
const switchElement = collectElements(tree, (element) => Boolean((element.props as any).onCheckedChange))[0];
141144
const orderInputs = collectElements(tree, (element) => (element.props as any).title === "最终规则行号(1=最前)");
142145

143146
expect(header.props.title).toBe("规则管理");
144-
expect(collectText(header.props.badge)).toContain("可调 2 / 全部 3");
145-
expect(text).toContain("可移动任意非 MATCH 规则");
147+
expect(collectText(header.props.badge)).toContain("可调 1 / 全部 3");
148+
expect(text).toContain("默认只能调整自定义规则顺序。");
146149
expect(text).toContain("系统 GEO");
147150
expect(text).toContain("自定义规则");
148151
expect(text).toContain("no-resolve");
149-
expect(orderInputs.map((input) => input.props.disabled)).toEqual([false, false, true]);
152+
expect(switchElement.props.disabled).toBe(false);
153+
expect(orderInputs.map((input) => input.props.disabled)).toEqual([true, false, true]);
150154
expect(mocks.buildGeneratedRuleEntries).toHaveBeenCalledWith(
151155
expect.objectContaining({
152156
enabledModules: ["core"],
@@ -179,7 +183,7 @@ describe("RulesManagementSection", () => {
179183
];
180184

181185
const defaultTree = renderSection();
182-
const allRulesTree = renderSection();
186+
const allRulesTree = renderSection({ allRulesOrderEditingEnabled: true });
183187
const detail = collectElements(
184188
defaultTree,
185189
(element) =>
@@ -260,7 +264,7 @@ describe("RulesManagementSection", () => {
260264
},
261265
{
262266
key: "module:education:scholar",
263-
editable: true,
267+
editable: false,
264268
summary: "Scholar",
265269
sourceLabel: "📚 教育学术",
266270
target: "💬 自定义1",
@@ -287,6 +291,33 @@ describe("RulesManagementSection", () => {
287291
expect(text).not.toContain("自定义分组 ·");
288292
});
289293

294+
it("confirms before enabling all-rules order mode and disables without confirmation", async () => {
295+
const switchElement = collectElements(renderSection(), (element) => Boolean((element.props as any).onCheckedChange))[0];
296+
297+
mocks.confirmDialog.mockResolvedValueOnce(false);
298+
await switchElement.props.onCheckedChange(true);
299+
expect(mocks.store.setAllRulesOrderEditingEnabled).not.toHaveBeenCalled();
300+
301+
mocks.confirmDialog.mockResolvedValueOnce(true);
302+
await switchElement.props.onCheckedChange(true);
303+
expect(mocks.confirmDialog).toHaveBeenCalledWith(
304+
expect.objectContaining({
305+
title: "开启“调整所有规则顺序”?",
306+
cancelText: "保持默认",
307+
confirmText: "继续开启",
308+
variant: "warning",
309+
})
310+
);
311+
expect(mocks.store.setAllRulesOrderEditingEnabled).toHaveBeenCalledWith(true);
312+
313+
const enabledSwitch = collectElements(
314+
renderSection({ allRulesOrderEditingEnabled: true }),
315+
(element) => Boolean((element.props as any).onCheckedChange)
316+
)[0];
317+
await enabledSwitch.props.onCheckedChange(false);
318+
expect(mocks.store.setAllRulesOrderEditingEnabled).toHaveBeenCalledWith(false);
319+
});
320+
290321
it("moves editable rules by buttons, absolute order input, blur, and escape cleanup", () => {
291322
stateMock.value = { "custom:one": "1" };
292323
const tree = renderSection();
@@ -327,6 +358,8 @@ describe("RulesManagementSection", () => {
327358
experimentalCnUseCnRuleSet: false,
328359
ruleOrder: [],
329360
setRuleOrder: vi.fn(),
361+
allRulesOrderEditingEnabled: false,
362+
setAllRulesOrderEditingEnabled: vi.fn(),
330363
};
331364
const tree = RulesManagementSection({ isExpanded: false, onToggle: vi.fn() });
332365
const header = collectElements(tree, (element) => element.props.title === "规则管理")[0];

packages/ui/src/product/converter/advanced-mode/sections/rules-management-section.tsx

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
import * as React from "react";
44
import { ArrowDown, ArrowUp, ListOrdered } from "lucide-react";
55
import { Badge } from "@subboost/ui/components/ui/badge";
6+
import { confirmDialog } from "@subboost/ui/components/ui/confirm-dialog";
67
import { Input } from "@subboost/ui/components/ui/input";
8+
import { Switch } from "@subboost/ui/components/ui/switch";
79
import {
810
buildGeneratedRuleEntries,
911
type GeneratedRuleEntry,
@@ -47,8 +49,11 @@ export function RulesManagementSection({
4749
experimentalCnUseCnRuleSet,
4850
ruleOrder,
4951
setRuleOrder,
52+
allRulesOrderEditingEnabled,
53+
setAllRulesOrderEditingEnabled,
5054
} = useConfigStore();
5155
const [orderDrafts, setOrderDrafts] = React.useState<Record<string, string>>({});
56+
const allRulesMode = allRulesOrderEditingEnabled;
5257

5358
const entries = React.useMemo(
5459
() =>
@@ -79,7 +84,8 @@ export function RulesManagementSection({
7984
[entries]
8085
);
8186
const preMatchKeys = React.useMemo(() => preMatchEntries.map((entry) => entry.key), [preMatchEntries]);
82-
const editableKeys = preMatchKeys;
87+
const editableEntries = React.useMemo(() => entries.filter((entry) => entry.editable), [entries]);
88+
const editableKeys = React.useMemo(() => editableEntries.map((entry) => entry.key), [editableEntries]);
8389

8490
const applyRuleOrder = React.useCallback(
8591
(nextRuleOrder: string[]) => {
@@ -123,6 +129,36 @@ export function RulesManagementSection({
123129
[applyRuleOrder, preMatchEntries.length, preMatchKeys]
124130
);
125131

132+
const handleToggleAllRulesMode = React.useCallback(
133+
async (checked: boolean) => {
134+
if (!checked) {
135+
setAllRulesOrderEditingEnabled(false);
136+
return;
137+
}
138+
139+
const ok = await confirmDialog({
140+
title: "开启“调整所有规则顺序”?",
141+
description: (
142+
<span className="block pt-2">
143+
<span className="block rounded-xl border border-amber-500/20 bg-amber-500/10 px-3 py-2 leading-6 text-amber-100/90">
144+
<span className="font-medium text-amber-200">警告:</span>
145+
开启后,你可以移动任意规则到任意位置,这会改变分流优先级与命中结果。
146+
</span>
147+
<span className="mt-3 block leading-6 text-white/65">
148+
如果你不知道调整规则顺序的影响,请不要动它。
149+
</span>
150+
</span>
151+
),
152+
cancelText: "保持默认",
153+
confirmText: "继续开启",
154+
variant: "warning",
155+
});
156+
if (!ok) return;
157+
setAllRulesOrderEditingEnabled(true);
158+
},
159+
[setAllRulesOrderEditingEnabled]
160+
);
161+
126162
return (
127163
<div>
128164
<SectionHeader
@@ -132,7 +168,7 @@ export function RulesManagementSection({
132168
onToggle={onToggle}
133169
badge={
134170
<Badge variant="outline" className="ml-auto shrink-0 border-emerald-500/40 bg-emerald-500/10 text-emerald-300">
135-
可调 {preMatchEntries.length} / 全部 {entries.length}
171+
可调 {allRulesMode ? preMatchEntries.length : editableEntries.length} / 全部 {entries.length}
136172
</Badge>
137173
}
138174
/>
@@ -142,15 +178,25 @@ export function RulesManagementSection({
142178
<div className="rounded-lg border border-white/10 bg-white/5 px-3 py-2">
143179
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
144180
<div className="min-w-0 flex-[1_1_13rem] text-[11px] leading-5 text-white/60">
145-
可移动任意非 MATCH 规则;排序只决定命中优先级,目标只决定命中后进入哪个分流组。
181+
{allRulesMode
182+
? "已开启全规则排序:可移动任意规则,但 MATCH 固定最后。"
183+
: "默认只能调整自定义规则顺序。"}
184+
</div>
185+
<div className="ml-auto flex shrink-0 items-center gap-2">
186+
<span className="text-[11px] whitespace-nowrap text-white/55">调整所有规则顺序</span>
187+
<Switch
188+
checked={allRulesMode}
189+
onCheckedChange={handleToggleAllRulesMode}
190+
disabled={preMatchEntries.length <= 1}
191+
/>
146192
</div>
147193
</div>
148194
</div>
149195

150196
<div className="max-h-[460px] overflow-y-auto overflow-x-hidden rounded-lg border border-white/10 bg-black/10 pr-1 custom-scrollbar">
151197
{entries.map((entry, index) => {
152198
const fullIndex = preMatchKeys.indexOf(entry.key);
153-
const canEditOrder = entry.key !== "special:match";
199+
const canEditOrder = entry.key !== "special:match" && (allRulesMode || entry.editable);
154200
const canMoveUp = canEditOrder && fullIndex > 0;
155201
const canMoveDown = canEditOrder && fullIndex >= 0 && fullIndex < preMatchKeys.length - 1;
156202
const absoluteOrder = index + 1;

packages/ui/src/store/config-store/actions/settings-actions.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,15 @@ describe("config store settings actions", () => {
4141
expect(store.setAndGenerateConfig).toHaveBeenCalledTimes(8);
4242
expect(store.set).not.toHaveBeenCalled();
4343
});
44+
45+
it("updates all-rules order editing as UI-only state", () => {
46+
const store = createStore();
47+
const actions = createSettingsActions(store.set as any, store.get as any, store.setAndGenerateConfig as any);
48+
49+
actions.setAllRulesOrderEditingEnabled(1 as unknown as boolean);
50+
51+
expect(store.state()).toEqual({ allRulesOrderEditingEnabled: true });
52+
expect(store.set).toHaveBeenCalledWith({ allRulesOrderEditingEnabled: true });
53+
expect(store.setAndGenerateConfig).not.toHaveBeenCalled();
54+
});
4455
});

packages/ui/src/store/config-store/actions/settings-actions.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@ type SettingsActions = Pick<
1111
| "setRuleProviderBaseUrl"
1212
| "setCnIpNoResolve"
1313
| "setExperimentalCnUseCnRuleSet"
14+
| "setAllRulesOrderEditingEnabled"
1415
>;
1516

1617
export function createSettingsActions(
17-
_set: SetState,
18+
set: SetState,
1819
_get: GetState,
1920
setAndGenerateConfig: SetAndGenerateConfig
2021
): SettingsActions {
@@ -50,5 +51,9 @@ export function createSettingsActions(
5051
setExperimentalCnUseCnRuleSet: (value: boolean) => {
5152
setAndGenerateConfig(() => ({ experimentalCnUseCnRuleSet: Boolean(value) }));
5253
},
54+
55+
setAllRulesOrderEditingEnabled: (enabled: boolean) => {
56+
set({ allRulesOrderEditingEnabled: Boolean(enabled) });
57+
},
5358
};
5459
}

packages/ui/src/store/config-store/actions/template-actions.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ describe("createTemplateActions", () => {
3737
customRuleSets: [{ id: "custom-ai", name: "Custom AI", behavior: "domain", path: "geosite/custom-ai.mrs", target: "🤖 AI 服务" }],
3838
builtinRuleEdits: { "module:ai:anthropic": { enabled: false } },
3939
ruleOrder: ["module:ai:openai"],
40+
allRulesOrderEditingEnabled: true,
4041
moduleRuleEditWarningAccepted: true,
4142
});
4243

@@ -51,6 +52,7 @@ describe("createTemplateActions", () => {
5152
customRuleSets: [],
5253
builtinRuleEdits: {},
5354
ruleOrder: [],
55+
allRulesOrderEditingEnabled: false,
5456
moduleRuleEditWarningAccepted: false,
5557
});
5658

@@ -161,6 +163,7 @@ describe("createTemplateActions", () => {
161163
},
162164
],
163165
builtinRuleEdits: { "module:ai:openai": { target: "🔍 Google" } },
166+
allRulesOrderEditingEnabled: true,
164167
moduleRuleEditWarningAccepted: false,
165168
cnIpNoResolve: false,
166169
experimentalCnUseCnRuleSet: true,

packages/ui/src/store/config-store/actions/template-actions.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { getBuiltinTemplateId } from "@subboost/core/templates/builtin";
22
import { TEMPLATES } from "@subboost/core/templates";
33
import { ensureCustomRulesHaveIds } from "@subboost/core/rules/custom-rule-utils";
4-
import { normalizePersistedRuleOrder } from "@subboost/core/generator/rules";
4+
import { hasFullRuleOrderKeys, normalizePersistedRuleOrder } from "@subboost/core/generator/rules";
55
import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-groups";
66
import { normalizeRuleModelFromConfig } from "@subboost/core/rules/rule-model";
77
import type { ConfigActions, SubBoostTemplateConfig } from "../definitions";
@@ -52,6 +52,7 @@ export function createTemplateActions(
5252
customRuleSets: [],
5353
builtinRuleEdits: {},
5454
ruleOrder: [],
55+
allRulesOrderEditingEnabled: false,
5556
moduleRuleEditWarningAccepted: false,
5657
}));
5758
},
@@ -144,6 +145,7 @@ export function createTemplateActions(
144145
ruleOrder: config.ruleOrder,
145146
})
146147
: state.ruleOrder;
148+
const legacyAllRulesOrderEditingEnabled = (config as Record<string, unknown>).allRulesOrderEditingEnabled;
147149

148150
return {
149151
// 不触碰 nodes/sources:模板只描述“生成策略”,节点仍由用户导入
@@ -159,6 +161,12 @@ export function createTemplateActions(
159161
moduleRuleEditWarningAccepted: false,
160162
customRules: nextCustomRules,
161163
ruleOrder: nextRuleOrder,
164+
allRulesOrderEditingEnabled:
165+
typeof legacyAllRulesOrderEditingEnabled === "boolean"
166+
? legacyAllRulesOrderEditingEnabled
167+
: shouldRefreshRuleOrder
168+
? hasFullRuleOrderKeys(nextRuleOrder)
169+
: state.allRulesOrderEditingEnabled,
162170
cnIpNoResolve:
163171
typeof config.cnIpNoResolve === "boolean" ? config.cnIpNoResolve : state.cnIpNoResolve,
164172
experimentalCnUseCnRuleSet:

packages/ui/src/store/config-store/auth-handoff.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ function meaningfulState(overrides: Record<string, unknown> = {}) {
6262
proxyGroupNameOverrides: { ai: "Labs" },
6363
proxyGroupOrder: ["module:ai"],
6464
ruleOrder: ["module:ai:openai"],
65+
allRulesOrderEditingEnabled: true,
6566
moduleRuleEditWarningAccepted: true,
6667
appliedTemplateId: "template-1",
6768
template: "full",
@@ -154,6 +155,7 @@ describe("auth config handoff", () => {
154155
proxyGroupNameOverrides: { ai: "Labs" },
155156
proxyGroupOrder: ["module:ai"],
156157
ruleOrder: ["module:ai:openai"],
158+
allRulesOrderEditingEnabled: true,
157159
moduleRuleEditWarningAccepted: true,
158160
appliedTemplateId: "template-1",
159161
dnsYaml: "dns: {}",
@@ -286,6 +288,7 @@ describe("auth config handoff", () => {
286288
dialerProxyGroups: [{ id: "dialer" }],
287289
proxyGroupOrder: ["module:ai"],
288290
ruleOrder: ["rule"],
291+
allRulesOrderEditingEnabled: true,
289292
moduleRuleEditWarningAccepted: false,
290293
appliedTemplateId: null,
291294
dnsYaml: "dns: {}",

packages/ui/src/store/config-store/auth-handoff.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ function hasMeaningfulConfig(state: ConfigState): boolean {
119119
hasRecordEntries(state.proxyGroupNameOverrides) ||
120120
state.proxyGroupOrder.length > 0 ||
121121
state.ruleOrder.length > 0 ||
122+
state.allRulesOrderEditingEnabled !== initialState.allRulesOrderEditingEnabled ||
122123
state.moduleRuleEditWarningAccepted !== initialState.moduleRuleEditWarningAccepted ||
123124
state.appliedTemplateId !== initialState.appliedTemplateId ||
124125
state.template !== initialState.template ||
@@ -154,6 +155,7 @@ function buildHandoffState(state: ConfigState): Partial<ConfigState> {
154155
proxyGroupNameOverrides: state.proxyGroupNameOverrides,
155156
proxyGroupOrder: state.proxyGroupOrder,
156157
ruleOrder: state.ruleOrder,
158+
allRulesOrderEditingEnabled: state.allRulesOrderEditingEnabled,
157159
moduleRuleEditWarningAccepted: state.moduleRuleEditWarningAccepted,
158160
appliedTemplateId: state.appliedTemplateId,
159161
dnsYaml: state.dnsYaml,
@@ -209,6 +211,7 @@ function normalizeHandoffState(raw: unknown): Partial<ConfigState> | null {
209211
if (proxyGroupOrder) out.proxyGroupOrder = proxyGroupOrder;
210212
const ruleOrder = stringArray(raw.ruleOrder);
211213
if (ruleOrder) out.ruleOrder = ruleOrder;
214+
if (typeof raw.allRulesOrderEditingEnabled === "boolean") out.allRulesOrderEditingEnabled = raw.allRulesOrderEditingEnabled;
212215
if (typeof raw.moduleRuleEditWarningAccepted === "boolean") out.moduleRuleEditWarningAccepted = raw.moduleRuleEditWarningAccepted;
213216
if (typeof raw.appliedTemplateId === "string" || raw.appliedTemplateId === null) {
214217
out.appliedTemplateId = raw.appliedTemplateId;

0 commit comments

Comments
 (0)