mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-26 13:11:53 +08:00
Enhance architecture documentation and update project dependencies. Introduce new features for browser recording, page callables, and transform capabilities. Improve build scripts and permissions for better functionality.
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AlertTriangle, ArrowDown, ArrowUp, CheckCircle2, CircleDot, Gauge, Plus, Route, Save, Search, Trash2, Zap,
|
||||
} from 'lucide-react';
|
||||
import { v7 as uuidv7 } from 'uuid';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Field } from '@/components/ui/field';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { request } from '@/platform/messaging/runtime';
|
||||
import type { ProxyConditionType, ProxyRule, ProxyRulePreview } from '@/types/models';
|
||||
import { CONDITION_LABELS, formatBytes, proxyProfileDetail } from './presentation';
|
||||
import type { ProxyViewProps } from './types';
|
||||
import './proxy-workspace.css';
|
||||
|
||||
const ROW_HEIGHT = 58;
|
||||
const LIST_HEIGHT = 408;
|
||||
const OVERSCAN = 4;
|
||||
|
||||
function hostFromUrl(url?: string): string {
|
||||
try { return url ? new URL(url).hostname : ''; } catch { return ''; }
|
||||
}
|
||||
|
||||
function freshRule(count: number, url?: string): ProxyRule {
|
||||
const now = Date.now();
|
||||
const hostname = hostFromUrl(url);
|
||||
return {
|
||||
id: uuidv7(),
|
||||
name: hostname ? `${hostname} 路由` : '新路由规则',
|
||||
enabled: true,
|
||||
condition: { type: hostname ? 'host_exact' : 'host_suffix', value: hostname },
|
||||
proxyProfileId: 'yakit-mitm',
|
||||
order: count,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
function conditionHint(type: ProxyConditionType): string {
|
||||
if (type === 'host_exact') return 'api.example.com';
|
||||
if (type === 'host_suffix') return 'example.com';
|
||||
if (type === 'host_wildcard') return '*.example.com';
|
||||
if (type === 'host_regex') return '(^|\\.)example\\.(com|net)$';
|
||||
if (type === 'url_prefix') return 'https://example.com/api/';
|
||||
if (type === 'url_wildcard') return '*://*.example.com/*';
|
||||
if (type === 'url_regex') return '^https://example\\.com/';
|
||||
return 'login';
|
||||
}
|
||||
|
||||
export function AutoSwitchView({ state, setState, run, busy, tab }: ProxyViewProps) {
|
||||
const rules = useMemo(() => [...state.proxyRules].sort((left, right) => left.order - right.order), [state.proxyRules]);
|
||||
const routableProfiles = useMemo(() => state.proxyProfiles.filter((profile) => ['direct', 'fixed_servers'].includes(profile.kind)), [state.proxyProfiles]);
|
||||
const [draft, setDraft] = useState<ProxyRule>(() => freshRule(state.proxyRules.length, tab?.url));
|
||||
const [previewUrl, setPreviewUrl] = useState(tab?.url || 'https://example.com/');
|
||||
const [preview, setPreview] = useState<ProxyRulePreview>();
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab?.url?.startsWith('http')) setPreviewUrl(tab.url);
|
||||
}, [tab?.url]);
|
||||
|
||||
const selectedExists = rules.some((rule) => rule.id === draft.id);
|
||||
const firstVisible = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN);
|
||||
const visibleCount = Math.ceil(LIST_HEIGHT / ROW_HEIGHT) + OVERSCAN * 2;
|
||||
const visibleRules = rules.slice(firstVisible, firstVisible + visibleCount);
|
||||
const enabledSources = state.proxyRuleSources.filter((source) => source.enabled && source.revision);
|
||||
const active = state.activeProxyId === 'auto';
|
||||
|
||||
const save = () => run(async () => {
|
||||
const now = Date.now();
|
||||
const next = { ...draft, updatedAt: now, createdAt: draft.createdAt || now, order: selectedExists ? draft.order : rules.length };
|
||||
const updated = await request('proxy.rule.save', next);
|
||||
setState(updated);
|
||||
setDraft(updated.proxyRules.find((rule) => rule.id === next.id) || next);
|
||||
}, '规则已保存,等待应用');
|
||||
|
||||
const reorder = (rule: ProxyRule, delta: -1 | 1) => run(async () => {
|
||||
const index = rules.findIndex((item) => item.id === rule.id);
|
||||
const target = index + delta;
|
||||
if (index < 0 || target < 0 || target >= rules.length) return;
|
||||
const ids = rules.map((item) => item.id);
|
||||
[ids[index], ids[target]] = [ids[target], ids[index]];
|
||||
setState(await request('proxy.rules.reorder', { ids }));
|
||||
});
|
||||
|
||||
const explain = () => run(async () => setPreview(await request('proxy.rules.preview', { url: previewUrl })));
|
||||
const quickRoute = (profileId: string) => run(async () => {
|
||||
if (!tab?.url) return;
|
||||
setState(await request('proxy.site.route', { url: tab.url, profileId }));
|
||||
setPreview(await request('proxy.rules.preview', { url: tab.url }));
|
||||
}, '当前站点规则已创建并应用');
|
||||
|
||||
return <div className="section-view proxy-page">
|
||||
<div className="page-heading proxy-page-heading">
|
||||
<div><h1>自动切换</h1><p>手动规则优先,随后按顺序匹配订阅源,未命中时使用默认出口。</p></div>
|
||||
<Button variant="primary" disabled={busy || (!state.proxyRuntime.dirty && active)} onClick={() => void run(async () => setState(await request('proxy.auto.apply')), '自动切换已应用')}><Zap size={16} />{active && !state.proxyRuntime.dirty ? '已应用' : '应用自动切换'}</Button>
|
||||
</div>
|
||||
|
||||
<section className={`proxy-apply-band ${active ? 'is-active' : ''} ${state.proxyRuntime.dirty ? 'is-dirty' : ''}`}>
|
||||
<div className="proxy-mode-state"><span><i />{active ? '自动切换运行中' : '自动切换未启用'}</span><strong>{state.proxyRuntime.dirty ? '存在未应用的更改' : state.proxyRuntime.appliedAt ? '配置与浏览器一致' : '尚未生成 PAC'}</strong></div>
|
||||
<Field label="默认出口"><select value={state.proxyRouting.defaultProfileId} onChange={(event) => void run(async () => setState(await request('proxy.rules.settings', { ...state.proxyRouting, defaultProfileId: event.target.value })), '默认出口已更新')}>{routableProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}</select></Field>
|
||||
<Field label="代理失败"><select value={state.proxyRouting.failMode} onChange={(event) => void run(async () => setState(await request('proxy.rules.settings', { ...state.proxyRouting, failMode: event.target.value as 'open' | 'closed' })), '失败策略已更新')}><option value="closed">保持失败</option><option value="open">回退到 DIRECT</option></select></Field>
|
||||
<div className="proxy-compile-metrics"><span><strong>{state.proxyRuntime.manualRuleCount}</strong> 手动</span><span><strong>{state.proxyRuntime.sourceRuleCount}</strong> 订阅</span><span><strong>{formatBytes(state.proxyRuntime.compiledBytes)}</strong> PAC</span></div>
|
||||
</section>
|
||||
|
||||
{state.proxyRuntime.error && <div className="proxy-runtime-alert"><AlertTriangle size={16} /><span><strong>上一轮应用失败</strong>{state.proxyRuntime.error}</span></div>}
|
||||
|
||||
<section className="proxy-route-probe">
|
||||
<div className="proxy-probe-input"><Search size={15} /><input value={previewUrl} onChange={(event) => setPreviewUrl(event.target.value)} placeholder="输入 URL 检查路由" /><Button size="sm" onClick={() => void explain()}>解释路由</Button></div>
|
||||
{preview ? <div className="proxy-probe-result"><span>{preview.matchedKind === 'default' ? '默认出口' : preview.matchedKind === 'manual' ? '手动规则' : '规则订阅'}</span><strong>{preview.matchedName}</strong><i>→</i><b>{state.proxyProfiles.find((profile) => profile.id === preview.effectiveProfileId)?.name}</b><small title={preview.matchedCondition}>{preview.matchedCondition || preview.hostname}</small></div> : <div className="proxy-probe-placeholder">查看某个请求为什么使用当前出口</div>}
|
||||
</section>
|
||||
|
||||
{tab?.url?.startsWith('http') && <section className="proxy-current-site">
|
||||
<div><CircleDot size={16} /><span><strong>{hostFromUrl(tab.url)}</strong><small>为当前站点创建最高优先级规则</small></span></div>
|
||||
<div><Button size="sm" disabled={busy} onClick={() => void quickRoute('direct')}>始终直连</Button>{state.proxyProfiles.some((profile) => profile.id === 'yakit-mitm') && <Button size="sm" variant="primary" disabled={busy} onClick={() => void quickRoute('yakit-mitm')}>始终走 MITM</Button>}</div>
|
||||
</section>}
|
||||
|
||||
<div className="proxy-rule-workspace">
|
||||
<section className="proxy-rule-table">
|
||||
<div className="proxy-table-toolbar"><div><span>手动规则</span><strong>{rules.length}</strong></div><Button size="sm" onClick={() => setDraft(freshRule(rules.length, tab?.url))}><Plus size={14} />新建</Button></div>
|
||||
<div className="proxy-rule-head"><span>顺序</span><span>规则</span><span>条件</span><span>出口</span><span>状态</span><span /></div>
|
||||
{rules.length === 0 ? <div className="proxy-table-empty"><Route size={22} /><strong>没有手动规则</strong><span>可以从当前站点快速创建,或在右侧添加。</span></div> : <div
|
||||
className="proxy-virtual-list"
|
||||
ref={listRef}
|
||||
style={{ height: LIST_HEIGHT }}
|
||||
onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}
|
||||
><div style={{ height: rules.length * ROW_HEIGHT, position: 'relative' }}>{visibleRules.map((rule, visibleIndex) => {
|
||||
const index = firstVisible + visibleIndex;
|
||||
const profile = state.proxyProfiles.find((item) => item.id === rule.proxyProfileId);
|
||||
return <div
|
||||
key={rule.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`proxy-rule-row ${draft.id === rule.id ? 'is-selected' : ''}`}
|
||||
style={{ position: 'absolute', top: index * ROW_HEIGHT, height: ROW_HEIGHT }}
|
||||
onClick={() => setDraft(rule)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
setDraft(rule);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="proxy-rule-order"><Button size="icon" variant="ghost" aria-label="上移规则" disabled={index === 0} onClick={(event) => { event.stopPropagation(); void reorder(rule, -1); }}><ArrowUp size={13} /></Button><Button size="icon" variant="ghost" aria-label="下移规则" disabled={index === rules.length - 1} onClick={(event) => { event.stopPropagation(); void reorder(rule, 1); }}><ArrowDown size={13} /></Button></span>
|
||||
<span><strong>{rule.name}</strong><small>{CONDITION_LABELS[rule.condition.type]}</small></span>
|
||||
<code title={rule.condition.value}>{rule.condition.value}</code>
|
||||
<span>{profile?.name || '出口已删除'}</span>
|
||||
<i className={rule.enabled ? 'is-enabled' : ''}>{rule.enabled ? '启用' : '停用'}</i>
|
||||
<Route size={14} />
|
||||
</div>;
|
||||
})}</div></div>}
|
||||
{enabledSources.length > 0 && <div className="proxy-source-summary"><Gauge size={15} /><span><strong>{enabledSources.length} 个订阅源参与匹配</strong><small>{enabledSources.reduce((sum, source) => sum + source.supportedRuleCount, 0).toLocaleString()} 条已规范化规则</small></span></div>}
|
||||
</section>
|
||||
|
||||
<aside className="proxy-rule-inspector">
|
||||
<div className="proxy-editor-heading"><div><span>{selectedExists ? '编辑规则' : '新建规则'}</span><h2>{draft.name || '未命名规则'}</h2></div><Switch checked={draft.enabled} onCheckedChange={(enabled) => setDraft({ ...draft, enabled })} /></div>
|
||||
<Field label="名称"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></Field>
|
||||
<Field label="条件类型"><select value={draft.condition.type} onChange={(event) => setDraft({ ...draft, condition: { type: event.target.value as ProxyConditionType, value: '' } })}>{Object.entries(CONDITION_LABELS).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></Field>
|
||||
<Field label="匹配值" hint={draft.condition.type.startsWith('url_') ? 'Chrome 对 HTTPS PAC 会隐藏路径与查询参数,优先使用域名条件。' : undefined}><textarea rows={4} value={draft.condition.value} placeholder={conditionHint(draft.condition.type)} onChange={(event) => setDraft({ ...draft, condition: { ...draft.condition, value: event.target.value } })} /></Field>
|
||||
<Field label="代理出口"><select value={draft.proxyProfileId} onChange={(event) => setDraft({ ...draft, proxyProfileId: event.target.value })}>{routableProfiles.map((profile) => <option value={profile.id} key={profile.id}>{profile.name} · {proxyProfileDetail(profile)}</option>)}</select></Field>
|
||||
<div className="proxy-inspector-actions"><Button variant="primary" disabled={busy || !draft.name.trim() || !draft.condition.value.trim()} onClick={() => void save()}><Save size={15} />保存规则</Button>{selectedExists && <Button variant="danger" disabled={busy} onClick={() => void run(async () => { const updated = await request('proxy.rule.delete', { id: draft.id }); setState(updated); setDraft(freshRule(updated.proxyRules.length, tab?.url)); }, '规则已删除')}><Trash2 size={15} />删除</Button>}</div>
|
||||
{preview && <section className="proxy-trace"><div><CheckCircle2 size={15} /><strong>匹配顺序</strong></div>{preview.trace.slice(0, 8).map((item, index) => <p key={`${item.kind}:${item.name}:${index}`} className={item.matched ? 'is-match' : ''}><i>{item.matched ? <CheckCircle2 size={13} /> : <span />}</i><span><strong>{item.name}</strong><small>{item.condition || item.kind}</small></span></p>)}</section>}
|
||||
</aside>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ChevronRight, KeyRound, Network, Plus, Power, Save, Trash2 } from 'lucide-react';
|
||||
import { v7 as uuidv7 } from 'uuid';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Field } from '@/components/ui/field';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { request } from '@/platform/messaging/runtime';
|
||||
import type { ProxyProfile } from '@/types/models';
|
||||
import { PROXY_KIND_LABELS, proxyProfileDetail } from './presentation';
|
||||
import type { ProxyViewProps } from './types';
|
||||
import './proxy-workspace.css';
|
||||
|
||||
function createProfile(): ProxyProfile {
|
||||
return {
|
||||
id: uuidv7(), name: '新代理出口', kind: 'fixed_servers', scheme: 'http', host: '127.0.0.1', port: 8080, bypass: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function ProxyProfilesView({ state, setState, run, busy }: ProxyViewProps) {
|
||||
const [draft, setDraft] = useState<ProxyProfile>(() => state.proxyProfiles[0] || createProfile());
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordConfigured, setPasswordConfigured] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setPassword('');
|
||||
void request('proxy.auth.status', { profileId: draft.id }).then((result) => setPasswordConfigured(result.configured));
|
||||
}, [draft.id]);
|
||||
|
||||
const save = () => run(async () => {
|
||||
setState(await request('proxy.save', draft));
|
||||
if (draft.authEnabled) {
|
||||
if (password) await request('proxy.auth.set', { profileId: draft.id, password });
|
||||
} else {
|
||||
await request('proxy.auth.set', { profileId: draft.id, password: '' });
|
||||
}
|
||||
setPassword('');
|
||||
setPasswordConfigured(Boolean(draft.authEnabled && (password || passwordConfigured)));
|
||||
}, '代理出口已保存');
|
||||
|
||||
const remove = () => run(async () => {
|
||||
setState(await request('proxy.delete', { id: draft.id }));
|
||||
await request('proxy.auth.set', { profileId: draft.id, password: '' });
|
||||
setDraft(state.proxyProfiles[0] || createProfile());
|
||||
}, '代理出口已删除');
|
||||
|
||||
return <div className="section-view proxy-page">
|
||||
<div className="page-heading proxy-page-heading">
|
||||
<div><h1>代理出口</h1><p>维护浏览器可以使用的直连、HTTP、HTTPS、SOCKS 和 PAC 出口。</p></div>
|
||||
<Button variant="primary" onClick={() => setDraft(createProfile())}><Plus size={16} />新建出口</Button>
|
||||
</div>
|
||||
|
||||
<div className="proxy-profile-workspace">
|
||||
<section className="proxy-profile-index" aria-label="代理出口列表">
|
||||
<div className="proxy-panel-label"><span>出口</span><strong>{state.proxyProfiles.length}</strong></div>
|
||||
<div className="proxy-profile-list">
|
||||
{state.proxyProfiles.map((profile) => <button
|
||||
key={profile.id}
|
||||
className={`${draft.id === profile.id ? 'is-selected' : ''} ${state.activeProxyId === profile.id ? 'is-active' : ''}`}
|
||||
onClick={() => setDraft({ ...profile, bypass: [...profile.bypass] })}
|
||||
>
|
||||
<span className="proxy-profile-icon"><Network size={16} /></span>
|
||||
<span><strong>{profile.name}</strong><small>{proxyProfileDetail(profile)}</small></span>
|
||||
{state.activeProxyId === profile.id && <i>使用中</i>}
|
||||
<ChevronRight size={15} />
|
||||
</button>)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="proxy-profile-editor">
|
||||
<div className="proxy-editor-heading">
|
||||
<div><span>{draft.builtin ? '内置出口' : '自定义出口'}</span><h2>{draft.name}</h2></div>
|
||||
<span className={`proxy-live-state ${state.activeProxyId === draft.id ? 'is-live' : ''}`}><i />{state.activeProxyId === draft.id ? '当前生效' : '未使用'}</span>
|
||||
</div>
|
||||
<div className="proxy-form-grid">
|
||||
<Field label="名称"><input value={draft.name} disabled={draft.id === 'direct' || draft.id === 'system'} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></Field>
|
||||
<Field label="类型"><select value={draft.kind} disabled={draft.builtin} onChange={(event) => setDraft({ ...draft, kind: event.target.value as ProxyProfile['kind'] })}>{Object.entries(PROXY_KIND_LABELS).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></Field>
|
||||
{draft.kind === 'fixed_servers' && <>
|
||||
<Field label="协议"><select value={draft.scheme || 'http'} onChange={(event) => setDraft({ ...draft, scheme: event.target.value as ProxyProfile['scheme'] })}><option value="http">HTTP</option><option value="https">HTTPS</option><option value="socks4">SOCKS4</option><option value="socks5">SOCKS5</option></select></Field>
|
||||
<Field label="主机"><input value={draft.host || ''} onChange={(event) => setDraft({ ...draft, host: event.target.value })} /></Field>
|
||||
<Field label="端口"><input type="number" min="1" max="65535" value={draft.port || ''} onChange={(event) => setDraft({ ...draft, port: Number(event.target.value) })} /></Field>
|
||||
<Field label="绕过列表" hint="每行一个域名、IP 或 <local>"><textarea rows={5} value={draft.bypass.join('\n')} onChange={(event) => setDraft({ ...draft, bypass: event.target.value.split('\n').map((item) => item.trim()).filter(Boolean) })} /></Field>
|
||||
</>}
|
||||
{draft.kind === 'pac_script' && <>
|
||||
<Field label="PAC URL"><input value={draft.pacUrl || ''} onChange={(event) => setDraft({ ...draft, pacUrl: event.target.value, pacScript: '' })} placeholder="https://example.com/proxy.pac" /></Field>
|
||||
<Field label="内联 PAC"><textarea rows={10} value={draft.pacScript || ''} onChange={(event) => setDraft({ ...draft, pacScript: event.target.value, pacUrl: '' })} /></Field>
|
||||
</>}
|
||||
</div>
|
||||
{draft.kind === 'fixed_servers' && <section className="proxy-auth-section">
|
||||
<label><span><KeyRound size={16} /><span><strong>代理认证</strong><small>{passwordConfigured ? '已保存本次浏览器会话的凭据' : '凭据仅保存在浏览器 session'}</small></span></span><Switch checked={Boolean(draft.authEnabled)} onCheckedChange={(checked) => setDraft({ ...draft, authEnabled: checked })} /></label>
|
||||
{draft.authEnabled && <div className="proxy-auth-fields"><Field label="用户名"><input value={draft.authUsername || ''} onChange={(event) => setDraft({ ...draft, authUsername: event.target.value })} /></Field><Field label="密码"><input type="password" value={password} onChange={(event) => setPassword(event.target.value)} placeholder={passwordConfigured ? '留空以保留当前密码' : '输入密码'} /></Field></div>}
|
||||
</section>}
|
||||
<div className="proxy-editor-actions">
|
||||
<Button variant="primary" disabled={busy || !draft.name || (draft.kind === 'fixed_servers' && (!draft.host || !draft.port))} onClick={() => void save()}><Save size={16} />保存</Button>
|
||||
<Button disabled={busy} onClick={() => void run(async () => setState(await request('proxy.switch', { id: draft.id })), `${draft.name} 已启用`)}><Power size={16} />立即使用</Button>
|
||||
{!draft.builtin && <Button variant="danger" disabled={busy} onClick={() => void remove()}><Trash2 size={16} />删除</Button>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AlertTriangle, ArrowDown, ArrowUp, CheckCircle2, ChevronLeft, ChevronRight, CloudDownload, Download, FileText, Plus, RefreshCw,
|
||||
Search, Trash2, Upload,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Field } from '@/components/ui/field';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { request } from '@/platform/messaging/runtime';
|
||||
import type {
|
||||
ProxyConfiguration, ProxyRulePage, ProxyRuleSource, ProxyRuleSourceFormat, ProxyRuleSourceInput,
|
||||
} from '@/types/models';
|
||||
import { CONDITION_LABELS, relativeTime, SOURCE_FORMAT_LABELS } from './presentation';
|
||||
import type { ProxyViewProps } from './types';
|
||||
import './proxy-workspace.css';
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
function sourceDraft(state: ProxyViewProps['state'], source?: ProxyRuleSource): ProxyRuleSourceInput {
|
||||
return source ? {
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
url: source.url,
|
||||
format: source.format,
|
||||
enabled: source.enabled,
|
||||
matchProfileId: source.matchProfileId,
|
||||
bypassProfileId: source.bypassProfileId,
|
||||
order: source.order,
|
||||
updateIntervalMinutes: source.updateIntervalMinutes,
|
||||
} : {
|
||||
name: 'GitHub 规则订阅',
|
||||
url: '',
|
||||
format: 'auto',
|
||||
enabled: true,
|
||||
matchProfileId: state.proxyProfiles.some((profile) => profile.id === 'yakit-mitm') ? 'yakit-mitm' : 'direct',
|
||||
bypassProfileId: 'direct',
|
||||
order: state.proxyRuleSources.length,
|
||||
updateIntervalMinutes: 720,
|
||||
};
|
||||
}
|
||||
|
||||
function sourceStatusLabel(source: ProxyRuleSource): string {
|
||||
if (source.status === 'updating') return '正在更新';
|
||||
if (source.status === 'error') return source.revision ? '使用上一版本' : '更新失败';
|
||||
if (source.status === 'ready') return '可用';
|
||||
return '尚未下载';
|
||||
}
|
||||
|
||||
export function RuleSourcesView({ state, setState, run, busy }: ProxyViewProps) {
|
||||
const routableProfiles = useMemo(() => state.proxyProfiles.filter((profile) => ['direct', 'fixed_servers'].includes(profile.kind)), [state.proxyProfiles]);
|
||||
const orderedSources = useMemo(() => [...state.proxyRuleSources].sort((left, right) => left.order - right.order), [state.proxyRuleSources]);
|
||||
const [selectedId, setSelectedId] = useState(orderedSources[0]?.id || '');
|
||||
const selected = state.proxyRuleSources.find((source) => source.id === selectedId);
|
||||
const [draft, setDraft] = useState<ProxyRuleSourceInput>(() => sourceDraft(state, selected));
|
||||
const [page, setPage] = useState<ProxyRulePage>();
|
||||
const [query, setQuery] = useState('');
|
||||
const [offset, setOffset] = useState(0);
|
||||
const importRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(sourceDraft(state, selected));
|
||||
setQuery('');
|
||||
setOffset(0);
|
||||
}, [selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected?.revision) {
|
||||
setPage(undefined);
|
||||
return undefined;
|
||||
}
|
||||
let cancelled = false;
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
void request('proxy.source.rules', { id: selected.id, offset, limit: PAGE_SIZE, query: query || undefined })
|
||||
.then((next) => { if (!cancelled) setPage(next); })
|
||||
.catch(() => { if (!cancelled) setPage(undefined); });
|
||||
}, 180);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
globalThis.clearTimeout(timer);
|
||||
};
|
||||
}, [selected?.id, selected?.revision, offset, query]);
|
||||
|
||||
const reorderSource = (index: number, delta: -1 | 1) => run(async () => {
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= orderedSources.length) return;
|
||||
const ids = orderedSources.map((source) => source.id);
|
||||
[ids[index], ids[target]] = [ids[target], ids[index]];
|
||||
setState(await request('proxy.sources.reorder', { ids }));
|
||||
}, '订阅匹配顺序已更新');
|
||||
|
||||
const saveAndRefresh = () => run(async () => {
|
||||
const saved = await request('proxy.source.save', draft);
|
||||
setSelectedId(saved.id);
|
||||
try {
|
||||
setState(await request('proxy.source.refresh', { id: saved.id }));
|
||||
} catch (error) {
|
||||
setState(await request('state.get'));
|
||||
throw error;
|
||||
}
|
||||
}, '规则源已更新');
|
||||
|
||||
const downloadConfiguration = () => run(async () => {
|
||||
const configuration = await request('proxy.config.export');
|
||||
const href = URL.createObjectURL(new Blob([JSON.stringify(configuration, null, 2)], { type: 'application/json' }));
|
||||
const link = document.createElement('a');
|
||||
link.href = href;
|
||||
link.download = `yakit-proxy-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(href);
|
||||
}, '代理配置已导出');
|
||||
|
||||
const importConfiguration = (file?: File) => run(async () => {
|
||||
if (!file) return;
|
||||
const configuration = JSON.parse(await file.text()) as ProxyConfiguration;
|
||||
setState(await request('proxy.config.import', { configuration }));
|
||||
setSelectedId('');
|
||||
}, '代理配置已导入');
|
||||
|
||||
return <div className="section-view proxy-page">
|
||||
<div className="page-heading proxy-page-heading">
|
||||
<div><h1>规则订阅</h1><p>从 GitHub 或任意 HTTP(S) 地址更新规则;下载失败时继续使用上一份可用版本。</p></div>
|
||||
<div className="proxy-heading-actions">
|
||||
<input ref={importRef} type="file" accept="application/json,.json" hidden onChange={(event) => { void importConfiguration(event.target.files?.[0]); event.currentTarget.value = ''; }} />
|
||||
<Button onClick={() => importRef.current?.click()}><Upload size={15} />导入</Button>
|
||||
<Button onClick={() => void downloadConfiguration()}><Download size={15} />导出</Button>
|
||||
<Button variant="primary" onClick={() => { setSelectedId(''); setDraft(sourceDraft(state)); }}><Plus size={15} />添加订阅</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proxy-source-workspace">
|
||||
<section className="proxy-source-index">
|
||||
<div className="proxy-panel-label"><span>订阅源</span><strong>{orderedSources.length}</strong></div>
|
||||
{orderedSources.length === 0 ? <div className="proxy-source-empty"><CloudDownload size={24} /><strong>尚无规则订阅</strong><span>添加 GitHub raw、AutoProxy 或域名列表。</span></div> : <div className="proxy-source-list">{orderedSources.map((source, index) => <div key={source.id} className={`proxy-source-item ${selectedId === source.id ? 'is-selected' : ''}`}>
|
||||
<button className="proxy-source-select" onClick={() => setSelectedId(source.id)}>
|
||||
<span className={`proxy-source-status ${source.status}`}><i /></span>
|
||||
<span><strong>{source.name}</strong><small>{source.supportedRuleCount.toLocaleString()} 条 · {relativeTime(source.lastUpdatedAt)}</small></span>
|
||||
<i>{sourceStatusLabel(source)}</i>
|
||||
<ChevronRight size={15} />
|
||||
</button>
|
||||
<span className="proxy-source-order"><Button size="icon" variant="ghost" aria-label="上移规则源" disabled={index === 0 || busy} onClick={() => void reorderSource(index, -1)}><ArrowUp size={13} /></Button><Button size="icon" variant="ghost" aria-label="下移规则源" disabled={index === orderedSources.length - 1 || busy} onClick={() => void reorderSource(index, 1)}><ArrowDown size={13} /></Button></span>
|
||||
</div>)}</div>}
|
||||
</section>
|
||||
|
||||
<section className="proxy-source-main">
|
||||
<div className="proxy-source-editor">
|
||||
<div className="proxy-editor-heading"><div><span>{selected ? '订阅设置' : '新规则订阅'}</span><h2>{draft.name || '未命名订阅'}</h2></div><label className="proxy-inline-switch"><span>{draft.enabled ? '参与匹配' : '已停用'}</span><Switch checked={draft.enabled} onCheckedChange={(enabled) => setDraft({ ...draft, enabled })} /></label></div>
|
||||
<div className="proxy-source-form">
|
||||
<Field label="名称"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></Field>
|
||||
<Field label="订阅地址" hint="GitHub blob 地址会自动转换为 raw 地址"><input value={draft.url} placeholder="https://github.com/user/repo/blob/main/rules.txt" onChange={(event) => setDraft({ ...draft, url: event.target.value })} /></Field>
|
||||
<Field label="格式"><select value={draft.format} onChange={(event) => setDraft({ ...draft, format: event.target.value as ProxyRuleSourceFormat })}>{Object.entries(SOURCE_FORMAT_LABELS).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></Field>
|
||||
<Field label="匹配出口"><select value={draft.matchProfileId} onChange={(event) => setDraft({ ...draft, matchProfileId: event.target.value })}>{routableProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}</select></Field>
|
||||
<Field label="排除出口"><select value={draft.bypassProfileId} onChange={(event) => setDraft({ ...draft, bypassProfileId: event.target.value })}>{routableProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}</select></Field>
|
||||
<Field label="更新周期"><select value={draft.updateIntervalMinutes} onChange={(event) => setDraft({ ...draft, updateIntervalMinutes: Number(event.target.value) })}><option value="60">每小时</option><option value="360">每 6 小时</option><option value="720">每 12 小时</option><option value="1440">每天</option><option value="10080">每周</option></select></Field>
|
||||
</div>
|
||||
{selected?.error && <div className={`proxy-source-message ${selected.status === 'error' ? 'is-error' : 'is-warning'}`}><AlertTriangle size={15} /><span>{selected.error}</span></div>}
|
||||
<div className="proxy-editor-actions">
|
||||
<Button variant="primary" disabled={busy || !draft.name.trim() || !draft.url.trim()} onClick={() => void saveAndRefresh()}>{selected?.status === 'updating' ? <RefreshCw className="spin" size={15} /> : <CloudDownload size={15} />}保存并更新</Button>
|
||||
{selected && <Button disabled={busy} onClick={() => void run(async () => setState(await request('proxy.source.refresh', { id: selected.id })), '规则源已更新')}><RefreshCw size={15} />立即更新</Button>}
|
||||
{selected && <Button variant="danger" disabled={busy} onClick={() => void run(async () => { const next = await request('proxy.source.delete', { id: selected.id }); setState(next); const nextId = next.proxyRuleSources[0]?.id || ''; setSelectedId(nextId); setDraft(sourceDraft(next, next.proxyRuleSources.find((source) => source.id === nextId))); }, '规则源已删除')}><Trash2 size={15} />删除</Button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selected && <section className="proxy-source-rules">
|
||||
<div className="proxy-source-rules-heading">
|
||||
<div><span>规范化规则</span><strong>{(page?.total ?? selected.supportedRuleCount).toLocaleString()}</strong></div>
|
||||
<label><Search size={14} /><input value={query} placeholder="搜索当前规则源" onChange={(event) => { setQuery(event.target.value); setOffset(0); }} /></label>
|
||||
</div>
|
||||
<div className="proxy-source-stats"><span><CheckCircle2 size={13} />{selected.supportedRuleCount.toLocaleString()} 有效</span><span>{selected.ignoredRuleCount.toLocaleString()} 忽略</span><span className={selected.invalidRuleCount ? 'is-warning' : ''}>{selected.invalidRuleCount.toLocaleString()} 无效</span><span>{SOURCE_FORMAT_LABELS[selected.format]}</span></div>
|
||||
<div className="proxy-source-rule-head"><span>#</span><span>类型</span><span>条件</span><span>结果</span></div>
|
||||
{!page ? <div className="proxy-source-rule-loading"><RefreshCw className="spin" size={16} />正在读取 IndexedDB</div> : page.rules.length === 0 ? <div className="proxy-source-rule-loading"><FileText size={18} />没有符合条件的规则</div> : <div className="proxy-source-rule-list">{page.rules.map((rule) => <div key={`${rule.sourceId}:${rule.ordinal}`}>
|
||||
<span>{rule.ordinal + 1}</span><span>{CONDITION_LABELS[rule.condition.type]}</span><code title={rule.raw}>{rule.condition.value}</code><i className={rule.exception ? 'is-exception' : ''}>{rule.exception ? state.proxyProfiles.find((profile) => profile.id === selected.bypassProfileId)?.name : rule.resultProfileName || state.proxyProfiles.find((profile) => profile.id === selected.matchProfileId)?.name}</i>
|
||||
</div>)}</div>}
|
||||
<div className="proxy-source-pagination"><span>{page ? page.total === 0 ? '0 / 0' : `${page.offset + 1}-${Math.min(page.offset + page.limit, page.total)} / ${page.total.toLocaleString()}` : '—'}</span><div><Button size="icon" variant="ghost" aria-label="上一页" disabled={!page || offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}><ChevronLeft size={15} /></Button><Button size="icon" variant="ghost" aria-label="下一页" disabled={!page || offset + PAGE_SIZE >= page.total} onClick={() => setOffset(offset + PAGE_SIZE)}><ChevronRight size={15} /></Button></div></div>
|
||||
</section>}
|
||||
</section>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ProxyConditionType, ProxyProfile, ProxyRuleSourceFormat } from '@/types/models';
|
||||
|
||||
export const PROXY_KIND_LABELS: Record<ProxyProfile['kind'], string> = {
|
||||
direct: '直接连接',
|
||||
system: '系统代理',
|
||||
fixed_servers: '固定代理',
|
||||
pac_script: 'PAC Script',
|
||||
};
|
||||
|
||||
export const CONDITION_LABELS: Record<ProxyConditionType, string> = {
|
||||
host_exact: '精确域名',
|
||||
host_suffix: '域名及子域',
|
||||
host_wildcard: '域名通配符',
|
||||
host_regex: '域名正则',
|
||||
url_prefix: 'URL 前缀',
|
||||
url_wildcard: 'URL 通配符',
|
||||
url_regex: 'URL 正则',
|
||||
keyword: 'URL 关键词',
|
||||
};
|
||||
|
||||
export const SOURCE_FORMAT_LABELS: Record<ProxyRuleSourceFormat, string> = {
|
||||
auto: '自动识别',
|
||||
autoproxy: 'AutoProxy / GFWList',
|
||||
switchyomega: 'SwitchyOmega Conditions',
|
||||
hosts: '域名 / Hosts 列表',
|
||||
};
|
||||
|
||||
export function proxyProfileDetail(profile: ProxyProfile): string {
|
||||
if (profile.kind === 'fixed_servers') return `${profile.scheme || 'http'}://${profile.host}:${profile.port}`;
|
||||
if (profile.kind === 'pac_script') return profile.pacUrl || '内联 PAC';
|
||||
return PROXY_KIND_LABELS[profile.kind];
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes > 100 * 1024 ? 0 : 1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
export function relativeTime(timestamp?: number): string {
|
||||
if (!timestamp) return '尚未更新';
|
||||
const delta = Date.now() - timestamp;
|
||||
if (delta < 60_000) return '刚刚';
|
||||
if (delta < 3_600_000) return `${Math.floor(delta / 60_000)} 分钟前`;
|
||||
if (delta < 86_400_000) return `${Math.floor(delta / 3_600_000)} 小时前`;
|
||||
return new Date(timestamp).toLocaleDateString();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
import type { ActiveTabInfo, ExtensionState } from '@/types/models';
|
||||
|
||||
export type ProxyRunTask = (task: () => Promise<void>, success?: string) => Promise<void>;
|
||||
|
||||
export interface ProxyViewProps {
|
||||
state: ExtensionState;
|
||||
setState: (state: ExtensionState) => void;
|
||||
run: ProxyRunTask;
|
||||
busy: boolean;
|
||||
tab?: ActiveTabInfo;
|
||||
}
|
||||
Reference in New Issue
Block a user