订阅和规则组支持从文件导入,配置页与表单一批打磨
文件导入走两步:内容先经新端点 POST /api/provider-files 写进内核工作 目录的 providers/ 下(路径白名单校验,只认两个路径助手生成的形状), 成了再写 file 类型的声明——顺序反了内核重载会去读不存在的文件。 file 类型没有地址和自动更新(内核靠文件监视跟进),详情页照此展示, 编辑按钮禁用。ini 导入的取文件按钮换成同一个拖放模块。 订阅的更新间隔改成开关语义:内核只在 interval > 0 时才起定时拉取 (component/resource/fetcher.go:226-228),关掉开关就是不写这个键。 配置页加「更新所有订阅」,详情的「立刻更新」改叫「更新订阅」。 规则表从零开始写入时自动垫上内网直连——保留地址不直连的话第一条 代理规则一生效,访问局域网的流量就全进了代理;按钮改成「补全内网 直连」,只在有缺口时出现。 所有表单对话框点遮罩关闭;失败横幅右侧加关闭按钮。
This commit is contained in:
@@ -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') {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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,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
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user