fix: 网页版增加对话图片支持
This commit is contained in:
@@ -15,6 +15,9 @@ VITE_WEB_BASE_API = '/dev-api'
|
|||||||
VITE_API_URL = http://localhost:19001/api/app
|
VITE_API_URL = http://localhost:19001/api/app
|
||||||
#VITE_API_URL=http://data.ccnetcore.com:19001/api/app
|
#VITE_API_URL=http://data.ccnetcore.com:19001/api/app
|
||||||
|
|
||||||
|
# 文件上传接口域名
|
||||||
|
VITE_FILE_UPLOAD_API = https://ai.ccnetcore.com
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# SSO单点登录url
|
# SSO单点登录url
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ VITE_WEB_BASE_API = '/prod-api'
|
|||||||
# 本地接口
|
# 本地接口
|
||||||
VITE_API_URL = http://data.ccnetcore.com:19001/api/app
|
VITE_API_URL = http://data.ccnetcore.com:19001/api/app
|
||||||
|
|
||||||
|
# 文件上传接口域名
|
||||||
|
VITE_FILE_UPLOAD_API = https://ai.ccnetcore.com
|
||||||
|
|
||||||
# 是否在打包时开启压缩,支持 gzip 和 brotli
|
# 是否在打包时开启压缩,支持 gzip 和 brotli
|
||||||
VITE_BUILD_COMPRESS = gzip
|
VITE_BUILD_COMPRESS = gzip
|
||||||
|
|
||||||
|
|||||||
34
Yi.Ai.Vue3/src/api/file/index.ts
Normal file
34
Yi.Ai.Vue3/src/api/file/index.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { UploadFileResponse } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文件
|
||||||
|
* @param file 文件对象
|
||||||
|
* @returns 返回文件ID数组
|
||||||
|
*/
|
||||||
|
export async function uploadFile(file: File): Promise<UploadFileResponse[]> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const uploadApiUrl = import.meta.env.VITE_FILE_UPLOAD_API;
|
||||||
|
|
||||||
|
const response = await fetch(`${uploadApiUrl}/prod-api/file`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('文件上传失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成文件URL
|
||||||
|
* @param fileId 文件ID
|
||||||
|
* @returns 文件访问URL
|
||||||
|
*/
|
||||||
|
export function getFileUrl(fileId: string): string {
|
||||||
|
return `https://ccnetcore.com/prod-api/file/${fileId}/true`;
|
||||||
|
}
|
||||||
3
Yi.Ai.Vue3/src/api/file/types.ts
Normal file
3
Yi.Ai.Vue3/src/api/file/types.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export interface UploadFileResponse {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
export * from './announcement'
|
export * from './announcement'
|
||||||
export * from './auth';
|
export * from './auth';
|
||||||
export * from './chat';
|
export * from './chat';
|
||||||
|
export * from './file';
|
||||||
export * from './model';
|
export * from './model';
|
||||||
export * from './pay';
|
export * from './pay';
|
||||||
export * from './session';
|
export * from './session';
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
<!-- 文件上传 -->
|
<!-- 文件上传 -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { FilesCardProps } from 'vue-element-plus-x/types/FilesCard';
|
import type { FileItem } from '@/stores/modules/files';
|
||||||
import { useFileDialog } from '@vueuse/core';
|
import { useFileDialog } from '@vueuse/core';
|
||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
import Popover from '@/components/Popover/index.vue';
|
import Popover from '@/components/Popover/index.vue';
|
||||||
import SvgIcon from '@/components/SvgIcon/index.vue';
|
import SvgIcon from '@/components/SvgIcon/index.vue';
|
||||||
import { useFilesStore } from '@/stores/modules/files';
|
import { useFilesStore } from '@/stores/modules/files';
|
||||||
|
|
||||||
type FilesList = FilesCardProps & {
|
|
||||||
file: File;
|
|
||||||
};
|
|
||||||
|
|
||||||
const filesStore = useFilesStore();
|
const filesStore = useFilesStore();
|
||||||
|
|
||||||
|
// 文件大小限制 3MB
|
||||||
|
const MAX_FILE_SIZE = 3 * 1024 * 1024;
|
||||||
|
|
||||||
/* 弹出面板 开始 */
|
/* 弹出面板 开始 */
|
||||||
const popoverStyle = ref({
|
const popoverStyle = ref({
|
||||||
padding: '4px',
|
padding: '4px',
|
||||||
@@ -26,31 +25,129 @@ const popoverRef = ref();
|
|||||||
/* 弹出面板 结束 */
|
/* 弹出面板 结束 */
|
||||||
|
|
||||||
const { reset, open, onChange } = useFileDialog({
|
const { reset, open, onChange } = useFileDialog({
|
||||||
// 允许所有图片文件,文档文件,音视频文件
|
// 允许图片和文件
|
||||||
accept: 'image/*,video/*,audio/*,application/*',
|
accept: 'image/*,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
directory: false, // 是否允许选择文件夹
|
directory: false, // 是否允许选择文件夹
|
||||||
multiple: true, // 是否允许多选
|
multiple: true, // 是否允许多选
|
||||||
});
|
});
|
||||||
|
|
||||||
onChange((files) => {
|
// 压缩图片
|
||||||
|
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
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(async (files) => {
|
||||||
if (!files)
|
if (!files)
|
||||||
return;
|
return;
|
||||||
const arr = [] as FilesList[];
|
|
||||||
|
const arr = [] as FileItem[];
|
||||||
|
|
||||||
for (let i = 0; i < files!.length; i++) {
|
for (let i = 0; i < files!.length; i++) {
|
||||||
const file = files![i];
|
const file = files![i];
|
||||||
|
|
||||||
|
// 验证文件大小
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
ElMessage.error(`文件 ${file.name} 超过 3MB 限制,已跳过`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isImage = file.type.startsWith('image/');
|
||||||
|
|
||||||
|
// 压缩并转换为base64
|
||||||
|
let base64 = '';
|
||||||
|
let previewUrl = '';
|
||||||
|
if (isImage) {
|
||||||
|
try {
|
||||||
|
// 先压缩图片
|
||||||
|
const compressedBlob = await compressImage(file, 1024, 1024, 0.8);
|
||||||
|
// 再转换为 base64
|
||||||
|
base64 = await blobToBase64(compressedBlob);
|
||||||
|
// 使用压缩后的图片作为预览
|
||||||
|
previewUrl = base64;
|
||||||
|
|
||||||
|
// 计算压缩比例
|
||||||
|
const originalSize = (file.size / 1024).toFixed(2);
|
||||||
|
const compressedSize = (compressedBlob.size / 1024).toFixed(2);
|
||||||
|
console.log(`图片压缩: ${file.name} - 原始: ${originalSize}KB, 压缩后: ${compressedSize}KB`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('压缩图片失败:', error);
|
||||||
|
ElMessage.error(`${file.name} 压缩失败`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
arr.push({
|
arr.push({
|
||||||
uid: crypto.randomUUID(), // 不写 uid,文件列表展示不出来,elx 1.2.0 bug 待修复
|
uid: crypto.randomUUID(), // 不写 uid,文件列表展示不出来,elx 1.2.0 bug 待修复
|
||||||
name: file.name,
|
name: file.name,
|
||||||
fileSize: file.size,
|
fileSize: file.size,
|
||||||
file,
|
file,
|
||||||
maxWidth: '200px',
|
maxWidth: '200px',
|
||||||
showDelIcon: true, // 显示删除图标
|
showDelIcon: true, // 显示删除图标
|
||||||
imgPreview: true, // 显示图片预览
|
imgPreview: isImage, // 图片才显示预览
|
||||||
imgVariant: 'square', // 图片预览的形状
|
imgVariant: 'square', // 图片预览的形状
|
||||||
url: URL.createObjectURL(file), // 图片预览地址
|
url: isImage ? previewUrl : undefined, // 使用压缩后的 base64 作为预览地址
|
||||||
|
isUploaded: true, // 直接标记为已完成
|
||||||
|
base64: isImage ? base64 : undefined, // 保存压缩后的base64
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
filesStore.setFilesList([...filesStore.filesList, ...arr]);
|
|
||||||
|
if (arr.length > 0) {
|
||||||
|
filesStore.setFilesList([...filesStore.filesList, ...arr]);
|
||||||
|
ElMessage.success(`已添加 ${arr.length} 个文件`);
|
||||||
|
}
|
||||||
|
|
||||||
// 重置文件选择器
|
// 重置文件选择器
|
||||||
nextTick(() => reset());
|
nextTick(() => reset());
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { ElMessage } from 'element-plus';
|
|||||||
import { nextTick, ref, watch } from 'vue';
|
import { nextTick, 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 { useGuideTour } from '@/hooks/useGuideTour';
|
|
||||||
import { useGuideTourStore, useUserStore } from '@/stores';
|
import { useGuideTourStore, useUserStore } from '@/stores';
|
||||||
import { useFilesStore } from '@/stores/modules/files';
|
import { useFilesStore } from '@/stores/modules/files';
|
||||||
|
|
||||||
@@ -135,6 +134,8 @@ watch(
|
|||||||
</template>
|
</template>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<div class="flex-1 flex items-center gap-8px flex-none w-fit overflow-hidden">
|
<div class="flex-1 flex items-center gap-8px flex-none w-fit overflow-hidden">
|
||||||
|
<FilesSelect />
|
||||||
|
|
||||||
<ModelSelect />
|
<ModelSelect />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { Sender } from 'vue-element-plus-x';
|
|||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { send } from '@/api';
|
import { send } from '@/api';
|
||||||
import ModelSelect from '@/components/ModelSelect/index.vue';
|
import ModelSelect from '@/components/ModelSelect/index.vue';
|
||||||
import { useGuideTour } from '@/hooks/useGuideTour';
|
|
||||||
import { useGuideTourStore } from '@/stores';
|
import { useGuideTourStore } from '@/stores';
|
||||||
import { useChatStore } from '@/stores/modules/chat';
|
import { useChatStore } from '@/stores/modules/chat';
|
||||||
import { useFilesStore } from '@/stores/modules/files';
|
import { useFilesStore } from '@/stores/modules/files';
|
||||||
@@ -30,6 +29,7 @@ type MessageItem = BubbleProps & {
|
|||||||
thinkingStatus?: ThinkingStatus;
|
thinkingStatus?: ThinkingStatus;
|
||||||
thinlCollapse?: boolean;
|
thinlCollapse?: boolean;
|
||||||
reasoning_content?: string;
|
reasoning_content?: string;
|
||||||
|
images?: Array<{ url: string; name?: string }>; // 用户消息中的图片列表
|
||||||
};
|
};
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -179,25 +179,85 @@ async function startSSE(chatContent: string) {
|
|||||||
if (isSending.value)
|
if (isSending.value)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
// 检查是否有未上传完成的文件
|
||||||
|
const hasUnuploadedFiles = filesStore.filesList.some(f => !f.isUploaded);
|
||||||
|
if (hasUnuploadedFiles) {
|
||||||
|
ElMessage.warning('文件正在上传中,请稍候...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
isSending.value = true;
|
isSending.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 清空输入框
|
// 清空输入框
|
||||||
inputValue.value = '';
|
inputValue.value = '';
|
||||||
addMessage(chatContent, true);
|
|
||||||
|
// 获取当前上传的图片(在清空之前保存)
|
||||||
|
const imageFiles = filesStore.filesList.filter(f => f.isUploaded && f.file.type.startsWith('image/'));
|
||||||
|
const images = imageFiles.map(f => ({
|
||||||
|
url: f.base64!, // 使用base64作为URL
|
||||||
|
name: f.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
addMessage(chatContent, true, images);
|
||||||
addMessage('', false);
|
addMessage('', false);
|
||||||
|
|
||||||
|
// 立即清空文件列表(不要等到响应完成)
|
||||||
|
filesStore.clearFilesList();
|
||||||
|
|
||||||
// 这里有必要调用一下 BubbleList 组件的滚动到底部 手动触发 自动滚动
|
// 这里有必要调用一下 BubbleList 组件的滚动到底部 手动触发 自动滚动
|
||||||
bubbleListRef.value?.scrollToBottom();
|
bubbleListRef.value?.scrollToBottom();
|
||||||
|
|
||||||
|
// 组装消息内容,支持图片
|
||||||
|
const messagesContent = bubbleItems.value.slice(0, -1).slice(-6).map((item: MessageItem) => {
|
||||||
|
const baseMessage: any = {
|
||||||
|
role: item.role,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果是用户消息且有附件(图片),组装成数组格式
|
||||||
|
if (item.role === 'user' && item.key === bubbleItems.value.length - 2) {
|
||||||
|
// 当前发送的消息
|
||||||
|
const contentArray: any[] = [];
|
||||||
|
|
||||||
|
// 添加文本内容
|
||||||
|
if (item.content) {
|
||||||
|
contentArray.push({
|
||||||
|
type: 'text',
|
||||||
|
text: item.content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加图片内容(使用之前保存的 imageFiles)
|
||||||
|
imageFiles.forEach((fileItem) => {
|
||||||
|
if (fileItem.base64) {
|
||||||
|
contentArray.push({
|
||||||
|
type: 'image_url',
|
||||||
|
image_url: {
|
||||||
|
url: fileItem.base64, // 使用base64
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果有图片或文件,使用数组格式
|
||||||
|
if (contentArray.length > 1 || imageFiles.length > 0) {
|
||||||
|
baseMessage.content = contentArray;
|
||||||
|
} else {
|
||||||
|
baseMessage.content = item.content;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 其他消息保持原样
|
||||||
|
baseMessage.content = (item.role === 'ai' || item.role === 'assistant') && item.content.length > 10000
|
||||||
|
? `${item.content.substring(0, 10000)}...(内容过长,已省略)`
|
||||||
|
: item.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseMessage;
|
||||||
|
});
|
||||||
|
|
||||||
// 使用 for-await 处理流式响应
|
// 使用 for-await 处理流式响应
|
||||||
for await (const chunk of stream({
|
for await (const chunk of stream({
|
||||||
messages: bubbleItems.value.slice(0, -1).slice(-6).map((item: MessageItem) => ({
|
messages: messagesContent,
|
||||||
role: item.role,
|
|
||||||
content: (item.role === 'ai' || item.role === 'assistant') && item.content.length > 10000
|
|
||||||
? `${item.content.substring(0, 10000)}...(内容过长,已省略)`
|
|
||||||
: item.content,
|
|
||||||
})),
|
|
||||||
sessionId: route.params?.id !== 'not_login' ? String(route.params?.id) : 'not_login',
|
sessionId: route.params?.id !== 'not_login' ? String(route.params?.id) : 'not_login',
|
||||||
stream: true,
|
stream: true,
|
||||||
userId: userStore.userInfo?.userId,
|
userId: userStore.userInfo?.userId,
|
||||||
@@ -250,7 +310,7 @@ async function cancelSSE() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 添加消息 - 维护聊天记录
|
// 添加消息 - 维护聊天记录
|
||||||
function addMessage(message: string, isUser: boolean) {
|
function addMessage(message: string, isUser: boolean, images?: Array<{ url: string; name?: string }>) {
|
||||||
const i = bubbleItems.value.length;
|
const i = bubbleItems.value.length;
|
||||||
const obj: MessageItem = {
|
const obj: MessageItem = {
|
||||||
key: i,
|
key: i,
|
||||||
@@ -267,6 +327,7 @@ function addMessage(message: string, isUser: boolean) {
|
|||||||
thinkingStatus: 'start',
|
thinkingStatus: 'start',
|
||||||
thinlCollapse: false,
|
thinlCollapse: false,
|
||||||
noStyle: !isUser,
|
noStyle: !isUser,
|
||||||
|
images: images && images.length > 0 ? images : undefined,
|
||||||
};
|
};
|
||||||
bubbleItems.value.push(obj);
|
bubbleItems.value.push(obj);
|
||||||
}
|
}
|
||||||
@@ -301,6 +362,11 @@ function copy(item: any) {
|
|||||||
.then(() => ElMessage.success('已复制到剪贴板'))
|
.then(() => ElMessage.success('已复制到剪贴板'))
|
||||||
.catch(() => ElMessage.error('复制失败'));
|
.catch(() => ElMessage.error('复制失败'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 图片预览
|
||||||
|
function handleImagePreview(url: string) {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -317,9 +383,23 @@ function copy(item: any) {
|
|||||||
<template #content="{ item }">
|
<template #content="{ item }">
|
||||||
<!-- chat 内容走 markdown -->
|
<!-- chat 内容走 markdown -->
|
||||||
<XMarkdown v-if="item.content && (item.role === 'assistant' || item.role === 'system')" class="markdown-body" :markdown="item.content" :themes="{ light: 'github-light', dark: 'github-dark' }" default-theme-mode="dark" />
|
<XMarkdown v-if="item.content && (item.role === 'assistant' || item.role === 'system')" class="markdown-body" :markdown="item.content" :themes="{ light: 'github-light', dark: 'github-dark' }" default-theme-mode="dark" />
|
||||||
<!-- user 内容 纯文本 -->
|
<!-- user 内容 纯文本 + 图片 -->
|
||||||
<div v-if="item.content && item.role === 'user'" class="user-content">
|
<div v-if="item.role === 'user'" class="user-content-wrapper">
|
||||||
{{ item.content }}
|
<!-- 图片列表 -->
|
||||||
|
<div v-if="item.images && item.images.length > 0" class="user-images">
|
||||||
|
<img
|
||||||
|
v-for="(image, index) in item.images"
|
||||||
|
:key="index"
|
||||||
|
:src="image.url"
|
||||||
|
:alt="image.name || '图片'"
|
||||||
|
class="user-image"
|
||||||
|
@click="() => handleImagePreview(image.url)"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<!-- 文本内容 -->
|
||||||
|
<div v-if="item.content" class="user-content">
|
||||||
|
{{ item.content }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -375,7 +455,7 @@ function copy(item: any) {
|
|||||||
</template>
|
</template>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<div class="flex-1 flex items-center gap-8px flex-none w-fit overflow-hidden">
|
<div class="flex-1 flex items-center gap-8px flex-none w-fit overflow-hidden">
|
||||||
<!-- <FilesSelect /> -->
|
<FilesSelect />
|
||||||
<ModelSelect />
|
<ModelSelect />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -421,6 +501,28 @@ function copy(item: any) {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
.user-content-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.user-images {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.user-image {
|
||||||
|
max-width: 200px;
|
||||||
|
max-height: 200px;
|
||||||
|
border-radius: 8px;
|
||||||
|
object-fit: cover;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
.user-content {
|
.user-content {
|
||||||
// 换行
|
// 换行
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
|||||||
@@ -2,11 +2,19 @@ import type { FilesCardProps } from 'vue-element-plus-x/types/FilesCard';
|
|||||||
// 对话聊天的文件上传列表
|
// 对话聊天的文件上传列表
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
|
|
||||||
|
export interface FileItem extends FilesCardProps {
|
||||||
|
file: File;
|
||||||
|
fileId?: string; // 上传后返回的文件ID
|
||||||
|
isUploaded?: boolean; // 是否已上传
|
||||||
|
uploadProgress?: number; // 上传进度
|
||||||
|
base64?: string; // 图片的base64编码
|
||||||
|
}
|
||||||
|
|
||||||
export const useFilesStore = defineStore('files', () => {
|
export const useFilesStore = defineStore('files', () => {
|
||||||
const filesList = ref<FilesCardProps & { file: File }[]>([]);
|
const filesList = ref<FileItem[]>([]);
|
||||||
|
|
||||||
// 设置文件列表
|
// 设置文件列表
|
||||||
const setFilesList = (list: FilesCardProps & { file: File }[]) => {
|
const setFilesList = (list: FileItem[]) => {
|
||||||
filesList.value = list;
|
filesList.value = list;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -15,9 +23,24 @@ export const useFilesStore = defineStore('files', () => {
|
|||||||
filesList.value.splice(index, 1);
|
filesList.value.splice(index, 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 更新文件上传状态
|
||||||
|
const updateFileUploadStatus = (index: number, fileId: string) => {
|
||||||
|
if (filesList.value[index]) {
|
||||||
|
filesList.value[index].fileId = fileId;
|
||||||
|
filesList.value[index].isUploaded = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清空文件列表
|
||||||
|
const clearFilesList = () => {
|
||||||
|
filesList.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
filesList,
|
filesList,
|
||||||
setFilesList,
|
setFilesList,
|
||||||
deleteFileByIndex,
|
deleteFileByIndex,
|
||||||
|
updateFileUploadStatus,
|
||||||
|
clearFilesList,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
3
Yi.Ai.Vue3/types/components.d.ts
vendored
3
Yi.Ai.Vue3/types/components.d.ts
vendored
@@ -27,6 +27,7 @@ declare module 'vue' {
|
|||||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||||
ElDivider: typeof import('element-plus/es')['ElDivider']
|
ElDivider: typeof import('element-plus/es')['ElDivider']
|
||||||
|
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
||||||
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||||
ElForm: typeof import('element-plus/es')['ElForm']
|
ElForm: typeof import('element-plus/es')['ElForm']
|
||||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||||
@@ -42,6 +43,8 @@ declare module 'vue' {
|
|||||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||||
ElProgress: typeof import('element-plus/es')['ElProgress']
|
ElProgress: typeof import('element-plus/es')['ElProgress']
|
||||||
ElRow: typeof import('element-plus/es')['ElRow']
|
ElRow: typeof import('element-plus/es')['ElRow']
|
||||||
|
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
||||||
|
ElSegmented: typeof import('element-plus/es')['ElSegmented']
|
||||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||||
|
|||||||
1
Yi.Ai.Vue3/types/import_meta.d.ts
vendored
1
Yi.Ai.Vue3/types/import_meta.d.ts
vendored
@@ -6,6 +6,7 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_WEB_ENV: string;
|
readonly VITE_WEB_ENV: string;
|
||||||
readonly VITE_WEB_BASE_API: string;
|
readonly VITE_WEB_BASE_API: string;
|
||||||
readonly VITE_API_URL: string;
|
readonly VITE_API_URL: string;
|
||||||
|
readonly VITE_FILE_UPLOAD_API: string;
|
||||||
readonly VITE_BUILD_COMPRESS: string;
|
readonly VITE_BUILD_COMPRESS: string;
|
||||||
readonly VITE_SSO_SEVER_URL: string;
|
readonly VITE_SSO_SEVER_URL: string;
|
||||||
readonly VITE_APP_VERSION: string;
|
readonly VITE_APP_VERSION: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user