This commit is contained in:
2026-01-24 22:37:49 +08:00
parent 177c35cc15
commit 5569e73ef1
6 changed files with 647 additions and 24 deletions

3
.gitignore vendored
View File

@@ -36,4 +36,5 @@ coverage
__screenshots__/
yarn.lock
package-lock.json
package-lock.json
.clinerules

View File

@@ -0,0 +1,508 @@
<template>
<div class="sc-icon-picker">
<a-input :value="selectedIcon ? '' : ''" :placeholder="placeholder" readonly @click="handleOpenPicker">
<template #prefix v-if="selectedIcon">
<component :is="selectedIcon" />
</template>
<template #suffix>
<SearchOutlined v-if="!selectedIcon" />
<CloseCircleFilled v-else @click.stop="handleClear" />
</template>
</a-input>
<a-modal v-model:open="visible" title="选择图标" :width="800" :footer="null" @cancel="handleCancel">
<a-tabs v-model:activeKey="activeTab" @change="handleTabChange">
<a-tab-pane key="antd" tab="Ant Design">
<div class="icon-search">
<a-input v-model:value="searchAntdValue" placeholder="搜索图标..." allow-clear>
<template #prefix>
<SearchOutlined />
</template>
</a-input>
</div>
<div class="icon-list">
<div
v-for="icon in filteredAntdIcons"
:key="icon"
:class="['icon-item', { active: tempIcon === icon }]"
@click="handleSelectIcon(icon)"
>
<component :is="icon" />
<div class="icon-name">{{ icon }}</div>
</div>
<a-empty v-if="filteredAntdIcons.length === 0" description="暂无图标" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
</div>
</a-tab-pane>
<a-tab-pane key="element" tab="Element Plus">
<div class="icon-search">
<a-input v-model:value="searchElementValue" placeholder="搜索图标..." allow-clear>
<template #prefix>
<SearchOutlined />
</template>
</a-input>
</div>
<div class="icon-list">
<div
v-for="icon in filteredElementIcons"
:key="icon"
:class="['icon-item', { active: tempIcon === icon }]"
@click="handleSelectIcon(icon)"
>
<component :is="icon" />
<div class="icon-name">{{ icon.replace('El', '') }}</div>
</div>
<a-empty v-if="filteredElementIcons.length === 0" description="暂无图标" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
</div>
</a-tab-pane>
</a-tabs>
</a-modal>
</div>
</template>
<script setup>
/**
* @component scIconPicker
*/
import { ref, computed, watch } from 'vue'
import { Empty } from 'ant-design-vue'
import { SearchOutlined, CloseCircleFilled } from '@ant-design/icons-vue'
const props = defineProps({
modelValue: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '请选择图标',
},
})
const emit = defineEmits(['update:modelValue', 'change'])
const visible = ref(false)
const activeTab = ref('antd')
const searchAntdValue = ref('')
const searchElementValue = ref('')
const tempIcon = ref('')
// Ant Design 图标列表(常用图标)
const antdIcons = [
'HomeOutlined',
'UserOutlined',
'SettingOutlined',
'EditOutlined',
'DeleteOutlined',
'PlusOutlined',
'MinusOutlined',
'CheckOutlined',
'CloseOutlined',
'SearchOutlined',
'FilterOutlined',
'ReloadOutlined',
'DownloadOutlined',
'UploadOutlined',
'FileTextOutlined',
'FolderOutlined',
'PictureOutlined',
'VideoCameraOutlined',
'AudioOutlined',
'FileOutlined',
'CalendarOutlined',
'ClockCircleOutlined',
'HeartOutlined',
'StarOutlined',
'ThumbUpOutlined',
'MessageOutlined',
'PhoneOutlined',
'MailOutlined',
'EnvironmentOutlined',
'GlobalOutlined',
'LinkOutlined',
'LockOutlined',
'UnlockOutlined',
'EyeOutlined',
'EyeInvisibleOutlined',
'ArrowLeftOutlined',
'ArrowRightOutlined',
'ArrowUpOutlined',
'ArrowDownOutlined',
'CaretLeftOutlined',
'CaretRightOutlined',
'CaretUpOutlined',
'CaretDownOutlined',
'LeftOutlined',
'RightOutlined',
'UpOutlined',
'DownOutlined',
'MenuFoldOutlined',
'MenuUnfoldOutlined',
'BarsOutlined',
'MoreOutlined',
'EllipsisOutlined',
'DashboardOutlined',
'AppstoreOutlined',
'LaptopOutlined',
'DesktopOutlined',
'TabletOutlined',
'MobileOutlined',
'WifiOutlined',
'BluetoothOutlined',
'ThunderboltOutlined',
'BulbOutlined',
'SoundOutlined',
'NotificationOutlined',
'BellOutlined',
'AlertOutlined',
'WarningOutlined',
'InfoCircleOutlined',
'QuestionCircleOutlined',
'CheckCircleOutlined',
'CloseCircleOutlined',
'StopOutlined',
'ExclamationCircleOutlined',
'SafetyOutlined',
'ShieldCheckOutlined',
'SecurityScanOutlined',
'KeyOutlined',
'IdcardOutlined',
'ProfileOutlined',
'SolutionOutlined',
'ContactsOutlined',
'TeamOutlined',
'UsergroupAddOutlined',
'UsergroupDeleteOutlined',
'CrownOutlined',
'GoldOutlined',
'MoneyCollectOutlined',
'BankOutlined',
'PayCircleOutlined',
'CreditCardOutlined',
'WalletOutlined',
'ShoppingCartOutlined',
'ShoppingOutlined',
'GiftOutlined',
'HddOutlined',
'DatabaseOutlined',
'CloudOutlined',
'CloudUploadOutlined',
'CloudDownloadOutlined',
'ServerOutlined',
'AuditOutlined',
'NodeIndexOutlined',
'ReconciliationOutlined',
'PartitionOutlined',
'AccountBookOutlined',
'ProjectOutlined',
'ControlOutlined',
'MonitorOutlined',
'TagsOutlined',
'TagOutlined',
'BookOutlined',
'ReadOutlined',
'ExperimentOutlined',
'FireOutlined',
'RocketOutlined',
'TrophyOutlined',
'MedalOutlined',
'DiamondOutlined',
'ThunderboltTwoTone',
]
// Element Plus 图标列表(常用图标)
const elementIcons = [
'ElIconEdit',
'ElIconDelete',
'ElIconSearch',
'ElIconClose',
'ElIconCheck',
'ElIconPlus',
'ElIconMinus',
'ElIconUpload',
'ElIconDownload',
'ElIconSetting',
'ElIconRefresh',
'ElIconRefreshLeft',
'ElIconRefreshRight',
'ElIconMenu',
'ElIconMore',
'ElIconMoreFilled',
'ElIconStar',
'ElIconStarFilled',
'ElIconSunny',
'ElIconMoon',
'ElIconBell',
'ElIconBellFilled',
'ElIconMessage',
'ElIconMessageFilled',
'ElIconChatDotRound',
'ElIconChatLineSquare',
'ElIconChatDotSquare',
'ElIconPhone',
'ElIconPhoneFilled',
'ElIconLocation',
'ElIconLocationFilled',
'ElIconLocationInformation',
'ElIconView',
'ElIconHide',
'ElIconLock',
'ElIconUnlock',
'ElIconKey',
'ElIconTickets',
'ElIconDocument',
'ElIconDocumentAdd',
'ElIconDocumentDelete',
'ElIconDocumentCopy',
'ElIconDocumentChecked',
'ElIconDocumentRemove',
'ElIconFolder',
'ElIconFolderOpened',
'ElIconFolderAdd',
'ElIconFolderDelete',
'ElIconFolderChecked',
'ElIconFiles',
'ElIconPicture',
'ElIconPictureRounded',
'ElIconPictureFilled',
'ElIconVideoCamera',
'ElIconVideoCameraFilled',
'ElIconMicrophone',
'ElIconMicrophoneFilled',
'ElIconHeadset',
'ElIconHeadsetFilled',
'ElIconMuteNotification',
'ElIconNotification',
'ElIconWarning',
'ElIconWarningFilled',
'ElIconInfoFilled',
'ElIconSuccessFilled',
'ElIconCircleCheck',
'ElIconCircleCheckFilled',
'ElIconCircleClose',
'ElIconCircleCloseFilled',
'ElIconCirclePlus',
'ElIconCirclePlusFilled',
'ElIconCircleMinus',
'ElIconCircleMinusFilled',
'ElIconAim',
'ElIconPosition',
'ElIconCompass',
'ElIconMapLocation',
'ElIconPromotion',
'ElIconDownload',
'ElIconUploadFilled',
'ElIconShare',
'ElIconConnection',
'ElIconLink',
'ElIconUnlink',
'ElIconOperation',
'ElIconDataAnalysis',
'ElIconDataLine',
'ElIconDataBoard',
'ElIconHistogram',
'ElIconTrendCharts',
'ElIconPieChart',
'ElIconOdometer',
'ElIconMonitor',
'ElIconTimer',
'ElIconClock',
'ElIconAlarmClock',
'ElIconCalendar',
'ElIconDate',
'ElIconSwitch',
'ElIconSwitchButton',
'ElIconTools',
'ElIconScrewdriver',
'ElIconHammer',
'ElIconBrush',
'ElIconEditPen',
'ElIconBriefcase',
'ElIconWallet',
'ElIconGoods',
'ElIconShoppingCart',
'ElIconShoppingCartFull',
'ElIconShoppingBag',
'ElIconPresent',
'ElIconSoldOut',
'ElIconSell',
'ElIconDiscount',
'ElIconTicket',
'ElIconCoin',
'ElIconMoney',
'ElIconWalletFilled',
'ElIconCreditCard',
'ElIconUser',
'ElIconUserFilled',
'ElIconAvatar',
'ElIconSuitcase',
'ElIconGrid',
'ElIconMenuFilled',
'ElIconHomeFilled',
'ElIconHouse',
'ElIconOfficeBuilding',
'ElIconSchool',
'ElIconReading',
'ElIconReadingLamp',
'ElIconNotebook',
'ElIconNotebookFilled',
'ElIconFinished',
'ElIconCollection',
'ElIconCollectionTag',
'ElIconFiles',
'ElIconPostcard',
'ElIconMemo',
'ElIconStamp',
'ElIconPriceTag',
'ElIconMedal',
'ElIconTrophy',
'ElIconTrophyBase',
'ElIconFirstAidKit',
'ElIconToiletPaper',
'ElIconAim',
'ElIconSFlag',
'ElIconSOpportunity',
'ElIconMagicStick',
'ElIconHelp',
'ElIconQuestionFilled',
'ElIconWarning',
'ElIconWarningFilled',
]
// 当前选中的图标
const selectedIcon = ref(props.modelValue)
// 过滤后的 Ant Design 图标
const filteredAntdIcons = computed(() => {
if (!searchAntdValue.value) {
return antdIcons
}
return antdIcons.filter((icon) =>
icon.toLowerCase().includes(searchAntdValue.value.toLowerCase()),
)
})
// 过滤后的 Element 图标
const filteredElementIcons = computed(() => {
if (!searchElementValue.value) {
return elementIcons
}
return elementIcons.filter((icon) =>
icon.toLowerCase().includes(searchElementValue.value.toLowerCase()),
)
})
// 打开选择器
const handleOpenPicker = () => {
tempIcon.value = props.modelValue
// 根据当前图标设置默认标签页
if (props.modelValue) {
activeTab.value = props.modelValue.startsWith('El') ? 'element' : 'antd'
}
visible.value = true
}
// 清除选择
const handleClear = () => {
emit('update:modelValue', '')
emit('change', '')
}
// 切换标签页
const handleTabChange = (key) => {
activeTab.value = key
}
// 选择图标(直接确认并关闭)
const handleSelectIcon = (icon) => {
emit('update:modelValue', icon)
emit('change', icon)
selectedIcon.value = icon
visible.value = false
}
// 取消选择
const handleCancel = () => {
visible.value = false
}
// 监听props变化更新本地状态
watch(
() => props.modelValue,
(newVal) => {
selectedIcon.value = newVal
}
)
</script>
<style scoped lang="scss">
.sc-icon-picker {
:deep(.ant-input) {
cursor: pointer;
}
.icon-search {
margin-bottom: 16px;
}
.icon-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 12px;
max-height: 400px;
overflow-y: auto;
padding: 8px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: #d9d9d9;
border-radius: 3px;
&:hover {
background: #bfbfbf;
}
}
.icon-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 12px 8px;
border: 1px solid #d9d9d9;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s;
&:hover {
border-color: #1890ff;
color: #1890ff;
}
&.active {
background-color: #e6f7ff;
border-color: #1890ff;
color: #1890ff;
}
:deep(svg) {
font-size: 24px;
margin-bottom: 8px;
}
.icon-name {
font-size: 12px;
color: #666;
text-align: center;
word-break: break-all;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
}
}
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<template v-for="item in menuItems" :key="item.path || item.name">
<!-- 有子菜单 - 使用递归 -->
<a-sub-menu v-if="item.children && item.children.length > 0" :key="`submenu-${item.path}`">
<a-sub-menu v-if="item.children && item.children.length > 0" :key="`${item.path}`">
<template #icon v-if="item.meta?.icon">
<component :is="getIconComponent(item.meta.icon)" />
</template>

View File

@@ -20,21 +20,35 @@
</div>
<div class="tags-actions">
<a-tooltip title="刷新当前页">
<a-button size="small" type="text" @click="refreshSelectedTag">
<ReloadOutlined />
<a-dropdown v-model:open="actionMenuVisible" trigger="click" placement="bottomRight">
<a-button size="small" type="text">
<MoreOutlined />
</a-button>
</a-tooltip>
<a-tooltip title="关闭其他">
<a-button size="small" type="text" @click="closeOthersTags">
<ColumnWidthOutlined />
</a-button>
</a-tooltip>
<a-tooltip title="关闭所有">
<a-button size="small" type="text" @click="closeAllTags">
<CloseCircleOutlined />
</a-button>
</a-tooltip>
<template #overlay>
<a-menu @click="handleActionMenuClick">
<a-menu-item key="refresh">
<ReloadOutlined />
<span>刷新当前页</span>
</a-menu-item>
<a-menu-item key="closeOthers">
<ColumnWidthOutlined />
<span>关闭其他</span>
</a-menu-item>
<a-menu-item key="closeLeft">
<LeftOutlined />
<span>关闭左侧</span>
</a-menu-item>
<a-menu-item key="closeRight">
<RightOutlined />
<span>关闭右侧</span>
</a-menu-item>
<a-menu-item key="closeAll">
<CloseCircleOutlined />
<span>关闭所有</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<!-- 右键菜单 -->
@@ -76,13 +90,6 @@
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useLayoutStore } from '@/stores/modules/layout'
import {
ReloadOutlined,
CloseOutlined,
ColumnWidthOutlined,
CloseCircleOutlined,
PushpinFilled
} from '@ant-design/icons-vue'
import config from '@/config'
defineOptions({
@@ -102,6 +109,8 @@ const contextMenu = ref({
x: 0,
y: 0
})
// 顶部操作菜单状态
const actionMenuVisible = ref(false)
// 判断是否是当前激活的标签
const isActive = (tag) => {
@@ -179,6 +188,38 @@ const closeAllTags = () => {
}
}
// 关闭左侧标签
const closeLeftTags = () => {
const currentTag = selectedTag.value || visitedViews.value.find((tag) => isActive(tag))
if (!currentTag) return
const currentIndex = visitedViews.value.findIndex((tag) => tag.fullPath === currentTag.fullPath)
if (currentIndex === -1) return
// 保留当前标签及其右侧的标签,以及所有固定标签
const tagsToKeep = visitedViews.value.filter((tag, index) => {
return tag.meta?.affix || index >= currentIndex
})
layoutStore.viewTags = tagsToKeep
}
// 关闭右侧标签
const closeRightTags = () => {
const currentTag = selectedTag.value || visitedViews.value.find((tag) => isActive(tag))
if (!currentTag) return
const currentIndex = visitedViews.value.findIndex((tag) => tag.fullPath === currentTag.fullPath)
if (currentIndex === -1) return
// 保留当前标签及其左侧的标签,以及所有固定标签
const tagsToKeep = visitedViews.value.filter((tag, index) => {
return tag.meta?.affix || index <= currentIndex
})
layoutStore.viewTags = tagsToKeep
}
// 点击标签
const clickTag = (tag) => {
if (!isActive(tag)) {
@@ -248,6 +289,28 @@ const handleMenuClick = ({ key }) => {
closeContextMenu()
}
// 顶部操作菜单点击处理
const handleActionMenuClick = ({ key }) => {
switch (key) {
case 'refresh':
refreshSelectedTag()
break
case 'closeOthers':
closeOthersTags()
break
case 'closeLeft':
closeLeftTags()
break
case 'closeRight':
closeRightTags()
break
case 'closeAll':
closeAllTags()
break
}
actionMenuVisible.value = false
}
// 点击其他地方关闭右键菜单
const handleClickOutside = (event) => {
if (contextMenu.value.visible) {

View File

@@ -41,7 +41,7 @@
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="菜单图标" name="icon">
<a-input v-model:value="form.icon" placeholder="请输入图标类名" allow-clear />
<sc-icon-picker v-model="form.icon" placeholder="请选择图标" />
</a-form-item>
</a-col>
<a-col :span="12">
@@ -169,6 +169,7 @@ import { ref, reactive, watch, onMounted } from 'vue'
import { message, Empty } from 'ant-design-vue'
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons-vue'
import authApi from '@/api/auth'
import ScIconPicker from '@/components/scIconPicker/index.vue'
defineOptions({
name: 'PermissionSave'

View File

@@ -0,0 +1,50 @@
<template>
<div class="icon-picker-demo">
<a-card title="图标选择器演示" style="max-width: 600px; margin: 20px auto;">
<a-form :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }">
<a-form-item label="选择图标">
<sc-icon-picker v-model="selectedIcon" @change="handleIconChange" />
</a-form-item>
<a-form-item label="选中值">
<a-input v-model:value="selectedIcon" readonly />
</a-form-item>
<a-form-item label="图标预览">
<div v-if="selectedIcon" class="icon-preview">
<component :is="selectedIcon" style="font-size: 48px;" />
</div>
<a-empty v-else description="未选择图标" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
</a-form-item>
</a-form>
</a-card>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { Empty } from 'ant-design-vue'
import ScIconPicker from '@/components/scIconPicker/index.vue'
const selectedIcon = ref('')
const handleIconChange = (value) => {
console.log('图标已选择:', value)
}
</script>
<style scoped lang="scss">
.icon-picker-demo {
padding: 20px;
.icon-preview {
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
border: 1px dashed #d9d9d9;
border-radius: 6px;
min-height: 88px;
}
}
</style>