diff --git a/internal/app/app.go b/internal/app/app.go index 7bac9b08..d845c48d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -846,6 +846,7 @@ func setupRoutes( protected.GET("/chat-uploads/download", chatUploadsHandler.Download) protected.GET("/chat-uploads/content", chatUploadsHandler.GetContent) protected.POST("/chat-uploads", chatUploadsHandler.Upload) + protected.POST("/chat-uploads/mkdir", chatUploadsHandler.Mkdir) protected.DELETE("/chat-uploads", chatUploadsHandler.Delete) protected.PUT("/chat-uploads/rename", chatUploadsHandler.Rename) protected.PUT("/chat-uploads/content", chatUploadsHandler.PutContent) diff --git a/internal/handler/chat_uploads.go b/internal/handler/chat_uploads.go index af26654b..4eacab2f 100644 --- a/internal/handler/chat_uploads.go +++ b/internal/handler/chat_uploads.go @@ -205,6 +205,77 @@ func (h *ChatUploadsHandler) Delete(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) } +type chatUploadMkdirBody struct { + Parent string `json:"parent"` + Name string `json:"name"` +} + +// Mkdir POST /api/chat-uploads/mkdir — 在 parent 目录下新建子目录(parent 为 chat_uploads 下相对路径,空表示根目录;name 为单段目录名) +func (h *ChatUploadsHandler) Mkdir(c *gin.Context) { + var body chatUploadMkdirBody + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"}) + return + } + name := strings.TrimSpace(body.Name) + if name == "" || strings.ContainsAny(name, `/\`) || name == "." || name == ".." { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"}) + return + } + if utf8.RuneCountInString(name) > 200 { + c.JSON(http.StatusBadRequest, gin.H{"error": "name too long"}) + return + } + + parent := strings.TrimSpace(body.Parent) + parent = filepath.ToSlash(filepath.Clean(filepath.FromSlash(parent))) + parent = strings.Trim(parent, "/") + if parent == "." { + parent = "" + } + + root, err := h.absRoot() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if parent != "" { + absParent, err := h.resolveUnderChatUploads(parent) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + st, err := os.Stat(absParent) + if err != nil || !st.IsDir() { + c.JSON(http.StatusBadRequest, gin.H{"error": "parent not found"}) + return + } + } + + var rel string + if parent == "" { + rel = name + } else { + rel = parent + "/" + name + } + absNew, err := h.resolveUnderChatUploads(rel) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if _, err := os.Stat(absNew); err == nil { + c.JSON(http.StatusConflict, gin.H{"error": "already exists"}) + return + } + if err := os.Mkdir(absNew, 0755); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + relOut, _ := filepath.Rel(root, absNew) + c.JSON(http.StatusOK, gin.H{"ok": true, "relativePath": filepath.ToSlash(relOut)}) +} + type chatUploadRenameBody struct { Path string `json:"path"` NewName string `json:"newName"` diff --git a/web/static/css/style.css b/web/static/css/style.css index ee75069b..da63d94c 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -13330,6 +13330,86 @@ button.chat-files-dropdown-item:hover:not(:disabled) { width: 100%; } +/* 新建文件夹弹窗:层次清晰、留白舒适,无强装饰 */ +.chat-files-mkdir-modal-content { + max-width: 480px; +} + +.chat-files-mkdir-body { + padding: 26px 28px 28px; +} + +.chat-files-mkdir-location { + margin: 0 0 22px; +} + +.chat-files-mkdir-location-caption { + font-size: 0.8125rem; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 8px; + letter-spacing: 0.01em; +} + +.chat-files-mkdir-path-box { + padding: 11px 14px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; +} + +.chat-files-mkdir-path { + display: block; + width: 100%; + margin: 0; + padding: 0; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 0.8125rem; + line-height: 1.55; + color: var(--text-primary); + word-break: break-all; + background: transparent; + border: none; +} + +.chat-files-mkdir-label { + gap: 10px; +} + +.chat-files-mkdir-field-name { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 0.875rem; + font-weight: 500; + color: var(--text-primary); +} + +.chat-files-mkdir-field-icon { + flex-shrink: 0; + color: var(--text-secondary); + opacity: 0.9; +} + +.chat-files-mkdir-input { + min-height: 40px; + padding: 9px 12px; + font-size: 0.875rem; + border-radius: 8px; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.chat-files-mkdir-input:focus { + outline: none; + border-color: var(--accent-color); + box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.14); +} + +.chat-files-mkdir-footer { + padding: 18px 28px 22px; + gap: 12px; +} + .chat-files-toast { position: fixed; z-index: 1100; diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 2d4b6f0a..8359f4c7 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -1021,6 +1021,14 @@ "confirmDeleteFolder": "Delete this folder and everything inside it? This cannot be undone.", "deleteFolderTitle": "Delete folder", "uploadToFolderTitle": "Upload file into this folder", + "newFolderButton": "New folder", + "newFolderTitle": "New folder", + "newFolderLocation": "Location", + "newFolderNameLabel": "Folder name", + "newFolderNamePlaceholder": "Name only, no slashes", + "mkdirOk": "Folder created", + "mkdirExists": "A file or folder with that name already exists", + "mkdirInvalidName": "Invalid name: cannot be empty or contain /, \\, or use . or ..", "colSubPath": "Subfolder", "folderRoot": "(root)", "groupCount": "{{count}} files", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 74cd4154..99ac6893 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -1021,6 +1021,14 @@ "confirmDeleteFolder": "确定删除该文件夹及其中的全部文件?此操作不可恢复。", "deleteFolderTitle": "删除文件夹", "uploadToFolderTitle": "上传文件到此文件夹", + "newFolderButton": "新建文件夹", + "newFolderTitle": "新建文件夹", + "newFolderLocation": "位置", + "newFolderNameLabel": "文件夹名称", + "newFolderNamePlaceholder": "仅名称,不含 /", + "mkdirOk": "文件夹已创建", + "mkdirExists": "该名称已存在", + "mkdirInvalidName": "名称无效:不能为空,且不能包含 /、\\ 或 . / ..", "colSubPath": "子路径", "folderRoot": "(根目录)", "groupCount": "{{count}} 个文件", diff --git a/web/static/js/chat-files.js b/web/static/js/chat-files.js index c6e75717..0b6979e2 100644 --- a/web/static/js/chat-files.js +++ b/web/static/js/chat-files.js @@ -13,6 +13,98 @@ let chatFilesBrowsePath = []; /** 非空时,下一次上传文件落到此相对路径(chat_uploads 下目录),如 2026-03-21/uuid/sub */ let chatFilesPendingUploadDir = ''; +/** 仅前端记录的「空目录」键 parentPath('' 表示 chat_uploads 根)-> 子目录名列表,与树合并以便 mkdir 后可见 */ +const CHAT_FILES_SYNTHETIC_DIRS_KEY = 'csai_chat_files_synthetic_dirs'; +let chatFilesSyntheticEmptyDirs = {}; + +function chatFilesLoadSyntheticDirsFromStorage() { + try { + const raw = localStorage.getItem(CHAT_FILES_SYNTHETIC_DIRS_KEY); + if (!raw) return; + const o = JSON.parse(raw); + if (o && typeof o === 'object') { + chatFilesSyntheticEmptyDirs = o; + } + } catch (e) { + chatFilesSyntheticEmptyDirs = {}; + } +} + +function chatFilesRegisterSyntheticEmptyDir(parentSegments, name) { + const p = parentSegments.join('/'); + if (!chatFilesSyntheticEmptyDirs[p]) { + chatFilesSyntheticEmptyDirs[p] = []; + } + const arr = chatFilesSyntheticEmptyDirs[p]; + if (arr.indexOf(name) === -1) { + arr.push(name); + } + try { + localStorage.setItem(CHAT_FILES_SYNTHETIC_DIRS_KEY, JSON.stringify(chatFilesSyntheticEmptyDirs)); + } catch (e) { + /* ignore */ + } +} + +function chatFilesRemoveSyntheticDirSubtree(relPathUnderRoot) { + const rel = String(relPathUnderRoot || '').replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, ''); + if (!rel) return; + const parts = rel.split('/').filter(function (x) { + return x.length > 0; + }); + if (parts.length === 0) return; + const leaf = parts[parts.length - 1]; + const parentKey = parts.slice(0, -1).join('/'); + const arr = chatFilesSyntheticEmptyDirs[parentKey]; + if (arr) { + const ix = arr.indexOf(leaf); + if (ix >= 0) arr.splice(ix, 1); + if (arr.length === 0) delete chatFilesSyntheticEmptyDirs[parentKey]; + } + const prefix = rel + '/'; + let k; + for (k in chatFilesSyntheticEmptyDirs) { + if (!Object.prototype.hasOwnProperty.call(chatFilesSyntheticEmptyDirs, k)) continue; + if (k === rel || k.indexOf(prefix) === 0) { + delete chatFilesSyntheticEmptyDirs[k]; + } + } + try { + localStorage.setItem(CHAT_FILES_SYNTHETIC_DIRS_KEY, JSON.stringify(chatFilesSyntheticEmptyDirs)); + } catch (e) { + /* ignore */ + } +} + +function chatFilesMergeSyntheticDirsIntoTree(root) { + function ensurePath(node, segments) { + let n = node; + let i; + for (i = 0; i < segments.length; i++) { + const s = segments[i]; + if (!n.dirs[s]) n.dirs[s] = chatFilesTreeMakeNode(); + n = n.dirs[s]; + } + return n; + } + let k; + for (k in chatFilesSyntheticEmptyDirs) { + if (!Object.prototype.hasOwnProperty.call(chatFilesSyntheticEmptyDirs, k)) continue; + const names = chatFilesSyntheticEmptyDirs[k]; + if (!Array.isArray(names)) continue; + const segs = k ? k.split('/').filter(function (x) { + return x.length > 0; + }) : []; + const node = ensurePath(root, segs); + let ni; + for (ni = 0; ni < names.length; ni++) { + const nm = names[ni]; + if (!nm || typeof nm !== 'string') continue; + if (!node.dirs[nm]) node.dirs[nm] = chatFilesTreeMakeNode(); + } + } +} + function chatFilesLoadBrowsePathFromStorage() { try { const raw = localStorage.getItem(CHAT_FILES_BROWSE_PATH_KEY); @@ -63,6 +155,7 @@ function chatFilesNormalizeBrowsePathForTree(root) { function initChatFilesPage() { chatFilesLoadBrowsePathFromStorage(); + chatFilesLoadSyntheticDirsFromStorage(); ensureChatFilesDocClickClose(); const sel = document.getElementById('chat-files-group-by'); if (sel) { @@ -368,6 +461,12 @@ function chatFilesBuildTree(files) { return root; } +function chatFilesTreeRootMerged() { + const root = chatFilesBuildTree(chatFilesDisplayed); + chatFilesMergeSyntheticDirsIntoTree(root); + return root; +} + function chatFilesTreeNodeMaxMod(node) { let m = 0; let i; @@ -570,7 +669,7 @@ function renderChatFilesTable() { let innerHtml; if (groupMode === 'folder') { - const root = chatFilesBuildTree(chatFilesDisplayed); + const root = chatFilesTreeRootMerged(); chatFilesNormalizeBrowsePathForTree(root); const node = chatFilesResolveTreeNode(root, chatFilesBrowsePath); const current = node || root; @@ -582,6 +681,7 @@ function renderChatFilesTable() { const tRoot = escapeHtml((typeof window.t === 'function') ? window.t('chatFilesPage.browseRoot') : 'chat_uploads'); const tUp = escapeHtml((typeof window.t === 'function') ? window.t('chatFilesPage.browseUp') : '上级'); + const tMkdir = escapeHtml((typeof window.t === 'function') ? window.t('chatFilesPage.newFolderButton') : '新建文件夹'); const tEmpty = escapeHtml((typeof window.t === 'function') ? window.t('chatFilesPage.folderEmpty') : '此文件夹为空'); const tCopyFolder = escapeHtml((typeof window.t === 'function') ? window.t('chatFilesPage.copyFolderPathTitle') : '复制 chat_uploads 下相对路径'); const tEnter = escapeHtml((typeof window.t === 'function') ? window.t('chatFilesPage.enterFolderTitle') : '进入'); @@ -603,6 +703,7 @@ function renderChatFilesTable() { const upDisabled = chatFilesBrowsePath.length === 0 ? ' disabled' : ''; const toolbarHtml = '
'; const svgTrash = ''; @@ -735,7 +836,7 @@ function renderChatFilesTable() { window.chatFilesGroupByChange = chatFilesGroupByChange; function chatFilesNavigateInto(name) { - const root = chatFilesBuildTree(chatFilesDisplayed); + const root = chatFilesTreeRootMerged(); chatFilesNormalizeBrowsePathForTree(root); const next = chatFilesBrowsePath.concat([name]); if (!chatFilesResolveTreeNode(root, next)) return; @@ -744,7 +845,7 @@ function chatFilesNavigateInto(name) { } function chatFilesNavigateBreadcrumb(level) { - const root = chatFilesBuildTree(chatFilesDisplayed); + const root = chatFilesTreeRootMerged(); chatFilesNormalizeBrowsePathForTree(root); if (level < 0) { chatFilesSetBrowsePath([]); @@ -805,6 +906,7 @@ async function deleteChatFolderFromBrowse(folderName) { if (!res.ok) { throw new Error(await res.text()); } + chatFilesRemoveSyntheticDirSubtree(rel); loadChatFilesPage(); } catch (e) { alert((e && e.message) ? e.message : String(e)); @@ -842,6 +944,9 @@ window.chatFilesCopyFolderPathFromBtn = chatFilesCopyFolderPathFromBtn; window.chatFilesDeleteFolderFromBtn = chatFilesDeleteFolderFromBtn; window.chatFilesOpenUploadPicker = chatFilesOpenUploadPicker; window.chatFilesUploadToFolderClick = chatFilesUploadToFolderClick; +window.openChatFilesMkdirModal = openChatFilesMkdirModal; +window.closeChatFilesMkdirModal = closeChatFilesMkdirModal; +window.submitChatFilesMkdir = submitChatFilesMkdir; function openChatFilesConversationIdx(idx) { const f = chatFilesDisplayed[idx]; @@ -1018,8 +1123,86 @@ async function submitChatFilesRename() { } } +function openChatFilesMkdirModal() { + if (chatFilesGetGroupByMode() !== 'folder') return; + const hint = document.getElementById('chat-files-mkdir-parent-hint'); + const input = document.getElementById('chat-files-mkdir-input'); + const modal = document.getElementById('chat-files-mkdir-modal'); + const p = chatFilesBrowsePath.join('/'); + if (hint) hint.textContent = p ? ('chat_uploads/' + p) : 'chat_uploads'; + if (input) input.value = ''; + if (modal) modal.style.display = 'block'; + if (modal && typeof window.applyTranslations === 'function') { + window.applyTranslations(modal); + } + setTimeout(() => { + if (input) input.focus(); + }, 100); +} + +function closeChatFilesMkdirModal() { + const modal = document.getElementById('chat-files-mkdir-modal'); + if (modal) modal.style.display = 'none'; + const input = document.getElementById('chat-files-mkdir-input'); + if (input) input.value = ''; +} + +async function submitChatFilesMkdir() { + const input = document.getElementById('chat-files-mkdir-input'); + const name = input ? String(input.value).trim() : ''; + if (!name) { + closeChatFilesMkdirModal(); + return; + } + if (name.includes('/') || name.includes('\\') || name === '.' || name === '..') { + const msg = (typeof window.t === 'function') + ? window.t('chatFilesPage.mkdirInvalidName') + : '名称无效'; + alert(msg); + return; + } + const parent = chatFilesBrowsePath.join('/'); + try { + const res = await apiFetch('/api/chat-uploads/mkdir', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ parent: parent, name: name }) + }); + if (!res.ok) { + let errText = ''; + try { + const j = await res.json(); + errText = j.error || JSON.stringify(j); + } catch (e2) { + errText = await res.text(); + } + if (res.status === 409) { + const msg = (typeof window.t === 'function') + ? window.t('chatFilesPage.mkdirExists') + : errText; + alert(msg); + return; + } + throw new Error(errText || String(res.status)); + } + chatFilesRegisterSyntheticEmptyDir(chatFilesBrowsePath.slice(), name); + closeChatFilesMkdirModal(); + loadChatFilesPage(); + const okMsg = (typeof window.t === 'function') + ? window.t('chatFilesPage.mkdirOk') + : '文件夹已创建'; + chatFilesShowToast(okMsg); + } catch (e) { + alert((e && e.message) ? e.message : String(e)); + } +} + function chatFilesOpenUploadPicker() { - chatFilesPendingUploadDir = ''; + if (chatFilesGetGroupByMode() === 'folder') { + chatFilesPendingUploadDir = chatFilesBrowsePath.join('/'); + } else { + chatFilesPendingUploadDir = ''; + } const inp = document.getElementById('chat-files-upload-input'); if (inp) inp.click(); } diff --git a/web/templates/index.html b/web/templates/index.html index 417e135d..8c0ed92b 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -1818,6 +1818,34 @@ +