初始化

This commit is contained in:
2026-03-03 09:53:54 +08:00
commit 3f349a35a4
437 changed files with 65639 additions and 0 deletions

View File

@@ -0,0 +1,71 @@
<!-- 表格按钮 -->
<template>
<div>
<el-tooltip :disabled="toolTip === ''" :content="toolTip" placement="top">
<div
:class="[
'inline-flex items-center justify-center min-w-8 h-8 px-2.5 text-sm c-p rounded-md align-middle',
buttonClass
]"
:style="{ backgroundColor: buttonBgColor, color: iconColor }"
@click="handleClick"
>
<Icon v-bind="bindAttrs" :icon="iconContent" class="art-svg-icon inline" />
</div>
</el-tooltip>
</div>
</template>
<script setup lang="ts">
import { Icon } from '@iconify/vue'
defineOptions({ name: 'SaButton' })
interface Props {
/** 按钮类型 */
type?: 'primary' | 'secondary' | 'error' | 'info' | 'success'
/** 按钮图标 */
icon?: string
/** 按钮工具提示 */
toolTip?: string
/** icon 颜色 */
iconColor?: string
/** 按钮背景色 */
buttonBgColor?: string
}
const props = withDefaults(defineProps<Props>(), { toolTip: '' })
const attrs = useAttrs()
const bindAttrs = computed<{ class: string; style: string }>(() => ({
class: (attrs.class as string) || '',
style: (attrs.style as string) || ''
}))
const emit = defineEmits<{
(e: 'click'): void
}>()
// 默认按钮配置
const defaultButtons = {
primary: { icon: 'ri:add-fill', class: 'bg-primary/12 text-primary' },
secondary: { icon: 'ri:pencil-line', class: 'bg-secondary/12 text-secondary' },
error: { icon: 'ri:delete-bin-5-line', class: 'bg-error/12 text-error' },
info: { icon: 'ri:more-2-fill', class: 'bg-info/12 text-info' },
success: { icon: 'ri:eye-line', class: 'bg-success/12 text-success' }
} as const
// 获取图标内容
const iconContent = computed(() => {
return props.icon || (props.type ? defaultButtons[props.type]?.icon : '') || ''
})
// 获取按钮样式类
const buttonClass = computed(() => {
return props.type ? defaultButtons[props.type]?.class : ''
})
const handleClick = () => {
emit('click')
}
</script>

View File

@@ -0,0 +1,108 @@
<template>
<el-checkbox-group
v-model="modelValue"
v-bind="$attrs"
:disabled="disabled"
:size="size"
:fill="fill"
:text-color="textColor"
>
<!-- 模式1: 按钮样式 -->
<template v-if="type === 'button'">
<el-checkbox-button
v-for="(item, index) in options"
:key="index"
:value="item.value"
:label="item.value"
:disabled="item.disabled"
>
{{ item.label }}
</el-checkbox-button>
</template>
<!-- 模式2: 普通/边框样式 -->
<template v-else>
<el-checkbox
v-for="(item, index) in options"
:key="index"
:value="item.value"
:label="item.value"
:border="type === 'border'"
:disabled="item.disabled"
>
{{ item.label }}
</el-checkbox>
</template>
</el-checkbox-group>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useDictStore } from '@/store/modules/dict'
defineOptions({ name: 'SaCheckbox', inheritAttrs: false })
interface Props {
dict: string
type?: 'checkbox' | 'button' | 'border'
disabled?: boolean
size?: 'large' | 'default' | 'small'
fill?: string
textColor?: string
/**
* 强制转换字典值的类型
* 可选值: 'number' | 'string'
* 默认使用 'number'
*/
valueType?: 'number' | 'string'
}
const props = withDefaults(defineProps<Props>(), {
type: 'checkbox',
disabled: false,
size: 'default',
fill: '',
textColor: '',
valueType: 'number'
})
const modelValue = defineModel<(string | number)[]>()
const dictStore = useDictStore()
const canConvertToNumberStrict = (value: any) => {
if (value == null) return false
if (typeof value === 'boolean') return false
if (Array.isArray(value) && value.length !== 1) return false
if (typeof value === 'object' && !Array.isArray(value)) return false
const num = Number(value)
return !isNaN(num)
}
const options = computed(() => {
const list = dictStore.getByCode(props.dict) || []
if (!props.valueType) return list
return list.map((item) => {
let newValue = item.value
switch (props.valueType) {
case 'number':
if (canConvertToNumberStrict(item.value)) {
newValue = Number(item.value)
}
break
case 'string':
newValue = String(item.value)
break
}
return {
...item,
value: newValue
}
})
})
</script>

View File

@@ -0,0 +1,216 @@
# sa-chunk-upload 切片上传组件
一个支持大文件分片上传的 Vue 3 组件,基于 Element Plus 的 el-upload 封装。
## 功能特性
-**分片上传**: 自动将大文件切分成小块上传,支持自定义分片大小
-**MD5 校验**: 自动计算文件 MD5 哈希值,用于文件去重和完整性校验
-**进度跟踪**: 实时显示上传进度、当前分片、上传速度
-**断点续传**: 支持上传失败后重试(需后端配合)
-**取消上传**: 可随时取消正在进行的上传任务
-**拖拽上传**: 支持拖拽文件到指定区域上传
-**文件验证**: 支持文件类型和大小验证
-**并发控制**: 串行上传多个文件,避免服务器压力过大
## Props 参数
| 参数 | 说明 | 类型 | 默认值 |
|------|------|------|--------|
| modelValue | v-model 绑定值,文件 URL | `string \| string[]` | `[]` |
| multiple | 是否支持多选文件 | `boolean` | `false` |
| limit | 最大上传文件数量 | `number` | `1` |
| maxSize | 单个文件最大大小MB | `number` | `1024` |
| chunkSize | 分片大小MB | `number` | `5` |
| accept | 接受的文件类型 | `string` | `'*'` |
| acceptHint | 文件类型提示文本 | `string` | `''` |
| disabled | 是否禁用 | `boolean` | `false` |
| drag | 是否启用拖拽上传 | `boolean` | `true` |
| buttonText | 按钮文本 | `string` | `'选择文件'` |
| autoUpload | 是否自动上传 | `boolean` | `false` |
## Events 事件
| 事件名 | 说明 | 回调参数 |
|--------|------|----------|
| update:modelValue | 文件 URL 更新 | `(value: string \| string[])` |
| success | 上传成功 | `(response: any)` |
| error | 上传失败 | `(error: any)` |
| progress | 上传进度更新 | `(progress: number)` |
## 基本用法
```vue
<template>
<sa-chunk-upload v-model="fileUrl" />
</template>
<script setup>
import { ref } from 'vue'
const fileUrl = ref('')
</script>
```
## 高级用法
### 1. 大视频文件上传
```vue
<template>
<sa-chunk-upload
v-model="videoUrl"
accept="video/*"
accept-hint="MP4AVIMOV"
:max-size="2048"
:chunk-size="10"
:drag="true"
@success="handleSuccess"
@progress="handleProgress"
/>
</template>
<script setup>
import { ref } from 'vue'
const videoUrl = ref('')
const handleSuccess = (response) => {
console.log('上传成功:', response)
}
const handleProgress = (progress) => {
console.log('上传进度:', progress + '%')
}
</script>
```
### 2. 多文件上传
```vue
<template>
<sa-chunk-upload
v-model="fileUrls"
:multiple="true"
:limit="5"
:chunk-size="5"
/>
</template>
<script setup>
import { ref } from 'vue'
const fileUrls = ref([])
</script>
```
### 3. 限制文件类型
```vue
<template>
<!-- 只允许上传压缩文件 -->
<sa-chunk-upload
v-model="zipUrl"
accept=".zip,.rar,.7z"
accept-hint="ZIPRAR7Z"
:max-size="500"
/>
<!-- 只允许上传 PDF -->
<sa-chunk-upload
v-model="pdfUrl"
accept=".pdf"
accept-hint="PDF"
/>
</template>
```
### 4. 自动上传模式
```vue
<template>
<sa-chunk-upload
v-model="fileUrl"
:auto-upload="true"
/>
</template>
```
### 5. 手动控制上传
```vue
<template>
<sa-chunk-upload
v-model="fileUrl"
:auto-upload="false"
/>
<!-- 组件会自动显示"开始上传"按钮 -->
</template>
```
## 后端接口要求
组件会调用 `chunkUpload` API每个分片上传时会发送以下参数
```typescript
{
file: Blob, // 分片文件数据
hash: string, // 文件 MD5 哈希值
chunkIndex: number, // 当前分片索引(从 0 开始)
totalChunks: number, // 总分片数
fileName: string // 原始文件名
}
```
### 后端实现建议
1. **接收分片**: 根据 `hash``chunkIndex` 保存分片
2. **合并文件**: 当接收到所有分片后(`chunkIndex === totalChunks - 1`),合并所有分片
3. **返回 URL**: 合并完成后返回文件访问 URL
4. **断点续传**: 可以实现接口检查已上传的分片,避免重复上传
### 示例后端响应
```json
{
"code": 200,
"data": {
"url": "/uploads/abc123def456/example.mp4",
"hash": "abc123def456"
},
"message": "上传成功"
}
```
## 工作原理
1. **文件选择**: 用户选择文件后,组件计算文件需要分成多少片
2. **MD5 计算**: 计算整个文件的 MD5 哈希值(用于去重和校验)
3. **分片上传**: 按顺序上传每个分片,实时更新进度
4. **进度显示**: 显示当前分片、总分片数、上传速度
5. **完成处理**: 所有分片上传完成后,更新 v-model 值
## 性能优化建议
1. **分片大小**:
- 网络较好: 可设置 10-20MB
- 网络一般: 建议 5-10MB
- 网络较差: 建议 2-5MB
2. **文件大小限制**: 根据实际需求设置合理的 `maxSize`
3. **并发控制**: 组件默认串行上传文件,避免同时上传多个大文件
## 注意事项
1. 需要安装依赖: `spark-md5``@types/spark-md5`
2. 后端需要实现分片接收和合并逻辑
3. 建议在后端实现文件去重(通过 MD5 哈希)
4. 大文件上传时注意服务器超时设置
## 依赖安装
```bash
pnpm add spark-md5
pnpm add -D @types/spark-md5
```

View File

@@ -0,0 +1,623 @@
<template>
<div class="sa-chunk-upload">
<el-upload
ref="uploadRef"
:file-list="fileList"
:limit="limit"
:multiple="multiple"
:accept="accept"
:auto-upload="false"
:on-change="handleFileChange"
:on-remove="handleRemove"
:on-exceed="handleExceed"
:disabled="disabled || uploading"
:drag="drag"
class="upload-container"
>
<template #default>
<div v-if="drag" class="upload-dragger">
<el-icon class="upload-icon"><UploadFilled /></el-icon>
<div class="upload-text">将文件拖到此处<em>点击选择</em></div>
<div class="upload-hint">支持大文件上传自动分片处理</div>
</div>
<el-button v-else type="primary" :icon="Upload" :disabled="disabled || uploading">
{{ buttonText }}
</el-button>
</template>
<template #tip>
<div class="el-upload__tip">
<span v-if="acceptHint">支持 {{ acceptHint }} 格式</span>
单个文件不超过 {{ maxSize }}MB最多上传 {{ limit }} 个文件
<span v-if="chunkSize">分片大小: {{ chunkSize }}MB</span>
</div>
</template>
</el-upload>
<!-- 上传进度 -->
<div v-if="uploadingFiles.length > 0" class="upload-progress-list">
<div v-for="item in uploadingFiles" :key="item.uid" class="upload-progress-item">
<div class="file-info">
<el-icon class="file-icon"><Document /></el-icon>
<span class="file-name">{{ item.name }}</span>
<span class="file-size">{{ formatFileSize(item.size) }}</span>
</div>
<div class="progress-bar">
<el-progress
:percentage="item.progress"
:status="getProgressStatus(item.status)"
:stroke-width="8"
/>
</div>
<div class="progress-info">
<span class="progress-text">
{{ item.currentChunk }}/{{ item.totalChunks }} 分片
<span v-if="item.speed"> - {{ item.speed }}</span>
</span>
<div class="action-buttons">
<el-button
v-if="item.status === 'exception'"
type="text"
size="small"
@click="retryUpload(item)"
>
重试
</el-button>
<el-button
v-else-if="item.status !== 'success'"
type="text"
size="small"
@click="cancelUpload(item)"
>
取消
</el-button>
<el-button type="text" size="small" @click="removeUploadingFile(item)">
删除
</el-button>
</div>
</div>
</div>
</div>
<!-- 开始上传按钮 -->
<div v-if="pendingFiles.length > 0 && !uploading" class="upload-actions">
<el-button type="primary" @click="startUpload">
开始上传 ({{ pendingFiles.length }} 个文件)
</el-button>
<el-button @click="clearPending">清空列表</el-button>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue'
import { Upload, UploadFilled, Document } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { UploadProps, UploadUserFile, UploadFile } from 'element-plus'
import { chunkUpload } from '@/api/auth'
import SparkMD5 from 'spark-md5'
defineOptions({ name: 'SaChunkUpload' })
// 定义 Props
interface Props {
modelValue?: string | string[] // v-model 绑定值
multiple?: boolean // 是否支持多选
limit?: number // 最大上传数量
maxSize?: number // 最大文件大小(MB)
chunkSize?: number // 分片大小(MB),默认 5MB
accept?: string // 接受的文件类型
acceptHint?: string // 接受文件类型的提示文本
disabled?: boolean // 是否禁用
drag?: boolean // 是否启用拖拽上传
buttonText?: string // 按钮文本
autoUpload?: boolean // 是否自动上传
}
const props = withDefaults(defineProps<Props>(), {
modelValue: () => [],
multiple: false,
limit: 1,
maxSize: 1024, // 默认最大 1GB
chunkSize: 5, // 默认 5MB 分片
accept: '*',
acceptHint: '',
disabled: false,
drag: true,
buttonText: '选择文件',
autoUpload: false
})
// 定义 Emits
const emit = defineEmits<{
'update:modelValue': [value: string | string[]]
success: [response: any]
error: [error: any]
progress: [progress: number]
}>()
// 上传文件信息接口
interface UploadingFile {
uid: number
name: string
size: number
file: File
progress: number
status: 'ready' | 'uploading' | 'success' | 'exception'
currentChunk: number
totalChunks: number
speed: string
hash?: string
uploadedChunks: Set<number>
canceled: boolean
}
// 状态
const uploadRef = ref()
const fileList = ref<UploadUserFile[]>([])
const uploadingFiles = ref<UploadingFile[]>([])
const uploading = ref(false)
const ext = ref<string | Blob>('')
// 待上传文件
const pendingFiles = computed(() => {
return uploadingFiles.value.filter((f) => f.status === 'ready')
})
// 将上传状态映射到进度条状态
const getProgressStatus = (status: 'ready' | 'uploading' | 'success' | 'exception') => {
if (status === 'success') return 'success'
if (status === 'exception') return 'exception'
return undefined // ready 和 uploading 使用默认状态
}
// 监听 modelValue 变化,同步组件状态
watch(
() => props.modelValue,
(newVal) => {
// 如果 modelValue 被清空(表单重置),清空所有状态
if (!newVal || (Array.isArray(newVal) && newVal.length === 0)) {
uploadingFiles.value = []
fileList.value = []
uploadRef.value?.clearFiles()
return
}
// 如果 modelValue 有值,同步到 fileList用于编辑场景
const urls = Array.isArray(newVal) ? newVal : [newVal]
const existingUrls = fileList.value.map((f) => f.url)
// 只添加新的 URL避免重复
urls
.filter((url) => url && !existingUrls.includes(url))
.forEach((url, index) => {
const fileName = url.split('/').pop() || `file-${index + 1}`
fileList.value.push({
name: fileName,
url: url,
uid: Date.now() + index
})
})
},
{ immediate: true }
)
// 文件选择变化
const handleFileChange: UploadProps['onChange'] = (uploadFile: UploadFile) => {
const file = uploadFile.raw
if (!file) return
// 验证文件大小
const isLtMaxSize = file.size / 1024 / 1024 < props.maxSize
if (!isLtMaxSize) {
ElMessage.error(`文件大小不能超过 ${props.maxSize}MB!`)
return
}
ext.value = '' + file.name.split('.').pop()?.toLowerCase()
// 验证文件类型
if (props.accept && props.accept !== '*') {
const acceptTypes = props.accept.split(',').map((type) => type.trim())
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase()
const fileMimeType = file.type
const isAccepted = acceptTypes.some((type) => {
if (type.startsWith('.')) {
return fileExtension === type.toLowerCase()
}
if (type.includes('/*')) {
const mainType = type.split('/')[0]
return fileMimeType.startsWith(mainType)
}
return fileMimeType === type
})
if (!isAccepted) {
ElMessage.error(
`不支持的文件类型!${props.acceptHint ? '请上传 ' + props.acceptHint + ' 格式的文件' : ''}`
)
return
}
}
// 计算分片数量
const chunkSizeBytes = props.chunkSize * 1024 * 1024
const totalChunks = Math.ceil(file.size / chunkSizeBytes)
// 添加到上传列表
const uploadingFile: UploadingFile = {
uid: uploadFile.uid,
name: file.name,
size: file.size,
file: file,
progress: 0,
status: 'ready',
currentChunk: 0,
totalChunks: totalChunks,
speed: '',
uploadedChunks: new Set(),
canceled: false
}
uploadingFiles.value.push(uploadingFile)
// 如果是自动上传,立即开始
if (props.autoUpload) {
startUpload()
}
}
// 删除文件(从 el-upload 触发)
const handleRemove: UploadProps['onRemove'] = (uploadFile) => {
removeUploadingFile({ uid: uploadFile.uid } as UploadingFile)
}
// 删除上传中的文件
const removeUploadingFile = (uploadingFile: UploadingFile) => {
// 从 uploadingFiles 中删除
const uploadingIndex = uploadingFiles.value.findIndex((item) => item.uid === uploadingFile.uid)
if (uploadingIndex > -1) {
uploadingFiles.value.splice(uploadingIndex, 1)
}
// 从 fileList 中删除
const fileIndex = fileList.value.findIndex((item) => item.uid === uploadingFile.uid)
if (fileIndex > -1) {
fileList.value.splice(fileIndex, 1)
updateModelValue()
}
// 如果所有文件都被删除,清空 el-upload 的内部状态
if (uploadingFiles.value.length === 0 && fileList.value.length === 0) {
uploadRef.value?.clearFiles()
}
}
// 超出限制提示
const handleExceed: UploadProps['onExceed'] = () => {
ElMessage.warning(`最多只能上传 ${props.limit} 个文件`)
}
// 计算文件 MD5 哈希
const calculateFileHash = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const chunkSize = 2 * 1024 * 1024 // 2MB per chunk for hash calculation
const chunks = Math.ceil(file.size / chunkSize)
let currentChunk = 0
const spark = new SparkMD5.ArrayBuffer()
const fileReader = new FileReader()
fileReader.onload = (e) => {
spark.append(e.target?.result as ArrayBuffer)
currentChunk++
if (currentChunk < chunks) {
loadNext()
} else {
resolve(spark.end())
}
}
fileReader.onerror = () => {
reject(new Error('文件读取失败'))
}
const loadNext = () => {
const start = currentChunk * chunkSize
const end = Math.min(start + chunkSize, file.size)
fileReader.readAsArrayBuffer(file.slice(start, end))
}
loadNext()
})
}
// 上传单个文件
const uploadFile = async (uploadingFile: UploadingFile) => {
try {
uploadingFile.status = 'uploading'
uploadingFile.canceled = false
// 计算文件哈希
const hash = await calculateFileHash(uploadingFile.file)
uploadingFile.hash = hash
const chunkSizeBytes = props.chunkSize * 1024 * 1024
const totalChunks = uploadingFile.totalChunks
const startTime = Date.now()
let result: any = {}
// 上传所有分片
for (let i = 0; i < totalChunks; i++) {
if (uploadingFile.canceled) {
throw new Error('上传已取消')
}
const start = i * chunkSizeBytes
const end = Math.min(start + chunkSizeBytes, uploadingFile.size)
const chunk = uploadingFile.file.slice(start, end)
// 创建 FormData
const formData = new FormData()
formData.append('file', chunk)
formData.append('ext', ext.value)
formData.append('size', uploadingFile.size.toString())
formData.append('type', uploadingFile.file.type)
formData.append('hash', hash)
formData.append('index', i.toString())
formData.append('total', totalChunks.toString())
formData.append('name', uploadingFile.name)
// 上传分片
result = await chunkUpload(formData)
// 检查后端是否返回了 URL秒传文件已存在
if (i == 0 && result?.url) {
// 文件已存在,直接使用返回的 URL跳过后续分片上传
uploadingFile.progress = 100
uploadingFile.currentChunk = totalChunks
uploadingFile.speed = '秒传'
emit('progress', 100)
ElMessage.success(`${uploadingFile.name} 秒传成功!`)
break
}
// 更新进度
uploadingFile.currentChunk = i + 1
uploadingFile.uploadedChunks.add(i)
uploadingFile.progress = Math.floor(((i + 1) / totalChunks) * 100)
// 计算上传速度
const elapsed = (Date.now() - startTime) / 1000
const uploaded = (i + 1) * chunkSizeBytes
const speed = uploaded / elapsed
uploadingFile.speed = formatSpeed(speed)
// 触发进度事件
emit('progress', uploadingFile.progress)
}
// 上传完成
uploadingFile.status = 'success'
uploadingFile.progress = 100
// 获取文件 URL支持秒传和正常上传两种情况
const fileUrl = result?.url || ''
if (!fileUrl) {
throw new Error('上传完成但未返回文件地址')
}
// 更新文件列表
const newFile: UploadUserFile = {
name: uploadingFile.name,
url: fileUrl,
uid: uploadingFile.uid
}
fileList.value.push(newFile)
updateModelValue()
emit('success', { url: fileUrl, hash })
// 如果不是秒传,显示普通上传成功消息
if (uploadingFile.speed !== '秒传') {
ElMessage.success(`${uploadingFile.name} 上传成功!`)
}
} catch (error: any) {
console.error('上传失败:', error)
uploadingFile.status = 'exception'
emit('error', error)
ElMessage.error(`${uploadingFile.name} 上传失败: ${error.message}`)
}
}
// 开始上传
const startUpload = async () => {
if (pendingFiles.value.length === 0) {
ElMessage.warning('没有待上传的文件')
return
}
uploading.value = true
try {
// 串行上传所有文件
for (const file of pendingFiles.value) {
if (!file.canceled) {
await uploadFile(file)
}
}
} finally {
uploading.value = false
}
}
// 重试上传
const retryUpload = async (uploadingFile: UploadingFile) => {
uploadingFile.status = 'ready'
uploadingFile.progress = 0
uploadingFile.currentChunk = 0
uploadingFile.uploadedChunks.clear()
await uploadFile(uploadingFile)
}
// 取消上传
const cancelUpload = (uploadingFile: UploadingFile) => {
uploadingFile.canceled = true
uploadingFile.status = 'exception'
ElMessage.info(`已取消上传: ${uploadingFile.name}`)
}
// 清空待上传列表
const clearPending = () => {
uploadingFiles.value = uploadingFiles.value.filter((f) => f.status !== 'ready')
fileList.value = []
}
// 更新 v-model 值
const updateModelValue = () => {
const urls = fileList.value.map((file) => file.url).filter((url) => url) as string[]
if (props.multiple) {
emit('update:modelValue', urls)
} else {
emit('update:modelValue', urls[0] || '')
}
}
// 格式化文件大小
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i]
}
// 格式化速度
const formatSpeed = (bytesPerSecond: number): string => {
return formatFileSize(bytesPerSecond) + '/s'
}
</script>
<style scoped lang="scss">
.sa-chunk-upload {
width: 100%;
.upload-container {
:deep(.el-upload) {
width: 100%;
}
:deep(.el-upload-dragger) {
width: 100%;
padding: 20px 10px;
}
:deep(.el-upload-list) {
display: none; // 隐藏默认的文件列表,使用自定义进度显示
}
}
.upload-dragger {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.upload-icon {
font-size: 36px;
color: #c0c4cc;
margin-bottom: 16px;
}
.upload-text {
font-size: 14px;
color: #606266;
margin-bottom: 8px;
em {
color: var(--el-color-primary);
font-style: normal;
}
}
.upload-hint {
font-size: 12px;
color: #909399;
}
}
.el-upload__tip {
font-size: 12px;
color: #909399;
margin-top: 7px;
line-height: 1.5;
}
.upload-progress-list {
margin-top: 20px;
.upload-progress-item {
padding: 15px;
background-color: #f5f7fa;
border-radius: 4px;
margin-bottom: 10px;
.file-info {
display: flex;
align-items: center;
margin-bottom: 10px;
.file-icon {
font-size: 20px;
color: #409eff;
margin-right: 8px;
}
.file-name {
flex: 1;
font-size: 14px;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
font-size: 12px;
color: #909399;
margin-left: 10px;
}
}
.progress-bar {
margin-bottom: 8px;
}
.progress-info {
display: flex;
justify-content: space-between;
align-items: center;
.progress-text {
font-size: 12px;
color: #606266;
}
.action-buttons {
display: flex;
gap: 5px;
}
}
}
}
.upload-actions {
margin-top: 15px;
display: flex;
gap: 10px;
}
}
</style>

View File

@@ -0,0 +1,51 @@
<template>
<pre class="code-pre"><code class="hljs" v-html="highlightedCode"></code></pre>
</template>
<script type="ts" setup>
import { ref, watch, onMounted } from 'vue'
import hljs from 'highlight.js/lib/core' // 核心
import 'highlight.js/styles/github-dark.css' // 主题示例(可换成其他,如 atom-one-light.css、vscode-dark.css 等)
const props = defineProps({
code: {
type: String,
required: true
},
language: {
type: String,
default: 'javascript' // 默认语言,可传入 'vue' 或 'php'
}
})
const highlightedCode = ref('')
const doHighlight = () => {
if (!props.code) {
highlightedCode.value = ''
return
}
try {
// ignoreIllegals 防止非法语法报错
highlightedCode.value = hljs.highlight(props.code, {
language: props.language,
ignoreIllegals: true
}).value
} catch (__) {
console.error('代码高亮失败', __)
highlightedCode.value = props.code // 降级:语法不支持时纯文本显示
}
}
watch(() => [props.code, props.language], doHighlight)
onMounted(doHighlight)
</script>
<style scoped>
.code-pre {
border-radius: 8px;
overflow-x: auto;
font-size: 14px;
line-height: 1.5;
}
</style>

View File

@@ -0,0 +1,113 @@
<!-- 字典组件 -->
<template>
<div class="sa-dict-wrapper">
<template v-if="render === 'tag'">
<ElTag
v-for="(item, index) in normalizedValues"
:key="index"
:size="size"
:style="{
backgroundColor: getColor(getData(item)?.color, 'bg'),
borderColor: getColor(getData(item)?.color, 'border'),
color: getColor(getData(item)?.color, 'text')
}"
:round="round"
class="mr-1 last:mr-0"
>
{{ getData(item)?.label || item }}
</ElTag>
</template>
<template v-else>
<span v-for="(item, index) in normalizedValues" :key="index">
{{ getData(item)?.label || item }}{{ index < normalizedValues.length - 1 ? '' : '' }}
</span>
</template>
</div>
</template>
<script setup lang="ts">
import { useDictStore } from '@/store/modules/dict'
defineOptions({ name: 'SaDict' })
interface Props {
/** 字典类型 */
dict: string
/** 字典值(支持字符串或数组) */
value: string | string[] | number | number[]
/** 渲染方式 */
render?: string
/** 标签大小 */
size?: 'large' | 'default' | 'small'
/** 是否圆角 */
round?: boolean
}
const props = withDefaults(defineProps<Props>(), {
render: 'tag',
size: 'default',
round: false
})
const dictStore = useDictStore()
// 统一处理 value转换为数组格式
const normalizedValues = computed(() => {
if (Array.isArray(props.value)) {
return props.value.map((v) => String(v))
}
return props.value !== undefined && props.value !== null && props.value !== ''
? [String(props.value)]
: []
})
// 根据值获取字典数据
const getData = (value: string) => dictStore.getDataByValue(props.dict, value)
const getColor = (color: string | undefined, type: 'bg' | 'border' | 'text') => {
// 如果没有指定颜色,使用默认主色调
if (!color) {
const colors = {
bg: 'var(--el-color-primary-light-9)',
border: 'var(--el-color-primary-light-8)',
text: 'var(--el-color-primary)'
}
return colors[type]
}
// 如果是 hex 颜色,转换为 RGB
let r, g, b
if (color.startsWith('#')) {
const hex = color.slice(1)
r = parseInt(hex.slice(0, 2), 16)
g = parseInt(hex.slice(2, 4), 16)
b = parseInt(hex.slice(4, 6), 16)
} else if (color.startsWith('rgb')) {
const match = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/)
if (match) {
r = parseInt(match[1])
g = parseInt(match[2])
b = parseInt(match[3])
} else {
return color
}
} else {
return color
}
// 根据类型返回不同的颜色变体
switch (type) {
case 'bg':
// 背景色 - 更浅的版本
return `rgba(${r}, ${g}, ${b}, 0.1)`
case 'border':
// 边框色 - 中等亮度
return `rgba(${r}, ${g}, ${b}, 0.3)`
case 'text':
// 文字色 - 原始颜色
return `rgb(${r}, ${g}, ${b})`
default:
return color
}
}
</script>

View File

@@ -0,0 +1,297 @@
<!-- WangEditor 富文本编辑器 插件地址https://www.wangeditor.com/ -->
<template>
<div class="editor-wrapper">
<div class="editor-toolbar-wrapper">
<Toolbar
class="editor-toolbar"
:editor="editorRef"
:mode="mode"
:defaultConfig="toolbarConfig"
/>
<!-- 自定义图库选择按钮 -->
<el-tooltip content="从图库选择" placement="top">
<el-button class="gallery-btn" :icon="FolderOpened" @click="openImageDialog" />
</el-tooltip>
</div>
<Editor
:style="{ height: height, overflowY: 'hidden' }"
v-model="modelValue"
:mode="mode"
:defaultConfig="editorConfig"
@onCreated="onCreateEditor"
/>
<!-- 图片选择弹窗 -->
<SaImageDialog
v-model:visible="imageDialogVisible"
:multiple="true"
:limit="10"
@confirm="onImageSelect"
/>
</div>
</template>
<script setup lang="ts">
import '@wangeditor/editor/dist/css/style.css'
import { onBeforeUnmount, onMounted, shallowRef, computed, ref } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import EmojiText from '@/utils/ui/emojo'
import { IDomEditor, IToolbarConfig, IEditorConfig } from '@wangeditor/editor'
import { uploadImage } from '@/api/auth'
import { FolderOpened } from '@element-plus/icons-vue'
import SaImageDialog from '@/components/sai/sa-image-dialog/index.vue'
defineOptions({ name: 'SaEditor' })
// Props 定义
interface Props {
/** 编辑器高度 */
height?: string
/** 自定义工具栏配置 */
toolbarKeys?: string[]
/** 插入新工具到指定位置 */
insertKeys?: { index: number; keys: string[] }
/** 排除的工具栏项 */
excludeKeys?: string[]
/** 编辑器模式 */
mode?: 'default' | 'simple'
/** 占位符文本 */
placeholder?: string
/** 上传配置 */
uploadConfig?: {
maxFileSize?: number
maxNumberOfFiles?: number
server?: string
}
}
const props = withDefaults(defineProps<Props>(), {
height: '500px',
mode: 'default',
placeholder: '请输入内容...',
excludeKeys: () => ['fontFamily']
})
const modelValue = defineModel<string>({ required: true })
// 编辑器实例
const editorRef = shallowRef<IDomEditor>()
// 图片弹窗状态
const imageDialogVisible = ref(false)
// 常量配置
const DEFAULT_UPLOAD_CONFIG = {
maxFileSize: 3 * 1024 * 1024, // 3MB
maxNumberOfFiles: 10,
fieldName: 'file',
allowedFileTypes: ['image/*']
} as const
// 合并上传配置
const mergedUploadConfig = computed(() => ({
...DEFAULT_UPLOAD_CONFIG,
...props.uploadConfig
}))
// 工具栏配置
const toolbarConfig = computed((): Partial<IToolbarConfig> => {
const config: Partial<IToolbarConfig> = {}
// 完全自定义工具栏
if (props.toolbarKeys && props.toolbarKeys.length > 0) {
config.toolbarKeys = props.toolbarKeys
}
// 插入新工具
if (props.insertKeys) {
config.insertKeys = props.insertKeys
}
// 排除工具
if (props.excludeKeys && props.excludeKeys.length > 0) {
config.excludeKeys = props.excludeKeys
}
return config
})
// 编辑器配置
const editorConfig: Partial<IEditorConfig> = {
placeholder: props.placeholder,
MENU_CONF: {
uploadImage: {
// 自定义上传
async customUpload(file: File, insertFn: (url: string, alt: string, href: string) => void) {
try {
// 验证文件大小
if (file.size > mergedUploadConfig.value.maxFileSize) {
const maxSizeMB = (mergedUploadConfig.value.maxFileSize / 1024 / 1024).toFixed(1)
ElMessage.error(`图片大小不能超过 ${maxSizeMB}MB`)
return
}
// 创建 FormData
const formData = new FormData()
formData.append('file', file)
// 调用上传接口
const response: any = await uploadImage(formData)
// 获取图片 URL
const imageUrl = response?.data?.url || response?.url || ''
if (!imageUrl) {
throw new Error('上传失败,未返回图片地址')
}
// 插入图片到编辑器
insertFn(imageUrl, file.name, imageUrl)
ElMessage.success(`图片上传成功 ${EmojiText[200]}`)
} catch (error: any) {
console.error('图片上传失败:', error)
ElMessage.error(`图片上传失败: ${error.message || EmojiText[500]}`)
}
},
// 其他配置
maxFileSize: mergedUploadConfig.value.maxFileSize,
maxNumberOfFiles: mergedUploadConfig.value.maxNumberOfFiles,
allowedFileTypes: mergedUploadConfig.value.allowedFileTypes
}
}
}
// 打开图片选择弹窗
const openImageDialog = () => {
imageDialogVisible.value = true
}
// 图片选择回调
const onImageSelect = (urls: string | string[]) => {
const editor = editorRef.value
if (!editor) return
const urlList = Array.isArray(urls) ? urls : [urls]
urlList.forEach((url) => {
editor.insertNode({
type: 'image',
src: url,
alt: '',
href: '',
style: {},
children: [{ text: '' }]
} as any)
})
}
// 编辑器创建回调
const onCreateEditor = (editor: IDomEditor) => {
editorRef.value = editor
// 监听全屏事件
editor.on('fullScreen', () => {
console.log('编辑器进入全屏模式')
})
// 确保在编辑器创建后应用自定义图标
applyCustomIcons()
}
// 应用自定义图标(带重试机制)
const applyCustomIcons = () => {
let retryCount = 0
const maxRetries = 10
const retryDelay = 100
const tryApplyIcons = () => {
const editor = editorRef.value
if (!editor) {
if (retryCount < maxRetries) {
retryCount++
setTimeout(tryApplyIcons, retryDelay)
}
return
}
// 获取当前编辑器的工具栏容器
const editorContainer = editor.getEditableContainer().closest('.editor-wrapper')
if (!editorContainer) {
if (retryCount < maxRetries) {
retryCount++
setTimeout(tryApplyIcons, retryDelay)
}
return
}
const toolbar = editorContainer.querySelector('.w-e-toolbar')
const toolbarButtons = editorContainer.querySelectorAll('.w-e-bar-item button[data-menu-key]')
if (toolbar && toolbarButtons.length > 0) {
return
}
// 如果工具栏还没渲染完成,继续重试
if (retryCount < maxRetries) {
retryCount++
setTimeout(tryApplyIcons, retryDelay)
} else {
console.warn('工具栏渲染超时,无法应用自定义图标 - 编辑器实例:', editor.id)
}
}
// 使用 requestAnimationFrame 确保在下一帧执行
requestAnimationFrame(tryApplyIcons)
}
// 暴露编辑器实例和方法
defineExpose({
/** 获取编辑器实例 */
getEditor: () => editorRef.value,
/** 设置编辑器内容 */
setHtml: (html: string) => editorRef.value?.setHtml(html),
/** 获取编辑器内容 */
getHtml: () => editorRef.value?.getHtml(),
/** 清空编辑器 */
clear: () => editorRef.value?.clear(),
/** 聚焦编辑器 */
focus: () => editorRef.value?.focus(),
/** 打开图库选择 */
openImageDialog
})
// 生命周期
onMounted(() => {
// 图标替换已在 onCreateEditor 中处理
})
onBeforeUnmount(() => {
const editor = editorRef.value
if (editor) {
editor.destroy()
}
})
</script>
<style lang="scss">
@use './style';
.editor-toolbar-wrapper {
display: flex;
align-items: center;
border-bottom: 1px solid var(--el-border-color-light);
.editor-toolbar {
flex: 1;
border-bottom: none !important;
}
.gallery-btn {
margin: 0 8px;
padding: 8px;
height: 32px;
width: 32px;
}
}
</style>

View File

@@ -0,0 +1,210 @@
$box-radius: calc(var(--custom-radius) / 3 + 2px);
// 全屏容器 z-index 调整
.w-e-full-screen-container {
z-index: 100 !important;
}
/* 编辑器容器 */
.editor-wrapper {
width: 100%;
height: 100%;
border: 1px solid var(--art-gray-300);
border-radius: $box-radius !important;
.w-e-bar {
border-radius: $box-radius $box-radius 0 0 !important;
}
.menu-item {
display: flex;
flex-direction: row;
align-items: center;
i {
margin-right: 5px;
}
}
/* 工具栏 */
.editor-toolbar {
border-bottom: 1px solid var(--default-border);
}
/* 下拉选择框配置 */
.w-e-select-list {
min-width: 140px;
padding: 5px 10px 10px;
border: none;
border-radius: $box-radius;
}
/* 下拉选择框元素配置 */
.w-e-select-list ul li {
margin-top: 5px;
font-size: 15px !important;
border-radius: $box-radius;
}
/* 下拉选择框 正文文字大小调整 */
.w-e-select-list ul li:last-of-type {
font-size: 16px !important;
}
/* 下拉选择框 hover 样式调整 */
.w-e-select-list ul li:hover {
background-color: var(--art-gray-200);
}
:root {
/* 激活颜色 */
--w-e-toolbar-active-bg-color: var(--art-gray-200);
/* toolbar 图标和文字颜色 */
--w-e-toolbar-color: #000;
/* 表格选中时候的边框颜色 */
--w-e-textarea-selected-border-color: #ddd;
/* 表格头背景颜色 */
--w-e-textarea-slight-bg-color: var(--art-gray-200);
}
/* 工具栏按钮样式 */
.w-e-bar-item svg {
fill: var(--art-gray-800);
}
.w-e-bar-item button {
color: var(--art-gray-800);
border-radius: $box-radius;
}
/* 工具栏 hover 按钮背景颜色 */
.w-e-bar-item button:hover {
background-color: var(--art-gray-200);
}
/* 工具栏分割线 */
.w-e-bar-divider {
height: 20px;
margin-top: 10px;
background-color: #ccc;
}
/* 工具栏菜单 */
.w-e-bar-item-group .w-e-bar-item-menus-container {
min-width: 120px;
padding: 10px 0;
border: none;
border-radius: $box-radius;
.w-e-bar-item {
button {
width: 100%;
margin: 0 5px;
}
}
}
/* 代码块 */
.w-e-text-container [data-slate-editor] pre > code {
padding: 0.6rem 1rem;
background-color: var(--art-gray-50);
border-radius: $box-radius;
}
/* 弹出框 */
.w-e-drop-panel {
border: 0;
border-radius: $box-radius;
}
a {
color: #318ef4;
}
.w-e-text-container {
strong,
b {
font-weight: 500;
}
i,
em {
font-style: italic;
}
}
/* 表格样式优化 */
.w-e-text-container [data-slate-editor] .table-container th {
border-right: none;
}
.w-e-text-container [data-slate-editor] .table-container th:last-of-type {
border-right: 1px solid #ccc !important;
}
/* 引用 */
.w-e-text-container [data-slate-editor] blockquote {
background-color: var(--art-gray-200);
border-left: 4px solid var(--art-gray-300);
}
/* 输入区域弹出 bar */
.w-e-hover-bar {
border-radius: $box-radius;
}
/* 超链接弹窗 */
.w-e-modal {
border: none;
border-radius: $box-radius;
}
/* 图片样式调整 */
.w-e-text-container [data-slate-editor] .w-e-selected-image-container {
overflow: inherit;
&:hover {
border: 0;
}
img {
border: 1px solid transparent;
transition: border 0.3s;
&:hover {
border: 1px solid #318ef4 !important;
}
}
.w-e-image-dragger {
width: 12px;
height: 12px;
background-color: #318ef4;
border: 2px solid #fff;
border-radius: $box-radius;
}
.left-top {
top: -6px;
left: -6px;
}
.right-top {
top: -6px;
right: -6px;
}
.left-bottom {
bottom: -6px;
left: -6px;
}
.right-bottom {
right: -6px;
bottom: -6px;
}
}
}

View File

@@ -0,0 +1,127 @@
<template>
<div class="sa-export-wrap" @click="handleExport">
<slot>
<ElButton :icon="Download" :loading="loading">
{{ label }}
</ElButton>
</slot>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Download } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import axios from 'axios'
import { useUserStore } from '@/store/modules/user'
defineOptions({ name: 'SaExport' })
const props = withDefaults(
defineProps<{
url: string
params?: Record<string, any>
fileName?: string
method?: string
label?: string
}>(),
{
method: 'post',
label: '导出',
fileName: '导出数据.xlsx'
}
)
const emit = defineEmits<{
success: []
error: [error: any]
}>()
const loading = ref(false)
const handleExport = async () => {
if (loading.value) return
if (!props.url) {
ElMessage.error('未配置导出接口')
return
}
let finalFileName = props.fileName
try {
const { value } = await ElMessageBox.prompt('请输入导出文件名称', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: props.fileName,
inputValidator: (val) => !!val.trim() || '文件名不能为空'
})
finalFileName = value
} catch {
// User cancelled
return
}
try {
loading.value = true
const { VITE_API_URL } = import.meta.env
const { accessToken } = useUserStore()
axios.defaults.baseURL = VITE_API_URL
const config = {
method: props.method,
url: props.url,
data: props.method.toLowerCase() === 'post' ? props.params : undefined,
params: props.method.toLowerCase() === 'get' ? props.params : undefined,
responseType: 'blob' as const,
headers: {
Authorization: accessToken ? `Bearer ${accessToken}` : undefined
}
}
const res = await axios(config)
// Check if response is json (error case)
if (res.data.type === 'application/json') {
const reader = new FileReader()
reader.onload = () => {
try {
const result = JSON.parse(reader.result as string)
ElMessage.error(result.msg || '导出失败')
emit('error', result)
} catch (e) {
ElMessage.error('导出失败')
emit('error', e)
}
}
reader.readAsText(res.data)
return
}
const blob = new Blob([res.data], { type: 'application/octet-stream' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = finalFileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
ElMessage.success('导出成功')
emit('success')
} catch (error: any) {
console.error(error)
ElMessage.error(error.message || '导出失败')
emit('error', error)
} finally {
loading.value = false
}
}
</script>
<style scoped>
.sa-export-wrap {
display: inline-block;
}
</style>

View File

@@ -0,0 +1,119 @@
<!--
sa-file-upload 组件使用示例
基本用法:
<sa-file-upload v-model="fileUrl" />
多文件上传:
<sa-file-upload v-model="fileUrls" :multiple="true" :limit="5" />
拖拽上传:
<sa-file-upload v-model="fileUrl" :drag="true" />
限制文件类型:
<sa-file-upload
v-model="fileUrl"
accept=".pdf,.doc,.docx"
accept-hint="PDF、Word"
/>
-->
<template>
<div class="demo-container">
<h2>文件上传组件示例</h2>
<!-- 示例1: 基本用法 -->
<el-card header="基本用法" class="demo-card">
<sa-file-upload v-model="singleFile" />
<div class="result">当前文件: {{ singleFile }}</div>
</el-card>
<!-- 示例2: 多文件上传 -->
<el-card header="多文件上传" class="demo-card">
<sa-file-upload
v-model="multipleFiles"
:multiple="true"
:limit="5"
/>
<div class="result">当前文件: {{ multipleFiles }}</div>
</el-card>
<!-- 示例3: 拖拽上传 -->
<el-card header="拖拽上传" class="demo-card">
<sa-file-upload
v-model="dragFile"
:drag="true"
button-text="点击或拖拽上传"
/>
<div class="result">当前文件: {{ dragFile }}</div>
</el-card>
<!-- 示例4: 限制文件类型 - PDF/Word -->
<el-card header="限制文件类型 (PDF、Word)" class="demo-card">
<sa-file-upload
v-model="docFile"
accept=".pdf,.doc,.docx"
accept-hint="PDF、Word"
:max-size="20"
/>
<div class="result">当前文件: {{ docFile }}</div>
</el-card>
<!-- 示例5: 限制文件类型 - Excel -->
<el-card header="限制文件类型 (Excel)" class="demo-card">
<sa-file-upload
v-model="excelFile"
accept=".xls,.xlsx"
accept-hint="Excel"
/>
<div class="result">当前文件: {{ excelFile }}</div>
</el-card>
<!-- 示例6: 压缩文件 -->
<el-card header="压缩文件上传" class="demo-card">
<sa-file-upload
v-model="zipFile"
accept=".zip,.rar,.7z"
accept-hint="ZIP、RAR、7Z"
:max-size="50"
:drag="true"
/>
<div class="result">当前文件: {{ zipFile }}</div>
</el-card>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
const singleFile = ref('')
const multipleFiles = ref<string[]>([])
const dragFile = ref('')
const docFile = ref('')
const excelFile = ref('')
const zipFile = ref('')
</script>
<style scoped lang="scss">
.demo-container {
padding: 20px;
h2 {
margin-bottom: 20px;
}
.demo-card {
margin-bottom: 20px;
.result {
margin-top: 15px;
padding: 10px;
background-color: #f5f7fa;
border-radius: 4px;
font-size: 12px;
color: #606266;
word-break: break-all;
}
}
}
</style>

View File

@@ -0,0 +1,269 @@
<template>
<div class="sa-file-upload">
<el-upload
ref="uploadRef"
:file-list="fileList"
:limit="limit"
:multiple="multiple"
:accept="accept"
:http-request="handleUpload"
:before-upload="beforeUpload"
:on-remove="handleRemove"
:on-preview="handlePreview"
:on-exceed="handleExceed"
:disabled="disabled"
:drag="drag"
class="upload-container"
>
<template #default>
<div v-if="drag" class="upload-dragger">
<el-icon class="upload-icon"><UploadFilled /></el-icon>
<div class="upload-text">将文件拖到此处<em>点击上传</em></div>
</div>
<el-button v-else type="primary" :icon="Upload">
{{ buttonText }}
</el-button>
</template>
<template #tip>
<div class="el-upload__tip">
<span v-if="acceptHint">支持 {{ acceptHint }} 格式</span>
单个文件不超过 {{ maxSize }}MB最多上传 {{ limit }} 个文件
</div>
</template>
</el-upload>
</div>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue'
import { Upload, UploadFilled } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { UploadProps, UploadUserFile, UploadRequestOptions } from 'element-plus'
import { uploadFile } from '@/api/auth'
defineOptions({ name: 'SaFileUpload' })
// 定义 Props
interface Props {
modelValue?: string | string[] // v-model 绑定值
multiple?: boolean // 是否支持多选
limit?: number // 最大上传数量
maxSize?: number // 最大文件大小(MB)
accept?: string // 接受的文件类型
acceptHint?: string // 接受文件类型的提示文本
disabled?: boolean // 是否禁用
drag?: boolean // 是否启用拖拽上传
buttonText?: string // 按钮文本
}
const props = withDefaults(defineProps<Props>(), {
modelValue: () => [],
multiple: false,
limit: 1,
maxSize: 10,
accept: '*',
acceptHint: '',
disabled: false,
drag: false,
buttonText: '选择文件'
})
// 定义 Emits
const emit = defineEmits<{
'update:modelValue': [value: string | string[]]
success: [response: any]
error: [error: any]
}>()
// 状态
const uploadRef = ref()
const fileList = ref<UploadUserFile[]>([])
// 监听 modelValue 变化,同步到 fileList
watch(
() => props.modelValue,
(newVal) => {
if (!newVal || (Array.isArray(newVal) && newVal.length === 0)) {
fileList.value = []
uploadRef.value?.clearFiles()
return
}
const urls = Array.isArray(newVal) ? newVal : [newVal]
fileList.value = urls
.filter((url) => url)
.map((url, index) => {
// 从 URL 中提取文件名
const fileName = url.split('/').pop() || `file-${index + 1}`
return {
name: fileName,
url: url,
uid: Date.now() + index
}
})
},
{ immediate: true }
)
// 上传前验证
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
// 验证文件类型(如果指定了 accept
if (props.accept && props.accept !== '*') {
const acceptTypes = props.accept.split(',').map((type) => type.trim())
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase()
const fileMimeType = file.type
const isAccepted = acceptTypes.some((type) => {
if (type.startsWith('.')) {
return fileExtension === type.toLowerCase()
}
if (type.includes('/*')) {
const mainType = type.split('/')[0]
return fileMimeType.startsWith(mainType)
}
return fileMimeType === type
})
if (!isAccepted) {
ElMessage.error(
`不支持的文件类型!${props.acceptHint ? '请上传 ' + props.acceptHint + ' 格式的文件' : ''}`
)
return false
}
}
// 验证文件大小
const isLtMaxSize = file.size / 1024 / 1024 < props.maxSize
if (!isLtMaxSize) {
ElMessage.error(`文件大小不能超过 ${props.maxSize}MB!`)
return false
}
return true
}
// 自定义上传
const handleUpload = async (options: UploadRequestOptions) => {
const { file, onSuccess, onError } = options
try {
// 创建 FormData
const formData = new FormData()
formData.append('file', file)
// 调用上传接口
const response: any = await uploadFile(formData)
// 尝试从不同的响应格式中获取文件URL
const fileUrl = response?.data?.url || response?.data || response?.url || ''
if (!fileUrl) {
throw new Error('上传失败,未返回文件地址')
}
// 更新文件列表
const newFile: UploadUserFile = {
name: file.name,
url: fileUrl,
uid: file.uid
}
fileList.value.push(newFile)
updateModelValue()
// 触发成功回调
onSuccess?.(response)
emit('success', response)
ElMessage.success('上传成功!')
} catch (error: any) {
console.error('上传失败:', error)
onError?.(error)
emit('error', error)
ElMessage.error(error.message || '上传失败!')
}
}
// 删除文件
const handleRemove: UploadProps['onRemove'] = (file) => {
const index = fileList.value.findIndex((item) => item.uid === file.uid)
if (index > -1) {
fileList.value.splice(index, 1)
updateModelValue()
}
}
// 超出限制提示
const handleExceed: UploadProps['onExceed'] = () => {
ElMessage.warning(`最多只能上传 ${props.limit} 个文件,请先删除已有文件后再上传`)
}
// 预览文件
const handlePreview: UploadProps['onPreview'] = (file) => {
if (file.url) {
// 在新窗口打开文件
window.open(file.url, '_blank')
}
}
// 更新 v-model 值
const updateModelValue = () => {
const urls = fileList.value.map((file) => file.url).filter((url) => url) as string[]
if (props.multiple) {
emit('update:modelValue', urls)
} else {
emit('update:modelValue', urls[0] || '')
}
}
</script>
<style scoped lang="scss">
.sa-file-upload {
width: 100%;
.upload-container {
:deep(.el-upload) {
width: 250px;
justify-content: start;
}
:deep(.el-upload-dragger) {
width: 250px;
padding: 20px 10px;
}
:deep(.el-upload-list) {
margin-top: 10px;
}
}
.upload-dragger {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.upload-icon {
font-size: 48px;
color: #c0c4cc;
margin-bottom: 16px;
}
.upload-text {
font-size: 14px;
color: #606266;
em {
color: var(--el-color-primary);
font-style: normal;
}
}
}
.el-upload__tip {
font-size: 12px;
color: #909399;
margin-top: 7px;
line-height: 1.5;
}
}
</style>

View File

@@ -0,0 +1,186 @@
<template>
<el-popover placement="bottom-start" :width="460" trigger="click" v-model:visible="visible">
<template #reference>
<div
class="w-full relative cursor-pointer"
@mouseenter="hovering = true"
@mouseleave="hovering = false"
>
<el-input v-bind="$attrs" v-model="modelValue" readonly placeholder="点击选择图标">
<template #prepend>
<div class="w-8 flex items-center justify-center">
<Icon v-if="modelValue" :icon="modelValue" class="text-lg" />
<el-icon v-else class="text-lg text-gray-400"><Search /></el-icon>
</div>
</template>
</el-input>
<div
v-if="hovering && modelValue && !disabled"
class="absolute right-2 top-0 h-full flex items-center cursor-pointer text-gray-400 hover:text-gray-600"
@click.stop="handleClear"
>
<el-icon><CircleClose /></el-icon>
</div>
</div>
</template>
<div class="icon-picker">
<div class="mb-3">
<el-input
v-model="searchText"
placeholder="搜索图标(英文关键词)"
clearable
prefix-icon="Search"
@input="handleSearch"
/>
</div>
<div class="h-[300px] overflow-y-auto custom-scrollbar">
<div v-if="searchText" class="search-results">
<div v-if="filteredIcons.length === 0" class="text-center text-gray-400 py-8">
未找到相关图标
</div>
<div v-else class="grid grid-cols-6 gap-2">
<div
v-for="icon in filteredIcons"
:key="icon.name"
class="icon-item flex flex-col items-center justify-center p-2 rounded cursor-pointer hover:bg-gray-100 transition-colors"
:class="{ 'bg-primary-50 text-primary': modelValue === icon.name }"
@click="handleSelect(icon.name)"
:title="icon.name"
>
<Icon :icon="icon.name" class="text-2xl mb-1" />
</div>
</div>
</div>
<div v-else>
<el-collapse v-model="activeNames">
<el-collapse-item
v-for="category in categories"
:key="category.name"
:title="category.name"
:name="category.name"
>
<div class="grid grid-cols-6 gap-2">
<div
v-for="icon in category.icons"
:key="icon.name"
class="icon-item flex flex-col items-center justify-center p-2 rounded cursor-pointer hover:bg-gray-100 transition-colors"
:class="{ 'bg-primary-50 text-primary': modelValue === icon.name }"
@click="handleSelect(icon.name)"
:title="icon.name"
>
<Icon :icon="icon.name" class="text-2xl mb-1" />
</div>
</div>
</el-collapse-item>
</el-collapse>
</div>
</div>
</div>
</el-popover>
</template>
<script lang="ts" setup>
import { Search, CircleClose } from '@element-plus/icons-vue'
import { Icon } from '@iconify/vue'
import rawIcons from './lib/RemixIcon.json'
defineOptions({ name: 'SaIconPicker', inheritAttrs: false })
interface Props {
disabled?: boolean
}
const props = withDefaults(defineProps<Props>(), {
disabled: false
})
const modelValue = defineModel<string>()
const visible = ref(false)
const searchText = ref('')
const hovering = ref(false)
const activeNames = ref(['Arrows']) // 默认展开第一个分类
// 处理图标数据
interface IconItem {
name: string
tags: string
}
interface Category {
name: string
icons: IconItem[]
}
// 计算属性缓存处理后的图标数据
const { allIcons, categories } = useMemo(() => {
const all: IconItem[] = []
const cats: Category[] = []
for (const [categoryName, icons] of Object.entries(rawIcons)) {
const iconList = icons as string[]
const categoryIcons: IconItem[] = []
for (const name of iconList) {
const iconName = `ri:${name}`
const item = { name: iconName, tags: name }
categoryIcons.push(item)
all.push(item)
}
cats.push({
name: categoryName,
icons: categoryIcons
})
}
return { allIcons: all, categories: cats }
})
// 简单的 hook 模拟 useMemo在 vue 中直接执行即可,因为 rawIcons 是静态的
function useMemo<T>(fn: () => T): T {
return fn()
}
// 搜索过滤
const filteredIcons = computed(() => {
if (!searchText.value) return []
const query = searchText.value.toLowerCase()
return allIcons
.filter(
(icon) => icon.name.toLowerCase().includes(query) || icon.tags.toLowerCase().includes(query)
)
.slice(0, 100) // 限制显示数量以提高性能
})
const handleSearch = () => {
// 搜索逻辑主要由 computed 处理
}
const handleSelect = (icon: string) => {
modelValue.value = icon
visible.value = false
searchText.value = ''
}
const handleClear = () => {
modelValue.value = ''
}
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: #e5e7eb;
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background-color: transparent;
}
</style>

View File

@@ -0,0 +1,3175 @@
{
"Arrows": [
"arrow-down-box-fill",
"arrow-down-box-line",
"arrow-down-circle-fill",
"arrow-down-circle-line",
"arrow-down-double-fill",
"arrow-down-double-line",
"arrow-down-fill",
"arrow-down-line",
"arrow-down-long-fill",
"arrow-down-long-line",
"arrow-down-s-fill",
"arrow-down-s-line",
"arrow-down-wide-fill",
"arrow-down-wide-line",
"arrow-drop-down-fill",
"arrow-drop-down-line",
"arrow-drop-left-fill",
"arrow-drop-left-line",
"arrow-drop-right-fill",
"arrow-drop-right-line",
"arrow-drop-up-fill",
"arrow-drop-up-line",
"arrow-go-back-fill",
"arrow-go-back-line",
"arrow-go-forward-fill",
"arrow-go-forward-line",
"arrow-left-box-fill",
"arrow-left-box-line",
"arrow-left-circle-fill",
"arrow-left-circle-line",
"arrow-left-double-fill",
"arrow-left-double-line",
"arrow-left-down-box-fill",
"arrow-left-down-box-line",
"arrow-left-down-fill",
"arrow-left-down-line",
"arrow-left-down-long-fill",
"arrow-left-down-long-line",
"arrow-left-fill",
"arrow-left-line",
"arrow-left-long-fill",
"arrow-left-long-line",
"arrow-left-right-fill",
"arrow-left-right-line",
"arrow-left-s-fill",
"arrow-left-s-line",
"arrow-left-up-box-fill",
"arrow-left-up-box-line",
"arrow-left-up-fill",
"arrow-left-up-line",
"arrow-left-up-long-fill",
"arrow-left-up-long-line",
"arrow-left-wide-fill",
"arrow-left-wide-line",
"arrow-right-box-fill",
"arrow-right-box-line",
"arrow-right-circle-fill",
"arrow-right-circle-line",
"arrow-right-double-fill",
"arrow-right-double-line",
"arrow-right-down-box-fill",
"arrow-right-down-box-line",
"arrow-right-down-fill",
"arrow-right-down-line",
"arrow-right-down-long-fill",
"arrow-right-down-long-line",
"arrow-right-fill",
"arrow-right-line",
"arrow-right-long-fill",
"arrow-right-long-line",
"arrow-right-s-fill",
"arrow-right-s-line",
"arrow-right-up-box-fill",
"arrow-right-up-box-line",
"arrow-right-up-fill",
"arrow-right-up-line",
"arrow-right-up-long-fill",
"arrow-right-up-long-line",
"arrow-right-wide-fill",
"arrow-right-wide-line",
"arrow-turn-back-fill",
"arrow-turn-back-line",
"arrow-turn-forward-fill",
"arrow-turn-forward-line",
"arrow-up-box-fill",
"arrow-up-box-line",
"arrow-up-circle-fill",
"arrow-up-circle-line",
"arrow-up-double-fill",
"arrow-up-double-line",
"arrow-up-down-fill",
"arrow-up-down-line",
"arrow-up-fill",
"arrow-up-line",
"arrow-up-long-fill",
"arrow-up-long-line",
"arrow-up-s-fill",
"arrow-up-s-line",
"arrow-up-wide-fill",
"arrow-up-wide-line",
"collapse-diagonal-2-fill",
"collapse-diagonal-2-line",
"collapse-diagonal-fill",
"collapse-diagonal-line",
"collapse-horizontal-fill",
"collapse-horizontal-line",
"collapse-vertical-fill",
"collapse-vertical-line",
"contract-left-fill",
"contract-left-line",
"contract-left-right-fill",
"contract-left-right-line",
"contract-right-fill",
"contract-right-line",
"contract-up-down-fill",
"contract-up-down-line",
"corner-down-left-fill",
"corner-down-left-line",
"corner-down-right-fill",
"corner-down-right-line",
"corner-left-down-fill",
"corner-left-down-line",
"corner-left-up-fill",
"corner-left-up-line",
"corner-right-down-fill",
"corner-right-down-line",
"corner-right-up-fill",
"corner-right-up-line",
"corner-up-left-double-fill",
"corner-up-left-double-line",
"corner-up-left-fill",
"corner-up-left-line",
"corner-up-right-double-fill",
"corner-up-right-double-line",
"corner-up-right-fill",
"corner-up-right-line",
"drag-move-2-fill",
"drag-move-2-line",
"drag-move-fill",
"drag-move-line",
"expand-diagonal-2-fill",
"expand-diagonal-2-line",
"expand-diagonal-fill",
"expand-diagonal-line",
"expand-diagonal-s-2-fill",
"expand-diagonal-s-2-line",
"expand-diagonal-s-fill",
"expand-diagonal-s-line",
"expand-height-fill",
"expand-height-line",
"expand-horizontal-fill",
"expand-horizontal-line",
"expand-horizontal-s-fill",
"expand-horizontal-s-line",
"expand-left-fill",
"expand-left-line",
"expand-left-right-fill",
"expand-left-right-line",
"expand-right-fill",
"expand-right-line",
"expand-up-down-fill",
"expand-up-down-line",
"expand-vertical-fill",
"expand-vertical-line",
"expand-vertical-s-fill",
"expand-vertical-s-line",
"expand-width-fill",
"expand-width-line",
"scroll-to-bottom-fill",
"scroll-to-bottom-line",
"skip-down-fill",
"skip-down-line",
"skip-left-fill",
"skip-left-line",
"skip-right-fill",
"skip-right-line",
"skip-up-fill",
"skip-up-line"
],
"Buildings": [
"ancient-gate-fill",
"ancient-gate-line",
"ancient-pavilion-fill",
"ancient-pavilion-line",
"bank-fill",
"bank-line",
"building-2-fill",
"building-2-line",
"building-3-fill",
"building-3-line",
"building-4-fill",
"building-4-line",
"building-fill",
"building-line",
"community-fill",
"community-line",
"government-fill",
"government-line",
"home-2-fill",
"home-2-line",
"home-3-fill",
"home-3-line",
"home-4-fill",
"home-4-line",
"home-5-fill",
"home-5-line",
"home-6-fill",
"home-6-line",
"home-7-fill",
"home-7-line",
"home-8-fill",
"home-8-line",
"home-9-fill",
"home-9-line",
"home-fill",
"home-gear-fill",
"home-gear-line",
"home-heart-fill",
"home-heart-line",
"home-line",
"home-office-fill",
"home-office-line",
"home-smile-2-fill",
"home-smile-2-line",
"home-smile-fill",
"home-smile-line",
"home-wifi-fill",
"home-wifi-line",
"hospital-fill",
"hospital-line",
"hotel-fill",
"hotel-line",
"school-fill",
"school-line",
"store-2-fill",
"store-2-line",
"store-3-fill",
"store-3-line",
"store-fill",
"store-line",
"tent-fill",
"tent-line"
],
"Business": [
"advertisement-fill",
"advertisement-line",
"archive-2-fill",
"archive-2-line",
"archive-drawer-fill",
"archive-drawer-line",
"archive-fill",
"archive-line",
"archive-stack-fill",
"archive-stack-line",
"at-fill",
"at-line",
"attachment-fill",
"attachment-line",
"award-fill",
"award-line",
"bar-chart-2-fill",
"bar-chart-2-line",
"bar-chart-box-ai-fill",
"bar-chart-box-ai-line",
"bar-chart-box-fill",
"bar-chart-box-line",
"bar-chart-fill",
"bar-chart-grouped-fill",
"bar-chart-grouped-line",
"bar-chart-horizontal-fill",
"bar-chart-horizontal-line",
"bar-chart-line",
"bookmark-2-fill",
"bookmark-2-line",
"bookmark-3-fill",
"bookmark-3-line",
"bookmark-fill",
"bookmark-line",
"briefcase-2-fill",
"briefcase-2-line",
"briefcase-3-fill",
"briefcase-3-line",
"briefcase-4-fill",
"briefcase-4-line",
"briefcase-5-fill",
"briefcase-5-line",
"briefcase-fill",
"briefcase-line",
"bubble-chart-fill",
"bubble-chart-line",
"calculator-fill",
"calculator-line",
"calendar-2-fill",
"calendar-2-line",
"calendar-check-fill",
"calendar-check-line",
"calendar-close-fill",
"calendar-close-line",
"calendar-event-fill",
"calendar-event-line",
"calendar-fill",
"calendar-line",
"calendar-schedule-fill",
"calendar-schedule-line",
"calendar-todo-fill",
"calendar-todo-line",
"cloud-fill",
"cloud-line",
"cloud-off-fill",
"cloud-off-line",
"copyleft-fill",
"copyleft-line",
"copyright-fill",
"copyright-line",
"creative-commons-by-fill",
"creative-commons-by-line",
"creative-commons-fill",
"creative-commons-line",
"creative-commons-nc-fill",
"creative-commons-nc-line",
"creative-commons-nd-fill",
"creative-commons-nd-line",
"creative-commons-sa-fill",
"creative-commons-sa-line",
"creative-commons-zero-fill",
"creative-commons-zero-line",
"customer-service-2-fill",
"customer-service-2-line",
"customer-service-fill",
"customer-service-line",
"donut-chart-fill",
"donut-chart-line",
"flag-2-fill",
"flag-2-line",
"flag-fill",
"flag-line",
"flag-off-fill",
"flag-off-line",
"global-fill",
"global-line",
"honour-fill",
"honour-line",
"id-card-fill",
"id-card-line",
"inbox-2-fill",
"inbox-2-line",
"inbox-archive-fill",
"inbox-archive-line",
"inbox-fill",
"inbox-line",
"inbox-unarchive-fill",
"inbox-unarchive-line",
"info-card-fill",
"info-card-line",
"line-chart-fill",
"line-chart-line",
"links-fill",
"links-line",
"mail-add-fill",
"mail-add-line",
"mail-ai-fill",
"mail-ai-line",
"mail-check-fill",
"mail-check-line",
"mail-close-fill",
"mail-close-line",
"mail-download-fill",
"mail-download-line",
"mail-fill",
"mail-forbid-fill",
"mail-forbid-line",
"mail-line",
"mail-lock-fill",
"mail-lock-line",
"mail-open-fill",
"mail-open-line",
"mail-send-fill",
"mail-send-line",
"mail-settings-fill",
"mail-settings-line",
"mail-star-fill",
"mail-star-line",
"mail-unread-fill",
"mail-unread-line",
"mail-volume-fill",
"mail-volume-line",
"medal-2-fill",
"medal-2-line",
"medal-fill",
"medal-line",
"megaphone-fill",
"megaphone-line",
"pass-expired-fill",
"pass-expired-line",
"pass-pending-fill",
"pass-pending-line",
"pass-valid-fill",
"pass-valid-line",
"pie-chart-2-fill",
"pie-chart-2-line",
"pie-chart-box-fill",
"pie-chart-box-line",
"pie-chart-fill",
"pie-chart-line",
"presentation-fill",
"presentation-line",
"printer-cloud-fill",
"printer-cloud-line",
"printer-fill",
"printer-line",
"profile-fill",
"profile-line",
"projector-2-fill",
"projector-2-line",
"projector-fill",
"projector-line",
"record-mail-fill",
"record-mail-line",
"registered-fill",
"registered-line",
"reply-all-fill",
"reply-all-line",
"reply-fill",
"reply-line",
"send-plane-2-fill",
"send-plane-2-line",
"send-plane-fill",
"send-plane-line",
"seo-fill",
"seo-line",
"service-fill",
"service-line",
"shake-hands-fill",
"shake-hands-line",
"slideshow-2-fill",
"slideshow-2-line",
"slideshow-3-fill",
"slideshow-3-line",
"slideshow-4-fill",
"slideshow-4-line",
"slideshow-fill",
"slideshow-line",
"stack-fill",
"stack-line",
"trademark-fill",
"trademark-line",
"triangular-flag-fill",
"triangular-flag-line",
"verified-badge-fill",
"verified-badge-line",
"window-2-fill",
"window-2-line",
"window-fill",
"window-line"
],
"Communication": [
"chat-1-fill",
"chat-1-line",
"chat-2-fill",
"chat-2-line",
"chat-3-fill",
"chat-3-line",
"chat-4-fill",
"chat-4-line",
"chat-ai-2-fill",
"chat-ai-2-line",
"chat-ai-3-fill",
"chat-ai-3-line",
"chat-ai-4-fill",
"chat-ai-4-line",
"chat-ai-fill",
"chat-ai-line",
"chat-check-fill",
"chat-check-line",
"chat-delete-fill",
"chat-delete-line",
"chat-download-fill",
"chat-download-line",
"chat-follow-up-fill",
"chat-follow-up-line",
"chat-forward-fill",
"chat-forward-line",
"chat-heart-fill",
"chat-heart-line",
"chat-history-fill",
"chat-history-line",
"chat-new-fill",
"chat-new-line",
"chat-off-fill",
"chat-off-line",
"chat-poll-fill",
"chat-poll-line",
"chat-private-fill",
"chat-private-line",
"chat-quote-fill",
"chat-quote-line",
"chat-search-fill",
"chat-search-line",
"chat-settings-fill",
"chat-settings-line",
"chat-smile-2-fill",
"chat-smile-2-line",
"chat-smile-3-fill",
"chat-smile-3-line",
"chat-smile-ai-3-fill",
"chat-smile-ai-3-line",
"chat-smile-ai-fill",
"chat-smile-ai-line",
"chat-smile-fill",
"chat-smile-line",
"chat-thread-fill",
"chat-thread-line",
"chat-unread-fill",
"chat-unread-line",
"chat-upload-fill",
"chat-upload-line",
"chat-voice-ai-fill",
"chat-voice-ai-line",
"chat-voice-fill",
"chat-voice-line",
"discuss-fill",
"discuss-line",
"emoji-sticker-fill",
"emoji-sticker-line",
"feedback-fill",
"feedback-line",
"message-2-fill",
"message-2-line",
"message-3-fill",
"message-3-line",
"message-ai-3-fill",
"message-ai-3-line",
"message-fill",
"message-line",
"question-answer-fill",
"question-answer-line",
"questionnaire-fill",
"questionnaire-line",
"speak-ai-fill",
"speak-ai-line",
"speak-fill",
"speak-line",
"speech-to-text-fill",
"speech-to-text-line",
"text-to-speech-fill",
"text-to-speech-line",
"video-chat-fill",
"video-chat-line"
],
"Design": [
"ai-generate-2-fill",
"ai-generate-2-line",
"align-item-bottom-fill",
"align-item-bottom-line",
"align-item-horizontal-center-fill",
"align-item-horizontal-center-line",
"align-item-left-fill",
"align-item-left-line",
"align-item-right-fill",
"align-item-right-line",
"align-item-top-fill",
"align-item-top-line",
"align-item-vertical-center-fill",
"align-item-vertical-center-line",
"anticlockwise-2-fill",
"anticlockwise-2-line",
"anticlockwise-fill",
"anticlockwise-line",
"artboard-2-fill",
"artboard-2-line",
"artboard-fill",
"artboard-line",
"ball-pen-fill",
"ball-pen-line",
"blur-off-fill",
"blur-off-line",
"brush-2-fill",
"brush-2-line",
"brush-3-fill",
"brush-3-line",
"brush-4-fill",
"brush-4-line",
"brush-ai-3-fill",
"brush-ai-3-line",
"brush-ai-fill",
"brush-ai-line",
"brush-fill",
"brush-line",
"circle-fill",
"circle-line",
"clockwise-2-fill",
"clockwise-2-line",
"clockwise-fill",
"clockwise-line",
"collage-fill",
"collage-line",
"color-filter-ai-fill",
"color-filter-ai-line",
"color-filter-fill",
"color-filter-line",
"compasses-2-fill",
"compasses-2-line",
"compasses-fill",
"compasses-line",
"contrast-2-fill",
"contrast-2-line",
"contrast-drop-2-fill",
"contrast-drop-2-line",
"contrast-drop-fill",
"contrast-drop-line",
"contrast-fill",
"contrast-line",
"crop-2-fill",
"crop-2-line",
"crop-fill",
"crop-line",
"crosshair-2-fill",
"crosshair-2-line",
"crosshair-fill",
"crosshair-line",
"drag-drop-fill",
"drag-drop-line",
"drop-fill",
"drop-line",
"edit-2-fill",
"edit-2-line",
"edit-box-fill",
"edit-box-line",
"edit-circle-fill",
"edit-circle-line",
"edit-fill",
"edit-line",
"eraser-fill",
"eraser-line",
"flip-horizontal-2-fill",
"flip-horizontal-2-line",
"flip-horizontal-fill",
"flip-horizontal-line",
"flip-vertical-2-fill",
"flip-vertical-2-line",
"flip-vertical-fill",
"flip-vertical-line",
"focus-2-fill",
"focus-2-line",
"focus-3-fill",
"focus-3-line",
"focus-fill",
"focus-line",
"grid-fill",
"grid-line",
"hammer-fill",
"hammer-line",
"hexagon-fill",
"hexagon-line",
"ink-bottle-fill",
"ink-bottle-line",
"input-method-fill",
"input-method-line",
"layout-2-fill",
"layout-2-line",
"layout-3-fill",
"layout-3-line",
"layout-4-fill",
"layout-4-line",
"layout-5-fill",
"layout-5-line",
"layout-6-fill",
"layout-6-line",
"layout-bottom-2-fill",
"layout-bottom-2-line",
"layout-bottom-fill",
"layout-bottom-line",
"layout-column-fill",
"layout-column-line",
"layout-fill",
"layout-grid-2-fill",
"layout-grid-2-line",
"layout-grid-fill",
"layout-grid-line",
"layout-horizontal-fill",
"layout-horizontal-line",
"layout-left-2-fill",
"layout-left-2-line",
"layout-left-fill",
"layout-left-line",
"layout-line",
"layout-masonry-fill",
"layout-masonry-line",
"layout-right-2-fill",
"layout-right-2-line",
"layout-right-fill",
"layout-right-line",
"layout-row-fill",
"layout-row-line",
"layout-top-2-fill",
"layout-top-2-line",
"layout-top-fill",
"layout-top-line",
"layout-vertical-fill",
"layout-vertical-line",
"magic-fill",
"magic-line",
"mark-pen-fill",
"mark-pen-line",
"markup-fill",
"markup-line",
"octagon-fill",
"octagon-line",
"paint-brush-fill",
"paint-brush-line",
"paint-fill",
"paint-line",
"painting-ai-fill",
"painting-ai-line",
"painting-fill",
"painting-line",
"palette-fill",
"palette-line",
"pantone-fill",
"pantone-line",
"pen-nib-fill",
"pen-nib-line",
"pencil-ai-2-fill",
"pencil-ai-2-line",
"pencil-ai-fill",
"pencil-ai-line",
"pencil-fill",
"pencil-line",
"pencil-ruler-2-fill",
"pencil-ruler-2-line",
"pencil-ruler-fill",
"pencil-ruler-line",
"pentagon-fill",
"pentagon-line",
"quill-pen-ai-fill",
"quill-pen-ai-line",
"quill-pen-fill",
"quill-pen-line",
"rectangle-fill",
"rectangle-line",
"remix-fill",
"remix-line",
"ruler-2-fill",
"ruler-2-line",
"ruler-fill",
"ruler-line",
"scissors-2-fill",
"scissors-2-line",
"scissors-cut-fill",
"scissors-cut-line",
"scissors-fill",
"scissors-line",
"screenshot-2-fill",
"screenshot-2-line",
"screenshot-fill",
"screenshot-line",
"shadow-fill",
"shadow-line",
"shape-2-fill",
"shape-2-line",
"shape-fill",
"shape-line",
"shapes-fill",
"shapes-line",
"sip-fill",
"sip-line",
"slice-fill",
"slice-line",
"square-fill",
"square-line",
"t-box-fill",
"t-box-line",
"table-alt-fill",
"table-alt-line",
"table-fill",
"table-line",
"tools-fill",
"tools-line",
"triangle-fill",
"triangle-line",
"wrench-fill",
"wrench-line"
],
"Development": [
"braces-fill",
"braces-line",
"brackets-fill",
"brackets-line",
"bug-2-fill",
"bug-2-line",
"bug-fill",
"bug-line",
"code-ai-fill",
"code-ai-line",
"code-box-fill",
"code-box-line",
"code-fill",
"code-line",
"code-s-fill",
"code-s-line",
"code-s-slash-fill",
"code-s-slash-line",
"command-fill",
"command-line",
"css3-fill",
"css3-line",
"cursor-fill",
"cursor-line",
"git-branch-fill",
"git-branch-line",
"git-close-pull-request-fill",
"git-close-pull-request-line",
"git-commit-fill",
"git-commit-line",
"git-fork-fill",
"git-fork-line",
"git-merge-fill",
"git-merge-line",
"git-pr-draft-fill",
"git-pr-draft-line",
"git-pull-request-fill",
"git-pull-request-line",
"git-repository-commits-fill",
"git-repository-commits-line",
"git-repository-fill",
"git-repository-line",
"git-repository-private-fill",
"git-repository-private-line",
"html5-fill",
"html5-line",
"javascript-fill",
"javascript-line",
"parentheses-fill",
"parentheses-line",
"php-fill",
"php-line",
"puzzle-2-fill",
"puzzle-2-line",
"puzzle-fill",
"puzzle-line",
"terminal-box-fill",
"terminal-box-line",
"terminal-fill",
"terminal-line",
"terminal-window-fill",
"terminal-window-line"
],
"Device": [
"airplay-fill",
"airplay-line",
"barcode-box-fill",
"barcode-box-line",
"barcode-fill",
"barcode-line",
"base-station-fill",
"base-station-line",
"battery-2-charge-fill",
"battery-2-charge-line",
"battery-2-fill",
"battery-2-line",
"battery-charge-fill",
"battery-charge-line",
"battery-fill",
"battery-line",
"battery-low-fill",
"battery-low-line",
"battery-saver-fill",
"battery-saver-line",
"battery-share-fill",
"battery-share-line",
"bluetooth-connect-fill",
"bluetooth-connect-line",
"bluetooth-fill",
"bluetooth-line",
"cast-fill",
"cast-line",
"cellphone-fill",
"cellphone-line",
"computer-fill",
"computer-line",
"cpu-fill",
"cpu-line",
"dashboard-2-fill",
"dashboard-2-line",
"dashboard-3-fill",
"dashboard-3-line",
"database-2-fill",
"database-2-line",
"database-fill",
"database-line",
"device-fill",
"device-line",
"device-recover-fill",
"device-recover-line",
"dual-sim-1-fill",
"dual-sim-1-line",
"dual-sim-2-fill",
"dual-sim-2-line",
"fingerprint-2-fill",
"fingerprint-2-line",
"fingerprint-fill",
"fingerprint-line",
"gamepad-fill",
"gamepad-line",
"gps-fill",
"gps-line",
"gradienter-fill",
"gradienter-line",
"hard-drive-2-fill",
"hard-drive-2-line",
"hard-drive-3-fill",
"hard-drive-3-line",
"hard-drive-fill",
"hard-drive-line",
"hotspot-fill",
"hotspot-line",
"install-fill",
"install-line",
"instance-fill",
"instance-line",
"keyboard-box-fill",
"keyboard-box-line",
"keyboard-fill",
"keyboard-line",
"mac-fill",
"mac-line",
"macbook-fill",
"macbook-line",
"mobile-download-fill",
"mobile-download-line",
"mouse-fill",
"mouse-line",
"phone-fill",
"phone-find-fill",
"phone-find-line",
"phone-line",
"phone-lock-fill",
"phone-lock-line",
"qr-code-fill",
"qr-code-line",
"qr-scan-2-fill",
"qr-scan-2-line",
"qr-scan-fill",
"qr-scan-line",
"radar-fill",
"radar-line",
"ram-2-fill",
"ram-2-line",
"ram-fill",
"ram-line",
"remote-control-2-fill",
"remote-control-2-line",
"remote-control-fill",
"remote-control-line",
"restart-fill",
"restart-line",
"rfid-fill",
"rfid-line",
"rotate-lock-fill",
"rotate-lock-line",
"router-fill",
"router-line",
"rss-fill",
"rss-line",
"save-2-fill",
"save-2-line",
"save-3-fill",
"save-3-line",
"save-fill",
"save-line",
"scan-2-fill",
"scan-2-line",
"scan-fill",
"scan-line",
"sd-card-fill",
"sd-card-line",
"sd-card-mini-fill",
"sd-card-mini-line",
"sensor-fill",
"sensor-line",
"server-fill",
"server-line",
"shut-down-fill",
"shut-down-line",
"signal-wifi-1-fill",
"signal-wifi-1-line",
"signal-wifi-2-fill",
"signal-wifi-2-line",
"signal-wifi-3-fill",
"signal-wifi-3-line",
"signal-wifi-error-fill",
"signal-wifi-error-line",
"signal-wifi-fill",
"signal-wifi-line",
"signal-wifi-off-fill",
"signal-wifi-off-line",
"sim-card-2-fill",
"sim-card-2-line",
"sim-card-fill",
"sim-card-line",
"smartphone-fill",
"smartphone-line",
"tablet-fill",
"tablet-line",
"tv-2-fill",
"tv-2-line",
"tv-fill",
"tv-line",
"u-disk-fill",
"u-disk-line",
"uninstall-fill",
"uninstall-line",
"usb-fill",
"usb-line",
"wifi-fill",
"wifi-line",
"wifi-off-fill",
"wifi-off-line",
"wireless-charging-fill",
"wireless-charging-line"
],
"Document": [
"article-fill",
"article-line",
"bill-fill",
"bill-line",
"book-2-fill",
"book-2-line",
"book-3-fill",
"book-3-line",
"book-ai-fill",
"book-ai-line",
"book-fill",
"book-line",
"book-marked-fill",
"book-marked-line",
"book-open-fill",
"book-open-line",
"book-read-fill",
"book-read-line",
"booklet-fill",
"booklet-line",
"clipboard-fill",
"clipboard-line",
"contacts-book-2-fill",
"contacts-book-2-line",
"contacts-book-3-fill",
"contacts-book-3-line",
"contacts-book-fill",
"contacts-book-line",
"contacts-book-upload-fill",
"contacts-book-upload-line",
"contract-fill",
"contract-line",
"draft-fill",
"draft-line",
"file-2-fill",
"file-2-line",
"file-3-fill",
"file-3-line",
"file-4-fill",
"file-4-line",
"file-add-fill",
"file-add-line",
"file-ai-2-fill",
"file-ai-2-line",
"file-ai-fill",
"file-ai-line",
"file-chart-2-fill",
"file-chart-2-line",
"file-chart-fill",
"file-chart-line",
"file-check-fill",
"file-check-line",
"file-close-fill",
"file-close-line",
"file-cloud-fill",
"file-cloud-line",
"file-code-fill",
"file-code-line",
"file-copy-2-fill",
"file-copy-2-line",
"file-copy-fill",
"file-copy-line",
"file-damage-fill",
"file-damage-line",
"file-download-fill",
"file-download-line",
"file-edit-fill",
"file-edit-line",
"file-excel-2-fill",
"file-excel-2-line",
"file-excel-fill",
"file-excel-line",
"file-fill",
"file-forbid-fill",
"file-forbid-line",
"file-gif-fill",
"file-gif-line",
"file-history-fill",
"file-history-line",
"file-hwp-fill",
"file-hwp-line",
"file-image-fill",
"file-image-line",
"file-info-fill",
"file-info-line",
"file-line",
"file-list-2-fill",
"file-list-2-line",
"file-list-3-fill",
"file-list-3-line",
"file-list-fill",
"file-list-line",
"file-lock-fill",
"file-lock-line",
"file-marked-fill",
"file-marked-line",
"file-music-fill",
"file-music-line",
"file-paper-2-fill",
"file-paper-2-line",
"file-paper-fill",
"file-paper-line",
"file-pdf-2-fill",
"file-pdf-2-line",
"file-pdf-fill",
"file-pdf-line",
"file-ppt-2-fill",
"file-ppt-2-line",
"file-ppt-fill",
"file-ppt-line",
"file-reduce-fill",
"file-reduce-line",
"file-search-fill",
"file-search-line",
"file-settings-fill",
"file-settings-line",
"file-shield-2-fill",
"file-shield-2-line",
"file-shield-fill",
"file-shield-line",
"file-shred-fill",
"file-shred-line",
"file-text-fill",
"file-text-line",
"file-transfer-fill",
"file-transfer-line",
"file-unknow-fill",
"file-unknow-line",
"file-upload-fill",
"file-upload-line",
"file-user-fill",
"file-user-line",
"file-video-fill",
"file-video-line",
"file-warning-fill",
"file-warning-line",
"file-word-2-fill",
"file-word-2-line",
"file-word-fill",
"file-word-line",
"file-zip-fill",
"file-zip-line",
"folder-2-fill",
"folder-2-line",
"folder-3-fill",
"folder-3-line",
"folder-4-fill",
"folder-4-line",
"folder-5-fill",
"folder-5-line",
"folder-6-fill",
"folder-6-line",
"folder-add-fill",
"folder-add-line",
"folder-chart-2-fill",
"folder-chart-2-line",
"folder-chart-fill",
"folder-chart-line",
"folder-check-fill",
"folder-check-line",
"folder-close-fill",
"folder-close-line",
"folder-cloud-fill",
"folder-cloud-line",
"folder-download-fill",
"folder-download-line",
"folder-fill",
"folder-forbid-fill",
"folder-forbid-line",
"folder-history-fill",
"folder-history-line",
"folder-image-fill",
"folder-image-line",
"folder-info-fill",
"folder-info-line",
"folder-keyhole-fill",
"folder-keyhole-line",
"folder-line",
"folder-lock-fill",
"folder-lock-line",
"folder-music-fill",
"folder-music-line",
"folder-open-fill",
"folder-open-line",
"folder-received-fill",
"folder-received-line",
"folder-reduce-fill",
"folder-reduce-line",
"folder-settings-fill",
"folder-settings-line",
"folder-shared-fill",
"folder-shared-line",
"folder-shield-2-fill",
"folder-shield-2-line",
"folder-shield-fill",
"folder-shield-line",
"folder-transfer-fill",
"folder-transfer-line",
"folder-unknow-fill",
"folder-unknow-line",
"folder-upload-fill",
"folder-upload-line",
"folder-user-fill",
"folder-user-line",
"folder-video-fill",
"folder-video-line",
"folder-warning-fill",
"folder-warning-line",
"folder-zip-fill",
"folder-zip-line",
"folders-fill",
"folders-line",
"keynote-fill",
"keynote-line",
"markdown-fill",
"markdown-line",
"news-fill",
"news-line",
"newspaper-fill",
"newspaper-line",
"numbers-fill",
"numbers-line",
"pages-fill",
"pages-line",
"receipt-fill",
"receipt-line",
"sticky-note-2-fill",
"sticky-note-2-line",
"sticky-note-add-fill",
"sticky-note-add-line",
"sticky-note-fill",
"sticky-note-line",
"survey-fill",
"survey-line",
"task-fill",
"task-line",
"todo-fill",
"todo-line"
],
"Editor": [
"a-b",
"ai",
"ai-generate",
"ai-generate-2",
"ai-generate-text",
"align-bottom",
"align-center",
"align-justify",
"align-left",
"align-right",
"align-top",
"align-vertically",
"asterisk",
"attachment-2",
"bold",
"bring-forward",
"bring-to-front",
"calendar-view",
"carousel-view",
"code-block",
"code-view",
"custom-size",
"delete-column",
"delete-row",
"double-quotes-l",
"double-quotes-r",
"draggable",
"dropdown-list",
"emphasis",
"emphasis-cn",
"english-input",
"flow-chart",
"focus-mode",
"font-color",
"font-family",
"font-mono",
"font-sans",
"font-sans-serif",
"font-size",
"font-size-2",
"font-size-ai",
"format-clear",
"formula",
"functions",
"gallery-view",
"gallery-view-2",
"h-1",
"h-2",
"h-3",
"h-4",
"h-5",
"h-6",
"hand",
"hashtag",
"heading",
"indent-decrease",
"indent-increase",
"info-i",
"input-cursor-move",
"input-field",
"insert-column-left",
"insert-column-right",
"insert-row-bottom",
"insert-row-top",
"italic",
"kanban-view",
"kanban-view-2",
"letter-spacing-2",
"line-height",
"line-height-2",
"link",
"link-m",
"link-unlink",
"link-unlink-m",
"list-check",
"list-check-2",
"list-check-3",
"list-indefinite",
"list-ordered",
"list-ordered-2",
"list-radio",
"list-unordered",
"list-view",
"merge-cells-horizontal",
"merge-cells-vertical",
"mind-map",
"node-tree",
"number-0",
"number-1",
"number-2",
"number-3",
"number-4",
"number-5",
"number-6",
"number-7",
"number-8",
"number-9",
"omega",
"organization-chart",
"overline",
"page-separator",
"paragraph",
"pinyin-input",
"question-mark",
"quote-text",
"rounded-corner",
"send-backward",
"send-to-back",
"separator",
"single-quotes-l",
"single-quotes-r",
"sketching",
"slash-commands",
"slash-commands-2",
"slideshow-view",
"sort-alphabet-asc",
"sort-alphabet-desc",
"sort-asc",
"sort-desc",
"sort-number-asc",
"sort-number-desc",
"space",
"split-cells-horizontal",
"split-cells-vertical",
"square-root",
"stacked-view",
"strikethrough",
"strikethrough-2",
"subscript",
"subscript-2",
"superscript",
"superscript-2",
"table-2",
"table-3",
"table-view",
"text",
"text-block",
"text-direction-l",
"text-direction-r",
"text-snippet",
"text-spacing",
"text-wrap",
"timeline-view",
"translate",
"translate-2",
"translate-ai",
"translate-ai-2",
"underline",
"wubi-input"
],
"Finance": [
"24-hours-fill",
"24-hours-line",
"auction-fill",
"auction-line",
"bank-card-2-fill",
"bank-card-2-line",
"bank-card-fill",
"bank-card-line",
"bit-coin-fill",
"bit-coin-line",
"bnb-fill",
"bnb-line",
"btc-fill",
"btc-line",
"cash-fill",
"cash-line",
"coin-fill",
"coin-line",
"coins-fill",
"coins-line",
"copper-coin-fill",
"copper-coin-line",
"copper-diamond-fill",
"copper-diamond-line",
"coupon-2-fill",
"coupon-2-line",
"coupon-3-fill",
"coupon-3-line",
"coupon-4-fill",
"coupon-4-line",
"coupon-5-fill",
"coupon-5-line",
"coupon-fill",
"coupon-line",
"currency-fill",
"currency-line",
"diamond-fill",
"diamond-line",
"diamond-ring-fill",
"diamond-ring-line",
"discount-percent-fill",
"discount-percent-line",
"eth-fill",
"eth-line",
"exchange-2-fill",
"exchange-2-line",
"exchange-box-fill",
"exchange-box-line",
"exchange-cny-fill",
"exchange-cny-line",
"exchange-dollar-fill",
"exchange-dollar-line",
"exchange-fill",
"exchange-funds-fill",
"exchange-funds-line",
"exchange-line",
"funds-box-fill",
"funds-box-line",
"funds-fill",
"funds-line",
"gift-2-fill",
"gift-2-line",
"gift-fill",
"gift-line",
"hand-coin-fill",
"hand-coin-line",
"hand-heart-fill",
"hand-heart-line",
"increase-decrease-fill",
"increase-decrease-line",
"jewelry-fill",
"jewelry-line",
"money-cny-box-fill",
"money-cny-box-line",
"money-cny-circle-fill",
"money-cny-circle-line",
"money-dollar-box-fill",
"money-dollar-box-line",
"money-dollar-circle-fill",
"money-dollar-circle-line",
"money-euro-box-fill",
"money-euro-box-line",
"money-euro-circle-fill",
"money-euro-circle-line",
"money-pound-box-fill",
"money-pound-box-line",
"money-pound-circle-fill",
"money-pound-circle-line",
"money-rupee-circle-fill",
"money-rupee-circle-line",
"nft-fill",
"nft-line",
"no-credit-card-fill",
"no-credit-card-line",
"p2p-fill",
"p2p-line",
"percent-fill",
"percent-line",
"price-tag-2-fill",
"price-tag-2-line",
"price-tag-3-fill",
"price-tag-3-line",
"price-tag-fill",
"price-tag-line",
"red-packet-fill",
"red-packet-line",
"refund-2-fill",
"refund-2-line",
"refund-fill",
"refund-line",
"safe-2-fill",
"safe-2-line",
"safe-3-fill",
"safe-3-line",
"safe-fill",
"safe-line",
"secure-payment-fill",
"secure-payment-line",
"shopping-bag-2-fill",
"shopping-bag-2-line",
"shopping-bag-3-fill",
"shopping-bag-3-line",
"shopping-bag-4-fill",
"shopping-bag-4-line",
"shopping-bag-fill",
"shopping-bag-line",
"shopping-basket-2-fill",
"shopping-basket-2-line",
"shopping-basket-fill",
"shopping-basket-line",
"shopping-cart-2-fill",
"shopping-cart-2-line",
"shopping-cart-fill",
"shopping-cart-line",
"stock-fill",
"stock-line",
"swap-2-fill",
"swap-2-line",
"swap-3-fill",
"swap-3-line",
"swap-box-fill",
"swap-box-line",
"swap-fill",
"swap-line",
"ticket-2-fill",
"ticket-2-line",
"ticket-fill",
"ticket-line",
"token-swap-fill",
"token-swap-line",
"trophy-fill",
"trophy-line",
"vip-crown-2-fill",
"vip-crown-2-line",
"vip-crown-fill",
"vip-crown-line",
"vip-diamond-fill",
"vip-diamond-line",
"vip-fill",
"vip-line",
"wallet-2-fill",
"wallet-2-line",
"wallet-3-fill",
"wallet-3-line",
"wallet-fill",
"wallet-line",
"water-flash-fill",
"water-flash-line",
"xrp-fill",
"xrp-line",
"xtz-fill",
"xtz-line"
],
"Food": [
"beer-fill",
"beer-line",
"bowl-fill",
"bowl-line",
"bread-fill",
"bread-line",
"cake-2-fill",
"cake-2-line",
"cake-3-fill",
"cake-3-line",
"cake-fill",
"cake-line",
"cup-fill",
"cup-line",
"drinks-2-fill",
"drinks-2-line",
"drinks-fill",
"drinks-line",
"goblet-2-fill",
"goblet-2-line",
"goblet-broken-fill",
"goblet-broken-line",
"goblet-fill",
"goblet-line",
"knife-blood-fill",
"knife-blood-line",
"knife-fill",
"knife-line",
"restaurant-2-fill",
"restaurant-2-line",
"restaurant-fill",
"restaurant-line"
],
"Health & Medical": [
"aed-electrodes-fill",
"aed-electrodes-line",
"aed-fill",
"aed-line",
"atom-fill",
"atom-line",
"brain-2-fill",
"brain-2-line",
"brain-3-fill",
"brain-3-line",
"brain-ai-3-fill",
"brain-ai-3-line",
"brain-fill",
"brain-line",
"capsule-fill",
"capsule-line",
"dislike-fill",
"dislike-line",
"dna-fill",
"dna-line",
"dossier-fill",
"dossier-line",
"dropper-fill",
"dropper-line",
"empathize-fill",
"empathize-line",
"first-aid-kit-fill",
"first-aid-kit-line",
"flask-fill",
"flask-line",
"hand-sanitizer-fill",
"hand-sanitizer-line",
"health-book-fill",
"health-book-line",
"heart-2-fill",
"heart-2-line",
"heart-3-fill",
"heart-3-line",
"heart-add-2-fill",
"heart-add-2-line",
"heart-add-fill",
"heart-add-line",
"heart-fill",
"heart-line",
"heart-pulse-fill",
"heart-pulse-line",
"hearts-fill",
"hearts-line",
"infrared-thermometer-fill",
"infrared-thermometer-line",
"lungs-fill",
"lungs-line",
"medicine-bottle-fill",
"medicine-bottle-line",
"mental-health-fill",
"mental-health-line",
"microscope-fill",
"microscope-line",
"nurse-fill",
"nurse-line",
"psychotherapy-fill",
"psychotherapy-line",
"pulse-ai-fill",
"pulse-ai-line",
"pulse-fill",
"pulse-line",
"rest-time-fill",
"rest-time-line",
"stethoscope-fill",
"stethoscope-line",
"surgical-mask-fill",
"surgical-mask-line",
"syringe-fill",
"syringe-line",
"test-tube-fill",
"test-tube-line",
"thermometer-fill",
"thermometer-line",
"virus-fill",
"virus-line",
"zzz-fill",
"zzz-line"
],
"Logos": [
"alibaba-cloud-fill",
"alibaba-cloud-line",
"alipay-fill",
"alipay-line",
"amazon-fill",
"amazon-line",
"android-fill",
"android-line",
"angularjs-fill",
"angularjs-line",
"anthropic-fill",
"anthropic-line",
"app-store-fill",
"app-store-line",
"apple-fill",
"apple-line",
"baidu-fill",
"baidu-line",
"bard-fill",
"bard-line",
"behance-fill",
"behance-line",
"bilibili-fill",
"bilibili-line",
"blender-fill",
"blender-line",
"blogger-fill",
"blogger-line",
"bluesky-fill",
"bluesky-line",
"bootstrap-fill",
"bootstrap-line",
"centos-fill",
"centos-line",
"chrome-fill",
"chrome-line",
"claude-fill",
"claude-line",
"codepen-fill",
"codepen-line",
"copilot-fill",
"copilot-line",
"coreos-fill",
"coreos-line",
"deepseek-fill",
"deepseek-line",
"dingding-fill",
"dingding-line",
"discord-fill",
"discord-line",
"disqus-fill",
"disqus-line",
"douban-fill",
"douban-line",
"dribbble-fill",
"dribbble-line",
"drive-fill",
"drive-line",
"dropbox-fill",
"dropbox-line",
"edge-fill",
"edge-line",
"edge-new-fill",
"edge-new-line",
"evernote-fill",
"evernote-line",
"facebook-box-fill",
"facebook-box-line",
"facebook-circle-fill",
"facebook-circle-line",
"facebook-fill",
"facebook-line",
"fediverse-fill",
"fediverse-line",
"figma-fill",
"figma-line",
"finder-fill",
"finder-line",
"firebase-fill",
"firebase-line",
"firefox-browser-fill",
"firefox-browser-line",
"firefox-fill",
"firefox-line",
"flickr-fill",
"flickr-line",
"flutter-fill",
"flutter-line",
"friendica-fill",
"friendica-line",
"gatsby-fill",
"gatsby-line",
"gemini-fill",
"gemini-line",
"github-fill",
"github-line",
"gitlab-fill",
"gitlab-line",
"google-fill",
"google-line",
"google-play-fill",
"google-play-line",
"honor-of-kings-fill",
"honor-of-kings-line",
"ie-fill",
"ie-line",
"instagram-fill",
"instagram-line",
"invision-fill",
"invision-line",
"java-fill",
"java-line",
"kakao-talk-fill",
"kakao-talk-line",
"kick-fill",
"kick-line",
"line-fill",
"line-line",
"linkedin-box-fill",
"linkedin-box-line",
"linkedin-fill",
"linkedin-line",
"mastercard-fill",
"mastercard-line",
"mastodon-fill",
"mastodon-line",
"medium-fill",
"medium-line",
"messenger-fill",
"messenger-line",
"meta-fill",
"meta-line",
"microsoft-fill",
"microsoft-line",
"microsoft-loop-fill",
"microsoft-loop-line",
"mini-program-fill",
"mini-program-line",
"mixtral-fill",
"mixtral-line",
"netease-cloud-music-fill",
"netease-cloud-music-line",
"netflix-fill",
"netflix-line",
"nextjs-fill",
"nextjs-line",
"nodejs-fill",
"nodejs-line",
"notion-fill",
"notion-line",
"npmjs-fill",
"npmjs-line",
"open-source-fill",
"open-source-line",
"openai-fill",
"openai-line",
"openbase-fill",
"openbase-line",
"opera-fill",
"opera-line",
"patreon-fill",
"patreon-line",
"paypal-fill",
"paypal-line",
"perplexity-fill",
"perplexity-line",
"pinterest-fill",
"pinterest-line",
"pix-fill",
"pix-line",
"pixelfed-fill",
"pixelfed-line",
"playstation-fill",
"playstation-line",
"product-hunt-fill",
"product-hunt-line",
"qq-fill",
"qq-line",
"reactjs-fill",
"reactjs-line",
"reddit-fill",
"reddit-line",
"remix-run-fill",
"remix-run-line",
"remixicon-fill",
"remixicon-line",
"safari-fill",
"safari-line",
"skype-fill",
"skype-line",
"slack-fill",
"slack-line",
"snapchat-fill",
"snapchat-line",
"soundcloud-fill",
"soundcloud-line",
"spectrum-fill",
"spectrum-line",
"spotify-fill",
"spotify-line",
"stack-overflow-fill",
"stack-overflow-line",
"stackshare-fill",
"stackshare-line",
"steam-fill",
"steam-line",
"supabase-fill",
"supabase-line",
"svelte-fill",
"svelte-line",
"switch-fill",
"switch-line",
"tailwind-css-fill",
"tailwind-css-line",
"taobao-fill",
"taobao-line",
"telegram-2-fill",
"telegram-2-line",
"telegram-fill",
"telegram-line",
"threads-fill",
"threads-line",
"tiktok-fill",
"tiktok-line",
"trello-fill",
"trello-line",
"tumblr-fill",
"tumblr-line",
"twitch-fill",
"twitch-line",
"twitter-fill",
"twitter-line",
"twitter-x-fill",
"twitter-x-line",
"ubuntu-fill",
"ubuntu-line",
"unsplash-fill",
"unsplash-line",
"vercel-fill",
"vercel-line",
"vimeo-fill",
"vimeo-line",
"visa-fill",
"visa-line",
"vk-fill",
"vk-line",
"vuejs-fill",
"vuejs-line",
"webhook-fill",
"webhook-line",
"wechat-2-fill",
"wechat-2-line",
"wechat-channels-fill",
"wechat-channels-line",
"wechat-fill",
"wechat-line",
"wechat-pay-fill",
"wechat-pay-line",
"weibo-fill",
"weibo-line",
"whatsapp-fill",
"whatsapp-line",
"windows-fill",
"windows-line",
"wordpress-fill",
"wordpress-line",
"xbox-fill",
"xbox-line",
"xing-fill",
"xing-line",
"youtube-fill",
"youtube-line",
"yuque-fill",
"yuque-line",
"zcool-fill",
"zcool-line",
"zhihu-fill",
"zhihu-line"
],
"Map": [
"anchor-fill",
"anchor-line",
"barricade-fill",
"barricade-line",
"bike-fill",
"bike-line",
"bus-2-fill",
"bus-2-line",
"bus-fill",
"bus-line",
"bus-wifi-fill",
"bus-wifi-line",
"car-fill",
"car-line",
"car-washing-fill",
"car-washing-line",
"caravan-fill",
"caravan-line",
"charging-pile-2-fill",
"charging-pile-2-line",
"charging-pile-fill",
"charging-pile-line",
"china-railway-fill",
"china-railway-line",
"compass-2-fill",
"compass-2-line",
"compass-3-fill",
"compass-3-line",
"compass-4-fill",
"compass-4-line",
"compass-discover-fill",
"compass-discover-line",
"compass-fill",
"compass-line",
"direction-fill",
"direction-line",
"e-bike-2-fill",
"e-bike-2-line",
"e-bike-fill",
"e-bike-line",
"earth-fill",
"earth-line",
"flight-land-fill",
"flight-land-line",
"flight-takeoff-fill",
"flight-takeoff-line",
"footprint-fill",
"footprint-line",
"gas-station-fill",
"gas-station-line",
"globe-fill",
"globe-line",
"guide-fill",
"guide-line",
"hotel-bed-fill",
"hotel-bed-line",
"lifebuoy-fill",
"lifebuoy-line",
"luggage-cart-fill",
"luggage-cart-line",
"luggage-deposit-fill",
"luggage-deposit-line",
"map-2-fill",
"map-2-line",
"map-fill",
"map-line",
"map-pin-2-fill",
"map-pin-2-line",
"map-pin-3-fill",
"map-pin-3-line",
"map-pin-4-fill",
"map-pin-4-line",
"map-pin-5-fill",
"map-pin-5-line",
"map-pin-add-fill",
"map-pin-add-line",
"map-pin-fill",
"map-pin-line",
"map-pin-range-fill",
"map-pin-range-line",
"map-pin-time-fill",
"map-pin-time-line",
"map-pin-user-fill",
"map-pin-user-line",
"motorbike-fill",
"motorbike-line",
"navigation-fill",
"navigation-line",
"oil-fill",
"oil-line",
"parking-box-fill",
"parking-box-line",
"parking-fill",
"parking-line",
"passport-fill",
"passport-line",
"pin-distance-fill",
"pin-distance-line",
"plane-fill",
"plane-line",
"planet-fill",
"planet-line",
"police-car-fill",
"police-car-line",
"pushpin-2-fill",
"pushpin-2-line",
"pushpin-fill",
"pushpin-line",
"riding-fill",
"riding-line",
"road-map-fill",
"road-map-line",
"roadster-fill",
"roadster-line",
"rocket-2-fill",
"rocket-2-line",
"rocket-fill",
"rocket-line",
"route-fill",
"route-line",
"run-fill",
"run-line",
"sailboat-fill",
"sailboat-line",
"ship-2-fill",
"ship-2-line",
"ship-fill",
"ship-line",
"signal-tower-fill",
"signal-tower-line",
"signpost-fill",
"signpost-line",
"space-ship-fill",
"space-ship-line",
"steering-2-fill",
"steering-2-line",
"steering-fill",
"steering-line",
"subway-fill",
"subway-line",
"subway-wifi-fill",
"subway-wifi-line",
"suitcase-2-fill",
"suitcase-2-line",
"suitcase-3-fill",
"suitcase-3-line",
"suitcase-fill",
"suitcase-line",
"takeaway-fill",
"takeaway-line",
"taxi-fill",
"taxi-line",
"taxi-wifi-fill",
"taxi-wifi-line",
"time-zone-fill",
"time-zone-line",
"traffic-light-fill",
"traffic-light-line",
"train-fill",
"train-line",
"train-wifi-fill",
"train-wifi-line",
"treasure-map-fill",
"treasure-map-line",
"truck-fill",
"truck-line",
"unpin-fill",
"unpin-line",
"walk-fill",
"walk-line"
],
"Media": [
"4k-fill",
"4k-line",
"album-fill",
"album-line",
"aspect-ratio-fill",
"aspect-ratio-line",
"broadcast-fill",
"broadcast-line",
"camera-2-fill",
"camera-2-line",
"camera-3-fill",
"camera-3-line",
"camera-4-fill",
"camera-4-line",
"camera-ai-2-fill",
"camera-ai-2-line",
"camera-ai-fill",
"camera-ai-line",
"camera-fill",
"camera-lens-ai-fill",
"camera-lens-ai-line",
"camera-lens-fill",
"camera-lens-line",
"camera-line",
"camera-off-fill",
"camera-off-line",
"camera-switch-fill",
"camera-switch-line",
"clapperboard-ai-fill",
"clapperboard-ai-line",
"clapperboard-fill",
"clapperboard-line",
"closed-captioning-ai-fill",
"closed-captioning-ai-line",
"closed-captioning-fill",
"closed-captioning-line",
"disc-fill",
"disc-line",
"dv-fill",
"dv-line",
"dvd-ai-fill",
"dvd-ai-line",
"dvd-fill",
"dvd-line",
"eject-fill",
"eject-line",
"equalizer-2-fill",
"equalizer-2-line",
"equalizer-3-fill",
"equalizer-3-line",
"equalizer-fill",
"equalizer-line",
"film-ai-fill",
"film-ai-line",
"film-fill",
"film-line",
"forward-10-fill",
"forward-10-line",
"forward-15-fill",
"forward-15-line",
"forward-30-fill",
"forward-30-line",
"forward-5-fill",
"forward-5-line",
"forward-end-fill",
"forward-end-line",
"forward-end-mini-fill",
"forward-end-mini-line",
"fullscreen-exit-fill",
"fullscreen-exit-line",
"fullscreen-fill",
"fullscreen-line",
"gallery-fill",
"gallery-line",
"gallery-upload-fill",
"gallery-upload-line",
"hd-fill",
"hd-line",
"headphone-fill",
"headphone-line",
"hq-fill",
"hq-line",
"image-2-fill",
"image-2-line",
"image-add-fill",
"image-add-line",
"image-ai-fill",
"image-ai-line",
"image-circle-ai-fill",
"image-circle-ai-line",
"image-circle-fill",
"image-circle-line",
"image-edit-fill",
"image-edit-line",
"image-fill",
"image-line",
"landscape-ai-fill",
"landscape-ai-line",
"landscape-fill",
"landscape-line",
"live-fill",
"live-line",
"memories-fill",
"memories-line",
"mic-2-ai-fill",
"mic-2-ai-line",
"mic-2-fill",
"mic-2-line",
"mic-ai-fill",
"mic-ai-line",
"mic-fill",
"mic-line",
"mic-off-fill",
"mic-off-line",
"movie-2-ai-fill",
"movie-2-ai-line",
"movie-2-fill",
"movie-2-line",
"movie-ai-fill",
"movie-ai-line",
"movie-fill",
"movie-line",
"multi-image-fill",
"multi-image-line",
"music-2-fill",
"music-2-line",
"music-ai-fill",
"music-ai-line",
"music-fill",
"music-line",
"mv-ai-fill",
"mv-ai-line",
"mv-fill",
"mv-line",
"notification-2-fill",
"notification-2-line",
"notification-3-fill",
"notification-3-line",
"notification-4-fill",
"notification-4-line",
"notification-fill",
"notification-line",
"notification-off-fill",
"notification-off-line",
"notification-snooze-fill",
"notification-snooze-line",
"order-play-fill",
"order-play-line",
"pause-circle-fill",
"pause-circle-line",
"pause-fill",
"pause-large-fill",
"pause-large-line",
"pause-line",
"pause-mini-fill",
"pause-mini-line",
"phone-camera-fill",
"phone-camera-line",
"picture-in-picture-2-fill",
"picture-in-picture-2-line",
"picture-in-picture-exit-fill",
"picture-in-picture-exit-line",
"picture-in-picture-fill",
"picture-in-picture-line",
"play-circle-fill",
"play-circle-line",
"play-fill",
"play-large-fill",
"play-large-line",
"play-line",
"play-list-2-fill",
"play-list-2-line",
"play-list-add-fill",
"play-list-add-line",
"play-list-fill",
"play-list-line",
"play-mini-fill",
"play-mini-line",
"play-reverse-fill",
"play-reverse-large-fill",
"play-reverse-large-line",
"play-reverse-line",
"play-reverse-mini-fill",
"play-reverse-mini-line",
"polaroid-2-fill",
"polaroid-2-line",
"polaroid-fill",
"polaroid-line",
"radio-2-fill",
"radio-2-line",
"radio-fill",
"radio-line",
"record-circle-fill",
"record-circle-line",
"repeat-2-fill",
"repeat-2-line",
"repeat-fill",
"repeat-line",
"repeat-one-fill",
"repeat-one-line",
"replay-10-fill",
"replay-10-line",
"replay-15-fill",
"replay-15-line",
"replay-30-fill",
"replay-30-line",
"replay-5-fill",
"replay-5-line",
"rewind-fill",
"rewind-line",
"rewind-mini-fill",
"rewind-mini-line",
"rewind-start-fill",
"rewind-start-line",
"rewind-start-mini-fill",
"rewind-start-mini-line",
"rhythm-fill",
"rhythm-line",
"shuffle-fill",
"shuffle-line",
"skip-back-fill",
"skip-back-line",
"skip-back-mini-fill",
"skip-back-mini-line",
"skip-forward-fill",
"skip-forward-line",
"skip-forward-mini-fill",
"skip-forward-mini-line",
"slow-down-fill",
"slow-down-line",
"sound-module-fill",
"sound-module-line",
"speaker-2-fill",
"speaker-2-line",
"speaker-3-fill",
"speaker-3-line",
"speaker-fill",
"speaker-line",
"speed-fill",
"speed-line",
"speed-mini-fill",
"speed-mini-line",
"speed-up-fill",
"speed-up-line",
"stop-circle-fill",
"stop-circle-line",
"stop-fill",
"stop-large-fill",
"stop-large-line",
"stop-line",
"stop-mini-fill",
"stop-mini-line",
"surround-sound-fill",
"surround-sound-line",
"tape-fill",
"tape-line",
"video-add-fill",
"video-add-line",
"video-ai-fill",
"video-ai-line",
"video-download-fill",
"video-download-line",
"video-fill",
"video-line",
"video-off-fill",
"video-off-line",
"video-on-ai-fill",
"video-on-ai-line",
"video-on-fill",
"video-on-line",
"video-upload-fill",
"video-upload-line",
"vidicon-2-fill",
"vidicon-2-line",
"vidicon-fill",
"vidicon-line",
"voice-ai-fill",
"voice-ai-line",
"voiceprint-fill",
"voiceprint-line",
"volume-down-fill",
"volume-down-line",
"volume-mute-fill",
"volume-mute-line",
"volume-off-vibrate-fill",
"volume-off-vibrate-line",
"volume-up-fill",
"volume-up-line",
"volume-vibrate-fill",
"volume-vibrate-line",
"webcam-fill",
"webcam-line"
],
"Others": [
"accessibility-fill",
"accessibility-line",
"ai-generate-3d-fill",
"ai-generate-3d-line",
"armchair-fill",
"armchair-line",
"basketball-fill",
"basketball-line",
"bell-fill",
"bell-line",
"billiards-fill",
"billiards-line",
"book-shelf-fill",
"book-shelf-line",
"box-1-fill",
"box-1-line",
"box-2-fill",
"box-2-line",
"box-3-fill",
"box-3-line",
"boxing-fill",
"boxing-line",
"cactus-fill",
"cactus-line",
"candle-fill",
"candle-line",
"character-recognition-fill",
"character-recognition-line",
"chess-fill",
"chess-line",
"cross-fill",
"cross-line",
"dice-1-fill",
"dice-1-line",
"dice-2-fill",
"dice-2-line",
"dice-3-fill",
"dice-3-line",
"dice-4-fill",
"dice-4-line",
"dice-5-fill",
"dice-5-line",
"dice-6-fill",
"dice-6-line",
"dice-fill",
"dice-line",
"door-closed-fill",
"door-closed-line",
"door-fill",
"door-line",
"door-lock-box-fill",
"door-lock-box-line",
"door-lock-fill",
"door-lock-line",
"door-open-fill",
"door-open-line",
"flower-fill",
"flower-line",
"football-fill",
"football-line",
"fridge-fill",
"fridge-line",
"game-2-fill",
"game-2-line",
"game-fill",
"game-line",
"glasses-2-fill",
"glasses-2-line",
"glasses-fill",
"glasses-line",
"goggles-fill",
"goggles-line",
"golf-ball-fill",
"golf-ball-line",
"graduation-cap-fill",
"graduation-cap-line",
"handbag-fill",
"handbag-line",
"infinity-fill",
"infinity-line",
"key-2-fill",
"key-2-line",
"key-fill",
"key-line",
"leaf-fill",
"leaf-line",
"lightbulb-ai-fill",
"lightbulb-ai-line",
"lightbulb-fill",
"lightbulb-flash-fill",
"lightbulb-flash-line",
"lightbulb-line",
"outlet-2-fill",
"outlet-2-line",
"outlet-fill",
"outlet-line",
"ping-pong-fill",
"ping-pong-line",
"plant-fill",
"plant-line",
"plug-2-fill",
"plug-2-line",
"plug-fill",
"plug-line",
"poker-clubs-fill",
"poker-clubs-line",
"poker-diamonds-fill",
"poker-diamonds-line",
"poker-hearts-fill",
"poker-hearts-line",
"poker-spades-fill",
"poker-spades-line",
"police-badge-fill",
"police-badge-line",
"recycle-fill",
"recycle-line",
"reserved-fill",
"reserved-line",
"scales-2-fill",
"scales-2-line",
"scales-3-fill",
"scales-3-line",
"scales-fill",
"scales-line",
"seedling-fill",
"seedling-line",
"service-bell-fill",
"service-bell-line",
"shirt-fill",
"shirt-line",
"sofa-fill",
"sofa-line",
"stairs-fill",
"stairs-line",
"sword-fill",
"sword-line",
"t-shirt-2-fill",
"t-shirt-2-line",
"t-shirt-air-fill",
"t-shirt-air-line",
"t-shirt-fill",
"t-shirt-line",
"target-fill",
"target-line",
"tooth-fill",
"tooth-line",
"tree-fill",
"tree-line",
"umbrella-fill",
"umbrella-line",
"voice-recognition-fill",
"voice-recognition-line",
"weight-fill",
"weight-line",
"wheelchair-fill",
"wheelchair-line"
],
"System": [
"add-box-fill",
"add-box-line",
"add-circle-fill",
"add-circle-line",
"add-fill",
"add-large-fill",
"add-large-line",
"add-line",
"alarm-add-fill",
"alarm-add-line",
"alarm-fill",
"alarm-line",
"alarm-snooze-fill",
"alarm-snooze-line",
"alarm-warning-fill",
"alarm-warning-line",
"alert-fill",
"alert-line",
"apps-2-add-fill",
"apps-2-add-line",
"apps-2-ai-fill",
"apps-2-ai-line",
"apps-2-fill",
"apps-2-line",
"apps-ai-fill",
"apps-ai-line",
"apps-fill",
"apps-line",
"check-double-fill",
"check-double-line",
"check-fill",
"check-line",
"checkbox-blank-circle-fill",
"checkbox-blank-circle-line",
"checkbox-blank-fill",
"checkbox-blank-line",
"checkbox-circle-fill",
"checkbox-circle-line",
"checkbox-fill",
"checkbox-indeterminate-fill",
"checkbox-indeterminate-line",
"checkbox-line",
"checkbox-multiple-blank-fill",
"checkbox-multiple-blank-line",
"checkbox-multiple-fill",
"checkbox-multiple-line",
"close-circle-fill",
"close-circle-line",
"close-fill",
"close-large-fill",
"close-large-line",
"close-line",
"dashboard-fill",
"dashboard-horizontal-fill",
"dashboard-horizontal-line",
"dashboard-line",
"delete-back-2-fill",
"delete-back-2-line",
"delete-back-fill",
"delete-back-line",
"delete-bin-2-fill",
"delete-bin-2-line",
"delete-bin-3-fill",
"delete-bin-3-line",
"delete-bin-4-fill",
"delete-bin-4-line",
"delete-bin-5-fill",
"delete-bin-5-line",
"delete-bin-6-fill",
"delete-bin-6-line",
"delete-bin-7-fill",
"delete-bin-7-line",
"delete-bin-fill",
"delete-bin-line",
"divide-fill",
"divide-line",
"download-2-fill",
"download-2-line",
"download-cloud-2-fill",
"download-cloud-2-line",
"download-cloud-fill",
"download-cloud-line",
"download-fill",
"download-line",
"equal-fill",
"equal-line",
"error-warning-fill",
"error-warning-line",
"export-fill",
"export-line",
"external-link-fill",
"external-link-line",
"eye-2-fill",
"eye-2-line",
"eye-close-fill",
"eye-close-line",
"eye-fill",
"eye-line",
"eye-off-fill",
"eye-off-line",
"filter-2-fill",
"filter-2-line",
"filter-3-fill",
"filter-3-line",
"filter-fill",
"filter-line",
"filter-off-fill",
"filter-off-line",
"find-replace-fill",
"find-replace-line",
"forbid-2-fill",
"forbid-2-line",
"forbid-fill",
"forbid-line",
"function-add-fill",
"function-add-line",
"function-ai-fill",
"function-ai-line",
"function-fill",
"function-line",
"history-fill",
"history-line",
"hourglass-2-fill",
"hourglass-2-line",
"hourglass-fill",
"hourglass-line",
"import-fill",
"import-line",
"indeterminate-circle-fill",
"indeterminate-circle-line",
"information-2-fill",
"information-2-line",
"information-fill",
"information-line",
"information-off-fill",
"information-off-line",
"list-settings-fill",
"list-settings-line",
"loader-2-fill",
"loader-2-line",
"loader-3-fill",
"loader-3-line",
"loader-4-fill",
"loader-4-line",
"loader-5-fill",
"loader-5-line",
"loader-fill",
"loader-line",
"lock-2-fill",
"lock-2-line",
"lock-fill",
"lock-line",
"lock-password-fill",
"lock-password-line",
"lock-star-fill",
"lock-star-line",
"lock-unlock-fill",
"lock-unlock-line",
"login-box-fill",
"login-box-line",
"login-circle-fill",
"login-circle-line",
"logout-box-fill",
"logout-box-line",
"logout-box-r-fill",
"logout-box-r-line",
"logout-circle-fill",
"logout-circle-line",
"logout-circle-r-fill",
"logout-circle-r-line",
"loop-left-ai-fill",
"loop-left-ai-line",
"loop-left-fill",
"loop-left-line",
"loop-right-ai-fill",
"loop-right-ai-line",
"loop-right-fill",
"loop-right-line",
"menu-2-fill",
"menu-2-line",
"menu-3-fill",
"menu-3-line",
"menu-4-fill",
"menu-4-line",
"menu-5-fill",
"menu-5-line",
"menu-add-fill",
"menu-add-line",
"menu-fill",
"menu-fold-2-fill",
"menu-fold-2-line",
"menu-fold-3-fill",
"menu-fold-3-line",
"menu-fold-4-fill",
"menu-fold-4-line",
"menu-fold-fill",
"menu-fold-line",
"menu-line",
"menu-search-fill",
"menu-search-line",
"menu-unfold-2-fill",
"menu-unfold-2-line",
"menu-unfold-3-fill",
"menu-unfold-3-line",
"menu-unfold-4-fill",
"menu-unfold-4-line",
"menu-unfold-fill",
"menu-unfold-line",
"more-2-fill",
"more-2-line",
"more-fill",
"more-line",
"notification-badge-fill",
"notification-badge-line",
"progress-1-fill",
"progress-1-line",
"progress-2-fill",
"progress-2-line",
"progress-3-fill",
"progress-3-line",
"progress-4-fill",
"progress-4-line",
"progress-5-fill",
"progress-5-line",
"progress-6-fill",
"progress-6-line",
"progress-7-fill",
"progress-7-line",
"progress-8-fill",
"progress-8-line",
"prohibited-2-fill",
"prohibited-2-line",
"prohibited-fill",
"prohibited-line",
"question-fill",
"question-line",
"radio-button-fill",
"radio-button-line",
"refresh-fill",
"refresh-line",
"reset-left-fill",
"reset-left-line",
"reset-right-fill",
"reset-right-line",
"search-2-fill",
"search-2-line",
"search-ai-2-fill",
"search-ai-2-line",
"search-ai-3-fill",
"search-ai-3-line",
"search-ai-4-fill",
"search-ai-4-line",
"search-ai-fill",
"search-ai-line",
"search-eye-fill",
"search-eye-line",
"search-fill",
"search-line",
"settings-2-fill",
"settings-2-line",
"settings-3-fill",
"settings-3-line",
"settings-4-fill",
"settings-4-line",
"settings-5-fill",
"settings-5-line",
"settings-6-fill",
"settings-6-line",
"settings-fill",
"settings-line",
"share-2-fill",
"share-2-line",
"share-box-fill",
"share-box-line",
"share-circle-fill",
"share-circle-line",
"share-fill",
"share-forward-2-fill",
"share-forward-2-line",
"share-forward-box-fill",
"share-forward-box-line",
"share-forward-fill",
"share-forward-line",
"share-line",
"shield-check-fill",
"shield-check-line",
"shield-cross-fill",
"shield-cross-line",
"shield-fill",
"shield-flash-fill",
"shield-flash-line",
"shield-keyhole-fill",
"shield-keyhole-line",
"shield-line",
"shield-star-fill",
"shield-star-line",
"shield-user-fill",
"shield-user-line",
"side-bar-fill",
"side-bar-line",
"sidebar-fold-fill",
"sidebar-fold-line",
"sidebar-unfold-fill",
"sidebar-unfold-line",
"spam-2-fill",
"spam-2-line",
"spam-3-fill",
"spam-3-line",
"spam-fill",
"spam-line",
"star-fill",
"star-half-fill",
"star-half-line",
"star-half-s-fill",
"star-half-s-line",
"star-line",
"star-off-fill",
"star-off-line",
"star-s-fill",
"star-s-line",
"subtract-fill",
"subtract-line",
"thumb-down-fill",
"thumb-down-line",
"thumb-up-fill",
"thumb-up-line",
"time-fill",
"time-line",
"timer-2-fill",
"timer-2-line",
"timer-fill",
"timer-flash-fill",
"timer-flash-line",
"timer-line",
"toggle-fill",
"toggle-line",
"upload-2-fill",
"upload-2-line",
"upload-cloud-2-fill",
"upload-cloud-2-line",
"upload-cloud-fill",
"upload-cloud-line",
"upload-fill",
"upload-line",
"zoom-in-fill",
"zoom-in-line",
"zoom-out-fill",
"zoom-out-line"
],
"User & Faces": [
"account-box-2-fill",
"account-box-2-line",
"account-box-fill",
"account-box-line",
"account-circle-2-fill",
"account-circle-2-line",
"account-circle-fill",
"account-circle-line",
"account-pin-box-fill",
"account-pin-box-line",
"account-pin-circle-fill",
"account-pin-circle-line",
"admin-fill",
"admin-line",
"ai-agent-fill",
"ai-agent-line",
"aliens-fill",
"aliens-line",
"bear-smile-fill",
"bear-smile-line",
"body-scan-fill",
"body-scan-line",
"contacts-fill",
"contacts-line",
"criminal-fill",
"criminal-line",
"emotion-2-fill",
"emotion-2-line",
"emotion-fill",
"emotion-happy-fill",
"emotion-happy-line",
"emotion-laugh-fill",
"emotion-laugh-line",
"emotion-line",
"emotion-normal-fill",
"emotion-normal-line",
"emotion-sad-fill",
"emotion-sad-line",
"emotion-unhappy-fill",
"emotion-unhappy-line",
"genderless-fill",
"genderless-line",
"ghost-2-fill",
"ghost-2-line",
"ghost-fill",
"ghost-line",
"ghost-smile-fill",
"ghost-smile-line",
"group-2-fill",
"group-2-line",
"group-3-fill",
"group-3-line",
"group-fill",
"group-line",
"men-fill",
"men-line",
"mickey-fill",
"mickey-line",
"open-arm-fill",
"open-arm-line",
"parent-fill",
"parent-line",
"robot-2-fill",
"robot-2-line",
"robot-3-fill",
"robot-3-line",
"robot-fill",
"robot-line",
"skull-2-fill",
"skull-2-line",
"skull-fill",
"skull-line",
"spy-fill",
"spy-line",
"star-smile-fill",
"star-smile-line",
"team-fill",
"team-line",
"travesti-fill",
"travesti-line",
"user-2-fill",
"user-2-line",
"user-3-fill",
"user-3-line",
"user-4-fill",
"user-4-line",
"user-5-fill",
"user-5-line",
"user-6-fill",
"user-6-line",
"user-add-fill",
"user-add-line",
"user-community-fill",
"user-community-line",
"user-fill",
"user-follow-fill",
"user-follow-line",
"user-forbid-fill",
"user-forbid-line",
"user-heart-fill",
"user-heart-line",
"user-line",
"user-location-fill",
"user-location-line",
"user-minus-fill",
"user-minus-line",
"user-received-2-fill",
"user-received-2-line",
"user-received-fill",
"user-received-line",
"user-search-fill",
"user-search-line",
"user-settings-fill",
"user-settings-line",
"user-shared-2-fill",
"user-shared-2-line",
"user-shared-fill",
"user-shared-line",
"user-smile-fill",
"user-smile-line",
"user-star-fill",
"user-star-line",
"user-unfollow-fill",
"user-unfollow-line",
"user-voice-fill",
"user-voice-line",
"women-fill",
"women-line"
],
"Weather": [
"blaze-fill",
"blaze-line",
"celsius-fill",
"celsius-line",
"cloud-windy-fill",
"cloud-windy-line",
"cloudy-2-fill",
"cloudy-2-line",
"cloudy-fill",
"cloudy-line",
"drizzle-fill",
"drizzle-line",
"earthquake-fill",
"earthquake-line",
"fahrenheit-fill",
"fahrenheit-line",
"fire-fill",
"fire-line",
"flashlight-fill",
"flashlight-line",
"flood-fill",
"flood-line",
"foggy-fill",
"foggy-line",
"hail-fill",
"hail-line",
"haze-2-fill",
"haze-2-line",
"haze-fill",
"haze-line",
"heavy-showers-fill",
"heavy-showers-line",
"meteor-fill",
"meteor-line",
"mist-fill",
"mist-line",
"moon-clear-fill",
"moon-clear-line",
"moon-cloudy-fill",
"moon-cloudy-line",
"moon-fill",
"moon-foggy-fill",
"moon-foggy-line",
"moon-line",
"rainbow-fill",
"rainbow-line",
"rainy-fill",
"rainy-line",
"shining-2-fill",
"shining-2-line",
"shining-fill",
"shining-line",
"showers-fill",
"showers-line",
"snowflake-fill",
"snowflake-line",
"snowy-fill",
"snowy-line",
"sparkling-2-fill",
"sparkling-2-line",
"sparkling-fill",
"sparkling-line",
"sun-cloudy-fill",
"sun-cloudy-line",
"sun-fill",
"sun-foggy-fill",
"sun-foggy-line",
"sun-line",
"temp-cold-fill",
"temp-cold-line",
"temp-hot-fill",
"temp-hot-line",
"thunderstorms-fill",
"thunderstorms-line",
"tornado-fill",
"tornado-line",
"typhoon-fill",
"typhoon-line",
"water-percent-fill",
"water-percent-line",
"windy-fill",
"windy-line"
]
}

View File

@@ -0,0 +1,389 @@
<template>
<el-dialog
v-model="visible"
title="选择图片"
width="1024px"
:close-on-click-modal="false"
@closed="onClosed"
>
<div class="resource-dialog">
<!-- 搜索和筛选 -->
<div class="dialog-header">
<div class="flex items-center justify-between gap-2 flex-1">
<el-tree-select
class="search-tree"
v-model="searchForm.category_id"
:data="categoryList"
:render-after-expand="false"
check-strictly
clearable
/>
<el-input v-model="searchForm.keywords" placeholder="搜索图片名称" clearable>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
<el-button type="primary" :icon="Refresh" @click="loadResources"> 搜索 </el-button>
<el-upload
class="upload-btn"
:show-file-list="false"
:http-request="handleUpload"
:before-upload="beforeUpload"
accept="image/*"
>
<el-button type="success" :icon="UploadFilled">上传图片</el-button>
</el-upload>
</div>
<!-- 图片列表 -->
<div class="image-list">
<div v-loading="loading" class="image-grid">
<div
v-for="item in imageList"
:key="item.id"
class="image-item"
:class="{ selected: selectedIds.has(item.id) }"
@click="selectImage(item)"
>
<el-image :src="item.url" fit="cover" class="grid-image" />
<div class="image-info">
<div class="image-name" :title="item.origin_name">{{ item.origin_name }}</div>
<div class="image-size">{{ item.size_info }}</div>
</div>
<div v-if="selectedIds.has(item.id)" class="selected-badge">
<el-icon><Check /></el-icon>
</div>
</div>
<!-- 空状态 -->
<el-empty
v-if="!loading && imageList.length === 0"
description="暂无图片资源"
class="empty-placeholder"
/>
</div>
</div>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[14, 28, 42, 56]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="loadResources"
@size-change="loadResources"
/>
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="confirmSelection" :disabled="selectedItems.length === 0">
确定
</el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue'
import { Search, Refresh, Check, UploadFilled } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { UploadRequestOptions, UploadProps } from 'element-plus'
import { getResourceCategory, getResourceList, uploadImage } from '@/api/auth'
defineOptions({ name: 'SaImageDialog' })
// Props 定义
interface Props {
multiple?: boolean // 是否多选
limit?: number // 多选限制
initialUrls?: string | string[] // 初始选中的 URL用于回显
}
const props = withDefaults(defineProps<Props>(), {
multiple: false,
limit: 3,
initialUrls: ''
})
// visible 使用 defineModel
const visible = defineModel<boolean>('visible', { default: false })
// Emits 定义
const emit = defineEmits<{
confirm: [value: string | string[]]
}>()
// 图片资源接口
interface ImageResource {
id: string | number
origin_name: string
url: string
size_info: string
type?: string
createTime?: string
}
// 状态
const loading = ref(false)
const searchForm = ref({
keywords: '',
category_id: null
})
const selectedIds = ref<Set<string | number>>(new Set())
const unselectedIds = ref<Set<string | number>>(new Set())
const selectedItems = ref<ImageResource[]>([])
const imageList = ref<ImageResource[]>([])
const categoryList = ref<any>([])
const currentPage = ref(1)
const pageSize = ref(14)
const total = ref(0)
// 监听弹窗打开
watch(
() => visible.value,
(newVal) => {
if (newVal) {
// 初始化选中状态
selectedIds.value.clear()
unselectedIds.value.clear()
selectedItems.value = []
if (imageList.value.length === 0) {
loadResources()
} else {
syncSelectionFromInitial()
}
}
}
)
// 根据 initialUrls 同步选中状态
const syncSelectionFromInitial = () => {
const urls = Array.isArray(props.initialUrls)
? props.initialUrls
: props.initialUrls
? [props.initialUrls]
: []
if (!urls.length) return
imageList.value.forEach((item) => {
if (
urls.includes(item.url) &&
!unselectedIds.value.has(item.id) &&
!selectedIds.value.has(item.id)
) {
selectedIds.value.add(item.id)
selectedItems.value.push(item)
}
})
}
// 加载资源列表
const loadResources = async () => {
loading.value = true
try {
const category = await getResourceCategory({ tree: 'true' })
categoryList.value = category || []
const response: any = await getResourceList({
page: currentPage.value,
limit: pageSize.value,
object_name: searchForm.value.keywords,
category_id: searchForm.value.category_id
})
const data = response
imageList.value = data?.data || []
total.value = data?.total || imageList.value.length
syncSelectionFromInitial()
} catch (error: any) {
console.error('加载图片资源失败:', error)
ElMessage.error('加载图片资源失败: ' + (error.message || ''))
} finally {
loading.value = false
}
}
// 选择图片
const selectImage = (item: ImageResource) => {
if (props.multiple) {
if (selectedIds.value.has(item.id)) {
selectedIds.value.delete(item.id)
unselectedIds.value.add(item.id)
const index = selectedItems.value.findIndex((i) => i.id === item.id)
if (index > -1) selectedItems.value.splice(index, 1)
} else {
if (selectedIds.value.size >= props.limit) {
ElMessage.warning(`最多只能选择 ${props.limit} 张图片`)
return
}
selectedIds.value.add(item.id)
unselectedIds.value.delete(item.id)
selectedItems.value.push(item)
}
} else {
selectedIds.value.clear()
selectedItems.value = []
selectedIds.value.add(item.id)
selectedItems.value.push(item)
}
}
// 确认选择
const confirmSelection = () => {
if (selectedItems.value.length === 0) {
ElMessage.warning('请选择图片')
return
}
const urls = selectedItems.value.map((item) => item.url)
const result = props.multiple ? urls : urls[0]
emit('confirm', result)
visible.value = false
ElMessage.success('图片选择成功')
}
// 上传前验证
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
const isImage = file.type.startsWith('image/')
if (!isImage) {
ElMessage.error('只能上传图片文件!')
return false
}
const isLt5M = file.size / 1024 / 1024 < 5
if (!isLt5M) {
ElMessage.error('图片大小不能超过 5MB!')
return false
}
return true
}
// 处理上传
const handleUpload = async (options: UploadRequestOptions) => {
const { file } = options
loading.value = true
try {
const formData = new FormData()
formData.append('file', file)
formData.append('category_id', searchForm.value.category_id || '1')
await uploadImage(formData)
ElMessage.success('上传成功')
currentPage.value = 1
await loadResources()
} catch (error: any) {
console.error('上传失败:', error)
ElMessage.error(error.message || '上传失败')
} finally {
loading.value = false
}
}
// 弹窗关闭时重置
const onClosed = () => {
// 可选:重置搜索等状态
}
</script>
<style scoped lang="scss">
.resource-dialog {
.dialog-header {
display: flex;
gap: 10px;
margin-bottom: 20px;
.search-tree {
width: 250px;
}
}
.image-list {
min-height: 450px;
max-height: 660px;
overflow-y: auto;
}
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 10px;
padding: 10px;
.image-item {
position: relative;
border: 2px solid transparent;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s;
background: #f5f7fa;
&:hover {
border-color: var(--el-color-primary);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
&.selected {
border-color: var(--el-color-primary);
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
}
.grid-image {
width: 100%;
height: 100px;
}
.image-info {
padding: 4px 8px;
background: #fff;
.image-name {
font-size: 12px;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.image-size {
font-size: 11px;
color: #909399;
}
}
.selected-badge {
position: absolute;
top: 8px;
right: 8px;
width: 24px;
height: 24px;
background: var(--el-color-primary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 14px;
}
}
.empty-placeholder {
grid-column: 1 / -1;
}
}
.pagination {
margin-top: 0px;
display: flex;
justify-content: center;
}
}
</style>

View File

@@ -0,0 +1,321 @@
<template>
<div class="sa-image-picker" :style="containerStyle">
<!-- 多选模式下的图片列表 -->
<div
v-if="multiple && Array.isArray(selectedImage) && selectedImage.length > 0"
class="image-list-display"
>
<div
v-for="(url, index) in selectedImage"
:key="url"
class="picker-trigger mini"
:class="{ round: round }"
>
<el-image :src="url" fit="cover" class="preview-image" />
<div class="image-mask" :class="{ round: round }">
<el-icon class="mask-icon" @click.stop="handlePreview(index)"><ZoomIn /></el-icon>
<el-icon class="mask-icon" @click.stop="removeImage(index)"><Delete /></el-icon>
</div>
</div>
<!-- 添加按钮 -->
<div
v-if="selectedImage.length < limit"
class="picker-trigger mini add-btn"
:class="{ round: round }"
@click="openDialog"
>
<el-icon class="picker-icon"><Plus /></el-icon>
</div>
</div>
<!-- 单选模式或空状态 -->
<div v-else class="picker-trigger" :style="triggerStyle" :class="{ round: round }">
<div
v-if="!selectedImage || (Array.isArray(selectedImage) && selectedImage.length === 0)"
class="empty-state"
@click="openDialog"
>
<el-icon class="picker-icon"><Plus v-if="multiple" /><Picture v-else /></el-icon>
<div class="picker-text">点击选择</div>
</div>
<div v-else class="selected-image">
<el-image
:src="Array.isArray(selectedImage) ? selectedImage[0] : selectedImage"
fit="cover"
class="preview-image"
:class="{ round: round }"
/>
<div class="image-mask" :class="{ round: round }">
<el-icon class="mask-icon" @click.stop="handlePreview(0)"><ZoomIn /></el-icon>
<el-icon class="mask-icon" @click.stop="openDialog"><Edit /></el-icon>
<el-icon class="mask-icon" @click.stop="clearImage"><Delete /></el-icon>
</div>
</div>
</div>
<!-- 使用独立的图片选择弹窗组件 -->
<SaImageDialog
v-model:visible="dialogVisible"
:multiple="multiple"
:limit="limit"
:initial-urls="modelValue"
@confirm="onDialogConfirm"
/>
<!-- 图片预览 -->
<el-image-viewer
v-if="previewVisible"
:url-list="previewList"
:initial-index="previewIndex"
@close="closePreview"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, watch } from 'vue'
import { Picture, Delete, Edit, ZoomIn, Plus } from '@element-plus/icons-vue'
import SaImageDialog from '@/components/sai/sa-image-dialog/index.vue'
defineOptions({ name: 'SaImagePicker' })
// Props 定义
interface Props {
modelValue?: string | string[] // v-model 绑定值
placeholder?: string // 占位符文本
multiple?: boolean // 是否多选
limit?: number // 多选限制
round?: boolean // 是否圆角
width?: string | number // 宽度
height?: string | number // 高度
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
placeholder: '点击选择图片',
multiple: false,
limit: 3,
round: false,
width: '120px',
height: '120px'
})
// 计算容器样式
const containerStyle = computed(() => {
return {
width: typeof props.width === 'number' ? `${props.width}px` : props.width,
height: typeof props.height === 'number' ? `${props.height}px` : props.height
}
})
// 计算触发器样式
const triggerStyle = computed(() => {
return {
width: '100%',
height: '100%'
}
})
// Emits 定义
const emit = defineEmits<{
'update:modelValue': [value: string | string[]]
change: [value: string | string[]]
}>()
// 状态
const dialogVisible = ref(false)
const previewVisible = ref(false)
const previewIndex = ref(0)
const selectedImage = ref<string | string[]>(props.modelValue)
// 监听 modelValue 变化
watch(
() => props.modelValue,
(newVal) => {
if (Array.isArray(newVal)) {
selectedImage.value = [...newVal]
} else {
selectedImage.value = newVal
}
},
{ deep: true, immediate: true }
)
// 打开对话框
const openDialog = () => {
dialogVisible.value = true
}
// 弹窗确认回调
const onDialogConfirm = (result: string | string[]) => {
selectedImage.value = result
emit('update:modelValue', result)
emit('change', result)
}
// 清除图片
const clearImage = () => {
selectedImage.value = props.multiple ? [] : ''
emit('update:modelValue', selectedImage.value)
emit('change', selectedImage.value)
}
// 移除单个图片(多选模式)
const removeImage = (index: number) => {
if (Array.isArray(selectedImage.value)) {
const newList = [...selectedImage.value]
newList.splice(index, 1)
selectedImage.value = newList
emit('update:modelValue', newList)
emit('change', newList)
}
}
// 预览处理
const handlePreview = (index: number = 0) => {
if (selectedImage.value) {
previewIndex.value = index
previewVisible.value = true
}
}
// 计算预览列表
const previewList = computed(() => {
if (Array.isArray(selectedImage.value)) {
return selectedImage.value
}
return selectedImage.value ? [selectedImage.value] : []
})
const closePreview = () => {
previewVisible.value = false
}
</script>
<style scoped lang="scss">
.sa-image-picker {
width: 100%;
height: 100%;
.image-list-display {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.picker-trigger {
border: 1px dashed var(--el-border-color);
border-radius: 6px;
cursor: pointer;
transition: var(--el-transition-duration-fast);
overflow: hidden;
position: relative;
box-sizing: border-box;
&:hover {
border-color: var(--el-color-primary);
}
&.mini {
width: 60px;
height: 60px;
}
&.round {
border-radius: 50%;
&.mini {
border-radius: 50%;
}
}
&.add-btn {
display: flex;
align-items: center;
justify-content: center;
.picker-icon {
font-size: 28px;
color: #8c939d;
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
&.round {
border-radius: 50%;
}
.picker-icon {
font-size: clamp(20px, 3vw, 28px);
color: #8c939d;
margin-bottom: 4px;
}
.picker-text {
font-size: clamp(10px, 2vw, 12px);
color: #606266;
}
}
.selected-image {
width: 100%;
height: 100%;
position: relative;
&.round {
border-radius: 50%;
}
.preview-image {
width: 100%;
height: 100%;
&.round {
border-radius: 50%;
}
}
}
.image-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
opacity: 0;
transition: opacity 0.3s;
z-index: 10;
&.round {
border-radius: 50%;
}
.mask-icon {
font-size: clamp(16px, 2vw, 20px);
color: #fff;
cursor: pointer;
transition: transform 0.2s;
&:hover {
transform: scale(1.2);
}
}
}
&:hover .image-mask {
opacity: 1;
}
}
}
</style>

View File

@@ -0,0 +1,310 @@
<template>
<div class="sa-image-upload">
<el-upload
ref="uploadRef"
:file-list="fileList"
:limit="limit"
:multiple="multiple"
:accept="accept"
:list-type="listType"
:http-request="handleUpload"
:before-upload="beforeUpload"
:on-remove="handleRemove"
:on-preview="handlePreview"
:on-exceed="handleExceed"
:disabled="disabled"
class="upload-container"
:class="{ 'is-round': round, 'hide-upload': hideUploadTrigger }"
>
<template #default>
<div
class="upload-trigger"
:style="{ width: width + 'px', height: height + 'px' }"
v-if="!hideUploadTrigger"
>
<el-icon class="upload-icon"><Plus /></el-icon>
<div class="upload-text">上传图片</div>
</div>
</template>
<template #tip>
<div class="el-upload__tip" v-if="showTips">
单个文件不超过 {{ maxSize }}MB最多上传 {{ limit }}
</div>
</template>
</el-upload>
<!-- 图片预览器 -->
<el-image-viewer
v-if="previewVisible"
:url-list="previewUrlList"
:initial-index="previewIndex"
@close="handleCloseViewer"
:hide-on-click-modal="true"
:teleported="true"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, watch, computed } from 'vue'
import { Plus } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { UploadProps, UploadUserFile, UploadRequestOptions } from 'element-plus'
import { uploadImage } from '@/api/auth'
defineOptions({ name: 'SaImageUpload' })
// 定义 Props
interface Props {
modelValue?: string | string[] // v-model 绑定值
multiple?: boolean // 是否支持多选
limit?: number // 最大上传数量
maxSize?: number // 最大文件大小(MB)
accept?: string // 接受的文件类型
disabled?: boolean // 是否禁用
listType?: 'text' | 'picture' | 'picture-card' // 文件列表类型
width?: number // 上传区域宽度(px)
height?: number // 上传区域高度(px)
round?: boolean // 是否圆形
showTips?: boolean // 是否显示上传提示
}
const props = withDefaults(defineProps<Props>(), {
modelValue: () => [],
multiple: false,
limit: 1,
maxSize: 5,
accept: 'image/*',
disabled: false,
listType: 'picture-card',
width: 148,
height: 148,
round: false,
showTips: true
})
// 定义 Emits
const emit = defineEmits<{
'update:modelValue': [value: string | string[]]
success: [response: any]
error: [error: any]
change: [value: string | string[]]
}>()
// 状态
const uploadRef = ref()
const fileList = ref<UploadUserFile[]>([])
const previewVisible = ref(false)
const previewIndex = ref(0)
// 计算预览图片列表
const previewUrlList = computed(() => {
return fileList.value.map((file) => file.url).filter((url) => url) as string[]
})
// 计算是否隐藏上传按钮(单图片模式且已有图片时隐藏)
const hideUploadTrigger = computed(() => {
return !props.multiple && fileList.value.length >= props.limit
})
// 监听 modelValue 变化,同步到 fileList
watch(
() => props.modelValue,
(newVal) => {
if (!newVal || (Array.isArray(newVal) && newVal.length === 0)) {
fileList.value = []
uploadRef.value?.clearFiles()
return
}
const urls = Array.isArray(newVal) ? newVal : [newVal]
fileList.value = urls
.filter((url) => url)
.map((url, index) => ({
name: `image-${index + 1}`,
url: url,
uid: Date.now() + index
}))
},
{ immediate: true }
)
// 上传前验证
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
// 验证文件类型
const isImage = file.type.startsWith('image/')
if (!isImage) {
ElMessage.error('只能上传图片文件!')
return false
}
// 验证文件大小
const isLtMaxSize = file.size / 1024 / 1024 < props.maxSize
if (!isLtMaxSize) {
ElMessage.error(`图片大小不能超过 ${props.maxSize}MB!`)
return false
}
return true
}
// 自定义上传
const handleUpload = async (options: UploadRequestOptions) => {
const { file, onSuccess, onError } = options
try {
// 创建 FormData
const formData = new FormData()
formData.append('file', file)
// 调用上传接口
const response: any = await uploadImage(formData)
// 尝试从不同的响应格式中获取图片URL
const imageUrl = response?.data?.url || response?.data || response?.url || ''
if (!imageUrl) {
throw new Error('上传失败,未返回图片地址')
}
// 更新文件列表
const newFile: UploadUserFile = {
name: file.name,
url: imageUrl,
uid: file.uid
}
fileList.value.push(newFile)
updateModelValue()
// 触发成功回调
onSuccess?.(response)
emit('success', response)
ElMessage.success('上传成功!')
} catch (error: any) {
console.error('上传失败:', error)
onError?.(error)
emit('error', error)
ElMessage.error(error.message || '上传失败!')
}
}
// 删除文件
const handleRemove: UploadProps['onRemove'] = (file) => {
const index = fileList.value.findIndex((item) => item.uid === file.uid)
if (index > -1) {
fileList.value.splice(index, 1)
updateModelValue()
}
}
// 超出限制提示
const handleExceed: UploadProps['onExceed'] = () => {
ElMessage.warning(`最多只能上传 ${props.limit} 张图片,请先删除已有图片后再上传`)
}
// 预览图片
const handlePreview: UploadProps['onPreview'] = (file) => {
const index = fileList.value.findIndex((item) => item.uid === file.uid)
previewIndex.value = index > -1 ? index : 0
previewVisible.value = true
}
// 关闭预览器
const handleCloseViewer = () => {
previewVisible.value = false
}
// 更新 v-model 值
const updateModelValue = () => {
const urls = fileList.value.map((file) => file.url).filter((url) => url) as string[]
if (props.multiple) {
emit('update:modelValue', urls)
emit('change', urls)
} else {
emit('update:modelValue', urls[0] || '')
emit('change', urls[0] || '')
}
}
</script>
<style scoped lang="scss">
.sa-image-upload {
.upload-container {
:deep(.el-upload) {
border: 1px dashed var(--el-border-color);
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: var(--el-transition-duration-fast);
width: v-bind('width + "px"');
height: v-bind('height + "px"');
&:hover {
border-color: var(--el-color-primary);
}
}
:deep(.el-icon--close-tip) {
display: none !important;
}
:deep(.el-upload-list--picture-card) {
.el-upload-list__item {
width: v-bind('width + "px"');
height: v-bind('height + "px"');
transition: all 0.3s;
&:hover {
transform: scale(1.05);
}
}
}
&.is-round {
:deep(.el-upload) {
border-radius: 50%;
}
:deep(.el-upload-list--picture-card) {
.el-upload-list__item {
border-radius: 50%;
}
}
}
&.hide-upload {
:deep(.el-upload) {
display: none;
}
}
}
.upload-trigger {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.upload-icon {
font-size: 28px;
color: #8c939d;
margin-bottom: 8px;
}
.upload-text {
font-size: 14px;
color: #606266;
}
}
.el-upload__tip {
font-size: 12px;
color: #909399;
margin-top: 7px;
line-height: 1.5;
}
}
</style>

View File

@@ -0,0 +1,223 @@
<template>
<div class="sa-import-wrap" @click="open">
<div class="trigger">
<slot>
<ElButton :icon="Upload">
{{ title }}
</ElButton>
</slot>
</div>
<el-dialog
v-model="visible"
:title="title"
:width="width"
append-to-body
destroy-on-close
class="sa-import-dialog"
:close-on-click-modal="false"
>
<div class="import-container">
<el-upload
ref="uploadRef"
class="upload-area"
drag
action="#"
:accept="accept"
:limit="1"
:auto-upload="true"
:on-exceed="handleExceed"
:on-remove="handleRemove"
v-model:file-list="fileList"
:http-request="customUpload"
>
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text"> 将文件拖到此处 <em>点击上传</em> </div>
<template #tip>
<div class="el-upload__tip">
{{ tip || `请上传 ${accept.replace(/,/g, '/')} 格式文件` }}
</div>
</template>
</el-upload>
<div class="template-download" v-if="showTemplate">
<el-link type="primary" :underline="false" @click="downloadTemplate">
<el-icon class="el-icon--left"><Download /></el-icon> 下载导入模板
</el-link>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import axios from 'axios'
import { ref, computed } from 'vue'
import { useUserStore } from '@/store/modules/user'
import { UploadFilled, Upload, Download } from '@element-plus/icons-vue'
import { ElMessage, genFileId } from 'element-plus'
import type {
UploadInstance,
UploadProps,
UploadRawFile,
UploadUserFile,
UploadRequestOptions
} from 'element-plus'
defineOptions({ name: 'SaImport' })
const props = withDefaults(
defineProps<{
title?: string
width?: string | number
uploadUrl?: string
downloadUrl?: string
accept?: string
tip?: string
data?: Record<string, any>
}>(),
{
title: '导入',
width: '600px',
accept: '.xlsx,.xls'
}
)
const emit = defineEmits<{
success: [response: any]
error: [error: any]
'download-template': []
}>()
const visible = ref(false)
const loading = ref(false)
const uploadRef = ref<UploadInstance>()
const fileList = ref<UploadUserFile[]>([])
const showTemplate = computed(() => {
return props.downloadUrl
})
const open = () => {
visible.value = true
fileList.value = []
}
const handleExceed: UploadProps['onExceed'] = (files) => {
uploadRef.value!.clearFiles()
const file = files[0] as UploadRawFile
file.uid = genFileId()
uploadRef.value!.handleStart(file)
}
const handleRemove = () => {
fileList.value = []
}
const customUpload = async (options: UploadRequestOptions) => {
if (!props.uploadUrl) {
ElMessage.error('未配置上传接口')
options.onError('未配置上传接口' as any)
return
}
try {
loading.value = true
const formData = new FormData()
formData.append('file', options.file)
if (props.data) {
Object.keys(props.data).forEach((key) => {
formData.append(key, props.data![key])
})
}
const { VITE_API_URL } = import.meta.env
const { accessToken } = useUserStore()
axios.defaults.baseURL = VITE_API_URL
const res = await axios.post(props.uploadUrl, formData, {
headers: {
Authorization: `Bearer ` + accessToken,
'Content-Type': 'multipart/form-data'
}
})
ElMessage.success(res?.data?.msg || '导入成功')
emit('success', res.data)
visible.value = false
} catch (error: any) {
console.error(error)
ElMessage.error(error?.response?.data?.msg || '导入失败')
emit('error', error)
} finally {
loading.value = false
}
}
const downloadTemplate = async () => {
if (props.downloadUrl) {
try {
const { VITE_API_URL } = import.meta.env
const { accessToken } = useUserStore()
axios.defaults.baseURL = VITE_API_URL
const config = {
method: 'post',
url: props.downloadUrl,
data: props.data,
responseType: 'blob' as const,
headers: {
Authorization: accessToken ? `Bearer ${accessToken}` : undefined
}
}
const res = await axios(config)
const blob = new Blob([res.data], { type: 'application/octet-stream' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = '导入模板.xlsx'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (error) {
console.error(error)
ElMessage.error('下载模板失败')
}
} else {
emit('download-template')
}
}
defineExpose({
open
})
</script>
<style scoped lang="scss">
.sa-import-wrap {
display: inline-block;
}
.import-container {
padding: 10px 0;
position: relative;
.upload-area {
:deep(.el-upload-dragger) {
width: 100%;
}
}
.template-download {
margin-top: 10px;
text-align: right;
display: flex;
justify-content: flex-end;
align-items: center;
}
}
</style>

View File

@@ -0,0 +1,30 @@
<template>
<div class="flex items-center">
<span class="label">{{ props.label }}</span>
<el-tooltip :content="props.tooltip" :placement="props.placement">
<el-icon class="ml-0.5">
<QuestionFilled />
</el-icon>
</el-tooltip>
</div>
</template>
<script lang="ts" setup>
import { QuestionFilled } from '@element-plus/icons-vue'
defineOptions({ name: 'SaLabel', inheritAttrs: false })
interface Props {
/** 标签内容 */
label: string
/** 提示内容 */
tooltip?: string
/** 提示位置 */
placement?: 'top' | 'bottom' | 'left' | 'right'
}
const props = withDefaults(defineProps<Props>(), {
tooltip: '',
placement: 'top'
})
</script>

View File

@@ -0,0 +1,113 @@
<!-- Markdown编辑器封装 -->
<template>
<div style="width: 100%">
<MdEditor
ref="editorRef"
v-model="modelValue"
:theme="theme"
previewTheme="github"
:toolbars="toolbars"
:preview="preview"
:style="{ height: height, minHeight: minHeight }"
>
<template #defToolbars>
<NormalToolbar title="图片" @onClick="openImageDialog">
<template #trigger>
<el-icon><Picture /></el-icon>
</template>
</NormalToolbar>
</template>
</MdEditor>
<SaImageDialog
v-model:visible="imageDialogVisible"
:multiple="true"
:limit="10"
@confirm="onImageSelect"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { MdEditor, NormalToolbar } from 'md-editor-v3'
import type { ExposeParam } from 'md-editor-v3'
import 'md-editor-v3/lib/style.css'
import { useSettingStore } from '@/store/modules/setting'
import { Picture } from '@element-plus/icons-vue'
import SaImageDialog from '@/components/sai/sa-image-dialog/index.vue'
defineOptions({ name: 'SaMdEditor' })
const settingStore = useSettingStore()
interface Props {
height?: string
preview?: boolean
minHeight?: string
}
withDefaults(defineProps<Props>(), {
height: '500px',
minHeight: '500px',
preview: true
})
const modelValue = defineModel<string>({ default: '' })
const editorRef = ref<ExposeParam>()
// 主题处理
const theme = computed(() => (settingStore.isDark ? 'dark' : 'light'))
// 图片弹窗
const imageDialogVisible = ref(false)
const openImageDialog = () => {
imageDialogVisible.value = true
}
const onImageSelect = (urls: string | string[]) => {
const urlList = Array.isArray(urls) ? urls : [urls]
const markdownImages = urlList.map((url) => `![](${url})`).join('\n')
editorRef.value?.insert(() => {
// 插入图片并配置光标位置
return {
targetValue: markdownImages,
select: true,
deviationStart: 0,
deviationEnd: 0
}
})
}
const toolbars = [
'bold',
'underline',
'italic',
'-',
'title',
'strikeThrough',
'sub',
'sup',
'quote',
'unorderedList',
'orderedList',
'task',
'-',
'codeRow',
'code',
'link',
0,
'table',
'mermaid',
'katex',
'-',
'revoke',
'next',
'=',
'pageFullscreen',
'preview',
'previewOnly',
'catalog'
] as any[]
</script>

View File

@@ -0,0 +1,126 @@
<template>
<el-radio-group
v-model="modelValue"
v-bind="$attrs"
:disabled="disabled"
:size="size"
:fill="fill"
:text-color="textColor"
>
<!-- 模式1: 按钮样式 -->
<template v-if="type === 'button'">
<el-radio-button v-if="allowNull" :value="nullValue" :label="nullLabel">
{{ nullLabel }}
</el-radio-button>
<el-radio-button
v-for="(item, index) in options"
:key="index"
:value="item.value"
:label="item.value"
:disabled="item.disabled"
>
{{ item.label }}
</el-radio-button>
</template>
<!-- 模式2: 普通/边框样式 -->
<template v-else>
<el-radio v-if="allowNull" :value="nullValue" :label="nullLabel" :border="type === 'border'">
{{ nullLabel }}
</el-radio>
<el-radio
v-for="(item, index) in options"
:key="index"
:value="item.value"
:label="item.value"
:border="type === 'border'"
:disabled="item.disabled"
>
{{ item.label }}
</el-radio>
</template>
</el-radio-group>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useDictStore } from '@/store/modules/dict'
defineOptions({ name: 'SaRadio', inheritAttrs: false })
interface Props {
dict: string
type?: 'radio' | 'button' | 'border'
disabled?: boolean
size?: 'large' | 'default' | 'small'
fill?: string
textColor?: string
allowNull?: boolean
nullValue?: string | number
nullLabel?: string
/**
* 强制转换字典值的类型
* 可选值: 'number' | 'string'
* 默认使用 'number'
*/
valueType?: 'number' | 'string'
}
const props = withDefaults(defineProps<Props>(), {
type: 'radio',
disabled: false,
size: 'default',
fill: '',
textColor: '',
allowNull: false,
nullValue: '',
nullLabel: '全部',
valueType: 'number' // 默认不转换
})
// 这里支持泛型,保证外部接收到的类型是正确的
const modelValue = defineModel<string | number | undefined>()
const dictStore = useDictStore()
// 判断能否转成数字
const canConvertToNumberStrict = (value: any) => {
// 严格模式:排除 null、undefined、布尔值、空数组等
if (value == null) return false
if (typeof value === 'boolean') return false
if (Array.isArray(value) && value.length !== 1) return false
if (typeof value === 'object' && !Array.isArray(value)) return false
const num = Number(value)
return !isNaN(num)
}
// 核心逻辑:在 computed 中处理数据类型转换
const options = computed(() => {
const list = dictStore.getByCode(props.dict) || []
// 如果没有指定 valueType直接返回原始字典
if (!props.valueType) return list
// 如果指定了类型,进行映射转换
return list.map((item) => {
let newValue = item.value
switch (props.valueType) {
case 'number':
if (canConvertToNumberStrict(item.value)) {
newValue = Number(item.value)
}
break
case 'string':
newValue = String(item.value)
break
}
return {
...item,
value: newValue
}
})
})
</script>

View File

@@ -0,0 +1,236 @@
<!-- 表格搜索组件 -->
<!-- 支持常用表单组件自定义组件插槽校验隐藏表单项 -->
<!-- 写法同 ElementPlus 官方文档组件把属性写在 props 里面就可以了 -->
<template>
<section class="art-search-bar art-card-sm" :class="{ 'is-expanded': isExpanded }">
<ElForm
ref="formRef"
:model="modelValue"
:label-position="labelPosition"
v-bind="{ ...$attrs }"
>
<ElRow :gutter="gutter">
<slot name="default" />
<ElCol :xs="24" :sm="24" :md="6" :lg="6" :xl="6" class="action-column">
<div class="action-buttons-wrapper" :style="actionButtonsStyle">
<div class="form-buttons">
<ElButton v-if="showReset" class="reset-button" @click="handleReset" v-ripple>
<template #icon>
<ArtSvgIcon icon="ri:reset-right-line" />
</template>
{{ t('table.searchBar.reset') }}
</ElButton>
<ElButton
v-if="showSearch"
type="primary"
class="search-button"
@click="handleSearch"
v-ripple
:disabled="disabledSearch"
>
<template #icon>
<ArtSvgIcon icon="ri:search-line" />
</template>
{{ t('table.searchBar.search') }}
</ElButton>
</div>
<div v-if="showExpand" class="filter-toggle" @click="toggleExpand">
<span>{{ expandToggleText }}</span>
<div class="icon-wrapper">
<ElIcon>
<ArrowUpBold v-if="isExpanded" />
<ArrowDownBold v-else />
</ElIcon>
</div>
</div>
</div>
</ElCol>
</ElRow>
</ElForm>
</section>
</template>
<script setup lang="ts">
import { ArrowUpBold, ArrowDownBold } from '@element-plus/icons-vue'
import { useWindowSize } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { FormInstance } from 'element-plus'
defineOptions({ name: 'SaSearchBar' })
const { width } = useWindowSize()
const { t } = useI18n()
const isMobile = computed(() => width.value < 500)
const formInstance = useTemplateRef<FormInstance>('formRef')
// 表单配置
interface SearchBarProps {
/** 表单控件间隙 */
gutter?: number
/** 展开/收起 */
isExpand?: boolean
/** 默认是否展开(仅在 showExpand 为 true 且 isExpand 为 false 时生效) */
defaultExpanded?: boolean
/** 表单域标签的位置 */
labelPosition?: 'left' | 'right' | 'top'
/** 是否需要展示,收起 */
showExpand?: boolean
/** 按钮靠左对齐限制(表单项小于等于该值时) */
buttonLeftLimit?: number
/** 是否显示重置按钮 */
showReset?: boolean
/** 是否显示搜索按钮 */
showSearch?: boolean
/** 是否禁用搜索按钮 */
disabledSearch?: boolean
}
const props = withDefaults(defineProps<SearchBarProps>(), {
items: () => [],
gutter: 12,
isExpand: false,
labelPosition: 'right',
showExpand: true,
defaultExpanded: false,
buttonLeftLimit: 2,
showReset: true,
showSearch: true,
disabledSearch: false
})
interface SearchBarEmits {
(e: 'reset'): void
(e: 'search'): void
(e: 'expand', expanded: boolean): void
}
const emit = defineEmits<SearchBarEmits>()
const modelValue = defineModel<Record<string, any>>({ default: {} })
/**
* 是否展开状态
*/
const isExpanded = ref(props.defaultExpanded)
/**
* 展开/收起按钮文本
*/
const expandToggleText = computed(() => {
return isExpanded.value ? t('table.searchBar.collapse') : t('table.searchBar.expand')
})
/**
* 操作按钮样式
*/
const actionButtonsStyle = computed(() => ({
'justify-content': isMobile.value ? 'flex-end' : 'flex-end'
}))
/**
* 切换展开/收起状态
*/
const toggleExpand = () => {
isExpanded.value = !isExpanded.value
emit('expand', isExpanded.value)
}
/**
* 处理重置事件
*/
const handleReset = () => {
// 触发 reset 事件
emit('reset')
}
/**
* 处理搜索事件
*/
const handleSearch = () => {
emit('search')
}
defineExpose({
ref: formInstance,
reset: handleReset
})
// 解构 props 以便在模板中直接使用
const { gutter, labelPosition } = toRefs(props)
</script>
<style lang="scss" scoped>
.art-search-bar {
padding: 15px 20px 0;
.action-column {
flex: 1;
max-width: 100%;
.action-buttons-wrapper {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
margin-bottom: 12px;
}
.form-buttons {
display: flex;
gap: 8px;
}
.filter-toggle {
display: flex;
align-items: center;
margin-left: 10px;
line-height: 32px;
color: var(--theme-color);
cursor: pointer;
transition: color 0.2s ease;
&:hover {
color: var(--ElColor-primary);
}
span {
font-size: 14px;
user-select: none;
}
.icon-wrapper {
display: flex;
align-items: center;
margin-left: 4px;
font-size: 14px;
transition: transform 0.2s ease;
}
}
}
}
// 响应式优化
@media (width <= 768px) {
.art-search-bar {
padding: 16px 16px 0;
.action-column {
.action-buttons-wrapper {
flex-direction: column;
gap: 8px;
align-items: stretch;
.form-buttons {
justify-content: center;
}
.filter-toggle {
justify-content: center;
margin-left: 0;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,125 @@
<template>
<!--
v-bind="$attrs" 透传父组件传递的 width, class, style 以及 change, focus 等事件
-->
<el-select
v-model="modelValue"
v-bind="$attrs"
:placeholder="placeholder"
:disabled="disabled"
:clearable="clearable"
:filterable="filterable"
:multiple="multiple"
:collapse-tags="collapseTags"
:collapse-tags-tooltip="collapseTagsTooltip"
>
<!-- 遍历生成选项 -->
<el-option
v-for="(item, index) in options"
:key="index"
:label="item.label"
:value="item.value"
:disabled="item.disabled"
>
<!-- 支持自定义 option 模板 (可选)不传则只显示 label -->
<slot name="option" :item="item">
<span>{{ item.label }}</span>
</slot>
</el-option>
<!-- 透传 el-select 的其他插槽 ( prefix, empty) -->
<template v-for="(_, name) in $slots" #[name]="slotData">
<slot v-if="name !== 'option'" :name="name" v-bind="slotData || {}"></slot>
</template>
</el-select>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useDictStore } from '@/store/modules/dict'
defineOptions({ name: 'SaSelect', inheritAttrs: false })
interface Props {
/** 字典编码 (必填) */
dict: string
/**
* 强制转换字典值的类型
* 解决后端返回字符串但前端表单需要数字的问题
*/
valueType?: 'number' | 'string'
// --- 以下为常用属性显式定义,为了 IDE 提示友好 ---
placeholder?: string
disabled?: boolean
clearable?: boolean
filterable?: boolean
multiple?: boolean
collapseTags?: boolean
collapseTagsTooltip?: boolean
}
const props = withDefaults(defineProps<Props>(), {
placeholder: '请选择',
disabled: false,
clearable: true, // 下拉框默认开启清除,体验更好
filterable: false, // 下拉框默认关闭搜索
multiple: false,
collapseTags: false,
collapseTagsTooltip: false,
valueType: 'number'
})
// 支持单选(string/number) 或 多选(Array)
const modelValue = defineModel<string | number | Array<string | number>>()
const dictStore = useDictStore()
// 判断能否转成数字
const canConvertToNumberStrict = (value: any) => {
// 严格模式:排除 null、undefined、布尔值、空数组等
if (value == null) return false
if (typeof value === 'boolean') return false
if (Array.isArray(value) && value.length !== 1) return false
if (typeof value === 'object' && !Array.isArray(value)) return false
const num = Number(value)
return !isNaN(num)
}
// 计算属性:获取字典数据并处理类型转换
const options = computed(() => {
const list = dictStore.getByCode(props.dict) || []
// 1. 如果没有指定 valueType直接返回
if (!props.valueType) return list
// 2. 如果指定了类型,进行映射转换
return list.map((item) => {
let newValue = item.value
switch (props.valueType) {
case 'number':
if (canConvertToNumberStrict(item.value)) {
newValue = Number(item.value)
}
break
case 'string':
newValue = String(item.value)
break
}
return {
...item,
value: newValue
}
})
})
</script>
<style scoped>
/* 让 Select 默认宽度占满父容器,通常在表单中体验更好,可视情况删除 */
.el-select {
width: 100%;
}
</style>

View File

@@ -0,0 +1,45 @@
<template>
<el-switch
v-model="modelValue"
v-bind="$attrs"
:loading="props.loading"
:inline-prompt="props.inlinePrompt"
:active-value="props.activeValue"
:inactive-value="props.inactiveValue"
:active-text="props.showText ? props.activeText : undefined"
:inactive-text="props.showText ? props.inactiveText : undefined"
/>
</template>
<script lang="ts" setup>
defineOptions({ name: 'SaSwitch', inheritAttrs: false })
interface Props {
/** 是否显示加载中 */
loading?: boolean
/** 是否在开关内显示文字 */
inlinePrompt?: boolean
/** 打开时的值 */
activeValue?: string | number | boolean
/** 关闭时的值 */
inactiveValue?: string | number | boolean
/** 是否显示文字 */
showText?: boolean
/** 打开时的文字描述 */
activeText?: string
/** 关闭时的文字描述 */
inactiveText?: string
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
inlinePrompt: true,
activeValue: 1,
inactiveValue: 2,
showText: true,
activeText: '启用',
inactiveText: '禁用'
})
const modelValue = defineModel<string | number | boolean | undefined>()
</script>

View File

@@ -0,0 +1,402 @@
<template>
<el-select
v-model="selectedValue"
v-bind="$attrs"
:placeholder="placeholder"
:disabled="disabled"
:clearable="clearable"
:filterable="false"
:multiple="multiple"
:collapse-tags="collapseTags"
:collapse-tags-tooltip="collapseTagsTooltip"
:loading="loading"
popper-class="sa-user-select-popper"
@visible-change="handleVisibleChange"
@clear="handleClear"
>
<template #header>
<div class="sa-user-select-header" @click.stop>
<el-input
v-model="searchKeyword"
placeholder="搜索用户名、姓名、手机号"
clearable
@input="handleSearch"
@click.stop
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
</template>
<!-- 隐藏的选项用于显示已选中的用户 -->
<el-option
v-for="user in selectedUsers"
:key="user.id"
:value="user.id"
:label="user.username"
style="display: none"
/>
<!-- 使用 el-option 包装表格内容 -->
<el-option value="" disabled style="height: auto; padding: 0">
<div class="sa-user-select-table" @click.stop>
<el-table
ref="tableRef"
:data="userList"
@row-click="handleRowClick"
@selection-change="handleSelectionChange"
size="small"
v-loading="loading"
>
<el-table-column
v-if="multiple"
type="selection"
width="45"
:selectable="checkSelectable"
/>
<el-table-column prop="id" label="编号" align="center" width="80" />
<el-table-column prop="avatar" label="头像" width="60">
<template #default="{ row }">
<el-avatar :size="32" :src="row.avatar">
{{ row.username?.charAt(0) }}
</el-avatar>
</template>
</el-table-column>
<el-table-column prop="username" label="用户名" width="100" show-overflow-tooltip />
<el-table-column prop="realname" label="姓名" width="100" show-overflow-tooltip />
<el-table-column prop="phone" label="手机号" width="110" show-overflow-tooltip />
</el-table>
<div class="sa-user-select-pagination">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.limit"
:total="pagination.total"
layout="total, prev, pager, next"
small
background
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</div>
</el-option>
<template #empty>
<el-empty description="暂无用户数据" :image-size="60" />
</template>
</el-select>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { getUserList } from '@/api/auth'
import { Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { TableInstance } from 'element-plus'
defineOptions({ name: 'SaUser', inheritAttrs: false })
interface UserItem {
id: number
username: string
email: string
phone: string
avatar?: string
status: string
[key: string]: any
}
interface Props {
/** 占位符 */
placeholder?: string
/** 是否禁用 */
disabled?: boolean
/** 是否可清空 */
clearable?: boolean
/** 是否可搜索 */
filterable?: boolean
/** 是否多选 */
multiple?: boolean
/** 多选时是否折叠标签 */
collapseTags?: boolean
/** 多选折叠时是否显示提示 */
collapseTagsTooltip?: boolean
/** 返回值类型:'id' 返回用户ID'object' 返回完整用户对象 */
valueType?: 'id' | 'object'
}
const props = withDefaults(defineProps<Props>(), {
placeholder: '请选择用户',
disabled: false,
clearable: true,
filterable: true,
multiple: false,
collapseTags: true,
collapseTagsTooltip: true,
valueType: 'id'
})
// 支持单选(number/object) 或 多选(Array)
const modelValue = defineModel<number | null | UserItem | Array<number | UserItem>>()
// 内部选中值
const selectedValue = ref<any>()
const searchKeyword = ref('')
const loading = ref(false)
const userList = ref<UserItem[]>([])
const tableRef = ref<TableInstance>()
// 缓存所有已选中的用户信息
const allSelectedUsers = ref<UserItem[]>([])
// 计算已选中的用户列表(用于显示)
const selectedUsers = computed(() => {
if (!selectedValue.value) return []
const selectedIds = props.multiple
? Array.isArray(selectedValue.value)
? selectedValue.value
: []
: [selectedValue.value]
// 从缓存中查找用户信息
return selectedIds
.map((id) => {
const cached = allSelectedUsers.value.find((u) => u.id === id)
if (cached) return cached
// 从当前列表中查找
const fromList = userList.value.find((u) => u.id === id)
if (fromList) {
// 添加到缓存
allSelectedUsers.value.push(fromList)
return fromList
}
// 如果都找不到,返回一个临时对象
return { id, username: `用户${id}`, email: '', phone: '', status: '1' }
})
.filter(Boolean)
})
// 分页参数
const pagination = ref({
page: 1,
limit: 5,
total: 0
})
// 获取用户列表
const fetchUserList = async () => {
loading.value = true
try {
const params: any = {
page: pagination.value.page,
limit: pagination.value.limit
}
// 添加搜索条件
if (searchKeyword.value) {
params.keyword = searchKeyword.value
}
const response = await getUserList(params)
if (response && response.data) {
userList.value = response.data || []
pagination.value.total = response.total || 0
}
} catch (error) {
console.error('获取用户列表失败:', error)
ElMessage.error('获取用户列表失败')
} finally {
loading.value = false
}
}
// 搜索防抖
let searchTimer: any = null
const handleSearch = () => {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
pagination.value.page = 1
fetchUserList()
}, 300)
}
// 下拉框显示/隐藏
const handleVisibleChange = (visible: boolean) => {
if (visible) {
// 打开时加载数据
fetchUserList()
}
}
// 清空选择
const handleClear = () => {
selectedValue.value = props.multiple ? [] : null
if (tableRef.value) {
if (props.multiple) {
tableRef.value.clearSelection()
} else {
tableRef.value.setCurrentRow(null)
}
}
}
// 单选 - 行点击
const handleRowClick = (row: UserItem) => {
if (!props.multiple) {
handleCurrentChange(row)
}
}
// 单选 - 当前行改变
const handleCurrentChange = (row: UserItem | undefined) => {
if (!props.multiple && row) {
// 添加到缓存
const existingIndex = allSelectedUsers.value.findIndex((u) => u.id === row.id)
if (existingIndex === -1) {
allSelectedUsers.value.push(row)
} else {
allSelectedUsers.value[existingIndex] = row
}
selectedValue.value = props.valueType === 'id' ? row.id : row
}
}
// 多选 - 选择改变
const handleSelectionChange = (selection: UserItem[]) => {
if (props.multiple) {
// 更新缓存
selection.forEach((row) => {
const existingIndex = allSelectedUsers.value.findIndex((u) => u.id === row.id)
if (existingIndex === -1) {
allSelectedUsers.value.push(row)
} else {
allSelectedUsers.value[existingIndex] = row
}
})
selectedValue.value = selection.map((item) => (props.valueType === 'id' ? item.id : item))
}
}
// 检查行是否可选
const checkSelectable = () => {
// 可以根据需要添加更多条件
return !props.disabled
}
// 分页改变
const handlePageChange = () => {
fetchUserList()
}
const handleSizeChange = () => {
pagination.value.page = 1
fetchUserList()
}
// 监听内部选中值变化,同步到 v-model
watch(
selectedValue,
(newVal) => {
modelValue.value = newVal
},
{ deep: true }
)
// 监听 v-model 变化,同步到内部选中值
watch(
() => modelValue.value,
(newVal) => {
selectedValue.value = newVal
},
{ immediate: true, deep: true }
)
// 组件挂载时初始化
onMounted(() => {
// 如果有初始值,加载数据
if (modelValue.value) {
fetchUserList()
}
})
</script>
<style scoped lang="scss">
.sa-user-select-header {
padding: 8px 12px;
border-bottom: 1px solid var(--el-border-color-light);
}
.sa-user-select-table {
min-height: 480px;
max-height: 600px;
display: flex;
flex-direction: column;
:deep(.el-table) {
.el-table__header-wrapper {
th {
background-color: var(--el-fill-color-light);
}
}
.el-table__row {
cursor: pointer;
&:hover {
background-color: var(--el-fill-color-light);
}
}
}
}
.sa-user-select-pagination {
padding: 8px 12px;
border-top: 1px solid var(--el-border-color-light);
background-color: var(--el-fill-color-blank);
display: flex;
justify-content: center;
}
</style>
<style lang="scss">
// 全局样式,不使用 scoped
.sa-user-select-popper {
max-width: 90vw !important;
.el-select-dropdown__item {
height: auto !important;
min-height: 320px !important;
max-height: 360px !important;
padding: 0 !important;
line-height: normal !important;
&.is-disabled {
cursor: default;
background-color: transparent !important;
}
}
.el-select-dropdown__wrap {
max-height: 340px !important;
}
// 确保下拉框列表容器也不限制高度
.el-select-dropdown__list {
padding: 0 !important;
}
// 确保滚动容器正确显示
.el-scrollbar__view {
padding: 0 !important;
}
}
</style>