feat: 添加 PFileUpload 组件及相关类型定义,支持文件上传功能
This commit is contained in:
3
.npmrc
3
.npmrc
@ -1 +1,4 @@
|
||||
registry=https://nexus.oo1.dev/repository/npm/
|
||||
use-node-version=22.12.0
|
||||
|
||||
shamefully-hoist=true
|
@ -16,6 +16,7 @@ import { addAsteriskPlugin } from './formkit.config.plugin.addAsteriskPlugin';
|
||||
import { debugPlugin } from './formkit.config.plugin.debug';
|
||||
import { PDatePicker } from '@/__fk-inputs__/inputs/p-date-picker';
|
||||
import { PCascadeSelect } from '@/__fk-inputs__/inputs/p-cascade-select';
|
||||
import { PFileUpload } from '@/__fk-inputs__/inputs/p-file-upload';
|
||||
|
||||
const plugins: FormKitPlugin[] = [
|
||||
// createLibraryPlugin(fkLibrary),
|
||||
@ -30,6 +31,7 @@ const plugins: FormKitPlugin[] = [
|
||||
PSelect,
|
||||
PDatePicker,
|
||||
PCascadeSelect,
|
||||
PFileUpload,
|
||||
}),
|
||||
// createLibraryPlugin(
|
||||
// {
|
||||
|
11
package.json
11
package.json
@ -16,18 +16,19 @@
|
||||
"@formkit/icons": "^1.6.9",
|
||||
"@formkit/pro": "^0.127.15",
|
||||
"@formkit/themes": "^1.6.9",
|
||||
"@formkit/vue": "^1.6.9",
|
||||
"@formkit/vue": "1.6.10-fix.202412201106",
|
||||
"@formkit/zod": "^1.6.9",
|
||||
"@headlessui/vue": "^1.7.23",
|
||||
"@primevue/forms": "^4.2.5",
|
||||
"@primevue/themes": "^4.2.5",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"axios": "^1.7.9",
|
||||
"dayjs": "^1.11.13",
|
||||
"i18next": "^24.2.0",
|
||||
"postcss": "^8.4.49",
|
||||
"primeicons": "^7.0.0",
|
||||
"primelocale": "^1.2.2",
|
||||
"primevue": "^4.2.5",
|
||||
"primevue": "4.2.6-fix.202412310330",
|
||||
"sweetalert2": "^11.15.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"utils4u": "^2.19.2",
|
||||
@ -39,9 +40,9 @@
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"@vitejs/plugin-vue-jsx": "^4.1.1",
|
||||
"typescript": "~5.7.2",
|
||||
"unocss": "^0.65.2",
|
||||
"unocss": "^0.65.3",
|
||||
"unplugin-vue-components": "^0.28.0",
|
||||
"vite": "^6.0.5",
|
||||
"vue-tsc": "^2.1.10"
|
||||
"vite": "^6.0.6",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
}
|
723
pnpm-lock.yaml
generated
723
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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>
|
202
src/__fk-inputs__/components/file-upload/file-upload.vue
Normal file
202
src/__fk-inputs__/components/file-upload/file-upload.vue
Normal 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>
|
28
src/__fk-inputs__/components/file-upload/types.ts
Normal file
28
src/__fk-inputs__/components/file-upload/types.ts
Normal 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;
|
47
src/__fk-inputs__/inputs/p-file-upload.tsx
Normal file
47
src/__fk-inputs__/inputs/p-file-upload.tsx
Normal 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;
|
||||
};
|
||||
}
|
||||
}
|
@ -4,6 +4,7 @@ import { text } from '@formkit/inputs';
|
||||
import Swal from 'sweetalert2';
|
||||
import dayjs from 'dayjs';
|
||||
import { arrayToTree } from 'utils4u/array';
|
||||
import { CustomRequest } from '@/__fk-inputs__/components/file-upload/types';
|
||||
|
||||
async function submit(formData: Record<string, any>, formNode: FormKitNode) {
|
||||
console.group('submit');
|
||||
@ -70,6 +71,23 @@ const promiseCascadeOptions = new Promise<typeof K_FLAT_TREE>((resolve) => {
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
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>
|
||||
|
||||
<template>
|
||||
@ -112,6 +130,20 @@ const promiseCascadeOptions = new Promise<typeof K_FLAT_TREE>((resolve) => {
|
||||
validation="required"
|
||||
>
|
||||
</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
|
||||
type="PInputPassword"
|
||||
name="PInputPassword"
|
||||
|
Reference in New Issue
Block a user