diff --git a/apps/web-naive/src/components/upload/README.md b/apps/web-naive/src/components/upload/README.md new file mode 100644 index 000000000..3696f0dc5 --- /dev/null +++ b/apps/web-naive/src/components/upload/README.md @@ -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 + + + + + +``` + +### FileUpload 文件上传 + +```vue + + + + + +``` + +### InputUpload 输入框上传 + +```vue + + + + + +``` + +## 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 错误 +- ✅ 添加完整的文档说明 + diff --git a/apps/web-naive/src/components/upload/file-upload.vue b/apps/web-naive/src/components/upload/file-upload.vue new file mode 100644 index 000000000..c76dccfbc --- /dev/null +++ b/apps/web-naive/src/components/upload/file-upload.vue @@ -0,0 +1,346 @@ + + + + + + + + + 点击或拖拽文件到此区域上传 + + 支持{{ accept.join('/') }}格式文件,不超过{{ maxSize }}MB + + + + + + + + {{ $t('ui.upload.upload') }} + + + + 请上传不超过 + {{ maxSize }}MB + 的 + {{ accept.join('/') }} + 格式文件 + + + + + diff --git a/apps/web-naive/src/components/upload/helper.ts b/apps/web-naive/src/components/upload/helper.ts new file mode 100644 index 000000000..27313cea6 --- /dev/null +++ b/apps/web-naive/src/components/upload/helper.ts @@ -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); +} diff --git a/apps/web-naive/src/components/upload/image-upload.vue b/apps/web-naive/src/components/upload/image-upload.vue new file mode 100644 index 000000000..e96fb96ad --- /dev/null +++ b/apps/web-naive/src/components/upload/image-upload.vue @@ -0,0 +1,336 @@ + + + + + + + + {{ $t('ui.upload.imgUpload') }} + + + + 请上传不超过 + {{ maxSize }}MB + 的 + {{ accept.join('/') }} + 格式文件 + + + + + + + + + + diff --git a/apps/web-naive/src/components/upload/index.ts b/apps/web-naive/src/components/upload/index.ts new file mode 100644 index 000000000..14e57fede --- /dev/null +++ b/apps/web-naive/src/components/upload/index.ts @@ -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'; diff --git a/apps/web-naive/src/components/upload/input-upload.vue b/apps/web-naive/src/components/upload/input-upload.vue new file mode 100644 index 000000000..9cfb03456 --- /dev/null +++ b/apps/web-naive/src/components/upload/input-upload.vue @@ -0,0 +1,81 @@ + + + + + + + + + + + + + diff --git a/apps/web-naive/src/components/upload/typing.ts b/apps/web-naive/src/components/upload/typing.ts new file mode 100644 index 000000000..ada73d244 --- /dev/null +++ b/apps/web-naive/src/components/upload/typing.ts @@ -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>; + // 上传的目录 + 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[]; +} diff --git a/apps/web-naive/src/components/upload/use-upload.ts b/apps/web-naive/src/components/upload/use-upload.ts new file mode 100644 index 000000000..2f46f9e98 --- /dev/null +++ b/apps/web-naive/src/components/upload/use-upload.ts @@ -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; + helpTextRef: Ref; + maxNumberRef: Ref; + maxSizeRef: Ref; +}) { + // 文件类型限制 + 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; +}
点击或拖拽文件到此区域上传
+ 支持{{ accept.join('/') }}格式文件,不超过{{ maxSize }}MB +