暂无任务,在上方输入框中添加
+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 index ceaea36..6f9a181 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ -jspm_packages/ - -# 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 +data/ +logs/ .env -.env.development.local -.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.* - +*.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 index 9de7925..ee144e3 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,79 @@ -# my-todo-list +# Todo List -待办事项页面 \ No newline at end of file +简约大方的 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 = ` +
暂无任务,在上方输入框中添加
+