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
+
+> 你的桌面,不止一面
+
+[](https://dotnet.microsoft.com/)
+[](https://avaloniaui.net/)
+[](LICENSE)
+
+> [!IMPORTANT]
+> **温馨提示**:本项目有部分成分由**氛围编程 (Vibe Coding)** 方式编写。
+>
+> 如果您对此类项目有固有的排斥感,请无视此项目,谢谢。
+
+## 简介
+
+**阑山桌面**是一个跨平台桌面环境增强工具,面向需要高频查看信息、追求桌面效率与个性化体验的用户。
+
+基于 Avalonia UI 和 .NET 10 构建,支持 Windows、Linux、macOS 三大平台。
+
+
+
+
+
+## 核心特性
+
+### 📊 信息聚合
+- 课程表、日历、天气、新闻、热搜
+- 所有信息一目了然,无需频繁切换窗口
+
+### 🎯 效率工具
+- 自习环境监测、计时器、知识卡片
+- 最近文档、浏览器快捷入口
+- 常用工具组件一键触达
+
+### 🎨 个性化桌面
+- 自由布局,随心所欲摆放组件
+- 多页桌面,工作学习场景分离
+- 主题切换、玻璃效果、圆角风格
+
+### 🔌 插件生态
+- 通过 `.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
};
}