设置页重做:配置文件下载上传、内核参数、资源、免责声明
配置每次落盘都打顶层 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:
+428
-131
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user