249 lines
7.0 KiB
JavaScript
249 lines
7.0 KiB
JavaScript
const express = require('express');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const crypto = require('crypto');
|
||
|
||
const app = express();
|
||
const PORT = process.env.PORT || 3003;
|
||
|
||
// ── 中间件 ────────────────────────────────────────────
|
||
app.use(express.json());
|
||
app.use(express.static(path.join(__dirname, 'public')));
|
||
|
||
// 统一设置 JSON 响应的 charset
|
||
app.use((_req, res, next) => {
|
||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||
next();
|
||
});
|
||
|
||
// ── 数据层 ────────────────────────────────────────────
|
||
const DATA_DIR = path.join(__dirname, 'data');
|
||
const DATA_FILE = path.join(DATA_DIR, 'todos.json');
|
||
|
||
function ensureDataDir() {
|
||
if (!fs.existsSync(DATA_DIR)) {
|
||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||
}
|
||
}
|
||
|
||
function readTodos() {
|
||
ensureDataDir();
|
||
try {
|
||
if (!fs.existsSync(DATA_FILE)) return [];
|
||
const raw = fs.readFileSync(DATA_FILE, 'utf-8');
|
||
return JSON.parse(raw);
|
||
} catch {
|
||
// 文件损坏时备份并重建
|
||
if (fs.existsSync(DATA_FILE)) {
|
||
const backup = DATA_FILE + '.bak.' + Date.now();
|
||
fs.copyFileSync(DATA_FILE, backup);
|
||
console.warn(`数据文件损坏,已备份至 ${backup}`);
|
||
}
|
||
return [];
|
||
}
|
||
}
|
||
|
||
let todos = readTodos();
|
||
let writeTimer = null;
|
||
|
||
function writeTodos() {
|
||
clearTimeout(writeTimer);
|
||
writeTimer = setTimeout(() => {
|
||
ensureDataDir();
|
||
fs.writeFileSync(DATA_FILE, JSON.stringify(todos, null, 2), 'utf-8');
|
||
}, 300); // 300ms 防抖
|
||
}
|
||
|
||
function generateId() {
|
||
return crypto.randomUUID();
|
||
}
|
||
|
||
// ── 辅助函数 ──────────────────────────────────────────
|
||
/** 递归收集所有子孙节点 ID */
|
||
function collectDescendantIds(parentId) {
|
||
const ids = [];
|
||
const children = todos.filter(t => t.parentId === parentId);
|
||
for (const child of children) {
|
||
ids.push(child.id);
|
||
ids.push(...collectDescendantIds(child.id));
|
||
}
|
||
return ids;
|
||
}
|
||
|
||
/** 获取某个任务的最大层级深度(用于限制三级) */
|
||
function getDepth(itemId) {
|
||
let depth = 0;
|
||
let current = todos.find(t => t.id === itemId);
|
||
while (current && current.parentId) {
|
||
depth++;
|
||
current = todos.find(t => t.id === current.parentId);
|
||
}
|
||
return depth;
|
||
}
|
||
|
||
// ── API 路由 ──────────────────────────────────────────
|
||
// 获取所有任务(排除已删除的)
|
||
app.get('/api/todos', (_req, res) => {
|
||
res.json(todos.filter(t => t.status !== 'deleted'));
|
||
});
|
||
|
||
// 创建任务
|
||
app.post('/api/todos', (req, res) => {
|
||
const { title, parentId } = req.body;
|
||
|
||
if (!title || !title.trim()) {
|
||
return res.status(400).json({ error: '标题不能为空' });
|
||
}
|
||
|
||
// 检查层级限制:最多三级(主/子/孙)
|
||
if (parentId) {
|
||
const parent = todos.find(t => t.id === parentId);
|
||
if (!parent) {
|
||
return res.status(404).json({ error: '父任务不存在' });
|
||
}
|
||
const parentDepth = getDepth(parentId);
|
||
if (parentDepth >= 2) {
|
||
return res.status(400).json({ error: '最多支持三级任务(主/子/孙)' });
|
||
}
|
||
}
|
||
|
||
const now = new Date().toISOString();
|
||
const todo = {
|
||
id: generateId(),
|
||
title: title.trim(),
|
||
status: 'active',
|
||
parentId: parentId || null,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
};
|
||
|
||
todos.push(todo);
|
||
writeTodos();
|
||
res.status(201).json(todo);
|
||
});
|
||
|
||
// 更新任务
|
||
app.patch('/api/todos/:id', (req, res) => {
|
||
const { id } = req.params;
|
||
const { title, status } = req.body;
|
||
|
||
const index = todos.findIndex(t => t.id === id);
|
||
if (index === -1) {
|
||
return res.status(404).json({ error: '任务不存在' });
|
||
}
|
||
|
||
const todo = todos[index];
|
||
const now = new Date().toISOString();
|
||
|
||
if (title !== undefined) {
|
||
if (!title.trim()) {
|
||
return res.status(400).json({ error: '标题不能为空' });
|
||
}
|
||
todo.title = title.trim();
|
||
}
|
||
|
||
if (status !== undefined) {
|
||
if (!['active', 'completed', 'abandoned', 'deleted'].includes(status)) {
|
||
return res.status(400).json({ error: '无效的状态值' });
|
||
}
|
||
todo.status = status;
|
||
todo.updatedAt = now;
|
||
|
||
// 级联完成:当标记为 completed 时,所有子孙也变为 completed
|
||
if (status === 'completed') {
|
||
const descendantIds = collectDescendantIds(id);
|
||
for (const descId of descendantIds) {
|
||
const desc = todos.find(t => t.id === descId);
|
||
if (desc) {
|
||
desc.status = 'completed';
|
||
desc.updatedAt = now;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
todo.updatedAt = now;
|
||
writeTodos();
|
||
res.json(todo);
|
||
});
|
||
|
||
// 删除任务(软删除:标记为 deleted,级联子孙)
|
||
app.delete('/api/todos/:id', (req, res) => {
|
||
const { id } = req.params;
|
||
|
||
const todo = todos.find(t => t.id === id);
|
||
if (!todo) {
|
||
return res.status(404).json({ error: '任务不存在' });
|
||
}
|
||
|
||
const idsToDelete = new Set([id, ...collectDescendantIds(id)]);
|
||
const now = new Date().toISOString();
|
||
let count = 0;
|
||
|
||
for (const t of todos) {
|
||
if (idsToDelete.has(t.id)) {
|
||
t.status = 'deleted';
|
||
t.updatedAt = now;
|
||
count++;
|
||
}
|
||
}
|
||
|
||
writeTodos();
|
||
res.json({ deleted: count });
|
||
});
|
||
|
||
// ── 设置接口 ──────────────────────────────────────────
|
||
const SETTINGS_FILE = path.join(DATA_DIR, 'settings.json');
|
||
|
||
function readSettings() {
|
||
ensureDataDir();
|
||
try {
|
||
if (!fs.existsSync(SETTINGS_FILE)) return { autoSaveEnabled: false, autoSaveSec: 10 };
|
||
return JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf-8'));
|
||
} catch {
|
||
return { autoSaveEnabled: false, autoSaveSec: 10 };
|
||
}
|
||
}
|
||
|
||
function writeSettings(s) {
|
||
ensureDataDir();
|
||
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(s, null, 2), 'utf-8');
|
||
}
|
||
|
||
app.get('/api/settings', (_req, res) => {
|
||
res.json(readSettings());
|
||
});
|
||
|
||
app.put('/api/settings', (req, res) => {
|
||
const s = readSettings();
|
||
if (req.body.autoSaveEnabled !== undefined) s.autoSaveEnabled = req.body.autoSaveEnabled;
|
||
if (req.body.autoSaveSec !== undefined) s.autoSaveSec = req.body.autoSaveSec;
|
||
writeSettings(s);
|
||
res.json(s);
|
||
});
|
||
|
||
// 回退到 index.html(SPA)
|
||
app.get('*', (_req, res) => {
|
||
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
||
});
|
||
|
||
// ── 启动服务 ──────────────────────────────────────────
|
||
function startServer(port) {
|
||
const server = app.listen(port, () => {
|
||
console.log(`✅ Todo List 服务已启动: http://localhost:${port}`);
|
||
console.log(`📁 数据文件: ${DATA_FILE}`);
|
||
});
|
||
|
||
server.on('error', (err) => {
|
||
// 生产环境(PM2 管理端口)不自动跳,直接抛错
|
||
if (err.code === 'EADDRINUSE' && process.env.NODE_ENV !== 'production') {
|
||
console.warn(`⚠️ 端口 ${port} 被占用,尝试 ${port + 1}…`);
|
||
startServer(port + 1);
|
||
} else {
|
||
throw err;
|
||
}
|
||
});
|
||
}
|
||
|
||
startServer(PORT);
|