mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat: 全新的Vue3管理后台(admin-spa)和路由重构
🎨 新增功能: - 使用Vue3 + Vite构建的全新管理后台界面 - 支持Tab切换的API统计页面(统计查询/使用教程) - 优雅的胶囊式Tab切换设计 - 同步了PR #106的会话窗口管理功能 - 完整的响应式设计和骨架屏加载状态 🔧 路由调整: - 新版管理后台部署在 /admin-next/ 路径 - 将根路径 / 重定向到 /admin-next/api-stats - 将 /web 页面路由重定向到新版,保留 /web/auth/* 认证路由 - 将 /apiStats 页面路由重定向到新版,保留API端点 🗑️ 清理工作: - 删除旧版 web/admin/ 静态文件 - 删除旧版 web/apiStats/ 静态文件 - 清理相关的文件服务代码 🐛 修复问题: - 修复重定向循环问题 - 修复环境变量配置 - 修复路由404错误 - 优化构建配置 🚀 生成方式:使用 Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
57
src/app.js
57
src/app.js
@@ -121,19 +121,70 @@ class Application {
|
||||
this.app.set('trust proxy', 1);
|
||||
}
|
||||
|
||||
// 🎨 新版管理界面静态文件服务(必须在其他路由之前)
|
||||
const adminSpaPath = path.join(__dirname, '..', 'web', 'admin-spa', 'dist');
|
||||
if (fs.existsSync(adminSpaPath)) {
|
||||
// 处理不带斜杠的路径,重定向到带斜杠的路径
|
||||
this.app.get('/admin-next', (req, res) => {
|
||||
res.redirect(301, '/admin-next/');
|
||||
});
|
||||
|
||||
// 安全的静态文件服务配置
|
||||
this.app.use('/admin-next/', express.static(adminSpaPath, {
|
||||
maxAge: '1d', // 缓存静态资源1天
|
||||
etag: true,
|
||||
lastModified: true,
|
||||
index: 'index.html',
|
||||
// 安全选项:禁止目录遍历
|
||||
dotfiles: 'deny', // 拒绝访问点文件
|
||||
redirect: false, // 禁止目录重定向
|
||||
// 自定义错误处理
|
||||
setHeaders: (res, path) => {
|
||||
// 为不同类型的文件设置适当的缓存策略
|
||||
if (path.endsWith('.js') || path.endsWith('.css')) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); // 1年缓存
|
||||
} else if (path.endsWith('.html')) {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// 处理SPA路由:所有未匹配的admin-next路径都返回index.html
|
||||
this.app.get('/admin-next/*', (req, res, next) => {
|
||||
// 安全检查:防止路径遍历攻击
|
||||
const requestPath = req.path.replace('/admin-next/', '');
|
||||
if (requestPath.includes('..') || requestPath.includes('//') || requestPath.includes('\\')) {
|
||||
return res.status(400).json({ error: 'Invalid path' });
|
||||
}
|
||||
|
||||
// 如果是静态资源请求但文件不存在,返回404
|
||||
if (requestPath.match(/\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf)$/i)) {
|
||||
return res.status(404).send('Not found');
|
||||
}
|
||||
|
||||
// 其他路径返回index.html(SPA路由处理)
|
||||
res.sendFile(path.join(adminSpaPath, 'index.html'));
|
||||
});
|
||||
|
||||
logger.info('✅ Admin SPA (next) static files mounted at /admin-next/');
|
||||
} else {
|
||||
logger.warn('⚠️ Admin SPA dist directory not found, skipping /admin-next route');
|
||||
}
|
||||
|
||||
// 🛣️ 路由
|
||||
this.app.use('/api', apiRoutes);
|
||||
this.app.use('/claude', apiRoutes); // /claude 路由别名,与 /api 功能相同
|
||||
this.app.use('/admin', adminRoutes);
|
||||
// 使用 web 路由(包含 auth 和页面重定向)
|
||||
this.app.use('/web', webRoutes);
|
||||
this.app.use('/apiStats', apiStatsRoutes);
|
||||
this.app.use('/gemini', geminiRoutes);
|
||||
this.app.use('/openai/gemini', openaiGeminiRoutes);
|
||||
this.app.use('/openai/claude', openaiClaudeRoutes);
|
||||
|
||||
// 🏠 根路径重定向到API统计页面
|
||||
// 🏠 根路径重定向到新版管理界面
|
||||
this.app.get('/', (req, res) => {
|
||||
res.redirect('/apiStats');
|
||||
res.redirect('/admin-next/api-stats');
|
||||
});
|
||||
|
||||
// 🏥 增强的健康检查端点
|
||||
@@ -321,7 +372,7 @@ class Application {
|
||||
|
||||
this.server = this.app.listen(config.server.port, config.server.host, () => {
|
||||
logger.start(`🚀 Claude Relay Service started on ${config.server.host}:${config.server.port}`);
|
||||
logger.info(`🌐 Web interface: http://${config.server.host}:${config.server.port}/web`);
|
||||
logger.info(`🌐 Web interface: http://${config.server.host}:${config.server.port}/admin-next/api-stats`);
|
||||
logger.info(`🔗 API endpoint: http://${config.server.host}:${config.server.port}/api/v1/messages`);
|
||||
logger.info(`⚙️ Admin API: http://${config.server.host}:${config.server.port}/admin`);
|
||||
logger.info(`🏥 Health check: http://${config.server.host}:${config.server.port}/health`);
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const redis = require('../models/redis');
|
||||
const logger = require('../utils/logger');
|
||||
const apiKeyService = require('../services/apiKeyService');
|
||||
@@ -8,45 +6,9 @@ const CostCalculator = require('../utils/costCalculator');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 🛡️ 安全文件服务函数
|
||||
function serveStaticFile(req, res, filename, contentType) {
|
||||
const filePath = path.join(__dirname, '../../web/apiStats', filename);
|
||||
|
||||
try {
|
||||
// 检查文件是否存在
|
||||
if (!fs.existsSync(filePath)) {
|
||||
logger.error(`❌ API Stats file not found: ${filePath}`);
|
||||
return res.status(404).json({ error: 'File not found' });
|
||||
}
|
||||
|
||||
// 读取并返回文件内容
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
res.setHeader('Expires', '0');
|
||||
res.send(content);
|
||||
|
||||
logger.info(`📄 Served API Stats file: ${filename}`);
|
||||
} catch (error) {
|
||||
logger.error(`❌ Error serving API Stats file ${filename}:`, error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
||||
// 🏠 API Stats 主页面
|
||||
// 🏠 重定向页面请求到新版 admin-spa
|
||||
router.get('/', (req, res) => {
|
||||
serveStaticFile(req, res, 'index.html', 'text/html; charset=utf-8');
|
||||
});
|
||||
|
||||
// 📱 JavaScript 文件
|
||||
router.get('/app.js', (req, res) => {
|
||||
serveStaticFile(req, res, 'app.js', 'application/javascript; charset=utf-8');
|
||||
});
|
||||
|
||||
// 🎨 CSS 文件
|
||||
router.get('/style.css', (req, res) => {
|
||||
serveStaticFile(req, res, 'style.css', 'text/css; charset=utf-8');
|
||||
res.redirect(301, '/admin-next/api-stats');
|
||||
});
|
||||
|
||||
// 🔑 获取 API Key 对应的 ID
|
||||
|
||||
@@ -12,52 +12,10 @@ const router = express.Router();
|
||||
// 🏠 服务静态文件
|
||||
router.use('/assets', express.static(path.join(__dirname, '../../web/assets')));
|
||||
|
||||
// 🔒 Web管理界面文件白名单 - 仅允许这些特定文件
|
||||
const ALLOWED_FILES = {
|
||||
'index.html': {
|
||||
path: path.join(__dirname, '../../web/admin/index.html'),
|
||||
contentType: 'text/html; charset=utf-8'
|
||||
},
|
||||
'app.js': {
|
||||
path: path.join(__dirname, '../../web/admin/app.js'),
|
||||
contentType: 'application/javascript; charset=utf-8'
|
||||
},
|
||||
'style.css': {
|
||||
path: path.join(__dirname, '../../web/admin/style.css'),
|
||||
contentType: 'text/css; charset=utf-8'
|
||||
},
|
||||
};
|
||||
|
||||
// 🛡️ 安全文件服务函数
|
||||
function serveWhitelistedFile(req, res, filename) {
|
||||
const fileConfig = ALLOWED_FILES[filename];
|
||||
|
||||
if (!fileConfig) {
|
||||
logger.security(`🚨 Attempted access to non-whitelisted file: ${filename}`);
|
||||
return res.status(404).json({ error: 'File not found' });
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查文件是否存在
|
||||
if (!fs.existsSync(fileConfig.path)) {
|
||||
logger.error(`❌ Whitelisted file not found: ${fileConfig.path}`);
|
||||
return res.status(404).json({ error: 'File not found' });
|
||||
}
|
||||
|
||||
// 读取并返回文件内容
|
||||
const content = fs.readFileSync(fileConfig.path, 'utf8');
|
||||
res.setHeader('Content-Type', fileConfig.contentType);
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
res.setHeader('Expires', '0');
|
||||
res.send(content);
|
||||
|
||||
logger.info(`📄 Served whitelisted file: ${filename}`);
|
||||
} catch (error) {
|
||||
logger.error(`❌ Error serving file ${filename}:`, error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
// 🌐 页面路由重定向到新版 admin-spa
|
||||
router.get('/', (req, res) => {
|
||||
res.redirect(301, '/admin-next/api-stats');
|
||||
});
|
||||
|
||||
// 🔐 管理员登录
|
||||
router.post('/auth/login', async (req, res) => {
|
||||
@@ -387,22 +345,4 @@ router.post('/auth/refresh', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 🌐 Web管理界面路由 - 使用固定白名单
|
||||
router.get('/', (req, res) => {
|
||||
serveWhitelistedFile(req, res, 'index.html');
|
||||
});
|
||||
|
||||
router.get('/app.js', (req, res) => {
|
||||
serveWhitelistedFile(req, res, 'app.js');
|
||||
});
|
||||
|
||||
router.get('/style.css', (req, res) => {
|
||||
serveWhitelistedFile(req, res, 'style.css');
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
// 🔑 Gemini OAuth 回调页面
|
||||
|
||||
module.exports = router;
|
||||
@@ -654,7 +654,7 @@ async function refreshAccountToken(accountId) {
|
||||
errorMessage: error.message
|
||||
});
|
||||
} catch (updateError) {
|
||||
logger.error(`Failed to update account status after refresh error:`, updateError);
|
||||
logger.error('Failed to update account status after refresh error:', updateError);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user