feat(browser): 浏览器插件二级设置页(代理 + 登录态导入)

This commit is contained in:
Rick Ye
2026-09-11 17:14:36 +08:00
parent b6e6031415
commit 4eb78ebc48
5 changed files with 317 additions and 250 deletions
-2
View File
@@ -377,8 +377,6 @@ export default function App(): JSX.Element {
onCloseTab={browser.closeTab} onCloseTab={browser.closeTab}
onCloseAllTabs={browser.closeBrowser} onCloseAllTabs={browser.closeBrowser}
onAcknowledgeRisk={browser.acknowledgeRisk} onAcknowledgeRisk={browser.acknowledgeRisk}
onDetectSources={browser.detectSources}
onImportProfile={browser.importProfile}
/> />
) )
} }
@@ -37,6 +37,8 @@ import type { PluginSettingsSchema, PluginSettingsSchemas, PluginSettingValues,
import { THEME_MODES, THEME_PRESETS, applyThemeColor } from '../lib/theme' import { THEME_MODES, THEME_PRESETS, applyThemeColor } from '../lib/theme'
import { usePluginList } from '../plugins/store' import { usePluginList } from '../plugins/store'
import { PluginIconValue } from '../plugins/plugin-icons' import { PluginIconValue } from '../plugins/plugin-icons'
import { BrowserProxyForm } from './browser/BrowserProxyForm'
import { BrowserImportForm } from './browser/BrowserImportForm'
type PluginSettingsTab = `plugin:${string}` type PluginSettingsTab = `plugin:${string}`
type Tab = 'general' | 'model' | 'prompt' | 'plugins' | 'skills' | 'commands' | PluginSettingsTab type Tab = 'general' | 'model' | 'prompt' | 'plugins' | 'skills' | 'commands' | PluginSettingsTab
@@ -855,7 +857,8 @@ function PluginsPanel({
(a.manifest.order ?? 100) - (b.manifest.order ?? 100) || a.manifest.id.localeCompare(b.manifest.id) (a.manifest.order ?? 100) - (b.manifest.order ?? 100) || a.manifest.id.localeCompare(b.manifest.id)
) )
.map((p) => { .map((p) => {
const openSettings = p.enabled && (p.hasSettings || Boolean(p.storageSchema)) const openSettings =
p.enabled && (p.hasSettings || Boolean(p.storageSchema) || p.manifest.id === 'browser')
return ( return (
<div <div
key={p.manifest.id} key={p.manifest.id}
@@ -1659,6 +1662,25 @@ export function SettingsModal({
{tab.startsWith('plugin:') && {tab.startsWith('plugin:') &&
(() => { (() => {
const pid = tab.slice(7) const pid = tab.slice(7)
// 内置浏览器:专属二级页(代理设置 + 登录态导入),不走向声明式设置表单
if (pid === 'browser') {
return (
<div className="flex h-full min-h-0 flex-col gap-4 overflow-y-auto pb-2 pr-1">
<section className="shrink-0 rounded-card border border-line">
<div className="border-b border-line px-3 py-2 text-[12px] font-medium text-ink">代理设置</div>
<div className="p-3">
<BrowserProxyForm />
</div>
</section>
<section className="shrink-0 rounded-card border border-line">
<div className="border-b border-line px-3 py-2 text-[12px] font-medium text-ink">导入本机浏览器登录态</div>
<div className="p-3">
<BrowserImportForm />
</div>
</section>
</div>
)
}
const schema = settingSchemas[pid] const schema = settingSchemas[pid]
const info = plugins.find((p) => p.manifest.id === pid) const info = plugins.find((p) => p.manifest.id === pid)
const hasForm = Boolean(schema && schema.items.length > 0) const hasForm = Boolean(schema && schema.items.length > 0)
@@ -0,0 +1,143 @@
/**
* 本机浏览器登录态导入表单(面板浮层与设置页二级页共用)。
* 选择本机浏览器(Chrome/Edge/Brave/Chromium)的一个 Profile,
* 把各站点登录态(Cookie / Local Storage)迁移到受管浏览器分区。
* 自含数据源:直接调用 window.api(browserDetectSources / browserImportProfile)。
*/
import { useEffect, useState, type JSX } from 'react'
import { LuChevronDown } from 'react-icons/lu'
import type { BrowserImportResult, BrowserSource, BrowserUiState } from '@shared/types'
export function BrowserImportForm(): JSX.Element {
const [state, setState] = useState<BrowserUiState | null>(null)
const [sources, setSources] = useState<BrowserSource[] | null>(null)
const [selected, setSelected] = useState<{ sourceId: string; profileDir: string } | null>(null)
const [busy, setBusy] = useState(false)
const [result, setResult] = useState<BrowserImportResult | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let alive = true
window.api
.browserGetState()
.then((s) => {
if (alive) setState(s)
})
.catch(() => undefined)
const off = window.api.onBrowserEvent((event) => {
if (event.type === 'state' && alive) setState(event.state)
})
return () => {
alive = false
off()
}
}, [])
useEffect(() => {
let alive = true
window.api
.browserDetectSources()
.then((list) => {
if (!alive) return
setSources(list)
const first = list[0]
if (first?.profiles[0]) setSelected({ sourceId: first.id, profileDir: first.profiles[0].dir })
})
.catch((err) => {
if (alive) setError(err instanceof Error ? err.message : String(err))
})
return () => {
alive = false
}
}, [])
const runImport = (): void => {
if (!selected) return
setBusy(true)
setError(null)
setResult(null)
window.api
.browserImportProfile(selected.sourceId, selected.profileDir)
.then(setResult)
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
.finally(() => setBusy(false))
}
return (
<div className="text-[12px] text-ink-2">
<p className="mb-3 leading-relaxed text-ink-3">
选择本机浏览器的一个配置(Profile),把其中各网站的登录态(Cookie / Local Storage)导入到内置受管浏览器。
导入前请<span className="font-medium text-ink-2">完全退出</span>该浏览器,并关闭本面板的所有标签页。
</p>
{state?.imported && (
<div className="mb-3 rounded border border-line bg-inset px-2.5 py-2 text-[11.5px] text-ink-3">
当前导入来源:{state.imported.browser} · {state.imported.profile}
{state.imported.cookies ? '(含 Cookie)' : ''}
</div>
)}
{sources === null && !error && <p className="py-6 text-center text-ink-3">正在检测本机浏览器…</p>}
{sources?.length === 0 && (
<p className="py-6 text-center text-ink-3">未检测到可导入的本机浏览器(支持 Chrome / Edge / Brave / Chromium)</p>
)}
<div className="flex flex-col gap-2">
{sources?.map((source) => (
<div key={source.id} className="rounded border border-line">
<div className="flex items-center gap-1.5 px-2.5 py-2 text-[12px] font-medium text-ink-2">
<LuChevronDown size={12} className="text-ink-3" />
{source.name}
<span className="text-[10.5px] font-normal text-ink-3">({source.profiles.length} 个配置)</span>
</div>
<div className="flex flex-col gap-1 px-2 pb-2">
{source.profiles.map((profile) => {
const active = selected?.sourceId === source.id && selected.profileDir === profile.dir
return (
<button
key={profile.dir}
type="button"
onClick={() => setSelected({ sourceId: source.id, profileDir: profile.dir })}
className={`flex items-center justify-between rounded px-2.5 py-1.5 text-left text-[11.5px] transition-colors ${
active ? 'bg-accent-tint text-accent-ink' : 'text-ink-2 hover:bg-hover'
}`}
>
<span className="min-w-0 truncate">
{profile.name}
<span className="ml-1.5 text-[10.5px] text-ink-3">{profile.dir}</span>
</span>
{active && <span className="shrink-0 text-[10px]">已选择</span>}
</button>
)
})}
</div>
</div>
))}
</div>
{error && (
<div className="mt-3 rounded border border-red/40 bg-red-tint px-2.5 py-2 text-[11.5px] text-red">{error}</div>
)}
{result && (
<div
className={`mt-3 rounded border px-2.5 py-2 text-[11.5px] leading-relaxed ${
result.ok ? 'border-green-200 bg-green-tint text-green' : 'border-red/40 bg-red-tint text-red'
}`}
>
{result.message}
</div>
)}
<div className="mt-3 flex items-center justify-end">
<button
type="button"
disabled={!selected || busy}
onClick={runImport}
className="rounded bg-accent px-3.5 py-1.5 text-[12px] font-medium text-page transition-opacity hover:opacity-90 disabled:opacity-40"
>
{busy ? '导入中…' : '导入登录态'}
</button>
</div>
</div>
)
}
@@ -11,8 +11,10 @@ import {
LuRotateCw, LuRotateCw,
LuX LuX
} from 'react-icons/lu' } from 'react-icons/lu'
import type { BrowserImportResult, BrowserProxySettings, BrowserSource, BrowserUiState } from '@shared/types' import type { BrowserUiState } from '@shared/types'
import { BrowserSlot } from './BrowserSlot' import { BrowserSlot } from './BrowserSlot'
import { BrowserImportForm } from './BrowserImportForm'
import { BrowserProxyForm } from './BrowserProxyForm'
/** /**
* 内置受管浏览器的「chrome」部分(页签栏/地址栏/工具条/导入浮层)。 * 内置受管浏览器的「chrome」部分(页签栏/地址栏/工具条/导入浮层)。
@@ -36,8 +38,6 @@ interface BrowserPanelProps {
/** 关闭全部页面(销毁各标签的 WebContentsView;分区会话/登录态保留) */ /** 关闭全部页面(销毁各标签的 WebContentsView;分区会话/登录态保留) */
onCloseAllTabs: () => void onCloseAllTabs: () => void
onAcknowledgeRisk: () => void onAcknowledgeRisk: () => void
onDetectSources: () => Promise<BrowserSource[]>
onImportProfile: (sourceId: string, profileDir: string) => Promise<BrowserImportResult>
} }
export function BrowserPanel(props: BrowserPanelProps): JSX.Element { export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
@@ -423,60 +423,18 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
{/* 配置导入浮层(覆盖面板,出现时原生视图已隐藏) */} {/* 配置导入浮层(覆盖面板,出现时原生视图已隐藏) */}
{importOpen && ( {importOpen && (
<ImportOverlay <ImportOverlay onClose={() => setImportOpen(false)} />
state={state}
onClose={() => setImportOpen(false)}
onDetectSources={props.onDetectSources}
onImportProfile={props.onImportProfile}
/>
)} )}
{/* 代理设置浮层(覆盖面板,出现时原生视图已隐藏) */} {/* 代理设置浮层(覆盖面板,出现时原生视图已隐藏) */}
{proxyOpen && <ProxyOverlay state={state} onClose={() => setProxyOpen(false)} />} {proxyOpen && <ProxyOverlay onClose={() => setProxyOpen(false)} />}
</div> </div>
) )
} }
// ---- 配置导入浮层 ---- // ---- 浮层薄壳:标题栏 + 关闭;表单本体在共用组件(设置页二级页同样复用) ----
function ImportOverlay({
state,
onClose,
onDetectSources,
onImportProfile
}: {
state: BrowserUiState | null
onClose: () => void
onDetectSources: () => Promise<BrowserSource[]>
onImportProfile: (sourceId: string, profileDir: string) => Promise<BrowserImportResult>
}): JSX.Element {
const [sources, setSources] = useState<BrowserSource[] | null>(null)
const [selected, setSelected] = useState<{ sourceId: string; profileDir: string } | null>(null)
const [busy, setBusy] = useState(false)
const [result, setResult] = useState<BrowserImportResult | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
onDetectSources()
.then((list) => {
setSources(list)
const first = list[0]
if (first?.profiles[0]) setSelected({ sourceId: first.id, profileDir: first.profiles[0].dir })
})
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
}, [onDetectSources])
const runImport = (): void => {
if (!selected) return
setBusy(true)
setError(null)
setResult(null)
onImportProfile(selected.sourceId, selected.profileDir)
.then(setResult)
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
.finally(() => setBusy(false))
}
function ImportOverlay({ onClose }: { onClose: () => void }): JSX.Element {
return ( return (
<div className="absolute inset-0 z-20 flex flex-col bg-surface/95 backdrop-blur-[2px]"> <div className="absolute inset-0 z-20 flex flex-col bg-surface/95 backdrop-blur-[2px]">
<div className="flex h-10 shrink-0 items-center justify-between border-b border-line px-3"> <div className="flex h-10 shrink-0 items-center justify-between border-b border-line px-3">
@@ -490,134 +448,14 @@ function ImportOverlay({
<LuX size={13} /> <LuX size={13} />
</button> </button>
</div> </div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<div className="min-h-0 flex-1 overflow-y-auto p-3 text-[12px] text-ink-2"> <BrowserImportForm />
<p className="mb-3 leading-relaxed text-ink-3">
选择本机浏览器的一个配置(Profile),把其中各网站的登录态(Cookie / Local Storage)导入到内置受管浏览器。
导入前请<span className="font-medium text-ink-2">完全退出</span>该浏览器,并关闭本面板的所有标签页。
</p>
{state?.imported && (
<div className="mb-3 rounded border border-line bg-inset px-2.5 py-2 text-[11.5px] text-ink-3">
当前导入来源:{state.imported.browser} · {state.imported.profile}
{state.imported.cookies ? '(含 Cookie)' : ''}
</div>
)}
{sources === null && !error && <p className="py-6 text-center text-ink-3">正在检测本机浏览器…</p>}
{sources?.length === 0 && <p className="py-6 text-center text-ink-3">未检测到可导入的本机浏览器(支持 Chrome / Edge / Brave / Chromium)</p>}
<div className="flex flex-col gap-2">
{sources?.map((source) => (
<div key={source.id} className="rounded border border-line">
<div className="flex items-center gap-1.5 px-2.5 py-2 text-[12px] font-medium text-ink-2">
<LuChevronDown size={12} className="text-ink-3" />
{source.name}
<span className="text-[10.5px] font-normal text-ink-3">({source.profiles.length} 个配置)</span>
</div>
<div className="flex flex-col gap-1 px-2 pb-2">
{source.profiles.map((profile) => {
const active = selected?.sourceId === source.id && selected.profileDir === profile.dir
return (
<button
key={profile.dir}
type="button"
onClick={() => setSelected({ sourceId: source.id, profileDir: profile.dir })}
className={`flex items-center justify-between rounded px-2.5 py-1.5 text-left text-[11.5px] transition-colors ${
active ? 'bg-accent-tint text-accent-ink' : 'text-ink-2 hover:bg-hover'
}`}
>
<span className="min-w-0 truncate">
{profile.name}
<span className="ml-1.5 text-[10.5px] text-ink-3">{profile.dir}</span>
</span>
{active && <span className="shrink-0 text-[10px]">已选择</span>}
</button>
)
})}
</div>
</div>
))}
</div>
{error && (
<div className="mt-3 rounded border border-red/40 bg-red-tint px-2.5 py-2 text-[11.5px] text-red">{error}</div>
)}
{result && (
<div
className={`mt-3 rounded border px-2.5 py-2 text-[11.5px] leading-relaxed ${
result.ok ? 'border-green-200 bg-green-tint text-green' : 'border-red/40 bg-red-tint text-red'
}`}
>
{result.message}
</div>
)}
</div>
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-line px-3 py-2.5">
<button
type="button"
onClick={onClose}
className="rounded px-3 py-1.5 text-[12px] text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
>
关闭
</button>
<button
type="button"
disabled={!selected || busy}
onClick={runImport}
className="rounded bg-accent px-3.5 py-1.5 text-[12px] font-medium text-page transition-opacity hover:opacity-90 disabled:opacity-40"
>
{busy ? '导入中…' : '导入登录态'}
</button>
</div> </div>
</div> </div>
) )
} }
// ---- 代理设置浮层 ---- function ProxyOverlay({ onClose }: { onClose: () => void }): JSX.Element {
const PROXY_MODES: Array<{ id: BrowserProxySettings['mode']; label: string }> = [
{ id: 'system', label: '跟随系统' },
{ id: 'direct', label: '直连' },
{ id: 'fixed', label: '手动代理' }
]
function ProxyOverlay({ state, onClose }: { state: BrowserUiState | null; onClose: () => void }): JSX.Element {
const current = state?.proxy ?? { mode: 'system' as const }
const [mode, setMode] = useState<BrowserProxySettings['mode']>(current.mode)
const [rules, setRules] = useState(current.mode === 'fixed' ? (current.rules ?? '') : '')
const [bypass, setBypass] = useState(current.bypass ?? '')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [saved, setSaved] = useState(false)
// 主进程广播回来(保存成功/其他入口改动)→ 表单跟随当前生效值
useEffect(() => {
setMode(current.mode)
setRules(current.mode === 'fixed' ? (current.rules ?? '') : '')
setBypass(current.bypass ?? '')
}, [current.mode, current.rules, current.bypass])
const save = (): void => {
setBusy(true)
setError(null)
setSaved(false)
window.api
.browserSetProxy({ mode, rules: rules.trim(), bypass: bypass.trim() })
.then((p) => {
setMode(p.mode)
setRules(p.rules ?? '')
setBypass(p.bypass ?? '')
setSaved(true)
})
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
.finally(() => setBusy(false))
}
const inputCls =
'w-full rounded border border-line bg-inset px-2.5 py-1.5 text-[12px] text-ink outline-none placeholder:text-ink-3 focus:border-accent focus:bg-surface'
return ( return (
<div className="absolute inset-0 z-20 flex flex-col bg-surface/95 backdrop-blur-[2px]"> <div className="absolute inset-0 z-20 flex flex-col bg-surface/95 backdrop-blur-[2px]">
<div className="flex h-10 shrink-0 items-center justify-between border-b border-line px-3"> <div className="flex h-10 shrink-0 items-center justify-between border-b border-line px-3">
@@ -631,81 +469,8 @@ function ProxyOverlay({ state, onClose }: { state: BrowserUiState | null; onClos
<LuX size={13} /> <LuX size={13} />
</button> </button>
</div> </div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<div className="min-h-0 flex-1 overflow-y-auto p-3 text-[12px] text-ink-2"> <BrowserProxyForm />
<p className="mb-3 leading-relaxed text-ink-3">
代理只作用于内置受管浏览器(不影响应用其他部分与系统设置),保存后立即生效。
</p>
<div className="flex w-fit items-center rounded-control border border-line bg-hover p-0.5">
{PROXY_MODES.map((m) => (
<button
key={m.id}
type="button"
onClick={() => setMode(m.id)}
className={`h-7 rounded-[6px] px-3 text-[12px] transition-colors ${
mode === m.id ? 'bg-surface font-medium text-ink shadow-card' : 'text-ink-3 hover:text-ink-2'
}`}
>
{m.label}
</button>
))}
</div>
{mode === 'fixed' && (
<div className="mt-3 flex flex-col gap-2.5">
<label className="flex flex-col gap-1">
<span className="text-[11px] text-ink-3">代理规则(多协议用分号分隔)</span>
<input
value={rules}
onChange={(e) => setRules(e.target.value)}
placeholder="127.0.0.1:7890 或 http=127.0.0.1:7890;https=127.0.0.1:7890"
spellCheck={false}
className={inputCls}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-[11px] text-ink-3">绕过列表(逗号分隔,可留空)</span>
<input
value={bypass}
onChange={(e) => setBypass(e.target.value)}
placeholder="<local>,*.example.com,127.0.0.1"
spellCheck={false}
className={inputCls}
/>
</label>
<p className="text-[10.5px] leading-4 text-ink-3">
规则语法与 Chromium 一致:host:port 应用于全部协议;也可按协议指定(http=…;https=…;socks=…)。
</p>
</div>
)}
{error && (
<div className="mt-3 rounded border border-red/40 bg-red-tint px-2.5 py-2 text-[11.5px] text-red">{error}</div>
)}
{saved && !error && (
<div className="mt-3 rounded border border-green-200 bg-green-tint px-2.5 py-2 text-[11.5px] text-green">
已保存并立即生效(在途连接已重置)。
</div>
)}
</div>
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-line px-3 py-2.5">
<button
type="button"
onClick={onClose}
className="rounded px-3 py-1.5 text-[12px] text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
>
关闭
</button>
<button
type="button"
disabled={busy || (mode === 'fixed' && !rules.trim())}
onClick={save}
className="rounded bg-accent px-3.5 py-1.5 text-[12px] font-medium text-page transition-opacity hover:opacity-90 disabled:opacity-40"
>
{busy ? '应用中…' : '保存并应用'}
</button>
</div> </div>
</div> </div>
) )
@@ -0,0 +1,139 @@
/**
* 受管浏览器代理设置表单(面板浮层与设置页二级页共用)。
* 只作用于 persist:aso-browser 分区;保存即生效(主进程 setProxy + 断开在途连接)。
* 自含数据源:自行订阅 browser 事件流获取当前生效设置。
*/
import { useEffect, useState, type JSX } from 'react'
import type { BrowserProxySettings, BrowserUiState } from '@shared/types'
const PROXY_MODES: Array<{ id: BrowserProxySettings['mode']; label: string }> = [
{ id: 'system', label: '跟随系统' },
{ id: 'direct', label: '直连' },
{ id: 'fixed', label: '手动代理' }
]
export function BrowserProxyForm(): JSX.Element {
const [state, setState] = useState<BrowserUiState | null>(null)
const [mode, setMode] = useState<BrowserProxySettings['mode']>('system')
const [rules, setRules] = useState('')
const [bypass, setBypass] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [saved, setSaved] = useState(false)
// 当前生效设置:首查 + 事件跟随(主进程广播,保存成功后表单同步)
useEffect(() => {
let alive = true
window.api
.browserGetState()
.then((s) => {
if (alive) setState(s)
})
.catch(() => undefined)
const off = window.api.onBrowserEvent((event) => {
if (event.type === 'state' && alive) setState(event.state)
})
return () => {
alive = false
off()
}
}, [])
const current = state?.proxy
useEffect(() => {
if (!current) return
setMode(current.mode)
setRules(current.mode === 'fixed' ? (current.rules ?? '') : '')
setBypass(current.bypass ?? '')
}, [current?.mode, current?.rules, current?.bypass])
const save = (): void => {
setBusy(true)
setError(null)
setSaved(false)
window.api
.browserSetProxy({ mode, rules: rules.trim(), bypass: bypass.trim() })
.then((p) => {
setMode(p.mode)
setRules(p.rules ?? '')
setBypass(p.bypass ?? '')
setSaved(true)
})
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
.finally(() => setBusy(false))
}
const inputCls =
'w-full rounded border border-line bg-inset px-2.5 py-1.5 text-[12px] text-ink outline-none placeholder:text-ink-3 focus:border-accent focus:bg-surface'
return (
<div className="text-[12px] text-ink-2">
<p className="mb-3 leading-relaxed text-ink-3">
代理只作用于内置受管浏览器(不影响应用其他部分与系统设置),保存后立即生效。
</p>
<div className="flex w-fit items-center rounded-control border border-line bg-hover p-0.5">
{PROXY_MODES.map((m) => (
<button
key={m.id}
type="button"
onClick={() => setMode(m.id)}
className={`h-7 rounded-[6px] px-3 text-[12px] transition-colors ${
mode === m.id ? 'bg-surface font-medium text-ink shadow-card' : 'text-ink-3 hover:text-ink-2'
}`}
>
{m.label}
</button>
))}
</div>
{mode === 'fixed' && (
<div className="mt-3 flex flex-col gap-2.5">
<label className="flex flex-col gap-1">
<span className="text-[11px] text-ink-3">代理规则(多协议用分号分隔)</span>
<input
value={rules}
onChange={(e) => setRules(e.target.value)}
placeholder="127.0.0.1:7890 或 http=127.0.0.1:7890;https=127.0.0.1:7890"
spellCheck={false}
className={inputCls}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-[11px] text-ink-3">绕过列表(逗号分隔,可留空)</span>
<input
value={bypass}
onChange={(e) => setBypass(e.target.value)}
placeholder="<local>,*.example.com,127.0.0.1"
spellCheck={false}
className={inputCls}
/>
</label>
<p className="text-[10.5px] leading-4 text-ink-3">
规则语法与 Chromium 一致:host:port 应用于全部协议;也可按协议指定(http=…;https=…;socks=…)。
</p>
</div>
)}
{error && (
<div className="mt-3 rounded border border-red/40 bg-red-tint px-2.5 py-2 text-[11.5px] text-red">{error}</div>
)}
{saved && !error && (
<div className="mt-3 rounded border border-green-200 bg-green-tint px-2.5 py-2 text-[11.5px] text-green">
已保存并立即生效(在途连接已重置)。
</div>
)}
<div className="mt-3 flex items-center justify-end gap-2">
<button
type="button"
disabled={busy || (mode === 'fixed' && !rules.trim())}
onClick={save}
className="rounded bg-accent px-3.5 py-1.5 text-[12px] font-medium text-page transition-opacity hover:opacity-90 disabled:opacity-40"
>
{busy ? '应用中…' : '保存并应用'}
</button>
</div>
</div>
)
}