feat: 实现智能助手WebSocket聊天界面

This commit is contained in:
Guo Tiantian
2025-09-10 00:49:53 +08:00
parent 8f06deaea4
commit b8c8e7af4c
5 changed files with 893 additions and 61 deletions
+840 -3
View File
@@ -1,13 +1,850 @@
<template>
<dev>
开发中...
</dev>
<div class="chatgpt-container">
<!-- 主聊天区域 -->
<div class="main-chat">
<!-- 顶部导航栏 -->
<div class="chat-navbar">
<div class="navbar-left">
<h2>FVA智能助手</h2>
</div>
<div class="navbar-right">
<div class="connection-status">
<el-icon :class="['status-icon', connectionStatus]">
<Connection v-if="connectionStatus === 'connected'" />
<Loading v-else-if="connectionStatus === 'connecting'" />
<Warning v-else />
</el-icon>
<span class="status-text">{{ connectionStatusText }}</span>
</div>
<el-button
text
@click="clearCurrentChat"
v-if="messages.length > 0"
>
<el-icon><Delete /></el-icon>
清空对话
</el-button>
<el-button
text
@click="toggleConnection"
>
<el-icon><Setting /></el-icon>
{{ isConnected ? '断开连接' : '重新连接' }}
</el-button>
</div>
</div>
<!-- 聊天消息区域 -->
<div class="chat-messages" ref="messagesContainer">
<!-- 欢迎界面 -->
<div v-if="messages.length === 0" class="welcome-screen">
<div class="welcome-content">
<div class="ai-logo">
<el-icon size="64"><ChatDotRound /></el-icon>
</div>
<h1>FVA智能助手</h1>
<p class="welcome-subtitle">我是您的专属AI助手可以帮您回答问题处理任务和进行智能对话</p>
<div class="example-prompts">
<div class="prompt-card" @click="setPrompt('请介绍一下FastAPI Vue3 Admin系统')">
<h4>系统介绍</h4>
<p>请介绍一下FastAPI Vue3 Admin系统</p>
</div>
<div class="prompt-card" @click="setPrompt('如何在FVA系统中创建新的模块?')">
<h4>开发指导</h4>
<p>如何在FVA系统中创建新的模块</p>
</div>
<div class="prompt-card" @click="setPrompt('FVA系统的权限管理是如何工作的?')">
<h4>权限管理</h4>
<p>FVA系统的权限管理是如何工作的</p>
</div>
<div class="prompt-card" @click="setPrompt('如何优化FVA系统的性能?')">
<h4>性能优化</h4>
<p>如何优化FVA系统的性能</p>
</div>
</div>
</div>
</div>
<!-- 消息列表 -->
<div v-else class="messages-list">
<div
v-for="message in messages"
:key="message.id"
:class="['message-group', message.type]"
>
<div class="message-avatar">
<div v-if="message.type === 'user'" class="user-avatar">
<el-icon><User /></el-icon>
</div>
<div v-else class="ai-avatar">
<el-icon><ChatDotRound /></el-icon>
</div>
</div>
<div class="message-content">
<div class="message-header">
<strong class="sender-name">
{{ message.type === 'user' ? 'You' : 'FVA助手' }}
</strong>
</div>
<div class="message-body">
<div v-if="message.type === 'assistant' && message.loading" class="typing-indicator">
<div class="typing-dots">
<span></span>
<span></span>
<span></span>
</div>
</div>
<div v-else class="message-text" v-html="formatMessage(message.content)"></div>
</div>
<div class="message-actions" v-if="!message.loading">
<el-button text size="small" @click="copyMessage(message.content)">
<el-icon><CopyDocument /></el-icon>
</el-button>
<el-button text size="small" v-if="message.type === 'assistant'">
<el-icon><RefreshLeft /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
<!-- 错误提示 -->
<div v-if="error" class="error-banner">
<el-alert
:title="error"
type="error"
:closable="true"
@close="error = ''"
show-icon
/>
</div>
</div>
<!-- 输入区域 -->
<div class="chat-input">
<div class="input-wrapper">
<div class="input-container">
<el-input
v-model="inputMessage"
:placeholder="isConnected ? '向FVA助手发送消息...' : '请先连接到服务器'"
:disabled="!isConnected || sending"
type="textarea"
:rows="1"
:autosize="{ minRows: 1, maxRows: 6 }"
@keydown.enter.exact.prevent="sendMessage"
@keydown.shift.enter.exact="inputMessage += '\n'"
class="message-input"
resize="none"
/>
<el-button
:disabled="!inputMessage.trim() || !isConnected || sending"
:loading="sending"
@click="sendMessage"
class="send-button"
type="primary"
circle
>
<el-icon><Promotion /></el-icon>
</el-button>
</div>
<div class="input-footer">
<span class="input-hint">
Enter 发送消息Shift + Enter 换行
</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted, nextTick, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
ChatDotRound,
User,
Delete,
Promotion,
Connection,
Loading,
Warning,
Setting,
CopyDocument,
RefreshLeft
} from '@element-plus/icons-vue'
// 消息接口
interface ChatMessage {
id: string
type: 'user' | 'assistant'
content: string
timestamp: number
loading?: boolean
}
// 响应式数据
const messages = ref<ChatMessage[]>([])
const inputMessage = ref('')
const sending = ref(false)
const isConnected = ref(false)
const connectionStatus = ref<'connected' | 'connecting' | 'disconnected'>('disconnected')
const error = ref('')
const messagesContainer = ref<HTMLElement>()
// WebSocket 连接
let ws: WebSocket | null = null
const WS_URL = 'ws://service.fastapiadmin.com/api/v1/ai/mcp/ws/chat'
// 计算属性
const connectionStatusText = computed(() => {
switch (connectionStatus.value) {
case 'connected': return '已连接'
case 'connecting': return '连接中...'
case 'disconnected': return '未连接'
default: return '未知状态'
}
})
// WebSocket 连接管理
const connectWebSocket = () => {
if (ws?.readyState === WebSocket.OPEN) {
return
}
connectionStatus.value = 'connecting'
error.value = ''
try {
ws = new WebSocket(WS_URL)
ws.onopen = () => {
console.log('WebSocket 连接已建立')
isConnected.value = true
connectionStatus.value = 'connected'
ElMessage.success('连接成功')
}
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
handleWebSocketMessage(data)
} catch (err) {
console.error('解析消息失败:', err)
handleWebSocketMessage({ content: event.data })
}
}
ws.onclose = (event) => {
console.log('WebSocket 连接已关闭', event.code, event.reason)
isConnected.value = false
connectionStatus.value = 'disconnected'
if (event.code !== 1000) {
error.value = '连接意外断开,请检查网络连接'
ElMessage.error('连接断开')
}
}
ws.onerror = (error) => {
console.error('WebSocket 错误:', error)
isConnected.value = false
connectionStatus.value = 'disconnected'
ElMessage.error('连接失败,请检查服务器状态')
}
} catch (err) {
console.error('创建 WebSocket 连接失败:', err)
connectionStatus.value = 'disconnected'
error.value = '无法创建连接'
}
}
// 断开连接
const disconnectWebSocket = () => {
if (ws) {
ws.close(1000, '用户主动断开')
ws = null
}
isConnected.value = false
connectionStatus.value = 'disconnected'
}
// 切换连接状态
const toggleConnection = () => {
if (isConnected.value) {
disconnectWebSocket()
ElMessage.info('已断开连接')
} else {
connectWebSocket()
}
}
// 处理 WebSocket 消息
const handleWebSocketMessage = (data: any) => {
// 查找最后一个助手消息
const lastMessage = messages.value[messages.value.length - 1]
if (lastMessage && lastMessage.type === 'assistant' && lastMessage.loading) {
// 更新加载中的消息
lastMessage.content = data.content || data.message || '收到回复'
lastMessage.loading = false
} else {
// 添加新的助手消息
addMessage('assistant', data.content || data.message || '收到回复')
}
scrollToBottom()
}
// 发送消息
const sendMessage = async () => {
const message = inputMessage.value.trim()
if (!message || !isConnected.value || sending.value) {
return
}
// 添加用户消息
addMessage('user', message)
inputMessage.value = ''
// 添加加载中的助手消息
const loadingMessage: ChatMessage = {
id: generateId(),
type: 'assistant',
content: '',
timestamp: Date.now(),
loading: true
}
messages.value.push(loadingMessage)
sending.value = true
scrollToBottom()
try {
// 发送消息到 WebSocket
if (ws?.readyState === WebSocket.OPEN) {
const payload = {
message: message,
timestamp: Date.now()
}
ws.send(JSON.stringify(payload))
} else {
throw new Error('WebSocket 连接未建立')
}
} catch (err) {
console.error('发送消息失败:', err)
// 移除加载消息并显示错误
messages.value.pop()
error.value = '发送消息失败,请检查连接状态'
ElMessage.error('发送失败')
} finally {
sending.value = false
}
}
// 添加消息
const addMessage = (type: 'user' | 'assistant', content: string) => {
const message: ChatMessage = {
id: generateId(),
type,
content,
timestamp: Date.now()
}
messages.value.push(message)
nextTick(() => scrollToBottom())
}
const clearCurrentChat = async () => {
try {
await ElMessageBox.confirm(
'确定要清空当前对话吗?此操作不可恢复。',
'确认清空',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
messages.value = []
ElMessage.success('对话已清空')
} catch {
// 用户取消
}
}
// 设置提示词
const setPrompt = (prompt: string) => {
inputMessage.value = prompt
}
// 复制消息
const copyMessage = async (content: string) => {
try {
await navigator.clipboard.writeText(content)
ElMessage.success('已复制到剪贴板')
} catch {
// 降级方案
const textArea = document.createElement('textarea')
textArea.value = content
document.body.appendChild(textArea)
textArea.select()
document.execCommand('copy')
document.body.removeChild(textArea)
ElMessage.success('已复制到剪贴板')
}
}
// 滚动到底部
const scrollToBottom = () => {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
})
}
// 格式化时间
const formatTime = (timestamp: number) => {
const date = new Date(timestamp)
const now = new Date()
if (date.toDateString() === now.toDateString()) {
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})
} else {
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
}
// 格式化消息内容
const formatMessage = (content: string) => {
if (!content) return ''
// 简单的 Markdown 支持
return content
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/`(.*?)`/g, '<code>$1</code>')
.replace(/\n/g, '<br>')
}
// 生成唯一ID
const generateId = () => {
return Date.now().toString(36) + Math.random().toString(36).substr(2)
}
// 生命周期
onMounted(() => {
connectWebSocket()
})
onUnmounted(() => {
disconnectWebSocket()
})
</script>
<style lang="scss" scoped>
.chatgpt-container {
display: flex;
height: calc(100vh - 120px);
background: var(--el-bg-color);
overflow: hidden;
}
// 主聊天区域
.main-chat {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
.chat-navbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid var(--el-border-color-light);
background: var(--el-bg-color);
.navbar-left {
h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.navbar-right {
display: flex;
align-items: center;
gap: 16px;
.connection-status {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
.status-icon {
&.connected { color: var(--el-color-success); }
&.connecting { color: var(--el-color-warning); }
&.disconnected { color: var(--el-color-danger); }
}
.status-text {
color: var(--el-text-color-secondary);
}
}
}
}
.chat-messages {
flex: 1;
overflow-y: auto;
background: var(--el-bg-color);
padding-bottom: 120px; // 为底部输入框留出空间
// 欢迎界面
.welcome-screen {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 32px;
text-align: center;
.welcome-content {
max-width: 800px;
.ai-logo {
margin-bottom: 24px;
color: var(--el-color-primary);
}
h1 {
font-size: 32px;
font-weight: 600;
margin: 0 0 16px;
color: var(--el-text-color-primary);
}
.welcome-subtitle {
font-size: 16px;
color: var(--el-text-color-secondary);
margin-bottom: 32px;
line-height: 1.5;
}
.example-prompts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
max-width: 600px;
.prompt-card {
background: var(--el-bg-color-page);
border: 1px solid var(--el-border-color-light);
border-radius: 12px;
padding: 20px;
cursor: pointer;
transition: all 0.2s ease;
text-align: left;
&:hover {
border-color: var(--el-color-primary);
transform: translateY(-2px);
box-shadow: var(--el-box-shadow-light);
}
h4 {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
p {
margin: 0;
font-size: 13px;
color: var(--el-text-color-secondary);
line-height: 1.4;
}
}
}
}
}
// 消息列表
.messages-list {
padding: 24px;
max-width: 800px;
margin: 0 auto;
.message-group {
margin-bottom: 32px;
display: flex;
gap: 16px;
.message-avatar {
flex-shrink: 0;
.user-avatar {
width: 32px;
height: 32px;
border-radius: 6px;
background: var(--el-color-primary);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 14px;
}
.ai-avatar {
width: 32px;
height: 32px;
border-radius: 6px;
background: var(--el-color-success);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 14px;
}
}
.message-content {
flex: 1;
min-width: 0;
.message-header {
margin-bottom: 8px;
.sender-name {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.message-body {
.message-text {
font-size: 15px;
line-height: 1.6;
color: var(--el-text-color-primary);
word-wrap: break-word;
:deep(p) {
margin: 0 0 12px;
&:last-child {
margin-bottom: 0;
}
}
:deep(code) {
background: var(--el-fill-color-light);
padding: 2px 6px;
border-radius: 4px;
font-family: 'JetBrains Mono', 'Courier New', monospace;
font-size: 14px;
}
:deep(pre) {
background: var(--el-fill-color-light);
padding: 16px;
border-radius: 8px;
overflow-x: auto;
margin: 12px 0;
code {
background: none;
padding: 0;
}
}
:deep(strong) {
font-weight: 600;
}
:deep(em) {
font-style: italic;
}
:deep(ul), :deep(ol) {
padding-left: 20px;
margin: 12px 0;
}
:deep(li) {
margin: 4px 0;
}
}
.typing-indicator {
display: flex;
align-items: center;
gap: 8px;
color: var(--el-text-color-secondary);
.typing-dots {
display: flex;
gap: 4px;
span {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--el-text-color-secondary);
animation: typing 1.4s infinite;
&:nth-child(2) { animation-delay: 0.2s; }
&:nth-child(3) { animation-delay: 0.4s; }
}
}
}
}
.message-actions {
display: flex;
gap: 4px;
margin-top: 8px;
opacity: 0;
transition: opacity 0.2s ease;
.el-button {
padding: 4px 8px;
min-height: auto;
}
}
&:hover .message-actions {
opacity: 1;
}
}
}
}
.error-banner {
padding: 16px 24px;
}
}
// 输入区域
.chat-input {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 16px 24px 24px;
background: var(--el-bg-color);
border-top: 1px solid var(--el-border-color-light);
z-index: 10;
.input-wrapper {
max-width: 800px;
margin: 0 auto;
.input-container {
position: relative;
background: var(--el-bg-color-page);
border: 1px solid var(--el-border-color);
border-radius: 12px;
transition: border-color 0.2s ease;
box-shadow: var(--el-box-shadow-light);
&:focus-within {
border-color: var(--el-color-primary);
box-shadow: var(--el-box-shadow);
}
.message-input {
:deep(.el-textarea__inner) {
border: none;
box-shadow: none;
background: transparent;
resize: none;
padding: 16px 60px 16px 16px;
font-size: 15px;
line-height: 1.5;
&:focus {
border: none;
box-shadow: none;
}
}
}
.send-button {
position: absolute;
right: 12px;
bottom: 12px;
width: 32px;
height: 32px;
min-height: 32px;
padding: 0;
}
}
.input-footer {
display: flex;
justify-content: center;
margin-top: 12px;
.input-hint {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
}
}
}
@keyframes typing {
0%, 20% {
opacity: 0.4;
transform: scale(0.8);
}
50% {
opacity: 1;
transform: scale(1);
}
80%, 100% {
opacity: 0.4;
transform: scale(0.8);
}
}
// 滚动条样式
.chat-messages::-webkit-scrollbar {
width: 6px;
}
.chat-messages::-webkit-scrollbar-track {
background: transparent;
}
.chat-messages::-webkit-scrollbar-thumb {
background: var(--el-fill-color);
border-radius: 3px;
&:hover {
background: var(--el-fill-color-dark);
}
}
</style>
+2 -13
View File
@@ -94,7 +94,7 @@
</div>
<!-- 空状态 -->
<div v-if="applicationList.length === 0 && !loading" class="empty-state">
<div v-if="applicationList.length === 0 && !loading" >
<el-empty :image-size="120" description="暂无应用" />
</div>
@@ -283,6 +283,7 @@ async function handleCurrentChange(page: number) {
// 创建应用
function handleCreateApp() {
console.log('handleCreateApp');
dialogType.value = 'create';
resetForm();
dialogVisible.value = true;
@@ -552,18 +553,6 @@ onMounted(() => {
justify-content: flex-end;
}
// 空状态样式
.empty-state {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;
z-index: 1;
}
.dialog-footer {
display: flex;
@@ -0,0 +1,13 @@
<template>
<div>
<!-- Simple view placeholder -->
</div>
</template>
<script setup lang="ts">
// Simple view component
</script>
<style scoped>
/* Simple view styles */
</style>
+36 -43
View File
@@ -127,11 +127,11 @@
<template #default="{ row }">
<div class="file-name">
<el-icon class="file-icon">
<Folder v-if="row.is_directory" />
<Folder v-if="row.is_dir" />
<Document v-else />
</el-icon>
<span
:class="{ 'file-name-clickable': !row.is_directory }"
:class="{ 'file-name-clickable': !row.is_dir }"
@click="handleFileNameClick(row)"
>
{{ row.name }}
@@ -141,7 +141,7 @@
</el-table-column>
<el-table-column label="大小" prop="size" min-width="120" align="center">
<template #default="{ row }">
<span v-if="!row.is_directory">{{ formatFileSize(row.size) }}</span>
<span v-if="!row.is_dir">{{ formatFileSize(row.size) }}</span>
</template>
</el-table-column>
<el-table-column label="类型" prop="file_type" min-width="100" align="center">
@@ -153,7 +153,7 @@
<el-table-column fixed="right" label="操作" align="center" min-width="200">
<template #default="{ row }">
<el-button
v-if="!row.is_directory"
v-if="!row.is_dir"
type="success"
size="small"
link
@@ -181,7 +181,7 @@
@click="handleItemClick(item)"
>
<div class="item-icon">
<el-icon v-if="item.is_directory" size="48">
<el-icon v-if="item.is_dir" size="48">
<Folder />
</el-icon>
<el-icon v-else size="48">
@@ -189,7 +189,7 @@
</el-icon>
</div>
<div class="item-name">{{ item.name }}</div>
<div class="item-size" v-if="!item.is_directory">
<div class="item-size" v-if="!item.is_dir">
{{ formatFileSize(item.size) }}
</div>
</div>
@@ -347,8 +347,14 @@ const renameForm = reactive({
old_path: ''
})
// 工具函数:确保路径基于根路径
// 工具函数:处理路径转换
const ensureRootPath = (path: string) => {
// 如果是HTTP URL,提取相对路径部分
if (path.startsWith('http')) {
const url = new URL(path)
return url.pathname.replace('/api/v1/static', '/home/static')
}
if (path.startsWith(RESOURCE_ROOT_PATH)) {
return path
}
@@ -367,26 +373,16 @@ const loadFileList = async () => {
loading.value = true
const response = await ResourceAPI.getResourceList(currentQuery.value)
// 根据实际 API 响应结构获取数据
// 根据新的 API 响应结构获取数据
const data = response.data?.data?.items || response.data?.data
if (Array.isArray(data)) {
// 转换数据格式以匹配前端期望的结构
// 直接使用后端返回的数据结构,无需转换
fileList.value = data.map(item => ({
name: item.name,
path: item.path,
is_directory: item.is_dir || item.is_directory,
size: item.size,
file_type: item.file_type,
extension: item.file_extension,
modified_time: item.modified_time,
created_time: item.created_time,
is_hidden: item.name.startsWith('.'),
resource_type: item.resource_type,
file_url: item.file_url,
thumbnail_url: item.thumbnail_url
...item,
is_hidden: item.name.startsWith('.')
}))
total.value = fileList.value.length
total.value = response.data?.data?.total_files + response.data?.data?.total_dirs || fileList.value.length
} else {
fileList.value = []
total.value = 0
@@ -411,7 +407,15 @@ const handleBreadcrumbClick = (item: any, index: number) => {
const updateBreadcrumb = () => {
const rootPath = RESOURCE_ROOT_PATH
const relativePath = currentPath.value.replace(rootPath, '').replace(/^\/+/, '')
let relativePath = currentPath.value
// 处理HTTP URL格式的路径
if (currentPath.value.startsWith('http')) {
const url = new URL(currentPath.value)
relativePath = url.pathname.replace('/api/v1/static', '/home/static')
}
relativePath = relativePath.replace(rootPath, '').replace(/^\/+/, '')
const pathParts = relativePath ? relativePath.split('/').filter(Boolean) : []
breadcrumbList.value = [
@@ -424,7 +428,7 @@ const updateBreadcrumb = () => {
}
const handleFileNameClick = (row: ResourceItem) => {
if (row.is_directory) {
if (row.is_dir) {
currentPath.value = ensureRootPath(row.path)
updateBreadcrumb()
loadFileList()
@@ -435,7 +439,7 @@ const handleFileNameClick = (row: ResourceItem) => {
}
const handleItemClick = (item: ResourceItem) => {
if (item.is_directory) {
if (item.is_dir) {
currentPath.value = ensureRootPath(item.path)
updateBreadcrumb()
loadFileList()
@@ -447,9 +451,8 @@ const handleItemClick = (item: ResourceItem) => {
// 文件预览
const handleFilePreview = (file: ResourceItem) => {
// 构建文件预览URL,移除/home前缀
const relativePath = file.path.replace('/home/static', '')
const previewUrl = `${import.meta.env.VITE_API_BASE_URL}${import.meta.env.VITE_APP_BASE_API}/static${relativePath}`
// 使用后端返回的完整URL路径进行预览
const previewUrl = file.path
// 根据文件类型决定预览方式
const fileExtension = file.file_extension?.toLowerCase() || ''
@@ -547,25 +550,15 @@ const handleQuery = async () => {
loading.value = true
const response = await ResourceAPI.searchResource(queryFormData)
// 根据实际 API 响应结构获取数据
// 根据新的 API 响应结构获取数据
const data = response.data?.data?.items || response.data?.data
if (Array.isArray(data)) {
// 转换数据格式以匹配前端期望的结构
// 直接使用后端返回的数据结构,无需转换
fileList.value = data.map(item => ({
name: item.name,
path: item.path,
is_directory: item.is_dir || item.is_directory,
size: item.size,
file_type: item.file_type,
extension: item.file_extension,
modified_time: item.modified_time,
created_time: item.created_time,
is_hidden: item.name.startsWith('.'),
resource_type: item.resource_type,
file_url: item.file_url,
thumbnail_url: item.thumbnail_url
...item,
is_hidden: item.name.startsWith('.')
}))
total.value = fileList.value.length
total.value = response.data?.data?.total_files + response.data?.data?.total_dirs || fileList.value.length
} else {
console.warn('搜索 API 返回的数据不是数组类型:', data)
fileList.value = []