Merge remote-tracking branch 'origin/master'

# Conflicts:
#	.gitignore
#	README.md
This commit is contained in:
guoqw7 2026-06-08 20:39:06 +08:00
commit 2369be3ec5
12 changed files with 2796 additions and 132 deletions

View File

@ -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"
}

133
.gitignore vendored
View File

@ -1,132 +1,5 @@
# ---> Node
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/ node_modules/
jspm_packages/ data/
logs/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env .env
.env.development.local *.log
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*

1
.vscode/.ai-record-port vendored Normal file
View File

@ -0,0 +1 @@
57816

View File

@ -1,3 +1,79 @@
# my-todo-list # 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` | 删除任务(级联删除子孙) |

24
ecosystem.config.js Normal file
View File

@ -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,
},
],
};

16
package.json Normal file
View File

@ -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"
}
}

579
pnpm-lock.yaml generated Normal file
View File

@ -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: {}

921
public/app.js Normal file
View File

@ -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 = `
<div class="inline-edit-wrap">
<textarea class="inline-edit-input" rows="1"
maxlength="200">${this.escapeHtml(displayTitle)}</textarea>
<button class="btn-icon btn-confirm btn-edit-confirm" title="确认">
<svg width="16" height="16" viewBox="0 0 18 18" fill="none">
<path d="M3 9l4 4 8-8" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>`;
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 = `
<input type="text" class="inline-create-input"
placeholder="${level === 0 ? '添加子任务…' : '添加孙任务…'}"
maxlength="200" value="${this.escapeHtml(draft)}">
<button class="btn-icon btn-confirm">
<svg width="16" height="16" viewBox="0 0 18 18" fill="none">
<path d="M3 9l4 4 8-8" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
`;
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(`<button class="popup-menu-item" data-action="complete">
<svg width="14" height="14" viewBox="0 0 14 14"><path d="M2 7l3 3 6-6" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
标记完成
</button>`);
items.push(`<button class="popup-menu-item" data-action="abandon">
<svg width="14" height="14" viewBox="0 0 14 14"><circle cx="7" cy="7" r="5.5" stroke="currentColor" stroke-width="1.2" fill="none"/><path d="M5 5l4 4M9 5l-4 4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
标记废弃
</button>`);
} else {
items.push(`<button class="popup-menu-item" data-action="restore">
<svg width="14" height="14" viewBox="0 0 14 14"><path d="M3 8l3 3 5-6" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
恢复
</button>`);
}
if (canAddSub) {
items.push(`<div class="popup-divider"></div>`);
items.push(`<button class="popup-menu-item" data-action="addSub">
<svg width="14" height="14" viewBox="0 0 14 14"><path d="M7 2v10M2 7h10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
添加子任务
</button>`);
}
items.push(`<div class="popup-divider"></div>`);
items.push(`<button class="popup-menu-item danger" data-action="delete">
<svg width="14" height="14" viewBox="0 0 14 14"><path d="M3 4h8M5 4V3a1 1 0 011-1h2a1 1 0 011 1v1M6 6v4M8 6v4" stroke="currentColor" stroke-width="1.2" fill="none" stroke-linecap="round"/><path d="M4 4l.8 7.2a1 1 0 001 .8h2.4a1 1 0 001-.8L10 4" stroke="currentColor" stroke-width="1.2" fill="none"/></svg>
删除
</button>`);
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 = `
<span class="swipe-act swipe-act-edit" data-swipe-action="edit">编辑</span>
<span class="swipe-act swipe-act-delete" data-swipe-action="delete">删除</span>`;
} else {
bgRightActions = `
<span class="swipe-act swipe-act-edit" data-swipe-action="edit">编辑</span>
<span class="swipe-act swipe-act-abandon" data-swipe-action="abandon">废弃</span>
<span class="swipe-act swipe-act-delete" data-swipe-action="delete">删除</span>`;
}
wrapper.innerHTML = `
<div class="swipe-bg">
<div class="swipe-bg-left">
<span> ${rightSwipeLabel}</span>
</div>
<div class="swipe-bg-right">${bgRightActions}</div>
</div>
<div class="todo-row-swipe">
<div class="todo-row ${todo.status} level-${Math.min(level, 2)}" data-id="${todo.id}">
<input type="checkbox" class="checkbox" ${checked} ${disabled}>
<span class="todo-title">${this.escapeHtml(todo.title)}</span>
${!isHistory ? `
<button class="btn-icon btn-add-sub" title="添加子任务">
<svg width="14" height="14" viewBox="0 0 14 14">
<path d="M7 2v10M2 7h10" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
</svg>
</button>
<button class="btn-icon btn-edit" title="编辑">
<svg width="13" height="13" viewBox="0 0 13 13">
<path d="M9.5 1.5l2 2-7.5 7.5L1 11.5l.5-2.5z" stroke="currentColor" stroke-width="1.2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
` : ''}
<button class="btn-icon btn-menu" title="更多操作">
<svg width="15" height="15" viewBox="0 0 15 15">
<circle cx="3.5" cy="7.5" r="1.2" fill="currentColor"/>
<circle cx="7.5" cy="7.5" r="1.2" fill="currentColor"/>
<circle cx="11.5" cy="7.5" r="1.2" fill="currentColor"/>
</svg>
</button>
${statusLabel ? `<span class="status-badge ${badgeClass}">${statusLabel}</span>` : ''}
<span class="todo-time">${timeStr}</span>
</div>
</div>`;
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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
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();
});

68
public/index.html Normal file
View File

@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Todo</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app">
<!-- 头部 -->
<header class="app-header">
<h1 class="app-title">Todo</h1>
<span class="task-count" id="taskCount"></span>
<div class="auto-save-group" id="autoSaveGroup">
<span class="auto-save-label">自动保存:</span>
<span class="auto-save-seconds" id="autoSaveSeconds" title="点击修改间隔">10s</span>
<label class="auto-save-toggle" title="开启/关闭定时自动保存">
<input type="checkbox" id="autoSaveCheck">
<span class="toggle-track">
<span class="toggle-thumb"></span>
</span>
</label>
</div>
</header>
<!-- 主内容区 -->
<main class="app-main">
<!-- 创建栏 -->
<div class="create-bar" id="createBar">
<input
type="text"
class="create-input"
id="createInput"
placeholder="输入新任务,回车确认…"
maxlength="200"
>
<button class="btn-icon btn-confirm" id="btnConfirmCreate" title="确认添加">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<path d="M3 9l4 4 8-8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
<!-- 任务列表 -->
<div class="todo-list-wrap">
<ul class="todo-list" id="todoList"></ul>
<div class="empty-state" id="emptyState">
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
<circle cx="24" cy="24" r="22" stroke="#dce0e4" stroke-width="1.5"/>
<path d="M24 16v16M16 24h16" stroke="#dce0e4" stroke-width="2" stroke-linecap="round"/>
</svg>
<p>暂无任务,在上方输入框中添加</p>
</div>
</div>
</main>
<!-- 底部筛选栏 -->
<footer class="app-footer">
<button class="filter-tab active" data-view="active">进行中</button>
<button class="filter-tab" data-view="completed">已完成</button>
<button class="filter-tab" data-view="abandoned">已废弃</button>
</footer>
</div>
<script src="app.js"></script>
</body>
</html>

768
public/style.css Normal file
View File

@ -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;
}
}

248
server.js Normal file
View File

@ -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.htmlSPA
app.get('*', (_req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// ── 启动服务 ──────────────────────────────────────────
function startServer(port) {
const server = app.listen(port, () => {
console.log(`✅ Todo List 服务已启动: http://localhost:${port}`);
console.log(`📁 数据文件: ${DATA_FILE}`);
});
server.on('error', (err) => {
// 生产环境PM2 管理端口)不自动跳,直接抛错
if (err.code === 'EADDRINUSE' && process.env.NODE_ENV !== 'production') {
console.warn(`⚠️ 端口 ${port} 被占用,尝试 ${port + 1}`);
startServer(port + 1);
} else {
throw err;
}
});
}
startServer(PORT);

82
spec.md Normal file
View File

@ -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
```