refactor(files): 下线独立 /file-manager 页面及其写操作端点

- 删除页面路由:/file-manager、/file-manager/editor、/file-preview/<path>
- 删除写端点:/api/gui/files/{create,delete,rename,copy,move,upload,download/batch}
- 保留主 SPA 共享端点:/api/files、entries、text、download、/api/project/files/search
- 拆除前端入口链:QuickMenu → InputComposer → App.vue → openGuiFileManager
- 删除 static/file_manager/ 目录(git 历史可恢复)
- sanitize_filename_preserve_unicode 迁至 server/utils_common.py,
  清理 5 个拆分文件的死 import,修复 api_v1/chat 上传的引用来源
- 安全收益:create/rename 路径穿越与 preview 存储型 XSS 随页面物理消解
This commit is contained in:
JOJO 2026-08-12 19:14:01 +08:00
parent 99f4d3d1dd
commit ff927d6739
20 changed files with 25 additions and 1805 deletions

View File

@ -12,7 +12,7 @@ from flask import Blueprint, request, jsonify, send_file, session
from .api_auth import api_token_required
from .tasks import task_manager
from .context import get_user_resources, ensure_conversation_loaded, get_upload_guard, apply_conversation_overrides
from .files import sanitize_filename_preserve_unicode
from .utils_common import sanitize_filename_preserve_unicode
from .utils_common import debug_log
from config.model_profiles import get_registered_model_profiles
from core.tool_config import TOOL_CATEGORIES

View File

@ -7,7 +7,6 @@ from flask import Blueprint, request, jsonify, session, redirect, send_from_dire
from modules.personalization_manager import load_personalization_config
from modules.host_workspace_manager import resolve_host_workspace
from modules.user_manager import UserWorkspace
from config import (
TERMINAL_SANDBOX_MODE,
DATA_DIR,
@ -25,7 +24,6 @@ from .security import (
is_action_blocked,
clear_failures,
)
from .context import with_terminal, get_gui_manager
from . import state
from .utils_common import debug_log
@ -442,36 +440,6 @@ def terminal_page():
return current_app.send_static_file('terminal.html')
@auth_bp.route('/file-manager')
@login_required
def gui_file_manager_page():
from .auth_helpers import resolve_admin_policy
policy = resolve_admin_policy(get_current_user_record())
if policy.get("ui_blocks", {}).get("block_file_manager"):
return "文件管理器已被管理员禁用", 403
return send_from_directory(Path(current_app.static_folder) / 'file_manager', 'index.html')
@auth_bp.route('/file-manager/editor')
@login_required
def gui_file_editor_page():
return send_from_directory(Path(current_app.static_folder) / 'file_manager', 'editor.html')
@auth_bp.route('/file-preview/<path:relative_path>')
@login_required
@with_terminal
def gui_file_preview(relative_path: str, terminal, workspace: UserWorkspace, username: str):
manager = get_gui_manager(workspace)
try:
target = manager.prepare_download(relative_path)
if not target.is_file():
return "预览仅支持文件", 400
return send_from_directory(directory=target.parent, path=target.name, mimetype='text/html')
except Exception as exc:
return f"无法预览文件: {exc}", 400
@auth_bp.route('/user_upload/<path:filename>')
@login_required
def serve_user_upload(filename: str):

View File

@ -41,7 +41,6 @@ from server.state import PROJECT_MAX_STORAGE_MB, pending_socket_tokens, SOCKET_T
from server.state import tool_approval_manager, user_question_manager
from server.extensions import socketio
from server.monitor import get_cached_monitor_snapshot
from server.files import sanitize_filename_preserve_unicode
UPLOAD_FOLDER_NAME = ".astrion/user_upload"
@chat_bp.route('/api/user-questions/pending', methods=['GET'])

View File

@ -42,7 +42,7 @@ from server.state import PROJECT_MAX_STORAGE_MB, pending_socket_tokens, SOCKET_T
from server.state import tool_approval_manager, user_question_manager
from server.extensions import socketio
from server.monitor import get_cached_monitor_snapshot
from server.files import sanitize_filename_preserve_unicode
from server.utils_common import sanitize_filename_preserve_unicode
UPLOAD_FOLDER_NAME = ".astrion/user_upload"

View File

@ -41,7 +41,6 @@ from server.state import PROJECT_MAX_STORAGE_MB, pending_socket_tokens, SOCKET_T
from server.state import tool_approval_manager, user_question_manager
from server.extensions import socketio
from server.monitor import get_cached_monitor_snapshot
from server.files import sanitize_filename_preserve_unicode
UPLOAD_FOLDER_NAME = ".astrion/user_upload"
@chat_bp.route('/api/memory', methods=['GET'])

View File

@ -51,7 +51,6 @@ from server.state import PROJECT_MAX_STORAGE_MB, pending_socket_tokens, SOCKET_T
from server.state import tool_approval_manager, user_question_manager
from server.extensions import socketio
from server.monitor import get_cached_monitor_snapshot
from server.files import sanitize_filename_preserve_unicode
import os
import re

View File

@ -43,7 +43,6 @@ from server.state import PROJECT_MAX_STORAGE_MB, pending_socket_tokens, SOCKET_T
from server.state import tool_approval_manager, user_question_manager
from server.extensions import socketio
from server.monitor import get_cached_monitor_snapshot
from server.files import sanitize_filename_preserve_unicode
UPLOAD_FOLDER_NAME = ".astrion/user_upload"

View File

@ -41,7 +41,6 @@ from server.state import PROJECT_MAX_STORAGE_MB, pending_socket_tokens, SOCKET_T
from server.state import tool_approval_manager, user_question_manager
from server.extensions import socketio
from server.monitor import get_cached_monitor_snapshot
from server.files import sanitize_filename_preserve_unicode
UPLOAD_FOLDER_NAME = ".astrion/user_upload"
@chat_bp.route('/api/terminals')

View File

@ -1,39 +1,23 @@
"""文件与GUI文件管理相关路由。"""
"""文件相关共享路由(主 SPA 使用):项目结构、目录列举、下载、文本读写、@文件搜索。
/file-manager 独立页面的写操作端点create/delete/rename/copy/move/upload/batch
已随该页面下线移除页面路由见 git 历史
"""
from __future__ import annotations
import os
import re
import zipfile
from io import BytesIO
from pathlib import Path
from typing import Dict, Any, List, Optional, Tuple
from flask import Blueprint, jsonify, request, send_file
from werkzeug.utils import secure_filename
from modules.upload_security import UploadSecurityError
from .auth_helpers import api_login_required, resolve_admin_policy, get_current_user_record
from .security import rate_limited
from .context import with_terminal, get_gui_manager, get_upload_guard, build_upload_error_response
from .context import with_terminal, get_gui_manager
from .utils_common import debug_log
files_bp = Blueprint("files", __name__)
def sanitize_filename_preserve_unicode(filename: str) -> str:
"""在保留中文等字符的同时,移除危险字符和路径成分"""
import re
if not filename:
return ""
cleaned = filename.strip().replace("\x00", "")
if not cleaned:
return ""
cleaned = cleaned.replace("\\", "/").split("/")[-1]
cleaned = re.sub(r'[<>:"\\|?*\n\r\t]', "_", cleaned)
cleaned = cleaned.strip(". ")
if not cleaned:
return ""
return cleaned[:255]
@files_bp.route('/api/files')
@api_login_required
@with_terminal
@ -81,153 +65,6 @@ def gui_list_entries(terminal, workspace, username):
return jsonify({"success": False, "error": str(exc)}), 400
@files_bp.route('/api/gui/files/create', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("gui_file_create", 30, 60, scope="user")
def gui_create_entry(terminal, workspace, username):
payload = request.get_json() or {}
parent = payload.get('path') or ""
name = payload.get('name') or ""
entry_type = payload.get('type') or "file"
policy = resolve_admin_policy(get_current_user_record())
if policy.get("ui_blocks", {}).get("block_file_manager"):
return jsonify({"success": False, "error": "文件管理已被管理员禁用"}), 403
manager = get_gui_manager(workspace)
try:
new_path = manager.create_entry(parent, name, entry_type)
return jsonify({"success": True, "path": new_path})
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 400
@files_bp.route('/api/gui/files/delete', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("gui_file_delete", 30, 60, scope="user")
def gui_delete_entries(terminal, workspace, username):
payload = request.get_json() or {}
paths = payload.get('paths') or []
policy = resolve_admin_policy(get_current_user_record())
if policy.get("ui_blocks", {}).get("block_file_manager"):
return jsonify({"success": False, "error": "文件管理已被管理员禁用"}), 403
manager = get_gui_manager(workspace)
try:
result = manager.delete_entries(paths)
return jsonify({"success": True, "result": result})
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 400
@files_bp.route('/api/gui/files/rename', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("gui_file_rename", 30, 60, scope="user")
def gui_rename_entry(terminal, workspace, username):
payload = request.get_json() or {}
path = payload.get('path')
new_name = payload.get('new_name')
if not path or not new_name:
return jsonify({"success": False, "error": "缺少 path 或 new_name"}), 400
policy = resolve_admin_policy(get_current_user_record())
if policy.get("ui_blocks", {}).get("block_file_manager"):
return jsonify({"success": False, "error": "文件管理已被管理员禁用"}), 403
manager = get_gui_manager(workspace)
try:
new_path = manager.rename_entry(path, new_name)
return jsonify({"success": True, "path": new_path})
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 400
@files_bp.route('/api/gui/files/copy', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("gui_file_copy", 40, 120, scope="user")
def gui_copy_entries(terminal, workspace, username):
payload = request.get_json() or {}
paths = payload.get('paths') or []
target_dir = payload.get('target_dir') or ""
policy = resolve_admin_policy(get_current_user_record())
if policy.get("ui_blocks", {}).get("block_file_manager"):
return jsonify({"success": False, "error": "文件管理已被管理员禁用"}), 403
manager = get_gui_manager(workspace)
try:
result = manager.copy_entries(paths, target_dir)
return jsonify({"success": True, "result": result})
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 400
@files_bp.route('/api/gui/files/move', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("gui_file_move", 40, 120, scope="user")
def gui_move_entries(terminal, workspace, username):
payload = request.get_json() or {}
paths = payload.get('paths') or []
target_dir = payload.get('target_dir') or ""
manager = get_gui_manager(workspace)
try:
result = manager.move_entries(paths, target_dir)
return jsonify({"success": True, "result": result})
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 400
@files_bp.route('/api/gui/files/upload', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("gui_file_upload", 10, 300, scope="user")
def gui_upload_entry(terminal, workspace, username):
policy = resolve_admin_policy(get_current_user_record())
if policy.get("ui_blocks", {}).get("block_upload"):
return jsonify({"success": False, "error": "文件上传已被管理员禁用"}), 403
if 'file' not in request.files:
return jsonify({"success": False, "error": "未找到文件"}), 400
file_obj = request.files['file']
if not file_obj or not file_obj.filename:
return jsonify({"success": False, "error": "文件名为空"}), 400
current_dir = request.form.get('path') or ""
raw_name = request.form.get('filename') or file_obj.filename
filename = sanitize_filename_preserve_unicode(raw_name) or secure_filename(raw_name)
if not filename:
return jsonify({"success": False, "error": "非法文件名"}), 400
manager = get_gui_manager(workspace)
try:
target_path = manager.prepare_upload(current_dir, filename)
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 400
try:
relative_path = manager._to_relative(target_path)
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 400
guard = get_upload_guard(workspace)
try:
result = guard.process_upload(
file_obj,
target_path,
username=username,
source="web_gui",
original_name=raw_name,
relative_path=relative_path,
)
except UploadSecurityError as exc:
return build_upload_error_response(exc)
except Exception as exc:
return jsonify({"success": False, "error": f"保存文件失败: {exc}"}), 500
metadata = result.get("metadata", {})
return jsonify({
"success": True,
"path": relative_path,
"filename": target_path.name,
"scan": metadata.get("scan"),
"sha256": metadata.get("sha256"),
"size": metadata.get("size"),
})
@files_bp.route('/api/gui/files/download', methods=['GET'])
@api_login_required
@with_terminal
@ -254,37 +91,6 @@ def gui_download_entry(terminal, workspace, username):
return jsonify({"success": False, "error": str(exc)}), 400
@files_bp.route('/api/gui/files/download/batch', methods=['POST'])
@api_login_required
@with_terminal
def gui_download_batch(terminal, workspace, username):
payload = request.get_json() or {}
paths = payload.get('paths') or []
if not paths:
return jsonify({"success": False, "error": "缺少待下载的路径"}), 400
manager = get_gui_manager(workspace)
try:
memory_file = BytesIO()
with zipfile.ZipFile(memory_file, mode='w', compression=zipfile.ZIP_DEFLATED) as zf:
for rel in paths:
target = manager.prepare_download(rel)
arc_base = rel.strip('/') or target.name
if target.is_dir():
for root, _, files in os.walk(target):
for file in files:
full_path = Path(root) / file
relative_sub = full_path.relative_to(target)
arcname = Path(arc_base) / relative_sub
zf.write(full_path, arcname=str(arcname))
else:
zf.write(target, arcname=arc_base)
memory_file.seek(0)
download_name = f"selected_{len(paths)}.zip"
return send_file(memory_file, as_attachment=True, download_name=download_name, mimetype='application/zip')
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 400
@files_bp.route('/api/gui/files/text', methods=['GET', 'POST'])
@api_login_required
@with_terminal

View File

@ -17,6 +17,22 @@ def _sanitize_filename_component(text: str) -> str:
return safe or "untitled"
def sanitize_filename_preserve_unicode(filename: str) -> str:
"""在保留中文等字符的同时,移除危险字符和路径成分(原 server/files.py随 /file-manager 下线迁入)。"""
import re
if not filename:
return ""
cleaned = filename.strip().replace("\x00", "")
if not cleaned:
return ""
cleaned = cleaned.replace("\\", "/").split("/")[-1]
cleaned = re.sub(r'[<>:"\\|?*\n\r\t]', "_", cleaned)
cleaned = cleaned.strip(". ")
if not cleaned:
return ""
return cleaned[:255]
def build_review_lines(messages, limit=None):
"""
将对话消息序列拍平成简化文本
@ -187,6 +203,7 @@ def log_streaming_debug_entry(data: Dict[str, Any]):
__all__ = [
"_sanitize_filename_component",
"sanitize_filename_preserve_unicode",
"build_review_lines",
"brief_log",
"debug_log",

View File

@ -1,899 +0,0 @@
(() => {
const API_BASE = '/api/gui/files';
const EDITOR_PAGE = '/file-manager/editor';
const state = {
currentPath: '',
items: [],
selected: new Set(),
lastSelectedIndex: null,
clipboard: null, // {mode: 'copy'|'cut', items: []}
treeCache: new Map(),
treeExpanded: new Set(['']),
isDraggingSelection: false,
dragStart: null,
selectionRect: null,
selectionJustFinished: false,
selectionDisabled: false,
};
const icons = {
directory: '📁',
default: '📄',
editable: '📝',
code: '💻',
markdown: '🧾',
image: '🖼️',
archive: '🗃️',
};
const fileGrid = document.getElementById('fileGrid');
const directoryTree = document.getElementById('directoryTree');
const breadcrumbEl = document.getElementById('breadcrumb');
const selectionInfo = document.getElementById('selectionInfo');
const statusBar = document.getElementById('statusBar');
const contextMenu = document.getElementById('contextMenu');
const dialogBackdrop = document.getElementById('dialogBackdrop');
const dialogTitle = document.getElementById('dialogTitle');
const dialogContent = document.getElementById('dialogContent');
const dialogCancel = document.getElementById('dialogCancel');
const dialogConfirm = document.getElementById('dialogConfirm');
const hiddenUploader = document.getElementById('hiddenUploader');
const pasteBtn = document.getElementById('btnPaste');
const newFolderBtn = document.getElementById('btnNewFolder');
const newFileBtn = document.getElementById('btnNewFile');
const refreshBtn = document.getElementById('btnRefresh');
const uploadBtn = document.getElementById('btnUpload');
const backBtn = document.getElementById('btnBack');
const returnChatBtn = document.getElementById('btnReturnChat');
const downloadBtn = document.getElementById('btnDownload');
const renameBtn = document.getElementById('btnRename');
const copyBtn = document.getElementById('btnCopy');
const cutBtn = document.getElementById('btnCut');
const deleteBtn = document.getElementById('btnDelete');
const toggleSelectionBtn = document.getElementById('btnToggleSelection');
const clamp = (value, min, max) => Math.max(min, Math.min(value, max));
const urlParams = new URLSearchParams(window.location.search);
const initialPathParam = (urlParams.get('path') || '').replace(/^\//, '').replace(/\/$/, '');
dialogBackdrop.hidden = true;
let dialogHandlers = { confirm: null, cancel: null };
function clearDialogHandlers() {
dialogHandlers.confirm = null;
dialogHandlers.cancel = null;
}
function registerDialogHandlers(confirmHandler, cancelHandler) {
dialogHandlers.confirm = confirmHandler || null;
dialogHandlers.cancel = cancelHandler || null;
}
function closeDialog() {
dialogBackdrop.hidden = true;
clearDialogHandlers();
}
dialogCancel.addEventListener('click', () => {
if (dialogHandlers.cancel) {
const handler = dialogHandlers.cancel;
clearDialogHandlers();
handler();
} else {
closeDialog();
}
});
dialogConfirm.addEventListener('click', () => {
if (dialogHandlers.confirm) {
const handler = dialogHandlers.confirm;
clearDialogHandlers();
handler();
} else {
closeDialog();
}
});
dialogBackdrop.addEventListener('click', (event) => {
if (event.target === dialogBackdrop) {
if (dialogHandlers.cancel) {
const handler = dialogHandlers.cancel;
clearDialogHandlers();
handler();
} else {
closeDialog();
}
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !dialogBackdrop.hidden) {
if (dialogHandlers.cancel) {
const handler = dialogHandlers.cancel;
clearDialogHandlers();
handler();
} else {
closeDialog();
}
}
});
closeDialog();
function showStatus(message) {
statusBar.textContent = message;
}
function formatSize(size) {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
if (size < 1024 * 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MB`;
return `${(size / 1024 / 1024 / 1024).toFixed(1)} GB`;
}
function formatTime(ts) {
const d = new Date(ts * 1000);
return d.toLocaleString();
}
function joinPath(base, name) {
if (!base) return name;
return `${base.replace(/\/$/, '')}/${name}`;
}
function getIcon(entry) {
if (entry.type === 'directory') return icons.directory;
if (entry.is_editable) return icons.editable;
const ext = entry.extension || '';
if (['.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp'].includes(ext)) {
return icons.image;
}
if (['.zip', '.rar', '.7z', '.tar', '.gz'].includes(ext)) {
return icons.archive;
}
if (['.js', '.ts', '.py', '.rb', '.php', '.java', '.kt', '.go', '.rs', '.c', '.cpp', '.h', '.hpp'].includes(ext)) {
return icons.code;
}
if (['.md', '.markdown'].includes(ext)) {
return icons.markdown;
}
return icons.default;
}
async function request(url, options = {}) {
const response = await fetch(url, options);
const data = await response.json().catch(() => ({}));
if (!response.ok || data.success === false) {
const message = data.error || data.message || `请求失败 (${response.status})`;
throw new Error(message);
}
return data;
}
function updateUrl(path) {
const url = new URL(window.location.href);
if (path) {
url.searchParams.set('path', path);
} else {
url.searchParams.delete('path');
}
window.history.replaceState({}, '', url.pathname + url.search);
}
async function ensureAncestors(path) {
await ensureTreeNode('', false);
state.treeExpanded.add('');
if (!path) {
renderTree();
return;
}
const segments = path.split('/').filter(Boolean);
let current = '';
for (let index = 0; index < segments.length; index += 1) {
const segment = segments[index];
current = current ? `${current}/${segment}` : segment;
if (index < segments.length - 1) {
state.treeExpanded.add(current);
}
await ensureTreeNode(current, false);
}
renderTree();
}
async function loadDirectory(path = '', { updateHistory = true } = {}) {
hideContextMenu();
showStatus('加载中...');
try {
const result = await request(`${API_BASE}/entries?path=${encodeURIComponent(path)}`);
const resolvedPath = result.data.path || '';
state.currentPath = resolvedPath;
state.items = result.data.items || [];
const directoryEntries = state.items.filter((item) => item.type === 'directory');
state.treeCache.set(resolvedPath, directoryEntries);
if (updateHistory) {
updateUrl(resolvedPath);
}
await ensureAncestors(resolvedPath);
renderBreadcrumb(result.data.breadcrumb || []);
renderGrid();
updateSelection([]);
state.lastSelectedIndex = null;
showStatus(`已加载 ${state.items.length}`);
} catch (err) {
showStatus(err.message);
}
}
function renderBreadcrumb(crumbs) {
breadcrumbEl.innerHTML = '';
crumbs.forEach((crumb, index) => {
const span = document.createElement('span');
span.textContent = crumb.name;
span.dataset.path = crumb.path;
span.addEventListener('click', () => {
loadDirectory(crumb.path);
});
breadcrumbEl.appendChild(span);
if (index < crumbs.length - 1) {
const sep = document.createElement('span');
sep.textContent = '';
sep.classList.add('fm-breadcrumb-sep');
breadcrumbEl.appendChild(sep);
}
});
}
function renderGrid() {
fileGrid.innerHTML = '';
state.items.forEach((entry, index) => {
const card = document.createElement('div');
card.className = 'fm-card';
card.tabIndex = 0;
card.dataset.path = entry.path;
card.dataset.index = index;
if (state.selected.has(entry.path)) {
card.classList.add('selected');
}
const icon = document.createElement('div');
icon.className = 'fm-card-icon';
icon.textContent = getIcon(entry);
const name = document.createElement('div');
name.className = 'fm-card-name';
name.textContent = entry.name;
const meta = document.createElement('div');
meta.className = 'fm-card-meta';
const lines = [];
if (entry.type === 'file') {
lines.push(formatSize(entry.size));
} else {
lines.push('目录');
}
lines.push(formatTime(entry.modified_at));
meta.innerHTML = lines.join('<br>');
card.appendChild(icon);
card.appendChild(name);
card.appendChild(meta);
card.addEventListener('click', (event) => handleItemClick(event, entry, index));
card.addEventListener('dblclick', () => handleItemDoubleClick(entry));
card.addEventListener('contextmenu', (event) => handleItemContextMenu(event, entry));
fileGrid.appendChild(card);
});
const overlay = document.createElement('div');
overlay.className = 'fm-drop-overlay';
overlay.textContent = '释放即可上传到此目录';
fileGrid.appendChild(overlay);
}
function updateSelection(paths, options = { append: false, range: false }) {
if (!options.append && !options.range) {
state.selected.clear();
if (paths.length === 0) {
state.lastSelectedIndex = null;
}
}
paths.forEach((path) => {
if (state.selected.has(path) && options.append) {
state.selected.delete(path);
} else {
state.selected.add(path);
}
});
syncSelectionUI();
}
function syncSelectionUI() {
const cards = fileGrid.querySelectorAll('.fm-card');
cards.forEach((card) => {
if (state.selected.has(card.dataset.path)) {
card.classList.add('selected');
} else {
card.classList.remove('selected');
}
});
selectionInfo.textContent = `已选中 ${state.selected.size}`;
pasteBtn.disabled = !state.clipboard || !state.clipboard.items.length;
}
function handleItemClick(event, entry, index) {
const isMetaKey = event.metaKey || event.ctrlKey;
const isShiftKey = event.shiftKey;
if (isShiftKey && state.lastSelectedIndex !== null) {
const start = Math.min(state.lastSelectedIndex, index);
const end = Math.max(state.lastSelectedIndex, index);
const paths = state.items.slice(start, end + 1).map((item) => item.path);
updateSelection(paths, { range: true });
} else if (isMetaKey) {
updateSelection([entry.path], { append: true });
state.lastSelectedIndex = index;
} else {
updateSelection([entry.path], { append: false });
state.lastSelectedIndex = index;
}
}
function handleItemDoubleClick(entry) {
if (entry.type === 'directory') {
loadDirectory(entry.path);
return;
}
if (entry.is_editable) {
window.location.href = `${EDITOR_PAGE}?path=${encodeURIComponent(entry.path)}`;
return;
}
window.open(`${API_BASE}/download?path=${encodeURIComponent(entry.path)}`, '_blank');
}
function handleItemContextMenu(event, entry) {
event.preventDefault();
if (!state.selected.has(entry.path)) {
updateSelection([entry.path], { append: false });
}
showContextMenu(event.clientX, event.clientY);
}
function showContextMenu(x, y) {
const single = state.selected.size === 1;
const singleEntry = single ? getSingleSelected() : null;
contextMenu.innerHTML = '';
const entries = [];
if (singleEntry) {
if (singleEntry.type === 'directory') {
entries.push({ label: '打开', action: openSelected, disabled: false });
} else if (singleEntry.is_editable) {
entries.push({ label: '在编辑器中打开', action: openEditor, disabled: false });
if (singleEntry.extension === '.html' || singleEntry.extension === '.htm') {
entries.push({ label: '预览', action: previewSelected, disabled: false });
}
entries.push({ label: '下载', action: downloadSelected, disabled: false });
} else {
const isHtml = singleEntry.extension === '.html' || singleEntry.extension === '.htm';
if (isHtml) {
entries.push({ label: '预览', action: previewSelected, disabled: false });
}
entries.push({ label: '下载', action: downloadSelected, disabled: false });
}
} else if (state.selected.size > 0) {
entries.push({ label: '下载', action: downloadSelected, disabled: false });
}
entries.push(
{ label: '重命名', action: renameSelected, disabled: !single },
{ label: '复制', action: copySelected, disabled: state.selected.size === 0 },
{ label: '剪切', action: cutSelected, disabled: state.selected.size === 0 },
{ label: '粘贴', action: pasteClipboard, disabled: !state.clipboard || !state.clipboard.items.length },
{ label: '删除', action: deleteSelected, disabled: state.selected.size === 0 },
);
entries.forEach((item) => {
const btn = document.createElement('button');
btn.textContent = item.label;
btn.disabled = item.disabled;
btn.addEventListener('click', () => {
hideContextMenu();
item.action();
});
contextMenu.appendChild(btn);
});
contextMenu.style.display = 'block';
const { innerWidth, innerHeight } = window;
const menuRect = contextMenu.getBoundingClientRect();
const left = clamp(x, 0, innerWidth - menuRect.width);
const top = clamp(y, 0, innerHeight - menuRect.height);
contextMenu.style.left = `${left}px`;
contextMenu.style.top = `${top}px`;
}
function hideContextMenu() {
contextMenu.style.display = 'none';
}
function getSingleSelected() {
if (state.selected.size !== 1) return null;
const path = Array.from(state.selected)[0];
return state.items.find((item) => item.path === path) || null;
}
function openSelected() {
const entry = getSingleSelected();
if (!entry) return;
handleItemDoubleClick(entry);
}
function previewSelected() {
const entry = getSingleSelected();
if (!entry) return;
if (entry.extension !== '.html' && entry.extension !== '.htm') {
showStatus('仅支持预览 HTML 文件');
return;
}
window.open(`/file-preview/${encodeURIComponent(entry.path)}`, '_blank');
}
function openEditor() {
const entry = getSingleSelected();
if (!entry || !entry.is_editable) return;
window.location.href = `${EDITOR_PAGE}?path=${encodeURIComponent(entry.path)}`;
}
function triggerBlobDownload(filename, blob) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
async function downloadSelected() {
if (!state.selected.size) return;
if (state.selected.size === 1) {
const path = Array.from(state.selected)[0];
window.open(`${API_BASE}/download?path=${encodeURIComponent(path)}`, '_blank');
return;
}
try {
const resp = await fetch(`${API_BASE}/download/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths: Array.from(state.selected) })
});
if (!resp.ok) {
let message = '批量下载失败';
try {
const data = await resp.json();
message = data.error || data.message || message;
} catch (_) {
// ignore
}
throw new Error(message);
}
const blob = await resp.blob();
triggerBlobDownload(`selected_${Date.now()}.zip`, blob);
showStatus(`已开始下载 ${state.selected.size} 个项目`);
} catch (err) {
showStatus(err.message);
}
}
async function renameSelected() {
const entry = getSingleSelected();
if (!entry) return;
const newName = await promptDialog('重命名', entry.name);
if (!newName) return;
try {
await request(`${API_BASE}/rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: entry.path, new_name: newName }),
});
await loadDirectory(state.currentPath);
} catch (err) {
showStatus(err.message);
}
}
function copySelected() {
if (!state.selected.size) return;
state.clipboard = { mode: 'copy', items: Array.from(state.selected) };
showStatus(`已复制 ${state.clipboard.items.length}`);
syncSelectionUI();
}
function cutSelected() {
if (!state.selected.size) return;
state.clipboard = { mode: 'cut', items: Array.from(state.selected) };
showStatus(`已剪切 ${state.clipboard.items.length}`);
syncSelectionUI();
}
async function pasteClipboard() {
if (!state.clipboard || !state.clipboard.items.length) return;
const endpoint = state.clipboard.mode === 'copy' ? 'copy' : 'move';
try {
await request(`${API_BASE}/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
paths: state.clipboard.items,
target_dir: state.currentPath,
}),
});
if (state.clipboard.mode === 'cut') {
state.clipboard = null;
}
await loadDirectory(state.currentPath);
} catch (err) {
showStatus(err.message);
}
}
async function deleteSelected() {
if (!state.selected.size) return;
const confirm = await confirmDialog(`确认删除选中的 ${state.selected.size} 项吗?该操作不可撤销。`);
if (!confirm) return;
try {
await request(`${API_BASE}/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths: Array.from(state.selected) }),
});
await loadDirectory(state.currentPath);
} catch (err) {
showStatus(err.message);
}
}
function promptDialog(title, defaultValue = '') {
return new Promise((resolve) => {
dialogTitle.textContent = title;
dialogContent.innerHTML = '';
const input = document.createElement('input');
input.value = defaultValue;
input.autofocus = true;
dialogContent.appendChild(input);
const finish = (value) => {
closeDialog();
resolve(value);
};
registerDialogHandlers(() => finish(input.value.trim()), () => finish(null));
dialogBackdrop.hidden = false;
input.addEventListener('keydown', (evt) => {
if (evt.key === 'Enter') {
evt.preventDefault();
finish(input.value.trim());
} else if (evt.key === 'Escape') {
evt.preventDefault();
finish(null);
}
});
setTimeout(() => input.select(), 50);
});
}
function confirmDialog(message) {
return new Promise((resolve) => {
dialogTitle.textContent = '确认操作';
dialogContent.innerHTML = `<p>${message}</p>`;
const finish = (value) => {
closeDialog();
resolve(value);
};
registerDialogHandlers(() => finish(true), () => finish(false));
dialogBackdrop.hidden = false;
});
}
function handleGlobalClick(event) {
if (!contextMenu.contains(event.target)) {
hideContextMenu();
}
}
function handleGridBackgroundClick(event) {
if (event.target === fileGrid) {
if (state.selectionJustFinished) {
return;
}
updateSelection([]);
}
}
function handleDragEnter(event) {
event.preventDefault();
fileGrid.classList.add('drop-target');
}
function handleDragOver(event) {
event.preventDefault();
}
function handleDragLeave(event) {
if (event.target === fileGrid) {
fileGrid.classList.remove('drop-target');
}
}
async function handleDrop(event) {
event.preventDefault();
fileGrid.classList.remove('drop-target');
if (!event.dataTransfer || !event.dataTransfer.files.length) return;
const files = event.dataTransfer.files;
await uploadFiles(files, state.currentPath);
}
async function uploadFiles(fileList, targetPath) {
for (const file of fileList) {
const form = new FormData();
form.append('file', file, file.name);
form.append('filename', file.name);
form.append('path', targetPath);
try {
await request(`${API_BASE}/upload`, {
method: 'POST',
body: form,
});
showStatus(`已上传 ${file.name}`);
} catch (err) {
showStatus(`上传失败:${err.message}`);
}
}
await loadDirectory(state.currentPath);
}
function initSelectionRectangle() {
fileGrid.addEventListener('pointerdown', (event) => {
if (state.selectionDisabled) return;
if (event.target !== fileGrid) return;
state.isDraggingSelection = true;
state.dragStart = { x: event.clientX, y: event.clientY };
state.selectionRect = document.createElement('div');
state.selectionRect.className = 'fm-selection-rect';
fileGrid.appendChild(state.selectionRect);
updateSelection([]);
state.selectionJustFinished = false;
fileGrid.setPointerCapture(event.pointerId);
});
fileGrid.addEventListener('pointermove', (event) => {
if (!state.isDraggingSelection || !state.selectionRect) return;
const rect = fileGrid.getBoundingClientRect();
const current = { x: event.clientX, y: event.clientY };
const x = Math.min(state.dragStart.x, current.x) - rect.left + fileGrid.scrollLeft;
const y = Math.min(state.dragStart.y, current.y) - rect.top + fileGrid.scrollTop;
const width = Math.abs(state.dragStart.x - current.x);
const height = Math.abs(state.dragStart.y - current.y);
Object.assign(state.selectionRect.style, {
left: `${x}px`,
top: `${y}px`,
width: `${width}px`,
height: `${height}px`,
});
const selectionBox = {
left: Math.min(state.dragStart.x, current.x),
right: Math.max(state.dragStart.x, current.x),
top: Math.min(state.dragStart.y, current.y),
bottom: Math.max(state.dragStart.y, current.y),
};
const selected = [];
const cards = fileGrid.querySelectorAll('.fm-card');
cards.forEach((card) => {
const bounds = card.getBoundingClientRect();
const intersects = !(selectionBox.right < bounds.left ||
selectionBox.left > bounds.right ||
selectionBox.bottom < bounds.top ||
selectionBox.top > bounds.bottom);
if (intersects) {
selected.push(card.dataset.path);
}
});
updateSelection(selected);
});
fileGrid.addEventListener('pointerup', (event) => {
if (!state.isDraggingSelection) return;
state.isDraggingSelection = false;
if (state.selectionRect) {
state.selectionRect.remove();
state.selectionRect = null;
}
state.selectionJustFinished = true;
requestAnimationFrame(() => {
state.selectionJustFinished = false;
});
fileGrid.releasePointerCapture(event.pointerId);
});
}
async function ensureTreeNode(path, shouldRender = true) {
let changed = false;
if (!state.treeCache.has(path)) {
try {
const result = await request(`${API_BASE}/entries?path=${encodeURIComponent(path)}`);
const directories = result.data.items.filter((item) => item.type === 'directory');
state.treeCache.set(path, directories);
changed = true;
} catch (err) {
showStatus(err.message);
}
}
if (shouldRender && changed) {
renderTree();
}
return changed;
}
function renderTree() {
directoryTree.innerHTML = '';
const rootNode = createTreeNode('', '根目录');
directoryTree.appendChild(rootNode);
}
function createTreeNode(path, name) {
const li = document.createElement('li');
const header = document.createElement('div');
header.className = 'fm-tree-item';
if (path === state.currentPath) {
header.classList.add('active');
}
const toggle = document.createElement('span');
toggle.className = 'fm-tree-toggle';
toggle.textContent = state.treeExpanded.has(path) ? '▾' : '▸';
toggle.addEventListener('click', async (event) => {
event.stopPropagation();
if (state.treeExpanded.has(path)) {
state.treeExpanded.delete(path);
} else {
state.treeExpanded.add(path);
await ensureTreeNode(path, false);
}
renderTree();
});
const label = document.createElement('span');
label.textContent = name;
label.addEventListener('click', () => loadDirectory(path));
header.addEventListener('click', () => loadDirectory(path));
header.appendChild(toggle);
header.appendChild(label);
li.appendChild(header);
if (state.treeExpanded.has(path)) {
const children = document.createElement('ul');
children.className = 'fm-tree-children';
const dirs = state.treeCache.get(path) || [];
dirs.forEach((dir) => {
const child = createTreeNode(dir.path, dir.name);
children.appendChild(child);
});
li.appendChild(children);
}
return li;
}
function bindToolbar() {
newFolderBtn.addEventListener('click', async () => {
const name = await promptDialog('新建文件夹', '新建文件夹');
if (!name) return;
try {
await request(`${API_BASE}/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
path: state.currentPath,
name,
type: 'directory',
}),
});
await loadDirectory(state.currentPath);
} catch (err) {
showStatus(err.message);
}
});
newFileBtn.addEventListener('click', async () => {
const name = await promptDialog('新建文件', '新建文件.txt');
if (!name) return;
try {
await request(`${API_BASE}/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
path: state.currentPath,
name,
type: 'file',
}),
});
await loadDirectory(state.currentPath);
} catch (err) {
showStatus(err.message);
}
});
refreshBtn.addEventListener('click', () => loadDirectory(state.currentPath));
uploadBtn.addEventListener('click', () => hiddenUploader.click());
backBtn.addEventListener('click', () => {
if (!state.currentPath) {
loadDirectory('');
return;
}
const segments = state.currentPath.split('/').filter(Boolean);
if (segments.length === 0) {
loadDirectory('');
return;
}
segments.pop();
const parentPath = segments.join('/');
loadDirectory(parentPath);
});
returnChatBtn.addEventListener('click', () => {
window.location.href = '/new';
});
downloadBtn.addEventListener('click', downloadSelected);
renameBtn.addEventListener('click', renameSelected);
copyBtn.addEventListener('click', copySelected);
cutBtn.addEventListener('click', cutSelected);
pasteBtn.addEventListener('click', pasteClipboard);
deleteBtn.addEventListener('click', deleteSelected);
toggleSelectionBtn.addEventListener('click', () => {
state.selectionDisabled = !state.selectionDisabled;
toggleSelectionBtn.textContent = state.selectionDisabled ? '启用框选' : '禁用框选';
const msg = state.selectionDisabled ? '已禁用框选' : '已启用框选';
showStatus(msg);
});
hiddenUploader.addEventListener('change', async (event) => {
const files = event.target.files;
if (files && files.length) {
await uploadFiles(files, state.currentPath);
}
hiddenUploader.value = '';
});
}
function bindGlobalEvents() {
document.addEventListener('click', handleGlobalClick);
fileGrid.addEventListener('click', handleGridBackgroundClick);
fileGrid.addEventListener('contextmenu', (event) => {
if (event.target === fileGrid) {
event.preventDefault();
if (state.selected.size) {
showContextMenu(event.clientX, event.clientY);
}
}
});
fileGrid.addEventListener('dragenter', handleDragEnter);
fileGrid.addEventListener('dragover', handleDragOver);
fileGrid.addEventListener('dragleave', handleDragLeave);
fileGrid.addEventListener('drop', handleDrop);
initSelectionRectangle();
}
async function bootstrap() {
bindToolbar();
bindGlobalEvents();
await loadDirectory(initialPathParam, { updateHistory: false });
}
bootstrap().catch((err) => {
console.error(err);
showStatus(err.message);
});
})();

View File

@ -1,60 +0,0 @@
.fe-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(17, 18, 26, 0.9);
backdrop-filter: blur(8px);
position: sticky;
top: 0;
z-index: 20;
}
.fe-left {
display: flex;
align-items: center;
gap: 12px;
}
.fe-right {
display: flex;
align-items: center;
gap: 12px;
}
.fe-path {
font-size: 14px;
color: rgba(255, 255, 255, 0.75);
max-width: 52vw;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fe-status {
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
}
.fe-main {
height: calc(100vh - 64px);
}
#editorArea {
width: 100%;
height: 100%;
border: none;
outline: none;
background: #0f1119;
color: #f1f3f5;
font-size: 14px;
line-height: 1.6;
padding: 20px;
font-family: "Fira Code", "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
resize: none;
}
#editorArea:focus {
outline: none;
}

View File

@ -1,31 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>文件编辑器</title>
<link rel="stylesheet" href="/static/file_manager/style.css">
<link rel="stylesheet" href="/static/file_manager/editor.css">
</head>
<body>
<div id="editorApp">
<header class="fe-header">
<div class="fe-left">
<button class="fm-btn" id="btnBack">← 返回</button>
<div class="fe-path" id="filePath"></div>
</div>
<div class="fe-right">
<span class="fe-status" id="statusInfo">未保存修改</span>
<button class="fm-btn" id="btnDownload">下载</button>
<button class="fm-btn" id="btnIncreaseFont">A+</button>
<button class="fm-btn" id="btnDecreaseFont">A-</button>
<button class="fm-btn primary" id="btnSave">保存</button>
</div>
</header>
<main class="fe-main">
<textarea id="editorArea" spellcheck="false"></textarea>
</main>
</div>
<script src="/static/security.js"></script>
<script src="/static/file_manager/editor.js"></script>
</body>
</html>

View File

@ -1,135 +0,0 @@
(() => {
const API_BASE = '/api/gui/files';
const params = new URLSearchParams(window.location.search);
const path = params.get('path');
const editorArea = document.getElementById('editorArea');
const filePathEl = document.getElementById('filePath');
const statusInfo = document.getElementById('statusInfo');
const saveBtn = document.getElementById('btnSave');
const downloadBtn = document.getElementById('btnDownload');
const backBtn = document.getElementById('btnBack');
const fontIncreaseBtn = document.getElementById('btnIncreaseFont');
const fontDecreaseBtn = document.getElementById('btnDecreaseFont');
let originalContent = '';
let dirty = false;
let fontSize = 14;
if (!path) {
editorArea.value = '缺少 path 参数,无法加载文件。';
editorArea.disabled = true;
saveBtn.disabled = true;
downloadBtn.disabled = true;
statusInfo.textContent = '缺少路径';
return;
}
filePathEl.textContent = path;
function setDirty(value) {
dirty = value;
statusInfo.textContent = dirty ? '有未保存的更改' : '已保存';
saveBtn.disabled = !dirty;
}
async function request(url, options = {}) {
const response = await fetch(url, options);
const data = await response.json().catch(() => ({}));
if (!response.ok || data.success === false) {
const message = data.error || data.message || `请求失败 (${response.status})`;
throw new Error(message);
}
return data;
}
async function loadFile() {
statusInfo.textContent = '加载中...';
try {
const result = await request(`${API_BASE}/text?path=${encodeURIComponent(path)}`);
originalContent = result.content || '';
editorArea.value = originalContent;
setDirty(false);
statusInfo.textContent = `最后修改时间:${result.modified_at}`;
} catch (err) {
editorArea.value = `文件加载失败:${err.message}`;
editorArea.disabled = true;
saveBtn.disabled = true;
statusInfo.textContent = '无法加载文件';
}
}
async function saveFile() {
statusInfo.textContent = '保存中...';
try {
await request(`${API_BASE}/text`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, content: editorArea.value }),
});
originalContent = editorArea.value;
setDirty(false);
statusInfo.textContent = '已保存';
} catch (err) {
statusInfo.textContent = `保存失败:${err.message}`;
}
}
editorArea.addEventListener('input', () => {
if (editorArea.disabled) return;
setDirty(editorArea.value !== originalContent);
});
saveBtn.addEventListener('click', () => {
if (!dirty) return;
saveFile();
});
downloadBtn.addEventListener('click', () => {
window.open(`${API_BASE}/download?path=${encodeURIComponent(path)}`, '_blank');
});
const getParentDirectory = () => {
const segments = path.split('/').filter(Boolean);
if (segments.length <= 1) {
return '';
}
segments.pop();
return segments.join('/');
};
const navigateToManager = () => {
const parentDir = getParentDirectory();
const target = parentDir ? `/file-manager?path=${encodeURIComponent(parentDir)}` : '/file-manager';
window.location.href = target;
};
backBtn.addEventListener('click', () => {
if (dirty) {
const confirmLeave = window.confirm('有未保存的更改,确认要离开吗?');
if (!confirmLeave) return;
}
navigateToManager();
});
fontIncreaseBtn.addEventListener('click', () => {
fontSize = Math.min(fontSize + 1, 28);
editorArea.style.fontSize = `${fontSize}px`;
});
fontDecreaseBtn.addEventListener('click', () => {
fontSize = Math.max(fontSize - 1, 10);
editorArea.style.fontSize = `${fontSize}px`;
});
window.addEventListener('beforeunload', (event) => {
if (!dirty) return;
event.preventDefault();
event.returnValue = '';
});
loadFile().catch((err) => {
console.error(err);
statusInfo.textContent = err.message;
});
})();

View File

@ -1,65 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>文件管理器</title>
<link rel="icon" type="image/svg+xml" href="/static/astrion-avatar.svg">
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
<link rel="stylesheet" href="/static/file_manager/style.css">
</head>
<body>
<div id="app">
<header class="fm-header">
<div class="fm-header-left">
<button class="fm-btn" id="btnBack" title="返回上一页">← 返回</button>
<button class="fm-btn" id="btnReturnChat" title="回到对话页面">返回对话</button>
<button class="fm-btn" id="btnToggleSelection" title="切换框选功能">禁用框选</button>
<div class="fm-breadcrumb" id="breadcrumb"></div>
</div>
<div class="fm-header-right">
<input type="file" id="hiddenUploader" hidden>
<button class="fm-btn" id="btnNewFolder">新建文件夹</button>
<button class="fm-btn" id="btnNewFile">新建文件</button>
<button class="fm-btn" id="btnUpload">上传</button>
<button class="fm-btn" id="btnRefresh">刷新</button>
</div>
</header>
<main class="fm-main">
<aside class="fm-sidebar">
<div class="fm-sidebar-title">目录</div>
<ul class="fm-tree" id="directoryTree"></ul>
</aside>
<section class="fm-content">
<div class="fm-toolbar">
<div class="fm-selection-info" id="selectionInfo">已选中 0 项</div>
<div class="fm-toolbar-right">
<button class="fm-btn" id="btnDownload">下载</button>
<button class="fm-btn" id="btnRename">重命名</button>
<button class="fm-btn" id="btnCopy">复制</button>
<button class="fm-btn" id="btnCut">剪切</button>
<button class="fm-btn" id="btnPaste" disabled>粘贴</button>
<button class="fm-btn danger" id="btnDelete">删除</button>
</div>
</div>
<div class="fm-grid" id="fileGrid"></div>
<div class="fm-status-bar" id="statusBar">拖拽文件到此处可上传到当前目录</div>
</section>
</main>
<div class="fm-context-menu" id="contextMenu"></div>
<div class="fm-dialog-backdrop" id="dialogBackdrop" hidden>
<div class="fm-dialog" id="dialog">
<h3 id="dialogTitle"></h3>
<div id="dialogContent"></div>
<div class="fm-dialog-actions">
<button class="fm-btn" id="dialogCancel">取消</button>
<button class="fm-btn primary" id="dialogConfirm">确定</button>
</div>
</div>
</div>
</div>
<script src="/static/security.js"></script>
<script src="/static/file_manager/app.js"></script>
</body>
</html>

View File

@ -1,357 +0,0 @@
* {
box-sizing: border-box;
}
html, body {
height: 100%;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif;
background: #1d1f27;
color: #f1f3f5;
}
a {
color: inherit;
text-decoration: none;
}
#app {
display: flex;
flex-direction: column;
height: 100%;
}
.fm-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(17, 18, 26, 0.9);
backdrop-filter: blur(8px);
position: sticky;
top: 0;
z-index: 20;
}
.fm-header-left {
display: flex;
align-items: center;
gap: 16px;
}
.fm-header-right {
display: flex;
align-items: center;
gap: 12px;
}
.fm-breadcrumb {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
}
.fm-breadcrumb span {
padding: 4px 8px;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
}
.fm-breadcrumb span:hover {
background: rgba(255, 255, 255, 0.08);
}
.fm-btn {
border: none;
border-radius: 6px;
padding: 6px 14px;
font-size: 14px;
cursor: pointer;
background: rgba(255, 255, 255, 0.08);
color: inherit;
transition: background 0.2s, transform 0.2s;
}
.fm-btn:hover {
background: rgba(255, 255, 255, 0.16);
}
.fm-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.fm-btn.primary {
background: #3d8bfd;
color: #fff;
}
.fm-btn.primary:hover {
background: #377df5;
}
.fm-btn.danger {
background: #f06595;
color: #fff;
}
.fm-btn.danger:hover {
background: #e64980;
}
.fm-main {
flex: 1;
display: grid;
grid-template-columns: 260px 1fr;
overflow: hidden;
}
.fm-sidebar {
border-right: 1px solid rgba(255, 255, 255, 0.06);
padding: 16px 12px;
overflow-y: auto;
background: rgba(17, 18, 26, 0.92);
}
.fm-sidebar-title {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 12px;
}
.fm-tree {
list-style: none;
padding-left: 0;
margin: 0;
font-size: 14px;
}
.fm-tree li {
margin: 4px 0;
}
.fm-tree-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
}
.fm-tree-item:hover,
.fm-tree-item.active {
background: rgba(255, 255, 255, 0.1);
}
.fm-tree-toggle {
width: 16px;
text-align: center;
cursor: pointer;
color: rgba(255, 255, 255, 0.6);
}
.fm-tree-children {
list-style: none;
padding-left: 16px;
margin: 6px 0 0;
border-left: 1px dashed rgba(255, 255, 255, 0.1);
}
.fm-content {
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.fm-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(17, 18, 26, 0.8);
backdrop-filter: blur(8px);
position: sticky;
top: 0;
z-index: 10;
}
.fm-toolbar-right {
display: flex;
gap: 10px;
}
.fm-selection-info {
font-size: 13px;
color: rgba(255, 255, 255, 0.65);
}
.fm-grid {
flex: 1;
padding: 18px;
position: relative;
overflow: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 14px;
}
.fm-card {
background: rgba(255, 255, 255, 0.05);
border: 1px solid transparent;
border-radius: 12px;
padding: 14px;
display: flex;
flex-direction: column;
gap: 12px;
justify-content: space-between;
cursor: pointer;
transition: border 0.2s, background 0.2s, transform 0.2s;
user-select: none;
aspect-ratio: 1;
}
.fm-card:hover {
border-color: rgba(255, 255, 255, 0.16);
}
.fm-card.selected {
border-color: #3d8bfd;
background: rgba(61, 139, 253, 0.2);
}
.fm-card-icon {
font-size: 32px;
}
.fm-card-name {
font-size: 14px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fm-card-meta {
font-size: 12px;
color: rgba(255, 255, 255, 0.6);
line-height: 1.5;
}
.fm-status-bar {
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding: 8px 16px;
font-size: 12px;
color: rgba(255, 255, 255, 0.55);
background: rgba(17, 18, 26, 0.8);
}
.fm-context-menu {
position: fixed;
z-index: 1000;
background: rgba(17, 18, 26, 0.95);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
padding: 6px 0;
width: 180px;
display: none;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
}
.fm-context-menu button {
width: 100%;
border: none;
background: transparent;
color: inherit;
padding: 8px 16px;
text-align: left;
font-size: 14px;
cursor: pointer;
}
.fm-context-menu button:hover {
background: rgba(255, 255, 255, 0.1);
}
.fm-dialog-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 1100;
}
.fm-dialog-backdrop[hidden] {
display: none !important;
}
.fm-dialog {
background: #1f212b;
border-radius: 12px;
padding: 24px;
width: 360px;
max-width: 92vw;
}
.fm-dialog h3 {
margin: 0 0 16px;
}
.fm-dialog input,
.fm-dialog textarea {
width: 100%;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05);
color: inherit;
font-size: 14px;
}
.fm-dialog textarea {
min-height: 120px;
resize: vertical;
}
.fm-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
}
.fm-selection-rect {
position: absolute;
border: 1px solid rgba(61, 139, 253, 0.8);
background: rgba(61, 139, 253, 0.25);
pointer-events: none;
z-index: 50;
}
.fm-drop-overlay {
position: absolute;
inset: 0;
border: 2px dashed rgba(61, 139, 253, 0.8);
background: rgba(61, 139, 253, 0.12);
display: none;
justify-content: center;
align-items: center;
font-size: 16px;
font-weight: 600;
color: rgba(61, 139, 253, 0.9);
}
.fm-grid.drop-target .fm-drop-overlay {
display: flex;
}

View File

@ -320,7 +320,6 @@
@send-message="sendMessage"
@send-or-stop="handleSendOrStop"
@quick-upload="handleQuickUpload"
@open-file-manager="openGuiFileManager"
@toggle-tool-menu="toggleToolMenu"
@toggle-mode-menu="toggleModeMenu"
@toggle-model-menu="toggleModelMenu"

View File

@ -138,12 +138,6 @@ export const systemMethods = {
}
return style;
},
openGuiFileManager() {
if (this.isPolicyBlocked('block_file_manager', '文件管理器已被管理员禁用')) {
return;
}
window.open('/file-manager', '_blank');
},
renderMarkdown(content, isStreaming = false) {
return renderMarkdownHelper(content, isStreaming);
},

View File

@ -435,7 +435,6 @@
:goal-mode-armed="goalModeArmed"
:goal-running="goalRunning"
@quick-upload="triggerQuickUpload"
@open-file-manager="$emit('open-file-manager')"
@pick-images="$emit('pick-images')"
@pick-video="$emit('pick-video')"
@toggle-tool-menu="$emit('toggle-tool-menu')"
@ -605,7 +604,6 @@ const emit = defineEmits([
'send-message',
'send-or-stop',
'quick-upload',
'open-file-manager',
'pick-images',
'pick-video',
'toggle-tool-menu',

View File

@ -11,15 +11,6 @@
>
{{ uploading ? '上传中...' : '上传文件' }}
</button>
<button
type="button"
class="menu-entry"
data-tutorial="quick-file-manager"
@click.stop="$emit('open-file-manager')"
:disabled="!isConnected"
>
文件管理
</button>
<button
type="button"
class="menu-entry"
@ -215,7 +206,6 @@ const props = defineProps<{
defineEmits<{
(event: 'quick-upload'): void;
(event: 'open-file-manager'): void;
(event: 'toggle-tool-menu'): void;
(event: 'toggle-settings'): void;
(event: 'update-tool-category', id: string, enabled: boolean): void;