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