mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-05-07 10:14:35 +00:00
refactor(ui): extract URL input components and enhance UI responsiveness (#1819)
This commit is contained in:
@@ -11,6 +11,8 @@ import {
|
|||||||
} from '../types/network'
|
} from '../types/network'
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import UrlInput from './UrlInput.vue'
|
||||||
|
import UrlListInput from './UrlListInput.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
configInvalid?: boolean
|
configInvalid?: boolean
|
||||||
@@ -32,57 +34,18 @@ const networking_methods = ref([
|
|||||||
{ value: NetworkingMethod.Standalone, label: () => t('standalone') },
|
{ value: NetworkingMethod.Standalone, label: () => t('standalone') },
|
||||||
])
|
])
|
||||||
|
|
||||||
const protos: { [proto: string]: number } = { tcp: 11010, udp: 11010, wg: 11011, ws: 11011, wss: 11012 }
|
const protos: { [proto: string]: number } = {
|
||||||
|
tcp: 11010,
|
||||||
function searchUrlSuggestions(e: { query: string }): string[] {
|
udp: 11010,
|
||||||
const query = e.query
|
wg: 11011,
|
||||||
const ret = []
|
ws: 11011,
|
||||||
// if query match "^\w+:.*", then no proto prefix
|
wss: 11012,
|
||||||
if (query.match(/^\w+:.*/)) {
|
quic: 11012,
|
||||||
// if query is a valid url, then add to suggestions
|
faketcp: 11013,
|
||||||
try {
|
http: 80,
|
||||||
// eslint-disable-next-line no-new
|
https: 443,
|
||||||
new URL(query)
|
txt: 0,
|
||||||
ret.push(query)
|
srv: 0,
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
for (const proto in protos) {
|
|
||||||
let item = `${proto}://${query}`
|
|
||||||
// if query match ":\d+$", then no port suffix
|
|
||||||
if (!query.match(/:\d+$/)) {
|
|
||||||
item += `:${protos[proto]}`
|
|
||||||
}
|
|
||||||
ret.push(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
const publicServerSuggestions = ref([''])
|
|
||||||
|
|
||||||
function searchPresetPublicServers(e: { query: string }) {
|
|
||||||
const presetPublicServers = [
|
|
||||||
'tcp://public.easytier.top:11010',
|
|
||||||
]
|
|
||||||
|
|
||||||
const query = e.query
|
|
||||||
// if query is sub string of presetPublicServers, add to suggestions
|
|
||||||
let ret = presetPublicServers.filter(item => item.includes(query))
|
|
||||||
// add additional suggestions
|
|
||||||
if (query.length > 0) {
|
|
||||||
ret = ret.concat(searchUrlSuggestions(e))
|
|
||||||
}
|
|
||||||
|
|
||||||
publicServerSuggestions.value = ret
|
|
||||||
}
|
|
||||||
|
|
||||||
const peerSuggestions = ref([''])
|
|
||||||
|
|
||||||
function searchPeerSuggestions(e: { query: string }) {
|
|
||||||
peerSuggestions.value = searchUrlSuggestions(e)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const inetSuggestions = ref([''])
|
const inetSuggestions = ref([''])
|
||||||
@@ -99,34 +62,6 @@ function searchInetSuggestions(e: { query: string }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const listenerSuggestions = ref([''])
|
|
||||||
|
|
||||||
function searchListenerSuggestions(e: { query: string }) {
|
|
||||||
const ret = []
|
|
||||||
|
|
||||||
for (const proto in protos) {
|
|
||||||
let item = `${proto}://0.0.0.0:`
|
|
||||||
// if query is a number, use it as port
|
|
||||||
if (e.query.match(/^\d+$/)) {
|
|
||||||
item += e.query
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
item += protos[proto]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.includes(e.query)) {
|
|
||||||
ret.push(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ret.length === 0) {
|
|
||||||
ret.push(e.query)
|
|
||||||
}
|
|
||||||
|
|
||||||
listenerSuggestions.value = ret
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const exitNodesSuggestions = ref([''])
|
const exitNodesSuggestions = ref([''])
|
||||||
|
|
||||||
function searchExitNodesSuggestions(e: { query: string }) {
|
function searchExitNodesSuggestions(e: { query: string }) {
|
||||||
@@ -266,14 +201,12 @@ onMounted(() => {
|
|||||||
<label for="nm">{{ t('networking_method') }}</label>
|
<label for="nm">{{ t('networking_method') }}</label>
|
||||||
<SelectButton v-model="curNetwork.networking_method" :options="networking_methods"
|
<SelectButton v-model="curNetwork.networking_method" :options="networking_methods"
|
||||||
:option-label="(v) => v.label()" option-value="value" />
|
:option-label="(v) => v.label()" option-value="value" />
|
||||||
<div class="items-center flex flex-row p-fluid gap-x-1">
|
<div class="items-center flex flex-col p-fluid gap-y-2">
|
||||||
<AutoComplete v-if="curNetwork.networking_method === NetworkingMethod.Manual" id="chips"
|
<UrlListInput v-if="curNetwork.networking_method === NetworkingMethod.Manual"
|
||||||
v-model="curNetwork.peer_urls" :placeholder="t('chips_placeholder', ['tcp://8.8.8.8:11010'])"
|
v-model="curNetwork.peer_urls" :protos="protos" :add-label="t('add_peer_url')" />
|
||||||
class="grow" multiple fluid :suggestions="peerSuggestions" @complete="searchPeerSuggestions" />
|
|
||||||
|
|
||||||
<AutoComplete v-if="curNetwork.networking_method === NetworkingMethod.PublicServer"
|
<UrlInput v-if="curNetwork.networking_method === NetworkingMethod.PublicServer"
|
||||||
v-model="curNetwork.public_server_url" :suggestions="publicServerSuggestions" class="grow"
|
v-model="curNetwork.public_server_url" :protos="protos" />
|
||||||
dropdown :complete-on-focus="false" @complete="searchPresetPublicServers" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -345,10 +278,8 @@ onMounted(() => {
|
|||||||
<div class="flex flex-row gap-x-9 flex-wrap">
|
<div class="flex flex-row gap-x-9 flex-wrap">
|
||||||
<div class="flex flex-col gap-2 grow p-fluid">
|
<div class="flex flex-col gap-2 grow p-fluid">
|
||||||
<label for="listener_urls">{{ t('listener_urls') }}</label>
|
<label for="listener_urls">{{ t('listener_urls') }}</label>
|
||||||
<AutoComplete id="listener_urls" v-model="curNetwork.listener_urls" :suggestions="listenerSuggestions"
|
<UrlListInput v-model="curNetwork.listener_urls" :protos="protos" :add-label="t('add_listener_url')"
|
||||||
class="w-full" dropdown :complete-on-focus="true"
|
placeholder="0.0.0.0" />
|
||||||
:placeholder="t('chips_placeholder', ['tcp://1.1.1.1:11010'])" multiple
|
|
||||||
@complete="searchListenerSuggestions" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -443,9 +374,8 @@ onMounted(() => {
|
|||||||
<label for="mapped_listeners">{{ t('mapped_listeners') }}</label>
|
<label for="mapped_listeners">{{ t('mapped_listeners') }}</label>
|
||||||
<span class="pi pi-question-circle ml-2 self-center" v-tooltip="t('mapped_listeners_help')"></span>
|
<span class="pi pi-question-circle ml-2 self-center" v-tooltip="t('mapped_listeners_help')"></span>
|
||||||
</div>
|
</div>
|
||||||
<AutoComplete id="mapped_listeners" v-model="curNetwork.mapped_listeners"
|
<UrlListInput v-model="curNetwork.mapped_listeners" :protos="protos"
|
||||||
:placeholder="t('chips_placeholder', ['tcp://123.123.123.123:11223'])" class="w-full" multiple fluid
|
:add-label="t('add_mapped_listener')" />
|
||||||
:suggestions="peerSuggestions" @complete="searchPeerSuggestions" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { AutoComplete, Button, Dialog, InputNumber, InputText } from 'primevue'
|
||||||
|
import InputGroup from 'primevue/inputgroup'
|
||||||
|
import InputGroupAddon from 'primevue/inputgroupaddon'
|
||||||
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
placeholder?: string
|
||||||
|
protos: { [proto: string]: number }
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const url = defineModel<string>({ required: true })
|
||||||
|
const editing = ref(false)
|
||||||
|
const container = ref<HTMLElement | null>(null)
|
||||||
|
const internalCompact = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (container.value) {
|
||||||
|
const observer = new ResizeObserver(entries => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
internalCompact.value = entry.contentRect.width < 400
|
||||||
|
}
|
||||||
|
})
|
||||||
|
observer.observe(container.value)
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
observer.disconnect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const parseUrl = (val: string | null | undefined) => {
|
||||||
|
const getValidPort = (portStr: string, proto: string) => {
|
||||||
|
const p = parseInt(portStr)
|
||||||
|
return isNaN(p) ? (props.protos[proto] ?? 11010) : p
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!val) {
|
||||||
|
return { proto: 'tcp', host: '', port: props.protos['tcp'] ?? 11010 }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(val)
|
||||||
|
const proto = urlObj.protocol.replace(':', '')
|
||||||
|
return {
|
||||||
|
proto: proto,
|
||||||
|
host: urlObj.hostname,
|
||||||
|
port: getValidPort(urlObj.port, proto)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback for incomplete or invalid URLs
|
||||||
|
const match = val.match(/^(\w+):\/\/(.*)$/)
|
||||||
|
if (match) {
|
||||||
|
const proto = match[1]
|
||||||
|
const rest = match[2]
|
||||||
|
const portMatch = rest.match(/:(\d+)$/)
|
||||||
|
return {
|
||||||
|
proto,
|
||||||
|
host: portMatch ? rest.slice(0, portMatch.index) : rest,
|
||||||
|
port: portMatch ? parseInt(portMatch[1]) : (props.protos[proto] ?? 11010)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { proto: 'tcp', host: '', port: 11010 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const internalValue = ref(parseUrl(url.value))
|
||||||
|
|
||||||
|
const isNoPortProto = computed(() => {
|
||||||
|
return props.protos[internalValue.value.proto] === 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sync from external
|
||||||
|
watch(() => url.value, (newVal) => {
|
||||||
|
const parsed = parseUrl(newVal)
|
||||||
|
if (parsed.proto !== internalValue.value.proto ||
|
||||||
|
parsed.host !== internalValue.value.host ||
|
||||||
|
parsed.port !== internalValue.value.port) {
|
||||||
|
internalValue.value = parsed
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sync to external
|
||||||
|
watch(internalValue, (newVal) => {
|
||||||
|
const proto = newVal.proto || 'tcp'
|
||||||
|
const host = newVal.host || '0.0.0.0'
|
||||||
|
let port = newVal.port
|
||||||
|
if (isNaN(parseInt(port as any))) {
|
||||||
|
port = props.protos[proto] ?? 11010
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.protos[proto] === 0) {
|
||||||
|
url.value = `${proto}://${host}`
|
||||||
|
} else {
|
||||||
|
url.value = `${proto}://${host}:${port}`
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
const protoOptions = computed(() => Object.keys(props.protos))
|
||||||
|
const filteredProtos = ref<string[]>([])
|
||||||
|
|
||||||
|
const searchProtos = (event: { query: string }) => {
|
||||||
|
if (!event.query.trim().length) {
|
||||||
|
filteredProtos.value = [...protoOptions.value]
|
||||||
|
} else {
|
||||||
|
filteredProtos.value = protoOptions.value.filter((proto) => {
|
||||||
|
return proto.toLowerCase().startsWith(event.query.toLowerCase())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onProtoChange = (newProto: string) => {
|
||||||
|
const oldProto = internalValue.value.proto
|
||||||
|
const oldDefault = props.protos[oldProto]
|
||||||
|
const newDefault = props.protos[newProto]
|
||||||
|
|
||||||
|
if (oldDefault !== undefined && internalValue.value.port === oldDefault && newDefault !== undefined) {
|
||||||
|
internalValue.value.port = newDefault
|
||||||
|
}
|
||||||
|
internalValue.value.proto = newProto
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="container" class="w-full">
|
||||||
|
<InputGroup v-if="!internalCompact" class="w-full">
|
||||||
|
<AutoComplete :model-value="internalValue.proto" :suggestions="filteredProtos" dropdown
|
||||||
|
class="max-w-32 proto-autocomplete-in-group" @complete="searchProtos"
|
||||||
|
@update:model-value="onProtoChange" />
|
||||||
|
<InputText v-model="internalValue.host" :placeholder="placeholder || '0.0.0.0'" class="grow" />
|
||||||
|
<template v-if="!isNoPortProto">
|
||||||
|
<InputGroupAddon>
|
||||||
|
<span style="font-weight: bold">:</span>
|
||||||
|
</InputGroupAddon>
|
||||||
|
<InputNumber v-model="internalValue.port" :format="false" :min="1" :max="65535" class="max-w-24"
|
||||||
|
fluid />
|
||||||
|
</template>
|
||||||
|
<slot name="actions"></slot>
|
||||||
|
</InputGroup>
|
||||||
|
|
||||||
|
<div v-else class="flex justify-between items-center p-2 border rounded w-full">
|
||||||
|
<span class="truncate mr-2">{{ url }}</span>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<Button icon="pi pi-pencil" class="p-button-sm p-button-text" @click="editing = true" />
|
||||||
|
<slot name="actions"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog v-model:visible="editing" modal :header="placeholder" :style="{ width: '90vw', maxWidth: '500px' }">
|
||||||
|
<div class="flex flex-col gap-4 py-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label>{{ t('tunnel_proto') }}</label>
|
||||||
|
<AutoComplete :model-value="internalValue.proto" :suggestions="filteredProtos" dropdown fluid
|
||||||
|
@complete="searchProtos" @update:model-value="onProtoChange" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label>{{ t('web.common.address') || 'Address' }}</label>
|
||||||
|
<InputText v-model="internalValue.host" :placeholder="placeholder || '0.0.0.0'" class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div v-if="!isNoPortProto" class="flex flex-col gap-2">
|
||||||
|
<label>{{ t('port') }}</label>
|
||||||
|
<InputNumber v-model="internalValue.port" :format="false" :min="1" :max="65535" class="w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<Button :label="t('web.common.confirm') || 'Done'" icon="pi pi-check" @click="editing = false"
|
||||||
|
autofocus />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.proto-autocomplete-in-group,
|
||||||
|
.proto-autocomplete-in-group :deep(.p-autocomplete-input),
|
||||||
|
.proto-autocomplete-in-group :deep(.p-autocomplete-dropdown) {
|
||||||
|
border-top-right-radius: 0 !important;
|
||||||
|
border-bottom-right-radius: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proto-autocomplete-in-group :deep(.p-autocomplete-dropdown) {
|
||||||
|
border-right: 0 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Button } from 'primevue'
|
||||||
|
import UrlInput from './UrlInput.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
protos: { [proto: string]: number }
|
||||||
|
addLabel: string
|
||||||
|
placeholder?: string
|
||||||
|
defaultUrl?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const list = defineModel<string[]>({ required: true })
|
||||||
|
|
||||||
|
const addUrl = () => {
|
||||||
|
list.value.push(props.defaultUrl || 'tcp://0.0.0.0:11010')
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeUrl = (index: number) => {
|
||||||
|
list.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-y-2 w-full">
|
||||||
|
<div v-for="(_, index) in list" :key="index" class="flex gap-2 items-center w-full">
|
||||||
|
<UrlInput v-model="list[index]" :protos="protos" :placeholder="placeholder">
|
||||||
|
<template #actions>
|
||||||
|
<Button icon="pi pi-trash" severity="danger" text rounded @click="removeUrl(index)" />
|
||||||
|
</template>
|
||||||
|
</UrlInput>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-center items-center w-full h-10 border-2 border-dashed border-surface-300 dark:border-surface-600 rounded-lg cursor-pointer hover:border-primary hover:bg-surface-50 dark:hover:bg-surface-800 transition-colors duration-200 gap-2 text-surface-500 dark:text-surface-400"
|
||||||
|
@click="addUrl">
|
||||||
|
<i class="pi pi-plus text-sm"></i>
|
||||||
|
<span class="text-sm font-medium">{{ addLabel }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -18,12 +18,16 @@ advanced_settings: 高级设置
|
|||||||
basic_settings: 基础设置
|
basic_settings: 基础设置
|
||||||
listener_urls: 监听地址
|
listener_urls: 监听地址
|
||||||
rpc_port: RPC端口
|
rpc_port: RPC端口
|
||||||
|
port: 端口
|
||||||
rpc_portal_whitelists: RPC白名单
|
rpc_portal_whitelists: RPC白名单
|
||||||
config_network: 配置网络
|
config_network: 配置网络
|
||||||
running: 运行中
|
running: 运行中
|
||||||
error_msg: 错误信息
|
error_msg: 错误信息
|
||||||
detail: 详情
|
detail: 详情
|
||||||
add_new_network: 添加新网络
|
add_new_network: 添加新网络
|
||||||
|
add_peer_url: 添加节点
|
||||||
|
add_listener_url: 添加监听地址
|
||||||
|
add_mapped_listener: 添加监听映射
|
||||||
del_cur_network: 删除当前网络
|
del_cur_network: 删除当前网络
|
||||||
select_network: 选择网络
|
select_network: 选择网络
|
||||||
network_instances: 网络实例
|
network_instances: 网络实例
|
||||||
@@ -337,6 +341,7 @@ web:
|
|||||||
info: 提示
|
info: 提示
|
||||||
enable: 开启
|
enable: 开启
|
||||||
disable: 关闭
|
disable: 关闭
|
||||||
|
address: 地址
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
title: 设置
|
title: 设置
|
||||||
|
|||||||
@@ -18,12 +18,16 @@ advanced_settings: Advanced Settings
|
|||||||
basic_settings: Basic Settings
|
basic_settings: Basic Settings
|
||||||
listener_urls: Listener URLs
|
listener_urls: Listener URLs
|
||||||
rpc_port: RPC Port
|
rpc_port: RPC Port
|
||||||
|
port: Port
|
||||||
rpc_portal_whitelists: RPC Whitelist
|
rpc_portal_whitelists: RPC Whitelist
|
||||||
config_network: Config Network
|
config_network: Config Network
|
||||||
running: Running
|
running: Running
|
||||||
error_msg: Error Message
|
error_msg: Error Message
|
||||||
detail: Detail
|
detail: Detail
|
||||||
add_new_network: New Network
|
add_new_network: New Network
|
||||||
|
add_peer_url: Add Peer
|
||||||
|
add_listener_url: Add Listener
|
||||||
|
add_mapped_listener: Add Mapped Listener
|
||||||
del_cur_network: Delete Current Network
|
del_cur_network: Delete Current Network
|
||||||
select_network: Select Network
|
select_network: Select Network
|
||||||
network_instances: Network Instances
|
network_instances: Network Instances
|
||||||
@@ -337,6 +341,7 @@ web:
|
|||||||
info: Info
|
info: Info
|
||||||
enable: Enable
|
enable: Enable
|
||||||
disable: Disable
|
disable: Disable
|
||||||
|
address: Address
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
title: Settings
|
title: Settings
|
||||||
|
|||||||
Reference in New Issue
Block a user