From f84111e837289993891b6e2feb57c080b9f60f38 Mon Sep 17 00:00:00 2001 From: lincube Date: Mon, 30 Mar 2026 02:40:10 +0800 Subject: [PATCH] 0.7.9.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自习设置,优化设置选项卡图标,加入智教hub组件 --- .github/README.md | 45 -- .github/READMEmd | 133 +++++ LanMountainDesktop/LanMountainDesktop.csproj | 4 +- LanMountainDesktop/Localization/en-US.json | 46 ++ LanMountainDesktop/Localization/ko-KR.json | 46 ++ LanMountainDesktop/Localization/zh-CN.json | 46 ++ .../Models/AppSettingsSnapshot.cs | 30 + .../Services/IRecommendationDataService.cs | 31 ++ .../Services/RecommendationDataService.cs | 114 ++++ .../Services/ZhiJiaoHubCacheService.cs | 165 +++++- .../ViewModels/SettingsViewModels.cs | 521 ++++++++++++++++++ .../Views/ComponentLibraryWindow.axaml.cs | 2 +- .../Views/Components/ZhiJiaoHubWidget.axaml | 2 +- .../Components/ZhiJiaoHubWidget.axaml.cs | 453 +++++++++------ .../Views/MainWindow.ComponentSystem.cs | 2 +- .../Views/MainWindow.SettingsHardCut.Stubs.cs | 28 +- .../ComponentsSettingsPage.axaml.cs | 2 +- .../LauncherSettingsPage.axaml.cs | 2 +- .../StatusBarSettingsPage.axaml.cs | 2 +- .../SettingsPages/StudySettingsPage.axaml | 334 +++++++++++ .../SettingsPages/StudySettingsPage.axaml.cs | 43 ++ .../Views/SettingsWindow.axaml.cs | 6 + 22 files changed, 1821 insertions(+), 236 deletions(-) delete mode 100644 .github/README.md create mode 100644 .github/READMEmd create mode 100644 LanMountainDesktop/Views/SettingsPages/StudySettingsPage.axaml create mode 100644 LanMountainDesktop/Views/SettingsPages/StudySettingsPage.axaml.cs diff --git a/.github/README.md b/.github/README.md deleted file mode 100644 index 809b3ea..0000000 --- a/.github/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# LanMountainDesktop - -> 你的桌面,不止一面。 - -`LanMountainDesktop` 是一个基于 Avalonia 的桌面壳层项目,目标不是“做一个启动器”,而是把桌面变成可编排的信息与交互空间。 - -## 项目定位 -- 以网格化布局组织桌面组件,支持多页桌面与组件自由摆放。 -- 提供顶部状态栏 + 底部任务栏的桌面框架,强调信息密度与可读性平衡。 -- 通过主题色、日夜模式、玻璃视觉与动画系统,形成统一的视觉语言。 -- 通过组件注册机制与 JSON 扩展入口,让桌面能力可持续扩展。 - -## 核心能力 -- 桌面组件系统:天气、时钟、计时器、课程表、日历、白板、音乐控制、学习环境等组件可组合使用。 -- 壁纸系统:支持图片与视频壁纸,并可在设置中实时预览。 -- 主题系统:支持日夜模式、主题色与调色联动(Monet 风格色板)。 -- 个性化设置:网格密度、状态栏间距、任务栏布局、语言与时区等可持久化配置。 -- 本地化:内置 `zh-CN` 与 `en-US` 资源。 - -## 工程结构 -- `LanMountainDesktop/`:桌面端主程序(Avalonia)。 -- `LanMountainDesktop.RecommendationBackend/`:推荐内容后端服务(ASP.NET Core Minimal API)。 -- `docs/`:视觉与圆角等规范文档。 -- `LanMountainDesktop/ComponentSystem/`:组件定义、注册、放置规则与扩展入口。 - -## 技术栈 -- .NET 10(`net10.0`) -- Avalonia 11 -- FluentAvalonia + FluentIcons.Avalonia -- LibVLCSharp(用于视频相关能力) -- WebView.Avalonia(嵌入式网页组件能力) - -## 扩展机制(摘要) -- 组件系统通过 `ComponentRegistry` 合并内置组件与扩展组件。 -- 运行时会扫描 `Extensions/Components/*.json`(相对应用输出目录)加载第三方组件清单。 -- 扩展契约与字段说明见组件系统文档:`LanMountainDesktop/ComponentSystem/README.md`。 - -## 当前状态 -- 项目包含桌面端与推荐后端两个子项目,并在同一 `LanMountainDesktop.slnx` 工作区中维护。 -- 配置默认写入本地:`%LOCALAPPDATA%\LanMountainDesktop\settings.json`。 -- 当前体验以 Windows 为主要目标平台。 -- SDK 版本由仓库根目录 `global.json` 锁定。 - -## 运行说明 -运行与环境准备已拆分到独立文档:[`run.md`](./run.md) diff --git a/.github/READMEmd b/.github/READMEmd new file mode 100644 index 0000000..47ff11b --- /dev/null +++ b/.github/READMEmd @@ -0,0 +1,133 @@ +# 阑山桌面 / LanMountainDesktop + +> 你的桌面,不止一面 + +[![.NET 10](https://img.shields.io/badge/.NET-10-512BD4)](https://dotnet.microsoft.com/) +[![Avalonia UI](https://img.shields.io/badge/Avalonia%20UI-11.2-blue)](https://avaloniaui.net/) +[![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) + +> [!IMPORTANT] +> **温馨提示**:本项目有部分成分由**氛围编程 (Vibe Coding)** 方式编写。 +> +> 如果您对此类项目有固有的排斥感,请无视此项目,谢谢。 + +## 简介 + +**阑山桌面**是一个跨平台桌面环境增强工具,面向需要高频查看信息、追求桌面效率与个性化体验的用户。 + +基于 Avalonia UI 和 .NET 10 构建,支持 Windows、Linux、macOS 三大平台。 + +![Platform](https://img.shields.io/badge/Windows-✓-0078D4) +![Platform](https://img.shields.io/badge/Linux-✓-FCC624?logo=linux&logoColor=black) +![Platform](https://img.shields.io/badge/macOS-✓-000000?logo=apple) + +## 核心特性 + +### 📊 信息聚合 +- 课程表、日历、天气、新闻、热搜 +- 所有信息一目了然,无需频繁切换窗口 + +### 🎯 效率工具 +- 自习环境监测、计时器、知识卡片 +- 最近文档、浏览器快捷入口 +- 常用工具组件一键触达 + +### 🎨 个性化桌面 +- 自由布局,随心所欲摆放组件 +- 多页桌面,工作学习场景分离 +- 主题切换、玻璃效果、圆角风格 + +### 🔌 插件生态 +- 通过 `.laapp` 插件扩展功能 +- 官方 Plugin SDK 支持自定义组件 +- 设置页、组件、集成功能一站式接入 + +## 为谁而设计 + +| 用户类型 | 典型场景 | +|---------|---------| +| 🎓 学生用户 | 课程表、自习监测、计时、天气和日常信息聚合 | +| 💼 办公用户 | 日历、资讯、最近文档、常用工具入口 | +| 🎨 效率爱好者 | 自由布局、主题切换、插件扩展 | +| 🇨🇳 中文用户 | 本地化界面、农历和节假日等本地语境支持 | + +## 快速开始 + +### 环境要求 +- .NET SDK 10 + +### 构建与运行 + +```bash +# 还原依赖 +dotnet restore + +# 构建项目 +dotnet build LanMountainDesktop.slnx -c Debug + +# 运行桌面宿主 +dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj +``` + +### 运行测试 + +```bash +dotnet test LanMountainDesktop.slnx -c Debug +``` + +## 插件开发 + +阑山桌面支持通过 Plugin SDK 开发自定义插件: + +```bash +# 安装插件模板 +dotnet new install LanMountainDesktop.PluginTemplate + +# 创建新插件 +dotnet new lmd-plugin -n MyPlugin +``` + +- **Plugin SDK**: `LanMountainDesktop.PluginSdk` (API 4.0.0) +- **共享契约**: `LanMountainDesktop.Shared.Contracts` +- **迁移指南**: [PLUGIN_SDK_V4_MIGRATION.md](docs/PLUGIN_SDK_V4_MIGRATION.md) + +## 项目结构 + +``` +LanMountainDesktop/ +├── LanMountainDesktop/ # 桌面宿主应用 +├── LanMountainDesktop.PluginSdk/ # 官方插件 SDK +├── LanMountainDesktop.Shared.Contracts/ # 宿主与插件共享契约 +├── LanMountainDesktop.Appearance/ # 主题与外观基础设施 +├── LanMountainDesktop.Settings.Core/# 设置持久化基础设施 +└── LanMountainDesktop.Tests/ # 测试项目 +``` + +## 生态边界 + +| 项目 | 职责 | +|-----|------| +| **本仓库** | 桌面宿主、插件运行时、Plugin SDK、共享契约 | +| [LanAirApp](https://github.com/yourorg/LanAirApp) | 插件市场元数据、开发者生态材料 | +| [LanMountainDesktop.SamplePlugin](https://github.com/yourorg/LanMountainDesktop.SamplePlugin) | 官方示例插件 | + +## 文档索引 + +- [产品定位](docs/PRODUCT.md) - 产品愿景与目标用户 +- [架构说明](docs/ARCHITECTURE.md) - 仓库结构与运行时主线 +- [开发指南](docs/DEVELOPMENT.md) - 构建、测试、调试 +- [视觉规范](docs/VISUAL_SPEC.md) - 主题、颜色、玻璃层级 +- [圆角规范](docs/CORNER_RADIUS_SPEC.md) - 圆角层级与动态规则 +- [贡献指南](docs/CONTRIBUTING.md) - PR、spec、文档协作规则 + +## 技术栈 + +- **UI 框架**: [Avalonia UI](https://avaloniaui.net/) +- **开发平台**: [.NET 10](https://dotnet.microsoft.com/) +- **支持平台**: Windows 10+, Linux, macOS + +## 许可证 + +[MIT](LICENSE) + + diff --git a/LanMountainDesktop/LanMountainDesktop.csproj b/LanMountainDesktop/LanMountainDesktop.csproj index f677fb0..58fd65e 100644 --- a/LanMountainDesktop/LanMountainDesktop.csproj +++ b/LanMountainDesktop/LanMountainDesktop.csproj @@ -53,8 +53,8 @@ - - + + diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index 56041c1..f0de867 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -203,6 +203,52 @@ "settings.weather.alert_list_desc": "One exclusion rule per line.", "settings.weather.no_tls_toggle": "Allow non-TLS request fallback", "settings.weather.footer_hint": "Desktop weather widgets will reuse the location and alert exclusion settings configured here.", + "settings.study.title": "Study", + "settings.study.description": "Configure study environment monitoring, focus timer, and alert settings.", + "settings.study.noise_header": "Noise Monitoring", + "settings.study.noise_description": "Configure microphone sampling rate and noise scoring sensitivity.", + "settings.study.sampling_rate_label": "Sampling Rate", + "settings.study.sampling_rate_desc": "Time interval for microphone audio sampling. Higher frequency captures noise changes more accurately but increases power consumption.", + "settings.study.sampling_rate_20ms": "20ms (High)", + "settings.study.sampling_rate_50ms": "50ms (Standard)", + "settings.study.sampling_rate_100ms": "100ms (Power Saving)", + "settings.study.sampling_rate_200ms": "200ms (Low Power)", + "settings.study.sensitivity_label": "Noise Sensitivity", + "settings.study.sensitivity_desc": "Scoring threshold determines what level of noise is considered interference. Stricter thresholds detect quieter noises.", + "settings.study.sensitivity_relaxed": "Relaxed (-45dBFS)", + "settings.study.sensitivity_standard": "Standard (-50dBFS)", + "settings.study.sensitivity_strict": "Strict (-55dBFS)", + "settings.study.sensitivity_very_strict": "Very Strict (-60dBFS)", + "settings.study.current_threshold_format": "Current scoring threshold: {0} dBFS", + "settings.study.timer_header": "Focus Timer", + "settings.study.timer_description": "Configure focus and break session durations.", + "settings.study.focus_duration_label": "Focus Duration", + "settings.study.focus_duration_desc": "Duration of a single focus session (minutes).", + "settings.study.break_duration_label": "Break Duration", + "settings.study.break_duration_desc": "Duration of a short break session (minutes).", + "settings.study.long_break_duration_label": "Long Break Duration", + "settings.study.long_break_duration_desc": "Duration of a long break session (minutes).", + "settings.study.sessions_before_long_break_label": "Long Break Interval", + "settings.study.sessions_before_long_break_desc": "Number of focus sessions before a long break.", + "settings.study.auto_start_break_label": "Auto-start Break", + "settings.study.auto_start_break_desc": "Automatically start break timer when focus session ends.", + "settings.study.auto_start_focus_label": "Auto-start Focus", + "settings.study.auto_start_focus_desc": "Automatically start focus timer when break ends.", + "settings.study.alert_header": "Alert Settings", + "settings.study.alert_description": "Configure noise interference alerts.", + "settings.study.noise_alert_enabled_label": "Enable Noise Alert", + "settings.study.noise_alert_enabled_desc": "Show an alert when noise interference exceeds tolerance threshold.", + "settings.study.max_interrupts_label": "Max Tolerated Interrupts", + "settings.study.max_interrupts_desc": "Maximum noise interference events per minute before triggering an alert.", + "settings.study.display_header": "Display Settings", + "settings.study.display_description": "Configure how noise data is displayed.", + "settings.study.show_realtime_db_label": "Show Realtime Decibel", + "settings.study.show_realtime_db_desc": "Display decibel values in real-time on components.", + "settings.study.baseline_db_label": "Baseline Display Decibel", + "settings.study.baseline_db_desc": "Calibrated baseline decibel value for converting dBFS to user-readable dB.", + "settings.study.avg_window_label": "Averaging Window", + "settings.study.avg_window_desc": "Time window for smoothing noise display. Larger values make display more stable but slower to respond.", + "settings.study.footer_hint": "These settings affect the behavior of study environment monitoring components.", "settings.weather.location_header": "Weather Location", "settings.weather.location_desc": "Set the location used by weather widgets.", "settings.weather.location_placeholder": "e.g. Beijing", diff --git a/LanMountainDesktop/Localization/ko-KR.json b/LanMountainDesktop/Localization/ko-KR.json index f38539d..a3a6879 100644 --- a/LanMountainDesktop/Localization/ko-KR.json +++ b/LanMountainDesktop/Localization/ko-KR.json @@ -210,6 +210,52 @@ "settings.weather.location_required": "날씨 위치는 비워둘 수 없습니다.", "settings.weather.location_current_format": "현재 날씨 위치: {0}", "settings.weather.location_saved_format": "날씨 위치 저장됨: {0}", + "settings.study.title": "공부", + "settings.study.description": "공부 환경 모니터링, 집중 타이머 및 알림 설정을 구성합니다.", + "settings.study.noise_header": "소음 모니터링", + "settings.study.noise_description": "마이크 샘플링 주파수와 소음 감도를 구성합니다.", + "settings.study.sampling_rate_label": "샘플링 주파수", + "settings.study.sampling_rate_desc": "마이크가 오디오를 샘플링하는 시간 간격입니다. 더 높은 주파수는 소음 변화를 더 정확하게 포착하지만 배터리 소모가 증가합니다.", + "settings.study.sampling_rate_20ms": "20ms (고주파)", + "settings.study.sampling_rate_50ms": "50ms (표준)", + "settings.study.sampling_rate_100ms": "100ms (절전)", + "settings.study.sampling_rate_200ms": "200ms (저전력)", + "settings.study.sensitivity_label": "소음 감도", + "settings.study.sensitivity_desc": "평가 임계값은 어떤 수준의 소음이 간주되는지 결정합니다. 임계값이 엄격할수록 미세한 소음도 감지하기 쉽습니다.", + "settings.study.sensitivity_relaxed": "느슨함 (-45dBFS)", + "settings.study.sensitivity_standard": "표준 (-50dBFS)", + "settings.study.sensitivity_strict": "엄격 (-55dBFS)", + "settings.study.sensitivity_very_strict": "매우 엄격 (-60dBFS)", + "settings.study.current_threshold_format": "현재 평가 임계값: {0} dBFS", + "settings.study.timer_header": "집중 타이머", + "settings.study.timer_description": "집중 시간과 휴식 시간을 구성합니다.", + "settings.study.focus_duration_label": "집중 시간", + "settings.study.focus_duration_desc": "한 번의 집중 세션 지속 시간(분)입니다.", + "settings.study.break_duration_label": "휴식 시간", + "settings.study.break_duration_desc": "짧은 휴식 세션의 지속 시간(분)입니다.", + "settings.study.long_break_duration_label": "긴 휴식 시간", + "settings.study.long_break_duration_desc": "긴 휴식 세션의 지속 시간(분)입니다.", + "settings.study.sessions_before_long_break_label": "긴 휴식 간격", + "settings.study.sessions_before_long_break_desc": "긴 휴식을 트리거하기 전의 집중 세션 수입니다.", + "settings.study.auto_start_break_label": "휴식 자동 시작", + "settings.study.auto_start_break_desc": "집중 세션이 끝나면 자동으로 휴식 타이머를 시작합니다.", + "settings.study.auto_start_focus_label": "집중 자동 시작", + "settings.study.auto_start_focus_desc": "휴식 세션이 끝나면 자동으로 집중 타이머를 시작합니다.", + "settings.study.alert_header": "알림 설정", + "settings.study.alert_description": "소음 방해 알림을 구성합니다.", + "settings.study.noise_alert_enabled_label": "소음 알림 활성화", + "settings.study.noise_alert_enabled_desc": "허용 임계값을 초과하는 소음 방해가 감지되면 알림을 표시합니다.", + "settings.study.max_interrupts_label": "최대 허용 중단 횟수", + "settings.study.max_interrupts_desc": "분당 허용되는 최대 소음 방해 이벤트 수로, 이 값을 초과하면 알림이 트리거됩니다.", + "settings.study.display_header": "표시 설정", + "settings.study.display_description": "소음 데이터 표시 방법을 구성합니다.", + "settings.study.show_realtime_db_label": "실시간 데시벨 표시", + "settings.study.show_realtime_db_desc": "구성 요소에 실시간 데시벨 값을 표시합니다.", + "settings.study.baseline_db_label": "기준 표시 데시벨", + "settings.study.baseline_db_desc": "dBFS를 사용자가 읽을 수 있는 dB로 변환하기 위한 보정된 기준 데시벨 값입니다.", + "settings.study.avg_window_label": "평균 시간 창", + "settings.study.avg_window_desc": "소음 평활 표시를 위한 시간 창입니다. 값이 클수록 표시가 더 안정적이지만 응답이 느려집니다.", + "settings.study.footer_hint": "이러한 설정은 공부 환경 모니터링 구성 요소의 동작에 영향을 미칩니다.", "weather.widget.location_not_configured": "날씨 위치가 구성되지 않음", "weather.widget.configure_hint": "설정 > 날씨에서 구성을 완료하세요", "weather.widget.loading": "로딩 중...", diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index 3685857..7f82812 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -206,6 +206,52 @@ "settings.weather.location_required": "天气位置不能为空。", "settings.weather.location_current_format": "当前天气位置:{0}", "settings.weather.location_saved_format": "天气位置已保存:{0}", + "settings.study.title": "自习", + "settings.study.description": "配置自习环境监测、专注计时和提醒设置。", + "settings.study.noise_header": "噪音监测", + "settings.study.noise_description": "配置麦克风采集频率和噪音评分敏感度。", + "settings.study.sampling_rate_label": "采集频率", + "settings.study.sampling_rate_desc": "麦克风采集音频的时间间隔。更高的频率会更准确地捕捉噪音变化,但会增加电量消耗。", + "settings.study.sampling_rate_20ms": "20ms (高频)", + "settings.study.sampling_rate_50ms": "50ms (标准)", + "settings.study.sampling_rate_100ms": "100ms (节能)", + "settings.study.sampling_rate_200ms": "200ms (低功耗)", + "settings.study.sensitivity_label": "噪音敏感度", + "settings.study.sensitivity_desc": "评分阈值决定了什么级别的噪音会被认为是干扰。阈值越严格,越容易检测到轻微噪音。", + "settings.study.sensitivity_relaxed": "宽松 (-45dBFS)", + "settings.study.sensitivity_standard": "标准 (-50dBFS)", + "settings.study.sensitivity_strict": "严格 (-55dBFS)", + "settings.study.sensitivity_very_strict": "极严 (-60dBFS)", + "settings.study.current_threshold_format": "当前评分阈值: {0} dBFS", + "settings.study.timer_header": "专注计时", + "settings.study.timer_description": "配置专注时段和休息时段的时长。", + "settings.study.focus_duration_label": "专注时长", + "settings.study.focus_duration_desc": "单次专注时段的持续时间(分钟)。", + "settings.study.break_duration_label": "休息时长", + "settings.study.break_duration_desc": "短休息时段的持续时间(分钟)。", + "settings.study.long_break_duration_label": "长休息时长", + "settings.study.long_break_duration_desc": "长休息时段的持续时间(分钟)。", + "settings.study.sessions_before_long_break_label": "长休息间隔", + "settings.study.sessions_before_long_break_desc": "经过几个专注时段后触发长休息。", + "settings.study.auto_start_break_label": "自动开始休息", + "settings.study.auto_start_break_desc": "专注时段结束后自动开始休息计时。", + "settings.study.auto_start_focus_label": "自动开始专注", + "settings.study.auto_start_focus_desc": "休息时段结束后自动开始专注计时。", + "settings.study.alert_header": "提醒设置", + "settings.study.alert_description": "配置噪音干扰提醒。", + "settings.study.noise_alert_enabled_label": "启用噪音提醒", + "settings.study.noise_alert_enabled_desc": "当检测到超过容忍阈值的噪音干扰时显示提醒。", + "settings.study.max_interrupts_label": "最大容忍打断次数", + "settings.study.max_interrupts_desc": "每分钟最多允许多少次噪音干扰事件,超过此值将触发提醒。", + "settings.study.display_header": "显示设置", + "settings.study.display_description": "配置噪音数据的显示方式。", + "settings.study.show_realtime_db_label": "显示实时分贝", + "settings.study.show_realtime_db_desc": "在组件中实时显示分贝值。", + "settings.study.baseline_db_label": "基准显示分贝", + "settings.study.baseline_db_desc": "校准后的显示分贝基准值,用于将 dBFS 转换为用户可读的 dB 值。", + "settings.study.avg_window_label": "平均时间窗", + "settings.study.avg_window_desc": "噪音平滑显示的时间窗口,较大的值会使显示更稳定但响应更慢。", + "settings.study.footer_hint": "这些设置将影响自习环境监测组件的行为。", "weather.widget.location_not_configured": "尚未配置天气位置", "weather.widget.configure_hint": "请前往 设置 > 天气 完成配置", "weather.widget.loading": "加载中...", diff --git a/LanMountainDesktop/Models/AppSettingsSnapshot.cs b/LanMountainDesktop/Models/AppSettingsSnapshot.cs index 44e70ba..de2634f 100644 --- a/LanMountainDesktop/Models/AppSettingsSnapshot.cs +++ b/LanMountainDesktop/Models/AppSettingsSnapshot.cs @@ -118,6 +118,36 @@ public sealed class AppSettingsSnapshot public List DisabledPluginIds { get; set; } = []; + #region Study Settings + + public int? StudyFrameMs { get; set; } + + public double? StudyScoreThresholdDbfs { get; set; } + + public int? StudyFocusDurationMinutes { get; set; } + + public int? StudyBreakDurationMinutes { get; set; } + + public int? StudyLongBreakDurationMinutes { get; set; } + + public int? StudySessionsBeforeLongBreak { get; set; } + + public bool? StudyAutoStartBreak { get; set; } + + public bool? StudyAutoStartFocus { get; set; } + + public bool? StudyNoiseAlertEnabled { get; set; } + + public int? StudyMaxInterruptsPerMinute { get; set; } + + public bool? StudyShowRealtimeDb { get; set; } + + public double? StudyBaselineDb { get; set; } + + public int? StudyAvgWindowSec { get; set; } + + #endregion + public AppSettingsSnapshot Clone() { var clone = (AppSettingsSnapshot)MemberwiseClone(); diff --git a/LanMountainDesktop/Services/IRecommendationDataService.cs b/LanMountainDesktop/Services/IRecommendationDataService.cs index 70f49a5..7ef61f6 100644 --- a/LanMountainDesktop/Services/IRecommendationDataService.cs +++ b/LanMountainDesktop/Services/IRecommendationDataService.cs @@ -68,6 +68,19 @@ public sealed record ZhiJiaoHubSnapshot( int CurrentIndex, string Source); +public sealed record ZhiJiaoHubHybridImageItem( + string Name, + string RemoteUrl, + string? LocalPath, + int Index, + bool IsCached); + +public sealed record ZhiJiaoHubHybridSnapshot( + IReadOnlyList Images, + string Source, + int CachedCount, + int TotalCount); + public sealed record RecommendationQueryResult( bool Success, T? Data, @@ -353,6 +366,24 @@ public interface IRecommendationInfoService ZhiJiaoHubQuery query, CancellationToken cancellationToken = default); + Task> GetZhiJiaoHubHybridImagesAsync( + string source, + string mirrorSource, + CancellationToken cancellationToken = default); + + Task DownloadAndCacheImageAsync( + string source, + ZhiJiaoHubImageItem image, + string mirrorSource, + CancellationToken cancellationToken = default); + + Task StartBackgroundDownloadAsync( + string source, + IReadOnlyList images, + string mirrorSource, + Action? onProgress = null, + CancellationToken cancellationToken = default); + Task SyncZhiJiaoHubImagesAsync( string source, string mirrorSource, diff --git a/LanMountainDesktop/Services/RecommendationDataService.cs b/LanMountainDesktop/Services/RecommendationDataService.cs index af07546..78b0270 100644 --- a/LanMountainDesktop/Services/RecommendationDataService.cs +++ b/LanMountainDesktop/Services/RecommendationDataService.cs @@ -3456,4 +3456,118 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis var normalizedSource = ZhiJiaoHubSources.Normalize(source); return _zhiJiaoHubCacheService.HasLocalCache(normalizedSource); } + + public async Task> GetZhiJiaoHubHybridImagesAsync( + string source, + string mirrorSource, + CancellationToken cancellationToken = default) + { + var normalizedSource = ZhiJiaoHubSources.Normalize(source); + var normalizedMirror = ZhiJiaoHubMirrorSources.Normalize(mirrorSource); + + var localPathMap = _zhiJiaoHubCacheService.LoadLocalPathMap(normalizedSource); + + try + { + var query = new ZhiJiaoHubQuery(normalizedSource, ForceRefresh: true, MirrorSource: normalizedMirror); + var result = await GetZhiJiaoHubImagesAsync(query, cancellationToken); + + if (!result.Success || result.Data == null) + { + return RecommendationQueryResult.Fail( + result.ErrorCode ?? "upstream_error", + result.ErrorMessage ?? "Failed to fetch image list"); + } + + var hybridImages = result.Data.Images.Select((img, idx) => + { + var hasLocal = localPathMap.TryGetValue(img.Url, out var localPath); + return new ZhiJiaoHubHybridImageItem( + img.Name, + img.Url, + hasLocal ? localPath : null, + idx, + hasLocal); + }).ToList(); + + var snapshot = new ZhiJiaoHubHybridSnapshot( + hybridImages, + normalizedSource, + hybridImages.Count(i => i.IsCached), + hybridImages.Count); + + return RecommendationQueryResult.Ok(snapshot); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return RecommendationQueryResult.Fail("upstream_network_error", ex.Message); + } + } + + public async Task DownloadAndCacheImageAsync( + string source, + ZhiJiaoHubImageItem image, + string mirrorSource, + CancellationToken cancellationToken = default) + { + var normalizedSource = ZhiJiaoHubSources.Normalize(source); + var normalizedMirror = ZhiJiaoHubMirrorSources.Normalize(mirrorSource); + + return await _zhiJiaoHubCacheService.DownloadAndSaveImageAsync( + normalizedSource, + image.Name, + image.Url, + normalizedMirror, + cancellationToken); + } + + public Task StartBackgroundDownloadAsync( + string source, + IReadOnlyList images, + string mirrorSource, + Action? onProgress = null, + CancellationToken cancellationToken = default) + { + var normalizedSource = ZhiJiaoHubSources.Normalize(source); + var normalizedMirror = ZhiJiaoHubMirrorSources.Normalize(mirrorSource); + + return Task.Run(async () => + { + var uncachedImages = images.Where(i => !i.IsCached).ToList(); + var total = uncachedImages.Count; + var downloaded = 0; + + foreach (var image in uncachedImages) + { + if (cancellationToken.IsCancellationRequested) + { + break; + } + + try + { + var localPath = await _zhiJiaoHubCacheService.DownloadAndSaveImageAsync( + normalizedSource, + image.Name, + image.RemoteUrl, + normalizedMirror, + cancellationToken); + + if (localPath != null) + { + downloaded++; + } + + onProgress?.Invoke(downloaded, total, image.Name); + } + catch + { + } + } + }, cancellationToken); + } } diff --git a/LanMountainDesktop/Services/ZhiJiaoHubCacheService.cs b/LanMountainDesktop/Services/ZhiJiaoHubCacheService.cs index d515820..aa0baa2 100644 --- a/LanMountainDesktop/Services/ZhiJiaoHubCacheService.cs +++ b/LanMountainDesktop/Services/ZhiJiaoHubCacheService.cs @@ -7,6 +7,7 @@ using System.Security.Authentication; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using LanMountainDesktop.Models; namespace LanMountainDesktop.Services; @@ -139,6 +140,119 @@ public sealed class ZhiJiaoHubCacheService : IDisposable } } + public Dictionary LoadLocalPathMap(string source) + { + lock (_manifestLock) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (!File.Exists(_manifestPath)) + { + return result; + } + + try + { + var json = File.ReadAllText(_manifestPath); + var manifest = JsonSerializer.Deserialize(json, JsonOptions); + if (manifest?.Entries?.TryGetValue(source, out var entry) != true) + { + return result; + } + + var sourceDir = GetSourceDirectory(source); + foreach (var img in entry.Images) + { + var localPath = Path.Combine(sourceDir, img.LocalFileName); + if (File.Exists(localPath)) + { + result[img.OriginalUrl] = localPath; + } + } + + return result; + } + catch + { + return result; + } + } + } + + public string? GetLocalPath(string source, string originalUrl) + { + lock (_manifestLock) + { + if (!File.Exists(_manifestPath)) + { + return null; + } + + try + { + var json = File.ReadAllText(_manifestPath); + var manifest = JsonSerializer.Deserialize(json, JsonOptions); + if (manifest?.Entries?.TryGetValue(source, out var entry) != true) + { + return null; + } + + var img = entry.Images.FirstOrDefault(i => + string.Equals(i.OriginalUrl, originalUrl, StringComparison.OrdinalIgnoreCase)); + + if (img == null) + { + return null; + } + + var sourceDir = GetSourceDirectory(source); + var localPath = Path.Combine(sourceDir, img.LocalFileName); + return File.Exists(localPath) ? localPath : null; + } + catch + { + return null; + } + } + } + + public async Task DownloadAndSaveImageAsync( + string source, + string name, + string remoteUrl, + string mirrorSource, + CancellationToken cancellationToken = default) + { + var sourceDir = GetSourceDirectory(source); + Directory.CreateDirectory(sourceDir); + + var fileName = GetSafeFileName(name, remoteUrl); + var localPath = Path.Combine(sourceDir, fileName); + + if (File.Exists(localPath)) + { + AddToManifest(source, name, remoteUrl, fileName); + return localPath; + } + + try + { + var downloadUrl = ResolveDownloadUrl(remoteUrl, mirrorSource); + using var response = await DownloadClient.GetAsync(downloadUrl, cancellationToken); + response.EnsureSuccessStatusCode(); + + await using var fileStream = File.Create(localPath); + await response.Content.CopyToAsync(fileStream, cancellationToken); + + AddToManifest(source, name, remoteUrl, fileName); + return localPath; + } + catch + { + return null; + } + } + public async Task SyncImagesAsync( string source, IReadOnlyList remoteImages, @@ -159,12 +273,6 @@ public sealed class ZhiJiaoHubCacheService : IDisposable var failedCount = 0; var localImages = new List(); - var existingFiles = new HashSet( - Directory.Exists(sourceDir) - ? Directory.GetFiles(sourceDir, "*.jpg").Concat(Directory.GetFiles(sourceDir, "*.png")).Concat(Directory.GetFiles(sourceDir, "*.gif")) - : Array.Empty(), - StringComparer.OrdinalIgnoreCase); - for (var i = 0; i < remoteImages.Count; i++) { cancellationToken.ThrowIfCancellationRequested(); @@ -291,6 +399,51 @@ public sealed class ZhiJiaoHubCacheService : IDisposable return originalUrl; } + private void AddToManifest(string source, string name, string originalUrl, string localFileName) + { + lock (_manifestLock) + { + CacheManifest manifest; + if (File.Exists(_manifestPath)) + { + try + { + var json = File.ReadAllText(_manifestPath); + manifest = JsonSerializer.Deserialize(json, JsonOptions) ?? new CacheManifest(); + } + catch + { + manifest = new CacheManifest(); + } + } + else + { + manifest = new CacheManifest(); + } + + if (!manifest.Entries.TryGetValue(source, out var entry)) + { + entry = new CacheEntry(new List(), DateTimeOffset.UtcNow); + manifest.Entries[source] = entry; + } + + var existingIndex = entry.Images.FindIndex(i => + string.Equals(i.OriginalUrl, originalUrl, StringComparison.OrdinalIgnoreCase)); + + if (existingIndex >= 0) + { + entry.Images[existingIndex] = new CachedImageInfo(name, originalUrl, localFileName); + } + else + { + entry.Images.Add(new CachedImageInfo(name, originalUrl, localFileName)); + } + + Directory.CreateDirectory(Path.GetDirectoryName(_manifestPath)!); + File.WriteAllText(_manifestPath, JsonSerializer.Serialize(manifest, JsonOptions)); + } + } + private void SaveManifest(string source, List images) { lock (_manifestLock) diff --git a/LanMountainDesktop/ViewModels/SettingsViewModels.cs b/LanMountainDesktop/ViewModels/SettingsViewModels.cs index 649d5a4..95ced56 100644 --- a/LanMountainDesktop/ViewModels/SettingsViewModels.cs +++ b/LanMountainDesktop/ViewModels/SettingsViewModels.cs @@ -2322,6 +2322,527 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase => _localizationService.GetString(_languageCode, key, fallback); } +public sealed partial class StudySettingsPageViewModel : ViewModelBase +{ + private readonly ISettingsFacadeService _settingsFacade; + private readonly LocalizationService _localizationService = new(); + private readonly string _languageCode; + private bool _isInitializing; + private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault(); + + public StudySettingsPageViewModel(ISettingsFacadeService settingsFacade) + { + _settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade)); + _languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode); + + RefreshLocalizedText(); + + _isInitializing = true; + LoadSettings(); + _isInitializing = false; + } + + #region Properties - Noise Monitoring + + [ObservableProperty] + private string _noiseMonitoringHeader = string.Empty; + + [ObservableProperty] + private string _noiseMonitoringDescription = string.Empty; + + [ObservableProperty] + private string _samplingRateLabel = string.Empty; + + [ObservableProperty] + private string _samplingRateDescription = string.Empty; + + [ObservableProperty] + private int _samplingRateMs = 50; + + [ObservableProperty] + private string _samplingRateValueText = string.Empty; + + [ObservableProperty] + private string _noiseSensitivityLabel = string.Empty; + + [ObservableProperty] + private string _noiseSensitivityDescription = string.Empty; + + [ObservableProperty] + private double _noiseSensitivityDbfs = -50; + + [ObservableProperty] + private string _noiseSensitivityValueText = string.Empty; + + [ObservableProperty] + private string _currentThresholdText = string.Empty; + + partial void OnNoiseSensitivityDbfsChanged(double value) + { + UpdateSensitivityText(); + UpdateThresholdText(); + if (!_isInitializing) + { + SaveNoiseSettings(); + } + } + + partial void OnSamplingRateMsChanged(int value) + { + UpdateSamplingRateText(); + if (!_isInitializing) + { + SaveNoiseSettings(); + } + } + + private void UpdateSamplingRateText() + { + SamplingRateValueText = $"{SamplingRateMs}ms"; + } + + private void UpdateSensitivityText() + { + NoiseSensitivityValueText = $"{NoiseSensitivityDbfs:F0} dBFS"; + } + + #endregion + + #region Properties - Focus Timer + + [ObservableProperty] + private string _focusTimerHeader = string.Empty; + + [ObservableProperty] + private string _focusTimerDescription = string.Empty; + + [ObservableProperty] + private string _focusDurationLabel = string.Empty; + + [ObservableProperty] + private string _focusDurationDescription = string.Empty; + + [ObservableProperty] + private int _focusDurationMinutes = 25; + + [ObservableProperty] + private string _focusDurationValueText = string.Empty; + + [ObservableProperty] + private string _breakDurationLabel = string.Empty; + + [ObservableProperty] + private string _breakDurationDescription = string.Empty; + + [ObservableProperty] + private int _breakDurationMinutes = 5; + + [ObservableProperty] + private string _breakDurationValueText = string.Empty; + + [ObservableProperty] + private string _longBreakDurationLabel = string.Empty; + + [ObservableProperty] + private string _longBreakDurationDescription = string.Empty; + + [ObservableProperty] + private int _longBreakDurationMinutes = 15; + + [ObservableProperty] + private string _longBreakDurationValueText = string.Empty; + + [ObservableProperty] + private string _sessionsBeforeLongBreakLabel = string.Empty; + + [ObservableProperty] + private string _sessionsBeforeLongBreakDescription = string.Empty; + + [ObservableProperty] + private int _sessionsBeforeLongBreak = 4; + + [ObservableProperty] + private string _sessionsBeforeLongBreakValueText = string.Empty; + + [ObservableProperty] + private string _autoStartBreakLabel = string.Empty; + + [ObservableProperty] + private string _autoStartBreakDescription = string.Empty; + + [ObservableProperty] + private bool _autoStartBreak; + + [ObservableProperty] + private string _autoStartFocusLabel = string.Empty; + + [ObservableProperty] + private string _autoStartFocusDescription = string.Empty; + + [ObservableProperty] + private bool _autoStartFocus; + + partial void OnFocusDurationMinutesChanged(int value) + { + UpdateFocusDurationText(); + if (!_isInitializing) + { + SaveTimerSettings(); + } + } + + partial void OnBreakDurationMinutesChanged(int value) + { + UpdateBreakDurationText(); + if (!_isInitializing) + { + SaveTimerSettings(); + } + } + + partial void OnLongBreakDurationMinutesChanged(int value) + { + UpdateLongBreakDurationText(); + if (!_isInitializing) + { + SaveTimerSettings(); + } + } + + partial void OnSessionsBeforeLongBreakChanged(int value) + { + UpdateSessionsBeforeLongBreakText(); + if (!_isInitializing) + { + SaveTimerSettings(); + } + } + + partial void OnAutoStartBreakChanged(bool value) + { + if (!_isInitializing) + { + SaveTimerSettings(); + } + } + + partial void OnAutoStartFocusChanged(bool value) + { + if (!_isInitializing) + { + SaveTimerSettings(); + } + } + + private void UpdateFocusDurationText() + { + FocusDurationValueText = $"{FocusDurationMinutes} 分钟"; + } + + private void UpdateBreakDurationText() + { + BreakDurationValueText = $"{BreakDurationMinutes} 分钟"; + } + + private void UpdateLongBreakDurationText() + { + LongBreakDurationValueText = $"{LongBreakDurationMinutes} 分钟"; + } + + private void UpdateSessionsBeforeLongBreakText() + { + SessionsBeforeLongBreakValueText = $"{SessionsBeforeLongBreak} 次"; + } + + #endregion + + #region Properties - Alert + + [ObservableProperty] + private string _alertHeader = string.Empty; + + [ObservableProperty] + private string _alertDescription = string.Empty; + + [ObservableProperty] + private string _noiseAlertEnabledLabel = string.Empty; + + [ObservableProperty] + private string _noiseAlertEnabledDescription = string.Empty; + + [ObservableProperty] + private bool _noiseAlertEnabled; + + [ObservableProperty] + private string _maxInterruptsPerMinuteLabel = string.Empty; + + [ObservableProperty] + private string _maxInterruptsPerMinuteDescription = string.Empty; + + [ObservableProperty] + private int _maxInterruptsPerMinute = 6; + + partial void OnNoiseAlertEnabledChanged(bool value) + { + if (!_isInitializing) + { + SaveAlertSettings(); + } + } + + partial void OnMaxInterruptsPerMinuteChanged(int value) + { + if (!_isInitializing) + { + SaveAlertSettings(); + } + } + + #endregion + + #region Properties - Display + + [ObservableProperty] + private string _displayHeader = string.Empty; + + [ObservableProperty] + private string _displayDescription = string.Empty; + + [ObservableProperty] + private string _showRealtimeDbLabel = string.Empty; + + [ObservableProperty] + private string _showRealtimeDbDescription = string.Empty; + + [ObservableProperty] + private bool _showRealtimeDb = true; + + [ObservableProperty] + private string _baselineDbLabel = string.Empty; + + [ObservableProperty] + private string _baselineDbDescription = string.Empty; + + [ObservableProperty] + private double _baselineDb = 45; + + [ObservableProperty] + private string _baselineDbValueText = string.Empty; + + [ObservableProperty] + private string _avgWindowSecLabel = string.Empty; + + [ObservableProperty] + private string _avgWindowSecDescription = string.Empty; + + [ObservableProperty] + private int _avgWindowSec = 1; + + [ObservableProperty] + private string _avgWindowSecValueText = string.Empty; + + partial void OnShowRealtimeDbChanged(bool value) + { + if (!_isInitializing) + { + SaveDisplaySettings(); + } + } + + partial void OnBaselineDbChanged(double value) + { + UpdateBaselineDbText(); + if (!_isInitializing) + { + SaveDisplaySettings(); + } + } + + partial void OnAvgWindowSecChanged(int value) + { + UpdateAvgWindowSecText(); + if (!_isInitializing) + { + SaveDisplaySettings(); + } + } + + #endregion + + [ObservableProperty] + private string _footerHint = string.Empty; + + private void UpdateBaselineDbText() + { + BaselineDbValueText = $"{BaselineDb:F0} dB"; + } + + private void UpdateAvgWindowSecText() + { + AvgWindowSecValueText = $"{AvgWindowSec} 秒"; + } + + private void LoadSettings() + { + var appSnapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App); + + // Noise settings + SamplingRateMs = appSnapshot.StudyFrameMs is > 0 ? appSnapshot.StudyFrameMs.Value : 50; + NoiseSensitivityDbfs = appSnapshot.StudyScoreThresholdDbfs ?? -50; + + // Timer settings + FocusDurationMinutes = appSnapshot.StudyFocusDurationMinutes is > 0 ? appSnapshot.StudyFocusDurationMinutes.Value : 25; + BreakDurationMinutes = appSnapshot.StudyBreakDurationMinutes is > 0 ? appSnapshot.StudyBreakDurationMinutes.Value : 5; + LongBreakDurationMinutes = appSnapshot.StudyLongBreakDurationMinutes is > 0 ? appSnapshot.StudyLongBreakDurationMinutes.Value : 15; + SessionsBeforeLongBreak = appSnapshot.StudySessionsBeforeLongBreak is > 0 ? appSnapshot.StudySessionsBeforeLongBreak.Value : 4; + AutoStartBreak = appSnapshot.StudyAutoStartBreak ?? false; + AutoStartFocus = appSnapshot.StudyAutoStartFocus ?? false; + + // Alert settings + NoiseAlertEnabled = appSnapshot.StudyNoiseAlertEnabled ?? false; + MaxInterruptsPerMinute = appSnapshot.StudyMaxInterruptsPerMinute is > 0 ? appSnapshot.StudyMaxInterruptsPerMinute.Value : 6; + + // Display settings + ShowRealtimeDb = appSnapshot.StudyShowRealtimeDb ?? true; + BaselineDb = appSnapshot.StudyBaselineDb ?? 45; + AvgWindowSec = appSnapshot.StudyAvgWindowSec ?? 1; + + UpdateSamplingRateText(); + UpdateSensitivityText(); + UpdateThresholdText(); + UpdateFocusDurationText(); + UpdateBreakDurationText(); + UpdateLongBreakDurationText(); + UpdateSessionsBeforeLongBreakText(); + UpdateBaselineDbText(); + UpdateAvgWindowSecText(); + } + + private void SaveNoiseSettings() + { + var appSnapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App); + appSnapshot.StudyFrameMs = SamplingRateMs; + appSnapshot.StudyScoreThresholdDbfs = NoiseSensitivityDbfs; + _settingsFacade.Settings.SaveSnapshot(SettingsScope.App, appSnapshot, + changedKeys: [nameof(AppSettingsSnapshot.StudyFrameMs), nameof(AppSettingsSnapshot.StudyScoreThresholdDbfs)]); + UpdateThresholdText(); + UpdateStudyAnalyticsConfig(); + } + + private void SaveTimerSettings() + { + var appSnapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App); + appSnapshot.StudyFocusDurationMinutes = FocusDurationMinutes; + appSnapshot.StudyBreakDurationMinutes = BreakDurationMinutes; + appSnapshot.StudyLongBreakDurationMinutes = LongBreakDurationMinutes; + appSnapshot.StudySessionsBeforeLongBreak = SessionsBeforeLongBreak; + appSnapshot.StudyAutoStartBreak = AutoStartBreak; + appSnapshot.StudyAutoStartFocus = AutoStartFocus; + _settingsFacade.Settings.SaveSnapshot(SettingsScope.App, appSnapshot, + changedKeys: [ + nameof(AppSettingsSnapshot.StudyFocusDurationMinutes), + nameof(AppSettingsSnapshot.StudyBreakDurationMinutes), + nameof(AppSettingsSnapshot.StudyLongBreakDurationMinutes), + nameof(AppSettingsSnapshot.StudySessionsBeforeLongBreak), + nameof(AppSettingsSnapshot.StudyAutoStartBreak), + nameof(AppSettingsSnapshot.StudyAutoStartFocus) + ]); + } + + private void SaveAlertSettings() + { + var appSnapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App); + appSnapshot.StudyNoiseAlertEnabled = NoiseAlertEnabled; + appSnapshot.StudyMaxInterruptsPerMinute = MaxInterruptsPerMinute; + _settingsFacade.Settings.SaveSnapshot(SettingsScope.App, appSnapshot, + changedKeys: [nameof(AppSettingsSnapshot.StudyNoiseAlertEnabled), nameof(AppSettingsSnapshot.StudyMaxInterruptsPerMinute)]); + UpdateStudyAnalyticsConfig(); + } + + private void SaveDisplaySettings() + { + var appSnapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App); + appSnapshot.StudyShowRealtimeDb = ShowRealtimeDb; + appSnapshot.StudyBaselineDb = BaselineDb; + appSnapshot.StudyAvgWindowSec = AvgWindowSec; + _settingsFacade.Settings.SaveSnapshot(SettingsScope.App, appSnapshot, + changedKeys: [nameof(AppSettingsSnapshot.StudyShowRealtimeDb), nameof(AppSettingsSnapshot.StudyBaselineDb), nameof(AppSettingsSnapshot.StudyAvgWindowSec)]); + UpdateStudyAnalyticsConfig(); + } + + private void UpdateStudyAnalyticsConfig() + { + var currentConfig = _studyAnalyticsService.GetConfig(); + var newConfig = currentConfig with + { + FrameMs = SamplingRateMs, + ScoreThresholdDbfs = NoiseSensitivityDbfs, + BaselineDb = BaselineDb, + AvgWindowSec = AvgWindowSec, + ShowRelativeDb = ShowRealtimeDb, + MaxSegmentsPerMin = MaxInterruptsPerMinute, + AlertSoundEnabled = NoiseAlertEnabled + }; + _studyAnalyticsService.UpdateConfig(newConfig); + } + + private void UpdateThresholdText() + { + CurrentThresholdText = string.Format( + CultureInfo.CurrentCulture, + L("settings.study.current_threshold_format", "当前评分阈值: {0} dBFS"), + NoiseSensitivityDbfs); + } + + private void RefreshLocalizedText() + { + NoiseMonitoringHeader = L("settings.study.noise_header", "噪音监测"); + NoiseMonitoringDescription = L("settings.study.noise_description", "配置麦克风采集频率和噪音评分敏感度。"); + SamplingRateLabel = L("settings.study.sampling_rate_label", "采集频率"); + SamplingRateDescription = L("settings.study.sampling_rate_desc", "麦克风采集音频的时间间隔。更高的频率会更准确地捕捉噪音变化,但会增加电量消耗。"); + NoiseSensitivityLabel = L("settings.study.sensitivity_label", "噪音敏感度"); + NoiseSensitivityDescription = L("settings.study.sensitivity_desc", "评分阈值决定了什么级别的噪音会被认为是干扰。阈值越严格,越容易检测到轻微噪音。"); + + FocusTimerHeader = L("settings.study.timer_header", "专注计时"); + FocusTimerDescription = L("settings.study.timer_description", "配置专注时段和休息时段的时长。"); + FocusDurationLabel = L("settings.study.focus_duration_label", "专注时长"); + FocusDurationDescription = L("settings.study.focus_duration_desc", "单次专注时段的持续时间(分钟)。"); + BreakDurationLabel = L("settings.study.break_duration_label", "休息时长"); + BreakDurationDescription = L("settings.study.break_duration_desc", "短休息时段的持续时间(分钟)。"); + LongBreakDurationLabel = L("settings.study.long_break_duration_label", "长休息时长"); + LongBreakDurationDescription = L("settings.study.long_break_duration_desc", "长休息时段的持续时间(分钟)。"); + SessionsBeforeLongBreakLabel = L("settings.study.sessions_before_long_break_label", "长休息间隔"); + SessionsBeforeLongBreakDescription = L("settings.study.sessions_before_long_break_desc", "经过几个专注时段后触发长休息。"); + AutoStartBreakLabel = L("settings.study.auto_start_break_label", "自动开始休息"); + AutoStartBreakDescription = L("settings.study.auto_start_break_desc", "专注时段结束后自动开始休息计时。"); + AutoStartFocusLabel = L("settings.study.auto_start_focus_label", "自动开始专注"); + AutoStartFocusDescription = L("settings.study.auto_start_focus_desc", "休息时段结束后自动开始专注计时。"); + + AlertHeader = L("settings.study.alert_header", "提醒设置"); + AlertDescription = L("settings.study.alert_description", "配置噪音干扰提醒。"); + NoiseAlertEnabledLabel = L("settings.study.noise_alert_enabled_label", "启用噪音提醒"); + NoiseAlertEnabledDescription = L("settings.study.noise_alert_enabled_desc", "当检测到超过容忍阈值的噪音干扰时显示提醒。"); + MaxInterruptsPerMinuteLabel = L("settings.study.max_interrupts_label", "最大容忍打断次数"); + MaxInterruptsPerMinuteDescription = L("settings.study.max_interrupts_desc", "每分钟最多允许多少次噪音干扰事件,超过此值将触发提醒。"); + + DisplayHeader = L("settings.study.display_header", "显示设置"); + DisplayDescription = L("settings.study.display_description", "配置噪音数据的显示方式。"); + ShowRealtimeDbLabel = L("settings.study.show_realtime_db_label", "显示实时分贝"); + ShowRealtimeDbDescription = L("settings.study.show_realtime_db_desc", "在组件中实时显示分贝值。"); + BaselineDbLabel = L("settings.study.baseline_db_label", "基准显示分贝"); + BaselineDbDescription = L("settings.study.baseline_db_desc", "校准后的显示分贝基准值,用于将 dBFS 转换为用户可读的 dB 值。"); + AvgWindowSecLabel = L("settings.study.avg_window_label", "平均时间窗"); + AvgWindowSecDescription = L("settings.study.avg_window_desc", "噪音平滑显示的时间窗口,较大的值会使显示更稳定但响应更慢。"); + + FooterHint = L("settings.study.footer_hint", "这些设置将影响自习环境监测组件的行为。"); + + UpdateThresholdText(); + } + + private string L(string key, string fallback) + => _localizationService.GetString(_languageCode, key, fallback); +} + public sealed class PluginGeneratedSettingsPageViewModel { public PluginGeneratedSettingsPageViewModel( diff --git a/LanMountainDesktop/Views/ComponentLibraryWindow.axaml.cs b/LanMountainDesktop/Views/ComponentLibraryWindow.axaml.cs index 5ab89ba..7c6cf86 100644 --- a/LanMountainDesktop/Views/ComponentLibraryWindow.axaml.cs +++ b/LanMountainDesktop/Views/ComponentLibraryWindow.axaml.cs @@ -230,7 +230,7 @@ public partial class ComponentLibraryWindow : Window if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase)) { - return Symbol.Apps; + return Symbol.Hourglass; } return Symbol.Apps; diff --git a/LanMountainDesktop/Views/Components/ZhiJiaoHubWidget.axaml b/LanMountainDesktop/Views/Components/ZhiJiaoHubWidget.axaml index 215d4c5..9426646 100644 --- a/LanMountainDesktop/Views/Components/ZhiJiaoHubWidget.axaml +++ b/LanMountainDesktop/Views/Components/ZhiJiaoHubWidget.axaml @@ -8,7 +8,7 @@ x:Class="LanMountainDesktop.Views.Components.ZhiJiaoHubWidget"> diff --git a/LanMountainDesktop/Views/Components/ZhiJiaoHubWidget.axaml.cs b/LanMountainDesktop/Views/Components/ZhiJiaoHubWidget.axaml.cs index 3607718..4d11b6c 100644 --- a/LanMountainDesktop/Views/Components/ZhiJiaoHubWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/ZhiJiaoHubWidget.axaml.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Avalonia; @@ -29,11 +30,12 @@ public partial class ZhiJiaoHubWidget : UserControl, private readonly DispatcherTimer _refreshTimer = new(); - private IRecommendationInfoService _recommendationService; + private IRecommendationInfoService _recommendationService = new RecommendationDataService(); private IComponentSettingsAccessor? _componentSettingsAccessor; private ISettingsService _appSettingsService = HostSettingsFacadeProvider.GetOrCreate().Settings; private CancellationTokenSource? _refreshCts; + private CancellationTokenSource? _backgroundDownloadCts; private string _source = ZhiJiaoHubSources.ClassIsland; private string _mirrorSource = ZhiJiaoHubMirrorSources.Direct; @@ -41,11 +43,11 @@ public partial class ZhiJiaoHubWidget : UserControl, private string _placementId = string.Empty; private double _currentCellSize = BaseCellSize; private bool _isAttached; - private bool _isSyncing; + private bool _isInitializing; private bool _autoRefreshEnabled = true; private int _pendingImageIndex = 0; - private IReadOnlyList _localImages = []; + private IReadOnlyList _images = []; private int _currentImageIndex = 0; private readonly Dictionary _imageCache = new(); @@ -56,6 +58,15 @@ public partial class ZhiJiaoHubWidget : UserControl, private Point _dragStartPoint; private double _dragOffset; private int _lastSwipeDirection = 0; + private bool _isInErrorState; + + private static readonly HttpClient ImageHttpClient = new(new HttpClientHandler + { + AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate + }) + { + Timeout = TimeSpan.FromSeconds(30) + }; public ZhiJiaoHubWidget() { @@ -67,8 +78,6 @@ public partial class ZhiJiaoHubWidget : UserControl, return; } - _recommendationService = new RecommendationDataService(); - _refreshTimer.Tick += OnRefreshTimerTick; AttachedToVisualTree += OnAttachedToVisualTree; @@ -84,7 +93,7 @@ public partial class ZhiJiaoHubWidget : UserControl, _isAttached = true; LoadSettings(); - _ = InitializeOrSyncAsync(); + _ = InitializeAsync(); UpdateTimers(); } @@ -93,6 +102,7 @@ public partial class ZhiJiaoHubWidget : UserControl, _isAttached = false; _refreshTimer.Stop(); _refreshCts?.Cancel(); + _backgroundDownloadCts?.Cancel(); lock (_cacheLock) { @@ -141,7 +151,7 @@ public partial class ZhiJiaoHubWidget : UserControl, if (_isAttached) { - _ = InitializeOrSyncAsync(); + _ = InitializeAsync(); } } @@ -157,7 +167,7 @@ public partial class ZhiJiaoHubWidget : UserControl, UpdateTimers(); if (_isAttached) { - _ = InitializeOrSyncAsync(); + _ = InitializeAsync(); } } @@ -208,80 +218,57 @@ public partial class ZhiJiaoHubWidget : UserControl, } } - private async Task InitializeOrSyncAsync() + private async Task InitializeAsync() { - if (_isSyncing) + if (_isInitializing) { return; } - _isSyncing = true; + _isInitializing = true; _refreshCts?.Cancel(); + _backgroundDownloadCts?.Cancel(); _refreshCts = new CancellationTokenSource(); var ct = _refreshCts.Token; try { - var localSnapshot = _recommendationService.LoadZhiJiaoHubLocalSnapshot(_source); - - if (localSnapshot != null && localSnapshot.Images.Count > 0) + await Dispatcher.UIThread.InvokeAsync(() => { - _localImages = localSnapshot.Images; - _currentImageIndex = Math.Clamp(_pendingImageIndex, 0, Math.Max(0, _localImages.Count - 1)); - _pendingImageIndex = 0; + LoadingTextBlock.Text = "加载中..."; + ApplyLoadingState(); + }); - await Dispatcher.UIThread.InvokeAsync(() => - { - UpdateIndicators(); - _ = LoadAndDisplayCurrentImageAsync(); - }); + var result = await _recommendationService.GetZhiJiaoHubHybridImagesAsync(_source, _mirrorSource, ct); + + if (ct.IsCancellationRequested) + { + return; } - else + + if (!result.Success || result.Data == null || result.Data.Images.Count == 0) { await Dispatcher.UIThread.InvokeAsync(() => { - LoadingTextBlock.Text = "首次同步图片..."; - ApplyLoadingState(); + ApplyErrorState(result.ErrorMessage ?? "无法获取图片列表"); }); + return; + } - var progress = new Progress<(int Current, int Total, string Status)>(p => - { - Dispatcher.UIThread.Post(() => - { - LoadingTextBlock.Text = $"同步中 {p.Current}/{p.Total}"; - }); - }); + _images = result.Data.Images; + _currentImageIndex = Math.Clamp(_pendingImageIndex, 0, Math.Max(0, _images.Count - 1)); + _pendingImageIndex = 0; - var syncResult = await _recommendationService.SyncZhiJiaoHubImagesAsync( - _source, - _mirrorSource, - progress, - ct); + await Dispatcher.UIThread.InvokeAsync(() => + { + UpdateIndicators(); + }); - if (ct.IsCancellationRequested) - { - return; - } + await LoadAndDisplayCurrentImageAsync(); - if (syncResult.Success && syncResult.Snapshot != null) - { - _localImages = syncResult.Snapshot.Images; - _currentImageIndex = Math.Clamp(_pendingImageIndex, 0, Math.Max(0, _localImages.Count - 1)); - _pendingImageIndex = 0; - - await Dispatcher.UIThread.InvokeAsync(() => - { - UpdateIndicators(); - _ = LoadAndDisplayCurrentImageAsync(); - }); - } - else - { - await Dispatcher.UIThread.InvokeAsync(() => - { - ApplyErrorState(syncResult.ErrorMessage ?? "同步失败"); - }); - } + if (result.Data.CachedCount < result.Data.TotalCount) + { + _ = StartBackgroundDownloadAsync(); } } catch (OperationCanceledException) @@ -296,70 +283,42 @@ public partial class ZhiJiaoHubWidget : UserControl, } finally { - _isSyncing = false; + _isInitializing = false; } } - private async Task CheckForUpdatesAsync() + private async Task StartBackgroundDownloadAsync() { - if (_isSyncing) - { - return; - } + _backgroundDownloadCts?.Cancel(); + _backgroundDownloadCts = new CancellationTokenSource(); + var ct = _backgroundDownloadCts.Token; - _isSyncing = true; - - try - { - var progress = new Progress<(int Current, int Total, string Status)>(p => + await _recommendationService.StartBackgroundDownloadAsync( + _source, + _images, + _mirrorSource, + (downloaded, total, name) => { - Dispatcher.UIThread.Post(() => + if (!ct.IsCancellationRequested) { - LoadingTextBlock.Text = $"更新中 {p.Current}/{p.Total}"; - }); - }); - - var syncResult = await _recommendationService.SyncZhiJiaoHubImagesAsync( - _source, - _mirrorSource, - progress, - CancellationToken.None); - - if (syncResult.Success && syncResult.Snapshot != null && syncResult.DownloadedCount > 0) - { - _localImages = syncResult.Snapshot.Images; - - await Dispatcher.UIThread.InvokeAsync(() => - { - if (_currentImageIndex >= _localImages.Count) + Dispatcher.UIThread.Post(() => { - _currentImageIndex = 0; - SaveCurrentImageIndex(); - } - - UpdateIndicators(); - _ = LoadAndDisplayCurrentImageAsync(); - }); - } - } - catch - { - } - finally - { - _isSyncing = false; - } + LoadingTextBlock.Text = $"后台缓存 {downloaded}/{total}"; + }); + } + }, + ct); } private async Task LoadAndDisplayCurrentImageAsync(int direction = 0) { - if (_localImages.Count == 0) + if (_images.Count == 0) { ApplyErrorState("暂无图片"); return; } - var imageItem = _localImages[_currentImageIndex]; + var imageItem = _images[_currentImageIndex]; try { @@ -378,32 +337,14 @@ public partial class ZhiJiaoHubWidget : UserControl, return; } - if (!File.Exists(imageItem.LocalPath)) + if (imageItem.IsCached && !string.IsNullOrEmpty(imageItem.LocalPath) && File.Exists(imageItem.LocalPath)) { - ApplyErrorState("图片文件不存在"); + await LoadFromLocalPathAsync(imageItem.LocalPath, imageItem.Name); + _ = Task.Run(async () => await PreloadAdjacentImagesAsync(direction)); return; } - await using var fileStream = File.OpenRead(imageItem.LocalPath); - var bitmap = new Bitmap(fileStream); - - lock (_cacheLock) - { - if (_imageCache.Count >= MaxCacheSize) - { - CleanupFarthestCacheUnsafe(); - } - _imageCache[_currentImageIndex] = bitmap; - } - - await Dispatcher.UIThread.InvokeAsync(() => - { - CurrentImage.Source = bitmap; - ImageNameTextBlock.Text = imageItem.Name; - ApplyContentVisibleState(); - }); - - _ = Task.Run(async () => await PreloadAdjacentImagesAsync(direction)); + await LoadFromRemoteUrlAsync(imageItem, direction); } catch (Exception ex) { @@ -414,9 +355,105 @@ public partial class ZhiJiaoHubWidget : UserControl, } } + private async Task LoadFromLocalPathAsync(string localPath, string name) + { + await using var fileStream = File.OpenRead(localPath); + var bitmap = new Bitmap(fileStream); + + lock (_cacheLock) + { + if (_imageCache.Count >= MaxCacheSize) + { + CleanupFarthestCacheUnsafe(); + } + _imageCache[_currentImageIndex] = bitmap; + } + + await Dispatcher.UIThread.InvokeAsync(() => + { + CurrentImage.Source = bitmap; + ImageNameTextBlock.Text = name; + ApplyContentVisibleState(); + }); + } + + private async Task LoadFromRemoteUrlAsync(ZhiJiaoHubHybridImageItem imageItem, int direction) + { + await Dispatcher.UIThread.InvokeAsync(() => + { + LoadingTextBlock.Text = "加载图片..."; + ApplyLoadingState(); + }); + + var imageUrl = imageItem.RemoteUrl; + if (string.Equals(_mirrorSource, ZhiJiaoHubMirrorSources.GhProxy, StringComparison.OrdinalIgnoreCase)) + { + imageUrl = ZhiJiaoHubMirrorSources.GhProxyBaseUrl.TrimEnd('/') + "/" + imageItem.RemoteUrl; + } + + using var response = await ImageHttpClient.GetAsync(imageUrl); + response.EnsureSuccessStatusCode(); + + var imageStream = await response.Content.ReadAsStreamAsync(); + + var bitmap = new Bitmap(imageStream); + + lock (_cacheLock) + { + if (_imageCache.Count >= MaxCacheSize) + { + CleanupFarthestCacheUnsafe(); + } + _imageCache[_currentImageIndex] = bitmap; + } + + await Dispatcher.UIThread.InvokeAsync(() => + { + CurrentImage.Source = bitmap; + ImageNameTextBlock.Text = imageItem.Name; + ApplyContentVisibleState(); + }); + + _ = CacheImageInBackgroundAsync(imageItem); + _ = Task.Run(async () => await PreloadAdjacentImagesAsync(direction)); + } + + private async Task CacheImageInBackgroundAsync(ZhiJiaoHubHybridImageItem imageItem) + { + if (imageItem.IsCached) + { + return; + } + + try + { + var image = new ZhiJiaoHubImageItem(imageItem.Name, imageItem.RemoteUrl, imageItem.Index); + var localPath = await _recommendationService.DownloadAndCacheImageAsync(_source, image, _mirrorSource); + + if (!string.IsNullOrEmpty(localPath)) + { + var index = imageItem.Index; + if (index >= 0 && index < _images.Count) + { + var updatedImage = _images[index] with + { + LocalPath = localPath, + IsCached = true + }; + var newImages = _images.ToList(); + newImages[index] = updatedImage; + _images = newImages; + } + } + } + catch + { + } + } + private async Task PreloadAdjacentImagesAsync(int direction = 0) { - if (_localImages.Count <= 1) + if (_images.Count <= 1) { return; } @@ -428,13 +465,13 @@ public partial class ZhiJiaoHubWidget : UserControl, { if (direction <= 0) { - var nextIndex = (currentIndex + 1) % _localImages.Count; + var nextIndex = (currentIndex + 1) % _images.Count; if (!_imageCache.ContainsKey(nextIndex)) { indicesToPreload.Add(nextIndex); } - var nextNextIndex = (currentIndex + 2) % _localImages.Count; + var nextNextIndex = (currentIndex + 2) % _images.Count; if (!_imageCache.ContainsKey(nextNextIndex) && indicesToPreload.Count < 3) { indicesToPreload.Add(nextNextIndex); @@ -443,13 +480,13 @@ public partial class ZhiJiaoHubWidget : UserControl, if (direction >= 0) { - var prevIndex = (currentIndex - 1 + _localImages.Count) % _localImages.Count; + var prevIndex = (currentIndex - 1 + _images.Count) % _images.Count; if (!_imageCache.ContainsKey(prevIndex)) { indicesToPreload.Add(prevIndex); } - var prevPrevIndex = (currentIndex - 2 + _localImages.Count) % _localImages.Count; + var prevPrevIndex = (currentIndex - 2 + _images.Count) % _images.Count; if (!_imageCache.ContainsKey(prevPrevIndex) && indicesToPreload.Count < 3) { indicesToPreload.Add(prevPrevIndex); @@ -479,24 +516,43 @@ public partial class ZhiJiaoHubWidget : UserControl, } } - var imageItem = _localImages[index]; - if (!File.Exists(imageItem.LocalPath)) + var imageItem = _images[index]; + Bitmap? bitmap = null; + + if (imageItem.IsCached && !string.IsNullOrEmpty(imageItem.LocalPath) && File.Exists(imageItem.LocalPath)) { - return; + await using var fileStream = File.OpenRead(imageItem.LocalPath); + bitmap = new Bitmap(fileStream); + } + else + { + var imageUrl = imageItem.RemoteUrl; + if (string.Equals(_mirrorSource, ZhiJiaoHubMirrorSources.GhProxy, StringComparison.OrdinalIgnoreCase)) + { + imageUrl = ZhiJiaoHubMirrorSources.GhProxyBaseUrl.TrimEnd('/') + "/" + imageItem.RemoteUrl; + } + + using var response = await ImageHttpClient.GetAsync(imageUrl); + response.EnsureSuccessStatusCode(); + + var imageStream = await response.Content.ReadAsStreamAsync(); + bitmap = new Bitmap(imageStream); + + _ = CacheImageInBackgroundAsync(imageItem); } - await using var fileStream = File.OpenRead(imageItem.LocalPath); - var bitmap = new Bitmap(fileStream); - - lock (_cacheLock) + if (bitmap != null) { - if (!_imageCache.ContainsKey(index)) + lock (_cacheLock) { - _imageCache[index] = bitmap; - } - else - { - bitmap.Dispose(); + if (!_imageCache.ContainsKey(index)) + { + _imageCache[index] = bitmap; + } + else + { + bitmap.Dispose(); + } } } } @@ -515,7 +571,7 @@ public partial class ZhiJiaoHubWidget : UserControl, var farthestKey = -1; var maxDistance = -1; var currentIndex = _currentImageIndex; - var imageCount = _localImages.Count; + var imageCount = _images.Count; foreach (var key in _imageCache.Keys) { @@ -544,7 +600,13 @@ public partial class ZhiJiaoHubWidget : UserControl, private void OnPointerPressed(object? sender, PointerPressedEventArgs e) { - if (_localImages.Count <= 1) + if (_isInErrorState) + { + _ = RefreshCurrentComponentAsync(); + return; + } + + if (_images.Count <= 1) { return; } @@ -556,7 +618,7 @@ public partial class ZhiJiaoHubWidget : UserControl, private void OnPointerMoved(object? sender, PointerEventArgs e) { - if (!_isDragging || _localImages.Count <= 1) + if (!_isDragging || _images.Count <= 1) { return; } @@ -591,7 +653,7 @@ public partial class ZhiJiaoHubWidget : UserControl, private void OnPointerWheelChanged(object? sender, PointerWheelEventArgs e) { - if (_localImages.Count <= 1) + if (_images.Count <= 1) { return; } @@ -612,12 +674,12 @@ public partial class ZhiJiaoHubWidget : UserControl, private void SwitchToPrevImage() { - if (_localImages.Count <= 1) + if (_images.Count <= 1) { return; } - _currentImageIndex = (_currentImageIndex - 1 + _localImages.Count) % _localImages.Count; + _currentImageIndex = (_currentImageIndex - 1 + _images.Count) % _images.Count; SaveCurrentImageIndex(); UpdateIndicators(); @@ -632,12 +694,12 @@ public partial class ZhiJiaoHubWidget : UserControl, private void SwitchToNextImage() { - if (_localImages.Count <= 1) + if (_images.Count <= 1) { return; } - _currentImageIndex = (_currentImageIndex + 1) % _localImages.Count; + _currentImageIndex = (_currentImageIndex + 1) % _images.Count; SaveCurrentImageIndex(); UpdateIndicators(); @@ -652,7 +714,7 @@ public partial class ZhiJiaoHubWidget : UserControl, private bool TryDisplayCachedImage(int index) { - if (_localImages.Count == 0 || index < 0 || index >= _localImages.Count) + if (_images.Count == 0 || index < 0 || index >= _images.Count) { return false; } @@ -665,7 +727,7 @@ public partial class ZhiJiaoHubWidget : UserControl, if (cachedBitmap != null) { - var imageItem = _localImages[index]; + var imageItem = _images[index]; CurrentImage.Source = cachedBitmap; ImageNameTextBlock.Text = imageItem.Name; ApplyContentVisibleState(); @@ -677,6 +739,7 @@ public partial class ZhiJiaoHubWidget : UserControl, private void ApplyLoadingState() { + _isInErrorState = false; CurrentImage.IsVisible = false; ImageNameTextBlock.IsVisible = false; GradientOverlay.IsVisible = false; @@ -686,6 +749,7 @@ public partial class ZhiJiaoHubWidget : UserControl, private void ApplyContentVisibleState() { + _isInErrorState = false; LoadingPanel.IsVisible = false; ErrorTextBlock.IsVisible = false; CurrentImage.IsVisible = true; @@ -695,11 +759,12 @@ public partial class ZhiJiaoHubWidget : UserControl, private void ApplyErrorState(string message) { + _isInErrorState = true; CurrentImage.IsVisible = false; ImageNameTextBlock.IsVisible = false; GradientOverlay.IsVisible = false; LoadingPanel.IsVisible = false; - ErrorTextBlock.Text = message; + ErrorTextBlock.Text = message + "\n点击任意区域重试"; ErrorTextBlock.IsVisible = true; } @@ -707,38 +772,76 @@ public partial class ZhiJiaoHubWidget : UserControl, { IndicatorPanel.Children.Clear(); - if (_localImages.Count <= 1) + if (_images.Count <= 1) { return; } - var maxIndicators = Math.Min(_localImages.Count, 7); - for (int i = 0; i < maxIndicators; i++) + var maxIndicators = Math.Min(_images.Count, 7); + var startIndex = Math.Max(0, _currentImageIndex - maxIndicators / 2); + var endIndex = Math.Min(_images.Count, startIndex + maxIndicators); + + if (endIndex - startIndex < maxIndicators) { - var isActive = i == _currentImageIndex % maxIndicators; - var indicator = new Border + startIndex = Math.Max(0, endIndex - maxIndicators); + } + + for (var i = startIndex; i < endIndex; i++) + { + var dot = new Border { - Width = isActive ? 6 : 4, - Height = isActive ? 6 : 4, + Width = 6, + Height = 6, CornerRadius = new CornerRadius(3), - Background = isActive - ? new SolidColorBrush(Colors.White) + Margin = new Thickness(2, 0), + Background = i == _currentImageIndex + ? Brushes.White : new SolidColorBrush(Color.FromArgb(128, 255, 255, 255)) }; - IndicatorPanel.Children.Add(indicator); - } - } - private void OnSizeChanged(object? sender, SizeChangedEventArgs e) - { - ApplyCellSize(_currentCellSize); + IndicatorPanel.Children.Add(dot); + } } private void OnRefreshTimerTick(object? sender, EventArgs e) { - if (_isAttached && _autoRefreshEnabled) + if (_isInitializing) { - _ = CheckForUpdatesAsync(); + return; } + + _ = InitializeAsync(); + } + + private void OnSizeChanged(object? sender, SizeChangedEventArgs e) + { + var cellSize = Math.Min(e.NewSize.Width, e.NewSize.Height) / 2; + ApplyCellSize(cellSize); + } + + private async Task RefreshCurrentComponentAsync() + { + if (_isInitializing) + { + return; + } + + _refreshCts?.Cancel(); + _backgroundDownloadCts?.Cancel(); + _refreshCts = new CancellationTokenSource(); + + lock (_cacheLock) + { + foreach (var bitmap in _imageCache.Values) + { + bitmap.Dispose(); + } + _imageCache.Clear(); + } + + _images = []; + _currentImageIndex = 0; + + await InitializeAsync(); } } diff --git a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs index fdd501d..791d270 100644 --- a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs +++ b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs @@ -2611,7 +2611,7 @@ public partial class MainWindow if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase)) { - return Symbol.Apps; + return Symbol.Hourglass; } if (string.Equals(categoryId, "File", StringComparison.OrdinalIgnoreCase)) diff --git a/LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs b/LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs index af08eb9..ac27f93 100644 --- a/LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs +++ b/LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs @@ -44,7 +44,16 @@ public partial class MainWindow if (changedKeys.All(key => string.Equals(key, nameof(AppSettingsSnapshot.ThemeColorMode), StringComparison.OrdinalIgnoreCase) || string.Equals(key, nameof(AppSettingsSnapshot.SystemMaterialMode), StringComparison.OrdinalIgnoreCase) || - string.Equals(key, nameof(AppSettingsSnapshot.SelectedWallpaperSeed), StringComparison.OrdinalIgnoreCase))) + string.Equals(key, nameof(AppSettingsSnapshot.SelectedWallpaperSeed), StringComparison.OrdinalIgnoreCase) || + string.Equals(key, nameof(AppSettingsSnapshot.LastUpdateCheckUtcMs), StringComparison.OrdinalIgnoreCase) || + string.Equals(key, nameof(AppSettingsSnapshot.PendingUpdateInstallerPath), StringComparison.OrdinalIgnoreCase) || + string.Equals(key, nameof(AppSettingsSnapshot.PendingUpdateVersion), StringComparison.OrdinalIgnoreCase) || + string.Equals(key, nameof(AppSettingsSnapshot.PendingUpdatePublishedAtUtcMs), StringComparison.OrdinalIgnoreCase) || + string.Equals(key, nameof(AppSettingsSnapshot.IncludePrereleaseUpdates), StringComparison.OrdinalIgnoreCase) || + string.Equals(key, nameof(AppSettingsSnapshot.UpdateChannel), StringComparison.OrdinalIgnoreCase) || + string.Equals(key, nameof(AppSettingsSnapshot.UpdateMode), StringComparison.OrdinalIgnoreCase) || + string.Equals(key, nameof(AppSettingsSnapshot.UpdateDownloadSource), StringComparison.OrdinalIgnoreCase) || + string.Equals(key, nameof(AppSettingsSnapshot.UpdateDownloadThreads), StringComparison.OrdinalIgnoreCase))) { return; } @@ -584,6 +593,7 @@ public partial class MainWindow var latestUpdateState = _updateSettingsService.Get(); var latestThemeState = _themeSettingsService.Get(); var latestPrivacyState = _settingsFacade.Privacy.Get(); + var existingSnapshot = _settingsService.LoadSnapshot(SettingsScope.App); return new AppSettingsSnapshot { GridShortSideCells = _targetShortSideCells, @@ -635,7 +645,21 @@ public partial class MainWindow ClockDisplayFormat = _clockDisplayFormat == ClockDisplayFormat.HourMinute ? "HourMinute" : "HourMinuteSecond", StatusBarClockTransparentBackground = _statusBarClockTransparentBackground, StatusBarSpacingMode = _statusBarSpacingMode, - StatusBarCustomSpacingPercent = _statusBarCustomSpacingPercent + StatusBarCustomSpacingPercent = _statusBarCustomSpacingPercent, + DisabledPluginIds = existingSnapshot.DisabledPluginIds, + StudyFrameMs = existingSnapshot.StudyFrameMs, + StudyScoreThresholdDbfs = existingSnapshot.StudyScoreThresholdDbfs, + StudyFocusDurationMinutes = existingSnapshot.StudyFocusDurationMinutes, + StudyBreakDurationMinutes = existingSnapshot.StudyBreakDurationMinutes, + StudyLongBreakDurationMinutes = existingSnapshot.StudyLongBreakDurationMinutes, + StudySessionsBeforeLongBreak = existingSnapshot.StudySessionsBeforeLongBreak, + StudyAutoStartBreak = existingSnapshot.StudyAutoStartBreak, + StudyAutoStartFocus = existingSnapshot.StudyAutoStartFocus, + StudyNoiseAlertEnabled = existingSnapshot.StudyNoiseAlertEnabled, + StudyMaxInterruptsPerMinute = existingSnapshot.StudyMaxInterruptsPerMinute, + StudyShowRealtimeDb = existingSnapshot.StudyShowRealtimeDb, + StudyBaselineDb = existingSnapshot.StudyBaselineDb, + StudyAvgWindowSec = existingSnapshot.StudyAvgWindowSec }; } diff --git a/LanMountainDesktop/Views/SettingsPages/ComponentsSettingsPage.axaml.cs b/LanMountainDesktop/Views/SettingsPages/ComponentsSettingsPage.axaml.cs index 6ce1bc3..f553ed1 100644 --- a/LanMountainDesktop/Views/SettingsPages/ComponentsSettingsPage.axaml.cs +++ b/LanMountainDesktop/Views/SettingsPages/ComponentsSettingsPage.axaml.cs @@ -8,7 +8,7 @@ namespace LanMountainDesktop.Views.SettingsPages; "components", "Components", SettingsPageCategory.Components, - IconKey = "Apps", + IconKey = "AppFolder", SortOrder = 20, TitleLocalizationKey = "settings.components.title", DescriptionLocalizationKey = "settings.components.description")] diff --git a/LanMountainDesktop/Views/SettingsPages/LauncherSettingsPage.axaml.cs b/LanMountainDesktop/Views/SettingsPages/LauncherSettingsPage.axaml.cs index 25b8939..fa512d2 100644 --- a/LanMountainDesktop/Views/SettingsPages/LauncherSettingsPage.axaml.cs +++ b/LanMountainDesktop/Views/SettingsPages/LauncherSettingsPage.axaml.cs @@ -8,7 +8,7 @@ namespace LanMountainDesktop.Views.SettingsPages; "launcher", "App Launcher", SettingsPageCategory.Components, - IconKey = "Apps", + IconKey = "AppsListDetail", SortOrder = 10, Scope = SettingsScope.Launcher, TitleLocalizationKey = "settings.launcher.title", diff --git a/LanMountainDesktop/Views/SettingsPages/StatusBarSettingsPage.axaml.cs b/LanMountainDesktop/Views/SettingsPages/StatusBarSettingsPage.axaml.cs index 967065b..9f596f6 100644 --- a/LanMountainDesktop/Views/SettingsPages/StatusBarSettingsPage.axaml.cs +++ b/LanMountainDesktop/Views/SettingsPages/StatusBarSettingsPage.axaml.cs @@ -8,7 +8,7 @@ namespace LanMountainDesktop.Views.SettingsPages; "status-bar", "Status Bar", SettingsPageCategory.Components, - IconKey = "Apps", + IconKey = "MatchAppLayout", SortOrder = 15, TitleLocalizationKey = "settings.status_bar.title", DescriptionLocalizationKey = "settings.status_bar.description")] diff --git a/LanMountainDesktop/Views/SettingsPages/StudySettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/StudySettingsPage.axaml new file mode 100644 index 0000000..7a91907 --- /dev/null +++ b/LanMountainDesktop/Views/SettingsPages/StudySettingsPage.axaml @@ -0,0 +1,334 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/LanMountainDesktop/Views/SettingsPages/StudySettingsPage.axaml.cs b/LanMountainDesktop/Views/SettingsPages/StudySettingsPage.axaml.cs new file mode 100644 index 0000000..93ce48b --- /dev/null +++ b/LanMountainDesktop/Views/SettingsPages/StudySettingsPage.axaml.cs @@ -0,0 +1,43 @@ +using Avalonia.Controls; +using LanMountainDesktop.PluginSdk; +using LanMountainDesktop.Services; +using LanMountainDesktop.Services.Settings; +using LanMountainDesktop.ViewModels; + +namespace LanMountainDesktop.Views.SettingsPages; + +[SettingsPageInfo( + "study", + "Study", + SettingsPageCategory.Appearance, + IconKey = "Hourglass", + SortOrder = 19, + TitleLocalizationKey = "settings.study.title", + DescriptionLocalizationKey = "settings.study.description")] +public partial class StudySettingsPage : SettingsPageBase +{ + public StudySettingsPage() + : this(Design.IsDesignMode ? CreateDesignTimeViewModel() : CreateDefaultViewModel()) + { + } + + public StudySettingsPage(StudySettingsPageViewModel viewModel) + { + ViewModel = viewModel; + DataContext = ViewModel; + InitializeComponent(); + } + + public StudySettingsPageViewModel ViewModel { get; } + + private static StudySettingsPageViewModel CreateDefaultViewModel() + { + var settingsFacade = HostSettingsFacadeProvider.GetOrCreate(); + return new StudySettingsPageViewModel(settingsFacade); + } + + private static StudySettingsPageViewModel CreateDesignTimeViewModel() + { + return CreateDefaultViewModel(); + } +} diff --git a/LanMountainDesktop/Views/SettingsWindow.axaml.cs b/LanMountainDesktop/Views/SettingsWindow.axaml.cs index d09a50b..8f35357 100644 --- a/LanMountainDesktop/Views/SettingsWindow.axaml.cs +++ b/LanMountainDesktop/Views/SettingsWindow.axaml.cs @@ -722,12 +722,18 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext "Image" => Symbol.Image, "WeatherMoon" => Symbol.WeatherMoon, "Apps" => Symbol.Apps, + "AppFolder" => Symbol.AppFolder, + "AppsListDetail" => Symbol.AppsListDetail, + "MatchAppLayout" => Symbol.MatchAppLayout, + "Widget" => Symbol.GridDots, + "SwitchApps" => Symbol.ArrowSync, "GridDots" => Symbol.GridDots, "PuzzlePiece" => Symbol.PuzzlePiece, "ShoppingBag" => Symbol.ShoppingBag, "Shield" => Symbol.ShieldDismiss, "Info" => Symbol.Info, "ArrowSync" => Symbol.ArrowSync, + "Hourglass" => Symbol.Hourglass, _ => Symbol.Settings }; }