追加 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:
2026-08-12 02:47:17 +08:00
parent b276c4a8fe
commit 275f536429
12 changed files with 513 additions and 36 deletions
+289
View File
@@ -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>