单值设置从常驻输入框改成 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,覆盖整份替换)。平时是按钮,拖着文件 进窗口时整行变成投放区。
857 lines
25 KiB
Vue
857 lines
25 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 { 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 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 }>()
|
||
|
||
/** 动了配置文件,外壳需要重读整份配置。 */
|
||
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
|
||
}
|
||
|
||
/* ── 状态与反馈 ───────────────────────────────── */
|
||
|
||
const saving = ref(false)
|
||
const failure = ref<{ message: string; hint: string } | null>(null)
|
||
const done = ref('')
|
||
|
||
function fail(error: unknown) {
|
||
failure.value =
|
||
error instanceof ApiError
|
||
? { message: error.message, hint: error.hint }
|
||
: { message: String(error), hint: '' }
|
||
}
|
||
|
||
/** 所有改配置文件的动作都从这里走。 */
|
||
async function commitConfig(next: ConfigObject, success: string) {
|
||
if (saving.value) return
|
||
saving.value = true
|
||
failure.value = null
|
||
done.value = ''
|
||
try {
|
||
const result = await api.applyConfig(next, props.revision)
|
||
// 内核不在线时只写了文件没重载,如实说,不能只报成功
|
||
done.value = result.reloaded ? success : `${success}(${result.reloadMessage})`
|
||
emit('saved')
|
||
} catch (error) {
|
||
fail(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())}`
|
||
}
|
||
|
||
/** GEO 更新是运行时动作,不写配置文件。内核只有全量更新的口子,四行共用一个忙态。 */
|
||
async function updateGeo() {
|
||
if (geoBusy.value || !kernel.value.online) return
|
||
geoBusy.value = true
|
||
failure.value = null
|
||
done.value = ''
|
||
try {
|
||
await api.updateGeo()
|
||
done.value = 'GEO 数据库已更新'
|
||
await loadGeo()
|
||
} catch (error) {
|
||
fail(error)
|
||
} finally {
|
||
geoBusy.value = false
|
||
}
|
||
}
|
||
|
||
/** 正在编辑下载地址的那一行(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() {
|
||
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)
|
||
}
|
||
}
|
||
|
||
type UploadKind = 'restore' | 'import'
|
||
|
||
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)
|
||
} 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
|
||
}
|
||
// 恢复入口只收 Patchbay 自己下载的文件,认标记。别家的配置走导入
|
||
if (kind === 'restore' && !hasValidMarker(parsed as ConfigObject)) {
|
||
failure.value = {
|
||
message: '这份文件没有 Patchbay 标记,不像是从 Patchbay 下载的配置。',
|
||
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
|
||
failure.value = null
|
||
done.value = ''
|
||
try {
|
||
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 {
|
||
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">
|
||
<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 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">
|
||
<h2 class="title">资源</h2>
|
||
<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>
|
||
<button type="button" class="btn with-ico" @click="downloadConfig">
|
||
<IconDownload /> 下载文件
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 拖着文件进窗口时,两个上传行整行变成投放区 -->
|
||
<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">
|
||
<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-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-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);
|
||
}
|
||
|
||
/* ── 反馈 ────────────────────────────────────── */
|
||
.failure {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: var(--s-3);
|
||
border: 1px solid color-mix(in srgb, var(--c-danger) 35%, var(--c-border));
|
||
border-radius: var(--r-md);
|
||
background: var(--c-danger-soft);
|
||
padding: var(--s-3) var(--s-4);
|
||
margin-bottom: var(--s-3);
|
||
font-size: var(--t-sm);
|
||
}
|
||
|
||
.failure-text {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--s-1);
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.failure-x {
|
||
flex: none;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 24px;
|
||
height: 24px;
|
||
border: none;
|
||
border-radius: var(--r-sm);
|
||
background: none;
|
||
color: var(--c-danger);
|
||
cursor: pointer;
|
||
}
|
||
.failure-x:hover {
|
||
background: color-mix(in srgb, var(--c-danger) 12%, transparent);
|
||
}
|
||
|
||
.done {
|
||
color: var(--c-ok);
|
||
font-size: var(--t-sm);
|
||
margin-bottom: var(--s-3);
|
||
}
|
||
</style>
|