feat: 添加系统设置中修改前端端口并自动重启功能
- 后端:添加 frontend_port 配置项和重启API - 前端:添加端口设置表单和自动重启交互 - 添加前端服务管理脚本支持启动/停止/重启 - 优化重启流程,避免Network Error提示
This commit is contained in:
@@ -16,6 +16,7 @@ const initDefaultSettings = async () => {
|
||||
{ settingKey: 'session_timeout', settingValue: JSON.stringify(30), settingType: 'number', category: 'general', description: '会话超时时间(分钟)', isEditable: true },
|
||||
{ settingKey: 'max_login_attempts', settingValue: JSON.stringify(5), settingType: 'number', category: 'general', description: '最大登录尝试次数', isEditable: true },
|
||||
{ settingKey: 'maintenance_mode', settingValue: JSON.stringify(false), settingType: 'boolean', category: 'general', description: '维护模式', isEditable: true },
|
||||
{ settingKey: 'frontend_port', settingValue: JSON.stringify(3000), settingType: 'number', category: 'general', description: '前端服务端口(修改后需重启前端服务)', isEditable: true },
|
||||
|
||||
// 外观设置
|
||||
{ settingKey: 'primary_color', settingValue: JSON.stringify('#667eea'), settingType: 'string', category: 'appearance', description: '主题主色调', isEditable: true },
|
||||
@@ -227,6 +228,7 @@ router.post('/reset/:key', async (req, res) => {
|
||||
session_timeout: 30,
|
||||
max_login_attempts: 5,
|
||||
maintenance_mode: false,
|
||||
frontend_port: 3000,
|
||||
primary_color: '#667eea',
|
||||
secondary_color: '#764ba2',
|
||||
compact_mode: false,
|
||||
@@ -518,4 +520,77 @@ router.get('/system/info', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 获取前端端口配置(供 vite.config.js 使用)
|
||||
router.get('/frontend/port', async (req, res) => {
|
||||
try {
|
||||
const portSetting = await SystemSetting.findByPk('frontend_port');
|
||||
const port = portSetting ? JSON.parse(portSetting.settingValue) : 3000;
|
||||
res.json({ port });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 同步前端端口到配置文件(供前端保存设置后调用)
|
||||
router.post('/frontend/port/sync', async (req, res) => {
|
||||
try {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const portSetting = await SystemSetting.findByPk('frontend_port');
|
||||
const port = portSetting ? JSON.parse(portSetting.settingValue) : 3000;
|
||||
|
||||
// 写入前端配置文件
|
||||
const frontendDir = path.join(__dirname, '../../frontend');
|
||||
const configPath = path.join(frontendDir, '.frontend-port');
|
||||
|
||||
fs.writeFileSync(configPath, port.toString(), 'utf-8');
|
||||
|
||||
res.json({
|
||||
message: '前端端口配置已同步',
|
||||
port,
|
||||
configPath: '.frontend-port',
|
||||
notice: '配置已更新,请重启前端服务以应用新端口'
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 重启前端服务
|
||||
router.post('/frontend/restart', async (req, res) => {
|
||||
try {
|
||||
const { restartFrontend, getStatus } = require('../../scripts/frontend-manager');
|
||||
|
||||
// 先获取当前状态
|
||||
const beforeStatus = await getStatus();
|
||||
|
||||
// 执行重启
|
||||
const result = await restartFrontend();
|
||||
|
||||
res.json({
|
||||
message: '前端服务重启成功',
|
||||
before: beforeStatus,
|
||||
after: {
|
||||
pid: result.pid,
|
||||
port: result.port,
|
||||
url: `http://localhost:${result.port}`
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取前端服务状态
|
||||
router.get('/frontend/status', async (req, res) => {
|
||||
try {
|
||||
const { getStatus } = require('../../scripts/frontend-manager');
|
||||
const status = await getStatus();
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -13,14 +13,25 @@ const SystemSettings = () => {
|
||||
const [settings, setSettings] = useState({});
|
||||
const [activeTab, setActiveTab] = useState('general');
|
||||
const [systemInfo, setSystemInfo] = useState(null);
|
||||
const [frontendStatus, setFrontendStatus] = useState(null);
|
||||
const [form] = Form.useForm();
|
||||
const { reloadConfig } = useConfig();
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
fetchSystemInfo();
|
||||
fetchFrontendStatus();
|
||||
}, []);
|
||||
|
||||
const fetchFrontendStatus = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/system-settings/frontend/status');
|
||||
setFrontendStatus(response.data);
|
||||
} catch (error) {
|
||||
console.error('获取前端服务状态失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSettings = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -59,7 +70,80 @@ const SystemSettings = () => {
|
||||
});
|
||||
|
||||
await axios.put('/api/system-settings', { settings: updates });
|
||||
message.success('设置保存成功');
|
||||
|
||||
// 如果修改了前端端口,同步配置并自动重启
|
||||
if ('frontend_port' in updates) {
|
||||
try {
|
||||
// 1. 同步端口到配置文件
|
||||
await axios.post('/api/system-settings/frontend/port/sync');
|
||||
|
||||
// 2. 显示确认对话框
|
||||
Modal.confirm({
|
||||
title: '前端端口已修改',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: (
|
||||
<div>
|
||||
<p>前端端口已从 <strong>{settings.frontend_port?.value}</strong> 更改为 <strong>{updates.frontend_port}</strong></p>
|
||||
<p>是否立即重启前端服务以应用新端口?</p>
|
||||
<Alert
|
||||
message="注意"
|
||||
description="重启后页面将自动跳转到新地址,如果新端口无法访问,请手动使用原端口访问。"
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
okText: '立即重启',
|
||||
cancelText: '稍后手动重启',
|
||||
onOk: async () => {
|
||||
const newPort = updates.frontend_port;
|
||||
const newUrl = `http://localhost:${newPort}`;
|
||||
|
||||
// 先显示跳转提示,再调用重启API
|
||||
// 因为重启API会导致当前服务中断
|
||||
Modal.success({
|
||||
title: '正在重启前端服务',
|
||||
content: (
|
||||
<div>
|
||||
<p>前端服务正在重启,新端口:<strong>{newPort}</strong></p>
|
||||
<p>页面将在3秒后自动跳转到新地址...</p>
|
||||
<p>如果跳转失败,请手动访问:<a href={newUrl}>{newUrl}</a></p>
|
||||
</div>
|
||||
),
|
||||
okText: '立即跳转',
|
||||
closable: false,
|
||||
maskClosable: false,
|
||||
onOk: () => {
|
||||
window.location.href = newUrl;
|
||||
}
|
||||
});
|
||||
|
||||
// 延迟调用重启API,让用户看到提示
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
// 调用重启API(这个请求可能会因为服务重启而失败)
|
||||
await axios.post('/api/system-settings/frontend/restart', {}, { timeout: 5000 });
|
||||
} catch (error) {
|
||||
// 忽略错误,因为服务重启会导致连接中断
|
||||
console.log('重启请求已发送,服务正在重启...');
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// 延迟3秒后跳转
|
||||
setTimeout(() => {
|
||||
window.location.href = newUrl;
|
||||
}, 3000);
|
||||
}
|
||||
});
|
||||
} catch (syncError) {
|
||||
console.warn('同步前端端口配置失败:', syncError);
|
||||
message.warning('端口配置已保存,但同步到配置文件失败');
|
||||
}
|
||||
} else {
|
||||
message.success('设置保存成功');
|
||||
}
|
||||
|
||||
fetchSettings();
|
||||
// 重新加载全局配置,使更改立即生效
|
||||
await reloadConfig();
|
||||
@@ -115,14 +199,20 @@ const SystemSettings = () => {
|
||||
</Form.Item>
|
||||
);
|
||||
case 'number':
|
||||
const isPortField = key === 'frontend_port';
|
||||
return (
|
||||
<Form.Item
|
||||
key={key}
|
||||
label={data.description || key}
|
||||
name={key}
|
||||
rules={[{ required: false, message: `请输入${data.description || key}` }]}
|
||||
rules={[
|
||||
{ required: false, message: `请输入${data.description || key}` },
|
||||
...(isPortField ? [
|
||||
{ type: 'number', min: 1, max: 65535, message: '端口号必须在 1-65535 之间', transform: value => Number(value) }
|
||||
] : [])
|
||||
]}
|
||||
>
|
||||
<Input type="number" style={{ width: '100%' }} />
|
||||
<Input type="number" style={{ width: '100%' }} min={isPortField ? 1 : undefined} max={isPortField ? 65535 : undefined} />
|
||||
</Form.Item>
|
||||
);
|
||||
case 'select':
|
||||
@@ -219,9 +309,25 @@ const SystemSettings = () => {
|
||||
};
|
||||
|
||||
const renderGeneralSettings = () => {
|
||||
const generalKeys = ['site_name', 'site_logo', 'timezone', 'date_format', 'session_timeout', 'max_login_attempts', 'maintenance_mode'];
|
||||
const generalKeys = ['site_name', 'site_logo', 'timezone', 'date_format', 'session_timeout', 'max_login_attempts', 'maintenance_mode', 'frontend_port'];
|
||||
return (
|
||||
<Card title="全局配置" bordered={false}>
|
||||
{frontendStatus && (
|
||||
<Alert
|
||||
message="前端服务状态"
|
||||
description={
|
||||
<div>
|
||||
<p>当前端口:<strong>{frontendStatus.port}</strong></p>
|
||||
<p>运行状态:{frontendStatus.isRunning ? <Tag color="success">运行中</Tag> : <Tag color="error">未运行</Tag>}</p>
|
||||
{frontendStatus.portInUse && <p style={{ color: '#ff4d4f' }}>警告:端口被其他程序占用</p>}
|
||||
<p>访问地址:<a href={frontendStatus.url} target="_blank" rel="noopener noreferrer">{frontendStatus.url}</a></p>
|
||||
</div>
|
||||
}
|
||||
type={frontendStatus.isRunning ? 'success' : 'warning'}
|
||||
showIcon
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
)}
|
||||
<Form form={form} layout="vertical" onFinish={handleSaveSettings}>
|
||||
{generalKeys.map(key => {
|
||||
// 确保时区和日期格式使用select类型
|
||||
|
||||
+29
-2
@@ -1,12 +1,39 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// 从配置文件或环境变量获取端口
|
||||
const getFrontendPort = () => {
|
||||
// 优先级:环境变量 > 配置文件 > 默认值
|
||||
if (process.env.FRONTEND_PORT) {
|
||||
return parseInt(process.env.FRONTEND_PORT, 10);
|
||||
}
|
||||
|
||||
// 尝试读取配置文件
|
||||
const configPath = path.resolve(__dirname, '.frontend-port');
|
||||
if (fs.existsSync(configPath)) {
|
||||
try {
|
||||
const port = parseInt(fs.readFileSync(configPath, 'utf-8').trim(), 10);
|
||||
if (!isNaN(port) && port > 0 && port < 65536) {
|
||||
return port;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('读取前端端口配置文件失败,使用默认端口 3000');
|
||||
}
|
||||
}
|
||||
|
||||
return 3000;
|
||||
};
|
||||
|
||||
const port = getFrontendPort();
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
assetsInclude: ['**/*.hdr', '**/*.woff2'],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
port: port,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
@@ -46,4 +73,4 @@ export default defineConfig({
|
||||
optimizeDeps: {
|
||||
include: ['antd', '@ant-design/icons', 'axios', 'react-router-dom']
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* 前端服务管理器
|
||||
* 用于在系统设置中修改端口后自动重启前端服务
|
||||
*/
|
||||
|
||||
const { exec } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const net = require('net');
|
||||
|
||||
// 配置文件路径
|
||||
const CONFIG_FILE = path.join(__dirname, '../frontend/.frontend-port');
|
||||
const PID_FILE = path.join(__dirname, '../frontend/.frontend-pid');
|
||||
|
||||
// 检查端口是否被占用
|
||||
const checkPort = (port) => {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', () => {
|
||||
resolve(false); // 端口被占用
|
||||
});
|
||||
server.once('listening', () => {
|
||||
server.close();
|
||||
resolve(true); // 端口可用
|
||||
});
|
||||
server.listen(port);
|
||||
});
|
||||
};
|
||||
|
||||
// 获取当前配置的端口
|
||||
const getConfiguredPort = () => {
|
||||
try {
|
||||
if (fs.existsSync(CONFIG_FILE)) {
|
||||
const port = parseInt(fs.readFileSync(CONFIG_FILE, 'utf-8').trim(), 10);
|
||||
if (!isNaN(port) && port > 0 && port < 65536) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('读取端口配置失败:', e.message);
|
||||
}
|
||||
return 3000; // 默认端口
|
||||
};
|
||||
|
||||
// 保存PID到文件
|
||||
const savePid = (pid) => {
|
||||
try {
|
||||
fs.writeFileSync(PID_FILE, pid.toString(), 'utf-8');
|
||||
} catch (e) {
|
||||
console.error('保存PID失败:', e.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 读取PID文件
|
||||
const getSavedPid = () => {
|
||||
try {
|
||||
if (fs.existsSync(PID_FILE)) {
|
||||
const pid = parseInt(fs.readFileSync(PID_FILE, 'utf-8').trim(), 10);
|
||||
return isNaN(pid) ? null : pid;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('读取PID文件失败:', e.message);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 清除PID文件
|
||||
const clearPid = () => {
|
||||
try {
|
||||
if (fs.existsSync(PID_FILE)) {
|
||||
fs.unlinkSync(PID_FILE);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('清除PID文件失败:', e.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 检查进程是否存在
|
||||
const isProcessRunning = (pid) => {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 停止前端服务
|
||||
const stopFrontend = async () => {
|
||||
return new Promise((resolve) => {
|
||||
const pid = getSavedPid();
|
||||
|
||||
if (pid && isProcessRunning(pid)) {
|
||||
console.log(`正在停止前端服务 (PID: ${pid})...`);
|
||||
|
||||
// Windows 使用 taskkill
|
||||
const killCmd = process.platform === 'win32'
|
||||
? `taskkill /PID ${pid} /T /F`
|
||||
: `kill -9 ${pid}`;
|
||||
|
||||
exec(killCmd, (error) => {
|
||||
if (error) {
|
||||
console.warn('停止服务警告:', error.message);
|
||||
} else {
|
||||
console.log('前端服务已停止');
|
||||
}
|
||||
clearPid();
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
console.log('没有找到运行中的前端服务');
|
||||
clearPid();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 启动前端服务
|
||||
const startFrontend = async () => {
|
||||
const port = getConfiguredPort();
|
||||
|
||||
// 检查端口是否可用
|
||||
const isAvailable = await checkPort(port);
|
||||
if (!isAvailable) {
|
||||
throw new Error(`端口 ${port} 已被占用,请更换端口或关闭占用该端口的程序`);
|
||||
}
|
||||
|
||||
console.log(`正在启动前端服务,端口: ${port}...`);
|
||||
|
||||
const frontendDir = path.join(__dirname, '../frontend');
|
||||
|
||||
// 使用 exec 启动服务(Windows 兼容)
|
||||
const isWindows = process.platform === 'win32';
|
||||
const cmd = isWindows
|
||||
? `set FRONTEND_PORT=${port} && npm run dev`
|
||||
: `FRONTEND_PORT=${port} npm run dev`;
|
||||
|
||||
const child = exec(cmd, {
|
||||
cwd: frontendDir,
|
||||
windowsHide: true // Windows 隐藏窗口
|
||||
});
|
||||
|
||||
// 保存PID
|
||||
savePid(child.pid);
|
||||
console.log(`前端服务已启动 (PID: ${child.pid}),端口: ${port}`);
|
||||
|
||||
return { pid: child.pid, port };
|
||||
};
|
||||
|
||||
// 重启前端服务
|
||||
const restartFrontend = async () => {
|
||||
console.log('正在重启前端服务...');
|
||||
await stopFrontend();
|
||||
|
||||
// 等待1秒确保端口释放
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
const result = await startFrontend();
|
||||
console.log(`前端服务重启完成,访问地址: http://localhost:${result.port}`);
|
||||
return result;
|
||||
};
|
||||
|
||||
// 获取前端服务状态
|
||||
const getStatus = async () => {
|
||||
const pid = getSavedPid();
|
||||
const port = getConfiguredPort();
|
||||
const isRunning = pid ? isProcessRunning(pid) : false;
|
||||
|
||||
// 检查端口实际占用情况
|
||||
const portAvailable = await checkPort(port);
|
||||
|
||||
return {
|
||||
pid,
|
||||
port,
|
||||
isRunning,
|
||||
portInUse: !portAvailable && !isRunning, // 端口被占用但服务未运行
|
||||
url: `http://localhost:${port}`
|
||||
};
|
||||
};
|
||||
|
||||
// 命令行接口
|
||||
const main = async () => {
|
||||
const command = process.argv[2];
|
||||
|
||||
try {
|
||||
switch (command) {
|
||||
case 'start':
|
||||
await startFrontend();
|
||||
break;
|
||||
case 'stop':
|
||||
await stopFrontend();
|
||||
break;
|
||||
case 'restart':
|
||||
await restartFrontend();
|
||||
break;
|
||||
case 'status':
|
||||
const status = await getStatus();
|
||||
console.log('前端服务状态:');
|
||||
console.log(` PID: ${status.pid || '无'}`);
|
||||
console.log(` 端口: ${status.port}`);
|
||||
console.log(` 运行中: ${status.isRunning ? '是' : '否'}`);
|
||||
console.log(` 访问地址: ${status.url}`);
|
||||
if (status.portInUse) {
|
||||
console.log(' 警告: 端口被其他程序占用');
|
||||
}
|
||||
break;
|
||||
default:
|
||||
console.log('用法: node frontend-manager.js [start|stop|restart|status]');
|
||||
console.log('');
|
||||
console.log('命令:');
|
||||
console.log(' start - 启动前端服务');
|
||||
console.log(' stop - 停止前端服务');
|
||||
console.log(' restart - 重启前端服务');
|
||||
console.log(' status - 查看服务状态');
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('错误:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// 如果是直接运行此脚本
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
// 导出模块供其他文件使用
|
||||
module.exports = {
|
||||
startFrontend,
|
||||
stopFrontend,
|
||||
restartFrontend,
|
||||
getStatus,
|
||||
getConfiguredPort
|
||||
};
|
||||
Reference in New Issue
Block a user