Files
mihomo-patchbay/web/views/SettingsPage.vue
T
im0o b276c4a8fe 轻提示铺开到全部页面;内核状态悬停变重启;GEO 支持单文件下载
侧栏动作排撤掉重启图标,内核状态(圆点加版本号)本身变成按钮:悬停
换层显示「重启内核」,不在线时只当状态看。行高压成 1 修掉圆点和等宽
字体不居中的问题。

四个页面的一次性反馈(保存、删除、更新订阅、断开连接、测延迟结论)
全部改走 toast,commit() 改返回成败给「失败不关对话框」用;持续状态
(连接页轮询失败、请求页日志流失败)留在页内,会自动消失的提示装不下
需要一直可见的信息。toast 加 info 类别给中性结论用。

GEO 每行的更新改成单独下载那一个文件:内核只有全量更新的口子,单文件
只能服务端自己下(POST /api/geo/sync),代价是只有形状校验(MaxMind
末尾标记、geodata 首字节,shared/geo.ts 带测试)且内核重启后才换用新
文件;「更新全部」文字按钮保留内核的全量口子,有完整校验和热加载。
上传两行的按钮改「选择或拖放文件」,投放区拖入高亮,恢复策略挪到
恢复行下方。
2026-08-12 02:20:46 +08:00

889 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 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) {
if (saving.value) return
saving.value = true
try {
const result = await api.applyConfig(next, props.revision)
// 内核不在线时只写了文件没重载,如实说,不能只报成功
toast(result.reloaded ? success : `${success}(${result.reloadMessage})`)
emit('saved')
} catch (error) {
toastError(error)
} 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)
}
/* ── 资源(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>
</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>
<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>