Files
EasyFlow/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/useChatDocumentUploads.ts
陈子默 0b764b79de feat: 优化聊天附件拖拽上传提示
- 不支持格式改用轻量提示并展示支持范围

- 扩展拖拽区域并增加全区上传覆盖层

- 补充文档和图片格式校验测试
2026-07-31 09:19:11 +08:00

350 lines
9.9 KiB
TypeScript

import type { ChatDocumentAttachment } from './types';
import { computed, ref } from 'vue';
import { uuid } from '@easyflow/utils';
const MAX_DOCUMENTS = 3;
const MAX_TOTAL_BYTES = 30 * 1024 * 1024;
const MAX_OFFICE_BYTES = 20 * 1024 * 1024;
const MAX_EXCEL_BYTES = 10 * 1024 * 1024;
const MAX_TEXT_BYTES = 5 * 1024 * 1024;
const POLL_INTERVAL_MS = 750;
const MAX_POLL_ATTEMPTS = 80;
export const CHAT_DOCUMENT_SUPPORTED_FORMATS =
'PDF、Word、PPT、Excel、TXT、Markdown';
const SUPPORTED_EXTENSIONS = new Set([
'doc',
'docx',
'md',
'pdf',
'ppt',
'pptx',
'txt',
'xls',
'xlsx',
]);
const EXCEL_EXTENSIONS = new Set(['xls', 'xlsx']);
const TEXT_EXTENSIONS = new Set(['md', 'txt']);
export interface ChatDocumentUploadContext {
agentId: string;
mode: 'DRAFT' | 'FORMAL';
sessionId: string;
}
export interface ChatDocumentUploadView extends ChatDocumentAttachment {
expiresAt?: string;
status: 'error' | 'reading' | 'ready' | 'uploading';
uploadId: string;
}
interface RequestResult<T = any> {
data: T;
errorCode: number;
message?: string;
}
export interface ChatDocumentUploadApi {
delete: (uploadId: string) => Promise<RequestResult<void>>;
retry: (uploadId: string) => Promise<RequestResult<ChatDocumentUploadView>>;
status: (uploadId: string) => Promise<RequestResult<ChatDocumentUploadView>>;
upload: (
file: File,
context: ChatDocumentUploadContext,
uploadId: string,
) => Promise<RequestResult<ChatDocumentUploadView>>;
}
export interface ChatDocumentAddFilesResult {
rejectedCount: number;
unsupportedCount: number;
}
interface LocalDocument extends ChatDocumentAttachment {
file?: File;
}
function createLocalId() {
return `agent-document-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
}
function createUploadId() {
return uuid().replaceAll('-', '');
}
function extensionOf(file: File) {
return file.name.includes('.')
? file.name.split('.').pop()?.toLowerCase() || ''
: '';
}
function maxBytes(extension: string) {
if (EXCEL_EXTENSIONS.has(extension)) return MAX_EXCEL_BYTES;
if (TEXT_EXTENSIONS.has(extension)) return MAX_TEXT_BYTES;
return MAX_OFFICE_BYTES;
}
function sizeLimitMessage(extension: string) {
if (EXCEL_EXTENSIONS.has(extension)) {
return 'Excel 文档不能超过 10 MiB';
}
if (TEXT_EXTENSIONS.has(extension)) {
return '文本文件不能超过 5 MiB';
}
return '单份文档不能超过 20 MiB';
}
function errorMessage(error: unknown) {
const candidate = error as any;
return (
candidate?.response?.data?.message || candidate?.message || '文档上传失败'
);
}
function displayStatus(
value?: string,
): NonNullable<ChatDocumentAttachment['status']> {
const status = String(value || '').toUpperCase();
if (status === 'READY') return 'ready';
if (status === 'FAILED' || status === 'READ_FAILED' || status === 'EXPIRED') {
return 'error';
}
if (status === 'UPLOADING') return 'uploading';
return 'reading';
}
function applyServerView(target: LocalDocument, view: ChatDocumentUploadView) {
const status = displayStatus(view.status);
Object.assign(target, view, {
error: view.error || (view as any).errorMessage,
file: target.file,
localId: target.localId,
status,
});
return status;
}
function wait(milliseconds: number) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
export function createChatDocumentUploads(api: ChatDocumentUploadApi) {
const items = ref<LocalDocument[]>([]);
let lifecycle = 0;
const readyItems = computed(() =>
items.value.filter(
(item): item is ChatDocumentUploadView & LocalDocument =>
item.status === 'ready' && Boolean(item.uploadId),
),
);
const uploadIds = computed(() =>
readyItems.value.map((item) => item.uploadId),
);
const processing = computed(() =>
items.value.some(
(item) => item.status === 'uploading' || item.status === 'reading',
),
);
async function poll(item: LocalDocument, expectedLifecycle = lifecycle) {
if (!item.uploadId) return;
for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt++) {
await wait(POLL_INTERVAL_MS);
if (expectedLifecycle !== lifecycle || !items.value.includes(item)) {
return;
}
try {
const response = await api.status(item.uploadId);
if (response.errorCode !== 0 || !response.data) {
throw new Error(response.message || '文档读取状态查询失败');
}
applyServerView(item, response.data);
if (item.status === 'ready' || item.status === 'error') {
return;
}
} catch (error) {
item.status = 'error';
item.error = errorMessage(error);
return;
}
}
item.status = 'error';
item.error = '文档读取时间较长,请重试状态';
}
async function upload(
item: LocalDocument,
context: ChatDocumentUploadContext,
) {
if (!item.file) return;
item.status = 'uploading';
item.error = undefined;
const currentLifecycle = lifecycle;
try {
item.uploadId ||= createUploadId();
const response = await api.upload(item.file, context, item.uploadId);
if (response.errorCode !== 0 || !response.data) {
throw new Error(response.message || '文档上传失败');
}
if (currentLifecycle !== lifecycle || !items.value.includes(item)) {
await api.delete(response.data.uploadId);
return;
}
const status = applyServerView(item, response.data);
if (status !== 'ready' && status !== 'error') {
await poll(item, currentLifecycle);
}
} catch (error) {
if (items.value.includes(item)) {
item.status = 'error';
item.error = errorMessage(error);
}
}
}
async function addFiles(files: File[], context: ChatDocumentUploadContext) {
const supportedFiles: File[] = [];
let unsupportedCount = 0;
for (const file of files) {
if (SUPPORTED_EXTENSIONS.has(extensionOf(file))) {
supportedFiles.push(file);
} else {
unsupportedCount++;
}
}
const available = Math.max(0, MAX_DOCUMENTS - items.value.length);
const accepted = supportedFiles.slice(0, available);
const rejectedCount = Math.max(0, supportedFiles.length - accepted.length);
const currentTotal = items.value.reduce(
(total, item) => total + Number(item.size || 0),
0,
);
let addedBytes = 0;
const uploads: Promise<void>[] = [];
for (const file of accepted) {
const extension = extensionOf(file);
const item: LocalDocument = {
file,
localId: createLocalId(),
mimeType: file.type,
name: file.name || '文档',
size: file.size,
status: 'uploading',
};
items.value.push(item);
const trackedItem = items.value[items.value.length - 1];
if (!trackedItem) continue;
if (file.size > maxBytes(extension)) {
trackedItem.status = 'error';
trackedItem.error = sizeLimitMessage(extension);
continue;
}
if (currentTotal + addedBytes + file.size > MAX_TOTAL_BYTES) {
trackedItem.status = 'error';
trackedItem.error = '本轮文档总大小不能超过 30 MiB';
continue;
}
addedBytes += file.size;
uploads.push(upload(trackedItem, context));
}
await Promise.all(uploads);
return {
rejectedCount,
unsupportedCount,
} satisfies ChatDocumentAddFilesResult;
}
async function remove(item: ChatDocumentAttachment) {
const index = items.value.findIndex(
(candidate) =>
candidate.localId === item.localId ||
(candidate.uploadId && candidate.uploadId === item.uploadId),
);
if (index === -1) return;
const selected = items.value[index];
if (selected?.uploadId) {
const response = await api.delete(selected.uploadId);
if (response.errorCode !== 0) {
throw new Error(response.message || '文档删除失败');
}
}
items.value.splice(index, 1);
}
async function retry(
item: ChatDocumentAttachment,
context: ChatDocumentUploadContext,
) {
const found = items.value.find(
(candidate) =>
candidate.localId === item.localId ||
(candidate.uploadId && candidate.uploadId === item.uploadId),
);
if (!found) return;
found.error = undefined;
if (!found.attachmentRef && found.file) {
await upload(found, context);
return;
}
if (!found.uploadId && found.file) {
await upload(found, context);
return;
}
if (!found.uploadId) return;
try {
const statusResponse = await api.status(found.uploadId);
if (statusResponse.errorCode !== 0 || !statusResponse.data) {
throw new Error(statusResponse.message || '文档状态查询失败');
}
const status = applyServerView(found, statusResponse.data);
if (status === 'ready') return;
if (status === 'error') {
const retryResponse = await api.retry(found.uploadId);
if (retryResponse.errorCode !== 0 || !retryResponse.data) {
throw new Error(retryResponse.message || '文档读取重试失败');
}
applyServerView(found, retryResponse.data);
}
if ((found.status as ChatDocumentAttachment['status']) !== 'ready') {
await poll(found);
}
} catch (error) {
found.status = 'error';
found.error = errorMessage(error);
}
}
function restore(restored: ChatDocumentUploadView[] = []) {
clear();
const currentLifecycle = lifecycle;
items.value = restored.slice(0, MAX_DOCUMENTS).map((item) => ({
...item,
status: displayStatus(item.status),
}));
for (const item of items.value) {
if (item.status === 'reading' || item.status === 'uploading') {
void poll(item, currentLifecycle);
}
}
}
function clear() {
lifecycle++;
items.value = [];
}
return {
addFiles,
clear,
items,
processing,
readyItems,
remove,
restore,
retry,
uploadIds,
};
}