2025年9月24日

尝试搭建一个简易任务清单-DateGo

任务清单实现效果如下:


1. 首先,添加一个Node项目,并设置以下配置:

    注意启动命令设置为:

    start:node server.js

    2. 创建一个MySQL的数据库用于保存多台设备间存储的任务信息。

    3. 在项目目录下,编写以下代码文件:

    styles.css

    :root {
      --bg: #0f172a; /* slate-900 */
      --panel: #111827; /* gray-900 */
      --text: #e5e7eb; /* gray-200 */
      --muted: #9ca3af; /* gray-400 */
      --primary: #60a5fa; /* blue-400 */
      --primary-hover: #3b82f6; /* blue-500 */
      --accent: #22c55e; /* green-500 */
      --danger: #ef4444; /* red-500 */
      --border: #1f2937; /* gray-800 */
    }
    
    * { box-sizing: border-box; }
    html, body { height: 100%; }
    body {
      margin: 0;
      font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Segoe UI Emoji";
      background: linear-gradient(135deg, #0f172a 0%, #111827 60%, #0b1220 100%);
      color: var(--text);
    }
    
    .app { max-width: 780px; margin: 40px auto; padding: 24px; background: rgba(17,24,39,0.6); border: 1px solid var(--border); border-radius: 16px; backdrop-filter: blur(6px); }
    .header { display: grid; grid-template-columns: 1fr auto; gap: 12px; align-items: center; }
    .header h1 { margin: 0; font-size: 28px; letter-spacing: 0.5px; }
    .date-row { display: flex; gap: 8px; align-items: center; }
    .date-row input[type="date"] { background: #0b1220; color: var(--text); border: 1px solid var(--border); padding: 8px 10px; border-radius: 8px; }
    .date-title { grid-column: 1 / -1; color: var(--muted); font-size: 14px; }
    
    .new-task { display: grid; grid-template-columns: 1fr auto; gap: 10px; margin-top: 16px; }
    .new-task input { padding: 12px; border-radius: 10px; border: 1px solid var(--border); background: #0b1220; color: var(--text); }
    .new-task .primary { padding: 10px 14px; border-radius: 10px; border: 1px solid var(--primary); background: var(--primary); color: #0b1220; cursor: pointer; }
    .new-task .primary:hover { background: var(--primary-hover); }
    
    .toolbar { display: flex; justify-content: space-between; align-items: center; margin: 14px 0; }
    .filters { display: flex; gap: 6px; }
    .filter { background: #0b1220; border: 1px solid var(--border); color: var(--text); padding: 8px 10px; border-radius: 999px; cursor: pointer; }
    .filter.active { border-color: var(--primary); color: var(--primary); }
    .actions { display: flex; gap: 8px; align-items: center; color: var(--muted); }
    .actions .ghost { background: transparent; color: var(--muted); border: 1px dashed var(--border); padding: 8px 10px; border-radius: 999px; cursor: pointer; }
    .actions .ghost:hover { color: var(--text); border-style: solid; }
    
    .task-list { list-style: none; padding: 0; margin: 0; }
    .task-item { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 10px; padding: 10px; border-bottom: 1px solid var(--border); }
    .task-item.dragging { background: rgba(96,165,250,0.08); }
    .task-title { word-break: break-word; }
    .task-title.completed { color: var(--muted); text-decoration: line-through; }
    
    .icon-btn { background: transparent; border: none; color: var(--muted); cursor: pointer; padding: 6px; border-radius: 8px; }
    .icon-btn:hover { color: var(--text); background: rgba(255,255,255,0.05); }
    .icon-btn.delete { color: var(--danger); }
    .icon-btn.edit { color: var(--accent); }
    
    .hint { color: var(--muted); font-size: 12px; margin-top: 12px; }
    
    /* 可访问性与拖拽辅助 */
    .task-item[draggable="true"] { cursor: grab; }
    .task-item[draggable="true"]:active { cursor: grabbing; }
    .all-dates { margin-top: 24px; }
    .section-title { font-size: 18px; margin: 8px 0 12px; color: var(--text); }
    .all-dates-grid { display: grid; grid-template-columns: 1fr; gap: 12px; }
    .date-card { border: 1px solid var(--border); background: rgba(11,18,32,0.6); border-radius: 12px; padding: 12px; }
    .date-card h3 { margin: 0 0 8px; font-size: 16px; color: var(--primary); }
    .date-card ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
    .date-card li { display: flex; align-items: center; gap: 8px; color: var(--text); }
    .date-card li.completed { color: var(--muted); text-decoration: line-through; }
    .date-card .empty { color: var(--muted); font-size: 12px; }
    span { font-size: 13px; }

    index.html

    <!doctype html>
    <html lang="zh-CN">
    <head>
      <meta charset="utf-8" />
      <meta name="viewport" content="width=device-width, initial-scale=1" />
      <title>任务清单 · 实时记录</title>
      <link rel="stylesheet" href="styles.css" />
      <!-- 标签栏图标与主题色 -->
      <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
      <meta name="theme-color" content="#0b1220" />
    </head>
    <body>
      <div class="app">
        <header class="header">
          <h1>任务清单</h1>
          <div class="date-row">
            <label for="datePicker">选择日期</label>
            <input id="datePicker" type="date" />
          </div>
          <div id="dateTitle" class="date-title">——</div>
        </header>
    
        <section class="new-task">
          <input id="newTaskInput" type="text" placeholder="添加新任务,按 Enter 确认" />
          <button id="addBtn" class="primary">添加</button>
        </section>
    
        <section class="toolbar">
          <div class="filters" role="tablist" aria-label="筛选任务">
            <button class="filter active" data-filter="all" role="tab" aria-selected="true">全部</button>
            <button class="filter" data-filter="active" role="tab" aria-selected="false">未完成</button>
            <button class="filter" data-filter="completed" role="tab" aria-selected="false">已完成</button>
          </div>
          <div class="actions">
            <span id="leftCount">0 项未完成</span>
            <button id="clearCompleted" class="ghost">🧹</button>
          </div>
        </section>
    
        <main>
          <ul id="taskList" class="task-list" aria-live="polite"></ul>
        </main>
    
        <section class="all-dates">
          <h2 class="section-title">所有日期任务</h2>
          <div id="allDatesContainer" class="all-dates-grid"></div>
        </section>
      </div>
    
      <script src="app.js"></script>
    </body>
    </html>

    app.js

    (function () {
      const $ = (sel, root = document) => root.querySelector(sel);
      const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
    
      // 在非安全上下文(http)下,可能没有 crypto.randomUUID,提供回退生成器
      function genId() {
        if (window.crypto && typeof window.crypto.randomUUID === 'function') {
          return window.crypto.randomUUID();
        }
        return (Date.now().toString(36) + Math.random().toString(36).slice(2));
      }
    
      // 后端 API 基础与用户标识
      const API_BASE = '/api';
      const USER_KEY = 'datego_user_v1';
      const DEFAULT_USER = 'shared'; // 统一同步码,所有设备默认使用这个值,可通过 ?user= 覆盖
      let userId = null;
      function getInitialUserId() {
        const p = new URLSearchParams(location.search).get('user');
        if (p) { localStorage.setItem(USER_KEY, p); return p; }
        // 强制使用统一同步码,确保跨设备共享
        localStorage.setItem(USER_KEY, DEFAULT_USER);
        return DEFAULT_USER;
      }
      async function fetchJSON(url, opts) {
      const res = await fetch(url, Object.assign({ headers: { 'Content-Type': 'application/json' } }, opts || {}));
      if (!res.ok) throw new Error('Network ' + res.status);
      return res.json();
    }
    async function loadAllFromServer() {
      const data = await fetchJSON(`${API_BASE}/all?user=${encodeURIComponent(userId)}`);
      state.tasksByDate = data.tasksByDate || {};
    }
    // 状态管理
    const state = {
      date: todayStr(),
      filter: 'all',
      tasksByDate: loadTasks(),
    };
    
    // 初始化
    window.addEventListener('DOMContentLoaded', () => {
      userId = getInitialUserId();
      const datePicker = $('#datePicker');
      datePicker.value = state.date;
      updateDateTitle();
    
      bindEvents();
      loadAllFromServer().then(() => {
        render();
      }).catch((e) => {
        console.error('加载云端数据失败', e);
        render();
      });
    });
    
    function todayStr() {
      const d = new Date();
      const y = d.getFullYear();
      const m = String(d.getMonth() + 1).padStart(2, '0');
      const day = String(d.getDate()).padStart(2, '0');
      return `${y}-${m}-${day}`;
    }
    
    function updateDateTitle() {
      const dateTitle = $('#dateTitle');
      const [y, m, d] = state.date.split('-');
      dateTitle.textContent = `${y}${m}${d}日下列举需要完成的任务`;
    }
    
    function bindEvents() {
      const datePicker = $('#datePicker');
      const newTaskInput = $('#newTaskInput');
      const addBtn = $('#addBtn');
      const clearCompleted = $('#clearCompleted');
    
      datePicker.addEventListener('change', () => {
        state.date = datePicker.value || todayStr();
        updateDateTitle();
        render();
      });
    
      newTaskInput.addEventListener('keydown', (e) => {
        if (e.key === 'Enter') addTask();
      });
      addBtn.addEventListener('click', addTask);
    
      $('.filters').addEventListener('click', (e) => {
        const btn = e.target.closest('.filter');
        if (!btn) return;
        state.filter = btn.dataset.filter;
        $$('.filter').forEach(b => b.classList.toggle('active', b === btn));
        render();
      });
    
      clearCompleted.addEventListener('click', () => {
        const list = getTasksForDate();
        const next = list.filter(t => !t.completed);
        const delIds = list.filter(t => t.completed).map(t => t.id);
        setTasksForDate(next);
        render();
        // 同步云端删除
        Promise.all(delIds.map(id => fetch(`${API_BASE}/tasks/${encodeURIComponent(id)}`, { method: 'DELETE' }))).catch(err => {
          console.error('批量删除失败', err);
        });
      });
    
      // 跨标签页同步(本地存储),保留但不影响云端
      window.addEventListener('storage', (e) => {
        if (e.key === STORAGE_KEY) {
          state.tasksByDate = JSON.parse(e.newValue || '{}');
          render();
        }
      });
    }
    
    // 数据持久化
    const STORAGE_KEY = 'datego_tasks_v1';
    function loadTasks() {
      try {
        return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
      } catch {
        return {};
      }
    }
    function saveTasks() {
      localStorage.setItem(STORAGE_KEY, JSON.stringify(state.tasksByDate));
    }
    function getTasksForDate() {
      return state.tasksByDate[state.date] || [];
    }
    function setTasksForDate(list) {
      state.tasksByDate[state.date] = list;
      saveTasks();
    }
    
    // 任务操作
    function addTask() {
      const input = $('#newTaskInput');
      const title = input.value.trim();
      if (!title) return;
      const list = getTasksForDate();
      const task = { id: genId(), title, completed: false };
      setTasksForDate([task, ...list]);
      input.value = '';
      render();
      // 同步到后端
      fetchJSON(`${API_BASE}/tasks`, {
        method: 'POST',
        body: JSON.stringify({ userId, date: state.date, title, id: task.id })
      }).catch(err => {
        console.error('保存到云端失败', err);
      });
    }
    
    function toggleTask(id) {
      const list = getTasksForDate();
      const target = list.find(t => t.id === id);
      const nextCompleted = target ? !target.completed : true;
      const next = list.map(t => t.id === id ? { ...t, completed: nextCompleted } : t);
      setTasksForDate(next);
      render();
      // 同步到后端
      fetchJSON(`${API_BASE}/tasks/${encodeURIComponent(id)}`, {
        method: 'PATCH',
        body: JSON.stringify({ completed: nextCompleted })
      }).catch(err => {
        console.error('更新到云端失败', err);
      });
    }
    
    function deleteTask(id) {
      const list = getTasksForDate();
      const next = list.filter(t => t.id !== id);
      setTasksForDate(next);
      render();
      // 同步到后端
      fetch(`${API_BASE}/tasks/${encodeURIComponent(id)}`, { method: 'DELETE' }).catch(err => {
        console.error('删除到云端失败', err);
      });
    }
    
    function editTask(id) {
      const li = document.getElementById(`task-${id}`);
      const titleEl = $('.task-title', li);
      const old = titleEl.textContent;
      const input = document.createElement('input');
      input.type = 'text';
      input.value = old;
      input.className = 'edit-input';
      input.style.padding = '8px';
      input.style.borderRadius = '8px';
      input.style.border = '1px solid var(--border)';
      input.style.background = '#0b1220';
      input.style.color = 'var(--text)';
    
      const commit = () => {
        const val = input.value.trim();
        const list = getTasksForDate();
        const next = list.map(t => t.id === id ? { ...t, title: val || old } : t);
        setTasksForDate(next);
        render();
        // 同步到云端
        fetchJSON(`${API_BASE}/tasks/${encodeURIComponent(id)}`, {
          method: 'PATCH',
          body: JSON.stringify({ title: val || old })
        }).catch(err => {
          console.error('更新标题到云端失败', err);
        });
      };
    
      input.addEventListener('keydown', (e) => {
        if (e.key === 'Enter') commit();
        if (e.key === 'Escape') render();
      });
      input.addEventListener('blur', commit);
    
      titleEl.replaceWith(input);
      input.focus();
      input.select();
    }
    
    // 拖拽排序
    function enableDragAndDrop() {
      const listEl = $('#taskList');
      const items = $$('.task-item', listEl);
    
      let draggingId = null;
    
      items.forEach(item => {
        item.draggable = true;
        item.addEventListener('dragstart', () => {
          draggingId = item.dataset.id;
          item.classList.add('dragging');
        });
        item.addEventListener('dragend', () => {
          item.classList.remove('dragging');
          draggingId = null;
        });
      });
    
      listEl.addEventListener('dragover', (e) => {
        e.preventDefault();
        const after = getDragAfterElement(listEl, e.clientY);
        const draggingEl = $('#taskList .task-item.dragging');
        if (!draggingEl) return;
        if (after == null) listEl.appendChild(draggingEl);
        else listEl.insertBefore(draggingEl, after);
      });
    
      listEl.addEventListener('drop', () => {
        const ids = $$('#taskList .task-item').map(li => li.dataset.id);
        const list = getTasksForDate();
        const map = new Map(list.map(t => [t.id, t]));
        const next = ids.map(id => map.get(id)).filter(Boolean);
        setTasksForDate(next);
        render();
        // 同步排序到云端
        fetchJSON(`${API_BASE}/tasks/order`, {
          method: 'POST',
          body: JSON.stringify({ userId, date: state.date, order: ids })
        }).catch(err => {
          console.error('更新排序失败', err);
        });
      });
    }
    
    function getDragAfterElement(container, y) {
      const els = [...container.querySelectorAll('.task-item:not(.dragging)')];
      return els.reduce((closest, child) => {
        const box = child.getBoundingClientRect();
        const offset = y - box.top - box.height / 2;
        if (offset < 0 && offset > closest.offset) {
          return { offset, element: child };
        } else {
          return closest;
        }
      }, { offset: Number.NEGATIVE_INFINITY, element: null }).element;
    }
    
    function setTasksFor(date, list) {
      state.tasksByDate[date] = list;
      saveTasks();
    }
    
    function getTasks(date) {
      return state.tasksByDate[date] || [];
    }
    
    function toggleTaskForDate(date, id) {
      const list = getTasks(date);
      const target = list.find(t => t.id === id);
      const nextCompleted = target ? !target.completed : true;
      const next = list.map(t => t.id === id ? { ...t, completed: nextCompleted } : t);
      setTasksFor(date, next);
      render();
      // 同步到云端
      fetchJSON(`${API_BASE}/tasks/${encodeURIComponent(id)}`, {
        method: 'PATCH',
        body: JSON.stringify({ completed: nextCompleted })
      }).catch(err => {
        console.error('更新到云端失败', err);
      });
    }
    
    function deleteTaskForDate(date, id) {
      const list = getTasks(date);
      const next = list.filter(t => t.id !== id);
      setTasksFor(date, next);
      render();
      // 同步到云端
      fetch(`${API_BASE}/tasks/${encodeURIComponent(id)}`, { method: 'DELETE' }).catch(err => {
        console.error('删除到云端失败', err);
      });
    }
    
    function formatDateCn(dateStr) {
      const [y, m, d] = dateStr.split('-');
      return `${y}${m}${d}日`;
    }
    
    function renderAllDates() {
      const container = document.getElementById('allDatesContainer');
      if (!container) return;
    
      const dates = Object.keys(state.tasksByDate).sort((a, b) => a.localeCompare(b));
    
      if (dates.length === 0) {
        container.innerHTML = `<div class="date-card"><h3>暂无日期</h3><div class="empty">当前没有任何任务,请先添加任务</div></div>`;
        return;
      }
    
      container.innerHTML = dates.map(date => {
        const tasks = getTasks(date);
        const listHtml = tasks.length
          ? tasks.map(t => `
            <li class="${t.completed ? 'completed' : ''}" data-id="${t.id}">
              <input type="checkbox" ${t.completed ? 'checked' : ''} aria-label="完成任务切换" />
              <span>${escapeHtml(t.title)}</span>
              <button class="icon-btn delete" title="删除" aria-label="删除任务">🗑️</button>
            </li>
          `).join('')
          : `<div class="empty">该日期暂无任务</div>`;
    
        return `
          <div class="date-card" data-date="${date}">
            <h3>${formatDateCn(date)}</h3>
            <ul>${listHtml}</ul>
          </div>
        `;
      }).join('');
    
      // 绑定事件
      dates.forEach(date => {
        const card = container.querySelector(`.date-card[data-date="${date}"]`);
        if (!card) return;
        const items = Array.from(card.querySelectorAll('ul li'));
        items.forEach(li => {
          const id = li.dataset.id;
          const checkbox = li.querySelector('input[type="checkbox"]');
          const delBtn = li.querySelector('.icon-btn.delete');
          if (checkbox) checkbox.addEventListener('change', () => toggleTaskForDate(date, id));
          if (delBtn) delBtn.addEventListener('click', () => deleteTaskForDate(date, id));
        });
      });
    }
    
    // 渲染
    function render() {
      const listEl = $('#taskList');
      const list = getTasksForDate();
      const filtered = list.filter(t => state.filter === 'all' ? true : state.filter === 'active' ? !t.completed : t.completed);
    
      listEl.innerHTML = filtered.map(t => itemTemplate(t)).join('');
      updateLeftCount();
      enableDragAndDrop();
    
      // 绑定行内事件
      filtered.forEach(t => bindItemEvents(t.id));
    
      // 渲染所有日期卡片
      renderAllDates();
    }
    
    function updateLeftCount() {
      const list = getTasksForDate();
      const left = list.filter(t => !t.completed).length;
      $('#leftCount').textContent = `${left} 项未完成`;
    }
    
    function itemTemplate(t) {
      const completed = t.completed ? 'completed' : '';
      return `
        <li id="task-${t.id}" class="task-item" data-id="${t.id}">
          <input type="checkbox" ${t.completed ? 'checked' : ''} aria-label="完成任务切换" />
          <div class="task-title ${completed}" title="双击编辑">${escapeHtml(t.title)}</div>
          <div class="ops">
            <button class="icon-btn edit" title="编辑">✏️</button>
            <button class="icon-btn delete" title="删除">🗑️</button>
          </div>
        </li>
      `;
    }
    
    function bindItemEvents(id) {
      const li = document.getElementById(`task-${id}`);
      const checkbox = $('input[type="checkbox"]', li);
      const titleEl = $('.task-title', li);
      const editBtn = $('.icon-btn.edit', li);
      const delBtn = $('.icon-btn.delete', li);
    
      checkbox.addEventListener('change', () => toggleTask(id));
      delBtn.addEventListener('click', () => deleteTask(id));
      editBtn.addEventListener('click', () => editTask(id));
      titleEl.addEventListener('dblclick', () => editTask(id));
    }
    
    function escapeHtml(str) {
      return str.replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
    }
    })();

    server.js

    const http = require('http');
    const fs = require('fs');
    const path = require('path');
    const crypto = require('crypto');
    const { URL } = require('url');
    const mysql = require('mysql2/promise');
    
    const port = process.env.PORT || 1818;
    const root = __dirname;
    
    const MIME = {
      '.html': 'text/html; charset=utf-8',
      '.css': 'text/css; charset=utf-8',
      '.js': 'application/javascript; charset=utf-8',
      '.json': 'application/json; charset=utf-8',
      '.svg': 'image/svg+xml; charset=utf-8',
    };
    
    // 必填:MYSQL_HOST, MYSQL_USER, MYSQL_PASSWORD, MYSQL_DB
    const pool = mysql.createPool({
      host: process.env.MYSQL_HOST || '127.0.0.1',
      user: process.env.MYSQL_USER || '项目名',
      password: process.env.MYSQL_PASSWORD || 'XXXXXXX',
      database: process.env.MYSQL_DB || '项目名',
      port: process.env.MYSQL_PORT ? Number(process.env.MYSQL_PORT) : 3306,
      connectionLimit: 10,
      charset: 'utf8mb4',
    });
    
    async function initDb() {
      const createSQL = `
        CREATE TABLE IF NOT EXISTS tasks (
          id VARCHAR(64) PRIMARY KEY,
          user_id VARCHAR(128) NOT NULL,
          date VARCHAR(10) NOT NULL,
          title TEXT NOT NULL,
          completed TINYINT(1) NOT NULL DEFAULT 0,
          position INT NOT NULL DEFAULT 0,
          created_at DATETIME NOT NULL,
          updated_at DATETIME NOT NULL,
          INDEX idx_user_date (user_id, date),
          INDEX idx_user (user_id)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
      `;
      const conn = await pool.getConnection();
      try {
        await conn.query(createSQL);
      } finally {
        conn.release();
      }
    }
    
    function sendJSON(res, code, data) {
      res.statusCode = code;
      res.setHeader('Content-Type', 'application/json; charset=utf-8');
      res.end(JSON.stringify(data));
    }
    
    function notFound(res) {
      res.statusCode = 404;
      res.end('Not Found');
    }
    
    function methodNotAllowed(res) {
      res.statusCode = 405;
      res.end('Method Not Allowed');
    }
    
    function readBody(req) {
      return new Promise((resolve, reject) => {
        let raw = '';
        req.on('data', (chunk) => {
          raw += chunk;
          if (raw.length > 1e6) {
            req.destroy();
            reject(new Error('Payload too large'));
          }
        });
        req.on('end', () => {
          if (!raw) return resolve({});
          try { resolve(JSON.parse(raw)); } catch { resolve({}); }
        });
        req.on('error', reject);
      });
    }
    
    function serveStatic(req, res, urlPath) {
      let filePath = path.join(root, urlPath);
      if (urlPath === '/' || urlPath === '') filePath = path.join(root, 'index.html');
      fs.stat(filePath, (err, stat) => {
        if (err || !stat.isFile()) return notFound(res);
        const ext = path.extname(filePath).toLowerCase();
        res.setHeader('Content-Type', MIME[ext] || 'application/octet-stream');
        fs.createReadStream(filePath).pipe(res);
      });
    }
    
    async function handleApi(req, res, url) {
      // 路由分发
      const pathname = url.pathname;
      try {
        if (pathname === '/api/tasks' && req.method === 'GET') {
          const user = url.searchParams.get('user');
          const date = url.searchParams.get('date');
          if (!user || !date) return sendJSON(res, 400, { error: 'missing user or date' });
          const [rows] = await pool.query(
            'SELECT id, user_id AS userId, date, title, completed, position, created_at AS createdAt, updated_at AS updatedAt FROM tasks WHERE user_id=? AND date=? ORDER BY position ASC, created_at ASC',
            [user, date]
          );
          return sendJSON(res, 200, { tasks: rows });
        }
    
        if (pathname === '/api/tasks' && req.method === 'POST') {
          const body = await readBody(req);
          const userId = body.userId;
          const date = body.date;
          let title = (body.title || '').toString().trim();
          let id = (body.id || '').toString().trim();
          if (!userId || !date || !title) return sendJSON(res, 400, { error: 'missing userId/date/title' });
          if (!id) id = crypto.randomUUID();
          const now = new Date();
          const conn = await pool.getConnection();
          try {
            const [[{ next }]] = await conn.query('SELECT COALESCE(MAX(position), 0) + 1 AS next FROM tasks WHERE user_id=? AND date=?', [userId, date]);
            await conn.query(
              'INSERT INTO tasks (id, user_id, date, title, completed, position, created_at, updated_at) VALUES (?, ?, ?, ?, 0, ?, ?, ?)',
              [id, userId, date, title, next, now, now]
            );
          } finally {
            conn.release();
          }
          return sendJSON(res, 201, { id });
        }
    
        if (pathname.startsWith('/api/tasks/') && req.method === 'PATCH') {
          const id = pathname.split('/').pop();
          const body = await readBody(req);
          const fields = [];
          const values = [];
          if (typeof body.title === 'string') { fields.push('title=?'); values.push(body.title); }
          if (typeof body.completed === 'boolean') { fields.push('completed=?'); values.push(body.completed ? 1 : 0); }
          if (fields.length === 0) return sendJSON(res, 400, { error: 'no updatable fields' });
          fields.push('updated_at=?'); values.push(new Date());
          values.push(id);
          await pool.query(`UPDATE tasks SET ${fields.join(', ')} WHERE id=?`, values);
          return sendJSON(res, 200, { ok: true });
        }
    
        if (pathname.startsWith('/api/tasks/') && req.method === 'DELETE') {
          const id = pathname.split('/').pop();
          await pool.query('DELETE FROM tasks WHERE id=?', [id]);
          return sendJSON(res, 200, { ok: true });
        }
    
        if (pathname === '/api/dates' && req.method === 'GET') {
          const user = url.searchParams.get('user');
          if (!user) return sendJSON(res, 400, { error: 'missing user' });
          const [rows] = await pool.query('SELECT DISTINCT date FROM tasks WHERE user_id=? ORDER BY date ASC', [user]);
          return sendJSON(res, 200, { dates: rows.map(r => r.date) });
        }
    
        if (pathname === '/api/all' && req.method === 'GET') {
          const user = url.searchParams.get('user');
          if (!user) return sendJSON(res, 400, { error: 'missing user' });
          const [rows] = await pool.query(
            'SELECT id, user_id AS userId, date, title, completed, position, created_at AS createdAt, updated_at AS updatedAt FROM tasks WHERE user_id=? ORDER BY date ASC, position ASC, created_at ASC',
            [user]
          );
          const map = {};
          for (const r of rows) {
            if (!map[r.date]) map[r.date] = [];
            map[r.date].push({ id: r.id, title: r.title, completed: !!r.completed });
          }
          return sendJSON(res, 200, { tasksByDate: map });
        }
    
        if (pathname === '/api/tasks/order' && req.method === 'POST') {
          const body = await readBody(req);
          const userId = body.userId;
          const date = body.date;
          const order = Array.isArray(body.order) ? body.order : [];
          if (!userId || !date || order.length === 0) return sendJSON(res, 400, { error: 'missing userId/date/order' });
          const conn = await pool.getConnection();
          try {
            await conn.beginTransaction();
            for (let i = 0; i < order.length; i++) {
              await conn.query('UPDATE tasks SET position=?, updated_at=? WHERE id=? AND user_id=? AND date=?', [i + 1, new Date(), order[i], userId, date]);
            }
            await conn.commit();
          } catch (e) {
            await conn.rollback();
            throw e;
          } finally {
            conn.release();
          }
          return sendJSON(res, 200, { ok: true });
        }
    
        return notFound(res);
      } catch (err) {
        console.error('API error:', err);
        return sendJSON(res, 500, { error: 'internal_error' });
      }
    }
    
    const server = http.createServer(async (req, res) => {
      const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
    
      if (url.pathname.startsWith('/api')) {
        return handleApi(req, res, url);
      }
    
      // 静态资源
      const urlPath = decodeURIComponent(url.pathname);
      return serveStatic(req, res, urlPath);
    });
    
    initDb().then(() => {
      server.listen(port, () => {
        console.log(`Server running at http://localhost:${port}/`);
      });
    }).catch((e) => {
      console.error('Failed to init DB:', e);
      process.exit(1);
    });