oimp
Version:
A CLI tool for generating OI problem and packages
1,150 lines (1,067 loc) • 39.1 kB
JavaScript
const path = require("path");
const fs = require("fs").promises;
const express = require("express");
const chokidar = require("chokidar");
const { marked } = require("marked");
const chalk = require("chalk");
const hljs = require("highlight.js");
const { markedHighlight } = require("marked-highlight");
const katex = require("katex");
const http = require("http");
const WebSocket = require("ws");
const pty = require('node-pty');
const multer = require('multer');
// const upload = multer({ dest: path.join(__dirname, '../../', problemName, 'tmp') }); // 移除顶层
// 动态导入open模块
let open;
try {
const openModule = require("open");
open = openModule.default || openModule;
} catch (error) {
console.log(chalk.yellow("⚠️ open模块未安装,将不会自动打开浏览器"));
open = null;
}
const terminalWss = new WebSocket.Server({ noServer: true });
const lspWss = new WebSocket.Server({ noServer: true });
const wss = new WebSocket.Server({ noServer: true }); // 预览/热重载 ws
let upgradeRegistered = false;
function registerUpgrade(server) {
if (upgradeRegistered) return;
server.on('upgrade', (req, socket, head) => {
console.log('[WS-UPGRADE] upgrade event:', req.url);
if (req.url === '/api/terminal') {
console.log('[WS-UPGRADE] Handling /api/terminal');
terminalWss.handleUpgrade(req, socket, head, ws => {
console.log('[WS-CONN] /api/terminal connected');
terminalWss.emit('connection', ws, req);
});
} else if (req.url === '/api/lsp') {
console.log('[WS-UPGRADE] Handling /api/lsp');
lspWss.handleUpgrade(req, socket, head, ws => {
console.log('[WS-CONN] /api/lsp connected');
lspWss.emit('connection', ws, req);
});
} else if (req.url === '/' || req.url.startsWith('/?')) {
console.log('[WS-UPGRADE] Handling / or /?');
wss.handleUpgrade(req, socket, head, ws => {
console.log('[WS-CONN] / or /? connected');
wss.emit('connection', ws, req);
});
} else {
console.log('[WS-UPGRADE] Unknown ws path, closing:', req.url);
socket.destroy();
}
});
upgradeRegistered = true;
}
// 命令执行 WebSocket
const runWss = new WebSocket.Server({ noServer: true });
module.exports = async function watchCommand(problemName, options = {}) {
// 使 problemDir 为 <题目ID> 的上一级目录,实际题目目录为 problemDir/problemName
const parentDir = process.cwd();
const problemDir = path.join(parentDir, problemName);
const port = options.port || 3000;
try {
// 检查题目目录是否存在
await fs.access(problemDir);
} catch (error) {
console.error(chalk.red(`题目目录 ${problemName} 不存在`));
console.log(chalk.yellow(`💡 请先运行 'oimp init ${problemName}' 创建题目`));
process.exit(1);
}
const tmppath = path.join(problemDir, 'tmp');
try {
await fs.access(tmppath);
} catch (error) {
await fs.mkdir(tmppath);
}
const upload = multer({ dest: tmppath });
// 查找所有problem*.md和solution/*.md文件
async function findMarkdownFiles(dir) {
const files = [];
try {
const items = await fs.readdir(dir);
for (const item of items) {
const fullPath = path.join(dir, item);
const stat = await fs.stat(fullPath);
if (stat.isFile() && /^problem.*\.md$/i.test(item)) {
files.push(fullPath);
} else if (stat.isDirectory() && item === 'solution') {
// 查找solution目录下所有md文件
const solItems = await fs.readdir(fullPath);
for (const solItem of solItems) {
if (/\.md$/i.test(solItem)) {
files.push(path.join(fullPath, solItem));
}
}
}
}
} catch (error) {
console.error(chalk.red(`读取目录失败: ${error.message}`));
}
return files;
}
const markdownFiles = await findMarkdownFiles(problemDir);
if (markdownFiles.length === 0) {
console.error(chalk.red(`在题目目录中未找到 problem*.md 或 solution/*.md 文件`));
process.exit(1);
}
console.log(chalk.blue(`🚀 启动watch模式,监听题目: ${problemName}`));
console.log(chalk.gray(`📁 题目目录: ${problemDir}`));
console.log(chalk.gray(`📄 发现 ${markdownFiles.length} 个markdown文件:`));
markdownFiles.forEach(file => {
console.log(chalk.gray(` - ${path.relative(problemDir, file)}`));
});
// 创建Express服务器
const app = express();
// 添加静态文件服务
app.use('/static', express.static(path.join(__dirname, '../../templates/static')));
// 添加题目目录的静态文件服务,用于图片等资源
app.use('/images', express.static(path.join(problemDir, 'images')));
app.use('/assets', express.static(path.join(problemDir, 'assets')));
// 添加题目目录的静态文件服务,支持相对路径访问
app.use('/files', express.static(problemDir));
// 配置marked扩展
marked.use(markedHighlight({
langPrefix: 'hljs language-',
highlight(code, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(code, { language: lang }).value;
} catch (err) {
console.error('代码高亮错误:', err);
}
}
return hljs.highlightAuto(code).value;
}
}));
// 自定义数学公式渲染函数
function renderMathInText(text) {
// 先渲染块级数学公式,避免与行内公式冲突
text = text.replace(/\$\$([\s\S]*?)\$\$/g, (match, formula) => {
try {
return katex.renderToString(formula.trim(), {
displayMode: true,
throwOnError: false,
errorColor: '#cc0000'
});
} catch (error) {
console.error('KaTeX渲染错误:', error);
return `<span style="color: #cc0000;">[数学公式渲染错误: ${formula}]</span>`;
}
});
// 然后渲染行内数学公式
text = text.replace(/\$([^\$\n]+?)\$/g, (match, formula) => {
// 跳过已经被渲染的块级公式
if (match.includes('katex')) {
return match;
}
try {
return katex.renderToString(formula.trim(), {
displayMode: false,
throwOnError: false,
errorColor: '#cc0000'
});
} catch (error) {
console.error('KaTeX渲染错误:', error);
return `<span style="color: #cc0000;">[数学公式渲染错误: $${formula}$]</span>`;
}
});
return text;
}
marked.setOptions({
breaks: true,
gfm: true
});
// 处理file://协议的文件引用
function processFileReferences(content) {
// 将 file://文件名 转换为 /files/additional_file/文件名
return content.replace(/file:\/\/([^\/\s]+)/g, '/files/additional_file/$1');
}
// 生成HTML模板
function generateHTML(markdownContent, fileName = 'problem.md') {
// 先处理file://协议的文件引用
const contentWithFileRefs = processFileReferences(markdownContent);
// 然后渲染数学公式
const contentWithMath = renderMathInText(contentWithFileRefs);
// 最后渲染markdown(包含代码高亮)
const htmlContent = marked.parse(contentWithMath);
return `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${problemName} - ${fileName} 预览</title>
<link rel="stylesheet" href="/static/katex.min.css">
<style>
/* GitHub风格的CSS */
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
line-height: 1.6;
color: #24292f;
background-color: #ffffff;
margin: 0;
padding: 0;
}
.container {
max-width: 980px;
margin: 0 auto;
padding: 45px 20px;
}
.markdown-body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
word-wrap: break-word;
}
.markdown-body h1 {
font-size: 2em;
border-bottom: 1px solid #d0d7de;
color: #24292f;
font-weight: 600;
line-height: 1.25;
margin-bottom: 16px;
margin-top: 24px;
padding-bottom: 0.3em;
}
.markdown-body h2 {
font-size: 1.5em;
border-bottom: 1px solid #d0d7de;
color: #24292f;
font-weight: 600;
line-height: 1.25;
margin-bottom: 16px;
margin-top: 24px;
padding-bottom: 0.3em;
}
.markdown-body h3 {
font-size: 1.25em;
color: #24292f;
font-weight: 600;
line-height: 1.25;
margin-bottom: 16px;
margin-top: 24px;
}
.markdown-body h4 {
font-size: 1em;
color: #24292f;
font-weight: 600;
line-height: 1.25;
margin-bottom: 16px;
margin-top: 24px;
}
.markdown-body h5 {
font-size: 0.875em;
color: #24292f;
font-weight: 600;
line-height: 1.25;
margin-bottom: 16px;
margin-top: 24px;
}
.markdown-body h6 {
font-size: 0.85em;
color: #57606a;
font-weight: 600;
line-height: 1.25;
margin-bottom: 16px;
margin-top: 24px;
}
.markdown-body p {
margin-bottom: 16px;
margin-top: 0;
}
.markdown-body blockquote {
border-left: 0.25em solid #d0d7de;
color: #656d76;
margin: 0 0 16px 0;
padding: 0 1em;
}
.markdown-body ul, .markdown-body ol {
margin-bottom: 16px;
margin-top: 0;
padding-left: 2em;
}
.markdown-body li {
margin-top: 0.25em;
}
.markdown-body code {
background-color: rgba(175, 184, 193, 0.2);
border-radius: 6px;
font-size: 85%;
margin: 0;
padding: 0.2em 0.4em;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
}
.markdown-body pre {
background-color: #f6f8fa;
border-radius: 6px;
font-size: 85%;
line-height: 1.45;
overflow: auto;
padding: 16px;
margin-bottom: 16px;
margin-top: 0;
}
.markdown-body pre code {
background-color: transparent;
border: 0;
display: inline;
line-height: inherit;
margin: 0;
overflow: visible;
padding: 0;
word-wrap: normal;
}
.markdown-body table {
border-spacing: 0;
border-collapse: collapse;
margin-bottom: 16px;
margin-top: 0;
width: 100%;
}
.markdown-body table th, .markdown-body table td {
border: 1px solid #d0d7de;
padding: 6px 13px;
}
.markdown-body table th {
background-color: #f6f8fa;
font-weight: 600;
}
.markdown-body table tr:nth-child(2n) {
background-color: #f6f8fa;
}
.markdown-body img {
max-width: 100%;
box-sizing: content-box;
}
.markdown-body hr {
background-color: #d0d7de;
border: 0;
height: 0.25em;
margin: 24px 0;
padding: 0;
}
/* KaTeX数学公式样式 */
.katex {
font-size: 1.1em;
}
.katex-display {
margin: 1em 0;
text-align: center;
}
/* 状态栏样式 */
.status-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #24292f;
color: white;
padding: 10px 20px;
text-align: center;
font-size: 14px;
border-top: 1px solid #d0d7de;
}
.auto-refresh {
background: #2da44e;
color: white;
padding: 4px 8px;
border-radius: 6px;
margin-left: 10px;
font-size: 12px;
}
.last-updated {
color: #656d76;
font-size: 12px;
margin-top: 20px;
text-align: center;
border-top: 1px solid #d0d7de;
padding-top: 16px;
}
/* 代码高亮主题 - GitHub风格 */
.hljs {
color: #24292f;
background: #f6f8fa;
}
.hljs-comment, .hljs-punctuation {
color: #6e7781;
}
.hljs-attr, .hljs-attribute, .hljs-meta, .hljs-selector-attr, .hljs-selector-class, .hljs-selector-id {
color: #953800;
}
.hljs-variable, .hljs-literal, .hljs-number, .hljs-doctag {
color: #0550ae;
}
.hljs-params {
color: #24292f;
}
.hljs-function {
color: #8250df;
}
.hljs-tag, .hljs-tag .hljs-name, .hljs-tag .hljs-attr {
color: #116329;
}
.hljs-string, .hljs-regexp {
color: #0a3069;
}
.hljs-built_in, .hljs-builtin-name {
color: #953800;
}
.hljs-keyword, .hljs-selector-tag, .hljs-type {
color: #cf222e;
}
.hljs-subst {
color: #24292f;
}
.hljs-symbol, .hljs-class .hljs-title, .hljs-formula {
color: #953800;
}
.hljs-addition {
color: #116329;
background-color: #dafbe1;
}
.hljs-deletion {
color: #82071e;
background-color: #ffebe9;
}
.hljs-meta .hljs-string {
color: #0a3069;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: 600;
}
/* WebSocket连接状态指示器 */
.ws-status {
position: fixed;
top: 20px;
right: 20px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #d73a49;
transition: background-color 0.3s ease;
}
.ws-status.connected {
background: #28a745;
}
.ws-status.connecting {
background: #f6a434;
}
/* 更新通知 */
.update-notification {
position: fixed;
top: 50px;
right: 20px;
background: #28a745;
color: white;
padding: 10px 15px;
border-radius: 6px;
font-size: 14px;
transform: translateX(100%);
transition: transform 0.3s ease;
z-index: 1000;
}
.update-notification.show {
transform: translateX(0);
}
</style>
</head>
<body>
<div class="ws-status" id="wsStatus"></div>
<div class="update-notification" id="updateNotification">
📝 文件已更新,正在刷新...
</div>
<div class="container">
<div class="markdown-body">
${htmlContent}
</div>
<div class="last-updated">
最后更新: <span id="lastUpdated">${new Date().toLocaleString('zh-CN')}</span>
</div>
</div>
<!--div class="status-bar">
<span>🚀 实时预览模式</span>
<span class="auto-refresh">WebSocket连接</span>
<span>💡 保存markdown文件后页面将自动刷新</span>
</div-->
<script>
// WebSocket连接管理
let ws = null;
let reconnectAttempts = 0;
const maxReconnectAttempts = 5;
const reconnectDelay = 1000;
const wsStatus = document.getElementById('wsStatus');
const updateNotification = document.getElementById('updateNotification');
const lastUpdated = document.getElementById('lastUpdated');
function updateWsStatus(status) {
wsStatus.className = 'ws-status ' + status;
}
function showUpdateNotification() {
updateNotification.classList.add('show');
setTimeout(() => {
updateNotification.classList.remove('show');
}, 3000);
}
function connectWebSocket() {
try {
ws = new WebSocket('ws://' + window.location.host);
ws.onopen = function() {
console.log('WebSocket连接已建立');
updateWsStatus('connected');
reconnectAttempts = 0;
};
ws.onmessage = function(event) {
const data = JSON.parse(event.data);
if (data.type === 'file_updated') {
console.log('收到文件更新通知:', data.file);
showUpdateNotification();
lastUpdated.textContent = new Date().toLocaleString('zh-CN');
// 检查当前页面是否正在预览变更的文件
const currentPath = window.location.pathname;
const changedFile = data.file.replace(/\.md$/, '');
const currentFile = currentPath === '/' ? 'problem_zh' : currentPath.substring(1);
// 处理solution目录下的文件
let targetRoute = changedFile;
if (data.filePath && data.filePath.startsWith('solution/')) {
targetRoute = data.filePath.replace(/\.md$/, '');
}
// 如果变更的文件不是当前预览的文件,则切换到该文件
if (currentFile !== changedFile || (data.filePath && data.filePath.startsWith('solution/') && !currentPath.includes('solution'))) {
console.log('切换到文件:', targetRoute);
window.location.href = '/' + targetRoute;
} else {
// 延迟刷新页面,给用户时间看到通知
setTimeout(() => {
window.location.reload();
}, 500);
}
}
};
ws.onclose = function() {
console.log('WebSocket连接已关闭');
updateWsStatus('connecting');
// 自动重连
if (reconnectAttempts < maxReconnectAttempts) {
reconnectAttempts++;
console.log(\`尝试重连 (\${reconnectAttempts}/\${maxReconnectAttempts})...\`);
setTimeout(connectWebSocket, reconnectDelay * reconnectAttempts);
} else {
updateWsStatus('');
console.log('WebSocket重连失败,请刷新页面');
}
};
ws.onerror = function(error) {
console.error('WebSocket错误:', error);
updateWsStatus('');
};
} catch (error) {
console.error('WebSocket连接失败:', error);
updateWsStatus('');
}
}
// 页面加载完成后连接WebSocket
document.addEventListener('DOMContentLoaded', function() {
console.log('DOM加载完成,连接WebSocket...');
connectWebSocket();
});
// 页面卸载时关闭WebSocket
window.addEventListener('beforeunload', function() {
if (ws) {
ws.close();
}
});
</script>
</body>
</html>`;
}
// 异步读取markdown文件
async function readMarkdownFile(filePath) {
try {
const content = await fs.readFile(filePath, 'utf8');
return content;
} catch (error) {
console.error(chalk.red(`读取文件失败: ${filePath}`), error.message);
return `# 文件读取错误\n\n无法读取文件: ${path.basename(filePath)}\n\n错误信息: ${error.message}`;
}
}
// 创建HTTP服务器
const server = http.createServer(app);
// 注册唯一 ws upgrade,合并所有 ws 路径
server.on('upgrade', (req, socket, head) => {
console.log('[WS-UPGRADE] upgrade event:', req.url);
if (req.url === '/api/terminal') {
console.log('[WS-UPGRADE] Handling /api/terminal');
terminalWss.handleUpgrade(req, socket, head, ws => {
console.log('[WS-CONN] /api/terminal connected');
terminalWss.emit('connection', ws, req);
});
} else if (req.url === '/api/lsp') {
console.log('[WS-UPGRADE] Handling /api/lsp');
lspWss.handleUpgrade(req, socket, head, ws => {
console.log('[WS-CONN] /api/lsp connected');
lspWss.emit('connection', ws, req);
});
} else if (req.url === '/api/runws') {
console.log('[WS-UPGRADE] Handling /api/runws');
runWss.handleUpgrade(req, socket, head, ws => {
console.log('[WS-CONN] /api/runws connected');
runWss.emit('connection', ws, req);
});
} else if (req.url === '/' || req.url.startsWith('/?')) {
console.log('[WS-UPGRADE] Handling / or /?');
wss.handleUpgrade(req, socket, head, ws => {
console.log('[WS-CONN] / or /? connected');
wss.emit('connection', ws, req);
});
} else {
console.log('[WS-UPGRADE] Unknown ws path, closing:', req.url);
socket.destroy();
}
});
// 存储连接的客户端
const clients = new Set();
// WebSocket连接处理
wss.on('connection', (ws, req) => {
console.log('[WS-EVENT] wss connection:', req.url);
console.log(chalk.green('🔗 WebSocket客户端已连接'));
clients.add(ws);
ws.on('close', () => {
console.log(chalk.yellow('🔌 WebSocket客户端已断开'));
clients.delete(ws);
});
ws.on('error', (error) => {
console.error(chalk.red('WebSocket错误:'), error);
clients.delete(ws);
});
// 设置连接超时,防止僵尸连接
const connectionTimeout = setTimeout(() => {
if (ws.readyState === WebSocket.OPEN) {
console.log(chalk.yellow('⏰ WebSocket连接超时,强制关闭'));
ws.close();
}
}, 300000); // 5分钟超时
ws.on('close', () => {
clearTimeout(connectionTimeout);
});
});
// 广播消息给所有连接的客户端
function broadcastMessage(message) {
const messageStr = JSON.stringify(message);
clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(messageStr);
}
});
}
// ====== Web IDE 路由与API ======
// 1. IDE 主页面
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, '../../templates/static/ide.html'));
});
// 2. 文件树 API
function safeId(relPath) {
return relPath.replace(/[^a-zA-Z0-9_]/g, '_');
}
async function buildFileTreeAsync(dir, rel = '') {
const tree = [];
const items = await fs.readdir(dir, { withFileTypes: true });
for (const item of items) {
if (item.name.startsWith('.')) continue;
const fullPath = path.join(dir, item.name);
const relPath = path.join(rel, item.name);
if (item.isDirectory()) {
// 递归获取子节点
const children = await buildFileTreeAsync(fullPath, relPath);
tree.push({
id: safeId(relPath),
text: item.name,
children, // 这里是数组
data: { type: 'dir', path: relPath, rawId: relPath }
});
} else if (item.isFile()) {
tree.push({
id: safeId(relPath),
text: item.name,
icon: 'jstree-file',
children: false,
data: { type: 'file', path: relPath, rawId: relPath }
});
}
}
return tree;
}
app.get('/api/tree', async (req, res) => {
try {
const relPath = req.query.path || '';
const absPath = path.join(problemDir, relPath);
const tree = await buildFileTreeAsync(absPath, relPath);
// 根目录时包一层题目ID节点
if (!relPath) {
res.json([
{
id: '',
text: path.basename(problemDir),
children: tree,
data: { type: 'dir', path: '' }
}
]);
} else {
res.json(tree);
}
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// 3. 文件读写 API
app.get('/api/file', async (req, res) => {
const relPath = req.query.path;
if (!relPath) return res.status(400).send('缺少 path 参数');
const absPath = path.join(problemDir, relPath);
try {
const stat = await fs.stat(absPath);
if (!stat.isFile()) return res.status(400).send('不是文件');
const content = await fs.readFile(absPath, 'utf8');
res.send(content);
} catch (e) {
res.status(404).send('文件不存在');
}
});
app.post('/api/file', express.json({ limit: '2mb' }), async (req, res) => {
const { path: relPath, content } = req.body;
if (!relPath) return res.status(400).send('缺少 path');
const absPath = path.join(problemDir, relPath);
try {
// 写文件前自动创建父目录
await fs.mkdir(path.dirname(absPath), { recursive: true });
await fs.writeFile(absPath, content, 'utf8');
res.send('ok');
} catch (e) {
console.error(e, e.message);
res.status(500).send('写入失败');
}
});
// 新增图片上传 API
app.post('/api/upload', upload.single('file'), async (req, res) => {
try {
if (!req.file) {
console.error('图片上传失败: 未收到文件, req.body:', req.body);
return res.status(400).json({ error: '未收到文件' });
}
const { filename, problemId } = req.body;
if (!filename || !problemId) {
console.error('图片上传失败: 缺少参数, req.body:', req.body);
return res.status(400).json({ error: '缺少参数' });
}
const tempPath = req.file.path;
const targetDir = path.join(problemDir, 'additional_file');
await fs.mkdir(targetDir, { recursive: true });
const targetPath = path.join(targetDir, filename);
await fs.rename(tempPath, targetPath);
res.json({ relPath: `additional_file/${filename}` });
} catch (e) {
console.error('图片保存失败:', e, 'req.body:', req.body, 'req.file:', req.file);
res.status(500).json({ error: '图片保存失败', detail: e.message });
}
});
// 题目状态 API
app.get('/api/status', async (req, res) => {
try {
const statusPath = path.join(problemDir, 'status.json');
const content = await fs.readFile(statusPath, 'utf8');
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.send(content);
} catch (e) {
res.status(404).json({ error: 'status.json 不存在', detail: e.message });
}
});
// 4. 终端 WebSocket (xterm.js)
terminalWss.on('connection', (ws, req) => {
console.log('[WS-EVENT] terminalWss connection:', req.url);
const shell = process.env.SHELL || process.env.ComSpec|| 'bash';
console.log('[PTY] Spawning shell:', shell, 'cwd:', problemDir);
const ptyProcess = pty.spawn(shell, [], {
name: 'xterm-color',
cwd: path.resolve(problemDir,"../"),
env: process.env
});
ptyProcess.on('data', data => {
console.log('[PTY] shell output:', JSON.stringify(data));
ws.send(data);
});
ws.on('message', msg => ptyProcess.write(msg));
ws.on('close', () => {
console.log('[WS-EVENT] terminal ws closed, killing pty');
ptyProcess.kill();
});
});
lspWss.on('connection', (ws, req) => {
console.log('[WS-EVENT] lspWss connection:', req.url);
// 启动 clangd 进程
const clangd = spawn('clangd', [], { cwd: problemDir });
// WebSocket <-> clangd 双向转发
ws.on('message', msg => clangd.stdin.write(msg));
clangd.stdout.on('data', data => ws.send(data));
clangd.stderr.on('data', data => ws.send(data));
ws.on('close', () => clangd.kill());
});
// 命令执行 API(仅启动 ws 通道,实际执行通过 ws)
app.post('/api/run', express.json(), (req, res) => {
const { cmd } = req.body;
if (!['check','package','gendata','testsample'].includes(cmd)) {
return res.status(400).json({ error: '不支持的命令' });
}
// 前端需先建立 ws 连接 /api/runws,收到 run 指令后执行
res.json({ ok: true });
});
// 获取平台信息 API
app.get('/api/platform', (req, res) => {
const platform = process.platform;
const isWindows = platform === 'win32';
// 平台特定的diff命令,忽略空白字符和空行
const diffCommand = isWindows ? 'fc /W' : 'diff -B -w';
const rmCommand = isWindows ? 'del /f /q' : 'rm -f';
const fileCheckCommand = isWindows ? 'if exist' : 'if [ -f';
// 平台特定的diff命令变体
const diffCommandIgnoreWhitespace = isWindows ? 'fc /W' : 'diff -w';
const diffCommandIgnoreBlankLines = isWindows ? 'fc' : 'diff -B';
const diffCommandIgnoreAll = isWindows ? 'fc /W' : 'diff -B -w';
res.json({
platform,
isWindows,
diffCommand,
rmCommand,
fileCheckCommand,
// Windows下fc命令的语法
fcSyntax: isWindows ? 'fc [/W] file1 file2' : null,
// Unix下diff命令的语法
diffSyntax: !isWindows ? 'diff [-B -w] file1 file2' : null,
// Windows下del命令的语法
delSyntax: isWindows ? 'del /f /q file' : null,
// Unix下rm命令的语法
rmSyntax: !isWindows ? 'rm -f file' : null,
// Windows下文件检查语法
fileCheckSyntax: isWindows ? 'if exist file' : null,
// Unix下文件检查语法
unixFileCheckSyntax: !isWindows ? 'if [ -f file ]' : null,
// 新增的diff命令变体
diffCommandIgnoreWhitespace,
diffCommandIgnoreBlankLines,
diffCommandIgnoreAll,
// 详细说明
diffOptions: {
ignoreWhitespace: isWindows ? 'fc /W (忽略空白字符)' : 'diff -w (忽略空白字符)',
ignoreBlankLines: isWindows ? 'fc (默认忽略空行变化)' : 'diff -B (忽略空行)',
ignoreAll: isWindows ? 'fc /W (忽略空白和空行)' : 'diff -B -w (忽略空白和空行)'
}
});
});
// 编译 API
app.post('/api/compile', express.json(), async (req, res) => {
const { path: relPath, problemId } = req.body;
if (!relPath) {
return res.status(400).json({ error: '缺少文件路径' });
}
try {
const filePath = path.join(problemDir, relPath);
const ext = path.extname(filePath).toLowerCase();
// 检查文件类型
if (!['.cpp', '.cc', '.cxx'].includes(ext)) {
return res.status(400).json({ error: '不支持的文件类型,仅支持 .cpp, .cc, .cxx' });
}
// 检查文件是否存在
try {
await fs.access(filePath);
} catch (error) {
return res.status(404).json({ error: '文件不存在' });
}
// 生成输出文件名
const outputPath = path.join(path.dirname(filePath), path.basename(filePath, ext));
// 执行编译
const { execSync } = require('child_process');
const compileCmd = `g++ -O2 -std=c++14 -o "${outputPath}" "${filePath}"`;
try {
const result = execSync(compileCmd, {
cwd: path.dirname(filePath),
encoding: 'utf8',
stdio: 'pipe'
});
res.json({
success: true,
output: '编译成功',
executable: path.basename(outputPath)
});
} catch (error) {
res.status(500).json({
error: '编译失败',
output: error.stdout || error.stderr || error.message
});
}
} catch (error) {
res.status(500).json({
error: '服务器错误',
output: error.message
});
}
});
// ws 处理
runWss.on('connection', (ws, req) => {
let proc = null;
ws.on('message', msg => {
let data;
try { data = JSON.parse(msg); } catch { return; }
// 支持命令启动
if (data && data.cmd && ['check','package','gendata','testsample'].includes(data.cmd)) {
const oimpCmd = `oimp ${data.cmd} ${problemName}`;
const { spawn } = require('child_process');
proc = spawn('sh', ['-c', oimpCmd], { cwd: parentDir });
ws.send(JSON.stringify({ type: 'start', cmd: data.cmd }));
proc.stdout.on('data', chunk => {
ws.send(JSON.stringify({ type: 'stdout', data: chunk.toString() }));
});
proc.stderr.on('data', chunk => {
ws.send(JSON.stringify({ type: 'stderr', data: chunk.toString() }));
});
proc.on('close', code => {
ws.send(JSON.stringify({ type: 'close', code }));
});
}
// 支持交互输入
if (data && typeof data.input === 'string' && proc && proc.stdin) {
proc.stdin.write(data.input);
}
});
});
// 设置路由
app.get('/', async (req, res) => {
try {
const firstFile = markdownFiles[0];
const fileName = path.basename(firstFile);
const content = await readMarkdownFile(firstFile);
const html = generateHTML(content, fileName);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(html);
} catch (error) {
console.error(chalk.red('生成HTML失败:'), error);
res.status(500).send('服务器内部错误');
}
});
// 为每个markdown文件创建路由
for (const filePath of markdownFiles) {
const fileName = path.basename(filePath);
const relativePath = path.relative(problemDir, filePath);
const route = `/${relativePath.replace(/\.md$/, '')}`;
app.get(route, async (req, res) => {
try {
const content = await readMarkdownFile(filePath);
const html = generateHTML(content, fileName);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(html);
} catch (error) {
console.error(chalk.red(`生成HTML失败 (${fileName}):`), error);
res.status(500).send('服务器内部错误');
}
});
}
// 启动服务器
server.listen(port, async () => {
console.log(chalk.green(`✅ 预览服务器已启动: http://localhost:${port}`));
console.log(chalk.gray(`📝 正在监听 ${markdownFiles.length} 个文件`));
console.log(chalk.gray(`💡 保存markdown文件后页面将自动刷新`));
console.log(chalk.gray(`🛑 按 Ctrl+C 停止服务器`));
// 自动打开浏览器
if (open) {
try {
await open(`http://localhost:${port}`);
} catch (error) {
console.log(chalk.yellow('⚠️ 无法自动打开浏览器'));
}
}
// 设置文件监听
const watcher = chokidar.watch(problemDir, {
persistent: true,
ignoreInitial: true,
awaitWriteFinish: {
stabilityThreshold: 100,
pollInterval: 100
},
usePolling: false,
interval: 100,
depth: 6,
ignored: /node_modules|\.snapshots|\.git/
});
watcher.on('change', async (filePath) => {
const fileName = path.basename(filePath);
const relativePath = path.relative(problemDir, filePath);
console.log(chalk.blue(`📝 文件已更新: ${relativePath}`));
// 广播更新消息给所有连接的客户端
broadcastMessage({
type: 'file_updated',
file: fileName,
filePath: relativePath,
timestamp: new Date().toISOString()
});
// 目录内容变化时也推送 tree_changed
broadcastMessage({ type: 'tree_changed', timestamp: new Date().toISOString() });
// status.json 变化时推送 status_changed
if (fileName === 'status.json') {
broadcastMessage({ type: 'status_changed', timestamp: new Date().toISOString() });
}
});
watcher.on('add', (filePath) => {
broadcastMessage({ type: 'tree_changed', timestamp: new Date().toISOString() });
});
watcher.on('unlink', (filePath) => {
broadcastMessage({ type: 'tree_changed', timestamp: new Date().toISOString() });
});
watcher.on('addDir', (dirPath) => {
broadcastMessage({ type: 'tree_changed', timestamp: new Date().toISOString() });
});
watcher.on('unlinkDir', (dirPath) => {
broadcastMessage({ type: 'tree_changed', timestamp: new Date().toISOString() });
});
watcher.on('error', (error) => {
console.error(chalk.red('文件监听错误:'), error);
});
watcher.on('ready', () => {
console.log(chalk.green('📁 文件监听器已就绪'));
});
// 优雅关闭函数
const gracefulShutdown = async (signal) => {
console.log(chalk.yellow(`\n🛑 收到信号 ${signal},正在关闭watch服务器...`));
try {
// 强制关闭所有WebSocket连接
clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.close();
}
});
clients.clear();
// 关闭WebSocket服务器
wss.close();
// 关闭文件监听
await watcher.close();
// 设置超时关闭HTTP服务器
const closeTimeout = setTimeout(() => {
console.log(chalk.yellow('⚠️ 强制关闭服务器...'));
process.exit(0);
}, 3000);
server.close(() => {
clearTimeout(closeTimeout);
console.log(chalk.green('✅ watch服务器已关闭'));
process.exit(0);
});
} catch (error) {
console.error(chalk.red('关闭服务器时出错:'), error);
process.exit(1);
}
};
// 监听多种关闭信号
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGQUIT', () => gracefulShutdown('SIGQUIT'));
});
}