const express = require('express');
const Database = require('better-sqlite3');
const marked = require('marked');
const path = require('path');
const fs = require('fs');
const app = express();
const PORT = 8082; // 新端口,避免冲突
const DIRECTORY = '/data/ai-output';
// 初始化SQLite数据库
const db = new Database('/data/ai-output/file_server.db');
// 创建表
db.exec(`
CREATE TABLE IF NOT EXISTS todo_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_path TEXT NOT NULL,
item_index INTEGER NOT NULL,
checked INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(file_path, item_index)
);
CREATE TABLE IF NOT EXISTS fold_states (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_path TEXT NOT NULL,
heading_id TEXT NOT NULL,
collapsed INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(file_path, heading_id)
);
`);
// 中间件
app.use(express.json());
app.use(express.static('public'));
// CORS支持
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
return res.sendStatus(200);
}
next();
});
// 自定义Markdown渲染器
class CustomRenderer extends marked.Renderer {
constructor(options) {
super(options);
this.todoIndex = 0;
this.headings = [];
this.currentFilePath = options.filePath || '';
}
// 待办列表渲染
listitem(text) {
if (text.startsWith('<input')) {
return `<li class="todo-item">${text}</li>\n`;
}
return super.listitem(text);
}
// 标题渲染(支持折叠)
heading(text, level, raw, slugger) {
const id = slugger.slug(raw);
this.headings.push({ level, text: raw, id });
const foldState = db.prepare(`
SELECT collapsed FROM fold_states
WHERE file_path = ? AND heading_id = ?
`).get(this.currentFilePath, id);
const isCollapsed = foldState ? foldState.collapsed : 0;
const collapseIcon = isCollapsed ? '▶' : '▼';
return `
<div class="heading-wrapper" data-heading-id="${id}">
<button class="fold-btn" onclick="toggleFold('${id}')">${collapseIcon}</button>
<h${level} id="${id}" class="${isCollapsed ? 'collapsed' : ''}">${text}</h${level}>
</div>
`;
}
}
// API: 更新待办项状态
app.post('/api/todo/toggle', (req, res) => {
const { filePath, itemIndex, checked } = req.body;
try {
const stmt = db.prepare(`
INSERT INTO todo_items (file_path, item_index, checked, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(file_path, item_index)
DO UPDATE SET checked = ?, updated_at = datetime('now')
`);
stmt.run(filePath, itemIndex, checked ? 1 : 0, checked ? 1 : 0);
res.json({ success: true });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// API: 更新折叠状态
app.post('/api/fold/toggle', (req, res) => {
const { filePath, headingId, collapsed } = req.body;
try {
const stmt = db.prepare(`
INSERT INTO fold_states (file_path, heading_id, collapsed, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(file_path, heading_id)
DO UPDATE SET collapsed = ?, updated_at = datetime('now')
`);
stmt.run(filePath, headingId, collapsed ? 1 : 0, collapsed ? 1 : 0);
res.json({ success: true });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// API: 获取文档大纲
app.get('/api/outline', (req, res) => {
const { filePath } = req.query;
try {
const content = fs.readFileSync(path.join(DIRECTORY, filePath), 'utf-8');
const renderer = new CustomRenderer({ filePath });
marked.use({ renderer });
marked.parse(content);
res.json({ success: true, outline: renderer.headings });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// Markdown渲染页面 - 使用正则匹配所有路径
app.get(/^\/view\/(.*)/, (req, res) => {
const filePath = decodeURIComponent(req.params[0]);
const fullPath = path.join(DIRECTORY, filePath);
if (!fs.existsSync(fullPath)) {
return res.status(404).send('文件未找到: ' + filePath);
}
const content = fs.readFileSync(fullPath, 'utf-8');
// 读取待办项状态
const todoStates = db.prepare(`
SELECT item_index, checked FROM todo_items WHERE file_path = ?
`).all(filePath);
const todoMap = new Map(todoStates.map(t => [t.item_index, t.checked]));
// 预处理待办列表 - 在Markdown解析前替换
let processedContent = content;
let todoIndex = 0;
processedContent = processedContent.replace(/^- \[([ xX])\] (.*)$/gm, (match, checked, text) => {
const isChecked = todoMap.get(todoIndex) || checked.toLowerCase() === 'x';
const checkbox = `<li class="todo-item"><input type="checkbox" class="todo-checkbox" data-index="${todoIndex}" data-file="${filePath}" ${isChecked ? 'checked' : ''}><span>${text}</span></li>`;
todoIndex++;
return checkbox;
});
// 移除markdown中可能存在的script标签(安全过滤)
processedContent = processedContent.replace(/<script[\s\S]*?<\/script>/gi, '');
// 使用标准marked渲染
const htmlContent = marked.parse(processedContent);
res.send(`
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${path.basename(filePath)}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", sans-serif;
line-height: 1.8;
background: #f5f5f5;
color: #333;
display: flex;
}
/* 大纲侧边栏 */
.outline-sidebar {
width: 250px;
background: white;
height: 100vh;
position: sticky;
top: 0;
border-right: 1px solid #e0e0e0;
padding: 20px;
overflow-y: auto;
}
.outline-title {
font-size: 16px;
font-weight: bold;
margin-bottom: 15px;
color: #666;
}
.outline-item {
padding: 8px 12px;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
font-size: 14px;
color: #555;
}
.outline-item:hover {
background: #f0f0f0;
color: #007bff;
}
.outline-item.level-1 { padding-left: 12px; font-weight: bold; }
.outline-item.level-2 { padding-left: 24px; }
.outline-item.level-3 { padding-left: 36px; font-size: 13px; }
.outline-item.level-4 { padding-left: 48px; font-size: 13px; }
/* 主内容区 */
.content-wrapper {
flex: 1;
max-width: 900px;
margin: 0 auto;
padding: 40px;
}
.markdown-body {
background: white;
padding: 50px;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
}
/* 标题样式 */
.heading-wrapper {
position: relative;
margin: 30px 0 15px 0;
}
.fold-btn {
position: absolute;
left: -30px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
font-size: 12px;
color: #999;
padding: 5px;
transition: all 0.2s;
}
.fold-btn:hover {
color: #007bff;
transform: translateY(-50%) scale(1.2);
}
h1, h2, h3, h4, h5, h6 {
margin: 0 !important;
color: #2c3e50;
}
h1 { font-size: 32px; border-bottom: 2px solid #e0e0e0; padding-bottom: 15px; }
h2 { font-size: 28px; }
h3 { font-size: 24px; }
h4 { font-size: 20px; }
/* 待办列表样式 - 优化版 */
.todo-item {
list-style: none;
margin: 12px 0;
padding: 12px 16px;
background: #f8f9fa;
border-radius: 8px;
border-left: 3px solid #007bff;
transition: all 0.3s;
}
.todo-item:hover {
background: #e9ecef;
transform: translateX(5px);
}
.todo-checkbox {
width: 20px;
height: 20px;
cursor: pointer;
margin-right: 12px;
vertical-align: middle;
accent-color: #007bff;
}
/* 已完成样式 */
.todo-checkbox:checked + span {
text-decoration: line-through;
color: #6c757d;
}
/* 段落样式 */
p {
margin: 15px 0;
line-height: 1.8;
}
code {
background: #f4f4f4;
padding: 2px 6px;
border-radius: 4px;
font-family: 'Courier New', monospace;
}
pre {
background: #2d2d2d;
color: #f8f8f2;
padding: 20px;
border-radius: 8px;
overflow-x: auto;
margin: 20px 0;
}
blockquote {
border-left: 4px solid #007bff;
padding-left: 20px;
margin: 20px 0;
color: #666;
font-style: italic;
}
table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
}
th, td {
border: 1px solid #ddd;
padding: 12px;
text-align: left;
}
th {
background: #007bff;
color: white;
}
/* 移动端适配 */
@media (max-width: 768px) {
.outline-sidebar {
display: none;
}
.content-wrapper {
padding: 20px;
}
.markdown-body {
padding: 25px;
}
.fold-btn {
left: -20px;
}
}
</style>
</head>
<body>
<!-- 大纲侧边栏 -->
<div class="outline-sidebar">
<div class="outline-title">📑 文档大纲</div>
<div id="outline-content">加载中...</div>
</div>
<!-- 主内容 -->
<div class="content-wrapper">
<div class="markdown-body">
${htmlContent}
</div>
</div>
<script>
// 使用事件委托处理所有checkbox点击(社区最佳实践)
document.addEventListener('DOMContentLoaded', function() {
console.log('✅ 页面加载完成');
// 事件委托 - 监听整个文档的点击
document.addEventListener('change', function(e) {
// 检查是否是todo-checkbox
if (e.target.classList.contains('todo-checkbox')) {
const checkbox = e.target;
const index = parseInt(checkbox.getAttribute('data-index'));
const filePath = checkbox.getAttribute('data-file');
const checked = checkbox.checked;
console.log('🖱️ Checkbox被点击:', {filePath, index, checked});
// 立即更新视觉反馈
const span = checkbox.nextElementSibling;
if (span && span.tagName === 'SPAN') {
if (checked) {
span.style.textDecoration = 'line-through';
span.style.color = '#6c757d';
} else {
span.style.textDecoration = 'none';
span.style.color = '';
}
}
// 异步保存到服务器
fetch('/api/todo/toggle', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
filePath: filePath,
itemIndex: index,
checked: checked
})
})
.then(response => response.json())
.then(data => {
console.log('✅ 保存成功:', data);
})
.catch(error => {
console.error('❌ 保存失败:', error);
// 失败时恢复状态
checkbox.checked = !checked;
if (span && span.tagName === 'SPAN') {
span.style.textDecoration = checked ? 'line-through' : 'none';
span.style.color = checked ? '#6c757d' : '';
}
});
}
});
console.log('✅ 事件监听已设置');
});
// 切换折叠状态
async function toggleFold(headingId) {
const wrapper = document.querySelector(\`[data-heading-id="\${headingId}"]\`);
const btn = wrapper.querySelector('.fold-btn');
const heading = wrapper.querySelector('h1, h2, h3, h4, h5, h6');
const isCollapsed = heading.classList.contains('collapsed');
// 切换状态
heading.classList.toggle('collapsed');
btn.textContent = isCollapsed ? '▼' : '▶';
// 折叠/展开后续内容
let nextElement = wrapper.nextElementSibling;
while (nextElement && !nextElement.classList.contains('heading-wrapper')) {
nextElement.style.display = isCollapsed ? '' : 'none';
nextElement = nextElement.nextElementSibling;
}
// 保存状态
try {
await fetch('/api/fold/toggle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filePath: '${filePath}',
headingId,
collapsed: !isCollapsed
})
});
} catch (error) {
console.error('更新折叠状态失败:', error);
}
}
// 加载大纲
async function loadOutline() {
try {
const response = await fetch(\`/api/outline?filePath=\${encodeURIComponent('${filePath}')}\`);
const data = await response.json();
if (data.success) {
const outlineHtml = data.outline.map(h =>
\`<div class="outline-item level-\${h.level}" onclick="scrollToHeading('\${h.id}')">\${h.text}</div>\`
).join('');
document.getElementById('outline-content').innerHTML = outlineHtml;
}
} catch (error) {
console.error('加载大纲失败:', error);
document.getElementById('outline-content').innerHTML = '加载失败';
}
}
// 滚动到标题
function scrollToHeading(id) {
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
// 页面加载完成后加载大纲
loadOutline();
</script>
</body>
</html>
`);
});
// 文件列表页面(保持原功能)
app.get('/', (req, res) => {
const dirPath = req.query.path || '';
const fullPath = path.join(DIRECTORY, dirPath);
if (!fs.existsSync(fullPath)) {
return res.status(404).send('目录未找到');
}
const stats = fs.statSync(fullPath);
if (stats.isDirectory()) {
const files = fs.readdirSync(fullPath).map(f => {
const fstat = fs.statSync(path.join(fullPath, f));
return {
name: f,
isDirectory: fstat.isDirectory(),
size: fstat.size,
modified: fstat.mtime
};
});
res.send(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>文件服务器</title>
</head>
<body>
<h1>📁 ${dirPath || '根目录'}</h1>
<ul>
${files.map(f => `
<li>
${f.isDirectory ? '📁' : '📄'}
<a href="${f.isDirectory ? `/?path=${path.join(dirPath, f.name)}` : `/view/${path.join(dirPath, f.name)}`}">
${f.name}
</a>
${!f.isDirectory ? `<small>(${(f.size / 1024).toFixed(2)} KB)</small>` : ''}
</li>
`).join('')}
</ul>
</body>
</html>
`);
} else {
res.redirect(`/view/${dirPath}`);
}
});
// 启动服务器
app.listen(PORT, () => {
console.log(`🚀 动态文件服务器运行在端口 ${PORT}`);
console.log(`📚 访问地址: http://localhost:${PORT}`);
console.log(`💾 数据库: /data/ai-output/file_server.db`);
});
// API: 获取待办状态
app.get('/api/get-todo-state', (req, res) => {
const { filePath } = req.query;
try {
const states = db.prepare(`
SELECT item_index, checked FROM todo_items WHERE file_path = ?
`).all(filePath);
res.json({ success: true, states });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});