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

文件导入走两步:内容先经新端点 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);
+34 -1
View File
@@ -153,7 +153,15 @@ async function closeAllConfirmed() {
</header>
<div v-if="shownFailure" class="failure">
<strong>{{ shownFailure }}</strong>
<strong class="failure-text">{{ shownFailure }}</strong>
<button
type="button"
class="failure-x"
title="关闭提示"
@click="((failure = ''), (pollFailure = ''))"
>
<IconClose />
</button>
</div>
<p v-if="!kernel.online" class="note">内核未连接,看不到当前有哪些连接。</p>
@@ -305,6 +313,9 @@ async function closeAllConfirmed() {
}
.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);
@@ -313,6 +324,28 @@ async function closeAllConfirmed() {
font-size: var(--t-sm);
}
.failure-text {
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);
}
.note {
color: var(--c-text-faint);
font-size: var(--t-sm);
+12 -1
View File
@@ -98,6 +98,11 @@ watch(
)
/** 把表单值转成配置里该有的形状。空字段不写,避免生成一堆没意义的键。 */
/** 点在遮罩上就关闭。内容区的点击 target 是 form 里的元素,到不了 dialog 本身。 */
function onBackdrop(event: MouseEvent) {
if (event.target === dialog.value) emit('close')
}
function submit() {
if (!canSubmit.value) return
const entry: ConfigObject = {
@@ -127,7 +132,13 @@ function submit() {
</script>
<template>
<dialog ref="dialog" class="dlg" @close="emit('close')" @cancel.prevent="emit('close')">
<dialog
ref="dialog"
class="dlg"
@click="onBackdrop"
@close="emit('close')"
@cancel.prevent="emit('close')"
>
<form method="dialog" @submit.prevent="submit">
<header class="dlg-head">
<h2>{{ editing ? '编辑路由' : '新增路由' }}</h2>
+15 -22
View File
@@ -15,6 +15,7 @@ import { computed, ref, watch } from 'vue'
import { parseIniRulesets, planIniImport } from '@shared/rules.ts'
import type { IniImportPlan } from '@shared/rules.ts'
import SelectBox from '../components/SelectBox.vue'
import DropFile from '../components/DropFile.vue'
import IconClose from '~icons/ri/close-line'
const props = defineProps<{
@@ -37,7 +38,6 @@ const KIND_LABELS: Record<string, string> = {
}
const dialog = ref<HTMLDialogElement | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const text = ref('')
const mapping = ref<Record<string, string>>({})
@@ -85,21 +85,25 @@ watch(targets, (list) => {
mapping.value = next
})
async function pickFile(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]
if (file) text.value = await file.text()
// 同一个文件选第二次也要触发 change
if (fileInput.value) fileInput.value.value = ''
}
function submit() {
if (!canSubmit.value) return
emit('submit', plan.value)
}
/** 点在遮罩上就关闭。内容区的点击 target 是 form 里的元素,到不了 dialog 本身。 */
function onBackdrop(event: MouseEvent) {
if (event.target === dialog.value) emit('close')
}
</script>
<template>
<dialog ref="dialog" class="dlg" @close="emit('close')" @cancel.prevent="emit('close')">
<dialog
ref="dialog"
class="dlg"
@click="onBackdrop"
@close="emit('close')"
@cancel.prevent="emit('close')"
>
<form method="dialog" @submit.prevent="submit">
<header class="dlg-head">
<h2>导入 ini</h2>
@@ -110,21 +114,14 @@ function submit() {
<label class="row">
<span class="k">内容</span>
<span class="v">
<DropFile accept=".ini,.conf,.txt" @load="(_, content) => (text = content)" />
<textarea
v-model="text"
class="mono"
rows="8"
placeholder="粘贴 ini 文本,或从文件读取"
placeholder="或把 ini 文本粘贴到这里"
spellcheck="false"
></textarea>
<input
ref="fileInput"
type="file"
accept=".ini,.conf,.txt"
hidden
@change="pickFile"
/>
<button type="button" class="btn file" @click="fileInput?.click()">从文件读取</button>
</span>
</label>
@@ -277,10 +274,6 @@ textarea {
font-size: var(--t-xs);
}
.file {
align-self: flex-start;
}
.btn {
padding: var(--s-2) var(--s-4);
border: 1px solid var(--c-border-strong);
+30 -1
View File
@@ -13,6 +13,7 @@ import SelectBox from '../components/SelectBox.vue'
import IconPause from '~icons/ri/pause-line'
import IconPlay from '~icons/ri/play-line'
import IconClear from '~icons/ri/delete-bin-line'
import IconClose from '~icons/ri/close-line'
const query = ref('')
@@ -76,7 +77,10 @@ const visible = computed(() => {
</header>
<div v-if="failure" class="failure">
<strong>{{ failure }}</strong>
<strong class="failure-text">{{ failure }}</strong>
<button type="button" class="failure-x" title="关闭提示" @click="failure = ''">
<IconClose />
</button>
</div>
<div ref="listEl" class="log" @scroll="onScroll">
@@ -152,6 +156,9 @@ const visible = computed(() => {
}
.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);
@@ -160,6 +167,28 @@ const visible = computed(() => {
font-size: var(--t-sm);
}
.failure-text {
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);
}
.log {
flex: 1;
min-height: 0;
+35 -4
View File
@@ -33,6 +33,7 @@ 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
@@ -282,8 +283,13 @@ async function removeConfirmed() {
</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>
<p v-else-if="notice" class="notice">{{ notice }}</p>
@@ -385,8 +391,8 @@ async function removeConfirmed() {
.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);
@@ -395,6 +401,31 @@ async function removeConfirmed() {
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);
+12 -1
View File
@@ -148,10 +148,21 @@ function submit() {
if (!canSubmit.value) return
emit('submit', rawMode.value ? raw.value.trim() : structured())
}
/** 点在遮罩上就关闭。内容区的点击 target 是 form 里的元素,到不了 dialog 本身。 */
function onBackdrop(event: MouseEvent) {
if (event.target === dialog.value) emit('close')
}
</script>
<template>
<dialog ref="dialog" class="dlg" @close="emit('close')" @cancel.prevent="emit('close')">
<dialog
ref="dialog"
class="dlg"
@click="onBackdrop"
@close="emit('close')"
@cancel.prevent="emit('close')"
>
<form method="dialog" @submit.prevent="submit">
<header class="dlg-head">
<h2>{{ editing ? '编辑规则' : '新增规则' }}</h2>
+96 -10
View File
@@ -10,7 +10,9 @@ import { computed, ref, watch } from 'vue'
import { inferRuleFormat, ruleProviderPath } from '@shared/rules.ts'
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
import SelectBox from '../components/SelectBox.vue'
import DropFile from '../components/DropFile.vue'
import IconClose from '~icons/ri/close-line'
import IconFile from '~icons/ri/file-text-line'
const props = defineProps<{
open: boolean
@@ -21,7 +23,8 @@ const props = defineProps<{
const emit = defineEmits<{
close: []
submit: [entry: { name: string; declaration: ConfigObject }]
/** file 非 null 表示清单内容来自上传,要先落盘再写声明 */
submit: [entry: { name: string; declaration: ConfigObject; file: { content: string } | null }]
}>()
const dialog = ref<HTMLDialogElement | null>(null)
@@ -36,12 +39,20 @@ const formatTouched = ref(false)
const BEHAVIOR_OPTIONS = [{ value: 'classical' }, { value: 'domain' }, { value: 'ipcidr' }]
const FORMAT_OPTIONS = [{ value: 'yaml' }, { value: 'text' }, { value: 'mrs' }]
/** 上传的清单。选了文件就不再需要来源:内容就是这份文件 */
const file = ref<{ name: string; content: string } | null>(null)
const isUrl = computed(() => /^https?:\/\//i.test(source.value.trim()))
watch(source, (value) => {
if (!formatTouched.value) format.value = inferRuleFormat(value.trim())
})
function onFile(fileName: string, content: string) {
file.value = { name: fileName, content }
if (!formatTouched.value) format.value = inferRuleFormat(fileName)
}
const nameTaken = computed(() => props.takenNames.includes(name.value.trim()))
/** 内核没有实现 classical 行为的 mrs 格式(rules/provider/ 下只有 domain 和 ipcidr 两种策略) */
@@ -50,7 +61,7 @@ const mrsConflict = computed(() => format.value === 'mrs' && behavior.value ===
const canSubmit = computed(
() =>
name.value.trim() !== '' &&
source.value.trim() !== '' &&
(file.value !== null || source.value.trim() !== '') &&
!nameTaken.value &&
!mrsConflict.value &&
!props.saving,
@@ -65,6 +76,7 @@ watch(
behavior.value = 'classical'
format.value = 'yaml'
formatTouched.value = false
file.value = null
dialog.value?.showModal()
} else {
dialog.value?.close()
@@ -76,22 +88,44 @@ function submit() {
if (!canSubmit.value) return
const trimmedName = name.value.trim()
const trimmedSource = source.value.trim()
const declaration: ConfigObject = isUrl.value
const declaration: ConfigObject = file.value
? {
type: 'http',
type: 'file',
behavior: behavior.value,
url: trimmedSource,
path: ruleProviderPath(trimmedName, format.value),
format: format.value,
interval: 86400,
}
: { type: 'file', behavior: behavior.value, path: trimmedSource, format: format.value }
emit('submit', { name: trimmedName, declaration })
: isUrl.value
? {
type: 'http',
behavior: behavior.value,
url: trimmedSource,
path: ruleProviderPath(trimmedName, format.value),
format: format.value,
interval: 86400,
}
: { type: 'file', behavior: behavior.value, path: trimmedSource, format: format.value }
emit('submit', {
name: trimmedName,
declaration,
file: file.value ? { content: file.value.content } : null,
})
}
/** 点在遮罩上就关闭。内容区的点击 target 是 form 里的元素,到不了 dialog 本身。 */
function onBackdrop(event: MouseEvent) {
if (event.target === dialog.value) emit('close')
}
</script>
<template>
<dialog ref="dialog" class="dlg" @close="emit('close')" @cancel.prevent="emit('close')">
<dialog
ref="dialog"
class="dlg"
@click="onBackdrop"
@close="emit('close')"
@cancel.prevent="emit('close')"
>
<form method="dialog" @submit.prevent="submit">
<header class="dlg-head">
<h2>新增规则组</h2>
@@ -107,7 +141,7 @@ function submit() {
</span>
</label>
<label class="row">
<label v-if="file === null" class="row">
<span class="k">来源</span>
<span class="v">
<input
@@ -146,6 +180,15 @@ function submit() {
<span v-if="mrsConflict" class="bad">mrs 格式只支持 domain 和 ipcidr 两种行为</span>
</span>
</label>
<div class="divide" role="presentation"><span>或从文件导入</span></div>
<DropFile v-if="file === null" accept=".yaml,.yml,.txt,.list" @load="onFile" />
<div v-else class="picked">
<IconFile class="picked-ico" aria-hidden="true" />
<span class="picked-name">{{ file.name }}</span>
<button type="button" class="x" title="移除文件" @click="file = null"><IconClose /></button>
</div>
</div>
<footer class="dlg-foot">
@@ -236,6 +279,49 @@ input {
background: var(--c-bg);
}
.divide {
display: flex;
align-items: center;
gap: var(--s-3);
color: var(--c-text-faint);
font-size: var(--t-xs);
user-select: none;
}
.divide::before,
.divide::after {
content: '';
flex: 1;
height: 1px;
background: var(--c-border);
}
.picked {
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-sunken);
font-size: var(--t-sm);
}
.picked-ico {
flex: none;
width: 16px;
height: 16px;
color: var(--c-text-soft);
}
.picked-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.note {
font-size: var(--t-xs);
color: var(--c-text-faint);
+69 -9
View File
@@ -39,6 +39,7 @@ import IconEdit from '~icons/ri/edit-line'
import IconDelete from '~icons/ri/delete-bin-line'
import IconRefresh from '~icons/ri/refresh-line'
import IconLoad from '~icons/ri/loader-4-line'
import IconClose from '~icons/ri/close-line'
const props = defineProps<{ data: ConfigObject; revision: string }>()
@@ -139,11 +140,21 @@ function openEditRule(index: number) {
async function submitRule(ruleText: string) {
const editing = ruleEditing.value
// 规则表从零开始时先垫上内网直连——保留地址不直连的话,第一条代理规则一生效,
// 访问路由器、局域网服务的流量就全进了代理。ini 导入不垫:配方自带直连规则
const seed = editing === null && rules.value.length === 0 ? missingLan.value : []
const next =
editing === null
? insertBeforeMatch(rules.value, [ruleText])
? insertBeforeMatch([...seed, ...rules.value], [ruleText])
: rules.value.map((rule, i) => (i === editing ? ruleText : rule))
await commit(withRules(next), editing === null ? '已新增规则' : '已保存')
await commit(
withRules(next),
editing === null
? seed.length > 0
? `已新增规则,并附带 ${seed.length} 条内网直连`
: '已新增规则'
: '已保存',
)
// 失败时不能关对话框——关了再打开就是空表单
if (!failure.value) ruleFormOpen.value = false
}
@@ -208,7 +219,24 @@ function formatTime(value: string | undefined): string {
const groupFormOpen = ref(false)
const importOpen = ref(false)
async function submitGroup(entry: { name: string; declaration: ConfigObject }) {
async function submitGroup(entry: {
name: string
declaration: ConfigObject
file: { content: string } | null
}) {
// 上传的清单先落盘再写声明。顺序不能反——声明先生效的话,内核重载会去读
// 一个还不存在的文件
if (entry.file && typeof entry.declaration.path === 'string') {
try {
await api.uploadProviderFile(entry.declaration.path, entry.file.content)
} catch (error) {
failure.value = {
message: error instanceof ApiError ? error.message : String(error),
hint: '清单没有写入,配置文件也没有改动。',
}
return
}
}
const next = clone()
setRuleProviders(next, { ...ruleProvidersOf(next), [entry.name]: entry.declaration })
await commit(next, `已新增规则组 ${entry.name}`)
@@ -308,8 +336,13 @@ async function updateNow(name: string) {
</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>
@@ -318,12 +351,14 @@ async function updateNow(name: string) {
<h2>分流规则</h2>
<div class="sec-acts">
<button
v-if="missingLan.length > 0 && rules.length > 0"
type="button"
class="btn"
:disabled="saving || missingLan.length === 0"
:disabled="saving"
:title="`补上缺的 ${missingLan.length} 条保留地址直连规则`"
@click="applyLanPreset"
>
内网直连
补全内网直连
</button>
<button type="button" class="btn" :disabled="saving" @click="openAddRule">
<IconAdd />
@@ -546,8 +581,8 @@ async function updateNow(name: string) {
.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);
@@ -556,6 +591,31 @@ async function updateNow(name: string) {
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);
+35 -4
View File
@@ -13,6 +13,7 @@ import { density, theme } from '../prefs'
import type { Density, Theme } from '../prefs'
import { kernel } from '../runtime'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import IconCloseTip from '~icons/ri/close-line'
/** 还原备份改的是配置文件,外壳需要重读整份配置(同路由页的 saved)。 */
const emit = defineEmits<{ saved: [] }>()
@@ -147,8 +148,13 @@ async function restoreConfirmed() {
<h2 class="title">备份还原</h2>
<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">
<IconCloseTip />
</button>
</div>
<p v-else-if="done" class="done">{{ done }}</p>
@@ -273,8 +279,8 @@ async function restoreConfirmed() {
.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);
@@ -283,6 +289,31 @@ async function restoreConfirmed() {
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);
+21 -8
View File
@@ -49,9 +49,16 @@ function delayClass(ms: number | null): string {
<span class="name">{{ subscription.name }}</span>
<div class="acts">
<button type="button" :disabled="!online || refreshing" @click="emit('refresh')">
<IconRefresh /> {{ refreshing ? '更新中' : '立刻更新' }}
<IconRefresh /> {{ refreshing ? '更新中' : '更新订阅' }}
</button>
<button
type="button"
:disabled="saving || subscription.type === 'file'"
:title="subscription.type === 'file' ? '文件导入的订阅没有可编辑的字段' : undefined"
@click="emit('edit')"
>
<IconEdit /> 编辑
</button>
<button type="button" :disabled="saving" @click="emit('edit')"><IconEdit /> 编辑</button>
<button type="button" class="danger" :disabled="saving" @click="emit('remove')">
<IconDelete /> 删除
</button>
@@ -59,12 +66,18 @@ function delayClass(ms: number | null): string {
</div>
<dl class="facts">
<dt>地址</dt>
<dd class="mono url">{{ subscription.url }}</dd>
<dt>更新间隔</dt>
<dd class="mono">
{{ subscription.interval !== null ? `${subscription.interval} 秒` : '未设置' }}
</dd>
<template v-if="subscription.type === 'file'">
<dt>文件</dt>
<dd class="mono url">{{ subscription.path ?? '—' }}</dd>
</template>
<template v-else>
<dt>地址</dt>
<dd class="mono url">{{ subscription.url }}</dd>
<dt>自动更新</dt>
<dd class="mono">
{{ subscription.interval !== null ? `每 ${subscription.interval} 秒` : '已关闭' }}
</dd>
</template>
<dt>健康检查</dt>
<dd class="mono">
<template v-if="subscription.healthCheck">
+193 -25
View File
@@ -5,12 +5,17 @@
* 原生 <dialog>,同 InboundForm。缓存路径不写进表单:它由名字自动生成,
* 让用户填只会填出和链式代理对不上的路径。订阅不能改名——名字被策略组的 `use:`
* 和生成的副本引用,改名要同步所有引用,所以编辑时名字只是静态展示,想换名就删了重建。
*
* 新增时地址之外还能从文件导入:选了文件就是 file 类型的订阅,没有地址也没有
* 自动更新(内核靠文件监视跟进变化),相关字段随之收起。
*/
import { computed, ref, watch } from 'vue'
import { DEFAULT_INTERVAL } from '@shared/providers.ts'
import type { Subscription } from '@shared/providers.ts'
import { GENERATED_PREFIX } from '@shared/inbound-groups.ts'
import DropFile from '../components/DropFile.vue'
import IconClose from '~icons/ri/close-line'
import IconFile from '~icons/ri/file-text-line'
const props = defineProps<{
open: boolean
@@ -23,14 +28,24 @@ const props = defineProps<{
const emit = defineEmits<{
close: []
/** 编辑时 name 是被编辑那个订阅的原名 */
submit: [draft: { name: string; url: string; interval: number }]
/** 编辑时 name 是被编辑那个订阅的原名。file 非 null 表示从文件导入 */
submit: [
draft: {
name: string
url: string
/** null = 不自动更新(配置里不写 interval) */
interval: number | null
file: { name: string; content: string } | null
},
]
}>()
const dialog = ref<HTMLDialogElement | null>(null)
const name = ref('')
const url = ref('')
const autoUpdate = ref(true)
const interval = ref(String(DEFAULT_INTERVAL))
const file = ref<{ name: string; content: string } | null>(null)
watch(
() => props.open,
@@ -38,7 +53,9 @@ watch(
if (open) {
name.value = props.editing?.name ?? ''
url.value = props.editing?.url ?? ''
autoUpdate.value = props.editing ? props.editing.interval !== null : true
interval.value = String(props.editing?.interval ?? DEFAULT_INTERVAL)
file.value = null
dialog.value?.showModal()
} else {
dialog.value?.close()
@@ -56,30 +73,46 @@ const nameError = computed(() => {
})
const intervalInvalid = computed(() => {
if (!autoUpdate.value) return false
const raw = interval.value.trim()
return raw === '' || !/^\d+$/.test(raw) || Number(raw) < 1
})
const canSubmit = computed(
() =>
url.value.trim() !== '' &&
!intervalInvalid.value &&
(props.editing !== null || (name.value.trim() !== '' && nameError.value === '')) &&
!props.saving,
)
const canSubmit = computed(() => {
if (props.saving) return false
if (props.editing === null && (name.value.trim() === '' || nameError.value !== '')) return false
if (file.value !== null) return true
return url.value.trim() !== '' && !intervalInvalid.value
})
function onFile(fileName: string, content: string) {
file.value = { name: fileName, content }
}
function submit() {
if (!canSubmit.value) return
emit('submit', {
name: props.editing?.name ?? name.value.trim(),
url: url.value.trim(),
interval: Number(interval.value.trim()),
interval: autoUpdate.value ? Number(interval.value.trim()) : null,
file: file.value,
})
}
/** 点在遮罩上就关闭。内容区的点击 target 是 form 里的元素,到不了 dialog 本身。 */
function onBackdrop(event: MouseEvent) {
if (event.target === dialog.value) emit('close')
}
</script>
<template>
<dialog ref="dialog" class="dlg" @close="emit('close')" @cancel.prevent="emit('close')">
<dialog
ref="dialog"
class="dlg"
@click="onBackdrop"
@close="emit('close')"
@cancel.prevent="emit('close')"
>
<form method="dialog" @submit.prevent="submit">
<header class="dlg-head">
<h2>{{ editing ? `编辑订阅 ${editing.name}` : '新增订阅' }}</h2>
@@ -95,20 +128,54 @@ function submit() {
</span>
</label>
<label class="row">
<span class="k">地址</span>
<span class="v">
<input v-model="url" class="mono" type="text" placeholder="https://…" autocomplete="off" />
</span>
</label>
<template v-if="file === null">
<label class="row">
<span class="k">地址</span>
<span class="v">
<input v-model="url" class="mono" type="text" placeholder="https://…" autocomplete="off" />
</span>
</label>
<label class="row">
<span class="k">更新间隔</span>
<span class="v">
<input v-model="interval" class="mono" type="text" inputmode="numeric" autocomplete="off" />
<span v-if="intervalInvalid" class="bad">正整数,单位秒</span>
</span>
</label>
<div class="row">
<span class="k">自动更新</span>
<span class="v">
<button
type="button"
role="switch"
:aria-checked="autoUpdate"
:class="['sw', autoUpdate && 'is-on']"
@click="autoUpdate = !autoUpdate"
>
<span class="knob" aria-hidden="true"></span>
</button>
</span>
</div>
<label v-if="autoUpdate" class="row">
<span class="k">自动更新间隔</span>
<span class="v">
<span class="with-unit">
<input v-model="interval" class="mono" type="text" inputmode="numeric" autocomplete="off" />
<span class="unit">秒</span>
</span>
<span v-if="intervalInvalid" class="bad">正整数,单位秒</span>
</span>
</label>
</template>
<template v-if="!editing">
<div class="divide" role="presentation"><span>或从文件导入</span></div>
<DropFile v-if="file === null" accept=".yaml,.yml,.txt" @load="onFile" />
<div v-else class="picked">
<IconFile class="picked-ico" aria-hidden="true" />
<span class="picked-name">{{ file.name }}</span>
<button type="button" class="x" title="移除文件" @click="file = null"><IconClose /></button>
</div>
<p v-if="file !== null" class="note">
导入后是 file 类型的订阅:内容就是这份文件,内核监视它的变化,没有自动更新。
</p>
</template>
</div>
<footer class="dlg-foot">
@@ -179,7 +246,7 @@ function submit() {
.row {
display: grid;
grid-template-columns: 5rem 1fr;
grid-template-columns: 6.5rem 1fr;
gap: var(--s-4);
align-items: baseline;
}
@@ -204,6 +271,107 @@ input {
background: var(--c-bg);
}
/* 输入框加单位后缀:数字和它的量纲摆在一起 */
.with-unit {
display: flex;
align-items: center;
gap: var(--s-2);
}
.unit {
flex: none;
color: var(--c-text-soft);
font-size: var(--t-sm);
}
/* 开关。align-self 抵消 row 的 baseline 对齐,胶囊不该被拉到文字基线上 */
.sw {
align-self: center;
position: relative;
width: 36px;
height: 20px;
padding: 0;
border: 1px solid var(--c-border-strong);
border-radius: 999px;
background: var(--c-surface-sunken);
cursor: pointer;
transition:
background-color 0.15s ease,
border-color 0.15s ease;
}
.sw .knob {
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--c-text-faint);
transition:
transform 0.15s ease,
background-color 0.15s ease;
}
.sw.is-on {
background: var(--c-accent);
border-color: transparent;
}
.sw.is-on .knob {
transform: translateX(16px);
background: #fff;
}
.divide {
display: flex;
align-items: center;
gap: var(--s-3);
color: var(--c-text-faint);
font-size: var(--t-xs);
user-select: none;
}
.divide::before,
.divide::after {
content: '';
flex: 1;
height: 1px;
background: var(--c-border);
}
.picked {
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-sunken);
font-size: var(--t-sm);
}
.picked-ico {
flex: none;
width: 16px;
height: 16px;
color: var(--c-text-soft);
}
.picked-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.note {
font-size: var(--t-xs);
color: var(--c-text-faint);
line-height: 1.5;
}
.bad {
font-size: var(--t-xs);
color: var(--c-danger);