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