mirror of
https://gitee.com/yudaocode/yudao-ui-admin-vben.git
synced 2025-12-30 10:32:25 +00:00
feat: add upload
This commit is contained in:
250
apps/web-naive/src/components/upload/README.md
Normal file
250
apps/web-naive/src/components/upload/README.md
Normal file
@@ -0,0 +1,250 @@
|
||||
# Upload Components - Naive UI 版本
|
||||
|
||||
本目录包含已重构为 Naive UI 的上传组件。
|
||||
|
||||
## 组件列表
|
||||
|
||||
### 1. ImageUpload - 图片上传组件
|
||||
- **文件**: `image-upload.vue`
|
||||
- **功能**: 专门用于图片上传的组件,支持图片预览
|
||||
- **特性**:
|
||||
- 支持单图/多图上传
|
||||
- 支持图片预览(使用 NModal + NImage)
|
||||
- 支持拖拽上传
|
||||
- 自动校验文件类型和大小
|
||||
- 支持自定义上传 API
|
||||
- 支持进度显示
|
||||
|
||||
### 2. FileUpload - 文件上传组件
|
||||
- **文件**: `file-upload.vue`
|
||||
- **功能**: 通用文件上传组件
|
||||
- **特性**:
|
||||
- 支持单文件/多文件上传
|
||||
- 支持拖拽上传区域
|
||||
- 支持文件预览和下载
|
||||
- 自动校验文件类型和大小
|
||||
- 支持自定义上传 API
|
||||
- 支持进度显示
|
||||
- 支持返回文本内容(用于配置文件等)
|
||||
|
||||
### 3. InputUpload - 输入框上传组件
|
||||
- **文件**: `input-upload.vue`
|
||||
- **功能**: 结合输入框和文件上传的组件
|
||||
- **特性**:
|
||||
- 支持文本输入框或文本域
|
||||
- 支持通过上传文件自动填充内容
|
||||
- 使用 NGrid 布局,响应式设计
|
||||
|
||||
## 使用示例
|
||||
|
||||
### ImageUpload 图片上传
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ImageUpload } from '#/components/upload';
|
||||
|
||||
const imageUrl = ref('');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ImageUpload
|
||||
v-model="imageUrl"
|
||||
:max-number="1"
|
||||
:max-size="2"
|
||||
:accept="['jpg', 'jpeg', 'png', 'gif']"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
### FileUpload 文件上传
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { FileUpload } from '#/components/upload';
|
||||
|
||||
const fileUrl = ref('');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FileUpload
|
||||
v-model="fileUrl"
|
||||
:max-number="3"
|
||||
:max-size="5"
|
||||
:accept="['pdf', 'doc', 'docx']"
|
||||
drag
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
### InputUpload 输入框上传
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { InputUpload } from '#/components/upload';
|
||||
|
||||
const configContent = ref('');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<InputUpload
|
||||
v-model="configContent"
|
||||
input-type="textarea"
|
||||
:file-upload-props="{
|
||||
accept: ['json', 'yaml', 'yml'],
|
||||
maxSize: 1
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Props 说明
|
||||
|
||||
### 通用 Props (FileUploadProps)
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| modelValue/value | `string \| string[]` | - | v-model 绑定值 |
|
||||
| accept | `string[]` | `[]` | 接受的文件类型 |
|
||||
| maxSize | `number` | `2` | 文件最大大小(MB) |
|
||||
| maxNumber | `number` | `1` | 最大文件数量 |
|
||||
| multiple | `boolean` | `false` | 是否支持多选 |
|
||||
| disabled | `boolean` | `false` | 是否禁用 |
|
||||
| drag | `boolean` | `false` | 是否支持拖拽上传 |
|
||||
| directory | `string` | - | 上传目录 |
|
||||
| api | `Function` | - | 自定义上传 API |
|
||||
| showDescription | `boolean` | - | 是否显示描述文本 |
|
||||
|
||||
### ImageUpload 特有 Props
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| listType | `string` | `'picture-card'` | 列表类型 |
|
||||
| accept | `string[]` | `['jpg', 'jpeg', 'png', 'gif', 'webp']` | 接受的图片类型 |
|
||||
| showDescription | `boolean` | `true` | 是否显示描述文本 |
|
||||
|
||||
### InputUpload 特有 Props
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| inputType | `'input' \| 'textarea'` | `'input'` | 输入框类型 |
|
||||
| inputProps | `InputProps` | - | 输入框属性 |
|
||||
| fileUploadProps | `FileUploadProps` | - | 文件上传组件属性 |
|
||||
|
||||
## Events
|
||||
|
||||
| 事件名 | 参数 | 说明 |
|
||||
|--------|------|------|
|
||||
| update:value | `value: string \| string[]` | 值更新事件 |
|
||||
| update:modelValue | `value: string \| string[]` | v-model 更新事件 |
|
||||
| change | `value: string \| string[]` | 值变化事件 |
|
||||
| delete | `file: UploadFileInfo` | 删除文件事件 |
|
||||
| preview | `file: UploadFileInfo` | 预览文件事件(仅 FileUpload) |
|
||||
| returnText | `text: string` | 返回文件文本内容(仅 FileUpload) |
|
||||
|
||||
## 辅助工具
|
||||
|
||||
### useUpload
|
||||
- **文件**: `use-upload.ts`
|
||||
- **功能**: 提供上传相关的工具函数
|
||||
- **主要方法**:
|
||||
- `httpRequest`: 统一的文件上传请求方法
|
||||
- `getUploadUrl`: 获取上传 URL
|
||||
|
||||
### useUploadType
|
||||
- **功能**: 处理上传类型相关的逻辑
|
||||
- **主要方法**:
|
||||
- `getStringAccept`: 获取 accept 字符串
|
||||
- `getHelpText`: 获取帮助文本
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **UI 框架**: Naive UI
|
||||
- **核心组件**:
|
||||
- NUpload
|
||||
- NImage
|
||||
- NImageGroup
|
||||
- NModal
|
||||
- NButton
|
||||
- NGrid
|
||||
- NInput
|
||||
- **工具库**:
|
||||
- @vueuse/core
|
||||
- @vben/utils
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 文件状态使用 Naive UI 的状态值:`'pending' | 'uploading' | 'finished' | 'error' | 'removed'`
|
||||
2. 所有文件 ID 使用 Naive UI 的 `id` 字段,而不是 `uid`
|
||||
3. 上传前会自动校验文件类型和大小
|
||||
4. 支持两种上传模式:
|
||||
- 客户端直接上传(S3)
|
||||
- 通过后端上传
|
||||
5. 支持自定义上传 API,如果不提供则使用默认的上传接口
|
||||
|
||||
## 迁移指南
|
||||
|
||||
从 Ant Design Vue 迁移到 Naive UI 的主要变化:
|
||||
|
||||
1. **组件导入**:
|
||||
```typescript
|
||||
// 旧
|
||||
import { Upload } from 'ant-design-vue';
|
||||
|
||||
// 新
|
||||
import { NUpload } from 'naive-ui';
|
||||
```
|
||||
|
||||
2. **文件列表类型**:
|
||||
```typescript
|
||||
// 旧
|
||||
import type { UploadFile } from 'ant-design-vue';
|
||||
|
||||
// 新
|
||||
import type { UploadFileInfo } from 'naive-ui';
|
||||
```
|
||||
|
||||
3. **状态值**:
|
||||
```typescript
|
||||
// 旧
|
||||
status: 'done'
|
||||
|
||||
// 新
|
||||
status: 'finished'
|
||||
```
|
||||
|
||||
4. **事件回调**:
|
||||
```typescript
|
||||
// 旧
|
||||
@remove="handleRemove"
|
||||
function handleRemove(file: UploadFile) { }
|
||||
|
||||
// 新
|
||||
@remove="handleRemove"
|
||||
function handleRemove(options: { file: UploadFileInfo; fileList: UploadFileInfo[] }) { }
|
||||
```
|
||||
|
||||
5. **自定义上传**:
|
||||
```typescript
|
||||
// 旧
|
||||
customRequest(info: UploadRequestOption) {
|
||||
info.onSuccess!(res);
|
||||
}
|
||||
|
||||
// 新
|
||||
customRequest(options: UploadCustomRequestOptions) {
|
||||
options.onFinish();
|
||||
}
|
||||
```
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.0.0 (2025-01-16)
|
||||
- ✅ 将所有上传组件从 Ant Design Vue 重构为 Naive UI
|
||||
- ✅ 保持原有功能和 API 兼容性
|
||||
- ✅ 优化代码结构和类型定义
|
||||
- ✅ 修复所有 linter 错误
|
||||
- ✅ 添加完整的文档说明
|
||||
|
||||
346
apps/web-naive/src/components/upload/file-upload.vue
Normal file
346
apps/web-naive/src/components/upload/file-upload.vue
Normal file
@@ -0,0 +1,346 @@
|
||||
<script lang="ts" setup>
|
||||
import type { UploadCustomRequestOptions, UploadFileInfo } from 'naive-ui';
|
||||
|
||||
import type { FileUploadProps } from './typing';
|
||||
|
||||
import type { AxiosProgressEvent } from '#/api/infra/file';
|
||||
|
||||
import { computed, ref, toRefs, watch } from 'vue';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
import { isFunction, isObject, isString } from '@vben/utils';
|
||||
|
||||
import { NButton, NUpload, useMessage } from 'naive-ui';
|
||||
|
||||
import { checkFileType } from './helper';
|
||||
import { useUpload, useUploadType } from './use-upload';
|
||||
|
||||
defineOptions({ name: 'FileUpload', inheritAttrs: false });
|
||||
|
||||
const props = withDefaults(defineProps<FileUploadProps>(), {
|
||||
value: () => [],
|
||||
modelValue: undefined,
|
||||
directory: undefined,
|
||||
disabled: false,
|
||||
drag: false,
|
||||
helpText: '',
|
||||
maxSize: 2,
|
||||
maxNumber: 1,
|
||||
accept: () => [],
|
||||
multiple: false,
|
||||
api: undefined,
|
||||
resultField: '',
|
||||
showDescription: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'change',
|
||||
'update:value',
|
||||
'update:modelValue',
|
||||
'delete',
|
||||
'returnText',
|
||||
'preview',
|
||||
]);
|
||||
|
||||
const { accept, helpText, maxNumber, maxSize } = toRefs(props);
|
||||
const message = useMessage();
|
||||
const isInnerOperate = ref<boolean>(false);
|
||||
const { getStringAccept } = useUploadType({
|
||||
acceptRef: accept,
|
||||
helpTextRef: helpText,
|
||||
maxNumberRef: maxNumber,
|
||||
maxSizeRef: maxSize,
|
||||
});
|
||||
|
||||
/** 计算当前绑定的值,优先使用 modelValue */
|
||||
const currentValue = computed(() => {
|
||||
return props.modelValue === undefined ? props.value : props.modelValue;
|
||||
});
|
||||
|
||||
/** 判断是否使用 modelValue */
|
||||
const isUsingModelValue = computed(() => {
|
||||
return props.modelValue !== undefined;
|
||||
});
|
||||
|
||||
const fileList = ref<UploadFileInfo[]>([]);
|
||||
const isLtMsg = ref<boolean>(true); // 文件大小错误提示
|
||||
const isActMsg = ref<boolean>(true); // 文件类型错误提示
|
||||
const isFirstRender = ref<boolean>(true); // 是否第一次渲染
|
||||
const uploadNumber = ref<number>(0); // 上传文件计数器
|
||||
const uploadList = ref<any[]>([]); // 临时上传列表
|
||||
|
||||
watch(
|
||||
currentValue,
|
||||
(v) => {
|
||||
if (isInnerOperate.value) {
|
||||
isInnerOperate.value = false;
|
||||
return;
|
||||
}
|
||||
let value: string[] = [];
|
||||
if (v) {
|
||||
if (Array.isArray(v)) {
|
||||
value = v;
|
||||
} else {
|
||||
value.push(v);
|
||||
}
|
||||
fileList.value = value
|
||||
.map((item, i) => {
|
||||
if (item && isString(item)) {
|
||||
return {
|
||||
id: `${-i}`,
|
||||
name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)),
|
||||
status: 'finished',
|
||||
url: item,
|
||||
} as UploadFileInfo;
|
||||
} else if (item && isObject(item)) {
|
||||
return item as unknown as UploadFileInfo;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((item) => item !== null) as UploadFileInfo[];
|
||||
}
|
||||
if (!isFirstRender.value) {
|
||||
emit('change', value);
|
||||
isFirstRender.value = false;
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
deep: true,
|
||||
},
|
||||
);
|
||||
|
||||
/** 移除文件 */
|
||||
function handleRemove(options: {
|
||||
file: UploadFileInfo;
|
||||
fileList: UploadFileInfo[];
|
||||
}) {
|
||||
const file = options.file;
|
||||
const index = fileList.value.findIndex((item) => item.id === file.id);
|
||||
if (index !== -1) {
|
||||
fileList.value.splice(index, 1);
|
||||
const value = getValue();
|
||||
isInnerOperate.value = true;
|
||||
emit('update:value', value);
|
||||
emit('update:modelValue', value);
|
||||
emit('change', value);
|
||||
emit('delete', file);
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理文件预览 */
|
||||
function handlePreview(file: UploadFileInfo) {
|
||||
emit('preview', file);
|
||||
}
|
||||
|
||||
/** 处理上传错误 */
|
||||
function handleUploadError(error: any) {
|
||||
console.error('上传错误:', error);
|
||||
message.error($t('ui.upload.uploadError'));
|
||||
// 上传失败时减少计数器
|
||||
uploadNumber.value = Math.max(0, uploadNumber.value - 1);
|
||||
}
|
||||
|
||||
/** 上传前校验 */
|
||||
async function beforeUpload(options: {
|
||||
file: UploadFileInfo;
|
||||
fileList: UploadFileInfo[];
|
||||
}) {
|
||||
const file = options.file.file as File;
|
||||
const fileContent = await file.text();
|
||||
emit('returnText', fileContent);
|
||||
|
||||
// 检查文件数量限制
|
||||
if (fileList.value.length >= props.maxNumber) {
|
||||
message.error($t('ui.upload.maxNumber', [props.maxNumber]));
|
||||
return false;
|
||||
}
|
||||
|
||||
const { maxSize, accept } = props;
|
||||
const isAct = checkFileType(file, accept);
|
||||
if (!isAct) {
|
||||
message.error($t('ui.upload.acceptUpload', [accept]));
|
||||
isActMsg.value = false;
|
||||
// 防止弹出多个错误提示
|
||||
setTimeout(() => (isActMsg.value = true), 1000);
|
||||
return false;
|
||||
}
|
||||
const isLt = file.size / 1024 / 1024 > maxSize;
|
||||
if (isLt) {
|
||||
message.error($t('ui.upload.maxSizeMultiple', [maxSize]));
|
||||
isLtMsg.value = false;
|
||||
// 防止弹出多个错误提示
|
||||
setTimeout(() => (isLtMsg.value = true), 1000);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 只有在验证通过后才增加计数器
|
||||
uploadNumber.value++;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 自定义上传 */
|
||||
async function customRequest(options: UploadCustomRequestOptions) {
|
||||
let { api } = props;
|
||||
if (!api || !isFunction(api)) {
|
||||
api = useUpload(props.directory).httpRequest;
|
||||
}
|
||||
try {
|
||||
// 上传文件
|
||||
const progressEvent: AxiosProgressEvent = (e) => {
|
||||
const percent = Math.trunc((e.loaded / e.total!) * 100);
|
||||
options.onProgress?.({ percent });
|
||||
};
|
||||
const res = await api?.(options.file.file as File, progressEvent);
|
||||
|
||||
// 处理上传成功后的逻辑
|
||||
handleUploadSuccess(res, options.file);
|
||||
|
||||
options.onFinish();
|
||||
message.success($t('ui.upload.uploadSuccess'));
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
options.onError();
|
||||
handleUploadError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理上传成功 */
|
||||
function handleUploadSuccess(res: any, file: UploadFileInfo) {
|
||||
// 删除临时文件
|
||||
const index = fileList.value?.findIndex((item) => item.name === file.name);
|
||||
if (index !== -1) {
|
||||
fileList.value?.splice(index!, 1);
|
||||
}
|
||||
|
||||
// 添加到临时上传列表
|
||||
const fileUrl = res?.url || res?.data || res;
|
||||
uploadList.value.push({
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
url: fileUrl,
|
||||
status: 'finished',
|
||||
});
|
||||
|
||||
// 检查是否所有文件都上传完成
|
||||
if (uploadList.value.length >= uploadNumber.value) {
|
||||
fileList.value?.push(...uploadList.value);
|
||||
uploadList.value = [];
|
||||
uploadNumber.value = 0;
|
||||
|
||||
// 更新值
|
||||
const value = getValue();
|
||||
isInnerOperate.value = true;
|
||||
emit('update:value', value);
|
||||
emit('update:modelValue', value);
|
||||
emit('change', value);
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取值 */
|
||||
function getValue() {
|
||||
const list = (fileList.value || [])
|
||||
.filter((item) => item?.status === 'finished')
|
||||
.map((item: any) => {
|
||||
if (item?.response && props?.resultField) {
|
||||
return item?.response;
|
||||
}
|
||||
return item?.url || item?.response?.url || item?.response;
|
||||
});
|
||||
|
||||
// 单个文件的情况,根据输入参数类型决定返回格式
|
||||
if (props.maxNumber === 1) {
|
||||
const singleValue = list.length > 0 ? list[0] : '';
|
||||
// 如果原始值是字符串或 modelValue 是字符串,返回字符串
|
||||
if (
|
||||
isString(props.value) ||
|
||||
(isUsingModelValue.value && isString(props.modelValue))
|
||||
) {
|
||||
return singleValue;
|
||||
}
|
||||
return singleValue;
|
||||
}
|
||||
|
||||
// 多文件情况,根据输入参数类型决定返回格式
|
||||
if (isUsingModelValue.value) {
|
||||
return Array.isArray(props.modelValue) ? list : list.join(',');
|
||||
}
|
||||
|
||||
return Array.isArray(props.value) ? list : list.join(',');
|
||||
}
|
||||
|
||||
/** 处理文件列表变化 */
|
||||
function handleChange() {
|
||||
// 移除操作已经在 handleRemove 中处理
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<NUpload
|
||||
v-bind="$attrs"
|
||||
v-model:file-list="fileList"
|
||||
:accept="getStringAccept"
|
||||
:custom-request="customRequest"
|
||||
:disabled="disabled"
|
||||
:directory="drag"
|
||||
:max="maxNumber"
|
||||
:multiple="multiple"
|
||||
:show-download-button="true"
|
||||
:show-preview-button="true"
|
||||
:show-remove-button="true"
|
||||
@before-upload="beforeUpload"
|
||||
@change="handleChange"
|
||||
@preview="handlePreview"
|
||||
@remove="handleRemove"
|
||||
>
|
||||
<div v-if="drag" class="upload-drag-area">
|
||||
<div class="flex flex-col items-center justify-center p-6">
|
||||
<IconifyIcon
|
||||
icon="lucide:cloud-upload"
|
||||
class="mb-4 text-5xl text-gray-400"
|
||||
/>
|
||||
<p class="mb-2 text-base text-gray-600">点击或拖拽文件到此区域上传</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
支持{{ accept.join('/') }}格式文件,不超过{{ maxSize }}MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<NButton v-else-if="fileList && fileList.length < maxNumber" secondary>
|
||||
<template #icon>
|
||||
<IconifyIcon icon="lucide:cloud-upload" />
|
||||
</template>
|
||||
{{ $t('ui.upload.upload') }}
|
||||
</NButton>
|
||||
</NUpload>
|
||||
<div
|
||||
v-if="showDescription && !drag"
|
||||
class="mt-2 flex flex-wrap items-center text-sm text-gray-600"
|
||||
>
|
||||
请上传不超过
|
||||
<div class="text-primary mx-1 font-bold">{{ maxSize }}MB</div>
|
||||
的
|
||||
<div class="text-primary mx-1 font-bold">{{ accept.join('/') }}</div>
|
||||
格式文件
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.upload-drag-area {
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
background-color: #fafafa;
|
||||
border: 2px dashed #d9d9d9;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.upload-drag-area:hover {
|
||||
background-color: #f0f9ff;
|
||||
border-color: #18a058;
|
||||
}
|
||||
</style>
|
||||
20
apps/web-naive/src/components/upload/helper.ts
Normal file
20
apps/web-naive/src/components/upload/helper.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 默认图片类型
|
||||
*/
|
||||
export const defaultImageAccepts = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
||||
|
||||
export function checkFileType(file: File, accepts: string[]) {
|
||||
if (!accepts || accepts.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const newTypes = accepts.join('|');
|
||||
const reg = new RegExp(`${String.raw`\.(` + newTypes})$`, 'i');
|
||||
return reg.test(file.name);
|
||||
}
|
||||
|
||||
export function checkImgType(
|
||||
file: File,
|
||||
accepts: string[] = defaultImageAccepts,
|
||||
) {
|
||||
return checkFileType(file, accepts);
|
||||
}
|
||||
336
apps/web-naive/src/components/upload/image-upload.vue
Normal file
336
apps/web-naive/src/components/upload/image-upload.vue
Normal file
@@ -0,0 +1,336 @@
|
||||
<script lang="ts" setup>
|
||||
import type { UploadCustomRequestOptions, UploadFileInfo } from 'naive-ui';
|
||||
|
||||
import type { FileUploadProps } from './typing';
|
||||
|
||||
import type { AxiosProgressEvent } from '#/api/infra/file';
|
||||
|
||||
import { computed, ref, toRefs, watch } from 'vue';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
import { isFunction, isObject, isString } from '@vben/utils';
|
||||
|
||||
import { NImage, NImageGroup, NModal, NUpload, useMessage } from 'naive-ui';
|
||||
|
||||
import { checkImgType, defaultImageAccepts } from './helper';
|
||||
import { useUpload, useUploadType } from './use-upload';
|
||||
|
||||
defineOptions({ name: 'ImageUpload', inheritAttrs: false });
|
||||
|
||||
const props = withDefaults(defineProps<FileUploadProps>(), {
|
||||
value: () => [],
|
||||
modelValue: undefined,
|
||||
directory: undefined,
|
||||
disabled: false,
|
||||
listType: 'picture-card',
|
||||
helpText: '',
|
||||
maxSize: 2,
|
||||
maxNumber: 1,
|
||||
accept: () => defaultImageAccepts,
|
||||
multiple: false,
|
||||
api: undefined,
|
||||
resultField: '',
|
||||
showDescription: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'change',
|
||||
'update:value',
|
||||
'update:modelValue',
|
||||
'delete',
|
||||
]);
|
||||
|
||||
const { accept, helpText, maxNumber, maxSize } = toRefs(props);
|
||||
const message = useMessage();
|
||||
const isInnerOperate = ref<boolean>(false);
|
||||
const { getStringAccept } = useUploadType({
|
||||
acceptRef: accept,
|
||||
helpTextRef: helpText,
|
||||
maxNumberRef: maxNumber,
|
||||
maxSizeRef: maxSize,
|
||||
});
|
||||
|
||||
/** 计算当前绑定的值,优先使用 modelValue */
|
||||
const currentValue = computed(() => {
|
||||
return props.modelValue === undefined ? props.value : props.modelValue;
|
||||
});
|
||||
|
||||
/** 判断是否使用 modelValue */
|
||||
const isUsingModelValue = computed(() => {
|
||||
return props.modelValue !== undefined;
|
||||
});
|
||||
|
||||
const previewOpen = ref<boolean>(false); // 是否展示预览
|
||||
const previewImage = ref<string>(''); // 预览图片
|
||||
const previewTitle = ref<string>(''); // 预览标题
|
||||
|
||||
const fileList = ref<UploadFileInfo[]>([]);
|
||||
const isLtMsg = ref<boolean>(true); // 文件大小错误提示
|
||||
const isActMsg = ref<boolean>(true); // 文件类型错误提示
|
||||
const isFirstRender = ref<boolean>(true); // 是否第一次渲染
|
||||
const uploadNumber = ref<number>(0); // 上传文件计数器
|
||||
const uploadList = ref<any[]>([]); // 临时上传列表
|
||||
|
||||
watch(
|
||||
currentValue,
|
||||
async (v) => {
|
||||
if (isInnerOperate.value) {
|
||||
isInnerOperate.value = false;
|
||||
return;
|
||||
}
|
||||
let value: string | string[] = [];
|
||||
if (v) {
|
||||
if (Array.isArray(v)) {
|
||||
value = v;
|
||||
} else {
|
||||
value.push(v);
|
||||
}
|
||||
fileList.value = value
|
||||
.map((item, i) => {
|
||||
if (item && isString(item)) {
|
||||
return {
|
||||
id: `${-i}`,
|
||||
name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)),
|
||||
status: 'finished',
|
||||
url: item,
|
||||
} as UploadFileInfo;
|
||||
} else if (item && isObject(item)) {
|
||||
return item as unknown as UploadFileInfo;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((item) => item !== null) as UploadFileInfo[];
|
||||
}
|
||||
if (!isFirstRender.value) {
|
||||
emit('change', value);
|
||||
isFirstRender.value = false;
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
deep: true,
|
||||
},
|
||||
);
|
||||
|
||||
/** 预览图片 */
|
||||
async function handlePreview(file: UploadFileInfo) {
|
||||
previewImage.value = file.url || '';
|
||||
previewOpen.value = true;
|
||||
previewTitle.value = file.name || '';
|
||||
}
|
||||
|
||||
/** 移除文件 */
|
||||
function handleRemove(options: {
|
||||
file: UploadFileInfo;
|
||||
fileList: UploadFileInfo[];
|
||||
}) {
|
||||
const file = options.file;
|
||||
const index = fileList.value.findIndex((item) => item.id === file.id);
|
||||
if (index !== -1) {
|
||||
fileList.value.splice(index, 1);
|
||||
const value = getValue();
|
||||
isInnerOperate.value = true;
|
||||
emit('update:value', value);
|
||||
emit('update:modelValue', value);
|
||||
emit('change', value);
|
||||
emit('delete', file);
|
||||
}
|
||||
}
|
||||
|
||||
/** 上传前校验 */
|
||||
function beforeUpload(options: {
|
||||
file: UploadFileInfo;
|
||||
fileList: UploadFileInfo[];
|
||||
}) {
|
||||
const file = options.file.file as File;
|
||||
|
||||
// 检查文件数量限制
|
||||
if (fileList.value.length >= props.maxNumber) {
|
||||
message.error($t('ui.upload.maxNumber', [props.maxNumber]));
|
||||
return false;
|
||||
}
|
||||
|
||||
const { maxSize, accept } = props;
|
||||
const isAct = checkImgType(file, accept);
|
||||
if (!isAct) {
|
||||
message.error($t('ui.upload.acceptUpload', [accept]));
|
||||
isActMsg.value = false;
|
||||
// 防止弹出多个错误提示
|
||||
setTimeout(() => (isActMsg.value = true), 1000);
|
||||
return false;
|
||||
}
|
||||
const isLt = file.size / 1024 / 1024 > maxSize;
|
||||
if (isLt) {
|
||||
message.error($t('ui.upload.maxSizeMultiple', [maxSize]));
|
||||
isLtMsg.value = false;
|
||||
// 防止弹出多个错误提示
|
||||
setTimeout(() => (isLtMsg.value = true), 1000);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 只有在验证通过后才增加计数器
|
||||
uploadNumber.value++;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 自定义上传 */
|
||||
async function customRequest(options: UploadCustomRequestOptions) {
|
||||
let { api } = props;
|
||||
if (!api || !isFunction(api)) {
|
||||
api = useUpload(props.directory).httpRequest;
|
||||
}
|
||||
try {
|
||||
// 上传文件
|
||||
const progressEvent: AxiosProgressEvent = (e) => {
|
||||
const percent = Math.trunc((e.loaded / e.total!) * 100);
|
||||
options.onProgress?.({ percent });
|
||||
};
|
||||
const res = await api?.(options.file.file as File, progressEvent);
|
||||
|
||||
// 处理上传成功后的逻辑
|
||||
handleUploadSuccess(res, options.file);
|
||||
|
||||
options.onFinish();
|
||||
message.success($t('ui.upload.uploadSuccess'));
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
options.onError();
|
||||
handleUploadError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理上传成功 */
|
||||
function handleUploadSuccess(res: any, file: UploadFileInfo) {
|
||||
// 删除临时文件
|
||||
const index = fileList.value?.findIndex((item) => item.name === file.name);
|
||||
if (index !== -1) {
|
||||
fileList.value?.splice(index!, 1);
|
||||
}
|
||||
|
||||
// 添加到临时上传列表
|
||||
const fileUrl = res?.url || res?.data || res;
|
||||
uploadList.value.push({
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
url: fileUrl,
|
||||
status: 'finished',
|
||||
});
|
||||
|
||||
// 检查是否所有文件都上传完成
|
||||
if (uploadList.value.length >= uploadNumber.value) {
|
||||
fileList.value?.push(...uploadList.value);
|
||||
uploadList.value = [];
|
||||
uploadNumber.value = 0;
|
||||
|
||||
// 更新值
|
||||
const value = getValue();
|
||||
isInnerOperate.value = true;
|
||||
emit('update:value', value);
|
||||
emit('update:modelValue', value);
|
||||
emit('change', value);
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理上传错误 */
|
||||
function handleUploadError(error: any) {
|
||||
console.error('上传错误:', error);
|
||||
message.error($t('ui.upload.uploadError'));
|
||||
// 上传失败时减少计数器
|
||||
uploadNumber.value = Math.max(0, uploadNumber.value - 1);
|
||||
}
|
||||
|
||||
/** 获取值 */
|
||||
function getValue() {
|
||||
const list = (fileList.value || [])
|
||||
.filter((item) => item?.status === 'finished')
|
||||
.map((item: any) => {
|
||||
if (item?.response && props?.resultField) {
|
||||
return item?.response;
|
||||
}
|
||||
return item?.url || item?.response?.url || item?.response;
|
||||
});
|
||||
|
||||
// 单个文件的情况,根据输入参数类型决定返回格式
|
||||
if (props.maxNumber === 1) {
|
||||
const singleValue = list.length > 0 ? list[0] : '';
|
||||
// 如果原始值是字符串或 modelValue 是字符串,返回字符串
|
||||
if (
|
||||
isString(props.value) ||
|
||||
(isUsingModelValue.value && isString(props.modelValue))
|
||||
) {
|
||||
return singleValue;
|
||||
}
|
||||
return singleValue;
|
||||
}
|
||||
|
||||
// 多文件情况,根据输入参数类型决定返回格式
|
||||
if (isUsingModelValue.value) {
|
||||
return Array.isArray(props.modelValue) ? list : list.join(',');
|
||||
}
|
||||
|
||||
return Array.isArray(props.value) ? list : list.join(',');
|
||||
}
|
||||
|
||||
/** 处理文件列表变化 */
|
||||
function handleChange() {
|
||||
// 移除操作已经在 handleRemove 中处理
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<NUpload
|
||||
v-bind="$attrs"
|
||||
v-model:file-list="fileList"
|
||||
:accept="getStringAccept"
|
||||
:custom-request="customRequest"
|
||||
:disabled="disabled"
|
||||
list-type="image-card"
|
||||
:max="maxNumber"
|
||||
:multiple="multiple"
|
||||
:show-preview-button="true"
|
||||
:show-remove-button="true"
|
||||
@before-upload="beforeUpload"
|
||||
@change="handleChange"
|
||||
@preview="handlePreview"
|
||||
@remove="handleRemove"
|
||||
>
|
||||
<div
|
||||
v-if="fileList && fileList.length < maxNumber"
|
||||
class="flex flex-col items-center justify-center p-2"
|
||||
>
|
||||
<IconifyIcon icon="lucide:cloud-upload" class="text-2xl" />
|
||||
<div class="mt-2 text-sm">{{ $t('ui.upload.imgUpload') }}</div>
|
||||
</div>
|
||||
</NUpload>
|
||||
<div
|
||||
v-if="showDescription"
|
||||
class="mt-2 flex flex-wrap items-center text-sm text-gray-600"
|
||||
>
|
||||
请上传不超过
|
||||
<div class="text-primary mx-1 font-bold">{{ maxSize }}MB</div>
|
||||
的
|
||||
<div class="text-primary mx-1 font-bold">{{ accept.join('/') }}</div>
|
||||
格式文件
|
||||
</div>
|
||||
<NModal
|
||||
v-model:show="previewOpen"
|
||||
:title="previewTitle"
|
||||
preset="card"
|
||||
class="w-[600px]"
|
||||
>
|
||||
<NImageGroup>
|
||||
<NImage :src="previewImage" alt="" class="w-full" />
|
||||
</NImageGroup>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.n-upload-trigger) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
3
apps/web-naive/src/components/upload/index.ts
Normal file
3
apps/web-naive/src/components/upload/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as FileUpload } from './file-upload.vue';
|
||||
export { default as ImageUpload } from './image-upload.vue';
|
||||
export { default as InputUpload } from './input-upload.vue';
|
||||
81
apps/web-naive/src/components/upload/input-upload.vue
Normal file
81
apps/web-naive/src/components/upload/input-upload.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import type { InputProps } from 'naive-ui';
|
||||
|
||||
import type { FileUploadProps } from './typing';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { NGrid, NGridItem, NInput } from 'naive-ui';
|
||||
|
||||
import FileUpload from './file-upload.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
defaultValue?: string;
|
||||
fileUploadProps?: FileUploadProps;
|
||||
inputProps?: InputProps;
|
||||
inputType?: 'input' | 'textarea';
|
||||
modelValue?: string;
|
||||
textareaProps?: InputProps;
|
||||
}>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'change', payload: string): void;
|
||||
(e: 'update:value', payload: string): void;
|
||||
(e: 'update:modelValue', payload: string): void;
|
||||
}>();
|
||||
|
||||
const modelValue = useVModel(props, 'modelValue', emits, {
|
||||
defaultValue: props.defaultValue,
|
||||
passive: true,
|
||||
});
|
||||
|
||||
function handleReturnText(text: string) {
|
||||
modelValue.value = text;
|
||||
emits('change', modelValue.value);
|
||||
emits('update:value', modelValue.value);
|
||||
emits('update:modelValue', modelValue.value);
|
||||
}
|
||||
|
||||
const inputPropsComputed = computed(() => {
|
||||
return {
|
||||
...props.inputProps,
|
||||
value: modelValue.value as string,
|
||||
};
|
||||
});
|
||||
|
||||
const textareaPropsComputed = computed(() => {
|
||||
return {
|
||||
...props.textareaProps,
|
||||
value: modelValue.value as string,
|
||||
};
|
||||
});
|
||||
|
||||
const fileUploadProps = computed(() => {
|
||||
return {
|
||||
...props.fileUploadProps,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NGrid :cols="24" :x-gap="12">
|
||||
<NGridItem :span="18">
|
||||
<NInput
|
||||
v-if="inputType === 'input'"
|
||||
readonly
|
||||
v-bind="inputPropsComputed"
|
||||
/>
|
||||
<NInput
|
||||
v-else
|
||||
readonly
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
v-bind="textareaPropsComputed"
|
||||
/>
|
||||
</NGridItem>
|
||||
<NGridItem :span="6">
|
||||
<FileUpload v-bind="fileUploadProps" @return-text="handleReturnText" />
|
||||
</NGridItem>
|
||||
</NGrid>
|
||||
</template>
|
||||
39
apps/web-naive/src/components/upload/typing.ts
Normal file
39
apps/web-naive/src/components/upload/typing.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { AxiosResponse } from '@vben/request';
|
||||
|
||||
import type { AxiosProgressEvent } from '#/api/infra/file';
|
||||
|
||||
export enum UploadResultStatus {
|
||||
DONE = 'done',
|
||||
ERROR = 'error',
|
||||
SUCCESS = 'success',
|
||||
UPLOADING = 'uploading',
|
||||
}
|
||||
|
||||
export type UploadListType = 'picture' | 'picture-card' | 'text';
|
||||
|
||||
export interface FileUploadProps {
|
||||
// 根据后缀,或者其他
|
||||
accept?: string[];
|
||||
api?: (
|
||||
file: File,
|
||||
onUploadProgress?: AxiosProgressEvent,
|
||||
) => Promise<AxiosResponse<any>>;
|
||||
// 上传的目录
|
||||
directory?: string;
|
||||
disabled?: boolean;
|
||||
drag?: boolean; // 是否支持拖拽上传
|
||||
helpText?: string;
|
||||
listType?: UploadListType;
|
||||
// 最大数量的文件,Infinity不限制
|
||||
maxNumber?: number;
|
||||
modelValue?: string | string[]; // v-model 支持
|
||||
// 文件最大多少MB
|
||||
maxSize?: number;
|
||||
// 是否支持多选
|
||||
multiple?: boolean;
|
||||
// support xxx.xxx.xx
|
||||
resultField?: string;
|
||||
// 是否显示下面的描述
|
||||
showDescription?: boolean;
|
||||
value?: string | string[];
|
||||
}
|
||||
168
apps/web-naive/src/components/upload/use-upload.ts
Normal file
168
apps/web-naive/src/components/upload/use-upload.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import type { AxiosProgressEvent, InfraFileApi } from '#/api/infra/file';
|
||||
|
||||
import { computed, unref } from 'vue';
|
||||
|
||||
import { useAppConfig } from '@vben/hooks';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { createFile, getFilePresignedUrl, uploadFile } from '#/api/infra/file';
|
||||
import { baseRequestClient } from '#/api/request';
|
||||
|
||||
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
|
||||
|
||||
/**
|
||||
* 上传类型
|
||||
*/
|
||||
enum UPLOAD_TYPE {
|
||||
// 客户端直接上传(只支持S3服务)
|
||||
CLIENT = 'client',
|
||||
// 客户端发送到后端上传
|
||||
SERVER = 'server',
|
||||
}
|
||||
|
||||
export function useUploadType({
|
||||
acceptRef,
|
||||
helpTextRef,
|
||||
maxNumberRef,
|
||||
maxSizeRef,
|
||||
}: {
|
||||
acceptRef: Ref<string[]>;
|
||||
helpTextRef: Ref<string>;
|
||||
maxNumberRef: Ref<number>;
|
||||
maxSizeRef: Ref<number>;
|
||||
}) {
|
||||
// 文件类型限制
|
||||
const getAccept = computed(() => {
|
||||
const accept = unref(acceptRef);
|
||||
if (accept && accept.length > 0) {
|
||||
return accept;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
const getStringAccept = computed(() => {
|
||||
return unref(getAccept)
|
||||
.map((item) => {
|
||||
return item.indexOf('/') > 0 || item.startsWith('.')
|
||||
? item
|
||||
: `.${item}`;
|
||||
})
|
||||
.join(',');
|
||||
});
|
||||
|
||||
// 支持jpg、jpeg、png格式,不超过2M,最多可选择10张图片,。
|
||||
const getHelpText = computed(() => {
|
||||
const helpText = unref(helpTextRef);
|
||||
if (helpText) {
|
||||
return helpText;
|
||||
}
|
||||
const helpTexts: string[] = [];
|
||||
|
||||
const accept = unref(acceptRef);
|
||||
if (accept.length > 0) {
|
||||
helpTexts.push($t('ui.upload.accept', [accept.join(',')]));
|
||||
}
|
||||
|
||||
const maxSize = unref(maxSizeRef);
|
||||
if (maxSize) {
|
||||
helpTexts.push($t('ui.upload.maxSize', [maxSize]));
|
||||
}
|
||||
|
||||
const maxNumber = unref(maxNumberRef);
|
||||
if (maxNumber && maxNumber !== Infinity) {
|
||||
helpTexts.push($t('ui.upload.maxNumber', [maxNumber]));
|
||||
}
|
||||
return helpTexts.join(',');
|
||||
});
|
||||
return { getAccept, getStringAccept, getHelpText };
|
||||
}
|
||||
|
||||
// TODO @芋艿:目前保持和 admin-vue3 一致,后续可能重构
|
||||
export function useUpload(directory?: string) {
|
||||
// 后端上传地址
|
||||
const uploadUrl = getUploadUrl();
|
||||
// 是否使用前端直连上传
|
||||
const isClientUpload =
|
||||
UPLOAD_TYPE.CLIENT === import.meta.env.VITE_UPLOAD_TYPE;
|
||||
// 重写ElUpload上传方法
|
||||
async function httpRequest(
|
||||
file: File,
|
||||
onUploadProgress?: AxiosProgressEvent,
|
||||
) {
|
||||
// 模式一:前端上传
|
||||
if (isClientUpload) {
|
||||
// 1.1 生成文件名称
|
||||
const fileName = await generateFileName(file);
|
||||
// 1.2 获取文件预签名地址
|
||||
const presignedInfo = await getFilePresignedUrl(fileName, directory);
|
||||
// 1.3 上传文件
|
||||
return baseRequestClient
|
||||
.put(presignedInfo.uploadUrl, file, {
|
||||
headers: {
|
||||
'Content-Type': file.type,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
// 1.4. 记录文件信息到后端(异步)
|
||||
createFile0(presignedInfo, file);
|
||||
// 通知成功,数据格式保持与后端上传的返回结果一致
|
||||
return { url: presignedInfo.url };
|
||||
});
|
||||
} else {
|
||||
// 模式二:后端上传
|
||||
return uploadFile({ file, directory }, onUploadProgress);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
uploadUrl,
|
||||
httpRequest,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获得上传 URL
|
||||
*/
|
||||
export function getUploadUrl(): string {
|
||||
return `${apiURL}/infra/file/upload`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文件信息
|
||||
*
|
||||
* @param vo 文件预签名信息
|
||||
* @param file 文件
|
||||
*/
|
||||
function createFile0(
|
||||
vo: InfraFileApi.FilePresignedUrlRespVO,
|
||||
file: File,
|
||||
): InfraFileApi.File {
|
||||
const fileVO = {
|
||||
configId: vo.configId,
|
||||
url: vo.url,
|
||||
path: vo.path,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
};
|
||||
createFile(fileVO);
|
||||
return fileVO;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文件名称(使用算法SHA256)
|
||||
*
|
||||
* @param file 要上传的文件
|
||||
*/
|
||||
async function generateFileName(file: File) {
|
||||
// // 读取文件内容
|
||||
// const data = await file.arrayBuffer();
|
||||
// const wordArray = CryptoJS.lib.WordArray.create(data);
|
||||
// // 计算SHA256
|
||||
// const sha256 = CryptoJS.SHA256(wordArray).toString();
|
||||
// // 拼接后缀
|
||||
// const ext = file.name.slice(Math.max(0, file.name.lastIndexOf('.')));
|
||||
// return `${sha256}${ext}`;
|
||||
return file.name;
|
||||
}
|
||||
Reference in New Issue
Block a user