From f014e373252ec71eb9aa0f9b9998d923281e36fc Mon Sep 17 00:00:00 2001 From: eddy Date: Sat, 1 Aug 2026 14:12:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=B7=E6=96=B0=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E7=9C=8B=E6=9D=BF=20UI=20=E5=B9=B6=E6=94=B9=E8=BF=9B=E6=97=A0?= =?UTF-8?q?=E9=9A=9C=E7=A2=8D=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 用 CSS 变量为状态列着色,并新增计数徽章样式 - 迁移至 Font Awesome 6,优化工具栏与卡片布局 - 通过 focus-within 显示操作按钮,并支持减少动画偏好 - 通过 data-key-aria / data-key-title 实现 aria-label 与 title 的国际化 --- src/css/styles.css | 248 +++++++++++++++++++++++++++++++++++++++---- src/index.html | 67 ++++++------ src/js/i18n/en.js | 2 + src/js/i18n/index.js | 9 ++ src/js/i18n/zh.js | 2 + src/js/render.js | 16 +-- 6 files changed, 290 insertions(+), 54 deletions(-) diff --git a/src/css/styles.css b/src/css/styles.css index 7a2dd30..386388c 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -21,6 +21,21 @@ --shadow-subtle: #00000012; --shadow-hover: #0002; --shadow-pinned: #ffc10744; + --shadow-header: #764ba240; + /* Tint/ink pairs per status: tint backs the count pill, ink keeps text readable on it. */ + --todo-tint: #6c757d1f; + --todo-ink: #5c636a; + --progress-tint: #ffc1072e; + --progress-ink: #7a5c03; + --complete-tint: #1987541f; + --complete-ink: #146c43; + /* Status-agnostic ink for neutral count pills; dark enough to clear AA on --timeline. */ + --count-ink: #495057; + /* Fallbacks so a .status-column without data-status degrades to neutral instead of + dropping the declaration entirely at computed-value time. */ + --status-accent: var(--primary); + --status-tint: var(--todo-tint); + --status-ink: var(--todo-ink); } body { background: var(--background); @@ -29,14 +44,32 @@ body { .task-header { background: linear-gradient(135deg, var(--header-start), var(--header-end)); color: var(--surface); - padding: 1.25rem; - border-radius: 1rem; - margin-bottom: 1.5rem; + padding: 0.85rem 1rem; + border-radius: 0.65rem; + margin-bottom: 0.75rem; + box-shadow: 0 4px 14px var(--shadow-header); } .task-header h1 { - font-size: 1.8rem; + font-size: 1.5rem; + font-weight: 600; + letter-spacing: 0.02em; margin: 0; } +.toolbar { + margin-bottom: 0.75rem; +} +.toolbar .btn, +.toolbar .form-control { + font-size: 0.875rem; + padding-block: 0.4rem; +} +.toolbar .btn { + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +/* Status columns */ .status-column { background: var(--surface); border-radius: 1rem; @@ -44,6 +77,22 @@ body { box-shadow: 0 2px 12px var(--shadow-subtle); min-height: 12rem; } +/* Each column publishes its palette so descendants can theme themselves. */ +.status-column[data-status='todo'] { + --status-accent: var(--todo); + --status-tint: var(--todo-tint); + --status-ink: var(--todo-ink); +} +.status-column[data-status='inProgress'] { + --status-accent: var(--progress); + --status-tint: var(--progress-tint); + --status-ink: var(--progress-ink); +} +.status-column[data-status='completed'] { + --status-accent: var(--complete); + --status-tint: var(--complete-tint); + --status-ink: var(--complete-ink); +} .status-column-header { display: flex; align-items: center; @@ -52,15 +101,49 @@ body { margin-bottom: 1rem; } .status-column-header h4 { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.4rem; + min-width: 0; font-size: 1rem; + font-weight: 600; margin: 0; } +/* Direct child only, so the visibility toggle's own icon keeps its muted colour. */ +.status-column-header h4 > i { + color: var(--status-ink); +} +.status-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.6rem; + padding: 0.1rem 0.45rem; + border-radius: 1rem; + background: var(--status-tint); + color: var(--status-ink); + font-size: 0.75rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} .sort-selector { font-size: 0.72rem; - padding: 0.25rem; + padding: 0.25rem 0.4rem; border: 1px solid var(--timeline); border-radius: 0.35rem; max-width: 45%; + background: var(--surface); + color: var(--muted); + cursor: pointer; + transition: border-color 0.15s ease; +} +.sort-selector:hover { + border-color: var(--status-accent); +} +.sort-selector:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 1px; } /* Task cards */ @@ -68,7 +151,10 @@ body { position: relative; border-left: 4px solid var(--primary); margin-bottom: 1rem; - transition: 0.2s; + /* Named properties only: `transition: 0.2s` also animated the overdue/due-soon colour flip. */ + transition: + transform 0.2s ease, + box-shadow 0.2s ease; } .task-card:hover { transform: translateY(-2px); @@ -100,24 +186,71 @@ body { } .task-actions { position: absolute; - right: 0.35rem; - top: 0.35rem; - z-index: 2; + right: 0.5rem; + top: 0.5rem; + z-index: 10; + display: flex; + align-items: center; + /* flex discards the inter-tag whitespace the original leaned on, so the gap has to be + declared rather than inherited from the markup's `ms-1` + collapsed space. */ + gap: 0.5rem; + padding: 0.125rem; background: var(--surface-raised); - border-radius: 0.4rem; + border-radius: 0.375rem; + -webkit-backdrop-filter: blur(5px); + backdrop-filter: blur(5px); opacity: 0; + transition: opacity 0.15s ease; } -.task-card:hover .task-actions { +/* focus-within keeps the buttons reachable by keyboard, which hover alone never allowed. */ +.task-card:hover .task-actions, +.task-card:focus-within .task-actions { opacity: 1; } +/* The action bar sits over the title row, so these keep the compact proportions the + original shipped with: a 0.8rem icon inside 6px/8px of padding. */ .task-actions .btn { - padding: 0.25rem 0.4rem; + flex: 0 0 auto; + font-size: 0.8rem; +} +.task-actions .task-action-button { + padding: 0.375rem 0.5rem; +} +.pin-button { + color: var(--muted); + text-decoration: none; + cursor: pointer; + transition: color 0.15s ease; +} +.pin-button:hover, +.pin-button:focus-visible, +.pin-button.pinned { + color: var(--progress); +} +/* The original tinted this button by letting the card's `.pinned` rule leak onto it, which + also gave it a 3px top border and left it standing taller than its neighbours. Keeping the + tint as a background reproduces the look without disturbing the row. */ +.pin-button.pinned { + background: var(--progress-tint); } .priority-badge { + position: relative; + z-index: 5; + flex: 0 0 auto; font-size: 0.7rem; border-radius: 1rem; - padding: 0.2rem 0.5rem; + padding: 0.125rem 0.375rem; color: var(--surface); + transition: + opacity 0.15s ease, + transform 0.15s ease; +} +/* The action bar overlaps the badge once it fades in, so the badge steps well back + instead of competing with the buttons stacked on top of it. */ +.task-card:hover .priority-badge, +.task-card:focus-within .priority-badge { + opacity: 0.3; + transform: translateY(-2px); } .priority-low-badge { background: var(--priority-low); @@ -143,6 +276,25 @@ body { /* Timeline */ .timeline-toggle { cursor: pointer; + color: var(--muted); + transition: color 0.15s ease; +} +.timeline-toggle:hover { + color: var(--primary); +} +.timeline-count { + display: inline-block; + min-width: 1.25rem; + padding: 0 0.35rem; + border-radius: 1rem; + background: var(--timeline); + color: var(--count-ink); + font-size: 0.7rem; + font-variant-numeric: tabular-nums; + text-align: center; +} +.timeline-caret { + margin-left: 0.15rem; } .timeline { position: relative; @@ -187,8 +339,10 @@ body { .timeline-item-actions { float: right; opacity: 0; + transition: opacity 0.15s ease; } -.timeline-item:hover .timeline-item-actions { +.timeline-item:hover .timeline-item-actions, +.timeline-item:focus-within .timeline-item-actions { opacity: 1; } @@ -202,6 +356,11 @@ body { } .visibility-toggle { padding: 0.1rem; + color: var(--muted); + text-decoration: none; +} +.visibility-toggle:hover { + color: var(--status-ink); } .empty-state { color: var(--muted); @@ -211,7 +370,24 @@ body { /* Responsive */ @media (max-width: 768px) { - .task-actions, + /* Flows inline above the card body, so the raised-surface treatment it needs while + floating would just paint a white block over overdue/due-soon card tints. */ + .task-actions { + position: static; + width: fit-content; + margin: 0.5rem 0.5rem 0 auto; + background: transparent; + -webkit-backdrop-filter: none; + backdrop-filter: none; + opacity: 0.9; + } + /* Nothing covers the badge once the action bar flows inline, and a sticky touch :hover + would otherwise leave it stuck at 30% long after the tap. */ + .task-card:hover .priority-badge, + .task-card:focus-within .priority-badge { + opacity: 1; + transform: none; + } .timeline-item-actions { opacity: 0.9; } @@ -224,7 +400,7 @@ body { } @media (max-width: 576px) { .task-header h1 { - font-size: 1.35rem; + font-size: 1.2rem; } .status-column-header { align-items: flex-start; @@ -232,11 +408,47 @@ body { .sort-selector { max-width: 50%; } + /* Flex centring only here: the enlarged touch target is taller than the icon's line box, + so normal flow would strand the glyph against the top padding. */ .task-actions .btn { + display: inline-flex; + align-items: center; + justify-content: center; min-width: 2.25rem; min-height: 2.25rem; } - .toolbar .btn span:not(#langBtn) { - display: none; + .toolbar .btn { + position: relative; + justify-content: center; + } + /* Collapse to icon-only, but keep the label readable to screen readers — + `display: none` would strip these buttons of their accessible name. */ + .toolbar .btn .toolbar-btn-label { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + border: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } +} +@media (prefers-reduced-motion: reduce) { + .task-card, + .task-actions, + .timeline-item-actions, + .priority-badge, + .pin-button, + .timeline-toggle, + .sort-selector { + transition: none; + } + .task-card:hover { + transform: none; + } + .task-card:hover .priority-badge, + .task-card:focus-within .priority-badge { + transform: none; } } diff --git a/src/index.html b/src/index.html index 6c70252..e2106f5 100644 --- a/src/index.html +++ b/src/index.html @@ -20,20 +20,32 @@ -
+
-

任务管理系统

+

+ 任务管理系统 +

-
+
+ + +
@@ -43,9 +55,9 @@ id="searchBtn" class="btn btn-outline-secondary" type="button" - aria-label="Search" + data-key-aria="search" > - +
@@ -53,18 +65,17 @@
-
+

- (0) + + 0

@@ -73,13 +84,11 @@
-
+
-

- (0) +

+ + 0

@@ -87,19 +96,17 @@
-
+
-

- (0) +

+ + 0

diff --git a/src/js/i18n/en.js b/src/js/i18n/en.js index 44075df..bfbaf4e 100644 --- a/src/js/i18n/en.js +++ b/src/js/i18n/en.js @@ -8,12 +8,14 @@ export default { exportData: 'Export Data', importData: 'Import Data', searchPlaceholder: 'Search tasks...', + search: 'Search', taskTitleLabel: 'Task Title *', statusLabel: 'Status', progressNotesLabel: 'Progress Notes', descriptionLabel: 'Task Description', priorityLabel: 'Priority', languageLabel: 'Language', + switchLanguage: 'Switch language', assigneeLabel: 'Assignee', dueDateLabel: 'Due Date', collaboratorsLabel: 'Collaborators', diff --git a/src/js/i18n/index.js b/src/js/i18n/index.js index 8a3b565..a646c60 100644 --- a/src/js/i18n/index.js +++ b/src/js/i18n/index.js @@ -19,6 +19,15 @@ export function applyTranslations(root = document) { root.querySelectorAll('[data-key-placeholder]').forEach((element) => { element.placeholder = t(element.dataset.keyPlaceholder); }); + // aria-label replaces the accessible name outright, so only use it on icon-only controls. + root.querySelectorAll('[data-key-aria]').forEach((element) => { + element.setAttribute('aria-label', t(element.dataset.keyAria)); + }); + // title only fills in as the accessible name when nothing else provides one, so it is the + // safe choice for controls that already show visible text (WCAG 2.5.3 Label in Name). + root.querySelectorAll('[data-key-title]').forEach((element) => { + element.title = t(element.dataset.keyTitle); + }); const button = document.querySelector('#langBtn'); if (button) button.textContent = language === 'zh' ? 'EN' : '中文'; } diff --git a/src/js/i18n/zh.js b/src/js/i18n/zh.js index 8187f02..5e86b1c 100644 --- a/src/js/i18n/zh.js +++ b/src/js/i18n/zh.js @@ -8,12 +8,14 @@ export default { exportData: '导出数据', importData: '导入数据', searchPlaceholder: '搜索任务...', + search: '搜索', taskTitleLabel: '任务标题 *', statusLabel: '状态', progressNotesLabel: '任务进展', descriptionLabel: '任务描述', priorityLabel: '优先级', languageLabel: '语言', + switchLanguage: '切换语言', assigneeLabel: '负责人', dueDateLabel: '到期日期', collaboratorsLabel: '协助人员', diff --git a/src/js/render.js b/src/js/render.js index 1bec4b5..6ddae2a 100644 --- a/src/js/render.js +++ b/src/js/render.js @@ -57,17 +57,20 @@ function createTimeline(task) { ? items .map( (item) => - `
${escapeHtml(item.date)} - ${escapeHtml(item.userType === 'system' ? t('systemUser') : item.user)}
${escapeHtml(timelineContent(item))}
${t('keyboardHint')}
`, + `
${escapeHtml(item.date)} - ${escapeHtml(item.userType === 'system' ? t('systemUser') : item.user)}
${escapeHtml(timelineContent(item))}
${t('keyboardHint')}
`, ) .join('') : `

${t('noTimelineEntries')}

`; - return `${rows}
`; + return `${rows}
`; } function createTaskActions(task) { const hiddenAction = HIDEABLE_STATUSES.includes(task.status) - ? `` + ? `` : ''; - return `
${hiddenAction}
`; + const pinClasses = ['btn', 'btn-sm', 'btn-link', 'pin-button', task.isPinned ? 'pinned' : ''] + .filter(Boolean) + .join(' '); + return `
${hiddenAction}
`; } function createPriorityBadge(task) { return `${t(task.priority)}`; @@ -134,7 +137,7 @@ function createCard(task) { const description = task.description ? `

${escapeHtml(task.description)}

` : ''; - return `
${createTaskActions(task)}
${escapeHtml(task.title)}
${createPriorityBadge(task)}
${description}${createProgress(task)}${createPeople(task)}${createDueDateBlock(task, due)}
${t('timeline')} (${task.timeline.length})
${createTimeline(task)}
`; + return `
${createTaskActions(task)}
${escapeHtml(task.title)}
${createPriorityBadge(task)}
${description}${createProgress(task)}${createPeople(task)}${createDueDateBlock(task, due)}
${t('timeline')} ${task.timeline.length}
${createTimeline(task)}
`; } export function updateTaskCounts() { const tasks = getTasks(); @@ -151,7 +154,8 @@ export function updateTaskCounts() { const count = tasks.filter((task) => task.status === status && isHiddenTask(task)).length; button.hidden = count === 0; button.title = t(getShowHidden(status) ? 'hideHiddenTitle' : 'showHiddenTitle'); - button.querySelector('i').className = `fas fa-${getShowHidden(status) ? 'eye-slash' : 'eye'}`; + button.querySelector('i').className = + `fa-solid fa-fw fa-${getShowHidden(status) ? 'eye-slash' : 'eye'}`; }); } function updateSearchEmptyState(list) {