feat: 新增自定义配置合并到 Surge 输出
管理面板新增「自定义」面板,可按 Surge 格式书写 [Proxy]/[Rule]/ [General] 等配置片段,生成时合并进最终输出:[Proxy] 行追加到节点 之后,[Rule] 行注入到规则最顶部(优先级最高),其他段注入对应段 顶部且同名 key 覆盖模板取值(如 doh-server)。仅管理员链接生效, 游客链接不包含自定义内容。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
206
pnpm-lock.yaml
generated
Normal file
206
pnpm-lock.yaml
generated
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
lockfileVersion: '9.0'
|
||||||
|
|
||||||
|
settings:
|
||||||
|
autoInstallPeers: true
|
||||||
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
importers:
|
||||||
|
|
||||||
|
.:
|
||||||
|
devDependencies:
|
||||||
|
concurrently:
|
||||||
|
specifier: ^9.1.0
|
||||||
|
version: 9.2.4
|
||||||
|
|
||||||
|
packages:
|
||||||
|
|
||||||
|
ansi-regex@5.0.1:
|
||||||
|
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
ansi-styles@4.3.0:
|
||||||
|
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
chalk@4.1.2:
|
||||||
|
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
cliui@8.0.1:
|
||||||
|
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
color-convert@2.0.1:
|
||||||
|
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||||
|
engines: {node: '>=7.0.0'}
|
||||||
|
|
||||||
|
color-name@1.1.4:
|
||||||
|
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||||
|
|
||||||
|
concurrently@9.2.4:
|
||||||
|
resolution: {integrity: sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
emoji-regex@8.0.0:
|
||||||
|
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||||
|
|
||||||
|
escalade@3.2.0:
|
||||||
|
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
get-caller-file@2.0.5:
|
||||||
|
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||||
|
engines: {node: 6.* || 8.* || >= 10.*}
|
||||||
|
|
||||||
|
has-flag@4.0.0:
|
||||||
|
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
is-fullwidth-code-point@3.0.0:
|
||||||
|
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
require-directory@2.1.1:
|
||||||
|
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
rxjs@7.8.2:
|
||||||
|
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
|
||||||
|
|
||||||
|
shell-quote@1.9.0:
|
||||||
|
resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
string-width@4.2.3:
|
||||||
|
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
strip-ansi@6.0.1:
|
||||||
|
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
supports-color@7.2.0:
|
||||||
|
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
supports-color@8.1.1:
|
||||||
|
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
tree-kill@1.2.2:
|
||||||
|
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
tslib@2.8.1:
|
||||||
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
|
wrap-ansi@7.0.0:
|
||||||
|
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
y18n@5.0.8:
|
||||||
|
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
yargs-parser@21.1.1:
|
||||||
|
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
yargs@17.7.2:
|
||||||
|
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
snapshots:
|
||||||
|
|
||||||
|
ansi-regex@5.0.1: {}
|
||||||
|
|
||||||
|
ansi-styles@4.3.0:
|
||||||
|
dependencies:
|
||||||
|
color-convert: 2.0.1
|
||||||
|
|
||||||
|
chalk@4.1.2:
|
||||||
|
dependencies:
|
||||||
|
ansi-styles: 4.3.0
|
||||||
|
supports-color: 7.2.0
|
||||||
|
|
||||||
|
cliui@8.0.1:
|
||||||
|
dependencies:
|
||||||
|
string-width: 4.2.3
|
||||||
|
strip-ansi: 6.0.1
|
||||||
|
wrap-ansi: 7.0.0
|
||||||
|
|
||||||
|
color-convert@2.0.1:
|
||||||
|
dependencies:
|
||||||
|
color-name: 1.1.4
|
||||||
|
|
||||||
|
color-name@1.1.4: {}
|
||||||
|
|
||||||
|
concurrently@9.2.4:
|
||||||
|
dependencies:
|
||||||
|
chalk: 4.1.2
|
||||||
|
rxjs: 7.8.2
|
||||||
|
shell-quote: 1.9.0
|
||||||
|
supports-color: 8.1.1
|
||||||
|
tree-kill: 1.2.2
|
||||||
|
yargs: 17.7.2
|
||||||
|
|
||||||
|
emoji-regex@8.0.0: {}
|
||||||
|
|
||||||
|
escalade@3.2.0: {}
|
||||||
|
|
||||||
|
get-caller-file@2.0.5: {}
|
||||||
|
|
||||||
|
has-flag@4.0.0: {}
|
||||||
|
|
||||||
|
is-fullwidth-code-point@3.0.0: {}
|
||||||
|
|
||||||
|
require-directory@2.1.1: {}
|
||||||
|
|
||||||
|
rxjs@7.8.2:
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
shell-quote@1.9.0: {}
|
||||||
|
|
||||||
|
string-width@4.2.3:
|
||||||
|
dependencies:
|
||||||
|
emoji-regex: 8.0.0
|
||||||
|
is-fullwidth-code-point: 3.0.0
|
||||||
|
strip-ansi: 6.0.1
|
||||||
|
|
||||||
|
strip-ansi@6.0.1:
|
||||||
|
dependencies:
|
||||||
|
ansi-regex: 5.0.1
|
||||||
|
|
||||||
|
supports-color@7.2.0:
|
||||||
|
dependencies:
|
||||||
|
has-flag: 4.0.0
|
||||||
|
|
||||||
|
supports-color@8.1.1:
|
||||||
|
dependencies:
|
||||||
|
has-flag: 4.0.0
|
||||||
|
|
||||||
|
tree-kill@1.2.2: {}
|
||||||
|
|
||||||
|
tslib@2.8.1: {}
|
||||||
|
|
||||||
|
wrap-ansi@7.0.0:
|
||||||
|
dependencies:
|
||||||
|
ansi-styles: 4.3.0
|
||||||
|
string-width: 4.2.3
|
||||||
|
strip-ansi: 6.0.1
|
||||||
|
|
||||||
|
y18n@5.0.8: {}
|
||||||
|
|
||||||
|
yargs-parser@21.1.1: {}
|
||||||
|
|
||||||
|
yargs@17.7.2:
|
||||||
|
dependencies:
|
||||||
|
cliui: 8.0.1
|
||||||
|
escalade: 3.2.0
|
||||||
|
get-caller-file: 2.0.5
|
||||||
|
require-directory: 2.1.1
|
||||||
|
string-width: 4.2.3
|
||||||
|
y18n: 5.0.8
|
||||||
|
yargs-parser: 21.1.1
|
||||||
@@ -18,6 +18,22 @@ router.post('/surge-token', (_req, res) => {
|
|||||||
res.json({ token });
|
res.json({ token });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/config/custom - get custom config snippet
|
||||||
|
router.get('/custom', (_req, res) => {
|
||||||
|
const row = db.prepare("SELECT value FROM config WHERE key = 'custom_config'").get() as any;
|
||||||
|
res.json({ content: row?.value || '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/config/custom - save custom config snippet
|
||||||
|
router.put('/custom', (req, res) => {
|
||||||
|
const { content } = req.body;
|
||||||
|
if (typeof content !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'content must be a string' });
|
||||||
|
}
|
||||||
|
db.prepare("INSERT OR REPLACE INTO config (key, value) VALUES ('custom_config', ?)").run(content);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
// GET /api/config/preview - preview generated config
|
// GET /api/config/preview - preview generated config
|
||||||
router.get('/preview', (req, res) => {
|
router.get('/preview', (req, res) => {
|
||||||
const host = req.headers.host || 'localhost:3456';
|
const host = req.headers.host || 'localhost:3456';
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ export function generateSurgeConfig(hostUrl: string, whitelist?: Set<string>): s
|
|||||||
'SELECT type, value, action, comment FROM rules WHERE enabled = 1 ORDER BY sort_order, id'
|
'SELECT type, value, action, comment FROM rules WHERE enabled = 1 ORDER BY sort_order, id'
|
||||||
).all() as any[];
|
).all() as any[];
|
||||||
|
|
||||||
|
// Custom config sections ([Proxy]/[Rule]/... snippets), admin links only —
|
||||||
|
// guest links must not receive admin-machine specifics like local interfaces
|
||||||
|
const customRow = whitelist
|
||||||
|
? null
|
||||||
|
: db.prepare("SELECT value FROM config WHERE key = 'custom_config'").get() as any;
|
||||||
|
const customSections = parseCustomSections(customRow?.value || '');
|
||||||
|
|
||||||
// Static nodes first, then fetched nodes
|
// Static nodes first, then fetched nodes
|
||||||
const lineName = (l: string) => l.split(' = ')[0].trim();
|
const lineName = (l: string) => l.split(' = ')[0].trim();
|
||||||
const keep = (l: string) => !whitelist || whitelist.has(lineName(l));
|
const keep = (l: string) => !whitelist || whitelist.has(lineName(l));
|
||||||
@@ -51,14 +58,22 @@ export function generateSurgeConfig(hostUrl: string, whitelist?: Set<string>): s
|
|||||||
let config = sub.raw_config;
|
let config = sub.raw_config;
|
||||||
|
|
||||||
// Replace [Proxy] section with only enabled nodes
|
// Replace [Proxy] section with only enabled nodes
|
||||||
config = rebuildProxySection(config, staticLines, fetchedLines);
|
config = rebuildProxySection(config, staticLines, fetchedLines, customSections.get('Proxy') || []);
|
||||||
|
|
||||||
// Rebuild [Proxy Group] select groups with only enabled node names
|
// Rebuild [Proxy Group] select groups with only enabled node names
|
||||||
config = rebuildProxyGroup(config, allNodeNames);
|
config = rebuildProxyGroup(config, allNodeNames);
|
||||||
|
|
||||||
// Inject user rules at the beginning of [Rule] section
|
// Inject user rules at the beginning of [Rule] section
|
||||||
if (ruleLines.length > 0) {
|
// (custom config rules go first — they take highest priority)
|
||||||
config = injectRules(config, ruleLines);
|
const customRuleLines = customSections.get('Rule') || [];
|
||||||
|
if (ruleLines.length > 0 || customRuleLines.length > 0) {
|
||||||
|
config = injectRules(config, ruleLines, customRuleLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge remaining custom sections (anything other than [Proxy]/[Rule])
|
||||||
|
for (const [section, lines] of customSections) {
|
||||||
|
if (section === 'Proxy' || section === 'Rule') continue;
|
||||||
|
config = injectIntoSection(config, section, lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rewrite MANAGED-CONFIG URL
|
// Rewrite MANAGED-CONFIG URL
|
||||||
@@ -71,10 +86,34 @@ export function generateSurgeConfig(hostUrl: string, whitelist?: Set<string>): s
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace the entire [Proxy] section content with only enabled nodes.
|
* Parse a custom config snippet into section name → lines.
|
||||||
* Static nodes go first, then fetched nodes.
|
* Lines before any [Section] header are ignored; blank lines are dropped
|
||||||
|
* (comments are kept so users can annotate their rules).
|
||||||
*/
|
*/
|
||||||
function rebuildProxySection(config: string, staticLines: string[], fetchedLines: string[]): string {
|
export function parseCustomSections(text: string): Map<string, string[]> {
|
||||||
|
const sections = new Map<string, string[]>();
|
||||||
|
let current: string[] | null = null;
|
||||||
|
|
||||||
|
for (const raw of text.split('\n')) {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||||
|
const name = trimmed.slice(1, -1).trim();
|
||||||
|
current = sections.get(name) || [];
|
||||||
|
sections.set(name, current);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!trimmed || !current) continue;
|
||||||
|
current.push(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the entire [Proxy] section content with only enabled nodes.
|
||||||
|
* Static nodes go first, then fetched nodes, then custom config proxies.
|
||||||
|
*/
|
||||||
|
function rebuildProxySection(config: string, staticLines: string[], fetchedLines: string[], customLines: string[]): string {
|
||||||
const lines = config.split('\n');
|
const lines = config.split('\n');
|
||||||
const result: string[] = [];
|
const result: string[] = [];
|
||||||
let inProxySection = false;
|
let inProxySection = false;
|
||||||
@@ -86,14 +125,14 @@ function rebuildProxySection(config: string, staticLines: string[], fetchedLines
|
|||||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||||
if (inProxySection && !proxyHeaderEmitted) {
|
if (inProxySection && !proxyHeaderEmitted) {
|
||||||
// Emit our rebuilt proxy content before leaving the section
|
// Emit our rebuilt proxy content before leaving the section
|
||||||
emitProxyContent(result, staticLines, fetchedLines);
|
emitProxyContent(result, staticLines, fetchedLines, customLines);
|
||||||
proxyHeaderEmitted = true;
|
proxyHeaderEmitted = true;
|
||||||
}
|
}
|
||||||
inProxySection = trimmed === '[Proxy]';
|
inProxySection = trimmed === '[Proxy]';
|
||||||
result.push(line);
|
result.push(line);
|
||||||
if (inProxySection) {
|
if (inProxySection) {
|
||||||
// Emit all enabled nodes right after [Proxy] header
|
// Emit all enabled nodes right after [Proxy] header
|
||||||
emitProxyContent(result, staticLines, fetchedLines);
|
emitProxyContent(result, staticLines, fetchedLines, customLines);
|
||||||
proxyHeaderEmitted = true;
|
proxyHeaderEmitted = true;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@@ -109,13 +148,13 @@ function rebuildProxySection(config: string, staticLines: string[], fetchedLines
|
|||||||
|
|
||||||
// If [Proxy] was the last section
|
// If [Proxy] was the last section
|
||||||
if (inProxySection && !proxyHeaderEmitted) {
|
if (inProxySection && !proxyHeaderEmitted) {
|
||||||
emitProxyContent(result, staticLines, fetchedLines);
|
emitProxyContent(result, staticLines, fetchedLines, customLines);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.join('\n');
|
return result.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitProxyContent(result: string[], staticLines: string[], fetchedLines: string[]) {
|
function emitProxyContent(result: string[], staticLines: string[], fetchedLines: string[], customLines: string[]) {
|
||||||
if (staticLines.length > 0) {
|
if (staticLines.length > 0) {
|
||||||
result.push('# --- 自定义节点 ---');
|
result.push('# --- 自定义节点 ---');
|
||||||
staticLines.forEach(l => result.push(l));
|
staticLines.forEach(l => result.push(l));
|
||||||
@@ -126,6 +165,11 @@ function emitProxyContent(result: string[], staticLines: string[], fetchedLines:
|
|||||||
fetchedLines.forEach(l => result.push(l));
|
fetchedLines.forEach(l => result.push(l));
|
||||||
result.push('');
|
result.push('');
|
||||||
}
|
}
|
||||||
|
if (customLines.length > 0) {
|
||||||
|
result.push('# --- 自定义配置 ---');
|
||||||
|
customLines.forEach(l => result.push(l));
|
||||||
|
result.push('');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -161,18 +205,74 @@ function rebuildProxyGroup(config: string, allNodeNames: string[]): string {
|
|||||||
return result.join('\n');
|
return result.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function injectRules(config: string, ruleLines: string[]): string {
|
function injectRules(config: string, ruleLines: string[], customRuleLines: string[] = []): string {
|
||||||
const lines = config.split('\n');
|
const lines = config.split('\n');
|
||||||
const result: string[] = [];
|
const result: string[] = [];
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
result.push(line);
|
result.push(line);
|
||||||
if (line.trim() === '[Rule]') {
|
if (line.trim() === '[Rule]') {
|
||||||
result.push('# --- 自定义规则 ---');
|
if (customRuleLines.length > 0) {
|
||||||
ruleLines.forEach(r => result.push(r));
|
result.push('# --- 自定义配置规则 ---');
|
||||||
result.push('');
|
customRuleLines.forEach(r => result.push(r));
|
||||||
|
result.push('');
|
||||||
|
}
|
||||||
|
if (ruleLines.length > 0) {
|
||||||
|
result.push('# --- 自定义规则 ---');
|
||||||
|
ruleLines.forEach(r => result.push(r));
|
||||||
|
result.push('');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.join('\n');
|
return result.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject custom lines at the top of an arbitrary [Section]; appends the
|
||||||
|
* section at the end of the config if the template doesn't have it.
|
||||||
|
* A custom `key = value` line overrides (removes) same-key lines the
|
||||||
|
* template already has in that section, e.g. doh-server in [General].
|
||||||
|
*/
|
||||||
|
function injectIntoSection(config: string, section: string, customLines: string[]): string {
|
||||||
|
if (customLines.length === 0) return config;
|
||||||
|
|
||||||
|
const header = `[${section}]`;
|
||||||
|
const keyOf = (l: string) => l.includes('=') && !l.startsWith('#') ? l.split('=')[0].trim() : null;
|
||||||
|
const overrideKeys = new Set(customLines.map(keyOf).filter(Boolean));
|
||||||
|
|
||||||
|
const lines = config.split('\n');
|
||||||
|
const result: string[] = [];
|
||||||
|
let found = false;
|
||||||
|
let inSection = false;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
|
||||||
|
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||||
|
inSection = trimmed === header;
|
||||||
|
result.push(line);
|
||||||
|
if (inSection && !found) {
|
||||||
|
found = true;
|
||||||
|
result.push('# --- 自定义配置 ---');
|
||||||
|
customLines.forEach(l => result.push(l));
|
||||||
|
result.push('');
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inSection) {
|
||||||
|
const key = keyOf(trimmed);
|
||||||
|
if (key && overrideKeys.has(key)) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!found) {
|
||||||
|
result.push('', header, '# --- 自定义配置 ---');
|
||||||
|
customLines.forEach(l => result.push(l));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.join('\n');
|
||||||
|
}
|
||||||
|
|||||||
1134
web/pnpm-lock.yaml
generated
Normal file
1134
web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import Subscriptions from './components/Subscriptions';
|
|||||||
import StaticNodes from './components/StaticNodes';
|
import StaticNodes from './components/StaticNodes';
|
||||||
import NodeSelector from './components/NodeSelector';
|
import NodeSelector from './components/NodeSelector';
|
||||||
import Rules from './components/Rules';
|
import Rules from './components/Rules';
|
||||||
|
import CustomConfig from './components/CustomConfig';
|
||||||
import Output from './components/Output';
|
import Output from './components/Output';
|
||||||
import Guests from './components/Guests';
|
import Guests from './components/Guests';
|
||||||
import { auth, setToken } from './api';
|
import { auth, setToken } from './api';
|
||||||
@@ -147,6 +148,7 @@ export default function App() {
|
|||||||
'static-nodes': <StaticNodes />,
|
'static-nodes': <StaticNodes />,
|
||||||
'node-selector': <NodeSelector />,
|
'node-selector': <NodeSelector />,
|
||||||
rules: <Rules />,
|
rules: <Rules />,
|
||||||
|
'custom-config': <CustomConfig />,
|
||||||
output: <Output />,
|
output: <Output />,
|
||||||
guests: <Guests />,
|
guests: <Guests />,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -124,6 +124,11 @@ export const config = {
|
|||||||
preview: () => request<{ config: string }>('/config/preview'),
|
preview: () => request<{ config: string }>('/config/preview'),
|
||||||
getSurgeToken: () => request<{ token: string }>('/config/surge-token'),
|
getSurgeToken: () => request<{ token: string }>('/config/surge-token'),
|
||||||
regenerateSurgeToken: () => request<{ token: string }>('/config/surge-token', { method: 'POST' }),
|
regenerateSurgeToken: () => request<{ token: string }>('/config/surge-token', { method: 'POST' }),
|
||||||
|
getCustom: () => request<{ content: string }>('/config/custom'),
|
||||||
|
saveCustom: (content: string) => request<{ ok: boolean }>('/config/custom', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ content }),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Guest links
|
// Guest links
|
||||||
|
|||||||
116
web/src/components/CustomConfig.tsx
Normal file
116
web/src/components/CustomConfig.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { config as configApi } from '../api';
|
||||||
|
|
||||||
|
const PLACEHOLDER = `[Proxy]
|
||||||
|
EN5-DIRECT = direct, interface = en5, allow-other-interface = false, dns-follow-interface = true
|
||||||
|
|
||||||
|
[Rule]
|
||||||
|
# 目标应用固定走 en5
|
||||||
|
PROCESS-NAME,/Applications/YourApp.app/,EN5-DIRECT
|
||||||
|
|
||||||
|
# 其他所有流量按照 macOS 系统默认路由出网
|
||||||
|
FINAL,DIRECT`;
|
||||||
|
|
||||||
|
export default function CustomConfig() {
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [saved, setSaved] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
configApi.getCustom().then(data => {
|
||||||
|
setContent(data.content);
|
||||||
|
setSaved(data.content);
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dirty = content !== saved;
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setMessage('');
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await configApi.saveCustom(content);
|
||||||
|
setSaved(content);
|
||||||
|
setMessage('已保存,将合并到 Surge 输出配置');
|
||||||
|
setTimeout(() => setMessage(''), 3000);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 style={styles.title}>自定义配置</h2>
|
||||||
|
<p style={styles.subtitle}>
|
||||||
|
按 Surge 配置格式书写(如 [Proxy]、[Rule]),保存后合并到最终输出的 Surge 配置中
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={styles.hints}>
|
||||||
|
<div>· [Proxy] 段的自定义节点会追加到输出的 [Proxy] 末尾(不加入策略组,供规则直接引用)</div>
|
||||||
|
<div>· [Rule] 段的自定义规则注入到输出 [Rule] 的最顶部,优先级高于「规则」面板中的规则</div>
|
||||||
|
<div>· 其他段(如 [Host])注入到对应段顶部,模板中不存在的段会追加到文件末尾</div>
|
||||||
|
<div>· 仅对管理员订阅链接生效,游客链接不包含自定义配置</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={content}
|
||||||
|
onChange={e => setContent(e.target.value)}
|
||||||
|
placeholder={PLACEHOLDER}
|
||||||
|
spellCheck={false}
|
||||||
|
style={styles.textarea}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 12 }}>
|
||||||
|
<button className="primary" onClick={handleSave} disabled={saving || !dirty}>
|
||||||
|
{saving ? '保存中...' : '保存'}
|
||||||
|
</button>
|
||||||
|
{dirty && <span style={{ fontSize: 12, color: 'var(--warning, #e0af68)' }}>有未保存的修改</span>}
|
||||||
|
{message && <span style={{ fontSize: 12, color: 'var(--success)' }}>{message}</span>}
|
||||||
|
{error && <span style={{ fontSize: 12, color: 'var(--danger)' }}>{error}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
title: {
|
||||||
|
fontFamily: 'var(--font-mono)' as const,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 600 as const,
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
hints: {
|
||||||
|
background: 'var(--bg-input)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
padding: '10px 14px',
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 1.8,
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
textarea: {
|
||||||
|
width: '100%',
|
||||||
|
minHeight: 'calc(100vh - 400px)',
|
||||||
|
background: 'var(--bg-input)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
padding: 16,
|
||||||
|
fontFamily: 'var(--font-mono)' as const,
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
resize: 'vertical' as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { stats as statsApi } from '../api';
|
import { stats as statsApi } from '../api';
|
||||||
|
|
||||||
type Panel = 'subscriptions' | 'static-nodes' | 'node-selector' | 'rules' | 'output' | 'guests';
|
type Panel = 'subscriptions' | 'static-nodes' | 'node-selector' | 'rules' | 'custom-config' | 'output' | 'guests';
|
||||||
|
|
||||||
const NAV_ITEMS: { key: Panel; label: string; icon: string }[] = [
|
const NAV_ITEMS: { key: Panel; label: string; icon: string }[] = [
|
||||||
{ key: 'subscriptions', label: '订阅', icon: '⟐' },
|
{ key: 'subscriptions', label: '订阅', icon: '⟐' },
|
||||||
{ key: 'static-nodes', label: '节点', icon: '◈' },
|
{ key: 'static-nodes', label: '节点', icon: '◈' },
|
||||||
{ key: 'node-selector', label: '选择', icon: '☰' },
|
{ key: 'node-selector', label: '选择', icon: '☰' },
|
||||||
{ key: 'rules', label: '规则', icon: '⧖' },
|
{ key: 'rules', label: '规则', icon: '⧖' },
|
||||||
|
{ key: 'custom-config', label: '自定义', icon: '✎' },
|
||||||
{ key: 'output', label: '输出', icon: '▸' },
|
{ key: 'output', label: '输出', icon: '▸' },
|
||||||
{ key: 'guests', label: '游客', icon: '◐' },
|
{ key: 'guests', label: '游客', icon: '◐' },
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user