Skip to content

Commit 752bdf1

Browse files
committed
feat(frontend): add lan share whitelist card
1 parent d64d214 commit 752bdf1

3 files changed

Lines changed: 254 additions & 15 deletions

File tree

frontend/src/features/settings/SettingsPage.test.tsx

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const baseSettings = {
2222
proxy_host: "127.0.0.1",
2323
proxy_port: 6789,
2424
lan_share_enabled: false,
25+
lan_share_whitelist_enabled: false,
2526
lan_share_ip_whitelist: "",
2627
upstream_proxy_mode: "system",
2728
upstream_proxy_url: "",
@@ -115,7 +116,13 @@ describe("SettingsPage", () => {
115116
expect(screen.queryByRole("textbox", { name: "代理主机" })).not.toBeInTheDocument();
116117
expect(await screen.findByRole("switch", { name: "局域网共享" })).toBeInTheDocument();
117118
fireEvent.click(screen.getByRole("switch", { name: "局域网共享" }));
118-
fireEvent.change(screen.getByRole("textbox", { name: "IP 白名单" }), { target: { value: "192.168.1.10\n192.168.1.0/24" } });
119+
fireEvent.click(await screen.findByRole("switch", { name: "是否开启白名单" }));
120+
fireEvent.click(screen.getByRole("button", { name: "新增白名单" }));
121+
fireEvent.change(await screen.findByRole("textbox", { name: "白名单 IP" }), { target: { value: "192.168.1.10" } });
122+
fireEvent.click(screen.getByRole("button", { name: "确认白名单弹窗" }));
123+
fireEvent.click(screen.getByRole("button", { name: "新增白名单" }));
124+
fireEvent.change(await screen.findByRole("textbox", { name: "白名单 IP" }), { target: { value: "192.168.1.0/24" } });
125+
fireEvent.click(screen.getByRole("button", { name: "确认白名单弹窗" }));
119126
expect(await screen.findByRole("switch", { name: "自动故障转移开关" })).toBeInTheDocument();
120127
fireEvent.click(screen.getByRole("radio", { name: "手动指定" }));
121128
fireEvent.change(screen.getByRole("textbox", { name: "上游代理地址" }), { target: { value: "http://127.0.0.1:7890" } });
@@ -138,6 +145,7 @@ describe("SettingsPage", () => {
138145
...baseSettings,
139146
launch_at_login: true,
140147
lan_share_enabled: true,
148+
lan_share_whitelist_enabled: true,
141149
lan_share_ip_whitelist: "192.168.1.10\n192.168.1.0/24",
142150
upstream_proxy_mode: "manual",
143151
upstream_proxy_url: "http://127.0.0.1:7890",
@@ -151,6 +159,7 @@ describe("SettingsPage", () => {
151159
...baseSettings,
152160
launch_at_login: true,
153161
lan_share_enabled: true,
162+
lan_share_whitelist_enabled: true,
154163
lan_share_ip_whitelist: "192.168.1.10\n192.168.1.0/24",
155164
upstream_proxy_mode: "manual",
156165
upstream_proxy_url: "http://127.0.0.1:7890",
@@ -161,6 +170,7 @@ describe("SettingsPage", () => {
161170
...baseSettings,
162171
launch_at_login: true,
163172
lan_share_enabled: true,
173+
lan_share_whitelist_enabled: true,
164174
lan_share_ip_whitelist: "192.168.1.10\n192.168.1.0/24",
165175
upstream_proxy_mode: "manual",
166176
upstream_proxy_url: "http://127.0.0.1:7890",
@@ -169,6 +179,52 @@ describe("SettingsPage", () => {
169179
});
170180
});
171181

182+
it("shows whitelist card only after lan share is enabled and supports edit and delete with modals", async () => {
183+
const fetchMock = vi.fn((input: RequestInfo | URL) => {
184+
const url = String(input);
185+
if (url === "/ai-router/api/accounts") {
186+
return Promise.resolve(new Response(JSON.stringify([]), { status: 200, headers: { "Content-Type": "application/json" } }));
187+
}
188+
if (url === "/ai-router/api/settings/database/backups") {
189+
return Promise.resolve(new Response(JSON.stringify([]), { status: 200, headers: { "Content-Type": "application/json" } }));
190+
}
191+
return Promise.resolve(new Response(null, { status: 404 }));
192+
});
193+
vi.stubGlobal("fetch", fetchMock);
194+
vi.mocked(getAppMetadata).mockResolvedValue({
195+
name: "AI Gate",
196+
version: "0.1.0",
197+
description: "桌面代理与路由控制台",
198+
author: "GcsSloop",
199+
});
200+
vi.mocked(getRecentDesktopLogs).mockResolvedValue([]);
201+
vi.mocked(applyDesktopAppSettings).mockResolvedValue(null);
202+
203+
render(<SettingsPage initialSettings={baseSettings} language="zh-CN" t={identity} proxyEnabled={false} onSettingsChanged={vi.fn()} />);
204+
205+
fireEvent.click(await screen.findByRole("tab", { name: "代理" }));
206+
expect(screen.queryByText("白名单")).not.toBeInTheDocument();
207+
208+
fireEvent.click(screen.getByRole("switch", { name: "局域网共享" }));
209+
expect(await screen.findByText("白名单")).toBeInTheDocument();
210+
fireEvent.click(screen.getByRole("switch", { name: "是否开启白名单" }));
211+
fireEvent.click(screen.getByRole("button", { name: "新增白名单" }));
212+
fireEvent.change(await screen.findByRole("textbox", { name: "白名单 IP" }), { target: { value: "10.0.0.8" } });
213+
fireEvent.click(screen.getByRole("button", { name: "确认白名单弹窗" }));
214+
215+
fireEvent.click(screen.getByRole("button", { name: "编辑白名单 10.0.0.8" }));
216+
fireEvent.change(await screen.findByRole("textbox", { name: "白名单 IP" }), { target: { value: "10.0.0.9" } });
217+
fireEvent.click(screen.getByRole("button", { name: "确认白名单弹窗" }));
218+
expect(await screen.findByText("10.0.0.9")).toBeInTheDocument();
219+
220+
fireEvent.click(screen.getByRole("button", { name: "删除白名单 10.0.0.9" }));
221+
fireEvent.click(await screen.findByRole("button", { name: "确认删除白名单" }));
222+
await waitFor(() => {
223+
expect(screen.queryByText("10.0.0.9")).not.toBeInTheDocument();
224+
});
225+
expect(screen.getByText("当前未配置任何 IP,保存后将拒绝所有非本机局域网访问。")).toBeInTheDocument();
226+
});
227+
172228
it("supports database backup actions and about metadata", async () => {
173229
const confirmSpy = vi.spyOn(Modal, "confirm").mockImplementation((config) => {
174230
void config.onOk?.();

frontend/src/features/settings/SettingsPage.tsx

Lines changed: 165 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ import {
88
DatabaseOutlined,
99
DeleteOutlined,
1010
DesktopOutlined,
11+
EditOutlined,
1112
EyeInvisibleOutlined,
1213
FileTextOutlined,
1314
InfoCircleOutlined,
1415
MoreOutlined,
1516
PoweroffOutlined,
17+
PlusOutlined,
1618
RollbackOutlined,
1719
SaveOutlined,
1820
SwapOutlined,
@@ -49,7 +51,6 @@ import appLogo from "../../assets/aigate_1024_1024.png";
4951
import { UpdateCard } from "../updates/UpdateCard";
5052

5153
const { Text, Title } = Typography;
52-
const { TextArea } = Input;
5354

5455
type SettingsTabKey = "general" | "proxy" | "advanced" | "about";
5556

@@ -98,6 +99,20 @@ function triggerTextDownload(filename: string, content: string) {
9899
URL.revokeObjectURL(url);
99100
}
100101

102+
function parseWhitelistEntries(raw: string): string[] {
103+
return raw
104+
.split(/[\n\r,;]+/)
105+
.map((entry) => entry.trim())
106+
.filter((entry) => entry.length > 0);
107+
}
108+
109+
function stringifyWhitelistEntries(entries: string[]): string {
110+
return entries
111+
.map((entry) => entry.trim())
112+
.filter((entry) => entry.length > 0)
113+
.join("\n");
114+
}
115+
101116
function validateExchangePayload(raw: string): void {
102117
let payload: { format?: string; version?: number };
103118
try {
@@ -176,6 +191,9 @@ export function SettingsPage({
176191
const [importingSQL, setImportingSQL] = useState(false);
177192
const [importModalOpen, setImportModalOpen] = useState(false);
178193
const [importFile, setImportFile] = useState<File | null>(null);
194+
const [whitelistModalOpen, setWhitelistModalOpen] = useState(false);
195+
const [whitelistModalValue, setWhitelistModalValue] = useState("");
196+
const [editingWhitelistIndex, setEditingWhitelistIndex] = useState<number | null>(null);
179197
const [activeTab, setActiveTab] = useState<SettingsTabKey>(initialTab);
180198

181199
useEffect(() => {
@@ -185,6 +203,7 @@ export function SettingsPage({
185203
account_pricing: initialSettings.account_pricing ?? {},
186204
usage_request_timeout_seconds: initialSettings.usage_request_timeout_seconds ?? 15,
187205
lan_share_enabled: initialSettings.lan_share_enabled ?? false,
206+
lan_share_whitelist_enabled: initialSettings.lan_share_whitelist_enabled ?? false,
188207
lan_share_ip_whitelist: initialSettings.lan_share_ip_whitelist ?? "",
189208
upstream_proxy_mode: initialSettings.upstream_proxy_mode ?? "system",
190209
upstream_proxy_url: initialSettings.upstream_proxy_url ?? "",
@@ -242,6 +261,71 @@ export function SettingsPage({
242261
}));
243262
}
244263

264+
const whitelistEntries = parseWhitelistEntries(draftSettings.lan_share_ip_whitelist);
265+
const whitelistEnabled = draftSettings.lan_share_whitelist_enabled ?? false;
266+
267+
function updateWhitelistEntries(entries: string[]) {
268+
updateDraft({ lan_share_ip_whitelist: stringifyWhitelistEntries(entries) });
269+
}
270+
271+
function openCreateWhitelistModal() {
272+
setEditingWhitelistIndex(null);
273+
setWhitelistModalValue("");
274+
setWhitelistModalOpen(true);
275+
}
276+
277+
function openEditWhitelistModal(index: number) {
278+
setEditingWhitelistIndex(index);
279+
setWhitelistModalValue(whitelistEntries[index] ?? "");
280+
setWhitelistModalOpen(true);
281+
}
282+
283+
function closeWhitelistModal() {
284+
setWhitelistModalOpen(false);
285+
setWhitelistModalValue("");
286+
setEditingWhitelistIndex(null);
287+
}
288+
289+
function submitWhitelistModal() {
290+
const nextValue = whitelistModalValue.trim();
291+
if (!nextValue) {
292+
void messageApi.error(t("请输入白名单 IP 或 CIDR"));
293+
return;
294+
}
295+
296+
const nextEntries = [...whitelistEntries];
297+
const duplicateIndex = nextEntries.findIndex((entry, index) => entry === nextValue && index !== editingWhitelistIndex);
298+
if (duplicateIndex >= 0) {
299+
void messageApi.error(t("该白名单条目已存在"));
300+
return;
301+
}
302+
303+
if (editingWhitelistIndex === null) {
304+
nextEntries.push(nextValue);
305+
} else {
306+
nextEntries[editingWhitelistIndex] = nextValue;
307+
}
308+
updateWhitelistEntries(nextEntries);
309+
closeWhitelistModal();
310+
}
311+
312+
function handleDeleteWhitelistEntry(index: number) {
313+
const value = whitelistEntries[index];
314+
if (!value) {
315+
return;
316+
}
317+
Modal.confirm({
318+
title: t("删除白名单"),
319+
content: t("确认删除该白名单条目?"),
320+
okText: t("删除"),
321+
cancelText: t("取消"),
322+
okButtonProps: { danger: true, "aria-label": t("确认删除白名单") } as any,
323+
onOk: () => {
324+
updateWhitelistEntries(whitelistEntries.filter((_, itemIndex) => itemIndex !== index));
325+
},
326+
});
327+
}
328+
245329
function updateProviderPricing(providerType: string, field: "input_per_million" | "output_per_million", value: number | null) {
246330
setDraftSettings((current) => ({
247331
...current,
@@ -585,22 +669,66 @@ export function SettingsPage({
585669
className="settings-number"
586670
/>
587671
</label>
588-
<label className="settings-field">
589-
<span className="settings-field-label">{t("IP 白名单")}</span>
590-
<TextArea
591-
aria-label={t("IP 白名单")}
592-
value={draftSettings.lan_share_ip_whitelist}
593-
onChange={(event) => updateDraft({ lan_share_ip_whitelist: event.target.value })}
594-
placeholder={`192.168.1.10\n192.168.1.0/24`}
595-
autoSize={{ minRows: 4, maxRows: 6 }}
596-
/>
597-
<Text type="secondary">
598-
{t("每行一个 IP 或 CIDR。留空表示允许所有局域网来源;本机 127.0.0.1 / ::1 始终放行。")}
599-
</Text>
600-
</label>
601672
</div>
602673
</Card>
603674

675+
{draftSettings.lan_share_enabled ? (
676+
<Card className="settings-card" variant="borderless">
677+
<SectionHeader
678+
icon={<ControlOutlined />}
679+
title={t("白名单")}
680+
description={t("仅控制局域网共享的远端访问来源;本机 127.0.0.1 / ::1 始终放行。")}
681+
actions={
682+
whitelistEnabled ? (
683+
<Button icon={<PlusOutlined />} aria-label={t("新增白名单")} onClick={openCreateWhitelistModal}>
684+
{t("新增")}
685+
</Button>
686+
) : null
687+
}
688+
/>
689+
<div className="settings-stack">
690+
<ToggleRow
691+
icon={<ControlOutlined />}
692+
title={t("是否开启白名单")}
693+
description={t("关闭时允许所有局域网来源;开启后只允许列表中的 IP。")}
694+
label={t("是否开启白名单")}
695+
checked={whitelistEnabled}
696+
onChange={(checked) => updateDraft({ lan_share_whitelist_enabled: checked })}
697+
/>
698+
</div>
699+
{whitelistEnabled ? (
700+
whitelistEntries.length > 0 ? (
701+
<div className="settings-whitelist-list">
702+
{whitelistEntries.map((entry, index) => (
703+
<div key={`${entry}-${index}`} className="settings-whitelist-item">
704+
<div className="settings-whitelist-value">{entry}</div>
705+
<div className="settings-whitelist-actions">
706+
<Button
707+
type="text"
708+
icon={<EditOutlined />}
709+
aria-label={`${t("编辑白名单")} ${entry}`}
710+
onClick={() => openEditWhitelistModal(index)}
711+
/>
712+
<Button
713+
type="text"
714+
danger
715+
icon={<DeleteOutlined />}
716+
aria-label={`${t("删除白名单")} ${entry}`}
717+
onClick={() => handleDeleteWhitelistEntry(index)}
718+
/>
719+
</div>
720+
</div>
721+
))}
722+
</div>
723+
) : (
724+
<div className="settings-empty">{t("当前未配置任何 IP,保存后将拒绝所有非本机局域网访问。")}</div>
725+
)
726+
) : (
727+
<Text type="secondary">{t("关闭白名单时,所有局域网来源都可访问;已配置条目会保留但不会生效。")}</Text>
728+
)}
729+
</Card>
730+
) : null}
731+
604732
<Card className="settings-card" variant="borderless">
605733
<SectionHeader
606734
icon={<DesktopOutlined />}
@@ -986,6 +1114,29 @@ export function SettingsPage({
9861114
<Input type="file" accept=".json,application/json,text/plain" onChange={(event) => setImportFile(event.target.files?.[0] || null)} />
9871115
</div>
9881116
</Modal>
1117+
<Modal
1118+
open={whitelistModalOpen}
1119+
title={editingWhitelistIndex === null ? t("新增白名单") : t("编辑白名单")}
1120+
onCancel={closeWhitelistModal}
1121+
footer={[
1122+
<Button key="cancel" onClick={closeWhitelistModal}>
1123+
{t("取消")}
1124+
</Button>,
1125+
<Button key="ok" type="primary" aria-label={t("确认白名单弹窗")} onClick={submitWhitelistModal}>
1126+
{t("确认")}
1127+
</Button>,
1128+
]}
1129+
>
1130+
<label className="settings-field">
1131+
<span className="settings-field-label">{t("白名单 IP")}</span>
1132+
<Input
1133+
aria-label={t("白名单 IP")}
1134+
value={whitelistModalValue}
1135+
onChange={(event) => setWhitelistModalValue(event.target.value)}
1136+
placeholder="192.168.1.10 或 192.168.1.0/24"
1137+
/>
1138+
</label>
1139+
</Modal>
9891140
</div>
9901141
);
9911142
}

frontend/src/styles.css

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -696,6 +696,38 @@ html[data-theme-mode="dark"] .stats-chart-shell {
696696
margin-bottom: 28px;
697697
}
698698

699+
.settings-whitelist-list {
700+
display: grid;
701+
gap: 12px;
702+
margin-top: 18px;
703+
}
704+
705+
.settings-whitelist-item {
706+
display: flex;
707+
align-items: center;
708+
justify-content: space-between;
709+
gap: 12px;
710+
padding: 14px 16px;
711+
border-radius: 18px;
712+
border: 1px solid var(--panel-border);
713+
background: var(--panel-strong);
714+
}
715+
716+
.settings-whitelist-value {
717+
min-width: 0;
718+
font-size: 14px;
719+
font-weight: 600;
720+
color: var(--text-primary);
721+
word-break: break-all;
722+
}
723+
724+
.settings-whitelist-actions {
725+
flex: 0 0 auto;
726+
display: inline-flex;
727+
align-items: center;
728+
gap: 4px;
729+
}
730+
699731
.settings-field {
700732
display: grid;
701733
gap: 8px;

0 commit comments

Comments
 (0)