feat: add upload

This commit is contained in:
xingyu4j
2025-10-16 18:03:33 +08:00
parent d92cd2e48e
commit 4319b4ec26
8 changed files with 1243 additions and 0 deletions

View 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 错误
- ✅ 添加完整的文档说明

View 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>

View 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);
}

View 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>

View 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';

View 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>

View 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[];
}

View 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;
}