订阅和规则组支持从文件导入,配置页与表单一批打磨

文件导入走两步:内容先经新端点 POST /api/provider-files 写进内核工作
目录的 providers/ 下(路径白名单校验,只认两个路径助手生成的形状),
成了再写 file 类型的声明——顺序反了内核重载会去读不存在的文件。
file 类型没有地址和自动更新(内核靠文件监视跟进),详情页照此展示,
编辑按钮禁用。ini 导入的取文件按钮换成同一个拖放模块。

订阅的更新间隔改成开关语义:内核只在 interval > 0 时才起定时拉取
(component/resource/fetcher.go:226-228),关掉开关就是不写这个键。
配置页加「更新所有订阅」,详情的「立刻更新」改叫「更新订阅」。

规则表从零开始写入时自动垫上内网直连——保留地址不直连的话第一条
代理规则一生效,访问局域网的流量就全进了代理;按钮改成「补全内网
直连」,只在有缺口时出现。

所有表单对话框点遮罩关闭;失败横幅右侧加关闭按钮。
This commit is contained in:
2026-08-12 00:46:17 +08:00
parent 7d08f37248
commit 013f537eaa
19 changed files with 871 additions and 101 deletions
+105 -10
View File
@@ -10,6 +10,7 @@ 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,
@@ -18,6 +19,7 @@ import {
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 type { ProviderInfo } from '../api'
@@ -27,6 +29,8 @@ 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'
import IconClose from '~icons/ri/close-line'
const props = defineProps<{ data: ConfigObject; revision: string }>()
@@ -142,11 +146,33 @@ function openEdit() {
formOpen.value = true
}
async function submitForm(draft: { name: string; url: string; interval: number }) {
async function submitForm(draft: {
name: string
url: string
interval: number | null
file: { name: string; content: string } | null
}) {
const editing = formEditing.value
// 从文件导入:先把内容写到订阅的缓存路径上,成了再写声明。
// 顺序不能反——声明先落盘的话,内核重载会去读一个还不存在的文件
if (draft.file) {
try {
await api.uploadProviderFile(providerCachePath(draft.name), draft.file.content)
} catch (error) {
failure.value =
error instanceof ApiError
? { message: error.message, hint: error.hint }
: { message: String(error), hint: '' }
return
}
}
const next = editing
? updateSubscription(clone(), editing.name, draft.url, draft.interval)
: addSubscription(clone(), draft.name, draft.url)
: draft.file
? addFileSubscription(clone(), draft.name)
: addSubscription(clone(), draft.name, draft.url, draft.interval)
await commit(next, editing ? '已保存' : `已新增 ${draft.name}`)
// 失败时不能关对话框——关了再打开就是空表单,填的名称、地址、间隔全没了
if (failure.value) return
@@ -194,6 +220,29 @@ async function refresh() {
refreshing.value = null
}
}
const refreshingAll = ref(false)
/** 挨个让内核重新拉取所有订阅。失败的点名,成功的不逐个报。 */
async function refreshAll() {
if (refreshingAll.value || !kernel.value.online || subscriptions.value.length === 0) return
refreshingAll.value = true
failure.value = null
done.value = ''
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) {
failure.value = {
message: `${failed.join('、')} 更新失败`,
hint: '其余订阅已更新。该操作仅修改内核运行状态,配置文件未被修改。',
}
} else {
done.value = `已更新全部 ${names.length} 个订阅`
}
refreshingAll.value = false
}
</script>
<template>
@@ -210,15 +259,31 @@ async function refresh() {
</div>
</div>
<button class="add" type="button" :disabled="saving" @click="openAdd">
<IconAdd />
<span>新增</span>
</button>
<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 v-if="failure" class="failure">
<strong>{{ failure.message }}</strong>
<span v-if="failure.hint">{{ failure.hint }}</span>
<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>
@@ -310,10 +375,15 @@ async function refresh() {
cursor: not-allowed;
}
.head-acts {
display: flex;
gap: var(--s-2);
}
.failure {
display: flex;
flex-direction: column;
gap: var(--s-1);
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);
@@ -322,6 +392,31 @@ async function refresh() {
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);