mirror of
https://github.com/fatedier/frp.git
synced 2026-03-21 17:29:16 +08:00
web/frps: redesign frps dashboard with sidebar nav, responsive layout, and shared component workspace (#5246)
This commit is contained in:
144
web/shared/components/ActionButton.vue
Normal file
144
web/shared/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>
|
||||
141
web/shared/components/BaseDialog.vue
Normal file
141
web/shared/components/BaseDialog.vue
Normal 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>
|
||||
87
web/shared/components/ConfirmDialog.vue
Normal file
87
web/shared/components/ConfirmDialog.vue
Normal 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>
|
||||
100
web/shared/components/FilterDropdown.vue
Normal file
100
web/shared/components/FilterDropdown.vue
Normal 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>
|
||||
303
web/shared/components/PopoverMenu.vue
Normal file
303
web/shared/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/shared/components/PopoverMenuItem.vue
Normal file
125
web/shared/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>
|
||||
Reference in New Issue
Block a user