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