追加 Hosts、路径撞名拦截、连接页表头随行滚动、GEO 借内核通路
设置页内核参数加 hosts 行,铅笔进弹窗编辑(顶层 hosts: 键, config.go:454,值单个或数组 parseHosts config.go:1131)。弹窗里随便改、 保存才一次性提交——写配置有几秒的校验重载,逐行失焦提交会把等待塞进 每次输入。没动过的行原样写回,数组不被重整形。 订阅和规则组新增时查缓存路径撞名:路径由名字净化而来,「订阅 A」和 「订阅/A」是两个名字、同一个文件,名字查重挡不住,会互相覆盖缓存。 纯函数在 shared 带测试,页面提交前拦。 连接页表头挪进滚动容器加 sticky:.scroller 声明 overflow-y 后按规范 连带拿到横向滚动上下文,表头留在外面时窄屏横向滚动两者脱节。表头 40px 的窗口偏移由 overscan 8 行吸收,不配 scrollMargin。 GEO 单文件下载先借内核的混合端口出去再退直连——内核自己更新 GEO 走 的是它的分流,Patchbay 直连 GitHub 不一定通。ECharts 拆独立 chunk, 业务代码改动不再让这 500KB 跟着换 hash。
This commit is contained in:
+25
-5
@@ -41,17 +41,31 @@ export async function geoStatus(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function download(url: string, proxy: string | null): Promise<Uint8Array> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
signal: AbortSignal.timeout(180_000),
|
||||||
|
redirect: 'follow',
|
||||||
|
...(proxy ? { proxy } : {}),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${String(res.status)}`)
|
||||||
|
return new Uint8Array(await res.arrayBuffer())
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 下载单个 GEO 文件写进内核工作目录。
|
* 下载单个 GEO 文件写进内核工作目录。
|
||||||
*
|
*
|
||||||
* 内核的更新接口是全量的(POST /configs/geo,按 enable 标记更新它用到的类型),
|
* 内核的更新接口是全量的(POST /configs/geo,按 enable 标记更新它用到的类型),
|
||||||
* 单文件只能 Patchbay 自己下。代价是拿不到内核那套完整校验,只能做形状检查
|
* 单文件只能 Patchbay 自己下。代价是拿不到内核那套完整校验,只能做形状检查
|
||||||
* (shared/geo.ts),也不触发热加载——内核用 mmap 缓存着旧数据,重启后才换新。
|
* (shared/geo.ts),也不触发热加载——内核用 mmap 缓存着旧数据,重启后才换新。
|
||||||
|
*
|
||||||
|
* `proxy` 给的话先经它出去(内核自己更新 GEO 时走的是它自己的分流,直连
|
||||||
|
* GitHub 不一定通,Patchbay 借内核的混合端口能拿到同样的通路),失败再直连。
|
||||||
*/
|
*/
|
||||||
export async function syncGeoFile(
|
export async function syncGeoFile(
|
||||||
env: Env,
|
env: Env,
|
||||||
configKey: string,
|
configKey: string,
|
||||||
geoxUrl: Record<string, unknown>,
|
geoxUrl: Record<string, unknown>,
|
||||||
|
proxy: string | null,
|
||||||
): Promise<{ ok: true } | { ok: false; message: string }> {
|
): Promise<{ ok: true } | { ok: false; message: string }> {
|
||||||
const spec = GEO_FILES.find((g) => g.configKey === configKey)
|
const spec = GEO_FILES.find((g) => g.configKey === configKey)
|
||||||
if (!spec) return { ok: false, message: `未知的 GEO 类型:${configKey}` }
|
if (!spec) return { ok: false, message: `未知的 GEO 类型:${configKey}` }
|
||||||
@@ -59,11 +73,17 @@ export async function syncGeoFile(
|
|||||||
|
|
||||||
let bytes: Uint8Array
|
let bytes: Uint8Array
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, { signal: AbortSignal.timeout(300_000), redirect: 'follow' })
|
bytes = await download(url, proxy)
|
||||||
if (!res.ok) return { ok: false, message: `下载失败:HTTP ${String(res.status)}` }
|
} catch (viaProxy) {
|
||||||
bytes = new Uint8Array(await res.arrayBuffer())
|
if (!proxy) return { ok: false, message: `下载失败:${String(viaProxy)}` }
|
||||||
} catch (error) {
|
try {
|
||||||
return { ok: false, message: `下载失败:${String(error)}` }
|
bytes = await download(url, null)
|
||||||
|
} catch (direct) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: `经内核代理(${String(viaProxy)})和直连(${String(direct)})都没能下载。`,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const shapeOk =
|
const shapeOk =
|
||||||
|
|||||||
+7
-1
@@ -312,7 +312,13 @@ function makeRoute(env: Env, token: string) {
|
|||||||
if (typeof body.key !== 'string') return json({ message: '请求缺少 key 字段' }, 400)
|
if (typeof body.key !== 'string') return json({ message: '请求缺少 key 字段' }, 400)
|
||||||
const snapshot = await readConfig(env)
|
const snapshot = await readConfig(env)
|
||||||
const geox = (snapshot.data['geox-url'] ?? {}) as Record<string, unknown>
|
const geox = (snapshot.data['geox-url'] ?? {}) as Record<string, unknown>
|
||||||
const result = await syncGeoFile(env, body.key, geox)
|
// 内核在线且开着老式 http 入口时借它的通路下载,直连 GitHub 不一定通
|
||||||
|
const port = [snapshot.data['mixed-port'], snapshot.data.port].find(
|
||||||
|
(p): p is number => typeof p === 'number' && p > 0,
|
||||||
|
)
|
||||||
|
const kernelUp = port !== undefined && (await kernelVersion(env)).online
|
||||||
|
const proxy = kernelUp ? `http://127.0.0.1:${String(port)}` : null
|
||||||
|
const result = await syncGeoFile(env, body.key, geox, proxy)
|
||||||
return result.ok ? json({ ok: true }) : json({ message: result.message }, 502)
|
return result.ok ? json({ ok: true }) : json({ message: result.message }, 502)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import {
|
|||||||
listSubscriptions,
|
listSubscriptions,
|
||||||
providerNames,
|
providerNames,
|
||||||
removeSubscription,
|
removeSubscription,
|
||||||
|
subscriptionPathConflict,
|
||||||
subscriptionReferences,
|
subscriptionReferences,
|
||||||
updateSubscription,
|
updateSubscription,
|
||||||
} from './providers.ts'
|
} from './providers.ts'
|
||||||
import { applySetup, chainProviderName } from './inbound-groups.ts'
|
import { applySetup, chainProviderName, providerCachePath } from './inbound-groups.ts'
|
||||||
import type { OutboundSetup } from './inbound-groups.ts'
|
import type { OutboundSetup } from './inbound-groups.ts'
|
||||||
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
||||||
|
|
||||||
@@ -181,3 +182,18 @@ describe('删除前的引用检查', () => {
|
|||||||
expect(subscriptionReferences(base(), 'ghost')).toEqual([])
|
expect(subscriptionReferences(base(), 'ghost')).toEqual([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('缓存路径撞名', () => {
|
||||||
|
it('名字只差符号时净化成同一个路径,能认出来', () => {
|
||||||
|
const data = addSubscription({}, '订阅 A', 'https://a.example/sub')
|
||||||
|
const path = providerCachePath('订阅/A')
|
||||||
|
expect(path).toBe(providerCachePath('订阅 A'))
|
||||||
|
expect(subscriptionPathConflict(data, '订阅/A', path)).toBe('订阅 A')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('不同路径、或者就是自己,都不算撞', () => {
|
||||||
|
const data = addSubscription({}, '订阅 A', 'https://a.example/sub')
|
||||||
|
expect(subscriptionPathConflict(data, 'B', providerCachePath('B'))).toBeNull()
|
||||||
|
expect(subscriptionPathConflict(data, '订阅 A', providerCachePath('订阅 A'))).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -109,6 +109,26 @@ export function addSubscription(
|
|||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新订阅的缓存路径是否和现有订阅的撞车。
|
||||||
|
*
|
||||||
|
* 名字查重挡不住这种:路径由名字净化而来,「订阅 A」和「订阅/A」是两个名字、
|
||||||
|
* 同一个 `providers/订阅-A.yaml`,两个订阅会互相覆盖对方的缓存文件。
|
||||||
|
* 返回撞上的那个订阅名,没撞返回 null。
|
||||||
|
*/
|
||||||
|
export function subscriptionPathConflict(
|
||||||
|
data: ConfigObject,
|
||||||
|
selfName: string,
|
||||||
|
path: string,
|
||||||
|
): string | null {
|
||||||
|
for (const [name, decl] of Object.entries(providersOf(data))) {
|
||||||
|
if (name === selfName) continue
|
||||||
|
// 没写 path 的声明由内核按 url 哈希生成路径,和这里的净化路径撞不上
|
||||||
|
if ((decl as ConfigObject).path === path) return name
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 新增一个来自本机文件的订阅。数据就在 path 指的文件里(内容由上传流程先写好),
|
* 新增一个来自本机文件的订阅。数据就在 path 指的文件里(内容由上传流程先写好),
|
||||||
* 内核靠文件监视跟进变化,没有地址也没有更新间隔。
|
* 内核靠文件监视跟进变化,没有地址也没有更新间隔。
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
parseRule,
|
parseRule,
|
||||||
planIniImport,
|
planIniImport,
|
||||||
ruleProviderPath,
|
ruleProviderPath,
|
||||||
|
ruleProviderPathConflict,
|
||||||
ruleSetReferences,
|
ruleSetReferences,
|
||||||
setRuleProviders,
|
setRuleProviders,
|
||||||
setRules,
|
setRules,
|
||||||
@@ -293,4 +294,16 @@ describe('清单格式与缓存路径', () => {
|
|||||||
expect(ruleProviderPath('ads', 'yaml')).toBe('./providers/rules/ads.yaml')
|
expect(ruleProviderPath('ads', 'yaml')).toBe('./providers/rules/ads.yaml')
|
||||||
expect(ruleProviderPath('🛑 广告', 'yaml')).toBe('./providers/rules/广告.yaml')
|
expect(ruleProviderPath('🛑 广告', 'yaml')).toBe('./providers/rules/广告.yaml')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('名字只差符号时清单路径撞车,能认出来;自己不算', () => {
|
||||||
|
const data = {
|
||||||
|
'rule-providers': {
|
||||||
|
'🛑 广告': { behavior: 'domain', path: './providers/rules/广告.yaml' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const path = ruleProviderPath('广告', 'yaml')
|
||||||
|
expect(ruleProviderPathConflict(data, '广告', path)).toBe('🛑 广告')
|
||||||
|
expect(ruleProviderPathConflict(data, '🛑 广告', path)).toBeNull()
|
||||||
|
expect(ruleProviderPathConflict(data, 'x', ruleProviderPath('x', 'yaml'))).toBeNull()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -219,6 +219,25 @@ export function ruleProviderPath(name: string, format: string): string {
|
|||||||
return `./providers/rules/${safeName(name) || shortHash(name)}.${ext}`
|
return `./providers/rules/${safeName(name) || shortHash(name)}.${ext}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新规则组的清单路径是否和现有规则组的撞车。
|
||||||
|
*
|
||||||
|
* 名字查重挡不住这种:路径由名字净化而来,「广告 A」和「广告/A」是两个名字、
|
||||||
|
* 同一个清单文件,互相覆盖。返回撞上的那个规则组名,没撞返回 null。
|
||||||
|
*/
|
||||||
|
export function ruleProviderPathConflict(
|
||||||
|
data: ConfigObject,
|
||||||
|
selfName: string,
|
||||||
|
path: string,
|
||||||
|
): string | null {
|
||||||
|
for (const [name, decl] of Object.entries(ruleProvidersOf(data))) {
|
||||||
|
if (name === selfName) continue
|
||||||
|
// 没写 path 的声明由内核按 url 哈希生成路径,和这里的净化路径撞不上
|
||||||
|
if (decl.path === path) return name
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
/** 清单格式按来源的扩展名推断。拿不准时回到 yaml——那是内核没写 format 时的默认。 */
|
/** 清单格式按来源的扩展名推断。拿不准时回到 yaml——那是内核没写 format 时的默认。 */
|
||||||
export function inferRuleFormat(source: string): 'yaml' | 'text' | 'mrs' {
|
export function inferRuleFormat(source: string): 'yaml' | 'text' | 'mrs' {
|
||||||
const clean = (source.split(/[?#]/)[0] ?? '').toLowerCase()
|
const clean = (source.split(/[?#]/)[0] ?? '').toLowerCase()
|
||||||
|
|||||||
@@ -35,5 +35,14 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
outDir: '../web-dist',
|
outDir: '../web-dist',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
// ECharts 单独一个 chunk。应用一进来就是仪表盘,懒加载省不了首屏,
|
||||||
|
// 拆开图的是缓存粒度:业务代码天天变,这 400KB 的库不跟着换 hash 重下
|
||||||
|
manualChunks: {
|
||||||
|
echarts: ['echarts/core', 'echarts/charts', 'echarts/components', 'echarts/renderers'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
listSubscriptions,
|
listSubscriptions,
|
||||||
providerNames,
|
providerNames,
|
||||||
removeSubscription,
|
removeSubscription,
|
||||||
|
subscriptionPathConflict,
|
||||||
subscriptionReferences,
|
subscriptionReferences,
|
||||||
updateSubscription,
|
updateSubscription,
|
||||||
} from '@shared/providers.ts'
|
} from '@shared/providers.ts'
|
||||||
@@ -148,6 +149,19 @@ async function submitForm(draft: {
|
|||||||
}) {
|
}) {
|
||||||
const editing = formEditing.value
|
const editing = formEditing.value
|
||||||
|
|
||||||
|
// 名字查重放行了、但净化后的缓存路径和别人相同的,在这里拦(编辑不改名,只查新增)
|
||||||
|
if (!editing) {
|
||||||
|
const path = providerCachePath(draft.name)
|
||||||
|
const clash = subscriptionPathConflict(props.data, draft.name, path)
|
||||||
|
if (clash) {
|
||||||
|
toast(`「${draft.name}」和「${clash}」的缓存文件会是同一个`, {
|
||||||
|
kind: 'error',
|
||||||
|
hint: '名字只在符号或空格上有差异时会净化成同一个文件名,换一个名字。',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 从文件导入:先把内容写到订阅的缓存路径上,成了再写声明。
|
// 从文件导入:先把内容写到订阅的缓存路径上,成了再写声明。
|
||||||
// 顺序不能反——声明先落盘的话,内核重载会去读一个还不存在的文件
|
// 顺序不能反——声明先落盘的话,内核重载会去读一个还不存在的文件
|
||||||
if (draft.file) {
|
if (draft.file) {
|
||||||
|
|||||||
@@ -175,30 +175,32 @@ async function closeAllConfirmed() {
|
|||||||
<p v-if="rows.length === 0" class="note">没有主机匹配「{{ query.trim() }}」。</p>
|
<p v-if="rows.length === 0" class="note">没有主机匹配「{{ query.trim() }}」。</p>
|
||||||
|
|
||||||
<div v-else class="table">
|
<div v-else class="table">
|
||||||
<div class="rowgrid thead">
|
<!-- 表头在滚动容器里而不是外面:.scroller 声明 overflow-y 后按规范会连带
|
||||||
<span>主机</span>
|
拿到自己的横向滚动上下文,表头留在外面的话窄屏横向滚动时两者会脱节。
|
||||||
<span>规则</span>
|
竖直方向靠 sticky 钉住。 -->
|
||||||
<span>出站链</span>
|
|
||||||
<span class="num">上传</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
:class="['th num', sort === 'download' && 'is-on']"
|
|
||||||
@click="sort = 'download'"
|
|
||||||
>
|
|
||||||
下载{{ sort === 'download' ? ' ↓' : '' }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
:class="['th num', sort === 'duration' && 'is-on']"
|
|
||||||
@click="sort = 'duration'"
|
|
||||||
>
|
|
||||||
持续时间{{ sort === 'duration' ? ' ↓' : '' }}
|
|
||||||
</button>
|
|
||||||
<span>入站</span>
|
|
||||||
<span></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div ref="scrollEl" class="scroller">
|
<div ref="scrollEl" class="scroller">
|
||||||
|
<div class="rowgrid thead">
|
||||||
|
<span>主机</span>
|
||||||
|
<span>规则</span>
|
||||||
|
<span>出站链</span>
|
||||||
|
<span class="num">上传</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="['th num', sort === 'download' && 'is-on']"
|
||||||
|
@click="sort = 'download'"
|
||||||
|
>
|
||||||
|
下载{{ sort === 'download' ? ' ↓' : '' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="['th num', sort === 'duration' && 'is-on']"
|
||||||
|
@click="sort = 'duration'"
|
||||||
|
>
|
||||||
|
持续时间{{ sort === 'duration' ? ' ↓' : '' }}
|
||||||
|
</button>
|
||||||
|
<span>入站</span>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
<div class="spacer" :style="{ height: `${totalSize}px` }">
|
<div class="spacer" :style="{ height: `${totalSize}px` }">
|
||||||
<div
|
<div
|
||||||
v-for="vRow in virtualRows"
|
v-for="vRow in virtualRows"
|
||||||
@@ -367,12 +369,12 @@ async function closeAllConfirmed() {
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 窄屏不挤变形,横向滚动 */
|
|
||||||
.table {
|
.table {
|
||||||
border: 1px solid var(--c-border);
|
border: 1px solid var(--c-border);
|
||||||
border-radius: var(--r-md);
|
border-radius: var(--r-md);
|
||||||
background: var(--c-surface);
|
background: var(--c-surface);
|
||||||
overflow-x: auto;
|
/* 只负责圆角裁切;横竖两个方向的滚动都归 .scroller 一个容器管 */
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rowgrid {
|
.rowgrid {
|
||||||
@@ -386,9 +388,15 @@ async function closeAllConfirmed() {
|
|||||||
padding: 0 var(--s-4);
|
padding: 0 var(--s-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 表头随内容横向滚动(同一个容器),竖直方向 sticky 钉在顶上。
|
||||||
|
行是绝对定位的,要 z-index 才能压住;背景不透明才不会透出滚过的行 */
|
||||||
.thead {
|
.thead {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
border-bottom: 1px solid var(--c-border);
|
border-bottom: 1px solid var(--c-border);
|
||||||
|
background: var(--c-surface);
|
||||||
color: var(--c-text-faint);
|
color: var(--c-text-faint);
|
||||||
font-size: var(--t-xs);
|
font-size: var(--t-xs);
|
||||||
}
|
}
|
||||||
@@ -412,9 +420,11 @@ async function closeAllConfirmed() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.scroller {
|
.scroller {
|
||||||
overflow-y: auto;
|
overflow: auto;
|
||||||
/* 表格最长不顶满一屏:留出头部和汇总条的位置,连接少时随内容缩短 */
|
/* 表格最长不顶满一屏:留出头部和汇总条的位置,连接少时随内容缩短 */
|
||||||
max-height: calc(100vh - 20rem);
|
max-height: calc(100vh - 20rem);
|
||||||
|
/* 表头占 40px,虚拟滚动的可见窗口按纯行高算会偏这 40px——overscan 8 行
|
||||||
|
(384px)足以盖住,不必给虚拟器配 scrollMargin */
|
||||||
}
|
}
|
||||||
|
|
||||||
.spacer {
|
.spacer {
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 追加 Hosts 的编辑弹窗:配置顶层的 `hosts:` 映射(config.go:454,值可以是
|
||||||
|
* 单个 IP/域名或数组,parseHosts config.go:1131)。
|
||||||
|
*
|
||||||
|
* 在弹窗里随便改,点保存才一次性提交——写配置要走校验和重载,每行失焦
|
||||||
|
* 就提交会把几秒的等待塞进每一次输入。
|
||||||
|
*
|
||||||
|
* 值的往返规则:没动过的行原样保留(数组仍是数组);动过的行按显示文本
|
||||||
|
* 重建,含逗号拆成数组,否则存字符串。
|
||||||
|
*/
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
|
||||||
|
import IconAdd from '~icons/ri/add-line'
|
||||||
|
import IconClose from '~icons/ri/close-line'
|
||||||
|
|
||||||
|
const props = defineProps<{ open: boolean; hosts: ConfigObject }>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ close: []; save: [hosts: ConfigObject] }>()
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
key: string
|
||||||
|
value: string
|
||||||
|
/** 值被编辑过。没动过的保存时用 original 原样写回 */
|
||||||
|
dirty: boolean
|
||||||
|
original: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
const dialog = ref<HTMLDialogElement | null>(null)
|
||||||
|
const rows = ref<Row[]>([])
|
||||||
|
/** 保存被拦下的行号(键空着或和别的行重复) */
|
||||||
|
const badRows = ref<Set<number>>(new Set())
|
||||||
|
|
||||||
|
function display(value: unknown): string {
|
||||||
|
if (typeof value === 'string') return value
|
||||||
|
if (Array.isArray(value)) return value.map(String).join(', ')
|
||||||
|
return JSON.stringify(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(open) => {
|
||||||
|
if (open) {
|
||||||
|
rows.value = Object.entries(props.hosts).map(([key, value]) => ({
|
||||||
|
key,
|
||||||
|
value: display(value),
|
||||||
|
dirty: false,
|
||||||
|
original: value,
|
||||||
|
}))
|
||||||
|
badRows.value = new Set()
|
||||||
|
dialog.value?.showModal()
|
||||||
|
} else {
|
||||||
|
dialog.value?.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function addRow() {
|
||||||
|
rows.value = [...rows.value, { key: '', value: '', dirty: true, original: undefined }]
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRow(index: number) {
|
||||||
|
rows.value = rows.value.filter((_, i) => i !== index)
|
||||||
|
badRows.value = new Set()
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
const bad = new Set<number>()
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const next: ConfigObject = {}
|
||||||
|
for (const [index, row] of rows.value.entries()) {
|
||||||
|
const key = row.key.trim()
|
||||||
|
const raw = row.value.trim()
|
||||||
|
if (key === '' && raw === '') continue
|
||||||
|
if (key === '' || seen.has(key)) {
|
||||||
|
bad.add(index)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen.add(key)
|
||||||
|
if (!row.dirty && row.original !== undefined) {
|
||||||
|
next[key] = row.original as ConfigObject[string]
|
||||||
|
} else {
|
||||||
|
next[key] = raw.includes(',')
|
||||||
|
? raw.split(',').map((part) => part.trim()).filter(Boolean)
|
||||||
|
: raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bad.size > 0) {
|
||||||
|
badRows.value = bad
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('save', next)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点在遮罩上就关闭。内容区的点击 target 是里面的元素,到不了 dialog 本身。 */
|
||||||
|
function onBackdrop(event: MouseEvent) {
|
||||||
|
if (event.target === dialog.value) emit('close')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<dialog
|
||||||
|
ref="dialog"
|
||||||
|
class="dlg"
|
||||||
|
@click="onBackdrop"
|
||||||
|
@close="emit('close')"
|
||||||
|
@cancel.prevent="emit('close')"
|
||||||
|
>
|
||||||
|
<div class="body">
|
||||||
|
<header class="head">
|
||||||
|
<h2>追加 Hosts</h2>
|
||||||
|
<button type="button" class="add" @click="addRow"><IconAdd /> 添加</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p v-if="rows.length === 0" class="faint">
|
||||||
|
没有条目。hosts 把域名固定解析到指定的 IP 或别名,值里用逗号分隔可以写多个。
|
||||||
|
</p>
|
||||||
|
<div v-else class="list">
|
||||||
|
<div v-for="(row, i) in rows" :key="i" :class="['hrow', badRows.has(i) && 'is-bad']">
|
||||||
|
<input
|
||||||
|
v-model="row.key"
|
||||||
|
class="input mono"
|
||||||
|
type="text"
|
||||||
|
placeholder="域名,如 router.lan"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="row.value"
|
||||||
|
class="input mono grow"
|
||||||
|
type="text"
|
||||||
|
placeholder="IP 或别名,逗号分隔多个"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
@input="row.dirty = true"
|
||||||
|
/>
|
||||||
|
<button type="button" class="x" title="删除这一行" @click="removeRow(i)">
|
||||||
|
<IconClose />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="foot">
|
||||||
|
<button type="button" class="btn" @click="emit('close')">取消</button>
|
||||||
|
<button type="button" class="btn go" @click="submit">保存</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dlg {
|
||||||
|
width: min(560px, calc(100vw - 2 * var(--s-5)));
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--c-border-strong);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
background: var(--c-surface);
|
||||||
|
color: var(--c-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlg::backdrop {
|
||||||
|
background: rgb(0 0 0 / 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
padding: var(--s-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--s-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.head h2 {
|
||||||
|
font-size: var(--t-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-1);
|
||||||
|
padding: var(--s-1) var(--s-2);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: none;
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.add:hover {
|
||||||
|
background: var(--c-surface-sunken);
|
||||||
|
color: var(--c-text);
|
||||||
|
}
|
||||||
|
.add svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faint {
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
line-height: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s-2);
|
||||||
|
max-height: 50vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--c-border-strong) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hrow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 180px;
|
||||||
|
padding: var(--s-2) var(--s-3);
|
||||||
|
border: 1px solid var(--c-border-strong);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: var(--c-bg);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grow {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-bad .input {
|
||||||
|
border-color: var(--c-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.x {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: none;
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.x:hover {
|
||||||
|
background: var(--c-surface-sunken);
|
||||||
|
color: var(--c-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--s-3);
|
||||||
|
margin-top: var(--s-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: var(--s-2) var(--s-4);
|
||||||
|
border: 1px solid var(--c-border-strong);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: var(--c-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn:hover {
|
||||||
|
border-color: var(--c-accent);
|
||||||
|
color: var(--c-accent-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.go {
|
||||||
|
border-color: transparent;
|
||||||
|
background: var(--c-accent);
|
||||||
|
color: var(--c-bg);
|
||||||
|
}
|
||||||
|
.go:hover {
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--c-bg);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
missingLanRules,
|
missingLanRules,
|
||||||
moveRule,
|
moveRule,
|
||||||
parseRule,
|
parseRule,
|
||||||
|
ruleProviderPathConflict,
|
||||||
ruleProvidersOf,
|
ruleProvidersOf,
|
||||||
ruleSetReferences,
|
ruleSetReferences,
|
||||||
rulesOf,
|
rulesOf,
|
||||||
@@ -219,6 +220,18 @@ async function submitGroup(entry: {
|
|||||||
declaration: ConfigObject
|
declaration: ConfigObject
|
||||||
file: { content: string } | null
|
file: { content: string } | null
|
||||||
}) {
|
}) {
|
||||||
|
// 名字查重放行了、但净化后的清单路径和别人相同的,在这里拦
|
||||||
|
if (typeof entry.declaration.path === 'string') {
|
||||||
|
const clash = ruleProviderPathConflict(props.data, entry.name, entry.declaration.path)
|
||||||
|
if (clash) {
|
||||||
|
toast(`「${entry.name}」和「${clash}」的清单文件会是同一个`, {
|
||||||
|
kind: 'error',
|
||||||
|
hint: '名字只在符号或空格上有差异时会净化成同一个文件名,换一个名字。',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 上传的清单先落盘再写声明。顺序不能反——声明先生效的话,内核重载会去读
|
// 上传的清单先落盘再写声明。顺序不能反——声明先生效的话,内核重载会去读
|
||||||
// 一个还不存在的文件
|
// 一个还不存在的文件
|
||||||
if (entry.file && typeof entry.declaration.path === 'string') {
|
if (entry.file && typeof entry.declaration.path === 'string') {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { DISCLAIMER, DISCLAIMER_TITLE } from '../legal'
|
|||||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||||
import InlineEdit from '../components/InlineEdit.vue'
|
import InlineEdit from '../components/InlineEdit.vue'
|
||||||
import SelectBox from '../components/SelectBox.vue'
|
import SelectBox from '../components/SelectBox.vue'
|
||||||
|
import HostsForm from './HostsForm.vue'
|
||||||
import IconRight from '~icons/ri/arrow-right-s-line'
|
import IconRight from '~icons/ri/arrow-right-s-line'
|
||||||
import IconBack from '~icons/ri/arrow-left-s-line'
|
import IconBack from '~icons/ri/arrow-left-s-line'
|
||||||
import IconDownload from '~icons/ri/download-2-line'
|
import IconDownload from '~icons/ri/download-2-line'
|
||||||
@@ -60,17 +61,19 @@ function saveTestUrl(value: string) {
|
|||||||
|
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
|
||||||
/** 所有改配置文件的动作都从这里走。 */
|
/** 所有改配置文件的动作都从这里走。反馈走轻提示,返回值告知成败。 */
|
||||||
async function commitConfig(next: ConfigObject, success: string) {
|
async function commitConfig(next: ConfigObject, success: string): Promise<boolean> {
|
||||||
if (saving.value) return
|
if (saving.value) return false
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const result = await api.applyConfig(next, props.revision)
|
const result = await api.applyConfig(next, props.revision)
|
||||||
// 内核不在线时只写了文件没重载,如实说,不能只报成功
|
// 内核不在线时只写了文件没重载,如实说,不能只报成功
|
||||||
toast(result.reloaded ? success : `${success}(${result.reloadMessage})`)
|
toast(result.reloaded ? success : `${success}(${result.reloadMessage})`)
|
||||||
emit('saved')
|
emit('saved')
|
||||||
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toastError(error)
|
toastError(error)
|
||||||
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
@@ -101,6 +104,25 @@ function saveNumberKey(key: string, value: string, success: string) {
|
|||||||
void commitConfig(next, success)
|
void commitConfig(next, success)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 追加 Hosts ───────────────────────────────── */
|
||||||
|
|
||||||
|
const hostsOpen = ref(false)
|
||||||
|
|
||||||
|
const hostsOf = (): ConfigObject => {
|
||||||
|
const value = props.data.hosts
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
? (value as ConfigObject)
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitHosts(hosts: ConfigObject) {
|
||||||
|
const next = clone()
|
||||||
|
if (Object.keys(hosts).length === 0) delete next.hosts
|
||||||
|
else next.hosts = hosts
|
||||||
|
const ok = await commitConfig(next, 'hosts 已保存')
|
||||||
|
if (ok) hostsOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
/* ── 资源(GEO 数据库) ───────────────────────── */
|
/* ── 资源(GEO 数据库) ───────────────────────── */
|
||||||
|
|
||||||
const geoFiles = ref<GeoFileStatus[] | null>(null)
|
const geoFiles = ref<GeoFileStatus[] | null>(null)
|
||||||
@@ -451,6 +473,25 @@ const backend = location.origin
|
|||||||
@submit="(v) => saveNumberKey('keep-alive-interval', v, 'TCP 保持活动间隔已保存')"
|
@submit="(v) => saveNumberKey('keep-alive-interval', v, 'TCP 保持活动间隔已保存')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="key mono">hosts</span>
|
||||||
|
<span class="g-right">
|
||||||
|
<span :class="['g-meta', Object.keys(hostsOf()).length === 0 && 'faint']">
|
||||||
|
{{
|
||||||
|
Object.keys(hostsOf()).length > 0 ? `${Object.keys(hostsOf()).length} 条` : '未设置'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ico"
|
||||||
|
title="编辑"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="hostsOpen = true"
|
||||||
|
>
|
||||||
|
<IconEdit />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="group">
|
<section class="group">
|
||||||
@@ -601,6 +642,13 @@ const backend = location.origin
|
|||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<HostsForm
|
||||||
|
:open="hostsOpen"
|
||||||
|
:hosts="hostsOf()"
|
||||||
|
@close="hostsOpen = false"
|
||||||
|
@save="submitHosts"
|
||||||
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
:open="pendingUpload !== null"
|
:open="pendingUpload !== null"
|
||||||
:message="uploadMessage()"
|
:message="uploadMessage()"
|
||||||
|
|||||||
Reference in New Issue
Block a user