Compare commits

...
9 Commits
Author SHA1 Message Date
eddy 4997c14bda fix: 改善任务编辑器布局与无障碍体验
- 调整任务编辑器的字号和文本框高度,使其更适合笔记本视口
- 将表单标签与控件正确关联,并应用 Bootstrap 标签样式
- 使用语义化表单元素优化复选框、进度显示和导入模式结构
2026-08-01 14:53:43 +08:00
eddy f014e37325 feat: 刷新任务看板 UI 并改进无障碍体验
- 用 CSS 变量为状态列着色,并新增计数徽章样式
- 迁移至 Font Awesome 6,优化工具栏与卡片布局
- 通过 focus-within 显示操作按钮,并支持减少动画偏好
- 通过 data-key-aria / data-key-title 实现 aria-label 与 title 的国际化
2026-08-01 14:53:38 +08:00
eddy f1c6c052bc refactor: 首次运行为空列表并简化存储初始化
- 移除 seed-data 模块,首次运行改为持久化空任务列表
- 简化 initializeStore(),将 tasksInitialized 处理集中到 store 层
- 移除 loadTasksData() 写入 tasksInitialized 的副作用
- 已归一化的任务数据跳过多余的 localStorage 回写
- 为旧版安装补写 tasksInitialized;首次写入失败时下次访问重试
- 补充存储兼容性测试并更新 README
2026-08-01 14:53:32 +08:00
eddy ebc245da50 fix: 使用服务器绑定地址生成本地访问 URL
- 通过 127.0.0.1 打开 Tasks,避免受到 localhost 旧 Service Worker 和缓存影响
- 更新 Windows 开发服务器文档,使其与实际生成的访问地址一致
2026-08-01 14:53:27 +08:00
eddy f25ab72cb6 feat: 启动开发服务器时自动打开浏览器
- 服务器成功启动后自动打开 Tasks 页面
- 开发服务器已运行时重新打开对应页面
- 无法自动打开浏览器时显示手动访问地址
2026-08-01 14:52:58 +08:00
eddy 9a20add58a feat: 添加 Windows 本地开发服务器控制脚本
- 新增 `serve.py` 脚本,支持启动、停止和查看开发服务器状态
- 更新 README,提供 Windows 用户使用开发服务器的说明
- 脚本检查项目文件完整性,并处理端口占用情况
2026-08-01 14:52:42 +08:00
eddy dadffae5cf remove: 删除 CHANGELOG.md 文件以清理过时内容 2026-08-01 14:52:36 +08:00
eddy 5665684e0f remove: 删除待办与任务管理系统重构相关文档,清理过时内容 2026-08-01 14:52:30 +08:00
eddy f292d4e40c remove: 删除 2025.06-19.md 文件,清理过时内容 2026-08-01 14:52:23 +08:00
17 changed files with 665 additions and 537 deletions
-8
View File
@@ -1,8 +0,0 @@
# 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.
+29 -3
View File
@@ -49,6 +49,31 @@ npm start
打开 `serve` 输出的地址即可使用。由于应用采用原生 ES Modules,必须通过 HTTP 服务器访问,不支持使用 `file://` 直接打开 `src/index.html`
#### Windows 开发服务器脚本
Windows 用户可以使用项目根目录下的 `serve.py` 控制本地开发服务器。服务器默认在 `http://127.0.0.1:8000/` 提供 `src` 中的静态文件,并在启动成功后自动使用默认浏览器打开。
```bash
# 启动服务器
python serve.py start
# 查看运行状态
python serve.py status
# 停止服务器
python serve.py stop
```
如需使用其他端口,通过 `--port` 为每条命令指定相同端口:
```bash
python serve.py start --port 8080
python serve.py status --port 8080
python serve.py stop --port 8080
```
启动时,脚本会检查 `src/index.html``src/css``src/js` 是否存在。如果端口已被其他程序占用,脚本会显示占用进程,并在启动操作中询问是否将其关闭。
也可以使用其他静态服务器:
```bash
@@ -130,7 +155,6 @@ npm run format
- `src/js/timeline.js`:手动及系统时间轴记录
- `src/js/import-export.js`1.0 版备份验证、导入和导出
- `src/js/notify.js`:通知和截止日期提醒
- `src/js/seed-data.js`:首次运行时的示例任务
- `src/js/i18n/`:中英文词典及插值逻辑
- `test/`:单元测试和旧版数据兼容性测试
@@ -142,10 +166,12 @@ npm run format
- `taskSortOrders`
- `showHiddenCompletedTasks`
- `showHiddenTodoTasks`
- `tasksInitialized`
- `tasksInitialized`(历史兼容标记)
- `tasksCorruptedBackup`
任务、排序和显示偏好使用 JSON 编码。若任务列表格式损坏,原始文本会保存到 `tasksCorruptedBackup`。当浏览器存储不可用或空间不足时,应用会在当前页面会话中继续运行,但刷新后内存中的改动会丢失
首次运行时任务列表为空,并会写入 `tasks: []``tasksInitialized` 标记。`tasksInitialized` 仅作历史兼容用途:读取时用于判断是否需要为旧版安装补写,写入则是为了在回退到含示例任务的旧版本时能识别出已初始化状态。从含示例任务的旧版本升级后,已有本地数据保持不变;仅全新安装(本地尚无 `tasks` 键)时以空列表开始
任务、排序和显示偏好使用 JSON 编码。若任务列表格式损坏,原始文本会保存到 `tasksCorruptedBackup`。当浏览器存储不可用时,应用会在当前页面会话中继续运行而不尝试写入,改动在刷新后丢失;当存储可用但空间不足时,首次初始化若写入失败则不会设置 `tasksInitialized`,下次访问会重试写入。
## 备份格式
-71
View File
@@ -1,71 +0,0 @@
## 中文
```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).
```
+186
View File
@@ -0,0 +1,186 @@
"""Tasks 项目的 Windows 本地开发服务器控制脚本。"""
from __future__ import annotations
import argparse
import subprocess
import sys
import time
import webbrowser
from pathlib import Path
ROOT = Path(__file__).resolve().parent
SOURCE_DIR = ROOT / "src"
DEFAULT_PORT = 8000
HOST = "127.0.0.1"
REQUIRED_PATHS = (
SOURCE_DIR / "index.html",
SOURCE_DIR / "css",
SOURCE_DIR / "js",
)
def listener_pid(port: int) -> int | None:
command = (
f"Get-NetTCPConnection -LocalPort {port} -State Listen "
"-ErrorAction SilentlyContinue | "
"Select-Object -First 1 -ExpandProperty OwningProcess"
)
result = subprocess.run(
["powershell", "-NoProfile", "-Command", command],
capture_output=True,
text=True,
check=False,
)
try:
return int(result.stdout.strip())
except ValueError:
return None
def command_line(pid: int) -> str:
command = f"(Get-CimInstance Win32_Process -Filter 'ProcessId = {pid}').CommandLine"
result = subprocess.run(
["powershell", "-NoProfile", "-Command", command],
capture_output=True,
text=True,
check=False,
)
return result.stdout.strip()
def is_tasks_server(pid: int, port: int) -> bool:
command = command_line(pid).lower()
return (
"-m http.server" in command
and str(SOURCE_DIR).lower() in command
and str(port) in command
)
def terminate_process(pid: int) -> bool:
result = subprocess.run(
["taskkill", "/PID", str(pid), "/T", "/F"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
return result.returncode == 0
def validate_project() -> bool:
missing = [path.relative_to(ROOT) for path in REQUIRED_PATHS if not path.exists()]
if not missing:
return True
print("Tasks 项目文件不完整,缺少:")
for path in missing:
print(f" - {path}")
return False
def server_url(port: int) -> str:
return f"http://{HOST}:{port}/"
def open_browser(port: int) -> None:
url = server_url(port)
if not webbrowser.open(url):
print(f"无法自动打开浏览器,请手动访问:{url}")
def start(port: int) -> None:
if not validate_project():
return
listener = listener_pid(port)
if listener:
if is_tasks_server(listener, port):
print(f"Tasks 开发服务器已运行:{server_url(port)}PID {listener}")
open_browser(port)
return
answer = input(
f"端口 {port} 已被其他程序使用(PID {listener})。"
"是否关闭该进程并强制启动?[y/N]: "
).strip().lower()
if answer not in ("y", "yes"):
print("已取消启动。")
return
if not terminate_process(listener):
print(f"无法关闭 PID {listener},请以管理员身份运行终端后重试。")
return
time.sleep(0.3)
if listener_pid(port):
print(f"端口 {port} 仍被占用,无法启动服务。")
return
print(f"已关闭 PID {listener},正在启动 Tasks 开发服务器。")
process = subprocess.Popen(
[
sys.executable,
"-m",
"http.server",
str(port),
"--bind",
HOST,
"--directory",
str(SOURCE_DIR),
],
cwd=ROOT,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
time.sleep(0.5)
if listener_pid(port) == process.pid:
print(f"Tasks 开发服务器已启动:{server_url(port)}PID {process.pid}")
open_browser(port)
else:
print("服务启动失败,请确认 Python 可用且端口未被占用。")
def stop(port: int) -> None:
listener = listener_pid(port)
if not listener or not is_tasks_server(listener, port):
print(f"端口 {port} 上没有运行 Tasks 开发服务器。")
return
if terminate_process(listener):
print("Tasks 开发服务器已停止。")
else:
print(f"无法停止 PID {listener},请以管理员身份运行终端后重试。")
def status(port: int) -> None:
listener = listener_pid(port)
if listener and is_tasks_server(listener, port):
print(f"Tasks 开发服务器运行中:{server_url(port)}PID {listener}")
elif listener:
print(f"端口 {port} 已被其他程序使用(PID {listener})。")
else:
print(f"端口 {port} 上没有运行 Tasks 开发服务器。")
def main() -> None:
parser = argparse.ArgumentParser(description="控制 Tasks 项目的本地开发服务器。")
parser.add_argument(
"command",
choices=("start", "stop", "status"),
help="启动、停止或查看服务器状态",
)
parser.add_argument(
"--port",
type=int,
default=DEFAULT_PORT,
help=f"HTTP 端口(默认:{DEFAULT_PORT}",
)
args = parser.parse_args()
if not 1 <= args.port <= 65535:
parser.error("端口必须在 1 到 65535 之间")
{"start": start, "stop": stop, "status": status}[args.command](args.port)
if __name__ == "__main__":
main()
+249 -18
View File
@@ -21,6 +21,21 @@
--shadow-subtle: #00000012;
--shadow-hover: #0002;
--shadow-pinned: #ffc10744;
--shadow-header: #764ba240;
/* Tint/ink pairs per status: tint backs the count pill, ink keeps text readable on it. */
--todo-tint: #6c757d1f;
--todo-ink: #5c636a;
--progress-tint: #ffc1072e;
--progress-ink: #7a5c03;
--complete-tint: #1987541f;
--complete-ink: #146c43;
/* Status-agnostic ink for neutral count pills; dark enough to clear AA on --timeline. */
--count-ink: #495057;
/* Fallbacks so a .status-column without data-status degrades to neutral instead of
dropping the declaration entirely at computed-value time. */
--status-accent: var(--primary);
--status-tint: var(--todo-tint);
--status-ink: var(--todo-ink);
}
body {
background: var(--background);
@@ -29,14 +44,32 @@ body {
.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;
padding: 0.85rem 1rem;
border-radius: 0.65rem;
margin-bottom: 0.75rem;
box-shadow: 0 4px 14px var(--shadow-header);
}
.task-header h1 {
font-size: 1.8rem;
font-size: 1.5rem;
font-weight: 600;
letter-spacing: 0.02em;
margin: 0;
}
.toolbar {
margin-bottom: 0.75rem;
}
.toolbar .btn,
.toolbar .form-control {
font-size: 0.875rem;
padding-block: 0.4rem;
}
.toolbar .btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
/* Status columns */
.status-column {
background: var(--surface);
border-radius: 1rem;
@@ -44,6 +77,22 @@ body {
box-shadow: 0 2px 12px var(--shadow-subtle);
min-height: 12rem;
}
/* Each column publishes its palette so descendants can theme themselves. */
.status-column[data-status='todo'] {
--status-accent: var(--todo);
--status-tint: var(--todo-tint);
--status-ink: var(--todo-ink);
}
.status-column[data-status='inProgress'] {
--status-accent: var(--progress);
--status-tint: var(--progress-tint);
--status-ink: var(--progress-ink);
}
.status-column[data-status='completed'] {
--status-accent: var(--complete);
--status-tint: var(--complete-tint);
--status-ink: var(--complete-ink);
}
.status-column-header {
display: flex;
align-items: center;
@@ -52,15 +101,49 @@ body {
margin-bottom: 1rem;
}
.status-column-header h4 {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem;
min-width: 0;
font-size: 1rem;
font-weight: 600;
margin: 0;
}
/* Direct child only, so the visibility toggle's own icon keeps its muted colour. */
.status-column-header h4 > i {
color: var(--status-ink);
}
.status-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.6rem;
padding: 0.1rem 0.45rem;
border-radius: 1rem;
background: var(--status-tint);
color: var(--status-ink);
font-size: 0.75rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.sort-selector {
font-size: 0.72rem;
padding: 0.25rem;
padding: 0.25rem 0.4rem;
border: 1px solid var(--timeline);
border-radius: 0.35rem;
max-width: 45%;
background: var(--surface);
color: var(--muted);
cursor: pointer;
transition: border-color 0.15s ease;
}
.sort-selector:hover {
border-color: var(--status-accent);
}
.sort-selector:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 1px;
}
/* Task cards */
@@ -68,7 +151,10 @@ body {
position: relative;
border-left: 4px solid var(--primary);
margin-bottom: 1rem;
transition: 0.2s;
/* Named properties only: `transition: 0.2s` also animated the overdue/due-soon colour flip. */
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.task-card:hover {
transform: translateY(-2px);
@@ -100,24 +186,71 @@ body {
}
.task-actions {
position: absolute;
right: 0.35rem;
top: 0.35rem;
z-index: 2;
right: 0.5rem;
top: 0.5rem;
z-index: 10;
display: flex;
align-items: center;
/* flex discards the inter-tag whitespace the original leaned on, so the gap has to be
declared rather than inherited from the markup's `ms-1` + collapsed space. */
gap: 0.5rem;
padding: 0.125rem;
background: var(--surface-raised);
border-radius: 0.4rem;
border-radius: 0.375rem;
-webkit-backdrop-filter: blur(5px);
backdrop-filter: blur(5px);
opacity: 0;
transition: opacity 0.15s ease;
}
.task-card:hover .task-actions {
/* focus-within keeps the buttons reachable by keyboard, which hover alone never allowed. */
.task-card:hover .task-actions,
.task-card:focus-within .task-actions {
opacity: 1;
}
/* The action bar sits over the title row, so these keep the compact proportions the
original shipped with: a 0.8rem icon inside 6px/8px of padding. */
.task-actions .btn {
padding: 0.25rem 0.4rem;
flex: 0 0 auto;
font-size: 0.8rem;
}
.task-actions .task-action-button {
padding: 0.375rem 0.5rem;
}
.pin-button {
color: var(--muted);
text-decoration: none;
cursor: pointer;
transition: color 0.15s ease;
}
.pin-button:hover,
.pin-button:focus-visible,
.pin-button.pinned {
color: var(--progress);
}
/* The original tinted this button by letting the card's `.pinned` rule leak onto it, which
also gave it a 3px top border and left it standing taller than its neighbours. Keeping the
tint as a background reproduces the look without disturbing the row. */
.pin-button.pinned {
background: var(--progress-tint);
}
.priority-badge {
position: relative;
z-index: 5;
flex: 0 0 auto;
font-size: 0.7rem;
border-radius: 1rem;
padding: 0.2rem 0.5rem;
padding: 0.125rem 0.375rem;
color: var(--surface);
transition:
opacity 0.15s ease,
transform 0.15s ease;
}
/* The action bar overlaps the badge once it fades in, so the badge steps well back
instead of competing with the buttons stacked on top of it. */
.task-card:hover .priority-badge,
.task-card:focus-within .priority-badge {
opacity: 0.3;
transform: translateY(-2px);
}
.priority-low-badge {
background: var(--priority-low);
@@ -140,9 +273,47 @@ body {
overflow-wrap: anywhere;
}
/* Task editor */
/* Compact type scale, scoped to the editor: at the board's default 1rem the form runs
past a laptop viewport and every textarea loses a visible line of text. */
#taskModal .modal-title {
font-size: 1.1rem;
}
#taskModal .form-label,
#taskModal .form-control,
#taskModal .form-select,
#taskModal .form-check-label,
#taskModal .modal-footer .btn {
font-size: 0.85rem;
}
/* Height comes from each textarea's rows attribute; this only stops horizontal
dragging, which would otherwise break the modal grid. */
#taskModal .task-editor-textarea {
resize: vertical;
}
/* Timeline */
.timeline-toggle {
cursor: pointer;
color: var(--muted);
transition: color 0.15s ease;
}
.timeline-toggle:hover {
color: var(--primary);
}
.timeline-count {
display: inline-block;
min-width: 1.25rem;
padding: 0 0.35rem;
border-radius: 1rem;
background: var(--timeline);
color: var(--count-ink);
font-size: 0.7rem;
font-variant-numeric: tabular-nums;
text-align: center;
}
.timeline-caret {
margin-left: 0.15rem;
}
.timeline {
position: relative;
@@ -187,8 +358,10 @@ body {
.timeline-item-actions {
float: right;
opacity: 0;
transition: opacity 0.15s ease;
}
.timeline-item:hover .timeline-item-actions {
.timeline-item:hover .timeline-item-actions,
.timeline-item:focus-within .timeline-item-actions {
opacity: 1;
}
@@ -202,6 +375,11 @@ body {
}
.visibility-toggle {
padding: 0.1rem;
color: var(--muted);
text-decoration: none;
}
.visibility-toggle:hover {
color: var(--status-ink);
}
.empty-state {
color: var(--muted);
@@ -211,7 +389,24 @@ body {
/* Responsive */
@media (max-width: 768px) {
.task-actions,
/* Flows inline above the card body, so the raised-surface treatment it needs while
floating would just paint a white block over overdue/due-soon card tints. */
.task-actions {
position: static;
width: fit-content;
margin: 0.5rem 0.5rem 0 auto;
background: transparent;
-webkit-backdrop-filter: none;
backdrop-filter: none;
opacity: 0.9;
}
/* Nothing covers the badge once the action bar flows inline, and a sticky touch :hover
would otherwise leave it stuck at 30% long after the tap. */
.task-card:hover .priority-badge,
.task-card:focus-within .priority-badge {
opacity: 1;
transform: none;
}
.timeline-item-actions {
opacity: 0.9;
}
@@ -224,7 +419,7 @@ body {
}
@media (max-width: 576px) {
.task-header h1 {
font-size: 1.35rem;
font-size: 1.2rem;
}
.status-column-header {
align-items: flex-start;
@@ -232,11 +427,47 @@ body {
.sort-selector {
max-width: 50%;
}
/* Flex centring only here: the enlarged touch target is taller than the icon's line box,
so normal flow would strand the glyph against the top padding. */
.task-actions .btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.25rem;
min-height: 2.25rem;
}
.toolbar .btn span:not(#langBtn) {
display: none;
.toolbar .btn {
position: relative;
justify-content: center;
}
/* Collapse to icon-only, but keep the label readable to screen readers —
`display: none` would strip these buttons of their accessible name. */
.toolbar .btn .toolbar-btn-label {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
border: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
}
@media (prefers-reduced-motion: reduce) {
.task-card,
.task-actions,
.timeline-item-actions,
.priority-badge,
.pin-button,
.timeline-toggle,
.sort-selector {
transition: none;
}
.task-card:hover {
transform: none;
}
.task-card:hover .priority-badge,
.task-card:focus-within .priority-badge {
transform: none;
}
}
+104 -70
View File
@@ -20,20 +20,32 @@
<link href="css/styles.css" rel="stylesheet" />
</head>
<body>
<main class="container-fluid py-3">
<main class="container-fluid p-2 p-lg-3">
<header class="task-header text-center">
<h1><i class="fas fa-tasks"></i> <span data-key="appTitle">任务管理系统</span></h1>
<h1>
<i class="fa-solid fa-list-check"></i> <span data-key="appTitle">任务管理系统</span>
</h1>
</header>
<section class="toolbar row g-2 mb-4">
<section class="toolbar row g-2">
<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>
<i class="fa-solid fa-circle-plus fa-fw"></i>
<span class="toolbar-btn-label" data-key="addTask"></span>
</button>
<button id="exportBtn" class="btn btn-outline-secondary">
<i class="fa-solid fa-file-arrow-down fa-fw"></i>
<span class="toolbar-btn-label" data-key="exportData"></span>
</button>
<button id="importBtn" class="btn btn-outline-secondary">
<i class="fa-solid fa-file-arrow-up fa-fw"></i>
<span class="toolbar-btn-label" data-key="importData"></span>
</button>
<button
id="languageBtn"
class="btn btn-outline-secondary"
data-key-title="switchLanguage"
>
<i class="fa-solid fa-globe fa-fw"></i> <span id="langBtn">EN</span>
</button>
</div>
<div class="col-lg-5">
@@ -43,9 +55,9 @@
id="searchBtn"
class="btn btn-outline-secondary"
type="button"
aria-label="Search"
data-key-aria="search"
>
<i class="fas fa-search"></i>
<i class="fa-solid fa-magnifying-glass fa-fw"></i>
</button>
</div>
</div>
@@ -53,18 +65,17 @@
<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" data-status="todo">
<div class="status-column-header">
<h4>
<i class="fas fa-list"></i> <span data-key="todo"></span> (<span id="todoCount"
>0</span
>)
<i class="fa-solid fa-list-ul fa-fw"></i> <span data-key="todo"></span>
<span id="todoCount" class="status-count">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>
<i class="fa-solid fa-eye fa-fw"></i>
</button>
</h4>
<select class="sort-selector" id="todoSort" data-status="todo"></select>
@@ -73,13 +84,11 @@
</div>
</div>
<div class="col-lg-4">
<div class="status-column">
<div class="status-column" data-status="inProgress">
<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>
<i class="fa-solid fa-hourglass-half fa-fw"></i> <span data-key="inProgress"></span>
<span id="inProgressCount" class="status-count">0</span>
</h4>
<select class="sort-selector" id="inProgressSort" data-status="inProgress"></select>
</div>
@@ -87,19 +96,17 @@
</div>
</div>
<div class="col-lg-4">
<div class="status-column">
<div class="status-column" data-status="completed">
<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
>)
<h4>
<i class="fa-solid fa-circle-check fa-fw"></i> <span data-key="completed"></span>
<span id="completedCount" class="status-count">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>
<i class="fa-solid fa-eye fa-fw"></i>
</button>
</h4>
<select class="sort-selector" id="completedSort" data-status="completed"></select>
@@ -121,11 +128,11 @@
<input type="hidden" id="taskId" />
<div class="row g-3">
<div class="col-md-8">
<label data-key="taskTitleLabel"></label
<label for="taskTitle" class="form-label" data-key="taskTitleLabel"></label
><input id="taskTitle" class="form-control" required />
</div>
<div class="col-md-4">
<label data-key="statusLabel"></label
<label for="taskStatus" class="form-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>
@@ -133,25 +140,33 @@
</select>
</div>
<div class="col-12">
<label data-key="progressNotesLabel"></label
<label
for="taskProgressNotes"
class="form-label"
data-key="progressNotesLabel"
></label
><textarea
id="taskProgressNotes"
class="form-control"
rows="3"
class="form-control task-editor-textarea"
rows="4"
data-key-placeholder="progressNotesPlaceholder"
></textarea>
</div>
<div class="col-12">
<label data-key="descriptionLabel"></label
<label
for="taskDescription"
class="form-label"
data-key="descriptionLabel"
></label
><textarea
id="taskDescription"
class="form-control"
rows="4"
class="form-control task-editor-textarea"
rows="6"
data-key-placeholder="descriptionPlaceholder"
></textarea>
</div>
<div class="col-md-3">
<label data-key="priorityLabel"></label
<label for="taskPriority" class="form-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>
@@ -159,14 +174,14 @@
</select>
</div>
<div class="col-md-3">
<label data-key="languageLabel"></label
<label for="taskLanguage" class="form-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
<label for="taskAssignee" class="form-label" data-key="assigneeLabel"></label
><input
id="taskAssignee"
class="form-control"
@@ -174,11 +189,15 @@
/>
</div>
<div class="col-md-3">
<label data-key="dueDateLabel"></label
<label for="taskDueDate" class="form-label" data-key="dueDateLabel"></label
><input id="taskDueDate" type="date" class="form-control" />
</div>
<div class="col-md-6">
<label data-key="collaboratorsLabel"></label
<label
for="taskCollaborators"
class="form-label"
data-key="collaboratorsLabel"
></label
><input
id="taskCollaborators"
class="form-control"
@@ -186,7 +205,7 @@
/>
</div>
<div class="col-md-6">
<label data-key="progressLabel"></label
<label for="taskProgress" class="form-label" data-key="progressLabel"></label
><input
id="taskProgress"
type="range"
@@ -195,13 +214,18 @@
value="0"
class="form-range"
/>
<div id="progressDisplay" class="text-center">0%</div>
<div class="text-center">
<small id="progressDisplay">0%</small>
</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 class="col-12">
<div class="form-check">
<input id="taskHidden" type="checkbox" class="form-check-input" /><label
for="taskHidden"
class="form-check-label"
data-key="hiddenTaskLabel"
></label>
</div>
</div>
</div>
</form>
@@ -222,26 +246,36 @@
</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>
<fieldset>
<legend class="form-label fs-6" data-key="importMode"></legend>
<div class="form-check">
<input
id="importModeReplace"
name="importMode"
type="radio"
value="replace"
checked
class="form-check-input"
/><label
for="importModeReplace"
class="form-check-label"
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"
class="form-check-label"
data-key="mergeMode"
></label>
</div>
</fieldset>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-bs-dismiss="modal" data-key="cancel"></button
+2
View File
@@ -8,12 +8,14 @@ export default {
exportData: 'Export Data',
importData: 'Import Data',
searchPlaceholder: 'Search tasks...',
search: 'Search',
taskTitleLabel: 'Task Title *',
statusLabel: 'Status',
progressNotesLabel: 'Progress Notes',
descriptionLabel: 'Task Description',
priorityLabel: 'Priority',
languageLabel: 'Language',
switchLanguage: 'Switch language',
assigneeLabel: 'Assignee',
dueDateLabel: 'Due Date',
collaboratorsLabel: 'Collaborators',
+9
View File
@@ -19,6 +19,15 @@ export function applyTranslations(root = document) {
root.querySelectorAll('[data-key-placeholder]').forEach((element) => {
element.placeholder = t(element.dataset.keyPlaceholder);
});
// aria-label replaces the accessible name outright, so only use it on icon-only controls.
root.querySelectorAll('[data-key-aria]').forEach((element) => {
element.setAttribute('aria-label', t(element.dataset.keyAria));
});
// title only fills in as the accessible name when nothing else provides one, so it is the
// safe choice for controls that already show visible text (WCAG 2.5.3 Label in Name).
root.querySelectorAll('[data-key-title]').forEach((element) => {
element.title = t(element.dataset.keyTitle);
});
const button = document.querySelector('#langBtn');
if (button) button.textContent = language === 'zh' ? 'EN' : '中文';
}
+2
View File
@@ -8,12 +8,14 @@ export default {
exportData: '导出数据',
importData: '导入数据',
searchPlaceholder: '搜索任务...',
search: '搜索',
taskTitleLabel: '任务标题 *',
statusLabel: '状态',
progressNotesLabel: '任务进展',
descriptionLabel: '任务描述',
priorityLabel: '优先级',
languageLabel: '语言',
switchLanguage: '切换语言',
assigneeLabel: '负责人',
dueDateLabel: '到期日期',
collaboratorsLabel: '协助人员',
+2 -3
View File
@@ -12,7 +12,6 @@ import {
setTimelineExpanded,
updateTaskCounts,
} from './render.js';
import { createSeedTasks } from './seed-data.js';
import {
getShowHidden,
getTasks,
@@ -24,7 +23,7 @@ import {
togglePin,
wasCorrupted,
} from './store.js';
import { consumeStorageWriteFailure, isInitialized } from './storage.js';
import { consumeStorageWriteFailure } from './storage.js';
import { addTimelineEntry, deleteTimelineEntry, updateTimelineEntry } from './timeline.js';
import { isHiddenTask } from './utils.js';
@@ -256,7 +255,7 @@ function bind() {
});
}
document.addEventListener('DOMContentLoaded', () => {
initializeStore(createSeedTasks(), isInitialized());
initializeStore();
applyTranslations();
renderAllTasks();
bind();
+10 -6
View File
@@ -57,17 +57,20 @@ function createTimeline(task) {
? 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>`,
`<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="fa-solid fa-pen-to-square"></i></button><button class="btn btn-sm btn-outline-danger" data-action="delete-timeline" title="${t('delete')}"><i class="fa-solid 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>`;
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="fa-solid 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>`
? `<button class="btn btn-outline-secondary task-action-button" data-action="hide" title="${t(task.isHidden ? 'showTask' : 'hideTask')}"><i class="fa-solid ${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>`;
const pinClasses = ['btn', 'btn-sm', 'btn-link', 'pin-button', task.isPinned ? 'pinned' : '']
.filter(Boolean)
.join(' ');
return `<div class="task-actions"><button class="${pinClasses}" data-action="pin" title="${t(task.isPinned ? 'unpinTask' : 'pinTask')}"><i class="fa-solid fa-thumbtack"></i></button>${hiddenAction}<button class="btn btn-outline-primary task-action-button" data-action="edit" title="${t('edit')}"><i class="fa-solid fa-pen-to-square"></i></button><button class="btn btn-outline-danger task-action-button" data-action="delete" title="${t('delete')}"><i class="fa-solid fa-trash"></i></button></div>`;
}
function createPriorityBadge(task) {
return `<span class="priority-badge priority-${task.priority}-badge">${t(task.priority)}</span>`;
@@ -134,7 +137,7 @@ function createCard(task) {
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>`;
return `<article class="${classes}" data-task-id="${task.id}">${createTaskActions(task)}<div class="card-body"><div class="d-flex justify-content-between align-items-start mb-2"><h6 class="mb-0">${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="fa-solid fa-clock-rotate-left fa-fw"></i> ${t('timeline')} <span class="timeline-count">${task.timeline.length}</span> <i class="fa-solid fa-chevron-${open ? 'up' : 'down'} timeline-caret"></i></small></div><div class="timeline ${open ? '' : 'd-none'}">${createTimeline(task)}</div></div></article>`;
}
export function updateTaskCounts() {
const tasks = getTasks();
@@ -151,7 +154,8 @@ export function updateTaskCounts() {
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'}`;
button.querySelector('i').className =
`fa-solid fa-fw fa-${getShowHidden(status) ? 'eye-slash' : 'eye'}`;
});
}
function updateSearchEmptyState(list) {
-94
View File
@@ -1,94 +0,0 @@
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,
},
];
}
+6 -5
View File
@@ -36,16 +36,17 @@ function writeJson(key, 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');
if (!available)
return { tasks: [], corrupted: false, exists: false, available: false, raw: null };
if (raw === null)
return { tasks: [], corrupted: false, exists: false, available: true, raw: null };
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 };
return { tasks, corrupted: false, exists: true, available: true, raw };
} catch {
writeRaw(STORAGE_KEYS.corruptedBackup, raw);
return { tasks: [], corrupted: true, exists: true, available: true };
return { tasks: [], corrupted: true, exists: true, available: true, raw };
}
}
export const saveTasksData = (tasks) => writeJson(STORAGE_KEYS.tasks, tasks);
+8 -6
View File
@@ -2,6 +2,7 @@ import { DEFAULT_SORT_ORDERS, HIDEABLE_STATUSES, SORT_VALUES, STATUSES } from '.
import { normalizeSortOrders, normalizeTask, normalizeTasks } from './task-model.js';
import { generateUniqueId } from './utils.js';
import {
isInitialized,
loadShowHiddenData,
loadSortOrdersData,
loadTasksData,
@@ -16,15 +17,16 @@ 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) {
export function initializeStore() {
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();
if (loaded.available && !loaded.corrupted) {
// normalizeTask 以 { ...input } 展开、保留原 key 顺序,故本应用写入的数据可用字符串
// 相等判断“无需回写”;未归一化的旧版数据必然不等,会被归一化后回写。
const unchanged = loaded.exists && loaded.raw === JSON.stringify(tasks);
const writeOk = unchanged || persistTasks();
if (!isInitialized() && (loaded.exists || writeOk)) markInitialized();
}
sortOrders = { ...DEFAULT_SORT_ORDERS, ...normalizeSortOrders(loadSortOrdersData()) };
showHidden = Object.fromEntries(
-41
View File
@@ -1,41 +0,0 @@
### 待办
- 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、添加软件图标
```
+58 -21
View File
@@ -32,7 +32,6 @@ import {
setSortOrder,
updateTask,
} from '../src/js/store.js';
import { createSeedTasks } from '../src/js/seed-data.js';
const base = (id, title, extra = {}) => ({
id,
@@ -46,7 +45,10 @@ const base = (id, title, extra = {}) => ({
...extra,
});
beforeEach(() => localStorage.clear());
beforeEach(() => {
localStorage.clear();
consumeStorageWriteFailure();
});
afterEach(() => vi.restoreAllMocks());
describe('task model', () => {
@@ -153,28 +155,62 @@ describe('due dates', () => {
});
});
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', () => {
it('backfills tasksInitialized for legacy installs that already have tasks', () => {
localStorage.setItem('tasks', JSON.stringify([base(1, 'Legacy')]));
expect(isInitialized()).toBe(false);
initializeStore();
expect(isInitialized()).toBe(true);
expect(getTasks()).toMatchObject([{ id: 1, title: 'Legacy' }]);
});
it('starts with an empty task list on first use', () => {
initializeStore();
expect(getTasks()).toEqual([]);
expect(localStorage.getItem('tasks')).toBe('[]');
expect(isInitialized()).toBe(true);
expect(consumeStorageWriteFailure()).toBe(false);
});
it('skips the redundant rewrite when stored tasks are already normalized', () => {
initializeStore();
addTask(base(1, 'Kept'));
const setItem = vi.spyOn(Storage.prototype, 'setItem');
initializeStore();
const taskWrites = setItem.mock.calls.filter(([key]) => key === 'tasks');
expect(taskWrites).toHaveLength(0);
expect(getTasks()).toMatchObject([{ id: 1, title: 'Kept' }]);
});
it('does not mark initialized when first persist fails', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('full', 'QuotaExceededError');
});
initializeStore();
expect(getTasks()).toEqual([]);
expect(localStorage.getItem('tasks')).toBeNull();
expect(isInitialized()).toBe(false);
expect(consumeStorageWriteFailure()).toBe(true);
});
it('attempts no write at all when storage is unavailable', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new DOMException('blocked', 'SecurityError');
});
const setItem = vi.spyOn(Storage.prototype, 'setItem');
initializeStore();
expect(getTasks()).toEqual([]);
expect(setItem).not.toHaveBeenCalled();
expect(consumeStorageWriteFailure()).toBe(false);
});
it('preserves corrupted raw data without replacing it', () => {
localStorage.setItem('tasks', '{broken');
initializeStore([base(1, 'Seed')], false);
initializeStore();
expect(getTasks()).toEqual([]);
expect(localStorage.getItem('tasks')).toBe('{broken');
expect(localStorage.getItem('tasksCorruptedBackup')).toBe('{broken');
expect(isInitialized()).toBe(false);
});
it('restores all legacy preferences and task fields', () => {
localStorage.setItem('tasks', JSON.stringify(legacyBackup.data.tasks));
@@ -183,8 +219,9 @@ describe('storage and store compatibility', () => {
localStorage.setItem('showHiddenTodoTasks', 'false');
localStorage.setItem('tasksInitialized', 'true');
localStorage.setItem('tasksCorruptedBackup', '{historical backup');
initializeStore([], isInitialized());
initializeStore();
expect(getTasks()).toMatchObject(legacyBackup.data.tasks);
expect(JSON.parse(localStorage.getItem('tasks'))).toEqual(getTasks());
expect(getSortOrders()).toEqual(legacyBackup.data.sortOrders);
expect(getShowHidden('completed')).toBe(true);
expect(getShowHidden('todo')).toBe(false);
@@ -193,7 +230,7 @@ describe('storage and store compatibility', () => {
expect(localStorage.getItem('tasksCorruptedBackup')).toBe('{historical backup');
});
it('maintains store ids and rejects invalid preference mutations', () => {
initializeStore([], true);
initializeStore();
const first = addTask(base(1, 'First'));
const duplicate = addTask(base(1, 'Second'));
const [appended] = appendTasks([base(first.id, 'Third')]);
@@ -213,7 +250,7 @@ describe('storage and store compatibility', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('full', 'QuotaExceededError');
});
expect(() => initializeStore([base(1, 'Seed')], false)).not.toThrow();
expect(() => initializeStore()).not.toThrow();
expect(consumeStorageWriteFailure()).toBe(true);
expect(consumeStorageWriteFailure()).toBe(false);
});
@@ -237,7 +274,7 @@ describe('imports', () => {
Modal: { getOrCreateInstance: () => ({ hide: vi.fn(), show: vi.fn() }) },
};
document.body.innerHTML = '<div id="importModal"></div>';
initializeStore([], true);
initializeStore();
});
it('validates the legacy export fixture', () => {
expect(validateImportData(legacyBackup)).toBe(true);
@@ -296,7 +333,7 @@ describe('imports', () => {
});
describe('timeline entries', () => {
beforeEach(() => initializeStore([], true));
beforeEach(() => initializeStore());
it('ignores fields that the change set omits', () => {
const task = addTask(base(1, 'Untouched', { assignee: 'Ada', dueDate: '2025-01-01' }));
@@ -367,7 +404,7 @@ describe('board rendering', () => {
beforeEach(() => {
document.body.innerHTML = fixture();
initializeStore([], true);
initializeStore();
});
it('lists timeline entries newest first within a single day', () => {
@@ -474,7 +511,7 @@ describe('export', () => {
original.createObjectURL = URL.createObjectURL;
original.revokeObjectURL = URL.revokeObjectURL;
document.body.innerHTML = '';
initializeStore([], true);
initializeStore();
});
afterEach(() => {
URL.createObjectURL = original.createObjectURL;
-191
View File
@@ -1,191 +0,0 @@
# 任务管理系统重构 TODO
> 分支:`dev-refactor` 目标:在**不改变任何现有功能与数据格式**的前提下,把单文件架构重构为模块化、可维护的结构。
> 原则:小步提交,每个阶段完成后跑一遍「回归测试清单」再进入下一阶段。
---
## 一、现状分析
| 文件 | 行数 | 问题概述 |
|------|------|----------|
| `src/index.html` | 311 | 内联 onclick、3 份重复的排序下拉、favicon 重复 4 次 |
| `src/script.js` | 1788 | 单文件巨石:状态、i18n、渲染、存储、导入导出全部混在一起,约 30 个全局函数 |
| `src/styles.css` | 432 | 颜色硬编码、选择器重复定义、存在疑似死代码 |
### 主要问题清单
1. **全局可变状态散落**`tasks``currentLang``sortOrders``showHiddenCompletedTasks``showHiddenTodoTasks``currentEditingTaskId``pendingImportData``idCounter` 共 8 个全局变量,任何函数都能直接改。
2. **内联事件处理**:HTML 和 JS 模板字符串里大量 `onclick="xxx()"`,迫使所有函数必须挂在全局作用域。
3. **成对重复代码**completed / todo 两套几乎相同的逻辑):
- `toggleShowHiddenCompletedTasks` / `toggleShowHiddenTodoTasks`script.js:1665 / 1728
- `updateShowHiddenCompletedTasksUI` / `updateShowHiddenTodoTasksUI`script.js:1682 / 1745
- `save/loadShowHiddenCompletedTasks` / `save/loadShowHiddenTodoTasks`script.js:1708 / 1771
- `renderTasks` 内部对 completed 和 todo 的隐藏任务处理两段几乎一样(script.js:630-667
4. **HTML 重复**:三列的排序 `<select>` 各 13 个 option 完全相同(index.html:72-86 / 99-113 / 132-146);favicon 的 data-URI 重复 4 次(index.html:9-12)。
5. **i18n 内嵌**:约 240 行翻译对象直接写在 script.js:162-403`{count}` 占位符靠手工 `.replace()`
6. **渲染方式**:整列 `innerHTML` 全量重绘,每次保存/通知都调 `renderAllTasks()`,导致时间轴展开状态丢失。
7. **职责重叠**`renderTasks``updateTaskCounts` 都在写计数 DOMscript.js:640-666 与 831-859),结果一致但逻辑双份。
8. **业务种子数据硬编码**`loadInitialTasks()`script.js:417-513)内嵌 5 条真实业务任务。
9. **排序逻辑重复**`priorityOrder` 映射定义了 3 次(script.js:562 / 605 / 608),number-asc/desc、priority-asc/desc 成对复制。
10. **无工程化设施**:无 ESLint / Prettier / 测试 / 构建脚本。
### 顺带发现的小 bug / 待改进点(重构中一并处理)
- [x] `exportData()` 失败时错用 `importError` 文案(script.js:1491)→ 应新增 `exportError` 翻译键
- [x] `exportData()` 中残留调试 `console.log`script.js:1453-1457)→ 删除
- [x] 时间轴区块只在 `timeline.length > 0` 时渲染(script.js:793),**没有任何时间轴条目的任务无法手动添加第一条**→ 补充入口
- [x] `sortTasks(tasks, ...)` 参数名遮蔽全局 `tasks`script.js:549)→ 重命名参数
- [x] `normalizeTask` 未校验 `language` 字段 → 补充白名单 `['zh','en']`
- [x] 每次重渲染后时间轴折叠状态丢失 → 渲染前记录展开的 taskId,渲染后恢复
- [x] CSS`.timeline-item` 重复定义(styles.css:87 与 276)→ 合并;确认 `.status-title`styles.css:270)、`.tasks-hidden-notice`styles.css:346)是否未使用,未使用则删除
- [x] `saveTask` 附近缩进混乱(script.js:907-1010 有多余空格)→ 统一格式化
---
## 二、目标目录结构
采用**原生 ES Modules,无构建工具**。
⚠️ 注意:ES Modules 无法通过 `file://` 直接打开,开发和使用需本地静态服务器(VSCode Live Server 或 `npx serve src`)。如果必须保留「双击 html 直接用」的能力,改用方案 B:保持多个普通 `<script>` 标签按依赖顺序加载(模块拆分方式不变,只是不用 import/export)。**开工前先确认选哪个方案。**
```
src/
├── index.html
├── css/
│ └── styles.css # 后续可再拆 base / components / responsive
└── js/
├── main.js # 入口:DOMContentLoaded 初始化、顶层事件绑定
├── config.js # 常量:状态列表、排序方式、优先级映射、localStorage 键名、导出版本号
├── utils.js # escapeHtml、formatLocalDate、ID 生成、日期解析
├── i18n/
│ ├── zh.js
│ ├── en.js
│ └── index.js # t(key, params) 翻译函数、applyTranslations()、toggleLanguage()
├── storage.js # localStorage 统一读写 + 容错(唯一接触 localStorage 的模块)
├── store.js # 应用状态(tasks、sortOrders、showHidden…)+ 受控的变更接口
├── task-model.js # normalizeTask / normalizeTasks / isValidId / 校验
├── sort.js # sortTasks 与各比较器
├── render.js # renderAllTasks / renderTasks / createTaskCard / createTimeline / 计数
├── modal.js # 任务编辑模态框:打开、填充、保存
├── timeline.js # 时间轴 CRUD + 系统条目
├── import-export.js # 导出、导入、validateImportData、合并去重
└── notify.js # showNotification / checkDueDates
```
---
## 三、重构步骤
### 阶段 0:准备(半天)
- [x] 确认模块方案:**A. ES Modules + 本地服务器**(推荐)或 B. 普通脚本多文件
- [x] 手工做一次完整功能走查,按「回归测试清单」录一遍基准行为(必要时截图)
- [ ] 导出一份当前 localStorage 数据(用现有导出功能生成 backup-tasks-*.json)作为兼容性测试样本
- [x] 添加 `.editorconfig`、ESLintflat config+ Prettier;先只做检查不大规模改格式
- [ ] git tag 一个重构前基线(如 `pre-refactor`
### 阶段 1:文件拆分(先搬家,不改逻辑)
> 本阶段只移动代码 + 加 import/export,函数体一行不改。每搬完一个模块提交一次。
- [x] 建立 `src/js/``src/css/` 目录,index.html 引用路径同步更新
- [x] `utils.js` ← script.js:8-69generateId、escapeHtml、parseDueDateEnd、getDueDateState、isValidId、generateUniqueId
- [x] `task-model.js` ← script.js:71-152normalizeTask、normalizeTasks、normalizeSortOrders、validStatuses、validSortOrders
- [x] `i18n/zh.js``i18n/en.js` ← script.js:162-403 的翻译对象
- [x] `storage.js` ← saveTasks/loadTasks516-538)、saveSortOrders/loadSortOrders1421-1441)、show-hidden 的 4 个 save/load1708-1725、1771-1788
- [x] `sort.js` ← sortTasks549-622
- [x] `render.js` ← renderAllTasks、renderTasks、applySearchFilter、createTaskCard、createTimeline、updateTaskCounts541-859、1106-1194
- [x] `modal.js` ← openTaskModal、populateForm、updateProgressDisplay、saveTask、editTask、deleteTask862-1027
- [x] `timeline.js` ← toggleTimeline 至 addSystemTimelineEntry1197-1381
- [x] `import-export.js` ← exportData 至 executeImport1444-1662
- [x] `notify.js` ← showNotification1085-1103)、checkDueDates1030-1053
- [x] `main.js` ← DOMContentLoaded 初始化(406-414+ loadInitialTasks417-513,暂时原样保留)
- [x] 过渡措施:内联 onclick 仍需全局函数,在 main.js 里临时 `window.xxx = xxx` 挂出所有被 HTML 引用的函数(阶段 2 已移除,无全局挂载残留)
- [x] 删除旧 `src/script.js`,全功能回归一遍(旧脚本已删除,完整人工回归已通过)
### 阶段 2:去内联事件,改事件委托
- [x] index.html 中静态元素的 `onclick` / `onchange` / `onkeyup` 全部移除,改在 main.js 里 `addEventListener` 绑定(添加任务、导出、导入、语言切换、搜索、排序下拉、眼睛按钮、保存任务、确认导入、进度滑块、文件 input)
- [x] 任务卡片内动态按钮改为 `data-action="pin|hide|edit|delete|toggle-timeline|…"` + `data-task-id` 属性,在三个列容器上做**事件委托**(click 一个监听器搞定)
- [x] 时间轴的添加/编辑/删除/键盘事件同样走委托(`data-action` + `data-timeline-id`
- [x] 移除阶段 1 的所有 `window.xxx` 临时挂载
- [x] 回归:重点测所有按钮、Enter 添加时间轴、Ctrl+Enter 保存、Esc 取消
### 阶段 3:消除重复
- [x] **HTML**:排序 `<select>` 的 13 个 option 改为 JS 根据 `config.js` 中的排序定义生成,三列共用;favicon 只保留一个 `<link>`
- [x] **show-hidden 逻辑合并**completed/todo 两套 toggle/updateUI/save/load 合并为按 status 参数化的一套(状态改为 `showHidden = { completed: false, todo: false }`localStorage 仍写原有两个键保持兼容)
- [x] **renderTasks** 中 completed/todo 的隐藏处理合并为一段参数化逻辑;**计数只由 `updateTaskCounts` 负责**renderTasks 不再写计数 DOM
- [x] **sort.js**`PRIORITY_ORDER` 常量只定义一次;number/priority/dueDate 的 asc/desc 用「比较器 + 方向系数」实现,消除成对复制
- [x] `config.js` 收敛所有魔法值:状态数组、优先级数组、localStorage 键名(`tasks``taskSortOrders``showHiddenCompletedTasks``showHiddenTodoTasks``tasksInitialized``tasksCorruptedBackup`)、导出版本号 `"1.0"`、到期预警天数 3
### 阶段 4:状态与存储收敛
- [x] `store.js`:所有状态私有化,暴露读取接口和语义化变更方法(addTask、updateTask、removeTask、togglePin、toggleHidden、setSortOrder…),变更方法内部统一调用 storage 持久化
- [x] `storage.js`:统一 try/catch 容错模式(现在 loadTasks/loadSortOrders/loadShowHidden* 三种写法各不相同),保留「损坏数据备份到 tasksCorruptedBackup」行为
- [x] 各模块不再直接改 `tasks` 数组,全部经 store 接口
### 阶段 5i18n 完善
- [x] `t(key, params)` 支持 `{count}` 等占位符插值,替换所有手工 `.replace('{count}', …)`
- [x] `applyTranslations()` 抽出(现在的 toggleLanguage 内 data-key 扫描逻辑),初始化时也调用一次(为后续记住语言偏好做准备)
- [x] 新增 `exportError` 键,修复导出失败文案 bug
- [ ] 可选:语言偏好持久化到 localStorage(新键,注意不影响旧数据)
### 阶段 6:渲染层改进
- [x] 渲染前记录已展开时间轴的 taskId 集合,渲染后恢复展开状态(修复现状缺陷)
- [x] 无时间轴条目的卡片也渲染「添加条目」入口(修复现状缺陷)
- [x] 精细化重渲染:单任务变更(置顶/隐藏/时间轴操作)只重渲染受影响列,而非三列全刷
- [x] createTaskCard 保持模板字符串方案即可,但拆出 createPriorityBadge、createDueDateBlock 等小函数,保证每段可读
### 阶段 7:数据清理
- [x] `loadInitialTasks` 的 5 条业务种子任务移到 `js/seed-data.js`(保留首次初始化种子行为)
- [x] 删除 exportData 中的调试 console.log
- [x] `normalizeTask` 补充 language 字段白名单校验
### 阶段 8CSS 整理
- [x] 顶部定义 CSS 自定义属性:主色、三列状态色、优先级色、到期警示色(当前 #007bff / #28a745 / #ffc107 / #dc3545 / #fd7e14 等散落各处)
- [x] 合并重复的 `.timeline-item` 定义;grep 验证并删除未使用的 `.status-title``.tasks-hidden-notice``.show-tasks-btn`
- [x] 按「基础 / 任务卡片 / 时间轴 / 模态框 / 响应式」重排分区注释(是否拆成多文件视方案 A/B 决定)
### 阶段 9:质量保障
- [x] ESLint + Prettier 全量通过,修掉参数遮蔽(sortTasks)等告警
- [x] 纯函数单元测试(Vitest + jsdom,可选但推荐):`normalizeTask``normalizeTasks`ID 去重)、`sortTasks` 全部 13 种排序、`getDueDateState`(过期/临期/边界日)、`validateImportData`、合并导入去重逻辑
- [x] 使用版本 1.0 旧格式 fixture 做**导入兼容性测试**(覆盖模式 + 合并模式)
- [x] 使用重构前格式的完整 localStorage 快照测试 6 个旧键与任务字段无损恢复
### 阶段 10:文档收尾
- [x] 更新 / 新建 README:项目结构说明、本地运行方式(如需服务器要写清楚)、数据存储说明(localStorage 键 + 导出格式)
- [x] CHANGELOG 记录重构版本
- [x] 合并 `dev-refactor``main` 前完整跑一遍下方回归清单
---
## 四、回归测试清单(每阶段结束必跑)
- [x] 添加任务:标题必填校验、进度滑块联动、隐藏勾选生效
- [x] 编辑任务:进展/状态/进度/负责人/到期日/优先级变更均自动生成时间轴系统条目
- [x] 删除任务:confirm 确认后删除
- [x] 三列各 13 种排序均正确,刷新后排序偏好保留;置顶任务在任何排序下都靠前
- [x] 搜索:实时过滤三列卡片
- [x] 隐藏/显示:卡片眼睛按钮、列头眼睛按钮、计数随显示模式变化、「全部已隐藏」提示文案
- [x] 时间轴:展开/收起、添加(按钮 + Enter)、编辑(Ctrl+Enter 保存、Esc 取消)、删除
- [x] 导出:文件名 `backup-tasks-yyyy-mm-dd.json`、内容含 version/exportDate/taskCount/tasks/sortOrders
- [x] 导入:非法文件报错;信息弹窗显示版本/数量/时间;覆盖模式全替换;合并模式按 标题|状态|创建时间 去重并提示跳过数
- [x] 中英切换:所有 data-key 文本、placeholder、页面标题、html lang、卡片内动态文本
- [x] 到期提醒:加载时对过期 / 3 天内到期的未完成任务弹通知
- [x] 容错:手工向 localStorage 写坏数据 → 提示 + 原始数据备份到 `tasksCorruptedBackup`
- [x] 首次访问(清空 localStorage):种子任务加载一次,`tasksInitialized` 置位
- [x] 移动端(<768px / <576px):按钮缩放、文字隐藏、操作按钮可点
## 五、风险与约束
1. **localStorage 兼容是红线**:6 个键名与数据结构不得变更,老用户数据必须无缝迁移。
2. **方案 AES Modules)改变使用方式**`file://` 直接打开会失效,需和使用场景确认后再定。
3. **内联事件改造面广**:HTML 静态元素 + JS 动态模板两处都有,漏改一处即功能失效,必须逐区域回归。
4. **无自动化测试兜底**(阶段 9 之前):靠回归清单人工保障,所以每阶段步子要小、提交要勤。