feat: 添加 PFileUpload 组件及相关类型定义,支持文件上传功能
All checks were successful
/ test (push) Successful in 3s
/ surge (push) Successful in 55s

This commit is contained in:
严浩
2024-12-31 15:23:51 +08:00
parent ab39617e7b
commit 6a1a7d2d47
9 changed files with 669 additions and 457 deletions

5
.npmrc
View File

@ -1 +1,4 @@
shamefully-hoist=true registry=https://nexus.oo1.dev/repository/npm/
use-node-version=22.12.0
shamefully-hoist=true

View File

@ -16,6 +16,7 @@ import { addAsteriskPlugin } from './formkit.config.plugin.addAsteriskPlugin';
import { debugPlugin } from './formkit.config.plugin.debug'; import { debugPlugin } from './formkit.config.plugin.debug';
import { PDatePicker } from '@/__fk-inputs__/inputs/p-date-picker'; import { PDatePicker } from '@/__fk-inputs__/inputs/p-date-picker';
import { PCascadeSelect } from '@/__fk-inputs__/inputs/p-cascade-select'; import { PCascadeSelect } from '@/__fk-inputs__/inputs/p-cascade-select';
import { PFileUpload } from '@/__fk-inputs__/inputs/p-file-upload';
const plugins: FormKitPlugin[] = [ const plugins: FormKitPlugin[] = [
// createLibraryPlugin(fkLibrary), // createLibraryPlugin(fkLibrary),
@ -30,6 +31,7 @@ const plugins: FormKitPlugin[] = [
PSelect, PSelect,
PDatePicker, PDatePicker,
PCascadeSelect, PCascadeSelect,
PFileUpload,
}), }),
// createLibraryPlugin( // createLibraryPlugin(
// { // {

View File

@ -16,18 +16,19 @@
"@formkit/icons": "^1.6.9", "@formkit/icons": "^1.6.9",
"@formkit/pro": "^0.127.15", "@formkit/pro": "^0.127.15",
"@formkit/themes": "^1.6.9", "@formkit/themes": "^1.6.9",
"@formkit/vue": "^1.6.9", "@formkit/vue": "1.6.10-fix.202412201106",
"@formkit/zod": "^1.6.9", "@formkit/zod": "^1.6.9",
"@headlessui/vue": "^1.7.23", "@headlessui/vue": "^1.7.23",
"@primevue/forms": "^4.2.5", "@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5", "@primevue/themes": "^4.2.5",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"axios": "^1.7.9",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"i18next": "^24.2.0", "i18next": "^24.2.0",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primelocale": "^1.2.2", "primelocale": "^1.2.2",
"primevue": "^4.2.5", "primevue": "4.2.6-fix.202412310330",
"sweetalert2": "^11.15.3", "sweetalert2": "^11.15.3",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"utils4u": "^2.19.2", "utils4u": "^2.19.2",
@ -39,9 +40,9 @@
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"@vitejs/plugin-vue-jsx": "^4.1.1", "@vitejs/plugin-vue-jsx": "^4.1.1",
"typescript": "~5.7.2", "typescript": "~5.7.2",
"unocss": "^0.65.2", "unocss": "^0.65.3",
"unplugin-vue-components": "^0.28.0", "unplugin-vue-components": "^0.28.0",
"vite": "^6.0.5", "vite": "^6.0.6",
"vue-tsc": "^2.1.10" "vue-tsc": "^2.2.0"
} }
} }

723
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,74 @@
<script setup lang="ts">
import TimesIcon from '@primevue/icons/times';
import type { FileUploadStatus } from './types';
import { computed } from 'vue';
import type { BadgeProps } from 'primevue/badge';
const props = defineProps<{
url: string;
filename: string;
status?: FileUploadStatus;
progress?: number;
}>();
defineEmits<{
remove: () => void;
}>();
const bageValue = computed(() => {
switch (props.status) {
case 'pending':
return '待上传';
case 'uploading':
return '上传中';
case 'failed':
return '上传失败';
case 'uploaded':
return '已上传';
default:
return '未知状态';
}
});
const bageSeverity = computed<BadgeProps['severity']>(() => {
switch (props.status) {
case 'pending':
return 'info';
case 'uploading':
return 'warn';
case 'failed':
return 'danger';
case 'uploaded':
return 'success';
default:
return 'contrast';
}
});
</script>
<template>
<div class="border p-2 rounded-md flex flex-wrap items-center gap-2">
<img
alt=""
:src="url"
class="w-10 h-10 shrink-0"
/>
<div class="break-all break-anywhere">{{ filename }}</div>
<Badge
:value="bageValue"
:severity="bageSeverity"
/>
<ProgressBar
v-if="status === 'uploading'"
:value="progress"
/>
<Button
text
:rounded="true"
severity="danger"
class="ml-auto"
@click="$emit('remove')"
>
<TimesIcon aria-hidden="true" />
</Button>
</div>
</template>

View File

@ -0,0 +1,202 @@
<script setup lang="ts">
import type { FormKitFrameworkContext } from '@formkit/core';
import FileUpload, { type FileUploadUploaderEvent } from 'primevue/fileupload';
import { computed, onMounted, useTemplateRef } from 'vue';
import FileUploadItem from './file-upload-item.vue';
import type { CustomRequest, FileExt, FileUploadInst, PropFilesToValue, PropValueToFiles } from './types';
const props = defineProps<{
context: FormKitFrameworkContext & {
fileLimit?: number;
maxFileSize?: number;
customRequest?: CustomRequest;
valueToFiles?: PropValueToFiles;
filesToValue?: PropFilesToValue;
autoUpload?: boolean;
};
}>();
const formkitContext = props.context;
const customRequest = props.context.customRequest;
const fileUploadRef = useTemplateRef<FileUploadInst>('fileUploadRef');
const cmpt_disabled = computed(() => {
if (fileUploadRef.value) {
// 有上传失败的文件
if (fileUploadRef.value.uploadedFiles.some((f) => f.status === 'failed')) {
return true;
}
// 已上传文件数量超过限制(这里是大于*等于*
if (fileUploadRef.value.uploadedFileCount >= (formkitContext.fileLimit ?? Infinity)) {
return true;
}
// 已上传和待上传文件数量超过限制(这里是*大于*
const uploaded_and_pending_count = fileUploadRef.value.uploadedFileCount + fileUploadRef.value.files.length;
if (uploaded_and_pending_count > (formkitContext.fileLimit ?? Infinity)) {
return true;
}
}
return false;
});
const cmpt_showUploadButton = computed(() => {
if (fileUploadRef.value?.files?.length) {
return true;
}
return false;
});
const changeModelValue = () => {
const uploadedFiles = fileUploadRef.value!.uploadedFiles.filter((f) => f.status === 'uploaded');
if (!formkitContext.filesToValue) {
console.warn('[FileUpload] filesToValue is not defined');
return;
}
formkitContext.node.input(formkitContext.filesToValue(uploadedFiles));
};
const changeUploadedFiles = () => {
if (!formkitContext.valueToFiles) {
console.warn('[FileUpload] valueToFiles is not defined');
return;
}
try {
const files = formkitContext.valueToFiles(formkitContext._value);
fileUploadRef.value!.uploadedFiles = files.map((f) => ({
name: f.name,
url: f.url,
status: f.status || 'uploaded',
progress: f.progress || 100,
}));
fileUploadRef.value!.uploadedFileCount = files.length;
} catch (error) {
console.warn('[FileUpload] valueToFiles error:', error);
}
};
onMounted(() => {
changeUploadedFiles();
});
const onUploader = (event: FileUploadUploaderEvent) => {
if (!customRequest) {
console.warn('[FileUpload] customRequest is not defined');
return;
}
const files = event.files as FileExt[];
for (const file of files) {
fileUploadRef.value!.uploadedFiles.push({
rawFile: file,
name: file.name,
url: '',
status: 'uploading',
progress: 0,
});
const fileItem = fileUploadRef.value!.uploadedFiles[fileUploadRef.value!.uploadedFiles.length - 1];
customRequest({
file,
onProgress: (percent) => {
fileItem.progress = percent;
},
})
.then((result) => {
fileItem.status = 'uploaded';
fileItem.url = result.url;
changeModelValue();
})
.catch(() => {
fileItem.status = 'failed';
});
}
};
</script>
<template>
<FileUpload
ref="fileUploadRef"
:auto="formkitContext.autoUpload"
:disabled="cmpt_disabled"
:showUploadButton="cmpt_showUploadButton"
:showCancelButton="false"
:customUpload="true"
mode="advanced"
:multiple="true"
accept="image/*"
:maxFileSize="formkitContext.maxFileSize"
invalidFileSizeMessage="文件 {0} 大小超过限制 {1}"
:fileLimit="formkitContext.fileLimit"
invalidFileLimitMessage="最多只能上传 {0} 个文件,请移除多余文件后点击上传"
@uploader="onUploader"
:chooseButtonProps="{ size: 'small' }"
:uploadButtonProps="{ size: 'small', severity: 'secondary' }"
:cancelButtonProps="{ size: 'small', severity: 'secondary' }"
>
<template #empty>
<Message
size="small"
severity="info"
>请上传图片</Message
>
</template>
<template #content="{ messages, removeFileCallback, removeUploadedFileCallback }">
<Message
size="small"
v-for="msg of messages"
closable
@close="fileUploadRef!.messages = []"
:key="msg"
severity="error"
>{{ msg }}</Message
>
<!-- 已上传列表上传中上传成功上传失败 -->
<template
v-for="(file, index) of fileUploadRef?.uploadedFiles"
:key="file.name + file.url"
>
<FileUploadItem
:url="file.url || file.rawFile?.objectURL || ''"
:filename="file.name || file.url || '未知文件'"
@remove="
() => {
fileUploadRef!.uploadedFileCount--;
removeUploadedFileCallback(index);
}
"
:status="file.status"
:progress="file.progress"
/>
</template>
<!-- 待上传列表 -->
<template
v-for="(file, index) of fileUploadRef?.files"
:key="file.name + file.type + file.size"
>
<FileUploadItem
:url="file.objectURL"
:filename="file.name"
@remove="removeFileCallback(index)"
status="pending"
/>
</template>
</template>
</FileUpload>
</template>
<style>
.p-floatlabel:has(.p-fileupload) label {
top: var(--p-floatlabel-over-active-top);
transform: translateY(0);
font-size: var(--p-floatlabel-active-font-size);
font-weight: var(--p-floatlabel-label-active-font-weight);
}
.p-fileupload-content .p-progressbar {
--p-fileupload-progressbar-height: 1rem;
}
</style>

View File

@ -0,0 +1,28 @@
import type { FileUploadState } from 'primevue/fileupload';
export interface FileExt extends File {
objectURL: string;
}
export type FileUploadStatus = 'pending' | 'uploading' | 'uploaded' | 'failed';
export type UploadedFileInfo = {
rawFile?: FileExt;
name?: string;
url?: string;
status?: FileUploadStatus;
progress?: number;
};
export interface FileUploadInst extends FileUploadState {
files: FileExt[];
uploadedFiles: UploadedFileInfo[];
chooseDisabled?: boolean;
}
export type CustomRequest = (options: {
file: File;
onProgress: (percent: number) => void;
}) => Promise<{ url: string }>;
export type PropValueToFiles = (value: unknown) => UploadedFileInfo[];
export type PropFilesToValue = (filelist: UploadedFileInfo[]) => unknown;

View File

@ -0,0 +1,47 @@
import type { FormKitTypeDefinition } from '@formkit/core';
import type { FormKitInputs } from '@formkit/inputs';
import { createSection, label, outer } from '@formkit/inputs';
import { markRaw } from 'vue';
import FileUploadComponent from '../components/file-upload/file-upload.vue';
import type { CustomRequest, PropFilesToValue, PropValueToFiles } from '../components/file-upload/types';
import { floatLabel } from '../sections/floatLabel';
import { help } from '../sections/help';
import { messages } from '../sections/messages';
const input = createSection('input', () => ({
$cmp: markRaw(FileUploadComponent) as never,
bind: '$attrs',
props: {
context: '$node.context',
},
}));
export const PFileUpload: FormKitTypeDefinition = {
type: 'input',
schema: outer(
floatLabel(
input(), //
label('$label'),
),
help('$help'),
messages(),
),
props: ['fileLimit', 'maxFileSize', 'customRequest', 'valueToFiles', 'filesToValue', 'autoUpload'],
schemaMemoKey: 'ihcxd4qdgh7', // Math.random().toString(36).substring(2, 15)
};
declare module '@formkit/inputs' {
// https://formkit.com/essentials/custom-inputs#typescript-support
interface FormKitInputProps<Props extends FormKitInputs<Props>> {
PFileUpload: {
type: 'PFileUpload';
value?: unknown;
fileLimit?: number;
maxFileSize?: number;
customRequest?: CustomRequest;
valueToFiles?: PropValueToFiles;
filesToValue?: PropFilesToValue;
autoUpload?: boolean;
};
}
}

View File

@ -4,6 +4,7 @@ import { text } from '@formkit/inputs';
import Swal from 'sweetalert2'; import Swal from 'sweetalert2';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { arrayToTree } from 'utils4u/array'; import { arrayToTree } from 'utils4u/array';
import { CustomRequest } from '@/__fk-inputs__/components/file-upload/types';
async function submit(formData: Record<string, any>, formNode: FormKitNode) { async function submit(formData: Record<string, any>, formNode: FormKitNode) {
console.group('submit'); console.group('submit');
@ -70,6 +71,23 @@ const promiseCascadeOptions = new Promise<typeof K_FLAT_TREE>((resolve) => {
await new Promise(r => setTimeout(r, 1000)) await new Promise(r => setTimeout(r, 1000))
return K_OPTIONS; return K_OPTIONS;
} */ } */
import axios from 'axios';
const customRequest: CustomRequest = (async ({ file, onProgress, }) => {
const formData = new FormData();
formData.append('file', file);
return axios
.post('https://jsonplaceholder.typicode.com/posts', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => {
onProgress(Math.round((e.loaded * 100) / (e.total || 1)));
},
})
.then(() => {
// onProgress(100);
return { url: 'https://picsum.photos/200/300' };
});
});
</script> </script>
<template> <template>
@ -112,6 +130,20 @@ const promiseCascadeOptions = new Promise<typeof K_FLAT_TREE>((resolve) => {
validation="required" validation="required"
> >
</FormKit> </FormKit>
<div class="border border-dashed border-gray-300 p-4 rounded-md">
<FormKit
type="PFileUpload"
name="PFileUpload"
label="文件上传"
:maxFileSize="1024 * 1024 * 2"
:customRequest
:fileLimit="99"
:autoUpload="true"
:filesToValue="(files) => JSON.stringify(files.map(f => ({ name: f.name, url: f.url })))"
:valueToFiles="(value) => JSON.parse(value as string)"
value='[{"name":"2KB图片_副本.jpeg","url":"https://picsum.photos/200/300"}]'
/>
</div>
<FormKit <FormKit
type="PInputPassword" type="PInputPassword"
name="PInputPassword" name="PInputPassword"