Files
mokeegateway/mokee-gateway-web/src/components/IconPicker.vue
2026-07-16 09:54:13 +08:00

270 lines
9.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="icon-picker">
<div class="icon-trigger" @click="visible = true">
<template v-if="modelValue">
<img v-if="isUrl(modelValue)" :src="modelValue" class="trigger-img" />
<el-icon v-else :size="20"><component :is="Icons[modelValue]" /></el-icon>
</template>
<span v-else class="placeholder">选择图标</span>
<el-icon class="arrow"><ArrowDown /></el-icon>
</div>
<Teleport to="body">
<div v-if="visible" class="icon-overlay" @click="visible = false">
<div class="icon-popup" @click.stop>
<div class="popup-header">
<span>选择图标</span>
<el-radio-group v-model="tab" size="small">
<el-radio-button value="builtin">内置图标</el-radio-button>
<el-radio-button value="upload">已上传</el-radio-button>
</el-radio-group>
<el-button v-if="tab === 'upload'" size="small" type="primary" :loading="uploading" @click="triggerUploadIcon">
<el-icon><Upload /></el-icon>上传
</el-button>
<input ref="fileInputRef" type="file" accept="image/*" style="display:none" @change="onIconFileSelected" />
<el-input v-model="search" placeholder="搜索..." size="small" clearable style="width:200px" />
<span class="count">{{ filtered.length }} </span>
<el-icon class="close-btn" :size="20" @click="visible = false"><Close /></el-icon>
</div>
<!-- 内置图标 -->
<div class="icon-grid" v-if="tab === 'builtin' && filtered.length > 0">
<div
v-for="ico in filtered" :key="ico"
:class="['icon-cell', { active: modelValue === ico }]"
@click="select(ico)"
:title="ico"
>
<el-icon :size="36"><component :is="Icons[ico]" /></el-icon>
<span class="icon-name">{{ ico }}</span>
</div>
</div>
<!-- 已上传图标 -->
<div v-if="tab === 'upload' && uploading" class="upload-status-bar">
<el-icon class="is-loading"><Loading /></el-icon>
<span>正在上传图标...</span>
<el-progress :percentage="uploadPercent" :stroke-width="6" :show-text="true" style="flex:1;max-width:200px" />
</div>
<div class="icon-grid uploaded-grid" v-if="tab === 'upload' && filteredUploads.length > 0">
<div
v-for="item in filteredUploads" :key="item.id"
:class="['icon-cell', { active: modelValue === getFileUrl(item.id) }]"
@click="select(getFileUrl(item.id))"
:title="item.fileName"
>
<img :src="getFileUrl(item.id)" class="upload-icon-thumb" />
<span class="icon-name">{{ item.fileName }}</span>
</div>
</div>
<el-empty v-if="(tab === 'builtin' && filtered.length === 0) || (tab === 'upload' && filteredUploads.length === 0)"
description="无匹配图标" :image-size="60" />
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ArrowDown, Close, Upload, Loading } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import * as Icons from '@element-plus/icons-vue'
import request from '@/utils/request'
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ (e: 'update:modelValue', v: string): void }>()
const visible = ref(false)
const search = ref('')
const tab = ref('builtin')
const uploadedIcons = ref<{id:string, fileName:string}[]>([])
// 上传相关状态
const FILE_SERVICE = import.meta.env.VITE_FILE_SERVICE_URL || 'https://file.wg.zgitm.com'
const uploading = ref(false)
const uploadPercent = ref(0)
const fileInputRef = ref<HTMLInputElement>()
const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || ''
function isUrl(v: string) { return v && (v.startsWith('http') || v.startsWith('/zgapi')) }
function getFileUrl(id: string) { return `${gatewayUrl}/zgapi/v1/admin/file/${id}/view` }
// 内置图标
const builtinIcons = Object.keys(Icons).filter(k =>
k !== 'default' && !k.startsWith('_') && k !== 'Menu'
)
const filtered = computed(() => {
const s = search.value.toLowerCase()
return s ? builtinIcons.filter(i => i.toLowerCase().includes(s)) : builtinIcons
})
// 已上传图标
const filteredUploads = computed(() => {
const s = search.value.toLowerCase()
return s
? uploadedIcons.value.filter(i => i.fileName.toLowerCase().includes(s))
: uploadedIcons.value
})
async function fetchUploadedIcons() {
try {
const res = await request.get('/zgapi/v1/admin/file/list', { params: { fileType: 'icon' } })
uploadedIcons.value = res.data || []
} catch {}
}
/** 获取文件服务上传 Token直调文件服务不走网关 */
let cachedFileToken = ''
async function getFileServiceToken(): Promise<string> {
if (cachedFileToken) return cachedFileToken
try {
const axios = (await import('axios')).default
const res = await axios.post(`${FILE_SERVICE}/api/token`, { systemCode: 'gateway' }, { timeout: 10000 })
const token = res.data?.data?.token || ''
if (token) cachedFileToken = token
return token
} catch { return '' }
}
/** 触发文件选择器 */
function triggerUploadIcon() {
fileInputRef.value?.click()
}
/** 文件选择后立即上传 */
async function onIconFileSelected(e: Event) {
const files = (e.target as HTMLInputElement).files
if (!files || files.length === 0) return
const file = files[0]
// 重置 input 以允许重复选择同一文件
if (fileInputRef.value) fileInputRef.value.value = ''
uploading.value = true
uploadPercent.value = 0
try {
// 1. 获取上传 Token
const token = await getFileServiceToken()
if (!token) {
ElMessage.error('获取上传 Token 失败')
return
}
// 2. 上传到文件服务(指定 fileType=icon
const axios = (await import('axios')).default
const fd = new FormData()
fd.append('file', file)
fd.append('fileType', 'icon')
const uploadRes = await axios.post(`${FILE_SERVICE}/api/upload`, fd, {
timeout: 120000,
headers: { Authorization: 'Bearer ' + token },
onUploadProgress: (e: any) => {
if (e.total > 0) uploadPercent.value = Math.round((e.loaded / e.total) * 100)
},
})
// Token 用完即清
cachedFileToken = ''
// 3. 刷新已上传图标列表
await fetchUploadedIcons()
// 4. 自动选中新上传的图标
const fileId = uploadRes.data?.data?.fileId
if (fileId) {
select(getFileUrl(fileId))
}
ElMessage.success('图标上传成功')
} catch {
cachedFileToken = ''
ElMessage.error('图标上传失败')
} finally {
uploading.value = false
uploadPercent.value = 0
}
}
function select(val: string) {
emit('update:modelValue', val)
visible.value = false
}
onMounted(fetchUploadedIcons)
</script>
<style scoped>
.icon-picker { width: 100%; }
.icon-trigger {
display: flex; align-items: center; gap: 8px;
height: 36px; padding: 0 12px;
background: var(--bg-surface); border: 1px solid var(--border-color);
border-radius: var(--radius-md); cursor: pointer; transition: all var(--transition-fast);
}
.icon-trigger:hover { border-color: var(--color-primary); }
.placeholder { font-size: 13px; color: var(--text-muted); flex: 1; }
.arrow { color: var(--text-muted); flex-shrink: 0; }
.icon-overlay {
position: fixed; inset: 0; z-index: 32000;
background: rgba(0,0,0,0.35);
display: flex; align-items: center; justify-content: center;
}
.icon-popup {
width: 920px; max-height: 640px;
background: var(--bg-surface); border-radius: var(--radius-xl);
box-shadow: var(--shadow-xl); overflow: hidden;
display: flex; flex-direction: column;
}
.popup-header {
display: flex; align-items: center; gap: 12px;
padding: 16px 20px; border-bottom: 1px solid var(--border-light);
font-weight: 600; font-size: 15px; flex-shrink: 0;
}
.popup-header span { flex: 1; }
.count { font-size: 12px; color: var(--text-muted); font-weight: 400; }
.close-btn { cursor: pointer; color: var(--text-muted); flex-shrink: 0; }
.close-btn:hover { color: var(--text-primary); }
.icon-grid {
flex: 1; overflow-y: auto; padding: 20px;
display: grid; grid-template-columns: repeat(10, 1fr); gap: 10px;
}
.icon-cell {
display: flex; flex-direction: column; align-items: center; gap: 6px;
padding: 14px 6px; border-radius: var(--radius-md); cursor: pointer;
transition: all var(--transition-fast); border: 2px solid transparent;
}
.icon-cell:hover { background: var(--bg-hover); border-color: var(--border-color); }
.icon-cell.active { background: var(--color-primary-bg); border-color: var(--color-primary); color: var(--color-primary); }
.icon-name { font-size: 10px; color: var(--text-muted); text-align: center; word-break: break-all; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
.icon-cell.active .icon-name { color: var(--color-primary); }
/* 触发器中的图片预览 */
.trigger-img { width:20px; height:20px; object-fit:contain; border-radius:3px; }
/* 上传进度条 */
.upload-status-bar {
display: flex; align-items: center; gap: 10px;
padding: 12px 20px; margin: 0 20px;
background: var(--color-primary-bg, #eef2ff); border-radius: var(--radius-md);
font-size: 13px; color: var(--color-primary);
}
.upload-status-bar .is-loading { animation: spin 1s linear infinite; }
/* 已上传图标 */
.uploaded-grid { grid-template-columns: repeat(8, 1fr); }
.upload-icon-thumb { width:48px; height:48px; object-fit:contain; border-radius:var(--radius-sm); }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width:960px) {
.icon-popup { width:98vw; max-height:90vh; }
.icon-grid { grid-template-columns: repeat(6, 1fr); }
.uploaded-grid { grid-template-columns: repeat(4, 1fr); }
}
</style>