This commit is contained in:
eddy
2026-08-01 02:11:12 +08:00
parent d85032bb09
commit c7c3db42f6
33 changed files with 7135 additions and 1 deletions
+9
View File
@@ -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
View File
@@ -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/
+1
View File
@@ -0,0 +1 @@
{ "singleQuote": true, "trailingComma": "all", "printWidth": 100 }
+8
View File
@@ -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.
+21
View File
@@ -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.
+213 -1
View File
@@ -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`(通常约 510 MB
- 可访问 Bootstrap 和 Font Awesome 的 CDN 资源
## 使用指南
### 基本操作
1. **添加任务**:填写标题、描述、进展、优先级等信息。
2. **编辑任务**:点击任务卡片上的编辑按钮。
3. **状态管理**:编辑任务并更改状态。
4. **进度跟踪**:更新任务进度和进展内容,系统会自动生成时间轴记录。
5. **任务置顶**:点击图钉按钮,使重要任务优先显示。
### 任务隐藏
- **适用范围**:隐藏仅对待办和已完成任务生效;进行中任务即使被标记隐藏也会正常显示、正常排序。
- **单个隐藏**:编辑任务并勾选“隐藏此任务”,或点击卡片上的隐藏按钮。
- **批量查看**:待办栏和已完成栏的眼睛按钮可显示或隐藏被隐藏的任务。
- **排序规则**:被隐藏的待办和已完成任务在排序结果中自动排到非隐藏任务之后。
- **视觉区分**:显示隐藏任务时,卡片以半透明样式呈现。
- **数量统计**:栏目标题的计数与当前实际显示的卡片一致,会随搜索结果变化。
### 数据备份
- 导出的文件名格式为 `backup-tasks-yyyy-mm-dd.json`
- 覆盖导入会替换任务及兼容的排序偏好。
- 合并导入会保留现有任务,并按 `title|status|createdDate` 跳过重复项;新导入任务会分配唯一 ID。
## 技术架构
### 技术栈
- **前端**HTML5、CSS3、JavaScriptES 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, // 0100
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 月
+173
View File
@@ -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 "$@"
+14
View File
@@ -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 } },
},
];
+71
View File
@@ -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).
```
+3655
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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"
}
}
+242
View File
@@ -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
View File
@@ -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>
+31
View File
@@ -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;
+123
View File
@@ -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',
};
+29
View File
@@ -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;
}
+122
View File
@@ -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} 个重复任务',
};
+115
View File
@@ -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
View File
@@ -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();
});
+82
View File
@@ -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 };
}
+30
View File
@@ -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 };
}
+192
View File
@@ -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();
}
+94
View File
@@ -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,
},
];
}
+36
View File
@@ -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);
});
}
+63
View File
@@ -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
View File
@@ -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);
}
+56
View File
@@ -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';
+63
View File
@@ -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;
}
+43
View File
@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
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 };
}
+41
View File
@@ -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、添加软件图标
```
+57
View File
@@ -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"
}
}
}
+505
View File
@@ -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');
});
});
+191
View File
@@ -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` 都在写计数 DOMscript.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`、ESLintflat config+ Prettier;先只做检查不大规模改格式
- [ ] git tag 一个重构前基线(如 `pre-refactor`
### 阶段 1:文件拆分(先搬家,不改逻辑)
> 本阶段只移动代码 + 加 import/export,函数体一行不改。每搬完一个模块提交一次。
- [x] 建立 `src/js/``src/css/` 目录,index.html 引用路径同步更新
- [x] `utils.js` ← script.js:8-69generateId、escapeHtml、parseDueDateEnd、getDueDateState、isValidId、generateUniqueId
- [x] `task-model.js` ← script.js:71-152normalizeTask、normalizeTasks、normalizeSortOrders、validStatuses、validSortOrders
- [x] `i18n/zh.js``i18n/en.js` ← script.js:162-403 的翻译对象
- [x] `storage.js` ← saveTasks/loadTasks516-538)、saveSortOrders/loadSortOrders1421-1441)、show-hidden 的 4 个 save/load1708-1725、1771-1788
- [x] `sort.js` ← sortTasks549-622
- [x] `render.js` ← renderAllTasks、renderTasks、applySearchFilter、createTaskCard、createTimeline、updateTaskCounts541-859、1106-1194
- [x] `modal.js` ← openTaskModal、populateForm、updateProgressDisplay、saveTask、editTask、deleteTask862-1027
- [x] `timeline.js` ← toggleTimeline 至 addSystemTimelineEntry1197-1381
- [x] `import-export.js` ← exportData 至 executeImport1444-1662
- [x] `notify.js` ← showNotification1085-1103)、checkDueDates1030-1053
- [x] `main.js` ← DOMContentLoaded 初始化(406-414+ loadInitialTasks417-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 接口
### 阶段 5i18n 完善
- [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 字段白名单校验
### 阶段 8CSS 整理
- [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. **方案 AES Modules)改变使用方式**`file://` 直接打开会失效,需和使用场景确认后再定。
3. **内联事件改造面广**:HTML 静态元素 + JS 动态模板两处都有,漏改一处即功能失效,必须逐区域回归。
4. **无自动化测试兜底**(阶段 9 之前):靠回归清单人工保障,所以每阶段步子要小、提交要勤。