1
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 390 KiB After Width: | Height: | Size: 54 KiB |
+1
-1
@@ -99,7 +99,7 @@ export const dynamicRoutes = [
|
|||||||
component: Layout,
|
component: Layout,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: 'index',
|
path: '',
|
||||||
component: () => import('@/views/frontend/index'),
|
component: () => import('@/views/frontend/index'),
|
||||||
name: 'Frontend',
|
name: 'Frontend',
|
||||||
meta: { title: '数智 AI' }
|
meta: { title: '数智 AI' }
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const useSettingsStore = defineStore(
|
|||||||
footerVisible: storageSetting.footerVisible === undefined ? footerVisible : storageSetting.footerVisible,
|
footerVisible: storageSetting.footerVisible === undefined ? footerVisible : storageSetting.footerVisible,
|
||||||
footerContent: footerContent,
|
footerContent: footerContent,
|
||||||
isDark: isDark.value,
|
isDark: isDark.value,
|
||||||
frontendTheme: (typeof localStorage !== 'undefined' && localStorage.getItem('frontend-theme')) || 'dark'
|
frontendTheme: (typeof localStorage !== 'undefined' && localStorage.getItem('frontend-theme')) || 'light'
|
||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
// 修改布局设置
|
// 修改布局设置
|
||||||
@@ -61,12 +61,15 @@ const useSettingsStore = defineStore(
|
|||||||
document.querySelectorAll('.frontend-container').forEach((el) => {
|
document.querySelectorAll('.frontend-container').forEach((el) => {
|
||||||
el.setAttribute('data-theme', this.frontendTheme)
|
el.setAttribute('data-theme', this.frontendTheme)
|
||||||
})
|
})
|
||||||
|
// 同步到 body,供 Teleport 到 body 的元素(如右键菜单)继承 CSS 变量
|
||||||
|
document.body.setAttribute('data-fe-theme', this.frontendTheme)
|
||||||
},
|
},
|
||||||
// 应用 frontend 主题到所有 .frontend-container(启动时调用)
|
// 应用 frontend 主题到所有 .frontend-container 和 body(启动时调用)
|
||||||
applyFrontendTheme() {
|
applyFrontendTheme() {
|
||||||
document.querySelectorAll('.frontend-container').forEach((el) => {
|
document.querySelectorAll('.frontend-container').forEach((el) => {
|
||||||
el.setAttribute('data-theme', this.frontendTheme)
|
el.setAttribute('data-theme', this.frontendTheme)
|
||||||
})
|
})
|
||||||
|
document.body.setAttribute('data-fe-theme', this.frontendTheme)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,55 +7,58 @@
|
|||||||
|
|
||||||
<transition name="sidebar-fade">
|
<transition name="sidebar-fade">
|
||||||
<div v-if="!collapsed" class="sidebar-content">
|
<div v-if="!collapsed" class="sidebar-content">
|
||||||
<div class="sidebar-header">
|
<slot name="content">
|
||||||
<span>会话列表</span>
|
<div class="sidebar-header">
|
||||||
<div class="header-actions">
|
<span>会话列表</span>
|
||||||
<el-tooltip content="新建会话" placement="top" :show-after="300">
|
<div class="header-actions">
|
||||||
<el-button class="icon-btn" link @click="handleNewSession">
|
<el-tooltip content="新建会话" placement="top" :show-after="300">
|
||||||
<el-icon><Plus /></el-icon>
|
<el-button class="icon-btn" link @click="handleNewSession">
|
||||||
</el-button>
|
<el-icon><Plus /></el-icon>
|
||||||
</el-tooltip>
|
</el-button>
|
||||||
<el-tooltip content="刷新" placement="top" :show-after="300">
|
</el-tooltip>
|
||||||
<el-button class="icon-btn" link @click="fetchSessions" :loading="loading">
|
<el-tooltip content="刷新" placement="top" :show-after="300">
|
||||||
<el-icon><Refresh /></el-icon>
|
<el-button class="icon-btn" link @click="fetchSessions">
|
||||||
</el-button>
|
<el-icon v-if="!loading"><Refresh /></el-icon>
|
||||||
</el-tooltip>
|
<el-icon v-else class="is-loading"><Loading /></el-icon>
|
||||||
</div>
|
</el-button>
|
||||||
</div>
|
</el-tooltip>
|
||||||
<el-scrollbar class="sidebar-scrollbar">
|
</div>
|
||||||
<div v-if="loading && sessionList.length === 0" class="loading-tip">
|
|
||||||
加载中...
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-for="session in sessionList"
|
|
||||||
:key="session.sessionId"
|
|
||||||
class="conversation-item"
|
|
||||||
:class="{ active: currentSession?.sessionId === session.sessionId, 'has-new-message': session.hasNewMessage }"
|
|
||||||
@click="selectSession(session)"
|
|
||||||
@contextmenu.prevent="handleRightClick($event, session)"
|
|
||||||
>
|
|
||||||
<div class="conv-dot"></div>
|
|
||||||
<div class="conv-info">
|
|
||||||
<span class="conv-name">{{ session.title || session.name }}</span>
|
|
||||||
<span class="conv-time">{{ formatTimeAgo(session.updateTime) }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="session.hasNewMessage" class="new-message-dot"></div>
|
<el-scrollbar class="sidebar-scrollbar">
|
||||||
</div>
|
<div v-if="loading && sessionList.length === 0" class="loading-tip">
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="session in sessionList"
|
||||||
|
:key="session.sessionId"
|
||||||
|
class="conversation-item"
|
||||||
|
:class="{ active: currentSession?.sessionId === session.sessionId, 'has-new-message': session.hasNewMessage }"
|
||||||
|
@click="selectSession(session)"
|
||||||
|
@contextmenu.prevent="handleRightClick($event, session)"
|
||||||
|
>
|
||||||
|
<div class="conv-dot"></div>
|
||||||
|
<div class="conv-info">
|
||||||
|
<span class="conv-name">{{ session.title || session.name }}</span>
|
||||||
|
<span class="conv-time">{{ formatTimeAgo(session.updateTime) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="session.hasNewMessage" class="new-message-dot"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 右键菜单 -->
|
<!-- 右键菜单 -->
|
||||||
<teleport to="body">
|
<teleport to="body">
|
||||||
<div
|
<div
|
||||||
v-if="contextMenuVisible"
|
v-if="contextMenuVisible"
|
||||||
class="context-menu"
|
class="context-menu"
|
||||||
:style="{ top: contextMenuTop + 'px', left: contextMenuLeft + 'px' }"
|
:style="{ top: contextMenuTop + 'px', left: contextMenuLeft + 'px' }"
|
||||||
@click.stop
|
@click.stop
|
||||||
>
|
>
|
||||||
<div class="context-menu-item" @click="handleRename">重命名</div>
|
<div class="context-menu-item" @click="handleRename">重命名</div>
|
||||||
<div class="context-menu-item danger" @click="handleDeleteFromMenu">删除</div>
|
<div class="context-menu-item danger" @click="handleDeleteFromMenu">删除</div>
|
||||||
</div>
|
</div>
|
||||||
</teleport>
|
</teleport>
|
||||||
<div v-if="!loading && sessionList.length === 0" class="empty-sessions">暂无会话</div>
|
<div v-if="!loading && sessionList.length === 0" class="empty-sessions">暂无会话</div>
|
||||||
</el-scrollbar>
|
</el-scrollbar>
|
||||||
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
|
|
||||||
@@ -66,7 +69,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { DArrowLeft, DArrowRight, Refresh, Plus, Delete } from '@element-plus/icons-vue'
|
import { DArrowLeft, DArrowRight, Refresh, Plus, Delete, Loading } from '@element-plus/icons-vue'
|
||||||
import { listChatSessions, deleteChatSession, renameChatSession } from '@/api/frontend'
|
import { listChatSessions, deleteChatSession, renameChatSession } from '@/api/frontend'
|
||||||
import { formatTimeAgo } from '@/utils/time'
|
import { formatTimeAgo } from '@/utils/time'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
@@ -113,13 +116,20 @@ function handleRename() {
|
|||||||
ElMessageBox.prompt('请输入新名称', '重命名会话', {
|
ElMessageBox.prompt('请输入新名称', '重命名会话', {
|
||||||
confirmButtonText: '确定',
|
confirmButtonText: '确定',
|
||||||
cancelButtonText: '取消',
|
cancelButtonText: '取消',
|
||||||
inputValue: session.title || session.name || ''
|
inputValue: session.title || session.name || '',
|
||||||
|
customClass: 'frontend-el-message-box'
|
||||||
}).then(async ({ value }) => {
|
}).then(async ({ value }) => {
|
||||||
await renameChatSession(session.sessionId, value)
|
await renameChatSession(session.sessionId, value)
|
||||||
ElMessage.success('重命名成功')
|
ElMessage.success('重命名成功')
|
||||||
// 更新本地列表
|
// 更新本地列表
|
||||||
session.title = value
|
session.title = value
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
|
|
||||||
|
// ElMessageBox 没有 @opened 事件,等待挂载完成后选中已有文字
|
||||||
|
setTimeout(() => {
|
||||||
|
const input = document.querySelector('.frontend-el-message-box .el-message-box__input input')
|
||||||
|
if (input) input.select()
|
||||||
|
}, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDeleteFromMenu() {
|
function handleDeleteFromMenu() {
|
||||||
@@ -142,6 +152,7 @@ function selectSession(session) {
|
|||||||
|
|
||||||
async function fetchSessions() {
|
async function fetchSessions() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
const start = Date.now()
|
||||||
try {
|
try {
|
||||||
const res = await listChatSessions(getCurrentAgentId())
|
const res = await listChatSessions(getCurrentAgentId())
|
||||||
sessionList.value = res.data || []
|
sessionList.value = res.data || []
|
||||||
@@ -153,6 +164,10 @@ async function fetchSessions() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取会话列表失败:', error)
|
console.error('获取会话列表失败:', error)
|
||||||
} finally {
|
} finally {
|
||||||
|
// 强制最小 loading 时长,避免接口返回过快导致 icon 一闪而过
|
||||||
|
const elapsed = Date.now() - start
|
||||||
|
const remain = 300 - elapsed
|
||||||
|
if (remain > 0) await new Promise(resolve => setTimeout(resolve, remain))
|
||||||
loading.value = false
|
loading.value = false
|
||||||
emit('ready')
|
emit('ready')
|
||||||
}
|
}
|
||||||
@@ -170,7 +185,8 @@ async function handleDeleteSession(session) {
|
|||||||
{
|
{
|
||||||
confirmButtonText: '确定',
|
confirmButtonText: '确定',
|
||||||
cancelButtonText: '取消',
|
cancelButtonText: '取消',
|
||||||
type: 'warning'
|
type: 'warning',
|
||||||
|
customClass: 'frontend-el-message-box'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
await deleteChatSession(session.workspacePath, session.sessionId)
|
await deleteChatSession(session.workspacePath, session.sessionId)
|
||||||
@@ -230,12 +246,17 @@ defineExpose({
|
|||||||
// 只刷新会话列表,不选中任何会话(避免触发 handleConversationSelect)
|
// 只刷新会话列表,不选中任何会话(避免触发 handleConversationSelect)
|
||||||
refreshQuiet: async () => {
|
refreshQuiet: async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
const start = Date.now()
|
||||||
try {
|
try {
|
||||||
const res = await listChatSessions(getCurrentAgentId())
|
const res = await listChatSessions(getCurrentAgentId())
|
||||||
sessionList.value = res.data || []
|
sessionList.value = res.data || []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取会话列表失败:', error)
|
console.error('获取会话列表失败:', error)
|
||||||
} finally {
|
} finally {
|
||||||
|
// 强制最小 loading 时长,避免 icon 一闪而过
|
||||||
|
const elapsed = Date.now() - start
|
||||||
|
const remain = 300 - elapsed
|
||||||
|
if (remain > 0) await new Promise(resolve => setTimeout(resolve, remain))
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -322,10 +343,20 @@ defineExpose({
|
|||||||
.el-icon {
|
.el-icon {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载中图标旋转动画
|
||||||
|
.is-loading {
|
||||||
|
animation: sidebar-rotate 1s linear infinite;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes sidebar-rotate {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-scrollbar {
|
.sidebar-scrollbar {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -371,11 +402,15 @@ defineExpose({
|
|||||||
.conv-dot {
|
.conv-dot {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
background: #4a9eff;
|
background: transparent;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.active .conv-dot {
|
||||||
|
background: var(--fe-accent);
|
||||||
|
}
|
||||||
|
|
||||||
.conv-info {
|
.conv-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -428,32 +463,3 @@ defineExpose({
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style>
|
|
||||||
.context-menu {
|
|
||||||
position: fixed;
|
|
||||||
background: var(--fe-bg-elevated);
|
|
||||||
border: 1px solid var(--fe-border);
|
|
||||||
border-radius: var(--fe-radius-sm);
|
|
||||||
box-shadow: var(--fe-shadow-md);
|
|
||||||
z-index: 9999;
|
|
||||||
padding: 4px 0;
|
|
||||||
min-width: 100px;
|
|
||||||
color: var(--fe-text-primary);
|
|
||||||
|
|
||||||
.context-menu-item {
|
|
||||||
padding: 8px 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
&:hover {
|
|
||||||
background: var(--fe-bg-hover);
|
|
||||||
}
|
|
||||||
&.danger {
|
|
||||||
color: var(--fe-danger);
|
|
||||||
&:hover {
|
|
||||||
background: var(--fe-danger-soft);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
<el-tooltip content="刷新" placement="top" :show-after="300">
|
<el-tooltip content="刷新" placement="top" :show-after="300">
|
||||||
<el-button class="icon-btn" link @click="fetchFiles">
|
<el-button class="icon-btn" link @click="fetchFiles">
|
||||||
<el-icon><Refresh /></el-icon>
|
<el-icon v-if="!loading"><Refresh /></el-icon>
|
||||||
|
<el-icon v-else class="is-loading"><Loading /></el-icon>
|
||||||
</el-button>
|
</el-button>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
<el-tooltip content="关闭" placement="top" :show-after="300">
|
<el-tooltip content="关闭" placement="top" :show-after="300">
|
||||||
@@ -27,7 +28,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-content" @contextmenu.prevent="handlePanelContextMenu">
|
<div class="panel-content" @contextmenu.prevent="handlePanelContextMenu">
|
||||||
<div v-if="loading" class="loading-wrapper">
|
<div v-if="loading && fileTree.length === 0" class="loading-wrapper">
|
||||||
<el-icon class="loading-icon"><Loading /></el-icon>
|
<el-icon class="loading-icon"><Loading /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="fileTree.length === 0" class="empty-wrapper">
|
<div v-else-if="fileTree.length === 0" class="empty-wrapper">
|
||||||
@@ -45,7 +46,7 @@
|
|||||||
<template #default="{ node, data }">
|
<template #default="{ node, data }">
|
||||||
<span class="tree-node" @contextmenu.stop.prevent="handleContextMenu($event, data)">
|
<span class="tree-node" @contextmenu.stop.prevent="handleContextMenu($event, data)">
|
||||||
<el-icon v-if="data.type === 'directory'"><Folder /></el-icon>
|
<el-icon v-if="data.type === 'directory'"><Folder /></el-icon>
|
||||||
<el-icon v-else><Document /></el-icon>
|
<el-icon v-else :style="{ color: getFileIconInfo(data.name).color }"><component :is="getFileIconInfo(data.name).component" /></el-icon>
|
||||||
<span class="node-label">{{ node.label }}</span>
|
<span class="node-label">{{ node.label }}</span>
|
||||||
<el-icon
|
<el-icon
|
||||||
v-if="data.type === 'file'"
|
v-if="data.type === 'file'"
|
||||||
@@ -82,8 +83,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 新建文件/文件夹对话框 -->
|
<!-- 新建文件/文件夹对话框 -->
|
||||||
<el-dialog v-model="createDialogVisible" :title="createDialogTitle" width="400px" :close-on-click-modal="false">
|
<el-dialog v-model="createDialogVisible" :title="createDialogTitle" width="400px" :close-on-click-modal="false" @opened="handleCreateOpened">
|
||||||
<el-input v-model="createName" placeholder="请输入名称" @keyup.enter="confirmCreate" />
|
<el-input ref="createInputRef" v-model="createName" placeholder="请输入名称" @keyup.enter="confirmCreate" />
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="createDialogVisible = false">取消</el-button>
|
<el-button @click="createDialogVisible = false">取消</el-button>
|
||||||
<el-button type="primary" @click="confirmCreate">确定</el-button>
|
<el-button type="primary" @click="confirmCreate">确定</el-button>
|
||||||
@@ -91,8 +92,8 @@
|
|||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 重命名对话框 -->
|
<!-- 重命名对话框 -->
|
||||||
<el-dialog v-model="renameDialogVisible" title="重命名" width="400px" :close-on-click-modal="false">
|
<el-dialog v-model="renameDialogVisible" title="重命名" width="400px" :close-on-click-modal="false" @opened="handleRenameOpened">
|
||||||
<el-input v-model="renameName" placeholder="请输入新名称" @keyup.enter="confirmRename" />
|
<el-input ref="renameInputRef" v-model="renameName" placeholder="请输入新名称" @keyup.enter="confirmRename" />
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="renameDialogVisible = false">取消</el-button>
|
<el-button @click="renameDialogVisible = false">取消</el-button>
|
||||||
<el-button type="primary" @click="confirmRename">确定</el-button>
|
<el-button type="primary" @click="confirmRename">确定</el-button>
|
||||||
@@ -150,11 +151,183 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, watch, onMounted, onUnmounted, defineComponent, h } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { Close, Folder, FolderOpened, Document, Download, Refresh, Upload, Loading } from '@element-plus/icons-vue'
|
import { Close, Folder, FolderOpened, Document, Download, Refresh, Upload, Loading } from '@element-plus/icons-vue'
|
||||||
import { listFiles, downloadFile, createFileOrDirectory, uploadFileOrDirectory, deleteFileOrDirectory, renameFile, downloadProject } from '@/api/frontend'
|
import { listFiles, downloadFile, createFileOrDirectory, uploadFileOrDirectory, deleteFileOrDirectory, renameFile, downloadProject } from '@/api/frontend'
|
||||||
|
|
||||||
|
// 类似 lucide-code-xml 的代码图标(Element Plus icons 没有原生 code icon)
|
||||||
|
const CodeXmlIcon = defineComponent({
|
||||||
|
name: 'CodeXmlIcon',
|
||||||
|
render() {
|
||||||
|
return h('svg', {
|
||||||
|
xmlns: 'http://www.w3.org/2000/svg',
|
||||||
|
viewBox: '0 0 24 24',
|
||||||
|
fill: 'none',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
'stroke-width': '1.75',
|
||||||
|
'stroke-linecap': 'round',
|
||||||
|
'stroke-linejoin': 'round',
|
||||||
|
style: 'width: 1em; height: 1em;'
|
||||||
|
}, [
|
||||||
|
h('path', { d: 'm18 16 4-4-4-4' }),
|
||||||
|
h('path', { d: 'm6 8-4 4 4 4' }),
|
||||||
|
h('path', { d: 'm14.5 4-5 16' })
|
||||||
|
])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 类似 lucide-terminal:> + 下划线
|
||||||
|
const TerminalIcon = defineComponent({
|
||||||
|
name: 'TerminalIcon',
|
||||||
|
render() {
|
||||||
|
return h('svg', {
|
||||||
|
xmlns: 'http://www.w3.org/2000/svg',
|
||||||
|
viewBox: '0 0 24 24',
|
||||||
|
fill: 'none',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
'stroke-width': '1.75',
|
||||||
|
'stroke-linecap': 'round',
|
||||||
|
'stroke-linejoin': 'round',
|
||||||
|
style: 'width: 1em; height: 1em;'
|
||||||
|
}, [
|
||||||
|
h('polyline', { points: '4 17 10 11 4 5' }),
|
||||||
|
h('line', { x1: '12', y1: '19', x2: '20', y2: '19' })
|
||||||
|
])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 类似 lucide-file-text:带三条横线的文件(用于 PDF)
|
||||||
|
const FileTextIcon = defineComponent({
|
||||||
|
name: 'FileTextIcon',
|
||||||
|
render() {
|
||||||
|
return h('svg', {
|
||||||
|
xmlns: 'http://www.w3.org/2000/svg',
|
||||||
|
viewBox: '0 0 24 24',
|
||||||
|
fill: 'none',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
'stroke-width': '1.75',
|
||||||
|
'stroke-linecap': 'round',
|
||||||
|
'stroke-linejoin': 'round',
|
||||||
|
style: 'width: 1em; height: 1em;'
|
||||||
|
}, [
|
||||||
|
h('path', { d: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' }),
|
||||||
|
h('path', { d: 'M14 2v4h6' }),
|
||||||
|
h('path', { d: 'M10 9H8' }),
|
||||||
|
h('path', { d: 'M16 13H8' }),
|
||||||
|
h('path', { d: 'M16 17H8' })
|
||||||
|
])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 类似 lucide-file-presentation:带三柱状条的文件(用于 PPT)
|
||||||
|
const FilePresentationIcon = defineComponent({
|
||||||
|
name: 'FilePresentationIcon',
|
||||||
|
render() {
|
||||||
|
return h('svg', {
|
||||||
|
xmlns: 'http://www.w3.org/2000/svg',
|
||||||
|
viewBox: '0 0 24 24',
|
||||||
|
fill: 'none',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
'stroke-width': '1.75',
|
||||||
|
'stroke-linecap': 'round',
|
||||||
|
'stroke-linejoin': 'round',
|
||||||
|
style: 'width: 1em; height: 1em;'
|
||||||
|
}, [
|
||||||
|
h('path', { d: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' }),
|
||||||
|
h('path', { d: 'M14 2v4h6' }),
|
||||||
|
h('path', { d: 'M8 18v-6' }),
|
||||||
|
h('path', { d: 'M12 18v-6' }),
|
||||||
|
h('path', { d: 'M16 18v-6' }),
|
||||||
|
h('path', { d: 'M8 18h8' })
|
||||||
|
])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 通用 SVG 属性(lucide 风格)
|
||||||
|
const lucideSvgProps = {
|
||||||
|
xmlns: 'http://www.w3.org/2000/svg',
|
||||||
|
viewBox: '0 0 24 24',
|
||||||
|
fill: 'none',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
'stroke-width': '1.75',
|
||||||
|
'stroke-linecap': 'round',
|
||||||
|
'stroke-linejoin': 'round',
|
||||||
|
style: 'width: 1em; height: 1em;'
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeLucideIcon(name, paths) {
|
||||||
|
return defineComponent({
|
||||||
|
name,
|
||||||
|
render() {
|
||||||
|
return h('svg', lucideSvgProps, paths.map((p) => h(p.tag, p.attrs)))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// lucide-file-image:文件 + 圆 + 山形
|
||||||
|
const FileImageIcon = makeLucideIcon('FileImageIcon', [
|
||||||
|
{ tag: 'path', attrs: { d: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' } },
|
||||||
|
{ tag: 'path', attrs: { d: 'M14 2v4h6' } },
|
||||||
|
{ tag: 'circle', attrs: { cx: '9.5', cy: '13.5', r: '1.5' } },
|
||||||
|
{ tag: 'path', attrs: { d: 'M16 16l-3-3-4 4' } }
|
||||||
|
])
|
||||||
|
|
||||||
|
// lucide-file-video:文件 + 三角形
|
||||||
|
const FileVideoIcon = makeLucideIcon('FileVideoIcon', [
|
||||||
|
{ tag: 'path', attrs: { d: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' } },
|
||||||
|
{ tag: 'path', attrs: { d: 'M14 2v4h6' } },
|
||||||
|
{ tag: 'path', attrs: { d: 'm10 11 5 3-5 3v-6Z' } }
|
||||||
|
])
|
||||||
|
|
||||||
|
// lucide-file-music:文件 + 音符
|
||||||
|
const FileMusicIcon = makeLucideIcon('FileMusicIcon', [
|
||||||
|
{ tag: 'path', attrs: { d: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' } },
|
||||||
|
{ tag: 'path', attrs: { d: 'M14 2v4h6' } },
|
||||||
|
{ tag: 'circle', attrs: { cx: '8', cy: '18', r: '2' } },
|
||||||
|
{ tag: 'path', attrs: { d: 'M10 18V8l6-2v8' } }
|
||||||
|
])
|
||||||
|
|
||||||
|
// lucide-file-archive:文件 + 拉链
|
||||||
|
const FileArchiveIcon = makeLucideIcon('FileArchiveIcon', [
|
||||||
|
{ tag: 'path', attrs: { d: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' } },
|
||||||
|
{ tag: 'path', attrs: { d: 'M14 2v4h6' } },
|
||||||
|
{ tag: 'rect', attrs: { x: '10', y: '12', width: '4', height: '6', rx: '1' } }
|
||||||
|
])
|
||||||
|
|
||||||
|
// lucide-file-spreadsheet:文件 + 表格
|
||||||
|
const FileSpreadsheetIcon = makeLucideIcon('FileSpreadsheetIcon', [
|
||||||
|
{ tag: 'path', attrs: { d: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' } },
|
||||||
|
{ tag: 'path', attrs: { d: 'M14 2v4h6' } },
|
||||||
|
{ tag: 'path', attrs: { d: 'M8 13h2' } },
|
||||||
|
{ tag: 'path', attrs: { d: 'M14 13h2' } },
|
||||||
|
{ tag: 'path', attrs: { d: 'M8 17h2' } },
|
||||||
|
{ tag: 'path', attrs: { d: 'M14 17h2' } }
|
||||||
|
])
|
||||||
|
|
||||||
|
// lucide-file-code:文件 + < >
|
||||||
|
const FileCodeIcon = makeLucideIcon('FileCodeIcon', [
|
||||||
|
{ tag: 'path', attrs: { d: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' } },
|
||||||
|
{ tag: 'path', attrs: { d: 'M14 2v4h6' } },
|
||||||
|
{ tag: 'path', attrs: { d: 'm9 18 3-3-3-3' } },
|
||||||
|
{ tag: 'path', attrs: { d: 'm15 12-3-3 3-3' } }
|
||||||
|
])
|
||||||
|
|
||||||
|
// lucide-file-json:文件 + { }
|
||||||
|
const FileJsonIcon = makeLucideIcon('FileJsonIcon', [
|
||||||
|
{ tag: 'path', attrs: { d: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' } },
|
||||||
|
{ tag: 'path', attrs: { d: 'M14 2v4h6' } },
|
||||||
|
{ tag: 'path', attrs: { d: 'M10 12a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1-1-1v-1' } },
|
||||||
|
{ tag: 'path', attrs: { d: 'M14 18a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1 1 1 0 0 1 1 1v1' } }
|
||||||
|
])
|
||||||
|
|
||||||
|
// lucide-database:圆柱形数据库
|
||||||
|
const DatabaseIcon = makeLucideIcon('DatabaseIcon', [
|
||||||
|
{ tag: 'ellipse', attrs: { cx: '12', cy: '5', rx: '9', ry: '3' } },
|
||||||
|
{ tag: 'path', attrs: { d: 'M3 5v14a9 3 0 0 0 18 0V5' } },
|
||||||
|
{ tag: 'path', attrs: { d: 'M3 12a9 3 0 0 0 18 0' } }
|
||||||
|
])
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
visible: {
|
visible: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -177,6 +350,61 @@ const loading = ref(false)
|
|||||||
const fileTree = ref([])
|
const fileTree = ref([])
|
||||||
const defaultExpandedKeys = ref([])
|
const defaultExpandedKeys = ref([])
|
||||||
|
|
||||||
|
// 按文件扩展名分类,返回 icon 组件 + 配色
|
||||||
|
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', 'webp', 'ico'])
|
||||||
|
const VIDEO_EXTS = new Set(['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm', 'mpeg', 'mpg'])
|
||||||
|
const AUDIO_EXTS = new Set(['mp3', 'wav', 'flac', 'ogg', 'aac', 'm4a'])
|
||||||
|
const ARCHIVE_EXTS = new Set(['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'jar', 'war'])
|
||||||
|
|
||||||
|
// Office 文档
|
||||||
|
const WORD_EXTS = new Set(['doc', 'docx', 'rtf'])
|
||||||
|
const EXCEL_EXTS = new Set(['xls', 'xlsx', 'csv', 'tsv'])
|
||||||
|
const PPT_EXTS = new Set(['ppt', 'pptx'])
|
||||||
|
const PDF_EXTS = new Set(['pdf'])
|
||||||
|
|
||||||
|
// 代码(按语言细分)
|
||||||
|
const WEB_CODE_EXTS = new Set([
|
||||||
|
'html', 'htm', 'css', 'scss', 'sass', 'less',
|
||||||
|
'js', 'jsx', 'ts', 'tsx', 'vue', 'svelte', 'mjs', 'cjs'
|
||||||
|
])
|
||||||
|
const BACKEND_CODE_EXTS = new Set([
|
||||||
|
'java', 'go', 'rs',
|
||||||
|
'c', 'h', 'cpp', 'cc', 'hpp',
|
||||||
|
'cs', 'swift', 'kt', 'kts', 'scala',
|
||||||
|
'rb', 'php'
|
||||||
|
])
|
||||||
|
const SCRIPT_EXTS = new Set([
|
||||||
|
'py', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd'
|
||||||
|
])
|
||||||
|
const DATA_EXTS = new Set([
|
||||||
|
'json', 'yaml', 'yml', 'xml', 'toml', 'ini', 'conf', 'properties', 'env'
|
||||||
|
])
|
||||||
|
const SQL_EXTS = new Set(['sql', 'graphql', 'gql', 'proto'])
|
||||||
|
|
||||||
|
const defaultFileIcon = { component: Document, color: '#94a3b8' }
|
||||||
|
|
||||||
|
function getFileIconInfo(fileName) {
|
||||||
|
if (!fileName) return defaultFileIcon
|
||||||
|
const ext = fileName.toLowerCase().split('.').pop()
|
||||||
|
// 多媒体
|
||||||
|
if (IMAGE_EXTS.has(ext)) return { component: FileImageIcon, color: '#3b82f6' }
|
||||||
|
if (VIDEO_EXTS.has(ext)) return { component: FileVideoIcon, color: '#a855f7' }
|
||||||
|
if (AUDIO_EXTS.has(ext)) return { component: FileMusicIcon, color: '#ec4899' }
|
||||||
|
if (ARCHIVE_EXTS.has(ext)) return { component: FileArchiveIcon, color: '#f59e0b' }
|
||||||
|
// Office
|
||||||
|
if (WORD_EXTS.has(ext)) return { component: Document, color: '#2563eb' }
|
||||||
|
if (EXCEL_EXTS.has(ext)) return { component: FileSpreadsheetIcon, color: '#16a34a' }
|
||||||
|
if (PPT_EXTS.has(ext)) return { component: FilePresentationIcon, color: '#ea580c' }
|
||||||
|
if (PDF_EXTS.has(ext)) return { component: FileTextIcon, color: '#dc2626' }
|
||||||
|
// 代码与数据
|
||||||
|
if (WEB_CODE_EXTS.has(ext)) return { component: CodeXmlIcon, color: '#10b981' }
|
||||||
|
if (BACKEND_CODE_EXTS.has(ext)) return { component: FileCodeIcon, color: '#3b82f6' }
|
||||||
|
if (SCRIPT_EXTS.has(ext)) return { component: TerminalIcon, color: '#eab308' }
|
||||||
|
if (DATA_EXTS.has(ext)) return { component: FileJsonIcon, color: '#14b8a6' }
|
||||||
|
if (SQL_EXTS.has(ext)) return { component: DatabaseIcon, color: '#8b5cf6' }
|
||||||
|
return defaultFileIcon
|
||||||
|
}
|
||||||
|
|
||||||
// 右键菜单
|
// 右键菜单
|
||||||
const contextMenuVisible = ref(false)
|
const contextMenuVisible = ref(false)
|
||||||
const contextMenuX = ref(0)
|
const contextMenuX = ref(0)
|
||||||
@@ -203,6 +431,9 @@ const filePickerRef = ref(null)
|
|||||||
const folderPickerRef = ref(null)
|
const folderPickerRef = ref(null)
|
||||||
const isFolderMode = computed(() => uploadDialogTitle.value === '上传文件夹')
|
const isFolderMode = computed(() => uploadDialogTitle.value === '上传文件夹')
|
||||||
|
|
||||||
|
const createInputRef = ref(null)
|
||||||
|
const renameInputRef = ref(null)
|
||||||
|
|
||||||
// 上传模式下拉菜单(context-menu 风格)
|
// 上传模式下拉菜单(context-menu 风格)
|
||||||
const uploadMenuVisible = ref(false)
|
const uploadMenuVisible = ref(false)
|
||||||
const uploadMenuX = ref(0)
|
const uploadMenuX = ref(0)
|
||||||
@@ -222,6 +453,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
async function fetchFiles() {
|
async function fetchFiles() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
const start = Date.now()
|
||||||
try {
|
try {
|
||||||
let res = await listFiles(props.workspacePath)
|
let res = await listFiles(props.workspacePath)
|
||||||
let arr = res
|
let arr = res
|
||||||
@@ -242,6 +474,10 @@ async function fetchFiles() {
|
|||||||
console.error('获取文件列表失败:', error)
|
console.error('获取文件列表失败:', error)
|
||||||
fileTree.value = []
|
fileTree.value = []
|
||||||
} finally {
|
} finally {
|
||||||
|
// 强制最小 loading 时长,避免接口返回过快导致 spinner 一闪而过
|
||||||
|
const elapsed = Date.now() - start
|
||||||
|
const remain = 300 - elapsed
|
||||||
|
if (remain > 0) await new Promise(resolve => setTimeout(resolve, remain))
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -475,6 +711,15 @@ function handleContextNewFolder() {
|
|||||||
createDialogVisible.value = true
|
createDialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleCreateOpened() {
|
||||||
|
createInputRef.value?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRenameOpened() {
|
||||||
|
renameInputRef.value?.focus()
|
||||||
|
renameInputRef.value?.select()
|
||||||
|
}
|
||||||
|
|
||||||
watch(() => props.visible, (val) => {
|
watch(() => props.visible, (val) => {
|
||||||
if (val && fileTree.value.length === 0) {
|
if (val && fileTree.value.length === 0) {
|
||||||
fetchFiles()
|
fetchFiles()
|
||||||
@@ -528,6 +773,11 @@ function handleNodeClick(data) {
|
|||||||
color: var(--fe-text-secondary);
|
color: var(--fe-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 自定义 loading 旋转动画(替换 Element Plus :loading)
|
||||||
|
.is-loading {
|
||||||
|
animation: rotate 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
.el-icon {
|
.el-icon {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
@@ -598,38 +848,6 @@ function handleNodeClick(data) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-menu {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 9999;
|
|
||||||
background: var(--fe-bg-elevated);
|
|
||||||
border: 1px solid var(--fe-border);
|
|
||||||
border-radius: 4px;
|
|
||||||
box-shadow: var(--fe-shadow-md);
|
|
||||||
padding: 4px 0;
|
|
||||||
min-width: 120px;
|
|
||||||
|
|
||||||
.context-menu-item {
|
|
||||||
padding: 8px 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
transition: background 0.15s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--fe-bg-overlay);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.danger {
|
|
||||||
color: var(--fe-danger);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.context-menu-divider {
|
|
||||||
height: 1px;
|
|
||||||
background: var(--fe-border);
|
|
||||||
margin: 4px 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-dropdown-menu__item) {
|
:deep(.el-dropdown-menu__item) {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div v-if="isEmpty && !switchingConversation" class="empty-hint">今天想做点什么?</div>
|
<div v-if="isEmpty && !switchingConversation" class="empty-hint">今天想做点什么?</div>
|
||||||
|
|
||||||
<div class="input-wrapper" v-if="!switchingConversation">
|
<div class="input-wrapper" v-if="!switchingConversation">
|
||||||
<div class="input-container">
|
<div class="input-container">
|
||||||
<!-- 悬浮文件气泡:显示在输入框正上方 -->
|
<!-- 悬浮文件气泡:显示在输入框正上方 -->
|
||||||
<div v-if="showFileBubble" class="file-bubble" @mousedown.stop>
|
<div v-if="showFileBubble" class="file-bubble" @mousedown.stop>
|
||||||
<div v-if="fileLoading" class="bubble-state">加载中...</div>
|
<div v-if="fileLoading" class="bubble-state">加载中...</div>
|
||||||
@@ -82,6 +82,7 @@
|
|||||||
placement="top"
|
placement="top"
|
||||||
:width="280"
|
:width="280"
|
||||||
trigger="click"
|
trigger="click"
|
||||||
|
:teleported="false"
|
||||||
>
|
>
|
||||||
<template #reference>
|
<template #reference>
|
||||||
<div class="context-indicator">
|
<div class="context-indicator">
|
||||||
@@ -352,17 +353,9 @@ function handleFilePick(file) {
|
|||||||
return handleFilePick(file)
|
return handleFilePick(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果光标前是 @,先移除(让选中的 @ 不进入最终文本)
|
// 查找并删除光标前最近的 @ 引用(含 @ 符号和 @ 之后的查询字符),
|
||||||
const node = range.startContainer
|
// 避免出现 "@12" + token 这种残留文本。
|
||||||
const offset = range.startOffset
|
removeAtReferenceBeforeCaret(range)
|
||||||
if (node.nodeType === Node.TEXT_NODE && offset > 0) {
|
|
||||||
const text = node.textContent
|
|
||||||
if (text[offset - 1] === '@') {
|
|
||||||
node.textContent = text.slice(0, offset - 1) + text.slice(offset)
|
|
||||||
range.setStart(node, offset - 1)
|
|
||||||
range.setEnd(node, offset - 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 插入原子 token
|
// 插入原子 token
|
||||||
const token = document.createElement('span')
|
const token = document.createElement('span')
|
||||||
@@ -396,6 +389,60 @@ function handleFilePick(file) {
|
|||||||
fileQuery.value = ''
|
fileQuery.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 查找并删除光标前最近的 @ 引用(@ + @ 之后的查询字符)。
|
||||||
|
// 跨多个文本节点也能正确处理:中间可能有 file-token 等元素节点分割。
|
||||||
|
// 删除完成后会把 range 折叠到删除起点,供后续插入 token。
|
||||||
|
function removeAtReferenceBeforeCaret(caretRange) {
|
||||||
|
const container = chatInputRef.value
|
||||||
|
if (!container) return
|
||||||
|
|
||||||
|
const preRange = document.createRange()
|
||||||
|
preRange.selectNodeContents(container)
|
||||||
|
preRange.setEnd(caretRange.endContainer, caretRange.endOffset)
|
||||||
|
const beforeText = preRange.toString()
|
||||||
|
|
||||||
|
const atIdx = beforeText.lastIndexOf('@')
|
||||||
|
if (atIdx < 0) return
|
||||||
|
// @ 引用已结束(有空白/换行),跳过清理
|
||||||
|
if (/[\s\n]/.test(beforeText.slice(atIdx + 1))) return
|
||||||
|
|
||||||
|
// 在 preRange 内逐文本节点迭代,定位 @ 所在的节点和偏移
|
||||||
|
// 用 intersectsNode 判断节点是否在 preRange 内(部分相交也算),
|
||||||
|
// 并显式计算节点在 preRange 内的字符范围,避免跨节点边界时的偏移误差。
|
||||||
|
const root = preRange.commonAncestorContainer
|
||||||
|
let remaining = atIdx
|
||||||
|
|
||||||
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null)
|
||||||
|
let node
|
||||||
|
while ((node = walker.nextNode())) {
|
||||||
|
if (!preRange.intersectsNode(node)) continue
|
||||||
|
|
||||||
|
// 节点在 preRange 内的实际字符范围
|
||||||
|
let nodeStart = 0
|
||||||
|
let nodeEnd = node.textContent.length
|
||||||
|
if (preRange.startContainer === node && preRange.startOffset > nodeStart) {
|
||||||
|
nodeStart = preRange.startOffset
|
||||||
|
}
|
||||||
|
if (preRange.endContainer === node && preRange.endOffset < nodeEnd) {
|
||||||
|
nodeEnd = preRange.endOffset
|
||||||
|
}
|
||||||
|
const nodeLenInRange = nodeEnd - nodeStart
|
||||||
|
|
||||||
|
if (remaining < nodeLenInRange) {
|
||||||
|
const offset = nodeStart + remaining
|
||||||
|
const deleteRange = document.createRange()
|
||||||
|
deleteRange.setStart(node, offset)
|
||||||
|
deleteRange.setEnd(caretRange.endContainer, caretRange.endOffset)
|
||||||
|
deleteRange.deleteContents()
|
||||||
|
// 折叠 caretRange 到删除起点,供后续 insertNode 使用
|
||||||
|
caretRange.setStart(node, offset)
|
||||||
|
caretRange.collapse(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
remaining -= nodeLenInRange
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function placeCaretAtEnd(el) {
|
function placeCaretAtEnd(el) {
|
||||||
el.focus()
|
el.focus()
|
||||||
const range = document.createRange()
|
const range = document.createRange()
|
||||||
|
|||||||
@@ -83,7 +83,9 @@ async function handleLogout() {
|
|||||||
await ElMessageBox.confirm('确定要退出登录吗?', '提示', {
|
await ElMessageBox.confirm('确定要退出登录吗?', '提示', {
|
||||||
confirmButtonText: '退出',
|
confirmButtonText: '退出',
|
||||||
cancelButtonText: '取消',
|
cancelButtonText: '取消',
|
||||||
type: 'warning'
|
type: 'warning',
|
||||||
|
// 弹窗 Teleport 到 body,需打 customClass 让 theme.scss 覆盖变量
|
||||||
|
customClass: 'frontend-el-message-box'
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -10,16 +10,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-tooltip content="设置" placement="top" :show-after="300">
|
<el-tooltip content="设置" placement="top" :show-after="300">
|
||||||
<el-button class="settings-btn" link @click.stop="openSettingsDialog">
|
<button class="settings-btn" type="button" @click.stop="openSettingsDialog">
|
||||||
<el-icon><Setting /></el-icon>
|
<el-icon><Setting /></el-icon>
|
||||||
</el-button>
|
</button>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-button class="marketplace-btn" @click="goToMarketplace">
|
<!-- <el-button v-if="!hideMarketplaceBtn" class="marketplace-btn" @click="goToMarketplace">
|
||||||
<el-icon><Promotion /></el-icon>
|
<el-icon><Promotion /></el-icon>
|
||||||
<span>前往智能体广场</span>
|
<span>前往智能体广场</span>
|
||||||
</el-button>
|
</el-button> -->
|
||||||
|
|
||||||
<UserInfoDialog v-model="infoDialogVisible" :user="user" @saved="handleSaved" />
|
<UserInfoDialog v-model="infoDialogVisible" :user="user" @saved="handleSaved" />
|
||||||
<SettingsDialog v-model="settingsDialogVisible" @open-user-info="openInfoDialog" />
|
<SettingsDialog v-model="settingsDialogVisible" @open-user-info="openInfoDialog" />
|
||||||
@@ -39,6 +39,10 @@ const props = defineProps({
|
|||||||
collapsed: {
|
collapsed: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
|
},
|
||||||
|
hideMarketplaceBtn: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -93,14 +97,8 @@ defineExpose({
|
|||||||
.user-info-row {
|
.user-info-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 8px;
|
||||||
padding: 4px 6px;
|
padding: 4px 0px;
|
||||||
border-radius: var(--fe-radius-md);
|
|
||||||
transition: background 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--fe-bg-hover);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-main {
|
.user-main {
|
||||||
@@ -109,9 +107,15 @@ defineExpose({
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 4px 6px;
|
// 紧贴头像,悬停背景不留内边距;左侧弧度跟头像一致(size 36 → 18px),
|
||||||
border-radius: var(--fe-radius-md);
|
// 视觉上头像"嵌"在背景左侧
|
||||||
|
padding: 0;
|
||||||
|
border-top-left-radius: 18px;
|
||||||
|
border-bottom-left-radius: 18px;
|
||||||
|
border-top-right-radius: var(--fe-radius-md);
|
||||||
|
border-bottom-right-radius: var(--fe-radius-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--fe-bg-hover);
|
background: var(--fe-bg-hover);
|
||||||
@@ -133,19 +137,33 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-btn {
|
.settings-btn {
|
||||||
padding: 6px 8px;
|
flex-shrink: 0;
|
||||||
margin-left: 0 !important;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
color: var(--fe-text-secondary);
|
color: var(--fe-text-secondary);
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
border-radius: var(--fe-radius-md);
|
border-radius: var(--fe-radius-md);
|
||||||
transition: all 0.2s;
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
|
||||||
|
// 只在 hover 时显示背景:focus / active 不加,避免点击后焦点残留导致背景持续
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--fe-bg-hover);
|
background: var(--fe-bg-hover);
|
||||||
color: var(--fe-text-primary);
|
color: var(--fe-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&:active {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
.el-icon {
|
.el-icon {
|
||||||
font-size: 18px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
v-model="cropperVisible"
|
v-model="cropperVisible"
|
||||||
width="800px"
|
width="800px"
|
||||||
:close-on-click-modal="false"
|
:close-on-click-modal="false"
|
||||||
append-to-body
|
modal-class="frontend-dialog"
|
||||||
@opened="modalOpened"
|
@opened="modalOpened"
|
||||||
@close="closeCropperDialog"
|
@close="closeCropperDialog"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="frontend-container">
|
<div class="frontend-container">
|
||||||
<!-- 左侧会话列表 -->
|
<!-- 左侧会话列表 -->
|
||||||
<ConversationSidebar ref="conversationSidebarRef" @select="handleConversationSelect" @new-session="handleNewSession" @ready="switchingConversation = false" />
|
<ConversationSidebar ref="conversationSidebarRef" :hide-marketplace-btn="true" @select="handleConversationSelect" @new-session="handleNewSession" @ready="switchingConversation = false" />
|
||||||
|
|
||||||
<!-- 右侧主内容区 -->
|
<!-- 右侧主内容区 -->
|
||||||
<div class="main-content">
|
<div class="main-content">
|
||||||
@@ -22,15 +22,17 @@
|
|||||||
<!-- 聊天区域 + 右侧文件面板 -->
|
<!-- 聊天区域 + 右侧文件面板 -->
|
||||||
<div class="content-wrapper">
|
<div class="content-wrapper">
|
||||||
<!-- 聊天区域 -->
|
<!-- 聊天区域 -->
|
||||||
<div class="chat-area">
|
<div class="chat-area" :class="{ 'is-switching': switchingConversation }">
|
||||||
<!-- 聊天消息区(有消息时才显示) -->
|
<!-- 聊天消息区(有消息时才显示) -->
|
||||||
<ChatWindow
|
<transition name="chat-fade">
|
||||||
v-if="messages.length > 0"
|
<ChatWindow
|
||||||
ref="chatWindowRef"
|
v-if="messages.length > 0"
|
||||||
:messages="messages"
|
ref="chatWindowRef"
|
||||||
:loading="loading"
|
:messages="messages"
|
||||||
@file-click="handleFileClick"
|
:loading="loading"
|
||||||
/>
|
@file-click="handleFileClick"
|
||||||
|
/>
|
||||||
|
</transition>
|
||||||
|
|
||||||
<!-- 输入区 -->
|
<!-- 输入区 -->
|
||||||
<InputArea
|
<InputArea
|
||||||
@@ -935,9 +937,24 @@ function handleStop() {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: all 0.3s;
|
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|
||||||
|
// 切换会话时整个聊天区域淡出 + 轻微下移,加载完成后淡入(向上复位)
|
||||||
|
&.is-switching {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatWindow 出现时的渐入动画(与 chat-area 配合)
|
||||||
|
.chat-fade-enter-active {
|
||||||
|
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||||
|
}
|
||||||
|
.chat-fade-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-panel-wrapper {
|
.file-panel-wrapper {
|
||||||
|
|||||||
+204
-172
@@ -1,93 +1,94 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="frontend-container marketplace-container">
|
<div class="frontend-container marketplace-container">
|
||||||
<!-- 顶部区域 -->
|
<!-- 左侧栏:复用 frontend 的 ConversationSidebar(含底部 UserArea),上半部分注入分类 Tab -->
|
||||||
<div class="marketplace-header">
|
<ConversationSidebar :hide-marketplace-btn="true">
|
||||||
<h1 class="marketplace-title">AI 智能体市场</h1>
|
<template #content>
|
||||||
<p class="marketplace-subtitle">探索并使用精选的 AI 智能体,提升工作效率</p>
|
<div class="marketplace-sidebar-content">
|
||||||
</div>
|
<div class="marketplace-tabs-header">分类</div>
|
||||||
|
<div class="marketplace-tabs">
|
||||||
<!-- 分类标签导航 -->
|
<span
|
||||||
<div class="category-nav">
|
v-for="tab in categoryTabs"
|
||||||
<div class="category-tabs">
|
:key="tab.key"
|
||||||
<span
|
class="marketplace-tab"
|
||||||
v-for="(tab, index) in categoryTabs"
|
:class="{ active: currentCategory === tab.key }"
|
||||||
:key="index"
|
@click="handleCategoryChange(tab.key)"
|
||||||
class="category-tab"
|
>
|
||||||
:class="{ active: currentCategory === tab.key }"
|
<span class="marketplace-tab-dot"></span>
|
||||||
@click="handleCategoryChange(tab.key)"
|
<span class="marketplace-tab-label">{{ tab.name }}</span>
|
||||||
>
|
</span>
|
||||||
{{ tab.name }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="sort-dropdown">
|
|
||||||
<el-dropdown trigger="click" @command="handleSortChange">
|
|
||||||
<span class="sort-button">
|
|
||||||
<el-icon><Filter /></el-icon>
|
|
||||||
综合排序
|
|
||||||
<el-icon><ArrowDown /></el-icon>
|
|
||||||
</span>
|
|
||||||
<template #dropdown>
|
|
||||||
<el-dropdown-menu>
|
|
||||||
<el-dropdown-item
|
|
||||||
v-for="item in sortOptions"
|
|
||||||
:key="item.key"
|
|
||||||
:command="item.key"
|
|
||||||
:class="{ selected: currentSort === item.key }"
|
|
||||||
>
|
|
||||||
{{ item.name }}
|
|
||||||
</el-dropdown-item>
|
|
||||||
</el-dropdown-menu>
|
|
||||||
</template>
|
|
||||||
</el-dropdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 加载状态 -->
|
|
||||||
<div v-if="loading" class="loading-wrapper">
|
|
||||||
<el-skeleton :rows="8" animated />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 智能体网格 -->
|
|
||||||
<div v-else class="agents-grid">
|
|
||||||
<div
|
|
||||||
v-for="agent in filteredAgents"
|
|
||||||
:key="agent.id"
|
|
||||||
class="agent-card"
|
|
||||||
@click="handleAgentClick(agent)"
|
|
||||||
>
|
|
||||||
<!-- 悬浮添加按钮 -->
|
|
||||||
<!-- <div v-if="agent.canAdd" class="card-add-btn">
|
|
||||||
<el-button type="primary" size="small" round>添加</el-button>
|
|
||||||
</div> -->
|
|
||||||
|
|
||||||
<!-- 卡片内容 -->
|
|
||||||
<div class="card-header">
|
|
||||||
<div class="agent-avatar">
|
|
||||||
<img v-if="agent.avatar" :src="agent.avatar" :alt="agent.name" />
|
|
||||||
<span v-else class="avatar-placeholder">{{ agent.name.charAt(0).toUpperCase() }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="agent-info">
|
|
||||||
<div class="agent-name">{{ agent.name }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="agent-intro">{{ agent.remark || '暂无备注' }}</div>
|
</template>
|
||||||
<!-- <el-tooltip :content="agent.remark || ''" placement="top" :disabled="!agent.remark">
|
</ConversationSidebar>
|
||||||
|
|
||||||
</el-tooltip> -->
|
<!-- 右侧主内容 -->
|
||||||
|
<div class="marketplace-main">
|
||||||
|
<!-- 顶部区域 -->
|
||||||
|
<div class="marketplace-header">
|
||||||
|
<h1 class="marketplace-title">AI 智能体市场</h1>
|
||||||
|
<p class="marketplace-subtitle">探索并使用精选的 AI 智能体,提升工作效率</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 空状态 -->
|
<!-- 加载状态 -->
|
||||||
<el-empty v-if="!loading && filteredAgents.length === 0" description="暂无智能体" />
|
<div v-if="loading" class="loading-wrapper">
|
||||||
|
<el-skeleton :rows="8" animated />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 智能体网格 -->
|
||||||
|
<div v-else class="agents-grid">
|
||||||
|
<div
|
||||||
|
v-for="agent in filteredAgents"
|
||||||
|
:key="agent.id"
|
||||||
|
class="agent-card"
|
||||||
|
@click="handleAgentClick(agent)"
|
||||||
|
>
|
||||||
|
<!-- 悬浮分享按钮:复制当前智能体的聊天链接 -->
|
||||||
|
<el-tooltip content="分享给好友" placement="top" :show-after="300">
|
||||||
|
<button
|
||||||
|
class="card-share-btn"
|
||||||
|
type="button"
|
||||||
|
aria-label="分享智能体"
|
||||||
|
@click.stop="handleShareAgent(agent)"
|
||||||
|
>
|
||||||
|
<el-icon><Share /></el-icon>
|
||||||
|
</button>
|
||||||
|
</el-tooltip>
|
||||||
|
|
||||||
|
<!-- 悬浮添加按钮 -->
|
||||||
|
<!-- <div v-if="agent.canAdd" class="card-add-btn">
|
||||||
|
<el-button type="primary" size="small" round>添加</el-button>
|
||||||
|
</div> -->
|
||||||
|
|
||||||
|
<!-- 卡片内容 -->
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="agent-avatar">
|
||||||
|
<img v-if="agent.avatar" :src="agent.avatar" :alt="agent.name" />
|
||||||
|
<span v-else class="avatar-placeholder">{{ agent.name.charAt(0).toUpperCase() }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="agent-info">
|
||||||
|
<div class="agent-name">{{ agent.name }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="agent-intro">{{ agent.remark || '暂无备注' }}</div>
|
||||||
|
<!-- <el-tooltip :content="agent.remark || ''" placement="top" :disabled="!agent.remark">
|
||||||
|
|
||||||
|
</el-tooltip> -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<el-empty v-if="!loading && filteredAgents.length === 0" description="暂无智能体" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { Filter, ArrowDown } from '@element-plus/icons-vue'
|
import { Share } from '@element-plus/icons-vue'
|
||||||
import { listAgentProjects } from '@/api/frontend'
|
import { listAgentProjects } from '@/api/frontend'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
import ConversationSidebar from './components/ConversationSidebar.vue'
|
||||||
import useSettingsStore from '@/store/modules/settings'
|
import useSettingsStore from '@/store/modules/settings'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -106,17 +107,8 @@ const categoryTabs = [
|
|||||||
{ key: 'life', name: '生活娱乐' }
|
{ key: 'life', name: '生活娱乐' }
|
||||||
]
|
]
|
||||||
|
|
||||||
// 排序选项
|
|
||||||
const sortOptions = [
|
|
||||||
{ key: 'popular', name: '人气最高' },
|
|
||||||
{ key: 'latest', name: '最新发布' },
|
|
||||||
{ key: 'name', name: '名称排序' }
|
|
||||||
]
|
|
||||||
|
|
||||||
// 当前选中分类
|
// 当前选中分类
|
||||||
const currentCategory = ref('all')
|
const currentCategory = ref('all')
|
||||||
// 当前排序方式
|
|
||||||
const currentSort = ref('popular')
|
|
||||||
// 加载状态
|
// 加载状态
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
// 智能体列表
|
// 智能体列表
|
||||||
@@ -139,30 +131,13 @@ const mockAgents = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据当前分类和排序筛选智能体列表
|
* 根据当前分类筛选智能体列表
|
||||||
*/
|
*/
|
||||||
const filteredAgents = computed(() => {
|
const filteredAgents = computed(() => {
|
||||||
let result = [...agentsList.value]
|
let result = [...agentsList.value]
|
||||||
|
|
||||||
// 分类筛选
|
|
||||||
if (currentCategory.value !== 'all') {
|
if (currentCategory.value !== 'all') {
|
||||||
result = result.filter(agent => agent.category === currentCategory.value)
|
result = result.filter(agent => agent.category === currentCategory.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 排序
|
|
||||||
switch (currentSort.value) {
|
|
||||||
case 'popular':
|
|
||||||
result.sort((a, b) => b.popularity - a.popularity)
|
|
||||||
break
|
|
||||||
case 'latest':
|
|
||||||
// 按 ID 降序模拟最新
|
|
||||||
result.sort((a, b) => b.id - a.id)
|
|
||||||
break
|
|
||||||
case 'name':
|
|
||||||
result.sort((a, b) => a.name.localeCompare(b.name))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -173,13 +148,6 @@ function handleCategoryChange(category) {
|
|||||||
currentCategory.value = category
|
currentCategory.value = category
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理排序切换
|
|
||||||
*/
|
|
||||||
function handleSortChange(sortKey) {
|
|
||||||
currentSort.value = sortKey
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理智能体卡片点击
|
* 处理智能体卡片点击
|
||||||
*/
|
*/
|
||||||
@@ -190,6 +158,21 @@ function handleAgentClick(agent) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复制分享链接到剪贴板
|
||||||
|
* 用户点击链接后可直接进入对应智能体的聊天界面
|
||||||
|
*/
|
||||||
|
async function handleShareAgent(agent) {
|
||||||
|
const url = `${window.location.origin}/frontend?agentId=${agent.id}&name=${encodeURIComponent(agent.name)}`
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url)
|
||||||
|
ElMessage.success('分享链接已复制')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('复制分享链接失败:', error)
|
||||||
|
ElMessage.error('复制失败,请手动复制')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取智能体列表数据
|
* 获取智能体列表数据
|
||||||
*/
|
*/
|
||||||
@@ -243,11 +226,83 @@ onMounted(() => {
|
|||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.marketplace-container {
|
.marketplace-container {
|
||||||
min-height: 100%;
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
background: var(--fe-bg-base);
|
background: var(--fe-bg-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketplace-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
padding: 24px 32px;
|
padding: 24px 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 左侧栏内的分类 tabs */
|
||||||
|
.marketplace-sidebar-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketplace-tabs-header {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 11px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--fe-text-muted);
|
||||||
|
border-bottom: 1px solid var(--fe-border);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketplace-tabs {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketplace-tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin: 2px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--fe-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--fe-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--fe-accent-soft);
|
||||||
|
color: var(--fe-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketplace-tab-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketplace-tab.active .marketplace-tab-dot {
|
||||||
|
background: var(--fe-accent);
|
||||||
|
}
|
||||||
|
|
||||||
/* 顶部区域 */
|
/* 顶部区域 */
|
||||||
.marketplace-header {
|
.marketplace-header {
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
@@ -266,62 +321,6 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 分类导航 */
|
|
||||||
.category-nav {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
background: var(--fe-bg-elevated);
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: var(--fe-shadow-sm);
|
|
||||||
|
|
||||||
.category-tabs {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-tab {
|
|
||||||
padding: 8px 16px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--fe-text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 20px;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--fe-accent);
|
|
||||||
background: var(--fe-accent-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
color: var(--fe-text-inverse);
|
|
||||||
background: var(--fe-accent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sort-dropdown {
|
|
||||||
.sort-button {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--fe-text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: all 0.3s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--fe-accent);
|
|
||||||
background: var(--fe-bg-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 加载状态 */
|
/* 加载状态 */
|
||||||
.loading-wrapper {
|
.loading-wrapper {
|
||||||
background: var(--fe-bg-elevated);
|
background: var(--fe-bg-elevated);
|
||||||
@@ -374,6 +373,45 @@ onMounted(() => {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 悬浮分享按钮:默认隐藏,hover 卡片时显示 */
|
||||||
|
.card-share-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--fe-bg-overlay);
|
||||||
|
color: var(--fe-text-secondary);
|
||||||
|
border: 1px solid var(--fe-border);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease, background 0.2s ease, color 0.2s ease,
|
||||||
|
border-color 0.2s ease;
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--fe-accent-soft);
|
||||||
|
color: var(--fe-accent);
|
||||||
|
border-color: var(--fe-accent-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .card-share-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* 卡片头部 */
|
/* 卡片头部 */
|
||||||
.card-header {
|
.card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -452,11 +490,5 @@ onMounted(() => {
|
|||||||
.agents-grid {
|
.agents-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-nav {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// Frontend dark theme tokens — scope to .frontend-container so admin pages are unaffected.
|
// Frontend 主题变量 — 定义在 :root 而非 .frontend-container,
|
||||||
.frontend-container {
|
// 这样 Teleport 到 body 的元素(如右键菜单)也能继承到变量。
|
||||||
|
// --fe- 前缀保证不会与 admin 主题变量冲突(admin 模块未使用 --fe-*)。
|
||||||
|
:root {
|
||||||
// —— 基础色 ——
|
// —— 基础色 ——
|
||||||
--fe-bg-base: #1e1e1e;
|
--fe-bg-base: #1e1e1e;
|
||||||
--fe-bg-elevated: #252526;
|
--fe-bg-elevated: #252526;
|
||||||
@@ -44,40 +46,8 @@
|
|||||||
--fe-shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.5);
|
--fe-shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
// —— Element Plus 变量覆盖(仅在 frontend-container 下生效)——
|
// —— 浅色主题覆盖(通过 body[data-fe-theme="light"] 切换,由 settings.js 同步)——
|
||||||
.frontend-container {
|
body[data-fe-theme="light"] {
|
||||||
--el-bg-color: var(--fe-bg-elevated);
|
|
||||||
--el-bg-color-page: var(--fe-bg-base);
|
|
||||||
--el-bg-color-overlay: var(--fe-bg-overlay);
|
|
||||||
--el-text-color-primary: var(--fe-text-primary);
|
|
||||||
--el-text-color-regular: var(--fe-text-secondary);
|
|
||||||
--el-text-color-secondary: var(--fe-text-muted);
|
|
||||||
--el-text-color-placeholder: var(--fe-text-muted);
|
|
||||||
--el-border-color: var(--fe-border);
|
|
||||||
--el-border-color-light: var(--fe-border);
|
|
||||||
--el-border-color-lighter: var(--fe-border);
|
|
||||||
--el-border-color-extra-light: var(--fe-border);
|
|
||||||
--el-fill-color-blank: var(--fe-bg-elevated);
|
|
||||||
--el-fill-color-light: var(--fe-bg-hover);
|
|
||||||
--el-fill-color-lighter: var(--fe-bg-hover);
|
|
||||||
--el-color-primary: var(--fe-accent);
|
|
||||||
--el-color-primary-light-3: var(--fe-accent-hover);
|
|
||||||
--el-color-primary-light-5: var(--fe-accent-soft);
|
|
||||||
--el-color-primary-light-7: var(--fe-accent-soft);
|
|
||||||
--el-color-primary-light-9: var(--fe-accent-soft);
|
|
||||||
--el-color-danger: var(--fe-danger);
|
|
||||||
--el-color-warning: var(--fe-warning);
|
|
||||||
--el-color-info: var(--fe-info);
|
|
||||||
--el-color-success: var(--fe-accent);
|
|
||||||
|
|
||||||
// —— 响应式断点 ——
|
|
||||||
// .file-panel-wrapper / .file-preview-wrapper 都在 index.vue 的 <style scoped> 里,
|
|
||||||
// 直接写在这里会被 [data-v-xxx] 选择器压过 specificity,无法生效。
|
|
||||||
// 实际断点已迁到 index.vue:≤768px 收起文件列表,≤1200px 收起文件预览。
|
|
||||||
}
|
|
||||||
|
|
||||||
// —— 浅色主题覆盖(通过 data-theme="light" 切换)——
|
|
||||||
.frontend-container[data-theme="light"] {
|
|
||||||
--fe-bg-base: #ffffff;
|
--fe-bg-base: #ffffff;
|
||||||
--fe-bg-elevated: #fafafa;
|
--fe-bg-elevated: #fafafa;
|
||||||
--fe-bg-overlay: #f5f5f5;
|
--fe-bg-overlay: #f5f5f5;
|
||||||
@@ -109,8 +79,60 @@
|
|||||||
--fe-shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
|
--fe-shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||||
--fe-shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1);
|
--fe-shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
--fe-shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.12);
|
--fe-shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
// Element Plus 变量重置为默认浅色
|
// —— Element Plus 变量映射(仅在 frontend-container 下生效)——
|
||||||
|
// .frontend-container 内的 el-* 组件继承这些变量;
|
||||||
|
// 弹窗已不再 append-to-body,所以 dialog 内 el-* 组件能拿到。
|
||||||
|
.frontend-container {
|
||||||
|
--el-bg-color: var(--fe-bg-elevated);
|
||||||
|
--el-bg-color-page: var(--fe-bg-base);
|
||||||
|
--el-bg-color-overlay: var(--fe-bg-overlay);
|
||||||
|
--el-text-color-primary: var(--fe-text-primary);
|
||||||
|
--el-text-color-regular: var(--fe-text-secondary);
|
||||||
|
--el-text-color-secondary: var(--fe-text-muted);
|
||||||
|
--el-text-color-placeholder: var(--fe-text-muted);
|
||||||
|
--el-border-color: var(--fe-border);
|
||||||
|
--el-border-color-light: var(--fe-border);
|
||||||
|
--el-border-color-lighter: var(--fe-border);
|
||||||
|
--el-border-color-extra-light: var(--fe-border);
|
||||||
|
--el-fill-color-blank: var(--fe-bg-elevated);
|
||||||
|
--el-fill-color-light: var(--fe-bg-hover);
|
||||||
|
--el-fill-color-lighter: var(--fe-bg-hover);
|
||||||
|
--el-color-primary: var(--fe-accent);
|
||||||
|
--el-color-primary-light-3: var(--fe-accent-hover);
|
||||||
|
--el-color-primary-light-5: var(--fe-accent-soft);
|
||||||
|
--el-color-primary-light-7: var(--fe-accent-soft);
|
||||||
|
--el-color-primary-light-9: var(--fe-accent-soft);
|
||||||
|
--el-color-danger: var(--fe-danger);
|
||||||
|
--el-color-warning: var(--fe-warning);
|
||||||
|
--el-color-info: var(--fe-info);
|
||||||
|
--el-color-success: var(--fe-accent);
|
||||||
|
|
||||||
|
// —— 按钮 primary active 状态跟随主题色 ——
|
||||||
|
// Element Plus 在 :root 上由 theme.js 算出 dark-* 系列(基于 admin 主题色),
|
||||||
|
// frontend-container 没覆盖,导致 primary 按钮长按仍显示 Element Plus 默认蓝色。
|
||||||
|
.el-button--primary {
|
||||||
|
--el-button-active-bg-color: var(--fe-accent-hover);
|
||||||
|
--el-button-active-border-color: var(--fe-accent-hover);
|
||||||
|
--el-button-active-color: var(--fe-accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 关闭 icon / 字数统计 等组件默认用 --el-color-info(被映射为深色蓝色),主题下应跟随主题色 ——
|
||||||
|
.el-dialog__close,
|
||||||
|
.el-input .el-input__count,
|
||||||
|
.el-textarea .el-input__count {
|
||||||
|
color: var(--fe-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-dialog__headerbtn:hover .el-dialog__close {
|
||||||
|
color: var(--fe-accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 浅色主题下 Element Plus 变量重置(仅 frontend 容器内)——
|
||||||
|
// --fe-* 已在 body[data-fe-theme="light"] 处理;这里只重置 --el-* 系列。
|
||||||
|
.frontend-container[data-theme="light"] {
|
||||||
--el-bg-color: #ffffff;
|
--el-bg-color: #ffffff;
|
||||||
--el-bg-color-page: #f2f3f5;
|
--el-bg-color-page: #f2f3f5;
|
||||||
--el-bg-color-overlay: #ffffff;
|
--el-bg-color-overlay: #ffffff;
|
||||||
@@ -135,3 +157,95 @@
|
|||||||
--el-color-info: #909399;
|
--el-color-info: #909399;
|
||||||
--el-color-success: #67c23a;
|
--el-color-success: #67c23a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// —— ElMessageBox 主题适配(Teleport 到 body,需打 customClass 标记)——
|
||||||
|
// 在 frontend 模块下,所有 ElMessageBox 调用应传 customClass: 'frontend-el-message-box',
|
||||||
|
// 让弹窗内的 Element Plus 组件继承 frontend 主题。
|
||||||
|
.frontend-el-message-box {
|
||||||
|
--el-bg-color: var(--fe-bg-elevated);
|
||||||
|
--el-bg-color-overlay: var(--fe-bg-overlay);
|
||||||
|
--el-bg-color-page: var(--fe-bg-base);
|
||||||
|
--el-text-color-primary: var(--fe-text-primary);
|
||||||
|
--el-text-color-regular: var(--fe-text-secondary);
|
||||||
|
--el-text-color-secondary: var(--fe-text-muted);
|
||||||
|
--el-text-color-placeholder: var(--fe-text-muted);
|
||||||
|
--el-border-color: var(--fe-border);
|
||||||
|
--el-border-color-light: var(--fe-border);
|
||||||
|
--el-border-color-lighter: var(--fe-border);
|
||||||
|
--el-border-color-extra-light: var(--fe-border);
|
||||||
|
--el-fill-color-blank: var(--fe-bg-elevated);
|
||||||
|
--el-fill-color-light: var(--fe-bg-hover);
|
||||||
|
--el-fill-color-lighter: var(--fe-bg-hover);
|
||||||
|
--el-color-primary: var(--fe-accent);
|
||||||
|
--el-color-primary-light-3: var(--fe-accent-hover);
|
||||||
|
--el-color-primary-light-5: var(--fe-accent-soft);
|
||||||
|
--el-color-primary-light-7: var(--fe-accent-soft);
|
||||||
|
--el-color-primary-light-9: var(--fe-accent-soft);
|
||||||
|
--el-color-warning: var(--fe-warning);
|
||||||
|
|
||||||
|
// 弹窗主体背景与边框
|
||||||
|
background: var(--fe-bg-elevated);
|
||||||
|
border: 1px solid var(--fe-border);
|
||||||
|
|
||||||
|
.el-message-box__header {
|
||||||
|
background: var(--fe-bg-elevated);
|
||||||
|
color: var(--fe-text-primary);
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--fe-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-message-box__title {
|
||||||
|
color: var(--fe-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-message-box__content {
|
||||||
|
background: var(--fe-bg-elevated);
|
||||||
|
color: var(--fe-text-primary);
|
||||||
|
padding: 20px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-message-box__message {
|
||||||
|
color: var(--fe-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-message-box__btns {
|
||||||
|
background: var(--fe-bg-elevated);
|
||||||
|
border-top: 1px solid var(--fe-border);
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 全局右键菜单(用于会话列表 / 文件面板等,通过 Teleport 渲染到 body)——
|
||||||
|
// --fe-* 变量在 :root 全局可继承,菜单元素样式不受所在 DOM 位置影响。
|
||||||
|
.context-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9999;
|
||||||
|
background: var(--fe-bg-elevated);
|
||||||
|
border: 1px solid var(--fe-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: var(--fe-shadow-md);
|
||||||
|
padding: 4px 0;
|
||||||
|
min-width: 120px;
|
||||||
|
color: var(--fe-text-primary);
|
||||||
|
|
||||||
|
.context-menu-item {
|
||||||
|
padding: 8px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--fe-bg-overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.danger {
|
||||||
|
color: var(--fe-danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--fe-border);
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user