forked from Mxmilu666/frp
web/frpc: redesign frpc dashboard with sidebar nav, proxy/visitor list and detail views (#5237)
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user