自习设置,优化设置选项卡图标,加入智教hub组件
This commit is contained in:
lincube
2026-03-30 02:40:10 +08:00
parent bd2313fe7e
commit f84111e837
22 changed files with 1821 additions and 236 deletions

45
.github/README.md vendored
View File

@@ -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)

133
.github/READMEmd vendored Normal file
View File

@@ -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)

View File

@@ -53,8 +53,8 @@
<PackageReference Include="DotNetCampus.AvaloniaInkCanvas" Version="1.0.1" />
<PackageReference Include="Downloader" Version="4.1.1" />
<PackageReference Include="FluentAvaloniaUI" Version="2.5.0" />
<PackageReference Include="FluentIcons.Avalonia" Version="2.0.319" />
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.319" />
<PackageReference Include="FluentIcons.Avalonia" Version="2.0.320" />
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.320" />
<PackageReference Include="Material.Avalonia" Version="3.13.4" />
<PackageReference Include="Material.Icons.Avalonia" Version="2.4.1" />
<PackageReference Include="ClassIsland.Markdown.Avalonia" Version="11.0.3.4" />

View File

@@ -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",

View File

@@ -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": "로딩 중...",

View File

@@ -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": "加载中...",

View File

@@ -118,6 +118,36 @@ public sealed class AppSettingsSnapshot
public List<string> 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();

View File

@@ -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<ZhiJiaoHubHybridImageItem> Images,
string Source,
int CachedCount,
int TotalCount);
public sealed record RecommendationQueryResult<T>(
bool Success,
T? Data,
@@ -353,6 +366,24 @@ public interface IRecommendationInfoService
ZhiJiaoHubQuery query,
CancellationToken cancellationToken = default);
Task<RecommendationQueryResult<ZhiJiaoHubHybridSnapshot>> GetZhiJiaoHubHybridImagesAsync(
string source,
string mirrorSource,
CancellationToken cancellationToken = default);
Task<string?> DownloadAndCacheImageAsync(
string source,
ZhiJiaoHubImageItem image,
string mirrorSource,
CancellationToken cancellationToken = default);
Task StartBackgroundDownloadAsync(
string source,
IReadOnlyList<ZhiJiaoHubHybridImageItem> images,
string mirrorSource,
Action<int, int, string>? onProgress = null,
CancellationToken cancellationToken = default);
Task<ZhiJiaoHubSyncResult> SyncZhiJiaoHubImagesAsync(
string source,
string mirrorSource,

View File

@@ -3456,4 +3456,118 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
var normalizedSource = ZhiJiaoHubSources.Normalize(source);
return _zhiJiaoHubCacheService.HasLocalCache(normalizedSource);
}
public async Task<RecommendationQueryResult<ZhiJiaoHubHybridSnapshot>> 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<ZhiJiaoHubHybridSnapshot>.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<ZhiJiaoHubHybridSnapshot>.Ok(snapshot);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return RecommendationQueryResult<ZhiJiaoHubHybridSnapshot>.Fail("upstream_network_error", ex.Message);
}
}
public async Task<string?> 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<ZhiJiaoHubHybridImageItem> images,
string mirrorSource,
Action<int, int, string>? 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);
}
}

View File

@@ -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<string, string> LoadLocalPathMap(string source)
{
lock (_manifestLock)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (!File.Exists(_manifestPath))
{
return result;
}
try
{
var json = File.ReadAllText(_manifestPath);
var manifest = JsonSerializer.Deserialize<CacheManifest>(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<CacheManifest>(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<string?> 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<ZhiJiaoHubSyncResult> SyncImagesAsync(
string source,
IReadOnlyList<ZhiJiaoHubImageItem> remoteImages,
@@ -159,12 +273,6 @@ public sealed class ZhiJiaoHubCacheService : IDisposable
var failedCount = 0;
var localImages = new List<CachedImageInfo>();
var existingFiles = new HashSet<string>(
Directory.Exists(sourceDir)
? Directory.GetFiles(sourceDir, "*.jpg").Concat(Directory.GetFiles(sourceDir, "*.png")).Concat(Directory.GetFiles(sourceDir, "*.gif"))
: Array.Empty<string>(),
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<CacheManifest>(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<CachedImageInfo>(), 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<CachedImageInfo> images)
{
lock (_manifestLock)

View File

@@ -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<AppSettingsSnapshot>(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<AppSettingsSnapshot>(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<AppSettingsSnapshot>(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<AppSettingsSnapshot>(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<AppSettingsSnapshot>(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(

View File

@@ -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;

View File

@@ -8,7 +8,7 @@
x:Class="LanMountainDesktop.Views.Components.ZhiJiaoHubWidget">
<Border x:Name="RootBorder"
CornerRadius="12"
CornerRadius="{DynamicResource DesignCornerRadiusXs}"
ClipToBounds="True"
BorderThickness="0"
Background="#1A1A1A">

View File

@@ -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<ZhiJiaoHubLocalImageItem> _localImages = [];
private IReadOnlyList<ZhiJiaoHubHybridImageItem> _images = [];
private int _currentImageIndex = 0;
private readonly Dictionary<int, Bitmap> _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();
}
}

View File

@@ -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))

View File

@@ -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<AppSettingsSnapshot>(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
};
}

View File

@@ -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")]

View File

@@ -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",

View File

@@ -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")]

View File

@@ -0,0 +1,334 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LanMountainDesktop.ViewModels"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
x:Class="LanMountainDesktop.Views.SettingsPages.StudySettingsPage"
x:DataType="vm:StudySettingsPageViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Classes="settings-page-container settings-page-animated">
<!-- 噪音监测设置 -->
<ui:SettingsExpander Classes="settings-expander-card"
Header="{Binding NoiseMonitoringHeader}"
Description="{Binding NoiseMonitoringDescription}">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="Mic" />
</ui:SettingsExpander.IconSource>
<!-- 采集频率 -->
<ui:SettingsExpanderItem>
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="16">
<StackPanel Classes="settings-item">
<TextBlock Classes="settings-item-label"
Text="{Binding SamplingRateLabel}" />
<TextBlock Classes="settings-item-description"
Text="{Binding SamplingRateDescription}" />
</StackPanel>
<TextBlock Grid.Column="1"
VerticalAlignment="Center"
FontWeight="SemiBold"
Text="{Binding SamplingRateValueText}" />
</Grid>
<Slider Minimum="20"
Maximum="200"
Value="{Binding SamplingRateMs}"
SmallChange="10"
LargeChange="20"
TickFrequency="20"
IsSnapToTickEnabled="True" />
</StackPanel>
</ui:SettingsExpanderItem>
<!-- 噪音敏感度 -->
<ui:SettingsExpanderItem>
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="16">
<StackPanel Classes="settings-item">
<TextBlock Classes="settings-item-label"
Text="{Binding NoiseSensitivityLabel}" />
<TextBlock Classes="settings-item-description"
Text="{Binding NoiseSensitivityDescription}" />
</StackPanel>
<TextBlock Grid.Column="1"
VerticalAlignment="Center"
FontWeight="SemiBold"
Text="{Binding NoiseSensitivityValueText}" />
</Grid>
<Slider Minimum="-70"
Maximum="-35"
Value="{Binding NoiseSensitivityDbfs}"
SmallChange="1"
LargeChange="5"
TickFrequency="5"
IsSnapToTickEnabled="True" />
</StackPanel>
</ui:SettingsExpanderItem>
<!-- 评分阈值显示 -->
<ui:SettingsExpanderItem>
<TextBlock Classes="settings-item-description"
Text="{Binding CurrentThresholdText}"
TextWrapping="Wrap" />
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
<!-- 专注计时设置 -->
<ui:SettingsExpander Classes="settings-expander-card"
Header="{Binding FocusTimerHeader}"
Description="{Binding FocusTimerDescription}">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="Timer" />
</ui:SettingsExpander.IconSource>
<!-- 专注时长 -->
<ui:SettingsExpanderItem>
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="16">
<StackPanel Classes="settings-item">
<TextBlock Classes="settings-item-label"
Text="{Binding FocusDurationLabel}" />
<TextBlock Classes="settings-item-description"
Text="{Binding FocusDurationDescription}" />
</StackPanel>
<TextBlock Grid.Column="1"
VerticalAlignment="Center"
FontWeight="SemiBold"
Text="{Binding FocusDurationValueText}" />
</Grid>
<Slider Minimum="5"
Maximum="90"
Value="{Binding FocusDurationMinutes}"
SmallChange="1"
LargeChange="5"
TickFrequency="5"
IsSnapToTickEnabled="True" />
</StackPanel>
</ui:SettingsExpanderItem>
<!-- 休息时长 -->
<ui:SettingsExpanderItem>
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="16">
<StackPanel Classes="settings-item">
<TextBlock Classes="settings-item-label"
Text="{Binding BreakDurationLabel}" />
<TextBlock Classes="settings-item-description"
Text="{Binding BreakDurationDescription}" />
</StackPanel>
<TextBlock Grid.Column="1"
VerticalAlignment="Center"
FontWeight="SemiBold"
Text="{Binding BreakDurationValueText}" />
</Grid>
<Slider Minimum="1"
Maximum="30"
Value="{Binding BreakDurationMinutes}"
SmallChange="1"
LargeChange="5"
TickFrequency="5"
IsSnapToTickEnabled="True" />
</StackPanel>
</ui:SettingsExpanderItem>
<!-- 长休息时长 -->
<ui:SettingsExpanderItem>
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="16">
<StackPanel Classes="settings-item">
<TextBlock Classes="settings-item-label"
Text="{Binding LongBreakDurationLabel}" />
<TextBlock Classes="settings-item-description"
Text="{Binding LongBreakDurationDescription}" />
</StackPanel>
<TextBlock Grid.Column="1"
VerticalAlignment="Center"
FontWeight="SemiBold"
Text="{Binding LongBreakDurationValueText}" />
</Grid>
<Slider Minimum="5"
Maximum="60"
Value="{Binding LongBreakDurationMinutes}"
SmallChange="1"
LargeChange="5"
TickFrequency="5"
IsSnapToTickEnabled="True" />
</StackPanel>
</ui:SettingsExpanderItem>
<!-- 长休息间隔 -->
<ui:SettingsExpanderItem>
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="16">
<StackPanel Classes="settings-item">
<TextBlock Classes="settings-item-label"
Text="{Binding SessionsBeforeLongBreakLabel}" />
<TextBlock Classes="settings-item-description"
Text="{Binding SessionsBeforeLongBreakDescription}" />
</StackPanel>
<TextBlock Grid.Column="1"
VerticalAlignment="Center"
FontWeight="SemiBold"
Text="{Binding SessionsBeforeLongBreakValueText}" />
</Grid>
<Slider Minimum="2"
Maximum="8"
Value="{Binding SessionsBeforeLongBreak}"
SmallChange="1"
LargeChange="1"
TickFrequency="1"
IsSnapToTickEnabled="True" />
</StackPanel>
</ui:SettingsExpanderItem>
<!-- 自动开始休息 -->
<ui:SettingsExpanderItem>
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="16">
<StackPanel Classes="settings-item">
<TextBlock Classes="settings-item-label"
Text="{Binding AutoStartBreakLabel}" />
<TextBlock Classes="settings-item-description"
Text="{Binding AutoStartBreakDescription}" />
</StackPanel>
<ToggleSwitch Grid.Column="1"
IsChecked="{Binding AutoStartBreak}" />
</Grid>
</ui:SettingsExpanderItem>
<!-- 自动开始专注 -->
<ui:SettingsExpanderItem>
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="16">
<StackPanel Classes="settings-item">
<TextBlock Classes="settings-item-label"
Text="{Binding AutoStartFocusLabel}" />
<TextBlock Classes="settings-item-description"
Text="{Binding AutoStartFocusDescription}" />
</StackPanel>
<ToggleSwitch Grid.Column="1"
IsChecked="{Binding AutoStartFocus}" />
</Grid>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
<!-- 提醒设置 -->
<ui:SettingsExpander Classes="settings-expander-card"
Header="{Binding AlertHeader}"
Description="{Binding AlertDescription}">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="Alert" />
</ui:SettingsExpander.IconSource>
<!-- 噪音打断提醒 -->
<ui:SettingsExpanderItem>
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="16">
<StackPanel Classes="settings-item">
<TextBlock Classes="settings-item-label"
Text="{Binding NoiseAlertEnabledLabel}" />
<TextBlock Classes="settings-item-description"
Text="{Binding NoiseAlertEnabledDescription}" />
</StackPanel>
<ToggleSwitch Grid.Column="1"
IsChecked="{Binding NoiseAlertEnabled}" />
</Grid>
</ui:SettingsExpanderItem>
<!-- 每分钟最大容忍打断次数 -->
<ui:SettingsExpanderItem IsVisible="{Binding NoiseAlertEnabled}">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="16">
<StackPanel Classes="settings-item">
<TextBlock Classes="settings-item-label"
Text="{Binding MaxInterruptsPerMinuteLabel}" />
<TextBlock Classes="settings-item-description"
Text="{Binding MaxInterruptsPerMinuteDescription}" />
</StackPanel>
<NumericUpDown Grid.Column="1"
Width="120"
Minimum="3"
Maximum="20"
Increment="1"
Value="{Binding MaxInterruptsPerMinute}" />
</Grid>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
<!-- 显示设置 -->
<ui:SettingsExpander Classes="settings-expander-card"
Header="{Binding DisplayHeader}"
Description="{Binding DisplayDescription}">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="Eye" />
</ui:SettingsExpander.IconSource>
<!-- 显示实时分贝 -->
<ui:SettingsExpanderItem>
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="16">
<StackPanel Classes="settings-item">
<TextBlock Classes="settings-item-label"
Text="{Binding ShowRealtimeDbLabel}" />
<TextBlock Classes="settings-item-description"
Text="{Binding ShowRealtimeDbDescription}" />
</StackPanel>
<ToggleSwitch Grid.Column="1"
IsChecked="{Binding ShowRealtimeDb}" />
</Grid>
</ui:SettingsExpanderItem>
<!-- 基准显示分贝 -->
<ui:SettingsExpanderItem>
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="16">
<StackPanel Classes="settings-item">
<TextBlock Classes="settings-item-label"
Text="{Binding BaselineDbLabel}" />
<TextBlock Classes="settings-item-description"
Text="{Binding BaselineDbDescription}" />
</StackPanel>
<TextBlock Grid.Column="1"
VerticalAlignment="Center"
FontWeight="SemiBold"
Text="{Binding BaselineDbValueText}" />
</Grid>
<Slider Minimum="20"
Maximum="90"
Value="{Binding BaselineDb}"
SmallChange="1"
LargeChange="5"
TickFrequency="5"
IsSnapToTickEnabled="True" />
</StackPanel>
</ui:SettingsExpanderItem>
<!-- 平均时间窗 -->
<ui:SettingsExpanderItem>
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="16">
<StackPanel Classes="settings-item">
<TextBlock Classes="settings-item-label"
Text="{Binding AvgWindowSecLabel}" />
<TextBlock Classes="settings-item-description"
Text="{Binding AvgWindowSecDescription}" />
</StackPanel>
<TextBlock Grid.Column="1"
VerticalAlignment="Center"
FontWeight="SemiBold"
Text="{Binding AvgWindowSecValueText}" />
</Grid>
<Slider Minimum="1"
Maximum="8"
Value="{Binding AvgWindowSec}"
SmallChange="1"
LargeChange="1"
TickFrequency="1"
IsSnapToTickEnabled="True" />
</StackPanel>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
<TextBlock Classes="settings-item-description"
Margin="0,8,0,0"
Text="{Binding FooterHint}"
TextWrapping="Wrap" />
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -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();
}
}

View File

@@ -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
};
}