已添加1个文件
已修改8个文件
992 ■■■■ 文件已修改
doc/20260608_知识库RAG向量检索功能前端联调文档.md 598 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/collaborativeApproval/knowledgeBase.js 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/collaborativeApproval/knowledgeBase/index.vue 72 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/collaborativeApproval/notificationManagement/meetApplication/index.vue 186 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/collaborativeApproval/notificationManagement/meetExamine/index.vue 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/collaborativeApproval/notificationManagement/meetIndex/index.vue 51 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/collaborativeApproval/notificationManagement/meetPublish/index.vue 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/collaborativeApproval/notificationManagement/meetSetting/index.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/collaborativeApproval/notificationManagement/summary/index.vue 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260608_֪ʶ¿âRAGÏòÁ¿¼ìË÷¹¦ÄÜǰ¶ËÁªµ÷Îĵµ.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,598 @@
# çŸ¥è¯†åº“文件上传与RAG向量检索功能前端联调文档
## ä¸€ã€åŠŸèƒ½æ¦‚è¿°
知识库模块新增文件上传功能,上传的文件会实时进入向量库进行切片处理,配合AI模块实现RAG(检索增强生成)问答功能。
### åŠŸèƒ½æ¨¡å—
1. **知识库文件管理**:使用系统已有的附件管理机制上传文件
2. **向量检索处理**:文件内容自动切片并存入向量库(Pinecone)
3. **知识库问答**:基于上传文件内容进行智能问答
## äºŒã€æ•°æ®åº“变更
### 2.1 æ–°å¢žçŸ¥è¯†åº“文件向量记录表
```sql
-- çŸ¥è¯†åº“文件向量记录表(用于跟踪向量化状态)
CREATE TABLE IF NOT EXISTS knowledge_base_vector (
    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
    knowledge_base_id BIGINT NOT NULL COMMENT '关联知识库ID',
    storage_blob_id BIGINT NOT NULL COMMENT '关联文件blob ID',
    file_name VARCHAR(255) NOT NULL COMMENT '文件名称',
    file_type VARCHAR(50) NOT NULL COMMENT '文件类型(docx/pdf/xlsx/txt等)',
    vector_status TINYINT DEFAULT 0 COMMENT '向量化状态: 0-待处理, 1-处理中, 2-已完成, 3-失败',
    vector_error VARCHAR(500) COMMENT '向量化失败原因',
    chunk_count INT DEFAULT 0 COMMENT '切片数量',
    namespace VARCHAR(100) COMMENT '向量命名空间',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    create_user INT COMMENT '创建人',
    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    update_user INT COMMENT '更新人',
    tenant_id BIGINT COMMENT '租户ID',
    dept_id BIGINT COMMENT '部门ID',
    INDEX idx_knowledge_base_id (knowledge_base_id),
    INDEX idx_storage_blob_id (storage_blob_id),
    INDEX idx_vector_status (vector_status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识库文件向量记录表';
```
### 2.2 ä¿®æ”¹çŸ¥è¯†åº“表
```sql
-- çŸ¥è¯†åº“表增加字段
ALTER TABLE knowledge_base
ADD COLUMN file_count INT DEFAULT 0 COMMENT '文件数量',
ADD COLUMN total_chunk_count INT DEFAULT 0 COMMENT '总切片数量',
ADD COLUMN description VARCHAR(500) COMMENT '知识库描述';
```
## ä¸‰ã€æŽ¥å£è®¾è®¡
### 3.1 æ–‡ä»¶ä¸Šä¼ ï¼ˆä½¿ç”¨ç³»ç»Ÿå·²æœ‰æŽ¥å£ï¼‰
**接口地址**:`POST /common/upload`
**请求方式**:multipart/form-data
**请求参数**:
| å‚数名 | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|--------|------|------|------|
| files | MultipartFile[] | æ˜¯ | ä¸Šä¼ çš„æ–‡ä»¶åˆ—表 |
**响应结果**:
```json
{
  "code": 200,
  "msg": "操作成功",
  "data": [
    {
      "id": 123,
      "name": "操作手册.docx",
      "url": "/profile/upload/20260608/xxx.docx",
      "previewURL": "/common/preview/xxx?token=yyy",
      "downloadURL": "/common/download/xxx?token=yyy",
      "storageAttachmentId": null
    }
  ]
}
```
### 3.2 ä¿å­˜çŸ¥è¯†åº“文件关联(触发向量化)
**重要**:上传完成后,**必须**调用此接口来关联文件并触发向量化处理。
**接口地址**:`POST /knowledgeBase/file/save`
**请求参数**:
```json
{
  "knowledgeBaseId": 10,
  "storageBlobIds": [123, 124]
}
```
| å‚数名 | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|--------|------|------|------|
| knowledgeBaseId | Long | æ˜¯ | çŸ¥è¯†åº“ID |
| storageBlobIds | List<Long> | æ˜¯ | ä¸Šä¼ è¿”回的文件blob ID列表 |
**响应结果**:
```json
{
  "code": 200,
  "msg": "操作成功"
}
```
**调用时机**:在 `/common/upload` ä¸Šä¼ æˆåŠŸåŽï¼Œç«‹å³è°ƒç”¨æ­¤æŽ¥å£ã€‚
**注意**:此接口会:
1. ä¿å­˜é™„件关联到 `storage_attachment` è¡¨
2. åˆ›å»ºå‘量记录到 `knowledge_base_vector` è¡¨
3. **异步触发向量化处理**(文件切片 â†’ å‘量嵌入 â†’ å­˜å…¥Pinecone)
---
### 3.3 æŸ¥è¯¢å‘量化状态(推荐使用)
**接口地址**:`GET /knowledgeBase/vector/status/{knowledgeBaseId}`
**响应结果**:
```json
{
  "code": 200,
  "data": [
    {
      "id": 1,
      "storageBlobId": 123,
      "fileName": "操作手册.docx",
      "fileType": "docx",
      "vectorStatus": 2,
      "chunkCount": 15,
      "namespace": "kb-10",
      "vectorError": null
    }
  ]
}
```
### 3.4 åˆ é™¤çŸ¥è¯†åº“文件
**接口地址**:`DELETE /knowledgeBase/file/delete`
**请求参数**:
```json
{
  "ids": [1, 2, 3]
}
```
**注意**:此接口会同时删除向量库中的相关数据。
**响应结果**:
```json
{
  "code": 200,
  "msg": "操作成功"
}
```
### 3.5 é‡æ–°å‘量化文件
**接口地址**:`POST /knowledgeBase/vector/reprocess/{vectorId}`
**响应结果**:
```json
{
  "code": 200,
  "msg": "已重新提交向量化任务"
}
```
### 3.6 çŸ¥è¯†åº“问答接口
**接口地址**:`POST /ai/knowledge/chat`(流式返回)
**请求参数**:
```json
{
  "knowledgeBaseId": 10,
  "memoryId": "session-xxx",
  "question": "如何操作审批流程?"
}
```
| å‚数名 | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|--------|------|------|------|
| knowledgeBaseId | Long | æ˜¯ | çŸ¥è¯†åº“ID |
| memoryId | String | æ˜¯ | ä¼šè¯ID,用于保持上下文 |
| question | String | æ˜¯ | ç”¨æˆ·æé—®å†…容 |
**响应结果**(流式返回 `text/stream;charset=utf-8`):
```
根据知识库内容,审批流程的操作步骤如下:
1. ç™»å½•系统后进入审批管理模块...
```
### 3.7 çŸ¥è¯†åº“问答会话历史
**接口地址**:`GET /ai/knowledge/history/{memoryId}`
**响应结果**:
```json
{
  "code": 200,
  "data": [
    {
      "role": "user",
      "content": "如何操作审批流程?",
      "createTime": "2026-06-08 10:00:00"
    },
    {
      "role": "assistant",
      "content": "根据知识库内容...",
      "createTime": "2026-06-08 10:01:00"
    }
  ]
}
```
## å››ã€æ–‡ä»¶ç±»åž‹æ”¯æŒ
| æ–‡ä»¶ç±»åž‹ | æ‰©å±•名 | è¯´æ˜Ž |
|----------|--------|------|
| Word文档 | .docx | æ”¯æŒæ–‡æœ¬æå– |
| Excel表格 | .xlsx, .xls | æ”¯æŒè¡¨æ ¼å†…容提取 |
| PDF文档 | .pdf | æ”¯æŒPDF文本提取 |
| æ–‡æœ¬æ–‡ä»¶ | .txt, .md, .json, .csv | ç›´æŽ¥è¯»å–内容 |
| ä»£ç æ–‡ä»¶ | .java, .js, .vue, .html, .sql等 | ç›´æŽ¥è¯»å–内容 |
**文件大小限制**:单文件最大10MB(系统默认限制)
## äº”、向量化状态说明
| çŠ¶æ€å€¼ | çŠ¶æ€åç§° | è¯´æ˜Ž |
|--------|----------|------|
| 0 | å¾…处理 | æ–‡ä»¶å·²ä¸Šä¼ ï¼Œç­‰å¾…向量化处理 |
| 1 | å¤„理中 | æ­£åœ¨è¿›è¡Œå‘量切片处理 |
| 2 | å·²å®Œæˆ | å‘量化完成,可进行检索问答 |
| 3 | å¤±è´¥ | å‘量化失败,需重新处理 |
## å…­ã€å‰ç«¯ç»„件设计
### 6.1 æ–‡ä»¶ä¸Šä¼ ç»„件(使用系统已有上传)
```vue
<template>
  <div class="knowledge-file-upload">
    <!-- çŸ¥è¯†åº“选择 -->
    <el-select v-model="selectedKnowledgeBase" placeholder="选择知识库" style="width: 200px">
      <el-option
        v-for="kb in knowledgeBaseList"
        :key="kb.id"
        :label="kb.title"
        :value="kb.id"
      />
    </el-select>
    <!-- ä½¿ç”¨ç³»ç»Ÿå·²æœ‰ä¸Šä¼ ç»„ä»¶ -->
    <el-upload
      :action="uploadUrl"
      :headers="uploadHeaders"
      :on-success="handleUploadSuccess"
      :before-upload="beforeUpload"
      :accept="acceptTypes"
      :limit="10"
      :file-list="fileList"
      multiple
    >
      <el-button type="primary">点击上传</el-button>
      <template #tip>
        <div class="el-upload__tip">
          æ”¯æŒ docx、xlsx、pdf、txt ç­‰æ ¼å¼ï¼Œå•文件不超过10MB
        </div>
      </template>
    </el-upload>
  </div>
</template>
<script setup>
import { ref } from 'vue'
import { getToken } from '@/utils/auth'
import request from '@/utils/request'
const uploadUrl = '/common/upload'
const uploadHeaders = { Authorization: 'Bearer ' + getToken() }
const acceptTypes = '.docx,.xlsx,.xls,.pdf,.txt,.md,.json,.csv'
const uploadedBlobs = ref([])
const beforeUpload = (file) => {
  const maxSize = 10 * 1024 * 1024
  if (file.size > maxSize) {
    ElMessage.error('文件大小不能超过10MB')
    return false
  }
  return true
}
// ä¸Šä¼ æˆåŠŸåŽä¿å­˜æ–‡ä»¶å…³è”å¹¶è§¦å‘å‘é‡åŒ–
const handleUploadSuccess = async (response, file) => {
  if (response.code === 200) {
    uploadedBlobs.value.push(...response.data)
    // è°ƒç”¨çŸ¥è¯†åº“文件保存接口,关联文件并触发向量化
    await saveKnowledgeBaseFiles()
    ElMessage.success('文件上传成功,正在处理向量化...')
    refreshVectorStatus()
  } else {
    ElMessage.error(response.msg)
  }
}
// ä¿å­˜æ–‡ä»¶å…³è”到知识库并触发向量化
const saveKnowledgeBaseFiles = async () => {
  await request.post('/knowledgeBase/file/save', {
    knowledgeBaseId: selectedKnowledgeBase.value,
    storageBlobIds: uploadedBlobs.value.map(b => b.id)
  })
  uploadedBlobs.value = []
}
</script>
```
### 6.2 æ–‡ä»¶åˆ—表组件
```vue
<template>
  <el-table :data="fileList" v-loading="loading">
    <el-table-column prop="name" label="文件名" width="200" />
    <el-table-column prop="fileType" label="类型" width="80">
      <template #default="{ row }">
        {{ getFileType(row.name) }}
      </template>
    </el-table-column>
    <el-table-column prop="vectorStatus" label="向量化状态" width="120">
      <template #default="{ row }">
        <el-tag :type="getVectorStatusType(row.vectorStatus)">
          {{ getVectorStatusText(row.vectorStatus) }}
        </el-tag>
      </template>
    </el-table-column>
    <el-table-column prop="chunkCount" label="切片数" width="80" />
    <el-table-column prop="createTime" label="上传时间" width="160" />
    <el-table-column label="操作" width="180">
      <template #default="{ row }">
        <el-button type="primary" size="small" link @click="previewFile(row)">预览</el-button>
        <el-button type="primary" size="small" link @click="downloadFile(row)">下载</el-button>
        <el-button
          v-if="row.vectorStatus === 3"
          type="warning"
          size="small"
          link
          @click="revectorFile(row)"
        >重新处理</el-button>
        <el-button type="danger" size="small" link @click="deleteFile(row)">删除</el-button>
      </template>
    </el-table-column>
  </el-table>
</template>
<script setup>
import request from '@/utils/request'
const getFileType = (name) => {
  return name?.split('.').pop() || ''
}
const getVectorStatusText = (status) => {
  const statusMap = { 0: '待处理', 1: '处理中', 2: '已完成', 3: '失败' }
  return statusMap[status] || '未知'
}
const getVectorStatusType = (status) => {
  const typeMap = { 0: 'info', 1: 'warning', 2: 'success', 3: 'danger' }
  return typeMap[status] || 'info'
}
const previewFile = (row) => {
  window.open(row.previewURL, '_blank')
}
const downloadFile = (row) => {
  window.open(row.downloadURL, '_blank')
}
const deleteFile = async (row) => {
  await request.delete('/knowledgeBase/file/delete', { data: [row.id] })
  ElMessage.success('删除成功')
  refreshFileList()
}
const revectorFile = async (row) => {
  await request.post(`/knowledgeBase/vector/reprocess/${row.vectorId}`)
  ElMessage.success('已重新提交向量化任务')
  refreshVectorStatus()
}
</script>
```
### 6.3 çŸ¥è¯†åº“问答组件
```vue
<template>
  <div class="knowledge-chat">
    <!-- çŸ¥è¯†åº“选择 -->
    <div class="kb-selector">
      <el-select v-model="selectedKnowledgeBase" placeholder="选择知识库">
        <el-option v-for="kb in knowledgeBaseList" :key="kb.id" :label="kb.title" :value="kb.id" />
      </el-select>
    </div>
    <!-- èŠå¤©åŒºåŸŸ -->
    <div class="chat-container">
      <div class="message-list" ref="messageList">
        <div v-for="msg in messages" :key="msg.id" :class="['message', msg.role]">
          <div class="message-content">{{ msg.content }}</div>
        </div>
        <div v-if="streamingContent" class="message assistant">
          <div class="message-content">{{ streamingContent }}</div>
        </div>
      </div>
      <!-- è¾“入区域 -->
      <div class="input-area">
        <el-input
          v-model="question"
          placeholder="输入问题,基于知识库内容回答..."
          @keyup.enter="sendQuestion"
        />
        <el-button type="primary" @click="sendQuestion" :loading="sending">发送</el-button>
      </div>
    </div>
  </div>
</template>
<script setup>
import { ref, nextTick } from 'vue'
import { getToken } from '@/utils/auth'
const selectedKnowledgeBase = ref(null)
const question = ref('')
const messages = ref([])
const sending = ref(false)
const streamingContent = ref('')
const memoryId = ref('kb-chat-' + Date.now())
const sendQuestion = async () => {
  if (!question.value.trim() || !selectedKnowledgeBase.value) return
  sending.value = true
  streamingContent.value = ''
  messages.value.push({ id: Date.now(), role: 'user', content: question.value })
  try {
    const response = await fetch('/ai/knowledge/chat', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: 'Bearer ' + getToken()
      },
      body: JSON.stringify({
        knowledgeBaseId: selectedKnowledgeBase.value,
        memoryId: memoryId.value,
        question: question.value
      })
    })
    // æµå¼è¯»å–响应
    const reader = response.body.getReader()
    const decoder = new TextDecoder()
    while (true) {
      const { done, value } = await reader.read()
      if (done) break
      streamingContent.value += decoder.decode(value, { stream: true })
      // æ»šåŠ¨åˆ°åº•éƒ¨
      nextTick(() => {
        const list = document.querySelector('.message-list')
        if (list) list.scrollTop = list.scrollHeight
      })
    }
    messages.value.push({ id: Date.now(), role: 'assistant', content: streamingContent.value })
    streamingContent.value = ''
  } catch (error) {
    ElMessage.error('问答请求失败')
  } finally {
    sending.value = false
    question.value = ''
  }
}
</script>
<style scoped>
.knowledge-chat {
  height: 100%;
  display: flex;
  flex-direction: column;
}
.kb-selector {
  padding: 10px;
  border-bottom: 1px solid #eee;
}
.chat-container {
  flex: 1;
  display: flex;
  flex-direction: column;
}
.message-list {
  flex: 1;
  overflow-y: auto;
  padding: 10px;
}
.message {
  margin-bottom: 10px;
  padding: 10px;
  border-radius: 8px;
}
.message.user {
  background: #e6f7ff;
  text-align: right;
}
.message.assistant {
  background: #f5f5f5;
}
.input-area {
  padding: 10px;
  display: flex;
  gap: 10px;
}
</style>
```
## ä¸ƒã€ä¸šåŠ¡æµç¨‹
### 7.1 æ–‡ä»¶ä¸Šä¼ æµç¨‹ï¼ˆé‡è¦ï¼‰
```
前端调用 /common/upload ä¸Šä¼ æ–‡ä»¶
    â†“
获取 StorageBlobVO åˆ—表(包含blobId、预览URL、下载URL)
    â†“
调用 /knowledgeBase/file/save å…³è”文件到知识库
    â†“
后端创建向量记录 â†’ å¼‚步触发向量化处理
    â†“
异步任务:提取文件文本 â†’ åˆ‡ç‰‡ â†’ è°ƒç”¨Embedding模型生成向量 â†’ å­˜å…¥Pinecone
    â†“
更新 knowledge_base_vector è¡¨çŠ¶æ€ä¸ºå·²å®Œæˆ
```
**关键点**:
- å¿…须调用 `/knowledgeBase/file/save` æŽ¥å£æ‰èƒ½è§¦å‘向量化
- å‘量化是异步处理的,不会阻塞请求
- å‰ç«¯å¯é€šè¿‡è½®è¯¢ `/knowledgeBase/vector/status/{knowledgeBaseId}` æŸ¥çœ‹å¤„理进度
### 7.2 çŸ¥è¯†åº“问答流程
```
用户提问 â†’ è°ƒç”¨ Embedding æ¨¡åž‹å¯¹é—®é¢˜å‘量化
    â†“
在 Pinecone ä¸­æ£€ç´¢ï¼ˆå‘½åç©ºé—´: kb-{knowledgeBaseId})
    â†“
获取相关切片文本 â†’ ä½œä¸ºä¸Šä¸‹æ–‡ + ç”¨æˆ·é—®é¢˜å‘ç»™ AI æ¨¡åž‹
    â†“
AI æµå¼ç”Ÿæˆå›žç­” â†’ è¿”回前端
```
## å…«ã€æŠ€æœ¯å®žçŽ°è¦ç‚¹
### 8.1 æ–‡æœ¬åˆ‡ç‰‡ç­–ç•¥
- **切片大小**:默认每片 500 å­—符
- **重叠大小**:默认 100 å­—符重叠,保证语义连贯
- **切片元数据**:包含文件ID、知识库ID、切片索引
### 8.2 å‘量命名空间
每个知识库使用独立命名空间:`kb-{knowledgeBaseId}`
## ä¹ã€æ³¨æ„äº‹é¡¹
1. **文件上传必须调用 `/knowledgeBase/file/save` è§¦å‘向量化**
2. åˆ é™¤æ–‡ä»¶æ—¶åŒæ­¥åˆ é™¤å‘量库中的相关切片
3. å¤§æ–‡ä»¶å‘量化可能耗时较长,前端需轮询状态或显示进度
4. çŸ¥è¯†åº“问答依赖向量检索质量,建议优化切片策略
5. ä¸åŒçŸ¥è¯†åº“使用不同命名空间,避免数据混淆
## åã€é”™è¯¯ç è¯´æ˜Ž
| é”™è¯¯ç  | è¯´æ˜Ž |
|--------|------|
| 40001 | æ–‡ä»¶ç±»åž‹ä¸æ”¯æŒ |
| 40002 | æ–‡ä»¶å¤§å°è¶…出限制 |
| 40003 | çŸ¥è¯†åº“不存在 |
| 50001 | æ–‡ä»¶ä¸Šä¼ å¤±è´¥ |
| 50002 | æ–‡ä»¶å†…容提取失败 |
| 50003 | å‘量化处理失败 |
| 50004 | å‘量检索失败 |
src/api/collaborativeApproval/knowledgeBase.js
@@ -54,7 +54,7 @@
  });
}
// æŸ¥è¯¢çŸ¥è¯†åº“文件向量化状态
// æŸ¥è¯¢çŸ¥è¯†åº“文件向量化状态(包含文件列表)
export function getVectorStatus(knowledgeBaseId) {
  return request({
    url: `/knowledgeBase/vector/status/${knowledgeBaseId}`,
@@ -62,6 +62,24 @@
  });
}
// ä¿å­˜çŸ¥è¯†åº“文件关联(触发向量化)
export function saveKnowledgeBaseFiles(data) {
  return request({
    url: "/knowledgeBase/file/save",
    method: "post",
    data,
  });
}
// åˆ é™¤çŸ¥è¯†åº“文件
export function deleteKnowledgeBaseFile(ids) {
  return request({
    url: "/knowledgeBase/file/delete",
    method: "delete",
    data: ids,
  });
}
// é‡æ–°å‘量化文件
export function reprocessVector(vectorId) {
  return request({
src/views/collaborativeApproval/knowledgeBase/index.vue
@@ -256,6 +256,7 @@
              :action="uploadUrl"
              :headers="uploadHeaders"
              :on-success="handleUploadSuccess"
              :on-change="handleFileChange"
              :before-upload="beforeUpload"
              :accept="acceptTypes"
              :file-list="uploadFileList"
@@ -398,9 +399,10 @@
  updateKnowledgeBase,
  getVectorStatus,
  reprocessVector,
  knowledgeChat
  knowledgeChat,
  saveKnowledgeBaseFiles,
  deleteKnowledgeBaseFile
} from "@/api/collaborativeApproval/knowledgeBase.js";
import { attachmentList, createAttachment, deleteAttachment } from "@/api/basicData/storageAttachment.js";
import useUserStore from '@/store/modules/user';
import { userListNoPageByTenantId } from '@/api/system/user.js';
import { getToken } from '@/utils/auth';
@@ -874,33 +876,17 @@
  refreshFileList();
};
// åˆ·æ–°æ–‡ä»¶åˆ—表
// åˆ·æ–°æ–‡ä»¶åˆ—表(直接使用向量状态接口)
const refreshFileList = async () => {
  if (!selectedKnowledgeBaseId.value) return;
  fileLoading.value = true;
  try {
    // èŽ·å–é™„ä»¶åˆ—è¡¨
    const attachmentRes = await attachmentList({
      recordType: 'knowledge_base',
      recordId: selectedKnowledgeBaseId.value,
      application: 'rag_file'
    });
    // èŽ·å–å‘é‡åŒ–çŠ¶æ€
    const vectorRes = await getVectorStatus(selectedKnowledgeBaseId.value);
    // åˆå¹¶æ•°æ®
    const vectorMap = {};
    (vectorRes.data || []).forEach(v => {
      vectorMap[v.storageBlobId] = v;
    });
    fileList.value = (attachmentRes.data || []).map(file => ({
      ...file,
      vectorStatus: vectorMap[file.storageBlobId]?.vectorStatus ?? 0,
      chunkCount: vectorMap[file.storageBlobId]?.chunkCount ?? 0,
      vectorId: vectorMap[file.storageBlobId]?.id
    const res = await getVectorStatus(selectedKnowledgeBaseId.value);
    fileList.value = (res.data || []).map(item => ({
      ...item,
      name: item.fileName,
      vectorId: item.id
    }));
  } catch (error) {
    console.error('获取文件列表失败:', error);
@@ -924,23 +910,35 @@
const handleUploadSuccess = async (response, file) => {
  if (response.code === 200) {
    uploadedBlobs.value.push(...response.data);
    await saveAttachment();
    ElMessage.success('文件上传成功,正在处理向量化...');
    uploadedBlobs.value = [];
    refreshFileList();
  } else {
    ElMessage.error(response.msg || '上传失败');
  }
};
// ä¿å­˜é™„件关联到知识库
const saveAttachment = async () => {
  await createAttachment({
    recordType: 'knowledge_base',
    recordId: selectedKnowledgeBaseId.value,
    application: 'rag_file',
    storageBlobDTOs: uploadedBlobs.value.map(b => b.id)
  });
// ä¸Šä¼ æ–‡ä»¶å˜åŒ–处理(用于检测所有文件上传完成)
const handleFileChange = (file, fileList) => {
  // å½“文件状态变化且没有正在上传的文件时,统一提交
  if (file.status === 'success' && fileList.every(f => f.status === 'success' || f.status === 'fail')) {
    if (uploadedBlobs.value.length > 0) {
      saveKnowledgeBaseFilesAndRefresh();
    }
  }
};
// ä¿å­˜çŸ¥è¯†åº“文件关联并触发向量化
const saveKnowledgeBaseFilesAndRefresh = async () => {
  try {
    await saveKnowledgeBaseFiles({
      knowledgeBaseId: selectedKnowledgeBaseId.value,
      storageBlobIds: uploadedBlobs.value.map(b => b.id)
    });
    ElMessage.success('文件上传成功,正在处理向量化...');
  } catch (error) {
    ElMessage.error('保存文件关联失败');
  } finally {
    uploadedBlobs.value = [];
    refreshFileList();
  }
};
// èŽ·å–æ–‡ä»¶ç±»åž‹
@@ -987,7 +985,7 @@
      type: 'warning'
    });
    await deleteAttachment([row.id]);
    await deleteKnowledgeBaseFile([row.id]);
    ElMessage.success('删除成功');
    refreshFileList();
  } catch (error) {
src/views/collaborativeApproval/notificationManagement/meetApplication/index.vue
@@ -84,34 +84,24 @@
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="开始时间" prop="startTime">
              <el-select
              <el-time-picker
                  v-model="meetingForm.startTime"
                  placeholder="请选择开始时间"
                  value-format="HH:mm"
                  format="HH:mm"
                  style="width: 100%"
              >
                <el-option
                    v-for="time in startTimeOptions"
                    :key="time.value"
                    :label="time.label"
                    :value="time.value"
                />
              </el-select>
              />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="结束时间" prop="endTime">
              <el-select
              <el-time-picker
                  v-model="meetingForm.endTime"
                  placeholder="请选择结束时间"
                  value-format="HH:mm"
                  format="HH:mm"
                  style="width: 100%"
              >
                <el-option
                    v-for="time in endTimeOptions"
                    :key="time.value"
                    :label="time.label"
                    :value="time.value"
                />
              </el-select>
              />
            </el-form-item>
          </el-col>
        </el-row>
@@ -152,9 +142,9 @@
</template>
<script setup>
import {ref, reactive, onMounted, computed, watch} from 'vue'
import {ref, reactive, onActivated} from 'vue'
import {ElMessage} from 'element-plus'
import {Plus, Document, Promotion, Bell} from '@element-plus/icons-vue'
import {Document, Promotion, Bell} from '@element-plus/icons-vue'
import {getRoomEnum, saveMeetingApplication} from '@/api/collaborativeApproval/meeting.js'
import {userListNoPageByTenantId} from "@/api/system/user.js";
@@ -205,152 +195,16 @@
// å‘˜å·¥åˆ—表
const employees = ref([])
// æ—¶é—´é€‰é¡¹ï¼ˆä»¥åŠå°æ—¶ä¸ºé—´éš”)
const timeOptions = ref([])
const getTimeInMinutes = (time) => {
  if (!time) return -1
  const [hour, minute] = time.split(':').map(Number)
  return hour * 60 + minute
}
const isToday = (dateText) => {
  if (!dateText) return false
  const [year, month, day] = dateText.split('-').map(Number)
  const now = new Date()
  return year === now.getFullYear() && month === now.getMonth() + 1 && day === now.getDate()
}
const validateStartTime = (_rule, value, callback) => {
  if (!value) {
    callback()
    return
  }
  if (isToday(meetingForm.meetingDate)) {
    const now = new Date()
    const currentMinutes = now.getHours() * 60 + now.getMinutes()
    if (getTimeInMinutes(value) > currentMinutes) {
      callback(new Error('当天开始时间不能晚于当前时间'))
      return
    }
  }
  callback()
}
const validateEndTime = (_rule, value, callback) => {
  if (!value || !meetingForm.startTime) {
    callback()
    return
  }
  if (getTimeInMinutes(value) <= getTimeInMinutes(meetingForm.startTime)) {
    callback(new Error('结束时间必须大于开始时间'))
    return
  }
  callback()
}
// è¡¨å•校验规则
const rules = {
  title: [{required: true, message: '请输入会议主题', trigger: 'blur'}],
  roomId: [{required: true, message: '请选择会议室', trigger: 'change'}],
  host: [{required: true, message: '请输入主持人', trigger: 'blur'}],
  meetingDate: [{required: true, message: '请选择会议日期', trigger: 'change'}],
  startTime: [
    {required: true, message: '请选择开始时间', trigger: 'change'},
    {validator: validateStartTime, trigger: 'change'}
  ],
  endTime: [
    {required: true, message: '请选择结束时间', trigger: 'change'},
    {validator: validateEndTime, trigger: 'change'}
  ],
  startTime: [{required: true, message: '请选择开始时间', trigger: 'change'}],
  endTime: [{required: true, message: '请选择结束时间', trigger: 'change'}],
  participants: [{required: true, message: '请选择参会人员', trigger: 'change'}]
}
const startTimeOptions = computed(() => {
  if (!isToday(meetingForm.meetingDate)) {
    return timeOptions.value
  }
  const now = new Date()
  const currentMinutes = now.getHours() * 60 + now.getMinutes()
  return timeOptions.value.filter(item => getTimeInMinutes(item.value) <= currentMinutes)
})
const endTimeOptions = computed(() => {
  if (!meetingForm.startTime) {
    return timeOptions.value
  }
  const startMinutes = getTimeInMinutes(meetingForm.startTime)
  return timeOptions.value.filter(item => getTimeInMinutes(item.value) > startMinutes)
})
// åˆå§‹åŒ–时间选项
const initTimeOptions = () => {
  const options = []
  const now = new Date()
  const currentHour = now.getHours()
  const currentMinute = now.getMinutes()
  // meetingDate æ˜¯ "yyyy-MM-dd"
  const meetingDate = new Date(meetingForm.meetingDate)
  const isSameDay =
    now.getFullYear() === meetingDate.getFullYear() &&
    now.getMonth() === meetingDate.getMonth() &&
    now.getDate() === meetingDate.getDate()
  console.log('是否同一天:', isSameDay)
  for (let hour = 8; hour <= 18; hour++) {
    // å¼€å§‹æ—¶é—´å¿…须晚于当前时间
    if (hour < currentHour && isSameDay) {
      continue
    }
    if (hour === currentHour && currentMinute > 30 && isSameDay) {
      continue
    }
    // æ¯ä¸ªå°æ—¶æ·»åŠ ä¸¤ä¸ªé€‰é¡¹ï¼šæ•´ç‚¹å’ŒåŠç‚¹
    options.push({
      value: `${hour.toString().padStart(2, '0')}:00`,
      label: `${hour.toString().padStart(2, '0')}:00`
    })
    if (hour < 18) { // 18:00之后没有半点选项
      options.push({
        value: `${hour.toString().padStart(2, '0')}:30`,
        label: `${hour.toString().padStart(2, '0')}:30`
      })
    }
  }
  timeOptions.value = options
}
watch(() => meetingForm.meetingDate, () => {
  if (meetingForm.startTime && !startTimeOptions.value.some(item => item.value === meetingForm.startTime)) {
    meetingForm.startTime = ''
  }
  if (meetingForm.endTime && !endTimeOptions.value.some(item => item.value === meetingForm.endTime)) {
    meetingForm.endTime = ''
  }
  if (meetingForm.startTime) {
    meetingFormRef.value?.validateField('startTime')
  }
  if (meetingForm.endTime) {
    meetingFormRef.value?.validateField('endTime')
  }
  initTimeOptions()
})
watch(() => meetingForm.startTime, () => {
  if (meetingForm.endTime && getTimeInMinutes(meetingForm.endTime) <= getTimeInMinutes(meetingForm.startTime)) {
    meetingForm.endTime = ''
  }
  if (meetingForm.endTime) {
    meetingFormRef.value?.validateField('endTime')
  }
})
// ç¦ç”¨æ—¥æœŸï¼ˆç¦ç”¨ä»Šå¤©ä¹‹å‰çš„æ—¥æœŸï¼‰
const disabledDate = (time) => {
@@ -389,19 +243,6 @@
        // æ¨¡æ‹Ÿæäº¤æ“ä½œ
        ElMessage.success(`${getCurrentTypeName()}提交成功`)
        // æ ¹æ®ä¸åŒç±»åž‹æ‰§è¡Œä¸åŒæ“ä½œ
        switch (currentType.value) {
          case 'approval':
            ElMessage.info('会议已提交审批流程')
            break
          case 'department':
            ElMessage.info('部门级会议申请已提交')
            break
          case 'notification':
            ElMessage.info('会议通知已发布')
            break
        }
        resetForm()
      })
@@ -410,8 +251,7 @@
}
// é¡µé¢åŠ è½½æ—¶åˆå§‹åŒ–
onMounted(() => {
  initTimeOptions()
onActivated(() => {
  getRoomEnum().then(res => {
    meetingRooms.value = res.data
  })
src/views/collaborativeApproval/notificationManagement/meetExamine/index.vue
@@ -184,12 +184,12 @@
</template>
<script setup>
import {ref, reactive, onMounted} from 'vue'
import {ref, reactive, onMounted, onActivated} from 'vue'
import {ElMessage, ElMessageBox} from 'element-plus'
import Pagination from '@/components/Pagination/index.vue'
import {getRoomEnum, getExamineList,saveMeetingApplication} from '@/api/collaborativeApproval/meeting.js'
import dayjs from "dayjs";
import {staffOnJobListPage} from "@/api/personnelManagement/staffOnJob.js";
import {userListNoPageByTenantId} from "@/api/system/user.js";
// æ•°æ®åˆ—表加载状态
const loading = ref(false)
@@ -235,12 +235,12 @@
    let room = roomEnum.value.find(room => it.roomId === room.id)
    it.location = `${room.name}(${room.location})`
    let staffs = JSON.parse(it.participants)
    it.staffCount = staffs.size
    it.staffCount = staffs.length
    it.meetingTime = `${it.meetingDate} ${dayjs(it.startTime).format('HH:mm:ss')} ~ ${dayjs(it.endTime).format('HH:mm:ss')}`
    it.participants = staffList.value.filter(staff => staffs.some(id=>id === staff.id)).map(staff => {
    it.participants = staffList.value.filter(staff => staffs.some(id => id == staff.userId)).map(staff => {
      return {
        id: staff.id,
        name:  `${staff.staffName}${staff.postName ? ` (${staff.postName})` : ''}`
        id: staff.userId,
        name:  `${staff.nickName || staff.userName}${staff.dept?.deptName ? ` (${staff.dept.deptName})` : ''}`
      }
    })
@@ -342,11 +342,13 @@
// é¡µé¢åŠ è½½æ—¶èŽ·å–æ•°æ®
onMounted(async () => {
  const [resp1, resp2]= await Promise.all([getRoomEnum(), staffOnJobListPage({current: -1, size: -1, staffState: 1})])
  const [resp1, resp2]= await Promise.all([getRoomEnum(), userListNoPageByTenantId()])
  roomEnum.value = resp1.data
  staffList.value = resp2.data.records
  staffList.value = resp2.data || []
})
  await getList()
onActivated(() => {
  getList()
})
</script>
src/views/collaborativeApproval/notificationManagement/meetIndex/index.vue
@@ -45,7 +45,7 @@
                <div
                    class="cell content-cell"
                    :class="[cell.type, `status-${cell.meeting?.status || '0'}`]"
                    :style="{ flex: cell.span-0.2 }"
                    :style="{ flex: cell.span }"
                    @click="viewMeetingDetails(cell)"
                >
                  <div v-if="cell.type === 'meeting'" class="meeting-content">
@@ -89,7 +89,7 @@
</template>
<script setup>
import {ref, reactive, onMounted} from 'vue'
import {ref, reactive, onMounted, onActivated} from 'vue'
import {ElMessage} from 'element-plus'
import {getMeetingUseList} from "@/api/collaborativeApproval/meeting.js"
import dayjs from "dayjs";
@@ -111,17 +111,17 @@
// æ˜¯å¦æ˜¾ç¤ºè¯¦æƒ…对话框
const detailDialogVisible = ref(false)
// åˆå§‹åŒ–时间槽(以半小时为间隔,从8:00到18:00)
// åˆå§‹åŒ–时间槽(以半小时为间隔,从8:00到19:00)
const initTimeSlots = () => {
  const slots = []
  for (let hour = 8; hour < 18; hour++) {
  for (let hour = 8; hour < 19; hour++) {
    // æ¯ä¸ªå°æ—¶æ·»åŠ ä¸¤ä¸ªæ—¶é—´æ®µï¼šæ•´ç‚¹å’ŒåŠç‚¹
    slots.push({
      label: `${hour.toString().padStart(2, '0')}:00`,
      value: `${hour.toString().padStart(2, '0')}:00`
    })
    if (hour < 18) { // åˆ°17:30为止
    if (hour < 19) { // åˆ°18:30为止
      slots.push({
        label: `${hour.toString().padStart(2, '0')}:30`,
        value: `${hour.toString().padStart(2, '0')}:30`
@@ -131,28 +131,36 @@
  timeSlots.value = slots
}
const toMinutes = (time) => {
  if (!time) return 0
  const [h, m] = time.split(':').map(Number)
  return h * 60 + m
}
// ç”Ÿæˆä¼šè®®å®¤çš„æ—¶é—´å•元格
const generateMeetingCells = (room) => {
  const cells = []
  const meetings = room.meetings || []
  const occupiedSlots = new Set()
  // å¤„理每个会议
  for (const meeting of meetings) {
    const startMin = toMinutes(meeting.startTime)
    const endMin = toMinutes(meeting.endTime)
    const startIdx = timeSlots.value.findIndex(slot => slot.value === meeting.startTime)
    let endIdx = timeSlots.value.findIndex(slot => slot.value === meeting.endTime)
    if (endIdx === -1) {
      endIdx = timeSlots.value.length
    }
    console.log('endIdx111', endIdx)
    if (startIdx !== -1 && endIdx !== -1) {
      // æ ‡è®°è¢«å ç”¨çš„æ—¶é—´æ®µ
    // æ‰¾åˆ°ç¬¬ä¸€ä¸ªæ—¶é—´ > startMin çš„æ§½ä½ï¼Œå†å¾€å‰é€€ä¸€æ ¼
    let startIdx = timeSlots.value.findIndex(slot => toMinutes(slot.value) > startMin)
    if (startIdx === -1) startIdx = timeSlots.value.length
    startIdx = Math.max(0, startIdx - 1)
    // æ‰¾åˆ°ç¬¬ä¸€ä¸ªæ—¶é—´ >= endMin çš„æ§½ä½ï¼Œæ²¡æ‰¾åˆ°åˆ™å»¶ä¼¸åˆ°æœ«å°¾
    let endIdx = timeSlots.value.findIndex(slot => toMinutes(slot.value) >= endMin)
    if (endIdx === -1) endIdx = timeSlots.value.length
    if (startIdx < endIdx) {
      for (let i = startIdx; i < endIdx; i++) {
        occupiedSlots.add(timeSlots.value[i].value)
      }
      // åˆ›å»ºä¼šè®®å•元格
      cells.push({
        type: 'meeting',
        meeting: meeting,
@@ -163,11 +171,9 @@
    }
  }
  // å¤„理空闲时间段
  for (let i = 0; i < timeSlots.value.length; i++) {
    const slot = timeSlots.value[i]
    if (!occupiedSlots.has(slot.value)) {
      // æŸ¥æ‰¾è¿žç»­çš„空闲时间段
      let span = 1
      while (i + span < timeSlots.value.length &&
      !occupiedSlots.has(timeSlots.value[i + span].value)) {
@@ -183,14 +189,11 @@
    }
  }
  // æŒ‰æ—¶é—´æŽ’序
  cells.sort((a, b) => {
    const timeA = a.startTime || a.time
    const timeB = b.startTime || b.time
    return timeSlots.value.findIndex(s => s.value === timeA) -
        timeSlots.value.findIndex(s => s.value === timeB)
    return toMinutes(timeA) - toMinutes(timeB)
  })
  console.log('cells', cells)
  return cells
}
@@ -219,12 +222,10 @@
// é¡µé¢åŠ è½½æ—¶èŽ·å–æ•°æ®
onMounted(() => {
  // åˆå§‹åŒ–æ—¶é—´æ§½
  initTimeSlots()
})
  // é»˜è®¤æŸ¥è¯¢ä»Šå¤©çš„æ•°æ®
  const today = new Date()
  queryForm.date = today.toISOString().split('T')[0]
onActivated(() => {
  handleSearch()
})
</script>
src/views/collaborativeApproval/notificationManagement/meetPublish/index.vue
@@ -182,12 +182,12 @@
</template>
<script setup>
import {ref, reactive, onMounted} from 'vue'
import {ref, reactive, onMounted, onActivated} from 'vue'
import {ElMessage, ElMessageBox} from 'element-plus'
import Pagination from '@/components/Pagination/index.vue'
import {getRoomEnum, getMeetingPublish,saveMeetingApplication} from '@/api/collaborativeApproval/meeting.js'
import dayjs from "dayjs";
import {staffOnJobListPage} from "@/api/personnelManagement/staffOnJob.js";
import {userListNoPageByTenantId} from "@/api/system/user.js";
// æ•°æ®åˆ—表加载状态
const loading = ref(false)
@@ -233,13 +233,13 @@
    let room = roomEnum.value.find(room => it.roomId === room.id)
    it.location = `${room.name}(${room.location})`
    let staffs = JSON.parse(it.participants)
    it.staffCount = staffs.size
    it.staffCount = staffs.length
    it.status = it.publishStatus
    it.meetingTime = `${it.meetingDate} ${dayjs(it.startTime).format('HH:mm:ss')} ~ ${dayjs(it.endTime).format('HH:mm:ss')}`
    it.participants = staffList.value.filter(staff => staffs.some(id=>id === staff.id)).map(staff => {
    it.participants = staffList.value.filter(staff => staffs.some(id => id == staff.userId)).map(staff => {
      return {
        id: staff.id,
        name: `${staff.staffName}${staff.postName ? ` (${staff.postName})` : ''}`
        id: staff.userId,
        name: `${staff.nickName || staff.userName}${staff.dept?.deptName ? ` (${staff.dept.deptName})` : ''}`
      }
    })
@@ -340,11 +340,13 @@
// é¡µé¢åŠ è½½æ—¶èŽ·å–æ•°æ®
onMounted(async () => {
  const [resp1, resp2]= await Promise.all([getRoomEnum(), staffOnJobListPage({current: -1, size: -1, staffState: 1})])
  const [resp1, resp2]= await Promise.all([getRoomEnum(), userListNoPageByTenantId()])
  roomEnum.value = resp1.data
  staffList.value = resp2.data.records
  staffList.value = resp2.data || []
})
  await getList()
onActivated(() => {
  getList()
})
</script>
src/views/collaborativeApproval/notificationManagement/meetSetting/index.vue
@@ -102,7 +102,7 @@
</template>
<script setup>
import { ref, reactive, onMounted, getCurrentInstance } from 'vue'
import { ref, reactive, onActivated, getCurrentInstance } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import Pagination from '@/components/Pagination/index.vue'
@@ -276,8 +276,7 @@
  proxy.download('/meeting/export', { ...searchForm }, '会议室设置.xlsx')
}
// é¡µé¢åŠ è½½æ—¶èŽ·å–æ•°æ®
onMounted(() => {
onActivated(() => {
  getList()
})
</script>
src/views/collaborativeApproval/notificationManagement/summary/index.vue
@@ -155,13 +155,13 @@
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, onMounted, onActivated } from 'vue'
import { ElMessage } from 'element-plus'
import Pagination from '@/components/Pagination/index.vue'
import Editor from '@/components/Editor/index.vue'
import { getRoomEnum, getMeetingPublish ,getMeetingMinutesByMeetingId,saveMeetingMinutes} from '@/api/collaborativeApproval/meeting.js'
import dayjs from "dayjs"
import {staffOnJobListPage} from "@/api/personnelManagement/staffOnJob.js";
import {userListNoPageByTenantId} from "@/api/system/user.js";
// æ•°æ®åˆ—表加载状态
const loading = ref(false)
@@ -209,12 +209,12 @@
    let room = roomEnum.value.find(room => it.roomId === room.id)
    it.location = `${room.name}(${room.location})`
    let staffs = JSON.parse(it.participants)
    it.staffCount = staffs.size
    it.staffCount = staffs.length
    it.meetingTime = `${it.meetingDate} ${dayjs(it.startTime).format('HH:mm:ss')} ~ ${dayjs(it.endTime).format('HH:mm:ss')}`
    it.participants = staffList.value.filter(staff => staffs.some(id => id === staff.id)).map(staff => {
    it.participants = staffList.value.filter(staff => staffs.some(id => id == staff.userId)).map(staff => {
      return {
        id: staff.id,
        name:  `${staff.staffName}${staff.postName ? ` (${staff.postName})` : ''}`
        id: staff.userId,
        name:  `${staff.nickName || staff.userName}${staff.dept?.deptName ? ` (${staff.dept.deptName})` : ''}`
      }
    })
@@ -337,11 +337,13 @@
// é¡µé¢åŠ è½½æ—¶èŽ·å–æ•°æ®
onMounted(async () => {
  const [resp1, resp2] = await Promise.all([getRoomEnum(), staffOnJobListPage({current: -1, size: -1, staffState: 1})])
  const [resp1, resp2] = await Promise.all([getRoomEnum(), userListNoPageByTenantId()])
  roomEnum.value = resp1.data
  staffList.value = resp2.data.records
  staffList.value = resp2.data || []
})
  await getList()
onActivated(() => {
  getList()
})
</script>