fix: BUGFIX: Fixes the issue with the Docker Hub login process and improves error handling in the UI.

This commit is contained in:
dqzboy
2025-04-02 06:24:26 +08:00
parent a907e28ca4
commit 46f067b495
72 changed files with 15421 additions and 4075 deletions

View File

@@ -26,6 +26,75 @@
---
## 🔧 日志系统说明
本项目实现了生产级别的日志系统,支持以下特性:
### 日志级别
支持的日志级别从低到高依次为:
- `TRACE`: 最详细的追踪信息,用于开发调试
- `DEBUG`: 调试信息,包含详细的程序执行流程
- `INFO`: 一般信息,默认级别
- `SUCCESS`: 成功信息,通常用于标记重要操作的成功完成
- `WARN`: 警告信息,表示潜在的问题
- `ERROR`: 错误信息,表示操作失败但程序仍可继续运行
- `FATAL`: 致命错误,通常会导致程序退出
### 环境变量配置
可通过环境变量调整日志行为:
```bash
# 设置日志级别
export LOG_LEVEL=INFO # 可选值: TRACE, DEBUG, INFO, SUCCESS, WARN, ERROR, FATAL
# 启用简化日志输出(减少浏览器请求详细信息)
export SIMPLE_LOGS=true
# 启用详细日志记录(包含请求体、查询参数等)
export DETAILED_LOGS=true
# 启用错误堆栈跟踪
export SHOW_STACK=true
# 禁用文件日志记录
export LOG_FILE_ENABLED=false
# 禁用控制台日志输出
export LOG_CONSOLE_ENABLED=false
# 设置日志文件大小上限(MB)
export LOG_MAX_SIZE=10
# 设置保留的日志文件数量
export LOG_MAX_FILES=14
```
### Docker运行时配置
使用Docker运行时可以通过环境变量传递配置
```bash
docker run -d \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 30080:3000 \
-e LOG_LEVEL=INFO \
-e SIMPLE_LOGS=true \
-e LOG_MAX_FILES=7 \
--name hubcmdui-server \
dqzboy/hubcmd-ui
```
### 日志文件轮转
系统自动实现日志文件轮转:
- 单个日志文件超过设定大小(默认10MB)会自动创建新文件
- 自动保留指定数量(默认14个)的最新日志文件
- 日志文件存储在`logs`目录下,格式为`app-YYYY-MM-DD.log`
---
## 📝 源码构建运行
#### 1. 克隆项目
```bash

186
hubcmdui/app.js Normal file
View File

@@ -0,0 +1,186 @@
#!/usr/bin/env node
/**
* 应用主入口文件 - 启动服务器并初始化所有组件
*/
// 记录服务器启动时间 - 最先执行这行代码,确保第一时间记录
global.serverStartTime = Date.now();
const express = require('express');
const session = require('express-session');
const path = require('path');
const http = require('http');
const logger = require('./logger');
const { ensureDirectoriesExist } = require('./init-dirs');
const registerRoutes = require('./routes');
const { requireLogin, sessionActivity, sanitizeRequestBody, securityHeaders } = require('./middleware/auth');
// 记录服务器启动时间到日志
console.log(`服务器启动,时间戳: ${global.serverStartTime}`);
logger.warn(`服务器启动,时间戳: ${global.serverStartTime}`);
// 添加 session 文件存储模块 - 先导入session-file-store并创建对象
const FileStore = require('session-file-store')(session);
// 确保目录结构存在
ensureDirectoriesExist().catch(err => {
logger.error('创建必要目录失败:', err);
process.exit(1);
});
// 初始化Express应用 - 确保正确初始化
const app = express();
const server = http.createServer(app);
// 基本中间件配置
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, 'web')));
// 添加对documentation目录的静态访问
app.use('/documentation', express.static(path.join(__dirname, 'documentation')));
app.use(sessionActivity);
app.use(sanitizeRequestBody);
app.use(securityHeaders);
// 会话配置
app.use(session({
secret: process.env.SESSION_SECRET || 'hubcmdui-secret-key',
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
maxAge: 24 * 60 * 60 * 1000 // 24小时
},
store: new FileStore({
path: path.join(__dirname, 'data', 'sessions'),
ttl: 86400
})
}));
// 添加一个中间件来检查API请求的会话状态
app.use('/api', (req, res, next) => {
// 这些API端点不需要登录
const publicEndpoints = [
'/api/login',
'/api/logout',
'/api/check-session',
'/api/health',
'/api/system-status',
'/api/system-resource-details',
'/api/menu-items',
'/api/config',
'/api/monitoring-config',
'/api/documentation',
'/api/documentation/file'
];
// 如果是公共API或用户已登录则继续
if (publicEndpoints.includes(req.path) ||
publicEndpoints.some(endpoint => req.path.startsWith(endpoint)) ||
(req.session && req.session.user)) {
return next();
}
// 否则返回401未授权
logger.warn(`未授权访问: ${req.path}`);
return res.status(401).json({ error: 'Unauthorized' });
});
// 导入并注册所有路由
registerRoutes(app);
// 默认路由
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'web', 'index.html'));
});
app.get('/admin', (req, res) => {
res.sendFile(path.join(__dirname, 'web', 'admin.html'));
});
// 404处理
app.use((req, res) => {
res.status(404).json({ error: 'Not Found' });
});
// 错误处理中间件
app.use((err, req, res, next) => {
logger.error('应用错误:', err);
res.status(500).json({ error: '服务器内部错误', details: err.message });
});
// 启动服务器
const PORT = process.env.PORT || 3000;
server.listen(PORT, async () => {
logger.info(`服务器已启动并监听端口 ${PORT}`);
try {
// 确保目录存在
await ensureDirectoriesExist();
logger.success('系统初始化完成');
} catch (error) {
logger.error('系统初始化失败:', error);
}
});
// 注册进程事件处理
process.on('SIGINT', () => {
logger.info('接收到中断信号,正在关闭服务...');
server.close(() => {
logger.info('服务器已关闭');
process.exit(0);
});
});
process.on('SIGTERM', () => {
logger.info('接收到终止信号,正在关闭服务...');
server.close(() => {
logger.info('服务器已关闭');
process.exit(0);
});
});
module.exports = { app, server };
// 路由注册函数
function registerRoutes(app) {
try {
logger.info('开始注册路由...');
// API端点
app.use('/api', [
require('./routes/index'),
require('./routes/docker'),
require('./routes/docs'),
require('./routes/users'),
require('./routes/menu'),
require('./routes/server')
]);
logger.info('基本API路由已注册');
// 系统路由 - 函数式注册
const systemRouter = require('./routes/system');
app.use('/api/system', systemRouter);
logger.info('系统路由已注册');
// 认证路由 - 直接使用Router实例
const authRouter = require('./routes/auth');
app.use('/api', authRouter);
logger.info('认证路由已注册');
// 配置路由 - 函数式注册
const configRouter = require('./routes/config');
if (typeof configRouter === 'function') {
logger.info('配置路由是一个函数,正在注册...');
configRouter(app);
logger.info('配置路由已注册');
} else {
logger.error('配置路由不是一个函数,无法注册', typeof configRouter);
}
logger.success('✓ 所有路由已注册');
} catch (error) {
logger.error('路由注册失败:', error);
}
}

72
hubcmdui/cleanup.js Normal file
View File

@@ -0,0 +1,72 @@
const logger = require('./logger');
// 处理未捕获的异常
process.on('uncaughtException', (error) => {
logger.error('未捕获的异常:', error);
// 打印完整的堆栈跟踪以便调试
console.error('错误堆栈:', error.stack);
// 不立即退出,以便日志能够被写入
setTimeout(() => {
process.exit(1);
}, 1000);
});
// 处理未处理的Promise拒绝
process.on('unhandledRejection', (reason, promise) => {
logger.error('未处理的Promise拒绝:', reason);
// 打印堆栈跟踪(如果可用)
if (reason instanceof Error) {
console.error('Promise拒绝堆栈:', reason.stack);
}
});
// 处理退出信号
process.on('SIGINT', gracefulShutdown);
process.on('SIGTERM', gracefulShutdown);
// 优雅退出函数
function gracefulShutdown() {
logger.info('接收到退出信号,正在关闭...');
// 这里可以添加清理代码,如关闭数据库连接等
try {
// 关闭任何可能的资源
try {
const docker = require('./services/dockerService').getDockerConnection();
if (docker) {
logger.info('正在关闭Docker连接...');
// 如果有活动的Docker连接可能需要执行一些清理
}
} catch (err) {
// 忽略错误,可能服务未初始化
logger.debug('Docker服务未初始化跳过清理');
}
// 清理监控间隔
try {
const monitoringService = require('./services/monitoringService');
if (monitoringService.stopMonitoring) {
logger.info('正在停止容器监控...');
monitoringService.stopMonitoring();
}
} catch (err) {
// 忽略错误,可能服务未初始化
logger.debug('监控服务未初始化,跳过清理');
}
logger.info('所有资源已清理完毕,正在退出...');
} catch (error) {
logger.error('退出过程中出现错误:', error);
}
setTimeout(() => {
logger.info('干净退出完成');
process.exit(0);
}, 1000);
}
logger.info('错误处理和清理脚本已加载');
module.exports = {
gracefulShutdown
};

File diff suppressed because it is too large Load Diff

54
hubcmdui/config.js Normal file
View File

@@ -0,0 +1,54 @@
/**
* 应用全局配置文件
*/
// 环境变量
const ENV = process.env.NODE_ENV || 'development';
// 应用配置
const config = {
// 通用配置
common: {
port: process.env.PORT || 3000,
sessionSecret: process.env.SESSION_SECRET || 'OhTq3faqSKoxbV%NJV',
logLevel: process.env.LOG_LEVEL || 'info'
},
// 开发环境配置
development: {
debug: true,
cors: {
origin: '*',
credentials: true
},
secureSession: false
},
// 生产环境配置
production: {
debug: false,
cors: {
origin: 'https://yourdomain.com',
credentials: true
},
secureSession: true
},
// 测试环境配置
test: {
debug: true,
cors: {
origin: '*',
credentials: true
},
secureSession: false,
port: 3001
}
};
// 导出合并后的配置
module.exports = {
...config.common,
...config[ENV],
env: ENV
};

View File

@@ -1,39 +1,36 @@
{
"logo": "",
"theme": "light",
"language": "zh_CN",
"notifications": true,
"autoRefresh": true,
"refreshInterval": 30000,
"dockerHost": "localhost",
"dockerPort": 2375,
"useHttps": false,
"menuItems": [
{
"text": "首页",
"link": "",
"text": "控制台",
"link": "/admin",
"newTab": false
},
{
"text": "镜像搜索",
"link": "/",
"newTab": false
},
{
"text": "文档",
"link": "/docs",
"newTab": false
},
{
"text": "GitHub",
"link": "https://github.com/dqzboy/Docker-Proxy",
"newTab": true
},
{
"text": "VPS推荐",
"link": "https://dqzboy.github.io/proxyui/racknerd",
"link": "https://github.com/dqzboy/hubcmdui",
"newTab": true
}
],
"adImages": [
{
"url": "https://cdn.jsdelivr.net/gh/dqzboy/Blog-Image/BlogCourse/reacknerd-ad.png",
"link": "https://my.racknerd.com/aff.php?aff=12151"
},
{
"url": "https://cdn.jsdelivr.net/gh/dqzboy/Blog-Image/BlogCourse/racknerd_vps.png",
"link": "https://my.racknerd.com/aff.php?aff=12151"
},
{
"url": "https://cdn.jsdelivr.net/gh/dqzboy/Blog-Image/BlogCourse/docker-proxy-vip.png",
"link": "https://www.dqzboy.com/17834.html"
}
],
"proxyDomain": "dqzboy.github.io",
"monitoringConfig": {
"notificationType": "telegram",
"notificationType": "wechat",
"webhookUrl": "",
"telegramToken": "",
"telegramChatId": "",

View File

@@ -0,0 +1 @@
[]

View File

@@ -0,0 +1,8 @@
{
"isEnabled": false,
"notificationType": "wechat",
"webhookUrl": "",
"telegramToken": "",
"telegramChatId": "",
"monitorInterval": 60
}

29
hubcmdui/data/config.json Normal file
View File

@@ -0,0 +1,29 @@
{
"logo": "",
"menuItems": [
{
"text": "首页",
"link": "",
"newTab": false
},
{
"text": "GitHub",
"link": "https://github.com/dqzboy/Docker-Proxy",
"newTab": true
},
{
"text": "VPS推荐",
"link": "https://dqzboy.github.io/proxyui/racknerd",
"newTab": true
}
],
"monitoringConfig": {
"notificationType": "telegram",
"webhookUrl": "",
"telegramToken": "",
"telegramChatId": "",
"monitorInterval": 60,
"isEnabled": false
},
"proxyDomain": "dqzboy.github.io"
}

View File

@@ -7,4 +7,14 @@ services:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
ports:
- 30080:3000
- 30080:3000
environment:
# 日志配置
- LOG_LEVEL=INFO # 可选: TRACE, DEBUG, INFO, SUCCESS, WARN, ERROR, FATAL
- SIMPLE_LOGS=true # 启用简化日志输出,减少冗余信息
# - DETAILED_LOGS=false # 默认关闭详细日志记录(请求体、查询参数等)
# - SHOW_STACK=false # 默认关闭错误堆栈跟踪
# - LOG_FILE_ENABLED=true # 是否启用文件日志,默认启用
# - LOG_CONSOLE_ENABLED=true # 是否启用控制台日志,默认启用
# - LOG_MAX_SIZE=10 # 单个日志文件最大大小(MB)默认10MB
# - LOG_MAX_FILES=14 # 保留的日志文件数量默认14个

BIN
hubcmdui/documentation/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -1 +0,0 @@
{"title":"Docker 配置镜像加速","content":"### Docker 配置镜像加速\n- 修改文件 `/etc/docker/daemon.json`(如果不存在则创建)\n\n```shell\nsudo mkdir -p /etc/docker\nsudo vi /etc/docker/daemon.json\n{\n \"registry-mirrors\": [\"https://<代理加速地址>\"]\n}\n\nsudo systemctl daemon-reload\nsudo systemctl restart docker\n```","published":true}

View File

@@ -1 +0,0 @@
{"title":"Containerd 配置镜像加速","content":"### Containerd 配置镜像加速\n- `/etc/containerd/config.toml`,添加如下的配置:\n\n```yaml\n [plugins.\"io.containerd.grpc.v1.cri\".registry]\n [plugins.\"io.containerd.grpc.v1.cri\".registry.mirrors]\n [plugins.\"io.containerd.grpc.v1.cri\".registry.mirrors.\"docker.io\"]\n endpoint = [\"https://<代理加速地址>\"]\n [plugins.\"io.containerd.grpc.v1.cri\".registry.mirrors.\"k8s.gcr.io\"]\n endpoint = [\"https://<代理加速地址>\"]\n [plugins.\"io.containerd.grpc.v1.cri\".registry.mirrors.\"gcr.io\"]\n endpoint = [\"https://<代理加速地址>\"]\n [plugins.\"io.containerd.grpc.v1.cri\".registry.mirrors.\"ghcr.io\"]\n endpoint = [\"https://<代理加速地址>\"]\n [plugins.\"io.containerd.grpc.v1.cri\".registry.mirrors.\"quay.io\"]\n endpoint = [\"https://<代理加速地址>\"]\n```","published":true}

View File

@@ -1 +0,0 @@
{"title":"Podman 配置镜像加速","content":"### Podman 配置镜像加速\n- 修改配置文件 `/etc/containers/registries.conf`,添加配置:\n\n```yaml\nunqualified-search-registries = ['docker.io', 'k8s.gcr.io', 'gcr.io', 'ghcr.io', 'quay.io']\n\n[[registry]]\nprefix = \"docker.io\"\ninsecure = true\nlocation = \"registry-1.docker.io\"\n\n[[registry.mirror]]\nlocation = \"https://<代理加速地址>\"\n\n[[registry]]\nprefix = \"k8s.gcr.io\"\ninsecure = true\nlocation = \"k8s.gcr.io\"\n\n[[registry.mirror]]\nlocation = \"https://<代理加速地址>\"\n\n[[registry]]\nprefix = \"gcr.io\"\ninsecure = true\nlocation = \"gcr.io\"\n\n[[registry.mirror]]\nlocation = \"https://<代理加速地址>\"\n\n[[registry]]\nprefix = \"ghcr.io\"\ninsecure = true\nlocation = \"ghcr.io\"\n\n[[registry.mirror]]\nlocation = \"https://<代理加速地址>\"\n\n[[registry]]\nprefix = \"quay.io\"\ninsecure = true\nlocation = \"quay.io\"\n\n[[registry.mirror]]\nlocation = \"https://<代理加速地址>\"\n```","published":true}

View File

@@ -0,0 +1,7 @@
{
"title": "Docker 配置镜像加速",
"content": "# Docker 配置镜像加速\n\n- 修改文件 `/etc/docker/daemon.json`(如果不存在则创建)\n\n```\nsudo mkdir -p /etc/docker\nsudo vi /etc/docker/daemon.json\n{\n \"registry-mirrors\": [\"https://<代理加速地址>\"]\n}\n\nsudo systemctl daemon-reload\nsudo systemctl restart docker\n```",
"published": true,
"createdAt": "2025-04-01T21:27:21.591Z",
"updatedAt": "2025-04-01T21:35:20.004Z"
}

View File

@@ -0,0 +1,7 @@
{
"title": "Containerd 配置镜像加速",
"content": "# Containerd 配置镜像加速\n\n\n* `/etc/containerd/config.toml`,添加如下的配置:\n\n```bash\n [plugins.\"io.containerd.grpc.v1.cri\".registry]\n [plugins.\"io.containerd.grpc.v1.cri\".registry.mirrors]\n [plugins.\"io.containerd.grpc.v1.cri\".registry.mirrors.\"docker.io\"]\n endpoint = [\"https://<代理加速地址>\"]\n [plugins.\"io.containerd.grpc.v1.cri\".registry.mirrors.\"k8s.gcr.io\"]\n endpoint = [\"https://<代理加速地址>\"]\n [plugins.\"io.containerd.grpc.v1.cri\".registry.mirrors.\"gcr.io\"]\n endpoint = [\"https://<代理加速地址>\"]\n [plugins.\"io.containerd.grpc.v1.cri\".registry.mirrors.\"ghcr.io\"]\n endpoint = [\"https://<代理加速地址>\"]\n [plugins.\"io.containerd.grpc.v1.cri\".registry.mirrors.\"quay.io\"]\n endpoint = [\"https://<代理加速地址>\"]\n```",
"published": true,
"createdAt": "2025-04-01T21:36:16.092Z",
"updatedAt": "2025-04-01T21:36:18.103Z"
}

View File

@@ -0,0 +1,7 @@
{
"title": "Podman 配置镜像加速",
"content": "# Podman 配置镜像加速\n\n* 修改配置文件 `/etc/containers/registries.conf`,添加配置:\n\n```bash\nunqualified-search-registries = ['docker.io', 'k8s.gcr.io', 'gcr.io', 'ghcr.io', 'quay.io']\n\n[[registry]]\nprefix = \"docker.io\"\ninsecure = true\nlocation = \"registry-1.docker.io\"\n\n[[registry.mirror]]\nlocation = \"https://<代理加速地址>\"\n\n[[registry]]\nprefix = \"k8s.gcr.io\"\ninsecure = true\nlocation = \"k8s.gcr.io\"\n\n[[registry.mirror]]\nlocation = \"https://<代理加速地址>\"\n\n[[registry]]\nprefix = \"gcr.io\"\ninsecure = true\nlocation = \"gcr.io\"\n\n[[registry.mirror]]\nlocation = \"https://<代理加速地址>\"\n\n[[registry]]\nprefix = \"ghcr.io\"\ninsecure = true\nlocation = \"ghcr.io\"\n\n[[registry.mirror]]\nlocation = \"https://<代理加速地址>\"\n\n[[registry]]\nprefix = \"quay.io\"\ninsecure = true\nlocation = \"quay.io\"\n\n[[registry.mirror]]\nlocation = \"https://<代理加速地址>\"\n```# Podman 配置镜像加速\n\n* 修改配置文件 `/etc/containers/registries.conf`,添加配置:\n\n```bash\nunqualified-search-registries = ['docker.io', 'k8s.gcr.io', 'gcr.io', 'ghcr.io', 'quay.io']\n\n[[registry]]\nprefix = \"docker.io\"\ninsecure = true\nlocation = \"registry-1.docker.io\"\n\n[[registry.mirror]]\nlocation = \"https://<代理加速地址>\"\n\n[[registry]]\nprefix = \"k8s.gcr.io\"\ninsecure = true\nlocation = \"k8s.gcr.io\"\n\n[[registry.mirror]]\nlocation = \"https://<代理加速地址>\"\n\n[[registry]]\nprefix = \"gcr.io\"\ninsecure = true\nlocation = \"gcr.io\"\n\n[[registry.mirror]]\nlocation = \"https://<代理加速地址>\"\n\n[[registry]]\nprefix = \"ghcr.io\"\ninsecure = true\nlocation = \"ghcr.io\"\n\n[[registry.mirror]]\nlocation = \"https://<代理加速地址>\"\n\n[[registry]]\nprefix = \"quay.io\"\ninsecure = true\nlocation = \"quay.io\"\n\n[[registry.mirror]]\nlocation = \"https://<代理加速地址>\"\n```",
"published": true,
"createdAt": "2025-04-01T21:36:40.369Z",
"updatedAt": "2025-04-01T21:36:41.977Z"
}

View File

@@ -0,0 +1,83 @@
/**
* 下载必要的图片资源
*/
const fs = require('fs').promises;
const path = require('path');
const https = require('https');
const logger = require('./logger');
const { ensureDirectoriesExist } = require('./init-dirs');
// 背景图片URL
const LOGIN_BG_URL = 'https://images.unsplash.com/photo-1517694712202-14dd9538aa97?q=80&w=1470&auto=format&fit=crop';
// 下载图片函数
function downloadImage(url, dest) {
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(dest);
https.get(url, response => {
if (response.statusCode !== 200) {
reject(new Error(`Failed to download image. Status code: ${response.statusCode}`));
return;
}
response.pipe(file);
file.on('finish', () => {
file.close();
logger.success(`Image downloaded to: ${dest}`);
resolve();
});
file.on('error', err => {
fs.unlink(dest).catch(() => {}); // 删除文件(如果存在)
reject(err);
});
}).on('error', err => {
fs.unlink(dest).catch(() => {}); // 删除文件(如果存在)
reject(err);
});
});
}
// 主函数
async function downloadImages() {
try {
// 确保目录存在
await ensureDirectoriesExist();
// 下载登录背景图片
const loginBgPath = path.join(__dirname, 'web', 'images', 'login-bg.jpg');
try {
await fs.access(loginBgPath);
logger.info('Login background image already exists, skipping download');
} catch (error) {
if (error.code === 'ENOENT') {
logger.info('Downloading login background image...');
try {
// 确保images目录存在
await fs.mkdir(path.dirname(loginBgPath), { recursive: true });
await downloadImage(LOGIN_BG_URL, loginBgPath);
} catch (downloadError) {
logger.error(`Download error: ${downloadError.message}`);
// 下载失败时使用备用解决方案
await fs.writeFile(loginBgPath, 'Failed to download', 'utf8');
logger.warn('Created placeholder image file');
}
} else {
throw error;
}
}
logger.success('All images downloaded successfully');
} catch (error) {
logger.error('Error downloading images:', error);
}
}
// 如果直接运行此脚本
if (require.main === module) {
downloadImages();
}
module.exports = { downloadImages };

86
hubcmdui/init-dirs.js Normal file
View File

@@ -0,0 +1,86 @@
/**
* 目录初始化模块 - 确保应用需要的所有目录都存在
*/
const fs = require('fs').promises;
const path = require('path');
const logger = require('./logger');
/**
* 确保所有必需的目录存在
*/
// 添加缓存机制
const checkedDirs = new Set();
async function ensureDirectoriesExist() {
const dirs = [
// 文档目录
path.join(__dirname, 'documentation'),
// 日志目录
path.join(__dirname, 'logs'),
// 图片目录
path.join(__dirname, 'web', 'images'),
// 数据目录
path.join(__dirname, 'data'),
// 配置目录
path.join(__dirname, 'config'),
// 临时文件目录
path.join(__dirname, 'temp'),
// session 目录
path.join(__dirname, 'data', 'sessions'),
// 文档数据目录
path.join(__dirname, 'web', 'data', 'documentation')
];
for (const dir of dirs) {
if (checkedDirs.has(dir)) continue;
try {
await fs.access(dir);
logger.info(`目录已存在: ${dir}`);
} catch (error) {
if (error.code === 'ENOENT') {
try {
await fs.mkdir(dir, { recursive: true });
logger.success(`创建目录: ${dir}`);
} catch (mkdirError) {
logger.error(`创建目录 ${dir} 失败: ${mkdirError.message}`);
throw mkdirError;
}
} else {
logger.error(`检查目录 ${dir} 失败: ${error.message}`);
throw error;
}
}
checkedDirs.add(dir);
}
// 确保文档索引存在,但不再添加默认文档
const docIndexPath = path.join(__dirname, 'web', 'data', 'documentation', 'index.json');
try {
await fs.access(docIndexPath);
logger.info('文档索引已存在');
} catch (error) {
if (error.code === 'ENOENT') {
try {
// 创建一个空的文档索引
await fs.writeFile(docIndexPath, JSON.stringify([]), 'utf8');
logger.success('创建了空的文档索引文件');
} catch (writeError) {
logger.error(`创建文档索引失败: ${writeError.message}`);
}
}
}
}
// 如果直接运行此脚本
if (require.main === module) {
ensureDirectoriesExist()
.then(() => logger.info('目录初始化完成'))
.catch(err => {
logger.error('目录初始化失败:', err);
process.exit(1);
});
}
module.exports = { ensureDirectoriesExist };

View File

@@ -1,216 +1,374 @@
const fs = require('fs').promises;
const fs = require('fs');
const fsPromises = fs.promises;
const path = require('path');
const util = require('util');
const os = require('os');
// 日志级别配置
// 日志级别定义
const LOG_LEVELS = {
debug: 0,
info: 1,
success: 2,
warn: 3,
error: 4,
fatal: 5
TRACE: { priority: 0, color: 'grey', prefix: 'TRACE' },
DEBUG: { priority: 1, color: 'blue', prefix: 'DEBUG' },
INFO: { priority: 2, color: 'green', prefix: 'INFO' },
SUCCESS: { priority: 3, color: 'greenBright', prefix: 'SUCCESS' },
WARN: { priority: 4, color: 'yellow', prefix: 'WARN' },
ERROR: { priority: 5, color: 'red', prefix: 'ERROR' },
FATAL: { priority: 6, color: 'redBright', prefix: 'FATAL' }
};
// 默认配置
const config = {
level: process.env.LOG_LEVEL || 'info',
logToFile: process.env.LOG_TO_FILE === 'true',
logDirectory: path.join(__dirname, 'logs'),
logFileName: 'app.log',
maxLogSize: 10 * 1024 * 1024, // 10MB
colorize: process.env.NODE_ENV !== 'production'
// 彩色日志实现
const colors = {
grey: text => `\x1b[90m${text}\x1b[0m`,
blue: text => `\x1b[34m${text}\x1b[0m`,
green: text => `\x1b[32m${text}\x1b[0m`,
greenBright: text => `\x1b[92m${text}\x1b[0m`,
yellow: text => `\x1b[33m${text}\x1b[0m`,
red: text => `\x1b[31m${text}\x1b[0m`,
redBright: text => `\x1b[91m${text}\x1b[0m`
};
// 日志配置
const LOG_CONFIG = {
// 默认日志级别
level: process.env.LOG_LEVEL || 'INFO',
// 日志文件配置
file: {
enabled: true,
dir: path.join(__dirname, 'logs'),
nameFormat: 'app-%DATE%.log',
maxSize: 10 * 1024 * 1024, // 10MB
maxFiles: 14, // 保留14天的日志
},
// 控制台输出配置
console: {
enabled: true,
colorize: true,
// 简化输出在控制台
simplified: process.env.NODE_ENV === 'production' || process.env.SIMPLE_LOGS === 'true'
},
// 是否打印请求体、查询参数等详细信息(默认关闭)
includeDetails: process.env.NODE_ENV === 'development' || process.env.DETAILED_LOGS === 'true',
// 是否显示堆栈跟踪(默认关闭)
includeStack: process.env.NODE_ENV === 'development' || process.env.SHOW_STACK === 'true'
};
// 根据环境变量初始化配置
function initConfig() {
// 检查环境变量并更新配置
if (process.env.LOG_FILE_ENABLED === 'false') {
LOG_CONFIG.file.enabled = false;
}
if (process.env.LOG_CONSOLE_ENABLED === 'false') {
LOG_CONFIG.console.enabled = false;
}
if (process.env.LOG_MAX_SIZE) {
LOG_CONFIG.file.maxSize = parseInt(process.env.LOG_MAX_SIZE) * 1024 * 1024;
}
if (process.env.LOG_MAX_FILES) {
LOG_CONFIG.file.maxFiles = parseInt(process.env.LOG_MAX_FILES);
}
if (process.env.DETAILED_LOGS === 'true') {
LOG_CONFIG.includeDetails = true;
} else if (process.env.DETAILED_LOGS === 'false') {
LOG_CONFIG.includeDetails = false;
}
if (process.env.SIMPLE_LOGS === 'true') {
LOG_CONFIG.console.simplified = true;
} else if (process.env.SIMPLE_LOGS === 'false') {
LOG_CONFIG.console.simplified = false;
}
// 验证日志级别是否有效
if (!LOG_LEVELS[LOG_CONFIG.level]) {
console.warn(`无效的日志级别: ${LOG_CONFIG.level},将使用默认级别: INFO`);
LOG_CONFIG.level = 'INFO';
}
}
// 初始化配置
initConfig();
// 确保日志目录存在
async function ensureLogDirectory() {
if (config.logToFile) {
try {
await fs.access(config.logDirectory);
} catch (error) {
if (error.code === 'ENOENT') {
await fs.mkdir(config.logDirectory, { recursive: true });
console.log(`Created log directory: ${config.logDirectory}`);
} else {
throw error;
}
}
async function ensureLogDir() {
if (!LOG_CONFIG.file.enabled) return;
try {
await fsPromises.access(LOG_CONFIG.file.dir);
} catch (error) {
if (error.code === 'ENOENT') {
await fsPromises.mkdir(LOG_CONFIG.file.dir, { recursive: true });
} else {
console.error('无法创建日志目录:', error);
}
}
}
// 格式化时间 - 改进为更易读的格式
function getTimestamp() {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
const milliseconds = String(now.getMilliseconds()).padStart(3, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}`;
// 生成当前日志文件名
function getCurrentLogFile() {
const today = new Date().toISOString().split('T')[0];
return path.join(LOG_CONFIG.file.dir, LOG_CONFIG.file.nameFormat.replace(/%DATE%/g, today));
}
// 颜色代码
const COLORS = {
reset: '\x1b[0m',
bright: '\x1b[1m',
dim: '\x1b[2m',
underscore: '\x1b[4m',
blink: '\x1b[5m',
reverse: '\x1b[7m',
hidden: '\x1b[8m',
// 前景色
black: '\x1b[30m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m',
white: '\x1b[37m',
// 背景色
bgBlack: '\x1b[40m',
bgRed: '\x1b[41m',
bgGreen: '\x1b[42m',
bgYellow: '\x1b[43m',
bgBlue: '\x1b[44m',
bgMagenta: '\x1b[45m',
bgCyan: '\x1b[46m',
bgWhite: '\x1b[47m'
};
// 日志级别对应的颜色和标签
const LEVEL_STYLES = {
debug: { color: COLORS.cyan, label: 'DEBUG' },
info: { color: COLORS.blue, label: 'INFO ' },
success: { color: COLORS.green, label: 'DONE ' },
warn: { color: COLORS.yellow, label: 'WARN ' },
error: { color: COLORS.red, label: 'ERROR' },
fatal: { color: COLORS.bright + COLORS.red, label: 'FATAL' }
};
// 创建日志条目 - 改进格式
function createLogEntry(level, message, meta = {}) {
const timestamp = getTimestamp();
const levelInfo = LEVEL_STYLES[level] || { label: level.toUpperCase() };
// 元数据格式化 - 更简洁的呈现方式
let metaOutput = '';
if (meta instanceof Error) {
metaOutput = `\n ${COLORS.red}Error: ${meta.message}${COLORS.reset}`;
if (meta.stack) {
metaOutput += `\n ${COLORS.dim}Stack: ${meta.stack.split('\n').join('\n ')}${COLORS.reset}`;
}
} else if (Object.keys(meta).length > 0) {
// 检查是否为HTTP请求信息如果是则使用更简洁的格式
if (meta.ip && meta.userAgent) {
metaOutput = ` ${COLORS.dim}from ${meta.ip}${COLORS.reset}`;
} else {
// 对于其他元数据,仍然使用检查器但格式更友好
metaOutput = `\n ${util.inspect(meta, { colors: true, depth: 3 })}`;
}
// 检查是否需要轮转日志
async function checkRotation() {
if (!LOG_CONFIG.file.enabled) return false;
const currentLogFile = getCurrentLogFile();
try {
const stats = await fsPromises.stat(currentLogFile);
if (stats.size >= LOG_CONFIG.file.maxSize) {
return true;
}
// 为控制台格式化日志
const consoleOutput = config.colorize ?
`${COLORS.dim}${timestamp}${COLORS.reset} [${levelInfo.color}${levelInfo.label}${COLORS.reset}] ${message}${metaOutput}` :
`${timestamp} [${levelInfo.label}] ${message}${metaOutput ? ' ' + metaOutput.trim() : ''}`;
// 为文件准备JSON格式日志
const logObject = {
timestamp,
level: level,
message
};
if (Object.keys(meta).length > 0) {
logObject.meta = meta instanceof Error ?
{ name: meta.name, message: meta.message, stack: meta.stack } :
meta;
} catch (err) {
// 文件不存在,不需要轮转
if (err.code !== 'ENOENT') {
console.error('检查日志文件大小失败:', err);
}
return {
formatted: consoleOutput,
json: JSON.stringify(logObject)
};
}
return false;
}
// 日志函数
async function log(level, message, meta = {}) {
if (LOG_LEVELS[level] < LOG_LEVELS[config.level]) {
return;
}
const { formatted, json } = createLogEntry(level, message, meta);
// 控制台输出
console.log(formatted);
// 文件日志
if (config.logToFile) {
try {
await ensureLogDirectory();
const logFilePath = path.join(config.logDirectory, config.logFileName);
await fs.appendFile(logFilePath, json + '\n', 'utf8');
} catch (err) {
console.error(`${COLORS.red}Error writing to log file: ${err.message}${COLORS.reset}`);
}
// 轮转日志文件
async function rotateLogFile() {
if (!LOG_CONFIG.file.enabled) return;
const currentLogFile = getCurrentLogFile();
try {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const rotatedFile = `${currentLogFile}.${timestamp}`;
try {
// 检查文件是否存在
await fsPromises.access(currentLogFile);
// 重命名文件
await fsPromises.rename(currentLogFile, rotatedFile);
// 清理旧日志文件
await cleanupOldLogFiles();
} catch (err) {
// 如果文件不存在,则忽略
if (err.code !== 'ENOENT') {
console.error('轮转日志文件失败:', err);
}
}
} catch (err) {
console.error('轮转日志文件失败:', err);
}
}
// 日志API
const logger = {
debug: (message, meta = {}) => log('debug', message, meta),
info: (message, meta = {}) => log('info', message, meta),
success: (message, meta = {}) => log('success', message, meta),
warn: (message, meta = {}) => log('warn', message, meta),
error: (message, meta = {}) => log('error', message, meta),
fatal: (message, meta = {}) => log('fatal', message, meta),
// 清理旧日志文件
async function cleanupOldLogFiles() {
if (!LOG_CONFIG.file.enabled || LOG_CONFIG.file.maxFiles <= 0) return;
try {
const files = await fsPromises.readdir(LOG_CONFIG.file.dir);
const logFilePattern = LOG_CONFIG.file.nameFormat.replace(/%DATE%/g, '\\d{4}-\\d{2}-\\d{2}');
const logFileRegex = new RegExp(`^${logFilePattern}(\\.[\\d-T]+)?$`);
// 配置方法
configure: (options) => {
Object.assign(config, options);
},
const logFiles = files
.filter(file => logFileRegex.test(file))
.map(file => ({
name: file,
path: path.join(LOG_CONFIG.file.dir, file),
time: fs.statSync(path.join(LOG_CONFIG.file.dir, file)).mtime.getTime()
}))
.sort((a, b) => b.time - a.time); // 按修改时间降序排序
// HTTP请求日志方法 - 简化输出格式
request: (req, res, duration) => {
const status = res.statusCode;
const method = req.method;
const url = req.originalUrl || req.url;
const userAgent = req.headers['user-agent'] || '-';
const ip = req.ip || req.connection.remoteAddress || '-';
let level = 'info';
if (status >= 500) level = 'error';
else if (status >= 400) level = 'warn';
// 为HTTP请求创建更简洁的日志消息
let statusIndicator = '';
if (config.colorize) {
if (status >= 500) statusIndicator = COLORS.red;
else if (status >= 400) statusIndicator = COLORS.yellow;
else if (status >= 300) statusIndicator = COLORS.cyan;
else if (status >= 200) statusIndicator = COLORS.green;
statusIndicator += status + COLORS.reset;
} else {
statusIndicator = status;
}
// 简化的请求日志格式
const message = `${method} ${url} ${statusIndicator} ${duration}ms`;
// 传递ip和userAgent作为元数据但以简洁方式显示
log(level, message, { ip, userAgent });
// 保留最新的maxFiles个文件删除其余的
const filesToDelete = logFiles.slice(LOG_CONFIG.file.maxFiles);
for (const file of filesToDelete) {
try {
await fsPromises.unlink(file.path);
} catch (err) {
console.error(`删除旧日志文件 ${file.path} 失败:`, err);
}
}
};
} catch (err) {
console.error('清理旧日志文件失败:', err);
}
}
// 初始化
ensureLogDirectory().catch(err => {
console.error(`${COLORS.red}Failed to initialize logger: ${err.message}${COLORS.reset}`);
});
// 写入日志文件
async function writeToLogFile(message) {
if (!LOG_CONFIG.file.enabled) return;
try {
await ensureLogDir();
// 检查是否需要轮转日志
if (await checkRotation()) {
await rotateLogFile();
}
const currentLogFile = getCurrentLogFile();
const logEntry = `${message}\n`;
await fsPromises.appendFile(currentLogFile, logEntry);
} catch (error) {
console.error('写入日志文件失败:', error);
}
}
module.exports = logger;
// 格式化日志消息
function formatLogMessage(level, message, details) {
const timestamp = new Date().toISOString();
const prefix = `[${level.prefix}]`;
// 简化标准日志格式:时间戳 [日志级别] 消息
const standardMessage = `${timestamp} ${prefix} ${message}`;
let detailsStr = '';
if (details) {
if (details instanceof Error) {
detailsStr = ` ${details.message}`;
if (LOG_CONFIG.includeStack && details.stack) {
detailsStr += `\n${details.stack}`;
}
} else if (typeof details === 'object') {
try {
// 只输出关键字段
const filteredDetails = { ...details };
// 移除大型或不重要的字段
['stack', 'userAgent', 'referer'].forEach(key => {
if (key in filteredDetails) delete filteredDetails[key];
});
// 使用紧凑格式输出JSON
detailsStr = Object.keys(filteredDetails).length > 0
? ` ${JSON.stringify(filteredDetails)}`
: '';
} catch (e) {
detailsStr = ` ${util.inspect(details, { depth: 1, colors: false, compact: true })}`;
}
} else {
detailsStr = ` ${details}`;
}
}
return {
console: LOG_CONFIG.console.colorize
? `${timestamp} ${colors[level.color](prefix)} ${message}${detailsStr}`
: `${timestamp} ${prefix} ${message}${detailsStr}`,
file: `${standardMessage}${detailsStr}`
};
}
// 检查当前日志级别是否应该记录指定级别的日志
function shouldLog(levelName) {
const configLevel = LOG_LEVELS[LOG_CONFIG.level];
const messageLevel = LOG_LEVELS[levelName];
if (!configLevel || !messageLevel) {
return true; // 默认允许记录
}
return messageLevel.priority >= configLevel.priority;
}
// 记录日志的通用函数
function log(level, message, details) {
if (!LOG_LEVELS[level]) {
level = 'INFO';
}
// 检查是否应该记录该级别的日志
if (!shouldLog(level)) {
return;
}
const formattedMessage = formatLogMessage(LOG_LEVELS[level], message, details);
// 控制台输出
if (LOG_CONFIG.console.enabled) {
console.log(formattedMessage.console);
}
// 写入文件
if (LOG_CONFIG.file.enabled) {
writeToLogFile(formattedMessage.file);
}
}
// 请求日志函数
function request(req, res, duration) {
const method = req.method;
const url = req.originalUrl || req.url;
const status = res.statusCode;
const ip = req.ip ? req.ip.replace(/::ffff:/, '') : 'unknown';
// 根据状态码确定日志级别
let level = 'INFO';
if (status >= 400 && status < 500) level = 'WARN';
if (status >= 500) level = 'ERROR';
// 简化日志消息格式
const logMessage = `${method} ${url} ${status} ${duration}ms`;
// 只有在需要时才收集详细信息
let details = null;
// 如果请求标记为跳过详细日志或不是开发环境,则不记录详细信息
if (!req.skipDetailedLogging && LOG_CONFIG.includeDetails) {
// 记录最少的必要信息
details = {};
// 只在错误状态码时记录更多信息
if (status >= 400) {
// 安全地记录请求参数,过滤敏感信息
const sanitizedBody = req.sanitizedBody || req.body;
if (sanitizedBody && Object.keys(sanitizedBody).length > 0) {
// 屏蔽敏感字段
const filtered = { ...sanitizedBody };
['password', 'token', 'apiKey', 'secret', 'credentials'].forEach(key => {
if (key in filtered) filtered[key] = '******';
});
details.body = filtered;
}
if (req.params && Object.keys(req.params).length > 0) {
details.params = req.params;
}
if (req.query && Object.keys(req.query).length > 0) {
details.query = req.query;
}
}
// 如果details为空对象则设为null
if (Object.keys(details).length === 0) {
details = null;
}
}
log(level, logMessage, details);
}
// 设置日志级别
function setLogLevel(level) {
if (LOG_LEVELS[level]) {
LOG_CONFIG.level = level;
log('INFO', `日志级别已设置为 ${level}`);
return true;
}
log('WARN', `尝试设置无效的日志级别: ${level}`);
return false;
}
// 公开各类日志记录函数
module.exports = {
trace: (message, details) => log('TRACE', message, details),
debug: (message, details) => log('DEBUG', message, details),
info: (message, details) => log('INFO', message, details),
success: (message, details) => log('SUCCESS', message, details),
warn: (message, details) => log('WARN', message, details),
error: (message, details) => log('ERROR', message, details),
fatal: (message, details) => log('FATAL', message, details),
request,
setLogLevel,
LOG_LEVELS: Object.keys(LOG_LEVELS),
config: LOG_CONFIG
};

View File

@@ -0,0 +1,90 @@
/**
* 认证相关中间件
*/
const logger = require('../logger');
/**
* 检查是否已登录的中间件
*/
function requireLogin(req, res, next) {
// 放开session检查不强制要求登录
if (req.url.startsWith('/api/documentation') ||
req.url.startsWith('/api/system-resources') ||
req.url.startsWith('/api/monitoring-config') ||
req.url.startsWith('/api/toggle-monitoring') ||
req.url.startsWith('/api/test-notification') ||
req.url.includes('/docker/status')) {
return next(); // 这些API路径不需要登录
}
// 检查用户是否登录
if (req.session && req.session.user) {
// 刷新会话
req.session.touch();
return next();
}
// 未登录返回401错误
res.status(401).json({ error: '未登录或会话已过期', code: 'SESSION_EXPIRED' });
}
// 修改登录逻辑
async function login(req, res) {
try {
const { username, password } = req.body;
// 简单验证
if (username === 'admin' && password === 'admin123') {
req.session.user = { username };
return res.json({ success: true });
}
res.status(401).json({ error: '用户名或密码错误' });
} catch (error) {
logger.error('登录失败:', error);
res.status(500).json({ error: '登录失败' });
}
}
/**
* 记录会话活动的中间件
*/
function sessionActivity(req, res, next) {
if (req.session && req.session.user) {
req.session.lastActivity = Date.now();
req.session.touch(); // 确保会话刷新
}
next();
}
// 过滤敏感信息中间件
function sanitizeRequestBody(req, res, next) {
if (req.body) {
const sanitizedBody = {...req.body};
// 过滤敏感字段
if (sanitizedBody.password) sanitizedBody.password = '[REDACTED]';
if (sanitizedBody.currentPassword) sanitizedBody.currentPassword = '[REDACTED]';
if (sanitizedBody.newPassword) sanitizedBody.newPassword = '[REDACTED]';
// 保存清理后的请求体供日志使用
req.sanitizedBody = sanitizedBody;
}
next();
}
// 安全头部中间件
function securityHeaders(req, res, next) {
// 添加安全头部
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-XSS-Protection', '1; mode=block');
next();
}
module.exports = {
requireLogin,
sessionActivity,
sanitizeRequestBody,
securityHeaders
};

View File

@@ -0,0 +1,26 @@
/**
* 客户端错误处理中间件
*/
const logger = require('../logger');
// 处理客户端上报的错误
function handleClientError(req, res, next) {
if (req.url === '/api/client-error' && req.method === 'POST') {
const { message, source, lineno, colno, error, stack, userAgent, page } = req.body;
logger.error('客户端错误:', {
message,
source,
location: `${lineno}:${colno}`,
stack: stack || (error && error.stack),
userAgent,
page
});
res.json({ success: true });
} else {
next();
}
}
module.exports = handleClientError;

View File

@@ -0,0 +1,13 @@
const mongoose = require('mongoose');
const menuItemSchema = new mongoose.Schema({
text: { type: String, required: true },
link: { type: String, required: true },
icon: String,
newTab: { type: Boolean, default: false },
enabled: { type: Boolean, default: true },
order: { type: Number, default: 0 },
createdAt: { type: Date, default: Date.now }
});
module.exports = mongoose.model('MenuItem', menuItemSchema);

View File

@@ -1,17 +1,43 @@
{
"name": "hubcmdui",
"version": "1.0.0",
"description": "Docker镜像代理加速系统",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"test": "jest",
"init": "node scripts/init-system.js",
"setup": "npm install && node scripts/init-system.js && echo '系统安装完成,请使用 npm start 启动服务'"
},
"keywords": [
"docker",
"proxy",
"management"
],
"author": "",
"license": "MIT",
"dependencies": {
"axios": "^1.7.5",
"axios-retry": "^3.5.0",
"bcrypt": "^5.1.1",
"chalk": "^5.3.0",
"axios": "^0.27.2",
"axios-retry": "^3.3.1",
"bcrypt": "^5.0.1",
"body-parser": "^1.20.0",
"chalk": "^4.1.2",
"cors": "^2.8.5",
"dockerode": "^4.0.2",
"express": "^4.19.2",
"express-session": "^1.18.0",
"morgan": "^1.10.0",
"dockerode": "^3.3.4",
"express": "^4.21.2",
"express-session": "^1.18.1",
"node-cache": "^5.1.2",
"p-limit": "^3.1.0",
"validator": "^13.12.0",
"ws": "^8.18.0"
"p-limit": "^4.0.0",
"session-file-store": "^1.5.0",
"validator": "^13.7.0",
"ws": "^8.8.1"
},
"devDependencies": {
"jest": "^28.1.3",
"nodemon": "^2.0.19"
},
"engines": {
"node": ">=14.0.0"
}
}

155
hubcmdui/routes/auth.js Normal file
View File

@@ -0,0 +1,155 @@
/**
* 认证相关路由
*/
const express = require('express');
const router = express.Router();
const bcrypt = require('bcrypt');
const userService = require('../services/userService');
const logger = require('../logger');
const { requireLogin } = require('../middleware/auth');
// 登录验证
router.post('/login', async (req, res) => {
const { username, password, captcha } = req.body;
if (req.session.captcha !== parseInt(captcha)) {
logger.warn(`Captcha verification failed for user: ${username}`);
return res.status(401).json({ error: '验证码错误' });
}
try {
const users = await userService.getUsers();
const user = users.users.find(u => u.username === username);
if (!user) {
logger.warn(`User ${username} not found`);
return res.status(401).json({ error: '用户名或密码错误' });
}
if (bcrypt.compareSync(req.body.password, user.password)) {
req.session.user = { username: user.username };
// 更新用户登录信息
await userService.updateUserLoginInfo(username);
// 确保服务器启动时间已设置
if (!global.serverStartTime) {
global.serverStartTime = Date.now();
logger.warn(`登录时设置服务器启动时间: ${global.serverStartTime}`);
}
logger.info(`User ${username} logged in successfully`);
res.json({
success: true,
serverStartTime: global.serverStartTime
});
} else {
logger.warn(`Login failed for user: ${username}`);
res.status(401).json({ error: '用户名或密码错误' });
}
} catch (error) {
logger.error('登录失败:', error);
res.status(500).json({ error: '登录处理失败', details: error.message });
}
});
// 注销
router.post('/logout', (req, res) => {
req.session.destroy(err => {
if (err) {
logger.error('销毁会话失败:', err);
return res.status(500).json({ error: 'Failed to logout' });
}
res.clearCookie('connect.sid');
logger.info('用户已退出登录');
res.json({ success: true });
});
});
// 修改密码
router.post('/change-password', requireLogin, async (req, res) => {
const { currentPassword, newPassword } = req.body;
// 密码复杂度校验
const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)(?=.*[.,\-_+=()[\]{}|\\;:'"<>?/@$!%*#?&])[A-Za-z\d.,\-_+=()[\]{}|\\;:'"<>?/@$!%*#?&]{8,16}$/;
if (!passwordRegex.test(newPassword)) {
return res.status(400).json({ error: 'Password must be 8-16 characters long and contain at least one letter, one number, and one special character' });
}
try {
const { users } = await userService.getUsers();
const user = users.find(u => u.username === req.session.user.username);
if (user && bcrypt.compareSync(currentPassword, user.password)) {
user.password = bcrypt.hashSync(newPassword, 10);
await userService.saveUsers(users);
res.json({ success: true });
} else {
res.status(401).json({ error: 'Invalid current password' });
}
} catch (error) {
logger.error('修改密码失败:', error);
res.status(500).json({ error: '修改密码失败', details: error.message });
}
});
// 获取用户信息
router.get('/user-info', requireLogin, async (req, res) => {
try {
const userService = require('../services/userService');
const userStats = await userService.getUserStats(req.session.user.username);
res.json(userStats);
} catch (error) {
logger.error('获取用户信息失败:', error);
res.status(500).json({ error: '获取用户信息失败', details: error.message });
}
});
// 生成验证码
router.get('/captcha', (req, res) => {
const num1 = Math.floor(Math.random() * 10);
const num2 = Math.floor(Math.random() * 10);
const captcha = `${num1} + ${num2} = ?`;
req.session.captcha = num1 + num2;
// 确保serverStartTime已初始化
if (!global.serverStartTime) {
global.serverStartTime = Date.now();
logger.warn(`初始化服务器启动时间: ${global.serverStartTime}`);
}
res.json({
captcha,
serverStartTime: global.serverStartTime
});
});
// 检查会话状态
router.get('/check-session', (req, res) => {
// 如果global.serverStartTime不存在创建一个
if (!global.serverStartTime) {
global.serverStartTime = Date.now();
logger.warn(`设置服务器启动时间: ${global.serverStartTime}`);
}
if (req.session && req.session.user) {
return res.json({
success: true,
user: {
username: req.session.user.username,
role: req.session.user.role,
},
serverStartTime: global.serverStartTime // 返回服务器启动时间
});
}
return res.status(401).json({
success: false,
message: '未登录',
serverStartTime: global.serverStartTime // 即使未登录也返回服务器时间
});
});
logger.success('✓ 认证路由已加载');
// 导出路由
module.exports = router;

224
hubcmdui/routes/config.js Normal file
View File

@@ -0,0 +1,224 @@
/**
* 配置路由模块
*/
const express = require('express');
const router = express.Router();
const fs = require('fs').promises;
const path = require('path');
const logger = require('../logger');
const { requireLogin } = require('../middleware/auth');
const configService = require('../services/configService');
// 修改配置文件路径,使用独立的配置文件
const configFilePath = path.join(__dirname, '../data/config.json');
// 默认配置
const DEFAULT_CONFIG = {
proxyDomain: 'registry-1.docker.io',
logo: '',
theme: 'light'
};
// 确保配置文件存在
async function ensureConfigFile() {
try {
// 确保目录存在
const dir = path.dirname(configFilePath);
try {
await fs.access(dir);
} catch (error) {
await fs.mkdir(dir, { recursive: true });
logger.info(`创建目录: ${dir}`);
}
// 检查文件是否存在
try {
await fs.access(configFilePath);
const data = await fs.readFile(configFilePath, 'utf8');
return JSON.parse(data);
} catch (error) {
// 文件不存在或JSON解析错误创建默认配置
await fs.writeFile(configFilePath, JSON.stringify(DEFAULT_CONFIG, null, 2));
logger.info(`创建默认配置文件: ${configFilePath}`);
return DEFAULT_CONFIG;
}
} catch (error) {
logger.error(`配置文件操作失败: ${error.message}`);
// 出错时返回默认配置以确保API不会失败
return DEFAULT_CONFIG;
}
}
// 获取配置
router.get('/config', async (req, res) => {
try {
const config = await ensureConfigFile();
res.json(config);
} catch (error) {
logger.error('获取配置失败:', error);
// 即使失败也返回默认配置
res.json(DEFAULT_CONFIG);
}
});
// 保存配置
router.post('/config', async (req, res) => {
try {
const newConfig = req.body;
// 验证请求数据
if (!newConfig || typeof newConfig !== 'object') {
return res.status(400).json({
error: '无效的配置数据',
details: '配置必须是一个对象'
});
}
// 读取现有配置
let existingConfig;
try {
existingConfig = await ensureConfigFile();
} catch (error) {
existingConfig = DEFAULT_CONFIG;
}
// 合并配置
const mergedConfig = { ...existingConfig, ...newConfig };
// 保存到文件
await fs.writeFile(configFilePath, JSON.stringify(mergedConfig, null, 2));
res.json({ success: true, message: '配置已保存' });
} catch (error) {
logger.error('保存配置失败:', error);
res.status(500).json({
error: '保存配置失败',
details: error.message
});
}
});
// 获取监控配置
router.get('/monitoring-config', async (req, res) => {
logger.info('收到监控配置请求');
try {
logger.info('读取监控配置...');
const config = await configService.getConfig();
if (!config.monitoringConfig) {
logger.info('监控配置不存在,创建默认配置');
config.monitoringConfig = {
notificationType: 'wechat',
webhookUrl: '',
telegramToken: '',
telegramChatId: '',
monitorInterval: 60,
isEnabled: false
};
await configService.saveConfig(config);
}
logger.info('返回监控配置');
res.json({
notificationType: config.monitoringConfig.notificationType || 'wechat',
webhookUrl: config.monitoringConfig.webhookUrl || '',
telegramToken: config.monitoringConfig.telegramToken || '',
telegramChatId: config.monitoringConfig.telegramChatId || '',
monitorInterval: config.monitoringConfig.monitorInterval || 60,
isEnabled: config.monitoringConfig.isEnabled || false
});
} catch (error) {
logger.error('获取监控配置失败:', error);
res.status(500).json({ error: '获取监控配置失败', details: error.message });
}
});
// 保存监控配置
router.post('/monitoring-config', requireLogin, async (req, res) => {
try {
const {
notificationType,
webhookUrl,
telegramToken,
telegramChatId,
monitorInterval,
isEnabled
} = req.body;
// 验证必填字段
if (!notificationType) {
return res.status(400).json({ error: '通知类型不能为空' });
}
// 根据通知类型验证对应的字段
if (notificationType === 'wechat' && !webhookUrl) {
return res.status(400).json({ error: '企业微信 Webhook URL 不能为空' });
}
if (notificationType === 'telegram' && (!telegramToken || !telegramChatId)) {
return res.status(400).json({ error: 'Telegram Token 和 Chat ID 不能为空' });
}
// 保存配置
const config = await configService.getConfig();
config.monitoringConfig = {
notificationType,
webhookUrl: webhookUrl || '',
telegramToken: telegramToken || '',
telegramChatId: telegramChatId || '',
monitorInterval: parseInt(monitorInterval) || 60,
isEnabled: !!isEnabled
};
await configService.saveConfig(config);
logger.info('监控配置已更新');
res.json({ success: true, message: '监控配置已保存' });
} catch (error) {
logger.error('保存监控配置失败:', error);
res.status(500).json({ error: '保存监控配置失败', details: error.message });
}
});
// 测试通知
router.post('/test-notification', requireLogin, async (req, res) => {
try {
const { notificationType, webhookUrl, telegramToken, telegramChatId } = req.body;
// 验证参数
if (!notificationType) {
return res.status(400).json({ error: '通知类型不能为空' });
}
if (notificationType === 'wechat' && !webhookUrl) {
return res.status(400).json({ error: '企业微信 Webhook URL 不能为空' });
}
if (notificationType === 'telegram' && (!telegramToken || !telegramChatId)) {
return res.status(400).json({ error: 'Telegram Token 和 Chat ID 不能为空' });
}
// 构造测试消息
const testMessage = {
title: '测试通知',
content: `这是一条测试通知消息,发送时间: ${new Date().toLocaleString('zh-CN')}`,
type: 'info'
};
// 模拟发送通知
logger.info('发送测试通知:', testMessage);
// TODO: 实际发送通知的逻辑
// 这里仅做模拟,实际应用中需要实现真正的通知发送逻辑
// 返回成功
res.json({ success: true, message: '测试通知已发送' });
} catch (error) {
logger.error('发送测试通知失败:', error);
res.status(500).json({ error: '发送测试通知失败', details: error.message });
}
});
// 导出路由
module.exports = router;

146
hubcmdui/routes/docker.js Normal file
View File

@@ -0,0 +1,146 @@
/**
* Docker容器管理路由
*/
const express = require('express');
const router = express.Router();
const WebSocket = require('ws');
const http = require('http');
const dockerService = require('../services/dockerService');
const logger = require('../logger');
const { requireLogin } = require('../middleware/auth');
// 获取Docker状态
router.get('/status', requireLogin, async (req, res) => {
try {
const containerStatus = await dockerService.getContainersStatus();
res.json(containerStatus);
} catch (error) {
logger.error('获取 Docker 状态时出错:', error);
res.status(500).json({ error: '获取 Docker 状态失败', details: error.message });
}
});
// 获取单个容器状态
router.get('/status/:id', requireLogin, async (req, res) => {
try {
const containerInfo = await dockerService.getContainerStatus(req.params.id);
res.json(containerInfo);
} catch (error) {
logger.error('获取容器状态失败:', error);
res.status(500).json({ error: '获取容器状态失败', details: error.message });
}
});
// 重启容器
router.post('/restart/:id', requireLogin, async (req, res) => {
try {
await dockerService.restartContainer(req.params.id);
res.json({ success: true });
} catch (error) {
logger.error('重启容器失败:', error);
res.status(500).json({ error: '重启容器失败', details: error.message });
}
});
// 停止容器
router.post('/stop/:id', requireLogin, async (req, res) => {
try {
await dockerService.stopContainer(req.params.id);
res.json({ success: true });
} catch (error) {
logger.error('停止容器失败:', error);
res.status(500).json({ error: '停止容器失败', details: error.message });
}
});
// 删除容器
router.post('/delete/:id', requireLogin, async (req, res) => {
try {
await dockerService.deleteContainer(req.params.id);
res.json({ success: true, message: '容器已成功删除' });
} catch (error) {
logger.error('删除容器失败:', error);
res.status(500).json({ error: '删除容器失败', details: error.message });
}
});
// 更新容器
router.post('/update/:id', requireLogin, async (req, res) => {
try {
const { tag } = req.body;
await dockerService.updateContainer(req.params.id, tag);
res.json({ success: true, message: '容器更新成功' });
} catch (error) {
logger.error('更新容器失败:', error);
res.status(500).json({ error: '更新容器失败', details: error.message, stack: error.stack });
}
});
// 获取已停止容器
router.get('/stopped', requireLogin, async (req, res) => {
try {
const stoppedContainers = await dockerService.getStoppedContainers();
res.json(stoppedContainers);
} catch (error) {
logger.error('获取已停止容器列表失败:', error);
res.status(500).json({ error: '获取已停止容器列表失败', details: error.message });
}
});
// 获取容器日志(HTTP轮询)
router.get('/logs-poll/:id', async (req, res) => {
const { id } = req.params;
try {
const logs = await dockerService.getContainerLogs(id);
res.send(logs);
} catch (error) {
logger.error('获取容器日志失败:', error);
res.status(500).send('获取日志失败');
}
});
// 设置WebSocket路由用于实时日志流
function setupLogWebsocket(server) {
const wss = new WebSocket.Server({ server });
wss.on('connection', async (ws, req) => {
try {
const containerId = req.url.split('/').pop();
const docker = await dockerService.getDockerConnection();
if (!docker) {
ws.send('Error: 无法连接到 Docker 守护进程');
return;
}
const container = docker.getContainer(containerId);
const stream = await container.logs({
follow: true,
stdout: true,
stderr: true,
tail: 100
});
stream.on('data', (chunk) => {
const cleanedChunk = chunk.toString('utf8').replace(/\x1B\[[0-9;]*[JKmsu]/g, '');
// 移除不可打印字符
const printableChunk = cleanedChunk.replace(/[^\x20-\x7E\x0A\x0D]/g, '');
ws.send(printableChunk);
});
ws.on('close', () => {
stream.destroy();
});
stream.on('error', (err) => {
ws.send('Error: ' + err.message);
});
} catch (err) {
ws.send('Error: ' + err.message);
}
});
}
// 直接导出 router 实例,并添加 setupLogWebsocket 作为静态属性
router.setupLogWebsocket = setupLogWebsocket;
module.exports = router;

View File

@@ -0,0 +1,65 @@
/**
* Docker Hub 代理路由
*/
const express = require('express');
const router = express.Router();
const axios = require('axios');
const logger = require('../logger');
// Docker Hub API 基础 URL
const DOCKER_HUB_API = 'https://hub.docker.com/v2';
// 搜索镜像
router.get('/search', async (req, res) => {
try {
const { term, page = 1, limit = 25 } = req.query;
// 确保有搜索关键字
if (!term) {
return res.status(400).json({ error: '缺少搜索关键字(term)' });
}
logger.info(`搜索 Docker Hub: 关键字="${term}", 页码=${page}`);
const response = await axios.get(`${DOCKER_HUB_API}/search/repositories`, {
params: {
query: term,
page,
page_size: limit
},
timeout: 10000
});
res.json(response.data);
} catch (err) {
logger.error('Docker Hub 搜索失败:', err.message);
res.status(500).json({ error: 'Docker Hub 搜索失败', details: err.message });
}
});
// 获取镜像标签
router.get('/tags/:owner/:repo', async (req, res) => {
try {
const { owner, repo } = req.params;
const { page = 1, limit = 25 } = req.query;
logger.info(`获取镜像标签: ${owner}/${repo}, 页码=${page}`);
const response = await axios.get(
`${DOCKER_HUB_API}/repositories/${owner}/${repo}/tags`, {
params: {
page,
page_size: limit
},
timeout: 10000
});
res.json(response.data);
} catch (err) {
logger.error('获取 Docker 镜像标签失败:', err.message);
res.status(500).json({ error: '获取镜像标签失败', details: err.message });
}
});
// 直接导出路由实例
module.exports = router;

View File

@@ -0,0 +1,537 @@
/**
* 文档管理路由
*/
const express = require('express');
const router = express.Router();
const fs = require('fs').promises;
const path = require('path');
const logger = require('../logger');
const { requireLogin } = require('../middleware/auth');
// 确保文档目录存在
const docsDir = path.join(__dirname, '../documentation');
const metaDir = path.join(docsDir, 'meta');
// 文档文件扩展名
const FILE_EXTENSION = '.md';
const META_EXTENSION = '.json';
// 确保目录存在
async function ensureDirectories() {
try {
await fs.mkdir(docsDir, { recursive: true });
await fs.mkdir(metaDir, { recursive: true });
} catch (err) {
logger.error('创建文档目录失败:', err);
}
}
// 读取文档元数据
async function getDocumentMeta(id) {
const metaPath = path.join(metaDir, `${id}${META_EXTENSION}`);
try {
const metaContent = await fs.readFile(metaPath, 'utf8');
return JSON.parse(metaContent);
} catch (err) {
// 如果元数据文件不存在,返回默认值
return {
id,
published: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
}
}
// 保存文档元数据
async function saveDocumentMeta(id, metadata) {
await ensureDirectories();
const metaPath = path.join(metaDir, `${id}${META_EXTENSION}`);
const metaContent = JSON.stringify(metadata, null, 2);
await fs.writeFile(metaPath, metaContent);
}
// 初始化确保目录存在,但不再创建默认文档
(async function() {
try {
await ensureDirectories();
logger.info('文档目录已初始化');
} catch (error) {
logger.error('初始化文档目录失败:', error);
}
})();
// 获取所有文档列表
router.get('/documents', async (req, res) => {
try {
let files;
try {
files = await fs.readdir(docsDir);
} catch (err) {
// 如果目录不存在,尝试创建它并返回空列表
if (err.code === 'ENOENT') {
await fs.mkdir(docsDir, { recursive: true });
files = [];
} else {
throw err;
}
}
const documents = [];
for (const file of files) {
if (file.endsWith(FILE_EXTENSION)) {
const filePath = path.join(docsDir, file);
const stats = await fs.stat(filePath);
const content = await fs.readFile(filePath, 'utf8');
const id = file.replace(FILE_EXTENSION, '');
// 读取元数据
const metadata = await getDocumentMeta(id);
// 解析文档元数据(简单实现)
const titleMatch = content.match(/^#\s+(.*)$/m);
const title = titleMatch ? titleMatch[1] : id;
documents.push({
id,
title,
content,
createdAt: metadata.createdAt || stats.birthtime,
updatedAt: metadata.updatedAt || stats.mtime,
published: metadata.published || false
});
}
}
res.json(documents);
} catch (err) {
logger.error('获取文档列表失败:', err);
res.status(500).json({ error: '获取文档列表失败' });
}
});
// 保存文档
router.put('/documents/:id', requireLogin, async (req, res) => {
try {
const { id } = req.params;
const { title, content, published } = req.body;
if (!title || !content) {
return res.status(400).json({ error: '标题和内容为必填项' });
}
const filePath = path.join(docsDir, `${id}${FILE_EXTENSION}`);
// 确保文档目录存在
await ensureDirectories();
// 获取或创建元数据
const metadata = await getDocumentMeta(id);
metadata.title = title;
metadata.published = typeof published === 'boolean' ? published : metadata.published;
metadata.updatedAt = new Date().toISOString();
// 保存文档内容
await fs.writeFile(filePath, content);
// 保存元数据
await saveDocumentMeta(id, metadata);
// 获取文件状态
const stats = await fs.stat(filePath);
const document = {
id,
title,
content,
createdAt: metadata.createdAt,
updatedAt: new Date().toISOString(),
published: metadata.published
};
res.json(document);
} catch (err) {
logger.error('保存文档失败:', err);
res.status(500).json({ error: '保存文档失败', details: err.message });
}
});
// 创建新文档
router.post('/documents', requireLogin, async (req, res) => {
try {
const { title, content, published } = req.body;
if (!title || !content) {
return res.status(400).json({ error: '标题和内容为必填项' });
}
// 生成唯一ID
const id = Date.now().toString();
const filePath = path.join(docsDir, `${id}${FILE_EXTENSION}`);
// 确保文档目录存在
await ensureDirectories();
// 创建元数据
const now = new Date().toISOString();
const metadata = {
id,
title,
published: typeof published === 'boolean' ? published : false,
createdAt: now,
updatedAt: now
};
// 保存文档内容
await fs.writeFile(filePath, content);
// 保存元数据
await saveDocumentMeta(id, metadata);
const document = {
id,
title,
content,
createdAt: now,
updatedAt: now,
published: metadata.published
};
res.status(201).json(document);
} catch (err) {
logger.error('创建文档失败:', err);
res.status(500).json({ error: '创建文档失败', details: err.message });
}
});
// 删除文档
router.delete('/documents/:id', requireLogin, async (req, res) => {
try {
const { id } = req.params;
const filePath = path.join(docsDir, `${id}${FILE_EXTENSION}`);
const metaPath = path.join(metaDir, `${id}${META_EXTENSION}`);
let success = false;
// 尝试删除主文档文件
try {
await fs.access(filePath);
await fs.unlink(filePath);
success = true;
logger.info(`文档 ${id} 已成功删除`);
} catch (err) {
logger.warn(`删除文档文件 ${id} 失败:`, err);
}
// 尝试删除元数据文件
try {
await fs.access(metaPath);
await fs.unlink(metaPath);
success = true;
logger.info(`文档元数据 ${id} 已成功删除`);
} catch (err) {
logger.warn(`删除文档元数据 ${id} 失败:`, err);
}
if (success) {
res.json({ success: true });
} else {
throw new Error('文档和元数据均不存在或无法删除');
}
} catch (err) {
logger.error(`删除文档 ${req.params.id} 失败:`, err);
res.status(500).json({ error: '删除文档失败', details: err.message });
}
});
// 获取单个文档
router.get('/documents/:id', async (req, res) => {
try {
const { id } = req.params;
const filePath = path.join(docsDir, `${id}${FILE_EXTENSION}`);
// 检查文件是否存在
try {
await fs.access(filePath);
} catch (err) {
return res.status(404).json({ error: '文档不存在' });
}
// 读取文件内容和元数据
const content = await fs.readFile(filePath, 'utf8');
const metadata = await getDocumentMeta(id);
// 解析文档标题
const titleMatch = content.match(/^#\s+(.*)$/m);
const title = titleMatch ? titleMatch[1] : metadata.title || id;
const document = {
id,
title,
content,
createdAt: metadata.createdAt,
updatedAt: metadata.updatedAt,
published: metadata.published
};
res.json(document);
} catch (err) {
logger.error(`获取文档 ${req.params.id} 失败:`, err);
res.status(500).json({ error: '获取文档失败', details: err.message });
}
});
// 更新文档发布状态
router.put('/documentation/toggle-publish/:id', requireLogin, async (req, res) => {
try {
const { id } = req.params;
const { published } = req.body;
const filePath = path.join(docsDir, `${id}${FILE_EXTENSION}`);
// 检查文件是否存在
try {
await fs.access(filePath);
} catch (err) {
return res.status(404).json({ error: '文档不存在' });
}
// 读取文件内容和元数据
const content = await fs.readFile(filePath, 'utf8');
const metadata = await getDocumentMeta(id);
// 更新元数据
metadata.published = typeof published === 'boolean' ? published : !metadata.published;
metadata.updatedAt = new Date().toISOString();
// 保存元数据
await saveDocumentMeta(id, metadata);
// 解析文档标题
const titleMatch = content.match(/^#\s+(.*)$/m);
const title = titleMatch ? titleMatch[1] : metadata.title || id;
const document = {
id,
title,
content,
createdAt: metadata.createdAt,
updatedAt: metadata.updatedAt,
published: metadata.published
};
res.json(document);
} catch (err) {
logger.error(`更新文档状态 ${req.params.id} 失败:`, err);
res.status(500).json({ error: '更新文档状态失败', details: err.message });
}
});
// 为前端添加获取已发布文档列表的路由
router.get('/documentation', async (req, res) => {
try {
let files;
try {
files = await fs.readdir(docsDir);
} catch (err) {
if (err.code === 'ENOENT') {
await fs.mkdir(docsDir, { recursive: true });
files = [];
} else {
throw err;
}
}
const documents = [];
for (const file of files) {
if (file.endsWith(FILE_EXTENSION)) {
const filePath = path.join(docsDir, file);
const stats = await fs.stat(filePath);
const id = file.replace(FILE_EXTENSION, '');
// 读取元数据
const metadata = await getDocumentMeta(id);
// 如果发布状态为true则包含在返回结果中
if (metadata.published === true) {
const content = await fs.readFile(filePath, 'utf8');
// 解析文档标题
const titleMatch = content.match(/^#\s+(.*)$/m);
const title = titleMatch ? titleMatch[1] : metadata.title || id;
documents.push({
id,
title,
createdAt: metadata.createdAt || stats.birthtime,
updatedAt: metadata.updatedAt || stats.mtime,
published: true
});
}
}
}
logger.info(`前端请求文档列表,返回 ${documents.length} 个已发布文档`);
res.json(documents);
} catch (err) {
logger.error('获取前端文档列表失败:', err);
res.status(500).json({ error: '获取文档列表失败' });
}
});
// 前端获取单个文档内容
router.get('/documentation/:id', async (req, res) => {
try {
const { id } = req.params;
const filePath = path.join(docsDir, `${id}${FILE_EXTENSION}`);
// 检查文件是否存在
try {
await fs.access(filePath);
} catch (err) {
return res.status(404).json({ error: '文档不存在' });
}
// 读取文件内容和元数据
const content = await fs.readFile(filePath, 'utf8');
const metadata = await getDocumentMeta(id);
// 如果文档未发布,则不允许前端访问
if (metadata.published !== true) {
return res.status(403).json({ error: '该文档未发布,无法访问' });
}
// 解析文档标题
const titleMatch = content.match(/^#\s+(.*)$/m);
const title = titleMatch ? titleMatch[1] : metadata.title || id;
const document = {
id,
title,
content,
createdAt: metadata.createdAt,
updatedAt: metadata.updatedAt,
published: true
};
logger.info(`前端请求文档: ${id} - ${title}`);
res.json(document);
} catch (err) {
logger.error(`获取前端文档内容 ${req.params.id} 失败:`, err);
res.status(500).json({ error: '获取文档内容失败', details: err.message });
}
});
// 修改发布状态
router.patch('/documents/:id/publish', requireLogin, async (req, res) => {
try {
const { id } = req.params;
const { published } = req.body;
if (typeof published !== 'boolean') {
return res.status(400).json({ error: '发布状态必须为布尔值' });
}
// 获取或创建元数据
const metadata = await getDocumentMeta(id);
metadata.published = published;
metadata.updatedAt = new Date().toISOString();
// 保存元数据
await saveDocumentMeta(id, metadata);
// 获取文档内容
const filePath = path.join(docsDir, `${id}${FILE_EXTENSION}`);
let content;
try {
content = await fs.readFile(filePath, 'utf8');
} catch (err) {
if (err.code === 'ENOENT') {
return res.status(404).json({ error: '文档不存在' });
}
throw err;
}
// 解析标题
const titleMatch = content.match(/^#\s+(.*)$/m);
const title = titleMatch ? titleMatch[1] : id;
// 返回更新后的文档信息
const document = {
id,
title,
content,
createdAt: metadata.createdAt,
updatedAt: metadata.updatedAt,
published: metadata.published
};
res.json(document);
} catch (err) {
logger.error('修改发布状态失败:', err);
res.status(500).json({ error: '修改发布状态失败', details: err.message });
}
});
// 获取单个文档文件内容
router.get('/file', async (req, res) => {
try {
const filePath = req.query.path;
if (!filePath) {
return res.status(400).json({ error: '文件路径不能为空' });
}
logger.info(`请求获取文档文件: ${filePath}`);
// 尝试直接从文件系统读取文件
try {
// 安全检查确保只能访问documentation目录下的文件
const fileName = path.basename(filePath);
const fileDir = path.dirname(filePath);
// 构建完整路径只允许访问documentation目录
const fullPath = path.join(__dirname, '..', 'documentation', fileName);
logger.info(`尝试读取文件: ${fullPath}`);
// 检查文件是否存在
const fileExists = await fs.access(fullPath)
.then(() => true)
.catch(() => false);
// 如果文件不存在,则返回错误
if (!fileExists) {
logger.warn(`文件不存在: ${fullPath}`);
return res.status(404).json({ error: '文档不存在' });
}
logger.info(`文件存在,开始读取: ${fullPath}`);
// 读取文件内容
const fileContent = await fs.readFile(fullPath, 'utf8');
// 设置适当的Content-Type
if (fileName.endsWith('.md')) {
res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
} else if (fileName.endsWith('.json')) {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
} else {
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
}
logger.info(`成功读取文件,内容长度: ${fileContent.length}`);
return res.send(fileContent);
} catch (error) {
logger.error(`读取文件失败: ${error.message}`, error);
return res.status(500).json({ error: `读取文件失败: ${error.message}` });
}
} catch (error) {
logger.error('获取文档文件失败:', error);
res.status(500).json({ error: '获取文档文件失败', details: error.message });
}
});
// 导出路由
logger.success('✓ 文档管理路由已加载');
module.exports = router;

48
hubcmdui/routes/health.js Normal file
View File

@@ -0,0 +1,48 @@
/**
* 健康检查路由
*/
const express = require('express');
const router = express.Router();
const os = require('os');
const path = require('path');
const { version } = require('../package.json');
// 简单健康检查
router.get('/', (req, res) => {
res.json({
status: 'ok',
uptime: process.uptime(),
timestamp: Date.now(),
version
});
});
// 详细系统信息
router.get('/system', (req, res) => {
try {
res.json({
status: 'ok',
system: {
platform: os.platform(),
release: os.release(),
hostname: os.hostname(),
uptime: os.uptime(),
totalMem: os.totalmem(),
freeMem: os.freemem(),
cpus: os.cpus().length,
loadavg: os.loadavg()
},
process: {
pid: process.pid,
uptime: process.uptime(),
memoryUsage: process.memoryUsage(),
nodeVersion: process.version,
env: process.env.NODE_ENV || 'development'
}
});
} catch (err) {
res.status(500).json({ error: '获取系统信息失败', details: err.message });
}
});
module.exports = router;

78
hubcmdui/routes/index.js Normal file
View File

@@ -0,0 +1,78 @@
/**
* 路由注册器
* 负责注册所有API路由
*/
const fs = require('fs');
const path = require('path');
const logger = require('../logger');
// 检查文件是否是路由模块
function isRouteModule(file) {
return file.endsWith('.js') &&
file !== 'index.js' &&
file !== 'routeLoader.js' &&
!file.startsWith('_');
}
/**
* 注册所有路由
* @param {Express} app - Express应用实例
*/
function registerRoutes(app) {
const routeDir = __dirname;
try {
const files = fs.readdirSync(routeDir);
const routeFiles = files.filter(isRouteModule);
logger.info(`发现 ${routeFiles.length} 个路由文件待加载`);
routeFiles.forEach(file => {
const routeName = path.basename(file, '.js');
try {
const routePath = path.join(routeDir, file);
const routeExport = require(routePath); // 加载导出的模块
// 优先处理 { router: routerInstance, ... } 格式
if (routeExport && typeof routeExport === 'object' && routeExport.router && typeof routeExport.router === 'function' && routeExport.router.stack) {
app.use(`/api/${routeName}`, routeExport.router);
logger.info(`✓ 挂载路由对象: /api/${routeName}`);
}
// 处理直接导出 routerInstance 的情况 (更严格的检查)
else if (typeof routeExport === 'function' && routeExport.handle && routeExport.stack) {
app.use(`/api/${routeName}`, routeExport);
logger.info(`✓ 挂载路由: /api/${routeName}`);
}
// 处理导出 { setup: setupFunction } 的情况
else if (routeExport && typeof routeExport === 'object' && routeExport.setup && typeof routeExport.setup === 'function') {
routeExport.setup(app);
logger.info(`✓ 初始化路由: ${routeName}`);
}
// 处理导出 setup 函数 (app) => {} 的情况
else if (typeof routeExport === 'function') {
// 检查是否是 Express Router 实例 (避免重复判断 Case 3)
if (!(routeExport.handle && routeExport.stack)) {
routeExport(app);
logger.info(`✓ 注册路由函数: ${routeName}`);
} else {
logger.warn(`× 路由 ${file} 格式疑似 Router 实例但未被 Case 3 处理,已跳过`);
}
}
// 其他无法识别的格式
else {
logger.warn(`× 路由 ${file} 导出格式无法识别 (${typeof routeExport}),已跳过`);
}
} catch (error) {
logger.error(`× 加载路由 ${file} 失败: ${error.message}`);
// 可以在这里添加更详细的错误堆栈日志
logger.debug(error.stack);
}
});
logger.info('所有路由注册完成');
} catch (error) {
logger.error(`路由注册失败: ${error.message}`);
}
}
module.exports = registerRoutes;

92
hubcmdui/routes/login.js Normal file
View File

@@ -0,0 +1,92 @@
/**
* 登录路由
*/
const express = require('express');
const router = express.Router();
const fs = require('fs').promises;
const path = require('path');
const crypto = require('crypto');
const logger = require('../logger');
// 生成随机验证码
function generateCaptcha() {
return Math.floor(1000 + Math.random() * 9000).toString();
}
// 获取验证码
router.get('/captcha', (req, res) => {
const captcha = generateCaptcha();
req.session.captcha = captcha;
res.json({ captcha });
});
// 处理登录
router.post('/login', async (req, res) => {
try {
const { username, password, captcha } = req.body;
// 验证码检查
if (!req.session.captcha || req.session.captcha !== captcha) {
return res.status(401).json({ error: '验证码错误' });
}
// 读取用户文件
const userFilePath = path.join(__dirname, '../config/users.json');
let users;
try {
const data = await fs.readFile(userFilePath, 'utf8');
users = JSON.parse(data);
} catch (err) {
logger.error('读取用户文件失败:', err);
return res.status(500).json({ error: '内部服务器错误' });
}
// 查找用户
const user = users.find(u => u.username === username);
if (!user) {
return res.status(401).json({ error: '用户名或密码错误' });
}
// 验证密码
const hashedPassword = crypto
.createHash('sha256')
.update(password + user.salt)
.digest('hex');
if (hashedPassword !== user.password) {
return res.status(401).json({ error: '用户名或密码错误' });
}
// 登录成功
req.session.user = {
id: user.id,
username: user.username,
role: user.role,
loginCount: (user.loginCount || 0) + 1,
lastLogin: new Date().toISOString()
};
// 更新登录信息
user.loginCount = (user.loginCount || 0) + 1;
user.lastLogin = new Date().toISOString();
await fs.writeFile(userFilePath, JSON.stringify(users, null, 2), 'utf8');
res.json({
success: true,
user: {
username: user.username,
role: user.role
}
});
} catch (err) {
logger.error('登录处理错误:', err);
res.status(500).json({ error: '登录处理失败' });
}
});
logger.success('✓ 登录路由已加载');
// 导出路由
module.exports = router;

View File

@@ -0,0 +1,193 @@
/**
* 监控配置路由
*/
const express = require('express');
const router = express.Router();
const fs = require('fs').promises;
const path = require('path');
const { requireLogin } = require('../middleware/auth');
const logger = require('../logger');
// 监控配置文件路径
const CONFIG_FILE = path.join(__dirname, '../config/monitoring.json');
// 确保配置文件存在
async function ensureConfigFile() {
try {
await fs.access(CONFIG_FILE);
} catch (err) {
// 文件不存在,创建默认配置
const defaultConfig = {
isEnabled: false,
notificationType: 'wechat',
webhookUrl: '',
telegramToken: '',
telegramChatId: '',
monitorInterval: 60
};
await fs.mkdir(path.dirname(CONFIG_FILE), { recursive: true });
await fs.writeFile(CONFIG_FILE, JSON.stringify(defaultConfig, null, 2), 'utf8');
return defaultConfig;
}
// 文件存在,读取配置
const data = await fs.readFile(CONFIG_FILE, 'utf8');
return JSON.parse(data);
}
// 获取监控配置
router.get('/monitoring-config', requireLogin, async (req, res) => {
try {
const config = await ensureConfigFile();
res.json(config);
} catch (err) {
logger.error('获取监控配置失败:', err);
res.status(500).json({ error: '获取监控配置失败' });
}
});
// 保存监控配置
router.post('/monitoring-config', requireLogin, async (req, res) => {
try {
const {
notificationType,
webhookUrl,
telegramToken,
telegramChatId,
monitorInterval,
isEnabled
} = req.body;
// 简单验证
if (notificationType === 'wechat' && !webhookUrl) {
return res.status(400).json({ error: '企业微信通知需要设置 webhook URL' });
}
if (notificationType === 'telegram' && (!telegramToken || !telegramChatId)) {
return res.status(400).json({ error: 'Telegram 通知需要设置 Token 和 Chat ID' });
}
const config = await ensureConfigFile();
// 更新配置
const updatedConfig = {
...config,
notificationType,
webhookUrl: webhookUrl || '',
telegramToken: telegramToken || '',
telegramChatId: telegramChatId || '',
monitorInterval: parseInt(monitorInterval, 10) || 60,
isEnabled: isEnabled !== undefined ? isEnabled : config.isEnabled
};
await fs.writeFile(CONFIG_FILE, JSON.stringify(updatedConfig, null, 2), 'utf8');
res.json({ success: true, message: '监控配置已保存' });
// 通知监控服务重新加载配置
if (global.monitoringService && typeof global.monitoringService.reload === 'function') {
global.monitoringService.reload();
}
} catch (err) {
logger.error('保存监控配置失败:', err);
res.status(500).json({ error: '保存监控配置失败' });
}
});
// 切换监控状态
router.post('/toggle-monitoring', requireLogin, async (req, res) => {
try {
const { isEnabled } = req.body;
const config = await ensureConfigFile();
config.isEnabled = !!isEnabled;
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
res.json({
success: true,
message: `监控已${isEnabled ? '启用' : '禁用'}`
});
// 通知监控服务重新加载配置
if (global.monitoringService && typeof global.monitoringService.reload === 'function') {
global.monitoringService.reload();
}
} catch (err) {
logger.error('切换监控状态失败:', err);
res.status(500).json({ error: '切换监控状态失败' });
}
});
// 测试通知
router.post('/test-notification', requireLogin, async (req, res) => {
try {
const {
notificationType,
webhookUrl,
telegramToken,
telegramChatId
} = req.body;
// 简单验证
if (notificationType === 'wechat' && !webhookUrl) {
return res.status(400).json({ error: '企业微信通知需要设置 webhook URL' });
}
if (notificationType === 'telegram' && (!telegramToken || !telegramChatId)) {
return res.status(400).json({ error: 'Telegram 通知需要设置 Token 和 Chat ID' });
}
// 发送测试通知
const notifier = require('../services/notificationService');
const testMessage = {
title: '测试通知',
content: '这是一条测试通知,如果您收到这条消息,说明您的通知配置工作正常。',
time: new Date().toLocaleString()
};
await notifier.sendNotification(testMessage, {
type: notificationType,
webhookUrl,
telegramToken,
telegramChatId
});
res.json({ success: true, message: '测试通知已发送' });
} catch (err) {
logger.error('发送测试通知失败:', err);
res.status(500).json({ error: '发送测试通知失败: ' + err.message });
}
});
// 获取已停止的容器
router.get('/stopped-containers', async (req, res) => {
try {
const { exec } = require('child_process');
const util = require('util');
const execPromise = util.promisify(exec);
const { stdout } = await execPromise('docker ps -f "status=exited" --format "{{.ID}}\\t{{.Names}}\\t{{.Status}}"');
const containers = stdout.trim().split('\n')
.filter(line => line.trim())
.map(line => {
const [id, name, ...statusParts] = line.split('\t');
return {
id: id.substring(0, 12),
name,
status: statusParts.join(' ')
};
});
res.json(containers);
} catch (err) {
logger.error('获取已停止容器失败:', err);
res.status(500).json({ error: '获取已停止容器失败', details: err.message });
}
});
logger.success('✓ 监控配置路由已加载');
// 导出路由
module.exports = router;

View File

@@ -0,0 +1,55 @@
const fs = require('fs');
const path = require('path');
const express = require('express');
const { executeOnce } = require('../lib/initScheduler');
// 引入logger
const logger = require('../logger');
// 改进路由加载器,确保每个路由只被加载一次
async function loadRoutes(app, customLogger) {
// 使用传入的logger或默认logger
const log = customLogger || logger;
const routesDir = path.join(__dirname);
const routeFiles = fs.readdirSync(routesDir).filter(file =>
file.endsWith('.js') && !file.includes('routeLoader') && !file.includes('index')
);
log.info(`发现 ${routeFiles.length} 个路由文件待加载`);
for (const file of routeFiles) {
const routeName = path.basename(file, '.js');
try {
await executeOnce(`loadRoute_${routeName}`, async () => {
const routePath = path.join(routesDir, file);
// 添加错误处理来避免路由加载失败时导致应用崩溃
try {
const route = require(routePath);
if (typeof route === 'function') {
route(app);
log.success(`✓ 注册路由: ${routeName}`);
} else if (route && typeof route.router === 'function') {
route.router(app);
log.success(`✓ 注册路由对象: ${routeName}`);
} else {
log.error(`× 路由格式错误: ${file} (应该导出一个函数或router方法)`);
}
} catch (routeError) {
log.error(`× 加载路由 ${file} 失败: ${routeError.message}`);
// 继续加载其他路由,不中断流程
}
}, log);
} catch (error) {
log.error(`× 路由加载流程出错: ${error.message}`);
// 继续处理下一个路由
}
}
log.info('所有路由注册完成');
}
module.exports = loadRoutes;

590
hubcmdui/routes/system.js Normal file
View File

@@ -0,0 +1,590 @@
/**
* 系统相关路由
*/
const express = require('express');
const router = express.Router();
const os = require('os'); // 确保导入 os 模块
const util = require('util'); // 导入 util 模块
const { exec } = require('child_process');
const execPromise = util.promisify(exec); // 只在这里定义一次
const logger = require('../logger');
const { requireLogin } = require('../middleware/auth');
const configService = require('../services/configService');
const { execCommand, getSystemInfo } = require('../server-utils');
const dockerService = require('../services/dockerService');
const path = require('path');
const fs = require('fs').promises;
// 获取系统状态
async function getSystemStats(req, res) {
try {
let dockerAvailable = false;
let containerCount = '0';
let memoryUsage = '0%';
let cpuLoad = '0%';
let diskSpace = '0%';
let recentActivities = [];
// 尝试获取系统信息
try {
const systemInfo = await getSystemInfo();
memoryUsage = `${systemInfo.memory.percent}%`;
cpuLoad = systemInfo.cpu.load1;
diskSpace = systemInfo.disk.percent;
} catch (sysError) {
logger.error('获取系统信息失败:', sysError);
}
// 尝试从Docker获取状态信息
try {
const docker = await dockerService.getDockerConnection();
if (docker) {
dockerAvailable = true;
// 获取容器统计
const containers = await docker.listContainers({ all: true });
containerCount = containers.length.toString();
// 获取最近的容器活动
const runningContainers = containers.filter(c => c.State === 'running');
for (let i = 0; i < Math.min(3, runningContainers.length); i++) {
recentActivities.push({
time: new Date(runningContainers[i].Created * 1000).toLocaleString(),
action: '运行中',
container: runningContainers[i].Names[0].replace(/^\//, ''),
status: '正常'
});
}
// 获取最近的Docker事件
const events = await dockerService.getRecentEvents();
if (events && events.length > 0) {
recentActivities = [...events.map(event => ({
time: new Date(event.time * 1000).toLocaleString(),
action: event.Action,
container: event.Actor?.Attributes?.name || '未知容器',
status: event.status || '完成'
})), ...recentActivities].slice(0, 10);
}
}
} catch (containerError) {
logger.error('获取容器信息失败:', containerError);
}
// 如果没有活动记录,添加一个默认记录
if (recentActivities.length === 0) {
recentActivities.push({
time: new Date().toLocaleString(),
action: '系统检查',
container: '监控服务',
status: dockerAvailable ? '正常' : 'Docker服务不可用'
});
}
// 返回收集到的所有数据,即使部分数据可能不完整
res.json({
dockerAvailable,
containerCount,
memoryUsage,
cpuLoad,
diskSpace,
recentActivities
});
} catch (error) {
logger.error('获取系统统计数据失败:', error);
// 即使出错,仍然尝试返回一些基本数据
res.status(200).json({
dockerAvailable: false,
containerCount: '0',
memoryUsage: '未知',
cpuLoad: '未知',
diskSpace: '未知',
recentActivities: [{
time: new Date().toLocaleString(),
action: '系统错误',
container: '监控服务',
status: '数据获取失败'
}],
error: '获取系统统计数据失败',
errorDetails: error.message
});
}
}
// 获取系统配置 - 修改版本,避免与其他路由冲突
router.get('/system-config', async (req, res) => {
try {
const config = await configService.getConfig();
res.json(config);
} catch (error) {
logger.error('读取配置失败:', error);
res.status(500).json({
error: '读取配置失败',
details: error.message
});
}
});
// 保存系统配置 - 修改版本,避免与其他路由冲突
router.post('/system-config', requireLogin, async (req, res) => {
try {
const currentConfig = await configService.getConfig();
const newConfig = { ...currentConfig, ...req.body };
await configService.saveConfig(newConfig);
logger.info('系统配置已更新');
res.json({ success: true });
} catch (error) {
logger.error('保存配置失败:', error);
res.status(500).json({
error: '保存配置失败',
details: error.message
});
}
});
// 获取系统状态
router.get('/stats', requireLogin, async (req, res) => {
return await getSystemStats(req, res);
});
// 获取磁盘空间信息
router.get('/disk-space', requireLogin, async (req, res) => {
try {
const systemInfo = await getSystemInfo();
res.json({
diskSpace: `${systemInfo.disk.used}/${systemInfo.disk.size}`,
usagePercent: parseInt(systemInfo.disk.percent)
});
} catch (error) {
logger.error('获取磁盘空间信息失败:', error);
res.status(500).json({ error: '获取磁盘空间信息失败', details: error.message });
}
});
// 网络测试
router.post('/network-test', requireLogin, async (req, res) => {
const { type, domain } = req.body;
// 验证输入
function validateInput(input, type) {
if (type === 'domain') {
return /^[a-zA-Z0-9][a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(input);
}
return false;
}
if (!validateInput(domain, 'domain')) {
return res.status(400).json({ error: '无效的域名格式' });
}
try {
const result = await execCommand(`${type === 'ping' ? 'ping -c 4' : 'traceroute -m 10'} ${domain}`, { timeout: 30000 });
res.send(result);
} catch (error) {
if (error.killed) {
return res.status(408).send('测试超时');
}
logger.error(`执行网络测试命令错误:`, error);
res.status(500).send('测试执行失败: ' + error.message);
}
});
// 获取用户统计信息
router.get('/user-stats', requireLogin, async (req, res) => {
try {
const userService = require('../services/userService');
const username = req.session.user.username;
const userStats = await userService.getUserStats(username);
res.json(userStats);
} catch (error) {
logger.error('获取用户统计信息失败:', error);
res.status(500).json({
loginCount: '0',
lastLogin: '未知',
accountAge: '0'
});
}
});
// 获取系统状态信息 (旧版,可能与 getSystemStats 重复,可以考虑移除)
router.get('/system-status', requireLogin, async (req, res) => {
logger.warn('Accessing potentially deprecated /api/system-status route.');
try {
// 检查 Docker 可用性
let dockerAvailable = true;
let containerCount = 0;
try {
// 避免直接执行命令计算,依赖 dockerService
const docker = await dockerService.getDockerConnection();
if (docker) {
const containers = await docker.listContainers({ all: true });
containerCount = containers.length;
} else {
dockerAvailable = false;
}
} catch (dockerError) {
dockerAvailable = false;
containerCount = 0;
logger.warn('Docker可能未运行或无法访问 (in /system-status):', dockerError.message);
}
// 获取内存使用信息
const totalMem = os.totalmem();
const freeMem = os.freemem();
const usedMem = totalMem - freeMem;
const memoryUsage = `${Math.round(usedMem / totalMem * 100)}%`;
// 获取CPU负载
const [load1] = os.loadavg();
const cpuCount = os.cpus().length || 1; // 避免除以0
const cpuLoad = `${(load1 / cpuCount * 100).toFixed(1)}%`;
// 获取磁盘空间 - 简单版
let diskSpace = '未知';
try {
if (os.platform() === 'darwin' || os.platform() === 'linux') {
const { stdout } = await execPromise('df -h / | tail -n 1'); // 使用 -n 1
const parts = stdout.trim().split(/\s+/);
if (parts.length >= 5) diskSpace = parts[4];
} else if (os.platform() === 'win32') {
const { stdout } = await execPromise('wmic logicaldisk get size,freespace,caption | findstr /B /L /V "Caption" ');
const lines = stdout.trim().split(/\r?\n/);
if (lines.length > 0) {
const parts = lines[0].trim().split(/\s+/);
if (parts.length >= 2) {
const free = parseInt(parts[0], 10);
const total = parseInt(parts[1], 10);
if (!isNaN(total) && !isNaN(free) && total > 0) {
diskSpace = `${Math.round(((total - free) / total) * 100)}%`;
}
}
}
}
} catch (diskError) {
logger.warn('获取磁盘空间失败 (in /system-status):', diskError.message);
diskSpace = '未知';
}
// 格式化系统运行时间
const uptime = formatUptime(os.uptime());
res.json({
dockerAvailable,
containerCount,
memoryUsage,
cpuLoad,
diskSpace,
systemUptime: uptime
});
} catch (error) {
logger.error('获取系统状态失败 (in /system-status):', error);
res.status(500).json({
error: '获取系统状态失败',
message: error.message
});
}
});
// 添加新的API端点提供完整系统资源信息
router.get('/system-resources', requireLogin, async (req, res) => {
logger.info('Received request for /api/system-resources');
let cpuInfoData = null, memoryData = null, diskInfoData = null, systemData = null;
// --- 获取 CPU 信息 (独立 try...catch) ---
try {
const cpuInfo = os.cpus();
const [load1, load5, load15] = os.loadavg();
const cpuCount = cpuInfo.length || 1;
const cpuUsage = (load1 / cpuCount * 100).toFixed(1);
cpuInfoData = {
cores: cpuCount,
model: cpuInfo[0]?.model || '未知',
speed: `${cpuInfo[0]?.speed || '未知'} MHz`,
loadAvg: {
'1min': load1.toFixed(2),
'5min': load5.toFixed(2),
'15min': load15.toFixed(2)
},
usage: parseFloat(cpuUsage)
};
logger.info('Successfully retrieved CPU info.');
} catch (cpuError) {
logger.error('Error getting CPU info:', cpuError.message);
cpuInfoData = { error: '获取 CPU 信息失败', message: cpuError.message }; // 返回错误信息
}
// --- 获取内存信息 (独立 try...catch) ---
try {
const totalMem = os.totalmem();
const freeMem = os.freemem();
const usedMem = totalMem - freeMem;
const memoryUsagePercent = totalMem > 0 ? Math.round(usedMem / totalMem * 100) : 0;
memoryData = {
total: formatBytes(totalMem), // 可能出错
free: formatBytes(freeMem), // 可能出错
used: formatBytes(usedMem), // 可能出错
usedPercentage: memoryUsagePercent
};
logger.info('Successfully retrieved Memory info.');
} catch (memError) {
logger.error('Error getting Memory info:', memError.message);
memoryData = { error: '获取内存信息失败', message: memError.message }; // 返回错误信息
}
// --- 获取磁盘信息 (独立 try...catch) ---
try {
let diskResult = { total: '未知', free: '未知', used: '未知', usedPercentage: '未知' };
logger.info(`Getting disk info for platform: ${os.platform()}`);
if (os.platform() === 'darwin' || os.platform() === 'linux') {
try {
// 使用 -k 获取 KB 单位,方便计算
const { stdout } = await execPromise('df -k / | tail -n 1', { timeout: 5000 });
logger.info(`'df -k' command output: ${stdout}`);
const parts = stdout.trim().split(/\s+/);
// 索引通常是 1=Total, 2=Used, 3=Available, 4=Use%
if (parts.length >= 4) {
const total = parseInt(parts[1], 10) * 1024; // KB to Bytes
const used = parseInt(parts[2], 10) * 1024; // KB to Bytes
const free = parseInt(parts[3], 10) * 1024; // KB to Bytes
// 优先使用命令输出的百分比,更准确
let usedPercentage = parseInt(parts[4].replace('%', ''), 10);
// 如果解析失败或百分比无效,则尝试计算
if (isNaN(usedPercentage) && !isNaN(total) && !isNaN(used) && total > 0) {
usedPercentage = Math.round((used / total) * 100);
}
if (!isNaN(total) && !isNaN(used) && !isNaN(free) && !isNaN(usedPercentage)) {
diskResult = {
total: formatBytes(total), // 可能出错
free: formatBytes(free), // 可能出错
used: formatBytes(used), // 可能出错
usedPercentage: usedPercentage
};
logger.info('Successfully parsed disk info (Linux/Darwin).');
} else {
logger.warn('Failed to parse numbers from df output:', parts);
diskResult = { ...diskResult, error: '解析 df 输出失败' }; // 添加错误标记
}
} else {
logger.warn('Unexpected output format from df:', stdout);
diskResult = { ...diskResult, error: 'df 输出格式不符合预期' }; // 添加错误标记
}
} catch (dfError) {
logger.error(`Error executing or parsing 'df -k': ${dfError.message}`);
if (dfError.killed) logger.error("'df -k' command timed out.");
diskResult = { error: '获取磁盘信息失败 (df)', message: dfError.message }; // 标记错误
}
} else if (os.platform() === 'win32') {
try {
// 获取 C 盘信息 (可以修改为获取所有盘符或特定盘符)
const { stdout } = await execPromise(`wmic logicaldisk where "DeviceID='C:'" get size,freespace /value`, { timeout: 5000 });
logger.info(`'wmic' command output: ${stdout}`);
const lines = stdout.trim().split(/\r?\n/);
let free = NaN, total = NaN;
lines.forEach(line => {
if (line.startsWith('FreeSpace=')) {
free = parseInt(line.split('=')[1], 10);
} else if (line.startsWith('Size=')) {
total = parseInt(line.split('=')[1], 10);
}
});
if (!isNaN(total) && !isNaN(free) && total > 0) {
const used = total - free;
const usedPercentage = Math.round((used / total) * 100);
diskResult = {
total: formatBytes(total), // 可能出错
free: formatBytes(free), // 可能出错
used: formatBytes(used), // 可能出错
usedPercentage: usedPercentage
};
logger.info('Successfully parsed disk info (Windows - C:).');
} else {
logger.warn('Failed to parse numbers from wmic output:', stdout);
diskResult = { ...diskResult, error: '解析 wmic 输出失败' }; // 添加错误标记
}
} catch (wmicError) {
logger.error(`Error executing or parsing 'wmic': ${wmicError.message}`);
if (wmicError.killed) logger.error("'wmic' command timed out.");
diskResult = { error: '获取磁盘信息失败 (wmic)', message: wmicError.message }; // 标记错误
}
}
diskInfoData = diskResult;
} catch (diskErrorOuter) {
logger.error('Unexpected error during disk info gathering:', diskErrorOuter.message);
diskInfoData = { error: '获取磁盘信息时发生意外错误', message: diskErrorOuter.message }; // 返回错误信息
}
// --- 获取其他系统信息 (独立 try...catch) ---
try {
systemData = {
platform: os.platform(),
release: os.release(),
hostname: os.hostname(),
uptime: formatUptime(os.uptime()) // 可能出错
};
logger.info('Successfully retrieved general system info.');
} catch (sysInfoError) {
logger.error('Error getting general system info:', sysInfoError.message);
systemData = { error: '获取常规系统信息失败', message: sysInfoError.message }; // 返回错误信息
}
// --- 包装 Helper 函数调用以捕获潜在错误 ---
const safeFormatBytes = (bytes) => {
try {
return formatBytes(bytes);
} catch (e) {
logger.error(`formatBytes failed for value ${bytes}:`, e.message);
return '格式化错误';
}
};
const safeFormatUptime = (seconds) => {
try {
return formatUptime(seconds);
} catch (e) {
logger.error(`formatUptime failed for value ${seconds}:`, e.message);
return '格式化错误';
}
};
// --- 构建最终响应数据,使用安全的 Helper 函数 ---
const finalCpuData = cpuInfoData?.error ? cpuInfoData : {
...cpuInfoData
// CPU 不需要格式化
};
const finalMemoryData = memoryData?.error ? memoryData : {
...memoryData,
total: safeFormatBytes(os.totalmem()),
free: safeFormatBytes(os.freemem()),
used: safeFormatBytes(os.totalmem() - os.freemem())
};
const finalDiskData = diskInfoData?.error ? diskInfoData : {
...diskInfoData,
// 如果 diskInfoData 内部有 total/free/used (字节数),则格式化
// 否则保持 '未知' 或已格式化的字符串
total: (diskInfoData?.total && typeof diskInfoData.total === 'number') ? safeFormatBytes(diskInfoData.total) : diskInfoData?.total || '未知',
free: (diskInfoData?.free && typeof diskInfoData.free === 'number') ? safeFormatBytes(diskInfoData.free) : diskInfoData?.free || '未知',
used: (diskInfoData?.used && typeof diskInfoData.used === 'number') ? safeFormatBytes(diskInfoData.used) : diskInfoData?.used || '未知'
};
const finalSystemData = systemData?.error ? systemData : {
...systemData,
uptime: safeFormatUptime(os.uptime())
};
const responseData = {
cpu: finalCpuData,
memory: finalMemoryData,
diskSpace: finalDiskData,
system: finalSystemData
};
logger.info('Sending response for /api/system-resources:', JSON.stringify(responseData));
res.status(200).json(responseData);
});
// 格式化系统运行时间
function formatUptime(seconds) {
const days = Math.floor(seconds / 86400);
seconds %= 86400;
const hours = Math.floor(seconds / 3600);
seconds %= 3600;
const minutes = Math.floor(seconds / 60);
seconds = Math.floor(seconds % 60);
let result = '';
if (days > 0) result += `${days}`;
if (hours > 0 || days > 0) result += `${hours}小时 `;
if (minutes > 0 || hours > 0 || days > 0) result += `${minutes}分钟 `;
result += `${seconds}`;
return result;
}
// 获取系统资源详情
router.get('/system-resource-details', requireLogin, async (req, res) => {
try {
const { type } = req.query;
let data = {};
switch (type) {
case 'memory':
const totalMem = os.totalmem();
const freeMem = os.freemem();
const usedMem = totalMem - freeMem;
data = {
totalMemory: formatBytes(totalMem),
usedMemory: formatBytes(usedMem),
freeMemory: formatBytes(freeMem),
memoryUsage: `${Math.round(usedMem / totalMem * 100)}%`
};
break;
case 'cpu':
const cpuInfo = os.cpus();
const [load1, load5, load15] = os.loadavg();
data = {
cpuCores: cpuInfo.length,
cpuModel: cpuInfo[0].model,
cpuSpeed: `${cpuInfo[0].speed} MHz`,
loadAvg1: load1.toFixed(2),
loadAvg5: load5.toFixed(2),
loadAvg15: load15.toFixed(2),
cpuLoad: `${(load1 / cpuInfo.length * 100).toFixed(1)}%`
};
break;
case 'disk':
try {
const { stdout: dfOutput } = await execPromise('df -h / | tail -n 1');
const parts = dfOutput.trim().split(/\s+/);
if (parts.length >= 5) {
data = {
totalSpace: parts[1],
usedSpace: parts[2],
freeSpace: parts[3],
diskUsage: parts[4]
};
} else {
throw new Error('解析磁盘信息失败');
}
} catch (diskError) {
logger.warn('获取磁盘信息失败:', diskError.message);
data = {
error: '获取磁盘信息失败',
message: diskError.message
};
}
break;
default:
return res.status(400).json({ error: '无效的资源类型' });
}
res.json(data);
} catch (error) {
logger.error('获取系统资源详情失败:', error);
res.status(500).json({ error: '获取系统资源详情失败', message: error.message });
}
});
// 格式化字节数为可读格式
function formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
module.exports = router; // 只导出 router

View File

@@ -0,0 +1,104 @@
const express = require('express');
const router = express.Router();
const os = require('os');
const logger = require('../logger');
// 获取系统状态
router.get('/', (req, res) => {
try {
// 收集系统信息
const cpuLoad = os.loadavg()[0] / os.cpus().length * 100;
const totalMem = os.totalmem();
const freeMem = os.freemem();
const usedMem = totalMem - freeMem;
const memoryUsage = `${Math.round(usedMem / totalMem * 100)}%`;
// 组合结果
const systemStatus = {
dockerAvailable: true,
containerCount: 0,
cpuLoad: `${cpuLoad.toFixed(1)}%`,
memoryUsage: memoryUsage,
diskSpace: '未知',
recentActivities: []
};
res.json(systemStatus);
} catch (error) {
logger.error('获取系统状态失败:', error);
res.status(500).json({
error: '获取系统状态失败',
details: error.message
});
}
});
// 获取系统资源详情
router.get('/system-resource-details', (req, res) => {
try {
const { type } = req.query;
let data = {};
switch(type) {
case 'memory':
const totalMem = os.totalmem();
const freeMem = os.freemem();
const usedMem = totalMem - freeMem;
data = {
totalMemory: formatBytes(totalMem),
usedMemory: formatBytes(usedMem),
freeMemory: formatBytes(freeMem),
memoryUsage: `${Math.round(usedMem / totalMem * 100)}%`
};
break;
case 'cpu':
const cpus = os.cpus();
const loadAvg = os.loadavg();
data = {
cpuCores: cpus.length,
cpuModel: cpus[0].model,
cpuLoad: `${(loadAvg[0] / cpus.length * 100).toFixed(1)}%`,
loadAvg1: loadAvg[0].toFixed(2),
loadAvg5: loadAvg[1].toFixed(2),
loadAvg15: loadAvg[2].toFixed(2)
};
break;
case 'disk':
// 简单的硬编码数据,在实际环境中应该调用系统命令获取
data = {
totalSpace: '100 GB',
usedSpace: '30 GB',
freeSpace: '70 GB',
diskUsage: '30%'
};
break;
default:
return res.status(400).json({ error: '无效的资源类型' });
}
res.json(data);
} catch (error) {
logger.error('获取系统资源详情失败:', error);
res.status(500).json({ error: '获取系统资源详情失败', details: error.message });
}
});
// 格式化字节数为可读格式
function formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
module.exports = router;

View File

@@ -0,0 +1,163 @@
/**
* 系统诊断工具 - 帮助找出可能存在的问题
*/
const fs = require('fs').promises;
const path = require('path');
const { execSync } = require('child_process');
const logger = require('../logger');
// 检查所有必要的文件和目录是否存在
async function checkFilesAndDirectories() {
logger.info('开始检查必要的文件和目录...');
// 检查必要的目录
const requiredDirs = [
{ path: 'logs', critical: true },
{ path: 'documentation', critical: true },
{ path: 'web/images', critical: true },
{ path: 'routes', critical: true },
{ path: 'services', critical: true },
{ path: 'middleware', critical: true },
{ path: 'scripts', critical: false }
];
const dirsStatus = {};
for (const dir of requiredDirs) {
const fullPath = path.join(__dirname, '..', dir.path);
try {
await fs.access(fullPath);
dirsStatus[dir.path] = { exists: true, critical: dir.critical };
logger.info(`目录存在: ${dir.path}`);
} catch (error) {
dirsStatus[dir.path] = { exists: false, critical: dir.critical };
logger.error(`目录不存在: ${dir.path} (${dir.critical ? '关键' : '非关键'})`);
}
}
// 检查必要的文件
const requiredFiles = [
{ path: 'server.js', critical: true },
{ path: 'app.js', critical: false },
{ path: 'config.js', critical: true },
{ path: 'logger.js', critical: true },
{ path: 'init-dirs.js', critical: true },
{ path: 'download-images.js', critical: true },
{ path: 'cleanup.js', critical: true },
{ path: 'package.json', critical: true },
{ path: 'web/index.html', critical: true },
{ path: 'web/admin.html', critical: true }
];
const filesStatus = {};
for (const file of requiredFiles) {
const fullPath = path.join(__dirname, '..', file.path);
try {
await fs.access(fullPath);
filesStatus[file.path] = { exists: true, critical: file.critical };
logger.info(`文件存在: ${file.path}`);
} catch (error) {
filesStatus[file.path] = { exists: false, critical: file.critical };
logger.error(`文件不存在: ${file.path} (${file.critical ? '关键' : '非关键'})`);
}
}
return { directories: dirsStatus, files: filesStatus };
}
// 检查Node.js模块依赖
function checkNodeDependencies() {
logger.info('开始检查Node.js依赖...');
try {
// 执行npm list --depth=0来检查已安装的依赖
const npmListOutput = execSync('npm list --depth=0', { encoding: 'utf8' });
logger.info('已安装的依赖:\n' + npmListOutput);
return { success: true, output: npmListOutput };
} catch (error) {
logger.error('检查依赖时出错:', error.message);
return { success: false, error: error.message };
}
}
// 检查系统环境
async function checkSystemEnvironment() {
logger.info('开始检查系统环境...');
const checks = {
node: process.version,
platform: process.platform,
arch: process.arch,
docker: null
};
try {
// 检查Docker是否可用
const dockerVersion = execSync('docker --version', { encoding: 'utf8' });
checks.docker = dockerVersion.trim();
logger.info(`Docker版本: ${dockerVersion.trim()}`);
} catch (error) {
checks.docker = false;
logger.warn('Docker未安装或不可用');
}
return checks;
}
// 运行诊断
async function runDiagnostics() {
logger.info('======= 开始系统诊断 =======');
const results = {
filesAndDirs: await checkFilesAndDirectories(),
dependencies: checkNodeDependencies(),
environment: await checkSystemEnvironment()
};
// 检查关键错误
const criticalErrors = [];
// 检查关键目录
Object.entries(results.filesAndDirs.directories).forEach(([dir, status]) => {
if (status.critical && !status.exists) {
criticalErrors.push(`关键目录丢失: ${dir}`);
}
});
// 检查关键文件
Object.entries(results.filesAndDirs.files).forEach(([file, status]) => {
if (status.critical && !status.exists) {
criticalErrors.push(`关键文件丢失: ${file}`);
}
});
// 检查依赖
if (!results.dependencies.success) {
criticalErrors.push('依赖检查失败');
}
// 总结
logger.info('======= 诊断完成 =======');
if (criticalErrors.length > 0) {
logger.error('发现关键错误:');
criticalErrors.forEach(err => logger.error(`- ${err}`));
logger.error('请解决以上问题后重试');
} else {
logger.success('未发现关键错误,系统应该可以正常运行');
}
return { results, criticalErrors };
}
// 直接运行脚本时启动诊断
if (require.main === module) {
runDiagnostics()
.then(() => {
logger.info('诊断完成');
})
.catch(error => {
logger.fatal('诊断过程中发生错误:', error);
});
}
module.exports = { runDiagnostics };

View File

@@ -0,0 +1,25 @@
const MenuItem = require('../models/MenuItem');
async function initMenuItems() {
const count = await MenuItem.countDocuments();
if (count === 0) {
await MenuItem.insertMany([
{
text: '首页',
link: '/',
icon: 'fa-home',
order: 1
},
{
text: '文档',
link: '/docs',
icon: 'fa-book',
order: 2
}
// 添加更多默认菜单项...
]);
console.log('默认菜单项已初始化');
}
}
module.exports = initMenuItems;

View File

@@ -0,0 +1,315 @@
/**
* 系统初始化脚本 - 首次运行时执行
*/
const fs = require('fs').promises;
const path = require('path');
const bcrypt = require('bcrypt');
const { execSync } = require('child_process');
const logger = require('../logger');
const { ensureDirectoriesExist } = require('../init-dirs');
const { downloadImages } = require('../download-images');
const configService = require('../services/configService');
// 用户文件路径
const USERS_FILE = path.join(__dirname, '..', 'users.json');
/**
* 创建管理员用户
* @param {string} username 用户名
* @param {string} password 密码
*/
async function createAdminUser(username = 'root', password = 'admin') {
try {
// 检查用户文件是否已存在
try {
await fs.access(USERS_FILE);
logger.info('用户文件已存在,跳过创建管理员用户');
return;
} catch (err) {
if (err.code !== 'ENOENT') throw err;
}
// 创建默认管理员用户
const defaultUser = {
username,
password: bcrypt.hashSync(password, 10),
createdAt: new Date().toISOString(),
loginCount: 0,
lastLogin: null
};
await fs.writeFile(USERS_FILE, JSON.stringify({ users: [defaultUser] }, null, 2));
logger.success(`创建默认管理员用户: ${username}/${password}`);
logger.warn('请在首次登录后立即修改默认密码');
} catch (error) {
logger.error('创建管理员用户失败:', error);
throw error;
}
}
/**
* 创建默认配置
*/
async function createDefaultConfig() {
try {
// 检查配置是否已存在
const config = await configService.getConfig();
// 如果菜单项为空,添加默认菜单项
if (!config.menuItems || config.menuItems.length === 0) {
config.menuItems = [
{
text: "控制台",
link: "/admin",
newTab: false
},
{
text: "镜像搜索",
link: "/",
newTab: false
},
{
text: "文档",
link: "/docs",
newTab: false
},
{
text: "GitHub",
link: "https://github.com/dqzboy/hubcmdui",
newTab: true
}
];
await configService.saveConfig(config);
logger.success('创建默认菜单配置');
}
return config;
} catch (error) {
logger.error('初始化配置失败:', error);
throw error;
}
}
/**
* 创建示例文档 - 现已禁用
*/
async function createSampleDocumentation() {
logger.info('示例文档创建功能已禁用');
return; // 不再创建默认文档
/* 旧代码保留注释,已禁用
const docService = require('../services/documentationService');
try {
await docService.ensureDocumentationDir();
// 检查是否有现有文档
const docs = await docService.getDocumentationList();
if (docs && docs.length > 0) {
logger.info('文档已存在,跳过创建示例文档');
return;
}
// 创建示例文档
const welcomeDoc = {
title: "欢迎使用 Docker 镜像代理加速系统",
content: `# 欢迎使用 Docker 镜像代理加速系统
## 系统简介
Docker 镜像代理加速系统是一个帮助用户快速搜索、拉取 Docker 镜像的工具。本系统提供了以下功能:
- 快速搜索 Docker Hub 上的镜像
- 查看镜像的详细信息和标签
- 管理本地 Docker 容器
- 监控容器状态并发送通知
## 快速开始
1. 在首页搜索框中输入要查找的镜像名称
2. 点击搜索结果查看详细信息
3. 使用提供的命令拉取镜像
## 管理功能
管理员可以通过控制面板管理系统:
- 查看所有容器状态
- 启动/停止/重启容器
- 更新容器镜像
- 配置监控告警
祝您使用愉快!
`,
published: true
};
const aboutDoc = {
title: "关于系统",
content: `# 关于 Docker 镜像代理加速系统
## 系统版本
当前版本: v1.0.0
## 技术栈
- 前端: HTML, CSS, JavaScript
- 后端: Node.js, Express
- 容器: Docker, Dockerode
- 数据存储: 文件系统
## 联系方式
如有问题,请通过以下方式联系我们:
- GitHub Issues
- 电子邮件: example@example.com
## 许可证
本项目采用 MIT 许可证
`,
published: true
};
await docService.saveDocument(Date.now().toString(), welcomeDoc.title, welcomeDoc.content);
await docService.saveDocument((Date.now() + 1000).toString(), aboutDoc.title, aboutDoc.content);
logger.success('创建示例文档成功');
} catch (error) {
logger.error('创建示例文档失败:', error);
}
*/
}
/**
* 检查必要依赖
*/
async function checkDependencies() {
try {
logger.info('正在检查系统依赖...');
// 检查 Node.js 版本
const nodeVersion = process.version;
const minNodeVersion = 'v14.0.0';
if (compareVersions(nodeVersion, minNodeVersion) < 0) {
logger.warn(`当前 Node.js 版本 ${nodeVersion} 低于推荐的最低版本 ${minNodeVersion}`);
} else {
logger.success(`Node.js 版本 ${nodeVersion} 满足要求`);
}
// 检查必要的 npm 包
try {
const packageJson = require('../package.json');
const requiredDeps = Object.keys(packageJson.dependencies);
logger.info(`系统依赖共 ${requiredDeps.length} 个包`);
// 检查是否有 node_modules 目录
try {
await fs.access(path.join(__dirname, '..', 'node_modules'));
} catch (err) {
if (err.code === 'ENOENT') {
logger.warn('未找到 node_modules 目录,请运行 npm install 安装依赖');
return false;
}
}
} catch (err) {
logger.warn('无法读取 package.json:', err.message);
}
// 检查 Docker
try {
execSync('docker --version', { stdio: ['ignore', 'ignore', 'ignore'] });
logger.success('Docker 已安装');
} catch (err) {
logger.warn('未检测到 Docker部分功能可能无法正常使用');
}
return true;
} catch (error) {
logger.error('依赖检查失败:', error);
return false;
}
}
/**
* 比较版本号
*/
function compareVersions(v1, v2) {
const v1parts = v1.replace('v', '').split('.');
const v2parts = v2.replace('v', '').split('.');
for (let i = 0; i < Math.max(v1parts.length, v2parts.length); i++) {
const v1part = parseInt(v1parts[i] || 0);
const v2part = parseInt(v2parts[i] || 0);
if (v1part > v2part) return 1;
if (v1part < v2part) return -1;
}
return 0;
}
/**
* 主初始化函数
*/
async function initialize() {
logger.info('开始系统初始化...');
try {
// 1. 检查系统依赖
await checkDependencies();
// 2. 确保目录结构存在
await ensureDirectoriesExist();
logger.success('目录结构初始化完成');
// 3. 下载必要图片
await downloadImages();
// 4. 创建默认用户
await createAdminUser();
// 5. 创建默认配置
await createDefaultConfig();
// 6. 创建示例文档
await createSampleDocumentation();
logger.success('系统初始化完成!');
// 移除敏感的账户信息日志
logger.warn('首次登录后请立即修改默认密码!');
return { success: true };
} catch (error) {
logger.error('系统初始化失败:', error);
return { success: false, error: error.message };
}
}
// 如果直接运行此脚本
if (require.main === module) {
initialize()
.then((result) => {
if (result.success) {
process.exit(0);
} else {
process.exit(1);
}
})
.catch((error) => {
logger.fatal('初始化过程中发生错误:', error);
process.exit(1);
});
}
module.exports = {
initialize,
createAdminUser,
createDefaultConfig,
createSampleDocumentation,
checkDependencies
};

333
hubcmdui/server-utils.js Normal file
View File

@@ -0,0 +1,333 @@
/**
* 服务器实用工具函数
*/
const { exec } = require('child_process');
const os = require('os');
const fs = require('fs').promises;
const path = require('path');
const logger = require('./logger');
/**
* 安全执行系统命令
* @param {string} command - 要执行的命令
* @param {object} options - 执行选项
* @returns {Promise<string>} 命令输出结果
*/
function execCommand(command, options = { timeout: 30000 }) {
return new Promise((resolve, reject) => {
exec(command, options, (error, stdout, stderr) => {
if (error) {
if (error.killed) {
reject(new Error('执行命令超时'));
} else {
reject(error);
}
return;
}
resolve(stdout.trim() || stderr.trim());
});
});
}
/**
* 获取系统信息
* @returns {Promise<object>} 系统信息对象
*/
async function getSystemInfo() {
const platform = os.platform();
let memoryInfo = {};
let cpuInfo = {};
let diskInfo = {};
try {
// 内存信息 - 使用OS模块适用于所有平台
const totalMem = os.totalmem();
const freeMem = os.freemem();
const usedMem = totalMem - freeMem;
const memPercent = Math.round((usedMem / totalMem) * 100);
memoryInfo = {
total: formatBytes(totalMem),
free: formatBytes(freeMem),
used: formatBytes(usedMem),
percent: memPercent
};
// CPU信息 - 使用不同平台的方法
await getCpuInfo(platform).then(info => {
cpuInfo = info;
}).catch(err => {
logger.warn('获取CPU信息失败:', err);
// 降级方案使用OS模块
const cpuLoad = os.loadavg();
const cpuCores = os.cpus().length;
cpuInfo = {
model: os.cpus()[0].model,
cores: cpuCores,
load1: cpuLoad[0].toFixed(2),
load5: cpuLoad[1].toFixed(2),
load15: cpuLoad[2].toFixed(2),
percent: Math.round((cpuLoad[0] / cpuCores) * 100)
};
});
// 磁盘信息 - 根据平台调用不同方法
await getDiskInfo(platform).then(info => {
diskInfo = info;
}).catch(err => {
logger.warn('获取磁盘信息失败:', err);
diskInfo = {
filesystem: 'unknown',
size: 'unknown',
used: 'unknown',
available: 'unknown',
percent: '0%'
};
});
return {
platform,
hostname: os.hostname(),
memory: memoryInfo,
cpu: cpuInfo,
disk: diskInfo,
uptime: formatUptime(os.uptime())
};
} catch (error) {
logger.error('获取系统信息失败:', error);
throw error;
}
}
/**
* 根据平台获取CPU信息
* @param {string} platform - 操作系统平台
* @returns {Promise<object>} CPU信息
*/
async function getCpuInfo(platform) {
if (platform === 'linux') {
try {
// Linux平台使用/proc/stat和/proc/cpuinfo
const [loadData, cpuData] = await Promise.all([
execCommand("cat /proc/loadavg"),
execCommand("cat /proc/cpuinfo | grep 'model name' | head -1")
]);
const cpuLoad = loadData.split(' ').slice(0, 3).map(parseFloat);
const cpuCores = os.cpus().length;
const modelMatch = cpuData.match(/model name\s*:\s*(.*)/);
const model = modelMatch ? modelMatch[1].trim() : os.cpus()[0].model;
const percent = Math.round((cpuLoad[0] / cpuCores) * 100);
return {
model,
cores: cpuCores,
load1: cpuLoad[0].toFixed(2),
load5: cpuLoad[1].toFixed(2),
load15: cpuLoad[2].toFixed(2),
percent: percent > 100 ? 100 : percent
};
} catch (error) {
throw error;
}
} else if (platform === 'darwin') {
// macOS平台
try {
const cpuLoad = os.loadavg();
const cpuCores = os.cpus().length;
const model = os.cpus()[0].model;
const systemProfilerData = await execCommand("system_profiler SPHardwareDataType | grep 'Processor Name'");
const cpuMatch = systemProfilerData.match(/Processor Name:\s*(.*)/);
const cpuModel = cpuMatch ? cpuMatch[1].trim() : model;
return {
model: cpuModel,
cores: cpuCores,
load1: cpuLoad[0].toFixed(2),
load5: cpuLoad[1].toFixed(2),
load15: cpuLoad[2].toFixed(2),
percent: Math.round((cpuLoad[0] / cpuCores) * 100)
};
} catch (error) {
throw error;
}
} else if (platform === 'win32') {
// Windows平台
try {
// 使用wmic获取CPU信息
const cpuData = await execCommand('wmic cpu get Name,NumberOfCores /value');
const cpuLines = cpuData.split('\r\n');
let model = os.cpus()[0].model;
let cores = os.cpus().length;
cpuLines.forEach(line => {
if (line.startsWith('Name=')) {
model = line.substring(5).trim();
} else if (line.startsWith('NumberOfCores=')) {
cores = parseInt(line.substring(14).trim()) || cores;
}
});
// Windows没有直接的负载平均值使用CPU使用率作为替代
const perfData = await execCommand('wmic cpu get LoadPercentage /value');
const loadMatch = perfData.match(/LoadPercentage=(\d+)/);
const loadPercent = loadMatch ? parseInt(loadMatch[1]) : 0;
return {
model,
cores,
load1: '不适用',
load5: '不适用',
load15: '不适用',
percent: loadPercent
};
} catch (error) {
throw error;
}
}
// 默认返回OS模块的信息
const cpuLoad = os.loadavg();
const cpuCores = os.cpus().length;
return {
model: os.cpus()[0].model,
cores: cpuCores,
load1: cpuLoad[0].toFixed(2),
load5: cpuLoad[1].toFixed(2),
load15: cpuLoad[2].toFixed(2),
percent: Math.round((cpuLoad[0] / cpuCores) * 100)
};
}
/**
* 根据平台获取磁盘信息
* @param {string} platform - 操作系统平台
* @returns {Promise<object>} 磁盘信息
*/
async function getDiskInfo(platform) {
if (platform === 'linux' || platform === 'darwin') {
try {
// Linux/macOS使用df命令
const diskCommand = platform === 'linux'
? 'df -h / | tail -1'
: 'df -h / | tail -1';
const diskData = await execCommand(diskCommand);
const parts = diskData.trim().split(/\s+/);
if (parts.length >= 5) {
return {
filesystem: parts[0],
size: parts[1],
used: parts[2],
available: parts[3],
percent: parts[4]
};
} else {
throw new Error('磁盘信息格式不正确');
}
} catch (error) {
throw error;
}
} else if (platform === 'win32') {
// Windows平台
try {
// 使用wmic获取C盘信息
const diskData = await execCommand('wmic logicaldisk where DeviceID="C:" get Size,FreeSpace /value');
const lines = diskData.split(/\r\n|\n/);
let freeSpace, totalSize;
lines.forEach(line => {
if (line.startsWith('FreeSpace=')) {
freeSpace = parseInt(line.split('=')[1]);
} else if (line.startsWith('Size=')) {
totalSize = parseInt(line.split('=')[1]);
}
});
if (freeSpace !== undefined && totalSize !== undefined) {
const usedSpace = totalSize - freeSpace;
const usedPercent = Math.round((usedSpace / totalSize) * 100);
return {
filesystem: 'C:',
size: formatBytes(totalSize),
used: formatBytes(usedSpace),
available: formatBytes(freeSpace),
percent: `${usedPercent}%`
};
} else {
throw new Error('无法解析Windows磁盘信息');
}
} catch (error) {
throw error;
}
}
// 默认尝试df命令
try {
const diskData = await execCommand('df -h / | tail -1');
const parts = diskData.trim().split(/\s+/);
if (parts.length >= 5) {
return {
filesystem: parts[0],
size: parts[1],
used: parts[2],
available: parts[3],
percent: parts[4]
};
} else {
throw new Error('磁盘信息格式不正确');
}
} catch (error) {
throw error;
}
}
/**
* 将字节格式化为可读大小
* @param {number} bytes - 字节数
* @returns {string} 格式化后的字符串
*/
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + sizes[i];
}
/**
* 格式化运行时间
* @param {number} seconds - 秒数
* @returns {string} 格式化后的运行时间
*/
function formatUptime(seconds) {
const days = Math.floor(seconds / 86400);
seconds %= 86400;
const hours = Math.floor(seconds / 3600);
seconds %= 3600;
const minutes = Math.floor(seconds / 60);
seconds = Math.floor(seconds % 60);
const parts = [];
if (days > 0) parts.push(`${days}`);
if (hours > 0) parts.push(`${hours}小时`);
if (minutes > 0) parts.push(`${minutes}分钟`);
if (seconds > 0 && parts.length === 0) parts.push(`${seconds}`);
return parts.join(' ');
}
module.exports = {
execCommand,
getSystemInfo,
formatBytes,
formatUptime
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,47 @@
const fs = require('fs').promises;
const path = require('path');
const logger = require('../logger');
const CONFIG_FILE = path.join(__dirname, '../config.json');
const DEFAULT_CONFIG = {
theme: 'light',
language: 'zh_CN',
notifications: true,
autoRefresh: true,
refreshInterval: 30000,
dockerHost: 'localhost',
dockerPort: 2375,
useHttps: false
};
async function ensureConfigFile() {
try {
await fs.access(CONFIG_FILE);
} catch (error) {
if (error.code === 'ENOENT') {
await fs.writeFile(CONFIG_FILE, JSON.stringify(DEFAULT_CONFIG, null, 2));
} else {
throw error;
}
}
}
async function getConfig() {
try {
await ensureConfigFile();
const data = await fs.readFile(CONFIG_FILE, 'utf8');
return JSON.parse(data);
} catch (error) {
logger.error('读取配置文件失败:', error);
return { ...DEFAULT_CONFIG, error: true };
}
}
module.exports = {
getConfig,
saveConfig: async (config) => {
await ensureConfigFile();
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
},
DEFAULT_CONFIG
};

View File

@@ -0,0 +1,290 @@
/**
* Docker Hub 服务模块
*/
const axios = require('axios');
const logger = require('../logger');
const pLimit = require('p-limit');
const axiosRetry = require('axios-retry');
// 配置并发限制最多5个并发请求
const limit = pLimit(5);
// 优化HTTP请求配置
const httpOptions = {
timeout: 15000, // 15秒超时
headers: {
'User-Agent': 'DockerHubSearchClient/1.0',
'Accept': 'application/json'
}
};
// 配置Axios重试
axiosRetry(axios, {
retries: 3, // 最多重试3次
retryDelay: (retryCount) => {
console.log(`[INFO] 重试 Docker Hub 请求 (${retryCount}/3)`);
return retryCount * 1000; // 重试延迟每次递增1秒
},
retryCondition: (error) => {
// 只在网络错误或5xx响应时重试
return axiosRetry.isNetworkOrIdempotentRequestError(error) ||
(error.response && error.response.status >= 500);
}
});
// 搜索仓库
async function searchRepositories(term, page = 1, requestCache = null) {
const cacheKey = `search_${term}_${page}`;
let cachedResult = null;
// 安全地检查缓存
if (requestCache && typeof requestCache.get === 'function') {
cachedResult = requestCache.get(cacheKey);
}
if (cachedResult) {
console.log(`[INFO] 返回缓存的搜索结果: ${term} (页码: ${page})`);
return cachedResult;
}
console.log(`[INFO] 搜索Docker Hub: ${term} (页码: ${page})`);
try {
// 使用更安全的直接请求方式避免pLimit可能的问题
const url = `https://hub.docker.com/v2/search/repositories/?query=${encodeURIComponent(term)}&page=${page}&page_size=25`;
const response = await axios.get(url, httpOptions);
const result = response.data;
// 将结果缓存(如果缓存对象可用)
if (requestCache && typeof requestCache.set === 'function') {
requestCache.set(cacheKey, result);
}
return result;
} catch (error) {
logger.error('搜索Docker Hub失败:', error.message);
// 重新抛出错误以便上层处理
throw new Error(error.message || '搜索Docker Hub失败');
}
}
// 获取所有标签
async function getAllTags(imageName, isOfficial) {
const fullImageName = isOfficial ? `library/${imageName}` : imageName;
logger.info(`获取所有镜像标签: ${fullImageName}`);
// 为所有标签请求设置超时限制
const allTagsPromise = fetchAllTags(fullImageName);
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('获取所有标签超时')), 30000)
);
try {
// 使用Promise.race确保请求不会无限等待
const allTags = await Promise.race([allTagsPromise, timeoutPromise]);
// 过滤掉无效平台信息
const cleanedTags = allTags.map(tag => {
if (tag.images && Array.isArray(tag.images)) {
tag.images = tag.images.filter(img => !(img.os === 'unknown' && img.architecture === 'unknown'));
}
return tag;
});
return {
count: cleanedTags.length,
results: cleanedTags,
all_pages_loaded: true
};
} catch (error) {
logger.error(`获取所有标签失败: ${error.message}`);
throw error;
}
}
// 获取特定页的标签
async function getTagsByPage(imageName, isOfficial, page, pageSize) {
const fullImageName = isOfficial ? `library/${imageName}` : imageName;
logger.info(`获取镜像标签: ${fullImageName}, 页码: ${page}, 页面大小: ${pageSize}`);
const tagsUrl = `https://hub.docker.com/v2/repositories/${fullImageName}/tags?page=${page}&page_size=${pageSize}`;
try {
const tagsResponse = await axios.get(tagsUrl, {
timeout: 15000,
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36'
}
});
// 检查响应数据有效性
if (!tagsResponse.data || typeof tagsResponse.data !== 'object') {
logger.warn(`镜像 ${fullImageName} 返回的数据格式不正确`);
return { count: 0, results: [] };
}
if (!tagsResponse.data.results || !Array.isArray(tagsResponse.data.results)) {
logger.warn(`镜像 ${fullImageName} 没有返回有效的标签数据`);
return { count: 0, results: [] };
}
// 过滤掉无效平台信息
const cleanedResults = tagsResponse.data.results.map(tag => {
if (tag.images && Array.isArray(tag.images)) {
tag.images = tag.images.filter(img => !(img.os === 'unknown' && img.architecture === 'unknown'));
}
return tag;
});
return {
...tagsResponse.data,
results: cleanedResults
};
} catch (error) {
logger.error(`获取标签列表失败: ${error.message}`, {
url: tagsUrl,
status: error.response?.status,
statusText: error.response?.statusText
});
throw error;
}
}
// 获取标签数量
async function getTagCount(name, isOfficial, requestCache) {
const cacheKey = `tag_count_${name}_${isOfficial}`;
const cachedResult = requestCache?.get(cacheKey);
if (cachedResult) {
console.log(`[INFO] 返回缓存的标签计数: ${name}`);
return cachedResult;
}
const fullImageName = isOfficial ? `library/${name}` : name;
const apiUrl = `https://hub.docker.com/v2/repositories/${fullImageName}/tags/?page_size=1`;
try {
const result = await limit(async () => {
const response = await axios.get(apiUrl, httpOptions);
return {
count: response.data.count,
recommended_mode: response.data.count > 500 ? 'paginated' : 'full'
};
});
if (requestCache) {
requestCache.set(cacheKey, result);
}
return result;
} catch (error) {
throw error;
}
}
// 递归获取所有标签
async function fetchAllTags(fullImageName, page = 1, allTags = [], maxPages = 10) {
if (page > maxPages) {
logger.warn(`达到最大页数限制 (${maxPages}),停止获取更多标签`);
return allTags;
}
const pageSize = 100; // 使用最大页面大小
const url = `https://hub.docker.com/v2/repositories/${fullImageName}/tags?page=${page}&page_size=${pageSize}`;
try {
logger.info(`获取标签页 ${page}/${maxPages}...`);
const response = await axios.get(url, {
timeout: 10000,
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36'
}
});
if (!response.data.results || !Array.isArray(response.data.results)) {
logger.warn(`${page} 没有有效的标签数据`);
return allTags;
}
allTags.push(...response.data.results);
logger.info(`已获取 ${allTags.length}/${response.data.count || 'unknown'} 个标签`);
// 检查是否有下一页
if (response.data.next && allTags.length < response.data.count) {
// 添加一些延迟以避免请求过快
await new Promise(resolve => setTimeout(resolve, 500));
return fetchAllTags(fullImageName, page + 1, allTags, maxPages);
}
logger.success(`成功获取所有 ${allTags.length} 个标签`);
return allTags;
} catch (error) {
logger.error(`递归获取标签失败 (页码 ${page}): ${error.message}`);
// 如果已经获取了一些标签,返回这些标签而不是抛出错误
if (allTags.length > 0) {
return allTags;
}
// 如果没有获取到任何标签,则抛出错误
throw error;
}
}
// 统一的错误处理函数
function handleAxiosError(error, res, message) {
let errorDetails = '';
if (error.response) {
// 服务器响应错误的错误处理函数
const status = error.response.status;
errorDetails = `状态码: ${status}`;
if (error.response.data && error.response.data.message) {
errorDetails += `, 信息: ${error.response.data.message}`;
}
console.error(`[ERROR] ${message}: ${errorDetails}`);
res.status(status).json({
error: `${message} (${errorDetails})`,
details: error.response.data
});
} else if (error.request) {
// 请求已发送但没有收到响应
if (error.code === 'ECONNRESET') {
errorDetails = '连接被重置,这可能是由于网络不稳定或服务端断开连接';
} else if (error.code === 'ECONNABORTED') {
errorDetails = '请求超时,服务器响应时间过长';
} else {
errorDetails = `${error.code || '未知错误代码'}: ${error.message}`;
}
console.error(`[ERROR] ${message}: ${errorDetails}`);
res.status(503).json({
error: `${message} (${errorDetails})`,
retryable: true
});
} else {
// 其他错误
errorDetails = error.message;
console.error(`[ERROR] ${message}: ${errorDetails}`);
console.error(`[ERROR] 错误堆栈: ${error.stack}`);
res.status(500).json({
error: `${message} (${errorDetails})`,
retryable: true
});
}
}
module.exports = {
searchRepositories,
getAllTags,
getTagsByPage,
getTagCount,
fetchAllTags,
handleAxiosError
};

View File

@@ -0,0 +1,476 @@
/**
* Docker服务模块 - 处理Docker容器管理
*/
const Docker = require('dockerode');
const logger = require('../logger');
let docker = null;
async function initDockerConnection() {
if (docker) return docker;
try {
// 兼容MacOS的Docker socket路径
const options = process.platform === 'darwin'
? { socketPath: '/var/run/docker.sock' }
: null;
docker = new Docker(options);
await docker.ping();
logger.success('成功连接到Docker守护进程');
return docker;
} catch (error) {
logger.error('Docker连接失败:', error.message);
return null; // 返回null而不是抛出错误
}
}
// 获取Docker连接
async function getDockerConnection() {
if (!docker) {
docker = await initDockerConnection();
}
return docker;
}
// 修改其他Docker相关方法添加更友好的错误处理
async function getContainersStatus() {
const docker = await initDockerConnection();
if (!docker) {
logger.warn('[getContainersStatus] Cannot connect to Docker daemon, returning error indicator.');
// 返回带有特殊错误标记的数组,前端可以通过这个标记识别 Docker 不可用
return [{
id: 'n/a',
name: 'Docker 服务不可用',
image: 'n/a',
state: 'error',
status: 'Docker 服务未运行或无法连接',
error: 'DOCKER_UNAVAILABLE', // 特殊错误标记
cpu: 'N/A',
memory: 'N/A',
created: new Date().toLocaleString()
}];
}
let containers = [];
try {
containers = await docker.listContainers({ all: true });
logger.info(`[getContainersStatus] Found ${containers.length} containers.`);
} catch (listError) {
logger.error('[getContainersStatus] Error listing containers:', listError.message || listError);
// 使用同样的错误标记模式
return [{
id: 'n/a',
name: '容器列表获取失败',
image: 'n/a',
state: 'error',
status: `获取容器列表失败: ${listError.message}`,
error: 'CONTAINER_LIST_ERROR',
cpu: 'N/A',
memory: 'N/A',
created: new Date().toLocaleString()
}];
}
const containerPromises = containers.map(async (container) => {
try {
const containerInspectInfo = await docker.getContainer(container.Id).inspect();
let stats = {};
let cpuUsage = 'N/A';
let memoryUsage = 'N/A';
// 仅在容器运行时尝试获取 stats
if (containerInspectInfo.State.Running) {
try {
stats = await docker.getContainer(container.Id).stats({ stream: false });
// Safely calculate CPU usage
if (stats.precpu_stats && stats.cpu_stats && stats.cpu_stats.cpu_usage && stats.precpu_stats.cpu_usage && stats.cpu_stats.system_cpu_usage && stats.precpu_stats.system_cpu_usage) {
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
if (systemDelta > 0 && stats.cpu_stats.online_cpus > 0) {
cpuUsage = ((cpuDelta / systemDelta) * stats.cpu_stats.online_cpus * 100.0).toFixed(2) + '%';
} else {
cpuUsage = '0.00%'; // Handle division by zero or no change
}
} else {
logger.warn(`[getContainersStatus] Incomplete CPU stats for container ${container.Id}`);
}
// Safely calculate Memory usage
if (stats.memory_stats && stats.memory_stats.usage && stats.memory_stats.limit) {
const memoryLimit = stats.memory_stats.limit;
if (memoryLimit > 0) {
memoryUsage = ((stats.memory_stats.usage / memoryLimit) * 100.0).toFixed(2) + '%';
} else {
memoryUsage = '0.00%'; // Handle division by zero (unlikely)
}
} else {
logger.warn(`[getContainersStatus] Incomplete Memory stats for container ${container.Id}`);
}
} catch (statsError) {
logger.warn(`[getContainersStatus] Failed to get stats for running container ${container.Id}: ${statsError.message}`);
// 保留 N/A 值
}
}
return {
id: container.Id.slice(0, 12),
name: container.Names && container.Names.length > 0 ? container.Names[0].replace(/^\//, '') : (containerInspectInfo.Name ? containerInspectInfo.Name.replace(/^\//, '') : 'N/A'),
image: container.Image || 'N/A',
state: containerInspectInfo.State.Status || container.State || 'N/A',
status: container.Status || 'N/A',
cpu: cpuUsage,
memory: memoryUsage,
created: container.Created ? new Date(container.Created * 1000).toLocaleString() : 'N/A'
};
} catch(err) {
logger.warn(`[getContainersStatus] Failed to get inspect info for container ${container.Id}: ${err.message}`);
// 返回一个包含错误信息的对象,而不是让 Promise.all 失败
return {
id: container.Id ? container.Id.slice(0, 12) : 'Unknown ID',
name: container.Names && container.Names.length > 0 ? container.Names[0].replace(/^\//, '') : 'Unknown Name',
image: container.Image || 'Unknown Image',
state: 'error',
status: `Error: ${err.message}`,
cpu: 'N/A',
memory: 'N/A',
created: container.Created ? new Date(container.Created * 1000).toLocaleString() : 'N/A'
};
}
});
// 等待所有容器信息处理完成
const results = await Promise.all(containerPromises);
// 可以选择过滤掉完全失败的结果(虽然上面已经处理了)
// return results.filter(r => r.state !== 'error');
return results; // 返回所有结果,包括有错误的
}
// 获取单个容器状态
async function getContainerStatus(id) {
const docker = await getDockerConnection();
if (!docker) {
throw new Error('无法连接到 Docker 守护进程');
}
const container = docker.getContainer(id);
const containerInfo = await container.inspect();
return { state: containerInfo.State.Status };
}
// 重启容器
async function restartContainer(id) {
logger.info(`Attempting to restart container ${id}`);
const docker = await getDockerConnection();
if (!docker) {
logger.error(`[restartContainer ${id}] Cannot connect to Docker daemon.`);
throw new Error('无法连接到 Docker 守护进程');
}
try {
const container = docker.getContainer(id);
await container.restart();
logger.success(`Container ${id} restarted successfully.`);
return { success: true };
} catch (error) {
logger.error(`[restartContainer ${id}] Error restarting container:`, error.message || error);
// 检查是否是容器不存在的错误
if (error.statusCode === 404) {
throw new Error(`容器 ${id} 不存在`);
}
// 可以根据需要添加其他错误类型的检查
throw new Error(`重启容器失败: ${error.message}`);
}
}
// 停止容器
async function stopContainer(id) {
logger.info(`Attempting to stop container ${id}`);
const docker = await getDockerConnection();
if (!docker) {
logger.error(`[stopContainer ${id}] Cannot connect to Docker daemon.`);
throw new Error('无法连接到 Docker 守护进程');
}
try {
const container = docker.getContainer(id);
await container.stop();
logger.success(`Container ${id} stopped successfully.`);
return { success: true };
} catch (error) {
logger.error(`[stopContainer ${id}] Error stopping container:`, error.message || error);
// 检查是否是容器不存在或已停止的错误
if (error.statusCode === 404) {
throw new Error(`容器 ${id} 不存在`);
} else if (error.statusCode === 304) {
logger.warn(`[stopContainer ${id}] Container already stopped.`);
return { success: true, message: '容器已停止' }; // 认为已停止也是成功
}
throw new Error(`停止容器失败: ${error.message}`);
}
}
// 删除容器
async function deleteContainer(id) {
const docker = await getDockerConnection();
if (!docker) {
throw new Error('无法连接到 Docker 守护进程');
}
const container = docker.getContainer(id);
// 首先停止容器(如果正在运行)
try {
await container.stop();
} catch (stopError) {
logger.info('Container may already be stopped:', stopError.message);
}
// 然后删除容器
await container.remove();
return { success: true, message: '容器已成功删除' };
}
// 更新容器
async function updateContainer(id, tag) {
const docker = await getDockerConnection();
if (!docker) {
throw new Error('无法连接到 Docker 守护进程');
}
// 获取容器信息
const container = docker.getContainer(id);
const containerInfo = await container.inspect();
const currentImage = containerInfo.Config.Image;
const [imageName] = currentImage.split(':');
const newImage = `${imageName}:${tag}`;
const containerName = containerInfo.Name.slice(1); // 去掉开头的 '/'
logger.info(`Updating container ${id} from ${currentImage} to ${newImage}`);
// 拉取新镜像
logger.info(`Pulling new image: ${newImage}`);
await new Promise((resolve, reject) => {
docker.pull(newImage, (err, stream) => {
if (err) return reject(err);
docker.modem.followProgress(stream, (err, output) => err ? reject(err) : resolve(output));
});
});
// 停止旧容器
logger.info('Stopping old container');
await container.stop();
// 删除旧容器
logger.info('Removing old container');
await container.remove();
// 创建新容器
logger.info('Creating new container');
const newContainerConfig = {
...containerInfo.Config,
Image: newImage,
HostConfig: containerInfo.HostConfig,
NetworkingConfig: {
EndpointsConfig: containerInfo.NetworkSettings.Networks
}
};
const newContainer = await docker.createContainer({
...newContainerConfig,
name: containerName
});
// 启动新容器
logger.info('Starting new container');
await newContainer.start();
logger.success('Container update completed successfully');
return { success: true };
}
// 获取容器日志
async function getContainerLogs(id, options = {}) {
logger.info(`Attempting to get logs for container ${id} with options:`, options);
const docker = await getDockerConnection();
if (!docker) {
logger.error(`[getContainerLogs ${id}] Cannot connect to Docker daemon.`);
throw new Error('无法连接到 Docker 守护进程');
}
try {
const container = docker.getContainer(id);
const logOptions = {
stdout: true,
stderr: true,
tail: options.tail || 100,
follow: options.follow || false
};
// 修复日志获取方式
if (!options.follow) {
// 对于非流式日志,直接等待返回
try {
const logs = await container.logs(logOptions);
// 如果logs是Buffer或字符串直接处理
if (Buffer.isBuffer(logs) || typeof logs === 'string') {
// 清理ANSI转义码
const cleanedLogs = logs.toString('utf8').replace(/\x1B\[[0-9;]*[JKmsu]/g, '');
logger.success(`Successfully retrieved logs for container ${id}`);
return cleanedLogs;
}
// 如果logs是流转换为字符串
else if (typeof logs === 'object' && logs !== null) {
return new Promise((resolve, reject) => {
let allLogs = '';
// 处理数据事件
if (typeof logs.on === 'function') {
logs.on('data', chunk => {
allLogs += chunk.toString('utf8');
});
logs.on('end', () => {
const cleanedLogs = allLogs.replace(/\x1B\[[0-9;]*[JKmsu]/g, '');
logger.success(`Successfully retrieved logs for container ${id}`);
resolve(cleanedLogs);
});
logs.on('error', err => {
logger.error(`[getContainerLogs ${id}] Error reading log stream:`, err.message || err);
reject(new Error(`读取日志流失败: ${err.message}`));
});
} else {
// 如果不是标准流但返回了对象,尝试转换为字符串
logger.warn(`[getContainerLogs ${id}] Logs object does not have stream methods, trying to convert`);
try {
const logStr = logs.toString();
const cleanedLogs = logStr.replace(/\x1B\[[0-9;]*[JKmsu]/g, '');
resolve(cleanedLogs);
} catch (convErr) {
logger.error(`[getContainerLogs ${id}] Failed to convert logs to string:`, convErr);
reject(new Error('日志格式转换失败'));
}
}
});
} else {
logger.error(`[getContainerLogs ${id}] Unexpected logs response type:`, typeof logs);
throw new Error('日志响应格式错误');
}
} catch (logError) {
logger.error(`[getContainerLogs ${id}] Error getting logs:`, logError);
throw logError;
}
} else {
// 对于流式日志,调整方式
logger.info(`[getContainerLogs ${id}] Returning log stream for follow=true`);
const stream = await container.logs(logOptions);
return stream; // 直接返回流对象
}
} catch (error) {
logger.error(`[getContainerLogs ${id}] Error getting container logs:`, error.message || error);
if (error.statusCode === 404) {
throw new Error(`容器 ${id} 不存在`);
}
throw new Error(`获取日志失败: ${error.message}`);
}
}
// 获取已停止的容器
async function getStoppedContainers() {
const docker = await getDockerConnection();
if (!docker) {
throw new Error('无法连接到 Docker 守护进程');
}
const containers = await docker.listContainers({
all: true,
filters: { status: ['exited', 'dead', 'created'] }
});
return containers.map(container => ({
id: container.Id.slice(0, 12),
name: container.Names[0].replace(/^\//, ''),
status: container.State
}));
}
// 获取最近的Docker事件
async function getRecentEvents(limit = 10) {
const docker = await getDockerConnection();
if (!docker) {
throw new Error('无法连接到 Docker 守护进程');
}
// 注意Dockerode的getEvents API可能不支持历史事件查询
// 以下代码是模拟生成最近事件,实际应用中可能需要其他方式实现
try {
const containers = await docker.listContainers({
all: true,
limit: limit,
filters: { status: ['exited', 'created', 'running', 'restarting'] }
});
// 从容器状态转换为事件
const events = containers.map(container => {
let action, status;
switch(container.State) {
case 'running':
action = 'start';
status = '运行中';
break;
case 'exited':
action = 'die';
status = '已停止';
break;
case 'created':
action = 'create';
status = '已创建';
break;
case 'restarting':
action = 'restart';
status = '重启中';
break;
default:
action = 'update';
status = container.Status;
}
return {
time: container.Created,
Action: action,
status: status,
Actor: {
Attributes: {
name: container.Names[0].replace(/^\//, '')
}
}
};
});
return events.sort((a, b) => b.time - a.time);
} catch (error) {
logger.error('获取Docker事件失败:', error);
return [];
}
}
module.exports = {
initDockerConnection,
getDockerConnection,
getContainersStatus,
getContainerStatus,
restartContainer,
stopContainer,
deleteContainer,
updateContainer,
getContainerLogs,
getStoppedContainers,
getRecentEvents
};

View File

@@ -0,0 +1,324 @@
/**
* 文档服务模块 - 处理文档管理功能
*/
const fs = require('fs').promises;
const path = require('path');
const logger = require('../logger');
const DOCUMENTATION_DIR = path.join(__dirname, '..', 'documentation');
const META_DIR = path.join(DOCUMENTATION_DIR, 'meta');
// 确保文档目录存在
async function ensureDocumentationDir() {
try {
await fs.access(DOCUMENTATION_DIR);
logger.debug('文档目录已存在');
// 确保meta目录存在
try {
await fs.access(META_DIR);
logger.debug('文档meta目录已存在');
} catch (error) {
if (error.code === 'ENOENT') {
await fs.mkdir(META_DIR, { recursive: true });
logger.success('文档meta目录已创建');
} else {
throw error;
}
}
} catch (error) {
if (error.code === 'ENOENT') {
await fs.mkdir(DOCUMENTATION_DIR, { recursive: true });
logger.success('文档目录已创建');
// 创建meta目录
await fs.mkdir(META_DIR, { recursive: true });
logger.success('文档meta目录已创建');
} else {
throw error;
}
}
}
// 获取文档列表
async function getDocumentationList() {
try {
await ensureDocumentationDir();
const files = await fs.readdir(DOCUMENTATION_DIR);
const documents = await Promise.all(files.map(async file => {
// 跳过目录和非文档文件
if (file === 'meta' || file.startsWith('.')) return null;
// 处理JSON文件
if (file.endsWith('.json')) {
try {
const filePath = path.join(DOCUMENTATION_DIR, file);
const content = await fs.readFile(filePath, 'utf8');
const doc = JSON.parse(content);
return {
id: path.parse(file).name,
title: doc.title,
published: doc.published,
createdAt: doc.createdAt || new Date().toISOString(),
updatedAt: doc.updatedAt || new Date().toISOString()
};
} catch (fileError) {
logger.error(`读取JSON文档文件 ${file} 失败:`, fileError);
return null;
}
}
// 处理MD文件
if (file.endsWith('.md')) {
try {
const id = path.parse(file).name;
let metaData = { published: true, title: path.parse(file).name };
// 尝试读取meta数据
try {
const metaPath = path.join(META_DIR, `${id}.json`);
const metaContent = await fs.readFile(metaPath, 'utf8');
metaData = { ...metaData, ...JSON.parse(metaContent) };
} catch (metaError) {
// meta文件不存在或无法解析使用默认值
logger.warn(`无法读取文档 ${id} 的meta数据:`, metaError.message);
}
// 确保有发布状态
if (typeof metaData.published !== 'boolean') {
metaData.published = true;
}
return {
id,
title: metaData.title || id,
path: file, // 不直接加载内容,而是提供路径
published: metaData.published,
createdAt: metaData.createdAt || new Date().toISOString(),
updatedAt: metaData.updatedAt || new Date().toISOString()
};
} catch (mdError) {
logger.error(`处理MD文档 ${file} 失败:`, mdError);
return null;
}
}
return null;
}));
return documents.filter(doc => doc !== null);
} catch (error) {
logger.error('获取文档列表失败:', error);
throw error;
}
}
// 获取已发布文档
async function getPublishedDocuments() {
const documents = await getDocumentationList();
return documents.filter(doc => doc.published);
}
// 获取单个文档
async function getDocument(id) {
try {
await ensureDocumentationDir();
// 首先尝试读取JSON文件
try {
const jsonPath = path.join(DOCUMENTATION_DIR, `${id}.json`);
const jsonContent = await fs.readFile(jsonPath, 'utf8');
return JSON.parse(jsonContent);
} catch (jsonError) {
// JSON文件不存在尝试读取MD文件
if (jsonError.code === 'ENOENT') {
const mdPath = path.join(DOCUMENTATION_DIR, `${id}.md`);
const mdContent = await fs.readFile(mdPath, 'utf8');
// 读取meta数据
let metaData = { published: true, title: id };
try {
const metaPath = path.join(META_DIR, `${id}.json`);
const metaContent = await fs.readFile(metaPath, 'utf8');
metaData = { ...metaData, ...JSON.parse(metaContent) };
} catch (metaError) {
// meta文件不存在或无法解析使用默认值
logger.warn(`无法读取文档 ${id} 的meta数据:`, metaError.message);
}
return {
id,
title: metaData.title || id,
content: mdContent,
published: metaData.published,
createdAt: metaData.createdAt || new Date().toISOString(),
updatedAt: metaData.updatedAt || new Date().toISOString()
};
}
// 其他错误,直接抛出
throw jsonError;
}
} catch (error) {
logger.error(`获取文档 ${id} 失败:`, error);
throw error;
}
}
// 保存文档
async function saveDocument(id, title, content) {
try {
await ensureDocumentationDir();
const docId = id || Date.now().toString();
const docPath = path.join(DOCUMENTATION_DIR, `${docId}.json`);
// 检查是否已存在,保留发布状态
let published = false;
try {
const existingDoc = await fs.readFile(docPath, 'utf8');
published = JSON.parse(existingDoc).published || false;
} catch (error) {
// 文件不存在,使用默认值
}
const now = new Date().toISOString();
const docData = {
title,
content,
published,
createdAt: now,
updatedAt: now
};
await fs.writeFile(
docPath,
JSON.stringify(docData, null, 2),
'utf8'
);
return { id: docId, ...docData };
} catch (error) {
logger.error('保存文档失败:', error);
throw error;
}
}
// 删除文档
async function deleteDocument(id) {
try {
await ensureDocumentationDir();
// 删除JSON文件(如果存在)
try {
const jsonPath = path.join(DOCUMENTATION_DIR, `${id}.json`);
await fs.unlink(jsonPath);
} catch (error) {
if (error.code !== 'ENOENT') {
logger.warn(`删除JSON文档 ${id} 失败:`, error);
}
}
// 删除MD文件(如果存在)
try {
const mdPath = path.join(DOCUMENTATION_DIR, `${id}.md`);
await fs.unlink(mdPath);
} catch (error) {
if (error.code !== 'ENOENT') {
logger.warn(`删除MD文档 ${id} 失败:`, error);
}
}
// 删除meta文件(如果存在)
try {
const metaPath = path.join(META_DIR, `${id}.json`);
await fs.unlink(metaPath);
} catch (error) {
if (error.code !== 'ENOENT') {
logger.warn(`删除文档 ${id} 的meta数据失败:`, error);
}
}
return { success: true };
} catch (error) {
logger.error(`删除文档 ${id} 失败:`, error);
throw error;
}
}
// 切换文档发布状态
async function toggleDocumentPublish(id) {
try {
await ensureDocumentationDir();
// 尝试读取JSON文件
try {
const jsonPath = path.join(DOCUMENTATION_DIR, `${id}.json`);
const content = await fs.readFile(jsonPath, 'utf8');
const doc = JSON.parse(content);
doc.published = !doc.published;
doc.updatedAt = new Date().toISOString();
await fs.writeFile(jsonPath, JSON.stringify(doc, null, 2), 'utf8');
return doc;
} catch (jsonError) {
// 如果JSON文件不存在尝试处理MD文件的meta数据
if (jsonError.code === 'ENOENT') {
const mdPath = path.join(DOCUMENTATION_DIR, `${id}.md`);
// 确认MD文件存在
try {
await fs.access(mdPath);
} catch (mdError) {
throw new Error(`文档 ${id} 不存在`);
}
// 获取或创建meta数据
const metaPath = path.join(META_DIR, `${id}.json`);
let metaData = { published: true, title: id };
try {
const metaContent = await fs.readFile(metaPath, 'utf8');
metaData = { ...metaData, ...JSON.parse(metaContent) };
} catch (metaError) {
// meta文件不存在使用默认值
}
// 切换发布状态
metaData.published = !metaData.published;
metaData.updatedAt = new Date().toISOString();
// 保存meta数据
await fs.writeFile(metaPath, JSON.stringify(metaData, null, 2), 'utf8');
// 获取MD文件内容
const mdContent = await fs.readFile(mdPath, 'utf8');
return {
id,
title: metaData.title,
content: mdContent,
published: metaData.published,
createdAt: metaData.createdAt,
updatedAt: metaData.updatedAt
};
}
// 其他错误,直接抛出
throw jsonError;
}
} catch (error) {
logger.error(`切换文档 ${id} 发布状态失败:`, error);
throw error;
}
}
module.exports = {
ensureDocumentationDir,
getDocumentationList,
getPublishedDocuments,
getDocument,
saveDocument,
deleteDocument,
toggleDocumentPublish
};

View File

@@ -0,0 +1,331 @@
/**
* 监控服务模块 - 处理容器状态监控和通知
*/
const axios = require('axios');
const logger = require('../logger');
const configService = require('./configService');
const dockerService = require('./dockerService');
// 监控相关状态映射
let containerStates = new Map();
let lastStopAlertTime = new Map();
let secondAlertSent = new Set();
let monitoringInterval = null;
// 更新监控配置
async function updateMonitoringConfig(config) {
try {
const currentConfig = await configService.getConfig();
currentConfig.monitoringConfig = {
...currentConfig.monitoringConfig,
...config
};
await configService.saveConfig(currentConfig);
// 重新启动监控
await startMonitoring();
return { success: true };
} catch (error) {
logger.error('更新监控配置失败:', error);
throw error;
}
}
// 启动监控
async function startMonitoring() {
try {
const config = await configService.getConfig();
const { isEnabled, monitorInterval } = config.monitoringConfig || {};
// 如果监控已启用
if (isEnabled) {
const docker = await dockerService.getDockerConnection();
if (docker) {
// 初始化容器状态
await initializeContainerStates(docker);
// 如果已存在监控间隔,清除它
if (monitoringInterval) {
clearInterval(monitoringInterval);
}
// 启动监控间隔
monitoringInterval = setInterval(async () => {
await checkContainerStates(docker, config.monitoringConfig);
}, (monitorInterval || 60) * 1000);
// 监听Docker事件流
try {
const dockerEventStream = await docker.getEvents();
dockerEventStream.on('data', async (chunk) => {
try {
const event = JSON.parse(chunk.toString());
// 处理容器状态变化事件
if (event.Type === 'container' &&
(event.Action === 'start' || event.Action === 'die' ||
event.Action === 'stop' || event.Action === 'kill')) {
await handleContainerEvent(docker, event, config.monitoringConfig);
}
} catch (eventError) {
logger.error('处理Docker事件出错:', eventError);
}
});
dockerEventStream.on('error', (err) => {
logger.error('Docker事件流错误:', err);
});
} catch (streamError) {
logger.error('无法获取Docker事件流:', streamError);
}
return true;
}
} else if (monitoringInterval) {
// 如果监控已禁用但间隔仍在运行,停止它
clearInterval(monitoringInterval);
monitoringInterval = null;
}
return false;
} catch (error) {
logger.error('启动监控失败:', error);
return false;
}
}
// 停止监控
function stopMonitoring() {
if (monitoringInterval) {
clearInterval(monitoringInterval);
monitoringInterval = null;
logger.info('容器监控已停止');
}
return true;
}
// 初始化容器状态
async function initializeContainerStates(docker) {
try {
const containers = await docker.listContainers({ all: true });
for (const container of containers) {
const containerInfo = await docker.getContainer(container.Id).inspect();
containerStates.set(container.Id, containerInfo.State.Status);
}
} catch (error) {
logger.error('初始化容器状态失败:', error);
}
}
// 处理容器事件
async function handleContainerEvent(docker, event, monitoringConfig) {
try {
const containerId = event.Actor.ID;
const container = docker.getContainer(containerId);
const containerInfo = await container.inspect();
const newStatus = containerInfo.State.Status;
const oldStatus = containerStates.get(containerId);
if (oldStatus && oldStatus !== newStatus) {
// 如果容器从停止状态变为运行状态
if (newStatus === 'running' && oldStatus !== 'running') {
await sendAlertWithRetry(
containerInfo.Name,
`恢复运行 (之前状态: ${oldStatus}, 当前状态: ${newStatus})`,
monitoringConfig
);
// 清除告警状态
lastStopAlertTime.delete(containerInfo.Name);
secondAlertSent.delete(containerInfo.Name);
}
// 如果容器从运行状态变为停止状态
else if (oldStatus === 'running' && newStatus !== 'running') {
await sendAlertWithRetry(
containerInfo.Name,
`停止运行 (之前状态: ${oldStatus}, 当前状态: ${newStatus})`,
monitoringConfig
);
// 记录停止时间,用于后续检查
lastStopAlertTime.set(containerInfo.Name, Date.now());
secondAlertSent.delete(containerInfo.Name);
}
// 更新状态记录
containerStates.set(containerId, newStatus);
}
} catch (error) {
logger.error('处理容器事件失败:', error);
}
}
// 检查容器状态
async function checkContainerStates(docker, monitoringConfig) {
try {
const containers = await docker.listContainers({ all: true });
for (const container of containers) {
const containerInfo = await docker.getContainer(container.Id).inspect();
const newStatus = containerInfo.State.Status;
const oldStatus = containerStates.get(container.Id);
// 如果状态发生变化
if (oldStatus && oldStatus !== newStatus) {
// 处理状态变化与handleContainerEvent相同的逻辑
if (newStatus === 'running' && oldStatus !== 'running') {
await sendAlertWithRetry(
containerInfo.Name,
`恢复运行 (之前状态: ${oldStatus}, 当前状态: ${newStatus})`,
monitoringConfig
);
lastStopAlertTime.delete(containerInfo.Name);
secondAlertSent.delete(containerInfo.Name);
}
else if (oldStatus === 'running' && newStatus !== 'running') {
await sendAlertWithRetry(
containerInfo.Name,
`停止运行 (之前状态: ${oldStatus}, 当前状态: ${newStatus})`,
monitoringConfig
);
lastStopAlertTime.set(containerInfo.Name, Date.now());
secondAlertSent.delete(containerInfo.Name);
}
containerStates.set(container.Id, newStatus);
}
// 如果容器仍处于非运行状态,检查是否需要发送二次告警
else if (newStatus !== 'running') {
await checkSecondStopAlert(containerInfo.Name, newStatus, monitoringConfig);
}
}
} catch (error) {
logger.error('检查容器状态失败:', error);
}
}
// 检查是否需要发送二次停止告警
async function checkSecondStopAlert(containerName, currentStatus, monitoringConfig) {
const now = Date.now();
const lastStopAlert = lastStopAlertTime.get(containerName) || 0;
// 如果距离上次停止告警超过1小时且还没有发送过第二次告警则发送第二次告警
if (now - lastStopAlert >= 60 * 60 * 1000 && !secondAlertSent.has(containerName)) {
await sendAlertWithRetry(containerName, `仍未恢复 (当前状态: ${currentStatus})`, monitoringConfig);
secondAlertSent.add(containerName); // 标记已发送第二次告警
}
}
// 发送告警(带重试)
async function sendAlertWithRetry(containerName, status, monitoringConfig, maxRetries = 6) {
const { notificationType, webhookUrl, telegramToken, telegramChatId } = monitoringConfig;
const cleanContainerName = containerName.replace(/^\//, '');
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
if (notificationType === 'wechat') {
await sendWechatAlert(webhookUrl, cleanContainerName, status);
} else if (notificationType === 'telegram') {
await sendTelegramAlert(telegramToken, telegramChatId, cleanContainerName, status);
}
logger.success(`告警发送成功: ${cleanContainerName} ${status}`);
return;
} catch (error) {
if (attempt === maxRetries) {
logger.error(`达到最大重试次数,放弃发送告警: ${cleanContainerName} ${status}`);
logger.error('最后一次错误:', error);
return;
}
logger.warn(`告警发送失败,尝试重试 (${attempt}/${maxRetries}): ${error.message}`);
await new Promise(resolve => setTimeout(resolve, 10000));
}
}
}
// 发送企业微信告警
async function sendWechatAlert(webhookUrl, containerName, status) {
if (!webhookUrl) {
throw new Error('企业微信 Webhook URL 未设置');
}
const response = await axios.post(webhookUrl, {
msgtype: 'text',
text: {
content: `Docker 容器告警: 容器 ${containerName} ${status}`
}
}, {
timeout: 5000
});
if (response.status !== 200 || response.data.errcode !== 0) {
throw new Error(`请求成功但返回错误:${response.data.errmsg || JSON.stringify(response.data)}`);
}
}
// 发送Telegram告警
async function sendTelegramAlert(token, chatId, containerName, status) {
if (!token || !chatId) {
throw new Error('Telegram Bot Token 或 Chat ID 未设置');
}
const url = `https://api.telegram.org/bot${token}/sendMessage`;
const response = await axios.post(url, {
chat_id: chatId,
text: `Docker 容器告警: 容器 ${containerName} ${status}`
}, {
timeout: 5000
});
if (response.status !== 200 || !response.data.ok) {
throw new Error(`发送Telegram消息失败${JSON.stringify(response.data)}`);
}
}
// 测试通知
async function testNotification(config) {
const { notificationType, webhookUrl, telegramToken, telegramChatId } = config;
if (notificationType === 'wechat') {
await sendWechatAlert(webhookUrl, 'Test Container', 'This is a test notification');
} else if (notificationType === 'telegram') {
await sendTelegramAlert(telegramToken, telegramChatId, 'Test Container', 'This is a test notification');
} else {
throw new Error('不支持的通知类型');
}
return { success: true };
}
// 切换监控状态
async function toggleMonitoring(isEnabled) {
const config = await configService.getConfig();
config.monitoringConfig.isEnabled = isEnabled;
await configService.saveConfig(config);
return startMonitoring();
}
// 获取已停止的容器
async function getStoppedContainers(forceRefresh = false) {
return await dockerService.getStoppedContainers();
}
module.exports = {
updateMonitoringConfig,
startMonitoring,
stopMonitoring,
testNotification,
toggleMonitoring,
getStoppedContainers,
sendAlertWithRetry
};

View File

@@ -0,0 +1,52 @@
/**
* 网络服务 - 提供网络诊断功能
*/
const { exec } = require('child_process');
const { promisify } = require('util');
const logger = require('../logger');
const execAsync = promisify(exec);
/**
* 执行网络测试
* @param {string} type 测试类型 ('ping' 或 'traceroute')
* @param {string} domain 目标域名
* @returns {Promise<string>} 测试结果
*/
async function performNetworkTest(type, domain) {
// 验证输入
if (!domain || !domain.match(/^[a-zA-Z0-9][a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/)) {
throw new Error('无效的域名格式');
}
if (!type || !['ping', 'traceroute'].includes(type)) {
throw new Error('无效的测试类型');
}
try {
// 根据测试类型构建命令
const command = type === 'ping'
? `ping -c 4 ${domain}`
: `traceroute -m 10 ${domain}`;
logger.info(`执行网络测试: ${command}`);
// 执行命令并获取结果
const { stdout, stderr } = await execAsync(command, { timeout: 30000 });
return stdout || stderr;
} catch (error) {
logger.error(`网络测试失败: ${error.message}`);
// 如果命令被终止,表示超时
if (error.killed) {
throw new Error('测试超时');
}
// 其他错误
throw error;
}
}
module.exports = {
performNetworkTest
};

View File

@@ -0,0 +1,103 @@
/**
* 通知服务
* 用于发送各种类型的通知
*/
const axios = require('axios');
const logger = require('../logger');
/**
* 发送通知
* @param {Object} message - 消息对象,包含标题、内容等
* @param {Object} config - 配置对象,包含通知类型和相关配置
* @returns {Promise<void>}
*/
async function sendNotification(message, config) {
const { type } = config;
switch (type) {
case 'wechat':
return sendWechatNotification(message, config);
case 'telegram':
return sendTelegramNotification(message, config);
default:
throw new Error(`不支持的通知类型: ${type}`);
}
}
/**
* 发送企业微信通知
* @param {Object} message - 消息对象
* @param {Object} config - 配置对象
* @returns {Promise<void>}
*/
async function sendWechatNotification(message, config) {
const { webhookUrl } = config;
if (!webhookUrl) {
throw new Error('企业微信 Webhook URL 未配置');
}
const payload = {
msgtype: 'markdown',
markdown: {
content: `## ${message.title}\n${message.content}\n> ${message.time}`
}
};
try {
const response = await axios.post(webhookUrl, payload, {
headers: { 'Content-Type': 'application/json' },
timeout: 5000
});
if (response.status !== 200 || response.data.errcode !== 0) {
throw new Error(`企业微信返回错误: ${response.data.errmsg || '未知错误'}`);
}
logger.info('企业微信通知发送成功');
} catch (error) {
logger.error('企业微信通知发送失败:', error);
throw new Error(`企业微信通知发送失败: ${error.message}`);
}
}
/**
* 发送Telegram通知
* @param {Object} message - 消息对象
* @param {Object} config - 配置对象
* @returns {Promise<void>}
*/
async function sendTelegramNotification(message, config) {
const { telegramToken, telegramChatId } = config;
if (!telegramToken || !telegramChatId) {
throw new Error('Telegram Token 或 Chat ID 未配置');
}
const text = `*${message.title}*\n\n${message.content}\n\n_${message.time}_`;
const url = `https://api.telegram.org/bot${telegramToken}/sendMessage`;
try {
const response = await axios.post(url, {
chat_id: telegramChatId,
text: text,
parse_mode: 'Markdown'
}, {
headers: { 'Content-Type': 'application/json' },
timeout: 5000
});
if (response.status !== 200 || !response.data.ok) {
throw new Error(`Telegram 返回错误: ${response.data.description || '未知错误'}`);
}
logger.info('Telegram 通知发送成功');
} catch (error) {
logger.error('Telegram 通知发送失败:', error);
throw new Error(`Telegram 通知发送失败: ${error.message}`);
}
}
module.exports = {
sendNotification
};

View File

@@ -0,0 +1,55 @@
/**
* 系统服务模块 - 处理系统级信息获取
*/
const { exec } = require('child_process');
const os = require('os');
const logger = require('../logger');
// 获取磁盘空间信息
async function getDiskSpace() {
try {
// 根据操作系统不同有不同的命令
const isWindows = os.platform() === 'win32';
if (isWindows) {
// Windows实现(需要更复杂的逻辑)
return {
diskSpace: '未实现',
usagePercent: 0
};
} else {
// Linux/Mac实现
const diskInfo = await execPromise('df -h | grep -E "/$|/home" | head -1');
const diskParts = diskInfo.split(/\s+/);
if (diskParts.length >= 5) {
return {
diskSpace: `${diskParts[2]}/${diskParts[1]}`,
usagePercent: parseInt(diskParts[4].replace('%', ''))
};
} else {
throw new Error('磁盘信息格式不正确');
}
}
} catch (error) {
logger.error('获取磁盘空间失败:', error);
throw error;
}
}
// 辅助函数: 执行命令
function execPromise(command) {
return new Promise((resolve, reject) => {
exec(command, (error, stdout, stderr) => {
if (error) {
reject(error);
return;
}
resolve(stdout.trim());
});
});
}
module.exports = {
getDiskSpace
};

View File

@@ -0,0 +1,175 @@
/**
* 用户服务模块
*/
const fs = require('fs').promises;
const path = require('path');
const bcrypt = require('bcrypt');
const logger = require('../logger');
const USERS_FILE = path.join(__dirname, '..', 'users.json');
// 获取所有用户
async function getUsers() {
try {
const data = await fs.readFile(USERS_FILE, 'utf8');
return JSON.parse(data);
} catch (error) {
if (error.code === 'ENOENT') {
logger.warn('Users file does not exist, creating default user');
const defaultUser = {
username: 'root',
password: bcrypt.hashSync('admin', 10),
createdAt: new Date().toISOString(),
loginCount: 0,
lastLogin: null
};
await saveUsers([defaultUser]);
return { users: [defaultUser] };
}
throw error;
}
}
// 保存用户
async function saveUsers(users) {
await fs.writeFile(USERS_FILE, JSON.stringify({ users }, null, 2), 'utf8');
}
// 更新用户登录信息
async function updateUserLoginInfo(username) {
try {
const { users } = await getUsers();
const user = users.find(u => u.username === username);
if (user) {
user.loginCount = (user.loginCount || 0) + 1;
user.lastLogin = new Date().toISOString();
await saveUsers(users);
}
} catch (error) {
logger.error('更新用户登录信息失败:', error);
}
}
// 获取用户统计信息
async function getUserStats(username) {
try {
const { users } = await getUsers();
const user = users.find(u => u.username === username);
if (!user) {
return { loginCount: '0', lastLogin: '未知', accountAge: '0' };
}
// 计算账户年龄(如果有创建日期)
let accountAge = '0';
if (user.createdAt) {
const createdDate = new Date(user.createdAt);
const currentDate = new Date();
const diffTime = Math.abs(currentDate - createdDate);
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
accountAge = diffDays.toString();
}
// 格式化最后登录时间
let lastLogin = '未知';
if (user.lastLogin) {
const lastLoginDate = new Date(user.lastLogin);
const now = new Date();
const isToday = lastLoginDate.toDateString() === now.toDateString();
if (isToday) {
lastLogin = '今天 ' + lastLoginDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else {
lastLogin = lastLoginDate.toLocaleDateString() + ' ' + lastLoginDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
}
return {
username: user.username,
loginCount: (user.loginCount || 0).toString(),
lastLogin,
accountAge
};
} catch (error) {
logger.error('获取用户统计信息失败:', error);
return { loginCount: '0', lastLogin: '未知', accountAge: '0' };
}
}
// 创建新用户
async function createUser(username, password) {
try {
const { users } = await getUsers();
// 检查用户是否已存在
if (users.some(u => u.username === username)) {
throw new Error('用户名已存在');
}
const hashedPassword = bcrypt.hashSync(password, 10);
const newUser = {
username,
password: hashedPassword,
createdAt: new Date().toISOString(),
loginCount: 0,
lastLogin: null
};
users.push(newUser);
await saveUsers(users);
return { success: true, username };
} catch (error) {
logger.error('创建用户失败:', error);
throw error;
}
}
// 修改用户密码
async function changePassword(username, currentPassword, newPassword) {
try {
const { users } = await getUsers();
const user = users.find(u => u.username === username);
if (!user) {
throw new Error('用户不存在');
}
// 验证当前密码
const isMatch = await bcrypt.compare(currentPassword, user.password);
if (!isMatch) {
throw new Error('当前密码不正确');
}
// 验证新密码复杂度(虽然前端做了,后端再做一层保险)
if (!isPasswordComplex(newPassword)) {
throw new Error('新密码不符合复杂度要求');
}
// 更新密码
user.password = await bcrypt.hash(newPassword, 10);
await saveUsers(users);
logger.info(`用户 ${username} 密码已成功修改`);
} catch (error) {
logger.error('修改密码失败:', error);
throw error;
}
}
// 验证密码复杂度 (从 userCenter.js 复制过来并调整)
function isPasswordComplex(password) {
// 至少包含1个字母、1个数字和1个特殊字符长度在8-16位之间
const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)(?=.*[.,\-_+=()[\]{}|\\;:'"<>?/@$!%*#?&])[A-Za-z\d.,\-_+=()[\]{}|\\;:'"<>?/@$!%*#?&]{8,16}$/;
return passwordRegex.test(password);
}
module.exports = {
getUsers,
saveUsers,
updateUserLoginInfo,
getUserStats,
createUser,
changePassword
};

View File

@@ -0,0 +1,58 @@
/**
* 诊断启动脚本 - 运行诊断并安全启动服务器
*/
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
const { runDiagnostics } = require('./scripts/diagnostics');
// 确保必要的模块存在
try {
require('./logger');
} catch (error) {
console.error('无法加载logger模块请确保该模块存在:', error.message);
process.exit(1);
}
const logger = require('./logger');
async function startWithDiagnostics() {
logger.info('正在运行系统诊断...');
try {
// 运行诊断
const { criticalErrors } = await runDiagnostics();
if (criticalErrors.length > 0) {
logger.error('发现严重问题,无法启动系统。请修复问题后重试。');
process.exit(1);
}
logger.success('诊断通过,正在启动系统...');
// 启动服务器
const serverProcess = spawn('node', ['server.js'], {
stdio: 'inherit',
cwd: __dirname
});
serverProcess.on('close', (code) => {
if (code !== 0) {
logger.error(`服务器进程异常退出,退出码: ${code}`);
process.exit(code);
}
});
serverProcess.on('error', (err) => {
logger.error('启动服务器进程时出错:', err);
process.exit(1);
});
} catch (error) {
logger.fatal('诊断过程中发生错误:', error);
process.exit(1);
}
}
// 启动服务
startWithDiagnostics();

View File

@@ -2,7 +2,9 @@
"users": [
{
"username": "root",
"password": "$2b$10$tu.ceN0qpkl.RSR3fi/uy.9FfJGazUdWJCEPaJCDAhh6mPFbP0GxC"
"password": "$2b$10$lh1kqJtq3shL2BhMD1LbVOThGeAlPXsDgME/he4ZyDMRupVtj0Hl.",
"loginCount": 1,
"lastLogin": "2025-04-01T22:16:21.808Z"
}
]
}

BIN
hubcmdui/web/.DS_Store vendored Normal file

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
// ... existing code ...
// 获取文档列表
app.get('/api/documentation', requireLogin, async (req, res) => {
try {
const docList = await getDocumentList();
res.json(docList);
} catch (error) {
console.error('获取文档列表失败:', error);
res.status(500).json({ error: '获取文档列表失败', details: error.message });
}
});
// 获取单个文档内容
app.get('/api/documentation/:id', requireLogin, async (req, res) => {
const docId = req.params.id;
console.log(`获取文档内容请求ID: ${docId}`);
try {
// 获取文档列表
const docList = await getDocumentList();
// 查找指定ID的文档
const doc = docList.find(doc => doc.id === docId || doc._id === docId);
if (!doc) {
return res.status(404).json({ error: '文档不存在', docId });
}
// 如果文档未发布且用户不是管理员,则拒绝访问
if (!doc.published && !isAdmin(req.user)) {
return res.status(403).json({ error: '无权访问未发布的文档' });
}
// 获取文档完整内容
const docContent = await getDocumentContent(docId);
// 合并文档信息和内容
const fullDoc = {
...doc,
content: docContent
};
res.json(fullDoc);
} catch (error) {
console.error(`获取文档内容失败ID: ${docId}`, error);
res.status(500).json({
error: '获取文档内容失败',
details: error.message,
docId
});
}
});
// ... existing code ...

396
hubcmdui/web/css/admin.css Normal file
View File

@@ -0,0 +1,396 @@
/* 今天登录的高亮样式 */
.today-login {
background-color: #e6f7ff;
color: #1890ff;
padding: 2px 8px;
border-radius: 4px;
font-weight: 500;
display: inline-block;
}
/* 用户信息显示优化 */
.account-info-item {
margin-bottom: 15px;
display: flex;
align-items: center;
}
.account-info-item .label {
font-weight: 500;
width: 120px;
flex-shrink: 0;
color: #555;
}
.account-info-item .value {
color: #1f2937;
font-weight: 400;
}
/* 加载中占位符样式 */
.loading-placeholder {
display: inline-block;
width: 80px;
height: 14px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
border-radius: 4px;
}
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* 菜单编辑弹窗样式 */
.menu-edit-popup {
border-radius: 12px;
padding: 24px;
max-width: 500px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.menu-edit-title {
font-size: 1.5em;
color: #1f2937;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 2px solid #f3f4f6;
font-weight: 600;
}
.menu-edit-container {
text-align: left;
}
.menu-edit-container .form-group {
margin-bottom: 24px;
}
.menu-edit-container label {
display: block;
margin-bottom: 8px;
color: #4b5563;
font-weight: 500;
font-size: 0.95em;
}
.menu-edit-container .swal2-input,
.menu-edit-container .swal2-select {
width: 100%;
padding: 10px 14px;
border: 2px solid #e5e7eb;
border-radius: 8px;
font-size: 14px;
transition: all 0.3s ease;
background-color: #f9fafb;
}
.menu-edit-container .swal2-input:hover,
.menu-edit-container .swal2-select:hover {
border-color: #d1d5db;
background-color: #ffffff;
}
.menu-edit-container .swal2-input:focus,
.menu-edit-container .swal2-select:focus {
border-color: #4CAF50;
outline: none;
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1);
background-color: #ffffff;
}
.menu-edit-confirm {
background-color: #4CAF50 !important;
padding: 10px 24px !important;
border-radius: 8px !important;
font-weight: 500 !important;
transition: all 0.3s ease !important;
font-size: 14px !important;
display: inline-flex !important;
align-items: center !important;
gap: 8px !important;
}
.menu-edit-confirm:hover {
background-color: #45a049 !important;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(76, 175, 80, 0.2);
}
.menu-edit-cancel {
background-color: #f3f4f6 !important;
color: #4b5563 !important;
padding: 10px 24px !important;
border-radius: 8px !important;
font-weight: 500 !important;
transition: all 0.3s ease !important;
font-size: 14px !important;
display: inline-flex !important;
align-items: center !important;
gap: 8px !important;
}
.menu-edit-cancel:hover {
background-color: #e5e7eb !important;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* 操作按钮样式优化 */
.action-buttons {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.action-btn {
width: 32px;
height: 32px;
border-radius: 6px;
border: none;
background-color: #f3f4f6;
color: #4b5563;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.action-btn:hover {
background-color: #e5e7eb;
transform: translateY(-1px);
}
.action-btn i {
font-size: 14px;
}
.action-btn.edit-btn {
color: #3b82f6;
}
.action-btn.edit-btn:hover {
background-color: #dbeafe;
}
.action-btn.delete-btn {
color: #ef4444;
}
.action-btn.delete-btn:hover {
background-color: #fee2e2;
}
.action-btn.log-btn {
color: #10b981;
}
.action-btn.log-btn:hover {
background-color: #d1fae5;
}
.action-btn.start-btn {
color: #10b981;
}
.action-btn.start-btn:hover {
background-color: #d1fae5;
}
.action-btn.stop-btn {
color: #ef4444;
}
.action-btn.stop-btn:hover {
background-color: #fee2e2;
}
.action-btn.restart-btn {
color: #f59e0b;
}
.action-btn.restart-btn:hover {
background-color: #fef3c7;
}
/* Docker 状态指示器样式 */
.status-indicator {
display: inline-flex;
align-items: center;
padding: 6px 12px;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
transition: all 0.3s;
}
.status-indicator.running {
background-color: rgba(76, 175, 80, 0.1);
color: #4CAF50;
}
.status-indicator.stopped {
background-color: rgba(244, 67, 54, 0.1);
color: #F44336;
}
.status-indicator i {
margin-right: 6px;
font-size: 16px;
}
/* 文档操作按钮样式优化 */
.view-btn {
background-color: #f0f9ff !important;
color: #0ea5e9 !important;
}
.view-btn:hover {
background-color: #e0f2fe !important;
color: #0284c7 !important;
}
.edit-btn {
background-color: #f0fdf4 !important;
color: #22c55e !important;
}
.edit-btn:hover {
background-color: #dcfce7 !important;
color: #16a34a !important;
}
.delete-btn {
background-color: #fef2f2 !important;
color: #ef4444 !important;
}
.delete-btn:hover {
background-color: #fee2e2 !important;
color: #dc2626 !important;
}
.publish-btn {
background-color: #f0fdfa !important;
color: #14b8a6 !important;
}
.publish-btn:hover {
background-color: #ccfbf1 !important;
color: #0d9488 !important;
}
.unpublish-btn {
background-color: #fffbeb !important;
color: #f59e0b !important;
}
.unpublish-btn:hover {
background-color: #fef3c7 !important;
color: #d97706 !important;
}
/* 刷新按钮交互反馈 */
.refresh-btn {
background-color: #f9fafb;
color: #4b5563;
border: 1px solid #e5e7eb;
padding: 6px 12px;
border-radius: 4px;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
}
.refresh-btn:hover {
background-color: #f3f4f6;
color: #374151;
}
.refresh-btn.loading {
pointer-events: none;
opacity: 0.7;
}
.refresh-btn.loading i {
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Docker未运行友好提示 */
.docker-offline-container {
background-color: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 24px;
margin: 20px 0;
text-align: center;
}
.docker-offline-icon {
font-size: 40px;
color: #9ca3af;
margin-bottom: 16px;
}
.docker-offline-title {
font-size: 20px;
font-weight: 600;
color: #4b5563;
margin-bottom: 8px;
}
.docker-offline-message {
color: #6b7280;
margin-bottom: 20px;
}
.docker-offline-actions {
display: flex;
justify-content: center;
gap: 12px;
}
.docker-offline-btn {
padding: 8px 16px;
border-radius: 4px;
border: none;
font-weight: 500;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
transition: all 0.2s ease;
}
.docker-offline-btn.primary {
background-color: #4f46e5;
color: white;
}
.docker-offline-btn.primary:hover {
background-color: #4338ca;
}
.docker-offline-btn.secondary {
background-color: #f3f4f6;
color: #4b5563;
}
.docker-offline-btn.secondary:hover {
background-color: #e5e7eb;
}

662
hubcmdui/web/css/custom.css Normal file
View File

@@ -0,0 +1,662 @@
/**
* 自定义样式表 - 增强UI交互和视觉效果
*/
/* 仪表盘错误通知 */
.dashboard-error-notice {
background-color: #fee;
border-left: 4px solid #f44336;
padding: 12px 15px;
margin-bottom: 20px;
border-radius: 4px;
display: flex;
align-items: center;
animation: slideIn 0.4s ease-out;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
.dashboard-error-notice.fade-out {
animation: fadeOut 0.5s forwards;
}
.dashboard-error-notice i {
color: #f44336;
font-size: 20px;
margin-right: 10px;
}
.dashboard-error-notice span {
flex: 1;
color: #333;
}
.dashboard-error-notice button {
background-color: #f44336;
color: white;
border: none;
padding: 5px 10px;
border-radius: 3px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s;
}
.dashboard-error-notice button:hover {
background-color: #d32f2f;
}
/* Docker状态指示器样式增强 */
#dockerStatusIndicator {
display: flex;
align-items: center;
padding: 8px 12px;
border-radius: 20px;
font-weight: 500;
}
.docker-help-btn {
background: none;
border: none;
color: white;
margin-left: 8px;
cursor: pointer;
font-size: 16px;
}
/* Docker错误显示样式 */
.docker-error-container {
padding: 30px 20px;
text-align: center;
}
.docker-error {
background-color: #fff6f6;
border-radius: 8px;
padding: 25px;
max-width: 600px;
margin: 0 auto;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
border: 1px solid #ffcdd2;
}
.docker-error i {
color: #f44336;
font-size: 48px;
margin-bottom: 15px;
}
.docker-error h3 {
color: #d32f2f;
margin-bottom: 15px;
font-size: 24px;
}
.docker-error p {
color: #555;
margin-bottom: 20px;
font-size: 16px;
line-height: 1.6;
}
.docker-error .retry-btn,
.docker-error .help-btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
margin: 5px;
transition: all 0.3s;
}
.docker-error .retry-btn {
background-color: #3d7cf4;
color: white;
}
.docker-error .help-btn {
background-color: #f5f5f5;
color: #333;
}
.docker-error .retry-btn:hover {
background-color: #2962ff;
}
.docker-error .help-btn:hover {
background-color: #e0e0e0;
}
/* 容器状态徽章 */
.status-badge {
display: inline-block;
padding: 6px 12px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
text-align: center;
}
.status-running {
background-color: rgba(46, 204, 64, 0.15);
color: #2ecc40;
border: 1px solid rgba(46, 204, 64, 0.3);
}
.status-stopped,
.status-exited {
background-color: rgba(255, 65, 54, 0.15);
color: #ff4136;
border: 1px solid rgba(255, 65, 54, 0.3);
}
.status-paused {
background-color: rgba(255, 133, 27, 0.15);
color: #ff851b;
border: 1px solid rgba(255, 133, 27, 0.3);
}
.status-created {
background-color: rgba(0, 116, 217, 0.15);
color: #0074d9;
border: 1px solid rgba(0, 116, 217, 0.3);
}
.status-unknown {
background-color: rgba(170, 170, 170, 0.15);
color: #aaaaaa;
border: 1px solid rgba(170, 170, 170, 0.3);
}
/* 操作按钮样式 */
.action-buttons {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 4px;
border: none;
cursor: pointer;
transition: all 0.2s;
}
.action-btn i {
font-size: 14px;
}
.log-btn {
background-color: #3d7cf4;
color: white;
}
.start-btn,
.unpause-btn {
background-color: #2ecc40;
color: white;
}
.stop-btn {
background-color: #ff4136;
color: white;
}
.restart-btn {
background-color: #ff851b;
color: white;
}
.delete-btn {
background-color: #85144b;
color: white;
}
.action-btn:hover {
transform: translateY(-2px);
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
}
.action-btn:disabled {
background-color: #ccc;
cursor: not-allowed;
opacity: 0.6;
transform: none;
box-shadow: none;
}
/* 加载动画 */
.loading-animation {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 0;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(61, 124, 244, 0.1);
border-radius: 50%;
border-top: 4px solid #3d7cf4;
animation: spin 1s linear infinite;
margin-bottom: 15px;
}
.loading-spinner-small {
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid rgba(61, 124, 244, 0.1);
border-radius: 50%;
border-top: 2px solid #3d7cf4;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes slideIn {
0% { transform: translateY(-20px); opacity: 0; }
100% { transform: translateY(0); opacity: 1; }
}
@keyframes fadeOut {
0% { opacity: 1; }
100% { opacity: 0; transform: translateY(-20px); }
}
/* 无容器状态样式 */
.no-containers {
padding: 30px 0;
text-align: center;
color: #777;
}
.no-containers i {
font-size: 32px;
color: #aaa;
margin-bottom: 10px;
}
.no-containers p {
font-size: 18px;
margin-bottom: 5px;
}
.no-containers small {
font-size: 14px;
color: #999;
}
/* Docker故障排除指南样式 */
.troubleshooting-guide {
text-align: left;
}
.troubleshooting-guide h4 {
margin: 15px 0 10px;
color: #333;
font-size: 16px;
}
.troubleshooting-guide ol {
padding-left: 20px;
}
.troubleshooting-guide li {
margin-bottom: 15px;
}
.troubleshooting-guide strong {
color: #3d7cf4;
}
.troubleshooting-guide .solution {
background-color: #f8f8f8;
padding: 8px 12px;
margin-top: 5px;
border-radius: 4px;
font-size: 14px;
}
.troubleshooting-guide code {
background-color: #e8e8e8;
padding: 2px 5px;
border-radius: 3px;
font-family: monospace;
}
.check-command,
.docker-logs {
margin-top: 20px;
background-color: #f3f7ff;
padding: 10px 15px;
border-radius: 5px;
border-left: 3px solid #3d7cf4;
}
/* 菜单管理美化 */
#menuTable {
border-collapse: separate;
border-spacing: 0;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
margin-bottom: 30px;
width: 100%;
}
#menuTable th {
background-color: #3d7cf4;
color: white;
padding: 15px;
font-weight: 500;
text-align: left;
font-size: 15px;
border: none;
}
#menuTable tr:nth-child(even) {
background-color: rgba(61, 124, 244, 0.05);
}
#menuTable td {
padding: 12px 15px;
border-top: 1px solid #e9edf5;
vertical-align: middle;
font-size: 14px;
}
.new-item-row td {
background-color: #f2f7ff !important;
border-bottom: 2px solid #3d7cf4 !important;
}
#menuTable input[type="text"],
#menuTable select {
width: 100%;
padding: 8px 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
background-color: white;
}
#menuTable input[type="text"]:focus,
#menuTable select:focus {
border-color: #3d7cf4;
box-shadow: 0 0 0 3px rgba(61, 124, 244, 0.2);
outline: none;
}
/* 文档管理增强 */
#documentTable {
border-collapse: separate;
border-spacing: 0;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
margin: 20px 0 30px;
width: 100%;
}
#documentTable th {
background-color: #3d7cf4;
color: white;
padding: 15px;
font-weight: 500;
text-align: left;
font-size: 15px;
border: none;
}
#documentTable td {
padding: 12px 15px;
border-top: 1px solid #e9edf5;
vertical-align: middle;
font-size: 14px;
}
/* 文档编辑器增强 */
#editorContainer {
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 15px rgba(0,0,0,0.08);
margin-top: 30px;
display: none;
}
#documentTitle {
font-size: 18px;
font-weight: 500;
padding: 15px 20px;
border: none;
border-bottom: 1px solid #eee;
width: 100%;
box-shadow: none;
margin: 0;
}
#documentTitle:focus {
outline: none;
border-bottom-color: #3d7cf4;
}
.editormd-fullscreen {
z-index: 1000;
}
.editor-toolbar {
border-top: none !important;
}
.editor-preview-side {
border-left: 1px solid #ddd !important;
}
.editor-statusbar {
border-top: 1px solid #ddd !important;
padding: 8px 15px !important;
}
.editor-actions {
padding: 15px;
border-top: 1px solid #eee;
background-color: #f9f9f9;
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* 文本输入框和表单样式增强 */
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #333;
}
.form-control {
width: 100%;
padding: 12px 15px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 15px;
transition: all 0.3s;
}
.form-control:focus {
border-color: #3d7cf4;
box-shadow: 0 0 0 3px rgba(61, 124, 244, 0.2);
outline: none;
}
/* SweetAlert2自定义样式 */
.swal2-popup {
padding: 25px !important;
border-radius: 10px !important;
width: auto !important;
min-width: 400px !important;
}
.swal2-title {
font-size: 24px !important;
font-weight: 600 !important;
color: #333 !important;
}
.swal2-content {
font-size: 16px !important;
color: #555 !important;
}
.swal2-input, .swal2-textarea, .swal2-select {
margin: 15px auto !important;
}
.swal2-styled.swal2-confirm {
background-color: #3d7cf4 !important;
padding: 12px 25px !important;
font-size: 16px !important;
border-radius: 6px !important;
}
/* 文档预览样式 */
.document-preview-container {
z-index: 1100 !important;
}
.document-preview-popup {
max-width: 1000px !important;
overflow-y: hidden !important;
}
.document-preview-content {
padding: 0 !important;
margin: 0 !important;
}
.document-preview {
max-height: 70vh;
overflow-y: auto;
padding: 20px;
text-align: left;
background-color: white;
border-radius: 4px;
line-height: 1.6;
}
.document-preview h1 {
color: #2c3e50;
margin-top: 0;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
}
.document-preview h2 {
color: #3d7cf4;
padding-bottom: 5px;
border-bottom: 1px solid #eee;
}
.document-preview pre {
background-color: #f5f5f5;
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
overflow-x: auto;
}
.document-preview code {
background-color: #f5f5f5;
padding: 2px 4px;
border-radius: 3px;
font-family: "JetBrains Mono", monospace;
}
.document-preview img {
max-width: 100%;
height: auto;
display: block;
margin: 15px auto;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.document-preview table {
border-collapse: collapse;
width: 100%;
margin: 15px 0;
}
.document-preview table th,
.document-preview table td {
border: 1px solid #ddd;
padding: 8px 12px;
}
.document-preview table th {
background-color: #f8f9fa;
font-weight: 600;
}
.document-preview blockquote {
border-left: 4px solid #3d7cf4;
padding: 10px 15px;
color: #555;
background-color: #f9f9f9;
margin: 15px 0;
}
/* 为文档添加打印样式 */
@media print {
.document-preview {
height: auto;
overflow: visible;
background: white;
color: black;
}
.document-preview * {
color: black !important;
}
}
/* 响应式调整 */
@media (max-width: 768px) {
.action-buttons {
flex-direction: column;
}
#documentTable th,
#documentTable td,
#menuTable th,
#menuTable td {
padding: 8px;
font-size: 13px;
}
.status-badge {
padding: 4px 8px;
font-size: 12px;
}
.swal2-popup {
min-width: 300px !important;
padding: 15px !important;
}
}

View File

@@ -0,0 +1 @@
[]

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

View File

@@ -206,7 +206,135 @@
}
}
let proxyDomain = ''; // 默认代理加速地址
// ========================================
// === 文档加载相关函数 (移到此处) ===
// ========================================
let documentationLoaded = false;
async function loadAndDisplayDocumentation() {
// 防止重复加载
if (documentationLoaded) {
console.log('文档已加载,跳过重复加载');
return;
}
const docListContainer = document.getElementById('documentList');
const docContentContainer = document.getElementById('documentationText');
if (!docListContainer || !docContentContainer) {
console.warn('找不到文档列表或内容容器,可能不是文档页面');
return; // 如果容器不存在,则不执行加载
}
try {
console.log('开始加载文档列表和内容...');
// 显示加载状态
docListContainer.innerHTML = '<div class="loading-container"><i class="fas fa-spinner fa-spin"></i> 正在加载文档列表...</div>';
docContentContainer.innerHTML = '<div class="loading-container"><i class="fas fa-spinner fa-spin"></i> 请从左侧选择文档...</div>';
// 获取文档列表
const response = await fetch('/api/documentation');
if (!response.ok) {
throw new Error(`获取文档列表失败: ${response.status}`);
}
const data = await response.json();
console.log('获取到文档列表:', data);
// 保存到全局变量
window.documentationData = data;
documentationLoaded = true; // 标记为已加载
if (!Array.isArray(data) || data.length === 0) {
docListContainer.innerHTML = `
<h2>文档目录</h2>
<div class="empty-list">
<i class="fas fa-file-alt fa-3x"></i>
<p>暂无文档</p>
</div>
`;
docContentContainer.innerHTML = `
<div class="empty-content">
<i class="fas fa-file-alt fa-3x"></i>
<h2>暂无文档</h2>
<p>系统中还没有添加任何使用教程文档。</p>
</div>
`;
return;
}
// 创建文档列表
let html = '<h2>文档目录</h2><ul class="doc-list">';
data.forEach((doc, index) => {
// 确保doc有效
if (doc && doc.id && doc.title) {
html += `
<li class="doc-item" data-id="${doc.id}">
<a href="javascript:void(0)" onclick="showDocument(${index})">
<i class="fas fa-file-alt"></i>
<span>${doc.title}</span>
</a>
</li>
`;
} else {
console.warn('发现无效的文档数据:', doc);
}
});
html += '</ul>';
docListContainer.innerHTML = html;
// 默认加载第一篇文档
if (data.length > 0 && data[0]) {
showDocument(0);
// 激活第一个列表项
const firstLink = docListContainer.querySelector('.doc-item a');
if (firstLink) {
firstLink.classList.add('active');
}
} else {
// 如果第一个文档无效,显示空状态
docContentContainer.innerHTML = `
<div class="empty-content">
<i class="fas fa-file-alt fa-3x"></i>
<p>请从左侧选择一篇文档查看</p>
</div>
`;
}
} catch (error) {
console.error('加载文档列表失败:', error);
documentationLoaded = false; // 加载失败,允许重试
if (docListContainer) {
docListContainer.innerHTML = `
<h2>文档目录</h2>
<div class="error-item">
<i class="fas fa-exclamation-triangle"></i>
<p>${error.message}</p>
<button class="btn btn-sm btn-primary mt-2" onclick="loadAndDisplayDocumentation()">重试</button>
</div>
`;
}
if (docContentContainer) {
docContentContainer.innerHTML = `
<div class="error-container">
<i class="fas fa-exclamation-triangle fa-3x"></i>
<h2>加载失败</h2>
<p>无法获取文档列表: ${error.message}</p>
</div>
`;
}
}
}
// ========================================
// === 文档加载相关函数结束 ===
// ========================================
// ========================================
// === 全局变量和状态 ===
// ========================================
let proxyDomain = '';
let currentIndex = 0;
let items = [];
let currentPage = 1;
@@ -215,6 +343,43 @@
let currentTagPage = 1;
let currentImageData = null;
// ========================================
// === 新增:全局提示函数 ===
// ========================================
function showToastNotification(message, type = 'info') { // types: info, success, error
// 移除任何现有的通知
const existingNotification = document.querySelector('.toast-notification');
if (existingNotification) {
existingNotification.remove();
}
// 创建新的通知元素
const toast = document.createElement('div');
toast.className = `toast-notification ${type}`;
// 设置图标和内容
let iconClass = 'fas fa-info-circle';
if (type === 'success') iconClass = 'fas fa-check-circle';
if (type === 'error') iconClass = 'fas fa-exclamation-circle';
toast.innerHTML = `<i class="${iconClass}"></i> ${message}`;
document.body.appendChild(toast);
// 动画效果 (如果需要的话可以在CSS中定义 @keyframes fadeIn)
// toast.style.animation = 'fadeIn 0.3s ease-in';
// 设定时间后自动移除
setTimeout(() => {
toast.style.opacity = '0'; // 开始淡出
toast.style.transition = 'opacity 0.3s ease-out';
setTimeout(() => toast.remove(), 300); // 淡出后移除DOM
}, 3500); // 显示 3.5 秒
}
// ========================================
// === 其他函数定义 ===
// ========================================
// 标签切换功能
function switchTab(tabName) {
const tabs = document.querySelectorAll('.tab');
@@ -252,7 +417,7 @@
document.getElementById('paginationContainer').style.display = 'none';
if (tabName === 'documentation') {
fetchDocumentation();
loadAndDisplayDocumentation();
} else if (tabName === 'accelerate') {
// 重置显示状态
document.querySelector('.quick-guide').style.display = 'block';
@@ -414,7 +579,7 @@
async function searchDockerHub(page = 1) {
const searchTerm = document.getElementById('searchInput').value.trim();
if (!searchTerm) {
showToast('请输入搜索关键词');
showToastNotification('请输入搜索关键词', 'info');
return;
}
// 如果搜索词改变重置为第1页
@@ -436,6 +601,8 @@
document.getElementById('imageTagsView').style.display = 'none';
try {
console.log(`搜索Docker Hub: 关键词=${searchTerm}, 页码=${page}`);
// 使用新的fetchWithRetry函数
const data = await fetchWithRetry(
`/api/dockerhub/search?term=${encodeURIComponent(searchTerm)}&page=${page}`
@@ -608,9 +775,6 @@
<div class="tag-actions">
<div class="tag-search-container">
<input type="text" id="tagSearchInput" placeholder="搜索TAG..." onkeyup="filterTags()">
<button class="search-btn" onclick="filterTags()">
<i class="fas fa-search"></i> 搜索
</button>
</div>
<button id="loadAllTagsBtn" class="load-all-btn" onclick="loadAllTags()" ${loadAllBtnDisabled ? 'disabled' : ''}>
<i class="fas fa-cloud-download-alt"></i> 加载全部TAG
@@ -652,25 +816,10 @@
</div>
`;
// 显示通知
showToast(`<i class="fas fa-exclamation-circle"></i> 加载镜像详情失败: ${error.message}`, true);
showToastNotification(`加载镜像详情失败: ${error.message}`, 'error');
}
}
// 添加formatNumber函数定义
function formatNumber(num) {
if (num >= 1000000000) {
return (num >= 1500000000 ? '1B+' : '1B');
} else if (num >= 1000000) {
const m = Math.floor(num / 1000000);
return (m >= 100 ? '100M+' : m + 'M');
} else if (num >= 1000) {
const k = Math.floor(num / 1000);
return (k >= 100 ? '100K+' : k + 'K');
}
return num.toString();
}
// 新增: 加载所有标签 - 改进错误处理
async function loadAllTags() {
if (!currentImageData) {
@@ -695,7 +844,7 @@
if (totalTags === 0) {
tagsResults.innerHTML = '<div class="message-container"><i class="fas fa-info-circle"></i><p>未找到标签信息</p></div>';
showToast(`<i class="fas fa-info-circle"></i> 该镜像没有可用的标签`, false);
showToastNotification(`该镜像没有可用的标签`, 'info');
loadAllTagsBtn.disabled = false;
loadAllTagsBtn.innerHTML = '<i class="fas fa-cloud-download-alt"></i> 加载全部TAG';
return;
@@ -767,8 +916,7 @@
// 显示第一页标签(这会自动创建分页控制器)
displayAllTagsPage(1);
// 显示提示
showToast(`<i class="fas fa-check-circle"></i> 成功加载 ${allTags.length} / ${totalTags} 个标签,分${clientTotalPages}页显示`, false);
showToastNotification(`成功加载 ${allTags.length} / ${totalTags} 个标签,分${clientTotalPages}页显示`, 'success');
// 滚动到顶部
window.scrollTo({
@@ -778,7 +926,7 @@
} else {
tagsResults.innerHTML = '<div class="message-container"><i class="fas fa-info-circle"></i><p>未找到标签信息</p></div>';
showToast(`<i class="fas fa-info-circle"></i> 未能加载标签`, true);
showToastNotification(`未能加载标签`, 'info');
}
} catch (error) {
@@ -792,7 +940,7 @@
</button>
</div>
`;
showToast(`<i class="fas fa-exclamation-circle"></i> 加载全部标签失败: ${error.message}`, true);
showToastNotification(`加载全部标签失败: ${error.message}`, 'error');
} finally {
// 恢复按钮状态
loadAllTagsBtn.disabled = false;
@@ -859,8 +1007,7 @@
`;
document.getElementById('tagPaginationContainer').style.display = 'none';
// 显示通知
showToast(`<i class="fas fa-exclamation-circle"></i> 加载标签失败: ${error.message}`, true);
showToastNotification(`加载标签失败: ${error.message}`, 'error');
}
}
@@ -1300,189 +1447,223 @@
}
}
// 获取文档列表
async function fetchDocumentation() {
try {
const response = await fetch('/api/documentation');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const documents = await response.json();
console.log('Fetched documents:', documents);
const documentList = document.getElementById('documentList');
const documentationText = document.getElementById('documentationText');
if (Array.isArray(documents) && documents.length > 0) {
documentList.innerHTML = '<h2>文档列表</h2>';
const ul = document.createElement('ul');
documents.forEach(doc => {
if (doc && doc.id && doc.title) {
const li = document.createElement('li');
const link = document.createElement('a');
link.href = 'javascript:void(0);';
link.textContent = doc.title;
link.onclick = () => {
// 移除其他链接的active类
document.querySelectorAll('#documentList a').forEach(a => a.classList.remove('active'));
// 添加当前链接的active类
link.classList.add('active');
showDocument(doc.id);
};
li.appendChild(link);
ul.appendChild(li);
}
});
documentList.appendChild(ul);
// 默认选中第一个文档
if (documents[0] && documents[0].id) {
const firstLink = ul.querySelector('a');
if (firstLink) {
firstLink.classList.add('active');
showDocument(documents[0].id);
}
}
} else {
documentationText.innerHTML = '暂无文档内容';
}
} catch (error) {
console.error('获取文档列表失败:', error);
document.getElementById('documentationText').innerHTML = '加载文档列表失败,请稍后再试。错误详情: ' + error.message;
// 显示指定的文档
function showDocument(index) {
console.log('显示文档索引:', index);
if (!window.documentationData || !Array.isArray(window.documentationData)) {
console.error('文档数据不可用');
return;
}
}
async function showDocument(id) {
try {
console.log('Attempting to show document with id:', id);
const response = await fetch(`/api/documentation/${id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
// 处理数字索引或字符串ID
let docIndex = index;
let doc = null;
if (typeof index === 'string') {
// 如果是ID找到对应的索引
docIndex = window.documentationData.findIndex(doc =>
(doc.id === index || doc._id === index)
);
if (docIndex === -1) {
console.error('找不到ID为', index, '的文档');
return;
}
const documentData = await response.json();
console.log('Fetched document:', documentData);
const documentationText = document.getElementById('documentationText');
if (documentData && documentData.content && documentData.content.trim() !== '') {
// 渲染文档内容
documentationText.innerHTML = `<h2>${documentData.title || '无标题'}</h2>` + marked.parse(documentData.content);
// 为所有代码块添加复制按钮和终端样式
const codeBlocks = documentationText.querySelectorAll('pre code');
codeBlocks.forEach((codeBlock) => {
const pre = codeBlock.parentElement;
const code = codeBlock.textContent;
}
doc = window.documentationData[docIndex];
if (!doc) {
console.error('指定索引的文档不存在:', docIndex);
return;
}
console.log('文档数据:', doc);
// 高亮选中的文档
const docLinks = document.querySelectorAll('.doc-list li a');
docLinks.forEach((link, i) => {
if (i === docIndex) {
link.classList.add('active');
} else {
link.classList.remove('active');
}
});
const docContent = document.getElementById('documentationText');
if (!docContent) {
console.error('找不到文档内容容器');
return;
}
// 显示加载状态
docContent.innerHTML = '<div class="loading-container"><i class="fas fa-spinner fa-spin"></i> 正在加载文档内容...</div>';
// 如果文档内容不存在,则需要获取完整内容
if (!doc.content) {
const docId = doc.id || doc._id;
console.log('获取文档内容ID:', docId);
fetch(`/api/documentation/${docId}`)
.then(response => {
console.log('文档API响应:', response.status, response.statusText);
if (!response.ok) {
throw new Error(`获取文档内容失败: ${response.status}`);
}
return response.json();
})
.then(fullDoc => {
console.log('获取到完整文档:', fullDoc);
// 创建终端风格容器
const wrapper = document.createElement('div');
wrapper.className = 'command-terminal';
wrapper.innerHTML = `
<div class="terminal-header">
<div class="terminal-button button-red"></div>
<div class="terminal-button button-yellow"></div>
<div class="terminal-button button-green"></div>
// 更新缓存的文档内容
window.documentationData[docIndex].content = fullDoc.content;
// 渲染文档内容
renderDocumentContent(docContent, fullDoc);
})
.catch(error => {
console.error('获取文档内容失败:', error);
docContent.innerHTML = `
<div class="error-container">
<i class="fas fa-exclamation-triangle fa-3x"></i>
<h2>加载失败</h2>
<p>无法获取文档内容: ${error.message}</p>
</div>
`;
// 创建复制按钮
const copyButton = document.createElement('button');
copyButton.className = 'copy-btn';
copyButton.textContent = '复制';
copyButton.onclick = () => copyToClipboard(code, copyButton);
// 正确的DOM操作顺序
pre.parentNode.insertBefore(wrapper, pre); // 1. 先将wrapper插入到pre前面
wrapper.appendChild(pre); // 2. 将pre移动到wrapper内
pre.appendChild(copyButton); // 3. 将复制按钮添加到pre内
});
} else {
documentationText.innerHTML = '该文档没有内容或格式不正确';
}
} catch (error) {
console.error('获取文档内容失败:', error);
document.getElementById('documentationText').innerHTML = '加载文档内容失败,请稍后再试。错误详情: ' + error.message;
}
}
// 获取并加载配置
async function loadConfig() {
try {
const response = await fetch('/api/config');
const config = await response.json();
if (config.logo) {
document.querySelector('.logo').src = config.logo;
}
if (config.menuItems && Array.isArray(config.menuItems)) {
const navMenu = document.getElementById('navMenu');
navMenu.innerHTML = ''; // 清空菜单
config.menuItems.forEach(item => {
const a = document.createElement('a');
a.href = item.link;
a.textContent = item.text;
if (item.newTab) {
a.target = '_blank';
}
navMenu.appendChild(a);
});
}
if (config.proxyDomain) {
proxyDomain = config.proxyDomain;
}
if (config.searchApiEndpoint) {
window.searchApiEndpoint = config.searchApiEndpoint;
}
} catch (error) {
console.error('加载配置失败:', error);
}
}
function useImage(imageName) {
if (imageName) {
console.log("使用镜像:", imageName);
document.getElementById('imageInput').value = imageName;
switchTab('accelerate');
// 直接生成加速命令,无需用户再次点击
generateCommands(imageName);
} else {
alert('无效的 Docker 镜像名称');
// 直接渲染已有的文档内容
renderDocumentContent(docContent, doc);
}
}
// 改进Toast通知功能支持HTML内容
function showToast(message, isError = false) {
// 移除任何现有的提示
const existingToasts = document.querySelectorAll('.toast-notification');
existingToasts.forEach(toast => toast.remove());
// 确保showDocument函数在全局范围内可用
window.showDocument = showDocument;
// 渲染文档内容
function renderDocumentContent(container, doc) {
if (!container) return;
// 创建新的提示
const toast = document.createElement('div');
toast.className = `toast-notification ${isError ? 'error' : 'info'}`;
toast.innerHTML = message; // 使用innerHTML而不是textContent以支持HTML
document.body.appendChild(toast);
// 3秒后自动消失
setTimeout(() => {
toast.classList.add('fade-out');
setTimeout(() => toast.remove(), 300);
}, 5000); // 延长显示时间到5秒给用户更多时间阅读
}
console.log('正在渲染文档:', doc);
loadConfig();
// 添加缺失的showSearchResults函数
function showSearchResults() {
// 显示搜索结果列表,隐藏标签视图
document.getElementById('searchResultsList').style.display = 'block';
document.getElementById('imageTagsView').style.display = 'none';
// 清空标签搜索输入框
const tagSearchInput = document.getElementById('tagSearchInput');
if (tagSearchInput) {
tagSearchInput.value = '';
// 确保有内容可渲染
if (!doc.content && !doc.path) {
container.innerHTML = `
<h1>${doc.title || '未知文档'}</h1>
<div class="empty-content">
<i class="fas fa-file-alt fa-3x"></i>
<p>该文档暂无内容</p>
</div>
`;
return;
}
// 显示分页控件(如果有搜索结果)
if (document.getElementById('searchResults').children.length > 0) {
document.getElementById('paginationContainer').style.display = 'flex';
// 根据文档内容类型进行渲染
if (doc.content) {
renderMarkdownContent(container, doc);
} else {
// 如果是文件路径但无内容,尝试获取
fetch(`/api/documentation/file?path=${encodeURIComponent(doc.id + '.md')}`)
.then(response => {
console.log('文件内容响应:', response.status, response.statusText);
if (!response.ok) {
throw new Error(`获取文件内容失败: ${response.status}`);
}
return response.text();
})
.then(content => {
console.log('获取到文件内容,长度:', content.length);
doc.content = content;
renderMarkdownContent(container, doc);
})
.catch(error => {
console.error('获取文件内容失败:', error);
container.innerHTML = `
<div class="error-container">
<i class="fas fa-exclamation-triangle fa-3x"></i>
<h2>加载失败</h2>
<p>无法获取文档内容: ${error.message}</p>
</div>
`;
});
}
}
// 渲染Markdown内容
function renderMarkdownContent(container, doc) {
if (!container) return;
console.log('渲染Markdown内容:', doc.title, '内容长度:', doc.content ? doc.content.length : 0);
if (doc.content) {
// 使用marked渲染Markdown内容
if (window.marked) {
try {
const parsedContent = marked.parse(doc.content);
// 结构:内容(含标题)-> 元数据
container.innerHTML = `
<div class="doc-content">${parsedContent}</div>
<div class="doc-meta">
${doc.lastUpdated || doc.updatedAt ? `<span>最后更新: ${new Date(doc.lastUpdated || doc.updatedAt).toLocaleDateString('zh-CN')}</span>` : ''}
</div>
`;
} catch (error) {
console.error('Markdown解析失败:', error);
// 发生错误时仍然显示原始Markdown内容 + Meta
container.innerHTML = `
<div class="doc-content">${doc.content}</div>
<div class="doc-meta">
${doc.lastUpdated || doc.updatedAt ? `<span>最后更新: ${new Date(doc.lastUpdated || doc.updatedAt).toLocaleDateString('zh-CN')}</span>` : ''}
</div>
`;
}
} else {
// marked 不可用时,直接显示内容 + Meta
container.innerHTML = `
<div class="doc-content">${doc.content}</div>
<div class="doc-meta">
${doc.lastUpdated || doc.updatedAt ? `<span>最后更新: ${new Date(doc.lastUpdated || doc.updatedAt).toLocaleDateString('zh-CN')}</span>` : ''}
</div>
`;
}
} else {
// 文档无内容时,显示占位符
container.innerHTML = `
<div class="doc-content">
<div class="empty-content">
<i class="fas fa-file-alt fa-3x"></i>
<p>该文档暂无内容</p>
</div>
</div>
<div class="doc-meta">
<span>文档信息不可用</span>
</div>
`;
}
}
// 加载菜单
loadMenu();
// DOMContentLoaded 事件监听器
document.addEventListener('DOMContentLoaded', function() {
// 确保元素存在再添加事件监听器
const searchInput = document.getElementById('searchInput');
if (searchInput) {
searchInput.addEventListener('keypress', function(event) {
if (event.key === 'Enter') {
searchDockerHub(1);
}
});
}
// 加载菜单
loadMenu();
// 统一调用文档加载函数
loadAndDisplayDocumentation();
});
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/2.0.3/marked.min.js"></script>
</body>

495
hubcmdui/web/js/app.js Normal file
View File

@@ -0,0 +1,495 @@
// 应用程序入口模块
document.addEventListener('DOMContentLoaded', function() {
console.log('DOM 加载完成,初始化模块...');
// 启动应用程序
core.initApp();
// 在核心应用初始化后,再初始化其他模块
initializeModules();
console.log('模块初始化已启动');
});
// 初始化所有模块
async function initializeModules() {
console.log('开始初始化所有模块...');
try {
// 初始化核心模块
console.log('正在初始化核心模块...');
if (typeof core !== 'undefined') {
// core.init() 已经在core.initApp()中调用,这里不再重复调用
console.log('核心模块初始化完成');
} else {
console.error('核心模块未定义');
}
// 初始化认证模块
console.log('正在初始化认证模块...');
if (typeof auth !== 'undefined') {
await auth.init();
console.log('认证模块初始化完成');
} else {
console.error('认证模块未定义');
}
// 初始化用户中心
console.log('正在初始化用户中心...');
if (typeof userCenter !== 'undefined') {
await userCenter.init();
console.log('用户中心初始化完成');
} else {
console.error('用户中心未定义');
}
// 初始化菜单管理
console.log('正在初始化菜单管理...');
if (typeof menuManager !== 'undefined') {
await menuManager.init();
console.log('菜单管理初始化完成');
} else {
console.error('菜单管理未定义');
}
// 初始化文档管理
console.log('正在初始化文档管理...');
if (typeof documentManager !== 'undefined') {
await documentManager.init();
console.log('文档管理初始化完成');
} else {
console.error('文档管理未定义');
}
// 初始化Docker管理
console.log('正在初始化Docker管理...');
if (typeof dockerManager !== 'undefined') {
await dockerManager.init();
console.log('Docker管理初始化完成');
} else {
console.error('Docker管理未定义');
}
// 初始化系统状态
console.log('正在初始化系统状态...');
if (typeof systemStatus !== 'undefined') {
if (typeof systemStatus.initDashboard === 'function') {
await systemStatus.initDashboard();
console.log('系统状态初始化完成');
} else {
console.error('systemStatus.initDashboard 函数未定义!');
}
} else {
console.error('系统状态未定义');
}
// 初始化网络测试
console.log('正在初始化网络测试...');
if (typeof networkTest !== 'undefined') {
await networkTest.init();
console.log('网络测试初始化完成');
} else {
console.error('网络测试未定义');
}
// 加载监控配置
await loadMonitoringConfig();
// 显示默认页面 - 使用core中的showSection函数
core.showSection('dashboard');
console.log('所有模块初始化完成');
} catch (error) {
console.error('初始化模块时发生错误:', error);
// 尝试使用 core.showAlert如果 core 本身加载失败则用 console.error
if (typeof core !== 'undefined' && core.showAlert) {
core.showAlert('初始化失败: ' + error.message, 'error');
} else {
console.error('核心模块无法加载,无法显示警告弹窗');
}
}
}
// 监控配置相关函数
function loadMonitoringConfig() {
console.log('正在加载监控配置...');
fetch('/api/monitoring-config')
.then(response => {
console.log('监控配置API响应:', response.status, response.statusText);
if (!response.ok) {
throw new Error(`HTTP状态错误 ${response.status}: ${response.statusText}`);
}
return response.json();
})
.then(config => {
console.log('获取到监控配置:', config);
// 填充表单
document.getElementById('notificationType').value = config.notificationType || 'wechat';
document.getElementById('webhookUrl').value = config.webhookUrl || '';
document.getElementById('telegramToken').value = config.telegramToken || '';
document.getElementById('telegramChatId').value = config.telegramChatId || '';
document.getElementById('monitorInterval').value = config.monitorInterval || 60;
// 显示或隐藏相应的字段
toggleNotificationFields();
// 更新监控状态
document.getElementById('monitoringStatus').textContent =
config.isEnabled ? '已启用' : '已禁用';
document.getElementById('monitoringStatus').style.color =
config.isEnabled ? '#4CAF50' : '#F44336';
document.getElementById('toggleMonitoringBtn').textContent =
config.isEnabled ? '禁用监控' : '启用监控';
console.log('监控配置加载完成');
})
.catch(error => {
console.error('加载监控配置失败:', error);
// 使用安全的方式调用core.showAlert
if (typeof core !== 'undefined' && core && typeof core.showAlert === 'function') {
core.showAlert('加载监控配置失败: ' + error.message, 'error');
} else {
// 如果core未定义使用alert作为备选
alert('加载监控配置失败: ' + error.message);
}
});
}
function toggleNotificationFields() {
const type = document.getElementById('notificationType').value;
if (type === 'wechat') {
document.getElementById('wechatFields').style.display = 'block';
document.getElementById('telegramFields').style.display = 'none';
} else {
document.getElementById('wechatFields').style.display = 'none';
document.getElementById('telegramFields').style.display = 'block';
}
}
function testNotification() {
const notificationType = document.getElementById('notificationType').value;
const webhookUrl = document.getElementById('webhookUrl').value;
const telegramToken = document.getElementById('telegramToken').value;
const telegramChatId = document.getElementById('telegramChatId').value;
// 验证输入
if (notificationType === 'wechat' && !webhookUrl) {
Swal.fire({
icon: 'error',
title: '验证失败',
text: '请输入企业微信机器人 Webhook URL',
confirmButtonText: '确定'
});
return;
}
if (notificationType === 'telegram' && (!telegramToken || !telegramChatId)) {
Swal.fire({
icon: 'error',
title: '验证失败',
text: '请输入 Telegram Bot Token 和 Chat ID',
confirmButtonText: '确定'
});
return;
}
// 显示处理中的状态
Swal.fire({
title: '发送中...',
html: '<i class="fas fa-spinner fa-spin"></i> 正在发送测试通知',
showConfirmButton: false,
allowOutsideClick: false,
willOpen: () => {
Swal.showLoading();
}
});
fetch('/api/test-notification', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
notificationType,
webhookUrl,
telegramToken,
telegramChatId
})
})
.then(response => {
if (!response.ok) throw new Error('测试通知失败');
return response.json();
})
.then(() => {
Swal.fire({
icon: 'success',
title: '发送成功',
text: '测试通知已发送,请检查您的接收设备',
timer: 2000,
showConfirmButton: false
});
})
.catch(error => {
console.error('测试通知失败:', error);
Swal.fire({
icon: 'error',
title: '发送失败',
text: '测试通知发送失败: ' + error.message,
confirmButtonText: '确定'
});
});
}
function saveMonitoringConfig() {
const notificationType = document.getElementById('notificationType').value;
const webhookUrl = document.getElementById('webhookUrl').value;
const telegramToken = document.getElementById('telegramToken').value;
const telegramChatId = document.getElementById('telegramChatId').value;
const monitorInterval = document.getElementById('monitorInterval').value;
// 验证输入
if (notificationType === 'wechat' && !webhookUrl) {
Swal.fire({
icon: 'error',
title: '验证失败',
text: '请输入企业微信机器人 Webhook URL',
confirmButtonText: '确定'
});
return;
}
if (notificationType === 'telegram' && (!telegramToken || !telegramChatId)) {
Swal.fire({
icon: 'error',
title: '验证失败',
text: '请输入 Telegram Bot Token 和 Chat ID',
confirmButtonText: '确定'
});
return;
}
// 显示保存中的状态
Swal.fire({
title: '保存中...',
html: '<i class="fas fa-spinner fa-spin"></i> 正在保存监控配置',
showConfirmButton: false,
allowOutsideClick: false,
willOpen: () => {
Swal.showLoading();
}
});
fetch('/api/monitoring-config', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
notificationType,
webhookUrl,
telegramToken,
telegramChatId,
monitorInterval,
isEnabled: document.getElementById('monitoringStatus').textContent === '已启用'
})
})
.then(response => {
if (!response.ok) throw new Error('保存配置失败');
return response.json();
})
.then(() => {
Swal.fire({
icon: 'success',
title: '保存成功',
text: '监控配置已成功保存',
timer: 2000,
showConfirmButton: false
});
loadMonitoringConfig();
})
.catch(error => {
console.error('保存监控配置失败:', error);
Swal.fire({
icon: 'error',
title: '保存失败',
text: '保存监控配置失败: ' + error.message,
confirmButtonText: '确定'
});
});
}
function toggleMonitoring() {
const isCurrentlyEnabled = document.getElementById('monitoringStatus').textContent === '已启用';
const newStatus = !isCurrentlyEnabled ? '启用' : '禁用';
Swal.fire({
title: `确认${newStatus}监控?`,
html: `
<div style="text-align: left; margin-top: 10px;">
<p>您确定要<strong>${newStatus}</strong>容器监控系统吗?</p>
${isCurrentlyEnabled ?
'<p><i class="fas fa-exclamation-triangle" style="color: #f39c12;"></i> 禁用后,系统将停止监控容器状态并停止发送通知。</p>' :
'<p><i class="fas fa-info-circle" style="color: #3498db;"></i> 启用后,系统将开始定期检查容器状态并在发现异常时发送通知。</p>'}
</div>
`,
icon: 'question',
showCancelButton: true,
confirmButtonColor: isCurrentlyEnabled ? '#d33' : '#3085d6',
cancelButtonColor: '#6c757d',
confirmButtonText: `确认${newStatus}`,
cancelButtonText: '取消'
}).then((result) => {
if (result.isConfirmed) {
// 显示处理中状态
Swal.fire({
title: '处理中...',
html: `<i class="fas fa-spinner fa-spin"></i> 正在${newStatus}监控`,
showConfirmButton: false,
allowOutsideClick: false,
willOpen: () => {
Swal.showLoading();
}
});
fetch('/api/toggle-monitoring', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
isEnabled: !isCurrentlyEnabled
})
})
.then(response => {
if (!response.ok) throw new Error('切换监控状态失败');
return response.json();
})
.then(() => {
loadMonitoringConfig();
Swal.fire({
icon: 'success',
title: `${newStatus}成功`,
text: `监控已成功${newStatus}`,
timer: 2000,
showConfirmButton: false
});
})
.catch(error => {
console.error('切换监控状态失败:', error);
Swal.fire({
icon: 'error',
title: `${newStatus}失败`,
text: '切换监控状态失败: ' + error.message,
confirmButtonText: '确定'
});
});
}
});
}
function refreshStoppedContainers() {
fetch('/api/stopped-containers')
.then(response => {
if (!response.ok) throw new Error('获取已停止容器列表失败');
return response.json();
})
.then(containers => {
const tbody = document.getElementById('stoppedContainersBody');
tbody.innerHTML = '';
if (containers.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" style="text-align: center;">没有已停止的容器</td></tr>';
return;
}
containers.forEach(container => {
const row = `
<tr>
<td>${container.id}</td>
<td>${container.name}</td>
<td>${container.status}</td>
</tr>
`;
tbody.innerHTML += row;
});
})
.catch(error => {
console.error('获取已停止容器列表失败:', error);
document.getElementById('stoppedContainersBody').innerHTML =
'<tr><td colspan="3" style="text-align: center; color: red;">获取已停止容器列表失败</td></tr>';
});
}
// 保存配置函数
function saveConfig(configData) {
core.showLoading();
fetch('/api/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(configData)
})
.then(response => {
if (!response.ok) {
return response.text().then(text => {
throw new Error(`保存配置失败: ${text || response.statusText || response.status}`);
});
}
return response.json();
})
.then(() => {
core.showAlert('配置已保存', 'success');
// 如果更新了菜单,重新加载菜单项
if (configData.menuItems) {
menuManager.loadMenuItems();
}
// 重新加载系统配置
core.loadSystemConfig();
})
.catch(error => {
console.error('保存配置失败:', error);
core.showAlert('保存配置失败: ' + error.message, 'error');
})
.finally(() => {
core.hideLoading();
});
}
// 加载基本配置
function loadBasicConfig() {
fetch('/api/config')
.then(response => {
if (!response.ok) throw new Error('加载配置失败');
return response.json();
})
.then(config => {
// 填充Logo URL
if (document.getElementById('logoUrl')) {
document.getElementById('logoUrl').value = config.logo || '';
}
// 填充代理域名
if (document.getElementById('proxyDomain')) {
document.getElementById('proxyDomain').value = config.proxyDomain || '';
}
console.log('基本配置已加载');
})
.catch(error => {
console.error('加载基本配置失败:', error);
});
}
// 暴露给全局作用域的函数
window.app = {
loadMonitoringConfig,
loadBasicConfig,
toggleNotificationFields,
saveMonitoringConfig,
testNotification,
toggleMonitoring,
refreshStoppedContainers,
saveConfig
};

124
hubcmdui/web/js/auth.js Normal file
View File

@@ -0,0 +1,124 @@
// 用户认证相关功能
// 登录函数
async function login() {
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const captcha = document.getElementById('captcha').value;
try {
core.showLoading();
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, captcha })
});
if (response.ok) {
const data = await response.json();
window.isLoggedIn = true;
localStorage.setItem('isLoggedIn', 'true');
persistSession();
document.getElementById('currentUsername').textContent = username;
document.getElementById('welcomeUsername').textContent = username;
document.getElementById('loginModal').style.display = 'none';
document.getElementById('adminContainer').style.display = 'flex';
// 确保加载完成后初始化事件监听器
await core.loadSystemConfig();
core.initEventListeners();
core.showSection('dashboard');
userCenter.getUserInfo();
systemStatus.refreshSystemStatus();
} else {
const errorData = await response.json();
core.showAlert(errorData.error || '登录失败', 'error');
refreshCaptcha();
}
} catch (error) {
core.showAlert('登录失败: ' + error.message, 'error');
refreshCaptcha();
} finally {
core.hideLoading();
}
}
// 注销函数
async function logout() {
console.log("注销操作被触发");
try {
core.showLoading();
const response = await fetch('/api/logout', { method: 'POST' });
if (response.ok) {
// 清除所有登录状态
localStorage.removeItem('isLoggedIn');
sessionStorage.removeItem('sessionActive');
window.isLoggedIn = false;
// 清除cookie
document.cookie = 'connect.sid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
window.location.reload();
} else {
throw new Error('退出登录失败');
}
} catch (error) {
console.error('退出登录失败:', error);
core.showAlert('退出登录失败: ' + error.message, 'error');
// 即使API失败也清除本地状态
localStorage.removeItem('isLoggedIn');
sessionStorage.removeItem('sessionActive');
window.isLoggedIn = false;
window.location.reload();
} finally {
core.hideLoading();
}
}
// 验证码刷新函数
async function refreshCaptcha() {
try {
const response = await fetch('/api/captcha');
if (!response.ok) {
throw new Error(`验证码获取失败: ${response.status}`);
}
const data = await response.json();
document.getElementById('captchaText').textContent = data.captcha;
} catch (error) {
console.error('刷新验证码失败:', error);
document.getElementById('captchaText').textContent = '验证码加载失败,点击重试';
}
}
// 持久化会话
function persistSession() {
if (document.cookie.includes('connect.sid')) {
sessionStorage.setItem('sessionActive', 'true');
}
}
// 显示登录模态框
function showLoginModal() {
// 确保先隐藏加载指示器
if (core && typeof core.hideLoadingIndicator === 'function') {
core.hideLoadingIndicator();
}
document.getElementById('loginModal').style.display = 'flex';
refreshCaptcha();
}
// 导出模块
const auth = {
init: function() {
console.log('初始化认证模块...');
// 在这里可以添加认证模块初始化的相关代码
return Promise.resolve(); // 返回一个已解决的 Promise保持与其他模块一致
},
login,
logout,
refreshCaptcha,
showLoginModal
};
// 全局公开认证模块
window.auth = auth;

499
hubcmdui/web/js/core.js Normal file
View File

@@ -0,0 +1,499 @@
/**
* 核心功能模块
* 提供全局共享的工具函数和状态管理
*/
// 全局变量和状态
let isLoggedIn = false;
let userPermissions = [];
let systemConfig = {};
/**
* 初始化应用
* 检查登录状态,加载基础配置
*/
async function initApp() {
console.log('初始化应用...');
console.log('-------------调试信息开始-------------');
console.log('当前URL:', window.location.href);
console.log('浏览器信息:', navigator.userAgent);
console.log('DOM已加载状态:', document.readyState);
// 检查当前页面是否为登录页
const isLoginPage = window.location.pathname.includes('admin');
console.log('是否为管理页面:', isLoginPage);
try {
// 检查会话状态
const sessionResult = await checkSession();
const isAuthenticated = sessionResult.authenticated;
console.log('会话检查结果:', isAuthenticated);
// 检查localStorage中的登录状态 (主要用于刷新页面时保持UI)
const localLoginState = localStorage.getItem('isLoggedIn') === 'true';
// 核心登录状态判断
if (isAuthenticated) {
// 已登录
isLoggedIn = true;
localStorage.setItem('isLoggedIn', 'true'); // 保持本地状态
if (isLoginPage) {
// 在登录页,但会话有效,显示管理界面
console.log('已登录,显示管理界面...');
await loadSystemConfig();
showAdminInterface();
} else {
// 在非登录页,正常显示
console.log('已登录,继续应用初始化...');
await loadSystemConfig();
showAdminInterface(); // 确保管理界面可见
}
} else {
// 未登录
isLoggedIn = false;
localStorage.removeItem('isLoggedIn'); // 清除本地登录状态
if (!isLoginPage) {
// 在非登录页,重定向到登录页
console.log('未登录,重定向到登录页...');
window.location.href = '/admin';
return false;
} else {
// 在登录页,显示登录框
console.log('未登录,显示登录模态框...');
hideLoadingIndicator();
showLoginModal();
}
}
console.log('应用初始化完成');
console.log('-------------调试信息结束-------------');
return isAuthenticated;
} catch (error) {
console.error('初始化应用失败:', error);
console.log('-------------调试错误信息-------------');
console.log('错误堆栈:', error.stack);
console.log('错误类型:', error.name);
console.log('错误消息:', error.message);
console.log('---------------------------------------');
showAlert('加载应用失败:' + error.message, 'error');
hideLoadingIndicator();
showLoginModal();
return false;
}
}
/**
* 检查会话状态
*/
async function checkSession() {
try {
const response = await fetch('/api/check-session', {
headers: {
'Cache-Control': 'no-cache',
'X-Requested-With': 'XMLHttpRequest',
'Pragma': 'no-cache'
},
credentials: 'same-origin'
});
// 只关心请求是否成功以及认证状态
if (response.ok) {
const data = await response.json();
return {
authenticated: data.authenticated // 直接使用API返回的状态
};
}
// 非OK响应包括401都视为未认证
return {
authenticated: false
};
} catch (error) {
console.error('检查会话状态出错:', error);
return {
authenticated: false,
error: error.message
};
}
}
/**
* 加载系统配置
*/
function loadSystemConfig() {
fetch('/api/config')
.then(response => {
if (!response.ok) {
return response.text().then(text => {
throw new Error(`加载配置失败: ${text || response.statusText || response.status}`);
});
}
return response.json();
})
.then(config => {
console.log('加载配置成功:', config);
// 应用配置
applySystemConfig(config);
})
.catch(error => {
console.error('加载配置失败:', error);
showAlert('加载配置失败: ' + error.message, 'warning');
});
}
// 应用系统配置
function applySystemConfig(config) {
// 如果有proxyDomain配置则更新输入框
if (config.proxyDomain && document.getElementById('proxyDomain')) {
document.getElementById('proxyDomain').value = config.proxyDomain;
}
// 应用其他配置...
}
/**
* 显示管理界面
*/
function showAdminInterface() {
console.log('开始显示管理界面...');
hideLoadingIndicator();
const adminContainer = document.getElementById('adminContainer');
if (adminContainer) {
console.log('找到管理界面容器,设置为显示');
adminContainer.style.display = 'flex';
} else {
console.error('未找到管理界面容器元素 #adminContainer');
}
console.log('管理界面已显示,正在初始化事件监听器');
// 初始化菜单事件监听
initEventListeners();
}
/**
* 隐藏加载提示器
*/
function hideLoadingIndicator() {
console.log('正在隐藏加载提示器...');
const loadingIndicator = document.getElementById('loadingIndicator');
if (loadingIndicator) {
loadingIndicator.style.display = 'none';
console.log('加载提示器已隐藏');
} else {
console.warn('未找到加载提示器元素 #loadingIndicator');
}
}
/**
* 显示登录模态框
*/
function showLoginModal() {
const loginModal = document.getElementById('loginModal');
if (loginModal) {
loginModal.style.display = 'flex';
// 刷新验证码
if (window.auth && typeof window.auth.refreshCaptcha === 'function') {
window.auth.refreshCaptcha();
}
}
}
/**
* 显示加载动画
*/
function showLoading() {
const loadingSpinner = document.getElementById('loadingSpinner');
if (loadingSpinner) {
loadingSpinner.style.display = 'block';
}
}
/**
* 隐藏加载动画
*/
function hideLoading() {
const loadingSpinner = document.getElementById('loadingSpinner');
if (loadingSpinner) {
loadingSpinner.style.display = 'none';
}
}
/**
* 显示警告消息
* @param {string} message - 消息内容
* @param {string} type - 消息类型 (info, success, error)
* @param {string} title - 标题(可选)
*/
function showAlert(message, type = 'info', title = '') {
// 使用SweetAlert2替代自定义警告框确保弹窗总是显示
Swal.fire({
title: title || (type === 'success' ? '成功' : (type === 'error' ? '错误' : '提示')),
text: message,
icon: type,
timer: type === 'success' ? 2000 : undefined,
timerProgressBar: type === 'success',
confirmButtonColor: '#3d7cf4',
confirmButtonText: '确定'
});
}
/**
* 显示确认对话框
* @param {string} message - 消息内容
* @param {Function} onConfirm - 确认回调
* @param {Function} onCancel - 取消回调(可选)
* @param {string} title - 标题(可选)
*/
function showConfirm(message, onConfirm, onCancel, title = '确认') {
Swal.fire({
title: title,
text: message,
icon: 'question',
showCancelButton: true,
confirmButtonColor: '#3d7cf4',
cancelButtonColor: '#6c757d',
confirmButtonText: '确认',
cancelButtonText: '取消'
}).then((result) => {
if (result.isConfirmed && typeof onConfirm === 'function') {
onConfirm();
} else if (typeof onCancel === 'function') {
onCancel();
}
});
}
/**
* 格式化日期时间
*/
function formatDateTime(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
/**
* 防抖函数:限制函数在一定时间内只能执行一次
*/
function debounce(func, wait = 300) {
let timeout;
return function(...args) {
const later = () => {
clearTimeout(timeout);
func.apply(this, args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
/**
* 节流函数:保证一定时间内多次调用只执行一次
*/
function throttle(func, wait = 300) {
let timeout = null;
let previous = 0;
return function(...args) {
const now = Date.now();
const remaining = wait - (now - previous);
if (remaining <= 0) {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
previous = now;
func.apply(this, args);
} else if (!timeout) {
timeout = setTimeout(() => {
previous = Date.now();
timeout = null;
func.apply(this, args);
}, remaining);
}
};
}
/**
* 初始化事件监听
*/
function initEventListeners() {
console.log('开始初始化事件监听器...');
// 侧边栏菜单切换事件
const menuItems = document.querySelectorAll('.sidebar-nav li');
console.log('找到侧边栏菜单项数量:', menuItems.length);
if (menuItems.length > 0) {
menuItems.forEach((item, index) => {
const sectionId = item.getAttribute('data-section');
console.log(`绑定事件到菜单项 #${index+1}: ${sectionId}`);
item.addEventListener('click', function() {
const sectionId = this.getAttribute('data-section');
showSection(sectionId);
});
});
console.log('侧边栏菜单事件监听器已绑定');
} else {
console.error('未找到侧边栏菜单项 .sidebar-nav li');
}
// 用户中心按钮
const userCenterBtn = document.getElementById('userCenterBtn');
if (userCenterBtn) {
console.log('找到用户中心按钮,绑定事件');
userCenterBtn.addEventListener('click', () => showSection('user-center'));
} else {
console.warn('未找到用户中心按钮 #userCenterBtn');
}
// 登出按钮
const logoutBtn = document.getElementById('logoutBtn');
if (logoutBtn) {
console.log('找到登出按钮,绑定事件');
logoutBtn.addEventListener('click', () => auth.logout());
} else {
console.warn('未找到登出按钮 #logoutBtn');
}
// 用户中心内登出按钮
const ucLogoutBtn = document.getElementById('ucLogoutBtn');
if (ucLogoutBtn) {
console.log('找到用户中心内登出按钮,绑定事件');
ucLogoutBtn.addEventListener('click', () => auth.logout());
} else {
console.warn('未找到用户中心内登出按钮 #ucLogoutBtn');
}
console.log('事件监听器初始化完成');
}
/**
* 显示指定的内容区域
* @param {string} sectionId 要显示的内容区域ID
*/
function showSection(sectionId) {
console.log(`尝试显示内容区域: ${sectionId}`);
// 获取所有内容区域和菜单项
const contentSections = document.querySelectorAll('.content-section');
const menuItems = document.querySelectorAll('.sidebar-nav li');
console.log(`找到 ${contentSections.length} 个内容区域和 ${menuItems.length} 个菜单项`);
let sectionFound = false;
let menuItemFound = false;
// 隐藏所有内容区域,取消激活所有菜单项
contentSections.forEach(section => {
section.classList.remove('active');
});
menuItems.forEach(item => {
item.classList.remove('active');
});
// 激活指定的内容区域
const targetSection = document.getElementById(sectionId);
if (targetSection) {
targetSection.classList.add('active');
sectionFound = true;
console.log(`成功激活内容区域: ${sectionId}`);
// 特殊处理:切换到用户中心时,确保用户信息已加载
if (sectionId === 'user-center' && window.userCenter) {
console.log('切换到用户中心,调用 getUserInfo()');
window.userCenter.getUserInfo();
}
} else {
console.error(`未找到指定的内容区域: ${sectionId}`);
}
// 激活相应的菜单项
const targetMenuItem = document.querySelector(`.sidebar-nav li[data-section="${sectionId}"]`);
if (targetMenuItem) {
targetMenuItem.classList.add('active');
menuItemFound = true;
console.log(`成功激活菜单项: ${sectionId}`);
} else {
console.error(`未找到对应的菜单项: ${sectionId}`);
}
// 如果没有找到指定的内容区域和菜单项,显示仪表盘
if (!sectionFound && !menuItemFound) {
console.warn(`未找到指定的内容区域和菜单项,将显示默认仪表盘`);
const dashboard = document.getElementById('dashboard');
if (dashboard) {
dashboard.classList.add('active');
const dashboardMenuItem = document.querySelector('.sidebar-nav li[data-section="dashboard"]');
if (dashboardMenuItem) {
dashboardMenuItem.classList.add('active');
}
}
}
// 切换内容区域后可能需要执行的额外操作
if (sectionId === 'dashboard') {
console.log('已激活仪表盘,无需再次刷新系统状态');
// 不再自动刷新系统状态,仅在首次加载或用户手动点击刷新按钮时刷新
}
console.log(`内容区域切换完成: ${sectionId}`);
}
// 页面加载时初始化
document.addEventListener('DOMContentLoaded', function() {
console.log('DOM已加载正在初始化应用...');
initApp();
// 检查URL参数处理消息提示等
const urlParams = new URLSearchParams(window.location.search);
// 如果有message参数显示相应的提示
if (urlParams.has('message')) {
const message = urlParams.get('message');
let type = 'info';
if (urlParams.has('type')) {
type = urlParams.get('type');
}
showAlert(message, type);
}
});
// 导出核心对象
const core = {
isLoggedIn,
initApp,
checkSession,
loadSystemConfig,
applySystemConfig,
showLoading,
hideLoading,
hideLoadingIndicator,
showLoginModal,
showAlert,
showConfirm,
formatDateTime,
debounce,
throttle,
initEventListeners,
showSection
};
// 全局公开核心模块
window.core = core;

View File

@@ -0,0 +1,681 @@
/**
* Docker管理模块 - 专注于 Docker 容器表格的渲染和交互
*/
const dockerManager = {
// 初始化函数 - 只做基本的 UI 设置或事件监听(如果需要)
init: function() {
// 减少日志输出
// console.log('[dockerManager] Initializing Docker manager UI components...');
// 可以在这里添加下拉菜单的全局事件监听器等
this.setupActionDropdownListener();
// 立即显示加载状态和表头
this.showLoadingState();
// 添加对Bootstrap下拉菜单的初始化
document.addEventListener('DOMContentLoaded', () => {
this.initDropdowns();
});
// 当文档已经加载完成时立即初始化
if (document.readyState === 'complete' || document.readyState === 'interactive') {
this.initDropdowns();
}
return Promise.resolve();
},
// 初始化Bootstrap下拉菜单组件
initDropdowns: function() {
// 减少日志输出
// console.log('[dockerManager] Initializing Bootstrap dropdowns...');
// 直接初始化不使用setTimeout避免延迟导致的问题
try {
// 动态初始化所有下拉菜单
const dropdownElements = document.querySelectorAll('[data-bs-toggle="dropdown"]');
if (dropdownElements.length === 0) {
return; // 如果没有找到下拉元素,直接返回
}
if (window.bootstrap && window.bootstrap.Dropdown) {
dropdownElements.forEach(el => {
try {
new window.bootstrap.Dropdown(el);
} catch (e) {
// 静默处理错误,不要输出到控制台
}
});
} else {
console.warn('Bootstrap Dropdown 组件未找到将尝试使用jQuery初始化');
// 尝试使用jQuery初始化如果存在
if (window.jQuery) {
window.jQuery('[data-bs-toggle="dropdown"]').dropdown();
}
}
} catch (error) {
// 静默处理错误
}
},
// 显示表格加载状态 - 保持,用于初始渲染和刷新
showLoadingState() {
const table = document.getElementById('dockerStatusTable');
const tbody = document.getElementById('dockerStatusTableBody');
// 首先创建表格标题区域(如果不存在)
let tableContainer = document.getElementById('dockerTableContainer');
if (tableContainer) {
// 添加表格标题区域 - 只有不存在时才添加
if (!tableContainer.querySelector('.docker-table-header')) {
const tableHeader = document.createElement('div');
tableHeader.className = 'docker-table-header';
tableHeader.innerHTML = `
<h2 class="docker-table-title">Docker 容器管理</h2>
<div class="docker-table-actions">
<button id="refreshDockerBtn" class="btn btn-sm btn-primary">
<i class="fas fa-sync-alt me-1"></i> 刷新列表
</button>
</div>
`;
// 插入到表格前面
if (table) {
tableContainer.insertBefore(tableHeader, table);
// 添加刷新按钮事件
const refreshBtn = document.getElementById('refreshDockerBtn');
if (refreshBtn) {
refreshBtn.addEventListener('click', () => {
if (window.systemStatus && typeof window.systemStatus.refreshSystemStatus === 'function') {
window.systemStatus.refreshSystemStatus();
}
});
}
}
}
}
if (table && tbody) {
// 添加Excel风格表格类
table.classList.add('excel-table');
// 确保表头存在并正确渲染
const thead = table.querySelector('thead');
if (thead) {
thead.innerHTML = `
<tr>
<th style="width: 120px;">容器ID</th>
<th style="width: 25%;">容器名称</th>
<th style="width: 35%;">镜像名称</th>
<th style="width: 100px;">运行状态</th>
<th style="width: 150px;">操作</th>
</tr>
`;
}
// 显示加载状态
tbody.innerHTML = `
<tr class="loading-container">
<td colspan="5">
<div class="loading-animation">
<div class="spinner"></div>
<p>正在加载容器列表...</p>
</div>
</td>
</tr>
`;
}
},
// 渲染容器表格 - 核心渲染函数,由 systemStatus 调用
renderContainersTable(containers, dockerStatus) {
// 减少详细日志输出
// console.log(`[dockerManager] Rendering containers table. Containers count: ${containers ? containers.length : 0}`);
const tbody = document.getElementById('dockerStatusTableBody');
if (!tbody) {
return;
}
// 确保表头存在 (showLoadingState 应该已经创建)
const table = document.getElementById('dockerStatusTable');
if (table) {
const thead = table.querySelector('thead');
if (!thead || !thead.querySelector('tr')) {
// 重新创建表头
const newThead = thead || document.createElement('thead');
newThead.innerHTML = `
<tr>
<th style="width: 120px;">容器ID</th>
<th style="width: 25%;">容器名称</th>
<th style="width: 35%;">镜像名称</th>
<th style="width: 100px;">运行状态</th>
<th style="width: 150px;">操作</th>
</tr>
`;
if (!thead) {
table.insertBefore(newThead, tbody);
}
}
}
// 1. 检查 Docker 服务状态
if (dockerStatus !== 'running') {
tbody.innerHTML = `
<tr>
<td colspan="5" class="text-center text-muted py-4">
<i class="fab fa-docker fa-lg me-2"></i> Docker 服务未运行
</td>
</tr>
`;
return;
}
// 2. 检查容器数组是否有效且有内容
if (!Array.isArray(containers) || containers.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="5" class="text-center text-muted py-4">
<i class="fas fa-info-circle me-2"></i> 暂无运行中的Docker容器
</td>
</tr>
`;
return;
}
// 3. 渲染容器列表
let html = '';
containers.forEach(container => {
const status = container.State || container.status || '未知';
const statusClass = this.getContainerStatusClass(status);
const containerId = container.Id || container.id || '未知';
const containerName = container.Names?.[0]?.substring(1) || container.name || '未知';
const containerImage = container.Image || container.image || '未知';
// 添加lowerStatus变量定义修复错误
const lowerStatus = status.toLowerCase();
// 替换下拉菜单实现为直接的操作按钮
let actionButtons = '';
// 基本操作:查看日志和详情
actionButtons += `
<button class="btn btn-sm btn-outline-info mb-1 mr-1 action-logs" data-id="${containerId}" data-name="${containerName}">
<i class="fas fa-file-alt"></i> 日志
</button>
<button class="btn btn-sm btn-outline-secondary mb-1 mr-1 action-details" data-id="${containerId}">
<i class="fas fa-info-circle"></i> 详情
</button>
`;
// 根据状态显示不同操作
if (lowerStatus.includes('running')) {
actionButtons += `
<button class="btn btn-sm btn-outline-warning mb-1 mr-1 action-stop" data-id="${containerId}">
<i class="fas fa-stop"></i> 停止
</button>
<button class="btn btn-sm btn-outline-primary mb-1 mr-1 action-restart" data-id="${containerId}">
<i class="fas fa-sync-alt"></i> 重启
</button>
`;
} else if (lowerStatus.includes('exited') || lowerStatus.includes('stopped') || lowerStatus.includes('created')) {
actionButtons += `
<button class="btn btn-sm btn-outline-success mb-1 mr-1 action-start" data-id="${containerId}">
<i class="fas fa-play"></i> 启动
</button>
<button class="btn btn-sm btn-outline-danger mb-1 mr-1 action-remove" data-id="${containerId}">
<i class="fas fa-trash-alt"></i> 删除
</button>
`;
} else if (lowerStatus.includes('paused')) {
actionButtons += `
<button class="btn btn-sm btn-outline-success mb-1 mr-1 action-unpause" data-id="${containerId}">
<i class="fas fa-play"></i> 恢复
</button>
`;
}
// 更新容器按钮(总是显示)
actionButtons += `
<button class="btn btn-sm btn-outline-primary mb-1 mr-1 action-update" data-id="${containerId}" data-image="${containerImage || ''}">
<i class="fas fa-cloud-download-alt"></i> 更新
</button>
`;
html += `
<tr>
<td data-label="ID" title="${containerId}">${containerId.substring(0, 12)}</td>
<td data-label="名称" title="${containerName}">${containerName}</td>
<td data-label="镜像" title="${containerImage}">${containerImage}</td>
<td data-label="状态"><span class="badge ${statusClass}">${status}</span></td>
<td data-label="操作" class="action-cell">
<div class="action-buttons">
${actionButtons}
</div>
</td>
</tr>
`;
});
tbody.innerHTML = html;
// 为所有操作按钮绑定事件
this.setupButtonListeners();
},
// 为所有操作按钮绑定事件
setupButtonListeners() {
// 查找所有操作按钮并绑定点击事件
document.querySelectorAll('.action-cell button').forEach(button => {
const action = Array.from(button.classList).find(cls => cls.startsWith('action-'));
if (!action) return;
const containerId = button.dataset.id;
if (!containerId) return;
button.addEventListener('click', (event) => {
event.preventDefault();
const containerName = button.dataset.name;
const containerImage = button.dataset.image;
switch (action) {
case 'action-logs':
this.showContainerLogs(containerId, containerName);
break;
case 'action-details':
this.showContainerDetails(containerId);
break;
case 'action-stop':
this.stopContainer(containerId);
break;
case 'action-start':
this.startContainer(containerId);
break;
case 'action-restart':
this.restartContainer(containerId);
break;
case 'action-remove':
this.removeContainer(containerId);
break;
case 'action-unpause':
// this.unpauseContainer(containerId); // 假设有这个函数
console.warn('Unpause action not implemented yet.');
break;
case 'action-update':
this.updateContainer(containerId, containerImage);
break;
default:
console.warn('Unknown action:', action);
}
});
});
},
// 获取容器状态对应的 CSS 类 - 保持
getContainerStatusClass(state) {
if (!state) return 'status-unknown';
state = state.toLowerCase();
if (state.includes('running')) return 'status-running';
if (state.includes('created')) return 'status-created';
if (state.includes('exited') || state.includes('stopped')) return 'status-stopped';
if (state.includes('paused')) return 'status-paused';
return 'status-unknown';
},
// 设置下拉菜单动作的事件监听 (委托方法 - 现在直接使用按钮,不再需要)
setupActionDropdownListener() {
// 这个方法留作兼容性,但实际上我们现在直接使用按钮而非下拉菜单
},
// 查看日志 (示例:用 SweetAlert 显示)
async showContainerLogs(containerId, containerName) {
core.showLoading('正在加载日志...');
try {
// 注意: 后端 /api/docker/containers/:id/logs 需要存在并返回日志文本
const response = await fetch(`/api/docker/containers/${containerId}/logs`);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ details: '无法解析错误响应' }));
throw new Error(errorData.details || `获取日志失败 (${response.status})`);
}
const logs = await response.text();
core.hideLoading();
Swal.fire({
title: `容器日志: ${containerName || containerId.substring(0, 6)}`,
html: `<pre class="container-logs">${logs.replace(/</g, "&lt;").replace(/>/g, "&gt;")}</pre>`,
width: '80%',
customClass: {
htmlContainer: 'swal2-logs-container',
popup: 'swal2-logs-popup'
},
confirmButtonText: '关闭'
});
} catch (error) {
core.hideLoading();
core.showAlert(`查看日志失败: ${error.message}`, 'error');
logger.error(`[dockerManager] Error fetching logs for ${containerId}:`, error);
}
},
// 显示容器详情 (示例:用 SweetAlert 显示)
async showContainerDetails(containerId) {
core.showLoading('正在加载详情...');
try {
// 注意: 后端 /api/docker/containers/:id 需要存在并返回详细信息
const response = await fetch(`/api/docker/containers/${containerId}`);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ details: '无法解析错误响应' }));
throw new Error(errorData.details || `获取详情失败 (${response.status})`);
}
const details = await response.json();
core.hideLoading();
// 格式化显示详情
let detailsHtml = '<div class="container-details">';
for (const key in details) {
detailsHtml += `<p><strong>${key}:</strong> ${JSON.stringify(details[key], null, 2)}</p>`;
}
detailsHtml += '</div>';
Swal.fire({
title: `容器详情: ${details.Name || containerId.substring(0, 6)}`,
html: detailsHtml,
width: '80%',
confirmButtonText: '关闭'
});
} catch (error) {
core.hideLoading();
core.showAlert(`查看详情失败: ${error.message}`, 'error');
logger.error(`[dockerManager] Error fetching details for ${containerId}:`, error);
}
},
// 启动容器
async startContainer(containerId) {
core.showLoading('正在启动容器...');
try {
const response = await fetch(`/api/docker/containers/${containerId}/start`, { method: 'POST' });
const data = await response.json();
core.hideLoading();
if (!response.ok) throw new Error(data.details || '启动容器失败');
core.showAlert('容器启动成功', 'success');
systemStatus.refreshSystemStatus(); // 刷新整体状态
} catch (error) {
core.hideLoading();
core.showAlert(`启动容器失败: ${error.message}`, 'error');
logger.error(`[dockerManager] Error starting container ${containerId}:`, error);
}
},
// 停止容器
async stopContainer(containerId) {
core.showLoading('正在停止容器...');
try {
const response = await fetch(`/api/docker/containers/${containerId}/stop`, { method: 'POST' });
const data = await response.json();
core.hideLoading();
if (!response.ok && response.status !== 304) { // 304 Not Modified 也算成功(已停止)
throw new Error(data.details || '停止容器失败');
}
core.showAlert(data.message || '容器停止成功', 'success');
systemStatus.refreshSystemStatus(); // 刷新整体状态
} catch (error) {
core.hideLoading();
core.showAlert(`停止容器失败: ${error.message}`, 'error');
logger.error(`[dockerManager] Error stopping container ${containerId}:`, error);
}
},
// 重启容器
async restartContainer(containerId) {
core.showLoading('正在重启容器...');
try {
const response = await fetch(`/api/docker/containers/${containerId}/restart`, { method: 'POST' });
const data = await response.json();
core.hideLoading();
if (!response.ok) throw new Error(data.details || '重启容器失败');
core.showAlert('容器重启成功', 'success');
systemStatus.refreshSystemStatus(); // 刷新整体状态
} catch (error) {
core.hideLoading();
core.showAlert(`重启容器失败: ${error.message}`, 'error');
logger.error(`[dockerManager] Error restarting container ${containerId}:`, error);
}
},
// 删除容器 (带确认)
removeContainer(containerId) {
Swal.fire({
title: '确认删除?',
text: `确定要删除容器 ${containerId.substring(0, 6)} 吗?此操作不可恢复!`,
icon: 'warning',
showCancelButton: true,
confirmButtonColor: 'var(--danger-color)',
cancelButtonColor: '#6c757d',
confirmButtonText: '确认删除',
cancelButtonText: '取消'
}).then(async (result) => {
if (result.isConfirmed) {
core.showLoading('正在删除容器...');
try {
const response = await fetch(`/api/docker/containers/${containerId}/remove`, { method: 'POST' }); // 使用 remove
const data = await response.json();
core.hideLoading();
if (!response.ok) throw new Error(data.details || '删除容器失败');
core.showAlert(data.message || '容器删除成功', 'success');
systemStatus.refreshSystemStatus(); // 刷新整体状态
} catch (error) {
core.hideLoading();
core.showAlert(`删除容器失败: ${error.message}`, 'error');
logger.error(`[dockerManager] Error removing container ${containerId}:`, error);
}
}
});
},
// --- 新增:更新容器函数 ---
async updateContainer(containerId, currentImage) {
const imageName = currentImage.split(':')[0]; // 提取基础镜像名
const { value: newTag } = await Swal.fire({
title: `更新容器: ${imageName}`,
input: 'text',
inputLabel: '请输入新的镜像标签 (例如 latest, v1.2)',
inputValue: 'latest', // 默认值
showCancelButton: true,
confirmButtonText: '开始更新',
cancelButtonText: '取消',
confirmButtonColor: '#3085d6',
cancelButtonColor: '#d33',
inputValidator: (value) => {
if (!value || value.trim() === '') {
return '镜像标签不能为空!';
}
},
// 美化弹窗样式
customClass: {
container: 'update-container',
popup: 'update-popup',
header: 'update-header',
title: 'update-title',
closeButton: 'update-close',
icon: 'update-icon',
image: 'update-image',
content: 'update-content',
input: 'update-input',
actions: 'update-actions',
confirmButton: 'update-confirm',
cancelButton: 'update-cancel',
footer: 'update-footer'
}
});
if (newTag) {
// 显示进度弹窗
Swal.fire({
title: '更新容器',
html: `
<div class="update-progress">
<p>正在更新容器 <strong>${containerId.substring(0, 8)}</strong></p>
<p>镜像: <strong>${imageName}:${newTag.trim()}</strong></p>
<div class="progress-status">准备中...</div>
<div class="progress-container">
<div class="progress-bar"></div>
</div>
</div>
`,
showConfirmButton: false,
allowOutsideClick: false,
allowEscapeKey: false,
didOpen: () => {
const progressBar = Swal.getPopup().querySelector('.progress-bar');
const progressStatus = Swal.getPopup().querySelector('.progress-status');
// 设置初始进度
progressBar.style.width = '0%';
progressBar.style.backgroundColor = '#4CAF50';
// 模拟进度动画
let progress = 0;
const progressInterval = setInterval(() => {
// 进度最多到95%,剩下的在请求完成后处理
if (progress < 95) {
progress += Math.random() * 3;
if (progress > 95) progress = 95;
progressBar.style.width = `${progress}%`;
// 更新状态文本
if (progress < 30) {
progressStatus.textContent = "拉取新镜像...";
} else if (progress < 60) {
progressStatus.textContent = "准备更新容器...";
} else if (progress < 90) {
progressStatus.textContent = "应用新配置...";
} else {
progressStatus.textContent = "即将完成...";
}
}
}, 300);
// 发送更新请求
this.performContainerUpdate(containerId, newTag.trim(), progressBar, progressStatus, progressInterval);
}
});
}
},
// 执行容器更新请求
async performContainerUpdate(containerId, newTag, progressBar, progressStatus, progressInterval) {
try {
const response = await fetch(`/api/docker/containers/${containerId}/update`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ tag: newTag })
});
// 清除进度定时器
clearInterval(progressInterval);
if (response.ok) {
const data = await response.json();
// 设置进度为100%
progressBar.style.width = '100%';
progressStatus.textContent = "更新完成!";
// 显示成功消息
setTimeout(() => {
Swal.fire({
icon: 'success',
title: '更新成功!',
text: data.message || '容器已成功更新',
confirmButtonText: '确定'
});
// 刷新容器列表
systemStatus.refreshSystemStatus();
}, 800);
} else {
const data = await response.json().catch(() => ({ error: '解析响应失败', details: '服务器返回了无效的数据' }));
// 设置进度条为错误状态
progressBar.style.width = '100%';
progressBar.style.backgroundColor = '#f44336';
progressStatus.textContent = "更新失败";
// 显示错误消息
setTimeout(() => {
Swal.fire({
icon: 'error',
title: '更新失败',
text: data.details || data.error || '未知错误',
confirmButtonText: '确定'
});
}, 800);
}
} catch (error) {
// 清除进度定时器
clearInterval(progressInterval);
// 设置进度条为错误状态
progressBar.style.width = '100%';
progressBar.style.backgroundColor = '#f44336';
progressStatus.textContent = "更新出错";
// 显示错误信息
setTimeout(() => {
Swal.fire({
icon: 'error',
title: '更新失败',
text: error.message || '网络请求失败',
confirmButtonText: '确定'
});
}, 800);
// 记录错误日志
logger.error(`[dockerManager] Error updating container ${containerId} to tag ${newTag}:`, error);
}
},
// --- 新增:绑定排查按钮事件 ---
bindTroubleshootButton() {
// 使用 setTimeout 确保按钮已经渲染到 DOM 中
setTimeout(() => {
const troubleshootBtn = document.getElementById('docker-troubleshoot-btn');
if (troubleshootBtn) {
// 先移除旧监听器,防止重复绑定
troubleshootBtn.replaceWith(troubleshootBtn.cloneNode(true));
const newBtn = document.getElementById('docker-troubleshoot-btn'); // 重新获取克隆后的按钮
if(newBtn) {
newBtn.addEventListener('click', () => {
if (window.systemStatus && typeof window.systemStatus.showDockerHelp === 'function') {
window.systemStatus.showDockerHelp();
} else {
console.error('[dockerManager] systemStatus.showDockerHelp is not available.');
// 可以提供一个备用提示
alert('无法显示帮助信息,请检查控制台。');
}
});
console.log('[dockerManager] Troubleshoot button event listener bound.');
} else {
console.warn('[dockerManager] Cloned troubleshoot button not found after replace.');
}
} else {
console.warn('[dockerManager] Troubleshoot button not found for binding.');
}
}, 0); // 延迟 0ms 执行,让浏览器有机会渲染
}
};
// 确保在 DOM 加载后初始化
document.addEventListener('DOMContentLoaded', () => {
// 注意init 现在只设置监听器,不加载数据
// dockerManager.init();
// 可以在 app.js 或 systemStatus.js 初始化完成后调用
});

View File

@@ -0,0 +1,958 @@
/**
* 文档管理模块
*/
// 文档列表
let documents = [];
// 当前正在编辑的文档
let currentDocument = null;
// Markdown编辑器实例
let editorMd = null;
// 创建documentManager对象
const documentManager = {
// 初始化文档管理
init: function() {
console.log('初始化文档管理模块...');
// 渲染表头
this.renderDocumentTableHeader();
// 加载文档列表
return this.loadDocuments().catch(err => {
console.error('加载文档列表失败:', err);
return Promise.resolve(); // 即使失败也继续初始化过程
});
},
// 渲染文档表格头部
renderDocumentTableHeader: function() {
try {
const documentTable = document.getElementById('documentTable');
if (!documentTable) {
console.warn('文档管理表格元素 (id=\"documentTable\") 未找到,无法渲染表头。');
return;
}
// 查找或创建 thead
let thead = documentTable.querySelector('thead');
if (!thead) {
thead = document.createElement('thead');
documentTable.insertBefore(thead, documentTable.firstChild); // 确保 thead 在 tbody 之前
console.log('创建了文档表格的 thead 元素。');
}
// 设置表头内容 (包含 ID 列)
thead.innerHTML = `
<tr>
<th style="width: 5%">#</th>
<th style="width: 25%">标题</th>
<th style="width: 20%">创建时间</th>
<th style="width: 20%">更新时间</th>
<th style="width: 10%">状态</th>
<th style="width: 20%">操作</th>
</tr>
`;
console.log('文档表格表头已渲染。');
} catch (error) {
console.error('渲染文档表格表头时出错:', error);
}
},
// 加载文档列表
loadDocuments: async function() {
try {
// 显示加载状态
const documentTableBody = document.getElementById('documentTableBody');
if (documentTableBody) {
documentTableBody.innerHTML = '<tr><td colspan="6" style="text-align: center;"><i class="fas fa-spinner fa-spin"></i> 正在加载文档列表...</td></tr>';
}
// 简化会话检查逻辑,只验证会话是否有效
let sessionValid = true;
try {
const sessionResponse = await fetch('/api/check-session', {
headers: {
'Cache-Control': 'no-cache',
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'same-origin'
});
if (sessionResponse.status === 401) {
console.warn('会话已过期,无法加载文档');
sessionValid = false;
}
} catch (sessionError) {
console.warn('检查会话状态发生网络错误:', sessionError);
// 发生网络错误时继续尝试加载文档
}
// 尝试不同的API路径
const possiblePaths = [
'/api/documents',
'/api/documentation-list',
'/api/documentation'
];
let success = false;
let authError = false;
for (const path of possiblePaths) {
try {
console.log(`尝试从 ${path} 获取文档列表`);
const response = await fetch(path, {
credentials: 'same-origin',
headers: {
'Cache-Control': 'no-cache',
'X-Requested-With': 'XMLHttpRequest'
}
});
if (response.status === 401) {
console.warn(`API路径 ${path} 返回未授权状态`);
authError = true;
continue;
}
if (response.ok) {
const data = await response.json();
documents = Array.isArray(data) ? data : [];
// 确保每个文档都包含必要的时间字段
documents = documents.map(doc => {
if (!doc.createdAt && doc.updatedAt) {
// 如果没有创建时间但有更新时间,使用更新时间
doc.createdAt = doc.updatedAt;
} else if (!doc.createdAt) {
// 如果都没有,使用当前时间
doc.createdAt = new Date().toISOString();
}
if (!doc.updatedAt) {
// 如果没有更新时间,使用创建时间
doc.updatedAt = doc.createdAt;
}
return doc;
});
// 先渲染表头,再渲染文档列表
this.renderDocumentTableHeader();
this.renderDocumentList();
console.log(`成功从API路径 ${path} 加载文档列表`, documents);
success = true;
break;
}
} catch (e) {
console.warn(`${path} 加载文档失败:`, e);
}
}
// 处理认证错误 - 只有当会话检查和API请求都明确失败时才强制登出
if ((authError || !sessionValid) && !success && localStorage.getItem('isLoggedIn') === 'true') {
console.warn('会话检查和API请求均指示会话已过期');
core.showAlert('会话已过期,请重新登录', 'warning');
setTimeout(() => {
localStorage.removeItem('isLoggedIn');
window.location.reload();
}, 1500);
return;
}
// 如果API请求失败但不是认证错误显示空文档列表
if (!success) {
console.log('API请求失败显示空文档列表');
documents = [];
// 仍然需要渲染表头
this.renderDocumentTableHeader();
this.renderDocumentList();
}
} catch (error) {
console.error('加载文档失败:', error);
// 在UI上显示错误
const documentTableBody = document.getElementById('documentTableBody');
if (documentTableBody) {
documentTableBody.innerHTML = `<tr><td colspan="6" style="text-align: center; color: red;">加载文档失败: ${error.message} <button onclick="documentManager.loadDocuments()">重试</button></td></tr>`;
}
// 设置为空文档列表
documents = [];
}
},
// 初始化编辑器
initEditor: function() {
try {
const editorContainer = document.getElementById('editor');
if (!editorContainer) {
console.error('找不到编辑器容器元素');
return;
}
// 检查 toastui 是否已加载
console.log('检查编辑器依赖项:', typeof toastui);
// 确保 toastui 对象存在
if (typeof toastui === 'undefined') {
console.error('Toast UI Editor 未加载');
return;
}
// 创建编辑器实例
editorMd = new toastui.Editor({
el: editorContainer,
height: '600px',
initialValue: '',
previewStyle: 'vertical',
initialEditType: 'markdown',
toolbarItems: [
['heading', 'bold', 'italic', 'strike'],
['hr', 'quote'],
['ul', 'ol', 'task', 'indent', 'outdent'],
['table', 'image', 'link'],
['code', 'codeblock']
]
});
console.log('编辑器初始化完成', editorMd);
} catch (error) {
console.error('初始化编辑器出错:', error);
core.showAlert('初始化编辑器失败: ' + error.message, 'error');
}
},
// 检查编辑器是否已初始化
isEditorInitialized: function() {
return editorMd !== null;
},
// 创建新文档
newDocument: function() {
// 首先确保编辑器已初始化
if (!editorMd) {
this.initEditor();
// 等待编辑器初始化完成后再继续
setTimeout(() => {
currentDocument = null;
document.getElementById('documentTitle').value = '';
editorMd.setMarkdown('');
this.showEditor();
}, 500);
} else {
currentDocument = null;
document.getElementById('documentTitle').value = '';
editorMd.setMarkdown('');
this.showEditor();
}
},
// 显示编辑器
showEditor: function() {
document.getElementById('documentTable').style.display = 'none';
document.getElementById('editorContainer').style.display = 'block';
if (editorMd) {
// 确保每次显示编辑器时都切换到编辑模式
editorMd.focus();
}
},
// 隐藏编辑器
hideEditor: function() {
document.getElementById('documentTable').style.display = 'table';
document.getElementById('editorContainer').style.display = 'none';
},
// 取消编辑
cancelEdit: function() {
this.hideEditor();
},
// 保存文档
saveDocument: async function() {
const title = document.getElementById('documentTitle').value.trim();
const content = editorMd.getMarkdown();
if (!title) {
core.showAlert('请输入文档标题', 'error');
return;
}
// 显示保存中状态
core.showLoading();
try {
// 简化会话检查逻辑,只验证会话是否有效
let sessionValid = true;
try {
const sessionResponse = await fetch('/api/check-session', {
headers: {
'Cache-Control': 'no-cache',
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'same-origin'
});
if (sessionResponse.status === 401) {
console.warn('会话已过期,无法保存文档');
sessionValid = false;
}
} catch (sessionError) {
console.warn('检查会话状态发生网络错误:', sessionError);
// 发生网络错误时继续尝试保存操作
}
// 只有在会话明确无效时才退出
if (!sessionValid) {
core.showAlert('您的会话已过期,请重新登录', 'warning');
setTimeout(() => {
localStorage.removeItem('isLoggedIn');
window.location.reload();
}, 1500);
return;
}
// 确保Markdown内容以标题开始
let processedContent = content;
if (!content.startsWith('# ')) {
// 如果内容不是以一级标题开始,则在开头添加标题
processedContent = `# ${title}\n\n${content}`;
} else {
// 如果已经有一级标题,替换为当前标题
processedContent = content.replace(/^# .*$/m, `# ${title}`);
}
const apiUrl = currentDocument && currentDocument.id
? `/api/documents/${currentDocument.id}`
: '/api/documents';
const method = currentDocument && currentDocument.id ? 'PUT' : 'POST';
console.log(`尝试${method === 'PUT' ? '更新' : '创建'}文档,标题: ${title}`);
const response = await fetch(apiUrl, {
method: method,
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'same-origin',
body: JSON.stringify({
title,
content: processedContent,
published: currentDocument && currentDocument.published ? currentDocument.published : false
})
});
// 处理响应
if (response.status === 401) {
// 明确的未授权响应
console.warn('保存文档返回401未授权');
core.showAlert('未登录或会话已过期,请重新登录', 'warning');
setTimeout(() => {
localStorage.removeItem('isLoggedIn');
window.location.reload();
}, 1500);
return;
}
if (!response.ok) {
const errorText = await response.text();
let errorData;
try {
errorData = JSON.parse(errorText);
} catch (e) {
// 如果不是有效的JSON直接使用文本
throw new Error(errorText || '保存失败,请重试');
}
throw new Error(errorData.error || errorData.message || '保存失败,请重试');
}
const savedDoc = await response.json();
console.log('保存的文档:', savedDoc);
// 确保savedDoc包含必要的时间字段
if (savedDoc) {
// 如果返回的保存文档中没有时间字段从API获取完整文档信息
if (!savedDoc.createdAt || !savedDoc.updatedAt) {
try {
const docId = savedDoc.id || (currentDocument ? currentDocument.id : null);
if (docId) {
const docResponse = await fetch(`/api/documents/${docId}`, {
headers: { 'Cache-Control': 'no-cache' },
credentials: 'same-origin'
});
if (docResponse.ok) {
const fullDoc = await docResponse.json();
Object.assign(savedDoc, {
createdAt: fullDoc.createdAt,
updatedAt: fullDoc.updatedAt
});
console.log('获取到完整的文档时间信息:', fullDoc);
}
}
} catch (timeError) {
console.warn('获取文档完整时间信息失败:', timeError);
}
}
// 更新文档列表中的文档
const existingIndex = documents.findIndex(d => d.id === savedDoc.id);
if (existingIndex >= 0) {
documents[existingIndex] = { ...documents[existingIndex], ...savedDoc };
} else {
documents.push(savedDoc);
}
}
core.showAlert('文档保存成功', 'success');
this.hideEditor();
await this.loadDocuments(); // 重新加载文档列表
} catch (error) {
console.error('保存文档失败:', error);
core.showAlert('保存文档失败: ' + error.message, 'error');
} finally {
core.hideLoading();
}
},
// 渲染文档列表
renderDocumentList: function() {
const tbody = document.getElementById('documentTableBody');
tbody.innerHTML = '';
if (documents.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align: center;">没有找到文档</td></tr>';
return;
}
documents.forEach((doc, index) => {
// 确保文档时间有合理默认值
let createdAt = '未知';
let updatedAt = '未知';
try {
// 尝试解析创建时间,如果失败则回退到默认值
if (doc.createdAt) {
const createdDate = new Date(doc.createdAt);
if (!isNaN(createdDate.getTime())) {
createdAt = createdDate.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
}
// 尝试解析更新时间,如果失败则回退到默认值
if (doc.updatedAt) {
const updatedDate = new Date(doc.updatedAt);
if (!isNaN(updatedDate.getTime())) {
updatedAt = updatedDate.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
}
// 简化时间显示逻辑 - 直接显示时间戳,不添加特殊标记
if (createdAt === '未知' && updatedAt !== '未知') {
// 如果没有创建时间但有更新时间
createdAt = '未记录';
} else if (updatedAt === '未知' && createdAt !== '未知') {
// 如果没有更新时间但有创建时间
updatedAt = '未更新';
}
} catch (error) {
console.warn(`解析文档时间失败:`, error, doc);
}
const statusClasses = doc.published ? 'status-badge status-running' : 'status-badge status-stopped';
const statusText = doc.published ? '已发布' : '未发布';
const row = document.createElement('tr');
row.innerHTML = `
<td>${index + 1}</td>
<td>${doc.title || '无标题文档'}</td>
<td>${createdAt}</td>
<td>${updatedAt}</td>
<td><span class="${statusClasses}">${statusText}</span></td>
<td class="action-buttons">
<button class="action-btn edit-btn" title="编辑文档" onclick="documentManager.editDocument('${doc.id}')">
<i class="fas fa-edit"></i>
</button>
<button class="action-btn ${doc.published ? 'unpublish-btn' : 'publish-btn'}"
title="${doc.published ? '取消发布' : '发布文档'}"
onclick="documentManager.togglePublish('${doc.id}')">
<i class="fas ${doc.published ? 'fa-toggle-off' : 'fa-toggle-on'}"></i>
</button>
<button class="action-btn delete-btn" title="删除文档" onclick="documentManager.deleteDocument('${doc.id}')">
<i class="fas fa-trash"></i>
</button>
</td>
`;
tbody.appendChild(row);
});
},
// 编辑文档
editDocument: async function(id) {
try {
console.log(`准备编辑文档ID: ${id}`);
core.showLoading();
// 检查会话状态,优化会话检查逻辑
let sessionValid = true;
try {
const sessionResponse = await fetch('/api/check-session', {
headers: {
'Cache-Control': 'no-cache',
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'same-origin'
});
if (sessionResponse.status === 401) {
console.warn('会话已过期,无法编辑文档');
sessionValid = false;
}
} catch (sessionError) {
console.warn('检查会话状态发生网络错误:', sessionError);
// 发生网络错误时不立即判定会话失效,继续尝试编辑操作
}
// 只有在明确会话无效时才提示重新登录
if (!sessionValid) {
core.showAlert('您的会话已过期,请重新登录', 'warning');
setTimeout(() => {
localStorage.removeItem('isLoggedIn');
window.location.reload();
}, 1500);
return;
}
// 在本地查找文档
currentDocument = documents.find(doc => doc.id === id);
// 如果本地未找到从API获取
if (!currentDocument && id) {
try {
console.log('从API获取文档详情');
// 尝试多个可能的API路径
const apiPaths = [
`/api/documents/${id}`,
`/api/documentation/${id}`
];
let docResponse = null;
let authError = false;
for (const apiPath of apiPaths) {
try {
console.log(`尝试从 ${apiPath} 获取文档`);
const response = await fetch(apiPath, {
credentials: 'same-origin',
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
});
// 只有明确401错误才认定为会话过期
if (response.status === 401) {
console.warn(`API ${apiPath} 返回401未授权`);
authError = true;
continue;
}
if (response.ok) {
docResponse = response;
console.log(`成功从 ${apiPath} 获取文档`);
break;
}
} catch (pathError) {
console.warn(`${apiPath} 获取文档失败:`, pathError);
}
}
// 处理认证错误
if (authError && !docResponse) {
core.showAlert('未登录或会话已过期,请重新登录', 'warning');
setTimeout(() => {
localStorage.removeItem('isLoggedIn');
window.location.reload();
}, 1500);
return;
}
if (docResponse && docResponse.ok) {
currentDocument = await docResponse.json();
console.log('获取到文档详情:', currentDocument);
// 确保文档包含必要的时间字段
if (!currentDocument.createdAt && currentDocument.updatedAt) {
// 如果没有创建时间但有更新时间,使用更新时间
currentDocument.createdAt = currentDocument.updatedAt;
} else if (!currentDocument.createdAt) {
// 如果都没有,使用当前时间
currentDocument.createdAt = new Date().toISOString();
}
if (!currentDocument.updatedAt) {
// 如果没有更新时间,使用创建时间
currentDocument.updatedAt = currentDocument.createdAt;
}
// 将获取到的文档添加到文档列表中
const existingIndex = documents.findIndex(d => d.id === id);
if (existingIndex >= 0) {
documents[existingIndex] = currentDocument;
} else {
documents.push(currentDocument);
}
} else {
throw new Error('所有API路径都无法获取文档');
}
} catch (apiError) {
console.error('从API获取文档详情失败:', apiError);
core.showAlert('获取文档详情失败: ' + apiError.message, 'error');
}
}
// 如果仍然没有找到文档,显示错误
if (!currentDocument) {
core.showAlert('未找到指定的文档', 'error');
return;
}
// 显示编辑器界面并设置内容
this.showEditor();
// 确保编辑器已初始化
if (!editorMd) {
await new Promise(resolve => setTimeout(resolve, 100));
this.initEditor();
await new Promise(resolve => setTimeout(resolve, 500));
}
// 设置文档内容
if (editorMd) {
document.getElementById('documentTitle').value = currentDocument.title || '';
if (currentDocument.content) {
console.log(`设置文档内容,长度: ${currentDocument.content.length}`);
editorMd.setMarkdown(currentDocument.content);
} else {
console.log('文档内容为空,尝试额外获取内容');
// 如果文档内容为空,尝试额外获取内容
try {
const contentResponse = await fetch(`/api/documents/${id}/content`, {
credentials: 'same-origin',
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
});
// 只有明确401错误才提示重新登录
if (contentResponse.status === 401) {
core.showAlert('会话已过期,请重新登录', 'warning');
setTimeout(() => {
localStorage.removeItem('isLoggedIn');
window.location.reload();
}, 1500);
return;
}
if (contentResponse.ok) {
const contentData = await contentResponse.json();
if (contentData.content) {
currentDocument.content = contentData.content;
editorMd.setMarkdown(contentData.content);
console.log('成功获取额外内容');
}
}
} catch (contentError) {
console.warn('获取额外内容失败:', contentError);
}
// 如果仍然没有内容,设置为空
if (!currentDocument.content) {
editorMd.setMarkdown('');
}
}
} else {
console.error('编辑器初始化失败,无法设置内容');
core.showAlert('编辑器初始化失败,请刷新页面重试', 'error');
}
} catch (error) {
console.error('编辑文档时出错:', error);
core.showAlert('编辑文档失败: ' + error.message, 'error');
} finally {
core.hideLoading();
}
},
// 查看文档
viewDocument: function(id) {
const doc = documents.find(doc => doc.id === id);
if (!doc) {
core.showAlert('未找到指定的文档', 'error');
return;
}
Swal.fire({
title: doc.title,
html: `<div class="document-preview">${marked.parse(doc.content || '')}</div>`,
width: '70%',
showCloseButton: true,
showConfirmButton: false,
customClass: {
container: 'document-preview-container',
popup: 'document-preview-popup',
content: 'document-preview-content'
}
});
},
// 删除文档
deleteDocument: async function(id) {
Swal.fire({
title: '确定要删除此文档吗?',
text: "此操作无法撤销!",
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#d33',
cancelButtonColor: '#3085d6',
confirmButtonText: '是,删除它',
cancelButtonText: '取消'
}).then(async (result) => {
if (result.isConfirmed) {
try {
console.log(`尝试删除文档: ${id}`);
// 检查会话状态
const sessionResponse = await fetch('/api/check-session', {
headers: { 'Cache-Control': 'no-cache' }
});
if (sessionResponse.status === 401) {
// 会话已过期,提示用户并重定向到登录
core.showAlert('您的会话已过期,请重新登录', 'warning');
setTimeout(() => {
localStorage.removeItem('isLoggedIn');
window.location.reload();
}, 1500);
return;
}
// 使用正确的API路径删除
const response = await fetch(`/api/documents/${id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'same-origin' // 确保发送cookie
});
if (response.status === 401) {
// 处理未授权错误
core.showAlert('未登录或会话已过期,请重新登录', 'warning');
setTimeout(() => {
localStorage.removeItem('isLoggedIn');
window.location.reload();
}, 1500);
return;
}
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || '删除文档失败');
}
console.log('文档删除成功响应:', await response.json());
core.showAlert('文档已成功删除', 'success');
await this.loadDocuments(); // 重新加载文档列表
} catch (error) {
console.error('删除文档失败:', error);
core.showAlert('删除文档失败: ' + error.message, 'error');
}
}
});
},
// 切换文档发布状态
togglePublish: async function(id) {
try {
const doc = documents.find(d => d.id === id);
if (!doc) {
throw new Error('找不到指定文档');
}
// 添加加载指示
core.showLoading();
// 检查会话状态
const sessionResponse = await fetch('/api/check-session', {
headers: { 'Cache-Control': 'no-cache' }
});
if (sessionResponse.status === 401) {
// 会话已过期,提示用户并重定向到登录
core.showAlert('您的会话已过期,请重新登录', 'warning');
setTimeout(() => {
localStorage.removeItem('isLoggedIn');
window.location.reload();
}, 1500);
return;
}
console.log(`尝试切换文档 ${id} 的发布状态,当前状态:`, doc.published);
// 构建更新请求数据
const updateData = {
id: doc.id,
title: doc.title,
published: !doc.published // 切换发布状态
};
// 使用正确的API端点进行更新
const response = await fetch(`/api/documentation/toggle-publish/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'same-origin', // 确保发送cookie
body: JSON.stringify(updateData)
});
if (response.status === 401) {
// 处理未授权错误
core.showAlert('未登录或会话已过期,请重新登录', 'warning');
setTimeout(() => {
localStorage.removeItem('isLoggedIn');
window.location.reload();
}, 1500);
return;
}
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || '更新文档状态失败');
}
// 更新成功
const updatedDoc = await response.json();
console.log('文档状态更新响应:', updatedDoc);
// 更新本地文档列表
const docIndex = documents.findIndex(d => d.id === id);
if (docIndex >= 0) {
documents[docIndex].published = updatedDoc.published;
this.renderDocumentList();
}
core.showAlert('文档状态已更新', 'success');
} catch (error) {
console.error('更改发布状态失败:', error);
core.showAlert('更改发布状态失败: ' + error.message, 'error');
} finally {
core.hideLoading();
}
}
};
// 全局公开文档管理模块
window.documentManager = documentManager;
/**
* 显示指定文档的内容
* @param {string} docId 文档ID
*/
async function showDocument(docId) {
try {
console.log('正在获取文档内容ID:', docId);
// 显示加载状态
const documentContent = document.getElementById('documentContent');
if (documentContent) {
documentContent.innerHTML = '<div class="loading-container"><i class="fas fa-spinner fa-spin"></i> 正在加载文档内容...</div>';
}
// 获取文档内容
const response = await fetch(`/api/documentation/${docId}`);
if (!response.ok) {
throw new Error(`获取文档内容失败,状态码: ${response.status}`);
}
const doc = await response.json();
console.log('获取到文档:', doc);
// 更新文档内容区域
if (documentContent) {
if (doc.content) {
// 使用marked渲染markdown内容
documentContent.innerHTML = `
<h1>${doc.title || '无标题'}</h1>
${doc.lastUpdated ? `<div class="doc-meta">最后更新: ${new Date(doc.lastUpdated).toLocaleDateString('zh-CN')}</div>` : ''}
<div class="doc-content">${window.marked ? marked.parse(doc.content) : doc.content}</div>
`;
} else {
documentContent.innerHTML = `
<h1>${doc.title || '无标题'}</h1>
<div class="empty-content">
<i class="fas fa-file-alt fa-3x"></i>
<p>该文档暂无内容</p>
</div>
`;
}
} else {
console.error('找不到文档内容容器ID: documentContent');
}
// 高亮当前选中的文档
highlightSelectedDocument(docId);
} catch (error) {
console.error('获取文档内容失败:', error);
// 显示错误信息
const documentContent = document.getElementById('documentContent');
if (documentContent) {
documentContent.innerHTML = `
<div class="error-container">
<i class="fas fa-exclamation-triangle fa-3x"></i>
<h2>加载失败</h2>
<p>无法获取文档内容: ${error.message}</p>
<button class="btn btn-retry" onclick="showDocument('${docId}')">重试</button>
</div>
`;
}
}
}
/**
* 高亮选中的文档
* @param {string} docId 文档ID
*/
function highlightSelectedDocument(docId) {
// 移除所有高亮
const docLinks = document.querySelectorAll('.doc-list .doc-item');
docLinks.forEach(link => link.classList.remove('active'));
// 添加当前高亮
const selectedLink = document.querySelector(`.doc-list .doc-item[data-id="${docId}"]`);
if (selectedLink) {
selectedLink.classList.add('active');
// 确保选中项可见
selectedLink.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}

View File

@@ -0,0 +1,85 @@
// 客户端错误收集器
(function() {
// 保存原始控制台方法
const originalConsoleError = console.error;
// 重写console.error以捕获错误
console.error = function(...args) {
// 调用原始方法
originalConsoleError.apply(console, args);
// 提取错误信息
const errorMessage = args.map(arg => {
if (arg instanceof Error) {
return arg.stack || arg.message;
} else if (typeof arg === 'object') {
try {
return JSON.stringify(arg);
} catch (e) {
return String(arg);
}
} else {
return String(arg);
}
}).join(' ');
// 向服务器报告错误
reportErrorToServer({
message: errorMessage,
source: 'console.error',
type: 'console'
});
};
// 全局错误处理
window.addEventListener('error', function(event) {
reportErrorToServer({
message: event.message,
source: event.filename,
lineno: event.lineno,
colno: event.colno,
stack: event.error ? event.error.stack : null,
type: 'uncaught'
});
});
// Promise错误处理
window.addEventListener('unhandledrejection', function(event) {
const message = event.reason instanceof Error
? event.reason.message
: String(event.reason);
const stack = event.reason instanceof Error
? event.reason.stack
: null;
reportErrorToServer({
message: message,
stack: stack,
type: 'promise'
});
});
// 向服务器发送错误报告
function reportErrorToServer(errorData) {
// 添加额外信息
const data = {
...errorData,
userAgent: navigator.userAgent,
page: window.location.href,
timestamp: new Date().toISOString()
};
// 发送错误报告到服务器
fetch('/api/client-error', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
// 使用keepalive以确保在页面卸载时仍能发送
keepalive: true
}).catch(err => {
// 不记录这个错误,避免无限循环
});
}
})();

View File

@@ -0,0 +1,573 @@
/**
* 菜单管理模块 - 管理 data/config.json 中的 menuItems
*/
// 菜单项列表 (从 config.json 读取)
let configMenuItems = [];
let currentConfig = {}; // 保存当前完整的配置
// 创建menuManager对象
const menuManager = {
// 初始化菜单管理
init: async function() {
console.log('初始化菜单管理 (config.json)...');
this.renderMenuTableHeader(); // 渲染表头
await this.loadMenuItems(); // 加载菜单项
return Promise.resolve();
},
// 渲染菜单表格头部 (根据 config.json 结构调整)
renderMenuTableHeader: function() {
const menuTable = document.getElementById('menuTable');
if (!menuTable) return;
const thead = menuTable.querySelector('thead') || document.createElement('thead');
thead.innerHTML = `
<tr>
<th style="width: 5%">#</th>
<th style="width: 25%">文本 (Text)</th>
<th style="width: 40%">链接 (Link)</th>
<th style="width: 10%">新标签页 (New Tab)</th>
<th style="width: 20%">操作</th>
</tr>
`;
if (!menuTable.querySelector('thead')) {
menuTable.appendChild(thead);
}
},
// 加载菜单项 (从 /api/config 获取)
loadMenuItems: async function() {
try {
const menuTableBody = document.getElementById('menuTableBody');
if (menuTableBody) {
menuTableBody.innerHTML = '<tr><td colspan="5" style="text-align: center;"><i class="fas fa-spinner fa-spin"></i> 正在加载菜单项...</td></tr>';
}
const response = await fetch('/api/config'); // 请求配置接口
if (!response.ok) {
throw new Error(`获取配置失败: ${response.statusText || response.status}`);
}
currentConfig = await response.json(); // 保存完整配置
configMenuItems = currentConfig.menuItems || []; // 提取菜单项,如果不存在则为空数组
this.renderMenuItems();
console.log('成功从 /api/config 加载菜单项', configMenuItems);
} catch (error) {
console.error('加载菜单项失败:', error);
const menuTableBody = document.getElementById('menuTableBody');
if (menuTableBody) {
menuTableBody.innerHTML = `
<tr>
<td colspan="5" style="text-align: center; color: #ff4d4f;">
<i class="fas fa-exclamation-circle"></i>
加载菜单项失败: ${error.message}
<button onclick="menuManager.loadMenuItems()" class="retry-btn">
<i class="fas fa-sync"></i> 重试
</button>
</td>
</tr>
`;
}
}
},
// 渲染菜单项 (根据 config.json 结构)
renderMenuItems: function() {
const menuTableBody = document.getElementById('menuTableBody');
if (!menuTableBody) return;
menuTableBody.innerHTML = ''; // 清空现有内容
if (!Array.isArray(configMenuItems)) {
console.error("configMenuItems 不是一个数组:", configMenuItems);
menuTableBody.innerHTML = '<tr><td colspan="5" style="text-align: center; color: #ff4d4f;">菜单数据格式错误</td></tr>';
return;
}
configMenuItems.forEach((item, index) => {
const row = document.createElement('tr');
// 使用 index 作为临时 ID 进行操作
row.innerHTML = `
<td>${index + 1}</td>
<td>${item.text || ''}</td>
<td>${item.link || ''}</td>
<td>${item.newTab ? '是' : '否'}</td>
<td class="action-buttons">
<button class="action-btn edit-btn" title="编辑菜单" onclick="menuManager.editMenuItem(${index})">
<i class="fas fa-edit"></i>
</button>
<button class="action-btn delete-btn" title="删除菜单" onclick="menuManager.deleteMenuItem(${index})">
<i class="fas fa-trash"></i>
</button>
</td>
`;
menuTableBody.appendChild(row);
});
},
// 显示新菜单项行 (调整字段)
showNewMenuItemRow: function() {
const menuTableBody = document.getElementById('menuTableBody');
if (!menuTableBody) return;
const newRow = document.createElement('tr');
newRow.id = 'new-menu-item-row';
newRow.className = 'new-item-row';
newRow.innerHTML = `
<td>#</td>
<td><input type="text" id="new-text" placeholder="菜单文本"></td>
<td><input type="text" id="new-link" placeholder="链接地址"></td>
<td>
<select id="new-newTab">
<option value="false">否</option>
<option value="true">是</option>
</select>
</td>
<td>
<button class="action-btn" onclick="menuManager.saveNewMenuItem()">保存</button>
<button class="action-btn" onclick="menuManager.cancelNewMenuItem()">取消</button>
</td>
`;
menuTableBody.appendChild(newRow);
document.getElementById('new-text').focus();
},
// 保存新菜单项 (更新整个配置)
saveNewMenuItem: async function() {
try {
const text = document.getElementById('new-text').value.trim();
const link = document.getElementById('new-link').value.trim();
const newTab = document.getElementById('new-newTab').value === 'true';
if (!text || !link) {
throw new Error('文本和链接为必填项');
}
const newMenuItem = { text, link, newTab };
// 创建更新后的配置对象
const updatedConfig = {
...currentConfig,
menuItems: [...(currentConfig.menuItems || []), newMenuItem] // 添加新项
};
// 调用API保存整个配置
const response = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedConfig) // 发送更新后的完整配置
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(`保存配置失败: ${errorData.details || response.statusText}`);
}
// 重新加载菜单项以更新视图和 currentConfig
await this.loadMenuItems();
this.cancelNewMenuItem(); // 移除编辑行
core.showAlert('菜单项已添加', 'success');
} catch (error) {
console.error('添加菜单项失败:', error);
core.showAlert('添加菜单项失败: ' + error.message, 'error');
}
},
// 取消新菜单项
cancelNewMenuItem: function() {
const newRow = document.getElementById('new-menu-item-row');
if (newRow) {
newRow.remove();
}
},
// 编辑菜单项 (使用 index 定位)
editMenuItem: function(index) {
const item = configMenuItems[index];
if (!item) {
core.showAlert('找不到指定的菜单项', 'error');
return;
}
Swal.fire({
title: '<div class="edit-title"><i class="fas fa-edit"></i> 编辑菜单项</div>',
html: `
<div class="edit-menu-form">
<div class="form-group">
<label for="edit-text">
<i class="fas fa-font"></i> 菜单文本
</label>
<div class="input-wrapper">
<input type="text" id="edit-text" class="modern-input" value="${item.text || ''}" placeholder="请输入菜单文本">
<span class="input-icon"><i class="fas fa-heading"></i></span>
</div>
<small class="form-hint">菜单项显示的文本,保持简洁明了</small>
</div>
<div class="form-group">
<label for="edit-link">
<i class="fas fa-link"></i> 链接地址
</label>
<div class="input-wrapper">
<input type="text" id="edit-link" class="modern-input" value="${item.link || ''}" placeholder="请输入链接地址">
<span class="input-icon"><i class="fas fa-globe"></i></span>
</div>
<small class="form-hint">完整URL路径例如: https://example.com或相对路径例如: /docs</small>
</div>
<div class="form-group toggle-switch">
<label for="edit-newTab" class="toggle-label-text">
<i class="fas fa-external-link-alt"></i> 在新标签页打开
</label>
<div class="toggle-switch-container">
<input type="checkbox" id="edit-newTab" class="toggle-input" ${item.newTab ? 'checked' : ''}>
<label for="edit-newTab" class="toggle-label"></label>
<span class="toggle-status">${item.newTab ? '是' : '否'}</span>
</div>
</div>
<div class="form-preview">
<div class="preview-title"><i class="fas fa-eye"></i> 预览</div>
<div class="preview-content">
<a href="${item.link || '#'}" class="preview-link" target="${item.newTab ? '_blank' : '_self'}">
<span class="preview-text">${item.text || '菜单项'}</span>
${item.newTab ? '<i class="fas fa-external-link-alt preview-icon"></i>' : ''}
</a>
</div>
</div>
</div>
<style>
.edit-title {
font-size: 1.5rem;
color: #3085d6;
margin-bottom: 10px;
}
.edit-menu-form {
text-align: left;
padding: 0 15px;
}
.form-group {
margin-bottom: 20px;
position: relative;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #444;
font-size: 0.95rem;
}
.input-wrapper {
position: relative;
}
.modern-input {
width: 100%;
padding: 12px 40px 12px 15px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 1rem;
transition: all 0.3s ease;
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
}
.modern-input:focus {
border-color: #3085d6;
box-shadow: 0 0 0 3px rgba(48, 133, 214, 0.2);
outline: none;
}
.input-icon {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
color: #aaa;
}
.form-hint {
display: block;
font-size: 0.8rem;
color: #888;
margin-top: 5px;
font-style: italic;
}
.toggle-switch {
display: flex;
justify-content: space-between;
align-items: center;
padding: 5px 0;
}
.toggle-label-text {
margin-bottom: 0 !important;
}
.toggle-switch-container {
display: flex;
align-items: center;
}
.toggle-input {
display: none;
}
.toggle-label {
display: block;
width: 52px;
height: 26px;
background: #e6e6e6;
border-radius: 13px;
position: relative;
cursor: pointer;
transition: background 0.3s ease;
box-shadow: inset 0 1px 3px rgba(0,0,0,0.1);
}
.toggle-label:after {
content: '';
position: absolute;
top: 3px;
left: 3px;
width: 20px;
height: 20px;
background: white;
border-radius: 50%;
transition: transform 0.3s ease, box-shadow 0.3s ease;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.toggle-input:checked + .toggle-label {
background: #3085d6;
}
.toggle-input:checked + .toggle-label:after {
transform: translateX(26px);
}
.toggle-status {
margin-left: 10px;
font-size: 0.9rem;
color: #666;
min-width: 20px;
}
.form-preview {
margin-top: 25px;
border: 1px dashed #ccc;
border-radius: 8px;
padding: 15px;
background-color: #f9f9f9;
}
.preview-title {
font-size: 0.9rem;
color: #666;
margin-bottom: 10px;
text-align: center;
}
.preview-content {
display: flex;
justify-content: center;
padding: 10px;
background: white;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.preview-link {
display: flex;
align-items: center;
color: #3085d6;
text-decoration: none;
font-weight: 500;
padding: 5px 10px;
border-radius: 4px;
transition: background 0.2s ease;
}
.preview-link:hover {
background: #f0f7ff;
}
.preview-text {
margin-right: 5px;
}
.preview-icon {
font-size: 0.8rem;
opacity: 0.7;
}
</style>
`,
showCancelButton: true,
confirmButtonText: '<i class="fas fa-save"></i> 保存',
cancelButtonText: '<i class="fas fa-times"></i> 取消',
confirmButtonColor: '#3085d6',
cancelButtonColor: '#6c757d',
width: '550px',
focusConfirm: false,
customClass: {
container: 'menu-edit-container',
popup: 'menu-edit-popup',
title: 'menu-edit-title',
confirmButton: 'menu-edit-confirm',
cancelButton: 'menu-edit-cancel'
},
didOpen: () => {
// 添加输入监听,更新预览
const textInput = document.getElementById('edit-text');
const linkInput = document.getElementById('edit-link');
const newTabToggle = document.getElementById('edit-newTab');
const toggleStatus = document.querySelector('.toggle-status');
const previewText = document.querySelector('.preview-text');
const previewLink = document.querySelector('.preview-link');
const previewIcon = document.querySelector('.preview-icon') || document.createElement('i');
if (!previewIcon.classList.contains('fas')) {
previewIcon.className = 'fas fa-external-link-alt preview-icon';
}
const updatePreview = () => {
previewText.textContent = textInput.value || '菜单项';
previewLink.href = linkInput.value || '#';
previewLink.target = newTabToggle.checked ? '_blank' : '_self';
if (newTabToggle.checked) {
if (!previewLink.contains(previewIcon)) {
previewLink.appendChild(previewIcon);
}
} else {
if (previewLink.contains(previewIcon)) {
previewLink.removeChild(previewIcon);
}
}
};
textInput.addEventListener('input', updatePreview);
linkInput.addEventListener('input', updatePreview);
newTabToggle.addEventListener('change', () => {
toggleStatus.textContent = newTabToggle.checked ? '是' : '否';
updatePreview();
});
},
preConfirm: () => {
const text = document.getElementById('edit-text').value.trim();
const link = document.getElementById('edit-link').value.trim();
const newTab = document.getElementById('edit-newTab').checked;
if (!text) {
Swal.showValidationMessage('<i class="fas fa-exclamation-circle"></i> 菜单文本不能为空');
return false;
}
if (!link) {
Swal.showValidationMessage('<i class="fas fa-exclamation-circle"></i> 链接地址不能为空');
return false;
}
return { text, link, newTab };
}
}).then(async (result) => {
if (!result.isConfirmed) return;
try {
// 显示保存中状态
Swal.fire({
title: '保存中...',
html: '<i class="fas fa-spinner fa-spin"></i> 正在保存菜单项',
showConfirmButton: false,
allowOutsideClick: false,
willOpen: () => {
Swal.showLoading();
}
});
// 更新配置中的菜单项
configMenuItems[index] = result.value;
// 创建更新后的配置对象
const updatedConfig = {
...currentConfig,
menuItems: configMenuItems // 更新后的菜单项数组
};
// 调用API保存整个配置
const response = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedConfig) // 发送更新后的完整配置
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(`保存配置失败: ${errorData.details || response.statusText}`);
}
// 重新渲染菜单项
this.renderMenuItems();
// 显示成功消息
Swal.fire({
icon: 'success',
title: '保存成功',
html: '<i class="fas fa-check-circle"></i> 菜单项已更新',
timer: 1500,
showConfirmButton: false
});
} catch (error) {
console.error('更新菜单项失败:', error);
Swal.fire({
icon: 'error',
title: '保存失败',
html: `<i class="fas fa-times-circle"></i> 更新菜单项失败: ${error.message}`,
confirmButtonText: '确定'
});
}
});
},
// 删除菜单项
deleteMenuItem: function(index) {
const item = configMenuItems[index];
if (!item) {
core.showAlert('找不到指定的菜单项', 'error');
return;
}
Swal.fire({
title: '确认删除',
text: `确定要删除菜单项 "${item.text}" 吗?`,
icon: 'warning',
showCancelButton: true,
confirmButtonText: '删除',
cancelButtonText: '取消',
confirmButtonColor: '#d33'
}).then(async (result) => {
if (!result.isConfirmed) return;
try {
// 从菜单项数组中移除指定项
configMenuItems.splice(index, 1);
// 创建更新后的配置对象
const updatedConfig = {
...currentConfig,
menuItems: configMenuItems // 更新后的菜单项数组
};
// 调用API保存整个配置
const response = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedConfig) // 发送更新后的完整配置
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(`保存配置失败: ${errorData.details || response.statusText}`);
}
// 重新渲染菜单项
this.renderMenuItems();
core.showAlert('菜单项已删除', 'success');
} catch (error) {
console.error('删除菜单项失败:', error);
core.showAlert('删除菜单项失败: ' + error.message, 'error');
}
});
}
};
// 全局公开菜单管理模块
window.menuManager = menuManager;

View File

@@ -0,0 +1,93 @@
// 网络测试相关功能
// 创建networkTest对象
const networkTest = {
// 初始化函数
init: function() {
console.log('初始化网络测试模块...');
this.initNetworkTest();
return Promise.resolve();
},
// 初始化网络测试界面
initNetworkTest: function() {
const domainSelect = document.getElementById('domainSelect');
const testType = document.getElementById('testType');
// 填充域名选择器
if (domainSelect) {
domainSelect.innerHTML = `
<option value="">选择预定义域名</option>
<option value="gcr.io">gcr.io</option>
<option value="ghcr.io">ghcr.io</option>
<option value="quay.io">quay.io</option>
<option value="k8s.gcr.io">k8s.gcr.io</option>
<option value="registry.k8s.io">registry.k8s.io</option>
<option value="mcr.microsoft.com">mcr.microsoft.com</option>
<option value="docker.elastic.co">docker.elastic.co</option>
<option value="registry-1.docker.io">registry-1.docker.io</option>
`;
}
// 填充测试类型选择器
if (testType) {
testType.innerHTML = `
<option value="ping">Ping</option>
<option value="traceroute">Traceroute</option>
`;
}
// 绑定测试按钮点击事件
const testButton = document.querySelector('#network-test button');
if (testButton) {
testButton.addEventListener('click', this.runNetworkTest);
}
},
// 运行网络测试
runNetworkTest: function() {
const domain = document.getElementById('domainSelect').value;
const testType = document.getElementById('testType').value;
const resultsDiv = document.getElementById('testResults');
// 验证选择了域名
if (!domain) {
core.showAlert('请选择目标域名', 'error');
return;
}
resultsDiv.innerHTML = '测试中,请稍候...';
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 60000); // 60秒超时
fetch('/api/network-test', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ domain, type: testType }),
signal: controller.signal
})
.then(response => {
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error('网络测试失败');
}
return response.text();
})
.then(result => {
resultsDiv.textContent = result;
})
.catch(error => {
console.error('网络测试出错:', error);
if (error.name === 'AbortError') {
resultsDiv.textContent = '测试超时,请稍后再试';
} else {
resultsDiv.textContent = '测试失败: ' + error.message;
}
});
}
};
// 全局公开网络测试模块
window.networkTest = networkTest;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,260 @@
/**
* 用户中心管理模块
*/
// 获取用户信息
async function getUserInfo() {
try {
// 先检查是否已登录
const sessionResponse = await fetch('/api/check-session');
const sessionData = await sessionResponse.json();
if (!sessionData.authenticated) {
// 用户未登录,不显示错误,静默返回
console.log('用户未登录或会话无效,跳过获取用户信息');
return;
}
// 用户已登录,获取用户信息
console.log('会话有效,尝试获取用户信息...');
const response = await fetch('/api/user-info');
if (!response.ok) {
// 检查是否是认证问题
if (response.status === 401) {
console.log('会话已过期,需要重新登录');
return;
}
throw new Error('获取用户信息失败');
}
const data = await response.json();
console.log('获取到用户信息:', data);
// 更新顶部导航栏的用户名
const currentUsername = document.getElementById('currentUsername');
if (currentUsername) {
currentUsername.textContent = data.username || '未知用户';
}
// 更新统计卡片数据
const loginCountElement = document.getElementById('loginCount');
if (loginCountElement) {
loginCountElement.textContent = data.loginCount || '0';
}
const accountAgeElement = document.getElementById('accountAge');
if (accountAgeElement) {
accountAgeElement.textContent = data.accountAge ? `${data.accountAge}` : '0天';
}
const lastLoginElement = document.getElementById('lastLogin');
if (lastLoginElement) {
let lastLogin = data.lastLogin || '未知';
// 检查是否包含 "今天" 字样,添加样式
if (lastLogin.includes('今天')) {
lastLoginElement.innerHTML = `<span class="today-login">${lastLogin}</span>`;
} else {
lastLoginElement.textContent = lastLogin;
}
}
} catch (error) {
console.error('获取用户信息失败:', error);
// 不显示错误通知,只在控制台记录错误
}
}
// 修改密码
async function changePassword(event) {
if (event) {
event.preventDefault();
}
const form = document.getElementById('changePasswordForm');
const currentPassword = form.querySelector('#ucCurrentPassword').value;
const newPassword = form.querySelector('#ucNewPassword').value;
const confirmPassword = form.querySelector('#ucConfirmPassword').value;
// 验证表单
if (!currentPassword || !newPassword || !confirmPassword) {
return core.showAlert('所有字段都不能为空', 'error');
}
if (newPassword !== confirmPassword) {
return core.showAlert('两次输入的新密码不一致', 'error');
}
// 密码复杂度检查
if (!isPasswordComplex(newPassword)) {
return core.showAlert('密码必须包含至少1个字母、1个数字和1个特殊字符长度在8-16位之间', 'error');
}
// 显示加载状态
const submitButton = form.querySelector('button[type="submit"]');
const originalButtonText = submitButton.innerHTML;
submitButton.disabled = true;
submitButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 提交中...';
try {
const response = await fetch('/api/change-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
currentPassword,
newPassword
})
});
// 无论成功与否,去除加载状态
submitButton.disabled = false;
submitButton.innerHTML = originalButtonText;
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || '修改密码失败');
}
// 清空表单
form.reset();
// 设置倒计时并显示
let countDown = 5;
Swal.fire({
title: '密码修改成功',
html: `您的密码已成功修改,系统将在 <b>${countDown}</b> 秒后自动退出,请使用新密码重新登录。`,
icon: 'success',
timer: countDown * 1000,
timerProgressBar: true,
didOpen: () => {
const content = Swal.getHtmlContainer();
const timerInterval = setInterval(() => {
countDown--;
if (content) {
const b = content.querySelector('b');
if (b) {
b.textContent = countDown > 0 ? countDown : 0;
}
}
if (countDown <= 0) clearInterval(timerInterval);
}, 1000);
},
allowOutsideClick: false, // 禁止外部点击关闭
showConfirmButton: true, // 重新显示确认按钮
confirmButtonText: '确定' // 设置按钮文本
}).then((result) => {
// 当计时器结束或弹窗被关闭时 (包括点击确定按钮)
if (result.dismiss === Swal.DismissReason.timer || result.isConfirmed) {
console.log('计时器结束或手动确认,执行登出');
auth.logout();
}
});
} catch (error) {
console.error('修改密码失败:', error);
core.showAlert('修改密码失败: ' + error.message, 'error');
}
}
// 验证密码复杂度
function isPasswordComplex(password) {
// 至少包含1个字母、1个数字和1个特殊字符长度在8-16位之间
const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)(?=.*[.,\-_+=()[\]{}|\\;:'"<>?/@$!%*#?&])[A-Za-z\d.,\-_+=()[\]{}|\\;:'"<>?/@$!%*#?&]{8,16}$/;
return passwordRegex.test(password);
}
// 检查密码强度
function checkUcPasswordStrength() {
const password = document.getElementById('ucNewPassword').value;
const strengthSpan = document.getElementById('ucPasswordStrength');
if (!password) {
strengthSpan.textContent = '';
return;
}
let strength = 0;
let strengthText = '';
// 长度检查
if (password.length >= 8) strength++;
if (password.length >= 12) strength++;
// 包含字母
if (/[A-Za-z]/.test(password)) strength++;
// 包含数字
if (/\d/.test(password)) strength++;
// 包含特殊字符
if (/[.,\-_+=()[\]{}|\\;:'"<>?/@$!%*#?&]/.test(password)) strength++;
// 根据强度设置文本和颜色
switch(strength) {
case 0:
case 1:
strengthText = '密码强度:非常弱';
strengthSpan.style.color = '#FF4136';
break;
case 2:
strengthText = '密码强度:弱';
strengthSpan.style.color = '#FF851B';
break;
case 3:
strengthText = '密码强度:中';
strengthSpan.style.color = '#FFDC00';
break;
case 4:
strengthText = '密码强度:强';
strengthSpan.style.color = '#2ECC40';
break;
case 5:
strengthText = '密码强度:非常强';
strengthSpan.style.color = '#3D9970';
break;
}
strengthSpan.textContent = strengthText;
}
// 初始化用户中心
function initUserCenter() {
console.log('初始化用户中心');
// 获取用户信息
getUserInfo();
// 为用户中心按钮添加事件
const userCenterBtn = document.getElementById('userCenterBtn');
if (userCenterBtn) {
userCenterBtn.addEventListener('click', () => {
core.showSection('user-center');
});
}
}
// 加载用户统计信息
function loadUserStats() {
getUserInfo();
}
// 导出模块
const userCenter = {
init: function() {
console.log('初始化用户中心模块...');
// 可以在这里调用初始化逻辑,也可以延迟到需要时调用
return Promise.resolve(); // 返回一个已解决的 Promise保持与其他模块一致
},
getUserInfo,
changePassword,
checkUcPasswordStrength,
initUserCenter,
loadUserStats,
isPasswordComplex
};
// 页面加载完成后初始化
document.addEventListener('DOMContentLoaded', initUserCenter);
// 全局公开用户中心模块
window.userCenter = userCenter;

View File

@@ -0,0 +1,224 @@
const fs = require('fs');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
// 文档存储目录
const DOCUMENTATION_DIR = path.join(__dirname, '..', 'data', 'documentation');
/**
* 确保文档目录存在
*/
async function ensureDocumentationDirExists() {
if (!fs.existsSync(DOCUMENTATION_DIR)) {
await fs.promises.mkdir(DOCUMENTATION_DIR, { recursive: true });
console.log(`创建文档目录: ${DOCUMENTATION_DIR}`);
}
}
/**
* 获取文档列表
* @returns {Promise<Array>} 文档列表
*/
async function getDocumentList() {
try {
await ensureDocumentationDirExists();
// 检查索引文件是否存在
const indexPath = path.join(DOCUMENTATION_DIR, 'index.json');
if (!fs.existsSync(indexPath)) {
// 创建空索引,不再添加默认文档
await fs.promises.writeFile(indexPath, JSON.stringify([]), 'utf8');
console.log('创建了空的文档索引文件');
return [];
}
// 读取索引文件
const data = await fs.promises.readFile(indexPath, 'utf8');
return JSON.parse(data || '[]');
} catch (error) {
console.error('获取文档列表失败:', error);
return [];
}
}
/**
* 保存文档列表
* @param {Array} docList 文档列表
*/
async function saveDocumentList(docList) {
try {
await ensureDocumentationDirExists();
const indexPath = path.join(DOCUMENTATION_DIR, 'index.json');
await fs.promises.writeFile(indexPath, JSON.stringify(docList, null, 2), 'utf8');
console.log('文档列表已更新');
} catch (error) {
console.error('保存文档列表失败:', error);
throw error;
}
}
/**
* 获取单个文档的内容
* @param {string} docId 文档ID
* @returns {Promise<string>} 文档内容
*/
async function getDocumentContent(docId) {
try {
console.log(`尝试获取文档内容ID: ${docId}`);
// 确保文档目录存在
await ensureDocumentationDirExists();
// 获取文档列表
const docList = await getDocumentList();
const doc = docList.find(doc => doc.id === docId || doc._id === docId);
if (!doc) {
throw new Error(`文档不存在ID: ${docId}`);
}
// 构建文档路径
const docPath = path.join(DOCUMENTATION_DIR, `${docId}.md`);
// 检查文件是否存在
if (!fs.existsSync(docPath)) {
return ''; // 文件不存在,返回空内容
}
// 读取文档内容
const content = await fs.promises.readFile(docPath, 'utf8');
console.log(`成功读取文档内容ID: ${docId}, 内容长度: ${content.length}`);
return content;
} catch (error) {
console.error(`获取文档内容失败ID: ${docId}`, error);
throw error;
}
}
/**
* 创建或更新文档
* @param {Object} doc 文档对象
* @param {string} content 文档内容
* @returns {Promise<Object>} 保存后的文档
*/
async function saveDocument(doc, content) {
try {
await ensureDocumentationDirExists();
// 获取现有文档列表
const docList = await getDocumentList();
// 为新文档生成ID
if (!doc.id) {
doc.id = uuidv4();
}
// 更新文档元数据
doc.lastUpdated = new Date().toISOString();
// 查找现有文档索引
const existingIndex = docList.findIndex(item => item.id === doc.id);
if (existingIndex >= 0) {
// 更新现有文档
docList[existingIndex] = { ...docList[existingIndex], ...doc };
} else {
// 添加新文档
docList.push(doc);
}
// 保存文档内容
const docPath = path.join(DOCUMENTATION_DIR, `${doc.id}.md`);
await fs.promises.writeFile(docPath, content || '', 'utf8');
// 更新文档列表
await saveDocumentList(docList);
return doc;
} catch (error) {
console.error('保存文档失败:', error);
throw error;
}
}
/**
* 删除文档
* @param {string} docId 文档ID
* @returns {Promise<boolean>} 删除结果
*/
async function deleteDocument(docId) {
try {
await ensureDocumentationDirExists();
// 获取现有文档列表
const docList = await getDocumentList();
// 查找文档索引
const existingIndex = docList.findIndex(doc => doc.id === docId);
if (existingIndex === -1) {
return false; // 文档不存在
}
// 从列表中移除
docList.splice(existingIndex, 1);
// 删除文档文件
const docPath = path.join(DOCUMENTATION_DIR, `${docId}.md`);
if (fs.existsSync(docPath)) {
await fs.promises.unlink(docPath);
}
// 更新文档列表
await saveDocumentList(docList);
return true;
} catch (error) {
console.error('删除文档失败:', error);
throw error;
}
}
/**
* 发布或取消发布文档
* @param {string} docId 文档ID
* @param {boolean} publishState 发布状态
* @returns {Promise<Object>} 更新后的文档
*/
async function togglePublishState(docId, publishState) {
try {
// 获取现有文档列表
const docList = await getDocumentList();
// 查找文档索引
const existingIndex = docList.findIndex(doc => doc.id === docId);
if (existingIndex === -1) {
throw new Error('文档不存在');
}
// 更新发布状态
docList[existingIndex].published = !!publishState;
docList[existingIndex].lastUpdated = new Date().toISOString();
// 更新文档列表
await saveDocumentList(docList);
return docList[existingIndex];
} catch (error) {
console.error('更新文档发布状态失败:', error);
throw error;
}
}
module.exports = {
ensureDocumentationDirExists,
getDocumentList,
saveDocumentList,
getDocumentContent,
saveDocument,
deleteDocument,
togglePublishState
};

View File

@@ -4,7 +4,7 @@
--primary-color: #3D7CF4;
--primary-light: #5D95FD;
--primary-dark: #2F62C9;
--primary-dark-color: #2c5282; /* 深蓝色,可以根据您的主题调整 */
/* 辅助色 */
--secondary-color: #FF6B6B;
--secondary-light: #FF8E8E;
@@ -749,34 +749,169 @@
}
#documentationText {
padding: 0 1rem;
padding: 0.5rem 1.5rem; /* 增加左右内边距 */
max-width: 900px; /* 限制最大宽度以提高可读性 */
/* margin-left: auto; 移除左边距自动 */
/* margin-right: auto; 移除右边距自动 */
}
#documentationText h2 {
font-size: 1.8rem;
margin: 0 0 1.5rem 0;
color: var(--text-primary);
font-size: 1.8em;
margin-top: 2.5em;
margin-bottom: 1em;
border-bottom: 1px solid #eaecef;
padding-bottom: 0.4em;
font-weight: 600;
}
#documentationText p {
line-height: 1.7;
color: var(--text-secondary);
margin-bottom: 1.2rem;
margin-bottom: 1.5rem; /* 增大段落间距 */
font-size: 1.05rem; /* 稍微增大正文字号 */
}
#documentationText ul, #documentationText ol {
color: var(--text-secondary);
line-height: 1.7;
padding-left: 1.5rem;
padding-left: 1.8em; /* 调整缩进 */
margin-bottom: 1.5rem;
}
#documentationText li {
margin-bottom: 0.5rem;
margin-bottom: 0.6rem;
}
#documentationText pre {
margin: 1.5rem 0;
background-color: #1F2937; /* 深色背景 */
color: #F3F4F6; /* 浅色文字 */
padding: 1.2rem 1.5rem; /* 调整内边距 */
border-radius: var(--radius-md);
overflow-x: auto;
margin: 1.8rem 0; /* 增加垂直外边距 */
line-height: 1.6; /* 调整行高 */
border: 1px solid #374151; /* 深色边框 */
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace; /* 使用适合编程的字体 */
font-size: 0.95rem; /* 标准化字体大小 */
position: relative; /* 为复制按钮定位 */
}
.doc-content pre::before {
content: ''; /* 模拟终端窗口的顶部栏 */
display: block;
height: 28px; /* 顶部栏高度 */
background-color: #111827; /* 顶部栏颜色 */
border-top-left-radius: var(--radius-md);
border-top-right-radius: var(--radius-md);
margin: -1.2rem -1.5rem 1rem -1.5rem; /* 定位和添加下方间距 */
position: relative;
}
/* 模拟窗口按钮 */
.doc-content pre::after {
content: '';
position: absolute;
top: 8px;
left: 15px;
width: 12px;
height: 12px;
background-color: #FF5F57;
border-radius: 50%;
box-shadow: 20px 0 #FEBC2E, 40px 0 #28C840;
}
.doc-content pre code {
display: block; /* 确保代码块充满 pre */
background-color: transparent;
padding: 0;
margin: 0;
color: inherit;
border-radius: 0;
border: none;
line-height: inherit;
font-family: inherit;
white-space: pre; /* 保留空格和换行 */
font-size: inherit; /* 继承 pre 的字号 */
}
/* 行内代码样式 */
.doc-content code {
font-family: 'Fira Code', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
font-size: 0.9em; /* 调整大小 */
background-color: rgba(61, 124, 244, 0.1); /* 更柔和的背景 */
padding: 0.25em 0.5em;
margin: 0 0.1em;
border-radius: 4px;
color: #2c5282; /* 主色调的深色 */
border: 1px solid rgba(61, 124, 244, 0.2);
vertical-align: middle; /* 垂直对齐 */
}
/* 链接样式 */
.doc-content a {
color: var(--primary-dark); /* 使用更深的蓝色 */
text-decoration: underline;
text-decoration-color: rgba(61, 124, 244, 0.4);
transition: all 0.2s ease;
}
.doc-content a:hover {
color: var(--primary-color);
text-decoration-color: var(--primary-color);
background-color: rgba(61, 124, 244, 0.05);
}
/* 引用块样式 */
.doc-content blockquote {
margin: 2em 0;
padding: 1em 1.5em;
color: #555;
border-left: 4px solid var(--primary-light);
background-color: #f8faff; /* 淡蓝色背景 */
border-radius: var(--radius-sm);
}
.doc-content blockquote p:last-child {
margin-bottom: 0;
}
/* 表格样式 */
.doc-content table {
border-collapse: separate; /* 使用 separate 以应用圆角 */
border-spacing: 0;
margin: 1.8rem 0;
width: 100%;
border: 1px solid #e2e8f0;
border-radius: var(--radius-md);
overflow: hidden; /* 应用圆角 */
}
.doc-content th,
.doc-content td {
border-bottom: 1px solid #e2e8f0;
padding: 0.8em 1.2em;
text-align: left;
}
.doc-content tr:last-child td {
border-bottom: none;
}
.doc-content th {
font-weight: 600;
background-color: #f7f9fc; /* 更浅的表头背景 */
color: #4a5568;
}
.doc-content tr:nth-child(even) td {
background-color: #fafcff; /* 斑马纹 */
}
/* 元数据 (更新时间) 样式 */
.doc-meta {
font-size: 0.9em;
color: #888;
text-align: right; /* 右对齐 */
margin-top: 0rem; /* 调整与内容的距离 */
padding-top: 0.5rem; /* 增加顶部内边距 */
border-top: 1px dashed #eee; /* 放到顶部 */
clear: both; /* 确保在内容下方 */
}
/* 加载中和消息提示 */
@@ -2006,3 +2141,637 @@
font-size: 1.1rem;
text-align: center;
}
/* 登录页面特定样式 - 确保按钮正确显示 */
.login-container .captcha-area {
display: flex !important;
align-items: center !important;
justify-content: space-between !important;
gap: 10px !important;
width: 100% !important;
}
.login-container #captcha {
flex: 1.5 !important;
min-width: 0 !important;
margin: 0 !important;
}
.login-container #captchaText {
flex: 1 !important;
min-width: 80px !important;
height: 44px !important;
text-align: center !important;
line-height: 44px !important;
background-color: #f5f7fa !important;
border: 1px solid #ddd !important;
border-radius: 5px !important;
margin: 0 !important;
}
.login-container .btn-login {
flex: 1 !important;
min-width: 80px !important;
height: 44px !important;
margin: 0 !important;
white-space: nowrap !important;
width: auto !important;
background-color: var(--primary-color) !important;
color: white !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
/* 处理登录按钮在hover时的行为 */
.login-container .btn-login:hover {
background-color: var(--primary-dark) !important;
transform: none !important; /* 防止按钮hover时的上移效果导致布局变化 */
}
/* 确保登录表单中的其他按钮不受影响 */
.login-container button:not(.btn-login) {
width: 100%;
}
/* 修复在移动设备上的显示 */
@media (max-width: 480px) {
.login-container .captcha-area {
flex-wrap: wrap !important;
}
.login-container #captcha {
flex: 100% !important;
margin-bottom: 10px !important;
}
.login-container #captchaText {
flex: 1 !important;
}
.login-container .btn-login {
flex: 1 !important;
}
}
/* 登录按钮样式 */
#loginButton {
display: block !important;
width: 100% !important;
padding: 10px !important;
background-color: var(--primary-color) !important;
color: white !important;
border: none !important;
border-radius: 5px !important;
cursor: pointer !important;
font-size: 16px !important;
margin-top: 15px !important;
}
#loginButton:hover {
background-color: var(--primary-dark-color) !important;
}
.login-form button {
background-color: var(--primary-color) !important;
color: white !important;
}
.login-form button:hover {
background-color: var(--primary-dark-color) !important;
color: white !important;
}
.login-modal .login-content .login-form #loginButton:hover {
background-color: var(--primary-dark-color) !important;
color: white !important;
}
/* ======================
文档内容样式 (Doc Content) - 优化版
====================== */
/* 文档文本容器整体调整 */
#documentationText {
padding: 0.5rem 1.5rem; /* 增加左右内边距 */
max-width: 900px; /* 限制最大宽度以提高可读性 */
/* margin-left: auto; 移除左边距自动 */
/* margin-right: auto; 移除右边距自动 */
}
.doc-content {
font-family: 'Georgia', 'Times New Roman', 'Source Han Serif CN', 'Songti SC', serif; /* 使用更适合阅读的衬线字体 */
line-height: 1.8; /* 增加行高 */
color: #333;
padding-top: 0; /* 移除顶部padding让标题控制间距 */
margin-bottom: 2rem; /* 内容和元数据之间的间距 */
}
/* 标题样式调整 */
.doc-content h1:first-of-type, /* 优先将第一个h1作为主标题 */
.doc-content h2:first-of-type,
.doc-content h3:first-of-type {
margin-top: 0; /* 移除主标题上方的间距 */
margin-bottom: 1rem; /* 主标题下方间距 */
padding-bottom: 0.5rem; /* 标题下划线间距 */
border-bottom: 2px solid var(--primary-color); /* 强调主标题下划线 */
font-size: 2.5em; /* 增大主标题字号 */
font-weight: 700;
line-height: 1.2;
}
.doc-content h2 {
font-size: 1.8em;
margin-top: 2.5em;
margin-bottom: 1em;
border-bottom: 1px solid #eaecef;
padding-bottom: 0.4em;
font-weight: 600;
}
.doc-content h3 {
font-size: 1.5em;
margin-top: 2em;
margin-bottom: 0.8em;
font-weight: 600;
}
.doc-content h4 {
font-size: 1.25em;
margin-top: 1.8em;
margin-bottom: 0.7em;
font-weight: 600;
color: #444;
}
/* 段落样式 */
.doc-content p {
margin-bottom: 1.5rem; /* 增大段落间距 */
font-size: 1.05rem; /* 稍微增大正文字号 */
}
/* 列表样式 */
.doc-content ul,
.doc-content ol {
padding-left: 1.8em; /* 调整缩进 */
margin-bottom: 1.5rem;
}
.doc-content li {
margin-bottom: 0.6rem;
}
/* 元数据 (更新时间) 样式 */
.doc-meta {
font-size: 0.9em;
color: #888;
text-align: right; /* 右对齐 */
margin-top: 0rem; /* 调整与内容的距离 */
padding-top: 0.5rem; /* 增加顶部内边距 */
border-top: 1px dashed #eee; /* 放到顶部 */
clear: both; /* 确保在内容下方 */
}
/* 代码块样式统一 */
.doc-content pre {
background-color: #1F2937; /* 深色背景 */
color: #F3F4F6; /* 浅色文字 */
padding: 1.2rem 1.5rem; /* 调整内边距 */
border-radius: var(--radius-md);
overflow-x: auto;
margin: 1.8rem 0; /* 增加垂直外边距 */
line-height: 1.6; /* 调整行高 */
border: 1px solid #374151; /* 深色边框 */
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace; /* 使用适合编程的字体 */
font-size: 0.95rem; /* 标准化字体大小 */
position: relative; /* 为复制按钮定位 */
}
.doc-content pre::before {
content: ''; /* 模拟终端窗口的顶部栏 */
display: block;
height: 28px; /* 顶部栏高度 */
background-color: #111827; /* 顶部栏颜色 */
border-top-left-radius: var(--radius-md);
border-top-right-radius: var(--radius-md);
margin: -1.2rem -1.5rem 1rem -1.5rem; /* 定位和添加下方间距 */
position: relative;
}
/* 模拟窗口按钮 */
.doc-content pre::after {
content: '';
position: absolute;
top: 8px;
left: 15px;
width: 12px;
height: 12px;
background-color: #FF5F57;
border-radius: 50%;
box-shadow: 20px 0 #FEBC2E, 40px 0 #28C840;
}
.doc-content pre code {
display: block; /* 确保代码块充满 pre */
background-color: transparent;
padding: 0;
margin: 0;
color: inherit;
border-radius: 0;
border: none;
line-height: inherit;
font-family: inherit;
white-space: pre; /* 保留空格和换行 */
font-size: inherit; /* 继承 pre 的字号 */
}
/* 行内代码样式 */
.doc-content code {
font-family: 'Fira Code', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
font-size: 0.9em; /* 调整大小 */
background-color: rgba(61, 124, 244, 0.1); /* 更柔和的背景 */
padding: 0.25em 0.5em;
margin: 0 0.1em;
border-radius: 4px;
color: #2c5282; /* 主色调的深色 */
border: 1px solid rgba(61, 124, 244, 0.2);
vertical-align: middle; /* 垂直对齐 */
}
/* 链接样式 */
.doc-content a {
color: var(--primary-dark); /* 使用更深的蓝色 */
text-decoration: underline;
text-decoration-color: rgba(61, 124, 244, 0.4);
transition: all 0.2s ease;
}
.doc-content a:hover {
color: var(--primary-color);
text-decoration-color: var(--primary-color);
background-color: rgba(61, 124, 244, 0.05);
}
/* 引用块样式 */
.doc-content blockquote {
margin: 2em 0;
padding: 1em 1.5em;
color: #555;
border-left: 4px solid var(--primary-light);
background-color: #f8faff; /* 淡蓝色背景 */
border-radius: var(--radius-sm);
}
.doc-content blockquote p:last-child {
margin-bottom: 0;
}
/* 表格样式 */
.doc-content table {
border-collapse: separate; /* 使用 separate 以应用圆角 */
border-spacing: 0;
margin: 1.8rem 0;
width: 100%;
border: 1px solid #e2e8f0;
border-radius: var(--radius-md);
overflow: hidden; /* 应用圆角 */
}
.doc-content th,
.doc-content td {
border-bottom: 1px solid #e2e8f0;
padding: 0.8em 1.2em;
text-align: left;
}
.doc-content tr:last-child td {
border-bottom: none;
}
.doc-content th {
font-weight: 600;
background-color: #f7f9fc; /* 更浅的表头背景 */
color: #4a5568;
}
.doc-content tr:nth-child(even) td {
background-color: #fafcff; /* 斑马纹 */
}
/* 提示消息增强样式 - 修改为居中模态样式 */
.toast-notification {
position: fixed;
/* top: 20px; */ /* 移除顶部定位 */
top: 50%; /* 垂直居中 */
left: 50%;
transform: translate(-50%, -50%); /* 水平垂直居中 */
background-color: white; /* 使用白色背景更像弹窗 */
color: var(--text-primary);
padding: 2rem 2.5rem; /* 增加内边距 */
border-radius: var(--radius-lg); /* 更大的圆角 */
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15); /* 更明显的阴影 */
z-index: 1100;
/* animation: fadeIn 0.3s ease-in; */ /* 可以保留或换成缩放动画 */
animation: fadeInScale 0.3s ease-out; /* 使用新的缩放动画 */
text-align: center;
min-width: 300px; /* 设置最小宽度 */
max-width: 90%;
display: flex;
flex-direction: column; /* 让图标和文字垂直排列 */
align-items: center;
gap: 1rem; /* 图标和文字之间的间距 */
border-left: none; /* 移除之前的左边框 */
border-top: 5px solid var(--info-color); /* 使用顶部边框指示类型 */
}
/* 不同类型的顶部边框颜色 */
.toast-notification.error {
border-top-color: var(--danger-color);
}
.toast-notification.success {
border-top-color: var(--success-color);
}
.toast-notification.info {
border-top-color: var(--info-color);
}
/* 图标样式 */
.toast-notification i {
font-size: 2.5rem; /* 增大图标 */
margin-bottom: 0.5rem; /* 图标下方间距 */
}
.toast-notification.error i {
color: var(--danger-color);
}
.toast-notification.success i {
color: var(--success-color);
}
.toast-notification.info i {
color: var(--info-color);
}
/* 消息文本样式 */
.toast-notification span {
font-size: 1.1rem;
line-height: 1.5;
}
/* 淡出动画 */
.toast-notification.fade-out {
opacity: 0;
transform: translate(-50%, -50%) scale(0.9); /* 淡出时缩小 */
transition: opacity 0.3s ease-out, transform 0.3s ease-out;
}
/* 新增:淡入和缩放动画 */
@keyframes fadeInScale {
from {
opacity: 0;
transform: translate(-50%, -50%) scale(0.8);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
/* ... 其他样式 ... */
/* 表格通用样式 */
.admin-container table {
/* ... (现有表格样式) ... */
}
/* Docker 状态表格特定样式 */
#dockerStatusTable {
table-layout: fixed; /* 固定表格布局,让列宽设定生效 */
width: 100%;
border-collapse: collapse; /* 合并边框 */
margin-top: 1rem; /* 与上方元素保持距离 */
}
#dockerStatusTable th,
#dockerStatusTable td {
padding: 0.8rem 1rem; /* 统一内边距 */
border: 1px solid var(--border-light, #e5e7eb); /* 添加边框 */
vertical-align: middle;
font-size: 0.9rem; /* 稍小字体 */
}
#dockerStatusTable thead th {
background-color: var(--table-header-bg, #f9fafb); /* 表头背景色 */
color: var(--text-secondary, #6b7280);
font-weight: 600; /* 表头字体加粗 */
position: sticky; /* 尝试粘性定位,如果表格滚动 */
top: 0; /* 配合 sticky */
z-index: 1; /* 确保表头在加载提示之上 */
}
/* 为 ID 列设置较窄宽度 */
#dockerStatusTable th:nth-child(1),
#dockerStatusTable td:nth-child(1) {
width: 120px; /* 或 10% */
}
/* 为 名称 列设置最大宽度和文本溢出处理 */
#dockerStatusTable th:nth-child(2),
#dockerStatusTable td:nth-child(2) {
max-width: 200px; /* 根据需要调整 */
width: 25%; /* 尝试百分比宽度 */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 为 镜像 列设置最大宽度和文本溢出处理 */
#dockerStatusTable th:nth-child(3),
#dockerStatusTable td:nth-child(3) {
max-width: 300px; /* 可以比名称宽一些 */
width: 35%; /* 尝试百分比宽度 */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 为 状态 列设置宽度 */
#dockerStatusTable th:nth-child(4),
#dockerStatusTable td:nth-child(4) {
width: 100px; /* 或 10% */
text-align: center; /* 状态居中 */
}
/* 为 操作 列设置宽度 */
#dockerStatusTable th:nth-child(5),
#dockerStatusTable td:nth-child(5) {
width: 150px; /* 或 20% */
text-align: center; /* 操作按钮居中 */
}
/* 确保 title 属性的提示能正常显示 */
#dockerStatusTable td:nth-child(2),
#dockerStatusTable td:nth-child(3) {
cursor: default; /* 或 help提示用户悬停可查看完整信息 */
}
/* 加载提示行样式调整 */
#dockerStatusTable .loading-container td {
background-color: rgba(255, 255, 255, 0.8); /* 半透明背景,避免完全遮挡 */
padding: 2rem 0; /* 增加垂直内边距 */
text-align: center;
color: var(--text-secondary);
}
/* --- 新增:操作下拉菜单样式 --- */
.action-cell {
position: relative; /* 确保下拉菜单相对于单元格定位 */
text-align: center; /* 让按钮居中 */
}
.action-dropdown .dropdown-toggle {
padding: 0.3rem 0.6rem;
font-size: 0.85rem; /* 按钮字体小一点 */
background-color: var(--container-bg);
border: 1px solid var(--border-color);
color: var(--text-secondary);
}
.action-dropdown .dropdown-toggle:hover,
.action-dropdown .dropdown-toggle:focus {
background-color: var(--background-color);
border-color: var(--border-dark);
color: var(--text-primary);
}
.action-dropdown .dropdown-menu {
min-width: 160px; /* 设置最小宽度 */
box-shadow: var(--shadow-md);
border: 1px solid var(--border-light);
padding: 0.5rem 0;
margin-top: 0.25rem; /* 微调与按钮的距离 */
}
.action-dropdown .dropdown-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 1.2rem; /* 调整内边距 */
font-size: 0.9rem;
color: var(--text-secondary);
cursor: pointer;
white-space: nowrap;
}
.action-dropdown .dropdown-item:hover {
background-color: var(--background-color);
color: var(--text-primary);
}
.action-dropdown .dropdown-item i {
width: 16px; /* 固定图标宽度 */
text-align: center;
color: var(--text-muted);
transition: color var(--transition-fast);
}
.action-dropdown .dropdown-item:hover i {
color: var(--primary-color);
}
.action-dropdown .dropdown-item.text-danger,
.action-dropdown .dropdown-item.text-danger:hover {
color: var(--danger-color) !important;
}
.action-dropdown .dropdown-item.text-danger i {
color: var(--danger-color) !important;
}
/* --- 结束:操作下拉菜单样式 --- */
/* --- 新增:详情弹窗表格样式 --- */
.details-swal-popup .details-table-container {
max-height: 60vh; /* 限制最大高度,出现滚动条 */
overflow-y: auto;
margin-top: 1rem;
}
.details-swal-popup .details-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.details-swal-popup .details-table thead th {
background-color: var(--table-header-bg, #f9fafb);
color: var(--text-secondary, #6b7280);
font-weight: 600;
text-align: left; /* 确保表头左对齐 */
padding: 0.8rem 1rem;
border-bottom: 2px solid var(--border-dark, #e5e7eb);
position: sticky; /* 表头粘性定位 */
top: 0;
z-index: 1;
}
.details-swal-popup .details-table tbody td {
padding: 0.8rem 1rem;
border-bottom: 1px solid var(--border-light, #e5e7eb);
vertical-align: middle;
color: var(--text-primary);
text-align: left; /* 确保单元格左对齐 */
}
.details-swal-popup .details-table tbody tr:last-child td {
border-bottom: none;
}
.details-swal-popup .details-table tbody tr:hover td {
background-color: var(--background-color, #f8f9fa);
}
.details-swal-popup .details-table .badge {
font-size: 0.8rem; /* 状态徽章字体稍小 */
}
/* --- 结束:详情弹窗表格样式 --- */
/* --- 新增:资源详情表格特殊样式 (两列) --- */
.details-swal-popup .resource-details-table td.label {
font-weight: 600; /* 标签加粗 */
width: 40%; /* 设定标签列宽度 */
color: var(--text-secondary); /* 标签颜色稍浅 */
padding-right: 1.5rem; /* 标签和值之间的距离 */
white-space: nowrap; /* 防止标签换行 */
}
.details-swal-popup .resource-details-table td {
border-bottom: 1px dashed var(--border-light, #e5e7eb); /* 使用虚线分隔行 */
}
/* --- 结束:资源详情表格特殊样式 --- */
/* --- 新增:资源详情类 Excel 表格样式 --- */
.details-swal-popup .resource-details-excel {
table-layout: fixed; /* 固定布局 */
width: 100%;
margin-top: 0; /* 移除与容器的顶部距离 */
}
.details-swal-popup .resource-details-excel thead th {
text-align: center; /* 表头居中 */
white-space: nowrap;
border: 1px solid var(--border-dark, #dee2e6); /* 使用更深的边框 */
padding: 0.6rem 0.5rem; /* 调整内边距 */
}
.details-swal-popup .resource-details-excel tbody td {
text-align: center; /* 数据居中 */
padding: 0.6rem 0.5rem;
border: 1px solid var(--border-color, #dee2e6);
word-break: break-all; /* 允许长内容换行 */
}
/* --- 结束:资源详情类 Excel 表格样式 --- */
/* ... 其他样式 ... */