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