Files
Yi.Admin/Yi.Ai.Vue3/src/components/FilesSelect/index.vue
2025-12-14 18:55:46 +08:00

727 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 文件上传 -->
<script setup lang="ts">
import type { FileItem } from '@/stores/modules/files';
import { useFileDialog } from '@vueuse/core';
import { ElMessage } from 'element-plus';
import mammoth from 'mammoth';
import * as pdfjsLib from 'pdfjs-dist';
import * as XLSX from 'xlsx';
import Popover from '@/components/Popover/index.vue';
import SvgIcon from '@/components/SvgIcon/index.vue';
import { useFilesStore } from '@/stores/modules/files';
// 配置 PDF.js worker - 使用稳定的 CDN
pdfjsLib.GlobalWorkerOptions.workerSrc = `https://cdn.jsdelivr.net/npm/pdfjs-dist@${pdfjsLib.version}/build/pdf.worker.min.mjs`;
const filesStore = useFilesStore();
// 文件大小限制 3MB
const MAX_FILE_SIZE = 3 * 1024 * 1024;
// 单个文件内容长度限制
const MAX_TEXT_FILE_LENGTH = 50000; // 文本文件最大字符数
const MAX_WORD_LENGTH = 30000; // Word 文档最大字符数
const MAX_EXCEL_ROWS = 100; // Excel 最大行数
const MAX_PDF_PAGES = 10; // PDF 最大页数
// 整个消息总长度限制(所有文件内容加起来,预估 token 安全限制)
// 272000 tokens * 0.55 安全系数 ≈ 150000 字符
const MAX_TOTAL_CONTENT_LENGTH = 150000;
const { reset, open, onChange } = useFileDialog({
// 支持图片、文档、文本文件等
accept: 'image/*,.txt,.log,.csv,.tsv,.md,.markdown,.json,.xml,.yaml,.yml,.toml,.ini,.conf,.config,.properties,.prop,.env,'
+ '.js,.jsx,.ts,.tsx,.vue,.html,.htm,.css,.scss,.sass,.less,.styl,'
+ '.java,.c,.cpp,.h,.hpp,.cs,.py,.rb,.go,.rs,.swift,.kt,.php,.sh,.bash,.zsh,.fish,.bat,.cmd,.ps1,'
+ '.sql,.graphql,.proto,.thrift,'
+ '.dockerfile,.gitignore,.gitattributes,.editorconfig,.npmrc,.nvmrc,'
+ '.sln,.csproj,.vbproj,.fsproj,.props,.targets,'
+ '.xlsx,.xls,.csv,.docx,.pdf',
directory: false,
multiple: true,
});
/**
* 压缩图片
* @param {File} file - 原始图片文件
* @param {number} maxWidth - 最大宽度,默认 1024px
* @param {number} maxHeight - 最大高度,默认 1024px
* @param {number} quality - 压缩质量0-1之间默认 0.8
* @returns {Promise<Blob>} 压缩后的图片 Blob
*/
function compressImage(file: File, maxWidth = 1024, maxHeight = 1024, quality = 0.8): Promise<Blob> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
let width = img.width;
let height = img.height;
// 计算缩放比例
if (width > maxWidth || height > maxHeight) {
const ratio = Math.min(maxWidth / width, maxHeight / height);
width = width * ratio;
height = height * ratio;
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d')!;
ctx.drawImage(img, 0, 0, width, height);
// 转换为 Blob
canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob);
}
else {
reject(new Error('压缩失败'));
}
},
file.type,
quality,
);
};
img.onerror = reject;
img.src = e.target?.result as string;
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
/**
* 将 Blob 转换为 base64 格式
* @param {Blob} blob - 要转换的 Blob 对象
* @returns {Promise<string>} base64 编码的字符串(包含 data:xxx;base64, 前缀)
*/
function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result as string);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
/**
* 读取文本文件内容
* @param {File} file - 文本文件
* @returns {Promise<string>} 文件内容字符串
*/
function readTextFile(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result as string);
};
reader.onerror = reject;
reader.readAsText(file, 'UTF-8');
});
}
/**
* 判断是否为文本文件
* 通过 MIME 类型或文件扩展名判断
* @param {File} file - 要判断的文件
* @returns {boolean} 是否为文本文件
*/
function isTextFile(file: File): boolean {
// 通过 MIME type 判断
if (file.type.startsWith('text/')) {
return true;
}
// 通过扩展名判断(更全面的列表)
const textExtensions = [
// 通用文本
'txt',
'log',
'md',
'markdown',
'rtf',
// 配置文件
'json',
'xml',
'yaml',
'yml',
'toml',
'ini',
'conf',
'config',
'properties',
'prop',
'env',
// 前端
'js',
'jsx',
'ts',
'tsx',
'vue',
'html',
'htm',
'css',
'scss',
'sass',
'less',
'styl',
// 编程语言
'java',
'c',
'cpp',
'h',
'hpp',
'cs',
'py',
'rb',
'go',
'rs',
'swift',
'kt',
'php',
// 脚本
'sh',
'bash',
'zsh',
'fish',
'bat',
'cmd',
'ps1',
// 数据库/API
'sql',
'graphql',
'proto',
'thrift',
// 版本控制/工具
'dockerfile',
'gitignore',
'gitattributes',
'editorconfig',
'npmrc',
'nvmrc',
// .NET 项目文件
'sln',
'csproj',
'vbproj',
'fsproj',
'props',
'targets',
// 数据文件
'csv',
'tsv',
];
const ext = file.name.split('.').pop()?.toLowerCase();
return ext ? textExtensions.includes(ext) : false;
}
/**
* 解析 Excel 文件,提取前 N 行数据转为 CSV 格式
* @param {File} file - Excel 文件 (.xlsx, .xls)
* @returns {Promise<{content: string, totalRows: number, extractedRows: number}>}
* - content: CSV 格式的文本内容
* - totalRows: 文件总行数
* - extractedRows: 实际提取的行数(受 MAX_EXCEL_ROWS 限制)
*/
async function parseExcel(file: File): Promise<{ content: string; totalRows: number; extractedRows: number }> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = new Uint8Array(e.target?.result as ArrayBuffer);
const workbook = XLSX.read(data, { type: 'array' });
let result = '';
let totalRows = 0;
let extractedRows = 0;
workbook.SheetNames.forEach((sheetName, index) => {
const worksheet = workbook.Sheets[sheetName];
// 获取工作表的范围
const range = XLSX.utils.decode_range(worksheet['!ref'] || 'A1');
const sheetTotalRows = range.e.r - range.s.r + 1;
totalRows += sheetTotalRows;
// 限制行数
const rowsToExtract = Math.min(sheetTotalRows, MAX_EXCEL_ROWS);
extractedRows += rowsToExtract;
// 创建新的范围,只包含前 N 行
const limitedRange = {
s: { r: range.s.r, c: range.s.c },
e: { r: range.s.r + rowsToExtract - 1, c: range.e.c },
};
// 提取限制范围内的数据
const limitedData: any[][] = [];
for (let row = limitedRange.s.r; row <= limitedRange.e.r; row++) {
const rowData: any[] = [];
for (let col = limitedRange.s.c; col <= limitedRange.e.c; col++) {
const cellAddress = XLSX.utils.encode_cell({ r: row, c: col });
const cell = worksheet[cellAddress];
rowData.push(cell ? cell.v : '');
}
limitedData.push(rowData);
}
// 转换为 CSV
const csvData = limitedData.map(row => row.join(',')).join('\n');
if (workbook.SheetNames.length > 1) {
result += `=== Sheet: ${sheetName} ===\n`;
}
result += csvData;
if (index < workbook.SheetNames.length - 1) {
result += '\n\n';
}
});
resolve({ content: result, totalRows, extractedRows });
}
catch (error) {
reject(error);
}
};
reader.onerror = reject;
reader.readAsArrayBuffer(file);
});
}
/**
* 解析 Word 文档,提取纯文本内容
* @param {File} file - Word 文档 (.docx)
* @returns {Promise<{content: string, totalLength: number, extracted: boolean}>}
* - content: 提取的文本内容
* - totalLength: 原始文本总长度
* - extracted: 是否被截断(超过 MAX_WORD_LENGTH
*/
async function parseWord(file: File): Promise<{ content: string; totalLength: number; extracted: boolean }> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async (e) => {
try {
const arrayBuffer = e.target?.result as ArrayBuffer;
const result = await mammoth.extractRawText({ arrayBuffer });
const fullText = result.value;
const totalLength = fullText.length;
if (totalLength > MAX_WORD_LENGTH) {
const truncated = fullText.substring(0, MAX_WORD_LENGTH);
resolve({ content: truncated, totalLength, extracted: true });
}
else {
resolve({ content: fullText, totalLength, extracted: false });
}
}
catch (error) {
reject(error);
}
};
reader.onerror = reject;
reader.readAsArrayBuffer(file);
});
}
/**
* 解析 PDF 文件,提取前 N 页的文本内容
* @param {File} file - PDF 文件
* @returns {Promise<{content: string, totalPages: number, extractedPages: number}>}
* - content: 提取的文本内容
* - totalPages: 文件总页数
* - extractedPages: 实际提取的页数(受 MAX_PDF_PAGES 限制)
*/
async function parsePDF(file: File): Promise<{ content: string; totalPages: number; extractedPages: number }> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async (e) => {
try {
const typedArray = new Uint8Array(e.target?.result as ArrayBuffer);
const pdf = await pdfjsLib.getDocument(typedArray).promise;
const totalPages = pdf.numPages;
const pagesToExtract = Math.min(totalPages, MAX_PDF_PAGES);
let fullText = '';
for (let i = 1; i <= pagesToExtract; i++) {
const page = await pdf.getPage(i);
const textContent = await page.getTextContent();
const pageText = textContent.items.map((item: any) => item.str).join(' ');
fullText += `${pageText}\n`;
}
resolve({ content: fullText, totalPages, extractedPages: pagesToExtract });
}
catch (error) {
reject(error);
}
};
reader.onerror = reject;
reader.readAsArrayBuffer(file);
});
}
/**
* 获取文件扩展名
* @param {string} filename - 文件名
* @returns {string} 小写的扩展名,无点号
*/
function getFileExtension(filename: string): string {
return filename.split('.').pop()?.toLowerCase() || '';
}
onChange(async (files) => {
if (!files)
return;
const arr = [] as FileItem[];
let totalContentLength = 0; // 跟踪总内容长度
// 先计算已有文件的总内容长度
filesStore.filesList.forEach((f) => {
if (f.fileType === 'text' && f.fileContent) {
totalContentLength += f.fileContent.length;
}
// 图片 base64 也计入(虽然转 token 时不同,但也要计算)
if (f.fileType === 'image' && f.base64) {
// base64 转 token 比例约 1:1.5,这里保守估计
totalContentLength += Math.floor(f.base64.length * 0.5);
}
});
for (let i = 0; i < files!.length; i++) {
const file = files![i];
// 验证文件大小
if (file.size > MAX_FILE_SIZE) {
ElMessage.error(`文件 ${file.name} 超过 3MB 限制,已跳过`);
continue;
}
const ext = getFileExtension(file.name);
const isImage = file.type.startsWith('image/');
const isExcel = ['xlsx', 'xls'].includes(ext);
const isWord = ext === 'docx';
const isPDF = ext === 'pdf';
const isText = isTextFile(file);
// 处理图片文件
if (isImage) {
try {
// 多级压缩策略:逐步降低质量和分辨率
const compressionLevels = [
{ maxWidth: 800, maxHeight: 800, quality: 0.6 },
{ maxWidth: 600, maxHeight: 600, quality: 0.5 },
{ maxWidth: 400, maxHeight: 400, quality: 0.4 },
];
let compressedBlob: Blob | null = null;
let base64 = '';
let compressionLevel = 0;
// 尝试不同级别的压缩
for (const level of compressionLevels) {
compressionLevel++;
compressedBlob = await compressImage(file, level.maxWidth, level.maxHeight, level.quality);
base64 = await blobToBase64(compressedBlob);
// 检查是否满足总长度限制
const estimatedLength = Math.floor(base64.length * 0.5);
if (totalContentLength + estimatedLength <= MAX_TOTAL_CONTENT_LENGTH) {
// 满足限制,使用当前压缩级别
totalContentLength += estimatedLength;
break;
}
// 如果是最后一级压缩仍然超限,则跳过
if (compressionLevel === compressionLevels.length) {
const fileSizeMB = (file.size / 1024 / 1024).toFixed(2);
ElMessage.error(`${file.name} (${fileSizeMB}MB) 即使压缩后仍超过总内容限制,已跳过`);
compressedBlob = null;
break;
}
}
// 如果压缩失败,跳过此文件
if (!compressedBlob) {
continue;
}
// 计算压缩比例
const originalSize = (file.size / 1024).toFixed(2);
const compressedSize = (compressedBlob.size / 1024).toFixed(2);
console.log(`图片压缩: ${file.name} - 原始: ${originalSize}KB, 压缩后: ${compressedSize}KB (级别${compressionLevel})`);
arr.push({
uid: crypto.randomUUID(),
name: file.name,
fileSize: file.size,
file,
maxWidth: '200px',
showDelIcon: true,
imgPreview: true,
imgVariant: 'square',
url: base64, // 使用压缩后的 base64 作为预览地址
isUploaded: true,
base64,
fileType: 'image',
});
}
catch (error) {
console.error('压缩图片失败:', error);
ElMessage.error(`${file.name} 压缩失败`);
continue;
}
}
// 处理 Excel 文件
else if (isExcel) {
try {
const result = await parseExcel(file);
// 动态裁剪内容以适应剩余空间
let finalContent = result.content;
let wasTruncated = result.totalRows > MAX_EXCEL_ROWS;
// 如果超过总内容限制,裁剪内容
const remainingSpace = MAX_TOTAL_CONTENT_LENGTH - totalContentLength;
if (result.content.length > remainingSpace && remainingSpace > 1000) {
// 至少保留1000字符才有意义
finalContent = result.content.substring(0, remainingSpace);
wasTruncated = true;
} else if (remainingSpace <= 1000) {
const fileSizeKB = (file.size / 1024).toFixed(2);
ElMessage.error(`${file.name} (${fileSizeKB}KB) 会超过总内容限制,已跳过`);
continue;
}
totalContentLength += finalContent.length;
arr.push({
uid: crypto.randomUUID(),
name: file.name,
fileSize: file.size,
file,
maxWidth: '200px',
showDelIcon: true,
imgPreview: false,
isUploaded: true,
fileContent: finalContent,
fileType: 'text',
});
// 提示信息
if (wasTruncated) {
const fileSizeKB = (file.size / 1024).toFixed(2);
ElMessage.warning(`${file.name} (${fileSizeKB}KB) 内容过大,已自动截取部分内容`);
}
console.log(`Excel 解析: ${file.name} - 大小: ${(file.size / 1024).toFixed(2)}KB, 总行数: ${result.totalRows}, 已提取: ${result.extractedRows} 行, 内容长度: ${finalContent.length} 字符`);
}
catch (error) {
console.error('解析 Excel 失败:', error);
ElMessage.error(`${file.name} 解析失败`);
continue;
}
}
// 处理 Word 文档
else if (isWord) {
try {
const result = await parseWord(file);
// 动态裁剪内容以适应剩余空间
let finalContent = result.content;
let wasTruncated = result.extracted;
// 如果超过总内容限制,裁剪内容
const remainingSpace = MAX_TOTAL_CONTENT_LENGTH - totalContentLength;
if (result.content.length > remainingSpace && remainingSpace > 1000) {
finalContent = result.content.substring(0, remainingSpace);
wasTruncated = true;
} else if (remainingSpace <= 1000) {
const fileSizeKB = (file.size / 1024).toFixed(2);
ElMessage.error(`${file.name} (${fileSizeKB}KB) 会超过总内容限制,已跳过`);
continue;
}
totalContentLength += finalContent.length;
arr.push({
uid: crypto.randomUUID(),
name: file.name,
fileSize: file.size,
file,
maxWidth: '200px',
showDelIcon: true,
imgPreview: false,
isUploaded: true,
fileContent: finalContent,
fileType: 'text',
});
// 提示信息
if (wasTruncated) {
const fileSizeKB = (file.size / 1024).toFixed(2);
ElMessage.warning(`${file.name} (${fileSizeKB}KB) 内容过大,已自动截取部分内容`);
}
console.log(`Word 解析: ${file.name} - 大小: ${(file.size / 1024).toFixed(2)}KB, 总长度: ${result.totalLength}, 已提取: ${finalContent.length} 字符`);
}
catch (error) {
console.error('解析 Word 失败:', error);
ElMessage.error(`${file.name} 解析失败`);
continue;
}
}
// 处理 PDF 文件
else if (isPDF) {
try {
const result = await parsePDF(file);
// 动态裁剪内容以适应剩余空间
let finalContent = result.content;
let wasTruncated = result.totalPages > MAX_PDF_PAGES;
// 如果超过总内容限制,裁剪内容
const remainingSpace = MAX_TOTAL_CONTENT_LENGTH - totalContentLength;
if (result.content.length > remainingSpace && remainingSpace > 1000) {
finalContent = result.content.substring(0, remainingSpace);
wasTruncated = true;
} else if (remainingSpace <= 1000) {
const fileSizeKB = (file.size / 1024).toFixed(2);
ElMessage.error(`${file.name} (${fileSizeKB}KB) 会超过总内容限制,已跳过`);
continue;
}
totalContentLength += finalContent.length;
arr.push({
uid: crypto.randomUUID(),
name: file.name,
fileSize: file.size,
file,
maxWidth: '200px',
showDelIcon: true,
imgPreview: false,
isUploaded: true,
fileContent: finalContent,
fileType: 'text',
});
// 提示信息
if (wasTruncated) {
const fileSizeKB = (file.size / 1024).toFixed(2);
ElMessage.warning(`${file.name} (${fileSizeKB}KB) 内容过大,已自动截取部分内容`);
}
console.log(`PDF 解析: ${file.name} - 大小: ${(file.size / 1024).toFixed(2)}KB, 总页数: ${result.totalPages}, 已提取: ${result.extractedPages} 页, 内容长度: ${finalContent.length} 字符`);
}
catch (error) {
console.error('解析 PDF 失败:', error);
ElMessage.error(`${file.name} 解析失败`);
continue;
}
}
// 处理文本文件
else if (isText) {
try {
// 读取文本文件内容
const content = await readTextFile(file);
// 限制单个文本文件长度
let finalContent = content;
let truncated = false;
if (content.length > MAX_TEXT_FILE_LENGTH) {
finalContent = content.substring(0, MAX_TEXT_FILE_LENGTH);
truncated = true;
}
// 动态裁剪内容以适应剩余空间
const remainingSpace = MAX_TOTAL_CONTENT_LENGTH - totalContentLength;
if (finalContent.length > remainingSpace && remainingSpace > 1000) {
finalContent = finalContent.substring(0, remainingSpace);
truncated = true;
} else if (remainingSpace <= 1000) {
const fileSizeKB = (file.size / 1024).toFixed(2);
ElMessage.error(`${file.name} (${fileSizeKB}KB) 会超过总内容限制,已跳过`);
continue;
}
totalContentLength += finalContent.length;
arr.push({
uid: crypto.randomUUID(),
name: file.name,
fileSize: file.size,
file,
maxWidth: '200px',
showDelIcon: true,
imgPreview: false,
isUploaded: true,
fileContent: finalContent,
fileType: 'text',
});
// 提示信息
if (truncated) {
const fileSizeKB = (file.size / 1024).toFixed(2);
ElMessage.warning(`${file.name} (${fileSizeKB}KB) 内容过大,已自动截取部分内容`);
}
console.log(`文本文件读取: ${file.name} - 大小: ${(file.size / 1024).toFixed(2)}KB, 内容长度: ${content.length} 字符`);
}
catch (error) {
console.error('读取文件失败:', error);
ElMessage.error(`${file.name} 读取失败`);
continue;
}
}
// 不支持的文件类型
else {
ElMessage.warning(`${file.name} 不是支持的文件类型`);
continue;
}
}
if (arr.length > 0) {
filesStore.setFilesList([...filesStore.filesList, ...arr]);
ElMessage.success(`已添加 ${arr.length} 个文件`);
}
// 重置文件选择器
nextTick(() => reset());
});
/**
* 打开文件选择对话框
*/
function handleUploadFiles() {
open();
}
</script>
<template>
<div class="files-select">
<!-- 直接点击上传添加 tooltip 提示 -->
<el-tooltip
content="上传文件或图片(支持 Excel、Word、PDF、代码文件等最大3MB"
placement="top"
>
<div
class="flex items-center gap-4px p-10px rounded-10px cursor-pointer font-size-14px border-1px border-[rgba(0,0,0,0.08)] border-solid hover:bg-[rgba(0,0,0,.04)]"
@click="handleUploadFiles"
>
<el-icon>
<Paperclip />
</el-icon>
</div>
</el-tooltip>
</div>
</template>
<style scoped lang="scss"></style>