This commit is contained in:
2026-08-08 11:51:22 +08:00
parent 152d5273a5
commit 79fb05d0f9
12 changed files with 833 additions and 376 deletions
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 390 KiB

After

Width:  |  Height:  |  Size: 54 KiB

+1 -1
View File
@@ -99,7 +99,7 @@ export const dynamicRoutes = [
component: Layout,
children: [
{
path: 'index',
path: '',
component: () => import('@/views/frontend/index'),
name: 'Frontend',
meta: { title: '数智 AI' }
+5 -2
View File
@@ -29,7 +29,7 @@ const useSettingsStore = defineStore(
footerVisible: storageSetting.footerVisible === undefined ? footerVisible : storageSetting.footerVisible,
footerContent: footerContent,
isDark: isDark.value,
frontendTheme: (typeof localStorage !== 'undefined' && localStorage.getItem('frontend-theme')) || 'dark'
frontendTheme: (typeof localStorage !== 'undefined' && localStorage.getItem('frontend-theme')) || 'light'
}),
actions: {
// 修改布局设置
@@ -61,12 +61,15 @@ const useSettingsStore = defineStore(
document.querySelectorAll('.frontend-container').forEach((el) => {
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() {
document.querySelectorAll('.frontend-container').forEach((el) => {
el.setAttribute('data-theme', this.frontendTheme)
})
document.body.setAttribute('data-fe-theme', this.frontendTheme)
}
}
})
@@ -7,55 +7,58 @@
<transition name="sidebar-fade">
<div v-if="!collapsed" class="sidebar-content">
<div class="sidebar-header">
<span>会话列表</span>
<div class="header-actions">
<el-tooltip content="新建会话" placement="top" :show-after="300">
<el-button class="icon-btn" link @click="handleNewSession">
<el-icon><Plus /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="刷新" placement="top" :show-after="300">
<el-button class="icon-btn" link @click="fetchSessions" :loading="loading">
<el-icon><Refresh /></el-icon>
</el-button>
</el-tooltip>
</div>
</div>
<el-scrollbar class="sidebar-scrollbar">
<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>
<slot name="content">
<div class="sidebar-header">
<span>会话列表</span>
<div class="header-actions">
<el-tooltip content="新建会话" placement="top" :show-after="300">
<el-button class="icon-btn" link @click="handleNewSession">
<el-icon><Plus /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="刷新" placement="top" :show-after="300">
<el-button class="icon-btn" link @click="fetchSessions">
<el-icon v-if="!loading"><Refresh /></el-icon>
<el-icon v-else class="is-loading"><Loading /></el-icon>
</el-button>
</el-tooltip>
</div>
</div>
<div v-if="session.hasNewMessage" class="new-message-dot"></div>
</div>
<el-scrollbar class="sidebar-scrollbar">
<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">
<div
v-if="contextMenuVisible"
class="context-menu"
:style="{ top: contextMenuTop + 'px', left: contextMenuLeft + 'px' }"
@click.stop
>
<div class="context-menu-item" @click="handleRename">重命名</div>
<div class="context-menu-item danger" @click="handleDeleteFromMenu">删除</div>
</div>
</teleport>
<div v-if="!loading && sessionList.length === 0" class="empty-sessions">暂无会话</div>
</el-scrollbar>
<!-- 右键菜单 -->
<teleport to="body">
<div
v-if="contextMenuVisible"
class="context-menu"
:style="{ top: contextMenuTop + 'px', left: contextMenuLeft + 'px' }"
@click.stop
>
<div class="context-menu-item" @click="handleRename">重命名</div>
<div class="context-menu-item danger" @click="handleDeleteFromMenu">删除</div>
</div>
</teleport>
<div v-if="!loading && sessionList.length === 0" class="empty-sessions">暂无会话</div>
</el-scrollbar>
</slot>
</div>
</transition>
@@ -66,7 +69,7 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
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 { formatTimeAgo } from '@/utils/time'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -113,13 +116,20 @@ function handleRename() {
ElMessageBox.prompt('请输入新名称', '重命名会话', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: session.title || session.name || ''
inputValue: session.title || session.name || '',
customClass: 'frontend-el-message-box'
}).then(async ({ value }) => {
await renameChatSession(session.sessionId, value)
ElMessage.success('重命名成功')
// 更新本地列表
session.title = value
}).catch(() => {})
// ElMessageBox 没有 @opened 事件,等待挂载完成后选中已有文字
setTimeout(() => {
const input = document.querySelector('.frontend-el-message-box .el-message-box__input input')
if (input) input.select()
}, 100)
}
function handleDeleteFromMenu() {
@@ -142,6 +152,7 @@ function selectSession(session) {
async function fetchSessions() {
loading.value = true
const start = Date.now()
try {
const res = await listChatSessions(getCurrentAgentId())
sessionList.value = res.data || []
@@ -153,6 +164,10 @@ async function fetchSessions() {
} catch (error) {
console.error('获取会话列表失败:', error)
} 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
emit('ready')
}
@@ -170,7 +185,8 @@ async function handleDeleteSession(session) {
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
type: 'warning',
customClass: 'frontend-el-message-box'
}
)
await deleteChatSession(session.workspacePath, session.sessionId)
@@ -230,12 +246,17 @@ defineExpose({
// 只刷新会话列表,不选中任何会话(避免触发 handleConversationSelect
refreshQuiet: async () => {
loading.value = true
const start = Date.now()
try {
const res = await listChatSessions(getCurrentAgentId())
sessionList.value = res.data || []
} catch (error) {
console.error('获取会话列表失败:', error)
} 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
}
}
@@ -322,10 +343,20 @@ defineExpose({
.el-icon {
font-size: 16px;
}
// 加载中图标旋转动画
.is-loading {
animation: sidebar-rotate 1s linear infinite;
}
}
}
}
@keyframes sidebar-rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.sidebar-scrollbar {
flex: 1;
min-height: 0;
@@ -371,11 +402,15 @@ defineExpose({
.conv-dot {
width: 6px;
height: 6px;
background: #4a9eff;
background: transparent;
border-radius: 50%;
flex-shrink: 0;
}
&.active .conv-dot {
background: var(--fe-accent);
}
.conv-info {
flex: 1;
display: flex;
@@ -428,32 +463,3 @@ defineExpose({
opacity: 0;
}
</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>
+258 -40
View File
@@ -15,7 +15,8 @@
</el-tooltip>
<el-tooltip content="刷新" placement="top" :show-after="300">
<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-tooltip>
<el-tooltip content="关闭" placement="top" :show-after="300">
@@ -27,7 +28,7 @@
</div>
<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>
</div>
<div v-else-if="fileTree.length === 0" class="empty-wrapper">
@@ -45,7 +46,7 @@
<template #default="{ node, data }">
<span class="tree-node" @contextmenu.stop.prevent="handleContextMenu($event, data)">
<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>
<el-icon
v-if="data.type === 'file'"
@@ -82,8 +83,8 @@
</div>
<!-- 新建文件/文件夹对话框 -->
<el-dialog v-model="createDialogVisible" :title="createDialogTitle" width="400px" :close-on-click-modal="false">
<el-input v-model="createName" placeholder="请输入名称" @keyup.enter="confirmCreate" />
<el-dialog v-model="createDialogVisible" :title="createDialogTitle" width="400px" :close-on-click-modal="false" @opened="handleCreateOpened">
<el-input ref="createInputRef" v-model="createName" placeholder="请输入名称" @keyup.enter="confirmCreate" />
<template #footer>
<el-button @click="createDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmCreate">确定</el-button>
@@ -91,8 +92,8 @@
</el-dialog>
<!-- 重命名对话框 -->
<el-dialog v-model="renameDialogVisible" title="重命名" width="400px" :close-on-click-modal="false">
<el-input v-model="renameName" placeholder="请输入新名称" @keyup.enter="confirmRename" />
<el-dialog v-model="renameDialogVisible" title="重命名" width="400px" :close-on-click-modal="false" @opened="handleRenameOpened">
<el-input ref="renameInputRef" v-model="renameName" placeholder="请输入新名称" @keyup.enter="confirmRename" />
<template #footer>
<el-button @click="renameDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmRename">确定</el-button>
@@ -150,11 +151,183 @@
</template>
<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 { Close, Folder, FolderOpened, Document, Download, Refresh, Upload, Loading } from '@element-plus/icons-vue'
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({
visible: {
type: Boolean,
@@ -177,6 +350,61 @@ const loading = ref(false)
const fileTree = 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 contextMenuX = ref(0)
@@ -203,6 +431,9 @@ const filePickerRef = ref(null)
const folderPickerRef = ref(null)
const isFolderMode = computed(() => uploadDialogTitle.value === '上传文件夹')
const createInputRef = ref(null)
const renameInputRef = ref(null)
// 上传模式下拉菜单(context-menu 风格)
const uploadMenuVisible = ref(false)
const uploadMenuX = ref(0)
@@ -222,6 +453,7 @@ onUnmounted(() => {
async function fetchFiles() {
loading.value = true
const start = Date.now()
try {
let res = await listFiles(props.workspacePath)
let arr = res
@@ -242,6 +474,10 @@ async function fetchFiles() {
console.error('获取文件列表失败:', error)
fileTree.value = []
} 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
}
}
@@ -475,6 +711,15 @@ function handleContextNewFolder() {
createDialogVisible.value = true
}
function handleCreateOpened() {
createInputRef.value?.focus()
}
function handleRenameOpened() {
renameInputRef.value?.focus()
renameInputRef.value?.select()
}
watch(() => props.visible, (val) => {
if (val && fileTree.value.length === 0) {
fetchFiles()
@@ -528,6 +773,11 @@ function handleNodeClick(data) {
color: var(--fe-text-secondary);
}
// 自定义 loading 旋转动画(替换 Element Plus :loading
.is-loading {
animation: rotate 1s linear infinite;
}
.el-icon {
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) {
display: flex;
align-items: center;
+59 -12
View File
@@ -3,7 +3,7 @@
<div v-if="isEmpty && !switchingConversation" class="empty-hint">今天想做点什么</div>
<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="fileLoading" class="bubble-state">加载中...</div>
@@ -82,6 +82,7 @@
placement="top"
:width="280"
trigger="click"
:teleported="false"
>
<template #reference>
<div class="context-indicator">
@@ -352,17 +353,9 @@ function handleFilePick(file) {
return handleFilePick(file)
}
// 如果光标前是 @,先移除(让选中的 @ 不进入最终文本)
const node = range.startContainer
const offset = range.startOffset
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)
}
}
// 查找并删除光标前最近的 @ 引用(含 @ 符号和 @ 之后的查询字符),
// 避免出现 "@12" + token 这种残留文本。
removeAtReferenceBeforeCaret(range)
// 插入原子 token
const token = document.createElement('span')
@@ -396,6 +389,60 @@ function handleFilePick(file) {
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) {
el.focus()
const range = document.createRange()
@@ -83,7 +83,9 @@ async function handleLogout() {
await ElMessageBox.confirm('确定要退出登录吗?', '提示', {
confirmButtonText: '退出',
cancelButtonText: '取消',
type: 'warning'
type: 'warning',
// 弹窗 Teleport 到 body,需打 customClass 让 theme.scss 覆盖变量
customClass: 'frontend-el-message-box'
})
} catch {
return
+36 -18
View File
@@ -10,16 +10,16 @@
</div>
</div>
<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-button>
</button>
</el-tooltip>
</div>
<el-button class="marketplace-btn" @click="goToMarketplace">
<!-- <el-button v-if="!hideMarketplaceBtn" class="marketplace-btn" @click="goToMarketplace">
<el-icon><Promotion /></el-icon>
<span>前往智能体广场</span>
</el-button>
</el-button> -->
<UserInfoDialog v-model="infoDialogVisible" :user="user" @saved="handleSaved" />
<SettingsDialog v-model="settingsDialogVisible" @open-user-info="openInfoDialog" />
@@ -39,6 +39,10 @@ const props = defineProps({
collapsed: {
type: Boolean,
default: false
},
hideMarketplaceBtn: {
type: Boolean,
default: false
}
})
@@ -93,14 +97,8 @@ defineExpose({
.user-info-row {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 6px;
border-radius: var(--fe-radius-md);
transition: background 0.2s;
&:hover {
background: var(--fe-bg-hover);
}
gap: 8px;
padding: 4px 0px;
}
.user-main {
@@ -109,9 +107,15 @@ defineExpose({
display: flex;
align-items: center;
gap: 10px;
padding: 4px 6px;
border-radius: var(--fe-radius-md);
// 紧贴头像,悬停背景不留内边距;左侧弧度跟头像一致(size 36 → 18px),
// 视觉上头像"嵌"在背景左侧
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;
transition: background 0.2s;
&:hover {
background: var(--fe-bg-hover);
@@ -133,19 +137,33 @@ defineExpose({
}
.settings-btn {
padding: 6px 8px;
margin-left: 0 !important;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
color: var(--fe-text-secondary);
border: none;
background: transparent;
border-radius: var(--fe-radius-md);
transition: all 0.2s;
cursor: pointer;
transition: background 0.2s, color 0.2s;
// 只在 hover 时显示背景:focus / active 不加,避免点击后焦点残留导致背景持续
&:hover {
background: var(--fe-bg-hover);
color: var(--fe-text-primary);
}
&:focus,
&:active {
outline: none;
}
.el-icon {
font-size: 18px;
font-size: 16px;
}
}
@@ -40,7 +40,7 @@
v-model="cropperVisible"
width="800px"
:close-on-click-modal="false"
append-to-body
modal-class="frontend-dialog"
@opened="modalOpened"
@close="closeCropperDialog"
>
+27 -10
View File
@@ -5,7 +5,7 @@
<template>
<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">
@@ -22,15 +22,17 @@
<!-- 聊天区域 + 右侧文件面板 -->
<div class="content-wrapper">
<!-- 聊天区域 -->
<div class="chat-area">
<div class="chat-area" :class="{ 'is-switching': switchingConversation }">
<!-- 聊天消息区有消息时才显示 -->
<ChatWindow
v-if="messages.length > 0"
ref="chatWindowRef"
:messages="messages"
:loading="loading"
@file-click="handleFileClick"
/>
<transition name="chat-fade">
<ChatWindow
v-if="messages.length > 0"
ref="chatWindowRef"
:messages="messages"
:loading="loading"
@file-click="handleFileClick"
/>
</transition>
<!-- 输入区 -->
<InputArea
@@ -935,9 +937,24 @@ function handleStop() {
display: flex;
flex-direction: column;
overflow: hidden;
transition: all 0.3s;
transition: opacity 0.3s ease, transform 0.3s ease;
min-width: 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 {
+206 -174
View File
@@ -1,93 +1,94 @@
<template>
<div class="frontend-container marketplace-container">
<!-- 顶部区域 -->
<div class="marketplace-header">
<h1 class="marketplace-title">AI 智能体市场</h1>
<p class="marketplace-subtitle">探索并使用精选的 AI 智能体提升工作效率</p>
</div>
<!-- 分类标签导航 -->
<div class="category-nav">
<div class="category-tabs">
<span
v-for="(tab, index) in categoryTabs"
:key="index"
class="category-tab"
:class="{ active: currentCategory === tab.key }"
@click="handleCategoryChange(tab.key)"
>
{{ 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>
<!-- 左侧栏复用 frontend ConversationSidebar含底部 UserArea上半部分注入分类 Tab -->
<ConversationSidebar :hide-marketplace-btn="true">
<template #content>
<div class="marketplace-sidebar-content">
<div class="marketplace-tabs-header">分类</div>
<div class="marketplace-tabs">
<span
v-for="tab in categoryTabs"
:key="tab.key"
class="marketplace-tab"
:class="{ active: currentCategory === tab.key }"
@click="handleCategoryChange(tab.key)"
>
<span class="marketplace-tab-dot"></span>
<span class="marketplace-tab-label">{{ tab.name }}</span>
</span>
</div>
</div>
<div class="agent-intro">{{ agent.remark || '暂无备注' }}</div>
<!-- <el-tooltip :content="agent.remark || ''" placement="top" :disabled="!agent.remark">
</el-tooltip> -->
</div>
</div>
</template>
</ConversationSidebar>
<!-- 空状态 -->
<el-empty v-if="!loading && filteredAgents.length === 0" description="暂无智能体" />
<!-- 右侧主内容 -->
<div class="marketplace-main">
<!-- 顶部区域 -->
<div class="marketplace-header">
<h1 class="marketplace-title">AI 智能体市场</h1>
<p class="marketplace-subtitle">探索并使用精选的 AI 智能体提升工作效率</p>
</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)"
>
<!-- 悬浮分享按钮复制当前智能体的聊天链接 -->
<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>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
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 { ElMessage } from 'element-plus'
import ConversationSidebar from './components/ConversationSidebar.vue'
import useSettingsStore from '@/store/modules/settings'
const router = useRouter()
@@ -106,17 +107,8 @@ const categoryTabs = [
{ key: 'life', name: '生活娱乐' }
]
// 排序选项
const sortOptions = [
{ key: 'popular', name: '人气最高' },
{ key: 'latest', name: '最新发布' },
{ key: 'name', name: '名称排序' }
]
// 当前选中分类
const currentCategory = ref('all')
// 当前排序方式
const currentSort = ref('popular')
// 加载状态
const loading = ref(false)
// 智能体列表
@@ -139,30 +131,13 @@ const mockAgents = [
]
/**
* 根据当前分类和排序筛选智能体列表
* 根据当前分类筛选智能体列表
*/
const filteredAgents = computed(() => {
let result = [...agentsList.value]
// 分类筛选
if (currentCategory.value !== 'all') {
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
})
@@ -173,13 +148,6 @@ function handleCategoryChange(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">
.marketplace-container {
min-height: 100%;
display: flex;
height: 100%;
overflow: hidden;
background: var(--fe-bg-base);
}
.marketplace-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
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 {
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 {
background: var(--fe-bg-elevated);
@@ -374,6 +373,45 @@ onMounted(() => {
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 {
display: flex;
@@ -452,11 +490,5 @@ onMounted(() => {
.agents-grid {
grid-template-columns: 1fr;
}
.category-nav {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
}
</style>
+151 -37
View File
@@ -1,5 +1,7 @@
// Frontend dark theme tokens — scope to .frontend-container so admin pages are unaffected.
.frontend-container {
// Frontend 主题变量 — 定义在 :root 而非 .frontend-container
// 这样 Teleport 到 body 的元素(如右键菜单)也能继承到变量。
// --fe- 前缀保证不会与 admin 主题变量冲突(admin 模块未使用 --fe-*)。
:root {
// —— 基础色 ——
--fe-bg-base: #1e1e1e;
--fe-bg-elevated: #252526;
@@ -44,40 +46,8 @@
--fe-shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.5);
}
// —— Element Plus 变量覆盖(仅在 frontend-container 下生效)——
.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);
// —— 响应式断点 ——
// .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"] {
// —— 浅色主题覆盖(通过 body[data-fe-theme="light"] 切换,由 settings.js 同步)——
body[data-fe-theme="light"] {
--fe-bg-base: #ffffff;
--fe-bg-elevated: #fafafa;
--fe-bg-overlay: #f5f5f5;
@@ -109,8 +79,60 @@
--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-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-page: #f2f3f5;
--el-bg-color-overlay: #ffffff;
@@ -135,3 +157,95 @@
--el-color-info: #909399;
--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;
}
}