This commit is contained in:
eddy
2026-08-01 02:11:12 +08:00
parent d85032bb09
commit c7c3db42f6
33 changed files with 7135 additions and 1 deletions
+242
View File
@@ -0,0 +1,242 @@
/* Base */
:root {
--primary: #0d6efd;
--todo: #6c757d;
--progress: #ffc107;
--complete: #198754;
--priority-low: #198754;
--priority-medium: #fd7e14;
--priority-high: #dc3545;
--danger: #dc3545;
--warning: #fd7e14;
--surface: #fff;
--surface-raised: #fffffff0;
--background: #f4f6fa;
--overdue-background: #fff5f5;
--due-soon-background: #fff8f0;
--muted: #6c757d;
--timeline: #dee2e6;
--header-start: #667eea;
--header-end: #764ba2;
--shadow-subtle: #00000012;
--shadow-hover: #0002;
--shadow-pinned: #ffc10744;
}
body {
background: var(--background);
font-family: 'Microsoft YaHei', system-ui, sans-serif;
}
.task-header {
background: linear-gradient(135deg, var(--header-start), var(--header-end));
color: var(--surface);
padding: 1.25rem;
border-radius: 1rem;
margin-bottom: 1.5rem;
}
.task-header h1 {
font-size: 1.8rem;
margin: 0;
}
.status-column {
background: var(--surface);
border-radius: 1rem;
padding: 1rem;
box-shadow: 0 2px 12px var(--shadow-subtle);
min-height: 12rem;
}
.status-column-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 1rem;
}
.status-column-header h4 {
font-size: 1rem;
margin: 0;
}
.sort-selector {
font-size: 0.72rem;
padding: 0.25rem;
border: 1px solid var(--timeline);
border-radius: 0.35rem;
max-width: 45%;
}
/* Task cards */
.task-card {
position: relative;
border-left: 4px solid var(--primary);
margin-bottom: 1rem;
transition: 0.2s;
}
.task-card:hover {
transform: translateY(-2px);
box-shadow: 0 5px 14px var(--shadow-hover);
}
.task-card.todo {
border-left-color: var(--todo);
}
.task-card.inProgress {
border-left-color: var(--progress);
}
.task-card.completed {
border-left-color: var(--complete);
}
.task-card.overdue {
border-left-color: var(--danger) !important;
background: var(--overdue-background);
}
.task-card.due-soon {
border-left-color: var(--warning) !important;
background: var(--due-soon-background);
}
.task-card.hidden-task {
opacity: 0.6;
}
.task-card.pinned {
border-top: 3px solid var(--progress);
box-shadow: 0 4px 12px var(--shadow-pinned);
}
.task-actions {
position: absolute;
right: 0.35rem;
top: 0.35rem;
z-index: 2;
background: var(--surface-raised);
border-radius: 0.4rem;
opacity: 0;
}
.task-card:hover .task-actions {
opacity: 1;
}
.task-actions .btn {
padding: 0.25rem 0.4rem;
}
.priority-badge {
font-size: 0.7rem;
border-radius: 1rem;
padding: 0.2rem 0.5rem;
color: var(--surface);
}
.priority-low-badge {
background: var(--priority-low);
}
.priority-medium-badge {
background: var(--priority-medium);
}
.priority-high-badge {
background: var(--priority-high);
}
.progress-custom {
height: 0.45rem;
}
.person-badge {
font-size: 0.75rem;
margin: 0.1rem;
}
.task-description-display {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* Timeline */
.timeline-toggle {
cursor: pointer;
}
.timeline {
position: relative;
padding-left: 1.25rem;
margin-top: 0.75rem;
}
.timeline:before {
content: '';
position: absolute;
left: 0.5rem;
inset-block: 0;
width: 2px;
background: var(--timeline);
}
.timeline-item {
position: relative;
padding-bottom: 0.75rem;
margin-bottom: 0.5rem;
}
.timeline-item:before {
content: '';
position: absolute;
left: -0.72rem;
top: 0.35rem;
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--primary);
}
.timeline-content,
.timeline-form,
.timeline-edit-form {
background: var(--background);
padding: 0.65rem;
border-radius: 0.5rem;
font-size: 0.85rem;
}
.timeline-date {
font-size: 0.75rem;
color: var(--muted);
}
.timeline-item-actions {
float: right;
opacity: 0;
}
.timeline-item:hover .timeline-item-actions {
opacity: 1;
}
/* Overlays and controls */
.notification {
position: fixed;
right: 1rem;
top: 1rem;
z-index: 2000;
max-width: min(28rem, 90vw);
}
.visibility-toggle {
padding: 0.1rem;
}
.empty-state {
color: var(--muted);
text-align: center;
padding: 1rem;
}
/* Responsive */
@media (max-width: 768px) {
.task-actions,
.timeline-item-actions {
opacity: 0.9;
}
.toolbar .btn {
flex: 1;
}
.status-column {
margin-bottom: 0.5rem;
}
}
@media (max-width: 576px) {
.task-header h1 {
font-size: 1.35rem;
}
.status-column-header {
align-items: flex-start;
}
.sort-selector {
max-width: 50%;
}
.task-actions .btn {
min-width: 2.25rem;
min-height: 2.25rem;
}
.toolbar .btn span:not(#langBtn) {
display: none;
}
}
+261
View File
@@ -0,0 +1,261 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>任务管理系统</title>
<link
rel="icon"
type="image/svg+xml"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%230d6efd'%3E%3Cpath d='M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zM7 17l-2-2 1.4-1.4.6.6 3.6-3.6L12 12l-5 5zm12-8h-8V7h8v2z'/%3E%3C/svg%3E"
/>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
rel="stylesheet"
/>
<link
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css"
rel="stylesheet"
/>
<link href="css/styles.css" rel="stylesheet" />
</head>
<body>
<main class="container-fluid py-3">
<header class="task-header text-center">
<h1><i class="fas fa-tasks"></i> <span data-key="appTitle">任务管理系统</span></h1>
</header>
<section class="toolbar row g-2 mb-4">
<div class="col-lg-7 d-flex flex-wrap gap-2">
<button id="addTaskBtn" class="btn btn-primary">
<i class="fas fa-plus"></i> <span data-key="addTask"></span></button
><button id="exportBtn" class="btn btn-success">
<i class="fas fa-download"></i> <span data-key="exportData"></span></button
><button id="importBtn" class="btn btn-warning">
<i class="fas fa-upload"></i> <span data-key="importData"></span></button
><button id="languageBtn" class="btn btn-secondary">
<i class="fas fa-language"></i> <span id="langBtn">EN</span>
</button>
</div>
<div class="col-lg-5">
<div class="input-group">
<input id="searchInput" class="form-control" data-key-placeholder="searchPlaceholder" />
<button
id="searchBtn"
class="btn btn-outline-secondary"
type="button"
aria-label="Search"
>
<i class="fas fa-search"></i>
</button>
</div>
</div>
</section>
<input type="file" id="fileInput" hidden accept=".json" />
<section class="row g-3" id="taskBoard">
<div class="col-lg-4">
<div class="status-column">
<div class="status-column-header">
<h4>
<i class="fas fa-list"></i> <span data-key="todo"></span> (<span id="todoCount"
>0</span
>)
<button
class="visibility-toggle btn btn-sm btn-link"
data-action="toggle-show-hidden"
data-status="todo"
>
<i class="fas fa-eye"></i>
</button>
</h4>
<select class="sort-selector" id="todoSort" data-status="todo"></select>
</div>
<div id="todoTasks" class="task-list" data-status="todo"></div>
</div>
</div>
<div class="col-lg-4">
<div class="status-column">
<div class="status-column-header">
<h4 class="text-warning">
<i class="fas fa-clock"></i> <span data-key="inProgress"></span> (<span
id="inProgressCount"
>0</span
>)
</h4>
<select class="sort-selector" id="inProgressSort" data-status="inProgress"></select>
</div>
<div id="inProgressTasks" class="task-list" data-status="inProgress"></div>
</div>
</div>
<div class="col-lg-4">
<div class="status-column">
<div class="status-column-header">
<h4 class="text-success">
<i class="fas fa-check-circle"></i> <span data-key="completed"></span> (<span
id="completedCount"
>0</span
>)
<button
class="visibility-toggle btn btn-sm btn-link"
data-action="toggle-show-hidden"
data-status="completed"
>
<i class="fas fa-eye"></i>
</button>
</h4>
<select class="sort-selector" id="completedSort" data-status="completed"></select>
</div>
<div id="completedTasks" class="task-list" data-status="completed"></div>
</div>
</div>
</section>
</main>
<div class="modal fade" id="taskModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 id="modalTitle" class="modal-title" data-key="addTask"></h5>
<button class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="taskForm">
<input type="hidden" id="taskId" />
<div class="row g-3">
<div class="col-md-8">
<label data-key="taskTitleLabel"></label
><input id="taskTitle" class="form-control" required />
</div>
<div class="col-md-4">
<label data-key="statusLabel"></label
><select id="taskStatus" class="form-select">
<option value="todo" data-key="todo"></option>
<option value="inProgress" data-key="inProgress"></option>
<option value="completed" data-key="completed"></option>
</select>
</div>
<div class="col-12">
<label data-key="progressNotesLabel"></label
><textarea
id="taskProgressNotes"
class="form-control"
rows="3"
data-key-placeholder="progressNotesPlaceholder"
></textarea>
</div>
<div class="col-12">
<label data-key="descriptionLabel"></label
><textarea
id="taskDescription"
class="form-control"
rows="4"
data-key-placeholder="descriptionPlaceholder"
></textarea>
</div>
<div class="col-md-3">
<label data-key="priorityLabel"></label
><select id="taskPriority" class="form-select">
<option value="low" data-key="lowPriority"></option>
<option value="medium" data-key="mediumPriority"></option>
<option value="high" data-key="highPriority"></option>
</select>
</div>
<div class="col-md-3">
<label data-key="languageLabel"></label
><select id="taskLanguage" class="form-select">
<option value="zh">中文</option>
<option value="en">English</option>
</select>
</div>
<div class="col-md-3">
<label data-key="assigneeLabel"></label
><input
id="taskAssignee"
class="form-control"
data-key-placeholder="assigneePlaceholder"
/>
</div>
<div class="col-md-3">
<label data-key="dueDateLabel"></label
><input id="taskDueDate" type="date" class="form-control" />
</div>
<div class="col-md-6">
<label data-key="collaboratorsLabel"></label
><input
id="taskCollaborators"
class="form-control"
data-key-placeholder="collaboratorsPlaceholder"
/>
</div>
<div class="col-md-6">
<label data-key="progressLabel"></label
><input
id="taskProgress"
type="range"
min="0"
max="100"
value="0"
class="form-range"
/>
<div id="progressDisplay" class="text-center">0%</div>
</div>
<div class="col-12 form-check ms-2">
<input id="taskHidden" type="checkbox" class="form-check-input" /><label
for="taskHidden"
data-key="hiddenTaskLabel"
></label>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-bs-dismiss="modal" data-key="cancel"></button
><button id="saveTaskBtn" class="btn btn-primary" data-key="saveTask"></button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="importModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 data-key="confirmImport"></h5>
<button class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="importInfo"></div>
<label data-key="importMode"></label>
<div class="form-check">
<input
id="importModeReplace"
name="importMode"
type="radio"
value="replace"
checked
class="form-check-input"
/><label for="importModeReplace" data-key="replaceMode"></label>
</div>
<div class="form-check">
<input
id="importModeMerge"
name="importMode"
type="radio"
value="merge"
class="form-check-input"
/><label for="importModeMerge" data-key="mergeMode"></label>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-bs-dismiss="modal" data-key="cancel"></button
><button
id="confirmImportBtn"
class="btn btn-warning"
data-key="confirmImportBtn"
></button>
</div>
</div>
</div>
</div>
<div id="notifications" class="notification"></div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script type="module" src="js/main.js"></script>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
export const STATUSES = ['todo', 'inProgress', 'completed'];
export const HIDEABLE_STATUSES = ['todo', 'completed'];
export const PRIORITIES = ['low', 'medium', 'high'];
export const PRIORITY_ORDER = { low: 1, medium: 2, high: 3 };
export const LANGUAGES = ['zh', 'en'];
export const SORT_OPTIONS = [
['default', 'defaultSort'],
['title-asc', 'titleAsc'],
['title-desc', 'titleDesc'],
['number-asc', 'numberAsc'],
['number-desc', 'numberDesc'],
['dueDate-asc', 'dueDateAsc'],
['dueDate-desc', 'dueDateDesc'],
['priority-asc', 'priorityAsc'],
['priority-desc', 'priorityDesc'],
['progress-asc', 'progressAsc'],
['progress-desc', 'progressDesc'],
['created-desc', 'createdDesc'],
['created-asc', 'createdAsc'],
];
export const SORT_VALUES = new Set(SORT_OPTIONS.map(([value]) => value));
export const DEFAULT_SORT_ORDERS = { todo: 'default', inProgress: 'default', completed: 'default' };
export const STORAGE_KEYS = {
tasks: 'tasks',
sortOrders: 'taskSortOrders',
initialized: 'tasksInitialized',
corruptedBackup: 'tasksCorruptedBackup',
showHidden: { completed: 'showHiddenCompletedTasks', todo: 'showHiddenTodoTasks' },
};
export const EXPORT_VERSION = '1.0';
export const DUE_SOON_DAYS = 3;
+123
View File
@@ -0,0 +1,123 @@
export default {
appTitle: 'Task Management System',
todo: 'To Do',
inProgress: 'In Progress',
completed: 'Completed',
addTask: 'Add Task',
editTask: 'Edit Task',
exportData: 'Export Data',
importData: 'Import Data',
searchPlaceholder: 'Search tasks...',
taskTitleLabel: 'Task Title *',
statusLabel: 'Status',
progressNotesLabel: 'Progress Notes',
descriptionLabel: 'Task Description',
priorityLabel: 'Priority',
languageLabel: 'Language',
assigneeLabel: 'Assignee',
dueDateLabel: 'Due Date',
collaboratorsLabel: 'Collaborators',
progressLabel: 'Progress (%)',
hiddenTaskLabel: 'Hide this task (applies to completed and to-do tasks)',
saveTask: 'Save Task',
cancel: 'Cancel',
progressNotesPlaceholder: 'Record the latest progress and status...',
descriptionPlaceholder: 'Describe the task goals and requirements...',
assigneePlaceholder: 'Enter assignee name',
collaboratorsPlaceholder: 'Separate multiple people with commas',
defaultSort: 'Default Sort',
titleAsc: 'Title A-Z',
titleDesc: 'Title Z-A',
numberAsc: 'Number ↑',
numberDesc: 'Number ↓',
dueDateAsc: 'Due Date ↑',
dueDateDesc: 'Due Date ↓',
priorityAsc: 'Priority ↑ (Low→High)',
priorityDesc: 'Priority ↓ (High→Low)',
progressAsc: 'Progress ↑ (Low→High)',
progressDesc: 'Progress ↓ (High→Low)',
createdDesc: 'Newest First',
createdAsc: 'Oldest First',
lowPriority: 'Low Priority',
mediumPriority: 'Medium Priority',
highPriority: 'High Priority',
low: 'Low',
medium: 'Medium',
high: 'High',
assignee: 'Assignee: ',
collaborators: 'Collaborators: ',
dueDate: 'Due: ',
progress: 'Progress: ',
overdue: 'Overdue',
dueSoon: 'Due Soon',
noTasks: 'No tasks',
noSearchResults: 'No matching tasks',
timeline: 'Timeline',
noTimelineEntries: 'No timeline entries',
addTimelinePlaceholder: 'Add timeline entry...',
add: 'Add',
addEntry: 'Add Entry',
edit: 'Edit',
delete: 'Delete',
save: 'Save',
editContentPlaceholder: 'Edit content...',
keyboardHint: 'Ctrl+Enter to save | Esc to cancel',
systemUser: 'System',
defaultUser: 'User',
taskAdded: 'Task added',
taskUpdated: 'Task updated',
taskDeleted: 'Task deleted',
taskPinned: 'Task pinned',
taskUnpinned: 'Task unpinned',
taskHidden: 'Task hidden',
taskShown: 'Task shown',
pinTask: 'Pin task',
unpinTask: 'Unpin task',
showTask: 'Show task',
hideTask: 'Hide task',
taskTitleRequired: 'Task title cannot be empty',
taskNotFound: 'The task no longer exists',
confirmDelete: 'Are you sure you want to delete this task?',
confirmDeleteTimeline: 'Delete this timeline entry?',
contentRequired: 'Content cannot be empty',
timelineEntryAdded: 'Timeline entry added',
timelineEntryUpdated: 'Timeline entry updated',
timelineEntryDeleted: 'Timeline entry deleted',
statusChanged: 'Status Changed',
progressUpdated: 'Progress Updated',
assigneeChanged: 'Assignee Changed',
dueDateChanged: 'Due Date Changed',
priorityChanged: 'Priority Changed',
progressNotesUpdated: 'Progress Notes Updated',
progressCleared: 'Progress notes cleared',
storageTasksReset: 'Local task data is corrupted; the original data was backed up',
storageWriteFailed:
'Could not save locally; this change is only in memory and will be lost on refresh',
overdueTasks: '{count} task(s) overdue!',
dueSoonTasks: '{count} task(s) due soon!',
allCompletedHidden: 'All completed tasks are hidden ({count})',
allTodoHidden: 'All to-do tasks are hidden ({count})',
showHiddenTasks: 'Show hidden tasks ({count})',
hideHiddenTasks: 'Hide hidden tasks',
showHiddenTitle: 'Show hidden tasks',
hideHiddenTitle: 'Hide hidden tasks',
exportSuccess: 'Data exported successfully',
exportError: 'Export failed',
importSuccess: 'Data imported successfully',
importError: 'Import failed',
invalidFileFormat: 'Invalid file format',
fileReadError: 'File read error',
confirmImport: 'Confirm Import Data',
importMode: 'Import Mode',
replaceMode: 'Replace Existing Data',
replaceModeDesc: 'Will delete all current tasks',
mergeMode: 'Merge Data',
mergeModeDesc: 'Keep existing tasks and add new tasks',
confirmImportBtn: 'Confirm Import',
importFileInfo: 'Import File Information',
fileVersion: 'File Version',
taskCount: 'Task Count',
exportDate: 'Export Date',
currentTaskCount: 'There are currently {count} tasks',
duplicateTasksSkipped: 'Skipped {count} duplicate tasks',
};
+29
View File
@@ -0,0 +1,29 @@
import zh from './zh.js';
import en from './en.js';
const dictionaries = { zh, en };
let language = 'zh';
export const getLanguage = () => language;
export function t(key, params = {}) {
const template = dictionaries[language][key] ?? key;
return Object.entries(params).reduce(
(text, [name, value]) => text.replaceAll(`{${name}}`, String(value)),
template,
);
}
export function applyTranslations(root = document) {
document.documentElement.lang = language === 'zh' ? 'zh-CN' : 'en';
document.title = t('appTitle');
root.querySelectorAll('[data-key]').forEach((element) => {
element.textContent = t(element.dataset.key);
});
root.querySelectorAll('[data-key-placeholder]').forEach((element) => {
element.placeholder = t(element.dataset.keyPlaceholder);
});
const button = document.querySelector('#langBtn');
if (button) button.textContent = language === 'zh' ? 'EN' : '中文';
}
export function toggleLanguage() {
language = language === 'zh' ? 'en' : 'zh';
applyTranslations();
return language;
}
+122
View File
@@ -0,0 +1,122 @@
export default {
appTitle: '任务管理系统',
todo: '待办事项',
inProgress: '进行中',
completed: '已完成',
addTask: '添加任务',
editTask: '编辑任务',
exportData: '导出数据',
importData: '导入数据',
searchPlaceholder: '搜索任务...',
taskTitleLabel: '任务标题 *',
statusLabel: '状态',
progressNotesLabel: '任务进展',
descriptionLabel: '任务描述',
priorityLabel: '优先级',
languageLabel: '语言',
assigneeLabel: '负责人',
dueDateLabel: '到期日期',
collaboratorsLabel: '协助人员',
progressLabel: '进度 (%)',
hiddenTaskLabel: '隐藏此任务(对已完成和待办事项生效)',
saveTask: '保存任务',
cancel: '取消',
progressNotesPlaceholder: '记录任务的最新进展、当前状态等...',
descriptionPlaceholder: '描述任务的目标、要求和基本信息...',
assigneePlaceholder: '输入负责人姓名',
collaboratorsPlaceholder: '用逗号分隔多个人员',
defaultSort: '默认排序',
titleAsc: '标题 A-Z',
titleDesc: '标题 Z-A',
numberAsc: '数字排序 ↑',
numberDesc: '数字排序 ↓',
dueDateAsc: '到期日期 ↑',
dueDateDesc: '到期日期 ↓',
priorityAsc: '优先级 ↑ (低→高)',
priorityDesc: '优先级 ↓ (高→低)',
progressAsc: '进度 ↑ (低→高)',
progressDesc: '进度 ↓ (高→低)',
createdDesc: '最新创建',
createdAsc: '最早创建',
lowPriority: '低优先级',
mediumPriority: '中优先级',
highPriority: '高优先级',
low: '低',
medium: '中',
high: '高',
assignee: '负责人:',
collaborators: '协助:',
dueDate: '到期:',
progress: '进度:',
overdue: '已过期',
dueSoon: '即将到期',
noTasks: '暂无任务',
noSearchResults: '没有匹配的任务',
timeline: '时间轴',
noTimelineEntries: '暂无时间轴记录',
addTimelinePlaceholder: '添加时间轴条目...',
add: '添加',
addEntry: '添加条目',
edit: '编辑',
delete: '删除',
save: '保存',
editContentPlaceholder: '编辑内容...',
keyboardHint: 'Ctrl+Enter保存 | Esc取消',
systemUser: '系统',
defaultUser: '用户',
taskAdded: '任务已添加',
taskUpdated: '任务已更新',
taskDeleted: '任务已删除',
taskPinned: '任务置顶',
taskUnpinned: '取消置顶',
taskHidden: '任务已隐藏',
taskShown: '任务已显示',
pinTask: '置顶任务',
unpinTask: '取消置顶',
showTask: '显示任务',
hideTask: '隐藏任务',
taskTitleRequired: '任务标题不能为空',
taskNotFound: '任务不存在或已被删除',
confirmDelete: '确定要删除这个任务吗?',
confirmDeleteTimeline: '确定要删除这个时间轴条目吗?',
contentRequired: '内容不能为空',
timelineEntryAdded: '时间轴条目已添加',
timelineEntryUpdated: '时间轴条目已更新',
timelineEntryDeleted: '时间轴条目已删除',
statusChanged: '状态变更',
progressUpdated: '进度更新',
assigneeChanged: '负责人变更',
dueDateChanged: '到期日期变更',
priorityChanged: '优先级变更',
progressNotesUpdated: '任务进展更新',
progressCleared: '进展内容已清空',
storageTasksReset: '本地任务数据损坏,无法加载;原始数据已备份',
storageWriteFailed: '无法保存到本地存储;当前修改仅保留在内存中,刷新后会丢失',
overdueTasks: '{count} 个任务已过期!',
dueSoonTasks: '{count} 个任务即将到期!',
allCompletedHidden: '所有已完成任务都已隐藏 ({count} 个)',
allTodoHidden: '所有待办事项都已隐藏 ({count} 个)',
showHiddenTasks: '显示隐藏任务 ({count}个)',
hideHiddenTasks: '隐藏已被隐藏的任务',
showHiddenTitle: '显示被隐藏的任务',
hideHiddenTitle: '隐藏被隐藏的任务',
exportSuccess: '数据导出成功',
exportError: '导出失败',
importSuccess: '数据导入成功',
importError: '导入失败',
invalidFileFormat: '无效的文件格式',
fileReadError: '文件读取失败',
confirmImport: '确认导入数据',
importMode: '导入模式',
replaceMode: '覆盖现有数据',
replaceModeDesc: '将删除当前所有任务',
mergeMode: '合并数据',
mergeModeDesc: '保留现有任务,添加新任务',
confirmImportBtn: '确认导入',
importFileInfo: '导入文件信息',
fileVersion: '文件版本',
taskCount: '任务数量',
exportDate: '导出时间',
currentTaskCount: '当前系统中有 {count} 个任务',
duplicateTasksSkipped: '已跳过 {count} 个重复任务',
};
+115
View File
@@ -0,0 +1,115 @@
import { EXPORT_VERSION, STATUSES } from './config.js';
import { t } from './i18n/index.js';
import { normalizeSortOrders, normalizeTask, normalizeTasks, isValidId } from './task-model.js';
import { getSortOrders, getTasks, replaceSortOrders, replaceTasks } from './store.js';
import { escapeHtml, formatLocalDate, generateUniqueId, parseDueDateEnd } from './utils.js';
const REVOKE_DELAY_MS = 10000;
let pending = null;
export function validateImportData(data) {
if (
!data ||
typeof data !== 'object' ||
data.version !== EXPORT_VERSION ||
!data.data ||
!Array.isArray(data.data.tasks)
)
return false;
return data.data.tasks.every(
(task) =>
task &&
typeof task === 'object' &&
isValidId(task.id) &&
typeof task.title === 'string' &&
task.title.trim() &&
STATUSES.includes(task.status) &&
(task.dueDate == null || task.dueDate === '' || parseDueDateEnd(task.dueDate)),
);
}
export function mergeTasks(existing, incoming) {
const keys = new Set(existing.map((task) => `${task.title}|${task.status}|${task.createdDate}`));
const ids = new Set(existing.map((task) => task.id));
const added = [];
let skipped = 0;
incoming.forEach((raw) => {
const created = typeof raw.createdDate === 'string' ? raw.createdDate : '';
const task = normalizeTask({ ...raw, id: generateUniqueId(ids) });
const key = created ? `${task.title}|${task.status}|${created}` : null;
if (key && keys.has(key)) {
skipped += 1;
return;
}
if (key) keys.add(key);
added.push(task);
});
return { tasks: [...existing, ...added], skipped };
}
export function createExportData(now = new Date()) {
return {
version: EXPORT_VERSION,
exportDate: now.toISOString(),
localDate: formatLocalDate(now),
taskCount: getTasks().length,
data: { tasks: getTasks(), sortOrders: getSortOrders() },
};
}
export function exportData() {
const data = createExportData();
const filename = `backup-tasks-${data.localDate}.json`;
const url = URL.createObjectURL(
new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }),
);
const link = document.createElement('a');
link.href = url;
link.download = filename;
// Firefox only downloads from an anchor in the document, and the blob must outlive the click
// because browsers start the transfer asynchronously.
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), REVOKE_DELAY_MS);
return filename;
}
export function readImportFile(file, onReady, onInvalid, onReadError) {
pending = null;
const reader = new FileReader();
reader.onload = () => {
try {
const data = JSON.parse(reader.result);
if (!validateImportData(data)) {
onInvalid();
return;
}
pending = data;
onReady(data);
} catch {
onInvalid();
}
};
reader.onerror = onReadError;
reader.readAsText(file);
}
export function showImportDialog(data) {
const date =
data.exportDate && !Number.isNaN(Date.parse(data.exportDate))
? new Date(data.exportDate).toLocaleString()
: '-';
document.querySelector('#importInfo').innerHTML =
`<div class="alert alert-info"><strong>${escapeHtml(t('importFileInfo'))}</strong><ul><li>${escapeHtml(t('fileVersion'))}: ${escapeHtml(data.version)}</li><li>${escapeHtml(t('taskCount'))}: ${data.data.tasks.length}</li><li>${escapeHtml(t('exportDate'))}: ${escapeHtml(date)}</li></ul></div><div class="alert alert-warning">${escapeHtml(t('currentTaskCount', { count: getTasks().length }))}</div>`;
bootstrap.Modal.getOrCreateInstance(document.querySelector('#importModal')).show();
}
export function executeImport(mode) {
if (!pending) throw new Error('No import pending');
if (!['replace', 'merge'].includes(mode)) throw new TypeError('Invalid import mode');
let skipped = 0;
if (mode === 'replace') replaceTasks(normalizeTasks(pending.data.tasks));
else {
const merged = mergeTasks(getTasks(), pending.data.tasks);
replaceTasks(merged.tasks);
skipped = merged.skipped;
}
replaceSortOrders(normalizeSortOrders(pending.data.sortOrders));
pending = null;
bootstrap.Modal.getOrCreateInstance(document.querySelector('#importModal')).hide();
return skipped;
}
+266
View File
@@ -0,0 +1,266 @@
import { SORT_VALUES, STATUSES } from './config.js';
import { applyTranslations, t, toggleLanguage } from './i18n/index.js';
import { executeImport, exportData, readImportFile, showImportDialog } from './import-export.js';
import { openTaskModal, saveTask, updateProgressDisplay } from './modal.js';
import { checkDueDates, showNotification } from './notify.js';
import {
applySearchFilter,
isTimelineExpanded,
pruneTaskCaches,
renderAllTasks,
renderColumn,
setTimelineExpanded,
updateTaskCounts,
} from './render.js';
import { createSeedTasks } from './seed-data.js';
import {
getShowHidden,
getTasks,
initializeStore,
removeTask,
setShowHidden,
setSortOrder,
toggleHidden,
togglePin,
wasCorrupted,
} from './store.js';
import { consumeStorageWriteFailure, isInitialized } from './storage.js';
import { addTimelineEntry, deleteTimelineEntry, updateTimelineEntry } from './timeline.js';
import { isHiddenTask } from './utils.js';
function renderAffected(...statuses) {
[...new Set(statuses.filter(Boolean))].forEach(renderColumn);
}
function notifyPersisted(message, type = 'info') {
if (consumeStorageWriteFailure()) {
showNotification(t('storageWriteFailed'), 'danger');
return false;
}
if (message) showNotification(message, type);
return true;
}
function taskAction(event) {
const action = event.target.closest('[data-action]');
if (!action) return;
const card = action.closest('[data-task-id]');
const id = Number(card?.dataset.taskId);
const status = card?.closest('.task-list')?.dataset.status;
if (action.dataset.action === 'toggle-show-hidden') {
const value = !getShowHidden(action.dataset.status);
setShowHidden(action.dataset.status, value);
renderColumn(action.dataset.status);
notifyPersisted(
t(value ? 'showHiddenTasks' : 'hideHiddenTasks', {
count: getTasks().filter(
(task) => task.status === action.dataset.status && isHiddenTask(task),
).length,
}),
);
return;
}
if (!card) return;
const timelineItem = action.closest('[data-timeline-id]');
const timelineId = Number(timelineItem?.dataset.timelineId);
switch (action.dataset.action) {
case 'pin': {
const task = togglePin(id);
if (!task) {
showNotification(t('taskNotFound'), 'warning');
break;
}
renderColumn(status);
notifyPersisted(t(task.isPinned ? 'taskPinned' : 'taskUnpinned'));
break;
}
case 'hide': {
const task = toggleHidden(id);
if (!task) {
showNotification(t('taskNotFound'), 'warning');
break;
}
renderColumn(status);
notifyPersisted(t(task.isHidden ? 'taskHidden' : 'taskShown'));
break;
}
case 'edit':
openTaskModal(id);
break;
case 'delete':
if (confirm(t('confirmDelete'))) {
if (!removeTask(id)) {
showNotification(t('taskNotFound'), 'warning');
break;
}
pruneTaskCaches();
renderColumn(status);
notifyPersisted(t('taskDeleted'), 'warning');
}
break;
case 'toggle-timeline':
setTimelineExpanded(id, !isTimelineExpanded(id));
renderColumn(status);
break;
case 'show-add-timeline': {
const form = action.closest('.timeline').querySelector('.timeline-form');
form.classList.remove('d-none');
form.querySelector('input').focus();
break;
}
case 'cancel-add-timeline':
action.closest('.timeline-form').classList.add('d-none');
break;
case 'add-timeline': {
const input = action.closest('.timeline-form').querySelector('input');
if (addTimelineEntry(id, input.value)) {
setTimelineExpanded(id, true);
renderColumn(status);
notifyPersisted(t('timelineEntryAdded'), 'success');
} else showNotification(t('contentRequired'), 'warning');
break;
}
case 'edit-timeline':
timelineItem.querySelector('.timeline-content').classList.add('d-none');
timelineItem.querySelector('.timeline-edit-form').classList.remove('d-none');
timelineItem.querySelector('textarea').focus();
break;
case 'cancel-timeline':
timelineItem.querySelector('.timeline-content').classList.remove('d-none');
timelineItem.querySelector('.timeline-edit-form').classList.add('d-none');
break;
case 'save-timeline': {
const value = timelineItem.querySelector('textarea').value;
if (updateTimelineEntry(id, timelineId, value)) {
setTimelineExpanded(id, true);
renderColumn(status);
notifyPersisted(t('timelineEntryUpdated'), 'success');
} else showNotification(t('contentRequired'), 'warning');
break;
}
case 'delete-timeline':
if (confirm(t('confirmDeleteTimeline')) && deleteTimelineEntry(id, timelineId)) {
setTimelineExpanded(id, true);
renderColumn(status);
notifyPersisted(t('timelineEntryDeleted'), 'warning');
}
break;
}
}
function bind() {
document.querySelector('#addTaskBtn').addEventListener('click', () => openTaskModal());
document.querySelector('#saveTaskBtn').addEventListener('click', () => {
const result = saveTask();
if (result?.error) {
showNotification(t(result.error), 'warning');
return;
}
if (result) {
renderAffected(result.oldStatus, result.task.status);
notifyPersisted(t(result.oldStatus ? 'taskUpdated' : 'taskAdded'), 'success');
}
});
document.querySelector('#taskProgress').addEventListener('input', updateProgressDisplay);
const runSearch = () => {
applySearchFilter();
updateTaskCounts();
};
document.querySelector('#searchInput').addEventListener('input', runSearch);
document.querySelector('#searchBtn').addEventListener('click', runSearch);
document.querySelector('#languageBtn').addEventListener('click', () => {
toggleLanguage();
renderAllTasks();
});
document.querySelector('#exportBtn').addEventListener('click', () => {
try {
showNotification(`${t('exportSuccess')} (${exportData()})`, 'success');
} catch {
showNotification(t('exportError'), 'danger');
}
});
document
.querySelector('#importBtn')
.addEventListener('click', () => document.querySelector('#fileInput').click());
document.querySelector('#fileInput').addEventListener('change', (event) => {
const file = event.target.files[0];
if (file)
readImportFile(
file,
showImportDialog,
() => showNotification(t('invalidFileFormat'), 'danger'),
() => showNotification(t('fileReadError'), 'danger'),
);
event.target.value = '';
});
document.querySelector('#confirmImportBtn').addEventListener('click', () => {
try {
const mode = document.querySelector('[name="importMode"]:checked').value;
const skipped = executeImport(mode);
pruneTaskCaches();
renderAllTasks();
if (notifyPersisted(t('importSuccess'), 'success') && skipped)
showNotification(t('duplicateTasksSkipped', { count: skipped }));
} catch {
showNotification(t('importError'), 'danger');
}
});
document.querySelector('#taskBoard').addEventListener('click', taskAction);
document.querySelector('#taskBoard').addEventListener('change', (event) => {
if (
event.target.matches('.sort-selector') &&
STATUSES.includes(event.target.dataset.status) &&
SORT_VALUES.has(event.target.value)
) {
setSortOrder(event.target.dataset.status, event.target.value);
renderColumn(event.target.dataset.status);
notifyPersisted();
}
});
document.querySelector('#taskBoard').addEventListener('keydown', (event) => {
const card = event.target.closest('[data-task-id]');
if (!card) return;
const status = card.closest('.task-list').dataset.status;
if (event.key === 'Enter' && event.target.matches('[data-role="timeline-input"]')) {
event.preventDefault();
if (addTimelineEntry(Number(card.dataset.taskId), event.target.value)) {
setTimelineExpanded(Number(card.dataset.taskId), true);
renderColumn(status);
notifyPersisted(t('timelineEntryAdded'), 'success');
} else showNotification(t('contentRequired'), 'warning');
}
if (event.target.matches('[data-role="timeline-edit"]') && event.key === 'Escape') {
event.preventDefault();
event.target.closest('.timeline-edit-form').classList.add('d-none');
event.target
.closest('.timeline-item')
.querySelector('.timeline-content')
.classList.remove('d-none');
}
if (
event.target.matches('[data-role="timeline-edit"]') &&
event.key === 'Enter' &&
event.ctrlKey
) {
event.preventDefault();
const item = event.target.closest('[data-timeline-id]');
if (
updateTimelineEntry(
Number(card.dataset.taskId),
Number(item.dataset.timelineId),
event.target.value,
)
) {
setTimelineExpanded(Number(card.dataset.taskId), true);
renderColumn(status);
notifyPersisted(t('timelineEntryUpdated'), 'success');
} else showNotification(t('contentRequired'), 'warning');
}
});
}
document.addEventListener('DOMContentLoaded', () => {
initializeStore(createSeedTasks(), isInitialized());
applyTranslations();
renderAllTasks();
bind();
if (wasCorrupted()) showNotification(t('storageTasksReset'), 'danger');
notifyPersisted();
checkDueDates();
});
+82
View File
@@ -0,0 +1,82 @@
import { t } from './i18n/index.js';
import { addTask, getTask, getTasks, updateTask } from './store.js';
import { addSystemEntries } from './timeline.js';
import { generateUniqueId } from './utils.js';
const byId = (id) => document.querySelector(`#${id}`);
export function updateProgressDisplay() {
byId('progressDisplay').textContent = `${byId('taskProgress').value}%`;
}
export function openTaskModal(taskId = null) {
const form = byId('taskForm');
form.reset();
byId('taskId').value = taskId ?? '';
const task = taskId ? getTask(taskId) : null;
byId('modalTitle').dataset.key = task ? 'editTask' : 'addTask';
byId('modalTitle').textContent = t(task ? 'editTask' : 'addTask');
if (task) {
byId('taskTitle').value = task.title;
byId('taskDescription').value = task.description;
byId('taskProgressNotes').value = task.progressNotes;
byId('taskStatus').value = task.status;
byId('taskProgress').value = task.progress;
byId('taskAssignee').value = task.assignee;
byId('taskDueDate').value = task.dueDate;
byId('taskCollaborators').value = task.collaborators.join(', ');
byId('taskLanguage').value = task.language;
byId('taskPriority').value = task.priority;
byId('taskHidden').checked = task.isHidden;
} else {
byId('taskPriority').value = 'low';
byId('taskLanguage').value = 'zh';
}
updateProgressDisplay();
bootstrap.Modal.getOrCreateInstance(byId('taskModal')).show();
}
function values() {
return {
title: byId('taskTitle').value.trim(),
description: byId('taskDescription').value,
progressNotes: byId('taskProgressNotes').value,
status: byId('taskStatus').value,
progress: Number(byId('taskProgress').value),
assignee: byId('taskAssignee').value,
dueDate: byId('taskDueDate').value,
collaborators: byId('taskCollaborators')
.value.split(',')
.map((v) => v.trim())
.filter(Boolean),
language: byId('taskLanguage').value,
priority: byId('taskPriority').value,
isHidden: byId('taskHidden').checked,
};
}
export function saveTask() {
const data = values();
const title = byId('taskTitle');
title.setCustomValidity(data.title ? '' : t('taskTitleRequired'));
if (!byId('taskForm').reportValidity()) return null;
const id = Number(byId('taskId').value);
let oldStatus = null;
let task;
if (id) {
const old = getTask(id);
if (!old) return { error: 'taskNotFound' };
oldStatus = old.status;
task = updateTask(id, { ...data, timeline: addSystemEntries(old, data) });
} else {
task = addTask({
id: generateUniqueId(new Set(getTasks().map((item) => item.id))),
...data,
isPinned: false,
createdDate: new Date().toISOString(),
timeline: [],
});
if (data.progressNotes)
task = updateTask(task.id, {
timeline: addSystemEntries({ ...task, progressNotes: '' }, data),
});
}
bootstrap.Modal.getOrCreateInstance(byId('taskModal')).hide();
return { task, oldStatus };
}
+30
View File
@@ -0,0 +1,30 @@
import { t } from './i18n/index.js';
import { getTasks } from './store.js';
import { getDueDateState } from './utils.js';
export function showNotification(message, type = 'info') {
const alert = document.createElement('div');
alert.className = `alert alert-${type} alert-dismissible fade show`;
const text = document.createElement('span');
text.textContent = String(message);
const close = document.createElement('button');
close.className = 'btn-close';
close.dataset.bsDismiss = 'alert';
alert.append(text, close);
document.querySelector('#notifications').append(alert);
setTimeout(() => alert.remove(), 5000);
}
export function checkDueDates(now = new Date()) {
let overdue = 0,
soon = 0;
getTasks().forEach((task) => {
if (!task.dueDate || task.status === 'completed') return;
const state = getDueDateState(task.dueDate, now);
if (state.isOverdue) overdue += 1;
else if (state.isDueSoon) soon += 1;
});
const parts = [];
if (overdue) parts.push(t('overdueTasks', { count: overdue }));
if (soon) parts.push(t('dueSoonTasks', { count: soon }));
if (parts.length) showNotification(parts.join(' '), overdue ? 'danger' : 'warning');
return { overdue, soon };
}
+192
View File
@@ -0,0 +1,192 @@
import { HIDEABLE_STATUSES, SORT_OPTIONS, STATUSES } from './config.js';
import { t } from './i18n/index.js';
import { getShowHidden, getSortOrders, getTasks } from './store.js';
import { sortTasks } from './sort.js';
import { escapeHtml, getDueDateState, isHiddenTask } from './utils.js';
const expanded = new Set();
const searchIndexes = new Map();
export const setTimelineExpanded = (id, value) => (value ? expanded.add(id) : expanded.delete(id));
export const isTimelineExpanded = (id) => expanded.has(id);
export function pruneTaskCaches(taskIds = getTasks().map((task) => task.id)) {
const validIds = new Set(taskIds);
[expanded, searchIndexes].forEach((cache) => {
[...cache.keys()].forEach((id) => {
if (!validIds.has(id)) cache.delete(id);
});
});
}
export function renderSortOptions() {
const orders = getSortOrders();
document.querySelectorAll('.sort-selector').forEach((select) => {
select.innerHTML = SORT_OPTIONS.map(
([value, key]) => `<option value="${value}">${escapeHtml(t(key))}</option>`,
).join('');
select.value = orders[select.dataset.status];
});
}
function timelineContent(item) {
if (!item.event) return String(item.content ?? '');
const values = item.event.values ?? {};
const from = values.from || '-';
const to = values.to || '-';
if (item.event.key === 'progressNotesUpdated') {
const value = String(values.value ?? '').trim();
return value ? `${value.slice(0, 50)}${value.length > 50 ? '...' : ''}` : t('progressCleared');
}
if (item.event.key === 'progressUpdated')
return `${t('progressUpdated')}: ${values.from ?? '-'}% → ${values.to ?? '-'}%`;
if (['statusChanged', 'priorityChanged'].includes(item.event.key))
return `${t(item.event.key)}: ${t(from)}${t(to)}`;
return `${t(item.event.key)}: ${from}${to}`;
}
function entryTime(item) {
// Imported entries can carry an unparsable date; treat those as the oldest instead of comparing NaN.
const time = Date.parse(item.date);
return Number.isNaN(time) ? -Infinity : time;
}
function timelineOrder(a, b) {
// Entry dates have day granularity, so ids (allocated in increasing order) break same-day ties.
const left = entryTime(a);
const right = entryTime(b);
return left === right ? b.id - a.id : right - left;
}
function createTimeline(task) {
const items = [...task.timeline].sort(timelineOrder);
const rows = items.length
? items
.map(
(item) =>
`<div class="timeline-item ${escapeHtml(item.type)}" data-timeline-id="${item.id}"><div class="timeline-item-actions"><button class="btn btn-sm btn-outline-primary" data-action="edit-timeline" title="${t('edit')}"><i class="fas fa-edit"></i></button><button class="btn btn-sm btn-outline-danger" data-action="delete-timeline" title="${t('delete')}"><i class="fas fa-trash"></i></button></div><div class="timeline-content"><div class="timeline-date">${escapeHtml(item.date)} - ${escapeHtml(item.userType === 'system' ? t('systemUser') : item.user)}</div><div>${escapeHtml(timelineContent(item))}</div></div><div class="timeline-edit-form d-none"><textarea class="form-control form-control-sm" data-role="timeline-edit">${escapeHtml(timelineContent(item))}</textarea><button class="btn btn-sm btn-primary mt-1" data-action="save-timeline">${t('save')}</button><button class="btn btn-sm btn-secondary mt-1" data-action="cancel-timeline">${t('cancel')}</button><small class="ms-2 text-muted">${t('keyboardHint')}</small></div></div>`,
)
.join('')
: `<p class="text-muted small">${t('noTimelineEntries')}</p>`;
return `${rows}<div class="timeline-form d-none"><input class="form-control form-control-sm" data-role="timeline-input" placeholder="${t('addTimelinePlaceholder')}"><button class="btn btn-sm btn-primary mt-2" data-action="add-timeline">${t('add')}</button><button class="btn btn-sm btn-secondary mt-2" data-action="cancel-add-timeline">${t('cancel')}</button></div><button class="btn btn-sm btn-outline-primary mt-2" data-action="show-add-timeline"><i class="fas fa-plus"></i> ${t('addEntry')}</button>`;
}
function createTaskActions(task) {
const hiddenAction = HIDEABLE_STATUSES.includes(task.status)
? `<button class="btn btn-outline-secondary" data-action="hide" title="${t(task.isHidden ? 'showTask' : 'hideTask')}"><i class="fas ${task.isHidden ? 'fa-eye' : 'fa-eye-slash'}"></i></button>`
: '';
return `<div class="task-actions"><button class="btn btn-link" data-action="pin" title="${t(task.isPinned ? 'unpinTask' : 'pinTask')}"><i class="fas fa-thumbtack"></i></button>${hiddenAction}<button class="btn btn-outline-primary" data-action="edit" title="${t('edit')}"><i class="fas fa-edit"></i></button><button class="btn btn-outline-danger" data-action="delete" title="${t('delete')}"><i class="fas fa-trash"></i></button></div>`;
}
function createPriorityBadge(task) {
return `<span class="priority-badge priority-${task.priority}-badge">${t(task.priority)}</span>`;
}
function createPeople(task) {
const assignee = task.assignee
? `<div><small>${t('assignee')}</small><span class="badge bg-primary person-badge">${escapeHtml(task.assignee)}</span></div>`
: '';
const collaborators = task.collaborators
.map((name) => `<span class="badge bg-secondary person-badge">${escapeHtml(name)}</span>`)
.join('');
return `${assignee}${collaborators ? `<div><small>${t('collaborators')}</small>${collaborators}</div>` : ''}`;
}
function createProgress(task) {
const notes = task.progressNotes
? `<div class="small text-primary mb-2"><strong>${t('progressNotesLabel')}:</strong><div class="task-description-display">${escapeHtml(task.progressNotes)}</div></div>`
: '';
const bar =
task.progress > 0
? `<div class="progress progress-custom"><div class="progress-bar" style="width:${task.progress}%"></div></div><small>${t('progress')}${task.progress}%</small>`
: '';
return `${notes}${bar}`;
}
function createDueDateBlock(task, due) {
if (!task.dueDate) return '';
const dateClass = due.isOverdue ? 'text-danger' : due.isDueSoon ? 'text-warning' : '';
const badge = due.isOverdue
? `<span class="badge bg-danger">${t('overdue')}</span>`
: due.isDueSoon
? `<span class="badge bg-warning">${t('dueSoon')}</span>`
: '';
return `<div><small>${t('dueDate')}<span class="${dateClass}">${escapeHtml(task.dueDate)}</span></small> ${badge}</div>`;
}
function buildSearchIndex(task) {
return [
task.title,
task.description,
task.progressNotes,
task.assignee,
...task.collaborators,
task.dueDate,
t(task.priority),
...task.timeline.map(timelineContent),
]
.join(' ')
.toLowerCase();
}
function createCard(task) {
// Indexed here so that a rendered card always has a matching entry for applySearchFilter.
searchIndexes.set(task.id, buildSearchIndex(task));
const due = getDueDateState(task.dueDate);
const open = expanded.has(task.id);
const classes = [
'task-card',
'card',
task.status,
`priority-${task.priority}`,
task.isPinned ? 'pinned' : '',
isHiddenTask(task) ? 'hidden-task' : '',
due.isOverdue ? 'overdue' : due.isDueSoon ? 'due-soon' : '',
]
.filter(Boolean)
.join(' ');
const description = task.description
? `<p class="small task-description-display">${escapeHtml(task.description)}</p>`
: '';
return `<article class="${classes}" data-task-id="${task.id}">${createTaskActions(task)}<div class="card-body"><div class="d-flex justify-content-between pe-5"><h6>${escapeHtml(task.title)}</h6>${createPriorityBadge(task)}</div>${description}${createProgress(task)}${createPeople(task)}${createDueDateBlock(task, due)}<div class="timeline-toggle mt-2 pt-2 border-top" data-action="toggle-timeline"><small><i class="fas fa-history"></i> ${t('timeline')} (${task.timeline.length}) <i class="fas fa-chevron-${open ? 'up' : 'down'}"></i></small></div><div class="timeline ${open ? '' : 'd-none'}">${createTimeline(task)}</div></div></article>`;
}
export function updateTaskCounts() {
const tasks = getTasks();
STATUSES.forEach((status) => {
const cards = [...document.querySelectorAll(`#${status}Tasks .task-card`)];
document.querySelector(`#${status}Count`).textContent = cards.filter(
(card) => !card.hidden,
).length;
});
HIDEABLE_STATUSES.forEach((status) => {
const button = document.querySelector(
`[data-action="toggle-show-hidden"][data-status="${status}"]`,
);
const count = tasks.filter((task) => task.status === status && isHiddenTask(task)).length;
button.hidden = count === 0;
button.title = t(getShowHidden(status) ? 'hideHiddenTitle' : 'showHiddenTitle');
button.querySelector('i').className = `fas fa-${getShowHidden(status) ? 'eye-slash' : 'eye'}`;
});
}
function updateSearchEmptyState(list) {
const cards = [...list.querySelectorAll('.task-card')];
const notice = list.querySelector('.search-empty-state');
if (cards.length && cards.every((card) => card.hidden)) {
const node = notice ?? document.createElement('p');
node.className = 'empty-state search-empty-state';
node.textContent = t('noSearchResults');
if (!notice) list.append(node);
} else notice?.remove();
}
export function applySearchFilter(root = document) {
const term = document.querySelector('#searchInput').value.trim().toLowerCase();
root.querySelectorAll('.task-card').forEach((card) => {
const index = searchIndexes.get(Number(card.dataset.taskId)) ?? '';
card.hidden = Boolean(term && !index.includes(term));
});
const lists = root.matches?.('.task-list') ? [root] : [...root.querySelectorAll('.task-list')];
lists.forEach(updateSearchEmptyState);
}
export function renderColumn(status, updateCounts = true) {
const all = getTasks().filter((task) => task.status === status);
const hidden = all.filter(isHiddenTask).length;
const shown = getShowHidden(status) ? all : all.filter((task) => !isHiddenTask(task));
const ordered = sortTasks(shown, getSortOrders()[status]);
const key = status === 'completed' ? 'allCompletedHidden' : 'allTodoHidden';
document.querySelector(`#${status}Tasks`).innerHTML = ordered.length
? ordered.map(createCard).join('')
: `<p class="empty-state">${hidden ? t(key, { count: hidden }) : t('noTasks')}</p>`;
applySearchFilter(document.querySelector(`#${status}Tasks`));
if (updateCounts) updateTaskCounts();
}
export function renderAllTasks() {
STATUSES.forEach((status) => renderColumn(status, false));
updateTaskCounts();
renderSortOptions();
}
+94
View File
@@ -0,0 +1,94 @@
export function createSeedTasks(now = new Date()) {
const createdDate = now.toISOString();
return [
{
id: 1,
title: '所有设备相机参数标准化',
description: '完成所有生产设备的相机参数标准化工作,确保图像采集的一致性和可靠性。',
progressNotes: '目前已完成大部分设备的参数标准化工作,仅剩DieBond贴片相机待确认参数配置。',
status: 'inProgress',
progress: 90,
assignee: '技术团队',
collaborators: [],
dueDate: '2025-02-15',
createdDate,
language: 'zh',
priority: 'high',
isPinned: false,
timeline: [],
isHidden: false,
},
{
id: 2,
title: 'DieBond设备照明优化',
description:
'定制组合光源:同轴光+环形光,采购更窄、更亮的侧面光源,以解决支架白边问题,提升图像质量。',
progressNotes:
'配置方案已与供应商确认,技术参数符合要求。供应商承诺2025年7月4日发货,目前正在生产中。',
status: 'inProgress',
progress: 60,
assignee: '硬件团队',
collaborators: ['供应商'],
dueDate: '2025-07-04',
createdDate,
language: 'zh',
priority: 'medium',
isPinned: true,
timeline: [],
isHidden: false,
},
{
id: 3,
title: 'DieBond和ClipBond软件界面中英文翻译更新',
description: '完成DieBond和ClipBond设备软件界面的中英文翻译工作,支持多语言操作环境。',
progressNotes:
'SG-TATA GUI 的中英文翻译已完成。未来更新杨杰的代码后,需要重新进行翻译(任务量很少)。',
status: 'inProgress',
progress: 80,
assignee: '杨杰',
collaborators: ['翻译团队'],
dueDate: '2025-02-28',
createdDate,
language: 'zh',
priority: 'medium',
isPinned: false,
timeline: [],
isHidden: false,
},
{
id: 4,
title: '所有设备相机手册',
description: '编写所有设备相机的操作手册,包括校准流程、位置引导方法、检测标准等技术文档。',
progressNotes: '任务刚启动,正在收集各设备的相机技术规格和操作要求。',
status: 'todo',
progress: 0,
assignee: '技术文档组',
collaborators: [],
dueDate: '2025-03-31',
createdDate,
language: 'zh',
priority: 'low',
isPinned: false,
timeline: [],
isHidden: false,
},
{
id: 5,
title: '开发 TaTa SECS/GEM & Map 软件',
description:
'开发符合SEMI标准的SECS/GEM通信协议软件,实现设备与MES系统的标准化通信,包括Map数据管理功能。',
progressNotes: '需求分析阶段,正在梳理SECS/GEM协议规范和技术架构设计。',
status: 'todo',
progress: 0,
assignee: '软件开发组',
collaborators: ['系统架构师'],
dueDate: '2025-06-30',
createdDate,
language: 'zh',
priority: 'high',
isPinned: false,
timeline: [],
isHidden: false,
},
];
}
+36
View File
@@ -0,0 +1,36 @@
import { PRIORITY_ORDER, SORT_VALUES } from './config.js';
import { isHiddenTask } from './utils.js';
const number = (task) => Number.parseInt(task.title.match(/\d+/)?.[0], 10) || 0;
const date = (value) => new Date(value).getTime() || 0;
const title = (a, b) => a.title.localeCompare(b.title);
const due = (a, b) => {
if (!a.dueDate && !b.dueDate) return 0;
if (!a.dueDate) return 1;
if (!b.dueDate) return -1;
return date(a.dueDate) - date(b.dueDate);
};
const comparators = {
title,
number: (a, b) => number(a) - number(b) || title(a, b),
dueDate: due,
priority: (a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority],
progress: (a, b) => a.progress - b.progress,
created: (a, b) => date(a.createdDate) - date(b.createdDate),
};
export function sortTasks(taskList, sortOrder = 'default') {
const result = [...taskList];
const valid = SORT_VALUES.has(sortOrder) ? sortOrder : 'default';
return result.sort((a, b) => {
if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1;
if (isHiddenTask(a) !== isHiddenTask(b)) return isHiddenTask(a) ? 1 : -1;
if (valid === 'default')
return (
PRIORITY_ORDER[b.priority] - PRIORITY_ORDER[a.priority] ||
date(b.createdDate) - date(a.createdDate)
);
const [key, direction] = valid.split('-');
if (key === 'dueDate' && (!a.dueDate || !b.dueDate)) return due(a, b);
return comparators[key](a, b) * (direction === 'desc' ? -1 : 1);
});
}
+63
View File
@@ -0,0 +1,63 @@
import { STORAGE_KEYS } from './config.js';
let writeFailed = false;
export function consumeStorageWriteFailure() {
const failed = writeFailed;
writeFailed = false;
return failed;
}
function readRaw(key) {
try {
return { value: localStorage.getItem(key), available: true };
} catch {
return { value: null, available: false };
}
}
function writeRaw(key, value) {
try {
localStorage.setItem(key, value);
return true;
} catch {
writeFailed = true;
return false;
}
}
function readJson(key, fallback) {
const { value, available } = readRaw(key);
if (!available || value === null) return fallback;
try {
return JSON.parse(value);
} catch {
return fallback;
}
}
function writeJson(key, value) {
return writeRaw(key, JSON.stringify(value));
}
export function loadTasksData() {
const { value: raw, available } = readRaw(STORAGE_KEYS.tasks);
if (!available) return { tasks: [], corrupted: false, exists: false, available: false };
if (raw === null) return { tasks: [], corrupted: false, exists: false, available: true };
writeRaw(STORAGE_KEYS.initialized, 'true');
try {
const tasks = JSON.parse(raw);
if (!Array.isArray(tasks)) throw new TypeError('Tasks must be an array');
return { tasks, corrupted: false, exists: true, available: true };
} catch {
writeRaw(STORAGE_KEYS.corruptedBackup, raw);
return { tasks: [], corrupted: true, exists: true, available: true };
}
}
export const saveTasksData = (tasks) => writeJson(STORAGE_KEYS.tasks, tasks);
export const loadSortOrdersData = () => readJson(STORAGE_KEYS.sortOrders, {});
export const saveSortOrdersData = (orders) => writeJson(STORAGE_KEYS.sortOrders, orders);
export const loadShowHiddenData = (status) => {
const key = STORAGE_KEYS.showHidden[status];
return key ? readJson(key, false) === true : false;
};
export const saveShowHiddenData = (status, value) => {
const key = STORAGE_KEYS.showHidden[status];
return key ? writeJson(key, value) : false;
};
export const isInitialized = () => readRaw(STORAGE_KEYS.initialized).value !== null;
export const markInitialized = () => writeRaw(STORAGE_KEYS.initialized, 'true');
+107
View File
@@ -0,0 +1,107 @@
import { DEFAULT_SORT_ORDERS, HIDEABLE_STATUSES, SORT_VALUES, STATUSES } from './config.js';
import { normalizeSortOrders, normalizeTask, normalizeTasks } from './task-model.js';
import { generateUniqueId } from './utils.js';
import {
loadShowHiddenData,
loadSortOrdersData,
loadTasksData,
markInitialized,
saveShowHiddenData,
saveSortOrdersData,
saveTasksData,
} from './storage.js';
let tasks = [];
let sortOrders = { ...DEFAULT_SORT_ORDERS };
let showHidden = Object.fromEntries(HIDEABLE_STATUSES.map((status) => [status, false]));
let corrupted = false;
const persistTasks = () => saveTasksData(tasks);
export function initializeStore(seedTasks, initialized) {
const loaded = loadTasksData();
corrupted = loaded.corrupted;
tasks = normalizeTasks(loaded.tasks);
if (loaded.exists && !loaded.corrupted) persistTasks();
if (!initialized && !loaded.exists) {
tasks = normalizeTasks(seedTasks);
persistTasks();
markInitialized();
}
sortOrders = { ...DEFAULT_SORT_ORDERS, ...normalizeSortOrders(loadSortOrdersData()) };
showHidden = Object.fromEntries(
HIDEABLE_STATUSES.map((status) => [status, loadShowHiddenData(status)]),
);
}
export const wasCorrupted = () => corrupted;
export const getTasks = () => tasks.map((task) => structuredClone(task));
export const getTask = (id) => {
const task = tasks.find((item) => item.id === id);
return task ? structuredClone(task) : null;
};
export const getSortOrders = () => ({ ...sortOrders });
export const getShowHidden = (status) => showHidden[status] === true;
export function addTask(task) {
const normalized = normalizeTask(task);
const ids = new Set(tasks.map((item) => item.id));
if (ids.has(normalized.id)) normalized.id = generateUniqueId(ids);
tasks.push(normalized);
persistTasks();
return structuredClone(normalized);
}
export function updateTask(id, changes) {
const index = tasks.findIndex((task) => task.id === id);
if (index < 0) return null;
tasks[index] = normalizeTask({ ...tasks[index], ...changes, id });
persistTasks();
return structuredClone(tasks[index]);
}
export function removeTask(id) {
const before = tasks.length;
tasks = tasks.filter((task) => task.id !== id);
if (tasks.length !== before) persistTasks();
return tasks.length !== before;
}
export function togglePin(id) {
const task = tasks.find((item) => item.id === id);
if (!task) return null;
task.isPinned = !task.isPinned;
persistTasks();
return structuredClone(task);
}
export function toggleHidden(id) {
const task = tasks.find((item) => item.id === id);
if (!task) return null;
task.isHidden = !task.isHidden;
persistTasks();
return structuredClone(task);
}
export function setSortOrder(status, value) {
if (!STATUSES.includes(status) || !SORT_VALUES.has(value)) return false;
sortOrders[status] = value;
saveSortOrdersData(sortOrders);
return true;
}
export function setShowHidden(status, value) {
if (!HIDEABLE_STATUSES.includes(status) || typeof value !== 'boolean') return false;
showHidden[status] = value;
saveShowHiddenData(status, value);
return true;
}
export function replaceTasks(value) {
tasks = normalizeTasks(value);
persistTasks();
}
export function appendTasks(value) {
const ids = new Set(tasks.map((task) => task.id));
const additions = normalizeTasks(value).map((task) => {
if (ids.has(task.id)) task.id = generateUniqueId(ids);
else ids.add(task.id);
return task;
});
tasks = [...tasks, ...additions];
persistTasks();
return additions.map((task) => structuredClone(task));
}
export function replaceSortOrders(value) {
sortOrders = { ...sortOrders, ...normalizeSortOrders(value) };
saveSortOrdersData(sortOrders);
}
+56
View File
@@ -0,0 +1,56 @@
import { LANGUAGES, PRIORITIES, SORT_VALUES, STATUSES } from './config.js';
import { generateId, generateUniqueId, isValidId } from './utils.js';
const TIMELINE_TYPES = ['manual-entry', 'status-change', 'progress-update', 'progressNotesUpdated'];
export function normalizeTask(input) {
const task = input && typeof input === 'object' ? { ...input } : {};
task.title = String(task.title ?? '').trim();
task.description = String(task.description ?? '');
task.progressNotes = String(task.progressNotes ?? '');
task.assignee = String(task.assignee ?? '');
task.dueDate = typeof task.dueDate === 'string' ? task.dueDate : '';
task.createdDate =
task.createdDate && !Number.isNaN(Date.parse(task.createdDate))
? task.createdDate
: new Date().toISOString();
task.status = STATUSES.includes(task.status) ? task.status : 'todo';
task.priority = PRIORITIES.includes(task.priority) ? task.priority : 'medium';
task.language = LANGUAGES.includes(task.language) ? task.language : 'zh';
task.progress = Math.min(100, Math.max(0, Number.parseInt(task.progress, 10) || 0));
task.collaborators = Array.isArray(task.collaborators) ? task.collaborators.map(String) : [];
task.isPinned = task.isPinned === true;
task.isHidden = task.isHidden === true;
task.id = isValidId(task.id) ? task.id : generateId();
const timelineIds = new Set();
task.timeline = (Array.isArray(task.timeline) ? task.timeline : [])
.filter((item) => item && typeof item === 'object' && !Array.isArray(item))
.map((item) => {
let id = isValidId(item.id) ? item.id : generateId();
while (timelineIds.has(id)) id = generateId();
timelineIds.add(id);
return { ...item, id, type: TIMELINE_TYPES.includes(item.type) ? item.type : 'manual-entry' };
});
return task;
}
export function normalizeTasks(items) {
if (!Array.isArray(items)) return [];
const ids = new Set();
return items
.filter((item) => item && typeof item === 'object')
.map((item) => {
const task = normalizeTask(item);
if (ids.has(task.id)) task.id = generateUniqueId(ids);
else ids.add(task.id);
return task;
});
}
export function normalizeSortOrders(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
return Object.fromEntries(
STATUSES.filter((status) => SORT_VALUES.has(value[status])).map((status) => [
status,
value[status],
]),
);
}
export { isValidId } from './utils.js';
+63
View File
@@ -0,0 +1,63 @@
import { t } from './i18n/index.js';
import { getTask, updateTask } from './store.js';
import { formatLocalDate, generateUniqueId } from './utils.js';
export function addSystemEntries(task, changes) {
const timeline = [...task.timeline];
const fields = [
['progressNotes', 'progressNotesUpdated', 'progressNotesUpdated', (a, b) => ({ value: b })],
['status', 'status-change', 'statusChanged', (a, b) => ({ from: a, to: b })],
['progress', 'progress-update', 'progressUpdated', (a, b) => ({ from: a, to: b })],
['assignee', 'status-change', 'assigneeChanged', (a, b) => ({ from: a, to: b })],
['dueDate', 'status-change', 'dueDateChanged', (a, b) => ({ from: a, to: b })],
['priority', 'status-change', 'priorityChanged', (a, b) => ({ from: a, to: b })],
];
const used = new Set(timeline.map((item) => item.id));
fields.forEach(([field, type, key, values]) => {
if (Object.hasOwn(changes, field) && task[field] !== changes[field])
timeline.push({
id: generateUniqueId(used),
date: formatLocalDate(),
type,
content: '',
event: { key, values: values(task[field], changes[field]) },
userType: 'system',
});
});
return timeline;
}
export function addTimelineEntry(taskId, content) {
const task = getTask(taskId);
if (!task || !content.trim()) return false;
const id = generateUniqueId(new Set(task.timeline.map((item) => item.id)));
task.timeline.push({
id,
date: formatLocalDate(),
type: 'manual-entry',
content: content.trim(),
user: task.assignee || t('defaultUser'),
});
updateTask(taskId, { timeline: task.timeline });
return true;
}
export function updateTimelineEntry(taskId, timelineId, content) {
const task = getTask(taskId);
const item = task?.timeline.find((entry) => entry.id === timelineId);
if (!item || !content.trim()) return false;
const wasSystem = item.userType === 'system';
item.content = content.trim();
item.type = 'manual-entry';
if (wasSystem || !item.user) item.user = task.assignee || t('defaultUser');
delete item.event;
delete item.userType;
updateTask(taskId, { timeline: task.timeline });
return true;
}
export function deleteTimelineEntry(taskId, timelineId) {
const task = getTask(taskId);
if (!task) return false;
const timeline = task.timeline.filter((item) => item.id !== timelineId);
if (timeline.length === task.timeline.length) return false;
updateTask(taskId, { timeline });
return true;
}
+43
View File
@@ -0,0 +1,43 @@
import { DUE_SOON_DAYS, HIDEABLE_STATUSES } from './config.js';
// Only todo and completed tasks can be hidden, so the flag is inert in any other column.
export const isHiddenTask = (task) =>
task.isHidden === true && HIDEABLE_STATUSES.includes(task.status);
let idCounter = 0;
export const generateId = () => Date.now() * 1000 + (idCounter++ % 1000);
export function generateUniqueId(usedIds) {
let id = generateId();
while (usedIds.has(id)) id = generateId();
usedIds.add(id);
return id;
}
export const isValidId = (value) => Number.isSafeInteger(value) && value > 0;
export function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
export function formatLocalDate(date = new Date()) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
export function parseDueDateEnd(value) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value ?? '');
if (!match) return null;
const [, y, m, d] = match.map(Number);
const date = new Date(y, m - 1, d, 23, 59, 59, 999);
return date.getFullYear() === y && date.getMonth() === m - 1 && date.getDate() === d
? date
: null;
}
export function getDueDateState(value, now = new Date()) {
const dueEnd = parseDueDateEnd(value);
if (!dueEnd) return { isOverdue: false, isDueSoon: false };
const soonEnd = new Date(now);
soonEnd.setDate(soonEnd.getDate() + DUE_SOON_DAYS);
soonEnd.setHours(23, 59, 59, 999);
return { isOverdue: dueEnd < now, isDueSoon: dueEnd >= now && dueEnd <= soonEnd };
}