作者 lixiang

问题库以及自定义问答

此 diff 太大无法显示。
... ... @@ -23,7 +23,7 @@ export const columns: BasicColumn[] = [
},
},
{
title: '按钮值',
title: '发送内容',
align: 'center',
dataIndex: 'buttonValues',
},
... ... @@ -64,7 +64,7 @@ export const searchFormSchema: FormSchema[] = [
component: 'JInput',
},
{
label: '按钮值',
label: '发送内容',
field: 'buttonValues',
component: 'JInput',
},
... ... @@ -85,7 +85,7 @@ export const formSchema: FormSchema[] = [
componentProps: {},
},
{
label: '按钮值',
label: '发送内容',
field: 'buttonValues',
component: 'Input',
required: true,
... ... @@ -103,7 +103,7 @@ export const formSchema: FormSchema[] = [
export const superQuerySchema = {
buttonName: { title: '按钮名称', order: 0, view: 'text', type: 'string' },
buttonSwitch: { title: '按钮开关', order: 1, view: 'switch', type: 'string' },
buttonValues: { title: '按钮值', order: 2, view: 'text', type: 'string' },
buttonValues: { title: '发送内容', order: 2, view: 'text', type: 'string' },
};
/**
... ...
... ... @@ -11,38 +11,52 @@ enum Api {
deleteBatch = '/question/embedding/deleteBatch',
importExcel = '/question/embedding/importZip',
uploadZip = '/question/embedding/uploadZip',
listKnowledge = '/embeddings/embeddings/listknowledge',
}
export const getExportUrl = Api.importExcel;
export const getImportZipUrl = Api.uploadZip;
// 新增知识库列表查询方法
export const listKnowledge = async () => {
try {
const res = await defHttp.get({ url: Api.listKnowledge });
return res;
} catch (error) {
console.error("Error fetching knowledge list:", error);
return [];
}
}
export const list = async (params) => {
try {
const res = await defHttp.get({
url: Api.list,
params: { ...params, size: 1000 },
params: { ...params, size: 1000 }
});
if (res?.records && Array.isArray(res.records)) {
res.records = res.records.map((item) => ({
res.records = res.records.map(item => ({
...item,
...item.metadata,
question: item.question || '',
answer: item.answer || '',
knowledgeName: item.knowledgeName || '',
knowledgeId: item.knowledgeId || ''
}));
}
return res;
} catch (error) {
console.error('Error fetching question embeddings:', error);
console.error("Error fetching question embeddings:", error);
return {
records: [],
total: 0,
size: 10,
current: 1,
pages: 0,
pages: 0
};
}
};
}
export const deleteOne = (params, handleSuccess) => {
return defHttp.delete({ url: Api.deleteOne, params }, { joinParamsToUrl: true }).then(() => {
... ...
import { BasicColumn } from '/@/components/Table';
import { FormSchema } from '/@/components/Table';
import { listKnowledge } from '@/views/super/airag/questionembedding/QuestionEmbedding.api';
export const columns: BasicColumn[] = [
{
... ... @@ -9,45 +10,49 @@ export const columns: BasicColumn[] = [
width: 200,
},
{
title: '知识库',
align: 'center',
dataIndex: 'knowledgeName',
width: 150
},
{
title: '问题',
align: 'center',
dataIndex: 'question',
width: 250,
width: 250
},
{
title: '回答',
align: 'center',
dataIndex: 'answer',
width: 300,
},
{
title: '原文',
align: 'center',
dataIndex: 'text',
width: 300,
},
{
title: '元数据',
align: 'center',
dataIndex: 'metadata',
width: 200,
customRender: ({ text }) => JSON.stringify(text || {}),
width: 300
},
];
export const searchFormSchema: FormSchema[] = [
{
field: 'knowledgeId',
label: '知识库',
component: 'ApiSelect',
componentProps: {
api: listKnowledge,
labelField: 'name',
valueField: 'id',
},
colProps: { span: 8 }
},
{
field: 'question',
label: '问题',
component: 'Input',
colProps: { span: 8 },
colProps: { span: 8 }
},
{
field: 'answer',
label: '回答',
component: 'Input',
colProps: { span: 8 },
},
}
];
export const formSchema: FormSchema[] = [
... ... @@ -58,36 +63,43 @@ export const formSchema: FormSchema[] = [
show: false,
},
{
field: 'knowledgeId',
label: '知识库',
component: 'ApiSelect',
required: true,
componentProps: {
api: listKnowledge,
labelField: 'name',
valueField: 'id',
}
},
{
field: 'question',
label: '问题',
component: 'InputTextArea',
component: 'Input',
required: true,
colProps: { span: 24 },
colProps: { span: 24 }
},
{
field: 'answer',
label: '回答',
component: 'InputTextArea',
required: true,
colProps: { span: 24 },
},
{
field: 'text',
label: '原文',
component: 'InputTextArea',
colProps: { span: 24 },
},
{
field: 'metadata',
label: '元数据',
component: 'InputTextArea',
colProps: { span: 24 },
},
componentProps: {
allowClear: true,
showCount: true,
autoSize: {
minRows: 5,
maxRows: 10
}
}
}
];
export const superQuerySchema = {
question: { title: '问题', order: 0, view: 'text', type: 'string' },
answer: { title: '回答', order: 1, view: 'text', type: 'string' },
knowledgeId: { title: '知识库', order: 0, view: 'text', type: 'string' },
question: { title: '问题', order: 1, view: 'text', type: 'string' },
answer: { title: '回答', order: 2, view: 'text', type: 'string' }
};
export function getBpmFormSchema(_formData): FormSchema[] {
... ...
... ... @@ -4,110 +4,302 @@
<template #tableTitle>
<a-button type="primary" @click="handleAdd" preIcon="ant-design:plus-outlined">新增</a-button>
<a-button type="primary" preIcon="ant-design:export-outlined" @click="onExportXls">导出</a-button>
<j-upload-button type="primary" preIcon="ant-design:import-outlined" @click="onImportXls">导入</j-upload-button>
<a-button
type="primary"
preIcon="ant-design:import-outlined"
@click="handleOpenImportModal"
>导入ZIP</a-button>
<a-dropdown v-if="selectedRowKeys.length > 0">
<template #overlay>
<a-menu>
<a-menu-item key="1" @click="batchHandleDelete"> <Icon icon="ant-design:delete-outlined" />删除 </a-menu-item>
<a-menu-item key="1" @click="batchHandleDelete">
<Icon icon="ant-design:delete-outlined" />删除
</a-menu-item>
</a-menu>
</template>
<a-button>批量操作<Icon icon="mdi:chevron-down" /></a-button>
</a-dropdown>
<super-query :config="superQueryConfig" @search="handleSuperQuery" />
</template>
<template #action="{ record }">
<TableAction :actions="getTableAction(record)" :dropDownActions="getDropDownAction(record)" />
</template>
</BasicTable>
<QuestionEmbeddingModal @register="registerModal" @success="handleSuccess" />
<!-- 导入ZIP模态框 -->
<BasicModal
v-model:visible="importModalVisible"
title="导入ZIP文件"
@ok="handleImportSubmit"
@cancel="handleImportCancel"
width="600px"
:okButtonProps="{ disabled: !selectedKnowledgeId || !currentFile }"
>
<BasicForm @register="registerImportForm" :showActionButtonGroup="false" />
<div style="margin-top: 20px;margin-left: 5%">
<input
type="file"
ref="fileInput"
accept=".zip"
@change="handleFileChange"
style="display: none"
>
<a-button
type="primary"
@click="triggerFileInput"
>
<UploadOutlined /> 选择ZIP文件
</a-button>
<div v-if="currentFile" style="margin-top: 8px;">
已选择文件: {{ currentFile.name }} ({{ formatFileSize(currentFile.size) }})
</div>
<div v-if="uploadProgress > 0" style="margin-top: 8px;">
上传进度: {{ uploadProgress }}%
<a-progress
:percent="uploadProgress"
:stroke-color="progressColor"
:status="uploadStatus"
/>
</div>
</div>
</BasicModal>
</div>
</template>
<script lang="ts" setup>
import { reactive } from 'vue';
import { BasicTable, TableAction } from '/@/components/Table';
import { useModal } from '/@/components/Modal';
import { useListPage } from '/@/hooks/system/useListPage';
import QuestionEmbeddingModal from './components/QuestionEmbeddingModal.vue';
import { columns, searchFormSchema, superQuerySchema } from './QuestionEmbedding.data';
import { list, deleteOne, batchDelete, getImportZipUrl, getExportUrl } from './QuestionEmbedding.api';
const queryParam = reactive<any>({});
const [registerModal, { openModal }] = useModal();
const { tableContext, onExportXls, onImportXls } = useListPage({
tableProps: {
title: '问答向量库',
api: list,
columns,
formConfig: {
schemas: searchFormSchema,
autoSubmitOnEnter: true,
showAdvancedButton: true,
},
actionColumn: {
width: 120,
fixed: 'right',
import { ref, reactive } from 'vue';
import { UploadOutlined } from '@ant-design/icons-vue';
import { BasicTable, useTable, TableAction } from '/@/components/Table';
import { BasicModal } from '/@/components/Modal';
import { BasicForm, useForm } from '/@/components/Form';
import { useModal } from '/@/components/Modal';
import { useListPage } from '/@/hooks/system/useListPage';
import { message } from 'ant-design-vue';
import QuestionEmbeddingModal from './components/QuestionEmbeddingModal.vue';
import { columns, searchFormSchema } from './QuestionEmbedding.data';
import { list, deleteOne, batchDelete, getImportZipUrl, getExportUrl, listKnowledge } from './QuestionEmbedding.api';
import { useUserStore } from '/@/store/modules/user';
import { getToken } from '/@/utils/auth';
const queryParam = reactive<any>({});
const [registerModal, { openModal }] = useModal();
const importModalVisible = ref(false);
const selectedKnowledgeId = ref('');
const fileInput = ref<HTMLInputElement | null>(null);
const currentFile = ref<File | null>(null);
const uploadProgress = ref(0);
const progressColor = ref('#1890ff');
const uploadStatus = ref<'normal' | 'exception' | 'active' | 'success'>('normal');
// 知识库选择表单
const [registerImportForm] = useForm({
labelWidth: 100,
showActionButtonGroup: false,
schemas: [
{
field: 'knowledgeId',
label: '知识库',
component: 'ApiSelect',
required: true,
componentProps: {
placeholder: '请选择知识库',
api: listKnowledge,
labelField: 'name',
valueField: 'id',
onChange: (value: string) => {
selectedKnowledgeId.value = value;
}
},
beforeFetch: (params) => Object.assign(params, queryParam),
},
exportConfig: {
name: '问答向量库',
url: getExportUrl,
],
});
const { tableContext, onExportXls } = useListPage({
tableProps: {
title: '问答向量库',
api: list,
columns,
formConfig: {
schemas: searchFormSchema,
autoSubmitOnEnter: true,
showAdvancedButton: true
},
importConfig: {
url: getImportZipUrl,
success: handleSuccess,
actionColumn: {
width: 120,
fixed: 'right'
},
});
beforeFetch: (params) => Object.assign(params, queryParam)
},
exportConfig: {
name: '问答向量库',
url: getExportUrl
}
});
const [registerTable, { reload }, { rowSelection, selectedRowKeys }] = tableContext;
const superQueryConfig = reactive(superQuerySchema);
const [registerTable, { reload }, { rowSelection, selectedRowKeys }] = tableContext;
function handleSuperQuery(params) {
Object.assign(queryParam, params);
reload();
}
function handleOpenImportModal() {
importModalVisible.value = true;
selectedKnowledgeId.value = '';
resetFileInput();
}
function handleAdd() {
openModal(true, { isUpdate: false, showFooter: true });
}
function handleImportCancel() {
importModalVisible.value = false;
resetFileInput();
}
function handleEdit(record) {
openModal(true, { record, isUpdate: true, showFooter: true });
}
function triggerFileInput() {
fileInput.value?.click();
}
function handleDetail(record) {
openModal(true, { record, isUpdate: true, showFooter: false });
function handleFileChange(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
currentFile.value = input.files[0];
uploadProgress.value = 0;
uploadStatus.value = 'normal';
console.log('文件选择成功:', {
name: currentFile.value.name,
size: currentFile.value.size,
type: currentFile.value.type
});
} else {
currentFile.value = null;
}
}
async function handleDelete(record) {
await deleteOne({ id: record.id }, handleSuccess);
function resetFileInput() {
if (fileInput.value) {
fileInput.value.value = '';
}
currentFile.value = null;
uploadProgress.value = 0;
uploadStatus.value = 'normal';
}
async function batchHandleDelete() {
await batchDelete({ ids: selectedRowKeys.value }, handleSuccess);
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function handleSuccess() {
selectedRowKeys.value = [];
reload();
}
// 获取用户token
const userStore = useUserStore();
const token = getToken();
function getTableAction(record) {
return [{ label: '编辑', onClick: handleEdit.bind(null, record) }];
async function handleImportSubmit() {
if (!selectedKnowledgeId.value || !currentFile.value) {
message.warning('请完整填写上传信息');
return;
}
function getDropDownAction(record) {
return [
{ label: '详情', onClick: handleDetail.bind(null, record) },
{
label: '删除',
popConfirm: {
title: '确认删除此问答?',
confirm: handleDelete.bind(null, record),
},
const formData = new FormData();
formData.append('file', currentFile.value, currentFile.value.name);
formData.append('knowledgeId', selectedKnowledgeId.value);
try {
uploadProgress.value = 0;
uploadStatus.value = 'active';
// 使用fetch API并添加认证头
const response = await fetch("/jeecgboot" + getImportZipUrl, {
method: 'POST',
body: formData,
headers: {
'X-Access-Token': token, // JEECG标准认证头
'Authorization': `Bearer ${token}` // 备用认证头
},
];
credentials: 'include' // 携带cookie
});
if (response.status === 401) {
message.error('登录已过期,请重新登录');
userStore.logout(true);
return;
}
if (!response.ok) {
throw new Error(`上传失败: ${response.statusText}`);
}
const result = await response.json();
if (result.success) {
message.success('上传成功');
importModalVisible.value = false;
handleSuccess();
} else {
throw new Error(result.message || '上传处理失败');
}
} catch (error) {
uploadStatus.value = 'exception';
message.error(error.message || '上传失败');
console.error('上传错误:', error);
}
}
function handleSuperQuery(params: any) {
Object.assign(queryParam, params);
reload();
}
function handleAdd() {
openModal(true, { isUpdate: false, showFooter: true });
}
function handleEdit(record: any) {
openModal(true, { record, isUpdate: true, showFooter: true });
}
function handleDetail(record: any) {
openModal(true, { record, isUpdate: true, showFooter: false });
}
async function handleDelete(record: any) {
await deleteOne({ id: record.id }, handleSuccess);
}
async function batchHandleDelete() {
await batchDelete({ ids: selectedRowKeys.value }, handleSuccess);
}
function handleSuccess() {
selectedRowKeys.value = [];
reload();
}
function getTableAction(record: any) {
return [
{ label: '编辑', onClick: handleEdit.bind(null, record) }
];
}
function getDropDownAction(record: any) {
return [
{ label: '详情', onClick: handleDetail.bind(null, record) },
{
label: '删除',
popConfirm: {
title: '确认删除此问答?',
confirm: handleDelete.bind(null, record)
}
}
];
}
</script>
<style scoped>
.upload-area {
margin-top: 20px;
padding: 20px;
border: 1px dashed #d9d9d9;
border-radius: 4px;
text-align: center;
cursor: pointer;
}
.upload-area:hover {
border-color: #1890ff;
}
</style>
... ...
... ... @@ -5,6 +5,7 @@ const { createMessage } = useMessage();
enum Api {
send = '/airag/zdyRag/send',
buttonList = '/airagbutton/airagButton/buttonList',
}
/**
... ... @@ -35,3 +36,16 @@ export const sendMessage = async (params: { questionText: string }) => {
return null;
}
};
export const getButtonList = async () =>{
const res = await defHttp.get({
url: Api.buttonList,
});
console.log("res",res);
if (res){
return res;
}
return null;
}
... ...
<template>
<div class="chat-container">
<div class="chat-layout">
<!-- 主内容区域 -->
<div class="main-content">
<!-- 聊天消息区域 -->
<div class="chat-messages" ref="messagesContainer">
<div v-for="(message, index) in messages" :key="index" :class="['message', message.type]">
... ... @@ -8,7 +9,10 @@
<div class="message-text" v-html="message.text"></div>
<div v-if="message.type === 'assistant' && message.similarity" class="message-meta">
相似度: {{ (message.similarity * 100).toFixed(2) }}%
<a v-if="message.fileBase64" @click="showPreview(message)">预览文件</a>
<div v-if="message.fileBase64">
相关资料:
<a @click="showPreview(message)">{{message.fileName}}</a>
</div>
</div>
</div>
</div>
... ... @@ -19,29 +23,65 @@
</div>
</div>
<!-- 文件预览区域 - 右侧 -->
<!-- 文件预览区域 -->
<div v-if="previewContent" class="file-preview">
<div class="preview-header">
<span>文件预览</span>
<a-button type="text" @click="closePreview">
<template #icon><CloseOutlined /></template>
</a-button>
<span class="preview-title">文件预览</span>
<div class="preview-actions">
<a-button type="link" @click="downloadFile" class="download-btn">
<DownloadOutlined />
<span class="btn-text">下载</span>
</a-button>
<a-button
type="text"
@click="closePreview"
class="close-btn"
title="关闭预览"
>
<CloseOutlined />
</a-button>
</div>
</div>
<div class="preview-content">
<div v-if="previewType === 'txt' || previewType === 'md'" class="text-preview">
<pre>{{ previewContent }}</pre>
</div>
<div v-else-if="previewType === 'doc' || previewType === 'docx'" class="doc-preview">
<!-- 使用Blob URL直接预览 -->
<iframe
:src="previewUrl"
frameborder="0"
style="width:100%; height:100%;"
></iframe>
<div v-else-if="previewType === 'pdf'" class="pdf-preview" ref="pdfPreview"></div>
<div v-else-if="previewType === 'docx'" class="docx-preview" ref="docxPreview"></div>
<div v-else-if="previewType === 'doc'" class="unsupported-preview">
不支持预览 DOC 格式的文件,请下载后使用本地软件查看
</div>
<div v-else class="unsupported-preview">
不支持预览 {{ previewType.toUpperCase() }} 格式的文件
</div>
</div>
</div>
</div>
<div>
<a-button
style="margin-left: 1%;
background-color: white;
color: #666;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 16px;
cursor: pointer;
font-size: 14px;
padding: 2px 10px;
width: max-content;
margin-right: 6px;
white-space: nowrap;
transition: all 300ms ease;
"
v-for="(btn, index) in buttonList"
:key="index"
type="primary"
class="action-button"
@click="sendButtonMessage(btn.buttonValues)"
>
{{ btn.buttonName }}
</a-button>
</div>
<!-- 输入区域 -->
<div class="chat-input">
... ... @@ -64,12 +104,31 @@
</template>
<script lang="ts" name="zdy-rag-chat" setup>
import { ref, reactive, nextTick } from 'vue';
import { sendMessage as apiSendMessage } from './ZdyRag.api';
import { ref, reactive, nextTick, onMounted, onBeforeUnmount } from 'vue';
import { sendMessage as apiSendMessage , getButtonList} from './ZdyRag.api';
import { useMessage } from '/@/hooks/web/useMessage';
import { CloseOutlined } from '@ant-design/icons-vue';
import { CloseOutlined, DownloadOutlined } from '@ant-design/icons-vue';
import * as pdfjsLib from 'pdfjs-dist';
import * as mammoth from 'mammoth';
import { renderAsync } from 'docx-preview';
const { createMessage } = useMessage();
// 设置PDF.js worker路径
pdfjsLib.GlobalWorkerOptions.workerSrc = '/js/pdf.worker.min.js';
const buttonList = reactive<any[]>([]);
onMounted(async () => {
try {
const res = await getButtonList();
if (res && Array.isArray(res)) {
buttonList.push(...res);
} else {
// createMessage.warning('未获取到按钮列表数据');
}
} catch (error) {
console.error('获取按钮列表失败:', error);
// createMessage.error('获取按钮列表失败');
}
});
// 聊天消息数据
const messages = reactive([
... ... @@ -77,7 +136,8 @@ const messages = reactive([
type: 'assistant',
text: '您好!我是智能助手,请问有什么可以帮您?',
similarity: null,
fileBase64: null
fileBase64: null,
fileName: null
}
]);
... ... @@ -87,6 +147,9 @@ const messagesContainer = ref<HTMLElement>();
const previewContent = ref('');
const previewType = ref('');
const previewUrl = ref('');
const currentFile = ref<any>(null);
const pdfPreview = ref<HTMLElement>();
const docxPreview = ref<HTMLElement>();
// 发送消息
const sendMessage = async () => {
... ... @@ -98,7 +161,8 @@ const sendMessage = async () => {
type: 'user',
text: text,
similarity: null,
fileBase64: null
fileBase64: null,
fileName: null
});
inputMessage.value = '';
... ... @@ -119,61 +183,212 @@ const sendMessage = async () => {
fileName: res.fileName || ''
};
messages.push(newMessage);
// 如果有文件内容,自动显示预览
if (res.fileBase64 && res.fileName) {
showPreview(newMessage);
}
} else {
createMessage.error('获取回答失败: 返回数据为空');
// createMessage.error('获取回答失败: 返回数据为空');
}
} catch (error) {
} catch (error: any) {
console.error('发送消息失败:', error);
createMessage.error('发送消息失败: ' + (error as Error).message);
// createMessage.error('发送消息失败: ' + error.message);
} finally {
loading.value = false;
scrollToBottom();
}
};
// 显示文件预览
const showPreview = (message) => {
if (!message.fileBase64 || !message.fileName) {
console.warn('无法预览: 缺少fileBase64或fileName');
return;
const sendButtonMessage = async (buttonValue: string) => {
if (loading.value) return;
// 添加用户消息
messages.push({
type: 'user',
text: buttonValue,
similarity: null,
fileBase64: null,
fileName: null
});
loading.value = true;
closePreview();
try {
// 调用API发送按钮值
const res = await apiSendMessage({ questionText: buttonValue });
if (res) {
const newMessage = {
type: 'assistant',
text: formatAnswer(res.answer),
similarity: res.similarity || null,
fileBase64: res.fileBase64 || null,
fileName: res.fileName || ''
};
messages.push(newMessage);
} else {
// createMessage.error('获取回答失败: 返回数据为空');
}
} catch (error: any) {
console.error('发送按钮消息失败:', error);
// createMessage.error('发送按钮消息失败: ' + error.message);
} finally {
loading.value = false;
scrollToBottom();
}
};
// 增强版showPreview函数
const showPreview = async (message: any) => {
try {
const fileExt = message.fileName.split('.').pop()?.toLowerCase() || '';
// 验证基本数据
if (!message?.fileBase64 || !message?.fileName) {
throw new Error('文件数据不完整');
}
// 关闭之前的预览
closePreview();
// 设置当前文件
currentFile.value = message;
const fileExt = getFileExtension(message.fileName);
previewType.value = fileExt;
if (fileExt === 'txt' || fileExt === 'md') {
// 对于文本文件,直接解码显示
previewContent.value = atob(message.fileBase64);
} else if (fileExt === 'doc' || fileExt === 'docx') {
// 对于Word文档,创建Blob对象并生成URL
const byteCharacters = atob(message.fileBase64);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], {
type: fileExt === 'docx' ?
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' :
'application/msword'
});
// 直接使用Blob URL
previewUrl.value = URL.createObjectURL(blob);
previewContent.value = 'doc-preview';
// 根据文件类型处理
if (['txt', 'md'].includes(fileExt)) {
// 文本文件处理
previewContent.value = decodeBase64Content(message.fileBase64);
} else if (fileExt === 'pdf') {
// PDF文件处理
previewContent.value = 'pdf-preview';
await nextTick(); // 等待DOM更新
await renderPdf(message.fileBase64);
} else if (fileExt === 'docx') {
// DOCX文件处理
previewContent.value = 'docx-preview';
await nextTick(); // 等待DOM更新
await renderDocx(message.fileBase64);
} else if (fileExt === 'doc') {
// 不支持DOC格式预览
previewContent.value = 'unsupported';
throw new Error('不支持DOC格式预览,请下载后查看');
} else {
console.warn('不支持的文件类型:', fileExt);
createMessage.warning(`不支持预览 ${fileExt} 格式的文件`);
throw new Error(`不支持的文件类型: ${fileExt}`);
}
} catch (error) {
console.error('文件预览失败:', error);
createMessage.error('文件预览失败');
console.error('预览失败:', error);
previewContent.value = 'error';
// createMessage.error(`预览失败: ${error.message}`);
}
};
// 渲染PDF
const renderPdf = async (base64: string) => {
if (!pdfPreview.value) return;
// 清空容器
pdfPreview.value.innerHTML = '';
// 将Base64转换为Uint8Array
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
// 加载PDF文档
const pdfDoc = await pdfjsLib.getDocument({ data: bytes }).promise;
// 创建Canvas容器
const container = document.createElement('div');
container.className = 'pdf-pages';
pdfPreview.value.appendChild(container);
// 渲染每一页
for (let i = 1; i <= pdfDoc.numPages; i++) {
const page = await pdfDoc.getPage(i);
const viewport = page.getViewport({ scale: 1.5 });
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.height = viewport.height;
canvas.width = viewport.width;
const renderContext = {
canvasContext: context,
viewport: viewport
};
await page.render(renderContext).promise;
const pageDiv = document.createElement('div');
pageDiv.className = 'pdf-page';
pageDiv.appendChild(canvas);
container.appendChild(pageDiv);
}
};
// 渲染DOCX
const renderDocx = async (base64: string) => {
if (!docxPreview.value) return;
// 清空容器
docxPreview.value.innerHTML = '';
// 将Base64转换为Blob
const byteString = atob(base64);
const byteNumbers = new Array(byteString.length);
for (let i = 0; i < byteString.length; i++) {
byteNumbers[i] = byteString.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], {
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
});
// 使用docx-preview渲染
await renderAsync(blob, docxPreview.value);
};
// 获取文件扩展名
const getFileExtension = (filename: string): string => {
const match = filename.match(/\.([0-9a-z]+)(?:[\?#]|$)/i);
return match ? match[1].toLowerCase() : 'unknown';
};
// Base64解码
const decodeBase64Content = (base64: string): string => {
try {
return decodeURIComponent(escape(atob(base64)));
} catch {
return atob(base64);
}
};
// 下载文件
const downloadFile = () => {
if (!currentFile.value || !currentFile.value.fileBase64 || !currentFile.value.fileName) {
// createMessage.warning('无法下载文件');
return;
}
try {
const byteCharacters = atob(currentFile.value.fileBase64);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: 'application/octet-stream' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = currentFile.value.fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// createMessage.success('文件下载开始');
} catch (error: any) {
console.error('文件下载失败:', error);
// createMessage.error('文件下载失败: ' + error.message);
}
};
... ... @@ -185,6 +400,11 @@ const closePreview = () => {
URL.revokeObjectURL(previewUrl.value);
previewUrl.value = '';
}
currentFile.value = null;
// 清空预览容器
if (pdfPreview.value) pdfPreview.value.innerHTML = '';
if (docxPreview.value) docxPreview.value.innerHTML = '';
};
// 格式化回答内容
... ... @@ -201,46 +421,125 @@ const scrollToBottom = () => {
}
});
};
// 组件卸载时清理资源
onBeforeUnmount(() => {
closePreview();
});
</script>
<style lang="less" scoped>
/* 样式保持不变 */
.chat-container {
display: flex;
flex-direction: column;
height: calc(100vh - 120px);
max-width: 1200px; /* 增加宽度以容纳两个区域 */
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
border-radius: 8px;
}
.chat-layout {
.main-content {
display: flex;
flex: 1;
gap: 20px;
overflow: hidden;
margin-bottom: 20px;
overflow: hidden;
}
.chat-messages {
flex: 2;
flex: 1;
overflow-y: auto;
padding: 10px;
padding: 15px;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
min-height: 300px;
}
//.file-preview {
// width: 55%;
// min-width: 300px;
// display: flex;
// flex-direction: column;
// background-color: white;
// border-radius: 8px;
// box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
// overflow: hidden;
//
// .preview-header {
// display: flex;
// justify-content: space-between;
// align-items: center;
// padding: 10px 15px;
// background-color: #f0f0f0;
// border-bottom: 1px solid #e8e8e8;
//
// .preview-actions {
// display: flex;
// align-items: center;
// gap: 8px;
// }
// }
//
// .preview-content {
// flex: 1;
// overflow: auto;
// padding: 10px;
//
// .text-preview {
// padding: 15px;
// white-space: pre-wrap;
// font-family: monospace;
// line-height: 1.6;
// }
//
// .pdf-preview {
// height: 100%;
// overflow-y: auto;
//
// .pdf-pages {
// display: flex;
// flex-direction: column;
// gap: 20px;
// padding: 10px;
//
// .pdf-page {
// border: 1px solid #e8e8e8;
// box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
// }
// }
// }
//
// .docx-preview {
// height: 100%;
// overflow-y: auto;
// padding: 20px;
// background-color: #fff;
// }
//
// .unsupported-preview {
// height: 100%;
// padding: 20px;
// display: flex;
// align-items: center;
// justify-content: center;
// color: #ff4d4f;
// font-weight: 500;
// }
// }
//}
.file-preview {
flex: 1;
min-width: 350px; /* 确保预览区域有最小宽度 */
width: 55%;
min-width: 300px;
display: flex;
flex-direction: column;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
overflow: hidden;
display: flex;
flex-direction: column;
.preview-header {
display: flex;
... ... @@ -249,30 +548,123 @@ const scrollToBottom = () => {
padding: 10px 15px;
background-color: #f0f0f0;
border-bottom: 1px solid #e8e8e8;
font-weight: 500;
font-size: 14px;
.preview-actions {
display: flex;
align-items: center;
gap: 10px;
.ant-btn {
padding: 0;
height: auto;
color: #666;
display: flex;
align-items: center;
transition: color 0.3s;
&:hover {
color: #1890ff;
background: transparent;
}
.anticon {
font-size: 16px;
}
}
.ant-btn-link {
padding: 0 8px;
}
}
}
.preview-content {
flex: 1;
overflow: auto;
padding: 10px;
.text-preview {
padding: 15px;
white-space: pre-wrap;
font-family: monospace;
line-height: 1.6;
background-color: #fafafa;
border-radius: 4px;
}
.doc-preview {
.pdf-preview {
height: 100%;
iframe {
width: 100%;
height: 100%;
border: none;
overflow-y: auto;
.pdf-pages {
display: flex;
flex-direction: column;
gap: 20px;
padding: 10px;
.pdf-page {
border: 1px solid #e8e8e8;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
background-color: white;
}
}
}
.docx-preview {
height: 100%;
overflow-y: auto;
padding: 20px;
background-color: #fff;
border: 1px solid #f0f0f0;
border-radius: 4px;
}
.unsupported-preview {
height: 100%;
padding: 20px;
display: flex;
align-items: center;
justify-content: center;
color: #ff4d4f;
font-weight: 500;
background-color: #fff2f0;
border-radius: 4px;
text-align: center;
}
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.file-preview {
width: 100%;
min-width: auto;
max-height: 300px;
.preview-header {
padding: 8px 12px;
.preview-actions {
gap: 6px;
.ant-btn-link {
padding: 0 4px;
}
}
}
.preview-content {
padding: 8px;
.text-preview,
.docx-preview {
padding: 10px;
}
}
}
}
.message {
margin-bottom: 15px;
display: flex;
... ... @@ -299,15 +691,16 @@ const scrollToBottom = () => {
}
.message-content {
max-width: 70%;
padding: 10px 15px;
max-width: 80%;
padding: 12px 16px;
word-wrap: break-word;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.message-meta {
font-size: 12px;
color: #666;
margin-top: 5px;
margin-top: 8px;
text-align: right;
a {
... ... @@ -323,17 +716,47 @@ const scrollToBottom = () => {
.chat-input {
display: flex;
gap: 10px;
gap: 12px;
padding: 10px 0;
:deep(.ant-input) {
flex: 1;
border-radius: 20px;
padding: 10px 15px;
padding: 12px 18px;
border: 1px solid #d9d9d9;
transition: all 0.3s;
&:hover {
border-color: #40a9ff;
}
&:focus {
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
}
.ant-btn {
border-radius: 20px;
padding: 0 20px;
padding: 0 24px;
height: 40px;
font-weight: 500;
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.main-content {
flex-direction: column;
}
.file-preview {
width: 100%;
min-width: auto;
max-height: 300px;
}
.message-content {
max-width: 90%;
}
}
</style>
... ...