Merge remote-tracking branch 'origin/master'
# Conflicts: # .gitignore # README.md
This commit is contained in:
commit
2369be3ec5
8
.claude/settings.local.json
Normal file
8
.claude/settings.local.json
Normal 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
133
.gitignore
vendored
@ -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
1
.vscode/.ai-record-port
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
57816
|
||||||
80
README.md
80
README.md
@ -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
24
ecosystem.config.js
Normal 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
16
package.json
Normal 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
579
pnpm-lock.yaml
generated
Normal 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
921
public/app.js
Normal 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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||||
|
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
68
public/index.html
Normal 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
768
public/style.css
Normal 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
248
server.js
Normal 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.html(SPA)
|
||||||
|
app.get('*', (_req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 启动服务 ──────────────────────────────────────────
|
||||||
|
function startServer(port) {
|
||||||
|
const server = app.listen(port, () => {
|
||||||
|
console.log(`✅ Todo List 服务已启动: http://localhost:${port}`);
|
||||||
|
console.log(`📁 数据文件: ${DATA_FILE}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on('error', (err) => {
|
||||||
|
// 生产环境(PM2 管理端口)不自动跳,直接抛错
|
||||||
|
if (err.code === 'EADDRINUSE' && process.env.NODE_ENV !== 'production') {
|
||||||
|
console.warn(`⚠️ 端口 ${port} 被占用,尝试 ${port + 1}…`);
|
||||||
|
startServer(port + 1);
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
startServer(PORT);
|
||||||
82
spec.md
Normal file
82
spec.md
Normal 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
|
||||||
|
```
|
||||||
Loading…
x
Reference in New Issue
Block a user