mirror of
https://github.com/dqzboy/Docker-Proxy.git
synced 2026-01-12 16:25:42 +08:00
feat: 调整数据存储模式为SQLite,优化代码结构
This commit is contained in:
@@ -8,4 +8,4 @@ RUN npm install
|
||||
# 暴露应用程序的端口
|
||||
EXPOSE 3000
|
||||
# 运行应用程序
|
||||
CMD ["node", "server.js"]
|
||||
CMD ["npm", "start"]
|
||||
@@ -150,7 +150,7 @@ proxy:
|
||||
```
|
||||
|
||||
|
||||
#### 13、解决国内服务器上hubcmdui无法使用http代理请求
|
||||
#### 13、[项目已实现]解决国内服务器上hubcmdui无法使用http代理请求
|
||||
简单的讲,需要解决两个问题:
|
||||
1. dns污染,请自行搭建smartdns服务
|
||||
2. 修改axios.get相关代码
|
||||
|
||||
21
hubcmdui/.env.example
Normal file
21
hubcmdui/.env.example
Normal file
@@ -0,0 +1,21 @@
|
||||
# HubCmdUI 环境变量配置示例
|
||||
# 复制此文件为 .env 并修改相应的值
|
||||
|
||||
# HTTP 代理配置 (用于所有出站请求)
|
||||
# HTTP_PROXY=http://proxy.example.com:8080
|
||||
# HTTPS_PROXY=https://proxy.example.com:8080
|
||||
# NO_PROXY=localhost,127.0.0.1,.local
|
||||
|
||||
# 如果代理需要用户名和密码认证
|
||||
# HTTP_PROXY=http://username:password@proxy.example.com:8080
|
||||
# HTTPS_PROXY=https://username:password@proxy.example.com:8080
|
||||
|
||||
|
||||
# 服务器配置
|
||||
PORT=3000
|
||||
|
||||
# 会话密钥
|
||||
SESSION_SECRET=OhTq3faqSKoxbV%NJV
|
||||
|
||||
# 服务器端口
|
||||
PORT=3000
|
||||
@@ -26,22 +26,27 @@
|
||||
|
||||
---
|
||||
|
||||
## 📝 源码构建运行
|
||||
#### 1. 克隆项目
|
||||
```bash
|
||||
git clone git@github.com:dqzboy/Docker-Proxy.git
|
||||
```
|
||||
## 📝 源码运行
|
||||
|
||||
#### 2. 安装依赖
|
||||
```bash
|
||||
# 克隆项目并启动
|
||||
git clone git@github.com:dqzboy/Docker-Proxy.git
|
||||
cd Docker-Proxy/hubcmdui
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
#### 3. 启动服务
|
||||
```bash
|
||||
node server.js
|
||||
```
|
||||
系统会自动检测并完成:
|
||||
- ✅ 依赖包安装(如果需要)
|
||||
- ✅ SQLite数据库初始化(如果需要)
|
||||
- ✅ 启动服务
|
||||
|
||||
|
||||
### 访问系统
|
||||
|
||||
- **主页**: http://localhost:3000
|
||||
- **管理面板**: http://localhost:3000/admin
|
||||
- **默认账户**: root / admin@123
|
||||
|
||||
## 📦 Docker 方式运行
|
||||
|
||||
@@ -52,7 +57,7 @@ docker pull dqzboy/hubcmd-ui:latest
|
||||
|
||||
#### 2. 运行 hubcmd-ui 容器
|
||||
```bash
|
||||
docker run -d -v /var/run/docker.sock:/var/run/docker.sock -v ./data/config:/app/data -v ./data/docs:/app/documentation -v ./data/user/users.json:/app/users.json -p 30080:3000 --name hubcmdui-server dqzboy/hubcmd-ui
|
||||
docker run -d -v /var/run/docker.sock:/var/run/docker.sock -v ./data:/app/data -p 30080:3000 --name hubcmdui-server dqzboy/hubcmd-ui
|
||||
```
|
||||
- `-v` 参数解释:左边是宿主机上的 Docker socket 文件路径,右边是容器内的映射路径
|
||||
|
||||
@@ -71,6 +76,56 @@ docker logs -f [容器ID或名称]
|
||||
|
||||
---
|
||||
|
||||
## 🌐 代理配置
|
||||
|
||||
支持通过环境变量配置 HTTP 代理,用于所有出站网络请求。
|
||||
|
||||
### 环境变量配置
|
||||
|
||||
```bash
|
||||
# HTTP 代理配置
|
||||
export HTTP_PROXY=http://proxy.example.com:8080
|
||||
export HTTPS_PROXY=https://proxy.example.com:8080
|
||||
export NO_PROXY=localhost,127.0.0.1,.local
|
||||
|
||||
# 启动应用
|
||||
npm start
|
||||
```
|
||||
|
||||
### Docker 部署代理配置
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
-e HTTP_PROXY=http://proxy.example.com:8080 \
|
||||
-e HTTPS_PROXY=https://proxy.example.com:8080 \
|
||||
-e NO_PROXY=localhost,127.0.0.1,.local \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ./data:/app/data \
|
||||
-p 30080:3000 \
|
||||
dqzboy/hubcmd-ui
|
||||
```
|
||||
|
||||
### Docker Compose 代理配置
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
hubcmdui:
|
||||
image: dqzboy/hubcmd-ui
|
||||
environment:
|
||||
- HTTP_PROXY=http://proxy.example.com:8080
|
||||
- HTTPS_PROXY=https://proxy.example.com:8080
|
||||
- NO_PROXY=localhost,127.0.0.1,.local
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# SQLite数据库文件
|
||||
- ./data:/app/data
|
||||
ports:
|
||||
- "30080:3000"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UI界面
|
||||
|
||||
- 默认容器监听`3000`端口,映射宿主机端口`30080`
|
||||
@@ -141,6 +196,41 @@ docker logs -f [容器ID或名称]
|
||||
|
||||
---
|
||||
|
||||
## 🚀 系统特性
|
||||
|
||||
### 数据存储优化
|
||||
- **SQLite数据库**: 所有数据统一存储在SQLite数据库中
|
||||
- **Session管理**: 使用数据库存储用户会话,自动清理过期会话
|
||||
- **配置管理**: 系统配置、用户数据、文档内容统一存储
|
||||
- **零文件依赖**: 不再依赖JSON文件存储,简化部署和维护
|
||||
|
||||
### 功能特性
|
||||
- 🔐 **用户认证**: 基于数据库的用户管理系统
|
||||
- ⚙️ **配置管理**: 灵活的系统配置和菜单管理
|
||||
- 📚 **文档系统**: 内置Markdown文档管理
|
||||
- 🔍 **镜像搜索**: Docker Hub镜像搜索和代理
|
||||
- 📊 **系统监控**: 实时系统状态监控
|
||||
- 🎨 **响应式界面**: 现代化的Web管理界面
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
hubcmdui/
|
||||
├── database/ # SQLite数据库相关
|
||||
│ └── database.js # 数据库管理模块
|
||||
├── services/ # 业务服务层
|
||||
│ ├── configServiceDB.js # 配置服务
|
||||
│ ├── userServiceDB.js # 用户服务
|
||||
│ └── documentationServiceDB.js # 文档服务
|
||||
├── routes/ # API路由
|
||||
├── web/ # 前端静态文件
|
||||
├── middleware/ # 中间件
|
||||
└── data/ # 数据目录(SQLite文件)
|
||||
└── app.db # SQLite数据库文件
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🫶 赞助
|
||||
如果你觉得这个项目对你有帮助,请给我点个Star。并且情况允许的话,可以给我一点点支持,总之非常感谢支持😊
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ const { requireLogin, sessionActivity, sanitizeRequestBody, securityHeaders } =
|
||||
console.log(`服务器启动,时间戳: ${global.serverStartTime}`);
|
||||
logger.warn(`服务器启动,时间戳: ${global.serverStartTime}`);
|
||||
|
||||
// 添加 session 文件存储模块 - 先导入session-file-store并创建对象
|
||||
const FileStore = require('session-file-store')(session);
|
||||
// 使用 SQLite 存储 session - 替代文件存储
|
||||
const SQLiteStore = require('connect-sqlite3')(session);
|
||||
|
||||
// 确保目录结构存在
|
||||
ensureDirectoriesExist().catch(err => {
|
||||
@@ -43,7 +43,7 @@ app.use(sessionActivity);
|
||||
app.use(sanitizeRequestBody);
|
||||
app.use(securityHeaders);
|
||||
|
||||
// 会话配置
|
||||
// 会话配置 - 使用SQLite存储
|
||||
app.use(session({
|
||||
secret: process.env.SESSION_SECRET || 'hubcmdui-secret-key',
|
||||
resave: false,
|
||||
@@ -52,9 +52,10 @@ app.use(session({
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 24 * 60 * 60 * 1000 // 24小时
|
||||
},
|
||||
store: new FileStore({
|
||||
path: path.join(__dirname, 'data', 'sessions'),
|
||||
ttl: 86400
|
||||
store: new SQLiteStore({
|
||||
db: 'app.db',
|
||||
dir: path.join(__dirname, 'data'),
|
||||
table: 'sessions'
|
||||
})
|
||||
}));
|
||||
|
||||
@@ -118,23 +119,67 @@ server.listen(PORT, async () => {
|
||||
try {
|
||||
// 确保目录存在
|
||||
await ensureDirectoriesExist();
|
||||
|
||||
// 启动Session清理任务
|
||||
await startSessionCleanupTask();
|
||||
|
||||
logger.success('系统初始化完成');
|
||||
} catch (error) {
|
||||
logger.error('系统初始化失败:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// 注册进程事件处理
|
||||
process.on('SIGINT', () => {
|
||||
// 启动定期清理过期会话的任务
|
||||
async function startSessionCleanupTask() {
|
||||
const database = require('./database/database');
|
||||
|
||||
// 立即清理一次
|
||||
try {
|
||||
await database.cleanExpiredSessions();
|
||||
} catch (error) {
|
||||
logger.error('清理过期会话失败:', error);
|
||||
}
|
||||
|
||||
// 每小时清理一次过期会话
|
||||
setInterval(async () => {
|
||||
try {
|
||||
await database.cleanExpiredSessions();
|
||||
} catch (error) {
|
||||
logger.error('定期清理过期会话失败:', error);
|
||||
}
|
||||
}, 60 * 60 * 1000); // 1小时
|
||||
}
|
||||
|
||||
// 监听进程退出事件,确保数据库连接正确关闭
|
||||
process.on('SIGINT', async () => {
|
||||
logger.info('收到SIGINT信号,正在关闭服务器...');
|
||||
const database = require('./database/database');
|
||||
await database.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
logger.info('收到SIGTERM信号,正在关闭服务器...');
|
||||
const database = require('./database/database');
|
||||
await database.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// 注册进程事件处理 - 优雅关闭
|
||||
process.on('SIGINT', async () => {
|
||||
logger.info('接收到中断信号,正在关闭服务...');
|
||||
const database = require('./database/database');
|
||||
await database.close();
|
||||
server.close(() => {
|
||||
logger.info('服务器已关闭');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
process.on('SIGTERM', async () => {
|
||||
logger.info('接收到终止信号,正在关闭服务...');
|
||||
const database = require('./database/database');
|
||||
await database.close();
|
||||
server.close(() => {
|
||||
logger.info('服务器已关闭');
|
||||
process.exit(0);
|
||||
@@ -169,14 +214,14 @@ function registerRoutes(app) {
|
||||
app.use('/api', authRouter);
|
||||
logger.info('认证路由已注册');
|
||||
|
||||
// 配置路由 - 函数式注册
|
||||
// 配置路由 - 使用 Express Router
|
||||
const configRouter = require('./routes/config');
|
||||
if (typeof configRouter === 'function') {
|
||||
logger.info('配置路由是一个函数,正在注册...');
|
||||
configRouter(app);
|
||||
if (configRouter && typeof configRouter === 'object') {
|
||||
logger.info('配置路由是一个 Router 对象,正在注册...');
|
||||
app.use('/api/config', configRouter);
|
||||
logger.info('配置路由已注册');
|
||||
} else {
|
||||
logger.error('配置路由不是一个函数,无法注册', typeof configRouter);
|
||||
logger.error('配置路由不是一个有效的 Router 对象,无法注册', typeof configRouter);
|
||||
}
|
||||
|
||||
logger.success('✓ 所有路由已注册');
|
||||
|
||||
@@ -25,11 +25,33 @@ process.on('SIGINT', gracefulShutdown);
|
||||
process.on('SIGTERM', gracefulShutdown);
|
||||
|
||||
// 优雅退出函数
|
||||
function gracefulShutdown() {
|
||||
async function gracefulShutdown() {
|
||||
logger.info('接收到退出信号,正在关闭...');
|
||||
|
||||
// 这里可以添加清理代码,如关闭数据库连接等
|
||||
try {
|
||||
// 关闭HTTP代理服务
|
||||
try {
|
||||
const httpProxyService = require('./services/httpProxyService');
|
||||
if (httpProxyService && httpProxyService.isRunning) {
|
||||
logger.info('正在关闭HTTP代理服务...');
|
||||
await httpProxyService.stop();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug('HTTP代理服务未运行,跳过清理');
|
||||
}
|
||||
|
||||
// 关闭数据库连接
|
||||
try {
|
||||
const database = require('./database/database');
|
||||
if (database) {
|
||||
logger.info('正在关闭数据库连接...');
|
||||
await database.close();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug('数据库未连接,跳过清理');
|
||||
}
|
||||
|
||||
// 关闭任何可能的资源
|
||||
try {
|
||||
const docker = require('./services/dockerService').getDockerConnection();
|
||||
|
||||
@@ -202,7 +202,7 @@ module.exports = function(app) {
|
||||
// 文档接口
|
||||
app.get('/api/documentation', async (req, res) => {
|
||||
try {
|
||||
const docService = require('./services/documentationService');
|
||||
const docService = require('./services/documentationServiceDB');
|
||||
const documents = await docService.getPublishedDocuments();
|
||||
res.json(documents);
|
||||
} catch (error) {
|
||||
@@ -324,7 +324,7 @@ module.exports = function(app) {
|
||||
// 获取单个文档接口
|
||||
app.get('/api/documentation/:id', async (req, res) => {
|
||||
try {
|
||||
const docService = require('./services/documentationService');
|
||||
const docService = require('./services/documentationServiceDB');
|
||||
const document = await docService.getDocument(req.params.id);
|
||||
|
||||
// 如果文档不是发布状态,只有已登录用户才能访问
|
||||
@@ -345,7 +345,7 @@ module.exports = function(app) {
|
||||
// 文档列表接口
|
||||
app.get('/api/documentation-list', requireLogin, async (req, res) => {
|
||||
try {
|
||||
const docService = require('./services/documentationService');
|
||||
const docService = require('./services/documentationServiceDB');
|
||||
const documents = await docService.getDocumentationList();
|
||||
res.json(documents);
|
||||
} catch (error) {
|
||||
@@ -586,28 +586,21 @@ module.exports = function(app) {
|
||||
return res.status(401).json({ error: '验证码错误' });
|
||||
}
|
||||
|
||||
const userService = require('./services/userService');
|
||||
const users = await userService.getUsers();
|
||||
const user = users.users.find(u => u.username === username);
|
||||
const userServiceDB = require('./services/userServiceDB');
|
||||
const user = await userServiceDB.validateUser(username, password);
|
||||
|
||||
if (!user) {
|
||||
logger.warn(`User ${username} not found`);
|
||||
return res.status(401).json({ error: '用户名或密码错误' });
|
||||
}
|
||||
|
||||
const bcrypt = require('bcrypt');
|
||||
if (bcrypt.compareSync(password, user.password)) {
|
||||
req.session.user = { username: user.username };
|
||||
req.session.user = { username: user.username };
|
||||
|
||||
// 更新用户登录信息
|
||||
await userService.updateUserLoginInfo(username);
|
||||
// 更新用户登录信息
|
||||
await userServiceDB.updateUserLoginInfo(username);
|
||||
|
||||
logger.info(`User ${username} logged in successfully`);
|
||||
res.json({ success: true });
|
||||
} else {
|
||||
logger.warn(`Login failed for user: ${username}`);
|
||||
res.status(401).json({ error: '用户名或密码错误' });
|
||||
}
|
||||
logger.info(`User ${username} logged in successfully`);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('登录失败:', error);
|
||||
res.status(500).json({ error: '登录处理失败', details: error.message });
|
||||
@@ -781,7 +774,7 @@ module.exports = function(app) {
|
||||
app.get('/api/documents', requireLogin, async (req, res) => {
|
||||
try {
|
||||
logger.info('兼容层处理获取文档列表请求');
|
||||
const docService = require('./services/documentationService');
|
||||
const docService = require('./services/documentationServiceDB');
|
||||
const documents = await docService.getDocumentationList();
|
||||
res.json(documents);
|
||||
} catch (err) {
|
||||
@@ -794,7 +787,7 @@ module.exports = function(app) {
|
||||
app.get('/api/documents/:id', async (req, res) => {
|
||||
try {
|
||||
logger.info(`兼容层处理获取文档请求: ${req.params.id}`);
|
||||
const docService = require('./services/documentationService');
|
||||
const docService = require('./services/documentationServiceDB');
|
||||
const document = await docService.getDocument(req.params.id);
|
||||
|
||||
// 如果文档不是发布状态,只有已登录用户才能访问
|
||||
@@ -817,7 +810,7 @@ module.exports = function(app) {
|
||||
try {
|
||||
logger.info(`兼容层处理更新文档请求: ${req.params.id}`);
|
||||
const { title, content, published } = req.body;
|
||||
const docService = require('./services/documentationService');
|
||||
const docService = require('./services/documentationServiceDB');
|
||||
|
||||
// 检查必需参数
|
||||
if (!title) {
|
||||
@@ -839,7 +832,7 @@ module.exports = function(app) {
|
||||
try {
|
||||
logger.info('兼容层处理创建文档请求');
|
||||
const { title, content, published } = req.body;
|
||||
const docService = require('./services/documentationService');
|
||||
const docService = require('./services/documentationServiceDB');
|
||||
|
||||
// 检查必需参数
|
||||
if (!title) {
|
||||
@@ -861,7 +854,7 @@ module.exports = function(app) {
|
||||
app.delete('/api/documents/:id', requireLogin, async (req, res) => {
|
||||
try {
|
||||
logger.info(`兼容层处理删除文档请求: ${req.params.id}`);
|
||||
const docService = require('./services/documentationService');
|
||||
const docService = require('./services/documentationServiceDB');
|
||||
|
||||
await docService.deleteDocument(req.params.id);
|
||||
res.json({ success: true, message: '文档已删除' });
|
||||
@@ -875,7 +868,7 @@ module.exports = function(app) {
|
||||
app.put('/api/documentation/toggle-publish/:id', requireLogin, async (req, res) => {
|
||||
try {
|
||||
logger.info(`兼容层处理切换文档发布状态请求: ${req.params.id}`);
|
||||
const docService = require('./services/documentationService');
|
||||
const docService = require('./services/documentationServiceDB');
|
||||
|
||||
const result = await docService.toggleDocumentPublish(req.params.id);
|
||||
res.json({
|
||||
@@ -924,8 +917,8 @@ module.exports = function(app) {
|
||||
// 用户信息接口
|
||||
app.get('/api/user-info', requireLogin, async (req, res) => {
|
||||
try {
|
||||
const userService = require('./services/userService');
|
||||
const userStats = await userService.getUserStats(req.session.user.username);
|
||||
const userServiceDB = require('./services/userServiceDB');
|
||||
const userStats = await userServiceDB.getUserStats(req.session.user.username);
|
||||
|
||||
res.json(userStats);
|
||||
} catch (error) {
|
||||
@@ -944,8 +937,8 @@ module.exports = function(app) {
|
||||
}
|
||||
|
||||
try {
|
||||
const userService = require('./services/userService');
|
||||
await userService.changePassword(username, currentPassword, newPassword);
|
||||
const userServiceDB = require('./services/userServiceDB');
|
||||
await userServiceDB.changePassword(username, currentPassword, newPassword);
|
||||
res.json({ success: true, message: '密码修改成功' });
|
||||
} catch (error) {
|
||||
logger.error(`用户 ${username} 修改密码失败:`, error);
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
[]
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"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": "github.dqzboy.Docker-Proxy"
|
||||
}
|
||||
390
hubcmdui/database/database.js
Normal file
390
hubcmdui/database/database.js
Normal file
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* SQLite 数据库管理模块
|
||||
*/
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const logger = require('../logger');
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
// 数据库文件路径
|
||||
const DB_PATH = path.join(__dirname, '../data/app.db');
|
||||
|
||||
class Database {
|
||||
constructor() {
|
||||
this.db = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化数据库连接
|
||||
*/
|
||||
async connect() {
|
||||
try {
|
||||
// 确保数据目录存在
|
||||
const dbDir = path.dirname(DB_PATH);
|
||||
await fs.mkdir(dbDir, { recursive: true });
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db = new sqlite3.Database(DB_PATH, (err) => {
|
||||
if (err) {
|
||||
logger.error('数据库连接失败:', err);
|
||||
reject(err);
|
||||
} else {
|
||||
logger.info('SQLite 数据库连接成功');
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('初始化数据库失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建数据表
|
||||
*/
|
||||
async createTables() {
|
||||
const tables = [
|
||||
// 用户表
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
login_count INTEGER DEFAULT 0,
|
||||
last_login DATETIME
|
||||
)`,
|
||||
|
||||
// 配置表
|
||||
`CREATE TABLE IF NOT EXISTS configs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT UNIQUE NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
type TEXT DEFAULT 'string',
|
||||
description TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
|
||||
// 文档表
|
||||
`CREATE TABLE IF NOT EXISTS documents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
doc_id TEXT UNIQUE NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
published BOOLEAN DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
|
||||
// 系统日志表
|
||||
`CREATE TABLE IF NOT EXISTS system_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
level TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
details TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
|
||||
// Session表 - 用于存储用户会话
|
||||
`CREATE TABLE IF NOT EXISTS sessions (
|
||||
sid TEXT PRIMARY KEY,
|
||||
sess TEXT NOT NULL,
|
||||
expire DATETIME NOT NULL
|
||||
)`,
|
||||
|
||||
// 菜单项表 - 用于存储导航菜单配置
|
||||
`CREATE TABLE IF NOT EXISTS menu_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
text TEXT NOT NULL,
|
||||
link TEXT NOT NULL,
|
||||
new_tab BOOLEAN DEFAULT 0,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
enabled BOOLEAN DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`
|
||||
];
|
||||
|
||||
for (const sql of tables) {
|
||||
await this.run(sql);
|
||||
}
|
||||
|
||||
logger.info('数据表创建完成');
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行SQL语句
|
||||
*/
|
||||
async run(sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(sql, params, function(err) {
|
||||
if (err) {
|
||||
logger.error('SQL执行失败:', err);
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ id: this.lastID, changes: this.changes });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询单条记录
|
||||
*/
|
||||
async get(sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.get(sql, params, (err, row) => {
|
||||
if (err) {
|
||||
logger.error('SQL查询失败:', err);
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(row);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询多条记录
|
||||
*/
|
||||
async all(sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.all(sql, params, (err, rows) => {
|
||||
if (err) {
|
||||
logger.error('SQL查询失败:', err);
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(rows);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 初始化默认管理员用户
|
||||
*/
|
||||
async createDefaultAdmin() {
|
||||
try {
|
||||
const adminUser = await this.get('SELECT id FROM users WHERE username = ?', ['root']);
|
||||
|
||||
if (!adminUser) {
|
||||
const hashedPassword = await bcrypt.hash('admin@123', 10);
|
||||
await this.run(
|
||||
'INSERT INTO users (username, password, created_at, login_count, last_login) VALUES (?, ?, ?, ?, ?)',
|
||||
['root', hashedPassword, new Date().toISOString(), 0, null]
|
||||
);
|
||||
logger.info('默认管理员用户创建成功: root/admin@123');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('创建默认管理员用户失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建默认文档
|
||||
*/
|
||||
async createDefaultDocuments() {
|
||||
try {
|
||||
const docCount = await this.get('SELECT COUNT(*) as count FROM documents');
|
||||
|
||||
if (docCount.count === 0) {
|
||||
const defaultDocs = [
|
||||
{
|
||||
doc_id: 'welcome',
|
||||
title: '欢迎使用 Docker 镜像代理加速系统',
|
||||
content: `## 系统介绍
|
||||
|
||||
这是一个基于 Nginx 的 Docker 镜像代理加速系统,可以帮助您加速 Docker 镜像的下载和部署。
|
||||
|
||||
## 主要功能
|
||||
|
||||
- 🚀 **镜像加速**: 提供多个 Docker 镜像仓库的代理加速
|
||||
- 🔧 **配置管理**: 简单易用的 Web 管理界面
|
||||
- 📊 **监控统计**: 实时监控代理服务状态
|
||||
- 📖 **文档管理**: 内置文档系统,方便管理和分享
|
||||
|
||||
## 快速开始
|
||||
|
||||
1. 访问管理面板进行基础配置
|
||||
2. 配置 Docker 客户端使用代理地址
|
||||
3. 开始享受加速的镜像下载体验
|
||||
|
||||
## 更多信息
|
||||
|
||||
如需更多帮助,请查看项目文档或访问 GitHub 仓库。`,
|
||||
published: 1
|
||||
},
|
||||
{
|
||||
doc_id: 'docker-config',
|
||||
title: 'Docker 客户端配置指南',
|
||||
content: `## 配置说明
|
||||
|
||||
使用本代理服务需要配置 Docker 客户端的镜像仓库地址。
|
||||
|
||||
## Linux/macOS 配置
|
||||
|
||||
编辑或创建 \`/etc/docker/daemon.json\` 文件:
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"registry-mirrors": [
|
||||
"http://your-proxy-domain.com"
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
重启 Docker 服务:
|
||||
\`\`\`bash
|
||||
sudo systemctl restart docker
|
||||
\`\`\`
|
||||
|
||||
## Windows 配置
|
||||
|
||||
在 Docker Desktop 设置中:
|
||||
1. 打开 Settings -> Docker Engine
|
||||
2. 添加配置到 JSON 文件中
|
||||
3. 点击 "Apply & Restart"
|
||||
|
||||
## 验证配置
|
||||
|
||||
运行以下命令验证配置是否生效:
|
||||
\`\`\`bash
|
||||
docker info
|
||||
\`\`\`
|
||||
|
||||
在输出中查看 "Registry Mirrors" 部分。`,
|
||||
published: 1
|
||||
}
|
||||
];
|
||||
|
||||
for (const doc of defaultDocs) {
|
||||
await this.run(
|
||||
'INSERT INTO documents (doc_id, title, content, published, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[doc.doc_id, doc.title, doc.content, doc.published, new Date().toISOString(), new Date().toISOString()]
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('创建默认文档失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查数据库是否已经初始化
|
||||
*/
|
||||
async isInitialized() {
|
||||
try {
|
||||
// 先检查是否有用户表
|
||||
const tableExists = await this.get("SELECT name FROM sqlite_master WHERE type='table' AND name='users'");
|
||||
if (!tableExists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否有初始化标记
|
||||
const configTableExists = await this.get("SELECT name FROM sqlite_master WHERE type='table' AND name='configs'");
|
||||
if (configTableExists) {
|
||||
const initFlag = await this.get('SELECT value FROM configs WHERE key = ?', ['db_initialized']);
|
||||
if (initFlag) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否有用户数据
|
||||
const userCount = await this.get('SELECT COUNT(*) as count FROM users');
|
||||
return userCount && userCount.count > 0;
|
||||
} catch (error) {
|
||||
// 如果查询失败,认为数据库未初始化
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记数据库已初始化
|
||||
*/
|
||||
async markAsInitialized() {
|
||||
try {
|
||||
await this.run(
|
||||
'INSERT OR REPLACE INTO configs (key, value, type, description) VALUES (?, ?, ?, ?)',
|
||||
['db_initialized', 'true', 'boolean', '数据库初始化标记']
|
||||
);
|
||||
logger.info('数据库已标记为已初始化');
|
||||
} catch (error) {
|
||||
logger.error('标记数据库初始化状态失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭数据库连接
|
||||
*/
|
||||
async close() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.db) {
|
||||
this.db.close((err) => {
|
||||
if (err) {
|
||||
logger.error('关闭数据库连接失败:', err);
|
||||
reject(err);
|
||||
} else {
|
||||
logger.info('数据库连接已关闭');
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期的会话
|
||||
*/
|
||||
async cleanExpiredSessions() {
|
||||
try {
|
||||
const result = await this.run(
|
||||
'DELETE FROM sessions WHERE expire < ?',
|
||||
[new Date().toISOString()]
|
||||
);
|
||||
if (result.changes > 0) {
|
||||
logger.info(`清理了 ${result.changes} 个过期会话`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('清理过期会话失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建默认菜单项
|
||||
*/
|
||||
async createDefaultMenuItems() {
|
||||
try {
|
||||
const menuCount = await this.get('SELECT COUNT(*) as count FROM menu_items');
|
||||
|
||||
if (menuCount.count === 0) {
|
||||
const defaultMenuItems = [
|
||||
{ text: '控制台', link: '/admin', new_tab: 0, sort_order: 1 },
|
||||
{ text: '镜像搜索', link: '/', new_tab: 0, sort_order: 2 },
|
||||
{ text: '文档', link: '/docs', new_tab: 0, sort_order: 3 },
|
||||
{ text: 'GitHub', link: 'https://github.com/dqzboy/hubcmdui', new_tab: 1, sort_order: 4 }
|
||||
];
|
||||
|
||||
for (const item of defaultMenuItems) {
|
||||
await this.run(
|
||||
'INSERT INTO menu_items (text, link, new_tab, sort_order, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[item.text, item.link, item.new_tab, item.sort_order, 1, new Date().toISOString(), new Date().toISOString()]
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('创建默认菜单项失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建数据库实例
|
||||
const database = new Database();
|
||||
|
||||
module.exports = database;
|
||||
@@ -4,13 +4,15 @@ services:
|
||||
container_name: hubcmd-ui
|
||||
image: dqzboy/hubcmd-ui:latest
|
||||
restart: always
|
||||
environment:
|
||||
# HTTP代理配置(可选)
|
||||
#- HTTP_PROXY=http://proxy.example.com:8080
|
||||
#- HTTPS_PROXY=https://proxy.example.com:8080
|
||||
#- NO_PROXY=localhost,127.0.0.1,.local
|
||||
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# 配置目录
|
||||
- ./data/config:/app/data
|
||||
# 文档目录
|
||||
- ./data/docs:/app/documentation
|
||||
# 用户数据,需提前在宿主机上创建并把项目users.json内容放入
|
||||
- ./data/user/users.json:/app/users.json
|
||||
# SQLite数据库文件
|
||||
- ./data:/app/data
|
||||
ports:
|
||||
- 30080:3000
|
||||
|
||||
BIN
hubcmdui/documentation/.DS_Store
vendored
BIN
hubcmdui/documentation/.DS_Store
vendored
Binary file not shown.
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"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-05-10T06:21:33.539Z"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"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-05-10T06:21:38.920Z"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"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-05-08T15:16:47.900Z"
|
||||
}
|
||||
@@ -19,14 +19,12 @@ async function ensureDirectoriesExist() {
|
||||
path.join(__dirname, 'logs'),
|
||||
// 图片目录
|
||||
path.join(__dirname, 'web', 'images'),
|
||||
// 数据目录
|
||||
// 数据目录(SQLite数据库文件)
|
||||
path.join(__dirname, 'data'),
|
||||
// 配置目录
|
||||
path.join(__dirname, 'config'),
|
||||
// 临时文件目录
|
||||
path.join(__dirname, 'temp'),
|
||||
// session 目录
|
||||
path.join(__dirname, 'data', 'sessions'),
|
||||
// 文档数据目录
|
||||
path.join(__dirname, 'web', 'data', 'documentation')
|
||||
];
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
"description": "Docker镜像代理加速系统",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"start": "node scripts/auto-setup.js",
|
||||
"start-only": "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 启动服务'"
|
||||
"init": "node scripts/init-complete.js",
|
||||
"setup": "npm install && node scripts/init-complete.js && echo 'SQLite系统安装完成,请使用 npm start 启动服务'"
|
||||
},
|
||||
"keywords": [
|
||||
"docker",
|
||||
@@ -23,13 +24,15 @@
|
||||
"bcrypt": "^5.0.1",
|
||||
"body-parser": "^1.20.0",
|
||||
"chalk": "^4.1.2",
|
||||
"connect-sqlite3": "^0.9.16",
|
||||
"cors": "^2.8.5",
|
||||
"dockerode": "^3.3.4",
|
||||
"editor.md": "^1.5.0",
|
||||
"express": "^4.21.2",
|
||||
"express-session": "^1.18.1",
|
||||
"node-cache": "^5.1.2",
|
||||
"p-limit": "^4.0.0",
|
||||
"session-file-store": "^1.5.0",
|
||||
"sqlite3": "^5.1.7",
|
||||
"systeminformation": "^5.25.11",
|
||||
"validator": "^13.7.0",
|
||||
"ws": "^8.8.1"
|
||||
|
||||
@@ -1,51 +1,48 @@
|
||||
/**
|
||||
* 认证相关路由
|
||||
* 认证相关路由 - 使用SQLite数据库
|
||||
*/
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const bcrypt = require('bcrypt');
|
||||
const userService = require('../services/userService');
|
||||
const userServiceDB = require('../services/userServiceDB');
|
||||
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);
|
||||
// 使用数据库认证
|
||||
const user = await userServiceDB.validateUser(username, password);
|
||||
|
||||
if (!user) {
|
||||
logger.warn(`User ${username} not found`);
|
||||
logger.warn(`Login failed for user: ${username}`);
|
||||
return res.status(401).json({ error: '用户名或密码错误' });
|
||||
}
|
||||
|
||||
if (bcrypt.compareSync(req.body.password, user.password)) {
|
||||
req.session.user = { username: user.username };
|
||||
// 更新登录信息
|
||||
await userServiceDB.updateUserLoginInfo(username);
|
||||
logger.info(`用户 ${username} 登录成功`);
|
||||
|
||||
// 更新用户登录信息
|
||||
await userService.updateUserLoginInfo(username);
|
||||
// 设置会话
|
||||
req.session.user = { username: user.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: '用户名或密码错误' });
|
||||
// 确保服务器启动时间已设置
|
||||
if (!global.serverStartTime) {
|
||||
global.serverStartTime = Date.now();
|
||||
logger.warn(`登录时设置服务器启动时间: ${global.serverStartTime}`);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
serverStartTime: global.serverStartTime
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('登录失败:', error);
|
||||
res.status(500).json({ error: '登录处理失败', details: error.message });
|
||||
@@ -76,16 +73,9 @@ router.post('/change-password', requireLogin, async (req, res) => {
|
||||
}
|
||||
|
||||
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' });
|
||||
}
|
||||
// 使用SQLite数据库服务修改密码
|
||||
await userServiceDB.changePassword(req.session.user.username, currentPassword, newPassword);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('修改密码失败:', error);
|
||||
res.status(500).json({ error: '修改密码失败', details: error.message });
|
||||
@@ -95,8 +85,7 @@ router.post('/change-password', requireLogin, async (req, res) => {
|
||||
// 获取用户信息
|
||||
router.get('/user-info', requireLogin, async (req, res) => {
|
||||
try {
|
||||
const userService = require('../services/userService');
|
||||
const userStats = await userService.getUserStats(req.session.user.username);
|
||||
const userStats = await userServiceDB.getUserStats(req.session.user.username);
|
||||
|
||||
res.json(userStats);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,63 +1,32 @@
|
||||
/**
|
||||
* 配置路由模块
|
||||
* 配置路由模块 - 使用SQLite数据库
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
const configServiceDB = require('../services/configServiceDB');
|
||||
|
||||
// 获取配置
|
||||
router.get('/config', async (req, res) => {
|
||||
try {
|
||||
const config = await ensureConfigFile();
|
||||
res.json(config);
|
||||
const config = await configServiceDB.getConfig();
|
||||
|
||||
// 如果配置为空,使用默认配置
|
||||
if (!config || Object.keys(config).length === 0) {
|
||||
const defaultConfig = configServiceDB.getDefaultConfig();
|
||||
res.json(defaultConfig);
|
||||
} else {
|
||||
// 合并默认配置和数据库配置,数据库配置优先
|
||||
const defaultConfig = configServiceDB.getDefaultConfig();
|
||||
const mergedConfig = { ...defaultConfig, ...config };
|
||||
res.json(mergedConfig);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('获取配置失败:', error);
|
||||
// 即使失败也返回默认配置
|
||||
res.json(DEFAULT_CONFIG);
|
||||
// 只在真正出错时返回默认配置
|
||||
const defaultConfig = configServiceDB.getDefaultConfig();
|
||||
res.json(defaultConfig);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -74,19 +43,8 @@ router.post('/config', async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// 读取现有配置
|
||||
let existingConfig;
|
||||
try {
|
||||
existingConfig = await ensureConfigFile();
|
||||
} catch (error) {
|
||||
existingConfig = DEFAULT_CONFIG;
|
||||
}
|
||||
|
||||
// 合并配置
|
||||
const mergedConfig = { ...existingConfig, ...newConfig };
|
||||
|
||||
// 保存到文件
|
||||
await fs.writeFile(configFilePath, JSON.stringify(mergedConfig, null, 2));
|
||||
// 保存配置到数据库
|
||||
await configServiceDB.saveConfigs(newConfig);
|
||||
|
||||
res.json({ success: true, message: '配置已保存' });
|
||||
} catch (error) {
|
||||
@@ -98,127 +56,70 @@ router.post('/config', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 获取监控配置
|
||||
router.get('/monitoring-config', async (req, res) => {
|
||||
logger.info('收到监控配置请求');
|
||||
|
||||
// 获取配置 - 兼容旧路由
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
logger.info('读取监控配置...');
|
||||
const config = await configService.getConfig();
|
||||
const config = await configServiceDB.getConfig();
|
||||
res.json(config);
|
||||
} catch (error) {
|
||||
logger.error('读取配置失败:', error);
|
||||
const defaultConfig = configServiceDB.getDefaultConfig();
|
||||
res.json(defaultConfig);
|
||||
}
|
||||
});
|
||||
|
||||
if (!config.monitoringConfig) {
|
||||
logger.info('监控配置不存在,创建默认配置');
|
||||
config.monitoringConfig = {
|
||||
notificationType: 'wechat',
|
||||
webhookUrl: '',
|
||||
telegramToken: '',
|
||||
telegramChatId: '',
|
||||
monitorInterval: 60,
|
||||
isEnabled: false
|
||||
};
|
||||
await configService.saveConfig(config);
|
||||
// 保存配置 - 兼容旧路由
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const newConfig = req.body;
|
||||
|
||||
if (!newConfig || typeof newConfig !== 'object') {
|
||||
return res.status(400).json({
|
||||
error: '无效的配置数据',
|
||||
details: '配置必须是一个对象'
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
await configServiceDB.saveConfigs(newConfig);
|
||||
res.json({ success: true, message: '配置已保存' });
|
||||
} catch (error) {
|
||||
logger.error('保存配置失败:', error);
|
||||
res.status(500).json({
|
||||
error: '保存配置失败',
|
||||
details: error.message
|
||||
});
|
||||
} 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: '通知类型不能为空' });
|
||||
// 获取菜单项配置
|
||||
router.get('/menu-items', async (req, res) => {
|
||||
try {
|
||||
const menuItems = await configServiceDB.getMenuItems();
|
||||
res.json(menuItems);
|
||||
} catch (error) {
|
||||
logger.error('获取菜单项失败:', error);
|
||||
res.status(500).json({ error: '获取菜单项失败', details: error.message });
|
||||
}
|
||||
|
||||
// 根据通知类型验证对应的字段
|
||||
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;
|
||||
// 保存菜单项配置
|
||||
router.post('/menu-items', requireLogin, async (req, res) => {
|
||||
try {
|
||||
const { menuItems } = req.body;
|
||||
|
||||
// 验证参数
|
||||
if (!notificationType) {
|
||||
return res.status(400).json({ error: '通知类型不能为空' });
|
||||
if (!Array.isArray(menuItems)) {
|
||||
return res.status(400).json({
|
||||
error: '无效的菜单项数据',
|
||||
details: '菜单项必须是一个数组'
|
||||
});
|
||||
}
|
||||
|
||||
await configServiceDB.saveMenuItems(menuItems);
|
||||
res.json({ success: true, message: '菜单项配置已保存' });
|
||||
} catch (error) {
|
||||
logger.error('保存菜单项失败:', error);
|
||||
res.status(500).json({ error: '保存菜单项失败', details: error.message });
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -1,108 +1,16 @@
|
||||
/**
|
||||
* 文档管理路由
|
||||
* 文档管理路由 - 使用SQLite数据库
|
||||
*/
|
||||
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 documentationServiceDB = require('../services/documentationServiceDB');
|
||||
|
||||
// 确保文档目录存在
|
||||
const docsDir = path.join(__dirname, '../documentation');
|
||||
const metaDir = path.join(docsDir, 'meta');
|
||||
|
||||
// 文档文件扩展名
|
||||
const FILE_EXTENSION = '.md';
|
||||
const META_EXTENSION = '.json';
|
||||
|
||||
// 确保目录存在
|
||||
async function ensureDirectories() {
|
||||
// 获取所有文档列表(管理员)
|
||||
router.get('/documents', requireLogin, async (req, res) => {
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const documents = await documentationServiceDB.getDocumentationList();
|
||||
res.json(documents);
|
||||
} catch (err) {
|
||||
logger.error('获取文档列表失败:', err);
|
||||
@@ -110,6 +18,39 @@ router.get('/documents', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 获取已发布文档列表(公开)
|
||||
router.get('/published', async (req, res) => {
|
||||
try {
|
||||
const documents = await documentationServiceDB.getPublishedDocuments();
|
||||
res.json(documents);
|
||||
} catch (err) {
|
||||
logger.error('获取已发布文档列表失败:', err);
|
||||
res.status(500).json({ error: '获取已发布文档列表失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取单个文档
|
||||
router.get('/documents/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const document = await documentationServiceDB.getDocument(id);
|
||||
|
||||
if (!document) {
|
||||
return res.status(404).json({ error: '文档不存在' });
|
||||
}
|
||||
|
||||
// 如果文档未发布,需要登录权限
|
||||
if (!document.published && !req.session.user) {
|
||||
return res.status(403).json({ error: '没有权限访问该文档' });
|
||||
}
|
||||
|
||||
res.json(document);
|
||||
} catch (err) {
|
||||
logger.error('获取文档失败:', err);
|
||||
res.status(500).json({ error: '获取文档失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 保存文档
|
||||
router.put('/documents/:id', requireLogin, async (req, res) => {
|
||||
try {
|
||||
@@ -120,38 +61,11 @@ router.put('/documents/:id', requireLogin, async (req, res) => {
|
||||
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
|
||||
};
|
||||
|
||||
const document = await documentationServiceDB.saveDocument(id, title, content, published);
|
||||
res.json(document);
|
||||
} catch (err) {
|
||||
logger.error('保存文档失败:', err);
|
||||
res.status(500).json({ error: '保存文档失败', details: err.message });
|
||||
res.status(500).json({ error: '保存文档失败' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -164,42 +78,12 @@ router.post('/documents', requireLogin, async (req, res) => {
|
||||
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
|
||||
};
|
||||
|
||||
const document = await documentationServiceDB.saveDocument(id, title, content, published);
|
||||
res.status(201).json(document);
|
||||
} catch (err) {
|
||||
logger.error('创建文档失败:', err);
|
||||
res.status(500).json({ error: '创建文档失败', details: err.message });
|
||||
res.status(500).json({ error: '创建文档失败' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -207,331 +91,30 @@ router.post('/documents', requireLogin, async (req, res) => {
|
||||
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) {
|
||||
const success = await documentationServiceDB.deleteDocument(id);
|
||||
if (!success) {
|
||||
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);
|
||||
res.json({ success: true, message: '文档已删除' });
|
||||
} catch (err) {
|
||||
logger.error(`获取文档 ${req.params.id} 失败:`, err);
|
||||
res.status(500).json({ error: '获取文档失败', details: err.message });
|
||||
logger.error('删除文档失败:', err);
|
||||
res.status(500).json({ error: '删除文档失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 更新文档发布状态
|
||||
router.put('/documentation/toggle-publish/:id', requireLogin, async (req, res) => {
|
||||
// 切换文档发布状态
|
||||
router.put('/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
|
||||
};
|
||||
|
||||
const document = await documentationServiceDB.toggleDocumentPublish(id);
|
||||
res.json(document);
|
||||
} catch (err) {
|
||||
logger.error(`更新文档状态 ${req.params.id} 失败:`, err);
|
||||
res.status(500).json({ error: '更新文档状态失败', details: err.message });
|
||||
logger.error('切换文档发布状态失败:', err);
|
||||
res.status(500).json({ error: '切换文档发布状态失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 为前端添加获取已发布文档列表的路由
|
||||
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;
|
||||
|
||||
167
hubcmdui/routes/httpProxy.js
Normal file
167
hubcmdui/routes/httpProxy.js
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* HTTP代理管理路由
|
||||
*/
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const logger = require('../logger');
|
||||
const { requireLogin } = require('../middleware/auth');
|
||||
const httpProxyService = require('../services/httpProxyService');
|
||||
|
||||
// 获取代理状态
|
||||
router.get('/proxy/status', requireLogin, async (req, res) => {
|
||||
try {
|
||||
const status = httpProxyService.getStatus();
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
logger.error('获取代理状态失败:', error);
|
||||
res.status(500).json({
|
||||
error: '获取代理状态失败',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 启动代理服务
|
||||
router.post('/proxy/start', requireLogin, async (req, res) => {
|
||||
try {
|
||||
const config = req.body;
|
||||
await httpProxyService.start(config);
|
||||
res.json({
|
||||
success: true,
|
||||
message: '代理服务已启动',
|
||||
status: httpProxyService.getStatus()
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('启动代理服务失败:', error);
|
||||
res.status(500).json({
|
||||
error: '启动代理服务失败',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 停止代理服务
|
||||
router.post('/proxy/stop', requireLogin, async (req, res) => {
|
||||
try {
|
||||
await httpProxyService.stop();
|
||||
res.json({
|
||||
success: true,
|
||||
message: '代理服务已停止',
|
||||
status: httpProxyService.getStatus()
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('停止代理服务失败:', error);
|
||||
res.status(500).json({
|
||||
error: '停止代理服务失败',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 重启代理服务
|
||||
router.post('/proxy/restart', requireLogin, async (req, res) => {
|
||||
try {
|
||||
await httpProxyService.stop();
|
||||
await httpProxyService.start(req.body);
|
||||
res.json({
|
||||
success: true,
|
||||
message: '代理服务已重启',
|
||||
status: httpProxyService.getStatus()
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('重启代理服务失败:', error);
|
||||
res.status(500).json({
|
||||
error: '重启代理服务失败',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 更新代理配置
|
||||
router.put('/proxy/config', requireLogin, async (req, res) => {
|
||||
try {
|
||||
const config = req.body;
|
||||
|
||||
// 验证配置
|
||||
if (config.port && (config.port < 1 || config.port > 65535)) {
|
||||
return res.status(400).json({ error: '端口号必须在1-65535之间' });
|
||||
}
|
||||
|
||||
if (config.enableAuth && (!config.username || !config.password)) {
|
||||
return res.status(400).json({ error: '启用认证时必须提供用户名和密码' });
|
||||
}
|
||||
|
||||
await httpProxyService.updateConfig(config);
|
||||
res.json({
|
||||
success: true,
|
||||
message: '代理配置已更新',
|
||||
status: httpProxyService.getStatus()
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('更新代理配置失败:', error);
|
||||
res.status(500).json({
|
||||
error: '更新代理配置失败',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 获取代理配置
|
||||
router.get('/proxy/config', requireLogin, async (req, res) => {
|
||||
try {
|
||||
const status = httpProxyService.getStatus();
|
||||
res.json({
|
||||
success: true,
|
||||
config: status.config
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('获取代理配置失败:', error);
|
||||
res.status(500).json({
|
||||
error: '获取代理配置失败',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 测试代理连接
|
||||
router.post('/proxy/test', requireLogin, async (req, res) => {
|
||||
try {
|
||||
const { testUrl = 'http://httpbin.org/ip' } = req.body;
|
||||
const axios = require('axios');
|
||||
const status = httpProxyService.getStatus();
|
||||
|
||||
if (!status.isRunning) {
|
||||
return res.status(400).json({ error: '代理服务未运行' });
|
||||
}
|
||||
|
||||
// 通过代理测试连接
|
||||
const proxyConfig = {
|
||||
host: status.config.host === '0.0.0.0' ? 'localhost' : status.config.host,
|
||||
port: status.config.port
|
||||
};
|
||||
|
||||
const startTime = Date.now();
|
||||
const response = await axios.get(testUrl, {
|
||||
proxy: proxyConfig,
|
||||
timeout: 10000
|
||||
});
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '代理连接测试成功',
|
||||
testUrl,
|
||||
responseTime: `${responseTime}ms`,
|
||||
statusCode: response.status,
|
||||
proxyConfig
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('代理连接测试失败:', error);
|
||||
res.status(500).json({
|
||||
error: '代理连接测试失败',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 系统相关路由
|
||||
* 系统相关路由 - 使用SQLite数据库
|
||||
*/
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
@@ -9,7 +9,7 @@ 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 configServiceDB = require('../services/configServiceDB');
|
||||
const { execCommand, getSystemInfo } = require('../server-utils');
|
||||
const dockerService = require('../services/dockerService');
|
||||
const path = require('path');
|
||||
@@ -115,7 +115,7 @@ async function getSystemStats(req, res) {
|
||||
// 获取系统配置 - 修改版本,避免与其他路由冲突
|
||||
router.get('/system-config', async (req, res) => {
|
||||
try {
|
||||
const config = await configService.getConfig();
|
||||
const config = await configServiceDB.getConfig();
|
||||
res.json(config);
|
||||
} catch (error) {
|
||||
logger.error('读取配置失败:', error);
|
||||
@@ -129,9 +129,9 @@ router.get('/system-config', async (req, res) => {
|
||||
// 保存系统配置 - 修改版本,避免与其他路由冲突
|
||||
router.post('/system-config', requireLogin, async (req, res) => {
|
||||
try {
|
||||
const currentConfig = await configService.getConfig();
|
||||
const currentConfig = await configServiceDB.getConfig();
|
||||
const newConfig = { ...currentConfig, ...req.body };
|
||||
await configService.saveConfig(newConfig);
|
||||
await configServiceDB.saveConfigs(newConfig);
|
||||
logger.info('系统配置已更新');
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
|
||||
117
hubcmdui/scripts/auto-setup.js
Executable file
117
hubcmdui/scripts/auto-setup.js
Executable file
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
const { isDatabaseReady, getDatabaseStats } = require('../utils/database-checker');
|
||||
|
||||
// 检查是否需要安装依赖
|
||||
function needsInstall() {
|
||||
const nodeModulesPath = path.join(process.cwd(), 'node_modules');
|
||||
const packageLockPath = path.join(process.cwd(), 'package-lock.json');
|
||||
|
||||
if (!fs.existsSync(nodeModulesPath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查package.json是否比package-lock.json新
|
||||
const packageJsonPath = path.join(process.cwd(), 'package.json');
|
||||
if (fs.existsSync(packageJsonPath) && fs.existsSync(packageLockPath)) {
|
||||
const packageStat = fs.statSync(packageJsonPath);
|
||||
const lockStat = fs.statSync(packageLockPath);
|
||||
if (packageStat.mtime > lockStat.mtime) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否需要初始化数据库
|
||||
async function needsInit() {
|
||||
const dataDir = path.join(process.cwd(), 'data');
|
||||
|
||||
// 如果data目录不存在,需要初始化
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 使用专门的数据库检查器
|
||||
const isReady = await isDatabaseReady();
|
||||
return !isReady;
|
||||
}
|
||||
|
||||
// 执行命令并显示输出
|
||||
function runCommand(command, description) {
|
||||
console.log(`\n🔄 ${description}...`);
|
||||
try {
|
||||
execSync(command, { stdio: 'inherit', cwd: process.cwd() });
|
||||
console.log(`✅ ${description}完成`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`❌ ${description}失败:`, error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function autoSetup() {
|
||||
console.log('🚀 HubCmdUI 自动设置检查...\n');
|
||||
|
||||
let needsSetup = false;
|
||||
|
||||
// 检查是否需要安装依赖
|
||||
if (needsInstall()) {
|
||||
console.log('📦 检测到需要安装依赖包');
|
||||
needsSetup = true;
|
||||
|
||||
if (!runCommand('npm install', '安装依赖包')) {
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
console.log('✅ 依赖包已安装');
|
||||
}
|
||||
|
||||
// 检查是否需要初始化
|
||||
const needsInitialization = await needsInit();
|
||||
if (needsInitialization) {
|
||||
console.log('🗄️ 检测到需要初始化数据库');
|
||||
needsSetup = true;
|
||||
|
||||
if (!runCommand('node scripts/init-complete.js', '初始化SQLite数据库')) {
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
console.log('✅ 数据库已初始化');
|
||||
}
|
||||
|
||||
if (needsSetup) {
|
||||
console.log('\n🎉 系统设置完成!正在启动服务...\n');
|
||||
} else {
|
||||
console.log('\n🎯 系统已就绪,正在启动服务...\n');
|
||||
}
|
||||
|
||||
// 启动服务器
|
||||
console.log('🌐 启动 HubCmdUI 服务器...');
|
||||
console.log('📍 访问地址: http://localhost:3000');
|
||||
console.log('🔧 管理面板: http://localhost:3000/admin');
|
||||
console.log('👤 默认账户: root / admin@123\n');
|
||||
|
||||
// 启动主服务器
|
||||
try {
|
||||
require('../server.js');
|
||||
} catch (error) {
|
||||
console.error('❌ 服务器启动失败:', error.message);
|
||||
console.error('💡 尝试运行: npm run init 重新初始化');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果直接运行此脚本
|
||||
if (require.main === module) {
|
||||
autoSetup().catch(error => {
|
||||
console.error('❌ 自动设置失败:', error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { autoSetup, needsInstall, needsInit };
|
||||
199
hubcmdui/scripts/init-complete.js
Normal file
199
hubcmdui/scripts/init-complete.js
Normal file
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 系统初始化和配置脚本
|
||||
*/
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const logger = require('../logger');
|
||||
|
||||
// 颜色输出
|
||||
const chalk = require('chalk');
|
||||
|
||||
async function initializeSystem() {
|
||||
console.log(chalk.blue('🚀 正在初始化 HubCmdUI 系统...\n'));
|
||||
|
||||
try {
|
||||
// 1. 检查并创建必要目录
|
||||
console.log(chalk.yellow('📁 创建必要目录...'));
|
||||
await createDirectories();
|
||||
|
||||
// 2. 检查数据库是否已初始化
|
||||
const database = require('../database/database');
|
||||
try {
|
||||
await database.connect();
|
||||
const isInitialized = await database.isInitialized();
|
||||
|
||||
if (isInitialized) {
|
||||
console.log(chalk.green(' ✓ 数据库已初始化,跳过初始化步骤'));
|
||||
console.log(chalk.green('\n✅ 系统检查完成!'));
|
||||
console.log(chalk.cyan('💡 使用 npm start 启动服务'));
|
||||
console.log(chalk.cyan('🌐 默认访问地址: http://localhost:3000'));
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
// 数据库连接失败,继续初始化流程
|
||||
}
|
||||
|
||||
// 3. 检查配置文件
|
||||
console.log(chalk.yellow('⚙️ 检查配置文件...'));
|
||||
await checkConfigFiles();
|
||||
|
||||
// 4. 询问用户是否要启用SQLite
|
||||
const useDatabase = await askUserChoice();
|
||||
|
||||
if (useDatabase) {
|
||||
// 5. 迁移数据到SQLite
|
||||
console.log(chalk.yellow('📊 初始化SQLite数据库...'));
|
||||
await initializeSQLite();
|
||||
|
||||
// 6. 设置环境变量
|
||||
console.log(chalk.yellow('🔧 配置数据库模式...'));
|
||||
await setDatabaseMode(true);
|
||||
} else {
|
||||
console.log(chalk.yellow('📁 使用文件存储模式...'));
|
||||
await setDatabaseMode(false);
|
||||
}
|
||||
|
||||
// 7. 创建默认用户
|
||||
console.log(chalk.yellow('👤 创建默认用户...'));
|
||||
await createDefaultUser();
|
||||
|
||||
// 8. 配置HTTP代理
|
||||
console.log(chalk.yellow('🌐 配置HTTP代理服务...'));
|
||||
await configureHttpProxy();
|
||||
|
||||
console.log(chalk.green('\n✅ 系统初始化完成!'));
|
||||
console.log(chalk.cyan('💡 使用 npm start 启动服务'));
|
||||
console.log(chalk.cyan('🌐 默认访问地址: http://localhost:3000'));
|
||||
console.log(chalk.cyan('👤 默认用户: root / admin@123'));
|
||||
|
||||
} catch (error) {
|
||||
console.error(chalk.red('❌ 初始化失败:'), error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建必要目录
|
||||
*/
|
||||
async function createDirectories() {
|
||||
const dirs = [
|
||||
'data', // 数据库文件目录
|
||||
'documentation', // 文档目录(静态文件)
|
||||
'logs', // 日志目录
|
||||
'temp' // 临时文件目录
|
||||
];
|
||||
|
||||
for (const dir of dirs) {
|
||||
const dirPath = path.join(__dirname, '..', dir);
|
||||
try {
|
||||
await fs.mkdir(dirPath, { recursive: true });
|
||||
} catch (error) {
|
||||
if (error.code !== 'EEXIST') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(chalk.green(' ✓ 目录创建完成'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查配置文件 - 简化版,不再创建config.json
|
||||
*/
|
||||
async function checkConfigFiles() {
|
||||
console.log(chalk.green(' ✓ 使用SQLite数据库存储配置'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 询问用户选择
|
||||
*/
|
||||
async function askUserChoice() {
|
||||
// 简化处理,默认使用SQLite
|
||||
const useDatabase = process.env.USE_SQLITE !== 'false';
|
||||
|
||||
if (useDatabase) {
|
||||
console.log(chalk.green(' ✓ 将使用SQLite数据库存储'));
|
||||
} else {
|
||||
console.log(chalk.yellow(' ⚠ 将使用文件存储模式'));
|
||||
}
|
||||
|
||||
return useDatabase;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化SQLite数据库
|
||||
*/
|
||||
async function initializeSQLite() {
|
||||
try {
|
||||
const database = require('../database/database');
|
||||
await database.connect();
|
||||
await database.createTables();
|
||||
|
||||
// 初始化数据库(创建默认数据)
|
||||
await database.createDefaultAdmin();
|
||||
await database.createDefaultDocuments();
|
||||
await database.createDefaultMenuItems();
|
||||
|
||||
// 初始化默认配置
|
||||
const configServiceDB = require('../services/configServiceDB');
|
||||
await configServiceDB.initializeDefaultConfig();
|
||||
|
||||
// 标记数据库已初始化
|
||||
await database.markAsInitialized();
|
||||
|
||||
console.log(chalk.green(' ✓ SQLite数据库初始化完成'));
|
||||
} catch (error) {
|
||||
console.log(chalk.red(' ❌ SQLite初始化失败:'), error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置数据库模式
|
||||
*/
|
||||
async function setDatabaseMode(useDatabase) {
|
||||
const envPath = path.join(__dirname, '../.env');
|
||||
const envContent = `# 数据库配置
|
||||
USE_DATABASE=${useDatabase}
|
||||
AUTO_MIGRATE=true
|
||||
|
||||
# HTTP代理配置
|
||||
PROXY_PORT=8080
|
||||
PROXY_HOST=0.0.0.0
|
||||
`;
|
||||
|
||||
await fs.writeFile(envPath, envContent);
|
||||
console.log(chalk.green(` ✓ 数据库模式已设置为: ${useDatabase ? 'SQLite' : '文件存储'}`));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建默认用户 - 简化版,数据库已处理
|
||||
*/
|
||||
async function createDefaultUser() {
|
||||
console.log(chalk.green(' ✓ 默认管理员用户由数据库处理 (root/admin@123)'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置HTTP代理服务信息
|
||||
*/
|
||||
async function configureHttpProxy() {
|
||||
try {
|
||||
console.log(chalk.green(' ✓ HTTP代理服务需要通过环境变量配置'));
|
||||
console.log(chalk.cyan(' 配置方式: 设置 PROXY_PORT 和 PROXY_HOST 环境变量'));
|
||||
console.log(chalk.cyan(' 示例: PROXY_PORT=8080 PROXY_HOST=0.0.0.0 npm start'));
|
||||
} catch (error) {
|
||||
console.log(chalk.yellow(' ⚠ HTTP代理服务配置提示显示失败'));
|
||||
}
|
||||
}
|
||||
|
||||
// 如果直接运行此脚本
|
||||
if (require.main === module) {
|
||||
initializeSystem().then(() => {
|
||||
process.exit(0);
|
||||
}).catch((error) => {
|
||||
console.error(chalk.red('初始化失败:'), error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { initializeSystem };
|
||||
92
hubcmdui/scripts/init-database.js
Normal file
92
hubcmdui/scripts/init-database.js
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 数据库初始化脚本
|
||||
*/
|
||||
const database = require('../database/database');
|
||||
const userServiceDB = require('../services/userServiceDB');
|
||||
const configServiceDB = require('../services/configServiceDB');
|
||||
const logger = require('../logger');
|
||||
|
||||
async function initializeDatabase() {
|
||||
try {
|
||||
logger.info('开始初始化数据库...');
|
||||
|
||||
// 连接数据库
|
||||
await database.connect();
|
||||
|
||||
// 检查数据库是否已经初始化
|
||||
const isInitialized = await database.isInitialized();
|
||||
if (isInitialized) {
|
||||
logger.info('数据库已经初始化,跳过重复初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建数据表
|
||||
await database.createTables();
|
||||
|
||||
// 创建默认管理员用户(如果不存在)
|
||||
await database.createDefaultAdmin();
|
||||
|
||||
// 创建默认文档
|
||||
await database.createDefaultDocuments();
|
||||
|
||||
// 初始化默认配置
|
||||
await configServiceDB.initializeDefaultConfig();
|
||||
|
||||
// 标记数据库已初始化
|
||||
await database.markAsInitialized();
|
||||
|
||||
logger.info('数据库初始化完成!');
|
||||
|
||||
// 显示数据库信息
|
||||
const userCount = await database.get('SELECT COUNT(*) as count FROM users');
|
||||
const configCount = await database.get('SELECT COUNT(*) as count FROM configs');
|
||||
const docCount = await database.get('SELECT COUNT(*) as count FROM documents');
|
||||
|
||||
logger.info(`数据库统计:`);
|
||||
logger.info(` 用户数量: ${userCount.count}`);
|
||||
logger.info(` 配置项数量: ${configCount.count}`);
|
||||
logger.info(` 文档数量: ${docCount.count}`);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('数据库初始化失败:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查数据库是否已经初始化
|
||||
*/
|
||||
async function checkDatabaseInitialized() {
|
||||
try {
|
||||
// 检查用户表是否有数据
|
||||
const userCount = await database.get('SELECT COUNT(*) as count FROM users');
|
||||
if (userCount && userCount.count > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查配置表是否有数据
|
||||
const configCount = await database.get('SELECT COUNT(*) as count FROM configs');
|
||||
if (configCount && configCount.count > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
// 如果查询失败,认为数据库未初始化
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果直接运行此脚本,则执行初始化
|
||||
if (require.main === module) {
|
||||
initializeDatabase().then(() => {
|
||||
process.exit(0);
|
||||
}).catch((error) => {
|
||||
logger.error('初始化过程出错:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initializeDatabase
|
||||
};
|
||||
@@ -1,315 +0,0 @@
|
||||
/**
|
||||
* 系统初始化脚本 - 首次运行时执行
|
||||
*/
|
||||
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
|
||||
};
|
||||
@@ -15,7 +15,9 @@ const { gracefulShutdown } = require('./cleanup');
|
||||
const os = require('os');
|
||||
const { requireLogin } = require('./middleware/auth');
|
||||
const compatibilityLayer = require('./compatibility-layer');
|
||||
const initSystem = require('./scripts/init-system');
|
||||
const { initializeDatabase } = require('./scripts/init-database');
|
||||
const database = require('./database/database');
|
||||
const httpProxyService = require('./services/httpProxyService');
|
||||
|
||||
// 设置日志级别 (默认INFO, 可通过环境变量设置)
|
||||
const logLevel = process.env.LOG_LEVEL || 'WARN';
|
||||
@@ -146,6 +148,15 @@ async function startServer() {
|
||||
logger.info(`服务器已启动并监听端口 ${PORT}`);
|
||||
|
||||
try {
|
||||
// 初始化数据库
|
||||
try {
|
||||
await initializeDatabase();
|
||||
logger.success('数据库初始化完成');
|
||||
} catch (dbError) {
|
||||
logger.error('数据库初始化失败:', dbError);
|
||||
logger.warn('将使用文件存储作为备用方案');
|
||||
}
|
||||
|
||||
// 确保目录存在
|
||||
await ensureDirectoriesExist();
|
||||
logger.success('系统目录初始化完成');
|
||||
@@ -154,14 +165,41 @@ async function startServer() {
|
||||
await downloadImages();
|
||||
logger.success('资源下载完成');
|
||||
|
||||
// 初始化系统
|
||||
// 默认使用SQLite数据库模式
|
||||
try {
|
||||
const { initialize } = require('./scripts/init-system');
|
||||
await initialize();
|
||||
logger.success('系统初始化完成');
|
||||
logger.info('正在检查SQLite数据库...');
|
||||
const { isDatabaseReady } = require('./utils/database-checker');
|
||||
const dbReady = await isDatabaseReady();
|
||||
|
||||
if (!dbReady) {
|
||||
logger.warn('数据库未完全初始化,正在初始化...');
|
||||
await initializeDatabase();
|
||||
} else {
|
||||
logger.info('SQLite数据库已就绪');
|
||||
}
|
||||
|
||||
logger.success('SQLite数据库初始化完成');
|
||||
} catch (dbError) {
|
||||
logger.error('SQLite数据库初始化失败:', dbError.message);
|
||||
throw dbError; // 数据库初始化失败时直接退出
|
||||
}
|
||||
|
||||
// 初始化系统配置
|
||||
try {
|
||||
// 系统配置已在数据库初始化时完成
|
||||
logger.info('系统配置初始化完成');
|
||||
} catch (initError) {
|
||||
logger.warn('系统初始化遇到问题:', initError.message);
|
||||
logger.warn('某些功能可能无法正常工作');
|
||||
logger.warn('系统配置初始化遇到问题:', initError.message);
|
||||
}
|
||||
|
||||
// 初始化HTTP代理服务
|
||||
try {
|
||||
await httpProxyService.loadConfig();
|
||||
// 检查环境变量并自动启动代理
|
||||
await httpProxyService.checkEnvironmentAndAutoStart();
|
||||
logger.success('HTTP代理服务配置已加载');
|
||||
} catch (proxyError) {
|
||||
logger.warn('HTTP代理服务初始化失败:', proxyError.message);
|
||||
}
|
||||
|
||||
// 尝试启动监控
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
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
|
||||
};
|
||||
233
hubcmdui/services/configServiceDB.js
Normal file
233
hubcmdui/services/configServiceDB.js
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* 基于SQLite的配置服务模块
|
||||
*/
|
||||
const logger = require('../logger');
|
||||
const database = require('../database/database');
|
||||
|
||||
class ConfigServiceDB {
|
||||
/**
|
||||
* 获取配置项
|
||||
*/
|
||||
async getConfig(key = null) {
|
||||
try {
|
||||
if (key) {
|
||||
const config = await database.get('SELECT * FROM configs WHERE key = ?', [key]);
|
||||
if (config) {
|
||||
return JSON.parse(config.value);
|
||||
}
|
||||
return null;
|
||||
} else {
|
||||
// 获取所有配置
|
||||
const configs = await database.all('SELECT * FROM configs');
|
||||
const result = {};
|
||||
for (const config of configs) {
|
||||
result[config.key] = JSON.parse(config.value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('获取配置失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存配置项
|
||||
*/
|
||||
async saveConfig(key, value, description = null) {
|
||||
try {
|
||||
const valueString = JSON.stringify(value);
|
||||
const valueType = typeof value;
|
||||
|
||||
const existingConfig = await database.get('SELECT id FROM configs WHERE key = ?', [key]);
|
||||
|
||||
if (existingConfig) {
|
||||
// 更新现有配置
|
||||
await database.run(
|
||||
'UPDATE configs SET value = ?, type = ?, description = ?, updated_at = ? WHERE key = ?',
|
||||
[valueString, valueType, description, new Date().toISOString(), key]
|
||||
);
|
||||
} else {
|
||||
// 创建新配置
|
||||
await database.run(
|
||||
'INSERT INTO configs (key, value, type, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[key, valueString, valueType, description, new Date().toISOString(), new Date().toISOString()]
|
||||
);
|
||||
}
|
||||
|
||||
// 移除详细的配置保存日志,减少日志噪音
|
||||
} catch (error) {
|
||||
logger.error('保存配置失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量保存配置
|
||||
*/
|
||||
async saveConfigs(configs) {
|
||||
try {
|
||||
const configCount = Object.keys(configs).length;
|
||||
for (const [key, value] of Object.entries(configs)) {
|
||||
await this.saveConfig(key, value);
|
||||
}
|
||||
logger.info(`批量保存配置完成,共 ${configCount} 项配置`);
|
||||
} catch (error) {
|
||||
logger.error('批量保存配置失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除配置项
|
||||
*/
|
||||
async deleteConfig(key) {
|
||||
try {
|
||||
await database.run('DELETE FROM configs WHERE key = ?', [key]);
|
||||
// 删除配置时仍保留日志,因为这是重要操作
|
||||
logger.info(`配置 ${key} 已删除`);
|
||||
} catch (error) {
|
||||
logger.error('删除配置失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统默认配置
|
||||
*/
|
||||
getDefaultConfig() {
|
||||
return {
|
||||
theme: 'light',
|
||||
language: 'zh_CN',
|
||||
notifications: true,
|
||||
autoRefresh: true,
|
||||
refreshInterval: 30000,
|
||||
dockerHost: 'localhost',
|
||||
dockerPort: 2375,
|
||||
useHttps: false,
|
||||
proxyDomain: 'registry-1.docker.io',
|
||||
logo: '',
|
||||
menuItems: [
|
||||
{
|
||||
text: "首页",
|
||||
link: "/",
|
||||
newTab: false
|
||||
},
|
||||
{
|
||||
text: "文档",
|
||||
link: "https://dqzboy.github.io/docs/",
|
||||
newTab: true
|
||||
},
|
||||
{
|
||||
text: "推广",
|
||||
link: "https://dqzboy.github.io/proxyui/zanzhu",
|
||||
newTab: true
|
||||
},
|
||||
{
|
||||
text: "GitHub",
|
||||
link: "https://github.com/dqzboy/Docker-Proxy",
|
||||
newTab: true
|
||||
}
|
||||
],
|
||||
monitoringConfig: {
|
||||
notificationType: 'wechat',
|
||||
webhookUrl: '',
|
||||
telegramToken: '',
|
||||
telegramChatId: '',
|
||||
monitorInterval: 60,
|
||||
isEnabled: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化默认配置
|
||||
*/
|
||||
async initializeDefaultConfig() {
|
||||
try {
|
||||
const defaultConfig = this.getDefaultConfig();
|
||||
let newConfigCount = 0;
|
||||
|
||||
for (const [key, value] of Object.entries(defaultConfig)) {
|
||||
const existingConfig = await database.get('SELECT id FROM configs WHERE key = ?', [key]);
|
||||
if (!existingConfig) {
|
||||
await this.saveConfig(key, value, `默认${key}配置`);
|
||||
newConfigCount++;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('初始化默认配置失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取监控配置
|
||||
*/
|
||||
async getMonitoringConfig() {
|
||||
try {
|
||||
return await this.getConfig('monitoringConfig') || this.getDefaultConfig().monitoringConfig;
|
||||
} catch (error) {
|
||||
logger.error('获取监控配置失败:', error);
|
||||
return this.getDefaultConfig().monitoringConfig;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存监控配置
|
||||
*/
|
||||
async saveMonitoringConfig(config) {
|
||||
try {
|
||||
await this.saveConfig('monitoringConfig', config, '监控系统配置');
|
||||
} catch (error) {
|
||||
logger.error('保存监控配置失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取菜单项配置
|
||||
*/
|
||||
async getMenuItems() {
|
||||
try {
|
||||
const menuItems = await database.all(
|
||||
'SELECT text, link, new_tab, sort_order, enabled FROM menu_items WHERE enabled = 1 ORDER BY sort_order'
|
||||
);
|
||||
|
||||
return menuItems.map(item => ({
|
||||
text: item.text,
|
||||
link: item.link,
|
||||
newTab: Boolean(item.new_tab)
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('获取菜单项失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存菜单项配置
|
||||
*/
|
||||
async saveMenuItems(menuItems) {
|
||||
try {
|
||||
// 先清空现有菜单项
|
||||
await database.run('DELETE FROM menu_items');
|
||||
|
||||
// 插入新的菜单项
|
||||
for (let i = 0; i < menuItems.length; i++) {
|
||||
const item = menuItems[i];
|
||||
await database.run(
|
||||
'INSERT INTO menu_items (text, link, new_tab, sort_order, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[item.text, item.link, item.newTab ? 1 : 0, i + 1, 1, new Date().toISOString(), new Date().toISOString()]
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('菜单项配置保存成功');
|
||||
} catch (error) {
|
||||
logger.error('保存菜单项失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ConfigServiceDB();
|
||||
@@ -1,324 +0,0 @@
|
||||
/**
|
||||
* 文档服务模块 - 处理文档管理功能
|
||||
*/
|
||||
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
|
||||
};
|
||||
242
hubcmdui/services/documentationServiceDB.js
Normal file
242
hubcmdui/services/documentationServiceDB.js
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* 基于SQLite的文档服务模块
|
||||
*/
|
||||
const logger = require('../logger');
|
||||
const database = require('../database/database');
|
||||
|
||||
class DocumentationServiceDB {
|
||||
/**
|
||||
* 获取文档列表
|
||||
*/
|
||||
async getDocumentationList() {
|
||||
try {
|
||||
const documents = await database.all(
|
||||
'SELECT doc_id, title, published, created_at, updated_at FROM documents ORDER BY updated_at DESC'
|
||||
);
|
||||
|
||||
return documents.map(doc => ({
|
||||
id: doc.doc_id,
|
||||
title: doc.title,
|
||||
published: Boolean(doc.published),
|
||||
createdAt: doc.created_at,
|
||||
updatedAt: doc.updated_at
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('获取文档列表失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已发布文档列表
|
||||
*/
|
||||
async getPublishedDocuments() {
|
||||
try {
|
||||
const documents = await database.all(
|
||||
'SELECT doc_id, title, published, created_at, updated_at FROM documents WHERE published = 1 ORDER BY updated_at DESC'
|
||||
);
|
||||
|
||||
return documents.map(doc => ({
|
||||
id: doc.doc_id,
|
||||
title: doc.title,
|
||||
published: Boolean(doc.published),
|
||||
createdAt: doc.created_at,
|
||||
updatedAt: doc.updated_at
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('获取已发布文档列表失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个文档
|
||||
*/
|
||||
async getDocument(docId) {
|
||||
try {
|
||||
const document = await database.get(
|
||||
'SELECT * FROM documents WHERE doc_id = ?',
|
||||
[docId]
|
||||
);
|
||||
|
||||
if (!document) {
|
||||
throw new Error(`文档 ${docId} 不存在`);
|
||||
}
|
||||
|
||||
return {
|
||||
id: document.doc_id,
|
||||
title: document.title,
|
||||
content: document.content,
|
||||
published: Boolean(document.published),
|
||||
createdAt: document.created_at,
|
||||
updatedAt: document.updated_at
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`获取文档 ${docId} 失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存文档
|
||||
*/
|
||||
async saveDocument(docId, title, content, published = false) {
|
||||
try {
|
||||
const id = docId || Date.now().toString();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const existingDoc = await database.get(
|
||||
'SELECT id FROM documents WHERE doc_id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (existingDoc) {
|
||||
// 更新现有文档
|
||||
await database.run(
|
||||
'UPDATE documents SET title = ?, content = ?, published = ?, updated_at = ? WHERE doc_id = ?',
|
||||
[title, content, published ? 1 : 0, now, id]
|
||||
);
|
||||
logger.info(`文档 ${id} 已更新`);
|
||||
} else {
|
||||
// 创建新文档
|
||||
await database.run(
|
||||
'INSERT INTO documents (doc_id, title, content, published, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[id, title, content, published ? 1 : 0, now, now]
|
||||
);
|
||||
logger.info(`文档 ${id} 已创建`);
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
content,
|
||||
published: Boolean(published),
|
||||
createdAt: existingDoc ? existingDoc.created_at : now,
|
||||
updatedAt: now
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('保存文档失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文档
|
||||
*/
|
||||
async deleteDocument(docId) {
|
||||
try {
|
||||
const result = await database.run(
|
||||
'DELETE FROM documents WHERE doc_id = ?',
|
||||
[docId]
|
||||
);
|
||||
|
||||
if (result.changes === 0) {
|
||||
throw new Error(`文档 ${docId} 不存在`);
|
||||
}
|
||||
|
||||
logger.info(`文档 ${docId} 已删除`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`删除文档 ${docId} 失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换文档发布状态
|
||||
*/
|
||||
async toggleDocumentPublish(docId) {
|
||||
try {
|
||||
const document = await this.getDocument(docId);
|
||||
const newPublishedStatus = !document.published;
|
||||
|
||||
await database.run(
|
||||
'UPDATE documents SET published = ?, updated_at = ? WHERE doc_id = ?',
|
||||
[newPublishedStatus ? 1 : 0, new Date().toISOString(), docId]
|
||||
);
|
||||
|
||||
logger.info(`文档 ${docId} 发布状态已切换为: ${newPublishedStatus}`);
|
||||
|
||||
return {
|
||||
...document,
|
||||
published: newPublishedStatus,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`切换文档 ${docId} 发布状态失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新文档发布状态
|
||||
*/
|
||||
async batchUpdatePublishStatus(docIds, published) {
|
||||
try {
|
||||
const placeholders = docIds.map(() => '?').join(',');
|
||||
const params = [...docIds, published ? 1 : 0, new Date().toISOString()];
|
||||
|
||||
const result = await database.run(
|
||||
`UPDATE documents SET published = ?, updated_at = ? WHERE doc_id IN (${placeholders})`,
|
||||
params
|
||||
);
|
||||
|
||||
logger.info(`批量更新 ${result.changes} 个文档的发布状态`);
|
||||
return result.changes;
|
||||
} catch (error) {
|
||||
logger.error('批量更新文档发布状态失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索文档
|
||||
*/
|
||||
async searchDocuments(keyword, publishedOnly = false) {
|
||||
try {
|
||||
let sql = 'SELECT doc_id, title, published, created_at, updated_at FROM documents WHERE (title LIKE ? OR content LIKE ?)';
|
||||
const params = [`%${keyword}%`, `%${keyword}%`];
|
||||
|
||||
if (publishedOnly) {
|
||||
sql += ' AND published = 1';
|
||||
}
|
||||
|
||||
sql += ' ORDER BY updated_at DESC';
|
||||
|
||||
const documents = await database.all(sql, params);
|
||||
|
||||
return documents.map(doc => ({
|
||||
id: doc.doc_id,
|
||||
title: doc.title,
|
||||
published: Boolean(doc.published),
|
||||
createdAt: doc.created_at,
|
||||
updatedAt: doc.updated_at
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('搜索文档失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文档统计信息
|
||||
*/
|
||||
async getDocumentStats() {
|
||||
try {
|
||||
const totalCount = await database.get('SELECT COUNT(*) as count FROM documents');
|
||||
const publishedCount = await database.get('SELECT COUNT(*) as count FROM documents WHERE published = 1');
|
||||
const unpublishedCount = await database.get('SELECT COUNT(*) as count FROM documents WHERE published = 0');
|
||||
|
||||
return {
|
||||
total: totalCount.count,
|
||||
published: publishedCount.count,
|
||||
unpublished: unpublishedCount.count
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('获取文档统计信息失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new DocumentationServiceDB();
|
||||
418
hubcmdui/services/httpProxyService.js
Normal file
418
hubcmdui/services/httpProxyService.js
Normal file
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* HTTP代理服务模块
|
||||
*/
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const url = require('url');
|
||||
const net = require('net');
|
||||
const logger = require('../logger');
|
||||
const configServiceDB = require('./configServiceDB');
|
||||
|
||||
class HttpProxyService {
|
||||
constructor() {
|
||||
this.proxyServer = null;
|
||||
this.isRunning = false;
|
||||
this.config = {
|
||||
port: 8080,
|
||||
host: '0.0.0.0',
|
||||
enableHttps: true,
|
||||
enableAuth: false,
|
||||
username: '',
|
||||
password: '',
|
||||
allowedHosts: [],
|
||||
blockedHosts: [],
|
||||
logRequests: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动代理服务器
|
||||
*/
|
||||
async start(config = {}) {
|
||||
try {
|
||||
this.config = { ...this.config, ...config };
|
||||
|
||||
if (this.isRunning) {
|
||||
logger.warn('HTTP代理服务器已在运行');
|
||||
return;
|
||||
}
|
||||
|
||||
this.proxyServer = http.createServer();
|
||||
|
||||
// 处理HTTP请求
|
||||
this.proxyServer.on('request', this.handleHttpRequest.bind(this));
|
||||
|
||||
// 处理HTTPS CONNECT请求
|
||||
this.proxyServer.on('connect', this.handleHttpsConnect.bind(this));
|
||||
|
||||
// 错误处理
|
||||
this.proxyServer.on('error', this.handleServerError.bind(this));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.proxyServer.listen(this.config.port, this.config.host, (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
this.isRunning = true;
|
||||
logger.info(`HTTP代理服务器已启动,监听 ${this.config.host}:${this.config.port}`);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('启动HTTP代理服务器失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止代理服务器
|
||||
*/
|
||||
async stop() {
|
||||
return new Promise((resolve) => {
|
||||
if (this.proxyServer && this.isRunning) {
|
||||
this.proxyServer.close(() => {
|
||||
this.isRunning = false;
|
||||
logger.info('HTTP代理服务器已停止');
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理HTTP请求
|
||||
*/
|
||||
handleHttpRequest(clientReq, clientRes) {
|
||||
try {
|
||||
const targetUrl = clientReq.url;
|
||||
const parsedUrl = url.parse(targetUrl);
|
||||
|
||||
// 记录请求日志
|
||||
if (this.config.logRequests) {
|
||||
logger.info(`HTTP代理请求: ${clientReq.method} ${targetUrl}`);
|
||||
}
|
||||
|
||||
// 认证检查
|
||||
if (this.config.enableAuth && !this.checkAuth(clientReq)) {
|
||||
this.sendAuthRequired(clientRes);
|
||||
return;
|
||||
}
|
||||
|
||||
// 主机检查
|
||||
if (!this.isHostAllowed(parsedUrl.hostname)) {
|
||||
this.sendForbidden(clientRes, '主机不在允许列表中');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isHostBlocked(parsedUrl.hostname)) {
|
||||
this.sendForbidden(clientRes, '主机已被阻止');
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建目标请求选项
|
||||
const options = {
|
||||
hostname: parsedUrl.hostname,
|
||||
port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
|
||||
path: parsedUrl.path,
|
||||
method: clientReq.method,
|
||||
headers: { ...clientReq.headers }
|
||||
};
|
||||
|
||||
// 移除代理相关的头部
|
||||
delete options.headers['proxy-connection'];
|
||||
delete options.headers['proxy-authorization'];
|
||||
|
||||
// 选择HTTP或HTTPS
|
||||
const httpModule = parsedUrl.protocol === 'https:' ? https : http;
|
||||
|
||||
// 发送请求到目标服务器
|
||||
const proxyReq = httpModule.request(options, (proxyRes) => {
|
||||
// 复制响应头
|
||||
clientRes.writeHead(proxyRes.statusCode, proxyRes.headers);
|
||||
|
||||
// 管道传输响应数据
|
||||
proxyRes.pipe(clientRes);
|
||||
});
|
||||
|
||||
// 错误处理
|
||||
proxyReq.on('error', (err) => {
|
||||
logger.error('代理请求错误:', err);
|
||||
if (!clientRes.headersSent) {
|
||||
clientRes.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||
clientRes.end('代理服务器错误');
|
||||
}
|
||||
});
|
||||
|
||||
// 管道传输请求数据
|
||||
clientReq.pipe(proxyReq);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('处理HTTP请求失败:', error);
|
||||
if (!clientRes.headersSent) {
|
||||
clientRes.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||
clientRes.end('内部服务器错误');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理HTTPS CONNECT请求
|
||||
*/
|
||||
handleHttpsConnect(clientReq, clientSocket, head) {
|
||||
try {
|
||||
const { hostname, port } = this.parseConnectUrl(clientReq.url);
|
||||
|
||||
// 记录请求日志
|
||||
if (this.config.logRequests) {
|
||||
logger.info(`HTTPS代理请求: CONNECT ${hostname}:${port}`);
|
||||
}
|
||||
|
||||
// 认证检查
|
||||
if (this.config.enableAuth && !this.checkAuth(clientReq)) {
|
||||
clientSocket.write('HTTP/1.1 407 Proxy Authentication Required\r\n\r\n');
|
||||
clientSocket.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// 主机检查
|
||||
if (!this.isHostAllowed(hostname)) {
|
||||
clientSocket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
||||
clientSocket.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isHostBlocked(hostname)) {
|
||||
clientSocket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
||||
clientSocket.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// 连接到目标服务器
|
||||
const serverSocket = net.connect(port, hostname, () => {
|
||||
// 发送连接成功响应
|
||||
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
||||
|
||||
// 建立隧道
|
||||
serverSocket.write(head);
|
||||
serverSocket.pipe(clientSocket);
|
||||
clientSocket.pipe(serverSocket);
|
||||
});
|
||||
|
||||
// 错误处理
|
||||
serverSocket.on('error', (err) => {
|
||||
logger.error('服务器连接错误:', err);
|
||||
clientSocket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n');
|
||||
clientSocket.end();
|
||||
});
|
||||
|
||||
clientSocket.on('error', (err) => {
|
||||
logger.error('客户端连接错误:', err);
|
||||
serverSocket.end();
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('处理HTTPS CONNECT请求失败:', error);
|
||||
clientSocket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n');
|
||||
clientSocket.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析CONNECT请求URL
|
||||
*/
|
||||
parseConnectUrl(connectUrl) {
|
||||
const [hostname, port] = connectUrl.split(':');
|
||||
return {
|
||||
hostname,
|
||||
port: parseInt(port) || 443
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查认证
|
||||
*/
|
||||
checkAuth(req) {
|
||||
if (!this.config.enableAuth) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const auth = req.headers['proxy-authorization'];
|
||||
if (!auth) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [type, credentials] = auth.split(' ');
|
||||
if (type !== 'Basic') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const decoded = Buffer.from(credentials, 'base64').toString();
|
||||
const [username, password] = decoded.split(':');
|
||||
|
||||
return username === this.config.username && password === this.config.password;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查主机是否允许
|
||||
*/
|
||||
isHostAllowed(hostname) {
|
||||
if (this.config.allowedHosts.length === 0) {
|
||||
return true; // 如果没有设置允许列表,则允许所有
|
||||
}
|
||||
|
||||
return this.config.allowedHosts.some(allowed => {
|
||||
if (allowed.startsWith('*.')) {
|
||||
const domain = allowed.substring(2);
|
||||
return hostname.endsWith(domain);
|
||||
}
|
||||
return hostname === allowed;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查主机是否被阻止
|
||||
*/
|
||||
isHostBlocked(hostname) {
|
||||
return this.config.blockedHosts.some(blocked => {
|
||||
if (blocked.startsWith('*.')) {
|
||||
const domain = blocked.substring(2);
|
||||
return hostname.endsWith(domain);
|
||||
}
|
||||
return hostname === blocked;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送认证要求响应
|
||||
*/
|
||||
sendAuthRequired(res) {
|
||||
res.writeHead(407, {
|
||||
'Proxy-Authenticate': 'Basic realm="Proxy"',
|
||||
'Content-Type': 'text/plain'
|
||||
});
|
||||
res.end('需要代理认证');
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送禁止访问响应
|
||||
*/
|
||||
sendForbidden(res, message) {
|
||||
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
||||
res.end(message || '禁止访问');
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理服务器错误
|
||||
*/
|
||||
handleServerError(error) {
|
||||
logger.error('HTTP代理服务器错误:', error);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取代理状态
|
||||
*/
|
||||
getStatus() {
|
||||
return {
|
||||
isRunning: this.isRunning,
|
||||
config: this.config,
|
||||
port: this.config.port,
|
||||
host: this.config.host
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新配置
|
||||
*/
|
||||
async updateConfig(newConfig) {
|
||||
try {
|
||||
const needRestart = this.isRunning && (
|
||||
newConfig.port !== this.config.port ||
|
||||
newConfig.host !== this.config.host
|
||||
);
|
||||
|
||||
this.config = { ...this.config, ...newConfig };
|
||||
|
||||
if (needRestart) {
|
||||
await this.stop();
|
||||
await this.start();
|
||||
logger.info('HTTP代理服务器配置已更新并重启');
|
||||
} else {
|
||||
logger.info('HTTP代理服务器配置已更新');
|
||||
}
|
||||
|
||||
// 保存配置到数据库
|
||||
await configServiceDB.saveConfig('httpProxyConfig', this.config);
|
||||
} catch (error) {
|
||||
logger.error('更新HTTP代理配置失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从数据库加载配置
|
||||
*/
|
||||
async loadConfig() {
|
||||
try {
|
||||
const savedConfig = await configServiceDB.getConfig('httpProxyConfig');
|
||||
if (savedConfig) {
|
||||
this.config = { ...this.config, ...savedConfig };
|
||||
logger.info('HTTP代理配置已从数据库加载');
|
||||
} else {
|
||||
// 从环境变量加载默认配置
|
||||
this.config = {
|
||||
...this.config,
|
||||
port: parseInt(process.env.PROXY_PORT) || this.config.port,
|
||||
host: process.env.PROXY_HOST || this.config.host
|
||||
};
|
||||
logger.info('使用默认HTTP代理配置');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('加载HTTP代理配置失败:', error);
|
||||
// 使用默认配置
|
||||
logger.info('使用默认HTTP代理配置');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查环境变量并自动启动代理
|
||||
*/
|
||||
async checkEnvironmentAndAutoStart() {
|
||||
const autoStart = process.env.PROXY_AUTO_START;
|
||||
const proxyPort = process.env.PROXY_PORT;
|
||||
const proxyHost = process.env.PROXY_HOST;
|
||||
const enableAuth = process.env.PROXY_ENABLE_AUTH;
|
||||
const username = process.env.PROXY_USERNAME;
|
||||
const password = process.env.PROXY_PASSWORD;
|
||||
|
||||
// 检查是否应该自动启动代理
|
||||
if (autoStart === 'true' || proxyPort || proxyHost) {
|
||||
logger.info('检测到代理环境变量,尝试自动启动HTTP代理服务...');
|
||||
|
||||
const envConfig = {};
|
||||
if (proxyPort) envConfig.port = parseInt(proxyPort);
|
||||
if (proxyHost) envConfig.host = proxyHost;
|
||||
if (enableAuth === 'true') {
|
||||
envConfig.enableAuth = true;
|
||||
if (username) envConfig.username = username;
|
||||
if (password) envConfig.password = password;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.start(envConfig);
|
||||
logger.info(`HTTP代理服务已自动启动 - ${envConfig.host || '0.0.0.0'}:${envConfig.port || 8080}`);
|
||||
} catch (error) {
|
||||
logger.warn('自动启动HTTP代理服务失败:', error.message);
|
||||
}
|
||||
} else {
|
||||
logger.info('未检测到代理自动启动环境变量');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建单例实例
|
||||
const httpProxyService = new HttpProxyService();
|
||||
|
||||
module.exports = httpProxyService;
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
const axios = require('axios');
|
||||
const logger = require('../logger');
|
||||
const configService = require('./configService');
|
||||
const configServiceDB = require('./configServiceDB');
|
||||
const dockerService = require('./dockerService');
|
||||
|
||||
// 监控相关状态映射
|
||||
@@ -15,13 +15,13 @@ let monitoringInterval = null;
|
||||
// 更新监控配置
|
||||
async function updateMonitoringConfig(config) {
|
||||
try {
|
||||
const currentConfig = await configService.getConfig();
|
||||
const currentConfig = await configServiceDB.getConfig();
|
||||
currentConfig.monitoringConfig = {
|
||||
...currentConfig.monitoringConfig,
|
||||
...config
|
||||
};
|
||||
|
||||
await configService.saveConfig(currentConfig);
|
||||
await configServiceDB.saveConfig(currentConfig);
|
||||
|
||||
// 重新启动监控
|
||||
await startMonitoring();
|
||||
@@ -36,7 +36,7 @@ async function updateMonitoringConfig(config) {
|
||||
// 启动监控
|
||||
async function startMonitoring() {
|
||||
try {
|
||||
const config = await configService.getConfig();
|
||||
const config = await configServiceDB.getConfig();
|
||||
const { isEnabled, monitorInterval } = config.monitoringConfig || {};
|
||||
|
||||
// 如果监控已启用
|
||||
@@ -308,9 +308,9 @@ async function testNotification(config) {
|
||||
|
||||
// 切换监控状态
|
||||
async function toggleMonitoring(isEnabled) {
|
||||
const config = await configService.getConfig();
|
||||
const config = await configServiceDB.getConfig();
|
||||
config.monitoringConfig.isEnabled = isEnabled;
|
||||
await configService.saveConfig(config);
|
||||
await configServiceDB.saveConfig(config);
|
||||
|
||||
return startMonitoring();
|
||||
}
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
/**
|
||||
* 用户服务模块
|
||||
*/
|
||||
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
|
||||
};
|
||||
190
hubcmdui/services/userServiceDB.js
Normal file
190
hubcmdui/services/userServiceDB.js
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* 基于SQLite的用户服务模块
|
||||
*/
|
||||
const bcrypt = require('bcrypt');
|
||||
const logger = require('../logger');
|
||||
const database = require('../database/database');
|
||||
|
||||
class UserServiceDB {
|
||||
/**
|
||||
* 获取所有用户
|
||||
*/
|
||||
async getUsers() {
|
||||
try {
|
||||
const users = await database.all('SELECT * FROM users ORDER BY created_at DESC');
|
||||
return { users };
|
||||
} catch (error) {
|
||||
logger.error('获取用户列表失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过用户名获取用户
|
||||
*/
|
||||
async getUserByUsername(username) {
|
||||
try {
|
||||
return await database.get('SELECT * FROM users WHERE username = ?', [username]);
|
||||
} catch (error) {
|
||||
logger.error('获取用户失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新用户
|
||||
*/
|
||||
async createUser(username, password) {
|
||||
try {
|
||||
// 检查用户是否已存在
|
||||
const existingUser = await this.getUserByUsername(username);
|
||||
if (existingUser) {
|
||||
throw new Error('用户名已存在');
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
const result = await database.run(
|
||||
'INSERT INTO users (username, password, created_at, login_count, last_login) VALUES (?, ?, ?, ?, ?)',
|
||||
[username, hashedPassword, new Date().toISOString(), 0, null]
|
||||
);
|
||||
|
||||
return { success: true, username, id: result.id };
|
||||
} catch (error) {
|
||||
logger.error('创建用户失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户登录信息
|
||||
*/
|
||||
async updateUserLoginInfo(username) {
|
||||
try {
|
||||
const user = await this.getUserByUsername(username);
|
||||
if (user) {
|
||||
await database.run(
|
||||
'UPDATE users SET login_count = login_count + 1, last_login = ? WHERE username = ?',
|
||||
[new Date().toISOString(), username]
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('更新用户登录信息失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户统计信息
|
||||
*/
|
||||
async getUserStats(username) {
|
||||
try {
|
||||
const user = await this.getUserByUsername(username);
|
||||
|
||||
if (!user) {
|
||||
return { loginCount: '0', lastLogin: '未知', accountAge: '0' };
|
||||
}
|
||||
|
||||
// 计算账户年龄
|
||||
let accountAge = '0';
|
||||
if (user.created_at) {
|
||||
const createdDate = new Date(user.created_at);
|
||||
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.last_login) {
|
||||
const lastLoginDate = new Date(user.last_login);
|
||||
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.login_count || 0).toString(),
|
||||
lastLogin,
|
||||
accountAge
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('获取用户统计信息失败:', error);
|
||||
return { loginCount: '0', lastLogin: '未知', accountAge: '0' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改用户密码
|
||||
*/
|
||||
async changePassword(username, currentPassword, newPassword) {
|
||||
try {
|
||||
const user = await this.getUserByUsername(username);
|
||||
|
||||
if (!user) {
|
||||
throw new Error('用户不存在');
|
||||
}
|
||||
|
||||
// 验证当前密码
|
||||
const isMatch = await bcrypt.compare(currentPassword, user.password);
|
||||
if (!isMatch) {
|
||||
throw new Error('当前密码不正确');
|
||||
}
|
||||
|
||||
// 验证新密码复杂度
|
||||
if (!this.isPasswordComplex(newPassword)) {
|
||||
throw new Error('新密码不符合复杂度要求');
|
||||
}
|
||||
|
||||
// 更新密码
|
||||
const hashedNewPassword = await bcrypt.hash(newPassword, 10);
|
||||
await database.run(
|
||||
'UPDATE users SET password = ?, updated_at = ? WHERE username = ?',
|
||||
[hashedNewPassword, new Date().toISOString(), username]
|
||||
);
|
||||
|
||||
logger.info(`用户 ${username} 密码已成功修改`);
|
||||
} catch (error) {
|
||||
logger.error('修改密码失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证密码复杂度
|
||||
*/
|
||||
isPasswordComplex(password) {
|
||||
// 至少包含1个字母、1个数字和1个特殊字符,长度在8-16位之间
|
||||
const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)(?=.*[.,\-_+=()[\]{}|\\;:'"<>?/@$!%*#?&])[A-Za-z\d.,\-_+=()[\]{}|\\;:'"<>?/@$!%*#?&]{8,16}$/;
|
||||
return passwordRegex.test(password);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证用户登录
|
||||
*/
|
||||
async validateUser(username, password) {
|
||||
try {
|
||||
const user = await this.getUserByUsername(username);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isMatch = await bcrypt.compare(password, user.password);
|
||||
if (isMatch) {
|
||||
return user;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error('验证用户失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new UserServiceDB();
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"users": [
|
||||
{
|
||||
"username": "root",
|
||||
"password": "$2b$10$HBYJPwEB1gdRxcc6Bm1mKukxCC8eyJOZC7sGJN5meghvsBfoQjKtW",
|
||||
"loginCount": 2,
|
||||
"lastLogin": "2025-07-11T02:17:50.457Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
131
hubcmdui/utils/database-checker.js
Normal file
131
hubcmdui/utils/database-checker.js
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 数据库状态检查工具
|
||||
*/
|
||||
const sqlite3 = require('sqlite3');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const DB_PATH = path.join(__dirname, '../data/app.db');
|
||||
|
||||
/**
|
||||
* 检查数据库是否已完全初始化
|
||||
*/
|
||||
async function isDatabaseReady() {
|
||||
return new Promise((resolve) => {
|
||||
// 检查数据库文件是否存在
|
||||
if (!fs.existsSync(DB_PATH)) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查文件大小
|
||||
try {
|
||||
const stats = fs.statSync(DB_PATH);
|
||||
if (stats.size < 1024) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查数据库结构和数据
|
||||
const db = new sqlite3.Database(DB_PATH, (err) => {
|
||||
if (err) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查必要的表是否存在
|
||||
const requiredTables = ['users', 'configs', 'documents'];
|
||||
let checkedTables = 0;
|
||||
let allTablesReady = true;
|
||||
let tablesWithData = 0;
|
||||
|
||||
requiredTables.forEach(tableName => {
|
||||
db.get(
|
||||
`SELECT name FROM sqlite_master WHERE type='table' AND name=?`,
|
||||
[tableName],
|
||||
(err, row) => {
|
||||
if (err || !row) {
|
||||
allTablesReady = false;
|
||||
checkedTables++;
|
||||
checkComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查表是否有数据(至少用户表和配置表应该有数据)
|
||||
if (tableName === 'users' || tableName === 'configs') {
|
||||
db.get(`SELECT COUNT(*) as count FROM ${tableName}`, (err, countRow) => {
|
||||
if (err || !countRow || countRow.count === 0) {
|
||||
allTablesReady = false;
|
||||
} else {
|
||||
tablesWithData++;
|
||||
}
|
||||
checkedTables++;
|
||||
checkComplete();
|
||||
});
|
||||
} else {
|
||||
checkedTables++;
|
||||
checkComplete();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
function checkComplete() {
|
||||
if (checkedTables === requiredTables.length) {
|
||||
db.close((err) => {
|
||||
// 需要至少用户表和配置表有数据
|
||||
resolve(allTablesReady && tablesWithData >= 2);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据库统计信息
|
||||
*/
|
||||
async function getDatabaseStats() {
|
||||
return new Promise((resolve) => {
|
||||
if (!fs.existsSync(DB_PATH)) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const db = new sqlite3.Database(DB_PATH, (err) => {
|
||||
if (err) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = {};
|
||||
let completedQueries = 0;
|
||||
const tables = ['users', 'configs', 'documents'];
|
||||
|
||||
tables.forEach(table => {
|
||||
db.get(`SELECT COUNT(*) as count FROM ${table}`, (err, row) => {
|
||||
completedQueries++;
|
||||
if (!err && row) {
|
||||
stats[table] = row.count;
|
||||
} else {
|
||||
stats[table] = 0;
|
||||
}
|
||||
|
||||
if (completedQueries === tables.length) {
|
||||
db.close();
|
||||
resolve(stats);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isDatabaseReady,
|
||||
getDatabaseStats
|
||||
};
|
||||
@@ -12,12 +12,24 @@
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.6/dist/umd/popper.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<!-- 引入 jQuery (Editor.md 需要) -->
|
||||
<script src="https://cdn.bootcss.com/jquery/1.11.3/jquery.min.js"></script>
|
||||
|
||||
<!-- 引入 Markdown 编辑器 -->
|
||||
<link rel="stylesheet" href="https://uicdn.toast.com/editor/3.0.0/toastui-editor.min.css" />
|
||||
<script src="https://uicdn.toast.com/editor/3.0.0/toastui-editor.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.0.6/purify.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
|
||||
<!-- 引入 Editor.md -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.62.0/codemirror.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/editor.md@1.5.0/css/editormd.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.62.0/codemirror.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.62.0/addon/mode/overlay.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.62.0/mode/markdown/markdown.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.62.0/mode/gfm/gfm.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/4.0.2/marked.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/editor.md@1.5.0/editormd.min.js"></script>
|
||||
|
||||
<!-- 自定义样式 -->
|
||||
<link rel="stylesheet" href="css/admin.css">
|
||||
<style>
|
||||
@@ -37,6 +49,11 @@
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
text-align: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* 文档管理新建文档徽章 */
|
||||
.new-badge {
|
||||
display: inline-block;
|
||||
@@ -1481,7 +1498,7 @@
|
||||
}
|
||||
|
||||
/* 文档编辑器部分 */
|
||||
#editorContainer {
|
||||
#editor #editorContainer {
|
||||
margin-top: 2rem;
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--container-bg);
|
||||
@@ -1493,12 +1510,261 @@
|
||||
#documentTitle {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
margin-bottom: 0;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var (--radius-md);;
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
box-shadow: var(--shadow-sm);
|
||||
box-shadow: none;
|
||||
background-color: var(--container-bg);
|
||||
color: var(--text-primary);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
#documentTitle:focus {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(61, 124, 244, 0.2);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Editor.md 样式覆盖 */
|
||||
#editor #editorContainer .editormd {
|
||||
border: 1px solid var(--border-light) !important;
|
||||
border-top: none !important;
|
||||
border-radius: 0 0 var(--radius-md) var(--radius-md) !important;
|
||||
box-shadow: none !important;
|
||||
margin-top: 0 !important;
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
#editor #editorContainer .editormd-toolbar {
|
||||
background-color: var(--container-bg) !important;
|
||||
border-bottom: 1px solid var(--border-light) !important;
|
||||
padding: 0.5rem !important;
|
||||
width: 100% !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
#editor #editorContainer .editormd-toolbar button {
|
||||
background-color: transparent !important;
|
||||
border: 1px solid var(--border-light) !important;
|
||||
color: var(--text-primary) !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
margin: 0 2px !important;
|
||||
padding: 0.4rem 0.6rem !important;
|
||||
transition: all 0.2s ease !important;
|
||||
}
|
||||
|
||||
#editor #editorContainer .editormd-toolbar button:hover {
|
||||
background-color: var(--primary-color) !important;
|
||||
color: white !important;
|
||||
border-color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
#editor #editorContainer .editormd-toolbar button.active {
|
||||
background-color: var(--primary-color) !important;
|
||||
color: white !important;
|
||||
border-color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
/* 编辑区域和预览区域等宽等高 */
|
||||
#editor #editorContainer .editormd > .editormd-editor {
|
||||
width: 50% !important;
|
||||
float: left !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
#editor #editorContainer .editormd > .editormd-preview {
|
||||
width: 50% !important;
|
||||
float: right !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
#editor #editorContainer .CodeMirror {
|
||||
background-color: var(--container-bg) !important;
|
||||
color: var(--text-primary) !important;
|
||||
border: none !important;
|
||||
font-family: 'Monaco', 'Consolas', 'Courier New', monospace !important;
|
||||
font-size: 14px !important;
|
||||
line-height: 1.6 !important;
|
||||
height: 500px !important;
|
||||
width: 100% !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
#editor #editorContainer .CodeMirror .CodeMirror-cursor {
|
||||
border-left: 1px solid var(--text-primary) !important;
|
||||
}
|
||||
|
||||
#editor #editorContainer .CodeMirror .CodeMirror-selected {
|
||||
background-color: rgba(61, 124, 244, 0.2) !important;
|
||||
}
|
||||
|
||||
#editor #editorContainer .editormd-preview {
|
||||
background-color: var(--container-bg) !important;
|
||||
color: var(--text-primary) !important;
|
||||
border-left: 1px solid var(--border-light) !important;
|
||||
padding: 1rem !important;
|
||||
height: 500px !important;
|
||||
overflow-y: auto !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
/* 确保容器清除浮动 */
|
||||
#editor #editorContainer .editormd::after {
|
||||
content: "" !important;
|
||||
display: table !important;
|
||||
clear: both !important;
|
||||
}
|
||||
|
||||
#editor #editorContainer .editormd-preview h1,
|
||||
#editor #editorContainer .editormd-preview h2,
|
||||
#editor #editorContainer .editormd-preview h3,
|
||||
#editor #editorContainer .editormd-preview h4,
|
||||
#editor #editorContainer .editormd-preview h5,
|
||||
#editor #editorContainer .editormd-preview h6 {
|
||||
color: var(--text-primary) !important;
|
||||
border-bottom: 1px solid var(--border-light) !important;
|
||||
padding-bottom: 0.5rem !important;
|
||||
margin-bottom: 1rem !important;
|
||||
}
|
||||
|
||||
#editor #editorContainer .editormd-preview pre {
|
||||
background-color: rgba(0, 0, 0, 0.05) !important;
|
||||
border: 1px solid var(--border-light) !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
padding: 1rem !important;
|
||||
overflow-x: auto !important;
|
||||
}
|
||||
|
||||
#editor #editorContainer .editormd-preview blockquote {
|
||||
border-left: 4px solid var(--primary-color) !important;
|
||||
background-color: rgba(61, 124, 244, 0.05) !important;
|
||||
margin: 1rem 0 !important;
|
||||
padding: 0.5rem 1rem !important;
|
||||
}
|
||||
|
||||
#editor #editorContainer .editormd-preview table {
|
||||
border-collapse: collapse !important;
|
||||
width: 100% !important;
|
||||
margin: 1rem 0 !important;
|
||||
}
|
||||
|
||||
#editor #editorContainer .editormd-preview table th,
|
||||
#editor #editorContainer .editormd-preview table td {
|
||||
border: 1px solid var(--border-light) !important;
|
||||
padding: 0.5rem 1rem !important;
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
#editor #editorContainer .editormd-preview table th {
|
||||
background-color: rgba(61, 124, 244, 0.1) !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
/* 编辑器操作按钮样式 */
|
||||
.editor-actions {
|
||||
padding: 1rem !important;
|
||||
background-color: var(--container-bg) !important;
|
||||
border-top: 1px solid var(--border-light) !important;
|
||||
display: flex !important;
|
||||
gap: 0.75rem !important;
|
||||
justify-content: flex-end !important;
|
||||
}
|
||||
|
||||
.editor-actions .btn {
|
||||
padding: 0.75rem 1.5rem !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
font-weight: 500 !important;
|
||||
transition: all 0.2s ease !important;
|
||||
}
|
||||
|
||||
.editor-actions .btn-primary {
|
||||
background-color: var(--primary-color) !important;
|
||||
color: white !important;
|
||||
border: 1px solid var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.editor-actions .btn-primary:hover {
|
||||
background-color: var(--primary-dark) !important;
|
||||
border-color: var(--primary-dark) !important;
|
||||
transform: translateY(-2px) !important;
|
||||
box-shadow: var(--shadow-md) !important;
|
||||
}
|
||||
|
||||
.editor-actions .btn-secondary {
|
||||
background-color: transparent !important;
|
||||
color: var(--text-secondary) !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
}
|
||||
|
||||
.editor-actions .btn-secondary:hover {
|
||||
background-color: var(--text-secondary) !important;
|
||||
color: white !important;
|
||||
transform: translateY(-2px) !important;
|
||||
box-shadow: var(--shadow-sm) !important;
|
||||
}
|
||||
|
||||
/* 响应式 Editor.md 样式 */
|
||||
@media (max-width: 768px) {
|
||||
#editor #editorContainer .editormd {
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
/* 移动端编辑区域和预览区域上下布局 */
|
||||
#editor #editorContainer .editormd > .editormd-editor {
|
||||
width: 100% !important;
|
||||
float: none !important;
|
||||
margin-bottom: 1px !important;
|
||||
}
|
||||
|
||||
#editor #editorContainer .editormd > .editormd-preview {
|
||||
width: 100% !important;
|
||||
float: none !important;
|
||||
border-left: none !important;
|
||||
border-top: 1px solid var(--border-light) !important;
|
||||
}
|
||||
|
||||
#editor #editorContainer .CodeMirror {
|
||||
height: 300px !important;
|
||||
}
|
||||
|
||||
#editor #editorContainer .editormd-preview {
|
||||
height: 300px !important;
|
||||
}
|
||||
|
||||
#editor #editorContainer .editormd-toolbar {
|
||||
padding: 0.3rem !important;
|
||||
flex-wrap: wrap !important;
|
||||
}
|
||||
|
||||
#editor #editorContainer .editormd-toolbar button {
|
||||
margin: 1px !important;
|
||||
padding: 0.3rem 0.4rem !important;
|
||||
font-size: 0.85rem !important;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
flex-direction: column !important;
|
||||
gap: 0.5rem !important;
|
||||
}
|
||||
|
||||
.editor-actions .btn {
|
||||
width: 100% !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 中等屏幕适配 */
|
||||
@media (max-width: 1024px) and (min-width: 769px) {
|
||||
#editor #editorContainer .CodeMirror {
|
||||
height: 450px !important;
|
||||
}
|
||||
|
||||
#editor #editorContainer .editormd-preview {
|
||||
height: 450px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.password-hint {
|
||||
@@ -1874,11 +2140,6 @@
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* 网络测试页面美化 */
|
||||
#network-test {
|
||||
/* 可以考虑将整个 #network-test 作为一个卡片,如果它还没有被 .content-section 样式化为卡片的话 */
|
||||
}
|
||||
|
||||
/* 直接覆盖#testResults.loading的样式,防止旋转 */
|
||||
#network-test #testResults.loading {
|
||||
animation: none !important;
|
||||
@@ -2401,6 +2662,9 @@
|
||||
</div>
|
||||
<div class="admin-container" id="adminContainer" style="display: none;">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2><i class="fas fa-cogs"></i>管理面板</h2>
|
||||
</div>
|
||||
<div class="user-profile">
|
||||
<div class="user-avatar">
|
||||
<i class="fas fa-user"></i>
|
||||
@@ -2414,7 +2678,6 @@
|
||||
<div class="user-action-btn logout" id="logoutBtn">退出登录</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2><i class="fas fa-cogs"></i>管理面板</h2>
|
||||
<ul class="sidebar-nav">
|
||||
<li data-section="dashboard" class="active">
|
||||
<i class="fas fa-tachometer-alt"></i>控制面板
|
||||
@@ -2518,17 +2781,6 @@
|
||||
<i class="fas fa-plus"></i> 新建文档
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="editorContainer" style="display: none;">
|
||||
<input type="text" id="documentTitle" placeholder="请输入文档标题" autocomplete="off">
|
||||
<div id="editor">
|
||||
<!-- 编辑器将在这里初始化 -->
|
||||
</div>
|
||||
<div class="editor-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="documentManager.cancelEdit()">取消</button>
|
||||
<button type="button" class="btn btn-primary" onclick="documentManager.saveDocument()">保存文档</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 修改密码部分 -->
|
||||
|
||||
@@ -394,3 +394,87 @@
|
||||
.docker-offline-btn.secondary:hover {
|
||||
background-color: #e5e7eb;
|
||||
}
|
||||
|
||||
/* 简单 Markdown 编辑器样式 */
|
||||
.editor-toolbar {
|
||||
background-color: var(--container-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-bottom: none;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.25rem 0.25rem 0 0;
|
||||
}
|
||||
|
||||
.editor-toolbar .btn {
|
||||
margin-right: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
border: 1px solid var(--border-color);
|
||||
border-top: none;
|
||||
padding: 1rem;
|
||||
border-radius: 0 0 0.25rem 0.25rem;
|
||||
background-color: var(--container-bg);
|
||||
}
|
||||
|
||||
#markdown-editor {
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.markdown-preview {
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 1rem;
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
min-height: 400px;
|
||||
border-radius: 0.25rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.markdown-preview h1, .markdown-preview h2, .markdown-preview h3,
|
||||
.markdown-preview h4, .markdown-preview h5, .markdown-preview h6 {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.markdown-preview code {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 0.125rem;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.markdown-preview pre {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
padding: 1rem;
|
||||
border-radius: 0.25rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.markdown-preview blockquote {
|
||||
border-left: 4px solid var(--border-color);
|
||||
padding-left: 1rem;
|
||||
margin-left: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.markdown-preview table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.markdown-preview table th,
|
||||
.markdown-preview table td {
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown-preview table th {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
500
hubcmdui/web/document-editor.html
Normal file
500
hubcmdui/web/document-editor.html
Normal file
@@ -0,0 +1,500 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>文档编辑器 - HubCmdUI</title>
|
||||
<link rel="icon" href="https://cdn.jsdelivr.net/gh/dqzboy/Blog-Image/BlogCourse/docker-proxy.png" type="image/png">
|
||||
|
||||
<!-- 引入 Bootstrap CSS -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
|
||||
<!-- 引入 jQuery -->
|
||||
<script src="https://cdn.bootcss.com/jquery/1.11.3/jquery.min.js"></script>
|
||||
|
||||
<!-- 引入 Editor.md -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/editor.md@1.5.0/css/editormd.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/editor.md@1.5.0/editormd.min.js"></script>
|
||||
|
||||
<!-- 引入 SweetAlert2 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
||||
|
||||
<!-- 自定义样式 -->
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="stylesheet" href="css/admin.css">
|
||||
|
||||
<style>
|
||||
body {
|
||||
background-color: var(--background-color, #f8f9fa);
|
||||
font-family: var(--font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
background-color: var(--container-bg, #ffffff);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 2px solid var(--border-light, #e9ecef);
|
||||
background-color: var(--container-bg, #ffffff);
|
||||
flex-shrink: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.editor-title {
|
||||
color: var(--text-primary, #2c3e50);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.document-title-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
border-bottom: 2px solid var(--border-light, #e9ecef);
|
||||
background-color: var(--container-bg, #ffffff);
|
||||
color: var(--text-primary, #2c3e50);
|
||||
transition: border-color 0.3s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.document-title-input:focus {
|
||||
outline: none;
|
||||
border-bottom-color: var(--primary-color, #3d7cfa);
|
||||
}
|
||||
|
||||
.document-title-input::placeholder {
|
||||
color: var(--text-secondary, #6c757d);
|
||||
}
|
||||
|
||||
/* Editor.md 样式定制 - 铺满全屏 */
|
||||
.editormd {
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
flex: 1;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
/* 编辑器容器铺满剩余空间 */
|
||||
#editor-md {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 确保 CodeMirror 和预览区域铺满高度 */
|
||||
.editormd .editormd-editor,
|
||||
.editormd .editormd-preview {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.CodeMirror {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.btn-custom {
|
||||
padding: 0.5rem 1rem;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
transition: all 0.3s ease;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-primary-custom {
|
||||
background-color: var(--primary-color, #3d7cfa);
|
||||
color: white;
|
||||
border: 2px solid var(--primary-color, #3d7cfa);
|
||||
}
|
||||
|
||||
.btn-primary-custom:hover {
|
||||
background-color: var(--primary-dark, #2c5aa0);
|
||||
border-color: var(--primary-dark, #2c5aa0);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(61, 124, 244, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary-custom {
|
||||
background-color: transparent;
|
||||
color: var(--text-secondary, #6c757d);
|
||||
border: 2px solid var(--border-light, #e9ecef);
|
||||
}
|
||||
|
||||
.btn-secondary-custom:hover {
|
||||
background-color: var(--text-secondary, #6c757d);
|
||||
color: white;
|
||||
border-color: var(--text-secondary, #6c757d);
|
||||
}
|
||||
|
||||
.btn-success-custom {
|
||||
background-color: var(--success-color, #28a745);
|
||||
color: white;
|
||||
border: 2px solid var(--success-color, #28a745);
|
||||
}
|
||||
|
||||
.btn-success-custom:hover {
|
||||
background-color: #218838;
|
||||
border-color: #218838;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(40, 167, 69, 0.3);
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.editor-header {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: stretch;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.editor-title {
|
||||
font-size: 1.3rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
flex-direction: row;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-custom {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
}
|
||||
|
||||
.document-title-input {
|
||||
padding: 0.6rem 1rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
background-color: white;
|
||||
padding: 2rem;
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow-lg, 0 8px 16px rgba(0, 0, 0, 0.15));
|
||||
}
|
||||
|
||||
.spinner {
|
||||
border: 3px solid #f3f3f3;
|
||||
border-top: 3px solid var(--primary-color, #3d7cfa);
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="editor-container">
|
||||
<div class="editor-header">
|
||||
<h1 class="editor-title">
|
||||
<i class="fas fa-edit"></i>
|
||||
<span id="pageTitle">新建文档</span>
|
||||
</h1>
|
||||
<div class="editor-actions">
|
||||
<a href="/admin" class="btn-custom btn-secondary-custom">
|
||||
<i class="fas fa-arrow-left"></i> 返回管理面板
|
||||
</a>
|
||||
<button type="button" class="btn-custom btn-success-custom" id="saveBtn">
|
||||
<i class="fas fa-save"></i> 保存文档
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
id="documentTitle"
|
||||
class="document-title-input"
|
||||
placeholder="请输入文档标题..."
|
||||
autocomplete="off"
|
||||
>
|
||||
|
||||
<div id="editor-md">
|
||||
<textarea style="display:none;" id="editorContent"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载覆盖层 -->
|
||||
<div class="loading-overlay" id="loadingOverlay" style="display: none;">
|
||||
<div class="loading-spinner">
|
||||
<div class="spinner"></div>
|
||||
<p>正在保存文档...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let editor;
|
||||
let currentDocId = null;
|
||||
|
||||
// 从 URL 参数获取文档 ID(如果是编辑模式)
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const docId = urlParams.get('id');
|
||||
|
||||
$(document).ready(function() {
|
||||
// 初始化 Editor.md
|
||||
initEditor();
|
||||
|
||||
// 如果有文档 ID,则加载文档
|
||||
if (docId) {
|
||||
loadDocument(docId);
|
||||
document.getElementById('pageTitle').textContent = '编辑文档';
|
||||
}
|
||||
|
||||
// 绑定保存按钮事件
|
||||
document.getElementById('saveBtn').addEventListener('click', saveDocument);
|
||||
|
||||
// 绑定键盘快捷键 Ctrl+S
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.ctrlKey && e.key === 's') {
|
||||
e.preventDefault();
|
||||
saveDocument();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function initEditor() {
|
||||
editor = editormd("editor-md", {
|
||||
width: "100%",
|
||||
height: "100%", // 使用 CSS 的 flex 布局控制高度
|
||||
syncScrolling: "single",
|
||||
placeholder: "在这里编写您的 Markdown 内容...",
|
||||
path: "https://cdn.jsdelivr.net/npm/editor.md@1.5.0/lib/",
|
||||
// 使用官方默认主题
|
||||
theme: "default",
|
||||
previewTheme: "default",
|
||||
editorTheme: "default",
|
||||
markdown: "",
|
||||
codeFold: true,
|
||||
saveHTMLToTextarea: true,
|
||||
searchReplace: true,
|
||||
htmlDecode: "style,script,iframe",
|
||||
emoji: true,
|
||||
taskList: true,
|
||||
tocm: true,
|
||||
tex: false,
|
||||
flowChart: false,
|
||||
sequenceDiagram: false,
|
||||
dialogLockScreen: false,
|
||||
dialogShowMask: false,
|
||||
previewCodeHighlight: true,
|
||||
toolbar: true,
|
||||
watch: true,
|
||||
lineNumbers: true,
|
||||
lineWrapping: false,
|
||||
autoCloseTags: true,
|
||||
autoFocus: true,
|
||||
indentUnit: 4,
|
||||
// 使用官方默认工具栏配置
|
||||
onload: function() {
|
||||
console.log('Editor.md 初始化完成');
|
||||
},
|
||||
onchange: function() {
|
||||
// 标记内容已修改
|
||||
markAsModified();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function markAsModified() {
|
||||
const saveBtn = document.getElementById('saveBtn');
|
||||
if (!saveBtn.classList.contains('modified')) {
|
||||
saveBtn.classList.add('modified');
|
||||
saveBtn.innerHTML = '<i class="fas fa-save"></i> 保存文档 *';
|
||||
}
|
||||
}
|
||||
|
||||
function markAsSaved() {
|
||||
const saveBtn = document.getElementById('saveBtn');
|
||||
saveBtn.classList.remove('modified');
|
||||
saveBtn.innerHTML = '<i class="fas fa-save"></i> 保存文档';
|
||||
}
|
||||
|
||||
async function loadDocument(id) {
|
||||
try {
|
||||
const response = await fetch(`/api/documents/${id}`, {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`加载文档失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const doc = await response.json();
|
||||
currentDocId = id;
|
||||
|
||||
// 设置文档标题
|
||||
document.getElementById('documentTitle').value = doc.title || '';
|
||||
|
||||
// 设置文档内容
|
||||
if (editor && editor.setMarkdown) {
|
||||
editor.setMarkdown(doc.content || '');
|
||||
}
|
||||
|
||||
// 更新页面标题
|
||||
document.title = `编辑文档: ${doc.title} - HubCmdUI`;
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载文档失败:', error);
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: '加载失败',
|
||||
text: '无法加载文档内容,请检查网络连接或文档是否存在。',
|
||||
confirmButtonColor: '#3d7cfa'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDocument() {
|
||||
const title = document.getElementById('documentTitle').value.trim();
|
||||
const content = editor ? editor.getMarkdown() : '';
|
||||
|
||||
if (!title) {
|
||||
Swal.fire({
|
||||
icon: 'warning',
|
||||
title: '请输入标题',
|
||||
text: '文档标题不能为空',
|
||||
confirmButtonColor: '#3d7cfa'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content.trim()) {
|
||||
Swal.fire({
|
||||
icon: 'warning',
|
||||
title: '请输入内容',
|
||||
text: '文档内容不能为空',
|
||||
confirmButtonColor: '#3d7cfa'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示加载动画
|
||||
document.getElementById('loadingOverlay').style.display = 'flex';
|
||||
|
||||
try {
|
||||
const url = currentDocId ? `/api/documents/${currentDocId}` : '/api/documents';
|
||||
const method = currentDocId ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
title: title,
|
||||
content: content
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`保存失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// 如果是新建文档,更新当前文档 ID
|
||||
if (!currentDocId && result.id) {
|
||||
currentDocId = result.id;
|
||||
// 更新 URL
|
||||
window.history.replaceState({}, '', `?id=${result.id}`);
|
||||
document.getElementById('pageTitle').textContent = '编辑文档';
|
||||
}
|
||||
|
||||
// 标记为已保存
|
||||
markAsSaved();
|
||||
|
||||
// 更新页面标题
|
||||
document.title = `编辑文档: ${title} - HubCmdUI`;
|
||||
|
||||
// 显示成功消息
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: '保存成功',
|
||||
text: currentDocId ? '文档已更新' : '文档已创建',
|
||||
timer: 2000,
|
||||
showConfirmButton: false,
|
||||
toast: true,
|
||||
position: 'top-end'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('保存文档失败:', error);
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: '保存失败',
|
||||
text: '无法保存文档,请检查网络连接或稍后重试。',
|
||||
confirmButtonColor: '#3d7cfa'
|
||||
});
|
||||
} finally {
|
||||
// 隐藏加载动画
|
||||
document.getElementById('loadingOverlay').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// 页面卸载前提醒保存
|
||||
window.addEventListener('beforeunload', function(e) {
|
||||
const saveBtn = document.getElementById('saveBtn');
|
||||
if (saveBtn && saveBtn.classList.contains('modified')) {
|
||||
e.preventDefault();
|
||||
e.returnValue = '您有未保存的更改,确定要离开吗?';
|
||||
return e.returnValue;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -13,7 +13,7 @@
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<a href="/" class="logo-link">
|
||||
<img src="https://cdn.jsdelivr.net/gh/dqzboy/Blog-Image/BlogCourse/docker-proxy.png" alt="Logo" class="logo">
|
||||
<img id="mainLogo" src="https://cdn.jsdelivr.net/gh/dqzboy/Blog-Image/BlogCourse/docker-proxy.png" alt="Logo" class="logo" style="opacity: 0; transition: opacity 0.3s ease;">
|
||||
</a>
|
||||
<nav class="nav-menu" id="navMenu">
|
||||
<!-- 菜单项通过 JavaScript 动态加载 -->
|
||||
@@ -1530,7 +1530,11 @@
|
||||
|
||||
// 显示指定的文档
|
||||
function showDocument(index) {
|
||||
// console.log('显示文档索引:', index);
|
||||
// 清理之前的返回顶部按钮
|
||||
const existingBackToTopBtn = document.querySelector('.back-to-top-btn');
|
||||
if (existingBackToTopBtn) {
|
||||
existingBackToTopBtn.remove();
|
||||
}
|
||||
|
||||
if (!window.documentationData || !Array.isArray(window.documentationData)) {
|
||||
console.error('文档数据不可用');
|
||||
@@ -1673,74 +1677,192 @@
|
||||
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 {
|
||||
// 配置marked选项以获得更好的渲染效果
|
||||
marked.setOptions({
|
||||
highlight: function(code, lang) {
|
||||
// 如果有语法高亮库,可以在这里使用
|
||||
return code;
|
||||
},
|
||||
langPrefix: 'language-',
|
||||
breaks: true,
|
||||
gfm: true
|
||||
});
|
||||
|
||||
const rawHtml = marked.parse(doc.content);
|
||||
|
||||
// 创建一个临时的根元素来容纳和处理已解析的Markdown内容
|
||||
const docFragmentRoot = document.createElement('div');
|
||||
docFragmentRoot.innerHTML = rawHtml;
|
||||
|
||||
// 在这个临时根元素中查找所有的 <pre> 元素
|
||||
// 为代码块添加语言标识和复制按钮
|
||||
const preElements = docFragmentRoot.querySelectorAll('pre');
|
||||
preElements.forEach(preElement => {
|
||||
preElements.forEach((preElement, index) => {
|
||||
const codeElement = preElement.querySelector('code');
|
||||
let codeToCopy = '';
|
||||
let language = 'Code';
|
||||
|
||||
if (codeElement) {
|
||||
codeToCopy = codeElement.textContent;
|
||||
// 尝试从className获取语言信息
|
||||
const className = codeElement.className;
|
||||
const langMatch = className.match(/language-(\w+)/);
|
||||
if (langMatch) {
|
||||
language = langMatch[1].toUpperCase();
|
||||
}
|
||||
} else {
|
||||
codeToCopy = preElement.textContent;
|
||||
}
|
||||
|
||||
// 设置语言属性用于CSS显示
|
||||
preElement.setAttribute('data-language', language);
|
||||
|
||||
if (codeToCopy.trim() !== '') {
|
||||
const copyButton = document.createElement('button');
|
||||
copyButton.className = 'copy-btn'; // 应用现有样式
|
||||
copyButton.textContent = '复制';
|
||||
copyButton.onclick = function() { // 事件监听器在此处附加到按钮对象
|
||||
// console.log('[Tutorial Copy Button] Attempting to copy:', codeToCopy); // 保留此调试日志
|
||||
copyButton.className = 'copy-btn';
|
||||
copyButton.innerHTML = '<i class="fas fa-copy"></i> 复制';
|
||||
copyButton.onclick = function() {
|
||||
copyToClipboard(codeToCopy, this);
|
||||
};
|
||||
preElement.style.position = 'relative';
|
||||
preElement.appendChild(copyButton); // 按钮被追加到 docFragmentRoot 内的 preElement
|
||||
preElement.appendChild(copyButton);
|
||||
}
|
||||
});
|
||||
|
||||
// 为链接添加外部链接图标
|
||||
const links = docFragmentRoot.querySelectorAll('a');
|
||||
links.forEach(link => {
|
||||
const href = link.getAttribute('href');
|
||||
if (href && (href.startsWith('http') || href.startsWith('https'))) {
|
||||
link.innerHTML += ' <i class="fas fa-external-link-alt" style="font-size: 0.8em; margin-left: 0.25rem;"></i>';
|
||||
link.setAttribute('target', '_blank');
|
||||
link.setAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
});
|
||||
|
||||
// 为表格添加响应式包装
|
||||
const tables = docFragmentRoot.querySelectorAll('table');
|
||||
tables.forEach(table => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'table-wrapper';
|
||||
wrapper.style.overflowX = 'auto';
|
||||
wrapper.style.marginBottom = '1.5rem';
|
||||
table.parentNode.insertBefore(wrapper, table);
|
||||
wrapper.appendChild(table);
|
||||
});
|
||||
|
||||
// 清空页面上的主容器
|
||||
container.innerHTML = '';
|
||||
|
||||
// 创建文档头部
|
||||
const docHeader = document.createElement('div');
|
||||
docHeader.className = 'doc-header';
|
||||
docHeader.innerHTML = `
|
||||
<h1>${doc.title || '文档标题'}</h1>
|
||||
${doc.description ? `<p class="doc-description">${doc.description}</p>` : ''}
|
||||
`;
|
||||
container.appendChild(docHeader);
|
||||
|
||||
// 创建 .doc-content div 并将处理过的文档片段追加进去
|
||||
const docContentDiv = document.createElement('div');
|
||||
docContentDiv.className = 'doc-content';
|
||||
// 将 docFragmentRoot 的所有子节点移动到 docContentDiv,以避免多余的包裹 div
|
||||
// 将 docFragmentRoot 的所有子节点移动到 docContentDiv
|
||||
while (docFragmentRoot.firstChild) {
|
||||
docContentDiv.appendChild(docFragmentRoot.firstChild);
|
||||
}
|
||||
container.appendChild(docContentDiv); // docContentDiv 现在包含带有活动按钮的 PRE 元素
|
||||
container.appendChild(docContentDiv);
|
||||
|
||||
// 创建并追加 .doc-meta div
|
||||
const docMetaDiv = document.createElement('div');
|
||||
docMetaDiv.className = 'doc-meta';
|
||||
docMetaDiv.innerHTML = `${doc.lastUpdated || doc.updatedAt ? `<span>最后更新: ${new Date(doc.lastUpdated || doc.updatedAt).toLocaleDateString('zh-CN')}</span>` : ''}`;
|
||||
const updateTime = doc.lastUpdated || doc.updatedAt || doc.updated_at;
|
||||
if (updateTime) {
|
||||
const formattedDate = new Date(updateTime).toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
docMetaDiv.innerHTML = `
|
||||
<i class="fas fa-clock"></i>
|
||||
<span>最后更新: ${formattedDate}</span>
|
||||
`;
|
||||
}
|
||||
container.appendChild(docMetaDiv);
|
||||
|
||||
// 添加返回顶部按钮(如果内容很长)
|
||||
if (docContentDiv.scrollHeight > 1000) {
|
||||
const backToTopBtn = document.createElement('button');
|
||||
backToTopBtn.className = 'back-to-top-btn';
|
||||
backToTopBtn.innerHTML = '<i class="fas fa-arrow-up"></i>';
|
||||
backToTopBtn.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 12px rgba(61, 124, 244, 0.3);
|
||||
z-index: 1000;
|
||||
opacity: 0.8;
|
||||
transition: all 0.3s ease;
|
||||
`;
|
||||
backToTopBtn.onclick = () => {
|
||||
container.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
backToTopBtn.onmouseenter = () => {
|
||||
backToTopBtn.style.opacity = '1';
|
||||
backToTopBtn.style.transform = 'scale(1.1)';
|
||||
};
|
||||
backToTopBtn.onmouseleave = () => {
|
||||
backToTopBtn.style.opacity = '0.8';
|
||||
backToTopBtn.style.transform = 'scale(1)';
|
||||
};
|
||||
document.body.appendChild(backToTopBtn);
|
||||
|
||||
// 当切换文档时清理按钮
|
||||
container.setAttribute('data-back-to-top', 'true');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Markdown解析失败:', error);
|
||||
// 发生错误时,仍然显示原始Markdown内容 + Meta
|
||||
// 发生错误时的降级处理
|
||||
container.innerHTML = `
|
||||
<div class="doc-content">${doc.content}</div>
|
||||
<div class="doc-header">
|
||||
<h1>${doc.title || '文档标题'}</h1>
|
||||
</div>
|
||||
<div class="doc-content">
|
||||
<div class="error-container">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<h3>内容解析失败</h3>
|
||||
<p>无法正确解析文档内容,显示原始内容:</p>
|
||||
<pre><code>${doc.content}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="doc-meta">
|
||||
${doc.lastUpdated || doc.updatedAt ? `<span>最后更新: ${new Date(doc.lastUpdated || doc.updatedAt).toLocaleDateString('zh-CN')}</span>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
// marked 不可用时,直接显示内容 + Meta
|
||||
// marked 不可用时的降级处理
|
||||
container.innerHTML = `
|
||||
<div class="doc-content">${doc.content}</div>
|
||||
<div class="doc-header">
|
||||
<h1>${doc.title || '文档标题'}</h1>
|
||||
</div>
|
||||
<div class="doc-content">
|
||||
<div class="markdown-fallback">
|
||||
<p><em>Markdown 解析器未加载,显示原始内容:</em></p>
|
||||
<pre><code>${doc.content}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="doc-meta">
|
||||
${doc.lastUpdated || doc.updatedAt ? `<span>最后更新: ${new Date(doc.lastUpdated || doc.updatedAt).toLocaleDateString('zh-CN')}</span>` : ''}
|
||||
</div>
|
||||
@@ -1767,6 +1889,9 @@
|
||||
|
||||
// DOMContentLoaded 事件监听器
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 加载系统配置(包括 logo)
|
||||
loadSystemConfig();
|
||||
|
||||
// 初始化代理域名
|
||||
initProxyDomain();
|
||||
|
||||
@@ -1786,6 +1911,38 @@
|
||||
// 统一调用文档加载函数
|
||||
loadAndDisplayDocumentation();
|
||||
});
|
||||
|
||||
// 加载系统配置
|
||||
function loadSystemConfig() {
|
||||
fetch('/api/config')
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
// 如果配置加载失败,使用默认配置
|
||||
return {};
|
||||
})
|
||||
.then(config => {
|
||||
const logoElement = document.getElementById('mainLogo');
|
||||
if (logoElement) {
|
||||
// 如果有自定义logo配置且不为空,则使用自定义logo
|
||||
if (config.logo && config.logo.trim() !== '') {
|
||||
logoElement.src = config.logo;
|
||||
}
|
||||
// 如果没有配置或为空,保持默认logo不变
|
||||
// 显示logo(无论是默认还是自定义)
|
||||
logoElement.style.opacity = '1';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
// 如果出错,也要显示默认logo
|
||||
console.warn('加载配置失败:', error);
|
||||
const logoElement = document.getElementById('mainLogo');
|
||||
if (logoElement) {
|
||||
logoElement.style.opacity = '1';
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/2.0.3/marked.min.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -150,6 +150,11 @@ function applySystemConfig(config) {
|
||||
document.getElementById('proxyDomain').value = config.proxyDomain;
|
||||
}
|
||||
|
||||
// 更新logo配置输入框(管理页面不显示logo图片,只显示配置)
|
||||
if (document.getElementById('logoUrl')) {
|
||||
document.getElementById('logoUrl').value = config.logo || '';
|
||||
}
|
||||
|
||||
// 应用其他配置...
|
||||
}
|
||||
|
||||
@@ -477,7 +482,6 @@ function toggleLoadingState(isLoading, elementId, originalText = null) {
|
||||
// 如果按钮文本没有被修改为 "加载中...",则不需要恢复
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载时初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// console.log('DOM已加载,正在初始化应用...');
|
||||
|
||||
@@ -4,10 +4,6 @@
|
||||
|
||||
// 文档列表
|
||||
let documents = [];
|
||||
// 当前正在编辑的文档
|
||||
let currentDocument = null;
|
||||
// Markdown编辑器实例
|
||||
let editorMd = null;
|
||||
|
||||
// 创建documentManager对象
|
||||
const documentManager = {
|
||||
@@ -179,240 +175,10 @@ const documentManager = {
|
||||
}
|
||||
},
|
||||
|
||||
// 初始化编辑器
|
||||
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();
|
||||
}
|
||||
// 跳转到专门的文档编辑页面
|
||||
window.open('/document-editor.html', '_blank');
|
||||
},
|
||||
|
||||
// 渲染文档列表
|
||||
@@ -504,84 +270,8 @@ const documentManager = {
|
||||
|
||||
// 编辑文档
|
||||
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) sessionValid = false;
|
||||
} catch (sessionError) {
|
||||
// console.warn('检查会话状态发生网络错误:', sessionError);
|
||||
// 继续尝试,API调用时会再次处理401
|
||||
}
|
||||
|
||||
if (!sessionValid) {
|
||||
core.showAlert('您的会话已过期,请重新登录', 'warning');
|
||||
auth.showLoginModal(); // 使用 auth 模块显示登录
|
||||
core.hideLoading();
|
||||
return;
|
||||
}
|
||||
|
||||
// 不再依赖本地缓存的列表项获取content,始终从API获取完整文档
|
||||
// console.log('始终从API获取完整文档详情进行编辑,ID:', id);
|
||||
|
||||
const response = await fetch(`/api/documents/${id}`, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache', // 确保获取最新数据
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
core.showAlert('您的会话已过期或无权限访问此文档,请重新登录', 'warning');
|
||||
auth.showLoginModal();
|
||||
core.hideLoading();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
// console.error(`获取文档失败 (${response.status}):`, errorText);
|
||||
throw new Error(`获取文档内容失败: ${errorText || response.status}`);
|
||||
}
|
||||
|
||||
const docToEdit = await response.json();
|
||||
currentDocument = docToEdit; // 更新当前编辑的文档对象
|
||||
|
||||
// 确保编辑器已初始化
|
||||
if (!editorMd) {
|
||||
this.initEditor();
|
||||
// 等待编辑器初始化完成后再继续
|
||||
// 使用短延时确保编辑器DOM完全准备好
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
if (!editorMd) {
|
||||
core.showAlert('编辑器初始化失败,无法编辑文档。', 'error');
|
||||
core.hideLoading();
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('documentTitle').value = docToEdit.title || '';
|
||||
editorMd.setMarkdown(docToEdit.content || '');
|
||||
this.showEditor();
|
||||
|
||||
} catch (error) {
|
||||
// console.error('编辑文档时出错:', error);
|
||||
core.showAlert(`加载文档进行编辑失败: ${error.message}`, 'error');
|
||||
} finally {
|
||||
core.hideLoading();
|
||||
}
|
||||
// 跳转到专门的文档编辑页面,并传递文档ID
|
||||
window.open(`/document-editor.html?id=${id}`, '_blank');
|
||||
},
|
||||
|
||||
// 查看文档
|
||||
@@ -754,89 +444,3 @@ const documentManager = {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 全局公开文档管理模块
|
||||
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' });
|
||||
}
|
||||
}
|
||||
|
||||
321
hubcmdui/web/js/httpProxyManager.js
Normal file
321
hubcmdui/web/js/httpProxyManager.js
Normal file
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* HTTP代理管理模块
|
||||
*/
|
||||
|
||||
const httpProxyManager = {
|
||||
currentConfig: {},
|
||||
|
||||
// 初始化代理管理
|
||||
init: async function() {
|
||||
try {
|
||||
console.log('初始化HTTP代理管理...');
|
||||
await this.loadProxyStatus();
|
||||
this.bindEvents();
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
console.error('初始化HTTP代理管理失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 加载代理状态
|
||||
loadProxyStatus: async function() {
|
||||
try {
|
||||
const response = await fetch('/api/httpProxy/proxy/status');
|
||||
if (!response.ok) {
|
||||
throw new Error('获取代理状态失败');
|
||||
}
|
||||
|
||||
const status = await response.json();
|
||||
this.updateStatusDisplay(status);
|
||||
|
||||
// 加载配置
|
||||
const configResponse = await fetch('/api/httpProxy/proxy/config');
|
||||
if (configResponse.ok) {
|
||||
const configData = await configResponse.json();
|
||||
this.currentConfig = configData.config || {};
|
||||
this.updateConfigForm();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载代理状态失败:', error);
|
||||
this.updateStatusDisplay({
|
||||
isRunning: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 更新状态显示
|
||||
updateStatusDisplay: function(status) {
|
||||
const statusElement = document.getElementById('proxyStatus');
|
||||
const statusBadge = document.getElementById('proxyStatusBadge');
|
||||
const portInfo = document.getElementById('proxyPortInfo');
|
||||
|
||||
if (statusElement) {
|
||||
if (status.isRunning) {
|
||||
statusElement.textContent = '运行中';
|
||||
statusElement.className = 'status-running';
|
||||
if (statusBadge) {
|
||||
statusBadge.textContent = '运行中';
|
||||
statusBadge.className = 'badge badge-success';
|
||||
}
|
||||
} else {
|
||||
statusElement.textContent = '已停止';
|
||||
statusElement.className = 'status-stopped';
|
||||
if (statusBadge) {
|
||||
statusBadge.textContent = '已停止';
|
||||
statusBadge.className = 'badge badge-secondary';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (portInfo && status.config) {
|
||||
portInfo.textContent = `${status.config.host}:${status.config.port}`;
|
||||
}
|
||||
},
|
||||
|
||||
// 更新配置表单
|
||||
updateConfigForm: function() {
|
||||
if (!this.currentConfig) return;
|
||||
|
||||
const elements = {
|
||||
'proxy-port': this.currentConfig.port || 8080,
|
||||
'proxy-host': this.currentConfig.host || '0.0.0.0',
|
||||
'proxy-enable-https': this.currentConfig.enableHttps || false,
|
||||
'proxy-enable-auth': this.currentConfig.enableAuth || false,
|
||||
'proxy-username': this.currentConfig.username || '',
|
||||
'proxy-password': this.currentConfig.password || '',
|
||||
'proxy-log-requests': this.currentConfig.logRequests !== false
|
||||
};
|
||||
|
||||
for (const [id, value] of Object.entries(elements)) {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
if (element.type === 'checkbox') {
|
||||
element.checked = Boolean(value);
|
||||
} else {
|
||||
element.value = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新允许和阻止的主机列表
|
||||
this.updateHostLists();
|
||||
},
|
||||
|
||||
// 更新主机列表显示
|
||||
updateHostLists: function() {
|
||||
const allowedList = document.getElementById('allowedHostsList');
|
||||
const blockedList = document.getElementById('blockedHostsList');
|
||||
|
||||
if (allowedList && this.currentConfig.allowedHosts) {
|
||||
allowedList.innerHTML = this.currentConfig.allowedHosts
|
||||
.map(host => `<span class="host-tag">${host} <button onclick="httpProxyManager.removeAllowedHost('${host}')">×</button></span>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
if (blockedList && this.currentConfig.blockedHosts) {
|
||||
blockedList.innerHTML = this.currentConfig.blockedHosts
|
||||
.map(host => `<span class="host-tag blocked">${host} <button onclick="httpProxyManager.removeBlockedHost('${host}')">×</button></span>`)
|
||||
.join('');
|
||||
}
|
||||
},
|
||||
|
||||
// 绑定事件
|
||||
bindEvents: function() {
|
||||
// 启动代理按钮
|
||||
const startBtn = document.getElementById('startProxyBtn');
|
||||
if (startBtn) {
|
||||
startBtn.addEventListener('click', () => this.startProxy());
|
||||
}
|
||||
|
||||
// 停止代理按钮
|
||||
const stopBtn = document.getElementById('stopProxyBtn');
|
||||
if (stopBtn) {
|
||||
stopBtn.addEventListener('click', () => this.stopProxy());
|
||||
}
|
||||
|
||||
// 保存配置按钮
|
||||
const saveBtn = document.getElementById('saveProxyConfigBtn');
|
||||
if (saveBtn) {
|
||||
saveBtn.addEventListener('click', () => this.saveConfig());
|
||||
}
|
||||
|
||||
// 测试代理按钮
|
||||
const testBtn = document.getElementById('testProxyBtn');
|
||||
if (testBtn) {
|
||||
testBtn.addEventListener('click', () => this.testProxy());
|
||||
}
|
||||
|
||||
// 添加允许主机
|
||||
const addAllowedBtn = document.getElementById('addAllowedHostBtn');
|
||||
if (addAllowedBtn) {
|
||||
addAllowedBtn.addEventListener('click', () => this.addAllowedHost());
|
||||
}
|
||||
|
||||
// 添加阻止主机
|
||||
const addBlockedBtn = document.getElementById('addBlockedHostBtn');
|
||||
if (addBlockedBtn) {
|
||||
addBlockedBtn.addEventListener('click', () => this.addBlockedHost());
|
||||
}
|
||||
},
|
||||
|
||||
// 启动代理
|
||||
startProxy: async function() {
|
||||
try {
|
||||
const config = this.getConfigFromForm();
|
||||
|
||||
const response = await fetch('/api/httpProxy/proxy/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || '启动失败');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
core.showAlert('代理服务启动成功', 'success');
|
||||
this.updateStatusDisplay(result.status);
|
||||
} catch (error) {
|
||||
console.error('启动代理失败:', error);
|
||||
core.showAlert('启动代理失败: ' + error.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// 停止代理
|
||||
stopProxy: async function() {
|
||||
try {
|
||||
const response = await fetch('/api/httpProxy/proxy/stop', {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || '停止失败');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
core.showAlert('代理服务已停止', 'success');
|
||||
this.updateStatusDisplay(result.status);
|
||||
} catch (error) {
|
||||
console.error('停止代理失败:', error);
|
||||
core.showAlert('停止代理失败: ' + error.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// 保存配置
|
||||
saveConfig: async function() {
|
||||
try {
|
||||
const config = this.getConfigFromForm();
|
||||
|
||||
const response = await fetch('/api/httpProxy/proxy/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || '保存配置失败');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
this.currentConfig = config;
|
||||
core.showAlert('代理配置已保存', 'success');
|
||||
this.updateStatusDisplay(result.status);
|
||||
} catch (error) {
|
||||
console.error('保存配置失败:', error);
|
||||
core.showAlert('保存配置失败: ' + error.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// 测试代理
|
||||
testProxy: async function() {
|
||||
try {
|
||||
const testUrl = document.getElementById('proxyTestUrl')?.value || 'http://httpbin.org/ip';
|
||||
|
||||
const response = await fetch('/api/httpProxy/proxy/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ testUrl })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || '测试失败');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
core.showAlert(`代理测试成功 (${result.responseTime})`, 'success');
|
||||
} catch (error) {
|
||||
console.error('代理测试失败:', error);
|
||||
core.showAlert('代理测试失败: ' + error.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// 从表单获取配置
|
||||
getConfigFromForm: function() {
|
||||
return {
|
||||
port: parseInt(document.getElementById('proxy-port')?.value) || 8080,
|
||||
host: document.getElementById('proxy-host')?.value || '0.0.0.0',
|
||||
enableHttps: document.getElementById('proxy-enable-https')?.checked || false,
|
||||
enableAuth: document.getElementById('proxy-enable-auth')?.checked || false,
|
||||
username: document.getElementById('proxy-username')?.value || '',
|
||||
password: document.getElementById('proxy-password')?.value || '',
|
||||
logRequests: document.getElementById('proxy-log-requests')?.checked !== false,
|
||||
allowedHosts: this.currentConfig.allowedHosts || [],
|
||||
blockedHosts: this.currentConfig.blockedHosts || []
|
||||
};
|
||||
},
|
||||
|
||||
// 添加允许的主机
|
||||
addAllowedHost: function() {
|
||||
const input = document.getElementById('newAllowedHost');
|
||||
const host = input?.value?.trim();
|
||||
|
||||
if (host && !this.currentConfig.allowedHosts?.includes(host)) {
|
||||
if (!this.currentConfig.allowedHosts) {
|
||||
this.currentConfig.allowedHosts = [];
|
||||
}
|
||||
this.currentConfig.allowedHosts.push(host);
|
||||
this.updateHostLists();
|
||||
input.value = '';
|
||||
}
|
||||
},
|
||||
|
||||
// 移除允许的主机
|
||||
removeAllowedHost: function(host) {
|
||||
if (this.currentConfig.allowedHosts) {
|
||||
this.currentConfig.allowedHosts = this.currentConfig.allowedHosts.filter(h => h !== host);
|
||||
this.updateHostLists();
|
||||
}
|
||||
},
|
||||
|
||||
// 添加阻止的主机
|
||||
addBlockedHost: function() {
|
||||
const input = document.getElementById('newBlockedHost');
|
||||
const host = input?.value?.trim();
|
||||
|
||||
if (host && !this.currentConfig.blockedHosts?.includes(host)) {
|
||||
if (!this.currentConfig.blockedHosts) {
|
||||
this.currentConfig.blockedHosts = [];
|
||||
}
|
||||
this.currentConfig.blockedHosts.push(host);
|
||||
this.updateHostLists();
|
||||
input.value = '';
|
||||
}
|
||||
},
|
||||
|
||||
// 移除阻止的主机
|
||||
removeBlockedHost: function(host) {
|
||||
if (this.currentConfig.blockedHosts) {
|
||||
this.currentConfig.blockedHosts = this.currentConfig.blockedHosts.filter(h => h !== host);
|
||||
this.updateHostLists();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 全局公开模块
|
||||
window.httpProxyManager = httpProxyManager;
|
||||
@@ -762,170 +762,547 @@
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ===== 文档内容区域优化样式 ===== */
|
||||
#documentationText {
|
||||
padding: 0.5rem 1.5rem; /* 增加左右内边距 */
|
||||
max-width: 900px; /* 限制最大宽度以提高可读性 */
|
||||
/* margin-left: auto; 移除左边距自动 */
|
||||
/* margin-right: auto; 移除右边距自动 */
|
||||
padding: 2rem;
|
||||
max-width: 900px;
|
||||
line-height: 1.7;
|
||||
font-size: 16px;
|
||||
color: #2d3748;
|
||||
background: #ffffff;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
/* 标题样式层次化 */
|
||||
#documentationText h1 {
|
||||
font-size: 2.5em;
|
||||
font-weight: 700;
|
||||
color: #1a202c;
|
||||
margin: 0 0 1.5rem 0;
|
||||
padding-bottom: 0.8rem;
|
||||
border-bottom: 3px solid var(--primary-color);
|
||||
background: linear-gradient(135deg, var(--primary-color), var(--primary-dark));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
#documentationText h2 {
|
||||
font-size: 1.8em;
|
||||
margin-top: 2.5em;
|
||||
margin-bottom: 1em;
|
||||
border-bottom: 1px solid #eaecef;
|
||||
padding-bottom: 0.4em;
|
||||
font-size: 2em;
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
margin: 2.5rem 0 1.2rem 0;
|
||||
padding: 0.5rem 0 0.5rem 1rem;
|
||||
border-left: 4px solid var(--primary-color);
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: linear-gradient(90deg, rgba(61, 124, 244, 0.05) 0%, transparent 100%);
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
#documentationText h3 {
|
||||
font-size: 1.5em;
|
||||
font-weight: 600;
|
||||
color: #4a5568;
|
||||
margin: 2rem 0 1rem 0;
|
||||
padding-left: 0.5rem;
|
||||
border-left: 3px solid var(--primary-light);
|
||||
}
|
||||
|
||||
#documentationText h4 {
|
||||
font-size: 1.25em;
|
||||
font-weight: 600;
|
||||
color: #4a5568;
|
||||
margin: 1.5rem 0 0.8rem 0;
|
||||
}
|
||||
|
||||
#documentationText h5 {
|
||||
font-size: 1.1em;
|
||||
font-weight: 600;
|
||||
color: #4a5568;
|
||||
margin: 1.2rem 0 0.6rem 0;
|
||||
}
|
||||
|
||||
#documentationText h6 {
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
color: #718096;
|
||||
margin: 1rem 0 0.5rem 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* 段落和文本样式 */
|
||||
#documentationText p {
|
||||
margin-bottom: 1.5rem; /* 增大段落间距 */
|
||||
font-size: 1.05rem; /* 稍微增大正文字号 */
|
||||
}
|
||||
|
||||
#documentationText ul, #documentationText ol {
|
||||
padding-left: 1.8em; /* 调整缩进 */
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.8;
|
||||
color: #4a5568;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
#documentationText li {
|
||||
margin-bottom: 0.6rem;
|
||||
#documentationText p:first-of-type {
|
||||
font-size: 1.1rem;
|
||||
color: #2d3748;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* 列表样式优化 */
|
||||
#documentationText ul, #documentationText ol {
|
||||
padding-left: 0;
|
||||
margin: 1.5rem 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
#documentationText ul li {
|
||||
position: relative;
|
||||
margin-bottom: 0.8rem;
|
||||
padding-left: 2rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
#documentationText ul li::before {
|
||||
content: "▸";
|
||||
position: absolute;
|
||||
left: 0.5rem;
|
||||
color: var(--primary-color);
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
#documentationText ol {
|
||||
counter-reset: ordered-list;
|
||||
}
|
||||
|
||||
#documentationText ol li {
|
||||
position: relative;
|
||||
margin-bottom: 0.8rem;
|
||||
padding-left: 2.5rem;
|
||||
counter-increment: ordered-list;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
#documentationText ol li::before {
|
||||
content: counter(ordered-list);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 1.8rem;
|
||||
height: 1.8rem;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* 嵌套列表样式 */
|
||||
#documentationText ul ul, #documentationText ol ul {
|
||||
margin: 0.5rem 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
#documentationText ul ul li::before {
|
||||
content: "◦";
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
/* 代码块样式大幅优化 */
|
||||
#documentationText 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; /* 为复制按钮定位 */
|
||||
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
|
||||
color: #f8fafc;
|
||||
padding: 0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin: 2rem 0;
|
||||
box-shadow:
|
||||
0 10px 25px rgba(0, 0, 0, 0.2),
|
||||
0 4px 6px rgba(0, 0, 0, 0.1),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
position: relative;
|
||||
font-family: 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* 代码块头部 */
|
||||
.doc-content pre::before {
|
||||
content: ''; /* 模拟终端窗口的顶部栏 */
|
||||
content: attr(data-language, 'Code');
|
||||
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; /* 定位和添加下方间距 */
|
||||
padding: 0.8rem 1.5rem;
|
||||
background: linear-gradient(90deg, #374151 0%, #4b5563 100%);
|
||||
color: #d1d5db;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 模拟窗口按钮 */
|
||||
/* 模拟 macOS 窗口按钮 */
|
||||
.doc-content pre::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 15px;
|
||||
top: 0.8rem;
|
||||
right: 1.5rem;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: #FF5F57;
|
||||
background: #ff5f57;
|
||||
border-radius: 50%;
|
||||
box-shadow: 20px 0 #FEBC2E, 40px 0 #28C840;
|
||||
box-shadow:
|
||||
-20px 0 #febc2e,
|
||||
-40px 0 #28ca42;
|
||||
}
|
||||
|
||||
.doc-content pre code {
|
||||
display: block; /* 确保代码块充满 pre */
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: block;
|
||||
padding: 1.5rem;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
line-height: inherit;
|
||||
border-radius: 0;
|
||||
font-family: inherit;
|
||||
white-space: pre; /* 保留空格和换行 */
|
||||
font-size: inherit; /* 继承 pre 的字号 */
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
|
||||
}
|
||||
|
||||
/* 行内代码样式 */
|
||||
.doc-content pre code::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.doc-content pre code::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.doc-content pre code::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.doc-content pre code::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
/* 行内代码样式优化 */
|
||||
.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; /* 垂直对齐 */
|
||||
font-family: 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, monospace;
|
||||
font-size: 0.9em;
|
||||
background: linear-gradient(135deg, rgba(61, 124, 244, 0.08) 0%, rgba(61, 124, 244, 0.12) 100%);
|
||||
color: #3d7cf4;
|
||||
padding: 0.3rem 0.6rem;
|
||||
margin: 0 0.2rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(61, 124, 244, 0.15);
|
||||
font-weight: 500;
|
||||
box-shadow: 0 1px 3px rgba(61, 124, 244, 0.1);
|
||||
}
|
||||
|
||||
/* 链接样式 */
|
||||
/* 链接样式优化 */
|
||||
.doc-content a {
|
||||
color: var(--primary-dark); /* 使用更深的蓝色 */
|
||||
text-decoration: underline;
|
||||
text-decoration-color: rgba(61, 124, 244, 0.4);
|
||||
transition: all 0.2s ease;
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.doc-content a::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, var(--primary-color), var(--primary-light));
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.doc-content a:hover {
|
||||
color: var(--primary-color);
|
||||
text-decoration-color: var(--primary-color);
|
||||
background-color: rgba(61, 124, 244, 0.05);
|
||||
color: var(--primary-dark);
|
||||
background: rgba(61, 124, 244, 0.05);
|
||||
padding: 0.2rem 0.4rem;
|
||||
margin: -0.2rem -0.4rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 引用块样式 */
|
||||
.doc-content a:hover::after {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 引用块样式大幅优化 */
|
||||
.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);
|
||||
margin: 2rem 0;
|
||||
padding: 1.5rem 2rem;
|
||||
background: linear-gradient(135deg, #f7fafc 0%, #edf2f7 100%);
|
||||
border-left: 5px solid var(--primary-color);
|
||||
border-radius: 0 12px 12px 0;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
|
||||
position: relative;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.doc-content blockquote::before {
|
||||
content: '"';
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: 15px;
|
||||
font-size: 3rem;
|
||||
color: var(--primary-light);
|
||||
font-family: serif;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.doc-content blockquote p {
|
||||
margin: 0;
|
||||
color: #4a5568;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.doc-content blockquote p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* 表格样式 */
|
||||
/* 表格样式大幅优化 */
|
||||
.doc-content table {
|
||||
border-collapse: separate; /* 使用 separate 以应用圆角 */
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
margin: 1.8rem 0;
|
||||
margin: 2rem 0;
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.07);
|
||||
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;
|
||||
.doc-content th {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
padding: 1rem 1.5rem;
|
||||
text-align: left;
|
||||
font-size: 0.95rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.doc-content td {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: white;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.doc-content tr:nth-child(even) td {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.doc-content tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.doc-content th {
|
||||
font-weight: 600;
|
||||
background-color: #f7f9fc; /* 更浅的表头背景 */
|
||||
color: #4a5568;
|
||||
.doc-content tr:hover td {
|
||||
background: #eef2ff;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.doc-content tr:nth-child(even) td {
|
||||
background-color: #fafcff; /* 斑马纹 */
|
||||
/* 分隔线样式 */
|
||||
.doc-content hr {
|
||||
border: none;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, transparent, var(--primary-light), transparent);
|
||||
margin: 3rem 0;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* 元数据 (更新时间) 样式 */
|
||||
/* 图片样式优化 */
|
||||
.doc-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
margin: 1.5rem 0;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.doc-content img:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
/* 强调文本样式 */
|
||||
.doc-content strong {
|
||||
font-weight: 700;
|
||||
color: #2d3748;
|
||||
background: linear-gradient(135deg, rgba(61, 124, 244, 0.1) 0%, rgba(61, 124, 244, 0.05) 100%);
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.doc-content em {
|
||||
font-style: italic;
|
||||
color: var(--primary-dark);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 复制按钮样式优化 */
|
||||
.copy-btn {
|
||||
position: absolute;
|
||||
top: 0.8rem;
|
||||
right: 4rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #d1d5db;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.copy-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 元数据样式优化 */
|
||||
.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; /* 确保在内容下方 */
|
||||
margin-top: 3rem;
|
||||
padding: 1rem 1.5rem;
|
||||
background: linear-gradient(135deg, #f7fafc 0%, #edf2f7 100%);
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid var(--primary-light);
|
||||
font-size: 0.9rem;
|
||||
color: #718096;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 空内容和错误状态样式优化 */
|
||||
.empty-content, .error-container {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.empty-content i, .error-container i {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #cbd5e0;
|
||||
}
|
||||
|
||||
.empty-content h2, .error-container h2 {
|
||||
color: #4a5568;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-content p, .error-container p {
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
/* 加载状态样式优化 */
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.loading-container i {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 文档列表样式优化 */
|
||||
.doc-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.doc-item {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.doc-item a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
color: #4a5568;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.doc-item a:hover {
|
||||
background: rgba(61, 124, 244, 0.05);
|
||||
color: var(--primary-color);
|
||||
border-color: rgba(61, 124, 244, 0.2);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.doc-item a.active {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%);
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(61, 124, 244, 0.3);
|
||||
}
|
||||
|
||||
.doc-item a i {
|
||||
margin-right: 0.75rem;
|
||||
width: 1.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 响应式优化 */
|
||||
@media (max-width: 768px) {
|
||||
#documentationText {
|
||||
padding: 1.5rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
#documentationText h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
#documentationText h2 {
|
||||
font-size: 1.75rem;
|
||||
margin: 2rem 0 1rem 0;
|
||||
}
|
||||
|
||||
.doc-content pre {
|
||||
margin: 1.5rem -1.5rem;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.doc-content table {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.doc-content th,
|
||||
.doc-content td {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 加载中和消息提示 */
|
||||
|
||||
Reference in New Issue
Block a user