فهرست منبع

feat:AI助手添加历史文件浏览功能

Jerry Liang 2 ماه پیش
والد
کامیت
beb693be96

+ 63 - 0
service/controllers/aiChatControllers.go

@@ -7,6 +7,8 @@ import (
 	"fmt"
 	"io"
 	"path/filepath"
+	"sort"
+	"time"
 
 	"github.com/gofiber/fiber/v2"
 	"github.com/gofiber/fiber/v2/utils"
@@ -321,3 +323,64 @@ func UploadAiFile(c *fiber.Ctx) error {
 		"size":     record.Size,
 	})
 }
+
+// ListAiFiles 获取用户的 AI 文件列表
+// GET /ai/files/list?type=upload|download
+func ListAiFiles(c *fiber.Ctx) error {
+	userId := middleware.AuthGetUserName(c)
+	fileType := utils.CopyString(c.Query("type"))
+
+	if fileType != "upload" && fileType != "download" {
+		return Fail(c, "type 参数必须为 upload 或 download")
+	}
+
+	// 按用户前缀列出 MinIO 文件
+	prefix := fmt.Sprintf("%s/%s/", fileType, userId)
+	objects, err := minioUtil.ListFilesObjectFromBucket(filestore.AIFileBucket, prefix, true)
+	if err != nil {
+		return Error(c, fmt.Sprintf("获取文件列表失败: %v", err))
+	}
+
+	type FileItem struct {
+		Key       string `json:"key"`
+		FileName  string `json:"fileName"`
+		Size      int64  `json:"size"`
+		CreatedAt int64  `json:"createdAt"` // Unix ms
+		ExpiresAt int64  `json:"expiresAt"` // Unix ms(0 表示不过期)
+	}
+
+	now := time.Now()
+	list := make([]FileItem, 0, len(objects))
+	for _, obj := range objects {
+		// 跳过已过期的文件(MinIO 可能延迟清理)
+		if !obj.Expires.IsZero() && obj.Expires.Before(now) {
+			continue
+		}
+
+		// 从 key 中提取文件名: {prefix}/{userId}/{sessionId}/{uuid}_{fileName}
+		fileName := filepath.Base(obj.Key)
+		if len(fileName) > 9 { // 去掉 "uuid_" 前缀(8位uuid + 下划线)
+			fileName = fileName[9:]
+		}
+
+		var expiresAt int64
+		if !obj.Expires.IsZero() {
+			expiresAt = obj.Expires.UnixMilli()
+		}
+
+		list = append(list, FileItem{
+			Key:       obj.Key,
+			FileName:  fileName,
+			Size:      obj.Size,
+			CreatedAt: obj.LastModified.UnixMilli(),
+			ExpiresAt: expiresAt,
+		})
+	}
+
+	// 按创建时间倒序
+	sort.Slice(list, func(i, j int) bool {
+		return list[i].CreatedAt > list[j].CreatedAt
+	})
+
+	return Success(c, list)
+}

+ 3 - 0
service/routes/aiChatRoutes.go

@@ -37,6 +37,9 @@ func AiChatSetup(api fiber.Router) {
 	// 下载 AI 生成的文件
 	api.Get("/ai/files/download", controllers.DownloadAiFile)
 
+	// 获取用户的 AI 文件列表
+	api.Get("/ai/files/list", controllers.ListAiFiles)
+
 	// 上传文件给 AI 分析
 	api.Post("/ai/files/upload", controllers.UploadAiFile)
 }

+ 84 - 1
src/layout/header/Actions/AiChat/ChatPanel.vue

@@ -1,7 +1,9 @@
 <script setup lang="ts">
-import { NInput, NButton, NEmpty, NScrollbar, NPopconfirm, NSpin, NTag, NModal, NCard } from 'naive-ui'
+import { NInput, NButton, NEmpty, NScrollbar, NPopconfirm, NSpin, NTag, NModal, NCard, NDrawer, NDrawerContent } from 'naive-ui'
 import { ref, nextTick, watch, onMounted, computed } from 'vue'
 import { useAiChat } from './useAiChat'
+import { listAiFiles, downloadAiFile } from '@/service/aiChat'
+import type { AiFileItem } from './interface'
 import MessageItem from './MessageItem.vue'
 
 const {
@@ -141,6 +143,50 @@ function handleCreateNewSessionFromDialog() {
   dismissSessionLoadInfo()
   createNewSession()
 }
+
+// ============ 文件历史 Drawer ============
+const fileDrawerVisible = ref(false)
+const fileDrawerType = ref<'upload' | 'download'>('upload')
+const fileList = ref<AiFileItem[]>([])
+const isLoadingFiles = ref(false)
+
+async function showFileDrawer(type: 'upload' | 'download') {
+  fileDrawerType.value = type
+  fileDrawerVisible.value = true
+  isLoadingFiles.value = true
+  try {
+    const data = await listAiFiles(type)
+    fileList.value = data || []
+  }
+  catch {
+    fileList.value = []
+  }
+  finally {
+    isLoadingFiles.value = false
+  }
+}
+
+function handleDownloadFile(key: string, fileName: string) {
+  downloadAiFile(key, fileName)
+}
+
+function formatFileSize(bytes: number): string {
+  if (bytes < 1024) return `${bytes} B`
+  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
+  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
+}
+
+function formatExpiry(expiresAt: number): string {
+  if (!expiresAt) return '不过期'
+  const now = Date.now()
+  const diff = expiresAt - now
+  if (diff <= 0) return '已过期'
+  const hours = Math.floor(diff / 3600000)
+  if (hours < 1) return '即将过期'
+  if (hours < 24) return `${hours} 小时后过期`
+  const days = Math.floor(hours / 24)
+  return `${days} 天后过期`
+}
 </script>
 
 <template>
@@ -154,6 +200,12 @@ function handleCreateNewSessionFromDialog() {
         <span class="text-xs text-neutral-400">AI 助手</span>
       </div>
       <div class="flex items-center gap-1">
+        <NButton text size="small" title="上传文件历史" @click="showFileDrawer('upload')">
+          <span class="i-ph:upload-simple text-sm" />
+        </NButton>
+        <NButton text size="small" title="AI生成文件" @click="showFileDrawer('download')">
+          <span class="i-ph:download-simple text-sm" />
+        </NButton>
         <NButton text size="small" title="新建对话" @click="handleNewSession">
           <span class="i-ph:plus text-sm" />
         </NButton>
@@ -356,6 +408,37 @@ function handleCreateNewSessionFromDialog() {
         </template>
       </NCard>
     </NModal>
+
+    <!-- 文件历史 Drawer -->
+    <NDrawer v-model:show="fileDrawerVisible" :width="320" placement="right">
+      <NDrawerContent :title="fileDrawerType === 'upload' ? '上传文件历史' : 'AI 生成文件'">
+        <div v-if="isLoadingFiles" class="flex justify-center py-8">
+          <NSpin size="medium" />
+        </div>
+        <div v-else-if="fileList.length === 0" class="py-8">
+          <NEmpty :description="fileDrawerType === 'upload' ? '暂无上传文件' : '暂无生成文件'" size="small" />
+        </div>
+        <div v-else class="space-y-2">
+          <div
+            v-for="file in fileList"
+            :key="file.key"
+            class="flex items-center gap-2 rounded-md border p-2 dark:border-neutral-600"
+          >
+            <div class="flex-1 min-w-0">
+              <div class="truncate text-xs font-medium text-neutral-700 dark:text-neutral-200">
+                {{ file.fileName }}
+              </div>
+              <div class="mt-0.5 text-[10px] text-neutral-400">
+                {{ formatFileSize(file.size) }} · {{ formatExpiry(file.expiresAt) }}
+              </div>
+            </div>
+            <NButton text size="tiny" title="下载" @click="handleDownloadFile(file.key, file.fileName)">
+              <span class="i-ph:download text-sm text-blue-500" />
+            </NButton>
+          </div>
+        </div>
+      </NDrawerContent>
+    </NDrawer>
   </div>
 </template>
 

+ 9 - 0
src/layout/header/Actions/AiChat/interface.ts

@@ -66,3 +66,12 @@ export interface UploadedFile {
   path: string
   size: number
 }
+
+// AI 文件列表项(MinIO 文件历史)
+export interface AiFileItem {
+  key: string
+  fileName: string
+  size: number
+  createdAt: number // Unix ms
+  expiresAt: number // Unix ms(0 表示不过期)
+}

+ 8 - 1
src/service/aiChat/index.ts

@@ -11,9 +11,10 @@
 // /ai/permission        - POST  回复权限请求
 // /ai/files/upload      - POST  上传文件
 // /ai/files/download    - GET   下载文件
+// /ai/files/list        - GET   获取文件列表
 
 import http from '@/service/serverReq'
-import type { SessionListItem } from '@/layout/header/Actions/AiChat/interface'
+import type { SessionListItem, AiFileItem } from '@/layout/header/Actions/AiChat/interface'
 
 const API = {
   chat: '/ai/chat',
@@ -27,6 +28,7 @@ const API = {
   permission: '/ai/permission',
   fileUpload: '/ai/files/upload',
   fileDownload: '/ai/files/download',
+  fileList: '/ai/files/list',
 }
 
 // GET 获取会话历史列表
@@ -78,5 +80,10 @@ export const uploadAiFile = async (file: File): Promise<{ key: string, fileName:
   return await http.post(API.fileUpload, formData)
 }
 
+// GET 获取文件列表(上传/下载)
+export const listAiFiles = async (type: 'upload' | 'download'): Promise<AiFileItem[]> => {
+  return await http.get<AiFileItem[]>(API.fileList, { params: { type } })
+}
+
 // 导出 chat SSE 请求路径(流式请求需在 useAiChat 中直接 fetch)
 export { API as AI_CHAT_API }