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

文件导入走两步:内容先经新端点 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
+22
View File
@@ -9,7 +9,9 @@
* 透传只开放 GET。写操作(切换节点、关连接)属于运行时面板的功能,等做到那一步再单独开, * 透传只开放 GET。写操作(切换节点、关连接)属于运行时面板的功能,等做到那一步再单独开,
* 现在不留一个「什么都能转发」的口子。 * 现在不留一个「什么都能转发」的口子。
*/ */
import { resolve, sep } from 'node:path'
import listenerFields from '../shared/listener-fields.json' with { type: 'json' } import listenerFields from '../shared/listener-fields.json' with { type: 'json' }
import { isProviderFilePath } from '../shared/provider-files.ts'
import { applyConfig, listBackups, readConfig, restoreBackup } from './config-store.ts' import { applyConfig, listBackups, readConfig, restoreBackup } from './config-store.ts'
import { hostInfo, probeExit } from './host.ts' import { hostInfo, probeExit } from './host.ts'
import { kernelVersion, proxyGet, proxyWrite, restartKernel, selectProxy } from './kernel.ts' import { kernelVersion, proxyGet, proxyWrite, restartKernel, selectProxy } from './kernel.ts'
@@ -181,6 +183,26 @@ function makeRoute(env: Env, token: string) {
return result.ok ? json(result) : json(result, applyFailureStatus(result.stage)) return result.ok ? json(result) : json(result, applyFailureStatus(result.stage))
} }
// ── 清单文件 ────────────────────────────────────────
// 从界面上传的订阅/规则清单,写进内核工作目录的 providers/ 下。
// 路径白名单校验 + 解析后确认没出工作目录,两道都过才落盘
if (path === '/api/provider-files' && req.method === 'POST') {
const body = (await req.json()) as { path?: string; content?: string }
if (typeof body.path !== 'string' || typeof body.content !== 'string') {
return json({ message: '请求缺少 path 或 content 字段' }, 400)
}
if (!isProviderFilePath(body.path)) {
return json({ message: `不允许写入该路径:${body.path}` }, 400)
}
const home = resolve(env.mihomoHome)
const target = resolve(home, body.path)
if (!target.startsWith(home + sep)) {
return json({ message: `不允许写入该路径:${body.path}` }, 400)
}
await Bun.write(target, body.content)
return json({ ok: true })
}
// ── 主机 ──────────────────────────────────────────── // ── 主机 ────────────────────────────────────────────
// 服务端和内核在同一台机器上,所以主机指标由 Patchbay 自己代答 // 服务端和内核在同一台机器上,所以主机指标由 Patchbay 自己代答
if (path === '/api/host' && req.method === 'GET') { if (path === '/api/host' && req.method === 'GET') {
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, test } from 'bun:test'
import { isProviderFilePath } from './provider-files.ts'
describe('isProviderFilePath', () => {
test('订阅缓存和规则清单的标准形状放行', () => {
expect(isProviderFilePath('./providers/subs.yaml')).toBe(true)
expect(isProviderFilePath('./providers/rules/ads.txt')).toBe(true)
expect(isProviderFilePath('./providers/rules/直连清单.yaml')).toBe(true)
})
test('目录逃逸和绝对路径全拦', () => {
expect(isProviderFilePath('./providers/../config.yaml')).toBe(false)
expect(isProviderFilePath('providers/subs.yaml')).toBe(false)
expect(isProviderFilePath('/etc/mihomo/config.yaml')).toBe(false)
expect(isProviderFilePath('./providers/rules/../../x.yaml')).toBe(false)
expect(isProviderFilePath('./providers/a/b.yaml')).toBe(false)
})
test('mrs 是二进制格式,不走文本上传', () => {
expect(isProviderFilePath('./providers/rules/geo.mrs')).toBe(false)
})
})
+11
View File
@@ -0,0 +1,11 @@
/**
* 上传的线路/规则清单文件允许写到哪。
*
* 服务端的写文件端点只认这两种形状——它们正是 providerCachePath()(订阅缓存)
* 和 ruleProviderPath()(规则组清单)生成的路径。白名单式校验:文件名只许
* 字母数字下划线连字符,杜绝 ../、绝对路径和多余的目录层级。
* mrs 不在扩展名里:那是二进制格式,文本上传通道传不了。
*/
export function isProviderFilePath(path: string): boolean {
return /^\.\/providers\/(rules\/)?[\p{L}\p{N}_-]+\.(yaml|txt)$/u.test(path)
}
+22
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'bun:test' import { describe, expect, it } from 'bun:test'
import { import {
DEFAULT_INTERVAL, DEFAULT_INTERVAL,
addFileSubscription,
addSubscription, addSubscription,
listSubscriptions, listSubscriptions,
providerNames, providerNames,
@@ -40,6 +41,8 @@ describe('读出订阅列表', () => {
expect(listSubscriptions(base())).toEqual([ expect(listSubscriptions(base())).toEqual([
{ {
name: 'subs', name: 'subs',
type: 'http',
path: './providers/subs.yaml',
url: URL, url: URL,
interval: 86400, interval: 86400,
healthCheck: { url: 'https://www.gstatic.com/generate_204', interval: 300 }, healthCheck: { url: 'https://www.gstatic.com/generate_204', interval: 300 },
@@ -92,6 +95,19 @@ describe('新增订阅', () => {
(data['proxy-providers'] as ConfigObject).subs, (data['proxy-providers'] as ConfigObject).subs,
) )
}) })
it('关掉自动更新时不写 interval 键', () => {
const next = addSubscription({}, 'subs2', URL, null)
const entry = (next['proxy-providers'] as ConfigObject).subs2 as ConfigObject
expect('interval' in entry).toBe(false)
})
it('从文件导入的订阅是 file 类型,没有地址和间隔', () => {
const next = addFileSubscription({}, 'subs2')
expect(next['proxy-providers']).toEqual({
subs2: { type: 'file', path: './providers/subs2.yaml' },
})
})
}) })
describe('编辑订阅', () => { describe('编辑订阅', () => {
@@ -112,6 +128,12 @@ describe('编辑订阅', () => {
const data = base() const data = base()
expect(updateSubscription(data, 'ghost', URL, 1)).toBe(data) expect(updateSubscription(data, 'ghost', URL, 1)).toBe(data)
}) })
it('关掉自动更新时把已有的 interval 键去掉', () => {
const next = updateSubscription(base(), 'subs', URL, null)
const entry = (next['proxy-providers'] as ConfigObject).subs as ConfigObject
expect('interval' in entry).toBe(false)
})
}) })
describe('删除订阅', () => { describe('删除订阅', () => {
+43 -5
View File
@@ -18,8 +18,16 @@ export const DEFAULT_INTERVAL = 86400
/** 一个用户订阅在界面上的样子。只读配置文件的 `proxy-providers` 段(决策记录 0004)。 */ /** 一个用户订阅在界面上的样子。只读配置文件的 `proxy-providers` 段(决策记录 0004)。 */
export interface Subscription { export interface Subscription {
name: string name: string
/** http 定时从地址拉取;file 读本机文件,内核靠文件监视跟进变化 */
type: 'http' | 'file'
url: string url: string
/** 更新间隔,秒。null 表示配置里没写 */ /** 线路文件的路径(http 类型是缓存,file 类型是数据本体) */
path: string | null
/**
* 更新间隔,秒。null 表示配置里没写——内核只在 interval > 0 时才起定时
* 拉取循环(内核源码 component/resource/fetcher.go:226-228),所以
* 「没写」就是「不自动更新」,界面上它是个开关而不是必填数字。
*/
interval: number | null interval: number | null
/** 健康检查。没写或 enable 不是 true 都算未开启 */ /** 健康检查。没写或 enable 不是 true 都算未开启 */
healthCheck: { url: string | null; interval: number | null } | null healthCheck: { url: string | null; interval: number | null } | null
@@ -49,6 +57,8 @@ export function listSubscriptions(data: ConfigObject): Subscription[] {
const hc = isPlainObject(entry['health-check']) ? entry['health-check'] : null const hc = isPlainObject(entry['health-check']) ? entry['health-check'] : null
result.push({ result.push({
name, name,
type: entry.type === 'file' ? 'file' : 'http',
path: typeof entry.path === 'string' ? entry.path : null,
url: typeof entry.url === 'string' ? entry.url : '', url: typeof entry.url === 'string' ? entry.url : '',
interval: typeof entry.interval === 'number' ? entry.interval : null, interval: typeof entry.interval === 'number' ? entry.interval : null,
healthCheck: healthCheck:
@@ -77,12 +87,37 @@ export function providerNames(data: ConfigObject): string[] {
* `path` 自动给出确定的缓存文件路径,不让用户填:不写的话内核按 url 哈希自己生成一个, * `path` 自动给出确定的缓存文件路径,不让用户填:不写的话内核按 url 哈希自己生成一个,
* 那个路径 Patchbay 算不出来,链式代理的副本就没法靠「读同一个缓存文件」认来源 * 那个路径 Patchbay 算不出来,链式代理的副本就没法靠「读同一个缓存文件」认来源
* (见 inbound-groups.ts 的 applySetup)。 * (见 inbound-groups.ts 的 applySetup)。
*
* `interval` 为 null 时不写这个键,内核就不自动更新(见 Subscription.interval 的注)。
*/ */
export function addSubscription(data: ConfigObject, name: string, url: string): ConfigObject { export function addSubscription(
data: ConfigObject,
name: string,
url: string,
interval: number | null = DEFAULT_INTERVAL,
): ConfigObject {
const next = { ...data } const next = { ...data }
next['proxy-providers'] = { next['proxy-providers'] = {
...providersOf(data), ...providersOf(data),
[name]: { type: 'http', url, path: providerCachePath(name), interval: DEFAULT_INTERVAL }, [name]: {
type: 'http',
url,
path: providerCachePath(name),
...(interval !== null ? { interval } : {}),
},
}
return next
}
/**
* 新增一个来自本机文件的订阅。数据就在 path 指的文件里(内容由上传流程先写好),
* 内核靠文件监视跟进变化,没有地址也没有更新间隔。
*/
export function addFileSubscription(data: ConfigObject, name: string): ConfigObject {
const next = { ...data }
next['proxy-providers'] = {
...providersOf(data),
[name]: { type: 'file', path: providerCachePath(name) },
} }
return next return next
} }
@@ -92,14 +127,17 @@ export function updateSubscription(
data: ConfigObject, data: ConfigObject,
name: string, name: string,
url: string, url: string,
interval: number, interval: number | null,
): ConfigObject { ): ConfigObject {
const entry = providersOf(data)[name] const entry = providersOf(data)[name]
if (!isPlainObject(entry)) return data if (!isPlainObject(entry)) return data
const updated: ConfigObject = { ...entry, url }
if (interval === null) delete updated.interval
else updated.interval = interval
const next = { ...data } const next = { ...data }
next['proxy-providers'] = { next['proxy-providers'] = {
...providersOf(data), ...providersOf(data),
[name]: { ...entry, url, interval }, [name]: updated,
} }
return next return next
} }
+7
View File
@@ -190,6 +190,13 @@ export const api = {
return res.body return res.body
}, },
/**
* 把上传的线路/规则清单写进内核工作目录的 providers/ 下。
* path 必须是 providerCachePath / ruleProviderPath 生成的形状,服务端有白名单校验。
*/
uploadProviderFile: (path: string, content: string) =>
request<{ ok: true }>('POST', '/api/provider-files', { path, content }),
/** 入站类型的字段表,从内核源码提取,用来驱动表单。 */ /** 入站类型的字段表,从内核源码提取,用来驱动表单。 */
listenerFields: () => request<ListenerFields>('GET', '/api/meta/listener-fields'), listenerFields: () => request<ListenerFields>('GET', '/api/meta/listener-fields'),
+87
View File
@@ -0,0 +1,87 @@
<script setup lang="ts">
/**
* 拖拽或点选一个文件,读出文本内容交给调用方。
*
* 无状态:只发 load 事件,选没选、选了什么由调用方自己记——三个用它的表单
* 对「选中之后长什么样」要求各不相同(订阅显示文件名待提交,ini 导入直接
* 灌进文本框),状态放这里反而要为每种用法开口子。
*/
import { ref } from 'vue'
import IconUpload from '~icons/ri/upload-cloud-2-line'
const props = defineProps<{
/** 原生 accept 串,如 ".yaml,.yml,.txt" */
accept?: string
}>()
const emit = defineEmits<{ load: [name: string, content: string] }>()
const input = ref<HTMLInputElement | null>(null)
const dragging = ref(false)
async function take(file: File | undefined) {
if (!file) return
emit('load', file.name, await file.text())
}
function onDrop(event: DragEvent) {
dragging.value = false
void take(event.dataTransfer?.files[0])
}
function onPick(event: Event) {
const el = event.target as HTMLInputElement
void take(el.files?.[0])
// 同一个文件选第二次也要触发 change
el.value = ''
}
</script>
<template>
<button
type="button"
:class="['zone', dragging && 'is-over']"
@click="input?.click()"
@dragover.prevent="dragging = true"
@dragleave="dragging = false"
@drop.prevent="onDrop"
>
<IconUpload class="ico" aria-hidden="true" />
<span>拖拽文件到这里,或点击选择</span>
<input ref="input" type="file" :accept="props.accept" hidden @change="onPick" />
</button>
</template>
<style scoped>
.zone {
display: flex;
align-items: center;
justify-content: center;
gap: var(--s-2);
width: 100%;
padding: var(--s-4);
border: 1px dashed var(--c-border-strong);
border-radius: var(--r-sm);
background: none;
color: var(--c-text-soft);
font-size: var(--t-sm);
cursor: pointer;
user-select: none;
transition:
border-color 0.15s ease,
color 0.15s ease,
background-color 0.15s ease;
}
.zone:hover,
.zone.is-over {
border-color: var(--c-accent);
color: var(--c-accent-text);
background: var(--c-accent-soft);
}
.ico {
width: 18px;
height: 18px;
}
</style>
+99 -4
View File
@@ -10,6 +10,7 @@ import { computed, onMounted, ref, watch } from 'vue'
import { collectInbounds } from '@shared/inbounds.ts' import { collectInbounds } from '@shared/inbounds.ts'
import { pruneGenerated, syncGenerated } from '@shared/inbound-groups.ts' import { pruneGenerated, syncGenerated } from '@shared/inbound-groups.ts'
import { import {
addFileSubscription,
addSubscription, addSubscription,
listSubscriptions, listSubscriptions,
providerNames, providerNames,
@@ -18,6 +19,7 @@ import {
updateSubscription, updateSubscription,
} from '@shared/providers.ts' } from '@shared/providers.ts'
import type { Subscription } 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 type { ConfigObject } from '../../vendor/config-editor/index.ts'
import { ApiError, api } from '../api' import { ApiError, api } from '../api'
import type { ProviderInfo } from '../api' import type { ProviderInfo } from '../api'
@@ -27,6 +29,8 @@ import ProviderList from '../components/ProviderList.vue'
import SubscriptionDetail from './SubscriptionDetail.vue' import SubscriptionDetail from './SubscriptionDetail.vue'
import SubscriptionForm from './SubscriptionForm.vue' import SubscriptionForm from './SubscriptionForm.vue'
import IconAdd from '~icons/ri/add-line' 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 }>() const props = defineProps<{ data: ConfigObject; revision: string }>()
@@ -142,11 +146,33 @@ function openEdit() {
formOpen.value = true 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 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 const next = editing
? updateSubscription(clone(), editing.name, draft.url, draft.interval) ? 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}`) await commit(next, editing ? '已保存' : `已新增 ${draft.name}`)
// 失败时不能关对话框——关了再打开就是空表单,填的名称、地址、间隔全没了 // 失败时不能关对话框——关了再打开就是空表单,填的名称、地址、间隔全没了
if (failure.value) return if (failure.value) return
@@ -194,6 +220,29 @@ async function refresh() {
refreshing.value = null 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> </script>
<template> <template>
@@ -210,16 +259,32 @@ async function refresh() {
</div> </div>
</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"> <button class="add" type="button" :disabled="saving" @click="openAdd">
<IconAdd /> <IconAdd />
<span>新增</span> <span>新增</span>
</button> </button>
</div>
</header> </header>
<div v-if="failure" class="failure"> <div v-if="failure" class="failure">
<div class="failure-text">
<strong>{{ failure.message }}</strong> <strong>{{ failure.message }}</strong>
<span v-if="failure.hint">{{ failure.hint }}</span> <span v-if="failure.hint">{{ failure.hint }}</span>
</div> </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="done" class="done">{{ done }}</p>
<div class="split"> <div class="split">
@@ -310,10 +375,15 @@ async function refresh() {
cursor: not-allowed; cursor: not-allowed;
} }
.head-acts {
display: flex;
gap: var(--s-2);
}
.failure { .failure {
display: flex; display: flex;
flex-direction: column; align-items: flex-start;
gap: var(--s-1); gap: var(--s-3);
border: 1px solid color-mix(in srgb, var(--c-danger) 35%, var(--c-border)); border: 1px solid color-mix(in srgb, var(--c-danger) 35%, var(--c-border));
border-radius: var(--r-md); border-radius: var(--r-md);
background: var(--c-danger-soft); background: var(--c-danger-soft);
@@ -322,6 +392,31 @@ async function refresh() {
font-size: var(--t-sm); 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 { .done {
color: var(--c-ok); color: var(--c-ok);
font-size: var(--t-sm); font-size: var(--t-sm);
+34 -1
View File
@@ -153,7 +153,15 @@ async function closeAllConfirmed() {
</header> </header>
<div v-if="shownFailure" class="failure"> <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> </div>
<p v-if="!kernel.online" class="note">内核未连接,看不到当前有哪些连接。</p> <p v-if="!kernel.online" class="note">内核未连接,看不到当前有哪些连接。</p>
@@ -305,6 +313,9 @@ async function closeAllConfirmed() {
} }
.failure { .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: 1px solid color-mix(in srgb, var(--c-danger) 35%, var(--c-border));
border-radius: var(--r-md); border-radius: var(--r-md);
background: var(--c-danger-soft); background: var(--c-danger-soft);
@@ -313,6 +324,28 @@ async function closeAllConfirmed() {
font-size: var(--t-sm); 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 { .note {
color: var(--c-text-faint); color: var(--c-text-faint);
font-size: var(--t-sm); 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() { function submit() {
if (!canSubmit.value) return if (!canSubmit.value) return
const entry: ConfigObject = { const entry: ConfigObject = {
@@ -127,7 +132,13 @@ function submit() {
</script> </script>
<template> <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"> <form method="dialog" @submit.prevent="submit">
<header class="dlg-head"> <header class="dlg-head">
<h2>{{ editing ? '编辑路由' : '新增路由' }}</h2> <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 { parseIniRulesets, planIniImport } from '@shared/rules.ts'
import type { IniImportPlan } from '@shared/rules.ts' import type { IniImportPlan } from '@shared/rules.ts'
import SelectBox from '../components/SelectBox.vue' import SelectBox from '../components/SelectBox.vue'
import DropFile from '../components/DropFile.vue'
import IconClose from '~icons/ri/close-line' import IconClose from '~icons/ri/close-line'
const props = defineProps<{ const props = defineProps<{
@@ -37,7 +38,6 @@ const KIND_LABELS: Record<string, string> = {
} }
const dialog = ref<HTMLDialogElement | null>(null) const dialog = ref<HTMLDialogElement | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const text = ref('') const text = ref('')
const mapping = ref<Record<string, string>>({}) const mapping = ref<Record<string, string>>({})
@@ -85,21 +85,25 @@ watch(targets, (list) => {
mapping.value = next 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() { function submit() {
if (!canSubmit.value) return if (!canSubmit.value) return
emit('submit', plan.value) emit('submit', plan.value)
} }
/** 点在遮罩上就关闭。内容区的点击 target 是 form 里的元素,到不了 dialog 本身。 */
function onBackdrop(event: MouseEvent) {
if (event.target === dialog.value) emit('close')
}
</script> </script>
<template> <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"> <form method="dialog" @submit.prevent="submit">
<header class="dlg-head"> <header class="dlg-head">
<h2>导入 ini</h2> <h2>导入 ini</h2>
@@ -110,21 +114,14 @@ function submit() {
<label class="row"> <label class="row">
<span class="k">内容</span> <span class="k">内容</span>
<span class="v"> <span class="v">
<DropFile accept=".ini,.conf,.txt" @load="(_, content) => (text = content)" />
<textarea <textarea
v-model="text" v-model="text"
class="mono" class="mono"
rows="8" rows="8"
placeholder="粘贴 ini 文本,或从文件读取" placeholder="或把 ini 文本粘贴到这里"
spellcheck="false" spellcheck="false"
></textarea> ></textarea>
<input
ref="fileInput"
type="file"
accept=".ini,.conf,.txt"
hidden
@change="pickFile"
/>
<button type="button" class="btn file" @click="fileInput?.click()">从文件读取</button>
</span> </span>
</label> </label>
@@ -277,10 +274,6 @@ textarea {
font-size: var(--t-xs); font-size: var(--t-xs);
} }
.file {
align-self: flex-start;
}
.btn { .btn {
padding: var(--s-2) var(--s-4); padding: var(--s-2) var(--s-4);
border: 1px solid var(--c-border-strong); 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 IconPause from '~icons/ri/pause-line'
import IconPlay from '~icons/ri/play-line' import IconPlay from '~icons/ri/play-line'
import IconClear from '~icons/ri/delete-bin-line' import IconClear from '~icons/ri/delete-bin-line'
import IconClose from '~icons/ri/close-line'
const query = ref('') const query = ref('')
@@ -76,7 +77,10 @@ const visible = computed(() => {
</header> </header>
<div v-if="failure" class="failure"> <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>
<div ref="listEl" class="log" @scroll="onScroll"> <div ref="listEl" class="log" @scroll="onScroll">
@@ -152,6 +156,9 @@ const visible = computed(() => {
} }
.failure { .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: 1px solid color-mix(in srgb, var(--c-danger) 35%, var(--c-border));
border-radius: var(--r-md); border-radius: var(--r-md);
background: var(--c-danger-soft); background: var(--c-danger-soft);
@@ -160,6 +167,28 @@ const visible = computed(() => {
font-size: var(--t-sm); 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 { .log {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
+33 -2
View File
@@ -33,6 +33,7 @@ import InboundList from '../components/InboundList.vue'
import InboundDetail from './InboundDetail.vue' import InboundDetail from './InboundDetail.vue'
import InboundForm from './InboundForm.vue' import InboundForm from './InboundForm.vue'
import IconAdd from '~icons/ri/add-line' import IconAdd from '~icons/ri/add-line'
import IconClose from '~icons/ri/close-line'
const props = defineProps<{ const props = defineProps<{
data: ConfigObject data: ConfigObject
@@ -282,9 +283,14 @@ async function removeConfirmed() {
</header> </header>
<div v-if="failure" class="failure"> <div v-if="failure" class="failure">
<div class="failure-text">
<strong>{{ failure.message }}</strong> <strong>{{ failure.message }}</strong>
<span v-if="failure.hint">{{ failure.hint }}</span> <span v-if="failure.hint">{{ failure.hint }}</span>
</div> </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="done" class="done">{{ done }}</p>
<p v-else-if="notice" class="notice">{{ notice }}</p> <p v-else-if="notice" class="notice">{{ notice }}</p>
@@ -385,8 +391,8 @@ async function removeConfirmed() {
.failure { .failure {
display: flex; display: flex;
flex-direction: column; align-items: flex-start;
gap: var(--s-1); gap: var(--s-3);
border: 1px solid color-mix(in srgb, var(--c-danger) 35%, var(--c-border)); border: 1px solid color-mix(in srgb, var(--c-danger) 35%, var(--c-border));
border-radius: var(--r-md); border-radius: var(--r-md);
background: var(--c-danger-soft); background: var(--c-danger-soft);
@@ -395,6 +401,31 @@ async function removeConfirmed() {
font-size: var(--t-sm); 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 { .done {
color: var(--c-ok); color: var(--c-ok);
font-size: var(--t-sm); font-size: var(--t-sm);
+12 -1
View File
@@ -148,10 +148,21 @@ function submit() {
if (!canSubmit.value) return if (!canSubmit.value) return
emit('submit', rawMode.value ? raw.value.trim() : structured()) emit('submit', rawMode.value ? raw.value.trim() : structured())
} }
/** 点在遮罩上就关闭。内容区的点击 target 是 form 里的元素,到不了 dialog 本身。 */
function onBackdrop(event: MouseEvent) {
if (event.target === dialog.value) emit('close')
}
</script> </script>
<template> <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"> <form method="dialog" @submit.prevent="submit">
<header class="dlg-head"> <header class="dlg-head">
<h2>{{ editing ? '编辑规则' : '新增规则' }}</h2> <h2>{{ editing ? '编辑规则' : '新增规则' }}</h2>
+92 -6
View File
@@ -10,7 +10,9 @@ import { computed, ref, watch } from 'vue'
import { inferRuleFormat, ruleProviderPath } from '@shared/rules.ts' import { inferRuleFormat, ruleProviderPath } from '@shared/rules.ts'
import type { ConfigObject } from '../../vendor/config-editor/index.ts' import type { ConfigObject } from '../../vendor/config-editor/index.ts'
import SelectBox from '../components/SelectBox.vue' import SelectBox from '../components/SelectBox.vue'
import DropFile from '../components/DropFile.vue'
import IconClose from '~icons/ri/close-line' import IconClose from '~icons/ri/close-line'
import IconFile from '~icons/ri/file-text-line'
const props = defineProps<{ const props = defineProps<{
open: boolean open: boolean
@@ -21,7 +23,8 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
close: [] 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) const dialog = ref<HTMLDialogElement | null>(null)
@@ -36,12 +39,20 @@ const formatTouched = ref(false)
const BEHAVIOR_OPTIONS = [{ value: 'classical' }, { value: 'domain' }, { value: 'ipcidr' }] const BEHAVIOR_OPTIONS = [{ value: 'classical' }, { value: 'domain' }, { value: 'ipcidr' }]
const FORMAT_OPTIONS = [{ value: 'yaml' }, { value: 'text' }, { value: 'mrs' }] 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())) const isUrl = computed(() => /^https?:\/\//i.test(source.value.trim()))
watch(source, (value) => { watch(source, (value) => {
if (!formatTouched.value) format.value = inferRuleFormat(value.trim()) 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())) const nameTaken = computed(() => props.takenNames.includes(name.value.trim()))
/** 内核没有实现 classical 行为的 mrs 格式(rules/provider/ 下只有 domain 和 ipcidr 两种策略) */ /** 内核没有实现 classical 行为的 mrs 格式(rules/provider/ 下只有 domain 和 ipcidr 两种策略) */
@@ -50,7 +61,7 @@ const mrsConflict = computed(() => format.value === 'mrs' && behavior.value ===
const canSubmit = computed( const canSubmit = computed(
() => () =>
name.value.trim() !== '' && name.value.trim() !== '' &&
source.value.trim() !== '' && (file.value !== null || source.value.trim() !== '') &&
!nameTaken.value && !nameTaken.value &&
!mrsConflict.value && !mrsConflict.value &&
!props.saving, !props.saving,
@@ -65,6 +76,7 @@ watch(
behavior.value = 'classical' behavior.value = 'classical'
format.value = 'yaml' format.value = 'yaml'
formatTouched.value = false formatTouched.value = false
file.value = null
dialog.value?.showModal() dialog.value?.showModal()
} else { } else {
dialog.value?.close() dialog.value?.close()
@@ -76,7 +88,14 @@ function submit() {
if (!canSubmit.value) return if (!canSubmit.value) return
const trimmedName = name.value.trim() const trimmedName = name.value.trim()
const trimmedSource = source.value.trim() const trimmedSource = source.value.trim()
const declaration: ConfigObject = isUrl.value const declaration: ConfigObject = file.value
? {
type: 'file',
behavior: behavior.value,
path: ruleProviderPath(trimmedName, format.value),
format: format.value,
}
: isUrl.value
? { ? {
type: 'http', type: 'http',
behavior: behavior.value, behavior: behavior.value,
@@ -86,12 +105,27 @@ function submit() {
interval: 86400, interval: 86400,
} }
: { type: 'file', behavior: behavior.value, path: trimmedSource, format: format.value } : { type: 'file', behavior: behavior.value, path: trimmedSource, format: format.value }
emit('submit', { name: trimmedName, declaration }) 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> </script>
<template> <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"> <form method="dialog" @submit.prevent="submit">
<header class="dlg-head"> <header class="dlg-head">
<h2>新增规则组</h2> <h2>新增规则组</h2>
@@ -107,7 +141,7 @@ function submit() {
</span> </span>
</label> </label>
<label class="row"> <label v-if="file === null" class="row">
<span class="k">来源</span> <span class="k">来源</span>
<span class="v"> <span class="v">
<input <input
@@ -146,6 +180,15 @@ function submit() {
<span v-if="mrsConflict" class="bad">mrs 格式只支持 domain 和 ipcidr 两种行为</span> <span v-if="mrsConflict" class="bad">mrs 格式只支持 domain 和 ipcidr 两种行为</span>
</span> </span>
</label> </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> </div>
<footer class="dlg-foot"> <footer class="dlg-foot">
@@ -236,6 +279,49 @@ input {
background: var(--c-bg); 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 { .note {
font-size: var(--t-xs); font-size: var(--t-xs);
color: var(--c-text-faint); color: var(--c-text-faint);
+67 -7
View File
@@ -39,6 +39,7 @@ import IconEdit from '~icons/ri/edit-line'
import IconDelete from '~icons/ri/delete-bin-line' import IconDelete from '~icons/ri/delete-bin-line'
import IconRefresh from '~icons/ri/refresh-line' import IconRefresh from '~icons/ri/refresh-line'
import IconLoad from '~icons/ri/loader-4-line' import IconLoad from '~icons/ri/loader-4-line'
import IconClose from '~icons/ri/close-line'
const props = defineProps<{ data: ConfigObject; revision: string }>() const props = defineProps<{ data: ConfigObject; revision: string }>()
@@ -139,11 +140,21 @@ function openEditRule(index: number) {
async function submitRule(ruleText: string) { async function submitRule(ruleText: string) {
const editing = ruleEditing.value const editing = ruleEditing.value
// 规则表从零开始时先垫上内网直连——保留地址不直连的话,第一条代理规则一生效,
// 访问路由器、局域网服务的流量就全进了代理。ini 导入不垫:配方自带直连规则
const seed = editing === null && rules.value.length === 0 ? missingLan.value : []
const next = const next =
editing === null editing === null
? insertBeforeMatch(rules.value, [ruleText]) ? insertBeforeMatch([...seed, ...rules.value], [ruleText])
: rules.value.map((rule, i) => (i === editing ? ruleText : rule)) : 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 if (!failure.value) ruleFormOpen.value = false
} }
@@ -208,7 +219,24 @@ function formatTime(value: string | undefined): string {
const groupFormOpen = ref(false) const groupFormOpen = ref(false)
const importOpen = 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() const next = clone()
setRuleProviders(next, { ...ruleProvidersOf(next), [entry.name]: entry.declaration }) setRuleProviders(next, { ...ruleProvidersOf(next), [entry.name]: entry.declaration })
await commit(next, `已新增规则组 ${entry.name}`) await commit(next, `已新增规则组 ${entry.name}`)
@@ -308,9 +336,14 @@ async function updateNow(name: string) {
</header> </header>
<div v-if="failure" class="failure"> <div v-if="failure" class="failure">
<div class="failure-text">
<strong>{{ failure.message }}</strong> <strong>{{ failure.message }}</strong>
<span v-if="failure.hint">{{ failure.hint }}</span> <span v-if="failure.hint">{{ failure.hint }}</span>
</div> </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="done" class="done">{{ done }}</p>
<section class="sec"> <section class="sec">
@@ -318,12 +351,14 @@ async function updateNow(name: string) {
<h2>分流规则</h2> <h2>分流规则</h2>
<div class="sec-acts"> <div class="sec-acts">
<button <button
v-if="missingLan.length > 0 && rules.length > 0"
type="button" type="button"
class="btn" class="btn"
:disabled="saving || missingLan.length === 0" :disabled="saving"
:title="`补上缺的 ${missingLan.length} 条保留地址直连规则`"
@click="applyLanPreset" @click="applyLanPreset"
> >
内网直连 补全内网直连
</button> </button>
<button type="button" class="btn" :disabled="saving" @click="openAddRule"> <button type="button" class="btn" :disabled="saving" @click="openAddRule">
<IconAdd /> <IconAdd />
@@ -546,8 +581,8 @@ async function updateNow(name: string) {
.failure { .failure {
display: flex; display: flex;
flex-direction: column; align-items: flex-start;
gap: var(--s-1); gap: var(--s-3);
border: 1px solid color-mix(in srgb, var(--c-danger) 35%, var(--c-border)); border: 1px solid color-mix(in srgb, var(--c-danger) 35%, var(--c-border));
border-radius: var(--r-md); border-radius: var(--r-md);
background: var(--c-danger-soft); background: var(--c-danger-soft);
@@ -556,6 +591,31 @@ async function updateNow(name: string) {
font-size: var(--t-sm); 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 { .done {
color: var(--c-ok); color: var(--c-ok);
font-size: var(--t-sm); font-size: var(--t-sm);
+33 -2
View File
@@ -13,6 +13,7 @@ import { density, theme } from '../prefs'
import type { Density, Theme } from '../prefs' import type { Density, Theme } from '../prefs'
import { kernel } from '../runtime' import { kernel } from '../runtime'
import ConfirmDialog from '../components/ConfirmDialog.vue' import ConfirmDialog from '../components/ConfirmDialog.vue'
import IconCloseTip from '~icons/ri/close-line'
/** 还原备份改的是配置文件,外壳需要重读整份配置(同路由页的 saved)。 */ /** 还原备份改的是配置文件,外壳需要重读整份配置(同路由页的 saved)。 */
const emit = defineEmits<{ saved: [] }>() const emit = defineEmits<{ saved: [] }>()
@@ -147,9 +148,14 @@ async function restoreConfirmed() {
<h2 class="title">备份还原</h2> <h2 class="title">备份还原</h2>
<div v-if="failure" class="failure"> <div v-if="failure" class="failure">
<div class="failure-text">
<strong>{{ failure.message }}</strong> <strong>{{ failure.message }}</strong>
<span v-if="failure.hint">{{ failure.hint }}</span> <span v-if="failure.hint">{{ failure.hint }}</span>
</div> </div>
<button type="button" class="failure-x" title="关闭提示" @click="failure = null">
<IconCloseTip />
</button>
</div>
<p v-else-if="done" class="done">{{ done }}</p> <p v-else-if="done" class="done">{{ done }}</p>
<p v-if="backups === null" class="faint">载入中</p> <p v-if="backups === null" class="faint">载入中</p>
@@ -273,8 +279,8 @@ async function restoreConfirmed() {
.failure { .failure {
display: flex; display: flex;
flex-direction: column; align-items: flex-start;
gap: var(--s-1); gap: var(--s-3);
border: 1px solid color-mix(in srgb, var(--c-danger) 35%, var(--c-border)); border: 1px solid color-mix(in srgb, var(--c-danger) 35%, var(--c-border));
border-radius: var(--r-md); border-radius: var(--r-md);
background: var(--c-danger-soft); background: var(--c-danger-soft);
@@ -283,6 +289,31 @@ async function restoreConfirmed() {
font-size: var(--t-sm); 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 { .done {
color: var(--c-ok); color: var(--c-ok);
font-size: var(--t-sm); font-size: var(--t-sm);
+17 -4
View File
@@ -49,9 +49,16 @@ function delayClass(ms: number | null): string {
<span class="name">{{ subscription.name }}</span> <span class="name">{{ subscription.name }}</span>
<div class="acts"> <div class="acts">
<button type="button" :disabled="!online || refreshing" @click="emit('refresh')"> <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>
<button type="button" :disabled="saving" @click="emit('edit')"><IconEdit /> 编辑</button>
<button type="button" class="danger" :disabled="saving" @click="emit('remove')"> <button type="button" class="danger" :disabled="saving" @click="emit('remove')">
<IconDelete /> 删除 <IconDelete /> 删除
</button> </button>
@@ -59,12 +66,18 @@ function delayClass(ms: number | null): string {
</div> </div>
<dl class="facts"> <dl class="facts">
<template v-if="subscription.type === 'file'">
<dt>文件</dt>
<dd class="mono url">{{ subscription.path ?? '—' }}</dd>
</template>
<template v-else>
<dt>地址</dt> <dt>地址</dt>
<dd class="mono url">{{ subscription.url }}</dd> <dd class="mono url">{{ subscription.url }}</dd>
<dt>更新间隔</dt> <dt>自动更新</dt>
<dd class="mono"> <dd class="mono">
{{ subscription.interval !== null ? `${subscription.interval} 秒` : '未设置' }} {{ subscription.interval !== null ? `每 ${subscription.interval} 秒` : '已关闭' }}
</dd> </dd>
</template>
<dt>健康检查</dt> <dt>健康检查</dt>
<dd class="mono"> <dd class="mono">
<template v-if="subscription.healthCheck"> <template v-if="subscription.healthCheck">
+182 -14
View File
@@ -5,12 +5,17 @@
* 原生 <dialog>,同 InboundForm。缓存路径不写进表单:它由名字自动生成, * 原生 <dialog>,同 InboundForm。缓存路径不写进表单:它由名字自动生成,
* 让用户填只会填出和链式代理对不上的路径。订阅不能改名——名字被策略组的 `use:` * 让用户填只会填出和链式代理对不上的路径。订阅不能改名——名字被策略组的 `use:`
* 和生成的副本引用,改名要同步所有引用,所以编辑时名字只是静态展示,想换名就删了重建。 * 和生成的副本引用,改名要同步所有引用,所以编辑时名字只是静态展示,想换名就删了重建。
*
* 新增时地址之外还能从文件导入:选了文件就是 file 类型的订阅,没有地址也没有
* 自动更新(内核靠文件监视跟进变化),相关字段随之收起。
*/ */
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { DEFAULT_INTERVAL } from '@shared/providers.ts' import { DEFAULT_INTERVAL } from '@shared/providers.ts'
import type { Subscription } from '@shared/providers.ts' import type { Subscription } from '@shared/providers.ts'
import { GENERATED_PREFIX } from '@shared/inbound-groups.ts' import { GENERATED_PREFIX } from '@shared/inbound-groups.ts'
import DropFile from '../components/DropFile.vue'
import IconClose from '~icons/ri/close-line' import IconClose from '~icons/ri/close-line'
import IconFile from '~icons/ri/file-text-line'
const props = defineProps<{ const props = defineProps<{
open: boolean open: boolean
@@ -23,14 +28,24 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
close: [] close: []
/** 编辑时 name 是被编辑那个订阅的原名 */ /** 编辑时 name 是被编辑那个订阅的原名。file 非 null 表示从文件导入 */
submit: [draft: { name: string; url: string; interval: number }] submit: [
draft: {
name: string
url: string
/** null = 不自动更新(配置里不写 interval) */
interval: number | null
file: { name: string; content: string } | null
},
]
}>() }>()
const dialog = ref<HTMLDialogElement | null>(null) const dialog = ref<HTMLDialogElement | null>(null)
const name = ref('') const name = ref('')
const url = ref('') const url = ref('')
const autoUpdate = ref(true)
const interval = ref(String(DEFAULT_INTERVAL)) const interval = ref(String(DEFAULT_INTERVAL))
const file = ref<{ name: string; content: string } | null>(null)
watch( watch(
() => props.open, () => props.open,
@@ -38,7 +53,9 @@ watch(
if (open) { if (open) {
name.value = props.editing?.name ?? '' name.value = props.editing?.name ?? ''
url.value = props.editing?.url ?? '' url.value = props.editing?.url ?? ''
autoUpdate.value = props.editing ? props.editing.interval !== null : true
interval.value = String(props.editing?.interval ?? DEFAULT_INTERVAL) interval.value = String(props.editing?.interval ?? DEFAULT_INTERVAL)
file.value = null
dialog.value?.showModal() dialog.value?.showModal()
} else { } else {
dialog.value?.close() dialog.value?.close()
@@ -56,30 +73,46 @@ const nameError = computed(() => {
}) })
const intervalInvalid = computed(() => { const intervalInvalid = computed(() => {
if (!autoUpdate.value) return false
const raw = interval.value.trim() const raw = interval.value.trim()
return raw === '' || !/^\d+$/.test(raw) || Number(raw) < 1 return raw === '' || !/^\d+$/.test(raw) || Number(raw) < 1
}) })
const canSubmit = computed( const canSubmit = computed(() => {
() => if (props.saving) return false
url.value.trim() !== '' && if (props.editing === null && (name.value.trim() === '' || nameError.value !== '')) return false
!intervalInvalid.value && if (file.value !== null) return true
(props.editing !== null || (name.value.trim() !== '' && nameError.value === '')) && return url.value.trim() !== '' && !intervalInvalid.value
!props.saving, })
)
function onFile(fileName: string, content: string) {
file.value = { name: fileName, content }
}
function submit() { function submit() {
if (!canSubmit.value) return if (!canSubmit.value) return
emit('submit', { emit('submit', {
name: props.editing?.name ?? name.value.trim(), name: props.editing?.name ?? name.value.trim(),
url: url.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> </script>
<template> <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"> <form method="dialog" @submit.prevent="submit">
<header class="dlg-head"> <header class="dlg-head">
<h2>{{ editing ? `编辑订阅 ${editing.name}` : '新增订阅' }}</h2> <h2>{{ editing ? `编辑订阅 ${editing.name}` : '新增订阅' }}</h2>
@@ -95,6 +128,7 @@ function submit() {
</span> </span>
</label> </label>
<template v-if="file === null">
<label class="row"> <label class="row">
<span class="k">地址</span> <span class="k">地址</span>
<span class="v"> <span class="v">
@@ -102,13 +136,46 @@ function submit() {
</span> </span>
</label> </label>
<label class="row"> <div class="row">
<span class="k">更新间隔</span> <span class="k">自动更新</span>
<span class="v"> <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" /> <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 v-if="intervalInvalid" class="bad">正整数,单位秒</span>
</span> </span>
</label> </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> </div>
<footer class="dlg-foot"> <footer class="dlg-foot">
@@ -179,7 +246,7 @@ function submit() {
.row { .row {
display: grid; display: grid;
grid-template-columns: 5rem 1fr; grid-template-columns: 6.5rem 1fr;
gap: var(--s-4); gap: var(--s-4);
align-items: baseline; align-items: baseline;
} }
@@ -204,6 +271,107 @@ input {
background: var(--c-bg); 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 { .bad {
font-size: var(--t-xs); font-size: var(--t-xs);
color: var(--c-danger); color: var(--c-danger);