设置页重做:配置文件下载上传、内核参数、资源、免责声明

配置每次落盘都打顶层 patchbay: 标记(盐加版本的哈希)。内核忽略未知
顶层键(yaml.Unmarshal 没开严格模式,config/config.go:612)。刻意不对
内容做哈希:本项目只接管部分段落,手改其余段落是正常用法,内容哈希
会把合法手改判成无效。上传配置时靠标记分流:有标记的确认后替换,
没有的用更重的措辞确认导入;两者都走 config-store 完整保护链路
(新端点 POST /api/config/import)。自动备份列表从界面撤下,改成
下载当前配置 + 拖放上传。

内核参数一组编辑五个老式顶层端口和 keep-alive-interval(config.go:444,
整数秒),走带基准指纹的保存链路。资源一组接 POST /configs/geo
(同步接口,透传给了 300 秒超时)。测速地址成为本地偏好,延迟测试
读它;prefs 模块顶层的浏览器全局加了存在性守卫——api.ts 开始引用
prefs,而 bun test 环境没有 localStorage。

退出登录挪到侧栏底部;密度设置从设置页撤下(侧栏按钮还在);
免责声明和关于做成子页(左上角返回),登录页输入框下加同一份
免责声明的弹层入口。
This commit is contained in:
2026-08-12 01:01:33 +08:00
parent 013f537eaa
commit 8eb98e2e9a
12 changed files with 792 additions and 147 deletions
+22 -1
View File
@@ -17,6 +17,7 @@ import SettingsPage from './views/SettingsPage.vue'
import TokenGate from './views/TokenGate.vue'
import IconFold from '~icons/ri/sidebar-fold-line'
import IconUnfold from '~icons/ri/sidebar-unfold-line'
import IconLogout from '~icons/ri/logout-box-r-line'
const snapshot = ref<ConfigSnapshot | null>(null)
const needsToken = ref(!getToken())
@@ -70,6 +71,11 @@ function submitToken(value: string) {
void load()
}
function logout() {
clearToken()
location.reload()
}
onMounted(() => {
if (!needsToken.value) void load()
// 流量、每路吞吐和日志的历史跨页面留存,所以收集挂在外壳上而不是各页里。
@@ -126,6 +132,11 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
</li>
</ul>
<button type="button" class="nav-item off" title="退出登录" @click="logout">
<IconLogout class="nav-icon" />
<span class="nav-label">退出登录</span>
</button>
<div class="side-foot">
<div class="kernel" :title="kernel.online ? kernel.version : '内核未连接'">
<span :class="['dot', kernel.online ? 'dot-ok' : 'dot-off']" aria-hidden="true"></span>
@@ -195,7 +206,12 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
<ConnectionsPage v-else-if="active === 'connections'" />
<SettingsPage v-else-if="active === 'settings'" @saved="load" />
<SettingsPage
v-else-if="active === 'settings'"
:data="snapshot.data"
:revision="snapshot.revision"
@saved="load"
/>
<div v-else class="note">
<component :is="current?.icon" class="note-icon" />
@@ -358,6 +374,11 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
height: 18px;
}
/* 退出登录:形态同导航项,但不参与「当前页」的高亮,收拢时同样缩成方块 */
.off {
color: var(--c-text-faint);
}
.side-foot {
display: flex;
align-items: center;
+30 -4
View File
@@ -7,12 +7,10 @@
import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/index.ts'
import type { ConnectionSnapshot } from '@shared/connections.ts'
import type { ExitProbe, HostInfo } from '@shared/host-info.ts'
import { DEFAULT_TEST_URL, testUrl } from './prefs'
const TOKEN_KEY = 'patchbay.token'
/** 测延迟用的目标。204 空响应,体积最小,全球都有部署。 */
const TEST_URL = 'https://www.gstatic.com/generate_204'
export function getToken(): string {
return localStorage.getItem(TOKEN_KEY) ?? ''
}
@@ -144,6 +142,26 @@ export const api = {
listBackups: () => request<{ backups: string[] }>('GET', '/api/config/backups'),
/** 配置文件的原文,给「下载当前配置」用。 */
configRaw: async (): Promise<string> => {
const res = await fetch('/api/config/raw', {
headers: { Authorization: `Bearer ${getToken()}` },
})
if (!res.ok) {
const text = await res.text()
const payload = text ? (JSON.parse(text) as Record<string, unknown>) : {}
throw new ApiError(res.status, payload)
}
return res.text()
},
/**
* 用上传的 YAML 原文整份替换当前配置。走服务端完整的保护链路
* (校验、备份、重载、失败回退),没有 baseRevision——整份替换本来就是覆盖语义。
*/
importConfig: (content: string) =>
request<ApplySuccess>('POST', '/api/config/import', { content }),
restoreBackup: (name: string) =>
request<ApplySuccess>('POST', `/api/config/backups/${encodeURIComponent(name)}/restore`),
@@ -170,6 +188,12 @@ export const api = {
*/
restartKernel: () => request<{ ok: true }>('POST', '/api/kernel/restart'),
/**
* 让内核更新 GEO 数据库(GEOIP、GEOSITE、MMDB、ASN)。
* 同步接口,下载完才回话,可能要等上几十秒。
*/
updateGeo: () => request<{ ok: true }>('POST', '/api/kernel/geo'),
/**
* 打开一个持续吐数据的流式接口(/traffic、/memory),每秒一行 JSON。
*
@@ -222,7 +246,9 @@ export const api = {
* 订阅线路不在表里(那正是它们无法被入站直接引用的原因),只能从所属订阅进去测。
*/
testLine: (name: string, provider?: string, timeout = 5000) => {
const q = `url=${encodeURIComponent(TEST_URL)}&timeout=${String(timeout)}`
// 测速地址是设置页里的偏好,空值退回默认
const target = testUrl.value.trim() || DEFAULT_TEST_URL
const q = `url=${encodeURIComponent(target)}&timeout=${String(timeout)}`
const path = provider
? `/api/kernel/providers/proxies/${encodeURIComponent(provider)}/${encodeURIComponent(name)}/healthcheck?${q}`
: `/api/kernel/proxies/${encodeURIComponent(name)}/delay?${q}`
+11
View File
@@ -0,0 +1,11 @@
/**
* 免责声明的文案。设置页的子页和登录页的弹层共用同一份,不出现两个版本。
*/
export const DISCLAIMER_TITLE = '免责声明'
export const DISCLAIMER = [
'Patchbay 是编辑 mihomo 配置文件的本地网页工具,按现状提供,不提供任何形式的担保。因配置错误、软件缺陷或网络中断造成的服务不可用、数据丢失,作者不承担责任;重要改动前请自行留存配置文件副本。',
'代理技术本身是中性的。使用者应遵守所在国家和地区的法律法规,因使用本工具产生的一切后果由使用者自行承担。',
'配置文件与备份中可能包含密钥、订阅地址等敏感信息,访问令牌等同于密码,请妥善保管,不要在公开场合分享。',
'继续使用 Patchbay 即视为已阅读并同意以上条款。',
]
+24 -5
View File
@@ -3,32 +3,50 @@
*
* 密度:工具栏和导航是只显示图标,还是图标加文字。
* 默认是「完整」——这个工具是隔一阵子才动一次的,纯图标对偶尔来一次的人不友好;
* 想要紧凑的人自己去设置里切。
* 想收拢的人用侧栏底部的按钮切。
*
* 主题:跟随系统、浅色、深色。默认跟随系统。
*
* localStorage 和 document 都加了存在性守卫:api.ts 引用这里(测速地址),
* 而 api.ts 被 bun test 环境里跑的模块间接引用——那里没有浏览器全局,
* 模块顶层直接碰它们会把整个测试文件炸掉。
*/
import { ref, watch } from 'vue'
const storage = typeof localStorage === 'undefined' ? null : localStorage
export type Density = 'full' | 'compact'
const KEY = 'patchbay.density'
const stored = localStorage.getItem(KEY)
const stored = storage?.getItem(KEY)
export const density = ref<Density>(stored === 'compact' ? 'compact' : 'full')
watch(density, (value) => {
localStorage.setItem(KEY, value)
storage?.setItem(KEY, value)
})
export function toggleDensity(): void {
density.value = density.value === 'full' ? 'compact' : 'full'
}
/** 测延迟用的目标地址。204 空响应的端点最合适,默认值全球都有部署。 */
export const DEFAULT_TEST_URL = 'https://www.gstatic.com/generate_204'
const TEST_URL_KEY = 'patchbay.test-url'
export const testUrl = ref(storage?.getItem(TEST_URL_KEY) || DEFAULT_TEST_URL)
watch(testUrl, (value) => {
if (value.trim() === '' || value === DEFAULT_TEST_URL) storage?.removeItem(TEST_URL_KEY)
else storage?.setItem(TEST_URL_KEY, value.trim())
})
export type Theme = 'system' | 'light' | 'dark'
const THEME_KEY = 'patchbay.theme'
const storedTheme = localStorage.getItem(THEME_KEY)
const storedTheme = storage?.getItem(THEME_KEY)
export const theme = ref<Theme>(
storedTheme === 'light' || storedTheme === 'dark' ? storedTheme : 'system',
)
@@ -39,7 +57,8 @@ export const theme = ref<Theme>(
watch(
theme,
(value) => {
localStorage.setItem(THEME_KEY, value)
storage?.setItem(THEME_KEY, value)
if (typeof document === 'undefined') return
if (value === 'system') document.documentElement.removeAttribute('data-theme')
else document.documentElement.setAttribute('data-theme', value)
},
+428 -131
View File
@@ -1,27 +1,37 @@
<script setup lang="ts">
/**
* 设置页:Patchbay 自己的设置,不含 mihomo 的任何设置——那些属于配置页。
* 设置页。分两类,反馈方式不同:
* - 本地偏好(主题、测速地址)点了就生效,没有等待
* - 动配置文件的(内核参数、上传替换)走服务端完整的保护链路,
* 有校验、备份、重载和失败回退,反馈方式照路由页的 commit()
*
* 四组里只有备份还原会等、会失败:还原走服务端完整的保护链路(先备份当前文件,
* 再写入、重载内核,失败自动回退),反馈方式照路由页的 commit()。其余三组是本地
* 偏好或只读信息,点了就生效,没有等待。
* 备份列表不再摆出来:自动备份是落盘链路的安全网,不是日常操作对象。
* 「配置文件」一组提供下载和上传替换;上传时用配置里的 Patchbay 标记
* 区分「还原自己导出的」和「导入外来的」,后者要多确认一步。
*/
import { onMounted, ref } from 'vue'
import { backupTime } from '@shared/backups.ts'
import { ApiError, api, clearToken } from '../api'
import { density, theme } from '../prefs'
import type { Density, Theme } from '../prefs'
import { computed, ref, watch } from 'vue'
import { parse } from 'yaml'
import { hasValidMarker } from '@shared/config-marker.ts'
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
import { ApiError, api } from '../api'
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 DropFile from '../components/DropFile.vue'
import IconCloseTip from '~icons/ri/close-line'
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'
/** 还原备份改的是配置文件,外壳需要重读整份配置(同路由页的 saved)。 */
const props = defineProps<{ data: ConfigObject; revision: string }>()
/** 动了配置文件,外壳需要重读整份配置。 */
const emit = defineEmits<{ saved: [] }>()
const densityOptions: { value: Density; label: string }[] = [
{ value: 'full', label: '完整' },
{ value: 'compact', label: '紧凑' },
]
/* ── 外观与测速 ───────────────────────────────── */
const themeOptions: { value: Theme; label: string }[] = [
{ value: 'system', label: '跟随系统' },
{ value: 'light', label: '浅色' },
@@ -29,83 +39,226 @@ const themeOptions: { value: Theme; label: string }[] = [
]
// 模板里直接给 import 进来的 ref 赋值编译结果不可靠,走显式的赋值函数
function pickDensity(value: Density) {
density.value = value
}
function pickTheme(value: Theme) {
theme.value = value
}
/** 接口请求都发往当前源(开发时由 vite 转发给后端),这就是后端地址。 */
const backend = location.origin
/* ── 状态与反馈 ───────────────────────────────── */
function logout() {
clearToken()
location.reload()
}
const backups = ref<string[] | null>(null)
const restoring = ref(false)
const saving = ref(false)
const failure = ref<{ message: string; hint: string } | null>(null)
const done = ref('')
/** 待确认还原的那份备份。非 null 时确认对话框打开着 */
const asking = ref<string | null>(null)
async function loadBackups() {
/** 走配置文件保护链路的动作共用的收尾。 */
function settle(result: { reloaded: boolean; reloadMessage: string }, success: string) {
done.value = result.reloaded ? success : `${success}(${result.reloadMessage})`
emit('saved')
}
function fail(error: unknown) {
failure.value =
error instanceof ApiError
? { message: error.message, hint: error.hint }
: { message: String(error), hint: '' }
}
/* ── 内核参数 ─────────────────────────────────── */
/** 老式顶层端口,键名用配置原词。空表示没启用(配置里不写这个键)。 */
const PORT_KEYS = ['mixed-port', 'port', 'socks-port', 'redir-port', 'tproxy-port'] as const
const ports = ref<Record<string, string>>({})
const keepAlive = ref('')
// 配置重读后回填表单。保存成功后 data 换新,这里跟着对齐
watch(
() => props.data,
() => {
const next: Record<string, string> = {}
for (const key of PORT_KEYS) {
const value = props.data[key]
next[key] = typeof value === 'number' ? String(value) : ''
}
ports.value = next
const ka = props.data['keep-alive-interval']
keepAlive.value = typeof ka === 'number' ? String(ka) : ''
},
{ immediate: true },
)
const portInvalid = (raw: string): boolean => {
const text = raw.trim()
if (text === '') return false
const n = Number(text)
return !/^\d+$/.test(text) || n < 1 || n > 65535
}
const keepAliveInvalid = computed(() => {
const text = keepAlive.value.trim()
return text !== '' && (!/^\d+$/.test(text) || Number(text) < 1)
})
const kernelParamsInvalid = computed(
() => keepAliveInvalid.value || PORT_KEYS.some((key) => portInvalid(ports.value[key] ?? '')),
)
const kernelParamsDirty = computed(() =>
PORT_KEYS.some((key) => {
const current = props.data[key]
const shown = (ports.value[key] ?? '').trim()
return (typeof current === 'number' ? String(current) : '') !== shown
}) ||
(typeof props.data['keep-alive-interval'] === 'number'
? String(props.data['keep-alive-interval'])
: '') !== keepAlive.value.trim(),
)
async function saveKernelParams() {
if (saving.value || kernelParamsInvalid.value || !kernelParamsDirty.value) return
saving.value = true
failure.value = null
done.value = ''
const next = JSON.parse(JSON.stringify(props.data)) as ConfigObject
for (const key of PORT_KEYS) {
const raw = (ports.value[key] ?? '').trim()
if (raw === '') delete next[key]
else next[key] = Number(raw)
}
const ka = keepAlive.value.trim()
if (ka === '') delete next['keep-alive-interval']
else next['keep-alive-interval'] = Number(ka)
try {
backups.value = (await api.listBackups()).backups
settle(await api.applyConfig(next, props.revision), '内核参数已保存')
} catch (error) {
failure.value = { message: error instanceof ApiError ? error.message : String(error), hint: '' }
fail(error)
} finally {
saving.value = false
}
}
onMounted(loadBackups)
/* ── 资源 ─────────────────────────────────────── */
async function restoreConfirmed() {
const name = asking.value
asking.value = null
if (!name || restoring.value) return
restoring.value = true
const geoBusy = ref(false)
/** GEO 更新是运行时动作,不写配置文件。同步接口,下载完才回话。 */
async function updateGeo() {
if (geoBusy.value || !kernel.value.online) return
geoBusy.value = true
failure.value = null
done.value = ''
try {
const result = await api.restoreBackup(name)
const time = backupTime(name) ?? name
// 内核不在线时只写了文件没重载,如实说,不能只报成功(同路由页 commit())
done.value = result.reloaded ? `已还原到 ${time}` : `已还原到 ${time}(${result.reloadMessage})`
emit('saved')
// 还原本身也走写入链路,会留下一份新备份,列表要重拉
await loadBackups()
await api.updateGeo()
done.value = 'GEO 数据库已更新'
} catch (error) {
failure.value =
error instanceof ApiError
? { message: error.message, hint: error.hint }
: { message: String(error), hint: '' }
fail(error)
} finally {
restoring.value = false
geoBusy.value = false
}
}
/* ── 配置文件 ─────────────────────────────────── */
async function downloadConfig() {
failure.value = null
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) {
fail(error)
}
}
/** 待确认的上传。foreign 表示没有 Patchbay 标记,要用更重的措辞确认 */
const pendingImport = ref<{ content: string; foreign: boolean } | null>(null)
function onUpload(_name: string, content: string) {
failure.value = null
done.value = ''
let parsed: unknown
try {
parsed = parse(content)
} catch (error) {
failure.value = { message: `这份文件不是合法的 YAML:${String(error)}`, hint: '' }
return
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
failure.value = { message: '这份文件不是一份 mihomo 配置。', hint: '' }
return
}
pendingImport.value = { content, foreign: !hasValidMarker(parsed as ConfigObject) }
}
async function importConfirmed() {
const pending = pendingImport.value
pendingImport.value = null
if (!pending || saving.value) return
saving.value = true
failure.value = null
done.value = ''
try {
settle(await api.importConfig(pending.content), '已替换为上传的配置')
} catch (error) {
fail(error)
} finally {
saving.value = false
}
}
/* ── 子页 ─────────────────────────────────────── */
const sub = ref<null | 'disclaimer' | 'about'>(null)
/** 接口请求都发往当前源(开发时由 vite 转发给后端),这就是后端地址。 */
const backend = location.origin
</script>
<template>
<div class="page">
<!-- 子页:左上角「< 设置」返回,标题加内容 -->
<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">
<div v-if="failure" class="failure">
<div class="failure-text">
<strong>{{ failure.message }}</strong>
<span v-if="failure.hint">{{ failure.hint }}</span>
</div>
<button type="button" class="failure-x" title="关闭提示" @click="failure = null">
<IconCloseTip />
</button>
</div>
<p v-else-if="done" class="done">{{ done }}</p>
<section class="group">
<h2 class="title">外观</h2>
<div class="row">
<span class="key">密度</span>
<div class="seg">
<button
v-for="o in densityOptions"
:key="o.value"
type="button"
:class="['seg-item', density === o.value && 'is-on']"
@click="pickDensity(o.value)"
>
{{ o.label }}
</button>
</div>
</div>
<div class="row">
<span class="key">主题</span>
<div class="seg">
@@ -122,6 +275,87 @@ async function restoreConfirmed() {
</div>
</section>
<section class="group">
<h2 class="title">测速</h2>
<div class="row">
<span class="key">测速地址</span>
<input
v-model="testUrl"
class="input mono"
type="text"
:placeholder="DEFAULT_TEST_URL"
autocomplete="off"
spellcheck="false"
/>
</div>
</section>
<section class="group">
<h2 class="title">内核参数</h2>
<div class="ports">
<label v-for="key in PORT_KEYS" :key="key" class="pfield">
<span class="pk mono">{{ key }}</span>
<input
v-model="ports[key]"
:class="['input mono', portInvalid(ports[key] ?? '') && 'is-bad']"
type="text"
inputmode="numeric"
placeholder="未启用"
autocomplete="off"
/>
</label>
</div>
<div class="row">
<span class="key">TCP 保持活动间隔</span>
<span class="with-unit">
<input
v-model="keepAlive"
:class="['input mono', keepAliveInvalid && 'is-bad']"
type="text"
inputmode="numeric"
placeholder="内核默认"
autocomplete="off"
/>
<span class="unit">秒</span>
</span>
</div>
<button
type="button"
class="btn"
:disabled="saving || kernelParamsInvalid || !kernelParamsDirty"
@click="saveKernelParams"
>
{{ saving ? '保存中' : '保存内核参数' }}
</button>
</section>
<section class="group">
<h2 class="title">资源</h2>
<div class="row">
<span class="key">GEO 数据库(GEOIP、GEOSITE、MMDB、ASN)</span>
<button
type="button"
class="btn"
:disabled="geoBusy || !kernel.online"
:title="kernel.online ? undefined : '内核未连接'"
@click="updateGeo"
>
{{ geoBusy ? '更新中' : '更新 GEO 数据库' }}
</button>
</div>
</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>
<DropFile accept=".yaml,.yml" @load="onUpload" />
</section>
<section class="group">
<h2 class="title">连接</h2>
<div class="row">
@@ -132,60 +366,39 @@ async function restoreConfirmed() {
<span class="key">内核状态</span>
<span>{{ kernel.online ? '已连接' : '内核未连接' }}</span>
</div>
<button type="button" class="btn logout" @click="logout">退出登录</button>
</section>
<section class="group">
<h2 class="title">关于</h2>
<p class="about">编辑 mihomo 配置文件的网页界面:把每一路入站接到它自己的出站上。</p>
<div class="row">
<span class="key">内核版本</span>
<span class="mono">{{ kernel.online && kernel.version ? kernel.version : '内核未连接' }}</span>
</div>
</section>
<section class="group">
<h2 class="title">备份还原</h2>
<div v-if="failure" class="failure">
<div class="failure-text">
<strong>{{ failure.message }}</strong>
<span v-if="failure.hint">{{ failure.hint }}</span>
</div>
<button type="button" class="failure-x" title="关闭提示" @click="failure = null">
<IconCloseTip />
</button>
</div>
<p v-else-if="done" class="done">{{ done }}</p>
<p v-if="backups === null" class="faint">载入中</p>
<p v-else-if="backups.length === 0" class="faint">没有备份。</p>
<ul v-else class="backups">
<li v-for="name in backups" :key="name" class="backup" :title="name">
<span class="mono">{{ backupTime(name) ?? name }}</span>
<button type="button" class="btn" :disabled="restoring" @click="asking = name">
还原
</button>
</li>
</ul>
<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>
<ConfirmDialog
:open="asking !== null"
:open="pendingImport !== null"
:message="
asking ? `把配置还原到 ${backupTime(asking) ?? asking}?当前配置会先自动备份。` : ''
pendingImport?.foreign
? '这份文件没有 Patchbay 标记,可能不是 Patchbay 管理过的配置。仍然导入会整份替换当前配置(当前配置会先自动备份),之后 Patchbay 开始接管其中的段落。'
: '用上传的文件整份替换当前配置?当前配置会先自动备份。'
"
action="还原"
@close="asking = null"
@confirm="restoreConfirmed"
:action="pendingImport?.foreign ? '仍然导入' : '替换'"
@close="pendingImport = null"
@confirm="importConfirmed"
/>
</div>
</template>
<style scoped>
/* 内容少的页面收窄成一列,段落之间用分割线而不是卡片——一屏最多两层容器 */
/* 内容少的页面收窄居中,段落之间用分割线而不是卡片——一屏最多两层容器 */
.page {
max-width: 560px;
max-width: 640px;
margin-inline: auto;
}
.group {
@@ -193,7 +406,7 @@ async function restoreConfirmed() {
border-top: 1px solid var(--c-border);
}
.group:first-child {
.group:first-of-type {
border-top: none;
padding-top: 0;
}
@@ -217,7 +430,6 @@ async function restoreConfirmed() {
font-size: var(--t-sm);
}
/* 分段选择器:两三个互斥选项,当前项用强调色淡底(同导航的选中态) */
.seg {
display: flex;
border: 1px solid var(--c-border-strong);
@@ -248,6 +460,58 @@ async function restoreConfirmed() {
outline-offset: -2px;
}
.input {
width: 260px;
max-width: 100%;
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);
}
.input.is-bad {
border-color: var(--c-danger);
}
.with-unit {
display: flex;
align-items: center;
gap: var(--s-2);
}
.with-unit .input {
width: 120px;
}
.unit {
color: var(--c-text-soft);
font-size: var(--t-sm);
}
/* 端口一组:键名原词在上,输入框在下,窄格子排开 */
.ports {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: var(--s-3);
padding: var(--s-2) 0;
}
.pfield {
display: flex;
flex-direction: column;
gap: var(--s-1);
}
.pfield .input {
width: 100%;
}
.pk {
color: var(--c-text-faint);
font-size: var(--t-xs);
}
.btn {
padding: var(--s-2) var(--s-4);
border: 1px solid var(--c-border-strong);
@@ -264,19 +528,70 @@ async function restoreConfirmed() {
cursor: not-allowed;
}
.logout {
margin-top: var(--s-2);
.with-ico {
display: inline-flex;
align-items: center;
gap: var(--s-2);
}
.about {
margin-bottom: var(--s-2);
/* 「其他」里的整行入口:左标题右箭头,整行点击 */
.more {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: var(--s-3) var(--s-2);
border: none;
border-radius: var(--r-sm);
background: none;
font-size: var(--t-base);
color: var(--c-text);
cursor: pointer;
}
.more:hover {
background: var(--c-surface-sunken);
}
.more + .more {
border-top: 1px solid var(--c-border);
border-radius: 0;
}
.faint {
color: var(--c-text-faint);
/* ── 子页 ────────────────────────────────────── */
.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);
}
/* ── 反馈 ────────────────────────────────────── */
.failure {
display: flex;
align-items: flex-start;
@@ -319,22 +634,4 @@ async function restoreConfirmed() {
font-size: var(--t-sm);
margin-bottom: var(--s-3);
}
.backups {
list-style: none;
margin: 0;
padding: 0;
}
.backup {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-4);
padding: var(--s-2) 0;
}
.backup + .backup {
border-top: 1px solid var(--c-border);
}
</style>
+106 -1
View File
@@ -1,8 +1,23 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ref, watch } from 'vue'
import { DISCLAIMER, DISCLAIMER_TITLE } from '../legal'
import IconClose from '~icons/ri/close-line'
const emit = defineEmits<{ submit: [token: string] }>()
const value = ref('')
const legalOpen = ref(false)
const legalDialog = ref<HTMLDialogElement | null>(null)
watch(legalOpen, (open) => {
if (open) legalDialog.value?.showModal()
else legalDialog.value?.close()
})
/** 点在遮罩上就关闭。内容区的点击 target 是里面的元素,到不了 dialog 本身。 */
function onBackdrop(event: MouseEvent) {
if (event.target === legalDialog.value) legalOpen.value = false
}
</script>
<template>
@@ -23,7 +38,26 @@ const value = ref('')
<p class="where">
令牌位于服务端配置文件的同级目录 <code>.patchbay-token</code>,启动时也会输出到控制台。
</p>
<button type="button" class="legal" @click="legalOpen = true">
使用 Patchbay 即视为同意《免责声明》
</button>
</form>
<dialog
ref="legalDialog"
class="dlg"
@click="onBackdrop"
@close="legalOpen = false"
@cancel.prevent="legalOpen = false"
>
<div class="dlg-body">
<header class="dlg-head">
<h2>{{ DISCLAIMER_TITLE }}</h2>
<button type="button" class="x" @click="legalOpen = false"><IconClose /></button>
</header>
<p v-for="(para, i) in DISCLAIMER" :key="i" class="para">{{ para }}</p>
</div>
</dialog>
</div>
</template>
@@ -85,4 +119,75 @@ h1 {
line-height: 1.6;
margin-top: var(--s-2);
}
.legal {
align-self: flex-start;
padding: 0;
border: none;
background: none;
color: var(--c-text-faint);
font-size: var(--t-xs);
cursor: pointer;
text-decoration: underline;
text-underline-offset: 3px;
}
.legal:hover {
color: var(--c-text-soft);
}
.dlg {
width: min(480px, 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);
}
.dlg-body {
padding: var(--s-5);
}
.dlg-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--s-3);
}
.dlg-head h2 {
font-size: var(--t-lg);
font-weight: 600;
}
.x {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid transparent;
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);
}
.para {
color: var(--c-text-soft);
font-size: var(--t-sm);
line-height: var(--line);
}
.para + .para {
margin-top: var(--s-3);
}
</style>