web/frpc: redesign frpc dashboard with sidebar nav, proxy/visitor list and detail views (#5237)

This commit is contained in:
fatedier
2026-03-16 09:44:30 +08:00
committed by GitHub
parent ff4ad2f907
commit 85e8e2c830
71 changed files with 5908 additions and 4292 deletions

View File

@@ -0,0 +1,144 @@
<template>
<button
type="button"
class="action-button"
:class="[variant, size, { 'is-loading': loading, 'is-danger': danger }]"
:disabled="disabled || loading"
@click="handleClick"
>
<div v-if="loading" class="spinner"></div>
<span v-if="loading && loadingText">{{ loadingText }}</span>
<slot v-else />
</button>
</template>
<script setup lang="ts">
interface Props {
variant?: 'primary' | 'secondary' | 'outline'
size?: 'small' | 'medium' | 'large'
disabled?: boolean
loading?: boolean
loadingText?: string
danger?: boolean
}
const props = withDefaults(defineProps<Props>(), {
variant: 'primary',
size: 'medium',
disabled: false,
loading: false,
loadingText: '',
danger: false,
})
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
const handleClick = (event: MouseEvent) => {
if (!props.disabled && !props.loading) {
emit('click', event)
}
}
</script>
<style scoped lang="scss">
.action-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: $spacing-sm;
border-radius: $radius-md;
font-weight: $font-weight-medium;
cursor: pointer;
transition: all $transition-fast;
border: 1px solid transparent;
white-space: nowrap;
.spinner {
width: 14px;
height: 14px;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.75s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
&.small {
padding: 5px $spacing-md;
font-size: $font-size-sm;
}
&.medium {
padding: $spacing-sm $spacing-lg;
font-size: $font-size-md;
}
&.large {
padding: 10px $spacing-xl;
font-size: $font-size-lg;
}
&.primary {
background: $color-btn-primary;
border-color: $color-btn-primary;
color: #fff;
&:hover:not(:disabled) {
background: $color-btn-primary-hover;
border-color: $color-btn-primary-hover;
}
}
&.secondary {
background: $color-bg-hover;
border-color: $color-border-light;
color: $color-text-primary;
&:hover:not(:disabled) {
border-color: $color-border;
}
}
&.outline {
background: transparent;
border-color: $color-border;
color: $color-text-primary;
&:hover:not(:disabled) {
background: $color-bg-hover;
}
}
&.is-danger {
&.primary {
background: $color-danger;
border-color: $color-danger;
&:hover:not(:disabled) {
background: $color-danger-dark;
border-color: $color-danger-dark;
}
}
&.outline, &.secondary {
color: $color-danger;
&:hover:not(:disabled) {
border-color: $color-danger;
background: rgba(239, 68, 68, 0.08);
}
}
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
</style>

View File

@@ -0,0 +1,142 @@
<template>
<el-dialog
v-model="visible"
:title="title"
:width="dialogWidth"
:destroy-on-close="destroyOnClose"
:close-on-click-modal="closeOnClickModal"
:close-on-press-escape="closeOnPressEscape"
:append-to-body="appendToBody"
:top="dialogTop"
:fullscreen="isMobile"
class="base-dialog"
:class="{ 'mobile-dialog': isMobile }"
>
<slot />
<template v-if="$slots.footer" #footer>
<slot name="footer" />
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useResponsive } from '../composables/useResponsive'
const props = withDefaults(
defineProps<{
modelValue: boolean
title: string
width?: string
destroyOnClose?: boolean
closeOnClickModal?: boolean
closeOnPressEscape?: boolean
appendToBody?: boolean
top?: string
}>(),
{
width: '480px',
destroyOnClose: true,
closeOnClickModal: true,
closeOnPressEscape: true,
appendToBody: false,
top: '15vh',
},
)
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
const { isMobile } = useResponsive()
const dialogWidth = computed(() => {
if (isMobile.value) return '100%'
return props.width
})
const dialogTop = computed(() => {
if (isMobile.value) return '0'
return props.top
})
</script>
<style lang="scss">
.base-dialog.el-dialog {
border-radius: 16px;
.el-dialog__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px;
min-height: 42px;
margin: 0;
position: relative;
&::after {
content: "";
position: absolute;
bottom: 0;
left: 8px;
right: 8px;
height: 1px;
background: $color-border-lighter;
}
}
.el-dialog__title {
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
}
.el-dialog__body {
padding: 16px 8px;
}
.el-dialog__headerbtn {
position: static;
width: 32px;
height: 32px;
@include flex-center;
border-radius: $radius-sm;
transition: background $transition-fast;
&:hover {
background: $color-bg-hover;
}
}
.el-dialog__footer {
padding: 8px;
display: flex;
justify-content: flex-end;
gap: 8px;
}
&.mobile-dialog {
border-radius: 0;
margin: 0;
height: 100%;
max-height: 100dvh;
display: flex;
flex-direction: column;
.el-dialog__body {
flex: 1;
overflow-y: auto;
padding: 16px 12px;
}
.el-dialog__footer {
padding: 8px 12px;
padding-bottom: calc(8px + env(safe-area-inset-bottom));
}
}
}
</style>

View File

@@ -0,0 +1,249 @@
<template>
<!-- Edit mode: use el-form-item for validation -->
<el-form-item v-if="!readonly" :label="label" :prop="prop" :class="($attrs.class as string)">
<!-- text -->
<el-input
v-if="type === 'text'"
:model-value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
@update:model-value="$emit('update:modelValue', $event)"
/>
<!-- number -->
<el-input
v-else-if="type === 'number'"
:model-value="modelValue != null ? String(modelValue) : ''"
:placeholder="placeholder"
:disabled="disabled"
@update:model-value="handleNumberInput($event)"
/>
<!-- switch -->
<div v-else-if="type === 'switch'" class="config-field-switch-wrap">
<el-switch
:model-value="modelValue"
:disabled="disabled"
size="small"
@update:model-value="$emit('update:modelValue', $event)"
/>
<span v-if="tip" class="config-field-switch-tip">{{ tip }}</span>
</div>
<!-- select -->
<PopoverMenu
v-else-if="type === 'select'"
:model-value="modelValue"
:display-value="selectDisplayValue"
:disabled="disabled"
:width="selectWidth"
selectable
full-width
filterable
:filter-placeholder="placeholder || 'Select...'"
@update:model-value="$emit('update:modelValue', $event)"
>
<template #default="{ filterText }">
<PopoverMenuItem
v-for="opt in filteredOptions(filterText)"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</PopoverMenuItem>
</template>
</PopoverMenu>
<!-- password -->
<el-input
v-else-if="type === 'password'"
:model-value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
type="password"
show-password
@update:model-value="$emit('update:modelValue', $event)"
/>
<!-- kv -->
<KeyValueEditor
v-else-if="type === 'kv'"
:model-value="modelValue"
:key-placeholder="keyPlaceholder"
:value-placeholder="valuePlaceholder"
@update:model-value="$emit('update:modelValue', $event)"
/>
<!-- tags (string array) -->
<StringListEditor
v-else-if="type === 'tags'"
:model-value="modelValue || []"
:placeholder="placeholder"
@update:model-value="$emit('update:modelValue', $event)"
/>
<div v-if="tip && type !== 'switch'" class="config-field-tip">{{ tip }}</div>
</el-form-item>
<!-- Readonly mode: plain display -->
<div v-else class="config-field-readonly" :class="($attrs.class as string)">
<div class="config-field-label">{{ label }}</div>
<!-- switch readonly -->
<el-switch
v-if="type === 'switch'"
:model-value="modelValue"
disabled
size="small"
/>
<!-- kv readonly -->
<KeyValueEditor
v-else-if="type === 'kv'"
:model-value="modelValue || []"
:key-placeholder="keyPlaceholder"
:value-placeholder="valuePlaceholder"
readonly
/>
<!-- tags readonly -->
<StringListEditor
v-else-if="type === 'tags'"
:model-value="modelValue || []"
readonly
/>
<!-- text/number/select/password readonly -->
<el-input
v-else
:model-value="displayValue"
disabled
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import KeyValueEditor from './KeyValueEditor.vue'
import StringListEditor from './StringListEditor.vue'
import PopoverMenu from './PopoverMenu.vue'
import PopoverMenuItem from './PopoverMenuItem.vue'
const props = withDefaults(
defineProps<{
label: string
type?: 'text' | 'number' | 'switch' | 'select' | 'password' | 'kv' | 'tags'
readonly?: boolean
modelValue?: any
placeholder?: string
disabled?: boolean
tip?: string
prop?: string
options?: Array<{ label: string; value: string | number }>
min?: number
max?: number
keyPlaceholder?: string
valuePlaceholder?: string
}>(),
{
type: 'text',
readonly: false,
modelValue: undefined,
placeholder: '',
disabled: false,
tip: '',
prop: '',
options: () => [],
min: undefined,
max: undefined,
keyPlaceholder: 'Key',
valuePlaceholder: 'Value',
},
)
const emit = defineEmits<{
'update:modelValue': [value: any]
}>()
const handleNumberInput = (val: string) => {
if (val === '') {
emit('update:modelValue', undefined)
return
}
const num = Number(val)
if (!isNaN(num)) {
let clamped = num
if (props.min != null && clamped < props.min) clamped = props.min
if (props.max != null && clamped > props.max) clamped = props.max
emit('update:modelValue', clamped)
}
}
const selectDisplayValue = computed(() => {
const opt = props.options.find((o) => o.value === props.modelValue)
return opt ? opt.label : ''
})
const selectWidth = computed(() => {
return Math.max(160, ...props.options.map((o) => o.label.length * 10 + 60))
})
const filteredOptions = (filterText: string) => {
if (!filterText) return props.options
const lower = filterText.toLowerCase()
return props.options.filter((o) => o.label.toLowerCase().includes(lower))
}
const displayValue = computed(() => {
if (props.modelValue == null || props.modelValue === '') return '—'
if (props.type === 'select') {
const opt = props.options.find((o) => o.value === props.modelValue)
return opt ? opt.label : String(props.modelValue)
}
if (props.type === 'password') {
return props.modelValue ? '••••••' : '—'
}
return String(props.modelValue)
})
</script>
<style scoped>
.config-field-switch-wrap {
display: flex;
align-items: center;
gap: 8px;
min-height: 32px;
width: 100%;
}
.config-field-switch-tip {
font-size: 12px;
color: var(--color-text-muted);
}
.config-field-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
.config-field-readonly {
margin-bottom: 16px;
}
.config-field-label {
font-size: 13px;
font-weight: 500;
color: var(--color-text-secondary);
margin-bottom: 6px;
line-height: 1;
}
.config-field-readonly :deep(*) {
cursor: default !important;
}
.config-field-readonly :deep(.el-input.is-disabled .el-input__wrapper) {
background: var(--color-bg-tertiary);
box-shadow: 0 0 0 1px var(--color-border-lighter) inset;
}
.config-field-readonly :deep(.el-input.is-disabled .el-input__inner) {
color: var(--color-text-primary);
-webkit-text-fill-color: var(--color-text-primary);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.config-field-readonly :deep(.el-switch.is-disabled) {
opacity: 1;
}
</style>

View File

@@ -0,0 +1,185 @@
<template>
<div class="config-section-card">
<!-- Collapsible: header is a separate clickable area -->
<template v-if="collapsible">
<div
v-if="title"
class="section-header clickable"
@click="handleToggle"
>
<h3 class="section-title">{{ title }}</h3>
<div class="section-header-right">
<span v-if="readonly && !hasValue" class="not-configured-badge">
Not configured
</span>
<el-icon v-if="canToggle" class="collapse-arrow" :class="{ expanded }">
<ArrowDown />
</el-icon>
</div>
</div>
<div class="collapse-wrapper" :class="{ expanded }">
<div class="collapse-inner">
<div class="section-body">
<slot />
</div>
</div>
</div>
</template>
<!-- Non-collapsible: title and content in one area -->
<template v-else>
<div class="section-body">
<h3 v-if="title" class="section-title section-title-inline">{{ title }}</h3>
<slot />
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ArrowDown } from '@element-plus/icons-vue'
const props = withDefaults(
defineProps<{
title?: string
collapsible?: boolean
readonly?: boolean
hasValue?: boolean
}>(),
{
title: '',
collapsible: false,
readonly: false,
hasValue: true,
},
)
const computeInitial = () => {
if (!props.collapsible) return true
return props.hasValue
}
const expanded = ref(computeInitial())
// Only auto-expand when hasValue goes from false to true (async data loaded)
// Never auto-collapse — don't override user interaction
watch(
() => props.hasValue,
(newVal, oldVal) => {
if (newVal && !oldVal && props.collapsible) {
expanded.value = true
}
},
)
const canToggle = computed(() => {
if (!props.collapsible) return false
if (props.readonly && !props.hasValue) return false
return true
})
const handleToggle = () => {
if (canToggle.value) {
expanded.value = !expanded.value
}
}
</script>
<style scoped lang="scss">
.config-section-card {
background: var(--el-bg-color);
border: 1px solid var(--color-border-lighter);
border-radius: 12px;
margin-bottom: 16px;
overflow: hidden;
}
/* Collapsible header */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 20px;
}
.section-header.clickable {
cursor: pointer;
transition: background 0.15s;
}
.section-header.clickable:hover {
background: var(--color-bg-hover);
}
.section-title {
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary);
margin: 0;
}
/* Inline title for non-collapsible sections */
.section-title-inline {
margin-bottom: 16px;
}
.section-header-right {
display: flex;
align-items: center;
gap: 8px;
}
.not-configured-badge {
font-size: 11px;
color: var(--color-text-light);
background: var(--color-bg-muted);
padding: 2px 8px;
border-radius: 4px;
}
.collapse-arrow {
transition: transform 0.3s;
color: var(--color-text-muted);
}
.collapse-arrow.expanded {
transform: rotate(-180deg);
}
/* Grid-based collapse animation */
.collapse-wrapper {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.25s ease;
}
.collapse-wrapper.expanded {
grid-template-rows: 1fr;
}
.collapse-inner {
overflow: hidden;
}
.section-body {
padding: 20px 20px 12px;
display: flex;
flex-direction: column;
gap: 16px;
}
.section-body :deep(.el-form-item) {
margin-bottom: 0;
}
.section-body :deep(.config-field-readonly) {
margin-bottom: 0;
}
@include mobile {
.section-body {
padding: 16px;
}
}
</style>

View File

@@ -0,0 +1,84 @@
<template>
<BaseDialog
v-model="visible"
:title="title"
width="400px"
:close-on-click-modal="false"
:append-to-body="true"
>
<p class="confirm-message">{{ message }}</p>
<template #footer>
<div class="dialog-footer">
<ActionButton variant="outline" @click="handleCancel">
{{ cancelText }}
</ActionButton>
<ActionButton
:danger="danger"
:loading="loading"
@click="handleConfirm"
>
{{ confirmText }}
</ActionButton>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import BaseDialog from './BaseDialog.vue'
import ActionButton from './ActionButton.vue'
const props = withDefaults(
defineProps<{
modelValue: boolean
title: string
message: string
confirmText?: string
cancelText?: string
danger?: boolean
loading?: boolean
}>(),
{
confirmText: 'Confirm',
cancelText: 'Cancel',
danger: false,
loading: false,
},
)
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'confirm'): void
(e: 'cancel'): void
}>()
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
const handleConfirm = () => {
emit('confirm')
}
const handleCancel = () => {
visible.value = false
emit('cancel')
}
</script>
<style scoped lang="scss">
.confirm-message {
margin: 0;
font-size: $font-size-md;
color: $color-text-secondary;
line-height: 1.6;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: $spacing-md;
}
</style>

View File

@@ -0,0 +1,102 @@
<template>
<PopoverMenu
:model-value="modelValue"
:width="width"
placement="bottom-start"
selectable
:display-value="displayLabel"
@update:model-value="$emit('update:modelValue', $event as string)"
>
<template #trigger>
<button class="filter-trigger" :class="{ 'has-value': modelValue }" :style="minWidth && !isMobile ? { minWidth: minWidth + 'px' } : undefined">
<span class="filter-label">{{ label }}:</span>
<span class="filter-value">{{ displayLabel }}</span>
<el-icon class="filter-arrow"><ArrowDown /></el-icon>
</button>
</template>
<PopoverMenuItem value="">{{ allLabel }}</PopoverMenuItem>
<PopoverMenuItem
v-for="opt in options"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</PopoverMenuItem>
</PopoverMenu>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ArrowDown } from '@element-plus/icons-vue'
import PopoverMenu from './PopoverMenu.vue'
import PopoverMenuItem from './PopoverMenuItem.vue'
import { useResponsive } from '../composables/useResponsive'
const { isMobile } = useResponsive()
interface Props {
modelValue: string
label: string
options: Array<{ label: string; value: string }>
allLabel?: string
width?: number
minWidth?: number
}
const props = withDefaults(defineProps<Props>(), {
allLabel: 'All',
width: 150,
})
defineEmits<{
'update:modelValue': [value: string]
}>()
const displayLabel = computed(() => {
if (!props.modelValue) return props.allLabel
const found = props.options.find((o) => o.value === props.modelValue)
return found ? found.label : props.modelValue
})
</script>
<style scoped lang="scss">
.filter-trigger {
display: inline-flex;
align-items: center;
gap: $spacing-sm;
padding: 7px 12px;
background: $color-bg-primary;
border: none;
border-radius: $radius-md;
box-shadow: 0 0 0 1px $color-border-light inset;
font-size: $font-size-sm;
color: $color-text-secondary;
cursor: pointer;
transition: box-shadow $transition-fast;
white-space: nowrap;
&:hover {
box-shadow: 0 0 0 1px $color-border inset;
}
&.has-value .filter-value {
color: $color-text-primary;
}
}
.filter-label {
color: $color-text-muted;
flex-shrink: 0;
}
.filter-value {
color: $color-text-secondary;
margin-left: auto;
}
.filter-arrow {
font-size: 12px;
color: $color-text-light;
flex-shrink: 0;
}
</style>

View File

@@ -1,42 +1,51 @@
<template>
<div class="kv-editor">
<div v-for="(entry, index) in modelValue" :key="index" class="kv-row">
<el-input
:model-value="entry.key"
:placeholder="keyPlaceholder"
class="kv-input"
@update:model-value="updateEntry(index, 'key', $event)"
/>
<el-input
:model-value="entry.value"
:placeholder="valuePlaceholder"
class="kv-input"
@update:model-value="updateEntry(index, 'value', $event)"
/>
<button class="kv-remove-btn" @click="removeEntry(index)">
<template v-if="readonly">
<div v-if="modelValue.length === 0" class="kv-empty"></div>
<div v-for="(entry, index) in modelValue" :key="index" class="kv-readonly-row">
<span class="kv-readonly-key">{{ entry.key }}</span>
<span class="kv-readonly-value">{{ entry.value }}</span>
</div>
</template>
<template v-else>
<div v-for="(entry, index) in modelValue" :key="index" class="kv-row">
<el-input
:model-value="entry.key"
:placeholder="keyPlaceholder"
class="kv-input"
@update:model-value="updateEntry(index, 'key', $event)"
/>
<el-input
:model-value="entry.value"
:placeholder="valuePlaceholder"
class="kv-input"
@update:model-value="updateEntry(index, 'value', $event)"
/>
<button class="kv-remove-btn" @click="removeEntry(index)">
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M5.5 5.5A.5.5 0 016 6v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm2.5 0a.5.5 0 01.5.5v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm3 .5a.5.5 0 00-1 0v6a.5.5 0 001 0V6z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M14.5 3a1 1 0 01-1 1H13v9a2 2 0 01-2 2H5a2 2 0 01-2-2V4h-.5a1 1 0 010-2H6a1 1 0 011-1h2a1 1 0 011 1h3.5a1 1 0 011 1zM4.118 4L4 4.059V13a1 1 0 001 1h6a1 1 0 001-1V4.059L11.882 4H4.118zM6 2h4v1H6V2z"
fill="currentColor"
/>
</svg>
</button>
</div>
<button class="kv-add-btn" @click="addEntry">
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M5.5 5.5A.5.5 0 016 6v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm2.5 0a.5.5 0 01.5.5v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm3 .5a.5.5 0 00-1 0v6a.5.5 0 001 0V6z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M14.5 3a1 1 0 01-1 1H13v9a2 2 0 01-2 2H5a2 2 0 01-2-2V4h-.5a1 1 0 010-2H6a1 1 0 011-1h2a1 1 0 011 1h3.5a1 1 0 011 1zM4.118 4L4 4.059V13a1 1 0 001 1h6a1 1 0 001-1V4.059L11.882 4H4.118zM6 2h4v1H6V2z"
d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z"
fill="currentColor"
/>
</svg>
Add
</button>
</div>
<button class="kv-add-btn" @click="addEntry">
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z"
fill="currentColor"
/>
</svg>
Add
</button>
</template>
</div>
</template>
@@ -50,11 +59,13 @@ interface Props {
modelValue: KVEntry[]
keyPlaceholder?: string
valuePlaceholder?: string
readonly?: boolean
}
const props = withDefaults(defineProps<Props>(), {
keyPlaceholder: 'Key',
valuePlaceholder: 'Value',
readonly: false,
})
const emit = defineEmits<{
@@ -129,25 +140,45 @@ html.dark .kv-remove-btn:hover {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border: 1px dashed var(--el-border-color);
border-radius: 8px;
padding: 5px 12px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: transparent;
color: var(--el-text-color-secondary);
color: var(--color-text-secondary);
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
transition: all 0.15s;
align-self: flex-start;
}
.kv-add-btn svg {
width: 14px;
height: 14px;
width: 13px;
height: 13px;
}
.kv-add-btn:hover {
color: var(--el-color-primary);
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
background: var(--color-bg-hover);
}
.kv-empty {
color: var(--el-text-color-secondary);
font-size: 13px;
}
.kv-readonly-row {
display: flex;
gap: 8px;
padding: 4px 0;
font-size: 13px;
}
.kv-readonly-key {
color: var(--el-text-color-secondary);
min-width: 100px;
}
.kv-readonly-value {
color: var(--el-text-color-primary);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
</style>

View File

@@ -0,0 +1,303 @@
<template>
<div
class="popover-menu-wrapper"
:class="{ 'is-full-width': fullWidth }"
ref="wrapperRef"
>
<el-popover
:visible="isOpen"
:placement="placement"
trigger="click"
:width="popoverWidth"
popper-class="popover-menu-popper"
:persistent="false"
:hide-after="0"
:offset="8"
:show-arrow="false"
>
<template #reference>
<div
v-if="filterable"
class="popover-trigger filterable-trigger"
:class="{ 'show-clear': showClearIcon }"
@click.stop
@mouseenter="isHovering = true"
@mouseleave="isHovering = false"
>
<el-input
ref="filterInputRef"
:model-value="inputValue"
:placeholder="inputPlaceholder"
:disabled="disabled"
:readonly="!isOpen"
@click="handleInputClick"
@update:model-value="handleFilterInput"
>
<template #suffix>
<el-icon
v-if="showClearIcon"
class="clear-icon"
@click.stop="handleClear"
>
<CircleClose />
</el-icon>
<el-icon v-else class="arrow-icon"><ArrowDown /></el-icon>
</template>
</el-input>
</div>
<div v-else class="popover-trigger" @click.stop="toggle">
<slot name="trigger" />
</div>
</template>
<div class="popover-menu-content">
<slot :close="close" :filter-text="filterText" />
</div>
</el-popover>
</div>
</template>
<script lang="ts">
// Module-level singleton for coordinating popover menus
const popoverEventTarget = new EventTarget()
const CLOSE_ALL_EVENT = 'close-all-popovers'
</script>
<script setup lang="ts">
import {
ref,
computed,
provide,
inject,
watch,
onMounted,
onUnmounted,
} from 'vue'
import { formItemContextKey, ElInput } from 'element-plus'
import { ArrowDown, CircleClose } from '@element-plus/icons-vue'
interface Props {
width?: number
placement?:
| 'top'
| 'top-start'
| 'top-end'
| 'bottom'
| 'bottom-start'
| 'bottom-end'
modelValue?: string | number | null
selectable?: boolean
disabled?: boolean
fullWidth?: boolean
filterable?: boolean
filterPlaceholder?: string
displayValue?: string
clearable?: boolean
}
const props = withDefaults(defineProps<Props>(), {
width: 160,
placement: 'bottom-end',
modelValue: null,
selectable: false,
disabled: false,
fullWidth: false,
filterable: false,
filterPlaceholder: 'Search...',
displayValue: '',
clearable: false,
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string | number | null): void
(e: 'filter-change', text: string): void
}>()
const elFormItem = inject(formItemContextKey, undefined)
const isOpen = ref(false)
const wrapperRef = ref<HTMLElement | null>(null)
const instanceId = Symbol()
const filterText = ref('')
const filterInputRef = ref<InstanceType<typeof ElInput> | null>(null)
const isHovering = ref(false)
const triggerWidth = ref(0)
const popoverWidth = computed(() => {
if (props.filterable && triggerWidth.value > 0) {
return Math.max(triggerWidth.value, props.width)
}
return props.width
})
const updateTriggerWidth = () => {
if (wrapperRef.value) {
triggerWidth.value = wrapperRef.value.offsetWidth
}
}
const inputValue = computed(() => {
if (isOpen.value) return filterText.value
if (props.modelValue) return props.displayValue || ''
return ''
})
const inputPlaceholder = computed(() => {
if (isOpen.value) return props.filterPlaceholder
if (!props.modelValue) return props.displayValue || props.filterPlaceholder
return props.filterPlaceholder
})
const showClearIcon = computed(() => {
return (
props.clearable && props.modelValue && isHovering.value && !props.disabled
)
})
watch(isOpen, (open) => {
if (!open && props.filterable) {
filterText.value = ''
emit('filter-change', '')
}
})
const handleInputClick = () => {
if (props.disabled) return
if (!isOpen.value) {
updateTriggerWidth()
popoverEventTarget.dispatchEvent(
new CustomEvent(CLOSE_ALL_EVENT, { detail: instanceId }),
)
isOpen.value = true
}
}
const handleFilterInput = (value: string) => {
filterText.value = value
emit('filter-change', value)
}
const handleClear = () => {
emit('update:modelValue', '')
filterText.value = ''
emit('filter-change', '')
elFormItem?.validate?.('change')
}
const toggle = () => {
if (props.disabled) return
if (!isOpen.value) {
popoverEventTarget.dispatchEvent(
new CustomEvent(CLOSE_ALL_EVENT, { detail: instanceId }),
)
}
isOpen.value = !isOpen.value
}
const handleCloseAll = (e: Event) => {
const customEvent = e as CustomEvent
if (customEvent.detail !== instanceId) {
isOpen.value = false
}
}
const close = () => {
isOpen.value = false
}
const select = (value: string | number) => {
emit('update:modelValue', value)
if (props.filterable) {
filterText.value = ''
emit('filter-change', '')
filterInputRef.value?.blur()
}
close()
elFormItem?.validate?.('change')
}
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (wrapperRef.value && !wrapperRef.value.contains(target)) {
close()
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
popoverEventTarget.addEventListener(CLOSE_ALL_EVENT, handleCloseAll)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
popoverEventTarget.removeEventListener(CLOSE_ALL_EVENT, handleCloseAll)
})
provide('popoverMenu', {
close,
select,
selectable: props.selectable,
modelValue: () => props.modelValue,
})
</script>
<style scoped lang="scss">
.popover-menu-wrapper {
display: inline-block;
&.is-full-width {
display: block;
width: 100%;
.popover-trigger {
display: block;
width: 100%;
}
}
}
.popover-trigger {
display: inline-flex;
&.filterable-trigger {
display: block;
width: 100%;
:deep(.el-input__wrapper) {
cursor: pointer;
}
:deep(.el-input__suffix) {
cursor: pointer;
}
.arrow-icon {
color: var(--el-text-color-placeholder);
transition: transform 0.2s;
}
.clear-icon {
color: var(--el-text-color-placeholder);
transition: color 0.2s;
&:hover {
color: var(--el-text-color-regular);
}
}
}
}
.popover-menu-content {
padding: 4px;
}
</style>
<style lang="scss">
.popover-menu-popper {
padding: 0 !important;
border-radius: 12px !important;
border: 1px solid $color-border-light !important;
box-shadow:
0 10px 25px -5px rgba(0, 0, 0, 0.1),
0 8px 10px -6px rgba(0, 0, 0, 0.1) !important;
}
</style>

View File

@@ -0,0 +1,125 @@
<template>
<button
class="popover-menu-item"
:class="{
'is-danger': danger,
'is-selected': isSelected,
'is-disabled': disabled,
}"
:disabled="disabled"
@click="handleClick"
>
<span class="item-content">
<slot />
</span>
<el-icon v-if="isSelected" class="check-icon">
<Check />
</el-icon>
</button>
</template>
<script setup lang="ts">
import { computed, inject } from 'vue'
import { Check } from '@element-plus/icons-vue'
interface Props {
danger?: boolean
disabled?: boolean
value?: string | number
}
const props = withDefaults(defineProps<Props>(), {
danger: false,
disabled: false,
value: undefined,
})
const emit = defineEmits<{
(e: 'click'): void
}>()
const popoverMenu = inject<{
close: () => void
select: (value: string | number) => void
selectable: boolean
modelValue: () => string | number | null
}>('popoverMenu')
const isSelected = computed(() => {
if (!popoverMenu?.selectable || props.value === undefined) return false
return popoverMenu.modelValue() === props.value
})
const handleClick = () => {
if (props.disabled) return
if (popoverMenu?.selectable && props.value !== undefined) {
popoverMenu.select(props.value)
} else {
emit('click')
popoverMenu?.close()
}
}
</script>
<style scoped lang="scss">
.popover-menu-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 8px 12px;
border: none;
border-radius: 6px;
background: transparent;
color: $color-text-secondary;
font-size: 14px;
cursor: pointer;
transition: background 0.15s ease;
text-align: left;
white-space: nowrap;
&:hover:not(.is-disabled) {
background: $color-bg-hover;
}
&.is-disabled {
opacity: 0.5;
cursor: not-allowed;
}
&.is-danger {
color: $color-danger;
.item-content :deep(.el-icon) {
color: $color-danger;
}
&:hover:not(.is-disabled) {
background: $color-danger-light;
}
}
&.is-selected {
background: $color-bg-hover;
}
.item-content {
display: flex;
align-items: center;
gap: 10px;
color: inherit;
:deep(.el-icon) {
font-size: 16px;
color: $color-text-light;
}
}
.check-icon {
font-size: 16px;
color: $color-primary;
flex-shrink: 0;
}
}
</style>

View File

@@ -1,104 +1,49 @@
<template>
<div
class="proxy-card"
:class="{ 'has-error': proxy.err, 'is-store': isStore }"
>
<div class="proxy-card" :class="{ 'has-error': proxy.err }" @click="$emit('click', proxy)">
<div class="card-main">
<div class="card-left">
<div class="card-header">
<span class="proxy-name">{{ proxy.name }}</span>
<span class="type-tag" :class="`type-${proxy.type}`">{{
proxy.type.toUpperCase()
}}</span>
<span v-if="isStore" class="source-tag">
<svg
class="store-icon"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2 4.5A1.5 1.5 0 013.5 3h9A1.5 1.5 0 0114 4.5v1a.5.5 0 01-.5.5h-11a.5.5 0 01-.5-.5v-1z"
fill="currentColor"
/>
<path
d="M3 7v5.5A1.5 1.5 0 004.5 14h7a1.5 1.5 0 001.5-1.5V7H3zm4 2h2a.5.5 0 010 1H7a.5.5 0 010-1z"
fill="currentColor"
/>
</svg>
Store
<span class="type-tag">{{ proxy.type.toUpperCase() }}</span>
<span class="status-pill" :class="statusClass">
<span class="status-dot"></span>
{{ proxy.status }}
</span>
</div>
<div class="card-meta">
<span v-if="proxy.local_addr" class="meta-item">
<span class="meta-label">Local</span>
<span class="meta-value code">{{ proxy.local_addr }}</span>
</span>
<span v-if="proxy.plugin" class="meta-item">
<span class="meta-label">Plugin</span>
<span class="meta-value code">{{ proxy.plugin }}</span>
</span>
<span v-if="proxy.remote_addr" class="meta-item">
<span class="meta-label">Remote</span>
<span class="meta-value code">{{ proxy.remote_addr }}</span>
</span>
<div class="card-address">
<template v-if="proxy.remote_addr && localDisplay">
{{ proxy.remote_addr }} {{ localDisplay }}
</template>
<template v-else-if="proxy.remote_addr">{{ proxy.remote_addr }}</template>
<template v-else-if="localDisplay">{{ localDisplay }}</template>
</div>
</div>
<div class="card-right">
<div v-if="proxy.err" class="error-info">
<el-tooltip :content="proxy.err" placement="top" :show-after="300">
<div class="error-badge">
<el-icon class="error-icon"><Warning /></el-icon>
<span class="error-text">Error</span>
</div>
</el-tooltip>
</div>
<div class="status-badge" :class="statusClass">
<span class="status-dot"></span>
{{ proxy.status }}
</div>
<!-- Store actions -->
<div v-if="isStore" class="card-actions">
<button
class="action-btn edit-btn"
@click.stop="$emit('edit', proxy)"
>
<svg
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.293 1.293a1 1 0 011.414 0l2 2a1 1 0 010 1.414l-9 9A1 1 0 015 14H3a1 1 0 01-1-1v-2a1 1 0 01.293-.707l9-9z"
fill="currentColor"
/>
</svg>
</button>
<button
class="action-btn delete-btn"
@click.stop="$emit('delete', proxy)"
>
<svg
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.5 5.5A.5.5 0 016 6v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm2.5 0a.5.5 0 01.5.5v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm3 .5a.5.5 0 00-1 0v6a.5.5 0 001 0V6z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M14.5 3a1 1 0 01-1 1H13v9a2 2 0 01-2 2H5a2 2 0 01-2-2V4h-.5a1 1 0 010-2H6a1 1 0 011-1h2a1 1 0 011 1h3.5a1 1 0 011 1zM4.118 4L4 4.059V13a1 1 0 001 1h6a1 1 0 001-1V4.059L11.882 4H4.118zM6 2h4v1H6V2z"
fill="currentColor"
/>
</svg>
</button>
<span v-if="showSource" class="source-label">{{ displaySource }}</span>
<div v-if="showActions" @click.stop>
<PopoverMenu :width="120" placement="bottom-end">
<template #trigger>
<ActionButton variant="outline" size="small">
<el-icon><MoreFilled /></el-icon>
</ActionButton>
</template>
<PopoverMenuItem v-if="proxy.status === 'disabled'" @click="$emit('toggle', proxy, true)">
<el-icon><Open /></el-icon>
Enable
</PopoverMenuItem>
<PopoverMenuItem v-else @click="$emit('toggle', proxy, false)">
<el-icon><TurnOff /></el-icon>
Disable
</PopoverMenuItem>
<PopoverMenuItem @click="$emit('edit', proxy)">
<el-icon><Edit /></el-icon>
Edit
</PopoverMenuItem>
<PopoverMenuItem danger @click="$emit('delete', proxy)">
<el-icon><Delete /></el-icon>
Delete
</PopoverMenuItem>
</PopoverMenu>
</div>
</div>
</div>
@@ -107,21 +52,40 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Warning } from '@element-plus/icons-vue'
import type { ProxyStatus } from '../types/proxy'
import { MoreFilled, Edit, Delete, Open, TurnOff } from '@element-plus/icons-vue'
import ActionButton from './ActionButton.vue'
import PopoverMenu from './PopoverMenu.vue'
import PopoverMenuItem from './PopoverMenuItem.vue'
import type { ProxyStatus } from '../types'
interface Props {
proxy: ProxyStatus
showSource?: boolean
showActions?: boolean
deleting?: boolean
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
showSource: false,
showActions: false,
deleting: false,
})
defineEmits<{
click: [proxy: ProxyStatus]
edit: [proxy: ProxyStatus]
delete: [proxy: ProxyStatus]
toggle: [proxy: ProxyStatus, enabled: boolean]
}>()
const isStore = computed(() => props.proxy.source === 'store')
const displaySource = computed(() => {
return props.proxy.source === 'store' ? 'store' : 'config'
})
const localDisplay = computed(() => {
if (props.proxy.plugin) return `plugin:${props.proxy.plugin}`
return props.proxy.local_addr || ''
})
const statusClass = computed(() => {
switch (props.proxy.status) {
@@ -129,53 +93,43 @@ const statusClass = computed(() => {
return 'running'
case 'error':
return 'error'
case 'disabled':
return 'disabled'
default:
return 'waiting'
}
})
</script>
<style scoped>
<style scoped lang="scss">
.proxy-card {
position: relative;
display: block;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 12px;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
background: $color-bg-primary;
border: 1px solid $color-border-lighter;
border-radius: $radius-md;
padding: 14px 20px;
cursor: pointer;
transition: all $transition-medium;
.proxy-card:hover {
border-color: var(--el-border-color);
box-shadow:
0 4px 16px rgba(0, 0, 0, 0.06),
0 1px 4px rgba(0, 0, 0, 0.04);
}
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
border-color: $color-border;
}
.proxy-card.has-error {
border-color: var(--el-color-danger-light-5);
}
html.dark .proxy-card.has-error {
border-color: var(--el-color-danger-dark-2);
&.has-error {
border-color: rgba(245, 108, 108, 0.3);
}
}
.card-main {
display: flex;
justify-content: space-between;
align-items: center;
padding: 18px 20px;
gap: 20px;
min-height: 76px;
gap: $spacing-lg;
}
/* Left Section */
.card-left {
display: flex;
flex-direction: column;
justify-content: center;
gap: 10px;
@include flex-column;
gap: $spacing-sm;
flex: 1;
min-width: 0;
}
@@ -183,311 +137,68 @@ html.dark .proxy-card.has-error {
.card-header {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
gap: $spacing-sm;
}
.proxy-name {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
line-height: 1.3;
letter-spacing: -0.01em;
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
color: $color-text-primary;
}
.type-tag {
font-size: 10px;
font-weight: 600;
padding: 2px 6px;
font-size: $font-size-xs;
font-weight: $font-weight-medium;
padding: 2px 8px;
border-radius: 4px;
background: var(--el-fill-color);
color: var(--el-text-color-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
background: $color-bg-muted;
color: $color-text-secondary;
}
.type-tag.type-tcp {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.type-tag.type-udp {
background: rgba(245, 158, 11, 0.1);
color: #f59e0b;
}
.type-tag.type-http {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
}
.type-tag.type-https {
background: rgba(16, 185, 129, 0.15);
color: #059669;
}
.type-tag.type-stcp,
.type-tag.type-sudp,
.type-tag.type-xtcp {
background: rgba(139, 92, 246, 0.1);
color: #8b5cf6;
}
.type-tag.type-tcpmux {
background: rgba(236, 72, 153, 0.1);
color: #ec4899;
}
html.dark .type-tag.type-tcp {
background: rgba(96, 165, 250, 0.15);
color: #60a5fa;
}
html.dark .type-tag.type-udp {
background: rgba(251, 191, 36, 0.15);
color: #fbbf24;
}
html.dark .type-tag.type-http {
background: rgba(52, 211, 153, 0.15);
color: #34d399;
}
html.dark .type-tag.type-https {
background: rgba(52, 211, 153, 0.2);
color: #34d399;
}
html.dark .type-tag.type-stcp,
html.dark .type-tag.type-sudp,
html.dark .type-tag.type-xtcp {
background: rgba(167, 139, 250, 0.15);
color: #a78bfa;
}
html.dark .type-tag.type-tcpmux {
background: rgba(244, 114, 182, 0.15);
color: #f472b6;
}
.source-tag {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
font-weight: 500;
padding: 2px 6px;
border-radius: 4px;
background: linear-gradient(
135deg,
rgba(102, 126, 234, 0.1) 0%,
rgba(118, 75, 162, 0.1) 100%
);
color: #764ba2;
}
html.dark .source-tag {
background: linear-gradient(
135deg,
rgba(129, 140, 248, 0.15) 0%,
rgba(167, 139, 250, 0.15) 100%
);
color: #a78bfa;
}
.store-icon {
width: 12px;
height: 12px;
}
.card-meta {
.card-address {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: $font-size-sm;
color: $color-text-muted;
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
gap: $spacing-sm;
}
.meta-item {
display: flex;
align-items: center;
gap: 6px;
line-height: 1;
}
.meta-label {
color: var(--el-text-color-placeholder);
font-size: 12px;
font-weight: 500;
}
.meta-value {
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-regular);
}
.meta-value.code {
font-family:
'SF Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
background: var(--el-fill-color-light);
padding: 3px 7px;
border-radius: 5px;
font-size: 11px;
letter-spacing: -0.02em;
}
/* Right Section */
.card-right {
display: flex;
align-items: center;
gap: 12px;
gap: $spacing-md;
flex-shrink: 0;
}
.error-badge {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 6px;
background: var(--el-color-danger-light-9);
cursor: help;
.source-label {
font-size: $font-size-xs;
color: $color-text-light;
}
.error-icon {
color: var(--el-color-danger);
font-size: 14px;
}
.error-text {
font-size: 11px;
font-weight: 500;
color: var(--el-color-danger);
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
text-transform: capitalize;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
background: currentColor;
}
.status-badge.running {
background: var(--el-color-success-light-9);
color: var(--el-color-success);
}
.status-badge.running .status-dot {
background: var(--el-color-success);
box-shadow: 0 0 0 2px var(--el-color-success-light-7);
animation: pulse 2s infinite;
}
.status-badge.error {
background: var(--el-color-danger-light-9);
color: var(--el-color-danger);
}
.status-badge.error .status-dot {
background: var(--el-color-danger);
}
.status-badge.waiting {
background: var(--el-color-warning-light-9);
color: var(--el-color-warning);
}
.status-badge.waiting .status-dot {
background: var(--el-color-warning);
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* Action buttons */
.card-actions {
display: flex;
gap: 4px;
}
@media (hover: hover) and (pointer: fine) {
.card-actions {
display: none;
}
.proxy-card.is-store:hover .status-badge {
display: none;
}
.proxy-card:hover .card-actions {
display: flex;
}
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 8px;
background: var(--el-fill-color);
color: var(--el-text-color-secondary);
cursor: pointer;
transition: all 0.15s ease;
}
.action-btn svg {
width: 14px;
height: 14px;
}
.action-btn:hover {
transform: scale(1.05);
}
.edit-btn:hover {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.delete-btn:hover {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
html.dark .edit-btn:hover {
background: rgba(96, 165, 250, 0.15);
color: #60a5fa;
}
html.dark .delete-btn:hover {
background: rgba(248, 113, 113, 0.15);
color: #f87171;
}
/* Mobile Responsive */
@media (max-width: 768px) {
@include mobile {
.card-main {
flex-direction: column;
align-items: stretch;
gap: 14px;
padding: 14px 16px;
gap: $spacing-sm;
}
.card-right {
flex-direction: row;
align-items: center;
justify-content: space-between;
border-top: 1px solid var(--el-border-color-lighter);
padding-top: 14px;
}
.card-address {
word-break: break-all;
}
}
</style>

View File

@@ -1,202 +0,0 @@
<template>
<el-card
class="stat-card"
:class="{ clickable: !!to }"
:body-style="{ padding: '20px' }"
shadow="hover"
@click="handleClick"
>
<div class="stat-card-content">
<div class="stat-icon" :class="`icon-${type}`">
<component :is="iconComponent" class="icon" />
</div>
<div class="stat-info">
<div class="stat-value">{{ value }}</div>
<div class="stat-label">{{ label }}</div>
</div>
<el-icon v-if="to" class="arrow-icon"><ArrowRight /></el-icon>
</div>
<div v-if="subtitle" class="stat-subtitle">{{ subtitle }}</div>
</el-card>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import {
Connection,
CircleCheck,
Warning,
Setting,
ArrowRight,
} from '@element-plus/icons-vue'
interface Props {
label: string
value: string | number
type?: 'proxies' | 'running' | 'error' | 'config'
subtitle?: string
to?: string
}
const props = withDefaults(defineProps<Props>(), {
type: 'proxies',
})
const router = useRouter()
const iconComponent = computed(() => {
switch (props.type) {
case 'proxies':
return Connection
case 'running':
return CircleCheck
case 'error':
return Warning
case 'config':
return Setting
default:
return Connection
}
})
const handleClick = () => {
if (props.to) {
router.push(props.to)
}
}
</script>
<style scoped>
.stat-card {
border-radius: 12px;
transition: all 0.3s ease;
border: 1px solid #e4e7ed;
}
.stat-card.clickable {
cursor: pointer;
}
.stat-card.clickable:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
}
.stat-card.clickable:hover .arrow-icon {
transform: translateX(4px);
}
html.dark .stat-card {
border-color: #3a3d5c;
background: #27293d;
}
.stat-card-content {
display: flex;
align-items: center;
gap: 16px;
}
.arrow-icon {
color: #909399;
font-size: 18px;
transition: transform 0.2s ease;
flex-shrink: 0;
}
html.dark .arrow-icon {
color: #9ca3af;
}
.stat-icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-icon .icon {
width: 28px;
height: 28px;
}
.icon-proxies {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.icon-running {
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
color: white;
}
.icon-error {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
}
.icon-config {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
}
html.dark .icon-proxies {
background: linear-gradient(135deg, #818cf8 0%, #a78bfa 100%);
}
html.dark .icon-running {
background: linear-gradient(135deg, #34d399 0%, #10b981 100%);
}
html.dark .icon-error {
background: linear-gradient(135deg, #fb7185 0%, #f43f5e 100%);
}
html.dark .icon-config {
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%);
}
.stat-info {
flex: 1;
min-width: 0;
}
.stat-value {
font-size: 28px;
font-weight: 500;
line-height: 1.2;
color: #303133;
margin-bottom: 4px;
}
html.dark .stat-value {
color: #e5e7eb;
}
.stat-label {
font-size: 14px;
color: #909399;
font-weight: 500;
}
html.dark .stat-label {
color: #9ca3af;
}
.stat-subtitle {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #e4e7ed;
font-size: 12px;
color: #909399;
}
html.dark .stat-subtitle {
border-top-color: #3a3d5c;
color: #9ca3af;
}
</style>

View File

@@ -0,0 +1,103 @@
<template>
<div class="status-pills">
<button
v-for="pill in pills"
:key="pill.status"
class="pill"
:class="{ active: modelValue === pill.status, [pill.status || 'all']: true }"
@click="emit('update:modelValue', pill.status)"
>
{{ pill.label }} {{ pill.count }}
</button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
items: Array<{ status: string }>
modelValue: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const pills = computed(() => {
const counts = { running: 0, error: 0, waiting: 0 }
for (const item of props.items) {
const s = item.status as keyof typeof counts
if (s in counts) {
counts[s]++
}
}
return [
{ status: '', label: 'All', count: props.items.length },
{ status: 'running', label: 'Running', count: counts.running },
{ status: 'error', label: 'Error', count: counts.error },
{ status: 'waiting', label: 'Waiting', count: counts.waiting },
]
})
</script>
<style scoped lang="scss">
.status-pills {
display: flex;
gap: $spacing-sm;
}
.pill {
border: none;
border-radius: 12px;
padding: $spacing-xs $spacing-md;
font-size: $font-size-xs;
font-weight: $font-weight-medium;
cursor: pointer;
background: $color-bg-muted;
color: $color-text-secondary;
transition: all $transition-fast;
white-space: nowrap;
&:hover {
opacity: 0.85;
}
&.active {
&.all {
background: $color-bg-muted;
color: $color-text-secondary;
}
&.running {
background: rgba(103, 194, 58, 0.1);
color: #67c23a;
}
&.error {
background: rgba(245, 108, 108, 0.1);
color: #f56c6c;
}
&.waiting {
background: rgba(230, 162, 60, 0.1);
color: #e6a23c;
}
}
}
@include mobile {
.status-pills {
overflow-x: auto;
flex-wrap: nowrap;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
}
</style>

View File

@@ -0,0 +1,141 @@
<template>
<div class="string-list-editor">
<template v-if="readonly">
<div v-if="!modelValue || modelValue.length === 0" class="list-empty"></div>
<div v-for="(item, index) in modelValue" :key="index" class="list-readonly-item">
{{ item }}
</div>
</template>
<template v-else>
<div v-for="(item, index) in modelValue" :key="index" class="item-row">
<el-input
:model-value="item"
:placeholder="placeholder"
@update:model-value="updateItem(index, $event)"
/>
<button class="item-remove" @click="removeItem(index)">
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z" fill="currentColor"/>
</svg>
</button>
</div>
<button class="list-add-btn" @click="addItem">
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" fill="currentColor"/>
</svg>
Add
</button>
</template>
</div>
</template>
<script setup lang="ts">
const props = withDefaults(
defineProps<{
modelValue: string[]
placeholder?: string
readonly?: boolean
}>(),
{
placeholder: 'Enter value',
readonly: false,
},
)
const emit = defineEmits<{
'update:modelValue': [value: string[]]
}>()
const addItem = () => {
emit('update:modelValue', [...(props.modelValue || []), ''])
}
const removeItem = (index: number) => {
const newValue = [...props.modelValue]
newValue.splice(index, 1)
emit('update:modelValue', newValue)
}
const updateItem = (index: number, value: string) => {
const newValue = [...props.modelValue]
newValue[index] = value
emit('update:modelValue', newValue)
}
</script>
<style scoped>
.string-list-editor {
width: 100%;
}
.item-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.item-row .el-input {
flex: 1;
}
.item-remove {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s;
}
.item-remove svg {
width: 14px;
height: 14px;
}
.item-remove:hover {
background: var(--color-bg-hover);
color: var(--color-text-primary);
}
.list-add-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: transparent;
color: var(--color-text-secondary);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.list-add-btn svg {
width: 13px;
height: 13px;
}
.list-add-btn:hover {
background: var(--color-bg-hover);
}
.list-empty {
color: var(--color-text-muted);
font-size: 13px;
}
.list-readonly-item {
font-size: 13px;
color: var(--color-text-primary);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
padding: 2px 0;
}
</style>

View File

@@ -0,0 +1,40 @@
<template>
<ConfigSection title="Authentication" :readonly="readonly">
<template v-if="['http', 'tcpmux'].includes(form.type)">
<div class="field-row three-col">
<ConfigField label="HTTP User" type="text" v-model="form.httpUser" :readonly="readonly" />
<ConfigField label="HTTP Password" type="password" v-model="form.httpPassword" :readonly="readonly" />
<ConfigField label="Route By HTTP User" type="text" v-model="form.routeByHTTPUser" :readonly="readonly" />
</div>
</template>
<template v-if="['stcp', 'sudp', 'xtcp'].includes(form.type)">
<div class="field-row two-col">
<ConfigField label="Secret Key" type="password" v-model="form.secretKey" prop="secretKey" :readonly="readonly" />
<ConfigField label="Allow Users" type="tags" v-model="form.allowUsers" placeholder="username" :readonly="readonly" />
</div>
</template>
</ConfigSection>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ProxyFormData } from '../../types'
import ConfigSection from '../ConfigSection.vue'
import ConfigField from '../ConfigField.vue'
const props = withDefaults(defineProps<{
modelValue: ProxyFormData
readonly?: boolean
}>(), { readonly: false })
const emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
</script>
<style scoped lang="scss">
@use '@/assets/css/form-layout';
</style>

View File

@@ -0,0 +1,149 @@
<template>
<!-- Backend Mode -->
<template v-if="!readonly">
<el-form-item label="Backend Mode">
<el-radio-group v-model="backendMode">
<el-radio value="direct">Direct</el-radio>
<el-radio value="plugin">Plugin</el-radio>
</el-radio-group>
</el-form-item>
</template>
<!-- Direct mode -->
<template v-if="backendMode === 'direct'">
<div class="field-row two-col">
<ConfigField label="Local IP" type="text" v-model="form.localIP" placeholder="127.0.0.1" :readonly="readonly" />
<ConfigField label="Local Port" type="number" v-model="form.localPort" :min="0" :max="65535" prop="localPort" :readonly="readonly" />
</div>
</template>
<!-- Plugin mode -->
<template v-else>
<div class="field-row two-col">
<ConfigField label="Plugin Type" type="select" v-model="form.pluginType"
:options="PLUGIN_LIST.map((p) => ({ label: p, value: p }))" :readonly="readonly" />
<div></div>
</div>
<template v-if="['http2https', 'https2http', 'https2https', 'http2http', 'tls2raw'].includes(form.pluginType)">
<div class="field-row two-col">
<ConfigField label="Local Address" type="text" v-model="form.pluginConfig.localAddr" placeholder="127.0.0.1:8080" :readonly="readonly" />
<ConfigField v-if="['http2https', 'https2http', 'https2https', 'http2http'].includes(form.pluginType)"
label="Host Header Rewrite" type="text" v-model="form.pluginConfig.hostHeaderRewrite" :readonly="readonly" />
<div v-else></div>
</div>
</template>
<template v-if="['http2https', 'https2http', 'https2https', 'http2http'].includes(form.pluginType)">
<ConfigField label="Request Headers" type="kv" v-model="pluginRequestHeaders"
key-placeholder="Header" value-placeholder="Value" :readonly="readonly" />
</template>
<template v-if="['https2http', 'https2https', 'tls2raw'].includes(form.pluginType)">
<div class="field-row two-col">
<ConfigField label="Certificate Path" type="text" v-model="form.pluginConfig.crtPath" placeholder="/path/to/cert.pem" :readonly="readonly" />
<ConfigField label="Key Path" type="text" v-model="form.pluginConfig.keyPath" placeholder="/path/to/key.pem" :readonly="readonly" />
</div>
</template>
<template v-if="['https2http', 'https2https'].includes(form.pluginType)">
<ConfigField label="Enable HTTP/2" type="switch" v-model="form.pluginConfig.enableHTTP2" :readonly="readonly" />
</template>
<template v-if="form.pluginType === 'http_proxy'">
<div class="field-row two-col">
<ConfigField label="HTTP User" type="text" v-model="form.pluginConfig.httpUser" :readonly="readonly" />
<ConfigField label="HTTP Password" type="password" v-model="form.pluginConfig.httpPassword" :readonly="readonly" />
</div>
</template>
<template v-if="form.pluginType === 'socks5'">
<div class="field-row two-col">
<ConfigField label="Username" type="text" v-model="form.pluginConfig.username" :readonly="readonly" />
<ConfigField label="Password" type="password" v-model="form.pluginConfig.password" :readonly="readonly" />
</div>
</template>
<template v-if="form.pluginType === 'static_file'">
<div class="field-row two-col">
<ConfigField label="Local Path" type="text" v-model="form.pluginConfig.localPath" placeholder="/path/to/files" :readonly="readonly" />
<ConfigField label="Strip Prefix" type="text" v-model="form.pluginConfig.stripPrefix" :readonly="readonly" />
</div>
<div class="field-row two-col">
<ConfigField label="HTTP User" type="text" v-model="form.pluginConfig.httpUser" :readonly="readonly" />
<ConfigField label="HTTP Password" type="password" v-model="form.pluginConfig.httpPassword" :readonly="readonly" />
</div>
</template>
<template v-if="form.pluginType === 'unix_domain_socket'">
<ConfigField label="Unix Socket Path" type="text" v-model="form.pluginConfig.unixPath" placeholder="/tmp/socket.sock" :readonly="readonly" />
</template>
</template>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted } from 'vue'
import type { ProxyFormData } from '../../types'
import ConfigField from '../ConfigField.vue'
const PLUGIN_LIST = [
'http2https', 'http_proxy', 'https2http', 'https2https', 'http2http',
'socks5', 'static_file', 'unix_domain_socket', 'tls2raw', 'virtual_net',
]
const props = withDefaults(defineProps<{
modelValue: ProxyFormData
readonly?: boolean
}>(), { readonly: false })
const emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
const backendMode = ref<'direct' | 'plugin'>(form.value.pluginType ? 'plugin' : 'direct')
const isHydrating = ref(false)
const pluginRequestHeaders = computed({
get() {
const set = form.value.pluginConfig?.requestHeaders?.set
if (!set || typeof set !== 'object') return []
return Object.entries(set).map(([key, value]) => ({ key, value: String(value) }))
},
set(val: Array<{ key: string; value: string }>) {
if (!form.value.pluginConfig) form.value.pluginConfig = {}
if (val.length === 0) {
delete form.value.pluginConfig.requestHeaders
} else {
form.value.pluginConfig.requestHeaders = {
set: Object.fromEntries(val.map((e) => [e.key, e.value])),
}
}
},
})
watch(() => form.value.pluginType, (newType, oldType) => {
if (isHydrating.value) return
if (!oldType || !newType || newType === oldType) return
if (form.value.pluginConfig && Object.keys(form.value.pluginConfig).length > 0) {
form.value.pluginConfig = {}
}
})
watch(backendMode, (mode) => {
if (mode === 'direct') {
form.value.pluginType = ''
form.value.pluginConfig = {}
} else if (!form.value.pluginType) {
form.value.pluginType = 'http2https'
}
})
const hydrate = () => {
isHydrating.value = true
backendMode.value = form.value.pluginType ? 'plugin' : 'direct'
nextTick(() => { isHydrating.value = false })
}
watch(() => props.modelValue, () => { hydrate() })
onMounted(() => { hydrate() })
</script>
<style scoped lang="scss">
@use '@/assets/css/form-layout';
</style>

View File

@@ -0,0 +1,51 @@
<template>
<!-- Name / Type / Enabled -->
<div v-if="!readonly" class="field-row three-col">
<el-form-item label="Name" prop="name" class="field-grow">
<el-input
v-model="form.name"
:disabled="editing || readonly"
placeholder="my-proxy"
/>
</el-form-item>
<ConfigField
label="Type"
type="select"
v-model="form.type"
:disabled="editing"
:options="PROXY_TYPES.map((t) => ({ label: t.toUpperCase(), value: t }))"
prop="type"
/>
<el-form-item label="Enabled" class="switch-field">
<el-switch v-model="form.enabled" size="small" />
</el-form-item>
</div>
<div v-else class="field-row three-col">
<ConfigField label="Name" type="text" :model-value="form.name" readonly class="field-grow" />
<ConfigField label="Type" type="text" :model-value="form.type.toUpperCase()" readonly />
<ConfigField label="Enabled" type="switch" :model-value="form.enabled" readonly />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { PROXY_TYPES, type ProxyFormData } from '../../types'
import ConfigField from '../ConfigField.vue'
const props = withDefaults(defineProps<{
modelValue: ProxyFormData
readonly?: boolean
editing?: boolean
}>(), { readonly: false, editing: false })
const emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
</script>
<style scoped lang="scss">
@use '@/assets/css/form-layout';
</style>

View File

@@ -0,0 +1,50 @@
<template>
<div class="proxy-form-layout">
<ConfigSection :readonly="readonly">
<ProxyBaseSection v-model="form" :readonly="readonly" :editing="editing" />
<ProxyRemoteSection
v-if="['tcp', 'udp', 'http', 'https', 'tcpmux'].includes(form.type)"
v-model="form" :readonly="readonly" />
<ProxyBackendSection v-model="form" :readonly="readonly" />
</ConfigSection>
<ProxyAuthSection
v-if="['http', 'tcpmux', 'stcp', 'sudp', 'xtcp'].includes(form.type)"
v-model="form" :readonly="readonly" />
<ProxyHttpSection v-if="form.type === 'http'" v-model="form" :readonly="readonly" />
<ProxyTransportSection v-model="form" :readonly="readonly" />
<ProxyHealthSection v-model="form" :readonly="readonly" />
<ProxyLoadBalanceSection v-model="form" :readonly="readonly" />
<ProxyNatSection v-if="form.type === 'xtcp'" v-model="form" :readonly="readonly" />
<ProxyMetadataSection v-model="form" :readonly="readonly" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ProxyFormData } from '../../types'
import ConfigSection from '../ConfigSection.vue'
import ProxyBaseSection from './ProxyBaseSection.vue'
import ProxyRemoteSection from './ProxyRemoteSection.vue'
import ProxyBackendSection from './ProxyBackendSection.vue'
import ProxyAuthSection from './ProxyAuthSection.vue'
import ProxyHttpSection from './ProxyHttpSection.vue'
import ProxyTransportSection from './ProxyTransportSection.vue'
import ProxyHealthSection from './ProxyHealthSection.vue'
import ProxyLoadBalanceSection from './ProxyLoadBalanceSection.vue'
import ProxyNatSection from './ProxyNatSection.vue'
import ProxyMetadataSection from './ProxyMetadataSection.vue'
const props = withDefaults(defineProps<{
modelValue: ProxyFormData
readonly?: boolean
editing?: boolean
}>(), { readonly: false, editing: false })
const emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
</script>

View File

@@ -0,0 +1,52 @@
<template>
<ConfigSection title="Health Check" collapsible :readonly="readonly" :has-value="!!form.healthCheckType">
<div class="field-row two-col">
<ConfigField label="Type" type="select" v-model="form.healthCheckType"
:options="[{ label: 'Disabled', value: '' }, { label: 'TCP', value: 'tcp' }, { label: 'HTTP', value: 'http' }]" :readonly="readonly" />
<div></div>
</div>
<template v-if="form.healthCheckType">
<div class="field-row three-col">
<ConfigField label="Timeout (s)" type="number" v-model="form.healthCheckTimeoutSeconds" :min="1" :readonly="readonly" />
<ConfigField label="Max Failed" type="number" v-model="form.healthCheckMaxFailed" :min="1" :readonly="readonly" />
<ConfigField label="Interval (s)" type="number" v-model="form.healthCheckIntervalSeconds" :min="1" :readonly="readonly" />
</div>
<template v-if="form.healthCheckType === 'http'">
<ConfigField label="Path" type="text" v-model="form.healthCheckPath" prop="healthCheckPath" placeholder="/health" :readonly="readonly" />
<ConfigField label="HTTP Headers" type="kv" v-model="healthCheckHeaders" key-placeholder="Header" value-placeholder="Value" :readonly="readonly" />
</template>
</template>
</ConfigSection>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ProxyFormData } from '../../types'
import ConfigSection from '../ConfigSection.vue'
import ConfigField from '../ConfigField.vue'
const props = withDefaults(defineProps<{
modelValue: ProxyFormData
readonly?: boolean
}>(), { readonly: false })
const emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
const healthCheckHeaders = computed({
get() {
return form.value.healthCheckHTTPHeaders.map((h) => ({ key: h.name, value: h.value }))
},
set(val: Array<{ key: string; value: string }>) {
form.value.healthCheckHTTPHeaders = val.map((h) => ({ name: h.key, value: h.value }))
},
})
</script>
<style scoped lang="scss">
@use '@/assets/css/form-layout';
</style>

View File

@@ -0,0 +1,32 @@
<template>
<ConfigSection title="HTTP Options" collapsible :readonly="readonly"
:has-value="form.locations.length > 0 || !!form.hostHeaderRewrite || form.requestHeaders.length > 0 || form.responseHeaders.length > 0">
<ConfigField label="Locations" type="tags" v-model="form.locations" placeholder="/path" :readonly="readonly" />
<ConfigField label="Host Header Rewrite" type="text" v-model="form.hostHeaderRewrite" :readonly="readonly" />
<ConfigField label="Request Headers" type="kv" v-model="form.requestHeaders" key-placeholder="Header" value-placeholder="Value" :readonly="readonly" />
<ConfigField label="Response Headers" type="kv" v-model="form.responseHeaders" key-placeholder="Header" value-placeholder="Value" :readonly="readonly" />
</ConfigSection>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ProxyFormData } from '../../types'
import ConfigSection from '../ConfigSection.vue'
import ConfigField from '../ConfigField.vue'
const props = withDefaults(defineProps<{
modelValue: ProxyFormData
readonly?: boolean
}>(), { readonly: false })
const emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
</script>
<style scoped lang="scss">
@use '@/assets/css/form-layout';
</style>

View File

@@ -0,0 +1,31 @@
<template>
<ConfigSection title="Load Balancer" collapsible :readonly="readonly" :has-value="!!form.loadBalancerGroup">
<div class="field-row two-col">
<ConfigField label="Group" type="text" v-model="form.loadBalancerGroup" placeholder="Group name" :readonly="readonly" />
<ConfigField label="Group Key" type="text" v-model="form.loadBalancerGroupKey" :readonly="readonly" />
</div>
</ConfigSection>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ProxyFormData } from '../../types'
import ConfigSection from '../ConfigSection.vue'
import ConfigField from '../ConfigField.vue'
const props = withDefaults(defineProps<{
modelValue: ProxyFormData
readonly?: boolean
}>(), { readonly: false })
const emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
</script>
<style scoped lang="scss">
@use '@/assets/css/form-layout';
</style>

View File

@@ -0,0 +1,29 @@
<template>
<ConfigSection title="Metadata" collapsible :readonly="readonly" :has-value="form.metadatas.length > 0 || form.annotations.length > 0">
<ConfigField label="Metadatas" type="kv" v-model="form.metadatas" :readonly="readonly" />
<ConfigField label="Annotations" type="kv" v-model="form.annotations" :readonly="readonly" />
</ConfigSection>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ProxyFormData } from '../../types'
import ConfigSection from '../ConfigSection.vue'
import ConfigField from '../ConfigField.vue'
const props = withDefaults(defineProps<{
modelValue: ProxyFormData
readonly?: boolean
}>(), { readonly: false })
const emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
</script>
<style scoped lang="scss">
@use '@/assets/css/form-layout';
</style>

View File

@@ -0,0 +1,29 @@
<template>
<ConfigSection title="NAT Traversal" collapsible :readonly="readonly" :has-value="form.natTraversalDisableAssistedAddrs">
<ConfigField label="Disable Assisted Addresses" type="switch" v-model="form.natTraversalDisableAssistedAddrs"
tip="Only use STUN-discovered public addresses" :readonly="readonly" />
</ConfigSection>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ProxyFormData } from '../../types'
import ConfigSection from '../ConfigSection.vue'
import ConfigField from '../ConfigField.vue'
const props = withDefaults(defineProps<{
modelValue: ProxyFormData
readonly?: boolean
}>(), { readonly: false })
const emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
</script>
<style scoped lang="scss">
@use '@/assets/css/form-layout';
</style>

View File

@@ -0,0 +1,41 @@
<template>
<template v-if="['tcp', 'udp'].includes(form.type)">
<div class="field-row two-col">
<ConfigField label="Remote Port" type="number" v-model="form.remotePort"
:min="0" :max="65535" prop="remotePort" tip="Use 0 for random port assignment" :readonly="readonly" />
<div></div>
</div>
</template>
<template v-if="['http', 'https', 'tcpmux'].includes(form.type)">
<div class="field-row two-col">
<ConfigField label="Custom Domains" type="tags" v-model="form.customDomains"
prop="customDomains" placeholder="example.com" :readonly="readonly" />
<ConfigField v-if="form.type !== 'tcpmux'" label="Subdomain" type="text"
v-model="form.subdomain" placeholder="test" :readonly="readonly" />
<ConfigField v-if="form.type === 'tcpmux'" label="Multiplexer" type="select"
v-model="form.multiplexer" :options="[{ label: 'HTTP CONNECT', value: 'httpconnect' }]" :readonly="readonly" />
</div>
</template>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ProxyFormData } from '../../types'
import ConfigField from '../ConfigField.vue'
const props = withDefaults(defineProps<{
modelValue: ProxyFormData
readonly?: boolean
}>(), { readonly: false })
const emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
</script>
<style scoped lang="scss">
@use '@/assets/css/form-layout';
</style>

View File

@@ -0,0 +1,39 @@
<template>
<ConfigSection title="Transport" collapsible :readonly="readonly"
:has-value="form.useEncryption || form.useCompression || !!form.bandwidthLimit || (!!form.bandwidthLimitMode && form.bandwidthLimitMode !== 'client') || !!form.proxyProtocolVersion">
<div class="field-row two-col">
<ConfigField label="Use Encryption" type="switch" v-model="form.useEncryption" :readonly="readonly" />
<ConfigField label="Use Compression" type="switch" v-model="form.useCompression" :readonly="readonly" />
</div>
<div class="field-row three-col">
<ConfigField label="Bandwidth Limit" type="text" v-model="form.bandwidthLimit" placeholder="1MB" tip="e.g., 1MB, 500KB" :readonly="readonly" />
<ConfigField label="Bandwidth Limit Mode" type="select" v-model="form.bandwidthLimitMode"
:options="[{ label: 'Client', value: 'client' }, { label: 'Server', value: 'server' }]" :readonly="readonly" />
<ConfigField label="Proxy Protocol Version" type="select" v-model="form.proxyProtocolVersion"
:options="[{ label: 'None', value: '' }, { label: 'v1', value: 'v1' }, { label: 'v2', value: 'v2' }]" :readonly="readonly" />
</div>
</ConfigSection>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ProxyFormData } from '../../types'
import ConfigSection from '../ConfigSection.vue'
import ConfigField from '../ConfigField.vue'
const props = withDefaults(defineProps<{
modelValue: ProxyFormData
readonly?: boolean
}>(), { readonly: false })
const emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
</script>
<style scoped lang="scss">
@use '@/assets/css/form-layout';
</style>

View File

@@ -0,0 +1,40 @@
<template>
<div v-if="!readonly" class="field-row three-col">
<el-form-item label="Name" prop="name" class="field-grow">
<el-input v-model="form.name" :disabled="editing || readonly" placeholder="my-visitor" />
</el-form-item>
<ConfigField label="Type" type="select" v-model="form.type" :disabled="editing"
:options="[{ label: 'STCP', value: 'stcp' }, { label: 'SUDP', value: 'sudp' }, { label: 'XTCP', value: 'xtcp' }]" prop="type" />
<el-form-item label="Enabled" class="switch-field">
<el-switch v-model="form.enabled" size="small" />
</el-form-item>
</div>
<div v-else class="field-row three-col">
<ConfigField label="Name" type="text" :model-value="form.name" readonly class="field-grow" />
<ConfigField label="Type" type="text" :model-value="form.type.toUpperCase()" readonly />
<ConfigField label="Enabled" type="switch" :model-value="form.enabled" readonly />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { VisitorFormData } from '../../types'
import ConfigField from '../ConfigField.vue'
const props = withDefaults(defineProps<{
modelValue: VisitorFormData
readonly?: boolean
editing?: boolean
}>(), { readonly: false, editing: false })
const emit = defineEmits<{ 'update:modelValue': [value: VisitorFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
</script>
<style scoped lang="scss">
@use '@/assets/css/form-layout';
</style>

View File

@@ -0,0 +1,43 @@
<template>
<ConfigSection title="Connection" :readonly="readonly">
<div class="field-row two-col">
<ConfigField label="Server Name" type="text" v-model="form.serverName" prop="serverName"
placeholder="Name of the proxy to visit" :readonly="readonly" />
<ConfigField label="Server User" type="text" v-model="form.serverUser"
placeholder="Leave empty for same user" :readonly="readonly" />
</div>
<ConfigField label="Secret Key" type="password" v-model="form.secretKey"
placeholder="Shared secret" :readonly="readonly" />
<div class="field-row two-col">
<ConfigField label="Bind Address" type="text" v-model="form.bindAddr"
placeholder="127.0.0.1" :readonly="readonly" />
<ConfigField label="Bind Port" type="number" v-model="form.bindPort"
:min="bindPortMin" :max="65535" prop="bindPort" :readonly="readonly" />
</div>
</ConfigSection>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { VisitorFormData } from '../../types'
import ConfigSection from '../ConfigSection.vue'
import ConfigField from '../ConfigField.vue'
const props = withDefaults(defineProps<{
modelValue: VisitorFormData
readonly?: boolean
}>(), { readonly: false })
const emit = defineEmits<{ 'update:modelValue': [value: VisitorFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
const bindPortMin = computed(() => (form.value.type === 'sudp' ? 1 : undefined))
</script>
<style scoped lang="scss">
@use '@/assets/css/form-layout';
</style>

View File

@@ -0,0 +1,33 @@
<template>
<div class="visitor-form-layout">
<ConfigSection :readonly="readonly">
<VisitorBaseSection v-model="form" :readonly="readonly" :editing="editing" />
</ConfigSection>
<VisitorConnectionSection v-model="form" :readonly="readonly" />
<VisitorTransportSection v-model="form" :readonly="readonly" />
<VisitorXtcpSection v-if="form.type === 'xtcp'" v-model="form" :readonly="readonly" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { VisitorFormData } from '../../types'
import ConfigSection from '../ConfigSection.vue'
import VisitorBaseSection from './VisitorBaseSection.vue'
import VisitorConnectionSection from './VisitorConnectionSection.vue'
import VisitorTransportSection from './VisitorTransportSection.vue'
import VisitorXtcpSection from './VisitorXtcpSection.vue'
const props = withDefaults(defineProps<{
modelValue: VisitorFormData
readonly?: boolean
editing?: boolean
}>(), { readonly: false, editing: false })
const emit = defineEmits<{ 'update:modelValue': [value: VisitorFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
</script>

View File

@@ -0,0 +1,32 @@
<template>
<ConfigSection title="Transport Options" collapsible :readonly="readonly"
:has-value="form.useEncryption || form.useCompression">
<div class="field-row two-col">
<ConfigField label="Use Encryption" type="switch" v-model="form.useEncryption" :readonly="readonly" />
<ConfigField label="Use Compression" type="switch" v-model="form.useCompression" :readonly="readonly" />
</div>
</ConfigSection>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { VisitorFormData } from '../../types'
import ConfigSection from '../ConfigSection.vue'
import ConfigField from '../ConfigField.vue'
const props = withDefaults(defineProps<{
modelValue: VisitorFormData
readonly?: boolean
}>(), { readonly: false })
const emit = defineEmits<{ 'update:modelValue': [value: VisitorFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
</script>
<style scoped lang="scss">
@use '@/assets/css/form-layout';
</style>

View File

@@ -0,0 +1,47 @@
<template>
<!-- XTCP Options -->
<ConfigSection title="XTCP Options" collapsible :readonly="readonly"
:has-value="form.protocol !== 'quic' || form.keepTunnelOpen || form.maxRetriesAnHour != null || form.minRetryInterval != null || !!form.fallbackTo || form.fallbackTimeoutMs != null">
<ConfigField label="Protocol" type="select" v-model="form.protocol"
:options="[{ label: 'QUIC', value: 'quic' }, { label: 'KCP', value: 'kcp' }]" :readonly="readonly" />
<ConfigField label="Keep Tunnel Open" type="switch" v-model="form.keepTunnelOpen" :readonly="readonly" />
<div class="field-row two-col">
<ConfigField label="Max Retries per Hour" type="number" v-model="form.maxRetriesAnHour" :min="0" :readonly="readonly" />
<ConfigField label="Min Retry Interval (s)" type="number" v-model="form.minRetryInterval" :min="0" :readonly="readonly" />
</div>
<div class="field-row two-col">
<ConfigField label="Fallback To" type="text" v-model="form.fallbackTo" placeholder="Fallback visitor name" :readonly="readonly" />
<ConfigField label="Fallback Timeout (ms)" type="number" v-model="form.fallbackTimeoutMs" :min="0" :readonly="readonly" />
</div>
</ConfigSection>
<!-- NAT Traversal -->
<ConfigSection title="NAT Traversal" collapsible :readonly="readonly"
:has-value="form.natTraversalDisableAssistedAddrs">
<ConfigField label="Disable Assisted Addresses" type="switch" v-model="form.natTraversalDisableAssistedAddrs"
tip="Only use STUN-discovered public addresses" :readonly="readonly" />
</ConfigSection>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { VisitorFormData } from '../../types'
import ConfigSection from '../ConfigSection.vue'
import ConfigField from '../ConfigField.vue'
const props = withDefaults(defineProps<{
modelValue: VisitorFormData
readonly?: boolean
}>(), { readonly: false })
const emit = defineEmits<{ 'update:modelValue': [value: VisitorFormData] }>()
const form = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
</script>
<style scoped lang="scss">
@use '@/assets/css/form-layout';
</style>