mirror of
https://github.com/fatedier/frp.git
synced 2026-03-24 09:08:13 +08:00
add persistent proxy/visitor store with CRUD API and web UI (#5188)
This commit is contained in:
@@ -48,6 +48,28 @@
|
||||
>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-select
|
||||
v-model="filterSource"
|
||||
placeholder="Source"
|
||||
clearable
|
||||
class="filter-select"
|
||||
>
|
||||
<el-option label="Config" value="config" />
|
||||
<el-option label="Store" value="store" />
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="filterType"
|
||||
placeholder="Type"
|
||||
clearable
|
||||
class="filter-select"
|
||||
>
|
||||
<el-option
|
||||
v-for="type in availableTypes"
|
||||
:key="type"
|
||||
:label="type.toUpperCase()"
|
||||
:value="type"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="searchText"
|
||||
placeholder="Search..."
|
||||
@@ -58,6 +80,18 @@
|
||||
<el-tooltip content="Refresh" placement="top">
|
||||
<el-button :icon="Refresh" circle @click="fetchData" />
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
v-if="storeEnabled"
|
||||
content="Add new proxy"
|
||||
placement="top"
|
||||
>
|
||||
<el-button
|
||||
type="primary"
|
||||
:icon="Plus"
|
||||
circle
|
||||
@click="handleCreate"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -68,10 +102,46 @@
|
||||
v-for="proxy in filteredStatus"
|
||||
:key="proxy.name"
|
||||
:proxy="proxy"
|
||||
@edit="handleEdit"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="!loading" class="empty-state">
|
||||
<el-empty description="No proxies found" />
|
||||
<div class="empty-content">
|
||||
<div class="empty-icon">
|
||||
<svg
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
x="8"
|
||||
y="16"
|
||||
width="48"
|
||||
height="32"
|
||||
rx="4"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<circle cx="20" cy="32" r="4" fill="currentColor" />
|
||||
<circle cx="32" cy="32" r="4" fill="currentColor" />
|
||||
<circle cx="44" cy="32" r="4" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="empty-text">No proxies configured</p>
|
||||
<p class="empty-hint">
|
||||
Add proxies in your configuration file or use Store to create
|
||||
dynamic proxies
|
||||
</p>
|
||||
<el-button
|
||||
v-if="storeEnabled"
|
||||
type="primary"
|
||||
:icon="Plus"
|
||||
@click="handleCreate"
|
||||
>
|
||||
Create First Proxy
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
@@ -90,7 +160,9 @@
|
||||
v-for="(count, type) in proxyTypeCounts"
|
||||
:key="type"
|
||||
class="proxy-type-item"
|
||||
:class="{ active: filterType === type }"
|
||||
v-show="count > 0"
|
||||
@click="toggleTypeFilter(String(type))"
|
||||
>
|
||||
<div class="proxy-type-name">
|
||||
{{ String(type).toUpperCase() }}
|
||||
@@ -125,6 +197,178 @@
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- Store Status Card -->
|
||||
<el-card class="store-status-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">Store</span>
|
||||
<el-tag
|
||||
size="small"
|
||||
:type="storeEnabled ? 'success' : 'info'"
|
||||
effect="plain"
|
||||
>
|
||||
{{ storeEnabled ? 'Enabled' : 'Disabled' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<div class="store-info">
|
||||
<template v-if="storeEnabled">
|
||||
<div class="store-stat">
|
||||
<span class="store-stat-label">Store Proxies</span>
|
||||
<span class="store-stat-value">{{ storeProxies.length }}</span>
|
||||
</div>
|
||||
<div class="store-stat">
|
||||
<span class="store-stat-label">Store Visitors</span>
|
||||
<span class="store-stat-value">{{ storeVisitors.length }}</span>
|
||||
</div>
|
||||
<p class="store-hint">
|
||||
Proxies from Store are marked with a purple indicator
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="store-disabled-text">
|
||||
Enable Store in your configuration to dynamically manage proxies
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- Disabled Store Proxies Section -->
|
||||
<el-row v-if="storeEnabled && disabledStoreProxies.length > 0" :gutter="20">
|
||||
<el-col :span="24">
|
||||
<el-card class="disabled-proxies-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<span class="card-title">Disabled Store Proxies</span>
|
||||
<el-tag size="small" type="warning">
|
||||
{{ disabledStoreProxies.length }} disabled
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="disabled-proxy-list">
|
||||
<div
|
||||
v-for="proxy in disabledStoreProxies"
|
||||
:key="proxy.name"
|
||||
class="disabled-proxy-card"
|
||||
>
|
||||
<div class="disabled-proxy-info">
|
||||
<span class="disabled-proxy-name">{{ proxy.name }}</span>
|
||||
<el-tag size="small" type="info">{{
|
||||
proxy.type.toUpperCase()
|
||||
}}</el-tag>
|
||||
<el-tag size="small" type="warning" effect="plain"
|
||||
>Disabled</el-tag
|
||||
>
|
||||
</div>
|
||||
<div class="disabled-proxy-actions">
|
||||
<el-button size="small" @click="handleEditStoreProxy(proxy)">
|
||||
Edit
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleDeleteStoreProxy(proxy)"
|
||||
>
|
||||
Delete
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="disabled-proxy-hint">
|
||||
Edit a proxy and enable it to make it active again.
|
||||
</p>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- Store Visitors Section -->
|
||||
<el-row v-if="storeEnabled" :gutter="20">
|
||||
<el-col :span="24">
|
||||
<el-card class="visitors-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<span class="card-title">Store Visitors</span>
|
||||
<el-tag size="small" type="info"
|
||||
>{{ storeVisitors.length }} visitors</el-tag
|
||||
>
|
||||
</div>
|
||||
<el-tooltip content="Add new visitor" placement="top">
|
||||
<el-button
|
||||
type="primary"
|
||||
:icon="Plus"
|
||||
circle
|
||||
@click="handleCreateVisitor"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="storeVisitors.length > 0" class="visitor-list">
|
||||
<div
|
||||
v-for="visitor in storeVisitors"
|
||||
:key="visitor.name"
|
||||
class="visitor-card"
|
||||
>
|
||||
<div class="visitor-card-header">
|
||||
<div class="visitor-info">
|
||||
<span class="visitor-name">{{ visitor.name }}</span>
|
||||
<el-tag size="small" type="info">{{
|
||||
visitor.type.toUpperCase()
|
||||
}}</el-tag>
|
||||
</div>
|
||||
<div class="visitor-actions">
|
||||
<el-button size="small" @click="handleEditVisitor(visitor)">
|
||||
Edit
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleDeleteVisitor(visitor.name)"
|
||||
>
|
||||
Delete
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="visitor-card-body">
|
||||
<span v-if="visitor.config?.serverName">
|
||||
Server: {{ visitor.config.serverName }}
|
||||
</span>
|
||||
<span
|
||||
v-if="
|
||||
visitor.config?.bindAddr || visitor.config?.bindPort != null
|
||||
"
|
||||
>
|
||||
Bind: {{ visitor.config.bindAddr || '127.0.0.1'
|
||||
}}<template v-if="visitor.config?.bindPort != null"
|
||||
>:{{ visitor.config.bindPort }}</template
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
<div class="empty-content">
|
||||
<p class="empty-text">No visitors configured</p>
|
||||
<p class="empty-hint">
|
||||
Create your first visitor to connect to secure proxies.
|
||||
</p>
|
||||
<el-button
|
||||
type="primary"
|
||||
:icon="Plus"
|
||||
@click="handleCreateVisitor"
|
||||
>
|
||||
Create First Visitor
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
@@ -132,17 +376,37 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||
import { getStatus } from '../api/frpc'
|
||||
import type { ProxyStatus } from '../types/proxy'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Search, Refresh, Plus } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getStatus,
|
||||
listStoreProxies,
|
||||
deleteStoreProxy,
|
||||
listStoreVisitors,
|
||||
deleteStoreVisitor,
|
||||
} from '../api/frpc'
|
||||
import type {
|
||||
ProxyStatus,
|
||||
StoreProxyConfig,
|
||||
StoreVisitorConfig,
|
||||
} from '../types/proxy'
|
||||
import StatCard from '../components/StatCard.vue'
|
||||
import ProxyCard from '../components/ProxyCard.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// State
|
||||
const status = ref<ProxyStatus[]>([])
|
||||
const storeProxies = ref<StoreProxyConfig[]>([])
|
||||
const storeVisitors = ref<StoreVisitorConfig[]>([])
|
||||
const storeEnabled = ref(false)
|
||||
const loading = ref(false)
|
||||
const searchText = ref('')
|
||||
const filterSource = ref('')
|
||||
const filterType = ref('')
|
||||
|
||||
// Computed
|
||||
const stats = computed(() => {
|
||||
const total = status.value.length
|
||||
const running = status.value.filter((p) => p.status === 'running').length
|
||||
@@ -163,41 +427,181 @@ const hasActiveProxies = computed(() => {
|
||||
return status.value.length > 0
|
||||
})
|
||||
|
||||
const filteredStatus = computed(() => {
|
||||
if (!searchText.value) {
|
||||
return status.value
|
||||
}
|
||||
const search = searchText.value.toLowerCase()
|
||||
return status.value.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(search) ||
|
||||
p.type.toLowerCase().includes(search) ||
|
||||
p.local_addr.toLowerCase().includes(search) ||
|
||||
p.remote_addr.toLowerCase().includes(search),
|
||||
)
|
||||
const availableTypes = computed(() => {
|
||||
const types = new Set<string>()
|
||||
status.value.forEach((p) => types.add(p.type))
|
||||
return Array.from(types).sort()
|
||||
})
|
||||
|
||||
const filteredStatus = computed(() => {
|
||||
let result = status.value
|
||||
|
||||
if (filterSource.value) {
|
||||
if (filterSource.value === 'store') {
|
||||
result = result.filter((p) => p.source === 'store')
|
||||
} else {
|
||||
result = result.filter((p) => !p.source || p.source !== 'store')
|
||||
}
|
||||
}
|
||||
|
||||
if (filterType.value) {
|
||||
result = result.filter((p) => p.type === filterType.value)
|
||||
}
|
||||
|
||||
if (searchText.value) {
|
||||
const search = searchText.value.toLowerCase()
|
||||
result = result.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(search) ||
|
||||
p.type.toLowerCase().includes(search) ||
|
||||
p.local_addr.toLowerCase().includes(search) ||
|
||||
p.remote_addr.toLowerCase().includes(search),
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const disabledStoreProxies = computed(() => {
|
||||
return storeProxies.value.filter((p) => p.config?.enabled === false)
|
||||
})
|
||||
|
||||
// Methods
|
||||
const toggleTypeFilter = (type: string) => {
|
||||
filterType.value = filterType.value === type ? '' : type
|
||||
}
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const json = await getStatus()
|
||||
const list: ProxyStatus[] = []
|
||||
for (const key in json) {
|
||||
for (const ps of json[key]) {
|
||||
list.push(ps)
|
||||
}
|
||||
}
|
||||
status.value = list
|
||||
} catch (err: any) {
|
||||
ElMessage.error('Failed to get status: ' + err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchStoreProxies = async () => {
|
||||
try {
|
||||
const res = await listStoreProxies()
|
||||
storeProxies.value = res.proxies || []
|
||||
storeEnabled.value = true
|
||||
} catch (err: any) {
|
||||
if (err.status === 404) {
|
||||
storeEnabled.value = false
|
||||
storeProxies.value = []
|
||||
} else {
|
||||
console.error('Failed to fetch store proxies:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fetchStoreVisitors = async () => {
|
||||
try {
|
||||
const res = await listStoreVisitors()
|
||||
storeVisitors.value = res.visitors || []
|
||||
} catch (err: any) {
|
||||
if (err.status === 404) {
|
||||
storeVisitors.value = []
|
||||
} else {
|
||||
console.error('Failed to fetch store visitors:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const json = await getStatus()
|
||||
status.value = []
|
||||
for (const key in json) {
|
||||
for (const ps of json[key]) {
|
||||
status.value.push(ps)
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
ElMessage({
|
||||
showClose: true,
|
||||
message: 'Get status info from frpc failed! ' + err.message,
|
||||
type: 'warning',
|
||||
})
|
||||
await Promise.all([
|
||||
fetchStoreProxies(),
|
||||
fetchStoreVisitors(),
|
||||
fetchStatus(),
|
||||
])
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
router.push('/proxies/create')
|
||||
}
|
||||
|
||||
const handleEdit = (proxy: ProxyStatus) => {
|
||||
if (proxy.source !== 'store') return
|
||||
router.push('/proxies/' + encodeURIComponent(proxy.name) + '/edit')
|
||||
}
|
||||
|
||||
const confirmAndDeleteProxy = async (name: string) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`Are you sure you want to delete "${name}"? This action cannot be undone.`,
|
||||
'Delete Proxy',
|
||||
{
|
||||
confirmButtonText: 'Delete',
|
||||
cancelButtonText: 'Cancel',
|
||||
type: 'warning',
|
||||
confirmButtonClass: 'el-button--danger',
|
||||
},
|
||||
)
|
||||
await deleteStoreProxy(name)
|
||||
ElMessage.success('Proxy deleted')
|
||||
fetchData()
|
||||
} catch (err: any) {
|
||||
if (err !== 'cancel' && err !== 'close') {
|
||||
ElMessage.error('Delete failed: ' + (err.message || 'Unknown error'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (proxy: ProxyStatus) => {
|
||||
if (proxy.source !== 'store') return
|
||||
confirmAndDeleteProxy(proxy.name)
|
||||
}
|
||||
|
||||
const handleEditStoreProxy = (proxy: StoreProxyConfig) => {
|
||||
router.push('/proxies/' + encodeURIComponent(proxy.name) + '/edit')
|
||||
}
|
||||
|
||||
const handleDeleteStoreProxy = async (proxy: StoreProxyConfig) => {
|
||||
confirmAndDeleteProxy(proxy.name)
|
||||
}
|
||||
|
||||
const handleCreateVisitor = () => {
|
||||
router.push('/visitors/create')
|
||||
}
|
||||
|
||||
const handleEditVisitor = (visitor: StoreVisitorConfig) => {
|
||||
router.push('/visitors/' + encodeURIComponent(visitor.name) + '/edit')
|
||||
}
|
||||
|
||||
const handleDeleteVisitor = async (name: string) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`Are you sure you want to delete visitor "${name}"? This action cannot be undone.`,
|
||||
'Delete Visitor',
|
||||
{
|
||||
confirmButtonText: 'Delete',
|
||||
cancelButtonText: 'Cancel',
|
||||
type: 'warning',
|
||||
confirmButtonClass: 'el-button--danger',
|
||||
},
|
||||
)
|
||||
await deleteStoreVisitor(name)
|
||||
ElMessage.success('Visitor deleted')
|
||||
fetchData()
|
||||
} catch (err: any) {
|
||||
if (err !== 'cancel' && err !== 'close') {
|
||||
ElMessage.error('Delete failed: ' + (err.message || 'Unknown error'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initial load
|
||||
fetchData()
|
||||
</script>
|
||||
|
||||
@@ -222,19 +626,22 @@ fetchData()
|
||||
|
||||
.proxy-list-card,
|
||||
.types-card,
|
||||
.status-summary-card {
|
||||
.status-summary-card,
|
||||
.store-status-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
html.dark .proxy-list-card,
|
||||
html.dark .types-card,
|
||||
html.dark .status-summary-card {
|
||||
html.dark .status-summary-card,
|
||||
html.dark .store-status-card {
|
||||
border-color: #3a3d5c;
|
||||
background: #27293d;
|
||||
}
|
||||
|
||||
.status-summary-card {
|
||||
.status-summary-card,
|
||||
.store-status-card {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
@@ -255,7 +662,7 @@ html.dark .status-summary-card {
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
@@ -268,8 +675,12 @@ html.dark .card-title {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 200px;
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.proxy-list-content {
|
||||
@@ -282,8 +693,45 @@ html.dark .card-title {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
padding: 40px 0;
|
||||
padding: 48px 24px;
|
||||
}
|
||||
|
||||
.empty-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin-bottom: 20px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
html.dark .empty-icon {
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #606266;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
html.dark .empty-text {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin: 0 0 20px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
/* Proxy Types Grid */
|
||||
@@ -303,6 +751,7 @@ html.dark .card-title {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.proxy-type-item:hover {
|
||||
@@ -310,6 +759,19 @@ html.dark .card-title {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.proxy-type-item.active {
|
||||
background: var(--el-color-primary-light-8);
|
||||
box-shadow: 0 0 0 2px var(--el-color-primary-light-5);
|
||||
}
|
||||
|
||||
.proxy-type-item.active .proxy-type-name {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.proxy-type-item.active .proxy-type-count {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
html.dark .proxy-type-item {
|
||||
background: #1e1e2d;
|
||||
}
|
||||
@@ -318,6 +780,11 @@ html.dark .proxy-type-item:hover {
|
||||
background: #2a2a3c;
|
||||
}
|
||||
|
||||
html.dark .proxy-type-item.active {
|
||||
background: var(--el-color-primary-dark-2);
|
||||
box-shadow: 0 0 0 2px var(--el-color-primary);
|
||||
}
|
||||
|
||||
.proxy-type-name {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
@@ -410,6 +877,213 @@ html.dark .status-item:hover {
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
/* Store Status Card */
|
||||
.store-info {
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.store-stat {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(102, 126, 234, 0.08) 0%,
|
||||
rgba(118, 75, 162, 0.08) 100%
|
||||
);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
html.dark .store-stat {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(129, 140, 248, 0.12) 0%,
|
||||
rgba(167, 139, 250, 0.12) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.store-stat-label {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
html.dark .store-stat-label {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.store-stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
html.dark .store-stat-value {
|
||||
background: linear-gradient(135deg, #818cf8 0%, #a78bfa 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.store-hint {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.store-disabled-text {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Disabled Proxies Card */
|
||||
.disabled-proxies-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e4e7ed;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
html.dark .disabled-proxies-card {
|
||||
border-color: #3a3d5c;
|
||||
background: #27293d;
|
||||
}
|
||||
|
||||
.disabled-proxy-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.disabled-proxy-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 8px;
|
||||
background: #faf7f0;
|
||||
border: 1px solid #f1d9a6;
|
||||
}
|
||||
|
||||
html.dark .disabled-proxy-card {
|
||||
background: rgba(161, 98, 7, 0.14);
|
||||
border-color: rgba(245, 158, 11, 0.45);
|
||||
}
|
||||
|
||||
.disabled-proxy-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.disabled-proxy-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
html.dark .disabled-proxy-name {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.disabled-proxy-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.disabled-proxy-hint {
|
||||
margin: 12px 2px 0;
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
/* Visitors Card */
|
||||
.visitors-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e4e7ed;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
html.dark .visitors-card {
|
||||
border-color: #3a3d5c;
|
||||
background: #27293d;
|
||||
}
|
||||
|
||||
.visitor-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.visitor-card {
|
||||
padding: 16px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.visitor-card:hover {
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
html.dark .visitor-card {
|
||||
background: #1e1e2d;
|
||||
}
|
||||
|
||||
html.dark .visitor-card:hover {
|
||||
background: #2a2a3c;
|
||||
}
|
||||
|
||||
.visitor-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.visitor-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.visitor-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
html.dark .visitor-name {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.visitor-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.visitor-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
html.dark .visitor-card-body {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
@@ -432,10 +1106,21 @@ html.dark .status-item:hover {
|
||||
.proxy-types-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.disabled-proxy-card {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.disabled-proxy-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.status-summary-card {
|
||||
.status-summary-card,
|
||||
.store-status-card {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
1238
web/frpc/src/views/ProxyEdit.vue
Normal file
1238
web/frpc/src/views/ProxyEdit.vue
Normal file
File diff suppressed because it is too large
Load Diff
606
web/frpc/src/views/VisitorEdit.vue
Normal file
606
web/frpc/src/views/VisitorEdit.vue
Normal file
@@ -0,0 +1,606 @@
|
||||
<template>
|
||||
<div class="visitor-edit-page">
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="breadcrumb">
|
||||
<a class="breadcrumb-link" @click="goBack">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
</a>
|
||||
<router-link to="/" class="breadcrumb-item">Overview</router-link>
|
||||
<span class="breadcrumb-separator">/</span>
|
||||
<span class="breadcrumb-current">{{
|
||||
isEditing ? 'Edit Visitor' : 'Create Visitor'
|
||||
}}</span>
|
||||
</nav>
|
||||
|
||||
<div v-loading="pageLoading" class="edit-content">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="formRules"
|
||||
label-position="top"
|
||||
@submit.prevent
|
||||
>
|
||||
<!-- Header Card -->
|
||||
<div class="form-card header-card">
|
||||
<div class="card-body">
|
||||
<div class="field-row three-col">
|
||||
<el-form-item label="Name" prop="name" class="field-grow">
|
||||
<el-input
|
||||
v-model="form.name"
|
||||
:disabled="isEditing"
|
||||
placeholder="my-visitor"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="Type" prop="type">
|
||||
<el-select
|
||||
v-model="form.type"
|
||||
:disabled="isEditing"
|
||||
:fit-input-width="false"
|
||||
popper-class="visitor-type-dropdown"
|
||||
class="type-select"
|
||||
>
|
||||
<el-option value="stcp" label="STCP">
|
||||
<div class="type-option">
|
||||
<span class="type-tag-inline type-stcp">STCP</span>
|
||||
<span class="type-desc">Secure TCP Visitor</span>
|
||||
</div>
|
||||
</el-option>
|
||||
<el-option value="sudp" label="SUDP">
|
||||
<div class="type-option">
|
||||
<span class="type-tag-inline type-sudp">SUDP</span>
|
||||
<span class="type-desc">Secure UDP Visitor</span>
|
||||
</div>
|
||||
</el-option>
|
||||
<el-option value="xtcp" label="XTCP">
|
||||
<div class="type-option">
|
||||
<span class="type-tag-inline type-xtcp">XTCP</span>
|
||||
<span class="type-desc">P2P (NAT traversal)</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="Enabled">
|
||||
<el-switch v-model="form.enabled" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connection -->
|
||||
<div class="form-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Connection</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="field-row two-col">
|
||||
<el-form-item label="Server Name" prop="serverName">
|
||||
<el-input
|
||||
v-model="form.serverName"
|
||||
placeholder="Name of the proxy to visit"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="Server User">
|
||||
<el-input
|
||||
v-model="form.serverUser"
|
||||
placeholder="Leave empty for same user"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="Secret Key">
|
||||
<el-input
|
||||
v-model="form.secretKey"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="Shared secret"
|
||||
/>
|
||||
</el-form-item>
|
||||
<div class="field-row two-col">
|
||||
<el-form-item label="Bind Address">
|
||||
<el-input v-model="form.bindAddr" placeholder="127.0.0.1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Bind Port" prop="bindPort">
|
||||
<el-input-number
|
||||
v-model="form.bindPort"
|
||||
:min="bindPortMin"
|
||||
:max="65535"
|
||||
controls-position="right"
|
||||
class="full-width"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transport Options (collapsible) -->
|
||||
<div class="form-card collapsible-card">
|
||||
<div
|
||||
class="card-header clickable"
|
||||
@click="transportExpanded = !transportExpanded"
|
||||
>
|
||||
<h3 class="card-title">Transport Options</h3>
|
||||
<el-icon
|
||||
class="collapse-icon"
|
||||
:class="{ expanded: transportExpanded }"
|
||||
><ArrowDown
|
||||
/></el-icon>
|
||||
</div>
|
||||
<el-collapse-transition>
|
||||
<div v-show="transportExpanded" class="card-body">
|
||||
<div class="field-row two-col">
|
||||
<el-form-item label="Use Encryption">
|
||||
<el-switch v-model="form.useEncryption" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Use Compression">
|
||||
<el-switch v-model="form.useCompression" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-transition>
|
||||
</div>
|
||||
|
||||
<!-- XTCP Options (collapsible, xtcp only) -->
|
||||
<template v-if="form.type === 'xtcp'">
|
||||
<div class="form-card collapsible-card">
|
||||
<div
|
||||
class="card-header clickable"
|
||||
@click="xtcpExpanded = !xtcpExpanded"
|
||||
>
|
||||
<h3 class="card-title">XTCP Options</h3>
|
||||
<el-icon class="collapse-icon" :class="{ expanded: xtcpExpanded }"
|
||||
><ArrowDown
|
||||
/></el-icon>
|
||||
</div>
|
||||
<el-collapse-transition>
|
||||
<div v-show="xtcpExpanded" class="card-body">
|
||||
<el-form-item label="Protocol">
|
||||
<el-select v-model="form.protocol" class="full-width">
|
||||
<el-option value="quic" label="QUIC" />
|
||||
<el-option value="kcp" label="KCP" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="Keep Tunnel Open">
|
||||
<el-switch v-model="form.keepTunnelOpen" />
|
||||
</el-form-item>
|
||||
<div class="field-row two-col">
|
||||
<el-form-item label="Max Retries per Hour">
|
||||
<el-input-number
|
||||
v-model="form.maxRetriesAnHour"
|
||||
:min="0"
|
||||
controls-position="right"
|
||||
class="full-width"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="Min Retry Interval (s)">
|
||||
<el-input-number
|
||||
v-model="form.minRetryInterval"
|
||||
:min="0"
|
||||
controls-position="right"
|
||||
class="full-width"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="field-row two-col">
|
||||
<el-form-item label="Fallback To">
|
||||
<el-input
|
||||
v-model="form.fallbackTo"
|
||||
placeholder="Fallback visitor name"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="Fallback Timeout (ms)">
|
||||
<el-input-number
|
||||
v-model="form.fallbackTimeoutMs"
|
||||
:min="0"
|
||||
controls-position="right"
|
||||
class="full-width"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-transition>
|
||||
</div>
|
||||
|
||||
<!-- NAT Traversal (collapsible, xtcp only) -->
|
||||
<div class="form-card collapsible-card">
|
||||
<div
|
||||
class="card-header clickable"
|
||||
@click="natExpanded = !natExpanded"
|
||||
>
|
||||
<h3 class="card-title">NAT Traversal</h3>
|
||||
<el-icon class="collapse-icon" :class="{ expanded: natExpanded }"
|
||||
><ArrowDown
|
||||
/></el-icon>
|
||||
</div>
|
||||
<el-collapse-transition>
|
||||
<div v-show="natExpanded" class="card-body">
|
||||
<el-form-item label="Disable Assisted Addresses">
|
||||
<el-switch v-model="form.natTraversalDisableAssistedAddrs" />
|
||||
<div class="form-tip">
|
||||
Only use STUN-discovered public addresses
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-collapse-transition>
|
||||
</div>
|
||||
</template>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- Sticky Footer -->
|
||||
<div class="sticky-footer">
|
||||
<div class="footer-content">
|
||||
<el-button @click="goBack">Cancel</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">
|
||||
{{ isEditing ? 'Update' : 'Create' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ArrowLeft, ArrowDown } from '@element-plus/icons-vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import {
|
||||
type VisitorFormData,
|
||||
createDefaultVisitorForm,
|
||||
formToStoreVisitor,
|
||||
storeVisitorToForm,
|
||||
} from '../types/proxy'
|
||||
import {
|
||||
getStoreVisitor,
|
||||
createStoreVisitor,
|
||||
updateStoreVisitor,
|
||||
} from '../api/frpc'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const isEditing = computed(() => !!route.params.name)
|
||||
const pageLoading = ref(false)
|
||||
const saving = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
const form = ref<VisitorFormData>(createDefaultVisitorForm())
|
||||
|
||||
const transportExpanded = ref(false)
|
||||
const xtcpExpanded = ref(false)
|
||||
const natExpanded = ref(false)
|
||||
const bindPortMin = computed(() => (form.value.type === 'sudp' ? 1 : undefined))
|
||||
|
||||
const formRules: FormRules = {
|
||||
name: [
|
||||
{ required: true, message: 'Name is required', trigger: 'blur' },
|
||||
{ min: 1, max: 50, message: 'Length should be 1 to 50', trigger: 'blur' },
|
||||
],
|
||||
type: [{ required: true, message: 'Type is required', trigger: 'change' }],
|
||||
serverName: [
|
||||
{ required: true, message: 'Server name is required', trigger: 'blur' },
|
||||
],
|
||||
bindPort: [
|
||||
{ required: true, message: 'Bind port is required', trigger: 'blur' },
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
if (value == null) {
|
||||
callback(new Error('Bind port is required'))
|
||||
return
|
||||
}
|
||||
if (value > 65535) {
|
||||
callback(new Error('Bind port must be less than or equal to 65535'))
|
||||
return
|
||||
}
|
||||
if (form.value.type === 'sudp') {
|
||||
if (value < 1) {
|
||||
callback(new Error('SUDP bind port must be greater than 0'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
return
|
||||
}
|
||||
if (value === 0) {
|
||||
callback(new Error('Bind port cannot be 0'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
const loadVisitor = async () => {
|
||||
const name = route.params.name as string
|
||||
if (!name) return
|
||||
|
||||
pageLoading.value = true
|
||||
try {
|
||||
const res = await getStoreVisitor(name)
|
||||
form.value = storeVisitorToForm(res)
|
||||
} catch (err: any) {
|
||||
ElMessage.error('Failed to load visitor: ' + err.message)
|
||||
router.push('/')
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
} catch {
|
||||
ElMessage.warning('Please fix the form errors')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const data = formToStoreVisitor(form.value)
|
||||
if (isEditing.value) {
|
||||
await updateStoreVisitor(form.value.name, data)
|
||||
ElMessage.success('Visitor updated')
|
||||
} else {
|
||||
await createStoreVisitor(data)
|
||||
ElMessage.success('Visitor created')
|
||||
}
|
||||
router.push('/')
|
||||
} catch (err: any) {
|
||||
ElMessage.error('Operation failed: ' + (err.message || 'Unknown error'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (isEditing.value) {
|
||||
loadVisitor()
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.params.name,
|
||||
(name, oldName) => {
|
||||
if (name === oldName) return
|
||||
if (name) {
|
||||
loadVisitor()
|
||||
return
|
||||
}
|
||||
form.value = createDefaultVisitorForm()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.visitor-edit-page {
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
/* Breadcrumb */
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.breadcrumb-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.breadcrumb-link:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.breadcrumb-item:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.breadcrumb-separator {
|
||||
color: var(--el-border-color);
|
||||
}
|
||||
|
||||
.breadcrumb-current {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Form Cards */
|
||||
.form-card {
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--header-border);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
html.dark .form-card {
|
||||
border-color: #3a3d5c;
|
||||
background: #27293d;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--header-border);
|
||||
}
|
||||
|
||||
html.dark .card-header {
|
||||
border-bottom-color: #3a3d5c;
|
||||
}
|
||||
|
||||
.card-header.clickable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.card-header.clickable:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.collapsible-card .card-header {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.collapsible-card .card-body {
|
||||
border-top: 1px solid var(--header-border);
|
||||
}
|
||||
|
||||
html.dark .collapsible-card .card-body {
|
||||
border-top-color: #3a3d5c;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.collapse-icon {
|
||||
transition: transform 0.3s;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.collapse-icon.expanded {
|
||||
transform: rotate(-180deg);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
/* Field Rows */
|
||||
.field-row {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field-row.two-col {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.field-row.three-col {
|
||||
grid-template-columns: 1fr auto auto;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.field-grow {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.type-select {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.type-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.type-tag-inline {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.type-tag-inline.type-stcp,
|
||||
.type-tag-inline.type-sudp,
|
||||
.type-tag-inline.type-xtcp {
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.type-desc {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Sticky Footer */
|
||||
.sticky-footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 99;
|
||||
background: var(--header-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-top: 1px solid var(--header-border);
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 40px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.field-row.two-col,
|
||||
.field-row.three-col {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.type-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
padding: 12px 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.visitor-type-dropdown {
|
||||
min-width: 300px !important;
|
||||
}
|
||||
|
||||
.visitor-type-dropdown .el-select-dropdown__item {
|
||||
height: auto;
|
||||
padding: 8px 16px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user