From 672117e48d69e37cd88869d9ce99a309f5c9322f Mon Sep 17 00:00:00 2001 From: xk27 <1780350196@qq.com> Date: Wed, 15 Jul 2026 20:52:46 +0800 Subject: [PATCH 01/24] =?UTF-8?q?docs(=E6=A1=8C=E9=9D=A2=E7=AB=AF):=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=B8=93=E6=B3=A8=E5=B7=A5=E4=BD=9C=E5=8F=B0?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E8=A7=84=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 明确桌面端信息架构、状态反馈、响应式与验收标准。 仅提交已批准的 UI 重设计规格,不包含工作区中的其他改动。 --- ...26-07-15-desktop-focus-workspace-design.md | 227 ++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-15-desktop-focus-workspace-design.md diff --git a/docs/superpowers/specs/2026-07-15-desktop-focus-workspace-design.md b/docs/superpowers/specs/2026-07-15-desktop-focus-workspace-design.md new file mode 100644 index 0000000..5a633a6 --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-desktop-focus-workspace-design.md @@ -0,0 +1,227 @@ +# TaskBridge 桌面端专注工作台设计规格 + +## 背景 + +当前桌面端在导航、任务列表和设置页同时展示大量说明、同步状态、筛选状态与操作反馈。常驻信息占用了任务工作区,正常状态与异常状态缺少层级差异,用户需要先阅读界面再判断下一步操作。 + +本次重设计采用已确认的「A:专注工作台」方案。桌面端以任务处理为核心,正常状态保持安静,低频功能按需出现,异常状态提供明确且可执行的反馈。 + +## 目标 + +- 用户打开应用后可以直接查看、添加和处理任务。 +- 正常同步状态不占用主工作区,离线、冲突和失败才升级为可见提示。 +- 今天、全部任务和设置拥有一致的页面骨架与操作层级。 +- 新建和编辑任务不改变用户当前列表上下文。 +- 设置页一次只展示一个分类,避免多个复杂面板同时堆叠。 +- 在常见桌面宽度和窄窗口下保持完整操作能力、清晰焦点顺序和无横向滚动。 + +## 非目标 + +- 不改变任务、同步、认证、备份、更新或提醒的业务语义。 +- 不修改后端 API、SQLite 数据结构或跨端同步协议。 +- 不把桌面端改造成统计仪表盘,也不新增项目管理功能。 +- 不重新设计登录页和悬浮窗;仅确保它们继续使用统一的基础视觉变量。 + +## 设计原则 + +### 视觉主张 + +使用安静的浅色工作面、克制的青绿色主操作和清晰的排版层级,让任务内容成为界面主体。边框仅用于分隔,阴影仅用于抽屉、菜单和 Toast 等临时浮层。 + +### 内容策略 + +- 页面只保留定位、状态和操作所需的文案。 +- 删除与标题或控件含义重复的常驻说明。 +- 正常结果使用短暂反馈;需要用户处理的情况保留到问题解决。 +- 技术细节、诊断信息和高级连接项默认收起。 + +### 交互策略 + +- 页面切换和筛选变化使用短促的淡入与位移动画,不阻塞操作。 +- 编辑器从右侧滑入,关闭后恢复原列表位置、筛选和搜索内容。 +- 行内次要操作在悬停或键盘聚焦时显现;触屏和窄窗口提供始终可访问的更多菜单。 +- 尊重 `prefers-reduced-motion`,关闭非必要动画。 + +## 信息架构 + +桌面应用保留 3 个一级入口: + +1. **今天:** 当前最需要处理的任务。 +2. **全部任务:** 搜索、筛选、批量处理和维护全部任务。 +3. **设置:** 账户与外观、连接、窗口、数据安全、同步恢复和高级维护。 + +导航之外的账号、同步和退出操作统一放在侧栏底部的账户区,不再分别占据固定区域。 + +## 应用骨架 + +### 宽屏布局 + +- 左侧固定 `184 px` 导航栏,右侧为单一主工作区。 +- 主内容最大宽度为 `1080 px`,在可用空间内居中;任务列表不使用外层卡片。 +- 主内容采用固定页头、可选工具区和滚动内容区。临时反馈不参与正常文档流,避免推动内容跳动。 +- 编辑器以右侧 `440 px` 抽屉覆盖主工作区边缘,不压缩任务列表。 + +### 侧栏 + +- 顶部展示 `TB` 标记和 `TaskBridge` 名称。 +- 中部只展示今天、全部任务和设置 3 个入口,当前入口使用浅色底和明确的 `aria-current="page"`。 +- 底部账户区展示默认头像、用户名和同步状态点。 +- 同步正常时只显示绿色状态点,悬停或聚焦时通过 Tooltip 显示「已同步」。 +- 同步中显示旋转状态;离线使用中性色;错误或冲突使用警示色和数量标记。 +- 点击账户区打开菜单,提供「立即同步」「同步详情」「退出登录」。退出和未同步数据保护沿用现有确认逻辑。 + +## 今天页面 + +### 页头 + +- 主标题为「今天」,副信息为当前未完成任务数。 +- 右侧只保留一个高强调「新建任务」按钮。 +- 不再使用 eyebrow、说明句或常驻健康状态卡片。 + +### 快捷添加 + +- 页头下方提供一行快捷输入,按 Enter 直接创建。 +- 「更多」打开完整任务编辑抽屉,并继承今天预设。 +- 输入为空时不提交;解析或保存失败时在输入下方显示单行错误,不使用全局 Toast 隐藏失败原因。 + +### 任务列表 + +- 任务按「已逾期」和「接下来」分组,组标题显示数量。 +- 任务行稳定高度不低于 `56 px`,主信息依次为完成控件、标题、时间与项目/标签元信息。 +- 逾期仅使用左侧警示线、时间颜色或小型状态文字,不给整行铺设高饱和背景。 +- 完成、编辑、稍后处理等次要操作在悬停或聚焦时出现。 +- 列表为空时只展示一句状态和「新建任务」按钮,不展示功能说明。 + +## 全部任务页面 + +### 页头与搜索 + +- 主标题为「全部任务」,副信息为未完成任务数。 +- 新建任务按钮位置与今天页面一致。 +- 搜索框和筛选控件位于同一工具行,搜索框占据剩余宽度。 + +### 筛选 + +- 常用筛选使用分段控件:全部、今天、已逾期、已完成。 +- 收件箱、本周、高优先级、待同步、冲突、模板和回收站放入「更多筛选」菜单。 +- 项目和标签放入同一高级筛选弹出面板。 +- 有效筛选仅显示为可移除的紧凑标签,不再重复展示「当前筛选」标题。 +- 清除搜索、清除筛选分别使用输入清除图标和统一的「全部清除」操作。 + +### 批量操作 + +- 未选择任务时不展示批量工具栏。 +- 选择任务后,在列表上方固定显示选择数量、完成、删除或回收站恢复/彻底删除操作。 +- 危险操作继续使用确认对话框,工具栏关闭后保留当前筛选和滚动位置。 + +## 任务编辑器 + +- 新建或编辑统一使用右侧抽屉,抽屉包含标题、关闭按钮、表单和固定底部操作区。 +- 新建时首个可编辑字段自动获得焦点;关闭后焦点返回触发按钮或任务行。 +- 保存成功后关闭抽屉并显示短暂 Toast;保存失败时抽屉保持打开,在相关字段或操作区展示错误。 +- 存在未保存修改时,关闭抽屉、切换页面、退出或关闭窗口均沿用现有放弃修改确认。 +- 抽屉打开时使用焦点约束,`Escape` 触发关闭流程,而不是直接丢弃内容。 + +## 设置页面 + +### 页面结构 + +- 页面内部使用 `200 px` 分类导航和单一内容列。 +- 分类导航包含:账户与外观、账户安全、连接、窗口、数据与更新、同步恢复、高级维护。 +- 一次只显示当前分类,各面板保持挂载以保留未保存的连接草稿;切换分类不会滚动到长页面中的锚点。 +- 页面顶部只展示「设置」和自动保存状态;删除重复副标题和自动保存说明。 + +### 信息分级 + +- 常用设置直接展示,并在修改后自动保存。 +- API、WebSocket、自定义端点、更新技术详情和诊断明细放入折叠区域。 +- 备份导入、清理本地数据、重试同步和元数据重命名按业务分组,不嵌套多层卡片。 +- 危险操作使用独立分隔区和危险色按钮,说明只在操作附近出现。 +- 成功保存使用短暂 Toast;连接失败、导入失败和同步恢复失败在对应设置区域内持续显示。 + +## 状态与反馈 + +### 正常状态 + +- 已同步:仅显示侧栏绿色状态点。 +- 正在同步:侧栏状态点动画,不展示全宽提示。 +- 保存、完成、恢复和删除成功:右下角 Toast,默认显示 `1.8 s`;重要批量结果显示 `4.5 s`。 + +### 需要关注 + +- 离线但本地仍可工作:主内容顶部显示一行中性提示,提供重试入口,不遮挡任务操作。 +- 同步失败、耗尽队列或冲突:显示一行警示提示,包含问题数量和「查看详情」。 +- 会话过期:保留当前工作区状态,进入现有重新登录流程。 +- 表单和连接错误:在发生错误的局部区域显示,错误消失前不自动隐藏。 + +同一个问题只展示一个最高优先级入口,避免侧栏状态、页面健康条和设置提示重复出现。 + +## 组件与职责 + +- `App.vue`:认证、一级视图切换、全局未保存保护和应用骨架编排。 +- 应用侧栏组件:导航、账户菜单和聚合后的同步状态入口。 +- 全局状态提示组件:只渲染需要用户关注的离线、失败或冲突状态。 +- `TodayView.vue`:今天任务分组、快捷添加和编辑抽屉状态。 +- `TaskView.vue`:搜索、筛选、选择、批量操作和编辑抽屉状态。 +- `SettingsView.vue`:设置分类选择、分类内容编排和局部反馈。 +- `TaskEditor.vue`、任务行和现有设置面板继续负责各自业务交互,不把业务逻辑移入纯布局组件。 + +同步状态继续来自 `useSyncStore`,任务数据继续来自 `useTaskStore`。重设计只改变状态的聚合和呈现方式,不增加第二份状态源。 + +## 响应式规则 + +### `>= 1100 px` + +- 使用 `184 px` 完整侧栏。 +- 主内容最大宽度 `1080 px`。 +- 编辑器使用 `440 px` 右侧抽屉。 + +### `800 px - 1099 px` + +- 侧栏收窄为 `72 px` 图标导航,使用 Tooltip 提供名称。 +- 主内容占满剩余空间,保持最小 `24 px` 页面边距。 +- 编辑器宽度不超过窗口宽度的 `56%`,且不超过 `440 px`。 + +### `< 800 px` + +- 使用 `64 px` 图标导航。 +- 工具行允许分为两行,但搜索、主操作和任务标题不得截断关键内容。 +- 编辑器作为覆盖式面板,占据除导航栏外的全部可用宽度。 +- 筛选与行内次要操作进入菜单,页面不得出现横向滚动。 + +应用窗口最小尺寸沿用 Electron 现有约束;视觉验收覆盖 `1440 x 1000`、`1024 x 768` 和 `800 x 700`。 + +## 可访问性与国际化 + +- 所有图标按钮提供可访问名称和 Tooltip。 +- 焦点样式在浅色、深色和暖色主题中均达到可见对比度。 +- 导航、菜单、抽屉、对话框、筛选和任务完成控件可完全使用键盘操作。 +- 动态异常提示使用 `aria-live="polite"`;阻止保存的错误与字段建立语义关联。 +- 中文和英文长文本均允许换行;按钮和固定列不依赖视口宽度缩放字体。 +- 颜色不作为状态的唯一表达方式,状态同时提供图标或文字。 + +## 测试与验收 + +### 自动化验证 + +- 为同步状态聚合、提示优先级、筛选显示和设置分类切换增加行为测试。 +- 更新现有桌面 UX 静态守门,验证正常同步提示不再常驻、编辑抽屉仍保留未保存保护。 +- 运行桌面单元测试、类型检查和生产构建。 +- 运行仓库现有全部静态守门,确保重设计未破坏安全、同步、备份和发布约束。 + +### 浏览器与桌面验收 + +- 在 `1440 x 1000`、`1024 x 768` 和 `800 x 700` 下截图检查布局。 +- 检查今天、全部任务、设置、空状态、长标题、长标签、筛选、批量选择和编辑抽屉。 +- 模拟已同步、同步中、离线、同步失败、冲突和会话过期状态。 +- 验证键盘焦点顺序、抽屉焦点恢复、`Escape` 行为和减少动画偏好。 +- 确认无文字遮挡、控件重叠、意外布局跳动和横向滚动。 + +## 完成标准 + +- 主工作区不再常驻正常同步说明、重复健康状态或成功反馈。 +- 今天页面首屏直接呈现任务与快捷添加,主要操作无需滚动。 +- 全部任务的搜索、常用筛选和新建入口在首屏可用,低频筛选按需展开。 +- 设置页一次只显示一个分类,高级和诊断内容默认收起。 +- 所有原有任务、同步、备份、更新、账号和设置能力仍可访问。 +- 自动化验证全部通过,3 个目标视口的实际截图通过人工检查。 From 4d95cfeb4a877627ef38fa210ffb2cfe1f93a284 Mon Sep 17 00:00:00 2001 From: xk27 <1780350196@qq.com> Date: Wed, 15 Jul 2026 21:48:37 +0800 Subject: [PATCH 02/24] =?UTF-8?q?docs(=E6=A1=8C=E9=9D=A2=E7=AB=AF):=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=B8=93=E6=B3=A8=E5=B7=A5=E4=BD=9C=E5=8F=B0?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 TDD 顺序拆分应用骨架、任务视图、设置、样式和验收步骤。 --- .../2026-07-15-desktop-focus-workspace.md | 823 ++++++++++++++++++ 1 file changed, 823 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-15-desktop-focus-workspace.md diff --git a/docs/superpowers/plans/2026-07-15-desktop-focus-workspace.md b/docs/superpowers/plans/2026-07-15-desktop-focus-workspace.md new file mode 100644 index 0000000..27f0cc6 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-desktop-focus-workspace.md @@ -0,0 +1,823 @@ +# TaskBridge 桌面端专注工作台实现计划 + +> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。 + +**目标:** 将 Windows 桌面端重构为提示克制、任务优先、编辑按需出现的专注工作台,并通过行为测试、静态守门、构建和真实视口截图验证。 + +**架构:** 保留现有 Pinia 状态、任务业务逻辑和 Electron IPC,以纯函数统一同步状态呈现策略。`App.vue` 只负责编排侧栏、异常横幅和一级视图;今天、全部任务、设置分别管理自己的交互状态。新增组件负责侧栏、Toast、状态横幅和快捷添加,新增独立样式表以隔离工作台布局,避免继续扩大已有 `base.css` 的重复覆盖。 + +**技术栈:** Vue 3 Composition API、TypeScript、Pinia、Electron、Lucide Vue Next、Node.js Test Runner、CSS Grid/Flexbox。 + +--- + +## 文件结构 + +### 新建 + +- `desktop/shared/workspace-ui-policy.ts`:同步状态优先级、指示器和异常横幅的纯函数策略。 +- `desktop/tests/workspace-ui-policy.test.mjs`:同步状态策略的行为测试。 +- `desktop/tests/desktop-focus-workspace.test.mjs`:新布局结构、反馈和可访问性静态契约测试。 +- `desktop/src/components/AppSidebar.vue`:一级导航、账户菜单和紧凑同步入口。 +- `desktop/src/components/WorkspaceStatusBanner.vue`:仅在离线或同步异常时显示的单行提示。 +- `desktop/src/components/AppToast.vue`:成功操作的固定位置礼貌播报区。 +- `desktop/src/components/WorkspaceQuickAdd.vue`:今天页面的单行快捷添加。 +- `desktop/src/components/settings/SettingsSyncRecoveryPanel.vue`:同步诊断、重试和支持工具。 +- `desktop/src/assets/workspace.css`:专注工作台布局、组件状态和响应式样式。 + +### 修改 + +- `desktop/package.json`、`desktop/package-lock.json`:加入 `lucide-vue-next`。 +- `desktop/src/main.ts`:在基础样式后加载工作台样式。 +- `desktop/src/App.vue`:改用新侧栏和聚合状态横幅。 +- `desktop/src/i18n.ts`:加入账户菜单、异常横幅、快捷添加和设置分类文案。 +- `desktop/src/views/TodayView.vue`:加入快捷添加、局部保存错误和 Toast,移除页面级同步健康卡片。 +- `desktop/src/views/TaskView.vue`:重排搜索与筛选、精简有效筛选、加入局部保存错误和 Toast。 +- `desktop/src/views/SettingsView.vue`:改为分类导航和单一内容区。 +- `desktop/src/components/TaskItem.vue`:收紧任务行信息层级,使用图标化更多操作入口。 +- `desktop/tests/ux-remediation.test.mjs`:更新桌面端同步提示与设置导航契约。 +- `desktop/scripts/check-ux-priority-polish.mjs`:守门逻辑改为验证聚合异常横幅。 +- `desktop/scripts/check-user-experience-optimizations.mjs`:守门逻辑改为验证新工作台组件和正常状态静默规则。 + +### 删除 + +- `desktop/src/components/SyncStatus.vue`:由侧栏聚合状态入口替代。 +- `desktop/src/components/TaskSyncHealthBar.vue`:由应用级异常横幅替代,避免同一问题重复显示。 + +## 任务 1:用纯函数锁定同步状态呈现规则 + +**文件:** +- 创建:`desktop/shared/workspace-ui-policy.ts` +- 创建:`desktop/tests/workspace-ui-policy.test.mjs` + +- [ ] **步骤 1:编写失败的同步状态策略测试** + +```javascript +import assert from "node:assert/strict"; +import test from "node:test"; +import { loadTsModule } from "./helpers/load-ts-module.mjs"; + +const { deriveWorkspaceStatus } = await loadTsModule("shared/workspace-ui-policy.ts"); +const healthy = { + pendingQueueCount: 0, + exhaustedQueueCount: 0, + failedCount: 0, + conflictCount: 0, +}; + +test("healthy and syncing states stay out of the main workspace", () => { + assert.deepEqual(deriveWorkspaceStatus("synced", healthy), { + indicator: "ready", + banner: "none", + issueCount: 0, + }); + assert.equal(deriveWorkspaceStatus("syncing", healthy).banner, "none"); +}); + +test("offline and actionable failures receive one banner", () => { + assert.equal(deriveWorkspaceStatus("offline", healthy).banner, "offline"); + assert.deepEqual( + deriveWorkspaceStatus("synced", { ...healthy, failedCount: 1, conflictCount: 2 }), + { indicator: "attention", banner: "attention", issueCount: 3 }, + ); +}); +``` + +- [ ] **步骤 2:运行测试并确认因模块缺失而失败** + +运行:`node --test --test-name-pattern="workspace status" desktop/tests/workspace-ui-policy.test.mjs` + +预期:FAIL,错误包含 `workspace-ui-policy.ts` 不存在。 + +- [ ] **步骤 3:实现最小同步状态策略** + +```typescript +export type WorkspaceSyncStatus = "idle" | "syncing" | "offline" | "error" | "synced"; +export type WorkspaceIndicator = "ready" | "working" | "offline" | "attention"; +export type WorkspaceBanner = "none" | "offline" | "attention"; + +export interface WorkspaceDiagnosticsSummary { + pendingQueueCount: number; + exhaustedQueueCount: number; + failedCount: number; + conflictCount: number; +} + +export interface WorkspaceStatusPresentation { + indicator: WorkspaceIndicator; + banner: WorkspaceBanner; + issueCount: number; +} + +export function deriveWorkspaceStatus( + status: WorkspaceSyncStatus, + diagnostics: WorkspaceDiagnosticsSummary, +): WorkspaceStatusPresentation { + const issueCount = diagnostics.exhaustedQueueCount + diagnostics.failedCount + diagnostics.conflictCount; + if (status === "error" || issueCount > 0) { + return { indicator: "attention", banner: "attention", issueCount }; + } + if (status === "offline") { + return { indicator: "offline", banner: "offline", issueCount: diagnostics.pendingQueueCount }; + } + if (status === "syncing") { + return { indicator: "working", banner: "none", issueCount: 0 }; + } + return { indicator: "ready", banner: "none", issueCount: 0 }; +} +``` + +- [ ] **步骤 4:运行策略测试并确认通过** + +运行:`node --test --test-name-pattern="workspace status" desktop/tests/workspace-ui-policy.test.mjs` + +预期:PASS,健康、同步中、离线、错误和诊断问题优先级均符合断言。 + +- [ ] **步骤 5:提交策略与测试** + +```bash +git add desktop/shared/workspace-ui-policy.ts desktop/tests/workspace-ui-policy.test.mjs +git commit -m "test(桌面端): 锁定工作台同步状态规则" +``` + +## 任务 2:建立应用侧栏、异常横幅和 Toast 基础组件 + +**文件:** +- 修改:`desktop/package.json` +- 修改:`desktop/package-lock.json` +- 创建:`desktop/src/components/AppSidebar.vue` +- 创建:`desktop/src/components/WorkspaceStatusBanner.vue` +- 创建:`desktop/src/components/AppToast.vue` +- 修改:`desktop/src/App.vue` +- 修改:`desktop/src/i18n.ts` +- 创建:`desktop/tests/desktop-focus-workspace.test.mjs` +- 删除:`desktop/src/components/SyncStatus.vue` + +- [ ] **步骤 1:安装图标依赖** + +运行:`npm --prefix desktop install --save-dev lucide-vue-next` + +预期:`desktop/package.json` 和锁文件新增同一版本的 `lucide-vue-next`,安装成功退出码为 `0`。 + +- [ ] **步骤 2:编写失败的应用骨架契约测试** + +```javascript +test("desktop shell keeps healthy sync quiet and exposes one account menu", async () => { + const [app, sidebar, banner, toast] = await Promise.all([ + source("desktop/src/App.vue"), + source("desktop/src/components/AppSidebar.vue"), + source("desktop/src/components/WorkspaceStatusBanner.vue"), + source("desktop/src/components/AppToast.vue"), + ]); + + assert.match(app, /deriveWorkspaceStatus/); + assert.match(app, /(); + +defineEmits<{ + navigate: [view: "today" | "tasks" | "settings"]; + syncNow: []; + openSyncDetails: []; + logout: []; +}>(); +``` + +`WorkspaceStatusBanner.vue` 使用以下接口: + +```typescript +defineProps<{ status: WorkspaceStatusPresentation }>(); +defineEmits<{ retry: []; openDetails: [] }>(); +``` + +`AppToast.vue` 使用以下模板语义: + +```vue + + +
+
+
+
+``` + +- [ ] **步骤 5:重构 `App.vue` 编排** + +```typescript +const workspaceStatus = computed(() => + deriveWorkspaceStatus(syncStore.status, syncStore.diagnostics), +); +``` + +```vue +
+ +
+ + +
+
+``` + +- [ ] **步骤 6:补齐中英文文案并运行类型检查** + +新增键:`nav.accountMenu`、`sync.details`、`sync.retry`、`sync.offlineWorkspace`、`sync.attentionWorkspace`、`sync.issueCount`、`task.saveFailed`、`task.quickAdd`、`task.quickAddMore`。 + +运行:`npm --prefix desktop run typecheck` + +预期:PASS,不存在组件属性、事件或 i18n 键类型错误。 + +- [ ] **步骤 7:运行骨架测试并提交** + +运行:`node --test desktop/tests/desktop-focus-workspace.test.mjs` + +预期:PASS。 + +```bash +git add desktop/package.json desktop/package-lock.json desktop/src/App.vue desktop/src/i18n.ts desktop/src/components/AppSidebar.vue desktop/src/components/WorkspaceStatusBanner.vue desktop/src/components/AppToast.vue desktop/src/components/SyncStatus.vue desktop/tests/desktop-focus-workspace.test.mjs +git commit -m "feat(桌面端): 重构专注工作台应用骨架" +``` + +## 任务 3:重设计今天页面和快捷添加 + +**文件:** +- 创建:`desktop/src/components/WorkspaceQuickAdd.vue` +- 修改:`desktop/src/views/TodayView.vue` +- 修改:`desktop/tests/desktop-focus-workspace.test.mjs` + +- [ ] **步骤 1:编写失败的今天页面契约测试** + +```javascript +test("today view starts with quick add and keeps save failures local", async () => { + const [today, quickAdd] = await Promise.all([ + source("desktop/src/views/TodayView.vue"), + source("desktop/src/components/WorkspaceQuickAdd.vue"), + ]); + + assert.match(today, /(); +const title = ref(""); + +function submit(): void { + const value = title.value.trim(); + if (!value) return; + emit("submit", value); + title.value = ""; +} +``` + +组件包含一个文本输入、一个 Enter 提交入口和一个带 `SlidersHorizontal` 图标的完整编辑按钮;图标按钮同时提供 `title` 与 `aria-label`。 + +- [ ] **步骤 4:实现今天页面任务流和失败反馈** + +```typescript +const saveError = ref(""); + +async function quickAddTask(title: string): Promise { + saveError.value = ""; + try { + await taskStore.addTask({ + title, + listType: "today", + plannedDate: todayLocalDate(taskStore.timelineNow, settingsStore.displayTimeZone), + }); + showNotice(settingsStore.t("task.feedbackSaved")); + } catch { + saveError.value = settingsStore.t("task.saveFailed"); + } +} + +async function save(draft: TaskDraft): Promise { + if (isSaving.value) return; + isSaving.value = true; + saveError.value = ""; + try { + if (editingTask.value) await taskStore.updateTask(editingTask.value, draft); + else await taskStore.addTask(draft); + editorOpen.value = false; + editingTask.value = null; + setEditorDirty(false); + showNotice(settingsStore.t("task.feedbackSaved")); + } catch { + saveError.value = settingsStore.t("task.saveFailed"); + } finally { + isSaving.value = false; + } +} +``` + +成功反馈改用 ``;`saveError` 在快捷输入下方或抽屉操作区使用 `role="alert"`,失败时不关闭抽屉、不清除草稿。 + +- [ ] **步骤 5:运行测试与类型检查** + +运行:`node --test --test-name-pattern="today view" desktop/tests/desktop-focus-workspace.test.mjs` + +运行:`npm --prefix desktop run typecheck` + +预期:两条命令均 PASS。 + +- [ ] **步骤 6:提交今天页面** + +```bash +git add desktop/src/views/TodayView.vue desktop/src/components/WorkspaceQuickAdd.vue desktop/tests/desktop-focus-workspace.test.mjs +git commit -m "feat(桌面端): 重设计今天任务工作区" +``` + +## 任务 4:重设计全部任务、筛选和任务行 + +**文件:** +- 修改:`desktop/src/views/TaskView.vue` +- 修改:`desktop/src/components/TaskItem.vue` +- 修改:`desktop/tests/desktop-focus-workspace.test.mjs` +- 删除:`desktop/src/components/TaskSyncHealthBar.vue` + +- [ ] **步骤 1:编写失败的筛选与反馈契约测试** + +```javascript +test("all tasks keeps common filters visible and moves secondary filters into menus", async () => { + const [taskView, taskItem] = await Promise.all([ + source("desktop/src/views/TaskView.vue"), + source("desktop/src/components/TaskItem.vue"), + ]); + + assert.match(taskView, /class="task-command-bar"/); + assert.match(taskView, /class="segment-control task-filter-segments"/); + assert.match(taskView, /class="filter-menu"/); + assert.match(taskView, /activeFilterItems/); + assert.match(taskView, /clearActiveFilter/); + assert.match(taskView, /role="alert"/); + assert.match(taskView, />(() => { + const items: Array<{ key: ActiveFilterKey; label: string }> = []; + if (filter.value !== "all") { + const option = [...primaryFilterOptions.value, ...secondaryFilterOptions.value] + .find((item) => item.value === filter.value); + if (option) items.push({ key: "filter", label: option.label }); + } + if (selectedProject.value) items.push({ key: "project", label: selectedProject.value }); + if (selectedTag.value) items.push({ key: "tag", label: `#${selectedTag.value}` }); + if (search.value.trim()) items.push({ key: "search", label: search.value.trim() }); + return items; +}); + +function clearActiveFilter(key: ActiveFilterKey): void { + if (key === "filter") filter.value = "all"; + if (key === "project") selectedProject.value = ""; + if (key === "tag") selectedTag.value = ""; + if (key === "search") search.value = ""; + clearSelectedTasks(); +} +``` + +- [ ] **步骤 4:重排搜索、常用筛选和更多菜单** + +工具行结构固定为:搜索框、清除搜索图标、新建按钮;下一行是 4 个常用分段筛选和 1 个更多筛选菜单。项目与标签位于同一弹出面板,未选择时不展示有效筛选区。批量工具栏仍只在 `bulkActionTargets.length > 0` 时渲染。 + +```vue +
+ +
+``` + +- [ ] **步骤 5:加入保存失败反馈并精简任务行操作** + +复制任务 3 的保存错误语义:`catch` 后保留抽屉和草稿,错误使用 `role="alert"`,成功使用 `AppToast`。`TaskItem.vue` 的更多操作 `summary` 改为 `MoreHorizontal` 图标和屏幕阅读器文本;任务标题允许在两行内换行,行内操作只通过 CSS 在悬停和 `:focus-within` 时增强可见度。 + +- [ ] **步骤 6:运行测试与类型检查** + +运行:`node --test --test-name-pattern="all tasks" desktop/tests/desktop-focus-workspace.test.mjs` + +运行:`npm --prefix desktop run typecheck` + +预期:两条命令均 PASS。 + +- [ ] **步骤 7:提交全部任务页面** + +```bash +git add desktop/src/views/TaskView.vue desktop/src/components/TaskItem.vue desktop/src/components/TaskSyncHealthBar.vue desktop/tests/desktop-focus-workspace.test.mjs +git commit -m "feat(桌面端): 精简任务筛选与列表操作" +``` + +## 任务 5:将设置页改为分类导航和单一内容区 + +**文件:** +- 创建:`desktop/src/components/settings/SettingsSyncRecoveryPanel.vue` +- 修改:`desktop/src/views/SettingsView.vue` +- 修改:`desktop/src/i18n.ts` +- 修改:`desktop/tests/desktop-focus-workspace.test.mjs` + +- [ ] **步骤 1:编写失败的设置页结构测试** + +```javascript +test("settings displays one persistent category panel at a time", async () => { + const [settings, recovery] = await Promise.all([ + source("desktop/src/views/SettingsView.vue"), + source("desktop/src/components/settings/SettingsSyncRecoveryPanel.vue"), + ]); + + assert.match(settings, /type SettingsSectionId/); + assert.match(settings, /const activeSettingsSection = ref/); + assert.match(settings, /class="settings-category-nav"/); + assert.match(settings, /v-show="activeSettingsSection === 'connection'"/); + assert.doesNotMatch(settings, /scrollIntoView/); + assert.match(recovery, /settings\.syncRecoveryCenter/); + assert.match(recovery, /settings\.diagnosticsSupportTools/); +}); +``` + +- [ ] **步骤 2:运行测试并确认失败** + +运行:`node --test --test-name-pattern="settings displays" desktop/tests/desktop-focus-workspace.test.mjs` + +预期:FAIL,错误指出恢复面板不存在或设置页仍使用锚点滚动。 + +- [ ] **步骤 3:建立分类状态与外部跳转映射** + +```typescript +type SettingsSectionId = + | "account-display" + | "account-security" + | "connection" + | "window" + | "data" + | "sync-recovery" + | "metadata"; + +const activeSettingsSection = ref("account-display"); + +function showSettingsSection(sectionId: string): void { + if (!settingsNavItems.value.some((item) => item.sectionId === sectionId)) return; + activeSettingsSection.value = sectionId as SettingsSectionId; + if (sectionId === "sync-recovery") syncDiagnosticsOpen.value = true; + if (sectionId === "metadata") metadataOpen.value = false; +} +``` + +`sectionRequest` 监听器调用 `showSettingsSection`。分类面板使用 `v-show`,保证连接草稿在内部切换时不卸载。 + +- [ ] **步骤 4:提取同步恢复面板** + +```typescript +defineProps<{ + deviceId: string; + diagnostics: SyncDiagnostics; + serverTaskMeta: TaskMetaDto | null; + note: string; + updateTechnicalDetail: string; +}>(); + +defineEmits<{ + refresh: []; + retry: []; + exportDiagnostics: []; +}>(); +``` + +组件内部保留诊断折叠区、耗尽队列列表和支持工具折叠区。正常无问题时只显示简短空状态;详细计数和技术信息默认收起。 + +- [ ] **步骤 5:重写设置模板** + +```vue +
+ +
+ +
+
+``` + +- [ ] **步骤 6:运行设置测试与类型检查** + +运行:`node --test --test-name-pattern="settings displays" desktop/tests/desktop-focus-workspace.test.mjs` + +运行:`npm --prefix desktop run typecheck` + +预期:两条命令均 PASS,连接草稿事件仍可传播到 `App.vue`。 + +- [ ] **步骤 7:提交设置页** + +```bash +git add desktop/src/views/SettingsView.vue desktop/src/components/settings/SettingsSyncRecoveryPanel.vue desktop/src/i18n.ts desktop/tests/desktop-focus-workspace.test.mjs +git commit -m "feat(桌面端): 重构设置分类工作区" +``` + +## 任务 6:实现完整视觉系统与响应式布局 + +**文件:** +- 创建:`desktop/src/assets/workspace.css` +- 修改:`desktop/src/main.ts` +- 修改:`desktop/src/assets/base.css` +- 修改:`desktop/tests/desktop-focus-workspace.test.mjs` + +- [ ] **步骤 1:编写失败的响应式样式契约测试** + +```javascript +test("workspace stylesheet defines stable desktop and narrow layouts", async () => { + const [main, css] = await Promise.all([ + source("desktop/src/main.ts"), + source("desktop/src/assets/workspace.css"), + ]); + + assert.match(main, /assets\/workspace\.css/); + assert.match(css, /grid-template-columns:\s*184px minmax\(0, 1fr\)/); + assert.match(css, /max-width:\s*1080px/); + assert.match(css, /width:\s*min\(440px/); + assert.match(css, /@media \(max-width: 1099px\)/); + assert.match(css, /grid-template-columns:\s*72px minmax\(0, 1fr\)/); + assert.match(css, /@media \(max-width: 799px\)/); + assert.match(css, /overflow-x:\s*hidden/); + assert.match(css, /@media \(prefers-reduced-motion: reduce\)/); +}); +``` + +- [ ] **步骤 2:运行测试并确认失败** + +运行:`node --test --test-name-pattern="workspace stylesheet" desktop/tests/desktop-focus-workspace.test.mjs` + +预期:FAIL,错误指出 `workspace.css` 不存在。 + +- [ ] **步骤 3:建立工作台变量和宽屏布局** + +```css +.focus-workspace { + --workspace-sidebar: #f1f5f3; + --workspace-canvas: #ffffff; + --workspace-line: #dce4e0; + --workspace-text: #17211f; + --workspace-muted: #68756f; + display: grid; + grid-template-columns: 184px minmax(0, 1fr); + width: 100%; + height: 100vh; + min-width: 0; + overflow: hidden; + color: var(--workspace-text); + background: var(--workspace-canvas); +} + +.focus-workspace .view-shell { + width: min(1080px, 100%); + margin: 0 auto; + padding: 28px 40px 48px; +} + +.focus-workspace .side-panel { + width: min(440px, calc(100vw - 184px)); +} +``` + +- [ ] **步骤 4:实现任务、菜单、Toast 和设置样式** + +要求:任务行最小高度 `56 px`;主列表无外层卡片;任务行边框半径不超过 `8 px`;成功 Toast 固定在右下角;弹出菜单、抽屉和对话框使用有限阴影;图标按钮为稳定 `36 x 36 px`;所有文字容器允许换行;危险色仅用于逾期、冲突和破坏性操作。 + +- [ ] **步骤 5:实现窄窗口和减少动画规则** + +```css +@media (max-width: 1099px) { + .focus-workspace { + grid-template-columns: 72px minmax(0, 1fr); + } + .focus-workspace .nav-label, + .focus-workspace .brand-name, + .focus-workspace .account-name { + display: none; + } +} + +@media (max-width: 799px) { + .focus-workspace { + grid-template-columns: 64px minmax(0, 1fr); + overflow-x: hidden; + } + .focus-workspace .task-command-bar, + .focus-workspace .settings-workspace { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (prefers-reduced-motion: reduce) { + .focus-workspace *, + .app-toast { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + } +} +``` + +- [ ] **步骤 6:移除 `base.css` 中已失效的常驻同步与重复工作台覆盖** + +删除仅服务于旧结构的 `.sidebar-sync-button`、`.sidebar .sync-status`、`.task-sync-health-bar` 和旧设置锚点导航样式。保留登录页、悬浮窗、编辑表单、对话框和主题变量所需样式。运行 `rg` 确认被删除组件的选择器不再存在。 + +- [ ] **步骤 7:运行样式契约、类型检查和构建** + +运行:`node --test --test-name-pattern="workspace stylesheet" desktop/tests/desktop-focus-workspace.test.mjs` + +运行:`npm --prefix desktop run typecheck` + +运行:`npm --prefix desktop run build` + +预期:3 条命令均 PASS。 + +- [ ] **步骤 8:提交视觉系统** + +```bash +git add desktop/src/assets/workspace.css desktop/src/assets/base.css desktop/src/main.ts desktop/tests/desktop-focus-workspace.test.mjs +git commit -m "style(桌面端): 完成专注工作台视觉与响应式布局" +``` + +## 任务 7:更新 UX 守门并运行桌面完整回归 + +**文件:** +- 修改:`desktop/tests/ux-remediation.test.mjs` +- 修改:`desktop/scripts/check-ux-priority-polish.mjs` +- 修改:`desktop/scripts/check-user-experience-optimizations.mjs` +- 修改:`desktop/tests/desktop-focus-workspace.test.mjs` + +- [ ] **步骤 1:将旧健康条断言改为单一异常横幅断言** + +```javascript +assert.match(desktopAppSource, /v-if="workspaceStatus\.banner !== 'none'"/); +assert.match(desktopWorkspaceBannerSource, /aria-live="polite"/); +assert.doesNotMatch(desktopTodayViewSource, /TaskSyncHealthBar|showTaskSyncHealth/); +assert.doesNotMatch(desktopTaskViewSource, /TaskSyncHealthBar|showTaskSyncHealth/); +``` + +守门文件读取 `WorkspaceStatusBanner.vue` 和 `workspace-ui-policy.ts`,不再读取已删除的 `TaskSyncHealthBar.vue`。 + +- [ ] **步骤 2:运行受影响的 UX 守门并确认通过** + +运行:`npm --prefix desktop run check:ux-priority-polish` + +运行:`npm --prefix desktop run check:user-experience` + +运行:`npm --prefix desktop run test:unit` + +预期:3 条命令均 PASS,桌面测试总数不低于改造前的 `50` 条。 + +- [ ] **步骤 3:运行桌面类型检查和生产构建** + +运行:`npm --prefix desktop run typecheck` + +运行:`npm --prefix desktop run build` + +预期:两条命令均 PASS,`desktop/out` 重新生成。 + +- [ ] **步骤 4:运行仓库静态守门** + +运行:`powershell -ExecutionPolicy Bypass -File .\scripts\check-local.ps1 -ReportOnly -SkipBackend -SkipDesktopBuild` + +预期:桌面静态守门全部 PASS;仅显式跳过后端、Android 和重复桌面构建,不出现 failed 或 blocked。 + +- [ ] **步骤 5:提交守门更新** + +```bash +git add desktop/tests/ux-remediation.test.mjs desktop/tests/desktop-focus-workspace.test.mjs desktop/scripts/check-ux-priority-polish.mjs desktop/scripts/check-user-experience-optimizations.mjs +git commit -m "test(桌面端): 更新专注工作台体验守门" +``` + +## 任务 8:真实视口与交互验收 + +**文件:** +- 创建:`docs/assets/desktop-focus-1440.png` +- 创建:`docs/assets/desktop-focus-1024.png` +- 创建:`docs/assets/desktop-focus-800.png` +- 修改:`desktop/README.md` + +- [ ] **步骤 1:启动桌面开发服务** + +运行:`npm --prefix desktop run dev` + +预期:Electron 窗口成功启动,主进程和渲染进程控制台无未处理异常。 + +- [ ] **步骤 2:准备可重复验收数据** + +使用现有测试账号或本地测试服务创建:1 条逾期任务、3 条今天任务、1 条长标题任务、1 条带项目和标签任务、1 条已完成任务。记录账号、服务地址和数据恢复方式,不修改真实用户数据。 + +- [ ] **步骤 3:检查 `1440 x 1000` 宽屏布局** + +验证:`184 px` 侧栏、快捷添加首屏可见、正常同步只显示状态点、编辑抽屉不压缩列表、任务行操作悬停和键盘聚焦均可见。保存截图到 `docs/assets/desktop-focus-1440.png`。 + +- [ ] **步骤 4:检查 `1024 x 768` 与 `800 x 700` 窄窗口布局** + +验证:侧栏分别收窄、标签不遮挡、筛选可换行、设置分类可用、抽屉覆盖内容且可关闭、页面无横向滚动。保存截图到 `docs/assets/desktop-focus-1024.png` 和 `docs/assets/desktop-focus-800.png`。 + +- [ ] **步骤 5:验证异常和辅助功能状态** + +依次验证同步中、离线、同步失败、冲突、保存失败和未保存关闭。确认同一同步问题只出现一个横幅;保存失败保留草稿并使用 `role="alert"`;成功 Toast 使用 `role="status"`;键盘焦点进入并离开抽屉后返回触发点;启用减少动画后无明显位移动画。 + +- [ ] **步骤 6:检查截图像素和可访问性树** + +对 3 张截图使用图像查看工具确认非空、无遮挡、无裁切。使用浏览器或 Electron DevTools 可访问性树检查所有图标按钮均有名称,且不存在空名称交互节点。 + +- [ ] **步骤 7:更新桌面 README 截图并提交** + +在 `desktop/README.md` 的界面部分引用宽屏截图,并注明窄窗口会收为图标导航。运行 Markdown 本地链接检查后提交。 + +```bash +git add docs/assets/desktop-focus-1440.png docs/assets/desktop-focus-1024.png docs/assets/desktop-focus-800.png desktop/README.md +git commit -m "docs(桌面端): 更新专注工作台界面截图" +``` + +- [ ] **步骤 8:最终验证工作区差异** + +运行:`git diff --check` + +运行:`git status --short` + +预期:`git diff --check` 无输出;状态列表中不包含临时日志、开发数据库、截图之外的生成物或未解释文件。 From 3b154526af4a8f64aaefffb5f4e91fdf586255d3 Mon Sep 17 00:00:00 2001 From: xk27 <1780350196@qq.com> Date: Wed, 15 Jul 2026 22:27:01 +0800 Subject: [PATCH 03/24] =?UTF-8?q?test(=E6=A1=8C=E9=9D=A2=E7=AB=AF):=20?= =?UTF-8?q?=E9=94=81=E5=AE=9A=E5=B7=A5=E4=BD=9C=E5=8F=B0=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- desktop/shared/workspace-ui-policy.ts | 42 ++++++++++++ desktop/tests/workspace-ui-policy.test.mjs | 76 ++++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 desktop/shared/workspace-ui-policy.ts create mode 100644 desktop/tests/workspace-ui-policy.test.mjs diff --git a/desktop/shared/workspace-ui-policy.ts b/desktop/shared/workspace-ui-policy.ts new file mode 100644 index 0000000..6f4e83b --- /dev/null +++ b/desktop/shared/workspace-ui-policy.ts @@ -0,0 +1,42 @@ +export type WorkspaceSyncStatus = "idle" | "syncing" | "offline" | "error" | "synced"; + +export type WorkspaceIndicator = "ready" | "working" | "offline" | "attention"; + +export type WorkspaceBanner = "none" | "offline" | "attention"; + +export interface WorkspaceDiagnosticsSummary { + pendingQueueCount: number; + exhaustedQueueCount: number; + failedCount: number; + conflictCount: number; +} + +export interface WorkspaceStatusPresentation { + indicator: WorkspaceIndicator; + banner: WorkspaceBanner; + issueCount: number; +} + +export function deriveWorkspaceStatus( + status: WorkspaceSyncStatus, + diagnostics: WorkspaceDiagnosticsSummary, +): WorkspaceStatusPresentation { + const issueCount = diagnostics.pendingQueueCount + + diagnostics.exhaustedQueueCount + + diagnostics.failedCount + + diagnostics.conflictCount; + const hasDiagnosticIssue = diagnostics.exhaustedQueueCount > 0 + || diagnostics.failedCount > 0 + || diagnostics.conflictCount > 0; + + if (status === "error" || hasDiagnosticIssue) { + return { indicator: "attention", banner: "attention", issueCount }; + } + if (status === "offline") { + return { indicator: "offline", banner: "offline", issueCount }; + } + if (status === "syncing") { + return { indicator: "working", banner: "none", issueCount }; + } + return { indicator: "ready", banner: "none", issueCount }; +} diff --git a/desktop/tests/workspace-ui-policy.test.mjs b/desktop/tests/workspace-ui-policy.test.mjs new file mode 100644 index 0000000..f5646cd --- /dev/null +++ b/desktop/tests/workspace-ui-policy.test.mjs @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; + +import { loadTsModule } from "./helpers/load-ts-module.mjs"; + +const { deriveWorkspaceStatus } = await loadTsModule( + "shared/workspace-ui-policy.ts", +); + +const zeroDiagnostics = { + pendingQueueCount: 0, + exhaustedQueueCount: 0, + failedCount: 0, + conflictCount: 0, +}; + +describe("workspace status presentation", () => { + test("keeps a synced workspace quiet when diagnostics are clear", () => { + assert.deepEqual(deriveWorkspaceStatus("synced", zeroDiagnostics), { + indicator: "ready", + banner: "none", + issueCount: 0, + }); + }); + + test("shows syncing as working without a main-area banner", () => { + assert.deepEqual(deriveWorkspaceStatus("syncing", zeroDiagnostics), { + indicator: "working", + banner: "none", + issueCount: 0, + }); + }); + + test("shows queued offline work in the offline presentation", () => { + assert.deepEqual(deriveWorkspaceStatus("offline", { + ...zeroDiagnostics, + pendingQueueCount: 2, + }), { + indicator: "offline", + banner: "offline", + issueCount: 2, + }); + }); + + test("shows a sync error as requiring attention", () => { + assert.deepEqual(deriveWorkspaceStatus("error", zeroDiagnostics), { + indicator: "attention", + banner: "attention", + issueCount: 0, + }); + }); + + test("prioritizes actionable diagnostics over a synced status", () => { + assert.deepEqual(deriveWorkspaceStatus("synced", { + pendingQueueCount: 0, + exhaustedQueueCount: 1, + failedCount: 1, + conflictCount: 2, + }), { + indicator: "attention", + banner: "attention", + issueCount: 4, + }); + }); + + test("does not escalate a synced pending queue to an alert banner", () => { + assert.deepEqual(deriveWorkspaceStatus("synced", { + ...zeroDiagnostics, + pendingQueueCount: 2, + }), { + indicator: "ready", + banner: "none", + issueCount: 2, + }); + }); +}); From 435993dbbf7e896f8dafc03b149c4f864385a2b0 Mon Sep 17 00:00:00 2001 From: xk27 <1780350196@qq.com> Date: Wed, 15 Jul 2026 22:56:10 +0800 Subject: [PATCH 04/24] =?UTF-8?q?fix(=E6=A1=8C=E9=9D=A2=E7=AB=AF):=20?= =?UTF-8?q?=E4=BF=9D=E6=8C=81=E7=A9=BA=E9=97=B2=E5=90=8C=E6=AD=A5=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E9=9D=99=E9=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- desktop/shared/workspace-ui-policy.ts | 4 ++++ desktop/tests/workspace-ui-policy.test.mjs | 13 +++++++++++++ 2 files changed, 17 insertions(+) diff --git a/desktop/shared/workspace-ui-policy.ts b/desktop/shared/workspace-ui-policy.ts index 6f4e83b..dbcc951 100644 --- a/desktop/shared/workspace-ui-policy.ts +++ b/desktop/shared/workspace-ui-policy.ts @@ -21,6 +21,10 @@ export function deriveWorkspaceStatus( status: WorkspaceSyncStatus, diagnostics: WorkspaceDiagnosticsSummary, ): WorkspaceStatusPresentation { + if (status === "idle") { + return { indicator: "ready", banner: "none", issueCount: 0 }; + } + const issueCount = diagnostics.pendingQueueCount + diagnostics.exhaustedQueueCount + diagnostics.failedCount diff --git a/desktop/tests/workspace-ui-policy.test.mjs b/desktop/tests/workspace-ui-policy.test.mjs index f5646cd..2a0721c 100644 --- a/desktop/tests/workspace-ui-policy.test.mjs +++ b/desktop/tests/workspace-ui-policy.test.mjs @@ -23,6 +23,19 @@ describe("workspace status presentation", () => { }); }); + test("keeps an idle workspace quiet while diagnostics initialize", () => { + assert.deepEqual(deriveWorkspaceStatus("idle", { + pendingQueueCount: 2, + exhaustedQueueCount: 1, + failedCount: 1, + conflictCount: 1, + }), { + indicator: "ready", + banner: "none", + issueCount: 0, + }); + }); + test("shows syncing as working without a main-area banner", () => { assert.deepEqual(deriveWorkspaceStatus("syncing", zeroDiagnostics), { indicator: "working", From 495a499117c47b18d6570e339df09436477b4a7b Mon Sep 17 00:00:00 2001 From: xk27 <1780350196@qq.com> Date: Wed, 15 Jul 2026 23:28:59 +0800 Subject: [PATCH 05/24] =?UTF-8?q?fix(=E6=A1=8C=E9=9D=A2=E7=AB=AF):=20?= =?UTF-8?q?=E5=8C=BA=E5=88=86=E5=BE=85=E5=8F=91=E9=80=81=E4=B8=8E=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E5=BC=82=E5=B8=B8=E8=AE=A1=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- desktop/shared/workspace-ui-policy.ts | 23 +++++++++------- desktop/tests/workspace-ui-policy.test.mjs | 31 +++++++++++++++++++++- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/desktop/shared/workspace-ui-policy.ts b/desktop/shared/workspace-ui-policy.ts index dbcc951..d61236d 100644 --- a/desktop/shared/workspace-ui-policy.ts +++ b/desktop/shared/workspace-ui-policy.ts @@ -25,22 +25,27 @@ export function deriveWorkspaceStatus( return { indicator: "ready", banner: "none", issueCount: 0 }; } - const issueCount = diagnostics.pendingQueueCount - + diagnostics.exhaustedQueueCount + const actionableIssueCount = diagnostics.exhaustedQueueCount + diagnostics.failedCount + diagnostics.conflictCount; - const hasDiagnosticIssue = diagnostics.exhaustedQueueCount > 0 - || diagnostics.failedCount > 0 - || diagnostics.conflictCount > 0; + const hasDiagnosticIssue = actionableIssueCount > 0; if (status === "error" || hasDiagnosticIssue) { - return { indicator: "attention", banner: "attention", issueCount }; + return { + indicator: "attention", + banner: "attention", + issueCount: actionableIssueCount, + }; } if (status === "offline") { - return { indicator: "offline", banner: "offline", issueCount }; + return { + indicator: "offline", + banner: "offline", + issueCount: diagnostics.pendingQueueCount, + }; } if (status === "syncing") { - return { indicator: "working", banner: "none", issueCount }; + return { indicator: "working", banner: "none", issueCount: 0 }; } - return { indicator: "ready", banner: "none", issueCount }; + return { indicator: "ready", banner: "none", issueCount: 0 }; } diff --git a/desktop/tests/workspace-ui-policy.test.mjs b/desktop/tests/workspace-ui-policy.test.mjs index 2a0721c..2fb10fa 100644 --- a/desktop/tests/workspace-ui-policy.test.mjs +++ b/desktop/tests/workspace-ui-policy.test.mjs @@ -76,6 +76,35 @@ describe("workspace status presentation", () => { }); }); + for (const diagnostic of [ + "exhaustedQueueCount", + "failedCount", + "conflictCount", + ]) { + test(`treats ${diagnostic} as an actionable sync issue`, () => { + assert.deepEqual(deriveWorkspaceStatus("synced", { + ...zeroDiagnostics, + [diagnostic]: 1, + }), { + indicator: "attention", + banner: "attention", + issueCount: 1, + }); + }); + } + + test("excludes pending work from the attention issue count", () => { + assert.deepEqual(deriveWorkspaceStatus("synced", { + ...zeroDiagnostics, + pendingQueueCount: 5, + failedCount: 1, + }), { + indicator: "attention", + banner: "attention", + issueCount: 1, + }); + }); + test("does not escalate a synced pending queue to an alert banner", () => { assert.deepEqual(deriveWorkspaceStatus("synced", { ...zeroDiagnostics, @@ -83,7 +112,7 @@ describe("workspace status presentation", () => { }), { indicator: "ready", banner: "none", - issueCount: 2, + issueCount: 0, }); }); }); From d2be74e98abac4d0695840cb41b97ae70b815dea Mon Sep 17 00:00:00 2001 From: xk27 <1780350196@qq.com> Date: Thu, 16 Jul 2026 00:03:42 +0800 Subject: [PATCH 06/24] =?UTF-8?q?feat(=E6=A1=8C=E9=9D=A2=E7=AB=AF):=20?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E4=B8=93=E6=B3=A8=E5=B7=A5=E4=BD=9C=E5=8F=B0?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E9=AA=A8=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- desktop/package-lock.json | 11 + desktop/package.json | 2 + desktop/src/App.vue | 242 ++++++++++++------ desktop/src/components/AppSidebar.vue | 197 ++++++++++++++ desktop/src/components/AppToast.vue | 24 ++ desktop/src/components/SyncStatus.vue | 20 -- .../src/components/WorkspaceStatusBanner.vue | 56 ++++ desktop/src/i18n.ts | 212 +++++++++++---- .../tests/desktop-focus-workspace.test.mjs | 82 ++++++ 9 files changed, 701 insertions(+), 145 deletions(-) create mode 100644 desktop/src/components/AppSidebar.vue create mode 100644 desktop/src/components/AppToast.vue delete mode 100644 desktop/src/components/SyncStatus.vue create mode 100644 desktop/src/components/WorkspaceStatusBanner.vue create mode 100644 desktop/tests/desktop-focus-workspace.test.mjs diff --git a/desktop/package-lock.json b/desktop/package-lock.json index bfb278e..d5ec4e7 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -19,6 +19,7 @@ "electron": "^42.3.2", "electron-builder": "^26.8.1", "electron-vite": "^5.0.0", + "lucide-vue-next": "^0.577.0", "pinia": "^2.3.0", "typescript": "^5.7.2", "vite": "^6.0.5", @@ -4291,6 +4292,16 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-vue-next": { + "version": "0.577.0", + "resolved": "https://registry.npmmirror.com/lucide-vue-next/-/lucide-vue-next-0.577.0.tgz", + "integrity": "sha512-py05bAfv9SHVJqscbiOnjcnLlEmOffA58a+7XhZuFxrs6txe1E8VoR1ngWGTYO+9aVKABAz8l3ee3PqiQN9QPA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "vue": ">=3.0.1" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", diff --git a/desktop/package.json b/desktop/package.json index 0c71b87..e33c720 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -61,6 +61,7 @@ "check:desktop-auto-update": "node ./scripts/check-desktop-auto-update-config.mjs", "check:android-data-extraction": "node ./scripts/check-android-data-extraction-config.mjs", "check:security-governance": "node ./scripts/check-security-governance-config.mjs", + "check:ux-priority-polish": "node ./scripts/check-ux-priority-polish.mjs", "check:user-experience": "node ./scripts/check-user-experience-optimizations.mjs", "clean": "node ./scripts/clean.mjs", "clean:dry-run": "node ./scripts/clean.mjs --dry-run", @@ -77,6 +78,7 @@ "electron": "^42.3.2", "electron-builder": "^26.8.1", "electron-vite": "^5.0.0", + "lucide-vue-next": "^0.577.0", "pinia": "^2.3.0", "typescript": "^5.7.2", "vite": "^6.0.5", diff --git a/desktop/src/App.vue b/desktop/src/App.vue index e7b7968..38a862a 100644 --- a/desktop/src/App.vue +++ b/desktop/src/App.vue @@ -1,8 +1,9 @@ + + diff --git a/desktop/src/components/AppToast.vue b/desktop/src/components/AppToast.vue new file mode 100644 index 0000000..761d862 --- /dev/null +++ b/desktop/src/components/AppToast.vue @@ -0,0 +1,24 @@ + + + diff --git a/desktop/src/components/SyncStatus.vue b/desktop/src/components/SyncStatus.vue deleted file mode 100644 index b94d644..0000000 --- a/desktop/src/components/SyncStatus.vue +++ /dev/null @@ -1,20 +0,0 @@ - - - diff --git a/desktop/src/components/WorkspaceStatusBanner.vue b/desktop/src/components/WorkspaceStatusBanner.vue new file mode 100644 index 0000000..bff3951 --- /dev/null +++ b/desktop/src/components/WorkspaceStatusBanner.vue @@ -0,0 +1,56 @@ + + + diff --git a/desktop/src/i18n.ts b/desktop/src/i18n.ts index a1071e4..daf1e67 100644 --- a/desktop/src/i18n.ts +++ b/desktop/src/i18n.ts @@ -9,23 +9,19 @@ type MessageKey = | "auth.firstUseExistingServerTitle" | "auth.firstUseNoServer" | "auth.firstUseNoServerTitle" - | "auth.firstUseLocalTrial" - | "auth.firstUseLocalTrialTitle" - | "auth.firstUseSelfHost" - | "auth.firstUseSelfHostTitle" - | "auth.setupChecklistTitle" - | "auth.setupStepServer" - | "auth.setupStepCheck" - | "auth.setupStepAccount" | "auth.openLocalTrialGuide" + | "auth.openSelfHostGuide" | "auth.noServerHelpSummary" | "auth.noServerHelpBody" | "auth.copyLocalTrialReference" | "auth.localTrialReferenceCopied" | "auth.localTrialReferenceCopyFailed" + | "auth.selfHostReferenceCopied" + | "auth.selfHostReferenceCopyFailed" | "auth.mode" | "auth.login" | "auth.register" + | "auth.registrationConnectionHint" | "auth.registrationUnknown" | "auth.registrationClosed" | "auth.username" @@ -41,12 +37,18 @@ type MessageKey = | "auth.serverUrlRequired" | "auth.networkError" | "auth.serverError" + | "auth.sessionExpired" + | "auth.serverChangedRelogin" | "nav.label" | "nav.today" | "nav.all" | "nav.settings" | "nav.logout" + | "nav.accountMenu" | "task.add" + | "task.saveFailed" + | "task.quickAdd" + | "task.quickAddMore" | "task.showAllTasks" | "task.clearFilters" | "task.addToday" @@ -67,6 +69,8 @@ type MessageKey = | "task.moreActions" | "task.title" | "task.content" + | "task.bodyDetails" + | "task.arrangementSettings" | "task.priority" | "task.tag" | "task.due" @@ -147,6 +151,11 @@ type MessageKey = | "sync.offline" | "sync.error" | "sync.manual" + | "sync.details" + | "sync.retry" + | "sync.offlineWorkspace" + | "sync.attentionWorkspace" + | "sync.attentionWorkspaceCount" | "sync.pendingCreate" | "sync.pendingUpdate" | "sync.pendingDelete" @@ -193,8 +202,10 @@ type MessageKey = | "settings.connectionTesting" | "settings.loginAutoChecksConnection" | "settings.connectionSaved" + | "settings.apiConnectionReady" | "settings.connectionReady" | "settings.connectionFailed" + | "settings.discardConnectionChangesConfirm" | "settings.serverUrlRequired" | "settings.localhostHint" | "settings.showAdvancedConnection" @@ -242,6 +253,33 @@ type MessageKey = | "settings.displayTimeZone" | "settings.displayTimeZoneHint" | "settings.accountDisplay" + | "settings.accountSecurity" + | "settings.refreshSessions" + | "settings.changePassword" + | "settings.currentPassword" + | "settings.newPassword" + | "settings.confirmNewPassword" + | "settings.passwordMismatch" + | "settings.passwordChanged" + | "settings.passwordChangeFailed" + | "settings.activeSessions" + | "settings.sessionSecurityHint" + | "settings.loadingSessions" + | "settings.noActiveSessions" + | "settings.thisDevice" + | "settings.unknownDevice" + | "settings.sessionCreated" + | "settings.sessionExpires" + | "settings.revokeSession" + | "settings.revokeSessionConfirm" + | "settings.sessionRevoked" + | "settings.sessionRevokeFailed" + | "settings.revokeOtherSessions" + | "settings.revokeOtherSessionsConfirm" + | "settings.otherSessionsRevoked" + | "settings.sessionsLoadFailed" + | "settings.serverTodayCount" + | "settings.serverOverdueCount" | "settings.dataSession" | "settings.dataTools" | "settings.general" @@ -296,6 +334,7 @@ type MessageKey = | "floating.feedbackAdded" | "floating.feedbackAddedInbox" | "floating.feedbackCompleted" + | "floating.hiddenTasks" | "floating.placeholder"; const messages: Record> = { @@ -303,8 +342,8 @@ const messages: Record> = { "auth.subtitle": { "zh-CN": "跨设备待办", "en-US": "Cross-device tasks" }, "auth.firstUseTitle": { "zh-CN": "已有服务器地址就能登录", "en-US": "Sign in with a server address" }, "auth.firstUseHint": { - "zh-CN": "把管理员给你的 TaskBridge 服务器地址填到下方,然后直接登录;客户端会自动检查连接。", - "en-US": "Enter the TaskBridge server address your administrator gave you, then sign in. The app checks the connection automatically.", + "zh-CN": "填写 TaskBridge 服务器地址后直接登录;没有地址时,先向管理员或部署者索取。", + "en-US": "Enter the TaskBridge server address and sign in. If you do not have one, ask your administrator or deployer first.", }, "auth.firstUseExistingServer": { "zh-CN": "直接填写管理员给你的服务器地址,检查通过后登录或注册。", @@ -312,29 +351,16 @@ const messages: Record> = { }, "auth.firstUseExistingServerTitle": { "zh-CN": "服务器地址", "en-US": "Server address" }, "auth.firstUseNoServer": { - "zh-CN": "先展开下方帮助,选择本机试用或生产自托管路径。", - "en-US": "Open the help below first, then choose local trial or production self-hosting.", + "zh-CN": "如果只是使用别人部署好的 TaskBridge,请先联系管理员或部署者;自己试用或自托管时再打开准备服务说明。", + "en-US": "If you use someone else's TaskBridge service, ask the administrator or deployer first. Open setup help only for a local trial or self-hosting.", }, "auth.firstUseNoServerTitle": { "zh-CN": "没有服务器地址", "en-US": "No server address" }, - "auth.firstUseLocalTrial": { - "zh-CN": "本机试用:保持默认地址,先在这台电脑启动后端;不需要修改高级连接设置。", - "en-US": "Local trial: keep the default address and start the backend on this computer first. No advanced connection settings are needed.", - }, - "auth.firstUseLocalTrialTitle": { "zh-CN": "本机试用", "en-US": "Local trial" }, - "auth.firstUseSelfHost": { - "zh-CN": "自托管:先按部署说明启动服务,再把服务器根地址填到这里。", - "en-US": "Self-hosting: deploy the service first, then enter the server root address here.", - }, - "auth.firstUseSelfHostTitle": { "zh-CN": "生产自托管", "en-US": "Production self-host" }, - "auth.setupChecklistTitle": { "zh-CN": "推荐顺序", "en-US": "Recommended order" }, - "auth.setupStepServer": { "zh-CN": "先确认服务器地址。", "en-US": "Confirm the server address first." }, - "auth.setupStepCheck": { "zh-CN": "直接登录或注册,客户端会自动检查连接。", "en-US": "Sign in or register directly. The app checks the connection automatically." }, - "auth.setupStepAccount": { "zh-CN": "检查连接只用于排查服务器地址。", "en-US": "Use connection testing only to troubleshoot the server address." }, "auth.openLocalTrialGuide": { "zh-CN": "打开本机试用说明", "en-US": "Open local trial guide" }, - "auth.noServerHelpSummary": { "zh-CN": "没有服务器?查看本机试用说明", "en-US": "No server? Local trial guide" }, + "auth.openSelfHostGuide": { "zh-CN": "打开自托管说明", "en-US": "Open self-hosting guide" }, + "auth.noServerHelpSummary": { "zh-CN": "没有服务器地址?查看准备服务说明", "en-US": "No server address? View setup guide" }, "auth.noServerHelpBody": { - "zh-CN": "没有服务器时,先在后端电脑准备 TaskBridge 源码或部署包,并按部署说明启动服务。启动后,同一台电脑保持默认地址;手机或另一台电脑填写后端电脑的局域网 IP。", - "en-US": "No server yet? Prepare the TaskBridge source or deployment package on the backend computer, then start the service from the deployment guide. Keep the default address on the same computer, or use the backend computer's LAN IP from another device.", + "zh-CN": "普通使用只需要一个服务器地址。如果你只是使用别人部署好的 TaskBridge,请联系管理员或部署者;想先试用或长期自托管时,再按说明准备服务。", + "en-US": "Normal use only needs one server address. If you use someone else's TaskBridge service, ask the administrator or deployer. Prepare the service only for a trial or long-term self-hosting.", }, "auth.copyLocalTrialReference": { "zh-CN": "复制本机试用参考", "en-US": "Copy local trial reference" }, "auth.localTrialReferenceCopied": { "zh-CN": "已复制本机试用参考。", "en-US": "Copied local trial reference." }, @@ -342,9 +368,18 @@ const messages: Record> = { "zh-CN": "复制失败,请按上方说明准备后端,或在项目部署说明中查看更完整的自托管步骤。", "en-US": "Copy failed. Use the guidance above, or check the project deployment guide for full self-hosting details.", }, + "auth.selfHostReferenceCopied": { "zh-CN": "已复制自托管说明参考。", "en-US": "Copied self-hosting reference." }, + "auth.selfHostReferenceCopyFailed": { + "zh-CN": "复制失败,请在项目部署说明中查看完整的自托管步骤。", + "en-US": "Copy failed. Check the project deployment guide for full self-hosting steps.", + }, "auth.mode": { "zh-CN": "认证模式", "en-US": "Authentication mode" }, "auth.login": { "zh-CN": "登录", "en-US": "Log in" }, "auth.register": { "zh-CN": "注册", "en-US": "Register" }, + "auth.registrationConnectionHint": { + "zh-CN": "注册状态取决于当前服务器。请先检查服务器地址;如果该服务器关闭注册,请联系管理员创建账号。", + "en-US": "Registration depends on the current server. Check the server address first; if registration is closed, ask the administrator to create an account.", + }, "auth.registrationUnknown": { "zh-CN": "点击“注册”会自动检查当前服务器是否开放注册。已有账号可直接登录。", "en-US": "Click Register to check automatically whether this server allows registration. Existing accounts can sign in now.", @@ -372,12 +407,24 @@ const messages: Record> = { "zh-CN": "服务器暂时无法完成请求,请稍后重试或联系管理员。", "en-US": "The server could not complete the request. Try again later or contact the admin.", }, + "auth.sessionExpired": { + "zh-CN": "登录会话已失效,请重新登录后继续同步。", + "en-US": "Your session expired. Sign in again to resume syncing.", + }, + "auth.serverChangedRelogin": { + "zh-CN": "服务器已切换,请登录新服务器。原服务器的本地任务和待同步操作仍保留在原工作区。", + "en-US": "The server changed. Sign in to the new server. Local tasks and pending changes remain in the previous workspace.", + }, "nav.label": { "zh-CN": "TaskBridge 导航", "en-US": "TaskBridge navigation" }, "nav.today": { "zh-CN": "今日", "en-US": "Today" }, "nav.all": { "zh-CN": "全部", "en-US": "All" }, "nav.settings": { "zh-CN": "设置", "en-US": "Settings" }, "nav.logout": { "zh-CN": "退出登录", "en-US": "Log out" }, + "nav.accountMenu": { "zh-CN": "账户菜单", "en-US": "Account menu" }, "task.add": { "zh-CN": "添加待办", "en-US": "Add task" }, + "task.saveFailed": { "zh-CN": "保存失败,请重试。", "en-US": "Could not save. Try again." }, + "task.quickAdd": { "zh-CN": "快速添加待办", "en-US": "Quick add task" }, + "task.quickAddMore": { "zh-CN": "更多", "en-US": "More" }, "task.showAllTasks": { "zh-CN": "查看全部待办", "en-US": "Show all tasks" }, "task.clearFilters": { "zh-CN": "清空筛选", "en-US": "Clear filters" }, "task.addToday": { "zh-CN": "添加今日待办", "en-US": "Add today" }, @@ -400,7 +447,9 @@ const messages: Record> = { "task.use": { "zh-CN": "使用", "en-US": "Use" }, "task.moreActions": { "zh-CN": "操作", "en-US": "Actions" }, "task.title": { "zh-CN": "标题", "en-US": "Title" }, - "task.content": { "zh-CN": "内容", "en-US": "Content" }, + "task.content": { "zh-CN": "备注", "en-US": "Notes" }, + "task.bodyDetails": { "zh-CN": "添加备注和清单", "en-US": "Add notes and checklist" }, + "task.arrangementSettings": { "zh-CN": "时间与安排", "en-US": "Time and schedule" }, "task.priority": { "zh-CN": "优先级", "en-US": "Priority" }, "task.tag": { "zh-CN": "标签", "en-US": "Tag" }, "task.due": { "zh-CN": "截止", "en-US": "Due" }, @@ -421,7 +470,7 @@ const messages: Record> = { }, "task.project": { "zh-CN": "项目", "en-US": "Project" }, "task.list": { "zh-CN": "归类", "en-US": "Location" }, - "task.checklist": { "zh-CN": "子清单", "en-US": "Checklist" }, + "task.checklist": { "zh-CN": "清单", "en-US": "Checklist" }, "task.noDue": { "zh-CN": "无截止时间", "en-US": "No due time" }, "task.search": { "zh-CN": "搜索标题、内容、标签或项目", "en-US": "Search title, content, tag or project" }, "task.clearSearch": { "zh-CN": "清空搜索", "en-US": "Clear search" }, @@ -445,20 +494,20 @@ const messages: Record> = { }, "task.syncHealthAction": { "zh-CN": "查看同步详情", "en-US": "View sync details" }, "task.emptySearch": { - "zh-CN": "没有匹配任务。可以清空搜索,或试试:明天下午 3 点写周报 #工作 P3。", - "en-US": "No matching tasks. Clear search or try: write weekly report tomorrow 3pm #work P3.", + "zh-CN": "没有匹配任务。可以清空搜索,或先输入一个普通标题,例如:写周报。", + "en-US": "No matching tasks. Clear search or start with a plain title, for example: write weekly report.", }, "task.emptyFiltered": { - "zh-CN": "当前筛选下暂无待办。清空筛选可查看其他任务;新建时可以直接输入:明天下午 3 点写周报 #工作 P3。", - "en-US": "No tasks match these filters. Clear filters to review the rest, or add one with: write weekly report tomorrow 3pm #work P3.", + "zh-CN": "当前筛选下暂无待办。清空筛选可查看其他任务;新建时先输入标题,例如:写周报。", + "en-US": "No tasks match these filters. Clear filters to review the rest, or add one with a plain title such as: write weekly report.", }, "task.empty": { - "zh-CN": "当前视图暂无待办。可以直接输入:明天下午 3 点写周报 #工作 P3。", - "en-US": "No tasks in this view. Try: write weekly report tomorrow 3pm #work P3.", + "zh-CN": "当前视图暂无待办。可以先输入一个标题,例如:写周报。", + "en-US": "No tasks in this view. Start with a plain title, for example: write weekly report.", }, "task.emptyToday": { - "zh-CN": "今天暂无待办。可以直接输入:明天下午 3 点写周报 #工作 P3。", - "en-US": "No tasks due today. Try: write weekly report tomorrow 3pm #work P3.", + "zh-CN": "今天暂无待办。可以先输入今天要做的事,例如:写周报。", + "en-US": "No tasks due today. Start with something for today, for example: write weekly report.", }, "task.allTitle": { "zh-CN": "全部待办", "en-US": "All tasks" }, "task.todayTitle": { "zh-CN": "今日待办", "en-US": "Today" }, @@ -483,19 +532,19 @@ const messages: Record> = { "task.templateName": { "zh-CN": "模板名称", "en-US": "Template name" }, "task.checklistPlaceholder": { "zh-CN": "每行一个清单项", "en-US": "One checklist item per line" }, "task.quickPlaceholder": { - "zh-CN": "例如:明天下午 3 点写周报 #工作 P3", - "en-US": "Example: write weekly report tomorrow 3pm #work P3", + "zh-CN": "例如:写周报", + "en-US": "Example: write weekly report", }, "task.autoFillHint": { - "zh-CN": "只填标题即可保存。可输入“明天下午 3 点写周报 #工作 P3”,系统会自动识别时间、标签和优先级。", - "en-US": "Title is enough. Try \"write weekly report tomorrow 3pm #work P3\" to auto-fill time, tag and priority.", + "zh-CN": "只填标题即可保存。需要时,也可以把时间、标签或优先级写进标题让系统识别。", + "en-US": "Title is enough. You can also add time, tags, or priority in the title when needed.", }, "task.scheduleHelp": { "zh-CN": "计划表示哪天要做;截止表示最晚完成时间;提醒只负责通知。", "en-US": "Plan is when you intend to work on it; due is the latest finish time; reminder only sends a notification.", }, - "task.moreSettings": { "zh-CN": "更多属性", "en-US": "More properties" }, - "task.hideSettings": { "zh-CN": "收起属性", "en-US": "Hide properties" }, + "task.moreSettings": { "zh-CN": "更多:标签、重复、模板", "en-US": "More: tags, repeat, templates" }, + "task.hideSettings": { "zh-CN": "收起更多", "en-US": "Hide more" }, "task.saveTemplate": { "zh-CN": "保存为模板", "en-US": "Save as template" }, "task.feedbackSaved": { "zh-CN": "已保存", "en-US": "Saved" }, "task.feedbackCompleted": { "zh-CN": "已完成", "en-US": "Completed" }, @@ -523,6 +572,11 @@ const messages: Record> = { "sync.offline": { "zh-CN": "离线", "en-US": "Offline" }, "sync.error": { "zh-CN": "同步异常", "en-US": "Sync error" }, "sync.manual": { "zh-CN": "立即同步", "en-US": "Sync now" }, + "sync.details": { "zh-CN": "同步详情", "en-US": "Sync details" }, + "sync.retry": { "zh-CN": "重试", "en-US": "Retry" }, + "sync.offlineWorkspace": { "zh-CN": "当前处于离线状态,仍可继续处理本地任务。", "en-US": "You are offline. You can keep working on local tasks." }, + "sync.attentionWorkspace": { "zh-CN": "同步遇到问题,请重试或查看详情。", "en-US": "Sync needs attention. Retry or view details." }, + "sync.attentionWorkspaceCount": { "zh-CN": "同步有 {count} 个问题需要处理。", "en-US": "{count} sync issues need attention." }, "sync.pendingCreate": { "zh-CN": "待创建", "en-US": "Pending create" }, "sync.pendingUpdate": { "zh-CN": "待同步", "en-US": "Pending sync" }, "sync.pendingDelete": { "zh-CN": "待删除", "en-US": "Pending delete" }, @@ -558,7 +612,7 @@ const messages: Record> = { "en-US": "Some tasks are still pending, failed, or conflicted. Local data stays on this device, but other devices may not see those changes yet. Log out anyway?", }, "settings.title": { "zh-CN": "设置", "en-US": "Settings" }, - "settings.subtitle": { "zh-CN": "桌面端配置", "en-US": "Desktop client" }, + "settings.subtitle": { "zh-CN": "常用设置与数据安全", "en-US": "Settings and data safety" }, "settings.navCommon": { "zh-CN": "常用设置", "en-US": "Common settings" }, "settings.navDataSafety": { "zh-CN": "数据安全", "en-US": "Data safety" }, "settings.navSyncRecovery": { "zh-CN": "同步问题", "en-US": "Sync issues" }, @@ -593,18 +647,29 @@ const messages: Record> = { "en-US": "Signing in checks the connection automatically. Use Test connection only when troubleshooting the server address.", }, "settings.connectionSaved": { "zh-CN": "连接设置已保存。", "en-US": "Connection settings saved." }, - "settings.connectionReady": { "zh-CN": "连接可用。现在可以直接登录。", "en-US": "Connection is ready. You can sign in now." }, + "settings.apiConnectionReady": { + "zh-CN": "API 可用;登录后会继续验证实时同步。", + "en-US": "The API is available. Real-time sync will be verified after sign-in.", + }, + "settings.connectionReady": { + "zh-CN": "API 可用;请在同步状态中确认实时同步。", + "en-US": "The API is available. Check Sync status to confirm real-time sync.", + }, "settings.connectionFailed": { "zh-CN": "连接失败:", "en-US": "Connection failed: " }, + "settings.discardConnectionChangesConfirm": { + "zh-CN": "连接地址还有未保存的修改,离开后会丢失。仍要离开吗?", + "en-US": "Connection addresses have unsaved changes. Leave and discard them?", + }, "settings.serverUrlRequired": { "zh-CN": "请输入服务器地址。", "en-US": "Enter the server address." }, "settings.localhostHint": { "zh-CN": "127.0.0.1 只适合连接这台电脑上的后端;手机或另一台电脑请填写后端所在电脑的局域网 IP 或域名。", "en-US": "127.0.0.1 only reaches the backend on this computer. Phones or other computers need the backend computer's LAN IP or domain.", }, - "settings.showAdvancedConnection": { "zh-CN": "自定义代理或高级部署设置", "en-US": "Custom proxy or advanced deployment settings" }, - "settings.advancedEndpoints": { "zh-CN": "高级连接设置", "en-US": "Advanced connection settings" }, + "settings.showAdvancedConnection": { "zh-CN": "排障:自定义连接地址", "en-US": "Troubleshooting: custom connection URLs" }, + "settings.advancedEndpoints": { "zh-CN": "排障:自定义连接地址", "en-US": "Troubleshooting: custom connection URLs" }, "settings.resetGeneratedEndpoints": { "zh-CN": "按服务器地址重新生成", "en-US": "Regenerate from server URL" }, - "settings.baseUrl": { "zh-CN": "请求地址(高级)", "en-US": "Request URL (advanced)" }, - "settings.wsUrl": { "zh-CN": "同步连接地址(高级)", "en-US": "Sync connection URL (advanced)" }, + "settings.baseUrl": { "zh-CN": "自定义请求地址", "en-US": "Request address for custom proxy" }, + "settings.wsUrl": { "zh-CN": "自定义同步地址", "en-US": "Sync address for custom proxy" }, "settings.baseUrlHint": { "zh-CN": "仅在反向代理拆分接口路径时修改,保存后新的请求会使用这个地址。", "en-US": "Change only when a reverse proxy splits the request path. New requests use this address after saving." }, "settings.wsUrlHint": { "zh-CN": "仅在反向代理拆分同步路径时修改,保存后同步连接会使用这个地址。", "en-US": "Change only when a reverse proxy splits the sync path. Sync uses this address after saving." }, "settings.deviceId": { "zh-CN": "设备 ID", "en-US": "Device ID" }, @@ -654,6 +719,48 @@ const messages: Record> = { "en-US": "Task times use this time zone. Sync data remains stored as UTC.", }, "settings.accountDisplay": { "zh-CN": "账号与显示", "en-US": "Account and display" }, + "settings.accountSecurity": { "zh-CN": "账号安全", "en-US": "Account security" }, + "settings.refreshSessions": { "zh-CN": "刷新会话", "en-US": "Refresh sessions" }, + "settings.changePassword": { "zh-CN": "修改密码", "en-US": "Change password" }, + "settings.currentPassword": { "zh-CN": "当前密码", "en-US": "Current password" }, + "settings.newPassword": { "zh-CN": "新密码", "en-US": "New password" }, + "settings.confirmNewPassword": { "zh-CN": "确认新密码", "en-US": "Confirm new password" }, + "settings.passwordMismatch": { "zh-CN": "两次输入的新密码不一致。", "en-US": "The new passwords do not match." }, + "settings.passwordChanged": { + "zh-CN": "密码已修改,并注销了 {count} 个其他会话。", + "en-US": "Password changed and {count} other sessions were revoked.", + }, + "settings.passwordChangeFailed": { "zh-CN": "密码修改失败。", "en-US": "Could not change the password." }, + "settings.activeSessions": { "zh-CN": "登录会话", "en-US": "Sign-in sessions" }, + "settings.sessionSecurityHint": { + "zh-CN": "发现不认识的会话时请立即吊销;吊销当前会话后需要重新登录。", + "en-US": "Revoke sessions you do not recognize. Revoking the current session requires signing in again.", + }, + "settings.loadingSessions": { "zh-CN": "正在加载会话...", "en-US": "Loading sessions..." }, + "settings.noActiveSessions": { "zh-CN": "未找到活动会话。", "en-US": "No active sessions found." }, + "settings.thisDevice": { "zh-CN": "此设备", "en-US": "This device" }, + "settings.unknownDevice": { "zh-CN": "未知设备", "en-US": "Unknown device" }, + "settings.sessionCreated": { "zh-CN": "登录时间", "en-US": "Signed in" }, + "settings.sessionExpires": { "zh-CN": "到期时间", "en-US": "Expires" }, + "settings.revokeSession": { "zh-CN": "吊销", "en-US": "Revoke" }, + "settings.revokeSessionConfirm": { + "zh-CN": "吊销这个登录会话?该会话随后需要重新登录。", + "en-US": "Revoke this sign-in session? It will need to sign in again.", + }, + "settings.sessionRevoked": { "zh-CN": "会话已吊销。", "en-US": "Session revoked." }, + "settings.sessionRevokeFailed": { "zh-CN": "无法吊销会话。", "en-US": "Could not revoke the session." }, + "settings.revokeOtherSessions": { "zh-CN": "注销其他设备", "en-US": "Sign out other devices" }, + "settings.revokeOtherSessionsConfirm": { + "zh-CN": "注销其他设备上的所有会话?这些设备随后需要重新登录。", + "en-US": "Sign out every other device? Those devices will need to sign in again.", + }, + "settings.otherSessionsRevoked": { + "zh-CN": "已注销 {count} 个其他设备会话。", + "en-US": "Signed out {count} sessions on other devices.", + }, + "settings.sessionsLoadFailed": { "zh-CN": "无法加载登录会话。", "en-US": "Could not load sign-in sessions." }, + "settings.serverTodayCount": { "zh-CN": "服务器今日待办(当前时区)", "en-US": "Server tasks today (current time zone)" }, + "settings.serverOverdueCount": { "zh-CN": "服务器逾期待办", "en-US": "Server overdue tasks" }, "settings.dataSession": { "zh-CN": "数据与备份", "en-US": "Data and backups" }, "settings.dataTools": { "zh-CN": "备份与本机数据", "en-US": "Backups and local data" }, "settings.general": { "zh-CN": "基础", "en-US": "General" }, @@ -665,7 +772,7 @@ const messages: Record> = { "settings.exportBackup": { "zh-CN": "导出本地备份", "en-US": "Export local backup" }, "settings.importBackup": { "zh-CN": "导入本地备份", "en-US": "Import local backup" }, "settings.checkUpdates": { "zh-CN": "检查更新", "en-US": "Check updates" }, - "settings.clearLocalData": { "zh-CN": "退出并清除此设备数据", "en-US": "Log out and clear this device" }, + "settings.clearLocalData": { "zh-CN": "清除此设备数据", "en-US": "Clear this device" }, "settings.clearLocalDataSafetyHint": { "zh-CN": "清除前先确认没有待同步、同步失败或冲突的任务;不确定时先导出本地备份。", "en-US": "Before clearing, check that there are no pending, failed, or conflicting tasks. Export a local backup if unsure.", @@ -726,6 +833,7 @@ const messages: Record> = { "floating.feedbackAdded": { "zh-CN": "已添加", "en-US": "Added" }, "floating.feedbackAddedInbox": { "zh-CN": "已添加到收件箱,可在主窗口查看", "en-US": "Added to Inbox. Open the main window to view" }, "floating.feedbackCompleted": { "zh-CN": "已完成", "en-US": "Completed" }, + "floating.hiddenTasks": { "zh-CN": "另有 {count} 项,打开主窗口", "en-US": "{count} more. Open main window" }, "floating.placeholder": { "zh-CN": "快速添加待办", "en-US": "Quick add task", diff --git a/desktop/tests/desktop-focus-workspace.test.mjs b/desktop/tests/desktop-focus-workspace.test.mjs new file mode 100644 index 0000000..25bf8c3 --- /dev/null +++ b/desktop/tests/desktop-focus-workspace.test.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const desktopRoot = resolve(fileURLToPath(new URL("..", import.meta.url))); +const repoRoot = resolve(desktopRoot, ".."); + +async function source(path) { + return readFile(resolve(repoRoot, path), "utf8"); +} + +test("desktop app composes the focus workspace shell", async () => { + const app = await source("desktop/src/App.vue"); + + assert.match(app, /import \{ deriveWorkspaceStatus \} from "\.\.\/shared\/workspace-ui-policy"/); + assert.match(app, /import AppSidebar from "\.\/components\/AppSidebar\.vue"/); + assert.match(app, /import WorkspaceStatusBanner from "\.\/components\/WorkspaceStatusBanner\.vue"/); + assert.match(app, /const workspaceStatus = computed\(\(\) =>[\s\S]{0,120}deriveWorkspaceStatus\(syncStore\.status, syncStore\.diagnostics\)/); + assert.match(app, / { + const sidebar = await source("desktop/src/components/AppSidebar.vue"); + + assert.match(sidebar, /from "lucide-vue-next"/); + assert.match(sidebar, /CalendarDays/); + assert.match(sidebar, /ListTodo/); + assert.match(sidebar, /Settings/); + assert.match(sidebar, /aria-current/); + assert.match(sidebar, /aria-expanded/); + assert.match(sidebar, /role="menu"/); + assert.match(sidebar, /role="menuitem"/); + assert.match(sidebar, /emit\("navigate", "today"\)/); + assert.match(sidebar, /emit\("navigate", "tasks"\)/); + assert.match(sidebar, /emit\("navigate", "settings"\)/); +}); + +test("desktop workspace banner exposes polite retry and details actions", async () => { + const banner = await source("desktop/src/components/WorkspaceStatusBanner.vue"); + + assert.match(banner, /aria-live="polite"/); + assert.match(banner, /aria-atomic="true"/); + assert.match(banner, /retry: \[\]/); + assert.match(banner, /openDetails: \[\]/); + assert.match(banner, /status\.issueCount > 0/); +}); + +test("desktop toast teleports an accessible status message", async () => { + const toast = await source("desktop/src/components/AppToast.vue"); + + assert.match(toast, //); + assert.match(toast, / { + const i18n = await source("desktop/src/i18n.ts"); + const keys = [ + "nav.accountMenu", + "sync.details", + "sync.retry", + "sync.offlineWorkspace", + "sync.attentionWorkspace", + "sync.attentionWorkspaceCount", + "task.saveFailed", + "task.quickAdd", + "task.quickAddMore", + ]; + + for (const key of keys) { + const entry = new RegExp(`"${key.replaceAll(".", "\\.")}"\\s*:\\s*\\{[^}]*"zh-CN"\\s*:\\s*"[^"]+"[^}]*"en-US"\\s*:\\s*"[^"]+"[^}]*\\}`); + assert.match(i18n, entry, `${key} must provide zh-CN and en-US copy`); + } +}); From 8a0b0970af0a753d6081779f29cd12b33fc6d63e Mon Sep 17 00:00:00 2001 From: xk27 <1780350196@qq.com> Date: Thu, 16 Jul 2026 00:38:30 +0800 Subject: [PATCH 07/24] =?UTF-8?q?fix(=E6=A1=8C=E9=9D=A2=E7=AB=AF):=20?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E8=B4=A6=E6=88=B7=E8=8F=9C=E5=8D=95=E5=A4=96?= =?UTF-8?q?=E9=83=A8=E5=85=B3=E9=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- desktop/shared/workspace.ts | 83 +++++++++++++++++++ desktop/src/components/AppSidebar.vue | 8 +- .../tests/desktop-focus-workspace.test.mjs | 13 +++ 3 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 desktop/shared/workspace.ts diff --git a/desktop/shared/workspace.ts b/desktop/shared/workspace.ts new file mode 100644 index 0000000..f070aa0 --- /dev/null +++ b/desktop/shared/workspace.ts @@ -0,0 +1,83 @@ +export const EPOCH_SYNC_TIME = "1970-01-01T00:00:00Z"; + +export interface WorkspaceCursorMigration { + cursors: Record; + legacyWorkspaceKey: string; + current: string; +} + +export function normalizeServerOrigin(baseUrl: string): string { + const value = baseUrl.trim(); + if (!value) throw new Error("Server URL is required"); + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("Server URL must use HTTP or HTTPS"); + } + return url.origin; +} + +export function createWorkspaceKey(baseUrl: string, userId: number): string { + if (!Number.isSafeInteger(userId) || userId <= 0) { + throw new Error("Invalid workspace user ID"); + } + return `${normalizeServerOrigin(baseUrl)}::user:${userId}`; +} + +export function tryCreateWorkspaceKey(baseUrl: string, userId: number | null | undefined): string | null { + if (typeof userId !== "number") return null; + try { + return createWorkspaceKey(baseUrl, userId); + } catch { + return null; + } +} + +export function workspaceDatabaseFileName(baseUrl: string, userId: number): string { + const workspaceKey = createWorkspaceKey(baseUrl, userId); + return `taskbridge-workspace-user-${userId}-${fnv1a64(workspaceKey)}.sqlite`; +} + +export function migrateLegacyWorkspaceCursor( + existingCursors: Readonly>, + legacyCursor: string, + legacyWorkspaceKey: string | undefined, + workspaceKey: string, +): WorkspaceCursorMigration { + const cursors = { ...existingCursors }; + const claimedWorkspaceKey = legacyWorkspaceKey?.trim() ?? ""; + if (isLegacyWorkspaceOwner(claimedWorkspaceKey, workspaceKey) && !cursors[workspaceKey]) { + cursors[workspaceKey] = normalizeCursor(legacyCursor); + } + return { + cursors, + legacyWorkspaceKey: claimedWorkspaceKey, + current: normalizeCursor(cursors[workspaceKey]), + }; +} + +export function isLegacyWorkspaceOwner( + claimedWorkspaceKey: string | null | undefined, + workspaceKey: string, +): boolean { + return Boolean(claimedWorkspaceKey?.trim()) && claimedWorkspaceKey?.trim() === workspaceKey; +} + +export function shouldPreserveWorkspaceState( + previousWorkspaceKey: string | null | undefined, + nextWorkspaceKey: string | null | undefined, +): boolean { + return Boolean(previousWorkspaceKey) && previousWorkspaceKey === nextWorkspaceKey; +} + +function normalizeCursor(value: unknown): string { + return typeof value === "string" && value.trim() ? value.trim() : EPOCH_SYNC_TIME; +} + +function fnv1a64(value: string): string { + let hash = 0xcbf29ce484222325n; + for (const byte of new TextEncoder().encode(value)) { + hash ^= BigInt(byte); + hash = BigInt.asUintN(64, hash * 0x100000001b3n); + } + return hash.toString(16).padStart(16, "0"); +} diff --git a/desktop/src/components/AppSidebar.vue b/desktop/src/components/AppSidebar.vue index f86dac0..5fd40aa 100644 --- a/desktop/src/components/AppSidebar.vue +++ b/desktop/src/components/AppSidebar.vue @@ -32,7 +32,7 @@ const emit = defineEmits<{ const settingsStore = useSettingsStore(); const menuOpen = shallowRef(false); -const sidebarRoot = useTemplateRef("sidebarRoot"); +const accountMenuRoot = useTemplateRef("accountMenuRoot"); const accountTrigger = useTemplateRef("accountTrigger"); const statusTitle = computed(() => { const key = { @@ -90,7 +90,7 @@ function logout(): void { function closeOnOutsidePointer(event: PointerEvent): void { if (!menuOpen.value) return; - if (event.target instanceof Node && !sidebarRoot.value?.contains(event.target)) { + if (event.target instanceof Node && !accountMenuRoot.value?.contains(event.target)) { closeMenu(); } } @@ -108,7 +108,7 @@ function closeMenu(): void {