fix: 对话框支持粘贴图片

This commit is contained in:
Gsh
2026-01-19 22:15:01 +08:00
parent 5895f9e794
commit 4ce77ececc
3 changed files with 520 additions and 3 deletions

View File

@@ -3,7 +3,10 @@
"allow": [ "allow": [
"Bash(npx vue-tsc --noEmit)", "Bash(npx vue-tsc --noEmit)",
"Bash(timeout 60 npx vue-tsc:*)", "Bash(timeout 60 npx vue-tsc:*)",
"Bash(npm run dev:*)" "Bash(npm run dev:*)",
"Bash(taskkill:*)",
"Bash(timeout /t 5 /nobreak)",
"Bash(git checkout:*)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

View File

@@ -4,7 +4,7 @@ import type { FilesCardProps } from 'vue-element-plus-x/types/FilesCard';
import { ArrowLeftBold, ArrowRightBold, Loading } from '@element-plus/icons-vue'; import { ArrowLeftBold, ArrowRightBold, Loading } from '@element-plus/icons-vue';
import { useDebounceFn } from '@vueuse/core'; import { useDebounceFn } from '@vueuse/core';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import { computed, nextTick, ref, watch } from 'vue'; import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import ModelSelect from '@/components/ModelSelect/index.vue'; import ModelSelect from '@/components/ModelSelect/index.vue';
import WelecomeText from '@/components/WelecomeText/index.vue'; import WelecomeText from '@/components/WelecomeText/index.vue';
import Collapse from '@/layouts/components/Header/components/Collapse.vue'; import Collapse from '@/layouts/components/Header/components/Collapse.vue';
@@ -26,6 +26,10 @@ const senderValue = ref(''); // 输入框内容
const senderRef = ref(); // Sender 组件引用 const senderRef = ref(); // Sender 组件引用
const isSending = ref(false); // 发送状态标志 const isSending = ref(false); // 发送状态标志
// 文件处理相关常量
const MAX_FILE_SIZE = 3 * 1024 * 1024;
const MAX_TOTAL_CONTENT_LENGTH = 150000;
/** /**
* 防抖发送消息函数 * 防抖发送消息函数
*/ */
@@ -107,6 +111,181 @@ watch(
}); });
}, },
); );
/**
* 压缩图片
*/
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);
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
*/
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);
});
}
/**
* 处理粘贴事件
*/
async function handlePaste(event: ClipboardEvent) {
const items = event.clipboardData?.items;
if (!items)
return;
const files: File[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) {
files.push(file);
}
}
}
if (files.length === 0)
return;
event.preventDefault();
// 计算已有文件的总内容长度
let totalContentLength = 0;
filesStore.filesList.forEach((f) => {
if (f.fileType === 'text' && f.fileContent) {
totalContentLength += f.fileContent.length;
}
if (f.fileType === 'image' && f.base64) {
totalContentLength += Math.floor(f.base64.length * 0.5);
}
});
const arr: any[] = [];
for (const file of files) {
// 验证文件大小
if (file.size > MAX_FILE_SIZE) {
ElMessage.error(`文件 ${file.name} 超过 3MB 限制,已跳过`);
continue;
}
const isImage = file.type.startsWith('image/');
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 = '';
for (const level of compressionLevels) {
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;
}
compressedBlob = null;
}
if (!compressedBlob) {
ElMessage.error(`${file.name} 图片内容过大,请压缩后上传`);
continue;
}
arr.push({
uid: crypto.randomUUID(),
name: file.name,
fileSize: file.size,
file,
maxWidth: '200px',
showDelIcon: true,
imgPreview: true,
imgVariant: 'square',
url: base64,
isUploaded: true,
base64,
fileType: 'image',
});
}
catch (error) {
console.error('处理图片失败:', error);
ElMessage.error(`${file.name} 处理失败`);
}
}
else {
ElMessage.warning(`${file.name} 不支持粘贴,请使用上传按钮`);
}
}
if (arr.length > 0) {
filesStore.setFilesList([...filesStore.filesList, ...arr]);
ElMessage.success(`已添加 ${arr.length} 个文件`);
}
}
// 监听粘贴事件
onMounted(() => {
document.addEventListener('paste', handlePaste);
});
onUnmounted(() => {
document.removeEventListener('paste', handlePaste);
});
</script> </script>
<template> <template>

View File

@@ -8,9 +8,12 @@ import type { ThinkingStatus } from 'vue-element-plus-x/types/Thinking';
import { ArrowLeftBold, ArrowRightBold, Document, Loading } from '@element-plus/icons-vue'; import { ArrowLeftBold, ArrowRightBold, Document, Loading } from '@element-plus/icons-vue';
import { ElIcon, ElMessage } from 'element-plus'; import { ElIcon, ElMessage } from 'element-plus';
import { useHookFetch } from 'hook-fetch/vue'; import { useHookFetch } from 'hook-fetch/vue';
import { computed, nextTick, ref, watch } from 'vue'; import mammoth from 'mammoth';
import * as pdfjsLib from 'pdfjs-dist';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { Sender } from 'vue-element-plus-x'; import { Sender } from 'vue-element-plus-x';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import * as XLSX from 'xlsx';
import { unifiedSend } from '@/api'; import { unifiedSend } from '@/api';
import ModelSelect from '@/components/ModelSelect/index.vue'; import ModelSelect from '@/components/ModelSelect/index.vue';
import Collapse from '@/layouts/components/Header/components/Collapse.vue'; import Collapse from '@/layouts/components/Header/components/Collapse.vue';
@@ -64,6 +67,17 @@ const bubbleItems = ref<MessageItem[]>([]);
const bubbleListRef = ref<BubbleListInstance | null>(null); const bubbleListRef = ref<BubbleListInstance | null>(null);
const isSending = ref(false); const isSending = ref(false);
// 文件处理相关常量(与 FilesSelect 保持一致)
const MAX_FILE_SIZE = 3 * 1024 * 1024;
const MAX_TOTAL_CONTENT_LENGTH = 150000;
const MAX_TEXT_FILE_LENGTH = 50000;
const MAX_WORD_LENGTH = 30000;
const MAX_EXCEL_ROWS = 100;
const MAX_PDF_PAGES = 10;
// 配置 PDF.js worker
pdfjsLib.GlobalWorkerOptions.workerSrc = `https://cdn.jsdelivr.net/npm/pdfjs-dist@${pdfjsLib.version}/build/pdf.worker.min.mjs`;
// 记录当前请求使用的 API 格式类型,用于正确解析响应 // 记录当前请求使用的 API 格式类型,用于正确解析响应
const currentRequestApiType = ref<string>(''); const currentRequestApiType = ref<string>('');
@@ -489,6 +503,327 @@ function copy(item: any) {
function handleImagePreview(url: string) { function handleImagePreview(url: string) {
window.open(url, '_blank'); window.open(url, '_blank');
} }
/**
* 压缩图片
*/
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);
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
*/
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);
});
}
/**
* 判断是否为文本文件
*/
function isTextFile(file: File): boolean {
if (file.type.startsWith('text/'))
return true;
const textExtensions = [
'txt', 'log', 'md', 'markdown', 'json', 'xml', 'yaml', 'yml', 'toml', 'ini', 'conf', 'config',
'js', 'jsx', 'ts', 'tsx', 'vue', 'html', 'htm', 'css', 'scss', 'sass', 'less',
'java', 'c', 'cpp', 'h', 'hpp', 'cs', 'py', 'rb', 'go', 'rs', 'swift', 'kt', 'php',
'sh', 'bash', 'sql', 'csv', 'tsv',
];
const ext = file.name.split('.').pop()?.toLowerCase();
return ext ? textExtensions.includes(ext) : false;
}
/**
* 读取文本文件
*/
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');
});
}
/**
* 解析 Excel 文件
*/
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;
const limitedData: any[][] = [];
for (let row = range.s.r; row < range.s.r + rowsToExtract; row++) {
const rowData: any[] = [];
for (let col = range.s.c; col <= range.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);
}
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 文档
*/
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 文件
*/
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);
});
}
/**
* 处理粘贴事件
*/
async function handlePaste(event: ClipboardEvent) {
const items = event.clipboardData?.items;
if (!items)
return;
const files: File[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) {
files.push(file);
}
}
}
if (files.length === 0)
return;
event.preventDefault();
// 计算已有文件的总内容长度
let totalContentLength = 0;
filesStore.filesList.forEach((f) => {
if (f.fileType === 'text' && f.fileContent) {
totalContentLength += f.fileContent.length;
}
if (f.fileType === 'image' && f.base64) {
totalContentLength += Math.floor(f.base64.length * 0.5);
}
});
const arr: any[] = [];
for (const file of files) {
// 验证文件大小
if (file.size > MAX_FILE_SIZE) {
ElMessage.error(`文件 ${file.name} 超过 3MB 限制,已跳过`);
continue;
}
const isImage = file.type.startsWith('image/');
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 = '';
for (const level of compressionLevels) {
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;
}
compressedBlob = null;
}
if (!compressedBlob) {
ElMessage.error(`${file.name} 图片内容过大,请压缩后上传`);
continue;
}
arr.push({
uid: crypto.randomUUID(),
name: file.name,
fileSize: file.size,
file,
maxWidth: '200px',
showDelIcon: true,
imgPreview: true,
imgVariant: 'square',
url: base64,
isUploaded: true,
base64,
fileType: 'image',
});
}
catch (error) {
console.error('处理图片失败:', error);
ElMessage.error(`${file.name} 处理失败`);
}
}
else {
ElMessage.warning(`${file.name} 不支持粘贴,请使用上传按钮`);
}
}
if (arr.length > 0) {
filesStore.setFilesList([...filesStore.filesList, ...arr]);
ElMessage.success(`已添加 ${arr.length} 个文件`);
}
}
// 监听粘贴事件
onMounted(() => {
document.addEventListener('paste', handlePaste);
});
onUnmounted(() => {
document.removeEventListener('paste', handlePaste);
});
</script> </script>
<template> <template>