设置页内核参数加 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。
937 lines
27 KiB
Vue
937 lines
27 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* 设置页。分两类,反馈方式不同:
|
||
* - 本地偏好(主题、测速地址)点了就生效,没有等待
|
||
* - 动配置文件的(内核参数、GEO 下载地址、上传恢复/导入)走服务端完整的
|
||
* 保护链路,有校验、备份、重载和失败回退,反馈方式照路由页的 commit()
|
||
*
|
||
* 单值编辑统一用 InlineEdit:平时纯文本,点铅笔才出输入框,Enter 或失焦提交。
|
||
* 备份列表不摆出来:自动备份是落盘链路的安全网,不是日常操作对象;
|
||
* 「配置文件」一组给下载和上传,上传按 Patchbay 标记分「恢复」和「导入」两个入口。
|
||
*/
|
||
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||
import { parse } from 'yaml'
|
||
import { hasValidMarker } from '@shared/config-marker.ts'
|
||
import { formatAgo, formatBytes } from '@shared/format.ts'
|
||
import type { GeoFileStatus } from '@shared/geo.ts'
|
||
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
|
||
import { api } from '../api'
|
||
import { toast, toastError } from '../toast'
|
||
import { DEFAULT_TEST_URL, testUrl, theme } from '../prefs'
|
||
import type { Theme } from '../prefs'
|
||
import { kernel } from '../runtime'
|
||
import { DISCLAIMER, DISCLAIMER_TITLE } from '../legal'
|
||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||
import InlineEdit from '../components/InlineEdit.vue'
|
||
import SelectBox from '../components/SelectBox.vue'
|
||
import HostsForm from './HostsForm.vue'
|
||
import IconRight from '~icons/ri/arrow-right-s-line'
|
||
import IconBack from '~icons/ri/arrow-left-s-line'
|
||
import IconDownload from '~icons/ri/download-2-line'
|
||
import IconEdit from '~icons/ri/pencil-line'
|
||
import IconSync from '~icons/ri/refresh-line'
|
||
|
||
const props = defineProps<{ data: ConfigObject; revision: string }>()
|
||
|
||
/** 动了配置文件,外壳需要重读整份配置。 */
|
||
const emit = defineEmits<{ saved: [] }>()
|
||
|
||
/* ── 外观与测速 ───────────────────────────────── */
|
||
|
||
const themeOptions: { value: Theme; label: string }[] = [
|
||
{ value: 'system', label: '跟随系统' },
|
||
{ value: 'light', label: '浅色' },
|
||
{ value: 'dark', label: '深色' },
|
||
]
|
||
|
||
// 模板里直接给 import 进来的 ref 赋值编译结果不可靠,走显式的赋值函数
|
||
function pickTheme(value: Theme) {
|
||
theme.value = value
|
||
}
|
||
|
||
const isUrlOrEmpty = (value: string) => value === '' || /^https?:\/\//.test(value)
|
||
|
||
/** 本地偏好,立刻生效。清空等于回到默认。 */
|
||
function saveTestUrl(value: string) {
|
||
testUrl.value = value || DEFAULT_TEST_URL
|
||
}
|
||
|
||
/* ── 状态与反馈 ───────────────────────────────── */
|
||
/* 动作结果一律走右上角的轻提示(web/toast.ts),页面里不再放常驻的横幅 */
|
||
|
||
const saving = ref(false)
|
||
|
||
/** 所有改配置文件的动作都从这里走。反馈走轻提示,返回值告知成败。 */
|
||
async function commitConfig(next: ConfigObject, success: string): Promise<boolean> {
|
||
if (saving.value) return false
|
||
saving.value = true
|
||
try {
|
||
const result = await api.applyConfig(next, props.revision)
|
||
// 内核不在线时只写了文件没重载,如实说,不能只报成功
|
||
toast(result.reloaded ? success : `${success}(${result.reloadMessage})`)
|
||
emit('saved')
|
||
return true
|
||
} catch (error) {
|
||
toastError(error)
|
||
return false
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
const clone = () => JSON.parse(JSON.stringify(props.data)) as ConfigObject
|
||
|
||
/* ── 内核参数 ─────────────────────────────────── */
|
||
|
||
/** 老式顶层端口,键名用配置原词。空表示没启用(配置里不写这个键)。 */
|
||
const PORT_KEYS = ['mixed-port', 'port', 'socks-port', 'redir-port', 'tproxy-port'] as const
|
||
|
||
function numberText(key: string): string {
|
||
const value = props.data[key]
|
||
return typeof value === 'number' ? String(value) : ''
|
||
}
|
||
|
||
const isPortOrEmpty = (value: string) =>
|
||
value === '' || (/^\d+$/.test(value) && Number(value) >= 1 && Number(value) <= 65535)
|
||
|
||
const isPosIntOrEmpty = (value: string) => value === '' || (/^\d+$/.test(value) && Number(value) >= 1)
|
||
|
||
/** 单个数字键的保存:空值删键,其余写数字。端口和 keep-alive 共用。 */
|
||
function saveNumberKey(key: string, value: string, success: string) {
|
||
const next = clone()
|
||
if (value === '') delete next[key]
|
||
else next[key] = Number(value)
|
||
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 数据库) ───────────────────────── */
|
||
|
||
const geoFiles = ref<GeoFileStatus[] | null>(null)
|
||
const geoBusy = ref(false)
|
||
|
||
async function loadGeo() {
|
||
// 失败给空数组而不是停在「载入中」,那是个出不去的死状态
|
||
geoFiles.value = await api
|
||
.geoStatus()
|
||
.then((r) => r.files)
|
||
.catch(() => [])
|
||
}
|
||
|
||
onMounted(() => void loadGeo())
|
||
// 保存过配置(比如改了下载地址)后重新问一遍生效中的地址
|
||
watch(() => props.data, () => void loadGeo())
|
||
|
||
function geoMeta(g: GeoFileStatus): string {
|
||
if (g.size === null || !g.updatedAt) return '未下载'
|
||
return `${formatBytes(g.size)} · ${formatAgo(new Date(g.updatedAt), new Date())}`
|
||
}
|
||
|
||
/**
|
||
* 更新全部:让内核自己更新(运行时动作,带内核的完整校验和热加载,
|
||
* 但只更新配置实际用到的类型)。
|
||
*/
|
||
async function updateGeo() {
|
||
if (geoBusy.value || geoSyncing.value !== null || !kernel.value.online) return
|
||
geoBusy.value = true
|
||
try {
|
||
await api.updateGeo()
|
||
toast('GEO 数据库已更新')
|
||
await loadGeo()
|
||
} catch (error) {
|
||
toastError(error)
|
||
} finally {
|
||
geoBusy.value = false
|
||
}
|
||
}
|
||
|
||
/** 正在单独下载的那一行(configKey) */
|
||
const geoSyncing = ref<string | null>(null)
|
||
|
||
/** 单独更新一个文件:服务端自己下载,内核重启后换用新文件。 */
|
||
async function syncOne(g: GeoFileStatus) {
|
||
if (geoBusy.value || geoSyncing.value !== null) return
|
||
geoSyncing.value = g.configKey
|
||
try {
|
||
await api.syncGeoFile(g.configKey)
|
||
toast(`${g.key} 已下载`, { hint: '内核还在用旧数据,重启内核后换用新文件。' })
|
||
await loadGeo()
|
||
} catch (error) {
|
||
toastError(error)
|
||
} finally {
|
||
geoSyncing.value = null
|
||
}
|
||
}
|
||
|
||
/** 正在编辑下载地址的那一行(configKey),一次只开一行 */
|
||
const geoEditing = ref<string | null>(null)
|
||
const geoDraft = ref('')
|
||
const geoBad = ref(false)
|
||
const geoInput = ref<HTMLInputElement | null>(null)
|
||
|
||
// 输入框在 v-for 里,字符串 ref 会被 Vue 收成数组;同时只开一行,函数 ref 存单个就够
|
||
function setGeoInput(el: unknown) {
|
||
geoInput.value = el instanceof HTMLInputElement ? el : null
|
||
}
|
||
|
||
async function openGeoEdit(g: GeoFileStatus) {
|
||
geoDraft.value = g.url
|
||
geoBad.value = false
|
||
geoEditing.value = g.configKey
|
||
await nextTick()
|
||
geoInput.value?.focus()
|
||
geoInput.value?.select()
|
||
}
|
||
|
||
/**
|
||
* 编辑按钮在输入框打开期间还留在行里。直接点它会先触发输入框的 blur(关闭)
|
||
* 再触发 click(重新打开),肉眼看就是闪一下。按钮上 mousedown.prevent 保住
|
||
* 焦点不掉,click 再显式决定开还是关。
|
||
*/
|
||
function toggleGeoEdit(g: GeoFileStatus) {
|
||
if (geoEditing.value === g.configKey) finishGeoEdit(g, true)
|
||
else void openGeoEdit(g)
|
||
}
|
||
|
||
/** lenient 是失焦路径:无效就放弃改动。空值 = 回到内核默认(配置里删掉这个键)。 */
|
||
function finishGeoEdit(g: GeoFileStatus, lenient: boolean) {
|
||
if (geoEditing.value !== g.configKey) return
|
||
const next = geoDraft.value.trim()
|
||
if (!isUrlOrEmpty(next)) {
|
||
if (lenient) geoEditing.value = null
|
||
else geoBad.value = true
|
||
return
|
||
}
|
||
geoEditing.value = null
|
||
if (next === g.url) return
|
||
const data = clone()
|
||
const geox: ConfigObject = { ...((data['geox-url'] ?? {}) as ConfigObject) }
|
||
if (next === '') delete geox[g.configKey]
|
||
else geox[g.configKey] = next
|
||
if (Object.keys(geox).length === 0) delete data['geox-url']
|
||
else data['geox-url'] = geox
|
||
void commitConfig(data, `${g.key} 的下载地址已保存`)
|
||
}
|
||
|
||
/* ── 配置文件 ─────────────────────────────────── */
|
||
|
||
async function downloadConfig() {
|
||
try {
|
||
const text = await api.configRaw()
|
||
const url = URL.createObjectURL(new Blob([text], { type: 'text/yaml' }))
|
||
const link = document.createElement('a')
|
||
link.href = url
|
||
link.download = 'config.yaml'
|
||
link.click()
|
||
URL.revokeObjectURL(url)
|
||
} catch (error) {
|
||
toastError(error)
|
||
}
|
||
}
|
||
|
||
type UploadKind = 'restore' | 'import'
|
||
|
||
const strategy = ref<'compatible' | 'override'>('compatible')
|
||
const STRATEGY_OPTIONS = [
|
||
{ value: 'compatible', label: '兼容' },
|
||
{ value: 'override', label: '覆盖' },
|
||
]
|
||
|
||
/** 窗口里正拖着文件。为真时两个上传行整行变成投放区。 */
|
||
const dragging = ref(false)
|
||
/** 悬在哪个投放区上,给高亮用 */
|
||
const overZone = ref<UploadKind | null>(null)
|
||
let dragDepth = 0
|
||
|
||
function hasFiles(event: DragEvent): boolean {
|
||
return Array.from(event.dataTransfer?.types ?? []).includes('Files')
|
||
}
|
||
|
||
function onWindowEnter(event: DragEvent) {
|
||
if (!hasFiles(event)) return
|
||
dragDepth += 1
|
||
dragging.value = true
|
||
}
|
||
|
||
function onWindowLeave(event: DragEvent) {
|
||
if (!hasFiles(event)) return
|
||
dragDepth = Math.max(0, dragDepth - 1)
|
||
if (dragDepth === 0) {
|
||
dragging.value = false
|
||
overZone.value = null
|
||
}
|
||
}
|
||
|
||
// dragover 不阻止默认行为浏览器不允许 drop;窗口级兜底 drop 防止误放导致页面跳走
|
||
function onWindowOver(event: DragEvent) {
|
||
event.preventDefault()
|
||
}
|
||
|
||
function onWindowDrop(event: DragEvent) {
|
||
event.preventDefault()
|
||
dragDepth = 0
|
||
dragging.value = false
|
||
overZone.value = null
|
||
}
|
||
|
||
onMounted(() => {
|
||
window.addEventListener('dragenter', onWindowEnter)
|
||
window.addEventListener('dragleave', onWindowLeave)
|
||
window.addEventListener('dragover', onWindowOver)
|
||
window.addEventListener('drop', onWindowDrop)
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
window.removeEventListener('dragenter', onWindowEnter)
|
||
window.removeEventListener('dragleave', onWindowLeave)
|
||
window.removeEventListener('dragover', onWindowOver)
|
||
window.removeEventListener('drop', onWindowDrop)
|
||
})
|
||
|
||
const restoreInput = ref<HTMLInputElement | null>(null)
|
||
const importInput = ref<HTMLInputElement | null>(null)
|
||
|
||
function onPick(kind: UploadKind, event: Event) {
|
||
const input = event.target as HTMLInputElement
|
||
const file = input.files?.[0]
|
||
input.value = ''
|
||
if (file) void readUpload(kind, file)
|
||
}
|
||
|
||
function onDrop(kind: UploadKind, event: DragEvent) {
|
||
const file = event.dataTransfer?.files[0]
|
||
if (file) void readUpload(kind, file)
|
||
}
|
||
|
||
/** 待确认的上传 */
|
||
const pendingUpload = ref<{ content: string; kind: UploadKind } | null>(null)
|
||
|
||
async function readUpload(kind: UploadKind, file: File) {
|
||
const content = await file.text()
|
||
let parsed: unknown
|
||
try {
|
||
parsed = parse(content)
|
||
} catch (error) {
|
||
toast(`这份文件不是合法的 YAML:${String(error)}`, { kind: 'error' })
|
||
return
|
||
}
|
||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||
toast('这份文件不是一份 mihomo 配置。', { kind: 'error' })
|
||
return
|
||
}
|
||
// 恢复入口只收 Patchbay 自己下载的文件,认标记。别家的配置走导入
|
||
if (kind === 'restore' && !hasValidMarker(parsed as ConfigObject)) {
|
||
toast('这份文件没有 Patchbay 标记,不像是从 Patchbay 下载的配置。', {
|
||
kind: 'error',
|
||
hint: '如果确实要用它,走「导入配置文件」。',
|
||
})
|
||
return
|
||
}
|
||
pendingUpload.value = { content, kind }
|
||
}
|
||
|
||
function uploadMessage(): string {
|
||
const kind = pendingUpload.value?.kind
|
||
const head = kind === 'import' ? '导入这份配置文件?' : '从这份文件恢复配置?'
|
||
const note =
|
||
strategy.value === 'compatible'
|
||
? '策略为兼容:只替换 Patchbay 接管的段落(入站、专属组、规则、订阅、规则组),其余段落维持当前配置。'
|
||
: '策略为覆盖:整份替换当前配置。'
|
||
return `${head}${note}当前配置会先自动备份。`
|
||
}
|
||
|
||
async function uploadConfirmed() {
|
||
const pending = pendingUpload.value
|
||
pendingUpload.value = null
|
||
if (!pending || saving.value) return
|
||
saving.value = true
|
||
try {
|
||
const result = await api.importConfig(pending.content, strategy.value)
|
||
const success = pending.kind === 'restore' ? '已从文件恢复配置' : '已导入配置文件'
|
||
toast(result.reloaded ? success : `${success}(${result.reloadMessage})`)
|
||
emit('saved')
|
||
} catch (error) {
|
||
toastError(error)
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
/* ── 子页 ─────────────────────────────────────── */
|
||
|
||
const sub = ref<null | 'disclaimer' | 'about'>(null)
|
||
|
||
/** 接口请求都发往当前源(开发时由 vite 转发给后端),这就是后端地址。 */
|
||
const backend = location.origin
|
||
</script>
|
||
|
||
<template>
|
||
<!-- 子页:左上角「< 设置」返回,标题加内容 -->
|
||
<div v-if="sub" class="page">
|
||
<button type="button" class="back" @click="sub = null"><IconBack /> 设置</button>
|
||
|
||
<template v-if="sub === 'disclaimer'">
|
||
<h1 class="sub-title">{{ DISCLAIMER_TITLE }}</h1>
|
||
<p v-for="(para, i) in DISCLAIMER" :key="i" class="para">{{ para }}</p>
|
||
</template>
|
||
|
||
<template v-else>
|
||
<h1 class="sub-title">关于</h1>
|
||
<p class="para">编辑 mihomo 配置文件的网页界面:把每一路入站接到它自己的出站上。</p>
|
||
<div class="row">
|
||
<span class="key">适配内核</span>
|
||
<span class="mono">mihomo v1.19.29</span>
|
||
</div>
|
||
<div class="row">
|
||
<span class="key">当前内核</span>
|
||
<span class="mono">{{ kernel.online && kernel.version ? kernel.version : '内核未连接' }}</span>
|
||
</div>
|
||
<div class="row">
|
||
<span class="key">后端地址</span>
|
||
<span class="mono">{{ backend }}</span>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<div v-else class="page">
|
||
<section class="group">
|
||
<h2 class="title">外观</h2>
|
||
<div class="row">
|
||
<span class="key">主题</span>
|
||
<div class="seg">
|
||
<button
|
||
v-for="o in themeOptions"
|
||
:key="o.value"
|
||
type="button"
|
||
:class="['seg-item', theme === o.value && 'is-on']"
|
||
@click="pickTheme(o.value)"
|
||
>
|
||
{{ o.label }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="group">
|
||
<h2 class="title">测速</h2>
|
||
<div class="row">
|
||
<span class="key">测速地址</span>
|
||
<InlineEdit
|
||
:value="testUrl"
|
||
mono
|
||
:can-reset="testUrl !== DEFAULT_TEST_URL"
|
||
:validate="isUrlOrEmpty"
|
||
style="--ie-width: 320px"
|
||
@submit="saveTestUrl"
|
||
@reset="testUrl = DEFAULT_TEST_URL"
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="group">
|
||
<h2 class="title">内核参数</h2>
|
||
<div v-for="key in PORT_KEYS" :key="key" class="row">
|
||
<span class="key mono">{{ key }}</span>
|
||
<InlineEdit
|
||
:value="numberText(key)"
|
||
placeholder="未启用"
|
||
mono
|
||
:busy="saving"
|
||
:validate="isPortOrEmpty"
|
||
style="--ie-width: 120px"
|
||
@submit="(v) => saveNumberKey(key, v, `${key} 已保存`)"
|
||
/>
|
||
</div>
|
||
<div class="row">
|
||
<span class="key">TCP 保持活动间隔</span>
|
||
<InlineEdit
|
||
:value="numberText('keep-alive-interval')"
|
||
placeholder="内核默认"
|
||
unit="秒"
|
||
mono
|
||
:busy="saving"
|
||
:validate="isPosIntOrEmpty"
|
||
style="--ie-width: 120px"
|
||
@submit="(v) => saveNumberKey('keep-alive-interval', v, 'TCP 保持活动间隔已保存')"
|
||
/>
|
||
</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 class="group">
|
||
<div class="g-head">
|
||
<h2 class="title">资源</h2>
|
||
<button
|
||
type="button"
|
||
class="tbtn"
|
||
:title="kernel.online ? '让内核更新它用到的全部 GEO 文件' : '内核未连接'"
|
||
:disabled="geoBusy || geoSyncing !== null || !kernel.online"
|
||
@click="updateGeo"
|
||
>
|
||
<IconSync :class="geoBusy && 'spin'" /> 更新全部
|
||
</button>
|
||
</div>
|
||
<p v-if="geoFiles === null" class="faint">载入中</p>
|
||
<p v-else-if="geoFiles.length === 0" class="faint">读不到 GEO 文件的状态。</p>
|
||
<template v-else>
|
||
<div v-for="g in geoFiles" :key="g.key" class="geo">
|
||
<div class="row" :title="g.url">
|
||
<span class="key mono">{{ g.key }}</span>
|
||
<span class="g-right">
|
||
<span :class="['g-meta', g.size === null && 'faint']">{{ geoMeta(g) }}</span>
|
||
<button
|
||
type="button"
|
||
class="ico"
|
||
title="编辑下载地址"
|
||
:disabled="saving"
|
||
@mousedown.prevent
|
||
@click="toggleGeoEdit(g)"
|
||
>
|
||
<IconEdit />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="ico"
|
||
title="单独下载这一个文件(重启内核后生效)"
|
||
:disabled="geoBusy || geoSyncing !== null"
|
||
@click="syncOne(g)"
|
||
>
|
||
<IconSync :class="geoSyncing === g.configKey && 'spin'" />
|
||
</button>
|
||
</span>
|
||
</div>
|
||
<input
|
||
v-if="geoEditing === g.configKey"
|
||
:ref="setGeoInput"
|
||
v-model="geoDraft"
|
||
:class="['g-input mono', geoBad && 'is-bad']"
|
||
type="text"
|
||
autocomplete="off"
|
||
spellcheck="false"
|
||
:placeholder="g.url"
|
||
@keydown.enter.prevent="finishGeoEdit(g, false)"
|
||
@keydown.esc.prevent="geoEditing = null"
|
||
@blur="finishGeoEdit(g, true)"
|
||
/>
|
||
</div>
|
||
</template>
|
||
</section>
|
||
|
||
<section class="group">
|
||
<h2 class="title">配置文件</h2>
|
||
<div class="row">
|
||
<span class="key">备份到本地</span>
|
||
<button type="button" class="btn with-ico" @click="downloadConfig">
|
||
<IconDownload /> 下载文件
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 拖着文件进窗口时,两个上传行整行变成投放区 -->
|
||
<div
|
||
v-if="dragging"
|
||
:class="['drop', overZone === 'restore' && 'is-over']"
|
||
@dragenter="overZone = 'restore'"
|
||
@dragleave="overZone = null"
|
||
@dragover.prevent
|
||
@drop.prevent="onDrop('restore', $event)"
|
||
>
|
||
拖拽到此处,恢复配置
|
||
</div>
|
||
<div v-else class="row">
|
||
<span class="key">通过文件恢复配置</span>
|
||
<button type="button" class="btn" :disabled="saving" @click="restoreInput?.click()">
|
||
选择或拖放文件
|
||
</button>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<span class="key">恢复策略</span>
|
||
<SelectBox v-model="strategy" :options="STRATEGY_OPTIONS" class="strategy" aria-label="恢复策略" />
|
||
</div>
|
||
|
||
<div
|
||
v-if="dragging"
|
||
:class="['drop', overZone === 'import' && 'is-over']"
|
||
@dragenter="overZone = 'import'"
|
||
@dragleave="overZone = null"
|
||
@dragover.prevent
|
||
@drop.prevent="onDrop('import', $event)"
|
||
>
|
||
拖拽到此处,导入配置文件
|
||
</div>
|
||
<div v-else class="row">
|
||
<span class="key">导入配置文件</span>
|
||
<button type="button" class="btn" :disabled="saving" @click="importInput?.click()">
|
||
选择或拖放文件
|
||
</button>
|
||
</div>
|
||
|
||
<input
|
||
ref="restoreInput"
|
||
type="file"
|
||
accept=".yaml,.yml"
|
||
hidden
|
||
@change="onPick('restore', $event)"
|
||
/>
|
||
<input
|
||
ref="importInput"
|
||
type="file"
|
||
accept=".yaml,.yml"
|
||
hidden
|
||
@change="onPick('import', $event)"
|
||
/>
|
||
</section>
|
||
|
||
<section class="group">
|
||
<h2 class="title">连接</h2>
|
||
<div class="row">
|
||
<span class="key">后端地址</span>
|
||
<span class="mono">{{ backend }}</span>
|
||
</div>
|
||
<div class="row">
|
||
<span class="key">内核状态</span>
|
||
<span>{{ kernel.online ? '已连接' : '内核未连接' }}</span>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="group">
|
||
<h2 class="title">其他</h2>
|
||
<button type="button" class="more" @click="sub = 'disclaimer'">
|
||
<span>{{ DISCLAIMER_TITLE }}</span>
|
||
<IconRight aria-hidden="true" />
|
||
</button>
|
||
<button type="button" class="more" @click="sub = 'about'">
|
||
<span>关于</span>
|
||
<IconRight aria-hidden="true" />
|
||
</button>
|
||
</section>
|
||
|
||
<HostsForm
|
||
:open="hostsOpen"
|
||
:hosts="hostsOf()"
|
||
@close="hostsOpen = false"
|
||
@save="submitHosts"
|
||
/>
|
||
|
||
<ConfirmDialog
|
||
:open="pendingUpload !== null"
|
||
:message="uploadMessage()"
|
||
:action="pendingUpload?.kind === 'import' ? '导入' : '恢复'"
|
||
@close="pendingUpload = null"
|
||
@confirm="uploadConfirmed"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* 内容少的页面收窄居中,段落之间用分割线而不是卡片——一屏最多两层容器 */
|
||
.page {
|
||
max-width: 640px;
|
||
margin-inline: auto;
|
||
}
|
||
|
||
.group {
|
||
padding: var(--s-5) 0;
|
||
border-top: 1px solid var(--c-border);
|
||
}
|
||
|
||
.group:first-of-type {
|
||
border-top: none;
|
||
padding-top: 0;
|
||
}
|
||
|
||
.title {
|
||
font-size: var(--t-lg);
|
||
font-weight: 600;
|
||
margin-bottom: var(--s-3);
|
||
}
|
||
|
||
.row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--s-4);
|
||
padding: var(--s-2) 0;
|
||
}
|
||
|
||
.key {
|
||
color: var(--c-text-soft);
|
||
font-size: var(--t-sm);
|
||
}
|
||
|
||
.faint {
|
||
color: var(--c-text-faint);
|
||
font-size: var(--t-sm);
|
||
}
|
||
|
||
.seg {
|
||
display: flex;
|
||
border: 1px solid var(--c-border-strong);
|
||
border-radius: var(--r-sm);
|
||
background: var(--c-surface);
|
||
}
|
||
|
||
.seg-item {
|
||
padding: var(--s-1) var(--s-3);
|
||
border: none;
|
||
background: none;
|
||
color: var(--c-text-soft);
|
||
font-size: var(--t-sm);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.seg-item + .seg-item {
|
||
border-left: 1px solid var(--c-border);
|
||
}
|
||
|
||
.seg-item.is-on {
|
||
background: var(--c-accent-soft);
|
||
color: var(--c-accent-text);
|
||
}
|
||
|
||
/* 焦点圈画在里面,外面的会被相邻边框和圆角裁掉 */
|
||
.seg-item:focus-visible {
|
||
outline-offset: -2px;
|
||
}
|
||
|
||
.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:not(:disabled) {
|
||
border-color: var(--c-accent);
|
||
color: var(--c-accent-text);
|
||
}
|
||
.btn:disabled {
|
||
color: var(--c-text-faint);
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.with-ico {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--s-2);
|
||
}
|
||
|
||
/* ── 资源 ────────────────────────────────────── */
|
||
.g-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.g-head .title {
|
||
margin-bottom: var(--s-3);
|
||
}
|
||
|
||
/* 文字按钮:图标加一个词,不占按钮的份量 */
|
||
.tbtn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--s-2);
|
||
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;
|
||
}
|
||
.tbtn:hover:not(:disabled) {
|
||
background: var(--c-surface-sunken);
|
||
color: var(--c-text);
|
||
}
|
||
.tbtn:disabled {
|
||
color: var(--c-text-faint);
|
||
cursor: not-allowed;
|
||
}
|
||
.tbtn svg {
|
||
width: 14px;
|
||
height: 14px;
|
||
}
|
||
|
||
.g-right {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--s-2);
|
||
}
|
||
|
||
.g-meta {
|
||
font-size: var(--t-sm);
|
||
color: var(--c-text);
|
||
margin-right: var(--s-2);
|
||
}
|
||
|
||
.ico {
|
||
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;
|
||
}
|
||
.ico:hover:not(:disabled) {
|
||
background: var(--c-surface-sunken);
|
||
color: var(--c-text);
|
||
}
|
||
.ico:disabled {
|
||
cursor: not-allowed;
|
||
}
|
||
.ico svg {
|
||
width: 15px;
|
||
height: 15px;
|
||
}
|
||
|
||
.spin {
|
||
animation: spin 1s linear infinite;
|
||
}
|
||
|
||
@keyframes spin {
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
/* 下载地址很长,编辑框独占一行放在行下面 */
|
||
.g-input {
|
||
width: 100%;
|
||
margin: 0 0 var(--s-2);
|
||
padding: var(--s-2) var(--s-3);
|
||
border: 1px solid var(--c-border-strong);
|
||
border-radius: var(--r-sm);
|
||
background: var(--c-surface);
|
||
font-size: var(--t-sm);
|
||
}
|
||
.g-input.is-bad {
|
||
border-color: var(--c-danger);
|
||
}
|
||
|
||
/* ── 配置文件 ────────────────────────────────── */
|
||
.drop {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: var(--s-3) 0;
|
||
margin: var(--s-1) 0;
|
||
border: 1px dashed var(--c-border-strong);
|
||
border-radius: var(--r-md);
|
||
color: var(--c-text-soft);
|
||
font-size: var(--t-sm);
|
||
transition:
|
||
border-color 0.15s ease,
|
||
background-color 0.15s ease;
|
||
}
|
||
|
||
.drop.is-over {
|
||
border-color: var(--c-accent);
|
||
background: var(--c-accent-soft);
|
||
color: var(--c-accent-text);
|
||
}
|
||
|
||
.strategy {
|
||
width: 140px;
|
||
--select-bg: var(--c-surface);
|
||
}
|
||
|
||
/* ── 其他:整行入口,形态与信息行一致,右侧箭头示意可进入 ── */
|
||
.more {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
width: 100%;
|
||
padding: var(--s-2) 0;
|
||
border: none;
|
||
background: none;
|
||
font-size: var(--t-sm);
|
||
color: var(--c-text-soft);
|
||
cursor: pointer;
|
||
}
|
||
.more svg {
|
||
color: var(--c-text-faint);
|
||
}
|
||
.more:hover,
|
||
.more:hover svg {
|
||
color: var(--c-accent-text);
|
||
}
|
||
|
||
/* ── 子页 ────────────────────────────────────── */
|
||
.back {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--s-1);
|
||
padding: var(--s-1) var(--s-2);
|
||
margin-bottom: var(--s-4);
|
||
border: none;
|
||
border-radius: var(--r-sm);
|
||
background: none;
|
||
color: var(--c-text-soft);
|
||
font-size: var(--t-sm);
|
||
cursor: pointer;
|
||
}
|
||
.back:hover {
|
||
background: var(--c-surface-sunken);
|
||
color: var(--c-text);
|
||
}
|
||
|
||
.sub-title {
|
||
font-size: var(--t-xl);
|
||
font-weight: 600;
|
||
margin-bottom: var(--s-4);
|
||
}
|
||
|
||
.para {
|
||
color: var(--c-text-soft);
|
||
font-size: var(--t-sm);
|
||
line-height: var(--line);
|
||
}
|
||
|
||
.para + .para {
|
||
margin-top: var(--s-3);
|
||
}
|
||
|
||
</style>
|