feat: 优化文件编辑部分UI

This commit is contained in:
ViperEkura 2026-03-27 11:54:51 +08:00
parent ea425cf9a6
commit f25eb4ecfc
16 changed files with 641 additions and 298 deletions

View File

@ -31,9 +31,7 @@
/>
</div>
<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;">
<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.folderLg" style="color: var(--text-tertiary); opacity: 0.5;" />
<p>当前对话未关联项目</p>
</div>
</div>
@ -82,7 +80,9 @@
<div class="create-modal">
<div class="modal-header">
<h3>创建项目</h3>
<CloseButton @click="showCreateModal = false" />
<button class="btn-close" @click="showCreateModal = false">
<span v-html="icons.closeMd" />
</button>
</div>
<div class="modal-body">
<div class="form-group">
@ -103,6 +103,8 @@
</div>
</div>
</div>
<ModalDialog />
<ToastContainer />
</template>
<script setup>
@ -110,12 +112,17 @@ import { ref, shallowRef, computed, onMounted, defineAsyncComponent } from 'vue'
import Sidebar from './components/Sidebar.vue'
import ChatView from './components/ChatView.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 StatsPanel = defineAsyncComponent(() => import('./components/StatsPanel.vue'))
import { conversationApi, messageApi, projectApi } from './api'
const modal = useModal()
// -- Conversations state --
const conversations = shallowRef([])
const currentConvId = ref(null)
@ -521,7 +528,8 @@ async function loadProjects() {
// -- Delete project --
async function deleteProject(project) {
if (!confirm(`确定删除项目「${project.name}」及其所有对话?`)) return
const ok = await modal.confirm('删除确认', `确定删除项目「${project.name}」及其所有对话?`, { danger: true })
if (!ok) return
try {
await projectApi.delete(project.id)
// Remove conversations belonging to this project

View File

@ -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>

View File

@ -3,33 +3,20 @@
<!-- File tree sidebar -->
<div class="explorer-sidebar">
<div class="explorer-header">
<svg width="14" height="14" 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" />
<span class="explorer-title">{{ projectName }}</span>
<div class="explorer-actions">
<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">
<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>
<span v-html="icons.fileNew" />
</button>
<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">
<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>
<span v-html="icons.folderNew" />
</button>
</div>
</div>
<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">
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>
<span class="spinner" v-html="icons.spinnerMd" />
</div>
<div v-else-if="treeItems.length === 0" class="explorer-empty">
@ -46,6 +33,7 @@
:project-id="projectId"
@select="openFile"
@refresh="loadTree"
@move="moveItem"
/>
</div>
</div>
@ -61,18 +49,13 @@
</div>
<div class="viewer-actions">
<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">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
<span v-html="icons.close" />
</button>
</div>
</div>
<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">
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>
<span class="spinner" v-html="icons.spinnerMd" />
加载中...
</div>
@ -98,13 +81,7 @@
<!-- Empty state -->
<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;">
<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 v-html="icons.fileLg" style="color: var(--text-tertiary); opacity: 0.5;" />
<span>选择文件以预览</span>
</div>
</div>
@ -117,8 +94,10 @@ import FileTreeItem from './FileTreeItem.vue'
import CodeEditor from './CodeEditor.vue'
import { normalizeFileTree } from '../utils/fileTree'
import { useTheme } from '../composables/useTheme'
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico'])
import { useModal } from '../composables/useModal'
import { useToast } from '../composables/useToast'
import { icons } from '../utils/icons'
import { getFileExtension, isImageFile } from '../utils/fileUtils'
const props = defineProps({
projectId: { type: String, required: true },
@ -126,6 +105,8 @@ const props = defineProps({
})
const { isDark } = useTheme()
const modal = useModal()
const toast = useToast()
// -- Tree state --
const treeItems = ref([])
@ -146,21 +127,14 @@ function releaseImageUrl() {
}
}
const fileExt = computed(() => {
if (!activeFile.value) return ''
const parts = activeFile.value.split('.')
return parts.length > 1 ? parts.pop().toLowerCase() : ''
})
const fileExt = computed(() => getFileExtension(activeFile.value))
const breadcrumbSegments = computed(() => {
if (!activeFile.value) return []
return activeFile.value.split('/')
})
const fileType = computed(() => {
if (IMAGE_EXTS.has(fileExt.value)) return 'image'
return 'text'
})
const fileType = computed(() => isImageFile(activeFile.value) ? 'image' : 'text')
async function loadTree(path = '') {
loadingTree.value = true
@ -182,7 +156,7 @@ async function openFile(filepath) {
loadingFile.value = true
const ext = filepath.split('.').pop().toLowerCase()
if (IMAGE_EXTS.has(ext)) {
if (isImageFile(filepath)) {
try {
const res = await projectApi.readFileRaw(props.projectId, filepath)
const blob = await res.blob()
@ -210,47 +184,54 @@ async function saveFile() {
saving.value = true
try {
await projectApi.writeFile(props.projectId, activeFile.value, editContent.value)
toast.success('已保存')
} catch (e) {
alert('保存失败: ' + e.message)
toast.error('保存失败: ' + e.message)
} finally {
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() {
const name = prompt('文件名(例如 utils.py')
if (!name?.trim()) return
const path = name.trim()
const name = await modal.prompt('新建文件', '请输入文件名(例如 utils.py')
if (!name) return
try {
await projectApi.writeFile(props.projectId, path, '')
await projectApi.writeFile(props.projectId, name, '')
toast.success(`已创建「${name}`)
await loadTree()
openFile(path)
openFile(name)
} catch (e) {
alert('创建失败: ' + e.message)
toast.error('创建失败: ' + e.message)
}
}
async function createNewFolder() {
const name = prompt('文件夹名称')
if (!name?.trim()) return
const name = await modal.prompt('新建文件夹', '请输入文件夹名称')
if (!name) return
try {
await projectApi.mkdir(props.projectId, name.trim())
await projectApi.mkdir(props.projectId, name)
toast.success(`已创建文件夹「${name}`)
await loadTree()
} 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;
}
.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 {
flex: 1;
overflow-y: auto;

View File

@ -1,26 +1,35 @@
<template>
<div class="tree-node" @mouseenter="hovered = true" @mouseleave="onMouseLeave">
<div class="tree-item" :class="{ active: isActive }" @click="onClick">
<div
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 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">
<polyline points="9 18 15 12 9 6"/>
</svg>
<span v-html="icons.chevronRight" />
</span>
<span v-else class="tree-arrow-placeholder"></span>
<!-- File icon -->
<span v-if="item.type === 'file'" class="tree-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" :stroke="iconColor" 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-if="item.type === 'file'" class="tree-icon" :style="{ color: iconColor }">
<span v-html="icons.file" />
</span>
<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">
<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" />
</span>
<!-- Rename input or name -->
@ -41,35 +50,27 @@
<!-- Folder: new file/folder dropdown -->
<div v-if="item.type === 'dir'" class="tree-action-dropdown">
<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">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
<span v-html="icons.plus" />
</button>
<!-- Dropdown menu -->
<div v-if="showCreateMenu" class="tree-create-menu" @mouseenter="hovered = true" @mouseleave="onMouseLeave">
<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 @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>
</div>
</div>
<!-- Rename -->
<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">
<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>
<span v-html="icons.edit" />
</button>
<!-- Delete -->
<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">
<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>
<span v-html="icons.trash" />
</button>
</div>
</div>
@ -77,9 +78,7 @@
<!-- Children -->
<div v-if="item.type === 'dir' && expanded" class="tree-children">
<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">
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>
<span class="spinner" v-html="icons.spinner" />
</div>
<FileTreeItem
v-for="child in item.children"
@ -90,6 +89,7 @@
:project-id="projectId"
@select="$emit('select', $event)"
@refresh="$emit('refresh')"
@move="$emit('move', $event)"
/>
</div>
</template>
@ -98,6 +98,10 @@
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
import { projectApi } from '../api'
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({
item: { type: Object, required: true },
@ -106,7 +110,10 @@ const props = defineProps({
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 loading = ref(false)
@ -119,14 +126,8 @@ const renameInput = ref(null)
const isActive = computed(() => props.activePath === props.item.path)
const iconColor = computed(() => {
const ext = props.item.extension?.toLowerCase() || ''
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[ext] || 'var(--text-tertiary)'
const ext = props.item.extension?.replace(/^\./, '').toLowerCase() || ''
return getFileIconColor(ext)
})
function onMouseLeave() {
@ -186,9 +187,10 @@ async function confirmRename() {
try {
await projectApi.renameFile(props.projectId, props.item.path, newPath)
toast.success(`已重命名为「${newName}`)
emit('refresh')
} catch (e) {
alert('重命名失败: ' + e.message)
toast.error('重命名失败: ' + e.message)
}
}
@ -199,42 +201,102 @@ function cancelRename() {
// -- Delete --
async function deleteItem() {
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 {
await projectApi.deleteFile(props.projectId, props.item.path)
toast.success(`已删除「${props.item.name}`)
emit('refresh')
} catch (e) {
alert('删除失败: ' + e.message)
toast.error('删除失败: ' + e.message)
}
}
// -- Create (in folder) --
async function createNewFile() {
showCreateMenu.value = false
const name = prompt('文件名(例如 utils.py')
if (!name?.trim()) return
const path = props.item.path ? `${props.item.path}/${name.trim()}` : name.trim()
const name = await modal.prompt('新建文件', '请输入文件名(例如 utils.py')
if (!name) return
const path = props.item.path ? `${props.item.path}/${name}` : name
try {
await projectApi.writeFile(props.projectId, path, '')
toast.success(`已创建「${name}`)
emit('refresh')
} catch (e) {
alert('创建失败: ' + e.message)
toast.error('创建失败: ' + e.message)
}
}
async function createNewFolder() {
showCreateMenu.value = false
const name = prompt('文件夹名称')
if (!name?.trim()) return
const path = props.item.path ? `${props.item.path}/${name.trim()}` : name.trim()
const name = await modal.prompt('新建文件夹', '请输入文件夹名称')
if (!name) return
const path = props.item.path ? `${props.item.path}/${name}` : name
try {
await projectApi.mkdir(props.projectId, path)
toast.success(`已创建文件夹「${name}`)
emit('refresh')
} 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>
<style scoped>
@ -341,31 +403,6 @@ async function createNewFolder() {
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 -- */
.tree-action-dropdown {
position: relative;
@ -411,12 +448,24 @@ async function createNewFolder() {
}
/* -- Children & loading -- */
.tree-children {
/* no additional styles needed */
}
.tree-loading {
padding: 4px 0 4px 40px;
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>

View File

@ -31,22 +31,13 @@
<span class="token-count" v-if="tokenCount">{{ tokenCount }} tokens</span>
<span class="message-time">{{ formatTime(createdAt) }}</span>
<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">
<path d="M1 4v6h6"/>
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>
</svg>
<span v-html="icons.regenerate" />
</button>
<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">
<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>
<span v-html="icons.copy" />
</button>
<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">
<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>
<span v-html="icons.trash" />
</button>
</div>
</div>
@ -58,6 +49,7 @@ import { computed, ref } from 'vue'
import { renderMarkdown } from '../utils/markdown'
import { formatTime } from '../utils/format'
import { useCodeEnhancement } from '../composables/useCodeEnhancement'
import { icons } from '../utils/icons'
import ProcessBlock from './ProcessBlock.vue'
const props = defineProps({

View File

@ -6,10 +6,7 @@
<span class="file-icon">{{ getFileIcon(file.extension) }}</span>
<span class="file-name">{{ file.name }}</span>
<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">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
<span v-html="icons.close" />
</button>
</div>
</div>
@ -39,9 +36,7 @@
@click="toggleTools"
:title="toolsEnabled ? '工具调用: 已开启' : '工具调用: 已关闭'"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
<span v-html="icons.wrench" />
</button>
<button
class="btn-upload"
@ -49,9 +44,7 @@
@click="triggerFileUpload"
title="上传文件"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
<span v-html="icons.upload" />
</button>
<button
class="btn-send"
@ -59,10 +52,7 @@
:disabled="!canSend || disabled"
@click="send"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>
<span v-html="icons.send" />
</button>
</div>
</div>
@ -73,6 +63,7 @@
<script setup>
import { ref, computed, nextTick } from 'vue'
import { icons } from '../utils/icons'
const props = defineProps({
disabled: { type: Boolean, default: false },

View File

@ -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>

View File

@ -2,9 +2,7 @@
<div class="settings-panel">
<div class="settings-header">
<div class="settings-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
<span v-html="icons.settings" />
<h4>会话设置</h4>
</div>
<div class="header-actions">
@ -18,7 +16,9 @@
{{ t.label }}
</button>
</div>
<CloseButton @click="$emit('close')" />
<button class="btn-close" @click="$emit('close')">
<span v-html="icons.closeMd" />
</button>
</div>
</div>
@ -114,9 +114,7 @@
</template>
<div class="auto-save-hint">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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 v-html="icons.save" />
<span>修改自动保存</span>
</div>
</div>
@ -127,7 +125,7 @@
import { reactive, ref, watch, onMounted } from 'vue'
import { modelApi, conversationApi } from '../api'
import { useTheme } from '../composables/useTheme'
import CloseButton from './CloseButton.vue'
import { icons } from '../utils/icons'
const props = defineProps({
visible: { type: Boolean, default: false },

View File

@ -11,9 +11,7 @@
<!-- Project groups -->
<div v-for="group in groupedData.groups" :key="group.id" class="project-group">
<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">
<polyline points="6 9 12 15 18 9"/>
</svg>
<span class="chevron" :class="{ collapsed: !expandedGroups[group.id] }" v-html="icons.chevronDown" />
<span class="project-name">{{ group.name }}</span>
<span class="conv-count">{{ group.conversations.length }}</span>
<button
@ -21,29 +19,21 @@
title="新建对话"
@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">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
<span v-html="icons.plus" />
</button>
<button
class="btn-group-action"
title="浏览文件"
@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">
<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
class="btn-group-action btn-delete-project"
title="删除项目"
@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">
<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>
<span v-html="icons.trashSm" />
</button>
</div>
<div v-show="expandedGroups[group.id]">
@ -61,10 +51,7 @@
</div>
</div>
<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">
<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>
<span v-html="icons.trash" />
</button>
</div>
</div>
@ -73,22 +60,15 @@
<!-- Standalone conversations (always visible) -->
<div class="project-group">
<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">
<polyline points="6 9 12 15 18 9"/>
</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="chevron" :class="{ collapsed: !expandedGroups['__standalone__'] }" v-html="icons.chevronDown" />
<span class="standalone-icon" v-html="icons.chat" />
<span class="conv-count">{{ groupedData.standalone.length }}</span>
<button
class="btn-group-action"
title="新建对话"
@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">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
<span v-html="icons.plus" />
</button>
<span class="btn-placeholder"></span>
<span class="btn-placeholder"></span>
@ -108,10 +88,7 @@
</div>
</div>
<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">
<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>
<span v-html="icons.trash" />
</button>
</div>
</div>
@ -123,17 +100,10 @@
<div class="sidebar-footer">
<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">
<path d="M18 20V10"/>
<path d="M12 20V4"/>
<path d="M6 20v-6"/>
</svg>
<span v-html="icons.stats" />
</button>
<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">
<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>
<span v-html="icons.settings" />
</button>
</div>
</aside>
@ -142,6 +112,7 @@
<script setup>
import { computed, reactive } from 'vue'
import { formatTime } from '../utils/format'
import { icons } from '../utils/icons'
const props = defineProps({
conversations: { type: Array, required: true },

View File

@ -2,11 +2,7 @@
<div class="stats-panel">
<div class="stats-header">
<div class="stats-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 20V10"/>
<path d="M12 20V4"/>
<path d="M6 20v-6"/>
</svg>
<span v-html="icons.stats" />
<h4>使用统计</h4>
</div>
<div class="header-actions">
@ -20,14 +16,14 @@
{{ p.label }}
</button>
</div>
<CloseButton @click="$emit('close')" />
<button class="btn-close" @click="$emit('close')">
<span v-html="icons.closeMd" />
</button>
</div>
</div>
<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">
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>
<span class="spinner" v-html="icons.spinnerMd" />
加载中...
</div>
@ -36,10 +32,7 @@
<div class="stats-summary">
<div class="stat-card">
<div class="stat-icon input-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
<span v-html="icons.edit" />
</div>
<div class="stat-info">
<span class="stat-label">输入</span>
@ -48,12 +41,7 @@
</div>
<div class="stat-card">
<div class="stat-icon output-icon">
<svg width="16" height="16" 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"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
</svg>
<span v-html="icons.file" />
</div>
<div class="stat-info">
<span class="stat-label">输出</span>
@ -62,9 +50,7 @@
</div>
<div class="stat-card total">
<div class="stat-icon total-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</svg>
<span v-html="icons.trendUp" />
</div>
<div class="stat-info">
<span class="stat-label">总计</span>
@ -203,11 +189,7 @@
<!-- 空状态 -->
<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">
<path d="M18 20V10"/>
<path d="M12 20V4"/>
<path d="M6 20v-6"/>
</svg>
<span v-html="icons.stats" style="opacity: 0.5;" />
<span>暂无使用数据</span>
</div>
</template>
@ -218,7 +200,7 @@
import { ref, computed, onMounted } from 'vue'
import { statsApi } from '../api'
import { formatNumber } from '../utils/format'
import CloseButton from './CloseButton.vue'
import { icons } from '../utils/icons'
defineEmits(['close'])

View File

@ -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>

View File

@ -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 }
}

View File

@ -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),
}
}

View File

@ -319,21 +319,25 @@ body {
}
/* ============ Scrollbar ============ */
textarea::-webkit-scrollbar {
textarea::-webkit-scrollbar,
.cm-scroller::-webkit-scrollbar {
width: 6px;
height: 6px;
}
textarea::-webkit-scrollbar-track {
textarea::-webkit-scrollbar-track,
.cm-scroller::-webkit-scrollbar-track {
background: transparent;
}
textarea::-webkit-scrollbar-thumb {
textarea::-webkit-scrollbar-thumb,
.cm-scroller::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 3px;
}
textarea::-webkit-scrollbar-thumb:hover {
textarea::-webkit-scrollbar-thumb:hover,
.cm-scroller::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
}
@ -341,6 +345,11 @@ textarea::-webkit-resizer {
background: transparent;
}
.cm-scroller {
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) transparent;
}
/* ============ Range Slider ============ */
input[type="range"] {
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");
}
/* ============ 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);
}

View File

@ -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)'
}

View File

@ -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"/>'),
}