diff --git a/docs/specs/agent-plan.md b/docs/specs/agent-plan.md new file mode 100644 index 0000000..e0a1e53 --- /dev/null +++ b/docs/specs/agent-plan.md @@ -0,0 +1,389 @@ +# Win11 文件管理器风格迁移 - Agent 团队并行执行计划 + +## Context + +PhotoWall 需要从"多页面 + 旧版 dashboard/sidebar + 局部玻璃拟态"架构迁移为 Windows 11 文件管理器风格的统一壳层。规范文档已完成:`docs/specs/product.md`、`docs/specs/architecture.md`、`docs/specs/tasks.md`,共 32 个任务分 5 个阶段。本计划设计 agent 团队实现最大化并行开发。 + +--- + +## ⚠️ 全局强制约束(所有 Agent 必须遵守) + +### 规范至上原则 + +1. **每个 agent 启动后必须先读取三份规范文档**:`docs/specs/product.md`、`docs/specs/architecture.md`、`docs/specs/tasks.md` +2. **严格按照 `tasks.md` 中的验证标准实现**,不得自行发明需求、不得跳过验证项、不得"差不多就行" +3. **严格按照 `architecture.md` 中的数据结构和 API 契约实现**,字段名、类型、默认值必须与规范一致 +4. **如发现规范与现有代码不一致,先报告给主 agent,不自行决定** + +### 禁止事项(硬性红线) + +- ❌ 不得保留 `localStorage`/`persist` 作为正式设置落盘源 +- ❌ 不得保留旧 `Layout`、旧 `Sidebar`、旧 dashboard 作为运行时 fallback +- ❌ 不得保留固定浅色逻辑或双主题源 +- ❌ 不得通过 deprecated 主路径、隐藏入口但保留命令注册等方式"保留一版" +- ❌ 不得为页面换皮单独发明新查询接口(优先复用现有业务查询面) +- ❌ 不得修改不属于自己职责范围的文件(见合并冲突热点表) + +### 允许的兜底(仅两类) + +- ✅ 平台级窗口效果降级:`mica → acrylic → solid` +- ✅ 旧配置缺字段时补默认值并立即归一化到新结构 + +### 验证要求 + +每个 agent 完成后必须执行: +1. `npm run lint` — ESLint 检查 +2. `npm run test` — Vitest 测试 +3. `cd src-tauri && cargo check` — Rust 编译检查(涉及后端的 agent) +4. `cd src-tauri && cargo clippy` — Rust 静态分析(涉及后端的 agent) + +### 环境约束 + +- 使用 PowerShell 或 CMD,不使用 bash/sh/WSL +- 使用 Windows 路径(反斜杠) +- 路径别名:`@/` 对应 `src/` + +--- + +## Agent 团队设计(13 个 Agent) + +### Agent 1: `contract-agent` — 设置契约与类型对齐 +- **任务:** T-002 +- **职责:** 统一 TypeScript 与 Rust 的 `AppSettings` 契约,包括新增 `appearance`、`window`(扩展 `effectMode` 等字段)、`shell` 三个子结构 +- **必读规范:** `architecture.md` §4(统一设置模型)全部内容,包括 §4.2 字段定义、§4.3 兼容策略、§4.4 旧字段迁移表 +- **关键文件:** + - `src/types/index.ts` — 新增/修改 `AppSettings`、`ShellSettings`、`WindowSettings`、`AppearanceConfig`、`FilterState`、`NodeViewPreference` 等 TS 类型 + - `src-tauri/src/models/settings.rs` — 扩展 Rust `AppSettings` 结构体,新增 `AppearanceConfig`、`ShellSettings`、`WindowSettings`(含 `effect_mode`) +- **依赖:** 无(首批启动) +- **产出:** 前后端类型契约对齐,后续所有 agent 可引用统一类型 + +### Agent 2: `fluent-setup-agent` — Fluent UI 安装与根配置 +- **任务:** T-001 +- **职责:** 安装 `@fluentui/react-components` 依赖,在 `main.tsx` 配置 `FluentProvider`,验证基础 Fluent 组件可渲染 +- **必读规范:** `product.md` §4 约束(必须使用 Fluent UI React v9);`architecture.md` §1 ADR 表中组件库决策 +- **关键文件:** + - `package.json` — 添加 Fluent UI 依赖 + - `src/main.tsx` — 包裹 `FluentProvider` + - `src/App.tsx` — 可能需要调整根组件结构 +- **依赖:** 无(首批启动) +- **产出:** Fluent UI 可用,后续 shell 组件可直接使用 Fluent 组件 + +### Agent 3: `settings-api-agent` — API 层拆分与设置命令扩展 +- **任务:** T-003 +- **职责:** 将 `api.ts` 拆分为模块化结构(见 `architecture.md` §5.0),扩展前端 `services/api/settings.ts`(新增 `saveShellSettings` 等),扩展后端 `commands/settings.rs`(新增 `save_shell_settings` 命令)和 `services/settings.rs`(保存逻辑) +- **必读规范:** `architecture.md` §5.0(API 层拆分)、§5.2(新增接口)、§5.3(保存优先级与写入规则)完整内容 +- **关键写入规则(必须严格遵守):** + - `save_shell_settings` 只更新 `shell` 子树 + - `save_settings` 始终保留服务端最新 `shell`,忽略请求体中的 `shell` + - 除 `save_shell_settings` 外不存在第二条写 `shell` 的路径 +- **关键文件:** + - `src/services/api.ts` → 拆分为 `src/services/api/` 模块化结构 + - `src/services/api/index.ts` — 重新导出保证现有 import 不破坏 + - `src-tauri/src/commands/settings.rs` — 新增 `save_shell_settings` 命令 + - `src-tauri/src/services/settings.rs` — 扩展 `SettingsManager` + - `src-tauri/src/lib.rs` — 注册新命令 +- **依赖:** T-002(`contract-agent` 完成后启动) +- **产出:** 前后端设置读写通路打通 + +### Agent 4: `theme-agent` — 主题统一与窗口联动 +- **任务:** T-004, T-006 +- **职责:** 统一主题机制(移除固定浅色逻辑、移除 localStorage 双持久化、FluentProvider 主题与根节点 `.dark` 类统一),然后打通前端窗口外观与设置联动 +- **必读规范:** `architecture.md` §5.5(主题与窗口的单一事实源)、§4.5(反兜底原则);`product.md` US-008(深色模式)全部 AC +- **关键文件:** + - `src/stores/settingsStore.ts` — 重构为从后端 hydrate,移除 `persist` middleware 的正式设置部分 + - `src/hooks/useThemeColor.ts` — 统一主题色应用逻辑 + - `src/hooks/useTheme.ts` — 统一主题模式(light/dark/system) + - `src/App.tsx` — FluentProvider 主题绑定 + 窗口外观应用逻辑 +- **依赖:** T-004 等 T-001+T-002;T-006 还需等 T-003+T-005 +- **执行顺序:** 先做 T-004(等 T-001+T-002),再做 T-006(等 T-003+T-005 额外完成) + +### Agent 5: `window-effects-agent` — 原生窗口效果扩展 +- **任务:** T-005 +- **职责:** 扩展 Rust 窗口效果,支持 `effectMode`(auto/mica/acrylic/solid)和 Win11 降级策略 +- **必读规范:** `architecture.md` §4.2 `window` 字段定义(默认策略);`product.md` US-007(Win11 材质与原生窗口集成)全部 AC +- **关键文件:** + - `src-tauri/src/commands/window_effects.rs` — 扩展 `apply_window_settings` + - `src-tauri/src/services/window_effects.rs`(如果存在) + - `src-tauri/src/models/settings.rs` — 确认 `WindowSettings` 新字段可用(由 `contract-agent` 已定义) +- **依赖:** T-002(`contract-agent` 完成后启动) +- **产出:** 窗口效果支持 effectMode 和平台降级 + +### Agent 6: `shell-frame-agent` — AppShell 骨架与顶部/底部栏 +- **任务:** T-007, T-008, T-009, T-012, T-013 +- **职责:** 创建 AppShell 容器(替换旧 Layout)、TitleBar、CommandBar、BreadcrumbBar、StatusBar +- **必读规范:** `product.md` US-002(命令栏)、US-003(面包屑)、US-009(状态栏)全部 AC;`architecture.md` §3.1 前端模块 `shell/` 目录结构;`tasks.md` T-007~T-009、T-012~T-013 的验证标准 +- **关键文件:** + - `src/components/shell/AppShell.tsx` — 新建,五大区域容器 + - `src/components/shell/TitleBar.tsx` — 新建,窗口拖拽 + 控制按钮 + - `src/components/shell/CommandBar.tsx` — 新建,视图切换/排序/筛选/预览开关/上下文操作 + - `src/components/shell/BreadcrumbBar.tsx` — 新建,面包屑导航 + - `src/components/shell/StatusBar.tsx` — 新建,项目总数/选中数/视图模式 + - `src/App.tsx` — 替换旧 Layout 为 AppShell +- **依赖:** T-007 等 T-006;T-012+T-013 等 T-011 +- **执行顺序:** T-007 → T-008 + T-009 并行 → 等 T-011 → T-012 + T-013 并行 + +### Agent 7: `nav-agent` — 导航窗格与导航状态持久化 +- **任务:** T-010, T-011 +- **职责:** 创建 NavigationPane + NavTree(接入文件夹树、相册、标签等业务数据),扩展 navigationStore 接入 shell 持久化 +- **必读规范:** `product.md` US-001(左侧导航窗格)全部 AC、US-010(壳层状态持久化)全部 AC;`architecture.md` §4.2 `shell` 字段定义、§5.4(路由与导航状态优先级);`tasks.md` T-010、T-011 验证标准 +- **关键持久化规则(必须严格遵守):** + - 加载优先级固定为 `URL > persisted shell.activeNode > default photos` + - 变更后 debounce 调用 `save_shell_settings` + - 不再通过 localStorage 保存正式 shell 状态 + - `viewPreferences` 按导航节点 ID 独立存储 +- **关键文件:** + - `src/components/navigation/NavigationPane.tsx` — 新建,左侧导航窗格 + - `src/components/navigation/NavTree.tsx` — 新建,树形导航 + - `src/stores/navigationStore.ts` — 大幅扩展:activeNode、expandedNodes、navPaneWidth、previewPaneOpen/Width、viewMode、sortBy/Order、filter、detailColumns/Widths、viewPreferences + debounce save_shell_settings + - `src/types/index.ts` — 可能需要补充导航专用类型(如 NavNode) +- **依赖:** T-010 等 T-007;T-011 等 T-003 也完成 +- **执行顺序:** T-010(等 T-007)→ T-011(等 T-003 也完成) + +### Agent 8: `content-views-agent` — 内容区与六种视图 +- **任务:** T-014, T-015, T-016 +- **职责:** 创建 ContentArea + ViewSwitcher,实现大/中/小图标视图和列表/详细信息/平铺视图 +- **必读规范:** `product.md` US-004(多视图模式)全部 AC(AC-004.1 ~ AC-004.7);`architecture.md` §9 性能考量(视图切换 < 100ms、10000 张照片 ≥ 55fps);`tasks.md` T-014~T-016 验证标准 +- **关键性能要求:** + - 非当前视图使用 `React.lazy` 懒加载 + - 复用现有 `PhotoGrid` / 缩略图 / react-virtuoso 虚拟滚动链路 + - 详细信息视图支持列排序和列宽调整,列宽可回写 `shell.detailColumnWidths` +- **关键文件:** + - `src/components/content/ContentArea.tsx` — 新建,根据 viewMode 渲染对应视图 + - `src/components/content/ViewSwitcher.tsx` — 新建,Fluent UI Menu 视图切换 + - `src/components/content/views/LargeIconView.tsx` — 新建(~256px 缩略图网格) + - `src/components/content/views/MediumIconView.tsx` — 新建(~128px) + - `src/components/content/views/SmallIconView.tsx` — 新建(~64px) + - `src/components/content/views/ListView.tsx` — 新建(单列列表) + - `src/components/content/views/DetailView.tsx` — 新建(表格+排序+列宽调整) + - `src/components/content/views/TileView.tsx` — 新建(平铺卡片) +- **依赖:** T-009 + T-011 完成后启动 +- **执行顺序:** T-014 → T-015 + T-016 并行 + +### Agent 9: `preview-viewer-agent` — 预览窗格与只读查看器 +- **任务:** T-017, T-018 +- **职责:** 创建 PreviewPane(选中照片预览+元数据),改造 PhotoViewer 为只读 Win11 风格 +- **必读规范:** `product.md` US-005(右侧预览窗格)全部 AC、US-006(应用内全屏查看器)全部 AC(特别注意 AC-006.4 不得出现编辑入口);`tasks.md` T-017、T-018 验证标准 +- **关键文件:** + - `src/components/preview/PreviewPane.tsx` — 新建,照片预览+元数据面板 + - `src/components/preview/MetadataPanel.tsx` — 新建,EXIF/标签/评分等元数据展示 + - `src/components/photo/PhotoViewer.tsx` — 改造:移除编辑入口和编辑状态,保留缩放/平移/切换/信息 +- **依赖:** T-018 只需 T-007;T-017 需 T-011 + T-014 +- **执行顺序:** T-018(等 T-007)可先行;T-017(等 T-011 + T-014) + +### Agent 10: `page-migration-agent` — 业务页面迁移(主页+文件夹+相册+标签+收藏+废纸篓) +- **任务:** T-019, T-020, T-021a, T-021b +- **职责:** 将照片主页、文件夹页、相册页、标签页、收藏页、废纸篓页迁移到新壳层内容区 +- **必读规范:** `product.md` US-011(现有业务区域统一纳入新外壳)全部 AC;`architecture.md` §5.1(复用的现有内容接口)完整表格;`tasks.md` T-019~T-021b 每条的验证标准 +- **核心原则(必须严格遵守):** + - 优先复用现有业务查询接口(`get_photos_cursor`、`get_folder_tree`、`get_all_albums_with_count`、`get_all_tags_with_count` 等),只有在现有 payload 明确缺失字段时才扩展 + - 所有页面必须在 AppShell 内容区内渲染,不得退回旧式页面布局 + - 旧 dashboard 元素(HeroSection、TagRibbon、ContentShelf)退出主流程 + - 不得为"页面换皮"重写现有数据库查询逻辑 +- **关键文件:** + - `src/pages/HomePage.tsx` — 迁移到 AppShell 内容区 + - `src/pages/FoldersPage.tsx` — 迁移 + - `src/pages/AlbumsPage.tsx` — 迁移 + - `src/pages/TagsPage.tsx` — 迁移 + - `src/pages/FavoritesPage.tsx` — 迁移 + - `src/pages/TrashPage.tsx` — 迁移 +- **依赖:** + - T-019 需 T-015, T-016, T-017, T-018 + - T-020 需 T-010, T-014, T-015 + - T-021a 需 T-014, T-015 + - T-021b 需 T-014, T-015 +- **执行顺序:** T-021a + T-021b(T-014+T-015 就绪后立即启动)→ T-020(T-010 也就绪后)→ T-019(T-016+T-017+T-018 全部就绪后) + +### Agent 11: `settings-page-agent` — 设置页迁移与统一设置模型接入 +- **任务:** T-022 +- **职责:** 将设置页迁移到新壳层,接入统一 `AppSettings` 模型,确保设置页保存不覆盖 `shell` 状态 +- **必读规范:** `architecture.md` §5.3(保存优先级与写入规则)——这是本 agent 最关键的约束;`product.md` US-008(深色模式)AC-008.3;`product.md` US-010(壳层状态持久化)AC-010.5;`tasks.md` T-022 验证标准 +- **关键写入规则(必须严格遵守):** + - 设置页提交的 `AppSettings` 即使携带 `shell`,服务端也必须忽略该字段 + - 整份保存不会覆盖新的 `shell` 状态 + - 主题色、主题模式、窗口效果统一走 `AppSettings`,不产生前后端双设置源 + - 不再由前端 `persist/localStorage` 作为正式落盘源 +- **关键文件:** + - `src/pages/SettingsPage.tsx` — 迁移到新壳层 + 接入统一设置模型 + - `src/services/api/settings.ts` — 调用(只读,不修改此文件) + - `src/stores/settingsStore.ts` — 调用(只读,不修改此文件) +- **依赖:** T-003, T-004, T-007, T-011 +- **产出:** 设置页在新壳层中工作,统一设置模型完全接入 + +### Agent 12: `filter-search-agent` — 筛选面板与搜索迁移 +- **任务:** T-022a, T-022b +- **职责:** 实现五维筛选面板与内容区联动,迁移搜索功能到新壳层 +- **必读规范:** `architecture.md` §4.2 `FilterState` 结构定义(五个维度:fileTypes、dateRange、tags、rating、sizeRange);`tasks.md` T-022a、T-022b 验证标准 +- **关键实现要求:** + - 筛选面板从 CommandBar 筛选按钮触发 + - 五维筛选:文件类型、拍摄日期范围、标签、评分、文件大小 + - 筛选状态写回 `shell.filter` 并通过 `save_shell_settings` 持久化 + - 重启后恢复筛选条件 + - 搜索入口集成到 CommandBar 或 TitleBar + - 搜索结果在 ContentArea 中渲染,适配当前视图模式 +- **关键文件:** + - `src/components/content/FilterPanel.tsx` — 新建,五维筛选面板 + - `src/components/content/ContentArea.tsx` — 集成筛选联动(追加,不重写) + - `src/components/search/SearchPanel.tsx` — 迁移到新壳层 + - `src/components/search/SearchSuggestions.tsx` — 迁移 + - `src/components/shell/CommandBar.tsx` — 追加筛选按钮和搜索入口(追加,不重写) + - `src/stores/navigationStore.ts` — 读取 filter 状态(只读,不修改此文件) +- **依赖:** T-022a 需 T-009, T-011, T-014;T-022b 需 T-009, T-014 +- **执行顺序:** T-022a + T-022b 可并行 + +### Agent 13: `context-menu-agent` — 右键菜单迁移到 Fluent UI +- **任务:** T-022c +- **职责:** 将所有右键菜单替换为 Fluent UI Menu,移除编辑相关菜单项 +- **必读规范:** `product.md` US-012(照片编辑器完全移除)AC-012.1——右键菜单不得出现编辑入口;`tasks.md` T-022c 验证标准 +- **关键实现要求:** + - 照片网格、导航树、预览窗格的右键菜单全部替换为 Fluent UI Menu + - 菜单项与现有功能一致(复制、移动、删除、添加标签、添加到相册等) + - 不再出现编辑相关菜单项 + - 使用 Fluent UI `Menu`/`MenuTrigger`/`MenuPopover`/`MenuList`/`MenuItem` 组件 +- **关键文件:** + - `src/components/common/ContextMenu.tsx` — 替换为 Fluent UI Menu + - `src/components/content/views/*.tsx` — 右键菜单集成 + - `src/components/navigation/NavTree.tsx` — 右键菜单集成(追加,不重写导航逻辑) + - `src/components/preview/PreviewPane.tsx` — 右键菜单集成(追加,不重写预览逻辑) +- **依赖:** T-015, T-016, T-017 +- **产出:** 全应用右键菜单统一为 Fluent UI 风格 + +--- + +## 执行波次(并行时间线) + +### Wave 1:基础契约(并行 2 个 agent) +| Agent | 任务 | 预估 | +|:---|:---|:---| +| `contract-agent` | T-002 | 2-3h | +| `fluent-setup-agent` | T-001 | 1-2h | + +**Wave 1 产出:** TS+Rust 类型契约对齐;Fluent UI 可用 + +### Wave 2:API/窗口/主题(并行 3 个 agent) +| Agent | 任务 | 预估 | 等待 | +|:---|:---|:---|:---| +| `settings-api-agent` | T-003 | 3-5h | T-002 完成 | +| `window-effects-agent` | T-005 | 2-3h | T-002 完成 | +| `theme-agent` | T-004 | 2-3h | T-001 + T-002 完成 | + +**Wave 2 产出:** 设置读写通路打通;窗口效果支持 effectMode;主题统一 + +### Wave 3:主题联动(`theme-agent` 继续) +| Agent | 任务 | 预估 | 等待 | +|:---|:---|:---|:---| +| `theme-agent` | T-006 | 1-2h | T-003 + T-005 完成 | + +**Wave 3 产出:** 前端窗口外观与设置联动打通;`shell-frame-agent` 可启动 + +### Wave 4:壳层骨架 + 导航 + 查看器改造(并行 3 个 agent) +| Agent | 任务 | 预估 | 等待 | +|:---|:---|:---|:---| +| `shell-frame-agent` | T-007 → T-008 + T-009 | 5-8h | T-006 完成 | +| `nav-agent` | T-010(等 T-007)→ T-011(等 T-003) | 5-7h | T-007 完成后启动 | +| `preview-viewer-agent` | T-018(只需 T-007) | 2-3h | T-007 完成 | + +**Wave 4 产出:** AppShell 骨架就位;TitleBar、CommandBar 可用;NavigationPane + NavTree 可用;navigationStore 扩展完成;PhotoViewer 改造为只读 + +### Wave 5:面包屑/状态栏 + 内容视图 + 预览窗格(并行 3 个 agent) +| Agent | 任务 | 预估 | 等待 | +|:---|:---|:---|:---| +| `shell-frame-agent` | T-012 + T-013 | 3-4h | T-011 完成 | +| `content-views-agent` | T-014 → T-015 + T-016 | 9-12h | T-009 + T-011 完成 | +| `preview-viewer-agent` | T-017 | 3-4h | T-011 + T-014 完成 | + +**Wave 5 产出:** BreadcrumbBar、StatusBar 完成;六种内容视图可用;PreviewPane 可用 + +### Wave 6:页面迁移 + 筛选搜索 + 右键菜单 + 设置页(并行 4 个 agent) +| Agent | 任务 | 预估 | 等待 | +|:---|:---|:---|:---| +| `page-migration-agent` | T-021a, T-021b, T-020, T-019 | 8-12h | T-014+T-015 就绪后分批启动 | +| `settings-page-agent` | T-022 | 3-4h | T-003+T-004+T-007+T-011 就绪 | +| `filter-search-agent` | T-022a + T-022b | 5-7h | T-009+T-011+T-014 就绪 | +| `context-menu-agent` | T-022c | 2-3h | T-015+T-016+T-017 就绪 | + +**Wave 6 产出:** 所有业务页面迁移完成;设置页接入统一模型;筛选面板、搜索迁移完成;右键菜单统一为 Fluent UI + +### Wave 7:清理(顺序执行,由主 agent 或指定 agent 继续) +- T-023(移除前端编辑器)→ T-024(移除后端编辑器) +- T-025(移除旧布局)→ T-026(样式收敛) + +### Wave 8:验证 +- T-027(性能优化)→ T-028(测试回归) + +--- + +## 合并冲突热点与缓解策略 + +| 热点文件 | 涉及 Agent | 缓解策略 | +|:---|:---|:---| +| `src/types/index.ts` | contract-agent, nav-agent | contract-agent 先完成所有类型定义;nav-agent 只追加导航专用类型 | +| `src/App.tsx` | fluent-setup-agent, theme-agent, shell-frame-agent | 按波次顺序修改:fluent-setup(W1) → theme(W2-3) → shell-frame(W4) | +| `src/stores/settingsStore.ts` | theme-agent, settings-page-agent | theme-agent 先重构完成;settings-page-agent 只读调用不修改 | +| `src/stores/navigationStore.ts` | nav-agent(主修改者)| 仅 nav-agent 修改此文件;filter-search-agent 只读 | +| `src-tauri/src/models/settings.rs` | contract-agent, window-effects-agent | contract-agent 先定义完整结构;window-effects-agent 只使用不修改 | +| `src-tauri/src/lib.rs` | settings-api-agent(注册新命令)| 仅 settings-api-agent 修改命令注册 | +| `src/services/api/settings.ts` | settings-api-agent, theme-agent | settings-api-agent 先扩展 API;theme-agent 只调用不修改 | +| `src/components/shell/CommandBar.tsx` | shell-frame-agent, filter-search-agent | shell-frame-agent 先创建;filter-search-agent 后续追加筛选/搜索入口 | +| `src/components/content/ContentArea.tsx` | content-views-agent, filter-search-agent | content-views-agent 先创建;filter-search-agent 后续追加筛选联动 | +| `src/components/navigation/NavTree.tsx` | nav-agent, context-menu-agent | nav-agent 先创建;context-menu-agent 后续追加右键菜单 | +| `src/components/preview/PreviewPane.tsx` | preview-viewer-agent, context-menu-agent | preview-viewer-agent 先创建;context-menu-agent 后续追加右键菜单 | + +**核心原则:** 每个文件有且仅有一个"主修改者" agent,其他 agent 只读或追加。通过波次顺序天然避免并发写入。 + +--- + +## Agent 启动配置 + +| Agent 名称 | subagent_type | 隔离模式 | 说明 | +|:---|:---|:---|:---| +| `contract-agent` | general-purpose | worktree | 修改前后端类型,需隔离 | +| `fluent-setup-agent` | general-purpose | worktree | 安装依赖+修改入口文件 | +| `settings-api-agent` | general-purpose | worktree | 前后端 API 扩展 | +| `window-effects-agent` | general-purpose | worktree | Rust 窗口效果 | +| `theme-agent` | general-purpose | worktree | 主题+窗口联动 | +| `shell-frame-agent` | general-purpose | worktree | 壳层骨架组件 | +| `nav-agent` | general-purpose | worktree | 导航窗格+状态 | +| `content-views-agent` | general-purpose | worktree | 内容区六视图 | +| `preview-viewer-agent` | general-purpose | worktree | 预览+查看器 | +| `page-migration-agent` | general-purpose | worktree | 主页+文件夹+相册+标签+收藏+废纸篓迁移 | +| `settings-page-agent` | general-purpose | worktree | 设置页迁移+统一设置模型 | +| `filter-search-agent` | general-purpose | worktree | 筛选面板+搜索迁移 | +| `context-menu-agent` | general-purpose | worktree | 右键菜单 Fluent UI 化 | + +> 所有 agent 使用 worktree 隔离,完成后由主 agent 合并到 main 分支。 + +--- + +## 验证方案 + +### 单 Agent 完成后验证 +1. `npm run lint` — ESLint 检查 +2. `npm run test` — Vitest 测试 +3. `cd src-tauri && cargo check` — Rust 编译检查(涉及后端的 agent) +4. `cd src-tauri && cargo clippy` — Rust 静态分析(涉及后端的 agent) + +### 全部合并后验证 +1. `npm run tauri dev` — 完整应用启动验证(用户手动) +2. `npm run tauri build` — 生产构建验证 +3. 手动验证清单: + - 主题切换(浅色/深色/跟随系统) + - 设置保存(不覆盖 shell 状态) + - shell 状态持久化(重启恢复导航位置、窗格宽度、视图偏好) + - 六种视图切换(大/中/小图标、列表、详细信息、平铺) + - 导航窗格(树形导航、文件夹懒加载、宽度调整) + - 预览窗格(开关、宽度调整、元数据展示) + - 面包屑(层级导航、同级切换) + - 搜索(搜索入口、搜索建议、结果渲染) + - 筛选(五维筛选、持久化、重启恢复) + - 右键菜单(Fluent UI Menu、无编辑入口) + - 编辑器已完全移除(前后端无残留) + +--- + +## 关键规范引用 + +- 需求规范:`docs/specs/product.md` +- 架构设计:`docs/specs/architecture.md` +- 任务清单:`docs/specs/tasks.md` + +**每个 agent 启动时必须先读取对应的规范文档章节,确保实现与规范一致。如发现规范与现有代码不一致,先报告,不自行决定。** diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md new file mode 100644 index 0000000..a11f566 --- /dev/null +++ b/docs/specs/architecture.md @@ -0,0 +1,469 @@ +# 架构设计 (Architecture Specification) + +> **功能名称:** Win11 文件管理器风格整体迁移 +> **版本:** v1.1 +> **状态:** 补强后待审查 +> **关联需求:** `docs/specs/product.md` +> **最后更新:** 2026-03-26 + +--- + +## 1. 系统概览 + +本次迁移是一次前后端联动的壳层重构,不是单纯的前端 UI 替换。目标是把当前“多页面 + 旧版 dashboard/sidebar + 局部玻璃拟态”的实现,迁移为 Win11 文件管理器风格的统一壳层: + +- 前端负责新的 AppShell、导航、命令栏、面包屑、内容视图、预览窗格、只读查看器和统一主题 +- Tauri 负责原生窗口材质、标题栏交互、窗口控制和平台降级 +- Rust 负责统一设置模型、壳层状态持久化、窗口外观应用以及编辑器能力退场 +- 现有照片、相册、标签、文件夹等业务查询接口尽量复用,避免无意义地重写数据层 + +本次设计的关键目标是:在不改变核心照片管理业务语义的前提下,统一前后端设置契约、统一壳层交互和视觉语言,并下线照片编辑器。 + +### 1.1 前后端职责划分 + +| 子系统 | 责任 | 明确不负责 | +|:---|:---|:---| +| React Shell | AppShell、NavigationPane、CommandBar、BreadcrumbBar、StatusBar、内容视图、PreviewPane、只读 PhotoViewer、主题应用 | 原生窗口效果计算、设置文件持久化 | +| Zustand / Query | 临时交互状态、页面内选中状态、远端数据查询缓存、壳层状态同步 | 最终持久化落盘 | +| Tauri Commands | 暴露设置读取/保存、壳层状态读写、窗口效果应用、窗口控制命令 | 直接承担前端 UI 布局 | +| Rust Services / SettingsManager | 持久化 `AppSettings`、合并旧配置默认值、应用窗口外观、维护编辑器退场后的依赖清理 | 前端组件渲染 | +| 现有业务查询命令 | 文件夹树、照片分页、相册、标签、收藏、废纸篓等数据查询 | 壳层状态管理 | + +### 架构决策记录 (ADR) + +| 决策 | 选择方案 | 被否定方案 | 理由 | +|:---|:---|:---|:---| +| 组件库 | Fluent UI React v9 | 纯 Tailwind 手写、Radix UI | 更贴近 Win11 设计语言,主题能力更完整 | +| 布局模式 | 单页面 AppShell + 内容区切换 | 保留多页面路由 + 整页切换动画 | 更接近 Win11 文件管理器,减少视觉跳转成本 | +| 路由策略 | 保留 React Router,但只负责内容区寻址 | 完全移除 Router / 继续整页路由 | 保留可寻址性,同时避免旧版页面级动画 | +| 设置模型 | 统一 `AppSettings`,前后端同时包含 `appearance`、`window`、`shell` | TS/Rust 分别维护不同结构 | 当前前后端设置模型已经漂移,必须收敛到单一契约 | +| 壳层状态持久化 | `get_settings` + `save_settings` + `save_shell_settings` 共用同一存储源 | 仅 localStorage / 每次都覆盖整份设置 | 需要后端持久化,同时避免 shell 自动保存覆盖设置页未提交草稿 | +| 内容数据层 | 优先复用现有照片、文件夹、相册、标签查询接口 | 全量重写后端命令 | 现有查询面已经覆盖主要内容区,不应扩大改动面 | +| 原生窗口集成 | Tauri/Rust 应用原生窗口材质与标题栏能力 | 纯 CSS 模拟 | 需要真正的 Win11 窗口观感和平台降级逻辑 | +| 编辑器处理 | 前后端完全移除编辑器能力 | 只隐藏 UI、保留后端 | 会持续保留无主流程依赖和维护成本,不符合当前产品目标 | + +--- + +## 2. 模块拓扑 + +```mermaid +graph TD + subgraph Frontend["React / TypeScript"] + App["App + AppShell"] + Stores["settingsStore + navigationStore + selectionStore"] + Views["Content Views / PreviewPane / PhotoViewer"] + Api["services/api/*"] + end + + subgraph Tauri["Tauri Commands"] + SettingsCmd["settings.rs"] + ShellCmd["shell settings commands"] + WindowCmd["window_effects.rs / commands/window_effects.rs"] + ContentCmd["photos / folders / albums / tags / trash commands"] + end + + subgraph Rust["Rust Services / Models"] + SettingsModel["models/settings.rs"] + SettingsMgr["SettingsManager"] + WindowFx["window_effects.rs"] + ContentSvc["existing db/services"] + EditorSvc["edit.rs + editor/native_editor (to be removed)"] + end + + App --> Stores + Stores --> Api + Api --> SettingsCmd + Api --> ShellCmd + Api --> WindowCmd + Api --> ContentCmd + + SettingsCmd --> SettingsModel + ShellCmd --> SettingsModel + SettingsCmd --> SettingsMgr + ShellCmd --> SettingsMgr + WindowCmd --> WindowFx + ContentCmd --> ContentSvc + EditorSvc -. retired .- App +``` + +### 2.1 启动与保存流程 + +1. 前端启动后先读取 `get_settings`,获取完整 `AppSettings` +2. `settingsStore`、主题系统和导航壳层用同一份设置完成初始 hydration +3. AppShell 渲染后调用窗口外观应用逻辑,让 Tauri/Rust 根据 `window` 设置启用原生效果 +4. 用户在设置页保存时调用 `save_settings` +5. 用户在日常浏览中变更导航宽度、预览窗格开关、视图模式、排序方式时,前端以 debounce 方式调用 `save_shell_settings` +6. `save_shell_settings` 只更新设置文件中的 `shell` 子树,避免覆盖设置页尚未保存的其他字段 + +--- + +## 3. 目录与模块落点 + +### 3.1 前端模块 + +``` +src/ +├── components/ +│ ├── shell/ # AppShell, TitleBar, CommandBar, BreadcrumbBar, StatusBar +│ ├── navigation/ # NavigationPane, NavTree, NavItem +│ ├── content/ # ContentArea, ViewSwitcher, six content views +│ ├── preview/ # PreviewPane, MetadataPanel +│ ├── photo/ # PhotoGrid, PhotoThumbnail, PhotoViewer (只读保留) +│ ├── album/ # 相册内容迁移到新壳层 +│ ├── tag/ # 标签内容迁移到新壳层 +│ ├── common/ # ContextMenu, Dialog, SelectionToolbar 等迁移或精简 +│ ├── layout/ # 旧布局,最终移除 +│ ├── sidebar/ # 旧侧边栏,最终移除 +│ └── dashboard/ # 旧仪表盘,最终移除 +├── services/api/ +│ ├── settings.ts # 扩展 get_settings / save_settings / save_shell_settings +│ ├── folders.ts # 复用 get_folder_tree / get_folder_children / get_photos_by_folder +│ ├── albums.ts # 复用 get_all_albums(_with_count) / get_photos_by_album +│ ├── tags.ts # 复用 get_all_tags(_with_count) / get_photos_by_tag +│ └── ... # 其他现有查询继续复用 +├── stores/ +│ ├── settingsStore.ts # 主题和窗口外观状态 +│ └── navigationStore.ts # activeNode、breadcrumbs、viewMode、pane states +└── types/ + └── index.ts # 与 Rust 对齐的 AppSettings / ShellSettings / WindowSettings +``` + +### 3.2 后端模块 + +``` +src-tauri/src/ +├── models/ +│ └── settings.rs # AppSettings, AppearanceConfig, WindowSettings, ShellSettings +├── commands/ +│ ├── settings.rs # get_settings / save_settings / reset_settings / save_shell_settings +│ ├── window_effects.rs # 应用原生窗口外观 +│ ├── folders.rs # 复用文件夹树和文件夹照片查询 +│ ├── albums.rs # 复用相册查询 +│ ├── tags.rs # 复用标签查询 +│ ├── trash.rs / other content # 复用废纸篓和其他内容区查询 +│ └── edit.rs # 退场并移除主流程注册 +├── services/ +│ ├── settings.rs # 设置持久化和默认值合并 +│ ├── window_effects.rs # 原生材质应用 +│ ├── editor.rs # 退场 +│ └── native_editor.rs # 退场 +└── lib.rs / main.rs # 命令注册更新 +``` + +--- + +## 4. 统一设置模型 + +### 4.1 顶层契约 + +迁移后,TypeScript 与 Rust 统一使用同一份设置结构: + +```text +AppSettings +├── theme +├── language +├── scan +├── thumbnail +├── performance +├── appearance +├── window +└── shell +``` + +### 4.2 字段定义 + +#### `appearance` + +保留和现有前端一致的轻量外观字段: + +```text +appearance +├── themeColor: string +└── fontSizeScale: number +``` + +#### `window` + +由后端负责解释并应用的原生窗口外观字段: + +```text +window +├── effectMode: auto | mica | acrylic | solid +├── transparency: number +├── blurRadius: number +├── customBlurEnabled: boolean +└── compositionBlurEnabled: boolean +``` + +默认策略: + +- `auto` 为默认值 +- Win11 下 `auto` 优先使用 `mica`,失败时退回 `acrylic` +- 非 Win11 或原生效果不可用时退回 `solid` +- 前端不得自行决定最终材质类型,只负责展示开关和当前状态 + +#### `shell` + +新增并由前后端共同维护的 UI 外壳状态: + +```text +shell +├── activeNode: string +├── expandedNodes: string[] +├── navPaneWidth: number +├── previewPaneOpen: boolean +├── previewPaneWidth: number +├── viewMode: large | medium | small | list | detail | tile +├── sortBy: dateTaken | dateAdded | fileName | fileSize | rating +├── sortOrder: asc | desc +├── filter: FilterState +├── detailColumns: string[] +├── detailColumnWidths: Record +└── viewPreferences: Record +``` + +其中 `FilterState` 结构为: + +```text +FilterState +├── fileTypes: string[] # 如 ["jpeg", "png", "raw"],空数组表示不筛选 +├── dateRange: { from: string | null, to: string | null } # ISO 日期,null 表示不限 +├── tags: string[] # 标签名列表,空数组表示不筛选 +├── rating: { min: number | null, max: number | null } # 1-5,null 表示不限 +└── sizeRange: { min: number | null, max: number | null } # 字节数,null 表示不限 +``` + +默认值:所有字段为空数组或 null,表示无筛选。 + +`viewPreferences` 的键为导航节点 ID 或文件夹路径,值结构固定为: + +```text +NodeViewPreference +├── viewMode +├── sortBy +└── sortOrder +``` + +### 4.3 兼容策略 + +- 旧配置文件中缺失 `appearance`、`window` 或 `shell` 时,Rust 侧补默认值 +- `get_settings` 必须返回完整结构,不允许返回前后端字段不一致的半结构 +- 旧字段迁移只允许在加载时执行一次归一化;首次按新结构保存后,不再回写旧字段 +- 除缺字段默认值外,不保留长期双读、双写或双结构兼容 + +### 4.4 旧字段迁移表 + +| 旧字段 | 去向 | 规则 | +|:---|:---|:---| +| `appearance.themeColor` | 保留到 `appearance.themeColor` | 字段名不变,直接沿用 | +| `appearance.fontSizeScale` | 保留到 `appearance.fontSizeScale` | 字段名不变,直接沿用 | +| Rust `window.opacity` | 删除并一次性迁移到 `window.transparency` | 仅当新 `transparency` 缺失时参与换算;按 `transparency = clamp(round((1 - opacity) * 100), 0, 100)` 转换;首次保存后不再输出旧字段 | +| 前端持久化 `windowOpacity` | 删除并一次性迁移到 `window.transparency` | 仅当后端设置中缺少新 `transparency` 且本地仍有旧值时参与换算;按 `transparency = clamp(100 - windowOpacity, 0, 100)` 转换;迁移完成后删除本地旧字段 | +| `window.transparency` | 保留到 `window.transparency` | 作为唯一透明度配置继续使用 | +| `window.blurRadius` | 保留到 `window.blurRadius` | 字段名不变 | +| `window.customBlurEnabled` | 保留到 `window.customBlurEnabled` | 字段名不变 | +| `compositionBlurEnabled` | 保留到 `window.compositionBlurEnabled` | 作为用户偏好继续落盘 | +| `compositionBlurSupported` | 删除 | 运行时能力探测结果,不属于持久化设置 | +| `highRefreshUi` | 删除 | 不迁入新契约;若后续仍需要,必须单独立项 | + +迁移要求: + +- 若新旧字段同时存在,始终以新字段为准,旧字段直接忽略 +- 迁移逻辑只能存在于设置加载与归一化阶段 +- 新写入路径只写新结构,不允许继续写旧字段 +- 不允许同时从“新字段 + 旧字段”长期读取同一语义 + +### 4.5 反兜底原则 + +本次迁移明确禁止以下会制造屎山的实现方式: + +- 保留旧 `Layout`、旧 `Sidebar`、旧 dashboard 作为运行时 fallback +- 保留旧主题逻辑、`useTheme` 固定浅色逻辑或 `settingsStore`/后端双主题源 +- 保留 `localStorage`/`persist` 与后端设置文件的正式双持久化 +- 通过 deprecated 主路径、隐藏入口但继续注册命令、旧 API 透传等方式“保留一版” +- 为页面换皮单独补一套新查询接口,而不是先复用现有业务查询面 + +允许的兜底只有两类: + +- 平台级窗口效果降级:`mica -> acrylic -> solid` +- 旧配置缺字段时补默认值并立即归一化到新结构 + +--- + +## 5. API 与数据流 + +### 5.0 前端 API 层拆分 + +当前前端 IPC 调用集中在单文件 `src/services/api.ts`。迁移过程中需要将其拆分为模块化结构: + +``` +src/services/api.ts → src/services/api/ +├── index.ts # 统一导出 +├── settings.ts # get_settings / save_settings / save_shell_settings +├── photos.ts # 照片查询、缩略图、元数据 +├── folders.ts # 文件夹树、文件夹照片 +├── albums.ts # 相册查询 +├── tags.ts # 标签查询 +├── favorites.ts # 收藏 +├── trash.ts # 废纸篓 +├── scanner.ts # 扫描 +└── file_ops.ts # 文件操作 +``` + +拆分规则: +- `index.ts` 重新导出所有子模块,保证现有 `import { xxx } from '@/services/api'` 不需要立即全量修改 +- 拆分在阶段 1 的 T-003 中完成,因为 `settings.ts` 是第一个需要扩展的 API 模块 +- 拆分过程中不改变任何函数签名和返回值,只做文件搬迁 + +### 5.1 复用的现有内容接口 + +以下数据链路默认复用,不单独发明新的“Win11 专用查询命令”: + +| 领域 | 复用接口 / 命令 | 备注 | +|:---|:---|:---| +| 文件夹树 | `get_folder_tree`, `get_folder_children`, `get_photos_by_folder` | 已具备树结构与分页照片查询 | +| 相册 | `get_all_albums_with_count`, `get_photos_by_album` | 用于导航节点和内容区 | +| 标签 | `get_all_tags_with_count`, `get_photos_by_tag` | 用于导航节点和内容区 | +| 照片列表 | `get_photos_cursor`, `search_photos_cursor`, `get_photo` | 用于多视图、预览、查看器 | +| 收藏 / 废纸篓 | 现有收藏与回收站命令 | 继续复用业务数据面 | + +原则: + +- 只有在 Win11 外壳需要而现有 payload 明确缺失字段时,才新增或扩展查询命令 +- 不为“页面换皮”而重写现有数据库查询逻辑 + +### 5.2 新增 / 扩展接口 + +| 接口 | 类型 | 用途 | +|:---|:---|:---| +| `get_settings` | 扩展返回值 | 返回完整 `AppSettings`,包含 `appearance`、`window`、`shell` | +| `save_settings` | 扩展入参 | 保存完整 `AppSettings` 的非 `shell` 部分,用于设置页显式保存 | +| `save_shell_settings` | 新增命令 | 只保存 `shell` 子树,用于壳层自动持久化 | +| `apply_window_settings` | 扩展实现 | 根据 `window.effectMode` 和主题状态应用原生窗口效果 | + +### 5.3 保存优先级与写入规则 + +- `save_shell_settings` 的唯一职责是:读取当前已持久化的 `AppSettings`,替换 `shell` 子树,再按新结构写回 +- `save_settings` 的唯一职责是:读取当前已持久化的 `AppSettings`,用请求体覆盖非 `shell` 字段,但始终保留存储中的最新 `shell` +- 设置页提交的 `AppSettings` 即使携带 `shell`,服务端也必须忽略该字段,不允许以旧快照覆盖最新 `shell` +- 除专用 `save_shell_settings` 外,不存在第二条写 `shell` 的正式路径 + +### 5.4 路由与导航状态优先级 + +- 应用启动时优先级固定为:`可解析 URL > persisted shell.activeNode > default photos` +- 当 URL 与 `shell.activeNode` 不一致时,以 URL 为准渲染内容区、面包屑和选中节点,并在稳定后回写最新 `shell.activeNode` +- 浏览器前进后退、深链接和显式路由跳转始终由 URL 驱动,不允许由持久化 `shell` 反向覆盖 URL +- `shell.activeNode` 的作用是恢复最近上下文,而不是替代路由来源 + +### 5.5 主题与窗口的单一事实源 + +主题系统采用以下单一事实源: + +- `settingsStore.theme` 是前端主题状态唯一来源 +- FluentProvider 主题切换、根节点 `.dark` 类切换和原生窗口外观应用都以该状态为准 +- `useTheme` 中固定浅色的旧逻辑要移除或重定向到统一主题源,不允许保留第二套主题状态 +- `settingsStore` 可以作为内存态容器,但不再作为正式设置落盘源;正式配置只以后端设置文件为准 + +--- + +## 6. 编辑器退场设计 + +### 6.1 前端退场 + +- `PhotoViewer` 改为只读查看器 +- 删除 `PhotoEditor` 入口、按钮、状态联动和任何“进入编辑”动作 +- 前端 `services/api/editor.ts` 及其导出不再属于主产品路径 +- `editStore` 若仅服务编辑器,需删除;若仍有残留用途,必须剥离编辑职责 + +### 6.2 后端退场 + +- `commands/edit.rs` 不再注册到主应用命令表 +- `services/editor.rs`、`services/native_editor.rs`、相关 native editor 依赖从主产品流程移除 +- 退场目标是主路径硬删除;编辑器相关文件、命令注册和前端导出必须在本次迁移内删除或从构建路径中彻底移除 + +### 6.3 验收口径 + +- 用户路径中不存在编辑入口 +- 主产品构建、命令注册和前端 API 导出中不再依赖编辑器 +- 本次迁移不引入任何新的编辑替代方案 + +--- + +## 7. 错误处理与降级 + +| 场景 | 处理方式 | 用户感知 | +|:---|:---|:---| +| `get_settings` 缺少新字段 | Rust 补默认值并继续启动 | 首次升级正常进入应用 | +| `save_shell_settings` 失败 | 当前会话保留内存态,不额外做兼容恢复;重启后继续使用最后一次成功持久化值 | 当前会话不中断,但不会引入额外保底链 | +| 原生窗口效果失败 | 自动切换到 `solid` 或轻量透明背景 | 视觉降级但功能正常 | +| 内容区查询失败 | 保留 AppShell,局部显示错误态,不回退旧页面或旧数据源 | 壳层不崩溃 | +| 编辑器残留调用 | 在迁移中视为缺陷,必须清理而不是静默保留 | 无 | + +--- + +## 8. 安全与维护 + +- 不新增外部网络能力或新的高权限文件访问范围 +- 设置持久化只允许保存 UI 与应用配置,不写入用户照片内容 +- 编辑器退场后,原生图像处理链和 DLL 加载面缩小,安全与维护成本降低 +- 任何新增 Tauri command 都必须保持最小权限原则 + +--- + +## 9. 性能考量 + +| 指标 | 目标值 | 测量方式 | +|:---|:---|:---| +| 首屏渲染 (FCP) | `< 800ms` | Tauri DevTools / 浏览器性能面板 | +| 导航节点切换 | `< 200ms` | 用户感知 + `Performance.now()` | +| 视图切换 | `< 100ms` | 用户感知 + `Performance.now()` | +| 10000 张照片滚动帧率 | `≥ 55fps` | FPS meter | +| Fluent UI bundle 增量 | `< 200KB gzip` | bundle 分析 | +| 壳层状态自动保存开销 | 单次 debounce 保存不阻塞主线程滚动 | 浏览器性能面板 | + +### 性能风险与缓解 + +| 风险 | 缓解措施 | +|:---|:---| +| Fluent UI 引入过重 | 按需导入、树摇优化 | +| 六种视图同时加载 | `React.lazy` 懒加载非当前视图 | +| 自动保存 `shell` 过于频繁 | debounce 合并写入,拖拽结束后再落盘 | +| 预览大图阻塞内容区 | 渐进式加载和占位图 | + +--- + +## 10. 迁移阶段 + +```mermaid +gantt + title Win11 整体迁移阶段 + dateFormat YYYY-MM-DD + + section 阶段 1:契约与基础设施 + Fluent UI Provider 与主题统一 :a1, 2026-03-26, 1d + 统一 AppSettings 契约 :a2, after a1, 1d + save_shell_settings 与窗口效果扩展 :a3, after a2, 1d + + section 阶段 2:新壳层 + AppShell / TitleBar / CommandBar :b1, after a3, 2d + NavigationPane / Breadcrumb / StatusBar :b2, after b1, 2d + + section 阶段 3:内容迁移 + 六种视图与 PreviewPane :c1, after b2, 3d + 照片 / 文件夹 / 相册 / 标签内容迁移 :c2, after c1, 3d + 收藏 / 废纸篓 / 设置迁移 :c3, after c2, 2d + + section 阶段 4:退场与优化 + 编辑器退场 :d1, after c3, 1d + 清理旧布局和动画 :d2, after d1, 1d + 回归测试与性能优化 :d3, after d2, 1d +``` + +--- + +## 11. 审批记录 + +| 日期 | 审批人 | 决定 | 备注 | +|:---|:---|:---|:---| +| 2026-03-25 | — | 待审查 | 初稿 | +| 2026-03-26 | — | 待审查 | 改为前后端联动架构,补充统一设置契约与编辑器退场设计 | diff --git a/docs/specs/product.md b/docs/specs/product.md new file mode 100644 index 0000000..d52902c --- /dev/null +++ b/docs/specs/product.md @@ -0,0 +1,363 @@ +# 需求规范 (Product Specification) + +> **功能名称:** Win11 文件管理器风格整体迁移 +> **版本:** v1.1 +> **状态:** 补强后待审查 +> **作者:** AI Architect + Human Engineer +> **最后更新:** 2026-03-26 + +--- + +## 1. 概述 + +本次迭代的目标不是“仅前端换皮”,而是将 PhotoWall 的整体使用体验迁移为 Windows 11 文件管理器风格,包括: + +- 前端 UI 外壳重构:单页面 AppShell、左侧导航窗格、顶部命令栏、面包屑地址栏、多视图内容区、右侧预览窗格、底部状态栏 +- Tauri 原生窗口集成:Win11 原生窗口材质、标题栏交互、拖拽区域、窗口控制和降级策略 +- Rust/Tauri 设置与 IPC 改造:为 UI 外壳状态、主题和窗口外观提供统一设置模型与持久化接口 +- 现有业务页面迁移:照片、相册、标签、收藏、废纸篓、文件夹、设置全部进入新的 Win11 风格外壳 +- 编辑器退场:前后端一起移除照片编辑器能力,不提供替代编辑流程 + +迁移后,PhotoWall 应保留现有照片管理、相册、标签、收藏、文件夹浏览和设置等业务能力,但整体视觉语言、壳层结构、窗口行为和状态持久化方式要统一为 Win11 文件管理器风格。 + +--- + +## 2. 用户故事与验收标准 + +### US-001: 左侧导航窗格 + +**作为**用户,**我希望**通过左侧树形导航窗格切换照片、相册、标签、收藏、废纸篓、文件夹和设置等区域,**以便**获得与 Win11 文件管理器一致的导航体验。 + +#### 验收标准 + +- **AC-001.1:** + - **GIVEN** 应用启动完成 + - **WHEN** 用户查看左侧导航窗格 + - **THEN** 显示树形结构,包含:照片、相册、标签、收藏、废纸篓、文件夹、设置等节点,整体样式与 Win11 文件管理器左侧导航窗格一致 + +- **AC-001.2:** + - **GIVEN** 左侧导航窗格可见 + - **WHEN** 用户点击任意导航节点 + - **THEN** 右侧内容区即时切换到对应内容,无页面级滑动或整页切换动画 + +- **AC-001.3:** + - **GIVEN** 文件夹节点存在子树数据 + - **WHEN** 用户展开文件夹节点 + - **THEN** 基于现有文件夹树数据展示多级嵌套结构,并支持懒加载子节点 + +- **AC-001.4:** + - **GIVEN** 左侧导航窗格可见 + - **WHEN** 用户拖拽导航窗格右边缘 + - **THEN** 可调整导航窗格宽度,并在重启后恢复上次宽度 + +### US-002: 顶部命令栏 + +**作为**用户,**我希望**通过顶部命令栏快速执行视图切换、排序、筛选、预览窗格开关和上下文操作,**以便**获得与 Win11 文件管理器一致的操作体验。 + +#### 验收标准 + +- **AC-002.1:** + - **GIVEN** 应用处于任意内容视图 + - **WHEN** 用户查看顶部区域 + - **THEN** 显示 Win11 风格命令栏,包含:新建相册、排序、视图切换、筛选、预览窗格切换、更多操作等按钮 + +- **AC-002.2:** + - **GIVEN** 命令栏可见 + - **WHEN** 用户点击视图切换按钮 + - **THEN** 弹出视图菜单,包含:大图标、中图标、小图标、列表、详细信息、平铺六种模式 + +- **AC-002.3:** + - **GIVEN** 命令栏可见 + - **WHEN** 用户点击排序按钮 + - **THEN** 弹出排序菜单,支持按名称、拍摄日期、添加日期、大小、评分等字段排序 + +### US-003: 面包屑地址栏 + +**作为**用户,**我希望**通过面包屑地址栏查看当前位置层级并快速回跳,**以便**清晰了解自己在应用中的位置。 + +#### 验收标准 + +- **AC-003.1:** + - **GIVEN** 用户处于任意内容视图 + - **WHEN** 查看命令栏下方 + - **THEN** 显示面包屑路径,例如 `照片 > 2024 > 旅行` 或 `文件夹 > D:\Pictures > Travel` + +- **AC-003.2:** + - **GIVEN** 面包屑路径包含多级 + - **WHEN** 用户点击中间某一级 + - **THEN** 内容区跳转到对应层级 + +- **AC-003.3:** + - **GIVEN** 面包屑节点存在同级候选项 + - **WHEN** 用户点击某级节点旁的下拉箭头 + - **THEN** 显示同级节点菜单,可直接切换 + +### US-004: 多视图模式 + +**作为**用户,**我希望**在大图标、中图标、小图标、列表、详细信息、平铺六种视图之间自由切换,**以便**按不同场景选择最合适的浏览方式。 + +#### 验收标准 + +- **AC-004.1:** + - **GIVEN** 内容区显示照片列表 + - **WHEN** 用户选择“大图标” + - **THEN** 照片以约 `256px` 缩略图网格展示,并显示文件名 + +- **AC-004.2:** + - **GIVEN** 内容区显示照片列表 + - **WHEN** 用户选择“中图标” + - **THEN** 照片以约 `128px` 缩略图网格展示,并显示文件名 + +- **AC-004.3:** + - **GIVEN** 内容区显示照片列表 + - **WHEN** 用户选择“小图标” + - **THEN** 照片以约 `64px` 缩略图网格展示,并显示文件名 + +- **AC-004.4:** + - **GIVEN** 内容区显示照片列表 + - **WHEN** 用户选择“列表” + - **THEN** 照片以单列列表展示,每行显示小图标和文件名 + +- **AC-004.5:** + - **GIVEN** 内容区显示照片列表 + - **WHEN** 用户选择“详细信息” + - **THEN** 照片以表格形式展示,列至少包含:名称、拍摄日期、大小、分辨率、标签,支持列排序和列宽调整 + +- **AC-004.6:** + - **GIVEN** 内容区显示照片列表 + - **WHEN** 用户选择“平铺” + - **THEN** 照片以平铺卡片展示,每张卡片包含缩略图、文件名、文件类型和大小信息 + +- **AC-004.7:** + - **GIVEN** 用户切换了视图模式、排序方式或详细信息列宽 + - **WHEN** 用户重启应用并回到同一导航节点 + - **THEN** 恢复该节点的上次视图偏好 + +### US-005: 右侧预览窗格 + +**作为**用户,**我希望**在右侧预览窗格中查看当前选中照片的预览和元数据,**以便**无需打开全屏查看器即可快速浏览。 + +#### 验收标准 + +- **AC-005.1:** + - **GIVEN** 预览窗格已开启 + - **WHEN** 用户在内容区选中一张照片 + - **THEN** 右侧预览窗格显示照片预览图和元数据,包括文件名、大小、分辨率、拍摄日期、相机信息、标签 + +- **AC-005.2:** + - **GIVEN** 预览窗格可见 + - **WHEN** 用户点击命令栏中的预览窗格切换按钮 + - **THEN** 预览窗格收起或展开 + +- **AC-005.3:** + - **GIVEN** 预览窗格可见 + - **WHEN** 用户拖拽预览窗格左边缘 + - **THEN** 可调整预览窗格宽度,并在重启后恢复上次宽度 + +### US-006: 应用内全屏查看器 + +**作为**用户,**我希望**双击照片后进入只读全屏查看模式,**以便**沉浸式浏览照片。 + +#### 验收标准 + +- **AC-006.1:** + - **GIVEN** 内容区显示照片 + - **WHEN** 用户双击某张照片 + - **THEN** 进入全屏查看模式,显示照片大图,支持缩放和平移 + +- **AC-006.2:** + - **GIVEN** 全屏查看模式已开启 + - **WHEN** 用户按左右方向键或点击导航箭头 + - **THEN** 切换到上一张或下一张照片 + +- **AC-006.3:** + - **GIVEN** 全屏查看模式已开启 + - **WHEN** 用户按 `Esc` 或点击关闭按钮 + - **THEN** 退出全屏查看模式并回到之前的内容视图 + +- **AC-006.4:** + - **GIVEN** 全屏查看器已打开 + - **WHEN** 用户查看顶部或底部操作区 + - **THEN** 不再出现任何照片编辑入口、编辑参数或“打开编辑器”按钮 + +### US-007: Win11 材质与原生窗口集成 + +**作为**用户,**我希望**应用在 Windows 11 上具有原生窗口材质和一致的 Win11 视觉语言,**以便**获得接近系统文件管理器的观感。 + +#### 验收标准 + +- **AC-007.1:** + - **GIVEN** 应用运行在 Windows 11 + - **WHEN** 用户查看应用窗口 + - **THEN** 主窗口使用 Tauri/Rust 原生窗口效果,优先使用 Mica,Mica 不可用时回退到 Acrylic 或纯色背景 + +- **AC-007.2:** + - **GIVEN** 应用运行在非 Win11 平台或不支持原生材质 + - **WHEN** 应用启动 + - **THEN** 自动降级到纯色或轻量透明背景,不影响功能使用,也不会因窗口效果失败阻塞界面渲染 + +- **AC-007.3:** + - **GIVEN** 应用运行 + - **WHEN** 用户查看导航窗格、预览窗格、菜单和对话框 + - **THEN** Fluent UI 主题、圆角、字体、阴影和半透明层表现与 Win11 视觉语言一致 + +### US-008: 深色模式 + +**作为**用户,**我希望**应用支持浅色、深色和跟随系统三种主题模式,**以便**在不同光线环境下舒适使用。 + +#### 验收标准 + +- **AC-008.1:** + - **GIVEN** 用户在设置中切换主题 + - **WHEN** 选择浅色、深色或跟随系统 + - **THEN** FluentProvider 主题、根节点 `.dark` 类和窗口外观使用同一主题源同步切换 + +- **AC-008.2:** + - **GIVEN** 用户选择“跟随系统” + - **WHEN** Windows 系统主题变化 + - **THEN** 应用自动跟随切换,所有 Fluent UI 组件和自定义样式颜色保持一致 + +- **AC-008.3:** + - **GIVEN** 用户已切换主题模式 + - **WHEN** 重启应用 + - **THEN** 保留上次主题模式和主题色,不出现固定浅色或固定深色的回退逻辑 + +### US-009: 底部状态栏 + +**作为**用户,**我希望**底部状态栏显示当前视图的统计信息,**以便**快速了解内容概况。 + +#### 验收标准 + +- **AC-009.1:** + - **GIVEN** 内容区显示照片或其他内容列表 + - **WHEN** 用户查看底部状态栏 + - **THEN** 显示项目总数、已选中数量、当前视图模式图标和必要的上下文信息 + +### US-010: 壳层状态持久化 + +**作为**用户,**我希望**导航、视图和面板状态在重启后自动恢复,**以便**继续上次的浏览上下文。 + +#### 验收标准 + +- **AC-010.1:** + - **GIVEN** 用户调整了导航窗格宽度、预览窗格宽度和预览窗格开关状态 + - **WHEN** 用户重启应用 + - **THEN** 这些状态自动恢复 + +- **AC-010.2:** + - **GIVEN** 用户在不同导航节点下设置了不同的视图模式和排序方式 + - **WHEN** 用户再次进入对应节点 + - **THEN** 每个节点恢复各自独立的视图偏好 + +- **AC-010.3:** + - **GIVEN** 用户从旧版本升级到新版本 + - **WHEN** 旧设置中不存在新的 `shell` 字段 + - **THEN** 系统自动补默认值并正常启动,不因缺少字段报错 + +- **AC-010.4:** + - **GIVEN** 用户通过深链接、浏览器历史或显式路由进入某个内容区域 + - **WHEN** URL 对应的内容区域与持久化的 `shell.activeNode` 不一致 + - **THEN** 以 URL 为准加载内容区,并在后续同步回写最新 `shell.activeNode` + +- **AC-010.5:** + - **GIVEN** 用户已在壳层中调整视图、窗格宽度或当前节点 + - **WHEN** 随后在设置页执行整份设置保存 + - **THEN** 最新 `shell` 状态不会被旧快照覆盖 + +### US-011: 现有业务区域统一纳入新外壳 + +**作为**用户,**我希望**照片、相册、标签、收藏、废纸篓、文件夹和设置全部采用同一套 Win11 外壳,**以便**获得一致的导航和视觉体验。 + +#### 验收标准 + +- **AC-011.1:** + - **GIVEN** 用户在不同业务区域之间切换 + - **WHEN** 进入相册、标签、收藏、废纸篓、文件夹或设置 + - **THEN** 仍位于同一个 AppShell 内,只切换内容区,不退回旧式页面布局 + +- **AC-011.2:** + - **GIVEN** 新外壳已经启用 + - **WHEN** 用户浏览应用 + - **THEN** 不再出现旧版 dashboard、旧 sidebar、旧 layout 入口或页面级切换动画 + +### US-012: 照片编辑器完全移除 + +**作为**用户,**我希望**应用聚焦于浏览、筛选、整理和查看照片,**以便**获得更稳定的文件管理器式体验。 + +#### 验收标准 + +- **AC-012.1:** + - **GIVEN** 用户在网格、查看器、命令栏和右键菜单中操作照片 + - **WHEN** 查找编辑入口 + - **THEN** 不再出现编辑器按钮、编辑菜单或编辑面板 + +- **AC-012.2:** + - **GIVEN** 应用完成本次迁移 + - **WHEN** 前端或后端代码被检查 + - **THEN** 编辑器相关 API、命令和服务不再属于主产品流程 + +- **AC-012.3:** + - **GIVEN** 用户需要编辑照片 + - **WHEN** 查看本版本能力边界 + - **THEN** 不提供新的内置编辑替代方案 + +--- + +## 3. 非功能性需求 + +| ID | 类别 | 描述 | 目标指标 | +|:---|:---|:---|:---| +| NFR-001 | 性能 | 视图切换响应速度 | 视图模式切换 `< 100ms`,导航节点切换 `< 200ms` | +| NFR-002 | 性能 | 大量照片虚拟滚动 | `10000+` 张照片滚动流畅,帧率 `≥ 55fps` | +| NFR-003 | 可用性 | 键盘导航 | 核心导航、查看器、命令栏和数据视图支持键盘操作 | +| NFR-004 | 可靠性 | 壳层状态持久化 | `shell` 状态保存失败时保持当前会话内存态;重启后使用最后一次成功持久化值,不引入额外恢复链 | +| NFR-005 | 兼容性 | 原生窗口效果降级 | Win11 优先原生材质;不支持时自动降级,功能不受影响 | +| NFR-006 | 一致性 | 设置模型统一 | TypeScript 与 Rust 的 `AppSettings` 结构保持一致,不允许一端缺字段 | +| NFR-007 | 可维护性 | 单一主题源 | FluentProvider 主题、`.dark` 类和窗口外观以同一主题状态为准 | +| NFR-008 | 可维护性 | 编辑器退场 | 编辑器下线后无死代码主路径、无残留入口、无隐式依赖 | +| NFR-009 | 可维护性 | 防过度兜底 | 除平台窗口效果降级和旧配置缺字段默认值外,不引入额外兼容层、双轨状态源或 deprecated 主路径 | + +--- + +## 4. 约束与假设 + +### 约束 + +- 必须使用 Fluent UI React (`@fluentui/react-components v9`) 作为基础组件库 +- 前端 React 与后端 Tauri/Rust 都在本次范围内,且都需要改动 +- 优先复用现有照片、相册、标签、文件夹、收藏、废纸篓查询接口,不无端扩大业务查询面 +- 设置、主题、窗口外观和壳层偏好要归入统一的持久化模型,不允许前后端各维护一套互不对齐的数据结构 +- 正式设置存储以 Tauri/Rust 设置文件为唯一落盘源,不允许 `localStorage` / `persist` 与后端长期双写 +- 路由、导航和内容区的加载优先级固定为 `URL > persisted shell.activeNode > default` +- `save_settings` 不得覆盖最新 `shell` 子树;`shell` 只能由专用壳层保存路径更新 +- 除平台窗口效果降级和旧配置缺字段默认值外,不允许以“临时兼容”“短期保留”“先 deprecated”作为正式方案 +- 保留现有 Zustand、TanStack Query、react-virtuoso 方案 +- 迁移需分阶段进行,任一阶段完成后应用都应能正常运行(阶段 2 结束时内容区可显示占位 UI,功能在阶段 3 逐步补齐) + +### 假设 + +- Tauri 2 与现有窗口效果模块可支撑 Win11 原生窗口材质;若 Mica 不可用,可回退到 Acrylic 或纯色背景 +- 现有文件夹树、相册、标签和照片查询接口能够支撑新导航与内容区的第一阶段改造 +- 现有设置持久化机制可以扩展到 `appearance`、`window`、`shell` 字段,无需新增数据库表 + +--- + +## 5. 超出范围 (Out of Scope) + +以下内容**明确不在**本次迭代范围内: + +- 新的照片编辑替代方案或新的图像处理流水线 +- 新的搜索算法、OCR 功能升级或新的文件格式支持 +- 相册、标签、文件夹、收藏、废纸篓的业务语义重构 +- macOS / Linux 平台的原生视觉对齐 +- 国际化 (i18n) 改造 +- 数据库 Schema 迁移;若实现受阻需单独立项评审 + +--- + +## 6. 审批记录 + +| 日期 | 审批人 | 决定 | 备注 | +|:---|:---|:---|:---| +| 2026-03-25 | — | 待审查 | 初稿 | +| 2026-03-26 | — | 待审查 | 根据“前后端联动迁移 + 编辑器完全移除”补强范围 | diff --git a/docs/specs/tasks.md b/docs/specs/tasks.md new file mode 100644 index 0000000..c0f7436 --- /dev/null +++ b/docs/specs/tasks.md @@ -0,0 +1,260 @@ +# 任务清单 (Task Breakdown) + +> **功能名称:** Win11 文件管理器风格整体迁移 +> **关联规范:** `docs/specs/product.md` · `docs/specs/architecture.md` +> **最后更新:** 2026-03-26 +> **进度:** 0 / 32 已完成 + +--- + +## 执行规则 + +1. **按阶段推进:** 必须先完成设置契约与窗口基础设施,再进入壳层和内容迁移 +2. **前后端同步验收:** 涉及 `AppSettings`、主题、窗口、编辑器退场的任务,必须同时验证前端与后端 +3. **优先复用现有查询面:** 文件夹、相册、标签、照片、收藏、废纸篓默认复用现有接口,只有字段不够时才补命令 +4. **退回机制:** 如果实现中发现接口或状态模型与 `architecture.md` 不一致,先改规范再继续 +5. **禁止过度兜底:** 不得通过 localStorage 双持久化、deprecated 主路径、隐藏入口但保留命令注册、旧布局 fallback 等方式完成迁移 + +--- + +## 阶段 1:契约与基础设施 (Contract & Foundation) + +> 统一设置模型、主题机制和原生窗口能力,为后续壳层迁移打底 + +- [ ] **T-001:** 安装 Fluent UI 依赖并在根节点配置 `FluentProvider` + - 📁 涉及文件:`package.json`, `src/main.tsx`, `src/App.tsx` + - ✅ 验证标准:`npm run dev` 启动无报错;根组件被 `FluentProvider` 包裹;基础 Fluent Button 可渲染 + - ⏱️ 预估工程量:1-2 小时 + - 🔗 依赖:无 + +- [ ] **T-002:** 统一 TypeScript 与 Rust 的 `AppSettings` 契约 + - 📁 涉及文件:`src/types/index.ts`, `src-tauri/src/models/settings.rs` + - ✅ 验证标准:前后端同时包含 `appearance`、`window`、`shell`;字段命名和默认值策略一致;旧字段迁移表中的字段都有明确去向;迁移只发生一次,不保留长期双读双写 + - ⏱️ 预估工程量:2-3 小时 + - 🔗 依赖:无 + +- [ ] **T-003:** 拆分前端 API 层并扩展设置命令,支持完整设置保存与 `shell` 子树保存 + - 📁 涉及文件:`src/services/api.ts` → `src/services/api/index.ts` + 各子模块, `src-tauri/src/commands/settings.rs`, `src-tauri/src/services/settings.rs` + - ✅ 验证标准:`api.ts` 拆分为 `api/` 模块化结构(见 architecture.md §5.0),`index.ts` 重新导出保证现有 import 不破坏;`get_settings` 返回完整 `AppSettings`;新增 `save_shell_settings` 只更新 `shell`;`save_settings` 始终保留服务端最新 `shell`,忽略请求体中的旧 `shell`;不存在覆盖未提交设置草稿的问题 + - ⏱️ 预估工程量:3-5 小时 + - 🔗 依赖:T-002 + +- [ ] **T-004:** 统一主题机制,移除固定浅色 / 多套主题源 + - 📁 涉及文件:`src/stores/settingsStore.ts`, `src/hooks/useThemeColor.ts`, `src/hooks/useTheme.ts`, `src/App.tsx` + - ✅ 验证标准:FluentProvider 主题、根节点 `.dark` 类和窗口主题使用同一主题状态;不再存在固定浅色逻辑;主题切换支持浅色 / 深色 / 跟随系统;设置状态不再由前端 `persist/localStorage` 作为正式落盘源 + - ⏱️ 预估工程量:2-3 小时 + - 🔗 依赖:T-001, T-002 + +- [ ] **T-005:** 扩展原生窗口效果,支持 `effectMode` 和 Win11 降级策略 + - 📁 涉及文件:`src-tauri/src/window_effects.rs`, `src-tauri/src/commands/window_effects.rs`, `src-tauri/src/models/settings.rs` + - ✅ 验证标准:Win11 下优先 Mica,失败时退回 Acrylic;非 Win11 自动退回纯色;窗口效果失败不阻塞 UI + - ⏱️ 预估工程量:2-3 小时 + - 🔗 依赖:T-002 + +- [ ] **T-006:** 打通前端窗口外观与设置联动 + - 📁 涉及文件:`src/App.tsx`, `src/services/api/settings.ts` + - ✅ 验证标准:应用启动后能根据设置应用窗口效果;主题变化与窗口外观同步;窗口外观应用逻辑挂在 `App.tsx` 层级(阶段 2 创建 AppShell 后再迁移到壳层内部);不再依赖旧布局里的临时窗口应用逻辑 + - ⏱️ 预估工程量:1-2 小时 + - 🔗 依赖:T-003, T-004, T-005 + +--- + +## 阶段 2:新壳层 (AppShell) + +> 构建新的 Win11 文件管理器式外壳,并让壳层状态可自动持久化 + +- [ ] **T-007:** 创建 `AppShell` 单页面容器并替换旧 `Layout` + - 📁 涉及文件:`src/components/shell/AppShell.tsx`, `src/App.tsx` + - ✅ 验证标准:AppShell 包含 TitleBar、CommandBar、BreadcrumbBar、MainArea、StatusBar 五大区域;旧 `Layout` 不再作为根布局;内容区渲染占位 UI("内容视图开发中"),阶段 3 逐步替换为真实视图 + - ⏱️ 预估工程量:2-3 小时 + - 🔗 依赖:T-006 + +- [ ] **T-008:** 创建 `TitleBar`,接入窗口拖拽和窗口控制 + - 📁 涉及文件:`src/components/shell/TitleBar.tsx` + - ✅ 验证标准:显示应用标题与窗口控制按钮;拖拽标题栏可移动窗口;最小化 / 最大化 / 关闭行为正常 + - ⏱️ 预估工程量:1-2 小时 + - 🔗 依赖:T-007 + +- [ ] **T-009:** 创建 `CommandBar` + - 📁 涉及文件:`src/components/shell/CommandBar.tsx` + - ✅ 验证标准:包含视图切换、排序、筛选、预览窗格切换、上下文操作;窄窗口下支持溢出菜单 + - ⏱️ 预估工程量:2-3 小时 + - 🔗 依赖:T-007 + +- [ ] **T-010:** 创建 `NavigationPane` 并接入现有业务数据 + - 📁 涉及文件:`src/components/navigation/NavigationPane.tsx`, `src/components/navigation/NavTree.tsx`, `src/services/api/folders.ts`, `src/services/api/albums.ts`, `src/services/api/tags.ts` + - ✅ 验证标准:显示照片、相册、标签、收藏、废纸篓、文件夹、设置;文件夹树支持现有懒加载;相册和标签节点可显示数量 + - ⏱️ 预估工程量:3-4 小时 + - 🔗 依赖:T-007 + +- [ ] **T-011:** 扩展 `navigationStore`,接入 `shell` 持久化 + - 📁 涉及文件:`src/stores/navigationStore.ts`, `src/services/api/settings.ts`, `src/types/index.ts` + - ✅ 验证标准:`activeNode`、`expandedNodes`、`navPaneWidth`、`previewPaneOpen`、`previewPaneWidth`、`viewMode`、`sortBy`、`sortOrder`、`filter`、`detailColumns`、`detailColumnWidths`、`viewPreferences` 可 hydration;加载优先级固定为 `URL > persisted shell.activeNode > default`;变更后 debounce 调用 `save_shell_settings`;不再通过 localStorage 保存正式 shell 状态 + - ⏱️ 预估工程量:2-3 小时 + - 🔗 依赖:T-003, T-010 + +- [ ] **T-012:** 创建 `BreadcrumbBar` + - 📁 涉及文件:`src/components/shell/BreadcrumbBar.tsx` + - ✅ 验证标准:可根据当前 URL、`activeNode` 和上下文生成面包屑;支持点击回跳和同级切换;当 URL 与 persisted shell 不一致时仍以 URL 为准 + - ⏱️ 预估工程量:2-3 小时 + - 🔗 依赖:T-011 + +- [ ] **T-013:** 创建 `StatusBar` + - 📁 涉及文件:`src/components/shell/StatusBar.tsx` + - ✅ 验证标准:显示项目总数、选中数量、视图模式和必要上下文;不会依赖旧布局组件 + - ⏱️ 预估工程量:1 小时 + - 🔗 依赖:T-011 + +--- + +## 阶段 3:内容区与页面迁移 (Content & Page Migration) + +> 在新壳层下完成照片多视图、预览和现有业务页面迁移 + +- [ ] **T-014:** 创建 `ContentArea` 与 `ViewSwitcher` + - 📁 涉及文件:`src/components/content/ContentArea.tsx`, `src/components/content/ViewSwitcher.tsx` + - ✅ 验证标准:根据 `viewMode` 渲染对应视图;ViewSwitcher 使用 Fluent UI Menu;非当前视图懒加载 + - ⏱️ 预估工程量:2-3 小时 + - 🔗 依赖:T-009, T-011 + +- [ ] **T-015:** 实现大 / 中 / 小图标视图 + - 📁 涉及文件:`src/components/content/views/LargeIconView.tsx`, `MediumIconView.tsx`, `SmallIconView.tsx` + - ✅ 验证标准:三种缩略图尺寸工作正常;使用现有 `PhotoGrid` / 缩略图链路;多选与滚动性能满足要求 + - ⏱️ 预估工程量:3-4 小时 + - 🔗 依赖:T-014 + +- [ ] **T-016:** 实现列表 / 详细信息 / 平铺视图 + - 📁 涉及文件:`src/components/content/views/ListView.tsx`, `DetailView.tsx`, `TileView.tsx` + - ✅ 验证标准:列表、表格、平铺三种视图正常切换;详细信息视图支持排序和列宽调整;列宽可回写 `shell` + - ⏱️ 预估工程量:4-5 小时 + - 🔗 依赖:T-014 + +- [ ] **T-017:** 创建 `PreviewPane` + - 📁 涉及文件:`src/components/preview/PreviewPane.tsx`, `src/components/preview/MetadataPanel.tsx` + - ✅ 验证标准:选中照片后显示预览和元数据;优先复用现有照片详情 / 标签查询;开关与宽度写回 `shell` + - ⏱️ 预估工程量:3-4 小时 + - 🔗 依赖:T-011, T-014 + +- [ ] **T-018:** 改造 `PhotoViewer` 为只读 Win11 风格查看器 + - 📁 涉及文件:`src/components/photo/PhotoViewer.tsx` + - ✅ 验证标准:保留缩放、平移、前后切换、信息查看;移除编辑入口和编辑状态;视觉风格与新壳层一致 + - ⏱️ 预估工程量:2-3 小时 + - 🔗 依赖:T-007 + +- [ ] **T-019:** 迁移照片主页到新壳层 + - 📁 涉及文件:`src/pages/HomePage.tsx`, `src/components/photo/PhotoGrid/*` + - ✅ 验证标准:主页内容在 AppShell 内容区内渲染;旧 dashboard 元素退出主流程;最近照片和全部照片都适配新壳层 + - ⏱️ 预估工程量:2-3 小时 + - 🔗 依赖:T-015, T-016, T-017, T-018 + +- [ ] **T-020:** 迁移文件夹页到新壳层 + - 📁 涉及文件:`src/pages/FoldersPage.tsx`, `src/services/api/folders.ts`, `src-tauri/src/commands/folders.rs` + - ✅ 验证标准:文件夹导航、文件夹树和文件夹照片视图在新壳层中工作;仅在现有 payload 不足时扩展命令 + - ⏱️ 预估工程量:2-3 小时 + - 🔗 依赖:T-010, T-014, T-015 + +- [ ] **T-021a:** 迁移相册、标签页到新壳层 + - 📁 涉及文件:`src/pages/AlbumsPage.tsx`, `src/pages/TagsPage.tsx`, `src/services/api/albums.ts`, `src/services/api/tags.ts` + - ✅ 验证标准:相册网格与相册详情在新壳层内容区渲染;标签列表与按标签筛选在新壳层内容区渲染;不再保留旧页面式布局;默认复用现有业务接口 + - ⏱️ 预估工程量:2-3 小时 + - 🔗 依赖:T-014, T-015 + +- [ ] **T-021b:** 迁移收藏、废纸篓页到新壳层 + - 📁 涉及文件:`src/pages/FavoritesPage.tsx`, `src/pages/TrashPage.tsx` + - ✅ 验证标准:收藏和废纸篓在新壳层内容区渲染;废纸篓保留恢复和永久删除操作;不再保留旧页面式布局;默认复用现有业务接口 + - ⏱️ 预估工程量:1-2 小时 + - 🔗 依赖:T-014, T-015 + +- [ ] **T-022:** 迁移设置页并接入统一设置模型 + - 📁 涉及文件:`src/pages/SettingsPage.tsx`, `src/services/api/settings.ts`, `src/stores/settingsStore.ts` + - ✅ 验证标准:设置页在新壳层中工作;主题色、主题模式、窗口效果和其他设置统一走 `AppSettings`;整份保存不会覆盖新的 `shell` 状态;不会再产生前后端双设置源 + - ⏱️ 预估工程量:3-4 小时 + - 🔗 依赖:T-003, T-004, T-007, T-011 + +- [ ] **T-022a:** 实现筛选面板与内容区联动 + - 📁 涉及文件:`src/components/shell/CommandBar.tsx`, `src/components/content/FilterPanel.tsx` (新建), `src/components/content/ContentArea.tsx`, `src/stores/navigationStore.ts` + - ✅ 验证标准:CommandBar 筛选按钮点击弹出筛选面板;支持文件类型、拍摄日期范围、标签、评分、文件大小五个维度;筛选条件变更后内容区实时过滤;筛选状态写回 `shell.filter` 并通过 `save_shell_settings` 持久化;重启后恢复筛选条件 + - ⏱️ 预估工程量:3-4 小时 + - 🔗 依赖:T-009, T-011, T-014 + +- [ ] **T-022b:** 迁移搜索功能到新壳层 + - 📁 涉及文件:`src/components/search/SearchPanel.tsx`, `src/components/search/SearchSuggestions.tsx`, `src/components/shell/CommandBar.tsx` 或 `src/components/shell/TitleBar.tsx` + - ✅ 验证标准:搜索入口集成到 CommandBar 或 TitleBar;搜索面板和搜索建议在新壳层中正常工作;搜索结果在 ContentArea 中渲染,适配当前视图模式 + - ⏱️ 预估工程量:2-3 小时 + - 🔗 依赖:T-009, T-014 + +- [ ] **T-022c:** 迁移右键菜单到 Fluent UI Menu + - 📁 涉及文件:`src/components/common/ContextMenu.tsx`, `src/components/content/views/*.tsx`, `src/components/navigation/NavTree.tsx`, `src/components/preview/PreviewPane.tsx` + - ✅ 验证标准:照片网格、导航树、预览窗格的右键菜单全部替换为 Fluent UI Menu;菜单项与现有功能一致(复制、移动、删除、添加标签、添加到相册等);不再出现编辑相关菜单项 + - ⏱️ 预估工程量:2-3 小时 + - 🔗 依赖:T-015, T-016, T-017 + +--- + +## 阶段 4:编辑器退场与旧实现清理 (Retirement & Cleanup) + +> 下线编辑器,移除旧布局与动画,收敛主产品路径 + +- [ ] **T-023:** 移除前端编辑器入口与状态 + - 📁 涉及文件:`src/components/photo/PhotoViewer.tsx`, `src/components/photo/PhotoEditor.tsx`, `src/stores/editStore.ts`, `src/services/api/editor.ts`, `src/services/api/index.ts` + - ✅ 验证标准:前端不再导出或调用编辑器入口;`PhotoEditor.tsx`、编辑器导出和编辑相关状态从主产品代码中硬删除,不保留 deprecated 主路径 + - ⏱️ 预估工程量:2-3 小时 + - 🔗 依赖:T-018 + +- [ ] **T-024:** 移除后端编辑器命令和服务的主流程依赖 + - 📁 涉及文件:`src-tauri/src/commands/edit.rs`, `src-tauri/src/services/editor.rs`, `src-tauri/src/services/native_editor.rs`, `src-tauri/src/lib.rs` / `main.rs` + - ✅ 验证标准:主产品命令注册不再包含编辑器;无前端主路径调用编辑命令;native editor DLL 不再是主流程依赖;不保留“隐藏 UI 但继续注册命令”的过渡方案 + - ⏱️ 预估工程量:2-3 小时 + - 🔗 依赖:T-023 + +- [ ] **T-025:** 移除旧布局、旧侧边栏、旧仪表盘和页面切换动画 + - 📁 涉及文件:`src/components/layout/`, `src/components/sidebar/`, `src/components/dashboard/`, `src/App.tsx` + - ✅ 验证标准:旧 `Layout`、`Sidebar`、`HeroSection`、`TagRibbon`、`ContentShelf` 和旧页面滑动动画退出主流程;无死代码引用 + - ⏱️ 预估工程量:2-3 小时 + - 🔗 依赖:T-019, T-020, T-021a, T-021b, T-022, T-022a, T-022b, T-022c + +- [ ] **T-026:** 完成 Win11 风格样式收敛 + - 📁 涉及文件:`src/index.css`, `tailwind.config.js`, 新壳层相关组件 + - ✅ 验证标准:旧 `.notebook-*`、旧玻璃拟态、旧按钮样式逐步退出;Fluent tokens 与少量 Tailwind 布局补充成为主样式来源 + - ⏱️ 预估工程量:2-3 小时 + - 🔗 依赖:T-025 + +--- + +## 阶段 5:验证与优化 (Validation & Optimization) + +> 验证性能、契约兼容性和整体回归 + +- [ ] **T-027:** 完成性能优化和懒加载收敛 + - 📁 涉及文件:`vite.config.ts`, `src/components/content/*`, `src/components/preview/*` + - ✅ 验证标准:Fluent UI 按需导入;非当前视图懒加载;自动保存 `shell` 不阻塞滚动和拖拽;首屏与视图切换满足指标 + - ⏱️ 预估工程量:2-3 小时 + - 🔗 依赖:T-026 + +- [ ] **T-028:** 补齐测试并完成全面回归 + - 📁 涉及文件:`src/test/`, 各模块就地 `*.test.ts(x)`, `src-tauri` 相关测试或验证脚本 + - ✅ 验证标准:主题切换、设置契约、旧字段一次性迁移、`shell` hydration、`save_settings`/`save_shell_settings` 冲突、URL 优先导航、六种视图、预览窗格、查看器、文件夹树、设置保存、编辑器退场路径都被验证;`npm run test` 通过;必要时补充 `npm run build` / `npm run tauri build` 验证 + - ⏱️ 预估工程量:3-4 小时 + - 🔗 依赖:T-027 + +--- + +## 风险标记 + +| 任务 ID | 风险类别 | 风险描述 | +|:---|:---|:---| +| T-002 | 契约漂移 | TS 与 Rust 设置模型当前已不一致,合并时容易引发兼容问题 | +| T-003 | 状态覆盖 | `shell` 自动保存与设置页手动保存存在互相覆盖风险 | +| T-004 | 双源风险 | 若 `persist/localStorage` 不清理,主题和设置会长期形成双状态源 | +| T-005 | 平台依赖 | Win11 原生材质依赖 Tauri 和平台能力,需确保可靠降级 | +| T-018 | 行为回归 | 查看器从“查看 + 编辑”改为只读后,快捷键和信息面板行为需重新验证 | +| T-024 | 删除代码 | 编辑器链路包含前端、命令、服务和 native 依赖,清理时容易遗漏 | +| T-025 | 架构变更 | 新壳层替换旧布局会影响所有页面的挂载方式和路由行为 | + +--- + +## 完成日志 + +| 任务 ID | 完成时间 | Commit Hash | 备注 | +|:---|:---|:---|:---| +| — | — | — | 暂无完成任务 | diff --git a/eslint.config.js b/eslint.config.js index 465d27d..783312f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -70,6 +70,14 @@ export default [ ResizeObserver: 'readonly', requestAnimationFrame: 'readonly', cancelAnimationFrame: 'readonly', + HTMLImageElement: 'readonly', + Image: 'readonly', + alert: 'readonly', + confirm: 'readonly', + SVGSVGElement: 'readonly', + SVGPathElement: 'readonly', + React: 'readonly', + globalThis: 'readonly', }, }, plugins: { @@ -90,6 +98,21 @@ export default [ }, }, }, + { + files: ['src/**/*.test.{ts,tsx}', 'src/test/**/*.{ts,tsx}'], + languageOptions: { + globals: { + describe: 'readonly', + it: 'readonly', + expect: 'readonly', + beforeEach: 'readonly', + afterEach: 'readonly', + beforeAll: 'readonly', + afterAll: 'readonly', + vi: 'readonly', + }, + }, + }, { ignores: ['dist/', 'node_modules/', 'src-tauri/'], }, diff --git a/package-lock.json b/package-lock.json index 590bd22..762c3b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "photowall", "version": "0.1.0", "dependencies": { + "@fluentui/react-components": "^9.73.5", "@tanstack/react-query": "^5.90.11", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.4.2", @@ -319,7 +320,6 @@ }, "node_modules/@babel/runtime": { "version": "7.28.4", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -498,6 +498,21 @@ "node": ">=18" } }, + "node_modules/@ctrl/tinycolor": { + "version": "3.6.1", + "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz", + "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmmirror.com/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, "node_modules/@esbuild/win32-x64": { "version": "0.25.12", "cpu": [ @@ -674,6 +689,1621 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/devtools": { + "version": "0.2.3", + "resolved": "https://registry.npmmirror.com/@floating-ui/devtools/-/devtools-0.2.3.tgz", + "integrity": "sha512-ZTcxTvgo9CRlP7vJV62yCxdqmahHTGpSTi5QaTDgGoyQq0OyjaVZhUhXv/qdkQFOI3Sxlfmz0XGG4HaZMsDf8Q==", + "license": "MIT", + "peerDependencies": { + "@floating-ui/dom": "^1.0.0" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@fluentui/keyboard-keys": { + "version": "9.0.8", + "resolved": "https://registry.npmmirror.com/@fluentui/keyboard-keys/-/keyboard-keys-9.0.8.tgz", + "integrity": "sha512-iUSJUUHAyTosnXK8O2Ilbfxma+ZyZPMua5vB028Ys96z80v+LFwntoehlFsdH3rMuPsA8GaC1RE7LMezwPBPdw==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.1" + } + }, + "node_modules/@fluentui/priority-overflow": { + "version": "9.3.0", + "resolved": "https://registry.npmmirror.com/@fluentui/priority-overflow/-/priority-overflow-9.3.0.tgz", + "integrity": "sha512-yaBC0R4e+4ZlCWDulB5S+xBrlnLwfzdg68GaarCqQO8OHjLg7Ah05xTj7PsAYcoHeEg/9vYeBwGXBpRO8+Tjqw==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.1" + } + }, + "node_modules/@fluentui/react-accordion": { + "version": "9.9.2", + "resolved": "https://registry.npmmirror.com/@fluentui/react-accordion/-/react-accordion-9.9.2.tgz", + "integrity": "sha512-Mmi5nVKfQrBiBiD1JPVtCmIMrR1CpCy8hsWZLwv/pHt+uHHyW9HyrPXwiOitj3ookA5ec1kXyl34BN8RUi7DGQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-motion": "^9.13.0", + "@fluentui/react-motion-components-preview": "^0.15.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-alert": { + "version": "9.0.0-beta.136", + "resolved": "https://registry.npmmirror.com/@fluentui/react-alert/-/react-alert-9.0.0-beta.136.tgz", + "integrity": "sha512-tXxU+fTjWtpv4V6qn21Vuhy1FS6VveRYy747241R1Ta4lqvxHfZ+Z8fowRZaXNdcso0ZM+QUzpcaz8kAELUm2g==", + "license": "MIT", + "dependencies": { + "@fluentui/react-avatar": "^9.10.3", + "@fluentui/react-button": "^9.9.0", + "@fluentui/react-icons": "^2.0.239", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-aria": { + "version": "9.17.10", + "resolved": "https://registry.npmmirror.com/@fluentui/react-aria/-/react-aria-9.17.10.tgz", + "integrity": "sha512-KqS2XcdN84XsgVG4fAESyOBfixN7zbObWfQVLNZ2gZrp2b1hPGVYfQ6J4WOO0vXMKYp0rre/QMOgDm6/srL0XQ==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-utilities": "^9.26.2", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-avatar": { + "version": "9.10.3", + "resolved": "https://registry.npmmirror.com/@fluentui/react-avatar/-/react-avatar-9.10.3.tgz", + "integrity": "sha512-f8oW58Z+9nehDk/7PInN1paw1jptN2+TiBKcRagU6evHlmnfsV9+GKnS/vInEG3QPQAVPSdYVub03EkF/w90SA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-badge": "^9.5.0", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-popover": "^9.14.0", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-tooltip": "^9.9.3", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-badge": { + "version": "9.5.0", + "resolved": "https://registry.npmmirror.com/@fluentui/react-badge/-/react-badge-9.5.0.tgz", + "integrity": "sha512-38vaGn7DjS2BqIzjon2U6Xmh+39e2khBwOfsmYx36Z3jqGHbuPwAjQ/kiquTIvtlRRSufwmNtj6C8/J1gN8jmw==", + "license": "MIT", + "dependencies": { + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-breadcrumb": { + "version": "9.4.0", + "resolved": "https://registry.npmmirror.com/@fluentui/react-breadcrumb/-/react-breadcrumb-9.4.0.tgz", + "integrity": "sha512-QpCjYlM3JTMnNwh/sDehDbuAVjTcgSfjkPdSmFaPk2lPHpER32CBcJVhheP9en2U5NbW1e+Gtvq8y06RN8FCWw==", + "license": "MIT", + "dependencies": { + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-button": "^9.9.0", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-link": "^9.8.0", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-button": { + "version": "9.9.0", + "resolved": "https://registry.npmmirror.com/@fluentui/react-button/-/react-button-9.9.0.tgz", + "integrity": "sha512-aH3aSjKyxIiNb9jJOUaaIq47w7jP5ESFSRzvMjcWOETvlWo4QgNqEOOsYqpcltM1OrQZ0sTy/isxppRcyMDlcQ==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-card": { + "version": "9.6.0", + "resolved": "https://registry.npmmirror.com/@fluentui/react-card/-/react-card-9.6.0.tgz", + "integrity": "sha512-vgBvhtSzQDa01aOP9zdhJXFLsZAiDVslRfX3HmlIo1pAMt8w+PBq+ypDp1wxM7HPFpj9+RYcERRKtf4MSNP9Nw==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-text": "^9.6.15", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-carousel": { + "version": "9.9.5", + "resolved": "https://registry.npmmirror.com/@fluentui/react-carousel/-/react-carousel-9.9.5.tgz", + "integrity": "sha512-YitJHBj+9bbJMB6E6mdqV0tLSFMkxXUdqa0xMY6QKjGXoFkG8GYLI8FZwIfpbqmQfZ2oP7cdUvibGQ4Qyh3LHQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-button": "^9.9.0", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-tooltip": "^9.9.3", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1", + "embla-carousel": "^8.5.1", + "embla-carousel-autoplay": "^8.5.1", + "embla-carousel-fade": "^8.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-checkbox": { + "version": "9.5.17", + "resolved": "https://registry.npmmirror.com/@fluentui/react-checkbox/-/react-checkbox-9.5.17.tgz", + "integrity": "sha512-40uRrCnWBMiWyVF2ZN9Ep2nnl/onYrSaa8fNnLBn6Tunhuk9flCxWZygkO5h9Da2QP6DasyGG8WZld1nrR9GUg==", + "license": "MIT", + "dependencies": { + "@fluentui/react-field": "^9.4.16", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-label": "^9.3.15", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-color-picker": { + "version": "9.2.15", + "resolved": "https://registry.npmmirror.com/@fluentui/react-color-picker/-/react-color-picker-9.2.15.tgz", + "integrity": "sha512-RMmawl7g4gUYLuTQG2QwCcR9fGC+vDD+snsBlXtObpj/cKpeDmYif46g88pYv86jeIXY1zsjINmLpELmz+uFmw==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^3.3.4", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-combobox": { + "version": "9.16.18", + "resolved": "https://registry.npmmirror.com/@fluentui/react-combobox/-/react-combobox-9.16.18.tgz", + "integrity": "sha512-nmyleswOSS9O/3gn8AWQ9Uuyis0WTHO1zZnDVapFUdgd2+hAcUSjJXPQv6NGftuUB5bgS2qAx9prRJg17ZrZvA==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-field": "^9.4.16", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-portal": "^9.8.11", + "@fluentui/react-positioning": "^9.22.0", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-components": { + "version": "9.73.5", + "resolved": "https://registry.npmmirror.com/@fluentui/react-components/-/react-components-9.73.5.tgz", + "integrity": "sha512-vq7xsdImOXaSAT6jBqPWRPiDDZFs44Lc8IshxO/IjzbstqLNz6T6I65BBueyhX7lHFQnCq6JpAah+HXVRqpkpQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-accordion": "^9.9.2", + "@fluentui/react-alert": "9.0.0-beta.136", + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-avatar": "^9.10.3", + "@fluentui/react-badge": "^9.5.0", + "@fluentui/react-breadcrumb": "^9.4.0", + "@fluentui/react-button": "^9.9.0", + "@fluentui/react-card": "^9.6.0", + "@fluentui/react-carousel": "^9.9.5", + "@fluentui/react-checkbox": "^9.5.17", + "@fluentui/react-color-picker": "^9.2.15", + "@fluentui/react-combobox": "^9.16.18", + "@fluentui/react-dialog": "^9.17.2", + "@fluentui/react-divider": "^9.7.0", + "@fluentui/react-drawer": "^9.11.5", + "@fluentui/react-field": "^9.4.16", + "@fluentui/react-image": "^9.4.0", + "@fluentui/react-infobutton": "9.0.0-beta.112", + "@fluentui/react-infolabel": "^9.4.17", + "@fluentui/react-input": "^9.8.0", + "@fluentui/react-label": "^9.3.15", + "@fluentui/react-link": "^9.8.0", + "@fluentui/react-list": "^9.6.12", + "@fluentui/react-menu": "^9.23.0", + "@fluentui/react-message-bar": "^9.6.22", + "@fluentui/react-motion": "^9.13.0", + "@fluentui/react-nav": "^9.3.21", + "@fluentui/react-overflow": "^9.7.1", + "@fluentui/react-persona": "^9.7.0", + "@fluentui/react-popover": "^9.14.0", + "@fluentui/react-portal": "^9.8.11", + "@fluentui/react-positioning": "^9.22.0", + "@fluentui/react-progress": "^9.4.16", + "@fluentui/react-provider": "^9.22.15", + "@fluentui/react-radio": "^9.5.16", + "@fluentui/react-rating": "^9.4.0", + "@fluentui/react-search": "^9.4.0", + "@fluentui/react-select": "^9.4.16", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-skeleton": "^9.7.0", + "@fluentui/react-slider": "^9.6.0", + "@fluentui/react-spinbutton": "^9.5.16", + "@fluentui/react-spinner": "^9.8.0", + "@fluentui/react-swatch-picker": "^9.5.0", + "@fluentui/react-switch": "^9.7.0", + "@fluentui/react-table": "^9.19.12", + "@fluentui/react-tabs": "^9.11.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-tag-picker": "^9.8.3", + "@fluentui/react-tags": "^9.7.18", + "@fluentui/react-teaching-popover": "^9.6.19", + "@fluentui/react-text": "^9.6.15", + "@fluentui/react-textarea": "^9.7.0", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-toast": "^9.7.15", + "@fluentui/react-toolbar": "^9.7.5", + "@fluentui/react-tooltip": "^9.9.3", + "@fluentui/react-tree": "^9.15.14", + "@fluentui/react-utilities": "^9.26.2", + "@fluentui/react-virtualizer": "9.0.0-alpha.111", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-context-selector": { + "version": "9.2.15", + "resolved": "https://registry.npmmirror.com/@fluentui/react-context-selector/-/react-context-selector-9.2.15.tgz", + "integrity": "sha512-QymBntFLJNZ9VfTOaBn2ApUSSSC5UuDW8ZcgPJPA+06XEFH+U9Zny2d9QAg1xYNYwIGWahWGQ+7ATOuLxtB8Jw==", + "license": "MIT", + "dependencies": { + "@fluentui/react-utilities": "^9.26.2", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0", + "scheduler": ">=0.19.0" + } + }, + "node_modules/@fluentui/react-dialog": { + "version": "9.17.2", + "resolved": "https://registry.npmmirror.com/@fluentui/react-dialog/-/react-dialog-9.17.2.tgz", + "integrity": "sha512-mZdKylSvh2fRf0e3wMX3ZNccb9DahsOE7A5Y9LG97ghYvndMBVG2YwScIzUFVvLS206ari6HMOl0lC5JRB1bKA==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-motion": "^9.13.0", + "@fluentui/react-motion-components-preview": "^0.15.2", + "@fluentui/react-portal": "^9.8.11", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-divider": { + "version": "9.7.0", + "resolved": "https://registry.npmmirror.com/@fluentui/react-divider/-/react-divider-9.7.0.tgz", + "integrity": "sha512-U8Nhrghjeh+XCGM4B7aHYosd6fXaxHC3MpZi7DB0xQ20ljn5cSTpBt4Yvl+tB9ld2+/eM8wekx1GVKyI4yWa3g==", + "license": "MIT", + "dependencies": { + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-drawer": { + "version": "9.11.5", + "resolved": "https://registry.npmmirror.com/@fluentui/react-drawer/-/react-drawer-9.11.5.tgz", + "integrity": "sha512-eoZY+jKZwbJo1PUsb7Ico7u/8aObHL4BhPP6hd+HHNzB7seTpN7rLd0DpASLZsxJUy5yvch4QF2TrjOu6V8kRA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-dialog": "^9.17.2", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-motion": "^9.13.0", + "@fluentui/react-motion-components-preview": "^0.15.2", + "@fluentui/react-portal": "^9.8.11", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-field": { + "version": "9.4.16", + "resolved": "https://registry.npmmirror.com/@fluentui/react-field/-/react-field-9.4.16.tgz", + "integrity": "sha512-2mfuYGldeqr9Llt8QSfwdj1hQofScvNQ/1Rns9TE4QUP6cdqs3cPX2+FZNJzpgO9vq5bk0hJpKqo7lvXZdyEzw==", + "license": "MIT", + "dependencies": { + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-label": "^9.3.15", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-icons": { + "version": "2.0.316", + "resolved": "https://registry.npmmirror.com/@fluentui/react-icons/-/react-icons-2.0.316.tgz", + "integrity": "sha512-tZPOtsUmoOrgLeM/rLjkzLlWOEmIghXNh/DYQzm5RD/Q4epklOzjnsFvc/Mn2tuXiVxi+vvXxsQp21E1aLpmWg==", + "license": "MIT", + "dependencies": { + "@griffel/react": "^1.0.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "react": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-image": { + "version": "9.4.0", + "resolved": "https://registry.npmmirror.com/@fluentui/react-image/-/react-image-9.4.0.tgz", + "integrity": "sha512-BpcBlmkukm7YYf6PTCbAIMkeCXc8+7aq2eMADsxF5gFD8j3d5lBY3cKByOWRM1NvXcMXmqXr/hQP+ovqNAHzEA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-infobutton": { + "version": "9.0.0-beta.112", + "resolved": "https://registry.npmmirror.com/@fluentui/react-infobutton/-/react-infobutton-9.0.0-beta.112.tgz", + "integrity": "sha512-Fhqoc6b1MQtHW+Mm5sBhfa5ZrRdOV4azuUa5WyBvwD4Ozq/z2pBOC/wi/A/WCjKMnGoMlQ2CggoLaMhQmenzAQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-icons": "^2.0.237", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-label": "^9.3.15", + "@fluentui/react-popover": "^9.14.0", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-infolabel": { + "version": "9.4.17", + "resolved": "https://registry.npmmirror.com/@fluentui/react-infolabel/-/react-infolabel-9.4.17.tgz", + "integrity": "sha512-zLw52jn2wAuEKWFzaNj3aKhuB4BAEI8LqblryCg0LKPKHcv/z9d9RllCqcVz+ngdK1tQGtCIPH/wxNlZXx/I3Q==", + "license": "MIT", + "dependencies": { + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-label": "^9.3.15", + "@fluentui/react-popover": "^9.14.0", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.8.0 <20.0.0", + "@types/react-dom": ">=16.8.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-input": { + "version": "9.8.0", + "resolved": "https://registry.npmmirror.com/@fluentui/react-input/-/react-input-9.8.0.tgz", + "integrity": "sha512-y/CUMEo2pgFLHUDnKTfXV1hwZ5j0GUD5exTyBKoeNgfAwY1UelWIvKc7fgelhV5GYEQJL7ycm8eNq71CqLA74A==", + "license": "MIT", + "dependencies": { + "@fluentui/react-field": "^9.4.16", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-jsx-runtime": { + "version": "9.4.1", + "resolved": "https://registry.npmmirror.com/@fluentui/react-jsx-runtime/-/react-jsx-runtime-9.4.1.tgz", + "integrity": "sha512-ZodSm7jRa4kaLKDi+emfHFMP/IDnYwFQQAI2BdtKbVrvfwvzPRprGcnTgivnqKBT1ROvKOCY2ddz7+yZzesnNw==", + "license": "MIT", + "dependencies": { + "@fluentui/react-utilities": "^9.26.2", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "react": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-label": { + "version": "9.3.15", + "resolved": "https://registry.npmmirror.com/@fluentui/react-label/-/react-label-9.3.15.tgz", + "integrity": "sha512-ycmaQwC4tavA8WeDfgcay1Ywu/4goHq1NOeVxkyzWTPGA7rs+tdCgdZBQZLAsBK2XFaZiHs7l+KG9r1oIRKolA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-link": { + "version": "9.8.0", + "resolved": "https://registry.npmmirror.com/@fluentui/react-link/-/react-link-9.8.0.tgz", + "integrity": "sha512-TH5LS4iuQ4jYzlR84A4n7lQTKaJuiuuGFHMIxoEqtKeMoL9F5AiabuBs6m7Q7clSdTrrcRMNzXLuEFarQrzGTQ==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-list": { + "version": "9.6.12", + "resolved": "https://registry.npmmirror.com/@fluentui/react-list/-/react-list-9.6.12.tgz", + "integrity": "sha512-vFeqP4r3rjqtd/p9p7woma/j2U3UlcirfqGje26ppBMzDs/0MWQiUmjTkQTMLnPeh72knnqwsF43dRSKSdTSng==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-checkbox": "^9.5.17", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.8.0 <20.0.0", + "@types/react-dom": ">=16.8.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-menu": { + "version": "9.23.0", + "resolved": "https://registry.npmmirror.com/@fluentui/react-menu/-/react-menu-9.23.0.tgz", + "integrity": "sha512-KM08C5GQ+wcsZ6dcmjrMonsW1RRcuhygR7GBT6rnWxxx6qzKv6RCumckzL5NfSgvyJw4wWKs857k5sUczs+7VQ==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-motion": "^9.13.0", + "@fluentui/react-motion-components-preview": "^0.15.2", + "@fluentui/react-portal": "^9.8.11", + "@fluentui/react-positioning": "^9.22.0", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-message-bar": { + "version": "9.6.22", + "resolved": "https://registry.npmmirror.com/@fluentui/react-message-bar/-/react-message-bar-9.6.22.tgz", + "integrity": "sha512-9FvKlJwBvfcvwjOf/hsiXUjcIFOvPmJ0woFH4y6rwXGCY/TL3dztcLHbPZ6oVPN4GK+5VwRt1eRRVifrpQRGZw==", + "license": "MIT", + "dependencies": { + "@fluentui/react-button": "^9.9.0", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-link": "^9.8.0", + "@fluentui/react-motion": "^9.13.0", + "@fluentui/react-motion-components-preview": "^0.15.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.8.0 <20.0.0", + "@types/react-dom": ">=16.8.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-motion": { + "version": "9.13.0", + "resolved": "https://registry.npmmirror.com/@fluentui/react-motion/-/react-motion-9.13.0.tgz", + "integrity": "sha512-YdOpW6e7qfvzoWKcqh8hReCqwYEoiEmNBcCprGaupKjWOi9jBbF/JESM1AHI9nOjPd8aY90WUG2+ahvrqfL9LA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-utilities": "^9.26.2", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.8.0 <20.0.0", + "@types/react-dom": ">=16.8.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-motion-components-preview": { + "version": "0.15.2", + "resolved": "https://registry.npmmirror.com/@fluentui/react-motion-components-preview/-/react-motion-components-preview-0.15.2.tgz", + "integrity": "sha512-KqHRV8lLmVwOWiHBdpUFA+TwMbuYu9cyzNvmhbMFLVKzZyr3MPgN+97Tf+6QYPf22o99SMT0BPySDv/HiNYanA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-motion": "*", + "@fluentui/react-utilities": "*", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-nav": { + "version": "9.3.21", + "resolved": "https://registry.npmmirror.com/@fluentui/react-nav/-/react-nav-9.3.21.tgz", + "integrity": "sha512-HsynotAKugl+mESwrON1rrahODNvbU8lsKNzwge/OQuf9yE0TdkpgpZPgYNzLgIabj1B4mtP3rUvHTRNy78GOQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-button": "^9.9.0", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-divider": "^9.7.0", + "@fluentui/react-drawer": "^9.11.5", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-motion": "^9.13.0", + "@fluentui/react-motion-components-preview": "^0.15.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-tooltip": "^9.9.3", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-overflow": { + "version": "9.7.1", + "resolved": "https://registry.npmmirror.com/@fluentui/react-overflow/-/react-overflow-9.7.1.tgz", + "integrity": "sha512-Ml1GlcLrAUv31d9WN15WGOZv32gzDtZD5Mp1MOQ3ichDfTtxrswIch7MDzZ8hLMGf/7Y2IzBpV8iFR1XdSrGBA==", + "license": "MIT", + "dependencies": { + "@fluentui/priority-overflow": "^9.3.0", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-persona": { + "version": "9.7.0", + "resolved": "https://registry.npmmirror.com/@fluentui/react-persona/-/react-persona-9.7.0.tgz", + "integrity": "sha512-RBxY9nKL1TwuDzK8mAe29A7RigUbqiAUrJ0R14fom5cDWo+e4qLwTxgUy/8MVe6+joWLjfLeuVWbgvIEYNoEbg==", + "license": "MIT", + "dependencies": { + "@fluentui/react-avatar": "^9.10.3", + "@fluentui/react-badge": "^9.5.0", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-popover": { + "version": "9.14.0", + "resolved": "https://registry.npmmirror.com/@fluentui/react-popover/-/react-popover-9.14.0.tgz", + "integrity": "sha512-XrZlSfSYhA12j5bna4Sq8N/If2vul7gl8woVrN8U3iQUjdaHB6OAMZ/WMNUdMm35Z+4e4rHClAZxU2dUsbHrmw==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-motion": "^9.13.0", + "@fluentui/react-motion-components-preview": "^0.15.2", + "@fluentui/react-portal": "^9.8.11", + "@fluentui/react-positioning": "^9.22.0", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-portal": { + "version": "9.8.11", + "resolved": "https://registry.npmmirror.com/@fluentui/react-portal/-/react-portal-9.8.11.tgz", + "integrity": "sha512-2eg4MdW7e2UGRYWPg05GCytAjWYNd55YOP9+iUDINoQwwto9oeFTtZRyn08HYw37cSNqoH24qGz/VBctzTkqDA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-positioning": { + "version": "9.22.0", + "resolved": "https://registry.npmmirror.com/@fluentui/react-positioning/-/react-positioning-9.22.0.tgz", + "integrity": "sha512-i3DLC4jd4MoYSZMYLKQNUTpkjKAJ0snIcihvkrjt2jpvv34CifKJhqVtjFQ470pRW4XNx/pBBX07vdXpA3poxA==", + "license": "MIT", + "dependencies": { + "@floating-ui/devtools": "^0.2.3", + "@floating-ui/dom": "^1.6.12", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1", + "use-sync-external-store": "^1.2.0" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-progress": { + "version": "9.4.16", + "resolved": "https://registry.npmmirror.com/@fluentui/react-progress/-/react-progress-9.4.16.tgz", + "integrity": "sha512-IWVuD1hQoyIBK+RIGOCTc3HUPkdtOQghJPZ5uGwRrUlxGgpUV1h7rdAApiuQTWitrFfN6bP4PrsJmHT2DM2OFw==", + "license": "MIT", + "dependencies": { + "@fluentui/react-field": "^9.4.16", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-provider": { + "version": "9.22.15", + "resolved": "https://registry.npmmirror.com/@fluentui/react-provider/-/react-provider-9.22.15.tgz", + "integrity": "sha512-a+ImgL9DOlylDM4UYPnxQTA3yXxbVj+O0iNEyTZ6fMzdMsHzpALU4GAq6tOyW4L7RaQtRBmNpVfwTCEKpqaTJQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/core": "^1.16.0", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-radio": { + "version": "9.5.16", + "resolved": "https://registry.npmmirror.com/@fluentui/react-radio/-/react-radio-9.5.16.tgz", + "integrity": "sha512-xHRqm+MTkIf6JLEz/dMLlHSL9X+ysXAkig+VOV5QTPZwDIr3SqfJVvBmLNUVmtzf+cmWsRKrrIbVGpFGo/CvxA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-field": "^9.4.16", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-label": "^9.3.15", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-rating": { + "version": "9.4.0", + "resolved": "https://registry.npmmirror.com/@fluentui/react-rating/-/react-rating-9.4.0.tgz", + "integrity": "sha512-qVesFNgQ7uuX8z9d8xqxIXn5ax06xffgBr/eAuZfqVYZG5aRrPHHRoiWf0HDrYD4Lb/HRBLPtbNihNxhXj/LEA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.8.0 <20.0.0", + "@types/react-dom": ">=16.8.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-search": { + "version": "9.4.0", + "resolved": "https://registry.npmmirror.com/@fluentui/react-search/-/react-search-9.4.0.tgz", + "integrity": "sha512-/uBJv2IK7gN7Mt+diByV+0COvKnkluvJ2gCnYQfeOpGjPS97IIeGUIa2xpfSq+eB7Ri++1OWlK61jRjlItDmsw==", + "license": "MIT", + "dependencies": { + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-input": "^9.8.0", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-select": { + "version": "9.4.16", + "resolved": "https://registry.npmmirror.com/@fluentui/react-select/-/react-select-9.4.16.tgz", + "integrity": "sha512-YsHMZsiKxH8suBtNTBXhtsvjM0u9UUXH641cEumgtjUz7SzeKNc/cWToLVyNz7GIoANL49rvubkByTeAQVCo2g==", + "license": "MIT", + "dependencies": { + "@fluentui/react-field": "^9.4.16", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-shared-contexts": { + "version": "9.26.2", + "resolved": "https://registry.npmmirror.com/@fluentui/react-shared-contexts/-/react-shared-contexts-9.26.2.tgz", + "integrity": "sha512-upKXkwlIp5oIhELr4clAZXQkuCd4GDXM6GZEz8BOmRO+PnxyqmycCXvxDxsmi6XN+0vkGM4joiIgkB14o/FctQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-theme": "^9.2.1", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "react": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-skeleton": { + "version": "9.7.0", + "resolved": "https://registry.npmmirror.com/@fluentui/react-skeleton/-/react-skeleton-9.7.0.tgz", + "integrity": "sha512-dSmB0jiz/swu/zquCbHx4nS0HKLJ09N6m9+3HNXY/t24JtK4gFNcl0jQssjIsgupeA8xWsjP7+b+VxUeWq1h9Q==", + "license": "MIT", + "dependencies": { + "@fluentui/react-field": "^9.4.16", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-slider": { + "version": "9.6.0", + "resolved": "https://registry.npmmirror.com/@fluentui/react-slider/-/react-slider-9.6.0.tgz", + "integrity": "sha512-AlSU3GVVgcuiHL0b5xcSy8KDPZbN7yuFZMjKRe1yInK9mGfc6LuUB73EQoSIdJxRw74lMAC+am/+xCtjONlc9w==", + "license": "MIT", + "dependencies": { + "@fluentui/react-field": "^9.4.16", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-spinbutton": { + "version": "9.5.16", + "resolved": "https://registry.npmmirror.com/@fluentui/react-spinbutton/-/react-spinbutton-9.5.16.tgz", + "integrity": "sha512-V4U9PSJM26BXrFqJ9K/VYYQeusBf8ldx5KOlZZ7hRamPsKTS5hyytWrF39lTLqCRlGckXPCLNzJpb1DLB+ID1g==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-field": "^9.4.16", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-spinner": { + "version": "9.8.0", + "resolved": "https://registry.npmmirror.com/@fluentui/react-spinner/-/react-spinner-9.8.0.tgz", + "integrity": "sha512-E1jMQueIvEEHdON6itZb3KxP67ACv+IKU/APNvQPftZVEpAZWn265T1EIe3OXAnAFHbXI3MjFcVxV9tu8+6yeg==", + "license": "MIT", + "dependencies": { + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-label": "^9.3.15", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-swatch-picker": { + "version": "9.5.0", + "resolved": "https://registry.npmmirror.com/@fluentui/react-swatch-picker/-/react-swatch-picker-9.5.0.tgz", + "integrity": "sha512-sl7MifqQGR4QGDhhgBIYc25YgPuFQW7+BOfNRMO5DYPq33lX5xHNcczhXywcBESAVHrjM0MC1lsE7glv6gU8RA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-field": "^9.4.16", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.8.0 <20.0.0", + "@types/react-dom": ">=16.8.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-switch": { + "version": "9.7.0", + "resolved": "https://registry.npmmirror.com/@fluentui/react-switch/-/react-switch-9.7.0.tgz", + "integrity": "sha512-fSgbLWmB+O7BREZsT9QvXsqRB39+DXMNkJwsVyRnzZ9XboUHTeN7fVGEuvWQdj8HTjtYE2YYfGUXFo3fST88xA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-field": "^9.4.16", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-label": "^9.3.15", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-table": { + "version": "9.19.12", + "resolved": "https://registry.npmmirror.com/@fluentui/react-table/-/react-table-9.19.12.tgz", + "integrity": "sha512-4SgGOAAL9P1gi9A6286SHGde6JJnT1uiT/st7wHJOMhbo8QE6+Cfn5MgH9LnvMbW+RxZ8m0NV8sMPi9QorAMZg==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-avatar": "^9.10.3", + "@fluentui/react-checkbox": "^9.5.17", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-radio": "^9.5.16", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-tabs": { + "version": "9.11.2", + "resolved": "https://registry.npmmirror.com/@fluentui/react-tabs/-/react-tabs-9.11.2.tgz", + "integrity": "sha512-zmWzySlPM9EwHJNW0/JhyxBCqBvmfZIj1OZLdRDpbPDsKjhO0aGZV6WjLHFYJmq58kbN0wHKUbxc7LfafHHUwA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-tabster": { + "version": "9.26.13", + "resolved": "https://registry.npmmirror.com/@fluentui/react-tabster/-/react-tabster-9.26.13.tgz", + "integrity": "sha512-uOuJj7jn1ME52Vc685/Ielf6srK/sfFQA5zBIbXIvy2Eisfp7R1RmJe2sXWoszz/Fu/XDkPwdM/GLv23N3vrvQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1", + "keyborg": "^2.6.0", + "tabster": "^8.5.5" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-tag-picker": { + "version": "9.8.3", + "resolved": "https://registry.npmmirror.com/@fluentui/react-tag-picker/-/react-tag-picker-9.8.3.tgz", + "integrity": "sha512-dM+Cl+Y1FDYBbpa5MNWzxGLCrcAlz0B2n90Fjt2LKeA4S5+zWH3/vzH3U9M0YxJCKe7S/5XlAtgO6VwDYu3CRw==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-combobox": "^9.16.18", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-field": "^9.4.16", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-portal": "^9.8.11", + "@fluentui/react-positioning": "^9.22.0", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-tags": "^9.7.18", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-tags": { + "version": "9.7.18", + "resolved": "https://registry.npmmirror.com/@fluentui/react-tags/-/react-tags-9.7.18.tgz", + "integrity": "sha512-8SIdeU3jjkvbIlwno/VuAk5zoMuk1qBfLgSIrFXG/PJWI7hjWL5S6gT/5AqAZYQMzftHZkm6hRnLJhWpgHqllA==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-avatar": "^9.10.3", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-teaching-popover": { + "version": "9.6.19", + "resolved": "https://registry.npmmirror.com/@fluentui/react-teaching-popover/-/react-teaching-popover-9.6.19.tgz", + "integrity": "sha512-rSidcDbgnJaZPA7RW6uJqQqEga/2ON5PuuLauZFLGxZyuAAZv+8dwUBsxHYwneVpbFIITF3GX4f1sdH5Ei+lqQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-button": "^9.9.0", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-popover": "^9.14.0", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1", + "use-sync-external-store": "^1.2.0" + }, + "peerDependencies": { + "@types/react": ">=16.8.0 <20.0.0", + "@types/react-dom": ">=16.8.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-text": { + "version": "9.6.15", + "resolved": "https://registry.npmmirror.com/@fluentui/react-text/-/react-text-9.6.15.tgz", + "integrity": "sha512-YB1azhq8MGfnYTGlEAX1mzcFZ6CvqkkaxaCogU4TM9BtPgQ1YUAxE01RMenl8VVi8W9hNbJKkuc8R8GzYwzT4Q==", + "license": "MIT", + "dependencies": { + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-textarea": { + "version": "9.7.0", + "resolved": "https://registry.npmmirror.com/@fluentui/react-textarea/-/react-textarea-9.7.0.tgz", + "integrity": "sha512-AaBcoTHQv1dZ36w0Uoy8bnnkO0Ag7T0+6ZbjkiSGu50245WvK+MJawuCW91UuZvEUR7MPaAK/TDXWlHYWlMqRA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-field": "^9.4.16", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-theme": { + "version": "9.2.1", + "resolved": "https://registry.npmmirror.com/@fluentui/react-theme/-/react-theme-9.2.1.tgz", + "integrity": "sha512-lJxfz7LmmglFz+c9C41qmMqaRRZZUPtPPl9DWQ79vH+JwZd4dkN7eA78OTRwcGCOTPEKoLTX72R+EFaWEDlX+w==", + "license": "MIT", + "dependencies": { + "@fluentui/tokens": "1.0.0-alpha.23", + "@swc/helpers": "^0.5.1" + } + }, + "node_modules/@fluentui/react-toast": { + "version": "9.7.15", + "resolved": "https://registry.npmmirror.com/@fluentui/react-toast/-/react-toast-9.7.15.tgz", + "integrity": "sha512-iuk4rf/WumpGrNIpRVLNamlPBY0rT9BhI4qTnVmzXqz5pY+8GmAq/TKUPER9/withtQW8V9srj91FWblxzpHRg==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-motion": "^9.13.0", + "@fluentui/react-motion-components-preview": "^0.15.2", + "@fluentui/react-portal": "^9.8.11", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-toolbar": { + "version": "9.7.5", + "resolved": "https://registry.npmmirror.com/@fluentui/react-toolbar/-/react-toolbar-9.7.5.tgz", + "integrity": "sha512-mafkxOQAdmUyDqaWC0FLMxYOCx2dEYafDK+3V3IQALuCuZ90nrW3lH9Uh6/GdkcXQ21ykiOmkGoxtQ3xNt6YeQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-button": "^9.9.0", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-divider": "^9.7.0", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-radio": "^9.5.16", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-tooltip": { + "version": "9.9.3", + "resolved": "https://registry.npmmirror.com/@fluentui/react-tooltip/-/react-tooltip-9.9.3.tgz", + "integrity": "sha512-a351JFoaBAOn0SnQ76tzuNv2ieHzAS+VO8Ncy4m9/emrIs5lvBBfKX8fvA4/efVxY+683XEQdoL1LuApuJuTWw==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-portal": "^9.8.11", + "@fluentui/react-positioning": "^9.22.0", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-tree": { + "version": "9.15.14", + "resolved": "https://registry.npmmirror.com/@fluentui/react-tree/-/react-tree-9.15.14.tgz", + "integrity": "sha512-gjLlvjFD64SREUeKaSocueUnntSofplU4EMZToaYqGixlzAouHzTFEZeiohuuYjVWhOn/WBBZsOEdr7bPva+kQ==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-avatar": "^9.10.3", + "@fluentui/react-button": "^9.9.0", + "@fluentui/react-checkbox": "^9.5.17", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-motion": "^9.13.0", + "@fluentui/react-motion-components-preview": "^0.15.2", + "@fluentui/react-radio": "^9.5.16", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-utilities": { + "version": "9.26.2", + "resolved": "https://registry.npmmirror.com/@fluentui/react-utilities/-/react-utilities-9.26.2.tgz", + "integrity": "sha512-Yp2GGNoWifj8Z/VVir4HyRumRsqXnLJd4IP/Y70vEm9ruAvyqUvfn+1lQUuA+k/Reqw8GI+Ix7FTo3rogixZBg==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-shared-contexts": "^9.26.2", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "react": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-virtualizer": { + "version": "9.0.0-alpha.111", + "resolved": "https://registry.npmmirror.com/@fluentui/react-virtualizer/-/react-virtualizer-9.0.0-alpha.111.tgz", + "integrity": "sha512-yku++0779Ve1RNz6y/HWjlXKd2x1wCSbWMydT2IdCICBVwolXjPYMpkqqZUSjbJ0N9gl6BfsCBpU9Dfe2bR8Zg==", + "license": "MIT", + "dependencies": { + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/tokens": { + "version": "1.0.0-alpha.23", + "resolved": "https://registry.npmmirror.com/@fluentui/tokens/-/tokens-1.0.0-alpha.23.tgz", + "integrity": "sha512-uxrzF9Z+J10naP0pGS7zPmzSkspSS+3OJDmYIK3o1nkntQrgBXq3dBob4xSlTDm5aOQ0kw6EvB9wQgtlyy4eKQ==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.1" + } + }, + "node_modules/@griffel/core": { + "version": "1.20.1", + "resolved": "https://registry.npmmirror.com/@griffel/core/-/core-1.20.1.tgz", + "integrity": "sha512-ld1mX04zpmeHn8agx4slSEh8kJ+8or3Y0x9gsJNKSKn6GdCkZBSiGUh+oBXCBn8RKzz8l60TA9IhVSStnyKekA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.0", + "@griffel/style-types": "^1.4.0", + "csstype": "^3.1.3", + "rtl-css-js": "^1.16.1", + "stylis": "^4.2.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@griffel/react": { + "version": "1.6.1", + "resolved": "https://registry.npmmirror.com/@griffel/react/-/react-1.6.1.tgz", + "integrity": "sha512-mNM4/+dIXzqeHboWpVZ1/jiwTAYNc5/8y/V/HasnQ2QXnV6gSUYpeUk/0n6IFU3NJmVJly9JrLSfNo0hM/IFeA==", + "license": "MIT", + "dependencies": { + "@griffel/core": "^1.20.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "react": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@griffel/style-types": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/@griffel/style-types/-/style-types-1.4.0.tgz", + "integrity": "sha512-vNDfOGV7RN/XkA7vxgf7Z5HgW8eiBm5cHT9wQPhsKB4pxWom5u6eQ9CkYE5mCCTSPl9H6Nd1NBai04d4P6BD7Q==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.3" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "dev": true, @@ -763,6 +2393,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.53.3", "cpu": [ @@ -792,6 +2435,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@swc/helpers": { + "version": "0.5.19", + "resolved": "https://registry.npmmirror.com/@swc/helpers/-/helpers-0.5.19.tgz", + "integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tailwindcss/node": { "version": "4.1.17", "dev": true, @@ -1105,7 +2757,6 @@ }, "node_modules/@types/react": { "version": "19.2.7", - "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1113,7 +2764,6 @@ }, "node_modules/@types/react-dom": { "version": "19.2.3", - "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -2015,7 +3665,6 @@ }, "node_modules/csstype": { "version": "3.2.3", - "devOptional": true, "license": "MIT" }, "node_modules/data-urls": { @@ -2195,6 +3844,30 @@ "dev": true, "license": "ISC" }, + "node_modules/embla-carousel": { + "version": "8.6.0", + "resolved": "https://registry.npmmirror.com/embla-carousel/-/embla-carousel-8.6.0.tgz", + "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", + "license": "MIT" + }, + "node_modules/embla-carousel-autoplay": { + "version": "8.6.0", + "resolved": "https://registry.npmmirror.com/embla-carousel-autoplay/-/embla-carousel-autoplay-8.6.0.tgz", + "integrity": "sha512-OBu5G3nwaSXkZCo1A6LTaFMZ8EpkYbwIaH+bPqdBnDGQ2fh4+NbzjXjs2SktoPNKCtflfVMc75njaDHOYXcrsA==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, + "node_modules/embla-carousel-fade": { + "version": "8.6.0", + "resolved": "https://registry.npmmirror.com/embla-carousel-fade/-/embla-carousel-fade-8.6.0.tgz", + "integrity": "sha512-qaYsx5mwCz72ZrjlsXgs1nKejSrW+UhkbOMwLgfRT7w2LtdEB03nPRI06GHuHv5ac2USvbEiX2/nAHctcDwvpg==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.3", "dev": true, @@ -3723,6 +5396,12 @@ "node": ">=4.0" } }, + "node_modules/keyborg": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/keyborg/-/keyborg-2.6.0.tgz", + "integrity": "sha512-o5kvLbuTF+o326CMVYpjlaykxqYP9DphFQZ2ZpgrvBouyvOxyEB7oqe8nOLFpiV5VCtz0D3pt8gXQYWpLpBnmA==", + "license": "MIT" + }, "node_modules/keyv": { "version": "4.5.4", "dev": true, @@ -4582,6 +6261,15 @@ "fsevents": "~2.3.2" } }, + "node_modules/rtl-css-js": { + "version": "1.16.1", + "resolved": "https://registry.npmmirror.com/rtl-css-js/-/rtl-css-js-1.16.1.tgz", + "integrity": "sha512-lRQgou1mu19e+Ya0LsTvKrVJ5TYUbqCVPAiImX3UfLTenarvPUl1QFdvu5Z3PYmHT9RCcwIfbjRQBntExyj3Zg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.1.2" + } + }, "node_modules/safe-array-concat": { "version": "1.1.3", "dev": true, @@ -4938,6 +6626,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stylis": { + "version": "4.3.6", + "resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "dev": true, @@ -4965,6 +6659,19 @@ "dev": true, "license": "MIT" }, + "node_modules/tabster": { + "version": "8.7.0", + "resolved": "https://registry.npmmirror.com/tabster/-/tabster-8.7.0.tgz", + "integrity": "sha512-AKYquti8AdWzuqJdQo4LUMQDZrHoYQy6V+8yUq2PmgLZV10EaB+8BD0nWOfC/3TBp4mPNg4fbHkz6SFtkr0PpA==", + "license": "MIT", + "dependencies": { + "keyborg": "2.6.0", + "tslib": "^2.8.1" + }, + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "4.53.3" + } + }, "node_modules/tailwindcss": { "version": "4.1.17", "dev": true, @@ -5217,6 +6924,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "7.2.6", "dev": true, diff --git a/package.json b/package.json index c467dfb..e9e56e1 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "test:coverage": "vitest --coverage" }, "dependencies": { + "@fluentui/react-components": "^9.73.5", "@tanstack/react-query": "^5.90.11", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.4.2", diff --git a/src-tauri/src/commands/edit.rs b/src-tauri/src/commands/edit.rs deleted file mode 100644 index b5cb749..0000000 --- a/src-tauri/src/commands/edit.rs +++ /dev/null @@ -1,225 +0,0 @@ -//! 照片编辑命令 -//! -//! 提供照片编辑相关的 Tauri IPC 命令 - -use std::path::PathBuf; -use tauri::State; -use crate::AppState; -use crate::services::editor::{EditorService, EditParams}; -use crate::services::native_editor::{NativeEditor, PwAdjustments}; -use crate::utils::error::CommandError; - -/// 将 EditParams 转换为 PwAdjustments -fn params_to_adjustments(params: &EditParams) -> PwAdjustments { - use crate::services::editor::EditOperation; - - let mut adj = PwAdjustments::default(); - - for op in ¶ms.operations { - match op { - EditOperation::Brightness { value } => adj.brightness = *value as f32, - EditOperation::Contrast { value } => adj.contrast = *value as f32, - EditOperation::Saturation { value } => adj.saturation = *value as f32, - EditOperation::Exposure { value } => adj.exposure = *value as f32, - EditOperation::Highlights { value } => adj.highlights = *value as f32, - EditOperation::Shadows { value } => adj.shadows = *value as f32, - EditOperation::Temperature { value } => adj.temperature = *value as f32, - EditOperation::Tint { value } => adj.tint = *value as f32, - EditOperation::Sharpen { value } => adj.sharpen = *value as f32, - EditOperation::Blur { value } => adj.blur = *value as f32, - EditOperation::Vignette { value } => adj.vignette = *value as f32, - _ => {} // 旋转、翻转、裁剪等由 Rust 处理 - } - } - - adj -} - -/// 检查是否有需要 native editor 处理的调整 -fn has_native_adjustments(params: &EditParams) -> bool { - use crate::services::editor::EditOperation; - - params.operations.iter().any(|op| matches!(op, - EditOperation::Brightness { .. } | - EditOperation::Contrast { .. } | - EditOperation::Saturation { .. } | - EditOperation::Exposure { .. } | - EditOperation::Highlights { .. } | - EditOperation::Shadows { .. } | - EditOperation::Temperature { .. } | - EditOperation::Tint { .. } | - EditOperation::Sharpen { .. } | - EditOperation::Blur { .. } - )) -} - -/// 应用编辑并保存照片 -#[tauri::command] -pub async fn apply_photo_edits( - state: State<'_, AppState>, - photo_id: i64, - params: EditParams, - save_as_copy: bool, -) -> Result { - let db = &state.db; - - // 获取照片信息 - let photo = db.get_photo(photo_id) - .map_err(|e| CommandError { code: "E_DB_ERROR".to_string(), message: e.to_string() })? - .ok_or_else(|| CommandError { code: "E_NOT_FOUND".to_string(), message: "照片不存在".to_string() })?; - - let source_path = PathBuf::from(&photo.file_path); - - // 确定保存路径 - let save_path = if save_as_copy { - generate_copy_path(&source_path) - } else { - source_path.clone() - }; - - // 尝试使用 native editor (libvips) - let use_native = has_native_adjustments(¶ms); - let native_result = if use_native { - match NativeEditor::load() { - Ok(editor) => { - let adj = params_to_adjustments(¶ms); - // 如果不是副本,先复制到临时文件 - let temp_path = if save_as_copy { - save_path.clone() - } else { - let temp = source_path.with_extension("tmp.jpg"); - std::fs::copy(&source_path, &temp).ok(); - temp - }; - - match editor.apply_adjustments(&source_path, &temp_path, &adj, 92) { - Ok(()) => { - if !save_as_copy { - // 替换原文件 - std::fs::rename(&temp_path, &save_path).ok(); - } - tracing::info!("使用 native editor (libvips) 处理图像"); - Some(true) - } - Err(e) => { - tracing::warn!("Native editor 失败,回退到 Rust 实现: {}", e); - let _ = std::fs::remove_file(&temp_path); - None - } - } - } - Err(e) => { - tracing::warn!("Native editor 不可用,使用 Rust 实现: {}", e); - None - } - } - } else { - None - }; - - // 如果 native editor 失败或不可用,使用 Rust 实现 - if native_result.is_none() { - let img = EditorService::load_image(&source_path) - .map_err(|e| CommandError { code: "E_IMAGE_ERROR".to_string(), message: e.to_string() })?; - - let edited = EditorService::apply_edits(img, ¶ms) - .map_err(|e| CommandError { code: "E_EDIT_ERROR".to_string(), message: e.to_string() })?; - - EditorService::save_image(&edited, &save_path, Some(92)) - .map_err(|e| CommandError { code: "E_SAVE_ERROR".to_string(), message: e.to_string() })?; - } - - // 更新数据库 - let updated_photo = if save_as_copy { - photo - } else { - // 重新读取图像尺寸 - let img = image::open(&save_path) - .map_err(|e| CommandError { code: "E_IMAGE_ERROR".to_string(), message: e.to_string() })?; - let (new_width, new_height) = (img.width(), img.height()); - - db.update_photo_dimensions(photo_id, new_width, new_height) - .map_err(|e| CommandError { code: "E_DB_ERROR".to_string(), message: e.to_string() })?; - - // 删除旧缩略图 - state.thumbnail_service.delete_thumbnails(&photo.file_hash) - .map_err(|e| CommandError { code: "E_THUMBNAIL_ERROR".to_string(), message: e.to_string() })?; - - db.get_photo(photo_id) - .map_err(|e| CommandError { code: "E_DB_ERROR".to_string(), message: e.to_string() })? - .ok_or_else(|| CommandError { code: "E_NOT_FOUND".to_string(), message: "照片不存在".to_string() })? - }; - - tracing::info!("照片编辑完成: {} -> {:?}", photo_id, save_path); - - Ok(updated_photo) -} - -/// 获取编辑预览(Base64 编码的图像) -#[tauri::command] -pub async fn get_edit_preview( - source_path: String, - params: EditParams, - max_size: Option, -) -> Result { - let path = PathBuf::from(&source_path); - - // 加载图像 - let img = EditorService::load_image(&path) - .map_err(|e| CommandError { code: "E_IMAGE_ERROR".to_string(), message: e.to_string() })?; - - // 生成预览尺寸 - let preview = EditorService::generate_preview(&img, max_size.unwrap_or(800)); - - // 应用编辑 - let edited = EditorService::apply_edits(preview, ¶ms) - .map_err(|e| CommandError { code: "E_EDIT_ERROR".to_string(), message: e.to_string() })?; - - // 编码为 Base64 JPEG - let mut buffer = Vec::new(); - let mut cursor = std::io::Cursor::new(&mut buffer); - edited.write_to(&mut cursor, image::ImageFormat::Jpeg) - .map_err(|e| CommandError { code: "E_ENCODE_ERROR".to_string(), message: e.to_string() })?; - - let base64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &buffer); - - Ok(format!("data:image/jpeg;base64,{}", base64)) -} - -/// 检查照片是否可编辑(非 RAW 格式) -#[tauri::command] -pub fn is_photo_editable(file_path: String) -> bool { - let path = PathBuf::from(&file_path); - let ext = path.extension() - .and_then(|e| e.to_str()) - .map(|e| e.to_lowercase()) - .unwrap_or_default(); - - // RAW 格式不可编辑 - !matches!(ext.as_str(), - "dng" | "cr2" | "cr3" | "nef" | "nrw" | "arw" | "srf" | "sr2" | - "orf" | "raf" | "rw2" | "pef" | "srw" | "raw" | "rwl" | "3fr" | - "erf" | "kdc" | "dcr" | "x3f" - ) -} - -/// 生成副本路径 -fn generate_copy_path(original: &PathBuf) -> PathBuf { - let stem = original.file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("photo"); - let ext = original.extension() - .and_then(|e| e.to_str()) - .unwrap_or("jpg"); - let parent = original.parent().unwrap_or(std::path::Path::new(".")); - - let mut counter = 1; - loop { - let new_name = format!("{}_edited_{}.{}", stem, counter, ext); - let new_path = parent.join(&new_name); - if !new_path.exists() { - return new_path; - } - counter += 1; - } -} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index cf005ab..4723bd9 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -14,7 +14,6 @@ pub mod folder_sync; pub mod folders; pub mod logging; pub mod window_effects; -pub mod edit; pub mod auto_scan; pub mod smart_albums; pub mod ocr; @@ -31,7 +30,6 @@ pub use folder_sync::*; pub use folders::*; pub use logging::*; pub use window_effects::*; -pub use edit::*; pub use auto_scan::*; pub use smart_albums::*; pub use ocr::*; diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 882dadb..b30fce9 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -1,6 +1,6 @@ //! 设置管理 Tauri 命令 -use crate::models::AppSettings; +use crate::models::{AppSettings, ShellSettings}; use crate::services::SettingsManager; use crate::utils::error::CommandError; use crate::AppState; @@ -85,3 +85,18 @@ pub async fn reset_settings(app: AppHandle, state: State<'_, AppState>) -> Resul Ok(settings) } + +/// 保存壳层状态设置(只更新 shell 子树) +#[tauri::command] +pub async fn save_shell_settings( + app: AppHandle, + shell: ShellSettings, +) -> Result<(), CommandError> { + let manager = SettingsManager::new(&app) + .map_err(CommandError::from)?; + + manager.save_shell_settings(&shell) + .map_err(CommandError::from)?; + + Ok(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index df455f4..668eba1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -58,6 +58,7 @@ use commands::{ get_settings, save_settings, reset_settings, + save_shell_settings, apply_window_settings, clear_blur_cache, set_exclude_from_capture, @@ -74,8 +75,6 @@ use commands::{ get_folder_tree, get_folder_children, get_photos_by_folder, get_folder_photo_count, // logging log_frontend, - // edit - apply_photo_edits, get_edit_preview, is_photo_editable, // auto_scan start_auto_scan, stop_auto_scan, get_auto_scan_status, get_directory_scan_states, reset_directory_scan_frequency, trigger_directory_scan, @@ -270,6 +269,7 @@ pub fn run() { get_settings, save_settings, reset_settings, + save_shell_settings, apply_window_settings, clear_blur_cache, set_exclude_from_capture, @@ -294,10 +294,6 @@ pub fn run() { get_folder_photo_count, // logging log_frontend, - // edit - apply_photo_edits, - get_edit_preview, - is_photo_editable, // auto_scan start_auto_scan, stop_auto_scan, diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 708e297..2b3c013 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -13,6 +13,7 @@ pub use tag::{Tag, CreateTag, UpdateTag, PhotoTag, TagWithCount}; pub use album::{Album, CreateAlbum, UpdateAlbum, AlbumPhoto, AlbumWithCount, RecentlyEditedAlbum}; pub use settings::{ AppSettings, ThemeMode, ScanSettings, ThumbnailSettings, PerformanceSettings, + ShellSettings, }; /// 分页参数 diff --git a/src-tauri/src/models/settings.rs b/src-tauri/src/models/settings.rs index af95bed..fb2ad22 100644 --- a/src-tauri/src/models/settings.rs +++ b/src-tauri/src/models/settings.rs @@ -1,6 +1,7 @@ //! 应用程序设置数据模型 use serde::{Deserialize, Serialize}; +use std::collections::HashMap; /// 主题模式 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] @@ -100,33 +101,62 @@ pub struct PerformanceSettings { impl Default for PerformanceSettings { fn default() -> Self { Self { - scan_threads: 0, // 自动 + scan_threads: 0, // 自动 thumbnail_threads: 0, // 自动 enable_wal: true, } } } +/// 外观设置 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppearanceConfig { + /// 主题色 (Hex) + pub theme_color: String, + /// 字体大小缩放 (0.8 - 1.2) + pub font_size_scale: f64, +} + +impl Default for AppearanceConfig { + fn default() -> Self { + Self { + theme_color: String::from("#0078D4"), + font_size_scale: 1.0, + } + } +} + +/// 窗口效果模式 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum EffectMode { + #[default] + Auto, + Mica, + Acrylic, + Solid, +} + /// 窗口外观设置 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WindowSettings { - /// 窗口背景不透明度 (0.0 - 1.0) - 保留兼容性 - #[serde(default = "default_opacity")] - pub opacity: f64, + /// 窗口效果模式 + #[serde(default)] + pub effect_mode: EffectMode, /// 窗口透明度 (0-100),0=不透明,100=高度透明 #[serde(default = "default_transparency")] pub transparency: u32, - /// 模糊半径 (0-100),0=不模糊,100=最大模糊 + /// 模糊半径 (0-100) #[serde(default = "default_blur_radius")] pub blur_radius: u32, - /// 是否启用自定义桌面模糊(而非DWM Acrylic) - #[serde(default = "default_custom_blur_enabled")] + /// 是否启用自定义桌面模糊 + #[serde(default)] pub custom_blur_enabled: bool, -} - -fn default_opacity() -> f64 { - 0.3 + /// 是否启用合成模糊 + #[serde(default)] + pub composition_blur_enabled: bool, } fn default_transparency() -> u32 { @@ -137,17 +167,217 @@ fn default_blur_radius() -> u32 { 20 } -fn default_custom_blur_enabled() -> bool { - false -} - impl Default for WindowSettings { fn default() -> Self { Self { - opacity: 0.3, + effect_mode: EffectMode::default(), transparency: 30, blur_radius: 20, custom_blur_enabled: false, + composition_blur_enabled: false, + } + } +} + +/// 壳层视图模式 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum ShellViewMode { + #[default] + Large, + Medium, + Small, + List, + Detail, + Tile, +} + +/// 壳层排序字段 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum ShellSortBy { + #[default] + DateTaken, + DateAdded, + FileName, + FileSize, + Rating, +} + +/// 壳层排序方向 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum ShellSortOrder { + #[default] + Asc, + Desc, +} + +/// 日期范围 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DateRange { + pub from: Option, + pub to: Option, +} + +impl Default for DateRange { + fn default() -> Self { + Self { + from: None, + to: None, + } + } +} + +/// 数值范围 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NumberRange { + pub min: Option, + pub max: Option, +} + +impl Default for NumberRange { + fn default() -> Self { + Self { + min: None, + max: None, + } + } +} + +/// 筛选状态 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FilterState { + /// 文件类型列表,空数组表示不筛选 + #[serde(default)] + pub file_types: Vec, + /// 日期范围 + #[serde(default)] + pub date_range: DateRange, + /// 标签名列表,空数组表示不筛选 + #[serde(default)] + pub tags: Vec, + /// 评分范围 (1-5) + #[serde(default)] + pub rating: NumberRange, + /// 文件大小范围(字节) + #[serde(default)] + pub size_range: NumberRange, +} + +impl Default for FilterState { + fn default() -> Self { + Self { + file_types: Vec::new(), + date_range: DateRange::default(), + tags: Vec::new(), + rating: NumberRange::default(), + size_range: NumberRange::default(), + } + } +} + +/// 节点视图偏好 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NodeViewPreference { + pub view_mode: ShellViewMode, + pub sort_by: ShellSortBy, + pub sort_order: ShellSortOrder, +} + +impl Default for NodeViewPreference { + fn default() -> Self { + Self { + view_mode: ShellViewMode::default(), + sort_by: ShellSortBy::default(), + sort_order: ShellSortOrder::default(), + } + } +} + +/// 壳层状态设置 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ShellSettings { + /// 当前活动导航节点 + #[serde(default = "default_active_node")] + pub active_node: String, + /// 展开的导航节点列表 + #[serde(default)] + pub expanded_nodes: Vec, + /// 导航窗格宽度 + #[serde(default = "default_nav_pane_width")] + pub nav_pane_width: f64, + /// 预览窗格是否打开 + #[serde(default)] + pub preview_pane_open: bool, + /// 预览窗格宽度 + #[serde(default = "default_preview_pane_width")] + pub preview_pane_width: f64, + /// 视图模式 + #[serde(default)] + pub view_mode: ShellViewMode, + /// 排序字段 + #[serde(default)] + pub sort_by: ShellSortBy, + /// 排序方向 + #[serde(default)] + pub sort_order: ShellSortOrder, + /// 筛选状态 + #[serde(default)] + pub filter: FilterState, + /// 详细信息视图列 + #[serde(default = "default_detail_columns")] + pub detail_columns: Vec, + /// 详细信息视图列宽 + #[serde(default)] + pub detail_column_widths: HashMap, + /// 各节点视图偏好 + #[serde(default)] + pub view_preferences: HashMap, +} + +fn default_active_node() -> String { + String::from("photos") +} + +fn default_nav_pane_width() -> f64 { + 220.0 +} + +fn default_preview_pane_width() -> f64 { + 320.0 +} + +fn default_detail_columns() -> Vec { + vec![ + String::from("name"), + String::from("dateTaken"), + String::from("fileSize"), + String::from("resolution"), + String::from("tags"), + ] +} + +impl Default for ShellSettings { + fn default() -> Self { + Self { + active_node: default_active_node(), + expanded_nodes: Vec::new(), + nav_pane_width: 220.0, + preview_pane_open: false, + preview_pane_width: 320.0, + view_mode: ShellViewMode::default(), + sort_by: ShellSortBy::default(), + sort_order: ShellSortOrder::default(), + filter: FilterState::default(), + detail_columns: default_detail_columns(), + detail_column_widths: HashMap::new(), + view_preferences: HashMap::new(), } } } @@ -166,9 +396,15 @@ pub struct AppSettings { pub thumbnail: ThumbnailSettings, /// 性能设置 pub performance: PerformanceSettings, + /// 外观设置 + #[serde(default)] + pub appearance: AppearanceConfig, /// 窗口设置 #[serde(default)] pub window: WindowSettings, + /// 壳层状态 + #[serde(default)] + pub shell: ShellSettings, } impl Default for AppSettings { @@ -179,7 +415,9 @@ impl Default for AppSettings { scan: ScanSettings::default(), thumbnail: ThumbnailSettings::default(), performance: PerformanceSettings::default(), + appearance: AppearanceConfig::default(), window: WindowSettings::default(), + shell: ShellSettings::default(), } } } diff --git a/src-tauri/src/services/colorspace.rs b/src-tauri/src/services/colorspace.rs deleted file mode 100644 index af99157..0000000 --- a/src-tauri/src/services/colorspace.rs +++ /dev/null @@ -1,356 +0,0 @@ -//! 色彩空间转换工具 -//! -//! 提供专业级图像处理所需的色彩空间转换函数 - -/// D65 白点 XYZ 值 -pub const D65_WHITE: [f32; 3] = [0.95047, 1.0, 1.08883]; - -/// sRGB 转线性 RGB (移除 gamma) -#[inline] -pub fn srgb_to_linear(c: f32) -> f32 { - if c <= 0.04045 { - c / 12.92 - } else { - ((c + 0.055) / 1.055).powf(2.4) - } -} - -/// 线性 RGB 转 sRGB (应用 gamma) -#[inline] -pub fn linear_to_srgb(c: f32) -> f32 { - if c <= 0.0031308 { - c * 12.92 - } else { - 1.055 * c.powf(1.0 / 2.4) - 0.055 - } -} - -/// sRGB 转线性 RGB (批量) -#[inline] -pub fn srgb_to_linear_rgb(r: f32, g: f32, b: f32) -> (f32, f32, f32) { - (srgb_to_linear(r), srgb_to_linear(g), srgb_to_linear(b)) -} - -/// 线性 RGB 转 sRGB (批量) -#[inline] -pub fn linear_to_srgb_rgb(r: f32, g: f32, b: f32) -> (f32, f32, f32) { - (linear_to_srgb(r), linear_to_srgb(g), linear_to_srgb(b)) -} - -/// 线性 RGB 转 CIE XYZ (D65) -#[inline] -pub fn rgb_to_xyz(r: f32, g: f32, b: f32) -> (f32, f32, f32) { - // sRGB to XYZ matrix (D65) - let x = 0.4124564 * r + 0.3575761 * g + 0.1804375 * b; - let y = 0.2126729 * r + 0.7151522 * g + 0.0721750 * b; - let z = 0.0193339 * r + 0.1191920 * g + 0.9503041 * b; - (x, y, z) -} - -/// CIE XYZ 转线性 RGB (D65) -#[inline] -pub fn xyz_to_rgb(x: f32, y: f32, z: f32) -> (f32, f32, f32) { - // XYZ to sRGB matrix (D65) - let r = 3.2404542 * x - 1.5371385 * y - 0.4985314 * z; - let g = -0.9692660 * x + 1.8760108 * y + 0.0415560 * z; - let b = 0.0556434 * x - 0.2040259 * y + 1.0572252 * z; - (r, g, b) -} - -/// CIE XYZ 转 CIE Lab -#[inline] -pub fn xyz_to_lab(x: f32, y: f32, z: f32) -> (f32, f32, f32) { - let xn = x / D65_WHITE[0]; - let yn = y / D65_WHITE[1]; - let zn = z / D65_WHITE[2]; - - let fx = lab_f(xn); - let fy = lab_f(yn); - let fz = lab_f(zn); - - let l = 116.0 * fy - 16.0; - let a = 500.0 * (fx - fy); - let b = 200.0 * (fy - fz); - - (l, a, b) -} - -/// CIE Lab 转 CIE XYZ -#[inline] -pub fn lab_to_xyz(l: f32, a: f32, b: f32) -> (f32, f32, f32) { - let fy = (l + 16.0) / 116.0; - let fx = a / 500.0 + fy; - let fz = fy - b / 200.0; - - let xn = lab_f_inv(fx); - let yn = lab_f_inv(fy); - let zn = lab_f_inv(fz); - - (xn * D65_WHITE[0], yn * D65_WHITE[1], zn * D65_WHITE[2]) -} - -/// Lab 转换辅助函数 -#[inline] -fn lab_f(t: f32) -> f32 { - const DELTA: f32 = 6.0 / 29.0; - const DELTA_CUBE: f32 = DELTA * DELTA * DELTA; - - if t > DELTA_CUBE { - t.cbrt() - } else { - t / (3.0 * DELTA * DELTA) + 4.0 / 29.0 - } -} - -/// Lab 逆转换辅助函数 -#[inline] -fn lab_f_inv(t: f32) -> f32 { - const DELTA: f32 = 6.0 / 29.0; - - if t > DELTA { - t * t * t - } else { - 3.0 * DELTA * DELTA * (t - 4.0 / 29.0) - } -} - -/// sRGB 转 Lab (便捷函数) -#[inline] -pub fn srgb_to_lab(r: f32, g: f32, b: f32) -> (f32, f32, f32) { - let (lr, lg, lb) = srgb_to_linear_rgb(r, g, b); - let (x, y, z) = rgb_to_xyz(lr, lg, lb); - xyz_to_lab(x, y, z) -} - -/// Lab 转 sRGB (便捷函数) -#[inline] -pub fn lab_to_srgb(l: f32, a: f32, b: f32) -> (f32, f32, f32) { - let (x, y, z) = lab_to_xyz(l, a, b); - let (lr, lg, lb) = xyz_to_rgb(x, y, z); - linear_to_srgb_rgb(lr, lg, lb) -} - -/// 计算亮度 (Rec. 709) -#[inline] -pub fn luminance(r: f32, g: f32, b: f32) -> f32 { - 0.2126 * r + 0.7152 * g + 0.0722 * b -} - -/// 计算亮度 (从 sRGB,先转线性) -#[inline] -pub fn luminance_srgb(r: f32, g: f32, b: f32) -> f32 { - let (lr, lg, lb) = srgb_to_linear_rgb(r, g, b); - luminance(lr, lg, lb) -} - -// ============ 色温相关 ============ - -/// 开尔文温度转 xy 色度坐标 (Planckian locus) -/// 有效范围: 1667K - 25000K -pub fn kelvin_to_xy(t: f32) -> (f32, f32) { - let t = t.clamp(1667.0, 25000.0); - let t2 = t * t; - let t3 = t2 * t; - - let x = if t <= 4000.0 { - -0.2661239e9 / t3 - 0.2343589e6 / t2 + 0.8776956e3 / t + 0.179910 - } else { - -3.0258469e9 / t3 + 2.1070379e6 / t2 + 0.2226347e3 / t + 0.24039 - }; - - let x2 = x * x; - let x3 = x2 * x; - - let y = if t <= 2222.0 { - -1.1063814 * x3 - 1.34811020 * x2 + 2.18555832 * x - 0.20219683 - } else if t <= 4000.0 { - -0.9549476 * x3 - 1.37418593 * x2 + 2.09137015 * x - 0.16748867 - } else { - 3.0817580 * x3 - 5.87338670 * x2 + 3.75112997 * x - 0.37001483 - }; - - (x, y) -} - -/// xy 色度坐标转 XYZ (Y=1) -#[inline] -pub fn xy_to_xyz(x: f32, y: f32) -> (f32, f32, f32) { - if y == 0.0 { - return (0.0, 0.0, 0.0); - } - (x / y, 1.0, (1.0 - x - y) / y) -} - -/// Bradford 色彩适应矩阵 -const BRADFORD_MA: [[f32; 3]; 3] = [ - [0.8951, 0.2664, -0.1614], - [-0.7502, 1.7135, 0.0367], - [0.0389, -0.0685, 1.0296], -]; - -/// Bradford 逆矩阵 -const BRADFORD_MA_INV: [[f32; 3]; 3] = [ - [0.9869929, -0.1470543, 0.1599627], - [0.4323053, 0.5183603, 0.0492912], - [-0.0085287, 0.0400428, 0.9684867], -]; - -/// 3x3 矩阵乘向量 -#[inline] -fn mat3_mul_vec3(m: &[[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] { - [ - m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2], - m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2], - m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2], - ] -} - -/// Bradford 色彩适应 -/// 将颜色从源白点适应到目标白点 -pub fn bradford_adapt(rgb: [f32; 3], src_white_xyz: [f32; 3], dst_white_xyz: [f32; 3]) -> [f32; 3] { - // 转换到 LMS 空间 - let src_lms = mat3_mul_vec3(&BRADFORD_MA, src_white_xyz); - let dst_lms = mat3_mul_vec3(&BRADFORD_MA, dst_white_xyz); - - // 计算缩放因子 - let scale = [ - if src_lms[0] != 0.0 { dst_lms[0] / src_lms[0] } else { 1.0 }, - if src_lms[1] != 0.0 { dst_lms[1] / src_lms[1] } else { 1.0 }, - if src_lms[2] != 0.0 { dst_lms[2] / src_lms[2] } else { 1.0 }, - ]; - - // RGB -> XYZ - let (x, y, z) = rgb_to_xyz(rgb[0], rgb[1], rgb[2]); - - // XYZ -> LMS - let lms = mat3_mul_vec3(&BRADFORD_MA, [x, y, z]); - - // 应用缩放 - let adapted_lms = [lms[0] * scale[0], lms[1] * scale[1], lms[2] * scale[2]]; - - // LMS -> XYZ - let adapted_xyz = mat3_mul_vec3(&BRADFORD_MA_INV, adapted_lms); - - // XYZ -> RGB - let (r, g, b) = xyz_to_rgb(adapted_xyz[0], adapted_xyz[1], adapted_xyz[2]); - [r, g, b] -} - -/// 根据色温调整颜色 -/// value: -100 到 100,0 表示 6500K (D65) -/// 负值偏冷(蓝),正值偏暖(黄/橙) -pub fn adjust_temperature_value(rgb: [f32; 3], value: i32) -> [f32; 3] { - if value == 0 { - return rgb; - } - - // 映射 value 到开尔文温度 - // -100 -> 10000K (冷), 0 -> 6500K, +100 -> 3000K (暖) - let kelvin = if value > 0 { - 6500.0 - (value as f32 / 100.0) * 3500.0 // 6500 -> 3000 - } else { - 6500.0 - (value as f32 / 100.0) * 3500.0 // 6500 -> 10000 - }; - - let (src_x, src_y) = kelvin_to_xy(6500.0); // D65 - let (dst_x, dst_y) = kelvin_to_xy(kelvin); - - let src_xyz = xy_to_xyz(src_x, src_y); - let dst_xyz = xy_to_xyz(dst_x, dst_y); - - bradford_adapt(rgb, [src_xyz.0, src_xyz.1, src_xyz.2], [dst_xyz.0, dst_xyz.1, dst_xyz.2]) -} - -// ============ 色调曲线 ============ - -/// Sigmoid 软过渡曲线 -/// x: 输入值 (0-1) -/// pivot: 过渡中心点 -/// strength: 强度 (-1 到 1) -#[inline] -pub fn soft_rolloff(x: f32, pivot: f32, strength: f32) -> f32 { - if strength == 0.0 { - return x; - } - - let k = 8.0 * strength.abs(); // 过渡锐度 - let sigmoid = 1.0 / (1.0 + (-k * (x - pivot)).exp()); - - if strength > 0.0 { - // 提亮 - x + (1.0 - x) * sigmoid * strength - } else { - // 压暗 - x * (1.0 - sigmoid * strength.abs()) - } -} - -/// Filmic 色调映射曲线 -/// 用于曝光调整时保护高光 -#[inline] -pub fn filmic_tonemap(x: f32) -> f32 { - if x <= 0.0 { - 0.0 - } else if x >= 1.0 { - // 软压缩高光 - 1.0 - (-x).exp() * 0.5 - } else { - x - } -} - -/// 带高光保护的曝光调整 -#[inline] -pub fn exposure_with_protection(x: f32, ev: f32) -> f32 { - let factor = 2.0_f32.powf(ev); - let result = x * factor; - - if result > 1.0 { - // 使用 filmic 曲线软压缩 - 1.0 - (-(result - 1.0) * 2.0).exp() * (result - 1.0).min(0.5) - } else { - result - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_gamma_roundtrip() { - for i in 0..=255 { - let c = i as f32 / 255.0; - let linear = srgb_to_linear(c); - let back = linear_to_srgb(linear); - assert!((c - back).abs() < 0.001, "Gamma roundtrip failed for {}", c); - } - } - - #[test] - fn test_lab_roundtrip() { - let test_colors = [ - (1.0, 0.0, 0.0), // Red - (0.0, 1.0, 0.0), // Green - (0.0, 0.0, 1.0), // Blue - (0.5, 0.5, 0.5), // Gray - ]; - - for (r, g, b) in test_colors { - let (l, a, b_lab) = srgb_to_lab(r, g, b); - let (r2, g2, b2) = lab_to_srgb(l, a, b_lab); - assert!((r - r2).abs() < 0.01, "Lab roundtrip failed for R"); - assert!((g - g2).abs() < 0.01, "Lab roundtrip failed for G"); - assert!((b - b2).abs() < 0.01, "Lab roundtrip failed for B"); - } - } - - #[test] - fn test_temperature_neutral() { - let rgb = [0.5, 0.5, 0.5]; - let result = adjust_temperature_value(rgb, 0); - assert!((rgb[0] - result[0]).abs() < 0.001); - assert!((rgb[1] - result[1]).abs() < 0.001); - assert!((rgb[2] - result[2]).abs() < 0.001); - } -} diff --git a/src-tauri/src/services/editor.rs b/src-tauri/src/services/editor.rs deleted file mode 100644 index 59e6420..0000000 --- a/src-tauri/src/services/editor.rs +++ /dev/null @@ -1,620 +0,0 @@ -//! 照片编辑服务 -//! -//! 提供非 RAW 格式照片的编辑功能 - -use std::path::Path; -use image::{DynamicImage, ImageFormat, imageops::FilterType}; -use serde::{Deserialize, Serialize}; -use crate::utils::error::{AppError, AppResult}; - -/// 翻转方向 -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum FlipDirection { - Horizontal, - Vertical, -} - -/// 裁剪区域 -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CropRect { - pub x: u32, - pub y: u32, - pub width: u32, - pub height: u32, -} - -/// 编辑操作 -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "camelCase")] -pub enum EditOperation { - /// 旋转 (90, 180, 270 度) - Rotate { degrees: i32 }, - /// 翻转 - Flip { direction: FlipDirection }, - /// 裁剪 - Crop { rect: CropRect }, - /// 亮度调整 (-100 to 100) - Brightness { value: i32 }, - /// 对比度调整 (-100 to 100) - Contrast { value: i32 }, - /// 饱和度调整 (-100 to 100) - Saturation { value: i32 }, - /// 曝光调整 (-200 to 200, 代表 -2.0 到 +2.0 EV) - Exposure { value: i32 }, - /// 锐化 (0 to 100) - Sharpen { value: i32 }, - /// 模糊 (0 to 100) - Blur { value: i32 }, - /// 高光调整 (-100 to 100) - Highlights { value: i32 }, - /// 阴影调整 (-100 to 100) - Shadows { value: i32 }, - /// 色温调整 (-100 to 100, 负值偏冷,正值偏暖) - Temperature { value: i32 }, - /// 色调调整 (-100 to 100, 负值偏绿,正值偏品红) - Tint { value: i32 }, - /// 暗角 (0 to 100) - Vignette { value: i32 }, - /// 一键优化 - AutoEnhance, -} - -/// 编辑参数 -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct EditParams { - pub operations: Vec, -} - -/// 照片编辑服务 -pub struct EditorService; - -impl EditorService { - /// 应用编辑操作到图像 - pub fn apply_edits(img: DynamicImage, params: &EditParams) -> AppResult { - let mut result = img; - - for op in ¶ms.operations { - result = Self::apply_operation(result, op)?; - } - - Ok(result) - } - - /// 应用单个编辑操作 - fn apply_operation(img: DynamicImage, op: &EditOperation) -> AppResult { - match op { - EditOperation::Rotate { degrees } => Ok(Self::rotate(img, *degrees)), - EditOperation::Flip { direction } => Ok(Self::flip(img, *direction)), - EditOperation::Crop { rect } => Self::crop(img, rect), - EditOperation::Brightness { value } => Ok(Self::adjust_brightness(img, *value)), - EditOperation::Contrast { value } => Ok(Self::adjust_contrast(img, *value)), - EditOperation::Saturation { value } => Ok(Self::adjust_saturation(img, *value)), - EditOperation::Exposure { value } => Ok(Self::adjust_exposure(img, *value)), - EditOperation::Sharpen { value } => Ok(Self::sharpen(img, *value)), - EditOperation::Blur { value } => Ok(Self::blur(img, *value)), - EditOperation::Highlights { value } => Ok(Self::adjust_highlights(img, *value)), - EditOperation::Shadows { value } => Ok(Self::adjust_shadows(img, *value)), - EditOperation::Temperature { value } => Ok(Self::adjust_temperature(img, *value)), - EditOperation::Tint { value } => Ok(Self::adjust_tint(img, *value)), - EditOperation::Vignette { value } => Ok(Self::apply_vignette(img, *value)), - EditOperation::AutoEnhance => Ok(Self::auto_enhance(img)), - } - } - - /// 旋转图像 - fn rotate(img: DynamicImage, degrees: i32) -> DynamicImage { - match degrees.rem_euclid(360) { - 90 => img.rotate90(), - 180 => img.rotate180(), - 270 => img.rotate270(), - _ => img, - } - } - - /// 翻转图像 - fn flip(img: DynamicImage, direction: FlipDirection) -> DynamicImage { - match direction { - FlipDirection::Horizontal => img.fliph(), - FlipDirection::Vertical => img.flipv(), - } - } - - /// 裁剪图像 - fn crop(img: DynamicImage, rect: &CropRect) -> AppResult { - let (w, h) = (img.width(), img.height()); - - if rect.x >= w || rect.y >= h { - return Err(AppError::General("裁剪区域超出图像边界".to_string())); - } - - let crop_w = rect.width.min(w - rect.x); - let crop_h = rect.height.min(h - rect.y); - - if crop_w == 0 || crop_h == 0 { - return Err(AppError::General("裁剪区域无效".to_string())); - } - - Ok(img.crop_imm(rect.x, rect.y, crop_w, crop_h)) - } - - /// 调整亮度 (-100 to 100) - fn adjust_brightness(img: DynamicImage, value: i32) -> DynamicImage { - let value = value.clamp(-100, 100); - let factor = value as f32 / 100.0; - - let mut rgba = img.to_rgba8(); - for pixel in rgba.pixels_mut() { - for i in 0..3 { - let v = pixel[i] as f32; - let new_v = if factor >= 0.0 { - v + (255.0 - v) * factor - } else { - v * (1.0 + factor) - }; - pixel[i] = new_v.clamp(0.0, 255.0) as u8; - } - } - DynamicImage::ImageRgba8(rgba) - } - - /// 调整对比度 (-100 to 100) - fn adjust_contrast(img: DynamicImage, value: i32) -> DynamicImage { - let value = value.clamp(-100, 100); - let factor = if value >= 0 { - 1.0 + value as f32 / 50.0 - } else { - 1.0 + value as f32 / 100.0 - }; - - let mut rgba = img.to_rgba8(); - for pixel in rgba.pixels_mut() { - for i in 0..3 { - let v = pixel[i] as f32 / 255.0; - let new_v = ((v - 0.5) * factor + 0.5) * 255.0; - pixel[i] = new_v.clamp(0.0, 255.0) as u8; - } - } - DynamicImage::ImageRgba8(rgba) - } - - /// 调整饱和度 (-100 to 100) - fn adjust_saturation(img: DynamicImage, value: i32) -> DynamicImage { - let value = value.clamp(-100, 100); - let factor = 1.0 + value as f32 / 100.0; - - let mut rgba = img.to_rgba8(); - for pixel in rgba.pixels_mut() { - let r = pixel[0] as f32; - let g = pixel[1] as f32; - let b = pixel[2] as f32; - - let gray = 0.299 * r + 0.587 * g + 0.114 * b; - - pixel[0] = (gray + (r - gray) * factor).clamp(0.0, 255.0) as u8; - pixel[1] = (gray + (g - gray) * factor).clamp(0.0, 255.0) as u8; - pixel[2] = (gray + (b - gray) * factor).clamp(0.0, 255.0) as u8; - } - DynamicImage::ImageRgba8(rgba) - } - - /// 调整曝光 (-200 to 200, 代表 -2.0 到 +2.0 EV) - /// 使用 filmic 曲线保护高光 - fn adjust_exposure(img: DynamicImage, value: i32) -> DynamicImage { - use super::colorspace::{srgb_to_linear, linear_to_srgb}; - - let value = value.clamp(-200, 200); - if value == 0 { - return img; - } - - let ev = value as f32 / 100.0; - let factor = 2.0_f32.powf(ev); - - let mut rgba = img.to_rgba8(); - for pixel in rgba.pixels_mut() { - for i in 0..3 { - // 转到线性空间 - let linear = srgb_to_linear(pixel[i] as f32 / 255.0); - // 应用曝光 - let exposed = linear * factor; - // Filmic 高光保护 - let protected = if exposed > 1.0 { - 1.0 - (-(exposed - 1.0) * 2.0).exp() * 0.5 - } else { - exposed - }; - // 转回 sRGB - let result = linear_to_srgb(protected.clamp(0.0, 1.0)); - pixel[i] = (result * 255.0).clamp(0.0, 255.0) as u8; - } - } - DynamicImage::ImageRgba8(rgba) - } - - /// 锐化 (0 to 100) - fn sharpen(img: DynamicImage, value: i32) -> DynamicImage { - let value = value.clamp(0, 100); - if value == 0 { - return img; - } - - let sigma = 1.0; - let amount = value as f32 / 50.0; - - let blurred = img.blur(sigma); - let mut rgba = img.to_rgba8(); - let blurred_rgba = blurred.to_rgba8(); - - for (pixel, blurred_pixel) in rgba.pixels_mut().zip(blurred_rgba.pixels()) { - for i in 0..3 { - let original = pixel[i] as f32; - let blur = blurred_pixel[i] as f32; - let sharpened = original + (original - blur) * amount; - pixel[i] = sharpened.clamp(0.0, 255.0) as u8; - } - } - DynamicImage::ImageRgba8(rgba) - } - - /// 模糊 (0 to 100) - fn blur(img: DynamicImage, value: i32) -> DynamicImage { - let value = value.clamp(0, 100); - if value == 0 { - return img; - } - - let sigma = value as f32 / 10.0; - img.blur(sigma) - } - - /// 调整高光 (-100 to 100) - /// 使用亮度感知的 sigmoid 软过渡,保持色彩比例 - fn adjust_highlights(img: DynamicImage, value: i32) -> DynamicImage { - use super::colorspace::{srgb_to_linear, linear_to_srgb, luminance}; - - let value = value.clamp(-100, 100); - if value == 0 { - return img; - } - - let strength = value as f32 / 100.0; - let pivot = 0.5; // 高光区域起点 - - let mut rgba = img.to_rgba8(); - for pixel in rgba.pixels_mut() { - // 转到线性空间 - let r = srgb_to_linear(pixel[0] as f32 / 255.0); - let g = srgb_to_linear(pixel[1] as f32 / 255.0); - let b = srgb_to_linear(pixel[2] as f32 / 255.0); - - // 计算亮度 - let lum = luminance(r, g, b); - - // 只处理高光区域 (亮度 > pivot) - if lum > pivot { - // Sigmoid 软过渡 - let k = 6.0; - let blend = 1.0 / (1.0 + (-k * (lum - pivot)).exp()); - - // 计算调整量 - let adjustment = if strength > 0.0 { - // 提亮高光 - (1.0 - lum) * blend * strength * 0.5 - } else { - // 压暗高光 - -lum * blend * strength.abs() * 0.5 - }; - - // 保持色彩比例调整 - let scale = if lum > 0.001 { (lum + adjustment) / lum } else { 1.0 }; - - let new_r = linear_to_srgb((r * scale).clamp(0.0, 1.0)); - let new_g = linear_to_srgb((g * scale).clamp(0.0, 1.0)); - let new_b = linear_to_srgb((b * scale).clamp(0.0, 1.0)); - - pixel[0] = (new_r * 255.0) as u8; - pixel[1] = (new_g * 255.0) as u8; - pixel[2] = (new_b * 255.0) as u8; - } - } - DynamicImage::ImageRgba8(rgba) - } - - /// 调整阴影 (-100 to 100) - /// 使用亮度感知的 sigmoid 软过渡,保持色彩比例 - fn adjust_shadows(img: DynamicImage, value: i32) -> DynamicImage { - use super::colorspace::{srgb_to_linear, linear_to_srgb, luminance}; - - let value = value.clamp(-100, 100); - if value == 0 { - return img; - } - - let strength = value as f32 / 100.0; - let pivot = 0.3; // 阴影区域终点 - - let mut rgba = img.to_rgba8(); - for pixel in rgba.pixels_mut() { - // 转到线性空间 - let r = srgb_to_linear(pixel[0] as f32 / 255.0); - let g = srgb_to_linear(pixel[1] as f32 / 255.0); - let b = srgb_to_linear(pixel[2] as f32 / 255.0); - - // 计算亮度 - let lum = luminance(r, g, b); - - // 只处理阴影区域 (亮度 < pivot) - if lum < pivot { - // Sigmoid 软过渡 (反向) - let k = 6.0; - let blend = 1.0 / (1.0 + (k * (lum - pivot)).exp()); - - // 计算调整量 - let adjustment = if strength > 0.0 { - // 提亮阴影 - (pivot - lum) * blend * strength * 0.8 - } else { - // 压暗阴影 - -lum * blend * strength.abs() * 0.5 - }; - - // 保持色彩比例调整 - let new_lum = (lum + adjustment).clamp(0.001, 1.0); - let scale = new_lum / lum.max(0.001); - - let new_r = linear_to_srgb((r * scale).clamp(0.0, 1.0)); - let new_g = linear_to_srgb((g * scale).clamp(0.0, 1.0)); - let new_b = linear_to_srgb((b * scale).clamp(0.0, 1.0)); - - pixel[0] = (new_r * 255.0) as u8; - pixel[1] = (new_g * 255.0) as u8; - pixel[2] = (new_b * 255.0) as u8; - } - } - DynamicImage::ImageRgba8(rgba) - } - - /// 调整色温 (-100 to 100) - /// 使用 Bradford 色彩适应矩阵,基于开尔文温度 - fn adjust_temperature(img: DynamicImage, value: i32) -> DynamicImage { - use super::colorspace::{srgb_to_linear, linear_to_srgb, adjust_temperature_value}; - - let value = value.clamp(-100, 100); - if value == 0 { - return img; - } - - let mut rgba = img.to_rgba8(); - for pixel in rgba.pixels_mut() { - // 转到线性空间 - let r = srgb_to_linear(pixel[0] as f32 / 255.0); - let g = srgb_to_linear(pixel[1] as f32 / 255.0); - let b = srgb_to_linear(pixel[2] as f32 / 255.0); - - // 应用 Bradford 色温调整 - let [new_r, new_g, new_b] = adjust_temperature_value([r, g, b], value); - - // 转回 sRGB - pixel[0] = (linear_to_srgb(new_r.clamp(0.0, 1.0)) * 255.0) as u8; - pixel[1] = (linear_to_srgb(new_g.clamp(0.0, 1.0)) * 255.0) as u8; - pixel[2] = (linear_to_srgb(new_b.clamp(0.0, 1.0)) * 255.0) as u8; - } - DynamicImage::ImageRgba8(rgba) - } - - /// 调整色调 (-100 to 100) - /// 在 Lab 色彩空间调整 a 通道(绿-品红轴) - fn adjust_tint(img: DynamicImage, value: i32) -> DynamicImage { - use super::colorspace::{srgb_to_lab, lab_to_srgb}; - - let value = value.clamp(-100, 100); - if value == 0 { - return img; - } - - // 映射到 Lab a 通道调整量 (-30 到 +30) - let adjustment = value as f32 / 100.0 * 30.0; - - let mut rgba = img.to_rgba8(); - for pixel in rgba.pixels_mut() { - let r = pixel[0] as f32 / 255.0; - let g = pixel[1] as f32 / 255.0; - let b = pixel[2] as f32 / 255.0; - - // 转到 Lab - let (l, a, b_lab) = srgb_to_lab(r, g, b); - - // 调整 a 通道 (绿-品红) - let new_a = a + adjustment; - - // 转回 sRGB - let (new_r, new_g, new_b) = lab_to_srgb(l, new_a, b_lab); - - pixel[0] = (new_r.clamp(0.0, 1.0) * 255.0) as u8; - pixel[1] = (new_g.clamp(0.0, 1.0) * 255.0) as u8; - pixel[2] = (new_b.clamp(0.0, 1.0) * 255.0) as u8; - } - DynamicImage::ImageRgba8(rgba) - } - - /// 应用暗角 (0 to 100) - fn apply_vignette(img: DynamicImage, value: i32) -> DynamicImage { - let value = value.clamp(0, 100); - if value == 0 { - return img; - } - - let strength = value as f32 / 100.0; - let (w, h) = (img.width() as f32, img.height() as f32); - let cx = w / 2.0; - let cy = h / 2.0; - let max_dist = (cx * cx + cy * cy).sqrt(); - - let mut rgba = img.to_rgba8(); - for (x, y, pixel) in rgba.enumerate_pixels_mut() { - let dx = x as f32 - cx; - let dy = y as f32 - cy; - let dist = (dx * dx + dy * dy).sqrt() / max_dist; - let vignette = 1.0 - (dist * dist * strength); - - for i in 0..3 { - let v = pixel[i] as f32 * vignette; - pixel[i] = v.clamp(0.0, 255.0) as u8; - } - } - DynamicImage::ImageRgba8(rgba) - } - - /// 一键优化 - fn auto_enhance(img: DynamicImage) -> DynamicImage { - let rgba = img.to_rgba8(); - - let mut min_v = 255u8; - let mut max_v = 0u8; - let mut sum: u64 = 0; - let pixel_count = rgba.width() as u64 * rgba.height() as u64; - - for pixel in rgba.pixels() { - let luminance = ((pixel[0] as u32 * 299 + pixel[1] as u32 * 587 + pixel[2] as u32 * 114) / 1000) as u8; - min_v = min_v.min(luminance); - max_v = max_v.max(luminance); - sum += luminance as u64; - } - - let avg = (sum / pixel_count) as f32; - let range = (max_v - min_v) as f32; - - let brightness_adj = if avg < 100.0 { ((128.0 - avg) / 2.0) as i32 } else if avg > 156.0 { ((128.0 - avg) / 2.0) as i32 } else { 0 }; - let contrast_adj = if range < 200.0 { ((200.0 - range) / 4.0) as i32 } else { 0 }; - let saturation_adj = 10; - - let mut result = img; - if brightness_adj != 0 { - result = Self::adjust_brightness(result, brightness_adj); - } - if contrast_adj != 0 { - result = Self::adjust_contrast(result, contrast_adj); - } - result = Self::adjust_saturation(result, saturation_adj); - - result - } - - /// 加载图像 - pub fn load_image(path: &Path) -> AppResult { - if !path.exists() { - return Err(AppError::FileNotFound(path.display().to_string())); - } - - let ext = path.extension() - .and_then(|e| e.to_str()) - .map(|e| e.to_lowercase()) - .unwrap_or_default(); - - if matches!(ext.as_str(), "dng" | "cr2" | "cr3" | "nef" | "nrw" | "arw" | "srf" | "sr2" | - "orf" | "raf" | "rw2" | "pef" | "srw" | "raw" | "rwl" | "3fr" | "erf" | "kdc" | "dcr" | "x3f") { - return Err(AppError::UnsupportedFormat("RAW 格式不支持编辑".to_string())); - } - - image::open(path).map_err(AppError::from) - } - - /// 保存图像 - pub fn save_image(img: &DynamicImage, path: &Path, quality: Option) -> AppResult<()> { - let ext = path.extension() - .and_then(|e| e.to_str()) - .map(|e| e.to_lowercase()) - .unwrap_or_else(|| "jpg".to_string()); - - let format = match ext.as_str() { - "jpg" | "jpeg" => ImageFormat::Jpeg, - "png" => ImageFormat::Png, - "webp" => ImageFormat::WebP, - "bmp" => ImageFormat::Bmp, - "tif" | "tiff" => ImageFormat::Tiff, - _ => ImageFormat::Jpeg, - }; - - if format == ImageFormat::Jpeg { - let quality = quality.unwrap_or(92); - let rgb = img.to_rgb8(); - let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality( - std::fs::File::create(path)?, - quality, - ); - encoder.encode_image(&rgb)?; - } else { - img.save_with_format(path, format)?; - } - - Ok(()) - } - - /// 生成预览(缩小尺寸以加快处理) - pub fn generate_preview(img: &DynamicImage, max_size: u32) -> DynamicImage { - let (w, h) = (img.width(), img.height()); - if w <= max_size && h <= max_size { - return img.clone(); - } - - img.resize(max_size, max_size, FilterType::Triangle) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use image::RgbImage; - - fn create_test_image() -> DynamicImage { - let img = RgbImage::from_fn(100, 100, |x, y| { - image::Rgb([ - ((x * 255) / 100) as u8, - ((y * 255) / 100) as u8, - 128, - ]) - }); - DynamicImage::ImageRgb8(img) - } - - #[test] - fn test_rotate() { - let img = create_test_image(); - let rotated = EditorService::rotate(img.clone(), 90); - assert_eq!(rotated.width(), 100); - assert_eq!(rotated.height(), 100); - } - - #[test] - fn test_flip() { - let img = create_test_image(); - let flipped = EditorService::flip(img.clone(), FlipDirection::Horizontal); - assert_eq!(flipped.width(), 100); - } - - #[test] - fn test_crop() { - let img = create_test_image(); - let rect = CropRect { x: 10, y: 10, width: 50, height: 50 }; - let cropped = EditorService::crop(img, &rect).unwrap(); - assert_eq!(cropped.width(), 50); - assert_eq!(cropped.height(), 50); - } - - #[test] - fn test_brightness() { - let img = create_test_image(); - let brightened = EditorService::adjust_brightness(img, 50); - assert_eq!(brightened.width(), 100); - } - - #[test] - fn test_auto_enhance() { - let img = create_test_image(); - let enhanced = EditorService::auto_enhance(img); - assert_eq!(enhanced.width(), 100); - } -} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index dff0702..f5dfa15 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -12,9 +12,6 @@ pub mod watcher; pub mod settings; pub mod libraw; pub mod wic; -pub mod editor; -pub mod colorspace; -pub mod native_editor; pub mod auto_scan; pub mod query_parser; pub mod ocr; @@ -28,7 +25,6 @@ pub use thumbnail::{ThumbnailService, ThumbnailSize, ThumbnailResult, CacheStats pub use thumbnail_queue::{ThumbnailQueue, ThumbnailTask}; pub use watcher::{FileWatcher, WatcherConfig, FileChangeEvent, FileChangeType}; pub use settings::SettingsManager; -pub use editor::{EditorService, EditParams, EditOperation, FlipDirection, CropRect}; pub use auto_scan::{AutoScanManager, AutoScanStatus, StepScanConfig}; pub use query_parser::{QueryParser, ParsedQuery, FieldFilter, FieldOperator}; pub use ocr::{OcrService, OcrResult, OcrProgress, OcrStats, OcrConfig, OcrStatus}; diff --git a/src-tauri/src/services/native_editor.rs b/src-tauri/src/services/native_editor.rs deleted file mode 100644 index 32cf492..0000000 --- a/src-tauri/src/services/native_editor.rs +++ /dev/null @@ -1,410 +0,0 @@ -//! Native Editor FFI 封装 -//! -//! 通过 FFI 调用 C/C++ 实现的 libvips 图像处理 - -use std::ffi::{CStr, CString}; -use std::os::raw::{c_char, c_float, c_int}; -use std::path::Path; -use std::sync::OnceLock; - -use libloading::{Library, Symbol}; - -use crate::utils::error::{AppError, AppResult}; - -/// 调整参数 (与 C 结构体对应) -#[repr(C)] -#[derive(Debug, Clone, Default)] -pub struct PwAdjustments { - pub brightness: f32, - pub contrast: f32, - pub saturation: f32, - pub exposure: f32, - pub highlights: f32, - pub shadows: f32, - pub temperature: f32, - pub tint: f32, - pub sharpen: f32, - pub blur: f32, - pub vignette: f32, -} - -// 函数类型定义 -type PwEditorInit = unsafe extern "C" fn() -> c_int; -type PwEditorCleanup = unsafe extern "C" fn(); -type PwGetLastError = unsafe extern "C" fn() -> *const c_char; -type PwApplyAdjustments = unsafe extern "C" fn( - input_path: *const c_char, - output_path: *const c_char, - adjustments: *const PwAdjustments, - quality: c_int, -) -> c_int; -type PwBlur = unsafe extern "C" fn( - input_path: *const c_char, - output_path: *const c_char, - sigma: c_float, -) -> c_int; -type PwSharpen = unsafe extern "C" fn( - input_path: *const c_char, - output_path: *const c_char, - sigma: c_float, - amount: c_float, -) -> c_int; -type PwAdjustExposure = unsafe extern "C" fn( - input_path: *const c_char, - output_path: *const c_char, - ev: c_float, -) -> c_int; -type PwAdjustHighlights = unsafe extern "C" fn( - input_path: *const c_char, - output_path: *const c_char, - amount: c_float, -) -> c_int; -type PwAdjustShadows = unsafe extern "C" fn( - input_path: *const c_char, - output_path: *const c_char, - amount: c_float, -) -> c_int; -type PwAdjustTemperature = unsafe extern "C" fn( - input_path: *const c_char, - output_path: *const c_char, - kelvin_shift: c_float, -) -> c_int; - -/// Native Editor 库封装 -pub struct NativeEditor { - _library: Library, - init: Symbol<'static, PwEditorInit>, - cleanup: Symbol<'static, PwEditorCleanup>, - get_last_error: Symbol<'static, PwGetLastError>, - apply_adjustments: Symbol<'static, PwApplyAdjustments>, - blur: Symbol<'static, PwBlur>, - sharpen: Symbol<'static, PwSharpen>, - adjust_exposure: Symbol<'static, PwAdjustExposure>, - adjust_highlights: Symbol<'static, PwAdjustHighlights>, - adjust_shadows: Symbol<'static, PwAdjustShadows>, - adjust_temperature: Symbol<'static, PwAdjustTemperature>, - initialized: bool, -} - -// 全局实例 -static NATIVE_EDITOR: OnceLock> = OnceLock::new(); - -impl NativeEditor { - /// 加载 native editor 库 - pub fn load() -> AppResult<&'static NativeEditor> { - let result = NATIVE_EDITOR.get_or_init(|| { - Self::load_internal().map_err(|e| e.to_string()) - }); - - match result { - Ok(editor) => Ok(editor), - Err(e) => Err(AppError::General(format!("Failed to load native editor: {}", e))), - } - } - - fn load_internal() -> AppResult { - // 尝试多个可能的 DLL 路径 - let dll_paths = [ - "photowall_editor.dll", - "./photowall_editor.dll", - "../native/build/Release/photowall_editor.dll", - "../native/build/Debug/photowall_editor.dll", - ]; - - let mut last_error = None; - let library = dll_paths.iter().find_map(|path| { - match unsafe { Library::new(path) } { - Ok(lib) => Some(lib), - Err(e) => { - last_error = Some(e); - None - } - } - }).ok_or_else(|| { - AppError::General(format!( - "Failed to load photowall_editor.dll: {:?}", - last_error - )) - })?; - - // 加载函数符号 - unsafe { - let init: Symbol = library - .get(b"pw_editor_init") - .map_err(|e| AppError::General(format!("Symbol pw_editor_init not found: {}", e)))?; - - let cleanup: Symbol = library - .get(b"pw_editor_cleanup") - .map_err(|e| AppError::General(format!("Symbol pw_editor_cleanup not found: {}", e)))?; - - let get_last_error: Symbol = library - .get(b"pw_get_last_error") - .map_err(|e| AppError::General(format!("Symbol pw_get_last_error not found: {}", e)))?; - - let apply_adjustments: Symbol = library - .get(b"pw_apply_adjustments") - .map_err(|e| AppError::General(format!("Symbol pw_apply_adjustments not found: {}", e)))?; - - let blur: Symbol = library - .get(b"pw_blur") - .map_err(|e| AppError::General(format!("Symbol pw_blur not found: {}", e)))?; - - let sharpen: Symbol = library - .get(b"pw_sharpen") - .map_err(|e| AppError::General(format!("Symbol pw_sharpen not found: {}", e)))?; - - let adjust_exposure: Symbol = library - .get(b"pw_adjust_exposure") - .map_err(|e| AppError::General(format!("Symbol pw_adjust_exposure not found: {}", e)))?; - - let adjust_highlights: Symbol = library - .get(b"pw_adjust_highlights") - .map_err(|e| AppError::General(format!("Symbol pw_adjust_highlights not found: {}", e)))?; - - let adjust_shadows: Symbol = library - .get(b"pw_adjust_shadows") - .map_err(|e| AppError::General(format!("Symbol pw_adjust_shadows not found: {}", e)))?; - - let adjust_temperature: Symbol = library - .get(b"pw_adjust_temperature") - .map_err(|e| AppError::General(format!("Symbol pw_adjust_temperature not found: {}", e)))?; - - // 延长生命周期 (库会一直保持加载) - let init: Symbol<'static, PwEditorInit> = std::mem::transmute(init); - let cleanup: Symbol<'static, PwEditorCleanup> = std::mem::transmute(cleanup); - let get_last_error: Symbol<'static, PwGetLastError> = std::mem::transmute(get_last_error); - let apply_adjustments: Symbol<'static, PwApplyAdjustments> = std::mem::transmute(apply_adjustments); - let blur: Symbol<'static, PwBlur> = std::mem::transmute(blur); - let sharpen: Symbol<'static, PwSharpen> = std::mem::transmute(sharpen); - let adjust_exposure: Symbol<'static, PwAdjustExposure> = std::mem::transmute(adjust_exposure); - let adjust_highlights: Symbol<'static, PwAdjustHighlights> = std::mem::transmute(adjust_highlights); - let adjust_shadows: Symbol<'static, PwAdjustShadows> = std::mem::transmute(adjust_shadows); - let adjust_temperature: Symbol<'static, PwAdjustTemperature> = std::mem::transmute(adjust_temperature); - - let mut editor = NativeEditor { - _library: library, - init, - cleanup, - get_last_error, - apply_adjustments, - blur, - sharpen, - adjust_exposure, - adjust_highlights, - adjust_shadows, - adjust_temperature, - initialized: false, - }; - - // 初始化 libvips - editor.initialize()?; - - Ok(editor) - } - } - - /// 初始化编辑器 - fn initialize(&mut self) -> AppResult<()> { - if self.initialized { - return Ok(()); - } - - let result = unsafe { (self.init)() }; - if result != 0 { - return Err(AppError::General(format!( - "Failed to initialize native editor: {}", - self.last_error() - ))); - } - - self.initialized = true; - Ok(()) - } - - /// 获取最后一次错误信息 - fn last_error(&self) -> String { - unsafe { - let ptr = (self.get_last_error)(); - if ptr.is_null() { - "Unknown error".to_string() - } else { - CStr::from_ptr(ptr).to_string_lossy().into_owned() - } - } - } - - /// 应用综合调整 - pub fn apply_adjustments( - &self, - input_path: &Path, - output_path: &Path, - adjustments: &PwAdjustments, - quality: i32, - ) -> AppResult<()> { - let input = path_to_cstring(input_path)?; - let output = path_to_cstring(output_path)?; - - let result = unsafe { - (self.apply_adjustments)(input.as_ptr(), output.as_ptr(), adjustments, quality) - }; - - if result != 0 { - Err(AppError::General(format!( - "Failed to apply adjustments: {}", - self.last_error() - ))) - } else { - Ok(()) - } - } - - /// 应用模糊 - pub fn blur(&self, input_path: &Path, output_path: &Path, sigma: f32) -> AppResult<()> { - let input = path_to_cstring(input_path)?; - let output = path_to_cstring(output_path)?; - - let result = unsafe { (self.blur)(input.as_ptr(), output.as_ptr(), sigma) }; - - if result != 0 { - Err(AppError::General(format!( - "Failed to apply blur: {}", - self.last_error() - ))) - } else { - Ok(()) - } - } - - /// 应用锐化 - pub fn sharpen( - &self, - input_path: &Path, - output_path: &Path, - sigma: f32, - amount: f32, - ) -> AppResult<()> { - let input = path_to_cstring(input_path)?; - let output = path_to_cstring(output_path)?; - - let result = unsafe { (self.sharpen)(input.as_ptr(), output.as_ptr(), sigma, amount) }; - - if result != 0 { - Err(AppError::General(format!( - "Failed to apply sharpen: {}", - self.last_error() - ))) - } else { - Ok(()) - } - } - - /// 调整曝光 - pub fn adjust_exposure(&self, input_path: &Path, output_path: &Path, ev: f32) -> AppResult<()> { - let input = path_to_cstring(input_path)?; - let output = path_to_cstring(output_path)?; - - let result = unsafe { (self.adjust_exposure)(input.as_ptr(), output.as_ptr(), ev) }; - - if result != 0 { - Err(AppError::General(format!( - "Failed to adjust exposure: {}", - self.last_error() - ))) - } else { - Ok(()) - } - } - - /// 调整高光 - pub fn adjust_highlights( - &self, - input_path: &Path, - output_path: &Path, - amount: f32, - ) -> AppResult<()> { - let input = path_to_cstring(input_path)?; - let output = path_to_cstring(output_path)?; - - let result = unsafe { (self.adjust_highlights)(input.as_ptr(), output.as_ptr(), amount) }; - - if result != 0 { - Err(AppError::General(format!( - "Failed to adjust highlights: {}", - self.last_error() - ))) - } else { - Ok(()) - } - } - - /// 调整阴影 - pub fn adjust_shadows( - &self, - input_path: &Path, - output_path: &Path, - amount: f32, - ) -> AppResult<()> { - let input = path_to_cstring(input_path)?; - let output = path_to_cstring(output_path)?; - - let result = unsafe { (self.adjust_shadows)(input.as_ptr(), output.as_ptr(), amount) }; - - if result != 0 { - Err(AppError::General(format!( - "Failed to adjust shadows: {}", - self.last_error() - ))) - } else { - Ok(()) - } - } - - /// 调整色温 - pub fn adjust_temperature( - &self, - input_path: &Path, - output_path: &Path, - kelvin_shift: f32, - ) -> AppResult<()> { - let input = path_to_cstring(input_path)?; - let output = path_to_cstring(output_path)?; - - let result = - unsafe { (self.adjust_temperature)(input.as_ptr(), output.as_ptr(), kelvin_shift) }; - - if result != 0 { - Err(AppError::General(format!( - "Failed to adjust temperature: {}", - self.last_error() - ))) - } else { - Ok(()) - } - } -} - -impl Drop for NativeEditor { - fn drop(&mut self) { - if self.initialized { - unsafe { - (self.cleanup)(); - } - } - } -} - -// 路径转 CString -fn path_to_cstring(path: &Path) -> AppResult { - let path_str = path.to_str().ok_or_else(|| { - AppError::General("Invalid path encoding".to_string()) - })?; - - CString::new(path_str).map_err(|_| { - AppError::General("Path contains null byte".to_string()) - }) -} - -/// 检查 native editor 是否可用 -pub fn is_native_editor_available() -> bool { - NativeEditor::load().is_ok() -} diff --git a/src-tauri/src/services/settings.rs b/src-tauri/src/services/settings.rs index 5786e0b..03d87b8 100644 --- a/src-tauri/src/services/settings.rs +++ b/src-tauri/src/services/settings.rs @@ -51,13 +51,11 @@ impl SettingsManager { Ok(settings) } - /// 保存设置 - pub fn save(&self, settings: &AppSettings) -> Result<(), AppError> { - // 序列化为 JSON(格式化输出) + /// 保存设置(内部方法,直接写入完整 AppSettings) + fn save_raw(&self, settings: &AppSettings) -> Result<(), AppError> { let content = serde_json::to_string_pretty(settings) .map_err(|e| AppError::Config(format!("无法序列化设置: {}", e)))?; - // 写入文件 fs::write(&self.settings_path, content) .map_err(|e| AppError::Config(format!("无法保存设置文件: {}", e)))?; @@ -65,10 +63,36 @@ impl SettingsManager { Ok(()) } + /// 保存设置(保留服务端最新 shell,忽略请求体中的 shell) + pub fn save(&self, settings: &AppSettings) -> Result<(), AppError> { + // 读取当前已持久化的设置,获取最新 shell + let current = self.load()?; + + // 用请求体覆盖非 shell 字段,但始终保留存储中的最新 shell + let merged = AppSettings { + shell: current.shell, + ..settings.clone() + }; + + self.save_raw(&merged) + } + + /// 保存壳层状态设置(只更新 shell 子树) + pub fn save_shell_settings(&self, shell: &crate::models::ShellSettings) -> Result<(), AppError> { + // 读取当前已持久化的设置 + let mut current = self.load()?; + + // 替换 shell 子树 + current.shell = shell.clone(); + + // 写回完整设置 + self.save_raw(¤t) + } + /// 重置为默认设置 pub fn reset(&self) -> Result { let default_settings = AppSettings::default(); - self.save(&default_settings)?; + self.save_raw(&default_settings)?; Ok(default_settings) } } diff --git a/src-tauri/src/window_effects.rs b/src-tauri/src/window_effects.rs index 899186f..2600ca6 100644 --- a/src-tauri/src/window_effects.rs +++ b/src-tauri/src/window_effects.rs @@ -1,7 +1,8 @@ use std::sync::{Mutex, OnceLock}; -use crate::models::settings::WindowSettings; +use crate::models::settings::{EffectMode, WindowSettings}; use tauri::WebviewWindow; +use tracing::{info, warn}; static LAST_WINDOW_SETTINGS: OnceLock> = OnceLock::new(); @@ -40,6 +41,10 @@ fn apply_platform_effects(window: &WebviewWindow, settings: &WindowSettings) { apply_macos_effects(window, settings); } +// --------------------------------------------------------------------------- +// Windows +// --------------------------------------------------------------------------- + #[cfg(target_os = "windows")] fn clear_all_effects(window: &WebviewWindow) { use window_vibrancy::{clear_acrylic, clear_blur, clear_mica, clear_tabbed}; @@ -50,38 +55,136 @@ fn clear_all_effects(window: &WebviewWindow) { let _ = clear_blur(window); } +/// Detect Windows 11 by checking the OS build number (>= 22000). #[cfg(target_os = "windows")] -fn apply_windows_effects(window: &WebviewWindow, settings: &WindowSettings) { - use window_vibrancy::apply_acrylic; +fn is_win11() -> bool { + use windows::Win32::System::SystemInformation::{GetVersionExW, OSVERSIONINFOW}; - // 清除旧效果 - clear_all_effects(window); + let mut info = OSVERSIONINFOW { + dwOSVersionInfoSize: std::mem::size_of::() as u32, + ..Default::default() + }; - // When custom desktop blur is enabled (rendered by the webview), keep native vibrancy off - // to avoid a fixed-strength DWM blur that cannot be tuned. - if settings.custom_blur_enabled { - return; + // GetVersionExW may be subject to manifest-based version lying, but for + // build numbers >= 22000 it is reliable enough for our purpose. + #[allow(unsafe_code)] + let ok = unsafe { GetVersionExW(&mut info) }; + if ok.is_ok() { + info.dwBuildNumber >= 22000 + } else { + false } +} - // 使用 transparency 参数 (0-100) - // 0 = 不透明 (alpha=240), 100 = 高度透明 (alpha=20) +/// Compute the tint colour from the transparency setting. +#[cfg(target_os = "windows")] +fn compute_tint(settings: &WindowSettings) -> (u8, u8, u8, u8) { + // transparency 0-100: 0 = opaque (alpha=240), 100 = highly transparent (alpha=20) let transparency = settings.transparency.min(100); let alpha = (240.0 - (transparency as f64 * 2.2)) .round() .clamp(20.0, 240.0) as u8; + (15, 23, 42, alpha) +} + +/// Try to apply Mica. Returns `true` on success. +#[cfg(target_os = "windows")] +fn try_apply_mica(window: &WebviewWindow) -> bool { + use window_vibrancy::apply_mica; + match apply_mica(window, None) { + Ok(_) => { + info!("Window effect: mica applied"); + true + } + Err(e) => { + warn!("Window effect: mica failed ({e}), will try fallback"); + false + } + } +} + +/// Try to apply Acrylic. Returns `true` on success. +#[cfg(target_os = "windows")] +fn try_apply_acrylic(window: &WebviewWindow, settings: &WindowSettings) -> bool { + use window_vibrancy::apply_acrylic; + let tint = compute_tint(settings); + match apply_acrylic(window, Some(tint)) { + Ok(_) => { + info!("Window effect: acrylic applied"); + true + } + Err(e) => { + warn!("Window effect: acrylic failed ({e}), falling back to solid"); + false + } + } +} - // 固定深色 tint 颜色 - let tint = (15, 23, 42, alpha); +/// Apply solid (no native effect). Always succeeds. +#[cfg(target_os = "windows")] +fn apply_solid(window: &WebviewWindow) { + clear_all_effects(window); + info!("Window effect: solid (no native effect)"); +} - // 始终应用 Acrylic 效果 - let _ = apply_acrylic(window, Some(tint)); +/// Resolve the effective mode when the user chose `auto`. +#[cfg(target_os = "windows")] +fn resolve_auto(window: &WebviewWindow, settings: &WindowSettings) { + if is_win11() { + // Win11: mica -> acrylic -> solid + if try_apply_mica(window) { + return; + } + if try_apply_acrylic(window, settings) { + return; + } + apply_solid(window); + } else { + // Non-Win11: acrylic -> solid + if try_apply_acrylic(window, settings) { + return; + } + apply_solid(window); + } } +#[cfg(target_os = "windows")] +fn apply_windows_effects(window: &WebviewWindow, settings: &WindowSettings) { + // Clear previous effects first + clear_all_effects(window); + + // When custom desktop blur is enabled (rendered by the webview), keep native + // vibrancy off to avoid a fixed-strength DWM blur that cannot be tuned. + if settings.custom_blur_enabled { + info!("Window effect: custom blur enabled, skipping native effects"); + return; + } + + match settings.effect_mode { + EffectMode::Auto => resolve_auto(window, settings), + EffectMode::Mica => { + if !try_apply_mica(window) && !try_apply_acrylic(window, settings) { + apply_solid(window); + } + } + EffectMode::Acrylic => { + if !try_apply_acrylic(window, settings) { + apply_solid(window); + } + } + EffectMode::Solid => apply_solid(window), + } +} + +// --------------------------------------------------------------------------- +// macOS +// --------------------------------------------------------------------------- + #[cfg(target_os = "macos")] fn apply_macos_effects(window: &WebviewWindow, settings: &WindowSettings) { use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial}; - // macOS 使用 transparency 作为模糊强度 + // macOS uses transparency as blur intensity let transparency = settings.transparency.min(100) as f64; let radius = if transparency <= 0.0 { None diff --git a/src/App.tsx b/src/App.tsx index 1b2dc90..5bc57f6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,23 +1,91 @@ -import { BrowserRouter, Routes, Route } from 'react-router-dom'; -import Layout from './components/layout/Layout'; +import { useEffect, useRef } from 'react'; +import { BrowserRouter } from 'react-router-dom'; +import { FluentProvider, webLightTheme, webDarkTheme } from '@fluentui/react-components'; +import { isTauri, invoke } from '@tauri-apps/api/core'; +import AppShell from './components/shell/AppShell'; import FrontendReady from './components/FrontendReady'; import { useThemeColor } from './hooks/useThemeColor'; import { useAutoScanEvents } from './hooks/useAutoScanEvents'; +import { useSettingsStore } from './stores/settingsStore'; +import { useNavigationStore } from './stores/navigationStore'; +import { getSettings } from './services/api/settings'; +import { useShallow } from 'zustand/shallow'; +import type { WindowSettings } from './types'; import './index.css'; function App() { - useThemeColor(); + const { isDark } = useThemeColor(); useAutoScanEvents(); + const applyWindowTimer = useRef | null>(null); + + // Hydrate settings from backend on startup + useEffect(() => { + let cancelled = false; + getSettings() + .then((settings) => { + if (!cancelled) { + useSettingsStore.getState().hydrateFromSettings(settings); + useNavigationStore.getState().hydrateShell(settings.shell); + } + }) + .catch((err) => { + console.debug('[settings] hydrate from backend failed:', err); + }); + return () => { + cancelled = true; + }; + }, []); + + // Window settings from store + const { effectMode, windowTransparency, blurRadius, customBlurEnabled, compositionBlurEnabled } = + useSettingsStore( + useShallow((state) => ({ + effectMode: state.effectMode, + windowTransparency: state.windowTransparency, + blurRadius: state.blurRadius, + customBlurEnabled: state.customBlurEnabled, + compositionBlurEnabled: state.compositionBlurEnabled, + })) + ); + + // Apply native window effects (Tauri desktop) + // Re-applies when window settings or theme change + useEffect(() => { + if (!isTauri()) return; + + if (applyWindowTimer.current) { + clearTimeout(applyWindowTimer.current); + } + + applyWindowTimer.current = setTimeout(() => { + const settings: WindowSettings = { + effectMode, + transparency: windowTransparency, + blurRadius, + customBlurEnabled, + compositionBlurEnabled, + }; + void invoke('apply_window_settings', { settings }).catch((err) => { + console.debug('[window] apply_window_settings failed:', err); + }); + }, 80); + + return () => { + if (applyWindowTimer.current) { + clearTimeout(applyWindowTimer.current); + applyWindowTimer.current = null; + } + }; + }, [effectMode, windowTransparency, blurRadius, customBlurEnabled, compositionBlurEnabled, isDark]); + return ( - <> + - - } /> - + - + ); } diff --git a/src/components/common/ContextMenu.tsx b/src/components/common/ContextMenu.tsx index ffabf5a..f75a76b 100644 --- a/src/components/common/ContextMenu.tsx +++ b/src/components/common/ContextMenu.tsx @@ -1,147 +1,179 @@ /** - * 右键菜单组件 - * - * 支持照片的常用操作:打开所在文件夹、复制路径、收藏、删除等 + * Fluent UI context menu for photos. + * + * Renders a positioned Menu at arbitrary (x, y) coordinates. + * Menu items: copy path, open folder, add to album, add tags, + * favorite/unfavorite, move, delete, view details. + * No edit-related items per AC-012.1. */ -import React, { useEffect, useRef, useCallback } from 'react'; -import { createPortal } from 'react-dom'; - -export interface ContextMenuItem { - /** 菜单项ID */ - id: string; - /** 显示文本 */ - label: string; - /** 图标 */ - icon?: React.ReactNode; - /** 是否禁用 */ - disabled?: boolean; - /** 是否显示分割线 */ - divider?: boolean; - /** 危险操作(红色显示) */ - danger?: boolean; - /** 点击回调 */ - onClick?: () => void; -} +import { useCallback, useMemo } from 'react'; +import { + Menu, + MenuPopover, + MenuList, + MenuItem, + MenuDivider, + MenuGroup, + MenuGroupHeader, +} from '@fluentui/react-components'; +import type { PositioningVirtualElement } from '@fluentui/react-components'; +import { + CopyRegular, + FolderOpenRegular, + AlbumRegular, + TagRegular, + StarRegular, + StarOffRegular, + ArrowMoveRegular, + DeleteRegular, + InfoRegular, +} from '@fluentui/react-icons'; +import type { Photo } from '@/types'; -interface ContextMenuProps { - /** 是否显示 */ - visible: boolean; - /** X 坐标 */ +export interface PhotoContextMenuState { + open: boolean; x: number; - /** Y 坐标 */ y: number; - /** 菜单项列表 */ - items: ContextMenuItem[]; - /** 关闭回调 */ + photo: Photo | null; +} + +export const INITIAL_CONTEXT_MENU: PhotoContextMenuState = { + open: false, + x: 0, + y: 0, + photo: null, +}; + +export interface PhotoContextMenuProps { + state: PhotoContextMenuState; onClose: () => void; + onCopyPath?: (photo: Photo) => void; + onOpenFolder?: (photo: Photo) => void; + onAddToAlbum?: (photo: Photo) => void; + onAddTags?: (photo: Photo) => void; + onToggleFavorite?: (photo: Photo) => void; + onMove?: (photo: Photo) => void; + onDelete?: (photo: Photo) => void; + onViewDetails?: (photo: Photo) => void; +} + +function makeVirtualElement(x: number, y: number): PositioningVirtualElement { + return { + getBoundingClientRect: () => ({ + x, + y, + top: y, + left: x, + bottom: y, + right: x, + width: 0, + height: 0, + toJSON: () => ({}), + }), + }; } -function ContextMenu({ visible, x, y, items, onClose }: ContextMenuProps) { - const menuRef = useRef(null); - - // 点击外部关闭 - useEffect(() => { - if (!visible) return; - - const handleClickOutside = (e: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(e.target as Node)) { - onClose(); - } - }; - - const handleScroll = () => { - onClose(); - }; - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - onClose(); - } - }; - - // 延迟添加监听器,避免立即触发 - const timer = setTimeout(() => { - document.addEventListener('mousedown', handleClickOutside); - document.addEventListener('scroll', handleScroll, true); - document.addEventListener('keydown', handleKeyDown); - }, 0); - - return () => { - clearTimeout(timer); - document.removeEventListener('mousedown', handleClickOutside); - document.removeEventListener('scroll', handleScroll, true); - document.removeEventListener('keydown', handleKeyDown); - }; - }, [visible, onClose]); - - // 调整位置防止溢出 - const adjustPosition = useCallback(() => { - if (!menuRef.current) return { x, y }; - - const rect = menuRef.current.getBoundingClientRect(); - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - - let adjustedX = x; - let adjustedY = y; - - if (x + rect.width > viewportWidth) { - adjustedX = viewportWidth - rect.width - 8; - } - - if (y + rect.height > viewportHeight) { - adjustedY = viewportHeight - rect.height - 8; - } - - return { x: adjustedX, y: adjustedY }; - }, [x, y]); - - if (!visible) return null; - - const position = adjustPosition(); - - return createPortal( -
makeVirtualElement(x, y), [x, y]); + + const handleOpenChange = useCallback( + (_e: unknown, data: { open: boolean }) => { + if (!data.open) onClose(); + }, + [onClose], + ); + + if (!photo) return null; + + const isFavorite = photo.isFavorite; + + return ( + - {items.map((item, index) => ( -
- {item.divider && index > 0 && ( -
- )} - -
- ))} -
, - document.body + 移动到... + + } + onClick={() => onDelete?.(photo)} + > + 删除 + + + + + } + onClick={() => onViewDetails?.(photo)} + > + 查看详情 + + + +
); } - -import clsx from 'clsx'; -export default ContextMenu; diff --git a/src/components/content/AlbumsContent.tsx b/src/components/content/AlbumsContent.tsx new file mode 100644 index 0000000..4efca97 --- /dev/null +++ b/src/components/content/AlbumsContent.tsx @@ -0,0 +1,296 @@ +/** + * Albums content view for AppShell + * + * Renders album grid and album detail (photos) within the shell content area. + * Reuses existing getAllAlbumsWithCount, getPhotosByAlbum APIs. + */ + +import { useState, useEffect, useCallback, useMemo } from 'react'; +import type { MouseEvent } from 'react'; +import { + makeStyles, + tokens, + Spinner, + Button, + Text, +} from '@fluentui/react-components'; +import { + Album24Regular, + ArrowLeft24Regular, + Settings24Regular, +} from '@fluentui/react-icons'; +import { getAllAlbumsWithCount, getPhotosByAlbum } from '@/services/api'; +import { AlbumGrid, AlbumManager } from '@/components/album'; +import ContentArea from './ContentArea'; +import { useSelectionStore } from '@/stores/selectionStore'; +import { useNavigationStore } from '@/stores/navigationStore'; +import { PhotoViewer } from '@/components/photo'; +import type { AlbumWithCount, Photo } from '@/types'; + +const PAGE_SIZE = 100; + +const useStyles = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + flex: 1, + minHeight: 0, + overflow: 'hidden', + }, + header: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '12px 16px', + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + flexShrink: 0, + }, + headerLeft: { + display: 'flex', + alignItems: 'center', + gap: '8px', + }, + content: { + flex: 1, + minHeight: 0, + overflow: 'auto', + }, + empty: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + flex: 1, + gap: '8px', + color: tokens.colorNeutralForeground3, + }, +}); + +interface AlbumsContentProps { + onTotalCountChange?: (count: number) => void; +} + +export default function AlbumsContent({ onTotalCountChange }: AlbumsContentProps) { + const styles = useStyles(); + const [albums, setAlbums] = useState([]); + const [loading, setLoading] = useState(false); + const [managerOpen, setManagerOpen] = useState(false); + + // Album detail state + const activeNode = useNavigationStore((s) => s.activeNode); + const setActiveNode = useNavigationStore((s) => s.setActiveNode); + const selectedAlbumId = useMemo(() => { + const match = activeNode.match(/^album:(\d+)$/); + return match ? Number(match[1]) : null; + }, [activeNode]); + + const selectedAlbum = useMemo( + () => albums.find((a) => a.album.albumId === selectedAlbumId) ?? null, + [albums, selectedAlbumId], + ); + + // Album photos state + const [albumPhotos, setAlbumPhotos] = useState([]); + const [albumPhotosLoading, setAlbumPhotosLoading] = useState(false); + const [albumPhotoPage, setAlbumPhotoPage] = useState(1); + const [albumPhotosHasMore, setAlbumPhotosHasMore] = useState(false); + + // Selection + const selectedIds = useSelectionStore((s) => s.selectedIds); + const clearSelection = useSelectionStore((s) => s.clearSelection); + const select = useSelectionStore((s) => s.select); + const toggle = useSelectionStore((s) => s.toggle); + + // Viewer + const [viewerOpen, setViewerOpen] = useState(false); + const [viewerPhoto, setViewerPhoto] = useState(null); + + const loadAlbums = useCallback(async () => { + try { + setLoading(true); + const list = await getAllAlbumsWithCount(); + setAlbums(list); + } catch (err) { + console.error('Failed to load albums:', err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadAlbums(); + }, [loadAlbums]); + + useEffect(() => { + if (selectedAlbumId != null) { + onTotalCountChange?.(selectedAlbum?.photoCount ?? albumPhotos.length); + return; + } + + onTotalCountChange?.(albums.length); + }, [ + albumPhotos.length, + albums.length, + onTotalCountChange, + selectedAlbum?.photoCount, + selectedAlbumId, + ]); + + // Load album photos when selectedAlbumId changes + useEffect(() => { + if (selectedAlbumId == null) { + setAlbumPhotos([]); + return; + } + let cancelled = false; + const load = async () => { + setAlbumPhotosLoading(true); + try { + const result = await getPhotosByAlbum(selectedAlbumId, { page: 1, pageSize: PAGE_SIZE }); + if (!cancelled) { + setAlbumPhotos(result.items); + setAlbumPhotoPage(1); + setAlbumPhotosHasMore(1 < result.totalPages); + } + } catch (err) { + console.error('Failed to load album photos:', err); + } finally { + if (!cancelled) setAlbumPhotosLoading(false); + } + }; + load(); + clearSelection(); + return () => { cancelled = true; }; + }, [selectedAlbumId, clearSelection]); + + const handleLoadMore = useCallback(async () => { + if (albumPhotosLoading || !albumPhotosHasMore || selectedAlbumId == null) return; + setAlbumPhotosLoading(true); + try { + const nextPage = albumPhotoPage + 1; + const result = await getPhotosByAlbum(selectedAlbumId, { page: nextPage, pageSize: PAGE_SIZE }); + setAlbumPhotos((prev) => [...prev, ...result.items]); + setAlbumPhotoPage(nextPage); + setAlbumPhotosHasMore(nextPage < result.totalPages); + } catch (err) { + console.error('Failed to load more album photos:', err); + } finally { + setAlbumPhotosLoading(false); + } + }, [albumPhotosLoading, albumPhotosHasMore, selectedAlbumId, albumPhotoPage]); + + const handleAlbumClick = useCallback( + (album: AlbumWithCount) => { + setActiveNode(`album:${album.album.albumId}`); + }, + [setActiveNode], + ); + + const handleAlbumContextMenu = useCallback((_album: AlbumWithCount, _event: MouseEvent) => { + // Context menu handled elsewhere + }, []); + + const handleBack = useCallback(() => { + setActiveNode('albums'); + clearSelection(); + }, [setActiveNode, clearSelection]); + + const handlePhotoClick = useCallback( + (photo: Photo, event: MouseEvent) => { + if (event.ctrlKey || event.metaKey) { + toggle(photo.photoId); + } else { + clearSelection(); + select(photo.photoId); + } + }, + [clearSelection, select, toggle], + ); + + const handlePhotoDoubleClick = useCallback((photo: Photo) => { + setViewerPhoto(photo); + setViewerOpen(true); + }, []); + + // Album detail view + if (selectedAlbumId != null) { + return ( +
+
+
+
+
+ + {viewerPhoto && ( + setViewerOpen(false)} + /> + )} +
+ ); + } + + // Album grid view + return ( +
+
+
+ + 相册 + + {albums.length} 个相册 + +
+ +
+
+ {loading ? ( +
+ +
+ ) : ( + + )} +
+ setManagerOpen(false)} + onAlbumsChange={loadAlbums} + /> +
+ ); +} diff --git a/src/components/content/ContentArea.tsx b/src/components/content/ContentArea.tsx new file mode 100644 index 0000000..a84f854 --- /dev/null +++ b/src/components/content/ContentArea.tsx @@ -0,0 +1,159 @@ +import { lazy, Suspense, useMemo } from 'react'; +import type { MouseEvent } from 'react'; +import { makeStyles, tokens, Spinner } from '@fluentui/react-components'; +import { useNavigationStore } from '@/stores/navigationStore'; +import type { Photo, FilterState } from '@/types'; + +// Lazy-load all view components +const LargeIconView = lazy(() => import('./views/LargeIconView')); +const MediumIconView = lazy(() => import('./views/MediumIconView')); +const SmallIconView = lazy(() => import('./views/SmallIconView')); +const ListView = lazy(() => import('./views/ListView')); +const DetailView = lazy(() => import('./views/DetailView')); +const TileView = lazy(() => import('./views/TileView')); + +export interface ContentAreaProps { + photos: Photo[]; + selectedIds?: Set; + loading?: boolean; + hasMore?: boolean; + onPhotoClick?: (photo: Photo, event: MouseEvent) => void; + onPhotoDoubleClick?: (photo: Photo) => void; + onPhotoContextMenu?: (photo: Photo, event: MouseEvent) => void; + onPhotoSelect?: (photo: Photo, selected: boolean) => void; + onLoadMore?: () => void; +} + +const useStyles = makeStyles({ + root: { + flex: 1, + minWidth: 0, + minHeight: 0, + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + }, + loading: { + flex: 1, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: tokens.colorNeutralForeground3, + }, +}); + +const VIEW_MAP = { + large: LargeIconView, + medium: MediumIconView, + small: SmallIconView, + list: ListView, + detail: DetailView, + tile: TileView, +} as const; + +/** Check if a filter has any active conditions */ +function isFilterActive(filter: FilterState): boolean { + return ( + filter.fileTypes.length > 0 || + filter.dateRange.from !== null || + filter.dateRange.to !== null || + filter.tags.length > 0 || + filter.rating.min !== null || + filter.rating.max !== null || + filter.sizeRange.min !== null || + filter.sizeRange.max !== null + ); +} + +/** Apply FilterState to a photo list (client-side filtering) */ +function applyFilter(photos: Photo[], filter: FilterState): Photo[] { + if (!isFilterActive(filter)) return photos; + + return photos.filter((photo) => { + // File type filter + if (filter.fileTypes.length > 0) { + const ext = (photo.format || photo.fileName.split('.').pop() || '') + .toLowerCase() + .replace('jpg', 'jpeg'); + if (!filter.fileTypes.some((t) => ext.includes(t))) return false; + } + + // Date range filter + if (filter.dateRange.from || filter.dateRange.to) { + const photoDate = photo.dateTaken || photo.dateAdded; + if (photoDate) { + const d = photoDate.slice(0, 10); // YYYY-MM-DD + if (filter.dateRange.from && d < filter.dateRange.from) return false; + if (filter.dateRange.to && d > filter.dateRange.to) return false; + } else { + // No date info, exclude if date filter is active + return false; + } + } + + // Rating filter + if (filter.rating.min !== null && photo.rating < filter.rating.min) + return false; + if (filter.rating.max !== null && photo.rating > filter.rating.max) + return false; + + // File size filter + if (filter.sizeRange.min !== null && photo.fileSize < filter.sizeRange.min) + return false; + if (filter.sizeRange.max !== null && photo.fileSize > filter.sizeRange.max) + return false; + + // Tag filter is name-based; photos don't carry tag names inline, + // so tag filtering is best done at the query level. Skip here if tags present. + // (Tag filtering will be handled by the data query layer when available) + + return true; + }); +} + +export default function ContentArea({ + photos, + selectedIds, + loading, + hasMore, + onPhotoClick, + onPhotoDoubleClick, + onPhotoContextMenu, + onPhotoSelect, + onLoadMore, +}: ContentAreaProps) { + const styles = useStyles(); + const viewMode = useNavigationStore((s) => s.viewMode); + const filter = useNavigationStore((s) => s.filter); + + const ViewComponent = useMemo(() => VIEW_MAP[viewMode], [viewMode]); + + const filteredPhotos = useMemo( + () => applyFilter(photos, filter), + [photos, filter], + ); + + return ( +
+ + +
+ } + > + + +
+ ); +} diff --git a/src/components/content/FavoritesContent.tsx b/src/components/content/FavoritesContent.tsx new file mode 100644 index 0000000..12be64d --- /dev/null +++ b/src/components/content/FavoritesContent.tsx @@ -0,0 +1,213 @@ +/** + * Favorites content view for AppShell + * + * Renders favorite photos within the shell content area. + * Reuses existing getFavoritePhotos, setPhotosFavorite APIs. + */ + +import { useState, useEffect, useCallback } from 'react'; +import type { MouseEvent } from 'react'; +import { + makeStyles, + tokens, + Spinner, + Text, + Button, +} from '@fluentui/react-components'; +import { Heart24Regular, Heart24Filled } from '@fluentui/react-icons'; +import { getFavoritePhotos, setPhotosFavorite } from '@/services/api'; +import ContentArea from './ContentArea'; +import { useSelectionStore } from '@/stores/selectionStore'; +import { PhotoViewer } from '@/components/photo'; +import type { Photo, PaginatedResult } from '@/types'; + +const PAGE_SIZE = 100; + +const useStyles = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + flex: 1, + minHeight: 0, + overflow: 'hidden', + }, + header: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '12px 16px', + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + flexShrink: 0, + }, + headerLeft: { + display: 'flex', + alignItems: 'center', + gap: '8px', + }, + empty: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + flex: 1, + gap: '8px', + color: tokens.colorNeutralForeground3, + }, +}); + +interface FavoritesContentProps { + onTotalCountChange?: (count: number) => void; +} + +export default function FavoritesContent({ onTotalCountChange }: FavoritesContentProps) { + const styles = useStyles(); + const [photos, setPhotos] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [loading, setLoading] = useState(false); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(false); + + // Selection + const selectedIds = useSelectionStore((s) => s.selectedIds); + const clearSelection = useSelectionStore((s) => s.clearSelection); + const select = useSelectionStore((s) => s.select); + const toggle = useSelectionStore((s) => s.toggle); + + // Viewer + const [viewerOpen, setViewerOpen] = useState(false); + const [viewerPhoto, setViewerPhoto] = useState(null); + + const loadFavorites = useCallback( + async (pageNum: number, reset: boolean) => { + setLoading(true); + try { + const result: PaginatedResult = await getFavoritePhotos({ + page: pageNum, + pageSize: PAGE_SIZE, + }); + if (reset) { + setPhotos(result.items); + } else { + setPhotos((prev) => [...prev, ...result.items]); + } + setPage(pageNum); + setTotalCount(result.total); + setHasMore(pageNum < result.totalPages); + } catch (err) { + console.error('Failed to load favorites:', err); + } finally { + setLoading(false); + } + }, + [], + ); + + useEffect(() => { + loadFavorites(1, true); + clearSelection(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + onTotalCountChange?.(totalCount); + }, [onTotalCountChange, totalCount]); + + const handleLoadMore = useCallback(() => { + if (!loading && hasMore) { + loadFavorites(page + 1, false); + } + }, [loading, hasMore, page, loadFavorites]); + + const handlePhotoClick = useCallback( + (photo: Photo, event: MouseEvent) => { + if (event.ctrlKey || event.metaKey) { + toggle(photo.photoId); + } else { + clearSelection(); + select(photo.photoId); + } + }, + [clearSelection, select, toggle], + ); + + const handlePhotoDoubleClick = useCallback((photo: Photo) => { + setViewerPhoto(photo); + setViewerOpen(true); + }, []); + + const handleUnfavorite = useCallback(async () => { + if (selectedIds.size === 0) return; + try { + await setPhotosFavorite(Array.from(selectedIds), false); + setPhotos((prev) => prev.filter((p) => !selectedIds.has(p.photoId))); + setTotalCount((prev) => Math.max(0, prev - selectedIds.size)); + clearSelection(); + } catch (err) { + console.error('Failed to unfavorite:', err); + } + }, [selectedIds, clearSelection]); + + // Empty state + if (!loading && photos.length === 0) { + return ( +
+
+
+ + 收藏 + + 0 张照片 + +
+
+
+ + 暂无收藏 + 点击照片上的爱心图标即可添加到收藏 +
+
+ ); + } + + return ( +
+
+
+ + 收藏 + + {totalCount} 张照片 + +
+ {selectedIds.size > 0 && ( + + )} +
+ {loading && photos.length === 0 ? ( +
+ +
+ ) : ( + + )} + {viewerPhoto && ( + setViewerOpen(false)} + /> + )} +
+ ); +} diff --git a/src/components/content/FilterPanel.tsx b/src/components/content/FilterPanel.tsx new file mode 100644 index 0000000..35a7e66 --- /dev/null +++ b/src/components/content/FilterPanel.tsx @@ -0,0 +1,461 @@ +/** + * FilterPanel - 筛选面板组件 + * + * 五维筛选:文件类型、拍摄日期范围、标签、评分、文件大小 + * 筛选状态写回 navigationStore.filter 并通过 save_shell_settings 持久化 + */ + +import { useState, useCallback, useEffect } from 'react'; +import { + makeStyles, + tokens, + Button, + Checkbox, + Label, + Slider, + Tag, + Popover, + PopoverSurface, + PopoverTrigger, + Input, + Divider, + Badge, + Tooltip, +} from '@fluentui/react-components'; +import { + FilterRegular, + DismissCircleRegular, + CalendarRegular, + TagRegular, + StarRegular, + DocumentRegular, + ArrowMinimizeRegular, +} from '@fluentui/react-icons'; +import { useNavigationStore } from '@/stores/navigationStore'; +import { getAllTags } from '@/services/api'; +import type { FilterState, Tag as TagType } from '@/types'; + +const FILE_TYPE_OPTIONS = [ + { value: 'jpeg', label: 'JPEG' }, + { value: 'png', label: 'PNG' }, + { value: 'webp', label: 'WebP' }, + { value: 'gif', label: 'GIF' }, + { value: 'bmp', label: 'BMP' }, + { value: 'tiff', label: 'TIFF' }, + { value: 'raw', label: 'RAW' }, +]; + +const SIZE_PRESETS = [ + { label: '< 1 MB', min: null, max: 1048576 }, + { label: '1-5 MB', min: 1048576, max: 5242880 }, + { label: '5-20 MB', min: 5242880, max: 20971520 }, + { label: '> 20 MB', min: 20971520, max: null }, +]; + +const useStyles = makeStyles({ + surface: { + width: '360px', + maxHeight: '480px', + overflowY: 'auto', + padding: '16px', + display: 'flex', + flexDirection: 'column', + gap: '16px', + }, + header: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + }, + headerTitle: { + fontSize: '14px', + fontWeight: 600, + color: tokens.colorNeutralForeground1, + }, + section: { + display: 'flex', + flexDirection: 'column', + gap: '8px', + }, + sectionLabel: { + fontSize: '12px', + fontWeight: 600, + color: tokens.colorNeutralForeground2, + display: 'flex', + alignItems: 'center', + gap: '6px', + }, + checkboxGroup: { + display: 'flex', + flexWrap: 'wrap', + gap: '4px', + }, + dateRow: { + display: 'flex', + gap: '8px', + alignItems: 'center', + }, + dateInput: { + flex: 1, + minWidth: 0, + }, + tagList: { + display: 'flex', + flexWrap: 'wrap', + gap: '4px', + }, + ratingRow: { + display: 'flex', + alignItems: 'center', + gap: '8px', + }, + ratingLabel: { + fontSize: '12px', + color: tokens.colorNeutralForeground3, + minWidth: '60px', + }, + sizePresets: { + display: 'flex', + flexWrap: 'wrap', + gap: '4px', + }, + footer: { + display: 'flex', + justifyContent: 'flex-end', + gap: '8px', + paddingTop: '4px', + }, +}); + +function isFilterActive(filter: FilterState): boolean { + return ( + filter.fileTypes.length > 0 || + filter.dateRange.from !== null || + filter.dateRange.to !== null || + filter.tags.length > 0 || + filter.rating.min !== null || + filter.rating.max !== null || + filter.sizeRange.min !== null || + filter.sizeRange.max !== null + ); +} + +function countActiveFilters(filter: FilterState): number { + let count = 0; + if (filter.fileTypes.length > 0) count++; + if (filter.dateRange.from !== null || filter.dateRange.to !== null) count++; + if (filter.tags.length > 0) count++; + if (filter.rating.min !== null || filter.rating.max !== null) count++; + if (filter.sizeRange.min !== null || filter.sizeRange.max !== null) count++; + return count; +} + +export interface FilterPanelProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export default function FilterPanel({ open, onOpenChange }: FilterPanelProps) { + const styles = useStyles(); + const filter = useNavigationStore((s) => s.filter); + const setFilter = useNavigationStore((s) => s.setFilter); + + // Local draft state for editing + const [draft, setDraft] = useState(filter); + const [tags, setTags] = useState([]); + + // Sync draft when filter changes externally or panel opens + useEffect(() => { + if (open) { + setDraft(filter); + } + }, [open, filter]); + + // Load tags on first open + useEffect(() => { + if (open && tags.length === 0) { + getAllTags().then(setTags).catch(console.error); + } + }, [open, tags.length]); + + const updateDraft = useCallback((partial: Partial) => { + setDraft((prev) => ({ ...prev, ...partial })); + }, []); + + // File type toggle + const toggleFileType = useCallback((type: string) => { + setDraft((prev) => ({ + ...prev, + fileTypes: prev.fileTypes.includes(type) + ? prev.fileTypes.filter((t) => t !== type) + : [...prev.fileTypes, type], + })); + }, []); + + // Tag toggle + const toggleTag = useCallback((tagName: string) => { + setDraft((prev) => ({ + ...prev, + tags: prev.tags.includes(tagName) + ? prev.tags.filter((t) => t !== tagName) + : [...prev.tags, tagName], + })); + }, []); + + // Size preset toggle + const toggleSizePreset = useCallback( + (min: number | null, max: number | null) => { + setDraft((prev) => { + if (prev.sizeRange.min === min && prev.sizeRange.max === max) { + return { ...prev, sizeRange: { min: null, max: null } }; + } + return { ...prev, sizeRange: { min, max } }; + }); + }, + [], + ); + + // Apply filter + const handleApply = useCallback(() => { + setFilter(draft); + onOpenChange(false); + }, [draft, setFilter, onOpenChange]); + + // Clear all + const handleClear = useCallback(() => { + const empty: FilterState = { + fileTypes: [], + dateRange: { from: null, to: null }, + tags: [], + rating: { min: null, max: null }, + sizeRange: { min: null, max: null }, + }; + setDraft(empty); + setFilter(empty); + }, [setFilter]); + + const activeCount = countActiveFilters(filter); + + return ( + onOpenChange(data.open)} + positioning="below-start" + trapFocus + > + + + + + + + {/* Header */} +
+ 筛选条件 + {isFilterActive(draft) && ( + + )} +
+ + + + {/* File Types */} +
+ +
+ {FILE_TYPE_OPTIONS.map((opt) => ( + toggleFileType(opt.value)} + size="medium" + /> + ))} +
+
+ + + + {/* Date Range */} +
+ +
+ + updateDraft({ + dateRange: { + ...draft.dateRange, + from: data.value || null, + }, + }) + } + placeholder="开始日期" + /> + - + + updateDraft({ + dateRange: { + ...draft.dateRange, + to: data.value || null, + }, + }) + } + placeholder="结束日期" + /> +
+
+ + + + {/* Tags */} +
+ +
+ {tags.length === 0 ? ( + + 暂无标签 + + ) : ( + tags.map((tag) => ( + toggleTag(tag.tagName)} + style={{ cursor: 'pointer' }} + > + {tag.tagName} + + )) + )} +
+
+ + + + {/* Rating */} +
+ +
+ + {draft.rating.min ?? 0} - {draft.rating.max ?? 5} 星 + + + updateDraft({ + rating: { + ...draft.rating, + min: data.value > 0 ? data.value : null, + }, + }) + } + style={{ flex: 1 }} + /> +
+
+ + + + {/* File Size */} +
+ +
+ {SIZE_PRESETS.map((preset) => ( + + ))} +
+
+ + + + {/* Footer */} +
+ + +
+
+
+ ); +} + +export { isFilterActive, countActiveFilters }; diff --git a/src/components/content/FoldersContent.tsx b/src/components/content/FoldersContent.tsx new file mode 100644 index 0000000..ac90cab --- /dev/null +++ b/src/components/content/FoldersContent.tsx @@ -0,0 +1,247 @@ +/** + * Folders content view for AppShell + * + * Renders folder photos within the shell content area. + * Folder tree is handled by NavigationPane; this component shows photos for the selected folder. + * Reuses existing getPhotosByFolder API. + */ + +import { useState, useEffect, useCallback, useMemo } from 'react'; +import type { MouseEvent } from 'react'; +import { + makeStyles, + tokens, + Spinner, + Text, + Checkbox, +} from '@fluentui/react-components'; +import { Folder24Regular } from '@fluentui/react-icons'; +import { getPhotosByFolder } from '@/services/api'; +import ContentArea from './ContentArea'; +import { useSelectionStore } from '@/stores/selectionStore'; +import { useNavigationStore } from '@/stores/navigationStore'; +import { PhotoViewer } from '@/components/photo'; +import type { Photo } from '@/types'; + +const PAGE_SIZE = 100; + +const useStyles = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + flex: 1, + minHeight: 0, + overflow: 'hidden', + }, + header: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '12px 16px', + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + flexShrink: 0, + }, + headerLeft: { + display: 'flex', + alignItems: 'center', + gap: '8px', + }, + empty: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + flex: 1, + gap: '8px', + color: tokens.colorNeutralForeground3, + }, +}); + +interface FoldersContentProps { + onTotalCountChange?: (count: number) => void; +} + +export default function FoldersContent({ onTotalCountChange }: FoldersContentProps) { + const styles = useStyles(); + + const activeNode = useNavigationStore((s) => s.activeNode); + const sortBy = useNavigationStore((s) => s.sortBy); + const sortOrder = useNavigationStore((s) => s.sortOrder); + + // Extract folder path from activeNode (format: "folder:C:\path\to\folder") + const folderPath = useMemo(() => { + if (activeNode.startsWith('folder:')) { + return activeNode.slice(7); + } + return null; + }, [activeNode]); + + const folderName = useMemo(() => { + if (!folderPath) return null; + const parts = folderPath.split(/[\\/]/); + return parts[parts.length - 1] || folderPath; + }, [folderPath]); + + // Photos state + const [photos, setPhotos] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [loading, setLoading] = useState(false); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(false); + const [includeSubfolders, setIncludeSubfolders] = useState(false); + + // Selection + const selectedIds = useSelectionStore((s) => s.selectedIds); + const clearSelection = useSelectionStore((s) => s.clearSelection); + const select = useSelectionStore((s) => s.select); + const toggle = useSelectionStore((s) => s.toggle); + + // Viewer + const [viewerOpen, setViewerOpen] = useState(false); + const [viewerPhoto, setViewerPhoto] = useState(null); + + useEffect(() => { + onTotalCountChange?.(folderPath ? totalCount : 0); + }, [folderPath, onTotalCountChange, totalCount]); + + // Load photos when folder path, sort, or includeSubfolders changes + useEffect(() => { + if (!folderPath) { + setPhotos([]); + setTotalCount(0); + return; + } + let cancelled = false; + const load = async () => { + setLoading(true); + try { + const sort = { field: sortBy as 'dateTaken' | 'dateAdded' | 'fileName' | 'fileSize' | 'rating', order: sortOrder }; + const result = await getPhotosByFolder( + folderPath, + includeSubfolders, + { page: 1, pageSize: PAGE_SIZE }, + sort, + ); + if (!cancelled) { + setPhotos(result.items); + setPage(1); + setTotalCount(result.total); + setHasMore(1 < result.totalPages); + } + } catch (err) { + console.error('Failed to load folder photos:', err); + } finally { + if (!cancelled) setLoading(false); + } + }; + load(); + clearSelection(); + return () => { cancelled = true; }; + }, [folderPath, sortBy, sortOrder, includeSubfolders, clearSelection]); + + const handleLoadMore = useCallback(async () => { + if (loading || !hasMore || !folderPath) return; + setLoading(true); + try { + const nextPage = page + 1; + const sort = { field: sortBy as 'dateTaken' | 'dateAdded' | 'fileName' | 'fileSize' | 'rating', order: sortOrder }; + const result = await getPhotosByFolder( + folderPath, + includeSubfolders, + { page: nextPage, pageSize: PAGE_SIZE }, + sort, + ); + setPhotos((prev) => [...prev, ...result.items]); + setPage(nextPage); + setHasMore(nextPage < result.totalPages); + } catch (err) { + console.error('Failed to load more folder photos:', err); + } finally { + setLoading(false); + } + }, [loading, hasMore, folderPath, page, sortBy, sortOrder, includeSubfolders]); + + const handlePhotoClick = useCallback( + (photo: Photo, event: MouseEvent) => { + if (event.ctrlKey || event.metaKey) { + toggle(photo.photoId); + } else { + clearSelection(); + select(photo.photoId); + } + }, + [clearSelection, select, toggle], + ); + + const handlePhotoDoubleClick = useCallback((photo: Photo) => { + setViewerPhoto(photo); + setViewerOpen(true); + }, []); + + // No folder selected - show prompt + if (!folderPath) { + return ( +
+
+
+ + 文件夹 +
+
+
+ + 从左侧导航选择一个文件夹 +
+
+ ); + } + + return ( +
+
+
+ + {folderName} + + {totalCount} 张照片 + +
+ setIncludeSubfolders(!!data.checked)} + /> +
+ + {loading && photos.length === 0 ? ( +
+ +
+ ) : photos.length === 0 ? ( +
+ + 此文件夹中没有照片 +
+ ) : ( + + )} + + {viewerPhoto && ( + setViewerOpen(false)} + /> + )} +
+ ); +} diff --git a/src/components/content/PhotosContent.tsx b/src/components/content/PhotosContent.tsx new file mode 100644 index 0000000..5fb4ce4 --- /dev/null +++ b/src/components/content/PhotosContent.tsx @@ -0,0 +1,236 @@ +/** + * Photos content view for AppShell (main page) + * + * Renders all photos within the shell content area using cursor-based pagination. + * Replaces the old dashboard-style HomePage (HeroSection, TagRibbon, ContentShelf). + * Reuses existing getPhotosCursor, searchPhotosCursor APIs. + */ + +import { useState, useCallback, useMemo, useEffect } from 'react'; +import type { MouseEvent } from 'react'; +import { + makeStyles, + tokens, + Spinner, + Text, +} from '@fluentui/react-components'; +import { Image24Regular } from '@fluentui/react-icons'; +import { getPhotosCursor, searchPhotosCursor } from '@/services/api'; +import ContentArea from './ContentArea'; +import { useSelectionStore } from '@/stores/selectionStore'; +import { useNavigationStore } from '@/stores/navigationStore'; +import { usePhotoStore } from '@/stores/photoStore'; +import { PhotoViewer } from '@/components/photo'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import type { Photo, PhotoCursor, SortField } from '@/types'; + +const PAGE_SIZE = 100; + +const useStyles = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + flex: 1, + minHeight: 0, + overflow: 'hidden', + }, + header: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '12px 16px', + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + flexShrink: 0, + }, + headerLeft: { + display: 'flex', + alignItems: 'center', + gap: '8px', + }, + empty: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + flex: 1, + gap: '8px', + color: tokens.colorNeutralForeground3, + }, +}); + +// Check Tauri runtime +const isTauriRuntime = (() => { + if (typeof window === 'undefined') return false; + const w = window as typeof window & { __TAURI__?: unknown; __TAURI_INTERNALS__?: unknown }; + return Boolean(w.__TAURI__ ?? w.__TAURI_INTERNALS__); +})(); + +interface PhotosContentProps { + onTotalCountChange?: (count: number) => void; +} + +export default function PhotosContent({ onTotalCountChange }: PhotosContentProps) { + const styles = useStyles(); + + const sortBy = useNavigationStore((s) => s.sortBy); + const sortOrder = useNavigationStore((s) => s.sortOrder); + const searchFilters = usePhotoStore((s) => s.searchFilters); + const totalCount = usePhotoStore((s) => s.totalCount); + const setTotalCount = usePhotoStore((s) => s.setTotalCount); + + // Selection + const selectedIds = useSelectionStore((s) => s.selectedIds); + const clearSelection = useSelectionStore((s) => s.clearSelection); + const select = useSelectionStore((s) => s.select); + const toggle = useSelectionStore((s) => s.toggle); + + // Viewer + const [viewerOpen, setViewerOpen] = useState(false); + const [viewerPhoto, setViewerPhoto] = useState(null); + + const sortOptions = useMemo( + () => ({ field: sortBy as SortField, order: sortOrder }), + [sortBy, sortOrder], + ); + + const hasActiveFilters = useMemo(() => { + return !!( + searchFilters.query?.trim() || + searchFilters.dateFrom || + searchFilters.dateTo || + (searchFilters.tagIds && searchFilters.tagIds.length > 0) || + searchFilters.minRating || + searchFilters.favoritesOnly || + (searchFilters.fileExtensions && searchFilters.fileExtensions.length > 0) + ); + }, [searchFilters]); + + const getCursorForPhoto = useCallback((photo: Photo, field: SortField): PhotoCursor => { + let sortValue: string | number | null = null; + switch (field) { + case 'dateTaken': sortValue = photo.dateTaken ?? null; break; + case 'dateAdded': sortValue = photo.dateAdded ?? null; break; + case 'fileName': sortValue = photo.fileName ?? null; break; + case 'fileSize': sortValue = photo.fileSize ?? null; break; + case 'rating': sortValue = photo.rating ?? null; break; + case 'relevance': sortValue = photo.relevanceScore ?? null; break; + default: sortValue = photo.dateTaken ?? null; break; + } + return { sortValue, photoId: photo.photoId }; + }, []); + + const queryKey = useMemo( + () => ['photoFeed', { filters: searchFilters, field: sortOptions.field, order: sortOptions.order }] as const, + [searchFilters, sortOptions.field, sortOptions.order], + ); + + const { + data, + isLoading, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInfiniteQuery({ + queryKey, + enabled: isTauriRuntime, + initialPageParam: null as PhotoCursor | null, + queryFn: async ({ pageParam }) => { + const cursor = pageParam ?? null; + const includeTotal = pageParam == null; + if (hasActiveFilters) { + return searchPhotosCursor(searchFilters, PAGE_SIZE, cursor, sortOptions, includeTotal); + } + return getPhotosCursor(PAGE_SIZE, cursor, sortOptions, includeTotal); + }, + getNextPageParam: (lastPage) => { + if (lastPage.items.length < PAGE_SIZE) return undefined; + const last = lastPage.items[lastPage.items.length - 1]; + return last ? getCursorForPhoto(last, sortOptions.field) : undefined; + }, + retry: 1, + }); + + const photos = useMemo(() => data?.pages.flatMap((p) => p.items) ?? [], [data]); + const loading = isLoading || isFetchingNextPage; + + useEffect(() => { + const total = data?.pages?.[0]?.total; + if (typeof total === 'number') setTotalCount(total); + }, [data, setTotalCount]); + + useEffect(() => { + onTotalCountChange?.(totalCount); + }, [onTotalCountChange, totalCount]); + + const handlePhotoClick = useCallback( + (photo: Photo, event: MouseEvent) => { + if (event.ctrlKey || event.metaKey) { + toggle(photo.photoId); + } else if (event.shiftKey) { + select(photo.photoId); + } else { + clearSelection(); + select(photo.photoId); + } + }, + [clearSelection, select, toggle], + ); + + const handlePhotoDoubleClick = useCallback((photo: Photo) => { + setViewerPhoto(photo); + setViewerOpen(true); + }, []); + + const handleLoadMore = useCallback(() => { + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + return ( +
+
+
+ + + {hasActiveFilters ? '搜索结果' : '所有照片'} + + + {totalCount} 张 + +
+
+ + {loading && photos.length === 0 ? ( +
+ +
+ ) : photos.length === 0 ? ( +
+ + 暂无照片 + 导入照片文件夹以开始使用 +
+ ) : ( + + )} + + {viewerPhoto && ( + setViewerOpen(false)} + /> + )} +
+ ); +} diff --git a/src/components/content/TagsContent.tsx b/src/components/content/TagsContent.tsx new file mode 100644 index 0000000..a857422 --- /dev/null +++ b/src/components/content/TagsContent.tsx @@ -0,0 +1,372 @@ +/** + * Tags content view for AppShell + * + * Renders tag list and tag-filtered photos within the shell content area. + * Reuses existing getAllTagsWithCount, getPhotosByTag APIs. + */ + +import { useState, useEffect, useCallback, useMemo } from 'react'; +import type { MouseEvent } from 'react'; +import { + makeStyles, + tokens, + Spinner, + Button, + Text, +} from '@fluentui/react-components'; +import { + Tag24Regular, + ArrowLeft24Regular, + Settings24Regular, +} from '@fluentui/react-icons'; +import { getAllTagsWithCount, getPhotosByTag } from '@/services/api'; +import { TagManager } from '@/components/tag'; +import ContentArea from './ContentArea'; +import { useSelectionStore } from '@/stores/selectionStore'; +import { useNavigationStore } from '@/stores/navigationStore'; +import { PhotoViewer } from '@/components/photo'; +import type { TagWithCount, Photo } from '@/types'; + +const PAGE_SIZE = 100; + +const useStyles = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + flex: 1, + minHeight: 0, + overflow: 'hidden', + }, + header: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '12px 16px', + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + flexShrink: 0, + }, + headerLeft: { + display: 'flex', + alignItems: 'center', + gap: '8px', + }, + content: { + flex: 1, + minHeight: 0, + overflow: 'auto', + padding: '16px', + }, + tagGrid: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', + gap: '12px', + }, + tagCard: { + display: 'flex', + alignItems: 'center', + gap: '10px', + padding: '12px', + borderRadius: '8px', + border: `1px solid ${tokens.colorNeutralStroke2}`, + backgroundColor: tokens.colorNeutralBackground1, + cursor: 'pointer', + transition: 'all 0.15s ease', + ':hover': { + backgroundColor: tokens.colorNeutralBackground1Hover, + borderTopColor: tokens.colorBrandStroke1, + borderRightColor: tokens.colorBrandStroke1, + borderBottomColor: tokens.colorBrandStroke1, + borderLeftColor: tokens.colorBrandStroke1, + boxShadow: tokens.shadow4, + }, + }, + tagColor: { + width: '32px', + height: '32px', + borderRadius: '8px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: 'white', + fontWeight: 'bold', + fontSize: '14px', + flexShrink: 0, + }, + tagInfo: { + flex: 1, + minWidth: 0, + }, + tagName: { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + empty: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + flex: 1, + gap: '8px', + color: tokens.colorNeutralForeground3, + }, +}); + +interface TagsContentProps { + onTotalCountChange?: (count: number) => void; +} + +export default function TagsContent({ onTotalCountChange }: TagsContentProps) { + const styles = useStyles(); + const [tags, setTags] = useState([]); + const [loading, setLoading] = useState(false); + const [managerOpen, setManagerOpen] = useState(false); + + // Tag detail state + const activeNode = useNavigationStore((s) => s.activeNode); + const setActiveNode = useNavigationStore((s) => s.setActiveNode); + const sortBy = useNavigationStore((s) => s.sortBy); + const sortOrder = useNavigationStore((s) => s.sortOrder); + + const selectedTagId = useMemo(() => { + const match = activeNode.match(/^tag:(\d+)$/); + return match ? Number(match[1]) : null; + }, [activeNode]); + + const selectedTag = useMemo( + () => tags.find((t) => t.tagId === selectedTagId) ?? null, + [tags, selectedTagId], + ); + + // Tag photos state + const [tagPhotos, setTagPhotos] = useState([]); + const [tagPhotosLoading, setTagPhotosLoading] = useState(false); + const [tagPhotoPage, setTagPhotoPage] = useState(1); + const [tagPhotosHasMore, setTagPhotosHasMore] = useState(false); + + // Selection + const selectedIds = useSelectionStore((s) => s.selectedIds); + const clearSelection = useSelectionStore((s) => s.clearSelection); + const select = useSelectionStore((s) => s.select); + const toggle = useSelectionStore((s) => s.toggle); + + // Viewer + const [viewerOpen, setViewerOpen] = useState(false); + const [viewerPhoto, setViewerPhoto] = useState(null); + + const loadTags = useCallback(async () => { + try { + setLoading(true); + const list = await getAllTagsWithCount(); + setTags(list); + } catch (err) { + console.error('Failed to load tags:', err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadTags(); + }, [loadTags]); + + useEffect(() => { + if (selectedTagId != null) { + onTotalCountChange?.(selectedTag?.photoCount ?? tagPhotos.length); + return; + } + + onTotalCountChange?.(tags.length); + }, [ + onTotalCountChange, + selectedTag?.photoCount, + selectedTagId, + tagPhotos.length, + tags.length, + ]); + + // Load tag photos when selectedTagId changes + useEffect(() => { + if (selectedTagId == null) { + setTagPhotos([]); + return; + } + let cancelled = false; + const load = async () => { + setTagPhotosLoading(true); + try { + const sort = { field: sortBy as 'dateTaken' | 'dateAdded' | 'fileName' | 'fileSize' | 'rating', order: sortOrder }; + const result = await getPhotosByTag(selectedTagId, { page: 1, pageSize: PAGE_SIZE }, sort); + if (!cancelled) { + setTagPhotos(result.items); + setTagPhotoPage(1); + setTagPhotosHasMore(1 < result.totalPages); + } + } catch (err) { + console.error('Failed to load tag photos:', err); + } finally { + if (!cancelled) setTagPhotosLoading(false); + } + }; + load(); + clearSelection(); + return () => { cancelled = true; }; + }, [selectedTagId, sortBy, sortOrder, clearSelection]); + + const handleLoadMore = useCallback(async () => { + if (tagPhotosLoading || !tagPhotosHasMore || selectedTagId == null) return; + setTagPhotosLoading(true); + try { + const nextPage = tagPhotoPage + 1; + const sort = { field: sortBy as 'dateTaken' | 'dateAdded' | 'fileName' | 'fileSize' | 'rating', order: sortOrder }; + const result = await getPhotosByTag(selectedTagId, { page: nextPage, pageSize: PAGE_SIZE }, sort); + setTagPhotos((prev) => [...prev, ...result.items]); + setTagPhotoPage(nextPage); + setTagPhotosHasMore(nextPage < result.totalPages); + } catch (err) { + console.error('Failed to load more tag photos:', err); + } finally { + setTagPhotosLoading(false); + } + }, [tagPhotosLoading, tagPhotosHasMore, selectedTagId, tagPhotoPage, sortBy, sortOrder]); + + const handleTagClick = useCallback( + (tag: TagWithCount) => { + setActiveNode(`tag:${tag.tagId}`); + }, + [setActiveNode], + ); + + const handleBack = useCallback(() => { + setActiveNode('tags'); + clearSelection(); + }, [setActiveNode, clearSelection]); + + const handlePhotoClick = useCallback( + (photo: Photo, event: MouseEvent) => { + if (event.ctrlKey || event.metaKey) { + toggle(photo.photoId); + } else { + clearSelection(); + select(photo.photoId); + } + }, + [clearSelection, select, toggle], + ); + + const handlePhotoDoubleClick = useCallback((photo: Photo) => { + setViewerPhoto(photo); + setViewerOpen(true); + }, []); + + // Tag detail view + if (selectedTagId != null) { + return ( +
+
+
+
+
+ + {viewerPhoto && ( + setViewerOpen(false)} + /> + )} +
+ ); + } + + // Tag list view + return ( +
+
+
+ + 标签 + + {tags.length} 个标签 + +
+ +
+
+ {loading ? ( +
+ +
+ ) : tags.length === 0 ? ( +
+ + 暂无标签 + 标签可以帮助您更好地组织照片 +
+ ) : ( +
+ {tags.map((tagItem) => ( +
handleTagClick(tagItem)} + > +
+ {tagItem.tagName.charAt(0).toUpperCase()} +
+
+ + {tagItem.tagName} + + + {tagItem.photoCount} 张照片 + +
+
+ ))} +
+ )} +
+ setManagerOpen(false)} + onTagsChange={loadTags} + /> +
+ ); +} diff --git a/src/components/content/TrashContent.tsx b/src/components/content/TrashContent.tsx new file mode 100644 index 0000000..5281262 --- /dev/null +++ b/src/components/content/TrashContent.tsx @@ -0,0 +1,363 @@ +/** + * Trash content view for AppShell + * + * Renders deleted photos within the shell content area. + * Preserves restore and permanent delete operations. + * Reuses existing getDeletedPhotos, restorePhotos, permanentDeletePhotos, emptyTrash APIs. + */ + +import { useState, useEffect, useCallback } from 'react'; +import type { MouseEvent } from 'react'; +import { + makeStyles, + tokens, + Spinner, + Text, + Button, + Dialog, + DialogSurface, + DialogBody, + DialogTitle, + DialogContent, + DialogActions, +} from '@fluentui/react-components'; +import { + Delete24Regular, + DeleteDismiss24Regular, + ArrowReset24Regular, +} from '@fluentui/react-icons'; +import { + getDeletedPhotos, + restorePhotos, + permanentDeletePhotos, + emptyTrash, +} from '@/services/api'; +import ContentArea from './ContentArea'; +import { useSelectionStore } from '@/stores/selectionStore'; +import { PhotoViewer } from '@/components/photo'; +import type { Photo, PaginatedResult } from '@/types'; + +const PAGE_SIZE = 100; + +const useStyles = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + flex: 1, + minHeight: 0, + overflow: 'hidden', + }, + header: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '12px 16px', + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + flexShrink: 0, + }, + headerLeft: { + display: 'flex', + alignItems: 'center', + gap: '8px', + }, + headerActions: { + display: 'flex', + alignItems: 'center', + gap: '8px', + }, + empty: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + flex: 1, + gap: '8px', + color: tokens.colorNeutralForeground3, + }, +}); + +interface TrashContentProps { + onTotalCountChange?: (count: number) => void; +} + +export default function TrashContent({ onTotalCountChange }: TrashContentProps) { + const styles = useStyles(); + const [photos, setPhotos] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [loading, setLoading] = useState(false); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(false); + + // Operation states + const [restoring, setRestoring] = useState(false); + const [deleting, setDeleting] = useState(false); + const [emptying, setEmptying] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [showEmptyDialog, setShowEmptyDialog] = useState(false); + + // Selection + const selectedIds = useSelectionStore((s) => s.selectedIds); + const clearSelection = useSelectionStore((s) => s.clearSelection); + const select = useSelectionStore((s) => s.select); + const toggle = useSelectionStore((s) => s.toggle); + + // Viewer + const [viewerOpen, setViewerOpen] = useState(false); + const [viewerPhoto, setViewerPhoto] = useState(null); + + const loadTrash = useCallback( + async (pageNum: number, reset: boolean) => { + setLoading(true); + try { + const result: PaginatedResult = await getDeletedPhotos({ + page: pageNum, + pageSize: PAGE_SIZE, + }); + if (reset) { + setPhotos(result.items); + } else { + setPhotos((prev) => [...prev, ...result.items]); + } + setPage(pageNum); + setTotalCount(result.total); + setHasMore(pageNum < result.totalPages); + } catch (err) { + console.error('Failed to load trash:', err); + } finally { + setLoading(false); + } + }, + [], + ); + + useEffect(() => { + loadTrash(1, true); + clearSelection(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + onTotalCountChange?.(totalCount); + }, [onTotalCountChange, totalCount]); + + const handleLoadMore = useCallback(() => { + if (!loading && hasMore) { + loadTrash(page + 1, false); + } + }, [loading, hasMore, page, loadTrash]); + + const handlePhotoClick = useCallback( + (photo: Photo, event: MouseEvent) => { + if (event.ctrlKey || event.metaKey) { + toggle(photo.photoId); + } else { + clearSelection(); + select(photo.photoId); + } + }, + [clearSelection, select, toggle], + ); + + const handlePhotoDoubleClick = useCallback((photo: Photo) => { + setViewerPhoto(photo); + setViewerOpen(true); + }, []); + + // Restore selected photos + const handleRestore = useCallback(async () => { + if (selectedIds.size === 0) return; + setRestoring(true); + try { + await restorePhotos(Array.from(selectedIds)); + setPhotos((prev) => prev.filter((p) => !selectedIds.has(p.photoId))); + setTotalCount((prev) => Math.max(0, prev - selectedIds.size)); + clearSelection(); + } catch (err) { + console.error('Failed to restore:', err); + } finally { + setRestoring(false); + } + }, [selectedIds, clearSelection]); + + // Permanent delete selected photos + const handlePermanentDelete = useCallback(async () => { + if (selectedIds.size === 0) return; + setDeleting(true); + try { + await permanentDeletePhotos(Array.from(selectedIds)); + setPhotos((prev) => prev.filter((p) => !selectedIds.has(p.photoId))); + setTotalCount((prev) => Math.max(0, prev - selectedIds.size)); + clearSelection(); + setShowDeleteDialog(false); + } catch (err) { + console.error('Failed to delete permanently:', err); + } finally { + setDeleting(false); + } + }, [selectedIds, clearSelection]); + + // Empty trash + const handleEmptyTrash = useCallback(async () => { + setEmptying(true); + try { + await emptyTrash(); + setPhotos([]); + setTotalCount(0); + clearSelection(); + setShowEmptyDialog(false); + } catch (err) { + console.error('Failed to empty trash:', err); + } finally { + setEmptying(false); + } + }, [clearSelection]); + + // Empty state + if (!loading && photos.length === 0) { + return ( +
+
+
+ + 最近删除 +
+
+
+ + 回收站为空 + 删除的项目将临时显示在这里,30 天后永久删除 +
+
+ ); + } + + return ( +
+
+
+ + 最近删除 + + {totalCount} 项 · 30 天后永久删除 + +
+
+ {selectedIds.size > 0 && ( + <> + + + + )} + {totalCount > 0 && ( + + )} +
+
+ + {loading && photos.length === 0 ? ( +
+ +
+ ) : ( + + )} + + {viewerPhoto && ( + setViewerOpen(false)} + /> + )} + + {/* Permanent delete dialog */} + setShowDeleteDialog(data.open)}> + + + 永久删除 + + 确定要永久删除这 {selectedIds.size} 张照片吗?此操作无法撤消。 + + + + + + + + + + {/* Empty trash dialog */} + setShowEmptyDialog(data.open)}> + + + 清空回收站 + + 确定要永久删除回收站中的全部 {totalCount} 张照片吗?此操作无法撤消。 + + + + + + + + +
+ ); +} diff --git a/src/components/content/ViewSwitcher.tsx b/src/components/content/ViewSwitcher.tsx new file mode 100644 index 0000000..18f6f53 --- /dev/null +++ b/src/components/content/ViewSwitcher.tsx @@ -0,0 +1,80 @@ +import { useCallback, useMemo } from 'react'; +import { + Menu, + MenuTrigger, + MenuPopover, + MenuList, + MenuItemRadio, + ToolbarButton, +} from '@fluentui/react-components'; +import type { MenuProps } from '@fluentui/react-components'; +import { + GridRegular, + GridFilled, + ListRegular, + TableRegular, + AppsFilled, +} from '@fluentui/react-icons'; +import { useNavigationStore } from '@/stores/navigationStore'; + +type ShellViewMode = 'large' | 'medium' | 'small' | 'list' | 'detail' | 'tile'; + +const VIEW_MODE_LABELS: Record = { + large: '大图标', + medium: '中图标', + small: '小图标', + list: '列表', + detail: '详细信息', + tile: '平铺', +}; + +export default function ViewSwitcher() { + const viewMode = useNavigationStore((s) => s.viewMode); + const setViewMode = useNavigationStore((s) => s.setViewMode); + + const checkedValues = useMemo( + () => ({ viewMode: [viewMode] }), + [viewMode], + ); + + const handleChange: MenuProps['onCheckedValueChange'] = useCallback( + (_e: unknown, data: { name: string; checkedItems: string[] }) => { + if (data.name === 'viewMode' && data.checkedItems.length > 0) { + setViewMode(data.checkedItems[0] as ShellViewMode); + } + }, + [setViewMode], + ); + + return ( + + + }> + {VIEW_MODE_LABELS[viewMode]} + + + + + }> + 大图标 + + }> + 中图标 + + }> + 小图标 + + }> + 列表 + + }> + 详细信息 + + }> + 平铺 + + + + + ); +} diff --git a/src/components/content/index.ts b/src/components/content/index.ts new file mode 100644 index 0000000..cce3f62 --- /dev/null +++ b/src/components/content/index.ts @@ -0,0 +1,11 @@ +export { default as ContentArea } from './ContentArea'; +export { default as ViewSwitcher } from './ViewSwitcher'; +export { default as FilterPanel } from './FilterPanel'; +export { isFilterActive, countActiveFilters } from './FilterPanel'; +export { default as PhotosContent } from './PhotosContent'; +export { default as AlbumsContent } from './AlbumsContent'; +export { default as TagsContent } from './TagsContent'; +export { default as FavoritesContent } from './FavoritesContent'; +export { default as TrashContent } from './TrashContent'; +export { default as FoldersContent } from './FoldersContent'; +export type { ContentViewProps } from './types'; diff --git a/src/components/content/types.ts b/src/components/content/types.ts new file mode 100644 index 0000000..ea59104 --- /dev/null +++ b/src/components/content/types.ts @@ -0,0 +1,14 @@ +import type { MouseEvent } from 'react'; +import type { Photo } from '@/types'; + +export interface ContentViewProps { + photos: Photo[]; + selectedIds?: Set; + loading?: boolean; + hasMore?: boolean; + onPhotoClick?: (photo: Photo, event: MouseEvent) => void; + onPhotoDoubleClick?: (photo: Photo) => void; + onPhotoContextMenu?: (photo: Photo, event: MouseEvent) => void; + onPhotoSelect?: (photo: Photo, selected: boolean) => void; + onLoadMore?: () => void; +} diff --git a/src/components/content/views/DetailView.tsx b/src/components/content/views/DetailView.tsx new file mode 100644 index 0000000..2bfef61 --- /dev/null +++ b/src/components/content/views/DetailView.tsx @@ -0,0 +1,319 @@ +import { memo, useCallback, useMemo, useState, useEffect } from 'react'; +import type { MouseEvent } from 'react'; +import { Virtuoso } from 'react-virtuoso'; +import { makeStyles, tokens } from '@fluentui/react-components'; +import { useNavigationStore } from '@/stores/navigationStore'; +import type { ContentViewProps } from '../types'; +import type { Photo } from '@/types'; + +const MIN_COL_WIDTH = 60; + +interface ColumnDef { + key: string; + label: string; + defaultWidth: number; + render: (photo: Photo) => string; +} + +const ALL_COLUMNS: ColumnDef[] = [ + { + key: 'fileName', + label: '名称', + defaultWidth: 240, + render: (p) => p.fileName, + }, + { + key: 'dateTaken', + label: '拍摄日期', + defaultWidth: 160, + render: (p) => + p.dateTaken + ? new Date(p.dateTaken).toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }) + : '-', + }, + { + key: 'fileSize', + label: '大小', + defaultWidth: 100, + render: (p) => + p.fileSize < 1024 * 1024 + ? `${(p.fileSize / 1024).toFixed(0)} KB` + : `${(p.fileSize / 1024 / 1024).toFixed(1)} MB`, + }, + { + key: 'resolution', + label: '分辨率', + defaultWidth: 120, + render: (p) => + p.width && p.height ? `${p.width} x ${p.height}` : '-', + }, + { + key: 'tags', + label: '标签', + defaultWidth: 140, + render: () => '-', + }, +]; + +const useStyles = makeStyles({ + root: { + height: '100%', + width: '100%', + display: 'flex', + flexDirection: 'column', + }, + header: { + display: 'flex', + alignItems: 'center', + height: '32px', + flexShrink: 0, + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + backgroundColor: tokens.colorNeutralBackground2, + userSelect: 'none', + }, + headerCell: { + position: 'relative', + display: 'flex', + alignItems: 'center', + height: '100%', + paddingLeft: '8px', + paddingRight: '8px', + fontSize: '12px', + fontWeight: 600, + color: tokens.colorNeutralForeground2, + cursor: 'pointer', + overflow: 'hidden', + whiteSpace: 'nowrap', + '&:hover': { + backgroundColor: tokens.colorNeutralBackground2Hover, + }, + }, + resizeHandle: { + position: 'absolute', + right: 0, + top: '4px', + bottom: '4px', + width: '4px', + cursor: 'col-resize', + backgroundColor: 'transparent', + '&:hover': { + backgroundColor: tokens.colorNeutralStroke1, + }, + }, + body: { + flex: 1, + minHeight: 0, + }, + row: { + display: 'flex', + alignItems: 'center', + height: '28px', + cursor: 'pointer', + '&:hover': { + backgroundColor: tokens.colorNeutralBackground1Hover, + }, + }, + rowSelected: { + backgroundColor: tokens.colorNeutralBackground1Selected, + '&:hover': { + backgroundColor: tokens.colorNeutralBackground1Selected, + }, + }, + cell: { + paddingLeft: '8px', + paddingRight: '8px', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + fontSize: '12px', + color: tokens.colorNeutralForeground1, + }, +}); + +const DetailRow = memo(function DetailRow({ + photo, + selected, + columns, + columnWidths, + onClick, + onDoubleClick, + onContextMenu, +}: { + photo: Photo; + selected: boolean; + columns: ColumnDef[]; + columnWidths: Record; + onClick?: (photo: Photo, event: MouseEvent) => void; + onDoubleClick?: (photo: Photo) => void; + onContextMenu?: (photo: Photo, event: MouseEvent) => void; +}) { + const styles = useStyles(); + + const handleClick = useCallback( + (e: MouseEvent) => onClick?.(photo, e), + [photo, onClick], + ); + const handleDblClick = useCallback( + () => onDoubleClick?.(photo), + [photo, onDoubleClick], + ); + const handleCtx = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + onContextMenu?.(photo, e); + }, + [photo, onContextMenu], + ); + + return ( +
+ {columns.map((col) => ( +
+ {col.render(photo)} +
+ ))} +
+ ); +}); + +export default function DetailView({ + photos, + selectedIds = new Set(), + loading, + hasMore, + onPhotoClick, + onPhotoDoubleClick, + onPhotoContextMenu, + onLoadMore, +}: ContentViewProps) { + const styles = useStyles(); + const detailColumns = useNavigationStore((s) => s.detailColumns); + const detailColumnWidths = useNavigationStore((s) => s.detailColumnWidths); + const setDetailColumnWidths = useNavigationStore((s) => s.setDetailColumnWidths); + const sortBy = useNavigationStore((s) => s.sortBy); + const sortOrder = useNavigationStore((s) => s.sortOrder); + const setSortBy = useNavigationStore((s) => s.setSortBy); + const setSortOrder = useNavigationStore((s) => s.setSortOrder); + + const [localWidths, setLocalWidths] = useState>(detailColumnWidths); + + useEffect(() => { + setLocalWidths(detailColumnWidths); + }, [detailColumnWidths]); + + const visibleColumns = useMemo(() => { + return detailColumns + .map((key) => ALL_COLUMNS.find((c) => c.key === key)) + .filter((c): c is ColumnDef => c != null); + }, [detailColumns]); + + const handleHeaderClick = useCallback( + (colKey: string) => { + const sortField = colKey as typeof sortBy; + if (['dateTaken', 'dateAdded', 'fileName', 'fileSize', 'rating'].includes(colKey)) { + if (sortBy === sortField) { + setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); + } else { + setSortBy(sortField); + } + } + }, + [sortBy, sortOrder, setSortBy, setSortOrder], + ); + + const handleResizeStart = useCallback( + (colKey: string, startX: number) => { + const startWidth = localWidths[colKey] ?? ALL_COLUMNS.find((c) => c.key === colKey)?.defaultWidth ?? 100; + + const onMouseMove = (e: globalThis.MouseEvent) => { + const delta = e.clientX - startX; + const newWidth = Math.max(MIN_COL_WIDTH, startWidth + delta); + setLocalWidths((prev) => ({ ...prev, [colKey]: newWidth })); + }; + + const onMouseUp = () => { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + setLocalWidths((prev) => { + setDetailColumnWidths(prev); + return prev; + }); + }; + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }, + [localWidths, setDetailColumnWidths], + ); + + const handleEndReached = useCallback(() => { + if (!loading && hasMore && onLoadMore) onLoadMore(); + }, [loading, hasMore, onLoadMore]); + + const itemContent = useCallback( + (index: number) => { + const photo = photos[index]; + if (!photo) return null; + return ( + + ); + }, + [photos, selectedIds, visibleColumns, localWidths, onPhotoClick, onPhotoDoubleClick, onPhotoContextMenu], + ); + + return ( +
+
+ {visibleColumns.map((col) => ( +
handleHeaderClick(col.key)} + > + {col.label} + {sortBy === col.key && (sortOrder === 'asc' ? ' \u2191' : ' \u2193')} +
{ + e.stopPropagation(); + handleResizeStart(col.key, e.clientX); + }} + /> +
+ ))} +
+
+ +
+
+ ); +} diff --git a/src/components/content/views/LargeIconView.tsx b/src/components/content/views/LargeIconView.tsx new file mode 100644 index 0000000..28fd0cc --- /dev/null +++ b/src/components/content/views/LargeIconView.tsx @@ -0,0 +1,30 @@ +import PhotoGrid from '@/components/photo/PhotoGrid'; +import type { ContentViewProps } from '../types'; + +export default function LargeIconView({ + photos, + selectedIds, + loading, + hasMore, + onPhotoClick, + onPhotoDoubleClick, + onPhotoContextMenu, + onPhotoSelect, + onLoadMore, +}: ContentViewProps) { + return ( + + ); +} diff --git a/src/components/content/views/ListView.tsx b/src/components/content/views/ListView.tsx new file mode 100644 index 0000000..9d2973e --- /dev/null +++ b/src/components/content/views/ListView.tsx @@ -0,0 +1,142 @@ +import { memo, useCallback, useRef } from 'react'; +import type { MouseEvent } from 'react'; +import { Virtuoso } from 'react-virtuoso'; +import { makeStyles, tokens } from '@fluentui/react-components'; +import { ImageRegular } from '@fluentui/react-icons'; +import type { ContentViewProps } from '../types'; +import type { Photo } from '@/types'; + +const useStyles = makeStyles({ + root: { + height: '100%', + width: '100%', + }, + row: { + display: 'flex', + alignItems: 'center', + height: '32px', + paddingLeft: '12px', + paddingRight: '12px', + cursor: 'pointer', + gap: '8px', + userSelect: 'none', + '&:hover': { + backgroundColor: tokens.colorNeutralBackground1Hover, + }, + }, + rowSelected: { + backgroundColor: tokens.colorNeutralBackground1Selected, + '&:hover': { + backgroundColor: tokens.colorNeutralBackground1Selected, + }, + }, + icon: { + flexShrink: 0, + color: tokens.colorNeutralForeground3, + display: 'flex', + alignItems: 'center', + }, + fileName: { + flex: 1, + minWidth: 0, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + fontSize: '13px', + color: tokens.colorNeutralForeground1, + }, +}); + +const ListRow = memo(function ListRow({ + photo, + selected, + onClick, + onDoubleClick, + onContextMenu, +}: { + photo: Photo; + selected: boolean; + onClick?: (photo: Photo, event: MouseEvent) => void; + onDoubleClick?: (photo: Photo) => void; + onContextMenu?: (photo: Photo, event: MouseEvent) => void; +}) { + const styles = useStyles(); + + const handleClick = useCallback( + (e: MouseEvent) => onClick?.(photo, e), + [photo, onClick], + ); + const handleDblClick = useCallback( + () => onDoubleClick?.(photo), + [photo, onDoubleClick], + ); + const handleCtx = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + onContextMenu?.(photo, e); + }, + [photo, onContextMenu], + ); + + return ( +
+ + + + {photo.fileName} +
+ ); +}); + +export default function ListView({ + photos, + selectedIds = new Set(), + loading, + hasMore, + onPhotoClick, + onPhotoDoubleClick, + onPhotoContextMenu, + onLoadMore, +}: ContentViewProps) { + const styles = useStyles(); + const containerRef = useRef(null); + + const handleEndReached = useCallback(() => { + if (!loading && hasMore && onLoadMore) onLoadMore(); + }, [loading, hasMore, onLoadMore]); + + const itemContent = useCallback( + (index: number) => { + const photo = photos[index]; + if (!photo) return null; + return ( + + ); + }, + [photos, selectedIds, onPhotoClick, onPhotoDoubleClick, onPhotoContextMenu], + ); + + return ( +
+ +
+ ); +} diff --git a/src/components/content/views/MediumIconView.tsx b/src/components/content/views/MediumIconView.tsx new file mode 100644 index 0000000..eeea645 --- /dev/null +++ b/src/components/content/views/MediumIconView.tsx @@ -0,0 +1,30 @@ +import PhotoGrid from '@/components/photo/PhotoGrid'; +import type { ContentViewProps } from '../types'; + +export default function MediumIconView({ + photos, + selectedIds, + loading, + hasMore, + onPhotoClick, + onPhotoDoubleClick, + onPhotoContextMenu, + onPhotoSelect, + onLoadMore, +}: ContentViewProps) { + return ( + + ); +} diff --git a/src/components/content/views/SmallIconView.tsx b/src/components/content/views/SmallIconView.tsx new file mode 100644 index 0000000..223e924 --- /dev/null +++ b/src/components/content/views/SmallIconView.tsx @@ -0,0 +1,30 @@ +import PhotoGrid from '@/components/photo/PhotoGrid'; +import type { ContentViewProps } from '../types'; + +export default function SmallIconView({ + photos, + selectedIds, + loading, + hasMore, + onPhotoClick, + onPhotoDoubleClick, + onPhotoContextMenu, + onPhotoSelect, + onLoadMore, +}: ContentViewProps) { + return ( + + ); +} diff --git a/src/components/content/views/TileView.tsx b/src/components/content/views/TileView.tsx new file mode 100644 index 0000000..3203ea4 --- /dev/null +++ b/src/components/content/views/TileView.tsx @@ -0,0 +1,209 @@ +import { memo, useCallback, useMemo } from 'react'; +import type { MouseEvent } from 'react'; +import { VirtuosoGrid } from 'react-virtuoso'; +import { makeStyles, tokens } from '@fluentui/react-components'; +import { ImageRegular } from '@fluentui/react-icons'; +import type { ContentViewProps } from '../types'; +import type { Photo } from '@/types'; + +const CARD_WIDTH = 220; +const THUMB_HEIGHT = 120; + +const useStyles = makeStyles({ + root: { + height: '100%', + width: '100%', + }, + listContainer: { + display: 'flex', + flexWrap: 'wrap', + gap: '8px', + padding: '8px 12px', + }, + card: { + width: `${CARD_WIDTH}px`, + borderRadius: '6px', + border: `1px solid ${tokens.colorNeutralStroke2}`, + backgroundColor: tokens.colorNeutralBackground1, + overflow: 'hidden', + cursor: 'pointer', + userSelect: 'none', + '&:hover': { + backgroundColor: tokens.colorNeutralBackground1Hover, + boxShadow: tokens.shadow4, + }, + }, + cardSelected: { + border: `2px solid ${tokens.colorBrandStroke1}`, + }, + thumbArea: { + width: '100%', + height: `${THUMB_HEIGHT}px`, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: tokens.colorNeutralBackground3, + overflow: 'hidden', + }, + thumbIcon: { + color: tokens.colorNeutralForeground3, + }, + info: { + padding: '6px 8px', + }, + fileName: { + fontSize: '12px', + fontWeight: 600, + color: tokens.colorNeutralForeground1, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + meta: { + fontSize: '11px', + color: tokens.colorNeutralForeground3, + marginTop: '2px', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, +}); + +function formatSize(bytes: number): string { + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + +function formatExt(fileName: string): string { + const dot = fileName.lastIndexOf('.'); + if (dot < 0) return ''; + return fileName.slice(dot + 1).toUpperCase(); +} + +const TileCard = memo(function TileCard({ + photo, + selected, + onClick, + onDoubleClick, + onContextMenu, +}: { + photo: Photo; + selected: boolean; + onClick?: (photo: Photo, event: MouseEvent) => void; + onDoubleClick?: (photo: Photo) => void; + onContextMenu?: (photo: Photo, event: MouseEvent) => void; +}) { + const styles = useStyles(); + + const handleClick = useCallback( + (e: MouseEvent) => onClick?.(photo, e), + [photo, onClick], + ); + const handleDblClick = useCallback( + () => onDoubleClick?.(photo), + [photo, onDoubleClick], + ); + const handleCtx = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + onContextMenu?.(photo, e); + }, + [photo, onContextMenu], + ); + + return ( +
+
+ +
+
+
{photo.fileName}
+
+ {formatExt(photo.fileName)} · {formatSize(photo.fileSize)} +
+
+
+ ); +}); + +export default function TileView({ + photos, + selectedIds = new Set(), + loading, + hasMore, + onPhotoClick, + onPhotoDoubleClick, + onPhotoContextMenu, + onLoadMore, +}: ContentViewProps) { + const styles = useStyles(); + + const handleEndReached = useCallback(() => { + if (!loading && hasMore && onLoadMore) onLoadMore(); + }, [loading, hasMore, onLoadMore]); + + const itemContent = useCallback( + (index: number) => { + const photo = photos[index]; + if (!photo) return null; + return ( + + ); + }, + [photos, selectedIds, onPhotoClick, onPhotoDoubleClick, onPhotoContextMenu], + ); + + const listContainerStyle = useMemo( + () => ({ + display: 'flex', + flexWrap: 'wrap' as const, + gap: '8px', + padding: '8px 12px', + }), + [], + ); + + const itemContainerStyle = useMemo( + () => ({ + width: `${CARD_WIDTH}px`, + }), + [], + ); + + return ( +
+ ( +
+ {children} +
+ ), + Item: ({ style, children, ...props }) => ( +
+ {children} +
+ ), + }} + /> +
+ ); +} diff --git a/src/components/dashboard/ContentShelf.tsx b/src/components/dashboard/ContentShelf.tsx deleted file mode 100644 index 6768a82..0000000 --- a/src/components/dashboard/ContentShelf.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import { useRef, useEffect, useState, useCallback } from 'react'; -import { convertFileSrc } from '@tauri-apps/api/core'; -import type { Photo } from '@/types'; -import { Icon, IconName } from '@/components/common/Icon'; - -interface ContentShelfProps { - title: string; - icon?: string; // Kept as string for compatibility, but will interpret as IconName - photos?: Photo[]; - loading?: boolean; - onPhotoClick?: (photo: Photo) => void; -} - -export default function ContentShelf({ - title, - icon = 'grid_view', - photos = [], - loading = false, - onPhotoClick -}: ContentShelfProps) { - const scrollRef = useRef(null); - const [canScrollLeft, setCanScrollLeft] = useState(false); - const [canScrollRight, setCanScrollRight] = useState(false); - - const checkScroll = useCallback(() => { - const el = scrollRef.current; - if (!el) return; - setCanScrollLeft(el.scrollLeft > 0); - setCanScrollRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 1); - }, []); - - useEffect(() => { - checkScroll(); - const el = scrollRef.current; - if (!el) return; - el.addEventListener('scroll', checkScroll); - window.addEventListener('resize', checkScroll); - return () => { - el.removeEventListener('scroll', checkScroll); - window.removeEventListener('resize', checkScroll); - }; - }, [checkScroll, photos]); - - const scroll = (direction: 'left' | 'right') => { - const el = scrollRef.current; - if (!el) return; - const scrollAmount = el.clientWidth * 0.8; - el.scrollBy({ - left: direction === 'left' ? -scrollAmount : scrollAmount, - behavior: 'smooth' - }); - }; - - // 加载状态 - if (loading) { - return ( -
-
-

- - {title} -

-
-
-
- 加载中... -
-
- ); - } - - // 空状态 - if (photos.length === 0) { - return ( -
-
-

- - {title} -

-
-
-
-
- -
-

暂无照片

-

扫描文件夹开始使用

-
-
-
- ); - } - - // 有照片时显示横向滚动列表 - return ( -
-
-

- - {title} -

- {photos.length} 张 -
- -
- {/* 左滚动按钮 */} - {canScrollLeft && ( - - )} - - {/* 右滚动按钮 */} - {canScrollRight && ( - - )} - - {/* 照片滚动容器 */} -
- {photos.map((photo) => ( -
onPhotoClick?.(photo)} - className="flex-shrink-0 w-40 h-40 rounded-lg overflow-hidden bg-surface border border-border shadow-sm hover:shadow-md transition-all cursor-pointer group/item" - > - {photo.fileName} -
- ))} -
-
-
- ); -} diff --git a/src/components/dashboard/HeroSection.tsx b/src/components/dashboard/HeroSection.tsx deleted file mode 100644 index 0795f61..0000000 --- a/src/components/dashboard/HeroSection.tsx +++ /dev/null @@ -1,252 +0,0 @@ -import { useNavigate } from 'react-router-dom'; -import { useQuery } from '@tanstack/react-query'; -import { getFavoritePhotos, getAssetUrl, getRecentlyEditedPhoto } from '@/services/api'; -import { useMemo } from 'react'; -import type { Photo } from '@/types'; - -import { Icon } from '@/components/common/Icon'; - -interface HeroSectionProps { - onPhotoClick?: (photo: Photo) => void; -} - -/** 格式化相对时间 */ -function formatRelativeTime(dateStr: string): string { - const date = new Date(dateStr); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMs / 3600000); - const diffDays = Math.floor(diffMs / 86400000); - - if (diffMins < 1) return '刚刚'; - if (diffMins < 60) return `${diffMins} 分钟前`; - if (diffHours < 24) return `${diffHours} 小时前`; - if (diffDays < 30) return `${diffDays} 天前`; - return date.toLocaleDateString('zh-CN'); -} - -/** 格式化文件大小 */ -function formatFileSize(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} - -/** 获取文件格式 */ -function getFileFormat(fileName: string): string { - return fileName.split('.').pop()?.toUpperCase() || ''; -} - -export default function HeroSection({ onPhotoClick }: HeroSectionProps) { - const navigate = useNavigate(); - - // 获取收藏照片 - const { data: favoritesData, isLoading } = useQuery({ - queryKey: ['favoritePhotos', 'hero'], - queryFn: () => getFavoritePhotos({ page: 1, pageSize: 10 }), - staleTime: 30000, - }); - - // 获取最近编辑的照片 - const { data: recentPhoto } = useQuery({ - queryKey: ['recentlyEditedPhoto'], - queryFn: getRecentlyEditedPhoto, - staleTime: 30000, - }); - - // 随机选择一张展示 - const featuredPhoto: Photo | null = useMemo(() => { - const photos = favoritesData?.items ?? []; - if (photos.length === 0) return null; - // 使用当天日期作为种子,保证同一天展示同一张 - const today = new Date().toDateString(); - const seed = today.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); - const index = seed % photos.length; - return photos[index]; - }, [favoritesData]); - - const hasFavorites = (favoritesData?.items?.length ?? 0) > 0; - - return ( -
- {/* 左侧:精选大卡片 */} -
hasFavorites && navigate('/favorites')} - className={`col-span-8 card rounded-3xl relative overflow-hidden flex flex-col border-none shadow-2xl ${hasFavorites ? 'cursor-pointer group' : ''}`} - > - {isLoading ? ( -
-
-
- ) : featuredPhoto ? ( - <> - {featuredPhoto.fileName} -
-
-
-
- -
- 每日精选 -
-

- {featuredPhoto.fileName} -

-

- {favoritesData?.total ?? 0} 张收藏照片 -

- {/* 照片信息:优先显示拍摄参数,否则显示基本信息 */} -
-
-
- {featuredPhoto.aperture || featuredPhoto.shutterSpeed || featuredPhoto.iso ? ( - <> - {featuredPhoto.aperture && ( -
- 光圈 - f/{featuredPhoto.aperture} -
- )} - {featuredPhoto.shutterSpeed && ( -
- 快门 - {featuredPhoto.shutterSpeed} -
- )} - {featuredPhoto.iso && ( -
- ISO - {featuredPhoto.iso} -
- )} - - ) : ( - <> - {featuredPhoto.width && featuredPhoto.height && ( -
- 分辨率 - {featuredPhoto.width} × {featuredPhoto.height} -
- )} -
- 大小 - {formatFileSize(featuredPhoto.fileSize)} -
-
- 格式 - {getFileFormat(featuredPhoto.fileName)} -
- - )} -
-
-
- - ) : ( - <> -
-
-
- -
-

每日精选

-

- 收藏您喜欢的照片后,这里将展示您的精选作品,为您开启美好的一天。 -

-
- - )} -
- - {/* 右侧:最近编辑的照片 */} -
recentPhoto && onPhotoClick?.(recentPhoto)} - className="col-span-4 card rounded-3xl relative overflow-hidden group cursor-pointer flex flex-col border border-border bg-surface transition-all duration-300 hover:border-primary/50 hover:shadow-xl hover:-translate-y-1" - > - {recentPhoto ? ( - <> - {recentPhoto.fileName} -
-
-
-
- -
- 最近编辑 -
-

- {recentPhoto.fileName} -

-

- {recentPhoto.dateModified ? formatRelativeTime(recentPhoto.dateModified) : ''} -

- {/* 照片信息:优先显示拍摄参数,否则显示基本信息 */} -
-
-
- {recentPhoto.aperture || recentPhoto.shutterSpeed || recentPhoto.iso ? ( - <> - {recentPhoto.aperture && ( -
- 光圈 - f/{recentPhoto.aperture} -
- )} - {recentPhoto.shutterSpeed && ( -
- 快门 - {recentPhoto.shutterSpeed} -
- )} - {recentPhoto.iso && ( -
- ISO - {recentPhoto.iso} -
- )} - - ) : ( - <> - {recentPhoto.width && recentPhoto.height && ( -
- 分辨率 - {recentPhoto.width} × {recentPhoto.height} -
- )} -
- 大小 - {formatFileSize(recentPhoto.fileSize)} -
- - )} -
-
-
- - ) : ( - <> -
-
-
- -
-

最近编辑

-

- 收藏或评分照片后,这里将显示您最近编辑的照片 -

-
- - )} -
-
- ); -} diff --git a/src/components/dashboard/TagRibbon.tsx b/src/components/dashboard/TagRibbon.tsx deleted file mode 100644 index 43e170c..0000000 --- a/src/components/dashboard/TagRibbon.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import clsx from 'clsx'; -import { useQuery } from '@tanstack/react-query'; -import { getAllTagsWithCount } from '@/services/api'; -import { usePhotoStore } from '@/stores/photoStore'; -import { Icon, IconName } from '@/components/common/Icon'; - -// RAW 格式扩展名 -const RAW_EXTENSIONS = ['cr2', 'nef', 'arw', 'dng', 'raw', 'orf', 'rw2', 'raf', 'srw', 'pef']; - -// 特殊筛选项 -const SPECIAL_FILTERS: { id: string; label: string; icon: import('@/components/common/Icon').IconName | null }[] = [ - { id: 'all', label: '全部', icon: 'grid_view' }, - { id: 'fav', label: '收藏', icon: 'favorite' }, - { id: '2025', label: '2025年', icon: 'calendar' }, - { id: 'raw', label: 'RAW', icon: 'camera' }, -]; - -export default function TagRibbon() { - const { data: tagsWithCount } = useQuery({ - queryKey: ['tagsWithCount'], - queryFn: getAllTagsWithCount, - }); - - const searchFilters = usePhotoStore(state => state.searchFilters); - const setSearchFilters = usePhotoStore(state => state.setSearchFilters); - const clearSearchFilters = usePhotoStore(state => state.clearSearchFilters); - - // 判断当前激活的筛选项 - const getActiveFilter = (): string => { - const { favoritesOnly, dateFrom, dateTo, tagIds, fileExtensions } = searchFilters; - - // 检查是否有任何筛选条件 - const hasFilters = favoritesOnly || dateFrom || dateTo || - (tagIds && tagIds.length > 0) || (fileExtensions && fileExtensions.length > 0); - - if (!hasFilters) return 'all'; - if (favoritesOnly) return 'fav'; - if (dateFrom === '2025-01-01' && dateTo === '2025-12-31') return '2025'; - if (fileExtensions && fileExtensions.length > 0) return 'raw'; - if (tagIds && tagIds.length === 1) return `tag-${tagIds[0]}`; - return ''; - }; - - const activeFilter = getActiveFilter(); - - const handleFilterClick = (filterId: string, tagId?: number) => { - if (filterId === 'all') { - clearSearchFilters(); - } else if (filterId === 'fav') { - setSearchFilters({ favoritesOnly: true }); - } else if (filterId === '2025') { - setSearchFilters({ dateFrom: '2025-01-01', dateTo: '2025-12-31' }); - } else if (filterId === 'raw') { - setSearchFilters({ fileExtensions: RAW_EXTENSIONS }); - } else if (tagId !== undefined) { - const tag = tagsWithCount?.find(t => t.tagId === tagId); - setSearchFilters({ - tagIds: [tagId], - tagNames: tag ? [tag.tagName] : undefined - }); - } - }; - - const renderButton = (id: string, label: string, icon: IconName | null | string, isActive: boolean, onClick: () => void) => ( - - ); - - return ( -
-

快速筛选

-
- {/* 特殊筛选项 */} - {SPECIAL_FILTERS.map((filter) => - renderButton( - filter.id, - filter.label, - filter.icon, - activeFilter === filter.id, - () => handleFilterClick(filter.id) - ) - )} - - {/* 动态标签 */} - {tagsWithCount?.map((item) => - renderButton( - `tag-${item.tagId}`, - item.tagName, - '', - activeFilter === `tag-${item.tagId}`, - () => handleFilterClick(`tag-${item.tagId}`, item.tagId) - ) - )} -
-
- ); -} diff --git a/src/components/layout/AppHeader.tsx b/src/components/layout/AppHeader.tsx deleted file mode 100644 index 0d6db32..0000000 --- a/src/components/layout/AppHeader.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { useRef, useState, useEffect } from 'react'; -import { NavLink, useLocation } from 'react-router-dom'; -import WindowControls from './WindowControls'; -import { Icon, IconName } from '@/components/common/Icon'; -import { SearchPanel } from '@/components/search'; -import clsx from 'clsx'; - -export default function AppHeader() { - const [pillStyle, setPillStyle] = useState({ left: 0, width: 0, opacity: 0 }); - const [showSearch, setShowSearch] = useState(false); - const navRef = useRef(null); - const itemsRef = useRef<(HTMLAnchorElement | null)[]>([]); - const location = useLocation(); - - const navItems: { to: string; label: string; icon: IconName; end?: boolean }[] = [ - { to: '/', label: '照片', icon: 'grid_view', end: true }, - { to: '/folders', label: '文件夹', icon: 'folder' }, - { to: '/trash', label: '废纸篓', icon: 'delete' }, - { to: '/settings', label: '设置', icon: 'settings' }, - ]; - - const preloadByPath: Partial Promise>> = { - '/': () => import('@/pages/HomePage'), - '/folders': () => import('@/pages/FoldersPage'), - '/trash': () => import('@/pages/TrashPage'), - '/settings': () => import('@/pages/SettingsPage'), - }; - - useEffect(() => { - requestAnimationFrame(() => { - const activeIndex = navItems.findIndex(item => - item.end ? location.pathname === item.to : location.pathname.startsWith(item.to) - ); - - if (activeIndex !== -1 && itemsRef.current[activeIndex] && navRef.current) { - const activeEl = itemsRef.current[activeIndex]; - const navRect = navRef.current.getBoundingClientRect(); - const itemRect = activeEl.getBoundingClientRect(); - - setPillStyle({ - left: itemRect.left - navRect.left, - width: itemRect.width, - opacity: 1 - }); - } - }); - }, [location.pathname]); - - return ( -
- {/* 透明拖拽层 - 覆盖整个 Header 但避开按钮 */} -
- - {/* 左侧 Logo */} -
-
- -
- PhotoWall -
- - {/* 中间导航 - Claude/Cursor 风格胶囊 */} - - - {/* 右侧工具 & 窗口控制 */} -
- -
- - -
- - {/* 搜索面板 */} - setShowSearch(false)} /> -
- ); -} diff --git a/src/components/layout/BlurredBackground.tsx b/src/components/layout/BlurredBackground.tsx deleted file mode 100644 index a9b9430..0000000 --- a/src/components/layout/BlurredBackground.tsx +++ /dev/null @@ -1,286 +0,0 @@ -/** - * 自定义桌面模糊背景组件 - * - * 实现可调模糊半径的桌面背景模糊效果 - * 支持两种模式: - * 1. Composition Backdrop (Windows 11+): GPU 实时模糊 - * 2. 抓屏模糊 (Fallback): CPU 模糊 + base64 背景 - */ - -import { useEffect, useRef, useState, useCallback } from 'react'; -import { isTauri } from '@tauri-apps/api/core'; -import { getCurrentWindow } from '@tauri-apps/api/window'; -import { - getBlurredDesktop, - setExcludeFromCapture, - clearBlurCache, - isCompositionBlurSupported, - enableCompositionBlur, - disableCompositionBlur, - setCompositionBlurRadius, -} from '@/services/api'; -import { useSettingsStore } from '@/stores/settingsStore'; -import { useShallow } from 'zustand/shallow'; - -interface BlurredBackgroundProps { - /** 是否启用 */ - enabled?: boolean; -} - -/** - * 根据模糊半径计算抓屏缩放因子 - */ -function getDesktopCaptureScaleFactor(blurRadius: number) { - const clamped = Math.max(0, Math.min(100, blurRadius)); - const max = 0.55; - const min = 0.25; - const t = Math.max(0, Math.min(1, clamped / 40)); - return max + (min - max) * t; -} - -function BlurredBackground({ enabled = true }: BlurredBackgroundProps) { - const [backgroundImage, setBackgroundImage] = useState(null); - const isUpdatingRef = useRef(false); - const pendingFinalRef = useRef(false); - const updateTimeoutRef = useRef | null>(null); - const lastUpdateRef = useRef(0); - const compositionInitializedRef = useRef(false); - const tauri = isTauri(); - - const { - blurRadius, - customBlurEnabled, - compositionBlurSupported, - compositionBlurEnabled, - highRefreshUi, - setCompositionBlurSupported, - setCompositionBlurEnabled, - } = useSettingsStore( - useShallow((state) => ({ - blurRadius: state.blurRadius, - customBlurEnabled: state.customBlurEnabled, - compositionBlurSupported: state.compositionBlurSupported, - compositionBlurEnabled: state.compositionBlurEnabled, - highRefreshUi: state.highRefreshUi, - setCompositionBlurSupported: state.setCompositionBlurSupported, - setCompositionBlurEnabled: state.setCompositionBlurEnabled, - })) - ); - - // 检测 Composition Blur 支持 - useEffect(() => { - if (!tauri) return; - - const checkSupport = async () => { - try { - const supported = await isCompositionBlurSupported(); - setCompositionBlurSupported(supported); - console.debug('[BlurredBackground] Composition blur supported:', supported); - } catch (err) { - console.debug('[BlurredBackground] Failed to check composition blur support:', err); - setCompositionBlurSupported(false); - } - }; - - void checkSupport(); - }, [tauri, setCompositionBlurSupported]); - - // 初始化/清理 Composition Blur - useEffect(() => { - if (!tauri || !enabled || !customBlurEnabled) { - // 禁用时清理 - if (compositionInitializedRef.current) { - void disableCompositionBlur().catch(() => undefined); - compositionInitializedRef.current = false; - setCompositionBlurEnabled(false); - } - setBackgroundImage(null); - if (tauri) { - void clearBlurCache(); - } - return; - } - - // 如果支持 Composition Blur,启用它 - if (compositionBlurSupported && !compositionInitializedRef.current) { - const initComposition = async () => { - try { - await enableCompositionBlur(); - await setCompositionBlurRadius(blurRadius); - compositionInitializedRef.current = true; - setCompositionBlurEnabled(true); - console.debug('[BlurredBackground] Composition blur enabled'); - } catch (err) { - console.debug('[BlurredBackground] Failed to enable composition blur:', err); - await disableCompositionBlur().catch(() => undefined); - setCompositionBlurSupported(false); - setCompositionBlurEnabled(false); - } - }; - void initComposition(); - } - - return () => { - if (compositionInitializedRef.current) { - void disableCompositionBlur().catch(() => undefined); - compositionInitializedRef.current = false; - } - }; - }, [ - tauri, - enabled, - customBlurEnabled, - compositionBlurSupported, - setCompositionBlurSupported, - setCompositionBlurEnabled, - blurRadius, - ]); - - // 更新 Composition Blur 半径 - useEffect(() => { - if (!tauri || !enabled || !customBlurEnabled || !compositionBlurEnabled) return; - - void setCompositionBlurRadius(blurRadius).catch((err) => { - console.debug('[BlurredBackground] Failed to set composition blur radius:', err); - setCompositionBlurSupported(false); - setCompositionBlurEnabled(false); - }); - }, [ - tauri, - enabled, - customBlurEnabled, - compositionBlurEnabled, - blurRadius, - setCompositionBlurSupported, - setCompositionBlurEnabled, - ]); - - // 更新模糊背景 (Fallback 模式) - const updateBackground = useCallback( - async (mode: 'preview' | 'final' = 'final', force = false) => { - // 如果使用 Composition Blur,不需要抓屏 - if (!tauri || !enabled || !customBlurEnabled || compositionBlurEnabled) return; - - const now = Date.now(); - const minIntervalMs = mode === 'preview' ? (highRefreshUi ? 240 : 120) : 0; - if (!force && minIntervalMs > 0 && now - lastUpdateRef.current < minIntervalMs) return; - lastUpdateRef.current = now; - - if (isUpdatingRef.current) { - if (mode === 'final') pendingFinalRef.current = true; - return; - } - isUpdatingRef.current = true; - - try { - await setExcludeFromCapture(true); - - const scaleFactor = mode === 'preview' - ? (highRefreshUi ? 0.15 : 0.2) - : getDesktopCaptureScaleFactor(blurRadius); - const image = await getBlurredDesktop(blurRadius, scaleFactor); - setBackgroundImage(image); - } catch (err) { - console.debug('[BlurredBackground] update failed:', err); - } finally { - isUpdatingRef.current = false; - await setExcludeFromCapture(false).catch(() => undefined); - if (pendingFinalRef.current) { - pendingFinalRef.current = false; - setTimeout(() => { - void updateBackground('final', true); - }, 0); - } - } - }, - [tauri, enabled, customBlurEnabled, compositionBlurEnabled, blurRadius, highRefreshUi] - ); - - // 监听窗口事件 (Fallback 模式) - useEffect(() => { - // 如果使用 Composition Blur,不需要监听窗口事件 - if (!tauri || !enabled || !customBlurEnabled || compositionBlurEnabled) return; - - let unlisten: (() => void) | null = null; - - const setupListener = async () => { - try { - const appWindow = getCurrentWindow(); - - // 监听窗口移动和调整大小事件 - const unlistenFns: Array<() => void> = []; - - const scheduleUpdate = () => { - // Clear previous timer - if (updateTimeoutRef.current) { - clearTimeout(updateTimeoutRef.current); - } - void updateBackground('preview'); - // Debounce final quality update - updateTimeoutRef.current = setTimeout(() => { - void updateBackground('final', true); - }, 250); - }; - - unlistenFns.push(await appWindow.onMoved(scheduleUpdate)); - unlistenFns.push(await appWindow.onResized(scheduleUpdate)); - unlisten = () => { - for (const fn of unlistenFns) fn(); - }; - - // 初始更新 - void updateBackground('final', true); - } catch (err) { - console.debug('[BlurredBackground] 设置监听器失败:', err); - } - }; - - void setupListener(); - - return () => { - if (unlisten) { - unlisten(); - } - if (updateTimeoutRef.current) { - clearTimeout(updateTimeoutRef.current); - } - // 清除缓存 - void clearBlurCache(); - }; - }, [tauri, enabled, customBlurEnabled, compositionBlurEnabled, updateBackground]); - - // 模糊半径变化时更新 (Fallback 模式) - useEffect(() => { - // 如果使用 Composition Blur,不需要抓屏更新 - if (!tauri || !enabled || !customBlurEnabled || compositionBlurEnabled) return; - void updateBackground('final', true); - }, [blurRadius, tauri, enabled, customBlurEnabled, compositionBlurEnabled, updateBackground]); - - // 不启用时不渲染 - if (!enabled || !customBlurEnabled) { - return null; - } - - // Composition Blur 模式:不需要渲染背景图(由原生层处理) - if (compositionBlurEnabled) { - return null; - } - - // Fallback 模式:渲染 base64 背景图 - if (!backgroundImage) { - return null; - } - - return ( -
- ); -} - -export default BlurredBackground; diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx deleted file mode 100644 index 846f38f..0000000 --- a/src/components/layout/Layout.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import { lazy, Suspense, useEffect, useLayoutEffect, useMemo, useRef } from 'react'; -import { Routes, Route, Navigate, useLocation } from 'react-router-dom'; -import { invoke, isTauri } from '@tauri-apps/api/core'; -import { AnimatePresence, motion } from 'framer-motion'; -import AppHeader from './AppHeader'; -import BlurredBackground from './BlurredBackground'; -import { ImportFab } from '@/components/common/ImportFab'; -import { useSettingsStore } from '@/stores/settingsStore'; -import { useShallow } from 'zustand/shallow'; - -const HomePage = lazy(() => import('@/pages/HomePage')); -const AlbumsPage = lazy(() => import('@/pages/AlbumsPage')); -const TagsPage = lazy(() => import('@/pages/TagsPage')); -const SettingsPage = lazy(() => import('@/pages/SettingsPage')); -const FavoritesPage = lazy(() => import('@/pages/FavoritesPage')); -const TrashPage = lazy(() => import('@/pages/TrashPage')); -const FoldersPage = lazy(() => import('@/pages/FoldersPage')); - -// 页面顺序,用于判断滑动方向 -const PAGE_ORDER = ['/', '/albums', '/tags', '/folders', '/favorites', '/trash', '/settings']; - -// 计算方向的函数 -function getDirection(from: string, to: string): number { - const fromIndex = PAGE_ORDER.indexOf(from); - const toIndex = PAGE_ORDER.indexOf(to); - // 如果是未知页面,默认向右滑动 (1) - if (fromIndex === -1 || toIndex === -1) return 1; - return toIndex > fromIndex ? 1 : -1; -} - -// 页面切换动画 variants - 移除透明度变化,实现类似手机桌面的平移效果 -const pageVariants = { - enter: (dir: number) => ({ x: dir * 100 + '%' }), - center: { x: 0 }, - exit: (dir: number) => ({ x: dir * -100 + '%' }), -}; - -/** - * 主布局组件 - 深色玻璃风格 - */ -function Layout() { - const location = useLocation(); - const warmCacheTriggered = useRef(false); - const applyWindowAppearanceTimer = useRef | null>(null); - const tauri = isTauri(); - - // 记录上一个路径和当前方向 - const prevPathRef = useRef(location.pathname); - const directionRef = useRef(1); - - // 同步计算方向(在渲染时立即计算,不是异步) - if (prevPathRef.current !== location.pathname) { - directionRef.current = getDirection(prevPathRef.current, location.pathname); - prevPathRef.current = location.pathname; - } - - // 启动时处理 Splash Screen - useEffect(() => { - /* disabled: splash handled by backend - if (!tauri || splashClosed.current) return; - - const initApp = async () => { - // 模拟或者等待初始化 (例如等待 1.5 秒让 Logo 动画展示完) - await new Promise(resolve => setTimeout(resolve, 1500)); - - try { - const windows = await getAllWindows(); - const splashWin = windows.find(w => w.label === 'splash'); - const mainWin = getCurrentWindow(); - - if (splashWin) { - // 显示主窗口 - await mainWin.show(); - await mainWin.setFocus(); - // 关闭 Splash - await splashWin.close(); - } else { - // 如果找不到 splash (开发模式可能),确保主窗口显示 - await mainWin.show(); - } - splashClosed.current = true; - } catch (e) { - console.error('Failed to close splash screen', e); - // Fallback: ensure main window is visible - getCurrentWindow().show(); - } - }; - - initApp(); - */ - }, [tauri]); - - // 启动后延迟触发暖缓存 - useEffect(() => { - if (warmCacheTriggered.current) return; - warmCacheTriggered.current = true; - - const timer = setTimeout(async () => { - try { - const result = await invoke<{ queued: number; alreadyCached: number }>('warm_thumbnail_cache', { - strategy: 'recent', - limit: 100, - }); - if (result.queued > 0) { - console.debug(`[暖缓存] 已入队 ${result.queued} 个任务,${result.alreadyCached} 个已有缓存`); - } - } catch (err) { - console.debug('[暖缓存] 失败:', err); - } - }, 2000); - - return () => clearTimeout(timer); - }, []); - - // 从 Store 获取外观设置 - const { windowOpacity, windowTransparency, blurRadius, customBlurEnabled, highRefreshUi } = useSettingsStore( - useShallow((state) => ({ - windowOpacity: state.windowOpacity, - windowTransparency: state.windowTransparency, - blurRadius: state.blurRadius, - customBlurEnabled: state.customBlurEnabled, - highRefreshUi: state.highRefreshUi, - })) - ); - - const pageTransition = useMemo( - () => ({ - type: 'tween' as const, - duration: highRefreshUi ? 0.2 : 0.22, - ease: [0.22, 1, 0.36, 1] as const, - }), - [highRefreshUi] - ); - - useEffect(() => { - document.documentElement.classList.toggle('high-refresh', highRefreshUi); - return () => { - document.documentElement.classList.remove('high-refresh'); - }; - }, [highRefreshUi]); - - // 同步玻璃拟态 CSS 变量(影响 glass-panel / native-glass-panel 等) - useLayoutEffect(() => { - const clampedTransparency = Math.max(0, Math.min(100, windowTransparency)); - const clampedBlurRadius = Math.max(0, Math.min(100, blurRadius)); - - document.documentElement.style.setProperty( - '--glass-opacity', - (clampedTransparency / 100).toString() - ); - document.documentElement.style.setProperty('--glass-blur', `${clampedBlurRadius}px`); - - return () => { - document.documentElement.style.removeProperty('--glass-opacity'); - document.documentElement.style.removeProperty('--glass-blur'); - }; - }, [windowTransparency, blurRadius]); - - // 将外观设置同步到原生窗口效果(Tauri 桌面端) - useEffect(() => { - if (!tauri) return; - - if (applyWindowAppearanceTimer.current) { - clearTimeout(applyWindowAppearanceTimer.current); - } - - applyWindowAppearanceTimer.current = setTimeout(() => { - void invoke('apply_window_settings', { - settings: { - opacity: windowOpacity, - transparency: windowTransparency, - blurRadius: blurRadius, - customBlurEnabled: customBlurEnabled, - }, - }).catch((err) => { - console.debug('[window] apply_window_settings failed:', err); - }); - }, 80); - - return () => { - if (applyWindowAppearanceTimer.current) { - clearTimeout(applyWindowAppearanceTimer.current); - applyWindowAppearanceTimer.current = null; - } - }; - }, [tauri, windowOpacity, windowTransparency, blurRadius, customBlurEnabled]); - - return ( -
- - -
-
- - - }> - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - -
-
- - -
- ); -} - -export default Layout; diff --git a/src/components/layout/StatusBar.tsx b/src/components/layout/StatusBar.tsx deleted file mode 100644 index c388eba..0000000 --- a/src/components/layout/StatusBar.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { usePhotoStore } from '@/stores/photoStore'; -import { useSelectionStore } from '@/stores/selectionStore'; -import type { Photo } from '@/types'; - -/** - * 状态栏组件 - Soft UI 风格重构 - */ -function StatusBar() { - const { photos, loading } = usePhotoStore(); - const { selectedIds } = useSelectionStore(); - - // 格式化文件大小总和 - const formatTotalSize = (bytes: number) => { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; - }; - - // 计算选中照片的总大小 - const selectedSize = photos - .filter((p: Photo) => selectedIds.has(p.photoId)) - .reduce((sum: number, p: Photo) => sum + p.fileSize, 0); - - // 计算所有照片的总大小 - const totalSize = photos.reduce((sum: number, p: Photo) => sum + p.fileSize, 0); - - return ( -
-
- {photos.length} 张照片 - {photos.length > 0 && 总大小 {formatTotalSize(totalSize)}} - {selectedIds.size > 0 && ( - - 已选 {selectedIds.size} 项 ({formatTotalSize(selectedSize)}) - - )} -
-
- {loading ? ( - - - 加载中... - - ) : ( - 就绪 - )} -
-
- ); -} - -export default StatusBar; diff --git a/src/components/layout/Toolbar.tsx b/src/components/layout/Toolbar.tsx deleted file mode 100644 index a6bf2d4..0000000 --- a/src/components/layout/Toolbar.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react'; -import type { FormEvent } from 'react'; -import { usePhotoStore } from '@/stores/photoStore'; -import type { ViewMode, SortField } from '@/types'; -import { Icon } from '@/components/common/Icon'; -import clsx from 'clsx'; - -const viewModes: { value: ViewMode; icon: React.ReactNode; label: string }[] = [ - { - value: 'grid', - label: '网格', - icon: , - }, - { - value: 'timeline', - label: '时间轴', - icon: , - }, -]; - -// 排序字段配置 -const sortFields: { value: SortField; label: string }[] = [ - { value: 'dateTaken', label: '拍摄时间' }, - { value: 'dateAdded', label: '添加时间' }, - { value: 'fileName', label: '文件名' }, - { value: 'fileSize', label: '文件大小' }, - { value: 'rating', label: '评分' }, -]; - -function Toolbar() { - const viewMode = usePhotoStore((state) => state.viewMode); - const totalCount = usePhotoStore((state) => state.totalCount); - const sortOptions = usePhotoStore((state) => state.sortOptions); - const setViewMode = usePhotoStore((state) => state.setViewMode); - const setSearchQuery = usePhotoStore((state) => state.setSearchQuery); - const setSortOptions = usePhotoStore((state) => state.setSortOptions); - - const [searchQuery, setLocalSearchQuery] = useState(''); - const [showSortMenu, setShowSortMenu] = useState(false); - const sortMenuRef = useRef(null); - - const formattedTotal = useMemo(() => new Intl.NumberFormat('zh-CN').format(totalCount), [totalCount]); - - // 点击外部关闭排序菜单 - useEffect(() => { - if (!showSortMenu) return; - - const handleClickOutside = (e: MouseEvent) => { - if (sortMenuRef.current && !sortMenuRef.current.contains(e.target as Node)) { - setShowSortMenu(false); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, [showSortMenu]); - - const handleSearch = useCallback( - (e: FormEvent) => { - e.preventDefault(); - setSearchQuery(searchQuery.trim()); - }, - [searchQuery, setSearchQuery] - ); - - return ( - <> - {/* 标题 */} -
-

所有照片

- {/* 计数徽章 */} -
- {formattedTotal} -
-
- - {/* 工具栏 */} -
- {/* 搜索框 */} -
-
- - setLocalSearchQuery(e.target.value)} - /> -
-
- - {/* 按钮组 */} -
- {/* 视图切换 */} -
- {viewModes.map((mode) => ( - - ))} -
- - {/* 排序按钮 */} -
- - - {/* 排序下拉菜单 */} - {showSortMenu && ( -
-
排序方式
- {sortFields.map((field) => ( - - ))} - -
- -
排序顺序
- - -
- )} -
-
-
- - ); -} - -export default Toolbar; diff --git a/src/components/layout/WindowControls.tsx b/src/components/layout/WindowControls.tsx deleted file mode 100644 index d6b3154..0000000 --- a/src/components/layout/WindowControls.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { getCurrentWindow } from '@tauri-apps/api/window'; -import { isTauri } from '@tauri-apps/api/core'; -import { Icon } from '@/components/common/Icon'; - -/** - * 自定义窗口控制按钮 (最小化/最大化/关闭) - */ -export default function WindowControls() { - if (!isTauri()) return null; - - const appWindow = getCurrentWindow(); - - return ( -
- - - -
- ); -} diff --git a/src/components/layout/index.ts b/src/components/layout/index.ts deleted file mode 100644 index 8cef5b2..0000000 --- a/src/components/layout/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { default as Layout } from './Layout'; -export { default as Toolbar } from './Toolbar'; -export { default as StatusBar } from './StatusBar'; diff --git a/src/components/navigation/NavTree.tsx b/src/components/navigation/NavTree.tsx new file mode 100644 index 0000000..b6e1c52 --- /dev/null +++ b/src/components/navigation/NavTree.tsx @@ -0,0 +1,412 @@ +/** + * NavTree - Win11 style tree navigation for the left pane. + * + * Uses Fluent UI Tree components. Renders static top-level nodes + * (photos, albums, tags, favorites, trash, settings) and a dynamic + * folder tree that lazy-loads children via get_folder_children. + * + * Navigation is driven by navigationStore.activeNode, not URL routes. + */ + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import type { ReactNode } from 'react'; +import { + Tree, + TreeItem, + TreeItemLayout, + CounterBadge, + Spinner, + makeStyles, + tokens, + mergeClasses, +} from '@fluentui/react-components'; +import type { TreeOpenChangeData } from '@fluentui/react-components'; +import { + Image24Regular, + Folder24Regular, + Album24Regular, + Tag24Regular, + Star24Regular, + Delete24Regular, + Settings24Regular, +} from '@fluentui/react-icons'; +import { getAllAlbumsWithCount } from '@/services/api/albums'; +import { getAllTagsWithCount } from '@/services/api/tags'; +import { getFolderTree, getFolderChildren } from '@/services/api/folders'; +import { useNavigationStore } from '@/stores/navigationStore'; +import type { AlbumWithCount, TagWithCount, FolderNode } from '@/types'; +import { useNavigate } from 'react-router-dom'; +import { getNodePath } from '@/components/shell/navigation'; + +// ---- styles ---- + +const useStyles = makeStyles({ + tree: { + width: '100%', + paddingTop: '4px', + paddingBottom: '4px', + }, + itemLayout: { + fontSize: '13px', + minHeight: '32px', + }, + badge: { + marginLeft: 'auto', + flexShrink: 0, + }, + sectionLabel: { + fontSize: '11px', + fontWeight: 600, + textTransform: 'uppercase', + letterSpacing: '0.04em', + color: tokens.colorNeutralForeground3, + paddingLeft: '12px', + paddingTop: '12px', + paddingBottom: '4px', + userSelect: 'none', + }, + folderSpinner: { + paddingLeft: '36px', + paddingTop: '4px', + paddingBottom: '4px', + }, + activeItem: { + backgroundColor: tokens.colorNeutralBackground1Selected, + }, +}); + +// ---- node id helpers ---- + +export const NAV_NODE = { + PHOTOS: 'photos', + ALBUMS: 'albums', + TAGS: 'tags', + FAVORITES: 'favorites', + TRASH: 'trash', + SETTINGS: 'settings', + FOLDERS: 'folders', + albumItem: (id: number) => `album:${id}`, + tagItem: (id: number) => `tag:${id}`, + folderItem: (path: string) => `folder:${path}`, +} as const; + +// ---- component ---- + +export default function NavTree() { + const styles = useStyles(); + const navigate = useNavigate(); + const activeNode = useNavigationStore((s) => s.activeNode); + const expandedNodes = useNavigationStore((s) => s.expandedNodes); + const setActiveNode = useNavigationStore((s) => s.setActiveNode); + const setExpandedNodes = useNavigationStore((s) => s.setExpandedNodes); + + // ---- data state ---- + const [albums, setAlbums] = useState([]); + const [tags, setTags] = useState([]); + const [rootFolders, setRootFolders] = useState([]); + const [folderChildren, setFolderChildren] = useState>({}); + const [loadingFolders, setLoadingFolders] = useState>(new Set()); + + // ---- fetch data on mount ---- + useEffect(() => { + getAllAlbumsWithCount() + .then(setAlbums) + .catch((e) => console.debug('[nav] albums fetch failed:', e)); + }, []); + + useEffect(() => { + getAllTagsWithCount() + .then(setTags) + .catch((e) => console.debug('[nav] tags fetch failed:', e)); + }, []); + + useEffect(() => { + getFolderTree() + .then((stats) => setRootFolders(stats.rootFolders)) + .catch((e) => console.debug('[nav] folder tree fetch failed:', e)); + }, []); + + // ---- handlers ---- + + const handleNodeClick = useCallback( + (nodeId: string) => { + setActiveNode(nodeId); + navigate(getNodePath(nodeId)); + }, + [navigate, setActiveNode], + ); + + const handleOpenChange = useCallback( + (_e: unknown, data: TreeOpenChangeData) => { + const next = Array.from(data.openItems as Iterable); + setExpandedNodes(next); + + // Lazy-load folder children when expanding a folder node + for (const item of next) { + if (typeof item === 'string' && item.startsWith('folder:') && !folderChildren[item]) { + const folderPath = item.slice('folder:'.length); + if (loadingFolders.has(item)) continue; + setLoadingFolders((prev) => new Set(prev).add(item)); + getFolderChildren(folderPath) + .then((children) => { + setFolderChildren((prev) => ({ ...prev, [item]: children })); + }) + .catch((e) => console.debug('[nav] folder children fetch failed:', e)) + .finally(() => { + setLoadingFolders((prev) => { + const s = new Set(prev); + s.delete(item); + return s; + }); + }); + } + } + }, + [folderChildren, loadingFolders, setExpandedNodes], + ); + + useEffect(() => { + for (const item of expandedNodes) { + if (!item.startsWith('folder:') || folderChildren[item] || loadingFolders.has(item)) { + continue; + } + + const folderPath = item.slice('folder:'.length); + setLoadingFolders((prev) => new Set(prev).add(item)); + getFolderChildren(folderPath) + .then((children) => { + setFolderChildren((prev) => ({ ...prev, [item]: children })); + }) + .catch((e) => console.debug('[nav] folder children fetch failed:', e)) + .finally(() => { + setLoadingFolders((prev) => { + const next = new Set(prev); + next.delete(item); + return next; + }); + }); + } + }, [expandedNodes, folderChildren, loadingFolders]); + + const openItems = useMemo( + () => new Set(expandedNodes), + [expandedNodes], + ); + + // ---- render helpers ---- + + const renderFolderNode = (node: FolderNode, depth: number): ReactNode => { + const nodeId = NAV_NODE.folderItem(node.path); + const hasChildren = !node.loaded || node.children.length > 0; + const children = folderChildren[nodeId] ?? node.children; + const isLoading = loadingFolders.has(nodeId); + const isActive = activeNode === nodeId; + + if (!hasChildren) { + return ( + + } + onClick={() => handleNodeClick(nodeId)} + > + {node.name} + {node.photoCount > 0 && ( + + )} + + + ); + } + + return ( + + } + onClick={() => handleNodeClick(nodeId)} + > + {node.name} + {node.totalPhotoCount > 0 && ( + + )} + + + {isLoading && ( +
+ +
+ )} + {children.map((child) => renderFolderNode(child, depth + 1))} +
+
+ ); + }; + + return ( + + {/* --- Photos --- */} + + } + onClick={() => handleNodeClick(NAV_NODE.PHOTOS)} + > + 照片 + + + + {/* --- Folders (branch) --- */} + + } + onClick={() => handleNodeClick(NAV_NODE.FOLDERS)} + > + 文件夹 + + + {rootFolders.map((f) => renderFolderNode(f, 1))} + + + + {/* --- Albums (branch) --- */} + + } + onClick={() => handleNodeClick(NAV_NODE.ALBUMS)} + > + 相册 + {albums.length > 0 && ( + + )} + + + {albums.map((a) => { + const itemId = NAV_NODE.albumItem(a.album.albumId); + return ( + + handleNodeClick(itemId)} + > + {a.album.albumName} + {a.photoCount > 0 && ( + + )} + + + ); + })} + + + + {/* --- Tags (branch) --- */} + + } + onClick={() => handleNodeClick(NAV_NODE.TAGS)} + > + 标签 + {tags.length > 0 && ( + + )} + + + {tags.map((t) => { + const itemId = NAV_NODE.tagItem(t.tagId); + return ( + + handleNodeClick(itemId)} + > + {t.tagName} + {t.photoCount > 0 && ( + + )} + + + ); + })} + + + + {/* --- Favorites --- */} + + } + onClick={() => handleNodeClick(NAV_NODE.FAVORITES)} + > + 收藏 + + + + {/* --- Trash --- */} + + } + onClick={() => handleNodeClick(NAV_NODE.TRASH)} + > + 废纸篓 + + + + {/* --- Settings --- */} + + } + onClick={() => handleNodeClick(NAV_NODE.SETTINGS)} + > + 设置 + + + + ); +} diff --git a/src/components/navigation/NavigationPane.tsx b/src/components/navigation/NavigationPane.tsx new file mode 100644 index 0000000..e745118 --- /dev/null +++ b/src/components/navigation/NavigationPane.tsx @@ -0,0 +1,96 @@ +/** + * NavigationPane - Left navigation pane container with drag-to-resize. + * + * Wraps NavTree and exposes a draggable right edge for width adjustment. + * Width is stored in navigationStore and persisted via shell settings. + */ + +import { useCallback, useRef } from 'react'; +import type { MouseEvent as ReactMouseEvent } from 'react'; +import { makeStyles, tokens } from '@fluentui/react-components'; +import NavTree from './NavTree'; + +const MIN_WIDTH = 160; +const MAX_WIDTH = 480; +const DEFAULT_WIDTH = 220; + +const useStyles = makeStyles({ + pane: { + position: 'relative', + display: 'flex', + flexDirection: 'column', + flexShrink: 0, + height: '100%', + overflowY: 'auto', + overflowX: 'hidden', + borderRight: `1px solid ${tokens.colorNeutralStroke2}`, + backgroundColor: tokens.colorNeutralBackground2, + userSelect: 'none', + }, + resizeHandle: { + position: 'absolute', + top: 0, + right: 0, + width: '4px', + height: '100%', + cursor: 'col-resize', + zIndex: 10, + ':hover': { + backgroundColor: tokens.colorBrandBackground, + opacity: 0.4, + }, + }, + resizeHandleActive: { + backgroundColor: tokens.colorBrandBackground, + opacity: 0.6, + }, +}); + +interface NavigationPaneProps { + width?: number; + onWidthChange?: (width: number) => void; +} + +export default function NavigationPane({ width = DEFAULT_WIDTH, onWidthChange }: NavigationPaneProps) { + const styles = useStyles(); + const dragging = useRef(false); + const startX = useRef(0); + const startWidth = useRef(width); + + const handleMouseDown = useCallback( + (e: ReactMouseEvent) => { + e.preventDefault(); + dragging.current = true; + startX.current = e.clientX; + startWidth.current = width; + + const handleMouseMove = (ev: MouseEvent) => { + if (!dragging.current) return; + const delta = ev.clientX - startX.current; + const next = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, startWidth.current + delta)); + onWidthChange?.(next); + }; + + const handleMouseUp = () => { + dragging.current = false; + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }, + [width, onWidthChange], + ); + + return ( +
+ +
+
+ ); +} diff --git a/src/components/navigation/index.ts b/src/components/navigation/index.ts new file mode 100644 index 0000000..2eb9c82 --- /dev/null +++ b/src/components/navigation/index.ts @@ -0,0 +1,2 @@ +export { default as NavigationPane } from './NavigationPane'; +export { default as NavTree, NAV_NODE } from './NavTree'; diff --git a/src/components/photo/FolderTree.tsx b/src/components/photo/FolderTree.tsx index ed0f3dc..54b419c 100644 --- a/src/components/photo/FolderTree.tsx +++ b/src/components/photo/FolderTree.tsx @@ -5,6 +5,7 @@ */ import { useState, useCallback, memo } from 'react'; +import type { MouseEvent } from 'react'; import clsx from 'clsx'; export interface FolderNode { @@ -47,7 +48,7 @@ const FolderTree = memo(function FolderTree({ // 切换展开/折叠 const toggleExpand = useCallback( - async (folder: FolderNode, e: React.MouseEvent) => { + async (folder: FolderNode, e: MouseEvent) => { e.stopPropagation(); const path = folder.path; diff --git a/src/components/photo/PhotoEditor.tsx b/src/components/photo/PhotoEditor.tsx deleted file mode 100644 index 9c9eb84..0000000 --- a/src/components/photo/PhotoEditor.tsx +++ /dev/null @@ -1,426 +0,0 @@ -/** - * 照片编辑器组件 - */ - -import { memo, useState, useCallback, useMemo, useRef } from 'react'; -import { createPortal } from 'react-dom'; -import clsx from 'clsx'; -import { Icon, type IconName } from '@/components/common/Icon'; -import { useEditStore } from '@/stores'; -import { applyPhotoEdits, getAssetUrl } from '@/services/api'; -import type { Photo, EditAdjustments } from '@/types'; - -interface PhotoEditorProps { - photo: Photo; - open: boolean; - onClose: () => void; - onSave?: (photo: Photo) => void; -} - -// 调整项配置 -const ADJUSTMENT_CONFIGS: Array<{ - key: keyof EditAdjustments; - label: string; - icon: IconName; - min: number; - max: number; -}> = [ - { key: 'brightness', label: '亮度', icon: 'brightness_6', min: -100, max: 100 }, - { key: 'contrast', label: '对比度', icon: 'contrast', min: -100, max: 100 }, - { key: 'saturation', label: '饱和度', icon: 'palette', min: -100, max: 100 }, - { key: 'exposure', label: '曝光', icon: 'exposure', min: -200, max: 200 }, - { key: 'highlights', label: '高光', icon: 'wb_sunny', min: -100, max: 100 }, - { key: 'shadows', label: '阴影', icon: 'nights_stay', min: -100, max: 100 }, - { key: 'temperature', label: '色温', icon: 'thermostat', min: -100, max: 100 }, - { key: 'tint', label: '色调', icon: 'tune', min: -100, max: 100 }, - { key: 'sharpen', label: '锐化', icon: 'deblur', min: 0, max: 100 }, - { key: 'blur', label: '模糊', icon: 'blur_on', min: 0, max: 100 }, - { key: 'vignette', label: '暗角', icon: 'vignette', min: 0, max: 100 }, -]; - -/** - * 照片编辑器 - */ -export const PhotoEditor = memo(function PhotoEditor({ - photo, - open, - onClose, - onSave, -}: PhotoEditorProps) { - const { - photo: editPhoto, - rotation, - flipH, - flipV, - adjustments, - hasChanges, - isSaving, - rotate, - flip, - setAdjustment, - resetAdjustments, - setIsSaving, - getEditParams, - } = useEditStore(); - - // 使用 store 中的 photo,避免外部 prop 变化导致重新渲染 - const currentPhoto = editPhoto ?? photo; - - const [activeTab, setActiveTab] = useState<'transform' | 'adjust'>('transform'); - const [saveAsCopy, setSaveAsCopy] = useState(false); - - // 缩放和拖拽状态 - const [zoom, setZoom] = useState(1); - const [pan, setPan] = useState({ x: 0, y: 0 }); - const [isDragging, setIsDragging] = useState(false); - const dragStart = useRef({ x: 0, y: 0 }); - const containerRef = useRef(null); - - // 计算 CSS 滤镜(用于实时预览) - const cssFilters = useMemo(() => { - const filters: string[] = []; - - if (adjustments.brightness !== 0) { - const brightness = 1 + adjustments.brightness / 100; - filters.push(`brightness(${brightness})`); - } - - if (adjustments.contrast !== 0) { - const contrast = 1 + adjustments.contrast / 100; - filters.push(`contrast(${contrast})`); - } - - if (adjustments.saturation !== 0) { - const saturate = 1 + adjustments.saturation / 100; - filters.push(`saturate(${saturate})`); - } - - if (adjustments.blur > 0) { - filters.push(`blur(${adjustments.blur / 10}px)`); - } - - if (adjustments.temperature !== 0) { - const sepia = Math.abs(adjustments.temperature) / 200; - const hue = adjustments.temperature > 0 ? -10 : 10; - filters.push(`sepia(${sepia}) hue-rotate(${hue}deg)`); - } - - return filters.join(' ') || 'none'; - }, [adjustments]); - - // 计算变换样式 - const transformStyle = useMemo(() => { - const transforms: string[] = []; - if (rotation !== 0) { - transforms.push(`rotate(${rotation}deg)`); - } - if (flipH) { - transforms.push('scaleX(-1)'); - } - if (flipV) { - transforms.push('scaleY(-1)'); - } - return transforms.join(' ') || 'none'; - }, [rotation, flipH, flipV]); - - // 滚轮缩放 - const handleWheel = useCallback((e: React.WheelEvent) => { - e.preventDefault(); - const delta = e.deltaY > 0 ? 0.9 : 1.1; - setZoom((z) => Math.min(Math.max(z * delta, 0.5), 5)); - }, []); - - // 拖拽开始 - const handleMouseDown = useCallback((e: React.MouseEvent) => { - if (zoom <= 1) return; - e.preventDefault(); - setIsDragging(true); - dragStart.current = { x: e.clientX - pan.x, y: e.clientY - pan.y }; - }, [zoom, pan]); - - // 拖拽移动 - const handleMouseMove = useCallback((e: React.MouseEvent) => { - if (!isDragging) return; - setPan({ x: e.clientX - dragStart.current.x, y: e.clientY - dragStart.current.y }); - }, [isDragging]); - - // 拖拽结束 - const handleMouseUp = useCallback(() => { - setIsDragging(false); - }, []); - - // 缩放控制 - const handleZoomIn = useCallback(() => setZoom((z) => Math.min(z * 1.25, 5)), []); - const handleZoomOut = useCallback(() => setZoom((z) => Math.max(z * 0.8, 0.5)), []); - const handleZoomReset = useCallback(() => { - setZoom(1); - setPan({ x: 0, y: 0 }); - }, []); - - // 保存编辑 - const handleSave = useCallback(async () => { - if (!hasChanges) { - onClose(); - return; - } - - setIsSaving(true); - try { - const params = getEditParams(); - const updatedPhoto = await applyPhotoEdits(currentPhoto.photoId, params, saveAsCopy); - onSave?.(updatedPhoto); - onClose(); - } catch (error) { - console.error('保存失败:', error); - alert('保存失败,请重试'); - } finally { - setIsSaving(false); - } - }, [hasChanges, currentPhoto.photoId, saveAsCopy, getEditParams, setIsSaving, onSave, onClose]); - - // 取消编辑 - const handleCancel = useCallback(() => { - if (hasChanges) { - if (!confirm('有未保存的更改,确定要放弃吗?')) { - return; - } - } - resetAdjustments(); - onClose(); - }, [hasChanges, resetAdjustments, onClose]); - - if (!open) return null; - - return createPortal( -
- {/* 左侧预览区 */} -
- {/* 缩放控制栏 */} -
- - - -
- - {/* 图片预览 */} -
1 ? (isDragging ? 'grabbing' : 'grab') : 'default' }} - > -
- {currentPhoto.fileName} -
-
-
- - {/* 右侧控制面板 */} -
- {/* 头部 */} -
-

编辑照片

- -
- - {/* 标签页 */} -
- - -
- - {/* 内容区 */} -
- {activeTab === 'transform' ? ( -
- {/* 旋转 */} -
-

旋转

-
- - -
-
- - {/* 翻转 */} -
-

翻转

-
- - -
-
- - {/* 当前状态 */} -
-
旋转: {rotation}°
-
水平翻转: {flipH ? '是' : '否'}
-
垂直翻转: {flipV ? '是' : '否'}
-
-
- ) : ( -
- {ADJUSTMENT_CONFIGS.map((config) => ( -
-
-
- - {config.label} -
- - {adjustments[config.key]} - -
- setAdjustment(config.key, Number(e.target.value))} - className="h-2 w-full cursor-pointer appearance-none rounded-full bg-secondary/20 accent-primary" - /> -
- ))} -
- )} -
- - {/* 底部操作 */} -
- {/* 另存为副本选项 */} - - - {/* 操作按钮 */} -
- - -
-
-
-
, - document.body - ); -}); - -export default PhotoEditor; diff --git a/src/components/photo/PhotoThumbnail.tsx b/src/components/photo/PhotoThumbnail.tsx index 63d4a6d..30e570d 100644 --- a/src/components/photo/PhotoThumbnail.tsx +++ b/src/components/photo/PhotoThumbnail.tsx @@ -12,7 +12,7 @@ interface PhotoThumbnailProps { aspectCategory?: AspectRatioCategory; /** 是否选中 */ selected?: boolean; - /** 是否启用缩略图加载(可用于 Scroll-Activated 加载等情况) */ + /** 是否启用缩略图加载(可用于 Scroll-Activated 加载等情况) */ thumbnailsEnabled?: boolean; /** 是否正在滚动(滚动时延迟加载缩略图) */ isScrolling?: boolean; diff --git a/src/components/photo/PhotoViewer.tsx b/src/components/photo/PhotoViewer.tsx index 1fd84ca..4b6d202 100644 --- a/src/components/photo/PhotoViewer.tsx +++ b/src/components/photo/PhotoViewer.tsx @@ -1,14 +1,260 @@ import React, { memo, useState, useCallback, useEffect } from 'react'; import { createPortal } from 'react-dom'; -import clsx from 'clsx'; import { format } from 'date-fns'; -import { getAssetUrl, setPhotoRating, setPhotoFavorite, getRawPreview, isRawFile, isPhotoEditable } from '@/services/api'; +import { + Button, + Tooltip, + tokens, + makeStyles, +} from '@fluentui/react-components'; +import { + ArrowLeft24Regular, + ChevronLeft24Regular, + ChevronRight24Regular, + ZoomIn24Regular, + ZoomOut24Regular, + CenterHorizontal24Regular, + Play24Regular, + Dismiss24Regular, + Star24Filled, + Star24Regular, + Heart24Filled, + Heart24Regular, + Info24Regular, + Info24Filled, + Dismiss16Regular, +} from '@fluentui/react-icons'; +import { getAssetUrl, setPhotoRating, setPhotoFavorite, getRawPreview, isRawFile } from '@/services/api'; import { useThumbnail } from '@/hooks/useThumbnail'; import type { Photo } from '@/types'; -import { Icon } from '@/components/common/Icon'; import { TagSelector } from '@/components/tag'; -import { PhotoEditor } from './PhotoEditor'; -import { useEditStore } from '@/stores'; + +const useStyles = makeStyles({ + overlay: { + position: 'fixed', + inset: '0', + zIndex: 9999, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: tokens.colorNeutralBackground1, + }, + toolbar: { + position: 'absolute', + top: '12px', + left: '50%', + transform: 'translateX(-50%)', + zIndex: 20, + display: 'flex', + alignItems: 'center', + gap: '8px', + height: '44px', + padding: '0 8px', + borderRadius: tokens.borderRadiusXLarge, + backgroundColor: tokens.colorNeutralBackground3, + boxShadow: tokens.shadow16, + border: `1px solid ${tokens.colorNeutralStroke2}`, + }, + toolbarDivider: { + width: '1px', + height: '20px', + backgroundColor: tokens.colorNeutralStroke2, + margin: '0 4px', + }, + counter: { + padding: '0 12px', + fontSize: tokens.fontSizeBase200, + fontWeight: tokens.fontWeightSemibold, + color: tokens.colorNeutralForeground2, + borderRight: `1px solid ${tokens.colorNeutralStroke2}`, + marginRight: '4px', + whiteSpace: 'nowrap', + }, + scaleText: { + width: '52px', + textAlign: 'center', + fontSize: tokens.fontSizeBase200, + fontWeight: tokens.fontWeightSemibold, + color: tokens.colorNeutralForeground1, + fontVariantNumeric: 'tabular-nums', + }, + backButton: { + position: 'absolute', + top: '12px', + left: '12px', + zIndex: 20, + }, + navButton: { + position: 'absolute', + top: '50%', + transform: 'translateY(-50%)', + zIndex: 20, + }, + navLeft: { + left: '16px', + }, + navRight: { + right: '16px', + }, + imageArea: { + display: 'flex', + height: '100%', + width: '100%', + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + }, + loadingOverlay: { + position: 'absolute', + inset: '0', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + zIndex: 10, + pointerEvents: 'none', + }, + loadingBox: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '12px', + backgroundColor: tokens.colorNeutralBackground3, + padding: '16px', + borderRadius: tokens.borderRadiusXLarge, + boxShadow: tokens.shadow8, + border: `1px solid ${tokens.colorNeutralStroke2}`, + }, + spinner: { + width: '28px', + height: '28px', + borderRadius: '50%', + border: `2px solid ${tokens.colorNeutralStroke2}`, + borderTopColor: tokens.colorBrandForeground1, + animationName: { + from: { transform: 'rotate(0deg)' }, + to: { transform: 'rotate(360deg)' }, + }, + animationDuration: '0.8s', + animationIterationCount: 'infinite', + animationTimingFunction: 'linear', + }, + loadingText: { + fontSize: tokens.fontSizeBase200, + color: tokens.colorNeutralForeground2, + }, + errorBox: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '12px', + padding: '24px', + backgroundColor: tokens.colorNeutralBackground3, + borderRadius: tokens.borderRadiusXLarge, + boxShadow: tokens.shadow8, + border: `1px solid ${tokens.colorNeutralStroke2}`, + }, + ratingBar: { + position: 'absolute', + bottom: '24px', + left: '50%', + transform: 'translateX(-50%)', + zIndex: 20, + display: 'flex', + alignItems: 'center', + gap: '4px', + padding: '8px 20px', + backgroundColor: tokens.colorNeutralBackground3, + boxShadow: tokens.shadow16, + border: `1px solid ${tokens.colorNeutralStroke2}`, + borderRadius: tokens.borderRadiusXLarge, + }, + infoPanel: { + position: 'absolute', + right: '12px', + top: '64px', + bottom: '80px', + zIndex: 20, + width: '320px', + overflowY: 'auto', + backgroundColor: tokens.colorNeutralBackground3, + padding: '20px', + borderRadius: tokens.borderRadiusXLarge, + boxShadow: tokens.shadow16, + border: `1px solid ${tokens.colorNeutralStroke2}`, + }, + infoPanelHeader: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: '20px', + }, + infoPanelTitle: { + fontSize: tokens.fontSizeBase400, + fontWeight: tokens.fontWeightSemibold, + color: tokens.colorNeutralForeground1, + }, + infoSection: { + marginBottom: '20px', + }, + infoSectionTitle: { + marginBottom: '8px', + fontSize: tokens.fontSizeBase200, + fontWeight: tokens.fontWeightSemibold, + color: tokens.colorNeutralForeground1, + paddingBottom: '4px', + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + }, + infoRow: { + display: 'flex', + justifyContent: 'space-between', + marginBottom: '6px', + fontSize: tokens.fontSizeBase200, + }, + infoLabel: { + color: tokens.colorNeutralForeground2, + }, + infoValue: { + color: tokens.colorNeutralForeground1, + fontWeight: tokens.fontWeightSemibold, + maxWidth: '160px', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + paramGrid: { + display: 'grid', + gridTemplateColumns: '1fr 1fr', + gap: '8px', + }, + paramCard: { + backgroundColor: tokens.colorNeutralBackground1, + borderRadius: tokens.borderRadiusMedium, + padding: '8px', + textAlign: 'center', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '2px', + }, + paramLabel: { + fontSize: tokens.fontSizeBase100, + color: tokens.colorNeutralForeground2, + }, + paramValue: { + fontSize: tokens.fontSizeBase200, + fontWeight: tokens.fontWeightBold, + color: tokens.colorNeutralForeground1, + }, + filePath: { + wordBreak: 'break-all', + fontSize: tokens.fontSizeBase100, + color: tokens.colorNeutralForeground2, + backgroundColor: tokens.colorNeutralBackground1, + padding: '8px', + borderRadius: tokens.borderRadiusMedium, + userSelect: 'text', + }, +}); interface PhotoViewerProps { /** 当前照片 */ @@ -26,7 +272,7 @@ interface PhotoViewerProps { } /** - * 照片查看器组件 + * 只读照片查看器 - Win11 风格 * * 全屏查看照片,支持缩放、拖拽、前后导航、评分、收藏等 */ @@ -38,6 +284,7 @@ const PhotoViewer = memo(function PhotoViewer({ onPhotoChange, onPhotoUpdate, }: PhotoViewerProps) { + const styles = useStyles(); const [scale, setScale] = useState(1); const [showInfo, setShowInfo] = useState(false); const [localPhoto, setLocalPhoto] = useState(photo); @@ -45,11 +292,6 @@ const PhotoViewer = memo(function PhotoViewer({ const [isDragging, setIsDragging] = useState(false); const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); const [isPlaying, setIsPlaying] = useState(false); - const [showEditor, setShowEditor] = useState(false); - const [canEdit, setCanEdit] = useState(false); - - // 编辑状态 - const { startEditing, stopEditing } = useEditStore(); // 渐进式加载状态 const [isFullLoaded, setIsFullLoaded] = useState(false); @@ -75,8 +317,6 @@ const PhotoViewer = memo(function PhotoViewer({ setIsFullLoaded(false); setLoadError(false); setRawPreviewUrl(null); - // 检查是否可编辑 - isPhotoEditable(photo.filePath).then(setCanEdit).catch(() => setCanEdit(false)); }, [photo]); // RAW 图像加载预览 @@ -312,139 +552,113 @@ const PhotoViewer = memo(function PhotoViewer({ if (!open) return null; return createPortal( -
- {/* 顶部工具栏 - 统一悬浮条 */} -
- {/* 统一控制栏 */} -
- - {/* 计数器 */} -
- {currentIndex + 1} - / - {photos.length || 1} -
+
+ {/* 顶部工具栏 */} +
+ {/* 计数器 */} +
+ {currentIndex + 1} / {photos.length || 1} +
- {/* 缩放控制 */} -
- - - {Math.round(scale * 100)}% - - -
+ {/* 缩放控制 */} + + - - - - - - {/* 编辑按钮 */} - {canEdit && ( - - )} +
- -
-
+ {/* 常用操作 */} + +
- {/* 关闭按钮 - 独立左上角 - 退出图标 */} - + {/* 返回按钮 */} +
+ +
{/* 导航按钮 */} {hasPrev && ( - +
+
)} {hasNext && ( - +
+
)} - {/* 图片 */} + {/* 图片区域 */}
{/* 加载指示器 */} {isFullLoading && ( -
-
-
- 加载高清图片... +
+
+
+ 加载高清图片...
)} {/* 错误提示 */} {loadError && ( -
-
- - 图片加载失败 +
+
+ + 图片加载失败
)} - {/* 缩略图占位 (isFullLoaded 为 false 时显示) */} + {/* 缩略图占位 */} {!isFullLoaded && placeholderUrl && !loadError && ( {localPhoto.fileName} )} - {/* 原图 - 始终渲染,通过 opacity 控制显示 */} + {/* 原图 */} {fullImageUrl && ( {localPhoto.fileName} e.stopPropagation()} onMouseDown={handleMouseDown} @@ -513,206 +733,164 @@ const PhotoViewer = memo(function PhotoViewer({ )}
- {/* 底部评分 - 优化样式 */} -
-
- {[1, 2, 3, 4, 5].map((star) => ( - - ))} -
+ /> + + ))}
- {/* 信息面板 - 侧边栏 */} + {/* 信息面板 */} {showInfo && (
e.stopPropagation()} > -
-

照片信息

- + />
-
- {/* 标签 */} -
-

标签

- -
- - {/* 基本信息 */} -
-

基本信息

-
-
-
文件名
-
- {localPhoto.fileName} -
-
-
-
格式
-
{localPhoto.format || '未知'}
-
-
-
尺寸
-
- {localPhoto.width && localPhoto.height - ? `${localPhoto.width} × ${localPhoto.height}` - : '未知'} -
+ {/* 标签 */} +
+
标签
+ +
+ + {/* 基本信息 */} +
+
基本信息
+
+ 文件名 + {localPhoto.fileName} +
+
+ 格式 + {localPhoto.format || '未知'} +
+
+ 尺寸 + + {localPhoto.width && localPhoto.height + ? `${localPhoto.width} x ${localPhoto.height}` + : '未知'} + +
+
+ 大小 + {formatFileSize(localPhoto.fileSize)} +
+
+ + {/* 日期信息 */} +
+
日期
+ {localPhoto.dateTaken && ( +
+ 拍摄时间 + + {format(new Date(localPhoto.dateTaken), 'yyyy-MM-dd HH:mm')} + +
+ )} +
+ 添加时间 + + {format(new Date(localPhoto.dateAdded), 'yyyy-MM-dd HH:mm')} + +
+
+ + {/* 相机信息 */} + {(localPhoto.cameraModel || localPhoto.lensModel) && ( +
+
相机
+ {localPhoto.cameraModel && ( +
+ 相机 + {localPhoto.cameraModel}
-
-
大小
-
{formatFileSize(localPhoto.fileSize)}
+ )} + {localPhoto.lensModel && ( +
+ 镜头 + {localPhoto.lensModel}
-
-
- - {/* 日期信息 */} -
-

日期

-
- {localPhoto.dateTaken && ( -
-
拍摄时间
-
- {format(new Date(localPhoto.dateTaken), 'yyyy-MM-dd HH:mm')} -
+ )} +
+ )} + + {/* 拍摄参数 */} + {(localPhoto.focalLength || localPhoto.aperture || localPhoto.iso || localPhoto.shutterSpeed) && ( +
+
拍摄参数
+
+ {localPhoto.focalLength && ( +
+ 焦距 + {localPhoto.focalLength}mm
)} -
-
添加时间
-
- {format(new Date(localPhoto.dateAdded), 'yyyy-MM-dd HH:mm')} -
-
-
-
- - {/* 相机信息 */} - {(localPhoto.cameraModel || localPhoto.lensModel) && ( -
-

相机

-
- {localPhoto.cameraModel && ( -
-
相机
-
{localPhoto.cameraModel}
-
- )} - {localPhoto.lensModel && ( -
-
镜头
-
- {localPhoto.lensModel} -
-
- )} -
-
- )} - - {/* 拍摄参数 */} - {(localPhoto.focalLength || localPhoto.aperture || localPhoto.iso || localPhoto.shutterSpeed) && ( -
-

拍摄参数

-
- {localPhoto.focalLength && ( -
- -
焦距
-
{localPhoto.focalLength}mm
-
- )} - {localPhoto.aperture && ( -
- -
光圈
-
f/{localPhoto.aperture}
-
- )} - {localPhoto.shutterSpeed && ( -
- -
快门
-
{localPhoto.shutterSpeed}
-
- )} - {localPhoto.iso && ( -
- -
ISO
-
{localPhoto.iso}
-
- )} -
-
- )} - - {/* GPS 信息 */} - {localPhoto.gpsLatitude && localPhoto.gpsLongitude && ( -
-

位置

-
-
-
纬度
-
{localPhoto.gpsLatitude.toFixed(6)}
+ {localPhoto.aperture && ( +
+ 光圈 + f/{localPhoto.aperture} +
+ )} + {localPhoto.shutterSpeed && ( +
+ 快门 + {localPhoto.shutterSpeed}
-
-
经度
-
{localPhoto.gpsLongitude.toFixed(6)}
+ )} + {localPhoto.iso && ( +
+ ISO + {localPhoto.iso}
-
-
- )} + )} +
+
+ )} + + {/* GPS 信息 */} + {localPhoto.gpsLatitude && localPhoto.gpsLongitude && ( +
+
位置
+
+ 纬度 + {localPhoto.gpsLatitude.toFixed(6)} +
+
+ 经度 + {localPhoto.gpsLongitude.toFixed(6)} +
+
+ )} - {/* 文件路径 */} -
-

文件路径

-

{localPhoto.filePath}

-
+ {/* 文件路径 */} +
+
文件路径
+

{localPhoto.filePath}

)} - - {/* 照片编辑器 */} - {showEditor && ( - { - stopEditing(); - setShowEditor(false); - }} - onSave={(updatedPhoto) => { - setLocalPhoto(updatedPhoto); - onPhotoUpdate?.(updatedPhoto); - }} - /> - )}
, document.body ); diff --git a/src/components/preview/MetadataPanel.tsx b/src/components/preview/MetadataPanel.tsx new file mode 100644 index 0000000..00725c9 --- /dev/null +++ b/src/components/preview/MetadataPanel.tsx @@ -0,0 +1,226 @@ +/** + * MetadataPanel - 照片元数据展示面板 + * + * 展示选中照片的详细元数据信息,包括基本信息、日期、相机参数、GPS 等。 + * 复用现有 Photo 类型和标签查询接口。 + */ + +import { memo } from 'react'; +import { format } from 'date-fns'; +import { makeStyles, tokens } from '@fluentui/react-components'; +import { TagSelector } from '@/components/tag'; +import type { Photo } from '@/types'; + +const useStyles = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + gap: '16px', + fontSize: tokens.fontSizeBase200, + }, + section: { + display: 'flex', + flexDirection: 'column', + gap: '6px', + }, + sectionTitle: { + fontSize: tokens.fontSizeBase200, + fontWeight: tokens.fontWeightSemibold, + color: tokens.colorNeutralForeground1, + paddingBottom: '4px', + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + marginBottom: '2px', + }, + row: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'baseline', + }, + label: { + color: tokens.colorNeutralForeground2, + flexShrink: 0, + }, + value: { + color: tokens.colorNeutralForeground1, + fontWeight: tokens.fontWeightSemibold, + maxWidth: '140px', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + textAlign: 'right', + }, + paramGrid: { + display: 'grid', + gridTemplateColumns: '1fr 1fr', + gap: '6px', + }, + paramCard: { + backgroundColor: tokens.colorNeutralBackground1, + borderRadius: tokens.borderRadiusMedium, + padding: '6px', + textAlign: 'center', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '2px', + }, + paramLabel: { + fontSize: tokens.fontSizeBase100, + color: tokens.colorNeutralForeground2, + }, + paramValue: { + fontSize: tokens.fontSizeBase200, + fontWeight: tokens.fontWeightBold, + color: tokens.colorNeutralForeground1, + }, + filePath: { + wordBreak: 'break-all', + fontSize: tokens.fontSizeBase100, + color: tokens.colorNeutralForeground2, + backgroundColor: tokens.colorNeutralBackground1, + padding: '6px', + borderRadius: tokens.borderRadiusMedium, + userSelect: 'text', + }, +}); + +interface MetadataPanelProps { + photo: Photo; +} + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export const MetadataPanel = memo(function MetadataPanel({ photo }: MetadataPanelProps) { + const styles = useStyles(); + + return ( +
+ {/* 标签 */} +
+
标签
+ +
+ + {/* 基本信息 */} +
+
基本信息
+
+ 文件名 + {photo.fileName} +
+
+ 格式 + {photo.format || '未知'} +
+
+ 尺寸 + + {photo.width && photo.height ? `${photo.width} x ${photo.height}` : '未知'} + +
+
+ 大小 + {formatFileSize(photo.fileSize)} +
+
+ + {/* 日期 */} +
+
日期
+ {photo.dateTaken && ( +
+ 拍摄时间 + + {format(new Date(photo.dateTaken), 'yyyy-MM-dd HH:mm')} + +
+ )} +
+ 添加时间 + + {format(new Date(photo.dateAdded), 'yyyy-MM-dd HH:mm')} + +
+
+ + {/* 相机 */} + {(photo.cameraModel || photo.lensModel) && ( +
+
相机
+ {photo.cameraModel && ( +
+ 相机 + {photo.cameraModel} +
+ )} + {photo.lensModel && ( +
+ 镜头 + {photo.lensModel} +
+ )} +
+ )} + + {/* 拍摄参数 */} + {(photo.focalLength || photo.aperture || photo.iso || photo.shutterSpeed) && ( +
+
拍摄参数
+
+ {photo.focalLength && ( +
+ 焦距 + {photo.focalLength}mm +
+ )} + {photo.aperture && ( +
+ 光圈 + f/{photo.aperture} +
+ )} + {photo.shutterSpeed && ( +
+ 快门 + {photo.shutterSpeed} +
+ )} + {photo.iso && ( +
+ ISO + {photo.iso} +
+ )} +
+
+ )} + + {/* GPS */} + {photo.gpsLatitude && photo.gpsLongitude && ( +
+
位置
+
+ 纬度 + {photo.gpsLatitude.toFixed(6)} +
+
+ 经度 + {photo.gpsLongitude.toFixed(6)} +
+
+ )} + + {/* 文件路径 */} +
+
文件路径
+

{photo.filePath}

+
+
+ ); +}); + +export default MetadataPanel; diff --git a/src/components/preview/PreviewPane.tsx b/src/components/preview/PreviewPane.tsx new file mode 100644 index 0000000..47bae03 --- /dev/null +++ b/src/components/preview/PreviewPane.tsx @@ -0,0 +1,387 @@ +/** + * PreviewPane - 右侧预览窗格 + * + * 显示当前选中照片的预览缩略图和元数据。 + * 支持拖拽调整宽度,开关和宽度状态写回 shell 持久化。 + * 复用 useThumbnail 和现有照片查询接口。 + * + * photos prop 可选:当提供时从中查找选中照片, + * 否则通过 getPhoto() API 按 ID 获取。 + */ + +import React, { memo, useCallback, useRef, useEffect, useState } from 'react'; +import { makeStyles, tokens, mergeClasses } from '@fluentui/react-components'; +import { + Image24Regular, + Star24Filled, + Star24Regular, + Heart24Filled, + Heart24Regular, +} from '@fluentui/react-icons'; +import { useThumbnail } from '@/hooks/useThumbnail'; +import { useSelectionStore } from '@/stores/selectionStore'; +import { useNavigationStore } from '@/stores/navigationStore'; +import { setPhotoRating, setPhotoFavorite, getPhoto } from '@/services/api'; +import { MetadataPanel } from './MetadataPanel'; +import type { Photo } from '@/types'; + +const MIN_WIDTH = 200; +const MAX_WIDTH = 480; + +const useStyles = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + height: '100%', + overflow: 'hidden', + backgroundColor: tokens.colorNeutralBackground2, + borderLeft: `1px solid ${tokens.colorNeutralStroke2}`, + position: 'relative', + }, + resizeHandle: { + position: 'absolute', + left: 0, + top: 0, + bottom: 0, + width: '4px', + cursor: 'col-resize', + zIndex: 10, + ':hover': { + backgroundColor: tokens.colorBrandBackground, + }, + }, + resizeHandleActive: { + backgroundColor: tokens.colorBrandBackground, + }, + content: { + flex: 1, + overflowY: 'auto', + overflowX: 'hidden', + padding: '16px', + display: 'flex', + flexDirection: 'column', + gap: '16px', + }, + emptyState: { + flex: 1, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: '8px', + color: tokens.colorNeutralForeground3, + fontSize: tokens.fontSizeBase200, + padding: '24px', + textAlign: 'center', + }, + emptyIcon: { + fontSize: '32px', + color: tokens.colorNeutralForeground4, + marginBottom: '4px', + }, + previewImage: { + width: '100%', + borderRadius: tokens.borderRadiusMedium, + objectFit: 'contain', + maxHeight: '280px', + backgroundColor: tokens.colorNeutralBackground1, + }, + previewPlaceholder: { + width: '100%', + height: '180px', + borderRadius: tokens.borderRadiusMedium, + backgroundColor: tokens.colorNeutralBackground1, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: tokens.colorNeutralForeground4, + }, + fileName: { + fontSize: tokens.fontSizeBase300, + fontWeight: tokens.fontWeightSemibold, + color: tokens.colorNeutralForeground1, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + quickActions: { + display: 'flex', + alignItems: 'center', + gap: '2px', + }, + starButton: { + background: 'none', + border: 'none', + padding: '2px', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: tokens.borderRadiusSmall, + ':hover': { + backgroundColor: tokens.colorNeutralBackground1Hover, + }, + }, + favoriteButton: { + background: 'none', + border: 'none', + padding: '4px', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: tokens.borderRadiusSmall, + marginLeft: '8px', + ':hover': { + backgroundColor: tokens.colorNeutralBackground1Hover, + }, + }, + multiSelectInfo: { + flex: 1, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: '8px', + color: tokens.colorNeutralForeground2, + fontSize: tokens.fontSizeBase200, + padding: '24px', + textAlign: 'center', + }, +}); + +interface PreviewPaneProps { + /** 当前可用的照片列表(可选,用于根据 selection 查找照片) */ + photos?: Photo[]; +} + +/** 预览图子组件 */ +const PreviewImage = memo(function PreviewImage({ photo }: { photo: Photo }) { + const styles = useStyles(); + const { thumbnailUrl, isLoading } = useThumbnail( + photo.filePath, + photo.fileHash, + { size: 'large', enabled: true }, + ); + + if (isLoading || !thumbnailUrl) { + return ( +
+ +
+ ); + } + + return ( + {photo.fileName} + ); +}); + +export const PreviewPane = memo(function PreviewPane({ photos = [] }: PreviewPaneProps) { + const styles = useStyles(); + + // Shell state + const previewPaneWidth = useNavigationStore((s) => s.previewPaneWidth); + const setPreviewPaneWidth = useNavigationStore((s) => s.setPreviewPaneWidth); + + // Selection state + const selectedIds = useSelectionStore((s) => s.selectedIds); + const selectedCount = selectedIds.size; + + // Find selected photo from provided list + const selectedPhotoFromList = + selectedCount === 1 + ? (photos.find((p) => selectedIds.has(p.photoId)) ?? null) + : null; + + // Fetch photo by ID when not found in provided list + const [fetchedPhoto, setFetchedPhoto] = useState(null); + const selectedId = selectedCount === 1 ? [...selectedIds][0] : null; + + useEffect(() => { + if (selectedId === null || selectedPhotoFromList) { + setFetchedPhoto(null); + return; + } + let cancelled = false; + getPhoto(selectedId) + .then((photo) => { + if (!cancelled) setFetchedPhoto(photo); + }) + .catch(() => { + if (!cancelled) setFetchedPhoto(null); + }); + return () => { + cancelled = true; + }; + }, [selectedId, selectedPhotoFromList]); + + const resolvedPhoto = selectedPhotoFromList ?? fetchedPhoto; + + // Local photo state for optimistic updates + const [localPhoto, setLocalPhoto] = useState(null); + useEffect(() => { + setLocalPhoto(resolvedPhoto); + }, [resolvedPhoto]); + + // Resize drag state + const [isResizing, setIsResizing] = useState(false); + const resizeRef = useRef<{ startX: number; startWidth: number } | null>(null); + + const handleResizeStart = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + setIsResizing(true); + resizeRef.current = { startX: e.clientX, startWidth: previewPaneWidth }; + }, + [previewPaneWidth], + ); + + useEffect(() => { + if (!isResizing) return; + + const handleMouseMove = (e: MouseEvent) => { + if (!resizeRef.current) return; + // Dragging left edge: moving left increases width + const delta = resizeRef.current.startX - e.clientX; + const newWidth = Math.min( + MAX_WIDTH, + Math.max(MIN_WIDTH, resizeRef.current.startWidth + delta), + ); + setPreviewPaneWidth(newWidth); + }; + + const handleMouseUp = () => { + setIsResizing(false); + resizeRef.current = null; + }; + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + }, [isResizing, setPreviewPaneWidth]); + + // Rating handler + const handleRating = useCallback( + async (rating: number) => { + if (!localPhoto) return; + try { + const newRating = localPhoto.rating === rating ? 0 : rating; + await setPhotoRating(localPhoto.photoId, newRating); + setLocalPhoto({ ...localPhoto, rating: newRating }); + } catch (error) { + console.error('设置评分失败:', error); + } + }, + [localPhoto], + ); + + // Favorite handler + const handleToggleFavorite = useCallback(async () => { + if (!localPhoto) return; + try { + const newValue = !localPhoto.isFavorite; + await setPhotoFavorite(localPhoto.photoId, newValue); + setLocalPhoto({ ...localPhoto, isFavorite: newValue }); + } catch (error) { + console.error('设置收藏失败:', error); + } + }, [localPhoto]); + + return ( +
+ {/* 拖拽调整宽度手柄 */} +
+ + {/* 无选中 */} + {selectedCount === 0 && ( +
+ + 选择一张照片以查看详情 +
+ )} + + {/* 多选 */} + {selectedCount > 1 && ( +
+ 已选择 {selectedCount} 张照片 +
+ )} + + {/* 单选预览 */} + {selectedCount === 1 && localPhoto && ( +
+ {/* 预览图 */} + + + {/* 文件名 */} +
+ {localPhoto.fileName} +
+ + {/* 快捷操作:评分 + 收藏 */} +
+ {[1, 2, 3, 4, 5].map((star) => ( + + ))} + +
+ + {/* 元数据面板 */} + +
+ )} +
+ ); +}); + +export default PreviewPane; diff --git a/src/components/shell/AppShell.tsx b/src/components/shell/AppShell.tsx new file mode 100644 index 0000000..756fbd8 --- /dev/null +++ b/src/components/shell/AppShell.tsx @@ -0,0 +1,201 @@ +import { + useCallback, + lazy, + Suspense, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useLocation } from 'react-router-dom'; +import { makeStyles, tokens, Spinner } from '@fluentui/react-components'; +import TitleBar from './TitleBar'; +import CommandBar from './CommandBar'; +import BreadcrumbBar from './BreadcrumbBar'; +import StatusBar from './StatusBar'; +import { NavigationPane } from '@/components/navigation'; +import { PreviewPane } from '@/components/preview/PreviewPane'; +import { useNavigationStore } from '@/stores/navigationStore'; +import { getNodeRoot, getRouteNode } from './navigation'; + +const SettingsPage = lazy(() => import('@/pages/SettingsPage')); +const PhotosContent = lazy(() => import('@/components/content/PhotosContent')); +const AlbumsContent = lazy(() => import('@/components/content/AlbumsContent')); +const TagsContent = lazy(() => import('@/components/content/TagsContent')); +const FavoritesContent = lazy(() => import('@/components/content/FavoritesContent')); +const TrashContent = lazy(() => import('@/components/content/TrashContent')); +const FoldersContent = lazy(() => import('@/components/content/FoldersContent')); + +const useStyles = makeStyles({ + shell: { + display: 'flex', + flexDirection: 'column', + height: '100vh', + width: '100vw', + overflow: 'hidden', + backgroundColor: tokens.colorNeutralBackground1, + color: tokens.colorNeutralForeground1, + }, + mainArea: { + display: 'flex', + flex: 1, + minHeight: 0, + overflow: 'hidden', + }, + contentWrapper: { + flex: 1, + minWidth: 0, + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + }, + settingsWrapper: { + flex: 1, + minWidth: 0, + overflow: 'auto', + display: 'flex', + }, + spinnerContainer: { + flex: 1, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, +}); + +interface ShellContentProps { + activeNode: string; + onTotalCountChange: (count: number) => void; +} + +function ShellContent({ activeNode, onTotalCountChange }: ShellContentProps) { + const styles = useStyles(); + + const content = useMemo(() => { + if (activeNode === 'settings') { + onTotalCountChange(0); + return ( +
+ +
+ ); + } + if (activeNode === 'albums' || activeNode.startsWith('album:')) { + return ; + } + if (activeNode === 'tags' || activeNode.startsWith('tag:')) { + return ; + } + if (activeNode === 'favorites') { + return ; + } + if (activeNode === 'trash') { + return ; + } + if (activeNode === 'folders' || activeNode.startsWith('folder:')) { + return ; + } + return ; + }, [activeNode, onTotalCountChange, styles.settingsWrapper]); + + return ( +
+ + +
+ } + > + {content} + +
+ ); +} + +export default function AppShell() { + const styles = useStyles(); + const location = useLocation(); + const activeNode = useNavigationStore((s) => s.activeNode); + const navWidth = useNavigationStore((s) => s.navPaneWidth); + const setNavWidth = useNavigationStore((s) => s.setNavPaneWidth); + const viewMode = useNavigationStore((s) => s.viewMode); + const setViewMode = useNavigationStore((s) => s.setViewMode); + const sortBy = useNavigationStore((s) => s.sortBy); + const setSortBy = useNavigationStore((s) => s.setSortBy); + const sortOrder = useNavigationStore((s) => s.sortOrder); + const setSortOrder = useNavigationStore((s) => s.setSortOrder); + const previewPaneOpen = useNavigationStore((s) => s.previewPaneOpen); + const setPreviewPaneOpen = useNavigationStore((s) => s.setPreviewPaneOpen); + const setActiveNode = useNavigationStore((s) => s.setActiveNode); + const [totalCount, setTotalCount] = useState(0); + const lastPathnameRef = useRef(null); + + const routeNode = useMemo( + () => getRouteNode(location.pathname), + [location.pathname], + ); + + const routeChanged = lastPathnameRef.current !== location.pathname; + + const effectiveNode = useMemo( + () => { + if (routeChanged) { + return routeNode; + } + + return getNodeRoot(activeNode) === routeNode ? activeNode : routeNode; + }, + [activeNode, routeChanged, routeNode], + ); + + useEffect(() => { + if (effectiveNode !== activeNode) { + setActiveNode(effectiveNode); + } + lastPathnameRef.current = location.pathname; + }, [activeNode, effectiveNode, location.pathname, setActiveNode]); + + useEffect(() => { + setTotalCount(0); + }, [effectiveNode]); + + const handleNavWidthChange = useCallback((w: number) => { + setNavWidth(w); + }, [setNavWidth]); + + return ( +
+ {/* TitleBar */} + + + {/* CommandBar */} + setPreviewPaneOpen(!previewPaneOpen)} + /> + + {/* BreadcrumbBar */} + + + {/* MainArea: NavPane + Content + PreviewPane */} +
+ + + {previewPaneOpen && } +
+ + {/* StatusBar */} + +
+ ); +} diff --git a/src/components/shell/BreadcrumbBar.tsx b/src/components/shell/BreadcrumbBar.tsx new file mode 100644 index 0000000..4cb8f7d --- /dev/null +++ b/src/components/shell/BreadcrumbBar.tsx @@ -0,0 +1,163 @@ +import { useMemo, useCallback, type ReactElement } from 'react'; +import { + makeStyles, + tokens, + Breadcrumb, + BreadcrumbItem, + BreadcrumbButton, + BreadcrumbDivider, +} from '@fluentui/react-components'; +import { + HomeRegular, + FolderRegular, + AlbumRegular, + TagRegular, + HeartRegular, + DeleteRegular, + SettingsRegular, + ImageRegular, +} from '@fluentui/react-icons'; +import { useNavigationStore } from '@/stores/navigationStore'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { getNodePath, getNodeRoot, getRouteNode } from './navigation'; + +interface BreadcrumbSegment { + key: string; + label: string; + icon?: ReactElement; + /** Navigation node to set when clicked, or null for non-navigable */ + node: string | null; + /** Route path to navigate to */ + path?: string; +} + +const NODE_LABELS: Record = { + photos: { label: '照片', icon: , path: '/' }, + folders: { label: '文件夹', icon: , path: '/folders' }, + albums: { label: '相册', icon: , path: '/albums' }, + tags: { label: '标签', icon: , path: '/tags' }, + favorites: { label: '收藏', icon: , path: '/favorites' }, + trash: { label: '废纸篓', icon: , path: '/trash' }, + settings: { label: '设置', icon: , path: '/settings' }, +}; + +/** Build breadcrumb segments from activeNode */ +function buildSegments(activeNode: string): BreadcrumbSegment[] { + // Root-level nodes + if (activeNode in NODE_LABELS) { + const info = NODE_LABELS[activeNode]; + return [{ key: activeNode, label: info.label, icon: info.icon, node: activeNode, path: info.path }]; + } + + // folder: + if (activeNode.startsWith('folder:')) { + const folderPath = activeNode.slice('folder:'.length); + const segments: BreadcrumbSegment[] = [ + { key: 'folders', label: '文件夹', icon: , node: 'folders', path: '/folders' }, + ]; + // Split folder path into parts + const parts = folderPath.replace(/\\/g, '/').split('/').filter(Boolean); + let accumulated = ''; + for (let i = 0; i < parts.length; i++) { + accumulated += (i === 0 ? '' : '/') + parts[i]; + // Restore Windows drive letter format (e.g., "D:" from "D:") + const displayPart = parts[i]; + const nodeValue = `folder:${accumulated}`; + segments.push({ + key: nodeValue, + label: displayPart, + icon: i === 0 ? undefined : , + node: nodeValue, + path: '/folders', + }); + } + return segments; + } + + // album: + if (activeNode.startsWith('album:')) { + const albumId = activeNode.slice('album:'.length); + return [ + { key: 'albums', label: '相册', icon: , node: 'albums', path: '/albums' }, + { key: activeNode, label: `相册 ${albumId}`, node: null }, + ]; + } + + // tag: + if (activeNode.startsWith('tag:')) { + const tagId = activeNode.slice('tag:'.length); + return [ + { key: 'tags', label: '标签', icon: , node: 'tags', path: '/tags' }, + { key: activeNode, label: `标签 ${tagId}`, node: null }, + ]; + } + + // Fallback + return [{ key: 'photos', label: '照片', icon: , node: 'photos', path: '/' }]; +} + +const useStyles = makeStyles({ + bar: { + display: 'flex', + alignItems: 'center', + height: '32px', + flexShrink: 0, + paddingLeft: '8px', + paddingRight: '12px', + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + backgroundColor: 'transparent', + }, +}); + +export default function BreadcrumbBar() { + const styles = useStyles(); + const activeNode = useNavigationStore((s) => s.activeNode); + const setActiveNode = useNavigationStore((s) => s.setActiveNode); + const location = useLocation(); + const navigate = useNavigate(); + + // URL takes priority: derive effective node from URL if it differs + const effectiveNode = useMemo(() => { + const urlNode = getRouteNode(location.pathname); + if (urlNode !== getNodeRoot(activeNode)) { + return urlNode; + } + return activeNode; + }, [activeNode, location.pathname]); + + const segments = useMemo(() => buildSegments(effectiveNode), [effectiveNode]); + + const handleClick = useCallback( + (segment: BreadcrumbSegment) => { + if (segment.node) { + setActiveNode(segment.node); + navigate(segment.path ?? getNodePath(segment.node)); + } + }, + [setActiveNode, navigate] + ); + + return ( +
+ + {segments.map((seg, i) => { + const isLast = i === segments.length - 1; + return ( + + {i > 0 && } + + handleClick(seg)} + > + {seg.label} + + + + ); + })} + +
+ ); +} diff --git a/src/components/shell/CommandBar.tsx b/src/components/shell/CommandBar.tsx new file mode 100644 index 0000000..6e04ec3 --- /dev/null +++ b/src/components/shell/CommandBar.tsx @@ -0,0 +1,306 @@ +import { + makeStyles, + tokens, + Toolbar, + ToolbarButton, + ToolbarDivider, + Menu, + MenuTrigger, + MenuPopover, + MenuList, + MenuItem, + MenuItemRadio, + MenuDivider, + Overflow, + OverflowItem, + useOverflowMenu, + useIsOverflowItemVisible, + Button, + Tooltip, +} from '@fluentui/react-components'; +import type { MenuProps } from '@fluentui/react-components'; +import { + GridRegular, + GridFilled, + ListRegular, + TableRegular, + AppsFilled, + ArrowSortRegular, + PanelRightRegular, + PanelRightFilled, + AlbumAddRegular, + MoreHorizontalRegular, + TextSortAscendingRegular, + TextSortDescendingRegular, + SearchRegular, + bundleIcon, +} from '@fluentui/react-icons'; +import { useCallback, useMemo, useState } from 'react'; +import FilterPanel from '@/components/content/FilterPanel'; +import { SearchPanel } from '@/components/search'; + +const PanelRight = bundleIcon(PanelRightFilled, PanelRightRegular); + +type ShellViewMode = 'large' | 'medium' | 'small' | 'list' | 'detail' | 'tile'; +type ShellSortBy = 'dateTaken' | 'dateAdded' | 'fileName' | 'fileSize' | 'rating'; +type ShellSortOrder = 'asc' | 'desc'; + +interface CommandBarProps { + viewMode?: ShellViewMode; + sortBy?: ShellSortBy; + sortOrder?: ShellSortOrder; + previewPaneOpen?: boolean; + onViewModeChange?: (mode: ShellViewMode) => void; + onSortByChange?: (sortBy: ShellSortBy) => void; + onSortOrderChange?: (order: ShellSortOrder) => void; + onPreviewPaneToggle?: () => void; + onNewAlbum?: () => void; +} + +const VIEW_MODE_LABELS: Record = { + large: '大图标', + medium: '中图标', + small: '小图标', + list: '列表', + detail: '详细信息', + tile: '平铺', +}; + +const useStyles = makeStyles({ + commandBar: { + display: 'flex', + alignItems: 'center', + height: '40px', + flexShrink: 0, + paddingLeft: '8px', + paddingRight: '8px', + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + backgroundColor: 'transparent', + gap: '2px', + }, + toolbar: { + flex: 1, + minWidth: 0, + }, + overflowMenu: { + minWidth: 0, + }, + searchButton: { + marginLeft: 'auto', + flexShrink: 0, + }, +}); + +function OverflowMenuItem(props: { id: string; children: string }) { + const isVisible = useIsOverflowItemVisible(props.id); + if (isVisible) return null; + return {props.children}; +} + +function OverflowMenuButton() { + const { ref, isOverflowing } = useOverflowMenu(); + if (!isOverflowing) return null; + return ( + + + + + ); +} + +export default function CommandBar({ + viewMode = 'large', + sortBy = 'dateAdded', + sortOrder = 'desc', + previewPaneOpen = false, + onViewModeChange, + onSortByChange, + onSortOrderChange, + onPreviewPaneToggle, + onNewAlbum, +}: CommandBarProps) { + const styles = useStyles(); + + const [filterOpen, setFilterOpen] = useState(false); + const [searchOpen, setSearchOpen] = useState(false); + + const viewMenuChecked = useMemo>( + () => ({ viewMode: [viewMode] }), + [viewMode], + ); + + const sortMenuChecked = useMemo>( + () => ({ + sortBy: [sortBy], + sortOrder: [sortOrder], + }), + [sortBy, sortOrder], + ); + + const handleViewModeChange: MenuProps['onCheckedValueChange'] = useCallback( + (_e: unknown, data: { name: string; checkedItems: string[] }) => { + if (data.name === 'viewMode' && data.checkedItems.length > 0) { + onViewModeChange?.(data.checkedItems[0] as ShellViewMode); + } + }, + [onViewModeChange] + ); + + const handleSortChange: MenuProps['onCheckedValueChange'] = useCallback( + (_e: unknown, data: { name: string; checkedItems: string[] }) => { + if (data.name === 'sortBy' && data.checkedItems.length > 0) { + onSortByChange?.(data.checkedItems[0] as ShellSortBy); + } + if (data.name === 'sortOrder' && data.checkedItems.length > 0) { + onSortOrderChange?.(data.checkedItems[0] as ShellSortOrder); + } + }, + [onSortByChange, onSortOrderChange] + ); + + return ( +
+ + + {/* New Album */} + + } + onClick={onNewAlbum} + > + 新建相册 + + + + + + {/* View Mode */} + + + }> + {VIEW_MODE_LABELS[viewMode]} + + + + + }> + 大图标 + + }> + 中图标 + + }> + 小图标 + + }> + 列表 + + }> + 详细信息 + + }> + 平铺 + + + + + + {/* Sort */} + + + }> + 排序 + + + + + + 名称 + + + 拍摄日期 + + + 添加日期 + + + 大小 + + + 评分 + + + } + > + 升序 + + } + > + 降序 + + + + + + {/* Filter Panel */} + + + + + {/* Preview Pane Toggle */} + + } + onClick={onPreviewPaneToggle} + appearance={previewPaneOpen ? 'primary' : 'subtle'} + /> + + + + + + + {/* Search Button - right aligned */} + +
+ ); +} diff --git a/src/components/shell/StatusBar.tsx b/src/components/shell/StatusBar.tsx new file mode 100644 index 0000000..408c6e1 --- /dev/null +++ b/src/components/shell/StatusBar.tsx @@ -0,0 +1,83 @@ +import { + makeStyles, + tokens, +} from '@fluentui/react-components'; +import { + GridRegular, + ListRegular, + TableRegular, + AppsFilled, +} from '@fluentui/react-icons'; +import { useNavigationStore } from '@/stores/navigationStore'; +import { useSelectionStore } from '@/stores/selectionStore'; +import type { ReactNode } from 'react'; + +const VIEW_MODE_INFO: Record = { + large: { label: '大图标', icon: }, + medium: { label: '中图标', icon: }, + small: { label: '小图标', icon: }, + list: { label: '列表', icon: }, + detail: { label: '详细信息', icon: }, + tile: { label: '平铺', icon: }, +}; + +interface StatusBarProps { + /** Total item count in current view */ + totalCount?: number; +} + +const useStyles = makeStyles({ + bar: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + height: '24px', + flexShrink: 0, + paddingLeft: '12px', + paddingRight: '12px', + borderTop: `1px solid ${tokens.colorNeutralStroke2}`, + fontSize: '12px', + color: tokens.colorNeutralForeground3, + backgroundColor: 'transparent', + userSelect: 'none', + }, + left: { + display: 'flex', + alignItems: 'center', + gap: '12px', + }, + right: { + display: 'flex', + alignItems: 'center', + gap: '4px', + }, + separator: { + color: tokens.colorNeutralStroke2, + }, +}); + +export default function StatusBar({ totalCount = 0 }: StatusBarProps) { + const styles = useStyles(); + const viewMode = useNavigationStore((s) => s.viewMode); + const selectedCount = useSelectionStore((s) => s.selectedIds.size); + + const viewInfo = VIEW_MODE_INFO[viewMode] ?? VIEW_MODE_INFO.large; + + return ( +
+
+ {totalCount} 个项目 + {selectedCount > 0 && ( + <> + | + 已选择 {selectedCount} 个 + + )} +
+
+ {viewInfo.icon} + {viewInfo.label} +
+
+ ); +} diff --git a/src/components/shell/TitleBar.tsx b/src/components/shell/TitleBar.tsx new file mode 100644 index 0000000..ffb288a --- /dev/null +++ b/src/components/shell/TitleBar.tsx @@ -0,0 +1,176 @@ +import { getCurrentWindow } from '@tauri-apps/api/window'; +import { isTauri } from '@tauri-apps/api/core'; +import { + makeStyles, + tokens, + Button, + Tooltip, +} from '@fluentui/react-components'; +import { + SubtractRegular, + MaximizeRegular, + DismissRegular, + SquareMultipleRegular, +} from '@fluentui/react-icons'; +import { useState, useEffect, useCallback } from 'react'; + +const useStyles = makeStyles({ + titleBar: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + height: '32px', + flexShrink: 0, + userSelect: 'none', + backgroundColor: 'transparent', + paddingLeft: '12px', + paddingRight: '0px', + }, + dragRegion: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + zIndex: 0, + }, + title: { + display: 'flex', + alignItems: 'center', + gap: '8px', + zIndex: 1, + pointerEvents: 'none', + fontSize: '12px', + fontWeight: 400, + color: tokens.colorNeutralForeground2, + }, + windowControls: { + display: 'flex', + alignItems: 'stretch', + zIndex: 1, + height: '32px', + }, + controlButton: { + minWidth: '46px', + height: '32px', + borderRadius: '0', + border: 'none', + backgroundColor: 'transparent', + color: tokens.colorNeutralForeground2, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + ':hover': { + backgroundColor: tokens.colorNeutralBackground1Hover, + color: tokens.colorNeutralForeground1, + }, + ':active': { + backgroundColor: tokens.colorNeutralBackground1Pressed, + }, + }, + closeButton: { + minWidth: '46px', + height: '32px', + borderRadius: '0', + border: 'none', + backgroundColor: 'transparent', + color: tokens.colorNeutralForeground2, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + ':hover': { + backgroundColor: '#c42b1c', + color: '#ffffff', + }, + ':active': { + backgroundColor: '#b4271a', + color: '#ffffff', + }, + }, +}); + +export default function TitleBar() { + const styles = useStyles(); + const [isMaximized, setIsMaximized] = useState(false); + const tauri = isTauri(); + + useEffect(() => { + if (!tauri) return; + + const appWindow = getCurrentWindow(); + let unlisten: (() => void) | undefined; + + const setup = async () => { + setIsMaximized(await appWindow.isMaximized()); + unlisten = await appWindow.onResized(async () => { + setIsMaximized(await appWindow.isMaximized()); + }); + }; + setup(); + + return () => { + unlisten?.(); + }; + }, [tauri]); + + const handleMinimize = useCallback(() => { + if (tauri) getCurrentWindow().minimize(); + }, [tauri]); + + const handleMaximize = useCallback(() => { + if (tauri) getCurrentWindow().toggleMaximize(); + }, [tauri]); + + const handleClose = useCallback(() => { + if (tauri) getCurrentWindow().close(); + }, [tauri]); + + return ( +
+
+ +
+ PhotoWall +
+ + {tauri && ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/shell/navigation.ts b/src/components/shell/navigation.ts new file mode 100644 index 0000000..5fc4c82 --- /dev/null +++ b/src/components/shell/navigation.ts @@ -0,0 +1,52 @@ +export type ShellRouteNode = + | 'photos' + | 'folders' + | 'albums' + | 'tags' + | 'favorites' + | 'trash' + | 'settings'; + +const ROOT_TO_PATH: Record = { + photos: '/', + folders: '/folders', + albums: '/albums', + tags: '/tags', + favorites: '/favorites', + trash: '/trash', + settings: '/settings', +}; + +export function getRouteNode(pathname: string): ShellRouteNode { + if (pathname === '/' || pathname === '') { + return 'photos'; + } + + const section = pathname.split('/').filter(Boolean)[0]; + + if (section === 'folders') return 'folders'; + if (section === 'albums') return 'albums'; + if (section === 'tags') return 'tags'; + if (section === 'favorites') return 'favorites'; + if (section === 'trash') return 'trash'; + if (section === 'settings') return 'settings'; + + return 'photos'; +} + +export function getNodeRoot(node: string): ShellRouteNode { + if (node === 'photos') return 'photos'; + if (node === 'folders' || node.startsWith('folder:')) return 'folders'; + if (node === 'albums' || node.startsWith('album:')) return 'albums'; + if (node === 'tags' || node.startsWith('tag:')) return 'tags'; + if (node === 'favorites') return 'favorites'; + if (node === 'trash') return 'trash'; + if (node === 'settings') return 'settings'; + + return 'photos'; +} + +export function getNodePath(node: string): string { + return ROOT_TO_PATH[getNodeRoot(node)]; +} + diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx deleted file mode 100644 index 0ea4eb9..0000000 --- a/src/components/sidebar/Sidebar.tsx +++ /dev/null @@ -1,243 +0,0 @@ -import { useNavigate, useLocation } from 'react-router-dom'; -import clsx from 'clsx'; -import { usePhotoStore } from '@/stores/photoStore'; - -// === Modern SVG Icons (JetBrains Style) === -// 采用线条风格,选中时加粗并变色,不再进行大面积填充 -const icons = { - photos: (active: boolean) => ( - - - - - - ), - albums: (active: boolean) => ( - - - - - ), - favorites: (active: boolean) => ( - - ), - folders: (active: boolean) => ( - // 更圆润的文件夹,类似 macOS - - ), - trash: (active: boolean) => ( - - - - - - - - ), - settings: (active: boolean) => ( - - - - - ) -}; - -interface IconProps { - name: keyof typeof icons; - active: boolean; - className?: string; -} - -const Icon = ({ name, active, className }: IconProps) => { - const iconRenderer = icons[name]; - if (!iconRenderer) return null; - - return ( - - {iconRenderer(active)} - - ); -}; - -interface NavItem { - id: string; - label: string; - iconName: keyof typeof icons; - path?: string; - count?: number; // Optional count for badge -} - -// 主要导航项 - 媒体库 -const libraryItems: NavItem[] = [ - { - id: 'photos', - label: '所有照片', - iconName: 'photos', - path: '/', - count: 0, - }, - { - id: 'albums', - label: '相册', - iconName: 'albums', - path: '/albums', - }, - { - id: 'favorites', - label: '收藏', - iconName: 'favorites', - path: '/favorites', - }, -]; - -// 次要导航项 -const secondaryItems: NavItem[] = [ - { - id: 'folders', - label: '文件夹', - iconName: 'folders', - path: '/folders', - }, - { - id: 'trash', - label: '回收站', - iconName: 'trash', - path: '/trash', - }, -]; - -/** - * 侧边栏组件 - Nucleo ���格 - */ -function Sidebar() { - const navigate = useNavigate(); - const location = useLocation(); - const totalCount = usePhotoStore(state => state.totalCount); - - const getActiveItem = () => { - const path = location.pathname; - - if (path === '/albums') return 'albums'; - if (path === '/tags') return 'tags'; - if (path === '/folders') return 'folders'; - if (path === '/favorites') return 'favorites'; - if (path === '/trash') return 'trash'; - if (path === '/settings') return 'settings'; - return 'photos'; - }; - - const activeItem = getActiveItem(); - - const handleNavigation = (item: NavItem) => { - if (item.path) { - navigate(item.path); - } - }; - - const renderNavItem = (item: NavItem) => { - const isActive = activeItem === item.id; - // Inject dynamic count for 'photos' - const displayCount = item.id === 'photos' ? totalCount : item.count; - - return ( - - ); - }; - - return ( -
- {/* 顶部导航 */} -
- - {/* 导航组 */} - -
- - {/* 底部设置 */} -
- -
-
- ); -} - -export default Sidebar; \ No newline at end of file diff --git a/src/components/sidebar/index.ts b/src/components/sidebar/index.ts deleted file mode 100644 index 871a4d9..0000000 --- a/src/components/sidebar/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as Sidebar } from './Sidebar'; diff --git a/src/hooks/usePhotoContextMenu.ts b/src/hooks/usePhotoContextMenu.ts new file mode 100644 index 0000000..19f1c05 --- /dev/null +++ b/src/hooks/usePhotoContextMenu.ts @@ -0,0 +1,84 @@ +/** + * Hook for managing photo context menu state and actions. + * + * Provides open/close state, positioning, and default action handlers + * (copy path, open folder, toggle favorite, soft delete). + */ + +import { useState, useCallback } from 'react'; +import type { MouseEvent } from 'react'; +import { revealItemInDir } from '@tauri-apps/plugin-opener'; +import { setPhotoFavorite, softDeletePhotos } from '@/services/api'; +import type { Photo } from '@/types'; +import type { PhotoContextMenuState } from '@/components/common/ContextMenu'; +import { INITIAL_CONTEXT_MENU } from '@/components/common/ContextMenu'; + +export interface UsePhotoContextMenuOptions { + /** Called after a photo is favorited/unfavorited so the caller can update its list */ + onPhotoUpdate?: (photo: Photo) => void; + /** Called after a photo is deleted so the caller can remove it from its list */ + onPhotoDeleted?: (photoId: number) => void; +} + +export function usePhotoContextMenu(options: UsePhotoContextMenuOptions = {}) { + const { onPhotoUpdate, onPhotoDeleted } = options; + const [menuState, setMenuState] = useState(INITIAL_CONTEXT_MENU); + + const openMenu = useCallback((photo: Photo, event: MouseEvent) => { + event.preventDefault(); + setMenuState({ open: true, x: event.clientX, y: event.clientY, photo }); + }, []); + + const closeMenu = useCallback(() => { + setMenuState(INITIAL_CONTEXT_MENU); + }, []); + + const handleCopyPath = useCallback(async (photo: Photo) => { + try { + await navigator.clipboard.writeText(photo.filePath); + } catch (e) { + console.warn('[ctx] copy path failed:', e); + } + closeMenu(); + }, [closeMenu]); + + const handleOpenFolder = useCallback(async (photo: Photo) => { + try { + await revealItemInDir(photo.filePath); + } catch (e) { + console.warn('[ctx] reveal in folder failed:', e); + } + closeMenu(); + }, [closeMenu]); + + const handleToggleFavorite = useCallback(async (photo: Photo) => { + try { + const newVal = !photo.isFavorite; + await setPhotoFavorite(photo.photoId, newVal); + onPhotoUpdate?.({ ...photo, isFavorite: newVal }); + } catch (e) { + console.warn('[ctx] toggle favorite failed:', e); + } + closeMenu(); + }, [closeMenu, onPhotoUpdate]); + + const handleDelete = useCallback(async (photo: Photo) => { + try { + await softDeletePhotos([photo.photoId]); + onPhotoDeleted?.(photo.photoId); + } catch (e) { + console.warn('[ctx] delete failed:', e); + } + closeMenu(); + }, [closeMenu, onPhotoDeleted]); + + return { + menuState, + openMenu, + closeMenu, + handleCopyPath, + handleOpenFolder, + handleToggleFavorite, + handleDelete, + }; +} diff --git a/src/hooks/useTheme.test.ts b/src/hooks/useTheme.test.ts index 19b4ffc..dd83d0b 100644 --- a/src/hooks/useTheme.test.ts +++ b/src/hooks/useTheme.test.ts @@ -1,18 +1,19 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { act, renderHook } from '@testing-library/react'; +import { renderHook, act } from '@testing-library/react'; import { useTheme } from './useTheme'; +import { useSettingsStore } from '@/stores/settingsStore'; describe('useTheme', () => { beforeEach(() => { - // 清理 localStorage - localStorage.clear(); - // 重置 DOM 类 document.documentElement.classList.remove('dark'); + // Reset store to defaults + useSettingsStore.setState({ theme: 'system' }); }); - it('should initialize with system theme', () => { + it('should initialize with system theme from settingsStore', () => { const { result } = renderHook(() => useTheme()); expect(result.current.theme).toBe('system'); + expect(['light', 'dark']).toContain(result.current.resolvedTheme); }); it('should set light theme', () => { @@ -24,7 +25,6 @@ describe('useTheme', () => { expect(result.current.theme).toBe('light'); expect(result.current.resolvedTheme).toBe('light'); - expect(document.documentElement.classList.contains('dark')).toBe(false); }); it('should set dark theme', () => { @@ -36,7 +36,6 @@ describe('useTheme', () => { expect(result.current.theme).toBe('dark'); expect(result.current.resolvedTheme).toBe('dark'); - expect(document.documentElement.classList.contains('dark')).toBe(true); }); it('should switch from light to dark', () => { @@ -45,12 +44,12 @@ describe('useTheme', () => { act(() => { result.current.setTheme('light'); }); - expect(document.documentElement.classList.contains('dark')).toBe(false); + expect(result.current.resolvedTheme).toBe('light'); act(() => { result.current.setTheme('dark'); }); - expect(document.documentElement.classList.contains('dark')).toBe(true); + expect(result.current.resolvedTheme).toBe('dark'); }); it('should resolve system theme', () => { @@ -61,22 +60,17 @@ describe('useTheme', () => { }); expect(result.current.theme).toBe('system'); - // resolvedTheme should be either 'light' or 'dark' expect(['light', 'dark']).toContain(result.current.resolvedTheme); }); - it('should persist theme preference', () => { - const { result, unmount } = renderHook(() => useTheme()); + it('should share state via settingsStore', () => { + const { result } = renderHook(() => useTheme()); act(() => { result.current.setTheme('dark'); }); - // 卸载 - unmount(); - - // 重新渲染,应该保持dark主题 - const { result: result2 } = renderHook(() => useTheme()); - expect(result2.current.theme).toBe('dark'); + // settingsStore should reflect the same theme + expect(useSettingsStore.getState().theme).toBe('dark'); }); }); diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts index 411fc25..ff7111b 100644 --- a/src/hooks/useTheme.ts +++ b/src/hooks/useTheme.ts @@ -1,35 +1,66 @@ /** - * 主题管理 Hook - 简化版(固定浅色主题) + * 主题管理 Hook + * + * 不再维护独立主题状态。 + * theme / resolvedTheme / setTheme 全部代理到 settingsStore.theme, + * 确保前端只有一个主题状态源。 */ -import { create } from 'zustand'; +import { useSyncExternalStore } from 'react'; +import { useSettingsStore } from '@/stores/settingsStore'; +import type { ThemeMode } from '@/types'; + +/** 解析当前实际主题(处理 system 模式) */ +function resolveTheme(mode: ThemeMode): 'light' | 'dark' { + if (mode === 'system') { + return typeof window !== 'undefined' && + window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light'; + } + return mode; +} + +/** 订阅系统主题变化(仅在 system 模式下有意义) */ +function subscribeToSystemTheme(callback: () => void) { + if (typeof window === 'undefined') return () => {}; + const mq = window.matchMedia('(prefers-color-scheme: dark)'); + mq.addEventListener('change', callback); + return () => mq.removeEventListener('change', callback); +} + +function getSystemSnapshot() { + if (typeof window === 'undefined') return false; + return window.matchMedia('(prefers-color-scheme: dark)').matches; +} interface ThemeState { - /** 主题模式 */ - theme: 'light'; - /** 实际使用的主题 */ - resolvedTheme: 'light'; - /** 设置主题 */ - setTheme: (theme: string) => void; + theme: ThemeMode; + resolvedTheme: 'light' | 'dark'; + setTheme: (theme: ThemeMode | string) => void; } /** - * 移除深色主题类 + * useTheme - 统一主题 hook + * + * 返回值与旧版兼容,但底层全部代理到 settingsStore.theme。 */ -function applyLightTheme() { - if (typeof window !== 'undefined') { - document.documentElement.classList.remove('dark'); - } -} +export function useTheme(): ThemeState { + const theme = useSettingsStore((s) => s.theme); + const setTheme = useSettingsStore((s) => s.setTheme); -// 立即应用浅色主题 -applyLightTheme(); - -export const useTheme = create()(() => ({ - theme: 'light', - resolvedTheme: 'light', - setTheme: () => { - // 固定浅色主题 - applyLightTheme(); - }, -})); + // 订阅系统主题变化,确保 system 模式下能响应 OS 切换 + useSyncExternalStore(subscribeToSystemTheme, getSystemSnapshot); + + const resolvedTheme = resolveTheme(theme); + + return { + theme, + resolvedTheme, + setTheme: (t: ThemeMode | string) => { + if (t === 'light' || t === 'dark' || t === 'system') { + setTheme(t); + } + }, + }; +} diff --git a/src/hooks/useThemeColor.ts b/src/hooks/useThemeColor.ts index e861b96..e9ca4d5 100644 --- a/src/hooks/useThemeColor.ts +++ b/src/hooks/useThemeColor.ts @@ -1,6 +1,8 @@ -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { useSettingsStore } from '@/stores/settingsStore'; import { useShallow } from 'zustand/shallow'; +import { useSyncExternalStore } from 'react'; +import type { ThemeMode } from '@/types'; /** * 将 Hex 颜色转换为 RGB 对象 @@ -40,9 +42,34 @@ const adjustBrightness = (hex: string, percent: number) => { .slice(1)}`; }; +/** 解析当前实际主题 */ +function resolveTheme(mode: ThemeMode): 'light' | 'dark' { + if (mode === 'system') { + return typeof window !== 'undefined' && + window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light'; + } + return mode; +} + +function subscribeToSystemTheme(callback: () => void) { + if (typeof window === 'undefined') return () => {}; + const mq = window.matchMedia('(prefers-color-scheme: dark)'); + mq.addEventListener('change', callback); + return () => mq.removeEventListener('change', callback); +} + +function getSystemSnapshot() { + if (typeof window === 'undefined') return false; + return window.matchMedia('(prefers-color-scheme: dark)').matches; +} + /** * 主题管理 Hook - * 监听 store 中的 themeColor 和 theme 变化,应用到 CSS 变量和 class + * + * 监听 settingsStore 中的 themeColor 和 theme 变化, + * 应用到 CSS 变量、根节点 .dark 类,并返回 isDark 供 FluentProvider 使用。 */ export function useThemeColor() { const { themeColor, theme } = useSettingsStore( @@ -52,34 +79,33 @@ export function useThemeColor() { })) ); - // 应用主题色 + // 订阅系统主题变化 + useSyncExternalStore(subscribeToSystemTheme, getSystemSnapshot); + + const isDark = useMemo(() => resolveTheme(theme) === 'dark', [theme]); + + // 应用主题色到 CSS 变量 useEffect(() => { if (!themeColor) return; const root = document.documentElement; - // 生成变体颜色 const primary = themeColor; - const primaryLight = adjustBrightness(themeColor, 20); // 变亮 20% - const primaryDark = adjustBrightness(themeColor, -15); // 变暗 15% + const primaryLight = adjustBrightness(themeColor, 20); + const primaryDark = adjustBrightness(themeColor, -15); - // 设置 CSS 变量 root.style.setProperty('--primary', primary); root.style.setProperty('--primary-light', primaryLight); root.style.setProperty('--primary-dark', primaryDark); }, [themeColor]); - // 应用主题模式 (Light/Dark) + // 应用主题模式:根节点 .dark 类切换 useEffect(() => { const root = document.documentElement; const applyThemeMode = () => { - const isDark = - theme === 'dark' || - (theme === 'system' && - window.matchMedia('(prefers-color-scheme: dark)').matches); - - if (isDark) { + const dark = resolveTheme(theme) === 'dark'; + if (dark) { root.classList.add('dark'); } else { root.classList.remove('dark'); @@ -88,6 +114,7 @@ export function useThemeColor() { applyThemeMode(); + // system 模式下监听 OS 主题变化 if (theme === 'system') { const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); const handler = () => applyThemeMode(); @@ -95,4 +122,6 @@ export function useThemeColor() { return () => mediaQuery.removeEventListener('change', handler); } }, [theme]); + + return { isDark }; } diff --git a/src/index.css b/src/index.css index c4a44cf..314518e 100644 --- a/src/index.css +++ b/src/index.css @@ -146,8 +146,7 @@ body *::-webkit-scrollbar-thumb:hover, h2, h3, h4, - .font-serif, - .timeline-header { + .font-serif { font-family: 'Noto Serif SC', Georgia, serif; font-weight: 600; letter-spacing: -0.02em; @@ -163,57 +162,6 @@ body *::-webkit-scrollbar-thumb:hover, - /* 侧边栏 - 实体 (Solid) */ - .sidebar { - background: var(--bg-sidebar); - /* 无边框,靠色差 */ - } - - /* 侧边栏导航项 - Claude/Cursor 风格 */ - .nav-item { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.5rem 0.75rem; - border-radius: 0.5rem; - font-size: 0.875rem; - font-weight: 500; - color: var(--text-secondary); - transition: all 0.15s ease; - } - - .nav-item:hover { - background: var(--bg-element); - color: var(--text-primary); - } - - .nav-item.active { - background: var(--bg-element); - color: var(--text-primary); - font-weight: 500; - } - - .nav-item.active-accent { - background: rgba(218, 119, 86, 0.1); - color: var(--primary); - } - - .dark .nav-item.active { - background: var(--bg-element); - color: var(--text-primary); - } - - .dark .nav-item.active-accent { - background: rgba(232, 149, 122, 0.15); - color: var(--primary); - } - - /* 主内容面板 */ - .main-panel { - background: var(--bg-base); - /* 纯净背景,无需圆角或边框 */ - } - /* 卡片样式 - Claude/Cursor 风格 */ .card { background: var(--bg-surface); @@ -302,195 +250,6 @@ body *::-webkit-scrollbar-thumb:hover, linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px); } - /* ========== 笔记本风格样式 ========== */ - - /* 笔记本页面背景 - 横线 */ - .notebook-page { - background-color: var(--bg-base); - background-image: - linear-gradient(transparent 23px, rgba(0, 0, 0, 0.05) 24px); - background-size: 100% 24px; - } - - .dark .notebook-page { - background-image: - linear-gradient(transparent 23px, rgba(255, 255, 255, 0.03) 24px); - } - - /* 笔记本区块 */ - .notebook-section { - padding-left: 1rem; - border-left: 3px solid rgba(0, 0, 0, 0.1); - } - - .dark .notebook-section { - border-left-color: rgba(255, 255, 255, 0.1); - } - - /* 重点区块 - 高亮边框 */ - .notebook-section-highlight { - padding: 1rem; - border: 2px solid rgba(0, 0, 0, 0.2); - border-radius: 0.5rem; - background: rgba(255, 200, 0, 0.03); - } - - .dark .notebook-section-highlight { - border-color: rgba(255, 255, 255, 0.15); - background: rgba(255, 200, 0, 0.02); - } - - /* 标题样式 */ - .notebook-bullet { - color: var(--primary); - font-size: 0.75rem; - } - - .notebook-bullet-hollow { - color: var(--text-secondary); - font-size: 0.75rem; - } - - .notebook-dash { - color: var(--text-tertiary); - font-weight: bold; - } - - .notebook-star { - color: #f59e0b; - font-size: 1rem; - } - - .notebook-underline { - height: 2px; - background: linear-gradient(90deg, var(--primary) 0%, transparent 100%); - margin-left: 0.5rem; - } - - /* 笔记本卡片 */ - .notebook-card { - background: var(--bg-surface); - border: 2px solid rgba(0, 0, 0, 0.15); - border-radius: 0.5rem; - box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.05); - } - - .dark .notebook-card { - border-color: rgba(255, 255, 255, 0.1); - box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.2); - } - - /* 笔记文字 */ - .notebook-note { - position: relative; - padding-left: 1rem; - line-height: 1.8; - } - - .notebook-note::before { - content: '•'; - position: absolute; - left: 0; - color: var(--text-tertiary); - } - - /* 标签按钮 */ - .notebook-tag { - padding: 0.25rem 0.75rem; - font-size: 0.875rem; - border: 2px solid rgba(0, 0, 0, 0.2); - border-radius: 1rem; - background: var(--bg-surface); - color: var(--text-secondary); - cursor: pointer; - transition: all 0.15s ease; - } - - .notebook-tag:hover { - border-color: var(--primary); - color: var(--primary); - } - - .notebook-tag-active { - background: var(--primary); - border-color: var(--primary); - color: white; - } - - .dark .notebook-tag { - border-color: rgba(255, 255, 255, 0.15); - } - - /* 缩略图 */ - .notebook-thumbnail { - background: var(--bg-element); - border: 2px solid rgba(0, 0, 0, 0.1); - border-radius: 0.375rem; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.15s ease; - } - - .notebook-thumbnail:hover { - border-color: var(--primary); - transform: translateY(-2px); - } - - .dark .notebook-thumbnail { - border-color: rgba(255, 255, 255, 0.1); - } - - /* 日期横栏 */ - .notebook-date-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0.5rem 1rem; - background: var(--bg-surface); - border: 2px solid rgba(0, 0, 0, 0.25); - border-radius: 0.5rem; - font-weight: 600; - } - - .dark .notebook-date-header { - border-color: rgba(255, 255, 255, 0.2); - } - - .notebook-date-text { - font-family: serif; - font-size: 1.125rem; - color: var(--text-primary); - } - - .notebook-date-count { - font-size: 0.875rem; - color: var(--text-secondary); - } - - /* 照片卡片 */ - .notebook-photo-card { - background: var(--bg-element); - border: 2px solid rgba(0, 0, 0, 0.1); - border-radius: 0.375rem; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: all 0.15s ease; - } - - .notebook-photo-card:hover { - border-color: var(--primary); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); - } - - .dark .notebook-photo-card { - border-color: rgba(255, 255, 255, 0.1); - } - - /* ========== 笔记本风格结束 ========== */ - /* 按钮样式 - Claude/Cursor 风格 */ .btn { display: inline-flex; @@ -546,75 +305,4 @@ body *::-webkit-scrollbar-thumb:hover, color: var(--text-primary); } - /* 输入框样式 - Claude/Cursor 风格 */ - .input { - width: 100%; - padding: 0.625rem 0.875rem; - border: 1px solid var(--border-color); - border-radius: 0.5rem; - background: var(--bg-surface); - color: var(--text-primary); - font-size: 0.875rem; - transition: - color 0.15s ease, - background-color 0.15s ease, - border-color 0.15s ease, - box-shadow 0.15s ease, - opacity 0.15s ease, - transform 0.15s ease; - } - - .input::placeholder { - color: var(--text-tertiary); - } - - .input:hover { - border-color: var(--text-tertiary); - } - - .input:focus { - outline: none; - border-color: var(--primary); - box-shadow: 0 0 0 3px rgba(218, 119, 86, 0.12); - } - - .dark .input:focus { - box-shadow: 0 0 0 3px rgba(232, 149, 122, 0.15); - } - - /* 移除原有 Glass 类,用实体类兜底 */ - .glass-panel, - .native-glass-panel, - .glass-card { - background: var(--bg-surface); - border: 1px solid var(--border-color); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); - } - - /* 时间线标题 */ - .timeline-header { - font-family: 'Noto Serif SC', serif; - font-size: 1.25rem; - font-weight: 600; - color: var(--text-primary); - margin-bottom: 1rem; - padding-top: 0.5rem; - /* 增加一点装饰线,更有杂志感 */ - display: flex; - align-items: center; - gap: 0.5rem; - } - - /* Scrollbar - 细致化 */ - - - /* 开关 (Switch) - Refined */ - .switch { - background: var(--bg-element); - /* ... 保持原有逻辑,颜色变量已自动适配 */ - } - - .switch.active { - background: var(--primary); - } } diff --git a/src/main.tsx b/src/main.tsx index ee0fc26..cccc9c1 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -84,8 +84,7 @@ async function emitToBackend(eventName: string, payload?: unknown) { } async function boot() { - // Don't force theme here; keep it light by default and let the app/theme hook decide. - document.documentElement.classList.remove('dark'); + // Theme is managed by settingsStore + useThemeColor; don't force any class here. window.addEventListener('error', (event) => { const details = getErrorDetails(event.error ?? event.message); diff --git a/src/pages/FavoritesPage.tsx b/src/pages/FavoritesPage.tsx index 5e839a6..a072015 100644 --- a/src/pages/FavoritesPage.tsx +++ b/src/pages/FavoritesPage.tsx @@ -18,6 +18,8 @@ import { setPhotosFavorite, } from '@/services/api'; import type { Photo, PaginatedResult } from '@/types'; +import PhotoContextMenu from '@/components/common/ContextMenu'; +import { usePhotoContextMenu } from '@/hooks/usePhotoContextMenu'; /** 每页加载数量 */ const PAGE_SIZE = 100; @@ -56,6 +58,33 @@ function FavoritesPage() { const [unfavoriting, setUnfavoriting] = useState(false); const [showTagSelector, setShowTagSelector] = useState(false); + // 右键菜单 + const { + menuState: ctxMenuState, + openMenu: openCtxMenu, + closeMenu: closeCtxMenu, + handleCopyPath, + handleOpenFolder, + handleToggleFavorite: handleCtxToggleFavorite, + handleDelete: handleCtxDelete, + } = usePhotoContextMenu({ + onPhotoUpdate: (updated) => { + // 如果取消收藏,从列表中移除 + if (!updated.isFavorite) { + setPhotos(prev => prev.filter(p => p.photoId !== updated.photoId)); + setTotalCount(prev => Math.max(0, prev - 1)); + } else { + setPhotos(prev => + prev.map(p => (p.photoId === updated.photoId ? updated : p)) + ); + } + }, + onPhotoDeleted: (photoId) => { + setPhotos(prev => prev.filter(p => p.photoId !== photoId)); + setTotalCount(prev => Math.max(0, prev - 1)); + }, + }); + // 初始加载 useEffect(() => { loadFavorites(1, true); @@ -172,8 +201,8 @@ function FavoritesPage() { // 照片右键菜单 const handlePhotoContextMenu = useCallback((photo: Photo, event: MouseEvent) => { - console.log('Context menu for photo:', photo.photoId, event); - }, []); + openCtxMenu(photo, event); + }, [openCtxMenu]); // 关闭查看器 const handleCloseViewer = useCallback(() => { @@ -395,6 +424,16 @@ function FavoritesPage() { )}
+ {/* 右键菜单 */} + + {/* 查看器 */} {viewerOpen && viewerPhoto && ( (null); const [showTagSelector, setShowTagSelector] = useState(false); + // 右键菜单 + const { + menuState: ctxMenuState, + openMenu: openCtxMenu, + closeMenu: closeCtxMenu, + handleCopyPath, + handleOpenFolder, + handleToggleFavorite: handleCtxToggleFavorite, + handleDelete: handleCtxDelete, + } = usePhotoContextMenu({ + onPhotoUpdate: (updated) => { + setPhotos(photos.map((p: Photo) => (p.photoId === updated.photoId ? updated : p))); + }, + onPhotoDeleted: (photoId) => { + setPhotos(photos.filter((p: Photo) => p.photoId !== photoId)); + setTotalPhotoCount(Math.max(0, totalPhotoCount - 1)); + }, + }); + // 初始加载 useEffect(() => { const doLoad = async () => { @@ -286,8 +307,8 @@ function FoldersPage() { // 照片右键菜单 const handlePhotoContextMenu = useCallback((photo: Photo, event: MouseEvent) => { - console.log('Context menu for photo:', photo.photoId, event); - }, []); + openCtxMenu(photo, event); + }, [openCtxMenu]); // 关闭查看器 const handleCloseViewer = useCallback(() => { @@ -641,6 +662,16 @@ function FoldersPage() { /> )} + {/* 右键菜单 */} + + {/* 删除确认对话框 */} {showDeleteDialog && (
diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx deleted file mode 100644 index 02e5c0f..0000000 --- a/src/pages/HomePage.tsx +++ /dev/null @@ -1,383 +0,0 @@ -/** - * 首页组件 - 新版仪表盘视图 - * - * 组装 HeroSection, TagRibbon 和 ContentShelf - */ - -import { useRef, useEffect, useCallback, useMemo, useState } from 'react'; -import HeroSection from '@/components/dashboard/HeroSection'; -import TagRibbon from '@/components/dashboard/TagRibbon'; -import ContentShelf from '@/components/dashboard/ContentShelf'; -import { PhotoGrid, PhotoViewer } from '@/components/photo'; -import { usePhotoStore } from '@/stores/photoStore'; -import { useSelectionStore } from '@/stores/selectionStore'; -import { SelectionToolbar, SelectionAction, SelectionDivider } from '@/components/common/SelectionToolbar'; -import { BatchTagSelector } from '@/components/tag'; -import { Icon } from '@/components/common/Icon'; -import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query'; -import { getPhotosCursor, searchPhotosCursor, setPhotosFavorite, softDeletePhotos } from '@/services/api'; -import type { Photo, PhotoCursor, SortField } from '@/types'; - -const PAGE_SIZE = 100; -const RECENT_PHOTOS_LIMIT = 20; - -export default function HomePage() { - const scrollContainerRef = useRef(null); - - // Store State - const sortOptions = usePhotoStore(state => state.sortOptions); - const searchFilters = usePhotoStore(state => state.searchFilters); - const totalCount = usePhotoStore(state => state.totalCount); - const setTotalCount = usePhotoStore(state => state.setTotalCount); - const selectedIds = useSelectionStore(state => state.selectedIds); - const clearSelection = useSelectionStore(state => state.clearSelection); - const select = useSelectionStore(state => state.select); - const toggleSelection = useSelectionStore(state => state.toggle); - const lastSelectedId = useSelectionStore(state => state.lastSelectedId); - - // Viewer State - const [viewerOpen, setViewerOpen] = useState(false); - const [viewerPhoto, setViewerPhoto] = useState(null); - - // --- Check Tauri Runtime (兼容 Tauri 2.0) --- - const isTauriRuntime = (() => { - if (typeof window === 'undefined') return false; - const w = window as typeof window & { __TAURI__?: unknown; __TAURI_INTERNALS__?: unknown }; - return Boolean(w.__TAURI__ ?? w.__TAURI_INTERNALS__); - })(); - - // --- 最近添加照片查询 --- - const { - data: recentPhotosData, - isLoading: recentLoading, - isError: recentError, - } = useQuery({ - queryKey: ['recentPhotos'], - queryFn: () => getPhotosCursor( - RECENT_PHOTOS_LIMIT, - null, - { field: 'dateAdded', order: 'desc' }, - false - ), - enabled: isTauriRuntime, - staleTime: 30000, - retry: 1, - }); - - const recentPhotos = recentPhotosData?.items ?? []; - - // --- Infinite Query Logic for All Photos Grid --- - const getCursorForPhoto = useCallback((photo: Photo, field: SortField): PhotoCursor => { - let sortValue: string | number | null = null; - switch (field) { - case 'dateTaken': sortValue = photo.dateTaken ?? null; break; - case 'dateAdded': sortValue = photo.dateAdded ?? null; break; - case 'fileName': sortValue = photo.fileName ?? null; break; - case 'fileSize': sortValue = photo.fileSize ?? null; break; - case 'rating': sortValue = photo.rating ?? null; break; - case 'relevance': sortValue = photo.relevanceScore ?? null; break; - default: sortValue = photo.dateTaken ?? null; break; - } - return { sortValue, photoId: photo.photoId }; - }, []); - - // 检查是否有活动的搜索过滤器 - const hasActiveFilters = useMemo(() => { - return !!( - searchFilters.query?.trim() || - searchFilters.dateFrom || - searchFilters.dateTo || - (searchFilters.tagIds && searchFilters.tagIds.length > 0) || - searchFilters.minRating || - searchFilters.favoritesOnly || - (searchFilters.fileExtensions && searchFilters.fileExtensions.length > 0) - ); - }, [searchFilters]); - - // 生成过滤器标题 - const filterTitle = useMemo(() => { - if (!hasActiveFilters) return "全部照片"; - - const parts: string[] = []; - if (searchFilters.query?.trim()) { - parts.push(`"${searchFilters.query.trim()}"`); - } - if (searchFilters.tagIds?.length) { - if (searchFilters.tagIds.length === 1 && searchFilters.tagNames?.[0]) { - parts.push(`标签: ${searchFilters.tagNames[0]}`); - } else { - parts.push(`${searchFilters.tagIds.length}个标签`); - } - } - if (searchFilters.dateFrom || searchFilters.dateTo) { - parts.push("日期范围"); - } - if (searchFilters.minRating) { - parts.push(`≥${searchFilters.minRating}星`); - } - if (searchFilters.favoritesOnly) { - parts.push("收藏"); - } - - return parts.length > 0 ? `搜索: ${parts.join(" · ")}` : "筛选结果"; - }, [hasActiveFilters, searchFilters]); - - const photoFeedQueryKey = useMemo( - () => ['photoFeed', { filters: searchFilters, field: sortOptions.field, order: sortOptions.order }] as const, - [searchFilters, sortOptions.field, sortOptions.order] - ); - - const { - data, - isLoading: feedLoading, - isError: feedError, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - } = useInfiniteQuery({ - queryKey: photoFeedQueryKey, - enabled: isTauriRuntime, - initialPageParam: null as PhotoCursor | null, - queryFn: async ({ pageParam }) => { - const cursor = pageParam ?? null; - const includeTotal = pageParam == null; - if (hasActiveFilters) { - return searchPhotosCursor(searchFilters, PAGE_SIZE, cursor, sortOptions, includeTotal); - } - return getPhotosCursor(PAGE_SIZE, cursor, sortOptions, includeTotal); - }, - getNextPageParam: (lastPage) => { - if (lastPage.items.length < PAGE_SIZE) return undefined; - const last = lastPage.items[lastPage.items.length - 1]; - return last ? getCursorForPhoto(last, sortOptions.field) : undefined; - }, - retry: 1, - }); - - const photos = useMemo(() => data?.pages.flatMap(page => page.items) ?? [], [data]); - - // 修复:使用 isLoading 而非 status === 'pending' - // isLoading 只在首次加载时为 true,之后即使数据为空也会是 false - const loading = feedLoading || isFetchingNextPage; - - useEffect(() => { - const total = data?.pages?.[0]?.total; - if (typeof total === 'number') setTotalCount(total); - }, [data, setTotalCount]); - - // --- Grid Interaction Handlers --- - const handlePhotoClick = useCallback((photo: Photo, event?: React.MouseEvent) => { - const isCtrlLike = Boolean(event?.ctrlKey || event?.metaKey); - const isShift = Boolean(event?.shiftKey); - if (isShift && lastSelectedId) { - select(photo.photoId); - return; - } - if (isCtrlLike) { - toggleSelection(photo.photoId); - return; - } - clearSelection(); - select(photo.photoId); - }, [clearSelection, select, toggleSelection, lastSelectedId]); - - const handlePhotoDoubleClick = useCallback((photo: Photo) => { - setViewerPhoto(photo); - setViewerOpen(true); - }, []); - - // 用于 ContentShelf 的单击打开查看器 - const handleShelfPhotoClick = useCallback((photo: Photo) => { - setViewerPhoto(photo); - setViewerOpen(true); - }, []); - - const handleCloseViewer = useCallback(() => { - setViewerOpen(false); - }, []); - - const homeDebugEnabled = - import.meta.env.DEV && - (import.meta.env as unknown as Record).VITE_DEBUG_HOME === '1'; - - // 调试信息(生产环境可移除) - useEffect(() => { - if (!homeDebugEnabled) return; - console.log('[HomePage] isTauriRuntime:', isTauriRuntime); - console.log('[HomePage] feedLoading:', feedLoading, 'feedError:', feedError); - console.log('[HomePage] recentLoading:', recentLoading, 'recentError:', recentError); - console.log('[HomePage] photos count:', photos.length); - console.log('[HomePage] recentPhotos count:', recentPhotos.length); - }, [ - homeDebugEnabled, - isTauriRuntime, - feedLoading, - feedError, - recentLoading, - recentError, - photos.length, - recentPhotos.length, - ]); - - // --- Selection Actions --- - const queryClient = useQueryClient(); - const [isActionLoading, setIsActionLoading] = useState(false); - const [showTagSelector, setShowTagSelector] = useState(false); - - // 批量移动到回收站 - const handleMoveToTrash = async () => { - if (selectedIds.size === 0) return; - setIsActionLoading(true); - try { - await softDeletePhotos(Array.from(selectedIds)); - clearSelection(); - // Invalidate queries to refresh the list - queryClient.invalidateQueries({ queryKey: ['photoFeed'] }); - queryClient.invalidateQueries({ queryKey: ['recentPhotos'] }); - // Optimistic update logic could be added here for better UX, - // but invalidation handles correctness. - } catch (error) { - console.error('Failed to move photos to trash:', error); - } finally { - setIsActionLoading(false); - } - }; - - // 批量收藏/取消收藏 (此处简化为统一设为收藏,或根据第一个状态反转) - // For simplicity: toggle favorite for all selected based on the first one's state or just favorite all. - // A better UX might be: if any un-favorited, favorite all. If all favorited, un-favorite all. - const handleToggleFavorite = async () => { - if (selectedIds.size === 0) return; - setIsActionLoading(true); - try { - const ids = Array.from(selectedIds); - const selectedPhotos = photos.filter((photo) => selectedIds.has(photo.photoId)); - const allSelectedFavorited = - selectedPhotos.length > 0 && selectedPhotos.every((photo) => photo.isFavorite); - await setPhotosFavorite(ids, !allSelectedFavorited); - - clearSelection(); - queryClient.invalidateQueries({ queryKey: ['photoFeed'] }); - queryClient.invalidateQueries({ queryKey: ['recentPhotos'] }); - } catch (error) { - console.error('Failed to toggle favorites:', error); - } finally { - setIsActionLoading(false); - } - }; - - // 批量标签操作完成 - const handleTagComplete = () => { - setShowTagSelector(false); - queryClient.invalidateQueries({ queryKey: ['photoFeed'] }); - queryClient.invalidateQueries({ queryKey: ['recentPhotos'] }); - }; - - return ( -
- {/* 仪表盘区域 */} - - - - - - - {/* 全部照片区域 (Grid) */} -
-
-

- - {filterTitle} -

- {totalCount} 张 -
- - {/* 错误提示 */} - {feedError && ( -
- -

加载照片失败,请检查数据库连接

-
- )} - - {!feedError && ( -
- -
- )} -
- - {/* 底部悬浮选择栏 */} -
- {selectedIds.size > 0 && ( -
- - setShowTagSelector(true)} - disabled={isActionLoading} - /> - - - - - -
- )} -
- - {/* 批量标签选择器 */} - {showTagSelector && selectedIds.size > 0 && ( - setShowTagSelector(false)} - /> - )} - - {viewerPhoto && ( - p.photoId === viewerPhoto.photoId) ? recentPhotos : photos} - onClose={handleCloseViewer} - /> - )} -
- ); -} diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index ae7bbe1..cc08871 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -102,8 +102,12 @@ function SettingsPage() { setThemeColor, theme, setTheme, - highRefreshUi, - setHighRefreshUi, + effectMode, + windowTransparency, + blurRadius, + customBlurEnabled, + compositionBlurEnabled, + hydrateFromSettings, } = useSettingsStore(); // Local Hue State @@ -180,14 +184,7 @@ function SettingsPage() { try { const data = await getSettings(); setSettings(data); - - // 同步 Store 中的外观设置 - if (data.appearance?.themeColor) { - setThemeColor(data.appearance.themeColor); - } - if (data.theme) { - setTheme(data.theme); - } + hydrateFromSettings(data); } catch (err) { showMessage('error', `加载设置失败: ${err}`); } finally { @@ -317,14 +314,22 @@ function SettingsPage() { if (!settings) return; setSaving(true); try { - // 构造包含外观设置的完整设置对象 - const settingsToSave = { + // 构造完整设置对象,统一走 AppSettings + // 后端会忽略 shell 字段,不会覆盖最新 shell 状态 + const settingsToSave: AppSettings = { ...settings, - theme: theme, + theme, appearance: { - themeColor: themeColor, - fontSizeScale: 1.0, // Default for now - } + themeColor, + fontSizeScale: settings.appearance?.fontSizeScale ?? 1.0, + }, + window: { + effectMode, + transparency: windowTransparency, + blurRadius, + customBlurEnabled, + compositionBlurEnabled, + }, }; await saveSettings(settingsToSave); showMessage('success', '设置已保存'); @@ -342,12 +347,7 @@ function SettingsPage() { try { const defaults = await resetSettings(); setSettings(defaults); - if (defaults.appearance) { - setThemeColor(defaults.appearance.themeColor); - } - if (defaults.theme) { - setTheme(defaults.theme); - } + hydrateFromSettings(defaults); showMessage('success', '设置已重置为默认值'); } catch (err) { showMessage('error', `重置失败: ${err}`); @@ -416,8 +416,8 @@ function SettingsPage() { if (loading || !settings) { return ( -
-
+
+

加载中...

@@ -426,7 +426,7 @@ function SettingsPage() { } return ( -
+
{message && (
-

设置

+
+

设置

管理您的应用程序设置和偏好。

@@ -1099,32 +1099,6 @@ function SettingsPage() {

性能

调整设置以优化应用程序的速度和资源使用。

-
-
- -

- 减少重特效,优先保证滚动与切换的帧率稳定。 -

-
- -
-
diff --git a/src/pages/TrashPage.tsx b/src/pages/TrashPage.tsx index f83787c..f9db4bf 100644 --- a/src/pages/TrashPage.tsx +++ b/src/pages/TrashPage.tsx @@ -4,7 +4,7 @@ * 显示所有已删除的照片,支持恢复、永久删除和清空回收站 */ -import { useState, useCallback, useEffect } from 'react'; +import { useState, useCallback, useEffect, useMemo } from 'react'; import type { MouseEvent } from 'react'; import { useNavigate } from 'react-router-dom'; import { PhotoGrid, PhotoViewer, TimelineView } from '@/components/photo'; @@ -12,6 +12,19 @@ import { usePhotoStore } from '@/stores/photoStore'; import { useSelectionStore } from '@/stores/selectionStore'; import { SelectionToolbar, SelectionAction, SelectionDivider } from '@/components/common/SelectionToolbar'; import { Icon } from '@/components/common/Icon'; +import { + Menu, + MenuPopover, + MenuList, + MenuItem, + MenuDivider, +} from '@fluentui/react-components'; +import type { PositioningVirtualElement } from '@fluentui/react-components'; +import { + ArrowUndoRegular, + DeleteRegular, + CopyRegular, +} from '@fluentui/react-icons'; import { getDeletedPhotos, restorePhotos, @@ -193,10 +206,63 @@ function TrashPage() { ); // 照片右键菜单 + const [ctxMenu, setCtxMenu] = useState<{ open: boolean; x: number; y: number; photo: Photo | null }>({ + open: false, x: 0, y: 0, photo: null, + }); + + const ctxTarget = useMemo((): PositioningVirtualElement => ({ + getBoundingClientRect: () => ({ + x: ctxMenu.x, y: ctxMenu.y, top: ctxMenu.y, left: ctxMenu.x, + bottom: ctxMenu.y, right: ctxMenu.x, width: 0, height: 0, + toJSON: () => ({}), + }), + }), [ctxMenu.x, ctxMenu.y]); + const handlePhotoContextMenu = useCallback((photo: Photo, event: MouseEvent) => { - console.log('Context menu for photo:', photo.photoId, event); + event.preventDefault(); + setCtxMenu({ open: true, x: event.clientX, y: event.clientY, photo }); + }, []); + + const closeCtxMenu = useCallback(() => { + setCtxMenu(prev => ({ ...prev, open: false, photo: null })); }, []); + const handleCtxRestore = useCallback(async () => { + if (!ctxMenu.photo) return; + try { + await restorePhotos([ctxMenu.photo.photoId]); + setPhotos(prev => prev.filter(p => p.photoId !== ctxMenu.photo!.photoId)); + setTotalCount(prev => Math.max(0, prev - 1)); + loadStats(); + } catch (e) { + console.warn('[ctx] restore failed:', e); + } + closeCtxMenu(); + }, [ctxMenu.photo, closeCtxMenu, loadStats]); + + const handleCtxPermanentDelete = useCallback(async () => { + if (!ctxMenu.photo) return; + try { + await permanentDeletePhotos([ctxMenu.photo.photoId]); + setPhotos(prev => prev.filter(p => p.photoId !== ctxMenu.photo!.photoId)); + setTotalCount(prev => Math.max(0, prev - 1)); + loadStats(); + } catch (e) { + console.warn('[ctx] permanent delete failed:', e); + } + closeCtxMenu(); + }, [ctxMenu.photo, closeCtxMenu, loadStats]); + + const handleCtxCopyPath = useCallback(async () => { + if (!ctxMenu.photo) return; + try { + await navigator.clipboard.writeText(ctxMenu.photo.filePath); + } catch (e) { + console.warn('[ctx] copy path failed:', e); + } + closeCtxMenu(); + }, [ctxMenu.photo, closeCtxMenu]); + // 关闭查看器 const handleCloseViewer = useCallback(() => { setViewerOpen(false); @@ -481,6 +547,30 @@ function TrashPage() { /> )} + {/* 右键菜单 */} + {ctxMenu.photo && ( + { if (!data.open) closeCtxMenu(); }} + positioning={{ target: ctxTarget, position: 'after', align: 'start' }} + > + + + } onClick={handleCtxRestore}> + 恢复 + + } onClick={handleCtxCopyPath}> + 复制路径 + + + } onClick={handleCtxPermanentDelete}> + 永久删除 + + + + + )} + {/* 永久删除确认对话框 */} {showDeleteDialog && (
diff --git a/src/pages/index.ts b/src/pages/index.ts index 32432bd..049a217 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -1,4 +1,3 @@ -export { default as HomePage } from './HomePage'; export { default as AlbumsPage } from './AlbumsPage'; export { default as TagsPage } from './TagsPage'; export { default as SettingsPage } from './SettingsPage'; diff --git a/src/services/api/editor.ts b/src/services/api/editor.ts deleted file mode 100644 index 904ca25..0000000 --- a/src/services/api/editor.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * 照片编辑相关 API - */ - -import { invoke } from '@tauri-apps/api/core'; -import type { Photo, EditParams } from '@/types'; - -/** - * 检查照片是否可编辑(非 RAW 格式) - */ -export async function isPhotoEditable(filePath: string): Promise { - return invoke('is_photo_editable', { filePath }); -} - -/** - * 应用编辑并保存照片 - * @param photoId 照片ID - * @param params 编辑参数 - * @param saveAsCopy 是否另存为副本 - * @returns 更新后的照片信息 - */ -export async function applyPhotoEdits( - photoId: number, - params: EditParams, - saveAsCopy: boolean = false -): Promise { - return invoke('apply_photo_edits', { photoId, params, saveAsCopy }); -} - -/** - * 获取编辑预览(Base64 编码的图像) - * @param sourcePath 源文件路径 - * @param params 编辑参数 - * @param maxSize 预览最大尺寸 - * @returns Base64 编码的 JPEG 图像 - */ -export async function getEditPreview( - sourcePath: string, - params: EditParams, - maxSize?: number -): Promise { - return invoke('get_edit_preview', { sourcePath, params, maxSize }); -} diff --git a/src/services/api/index.ts b/src/services/api/index.ts index da2c071..b7ca079 100644 --- a/src/services/api/index.ts +++ b/src/services/api/index.ts @@ -40,9 +40,6 @@ export * from './folders'; // 桌面模糊 export * from './blur'; -// 照片编辑 -export * from './editor'; - // 智能相册 export * from './smartAlbums'; diff --git a/src/services/api/settings.ts b/src/services/api/settings.ts index 48e2349..ba582f6 100644 --- a/src/services/api/settings.ts +++ b/src/services/api/settings.ts @@ -3,7 +3,7 @@ */ import { invoke } from '@tauri-apps/api/core'; -import type { AppSettings } from '@/types'; +import type { AppSettings, ShellSettings } from '@/types'; /** * 获取应用程序设置 @@ -13,12 +13,23 @@ export async function getSettings(): Promise { } /** - * 保存应用程序设置 + * 保存应用程序设置(非 shell 部分) + * + * 服务端会忽略请求体中的 shell 字段,始终保留已持久化的最新 shell。 */ export async function saveSettings(settings: AppSettings): Promise { return invoke('save_settings', { settings }); } +/** + * 保存壳层状态设置 + * + * 只更新 shell 子树,不影响其他设置字段。 + */ +export async function saveShellSettings(shell: ShellSettings): Promise { + return invoke('save_shell_settings', { shell }); +} + /** * 重置设置为默认值 */ diff --git a/src/stores/editStore.ts b/src/stores/editStore.ts deleted file mode 100644 index a07a2bf..0000000 --- a/src/stores/editStore.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * 照片编辑状态管理 - */ - -import { create } from 'zustand'; -import type { Photo, EditAdjustments, CropRect, FlipDirection, EditParams, EditOperation } from '../types'; -import { DEFAULT_ADJUSTMENTS } from '../types'; - -interface EditState { - /** 是否正在编辑 */ - isEditing: boolean; - /** 当前编辑的照片 */ - photo: Photo | null; - /** 旋转角度 (0, 90, 180, 270) */ - rotation: number; - /** 翻转状态 */ - flipH: boolean; - flipV: boolean; - /** 裁剪区域 */ - cropRect: CropRect | null; - /** 是否正在裁剪 */ - isCropping: boolean; - /** 调整参数 */ - adjustments: EditAdjustments; - /** 是否有未保存的更改 */ - hasChanges: boolean; - /** 是否正在保存 */ - isSaving: boolean; - - // Actions - startEditing: (photo: Photo) => void; - stopEditing: () => void; - rotate: (degrees: 90 | -90) => void; - flip: (direction: FlipDirection) => void; - setCropRect: (rect: CropRect | null) => void; - setIsCropping: (isCropping: boolean) => void; - setAdjustment: (key: K, value: number) => void; - resetAdjustments: () => void; - autoEnhance: () => void; - setIsSaving: (isSaving: boolean) => void; - getEditParams: () => EditParams; -} - -export const useEditStore = create((set, get) => ({ - isEditing: false, - photo: null, - rotation: 0, - flipH: false, - flipV: false, - cropRect: null, - isCropping: false, - adjustments: { ...DEFAULT_ADJUSTMENTS }, - hasChanges: false, - isSaving: false, - - startEditing: (photo) => set({ - isEditing: true, - photo, - rotation: 0, - flipH: false, - flipV: false, - cropRect: null, - isCropping: false, - adjustments: { ...DEFAULT_ADJUSTMENTS }, - hasChanges: false, - isSaving: false, - }), - - stopEditing: () => set({ - isEditing: false, - photo: null, - rotation: 0, - flipH: false, - flipV: false, - cropRect: null, - isCropping: false, - adjustments: { ...DEFAULT_ADJUSTMENTS }, - hasChanges: false, - isSaving: false, - }), - - rotate: (degrees) => set((state) => ({ - rotation: (state.rotation + degrees + 360) % 360, - hasChanges: true, - })), - - flip: (direction) => set((state) => ({ - flipH: direction === 'horizontal' ? !state.flipH : state.flipH, - flipV: direction === 'vertical' ? !state.flipV : state.flipV, - hasChanges: true, - })), - - setCropRect: (rect) => set({ cropRect: rect, hasChanges: rect !== null }), - - setIsCropping: (isCropping) => set({ isCropping }), - - setAdjustment: (key, value) => set((state) => ({ - adjustments: { ...state.adjustments, [key]: value }, - hasChanges: true, - })), - - resetAdjustments: () => set({ - adjustments: { ...DEFAULT_ADJUSTMENTS }, - rotation: 0, - flipH: false, - flipV: false, - cropRect: null, - hasChanges: false, - }), - - autoEnhance: () => set({ hasChanges: true }), - - setIsSaving: (isSaving) => set({ isSaving }), - - getEditParams: () => { - const state = get(); - const operations: EditOperation[] = []; - - // 旋转 - if (state.rotation !== 0) { - operations.push({ type: 'rotate', degrees: state.rotation }); - } - - // 翻转 - if (state.flipH) { - operations.push({ type: 'flip', direction: 'horizontal' }); - } - if (state.flipV) { - operations.push({ type: 'flip', direction: 'vertical' }); - } - - // 裁剪 - if (state.cropRect) { - operations.push({ type: 'crop', rect: state.cropRect }); - } - - // 调整参数(只添加非零值) - const adj = state.adjustments; - if (adj.brightness !== 0) operations.push({ type: 'brightness', value: adj.brightness }); - if (adj.contrast !== 0) operations.push({ type: 'contrast', value: adj.contrast }); - if (adj.saturation !== 0) operations.push({ type: 'saturation', value: adj.saturation }); - if (adj.exposure !== 0) operations.push({ type: 'exposure', value: adj.exposure }); - if (adj.highlights !== 0) operations.push({ type: 'highlights', value: adj.highlights }); - if (adj.shadows !== 0) operations.push({ type: 'shadows', value: adj.shadows }); - if (adj.temperature !== 0) operations.push({ type: 'temperature', value: adj.temperature }); - if (adj.tint !== 0) operations.push({ type: 'tint', value: adj.tint }); - if (adj.sharpen !== 0) operations.push({ type: 'sharpen', value: adj.sharpen }); - if (adj.blur !== 0) operations.push({ type: 'blur', value: adj.blur }); - if (adj.vignette !== 0) operations.push({ type: 'vignette', value: adj.vignette }); - - return { operations }; - }, -})); diff --git a/src/stores/index.ts b/src/stores/index.ts index d0f988f..81120bd 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -3,7 +3,6 @@ export { useSelectionStore } from './selectionStore'; export { useNavigationStore } from './navigationStore'; export { useSettingsStore } from './settingsStore'; export { useFolderStore } from './folderStore'; -export { useEditStore } from './editStore'; export { useSearchStore } from './searchStore'; export type { NavigationSection } from './navigationStore'; export type { SearchHistoryItem, SearchSuggestion } from './searchStore'; diff --git a/src/stores/navigationStore.ts b/src/stores/navigationStore.ts index 15c16e2..e11ff04 100644 --- a/src/stores/navigationStore.ts +++ b/src/stores/navigationStore.ts @@ -1,75 +1,350 @@ /** - * 导航状态管理 + * 导航与壳层状态管理 + * + * 管理导航节点、视图模式、排序、筛选、窗格宽度等壳层状态。 + * 启动时从后端 shell 设置 hydrate,变更后 debounce 调用 save_shell_settings。 + * 不再使用 localStorage 保存正式 shell 状态。 + * + * 加载优先级:URL > persisted shell.activeNode > default "photos" */ import { create } from 'zustand'; +import { subscribeWithSelector } from 'zustand/middleware'; +import { saveShellSettings } from '@/services/api/settings'; +import type { ShellSettings, FilterState, NodeViewPreference } from '@/types'; +// ---- legacy compat re-export ---- export type NavigationSection = 'all' | 'folders' | 'albums' | 'tags' | 'favorites'; -interface NavigationState { - /** 当前活动的导航项 */ +// ---- defaults ---- + +const DEFAULT_FILTER: FilterState = { + fileTypes: [], + dateRange: { from: null, to: null }, + tags: [], + rating: { min: null, max: null }, + sizeRange: { min: null, max: null }, +}; + +const DEFAULT_SHELL: ShellSettings = { + activeNode: 'photos', + expandedNodes: [], + navPaneWidth: 220, + previewPaneOpen: false, + previewPaneWidth: 260, + viewMode: 'large', + sortBy: 'dateAdded', + sortOrder: 'desc', + filter: DEFAULT_FILTER, + detailColumns: ['fileName', 'dateTaken', 'fileSize', 'resolution', 'tags'], + detailColumnWidths: {}, + viewPreferences: {}, +}; + +// ---- types ---- + +type ShellViewMode = ShellSettings['viewMode']; +type ShellSortBy = ShellSettings['sortBy']; +type ShellSortOrder = ShellSettings['sortOrder']; + +interface NavigationState extends ShellSettings { + // Legacy navigation fields (kept for backward compat during migration) activeSection: NavigationSection; - /** 当前文件夹路径 (当 section 为 folders 时) */ currentFolderPath: string | null; - /** 当前相册ID (当 section 为 albums 时) */ currentAlbumId: number | null; - /** 当前标签ID (当 section 为 tags 时) */ currentTagId: number | null; - // Actions + /** Whether hydration from backend has completed */ + _shellHydrated: boolean; + + // ---- Actions ---- + + // Legacy actions setActiveSection: (section: NavigationSection) => void; navigateToFolder: (path: string) => void; navigateToAlbum: (albumId: number) => void; navigateToTag: (tagId: number) => void; navigateToAll: () => void; navigateToFavorites: () => void; + + // Shell state actions + setActiveNode: (node: string) => void; + setExpandedNodes: (nodes: string[]) => void; + toggleExpandedNode: (node: string) => void; + setNavPaneWidth: (width: number) => void; + setPreviewPaneOpen: (open: boolean) => void; + setPreviewPaneWidth: (width: number) => void; + setViewMode: (mode: ShellViewMode) => void; + setSortBy: (field: ShellSortBy) => void; + setSortOrder: (order: ShellSortOrder) => void; + setFilter: (filter: FilterState) => void; + setDetailColumns: (columns: string[]) => void; + setDetailColumnWidths: (widths: Record) => void; + setViewPreference: (nodeId: string, pref: NodeViewPreference) => void; + + /** Hydrate shell state from backend AppSettings.shell */ + hydrateShell: (shell: ShellSettings) => void; + + /** Get current shell state snapshot for persistence */ + getShellSnapshot: () => ShellSettings; +} + +// ---- debounced save ---- + +let saveTimer: ReturnType | null = null; +const SAVE_DEBOUNCE_MS = 800; + +function debouncedSaveShell(getSnapshot: () => ShellSettings) { + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(() => { + saveTimer = null; + const shell = getSnapshot(); + saveShellSettings(shell).catch((err) => { + console.debug('[nav] save_shell_settings failed:', err); + }); + }, SAVE_DEBOUNCE_MS); +} + +// ---- helper: derive legacy section from activeNode ---- + +function sectionFromNode(node: string): NavigationSection { + if (node === 'photos') return 'all'; + if (node === 'folders' || node.startsWith('folder:')) return 'folders'; + if (node === 'albums' || node.startsWith('album:')) return 'albums'; + if (node === 'tags' || node.startsWith('tag:')) return 'tags'; + if (node === 'favorites') return 'favorites'; + return 'all'; +} + +// ---- helper: apply node-specific view preference ---- + +function applyViewPreference( + state: NavigationState, + nodeId: string, +): Partial { + const pref = state.viewPreferences[nodeId]; + if (!pref) return {}; + return { + viewMode: pref.viewMode, + sortBy: pref.sortBy, + sortOrder: pref.sortOrder, + }; } -export const useNavigationStore = create((set) => ({ - activeSection: 'all', - currentFolderPath: null, - currentAlbumId: null, - currentTagId: null, - - setActiveSection: (activeSection) => set({ activeSection }), - - navigateToFolder: (path) => - set({ - activeSection: 'folders', - currentFolderPath: path, - currentAlbumId: null, - currentTagId: null, - }), - - navigateToAlbum: (albumId) => - set({ - activeSection: 'albums', - currentAlbumId: albumId, - currentFolderPath: null, - currentTagId: null, - }), - - navigateToTag: (tagId) => - set({ - activeSection: 'tags', - currentTagId: tagId, - currentFolderPath: null, - currentAlbumId: null, - }), - - navigateToAll: () => - set({ - activeSection: 'all', - currentFolderPath: null, - currentAlbumId: null, - currentTagId: null, - }), - - navigateToFavorites: () => - set({ - activeSection: 'favorites', - currentFolderPath: null, - currentAlbumId: null, - currentTagId: null, - }), -})); +// ---- store ---- + +export const useNavigationStore = create()( + subscribeWithSelector((set, get) => ({ + // Shell state defaults + ...DEFAULT_SHELL, + + // Legacy fields + activeSection: 'all', + currentFolderPath: null, + currentAlbumId: null, + currentTagId: null, + + _shellHydrated: false, + + // ---- Legacy actions ---- + + setActiveSection: (activeSection) => set({ activeSection }), + + navigateToFolder: (path) => + set({ + activeSection: 'folders', + currentFolderPath: path, + currentAlbumId: null, + currentTagId: null, + }), + + navigateToAlbum: (albumId) => + set({ + activeSection: 'albums', + currentAlbumId: albumId, + currentFolderPath: null, + currentTagId: null, + }), + + navigateToTag: (tagId) => + set({ + activeSection: 'tags', + currentTagId: tagId, + currentFolderPath: null, + currentAlbumId: null, + }), + + navigateToAll: () => + set({ + activeSection: 'all', + currentFolderPath: null, + currentAlbumId: null, + currentTagId: null, + }), + + navigateToFavorites: () => + set({ + activeSection: 'favorites', + currentFolderPath: null, + currentAlbumId: null, + currentTagId: null, + }), + + // ---- Shell state actions ---- + + setActiveNode: (node) => { + const section = sectionFromNode(node); + set((state) => ({ + activeNode: node, + activeSection: section, + ...applyViewPreference(state, node), + })); + debouncedSaveShell(() => get().getShellSnapshot()); + }, + + setExpandedNodes: (nodes) => { + set({ expandedNodes: nodes }); + debouncedSaveShell(() => get().getShellSnapshot()); + }, + + toggleExpandedNode: (node) => { + set((state) => { + const expanded = state.expandedNodes.includes(node) + ? state.expandedNodes.filter((n) => n !== node) + : [...state.expandedNodes, node]; + return { expandedNodes: expanded }; + }); + debouncedSaveShell(() => get().getShellSnapshot()); + }, + + setNavPaneWidth: (width) => { + set({ navPaneWidth: width }); + debouncedSaveShell(() => get().getShellSnapshot()); + }, + + setPreviewPaneOpen: (open) => { + set({ previewPaneOpen: open }); + debouncedSaveShell(() => get().getShellSnapshot()); + }, + + setPreviewPaneWidth: (width) => { + set({ previewPaneWidth: width }); + debouncedSaveShell(() => get().getShellSnapshot()); + }, + + setViewMode: (mode) => { + set((state) => { + // Also save to current node's viewPreferences + const nodeId = state.activeNode; + const existing = state.viewPreferences[nodeId]; + const pref: NodeViewPreference = { + viewMode: mode, + sortBy: existing?.sortBy ?? state.sortBy, + sortOrder: existing?.sortOrder ?? state.sortOrder, + }; + return { + viewMode: mode, + viewPreferences: { ...state.viewPreferences, [nodeId]: pref }, + }; + }); + debouncedSaveShell(() => get().getShellSnapshot()); + }, + + setSortBy: (field) => { + set((state) => { + const nodeId = state.activeNode; + const existing = state.viewPreferences[nodeId]; + const pref: NodeViewPreference = { + viewMode: existing?.viewMode ?? state.viewMode, + sortBy: field, + sortOrder: existing?.sortOrder ?? state.sortOrder, + }; + return { + sortBy: field, + viewPreferences: { ...state.viewPreferences, [nodeId]: pref }, + }; + }); + debouncedSaveShell(() => get().getShellSnapshot()); + }, + + setSortOrder: (order) => { + set((state) => { + const nodeId = state.activeNode; + const existing = state.viewPreferences[nodeId]; + const pref: NodeViewPreference = { + viewMode: existing?.viewMode ?? state.viewMode, + sortBy: existing?.sortBy ?? state.sortBy, + sortOrder: order, + }; + return { + sortOrder: order, + viewPreferences: { ...state.viewPreferences, [nodeId]: pref }, + }; + }); + debouncedSaveShell(() => get().getShellSnapshot()); + }, + + setFilter: (filter) => { + set({ filter }); + debouncedSaveShell(() => get().getShellSnapshot()); + }, + + setDetailColumns: (columns) => { + set({ detailColumns: columns }); + debouncedSaveShell(() => get().getShellSnapshot()); + }, + + setDetailColumnWidths: (widths) => { + set({ detailColumnWidths: widths }); + debouncedSaveShell(() => get().getShellSnapshot()); + }, + + setViewPreference: (nodeId, pref) => { + set((state) => ({ + viewPreferences: { ...state.viewPreferences, [nodeId]: pref }, + })); + debouncedSaveShell(() => get().getShellSnapshot()); + }, + + // ---- Hydration ---- + + hydrateShell: (shell) => { + set({ + activeNode: shell.activeNode, + expandedNodes: shell.expandedNodes, + navPaneWidth: shell.navPaneWidth, + previewPaneOpen: shell.previewPaneOpen, + previewPaneWidth: shell.previewPaneWidth, + viewMode: shell.viewMode, + sortBy: shell.sortBy, + sortOrder: shell.sortOrder, + filter: shell.filter ?? DEFAULT_FILTER, + detailColumns: shell.detailColumns ?? DEFAULT_SHELL.detailColumns, + detailColumnWidths: shell.detailColumnWidths ?? {}, + viewPreferences: shell.viewPreferences ?? {}, + activeSection: sectionFromNode(shell.activeNode), + _shellHydrated: true, + }); + }, + + // ---- Snapshot ---- + + getShellSnapshot: (): ShellSettings => { + const s = get(); + return { + activeNode: s.activeNode, + expandedNodes: s.expandedNodes, + navPaneWidth: s.navPaneWidth, + previewPaneOpen: s.previewPaneOpen, + previewPaneWidth: s.previewPaneWidth, + viewMode: s.viewMode, + sortBy: s.sortBy, + sortOrder: s.sortOrder, + filter: s.filter, + detailColumns: s.detailColumns, + detailColumnWidths: s.detailColumnWidths, + viewPreferences: s.viewPreferences, + }; + }, + })), +); diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index 9a70014..974a048 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -1,11 +1,14 @@ /** * 设置状态管理 * - * 注意:主题已固定为深色,不再支持切换 + * settingsStore 是前端设置的内存态容器。 + * 正式配置以后端设置文件为唯一落盘源,不再使用 localStorage/persist。 + * 启动时通过 hydrateFromSettings 从后端加载完整 AppSettings。 + * settingsStore.theme 是前端主题状态的唯一来源。 */ import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; +import type { AppSettings, ThemeMode, WindowSettings } from '@/types'; interface SettingsState { /** 语言 */ @@ -25,18 +28,18 @@ interface SettingsState { /** 主题色 (Hex) */ themeColor: string; - /** 主题模式 */ - theme: 'light' | 'dark' | 'system'; + /** 主题模式 - 前端主题状态唯一来源 */ + theme: ThemeMode; - windowOpacity: number; + /** 窗口效果模式 */ + effectMode: WindowSettings['effectMode']; windowTransparency: number; blurRadius: number; customBlurEnabled: boolean; - compositionBlurSupported: boolean; compositionBlurEnabled: boolean; - /** 高刷/流畅优先模式(减少重特效,提升帧率稳定性) */ - highRefreshUi: boolean; + /** 是否已从后端完成 hydration */ + _hydrated: boolean; // Actions setLanguage: (language: 'zh-CN' | 'en-US') => void; @@ -48,37 +51,35 @@ interface SettingsState { setWorkerThreads: (threads: number) => void; setAutoScanOnStart: (enabled: boolean) => void; setThemeColor: (color: string) => void; - setTheme: (theme: 'light' | 'dark' | 'system') => void; - setWindowOpacity: (opacity: number) => void; + setTheme: (theme: ThemeMode) => void; setWindowTransparency: (transparency: number) => void; setBlurRadius: (radius: number) => void; setCustomBlurEnabled: (enabled: boolean) => void; - setCompositionBlurSupported: (supported: boolean) => void; setCompositionBlurEnabled: (enabled: boolean) => void; - setHighRefreshUi: (enabled: boolean) => void; + /** 从后端 AppSettings 一次性 hydrate 前端状态 */ + hydrateFromSettings: (settings: AppSettings) => void; resetToDefaults: () => void; } const defaultSettings = { language: 'zh-CN' as const, - watchedFolders: [], + watchedFolders: [] as string[], excludePatterns: ['node_modules', '.git', '__pycache__', 'temp'], maxCacheSize: 1024, // 1GB autoCleanupCache: true, workerThreads: 4, autoScanOnStart: false, themeColor: '#DA7756', // 默认 Terracotta - theme: 'system' as const, - windowOpacity: 100, + theme: 'system' as ThemeMode, + effectMode: 'auto' as WindowSettings['effectMode'], windowTransparency: 30, blurRadius: 24, customBlurEnabled: false, - compositionBlurSupported: false, compositionBlurEnabled: false, - highRefreshUi: true, + _hydrated: false, }; -export const useSettingsStore = create()(persist( +export const useSettingsStore = create()( (set) => ({ ...defaultSettings, @@ -116,11 +117,6 @@ export const useSettingsStore = create()(persist( setThemeColor: (themeColor) => set((state) => (state.themeColor === themeColor ? state : { themeColor })), setTheme: (theme) => set((state) => (state.theme === theme ? state : { theme })), - setWindowOpacity: (windowOpacity) => - set((state) => { - const next = Math.max(0, Math.min(100, windowOpacity)); - return state.windowOpacity === next ? state : { windowOpacity: next }; - }), setWindowTransparency: (windowTransparency) => set((state) => { const next = Math.max(0, Math.min(100, windowTransparency)); @@ -145,24 +141,26 @@ export const useSettingsStore = create()(persist( compositionBlurEnabled: nextCompositionBlurEnabled, }; }), - setCompositionBlurSupported: (compositionBlurSupported) => - set((state) => - state.compositionBlurSupported === compositionBlurSupported - ? state - : { compositionBlurSupported } - ), setCompositionBlurEnabled: (compositionBlurEnabled) => set((state) => state.compositionBlurEnabled === compositionBlurEnabled ? state : { compositionBlurEnabled } ), - setHighRefreshUi: (highRefreshUi) => - set((state) => (state.highRefreshUi === highRefreshUi ? state : { highRefreshUi })), + + hydrateFromSettings: (settings: AppSettings) => + set({ + theme: settings.theme, + language: (settings.language === 'en-US' ? 'en-US' : 'zh-CN') as 'zh-CN' | 'en-US', + themeColor: settings.appearance.themeColor, + effectMode: settings.window.effectMode, + windowTransparency: settings.window.transparency, + blurRadius: settings.window.blurRadius, + customBlurEnabled: settings.window.customBlurEnabled, + compositionBlurEnabled: settings.window.compositionBlurEnabled, + _hydrated: true, + }), resetToDefaults: () => set(defaultSettings), - }), - { - name: 'photowall-settings', - } -)); + }) +); diff --git a/src/test/navigationStore.test.ts b/src/test/navigationStore.test.ts new file mode 100644 index 0000000..dea67a7 --- /dev/null +++ b/src/test/navigationStore.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useNavigationStore } from '@/stores/navigationStore'; +import type { ShellSettings } from '@/types'; + +const DEFAULT_SHELL: ShellSettings = { + activeNode: 'photos', + expandedNodes: [], + navPaneWidth: 220, + previewPaneOpen: false, + previewPaneWidth: 260, + viewMode: 'large', + sortBy: 'dateAdded', + sortOrder: 'desc', + filter: { + fileTypes: [], + dateRange: { from: null, to: null }, + tags: [], + rating: { min: null, max: null }, + sizeRange: { min: null, max: null }, + }, + detailColumns: ['fileName', 'dateTaken', 'fileSize', 'resolution', 'tags'], + detailColumnWidths: {}, + viewPreferences: {}, +}; + +function resetStore() { + // Reset by hydrating with defaults + useNavigationStore.getState().hydrateShell(DEFAULT_SHELL); +} + +describe('navigationStore', () => { + beforeEach(resetStore); + + it('has correct defaults after reset', () => { + const state = useNavigationStore.getState(); + expect(state.activeNode).toBe('photos'); + expect(state.viewMode).toBe('large'); + expect(state.sortBy).toBe('dateAdded'); + expect(state.sortOrder).toBe('desc'); + expect(state.previewPaneOpen).toBe(false); + expect(state.navPaneWidth).toBe(220); + }); + + it('hydrateShell applies backend shell settings', () => { + const shell: ShellSettings = { + ...DEFAULT_SHELL, + activeNode: 'albums', + viewMode: 'medium', + sortBy: 'fileName', + sortOrder: 'asc', + navPaneWidth: 300, + previewPaneOpen: true, + expandedNodes: ['folders', 'tags'], + }; + + useNavigationStore.getState().hydrateShell(shell); + const state = useNavigationStore.getState(); + + expect(state.activeNode).toBe('albums'); + expect(state.viewMode).toBe('medium'); + expect(state.sortBy).toBe('fileName'); + expect(state.sortOrder).toBe('asc'); + expect(state.navPaneWidth).toBe(300); + expect(state.previewPaneOpen).toBe(true); + expect(state.expandedNodes).toEqual(['folders', 'tags']); + expect(state._shellHydrated).toBe(true); + }); + + it('hydrateShell derives legacy activeSection from activeNode', () => { + useNavigationStore.getState().hydrateShell({ ...DEFAULT_SHELL, activeNode: 'folders' }); + expect(useNavigationStore.getState().activeSection).toBe('folders'); + + useNavigationStore.getState().hydrateShell({ ...DEFAULT_SHELL, activeNode: 'albums' }); + expect(useNavigationStore.getState().activeSection).toBe('albums'); + + useNavigationStore.getState().hydrateShell({ ...DEFAULT_SHELL, activeNode: 'tags' }); + expect(useNavigationStore.getState().activeSection).toBe('tags'); + + useNavigationStore.getState().hydrateShell({ ...DEFAULT_SHELL, activeNode: 'favorites' }); + expect(useNavigationStore.getState().activeSection).toBe('favorites'); + + useNavigationStore.getState().hydrateShell({ ...DEFAULT_SHELL, activeNode: 'photos' }); + expect(useNavigationStore.getState().activeSection).toBe('all'); + }); + + it('setActiveNode updates node and section', () => { + useNavigationStore.getState().setActiveNode('folder:/photos'); + const state = useNavigationStore.getState(); + expect(state.activeNode).toBe('folder:/photos'); + expect(state.activeSection).toBe('folders'); + }); + + it('setViewMode updates mode and saves to viewPreferences', () => { + useNavigationStore.getState().setActiveNode('photos'); + useNavigationStore.getState().setViewMode('detail'); + const state = useNavigationStore.getState(); + expect(state.viewMode).toBe('detail'); + expect(state.viewPreferences['photos']?.viewMode).toBe('detail'); + }); + + it('setSortBy updates sort field and saves to viewPreferences', () => { + useNavigationStore.getState().setActiveNode('photos'); + useNavigationStore.getState().setSortBy('fileSize'); + const state = useNavigationStore.getState(); + expect(state.sortBy).toBe('fileSize'); + expect(state.viewPreferences['photos']?.sortBy).toBe('fileSize'); + }); + + it('toggleExpandedNode adds and removes nodes', () => { + useNavigationStore.getState().toggleExpandedNode('folders'); + expect(useNavigationStore.getState().expandedNodes).toContain('folders'); + + useNavigationStore.getState().toggleExpandedNode('folders'); + expect(useNavigationStore.getState().expandedNodes).not.toContain('folders'); + }); + + it('setPreviewPaneOpen toggles preview pane', () => { + useNavigationStore.getState().setPreviewPaneOpen(true); + expect(useNavigationStore.getState().previewPaneOpen).toBe(true); + + useNavigationStore.getState().setPreviewPaneOpen(false); + expect(useNavigationStore.getState().previewPaneOpen).toBe(false); + }); + + it('getShellSnapshot returns current shell state', () => { + useNavigationStore.getState().setActiveNode('tags'); + useNavigationStore.getState().setViewMode('list'); + useNavigationStore.getState().setNavPaneWidth(350); + + const snapshot = useNavigationStore.getState().getShellSnapshot(); + expect(snapshot.activeNode).toBe('tags'); + expect(snapshot.viewMode).toBe('list'); + expect(snapshot.navPaneWidth).toBe(350); + // Snapshot should not include internal fields + expect((snapshot as unknown as Record)['_shellHydrated']).toBeUndefined(); + expect((snapshot as unknown as Record)['activeSection']).toBeUndefined(); + }); +}); diff --git a/src/test/settingsStore.test.ts b/src/test/settingsStore.test.ts new file mode 100644 index 0000000..25f77e6 --- /dev/null +++ b/src/test/settingsStore.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useSettingsStore } from '@/stores/settingsStore'; +import type { AppSettings } from '@/types'; + +function resetStore() { + useSettingsStore.getState().resetToDefaults(); +} + +const mockSettings: AppSettings = { + theme: 'dark', + language: 'en-US', + scan: { + watchedFolders: ['/photos'], + excludedPatterns: ['node_modules'], + autoScan: true, + scanInterval: 1800, + recursive: true, + realtimeWatch: false, + }, + thumbnail: { + cacheSizeMb: 2048, + quality: 85, + autoCleanup: false, + cleanupThreshold: 90, + }, + performance: { + scanThreads: 4, + thumbnailThreads: 8, + enableWal: true, + }, + appearance: { + themeColor: '#FF0000', + fontSizeScale: 1.2, + }, + window: { + effectMode: 'mica', + transparency: 50, + blurRadius: 32, + customBlurEnabled: true, + compositionBlurEnabled: true, + }, + shell: { + activeNode: 'albums', + expandedNodes: ['folders'], + navPaneWidth: 280, + previewPaneOpen: true, + previewPaneWidth: 300, + viewMode: 'medium', + sortBy: 'fileName', + sortOrder: 'asc', + filter: { + fileTypes: [], + dateRange: { from: null, to: null }, + tags: [], + rating: { min: null, max: null }, + sizeRange: { min: null, max: null }, + }, + detailColumns: ['fileName'], + detailColumnWidths: {}, + viewPreferences: {}, + }, +}; + +describe('settingsStore', () => { + beforeEach(resetStore); + + it('has correct defaults', () => { + const state = useSettingsStore.getState(); + expect(state.theme).toBe('system'); + expect(state.language).toBe('zh-CN'); + expect(state.themeColor).toBe('#DA7756'); + expect(state.effectMode).toBe('auto'); + expect(state._hydrated).toBe(false); + }); + + it('hydrateFromSettings applies backend settings', () => { + useSettingsStore.getState().hydrateFromSettings(mockSettings); + const state = useSettingsStore.getState(); + + expect(state.theme).toBe('dark'); + expect(state.language).toBe('en-US'); + expect(state.themeColor).toBe('#FF0000'); + expect(state.effectMode).toBe('mica'); + expect(state.windowTransparency).toBe(50); + expect(state.blurRadius).toBe(32); + expect(state.customBlurEnabled).toBe(true); + expect(state.compositionBlurEnabled).toBe(true); + expect(state._hydrated).toBe(true); + }); + + it('setTheme updates theme', () => { + useSettingsStore.getState().setTheme('dark'); + expect(useSettingsStore.getState().theme).toBe('dark'); + useSettingsStore.getState().setTheme('light'); + expect(useSettingsStore.getState().theme).toBe('light'); + }); + + it('setWindowTransparency clamps to 0-100', () => { + useSettingsStore.getState().setWindowTransparency(150); + expect(useSettingsStore.getState().windowTransparency).toBe(100); + useSettingsStore.getState().setWindowTransparency(-10); + expect(useSettingsStore.getState().windowTransparency).toBe(0); + useSettingsStore.getState().setWindowTransparency(42); + expect(useSettingsStore.getState().windowTransparency).toBe(42); + }); + + it('setBlurRadius clamps to 0-100', () => { + useSettingsStore.getState().setBlurRadius(200); + expect(useSettingsStore.getState().blurRadius).toBe(100); + useSettingsStore.getState().setBlurRadius(-5); + expect(useSettingsStore.getState().blurRadius).toBe(0); + }); + + it('setCustomBlurEnabled=false also disables compositionBlur', () => { + useSettingsStore.getState().setCustomBlurEnabled(true); + useSettingsStore.getState().setCompositionBlurEnabled(true); + expect(useSettingsStore.getState().compositionBlurEnabled).toBe(true); + + useSettingsStore.getState().setCustomBlurEnabled(false); + expect(useSettingsStore.getState().customBlurEnabled).toBe(false); + expect(useSettingsStore.getState().compositionBlurEnabled).toBe(false); + }); + + it('resetToDefaults restores initial state', () => { + useSettingsStore.getState().hydrateFromSettings(mockSettings); + expect(useSettingsStore.getState()._hydrated).toBe(true); + + useSettingsStore.getState().resetToDefaults(); + const state = useSettingsStore.getState(); + expect(state.theme).toBe('system'); + expect(state._hydrated).toBe(false); + expect(state.effectMode).toBe('auto'); + }); +}); diff --git a/src/test/setup.ts b/src/test/setup.ts index 311bc85..9261e77 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -3,7 +3,6 @@ import { cleanup } from '@testing-library/react'; import { afterEach } from 'vitest'; declare global { - // eslint-disable-next-line no-var var __TAURI_INTERNALS__: { convertFileSrc: (path: string) => string; }; diff --git a/src/test/shellNavigation.test.ts b/src/test/shellNavigation.test.ts new file mode 100644 index 0000000..9a56277 --- /dev/null +++ b/src/test/shellNavigation.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { + getNodePath, + getNodeRoot, + getRouteNode, +} from '@/components/shell/navigation'; + +describe('shell navigation helpers', () => { + it('maps routes to the correct shell root node', () => { + expect(getRouteNode('/')).toBe('photos'); + expect(getRouteNode('/albums')).toBe('albums'); + expect(getRouteNode('/tags')).toBe('tags'); + expect(getRouteNode('/folders')).toBe('folders'); + expect(getRouteNode('/settings')).toBe('settings'); + expect(getRouteNode('/unknown')).toBe('photos'); + }); + + it('keeps detail nodes under the correct route root', () => { + expect(getNodeRoot('album:3')).toBe('albums'); + expect(getNodeRoot('tag:9')).toBe('tags'); + expect(getNodeRoot('folder:D:\\Photos')).toBe('folders'); + }); + + it('resolves node paths from both root and detail nodes', () => { + expect(getNodePath('photos')).toBe('/'); + expect(getNodePath('album:3')).toBe('/albums'); + expect(getNodePath('tag:9')).toBe('/tags'); + expect(getNodePath('folder:D:\\Photos')).toBe('/folders'); + }); +}); diff --git a/src/test/useTheme.test.ts b/src/test/useTheme.test.ts index 3d814f4..3838f28 100644 --- a/src/test/useTheme.test.ts +++ b/src/test/useTheme.test.ts @@ -1,34 +1,39 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { useTheme } from '@/hooks/useTheme'; +import { useSettingsStore } from '@/stores/settingsStore'; describe('useTheme', () => { beforeEach(() => { document.documentElement.classList.remove('dark'); + useSettingsStore.setState({ theme: 'system' }); }); - it('should always return light theme', () => { + it('should initialize with system theme from settingsStore', () => { const { result } = renderHook(() => useTheme()); - expect(result.current.theme).toBe('light'); - expect(result.current.resolvedTheme).toBe('light'); + expect(result.current.theme).toBe('system'); + expect(['light', 'dark']).toContain(result.current.resolvedTheme); }); - it('should remove dark class when hook is used', () => { - document.documentElement.classList.add('dark'); + it('should set light theme', () => { const { result } = renderHook(() => useTheme()); - // 调用 setTheme 触发 applyLightTheme + act(() => { - result.current.setTheme('dark'); + result.current.setTheme('light'); }); - expect(document.documentElement.classList.contains('dark')).toBe(false); + + expect(result.current.theme).toBe('light'); + expect(result.current.resolvedTheme).toBe('light'); }); - it('setTheme should be a no-op (always light)', () => { + it('should set dark theme', () => { const { result } = renderHook(() => useTheme()); + act(() => { result.current.setTheme('dark'); }); - expect(result.current.theme).toBe('light'); - expect(result.current.resolvedTheme).toBe('light'); + + expect(result.current.theme).toBe('dark'); + expect(result.current.resolvedTheme).toBe('dark'); }); }); diff --git a/src/types/index.ts b/src/types/index.ts index 2a9ce19..706723b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -382,6 +382,77 @@ export interface AppearanceConfig { fontSizeScale: number; } +/** + * 窗口外观设置 + */ +export interface WindowSettings { + /** 窗口效果模式 */ + effectMode: 'auto' | 'mica' | 'acrylic' | 'solid'; + /** 窗口透明度 (0-100),0=不透明,100=高度透明 */ + transparency: number; + /** 模糊半径 (0-100) */ + blurRadius: number; + /** 是否启用自定义桌面模糊 */ + customBlurEnabled: boolean; + /** 是否启用合成模糊 */ + compositionBlurEnabled: boolean; +} + +/** + * 筛选状态 + */ +export interface FilterState { + /** 文件类型列表,空数组表示不筛选 */ + fileTypes: string[]; + /** 日期范围 */ + dateRange: { from: string | null; to: string | null }; + /** 标签名列表,空数组表示不筛选 */ + tags: string[]; + /** 评分范围 (1-5) */ + rating: { min: number | null; max: number | null }; + /** 文件大小范围(字节) */ + sizeRange: { min: number | null; max: number | null }; +} + +/** + * 节点视图偏好 + */ +export interface NodeViewPreference { + viewMode: 'large' | 'medium' | 'small' | 'list' | 'detail' | 'tile'; + sortBy: 'dateTaken' | 'dateAdded' | 'fileName' | 'fileSize' | 'rating'; + sortOrder: 'asc' | 'desc'; +} + +/** + * 壳层状态设置 + */ +export interface ShellSettings { + /** 当前活动导航节点 */ + activeNode: string; + /** 展开的导航节点列表 */ + expandedNodes: string[]; + /** 导航窗格宽度 */ + navPaneWidth: number; + /** 预览窗格是否打开 */ + previewPaneOpen: boolean; + /** 预览窗格宽度 */ + previewPaneWidth: number; + /** 视图模式 */ + viewMode: 'large' | 'medium' | 'small' | 'list' | 'detail' | 'tile'; + /** 排序字段 */ + sortBy: 'dateTaken' | 'dateAdded' | 'fileName' | 'fileSize' | 'rating'; + /** 排序方向 */ + sortOrder: 'asc' | 'desc'; + /** 筛选状态 */ + filter: FilterState; + /** 详细信息视图列 */ + detailColumns: string[]; + /** 详细信息视图列宽 */ + detailColumnWidths: Record; + /** 各节点视图偏好 */ + viewPreferences: Record; +} + /** * 应用程序设置 */ @@ -398,6 +469,10 @@ export interface AppSettings { performance: PerformanceSettings; /** 外观设置 */ appearance: AppearanceConfig; + /** 窗口设置 */ + window: WindowSettings; + /** 壳层状态 */ + shell: ShellSettings; } // ============ 文件夹视图类型 ============ @@ -453,84 +528,6 @@ export function getAspectRatioCategory(width?: number, height?: number): AspectR return 'normal'; } -// ============ 照片编辑类型 ============ - -/** - * 翻转方向 - */ -export type FlipDirection = 'horizontal' | 'vertical'; - -/** - * 裁剪区域 - */ -export interface CropRect { - x: number; - y: number; - width: number; - height: number; -} - -/** - * 编辑操作类型 - */ -export type EditOperation = - | { type: 'rotate'; degrees: number } - | { type: 'flip'; direction: FlipDirection } - | { type: 'crop'; rect: CropRect } - | { type: 'brightness'; value: number } - | { type: 'contrast'; value: number } - | { type: 'saturation'; value: number } - | { type: 'exposure'; value: number } - | { type: 'sharpen'; value: number } - | { type: 'blur'; value: number } - | { type: 'highlights'; value: number } - | { type: 'shadows'; value: number } - | { type: 'temperature'; value: number } - | { type: 'tint'; value: number } - | { type: 'vignette'; value: number } - | { type: 'autoEnhance' }; - -/** - * 编辑参数 - */ -export interface EditParams { - operations: EditOperation[]; -} - -/** - * 编辑调整值(用于 UI 滑块) - */ -export interface EditAdjustments { - brightness: number; - contrast: number; - saturation: number; - exposure: number; - highlights: number; - shadows: number; - temperature: number; - tint: number; - sharpen: number; - blur: number; - vignette: number; -} - -/** - * 默认编辑调整值 - */ -export const DEFAULT_ADJUSTMENTS: EditAdjustments = { - brightness: 0, - contrast: 0, - saturation: 0, - exposure: 0, - highlights: 0, - shadows: 0, - temperature: 0, - tint: 0, - sharpen: 0, - blur: 0, - vignette: 0, -}; - // ============ OCR 类型 ============ /** diff --git a/vite.config.ts b/vite.config.ts index cc13392..77b7e3f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -21,11 +21,16 @@ export default defineConfig(async () => ({ vendor: ['react', 'react-dom', 'react-router-dom'], tauri: ['@tauri-apps/api', '@tauri-apps/plugin-dialog', '@tauri-apps/plugin-shell'], query: ['@tanstack/react-query'], + fluent: ['@fluentui/react-components', '@fluentui/react-icons'], ui: ['react-virtuoso', 'clsx'], }, }, }, }, + // 预打包 Fluent UI 以加速开发服务器冷启动 + optimizeDeps: { + include: ['@fluentui/react-components', '@fluentui/react-icons'], + }, // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` // // 1. prevent Vite from obscuring rust errors