轻提示铺开到全部页面;内核状态悬停变重启;GEO 支持单文件下载

侧栏动作排撤掉重启图标,内核状态(圆点加版本号)本身变成按钮:悬停
换层显示「重启内核」,不在线时只当状态看。行高压成 1 修掉圆点和等宽
字体不居中的问题。

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

GEO 每行的更新改成单独下载那一个文件:内核只有全量更新的口子,单文件
只能服务端自己下(POST /api/geo/sync),代价是只有形状校验(MaxMind
末尾标记、geodata 首字节,shared/geo.ts 带测试)且内核重启后才换用新
文件;「更新全部」文字按钮保留内核的全量口子,有完整校验和热加载。
上传两行的按钮改「选择或拖放文件」,投放区拖入高亮,恢复策略挪到
恢复行下方。
This commit is contained in:
2026-08-12 02:20:46 +08:00
parent f761d13579
commit b276c4a8fe
13 changed files with 366 additions and 304 deletions
+16 -90
View File
@@ -24,6 +24,7 @@ import {
import type { OutboundSetup } from '@shared/inbound-groups.ts'
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
import { ApiError, api } from '../api'
import { toast, toastError } from '../toast'
import type { ListenerFields } from '../api'
import type { GroupView } from '../runtime'
import { optimisticSelect, providerLines, reloadRuntime, runtime } from '../runtime'
@@ -33,7 +34,6 @@ import InboundList from '../components/InboundList.vue'
import InboundDetail from './InboundDetail.vue'
import InboundForm from './InboundForm.vue'
import IconAdd from '~icons/ri/add-line'
import IconClose from '~icons/ri/close-line'
const props = defineProps<{
data: ConfigObject
@@ -56,20 +56,14 @@ const defaultSetup = (): OutboundSetup => ({
const selectedId = ref<string | null>(null)
const saving = ref(false)
const failure = ref<{ message: string; hint: string } | null>(null)
/** 成功的结果,绿色 */
const done = ref('')
/** 中性的告知,比如某条线路测不通——那是关于线路的结论,不是操作失败 */
const notice = ref('')
const fields = ref<ListenerFields | null>(null)
const formOpen = ref(false)
const formEditing = ref<Inbound | null>(null)
const { testing, test } = useDelayTest(
() => void reloadRuntime(),
(message) => {
notice.value = message
},
// 「某条线路测不通」是关于线路的结论,不是操作失败,走中性的轻提示
(message) => toast(message, { kind: 'info' }),
)
onMounted(async () => {
@@ -130,25 +124,21 @@ const chainView = computed<GroupView | null>(() => {
const clone = () => JSON.parse(JSON.stringify(props.data)) as ConfigObject
/** 所有改配置文件的动作都从这里走。 */
async function commit(raw: ConfigObject, success: string) {
/** 所有改配置文件的动作都从这里走。反馈走右上角轻提示,返回值告知成败。 */
async function commit(raw: ConfigObject, success: string): Promise<boolean> {
// 先把生成的东西按当前规则重建一遍,再清掉没人再引用的。不清理的话,改几次文件里就
// 攒下一堆用户从没主动建过的东西,而没人用的订阅副本还会按 interval 继续联网。
const next = pruneGenerated(syncGenerated(raw, collectInbounds(raw)))
saving.value = true
failure.value = null
done.value = ''
notice.value = ''
try {
const result = await api.applyConfig(next, props.revision)
// 没重载有两种情况(内容没有变化、内核不在线只写了文件),都要如实说,不能只报成功
done.value = result.reloaded ? success : `${success}(${result.reloadMessage})`
toast(result.reloaded ? success : `${success}(${result.reloadMessage})`)
emit('saved')
return true
} catch (error) {
failure.value =
error instanceof ApiError
? { message: error.message, hint: error.hint }
: { message: String(error), hint: '' }
toastError(error)
return false
} finally {
saving.value = false
}
@@ -158,8 +148,8 @@ async function commit(raw: ConfigObject, success: string) {
async function saveSetup(next: OutboundSetup, picks: { proxy: string | null; chain: string | null }) {
const target = selected.value
if (!target || saving.value) return
await commit(applySetup(clone(), target.name, next), `${target.name} 的代理配置已保存`)
if (failure.value) return
const ok = await commit(applySetup(clone(), target.name, next), `${target.name} 的代理配置已保存`)
if (!ok) return
// 草稿态里预选的线路在保存成功、组按新配置就位之后逐个选上。这是运行时动作,
// 失败不影响已保存的配置:组停在内核的默认选中上,刷新后显示的就是真实状态。
const picked: Array<[string, string | null]> = [
@@ -207,9 +197,9 @@ async function submitForm(entry: ConfigObject) {
const name = String(entry.name)
const withGroup = editingIndex === null ? applySetup(next, name, defaultSetup()) : next
await commit(withGroup, editingIndex === null ? `已新增 ${name}` : '已保存')
const ok = await commit(withGroup, editingIndex === null ? `已新增 ${name}` : '已保存')
// 失败时不能关对话框——关了再打开就是空表单,用户填的名称、端口、类型全没了
if (failure.value) return
if (!ok) return
formOpen.value = false
if (editingIndex === null) selectedId.value = `listener:${String(listeners.length - 1)}`
}
@@ -218,17 +208,14 @@ async function submitForm(entry: ConfigObject) {
async function selectMember(group: string, member: string) {
if (saving.value) return
optimisticSelect(group, member)
failure.value = null
done.value = ''
notice.value = ''
try {
await api.selectProxy(group, member)
await reloadRuntime()
} catch (error) {
failure.value = {
message: error instanceof ApiError ? error.message : String(error),
toast(error instanceof ApiError ? error.message : String(error), {
kind: 'error',
hint: '该操作仅修改内核运行状态,配置文件未被修改。',
}
})
}
}
@@ -282,18 +269,6 @@ async function removeConfirmed() {
</button>
</header>
<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">
<IconClose />
</button>
</div>
<p v-else-if="done" class="done">{{ done }}</p>
<p v-else-if="notice" class="notice">{{ notice }}</p>
<div class="split">
<InboundList v-model:selected="selectedId" :inbounds="inbounds" />
@@ -389,55 +364,6 @@ async function removeConfirmed() {
cursor: not-allowed;
}
.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-4);
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-4);
}
.notice {
color: var(--c-text-soft);
font-size: var(--t-sm);
margin-bottom: var(--s-4);
}
/* 左列表右详情。窄时改成上下,详情不再吸顶 */
.split {
display: grid;