my-todo-list/server.js
2026-06-08 18:01:22 +08:00

249 lines
7.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.htmlSPA
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);