初始化
This commit is contained in:
71
saiadmin-artd/src/components/sai/sa-button/index.vue
Normal file
71
saiadmin-artd/src/components/sai/sa-button/index.vue
Normal 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>
|
||||
108
saiadmin-artd/src/components/sai/sa-checkbox/index.vue
Normal file
108
saiadmin-artd/src/components/sai/sa-checkbox/index.vue
Normal 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>
|
||||
216
saiadmin-artd/src/components/sai/sa-chunk-upload/README.md
Normal file
216
saiadmin-artd/src/components/sai/sa-chunk-upload/README.md
Normal 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="MP4、AVI、MOV"
|
||||
: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="ZIP、RAR、7Z"
|
||||
: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
|
||||
```
|
||||
623
saiadmin-artd/src/components/sai/sa-chunk-upload/index.vue
Normal file
623
saiadmin-artd/src/components/sai/sa-chunk-upload/index.vue
Normal 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>
|
||||
51
saiadmin-artd/src/components/sai/sa-code/index.vue
Normal file
51
saiadmin-artd/src/components/sai/sa-code/index.vue
Normal 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>
|
||||
113
saiadmin-artd/src/components/sai/sa-dict/index.vue
Normal file
113
saiadmin-artd/src/components/sai/sa-dict/index.vue
Normal 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>
|
||||
297
saiadmin-artd/src/components/sai/sa-editor/index.vue
Normal file
297
saiadmin-artd/src/components/sai/sa-editor/index.vue
Normal 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>
|
||||
210
saiadmin-artd/src/components/sai/sa-editor/style.scss
Normal file
210
saiadmin-artd/src/components/sai/sa-editor/style.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
127
saiadmin-artd/src/components/sai/sa-export/index.vue
Normal file
127
saiadmin-artd/src/components/sai/sa-export/index.vue
Normal 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>
|
||||
119
saiadmin-artd/src/components/sai/sa-file-upload/README.MD
Normal file
119
saiadmin-artd/src/components/sai/sa-file-upload/README.MD
Normal 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>
|
||||
269
saiadmin-artd/src/components/sai/sa-file-upload/index.vue
Normal file
269
saiadmin-artd/src/components/sai/sa-file-upload/index.vue
Normal 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>
|
||||
186
saiadmin-artd/src/components/sai/sa-icon-picker/index.vue
Normal file
186
saiadmin-artd/src/components/sai/sa-icon-picker/index.vue
Normal 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>
|
||||
3175
saiadmin-artd/src/components/sai/sa-icon-picker/lib/RemixIcon.json
Normal file
3175
saiadmin-artd/src/components/sai/sa-icon-picker/lib/RemixIcon.json
Normal 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"
|
||||
]
|
||||
}
|
||||
389
saiadmin-artd/src/components/sai/sa-image-dialog/index.vue
Normal file
389
saiadmin-artd/src/components/sai/sa-image-dialog/index.vue
Normal 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>
|
||||
321
saiadmin-artd/src/components/sai/sa-image-picker/index.vue
Normal file
321
saiadmin-artd/src/components/sai/sa-image-picker/index.vue
Normal 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>
|
||||
310
saiadmin-artd/src/components/sai/sa-image-upload/index.vue
Normal file
310
saiadmin-artd/src/components/sai/sa-image-upload/index.vue
Normal 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>
|
||||
223
saiadmin-artd/src/components/sai/sa-import/index.vue
Normal file
223
saiadmin-artd/src/components/sai/sa-import/index.vue
Normal 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>
|
||||
30
saiadmin-artd/src/components/sai/sa-label/index.vue
Normal file
30
saiadmin-artd/src/components/sai/sa-label/index.vue
Normal 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>
|
||||
113
saiadmin-artd/src/components/sai/sa-md-editor/index.vue
Normal file
113
saiadmin-artd/src/components/sai/sa-md-editor/index.vue
Normal 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) => ``).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>
|
||||
126
saiadmin-artd/src/components/sai/sa-radio/index.vue
Normal file
126
saiadmin-artd/src/components/sai/sa-radio/index.vue
Normal 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>
|
||||
236
saiadmin-artd/src/components/sai/sa-search-bar/index.vue
Normal file
236
saiadmin-artd/src/components/sai/sa-search-bar/index.vue
Normal 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>
|
||||
125
saiadmin-artd/src/components/sai/sa-select/index.vue
Normal file
125
saiadmin-artd/src/components/sai/sa-select/index.vue
Normal 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>
|
||||
45
saiadmin-artd/src/components/sai/sa-switch/index.vue
Normal file
45
saiadmin-artd/src/components/sai/sa-switch/index.vue
Normal 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>
|
||||
402
saiadmin-artd/src/components/sai/sa-user/index.vue
Normal file
402
saiadmin-artd/src/components/sai/sa-user/index.vue
Normal 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>
|
||||
Reference in New Issue
Block a user