forked from Mxmilu666/frp
web/frpc: redesign frpc dashboard with sidebar nav, proxy/visitor list and detail views (#5237)
This commit is contained in:
144
web/frpc/src/components/ActionButton.vue
Normal file
144
web/frpc/src/components/ActionButton.vue
Normal 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>
|
||||
142
web/frpc/src/components/BaseDialog.vue
Normal file
142
web/frpc/src/components/BaseDialog.vue
Normal 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>
|
||||
249
web/frpc/src/components/ConfigField.vue
Normal file
249
web/frpc/src/components/ConfigField.vue
Normal 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>
|
||||
185
web/frpc/src/components/ConfigSection.vue
Normal file
185
web/frpc/src/components/ConfigSection.vue
Normal 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>
|
||||
84
web/frpc/src/components/ConfirmDialog.vue
Normal file
84
web/frpc/src/components/ConfirmDialog.vue
Normal 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>
|
||||
102
web/frpc/src/components/FilterDropdown.vue
Normal file
102
web/frpc/src/components/FilterDropdown.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
303
web/frpc/src/components/PopoverMenu.vue
Normal file
303
web/frpc/src/components/PopoverMenu.vue
Normal 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>
|
||||
125
web/frpc/src/components/PopoverMenuItem.vue
Normal file
125
web/frpc/src/components/PopoverMenuItem.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
103
web/frpc/src/components/StatusPills.vue
Normal file
103
web/frpc/src/components/StatusPills.vue
Normal 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>
|
||||
141
web/frpc/src/components/StringListEditor.vue
Normal file
141
web/frpc/src/components/StringListEditor.vue
Normal 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>
|
||||
40
web/frpc/src/components/proxy-form/ProxyAuthSection.vue
Normal file
40
web/frpc/src/components/proxy-form/ProxyAuthSection.vue
Normal 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>
|
||||
149
web/frpc/src/components/proxy-form/ProxyBackendSection.vue
Normal file
149
web/frpc/src/components/proxy-form/ProxyBackendSection.vue
Normal 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>
|
||||
51
web/frpc/src/components/proxy-form/ProxyBaseSection.vue
Normal file
51
web/frpc/src/components/proxy-form/ProxyBaseSection.vue
Normal 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>
|
||||
50
web/frpc/src/components/proxy-form/ProxyFormLayout.vue
Normal file
50
web/frpc/src/components/proxy-form/ProxyFormLayout.vue
Normal 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>
|
||||
52
web/frpc/src/components/proxy-form/ProxyHealthSection.vue
Normal file
52
web/frpc/src/components/proxy-form/ProxyHealthSection.vue
Normal 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>
|
||||
32
web/frpc/src/components/proxy-form/ProxyHttpSection.vue
Normal file
32
web/frpc/src/components/proxy-form/ProxyHttpSection.vue
Normal 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>
|
||||
@@ -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>
|
||||
29
web/frpc/src/components/proxy-form/ProxyMetadataSection.vue
Normal file
29
web/frpc/src/components/proxy-form/ProxyMetadataSection.vue
Normal 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>
|
||||
29
web/frpc/src/components/proxy-form/ProxyNatSection.vue
Normal file
29
web/frpc/src/components/proxy-form/ProxyNatSection.vue
Normal 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>
|
||||
41
web/frpc/src/components/proxy-form/ProxyRemoteSection.vue
Normal file
41
web/frpc/src/components/proxy-form/ProxyRemoteSection.vue
Normal 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>
|
||||
39
web/frpc/src/components/proxy-form/ProxyTransportSection.vue
Normal file
39
web/frpc/src/components/proxy-form/ProxyTransportSection.vue
Normal 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>
|
||||
40
web/frpc/src/components/visitor-form/VisitorBaseSection.vue
Normal file
40
web/frpc/src/components/visitor-form/VisitorBaseSection.vue
Normal 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>
|
||||
@@ -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>
|
||||
33
web/frpc/src/components/visitor-form/VisitorFormLayout.vue
Normal file
33
web/frpc/src/components/visitor-form/VisitorFormLayout.vue
Normal 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>
|
||||
@@ -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>
|
||||
47
web/frpc/src/components/visitor-form/VisitorXtcpSection.vue
Normal file
47
web/frpc/src/components/visitor-form/VisitorXtcpSection.vue
Normal 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>
|
||||
Reference in New Issue
Block a user