3111 lines
124 KiB
HTML
3111 lines
124 KiB
HTML
<!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>
|