mirror of
https://github.com/fatedier/frp.git
synced 2026-03-24 02:39:14 +08:00
web/frpc: redesign frpc dashboard with sidebar nav, proxy/visitor list and detail views (#5237)
This commit is contained in:
@@ -2,127 +2,65 @@
|
||||
<div class="configure-page">
|
||||
<div class="page-header">
|
||||
<div class="title-section">
|
||||
<h1 class="page-title">Configuration</h1>
|
||||
<p class="page-subtitle">
|
||||
Edit and manage your frpc configuration file
|
||||
</p>
|
||||
<h1 class="page-title">Config</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :lg="16">
|
||||
<el-card class="editor-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<span class="card-title">Configuration Editor</span>
|
||||
<el-tag size="small" type="success">TOML</el-tag>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-tooltip content="Refresh" placement="top">
|
||||
<el-button :icon="Refresh" circle @click="fetchData" />
|
||||
</el-tooltip>
|
||||
<el-button type="primary" :icon="Upload" @click="handleUpload">
|
||||
Update & Reload
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="editor-header">
|
||||
<div class="header-left">
|
||||
<a
|
||||
href="https://github.com/fatedier/frp#configuration-files"
|
||||
target="_blank"
|
||||
class="docs-link"
|
||||
>
|
||||
<el-icon><Link /></el-icon>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<ActionButton @click="handleUpload">Update & Reload</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-wrapper">
|
||||
<el-input
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 20, maxRows: 40 }"
|
||||
v-model="configContent"
|
||||
placeholder="# frpc configuration file content...
|
||||
<div class="editor-wrapper">
|
||||
<el-input
|
||||
type="textarea"
|
||||
:autosize="false"
|
||||
v-model="configContent"
|
||||
placeholder="# frpc configuration file content...
|
||||
|
||||
[common]
|
||||
server_addr = 127.0.0.1
|
||||
server_port = 7000"
|
||||
class="code-editor"
|
||||
></el-input>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
serverAddr = "127.0.0.1"
|
||||
serverPort = 7000"
|
||||
class="code-editor"
|
||||
></el-input>
|
||||
</div>
|
||||
|
||||
<el-col :xs="24" :lg="8">
|
||||
<el-card class="help-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">Quick Reference</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="help-content">
|
||||
<div class="help-section">
|
||||
<h4 class="help-section-title">Common Settings</h4>
|
||||
<div class="help-items">
|
||||
<div class="help-item">
|
||||
<code>serverAddr</code>
|
||||
<span>Server address</span>
|
||||
</div>
|
||||
<div class="help-item">
|
||||
<code>serverPort</code>
|
||||
<span>Server port (default: 7000)</span>
|
||||
</div>
|
||||
<div class="help-item">
|
||||
<code>auth.token</code>
|
||||
<span>Authentication token</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h4 class="help-section-title">Proxy Types</h4>
|
||||
<div class="proxy-type-tags">
|
||||
<el-tag type="primary" effect="plain">TCP</el-tag>
|
||||
<el-tag type="success" effect="plain">UDP</el-tag>
|
||||
<el-tag type="warning" effect="plain">HTTP</el-tag>
|
||||
<el-tag type="danger" effect="plain">HTTPS</el-tag>
|
||||
<el-tag type="info" effect="plain">STCP</el-tag>
|
||||
<el-tag effect="plain">XTCP</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h4 class="help-section-title">Example Proxy</h4>
|
||||
<pre class="code-example">
|
||||
[[proxies]]
|
||||
name = "web"
|
||||
type = "http"
|
||||
localPort = 80
|
||||
customDomains = ["example.com"]</pre
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<a
|
||||
href="https://github.com/fatedier/frp#configuration-files"
|
||||
target="_blank"
|
||||
class="docs-link"
|
||||
>
|
||||
<el-icon><Link /></el-icon>
|
||||
View Full Documentation
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<ConfirmDialog
|
||||
v-model="confirmVisible"
|
||||
title="Confirm Update"
|
||||
message="This operation will update your frpc configuration and reload it. Do you want to continue?"
|
||||
confirm-text="Update"
|
||||
:loading="uploading"
|
||||
@confirm="doUpload"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Refresh, Upload, Link } from '@element-plus/icons-vue'
|
||||
import { getConfig, putConfig, reloadConfig } from '../api/frpc'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Link } from '@element-plus/icons-vue'
|
||||
import { useClientStore } from '../stores/client'
|
||||
import ActionButton from '../components/ActionButton.vue'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
|
||||
const clientStore = useClientStore()
|
||||
const configContent = ref('')
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const text = await getConfig()
|
||||
configContent.value = text
|
||||
await clientStore.fetchConfig()
|
||||
configContent.value = clientStore.config
|
||||
} catch (err: any) {
|
||||
ElMessage({
|
||||
showClose: true,
|
||||
@@ -132,256 +70,116 @@ const fetchData = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpload = () => {
|
||||
ElMessageBox.confirm(
|
||||
'This operation will update your frpc configuration and reload it. Do you want to continue?',
|
||||
'Confirm Update',
|
||||
{
|
||||
confirmButtonText: 'Update',
|
||||
cancelButtonText: 'Cancel',
|
||||
type: 'warning',
|
||||
},
|
||||
)
|
||||
.then(async () => {
|
||||
if (!configContent.value.trim()) {
|
||||
ElMessage({
|
||||
message: 'Configuration content cannot be empty!',
|
||||
type: 'warning',
|
||||
})
|
||||
return
|
||||
}
|
||||
const confirmVisible = ref(false)
|
||||
const uploading = ref(false)
|
||||
|
||||
try {
|
||||
await putConfig(configContent.value)
|
||||
await reloadConfig()
|
||||
ElMessage({
|
||||
type: 'success',
|
||||
message: 'Configuration updated and reloaded successfully',
|
||||
})
|
||||
} catch (err: any) {
|
||||
ElMessage({
|
||||
showClose: true,
|
||||
message: 'Update failed: ' + err.message,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// cancelled
|
||||
})
|
||||
const handleUpload = () => {
|
||||
confirmVisible.value = true
|
||||
}
|
||||
|
||||
const doUpload = async () => {
|
||||
if (!configContent.value.trim()) {
|
||||
ElMessage.warning('Configuration content cannot be empty!')
|
||||
return
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
try {
|
||||
await clientStore.saveConfig(configContent.value)
|
||||
await clientStore.reload()
|
||||
ElMessage.success('Configuration updated and reloaded successfully')
|
||||
confirmVisible.value = false
|
||||
} catch (err: any) {
|
||||
ElMessage.error('Update failed: ' + err.message)
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fetchData()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style scoped lang="scss">
|
||||
.configure-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
padding: $spacing-xl 40px;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
@include flex-column;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.editor-wrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
@include flex-column;
|
||||
gap: $spacing-sm;
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
|
||||
.title-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.editor-card,
|
||||
.help-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
html.dark .editor-card,
|
||||
html.dark .help-card {
|
||||
border-color: #3a3d5c;
|
||||
background: #27293d;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
.editor-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
html.dark .card-title {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.editor-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.code-editor :deep(.el-textarea__inner) {
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e4e7ed;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
html.dark .code-editor :deep(.el-textarea__inner) {
|
||||
background: #1e1e2d;
|
||||
border-color: #3a3d5c;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.code-editor :deep(.el-textarea__inner:focus) {
|
||||
border-color: var(--el-color-primary);
|
||||
box-shadow: 0 0 0 1px var(--el-color-primary-light-5);
|
||||
}
|
||||
|
||||
/* Help Card */
|
||||
.help-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.help-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.help-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.help-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.help-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
html.dark .help-item {
|
||||
background: #1e1e2d;
|
||||
}
|
||||
|
||||
.help-item code {
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.help-item span {
|
||||
color: var(--el-text-color-secondary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.proxy-type-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.code-example {
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
padding: 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e4e7ed;
|
||||
margin: 0;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
html.dark .code-example {
|
||||
background: #1e1e2d;
|
||||
border-color: #3a3d5c;
|
||||
color: #e5e7eb;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.docs-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--el-color-primary);
|
||||
gap: $spacing-xs;
|
||||
color: $color-text-muted;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
padding: 12px 16px;
|
||||
background: var(--el-color-primary-light-9);
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
font-size: $font-size-sm;
|
||||
transition: color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
color: $color-text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.docs-link:hover {
|
||||
background: var(--el-color-primary-light-8);
|
||||
.code-editor {
|
||||
height: 100%;
|
||||
|
||||
:deep(.el-textarea__inner) {
|
||||
height: 100% !important;
|
||||
overflow-y: auto;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: $font-size-sm;
|
||||
line-height: 1.6;
|
||||
padding: $spacing-lg;
|
||||
border-radius: $radius-md;
|
||||
background: $color-bg-tertiary;
|
||||
border: 1px solid $color-border-light;
|
||||
resize: none;
|
||||
|
||||
&:focus {
|
||||
border-color: $color-text-light;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
@include mobile {
|
||||
.configure-page {
|
||||
padding: $spacing-xl $spacing-lg;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
@@ -392,10 +190,4 @@ html.dark .code-example {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.help-card {
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
303
web/frpc/src/views/ProxyDetail.vue
Normal file
303
web/frpc/src/views/ProxyDetail.vue
Normal file
@@ -0,0 +1,303 @@
|
||||
<template>
|
||||
<div class="proxy-detail-page">
|
||||
<!-- Fixed Header -->
|
||||
<div class="detail-top">
|
||||
<nav class="breadcrumb">
|
||||
<router-link :to="isStore ? '/proxies?tab=store' : '/proxies'" class="breadcrumb-link">Proxies</router-link>
|
||||
<span class="breadcrumb-sep">›</span>
|
||||
<span class="breadcrumb-current">{{ proxyName }}</span>
|
||||
</nav>
|
||||
|
||||
<template v-if="proxy">
|
||||
<div class="detail-header">
|
||||
<div>
|
||||
<div class="header-title-row">
|
||||
<h2 class="detail-title">{{ proxy.name }}</h2>
|
||||
<span class="status-pill" :class="statusClass">
|
||||
<span class="status-dot"></span>
|
||||
{{ proxy.status }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="header-subtitle">
|
||||
Source: {{ displaySource }} · Type:
|
||||
{{ proxy.type.toUpperCase() }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="isStore" class="header-actions">
|
||||
<ActionButton variant="outline" size="small" @click="handleEdit">
|
||||
Edit
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable Content -->
|
||||
<div v-if="notFound" class="not-found">
|
||||
<p class="empty-text">Proxy not found</p>
|
||||
<p class="empty-hint">The proxy "{{ proxyName }}" does not exist.</p>
|
||||
<ActionButton variant="outline" @click="router.push('/proxies')">
|
||||
Back to Proxies
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
<div v-else-if="proxy" v-loading="loading" class="detail-content">
|
||||
<!-- Error Banner -->
|
||||
<div v-if="proxy.err" class="error-banner">
|
||||
<el-icon class="error-icon"><Warning /></el-icon>
|
||||
<div>
|
||||
<div class="error-title">Connection Error</div>
|
||||
<div class="error-message">{{ proxy.err }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Config Sections -->
|
||||
<ProxyFormLayout
|
||||
v-if="formData"
|
||||
:model-value="formData"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else v-loading="loading" class="loading-area"></div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Warning } from '@element-plus/icons-vue'
|
||||
import ActionButton from '../components/ActionButton.vue'
|
||||
import ProxyFormLayout from '../components/proxy-form/ProxyFormLayout.vue'
|
||||
import { getProxyConfig, getStoreProxy } from '../api/frpc'
|
||||
import { useProxyStore } from '../stores/proxy'
|
||||
import { storeProxyToForm } from '../types'
|
||||
import type { ProxyStatus, ProxyDefinition, ProxyFormData } from '../types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const proxyStore = useProxyStore()
|
||||
|
||||
const proxyName = route.params.name as string
|
||||
const proxy = ref<ProxyStatus | null>(null)
|
||||
const proxyConfig = ref<ProxyDefinition | null>(null)
|
||||
const loading = ref(true)
|
||||
const notFound = ref(false)
|
||||
const isStore = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Try status API first
|
||||
await proxyStore.fetchStatus()
|
||||
const found = proxyStore.proxies.find((p) => p.name === proxyName)
|
||||
|
||||
// Try config API (works for any source)
|
||||
let configDef: ProxyDefinition | null = null
|
||||
try {
|
||||
configDef = await getProxyConfig(proxyName)
|
||||
proxyConfig.value = configDef
|
||||
} catch {
|
||||
// Config not available
|
||||
}
|
||||
|
||||
// Check if proxy is from the store (for Edit/Delete buttons)
|
||||
try {
|
||||
await getStoreProxy(proxyName)
|
||||
isStore.value = true
|
||||
} catch {
|
||||
// Not a store proxy
|
||||
}
|
||||
|
||||
if (found) {
|
||||
proxy.value = found
|
||||
} else if (configDef) {
|
||||
// Proxy not in status (e.g. disabled), build from config definition
|
||||
const block = (configDef as any)[configDef.type]
|
||||
const localIP = block?.localIP || '127.0.0.1'
|
||||
const localPort = block?.localPort
|
||||
const enabled = block?.enabled !== false
|
||||
proxy.value = {
|
||||
name: configDef.name,
|
||||
type: configDef.type,
|
||||
status: enabled ? 'waiting' : 'disabled',
|
||||
err: '',
|
||||
local_addr: localPort != null ? `${localIP}:${localPort}` : '',
|
||||
remote_addr: block?.remotePort != null ? `:${block.remotePort}` : '',
|
||||
plugin: block?.plugin?.type || '',
|
||||
}
|
||||
} else {
|
||||
notFound.value = true
|
||||
}
|
||||
} catch (err: any) {
|
||||
ElMessage.error('Failed to load proxy: ' + err.message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const displaySource = computed(() =>
|
||||
isStore.value ? 'store' : 'config',
|
||||
)
|
||||
|
||||
const statusClass = computed(() => {
|
||||
const s = proxy.value?.status
|
||||
if (s === 'running') return 'running'
|
||||
if (s === 'error') return 'error'
|
||||
if (s === 'disabled') return 'disabled'
|
||||
return 'waiting'
|
||||
})
|
||||
|
||||
const formData = computed((): ProxyFormData | null => {
|
||||
if (!proxyConfig.value) return null
|
||||
return storeProxyToForm(proxyConfig.value)
|
||||
})
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push('/proxies/' + encodeURIComponent(proxyName) + '/edit')
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.proxy-detail-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.detail-top {
|
||||
flex-shrink: 0;
|
||||
padding: $spacing-xl 24px 0;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 24px 160px;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
font-size: $font-size-md;
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
|
||||
.breadcrumb-link {
|
||||
color: $color-text-secondary;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: $color-text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.breadcrumb-sep {
|
||||
color: $color-text-light;
|
||||
}
|
||||
|
||||
.breadcrumb-current {
|
||||
color: $color-text-primary;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: $spacing-xl;
|
||||
}
|
||||
|
||||
.header-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $color-text-primary;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-muted;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: $spacing-sm;
|
||||
padding: 12px 16px;
|
||||
background: var(--color-danger-light);
|
||||
border: 1px solid rgba(245, 108, 108, 0.2);
|
||||
border-radius: $radius-md;
|
||||
margin-bottom: $spacing-xl;
|
||||
|
||||
.error-icon {
|
||||
color: $color-danger;
|
||||
font-size: 18px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: $font-size-md;
|
||||
font-weight: $font-weight-medium;
|
||||
color: $color-danger;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.not-found,
|
||||
.loading-area {
|
||||
text-align: center;
|
||||
padding: 60px $spacing-xl;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: $font-size-lg;
|
||||
font-weight: $font-weight-medium;
|
||||
color: $color-text-secondary;
|
||||
margin: 0 0 $spacing-xs;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-muted;
|
||||
margin: 0 0 $spacing-lg;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
.detail-top {
|
||||
padding: $spacing-xl $spacing-lg 0;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
padding: 0 $spacing-lg $spacing-xl;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
439
web/frpc/src/views/ProxyList.vue
Normal file
439
web/frpc/src/views/ProxyList.vue
Normal file
@@ -0,0 +1,439 @@
|
||||
<template>
|
||||
<div class="proxies-page">
|
||||
<!-- Fixed top area -->
|
||||
<div class="page-top">
|
||||
<!-- Header -->
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">Proxies</h2>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tab-bar">
|
||||
<div class="tab-buttons">
|
||||
<button class="tab-btn" :class="{ active: activeTab === 'status' }" @click="switchTab('status')">Status</button>
|
||||
<button class="tab-btn" :class="{ active: activeTab === 'store' }" @click="switchTab('store')">Store</button>
|
||||
</div>
|
||||
<div class="tab-actions">
|
||||
<ActionButton variant="outline" size="small" @click="refreshData">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
</ActionButton>
|
||||
<ActionButton v-if="activeTab === 'store' && proxyStore.storeEnabled" size="small" @click="handleCreate">
|
||||
+ New Proxy
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Tab Filters -->
|
||||
<template v-if="activeTab === 'status'">
|
||||
<StatusPills v-if="!isMobile" :items="proxyStore.proxies" v-model="statusFilter" />
|
||||
<div class="filter-bar">
|
||||
<el-input v-model="searchText" placeholder="Search..." clearable class="search-input">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<FilterDropdown v-model="sourceFilter" label="Source" :options="sourceOptions" :min-width="140" />
|
||||
<FilterDropdown v-model="typeFilter" label="Type" :options="typeOptions" :min-width="140" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Store Tab Filters -->
|
||||
<template v-if="activeTab === 'store' && proxyStore.storeEnabled">
|
||||
<div class="filter-bar">
|
||||
<el-input v-model="storeSearch" placeholder="Search..." clearable class="search-input">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<FilterDropdown v-model="storeTypeFilter" label="Type" :options="storeTypeOptions" :min-width="140" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable list area -->
|
||||
<div class="page-content">
|
||||
<!-- Status Tab List -->
|
||||
<div v-if="activeTab === 'status'" v-loading="proxyStore.loading">
|
||||
<div v-if="filteredStatus.length > 0" class="proxy-list">
|
||||
<ProxyCard
|
||||
v-for="p in filteredStatus"
|
||||
:key="p.name"
|
||||
:proxy="p"
|
||||
showSource
|
||||
@click="goToDetail(p.name)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="!proxyStore.loading" class="empty-state">
|
||||
<p class="empty-text">No proxies found</p>
|
||||
<p class="empty-hint">Proxies will appear here once configured and connected.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Store Tab List -->
|
||||
<div v-if="activeTab === 'store'" v-loading="proxyStore.storeLoading">
|
||||
<div v-if="!proxyStore.storeEnabled" class="store-disabled">
|
||||
<p>Store is not enabled. Add the following to your frpc configuration:</p>
|
||||
<pre class="config-hint">[store]
|
||||
path = "./frpc_store.json"</pre>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-if="filteredStoreProxies.length > 0" class="proxy-list">
|
||||
<ProxyCard
|
||||
v-for="p in filteredStoreProxies"
|
||||
:key="p.name"
|
||||
:proxy="proxyStore.storeProxyWithStatus(p)"
|
||||
showActions
|
||||
@click="goToDetail(p.name)"
|
||||
@edit="handleEdit"
|
||||
@toggle="handleToggleProxy"
|
||||
@delete="handleDeleteProxy(p.name)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
<p class="empty-text">No store proxies</p>
|
||||
<p class="empty-hint">Click "New Proxy" to create one.</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model="deleteDialog.visible"
|
||||
title="Delete Proxy"
|
||||
:message="deleteDialog.message"
|
||||
confirm-text="Delete"
|
||||
danger
|
||||
:loading="deleteDialog.loading"
|
||||
@confirm="doDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, reactive, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||
import ActionButton from '../components/ActionButton.vue'
|
||||
import StatusPills from '../components/StatusPills.vue'
|
||||
import FilterDropdown from '../components/FilterDropdown.vue'
|
||||
import ProxyCard from '../components/ProxyCard.vue'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
import { useProxyStore } from '../stores/proxy'
|
||||
import { useResponsive } from '../composables/useResponsive'
|
||||
import type { ProxyStatus } from '../types'
|
||||
|
||||
const { isMobile } = useResponsive()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const proxyStore = useProxyStore()
|
||||
|
||||
// Tab
|
||||
const activeTab = computed(() => {
|
||||
const tab = route.query.tab as string
|
||||
return tab === 'store' ? 'store' : 'status'
|
||||
})
|
||||
|
||||
const switchTab = (tab: string) => {
|
||||
router.replace({ query: tab === 'status' ? {} : { tab } })
|
||||
}
|
||||
|
||||
// Filters (local UI state)
|
||||
const statusFilter = ref('')
|
||||
const typeFilter = ref('')
|
||||
const sourceFilter = ref('')
|
||||
const searchText = ref('')
|
||||
const storeSearch = ref('')
|
||||
const storeTypeFilter = ref('')
|
||||
|
||||
// Delete dialog
|
||||
const deleteDialog = reactive({
|
||||
visible: false,
|
||||
title: 'Delete Proxy',
|
||||
message: '',
|
||||
loading: false,
|
||||
name: '',
|
||||
})
|
||||
|
||||
// Source handling
|
||||
const displaySource = (proxy: ProxyStatus): string => {
|
||||
return proxy.source === 'store' ? 'store' : 'config'
|
||||
}
|
||||
|
||||
// Filter options
|
||||
const sourceOptions = computed(() => {
|
||||
const sources = new Set<string>()
|
||||
sources.add('config')
|
||||
sources.add('store')
|
||||
proxyStore.proxies.forEach((p) => {
|
||||
sources.add(displaySource(p))
|
||||
})
|
||||
return Array.from(sources)
|
||||
.sort()
|
||||
.map((s) => ({ label: s, value: s }))
|
||||
})
|
||||
|
||||
const PROXY_TYPE_ORDER = ['tcp', 'udp', 'http', 'https', 'tcpmux', 'stcp', 'sudp', 'xtcp']
|
||||
|
||||
const sortByTypeOrder = (types: string[]) => {
|
||||
return types.sort((a, b) => {
|
||||
const ia = PROXY_TYPE_ORDER.indexOf(a)
|
||||
const ib = PROXY_TYPE_ORDER.indexOf(b)
|
||||
return (ia === -1 ? 999 : ia) - (ib === -1 ? 999 : ib)
|
||||
})
|
||||
}
|
||||
|
||||
const typeOptions = computed(() => {
|
||||
const types = new Set<string>()
|
||||
proxyStore.proxies.forEach((p) => types.add(p.type))
|
||||
return sortByTypeOrder(Array.from(types))
|
||||
.map((t) => ({ label: t.toUpperCase(), value: t }))
|
||||
})
|
||||
|
||||
const storeTypeOptions = computed(() => {
|
||||
const types = new Set<string>()
|
||||
proxyStore.storeProxies.forEach((p) => types.add(p.type))
|
||||
return sortByTypeOrder(Array.from(types))
|
||||
.map((t) => ({ label: t.toUpperCase(), value: t }))
|
||||
})
|
||||
|
||||
// Filtered computeds — Status tab uses proxyStore.proxies (runtime only)
|
||||
const filteredStatus = computed(() => {
|
||||
let result = proxyStore.proxies as ProxyStatus[]
|
||||
|
||||
if (statusFilter.value) {
|
||||
result = result.filter((p) => p.status === statusFilter.value)
|
||||
}
|
||||
|
||||
if (typeFilter.value) {
|
||||
result = result.filter((p) => p.type === typeFilter.value)
|
||||
}
|
||||
|
||||
if (sourceFilter.value) {
|
||||
result = result.filter((p) => displaySource(p) === sourceFilter.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 filteredStoreProxies = computed(() => {
|
||||
let list = proxyStore.storeProxies
|
||||
|
||||
if (storeTypeFilter.value) {
|
||||
list = list.filter((p) => p.type === storeTypeFilter.value)
|
||||
}
|
||||
|
||||
if (storeSearch.value) {
|
||||
const q = storeSearch.value.toLowerCase()
|
||||
list = list.filter((p) => p.name.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
return list
|
||||
})
|
||||
|
||||
// Data fetching
|
||||
const refreshData = () => {
|
||||
proxyStore.fetchStatus().catch((err: any) => {
|
||||
ElMessage.error('Failed to get status: ' + err.message)
|
||||
})
|
||||
proxyStore.fetchStoreProxies()
|
||||
}
|
||||
|
||||
// Navigation
|
||||
const goToDetail = (name: string) => {
|
||||
router.push('/proxies/detail/' + encodeURIComponent(name))
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
router.push('/proxies/create')
|
||||
}
|
||||
|
||||
const handleEdit = (proxy: ProxyStatus) => {
|
||||
router.push('/proxies/' + encodeURIComponent(proxy.name) + '/edit')
|
||||
}
|
||||
|
||||
const handleToggleProxy = async (proxy: ProxyStatus, enabled: boolean) => {
|
||||
try {
|
||||
await proxyStore.toggleProxy(proxy.name, enabled)
|
||||
ElMessage.success(enabled ? 'Proxy enabled' : 'Proxy disabled')
|
||||
} catch (err: any) {
|
||||
ElMessage.error('Operation failed: ' + (err.message || 'Unknown error'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteProxy = (name: string) => {
|
||||
deleteDialog.name = name
|
||||
deleteDialog.message = `Are you sure you want to delete "${name}"? This action cannot be undone.`
|
||||
deleteDialog.visible = true
|
||||
}
|
||||
|
||||
const doDelete = async () => {
|
||||
deleteDialog.loading = true
|
||||
try {
|
||||
await proxyStore.deleteProxy(deleteDialog.name)
|
||||
ElMessage.success('Proxy deleted')
|
||||
deleteDialog.visible = false
|
||||
proxyStore.fetchStatus()
|
||||
} catch (err: any) {
|
||||
ElMessage.error('Delete failed: ' + (err.message || 'Unknown error'))
|
||||
} finally {
|
||||
deleteDialog.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.proxies-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-top {
|
||||
flex-shrink: 0;
|
||||
padding: $spacing-xl 40px 0;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 40px $spacing-xl;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: $spacing-xl;
|
||||
}
|
||||
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid $color-border-lighter;
|
||||
margin-bottom: $spacing-xl;
|
||||
}
|
||||
|
||||
.tab-buttons {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.tab-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: $spacing-sm $spacing-xl;
|
||||
font-size: $font-size-md;
|
||||
color: $color-text-muted;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all $transition-fast;
|
||||
|
||||
&:hover { color: $color-text-primary; }
|
||||
&.active {
|
||||
color: $color-text-primary;
|
||||
border-bottom-color: $color-text-primary;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
margin-top: $spacing-lg;
|
||||
padding-bottom: $spacing-lg;
|
||||
|
||||
:deep(.search-input) {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
.proxy-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px $spacing-xl;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: $font-size-lg;
|
||||
font-weight: $font-weight-medium;
|
||||
color: $color-text-secondary;
|
||||
margin: 0 0 $spacing-xs;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-muted;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.store-disabled {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.config-hint {
|
||||
display: inline-block;
|
||||
text-align: left;
|
||||
background: $color-bg-hover;
|
||||
padding: 12px 20px;
|
||||
border-radius: $radius-sm;
|
||||
font-size: $font-size-sm;
|
||||
margin-top: $spacing-md;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
.page-top {
|
||||
padding: $spacing-lg $spacing-lg 0;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 0 $spacing-lg $spacing-lg;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
:deep(.search-input) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
206
web/frpc/src/views/VisitorDetail.vue
Normal file
206
web/frpc/src/views/VisitorDetail.vue
Normal file
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<div class="visitor-detail-page">
|
||||
<!-- Fixed Header -->
|
||||
<div class="detail-top">
|
||||
<nav class="breadcrumb">
|
||||
<router-link to="/visitors" class="breadcrumb-link">Visitors</router-link>
|
||||
<span class="breadcrumb-sep">›</span>
|
||||
<span class="breadcrumb-current">{{ visitorName }}</span>
|
||||
</nav>
|
||||
|
||||
<template v-if="visitor">
|
||||
<div class="detail-header">
|
||||
<div>
|
||||
<h2 class="detail-title">{{ visitor.name }}</h2>
|
||||
<p class="header-subtitle">Type: {{ visitor.type.toUpperCase() }}</p>
|
||||
</div>
|
||||
<div v-if="isStore" class="header-actions">
|
||||
<ActionButton variant="outline" size="small" @click="handleEdit">
|
||||
Edit
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="notFound" class="not-found">
|
||||
<p class="empty-text">Visitor not found</p>
|
||||
<p class="empty-hint">The visitor "{{ visitorName }}" does not exist.</p>
|
||||
<ActionButton variant="outline" @click="router.push('/visitors')">
|
||||
Back to Visitors
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
<div v-else-if="visitor" v-loading="loading" class="detail-content">
|
||||
<VisitorFormLayout
|
||||
v-if="formData"
|
||||
:model-value="formData"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else v-loading="loading" class="loading-area"></div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import ActionButton from '../components/ActionButton.vue'
|
||||
import VisitorFormLayout from '../components/visitor-form/VisitorFormLayout.vue'
|
||||
import { getVisitorConfig, getStoreVisitor } from '../api/frpc'
|
||||
import type { VisitorDefinition, VisitorFormData } from '../types'
|
||||
import { storeVisitorToForm } from '../types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const visitorName = route.params.name as string
|
||||
const visitor = ref<VisitorDefinition | null>(null)
|
||||
const loading = ref(true)
|
||||
const notFound = ref(false)
|
||||
const isStore = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const config = await getVisitorConfig(visitorName)
|
||||
visitor.value = config
|
||||
|
||||
// Check if visitor is from the store (for Edit/Delete buttons)
|
||||
try {
|
||||
await getStoreVisitor(visitorName)
|
||||
isStore.value = true
|
||||
} catch {
|
||||
// Not a store visitor — Edit/Delete not available
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err?.status === 404 || err?.response?.status === 404) {
|
||||
notFound.value = true
|
||||
} else {
|
||||
notFound.value = true
|
||||
ElMessage.error('Failed to load visitor: ' + err.message)
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const formData = computed<VisitorFormData | null>(() => {
|
||||
if (!visitor.value) return null
|
||||
return storeVisitorToForm(visitor.value)
|
||||
})
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push('/visitors/' + encodeURIComponent(visitorName) + '/edit')
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.visitor-detail-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.detail-top {
|
||||
flex-shrink: 0;
|
||||
padding: $spacing-xl 24px 0;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 24px 160px;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
font-size: $font-size-md;
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
|
||||
.breadcrumb-link {
|
||||
color: $color-text-secondary;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: $color-text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.breadcrumb-sep {
|
||||
color: $color-text-light;
|
||||
}
|
||||
|
||||
.breadcrumb-current {
|
||||
color: $color-text-primary;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: $spacing-xl;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $color-text-primary;
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-muted;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.not-found,
|
||||
.loading-area {
|
||||
text-align: center;
|
||||
padding: 60px $spacing-xl;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: $font-size-lg;
|
||||
font-weight: $font-weight-medium;
|
||||
color: $color-text-secondary;
|
||||
margin: 0 0 $spacing-xs;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-muted;
|
||||
margin: 0 0 $spacing-lg;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
.detail-top {
|
||||
padding: $spacing-xl $spacing-lg 0;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
padding: 0 $spacing-lg $spacing-xl;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,16 +1,18 @@
|
||||
<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 class="edit-header">
|
||||
<nav class="breadcrumb">
|
||||
<router-link to="/visitors" class="breadcrumb-item">Visitors</router-link>
|
||||
<span class="breadcrumb-separator">›</span>
|
||||
<span class="breadcrumb-current">{{ isEditing ? 'Edit Visitor' : 'New Visitor' }}</span>
|
||||
</nav>
|
||||
<div class="header-actions">
|
||||
<ActionButton variant="outline" size="small" @click="goBack">Cancel</ActionButton>
|
||||
<ActionButton size="small" :loading="saving" @click="handleSave">
|
||||
{{ isEditing ? 'Update' : 'Create' }}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-loading="pageLoading" class="edit-content">
|
||||
<el-form
|
||||
@@ -20,254 +22,49 @@
|
||||
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>
|
||||
<VisitorFormLayout v-model="form" :editing="isEditing" />
|
||||
</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>
|
||||
<ConfirmDialog
|
||||
v-model="leaveDialogVisible"
|
||||
title="Unsaved Changes"
|
||||
message="You have unsaved changes. Are you sure you want to leave?"
|
||||
@confirm="handleLeaveConfirm"
|
||||
@cancel="handleLeaveCancel"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ArrowLeft, ArrowDown } from '@element-plus/icons-vue'
|
||||
import ActionButton from '../components/ActionButton.vue'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
import VisitorFormLayout from '../components/visitor-form/VisitorFormLayout.vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import {
|
||||
type VisitorFormData,
|
||||
createDefaultVisitorForm,
|
||||
formToStoreVisitor,
|
||||
storeVisitorToForm,
|
||||
} from '../types/proxy'
|
||||
import {
|
||||
getStoreVisitor,
|
||||
createStoreVisitor,
|
||||
updateStoreVisitor,
|
||||
} from '../api/frpc'
|
||||
} from '../types'
|
||||
import { getStoreVisitor } from '../api/frpc'
|
||||
import { useVisitorStore } from '../stores/visitor'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const visitorStore = useVisitorStore()
|
||||
|
||||
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 dirty = ref(false)
|
||||
const formSaved = ref(false)
|
||||
const trackChanges = ref(false)
|
||||
|
||||
const formRules: FormRules = {
|
||||
name: [
|
||||
@@ -310,22 +107,60 @@ const formRules: FormRules = {
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.push('/')
|
||||
router.back()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => form.value,
|
||||
() => {
|
||||
if (trackChanges.value) {
|
||||
dirty.value = true
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
const leaveDialogVisible = ref(false)
|
||||
const leaveResolve = ref<((value: boolean) => void) | null>(null)
|
||||
|
||||
onBeforeRouteLeave(async () => {
|
||||
if (dirty.value && !formSaved.value) {
|
||||
leaveDialogVisible.value = true
|
||||
return new Promise<boolean>((resolve) => {
|
||||
leaveResolve.value = resolve
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const handleLeaveConfirm = () => {
|
||||
leaveDialogVisible.value = false
|
||||
leaveResolve.value?.(true)
|
||||
}
|
||||
|
||||
const handleLeaveCancel = () => {
|
||||
leaveDialogVisible.value = false
|
||||
leaveResolve.value?.(false)
|
||||
}
|
||||
|
||||
const loadVisitor = async () => {
|
||||
const name = route.params.name as string
|
||||
if (!name) return
|
||||
|
||||
trackChanges.value = false
|
||||
dirty.value = false
|
||||
pageLoading.value = true
|
||||
try {
|
||||
const res = await getStoreVisitor(name)
|
||||
form.value = storeVisitorToForm(res)
|
||||
await nextTick()
|
||||
} catch (err: any) {
|
||||
ElMessage.error('Failed to load visitor: ' + err.message)
|
||||
router.push('/')
|
||||
router.push('/visitors')
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
nextTick(() => {
|
||||
trackChanges.value = true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,13 +178,14 @@ const handleSave = async () => {
|
||||
try {
|
||||
const data = formToStoreVisitor(form.value)
|
||||
if (isEditing.value) {
|
||||
await updateStoreVisitor(form.value.name, data)
|
||||
await visitorStore.updateVisitor(form.value.name, data)
|
||||
ElMessage.success('Visitor updated')
|
||||
} else {
|
||||
await createStoreVisitor(data)
|
||||
await visitorStore.createVisitor(data)
|
||||
ElMessage.success('Visitor created')
|
||||
}
|
||||
router.push('/')
|
||||
formSaved.value = true
|
||||
router.push('/visitors')
|
||||
} catch (err: any) {
|
||||
ElMessage.error('Operation failed: ' + (err.message || 'Unknown error'))
|
||||
} finally {
|
||||
@@ -360,6 +196,8 @@ const handleSave = async () => {
|
||||
onMounted(() => {
|
||||
if (isEditing.value) {
|
||||
loadVisitor()
|
||||
} else {
|
||||
trackChanges.value = true
|
||||
}
|
||||
})
|
||||
|
||||
@@ -371,14 +209,48 @@ watch(
|
||||
loadVisitor()
|
||||
return
|
||||
}
|
||||
trackChanges.value = false
|
||||
form.value = createDefaultVisitorForm()
|
||||
dirty.value = false
|
||||
nextTick(() => {
|
||||
trackChanges.value = true
|
||||
})
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style scoped lang="scss">
|
||||
.visitor-edit-page {
|
||||
padding-bottom: 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.edit-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.edit-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 24px 160px;
|
||||
|
||||
> * {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Breadcrumb */
|
||||
@@ -387,20 +259,6 @@ watch(
|
||||
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 {
|
||||
@@ -422,185 +280,13 @@ watch(
|
||||
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;
|
||||
@include mobile {
|
||||
.edit-header {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.type-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
padding: 12px 20px;
|
||||
.edit-content {
|
||||
padding: 0 16px 160px;
|
||||
}
|
||||
}
|
||||
</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>
|
||||
|
||||
371
web/frpc/src/views/VisitorList.vue
Normal file
371
web/frpc/src/views/VisitorList.vue
Normal file
@@ -0,0 +1,371 @@
|
||||
<template>
|
||||
<div class="visitors-page">
|
||||
<!-- Header -->
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">Visitors</h2>
|
||||
</div>
|
||||
|
||||
<!-- Tab bar -->
|
||||
<div class="tab-bar">
|
||||
<div class="tab-buttons">
|
||||
<button class="tab-btn active">Store</button>
|
||||
</div>
|
||||
<div class="tab-actions">
|
||||
<ActionButton variant="outline" size="small" @click="fetchData">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
</ActionButton>
|
||||
<ActionButton v-if="visitorStore.storeEnabled" size="small" @click="handleCreate">
|
||||
+ New Visitor
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-loading="visitorStore.loading">
|
||||
<div v-if="!visitorStore.storeEnabled" class="store-disabled">
|
||||
<p>Store is not enabled. Add the following to your frpc configuration:</p>
|
||||
<pre class="config-hint">[store]
|
||||
path = "./frpc_store.json"</pre>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="filter-bar">
|
||||
<el-input v-model="searchText" placeholder="Search..." clearable class="search-input">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<FilterDropdown v-model="typeFilter" label="Type" :options="typeOptions" :min-width="140" />
|
||||
</div>
|
||||
|
||||
<div v-if="filteredVisitors.length > 0" class="visitor-list">
|
||||
<div v-for="v in filteredVisitors" :key="v.name" class="visitor-card" @click="goToDetail(v.name)">
|
||||
<div class="card-left">
|
||||
<div class="card-header">
|
||||
<span class="visitor-name">{{ v.name }}</span>
|
||||
<span class="type-tag">{{ v.type.toUpperCase() }}</span>
|
||||
</div>
|
||||
<div v-if="getServerName(v)" class="card-meta">{{ getServerName(v) }}</div>
|
||||
</div>
|
||||
<div class="card-right">
|
||||
<div @click.stop>
|
||||
<PopoverMenu :width="120" placement="bottom-end">
|
||||
<template #trigger>
|
||||
<ActionButton variant="outline" size="small">
|
||||
<el-icon><MoreFilled /></el-icon>
|
||||
</ActionButton>
|
||||
</template>
|
||||
<PopoverMenuItem @click="handleEdit(v)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
Edit
|
||||
</PopoverMenuItem>
|
||||
<PopoverMenuItem danger @click="handleDelete(v.name)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
Delete
|
||||
</PopoverMenuItem>
|
||||
</PopoverMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
<p class="empty-text">No visitors found</p>
|
||||
<p class="empty-hint">Click "New Visitor" to create one.</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog v-model="deleteDialog.visible" title="Delete Visitor"
|
||||
:message="deleteDialog.message" confirm-text="Delete" danger
|
||||
:loading="deleteDialog.loading" @confirm="doDelete" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Search, Refresh, MoreFilled, Edit, Delete } from '@element-plus/icons-vue'
|
||||
import ActionButton from '../components/ActionButton.vue'
|
||||
import FilterDropdown from '../components/FilterDropdown.vue'
|
||||
import PopoverMenu from '../components/PopoverMenu.vue'
|
||||
import PopoverMenuItem from '../components/PopoverMenuItem.vue'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
import { useVisitorStore } from '../stores/visitor'
|
||||
import type { VisitorDefinition } from '../types'
|
||||
|
||||
const router = useRouter()
|
||||
const visitorStore = useVisitorStore()
|
||||
|
||||
const searchText = ref('')
|
||||
const typeFilter = ref('')
|
||||
|
||||
const deleteDialog = reactive({
|
||||
visible: false,
|
||||
message: '',
|
||||
loading: false,
|
||||
name: '',
|
||||
})
|
||||
|
||||
const typeOptions = computed(() => {
|
||||
return [
|
||||
{ label: 'STCP', value: 'stcp' },
|
||||
{ label: 'SUDP', value: 'sudp' },
|
||||
{ label: 'XTCP', value: 'xtcp' },
|
||||
]
|
||||
})
|
||||
|
||||
const filteredVisitors = computed(() => {
|
||||
let list = visitorStore.storeVisitors
|
||||
|
||||
if (typeFilter.value) {
|
||||
list = list.filter((v) => v.type === typeFilter.value)
|
||||
}
|
||||
|
||||
if (searchText.value) {
|
||||
const q = searchText.value.toLowerCase()
|
||||
list = list.filter((v) => v.name.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
return list
|
||||
})
|
||||
|
||||
const getServerName = (v: VisitorDefinition): string => {
|
||||
const block = (v as any)[v.type]
|
||||
return block?.serverName || ''
|
||||
}
|
||||
|
||||
const fetchData = () => {
|
||||
visitorStore.fetchStoreVisitors()
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
router.push('/visitors/create')
|
||||
}
|
||||
|
||||
const handleEdit = (v: VisitorDefinition) => {
|
||||
router.push('/visitors/' + encodeURIComponent(v.name) + '/edit')
|
||||
}
|
||||
|
||||
const goToDetail = (name: string) => {
|
||||
router.push('/visitors/detail/' + encodeURIComponent(name))
|
||||
}
|
||||
|
||||
const handleDelete = (name: string) => {
|
||||
deleteDialog.name = name
|
||||
deleteDialog.message = `Are you sure you want to delete visitor "${name}"? This action cannot be undone.`
|
||||
deleteDialog.visible = true
|
||||
}
|
||||
|
||||
const doDelete = async () => {
|
||||
deleteDialog.loading = true
|
||||
try {
|
||||
await visitorStore.deleteVisitor(deleteDialog.name)
|
||||
ElMessage.success('Visitor deleted')
|
||||
deleteDialog.visible = false
|
||||
fetchData()
|
||||
} catch (err: any) {
|
||||
ElMessage.error('Delete failed: ' + (err.message || 'Unknown error'))
|
||||
} finally {
|
||||
deleteDialog.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.visitors-page {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: $spacing-xl 40px;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: $spacing-xl;
|
||||
}
|
||||
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid $color-border-lighter;
|
||||
margin-bottom: $spacing-xl;
|
||||
}
|
||||
|
||||
.tab-buttons {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: $spacing-sm $spacing-xl;
|
||||
font-size: $font-size-md;
|
||||
color: $color-text-muted;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all $transition-fast;
|
||||
|
||||
&:hover {
|
||||
color: $color-text-primary;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: $color-text-primary;
|
||||
border-bottom-color: $color-text-primary;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
margin-bottom: $spacing-xl;
|
||||
|
||||
:deep(.search-input) {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
.visitor-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.visitor-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: $color-bg-primary;
|
||||
border: 1px solid $color-border-lighter;
|
||||
border-radius: $radius-md;
|
||||
padding: 14px 20px;
|
||||
cursor: pointer;
|
||||
transition: all $transition-medium;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
border-color: $color-border;
|
||||
}
|
||||
}
|
||||
|
||||
.card-left {
|
||||
@include flex-column;
|
||||
gap: $spacing-sm;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.visitor-name {
|
||||
font-size: $font-size-lg;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $color-text-primary;
|
||||
}
|
||||
|
||||
.type-tag {
|
||||
font-size: $font-size-xs;
|
||||
font-weight: $font-weight-medium;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: $color-bg-muted;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.card-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.store-disabled {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.config-hint {
|
||||
display: inline-block;
|
||||
text-align: left;
|
||||
background: $color-bg-hover;
|
||||
padding: 12px 20px;
|
||||
border-radius: $radius-sm;
|
||||
font-size: $font-size-sm;
|
||||
margin-top: $spacing-md;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px $spacing-xl;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: $font-size-lg;
|
||||
font-weight: $font-weight-medium;
|
||||
color: $color-text-secondary;
|
||||
margin: 0 0 $spacing-xs;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-muted;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
.visitors-page {
|
||||
padding: $spacing-lg;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
flex-wrap: wrap;
|
||||
|
||||
:deep(.search-input) {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.visitor-card {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.card-right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user