设置页第二轮:行内编辑、GEO 资源展开、恢复与导入分家

单值设置从常驻输入框改成 InlineEdit(纯文本加铅笔,Enter 或失焦提交,
失焦无效直接放弃——失焦是「我走了」,不该把人拽回来)。测速地址加恢复
默认按钮;内核参数逐项提交,不再攒一个保存按钮。

资源一节按文件展开:四类 GEO 文件的大小和更新时间由服务端 stat 内核
工作目录得来(GET /api/geo;文件名和大小写不敏感匹配照 constant/
path.go:18-20,193-224),下载地址可改(geox-url,config.go:363-368,
默认值 578-583)。行内的更新按钮仍是全量更新——内核只有这一个口子,
没变化的文件靠内容哈希跳过。

上传拆成两个入口:恢复只认带 Patchbay 标记的文件,导入收任意配置;
新增恢复策略(兼容只搬接管段落 listeners/proxy-groups/rules/
proxy-providers/rule-providers,覆盖整份替换)。平时是按钮,拖着文件
进窗口时整行变成投放区。
This commit is contained in:
2026-08-12 01:40:49 +08:00
parent 8eb98e2e9a
commit 2580c03bfa
10 changed files with 790 additions and 229 deletions
+429 -210
View File
@@ -2,16 +2,18 @@
/**
* 设置页。分两类,反馈方式不同:
* - 本地偏好(主题、测速地址)点了就生效,没有等待
* - 动配置文件的(内核参数、上传替换)走服务端完整的保护链路,
* 有校验、备份、重载和失败回退,反馈方式照路由页的 commit()
* - 动配置文件的(内核参数、GEO 下载地址、上传恢复/导入)走服务端完整的
* 保护链路,有校验、备份、重载和失败回退,反馈方式照路由页的 commit()
*
* 备份列表不再摆出来:自动备份是落盘链路的安全网,不是日常操作对象。
* 「配置文件」一组提供下载和上传替换;上传时用配置里的 Patchbay 标记
* 区分「还原自己导出的」和「导入外来的」,后者要多确认一步。
* 单值编辑统一用 InlineEdit:平时纯文本,点铅笔才出输入框,Enter 或失焦提交。
* 备份列表不摆出来:自动备份是落盘链路的安全网,不是日常操作对象;
* 「配置文件」一组给下载和上传,上传按 Patchbay 标记分「恢复」和「导入」两个入口。
*/
import { computed, ref, watch } from 'vue'
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 { ApiError, api } from '../api'
import { DEFAULT_TEST_URL, testUrl, theme } from '../prefs'
@@ -19,11 +21,14 @@ 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 InlineEdit from '../components/InlineEdit.vue'
import SelectBox from '../components/SelectBox.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'
import IconEdit from '~icons/ri/pencil-line'
import IconSync from '~icons/ri/refresh-line'
const props = defineProps<{ data: ConfigObject; revision: string }>()
@@ -43,18 +48,19 @@ 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
}
/* ── 状态与反馈 ───────────────────────────────── */
const saving = ref(false)
const failure = ref<{ message: string; hint: string } | null>(null)
const done = ref('')
/** 走配置文件保护链路的动作共用的收尾。 */
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
@@ -62,73 +68,17 @@ function fail(error: unknown) {
: { 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
/** 所有改配置文件的动作都从这里走。 */
async function commitConfig(next: ConfigObject, success: string) {
if (saving.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 {
settle(await api.applyConfig(next, props.revision), '内核参数已保存')
const result = await api.applyConfig(next, props.revision)
// 内核不在线时只写了文件没重载,如实说,不能只报成功
done.value = result.reloaded ? success : `${success}(${result.reloadMessage})`
emit('saved')
} catch (error) {
fail(error)
} finally {
@@ -136,11 +86,54 @@ async function saveKernelParams() {
}
}
/* ── 资源 ─────────────────────────────────────── */
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)
}
/* ── 资源(GEO 数据库) ───────────────────────── */
const geoFiles = ref<GeoFileStatus[] | null>(null)
const geoBusy = ref(false)
/** GEO 更新是运行时动作,不写配置文件。同步接口,下载完才回话。 */
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())}`
}
/** GEO 更新是运行时动作,不写配置文件。内核只有全量更新的口子,四行共用一个忙态。 */
async function updateGeo() {
if (geoBusy.value || !kernel.value.online) return
geoBusy.value = true
@@ -149,6 +142,7 @@ async function updateGeo() {
try {
await api.updateGeo()
done.value = 'GEO 数据库已更新'
await loadGeo()
} catch (error) {
fail(error)
} finally {
@@ -156,6 +150,46 @@ async function updateGeo() {
}
}
/** 正在编辑下载地址的那一行(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()
}
/** 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() {
@@ -173,12 +207,81 @@ async function downloadConfig() {
}
}
/** 待确认的上传。foreign 表示没有 Patchbay 标记,要用更重的措辞确认 */
const pendingImport = ref<{ content: string; foreign: boolean } | null>(null)
type UploadKind = 'restore' | 'import'
function onUpload(_name: string, content: string) {
const strategy = ref<'compatible' | 'override'>('compatible')
const STRATEGY_OPTIONS = [
{ value: 'compatible', label: '兼容' },
{ value: 'override', label: '覆盖' },
]
/** 窗口里正拖着文件。为真时两个上传行整行变成投放区。 */
const dragging = ref(false)
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
}
// dragover 不阻止默认行为浏览器不允许 drop;窗口级兜底 drop 防止误放导致页面跳走
function onWindowOver(event: DragEvent) {
event.preventDefault()
}
function onWindowDrop(event: DragEvent) {
event.preventDefault()
dragDepth = 0
dragging.value = false
}
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) {
failure.value = null
done.value = ''
const content = await file.text()
let parsed: unknown
try {
parsed = parse(content)
@@ -190,18 +293,39 @@ function onUpload(_name: string, content: string) {
failure.value = { message: '这份文件不是一份 mihomo 配置。', hint: '' }
return
}
pendingImport.value = { content, foreign: !hasValidMarker(parsed as ConfigObject) }
// 恢复入口只收 Patchbay 自己下载的文件,认标记。别家的配置走导入
if (kind === 'restore' && !hasValidMarker(parsed as ConfigObject)) {
failure.value = {
message: '这份文件没有 Patchbay 标记,不像是从 Patchbay 下载的配置。',
hint: '如果确实要用它,走「导入配置文件」。',
}
return
}
pendingUpload.value = { content, kind }
}
async function importConfirmed() {
const pending = pendingImport.value
pendingImport.value = null
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
failure.value = null
done.value = ''
try {
settle(await api.importConfig(pending.content), '已替换为上传的配置')
const result = await api.importConfig(pending.content, strategy.value)
const success = pending.kind === 'restore' ? '已从文件恢复配置' : '已导入配置文件'
done.value = result.reloaded ? success : `${success}(${result.reloadMessage})`
emit('saved')
} catch (error) {
fail(error)
} finally {
@@ -279,81 +403,148 @@ const backend = location.origin
<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"
<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 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 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>
<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>
<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>
<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>
<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"
@click="openGeoEdit(g)"
>
<IconEdit />
</button>
<button
type="button"
class="ico"
:title="kernel.online ? '更新(有变化的 GEO 文件会一并更新)' : '内核未连接'"
:disabled="geoBusy || !kernel.online"
@click="updateGeo"
>
<IconSync :class="geoBusy && '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>
<span class="key">备份到本地</span>
<button type="button" class="btn with-ico" @click="downloadConfig">
<IconDownload /> 下载
<IconDownload /> 下载文件
</button>
</div>
<DropFile accept=".yaml,.yml" @load="onUpload" />
<!-- 拖着文件进窗口时,两个上传行整行变成投放区 -->
<div
v-if="dragging"
class="drop"
@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 v-if="dragging" class="drop" @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>
<div class="row">
<span class="key">恢复策略</span>
<SelectBox v-model="strategy" :options="STRATEGY_OPTIONS" class="strategy" aria-label="恢复策略" />
</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">
@@ -381,15 +572,11 @@ const backend = location.origin
</section>
<ConfirmDialog
:open="pendingImport !== null"
:message="
pendingImport?.foreign
? '这份文件没有 Patchbay 标记,可能不是 Patchbay 管理过的配置。仍然导入会整份替换当前配置(当前配置会先自动备份),之后 Patchbay 开始接管其中的段落。'
: '用上传的文件整份替换当前配置?当前配置会先自动备份。'
"
:action="pendingImport?.foreign ? '仍然导入' : '替换'"
@close="pendingImport = null"
@confirm="importConfirmed"
:open="pendingUpload !== null"
:message="uploadMessage()"
:action="pendingUpload?.kind === 'import' ? '导入' : '恢复'"
@close="pendingUpload = null"
@confirm="uploadConfirmed"
/>
</div>
</template>
@@ -430,6 +617,11 @@ const backend = location.origin
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);
@@ -460,58 +652,6 @@ const backend = location.origin
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);
@@ -534,26 +674,105 @@ const backend = location.origin
gap: var(--s-2);
}
/* 「其他」里的整行入口:左标题右箭头,整行点击 */
/* ── 资源 ────────────────────────────────────── */
.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);
}
.strategy {
width: 140px;
--select-bg: var(--c-surface);
}
/* ── 其他:整行入口,形态与信息行一致,右侧箭头示意可进入 ── */
.more {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: var(--s-3) var(--s-2);
padding: var(--s-2) 0;
border: none;
border-radius: var(--r-sm);
background: none;
font-size: var(--t-base);
color: var(--c-text);
font-size: var(--t-sm);
color: var(--c-text-soft);
cursor: pointer;
}
.more:hover {
background: var(--c-surface-sunken);
.more svg {
color: var(--c-text-faint);
}
.more + .more {
border-top: 1px solid var(--c-border);
border-radius: 0;
.more:hover,
.more:hover svg {
color: var(--c-accent-text);
}
/* ── 子页 ────────────────────────────────────── */