diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 01fa6ae..0ae2ba9 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -31,9 +31,7 @@ />
- - - +

当前对话未关联项目

@@ -82,7 +80,9 @@
+ + diff --git a/frontend/src/components/FileExplorer.vue b/frontend/src/components/FileExplorer.vue index 22473e0..589cd5b 100644 --- a/frontend/src/components/FileExplorer.vue +++ b/frontend/src/components/FileExplorer.vue @@ -3,33 +3,20 @@
- - - + {{ projectName }}
- - - +
@@ -46,6 +33,7 @@ :project-id="projectId" @select="openFile" @refresh="loadTree" + @move="moveItem" />
@@ -61,18 +49,13 @@
- - - + 加载中...
@@ -98,13 +81,7 @@
- - - - - - - + 选择文件以预览
@@ -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; diff --git a/frontend/src/components/FileTreeItem.vue b/frontend/src/components/FileTreeItem.vue index b158053..7068d35 100644 --- a/frontend/src/components/FileTreeItem.vue +++ b/frontend/src/components/FileTreeItem.vue @@ -1,26 +1,35 @@ @@ -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 (_) {} +} diff --git a/frontend/src/components/MessageBubble.vue b/frontend/src/components/MessageBubble.vue index ca7ba11..c1e7f82 100644 --- a/frontend/src/components/MessageBubble.vue +++ b/frontend/src/components/MessageBubble.vue @@ -31,22 +31,13 @@ {{ tokenCount }} tokens {{ formatTime(createdAt) }} @@ -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({ diff --git a/frontend/src/components/MessageInput.vue b/frontend/src/components/MessageInput.vue index b5e5645..f826c3f 100644 --- a/frontend/src/components/MessageInput.vue +++ b/frontend/src/components/MessageInput.vue @@ -6,10 +6,7 @@ {{ getFileIcon(file.extension) }} {{ file.name }} @@ -39,9 +36,7 @@ @click="toggleTools" :title="toolsEnabled ? '工具调用: 已开启' : '工具调用: 已关闭'" > - - - + @@ -73,6 +63,7 @@ + + diff --git a/frontend/src/components/SettingsPanel.vue b/frontend/src/components/SettingsPanel.vue index 7d56922..3d22625 100644 --- a/frontend/src/components/SettingsPanel.vue +++ b/frontend/src/components/SettingsPanel.vue @@ -2,9 +2,7 @@
- - - +

会话设置

@@ -18,7 +16,9 @@ {{ t.label }}
- +
@@ -114,9 +114,7 @@
- - - + 修改自动保存
@@ -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 }, diff --git a/frontend/src/components/Sidebar.vue b/frontend/src/components/Sidebar.vue index fe9d283..8f1be70 100644 --- a/frontend/src/components/Sidebar.vue +++ b/frontend/src/components/Sidebar.vue @@ -11,9 +11,7 @@
- - - + {{ group.name }} {{ group.conversations.length }}
@@ -61,10 +51,7 @@
@@ -73,22 +60,15 @@
- - - - - - + + {{ groupedData.standalone.length }} @@ -108,10 +88,7 @@
@@ -123,17 +100,10 @@ @@ -142,6 +112,7 @@ + + diff --git a/frontend/src/composables/useModal.js b/frontend/src/composables/useModal.js new file mode 100644 index 0000000..f440d08 --- /dev/null +++ b/frontend/src/composables/useModal.js @@ -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 } +} diff --git a/frontend/src/composables/useToast.js b/frontend/src/composables/useToast.js new file mode 100644 index 0000000..e380b04 --- /dev/null +++ b/frontend/src/composables/useToast.js @@ -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), + } +} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index f4f8366..c933622 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -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); +} + + diff --git a/frontend/src/utils/fileUtils.js b/frontend/src/utils/fileUtils.js new file mode 100644 index 0000000..b588fff --- /dev/null +++ b/frontend/src/utils/fileUtils.js @@ -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)' +} diff --git a/frontend/src/utils/icons.js b/frontend/src/utils/icons.js new file mode 100644 index 0000000..1543a26 --- /dev/null +++ b/frontend/src/utils/icons.js @@ -0,0 +1,57 @@ +/** + * Centralized SVG icon strings. + * Usage: import { icons } from '../utils/icons' + * + * Props like size/stroke are set via CSS on the wrapper. + */ + +const s = (size, paths) => + `${paths}` + +const S = 14 +const SM = 12 +const M = 16 + +export const icons = { + // -- Navigation -- + chevronDown: s(10, ''), + chevronRight: s(10, ''), + + // -- Actions -- + close: s(S, ''), + closeMd: s(M, ''), + plus: s(SM, ''), + trash: s(S, ''), + trashSm: s(SM, ''), + edit: s(S, ''), + copy: s(S, ''), + check: s(S, ''), + regenerate: s(S, ''), + + // -- Files & folders -- + file: s(S, ''), + fileLg: s(40, ''), + fileNew: s(S, ''), + folder: s(S, ''), + folderLg: s(48, ''), + folderNew: s(S, ''), + save: s(12, ''), + + // -- Tools -- + wrench: s(S, ''), + settings: s(M, ''), + + // -- UI -- + spinner: s(S, ''), + spinnerMd: s(20, ''), + lightbulb: s(S, ''), + send: s(18, ''), + upload: s(18, ''), + stats: s(M, ''), + chat: s(15, ''), + trendUp: s(16, ''), + + // -- Status -- + error: s(S, ''), + info: s(S, ''), +}