Files
mihomo-patchbay/web/views/ConfigPage.vue
T
im0o 0eef680fba 配置页:订阅的增删改和立刻更新
写配置的纯逻辑在 shared/providers.ts。删除前 subscriptionReferences 同时查
直接 use 和经订阅副本(共用缓存文件 path)的间接引用,有被引用的就拦下并列出
策略组。订阅不允许改名:名字被 use: 和副本引用,同步所有引用不如删了重建。

页面还没挂进 App.vue 的导航,由集成者统一加。
2026-08-11 18:50:13 +08:00

356 lines
10 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 {
addSubscription,
listSubscriptions,
providerNames,
removeSubscription,
subscriptionReferences,
updateSubscription,
} from '@shared/providers.ts'
import type { Subscription } from '@shared/providers.ts'
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
import { ApiError, api } from '../api'
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'
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 failure = ref<{ message: string; hint: string } | null>(null)
/** 成功的结果,绿色 */
const done = ref('')
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),
healthCheck: s.healthCheck !== null,
}
}),
)
const clone = () => JSON.parse(JSON.stringify(props.data)) as ConfigObject
/** 所有改配置文件的动作都从这里走。 */
async function commit(raw: ConfigObject, success: string) {
// 先把生成的东西按当前规则重建一遍,再清掉没人再引用的。订阅副本的来源被改地址时
// 靠这一步保持和原订阅一致,删订阅前已由 subscriptionReferences 挡住有引用的情形。
const next = pruneGenerated(syncGenerated(raw, collectInbounds(raw)))
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) {
failure.value =
error instanceof ApiError
? { message: error.message, hint: error.hint }
: { message: String(error), hint: '' }
} 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 }) {
const editing = formEditing.value
const next = editing
? updateSubscription(clone(), editing.name, draft.url, draft.interval)
: addSubscription(clone(), draft.name, draft.url)
await commit(next, editing ? '已保存' : `已新增 ${draft.name}`)
// 失败时不能关对话框——关了再打开就是空表单,填的名称、地址、间隔全没了
if (failure.value) return
formOpen.value = false
if (!editing) 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) {
failure.value = { message: `无法删除 ${target.name}:${refs.join('、')} 在引用它`, hint: '' }
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 refresh() {
const target = selected.value
if (!target || refreshing.value !== null) return
refreshing.value = target.name
failure.value = null
done.value = ''
try {
await api.refreshProvider(target.name)
await Promise.all([reloadRuntime(), reloadProviders()])
done.value = `已更新 ${target.name}`
} catch (error) {
failure.value = {
message: error instanceof ApiError ? error.message : String(error),
hint: '该操作仅修改内核运行状态,配置文件未被修改。',
}
} finally {
refreshing.value = null
}
}
</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>
<button class="add" type="button" :disabled="saving" @click="openAdd">
<IconAdd />
<span>新增</span>
</button>
</header>
<div v-if="failure" class="failure">
<strong>{{ failure.message }}</strong>
<span v-if="failure.hint">{{ failure.hint }}</span>
</div>
<p v-else-if="done" class="done">{{ done }}</p>
<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"
/>
</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;
}
.failure {
display: flex;
flex-direction: column;
gap: var(--s-1);
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);
}
.done {
color: var(--c-ok);
font-size: var(--t-sm);
margin-bottom: var(--s-4);
}
/* 左列表右详情。窄时改成上下,详情不再吸顶 */
.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>