ソースを参照

[Feat 0000]AI智能助手添加文件编辑功能

wangkeyi 2 週間 前
コミット
9a15c1b7ae

+ 45 - 4
src/views/ventAI/manageAssistent/api.ts

@@ -11,6 +11,9 @@ enum Api {
   switchThinking = '/ventAI/api/model/thinking',
   getSkills = '/ventAI/api/skills',
   schedules = '/ventAI/api/schedules',
+  downloadReport = '/ventAI/api/download/report/',
+  saveReport = '/ventAI/api/report/save/',
+  editWord = '/ventAI/api/report/edit-word',
 }
 
 /**
@@ -378,8 +381,7 @@ export const createSchedule = (data: {
 export const getScheduleList = (include_disabled = true) =>
   defHttp.get({ url: Api.schedules, params: { include_disabled } }, { joinParamsToUrl: true, isTransformResponse: false });
 
-export const getScheduleDetail = (task_id: number) =>
-  defHttp.get({ url: `${Api.schedules}/${task_id}` }, { isTransformResponse: false });
+export const getScheduleDetail = (task_id: number) => defHttp.get({ url: `${Api.schedules}/${task_id}` }, { isTransformResponse: false });
 
 export const updateSchedule = (
   task_id: number,
@@ -395,8 +397,7 @@ export const deleteSchedule = (id: number) => defHttp.delete({ url: `${Api.sched
 export const getScheduleRuns = (task_id: number, limit = 50) =>
   defHttp.get({ url: `${Api.schedules}/${task_id}/runs`, params: { limit } }, { joinParamsToUrl: true, isTransformResponse: false });
 
-export const runScheduleNow = (task_id: number) =>
-  defHttp.post({ url: `${Api.schedules}/${task_id}/run` }, { isTransformResponse: false });
+export const runScheduleNow = (task_id: number) => defHttp.post({ url: `${Api.schedules}/${task_id}/run` }, { isTransformResponse: false });
 
 /**
  * 获取子智能体列表
@@ -442,3 +443,43 @@ export const deleteSubAgent = (name: string) => defHttp.delete({ url: `/ventAI/a
  * 启用/禁用子智能体
  */
 export const toggleSubAgent = (name: string) => defHttp.post({ url: `/ventAI/api/subagents/${name}/toggle` }, { isTransformResponse: false });
+
+/**
+ * 报告下载
+ * 从 URL 或文件名中提取纯文件名,构建 /api/download/report/{filename} 地址
+ */
+export const buildReportDownloadUrl = (urlOrFilename: string) => {
+  const filename = urlOrFilename.split('/').pop() || urlOrFilename;
+  return `${Api.downloadReport}${encodeURIComponent(filename)}`;
+};
+
+/**
+ * 覆盖保存报告(文本)
+ * @param filename - 文件名,如 report_20260810_150000.md
+ * @param content - 文本内容
+ */
+export const saveReportText = (filename: string, content: string) =>
+  defHttp.put({ url: `${Api.saveReport}${encodeURIComponent(filename)}`, data: { content } }, { isTransformResponse: false });
+
+/**
+ * 覆盖保存报告(二进制,如 xlsx)
+ * @param filename - 文件名
+ * @param data - ArrayBuffer / Blob / Uint8Array
+ */
+export const saveReportBinary = (filename: string, data: ArrayBuffer | Blob | Uint8Array) =>
+  defHttp.put(
+    {
+      url: `${Api.saveReport}${encodeURIComponent(filename)}`,
+      data,
+      headers: { 'Content-Type': 'application/octet-stream' },
+    },
+    { isTransformResponse: false }
+  );
+
+/**
+ * 对话式修改 Word 报告
+ * @param filename - docx 文件名
+ * @param instruction - 修改要求(自然语言)
+ */
+export const editWordReport = (filename: string, instruction: string) =>
+  defHttp.post({ url: Api.editWord, data: { filename, instruction } }, { isTransformResponse: false });

+ 31 - 49
src/views/ventAI/manageAssistent/components/AiAssistantModal.vue

@@ -74,18 +74,11 @@
             :messages="messages"
             @file-preview="openFilePreview"
             @download-word="downloadWordFile"
+            @open-file-preview="handleOpenFilePreview"
             @ask-user-submit="handleAskUserSubmit"
             @ask-user-cancel="handleAskUserCancel"
           />
 
-          <!-- 下载确认条 -->
-          <DownloadConfirmBar
-            v-if="downloadConfirm && downloadConfirmTaskId === currentTaskId"
-            :filename="downloadConfirm.filename"
-            @confirm="doDownload"
-            @cancel="clearDownloadConfirm"
-          />
-
           <!-- 任务进度条 -->
           <TodoListBar v-if="showTodoBar" :todos="currentTodos" />
 
@@ -119,7 +112,13 @@
 
         <!-- 文件预览面板(与 chat-panel 并列) -->
         <div v-if="previewFile" class="right-panel">
-          <FilePreviewPanel :file="previewFile" @close="removePreviewFile" />
+          <FilePreviewPanel
+            :file="previewFile"
+            :editable="previewEditable"
+            :save-filename="previewFile?.name || ''"
+            @close="removePreviewFile"
+            @save-success="handleSaveSuccess"
+          />
         </div>
 
         <!-- 定时任务详情面板(与 chat-panel 并列) -->
@@ -182,7 +181,6 @@
   import ChatInputArea from './chatModal/ChatInputArea.vue';
   import FilePreviewPanel from './chatModal/FilePreviewPanel.vue';
   import TodoListBar from './chatModal/TodoListBar.vue';
-  import DownloadConfirmBar from './chatModal/DownloadConfirmBar.vue';
   import SkillPanel from './chatModal/SkillPanel.vue';
   import SubAgentPanel from './chatModal/SubAgentPanel.vue';
   import SchedulePanel from './chatModal/SchedulePanel.vue';
@@ -268,6 +266,7 @@
   const chatMessagesRef = ref<InstanceType<typeof ChatMessages>>();
 
   const previewFile = ref<AttachedFile | null>(null);
+  const previewEditable = ref(false);
   const showFileList = ref(false);
   const activePanel = ref<'chat' | 'skill' | 'sub-agents' | 'schedules'>('chat');
   const scheduleListForDetail = ref<any[]>([]);
@@ -297,25 +296,6 @@
   const contextPercent = ref(0);
   const thinkLevel = ref('off');
   const editMode = ref('full');
-  const downloadConfirm = ref<{ url: string; filename: string } | null>(null);
-  const downloadConfirmTaskId = ref('');
-  let downloadConfirmTimer: ReturnType<typeof setTimeout> | null = null;
-
-  const clearDownloadConfirm = () => {
-    downloadConfirm.value = null;
-    downloadConfirmTaskId.value = '';
-    if (downloadConfirmTimer) {
-      clearTimeout(downloadConfirmTimer);
-      downloadConfirmTimer = null;
-    }
-  };
-
-  const startDownloadConfirmTimer = () => {
-    if (downloadConfirmTimer) clearTimeout(downloadConfirmTimer);
-    downloadConfirmTimer = setTimeout(() => {
-      clearDownloadConfirm();
-    }, 10000);
-  };
   const contextBreakdown = ref({ messages: 0, mcp: 0, skills: 0, system_prompt: 0, other: 0 });
 
   const currentTodos = ref<Array<{ content: string; status: string }>>([]);
@@ -862,7 +842,7 @@
     return str.replace(/\\n/g, '\n').replace(/\r\n/g, '\n');
   };
 
-  const transformHistoryToMessages = (data: Array<{ role?: string; type?: string; content: string; created_at?: string }>): Message[] => {
+  const transformHistoryToMessages = (data: Array<{ role?: string; type?: string; content: string; created_at?: string; report_url?: string; report_filename?: string }>): Message[] => {
     const messages: Message[] = [];
 
     for (const item of data) {
@@ -874,7 +854,11 @@
       if (item.role === 'user') {
         messages.push({ type: 'user', content: normalizedContent, time, createdAt });
       } else if (item.role === 'assistant') {
-        messages.push({ type: 'ai', content: normalizedContent, time, createdAt });
+        const msg: Message = { type: 'ai', content: normalizedContent, time, createdAt };
+        if (item.report_url && item.report_filename) {
+          msg.downloadFiles = [{ url: item.report_url, filename: item.report_filename }];
+        }
+        messages.push(msg);
       }
       // 兼容旧格式:type 字段
       else if (item.type === 'user_message') {
@@ -952,6 +936,7 @@
 
   const removePreviewFile = () => {
     previewFile.value = null;
+    previewEditable.value = false;
     // 关闭文件预览后,恢复定时任务详情面板
     const sessionId = currentTask.value?.sessionId;
     if (sessionId && scheduleListForDetail.value.some((s: any) => s.feedback_session_id === sessionId)) {
@@ -961,6 +946,7 @@
 
   const openFilePreview = (file: AttachedFile) => {
     previewFile.value = file;
+    previewEditable.value = false;
     showScheduleDetail.value = false;
 
     const msgIndex = messages.value.findIndex((m) => m.attachedFile?.id === file.id);
@@ -1016,17 +1002,6 @@
     document.body.removeChild(a);
   };
 
-  const doDownload = () => {
-    if (!downloadConfirm.value) return;
-    const a = document.createElement('a');
-    a.href = downloadConfirm.value.url;
-    a.download = downloadConfirm.value.filename;
-    document.body.appendChild(a);
-    a.click();
-    document.body.removeChild(a);
-    clearDownloadConfirm();
-  };
-
   const downloadWordFile = (url: string) => {
     if (!url) return;
     const a = document.createElement('a');
@@ -1038,6 +1013,18 @@
     document.body.removeChild(a);
   };
 
+  const handleOpenFilePreview = (file: AttachedFile) => {
+    previewFile.value = file;
+    previewEditable.value = true;
+    showScheduleDetail.value = false;
+  };
+
+  const handleSaveSuccess = (content: string) => {
+    if (previewFile.value) {
+      previewFile.value = { ...previewFile.value, content };
+    }
+  };
+
   const handleSendMessage = async () => {
     const hasText = inputMessage.value.trim();
     const hasFile = pendingFile.value;
@@ -1279,9 +1266,8 @@
           timestamp: Date.now(),
         });
         if (data.download_url) {
-          downloadConfirm.value = { url: data.download_url, filename: data.filename || '文件' };
-          downloadConfirmTaskId.value = taskId;
-          startDownloadConfirmTimer();
+          if (!aiMsg.downloadFiles) aiMsg.downloadFiles = [];
+          aiMsg.downloadFiles.push({ url: data.download_url, filename: data.filename || '文件' });
         }
         break;
       }
@@ -1810,10 +1796,6 @@
   onBeforeUnmount(() => {
     document.removeEventListener('mousemove', onDragMove);
     document.removeEventListener('mouseup', onDragEnd);
-    if (downloadConfirmTimer) {
-      clearTimeout(downloadConfirmTimer);
-      downloadConfirmTimer = null;
-    }
     // 组件卸载时主动断开所有任务的流式连接
     taskAbortControllers.value.forEach((controller) => controller.abort());
   });

+ 227 - 24
src/views/ventAI/manageAssistent/components/chatModal/ChatMessages.vue

@@ -105,6 +105,29 @@
             <div v-if="message.wordDownloadUrl" class="word-download-area">
               <a-button type="link" @click="emit('download-word', message.wordDownloadUrl)"> 下载报告文件 (.docx) </a-button>
             </div>
+
+            <!-- 文件操作卡片 -->
+            <div v-if="message.downloadFiles?.length" class="download-file-cards">
+              <div v-for="(file, fIdx) in message.downloadFiles" :key="fIdx" class="download-file-card">
+                <div class="file-card-info">
+                  <div class="file-card-icon"></div>
+                  <div class="file-card-text">
+                    <span class="file-card-name">{{ file.filename }}</span>
+                    <span class="file-card-hint">点击操作文件</span>
+                  </div>
+                </div>
+                <div class="file-card-actions">
+                  <button class="file-action-btn download-btn" @click="handleDownloadFile(file.url, file.filename)">
+                    <img :src="getCssUrl('--img-chat-download-icon-btn')" width="14" height="14" />
+                    <span>下载</span>
+                  </button>
+                  <button v-if="isEditableFile(file.filename)" class="file-action-btn edit-btn" @click="handleEditFile(file.url, file.filename)">
+                    <img :src="getCssUrl('--img-chat-edit-icon')" width="14" height="14" />
+                    <span>编辑</span>
+                  </button>
+                </div>
+              </div>
+            </div>
             <div v-if="message.isLoading" class="loading-dots">
               <span class="dot"></span>
               <span class="dot"></span>
@@ -128,31 +151,47 @@
                   <div
                     v-for="(_, sIdx) in message.pendingApprovalData.ask_user.questions"
                     :key="sIdx"
-                    :class="['ask-user-step-dot', {
-                      active: sIdx === (askUserCurrentStep[index] ?? 0),
-                      completed: sIdx < (askUserCurrentStep[index] ?? 0) || !!(askUserAnswers[index]?.[sIdx] && askUserAnswers[index][sIdx].trim())
-                    }]"
+                    :class="[
+                      'ask-user-step-dot',
+                      {
+                        active: sIdx === (askUserCurrentStep[index] ?? 0),
+                        completed: sIdx < (askUserCurrentStep[index] ?? 0) || !!(askUserAnswers[index]?.[sIdx] && askUserAnswers[index][sIdx].trim()),
+                      },
+                    ]"
                     @click="askUserCurrentStep[index] = sIdx"
                   ></div>
-                  <span class="ask-user-step-text">{{ (askUserCurrentStep[index] ?? 0) + 1 }} / {{ message.pendingApprovalData.ask_user.questions.length }}</span>
+                  <span class="ask-user-step-text"
+                    >{{ (askUserCurrentStep[index] ?? 0) + 1 }} / {{ message.pendingApprovalData.ask_user.questions.length }}</span
+                  >
                 </div>
 
                 <!-- 当前问题 -->
-                <div
-                  v-if="message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0]"
-                  class="ask-user-question"
-                >
+                <div v-if="message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0]" class="ask-user-question">
                   <div class="ask-user-question-text">
                     {{ message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0].question }}
-                    <span v-if="message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0].required !== false" class="ask-user-required">*</span>
+                    <span
+                      v-if="message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0].required !== false"
+                      class="ask-user-required"
+                      >*</span
+                    >
                   </div>
 
                   <!-- multiple_choice 类型 -->
-                  <div v-if="message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0].type === 'multiple_choice'" class="ask-user-choices">
+                  <div
+                    v-if="message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0].type === 'multiple_choice'"
+                    class="ask-user-choices"
+                  >
                     <div
                       v-for="(choice, cIdx) in message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0].choices"
                       :key="cIdx"
-                      :class="['ask-user-choice', { selected: askUserAnswers[index]?.[askUserCurrentStep[index] ?? 0] === getChoiceValue(choice) && !askUserOtherSelected[index]?.[askUserCurrentStep[index] ?? 0] }]"
+                      :class="[
+                        'ask-user-choice',
+                        {
+                          selected:
+                            askUserAnswers[index]?.[askUserCurrentStep[index] ?? 0] === getChoiceValue(choice) &&
+                            !askUserOtherSelected[index]?.[askUserCurrentStep[index] ?? 0],
+                        },
+                      ]"
                       @click="selectChoice(index, askUserCurrentStep[index] ?? 0, choice, message.pendingApprovalData.ask_user.questions.length)"
                     >
                       <span>{{ getChoiceValue(choice) }}</span>
@@ -174,7 +213,10 @@
                   </div>
 
                   <!-- text 类型 -->
-                  <div v-else-if="message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0].type === 'text'" class="ask-user-text">
+                  <div
+                    v-else-if="message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0].type === 'text'"
+                    class="ask-user-text"
+                  >
                     <textarea
                       class="ask-user-textarea"
                       placeholder="请输入..."
@@ -186,23 +228,33 @@
 
                 <!-- 操作按钮 -->
                 <div class="approval-actions">
-                  <button
-                    v-if="(askUserCurrentStep[index] ?? 0) > 0"
-                    class="approval-btn nav-btn"
-                    @click="goPrevStep(index)"
-                  >上一步</button>
+                  <button v-if="(askUserCurrentStep[index] ?? 0) > 0" class="approval-btn nav-btn" @click="goPrevStep(index)">上一步</button>
                   <button
                     v-if="(askUserCurrentStep[index] ?? 0) < message.pendingApprovalData.ask_user.questions.length - 1"
                     class="approval-btn approve-btn"
-                    :disabled="!isCurrentStepAnswered(index, askUserCurrentStep[index] ?? 0, message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0])"
+                    :disabled="
+                      !isCurrentStepAnswered(
+                        index,
+                        askUserCurrentStep[index] ?? 0,
+                        message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0]
+                      )
+                    "
                     @click="goNextStep(index, message.pendingApprovalData.ask_user.questions.length)"
-                  >下一步</button>
+                    >下一步</button
+                  >
                   <button
                     v-if="(askUserCurrentStep[index] ?? 0) === message.pendingApprovalData.ask_user.questions.length - 1"
                     class="approval-btn approve-btn"
-                    :disabled="!isCurrentStepAnswered(index, askUserCurrentStep[index] ?? 0, message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0])"
+                    :disabled="
+                      !isCurrentStepAnswered(
+                        index,
+                        askUserCurrentStep[index] ?? 0,
+                        message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0]
+                      )
+                    "
                     @click="handleSubmitAskUser(index)"
-                  >提交</button>
+                    >提交</button
+                  >
                   <button class="approval-btn reject-btn" @click="handleCancelAskUser">取消</button>
                 </div>
               </div>
@@ -263,7 +315,9 @@
 <script setup lang="ts">
   import { ref, reactive, computed, watch, onMounted, onBeforeUnmount } from 'vue';
   import dayjs from 'dayjs';
-  import { renderMarkdown, formatFileSize, tableHtmlToMarkdown, tableHtmlToCsv } from './utils';
+  import { renderMarkdown, formatFileSize, tableHtmlToMarkdown, tableHtmlToCsv, isMdFile, isTextFile } from './utils';
+  import { getToken } from '/@/utils/auth';
+  import { message } from 'ant-design-vue';
   import type { Message, AttachedFile, ThinkingStep } from './types';
   // import katex from 'katex';
   // import 'katex/dist/katex.min.css';
@@ -336,10 +390,53 @@
   const emit = defineEmits<{
     (e: 'file-preview', file: AttachedFile): void;
     (e: 'download-word', url: string): void;
+    (e: 'open-file-preview', file: AttachedFile): void;
     (e: 'ask-user-submit', answers: string[]): void;
     (e: 'ask-user-cancel'): void;
   }>();
 
+  const isEditableFile = (filename: string) => isMdFile(filename) || isTextFile(filename);
+
+  const handleDownloadFile = (url: string, filename: string) => {
+    if (!url) return;
+    const a = document.createElement('a');
+    a.href = url;
+    a.download = filename;
+    document.body.appendChild(a);
+    a.click();
+    document.body.removeChild(a);
+  };
+
+  const handleEditFile = async (url: string, filename: string) => {
+    try {
+      const resp = await fetch(url, { headers: { 'X-Access-Token': getToken() } });
+      if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
+      const content = await resp.text();
+      const ext = filename.split('.').pop()?.toLowerCase() || '';
+      const mimeMap: Record<string, string> = {
+        md: 'text/markdown',
+        txt: 'text/plain',
+        json: 'application/json',
+        xml: 'text/xml',
+        yaml: 'text/yaml',
+        yml: 'text/yaml',
+        csv: 'text/csv',
+      };
+      const file: AttachedFile = {
+        id: `report-${Date.now()}`,
+        name: filename,
+        size: content.length,
+        type: mimeMap[ext] || 'text/plain',
+        content,
+        uploadTime: new Date().toISOString(),
+      };
+      emit('open-file-preview', file);
+    } catch (err) {
+      console.error('获取文件内容失败:', err);
+      message.error('获取文件内容失败');
+    }
+  };
+
   // ask_user 答案状态(按消息索引存储)
   const askUserAnswers = reactive<Record<number, string[]>>({});
   const askUserOtherSelected = reactive<Record<number, boolean[]>>({});
@@ -645,7 +742,7 @@
         .message-body {
           padding: 12px 12px 4px 12px;
           border-radius: 6px;
-          width: calc(100% - 50px);
+          width: 100%;
           min-width: 0;
           overflow: hidden;
 
@@ -1269,6 +1366,112 @@
             }
           }
         }
+
+        .download-file-cards {
+          display: flex;
+          flex-direction: column;
+          gap: 8px;
+          margin-top: 12px;
+
+          .download-file-card {
+            display: flex;
+            align-items: center;
+            justify-content: space-between;
+            padding: 12px 16px;
+            background: linear-gradient(135deg, rgba(0, 47, 74, 0.8) 0%, rgba(0, 60, 90, 0.6) 100%);
+            border: 1px solid rgba(0, 84, 130, 0.6);
+            border-radius: 10px;
+            transition: all 0.2s ease;
+
+            &:hover {
+              border-color: rgba(10, 132, 255, 0.4);
+              box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
+            }
+
+            .file-card-info {
+              display: flex;
+              align-items: center;
+              gap: 12px;
+              min-width: 0;
+              flex: 1;
+
+              .file-card-icon {
+                width: 36px;
+                height: 36px;
+                background-image: var(--img-chat-download-icon);
+                background-repeat: no-repeat;
+                background-size: 100% 100%;
+                flex-shrink: 0;
+                opacity: 0.9;
+              }
+
+              .file-card-text {
+                display: flex;
+                flex-direction: column;
+                gap: 2px;
+                min-width: 0;
+
+                .file-card-name {
+                  font-size: 13px;
+                  font-weight: 500;
+                  color: #e0e6ed;
+                  overflow: hidden;
+                  text-overflow: ellipsis;
+                  white-space: nowrap;
+                }
+
+                .file-card-hint {
+                  font-size: 11px;
+                  color: #6e7681;
+                }
+              }
+            }
+
+            .file-card-actions {
+              display: flex;
+              gap: 8px;
+              flex-shrink: 0;
+
+              .file-action-btn {
+                display: inline-flex;
+                align-items: center;
+                gap: 5px;
+                padding: 6px 14px;
+                border-radius: 7px;
+                font-size: 12px;
+                font-weight: 500;
+                cursor: pointer;
+                border: none;
+                transition: all 0.2s ease;
+                white-space: nowrap;
+
+                &.download-btn {
+                  background: rgba(255, 255, 255, 0.08);
+                  color: #c9d1d9;
+                  border: 1px solid rgba(63, 80, 106, 0.5);
+
+                  &:hover {
+                    background: rgba(255, 255, 255, 0.14);
+                    color: #e0e6ed;
+                    border-color: rgba(63, 80, 106, 0.8);
+                  }
+                }
+
+                &.edit-btn {
+                  background: linear-gradient(135deg, #0a84ff 0%, #0066cc 100%);
+                  color: #fff;
+                  border: 1px solid rgba(10, 132, 255, 0.5);
+                  box-shadow: 0 2px 6px rgba(10, 132, 255, 0.2);
+
+                  &:hover {
+                    background: linear-gradient(135deg, #3a9bfd 0%, #0a84ff 100%);
+                    box-shadow: 0 3px 10px rgba(10, 132, 255, 0.35);
+                  }
+                }
+              }
+            }
+          }
+        }
       }
 
       .user-message {

+ 0 - 78
src/views/ventAI/manageAssistent/components/chatModal/DownloadConfirmBar.vue

@@ -1,78 +0,0 @@
-<template>
-  <div class="download-confirm-bar">
-    <span class="download-confirm-text">是否下载 {{ filename }} 到本地?</span>
-    <div class="download-confirm-actions">
-      <button class="download-confirm-btn download-btn" @click="emit('confirm')">下载</button>
-      <button class="download-confirm-btn cancel-btn" @click="emit('cancel')">取消</button>
-    </div>
-  </div>
-</template>
-
-<script setup lang="ts">
-  interface Props {
-    filename: string;
-  }
-
-  defineProps<Props>();
-  const emit = defineEmits<{
-    (e: 'confirm'): void;
-    (e: 'cancel'): void;
-  }>();
-</script>
-
-<style scoped lang="less">
-  .download-confirm-bar {
-    display: flex;
-    align-items: center;
-    justify-content: space-between;
-    padding: 10px 16px;
-    margin: 0 16px;
-    background: #0a2a3f;
-    border: 1px solid #1a4a6f;
-    border-radius: 8px;
-    flex-shrink: 0;
-
-    .download-confirm-text {
-      font-size: 13px;
-      color: #e0e6ed;
-      overflow: hidden;
-      text-overflow: ellipsis;
-      white-space: nowrap;
-      margin-right: 12px;
-    }
-
-    .download-confirm-actions {
-      display: flex;
-      gap: 8px;
-      flex-shrink: 0;
-    }
-
-    .download-confirm-btn {
-      padding: 4px 14px;
-      border-radius: 4px;
-      font-size: 12px;
-      cursor: pointer;
-      border: none;
-      transition: all 0.2s;
-
-      &.download-btn {
-        background: #0a84ff;
-        color: #fff;
-
-        &:hover {
-          background: #3a9bfd;
-        }
-      }
-
-      &.cancel-btn {
-        background: rgba(255, 255, 255, 0.08);
-        color: #e0e6ed;
-        border: 1px solid rgba(63, 80, 106, 0.5);
-
-        &:hover {
-          background: rgba(255, 255, 255, 0.15);
-        }
-      }
-    }
-  }
-</style>

+ 235 - 55
src/views/ventAI/manageAssistent/components/chatModal/FilePreviewPanel.vue

@@ -2,82 +2,106 @@
   <div class="file-preview-panel">
     <div class="panel-header">
       <span class="panel-title">{{ file.name }}</span>
+      <div class="panel-header-actions">
+        <button v-if="editable && !isEditing" class="header-action-btn edit-btn" @click="enterEdit">
+          <img :src="getCssUrl('--img-chat-edit-icon')" width="14" height="14" />
+          <span>编辑</span>
+        </button>
+        <button v-if="isEditing" class="header-action-btn cancel-btn" @click="cancelEdit">
+          <img :src="getCssUrl('--img-chat-close-icon-btn')" width="14" height="14" />
+          <span>取消</span>
+        </button>
+        <button v-if="isEditing" class="header-action-btn save-btn" :class="{ saving }" :disabled="saving" @click="saveEdit">
+          <img v-if="!saving" :src="getCssUrl('--img-chat-mode-confirm-icon')" width="14" height="14" />
+          <img v-else class="spin-icon" :src="getCssUrl('--img-chat-mode-confirm-icon')" width="14" height="14" />
+          <span>{{ saving ? '保存中...' : '保存' }}</span>
+        </button>
+      </div>
       <div class="close-btn" @click="emit('close')">
         <div class="btn-close-icon"></div>
       </div>
     </div>
     <div class="panel-content file-preview-content">
-      <div v-if="isImageFile(file.name)" class="image-preview">
-        <img :src="file.preview" alt="预览图片" />
-      </div>
-      <div v-else-if="isPdfFile(file.name)" class="pdf-preview">
-        <iframe v-if="file.preview" :src="pdfSrc" frameborder="0"></iframe>
-        <div v-else class="no-preview-text">无法加载PDF预览</div>
-      </div>
-      <div v-else-if="isOldDocFile(file.name)" class="no-preview">
-        <div class="no-preview-icon"></div>
-        <div class="no-preview-text">暂不支持 .doc 格式预览</div>
-        <div class="no-preview-hint">请将文件转换为 .docx 格式后重新上传</div>
+      <!-- 编辑模式 -->
+      <div v-if="isEditing" class="edit-mode">
+        <textarea v-model="editContent" class="edit-textarea" spellcheck="false"></textarea>
       </div>
-      <div v-else-if="isWordFile(file.name)" class="word-preview">
-        <VueOfficeDocx v-if="file.arrayBuffer" :src="file.arrayBuffer" @rendered="handleRendered" @error="handleError" />
-        <div v-else class="no-preview-text">无法加载Word文档</div>
-      </div>
-      <div v-else-if="isExcelFile(file.name)" class="excel-preview">
-        <div v-if="excelSheets.length > 1" class="excel-tabs">
-          <div v-for="(sheet, i) in excelSheets" :key="i" :class="['excel-tab', { active: activeSheet === i }]" @click="activeSheet = i">
-            {{ sheet.name }}
+      <!-- 预览模式 -->
+      <template v-else>
+        <div v-if="isImageFile(file.name)" class="image-preview">
+          <img :src="file.preview" alt="预览图片" />
+        </div>
+        <div v-else-if="isPdfFile(file.name)" class="pdf-preview">
+          <iframe v-if="file.preview" :src="pdfSrc" frameborder="0"></iframe>
+          <div v-else class="no-preview-text">无法加载PDF预览</div>
+        </div>
+        <div v-else-if="isOldDocFile(file.name)" class="no-preview">
+          <div class="no-preview-icon"></div>
+          <div class="no-preview-text">暂不支持 .doc 格式预览</div>
+          <div class="no-preview-hint">请将文件转换为 .docx 格式后重新上传</div>
+        </div>
+        <div v-else-if="isWordFile(file.name)" class="word-preview">
+          <VueOfficeDocx v-if="file.arrayBuffer" :src="file.arrayBuffer" @rendered="handleRendered" @error="handleError" />
+          <div v-else class="no-preview-text">无法加载Word文档</div>
+        </div>
+        <div v-else-if="isExcelFile(file.name)" class="excel-preview">
+          <div v-if="excelSheets.length > 1" class="excel-tabs">
+            <div v-for="(sheet, i) in excelSheets" :key="i" :class="['excel-tab', { active: activeSheet === i }]" @click="activeSheet = i">
+              {{ sheet.name }}
+            </div>
+          </div>
+          <div v-if="excelLoading" class="no-preview-text">加载中...</div>
+          <div v-else-if="currentSheetData.length > 0" class="excel-table-wrapper">
+            <table class="excel-table">
+              <thead>
+                <tr>
+                  <th v-for="(cell, i) in currentSheetData[0]" :key="i">{{ cell }}</th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr v-for="(row, ri) in currentSheetData.slice(1)" :key="ri">
+                  <td v-for="(cell, ci) in row" :key="ci">{{ cell }}</td>
+                </tr>
+              </tbody>
+            </table>
           </div>
+          <div v-else class="no-preview-text">Excel文件内容为空</div>
         </div>
-        <div v-if="excelLoading" class="no-preview-text">加载中...</div>
-        <div v-else-if="currentSheetData.length > 0" class="excel-table-wrapper">
-          <table class="excel-table">
+        <div v-else-if="isCsvFile(file.name)" class="csv-preview">
+          <table v-if="csvRows.length > 0" class="csv-table">
             <thead>
               <tr>
-                <th v-for="(cell, i) in currentSheetData[0]" :key="i">{{ cell }}</th>
+                <th v-for="(cell, i) in csvRows[0]" :key="i">{{ cell }}</th>
               </tr>
             </thead>
             <tbody>
-              <tr v-for="(row, ri) in currentSheetData.slice(1)" :key="ri">
+              <tr v-for="(row, ri) in csvRows.slice(1)" :key="ri">
                 <td v-for="(cell, ci) in row" :key="ci">{{ cell }}</td>
               </tr>
             </tbody>
           </table>
+          <div v-else class="no-preview-text">CSV文件内容为空</div>
         </div>
-        <div v-else class="no-preview-text">Excel文件内容为空</div>
-      </div>
-      <div v-else-if="isCsvFile(file.name)" class="csv-preview">
-        <table v-if="csvRows.length > 0" class="csv-table">
-          <thead>
-            <tr>
-              <th v-for="(cell, i) in csvRows[0]" :key="i">{{ cell }}</th>
-            </tr>
-          </thead>
-          <tbody>
-            <tr v-for="(row, ri) in csvRows.slice(1)" :key="ri">
-              <td v-for="(cell, ci) in row" :key="ci">{{ cell }}</td>
-            </tr>
-          </tbody>
-        </table>
-        <div v-else class="no-preview-text">CSV文件内容为空</div>
-      </div>
-      <div v-else-if="isMdFile(file.name)" class="markdown-preview">
-        <div class="markdown-body" v-html="renderedMarkdown"></div>
-      </div>
-      <div v-else-if="isTextFile(file.name)" class="text-preview">
-        <pre>{{ file.content || '文件内容加载中...' }}</pre>
-      </div>
-      <div v-else class="no-preview">
-        <div class="no-preview-icon"></div>
-        <div class="no-preview-text">不支持预览此文件格式</div>
-        <div class="no-preview-hint">{{ getFileExtension(file.name) }} 格式文件暂不支持在线预览</div>
-      </div>
+        <div v-else-if="isMdFile(file.name)" class="markdown-preview">
+          <div class="markdown-body" v-html="renderedMarkdown"></div>
+        </div>
+        <div v-else-if="isTextFile(file.name)" class="text-preview">
+          <pre>{{ file.content || '文件内容加载中...' }}</pre>
+        </div>
+        <div v-else class="no-preview">
+          <div class="no-preview-icon"></div>
+          <div class="no-preview-text">不支持预览此文件格式</div>
+          <div class="no-preview-hint">{{ getFileExtension(file.name) }} 格式文件暂不支持在线预览</div>
+        </div>
+      </template>
     </div>
   </div>
 </template>
 
 <script setup lang="ts">
   import { ref, computed, watch } from 'vue';
+  import { message } from 'ant-design-vue';
+  import { saveReportText } from '../../api';
   import VueOfficeDocx from '@vue-office/docx/lib/v3/vue-office-docx.mjs';
   import * as XLSX from 'xlsx';
   import {
@@ -94,14 +118,67 @@
   } from './utils';
   import type { AttachedFile } from './types';
 
-  const props = defineProps<{
-    file: AttachedFile;
-  }>();
+  const getCssUrl = (varName: string) => {
+    const val = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
+    return val.replace(/^url\(["']?/, '').replace(/["']?\)$/, '');
+  };
+
+  const props = withDefaults(
+    defineProps<{
+      file: AttachedFile;
+      editable?: boolean;
+      saveFilename?: string;
+    }>(),
+    { editable: false, saveFilename: '' }
+  );
 
   const emit = defineEmits<{
     (e: 'close'): void;
+    (e: 'save-success', content: string): void;
   }>();
 
+  // 编辑模式
+  const isEditing = ref(false);
+  const editContent = ref('');
+  const saving = ref(false);
+
+  const enterEdit = () => {
+    editContent.value = props.file.content || '';
+    isEditing.value = true;
+  };
+
+  const cancelEdit = () => {
+    isEditing.value = false;
+    editContent.value = '';
+  };
+
+  const saveEdit = async () => {
+    if (!props.saveFilename) return;
+    saving.value = true;
+    try {
+      await saveReportText(props.saveFilename, editContent.value);
+      message.success('保存成功');
+      emit('save-success', editContent.value);
+    } catch (err) {
+      console.error('保存文件失败:', err);
+      message.error('保存文件失败');
+    } finally {
+      saving.value = false;
+    }
+  };
+
+  // 文件内容更新后(保存成功),自动退出编辑模式
+  watch(
+    () => props.file.content,
+    () => {
+      if (isEditing.value) {
+        isEditing.value = false;
+        editContent.value = '';
+        saving.value = false;
+      }
+    }
+  );
+
   // Excel 解析
   const excelSheets = ref<{ name: string; data: string[][] }[]>([]);
   const activeSheet = ref(0);
@@ -221,6 +298,81 @@
       margin-right: 10px;
     }
 
+    .panel-header-actions {
+      display: flex;
+      gap: 6px;
+      flex-shrink: 0;
+      margin-right: 6px;
+
+      .header-action-btn {
+        display: inline-flex;
+        align-items: center;
+        gap: 5px;
+        padding: 4px 12px;
+        border-radius: 6px;
+        font-size: 12px;
+        font-weight: 500;
+        cursor: pointer;
+        border: none;
+        transition: all 0.2s ease;
+        white-space: nowrap;
+
+        &.edit-btn {
+          background: linear-gradient(135deg, #0a84ff 0%, #0066cc 100%);
+          color: #fff;
+          border: 1px solid rgba(10, 132, 255, 0.5);
+          box-shadow: 0 2px 6px rgba(10, 132, 255, 0.2);
+
+          &:hover {
+            background: linear-gradient(135deg, #3a9bfd 0%, #0a84ff 100%);
+            box-shadow: 0 3px 10px rgba(10, 132, 255, 0.35);
+          }
+        }
+
+        &.cancel-btn {
+          background: rgba(255, 255, 255, 0.06);
+          color: #8b949e;
+          border: 1px solid rgba(63, 80, 106, 0.4);
+
+          &:hover {
+            background: rgba(255, 255, 255, 0.1);
+            color: #c9d1d9;
+            border-color: rgba(63, 80, 106, 0.6);
+          }
+        }
+
+        &.save-btn {
+          background: linear-gradient(135deg, #0a84ff 0%, #0066cc 100%);
+          color: #fff;
+          border: 1px solid rgba(10, 132, 255, 0.5);
+          box-shadow: 0 2px 6px rgba(10, 132, 255, 0.25);
+
+          &:hover:not(:disabled) {
+            background: linear-gradient(135deg, #3a9bfd 0%, #0a84ff 100%);
+            box-shadow: 0 3px 10px rgba(10, 132, 255, 0.35);
+          }
+
+          &:disabled {
+            opacity: 0.6;
+            cursor: not-allowed;
+          }
+
+          &.saving {
+            background: linear-gradient(135deg, #3a8fd4 0%, #2a6fa0 100%);
+          }
+        }
+
+        .spin-icon {
+          animation: spin 1s linear infinite;
+        }
+
+        @keyframes spin {
+          from { transform: rotate(0deg); }
+          to { transform: rotate(360deg); }
+        }
+      }
+    }
+
     .close-btn {
       cursor: pointer;
       width: 36px;
@@ -570,6 +722,34 @@
       }
     }
 
+    .edit-mode {
+      height: 100%;
+      display: flex;
+      flex-direction: column;
+
+      .edit-textarea {
+        flex: 1;
+        width: 100%;
+        min-height: 100%;
+        padding: 12px;
+        font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
+        font-size: 13px;
+        line-height: 1.6;
+        color: #e0e6ed;
+        background: rgba(0, 0, 0, 0.2);
+        border: 1px solid rgba(63, 80, 106, 0.4);
+        border-radius: 6px;
+        outline: none;
+        resize: none;
+        tab-size: 4;
+
+        &:focus {
+          border-color: #0a84ff;
+          background: rgba(0, 0, 0, 0.3);
+        }
+      }
+    }
+
     .no-preview {
       display: flex;
       flex-direction: column;

+ 1 - 0
src/views/ventAI/manageAssistent/components/chatModal/types.ts

@@ -68,6 +68,7 @@ export interface Message {
   thinkingSteps?: ThinkingStep[];
   sessionId?: string;
   wordDownloadUrl?: string;
+  downloadFiles?: Array<{ url: string; filename: string }>;
   durationMs?: number;
   baseDurationMs?: number;
   generateStartTime?: number;