追加 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:
@@ -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>
|
||||
Reference in New Issue
Block a user