Add files via upload

This commit is contained in:
公明
2026-03-21 22:38:48 +08:00
committed by GitHub
parent 2545774187
commit 87a2eb9e97
7 changed files with 383 additions and 4 deletions
+1
View File
@@ -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)
+71
View File
@@ -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"`
+80
View File
@@ -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;
+8
View File
@@ -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",
+8
View File
@@ -1021,6 +1021,14 @@
"confirmDeleteFolder": "确定删除该文件夹及其中的全部文件?此操作不可恢复。",
"deleteFolderTitle": "删除文件夹",
"uploadToFolderTitle": "上传文件到此文件夹",
"newFolderButton": "新建文件夹",
"newFolderTitle": "新建文件夹",
"newFolderLocation": "位置",
"newFolderNameLabel": "文件夹名称",
"newFolderNamePlaceholder": "仅名称,不含 /",
"mkdirOk": "文件夹已创建",
"mkdirExists": "该名称已存在",
"mkdirInvalidName": "名称无效:不能为空,且不能包含 /、\\ 或 . / ..",
"colSubPath": "子路径",
"folderRoot": "(根目录)",
"groupCount": "{{count}} 个文件",
+187 -4
View File
@@ -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 = '<div class="chat-files-browse-toolbar">' + breadcrumbHtml +
'<button type="button" class="btn-secondary chat-files-mkdir-btn" onclick="openChatFilesMkdirModal()">' + tMkdir + '</button>' +
'<button type="button" class="btn-secondary chat-files-browse-up"' + upDisabled + ' onclick="chatFilesNavigateUp()">' + tUp + '</button></div>';
const svgTrash = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><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>';
@@ -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();
}
+28
View File
@@ -1818,6 +1818,34 @@
</div>
</div>
<div id="chat-files-mkdir-modal" class="modal">
<div class="modal-content chat-files-mkdir-modal-content">
<div class="modal-header">
<h2 data-i18n="chatFilesPage.newFolderTitle">新建文件夹</h2>
<span class="modal-close" onclick="closeChatFilesMkdirModal()">&times;</span>
</div>
<div class="modal-body chat-files-mkdir-body">
<div class="chat-files-mkdir-location" aria-live="polite">
<div class="chat-files-mkdir-location-caption" data-i18n="chatFilesPage.newFolderLocation">位置</div>
<div class="chat-files-mkdir-path-box">
<code class="chat-files-mkdir-path" id="chat-files-mkdir-parent-hint">chat_uploads</code>
</div>
</div>
<label class="chat-files-rename-label chat-files-mkdir-label">
<span class="chat-files-mkdir-field-name">
<svg class="chat-files-mkdir-field-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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 data-i18n="chatFilesPage.newFolderNameLabel">文件夹名称</span>
</span>
<input type="text" id="chat-files-mkdir-input" class="form-control chat-files-mkdir-input" data-i18n="chatFilesPage.newFolderNamePlaceholder" data-i18n-attr="placeholder" placeholder="仅名称,不含 /" autocomplete="off" />
</label>
</div>
<div class="modal-footer chat-files-mkdir-footer">
<button type="button" class="btn-secondary chat-files-mkdir-btn-cancel" onclick="closeChatFilesMkdirModal()" data-i18n="common.cancel">取消</button>
<button type="button" class="btn-primary chat-files-mkdir-btn-submit" onclick="submitChatFilesMkdir()" data-i18n="common.ok">确定</button>
</div>
</div>
</div>
<!-- Marked.js for Markdown parsing -->
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
<!-- DOMPurify for HTML sanitization to prevent XSS -->