Files
mihomo-patchbay/web/views/ConfigPage.vue
T
im0o d6942c5702 订阅健康检查默认开启,编辑支持改名
内核没有默认的健康检查地址,url 为空时检查根本不会运行
(adapter/provider/healthcheck.go:196-199),所以默认值必须写全;
开关只翻 enable,手写的 url/interval 留着下次开启时还用。
改名和策略组 use: 的引用同步收在同一次变换里,链式副本由保存
链路的 syncGenerated 按新名字重建,缓存路径刻意不动——file 类型
的数据本体和副本认亲都靠它。
2026-08-12 17:30:07 +08:00

422 lines
13 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">
/**
* 配置页:订阅的导入和管理。左列表右详情,同路由页的主从布局。
*
* 编排守的分工和路由页一样:增删改订阅都走 commit(),带校验和重载,失败按
* message/hint 说;「立刻更新」是让内核重新拉取的运行时动作,不写配置文件,
* 不走 commit(),也没有保存那种等待感。
*/
import { computed, onMounted, ref, watch } from 'vue'
import { collectInbounds } from '@shared/inbounds.ts'
import { pruneGenerated, syncGenerated } from '@shared/inbound-groups.ts'
import {
addFileSubscription,
addSubscription,
listSubscriptions,
providerNames,
removeSubscription,
renameSubscription,
setSubscriptionHealthCheck,
subscriptionPathConflict,
subscriptionReferences,
updateSubscription,
} from '@shared/providers.ts'
import type { Subscription } from '@shared/providers.ts'
import { providerCachePath } 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 { ProviderInfo } from '../api'
import { kernel, providerLines, reloadRuntime } from '../runtime'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import ProviderList from '../components/ProviderList.vue'
import SubscriptionDetail from './SubscriptionDetail.vue'
import SubscriptionForm from './SubscriptionForm.vue'
import IconAdd from '~icons/ri/add-line'
import IconRefresh from '~icons/ri/refresh-line'
const props = defineProps<{ data: ConfigObject; revision: string }>()
/** 配置文件变了,需要重读整份配置。运行时的变化不走这里。 */
const emit = defineEmits<{ saved: [] }>()
const subscriptions = computed(() => listSubscriptions(props.data))
const selectedName = ref<string | null>(null)
const saving = ref(false)
const formOpen = ref(false)
const formEditing = ref<Subscription | null>(null)
/** 待确认删除的那个订阅。非 null 时确认对话框打开着 */
const removing = ref<Subscription | null>(null)
/** 正在等内核重新拉取的订阅名 */
const refreshing = ref<string | null>(null)
/**
* 每个订阅的运行时数据:当前线路、上次更新时间。
*
* 配置文件里只有地址和间隔,这些只有运行中的内核知道。runtime.ts 那份合并视图
* 没有 updatedAt,而它归路由页共用、不归这个页面改,所以这里自己留一份。
* 内核没连接时就是空的,页面照常显示配置里有的东西(决策记录 0004)。
*/
const providerInfo = ref<Record<string, ProviderInfo>>({})
async function reloadProviders() {
if (!kernel.value.online) {
providerInfo.value = {}
return
}
providerInfo.value = await api
.providers()
.then((result) => result.providers)
.catch(() => ({}))
}
onMounted(() => void reloadProviders())
// 保存成功后父组件重读配置,data 换了就顺带把运行时数据也换新的
watch(() => props.data, () => void reloadProviders())
// 默认选中第一行。空面板没有信息量,不如直接展示一个订阅。
watch(
subscriptions,
(list) => {
if (!list.some((s) => s.name === selectedName.value)) selectedName.value = list[0]?.name ?? null
},
{ immediate: true },
)
const selected = computed(
() => subscriptions.value.find((s) => s.name === selectedName.value) ?? null,
)
/** 新增表单挡重名要用全部键:和用户看不见的订阅副本撞名也会被静默丢掉 */
const takenNames = computed(() => providerNames(props.data))
/** Go 零值时间(0001-01-01)表示从没拉取过,按没有处理 */
function parseUpdated(at: string | undefined): Date | null {
if (!at) return null
const d = new Date(at)
return Number.isNaN(d.getTime()) || d.getFullYear() < 2000 ? null : d
}
const rows = computed(() =>
subscriptions.value.map((s) => {
const info = providerInfo.value[s.name]
return {
name: s.name,
lines: info ? info.proxies.length : null,
updated: parseUpdated(info?.updatedAt),
}
}),
)
const clone = () => JSON.parse(JSON.stringify(props.data)) as ConfigObject
/** 所有改配置文件的动作都从这里走。反馈走右上角轻提示,返回值告知成败。 */
async function commit(raw: ConfigObject, success: string): Promise<boolean> {
// 先把生成的东西按当前规则重建一遍,再清掉没人再引用的。订阅副本的来源被改地址时
// 靠这一步保持和原订阅一致,删订阅前已由 subscriptionReferences 挡住有引用的情形。
const next = pruneGenerated(syncGenerated(raw, collectInbounds(raw)))
saving.value = true
try {
const result = await api.applyConfig(next, props.revision)
// 没重载有两种情况(内容没有变化、内核不在线只写了文件),都要如实说,不能只报成功
toast(result.reloaded ? success : `${success}(${result.reloadMessage})`)
emit('saved')
return true
} catch (error) {
toastError(error)
return false
} finally {
saving.value = false
}
}
function openAdd() {
formEditing.value = null
formOpen.value = true
}
function openEdit() {
if (!selected.value || saving.value) return
formEditing.value = selected.value
formOpen.value = true
}
async function submitForm(draft: {
name: string
url: string
interval: number | null
file: { name: string; content: string } | null
}) {
const editing = formEditing.value
// 名字查重放行了、但净化后的缓存路径和别人相同的,在这里拦
// (只查新增:改名不动缓存路径,不会制造新的撞车)
if (!editing) {
const path = providerCachePath(draft.name)
const clash = subscriptionPathConflict(props.data, draft.name, path)
if (clash) {
toast(`「${draft.name}」和「${clash}」的缓存文件会是同一个`, {
kind: 'error',
hint: '名字只在符号或空格上有差异时会净化成同一个文件名,换一个名字。',
})
return
}
}
// 从文件导入:先把内容写到订阅的缓存路径上,成了再写声明。
// 顺序不能反——声明先落盘的话,内核重载会去读一个还不存在的文件
if (draft.file) {
try {
await api.uploadProviderFile(providerCachePath(draft.name), draft.file.content)
} catch (error) {
toastError(error)
return
}
}
// 编辑时先改名再改字段。名字没动时 renameSubscription 原样返回;
// file 类型只有名称可改,不能走 updateSubscription——它会写进 url
const next = editing
? editing.type === 'file'
? renameSubscription(clone(), editing.name, draft.name)
: updateSubscription(
renameSubscription(clone(), editing.name, draft.name),
draft.name,
draft.url,
draft.interval,
)
: draft.file
? addFileSubscription(clone(), draft.name)
: addSubscription(clone(), draft.name, draft.url, draft.interval)
const ok = await commit(next, editing ? '已保存' : `已新增 ${draft.name}`)
// 失败时不能关对话框——关了再打开就是空表单,填的名称、地址、间隔全没了
if (!ok) return
formOpen.value = false
// 新增和改名都要把选中跟过去,否则选中名失效会被重置到第一行
selectedName.value = draft.name
}
function askRemove() {
const target = selected.value
if (!target || saving.value) return
// 被引用的订阅不能删:那些组的 use: 一空,指向它们的入站就无路可走
const refs = subscriptionReferences(props.data, target.name)
if (refs.length > 0) {
toast(`无法删除 ${target.name}:${refs.join('、')} 在引用它`, { kind: 'error' })
return
}
removing.value = target
}
async function removeConfirmed() {
const target = removing.value
removing.value = null
if (!target || saving.value) return
selectedName.value = null
await commit(removeSubscription(clone(), target.name), `已删除 ${target.name}`)
}
/** 开关选中订阅的健康检查。写配置文件,走 commit()。 */
async function toggleHealthCheck() {
const target = selected.value
if (!target || saving.value) return
const enable = target.healthCheck === null
await commit(
setSubscriptionHealthCheck(clone(), target.name, enable),
enable ? `已开启 ${target.name} 的健康检查` : `已关闭 ${target.name} 的健康检查`,
)
}
/** 让内核立刻重新拉取。运行时动作,不写配置文件,所以不走 commit()。 */
async function refresh() {
const target = selected.value
if (!target || refreshing.value !== null) return
refreshing.value = target.name
try {
await api.refreshProvider(target.name)
await Promise.all([reloadRuntime(), reloadProviders()])
toast(`已更新 ${target.name}`)
} catch (error) {
toast(error instanceof ApiError ? error.message : String(error), {
kind: 'error',
hint: '该操作仅修改内核运行状态,配置文件未被修改。',
})
} finally {
refreshing.value = null
}
}
const refreshingAll = ref(false)
/** 挨个让内核重新拉取所有订阅。失败的点名,成功的不逐个报。 */
async function refreshAll() {
if (refreshingAll.value || !kernel.value.online || subscriptions.value.length === 0) return
refreshingAll.value = true
const names = subscriptions.value.map((s) => s.name)
const results = await Promise.allSettled(names.map((n) => api.refreshProvider(n)))
await Promise.all([reloadRuntime(), reloadProviders()])
const failed = names.filter((_, i) => results[i]?.status === 'rejected')
if (failed.length > 0) {
toast(`${failed.join('、')} 更新失败`, {
kind: 'error',
hint: '其余订阅已更新。该操作仅修改内核运行状态,配置文件未被修改。',
})
} else {
toast(`已更新全部 ${names.length} 个订阅`)
}
refreshingAll.value = false
}
</script>
<template>
<div class="page">
<header class="head">
<div class="sums">
<div class="sum">
<span class="sum-n">{{ subscriptions.length }}</span>
<span class="sum-k">订阅</span>
</div>
<div class="sum">
<span class="sum-n">{{ providerLines.length }}</span>
<span class="sum-k">订阅线路</span>
</div>
</div>
<div class="head-acts">
<button
class="add"
type="button"
:disabled="refreshingAll || !kernel.online || subscriptions.length === 0"
@click="refreshAll"
>
<IconRefresh />
<span>{{ refreshingAll ? '更新中' : '更新所有订阅' }}</span>
</button>
<button class="add" type="button" :disabled="saving" @click="openAdd">
<IconAdd />
<span>新增</span>
</button>
</div>
</header>
<div class="split">
<ProviderList v-model:selected="selectedName" :rows="rows" />
<SubscriptionDetail
v-if="selected"
:subscription="selected"
:info="providerInfo[selected.name]"
:online="kernel.online"
:refreshing="refreshing === selected.name"
:saving="saving"
@edit="openEdit"
@remove="askRemove"
@refresh="refresh"
@toggle-health-check="toggleHealthCheck"
/>
</div>
<p v-if="subscriptions.length === 0" class="empty">没有订阅。</p>
<SubscriptionForm
:open="formOpen"
:editing="formEditing"
:taken-names="takenNames"
:saving="saving"
@close="formOpen = false"
@submit="submitForm"
/>
<ConfirmDialog
:open="removing !== null"
:message="removing ? `删除订阅「${removing.name}」?` : ''"
action="删除"
@close="removing = null"
@confirm="removeConfirmed"
/>
</div>
</template>
<style scoped>
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-4);
margin-bottom: var(--s-5);
}
.sums {
display: flex;
gap: var(--s-6);
}
.sum {
display: flex;
align-items: baseline;
gap: var(--s-2);
}
.sum-n {
font-size: var(--t-2xl);
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
}
.sum-k {
color: var(--c-text-soft);
font-size: var(--t-sm);
}
.add {
display: flex;
align-items: center;
gap: 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);
cursor: pointer;
}
.add:hover:not(:disabled) {
border-color: var(--c-accent);
color: var(--c-accent-text);
}
.add:disabled {
color: var(--c-text-faint);
cursor: not-allowed;
}
.head-acts {
display: flex;
gap: var(--s-2);
}
/* 左列表右详情。窄时改成上下,详情不再吸顶 */
.split {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(300px, 1fr);
gap: var(--s-5);
align-items: start;
}
/* 按内容区宽度切换而不是按视口:侧栏能收拢,同一个视口宽度下内容区可宽可窄 */
.page {
container-type: inline-size;
}
@container (max-width: 900px) {
.split {
grid-template-columns: 1fr;
}
}
.empty {
color: var(--c-text-faint);
font-size: var(--t-sm);
margin-top: var(--s-4);
}
</style>