web/frps: redesign frps dashboard with sidebar nav, responsive layout, and shared component workspace (#5246)

This commit is contained in:
fatedier
2026-03-20 03:33:44 +08:00
committed by GitHub
parent 6cdef90113
commit 38a71a6803
38 changed files with 1484 additions and 8548 deletions

View File

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

View File

@@ -0,0 +1,141 @@
<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'
const props = withDefaults(
defineProps<{
modelValue: boolean
title: string
width?: string
destroyOnClose?: boolean
closeOnClickModal?: boolean
closeOnPressEscape?: boolean
appendToBody?: boolean
top?: string
isMobile?: boolean
}>(),
{
width: '480px',
destroyOnClose: true,
closeOnClickModal: true,
closeOnPressEscape: true,
appendToBody: false,
top: '15vh',
isMobile: false,
},
)
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
const dialogWidth = computed(() => {
if (props.isMobile) return '100%'
return props.width
})
const dialogTop = computed(() => {
if (props.isMobile) return '0'
return props.top
})
</script>
<style lang="scss">
.base-dialog.el-dialog {
border-radius: 16px;
.el-dialog__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px;
min-height: 42px;
margin: 0;
position: relative;
&::after {
content: "";
position: absolute;
bottom: 0;
left: 8px;
right: 8px;
height: 1px;
background: $color-border-lighter;
}
}
.el-dialog__title {
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
}
.el-dialog__body {
padding: 16px 8px;
}
.el-dialog__headerbtn {
position: static;
width: 32px;
height: 32px;
@include flex-center;
border-radius: $radius-sm;
transition: background $transition-fast;
&:hover {
background: $color-bg-hover;
}
}
.el-dialog__footer {
padding: 8px;
display: flex;
justify-content: flex-end;
gap: 8px;
}
&.mobile-dialog {
border-radius: 0;
margin: 0;
height: 100%;
max-height: 100dvh;
display: flex;
flex-direction: column;
.el-dialog__body {
flex: 1;
overflow-y: auto;
padding: 16px 12px;
}
.el-dialog__footer {
padding: 8px 12px;
padding-bottom: calc(8px + env(safe-area-inset-bottom));
}
}
}
</style>

View File

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

View File

@@ -0,0 +1,100 @@
<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'
interface Props {
modelValue: string
label: string
options: Array<{ label: string; value: string }>
allLabel?: string
width?: number
minWidth?: number
isMobile?: boolean
}
const props = withDefaults(defineProps<Props>(), {
allLabel: 'All',
width: 150,
})
defineEmits<{
'update:modelValue': [value: string]
}>()
const displayLabel = computed(() => {
if (!props.modelValue) return props.allLabel
const found = props.options.find((o) => o.value === props.modelValue)
return found ? found.label : props.modelValue
})
</script>
<style scoped lang="scss">
.filter-trigger {
display: inline-flex;
align-items: center;
gap: $spacing-sm;
padding: 7px 12px;
background: $color-bg-primary;
border: none;
border-radius: $radius-md;
box-shadow: 0 0 0 1px $color-border-light inset;
font-size: $font-size-sm;
color: $color-text-secondary;
cursor: pointer;
transition: box-shadow $transition-fast;
white-space: nowrap;
&:hover {
box-shadow: 0 0 0 1px $color-border inset;
}
&.has-value .filter-value {
color: $color-text-primary;
}
}
.filter-label {
color: $color-text-muted;
flex-shrink: 0;
}
.filter-value {
color: $color-text-secondary;
margin-left: auto;
}
.filter-arrow {
font-size: 12px;
color: $color-text-light;
flex-shrink: 0;
}
</style>

View File

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

View File

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