qwen_agent/public/admin.html

3111 lines
124 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>管理后台 - Qwen Agent</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/5.3.8/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.bootcdn.net/ajax/libs/bootstrap-icons/1.13.1/font/bootstrap-icons.css">
<style>
:root {
--primary-color: #0d6efd;
--secondary-color: #6c757d;
--success-color: #198754;
--warning-color: #ffc107;
--error-color: #dc3545;
--info-color: #0dcaf0;
--sidebar-bg: #212529;
--sidebar-text: #ffffff;
--card-bg: #ffffff;
--text-primary: #212529;
--text-secondary: #6c757d;
--border-color: #dee2e6;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #f8f9fa;
color: var(--text-primary);
line-height: 1.6;
height: 100vh;
overflow: hidden;
}
/* 登录页面样式 */
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
background: white;
padding: 2.5rem;
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
width: 100%;
max-width: 400px;
}
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.login-header h1 {
color: var(--primary-color);
font-size: 2rem;
margin-bottom: 0.5rem;
}
/* 管理后台主界面样式 */
.admin-container {
display: none; /* 默认隐藏,登录后显示 */
height: 100vh;
overflow: hidden;
}
.sidebar {
width: 280px;
background-color: var(--sidebar-bg);
color: var(--sidebar-text);
height: 100vh;
overflow-y: auto;
border-right: 1px solid rgba(255, 255, 255, 0.1);
}
.sidebar-header {
padding: 1.5rem;
background-color: rgba(0, 0, 0, 0.2);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.sidebar-header h2 {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
}
.sidebar-nav {
padding: 0;
}
.nav-item {
display: block;
padding: 0.75rem 1.5rem;
color: var(--sidebar-text);
text-decoration: none;
transition: background-color 0.3s;
border: none;
background: none;
width: 100%;
text-align: left;
cursor: pointer;
font-size: 0.95rem;
border-radius: 0;
}
.nav-item:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.nav-item.active {
background-color: var(--primary-color);
border-left: 4px solid #64b5f6;
color: white;
}
.main-content {
height: 100vh;
overflow-y: auto;
background-color: #f8f9fa;
}
.content-header {
background-color: white;
padding: 1.5rem;
border-bottom: 1px solid var(--border-color);
position: sticky;
top: 0;
z-index: 10;
}
.content-header h1 {
font-size: 1.75rem;
color: var(--text-primary);
margin: 0;
}
.user-info {
display: flex;
align-items: center;
gap: 1rem;
}
.btn-logout {
background-color: var(--error-color);
color: white;
}
.btn-logout:hover {
background-color: #bb2d3b;
}
/* 页面内容区域 */
.page {
display: none;
animation: fadeIn 0.3s ease-in-out;
padding: 1.5rem;
}
.page.active {
display: block;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
/* 统计卡片 */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: var(--card-bg);
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
text-align: center;
transition: transform 0.3s, box-shadow 0.3s;
border: 1px solid rgba(0, 0, 0, 0.125);
}
.stat-card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.15);
}
.stat-icon {
font-size: 2.5rem;
margin-bottom: 1rem;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 0.5rem;
}
.stat-label {
color: var(--text-secondary);
font-size: 0.875rem;
}
/* 表格样式覆盖 */
.table-container {
background-color: var(--card-bg);
border-radius: 8px;
overflow: hidden;
}
/* 状态标签 */
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
}
/* 自定义Toast消息提示 */
.custom-toast {
position: fixed;
top: 20px;
right: 20px;
padding: 1rem 1.5rem;
border-radius: 5px;
color: white;
z-index: 10000;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.custom-toast.success { background-color: var(--success-color); }
.custom-toast.error { background-color: var(--error-color); }
.custom-toast.warning { background-color: var(--warning-color); }
.custom-toast.info { background-color: var(--info-color); }
/* 文件管理相关样式 */
.file-toolbar {
background: var(--card-bg);
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.file-list-container {
background: var(--card-bg);
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.file-item {
display: flex;
align-items: center;
padding: 12px 20px;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
transition: background-color 0.2s;
}
.file-item:hover {
background-color: #f8f9fa;
}
.file-item.selected {
background-color: #e3f2fd;
}
.file-item:last-child {
border-bottom: none;
}
.file-icon {
width: 24px;
height: 24px;
margin-right: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.file-info {
flex: 1;
min-width: 0;
}
.file-name {
font-weight: 500;
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-meta {
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
}
.file-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
margin-left: 12px;
}
.file-breadcrumb {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 15px;
font-size: 14px;
padding: 10px 0;
flex-wrap: wrap;
}
.breadcrumb-item {
cursor: pointer;
color: var(--primary-color);
white-space: nowrap;
}
.breadcrumb-item:hover {
text-decoration: underline;
}
.file-search-box {
flex: 1;
min-width: 200px;
}
.upload-area {
border: 2px dashed var(--border-color);
border-radius: 8px;
padding: 40px;
text-align: center;
cursor: pointer;
transition: border-color 0.2s, background-color 0.2s;
margin-bottom: 15px;
}
.upload-area:hover {
border-color: var(--primary-color);
background-color: rgba(13, 110, 253, 0.02);
}
.upload-area.dragover {
border-color: var(--primary-color);
background-color: rgba(13, 110, 253, 0.05);
}
.file-actions .btn {
padding: 4px 8px;
font-size: 12px;
min-width: auto;
}
/* 加载和空状态显示 */
.loading {
padding: 40px 20px;
text-align: center;
color: var(--text-secondary);
}
.empty-state {
padding: 40px 20px;
text-align: center;
color: var(--text-secondary);
}
/* 修复面包屑分隔符显示 */
.file-breadcrumb span:not(.breadcrumb-item) {
color: var(--text-secondary);
margin: 0 4px;
}
/* 小屏幕优化 */
@media (max-width: 768px) {
.file-item {
padding: 8px 12px;
}
.file-actions {
flex-direction: column;
gap: 4px;
margin-left: 8px;
}
.file-actions .btn {
padding: 2px 6px;
font-size: 11px;
}
.file-name {
font-size: 14px;
}
.file-meta {
font-size: 11px;
}
.sidebar {
width: 100%;
height: auto;
position: relative;
}
.main-content {
height: 100vh;
}
.content-header {
padding: 1rem;
}
.page {
padding: 1rem;
}
}
/* 大屏幕优化 */
@media (min-width: 769px) {
.stats-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (min-width: 1200px) {
.stats-grid {
grid-template-columns: repeat(6, 1fr);
}
}
/* 加载动画 */
.loading::after {
content: '';
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid var(--border-color);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-left: 10px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* 空状态 */
.empty-state-icon {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.5;
}
/* 文件编辑器相关样式 */
.editor-container {
border: 1px solid var(--border-color);
border-radius: 0.375rem;
overflow: hidden;
}
.editor-wrapper {
display: flex;
position: relative;
}
.line-numbers {
background-color: #f8f9fa;
border-right: 1px solid var(--border-color);
color: #6c757d;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 14px;
line-height: 1.5;
padding: 12px 8px;
text-align: right;
user-select: none;
min-width: 40px;
overflow: hidden;
}
.editor-container textarea {
border: none;
border-radius: 0;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 14px;
line-height: 1.5;
resize: vertical;
min-height: 500px;
flex: 1;
padding: 12px;
tab-size: 4;
}
.editor-container textarea:focus {
border: none;
box-shadow: none;
outline: none;
}
/* 快捷键样式 */
kbd {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 0.25rem;
color: #495057;
display: inline-block;
font-size: 0.75rem;
font-weight: 700;
line-height: 1;
padding: 0.125rem 0.25rem;
white-space: nowrap;
}
/* Markdown 预览样式 */
.markdown-preview {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: var(--text-primary);
max-height: 600px;
overflow-y: auto;
padding: 1rem;
background-color: #f8f9fa;
border-radius: 0.375rem;
}
.markdown-preview h1, .markdown-preview h2, .markdown-preview h3,
.markdown-preview h4, .markdown-preview h5, .markdown-preview h6 {
margin-top: 1.5rem;
margin-bottom: 1rem;
font-weight: 600;
}
.markdown-preview h1 { font-size: 2rem; border-bottom: 2px solid #eee; padding-bottom: 0.5rem; }
.markdown-preview h2 { font-size: 1.5rem; border-bottom: 1px solid #eee; padding-bottom: 0.3rem; }
.markdown-preview h3 { font-size: 1.25rem; }
.markdown-preview h4 { font-size: 1rem; }
.markdown-preview h5 { font-size: 0.875rem; }
.markdown-preview h6 { font-size: 0.75rem; }
.markdown-preview p {
margin-bottom: 1rem;
}
.markdown-preview ul, .markdown-preview ol {
margin-bottom: 1rem;
padding-left: 2rem;
}
.markdown-preview li {
margin-bottom: 0.5rem;
}
.markdown-preview code {
background-color: rgba(13, 110, 253, 0.1);
color: #d63384;
padding: 0.2rem 0.4rem;
border-radius: 0.25rem;
font-size: 0.875em;
}
.markdown-preview pre {
background-color: #212529;
color: #f8f9fa;
padding: 1rem;
border-radius: 0.375rem;
overflow-x: auto;
margin-bottom: 1rem;
}
.markdown-preview pre code {
background-color: transparent;
color: inherit;
padding: 0;
border-radius: 0;
font-size: inherit;
}
.markdown-preview blockquote {
border-left: 4px solid var(--primary-color);
padding-left: 1rem;
margin: 1rem 0;
color: var(--text-secondary);
font-style: italic;
}
.markdown-preview table {
border-collapse: collapse;
width: 100%;
margin-bottom: 1rem;
}
.markdown-preview th, .markdown-preview td {
border: 1px solid #dee2e6;
padding: 0.75rem;
text-align: left;
}
.markdown-preview th {
background-color: #f8f9fa;
font-weight: 600;
}
/* 文件编辑状态指示器 */
.edit-status {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
}
.edit-status.modified {
color: var(--warning-color);
}
.edit-status.saved {
color: var(--success-color);
}
.edit-status.error {
color: var(--error-color);
}
</style>
</head>
<body>
<!-- 登录页面 -->
<div class="login-container" id="loginPage">
<div class="login-card">
<div class="login-header text-center mb-4">
<h1>🔐 管理后台</h1>
<p class="text-muted">Qwen Agent 系统管理</p>
</div>
<form id="loginForm">
<div class="mb-3">
<label for="username" class="form-label">用户名</label>
<input type="text" class="form-control" id="username" name="username" required placeholder="请输入用户名">
</div>
<div class="mb-3">
<label for="password" class="form-label">密码</label>
<input type="password" class="form-control" id="password" name="password" required placeholder="请输入密码">
</div>
<button type="submit" class="btn btn-primary w-100" id="loginBtn">登录</button>
</form>
</div>
</div>
<!-- 管理后台主界面 -->
<div class="admin-container h-100" id="adminContainer">
<div class="container-fluid h-100 p-0">
<div class="row h-100 g-0">
<!-- 侧边栏 -->
<div class="col-md-3 col-lg-2 d-md-block">
<aside class="sidebar h-100">
<div class="sidebar-header">
<h2>🎛️ 控制面板</h2>
</div>
<nav class="sidebar-nav">
<button class="nav-item active w-100 text-start" data-page="dashboard">
<i class="bi bi-speedometer2 me-2"></i> 系统概览
</button>
<button class="nav-item w-100 text-start" data-page="queue">
<i class="bi bi-clock-history me-2"></i> 队列管理
</button>
<button class="nav-item w-100 text-start" data-page="files">
<i class="bi bi-folder me-2"></i> 文件管理
</button>
<button class="nav-item w-100 text-start" data-page="projects">
<i class="bi bi-collection me-2"></i> 项目管理
</button>
<button class="nav-item w-100 text-start" data-page="settings">
<i class="bi bi-gear me-2"></i> 系统设置
</button>
</nav>
</aside>
</div>
<!-- 主内容区 -->
<div class="col-md-9 col-lg-10">
<main class="main-content h-100">
<header class="content-header">
<div class="d-flex justify-content-between align-items-center">
<h1 id="pageTitle" class="h3 mb-0">系统概览</h1>
<div class="user-info">
<span class="me-3">
<i class="bi bi-person-circle me-1"></i> <span id="currentUser">admin</span>
</span>
<button class="btn btn-outline-danger btn-sm" onclick="logout()">
<i class="bi bi-box-arrow-right me-1"></i> 退出登录
</button>
</div>
</div>
</header>
<!-- 仪表板页面 -->
<div class="page active" id="dashboard">
<div class="stats-grid">
<div class="card stat-card text-center">
<div class="card-body">
<div class="stat-icon text-primary mb-2">
<i class="bi bi-list-check" style="font-size: 2.5rem;"></i>
</div>
<div class="stat-value h2 fw-bold" id="totalTasks">-</div>
<div class="stat-label text-muted small">总任务数</div>
</div>
</div>
<div class="card stat-card text-center">
<div class="card-body">
<div class="stat-icon text-warning mb-2">
<i class="bi bi-clock" style="font-size: 2.5rem;"></i>
</div>
<div class="stat-value h2 fw-bold" id="pendingTasks">-</div>
<div class="stat-label text-muted small">待处理</div>
</div>
</div>
<div class="card stat-card text-center">
<div class="card-body">
<div class="stat-icon text-info mb-2">
<i class="bi bi-arrow-repeat" style="font-size: 2.5rem;"></i>
</div>
<div class="stat-value h2 fw-bold" id="processingTasks">-</div>
<div class="stat-label text-muted small">处理中</div>
</div>
</div>
<div class="card stat-card text-center">
<div class="card-body">
<div class="stat-icon text-success mb-2">
<i class="bi bi-check-circle" style="font-size: 2.5rem;"></i>
</div>
<div class="stat-value h2 fw-bold" id="completedTasks">-</div>
<div class="stat-label text-muted small">已完成</div>
</div>
</div>
<div class="card stat-card text-center">
<div class="card-body">
<div class="stat-icon text-danger mb-2">
<i class="bi bi-x-circle" style="font-size: 2.5rem;"></i>
</div>
<div class="stat-value h2 fw-bold" id="failedTasks">-</div>
<div class="stat-label text-muted small">失败</div>
</div>
</div>
<div class="card stat-card text-center">
<div class="card-body">
<div class="stat-icon text-secondary mb-2">
<i class="bi bi-folder" style="font-size: 2.5rem;"></i>
</div>
<div class="stat-value h2 fw-bold" id="totalProjects">-</div>
<div class="stat-label text-muted small">项目总数</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="bi bi-fire me-2"></i> 最近任务
</h5>
</div>
<div class="card-body">
<div id="recentTasks" class="loading">加载中...</div>
</div>
</div>
</div>
<!-- 队列管理页面 -->
<div class="page" id="queue">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="bi bi-clock-history me-2"></i> 任务队列
</h5>
</div>
<div class="card-body">
<div id="queueContent" class="loading">加载中...</div>
</div>
</div>
</div>
<!-- 文件管理页面 -->
<div class="page" id="files">
<!-- 文件管理工具栏 -->
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="bi bi-folder me-2"></i> 项目文件管理器
</h5>
</div>
<div class="card-body">
<div class="file-toolbar">
<div class="file-search-box">
<input type="text" class="form-control" id="fileSearchInput" placeholder="搜索文件..." />
</div>
<div class="btn-group" role="group">
<button class="btn btn-primary" onclick="showUploadModal()">
<i class="bi bi-upload me-1"></i> 上传文件
</button>
<button class="btn btn-success" onclick="showCreateFolderModal()">
<i class="bi bi-folder-plus me-1"></i> 新建文件夹
</button>
<button class="btn btn-secondary" onclick="refreshFileList()">
<i class="bi bi-arrow-clockwise me-1"></i> 刷新
</button>
<button class="btn btn-info" onclick="toggleBatchMode()" id="batchModeBtn">
<i class="bi bi-check-square me-1"></i> 批量操作
</button>
<button class="btn btn-primary" onclick="selectAll()" id="selectAllBtn" style="display: none;">
<i class="bi bi-check-all me-1"></i> 全选
</button>
<button class="btn btn-success" onclick="downloadSelected()" id="downloadSelectedBtn" style="display: none;">
<i class="bi bi-download me-1"></i> 下载选中
</button>
</div>
</div>
<!-- 面包屑导航 -->
<nav aria-label="breadcrumb" class="file-breadcrumb" id="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a href="#" onclick="navigateToPath('')">
<i class="bi bi-house me-1"></i> 根目录
</a>
</li>
</ol>
</nav>
<!-- 文件列表 -->
<div class="file-list-container border rounded">
<div id="fileList">
<div class="loading">加载中...</div>
</div>
</div>
</div>
</div>
</div>
<!-- 项目管理页面 -->
<div class="page" id="projects">
<div class="row">
<!-- 机器人项目 -->
<div class="col-lg-6">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="bi bi-robot me-2"></i> 机器人项目
</h5>
</div>
<div class="card-body">
<div id="robotProjectsContent" class="loading">加载中...</div>
</div>
</div>
</div>
<!-- 数据集 -->
<div class="col-lg-6">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="bi bi-database me-2"></i> 数据集
</h5>
</div>
<div class="card-body">
<div id="datasetsContent" class="loading">加载中...</div>
</div>
</div>
</div>
</div>
</div>
<!-- 系统设置页面 -->
<div class="page" id="settings">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="bi bi-gear me-2"></i> 系统设置
</h5>
</div>
<div class="card-body">
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>
系统设置功能正在开发中...
</div>
</div>
</div>
</div>
</main>
</div>
</div>
<!-- 文件上传模态框 -->
<div class="modal fade" id="uploadModal" tabindex="-1" aria-labelledby="uploadModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="uploadModalLabel">
<i class="bi bi-upload me-2"></i>上传文件
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="upload-area" id="uploadArea">
<p class="mb-0">📁 拖拽文件到这里或点击选择文件</p>
<input type="file" id="fileInput" multiple style="display: none;" />
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
</div>
</div>
</div>
</div>
<!-- 新建文件夹模态框 -->
<div class="modal fade" id="createFolderModal" tabindex="-1" aria-labelledby="createFolderModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="createFolderModalLabel">
<i class="bi bi-folder-plus me-2"></i>新建文件夹
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="folderNameInput" class="form-label">文件夹名称:</label>
<input type="text" class="form-control" id="folderNameInput" placeholder="输入文件夹名称" />
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" onclick="createFolder()">创建</button>
</div>
</div>
</div>
</div>
<!-- 重命名模态框 -->
<div class="modal fade" id="renameModal" tabindex="-1" aria-labelledby="renameModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="renameModalLabel">
<i class="bi bi-pencil me-2"></i>重命名
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="renameInput" class="form-label">新名称:</label>
<input type="text" class="form-control" id="renameInput" placeholder="输入新名称" />
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" onclick="renameItem()">重命名</button>
</div>
</div>
</div>
</div>
<!-- 文件编辑模态框 -->
<div class="modal fade" id="editFileModal" tabindex="-1" aria-labelledby="editFileModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="editFileModalLabel">
<i class="bi bi-file-earmark-code me-2"></i>编辑文件
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="editFileName" class="form-label">文件路径:</label>
<input type="text" class="form-control" id="editFileName" readonly />
</div>
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<label for="fileContent" class="form-label">文件内容:</label>
<div class="btn-group" role="group">
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="toggleWordWrap()">
<i class="bi bi-text-wrap me-1"></i>换行
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="toggleLineNumbers()">
<i class="bi bi-list-ol me-1"></i>行号
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="formatMarkdown()">
<i class="bi bi-markdown me-1"></i>格式化
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="insertTab()">
<i class="bi bi-indent me-1"></i>Tab
</button>
</div>
</div>
<div class="editor-container">
<div class="editor-wrapper">
<div class="line-numbers" id="lineNumbers" style="display: none;">1</div>
<textarea class="form-control font-monospace" id="fileContent" rows="25" placeholder="文件内容将在这里显示..." style="min-height: 500px; resize: vertical;" spellcheck="false"></textarea>
</div>
</div>
</div>
<div class="d-flex justify-content-between align-items-center">
<div class="text-muted">
<small id="editFileInfo">准备编辑...</small>
</div>
<div class="btn-group" role="group">
<button type="button" class="btn btn-outline-warning" onclick="previewFile()">
<i class="bi bi-eye me-1"></i>预览
</button>
<button type="button" class="btn btn-outline-secondary" onclick="resetContent()">
<i class="bi bi-arrow-clockwise me-1"></i>重置
</button>
</div>
</div>
</div>
<div class="modal-footer">
<div class="text-start">
<small class="text-muted">
快捷键: <kbd>Ctrl+S</kbd> 保存 | <kbd>Ctrl+P</kbd> 预览 | <kbd>Ctrl+L</kbd> 行号 | <kbd>Ctrl+W</kbd> 换行 | <kbd>Ctrl+/</kbd> 注释 | <kbd>Ctrl+D</kbd> 复制行 | <kbd>Tab</kbd> 缩进
</small>
</div>
<div>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" onclick="saveFile()">
<i class="bi bi-save me-1"></i>保存文件
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 文件预览模态框 -->
<div class="modal fade" id="previewFileModal" tabindex="-1" aria-labelledby="previewFileModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="previewFileModalLabel">
<i class="bi bi-eye me-2"></i>文件预览
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="previewContent" class="markdown-preview">
<!-- Markdown 预览内容将在这里显示 -->
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
</div>
</div>
</div>
</div>
<script>
// 全局变量
const API_BASE = `${window.location.protocol}//${window.location.host}/api/v1`;
const FILE_API_BASE = `${window.location.protocol}//${window.location.host}/api/v1/file-manager`;
let isLoggedIn = false;
let currentPage = 'dashboard';
let refreshInterval = null;
// 文件管理相关变量
let currentPath = '';
let selectedItems = new Set();
let currentItemToRename = '';
let batchMode = false;
// 文件编辑相关变量
let currentEditingFile = '';
let originalFileContent = '';
let fileEditStatus = 'ready'; // ready, modified, saving, error
let showLineNumbers = false;
// 初始化
document.addEventListener('DOMContentLoaded', function() {
// 检查登录状态
checkLoginStatus();
// 设置事件监听器
setupEventListeners();
});
// 设置事件监听器
function setupEventListeners() {
// 登录表单
document.getElementById('loginForm').addEventListener('submit', handleLogin);
// 导航按钮
document.querySelectorAll('.nav-item').forEach(btn => {
btn.addEventListener('click', function() {
const page = this.dataset.page;
if (page) {
navigateToPage(page);
}
});
});
// 文件管理事件监听器
setupFileEventListeners();
}
// 设置文件管理事件监听器
function setupFileEventListeners() {
// 搜索功能
const searchInput = document.getElementById('fileSearchInput');
if (searchInput) {
searchInput.addEventListener('input', function(e) {
if (e.target.value.trim()) {
searchFiles(e.target.value.trim());
} else {
loadFileList();
}
});
}
// 文件上传
const uploadArea = document.getElementById('uploadArea');
const fileInput = document.getElementById('fileInput');
if (uploadArea && fileInput) {
uploadArea.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', handleFileSelect);
// 拖拽上传
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('dragover');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
handleFiles(e.dataTransfer.files);
});
}
// Bootstrap模态框会自动处理点击外部关闭
// 但我们仍需要处理文件上传区域的事件
}
// 检查登录状态
function checkLoginStatus() {
const token = localStorage.getItem('admin_token');
const username = localStorage.getItem('admin_username');
if (token && username) {
// 验证token是否有效这里简单检查实际应该调用API验证
isLoggedIn = true;
document.getElementById('currentUser').textContent = username;
showAdminPanel();
} else {
showLoginPage();
}
}
// 处理登录
async function handleLogin(e) {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const loginBtn = document.getElementById('loginBtn');
// 禁用登录按钮
loginBtn.disabled = true;
loginBtn.textContent = '登录中...';
try {
// 简单的登录验证实际应该调用后端API
if (username === 'admin' && password === 'admin123') {
// 模拟登录成功
localStorage.setItem('admin_token', 'mock_token_' + Date.now());
localStorage.setItem('admin_username', username);
isLoggedIn = true;
document.getElementById('currentUser').textContent = username;
showMessage('登录成功', 'success');
setTimeout(() => {
showAdminPanel();
}, 1000);
} else {
showMessage('用户名或密码错误', 'error');
}
} catch (error) {
console.error('登录失败:', error);
showMessage('登录失败,请重试', 'error');
} finally {
// 恢复登录按钮
loginBtn.disabled = false;
loginBtn.textContent = '登录';
}
}
// 退出登录
function logout() {
if (confirm('确定要退出登录吗?')) {
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_username');
isLoggedIn = false;
// 停止定时刷新
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
}
showMessage('已退出登录', 'info');
showLoginPage();
}
}
// 显示登录页面
function showLoginPage() {
document.getElementById('loginPage').style.display = 'flex';
document.getElementById('adminContainer').style.display = 'none';
// 清空表单
document.getElementById('loginForm').reset();
}
// 显示管理后台
function showAdminPanel() {
document.getElementById('loginPage').style.display = 'none';
document.getElementById('adminContainer').style.display = 'block';
// 加载仪表板数据
loadDashboardData();
// 设置定时刷新
refreshInterval = setInterval(() => {
if (currentPage === 'dashboard') {
loadDashboardData();
} else if (currentPage === 'queue') {
loadQueueData();
}
}, 30000); // 每30秒刷新一次
}
// 导航到页面
function navigateToPage(page) {
// 更新导航状态
document.querySelectorAll('.nav-item').forEach(btn => {
btn.classList.remove('active');
});
document.querySelector(`[data-page="${page}"]`).classList.add('active');
// 隐藏所有页面
document.querySelectorAll('.page').forEach(p => {
p.classList.remove('active');
});
// 显示目标页面
document.getElementById(page).classList.add('active');
currentPage = page;
// 更新页面标题
const titles = {
dashboard: '系统概览',
queue: '队列管理',
files: '文件管理',
projects: '项目管理',
settings: '系统设置'
};
document.getElementById('pageTitle').textContent = titles[page] || '管理后台';
// 加载页面数据
loadPageData(page);
}
// 加载页面数据
function loadPageData(page) {
switch (page) {
case 'dashboard':
loadDashboardData();
break;
case 'queue':
loadQueueData();
break;
case 'files':
loadFileList();
break;
case 'projects':
loadProjectsData();
break;
}
}
// 加载仪表板数据
async function loadDashboardData() {
try {
// 加载任务统计
await loadTaskStats();
// 加载最近任务
await loadRecentTasks();
// 加载项目统计
await loadProjectStats();
} catch (error) {
console.error('加载仪表板数据失败:', error);
showMessage('加载数据失败', 'error');
}
}
// 加载任务统计
async function loadTaskStats() {
try {
const response = await fetch(`${API_BASE}/tasks/statistics`);
const data = await response.json();
if (data.success) {
const stats = data.statistics;
const statusBreakdown = stats.status_breakdown || {};
document.getElementById('totalTasks').textContent = stats.total_tasks || 0;
document.getElementById('pendingTasks').textContent = statusBreakdown.pending || 0;
document.getElementById('processingTasks').textContent = statusBreakdown.processing || 0;
document.getElementById('completedTasks').textContent = statusBreakdown.completed || 0;
document.getElementById('failedTasks').textContent = statusBreakdown.failed || 0;
}
} catch (error) {
console.error('加载任务统计失败:', error);
}
}
// 加载最近任务
async function loadRecentTasks() {
try {
const response = await fetch(`${API_BASE}/tasks?limit=10`);
const data = await response.json();
const container = document.getElementById('recentTasks');
if (data.success && data.tasks && data.tasks.length > 0) {
const html = `
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>任务ID</th>
<th>项目ID</th>
<th>状态</th>
<th>创建时间</th>
</tr>
</thead>
<tbody>
${data.tasks.map(task => `
<tr>
<td><code>${task.task_id || '-'}</code></td>
<td><code>${task.unique_id || '-'}</code></td>
<td>${getStatusBadge(task.status)}</td>
<td>${formatDate(task.created_at)}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
container.innerHTML = html;
} else {
container.innerHTML = '<div class="empty-state text-center py-5"><div class="empty-state-icon fs-1 mb-3">📋</div><p class="text-muted">暂无任务记录</p></div>';
}
} catch (error) {
console.error('加载最近任务失败:', error);
document.getElementById('recentTasks').innerHTML = '<div class="empty-state"><p>加载失败</p></div>';
}
}
// 加载项目统计
async function loadProjectStats() {
try {
const [robotResponse, datasetResponse] = await Promise.all([
fetch(`${API_BASE}/projects/robot`),
fetch(`${API_BASE}/projects/datasets`)
]);
const robotData = await robotResponse.json();
const datasetData = await datasetResponse.json();
let totalProjects = 0;
if (robotData.success) {
totalProjects += robotData.projects ? robotData.projects.length : 0;
}
if (datasetData.success) {
totalProjects += datasetData.projects ? datasetData.projects.length : 0;
}
document.getElementById('totalProjects').textContent = totalProjects;
} catch (error) {
console.error('加载项目统计失败:', error);
document.getElementById('totalProjects').textContent = '0';
}
}
// 加载队列数据
async function loadQueueData() {
try {
const response = await fetch(`${API_BASE}/tasks`);
const data = await response.json();
const container = document.getElementById('queueContent');
if (data.success && data.tasks) {
const html = `
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>任务ID</th>
<th>项目ID</th>
<th>状态</th>
<th>创建时间</th>
<th>更新时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
${data.tasks.map(task => `
<tr>
<td><code>${task.task_id || '-'}</code></td>
<td><code>${task.unique_id || '-'}</code></td>
<td>${getStatusBadge(task.status)}</td>
<td>${formatDate(task.created_at)}</td>
<td>${formatDate(task.updated_at)}</td>
<td>
<div class="btn-group" role="group">
${task.status === 'failed' ? `<button class="btn btn-primary btn-sm" onclick="retryTask('${task.task_id}')"><i class="bi bi-arrow-clockwise me-1"></i>重试</button>` : ''}
${task.status === 'pending' ? `<button class="btn btn-warning btn-sm" onclick="cancelTask('${task.task_id}')"><i class="bi bi-x-circle me-1"></i>取消</button>` : ''}
</div>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
container.innerHTML = html;
} else {
container.innerHTML = '<div class="empty-state text-center py-5"><div class="empty-state-icon fs-1 mb-3">⏳</div><p class="text-muted">队列为空</p></div>';
}
} catch (error) {
console.error('加载队列数据失败:', error);
document.getElementById('queueContent').innerHTML = '<div class="empty-state"><p>加载失败</p></div>';
}
}
// 加载项目数据
async function loadProjectsData() {
try {
// 加载机器人项目
await loadRobotProjects();
// 加载数据集
await loadDatasets();
} catch (error) {
console.error('加载项目数据失败:', error);
document.getElementById('robotProjectsContent').innerHTML = '<div class="empty-state"><p>加载失败</p></div>';
document.getElementById('datasetsContent').innerHTML = '<div class="empty-state"><p>加载失败</p></div>';
}
}
// 加载机器人项目
async function loadRobotProjects() {
try {
const response = await fetch(`${API_BASE}/projects/robot`);
const data = await response.json();
const container = document.getElementById('robotProjectsContent');
if (data.success && data.projects && data.projects.length > 0) {
const html = `
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>项目ID</th>
<th>名称</th>
<th>文件数量</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
${data.projects.map(project => `
<tr>
<td><code>${project.id}</code></td>
<td>${project.name || '-'}</td>
<td><span class="badge bg-secondary">${project.file_count || 0}</span></td>
<td>${getProjectStatusBadge(project.status)}</td>
<td>${formatDate(project.created_at)}</td>
<td>
<div class="btn-group" role="group">
<button class="btn btn-outline-primary btn-sm" onclick="viewRobotProject('${project.id}')"><i class="bi bi-eye me-1"></i>查看</button>
<button class="btn btn-outline-danger btn-sm" onclick="deleteRobotProject('${project.id}')"><i class="bi bi-trash me-1"></i>删除</button>
</div>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
container.innerHTML = html;
} else {
container.innerHTML = '<div class="empty-state text-center py-5"><div class="empty-state-icon fs-1 mb-3">🤖</div><p class="text-muted">暂无机器人项目</p></div>';
}
} catch (error) {
console.error('加载机器人项目失败:', error);
document.getElementById('robotProjectsContent').innerHTML = '<div class="empty-state"><p>加载失败</p></div>';
}
}
// 加载数据集
async function loadDatasets() {
try {
const response = await fetch(`${API_BASE}/projects/datasets`);
const data = await response.json();
const container = document.getElementById('datasetsContent');
if (data.success && data.projects && data.projects.length > 0) {
const html = `
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>数据集ID</th>
<th>名称</th>
<th>文件数量</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
${data.projects.map(dataset => `
<tr>
<td><code>${dataset.id}</code></td>
<td>${dataset.name || '-'}</td>
<td><span class="badge bg-secondary">${dataset.file_count || 0}</span></td>
<td>${getDatasetStatusBadge(dataset.status)}</td>
<td>${formatDate(dataset.created_at)}</td>
<td>
<div class="btn-group" role="group">
<button class="btn btn-outline-primary btn-sm" onclick="viewDataset('${dataset.id}')"><i class="bi bi-eye me-1"></i>查看</button>
<button class="btn btn-outline-danger btn-sm" onclick="deleteDataset('${dataset.id}')"><i class="bi bi-trash me-1"></i>删除</button>
</div>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
container.innerHTML = html;
} else {
container.innerHTML = '<div class="empty-state text-center py-5"><div class="empty-state-icon fs-1 mb-3">📊</div><p class="text-muted">暂无数据集</p></div>';
}
} catch (error) {
console.error('加载数据集失败:', error);
document.getElementById('datasetsContent').innerHTML = '<div class="empty-state"><p>加载失败</p></div>';
}
}
// 工具函数
function getStatusText(status) {
const statusMap = {
'pending': '待处理',
'processing': '处理中',
'completed': '已完成',
'failed': '失败',
'cancelled': '已取消'
};
return statusMap[status] || status;
}
// Bootstrap 状态标签
function getStatusBadge(status) {
const statusMap = {
'pending': '<span class="badge bg-warning">待处理</span>',
'processing': '<span class="badge bg-info">处理中</span>',
'completed': '<span class="badge bg-success">已完成</span>',
'failed': '<span class="badge bg-danger">失败</span>',
'cancelled': '<span class="badge bg-secondary">已取消</span>'
};
return statusMap[status] || `<span class="badge bg-secondary">${status}</span>`;
}
function getProjectStatusBadge(status) {
const statusMap = {
'active': '<span class="badge bg-success">活跃</span>',
'completed': '<span class="badge bg-primary">已完成</span>',
'failed': '<span class="badge bg-danger">失败</span>',
'unknown': '<span class="badge bg-secondary">未知</span>'
};
return statusMap[status] || `<span class="badge bg-secondary">${status}</span>`;
}
function getDatasetStatusBadge(status) {
const statusMap = {
'active': '<span class="badge bg-success">活跃</span>',
'completed': '<span class="badge bg-primary">已完成</span>',
'processing': '<span class="badge bg-info">处理中</span>',
'failed': '<span class="badge bg-danger">失败</span>',
'unknown': '<span class="badge bg-secondary">未知</span>'
};
return statusMap[status] || `<span class="badge bg-secondary">${status}</span>`;
}
function getProjectStatusText(status) {
const statusMap = {
'active': '活跃',
'completed': '已完成',
'failed': '失败',
'unknown': '未知'
};
return statusMap[status] || status;
}
function getDatasetStatusText(status) {
const statusMap = {
'active': '活跃',
'completed': '已完成',
'processing': '处理中',
'failed': '失败',
'unknown': '未知'
};
return statusMap[status] || status;
}
function formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleString('zh-CN');
}
function showMessage(message, type) {
const toast = document.createElement('div');
toast.className = `custom-toast ${type}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
if (document.body.contains(toast)) {
document.body.removeChild(toast);
}
}, 4000);
}
// 任务操作函数
async function retryTask(taskId) {
try {
showMessage(`正在重试任务 ${taskId}...`, 'info');
// 这里可以调用实际的API来重试任务
// const response = await fetch(`${API_BASE}/tasks/${taskId}/retry`, { method: 'POST' });
showMessage(`任务 ${taskId} 已重新提交`, 'success');
loadQueueData();
} catch (error) {
console.error('重试任务失败:', error);
showMessage(`重试任务失败`, 'error');
}
}
async function cancelTask(taskId) {
if (confirm(`确定要取消任务 ${taskId} 吗?`)) {
try {
// 这里可以调用实际的API来取消任务
// const response = await fetch(`${API_BASE}/tasks/${taskId}/cancel`, { method: 'POST' });
showMessage(`已取消任务 ${taskId}`, 'success');
loadQueueData();
} catch (error) {
console.error('取消任务失败:', error);
showMessage(`取消任务失败`, 'error');
}
}
}
function viewProject(projectId) {
// 切换到队列管理页面并筛选该项目的任务
navigateToPage('queue');
showMessage(`正在查看项目 ${projectId} 的任务`, 'info');
}
function viewRobotProject(projectId) {
navigateToPage('queue');
showMessage(`正在查看机器人项目 ${projectId} 的任务`, 'info');
}
function viewDataset(datasetId) {
navigateToPage('queue');
showMessage(`正在查看数据集 ${datasetId} 的任务`, 'info');
}
async function deleteProject(projectId) {
if (confirm(`确定要删除项目 ${projectId} 吗?此操作不可撤销!`)) {
try {
showMessage(`正在删除项目 ${projectId}...`, 'info');
// 这里可以调用实际的API来删除项目
// const response = await fetch(`${API_BASE}/projects/${projectId}`, { method: 'DELETE' });
showMessage(`已删除项目 ${projectId}`, 'success');
loadProjectsData();
} catch (error) {
console.error('删除项目失败:', error);
showMessage(`删除项目失败`, 'error');
}
}
}
async function deleteRobotProject(projectId) {
if (confirm(`确定要删除机器人项目 ${projectId} 吗?此操作不可撤销!`)) {
try {
showMessage(`正在删除机器人项目 ${projectId}...`, 'info');
// 这里可以调用实际的API来删除项目
// const response = await fetch(`${API_BASE}/projects/robot/${projectId}`, { method: 'DELETE' });
showMessage(`已删除机器人项目 ${projectId}`, 'success');
loadRobotProjects();
} catch (error) {
console.error('删除机器人项目失败:', error);
showMessage(`删除机器人项目失败`, 'error');
}
}
}
async function deleteDataset(datasetId) {
if (confirm(`确定要删除数据集 ${datasetId} 吗?此操作不可撤销!`)) {
try {
showMessage(`正在删除数据集 ${datasetId}...`, 'info');
// 这里可以调用实际的API来删除数据集
// const response = await fetch(`${API_BASE}/projects/datasets/${datasetId}`, { method: 'DELETE' });
showMessage(`已删除数据集 ${datasetId}`, 'success');
loadDatasets();
} catch (error) {
console.error('删除数据集失败:', error);
showMessage(`删除数据集失败`, 'error');
}
}
}
// ========== 文件管理功能 ==========
// 加载文件列表
async function loadFileList(path = '') {
try {
// 清理路径,确保发送相对路径
const cleanPath = sanitizePath(path);
const response = await fetch(`${FILE_API_BASE}/list?path=${encodeURIComponent(cleanPath)}&recursive=false`);
const data = await response.json();
if (data.success) {
currentPath = cleanPath;
renderFileList(data.items);
updateBreadcrumb(cleanPath);
}
} catch (error) {
console.error('加载文件列表失败:', error);
showMessage('加载文件列表失败', 'error');
}
}
// 渲染文件列表
function renderFileList(items) {
const fileList = document.getElementById('fileList');
if (!fileList) return;
fileList.innerHTML = '';
// 返回上级目录
if (currentPath) {
const parentItem = createFileItem({
name: '..',
type: 'directory',
path: getParentPath(currentPath),
modified: Date.now() / 1000
});
fileList.appendChild(parentItem);
}
// 渲染文件和文件夹
items.sort((a, b) => {
if (a.type !== b.type) {
return a.type === 'directory' ? -1 : 1;
}
return a.name.localeCompare(b.name);
}).forEach(item => {
// 确保路径是相对路径格式
const cleanItem = {
...item,
path: sanitizePath(item.path)
};
const fileItem = createFileItem(cleanItem);
fileList.appendChild(fileItem);
});
}
// 清理路径,确保使用相对路径
function sanitizePath(path) {
// 如果是绝对路径,转换为相对路径
if (path.startsWith('/')) {
const pathParts = path.split('/');
const qwenAgentIndex = pathParts.indexOf('qwen-agent');
if (qwenAgentIndex !== -1) {
return pathParts.slice(qwenAgentIndex + 1).join('/');
}
// 如果找不到 qwen-agent取最后两部分
return pathParts.slice(-2).join('/');
}
return path;
}
// 创建文件项
function createFileItem(item) {
const div = document.createElement('div');
div.className = 'file-item';
div.dataset.path = item.path;
const icon = item.type === 'directory' ? '📁' : getFileIcon(item.name);
const size = item.type === 'file' ? formatFileSize(item.size) : '-';
const date = new Date(item.modified * 1000).toLocaleDateString();
const isSelected = selectedItems.has(item.path);
// 构建复选框HTML
const checkboxHtml = batchMode ?
`<input type="checkbox" ${isSelected ? 'checked' : ''} onchange="toggleItemSelection('${item.path}')" style="margin-right: 12px;">` : '';
div.innerHTML = `
${checkboxHtml}
<div class="file-icon">${icon}</div>
<div class="file-info">
<div class="file-name fw-bold">${item.name}</div>
<div class="file-meta text-muted small">${size}${date}</div>
</div>
<div class="file-actions">
<div class="btn-group" role="group">
${item.type === 'file' ?
(isEditableFile(item.name) ?
`<button class="btn btn-outline-success btn-sm" onclick="editFile('${item.path}')" title="编辑文件"><i class="bi bi-file-earmark-code"></i></button>
<button class="btn btn-outline-primary btn-sm" onclick="downloadFile('${item.path}')" title="下载"><i class="bi bi-download"></i></button>` :
`<button class="btn btn-outline-primary btn-sm" onclick="downloadFile('${item.path}')" title="下载"><i class="bi bi-download"></i></button>`
) :
`<button class="btn btn-outline-primary btn-sm" onclick="downloadFolderAsZip('${item.path}')" title="压缩下载"><i class="bi bi-file-zip"></i></button>`
}
<button class="btn btn-outline-secondary btn-sm" onclick="showRenameModal('${item.path}')" title="重命名"><i class="bi bi-pencil"></i></button>
<button class="btn btn-outline-danger btn-sm" onclick="deleteItem('${item.path}')" title="删除"><i class="bi bi-trash"></i></button>
</div>
</div>
`;
// 添加批量模式下的点击事件
div.addEventListener('click', (e) => {
if (e.target.closest('.file-actions') || e.target.type === 'checkbox') return;
if (batchMode) {
toggleItemSelection(item.path);
// 更新复选框状态
const checkbox = div.querySelector('input[type="checkbox"]');
if (checkbox) {
checkbox.checked = selectedItems.has(item.path);
}
} else {
if (item.type === 'directory') {
loadFileList(item.path);
} else {
window.open(`${FILE_API_BASE}/download/${item.path}`, '_blank');
}
}
});
// Bootstrap点击外部关闭模态框的兼容处理
div.addEventListener('contextmenu', (e) => {
e.preventDefault();
// 这里可以添加右键菜单功能
});
// 添加选中状态的样式
if (isSelected) {
div.classList.add('selected');
}
return div;
}
// 获取文件图标
function getFileIcon(fileName) {
const ext = fileName.split('.').pop().toLowerCase();
const iconMap = {
'md': '<i class="bi bi-file-text"></i>',
'txt': '<i class="bi bi-file-earmark-text"></i>',
'pdf': '<i class="bi bi-file-pdf"></i>',
'doc': '<i class="bi bi-file-word"></i>',
'docx': '<i class="bi bi-file-word"></i>',
'xls': '<i class="bi bi-file-excel"></i>',
'xlsx': '<i class="bi bi-file-excel"></i>',
'ppt': '<i class="bi bi-file-ppt"></i>',
'pptx': '<i class="bi bi-file-ppt"></i>',
'jpg': '<i class="bi bi-file-image"></i>',
'jpeg': '<i class="bi bi-file-image"></i>',
'png': '<i class="bi bi-file-image"></i>',
'gif': '<i class="bi bi-file-image"></i>',
'zip': '<i class="bi bi-file-zip"></i>',
'rar': '<i class="bi bi-file-zip"></i>',
'mp4': '<i class="bi bi-file-play"></i>',
'mp3': '<i class="bi bi-file-music"></i>',
'json': '<i class="bi bi-file-code"></i>',
'xml': '<i class="bi bi-file-code"></i>',
'html': '<i class="bi bi-file-code"></i>',
'css': '<i class="bi bi-file-code"></i>',
'js': '<i class="bi bi-file-code"></i>',
'py': '<i class="bi bi-file-code"></i>'
};
return iconMap[ext] || '<i class="bi bi-file-earmark"></i>';
}
// 格式化文件大小
function formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
// 更新面包屑导航
function updateBreadcrumb(path) {
const breadcrumb = document.getElementById('breadcrumb');
if (!breadcrumb) return;
let breadcrumbHtml = '<ol class="breadcrumb mb-0"><li class="breadcrumb-item"><a href="#" onclick="navigateToPath(\'\')"><i class="bi bi-house me-1"></i>根目录</a></li>';
if (path) {
const parts = path.split('/');
let currentPath = '';
parts.forEach((part, index) => {
currentPath += (currentPath ? '/' : '') + part;
const isLast = index === parts.length - 1;
if (!isLast) {
breadcrumbHtml += `<li class="breadcrumb-item"><a href="#" onclick="navigateToPath('${currentPath}')">${part}</a></li>`;
} else {
breadcrumbHtml += `<li class="breadcrumb-item active" aria-current="page">${part}</li>`;
}
});
}
breadcrumbHtml += '</ol>';
breadcrumb.innerHTML = breadcrumbHtml;
}
// 导航到路径
function navigateToPath(path) {
loadFileList(path);
}
// 获取父路径
function getParentPath(path) {
const parts = path.split('/');
return parts.slice(0, -1).join('/');
}
// 搜索文件
async function searchFiles(query) {
try {
const cleanPath = sanitizePath(currentPath);
const response = await fetch(`${FILE_API_BASE}/search?query=${encodeURIComponent(query)}&path=${encodeURIComponent(cleanPath)}`);
const data = await response.json();
if (data.success) {
renderFileList(data.results.map(item => ({
...item,
modified: Date.now() / 1000,
size: 0
})));
}
} catch (error) {
console.error('搜索失败:', error);
showMessage('搜索失败', 'error');
}
}
// 处理文件选择
function handleFileSelect(e) {
handleFiles(e.target.files);
}
// 处理文件上传
async function handleFiles(files) {
for (let file of files) {
await uploadFile(file);
}
closeModal('uploadModal');
loadFileList();
}
// 重写文件上传函数以正确处理模态框关闭
async function uploadFile(file) {
const formData = new FormData();
formData.append('file', file);
formData.append('path', currentPath);
try {
const response = await fetch(`${FILE_API_BASE}/upload`, {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
showMessage(`文件 ${file.name} 上传成功`, 'success');
} else {
showMessage(`文件 ${file.name} 上传失败`, 'error');
}
} catch (error) {
console.error('上传失败:', error);
showMessage(`文件 ${file.name} 上传失败`, 'error');
}
}
// 上传文件
async function uploadFile(file) {
const formData = new FormData();
formData.append('file', file);
formData.append('path', currentPath);
try {
const response = await fetch(`${FILE_API_BASE}/upload`, {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
showMessage(`文件 ${file.name} 上传成功`, 'success');
} else {
showMessage(`文件 ${file.name} 上传失败`, 'error');
}
} catch (error) {
console.error('上传失败:', error);
showMessage(`文件 ${file.name} 上传失败`, 'error');
}
}
// 删除项目
async function deleteItem(path) {
if (!confirm(`确定要删除 ${path.split('/').pop()} 吗?`)) return;
try {
const response = await fetch(`${FILE_API_BASE}/delete?path=${encodeURIComponent(path)}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
showMessage('删除成功', 'success');
loadFileList();
} else {
showMessage('删除失败', 'error');
}
} catch (error) {
console.error('删除失败:', error);
showMessage('删除失败', 'error');
}
}
// 创建文件夹
async function createFolder() {
const folderName = document.getElementById('folderNameInput').value.trim();
if (!folderName) {
showMessage('请输入文件夹名称', 'error');
return;
}
try {
const response = await fetch(`${FILE_API_BASE}/create-folder`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
path: currentPath,
name: folderName
})
});
const data = await response.json();
if (data.success) {
showMessage('文件夹创建成功', 'success');
closeModal('createFolderModal');
loadFileList();
} else {
showMessage('文件夹创建失败', 'error');
}
} catch (error) {
console.error('创建文件夹失败:', error);
showMessage('创建文件夹失败', 'error');
}
}
// 重命名项目
async function renameItem() {
const newName = document.getElementById('renameInput').value.trim();
if (!newName) {
showMessage('请输入新名称', 'error');
return;
}
try {
const response = await fetch(`${FILE_API_BASE}/rename`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
old_path: currentItemToRename,
new_name: newName
})
});
const data = await response.json();
if (data.success) {
showMessage('重命名成功', 'success');
closeModal('renameModal');
loadFileList();
} else {
showMessage('重命名失败', 'error');
}
} catch (error) {
console.error('重命名失败:', error);
showMessage('重命名失败', 'error');
}
}
// 下载文件
function downloadFile(path) {
window.open(`${FILE_API_BASE}/download/${path}`, '_blank');
}
// 下载文件夹为ZIP
async function downloadFolderAsZip(folderPath) {
const folderName = folderPath.split('/').pop();
const downloadButton = event.target;
// 禁用按钮,显示加载状态
const originalText = downloadButton.textContent;
downloadButton.disabled = true;
downloadButton.textContent = '压缩中...';
try {
// 显示压缩开始提示
showMessage(`正在压缩文件夹 "${folderName}"...`, 'info');
// 使用新的下载文件夹ZIP接口
const response = await fetch(`${FILE_API_BASE}/download-folder-zip`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ path: folderPath })
});
if (response.ok) {
// 获取文件名
const contentDisposition = response.headers.get('content-disposition');
let fileName = `${folderName}.zip`;
if (contentDisposition) {
const fileNameMatch = contentDisposition.match(/filename="?([^"]+)"?/);
if (fileNameMatch) {
fileName = fileNameMatch[1];
}
}
// 检查文件大小
const contentLength = response.headers.get('content-length');
const fileSize = contentLength ? parseInt(contentLength) : 0;
if (fileSize > 100 * 1024 * 1024) { // 大于100MB显示警告
showMessage(`文件较大 (${formatFileSize(fileSize)}),下载可能需要一些时间...`, 'warning');
}
downloadButton.textContent = '下载中...';
// 创建下载链接
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
showMessage(`文件夹 "${folderName}" 压缩下载成功`, 'success');
} else {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `服务器错误: ${response.status}`);
}
} catch (error) {
console.error('下载文件夹失败:', error);
showMessage(`下载文件夹 "${folderName}" 失败: ${error.message}`, 'error');
} finally {
// 恢复按钮状态
downloadButton.disabled = false;
downloadButton.textContent = originalText;
}
}
// 刷新文件列表
function refreshFileList() {
loadFileList(currentPath);
}
// 切换批量模式
function toggleBatchMode() {
batchMode = !batchMode;
const batchBtn = document.getElementById('batchModeBtn');
const downloadBtn = document.getElementById('downloadSelectedBtn');
const selectAllBtn = document.getElementById('selectAllBtn');
if (batchMode) {
batchBtn.textContent = '❌ 退出批量';
batchBtn.classList.remove('btn-info');
batchBtn.classList.add('btn-secondary');
if (downloadBtn) downloadBtn.style.display = 'inline-block';
if (selectAllBtn) selectAllBtn.style.display = 'inline-block';
selectedItems.clear();
} else {
batchBtn.textContent = '📦 批量操作';
batchBtn.classList.remove('btn-secondary');
batchBtn.classList.add('btn-info');
if (downloadBtn) downloadBtn.style.display = 'none';
if (selectAllBtn) selectAllBtn.style.display = 'none';
selectedItems.clear();
}
refreshFileList(); // 重新渲染以显示/隐藏复选框
}
// 全选
function selectAll() {
const allItems = document.querySelectorAll('.file-item[data-path]');
const allPaths = Array.from(allItems).map(item => item.dataset.path);
const selectAllBtn = document.getElementById('selectAllBtn');
if (selectedItems.size === allPaths.length) {
// 如果已全选,则取消全选
selectedItems.clear();
if (selectAllBtn) selectAllBtn.textContent = '☑️ 全选';
} else {
// 全选
selectedItems.clear();
allPaths.forEach(path => selectedItems.add(path));
if (selectAllBtn) selectAllBtn.textContent = '❌ 取消全选';
}
// 更新所有复选框和选中状态
allItems.forEach(item => {
const path = item.dataset.path;
const checkbox = item.querySelector('input[type="checkbox"]');
if (checkbox) {
checkbox.checked = selectedItems.has(path);
}
if (selectedItems.has(path)) {
item.classList.add('selected');
} else {
item.classList.remove('selected');
}
});
updateBatchUI();
}
// 切换项目选择
function toggleItemSelection(path) {
if (selectedItems.has(path)) {
selectedItems.delete(path);
} else {
selectedItems.add(path);
}
updateBatchUI();
}
// 更新批量操作UI
function updateBatchUI() {
const downloadBtn = document.getElementById('downloadSelectedBtn');
const count = selectedItems.size;
if (downloadBtn) {
if (count > 0) {
downloadBtn.textContent = `📥 下载选中 (${count})`;
downloadBtn.classList.remove('btn-primary');
downloadBtn.classList.add('btn-success');
} else {
downloadBtn.textContent = '📥 下载选中';
downloadBtn.classList.remove('btn-success');
downloadBtn.classList.add('btn-primary');
}
}
}
// 下载选中项目
async function downloadSelected() {
if (selectedItems.size === 0) {
showMessage('请先选择要下载的文件', 'warning');
return;
}
const items = Array.from(selectedItems);
const hasFolders = items.some(path => {
// 这里可以检查是否为文件夹,简化处理
return false;
});
if (hasFolders) {
showMessage('检测到文件夹,正在创建压缩包...', 'info');
await downloadItemsAsZip(items);
} else {
// 下载单个文件
for (const item of items) {
downloadFile(item);
}
}
}
// 批量下载项目为ZIP
async function downloadItemsAsZip(items) {
try {
showMessage(`正在压缩 ${items.length} 个项目...`, 'info');
const response = await fetch(`${FILE_API_BASE}/download-multiple-zip`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
paths: items,
filename: `batch_download_${new Date().getTime()}.zip`
})
});
if (response.ok) {
const contentDisposition = response.headers.get('content-disposition');
let fileName = `batch_download.zip`;
if (contentDisposition) {
const fileNameMatch = contentDisposition.match(/filename="?([^"]+)"?/);
if (fileNameMatch) {
fileName = fileNameMatch[1];
}
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
showMessage('批量下载成功', 'success');
} else {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `服务器错误: ${response.status}`);
}
} catch (error) {
console.error('批量下载失败:', error);
showMessage(`批量下载失败: ${error.message}`, 'error');
}
}
// 显示上传模态框
function showUploadModal() {
document.getElementById('uploadModal').style.display = 'block';
document.getElementById('fileInput').value = '';
}
// 显示创建文件夹模态框
function showCreateFolderModal() {
document.getElementById('createFolderModal').style.display = 'block';
document.getElementById('folderNameInput').value = '';
}
// 显示重命名模态框
function showRenameModal(path) {
currentItemToRename = path;
const fileName = path.split('/').pop();
document.getElementById('renameInput').value = fileName;
document.getElementById('renameModal').style.display = 'block';
}
// 显示模态框 (使用Bootstrap)
function showUploadModal() {
const modal = new bootstrap.Modal(document.getElementById('uploadModal'));
modal.show();
}
function showCreateFolderModal() {
const modal = new bootstrap.Modal(document.getElementById('createFolderModal'));
modal.show();
}
function showRenameModal(path) {
currentItemToRename = path;
const fileName = path.split('/').pop();
document.getElementById('renameInput').value = fileName;
const modal = new bootstrap.Modal(document.getElementById('renameModal'));
modal.show();
}
// 关闭模态框
function closeModal(modalId) {
const modal = bootstrap.Modal.getInstance(document.getElementById(modalId));
if (modal) {
modal.hide();
}
}
// 创建文件夹成功后关闭模态框
function createFolder() {
// 保持原有逻辑添加Bootstrap模态框关闭
const folderName = document.getElementById('folderNameInput').value.trim();
if (!folderName) {
showMessage('请输入文件夹名称', 'error');
return;
}
fetch(`${FILE_API_BASE}/create-folder`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
path: currentPath,
name: folderName
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
showMessage('文件夹创建成功', 'success');
closeModal('createFolderModal');
loadFileList();
} else {
showMessage('文件夹创建失败', 'error');
}
})
.catch(error => {
console.error('创建文件夹失败:', error);
showMessage('创建文件夹失败', 'error');
});
}
// 重命名成功后关闭模态框
function renameItem() {
const newName = document.getElementById('renameInput').value.trim();
if (!newName) {
showMessage('请输入新名称', 'error');
return;
}
fetch(`${FILE_API_BASE}/rename`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
old_path: currentItemToRename,
new_name: newName
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
showMessage('重命名成功', 'success');
closeModal('renameModal');
loadFileList();
} else {
showMessage('重命名失败', 'error');
}
})
.catch(error => {
console.error('重命名失败:', error);
showMessage('重命名失败', 'error');
});
}
// ========== 文件编辑功能 ==========
// 判断文件是否可编辑
function isEditableFile(fileName) {
const editableExtensions = [
'md', 'txt', 'json', 'xml', 'yaml', 'yml',
'js', 'ts', 'jsx', 'tsx', 'html', 'css', 'scss', 'sass',
'py', 'java', 'cpp', 'c', 'h', 'hpp', 'cs', 'php', 'rb', 'go',
'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd',
'sql', 'log', 'conf', 'ini', 'cfg', 'toml', 'env',
'gitignore', 'dockerfile', 'makefile', 'readme', 'license'
];
const ext = fileName.split('.').pop().toLowerCase();
const name = fileName.toLowerCase();
// 检查扩展名
if (editableExtensions.includes(ext)) {
return true;
}
// 检查特殊文件名
const specialFiles = ['readme', 'license', 'dockerfile', 'makefile', 'gitignore'];
if (specialFiles.some(special => name.includes(special))) {
return true;
}
return false;
}
// 编辑文件
async function editFile(filePath) {
try {
currentEditingFile = filePath;
fileEditStatus = 'loading';
// 显示编辑模态框
const modal = new bootstrap.Modal(document.getElementById('editFileModal'));
// 设置文件路径
document.getElementById('editFileName').value = filePath;
// 显示加载状态
document.getElementById('fileContent').value = '加载文件内容中...';
document.getElementById('editFileInfo').innerHTML = '<span class="edit-status"><i class="bi bi-hourglass-split me-1"></i>正在加载...</span>';
modal.show();
// 获取文件内容
const response = await fetch(`${FILE_API_BASE}/read?path=${encodeURIComponent(filePath)}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.success) {
originalFileContent = data.content || '';
document.getElementById('fileContent').value = originalFileContent;
// 更新文件信息
const size = data.size ? formatFileSize(data.size) : '未知';
const modified = data.modified ? formatDate(data.modified * 1000) : '未知';
document.getElementById('editFileInfo').innerHTML =
`<span class="edit-status saved"><i class="bi bi-check-circle me-1"></i>已加载 • 大小: ${size} • 修改时间: ${modified}</span>`;
fileEditStatus = 'ready';
// 监听内容变化
setupContentChangeListener();
} else {
throw new Error(data.detail || '读取文件失败');
}
} catch (error) {
console.error('加载文件失败:', error);
document.getElementById('fileContent').value = `加载文件失败: ${error.message}`;
document.getElementById('editFileInfo').innerHTML =
`<span class="edit-status error"><i class="bi bi-exclamation-triangle me-1"></i>加载失败</span>`;
fileEditStatus = 'error';
showMessage(`加载文件失败: ${error.message}`, 'error');
}
}
// 设置内容变化监听器
function setupContentChangeListener() {
const textarea = document.getElementById('fileContent');
// 移除旧的监听器(如果存在)
textarea.removeEventListener('input', handleContentChange);
textarea.removeEventListener('scroll', handleEditorScroll);
textarea.removeEventListener('keydown', handleEditorKeydown);
// 添加新的监听器
textarea.addEventListener('input', handleContentChange);
textarea.addEventListener('scroll', handleEditorScroll);
textarea.addEventListener('keydown', handleEditorKeydown);
// 初始化行号
updateLineNumbers();
}
// 处理内容变化
function handleContentChange() {
const currentContent = document.getElementById('fileContent').value;
const isModified = currentContent !== originalFileContent;
// 更新行号
updateLineNumbers();
if (isModified && fileEditStatus === 'ready') {
fileEditStatus = 'modified';
document.getElementById('editFileInfo').innerHTML =
'<span class="edit-status modified"><i class="bi bi-pencil-square me-1"></i>已修改</span>';
} else if (!isModified && fileEditStatus === 'modified') {
fileEditStatus = 'ready';
const size = formatFileSize(new Blob([currentContent]).size);
document.getElementById('editFileInfo').innerHTML =
`<span class="edit-status saved"><i class="bi bi-check-circle me-1"></i>已保存 • 大小: ${size}</span>`;
}
}
// 处理编辑器滚动
function handleEditorScroll() {
const textarea = document.getElementById('fileContent');
const lineNumbers = document.getElementById('lineNumbers');
if (showLineNumbers && lineNumbers) {
lineNumbers.scrollTop = textarea.scrollTop;
}
}
// 处理编辑器键盘事件
function handleEditorKeydown(e) {
// Tab 键处理
if (e.key === 'Tab') {
e.preventDefault();
insertTab();
return;
}
// Ctrl+Z 撤销 (浏览器原生支持)
// Ctrl+Y 重做 (浏览器原生支持)
// 其他快捷键在全局事件处理器中处理
}
// 更新行号
function updateLineNumbers() {
if (!showLineNumbers) return;
const textarea = document.getElementById('fileContent');
const lineNumbers = document.getElementById('lineNumbers');
if (!lineNumbers) return;
const lines = textarea.value.split('\n').length;
let lineNumbersHtml = '';
for (let i = 1; i <= lines; i++) {
lineNumbersHtml += i + '\n';
}
lineNumbers.textContent = lineNumbersHtml;
}
// 切换行号显示
function toggleLineNumbers() {
showLineNumbers = !showLineNumbers;
const lineNumbers = document.getElementById('lineNumbers');
const textarea = document.getElementById('fileContent');
if (showLineNumbers) {
lineNumbers.style.display = 'block';
updateLineNumbers();
showMessage('已启用行号', 'info');
} else {
lineNumbers.style.display = 'none';
showMessage('已禁用行号', 'info');
}
}
// 插入 Tab
function insertTab() {
const textarea = document.getElementById('fileContent');
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const value = textarea.value;
// 插入4个空格作为Tab
const tabText = ' ';
textarea.value = value.substring(0, start) + tabText + value.substring(end);
textarea.selectionStart = textarea.selectionEnd = start + tabText.length;
// 触发内容变化事件
handleContentChange();
// 设置焦点
textarea.focus();
}
// 保存文件
async function saveFile() {
if (!currentEditingFile) {
showMessage('没有正在编辑的文件', 'warning');
return;
}
try {
fileEditStatus = 'saving';
const content = document.getElementById('fileContent').value;
// 显示保存状态
document.getElementById('editFileInfo').innerHTML =
'<span class="edit-status"><i class="bi bi-hourglass-split me-1"></i>正在保存...</span>';
// 禁用保存按钮
const saveButton = document.querySelector('#editFileModal .btn-primary');
const originalText = saveButton.innerHTML;
saveButton.disabled = true;
saveButton.innerHTML = '<i class="bi bi-hourglass-split me-1"></i>保存中...';
// 发送保存请求
const response = await fetch(`${FILE_API_BASE}/save`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
path: currentEditingFile,
content: content
})
});
const data = await response.json();
if (data.success) {
originalFileContent = content;
fileEditStatus = 'ready';
const size = formatFileSize(new Blob([content]).size);
document.getElementById('editFileInfo').innerHTML =
`<span class="edit-status saved"><i class="bi bi-check-circle me-1"></i>已保存 • 大小: ${size}${new Date().toLocaleTimeString()}</span>`;
showMessage('文件保存成功', 'success');
} else {
throw new Error(data.detail || '保存文件失败');
}
} catch (error) {
console.error('保存文件失败:', error);
fileEditStatus = 'error';
document.getElementById('editFileInfo').innerHTML =
`<span class="edit-status error"><i class="bi bi-exclamation-triangle me-1"></i>保存失败: ${error.message}</span>`;
showMessage(`保存文件失败: ${error.message}`, 'error');
} finally {
// 恢复保存按钮
const saveButton = document.querySelector('#editFileModal .btn-primary');
saveButton.disabled = false;
saveButton.innerHTML = '<i class="bi bi-save me-1"></i>保存文件';
}
}
// 重置内容
function resetContent() {
if (fileEditStatus === 'modified') {
if (!confirm('确定要重置文件内容吗?所有未保存的修改将丢失。')) {
return;
}
}
document.getElementById('fileContent').value = originalFileContent;
fileEditStatus = 'ready';
const size = formatFileSize(new Blob([originalFileContent]).size);
document.getElementById('editFileInfo').innerHTML =
`<span class="edit-status saved"><i class="bi bi-check-circle me-1"></i>已重置 • 大小: ${size}</span>`;
}
// 切换自动换行
function toggleWordWrap() {
const textarea = document.getElementById('fileContent');
const currentWrap = textarea.style.whiteSpace || 'pre-wrap';
if (currentWrap === 'pre-wrap') {
textarea.style.whiteSpace = 'pre';
textarea.style.overflowX = 'auto';
} else {
textarea.style.whiteSpace = 'pre-wrap';
textarea.style.overflowX = 'hidden';
}
showMessage(`${currentWrap === 'pre-wrap' ? '禁用' : '启用'}自动换行`, 'info');
}
// 简单的 Markdown 格式化函数
function formatMarkdown() {
const textarea = document.getElementById('fileContent');
let content = textarea.value;
// 基本的 Markdown 格式化
content = content.replace(/\r\n/g, '\n'); // 统一换行符
content = content.replace(/\n{3,}/g, '\n\n'); // 减少多余空行
content = content.replace(/([^\n])\n([^\n#*-])/g, '$1\n\n$2'); // 在段落间添加空行
textarea.value = content;
handleContentChange(); // 更新修改状态
showMessage('已应用基本格式化', 'info');
}
// 预览文件
function previewFile() {
const content = document.getElementById('fileContent').value;
const previewContainer = document.getElementById('previewContent');
// Markdown 到 HTML 转换
let html = markdownToHtml(content);
previewContainer.innerHTML = html;
// 应用代码高亮
if (typeof Prism !== 'undefined') {
Prism.highlightAllUnder(previewContainer);
}
// 显示预览模态框
const modal = new bootstrap.Modal(document.getElementById('previewFileModal'));
modal.show();
}
// 改进的 Markdown 解析器,支持代码高亮
function markdownToHtml(markdown) {
if (!markdown) return '<p>空文件</p>';
let html = markdown;
// 代码块(支持语言标识)
html = html.replace(/```(\w+)?\n([\s\S]*?)```/g, function(match, lang, code) {
const language = lang || 'text';
const cleanCode = code.trim();
return `<pre><code class="language-${language}">${escapeHtml(cleanCode)}</code></pre>`;
});
// 标题
html = html.replace(/^#### (.*$)/gim, '<h4>$1</h4>');
html = html.replace(/^### (.*$)/gim, '<h3>$1</h3>');
html = html.replace(/^## (.*$)/gim, '<h2>$1</h2>');
html = html.replace(/^# (.*$)/gim, '<h1>$1</h1>');
// 粗体和斜体
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
// 行内代码
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
// 链接
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
// 引用块
html = html.replace(/^> (.+)$/gim, '<blockquote>$1</blockquote>');
// 水平分割线
html = html.replace(/^---$/gim, '<hr>');
// 有序列表
html = html.replace(/^\d+\. (.+)$/gim, '<li>$1</li>');
// 无序列表
html = html.replace(/^[\*\-\+] (.+)$/gim, '<li>$1</li>');
// 合并连续列表项
html = html.replace(/(<li>.*<\/li>)\s*(<li>)/g, '$1$2');
html = html.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>');
// 段落处理
const lines = html.split('\n');
let result = [];
let inParagraph = false;
for (let line of lines) {
const trimmed = line.trim();
// 如果是空行,结束当前段落
if (!trimmed) {
if (inParagraph) {
result.push('</p>');
inParagraph = false;
}
continue;
}
// 如果是HTML标签块直接添加
if (trimmed.match(/^<(h[1-6]|ul|ol|li|pre|blockquote|hr)/)) {
if (inParagraph) {
result.push('</p>');
inParagraph = false;
}
result.push(trimmed);
continue;
}
// 如果是段落结束标签
if (trimmed.match(/^<\/(ul|ol|pre|blockquote)>/)) {
result.push(trimmed);
continue;
}
// 普通文本行
if (!inParagraph) {
result.push('<p>');
inParagraph = true;
}
result.push(trimmed + ' ');
}
if (inParagraph) {
result.push('</p>');
}
html = result.join('\n');
return html;
}
// HTML转义函数
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 添加快捷键支持
document.addEventListener('keydown', function(e) {
// 只在编辑模态框打开时处理编辑器快捷键
const editModal = bootstrap.Modal.getInstance(document.getElementById('editFileModal'));
const isEditing = editModal && editModal._isShown;
// Ctrl+S 保存
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
if (currentEditingFile && fileEditStatus !== 'saving') {
saveFile();
}
return;
}
// Ctrl+W 切换换行
if ((e.ctrlKey || e.metaKey) && e.key === 'w') {
e.preventDefault();
if (currentEditingFile) {
toggleWordWrap();
}
return;
}
// Ctrl+L 切换行号
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
e.preventDefault();
if (currentEditingFile) {
toggleLineNumbers();
}
return;
}
// Ctrl+P 预览
if ((e.ctrlKey || e.metaKey) && e.key === 'p') {
e.preventDefault();
if (currentEditingFile) {
previewFile();
}
return;
}
// Ctrl+Shift+F 格式化
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'F') {
e.preventDefault();
if (currentEditingFile) {
formatMarkdown();
}
return;
}
// Escape 关闭预览
if (e.key === 'Escape') {
const previewModal = bootstrap.Modal.getInstance(document.getElementById('previewFileModal'));
if (previewModal) {
previewModal.hide();
}
return;
}
// 编辑器快捷键(只在编辑时处理)
if (isEditing && e.target.id === 'fileContent') {
// Tab 键已在 handleEditorKeydown 中处理
// Ctrl+/ 注释/取消注释
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
e.preventDefault();
toggleComment();
return;
}
// Ctrl+D 复制行
if ((e.ctrlKey || e.metaKey) && e.key === 'd') {
e.preventDefault();
duplicateLine();
return;
}
}
});
// 切换注释(简单实现)
function toggleComment() {
const textarea = document.getElementById('fileContent');
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const value = textarea.value;
// 获取选中的行
const lines = value.substring(0, start).split('\n').length - 1;
const endLines = value.substring(0, end).split('\n').length - 1;
const allLines = value.split('\n');
let hasComment = true;
// 检查是否所有行都已注释
for (let i = lines; i <= endLines; i++) {
if (allLines[i] && !allLines[i].trim().startsWith('#')) {
hasComment = false;
break;
}
}
// 切换注释
if (hasComment) {
// 取消注释
for (let i = lines; i <= endLines; i++) {
if (allLines[i] && allLines[i].trim().startsWith('#')) {
allLines[i] = allLines[i].replace(/^(\s*)#\s?/, '$1');
}
}
} else {
// 添加注释
for (let i = lines; i <= endLines; i++) {
if (allLines[i] && !allLines[i].trim().startsWith('#')) {
allLines[i] = '# ' + allLines[i];
}
}
}
textarea.value = allLines.join('\n');
handleContentChange();
textarea.focus();
}
// 复制当前行
function duplicateLine() {
const textarea = document.getElementById('fileContent');
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const value = textarea.value;
// 找到当前行的开始和结束
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
const lineEnd = value.indexOf('\n', end);
const actualLineEnd = lineEnd === -1 ? value.length : lineEnd;
const currentLine = value.substring(lineStart, actualLineEnd);
const newContent = value.substring(0, actualLineEnd) + '\n' + currentLine + value.substring(actualLineEnd);
textarea.value = newContent;
const newCursorPos = actualLineEnd + currentLine.length + 1;
textarea.selectionStart = textarea.selectionEnd = newCursorPos;
handleContentChange();
textarea.focus();
}
</script>
<!-- Prism.js for code highlighting -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css" rel="stylesheet" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
<!-- Bootstrap JS Bundle -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>