init
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
root = true
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
+199
@@ -0,0 +1,199 @@
|
|||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# UV
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
#uv.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
|
#poetry.lock
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
|
#pdm.lock
|
||||||
|
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||||
|
# in version control.
|
||||||
|
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||||
|
.pdm.toml
|
||||||
|
.pdm-python
|
||||||
|
.pdm-build/
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
#.idea/
|
||||||
|
|
||||||
|
# Abstra
|
||||||
|
# Abstra is an AI-powered process automation framework.
|
||||||
|
# Ignore directories containing user credentials, local state, and settings.
|
||||||
|
# Learn more at https://abstra.io/docs
|
||||||
|
.abstra/
|
||||||
|
|
||||||
|
# Visual Studio Code
|
||||||
|
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
||||||
|
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
||||||
|
# you could uncomment the following to ignore the enitre vscode folder
|
||||||
|
# .vscode/
|
||||||
|
|
||||||
|
# Ruff stuff:
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# PyPI configuration file
|
||||||
|
.pypirc
|
||||||
|
|
||||||
|
# Cursor
|
||||||
|
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
||||||
|
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
||||||
|
# refer to https://docs.cursor.com/context/ignore-files
|
||||||
|
.cursorignore
|
||||||
|
.cursorindexingignore
|
||||||
|
|
||||||
|
# Node.js tooling
|
||||||
|
node_modules/
|
||||||
|
.vite/
|
||||||
|
coverage/
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "singleQuote": true, "trailingComma": "all", "printWidth": 100 }
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 1.0.0 - 2026-07-19
|
||||||
|
|
||||||
|
- Refactored the single-file application into native ES Modules.
|
||||||
|
- Preserved existing storage keys, seed tasks, and version 1.0 backup format.
|
||||||
|
- Replaced inline handlers with static listeners and delegated actions.
|
||||||
|
- Added resilient normalization, scoped rendering, i18n interpolation, and automated tests.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 ghuang.top
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -1,2 +1,214 @@
|
|||||||
# Tasks
|
# 任务管理系统
|
||||||
|
|
||||||
|
一个基于浏览器运行的现代化任务管理系统,支持任务生命周期管理、团队协作、进度跟踪、数据备份和中英文界面。项目已从单体脚本重构为原生 ES Modules,并兼容已有任务数据、偏好设置和 1.0 版 JSON 备份。
|
||||||
|
|
||||||
|
## 核心特性
|
||||||
|
|
||||||
|
### 任务管理
|
||||||
|
|
||||||
|
- **三状态管理**:待办事项、进行中、已完成
|
||||||
|
- **双重描述系统**:任务描述(目标要求)和任务进展(实时状态)
|
||||||
|
- **可视化进度**:使用 0–100% 进度条跟踪任务
|
||||||
|
- **优先级管理**:高、中、低三级优先级及颜色标识
|
||||||
|
- **截止日期**:支持到期提醒和过期任务标识
|
||||||
|
- **任务置顶**:重要任务在所有排序方式中优先显示
|
||||||
|
|
||||||
|
### 协作与记录
|
||||||
|
|
||||||
|
- **人员管理**:支持负责人和协作者
|
||||||
|
- **时间轴记录**:
|
||||||
|
- 自动记录状态、进度、负责人、截止日期、优先级和进展内容变更
|
||||||
|
- 支持手动添加、编辑和删除记录
|
||||||
|
- 使用不同类型区分系统记录和手动记录;编辑系统记录会将其转为手动记录,并记在当前负责人名下
|
||||||
|
- 编辑已有的手动记录只更新内容,原记录人保持不变
|
||||||
|
- 记录按时间倒序展示,同一天内新增的记录也排在前面
|
||||||
|
|
||||||
|
### 搜索、排序与显示
|
||||||
|
|
||||||
|
- **13 种排序模式**:支持标题、数字、日期、优先级、进度和创建时间排序
|
||||||
|
- **实时搜索**:搜索标题、描述、进展、负责人、协作者、到期日期、优先级和时间轴内容,界面固定文字不参与匹配;无匹配结果时栏内给出提示
|
||||||
|
- **任务隐藏**:待办和已完成任务可单独隐藏,并可批量显示或隐藏
|
||||||
|
- **国际化**:支持中英文界面切换
|
||||||
|
|
||||||
|
### 数据与兼容性
|
||||||
|
|
||||||
|
- **本地存储**:基于 `localStorage`,无需后端服务
|
||||||
|
- **实时保存**:每次操作后自动持久化
|
||||||
|
- **导入导出**:支持 JSON 覆盖导入和合并导入
|
||||||
|
- **向后兼容**:保留原有存储键和 1.0 版备份格式
|
||||||
|
- **容错处理**:损坏的任务原始数据会备份;存储不可用时当前页面会继续以内存模式运行
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 安装与运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
打开 `serve` 输出的地址即可使用。由于应用采用原生 ES Modules,必须通过 HTTP 服务器访问,不支持使用 `file://` 直接打开 `src/index.html`。
|
||||||
|
|
||||||
|
也可以使用其他静态服务器:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m http.server 8000 --directory src
|
||||||
|
# 或
|
||||||
|
npx http-server src
|
||||||
|
```
|
||||||
|
|
||||||
|
### 质量检查
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
npm run format:check
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
如需自动格式化:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run format
|
||||||
|
```
|
||||||
|
|
||||||
|
### 系统要求
|
||||||
|
|
||||||
|
- 支持原生 ES Modules 的现代浏览器
|
||||||
|
- 支持 `localStorage`(通常约 5–10 MB)
|
||||||
|
- 可访问 Bootstrap 和 Font Awesome 的 CDN 资源
|
||||||
|
|
||||||
|
## 使用指南
|
||||||
|
|
||||||
|
### 基本操作
|
||||||
|
|
||||||
|
1. **添加任务**:填写标题、描述、进展、优先级等信息。
|
||||||
|
2. **编辑任务**:点击任务卡片上的编辑按钮。
|
||||||
|
3. **状态管理**:编辑任务并更改状态。
|
||||||
|
4. **进度跟踪**:更新任务进度和进展内容,系统会自动生成时间轴记录。
|
||||||
|
5. **任务置顶**:点击图钉按钮,使重要任务优先显示。
|
||||||
|
|
||||||
|
### 任务隐藏
|
||||||
|
|
||||||
|
- **适用范围**:隐藏仅对待办和已完成任务生效;进行中任务即使被标记隐藏也会正常显示、正常排序。
|
||||||
|
- **单个隐藏**:编辑任务并勾选“隐藏此任务”,或点击卡片上的隐藏按钮。
|
||||||
|
- **批量查看**:待办栏和已完成栏的眼睛按钮可显示或隐藏被隐藏的任务。
|
||||||
|
- **排序规则**:被隐藏的待办和已完成任务在排序结果中自动排到非隐藏任务之后。
|
||||||
|
- **视觉区分**:显示隐藏任务时,卡片以半透明样式呈现。
|
||||||
|
- **数量统计**:栏目标题的计数与当前实际显示的卡片一致,会随搜索结果变化。
|
||||||
|
|
||||||
|
### 数据备份
|
||||||
|
|
||||||
|
- 导出的文件名格式为 `backup-tasks-yyyy-mm-dd.json`。
|
||||||
|
- 覆盖导入会替换任务及兼容的排序偏好。
|
||||||
|
- 合并导入会保留现有任务,并按 `title|status|createdDate` 跳过重复项;新导入任务会分配唯一 ID。
|
||||||
|
|
||||||
|
## 技术架构
|
||||||
|
|
||||||
|
### 技术栈
|
||||||
|
|
||||||
|
- **前端**:HTML5、CSS3、JavaScript(ES Modules)
|
||||||
|
- **UI 框架**:Bootstrap 5.3.3
|
||||||
|
- **图标**:Font Awesome 6.7.2
|
||||||
|
- **存储**:`localStorage`
|
||||||
|
- **测试**:Vitest + jsdom
|
||||||
|
- **代码质量**:ESLint + Prettier
|
||||||
|
- **部署方式**:纯静态部署
|
||||||
|
|
||||||
|
### 项目结构
|
||||||
|
|
||||||
|
- `src/index.html`:应用页面和模态框结构
|
||||||
|
- `src/css/styles.css`:应用样式和响应式布局
|
||||||
|
- `src/js/main.js`:初始化和委托事件处理
|
||||||
|
- `src/js/config.js`:状态、排序选项、存储键和共享常量
|
||||||
|
- `src/js/utils.js`:ID、日期、到期状态和 HTML 转义工具
|
||||||
|
- `src/js/task-model.js`:任务和偏好数据标准化
|
||||||
|
- `src/js/store.js`:应用状态及持久化变更
|
||||||
|
- `src/js/storage.js`:唯一直接访问 `localStorage` 的应用模块
|
||||||
|
- `src/js/sort.js`:任务比较器和 13 种排序模式
|
||||||
|
- `src/js/render.js`:任务、时间轴、计数和排序选项渲染
|
||||||
|
- `src/js/modal.js`:任务表单填充和保存
|
||||||
|
- `src/js/timeline.js`:手动及系统时间轴记录
|
||||||
|
- `src/js/import-export.js`:1.0 版备份验证、导入和导出
|
||||||
|
- `src/js/notify.js`:通知和截止日期提醒
|
||||||
|
- `src/js/seed-data.js`:首次运行时的示例任务
|
||||||
|
- `src/js/i18n/`:中英文词典及插值逻辑
|
||||||
|
- `test/`:单元测试和旧版数据兼容性测试
|
||||||
|
|
||||||
|
## 本地存储
|
||||||
|
|
||||||
|
应用保留以下六个原有存储键:
|
||||||
|
|
||||||
|
- `tasks`
|
||||||
|
- `taskSortOrders`
|
||||||
|
- `showHiddenCompletedTasks`
|
||||||
|
- `showHiddenTodoTasks`
|
||||||
|
- `tasksInitialized`
|
||||||
|
- `tasksCorruptedBackup`
|
||||||
|
|
||||||
|
任务、排序和显示偏好使用 JSON 编码。若任务列表格式损坏,原始文本会保存到 `tasksCorruptedBackup`。当浏览器存储不可用或空间不足时,应用会在当前页面会话中继续运行,但刷新后内存中的改动会丢失。
|
||||||
|
|
||||||
|
## 备份格式
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "1.0",
|
||||||
|
"exportDate": "2026-07-20T00:00:00.000Z",
|
||||||
|
"localDate": "2026-07-20",
|
||||||
|
"taskCount": 1,
|
||||||
|
"data": {
|
||||||
|
"tasks": [],
|
||||||
|
"sortOrders": {
|
||||||
|
"todo": "default",
|
||||||
|
"inProgress": "default",
|
||||||
|
"completed": "default"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 任务数据结构
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
id: Number, // 唯一标识
|
||||||
|
title: String, // 任务标题
|
||||||
|
description: String, // 任务描述
|
||||||
|
progressNotes: String, // 任务进展
|
||||||
|
status: String, // todo | inProgress | completed
|
||||||
|
progress: Number, // 0–100
|
||||||
|
assignee: String, // 负责人
|
||||||
|
collaborators: Array, // 协作者
|
||||||
|
dueDate: String, // 截止日期
|
||||||
|
priority: String, // low | medium | high
|
||||||
|
language: String, // zh | en
|
||||||
|
isPinned: Boolean, // 是否置顶
|
||||||
|
isHidden: Boolean, // 是否隐藏
|
||||||
|
createdDate: String, // 创建时间
|
||||||
|
timeline: Array // 时间轴记录
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 故障排除
|
||||||
|
|
||||||
|
- **页面无法直接打开**:请使用 HTTP 服务器运行,原生 ES Modules 不支持 `file://` 加载。
|
||||||
|
- **数据丢失**:检查浏览器是否允许 `localStorage`,并定期导出备份。
|
||||||
|
- **导入失败**:确认文件为 1.0 版 JSON 备份,并检查任务字段和日期格式。
|
||||||
|
- **界面异常**:检查 Bootstrap 和 Font Awesome CDN 是否可访问,并尝试清空缓存。
|
||||||
|
- **性能问题**:任务数量较多时,建议归档已完成任务并清理不必要的时间轴记录。
|
||||||
|
|
||||||
|
## 贡献指南
|
||||||
|
|
||||||
|
欢迎提交 Issue 和 Pull Request。
|
||||||
|
|
||||||
|
- **问题反馈**:请详细描述问题场景和复现步骤。
|
||||||
|
- **功能建议**:请说明需求和使用场景。
|
||||||
|
- **代码贡献**:遵循现有代码风格,并运行 lint、格式检查和测试。
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- **版本**:v1.0.0
|
||||||
|
- **重构日期**:2026 年 7 月
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
REPO_URL="https://github.com/ghuang-top/Tasks"
|
||||||
|
REPO_ARCHIVE_URL="${REPO_URL}/archive/refs/heads/main.tar.gz"
|
||||||
|
TARGET_DIR="/root/data/docker_data/Nginx/html/tasks"
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[0;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
print_info() {
|
||||||
|
echo -e "${BLUE}[INFO]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_success() {
|
||||||
|
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_warning() {
|
||||||
|
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
check_root() {
|
||||||
|
if [[ ${EUID} -ne 0 ]]; then
|
||||||
|
print_error "此脚本需要 root 权限运行"
|
||||||
|
print_info "请切换到 root 用户,或使用 sudo bash 执行脚本"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_dependencies() {
|
||||||
|
local command_name
|
||||||
|
for command_name in curl tar find; do
|
||||||
|
if ! command -v "$command_name" >/dev/null 2>&1; then
|
||||||
|
print_error "缺少必要命令: $command_name"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare_target() {
|
||||||
|
if [[ -e "$TARGET_DIR" && ! -d "$TARGET_DIR" ]]; then
|
||||||
|
print_error "目标路径存在,但不是文件夹: $TARGET_DIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$TARGET_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
clear_target() {
|
||||||
|
prepare_target
|
||||||
|
print_info "正在清空目标目录 $TARGET_DIR ..."
|
||||||
|
|
||||||
|
find "$TARGET_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
|
||||||
|
|
||||||
|
print_success "目标目录已清空"
|
||||||
|
}
|
||||||
|
|
||||||
|
deploy() (
|
||||||
|
local temp_dir archive_file source_dir
|
||||||
|
temp_dir=$(mktemp -d)
|
||||||
|
archive_file="$temp_dir/repository.tar.gz"
|
||||||
|
trap 'rm -rf -- "$temp_dir"' EXIT
|
||||||
|
|
||||||
|
print_info "正在从 GitHub 下载最新版本..."
|
||||||
|
print_info "仓库地址: $REPO_URL"
|
||||||
|
if ! curl -fL --retry 3 --connect-timeout 15 "$REPO_ARCHIVE_URL" -o "$archive_file"; then
|
||||||
|
print_error "仓库下载失败,请检查网络连接及 main 分支是否存在"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_info "正在解压并验证部署文件..."
|
||||||
|
if ! tar -xzf "$archive_file" -C "$temp_dir"; then
|
||||||
|
print_error "仓库压缩包解压失败"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
source_dir=$(find "$temp_dir" -mindepth 2 -maxdepth 2 -type d -name src -print -quit)
|
||||||
|
if [[ -z "$source_dir" || ! -f "$source_dir/index.html" || ! -d "$source_dir/js" || ! -d "$source_dir/css" ]]; then
|
||||||
|
print_error "仓库中缺少 src/index.html、src/js 或 src/css,已取消部署"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
clear_target
|
||||||
|
|
||||||
|
print_info "正在拷贝网站文件到 $TARGET_DIR ..."
|
||||||
|
cp -a "$source_dir/." "$TARGET_DIR/"
|
||||||
|
|
||||||
|
print_success "Tasks 部署完成"
|
||||||
|
print_info "部署目录: $TARGET_DIR"
|
||||||
|
)
|
||||||
|
|
||||||
|
confirm_clear() {
|
||||||
|
local answer
|
||||||
|
print_warning "将清空 $TARGET_DIR 中的所有内容,此操作不可恢复"
|
||||||
|
read -r -p "确认继续?[y/N]: " answer
|
||||||
|
if [[ "$answer" =~ ^[Yy]$ ]]; then
|
||||||
|
clear_target
|
||||||
|
else
|
||||||
|
print_info "已取消清空"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
show_menu() {
|
||||||
|
clear 2>/dev/null || true
|
||||||
|
echo -e "${CYAN}============================================${NC}"
|
||||||
|
echo " Tasks 一键安装脚本"
|
||||||
|
echo -e "${CYAN}============================================${NC}"
|
||||||
|
echo -e "${GREEN}1.${NC} 下载并安装最新版本"
|
||||||
|
echo -e "${GREEN}2.${NC} 清空网站文件"
|
||||||
|
echo -e "${GREEN}0.${NC} 退出"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
run_menu() {
|
||||||
|
local choice
|
||||||
|
while true; do
|
||||||
|
show_menu
|
||||||
|
read -r -p "请选择操作 [0-2]: " choice
|
||||||
|
case "$choice" in
|
||||||
|
1)
|
||||||
|
# 部署失败时回到菜单,而不是因 set -e 直接退出脚本
|
||||||
|
deploy || print_warning "部署失败,可稍后重试"
|
||||||
|
read -r -p "按 Enter 键继续..."
|
||||||
|
;;
|
||||||
|
2)
|
||||||
|
confirm_clear
|
||||||
|
read -r -p "按 Enter 键继续..."
|
||||||
|
;;
|
||||||
|
0)
|
||||||
|
print_info "退出脚本"
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
print_error "无效选择,请重新输入"
|
||||||
|
sleep 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
check_root
|
||||||
|
check_dependencies
|
||||||
|
|
||||||
|
case "${1:-menu}" in
|
||||||
|
deploy)
|
||||||
|
deploy
|
||||||
|
;;
|
||||||
|
clear)
|
||||||
|
confirm_clear
|
||||||
|
;;
|
||||||
|
menu)
|
||||||
|
run_menu
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
print_error "未知参数: $1"
|
||||||
|
echo "用法: $0 [deploy|clear|menu]" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import globals from 'globals';
|
||||||
|
export default [
|
||||||
|
{ ignores: ['node_modules/**'] },
|
||||||
|
js.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ['src/**/*.js'],
|
||||||
|
languageOptions: { globals: { ...globals.browser, bootstrap: 'readonly' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['test/**/*.js'],
|
||||||
|
languageOptions: { globals: { ...globals.node, ...globals.browser } },
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
## 中文
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
## 新完成
|
||||||
|
|
||||||
|
## 进行中
|
||||||
|
- 1. 所有设备相机参数标准化
|
||||||
|
进展:仅剩DieBond贴片相机待确认。
|
||||||
|
|
||||||
|
- 2. DieBond设备照明优化
|
||||||
|
- I. 定制组合光源:同轴光+环形光
|
||||||
|
- II. 采购更窄、更亮的侧面光源,以解决支架白边问题
|
||||||
|
进展:配置已与供应商确认,预计2025年7月4日发货。
|
||||||
|
|
||||||
|
- 3. DieBond和ClipBond软件界面中英文翻译更新(SG-TATA)
|
||||||
|
进展:SG-TATA GUI 的中英文翻译已完成。未来更新杨杰的代码后,需要重新进行翻译(任务量很少)。
|
||||||
|
|
||||||
|
## 下一步计划
|
||||||
|
- 1. 所有设备相机手册(校准、位置引导、检测)
|
||||||
|
- 2. 开发 TaTa SECS/GEM & Map 软件。
|
||||||
|
- 3. 跟进 TaTa 软件授权:
|
||||||
|
- I. Halcon
|
||||||
|
- II. CIMConnect RT
|
||||||
|
- III. CIMConnect CIM142
|
||||||
|
- IV. Visual Studio 2019
|
||||||
|
- V. Microsoft .NET Runtime(免费)
|
||||||
|
- 4. 完成 TaTa SECS/GEM 通讯手册,用于支持客户集成机器的 SECS/GEM 功能。
|
||||||
|
|
||||||
|
- 5. 扫码枪是否需要添加: 用于获取 Wafer 和 Strip 的序列号(涉及 SECS/GEM 相关内容)。
|
||||||
|
- 6. 日志文件优化:: 为 LogFile 添加翻译(当前日志文件为中文),格式如下:日期:中文+英文
|
||||||
|
- 7. 光源优化(ClipBond 点胶相机): 添加侧灯,以进一步优化芯片上胶点的检测图像效果。
|
||||||
|
- 8. 光源优化(ClipBond 向下看相机): 添加侧灯。
|
||||||
|
- 9. 光源优化(DieBond Pick Camera): 添加侧灯。当前使用的光源为手工焊接的环光。需要找灵猴定制标准化侧灯(非紧急事项)。
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## 英文
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
## Newly Completed
|
||||||
|
|
||||||
|
## Work In Progress
|
||||||
|
- 1. All Machines Camera parameter standardization
|
||||||
|
Progress: Only DieBond Place camera remains to be confirmed.
|
||||||
|
|
||||||
|
- 2. DieBond machine lighting optimization (DieBond Place Camera)
|
||||||
|
- I. Custom combination light source: coaxial light + ring light
|
||||||
|
- II. Purchase narrower and brighter side lighting to solve the white edge problem of the frame
|
||||||
|
Progress: Configuration has been confirmed with the supplier, expected delivery on July 7, 2025.
|
||||||
|
|
||||||
|
- 3. DieBond and ClipBond Software GUI Chinese-English translation update (SG-TATA)
|
||||||
|
Progress: The Chinese and English translations for the SG-TATA GUI have been completed. After updating to Yang Jie's code in the future, the translations will need to be redone (little workload).
|
||||||
|
|
||||||
|
## Next To Do
|
||||||
|
- 1. All Machines Camera Manual (Calibration, Position Guidance, Inspection)
|
||||||
|
- 2. Develop TaTa SECS/GEM & Map software.
|
||||||
|
- 3. Follow up on TaTa software licensing:
|
||||||
|
- I. Halcon
|
||||||
|
- II. CIMConnect RT
|
||||||
|
- III. CIMConnect CIM142
|
||||||
|
- IV. Visual Studio 2019
|
||||||
|
- V. Microsoft .NET Runtime (free)
|
||||||
|
- 4. Complete TaTa SECS/GEM communication manual to support customers in integrating machine SECS/GEM functionality.
|
||||||
|
|
||||||
|
- 5. Whether to add barcode scanner: for obtaining Wafer and Strip serial numbers (related to SECS/GEM content).
|
||||||
|
- 6. Log file optimization: Add translation for LogFile (current log files are in Chinese), format as follows: Date: Chinese + English
|
||||||
|
- 7. Lighting optimization (ClipBond dispensing camera): Add side lighting to further optimize chip adhesive point detection image quality.
|
||||||
|
- 8. Lighting optimization (ClipBond downward-looking camera): Add side lighting.
|
||||||
|
- 9. Lighting optimization (DieBond Pick Camera): Add side lighting. Currently using manually welded ring light. Need to find Linghou to customize standardized side lighting (non-urgent task).
|
||||||
|
|
||||||
|
```
|
||||||
Generated
+3655
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "task-manager",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "serve src",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check .",
|
||||||
|
"test": "vitest run --environment jsdom"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "latest",
|
||||||
|
"eslint": "latest",
|
||||||
|
"globals": "latest",
|
||||||
|
"jsdom": "latest",
|
||||||
|
"prettier": "latest",
|
||||||
|
"serve": "latest",
|
||||||
|
"vitest": "latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
/* Base */
|
||||||
|
:root {
|
||||||
|
--primary: #0d6efd;
|
||||||
|
--todo: #6c757d;
|
||||||
|
--progress: #ffc107;
|
||||||
|
--complete: #198754;
|
||||||
|
--priority-low: #198754;
|
||||||
|
--priority-medium: #fd7e14;
|
||||||
|
--priority-high: #dc3545;
|
||||||
|
--danger: #dc3545;
|
||||||
|
--warning: #fd7e14;
|
||||||
|
--surface: #fff;
|
||||||
|
--surface-raised: #fffffff0;
|
||||||
|
--background: #f4f6fa;
|
||||||
|
--overdue-background: #fff5f5;
|
||||||
|
--due-soon-background: #fff8f0;
|
||||||
|
--muted: #6c757d;
|
||||||
|
--timeline: #dee2e6;
|
||||||
|
--header-start: #667eea;
|
||||||
|
--header-end: #764ba2;
|
||||||
|
--shadow-subtle: #00000012;
|
||||||
|
--shadow-hover: #0002;
|
||||||
|
--shadow-pinned: #ffc10744;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background: var(--background);
|
||||||
|
font-family: 'Microsoft YaHei', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
.task-header {
|
||||||
|
background: linear-gradient(135deg, var(--header-start), var(--header-end));
|
||||||
|
color: var(--surface);
|
||||||
|
padding: 1.25rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.task-header h1 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.status-column {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
box-shadow: 0 2px 12px var(--shadow-subtle);
|
||||||
|
min-height: 12rem;
|
||||||
|
}
|
||||||
|
.status-column-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.status-column-header h4 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.sort-selector {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border: 1px solid var(--timeline);
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
max-width: 45%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Task cards */
|
||||||
|
.task-card {
|
||||||
|
position: relative;
|
||||||
|
border-left: 4px solid var(--primary);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
transition: 0.2s;
|
||||||
|
}
|
||||||
|
.task-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 14px var(--shadow-hover);
|
||||||
|
}
|
||||||
|
.task-card.todo {
|
||||||
|
border-left-color: var(--todo);
|
||||||
|
}
|
||||||
|
.task-card.inProgress {
|
||||||
|
border-left-color: var(--progress);
|
||||||
|
}
|
||||||
|
.task-card.completed {
|
||||||
|
border-left-color: var(--complete);
|
||||||
|
}
|
||||||
|
.task-card.overdue {
|
||||||
|
border-left-color: var(--danger) !important;
|
||||||
|
background: var(--overdue-background);
|
||||||
|
}
|
||||||
|
.task-card.due-soon {
|
||||||
|
border-left-color: var(--warning) !important;
|
||||||
|
background: var(--due-soon-background);
|
||||||
|
}
|
||||||
|
.task-card.hidden-task {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.task-card.pinned {
|
||||||
|
border-top: 3px solid var(--progress);
|
||||||
|
box-shadow: 0 4px 12px var(--shadow-pinned);
|
||||||
|
}
|
||||||
|
.task-actions {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.35rem;
|
||||||
|
top: 0.35rem;
|
||||||
|
z-index: 2;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.task-card:hover .task-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.task-actions .btn {
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
}
|
||||||
|
.priority-badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
color: var(--surface);
|
||||||
|
}
|
||||||
|
.priority-low-badge {
|
||||||
|
background: var(--priority-low);
|
||||||
|
}
|
||||||
|
.priority-medium-badge {
|
||||||
|
background: var(--priority-medium);
|
||||||
|
}
|
||||||
|
.priority-high-badge {
|
||||||
|
background: var(--priority-high);
|
||||||
|
}
|
||||||
|
.progress-custom {
|
||||||
|
height: 0.45rem;
|
||||||
|
}
|
||||||
|
.person-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin: 0.1rem;
|
||||||
|
}
|
||||||
|
.task-description-display {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Timeline */
|
||||||
|
.timeline-toggle {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.timeline {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.timeline:before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0.5rem;
|
||||||
|
inset-block: 0;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--timeline);
|
||||||
|
}
|
||||||
|
.timeline-item {
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.timeline-item:before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -0.72rem;
|
||||||
|
top: 0.35rem;
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
.timeline-content,
|
||||||
|
.timeline-form,
|
||||||
|
.timeline-edit-form {
|
||||||
|
background: var(--background);
|
||||||
|
padding: 0.65rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.timeline-date {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.timeline-item-actions {
|
||||||
|
float: right;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.timeline-item:hover .timeline-item-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Overlays and controls */
|
||||||
|
.notification {
|
||||||
|
position: fixed;
|
||||||
|
right: 1rem;
|
||||||
|
top: 1rem;
|
||||||
|
z-index: 2000;
|
||||||
|
max-width: min(28rem, 90vw);
|
||||||
|
}
|
||||||
|
.visibility-toggle {
|
||||||
|
padding: 0.1rem;
|
||||||
|
}
|
||||||
|
.empty-state {
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.task-actions,
|
||||||
|
.timeline-item-actions {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.toolbar .btn {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.status-column {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.task-header h1 {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
.status-column-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.sort-selector {
|
||||||
|
max-width: 50%;
|
||||||
|
}
|
||||||
|
.task-actions .btn {
|
||||||
|
min-width: 2.25rem;
|
||||||
|
min-height: 2.25rem;
|
||||||
|
}
|
||||||
|
.toolbar .btn span:not(#langBtn) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
+261
@@ -0,0 +1,261 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>任务管理系统</title>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/svg+xml"
|
||||||
|
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%230d6efd'%3E%3Cpath d='M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zM7 17l-2-2 1.4-1.4.6.6 3.6-3.6L12 12l-5 5zm12-8h-8V7h8v2z'/%3E%3C/svg%3E"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<link href="css/styles.css" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="container-fluid py-3">
|
||||||
|
<header class="task-header text-center">
|
||||||
|
<h1><i class="fas fa-tasks"></i> <span data-key="appTitle">任务管理系统</span></h1>
|
||||||
|
</header>
|
||||||
|
<section class="toolbar row g-2 mb-4">
|
||||||
|
<div class="col-lg-7 d-flex flex-wrap gap-2">
|
||||||
|
<button id="addTaskBtn" class="btn btn-primary">
|
||||||
|
<i class="fas fa-plus"></i> <span data-key="addTask"></span></button
|
||||||
|
><button id="exportBtn" class="btn btn-success">
|
||||||
|
<i class="fas fa-download"></i> <span data-key="exportData"></span></button
|
||||||
|
><button id="importBtn" class="btn btn-warning">
|
||||||
|
<i class="fas fa-upload"></i> <span data-key="importData"></span></button
|
||||||
|
><button id="languageBtn" class="btn btn-secondary">
|
||||||
|
<i class="fas fa-language"></i> <span id="langBtn">EN</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="input-group">
|
||||||
|
<input id="searchInput" class="form-control" data-key-placeholder="searchPlaceholder" />
|
||||||
|
<button
|
||||||
|
id="searchBtn"
|
||||||
|
class="btn btn-outline-secondary"
|
||||||
|
type="button"
|
||||||
|
aria-label="Search"
|
||||||
|
>
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<input type="file" id="fileInput" hidden accept=".json" />
|
||||||
|
<section class="row g-3" id="taskBoard">
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="status-column">
|
||||||
|
<div class="status-column-header">
|
||||||
|
<h4>
|
||||||
|
<i class="fas fa-list"></i> <span data-key="todo"></span> (<span id="todoCount"
|
||||||
|
>0</span
|
||||||
|
>)
|
||||||
|
<button
|
||||||
|
class="visibility-toggle btn btn-sm btn-link"
|
||||||
|
data-action="toggle-show-hidden"
|
||||||
|
data-status="todo"
|
||||||
|
>
|
||||||
|
<i class="fas fa-eye"></i>
|
||||||
|
</button>
|
||||||
|
</h4>
|
||||||
|
<select class="sort-selector" id="todoSort" data-status="todo"></select>
|
||||||
|
</div>
|
||||||
|
<div id="todoTasks" class="task-list" data-status="todo"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="status-column">
|
||||||
|
<div class="status-column-header">
|
||||||
|
<h4 class="text-warning">
|
||||||
|
<i class="fas fa-clock"></i> <span data-key="inProgress"></span> (<span
|
||||||
|
id="inProgressCount"
|
||||||
|
>0</span
|
||||||
|
>)
|
||||||
|
</h4>
|
||||||
|
<select class="sort-selector" id="inProgressSort" data-status="inProgress"></select>
|
||||||
|
</div>
|
||||||
|
<div id="inProgressTasks" class="task-list" data-status="inProgress"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="status-column">
|
||||||
|
<div class="status-column-header">
|
||||||
|
<h4 class="text-success">
|
||||||
|
<i class="fas fa-check-circle"></i> <span data-key="completed"></span> (<span
|
||||||
|
id="completedCount"
|
||||||
|
>0</span
|
||||||
|
>)
|
||||||
|
<button
|
||||||
|
class="visibility-toggle btn btn-sm btn-link"
|
||||||
|
data-action="toggle-show-hidden"
|
||||||
|
data-status="completed"
|
||||||
|
>
|
||||||
|
<i class="fas fa-eye"></i>
|
||||||
|
</button>
|
||||||
|
</h4>
|
||||||
|
<select class="sort-selector" id="completedSort" data-status="completed"></select>
|
||||||
|
</div>
|
||||||
|
<div id="completedTasks" class="task-list" data-status="completed"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<div class="modal fade" id="taskModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 id="modalTitle" class="modal-title" data-key="addTask"></h5>
|
||||||
|
<button class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="taskForm">
|
||||||
|
<input type="hidden" id="taskId" />
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<label data-key="taskTitleLabel"></label
|
||||||
|
><input id="taskTitle" class="form-control" required />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label data-key="statusLabel"></label
|
||||||
|
><select id="taskStatus" class="form-select">
|
||||||
|
<option value="todo" data-key="todo"></option>
|
||||||
|
<option value="inProgress" data-key="inProgress"></option>
|
||||||
|
<option value="completed" data-key="completed"></option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label data-key="progressNotesLabel"></label
|
||||||
|
><textarea
|
||||||
|
id="taskProgressNotes"
|
||||||
|
class="form-control"
|
||||||
|
rows="3"
|
||||||
|
data-key-placeholder="progressNotesPlaceholder"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label data-key="descriptionLabel"></label
|
||||||
|
><textarea
|
||||||
|
id="taskDescription"
|
||||||
|
class="form-control"
|
||||||
|
rows="4"
|
||||||
|
data-key-placeholder="descriptionPlaceholder"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label data-key="priorityLabel"></label
|
||||||
|
><select id="taskPriority" class="form-select">
|
||||||
|
<option value="low" data-key="lowPriority"></option>
|
||||||
|
<option value="medium" data-key="mediumPriority"></option>
|
||||||
|
<option value="high" data-key="highPriority"></option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label data-key="languageLabel"></label
|
||||||
|
><select id="taskLanguage" class="form-select">
|
||||||
|
<option value="zh">中文</option>
|
||||||
|
<option value="en">English</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label data-key="assigneeLabel"></label
|
||||||
|
><input
|
||||||
|
id="taskAssignee"
|
||||||
|
class="form-control"
|
||||||
|
data-key-placeholder="assigneePlaceholder"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label data-key="dueDateLabel"></label
|
||||||
|
><input id="taskDueDate" type="date" class="form-control" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label data-key="collaboratorsLabel"></label
|
||||||
|
><input
|
||||||
|
id="taskCollaborators"
|
||||||
|
class="form-control"
|
||||||
|
data-key-placeholder="collaboratorsPlaceholder"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label data-key="progressLabel"></label
|
||||||
|
><input
|
||||||
|
id="taskProgress"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value="0"
|
||||||
|
class="form-range"
|
||||||
|
/>
|
||||||
|
<div id="progressDisplay" class="text-center">0%</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 form-check ms-2">
|
||||||
|
<input id="taskHidden" type="checkbox" class="form-check-input" /><label
|
||||||
|
for="taskHidden"
|
||||||
|
data-key="hiddenTaskLabel"
|
||||||
|
></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" data-bs-dismiss="modal" data-key="cancel"></button
|
||||||
|
><button id="saveTaskBtn" class="btn btn-primary" data-key="saveTask"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal fade" id="importModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 data-key="confirmImport"></h5>
|
||||||
|
<button class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="importInfo"></div>
|
||||||
|
<label data-key="importMode"></label>
|
||||||
|
<div class="form-check">
|
||||||
|
<input
|
||||||
|
id="importModeReplace"
|
||||||
|
name="importMode"
|
||||||
|
type="radio"
|
||||||
|
value="replace"
|
||||||
|
checked
|
||||||
|
class="form-check-input"
|
||||||
|
/><label for="importModeReplace" data-key="replaceMode"></label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input
|
||||||
|
id="importModeMerge"
|
||||||
|
name="importMode"
|
||||||
|
type="radio"
|
||||||
|
value="merge"
|
||||||
|
class="form-check-input"
|
||||||
|
/><label for="importModeMerge" data-key="mergeMode"></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" data-bs-dismiss="modal" data-key="cancel"></button
|
||||||
|
><button
|
||||||
|
id="confirmImportBtn"
|
||||||
|
class="btn btn-warning"
|
||||||
|
data-key="confirmImportBtn"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="notifications" class="notification"></div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script type="module" src="js/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export const STATUSES = ['todo', 'inProgress', 'completed'];
|
||||||
|
export const HIDEABLE_STATUSES = ['todo', 'completed'];
|
||||||
|
export const PRIORITIES = ['low', 'medium', 'high'];
|
||||||
|
export const PRIORITY_ORDER = { low: 1, medium: 2, high: 3 };
|
||||||
|
export const LANGUAGES = ['zh', 'en'];
|
||||||
|
export const SORT_OPTIONS = [
|
||||||
|
['default', 'defaultSort'],
|
||||||
|
['title-asc', 'titleAsc'],
|
||||||
|
['title-desc', 'titleDesc'],
|
||||||
|
['number-asc', 'numberAsc'],
|
||||||
|
['number-desc', 'numberDesc'],
|
||||||
|
['dueDate-asc', 'dueDateAsc'],
|
||||||
|
['dueDate-desc', 'dueDateDesc'],
|
||||||
|
['priority-asc', 'priorityAsc'],
|
||||||
|
['priority-desc', 'priorityDesc'],
|
||||||
|
['progress-asc', 'progressAsc'],
|
||||||
|
['progress-desc', 'progressDesc'],
|
||||||
|
['created-desc', 'createdDesc'],
|
||||||
|
['created-asc', 'createdAsc'],
|
||||||
|
];
|
||||||
|
export const SORT_VALUES = new Set(SORT_OPTIONS.map(([value]) => value));
|
||||||
|
export const DEFAULT_SORT_ORDERS = { todo: 'default', inProgress: 'default', completed: 'default' };
|
||||||
|
export const STORAGE_KEYS = {
|
||||||
|
tasks: 'tasks',
|
||||||
|
sortOrders: 'taskSortOrders',
|
||||||
|
initialized: 'tasksInitialized',
|
||||||
|
corruptedBackup: 'tasksCorruptedBackup',
|
||||||
|
showHidden: { completed: 'showHiddenCompletedTasks', todo: 'showHiddenTodoTasks' },
|
||||||
|
};
|
||||||
|
export const EXPORT_VERSION = '1.0';
|
||||||
|
export const DUE_SOON_DAYS = 3;
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
export default {
|
||||||
|
appTitle: 'Task Management System',
|
||||||
|
todo: 'To Do',
|
||||||
|
inProgress: 'In Progress',
|
||||||
|
completed: 'Completed',
|
||||||
|
addTask: 'Add Task',
|
||||||
|
editTask: 'Edit Task',
|
||||||
|
exportData: 'Export Data',
|
||||||
|
importData: 'Import Data',
|
||||||
|
searchPlaceholder: 'Search tasks...',
|
||||||
|
taskTitleLabel: 'Task Title *',
|
||||||
|
statusLabel: 'Status',
|
||||||
|
progressNotesLabel: 'Progress Notes',
|
||||||
|
descriptionLabel: 'Task Description',
|
||||||
|
priorityLabel: 'Priority',
|
||||||
|
languageLabel: 'Language',
|
||||||
|
assigneeLabel: 'Assignee',
|
||||||
|
dueDateLabel: 'Due Date',
|
||||||
|
collaboratorsLabel: 'Collaborators',
|
||||||
|
progressLabel: 'Progress (%)',
|
||||||
|
hiddenTaskLabel: 'Hide this task (applies to completed and to-do tasks)',
|
||||||
|
saveTask: 'Save Task',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
progressNotesPlaceholder: 'Record the latest progress and status...',
|
||||||
|
descriptionPlaceholder: 'Describe the task goals and requirements...',
|
||||||
|
assigneePlaceholder: 'Enter assignee name',
|
||||||
|
collaboratorsPlaceholder: 'Separate multiple people with commas',
|
||||||
|
defaultSort: 'Default Sort',
|
||||||
|
titleAsc: 'Title A-Z',
|
||||||
|
titleDesc: 'Title Z-A',
|
||||||
|
numberAsc: 'Number ↑',
|
||||||
|
numberDesc: 'Number ↓',
|
||||||
|
dueDateAsc: 'Due Date ↑',
|
||||||
|
dueDateDesc: 'Due Date ↓',
|
||||||
|
priorityAsc: 'Priority ↑ (Low→High)',
|
||||||
|
priorityDesc: 'Priority ↓ (High→Low)',
|
||||||
|
progressAsc: 'Progress ↑ (Low→High)',
|
||||||
|
progressDesc: 'Progress ↓ (High→Low)',
|
||||||
|
createdDesc: 'Newest First',
|
||||||
|
createdAsc: 'Oldest First',
|
||||||
|
lowPriority: 'Low Priority',
|
||||||
|
mediumPriority: 'Medium Priority',
|
||||||
|
highPriority: 'High Priority',
|
||||||
|
low: 'Low',
|
||||||
|
medium: 'Medium',
|
||||||
|
high: 'High',
|
||||||
|
assignee: 'Assignee: ',
|
||||||
|
collaborators: 'Collaborators: ',
|
||||||
|
dueDate: 'Due: ',
|
||||||
|
progress: 'Progress: ',
|
||||||
|
overdue: 'Overdue',
|
||||||
|
dueSoon: 'Due Soon',
|
||||||
|
noTasks: 'No tasks',
|
||||||
|
noSearchResults: 'No matching tasks',
|
||||||
|
timeline: 'Timeline',
|
||||||
|
noTimelineEntries: 'No timeline entries',
|
||||||
|
addTimelinePlaceholder: 'Add timeline entry...',
|
||||||
|
add: 'Add',
|
||||||
|
addEntry: 'Add Entry',
|
||||||
|
edit: 'Edit',
|
||||||
|
delete: 'Delete',
|
||||||
|
save: 'Save',
|
||||||
|
editContentPlaceholder: 'Edit content...',
|
||||||
|
keyboardHint: 'Ctrl+Enter to save | Esc to cancel',
|
||||||
|
systemUser: 'System',
|
||||||
|
defaultUser: 'User',
|
||||||
|
taskAdded: 'Task added',
|
||||||
|
taskUpdated: 'Task updated',
|
||||||
|
taskDeleted: 'Task deleted',
|
||||||
|
taskPinned: 'Task pinned',
|
||||||
|
taskUnpinned: 'Task unpinned',
|
||||||
|
taskHidden: 'Task hidden',
|
||||||
|
taskShown: 'Task shown',
|
||||||
|
pinTask: 'Pin task',
|
||||||
|
unpinTask: 'Unpin task',
|
||||||
|
showTask: 'Show task',
|
||||||
|
hideTask: 'Hide task',
|
||||||
|
taskTitleRequired: 'Task title cannot be empty',
|
||||||
|
taskNotFound: 'The task no longer exists',
|
||||||
|
confirmDelete: 'Are you sure you want to delete this task?',
|
||||||
|
confirmDeleteTimeline: 'Delete this timeline entry?',
|
||||||
|
contentRequired: 'Content cannot be empty',
|
||||||
|
timelineEntryAdded: 'Timeline entry added',
|
||||||
|
timelineEntryUpdated: 'Timeline entry updated',
|
||||||
|
timelineEntryDeleted: 'Timeline entry deleted',
|
||||||
|
statusChanged: 'Status Changed',
|
||||||
|
progressUpdated: 'Progress Updated',
|
||||||
|
assigneeChanged: 'Assignee Changed',
|
||||||
|
dueDateChanged: 'Due Date Changed',
|
||||||
|
priorityChanged: 'Priority Changed',
|
||||||
|
progressNotesUpdated: 'Progress Notes Updated',
|
||||||
|
progressCleared: 'Progress notes cleared',
|
||||||
|
storageTasksReset: 'Local task data is corrupted; the original data was backed up',
|
||||||
|
storageWriteFailed:
|
||||||
|
'Could not save locally; this change is only in memory and will be lost on refresh',
|
||||||
|
overdueTasks: '{count} task(s) overdue!',
|
||||||
|
dueSoonTasks: '{count} task(s) due soon!',
|
||||||
|
allCompletedHidden: 'All completed tasks are hidden ({count})',
|
||||||
|
allTodoHidden: 'All to-do tasks are hidden ({count})',
|
||||||
|
showHiddenTasks: 'Show hidden tasks ({count})',
|
||||||
|
hideHiddenTasks: 'Hide hidden tasks',
|
||||||
|
showHiddenTitle: 'Show hidden tasks',
|
||||||
|
hideHiddenTitle: 'Hide hidden tasks',
|
||||||
|
exportSuccess: 'Data exported successfully',
|
||||||
|
exportError: 'Export failed',
|
||||||
|
importSuccess: 'Data imported successfully',
|
||||||
|
importError: 'Import failed',
|
||||||
|
invalidFileFormat: 'Invalid file format',
|
||||||
|
fileReadError: 'File read error',
|
||||||
|
confirmImport: 'Confirm Import Data',
|
||||||
|
importMode: 'Import Mode',
|
||||||
|
replaceMode: 'Replace Existing Data',
|
||||||
|
replaceModeDesc: 'Will delete all current tasks',
|
||||||
|
mergeMode: 'Merge Data',
|
||||||
|
mergeModeDesc: 'Keep existing tasks and add new tasks',
|
||||||
|
confirmImportBtn: 'Confirm Import',
|
||||||
|
importFileInfo: 'Import File Information',
|
||||||
|
fileVersion: 'File Version',
|
||||||
|
taskCount: 'Task Count',
|
||||||
|
exportDate: 'Export Date',
|
||||||
|
currentTaskCount: 'There are currently {count} tasks',
|
||||||
|
duplicateTasksSkipped: 'Skipped {count} duplicate tasks',
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import zh from './zh.js';
|
||||||
|
import en from './en.js';
|
||||||
|
const dictionaries = { zh, en };
|
||||||
|
let language = 'zh';
|
||||||
|
export const getLanguage = () => language;
|
||||||
|
export function t(key, params = {}) {
|
||||||
|
const template = dictionaries[language][key] ?? key;
|
||||||
|
return Object.entries(params).reduce(
|
||||||
|
(text, [name, value]) => text.replaceAll(`{${name}}`, String(value)),
|
||||||
|
template,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function applyTranslations(root = document) {
|
||||||
|
document.documentElement.lang = language === 'zh' ? 'zh-CN' : 'en';
|
||||||
|
document.title = t('appTitle');
|
||||||
|
root.querySelectorAll('[data-key]').forEach((element) => {
|
||||||
|
element.textContent = t(element.dataset.key);
|
||||||
|
});
|
||||||
|
root.querySelectorAll('[data-key-placeholder]').forEach((element) => {
|
||||||
|
element.placeholder = t(element.dataset.keyPlaceholder);
|
||||||
|
});
|
||||||
|
const button = document.querySelector('#langBtn');
|
||||||
|
if (button) button.textContent = language === 'zh' ? 'EN' : '中文';
|
||||||
|
}
|
||||||
|
export function toggleLanguage() {
|
||||||
|
language = language === 'zh' ? 'en' : 'zh';
|
||||||
|
applyTranslations();
|
||||||
|
return language;
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
export default {
|
||||||
|
appTitle: '任务管理系统',
|
||||||
|
todo: '待办事项',
|
||||||
|
inProgress: '进行中',
|
||||||
|
completed: '已完成',
|
||||||
|
addTask: '添加任务',
|
||||||
|
editTask: '编辑任务',
|
||||||
|
exportData: '导出数据',
|
||||||
|
importData: '导入数据',
|
||||||
|
searchPlaceholder: '搜索任务...',
|
||||||
|
taskTitleLabel: '任务标题 *',
|
||||||
|
statusLabel: '状态',
|
||||||
|
progressNotesLabel: '任务进展',
|
||||||
|
descriptionLabel: '任务描述',
|
||||||
|
priorityLabel: '优先级',
|
||||||
|
languageLabel: '语言',
|
||||||
|
assigneeLabel: '负责人',
|
||||||
|
dueDateLabel: '到期日期',
|
||||||
|
collaboratorsLabel: '协助人员',
|
||||||
|
progressLabel: '进度 (%)',
|
||||||
|
hiddenTaskLabel: '隐藏此任务(对已完成和待办事项生效)',
|
||||||
|
saveTask: '保存任务',
|
||||||
|
cancel: '取消',
|
||||||
|
progressNotesPlaceholder: '记录任务的最新进展、当前状态等...',
|
||||||
|
descriptionPlaceholder: '描述任务的目标、要求和基本信息...',
|
||||||
|
assigneePlaceholder: '输入负责人姓名',
|
||||||
|
collaboratorsPlaceholder: '用逗号分隔多个人员',
|
||||||
|
defaultSort: '默认排序',
|
||||||
|
titleAsc: '标题 A-Z',
|
||||||
|
titleDesc: '标题 Z-A',
|
||||||
|
numberAsc: '数字排序 ↑',
|
||||||
|
numberDesc: '数字排序 ↓',
|
||||||
|
dueDateAsc: '到期日期 ↑',
|
||||||
|
dueDateDesc: '到期日期 ↓',
|
||||||
|
priorityAsc: '优先级 ↑ (低→高)',
|
||||||
|
priorityDesc: '优先级 ↓ (高→低)',
|
||||||
|
progressAsc: '进度 ↑ (低→高)',
|
||||||
|
progressDesc: '进度 ↓ (高→低)',
|
||||||
|
createdDesc: '最新创建',
|
||||||
|
createdAsc: '最早创建',
|
||||||
|
lowPriority: '低优先级',
|
||||||
|
mediumPriority: '中优先级',
|
||||||
|
highPriority: '高优先级',
|
||||||
|
low: '低',
|
||||||
|
medium: '中',
|
||||||
|
high: '高',
|
||||||
|
assignee: '负责人:',
|
||||||
|
collaborators: '协助:',
|
||||||
|
dueDate: '到期:',
|
||||||
|
progress: '进度:',
|
||||||
|
overdue: '已过期',
|
||||||
|
dueSoon: '即将到期',
|
||||||
|
noTasks: '暂无任务',
|
||||||
|
noSearchResults: '没有匹配的任务',
|
||||||
|
timeline: '时间轴',
|
||||||
|
noTimelineEntries: '暂无时间轴记录',
|
||||||
|
addTimelinePlaceholder: '添加时间轴条目...',
|
||||||
|
add: '添加',
|
||||||
|
addEntry: '添加条目',
|
||||||
|
edit: '编辑',
|
||||||
|
delete: '删除',
|
||||||
|
save: '保存',
|
||||||
|
editContentPlaceholder: '编辑内容...',
|
||||||
|
keyboardHint: 'Ctrl+Enter保存 | Esc取消',
|
||||||
|
systemUser: '系统',
|
||||||
|
defaultUser: '用户',
|
||||||
|
taskAdded: '任务已添加',
|
||||||
|
taskUpdated: '任务已更新',
|
||||||
|
taskDeleted: '任务已删除',
|
||||||
|
taskPinned: '任务置顶',
|
||||||
|
taskUnpinned: '取消置顶',
|
||||||
|
taskHidden: '任务已隐藏',
|
||||||
|
taskShown: '任务已显示',
|
||||||
|
pinTask: '置顶任务',
|
||||||
|
unpinTask: '取消置顶',
|
||||||
|
showTask: '显示任务',
|
||||||
|
hideTask: '隐藏任务',
|
||||||
|
taskTitleRequired: '任务标题不能为空',
|
||||||
|
taskNotFound: '任务不存在或已被删除',
|
||||||
|
confirmDelete: '确定要删除这个任务吗?',
|
||||||
|
confirmDeleteTimeline: '确定要删除这个时间轴条目吗?',
|
||||||
|
contentRequired: '内容不能为空',
|
||||||
|
timelineEntryAdded: '时间轴条目已添加',
|
||||||
|
timelineEntryUpdated: '时间轴条目已更新',
|
||||||
|
timelineEntryDeleted: '时间轴条目已删除',
|
||||||
|
statusChanged: '状态变更',
|
||||||
|
progressUpdated: '进度更新',
|
||||||
|
assigneeChanged: '负责人变更',
|
||||||
|
dueDateChanged: '到期日期变更',
|
||||||
|
priorityChanged: '优先级变更',
|
||||||
|
progressNotesUpdated: '任务进展更新',
|
||||||
|
progressCleared: '进展内容已清空',
|
||||||
|
storageTasksReset: '本地任务数据损坏,无法加载;原始数据已备份',
|
||||||
|
storageWriteFailed: '无法保存到本地存储;当前修改仅保留在内存中,刷新后会丢失',
|
||||||
|
overdueTasks: '{count} 个任务已过期!',
|
||||||
|
dueSoonTasks: '{count} 个任务即将到期!',
|
||||||
|
allCompletedHidden: '所有已完成任务都已隐藏 ({count} 个)',
|
||||||
|
allTodoHidden: '所有待办事项都已隐藏 ({count} 个)',
|
||||||
|
showHiddenTasks: '显示隐藏任务 ({count}个)',
|
||||||
|
hideHiddenTasks: '隐藏已被隐藏的任务',
|
||||||
|
showHiddenTitle: '显示被隐藏的任务',
|
||||||
|
hideHiddenTitle: '隐藏被隐藏的任务',
|
||||||
|
exportSuccess: '数据导出成功',
|
||||||
|
exportError: '导出失败',
|
||||||
|
importSuccess: '数据导入成功',
|
||||||
|
importError: '导入失败',
|
||||||
|
invalidFileFormat: '无效的文件格式',
|
||||||
|
fileReadError: '文件读取失败',
|
||||||
|
confirmImport: '确认导入数据',
|
||||||
|
importMode: '导入模式',
|
||||||
|
replaceMode: '覆盖现有数据',
|
||||||
|
replaceModeDesc: '将删除当前所有任务',
|
||||||
|
mergeMode: '合并数据',
|
||||||
|
mergeModeDesc: '保留现有任务,添加新任务',
|
||||||
|
confirmImportBtn: '确认导入',
|
||||||
|
importFileInfo: '导入文件信息',
|
||||||
|
fileVersion: '文件版本',
|
||||||
|
taskCount: '任务数量',
|
||||||
|
exportDate: '导出时间',
|
||||||
|
currentTaskCount: '当前系统中有 {count} 个任务',
|
||||||
|
duplicateTasksSkipped: '已跳过 {count} 个重复任务',
|
||||||
|
};
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { EXPORT_VERSION, STATUSES } from './config.js';
|
||||||
|
import { t } from './i18n/index.js';
|
||||||
|
import { normalizeSortOrders, normalizeTask, normalizeTasks, isValidId } from './task-model.js';
|
||||||
|
import { getSortOrders, getTasks, replaceSortOrders, replaceTasks } from './store.js';
|
||||||
|
import { escapeHtml, formatLocalDate, generateUniqueId, parseDueDateEnd } from './utils.js';
|
||||||
|
|
||||||
|
const REVOKE_DELAY_MS = 10000;
|
||||||
|
let pending = null;
|
||||||
|
export function validateImportData(data) {
|
||||||
|
if (
|
||||||
|
!data ||
|
||||||
|
typeof data !== 'object' ||
|
||||||
|
data.version !== EXPORT_VERSION ||
|
||||||
|
!data.data ||
|
||||||
|
!Array.isArray(data.data.tasks)
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
return data.data.tasks.every(
|
||||||
|
(task) =>
|
||||||
|
task &&
|
||||||
|
typeof task === 'object' &&
|
||||||
|
isValidId(task.id) &&
|
||||||
|
typeof task.title === 'string' &&
|
||||||
|
task.title.trim() &&
|
||||||
|
STATUSES.includes(task.status) &&
|
||||||
|
(task.dueDate == null || task.dueDate === '' || parseDueDateEnd(task.dueDate)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function mergeTasks(existing, incoming) {
|
||||||
|
const keys = new Set(existing.map((task) => `${task.title}|${task.status}|${task.createdDate}`));
|
||||||
|
const ids = new Set(existing.map((task) => task.id));
|
||||||
|
const added = [];
|
||||||
|
let skipped = 0;
|
||||||
|
incoming.forEach((raw) => {
|
||||||
|
const created = typeof raw.createdDate === 'string' ? raw.createdDate : '';
|
||||||
|
const task = normalizeTask({ ...raw, id: generateUniqueId(ids) });
|
||||||
|
const key = created ? `${task.title}|${task.status}|${created}` : null;
|
||||||
|
if (key && keys.has(key)) {
|
||||||
|
skipped += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key) keys.add(key);
|
||||||
|
added.push(task);
|
||||||
|
});
|
||||||
|
return { tasks: [...existing, ...added], skipped };
|
||||||
|
}
|
||||||
|
export function createExportData(now = new Date()) {
|
||||||
|
return {
|
||||||
|
version: EXPORT_VERSION,
|
||||||
|
exportDate: now.toISOString(),
|
||||||
|
localDate: formatLocalDate(now),
|
||||||
|
taskCount: getTasks().length,
|
||||||
|
data: { tasks: getTasks(), sortOrders: getSortOrders() },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function exportData() {
|
||||||
|
const data = createExportData();
|
||||||
|
const filename = `backup-tasks-${data.localDate}.json`;
|
||||||
|
const url = URL.createObjectURL(
|
||||||
|
new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }),
|
||||||
|
);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
// Firefox only downloads from an anchor in the document, and the blob must outlive the click
|
||||||
|
// because browsers start the transfer asynchronously.
|
||||||
|
document.body.append(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), REVOKE_DELAY_MS);
|
||||||
|
return filename;
|
||||||
|
}
|
||||||
|
export function readImportFile(file, onReady, onInvalid, onReadError) {
|
||||||
|
pending = null;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(reader.result);
|
||||||
|
if (!validateImportData(data)) {
|
||||||
|
onInvalid();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pending = data;
|
||||||
|
onReady(data);
|
||||||
|
} catch {
|
||||||
|
onInvalid();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = onReadError;
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
export function showImportDialog(data) {
|
||||||
|
const date =
|
||||||
|
data.exportDate && !Number.isNaN(Date.parse(data.exportDate))
|
||||||
|
? new Date(data.exportDate).toLocaleString()
|
||||||
|
: '-';
|
||||||
|
document.querySelector('#importInfo').innerHTML =
|
||||||
|
`<div class="alert alert-info"><strong>${escapeHtml(t('importFileInfo'))}</strong><ul><li>${escapeHtml(t('fileVersion'))}: ${escapeHtml(data.version)}</li><li>${escapeHtml(t('taskCount'))}: ${data.data.tasks.length}</li><li>${escapeHtml(t('exportDate'))}: ${escapeHtml(date)}</li></ul></div><div class="alert alert-warning">${escapeHtml(t('currentTaskCount', { count: getTasks().length }))}</div>`;
|
||||||
|
bootstrap.Modal.getOrCreateInstance(document.querySelector('#importModal')).show();
|
||||||
|
}
|
||||||
|
export function executeImport(mode) {
|
||||||
|
if (!pending) throw new Error('No import pending');
|
||||||
|
if (!['replace', 'merge'].includes(mode)) throw new TypeError('Invalid import mode');
|
||||||
|
let skipped = 0;
|
||||||
|
if (mode === 'replace') replaceTasks(normalizeTasks(pending.data.tasks));
|
||||||
|
else {
|
||||||
|
const merged = mergeTasks(getTasks(), pending.data.tasks);
|
||||||
|
replaceTasks(merged.tasks);
|
||||||
|
skipped = merged.skipped;
|
||||||
|
}
|
||||||
|
replaceSortOrders(normalizeSortOrders(pending.data.sortOrders));
|
||||||
|
pending = null;
|
||||||
|
bootstrap.Modal.getOrCreateInstance(document.querySelector('#importModal')).hide();
|
||||||
|
return skipped;
|
||||||
|
}
|
||||||
+266
@@ -0,0 +1,266 @@
|
|||||||
|
import { SORT_VALUES, STATUSES } from './config.js';
|
||||||
|
import { applyTranslations, t, toggleLanguage } from './i18n/index.js';
|
||||||
|
import { executeImport, exportData, readImportFile, showImportDialog } from './import-export.js';
|
||||||
|
import { openTaskModal, saveTask, updateProgressDisplay } from './modal.js';
|
||||||
|
import { checkDueDates, showNotification } from './notify.js';
|
||||||
|
import {
|
||||||
|
applySearchFilter,
|
||||||
|
isTimelineExpanded,
|
||||||
|
pruneTaskCaches,
|
||||||
|
renderAllTasks,
|
||||||
|
renderColumn,
|
||||||
|
setTimelineExpanded,
|
||||||
|
updateTaskCounts,
|
||||||
|
} from './render.js';
|
||||||
|
import { createSeedTasks } from './seed-data.js';
|
||||||
|
import {
|
||||||
|
getShowHidden,
|
||||||
|
getTasks,
|
||||||
|
initializeStore,
|
||||||
|
removeTask,
|
||||||
|
setShowHidden,
|
||||||
|
setSortOrder,
|
||||||
|
toggleHidden,
|
||||||
|
togglePin,
|
||||||
|
wasCorrupted,
|
||||||
|
} from './store.js';
|
||||||
|
import { consumeStorageWriteFailure, isInitialized } from './storage.js';
|
||||||
|
import { addTimelineEntry, deleteTimelineEntry, updateTimelineEntry } from './timeline.js';
|
||||||
|
import { isHiddenTask } from './utils.js';
|
||||||
|
|
||||||
|
function renderAffected(...statuses) {
|
||||||
|
[...new Set(statuses.filter(Boolean))].forEach(renderColumn);
|
||||||
|
}
|
||||||
|
function notifyPersisted(message, type = 'info') {
|
||||||
|
if (consumeStorageWriteFailure()) {
|
||||||
|
showNotification(t('storageWriteFailed'), 'danger');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (message) showNotification(message, type);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function taskAction(event) {
|
||||||
|
const action = event.target.closest('[data-action]');
|
||||||
|
if (!action) return;
|
||||||
|
const card = action.closest('[data-task-id]');
|
||||||
|
const id = Number(card?.dataset.taskId);
|
||||||
|
const status = card?.closest('.task-list')?.dataset.status;
|
||||||
|
if (action.dataset.action === 'toggle-show-hidden') {
|
||||||
|
const value = !getShowHidden(action.dataset.status);
|
||||||
|
setShowHidden(action.dataset.status, value);
|
||||||
|
renderColumn(action.dataset.status);
|
||||||
|
notifyPersisted(
|
||||||
|
t(value ? 'showHiddenTasks' : 'hideHiddenTasks', {
|
||||||
|
count: getTasks().filter(
|
||||||
|
(task) => task.status === action.dataset.status && isHiddenTask(task),
|
||||||
|
).length,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!card) return;
|
||||||
|
const timelineItem = action.closest('[data-timeline-id]');
|
||||||
|
const timelineId = Number(timelineItem?.dataset.timelineId);
|
||||||
|
switch (action.dataset.action) {
|
||||||
|
case 'pin': {
|
||||||
|
const task = togglePin(id);
|
||||||
|
if (!task) {
|
||||||
|
showNotification(t('taskNotFound'), 'warning');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
renderColumn(status);
|
||||||
|
notifyPersisted(t(task.isPinned ? 'taskPinned' : 'taskUnpinned'));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'hide': {
|
||||||
|
const task = toggleHidden(id);
|
||||||
|
if (!task) {
|
||||||
|
showNotification(t('taskNotFound'), 'warning');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
renderColumn(status);
|
||||||
|
notifyPersisted(t(task.isHidden ? 'taskHidden' : 'taskShown'));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'edit':
|
||||||
|
openTaskModal(id);
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
if (confirm(t('confirmDelete'))) {
|
||||||
|
if (!removeTask(id)) {
|
||||||
|
showNotification(t('taskNotFound'), 'warning');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pruneTaskCaches();
|
||||||
|
renderColumn(status);
|
||||||
|
notifyPersisted(t('taskDeleted'), 'warning');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'toggle-timeline':
|
||||||
|
setTimelineExpanded(id, !isTimelineExpanded(id));
|
||||||
|
renderColumn(status);
|
||||||
|
break;
|
||||||
|
case 'show-add-timeline': {
|
||||||
|
const form = action.closest('.timeline').querySelector('.timeline-form');
|
||||||
|
form.classList.remove('d-none');
|
||||||
|
form.querySelector('input').focus();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'cancel-add-timeline':
|
||||||
|
action.closest('.timeline-form').classList.add('d-none');
|
||||||
|
break;
|
||||||
|
case 'add-timeline': {
|
||||||
|
const input = action.closest('.timeline-form').querySelector('input');
|
||||||
|
if (addTimelineEntry(id, input.value)) {
|
||||||
|
setTimelineExpanded(id, true);
|
||||||
|
renderColumn(status);
|
||||||
|
notifyPersisted(t('timelineEntryAdded'), 'success');
|
||||||
|
} else showNotification(t('contentRequired'), 'warning');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'edit-timeline':
|
||||||
|
timelineItem.querySelector('.timeline-content').classList.add('d-none');
|
||||||
|
timelineItem.querySelector('.timeline-edit-form').classList.remove('d-none');
|
||||||
|
timelineItem.querySelector('textarea').focus();
|
||||||
|
break;
|
||||||
|
case 'cancel-timeline':
|
||||||
|
timelineItem.querySelector('.timeline-content').classList.remove('d-none');
|
||||||
|
timelineItem.querySelector('.timeline-edit-form').classList.add('d-none');
|
||||||
|
break;
|
||||||
|
case 'save-timeline': {
|
||||||
|
const value = timelineItem.querySelector('textarea').value;
|
||||||
|
if (updateTimelineEntry(id, timelineId, value)) {
|
||||||
|
setTimelineExpanded(id, true);
|
||||||
|
renderColumn(status);
|
||||||
|
notifyPersisted(t('timelineEntryUpdated'), 'success');
|
||||||
|
} else showNotification(t('contentRequired'), 'warning');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'delete-timeline':
|
||||||
|
if (confirm(t('confirmDeleteTimeline')) && deleteTimelineEntry(id, timelineId)) {
|
||||||
|
setTimelineExpanded(id, true);
|
||||||
|
renderColumn(status);
|
||||||
|
notifyPersisted(t('timelineEntryDeleted'), 'warning');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function bind() {
|
||||||
|
document.querySelector('#addTaskBtn').addEventListener('click', () => openTaskModal());
|
||||||
|
document.querySelector('#saveTaskBtn').addEventListener('click', () => {
|
||||||
|
const result = saveTask();
|
||||||
|
if (result?.error) {
|
||||||
|
showNotification(t(result.error), 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result) {
|
||||||
|
renderAffected(result.oldStatus, result.task.status);
|
||||||
|
notifyPersisted(t(result.oldStatus ? 'taskUpdated' : 'taskAdded'), 'success');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.querySelector('#taskProgress').addEventListener('input', updateProgressDisplay);
|
||||||
|
const runSearch = () => {
|
||||||
|
applySearchFilter();
|
||||||
|
updateTaskCounts();
|
||||||
|
};
|
||||||
|
document.querySelector('#searchInput').addEventListener('input', runSearch);
|
||||||
|
document.querySelector('#searchBtn').addEventListener('click', runSearch);
|
||||||
|
document.querySelector('#languageBtn').addEventListener('click', () => {
|
||||||
|
toggleLanguage();
|
||||||
|
renderAllTasks();
|
||||||
|
});
|
||||||
|
document.querySelector('#exportBtn').addEventListener('click', () => {
|
||||||
|
try {
|
||||||
|
showNotification(`${t('exportSuccess')} (${exportData()})`, 'success');
|
||||||
|
} catch {
|
||||||
|
showNotification(t('exportError'), 'danger');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document
|
||||||
|
.querySelector('#importBtn')
|
||||||
|
.addEventListener('click', () => document.querySelector('#fileInput').click());
|
||||||
|
document.querySelector('#fileInput').addEventListener('change', (event) => {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (file)
|
||||||
|
readImportFile(
|
||||||
|
file,
|
||||||
|
showImportDialog,
|
||||||
|
() => showNotification(t('invalidFileFormat'), 'danger'),
|
||||||
|
() => showNotification(t('fileReadError'), 'danger'),
|
||||||
|
);
|
||||||
|
event.target.value = '';
|
||||||
|
});
|
||||||
|
document.querySelector('#confirmImportBtn').addEventListener('click', () => {
|
||||||
|
try {
|
||||||
|
const mode = document.querySelector('[name="importMode"]:checked').value;
|
||||||
|
const skipped = executeImport(mode);
|
||||||
|
pruneTaskCaches();
|
||||||
|
renderAllTasks();
|
||||||
|
if (notifyPersisted(t('importSuccess'), 'success') && skipped)
|
||||||
|
showNotification(t('duplicateTasksSkipped', { count: skipped }));
|
||||||
|
} catch {
|
||||||
|
showNotification(t('importError'), 'danger');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.querySelector('#taskBoard').addEventListener('click', taskAction);
|
||||||
|
document.querySelector('#taskBoard').addEventListener('change', (event) => {
|
||||||
|
if (
|
||||||
|
event.target.matches('.sort-selector') &&
|
||||||
|
STATUSES.includes(event.target.dataset.status) &&
|
||||||
|
SORT_VALUES.has(event.target.value)
|
||||||
|
) {
|
||||||
|
setSortOrder(event.target.dataset.status, event.target.value);
|
||||||
|
renderColumn(event.target.dataset.status);
|
||||||
|
notifyPersisted();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.querySelector('#taskBoard').addEventListener('keydown', (event) => {
|
||||||
|
const card = event.target.closest('[data-task-id]');
|
||||||
|
if (!card) return;
|
||||||
|
const status = card.closest('.task-list').dataset.status;
|
||||||
|
if (event.key === 'Enter' && event.target.matches('[data-role="timeline-input"]')) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (addTimelineEntry(Number(card.dataset.taskId), event.target.value)) {
|
||||||
|
setTimelineExpanded(Number(card.dataset.taskId), true);
|
||||||
|
renderColumn(status);
|
||||||
|
notifyPersisted(t('timelineEntryAdded'), 'success');
|
||||||
|
} else showNotification(t('contentRequired'), 'warning');
|
||||||
|
}
|
||||||
|
if (event.target.matches('[data-role="timeline-edit"]') && event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.target.closest('.timeline-edit-form').classList.add('d-none');
|
||||||
|
event.target
|
||||||
|
.closest('.timeline-item')
|
||||||
|
.querySelector('.timeline-content')
|
||||||
|
.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
event.target.matches('[data-role="timeline-edit"]') &&
|
||||||
|
event.key === 'Enter' &&
|
||||||
|
event.ctrlKey
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
const item = event.target.closest('[data-timeline-id]');
|
||||||
|
if (
|
||||||
|
updateTimelineEntry(
|
||||||
|
Number(card.dataset.taskId),
|
||||||
|
Number(item.dataset.timelineId),
|
||||||
|
event.target.value,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
setTimelineExpanded(Number(card.dataset.taskId), true);
|
||||||
|
renderColumn(status);
|
||||||
|
notifyPersisted(t('timelineEntryUpdated'), 'success');
|
||||||
|
} else showNotification(t('contentRequired'), 'warning');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
initializeStore(createSeedTasks(), isInitialized());
|
||||||
|
applyTranslations();
|
||||||
|
renderAllTasks();
|
||||||
|
bind();
|
||||||
|
if (wasCorrupted()) showNotification(t('storageTasksReset'), 'danger');
|
||||||
|
notifyPersisted();
|
||||||
|
checkDueDates();
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { t } from './i18n/index.js';
|
||||||
|
import { addTask, getTask, getTasks, updateTask } from './store.js';
|
||||||
|
import { addSystemEntries } from './timeline.js';
|
||||||
|
import { generateUniqueId } from './utils.js';
|
||||||
|
|
||||||
|
const byId = (id) => document.querySelector(`#${id}`);
|
||||||
|
export function updateProgressDisplay() {
|
||||||
|
byId('progressDisplay').textContent = `${byId('taskProgress').value}%`;
|
||||||
|
}
|
||||||
|
export function openTaskModal(taskId = null) {
|
||||||
|
const form = byId('taskForm');
|
||||||
|
form.reset();
|
||||||
|
byId('taskId').value = taskId ?? '';
|
||||||
|
const task = taskId ? getTask(taskId) : null;
|
||||||
|
byId('modalTitle').dataset.key = task ? 'editTask' : 'addTask';
|
||||||
|
byId('modalTitle').textContent = t(task ? 'editTask' : 'addTask');
|
||||||
|
if (task) {
|
||||||
|
byId('taskTitle').value = task.title;
|
||||||
|
byId('taskDescription').value = task.description;
|
||||||
|
byId('taskProgressNotes').value = task.progressNotes;
|
||||||
|
byId('taskStatus').value = task.status;
|
||||||
|
byId('taskProgress').value = task.progress;
|
||||||
|
byId('taskAssignee').value = task.assignee;
|
||||||
|
byId('taskDueDate').value = task.dueDate;
|
||||||
|
byId('taskCollaborators').value = task.collaborators.join(', ');
|
||||||
|
byId('taskLanguage').value = task.language;
|
||||||
|
byId('taskPriority').value = task.priority;
|
||||||
|
byId('taskHidden').checked = task.isHidden;
|
||||||
|
} else {
|
||||||
|
byId('taskPriority').value = 'low';
|
||||||
|
byId('taskLanguage').value = 'zh';
|
||||||
|
}
|
||||||
|
updateProgressDisplay();
|
||||||
|
bootstrap.Modal.getOrCreateInstance(byId('taskModal')).show();
|
||||||
|
}
|
||||||
|
function values() {
|
||||||
|
return {
|
||||||
|
title: byId('taskTitle').value.trim(),
|
||||||
|
description: byId('taskDescription').value,
|
||||||
|
progressNotes: byId('taskProgressNotes').value,
|
||||||
|
status: byId('taskStatus').value,
|
||||||
|
progress: Number(byId('taskProgress').value),
|
||||||
|
assignee: byId('taskAssignee').value,
|
||||||
|
dueDate: byId('taskDueDate').value,
|
||||||
|
collaborators: byId('taskCollaborators')
|
||||||
|
.value.split(',')
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
language: byId('taskLanguage').value,
|
||||||
|
priority: byId('taskPriority').value,
|
||||||
|
isHidden: byId('taskHidden').checked,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function saveTask() {
|
||||||
|
const data = values();
|
||||||
|
const title = byId('taskTitle');
|
||||||
|
title.setCustomValidity(data.title ? '' : t('taskTitleRequired'));
|
||||||
|
if (!byId('taskForm').reportValidity()) return null;
|
||||||
|
const id = Number(byId('taskId').value);
|
||||||
|
let oldStatus = null;
|
||||||
|
let task;
|
||||||
|
if (id) {
|
||||||
|
const old = getTask(id);
|
||||||
|
if (!old) return { error: 'taskNotFound' };
|
||||||
|
oldStatus = old.status;
|
||||||
|
task = updateTask(id, { ...data, timeline: addSystemEntries(old, data) });
|
||||||
|
} else {
|
||||||
|
task = addTask({
|
||||||
|
id: generateUniqueId(new Set(getTasks().map((item) => item.id))),
|
||||||
|
...data,
|
||||||
|
isPinned: false,
|
||||||
|
createdDate: new Date().toISOString(),
|
||||||
|
timeline: [],
|
||||||
|
});
|
||||||
|
if (data.progressNotes)
|
||||||
|
task = updateTask(task.id, {
|
||||||
|
timeline: addSystemEntries({ ...task, progressNotes: '' }, data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
bootstrap.Modal.getOrCreateInstance(byId('taskModal')).hide();
|
||||||
|
return { task, oldStatus };
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { t } from './i18n/index.js';
|
||||||
|
import { getTasks } from './store.js';
|
||||||
|
import { getDueDateState } from './utils.js';
|
||||||
|
export function showNotification(message, type = 'info') {
|
||||||
|
const alert = document.createElement('div');
|
||||||
|
alert.className = `alert alert-${type} alert-dismissible fade show`;
|
||||||
|
const text = document.createElement('span');
|
||||||
|
text.textContent = String(message);
|
||||||
|
const close = document.createElement('button');
|
||||||
|
close.className = 'btn-close';
|
||||||
|
close.dataset.bsDismiss = 'alert';
|
||||||
|
alert.append(text, close);
|
||||||
|
document.querySelector('#notifications').append(alert);
|
||||||
|
setTimeout(() => alert.remove(), 5000);
|
||||||
|
}
|
||||||
|
export function checkDueDates(now = new Date()) {
|
||||||
|
let overdue = 0,
|
||||||
|
soon = 0;
|
||||||
|
getTasks().forEach((task) => {
|
||||||
|
if (!task.dueDate || task.status === 'completed') return;
|
||||||
|
const state = getDueDateState(task.dueDate, now);
|
||||||
|
if (state.isOverdue) overdue += 1;
|
||||||
|
else if (state.isDueSoon) soon += 1;
|
||||||
|
});
|
||||||
|
const parts = [];
|
||||||
|
if (overdue) parts.push(t('overdueTasks', { count: overdue }));
|
||||||
|
if (soon) parts.push(t('dueSoonTasks', { count: soon }));
|
||||||
|
if (parts.length) showNotification(parts.join(' '), overdue ? 'danger' : 'warning');
|
||||||
|
return { overdue, soon };
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { HIDEABLE_STATUSES, SORT_OPTIONS, STATUSES } from './config.js';
|
||||||
|
import { t } from './i18n/index.js';
|
||||||
|
import { getShowHidden, getSortOrders, getTasks } from './store.js';
|
||||||
|
import { sortTasks } from './sort.js';
|
||||||
|
import { escapeHtml, getDueDateState, isHiddenTask } from './utils.js';
|
||||||
|
|
||||||
|
const expanded = new Set();
|
||||||
|
const searchIndexes = new Map();
|
||||||
|
export const setTimelineExpanded = (id, value) => (value ? expanded.add(id) : expanded.delete(id));
|
||||||
|
export const isTimelineExpanded = (id) => expanded.has(id);
|
||||||
|
export function pruneTaskCaches(taskIds = getTasks().map((task) => task.id)) {
|
||||||
|
const validIds = new Set(taskIds);
|
||||||
|
[expanded, searchIndexes].forEach((cache) => {
|
||||||
|
[...cache.keys()].forEach((id) => {
|
||||||
|
if (!validIds.has(id)) cache.delete(id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function renderSortOptions() {
|
||||||
|
const orders = getSortOrders();
|
||||||
|
document.querySelectorAll('.sort-selector').forEach((select) => {
|
||||||
|
select.innerHTML = SORT_OPTIONS.map(
|
||||||
|
([value, key]) => `<option value="${value}">${escapeHtml(t(key))}</option>`,
|
||||||
|
).join('');
|
||||||
|
select.value = orders[select.dataset.status];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function timelineContent(item) {
|
||||||
|
if (!item.event) return String(item.content ?? '');
|
||||||
|
const values = item.event.values ?? {};
|
||||||
|
const from = values.from || '-';
|
||||||
|
const to = values.to || '-';
|
||||||
|
if (item.event.key === 'progressNotesUpdated') {
|
||||||
|
const value = String(values.value ?? '').trim();
|
||||||
|
return value ? `${value.slice(0, 50)}${value.length > 50 ? '...' : ''}` : t('progressCleared');
|
||||||
|
}
|
||||||
|
if (item.event.key === 'progressUpdated')
|
||||||
|
return `${t('progressUpdated')}: ${values.from ?? '-'}% → ${values.to ?? '-'}%`;
|
||||||
|
if (['statusChanged', 'priorityChanged'].includes(item.event.key))
|
||||||
|
return `${t(item.event.key)}: ${t(from)} → ${t(to)}`;
|
||||||
|
return `${t(item.event.key)}: ${from} → ${to}`;
|
||||||
|
}
|
||||||
|
function entryTime(item) {
|
||||||
|
// Imported entries can carry an unparsable date; treat those as the oldest instead of comparing NaN.
|
||||||
|
const time = Date.parse(item.date);
|
||||||
|
return Number.isNaN(time) ? -Infinity : time;
|
||||||
|
}
|
||||||
|
function timelineOrder(a, b) {
|
||||||
|
// Entry dates have day granularity, so ids (allocated in increasing order) break same-day ties.
|
||||||
|
const left = entryTime(a);
|
||||||
|
const right = entryTime(b);
|
||||||
|
return left === right ? b.id - a.id : right - left;
|
||||||
|
}
|
||||||
|
function createTimeline(task) {
|
||||||
|
const items = [...task.timeline].sort(timelineOrder);
|
||||||
|
const rows = items.length
|
||||||
|
? items
|
||||||
|
.map(
|
||||||
|
(item) =>
|
||||||
|
`<div class="timeline-item ${escapeHtml(item.type)}" data-timeline-id="${item.id}"><div class="timeline-item-actions"><button class="btn btn-sm btn-outline-primary" data-action="edit-timeline" title="${t('edit')}"><i class="fas fa-edit"></i></button><button class="btn btn-sm btn-outline-danger" data-action="delete-timeline" title="${t('delete')}"><i class="fas fa-trash"></i></button></div><div class="timeline-content"><div class="timeline-date">${escapeHtml(item.date)} - ${escapeHtml(item.userType === 'system' ? t('systemUser') : item.user)}</div><div>${escapeHtml(timelineContent(item))}</div></div><div class="timeline-edit-form d-none"><textarea class="form-control form-control-sm" data-role="timeline-edit">${escapeHtml(timelineContent(item))}</textarea><button class="btn btn-sm btn-primary mt-1" data-action="save-timeline">${t('save')}</button><button class="btn btn-sm btn-secondary mt-1" data-action="cancel-timeline">${t('cancel')}</button><small class="ms-2 text-muted">${t('keyboardHint')}</small></div></div>`,
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
: `<p class="text-muted small">${t('noTimelineEntries')}</p>`;
|
||||||
|
return `${rows}<div class="timeline-form d-none"><input class="form-control form-control-sm" data-role="timeline-input" placeholder="${t('addTimelinePlaceholder')}"><button class="btn btn-sm btn-primary mt-2" data-action="add-timeline">${t('add')}</button><button class="btn btn-sm btn-secondary mt-2" data-action="cancel-add-timeline">${t('cancel')}</button></div><button class="btn btn-sm btn-outline-primary mt-2" data-action="show-add-timeline"><i class="fas fa-plus"></i> ${t('addEntry')}</button>`;
|
||||||
|
}
|
||||||
|
function createTaskActions(task) {
|
||||||
|
const hiddenAction = HIDEABLE_STATUSES.includes(task.status)
|
||||||
|
? `<button class="btn btn-outline-secondary" data-action="hide" title="${t(task.isHidden ? 'showTask' : 'hideTask')}"><i class="fas ${task.isHidden ? 'fa-eye' : 'fa-eye-slash'}"></i></button>`
|
||||||
|
: '';
|
||||||
|
return `<div class="task-actions"><button class="btn btn-link" data-action="pin" title="${t(task.isPinned ? 'unpinTask' : 'pinTask')}"><i class="fas fa-thumbtack"></i></button>${hiddenAction}<button class="btn btn-outline-primary" data-action="edit" title="${t('edit')}"><i class="fas fa-edit"></i></button><button class="btn btn-outline-danger" data-action="delete" title="${t('delete')}"><i class="fas fa-trash"></i></button></div>`;
|
||||||
|
}
|
||||||
|
function createPriorityBadge(task) {
|
||||||
|
return `<span class="priority-badge priority-${task.priority}-badge">${t(task.priority)}</span>`;
|
||||||
|
}
|
||||||
|
function createPeople(task) {
|
||||||
|
const assignee = task.assignee
|
||||||
|
? `<div><small>${t('assignee')}</small><span class="badge bg-primary person-badge">${escapeHtml(task.assignee)}</span></div>`
|
||||||
|
: '';
|
||||||
|
const collaborators = task.collaborators
|
||||||
|
.map((name) => `<span class="badge bg-secondary person-badge">${escapeHtml(name)}</span>`)
|
||||||
|
.join('');
|
||||||
|
return `${assignee}${collaborators ? `<div><small>${t('collaborators')}</small>${collaborators}</div>` : ''}`;
|
||||||
|
}
|
||||||
|
function createProgress(task) {
|
||||||
|
const notes = task.progressNotes
|
||||||
|
? `<div class="small text-primary mb-2"><strong>${t('progressNotesLabel')}:</strong><div class="task-description-display">${escapeHtml(task.progressNotes)}</div></div>`
|
||||||
|
: '';
|
||||||
|
const bar =
|
||||||
|
task.progress > 0
|
||||||
|
? `<div class="progress progress-custom"><div class="progress-bar" style="width:${task.progress}%"></div></div><small>${t('progress')}${task.progress}%</small>`
|
||||||
|
: '';
|
||||||
|
return `${notes}${bar}`;
|
||||||
|
}
|
||||||
|
function createDueDateBlock(task, due) {
|
||||||
|
if (!task.dueDate) return '';
|
||||||
|
const dateClass = due.isOverdue ? 'text-danger' : due.isDueSoon ? 'text-warning' : '';
|
||||||
|
const badge = due.isOverdue
|
||||||
|
? `<span class="badge bg-danger">${t('overdue')}</span>`
|
||||||
|
: due.isDueSoon
|
||||||
|
? `<span class="badge bg-warning">${t('dueSoon')}</span>`
|
||||||
|
: '';
|
||||||
|
return `<div><small>${t('dueDate')}<span class="${dateClass}">${escapeHtml(task.dueDate)}</span></small> ${badge}</div>`;
|
||||||
|
}
|
||||||
|
function buildSearchIndex(task) {
|
||||||
|
return [
|
||||||
|
task.title,
|
||||||
|
task.description,
|
||||||
|
task.progressNotes,
|
||||||
|
task.assignee,
|
||||||
|
...task.collaborators,
|
||||||
|
task.dueDate,
|
||||||
|
t(task.priority),
|
||||||
|
...task.timeline.map(timelineContent),
|
||||||
|
]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
function createCard(task) {
|
||||||
|
// Indexed here so that a rendered card always has a matching entry for applySearchFilter.
|
||||||
|
searchIndexes.set(task.id, buildSearchIndex(task));
|
||||||
|
const due = getDueDateState(task.dueDate);
|
||||||
|
const open = expanded.has(task.id);
|
||||||
|
const classes = [
|
||||||
|
'task-card',
|
||||||
|
'card',
|
||||||
|
task.status,
|
||||||
|
`priority-${task.priority}`,
|
||||||
|
task.isPinned ? 'pinned' : '',
|
||||||
|
isHiddenTask(task) ? 'hidden-task' : '',
|
||||||
|
due.isOverdue ? 'overdue' : due.isDueSoon ? 'due-soon' : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
const description = task.description
|
||||||
|
? `<p class="small task-description-display">${escapeHtml(task.description)}</p>`
|
||||||
|
: '';
|
||||||
|
return `<article class="${classes}" data-task-id="${task.id}">${createTaskActions(task)}<div class="card-body"><div class="d-flex justify-content-between pe-5"><h6>${escapeHtml(task.title)}</h6>${createPriorityBadge(task)}</div>${description}${createProgress(task)}${createPeople(task)}${createDueDateBlock(task, due)}<div class="timeline-toggle mt-2 pt-2 border-top" data-action="toggle-timeline"><small><i class="fas fa-history"></i> ${t('timeline')} (${task.timeline.length}) <i class="fas fa-chevron-${open ? 'up' : 'down'}"></i></small></div><div class="timeline ${open ? '' : 'd-none'}">${createTimeline(task)}</div></div></article>`;
|
||||||
|
}
|
||||||
|
export function updateTaskCounts() {
|
||||||
|
const tasks = getTasks();
|
||||||
|
STATUSES.forEach((status) => {
|
||||||
|
const cards = [...document.querySelectorAll(`#${status}Tasks .task-card`)];
|
||||||
|
document.querySelector(`#${status}Count`).textContent = cards.filter(
|
||||||
|
(card) => !card.hidden,
|
||||||
|
).length;
|
||||||
|
});
|
||||||
|
HIDEABLE_STATUSES.forEach((status) => {
|
||||||
|
const button = document.querySelector(
|
||||||
|
`[data-action="toggle-show-hidden"][data-status="${status}"]`,
|
||||||
|
);
|
||||||
|
const count = tasks.filter((task) => task.status === status && isHiddenTask(task)).length;
|
||||||
|
button.hidden = count === 0;
|
||||||
|
button.title = t(getShowHidden(status) ? 'hideHiddenTitle' : 'showHiddenTitle');
|
||||||
|
button.querySelector('i').className = `fas fa-${getShowHidden(status) ? 'eye-slash' : 'eye'}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function updateSearchEmptyState(list) {
|
||||||
|
const cards = [...list.querySelectorAll('.task-card')];
|
||||||
|
const notice = list.querySelector('.search-empty-state');
|
||||||
|
if (cards.length && cards.every((card) => card.hidden)) {
|
||||||
|
const node = notice ?? document.createElement('p');
|
||||||
|
node.className = 'empty-state search-empty-state';
|
||||||
|
node.textContent = t('noSearchResults');
|
||||||
|
if (!notice) list.append(node);
|
||||||
|
} else notice?.remove();
|
||||||
|
}
|
||||||
|
export function applySearchFilter(root = document) {
|
||||||
|
const term = document.querySelector('#searchInput').value.trim().toLowerCase();
|
||||||
|
root.querySelectorAll('.task-card').forEach((card) => {
|
||||||
|
const index = searchIndexes.get(Number(card.dataset.taskId)) ?? '';
|
||||||
|
card.hidden = Boolean(term && !index.includes(term));
|
||||||
|
});
|
||||||
|
const lists = root.matches?.('.task-list') ? [root] : [...root.querySelectorAll('.task-list')];
|
||||||
|
lists.forEach(updateSearchEmptyState);
|
||||||
|
}
|
||||||
|
export function renderColumn(status, updateCounts = true) {
|
||||||
|
const all = getTasks().filter((task) => task.status === status);
|
||||||
|
const hidden = all.filter(isHiddenTask).length;
|
||||||
|
const shown = getShowHidden(status) ? all : all.filter((task) => !isHiddenTask(task));
|
||||||
|
const ordered = sortTasks(shown, getSortOrders()[status]);
|
||||||
|
const key = status === 'completed' ? 'allCompletedHidden' : 'allTodoHidden';
|
||||||
|
document.querySelector(`#${status}Tasks`).innerHTML = ordered.length
|
||||||
|
? ordered.map(createCard).join('')
|
||||||
|
: `<p class="empty-state">${hidden ? t(key, { count: hidden }) : t('noTasks')}</p>`;
|
||||||
|
applySearchFilter(document.querySelector(`#${status}Tasks`));
|
||||||
|
if (updateCounts) updateTaskCounts();
|
||||||
|
}
|
||||||
|
export function renderAllTasks() {
|
||||||
|
STATUSES.forEach((status) => renderColumn(status, false));
|
||||||
|
updateTaskCounts();
|
||||||
|
renderSortOptions();
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
export function createSeedTasks(now = new Date()) {
|
||||||
|
const createdDate = now.toISOString();
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: '所有设备相机参数标准化',
|
||||||
|
description: '完成所有生产设备的相机参数标准化工作,确保图像采集的一致性和可靠性。',
|
||||||
|
progressNotes: '目前已完成大部分设备的参数标准化工作,仅剩DieBond贴片相机待确认参数配置。',
|
||||||
|
status: 'inProgress',
|
||||||
|
progress: 90,
|
||||||
|
assignee: '技术团队',
|
||||||
|
collaborators: [],
|
||||||
|
dueDate: '2025-02-15',
|
||||||
|
createdDate,
|
||||||
|
language: 'zh',
|
||||||
|
priority: 'high',
|
||||||
|
isPinned: false,
|
||||||
|
timeline: [],
|
||||||
|
isHidden: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: 'DieBond设备照明优化',
|
||||||
|
description:
|
||||||
|
'定制组合光源:同轴光+环形光,采购更窄、更亮的侧面光源,以解决支架白边问题,提升图像质量。',
|
||||||
|
progressNotes:
|
||||||
|
'配置方案已与供应商确认,技术参数符合要求。供应商承诺2025年7月4日发货,目前正在生产中。',
|
||||||
|
status: 'inProgress',
|
||||||
|
progress: 60,
|
||||||
|
assignee: '硬件团队',
|
||||||
|
collaborators: ['供应商'],
|
||||||
|
dueDate: '2025-07-04',
|
||||||
|
createdDate,
|
||||||
|
language: 'zh',
|
||||||
|
priority: 'medium',
|
||||||
|
isPinned: true,
|
||||||
|
timeline: [],
|
||||||
|
isHidden: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: 'DieBond和ClipBond软件界面中英文翻译更新',
|
||||||
|
description: '完成DieBond和ClipBond设备软件界面的中英文翻译工作,支持多语言操作环境。',
|
||||||
|
progressNotes:
|
||||||
|
'SG-TATA GUI 的中英文翻译已完成。未来更新杨杰的代码后,需要重新进行翻译(任务量很少)。',
|
||||||
|
status: 'inProgress',
|
||||||
|
progress: 80,
|
||||||
|
assignee: '杨杰',
|
||||||
|
collaborators: ['翻译团队'],
|
||||||
|
dueDate: '2025-02-28',
|
||||||
|
createdDate,
|
||||||
|
language: 'zh',
|
||||||
|
priority: 'medium',
|
||||||
|
isPinned: false,
|
||||||
|
timeline: [],
|
||||||
|
isHidden: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
title: '所有设备相机手册',
|
||||||
|
description: '编写所有设备相机的操作手册,包括校准流程、位置引导方法、检测标准等技术文档。',
|
||||||
|
progressNotes: '任务刚启动,正在收集各设备的相机技术规格和操作要求。',
|
||||||
|
status: 'todo',
|
||||||
|
progress: 0,
|
||||||
|
assignee: '技术文档组',
|
||||||
|
collaborators: [],
|
||||||
|
dueDate: '2025-03-31',
|
||||||
|
createdDate,
|
||||||
|
language: 'zh',
|
||||||
|
priority: 'low',
|
||||||
|
isPinned: false,
|
||||||
|
timeline: [],
|
||||||
|
isHidden: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
title: '开发 TaTa SECS/GEM & Map 软件',
|
||||||
|
description:
|
||||||
|
'开发符合SEMI标准的SECS/GEM通信协议软件,实现设备与MES系统的标准化通信,包括Map数据管理功能。',
|
||||||
|
progressNotes: '需求分析阶段,正在梳理SECS/GEM协议规范和技术架构设计。',
|
||||||
|
status: 'todo',
|
||||||
|
progress: 0,
|
||||||
|
assignee: '软件开发组',
|
||||||
|
collaborators: ['系统架构师'],
|
||||||
|
dueDate: '2025-06-30',
|
||||||
|
createdDate,
|
||||||
|
language: 'zh',
|
||||||
|
priority: 'high',
|
||||||
|
isPinned: false,
|
||||||
|
timeline: [],
|
||||||
|
isHidden: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { PRIORITY_ORDER, SORT_VALUES } from './config.js';
|
||||||
|
import { isHiddenTask } from './utils.js';
|
||||||
|
|
||||||
|
const number = (task) => Number.parseInt(task.title.match(/\d+/)?.[0], 10) || 0;
|
||||||
|
const date = (value) => new Date(value).getTime() || 0;
|
||||||
|
const title = (a, b) => a.title.localeCompare(b.title);
|
||||||
|
const due = (a, b) => {
|
||||||
|
if (!a.dueDate && !b.dueDate) return 0;
|
||||||
|
if (!a.dueDate) return 1;
|
||||||
|
if (!b.dueDate) return -1;
|
||||||
|
return date(a.dueDate) - date(b.dueDate);
|
||||||
|
};
|
||||||
|
const comparators = {
|
||||||
|
title,
|
||||||
|
number: (a, b) => number(a) - number(b) || title(a, b),
|
||||||
|
dueDate: due,
|
||||||
|
priority: (a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority],
|
||||||
|
progress: (a, b) => a.progress - b.progress,
|
||||||
|
created: (a, b) => date(a.createdDate) - date(b.createdDate),
|
||||||
|
};
|
||||||
|
export function sortTasks(taskList, sortOrder = 'default') {
|
||||||
|
const result = [...taskList];
|
||||||
|
const valid = SORT_VALUES.has(sortOrder) ? sortOrder : 'default';
|
||||||
|
return result.sort((a, b) => {
|
||||||
|
if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1;
|
||||||
|
if (isHiddenTask(a) !== isHiddenTask(b)) return isHiddenTask(a) ? 1 : -1;
|
||||||
|
if (valid === 'default')
|
||||||
|
return (
|
||||||
|
PRIORITY_ORDER[b.priority] - PRIORITY_ORDER[a.priority] ||
|
||||||
|
date(b.createdDate) - date(a.createdDate)
|
||||||
|
);
|
||||||
|
const [key, direction] = valid.split('-');
|
||||||
|
if (key === 'dueDate' && (!a.dueDate || !b.dueDate)) return due(a, b);
|
||||||
|
return comparators[key](a, b) * (direction === 'desc' ? -1 : 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { STORAGE_KEYS } from './config.js';
|
||||||
|
|
||||||
|
let writeFailed = false;
|
||||||
|
export function consumeStorageWriteFailure() {
|
||||||
|
const failed = writeFailed;
|
||||||
|
writeFailed = false;
|
||||||
|
return failed;
|
||||||
|
}
|
||||||
|
function readRaw(key) {
|
||||||
|
try {
|
||||||
|
return { value: localStorage.getItem(key), available: true };
|
||||||
|
} catch {
|
||||||
|
return { value: null, available: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function writeRaw(key, value) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
writeFailed = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function readJson(key, fallback) {
|
||||||
|
const { value, available } = readRaw(key);
|
||||||
|
if (!available || value === null) return fallback;
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function writeJson(key, value) {
|
||||||
|
return writeRaw(key, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
export function loadTasksData() {
|
||||||
|
const { value: raw, available } = readRaw(STORAGE_KEYS.tasks);
|
||||||
|
if (!available) return { tasks: [], corrupted: false, exists: false, available: false };
|
||||||
|
if (raw === null) return { tasks: [], corrupted: false, exists: false, available: true };
|
||||||
|
writeRaw(STORAGE_KEYS.initialized, 'true');
|
||||||
|
try {
|
||||||
|
const tasks = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(tasks)) throw new TypeError('Tasks must be an array');
|
||||||
|
return { tasks, corrupted: false, exists: true, available: true };
|
||||||
|
} catch {
|
||||||
|
writeRaw(STORAGE_KEYS.corruptedBackup, raw);
|
||||||
|
return { tasks: [], corrupted: true, exists: true, available: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export const saveTasksData = (tasks) => writeJson(STORAGE_KEYS.tasks, tasks);
|
||||||
|
export const loadSortOrdersData = () => readJson(STORAGE_KEYS.sortOrders, {});
|
||||||
|
export const saveSortOrdersData = (orders) => writeJson(STORAGE_KEYS.sortOrders, orders);
|
||||||
|
export const loadShowHiddenData = (status) => {
|
||||||
|
const key = STORAGE_KEYS.showHidden[status];
|
||||||
|
return key ? readJson(key, false) === true : false;
|
||||||
|
};
|
||||||
|
export const saveShowHiddenData = (status, value) => {
|
||||||
|
const key = STORAGE_KEYS.showHidden[status];
|
||||||
|
return key ? writeJson(key, value) : false;
|
||||||
|
};
|
||||||
|
export const isInitialized = () => readRaw(STORAGE_KEYS.initialized).value !== null;
|
||||||
|
export const markInitialized = () => writeRaw(STORAGE_KEYS.initialized, 'true');
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
import { DEFAULT_SORT_ORDERS, HIDEABLE_STATUSES, SORT_VALUES, STATUSES } from './config.js';
|
||||||
|
import { normalizeSortOrders, normalizeTask, normalizeTasks } from './task-model.js';
|
||||||
|
import { generateUniqueId } from './utils.js';
|
||||||
|
import {
|
||||||
|
loadShowHiddenData,
|
||||||
|
loadSortOrdersData,
|
||||||
|
loadTasksData,
|
||||||
|
markInitialized,
|
||||||
|
saveShowHiddenData,
|
||||||
|
saveSortOrdersData,
|
||||||
|
saveTasksData,
|
||||||
|
} from './storage.js';
|
||||||
|
|
||||||
|
let tasks = [];
|
||||||
|
let sortOrders = { ...DEFAULT_SORT_ORDERS };
|
||||||
|
let showHidden = Object.fromEntries(HIDEABLE_STATUSES.map((status) => [status, false]));
|
||||||
|
let corrupted = false;
|
||||||
|
const persistTasks = () => saveTasksData(tasks);
|
||||||
|
export function initializeStore(seedTasks, initialized) {
|
||||||
|
const loaded = loadTasksData();
|
||||||
|
corrupted = loaded.corrupted;
|
||||||
|
tasks = normalizeTasks(loaded.tasks);
|
||||||
|
if (loaded.exists && !loaded.corrupted) persistTasks();
|
||||||
|
if (!initialized && !loaded.exists) {
|
||||||
|
tasks = normalizeTasks(seedTasks);
|
||||||
|
persistTasks();
|
||||||
|
markInitialized();
|
||||||
|
}
|
||||||
|
sortOrders = { ...DEFAULT_SORT_ORDERS, ...normalizeSortOrders(loadSortOrdersData()) };
|
||||||
|
showHidden = Object.fromEntries(
|
||||||
|
HIDEABLE_STATUSES.map((status) => [status, loadShowHiddenData(status)]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export const wasCorrupted = () => corrupted;
|
||||||
|
export const getTasks = () => tasks.map((task) => structuredClone(task));
|
||||||
|
export const getTask = (id) => {
|
||||||
|
const task = tasks.find((item) => item.id === id);
|
||||||
|
return task ? structuredClone(task) : null;
|
||||||
|
};
|
||||||
|
export const getSortOrders = () => ({ ...sortOrders });
|
||||||
|
export const getShowHidden = (status) => showHidden[status] === true;
|
||||||
|
export function addTask(task) {
|
||||||
|
const normalized = normalizeTask(task);
|
||||||
|
const ids = new Set(tasks.map((item) => item.id));
|
||||||
|
if (ids.has(normalized.id)) normalized.id = generateUniqueId(ids);
|
||||||
|
tasks.push(normalized);
|
||||||
|
persistTasks();
|
||||||
|
return structuredClone(normalized);
|
||||||
|
}
|
||||||
|
export function updateTask(id, changes) {
|
||||||
|
const index = tasks.findIndex((task) => task.id === id);
|
||||||
|
if (index < 0) return null;
|
||||||
|
tasks[index] = normalizeTask({ ...tasks[index], ...changes, id });
|
||||||
|
persistTasks();
|
||||||
|
return structuredClone(tasks[index]);
|
||||||
|
}
|
||||||
|
export function removeTask(id) {
|
||||||
|
const before = tasks.length;
|
||||||
|
tasks = tasks.filter((task) => task.id !== id);
|
||||||
|
if (tasks.length !== before) persistTasks();
|
||||||
|
return tasks.length !== before;
|
||||||
|
}
|
||||||
|
export function togglePin(id) {
|
||||||
|
const task = tasks.find((item) => item.id === id);
|
||||||
|
if (!task) return null;
|
||||||
|
task.isPinned = !task.isPinned;
|
||||||
|
persistTasks();
|
||||||
|
return structuredClone(task);
|
||||||
|
}
|
||||||
|
export function toggleHidden(id) {
|
||||||
|
const task = tasks.find((item) => item.id === id);
|
||||||
|
if (!task) return null;
|
||||||
|
task.isHidden = !task.isHidden;
|
||||||
|
persistTasks();
|
||||||
|
return structuredClone(task);
|
||||||
|
}
|
||||||
|
export function setSortOrder(status, value) {
|
||||||
|
if (!STATUSES.includes(status) || !SORT_VALUES.has(value)) return false;
|
||||||
|
sortOrders[status] = value;
|
||||||
|
saveSortOrdersData(sortOrders);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
export function setShowHidden(status, value) {
|
||||||
|
if (!HIDEABLE_STATUSES.includes(status) || typeof value !== 'boolean') return false;
|
||||||
|
showHidden[status] = value;
|
||||||
|
saveShowHiddenData(status, value);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
export function replaceTasks(value) {
|
||||||
|
tasks = normalizeTasks(value);
|
||||||
|
persistTasks();
|
||||||
|
}
|
||||||
|
export function appendTasks(value) {
|
||||||
|
const ids = new Set(tasks.map((task) => task.id));
|
||||||
|
const additions = normalizeTasks(value).map((task) => {
|
||||||
|
if (ids.has(task.id)) task.id = generateUniqueId(ids);
|
||||||
|
else ids.add(task.id);
|
||||||
|
return task;
|
||||||
|
});
|
||||||
|
tasks = [...tasks, ...additions];
|
||||||
|
persistTasks();
|
||||||
|
return additions.map((task) => structuredClone(task));
|
||||||
|
}
|
||||||
|
export function replaceSortOrders(value) {
|
||||||
|
sortOrders = { ...sortOrders, ...normalizeSortOrders(value) };
|
||||||
|
saveSortOrdersData(sortOrders);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { LANGUAGES, PRIORITIES, SORT_VALUES, STATUSES } from './config.js';
|
||||||
|
import { generateId, generateUniqueId, isValidId } from './utils.js';
|
||||||
|
|
||||||
|
const TIMELINE_TYPES = ['manual-entry', 'status-change', 'progress-update', 'progressNotesUpdated'];
|
||||||
|
export function normalizeTask(input) {
|
||||||
|
const task = input && typeof input === 'object' ? { ...input } : {};
|
||||||
|
task.title = String(task.title ?? '').trim();
|
||||||
|
task.description = String(task.description ?? '');
|
||||||
|
task.progressNotes = String(task.progressNotes ?? '');
|
||||||
|
task.assignee = String(task.assignee ?? '');
|
||||||
|
task.dueDate = typeof task.dueDate === 'string' ? task.dueDate : '';
|
||||||
|
task.createdDate =
|
||||||
|
task.createdDate && !Number.isNaN(Date.parse(task.createdDate))
|
||||||
|
? task.createdDate
|
||||||
|
: new Date().toISOString();
|
||||||
|
task.status = STATUSES.includes(task.status) ? task.status : 'todo';
|
||||||
|
task.priority = PRIORITIES.includes(task.priority) ? task.priority : 'medium';
|
||||||
|
task.language = LANGUAGES.includes(task.language) ? task.language : 'zh';
|
||||||
|
task.progress = Math.min(100, Math.max(0, Number.parseInt(task.progress, 10) || 0));
|
||||||
|
task.collaborators = Array.isArray(task.collaborators) ? task.collaborators.map(String) : [];
|
||||||
|
task.isPinned = task.isPinned === true;
|
||||||
|
task.isHidden = task.isHidden === true;
|
||||||
|
task.id = isValidId(task.id) ? task.id : generateId();
|
||||||
|
const timelineIds = new Set();
|
||||||
|
task.timeline = (Array.isArray(task.timeline) ? task.timeline : [])
|
||||||
|
.filter((item) => item && typeof item === 'object' && !Array.isArray(item))
|
||||||
|
.map((item) => {
|
||||||
|
let id = isValidId(item.id) ? item.id : generateId();
|
||||||
|
while (timelineIds.has(id)) id = generateId();
|
||||||
|
timelineIds.add(id);
|
||||||
|
return { ...item, id, type: TIMELINE_TYPES.includes(item.type) ? item.type : 'manual-entry' };
|
||||||
|
});
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
export function normalizeTasks(items) {
|
||||||
|
if (!Array.isArray(items)) return [];
|
||||||
|
const ids = new Set();
|
||||||
|
return items
|
||||||
|
.filter((item) => item && typeof item === 'object')
|
||||||
|
.map((item) => {
|
||||||
|
const task = normalizeTask(item);
|
||||||
|
if (ids.has(task.id)) task.id = generateUniqueId(ids);
|
||||||
|
else ids.add(task.id);
|
||||||
|
return task;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function normalizeSortOrders(value) {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||||
|
return Object.fromEntries(
|
||||||
|
STATUSES.filter((status) => SORT_VALUES.has(value[status])).map((status) => [
|
||||||
|
status,
|
||||||
|
value[status],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export { isValidId } from './utils.js';
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { t } from './i18n/index.js';
|
||||||
|
import { getTask, updateTask } from './store.js';
|
||||||
|
import { formatLocalDate, generateUniqueId } from './utils.js';
|
||||||
|
|
||||||
|
export function addSystemEntries(task, changes) {
|
||||||
|
const timeline = [...task.timeline];
|
||||||
|
const fields = [
|
||||||
|
['progressNotes', 'progressNotesUpdated', 'progressNotesUpdated', (a, b) => ({ value: b })],
|
||||||
|
['status', 'status-change', 'statusChanged', (a, b) => ({ from: a, to: b })],
|
||||||
|
['progress', 'progress-update', 'progressUpdated', (a, b) => ({ from: a, to: b })],
|
||||||
|
['assignee', 'status-change', 'assigneeChanged', (a, b) => ({ from: a, to: b })],
|
||||||
|
['dueDate', 'status-change', 'dueDateChanged', (a, b) => ({ from: a, to: b })],
|
||||||
|
['priority', 'status-change', 'priorityChanged', (a, b) => ({ from: a, to: b })],
|
||||||
|
];
|
||||||
|
const used = new Set(timeline.map((item) => item.id));
|
||||||
|
fields.forEach(([field, type, key, values]) => {
|
||||||
|
if (Object.hasOwn(changes, field) && task[field] !== changes[field])
|
||||||
|
timeline.push({
|
||||||
|
id: generateUniqueId(used),
|
||||||
|
date: formatLocalDate(),
|
||||||
|
type,
|
||||||
|
content: '',
|
||||||
|
event: { key, values: values(task[field], changes[field]) },
|
||||||
|
userType: 'system',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return timeline;
|
||||||
|
}
|
||||||
|
export function addTimelineEntry(taskId, content) {
|
||||||
|
const task = getTask(taskId);
|
||||||
|
if (!task || !content.trim()) return false;
|
||||||
|
const id = generateUniqueId(new Set(task.timeline.map((item) => item.id)));
|
||||||
|
task.timeline.push({
|
||||||
|
id,
|
||||||
|
date: formatLocalDate(),
|
||||||
|
type: 'manual-entry',
|
||||||
|
content: content.trim(),
|
||||||
|
user: task.assignee || t('defaultUser'),
|
||||||
|
});
|
||||||
|
updateTask(taskId, { timeline: task.timeline });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
export function updateTimelineEntry(taskId, timelineId, content) {
|
||||||
|
const task = getTask(taskId);
|
||||||
|
const item = task?.timeline.find((entry) => entry.id === timelineId);
|
||||||
|
if (!item || !content.trim()) return false;
|
||||||
|
const wasSystem = item.userType === 'system';
|
||||||
|
item.content = content.trim();
|
||||||
|
item.type = 'manual-entry';
|
||||||
|
if (wasSystem || !item.user) item.user = task.assignee || t('defaultUser');
|
||||||
|
delete item.event;
|
||||||
|
delete item.userType;
|
||||||
|
updateTask(taskId, { timeline: task.timeline });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
export function deleteTimelineEntry(taskId, timelineId) {
|
||||||
|
const task = getTask(taskId);
|
||||||
|
if (!task) return false;
|
||||||
|
const timeline = task.timeline.filter((item) => item.id !== timelineId);
|
||||||
|
if (timeline.length === task.timeline.length) return false;
|
||||||
|
updateTask(taskId, { timeline });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { DUE_SOON_DAYS, HIDEABLE_STATUSES } from './config.js';
|
||||||
|
|
||||||
|
// Only todo and completed tasks can be hidden, so the flag is inert in any other column.
|
||||||
|
export const isHiddenTask = (task) =>
|
||||||
|
task.isHidden === true && HIDEABLE_STATUSES.includes(task.status);
|
||||||
|
|
||||||
|
let idCounter = 0;
|
||||||
|
export const generateId = () => Date.now() * 1000 + (idCounter++ % 1000);
|
||||||
|
export function generateUniqueId(usedIds) {
|
||||||
|
let id = generateId();
|
||||||
|
while (usedIds.has(id)) id = generateId();
|
||||||
|
usedIds.add(id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
export const isValidId = (value) => Number.isSafeInteger(value) && value > 0;
|
||||||
|
export function escapeHtml(value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
export function formatLocalDate(date = new Date()) {
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
export function parseDueDateEnd(value) {
|
||||||
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value ?? '');
|
||||||
|
if (!match) return null;
|
||||||
|
const [, y, m, d] = match.map(Number);
|
||||||
|
const date = new Date(y, m - 1, d, 23, 59, 59, 999);
|
||||||
|
return date.getFullYear() === y && date.getMonth() === m - 1 && date.getDate() === d
|
||||||
|
? date
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
export function getDueDateState(value, now = new Date()) {
|
||||||
|
const dueEnd = parseDueDateEnd(value);
|
||||||
|
if (!dueEnd) return { isOverdue: false, isDueSoon: false };
|
||||||
|
const soonEnd = new Date(now);
|
||||||
|
soonEnd.setDate(soonEnd.getDate() + DUE_SOON_DAYS);
|
||||||
|
soonEnd.setHours(23, 59, 59, 999);
|
||||||
|
return { isOverdue: dueEnd < now, isDueSoon: dueEnd >= now && dueEnd <= soonEnd };
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
|
||||||
|
### 待办
|
||||||
|
- 1、打印任务功能
|
||||||
|
- 2、任务中英文切换
|
||||||
|
- 3、记录创建日期,用于 Completed 分类中的排序
|
||||||
|
|
||||||
|
|
||||||
|
### 进行中
|
||||||
|
|
||||||
|
- V1.0 #1、拆分成 html、cs、js
|
||||||
|
- V1.0 #2、将Json文件 改名为backup-tasks-yyyy-mm-dd.json
|
||||||
|
|
||||||
|
|
||||||
|
### 已完成
|
||||||
|
|
||||||
|
### V1.0
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### V0.3
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- 1、【待办事项】添加隐藏任务功能
|
||||||
|
```
|
||||||
|
|
||||||
|
### V0.2
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- 1、数据保存到本地。Json 文件格式
|
||||||
|
- 2、去除按钮 【检测到期按钮提醒】,保留刷新时自动检测
|
||||||
|
- 3、已完成栏,可以隐藏任务功能
|
||||||
|
```
|
||||||
|
|
||||||
|
### V0.1
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- 1、时间轴的内容可以编辑,修改,添加
|
||||||
|
- 2、添加软件图标
|
||||||
|
```
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"version": "1.0",
|
||||||
|
"exportDate": "2025-01-10T08:00:00.000Z",
|
||||||
|
"localDate": "2025-01-10",
|
||||||
|
"taskCount": 2,
|
||||||
|
"data": {
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"id": 101,
|
||||||
|
"title": "Legacy todo",
|
||||||
|
"description": "Created by the pre-refactor app",
|
||||||
|
"progressNotes": "Existing progress",
|
||||||
|
"status": "todo",
|
||||||
|
"progress": 25,
|
||||||
|
"assignee": "Alice",
|
||||||
|
"collaborators": ["Bob"],
|
||||||
|
"dueDate": "2025-02-01",
|
||||||
|
"createdDate": "2025-01-01T08:00:00.000Z",
|
||||||
|
"language": "en",
|
||||||
|
"priority": "high",
|
||||||
|
"isPinned": true,
|
||||||
|
"isHidden": false,
|
||||||
|
"timeline": [
|
||||||
|
{
|
||||||
|
"id": 1001,
|
||||||
|
"date": "2025-01-01",
|
||||||
|
"type": "manual-entry",
|
||||||
|
"content": "Legacy timeline entry",
|
||||||
|
"user": "Alice"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 102,
|
||||||
|
"title": "Legacy completed",
|
||||||
|
"description": "",
|
||||||
|
"progressNotes": "",
|
||||||
|
"status": "completed",
|
||||||
|
"progress": 100,
|
||||||
|
"assignee": "",
|
||||||
|
"collaborators": [],
|
||||||
|
"dueDate": "",
|
||||||
|
"createdDate": "2025-01-02T08:00:00.000Z",
|
||||||
|
"language": "zh",
|
||||||
|
"priority": "low",
|
||||||
|
"isPinned": false,
|
||||||
|
"isHidden": true,
|
||||||
|
"timeline": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sortOrders": {
|
||||||
|
"todo": "priority-desc",
|
||||||
|
"inProgress": "created-asc",
|
||||||
|
"completed": "title-asc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,505 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import legacyBackup from './backup-tasks-v1.json' with { type: 'json' };
|
||||||
|
import { HIDEABLE_STATUSES, STATUSES } from '../src/js/config.js';
|
||||||
|
import { t } from '../src/js/i18n/index.js';
|
||||||
|
import { normalizeTask, normalizeTasks } from '../src/js/task-model.js';
|
||||||
|
import { sortTasks } from '../src/js/sort.js';
|
||||||
|
import { getDueDateState } from '../src/js/utils.js';
|
||||||
|
import { applySearchFilter, renderColumn, updateTaskCounts } from '../src/js/render.js';
|
||||||
|
import { addSystemEntries, addTimelineEntry, updateTimelineEntry } from '../src/js/timeline.js';
|
||||||
|
import {
|
||||||
|
executeImport,
|
||||||
|
exportData,
|
||||||
|
mergeTasks,
|
||||||
|
readImportFile,
|
||||||
|
validateImportData,
|
||||||
|
} from '../src/js/import-export.js';
|
||||||
|
import {
|
||||||
|
consumeStorageWriteFailure,
|
||||||
|
isInitialized,
|
||||||
|
loadShowHiddenData,
|
||||||
|
loadSortOrdersData,
|
||||||
|
loadTasksData,
|
||||||
|
} from '../src/js/storage.js';
|
||||||
|
import {
|
||||||
|
addTask,
|
||||||
|
appendTasks,
|
||||||
|
getShowHidden,
|
||||||
|
getSortOrders,
|
||||||
|
getTasks,
|
||||||
|
initializeStore,
|
||||||
|
setShowHidden,
|
||||||
|
setSortOrder,
|
||||||
|
updateTask,
|
||||||
|
} from '../src/js/store.js';
|
||||||
|
import { createSeedTasks } from '../src/js/seed-data.js';
|
||||||
|
|
||||||
|
const base = (id, title, extra = {}) => ({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
status: 'todo',
|
||||||
|
priority: 'medium',
|
||||||
|
progress: 20,
|
||||||
|
createdDate: `2025-01-${String(id).padStart(2, '0')}T00:00:00Z`,
|
||||||
|
dueDate: `2025-01-${String(id).padStart(2, '0')}`,
|
||||||
|
timeline: [],
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => localStorage.clear());
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
describe('task model', () => {
|
||||||
|
it('normalizes fields and language whitelist', () => {
|
||||||
|
const task = normalizeTask({
|
||||||
|
id: 1,
|
||||||
|
title: ' x ',
|
||||||
|
language: 'fr',
|
||||||
|
progress: 999,
|
||||||
|
status: 'bad',
|
||||||
|
});
|
||||||
|
expect(task).toMatchObject({ title: 'x', language: 'zh', progress: 100, status: 'todo' });
|
||||||
|
expect(normalizeTask({ id: 2, title: 'English', language: 'en' }).language).toBe('en');
|
||||||
|
});
|
||||||
|
it('deduplicates task and timeline ids', () => {
|
||||||
|
const tasks = normalizeTasks([
|
||||||
|
base(1, 'a', { timeline: [{ id: 5 }, { id: 5 }] }),
|
||||||
|
base(1, 'b'),
|
||||||
|
]);
|
||||||
|
expect(new Set(tasks.map((task) => task.id)).size).toBe(2);
|
||||||
|
expect(new Set(tasks[0].timeline.map((item) => item.id)).size).toBe(2);
|
||||||
|
});
|
||||||
|
it('drops non-object timeline entries from legacy data', () => {
|
||||||
|
const task = normalizeTask({
|
||||||
|
...base(1, 'dirty timeline'),
|
||||||
|
timeline: ['oops', 42, true, null, [], { id: 7, content: 'valid' }],
|
||||||
|
});
|
||||||
|
expect(task.timeline).toEqual([{ id: 7, content: 'valid', type: 'manual-entry' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('all 13 sorts', () => {
|
||||||
|
const tasks = [
|
||||||
|
base(1, 'Beta 10', {
|
||||||
|
priority: 'medium',
|
||||||
|
progress: 50,
|
||||||
|
dueDate: '2025-03-02',
|
||||||
|
createdDate: '2025-01-02T00:00:00Z',
|
||||||
|
}),
|
||||||
|
base(2, 'Alpha 2', {
|
||||||
|
priority: 'high',
|
||||||
|
progress: 90,
|
||||||
|
dueDate: '2025-03-03',
|
||||||
|
createdDate: '2025-01-03T00:00:00Z',
|
||||||
|
}),
|
||||||
|
base(3, 'Gamma 1', {
|
||||||
|
priority: 'low',
|
||||||
|
progress: 10,
|
||||||
|
dueDate: '2025-03-01',
|
||||||
|
createdDate: '2025-01-01T00:00:00Z',
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const expected = {
|
||||||
|
default: [2, 1, 3],
|
||||||
|
'title-asc': [2, 1, 3],
|
||||||
|
'title-desc': [3, 1, 2],
|
||||||
|
'number-asc': [3, 2, 1],
|
||||||
|
'number-desc': [1, 2, 3],
|
||||||
|
'dueDate-asc': [3, 1, 2],
|
||||||
|
'dueDate-desc': [2, 1, 3],
|
||||||
|
'priority-asc': [3, 1, 2],
|
||||||
|
'priority-desc': [2, 1, 3],
|
||||||
|
'progress-asc': [3, 1, 2],
|
||||||
|
'progress-desc': [2, 1, 3],
|
||||||
|
'created-asc': [3, 1, 2],
|
||||||
|
'created-desc': [2, 1, 3],
|
||||||
|
};
|
||||||
|
it.each(Object.entries(expected))('sorts %s in the expected order', (order, ids) => {
|
||||||
|
const copy = structuredClone(tasks);
|
||||||
|
expect(sortTasks(tasks, order).map((task) => task.id)).toEqual(ids);
|
||||||
|
expect(tasks).toEqual(copy);
|
||||||
|
});
|
||||||
|
it('keeps pinned first for every sort', () => {
|
||||||
|
const pinned = { ...tasks[2], isPinned: true };
|
||||||
|
Object.keys(expected).forEach((order) =>
|
||||||
|
expect(sortTasks([tasks[0], tasks[1], pinned], order)[0].id).toBe(3),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('keeps hidden todo tasks last but leaves hidden in-progress tasks in place', () => {
|
||||||
|
const hiddenTodo = { ...tasks[1], isHidden: true };
|
||||||
|
expect(sortTasks([hiddenTodo, tasks[0]], 'title-asc').map((task) => task.id)).toEqual([1, 2]);
|
||||||
|
const hiddenWork = { ...tasks[1], status: 'inProgress', isHidden: true };
|
||||||
|
expect(sortTasks([hiddenWork, tasks[0]], 'title-asc').map((task) => task.id)).toEqual([2, 1]);
|
||||||
|
});
|
||||||
|
it('keeps tasks without due dates last in both directions', () => {
|
||||||
|
const dated = base(1, 'Dated');
|
||||||
|
const undated = { ...base(2, 'Undated'), dueDate: '' };
|
||||||
|
expect(sortTasks([undated, dated], 'dueDate-asc').map((task) => task.id)).toEqual([1, 2]);
|
||||||
|
expect(sortTasks([undated, dated], 'dueDate-desc').map((task) => task.id)).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('due dates', () => {
|
||||||
|
const now = new Date(2025, 0, 10, 12);
|
||||||
|
it('detects overdue and due-soon boundaries', () => {
|
||||||
|
expect(getDueDateState('2025-01-09', now)).toEqual({ isOverdue: true, isDueSoon: false });
|
||||||
|
expect(getDueDateState('2025-01-10', now)).toEqual({ isOverdue: false, isDueSoon: true });
|
||||||
|
expect(getDueDateState('2025-01-13', now).isDueSoon).toBe(true);
|
||||||
|
expect(getDueDateState('2025-01-14', now).isDueSoon).toBe(false);
|
||||||
|
});
|
||||||
|
it('ignores empty and invalid dates', () => {
|
||||||
|
expect(getDueDateState('', now)).toEqual({ isOverdue: false, isDueSoon: false });
|
||||||
|
expect(getDueDateState('2025-02-30', now)).toEqual({ isOverdue: false, isDueSoon: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('seed data', () => {
|
||||||
|
it('creates five unique tasks at the initialization time', () => {
|
||||||
|
const now = new Date('2026-07-19T02:54:00.000Z');
|
||||||
|
const tasks = createSeedTasks(now);
|
||||||
|
expect(tasks).toHaveLength(5);
|
||||||
|
expect(new Set(tasks.map((task) => task.id)).size).toBe(5);
|
||||||
|
expect(tasks.every((task) => task.createdDate === now.toISOString())).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('storage and store compatibility', () => {
|
||||||
|
it('distinguishes a missing task key from an intentionally empty task list', () => {
|
||||||
|
expect(loadTasksData()).toMatchObject({ tasks: [], corrupted: false, exists: false });
|
||||||
|
localStorage.setItem('tasks', '[]');
|
||||||
|
expect(loadTasksData()).toMatchObject({ tasks: [], corrupted: false, exists: true });
|
||||||
|
});
|
||||||
|
it('preserves corrupted raw data without replacing it with seed tasks', () => {
|
||||||
|
localStorage.setItem('tasks', '{broken');
|
||||||
|
initializeStore([base(1, 'Seed')], false);
|
||||||
|
expect(getTasks()).toEqual([]);
|
||||||
|
expect(localStorage.getItem('tasks')).toBe('{broken');
|
||||||
|
expect(localStorage.getItem('tasksCorruptedBackup')).toBe('{broken');
|
||||||
|
});
|
||||||
|
it('restores all legacy preferences and task fields', () => {
|
||||||
|
localStorage.setItem('tasks', JSON.stringify(legacyBackup.data.tasks));
|
||||||
|
localStorage.setItem('taskSortOrders', JSON.stringify(legacyBackup.data.sortOrders));
|
||||||
|
localStorage.setItem('showHiddenCompletedTasks', 'true');
|
||||||
|
localStorage.setItem('showHiddenTodoTasks', 'false');
|
||||||
|
localStorage.setItem('tasksInitialized', 'true');
|
||||||
|
localStorage.setItem('tasksCorruptedBackup', '{historical backup');
|
||||||
|
initializeStore([], isInitialized());
|
||||||
|
expect(getTasks()).toMatchObject(legacyBackup.data.tasks);
|
||||||
|
expect(getSortOrders()).toEqual(legacyBackup.data.sortOrders);
|
||||||
|
expect(getShowHidden('completed')).toBe(true);
|
||||||
|
expect(getShowHidden('todo')).toBe(false);
|
||||||
|
expect(loadSortOrdersData()).toEqual(legacyBackup.data.sortOrders);
|
||||||
|
expect(loadShowHiddenData('completed')).toBe(true);
|
||||||
|
expect(localStorage.getItem('tasksCorruptedBackup')).toBe('{historical backup');
|
||||||
|
});
|
||||||
|
it('maintains store ids and rejects invalid preference mutations', () => {
|
||||||
|
initializeStore([], true);
|
||||||
|
const first = addTask(base(1, 'First'));
|
||||||
|
const duplicate = addTask(base(1, 'Second'));
|
||||||
|
const [appended] = appendTasks([base(first.id, 'Third')]);
|
||||||
|
expect(new Set([first.id, duplicate.id, appended.id]).size).toBe(3);
|
||||||
|
expect(setSortOrder('invalid', 'title-asc')).toBe(false);
|
||||||
|
expect(setSortOrder('todo', 'invalid')).toBe(false);
|
||||||
|
expect(setShowHidden('inProgress', true)).toBe(false);
|
||||||
|
expect(localStorage.getItem('undefined')).toBeNull();
|
||||||
|
});
|
||||||
|
it('degrades safely when storage access throws', () => {
|
||||||
|
const getItem = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||||
|
throw new DOMException('blocked', 'SecurityError');
|
||||||
|
});
|
||||||
|
expect(loadTasksData()).toMatchObject({ tasks: [], exists: false, available: false });
|
||||||
|
expect(isInitialized()).toBe(false);
|
||||||
|
getItem.mockRestore();
|
||||||
|
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||||
|
throw new DOMException('full', 'QuotaExceededError');
|
||||||
|
});
|
||||||
|
expect(() => initializeStore([base(1, 'Seed')], false)).not.toThrow();
|
||||||
|
expect(consumeStorageWriteFailure()).toBe(true);
|
||||||
|
expect(consumeStorageWriteFailure()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('imports', () => {
|
||||||
|
function loadPending(data) {
|
||||||
|
const original = globalThis.FileReader;
|
||||||
|
class FakeReader {
|
||||||
|
readAsText() {
|
||||||
|
this.result = JSON.stringify(data);
|
||||||
|
this.onload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalThis.FileReader = FakeReader;
|
||||||
|
readImportFile({}, vi.fn(), vi.fn(), vi.fn());
|
||||||
|
globalThis.FileReader = original;
|
||||||
|
}
|
||||||
|
beforeEach(() => {
|
||||||
|
globalThis.bootstrap = {
|
||||||
|
Modal: { getOrCreateInstance: () => ({ hide: vi.fn(), show: vi.fn() }) },
|
||||||
|
};
|
||||||
|
document.body.innerHTML = '<div id="importModal"></div>';
|
||||||
|
initializeStore([], true);
|
||||||
|
});
|
||||||
|
it('validates the legacy export fixture', () => {
|
||||||
|
expect(validateImportData(legacyBackup)).toBe(true);
|
||||||
|
expect(validateImportData({ version: '1.0', data: { tasks: [] } })).toBe(true);
|
||||||
|
});
|
||||||
|
it('rejects unsupported versions, invalid task shapes, and dates', () => {
|
||||||
|
expect(validateImportData(null)).toBe(false);
|
||||||
|
expect(validateImportData({ version: '2.0', data: { tasks: [] } })).toBe(false);
|
||||||
|
expect(validateImportData({ version: '1.0', data: { tasks: 'bad' } })).toBe(false);
|
||||||
|
expect(
|
||||||
|
validateImportData({
|
||||||
|
version: '1.0',
|
||||||
|
data: { tasks: [{ id: 1, title: '', status: 'todo' }] },
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
validateImportData({
|
||||||
|
version: '1.0',
|
||||||
|
data: { tasks: [{ ...base(1, 'Bad date'), dueDate: '2025-02-30' }] },
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
it('deduplicates merge key and preserves missing dates', () => {
|
||||||
|
const existing = [base(1, 'Same')];
|
||||||
|
const result = mergeTasks(existing, [
|
||||||
|
base(2, 'Same', { createdDate: existing[0].createdDate }),
|
||||||
|
{ ...base(3, 'Same'), createdDate: undefined },
|
||||||
|
]);
|
||||||
|
expect(result.skipped).toBe(1);
|
||||||
|
expect(result.tasks).toHaveLength(2);
|
||||||
|
expect(new Set(result.tasks.map((task) => task.id)).size).toBe(2);
|
||||||
|
});
|
||||||
|
it('executes replacement import with legacy tasks and sort orders', () => {
|
||||||
|
addTask(base(1, 'Existing'));
|
||||||
|
loadPending(legacyBackup);
|
||||||
|
expect(executeImport('replace')).toBe(0);
|
||||||
|
expect(getTasks()).toMatchObject(legacyBackup.data.tasks);
|
||||||
|
expect(getSortOrders()).toEqual(legacyBackup.data.sortOrders);
|
||||||
|
});
|
||||||
|
it('executes merge import and reports duplicates', () => {
|
||||||
|
const existing = legacyBackup.data.tasks[0];
|
||||||
|
addTask(existing);
|
||||||
|
loadPending(legacyBackup);
|
||||||
|
expect(executeImport('merge')).toBe(1);
|
||||||
|
expect(getTasks()).toHaveLength(2);
|
||||||
|
});
|
||||||
|
it('rejects an invalid import mode', () => {
|
||||||
|
loadPending(legacyBackup);
|
||||||
|
expect(() => executeImport('unexpected')).toThrow(TypeError);
|
||||||
|
});
|
||||||
|
it('clears a previous pending import when a newly selected file is invalid', () => {
|
||||||
|
loadPending(legacyBackup);
|
||||||
|
loadPending({ version: '1.0', data: { tasks: 'invalid' } });
|
||||||
|
expect(() => executeImport('replace')).toThrow('No import pending');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('timeline entries', () => {
|
||||||
|
beforeEach(() => initializeStore([], true));
|
||||||
|
|
||||||
|
it('ignores fields that the change set omits', () => {
|
||||||
|
const task = addTask(base(1, 'Untouched', { assignee: 'Ada', dueDate: '2025-01-01' }));
|
||||||
|
expect(addSystemEntries(task, { progress: task.progress })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records only the fields that actually changed', () => {
|
||||||
|
const task = addTask(base(1, 'Changed'));
|
||||||
|
const timeline = addSystemEntries(task, { progress: 80, status: task.status });
|
||||||
|
expect(timeline).toHaveLength(1);
|
||||||
|
expect(timeline[0].event).toEqual({ key: 'progressUpdated', values: { from: 20, to: 80 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns an edited system entry into a manual entry owned by the assignee', () => {
|
||||||
|
const task = addTask(base(1, 'Edited', { assignee: 'Ada' }));
|
||||||
|
const timeline = addSystemEntries(task, { progress: 80 });
|
||||||
|
updateTask(task.id, { timeline });
|
||||||
|
expect(updateTimelineEntry(task.id, timeline[0].id, ' my own words ')).toBe(true);
|
||||||
|
const [stored] = getTasks();
|
||||||
|
expect(stored.timeline[0]).toMatchObject({
|
||||||
|
type: 'manual-entry',
|
||||||
|
content: 'my own words',
|
||||||
|
user: 'Ada',
|
||||||
|
});
|
||||||
|
expect(stored.timeline[0].userType).toBeUndefined();
|
||||||
|
expect(stored.timeline[0].event).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the original author when an existing manual entry is edited', () => {
|
||||||
|
const task = addTask(base(1, 'Reassigned', { assignee: 'Ada' }));
|
||||||
|
updateTask(task.id, {
|
||||||
|
timeline: [
|
||||||
|
{ id: 11, date: '2025-01-01', content: 'by bob', type: 'manual-entry', user: 'Bob' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(updateTimelineEntry(task.id, 11, ' typo fixed ')).toBe(true);
|
||||||
|
expect(getTasks()[0].timeline[0]).toMatchObject({ user: 'Bob', content: 'typo fixed' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('board rendering', () => {
|
||||||
|
const fixture = () =>
|
||||||
|
'<input id="searchInput" value="" /><div id="taskBoard">' +
|
||||||
|
STATUSES.map(
|
||||||
|
(status) =>
|
||||||
|
`<span id="${status}Count"></span>` +
|
||||||
|
`<select class="sort-selector" data-status="${status}"></select>` +
|
||||||
|
`<div id="${status}Tasks" class="task-list" data-status="${status}"></div>`,
|
||||||
|
).join('') +
|
||||||
|
HIDEABLE_STATUSES.map(
|
||||||
|
(status) =>
|
||||||
|
`<button data-action="toggle-show-hidden" data-status="${status}"><i></i></button>`,
|
||||||
|
).join('') +
|
||||||
|
'</div>';
|
||||||
|
const visibleTitles = (status) =>
|
||||||
|
[...document.querySelectorAll(`#${status}Tasks .task-card`)]
|
||||||
|
.filter((card) => !card.hidden)
|
||||||
|
.map((card) => card.querySelector('h6').textContent);
|
||||||
|
const entryTexts = (status) =>
|
||||||
|
[...document.querySelectorAll(`#${status}Tasks .timeline-content`)].map((node) =>
|
||||||
|
node.lastElementChild.textContent.trim(),
|
||||||
|
);
|
||||||
|
const search = (term) => {
|
||||||
|
document.querySelector('#searchInput').value = term;
|
||||||
|
applySearchFilter();
|
||||||
|
updateTaskCounts();
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = fixture();
|
||||||
|
initializeStore([], true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists timeline entries newest first within a single day', () => {
|
||||||
|
const task = addTask(base(1, 'Ordered'));
|
||||||
|
['first', 'second', 'third'].forEach((text) => addTimelineEntry(task.id, text));
|
||||||
|
renderColumn('todo');
|
||||||
|
expect(entryTexts('todo')).toEqual(['third', 'second', 'first']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists timeline entries newest first across days', () => {
|
||||||
|
const task = addTask(base(1, 'Ordered'));
|
||||||
|
updateTask(task.id, {
|
||||||
|
timeline: [
|
||||||
|
{ id: 11, date: '2025-01-01', content: 'older', type: 'manual-entry', user: 'Ada' },
|
||||||
|
{ id: 12, date: '2025-06-01', content: 'newer', type: 'manual-entry', user: 'Ada' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
renderColumn('todo');
|
||||||
|
expect(entryTexts('todo')).toEqual(['newer', 'older']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists entries carrying an unusable date last', () => {
|
||||||
|
const task = addTask(base(1, 'Ordered'));
|
||||||
|
updateTask(task.id, {
|
||||||
|
timeline: [
|
||||||
|
{ id: 11, date: 'sometime', content: 'undated', type: 'manual-entry', user: 'Ada' },
|
||||||
|
{ id: 12, date: '2025-01-01', content: 'dated', type: 'manual-entry', user: 'Ada' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
renderColumn('todo');
|
||||||
|
expect(entryTexts('todo')).toEqual(['dated', 'undated']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('searches task content without matching card boilerplate', () => {
|
||||||
|
addTask(base(1, 'Apple', { description: 'crisp fruit', assignee: 'Ada' }));
|
||||||
|
addTask(base(2, 'Banana', { progressNotes: 'still ripening' }));
|
||||||
|
renderColumn('todo');
|
||||||
|
|
||||||
|
search('apple');
|
||||||
|
expect(visibleTitles('todo')).toEqual(['Apple']);
|
||||||
|
search('crisp');
|
||||||
|
expect(visibleTitles('todo')).toEqual(['Apple']);
|
||||||
|
search('ripening');
|
||||||
|
expect(visibleTitles('todo')).toEqual(['Banana']);
|
||||||
|
search('ada');
|
||||||
|
expect(visibleTitles('todo')).toEqual(['Apple']);
|
||||||
|
|
||||||
|
[t('timeline'), t('addEntry'), t('edit'), t('progress')].forEach((label) => {
|
||||||
|
search(label);
|
||||||
|
expect(visibleTitles('todo')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the header count in step with the active search', () => {
|
||||||
|
addTask(base(1, 'Apple'));
|
||||||
|
addTask(base(2, 'Banana'));
|
||||||
|
renderColumn('todo');
|
||||||
|
expect(document.querySelector('#todoCount').textContent).toBe('2');
|
||||||
|
search('apple');
|
||||||
|
expect(document.querySelector('#todoCount').textContent).toBe('1');
|
||||||
|
search('');
|
||||||
|
expect(document.querySelector('#todoCount').textContent).toBe('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves hidden tasks out of the header count while they stay collapsed', () => {
|
||||||
|
addTask(base(1, 'Visible'));
|
||||||
|
addTask(base(2, 'Quiet', { isHidden: true }));
|
||||||
|
renderColumn('todo');
|
||||||
|
expect(document.querySelector('#todoCount').textContent).toBe('1');
|
||||||
|
setShowHidden('todo', true);
|
||||||
|
renderColumn('todo');
|
||||||
|
expect(document.querySelector('#todoCount').textContent).toBe('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('explains a column emptied by the search instead of leaving it blank', () => {
|
||||||
|
addTask(base(1, 'Apple'));
|
||||||
|
renderColumn('todo');
|
||||||
|
search('kiwi');
|
||||||
|
expect(document.querySelector('#todoTasks .search-empty-state').textContent).toBe(
|
||||||
|
t('noSearchResults'),
|
||||||
|
);
|
||||||
|
search('apple');
|
||||||
|
expect(document.querySelector('#todoTasks .search-empty-state')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dims hidden todo tasks but leaves in-progress tasks alone', () => {
|
||||||
|
addTask(base(1, 'Hidden todo', { isHidden: true }));
|
||||||
|
addTask(base(2, 'Hidden work', { status: 'inProgress', isHidden: true }));
|
||||||
|
setShowHidden('todo', true);
|
||||||
|
renderColumn('todo');
|
||||||
|
renderColumn('inProgress');
|
||||||
|
expect(document.querySelector('#todoTasks .task-card').className).toContain('hidden-task');
|
||||||
|
expect(document.querySelector('#inProgressTasks .task-card').className).not.toContain(
|
||||||
|
'hidden-task',
|
||||||
|
);
|
||||||
|
expect(document.querySelector('#inProgressCount').textContent).toBe('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('export', () => {
|
||||||
|
const original = {};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
original.createObjectURL = URL.createObjectURL;
|
||||||
|
original.revokeObjectURL = URL.revokeObjectURL;
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
initializeStore([], true);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
URL.createObjectURL = original.createObjectURL;
|
||||||
|
URL.revokeObjectURL = original.revokeObjectURL;
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicks an attached anchor and revokes the blob url only afterwards', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
addTask(base(1, 'Exported'));
|
||||||
|
URL.createObjectURL = vi.fn(() => 'blob:fake');
|
||||||
|
URL.revokeObjectURL = vi.fn();
|
||||||
|
let connectedAtClick = null;
|
||||||
|
const createElement = document.createElement.bind(document);
|
||||||
|
vi.spyOn(document, 'createElement').mockImplementation((tag) => {
|
||||||
|
const element = createElement(tag);
|
||||||
|
if (tag === 'a') element.click = () => (connectedAtClick = element.isConnected);
|
||||||
|
return element;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exportData()).toMatch(/^backup-tasks-\d{4}-\d{2}-\d{2}\.json$/);
|
||||||
|
expect(connectedAtClick).toBe(true);
|
||||||
|
expect(document.querySelector('a')).toBeNull();
|
||||||
|
expect(URL.revokeObjectURL).not.toHaveBeenCalled();
|
||||||
|
vi.runAllTimers();
|
||||||
|
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:fake');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# 任务管理系统重构 TODO
|
||||||
|
|
||||||
|
> 分支:`dev-refactor` 目标:在**不改变任何现有功能与数据格式**的前提下,把单文件架构重构为模块化、可维护的结构。
|
||||||
|
> 原则:小步提交,每个阶段完成后跑一遍「回归测试清单」再进入下一阶段。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、现状分析
|
||||||
|
|
||||||
|
| 文件 | 行数 | 问题概述 |
|
||||||
|
|------|------|----------|
|
||||||
|
| `src/index.html` | 311 | 内联 onclick、3 份重复的排序下拉、favicon 重复 4 次 |
|
||||||
|
| `src/script.js` | 1788 | 单文件巨石:状态、i18n、渲染、存储、导入导出全部混在一起,约 30 个全局函数 |
|
||||||
|
| `src/styles.css` | 432 | 颜色硬编码、选择器重复定义、存在疑似死代码 |
|
||||||
|
|
||||||
|
### 主要问题清单
|
||||||
|
|
||||||
|
1. **全局可变状态散落**:`tasks`、`currentLang`、`sortOrders`、`showHiddenCompletedTasks`、`showHiddenTodoTasks`、`currentEditingTaskId`、`pendingImportData`、`idCounter` 共 8 个全局变量,任何函数都能直接改。
|
||||||
|
2. **内联事件处理**:HTML 和 JS 模板字符串里大量 `onclick="xxx()"`,迫使所有函数必须挂在全局作用域。
|
||||||
|
3. **成对重复代码**(completed / todo 两套几乎相同的逻辑):
|
||||||
|
- `toggleShowHiddenCompletedTasks` / `toggleShowHiddenTodoTasks`(script.js:1665 / 1728)
|
||||||
|
- `updateShowHiddenCompletedTasksUI` / `updateShowHiddenTodoTasksUI`(script.js:1682 / 1745)
|
||||||
|
- `save/loadShowHiddenCompletedTasks` / `save/loadShowHiddenTodoTasks`(script.js:1708 / 1771)
|
||||||
|
- `renderTasks` 内部对 completed 和 todo 的隐藏任务处理两段几乎一样(script.js:630-667)
|
||||||
|
4. **HTML 重复**:三列的排序 `<select>` 各 13 个 option 完全相同(index.html:72-86 / 99-113 / 132-146);favicon 的 data-URI 重复 4 次(index.html:9-12)。
|
||||||
|
5. **i18n 内嵌**:约 240 行翻译对象直接写在 script.js:162-403,`{count}` 占位符靠手工 `.replace()`。
|
||||||
|
6. **渲染方式**:整列 `innerHTML` 全量重绘,每次保存/通知都调 `renderAllTasks()`,导致时间轴展开状态丢失。
|
||||||
|
7. **职责重叠**:`renderTasks` 和 `updateTaskCounts` 都在写计数 DOM(script.js:640-666 与 831-859),结果一致但逻辑双份。
|
||||||
|
8. **业务种子数据硬编码**:`loadInitialTasks()`(script.js:417-513)内嵌 5 条真实业务任务。
|
||||||
|
9. **排序逻辑重复**:`priorityOrder` 映射定义了 3 次(script.js:562 / 605 / 608),number-asc/desc、priority-asc/desc 成对复制。
|
||||||
|
10. **无工程化设施**:无 ESLint / Prettier / 测试 / 构建脚本。
|
||||||
|
|
||||||
|
### 顺带发现的小 bug / 待改进点(重构中一并处理)
|
||||||
|
|
||||||
|
- [x] `exportData()` 失败时错用 `importError` 文案(script.js:1491)→ 应新增 `exportError` 翻译键
|
||||||
|
- [x] `exportData()` 中残留调试 `console.log`(script.js:1453-1457)→ 删除
|
||||||
|
- [x] 时间轴区块只在 `timeline.length > 0` 时渲染(script.js:793),**没有任何时间轴条目的任务无法手动添加第一条**→ 补充入口
|
||||||
|
- [x] `sortTasks(tasks, ...)` 参数名遮蔽全局 `tasks`(script.js:549)→ 重命名参数
|
||||||
|
- [x] `normalizeTask` 未校验 `language` 字段 → 补充白名单 `['zh','en']`
|
||||||
|
- [x] 每次重渲染后时间轴折叠状态丢失 → 渲染前记录展开的 taskId,渲染后恢复
|
||||||
|
- [x] CSS:`.timeline-item` 重复定义(styles.css:87 与 276)→ 合并;确认 `.status-title`(styles.css:270)、`.tasks-hidden-notice`(styles.css:346)是否未使用,未使用则删除
|
||||||
|
- [x] `saveTask` 附近缩进混乱(script.js:907-1010 有多余空格)→ 统一格式化
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、目标目录结构
|
||||||
|
|
||||||
|
采用**原生 ES Modules,无构建工具**。
|
||||||
|
⚠️ 注意:ES Modules 无法通过 `file://` 直接打开,开发和使用需本地静态服务器(VSCode Live Server 或 `npx serve src`)。如果必须保留「双击 html 直接用」的能力,改用方案 B:保持多个普通 `<script>` 标签按依赖顺序加载(模块拆分方式不变,只是不用 import/export)。**开工前先确认选哪个方案。**
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── index.html
|
||||||
|
├── css/
|
||||||
|
│ └── styles.css # 后续可再拆 base / components / responsive
|
||||||
|
└── js/
|
||||||
|
├── main.js # 入口:DOMContentLoaded 初始化、顶层事件绑定
|
||||||
|
├── config.js # 常量:状态列表、排序方式、优先级映射、localStorage 键名、导出版本号
|
||||||
|
├── utils.js # escapeHtml、formatLocalDate、ID 生成、日期解析
|
||||||
|
├── i18n/
|
||||||
|
│ ├── zh.js
|
||||||
|
│ ├── en.js
|
||||||
|
│ └── index.js # t(key, params) 翻译函数、applyTranslations()、toggleLanguage()
|
||||||
|
├── storage.js # localStorage 统一读写 + 容错(唯一接触 localStorage 的模块)
|
||||||
|
├── store.js # 应用状态(tasks、sortOrders、showHidden…)+ 受控的变更接口
|
||||||
|
├── task-model.js # normalizeTask / normalizeTasks / isValidId / 校验
|
||||||
|
├── sort.js # sortTasks 与各比较器
|
||||||
|
├── render.js # renderAllTasks / renderTasks / createTaskCard / createTimeline / 计数
|
||||||
|
├── modal.js # 任务编辑模态框:打开、填充、保存
|
||||||
|
├── timeline.js # 时间轴 CRUD + 系统条目
|
||||||
|
├── import-export.js # 导出、导入、validateImportData、合并去重
|
||||||
|
└── notify.js # showNotification / checkDueDates
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、重构步骤
|
||||||
|
|
||||||
|
### 阶段 0:准备(半天)
|
||||||
|
|
||||||
|
- [x] 确认模块方案:**A. ES Modules + 本地服务器**(推荐)或 B. 普通脚本多文件
|
||||||
|
- [x] 手工做一次完整功能走查,按「回归测试清单」录一遍基准行为(必要时截图)
|
||||||
|
- [ ] 导出一份当前 localStorage 数据(用现有导出功能生成 backup-tasks-*.json)作为兼容性测试样本
|
||||||
|
- [x] 添加 `.editorconfig`、ESLint(flat config)+ Prettier;先只做检查不大规模改格式
|
||||||
|
- [ ] git tag 一个重构前基线(如 `pre-refactor`)
|
||||||
|
|
||||||
|
### 阶段 1:文件拆分(先搬家,不改逻辑)
|
||||||
|
|
||||||
|
> 本阶段只移动代码 + 加 import/export,函数体一行不改。每搬完一个模块提交一次。
|
||||||
|
|
||||||
|
- [x] 建立 `src/js/`、`src/css/` 目录,index.html 引用路径同步更新
|
||||||
|
- [x] `utils.js` ← script.js:8-69(generateId、escapeHtml、parseDueDateEnd、getDueDateState、isValidId、generateUniqueId)
|
||||||
|
- [x] `task-model.js` ← script.js:71-152(normalizeTask、normalizeTasks、normalizeSortOrders、validStatuses、validSortOrders)
|
||||||
|
- [x] `i18n/zh.js`、`i18n/en.js` ← script.js:162-403 的翻译对象
|
||||||
|
- [x] `storage.js` ← saveTasks/loadTasks(516-538)、saveSortOrders/loadSortOrders(1421-1441)、show-hidden 的 4 个 save/load(1708-1725、1771-1788)
|
||||||
|
- [x] `sort.js` ← sortTasks(549-622)
|
||||||
|
- [x] `render.js` ← renderAllTasks、renderTasks、applySearchFilter、createTaskCard、createTimeline、updateTaskCounts(541-859、1106-1194)
|
||||||
|
- [x] `modal.js` ← openTaskModal、populateForm、updateProgressDisplay、saveTask、editTask、deleteTask(862-1027)
|
||||||
|
- [x] `timeline.js` ← toggleTimeline 至 addSystemTimelineEntry(1197-1381)
|
||||||
|
- [x] `import-export.js` ← exportData 至 executeImport(1444-1662)
|
||||||
|
- [x] `notify.js` ← showNotification(1085-1103)、checkDueDates(1030-1053)
|
||||||
|
- [x] `main.js` ← DOMContentLoaded 初始化(406-414)+ loadInitialTasks(417-513,暂时原样保留)
|
||||||
|
- [x] 过渡措施:内联 onclick 仍需全局函数,在 main.js 里临时 `window.xxx = xxx` 挂出所有被 HTML 引用的函数(阶段 2 已移除,无全局挂载残留)
|
||||||
|
- [x] 删除旧 `src/script.js`,全功能回归一遍(旧脚本已删除,完整人工回归已通过)
|
||||||
|
|
||||||
|
### 阶段 2:去内联事件,改事件委托
|
||||||
|
|
||||||
|
- [x] index.html 中静态元素的 `onclick` / `onchange` / `onkeyup` 全部移除,改在 main.js 里 `addEventListener` 绑定(添加任务、导出、导入、语言切换、搜索、排序下拉、眼睛按钮、保存任务、确认导入、进度滑块、文件 input)
|
||||||
|
- [x] 任务卡片内动态按钮改为 `data-action="pin|hide|edit|delete|toggle-timeline|…"` + `data-task-id` 属性,在三个列容器上做**事件委托**(click 一个监听器搞定)
|
||||||
|
- [x] 时间轴的添加/编辑/删除/键盘事件同样走委托(`data-action` + `data-timeline-id`)
|
||||||
|
- [x] 移除阶段 1 的所有 `window.xxx` 临时挂载
|
||||||
|
- [x] 回归:重点测所有按钮、Enter 添加时间轴、Ctrl+Enter 保存、Esc 取消
|
||||||
|
|
||||||
|
### 阶段 3:消除重复
|
||||||
|
|
||||||
|
- [x] **HTML**:排序 `<select>` 的 13 个 option 改为 JS 根据 `config.js` 中的排序定义生成,三列共用;favicon 只保留一个 `<link>`
|
||||||
|
- [x] **show-hidden 逻辑合并**:completed/todo 两套 toggle/updateUI/save/load 合并为按 status 参数化的一套(状态改为 `showHidden = { completed: false, todo: false }`,localStorage 仍写原有两个键保持兼容)
|
||||||
|
- [x] **renderTasks** 中 completed/todo 的隐藏处理合并为一段参数化逻辑;**计数只由 `updateTaskCounts` 负责**,renderTasks 不再写计数 DOM
|
||||||
|
- [x] **sort.js**:`PRIORITY_ORDER` 常量只定义一次;number/priority/dueDate 的 asc/desc 用「比较器 + 方向系数」实现,消除成对复制
|
||||||
|
- [x] `config.js` 收敛所有魔法值:状态数组、优先级数组、localStorage 键名(`tasks`、`taskSortOrders`、`showHiddenCompletedTasks`、`showHiddenTodoTasks`、`tasksInitialized`、`tasksCorruptedBackup`)、导出版本号 `"1.0"`、到期预警天数 3
|
||||||
|
|
||||||
|
### 阶段 4:状态与存储收敛
|
||||||
|
|
||||||
|
- [x] `store.js`:所有状态私有化,暴露读取接口和语义化变更方法(addTask、updateTask、removeTask、togglePin、toggleHidden、setSortOrder…),变更方法内部统一调用 storage 持久化
|
||||||
|
- [x] `storage.js`:统一 try/catch 容错模式(现在 loadTasks/loadSortOrders/loadShowHidden* 三种写法各不相同),保留「损坏数据备份到 tasksCorruptedBackup」行为
|
||||||
|
- [x] 各模块不再直接改 `tasks` 数组,全部经 store 接口
|
||||||
|
|
||||||
|
### 阶段 5:i18n 完善
|
||||||
|
|
||||||
|
- [x] `t(key, params)` 支持 `{count}` 等占位符插值,替换所有手工 `.replace('{count}', …)`
|
||||||
|
- [x] `applyTranslations()` 抽出(现在的 toggleLanguage 内 data-key 扫描逻辑),初始化时也调用一次(为后续记住语言偏好做准备)
|
||||||
|
- [x] 新增 `exportError` 键,修复导出失败文案 bug
|
||||||
|
- [ ] 可选:语言偏好持久化到 localStorage(新键,注意不影响旧数据)
|
||||||
|
|
||||||
|
### 阶段 6:渲染层改进
|
||||||
|
|
||||||
|
- [x] 渲染前记录已展开时间轴的 taskId 集合,渲染后恢复展开状态(修复现状缺陷)
|
||||||
|
- [x] 无时间轴条目的卡片也渲染「添加条目」入口(修复现状缺陷)
|
||||||
|
- [x] 精细化重渲染:单任务变更(置顶/隐藏/时间轴操作)只重渲染受影响列,而非三列全刷
|
||||||
|
- [x] createTaskCard 保持模板字符串方案即可,但拆出 createPriorityBadge、createDueDateBlock 等小函数,保证每段可读
|
||||||
|
|
||||||
|
### 阶段 7:数据清理
|
||||||
|
|
||||||
|
- [x] `loadInitialTasks` 的 5 条业务种子任务移到 `js/seed-data.js`(保留首次初始化种子行为)
|
||||||
|
- [x] 删除 exportData 中的调试 console.log
|
||||||
|
- [x] `normalizeTask` 补充 language 字段白名单校验
|
||||||
|
|
||||||
|
### 阶段 8:CSS 整理
|
||||||
|
|
||||||
|
- [x] 顶部定义 CSS 自定义属性:主色、三列状态色、优先级色、到期警示色(当前 #007bff / #28a745 / #ffc107 / #dc3545 / #fd7e14 等散落各处)
|
||||||
|
- [x] 合并重复的 `.timeline-item` 定义;grep 验证并删除未使用的 `.status-title`、`.tasks-hidden-notice`、`.show-tasks-btn`
|
||||||
|
- [x] 按「基础 / 任务卡片 / 时间轴 / 模态框 / 响应式」重排分区注释(是否拆成多文件视方案 A/B 决定)
|
||||||
|
|
||||||
|
### 阶段 9:质量保障
|
||||||
|
|
||||||
|
- [x] ESLint + Prettier 全量通过,修掉参数遮蔽(sortTasks)等告警
|
||||||
|
- [x] 纯函数单元测试(Vitest + jsdom,可选但推荐):`normalizeTask`、`normalizeTasks`(ID 去重)、`sortTasks` 全部 13 种排序、`getDueDateState`(过期/临期/边界日)、`validateImportData`、合并导入去重逻辑
|
||||||
|
- [x] 使用版本 1.0 旧格式 fixture 做**导入兼容性测试**(覆盖模式 + 合并模式)
|
||||||
|
- [x] 使用重构前格式的完整 localStorage 快照测试 6 个旧键与任务字段无损恢复
|
||||||
|
|
||||||
|
### 阶段 10:文档收尾
|
||||||
|
|
||||||
|
- [x] 更新 / 新建 README:项目结构说明、本地运行方式(如需服务器要写清楚)、数据存储说明(localStorage 键 + 导出格式)
|
||||||
|
- [x] CHANGELOG 记录重构版本
|
||||||
|
- [x] 合并 `dev-refactor` → `main` 前完整跑一遍下方回归清单
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、回归测试清单(每阶段结束必跑)
|
||||||
|
|
||||||
|
- [x] 添加任务:标题必填校验、进度滑块联动、隐藏勾选生效
|
||||||
|
- [x] 编辑任务:进展/状态/进度/负责人/到期日/优先级变更均自动生成时间轴系统条目
|
||||||
|
- [x] 删除任务:confirm 确认后删除
|
||||||
|
- [x] 三列各 13 种排序均正确,刷新后排序偏好保留;置顶任务在任何排序下都靠前
|
||||||
|
- [x] 搜索:实时过滤三列卡片
|
||||||
|
- [x] 隐藏/显示:卡片眼睛按钮、列头眼睛按钮、计数随显示模式变化、「全部已隐藏」提示文案
|
||||||
|
- [x] 时间轴:展开/收起、添加(按钮 + Enter)、编辑(Ctrl+Enter 保存、Esc 取消)、删除
|
||||||
|
- [x] 导出:文件名 `backup-tasks-yyyy-mm-dd.json`、内容含 version/exportDate/taskCount/tasks/sortOrders
|
||||||
|
- [x] 导入:非法文件报错;信息弹窗显示版本/数量/时间;覆盖模式全替换;合并模式按 标题|状态|创建时间 去重并提示跳过数
|
||||||
|
- [x] 中英切换:所有 data-key 文本、placeholder、页面标题、html lang、卡片内动态文本
|
||||||
|
- [x] 到期提醒:加载时对过期 / 3 天内到期的未完成任务弹通知
|
||||||
|
- [x] 容错:手工向 localStorage 写坏数据 → 提示 + 原始数据备份到 `tasksCorruptedBackup`
|
||||||
|
- [x] 首次访问(清空 localStorage):种子任务加载一次,`tasksInitialized` 置位
|
||||||
|
- [x] 移动端(<768px / <576px):按钮缩放、文字隐藏、操作按钮可点
|
||||||
|
|
||||||
|
## 五、风险与约束
|
||||||
|
|
||||||
|
1. **localStorage 兼容是红线**:6 个键名与数据结构不得变更,老用户数据必须无缝迁移。
|
||||||
|
2. **方案 A(ES Modules)改变使用方式**:`file://` 直接打开会失效,需和使用场景确认后再定。
|
||||||
|
3. **内联事件改造面广**:HTML 静态元素 + JS 动态模板两处都有,漏改一处即功能失效,必须逐区域回归。
|
||||||
|
4. **无自动化测试兜底**(阶段 9 之前):靠回归清单人工保障,所以每阶段步子要小、提交要勤。
|
||||||
Reference in New Issue
Block a user