From 1a07538eb8570bc94b0b68db992fd738dfa216ff Mon Sep 17 00:00:00 2001 From: guoqw7 Date: Mon, 8 Jun 2026 18:01:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=A1=B9=E7=9B=AE=E5=88=9D=E5=A7=8B?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 8 + .gitignore | 5 + .vscode/.ai-record-port | 1 + README.md | 79 ++++ ecosystem.config.js | 24 + package.json | 16 + pnpm-lock.yaml | 579 +++++++++++++++++++++++ public/app.js | 921 ++++++++++++++++++++++++++++++++++++ public/index.html | 68 +++ public/style.css | 768 ++++++++++++++++++++++++++++++ server.js | 248 ++++++++++ spec.md | 82 ++++ 12 files changed, 2799 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .gitignore create mode 100644 .vscode/.ai-record-port create mode 100644 README.md create mode 100644 ecosystem.config.js create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 public/style.css create mode 100644 server.js create mode 100644 spec.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..842f682 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "env": { + "ANTHROPIC_AUTH_TOKEN": "sk-a26bd7fd509a4fa581fec1a0daee3cd2", + "ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic", + "ANTHROPIC_MODEL": "deepseek-v4-pro" + }, + "model": "deepseek-v4-pro" +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f9a181 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +data/ +logs/ +.env +*.log diff --git a/.vscode/.ai-record-port b/.vscode/.ai-record-port new file mode 100644 index 0000000..80e358d --- /dev/null +++ b/.vscode/.ai-record-port @@ -0,0 +1 @@ +57816 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ee144e3 --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# Todo List + +简约大方的 Todo List 应用,支持多层级任务管理(主任务 → 子任务 → 孙任务),数据存储于本地 JSON 文件,无需数据库和鉴权。 + +## 技术栈 + +- **后端**:Node.js + Express +- **前端**:原生 HTML/CSS/JS,零框架依赖 +- **数据存储**:本地 JSON 文件 (`data/todos.json`) +- **部署**:PM2 进程守护 + +## 快速开始 + +```bash +# 安装依赖 +pnpm install + +# 开发模式(文件变更自动重启) +pnpm dev + +# 生产模式 +pnpm start +``` + +服务默认运行在 `http://localhost:3003`。 + +## PM2 部署 + +```bash +# 首次启动 +pm2 start ecosystem.config.js + +# 查看状态 +pm2 status + +# 重启 +pm2 restart my-todo-list + +# 停止 +pm2 stop my-todo-list + +# 设置开机自启 +pm2 save +pm2 startup +``` + +## 功能 + +- ✅ 创建主任务(+ 按钮 + 输入框,回车确认) +- ✅ 子任务 / 孙任务(层级缩进,最多三级) +- ✅ 父任务完成时子孙自动完成 +- ✅ 完成 / 废弃 / 恢复 +- ✅ 历史记录查询(按状态筛选) +- ✅ 响应式设计,适配桌面和移动端 + +## 目录结构 + +``` +my-todo-list/ +├── server.js # Express 服务入口 +├── ecosystem.config.js # PM2 配置 +├── package.json +├── public/ +│ ├── index.html # 页面 +│ ├── style.css # 样式 +│ └── app.js # 前端逻辑 +├── data/ +│ └── todos.json # 数据文件(运行时自动创建) +└── spec.md # 需求规格 +``` + +## API + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/todos` | 获取全部任务 | +| POST | `/api/todos` | 创建任务 | +| PATCH | `/api/todos/:id` | 更新任务 | +| DELETE | `/api/todos/:id` | 删除任务(级联删除子孙) | diff --git a/ecosystem.config.js b/ecosystem.config.js new file mode 100644 index 0000000..d9d63c9 --- /dev/null +++ b/ecosystem.config.js @@ -0,0 +1,24 @@ +module.exports = { + apps: [ + { + name: 'my-todo-list', + script: 'server.js', + cwd: __dirname, + instances: 1, + exec_mode: 'fork', + env: { + NODE_ENV: 'production', + PORT: 3003, + }, + // 日志 + log_date_format: 'YYYY-MM-DD HH:mm:ss', + error_file: './logs/error.log', + out_file: './logs/out.log', + merge_logs: true, + // 自动重启 + max_memory_restart: '200M', + autorestart: true, + watch: false, + }, + ], +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..c254fd4 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "my-todo-list", + "version": "1.0.0", + "description": "简约大方的 Todo List 应用,支持多层级任务管理", + "private": true, + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js" + }, + "dependencies": { + "express": "^4.21.0" + }, + "engines": { + "node": ">=18" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..f4cdb75 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,579 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + express: + specifier: ^4.21.0 + version: 4.22.2 + +packages: + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + body-parser@1.20.5: + resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + express@4.22.2: + resolution: {integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==} + engines: {node: '>= 0.10.0'} + + finalhandler@1.3.2: + resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} + engines: {node: '>= 0.8'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-to-regexp@0.1.13: + resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} + engines: {node: '>= 0.8'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + +snapshots: + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + array-flatten@1.1.1: {} + + body-parser@1.20.5: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.15.2 + raw-body: 2.5.3 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + cookie-signature@1.0.7: {} + + cookie@0.7.2: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + depd@2.0.0: {} + + destroy@1.2.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + express@4.22.2: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.5 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.2 + fresh: 0.5.2 + http-errors: 2.0.1 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.13 + proxy-addr: 2.0.7 + qs: 6.15.2 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.2 + serve-static: 1.16.3 + setprototypeof: 1.2.0 + statuses: 2.0.2 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + finalhandler@1.3.2: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@0.5.2: {} + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + merge-descriptors@1.0.3: {} + + methods@1.1.2: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + + ms@2.0.0: {} + + ms@2.1.3: {} + + negotiator@0.6.3: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + parseurl@1.3.3: {} + + path-to-regexp@0.1.13: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.15.2: + dependencies: + side-channel: 1.1.0 + + range-parser@1.2.1: {} + + raw-body@2.5.3: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + statuses@2.0.2: {} + + toidentifier@1.0.1: {} + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + unpipe@1.0.0: {} + + utils-merge@1.0.1: {} + + vary@1.1.2: {} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..3451aba --- /dev/null +++ b/public/app.js @@ -0,0 +1,921 @@ +/* ── Todo App ──────────────────────────────────────── */ +class TodoApp { + constructor() { + this.todos = []; + this.view = 'active'; // 'active' | 'completed' | 'abandoned' + this.openMenuId = null; // 当前打开的菜单 ID + this.addingChildFor = null; // 正在添加子任务的父任务 ID + this.editingId = null; // 正在编辑的任务 ID + this.draftKey = 'todo-draft'; // localStorage 主输入草稿 + this.subDraftPrefix = 'todo-sub-draft-'; // 子任务草稿前缀 + this.editDraftPrefix = 'todo-edit-draft-'; // 编辑草稿前缀 + + // 滑动状态 + this.swipeState = { wrapper: null, startX: 0, startY: 0, deltaX: 0, locked: false }; + this.openWrapper = null; // 当前左滑展开的 wrapper + + // 自动保存 + this.autoSaveEnabled = false; + this.autoSaveSec = 10; // 默认 10 秒 + this.autoSaveTimer = null; + + this.cacheDom(); + this.bindEvents(); + this.restoreDraft(); + this.init(); + } + + async init() { + await this.fetchSettings(); // 从服务端恢复自动保存状态 + await this.fetchAndRender(); + } + + /* ── DOM 缓存 ──────────────────────────────────── */ + cacheDom() { + this.$todoList = document.getElementById('todoList'); + this.$emptyState = document.getElementById('emptyState'); + this.$taskCount = document.getElementById('taskCount'); + this.$createInput = document.getElementById('createInput'); + this.$btnConfirmCreate = document.getElementById('btnConfirmCreate'); + this.$autoSaveCheck = document.getElementById('autoSaveCheck'); + this.$autoSaveSeconds = document.getElementById('autoSaveSeconds'); + } + + /* ── 事件绑定 ──────────────────────────────────── */ + bindEvents() { + // 主任务创建 + this.$btnConfirmCreate.addEventListener('click', () => this.createMainTodo()); + this.$createInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') this.createMainTodo(); + if (e.key === 'Escape') { this.$createInput.value = ''; this.saveDraft(); } + }); + this.$createInput.addEventListener('input', () => this.saveDraft()); + + // 事件委托 + this.$todoList.addEventListener('click', (e) => { + // 滑动背景按钮 + const swipeAct = e.target.closest('.swipe-act'); + if (swipeAct) { + e.stopPropagation(); + const wrapper = e.target.closest('.todo-row-wrapper'); + if (wrapper) this.handleSwipeAction(swipeAct.dataset.swipeAction, wrapper.dataset.id); + return; + } + + const row = e.target.closest('.todo-row'); + if (!row) return; + const id = row.dataset.id; + + if (e.target.closest('.checkbox')) { + this.toggleComplete(id, row); + } else if (e.target.closest('.btn-menu')) { + e.stopPropagation(); + this.toggleMenu(id, row); + } else if (e.target.closest('.btn-add-sub')) { + e.stopPropagation(); + this.showSubInput(id); + } else if (e.target.closest('.btn-edit')) { + e.stopPropagation(); + this.startEdit(id, row); + } + }); + + this.$todoList.addEventListener('keydown', (e) => { + if (e.target.classList.contains('inline-create-input')) { + const wrap = e.target.closest('.inline-create'); + const parentId = wrap.dataset.parentId; + if (e.key === 'Enter') this.createSubTodo(parentId, e.target); + if (e.key === 'Escape') this.hideSubInput(); + } + if (e.target.classList.contains('inline-edit-input')) { + if (e.key === 'Escape') this.cancelEdit(); + } + }); + + // 双击标题 → 进入编辑 + this.$todoList.addEventListener('dblclick', (e) => { + const row = e.target.closest('.todo-row'); + if (!row) return; + // 排除 checkbox、按钮等 + if (e.target.closest('.checkbox, .btn-icon, button')) return; + const id = row.dataset.id; + const todo = this.todos.find(t => t.id === id); + if (todo && todo.status === 'active') this.startEdit(id, row); + }); + + // 右键 → 打开"更多"菜单 + this.$todoList.addEventListener('contextmenu', (e) => { + const row = e.target.closest('.todo-row'); + if (!row) return; + e.preventDefault(); + const id = row.dataset.id; + this.toggleMenu(id, row); + }); + + // 全局点击:关闭菜单 + 关闭滑动(排除菜单按钮和菜单本身) + document.addEventListener('click', (e) => { + if (!e.target.closest('.btn-menu') && !e.target.closest('.popup-menu')) { + this.closeMenu(); + } + if (!e.target.closest('.todo-row-wrapper')) this.closeSwipe(); + }); + + // 页面关闭前:自动保存未提交的新建任务(编辑不自动保存) + window.addEventListener('beforeunload', () => this.autoSaveDrafts()); + + // 任何输入框键入时,若自动保存已开启则重置倒计时 + document.addEventListener('input', (e) => { + if (e.target.matches('.create-input, .inline-create-input, .inline-edit-input')) { + this.scheduleAutoSave(); + } + }); + + // 触摸事件委托(滑动) + this.$todoList.addEventListener('touchstart', (e) => this.onTouchStart(e), { passive: false }); + this.$todoList.addEventListener('touchmove', (e) => this.onTouchMove(e), { passive: false }); + this.$todoList.addEventListener('touchend', (e) => this.onTouchEnd(e), { passive: false }); + + // 底部筛选 + document.querySelectorAll('.filter-tab').forEach(btn => { + btn.addEventListener('click', () => this.switchView(btn.dataset.view)); + }); + + // 自动保存开关 + this.$autoSaveCheck.addEventListener('change', () => { + this.autoSaveEnabled = this.$autoSaveCheck.checked; + this.saveSetting('autoSaveEnabled', this.autoSaveEnabled); + if (!this.autoSaveEnabled) { + clearTimeout(this.autoSaveTimer); + this.autoSaveTimer = null; + } + }); + + // 点击秒数切换间隔 + this.$autoSaveSeconds.addEventListener('click', () => { + const options = [5, 10, 30, 60]; + const current = this.autoSaveSec; + const idx = options.indexOf(current); + const next = options[(idx + 1) % options.length]; + this.autoSaveSec = next; + this.$autoSaveSeconds.textContent = next + 's'; + this.saveSetting('autoSaveSec', this.autoSaveSec); + }); + } + + /* ── 滑动触摸处理 ──────────────────────────────── */ + onTouchStart(e) { + const wrapper = e.target.closest('.todo-row-wrapper'); + if (!wrapper) { this.closeSwipe(); return; } + + // 如果用户在编辑/输入状态,不处理滑动 + if (e.target.closest('input, textarea')) return; + if (e.target.closest('.popup-menu')) return; + + const touch = e.touches[0]; + this.swipeState = { + wrapper, + startX: touch.clientX, + startY: touch.clientY, + deltaX: 0, + locked: false, + }; + } + + onTouchMove(e) { + const { wrapper, startX, startY, locked } = this.swipeState; + if (!wrapper) return; + + const touch = e.touches[0]; + const dx = touch.clientX - startX; + const dy = touch.clientY - startY; + + if (!locked) { + if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 8) { + this.swipeState.locked = true; + } else { + return; // 垂直滚动,不处理 + } + } + + // 限制滑动范围:最多展开 200px + const clamped = Math.max(-200, Math.min(200, dx)); + this.swipeState.deltaX = clamped; + + const swipeEl = wrapper.querySelector('.todo-row-swipe'); + if (swipeEl) { + swipeEl.style.transition = 'none'; + swipeEl.style.transform = `translateX(${clamped}px)`; + } + + e.preventDefault(); + } + + onTouchEnd(e) { + const { wrapper, deltaX } = this.swipeState; + this.swipeState = { wrapper: null, startX: 0, startY: 0, deltaX: 0, locked: false }; + if (!wrapper) return; + + const swipeEl = wrapper.querySelector('.todo-row-swipe'); + const SWIPE_THRESHOLD = 60; + + if (deltaX > SWIPE_THRESHOLD) { + // 右滑:触发完成/恢复 + const id = wrapper.dataset.id; + const todo = this.todos.find(t => t.id === id); + if (!todo) { this.snapSwipe(swipeEl, 0); return; } + if (todo.status === 'active') { + this.handleMenuAction('complete', id); + } else { + this.handleMenuAction('restore', id); + } + if (swipeEl) this.snapSwipe(swipeEl, 0); + } else if (deltaX < -SWIPE_THRESHOLD) { + // 左滑:展开操作按钮 + this.closeSwipe(); + const rightBg = wrapper.querySelector('.swipe-bg-right'); + const btnWidth = rightBg ? rightBg.offsetWidth : 140; + this.openWrapper = wrapper; + wrapper.classList.add('swiped-open'); + if (swipeEl) this.snapSwipe(swipeEl, -btnWidth); + } else { + // 滑动不足,回弹 + this.snapSwipe(swipeEl, 0); + } + } + + snapSwipe(el, targetX) { + if (!el) return; + el.style.transition = 'transform 0.25s cubic-bezier(0.2, 0, 0, 1)'; + el.style.transform = targetX === 0 ? '' : `translateX(${targetX}px)`; + } + + closeSwipe() { + if (this.openWrapper) { + this.openWrapper.classList.remove('swiped-open'); + const swipeEl = this.openWrapper.querySelector('.todo-row-swipe'); + this.snapSwipe(swipeEl, 0); + this.openWrapper = null; + } + } + + async handleSwipeAction(action, id) { + this.closeSwipe(); + switch (action) { + case 'edit': { + const wrapper = document.querySelector(`.todo-row-wrapper[data-id="${id}"]`); + const row = wrapper?.querySelector('.todo-row'); + if (row) this.startEdit(id, row); + return; + } + case 'abandon': + await this.handleMenuAction('abandon', id); + break; + case 'delete': + await this.handleMenuAction('delete', id); + break; + } + } + + /* ── API 调用 ──────────────────────────────────── */ + async api(path, options = {}) { + const res = await fetch(path, { + headers: { 'Content-Type': 'application/json' }, + ...options, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || `请求失败 (${res.status})`); + } + return res.json(); + } + + async fetchAndRender() { + try { + this.todos = await this.api('/api/todos'); + this.render(); + } catch (e) { + console.error('加载数据失败:', e); + } + } + + /* ── 主任务创建 ────────────────────────────────── */ + async createMainTodo() { + const title = this.$createInput.value.trim(); + if (!title) return; + + try { + await this.api('/api/todos', { + method: 'POST', + body: JSON.stringify({ title }), + }); + this.$createInput.value = ''; + this.saveDraft(); + this.$createInput.focus(); + await this.fetchAndRender(); + } catch (e) { + alert('创建失败: ' + e.message); + } + } + + /* ── 草稿保存 / 恢复 ──────────────────────────── */ + saveDraft() { + const val = this.$createInput.value; + if (val) { + localStorage.setItem(this.draftKey, val); + } else { + localStorage.removeItem(this.draftKey); + } + } + + restoreDraft() { + const draft = localStorage.getItem(this.draftKey); + if (draft) { + this.$createInput.value = draft; + } + } + + /* 页面关闭时:自动落库未提交的新建任务(编辑不自动保存) */ + autoSaveDrafts() { + const beacon = (body) => { + const blob = new Blob([JSON.stringify(body)], { type: 'application/json' }); + navigator.sendBeacon('/api/todos', blob); + }; + + // 主任务新建输入框 + const mainTitle = this.$createInput.value.trim(); + if (mainTitle) { + beacon({ title: mainTitle }); + } + + // 子/孙任务新建输入框 + const subInput = document.querySelector('.inline-create-input'); + if (subInput) { + const subTitle = subInput.value.trim(); + if (subTitle) { + const parentId = subInput.closest('.inline-create')?.dataset.parentId; + beacon({ title: subTitle, parentId: parentId || null }); + } + } + + // 编辑中的已有任务不做自动保存(草稿已在 localStorage 中) + } + + /* ── 服务端设置存取 ────────────────────────────── */ + async fetchSettings() { + try { + const s = await this.api('/api/settings'); + this.autoSaveEnabled = s.autoSaveEnabled || false; + this.autoSaveSec = s.autoSaveSec || 10; + } catch { /* 使用默认值 */ } + // 恢复 UI + this.$autoSaveCheck.checked = this.autoSaveEnabled; + this.$autoSaveSeconds.textContent = this.autoSaveSec + 's'; + } + + async saveSetting(key, value) { + try { + await this.api('/api/settings', { + method: 'PUT', + body: JSON.stringify({ [key]: value }), + }); + } catch { /* 静默失败 */ } + } + + /* 自动保存调度:任何输入框键入时重置倒计时,N 秒无操作后自动落库 */ + scheduleAutoSave() { + if (!this.autoSaveEnabled) return; + clearTimeout(this.autoSaveTimer); + this.autoSaveTimer = setTimeout(() => this.performAutoSave(), this.autoSaveSec * 1000); + } + + async performAutoSave() { + // 1. 主任务新建输入框 + const mainTitle = this.$createInput.value.trim(); + if (mainTitle) { + try { + await this.api('/api/todos', { method: 'POST', body: JSON.stringify({ title: mainTitle }) }); + this.$createInput.value = ''; + this.saveDraft(); + await this.fetchAndRender(); + this.$createInput.focus(); + return; + } catch { /* 静默失败 */ } + } + + // 2. 子/孙任务新建输入框 + const subInput = document.querySelector('.inline-create-input'); + if (subInput) { + const subTitle = subInput.value.trim(); + if (subTitle) { + const parentId = subInput.closest('.inline-create')?.dataset.parentId; + try { + await this.api('/api/todos', { method: 'POST', body: JSON.stringify({ title: subTitle, parentId: parentId || null }) }); + await this.fetchAndRender(); + return; + } catch { /* 静默失败 */ } + } + } + + // 3. 行内编辑输入框(静默保存,不退出编辑状态) + const editInput = document.querySelector('.inline-edit-input'); + if (editInput && this.editingId) { + const newTitle = editInput.value.trim(); + const row = editInput.closest('.todo-row'); + const original = row?.dataset.originalTitle; + if (newTitle && newTitle !== original) { + try { + await this.api(`/api/todos/${this.editingId}`, { + method: 'PATCH', + body: JSON.stringify({ title: newTitle }), + }); + // 更新内存中的数据,避免重渲染 + const todo = this.todos.find(t => t.id === this.editingId); + if (todo) todo.title = newTitle; + // 更新基准标题,后续自动保存只比较最新已保存版本 + row.dataset.originalTitle = newTitle; + localStorage.removeItem(this.editDraftPrefix + this.editingId); + // 不退出编辑,不重渲染 — 用户手动点 ✓ 才退出 + } catch { /* 静默失败 */ } + } + } + } + + /* ── 行内编辑 ──────────────────────────────────── */ + startEdit(id, row) { + // 清理上一个编辑态(不重渲染,避免破坏当前 DOM 引用) + if (this.editingId && this.editingId !== id) { + const prevRow = document.querySelector(`.todo-row[data-id="${this.editingId}"]`); + if (prevRow) { + const prevTodo = this.todos.find(t => t.id === this.editingId); + if (prevTodo) { + const prevTitle = prevRow.querySelector('.todo-title'); + if (prevTitle) prevTitle.textContent = prevTodo.title; + } + prevRow.querySelector('.btn-edit-confirm')?.remove(); + } + localStorage.removeItem(this.editDraftPrefix + this.editingId); + this.editingId = null; + } + + const todo = this.todos.find(t => t.id === id); + if (!todo) return; + + this.editingId = id; + const titleEl = row.querySelector('.todo-title'); + const originalTitle = todo.title; + const editDraftKey = this.editDraftPrefix + id; + // 恢复草稿:有编辑草稿则用它,否则用原标题 + const draft = localStorage.getItem(editDraftKey); + const displayTitle = draft !== null ? draft : originalTitle; + + titleEl.innerHTML = ` +
+ + +
`; + const textarea = titleEl.querySelector('.inline-edit-input'); + const confirmBtn = titleEl.querySelector('.btn-edit-confirm'); + textarea.focus(); + textarea.setSelectionRange(textarea.value.length, textarea.value.length); + + // 保存编辑草稿 + textarea.addEventListener('input', () => { + const val = textarea.value; + if (val && val !== originalTitle) { + localStorage.setItem(editDraftKey, val); + } else { + localStorage.removeItem(editDraftKey); + } + }); + + const autoResize = () => { + textarea.style.height = 'auto'; + textarea.style.height = textarea.scrollHeight + 'px'; + }; + textarea.addEventListener('input', autoResize); + autoResize(); + + confirmBtn.addEventListener('mousedown', (e) => { + e.preventDefault(); + this.commitEdit(textarea); + }); + + row.dataset.originalTitle = originalTitle; + } + + async commitEdit(inputEl) { + const id = this.editingId; + if (!id) return; + const title = inputEl.value.trim(); + this.editingId = null; + localStorage.removeItem(this.editDraftPrefix + id); + + if (!title || title === inputEl.closest('.todo-row')?.dataset.originalTitle) { + this.fetchAndRender(); + return; + } + + try { + await this.api(`/api/todos/${id}`, { + method: 'PATCH', + body: JSON.stringify({ title }), + }); + await this.fetchAndRender(); + } catch (e) { + alert('编辑失败: ' + e.message); + this.fetchAndRender(); + } + } + + cancelEdit() { + const id = this.editingId; + if (id) localStorage.removeItem(this.editDraftPrefix + id); + this.editingId = null; + this.fetchAndRender(); + } + + /* ── 子任务创建 ────────────────────────────────── */ + showSubInput(parentId) { + this.hideSubInput(); + this.addingChildFor = parentId; + + const level = this.getLevel(parentId); + if (level >= 2) return; + + const insertAfter = this.findLastDescendantRow(parentId); + const row = document.createElement('li'); + row.className = `inline-create level-${level + 1}`; + row.dataset.parentId = parentId; + const draftKey = this.subDraftPrefix + parentId; + const draft = localStorage.getItem(draftKey) || ''; + + row.innerHTML = ` + + + `; + + if (insertAfter) { + insertAfter.after(row); + } else { + const parentEl = this.$todoList.querySelector(`.todo-row-wrapper[data-id="${parentId}"]`); + if (parentEl) parentEl.after(row); + else this.$todoList.appendChild(row); + } + + const input = row.querySelector('.inline-create-input'); + input.focus(); + // 保存草稿 + input.addEventListener('input', () => { + const val = input.value; + if (val) localStorage.setItem(draftKey, val); + else localStorage.removeItem(draftKey); + }); + row.dataset.draftKey = draftKey; + } + + hideSubInput() { + const existing = this.$todoList.querySelector('.inline-create'); + if (existing) { + const dk = existing.dataset.draftKey; + if (dk) localStorage.removeItem(dk); + existing.remove(); + } + this.addingChildFor = null; + } + + async createSubTodo(parentId, inputEl) { + const title = inputEl.value.trim(); + if (!title) return; + + try { + await this.api('/api/todos', { + method: 'POST', + body: JSON.stringify({ title, parentId }), + }); + // 清除草稿 + const dk = this.subDraftPrefix + parentId; + localStorage.removeItem(dk); + await this.fetchAndRender(); + } catch (e) { + alert('创建失败: ' + e.message); + } + } + + /* ── 切换完成状态 ──────────────────────────────── */ + async toggleComplete(id, row) { + const todo = this.todos.find(t => t.id === id); + if (!todo) return; + const newStatus = todo.status === 'completed' ? 'active' : 'completed'; + + try { + await this.api(`/api/todos/${id}`, { + method: 'PATCH', + body: JSON.stringify({ status: newStatus }), + }); + await this.fetchAndRender(); + } catch (e) { + alert('操作失败: ' + e.message); + } + } + + /* ── 弹出菜单 ──────────────────────────────────── */ + toggleMenu(id, row) { + this.closeMenu(); + + if (this.openMenuId === id) { + this.openMenuId = null; + return; + } + + this.openMenuId = id; + const todo = this.todos.find(t => t.id === id); + if (!todo) return; + + const level = this.getLevel(id); + const canAddSub = level < 2 && todo.status === 'active'; + + const wrapper = row.closest('.todo-row-wrapper'); + wrapper.querySelector('.popup-menu')?.remove(); + + const menu = document.createElement('div'); + menu.className = 'popup-menu show'; + menu.innerHTML = this.buildMenuHtml(todo, canAddSub); + wrapper.appendChild(menu); + + menu.addEventListener('click', (e) => { + const action = e.target.closest('[data-action]')?.dataset.action; + if (!action) return; + this.handleMenuAction(action, id); + }); + } + + buildMenuHtml(todo, canAddSub) { + const items = []; + if (todo.status === 'active') { + items.push(``); + items.push(``); + } else { + items.push(``); + } + if (canAddSub) { + items.push(``); + items.push(``); + } + items.push(``); + items.push(``); + return items.join(''); + } + + closeMenu() { + document.querySelectorAll('.popup-menu').forEach(m => m.remove()); + this.openMenuId = null; + } + + async handleMenuAction(action, id) { + this.closeMenu(); + try { + switch (action) { + case 'complete': + await this.api(`/api/todos/${id}`, { + method: 'PATCH', + body: JSON.stringify({ status: 'completed' }), + }); + break; + case 'abandon': + await this.api(`/api/todos/${id}`, { + method: 'PATCH', + body: JSON.stringify({ status: 'abandoned' }), + }); + break; + case 'restore': + await this.api(`/api/todos/${id}`, { + method: 'PATCH', + body: JSON.stringify({ status: 'active' }), + }); + break; + case 'addSub': + this.showSubInput(id); + return; + case 'delete': + if (!confirm('确定要删除该任务吗?子任务也会一并删除。')) return; + await this.api(`/api/todos/${id}`, { method: 'DELETE' }); + break; + } + await this.fetchAndRender(); + } catch (e) { + alert('操作失败: ' + e.message); + } + } + + /* ── 视图切换 ──────────────────────────────────── */ + switchView(view) { + this.view = view; + this.hideSubInput(); + this.closeMenu(); + this.closeSwipe(); + + document.querySelectorAll('.filter-tab').forEach(t => { + t.classList.toggle('active', t.dataset.view === view); + }); + + this.render(); + } + + /* ── 渲染 ──────────────────────────────────────── */ + render() { + this.$todoList.innerHTML = ''; + this.hideSubInput(); + this.closeMenu(); + this.closeSwipe(); + + // 找出根任务并按状态筛选 + let roots = this.todos.filter(t => !t.parentId && t.status === this.view); + + // 已完成和已废弃按更新时间倒序 + if (this.view !== 'active') { + roots.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)); + } + + if (roots.length === 0) { + this.$emptyState.classList.remove('hidden'); + const labels = { active: '暂无进行中的任务', completed: '暂无已完成的任务', abandoned: '暂无已废弃的任务' }; + this.$emptyState.querySelector('p').textContent = labels[this.view] || '暂无任务'; + return; + } + + this.$emptyState.classList.add('hidden'); + for (const root of roots) { + this.renderNode(root, 0, this.$todoList); + } + + this.updateTaskCount(); + } + + // 递归渲染节点及其所有子孙(不限状态,跟随父级归属) + renderNode(todo, level, container) { + const wrapper = this.createRowElement(todo, level); + container.appendChild(wrapper); + + // 渲染所有子节点,不限状态 + const children = this.todos.filter(t => t.parentId === todo.id); + for (const child of children) { + this.renderNode(child, level + 1, container); + } + } + + /* 创建行 DOM(含滑动包装层) */ + createRowElement(todo, level, isHistory = false) { + const wrapper = document.createElement('li'); + wrapper.className = 'todo-row-wrapper'; + wrapper.dataset.id = todo.id; + + const checked = todo.status === 'completed' ? 'checked' : ''; + const disabled = isHistory ? 'disabled' : ''; + const statusLabel = todo.status === 'completed' ? '已完成' : + todo.status === 'abandoned' ? '已废弃' : ''; + const badgeClass = todo.status === 'completed' ? 'completed' : 'abandoned'; + const timeStr = this.formatTime(todo.createdAt); + + // 滑动右侧标签:active → 完成, 其他 → 恢复 + const rightSwipeLabel = isHistory ? '恢复' : '完成'; + + // 右侧操作按钮 + let bgRightActions; + if (isHistory) { + bgRightActions = ` + 编辑 + 删除`; + } else { + bgRightActions = ` + 编辑 + 废弃 + 删除`; + } + + wrapper.innerHTML = ` +
+
+ ✓ ${rightSwipeLabel} +
+
${bgRightActions}
+
+
+
+ + ${this.escapeHtml(todo.title)} + ${!isHistory ? ` + + + ` : ''} + + ${statusLabel ? `${statusLabel}` : ''} + ${timeStr} +
+
`; + + return wrapper; + } + + /* ── 工具方法 ──────────────────────────────────── */ + getLevel(id) { + let depth = 0; + let current = this.todos.find(t => t.id === id); + while (current && current.parentId) { + depth++; + current = this.todos.find(t => t.id === current.parentId); + } + return depth; + } + + findLastDescendantRow(parentId) { + const descendantIds = this.collectDescendantIds(parentId); + for (let i = descendantIds.length - 1; i >= 0; i--) { + const el = this.$todoList.querySelector(`.todo-row-wrapper[data-id="${descendantIds[i]}"]`); + if (el) return el; + } + return null; + } + + collectDescendantIds(parentId) { + const ids = []; + const children = this.todos.filter(t => t.parentId === parentId); + for (const child of children) { + ids.push(child.id); + ids.push(...this.collectDescendantIds(child.id)); + } + return ids; + } + + formatTime(isoStr) { + const d = new Date(isoStr); + const now = new Date(); + const diff = now - d; + + if (diff < 60_000) return '刚刚'; + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟前`; + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} 小时前`; + if (diff < 604_800_000) return `${Math.floor(diff / 86_400_000)} 天前`; + + 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}`; + } + + escapeHtml(str) { + const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; + return str.replace(/[&<>"']/g, c => map[c]); + } + + updateTaskCount() { + const count = this.todos.filter(t => t.status === 'active').length; + this.$taskCount.textContent = count ? `${count} 个进行中` : ''; + } +} + +/* ── 启动 ─────────────────────────────────────────── */ +document.addEventListener('DOMContentLoaded', () => { + new TodoApp(); +}); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..b503f6d --- /dev/null +++ b/public/index.html @@ -0,0 +1,68 @@ + + + + + + Todo + + + +
+ +
+

Todo

+ +
+ 自动保存: + 10s + +
+
+ + +
+ +
+ + +
+ + +
+
    +
    + + + + +

    暂无任务,在上方输入框中添加

    +
    +
    +
    + + +
    + + + +
    +
    + + + + diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..957b139 --- /dev/null +++ b/public/style.css @@ -0,0 +1,768 @@ +/* ── Reset & Base ─────────────────────────────────── */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --color-bg: #f7f8fa; + --color-surface: #ffffff; + --color-border: #ebeef2; + --color-text: #2c3e50; + --color-text-secondary: #94a3b8; + --color-text-muted: #c0c8d2; + --color-accent: #75dac7; + --color-accent-hover: #5cc4b0; + --color-accent-light: rgba(117, 218, 199, 0.12); + --color-completed: #75dac7; + --color-abandoned: #c4a87a; + --color-danger: #e07070; + --color-danger-hover: #c95a5a; + --radius: 10px; + --radius-sm: 6px; + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.04); + --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.06); + --transition: 0.18s ease; +} + +html { + font-size: 16px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", + "Microsoft YaHei", "Helvetica Neue", sans-serif; + background: var(--color-bg); + color: var(--color-text); + line-height: 1.6; + min-height: 100vh; +} + +/* ── App Container ────────────────────────────────── */ +#app { + max-width: 980px; + margin: 0 auto; + padding: 32px 28px 80px; +} + +/* ── Header ───────────────────────────────────────── */ +.app-header { + display: flex; + align-items: baseline; + gap: 12px; + margin-bottom: 28px; +} + +.app-title { + font-size: 1.75rem; + font-weight: 700; + color: var(--color-text); + letter-spacing: -0.02em; +} + +.task-count { + font-size: 0.85rem; + color: var(--color-text-secondary); + font-weight: 400; +} + +/* ── Auto Save ────────────────────────────────────── */ +.auto-save-group { + display: flex; + align-items: center; + gap: 6px; + margin-left: auto; +} + +.auto-save-label { + font-size: 0.75rem; + color: var(--color-text-muted); + user-select: none; +} + +.auto-save-seconds { + font-size: 0.78rem; + font-weight: 500; + color: var(--color-accent); + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + transition: background var(--transition); + user-select: none; +} + +.auto-save-seconds:hover { + background: var(--color-accent-light); +} + +.auto-save-toggle { + display: inline-flex; + cursor: pointer; +} + +.auto-save-toggle input { + display: none; +} + +.toggle-track { + width: 34px; + height: 20px; + background: #cdd3da; + border-radius: 10px; + position: relative; + transition: background var(--transition); +} + +.toggle-thumb { + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + background: #fff; + border-radius: 50%; + transition: transform var(--transition); + box-shadow: 0 1px 2px rgba(0,0,0,0.15); +} + +.auto-save-toggle input:checked + .toggle-track { + background: var(--color-accent); +} + +.auto-save-toggle input:checked + .toggle-track .toggle-thumb { + transform: translateX(14px); +} + +/* ── Create Bar ───────────────────────────────────── */ +.create-bar { + display: flex; + align-items: center; + gap: 10px; + background: var(--color-surface); + border-radius: var(--radius); + padding: 10px 14px; + box-shadow: var(--shadow-sm); + border: 1px solid var(--color-border); + transition: box-shadow var(--transition); + margin-bottom: 20px; +} + +.create-bar:focus-within { + box-shadow: var(--shadow-md); + border-color: var(--color-accent); +} + +.create-input { + flex: 1; + border: none; + outline: none; + font-size: 0.95rem; + color: var(--color-text); + background: transparent; + line-height: 1.5; +} + +.create-input::placeholder { + color: var(--color-text-muted); +} + +/* ── Buttons ──────────────────────────────────────── */ +.btn-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + background: transparent; + border-radius: var(--radius-sm); + cursor: pointer; + color: var(--color-text-secondary); + transition: all var(--transition); + flex-shrink: 0; +} + +.btn-icon:hover { + background: #f1f3f6; + color: var(--color-text); +} + +.btn-icon:active { + transform: scale(0.94); +} + +.btn-confirm { + color: var(--color-accent); +} + +.btn-confirm:hover { + background: var(--color-accent-light); + color: var(--color-accent-hover); +} + +.btn-add-sub { + opacity: 0; + width: 24px; + height: 24px; + color: var(--color-accent); +} + +.btn-edit { + opacity: 0; + width: 24px; + height: 24px; +} + +.btn-menu { + opacity: 0; + width: 26px; + height: 26px; +} + +.todo-row:hover .btn-add-sub, +.todo-row:hover .btn-edit, +.todo-row:hover .btn-menu { + opacity: 1; +} + +.btn-add-sub:hover, +.btn-edit:hover, +.btn-menu:hover { + background: #f1f3f6; +} + +/* ── Swipe Wrapper ─────────────────────────────────── */ +.todo-row-wrapper { + position: relative; + margin-bottom: 1px; + border-radius: var(--radius-sm); +} + +/* 滑动背景层 */ +.swipe-bg { + position: absolute; + inset: 0; + display: flex; + justify-content: space-between; + align-items: stretch; + border-radius: var(--radius-sm); +} + +.swipe-bg-left { + display: flex; + align-items: center; + padding: 0 24px; + background: var(--color-accent); + color: #fff; + font-size: 0.85rem; + font-weight: 600; + white-space: nowrap; + border-radius: var(--radius-sm) 0 0 var(--radius-sm); +} + +.swipe-bg-right { + display: flex; + align-items: stretch; + margin-left: auto; +} + +.swipe-bg-right .swipe-act { + display: flex; + align-items: center; + padding: 0 16px; + color: #fff; + font-size: 0.8rem; + font-weight: 600; + white-space: nowrap; + cursor: pointer; + user-select: none; +} + +.swipe-act-edit { background: var(--color-accent); } +.swipe-act-abandon { background: #c4a87a; } +.swipe-act-delete { background: var(--color-danger); } +.swipe-act-restore { background: var(--color-accent); } +.swipe-act:last-child { border-radius: 0 var(--radius-sm) var(--radius-sm) 0; } + +/* 滑动前景层 */ +.todo-row-swipe { + position: relative; + z-index: 1; + overflow: hidden; + background: var(--color-bg); + border-radius: var(--radius-sm); + transition: transform 0.25s cubic-bezier(0.2, 0, 0, 1); + will-change: transform; +} + +/* ── Todo List ────────────────────────────────────── */ +.todo-list { + list-style: none; +} + +.todo-list-wrap { + min-height: 100px; +} + +/* ── Todo Row ─────────────────────────────────────── */ +.todo-row { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 10px 12px; + border-radius: var(--radius-sm); + transition: background var(--transition); + position: relative; + min-height: 44px; +} + +.todo-row:hover { + background: #f1f3f6; +} + +/* 缩进 */ +.todo-row.level-1 { + padding-left: 42px; +} + +.todo-row.level-2 { + padding-left: 72px; +} + +/* 缩进引导线 */ +.todo-row.level-1::before, +.todo-row.level-2::before { + content: ""; + position: absolute; + left: 20px; + top: 0; + bottom: 0; + width: 1px; + background: #cdd3da; +} + +.todo-row.level-2::before { + left: 50px; +} + +/* ── Checkbox ─────────────────────────────────────── */ +.checkbox { + appearance: none; + -webkit-appearance: none; + width: 20px; + height: 20px; + border: 2px solid #cdd3da; + margin-top: 2px; + border-radius: 50%; + cursor: pointer; + flex-shrink: 0; + transition: all var(--transition); + position: relative; +} + +.checkbox:hover { + border-color: var(--color-accent); +} + +.checkbox:checked { + background: var(--color-accent); + border-color: var(--color-accent); +} + +.checkbox:checked::after { + content: ""; + position: absolute; + left: 5px; + top: 1px; + width: 7px; + height: 11px; + border: 2px solid #fff; + border-top: none; + border-left: none; + transform: rotate(45deg); +} + +/* ── Todo Title ───────────────────────────────────── */ +.todo-title { + flex: 1; + font-size: 0.925rem; + color: var(--color-text); + word-break: break-word; + min-height: calc(0.925rem * 1.6); + transition: color var(--transition); +} + +.todo-row.completed .todo-title { + color: var(--color-text-secondary); +} + +.todo-row.abandoned .todo-title { + color: var(--color-text-secondary); + text-decoration: line-through; + text-decoration-thickness: 1px; +} + +/* ── Todo Time ────────────────────────────────────── */ +.todo-time { + font-size: 0.75rem; + color: var(--color-text-muted); + flex-shrink: 0; + margin-left: auto; + margin-right: 4px; +} + +/* ── Status Badge ─────────────────────────────────── */ +.status-badge { + font-size: 0.72rem; + padding: 2px 8px; + border-radius: 10px; + font-weight: 500; + flex-shrink: 0; + opacity: 0; + transition: opacity var(--transition); +} + +.todo-row.completed .status-badge, +.todo-row.abandoned .status-badge, +.todo-row:hover .status-badge { + opacity: 1; +} + +.status-badge.completed { + color: #4da08c; + background: rgba(117, 218, 199, 0.15); +} + +.status-badge.abandoned { + color: #a08860; + background: rgba(196, 168, 122, 0.15); +} + +/* ── Inline Create (子任务添加) ────────────────────── */ +.inline-create { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + margin-top: 2px; +} + +.inline-create.level-1 { + padding-left: 42px; +} + +.inline-create.level-2 { + padding-left: 72px; +} + +.inline-create-input { + flex: 1; + border: none; + border-bottom: 1.5px solid var(--color-accent); + outline: none; + font-size: 0.9rem; + color: var(--color-text); + background: transparent; + padding: 4px 0; + line-height: 1.4; +} + +.inline-create-input::placeholder { + color: var(--color-text-muted); +} + +/* ── Inline Edit (行内编辑) ───────────────────────── */ +.inline-edit-wrap { + display: flex; + align-items: flex-start; + gap: 6px; + flex: 1; + min-width: 0; +} + +.inline-edit-input { + flex: 1; + min-width: 0; + margin: 0; + border: none; + box-shadow: 0 1.5px 0 0 var(--color-accent); + outline: none; + font-size: 0.925rem; + color: var(--color-text); + background: transparent; + padding: 0; + line-height: inherit; + font-family: inherit; + resize: none; + overflow: hidden; + overflow-wrap: break-word; + word-break: break-word; +} + +.btn-edit-confirm { + flex-shrink: 0; + width: 22px; + height: 22px; + padding-top: 1px; +} + +/* ── Empty State ──────────────────────────────────── */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + padding: 48px 20px; + color: var(--color-text-secondary); + text-align: center; +} + +.empty-state svg { + margin-bottom: 16px; + opacity: 0.6; +} + +.empty-state p { + font-size: 0.9rem; +} + +.empty-state.hidden { + display: none; +} + +/* ── Footer ───────────────────────────────────────── */ +.app-footer { + position: fixed; + bottom: 0; + left: 0; + right: 0; + display: flex; + justify-content: center; + gap: 0; + background: var(--color-surface); + border-top: 1px solid var(--color-border); + padding: 0; + z-index: 10; +} + +.filter-tab { + padding: 12px 24px; + border: none; + background: transparent; + font-size: 0.875rem; + font-weight: 500; + color: var(--color-text-secondary); + cursor: pointer; + transition: all var(--transition); + position: relative; +} + +.filter-tab.active { + color: var(--color-accent); +} + +.filter-tab.active::after { + content: ""; + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 24px; + height: 2.5px; + background: var(--color-accent); + border-radius: 2px; +} + +.filter-tab:hover { + color: var(--color-text); +} + +.filter-tab.active:hover { + color: var(--color-accent); +} + +/* ── Popup Menu ───────────────────────────────────── */ +.popup-menu { + position: absolute; + right: 12px; + top: 100%; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + box-shadow: var(--shadow-md); + padding: 6px; + z-index: 20; + min-width: 130px; + display: none; +} + +.popup-menu.show { + display: block; + animation: fadeIn 0.12s ease; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +.popup-menu-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 12px; + border: none; + background: transparent; + font-size: 0.84rem; + color: var(--color-text); + cursor: pointer; + border-radius: var(--radius-sm); + transition: background var(--transition); + white-space: nowrap; +} + +.popup-menu-item:hover { + background: #f1f3f6; +} + +.popup-menu-item.danger { + color: var(--color-danger); +} + +.popup-menu-item.danger:hover { + background: #fef0f0; +} + +.popup-divider { + height: 1px; + background: var(--color-border); + margin: 4px 8px; +} + +/* ── Scrollbar ────────────────────────────────────── */ +::-webkit-scrollbar { + width: 4px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: #dce0e4; + border-radius: 2px; +} + +/* ── Responsive ───────────────────────────────────── */ +/* 平板以下 */ +@media (max-width: 768px) { + #app { + padding: 16px 10px 100px; + } + + .app-title { + font-size: 1.4rem; + } + + .create-bar { + padding: 10px 12px; + margin-bottom: 14px; + } + + /* 移动端:隐藏桌面 hover 按钮,改用滑动 */ + .btn-add-sub, + .btn-edit { + display: none; + } + + /* 保留菜单按钮,缩小尺寸 */ + .btn-menu { + opacity: 1; + width: 28px; + height: 28px; + color: var(--color-text-muted); + } + + /* 移动端行高适配触摸 */ + .todo-row { + padding: 12px 10px; + min-height: 48px; + gap: 10px; + } + + /* 复选框稍大,方便手指点击 */ + .checkbox { + width: 22px; + height: 22px; + margin-top: 1px; + } + + /* 缩进 */ + .todo-row.level-1 { + padding-left: 30px; + } + .todo-row.level-2 { + padding-left: 48px; + } + .todo-row.level-1::before { left: 14px; } + .todo-row.level-2::before { left: 32px; } + + .inline-create.level-1 { padding-left: 30px; } + .inline-create.level-2 { padding-left: 48px; } + + /* 时间文字更小 */ + .todo-time { + font-size: 0.7rem; + } + + /* 状态徽标始终可见 */ + .status-badge { + opacity: 1; + font-size: 0.68rem; + padding: 1px 6px; + } + + /* 底部筛选全宽 */ + .filter-tab { + max-width: none; + padding: 14px 16px; + } +} + +/* 小屏手机优化 */ +@media (max-width: 480px) { + #app { + padding: 12px 6px 90px; + } + + .app-title { + font-size: 1.3rem; + } + + .app-header { + margin-bottom: 18px; + padding: 0 4px; + } + + .todo-row.level-1 { padding-left: 26px; } + .todo-row.level-2 { padding-left: 42px; } + .todo-row.level-1::before { left: 11px; } + .todo-row.level-2::before { left: 27px; } + + .inline-create.level-1 { padding-left: 26px; } + .inline-create.level-2 { padding-left: 42px; } + + .swipe-bg-left { + padding: 0 16px; + font-size: 0.75rem; + } + .swipe-bg-right .swipe-act { + padding: 0 12px; + font-size: 0.72rem; + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..4382b54 --- /dev/null +++ b/server.js @@ -0,0 +1,248 @@ +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); diff --git a/spec.md b/spec.md new file mode 100644 index 0000000..604713a --- /dev/null +++ b/spec.md @@ -0,0 +1,82 @@ +# Todo List 需求规格说明 + +## 项目简介 + +一个简约大方的 Todo List 单页应用,支持多层级待办事项管理(主任务/子任务/孙任务),数据存储于本地 JSON 文件,无需数据库和鉴权,适合个人服务器部署。 + +## 技术栈 + +- **后端**:Node.js + Express,提供 RESTful API +- **前端**:原生 HTML/CSS/JS,无框架依赖 +- **数据存储**:本地 JSON 文件(`data/todos.json`) +- **部署**:PM2 进程守护 +- **运行环境**:Node.js ≥ 18 + +## 核心功能 + +### 1. 任务管理 + +- **创建主任务**:页面顶部提供一个 `+` 按钮和输入框,输入标题后点击确认按钮(✓)或按回车键即可创建 +- **创建子任务**:每个主任务下方可添加子任务,子任务缩进一级 +- **创建孙任务**:每个子任务下方可添加孙任务,孙任务缩进两级 +- **层级限制**:最多支持三级(主/子/孙),不再支持更深层级 + +### 2. 任务状态 + +| 状态 | 说明 | +|------|------| +| `active` | 进行中(默认) | +| `completed` | 已完成 | +| `abandoned` | 已废弃 | + +- **完成**:点击任务前的复选框即可标记完成,完成的任务文字添加删除线并变灰 +- **废弃**:通过操作菜单将任务标记为废弃,废弃任务与已完成任务视觉区分(如使用不同颜色/图标) +- **级联完成**:当主任务被标记为完成时,其下所有子孙任务自动标记为已完成 +- **恢复**:已完成/已废弃的任务可以恢复为进行中状态 + +### 3. 历史记录 + +- **历史面板**:可切换查看"进行中"和"历史记录"(已完成 + 已废弃) +- 历史记录按完成/废弃时间倒序排列 +- 支持按状态筛选:全部 / 已完成 / 已废弃 +- 历史记录中的任务不可编辑,仅可恢复或删除 + +### 4. 数据持久化 + +- 所有数据存储在 `data/todos.json` 文件中 +- 服务启动时自动读取,每次数据变更时自动写入 +- 文件写入采用防抖策略,避免频繁 I/O +- 如 JSON 文件损坏,自动备份并重建空数据 + +## 非功能需求 + +- **响应式设计**:适配桌面和移动端 +- **无框架依赖**:前端使用原生 JavaScript,保持极简 +- **部署简单**:`pnpm start` 或 `pm2 start` 一键启动 +- **无鉴权**:不实现登录/鉴权,适合内网或个人 VPS 使用 + +## API 设计 + +| 方法 | 路径 | 说明 | +|------|------|------| +| `GET` | `/api/todos` | 获取所有任务 | +| `POST` | `/api/todos` | 创建任务 | +| `PATCH` | `/api/todos/:id` | 更新任务(标题、状态) | +| `DELETE` | `/api/todos/:id` | 删除任务(级联删除子孙) | + +## 目录结构 + +``` +my-todo-list/ +├── server.js # Express 服务入口 +├── data/ +│ └── todos.json # 数据文件(运行时生成) +├── public/ +│ ├── index.html # 页面结构 +│ ├── style.css # 样式 +│ └── app.js # 前端逻辑 +├── ecosystem.config.js # PM2 配置 +├── package.json +├── README.md +└── spec.md +```