4 小时以前 3ca2c89288db1d6cba514ced02c91624ef5fe497
src/views/collaborativeApproval/knowledgeBase/index.vue
@@ -225,18 +225,141 @@
        </div>
      </div>
    </FormDialog>
    <!-- 文件管理弹窗 -->
    <FormDialog
      v-model="filesDialogVisible"
      title="文件管理"
      :width="'900px'"
      @close="closeFilesDialog"
      @confirm="closeFilesDialog"
      @cancel="closeFilesDialog"
    >
      <div class="file-manager">
        <!-- 文件上传 -->
        <div class="upload-section">
          <el-upload
            :action="uploadUrl"
            :headers="uploadHeaders"
            :on-success="handleUploadSuccess"
            :on-error="handleUploadError"
            :before-upload="beforeUpload"
            multiple
            :show-file-list="false"
            accept=".txt,.md,.docx,.xlsx,.xls,.pdf"
          >
            <el-button type="primary">上传文件</el-button>
          </el-upload>
          <el-button
            type="success"
            @click="saveFiles"
            :disabled="uploadedBlobIds.length === 0"
            :loading="savingFiles"
            style="margin-left: 10px"
          >
            保存文件关联
          </el-button>
        </div>
        <!-- 文件列表与向量化状态 -->
        <el-table :data="fileList" style="margin-top: 20px" border>
          <el-table-column prop="fileName" label="文件名" show-overflow-tooltip />
          <el-table-column prop="fileType" label="文件类型" width="100" />
          <el-table-column label="向量化状态" width="120">
            <template #default="{ row }">
              <el-tag :type="getStatusType(row.vectorStatus)">
                {{ getStatusText(row.vectorStatus) }}
              </el-tag>
            </template>
          </el-table-column>
          <el-table-column prop="chunkCount" label="切片数" width="100" align="center" />
          <el-table-column prop="createTime" label="上传时间" width="180" />
          <el-table-column label="操作" width="150" align="center">
            <template #default="{ row }">
              <el-button
                v-if="row.vectorStatus === 3"
                type="text"
                @click="reprocessFile(row)"
              >
                重新处理
              </el-button>
              <el-button type="text" @click="deleteFile(row)" style="color: #f56c6c">
                删除
              </el-button>
            </template>
          </el-table-column>
        </el-table>
      </div>
    </FormDialog>
    <!-- 知识库问答弹窗 -->
    <FormDialog
      v-model="chatDialogVisible"
      title="知识库问答"
      :width="'800px'"
      @close="closeChatDialog"
      @confirm="closeChatDialog"
      @cancel="closeChatDialog"
    >
      <div class="knowledge-chat">
        <div class="chat-header">
          <el-tag type="success">当前知识库: {{ currentKnowledgeBase?.title }}</el-tag>
        </div>
        <!-- 对话区域 -->
        <div class="chat-messages" ref="chatMessagesRef">
          <div
            v-for="(msg, index) in messages"
            :key="index"
            :class="['message', msg.role]"
          >
            <div class="message-role">{{ msg.role === 'user' ? '我' : 'AI助手' }}</div>
            <div class="message-content">{{ msg.content }}</div>
          </div>
          <div v-if="chatLoading" class="message assistant">
            <div class="message-role">AI助手</div>
            <div class="message-content typing">正在思考中...</div>
          </div>
        </div>
        <!-- 输入框 -->
        <div class="chat-input">
          <el-input
            v-model="inputQuestion"
            placeholder="请输入问题,按回车发送"
            @keyup.enter="sendMessage"
            :disabled="chatLoading"
          >
            <template #append>
              <el-button @click="sendMessage" :loading="chatLoading">发送</el-button>
            </template>
          </el-input>
        </div>
      </div>
    </FormDialog>
  </div>
</template>
<script setup>
import { Search } from "@element-plus/icons-vue";
import { onMounted, ref, reactive, toRefs, getCurrentInstance, computed, watch } from "vue";
import { onMounted, ref, reactive, toRefs, getCurrentInstance, computed, watch, nextTick } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import PIMTable from "@/components/PIMTable/PIMTable.vue";
import FormDialog from '@/components/Dialog/FormDialog.vue';
import { listKnowledgeBase, delKnowledgeBase,addKnowledgeBase,updateKnowledgeBase } from "@/api/collaborativeApproval/knowledgeBase.js";
import {
  listKnowledgeBase,
  delKnowledgeBase,
  addKnowledgeBase,
  updateKnowledgeBase,
  getVectorStatus,
  reprocessVector,
  saveKnowledgeBaseFiles,
  deleteKnowledgeBaseFiles,
  knowledgeChat
} from "@/api/collaborativeApproval/knowledgeBase.js";
import useUserStore from '@/store/modules/user';
import { userListNoPageByTenantId } from '@/api/system/user.js';
import { getToken } from "@/utils/auth";
// 表单验证规则
const rules = {
@@ -283,7 +406,17 @@
  dialogTitle: "",
  dialogType: "add",
  viewDialogVisible: false,
  currentKnowledge: {}
  currentKnowledge: {},
  filesDialogVisible: false,
  currentKnowledgeBase: null,
  fileList: [],
  uploadedBlobIds: [],
  savingFiles: false,
  chatDialogVisible: false,
  messages: [],
  inputQuestion: "",
  chatLoading: false,
  memoryId: ""
});
const {
@@ -297,7 +430,17 @@
  dialogTitle,
  dialogType,
  viewDialogVisible,
  currentKnowledge
  currentKnowledge,
  filesDialogVisible,
  currentKnowledgeBase,
  fileList,
  uploadedBlobIds,
  savingFiles,
  chatDialogVisible,
  messages,
  inputQuestion,
  chatLoading,
  memoryId
} = toRefs(data);
// 表单引用
@@ -305,6 +448,12 @@
// 用户相关
const userStore = useUserStore();
const userList = ref([]);
// 聊天消息容器引用
const chatMessagesRef = ref();
// 文件上传相关
const uploadUrl = import.meta.env.VITE_APP_BASE_API + "/common/upload";
const uploadHeaders = { Authorization: "Bearer " + getToken() };
// 表格列配置
const tableColumn = ref([
@@ -352,6 +501,18 @@
    }
  },
  {
    label: "文件数量",
    prop: "fileCount",
    width: 100,
    align: "center"
  },
  {
    label: "切片数量",
    prop: "totalChunkCount",
    width: 100,
    align: "center"
  },
  {
    label: "使用次数",
    prop: "usageCount",
    width: 100,
@@ -379,6 +540,20 @@
        type: "text",
        clickFun: (row) => {
          openForm("edit", row);
        }
      },
      {
        name: "文件",
        type: "text",
        clickFun: (row) => {
          openFilesDialog(row);
        }
      },
      {
        name: "问答",
        type: "text",
        clickFun: (row) => {
          openChatDialog(row);
        }
      },
      {
@@ -680,6 +855,260 @@
const handleExport = () => {
  proxy.download('/knowledgeBase/export', { ...searchForm.value }, '知识库.xlsx')
}
// ============ 文件管理相关 ============
// 打开文件管理弹窗
const openFilesDialog = (row) => {
  currentKnowledgeBase.value = row;
  filesDialogVisible.value = true;
  loadFileList();
};
// 加载文件列表
const loadFileList = async () => {
  if (!currentKnowledgeBase.value?.id) return;
  try {
    const res = await getVectorStatus(currentKnowledgeBase.value.id);
    fileList.value = res.data || [];
  } catch (error) {
    console.error("加载文件列表失败:", error);
    ElMessage.error("加载文件列表失败");
  }
};
// 上传前校验
const beforeUpload = (file) => {
  const allowedTypes = ['.txt', '.md', '.docx', '.xlsx', '.xls', '.pdf'];
  const fileName = file.name.toLowerCase();
  const isAllowed = allowedTypes.some(type => fileName.endsWith(type));
  if (!isAllowed) {
    ElMessage.error('只支持 txt、md、docx、xlsx、xls、pdf 格式的文件');
    return false;
  }
  const isLt50M = file.size / 1024 / 1024 < 50;
  if (!isLt50M) {
    ElMessage.error('文件大小不能超过 50MB');
    return false;
  }
  return true;
};
// 上传成功
const handleUploadSuccess = (response, file) => {
  if (response.code === 200) {
    uploadedBlobIds.value.push(response.data.id);
    ElMessage.success(`文件 ${file.name} 上传成功`);
  } else {
    ElMessage.error(response.msg || "上传失败");
  }
};
// 上传失败
const handleUploadError = (error, file) => {
  ElMessage.error(`文件 ${file.name} 上传失败`);
};
// 保存文件关联
const saveFiles = async () => {
  if (uploadedBlobIds.value.length === 0) {
    ElMessage.warning("请先上传文件");
    return;
  }
  savingFiles.value = true;
  try {
    await saveKnowledgeBaseFiles({
      knowledgeBaseId: currentKnowledgeBase.value.id,
      storageBlobIds: uploadedBlobIds.value
    });
    ElMessage.success("文件关联保存成功,正在后台处理向量化");
    uploadedBlobIds.value = [];
    // 延迟刷新文件列表,给后台处理时间
    setTimeout(() => {
      loadFileList();
    }, 1000);
  } catch (error) {
    console.error("保存文件关联失败:", error);
    ElMessage.error("保存文件关联失败");
  } finally {
    savingFiles.value = false;
  }
};
// 重新处理向量化的文件
const reprocessFile = async (row) => {
  try {
    await reprocessVector(row.id);
    ElMessage.success("已重新提交向量化任务");
    // 延迟刷新
    setTimeout(() => {
      loadFileList();
    }, 1000);
  } catch (error) {
    console.error("重新处理失败:", error);
    ElMessage.error("重新处理失败");
  }
};
// 删除文件
const deleteFile = async (row) => {
  try {
    await ElMessageBox.confirm(
      "确定要删除该文件吗?删除后将无法恢复向量数据",
      "删除确认",
      {
        confirmButtonText: "确定",
        cancelButtonText: "取消",
        type: "warning"
      }
    );
    await deleteKnowledgeBaseFiles([row.id]);
    ElMessage.success("删除成功");
    loadFileList();
  } catch (error) {
    if (error !== 'cancel') {
      console.error("删除文件失败:", error);
      ElMessage.error("删除文件失败");
    }
  }
};
// 状态文本映射
const getStatusText = (status) => {
  const map = {
    0: '待处理',
    1: '处理中',
    2: '已完成',
    3: '失败'
  };
  return map[status] || '未知';
};
// 状态标签类型映射
const getStatusType = (status) => {
  const map = {
    0: 'info',
    1: 'warning',
    2: 'success',
    3: 'danger'
  };
  return map[status] || 'info';
};
// 关闭文件管理弹窗
const closeFilesDialog = () => {
  filesDialogVisible.value = false;
  currentKnowledgeBase.value = null;
  fileList.value = [];
  uploadedBlobIds.value = [];
  getList(); // 刷新主列表,更新文件数量
};
// ============ 知识库问答相关 ============
// 打开问答弹窗
const openChatDialog = (row) => {
  currentKnowledgeBase.value = row;
  chatDialogVisible.value = true;
  memoryId.value = crypto.randomUUID();
  messages.value = [];
  inputQuestion.value = "";
};
// 发送消息
const sendMessage = async () => {
  if (!inputQuestion.value.trim()) {
    ElMessage.warning("请输入问题");
    return;
  }
  const question = inputQuestion.value.trim();
  // 添加用户消息
  messages.value.push({
    role: 'user',
    content: question
  });
  inputQuestion.value = "";
  chatLoading.value = true;
  // 滚动到底部
  await nextTick();
  scrollToBottom();
  try {
    // 流式请求
    const response = await fetch('/api/ai/knowledge/chat', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': 'Bearer ' + getToken()
      },
      body: JSON.stringify({
        knowledgeBaseId: currentKnowledgeBase.value.id,
        memoryId: memoryId.value,
        question: question
      })
    });
    if (!response.ok) {
      throw new Error('请求失败');
    }
    // 处理SSE流式响应
    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let aiContent = '';
    messages.value.push({ role: 'assistant', content: '' });
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      const text = decoder.decode(value);
      aiContent += text;
      messages.value[messages.value.length - 1].content = aiContent;
      // 滚动到底部
      await nextTick();
      scrollToBottom();
    }
  } catch (error) {
    console.error("问答请求失败:", error);
    ElMessage.error("问答请求失败,请稍后重试");
    messages.value.push({
      role: 'assistant',
      content: '抱歉,发生了错误,请稍后重试'
    });
  } finally {
    chatLoading.value = false;
  }
};
// 滚动到底部
const scrollToBottom = () => {
  if (chatMessagesRef.value) {
    chatMessagesRef.value.scrollTop = chatMessagesRef.value.scrollHeight;
  }
};
// 关闭问答弹窗
const closeChatDialog = () => {
  chatDialogVisible.value = false;
  currentKnowledgeBase.value = null;
  messages.value = [];
  inputQuestion.value = "";
};
</script>
<style scoped>
@@ -755,4 +1184,92 @@
  font-size: 14px;
  color: #909399;
}
/* 文件管理样式 */
.file-manager {
  padding: 20px 0;
}
.upload-section {
  display: flex;
  align-items: center;
}
/* 知识库问答样式 */
.knowledge-chat {
  display: flex;
  flex-direction: column;
  height: 500px;
}
.chat-header {
  margin-bottom: 16px;
}
.chat-messages {
  flex: 1;
  overflow-y: auto;
  padding: 16px;
  background: #f5f7fa;
  border-radius: 8px;
  margin-bottom: 16px;
}
.message {
  margin-bottom: 16px;
  max-width: 80%;
}
.message.user {
  margin-left: auto;
  text-align: right;
}
.message.assistant {
  margin-right: auto;
}
.message-role {
  font-size: 12px;
  color: #909399;
  margin-bottom: 4px;
}
.message-content {
  display: inline-block;
  padding: 10px 14px;
  border-radius: 8px;
  line-height: 1.6;
  word-wrap: break-word;
  white-space: pre-wrap;
}
.message.user .message-content {
  background: #409eff;
  color: white;
}
.message.assistant .message-content {
  background: white;
  color: #303133;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.typing {
  animation: typing 1.5s infinite;
}
@keyframes typing {
  0%, 50%, 100% {
    opacity: 1;
  }
  25%, 75% {
    opacity: 0.5;
  }
}
.chat-input {
  margin-top: auto;
}
</style>