feat: 优化文件编辑部分UI
This commit is contained in:
parent
ea425cf9a6
commit
f25eb4ecfc
|
|
@ -31,9 +31,7 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="explorer-body explorer-empty">
|
<div v-else class="explorer-body explorer-empty">
|
||||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" style="color: var(--text-tertiary); opacity: 0.5;">
|
<span v-html="icons.folderLg" style="color: var(--text-tertiary); opacity: 0.5;" />
|
||||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
|
||||||
</svg>
|
|
||||||
<p>当前对话未关联项目</p>
|
<p>当前对话未关联项目</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -82,7 +80,9 @@
|
||||||
<div class="create-modal">
|
<div class="create-modal">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3>创建项目</h3>
|
<h3>创建项目</h3>
|
||||||
<CloseButton @click="showCreateModal = false" />
|
<button class="btn-close" @click="showCreateModal = false">
|
||||||
|
<span v-html="icons.closeMd" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
@ -103,6 +103,8 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ModalDialog />
|
||||||
|
<ToastContainer />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
|
@ -110,12 +112,17 @@ import { ref, shallowRef, computed, onMounted, defineAsyncComponent } from 'vue'
|
||||||
import Sidebar from './components/Sidebar.vue'
|
import Sidebar from './components/Sidebar.vue'
|
||||||
import ChatView from './components/ChatView.vue'
|
import ChatView from './components/ChatView.vue'
|
||||||
import FileExplorer from './components/FileExplorer.vue'
|
import FileExplorer from './components/FileExplorer.vue'
|
||||||
import CloseButton from './components/CloseButton.vue'
|
import ModalDialog from './components/ModalDialog.vue'
|
||||||
|
import ToastContainer from './components/ToastContainer.vue'
|
||||||
|
import { icons } from './utils/icons'
|
||||||
|
import { useModal } from './composables/useModal'
|
||||||
|
|
||||||
const SettingsPanel = defineAsyncComponent(() => import('./components/SettingsPanel.vue'))
|
const SettingsPanel = defineAsyncComponent(() => import('./components/SettingsPanel.vue'))
|
||||||
const StatsPanel = defineAsyncComponent(() => import('./components/StatsPanel.vue'))
|
const StatsPanel = defineAsyncComponent(() => import('./components/StatsPanel.vue'))
|
||||||
import { conversationApi, messageApi, projectApi } from './api'
|
import { conversationApi, messageApi, projectApi } from './api'
|
||||||
|
|
||||||
|
const modal = useModal()
|
||||||
|
|
||||||
// -- Conversations state --
|
// -- Conversations state --
|
||||||
const conversations = shallowRef([])
|
const conversations = shallowRef([])
|
||||||
const currentConvId = ref(null)
|
const currentConvId = ref(null)
|
||||||
|
|
@ -521,7 +528,8 @@ async function loadProjects() {
|
||||||
|
|
||||||
// -- Delete project --
|
// -- Delete project --
|
||||||
async function deleteProject(project) {
|
async function deleteProject(project) {
|
||||||
if (!confirm(`确定删除项目「${project.name}」及其所有对话?`)) return
|
const ok = await modal.confirm('删除确认', `确定删除项目「${project.name}」及其所有对话?`, { danger: true })
|
||||||
|
if (!ok) return
|
||||||
try {
|
try {
|
||||||
await projectApi.delete(project.id)
|
await projectApi.delete(project.id)
|
||||||
// Remove conversations belonging to this project
|
// Remove conversations belonging to this project
|
||||||
|
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<template>
|
|
||||||
<button class="btn-close" @click="$emit('click')">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
||||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
defineEmits(['click'])
|
|
||||||
</script>
|
|
||||||
|
|
@ -3,33 +3,20 @@
|
||||||
<!-- File tree sidebar -->
|
<!-- File tree sidebar -->
|
||||||
<div class="explorer-sidebar">
|
<div class="explorer-sidebar">
|
||||||
<div class="explorer-header">
|
<div class="explorer-header">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span v-html="icons.folder" />
|
||||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
|
||||||
</svg>
|
|
||||||
<span class="explorer-title">{{ projectName }}</span>
|
<span class="explorer-title">{{ projectName }}</span>
|
||||||
<div class="explorer-actions">
|
<div class="explorer-actions">
|
||||||
<button class="btn-icon-sm" @click="createNewFile" title="新建文件">
|
<button class="btn-icon-sm" @click="createNewFile" title="新建文件">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span v-html="icons.fileNew" />
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
|
||||||
<polyline points="14 2 14 8 20 8"/>
|
|
||||||
<line x1="12" y1="11" x2="12" y2="17"/>
|
|
||||||
<line x1="9" y1="14" x2="15" y2="14"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-icon-sm" @click="createNewFolder" title="新建文件夹">
|
<button class="btn-icon-sm" @click="createNewFolder" title="新建文件夹">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span v-html="icons.folderNew" />
|
||||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
|
||||||
<line x1="12" y1="11" x2="12" y2="17"/>
|
|
||||||
<line x1="9" y1="14" x2="15" y2="14"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loadingTree" class="explorer-loading">
|
<div v-if="loadingTree" class="explorer-loading">
|
||||||
<svg class="spinner" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span class="spinner" v-html="icons.spinnerMd" />
|
||||||
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="treeItems.length === 0" class="explorer-empty">
|
<div v-else-if="treeItems.length === 0" class="explorer-empty">
|
||||||
|
|
@ -46,6 +33,7 @@
|
||||||
:project-id="projectId"
|
:project-id="projectId"
|
||||||
@select="openFile"
|
@select="openFile"
|
||||||
@refresh="loadTree"
|
@refresh="loadTree"
|
||||||
|
@move="moveItem"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -61,18 +49,13 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="viewer-actions">
|
<div class="viewer-actions">
|
||||||
<button class="btn-icon-sm" @click="activeFile = null" title="关闭">
|
<button class="btn-icon-sm" @click="activeFile = null" title="关闭">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span v-html="icons.close" />
|
||||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
|
||||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loadingFile" class="viewer-loading">
|
<div v-if="loadingFile" class="viewer-loading">
|
||||||
<svg class="spinner" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span class="spinner" v-html="icons.spinnerMd" />
|
||||||
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
|
||||||
</svg>
|
|
||||||
加载中...
|
加载中...
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -98,13 +81,7 @@
|
||||||
|
|
||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
<div v-else class="viewer-placeholder">
|
<div v-else class="viewer-placeholder">
|
||||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" style="color: var(--text-tertiary); opacity: 0.5;">
|
<span v-html="icons.fileLg" style="color: var(--text-tertiary); opacity: 0.5;" />
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
|
||||||
<polyline points="14 2 14 8 20 8"/>
|
|
||||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
|
||||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
|
||||||
<polyline points="10 9 9 9 8 9"/>
|
|
||||||
</svg>
|
|
||||||
<span>选择文件以预览</span>
|
<span>选择文件以预览</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -117,8 +94,10 @@ import FileTreeItem from './FileTreeItem.vue'
|
||||||
import CodeEditor from './CodeEditor.vue'
|
import CodeEditor from './CodeEditor.vue'
|
||||||
import { normalizeFileTree } from '../utils/fileTree'
|
import { normalizeFileTree } from '../utils/fileTree'
|
||||||
import { useTheme } from '../composables/useTheme'
|
import { useTheme } from '../composables/useTheme'
|
||||||
|
import { useModal } from '../composables/useModal'
|
||||||
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico'])
|
import { useToast } from '../composables/useToast'
|
||||||
|
import { icons } from '../utils/icons'
|
||||||
|
import { getFileExtension, isImageFile } from '../utils/fileUtils'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
projectId: { type: String, required: true },
|
projectId: { type: String, required: true },
|
||||||
|
|
@ -126,6 +105,8 @@ const props = defineProps({
|
||||||
})
|
})
|
||||||
|
|
||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
|
const modal = useModal()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
// -- Tree state --
|
// -- Tree state --
|
||||||
const treeItems = ref([])
|
const treeItems = ref([])
|
||||||
|
|
@ -146,21 +127,14 @@ function releaseImageUrl() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileExt = computed(() => {
|
const fileExt = computed(() => getFileExtension(activeFile.value))
|
||||||
if (!activeFile.value) return ''
|
|
||||||
const parts = activeFile.value.split('.')
|
|
||||||
return parts.length > 1 ? parts.pop().toLowerCase() : ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const breadcrumbSegments = computed(() => {
|
const breadcrumbSegments = computed(() => {
|
||||||
if (!activeFile.value) return []
|
if (!activeFile.value) return []
|
||||||
return activeFile.value.split('/')
|
return activeFile.value.split('/')
|
||||||
})
|
})
|
||||||
|
|
||||||
const fileType = computed(() => {
|
const fileType = computed(() => isImageFile(activeFile.value) ? 'image' : 'text')
|
||||||
if (IMAGE_EXTS.has(fileExt.value)) return 'image'
|
|
||||||
return 'text'
|
|
||||||
})
|
|
||||||
|
|
||||||
async function loadTree(path = '') {
|
async function loadTree(path = '') {
|
||||||
loadingTree.value = true
|
loadingTree.value = true
|
||||||
|
|
@ -182,7 +156,7 @@ async function openFile(filepath) {
|
||||||
loadingFile.value = true
|
loadingFile.value = true
|
||||||
|
|
||||||
const ext = filepath.split('.').pop().toLowerCase()
|
const ext = filepath.split('.').pop().toLowerCase()
|
||||||
if (IMAGE_EXTS.has(ext)) {
|
if (isImageFile(filepath)) {
|
||||||
try {
|
try {
|
||||||
const res = await projectApi.readFileRaw(props.projectId, filepath)
|
const res = await projectApi.readFileRaw(props.projectId, filepath)
|
||||||
const blob = await res.blob()
|
const blob = await res.blob()
|
||||||
|
|
@ -210,47 +184,54 @@ async function saveFile() {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
await projectApi.writeFile(props.projectId, activeFile.value, editContent.value)
|
await projectApi.writeFile(props.projectId, activeFile.value, editContent.value)
|
||||||
|
toast.success('已保存')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('保存失败: ' + e.message)
|
toast.error('保存失败: ' + e.message)
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteFile() {
|
|
||||||
if (!activeFile.value) return
|
|
||||||
const name = activeFile.value.split('/').pop()
|
|
||||||
if (!confirm(`确定要删除 ${name} 吗?`)) return
|
|
||||||
|
|
||||||
projectApi.deleteFile(props.projectId, activeFile.value).then(() => {
|
|
||||||
activeFile.value = null
|
|
||||||
loadTree()
|
|
||||||
}).catch(e => {
|
|
||||||
alert('删除失败: ' + e.message)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createNewFile() {
|
async function createNewFile() {
|
||||||
const name = prompt('文件名(例如 utils.py)')
|
const name = await modal.prompt('新建文件', '请输入文件名(例如 utils.py)')
|
||||||
if (!name?.trim()) return
|
if (!name) return
|
||||||
const path = name.trim()
|
|
||||||
try {
|
try {
|
||||||
await projectApi.writeFile(props.projectId, path, '')
|
await projectApi.writeFile(props.projectId, name, '')
|
||||||
|
toast.success(`已创建「${name}」`)
|
||||||
await loadTree()
|
await loadTree()
|
||||||
openFile(path)
|
openFile(name)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('创建失败: ' + e.message)
|
toast.error('创建失败: ' + e.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createNewFolder() {
|
async function createNewFolder() {
|
||||||
const name = prompt('文件夹名称')
|
const name = await modal.prompt('新建文件夹', '请输入文件夹名称')
|
||||||
if (!name?.trim()) return
|
if (!name) return
|
||||||
try {
|
try {
|
||||||
await projectApi.mkdir(props.projectId, name.trim())
|
await projectApi.mkdir(props.projectId, name)
|
||||||
|
toast.success(`已创建文件夹「${name}」`)
|
||||||
await loadTree()
|
await loadTree()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('创建失败: ' + e.message)
|
toast.error('创建失败: ' + e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveItem({ srcPath, destDir, name }) {
|
||||||
|
const newPath = `${destDir}/${name}`
|
||||||
|
try {
|
||||||
|
await projectApi.renameFile(props.projectId, srcPath, newPath)
|
||||||
|
toast.success(`已移动「${name}」到 ${destDir}`)
|
||||||
|
// Update active file path if it was moved
|
||||||
|
if (activeFile.value && activeFile.value === srcPath) {
|
||||||
|
activeFile.value = newPath
|
||||||
|
} else if (activeFile.value && activeFile.value.startsWith(srcPath + '/')) {
|
||||||
|
activeFile.value = newPath + activeFile.value.slice(srcPath.length)
|
||||||
|
}
|
||||||
|
await loadTree()
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('移动失败: ' + e.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -330,35 +311,6 @@ onUnmounted(() => {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-icon-sm {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon-sm:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon-sm.save:hover {
|
|
||||||
background: var(--success-bg);
|
|
||||||
color: var(--success-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon-sm.danger:hover {
|
|
||||||
background: var(--danger-bg);
|
|
||||||
color: var(--danger-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-container {
|
.tree-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,35 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="tree-node" @mouseenter="hovered = true" @mouseleave="onMouseLeave">
|
<div
|
||||||
<div class="tree-item" :class="{ active: isActive }" @click="onClick">
|
class="tree-node"
|
||||||
|
:class="{ 'drag-over': isDragOver }"
|
||||||
|
@mouseenter="hovered = true"
|
||||||
|
@mouseleave="onMouseLeave"
|
||||||
|
@dragover.prevent="onDragOver"
|
||||||
|
@dragenter.prevent="onDragEnter"
|
||||||
|
@dragleave="onDragLeave"
|
||||||
|
@drop.prevent="onDrop"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="tree-item"
|
||||||
|
:class="{ active: isActive }"
|
||||||
|
:draggable="true"
|
||||||
|
@click="onClick"
|
||||||
|
@dragstart="onDragStart"
|
||||||
|
@dragend="onDragEnd"
|
||||||
|
>
|
||||||
<span class="tree-indent" :style="{ width: depth * 16 + 'px' }"></span>
|
<span class="tree-indent" :style="{ width: depth * 16 + 'px' }"></span>
|
||||||
|
|
||||||
<span v-if="item.type === 'dir'" class="tree-arrow" :class="{ open: expanded }">
|
<span v-if="item.type === 'dir'" class="tree-arrow" :class="{ open: expanded }">
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span v-html="icons.chevronRight" />
|
||||||
<polyline points="9 18 15 12 9 6"/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="tree-arrow-placeholder"></span>
|
<span v-else class="tree-arrow-placeholder"></span>
|
||||||
|
|
||||||
<!-- File icon -->
|
<!-- File icon -->
|
||||||
<span v-if="item.type === 'file'" class="tree-icon">
|
<span v-if="item.type === 'file'" class="tree-icon" :style="{ color: iconColor }">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" :stroke="iconColor" stroke-width="2">
|
<span v-html="icons.file" />
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
|
||||||
<polyline points="14 2 14 8 20 8"/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="tree-icon folder-icon">
|
<span v-else class="tree-icon folder-icon">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span v-html="icons.folder" />
|
||||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- Rename input or name -->
|
<!-- Rename input or name -->
|
||||||
|
|
@ -41,35 +50,27 @@
|
||||||
<!-- Folder: new file/folder dropdown -->
|
<!-- Folder: new file/folder dropdown -->
|
||||||
<div v-if="item.type === 'dir'" class="tree-action-dropdown">
|
<div v-if="item.type === 'dir'" class="tree-action-dropdown">
|
||||||
<button class="btn-icon-sm" @click.stop="showCreateMenu = !showCreateMenu" title="新建">
|
<button class="btn-icon-sm" @click.stop="showCreateMenu = !showCreateMenu" title="新建">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span v-html="icons.plus" />
|
||||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
<!-- Dropdown menu -->
|
<!-- Dropdown menu -->
|
||||||
<div v-if="showCreateMenu" class="tree-create-menu" @mouseenter="hovered = true" @mouseleave="onMouseLeave">
|
<div v-if="showCreateMenu" class="tree-create-menu" @mouseenter="hovered = true" @mouseleave="onMouseLeave">
|
||||||
<button @click="createNewFile">
|
<button @click="createNewFile">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
<span v-html="icons.file" />
|
||||||
新建文件
|
新建文件
|
||||||
</button>
|
</button>
|
||||||
<button @click="createNewFolder">
|
<button @click="createNewFolder">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
<span v-html="icons.folder" />
|
||||||
新建文件夹
|
新建文件夹
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Rename -->
|
<!-- Rename -->
|
||||||
<button class="btn-icon-sm" @click.stop="startRename" title="重命名">
|
<button class="btn-icon-sm" @click.stop="startRename" title="重命名">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span v-html="icons.edit" />
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
<!-- Delete -->
|
<!-- Delete -->
|
||||||
<button class="btn-icon-sm danger" @click.stop="deleteItem" title="删除">
|
<button class="btn-icon-sm danger" @click.stop="deleteItem" title="删除">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span v-html="icons.trash" />
|
||||||
<polyline points="3 6 5 6 21 6"/>
|
|
||||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -77,9 +78,7 @@
|
||||||
<!-- Children -->
|
<!-- Children -->
|
||||||
<div v-if="item.type === 'dir' && expanded" class="tree-children">
|
<div v-if="item.type === 'dir' && expanded" class="tree-children">
|
||||||
<div v-if="loading" class="tree-loading">
|
<div v-if="loading" class="tree-loading">
|
||||||
<svg class="spinner" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span class="spinner" v-html="icons.spinner" />
|
||||||
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<FileTreeItem
|
<FileTreeItem
|
||||||
v-for="child in item.children"
|
v-for="child in item.children"
|
||||||
|
|
@ -90,6 +89,7 @@
|
||||||
:project-id="projectId"
|
:project-id="projectId"
|
||||||
@select="$emit('select', $event)"
|
@select="$emit('select', $event)"
|
||||||
@refresh="$emit('refresh')"
|
@refresh="$emit('refresh')"
|
||||||
|
@move="$emit('move', $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -98,6 +98,10 @@
|
||||||
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
|
||||||
import { projectApi } from '../api'
|
import { projectApi } from '../api'
|
||||||
import { normalizeFileTree } from '../utils/fileTree'
|
import { normalizeFileTree } from '../utils/fileTree'
|
||||||
|
import { useModal } from '../composables/useModal'
|
||||||
|
import { useToast } from '../composables/useToast'
|
||||||
|
import { icons } from '../utils/icons'
|
||||||
|
import { getFileIconColor } from '../utils/fileUtils'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
item: { type: Object, required: true },
|
item: { type: Object, required: true },
|
||||||
|
|
@ -106,7 +110,10 @@ const props = defineProps({
|
||||||
projectId: { type: String, required: true },
|
projectId: { type: String, required: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['select', 'refresh'])
|
const emit = defineEmits(['select', 'refresh', 'move'])
|
||||||
|
|
||||||
|
const modal = useModal()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
const expanded = ref(false)
|
const expanded = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
@ -119,14 +126,8 @@ const renameInput = ref(null)
|
||||||
const isActive = computed(() => props.activePath === props.item.path)
|
const isActive = computed(() => props.activePath === props.item.path)
|
||||||
|
|
||||||
const iconColor = computed(() => {
|
const iconColor = computed(() => {
|
||||||
const ext = props.item.extension?.toLowerCase() || ''
|
const ext = props.item.extension?.replace(/^\./, '').toLowerCase() || ''
|
||||||
const colorMap = {
|
return getFileIconColor(ext)
|
||||||
'.py': '#3572A5', '.js': '#f1e05a', '.ts': '#3178c6', '.vue': '#41b883',
|
|
||||||
'.html': '#e34c26', '.css': '#563d7c', '.json': '#292929', '.md': '#083fa1',
|
|
||||||
'.yml': '#cb171e', '.yaml': '#cb171e', '.toml': '#9c4221', '.sql': '#e38c00',
|
|
||||||
'.sh': '#89e051', '.java': '#b07219', '.go': '#00ADD8', '.rs': '#dea584',
|
|
||||||
}
|
|
||||||
return colorMap[ext] || 'var(--text-tertiary)'
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function onMouseLeave() {
|
function onMouseLeave() {
|
||||||
|
|
@ -186,9 +187,10 @@ async function confirmRename() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await projectApi.renameFile(props.projectId, props.item.path, newPath)
|
await projectApi.renameFile(props.projectId, props.item.path, newPath)
|
||||||
|
toast.success(`已重命名为「${newName}」`)
|
||||||
emit('refresh')
|
emit('refresh')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('重命名失败: ' + e.message)
|
toast.error('重命名失败: ' + e.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -199,42 +201,102 @@ function cancelRename() {
|
||||||
// -- Delete --
|
// -- Delete --
|
||||||
async function deleteItem() {
|
async function deleteItem() {
|
||||||
const type = props.item.type === 'dir' ? '文件夹' : '文件'
|
const type = props.item.type === 'dir' ? '文件夹' : '文件'
|
||||||
if (!confirm(`确定要删除${type}「${props.item.name}」吗?`)) return
|
const ok = await modal.confirm('删除确认', `确定要删除${type}「${props.item.name}」吗?`, { danger: true })
|
||||||
|
if (!ok) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await projectApi.deleteFile(props.projectId, props.item.path)
|
await projectApi.deleteFile(props.projectId, props.item.path)
|
||||||
|
toast.success(`已删除「${props.item.name}」`)
|
||||||
emit('refresh')
|
emit('refresh')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('删除失败: ' + e.message)
|
toast.error('删除失败: ' + e.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Create (in folder) --
|
// -- Create (in folder) --
|
||||||
async function createNewFile() {
|
async function createNewFile() {
|
||||||
showCreateMenu.value = false
|
showCreateMenu.value = false
|
||||||
const name = prompt('文件名(例如 utils.py)')
|
const name = await modal.prompt('新建文件', '请输入文件名(例如 utils.py)')
|
||||||
if (!name?.trim()) return
|
if (!name) return
|
||||||
const path = props.item.path ? `${props.item.path}/${name.trim()}` : name.trim()
|
const path = props.item.path ? `${props.item.path}/${name}` : name
|
||||||
try {
|
try {
|
||||||
await projectApi.writeFile(props.projectId, path, '')
|
await projectApi.writeFile(props.projectId, path, '')
|
||||||
|
toast.success(`已创建「${name}」`)
|
||||||
emit('refresh')
|
emit('refresh')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('创建失败: ' + e.message)
|
toast.error('创建失败: ' + e.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createNewFolder() {
|
async function createNewFolder() {
|
||||||
showCreateMenu.value = false
|
showCreateMenu.value = false
|
||||||
const name = prompt('文件夹名称')
|
const name = await modal.prompt('新建文件夹', '请输入文件夹名称')
|
||||||
if (!name?.trim()) return
|
if (!name) return
|
||||||
const path = props.item.path ? `${props.item.path}/${name.trim()}` : name.trim()
|
const path = props.item.path ? `${props.item.path}/${name}` : name
|
||||||
try {
|
try {
|
||||||
await projectApi.mkdir(props.projectId, path)
|
await projectApi.mkdir(props.projectId, path)
|
||||||
|
toast.success(`已创建文件夹「${name}」`)
|
||||||
emit('refresh')
|
emit('refresh')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('创建失败: ' + e.message)
|
toast.error('创建失败: ' + e.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Drag & Drop (move file/folder) --
|
||||||
|
const isDragOver = ref(false)
|
||||||
|
let dragCounter = 0
|
||||||
|
|
||||||
|
function onDragStart(e) {
|
||||||
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
|
e.dataTransfer.setData('application/json', JSON.stringify({
|
||||||
|
path: props.item.path,
|
||||||
|
name: props.item.name,
|
||||||
|
type: props.item.type,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnd() {
|
||||||
|
isDragOver.value = false
|
||||||
|
dragCounter = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragOver(e) {
|
||||||
|
if (props.item.type !== 'dir') return
|
||||||
|
e.dataTransfer.dropEffect = 'move'
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnter() {
|
||||||
|
if (props.item.type !== 'dir') return
|
||||||
|
dragCounter++
|
||||||
|
isDragOver.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragLeave() {
|
||||||
|
dragCounter--
|
||||||
|
if (dragCounter <= 0) {
|
||||||
|
dragCounter = 0
|
||||||
|
isDragOver.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(e) {
|
||||||
|
isDragOver.value = false
|
||||||
|
dragCounter = 0
|
||||||
|
if (props.item.type !== 'dir') return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.dataTransfer.getData('application/json'))
|
||||||
|
if (!data.path || data.path === props.item.path) return
|
||||||
|
|
||||||
|
// Prevent moving a parent into its own child
|
||||||
|
if (props.item.path.startsWith(data.path + '/')) {
|
||||||
|
toast.error('不能将文件夹移动到其子文件夹内')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('move', { srcPath: data.path, destDir: props.item.path, name: data.name })
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -341,31 +403,6 @@ async function createNewFolder() {
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-icon-sm {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: all 0.15s;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon-sm:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon-sm.danger:hover {
|
|
||||||
background: var(--danger-bg);
|
|
||||||
color: var(--danger-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -- Create dropdown -- */
|
/* -- Create dropdown -- */
|
||||||
.tree-action-dropdown {
|
.tree-action-dropdown {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
@ -411,12 +448,24 @@ async function createNewFolder() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -- Children & loading -- */
|
/* -- Children & loading -- */
|
||||||
.tree-children {
|
|
||||||
/* no additional styles needed */
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-loading {
|
.tree-loading {
|
||||||
padding: 4px 0 4px 40px;
|
padding: 4px 0 4px 40px;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -- Drag & Drop -- */
|
||||||
|
.tree-node.drag-over > .tree-item {
|
||||||
|
background: var(--accent-primary-light);
|
||||||
|
outline: 2px dashed var(--accent-primary);
|
||||||
|
outline-offset: -2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-item[draggable="true"] {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-item[draggable="true"]:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -31,22 +31,13 @@
|
||||||
<span class="token-count" v-if="tokenCount">{{ tokenCount }} tokens</span>
|
<span class="token-count" v-if="tokenCount">{{ tokenCount }} tokens</span>
|
||||||
<span class="message-time">{{ formatTime(createdAt) }}</span>
|
<span class="message-time">{{ formatTime(createdAt) }}</span>
|
||||||
<button v-if="role === 'assistant'" class="btn-regenerate" @click="$emit('regenerate')" title="重新生成">
|
<button v-if="role === 'assistant'" class="btn-regenerate" @click="$emit('regenerate')" title="重新生成">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span v-html="icons.regenerate" />
|
||||||
<path d="M1 4v6h6"/>
|
|
||||||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
<button v-if="role === 'assistant'" class="btn-copy" @click="copyContent" title="复制">
|
<button v-if="role === 'assistant'" class="btn-copy" @click="copyContent" title="复制">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span v-html="icons.copy" />
|
||||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
|
||||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
<button v-if="deletable" class="btn-delete-msg" @click="$emit('delete')" title="删除">
|
<button v-if="deletable" class="btn-delete-msg" @click="$emit('delete')" title="删除">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span v-html="icons.trash" />
|
||||||
<polyline points="3 6 5 6 21 6"></polyline>
|
|
||||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -58,6 +49,7 @@ import { computed, ref } from 'vue'
|
||||||
import { renderMarkdown } from '../utils/markdown'
|
import { renderMarkdown } from '../utils/markdown'
|
||||||
import { formatTime } from '../utils/format'
|
import { formatTime } from '../utils/format'
|
||||||
import { useCodeEnhancement } from '../composables/useCodeEnhancement'
|
import { useCodeEnhancement } from '../composables/useCodeEnhancement'
|
||||||
|
import { icons } from '../utils/icons'
|
||||||
import ProcessBlock from './ProcessBlock.vue'
|
import ProcessBlock from './ProcessBlock.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,7 @@
|
||||||
<span class="file-icon">{{ getFileIcon(file.extension) }}</span>
|
<span class="file-icon">{{ getFileIcon(file.extension) }}</span>
|
||||||
<span class="file-name">{{ file.name }}</span>
|
<span class="file-name">{{ file.name }}</span>
|
||||||
<button class="btn-remove-file" @click="removeFile(index)" title="移除">
|
<button class="btn-remove-file" @click="removeFile(index)" title="移除">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span v-html="icons.close" />
|
||||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
||||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -39,9 +36,7 @@
|
||||||
@click="toggleTools"
|
@click="toggleTools"
|
||||||
:title="toolsEnabled ? '工具调用: 已开启' : '工具调用: 已关闭'"
|
:title="toolsEnabled ? '工具调用: 已开启' : '工具调用: 已关闭'"
|
||||||
>
|
>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span v-html="icons.wrench" />
|
||||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn-upload"
|
class="btn-upload"
|
||||||
|
|
@ -49,9 +44,7 @@
|
||||||
@click="triggerFileUpload"
|
@click="triggerFileUpload"
|
||||||
title="上传文件"
|
title="上传文件"
|
||||||
>
|
>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span v-html="icons.upload" />
|
||||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn-send"
|
class="btn-send"
|
||||||
|
|
@ -59,10 +52,7 @@
|
||||||
:disabled="!canSend || disabled"
|
:disabled="!canSend || disabled"
|
||||||
@click="send"
|
@click="send"
|
||||||
>
|
>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span v-html="icons.send" />
|
||||||
<line x1="22" y1="2" x2="11" y2="13"></line>
|
|
||||||
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -73,6 +63,7 @@
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, nextTick } from 'vue'
|
import { ref, computed, nextTick } from 'vue'
|
||||||
|
import { icons } from '../utils/icons'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
disabled: { type: Boolean, default: false },
|
disabled: { type: Boolean, default: false },
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="modal-fade">
|
||||||
|
<div v-if="state.visible" class="modal-overlay" @click.self="onCancel" @keydown.escape="onCancel">
|
||||||
|
<div class="modal-dialog" role="dialog" :aria-modal="true">
|
||||||
|
<h3 class="modal-title">{{ state.title }}</h3>
|
||||||
|
<p class="modal-message">{{ state.message }}</p>
|
||||||
|
|
||||||
|
<input
|
||||||
|
v-if="state.type === 'prompt'"
|
||||||
|
ref="inputRef"
|
||||||
|
v-model="state.inputValue"
|
||||||
|
class="modal-input"
|
||||||
|
@keydown.enter="onOk"
|
||||||
|
@keydown.escape="onCancel"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="modal-btn" @click="onCancel">取消</button>
|
||||||
|
<button
|
||||||
|
class="modal-btn"
|
||||||
|
:class="{ danger: state.danger }"
|
||||||
|
@click="onOk"
|
||||||
|
>确认</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch, nextTick } from 'vue'
|
||||||
|
import { useModal } from '../composables/useModal'
|
||||||
|
|
||||||
|
const { state, onOk, onCancel } = useModal()
|
||||||
|
const inputRef = ref(null)
|
||||||
|
|
||||||
|
watch(() => state.visible, (v) => {
|
||||||
|
if (v) {
|
||||||
|
nextTick(() => inputRef.value?.focus())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
width: 380px;
|
||||||
|
max-width: 90vw;
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-message {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-input:focus {
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn {
|
||||||
|
padding: 6px 16px;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn.danger {
|
||||||
|
background: var(--danger-color);
|
||||||
|
border-color: var(--danger-color);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn.danger:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
.modal-fade-enter-active,
|
||||||
|
.modal-fade-leave-active {
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.modal-fade-enter-from,
|
||||||
|
.modal-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -2,9 +2,7 @@
|
||||||
<div class="settings-panel">
|
<div class="settings-panel">
|
||||||
<div class="settings-header">
|
<div class="settings-header">
|
||||||
<div class="settings-title">
|
<div class="settings-title">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span v-html="icons.settings" />
|
||||||
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
|
||||||
</svg>
|
|
||||||
<h4>会话设置</h4>
|
<h4>会话设置</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
|
@ -18,7 +16,9 @@
|
||||||
{{ t.label }}
|
{{ t.label }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<CloseButton @click="$emit('close')" />
|
<button class="btn-close" @click="$emit('close')">
|
||||||
|
<span v-html="icons.closeMd" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -114,9 +114,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="auto-save-hint">
|
<div class="auto-save-hint">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span v-html="icons.save" />
|
||||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>
|
|
||||||
</svg>
|
|
||||||
<span>修改自动保存</span>
|
<span>修改自动保存</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -127,7 +125,7 @@
|
||||||
import { reactive, ref, watch, onMounted } from 'vue'
|
import { reactive, ref, watch, onMounted } from 'vue'
|
||||||
import { modelApi, conversationApi } from '../api'
|
import { modelApi, conversationApi } from '../api'
|
||||||
import { useTheme } from '../composables/useTheme'
|
import { useTheme } from '../composables/useTheme'
|
||||||
import CloseButton from './CloseButton.vue'
|
import { icons } from '../utils/icons'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
visible: { type: Boolean, default: false },
|
visible: { type: Boolean, default: false },
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,7 @@
|
||||||
<!-- Project groups -->
|
<!-- Project groups -->
|
||||||
<div v-for="group in groupedData.groups" :key="group.id" class="project-group">
|
<div v-for="group in groupedData.groups" :key="group.id" class="project-group">
|
||||||
<div class="project-header" @click="toggleGroup(group.id)">
|
<div class="project-header" @click="toggleGroup(group.id)">
|
||||||
<svg class="chevron" :class="{ collapsed: !expandedGroups[group.id] }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span class="chevron" :class="{ collapsed: !expandedGroups[group.id] }" v-html="icons.chevronDown" />
|
||||||
<polyline points="6 9 12 15 18 9"/>
|
|
||||||
</svg>
|
|
||||||
<span class="project-name">{{ group.name }}</span>
|
<span class="project-name">{{ group.name }}</span>
|
||||||
<span class="conv-count">{{ group.conversations.length }}</span>
|
<span class="conv-count">{{ group.conversations.length }}</span>
|
||||||
<button
|
<button
|
||||||
|
|
@ -21,29 +19,21 @@
|
||||||
title="新建对话"
|
title="新建对话"
|
||||||
@click.stop="$emit('createInProject', { id: group.id, name: group.name })"
|
@click.stop="$emit('createInProject', { id: group.id, name: group.name })"
|
||||||
>
|
>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span v-html="icons.plus" />
|
||||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
|
||||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn-group-action"
|
class="btn-group-action"
|
||||||
title="浏览文件"
|
title="浏览文件"
|
||||||
@click.stop="$emit('browseProject', { id: group.id, name: group.name })"
|
@click.stop="$emit('browseProject', { id: group.id, name: group.name })"
|
||||||
>
|
>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span v-html="icons.folder" />
|
||||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn-group-action btn-delete-project"
|
class="btn-group-action btn-delete-project"
|
||||||
title="删除项目"
|
title="删除项目"
|
||||||
@click.stop="$emit('deleteProject', { id: group.id, name: group.name })"
|
@click.stop="$emit('deleteProject', { id: group.id, name: group.name })"
|
||||||
>
|
>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span v-html="icons.trashSm" />
|
||||||
<polyline points="3 6 5 6 21 6"></polyline>
|
|
||||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="expandedGroups[group.id]">
|
<div v-show="expandedGroups[group.id]">
|
||||||
|
|
@ -61,10 +51,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-delete" @click.stop="$emit('delete', conv.id)" title="删除">
|
<button class="btn-delete" @click.stop="$emit('delete', conv.id)" title="删除">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span v-html="icons.trash" />
|
||||||
<polyline points="3 6 5 6 21 6"></polyline>
|
|
||||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -73,22 +60,15 @@
|
||||||
<!-- Standalone conversations (always visible) -->
|
<!-- Standalone conversations (always visible) -->
|
||||||
<div class="project-group">
|
<div class="project-group">
|
||||||
<div class="project-header" @click="toggleGroup('__standalone__')">
|
<div class="project-header" @click="toggleGroup('__standalone__')">
|
||||||
<svg class="chevron" :class="{ collapsed: !expandedGroups['__standalone__'] }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span class="chevron" :class="{ collapsed: !expandedGroups['__standalone__'] }" v-html="icons.chevronDown" />
|
||||||
<polyline points="6 9 12 15 18 9"/>
|
<span class="standalone-icon" v-html="icons.chat" />
|
||||||
</svg>
|
|
||||||
<svg class="standalone-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
|
||||||
</svg>
|
|
||||||
<span class="conv-count">{{ groupedData.standalone.length }}</span>
|
<span class="conv-count">{{ groupedData.standalone.length }}</span>
|
||||||
<button
|
<button
|
||||||
class="btn-group-action"
|
class="btn-group-action"
|
||||||
title="新建对话"
|
title="新建对话"
|
||||||
@click.stop="$emit('createInProject', { id: null, name: null })"
|
@click.stop="$emit('createInProject', { id: null, name: null })"
|
||||||
>
|
>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span v-html="icons.plus" />
|
||||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
|
||||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
<span class="btn-placeholder"></span>
|
<span class="btn-placeholder"></span>
|
||||||
<span class="btn-placeholder"></span>
|
<span class="btn-placeholder"></span>
|
||||||
|
|
@ -108,10 +88,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-delete" @click.stop="$emit('delete', conv.id)" title="删除">
|
<button class="btn-delete" @click.stop="$emit('delete', conv.id)" title="删除">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span v-html="icons.trash" />
|
||||||
<polyline points="3 6 5 6 21 6"></polyline>
|
|
||||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -123,17 +100,10 @@
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<button class="btn-footer" title="使用统计" @click="$emit('toggleStats')">
|
<button class="btn-footer" title="使用统计" @click="$emit('toggleStats')">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span v-html="icons.stats" />
|
||||||
<path d="M18 20V10"/>
|
|
||||||
<path d="M12 20V4"/>
|
|
||||||
<path d="M6 20v-6"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-footer" title="设置" @click="$emit('toggleSettings')">
|
<button class="btn-footer" title="设置" @click="$emit('toggleSettings')">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span v-html="icons.settings" />
|
||||||
<circle cx="12" cy="12" r="3"></circle>
|
|
||||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
@ -142,6 +112,7 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, reactive } from 'vue'
|
import { computed, reactive } from 'vue'
|
||||||
import { formatTime } from '../utils/format'
|
import { formatTime } from '../utils/format'
|
||||||
|
import { icons } from '../utils/icons'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
conversations: { type: Array, required: true },
|
conversations: { type: Array, required: true },
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,7 @@
|
||||||
<div class="stats-panel">
|
<div class="stats-panel">
|
||||||
<div class="stats-header">
|
<div class="stats-header">
|
||||||
<div class="stats-title">
|
<div class="stats-title">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span v-html="icons.stats" />
|
||||||
<path d="M18 20V10"/>
|
|
||||||
<path d="M12 20V4"/>
|
|
||||||
<path d="M6 20v-6"/>
|
|
||||||
</svg>
|
|
||||||
<h4>使用统计</h4>
|
<h4>使用统计</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
|
@ -20,14 +16,14 @@
|
||||||
{{ p.label }}
|
{{ p.label }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<CloseButton @click="$emit('close')" />
|
<button class="btn-close" @click="$emit('close')">
|
||||||
|
<span v-html="icons.closeMd" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="stats-loading">
|
<div v-if="loading" class="stats-loading">
|
||||||
<svg class="spinner" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span class="spinner" v-html="icons.spinnerMd" />
|
||||||
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
|
||||||
</svg>
|
|
||||||
加载中...
|
加载中...
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -36,10 +32,7 @@
|
||||||
<div class="stats-summary">
|
<div class="stats-summary">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-icon input-icon">
|
<div class="stat-icon input-icon">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span v-html="icons.edit" />
|
||||||
<path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
|
||||||
<path d="M18.375 2.625a2.121 2.121 0 1 1 3 3L12 15l-4 1 1-4Z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-info">
|
<div class="stat-info">
|
||||||
<span class="stat-label">输入</span>
|
<span class="stat-label">输入</span>
|
||||||
|
|
@ -48,12 +41,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-icon output-icon">
|
<div class="stat-icon output-icon">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span v-html="icons.file" />
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/>
|
|
||||||
<polyline points="14 2 14 8 20 8"/>
|
|
||||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
|
||||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-info">
|
<div class="stat-info">
|
||||||
<span class="stat-label">输出</span>
|
<span class="stat-label">输出</span>
|
||||||
|
|
@ -62,9 +50,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card total">
|
<div class="stat-card total">
|
||||||
<div class="stat-icon total-icon">
|
<div class="stat-icon total-icon">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span v-html="icons.trendUp" />
|
||||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-info">
|
<div class="stat-info">
|
||||||
<span class="stat-label">总计</span>
|
<span class="stat-label">总计</span>
|
||||||
|
|
@ -203,11 +189,7 @@
|
||||||
|
|
||||||
<!-- 空状态 -->
|
<!-- 空状态 -->
|
||||||
<div v-if="!stats.total_tokens" class="stats-empty">
|
<div v-if="!stats.total_tokens" class="stats-empty">
|
||||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
<span v-html="icons.stats" style="opacity: 0.5;" />
|
||||||
<path d="M18 20V10"/>
|
|
||||||
<path d="M12 20V4"/>
|
|
||||||
<path d="M6 20v-6"/>
|
|
||||||
</svg>
|
|
||||||
<span>暂无使用数据</span>
|
<span>暂无使用数据</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -218,7 +200,7 @@
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { statsApi } from '../api'
|
import { statsApi } from '../api'
|
||||||
import { formatNumber } from '../utils/format'
|
import { formatNumber } from '../utils/format'
|
||||||
import CloseButton from './CloseButton.vue'
|
import { icons } from '../utils/icons'
|
||||||
|
|
||||||
defineEmits(['close'])
|
defineEmits(['close'])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div class="toast-container">
|
||||||
|
<TransitionGroup name="toast-slide">
|
||||||
|
<div v-for="t in toasts" :key="t.id" class="toast-item" :class="t.type">
|
||||||
|
<span class="toast-icon" v-html="iconMap[t.type]" />
|
||||||
|
<span class="toast-msg">{{ t.message }}</span>
|
||||||
|
</div>
|
||||||
|
</TransitionGroup>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useToast } from '../composables/useToast'
|
||||||
|
import { icons } from '../utils/icons'
|
||||||
|
|
||||||
|
const { toasts } = useToast()
|
||||||
|
|
||||||
|
const iconMap = {
|
||||||
|
success: icons.check,
|
||||||
|
error: icons.error,
|
||||||
|
info: icons.info,
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toast-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 99999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||||
|
pointer-events: auto;
|
||||||
|
border-left: 3px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-item.success { border-left-color: var(--success-color); }
|
||||||
|
.toast-item.error { border-left-color: var(--danger-color); }
|
||||||
|
.toast-item.info { border-left-color: var(--accent-primary); }
|
||||||
|
|
||||||
|
.toast-item.success .toast-icon { color: var(--success-color); }
|
||||||
|
.toast-item.error .toast-icon { color: var(--danger-color); }
|
||||||
|
.toast-item.info .toast-icon { color: var(--accent-primary); }
|
||||||
|
|
||||||
|
.toast-msg {
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-slide-enter-active { transition: all 0.25s ease-out; }
|
||||||
|
.toast-slide-leave-active { transition: all 0.2s ease-in; }
|
||||||
|
.toast-slide-enter-from { transform: translateX(100%); opacity: 0; }
|
||||||
|
.toast-slide-leave-to { transform: translateX(60%); opacity: 0; }
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
visible: false,
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
type: 'confirm', // 'confirm' | 'prompt'
|
||||||
|
danger: false,
|
||||||
|
inputValue: '',
|
||||||
|
_resolve: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useModal() {
|
||||||
|
function confirm(title, message, options = {}) {
|
||||||
|
state.title = title
|
||||||
|
state.message = message
|
||||||
|
state.type = 'confirm'
|
||||||
|
state.danger = options.danger || false
|
||||||
|
state.visible = true
|
||||||
|
return new Promise(resolve => { state._resolve = resolve })
|
||||||
|
}
|
||||||
|
|
||||||
|
function prompt(title, message, defaultValue = '') {
|
||||||
|
state.title = title
|
||||||
|
state.message = message
|
||||||
|
state.type = 'prompt'
|
||||||
|
state.danger = false
|
||||||
|
state.inputValue = defaultValue
|
||||||
|
state.visible = true
|
||||||
|
return new Promise(resolve => { state._resolve = resolve })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOk() {
|
||||||
|
if (state.type === 'prompt') {
|
||||||
|
state._resolve?.(state.inputValue.trim())
|
||||||
|
} else {
|
||||||
|
state._resolve?.(true)
|
||||||
|
}
|
||||||
|
state.visible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCancel() {
|
||||||
|
state._resolve?.(state.type === 'prompt' ? null : false)
|
||||||
|
state.visible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return { state, confirm, prompt, onOk, onCancel }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
toasts: [],
|
||||||
|
_id: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
function add(type, message, duration = 1500) {
|
||||||
|
const id = ++state._id
|
||||||
|
state.toasts.push({ id, type, message })
|
||||||
|
setTimeout(() => {
|
||||||
|
const idx = state.toasts.findIndex(t => t.id === id)
|
||||||
|
if (idx !== -1) state.toasts.splice(idx, 1)
|
||||||
|
}, duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
toasts: state.toasts,
|
||||||
|
success: (msg, dur) => add('success', msg, dur),
|
||||||
|
error: (msg, dur) => add('error', msg, dur),
|
||||||
|
info: (msg, dur) => add('info', msg, dur),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -319,21 +319,25 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============ Scrollbar ============ */
|
/* ============ Scrollbar ============ */
|
||||||
textarea::-webkit-scrollbar {
|
textarea::-webkit-scrollbar,
|
||||||
|
.cm-scroller::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea::-webkit-scrollbar-track {
|
textarea::-webkit-scrollbar-track,
|
||||||
|
.cm-scroller::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea::-webkit-scrollbar-thumb {
|
textarea::-webkit-scrollbar-thumb,
|
||||||
|
.cm-scroller::-webkit-scrollbar-thumb {
|
||||||
background: var(--scrollbar-thumb);
|
background: var(--scrollbar-thumb);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea::-webkit-scrollbar-thumb:hover {
|
textarea::-webkit-scrollbar-thumb:hover,
|
||||||
|
.cm-scroller::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--text-tertiary);
|
background: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -341,6 +345,11 @@ textarea::-webkit-resizer {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cm-scroller {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--scrollbar-thumb) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============ Range Slider ============ */
|
/* ============ Range Slider ============ */
|
||||||
input[type="range"] {
|
input[type="range"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -717,4 +726,30 @@ input[type="range"]::-moz-range-thumb {
|
||||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23a0a0a0' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23a0a0a0' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============ Small Icon Button ============ */
|
||||||
|
.btn-icon-sm {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon-sm:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon-sm.danger:hover {
|
||||||
|
background: var(--danger-bg);
|
||||||
|
color: var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
/**
|
||||||
|
* File utility functions shared across FileExplorer, FileTreeItem, CodeEditor, etc.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Common image extensions */
|
||||||
|
export const IMAGE_EXTS = new Set([
|
||||||
|
'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico',
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract file extension from a path (without dot).
|
||||||
|
* Returns '' if no extension found.
|
||||||
|
*/
|
||||||
|
export function getFileExtension(filepath) {
|
||||||
|
if (!filepath) return ''
|
||||||
|
const parts = filepath.split('.')
|
||||||
|
return parts.length > 1 ? parts.pop().toLowerCase() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if a path has an image extension */
|
||||||
|
export function isImageFile(filepath) {
|
||||||
|
return IMAGE_EXTS.has(getFileExtension(filepath))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File icon color based on extension.
|
||||||
|
* Returns a CSS color string.
|
||||||
|
*/
|
||||||
|
export function getFileIconColor(extension) {
|
||||||
|
if (!extension) return 'var(--text-tertiary)'
|
||||||
|
const colorMap = {
|
||||||
|
py: '#3572A5', js: '#f1e05a', ts: '#3178c6', vue: '#41b883',
|
||||||
|
html: '#e34c26', css: '#563d7c', json: '#292929', md: '#083fa1',
|
||||||
|
yml: '#cb171e', yaml: '#cb171e', toml: '#9c4221', sql: '#e38c00',
|
||||||
|
sh: '#89e051', java: '#b07219', go: '#00ADD8', rs: '#dea584',
|
||||||
|
}
|
||||||
|
return colorMap[extension.toLowerCase()] || 'var(--text-tertiary)'
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
/**
|
||||||
|
* Centralized SVG icon strings.
|
||||||
|
* Usage: import { icons } from '../utils/icons'
|
||||||
|
* <span v-html="icons.close" />
|
||||||
|
* Props like size/stroke are set via CSS on the wrapper.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const s = (size, paths) =>
|
||||||
|
`<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">${paths}</svg>`
|
||||||
|
|
||||||
|
const S = 14
|
||||||
|
const SM = 12
|
||||||
|
const M = 16
|
||||||
|
|
||||||
|
export const icons = {
|
||||||
|
// -- Navigation --
|
||||||
|
chevronDown: s(10, '<polyline points="6 9 12 15 18 9"/>'),
|
||||||
|
chevronRight: s(10, '<polyline points="9 18 15 12 9 6"/>'),
|
||||||
|
|
||||||
|
// -- Actions --
|
||||||
|
close: s(S, '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>'),
|
||||||
|
closeMd: s(M, '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>'),
|
||||||
|
plus: s(SM, '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>'),
|
||||||
|
trash: s(S, '<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>'),
|
||||||
|
trashSm: s(SM, '<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>'),
|
||||||
|
edit: s(S, '<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>'),
|
||||||
|
copy: s(S, '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>'),
|
||||||
|
check: s(S, '<polyline points="20 6 9 17 4 12"/>'),
|
||||||
|
regenerate: s(S, '<path d="M1 4v6h6"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>'),
|
||||||
|
|
||||||
|
// -- Files & folders --
|
||||||
|
file: s(S, '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>'),
|
||||||
|
fileLg: s(40, '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>'),
|
||||||
|
fileNew: s(S, '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="12" y1="11" x2="12" y2="17"/><line x1="9" y1="14" x2="15" y2="14"/>'),
|
||||||
|
folder: s(S, '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>'),
|
||||||
|
folderLg: s(48, '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>'),
|
||||||
|
folderNew: s(S, '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/><line x1="12" y1="11" x2="12" y2="17"/><line x1="9" y1="14" x2="15" y2="14"/>'),
|
||||||
|
save: s(12, '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>'),
|
||||||
|
|
||||||
|
// -- Tools --
|
||||||
|
wrench: s(S, '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>'),
|
||||||
|
settings: s(M, '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>'),
|
||||||
|
|
||||||
|
// -- UI --
|
||||||
|
spinner: s(S, '<path d="M21 12a9 9 0 1 1-6.219-8.56"/>'),
|
||||||
|
spinnerMd: s(20, '<path d="M21 12a9 9 0 1 1-6.219-8.56"/>'),
|
||||||
|
lightbulb: s(S, '<path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>'),
|
||||||
|
send: s(18, '<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>'),
|
||||||
|
upload: s(18, '<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>'),
|
||||||
|
stats: s(M, '<path d="M18 20V10"/><path d="M12 20V4"/><path d="M6 20v-6"/>'),
|
||||||
|
chat: s(15, '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>'),
|
||||||
|
trendUp: s(16, '<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>'),
|
||||||
|
|
||||||
|
// -- Status --
|
||||||
|
error: s(S, '<circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>'),
|
||||||
|
info: s(S, '<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>'),
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue