mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-07-03 09:14:25 +08:00
Compare commits
2 Commits
v0.7.9
...
f84111e837
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f84111e837 | ||
|
|
bd2313fe7e |
45
.github/README.md
vendored
45
.github/README.md
vendored
@@ -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
133
.github/READMEmd
vendored
Normal file
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
@@ -43,4 +43,5 @@ public static class BuiltInComponentIds
|
|||||||
public const string DesktopBrowser = "DesktopBrowser";
|
public const string DesktopBrowser = "DesktopBrowser";
|
||||||
public const string DesktopOfficeRecentDocuments = "DesktopOfficeRecentDocuments";
|
public const string DesktopOfficeRecentDocuments = "DesktopOfficeRecentDocuments";
|
||||||
public const string DesktopRemovableStorage = "DesktopRemovableStorage";
|
public const string DesktopRemovableStorage = "DesktopRemovableStorage";
|
||||||
|
public const string DesktopZhiJiaoHub = "DesktopZhiJiaoHub";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -390,7 +390,17 @@ public sealed class ComponentRegistry
|
|||||||
MinWidthCells: 2,
|
MinWidthCells: 2,
|
||||||
MinHeightCells: 2,
|
MinHeightCells: 2,
|
||||||
AllowStatusBarPlacement: false,
|
AllowStatusBarPlacement: false,
|
||||||
AllowDesktopPlacement: true)
|
AllowDesktopPlacement: true),
|
||||||
|
new DesktopComponentDefinition(
|
||||||
|
BuiltInComponentIds.DesktopZhiJiaoHub,
|
||||||
|
"智教Hub",
|
||||||
|
"Image",
|
||||||
|
"Info",
|
||||||
|
MinWidthCells: 2,
|
||||||
|
MinHeightCells: 2,
|
||||||
|
AllowStatusBarPlacement: false,
|
||||||
|
AllowDesktopPlacement: true,
|
||||||
|
ResizeMode: DesktopComponentResizeMode.Free)
|
||||||
};
|
};
|
||||||
|
|
||||||
return new ComponentRegistry(builtIn);
|
return new ComponentRegistry(builtIn);
|
||||||
|
|||||||
@@ -53,8 +53,8 @@
|
|||||||
<PackageReference Include="DotNetCampus.AvaloniaInkCanvas" Version="1.0.1" />
|
<PackageReference Include="DotNetCampus.AvaloniaInkCanvas" Version="1.0.1" />
|
||||||
<PackageReference Include="Downloader" Version="4.1.1" />
|
<PackageReference Include="Downloader" Version="4.1.1" />
|
||||||
<PackageReference Include="FluentAvaloniaUI" Version="2.5.0" />
|
<PackageReference Include="FluentAvaloniaUI" Version="2.5.0" />
|
||||||
<PackageReference Include="FluentIcons.Avalonia" Version="2.0.319" />
|
<PackageReference Include="FluentIcons.Avalonia" Version="2.0.320" />
|
||||||
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.319" />
|
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.320" />
|
||||||
<PackageReference Include="Material.Avalonia" Version="3.13.4" />
|
<PackageReference Include="Material.Avalonia" Version="3.13.4" />
|
||||||
<PackageReference Include="Material.Icons.Avalonia" Version="2.4.1" />
|
<PackageReference Include="Material.Icons.Avalonia" Version="2.4.1" />
|
||||||
<PackageReference Include="ClassIsland.Markdown.Avalonia" Version="11.0.3.4" />
|
<PackageReference Include="ClassIsland.Markdown.Avalonia" Version="11.0.3.4" />
|
||||||
|
|||||||
@@ -203,6 +203,52 @@
|
|||||||
"settings.weather.alert_list_desc": "One exclusion rule per line.",
|
"settings.weather.alert_list_desc": "One exclusion rule per line.",
|
||||||
"settings.weather.no_tls_toggle": "Allow non-TLS request fallback",
|
"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.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_header": "Weather Location",
|
||||||
"settings.weather.location_desc": "Set the location used by weather widgets.",
|
"settings.weather.location_desc": "Set the location used by weather widgets.",
|
||||||
"settings.weather.location_placeholder": "e.g. Beijing",
|
"settings.weather.location_placeholder": "e.g. Beijing",
|
||||||
@@ -977,5 +1023,19 @@
|
|||||||
"single_instance.notice.description": "The app is already running. There is no need to click multiple times to open it.",
|
"single_instance.notice.description": "The app is already running. There is no need to click multiple times to open it.",
|
||||||
"single_instance.notice.button": "OK",
|
"single_instance.notice.button": "OK",
|
||||||
"market.status.install_success_restart_format": "✓ Plugin '{0}' installed successfully! Please restart the application to activate it.",
|
"market.status.install_success_restart_format": "✓ Plugin '{0}' installed successfully! Please restart the application to activate it.",
|
||||||
"market.dialog.restart_message_format": "Plugin '{0}' has been installed successfully.\n\nTo use this plugin, you need to restart the application now.\n\nWould you like to restart?"
|
"market.dialog.restart_message_format": "Plugin '{0}' has been installed successfully.\n\nTo use this plugin, you need to restart the application now.\n\nWould you like to restart?",
|
||||||
|
"zhijiaohub.settings.source": "Image Source",
|
||||||
|
"zhijiaohub.settings.classisland": "ClassIsland Gallery",
|
||||||
|
"zhijiaohub.settings.sectl": "SECTL Gallery",
|
||||||
|
"zhijiaohub.settings.source_desc": "Select the image source. ClassIsland Gallery contains fun moments from the ClassIsland community, SECTL Gallery contains content from the SECTL community.",
|
||||||
|
"zhijiaohub.settings.mirror_source": "Mirror Acceleration",
|
||||||
|
"zhijiaohub.settings.mirror_direct": "Direct (GitHub)",
|
||||||
|
"zhijiaohub.settings.mirror_ghproxy": "Mirror Acceleration (Recommended)",
|
||||||
|
"zhijiaohub.settings.mirror_source_desc": "If images load slowly or fail, try using mirror acceleration. Mirror acceleration speeds up GitHub access through third-party proxy services.",
|
||||||
|
"zhijiaohub.settings.refresh": "Refresh Settings",
|
||||||
|
"zhijiaohub.settings.auto_refresh": "Auto Refresh",
|
||||||
|
"zhijiaohub.settings.auto_refresh_desc": "Automatically refresh the image list periodically.",
|
||||||
|
"zhijiaohub.settings.interval": "Refresh Interval (minutes)",
|
||||||
|
"zhijiaohub.settings.about": "About",
|
||||||
|
"zhijiaohub.settings.about_desc": "ZhiJiaoHub displays interesting images from the educational technology community. Images are fetched from GitHub repositories and cached locally."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,6 +210,52 @@
|
|||||||
"settings.weather.location_required": "날씨 위치는 비워둘 수 없습니다.",
|
"settings.weather.location_required": "날씨 위치는 비워둘 수 없습니다.",
|
||||||
"settings.weather.location_current_format": "현재 날씨 위치: {0}",
|
"settings.weather.location_current_format": "현재 날씨 위치: {0}",
|
||||||
"settings.weather.location_saved_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.location_not_configured": "날씨 위치가 구성되지 않음",
|
||||||
"weather.widget.configure_hint": "설정 > 날씨에서 구성을 완료하세요",
|
"weather.widget.configure_hint": "설정 > 날씨에서 구성을 완료하세요",
|
||||||
"weather.widget.loading": "로딩 중...",
|
"weather.widget.loading": "로딩 중...",
|
||||||
|
|||||||
@@ -206,6 +206,52 @@
|
|||||||
"settings.weather.location_required": "天气位置不能为空。",
|
"settings.weather.location_required": "天气位置不能为空。",
|
||||||
"settings.weather.location_current_format": "当前天气位置:{0}",
|
"settings.weather.location_current_format": "当前天气位置:{0}",
|
||||||
"settings.weather.location_saved_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.location_not_configured": "尚未配置天气位置",
|
||||||
"weather.widget.configure_hint": "请前往 设置 > 天气 完成配置",
|
"weather.widget.configure_hint": "请前往 设置 > 天气 完成配置",
|
||||||
"weather.widget.loading": "加载中...",
|
"weather.widget.loading": "加载中...",
|
||||||
@@ -971,5 +1017,19 @@
|
|||||||
"single_instance.notice.description": "应用已经运行,无需多次点击打开。",
|
"single_instance.notice.description": "应用已经运行,无需多次点击打开。",
|
||||||
"single_instance.notice.button": "确定",
|
"single_instance.notice.button": "确定",
|
||||||
"market.status.install_success_restart_format": "✓ 插件'{0}'安装成功!请重启应用以激活它。",
|
"market.status.install_success_restart_format": "✓ 插件'{0}'安装成功!请重启应用以激活它。",
|
||||||
"market.dialog.restart_message_format": "插件'{0}'已成功安装。\n\n要使用此插件,您需要立即重启应用。\n\n是否立即重启?"
|
"market.dialog.restart_message_format": "插件'{0}'已成功安装。\n\n要使用此插件,您需要立即重启应用。\n\n是否立即重启?",
|
||||||
}
|
"zhijiaohub.settings.source": "图片源",
|
||||||
|
"zhijiaohub.settings.classisland": "ClassIsland 图库",
|
||||||
|
"zhijiaohub.settings.sectl": "SECTL 图库",
|
||||||
|
"zhijiaohub.settings.source_desc": "选择图片来源。ClassIsland 图库包含 ClassIsland 社区的趣味瞬间,SECTL 图库包含 SECTL 社区的内容。",
|
||||||
|
"zhijiaohub.settings.mirror_source": "镜像加速",
|
||||||
|
"zhijiaohub.settings.mirror_direct": "直连(GitHub)",
|
||||||
|
"zhijiaohub.settings.mirror_ghproxy": "镜像加速(推荐)",
|
||||||
|
"zhijiaohub.settings.mirror_source_desc": "如果图片加载缓慢或失败,请尝试使用镜像加速。镜像加速通过第三方代理服务加速 GitHub 访问。",
|
||||||
|
"zhijiaohub.settings.refresh": "刷新设置",
|
||||||
|
"zhijiaohub.settings.auto_refresh": "自动刷新",
|
||||||
|
"zhijiaohub.settings.auto_refresh_desc": "定期自动刷新图片列表。",
|
||||||
|
"zhijiaohub.settings.interval": "刷新间隔(分钟)",
|
||||||
|
"zhijiaohub.settings.about": "关于",
|
||||||
|
"zhijiaohub.settings.about_desc": "智教Hub 展示来自教育技术社区的有趣图片。图片从 GitHub 仓库获取并缓存在本地。"
|
||||||
|
}
|
||||||
|
|||||||
@@ -118,6 +118,36 @@ public sealed class AppSettingsSnapshot
|
|||||||
|
|
||||||
public List<string> DisabledPluginIds { get; set; } = [];
|
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()
|
public AppSettingsSnapshot Clone()
|
||||||
{
|
{
|
||||||
var clone = (AppSettingsSnapshot)MemberwiseClone();
|
var clone = (AppSettingsSnapshot)MemberwiseClone();
|
||||||
|
|||||||
@@ -73,6 +73,17 @@ public sealed class ComponentSettingsSnapshot
|
|||||||
|
|
||||||
public List<string>? OfficeRecentDocumentsEnabledSources { get; set; }
|
public List<string>? OfficeRecentDocumentsEnabledSources { get; set; }
|
||||||
|
|
||||||
|
// 智教Hub组件配置
|
||||||
|
public string ZhiJiaoHubSource { get; set; } = ZhiJiaoHubSources.ClassIsland;
|
||||||
|
|
||||||
|
public string ZhiJiaoHubMirrorSource { get; set; } = ZhiJiaoHubMirrorSources.Direct;
|
||||||
|
|
||||||
|
public bool ZhiJiaoHubAutoRefreshEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
public int ZhiJiaoHubAutoRefreshIntervalMinutes { get; set; } = 30;
|
||||||
|
|
||||||
|
public int ZhiJiaoHubCurrentImageIndex { get; set; } = 0;
|
||||||
|
|
||||||
public ComponentSettingsSnapshot Clone()
|
public ComponentSettingsSnapshot Clone()
|
||||||
{
|
{
|
||||||
var clone = (ComponentSettingsSnapshot)MemberwiseClone();
|
var clone = (ComponentSettingsSnapshot)MemberwiseClone();
|
||||||
@@ -107,3 +118,56 @@ public sealed class ComponentSettingsSnapshot
|
|||||||
return clone;
|
return clone;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 智教Hub数据源常量
|
||||||
|
public static class ZhiJiaoHubSources
|
||||||
|
{
|
||||||
|
public const string ClassIsland = "classisland";
|
||||||
|
public const string Sectl = "sectl";
|
||||||
|
|
||||||
|
public static string Normalize(string? value)
|
||||||
|
{
|
||||||
|
return value?.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"sectl" => Sectl,
|
||||||
|
_ => ClassIsland
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 智教Hub镜像加速源常量
|
||||||
|
public static class ZhiJiaoHubMirrorSources
|
||||||
|
{
|
||||||
|
public const string Direct = "direct";
|
||||||
|
public const string GhProxy = "gh-proxy";
|
||||||
|
|
||||||
|
public const string GhProxyBaseUrl = "https://gh-proxy.com/";
|
||||||
|
|
||||||
|
public static string Normalize(string? value)
|
||||||
|
{
|
||||||
|
return string.Equals(value, GhProxy, StringComparison.OrdinalIgnoreCase)
|
||||||
|
? GhProxy
|
||||||
|
: Direct;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ApplyMirror(string url, string? mirrorSource)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(url))
|
||||||
|
{
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(Normalize(mirrorSource), GhProxy, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.StartsWith("https://raw.githubusercontent.com/", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
url.StartsWith("https://github.com/", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return GhProxyBaseUrl.TrimEnd('/') + "/" + url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -262,7 +262,12 @@ public static class DesktopComponentEditorRegistryFactory
|
|||||||
nameof(ComponentSettingsSnapshot.Stcn24ForumAutoRefreshIntervalMinutes),
|
nameof(ComponentSettingsSnapshot.Stcn24ForumAutoRefreshIntervalMinutes),
|
||||||
nameof(ComponentSettingsSnapshot.Stcn24ForumSourceType)
|
nameof(ComponentSettingsSnapshot.Stcn24ForumSourceType)
|
||||||
]
|
]
|
||||||
}))
|
})),
|
||||||
|
[BuiltInComponentIds.DesktopZhiJiaoHub] = new(
|
||||||
|
BuiltInComponentIds.DesktopZhiJiaoHub,
|
||||||
|
context => new ZhiJiaoHubComponentEditor(context),
|
||||||
|
preferredWidth: 480d,
|
||||||
|
preferredHeight: 520d)
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (var componentId in GetBuiltInDesktopComponentIds(componentRegistry))
|
foreach (var componentId in GetBuiltInDesktopComponentIds(componentRegistry))
|
||||||
|
|||||||
@@ -107,7 +107,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
|||||||
bool includePrerelease,
|
bool includePrerelease,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var normalizedCurrentVersionText = NormalizeVersion(currentVersion).ToString(3);
|
var normalizedCurrentVersion = NormalizeVersion(currentVersion);
|
||||||
|
var normalizedCurrentVersionText = FormatVersionText(normalizedCurrentVersion);
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(_owner) || string.IsNullOrWhiteSpace(_repo))
|
if (string.IsNullOrWhiteSpace(_owner) || string.IsNullOrWhiteSpace(_repo))
|
||||||
{
|
{
|
||||||
@@ -141,7 +142,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
|||||||
|
|
||||||
var hasParsedTagVersion = TryParseVersion(release.TagName, out var parsedTagVersion);
|
var hasParsedTagVersion = TryParseVersion(release.TagName, out var parsedTagVersion);
|
||||||
var latestVersionText = hasParsedTagVersion && parsedTagVersion is not null
|
var latestVersionText = hasParsedTagVersion && parsedTagVersion is not null
|
||||||
? parsedTagVersion.ToString(3)
|
? FormatVersionText(parsedTagVersion)
|
||||||
: release.TagName;
|
: release.TagName;
|
||||||
|
|
||||||
var isUpdateAvailable = parsedTagVersion is not null && parsedTagVersion > currentVersion;
|
var isUpdateAvailable = parsedTagVersion is not null && parsedTagVersion > currentVersion;
|
||||||
@@ -180,7 +181,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
|||||||
bool includePrerelease,
|
bool includePrerelease,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var normalizedCurrentVersionText = NormalizeVersion(currentVersion).ToString(3);
|
var normalizedCurrentVersion = NormalizeVersion(currentVersion);
|
||||||
|
var normalizedCurrentVersionText = FormatVersionText(normalizedCurrentVersion);
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(_owner) || string.IsNullOrWhiteSpace(_repo))
|
if (string.IsNullOrWhiteSpace(_owner) || string.IsNullOrWhiteSpace(_repo))
|
||||||
{
|
{
|
||||||
@@ -216,7 +218,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
|||||||
|
|
||||||
var hasParsedTagVersion = TryParseVersion(release.TagName, out var parsedTagVersion);
|
var hasParsedTagVersion = TryParseVersion(release.TagName, out var parsedTagVersion);
|
||||||
var latestVersionText = hasParsedTagVersion && parsedTagVersion is not null
|
var latestVersionText = hasParsedTagVersion && parsedTagVersion is not null
|
||||||
? parsedTagVersion.ToString(3)
|
? FormatVersionText(parsedTagVersion)
|
||||||
: release.TagName;
|
: release.TagName;
|
||||||
|
|
||||||
var preferredAsset = SelectPreferredInstallerAsset(release.Assets);
|
var preferredAsset = SelectPreferredInstallerAsset(release.Assets);
|
||||||
@@ -740,8 +742,18 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
|||||||
{
|
{
|
||||||
var major = Math.Max(0, version.Major);
|
var major = Math.Max(0, version.Major);
|
||||||
var minor = Math.Max(0, version.Minor);
|
var minor = Math.Max(0, version.Minor);
|
||||||
var build = Math.Max(0, version.Build);
|
var build = Math.Max(0, version.Build >= 0 ? version.Build : 0);
|
||||||
return new Version(major, minor, build);
|
var revision = Math.Max(0, version.Revision >= 0 ? version.Revision : 0);
|
||||||
|
return revision > 0
|
||||||
|
? new Version(major, minor, build, revision)
|
||||||
|
: new Version(major, minor, build);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatVersionText(Version version)
|
||||||
|
{
|
||||||
|
return version.Revision > 0
|
||||||
|
? version.ToString(4)
|
||||||
|
: version.ToString(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string Truncate(string value, int maxLength)
|
private static string Truncate(string value, int maxLength)
|
||||||
|
|||||||
@@ -52,6 +52,35 @@ public sealed record ExchangeRateQuery(
|
|||||||
string? TargetCurrency = null,
|
string? TargetCurrency = null,
|
||||||
bool ForceRefresh = false);
|
bool ForceRefresh = false);
|
||||||
|
|
||||||
|
public sealed record ZhiJiaoHubQuery(
|
||||||
|
string? Source = null,
|
||||||
|
int? ImageIndex = null,
|
||||||
|
bool ForceRefresh = false,
|
||||||
|
string? MirrorSource = null);
|
||||||
|
|
||||||
|
public sealed record ZhiJiaoHubImageItem(
|
||||||
|
string Name,
|
||||||
|
string Url,
|
||||||
|
int Index);
|
||||||
|
|
||||||
|
public sealed record ZhiJiaoHubSnapshot(
|
||||||
|
IReadOnlyList<ZhiJiaoHubImageItem> Images,
|
||||||
|
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>(
|
public sealed record RecommendationQueryResult<T>(
|
||||||
bool Success,
|
bool Success,
|
||||||
T? Data,
|
T? Data,
|
||||||
@@ -285,6 +314,14 @@ public sealed record RecommendationApiOptions
|
|||||||
public int DefaultBaiduHotSearchCount { get; init; } = 4;
|
public int DefaultBaiduHotSearchCount { get; init; } = 4;
|
||||||
|
|
||||||
public int DefaultStcn24ForumPostCount { get; init; } = 4;
|
public int DefaultStcn24ForumPostCount { get; init; } = 4;
|
||||||
|
|
||||||
|
public string ClassIslandHubApiUrl { get; init; } = "https://api.github.com/repos/ClassIsland/classisland-hub/contents/images";
|
||||||
|
|
||||||
|
public string SectlHubApiUrl { get; init; } = "https://api.github.com/repos/SECTL/SECTL-hub/contents/images";
|
||||||
|
|
||||||
|
public string ClassIslandHubRawUrlTemplate { get; init; } = "https://raw.githubusercontent.com/ClassIsland/classisland-hub/main/images/{0}";
|
||||||
|
|
||||||
|
public string SectlHubRawUrlTemplate { get; init; } = "https://raw.githubusercontent.com/SECTL/SECTL-hub/main/images/{0}";
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IRecommendationInfoService
|
public interface IRecommendationInfoService
|
||||||
@@ -325,5 +362,37 @@ public interface IRecommendationInfoService
|
|||||||
ExchangeRateQuery query,
|
ExchangeRateQuery query,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RecommendationQueryResult<ZhiJiaoHubSnapshot>> GetZhiJiaoHubImagesAsync(
|
||||||
|
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,
|
||||||
|
IProgress<(int Current, int Total, string Status)>? progress = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
ZhiJiaoHubLocalSnapshot? LoadZhiJiaoHubLocalSnapshot(string source);
|
||||||
|
|
||||||
|
bool HasZhiJiaoHubLocalCache(string source);
|
||||||
|
|
||||||
void ClearCache();
|
void ClearCache();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -53,6 +53,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
|||||||
Dictionary<string, decimal> Rates,
|
Dictionary<string, decimal> Rates,
|
||||||
DateTimeOffset ExpireAt,
|
DateTimeOffset ExpireAt,
|
||||||
DateTimeOffset FetchedAt);
|
DateTimeOffset FetchedAt);
|
||||||
|
private sealed record ZhiJiaoHubCacheEntry(ZhiJiaoHubSnapshot Snapshot, DateTimeOffset ExpireAt);
|
||||||
private sealed record ArtworkCandidate(
|
private sealed record ArtworkCandidate(
|
||||||
string Title,
|
string Title,
|
||||||
string? Artist,
|
string? Artist,
|
||||||
@@ -80,6 +81,8 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
|||||||
new(StringComparer.OrdinalIgnoreCase);
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly Dictionary<string, ExchangeRateTableCacheEntry> _exchangeRateCacheByBaseCurrency =
|
private readonly Dictionary<string, ExchangeRateTableCacheEntry> _exchangeRateCacheByBaseCurrency =
|
||||||
new(StringComparer.OrdinalIgnoreCase);
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly Dictionary<string, ZhiJiaoHubCacheEntry> _zhiJiaoHubCacheBySource =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
private int _dailyNewsRotationCursor;
|
private int _dailyNewsRotationCursor;
|
||||||
|
|
||||||
static RecommendationDataService()
|
static RecommendationDataService()
|
||||||
@@ -94,7 +97,15 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
|||||||
_options = options ?? new RecommendationApiOptions();
|
_options = options ?? new RecommendationApiOptions();
|
||||||
if (httpClient is null)
|
if (httpClient is null)
|
||||||
{
|
{
|
||||||
_httpClient = new HttpClient
|
// 配置 HttpClientHandler 以支持所有 TLS 版本
|
||||||
|
var handler = new HttpClientHandler
|
||||||
|
{
|
||||||
|
SslProtocols = System.Security.Authentication.SslProtocols.Tls12 |
|
||||||
|
System.Security.Authentication.SslProtocols.Tls13,
|
||||||
|
ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => true
|
||||||
|
};
|
||||||
|
|
||||||
|
_httpClient = new HttpClient(handler)
|
||||||
{
|
{
|
||||||
Timeout = _options.RequestTimeout
|
Timeout = _options.RequestTimeout
|
||||||
};
|
};
|
||||||
@@ -128,6 +139,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
|||||||
_dailyWordCache = null;
|
_dailyWordCache = null;
|
||||||
_stcn24ForumPostsCacheBySource.Clear();
|
_stcn24ForumPostsCacheBySource.Clear();
|
||||||
_exchangeRateCacheByBaseCurrency.Clear();
|
_exchangeRateCacheByBaseCurrency.Clear();
|
||||||
|
_zhiJiaoHubCacheBySource.Clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3194,4 +3206,368 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
|||||||
? text
|
? text
|
||||||
: $"{text[..maxLength]}...";
|
: $"{text[..maxLength]}...";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 智教Hub相关方法
|
||||||
|
public async Task<RecommendationQueryResult<ZhiJiaoHubSnapshot>> GetZhiJiaoHubImagesAsync(
|
||||||
|
ZhiJiaoHubQuery query,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var normalizedQuery = query ?? new ZhiJiaoHubQuery();
|
||||||
|
var source = ZhiJiaoHubSources.Normalize(normalizedQuery.Source);
|
||||||
|
var mirrorSource = ZhiJiaoHubMirrorSources.Normalize(normalizedQuery.MirrorSource);
|
||||||
|
var cacheKey = $"{source}|{mirrorSource}";
|
||||||
|
|
||||||
|
if (!normalizedQuery.ForceRefresh && TryGetZhiJiaoHubFromCache(cacheKey, out var cached))
|
||||||
|
{
|
||||||
|
return RecommendationQueryResult<ZhiJiaoHubSnapshot>.Ok(cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var snapshot = await FetchZhiJiaoHubSnapshotAsync(source, mirrorSource, cancellationToken);
|
||||||
|
SetZhiJiaoHubCache(cacheKey, snapshot);
|
||||||
|
return RecommendationQueryResult<ZhiJiaoHubSnapshot>.Ok(snapshot);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
return RecommendationQueryResult<ZhiJiaoHubSnapshot>.Fail("upstream_network_error", ex.Message);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return RecommendationQueryResult<ZhiJiaoHubSnapshot>.Fail("upstream_parse_error", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ZhiJiaoHubSnapshot> FetchZhiJiaoHubSnapshotAsync(string source, string mirrorSource, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var (owner, repo, path) = source switch
|
||||||
|
{
|
||||||
|
ZhiJiaoHubSources.Sectl => ("SECTL", "SECTL-hub", "docs/.vuepress/public/images"),
|
||||||
|
_ => ("ClassIsland", "classisland-hub", "images")
|
||||||
|
};
|
||||||
|
|
||||||
|
var contentsUrl = $"https://api.github.com/repos/{owner}/{repo}/contents/{path}";
|
||||||
|
|
||||||
|
// 如果使用镜像加速,代理 GitHub API 请求
|
||||||
|
if (string.Equals(mirrorSource, ZhiJiaoHubMirrorSources.GhProxy, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
contentsUrl = ZhiJiaoHubMirrorSources.GhProxyBaseUrl.TrimEnd('/') + "/" + contentsUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var images = await FetchImagesFromContentsApi(owner, repo, path, contentsUrl, mirrorSource, cancellationToken);
|
||||||
|
|
||||||
|
if (images.Count == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("未找到图片文件");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 随机打乱图片顺序
|
||||||
|
var random = new Random();
|
||||||
|
var shuffled = images.OrderBy(_ => random.Next()).ToList();
|
||||||
|
|
||||||
|
// 重新设置索引
|
||||||
|
for (int i = 0; i < shuffled.Count; i++)
|
||||||
|
{
|
||||||
|
var item = shuffled[i];
|
||||||
|
shuffled[i] = item with { Index = i };
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ZhiJiaoHubSnapshot(shuffled, 0, source);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex) when (ex.Message.Contains("403") || ex.Message.Contains("rate limit"))
|
||||||
|
{
|
||||||
|
throw new HttpRequestException("GitHub API 速率限制,请稍后重试");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
throw new HttpRequestException($"获取图片列表失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<ZhiJiaoHubImageItem>> FetchImagesFromContentsApi(string owner, string repo, string path, string contentsUrl, string mirrorSource, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var images = new List<ZhiJiaoHubImageItem>();
|
||||||
|
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, contentsUrl);
|
||||||
|
request.Headers.TryAddWithoutValidation("User-Agent", "LanMountainDesktop/1.0");
|
||||||
|
request.Headers.TryAddWithoutValidation("Accept", "application/vnd.github+json");
|
||||||
|
request.Headers.TryAddWithoutValidation("X-GitHub-Api-Version", "2022-11-28");
|
||||||
|
|
||||||
|
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var errorText = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
if ((int)response.StatusCode == 403)
|
||||||
|
{
|
||||||
|
throw new HttpRequestException("GitHub API 速率限制,请稍后重试");
|
||||||
|
}
|
||||||
|
throw new HttpRequestException($"API 返回错误: {(int)response.StatusCode} - {Truncate(errorText, 200)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var responseText = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
using var document = JsonDocument.Parse(responseText);
|
||||||
|
var root = document.RootElement;
|
||||||
|
|
||||||
|
if (root.ValueKind != JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("message", out var messageNode))
|
||||||
|
{
|
||||||
|
var errorMessage = messageNode.GetString();
|
||||||
|
throw new InvalidOperationException($"GitHub API 错误: {errorMessage}");
|
||||||
|
}
|
||||||
|
throw new InvalidOperationException("Invalid response format from GitHub API.");
|
||||||
|
}
|
||||||
|
|
||||||
|
int index = 0;
|
||||||
|
foreach (var item in root.EnumerateArray())
|
||||||
|
{
|
||||||
|
var type = ReadString(item, "type");
|
||||||
|
if (type != "file")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = ReadString(item, "name");
|
||||||
|
var downloadUrl = ReadString(item, "download_url");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只处理图片文件
|
||||||
|
var extension = Path.GetExtension(name).ToLowerInvariant();
|
||||||
|
if (extension != ".png" && extension != ".jpg" && extension != ".jpeg" && extension != ".gif" && extension != ".webp")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解码文件名
|
||||||
|
var decodedName = Uri.UnescapeDataString(name);
|
||||||
|
decodedName = Path.GetFileNameWithoutExtension(decodedName);
|
||||||
|
|
||||||
|
// 构造图片 URL
|
||||||
|
string imageUrl;
|
||||||
|
if (!string.IsNullOrWhiteSpace(downloadUrl))
|
||||||
|
{
|
||||||
|
imageUrl = downloadUrl;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
imageUrl = $"https://raw.githubusercontent.com/{owner}/{repo}/main/{path}/{Uri.EscapeDataString(name)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用镜像加速到图片 URL
|
||||||
|
imageUrl = ZhiJiaoHubMirrorSources.ApplyMirror(imageUrl, mirrorSource);
|
||||||
|
|
||||||
|
images.Add(new ZhiJiaoHubImageItem(decodedName, imageUrl, index));
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return images;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryGetZhiJiaoHubFromCache(string cacheKey, out ZhiJiaoHubSnapshot snapshot)
|
||||||
|
{
|
||||||
|
lock (_cacheGate)
|
||||||
|
{
|
||||||
|
if (_zhiJiaoHubCacheBySource.TryGetValue(cacheKey, out var cacheEntry) &&
|
||||||
|
cacheEntry.ExpireAt > DateTimeOffset.UtcNow)
|
||||||
|
{
|
||||||
|
snapshot = cacheEntry.Snapshot;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot = null!;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetZhiJiaoHubCache(string cacheKey, ZhiJiaoHubSnapshot snapshot)
|
||||||
|
{
|
||||||
|
lock (_cacheGate)
|
||||||
|
{
|
||||||
|
// 使用较长的缓存时间(1小时),因为图片列表不常变化
|
||||||
|
_zhiJiaoHubCacheBySource[cacheKey] = new ZhiJiaoHubCacheEntry(
|
||||||
|
snapshot,
|
||||||
|
DateTimeOffset.UtcNow.Add(TimeSpan.FromHours(1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly ZhiJiaoHubCacheService _zhiJiaoHubCacheService = new();
|
||||||
|
|
||||||
|
public async Task<ZhiJiaoHubSyncResult> SyncZhiJiaoHubImagesAsync(
|
||||||
|
string source,
|
||||||
|
string mirrorSource,
|
||||||
|
IProgress<(int Current, int Total, string Status)>? progress = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var normalizedSource = ZhiJiaoHubSources.Normalize(source);
|
||||||
|
var normalizedMirror = ZhiJiaoHubMirrorSources.Normalize(mirrorSource);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var query = new ZhiJiaoHubQuery(normalizedSource, ForceRefresh: true, MirrorSource: normalizedMirror);
|
||||||
|
var result = await GetZhiJiaoHubImagesAsync(query, cancellationToken);
|
||||||
|
|
||||||
|
if (!result.Success || result.Data == null)
|
||||||
|
{
|
||||||
|
return new ZhiJiaoHubSyncResult(
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
result.ErrorMessage ?? "Failed to fetch image list");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await _zhiJiaoHubCacheService.SyncImagesAsync(
|
||||||
|
normalizedSource,
|
||||||
|
result.Data.Images,
|
||||||
|
normalizedMirror,
|
||||||
|
progress,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new ZhiJiaoHubSyncResult(false, null, 0, 0, 0, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ZhiJiaoHubLocalSnapshot? LoadZhiJiaoHubLocalSnapshot(string source)
|
||||||
|
{
|
||||||
|
var normalizedSource = ZhiJiaoHubSources.Normalize(source);
|
||||||
|
return _zhiJiaoHubCacheService.LoadLocalSnapshot(normalizedSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasZhiJiaoHubLocalCache(string source)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
512
LanMountainDesktop/Services/ZhiJiaoHubCacheService.cs
Normal file
512
LanMountainDesktop/Services/ZhiJiaoHubCacheService.cs
Normal file
@@ -0,0 +1,512 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Security.Authentication;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
public sealed record ZhiJiaoHubLocalImageItem(
|
||||||
|
string Name,
|
||||||
|
string OriginalUrl,
|
||||||
|
string LocalPath,
|
||||||
|
int Index);
|
||||||
|
|
||||||
|
public sealed record ZhiJiaoHubLocalSnapshot(
|
||||||
|
IReadOnlyList<ZhiJiaoHubLocalImageItem> Images,
|
||||||
|
string Source,
|
||||||
|
DateTimeOffset LastUpdated,
|
||||||
|
int TotalCount);
|
||||||
|
|
||||||
|
public sealed record ZhiJiaoHubSyncResult(
|
||||||
|
bool Success,
|
||||||
|
ZhiJiaoHubLocalSnapshot? Snapshot,
|
||||||
|
int DownloadedCount,
|
||||||
|
int SkippedCount,
|
||||||
|
int FailedCount,
|
||||||
|
string? ErrorMessage = null);
|
||||||
|
|
||||||
|
public sealed class ZhiJiaoHubCacheService : IDisposable
|
||||||
|
{
|
||||||
|
private static readonly HttpClient DownloadClient;
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
WriteIndented = true
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly string _cacheDirectory;
|
||||||
|
private readonly string _manifestPath;
|
||||||
|
private readonly object _manifestLock = new();
|
||||||
|
private bool _isDisposed;
|
||||||
|
|
||||||
|
static ZhiJiaoHubCacheService()
|
||||||
|
{
|
||||||
|
var handler = new HttpClientHandler
|
||||||
|
{
|
||||||
|
SslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13,
|
||||||
|
AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate
|
||||||
|
};
|
||||||
|
|
||||||
|
DownloadClient = new HttpClient(handler)
|
||||||
|
{
|
||||||
|
Timeout = TimeSpan.FromSeconds(30)
|
||||||
|
};
|
||||||
|
DownloadClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop/1.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
public ZhiJiaoHubCacheService()
|
||||||
|
{
|
||||||
|
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||||
|
var dataDirectory = Path.Combine(appData, "LanMountainDesktop", "cache", "zhijiaohub");
|
||||||
|
_cacheDirectory = dataDirectory;
|
||||||
|
_manifestPath = Path.Combine(dataDirectory, "manifest.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
public string CacheDirectory => _cacheDirectory;
|
||||||
|
|
||||||
|
public bool HasLocalCache(string source)
|
||||||
|
{
|
||||||
|
lock (_manifestLock)
|
||||||
|
{
|
||||||
|
if (!File.Exists(_manifestPath))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(_manifestPath);
|
||||||
|
var manifest = JsonSerializer.Deserialize<CacheManifest>(json, JsonOptions);
|
||||||
|
return manifest?.Entries?.ContainsKey(source) == true &&
|
||||||
|
manifest.Entries[source].Images.Count > 0 &&
|
||||||
|
Directory.Exists(GetSourceDirectory(source));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ZhiJiaoHubLocalSnapshot? LoadLocalSnapshot(string source)
|
||||||
|
{
|
||||||
|
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 sourceDir = GetSourceDirectory(source);
|
||||||
|
var images = entry.Images
|
||||||
|
.Where(img => File.Exists(Path.Combine(sourceDir, img.LocalFileName)))
|
||||||
|
.Select((img, idx) => new ZhiJiaoHubLocalImageItem(
|
||||||
|
img.Name,
|
||||||
|
img.OriginalUrl,
|
||||||
|
Path.Combine(sourceDir, img.LocalFileName),
|
||||||
|
idx))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (images.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ZhiJiaoHubLocalSnapshot(
|
||||||
|
images,
|
||||||
|
source,
|
||||||
|
entry.LastUpdated,
|
||||||
|
images.Count);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
string mirrorSource,
|
||||||
|
IProgress<(int Current, int Total, string Status)>? progress = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (remoteImages == null || remoteImages.Count == 0)
|
||||||
|
{
|
||||||
|
return new ZhiJiaoHubSyncResult(false, null, 0, 0, 0, "No images to sync");
|
||||||
|
}
|
||||||
|
|
||||||
|
var sourceDir = GetSourceDirectory(source);
|
||||||
|
Directory.CreateDirectory(sourceDir);
|
||||||
|
|
||||||
|
var downloadedCount = 0;
|
||||||
|
var skippedCount = 0;
|
||||||
|
var failedCount = 0;
|
||||||
|
var localImages = new List<CachedImageInfo>();
|
||||||
|
|
||||||
|
for (var i = 0; i < remoteImages.Count; i++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var remoteImage = remoteImages[i];
|
||||||
|
var fileName = GetSafeFileName(remoteImage.Name, remoteImage.Url);
|
||||||
|
var localPath = Path.Combine(sourceDir, fileName);
|
||||||
|
|
||||||
|
progress?.Report((i + 1, remoteImages.Count, $"Downloading {remoteImage.Name}..."));
|
||||||
|
|
||||||
|
if (File.Exists(localPath))
|
||||||
|
{
|
||||||
|
skippedCount++;
|
||||||
|
localImages.Add(new CachedImageInfo(remoteImage.Name, remoteImage.Url, fileName));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var downloadUrl = ResolveDownloadUrl(remoteImage.Url, mirrorSource);
|
||||||
|
using var response = await DownloadClient.GetAsync(downloadUrl, cancellationToken);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
await using var fileStream = File.Create(localPath);
|
||||||
|
await response.Content.CopyToAsync(fileStream, cancellationToken);
|
||||||
|
|
||||||
|
downloadedCount++;
|
||||||
|
localImages.Add(new CachedImageInfo(remoteImage.Name, remoteImage.Url, fileName));
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
failedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localImages.Count == 0)
|
||||||
|
{
|
||||||
|
return new ZhiJiaoHubSyncResult(false, null, downloadedCount, skippedCount, failedCount, "All downloads failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
SaveManifest(source, localImages);
|
||||||
|
|
||||||
|
var snapshot = new ZhiJiaoHubLocalSnapshot(
|
||||||
|
localImages.Select((img, idx) => new ZhiJiaoHubLocalImageItem(
|
||||||
|
img.Name,
|
||||||
|
img.OriginalUrl,
|
||||||
|
Path.Combine(sourceDir, img.LocalFileName),
|
||||||
|
idx)).ToList(),
|
||||||
|
source,
|
||||||
|
DateTimeOffset.UtcNow,
|
||||||
|
localImages.Count);
|
||||||
|
|
||||||
|
return new ZhiJiaoHubSyncResult(true, snapshot, downloadedCount, skippedCount, failedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearCache(string? source = null)
|
||||||
|
{
|
||||||
|
lock (_manifestLock)
|
||||||
|
{
|
||||||
|
if (source != null)
|
||||||
|
{
|
||||||
|
var sourceDir = GetSourceDirectory(source);
|
||||||
|
if (Directory.Exists(sourceDir))
|
||||||
|
{
|
||||||
|
Directory.Delete(sourceDir, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (File.Exists(_manifestPath))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(_manifestPath);
|
||||||
|
var manifest = JsonSerializer.Deserialize<CacheManifest>(json, JsonOptions);
|
||||||
|
if (manifest?.Entries != null && manifest.Entries.ContainsKey(source))
|
||||||
|
{
|
||||||
|
manifest.Entries.Remove(source);
|
||||||
|
File.WriteAllText(_manifestPath, JsonSerializer.Serialize(manifest, JsonOptions));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_cacheDirectory))
|
||||||
|
{
|
||||||
|
Directory.Delete(_cacheDirectory, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetSourceDirectory(string source)
|
||||||
|
{
|
||||||
|
return Path.Combine(_cacheDirectory, source.ToLowerInvariant().Replace(" ", "-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetSafeFileName(string name, string url)
|
||||||
|
{
|
||||||
|
var ext = Path.GetExtension(new Uri(url).AbsolutePath);
|
||||||
|
if (string.IsNullOrEmpty(ext) || ext.Length > 5)
|
||||||
|
{
|
||||||
|
ext = ".jpg";
|
||||||
|
}
|
||||||
|
|
||||||
|
var safeName = string.Concat(name.Split(Path.GetInvalidFileNameChars()));
|
||||||
|
if (string.IsNullOrWhiteSpace(safeName))
|
||||||
|
{
|
||||||
|
safeName = Guid.NewGuid().ToString("N")[..8];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{safeName}{ext}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveDownloadUrl(string originalUrl, string mirrorSource)
|
||||||
|
{
|
||||||
|
if (string.Equals(mirrorSource, ZhiJiaoHubMirrorSources.GhProxy, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return ZhiJiaoHubMirrorSources.GhProxyBaseUrl.TrimEnd('/') + "/" + originalUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest.Entries[source] = new CacheEntry(images, DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(_manifestPath)!);
|
||||||
|
File.WriteAllText(_manifestPath, JsonSerializer.Serialize(manifest, JsonOptions));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_isDisposed) return;
|
||||||
|
_isDisposed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CacheManifest
|
||||||
|
{
|
||||||
|
public Dictionary<string, CacheEntry> Entries { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CacheEntry
|
||||||
|
{
|
||||||
|
public List<CachedImageInfo> Images { get; set; }
|
||||||
|
public DateTimeOffset LastUpdated { get; set; }
|
||||||
|
|
||||||
|
public CacheEntry(List<CachedImageInfo> images, DateTimeOffset lastUpdated)
|
||||||
|
{
|
||||||
|
Images = images;
|
||||||
|
LastUpdated = lastUpdated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CachedImageInfo
|
||||||
|
{
|
||||||
|
public string Name { get; set; }
|
||||||
|
public string OriginalUrl { get; set; }
|
||||||
|
public string LocalFileName { get; set; }
|
||||||
|
|
||||||
|
public CachedImageInfo(string name, string originalUrl, string localFileName)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
OriginalUrl = originalUrl;
|
||||||
|
LocalFileName = localFileName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2322,6 +2322,527 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
=> _localizationService.GetString(_languageCode, key, fallback);
|
=> _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 sealed class PluginGeneratedSettingsPageViewModel
|
||||||
{
|
{
|
||||||
public PluginGeneratedSettingsPageViewModel(
|
public PluginGeneratedSettingsPageViewModel(
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
x:Class="LanMountainDesktop.Views.ComponentEditors.ZhiJiaoHubComponentEditor">
|
||||||
|
<StackPanel Spacing="16">
|
||||||
|
<!-- 数据源选择 -->
|
||||||
|
<Border Classes="component-editor-card"
|
||||||
|
Padding="20">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock x:Name="SourceLabelTextBlock"
|
||||||
|
Classes="component-editor-section-title" />
|
||||||
|
<ComboBox x:Name="SourceComboBox"
|
||||||
|
Classes="component-editor-select"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
SelectionChanged="OnSourceSelectionChanged">
|
||||||
|
<ComboBoxItem x:Name="ClassIslandItem"
|
||||||
|
Classes="component-editor-select-item"
|
||||||
|
Tag="classisland" />
|
||||||
|
<ComboBoxItem x:Name="SectlItem"
|
||||||
|
Classes="component-editor-select-item"
|
||||||
|
Tag="sectl" />
|
||||||
|
</ComboBox>
|
||||||
|
<TextBlock x:Name="SourceDescriptionTextBlock"
|
||||||
|
Classes="component-editor-secondary-text"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- 镜像加速源选择 -->
|
||||||
|
<Border Classes="component-editor-card"
|
||||||
|
Padding="20">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock x:Name="MirrorSourceLabelTextBlock"
|
||||||
|
Classes="component-editor-section-title" />
|
||||||
|
<ComboBox x:Name="MirrorSourceComboBox"
|
||||||
|
Classes="component-editor-select"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
SelectionChanged="OnMirrorSourceSelectionChanged">
|
||||||
|
<ComboBoxItem x:Name="DirectMirrorItem"
|
||||||
|
Classes="component-editor-select-item"
|
||||||
|
Tag="direct" />
|
||||||
|
<ComboBoxItem x:Name="GhProxyMirrorItem"
|
||||||
|
Classes="component-editor-select-item"
|
||||||
|
Tag="gh-proxy" />
|
||||||
|
</ComboBox>
|
||||||
|
<TextBlock x:Name="MirrorSourceDescriptionTextBlock"
|
||||||
|
Classes="component-editor-secondary-text"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- 自动刷新设置 -->
|
||||||
|
<Border Classes="component-editor-card"
|
||||||
|
Padding="20">
|
||||||
|
<StackPanel Spacing="16">
|
||||||
|
<TextBlock x:Name="RefreshSettingsLabelTextBlock"
|
||||||
|
Classes="component-editor-section-title" />
|
||||||
|
|
||||||
|
<!-- 自动刷新开关 -->
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock x:Name="AutoRefreshLabelTextBlock"
|
||||||
|
Classes="component-editor-primary-text" />
|
||||||
|
<TextBlock x:Name="AutoRefreshDescriptionTextBlock"
|
||||||
|
Classes="component-editor-secondary-text"
|
||||||
|
FontSize="12" />
|
||||||
|
</StackPanel>
|
||||||
|
<ToggleSwitch x:Name="AutoRefreshToggle"
|
||||||
|
Grid.Column="1"
|
||||||
|
IsCheckedChanged="OnAutoRefreshChanged" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- 刷新间隔 -->
|
||||||
|
<StackPanel x:Name="IntervalPanel"
|
||||||
|
Spacing="8">
|
||||||
|
<TextBlock x:Name="IntervalLabelTextBlock"
|
||||||
|
Classes="component-editor-primary-text" />
|
||||||
|
<NumericUpDown x:Name="IntervalNumeric"
|
||||||
|
Classes="component-editor-numeric"
|
||||||
|
Minimum="5"
|
||||||
|
Maximum="1440"
|
||||||
|
Increment="5"
|
||||||
|
ValueChanged="OnIntervalValueChanged" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- 说明信息 -->
|
||||||
|
<Border Classes="component-editor-card"
|
||||||
|
Padding="20">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock x:Name="AboutLabelTextBlock"
|
||||||
|
Classes="component-editor-section-title" />
|
||||||
|
<TextBlock x:Name="AboutDescriptionTextBlock"
|
||||||
|
Classes="component-editor-secondary-text"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
using System;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using LanMountainDesktop.ComponentSystem;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views.ComponentEditors;
|
||||||
|
|
||||||
|
public partial class ZhiJiaoHubComponentEditor : ComponentEditorViewBase
|
||||||
|
{
|
||||||
|
private bool _suppressEvents;
|
||||||
|
|
||||||
|
public ZhiJiaoHubComponentEditor()
|
||||||
|
: this(null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public ZhiJiaoHubComponentEditor(DesktopComponentEditorContext? context)
|
||||||
|
: base(context)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
ApplyLocalization();
|
||||||
|
LoadState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyLocalization()
|
||||||
|
{
|
||||||
|
// 标题
|
||||||
|
SourceLabelTextBlock.Text = L("zhijiaohub.settings.source", "图片源");
|
||||||
|
ClassIslandItem.Content = L("zhijiaohub.settings.classisland", "ClassIsland 图库");
|
||||||
|
SectlItem.Content = L("zhijiaohub.settings.sectl", "SECTL 图库");
|
||||||
|
|
||||||
|
// 数据源描述
|
||||||
|
SourceDescriptionTextBlock.Text = L("zhijiaohub.settings.source_desc",
|
||||||
|
"选择图片来源。ClassIsland 图库包含 ClassIsland 社区的趣味瞬间,SECTL 图库包含 SECTL 社区的内容。");
|
||||||
|
|
||||||
|
// 镜像加速源
|
||||||
|
MirrorSourceLabelTextBlock.Text = L("zhijiaohub.settings.mirror_source", "镜像加速");
|
||||||
|
DirectMirrorItem.Content = L("zhijiaohub.settings.mirror_direct", "直连(GitHub)");
|
||||||
|
GhProxyMirrorItem.Content = L("zhijiaohub.settings.mirror_ghproxy", "镜像加速(推荐)");
|
||||||
|
MirrorSourceDescriptionTextBlock.Text = L("zhijiaohub.settings.mirror_source_desc",
|
||||||
|
"如果图片加载缓慢或失败,请尝试使用镜像加速。镜像加速通过第三方代理服务加速 GitHub 访问。");
|
||||||
|
|
||||||
|
// 刷新设置
|
||||||
|
RefreshSettingsLabelTextBlock.Text = L("zhijiaohub.settings.refresh", "刷新设置");
|
||||||
|
AutoRefreshLabelTextBlock.Text = L("zhijiaohub.settings.auto_refresh", "自动刷新");
|
||||||
|
AutoRefreshDescriptionTextBlock.Text = L("zhijiaohub.settings.auto_refresh_desc",
|
||||||
|
"定期自动刷新图片列表。");
|
||||||
|
IntervalLabelTextBlock.Text = L("zhijiaohub.settings.interval", "刷新间隔(分钟)");
|
||||||
|
|
||||||
|
// 关于
|
||||||
|
AboutLabelTextBlock.Text = L("zhijiaohub.settings.about", "关于");
|
||||||
|
AboutDescriptionTextBlock.Text = L("zhijiaohub.settings.about_desc",
|
||||||
|
"智教Hub 展示来自教育技术社区的有趣图片。图片从 GitHub 仓库获取并缓存在本地。");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadState()
|
||||||
|
{
|
||||||
|
_suppressEvents = true;
|
||||||
|
|
||||||
|
var snapshot = LoadSnapshot();
|
||||||
|
|
||||||
|
// 数据源
|
||||||
|
var source = ZhiJiaoHubSources.Normalize(snapshot.ZhiJiaoHubSource);
|
||||||
|
SourceComboBox.SelectedItem = source switch
|
||||||
|
{
|
||||||
|
ZhiJiaoHubSources.Sectl => SectlItem,
|
||||||
|
_ => ClassIslandItem
|
||||||
|
};
|
||||||
|
|
||||||
|
// 镜像加速源
|
||||||
|
var mirrorSource = ZhiJiaoHubMirrorSources.Normalize(snapshot.ZhiJiaoHubMirrorSource);
|
||||||
|
MirrorSourceComboBox.SelectedItem = mirrorSource switch
|
||||||
|
{
|
||||||
|
ZhiJiaoHubMirrorSources.GhProxy => GhProxyMirrorItem,
|
||||||
|
_ => DirectMirrorItem
|
||||||
|
};
|
||||||
|
|
||||||
|
// 自动刷新
|
||||||
|
AutoRefreshToggle.IsChecked = snapshot.ZhiJiaoHubAutoRefreshEnabled;
|
||||||
|
|
||||||
|
// 刷新间隔
|
||||||
|
var interval = Math.Clamp(snapshot.ZhiJiaoHubAutoRefreshIntervalMinutes, 5, 1440);
|
||||||
|
IntervalNumeric.Value = interval;
|
||||||
|
IntervalPanel.IsVisible = snapshot.ZhiJiaoHubAutoRefreshEnabled;
|
||||||
|
|
||||||
|
_suppressEvents = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSourceSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_suppressEvents)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var source = SourceComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tag
|
||||||
|
? ZhiJiaoHubSources.Normalize(tag)
|
||||||
|
: ZhiJiaoHubSources.ClassIsland;
|
||||||
|
|
||||||
|
var snapshot = LoadSnapshot();
|
||||||
|
snapshot.ZhiJiaoHubSource = source;
|
||||||
|
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ZhiJiaoHubSource));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnMirrorSourceSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_suppressEvents)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var mirrorSource = MirrorSourceComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tag
|
||||||
|
? ZhiJiaoHubMirrorSources.Normalize(tag)
|
||||||
|
: ZhiJiaoHubMirrorSources.Direct;
|
||||||
|
|
||||||
|
var snapshot = LoadSnapshot();
|
||||||
|
snapshot.ZhiJiaoHubMirrorSource = mirrorSource;
|
||||||
|
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ZhiJiaoHubMirrorSource));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAutoRefreshChanged(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_ = sender;
|
||||||
|
_ = e;
|
||||||
|
if (_suppressEvents)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var isEnabled = AutoRefreshToggle.IsChecked ?? true;
|
||||||
|
IntervalPanel.IsVisible = isEnabled;
|
||||||
|
|
||||||
|
var snapshot = LoadSnapshot();
|
||||||
|
snapshot.ZhiJiaoHubAutoRefreshEnabled = isEnabled;
|
||||||
|
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ZhiJiaoHubAutoRefreshEnabled));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnIntervalValueChanged(object? sender, NumericUpDownValueChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_suppressEvents)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var interval = (int)Math.Clamp(IntervalNumeric.Value ?? 30, 5, 1440);
|
||||||
|
|
||||||
|
var snapshot = LoadSnapshot();
|
||||||
|
snapshot.ZhiJiaoHubAutoRefreshIntervalMinutes = interval;
|
||||||
|
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ZhiJiaoHubAutoRefreshIntervalMinutes));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -230,7 +230,7 @@ public partial class ComponentLibraryWindow : Window
|
|||||||
|
|
||||||
if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return Symbol.Apps;
|
return Symbol.Hourglass;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Symbol.Apps;
|
return Symbol.Apps;
|
||||||
|
|||||||
@@ -471,7 +471,11 @@ public sealed class DesktopComponentRuntimeRegistry
|
|||||||
new DesktopComponentRuntimeRegistration(
|
new DesktopComponentRuntimeRegistration(
|
||||||
BuiltInComponentIds.HolidayCalendar,
|
BuiltInComponentIds.HolidayCalendar,
|
||||||
"component.holiday_calendar",
|
"component.holiday_calendar",
|
||||||
() => new HolidayCalendarWidget())
|
() => new HolidayCalendarWidget()),
|
||||||
|
new DesktopComponentRuntimeRegistration(
|
||||||
|
BuiltInComponentIds.DesktopZhiJiaoHub,
|
||||||
|
"component.zhijiao_hub",
|
||||||
|
() => new ZhiJiaoHubWidget())
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
97
LanMountainDesktop/Views/Components/ZhiJiaoHubWidget.axaml
Normal file
97
LanMountainDesktop/Views/Components/ZhiJiaoHubWidget.axaml
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
d:DesignWidth="96"
|
||||||
|
d:DesignHeight="96"
|
||||||
|
x:Class="LanMountainDesktop.Views.Components.ZhiJiaoHubWidget">
|
||||||
|
|
||||||
|
<Border x:Name="RootBorder"
|
||||||
|
CornerRadius="{DynamicResource DesignCornerRadiusXs}"
|
||||||
|
ClipToBounds="True"
|
||||||
|
BorderThickness="0"
|
||||||
|
Background="#1A1A1A">
|
||||||
|
<Grid x:Name="MainGrid">
|
||||||
|
<!-- 图片显示 -->
|
||||||
|
<Image x:Name="CurrentImage"
|
||||||
|
Stretch="UniformToFill"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
|
||||||
|
<!-- 左下角渐变遮罩 -->
|
||||||
|
<Border x:Name="GradientOverlay"
|
||||||
|
VerticalAlignment="Bottom"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
Height="60">
|
||||||
|
<Border.Background>
|
||||||
|
<LinearGradientBrush StartPoint="0%,0%" EndPoint="0%,100%">
|
||||||
|
<GradientStop Offset="0" Color="#00000000" />
|
||||||
|
<GradientStop Offset="1" Color="#CC000000" />
|
||||||
|
</LinearGradientBrush>
|
||||||
|
</Border.Background>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- 图片名称 -->
|
||||||
|
<TextBlock x:Name="ImageNameTextBlock"
|
||||||
|
Text=""
|
||||||
|
Foreground="#FFFFFF"
|
||||||
|
FontSize="11"
|
||||||
|
FontWeight="Medium"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
MaxLines="1"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
VerticalAlignment="Bottom"
|
||||||
|
Margin="10,0,10,8" />
|
||||||
|
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<StackPanel x:Name="LoadingPanel"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Spacing="8"
|
||||||
|
IsVisible="False">
|
||||||
|
<TextBlock x:Name="LoadingTextBlock"
|
||||||
|
Text="加载中..."
|
||||||
|
Foreground="#AAAAAA"
|
||||||
|
FontSize="12" />
|
||||||
|
<ProgressBar x:Name="LoadingProgressBar"
|
||||||
|
IsIndeterminate="True"
|
||||||
|
Width="60"
|
||||||
|
Height="2"
|
||||||
|
Foreground="#4A9EFF" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- 错误状态 -->
|
||||||
|
<TextBlock x:Name="ErrorTextBlock"
|
||||||
|
Text=""
|
||||||
|
Foreground="#FF6666"
|
||||||
|
FontSize="10"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
TextAlignment="Center"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="10"
|
||||||
|
IsVisible="False" />
|
||||||
|
|
||||||
|
<!-- 指示器 -->
|
||||||
|
<Border x:Name="IndicatorBorder"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="0,0,6,0"
|
||||||
|
Background="Transparent">
|
||||||
|
<StackPanel x:Name="IndicatorPanel"
|
||||||
|
Orientation="Vertical"
|
||||||
|
Spacing="4">
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- 触摸/鼠标捕获层 -->
|
||||||
|
<Border x:Name="InputCaptureBorder"
|
||||||
|
Background="Transparent"
|
||||||
|
PointerPressed="OnPointerPressed"
|
||||||
|
PointerMoved="OnPointerMoved"
|
||||||
|
PointerReleased="OnPointerReleased"
|
||||||
|
PointerWheelChanged="OnPointerWheelChanged" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</UserControl>
|
||||||
847
LanMountainDesktop/Views/Components/ZhiJiaoHubWidget.axaml.cs
Normal file
847
LanMountainDesktop/Views/Components/ZhiJiaoHubWidget.axaml.cs
Normal file
@@ -0,0 +1,847 @@
|
|||||||
|
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;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Media.Imaging;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using LanMountainDesktop.ComponentSystem;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views.Components;
|
||||||
|
|
||||||
|
public partial class ZhiJiaoHubWidget : UserControl,
|
||||||
|
IDesktopComponentWidget,
|
||||||
|
IRecommendationInfoAwareComponentWidget,
|
||||||
|
IComponentSettingsContextAware,
|
||||||
|
IComponentPlacementContextAware
|
||||||
|
{
|
||||||
|
private const double BaseCellSize = 48d;
|
||||||
|
private const double SwipeThreshold = 50;
|
||||||
|
|
||||||
|
private readonly DispatcherTimer _refreshTimer = new();
|
||||||
|
|
||||||
|
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;
|
||||||
|
private string _componentId = BuiltInComponentIds.DesktopZhiJiaoHub;
|
||||||
|
private string _placementId = string.Empty;
|
||||||
|
private double _currentCellSize = BaseCellSize;
|
||||||
|
private bool _isAttached;
|
||||||
|
private bool _isInitializing;
|
||||||
|
private bool _autoRefreshEnabled = true;
|
||||||
|
private int _pendingImageIndex = 0;
|
||||||
|
|
||||||
|
private IReadOnlyList<ZhiJiaoHubHybridImageItem> _images = [];
|
||||||
|
private int _currentImageIndex = 0;
|
||||||
|
|
||||||
|
private readonly Dictionary<int, Bitmap> _imageCache = new();
|
||||||
|
private readonly object _cacheLock = new();
|
||||||
|
private const int MaxCacheSize = 5;
|
||||||
|
|
||||||
|
private bool _isDragging;
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
if (Design.IsDesignMode)
|
||||||
|
{
|
||||||
|
ApplyCellSize(_currentCellSize);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_refreshTimer.Tick += OnRefreshTimerTick;
|
||||||
|
|
||||||
|
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||||
|
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||||
|
SizeChanged += OnSizeChanged;
|
||||||
|
|
||||||
|
ApplyCellSize(_currentCellSize);
|
||||||
|
ApplyLoadingState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
|
{
|
||||||
|
_isAttached = true;
|
||||||
|
|
||||||
|
LoadSettings();
|
||||||
|
_ = InitializeAsync();
|
||||||
|
UpdateTimers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
|
{
|
||||||
|
_isAttached = false;
|
||||||
|
_refreshTimer.Stop();
|
||||||
|
_refreshCts?.Cancel();
|
||||||
|
_backgroundDownloadCts?.Cancel();
|
||||||
|
|
||||||
|
lock (_cacheLock)
|
||||||
|
{
|
||||||
|
foreach (var bitmap in _imageCache.Values)
|
||||||
|
{
|
||||||
|
bitmap.Dispose();
|
||||||
|
}
|
||||||
|
_imageCache.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ApplyCellSize(double cellSize)
|
||||||
|
{
|
||||||
|
_currentCellSize = Math.Max(1, cellSize);
|
||||||
|
var scale = _currentCellSize / BaseCellSize;
|
||||||
|
|
||||||
|
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(12 * scale, 4, 24));
|
||||||
|
|
||||||
|
var fontSize = Math.Clamp(11 * scale, 9, 18);
|
||||||
|
ImageNameTextBlock.FontSize = fontSize;
|
||||||
|
LoadingTextBlock.FontSize = Math.Clamp(12 * scale, 10, 16);
|
||||||
|
ErrorTextBlock.FontSize = Math.Clamp(10 * scale, 8, 14);
|
||||||
|
|
||||||
|
GradientOverlay.Height = Math.Clamp(60 * scale, 30, 100);
|
||||||
|
|
||||||
|
ImageNameTextBlock.Margin = new Thickness(
|
||||||
|
Math.Clamp(10 * scale, 5, 20),
|
||||||
|
0,
|
||||||
|
Math.Clamp(10 * scale, 5, 20),
|
||||||
|
Math.Clamp(8 * scale, 4, 16));
|
||||||
|
|
||||||
|
IndicatorBorder.Margin = new Thickness(0, 0, Math.Clamp(6 * scale, 3, 12), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetComponentSettingsContext(DesktopComponentSettingsContext context)
|
||||||
|
{
|
||||||
|
_componentId = context.ComponentId;
|
||||||
|
_placementId = context.PlacementId ?? string.Empty;
|
||||||
|
_componentSettingsAccessor = context.ComponentSettingsAccessor;
|
||||||
|
|
||||||
|
LoadSettings();
|
||||||
|
|
||||||
|
if (_isAttached)
|
||||||
|
{
|
||||||
|
_ = InitializeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||||
|
{
|
||||||
|
_componentId = componentId;
|
||||||
|
_placementId = placementId ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RefreshFromSettings()
|
||||||
|
{
|
||||||
|
LoadSettings();
|
||||||
|
UpdateTimers();
|
||||||
|
if (_isAttached)
|
||||||
|
{
|
||||||
|
_ = InitializeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadSettings()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var snapshot = _componentSettingsAccessor?.LoadSnapshot<ComponentSettingsSnapshot>();
|
||||||
|
if (snapshot is not null)
|
||||||
|
{
|
||||||
|
_source = ZhiJiaoHubSources.Normalize(snapshot.ZhiJiaoHubSource);
|
||||||
|
_mirrorSource = ZhiJiaoHubMirrorSources.Normalize(snapshot.ZhiJiaoHubMirrorSource);
|
||||||
|
_autoRefreshEnabled = snapshot.ZhiJiaoHubAutoRefreshEnabled;
|
||||||
|
_pendingImageIndex = snapshot.ZhiJiaoHubCurrentImageIndex;
|
||||||
|
|
||||||
|
var intervalMinutes = Math.Clamp(snapshot.ZhiJiaoHubAutoRefreshIntervalMinutes, 5, 1440);
|
||||||
|
_refreshTimer.Interval = TimeSpan.FromMinutes(intervalMinutes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SaveCurrentImageIndex()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var snapshot = _componentSettingsAccessor?.LoadSnapshot<ComponentSettingsSnapshot>()
|
||||||
|
?? new ComponentSettingsSnapshot();
|
||||||
|
snapshot.ZhiJiaoHubCurrentImageIndex = _currentImageIndex;
|
||||||
|
_componentSettingsAccessor?.SaveSnapshot(snapshot, [nameof(ComponentSettingsSnapshot.ZhiJiaoHubCurrentImageIndex)]);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateTimers()
|
||||||
|
{
|
||||||
|
if (_autoRefreshEnabled)
|
||||||
|
{
|
||||||
|
_refreshTimer.Start();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_refreshTimer.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
if (_isInitializing)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isInitializing = true;
|
||||||
|
_refreshCts?.Cancel();
|
||||||
|
_backgroundDownloadCts?.Cancel();
|
||||||
|
_refreshCts = new CancellationTokenSource();
|
||||||
|
var ct = _refreshCts.Token;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
LoadingTextBlock.Text = "加载中...";
|
||||||
|
ApplyLoadingState();
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = await _recommendationService.GetZhiJiaoHubHybridImagesAsync(_source, _mirrorSource, ct);
|
||||||
|
|
||||||
|
if (ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.Success || result.Data == null || result.Data.Images.Count == 0)
|
||||||
|
{
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
ApplyErrorState(result.ErrorMessage ?? "无法获取图片列表");
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_images = result.Data.Images;
|
||||||
|
_currentImageIndex = Math.Clamp(_pendingImageIndex, 0, Math.Max(0, _images.Count - 1));
|
||||||
|
_pendingImageIndex = 0;
|
||||||
|
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
UpdateIndicators();
|
||||||
|
});
|
||||||
|
|
||||||
|
await LoadAndDisplayCurrentImageAsync();
|
||||||
|
|
||||||
|
if (result.Data.CachedCount < result.Data.TotalCount)
|
||||||
|
{
|
||||||
|
_ = StartBackgroundDownloadAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
ApplyErrorState($"初始化失败: {ex.Message}");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isInitializing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task StartBackgroundDownloadAsync()
|
||||||
|
{
|
||||||
|
_backgroundDownloadCts?.Cancel();
|
||||||
|
_backgroundDownloadCts = new CancellationTokenSource();
|
||||||
|
var ct = _backgroundDownloadCts.Token;
|
||||||
|
|
||||||
|
await _recommendationService.StartBackgroundDownloadAsync(
|
||||||
|
_source,
|
||||||
|
_images,
|
||||||
|
_mirrorSource,
|
||||||
|
(downloaded, total, name) =>
|
||||||
|
{
|
||||||
|
if (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
LoadingTextBlock.Text = $"后台缓存 {downloaded}/{total}";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAndDisplayCurrentImageAsync(int direction = 0)
|
||||||
|
{
|
||||||
|
if (_images.Count == 0)
|
||||||
|
{
|
||||||
|
ApplyErrorState("暂无图片");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var imageItem = _images[_currentImageIndex];
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Bitmap? cachedBitmap = null;
|
||||||
|
lock (_cacheLock)
|
||||||
|
{
|
||||||
|
_imageCache.TryGetValue(_currentImageIndex, out cachedBitmap);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cachedBitmap != null)
|
||||||
|
{
|
||||||
|
CurrentImage.Source = cachedBitmap;
|
||||||
|
ImageNameTextBlock.Text = imageItem.Name;
|
||||||
|
ApplyContentVisibleState();
|
||||||
|
_ = Task.Run(async () => await PreloadAdjacentImagesAsync(direction));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageItem.IsCached && !string.IsNullOrEmpty(imageItem.LocalPath) && File.Exists(imageItem.LocalPath))
|
||||||
|
{
|
||||||
|
await LoadFromLocalPathAsync(imageItem.LocalPath, imageItem.Name);
|
||||||
|
_ = Task.Run(async () => await PreloadAdjacentImagesAsync(direction));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadFromRemoteUrlAsync(imageItem, direction);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
ApplyErrorState($"图片加载失败: {ex.Message}");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (_images.Count <= 1)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var indicesToPreload = new List<int>();
|
||||||
|
var currentIndex = _currentImageIndex;
|
||||||
|
|
||||||
|
lock (_cacheLock)
|
||||||
|
{
|
||||||
|
if (direction <= 0)
|
||||||
|
{
|
||||||
|
var nextIndex = (currentIndex + 1) % _images.Count;
|
||||||
|
if (!_imageCache.ContainsKey(nextIndex))
|
||||||
|
{
|
||||||
|
indicesToPreload.Add(nextIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
var nextNextIndex = (currentIndex + 2) % _images.Count;
|
||||||
|
if (!_imageCache.ContainsKey(nextNextIndex) && indicesToPreload.Count < 3)
|
||||||
|
{
|
||||||
|
indicesToPreload.Add(nextNextIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (direction >= 0)
|
||||||
|
{
|
||||||
|
var prevIndex = (currentIndex - 1 + _images.Count) % _images.Count;
|
||||||
|
if (!_imageCache.ContainsKey(prevIndex))
|
||||||
|
{
|
||||||
|
indicesToPreload.Add(prevIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
var prevPrevIndex = (currentIndex - 2 + _images.Count) % _images.Count;
|
||||||
|
if (!_imageCache.ContainsKey(prevPrevIndex) && indicesToPreload.Count < 3)
|
||||||
|
{
|
||||||
|
indicesToPreload.Add(prevPrevIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (indicesToPreload.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var preloadTasks = indicesToPreload.Select(async index =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
lock (_cacheLock)
|
||||||
|
{
|
||||||
|
if (_imageCache.ContainsKey(index))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_imageCache.Count >= MaxCacheSize)
|
||||||
|
{
|
||||||
|
CleanupFarthestCacheUnsafe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var imageItem = _images[index];
|
||||||
|
Bitmap? bitmap = null;
|
||||||
|
|
||||||
|
if (imageItem.IsCached && !string.IsNullOrEmpty(imageItem.LocalPath) && File.Exists(imageItem.LocalPath))
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bitmap != null)
|
||||||
|
{
|
||||||
|
lock (_cacheLock)
|
||||||
|
{
|
||||||
|
if (!_imageCache.ContainsKey(index))
|
||||||
|
{
|
||||||
|
_imageCache[index] = bitmap;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
bitmap.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
await Task.WhenAll(preloadTasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CleanupFarthestCacheUnsafe()
|
||||||
|
{
|
||||||
|
if (_imageCache.Count == 0) return;
|
||||||
|
|
||||||
|
var farthestKey = -1;
|
||||||
|
var maxDistance = -1;
|
||||||
|
var currentIndex = _currentImageIndex;
|
||||||
|
var imageCount = _images.Count;
|
||||||
|
|
||||||
|
foreach (var key in _imageCache.Keys)
|
||||||
|
{
|
||||||
|
if (key == currentIndex) continue;
|
||||||
|
|
||||||
|
var forwardDistance = (key - currentIndex + imageCount) % imageCount;
|
||||||
|
var backwardDistance = (currentIndex - key + imageCount) % imageCount;
|
||||||
|
var distance = Math.Min(forwardDistance, backwardDistance);
|
||||||
|
|
||||||
|
if (distance > maxDistance)
|
||||||
|
{
|
||||||
|
maxDistance = distance;
|
||||||
|
farthestKey = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (farthestKey >= 0)
|
||||||
|
{
|
||||||
|
if (_imageCache.TryGetValue(farthestKey, out var bitmap))
|
||||||
|
{
|
||||||
|
bitmap.Dispose();
|
||||||
|
}
|
||||||
|
_imageCache.Remove(farthestKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_isInErrorState)
|
||||||
|
{
|
||||||
|
_ = RefreshCurrentComponentAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_images.Count <= 1)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isDragging = true;
|
||||||
|
_dragStartPoint = e.GetPosition(this);
|
||||||
|
_dragOffset = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPointerMoved(object? sender, PointerEventArgs e)
|
||||||
|
{
|
||||||
|
if (!_isDragging || _images.Count <= 1)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentPoint = e.GetPosition(this);
|
||||||
|
_dragOffset = currentPoint.Y - _dragStartPoint.Y;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!_isDragging)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isDragging = false;
|
||||||
|
|
||||||
|
if (Math.Abs(_dragOffset) > SwipeThreshold)
|
||||||
|
{
|
||||||
|
if (_dragOffset > 0)
|
||||||
|
{
|
||||||
|
_lastSwipeDirection = 1;
|
||||||
|
SwitchToPrevImage();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_lastSwipeDirection = -1;
|
||||||
|
SwitchToNextImage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPointerWheelChanged(object? sender, PointerWheelEventArgs e)
|
||||||
|
{
|
||||||
|
if (_images.Count <= 1)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.Delta.Y > 0)
|
||||||
|
{
|
||||||
|
_lastSwipeDirection = 1;
|
||||||
|
SwitchToPrevImage();
|
||||||
|
}
|
||||||
|
else if (e.Delta.Y < 0)
|
||||||
|
{
|
||||||
|
_lastSwipeDirection = -1;
|
||||||
|
SwitchToNextImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SwitchToPrevImage()
|
||||||
|
{
|
||||||
|
if (_images.Count <= 1)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentImageIndex = (_currentImageIndex - 1 + _images.Count) % _images.Count;
|
||||||
|
SaveCurrentImageIndex();
|
||||||
|
UpdateIndicators();
|
||||||
|
|
||||||
|
if (TryDisplayCachedImage(_currentImageIndex))
|
||||||
|
{
|
||||||
|
_ = Task.Run(async () => await PreloadAdjacentImagesAsync(_lastSwipeDirection));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = LoadAndDisplayCurrentImageAsync(_lastSwipeDirection);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SwitchToNextImage()
|
||||||
|
{
|
||||||
|
if (_images.Count <= 1)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentImageIndex = (_currentImageIndex + 1) % _images.Count;
|
||||||
|
SaveCurrentImageIndex();
|
||||||
|
UpdateIndicators();
|
||||||
|
|
||||||
|
if (TryDisplayCachedImage(_currentImageIndex))
|
||||||
|
{
|
||||||
|
_ = Task.Run(async () => await PreloadAdjacentImagesAsync(_lastSwipeDirection));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = LoadAndDisplayCurrentImageAsync(_lastSwipeDirection);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryDisplayCachedImage(int index)
|
||||||
|
{
|
||||||
|
if (_images.Count == 0 || index < 0 || index >= _images.Count)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Bitmap? cachedBitmap = null;
|
||||||
|
lock (_cacheLock)
|
||||||
|
{
|
||||||
|
_imageCache.TryGetValue(index, out cachedBitmap);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cachedBitmap != null)
|
||||||
|
{
|
||||||
|
var imageItem = _images[index];
|
||||||
|
CurrentImage.Source = cachedBitmap;
|
||||||
|
ImageNameTextBlock.Text = imageItem.Name;
|
||||||
|
ApplyContentVisibleState();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyLoadingState()
|
||||||
|
{
|
||||||
|
_isInErrorState = false;
|
||||||
|
CurrentImage.IsVisible = false;
|
||||||
|
ImageNameTextBlock.IsVisible = false;
|
||||||
|
GradientOverlay.IsVisible = false;
|
||||||
|
ErrorTextBlock.IsVisible = false;
|
||||||
|
LoadingPanel.IsVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyContentVisibleState()
|
||||||
|
{
|
||||||
|
_isInErrorState = false;
|
||||||
|
LoadingPanel.IsVisible = false;
|
||||||
|
ErrorTextBlock.IsVisible = false;
|
||||||
|
CurrentImage.IsVisible = true;
|
||||||
|
ImageNameTextBlock.IsVisible = true;
|
||||||
|
GradientOverlay.IsVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyErrorState(string message)
|
||||||
|
{
|
||||||
|
_isInErrorState = true;
|
||||||
|
CurrentImage.IsVisible = false;
|
||||||
|
ImageNameTextBlock.IsVisible = false;
|
||||||
|
GradientOverlay.IsVisible = false;
|
||||||
|
LoadingPanel.IsVisible = false;
|
||||||
|
ErrorTextBlock.Text = message + "\n点击任意区域重试";
|
||||||
|
ErrorTextBlock.IsVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateIndicators()
|
||||||
|
{
|
||||||
|
IndicatorPanel.Children.Clear();
|
||||||
|
|
||||||
|
if (_images.Count <= 1)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
startIndex = Math.Max(0, endIndex - maxIndicators);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = startIndex; i < endIndex; i++)
|
||||||
|
{
|
||||||
|
var dot = new Border
|
||||||
|
{
|
||||||
|
Width = 6,
|
||||||
|
Height = 6,
|
||||||
|
CornerRadius = new CornerRadius(3),
|
||||||
|
Margin = new Thickness(2, 0),
|
||||||
|
Background = i == _currentImageIndex
|
||||||
|
? Brushes.White
|
||||||
|
: new SolidColorBrush(Color.FromArgb(128, 255, 255, 255))
|
||||||
|
};
|
||||||
|
|
||||||
|
IndicatorPanel.Children.Add(dot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnRefreshTimerTick(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (_isInitializing)
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1481,6 +1481,15 @@ public partial class MainWindow
|
|||||||
new ComponentScaleRule(WidthUnit: 1, HeightUnit: 1, MinScale: 4));
|
new ComponentScaleRule(WidthUnit: 1, HeightUnit: 1, MinScale: 4));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (string.Equals(componentId, BuiltInComponentIds.DesktopZhiJiaoHub, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// ZhiJiao Hub allows free resize but starts at 2x2
|
||||||
|
// Allow any aspect ratio, minimum 2x2
|
||||||
|
var width = Math.Max(2, span.WidthCells);
|
||||||
|
var height = Math.Max(2, span.HeightCells);
|
||||||
|
return (width, height);
|
||||||
|
}
|
||||||
|
|
||||||
return span;
|
return span;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2602,7 +2611,7 @@ public partial class MainWindow
|
|||||||
|
|
||||||
if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return Symbol.Apps;
|
return Symbol.Hourglass;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.Equals(categoryId, "File", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(categoryId, "File", StringComparison.OrdinalIgnoreCase))
|
||||||
|
|||||||
@@ -44,7 +44,16 @@ public partial class MainWindow
|
|||||||
if (changedKeys.All(key =>
|
if (changedKeys.All(key =>
|
||||||
string.Equals(key, nameof(AppSettingsSnapshot.ThemeColorMode), StringComparison.OrdinalIgnoreCase) ||
|
string.Equals(key, nameof(AppSettingsSnapshot.ThemeColorMode), StringComparison.OrdinalIgnoreCase) ||
|
||||||
string.Equals(key, nameof(AppSettingsSnapshot.SystemMaterialMode), 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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -443,10 +452,13 @@ public partial class MainWindow
|
|||||||
currentVersion = new Version(0, 0, 0);
|
currentVersion = new Version(0, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
var normalizedVersion = new Version(
|
var major = Math.Max(0, currentVersion.Major);
|
||||||
Math.Max(0, currentVersion.Major),
|
var minor = Math.Max(0, currentVersion.Minor);
|
||||||
Math.Max(0, currentVersion.Minor),
|
var build = Math.Max(0, currentVersion.Build >= 0 ? currentVersion.Build : 0);
|
||||||
Math.Max(0, currentVersion.Build));
|
var revision = Math.Max(0, currentVersion.Revision >= 0 ? currentVersion.Revision : 0);
|
||||||
|
var normalizedVersion = revision > 0
|
||||||
|
? new Version(major, minor, build, revision)
|
||||||
|
: new Version(major, minor, build);
|
||||||
|
|
||||||
DispatcherTimer.RunOnce(
|
DispatcherTimer.RunOnce(
|
||||||
async () =>
|
async () =>
|
||||||
@@ -581,6 +593,7 @@ public partial class MainWindow
|
|||||||
var latestUpdateState = _updateSettingsService.Get();
|
var latestUpdateState = _updateSettingsService.Get();
|
||||||
var latestThemeState = _themeSettingsService.Get();
|
var latestThemeState = _themeSettingsService.Get();
|
||||||
var latestPrivacyState = _settingsFacade.Privacy.Get();
|
var latestPrivacyState = _settingsFacade.Privacy.Get();
|
||||||
|
var existingSnapshot = _settingsService.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||||
return new AppSettingsSnapshot
|
return new AppSettingsSnapshot
|
||||||
{
|
{
|
||||||
GridShortSideCells = _targetShortSideCells,
|
GridShortSideCells = _targetShortSideCells,
|
||||||
@@ -632,7 +645,21 @@ public partial class MainWindow
|
|||||||
ClockDisplayFormat = _clockDisplayFormat == ClockDisplayFormat.HourMinute ? "HourMinute" : "HourMinuteSecond",
|
ClockDisplayFormat = _clockDisplayFormat == ClockDisplayFormat.HourMinute ? "HourMinute" : "HourMinuteSecond",
|
||||||
StatusBarClockTransparentBackground = _statusBarClockTransparentBackground,
|
StatusBarClockTransparentBackground = _statusBarClockTransparentBackground,
|
||||||
StatusBarSpacingMode = _statusBarSpacingMode,
|
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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ namespace LanMountainDesktop.Views.SettingsPages;
|
|||||||
"components",
|
"components",
|
||||||
"Components",
|
"Components",
|
||||||
SettingsPageCategory.Components,
|
SettingsPageCategory.Components,
|
||||||
IconKey = "Apps",
|
IconKey = "AppFolder",
|
||||||
SortOrder = 20,
|
SortOrder = 20,
|
||||||
TitleLocalizationKey = "settings.components.title",
|
TitleLocalizationKey = "settings.components.title",
|
||||||
DescriptionLocalizationKey = "settings.components.description")]
|
DescriptionLocalizationKey = "settings.components.description")]
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ namespace LanMountainDesktop.Views.SettingsPages;
|
|||||||
"launcher",
|
"launcher",
|
||||||
"App Launcher",
|
"App Launcher",
|
||||||
SettingsPageCategory.Components,
|
SettingsPageCategory.Components,
|
||||||
IconKey = "Apps",
|
IconKey = "AppsListDetail",
|
||||||
SortOrder = 10,
|
SortOrder = 10,
|
||||||
Scope = SettingsScope.Launcher,
|
Scope = SettingsScope.Launcher,
|
||||||
TitleLocalizationKey = "settings.launcher.title",
|
TitleLocalizationKey = "settings.launcher.title",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ namespace LanMountainDesktop.Views.SettingsPages;
|
|||||||
"status-bar",
|
"status-bar",
|
||||||
"Status Bar",
|
"Status Bar",
|
||||||
SettingsPageCategory.Components,
|
SettingsPageCategory.Components,
|
||||||
IconKey = "Apps",
|
IconKey = "MatchAppLayout",
|
||||||
SortOrder = 15,
|
SortOrder = 15,
|
||||||
TitleLocalizationKey = "settings.status_bar.title",
|
TitleLocalizationKey = "settings.status_bar.title",
|
||||||
DescriptionLocalizationKey = "settings.status_bar.description")]
|
DescriptionLocalizationKey = "settings.status_bar.description")]
|
||||||
|
|||||||
334
LanMountainDesktop/Views/SettingsPages/StudySettingsPage.axaml
Normal file
334
LanMountainDesktop/Views/SettingsPages/StudySettingsPage.axaml
Normal 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>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -722,12 +722,18 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
|
|||||||
"Image" => Symbol.Image,
|
"Image" => Symbol.Image,
|
||||||
"WeatherMoon" => Symbol.WeatherMoon,
|
"WeatherMoon" => Symbol.WeatherMoon,
|
||||||
"Apps" => Symbol.Apps,
|
"Apps" => Symbol.Apps,
|
||||||
|
"AppFolder" => Symbol.AppFolder,
|
||||||
|
"AppsListDetail" => Symbol.AppsListDetail,
|
||||||
|
"MatchAppLayout" => Symbol.MatchAppLayout,
|
||||||
|
"Widget" => Symbol.GridDots,
|
||||||
|
"SwitchApps" => Symbol.ArrowSync,
|
||||||
"GridDots" => Symbol.GridDots,
|
"GridDots" => Symbol.GridDots,
|
||||||
"PuzzlePiece" => Symbol.PuzzlePiece,
|
"PuzzlePiece" => Symbol.PuzzlePiece,
|
||||||
"ShoppingBag" => Symbol.ShoppingBag,
|
"ShoppingBag" => Symbol.ShoppingBag,
|
||||||
"Shield" => Symbol.ShieldDismiss,
|
"Shield" => Symbol.ShieldDismiss,
|
||||||
"Info" => Symbol.Info,
|
"Info" => Symbol.Info,
|
||||||
"ArrowSync" => Symbol.ArrowSync,
|
"ArrowSync" => Symbol.ArrowSync,
|
||||||
|
"Hourglass" => Symbol.Hourglass,
|
||||||
_ => Symbol.Settings
|
_ => Symbol.Settings
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using System.Linq;
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
@@ -537,11 +538,25 @@ public sealed class PluginLoader
|
|||||||
private string ExtractPackage(string packagePath, string pluginsRootDirectory)
|
private string ExtractPackage(string packagePath, string pluginsRootDirectory)
|
||||||
{
|
{
|
||||||
var extractionDirectory = GetPackageExtractionDirectory(pluginsRootDirectory, packagePath);
|
var extractionDirectory = GetPackageExtractionDirectory(pluginsRootDirectory, packagePath);
|
||||||
|
|
||||||
|
// 检查是否可以跳过解压(缓存有效)
|
||||||
|
if (ShouldSkipExtraction(packagePath, extractionDirectory))
|
||||||
|
{
|
||||||
|
AppLogger.Info(
|
||||||
|
"PluginLoader",
|
||||||
|
$"Skipping extraction for '{packagePath}'. Cache is up-to-date.");
|
||||||
|
return extractionDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
AppLogger.Info(
|
AppLogger.Info(
|
||||||
"PluginLoader",
|
"PluginLoader",
|
||||||
$"Extracting package '{packagePath}' to '{extractionDirectory}'.");
|
$"Extracting package '{packagePath}' to '{extractionDirectory}'.");
|
||||||
RecreateDirectory(extractionDirectory);
|
RecreateDirectory(extractionDirectory);
|
||||||
ZipFile.ExtractToDirectory(packagePath, extractionDirectory, overwriteFiles: true);
|
ZipFile.ExtractToDirectory(packagePath, extractionDirectory, overwriteFiles: true);
|
||||||
|
|
||||||
|
// 保存解压元数据用于后续缓存检查
|
||||||
|
SaveExtractionMetadata(packagePath, extractionDirectory);
|
||||||
|
|
||||||
return extractionDirectory;
|
return extractionDirectory;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -608,6 +623,85 @@ public sealed class PluginLoader
|
|||||||
return string.IsNullOrWhiteSpace(builder.ToString()) ? "_plugin" : builder.ToString().Trim();
|
return string.IsNullOrWhiteSpace(builder.ToString()) ? "_plugin" : builder.ToString().Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool ShouldSkipExtraction(string packagePath, string extractionDirectory)
|
||||||
|
{
|
||||||
|
// 如果解压目录不存在,必须解压
|
||||||
|
if (!Directory.Exists(extractionDirectory))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查元数据文件是否存在
|
||||||
|
var metadataPath = Path.Combine(extractionDirectory, ".extraction-metadata.json");
|
||||||
|
if (!File.Exists(metadataPath))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var packageInfo = new FileInfo(packagePath);
|
||||||
|
var metadata = ReadExtractionMetadata(metadataPath);
|
||||||
|
|
||||||
|
// 如果包文件修改时间晚于解压时间,需要重新解压
|
||||||
|
// 同时检查文件大小是否匹配
|
||||||
|
return packageInfo.Length == metadata.PackageSize &&
|
||||||
|
packageInfo.LastWriteTimeUtc <= metadata.ExtractedAt;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn(
|
||||||
|
"PluginLoader",
|
||||||
|
$"Failed to read extraction metadata for '{packagePath}'. Will re-extract.",
|
||||||
|
ex);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SaveExtractionMetadata(string packagePath, string extractionDirectory)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var packageInfo = new FileInfo(packagePath);
|
||||||
|
var metadata = new ExtractionMetadata
|
||||||
|
{
|
||||||
|
PackagePath = Path.GetFullPath(packagePath),
|
||||||
|
ExtractedAt = DateTime.UtcNow,
|
||||||
|
PackageSize = packageInfo.Length,
|
||||||
|
PackageLastWriteTime = packageInfo.LastWriteTimeUtc
|
||||||
|
};
|
||||||
|
|
||||||
|
var metadataPath = Path.Combine(extractionDirectory, ".extraction-metadata.json");
|
||||||
|
var json = JsonSerializer.Serialize(metadata, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = true
|
||||||
|
});
|
||||||
|
File.WriteAllText(metadataPath, json);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn(
|
||||||
|
"PluginLoader",
|
||||||
|
$"Failed to save extraction metadata for '{packagePath}'.",
|
||||||
|
ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExtractionMetadata ReadExtractionMetadata(string metadataPath)
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(metadataPath);
|
||||||
|
return JsonSerializer.Deserialize<ExtractionMetadata>(json)
|
||||||
|
?? throw new InvalidOperationException("Failed to deserialize extraction metadata.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ExtractionMetadata
|
||||||
|
{
|
||||||
|
public string PackagePath { get; set; } = string.Empty;
|
||||||
|
public DateTime ExtractedAt { get; set; }
|
||||||
|
public long PackageSize { get; set; }
|
||||||
|
public DateTime PackageLastWriteTime { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
private static ReadOnlyDictionary<string, object?> CreateReadOnlyProperties(
|
private static ReadOnlyDictionary<string, object?> CreateReadOnlyProperties(
|
||||||
IReadOnlyDictionary<string, object?>? properties)
|
IReadOnlyDictionary<string, object?>? properties)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -497,10 +497,13 @@ internal sealed class AirAppMarketIndexDocument
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
version = new Version(
|
var major = Math.Max(0, parsed.Major);
|
||||||
Math.Max(0, parsed.Major),
|
var minor = Math.Max(0, parsed.Minor);
|
||||||
Math.Max(0, parsed.Minor),
|
var build = Math.Max(0, parsed.Build >= 0 ? parsed.Build : 0);
|
||||||
Math.Max(0, parsed.Build));
|
var revision = Math.Max(0, parsed.Revision >= 0 ? parsed.Revision : 0);
|
||||||
|
version = revision > 0
|
||||||
|
? new Version(major, minor, build, revision)
|
||||||
|
: new Version(major, minor, build);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
440
docs/IMAGE_RECOMMENDATION_COMPONENT_FEASIBILITY.md
Normal file
440
docs/IMAGE_RECOMMENDATION_COMPONENT_FEASIBILITY.md
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
# 图片推荐组件可行性分析报告
|
||||||
|
|
||||||
|
## 需求概述
|
||||||
|
|
||||||
|
开发一个新的**图片推荐组件**,具备以下特性:
|
||||||
|
- 最小尺寸:**2×2 cells**
|
||||||
|
- 支持在组件设置界面**更换图片源**
|
||||||
|
- 独立AXAML文件实现
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 可行性结论
|
||||||
|
|
||||||
|
**高度可行**。项目已具备完整的组件基础设施,包括设置编辑器系统、数据源切换机制。预计开发工作量 **6-10小时**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 现有基础设施分析
|
||||||
|
|
||||||
|
### 1.1 参考实现:DailyArtworkWidget
|
||||||
|
|
||||||
|
`DailyArtworkWidget` 已具备图片展示 + 图片源切换功能,是最佳参考:
|
||||||
|
|
||||||
|
**组件定义** (`ComponentRegistry.cs`):
|
||||||
|
```csharp
|
||||||
|
new DesktopComponentDefinition(
|
||||||
|
BuiltInComponentIds.DesktopDailyArtwork,
|
||||||
|
"Daily Artwork",
|
||||||
|
"Image",
|
||||||
|
"Info",
|
||||||
|
MinWidthCells: 4, // 当前最小4×2
|
||||||
|
MinHeightCells: 2,
|
||||||
|
AllowStatusBarPlacement: false,
|
||||||
|
AllowDesktopPlacement: true)
|
||||||
|
```
|
||||||
|
|
||||||
|
**设置编辑器** (`DailyArtworkComponentEditor.axaml`):
|
||||||
|
```xml
|
||||||
|
<ComboBox x:Name="SourceComboBox" SelectionChanged="OnSourceSelectionChanged">
|
||||||
|
<ComboBoxItem Tag="Domestic" /> <!-- 国内镜像 -->
|
||||||
|
<ComboBoxItem Tag="Overseas" /> <!-- 海外镜像 -->
|
||||||
|
</ComboBox>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 组件设置系统架构
|
||||||
|
|
||||||
|
```
|
||||||
|
用户点击设置
|
||||||
|
↓
|
||||||
|
ComponentEditorWindow 打开
|
||||||
|
↓
|
||||||
|
DesktopComponentEditorRegistry 查找编辑器
|
||||||
|
↓
|
||||||
|
创建对应的 ComponentEditor (如 DailyArtworkComponentEditor)
|
||||||
|
↓
|
||||||
|
编辑器通过 ComponentSettingsAccessor 读写配置
|
||||||
|
↓
|
||||||
|
配置变更通知组件刷新
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键接口**:
|
||||||
|
- `IComponentSettingsContextAware` - 组件接收设置上下文
|
||||||
|
- `ComponentEditorViewBase` - 编辑器基类,提供 `LoadSnapshot()` / `SaveSnapshot()`
|
||||||
|
- `ComponentSettingsSnapshot` - 统一配置存储模型
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 技术实现方案
|
||||||
|
|
||||||
|
### 2.1 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
LanMountainDesktop/
|
||||||
|
├── ComponentSystem/
|
||||||
|
│ ├── BuiltInComponentIds.cs # 添加组件ID常量
|
||||||
|
│ └── ComponentRegistry.cs # 注册组件定义
|
||||||
|
├── Views/
|
||||||
|
│ ├── Components/
|
||||||
|
│ │ ├── ImageRecommendationWidget.axaml # 新组件UI
|
||||||
|
│ │ ├── ImageRecommendationWidget.axaml.cs # 新组件逻辑
|
||||||
|
│ │ └── DesktopComponentRuntimeRegistry.cs # 注册运行时
|
||||||
|
│ └── ComponentEditors/
|
||||||
|
│ ├── ImageRecommendationComponentEditor.axaml # 设置编辑器UI
|
||||||
|
│ ├── ImageRecommendationComponentEditor.axaml.cs # 设置编辑器逻辑
|
||||||
|
│ └── DesktopComponentEditorRegistryFactory.cs # 注册编辑器
|
||||||
|
├── Services/
|
||||||
|
│ ├── IRecommendationDataService.cs # 添加查询接口
|
||||||
|
│ └── RecommendationDataService.cs # 实现数据获取
|
||||||
|
└── Models/
|
||||||
|
└── ComponentSettingsSnapshot.cs # 添加配置字段
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 组件定义 (2×2最小尺寸)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// BuiltInComponentIds.cs
|
||||||
|
public const string DesktopImageRecommendation = "DesktopImageRecommendation";
|
||||||
|
|
||||||
|
// ComponentRegistry.cs
|
||||||
|
new DesktopComponentDefinition(
|
||||||
|
BuiltInComponentIds.DesktopImageRecommendation,
|
||||||
|
"Image Recommendation",
|
||||||
|
"Image",
|
||||||
|
"Info",
|
||||||
|
MinWidthCells: 2, // 最小2×2
|
||||||
|
MinHeightCells: 2,
|
||||||
|
AllowStatusBarPlacement: false,
|
||||||
|
AllowDesktopPlacement: true,
|
||||||
|
ResizeMode: DesktopComponentResizeMode.Proportional) // 保持比例
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 数据源配置设计
|
||||||
|
|
||||||
|
**配置模型** (`ComponentSettingsSnapshot.cs`):
|
||||||
|
```csharp
|
||||||
|
public sealed class ComponentSettingsSnapshot
|
||||||
|
{
|
||||||
|
// 现有字段...
|
||||||
|
|
||||||
|
// 新增:图片推荐组件配置
|
||||||
|
public string ImageRecommendationSource { get; set; } = ImageRecommendationSources.Bing;
|
||||||
|
public bool ImageRecommendationAutoRefreshEnabled { get; set; } = true;
|
||||||
|
public int ImageRecommendationAutoRefreshIntervalMinutes { get; set; } = 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ImageRecommendationSources
|
||||||
|
{
|
||||||
|
public const string Bing = "bing"; // Bing每日图片
|
||||||
|
public const string Picsum = "picsum"; // Picsum随机图片
|
||||||
|
public const string Unsplash = "unsplash"; // Unsplash精选
|
||||||
|
|
||||||
|
public static string Normalize(string? value) => value?.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"picsum" => Picsum,
|
||||||
|
"unsplash" => Unsplash,
|
||||||
|
_ => Bing
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 Widget实现要点
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// ImageRecommendationWidget.axaml.cs
|
||||||
|
public partial class ImageRecommendationWidget : UserControl,
|
||||||
|
IDesktopComponentWidget,
|
||||||
|
IRecommendationInfoAwareComponentWidget,
|
||||||
|
IComponentSettingsContextAware, // 接收设置变更
|
||||||
|
IComponentPlacementContextAware
|
||||||
|
{
|
||||||
|
private string _imageSource = ImageRecommendationSources.Bing;
|
||||||
|
|
||||||
|
public void SetComponentSettingsContext(DesktopComponentSettingsContext context)
|
||||||
|
{
|
||||||
|
// 读取组件实例配置
|
||||||
|
var snapshot = context.ComponentSettingsAccessor
|
||||||
|
.LoadSnapshot<ComponentSettingsSnapshot>();
|
||||||
|
_imageSource = ImageRecommendationSources.Normalize(
|
||||||
|
snapshot?.ImageRecommendationSource);
|
||||||
|
|
||||||
|
// 刷新图片
|
||||||
|
_ = RefreshImageAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RefreshImageAsync()
|
||||||
|
{
|
||||||
|
var query = new ImageRecommendationQuery
|
||||||
|
{
|
||||||
|
Source = _imageSource
|
||||||
|
};
|
||||||
|
var result = await _recommendationService
|
||||||
|
.GetImageRecommendationAsync(query);
|
||||||
|
|
||||||
|
if (result.Success && result.Data is not null)
|
||||||
|
{
|
||||||
|
await LoadImageAsync(result.Data.ImageUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.5 设置编辑器实现
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- ImageRecommendationComponentEditor.axaml -->
|
||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
x:Class="LanMountainDesktop.Views.ComponentEditors.ImageRecommendationComponentEditor">
|
||||||
|
<StackPanel Spacing="16">
|
||||||
|
<!-- 图片源选择 -->
|
||||||
|
<Border Classes="component-editor-card" Padding="20">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock x:Name="SourceLabelTextBlock"
|
||||||
|
Classes="component-editor-section-title" />
|
||||||
|
<ComboBox x:Name="SourceComboBox"
|
||||||
|
Classes="component-editor-select"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
SelectionChanged="OnSourceSelectionChanged">
|
||||||
|
<ComboBoxItem x:Name="BingItem" Tag="bing" />
|
||||||
|
<ComboBoxItem x:Name="PicsumItem" Tag="picsum" />
|
||||||
|
<ComboBoxItem x:Name="UnsplashItem" Tag="unsplash" />
|
||||||
|
</ComboBox>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- 自动刷新设置 -->
|
||||||
|
<Border Classes="component-editor-card" Padding="20">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<ToggleSwitch x:Name="AutoRefreshToggle"
|
||||||
|
Toggled="OnAutoRefreshToggled" />
|
||||||
|
<NumericUpDown x:Name="IntervalNumeric"
|
||||||
|
Minimum="5"
|
||||||
|
Maximum="1440"
|
||||||
|
ValueChanged="OnIntervalChanged" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</UserControl>
|
||||||
|
```
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// ImageRecommendationComponentEditor.axaml.cs
|
||||||
|
public partial class ImageRecommendationComponentEditor : ComponentEditorViewBase
|
||||||
|
{
|
||||||
|
public ImageRecommendationComponentEditor(DesktopComponentEditorContext? context)
|
||||||
|
: base(context)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
ApplyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyState()
|
||||||
|
{
|
||||||
|
// 本地化
|
||||||
|
SourceLabelTextBlock.Text = L("imgrec.settings.source", "Image Source");
|
||||||
|
BingItem.Content = L("imgrec.settings.bing", "Bing Daily");
|
||||||
|
PicsumItem.Content = L("imgrec.settings.picsum", "Random (Picsum)");
|
||||||
|
UnsplashItem.Content = L("imgrec.settings.unsplash", "Unsplash");
|
||||||
|
|
||||||
|
// 加载当前配置
|
||||||
|
var snapshot = LoadSnapshot();
|
||||||
|
var source = ImageRecommendationSources.Normalize(snapshot.ImageRecommendationSource);
|
||||||
|
SourceComboBox.SelectedItem = source switch
|
||||||
|
{
|
||||||
|
ImageRecommendationSources.Picsum => PicsumItem,
|
||||||
|
ImageRecommendationSources.Unsplash => UnsplashItem,
|
||||||
|
_ => BingItem
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSourceSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_suppressEvents) return;
|
||||||
|
|
||||||
|
var source = SourceComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tag
|
||||||
|
? ImageRecommendationSources.Normalize(tag)
|
||||||
|
: ImageRecommendationSources.Bing;
|
||||||
|
|
||||||
|
var snapshot = LoadSnapshot();
|
||||||
|
snapshot.ImageRecommendationSource = source;
|
||||||
|
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ImageRecommendationSource));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.6 数据服务扩展
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// IRecommendationDataService.cs
|
||||||
|
public sealed record ImageRecommendationQuery(
|
||||||
|
string? Source = null,
|
||||||
|
bool ForceRefresh = false);
|
||||||
|
|
||||||
|
public sealed record ImageRecommendationSnapshot(
|
||||||
|
string ImageUrl,
|
||||||
|
string? Title = null,
|
||||||
|
string? Description = null,
|
||||||
|
string? SourceName = null);
|
||||||
|
|
||||||
|
public interface IRecommendationInfoService
|
||||||
|
{
|
||||||
|
// 现有方法...
|
||||||
|
|
||||||
|
Task<RecommendationQueryResult<ImageRecommendationSnapshot>> GetImageRecommendationAsync(
|
||||||
|
ImageRecommendationQuery query,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// RecommendationDataService.cs
|
||||||
|
public async Task<RecommendationQueryResult<ImageRecommendationSnapshot>> GetImageRecommendationAsync(
|
||||||
|
ImageRecommendationQuery query,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var source = ImageRecommendationSources.Normalize(query?.Source);
|
||||||
|
|
||||||
|
return source switch
|
||||||
|
{
|
||||||
|
ImageRecommendationSources.Picsum => await GetPicsumImageAsync(query, cancellationToken),
|
||||||
|
ImageRecommendationSources.Unsplash => await GetUnsplashImageAsync(query, cancellationToken),
|
||||||
|
_ => await GetBingImageAsync(query, cancellationToken)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<RecommendationQueryResult<ImageRecommendationSnapshot>> GetBingImageAsync(
|
||||||
|
ImageRecommendationQuery? query,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Bing每日图片API
|
||||||
|
var url = "https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1&mkt=zh-CN";
|
||||||
|
// ... 解析返回获取图片URL
|
||||||
|
var imageUrl = $"https://cn.bing.com{imageData.Url}";
|
||||||
|
return RecommendationQueryResult<ImageRecommendationSnapshot>.Ok(
|
||||||
|
new ImageRecommendationSnapshot(imageUrl, imageData.Title, imageData.Copyright));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 2×2尺寸适配考虑
|
||||||
|
|
||||||
|
### 3.1 布局适配策略
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// ImageRecommendationWidget.axaml.cs
|
||||||
|
private void ApplyCellSize(double cellSize)
|
||||||
|
{
|
||||||
|
_currentCellSize = Math.Max(1, cellSize);
|
||||||
|
var scale = _currentCellSize / BaseCellSize;
|
||||||
|
|
||||||
|
// 2×2尺寸较小,需要调整字体和间距
|
||||||
|
var isSmallSize = _currentCellSize * 2 < 120; // 小于120px视为小尺寸
|
||||||
|
|
||||||
|
if (isSmallSize)
|
||||||
|
{
|
||||||
|
// 小尺寸模式:简化UI,只显示图片
|
||||||
|
TitleTextBlock.IsVisible = false;
|
||||||
|
DescriptionTextBlock.IsVisible = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 正常模式:显示图片+文字
|
||||||
|
TitleTextBlock.IsVisible = true;
|
||||||
|
TitleTextBlock.FontSize = Math.Clamp(16 * scale, 10, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 圆角随尺寸缩放
|
||||||
|
RootBorder.CornerRadius = new CornerRadius(12 * scale);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 比例约束
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// MainWindow.ComponentSystem.cs 添加比例约束
|
||||||
|
if (string.Equals(componentId, BuiltInComponentIds.DesktopImageRecommendation, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// 保持1:1比例(正方形),最小2×2
|
||||||
|
return SnapSpanToScaleRules(
|
||||||
|
span,
|
||||||
|
new ComponentScaleRule(WidthUnit: 1, HeightUnit: 1, MinScale: 2));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 开发工作量估算
|
||||||
|
|
||||||
|
| 任务 | 文件 | 预估工时 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 添加组件ID | `BuiltInComponentIds.cs` | 5分钟 |
|
||||||
|
| 注册组件定义 | `ComponentRegistry.cs` | 10分钟 |
|
||||||
|
| 实现Widget UI | `ImageRecommendationWidget.axaml` | 1.5小时 |
|
||||||
|
| 实现Widget逻辑 | `ImageRecommendationWidget.axaml.cs` | 2小时 |
|
||||||
|
| 注册运行时 | `DesktopComponentRuntimeRegistry.cs` | 10分钟 |
|
||||||
|
| 实现设置编辑器UI | `ImageRecommendationComponentEditor.axaml` | 1小时 |
|
||||||
|
| 实现设置编辑器逻辑 | `ImageRecommendationComponentEditor.axaml.cs` | 1小时 |
|
||||||
|
| 注册编辑器 | `DesktopComponentEditorRegistryFactory.cs` | 15分钟 |
|
||||||
|
| 扩展数据服务接口 | `IRecommendationDataService.cs` | 15分钟 |
|
||||||
|
| 实现数据获取 | `RecommendationDataService.cs` | 1.5小时 |
|
||||||
|
| 添加配置字段 | `ComponentSettingsSnapshot.cs` | 15分钟 |
|
||||||
|
| 添加比例约束 | `MainWindow.ComponentSystem.cs` | 15分钟 |
|
||||||
|
| 添加本地化 | `Resources.resx` | 30分钟 |
|
||||||
|
| **总计** | | **8-10小时** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 风险与缓解
|
||||||
|
|
||||||
|
| 风险 | 等级 | 缓解措施 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 2×2尺寸下UI过于拥挤 | 中 | 实现响应式布局,小尺寸隐藏文字 |
|
||||||
|
| 图片源API不稳定 | 低 | 多源备选,本地缓存 |
|
||||||
|
| 图片加载慢影响体验 | 低 | 异步加载,占位图过渡 |
|
||||||
|
| 跨域问题 | 低 | 使用支持CORS的源或后端代理 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 建议图片源
|
||||||
|
|
||||||
|
| 源 | URL示例 | 特点 |
|
||||||
|
|----|---------|------|
|
||||||
|
| **Bing每日图片** | `https://cn.bing.com/HPImageArchive.aspx` | 高质量,每日更新 |
|
||||||
|
| **Picsum** | `https://picsum.photos/400/400` | 随机图片,稳定快速 |
|
||||||
|
| **Unsplash Source** | `https://source.unsplash.com/400x400` | 精选摄影,高质量 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 结论
|
||||||
|
|
||||||
|
### 7.1 可行性评级: **A级 (强烈推荐)**
|
||||||
|
|
||||||
|
| 维度 | 评分 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 技术成熟度 | ★★★★★ | DailyArtworkWidget提供完整参考 |
|
||||||
|
| 开发成本 | ★★★★★ | 8-10小时,模式清晰 |
|
||||||
|
| 2×2适配 | ★★★★☆ | 需响应式布局适配小尺寸 |
|
||||||
|
| 用户价值 | ★★★★★ | 图片组件是桌面美化核心需求 |
|
||||||
|
|
||||||
|
### 7.2 下一步行动
|
||||||
|
|
||||||
|
1. **确认图片源**:选择1-3个稳定的图片API
|
||||||
|
2. **UI设计**:确认2×2尺寸下的视觉呈现
|
||||||
|
3. **开发**:按文件清单逐项实现
|
||||||
|
4. **测试**:验证不同尺寸、不同数据源切换
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录: 关键代码参考
|
||||||
|
|
||||||
|
### DailyArtworkWidget (现有参考)
|
||||||
|
- `Views/Components/DailyArtworkWidget.axaml`
|
||||||
|
- `Views/Components/DailyArtworkWidget.axaml.cs`
|
||||||
|
|
||||||
|
### DailyArtworkComponentEditor (设置编辑器参考)
|
||||||
|
- `Views/ComponentEditors/DailyArtworkComponentEditor.axaml`
|
||||||
|
- `Views/ComponentEditors/DailyArtworkComponentEditor.axaml.cs`
|
||||||
|
|
||||||
|
### 组件注册 (参考模式)
|
||||||
|
- `Services/DesktopComponentEditorRegistryFactory.cs` 第69-71行
|
||||||
248
docs/INFO_RECOMMENDATION_COMPONENT_FEASIBILITY.md
Normal file
248
docs/INFO_RECOMMENDATION_COMPONENT_FEASIBILITY.md
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
# 信息推荐类组件引入可行性分析报告
|
||||||
|
|
||||||
|
## 执行摘要
|
||||||
|
|
||||||
|
**结论:高度可行**。阑山桌面已具备完善的信息推荐类组件基础设施,引入新组件的技术门槛低,开发成本可控。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 现有基础设施评估
|
||||||
|
|
||||||
|
### 1.1 组件系统架构
|
||||||
|
|
||||||
|
项目采用**分层组件架构**,信息推荐类组件属于 `Info` 分类:
|
||||||
|
|
||||||
|
```
|
||||||
|
LanMountainDesktop/ComponentSystem/
|
||||||
|
├── DesktopComponentDefinition.cs # 组件元数据定义
|
||||||
|
├── ComponentRegistry.cs # 组件注册中心
|
||||||
|
├── BuiltInComponentIds.cs # 内置组件ID常量
|
||||||
|
└── Extensions/ # 扩展组件支持
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 现有信息推荐类组件清单
|
||||||
|
|
||||||
|
| 组件ID | 名称 | 分类 | 尺寸 | 数据源 |
|
||||||
|
|--------|------|------|------|--------|
|
||||||
|
| `DesktopDailyPoetry` | 每日诗词 | Info | 4x2 | jinrishici.com |
|
||||||
|
| `DesktopDailyArtwork` | 每日画作 | Info | 4x2 | Art Institute API |
|
||||||
|
| `DesktopDailyWord` | 每日单词 | Info | 4x2 | Youdao API |
|
||||||
|
| `DesktopDailyWord2x2` | 每日单词(小) | Info | 2x2 | Youdao API |
|
||||||
|
| `DesktopCnrDailyNews` | 央广新闻 | Info | 4x2 | CNR RSS |
|
||||||
|
| `DesktopIfengNews` | 凤凰新闻 | Info | 4x4 | 凤凰网 |
|
||||||
|
| `DesktopJuyaNews` | 橘鸦早报 | Info | 4x4 | 橘鸦API |
|
||||||
|
| `DesktopBilibiliHotSearch` | B站热搜 | Info | 4x2 | Bilibili API |
|
||||||
|
| `DesktopBaiduHotSearch` | 百度热搜 | Info | 4x2 | 百度API |
|
||||||
|
| `DesktopStcn24Forum` | STCN论坛 | Info | 4x4 | SmartTeach Forum |
|
||||||
|
|
||||||
|
**分析**:已有10个信息推荐类组件,覆盖新闻、诗词、艺术、单词、热搜等类型,证明该类别组件需求旺盛且技术路径成熟。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 技术实现路径
|
||||||
|
|
||||||
|
### 2.1 数据服务层
|
||||||
|
|
||||||
|
**位置**: `LanMountainDesktop/Services/IRecommendationDataService.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public interface IRecommendationInfoService
|
||||||
|
{
|
||||||
|
Task<RecommendationQueryResult<T>> GetXXXAsync(XXXQuery query, CancellationToken ct);
|
||||||
|
void ClearCache();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**已有能力**:
|
||||||
|
- 统一的查询/结果模式 (`RecommendationQueryResult<T>`)
|
||||||
|
- 缓存机制 (按渠道/类型分桶缓存)
|
||||||
|
- 超时控制 (默认8秒)
|
||||||
|
- 错误处理标准化
|
||||||
|
|
||||||
|
### 2.2 组件实现层
|
||||||
|
|
||||||
|
**位置**: `LanMountainDesktop/Views/Components/`
|
||||||
|
|
||||||
|
**标准实现模式**:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public partial class XXXWidget : UserControl,
|
||||||
|
IDesktopComponentWidget, // 基础组件接口
|
||||||
|
IRecommendationInfoAwareComponentWidget // 推荐信息感知接口
|
||||||
|
{
|
||||||
|
private readonly IRecommendationInfoService _recommendationService;
|
||||||
|
private readonly DispatcherTimer _refreshTimer;
|
||||||
|
|
||||||
|
// 标准生命周期
|
||||||
|
// - 附加到视觉树时启动刷新
|
||||||
|
// - 分离时清理资源
|
||||||
|
// - 支持自动刷新配置
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 注册与集成
|
||||||
|
|
||||||
|
**步骤1**: 在 `BuiltInComponentIds.cs` 添加ID常量
|
||||||
|
```csharp
|
||||||
|
public const string DesktopNewInfoComponent = "DesktopNewInfoComponent";
|
||||||
|
```
|
||||||
|
|
||||||
|
**步骤2**: 在 `ComponentRegistry.cs` 注册元数据
|
||||||
|
```csharp
|
||||||
|
new DesktopComponentDefinition(
|
||||||
|
BuiltInComponentIds.DesktopNewInfoComponent,
|
||||||
|
"New Info Component",
|
||||||
|
"IconKey",
|
||||||
|
"Info", // 分类
|
||||||
|
MinWidthCells: 4,
|
||||||
|
MinHeightCells: 2,
|
||||||
|
AllowStatusBarPlacement: false,
|
||||||
|
AllowDesktopPlacement: true)
|
||||||
|
```
|
||||||
|
|
||||||
|
**步骤3**: 在 `DesktopComponentRuntimeRegistry.cs` 注册运行时
|
||||||
|
```csharp
|
||||||
|
new DesktopComponentRuntimeRegistration(
|
||||||
|
BuiltInComponentIds.DesktopNewInfoComponent,
|
||||||
|
"NewInfoComponent_DisplayName",
|
||||||
|
ctx => new NewInfoComponentWidget())
|
||||||
|
```
|
||||||
|
|
||||||
|
**步骤4**: 实现数据服务方法 (可选,如使用现有服务可跳过)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 开发工作量估算
|
||||||
|
|
||||||
|
### 3.1 最小可行实现 (MVP)
|
||||||
|
|
||||||
|
| 任务 | 文件 | 预估工时 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 添加组件ID | `BuiltInComponentIds.cs` | 5分钟 |
|
||||||
|
| 注册组件定义 | `ComponentRegistry.cs` | 10分钟 |
|
||||||
|
| 注册运行时 | `DesktopComponentRuntimeRegistry.cs` | 10分钟 |
|
||||||
|
| 实现Widget | `Views/Components/NewInfoWidget.axaml` | 2-4小时 |
|
||||||
|
| 实现数据服务方法 | `RecommendationDataService.cs` | 1-2小时 |
|
||||||
|
| 添加本地化 | `Localization/Resources.resx` | 15分钟 |
|
||||||
|
| **总计** | | **4-8小时** |
|
||||||
|
|
||||||
|
### 3.2 参考实现
|
||||||
|
|
||||||
|
**简单组件** (如 `BaiduHotSearchWidget`): ~200行代码
|
||||||
|
**复杂组件** (如 `IfengNewsWidget`): ~600行代码
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 扩展性评估
|
||||||
|
|
||||||
|
### 4.1 数据源扩展
|
||||||
|
|
||||||
|
**支持的接入方式**:
|
||||||
|
1. **REST API** (如 Bilibili API)
|
||||||
|
2. **RSS Feed** (如 CNR RSS)
|
||||||
|
3. **网页抓取** (如凤凰网)
|
||||||
|
4. **第三方SDK** (可扩展)
|
||||||
|
|
||||||
|
**配置化选项** (`RecommendationApiOptions`):
|
||||||
|
```csharp
|
||||||
|
public sealed record RecommendationApiOptions
|
||||||
|
{
|
||||||
|
public string NewDataSourceUrl { get; init; }
|
||||||
|
public TimeSpan CacheDuration { get; init; } = TimeSpan.FromMinutes(20);
|
||||||
|
public TimeSpan RequestTimeout { get; init; } = TimeSpan.FromSeconds(8);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 组件模板化
|
||||||
|
|
||||||
|
现有组件可按功能类型抽象模板:
|
||||||
|
|
||||||
|
| 模板类型 | 代表组件 | 特点 |
|
||||||
|
|----------|----------|------|
|
||||||
|
| 列表型 | IfengNews, BilibiliHotSearch | 滚动列表,支持点击跳转 |
|
||||||
|
| 卡片型 | DailyPoetry, DailyWord | 单条内容展示 |
|
||||||
|
| 画廊型 | DailyArtwork | 图片为主,支持缩放 |
|
||||||
|
| 混合型 | JuyaNews | 图文混排 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 风险与缓解措施
|
||||||
|
|
||||||
|
### 5.1 技术风险
|
||||||
|
|
||||||
|
| 风险 | 等级 | 缓解措施 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 数据源不稳定 | 中 | 实现本地缓存 + 降级显示 |
|
||||||
|
| API限流 | 低 | 统一请求间隔控制 (已存在) |
|
||||||
|
| 跨域问题 | 低 | 使用后端代理或CORS支持API |
|
||||||
|
|
||||||
|
### 5.2 维护风险
|
||||||
|
|
||||||
|
| 风险 | 等级 | 缓解措施 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 数据源API变更 | 中 | 抽象数据适配层,隔离变化 |
|
||||||
|
| 组件数量膨胀 | 低 | 考虑插件化迁移 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 建议方案
|
||||||
|
|
||||||
|
### 6.1 短期方案 (推荐)
|
||||||
|
|
||||||
|
**直接添加内置组件**,遵循现有模式:
|
||||||
|
|
||||||
|
```
|
||||||
|
优点:
|
||||||
|
- 开发成本低 (4-8小时/组件)
|
||||||
|
- 与现有系统无缝集成
|
||||||
|
- 用户体验一致
|
||||||
|
|
||||||
|
适用场景:
|
||||||
|
- 核心信息源 (如官方新闻、学习资源)
|
||||||
|
- 高频使用组件
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 长期方案
|
||||||
|
|
||||||
|
**信息推荐组件插件化**:
|
||||||
|
|
||||||
|
```
|
||||||
|
优点:
|
||||||
|
- 数据源可热插拔
|
||||||
|
- 社区可贡献组件
|
||||||
|
- 减小主程序体积
|
||||||
|
|
||||||
|
实现路径:
|
||||||
|
1. 定义信息推荐组件SDK接口
|
||||||
|
2. 提供组件模板脚手架
|
||||||
|
3. 市场发布审核流程
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 结论
|
||||||
|
|
||||||
|
### 7.1 可行性评级: **A级 (强烈推荐)**
|
||||||
|
|
||||||
|
| 维度 | 评分 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 技术成熟度 | ★★★★★ | 已有10个同类组件,模式稳定 |
|
||||||
|
| 开发成本 | ★★★★★ | 4-8小时/组件,成本低 |
|
||||||
|
| 维护成本 | ★★★★☆ | 依赖外部API需持续维护 |
|
||||||
|
| 用户价值 | ★★★★★ | 信息类组件是桌面核心场景 |
|
||||||
|
| 扩展性 | ★★★★★ | 架构支持多种数据源 |
|
||||||
|
|
||||||
|
### 7.2 行动建议
|
||||||
|
|
||||||
|
1. **立即行动**: 选择1-2个高价值信息源进行试点开发
|
||||||
|
2. **建立规范**: 制定信息推荐组件开发SOP
|
||||||
|
3. **考虑插件化**: 当组件数量超过15个时评估插件化方案
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录: 参考文档
|
||||||
|
|
||||||
|
- `docs/ARCHITECTURE.md` - 系统架构概述
|
||||||
|
- `docs/ECOSYSTEM_BOUNDARIES.md` - 生态边界定义
|
||||||
|
- `LanMountainDesktop/ComponentSystem/README.md` - 组件系统说明
|
||||||
|
- `LanMountainDesktop/Services/IRecommendationDataService.cs` - 数据服务接口
|
||||||
128
docs/ZHIJIAO_HUB_COMPONENT_FINAL.md
Normal file
128
docs/ZHIJIAO_HUB_COMPONENT_FINAL.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# 智教Hub组件 - 最终实现总结
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
### 核心功能
|
||||||
|
- ✅ **最小尺寸 2×2** - 符合要求
|
||||||
|
- ✅ **自由缩放** - ResizeMode.Free,允许任意调整大小
|
||||||
|
- ✅ **双数据源** - ClassIsland Hub 和 SECTL Hub
|
||||||
|
- ✅ **上下滑动切换** - 像短视频一样的交互体验
|
||||||
|
- ✅ **鼠标滚轮支持** - 滚轮上下滚动切换图片
|
||||||
|
- ✅ **图片名称显示** - 左下角显示当前图片名称
|
||||||
|
- ✅ **自动刷新** - 可配置间隔,可开启/关闭
|
||||||
|
- ✅ **设置面板** - 数据源切换、自动刷新配置
|
||||||
|
|
||||||
|
### 交互方式
|
||||||
|
1. **触摸/鼠标拖动**: 上下拖动超过50px切换图片
|
||||||
|
2. **鼠标滚轮**: 滚轮上下滚动切换图片
|
||||||
|
3. **自动刷新**: 定时刷新图片列表
|
||||||
|
|
||||||
|
## 技术实现
|
||||||
|
|
||||||
|
### 文件清单
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `Models/ComponentSettingsSnapshot.cs` | 配置字段 + ZhiJiaoHubSources常量 |
|
||||||
|
| `Services/IRecommendationDataService.cs` | 数据接口和类型定义 |
|
||||||
|
| `Services/RecommendationDataService.cs` | GitHub API数据获取实现 |
|
||||||
|
| `Views/Components/ZhiJiaoHubWidget.axaml` | 组件UI布局 |
|
||||||
|
| `Views/Components/ZhiJiaoHubWidget.axaml.cs` | 组件逻辑(滑动交互) |
|
||||||
|
| `Views/ComponentEditors/ZhiJiaoHubComponentEditor.axaml` | 设置编辑器UI |
|
||||||
|
| `Views/ComponentEditors/ZhiJiaoHubComponentEditor.axaml.cs` | 设置编辑器逻辑 |
|
||||||
|
| `ComponentSystem/BuiltInComponentIds.cs` | 组件ID常量 |
|
||||||
|
| `ComponentSystem/ComponentRegistry.cs` | 组件注册 |
|
||||||
|
| `Views/Components/DesktopComponentRuntimeRegistry.cs` | 运行时注册 |
|
||||||
|
| `Services/DesktopComponentEditorRegistryFactory.cs` | 编辑器注册 |
|
||||||
|
| `Views/MainWindow.ComponentSystem.cs` | 比例约束 |
|
||||||
|
|
||||||
|
### 滑动交互实现
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// 核心滑动逻辑
|
||||||
|
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
_isDragging = true;
|
||||||
|
_dragStartPoint = e.GetPosition(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPointerMoved(object? sender, PointerEventArgs e)
|
||||||
|
{
|
||||||
|
if (!_isDragging) return;
|
||||||
|
var currentPoint = e.GetPosition(this);
|
||||||
|
_dragOffset = currentPoint.Y - _dragStartPoint.Y;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!_isDragging) return;
|
||||||
|
_isDragging = false;
|
||||||
|
|
||||||
|
// 超过阈值切换图片
|
||||||
|
if (Math.Abs(_dragOffset) > SwipeThreshold)
|
||||||
|
{
|
||||||
|
if (_dragOffset > 0) SwitchToPrevImage(); // 向下滑动
|
||||||
|
else SwitchToNextImage(); // 向上滑动
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据源
|
||||||
|
|
||||||
|
| 源 | API地址 | 图片数量 |
|
||||||
|
|----|---------|----------|
|
||||||
|
| ClassIsland Hub | api.github.com/repos/ClassIsland/classisland-hub/contents/images | ~70张 |
|
||||||
|
| SECTL Hub | api.github.com/repos/SECTL/SECTL-hub/contents/docs/.vuepress/public/images | ~78张 |
|
||||||
|
|
||||||
|
### 缓存策略
|
||||||
|
- 图片列表缓存:1小时
|
||||||
|
- 图片缓存:最多5张(当前+前后各1张)
|
||||||
|
- 预加载:自动加载相邻图片
|
||||||
|
|
||||||
|
## 设置选项
|
||||||
|
|
||||||
|
### 数据源选择
|
||||||
|
- ClassIsland Hub(默认)
|
||||||
|
- SECTL Hub
|
||||||
|
|
||||||
|
### 自动刷新
|
||||||
|
- 开关:开启/关闭
|
||||||
|
- 间隔:5-1440分钟(默认30分钟)
|
||||||
|
|
||||||
|
## 构建状态
|
||||||
|
|
||||||
|
✅ **构建成功** - 无错误
|
||||||
|
|
||||||
|
```
|
||||||
|
23 个警告(与本次修改无关)
|
||||||
|
0 个错误
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用说明
|
||||||
|
|
||||||
|
### 添加组件
|
||||||
|
1. 进入桌面编辑模式
|
||||||
|
2. 从组件库选择 "ZhiJiao Hub"
|
||||||
|
3. 最小2×2,可自由调整大小
|
||||||
|
|
||||||
|
### 浏览图片
|
||||||
|
- **上下滑动**:像短视频一样切换图片
|
||||||
|
- **鼠标滚轮**:滚动切换
|
||||||
|
- **指示器**:右侧显示当前位置
|
||||||
|
|
||||||
|
### 切换数据源
|
||||||
|
1. 选中组件,点击设置按钮
|
||||||
|
2. 选择 "Image Source"
|
||||||
|
3. 选择 ClassIsland 或 SECTL
|
||||||
|
|
||||||
|
### 配置自动刷新
|
||||||
|
1. 在设置面板中开关 "Auto Refresh"
|
||||||
|
2. 设置刷新间隔(分钟)
|
||||||
|
|
||||||
|
## 后续优化建议
|
||||||
|
|
||||||
|
1. **动画效果**: 添加滑动时的图片过渡动画
|
||||||
|
2. **本地缓存**: 持久化图片到本地磁盘
|
||||||
|
3. **收藏功能**: 允许用户收藏喜欢的图片
|
||||||
|
4. **分享功能**: 分享图片链接
|
||||||
|
5. **更多源**: 添加更多教育技术社区图片源
|
||||||
161
docs/ZHIJIAO_HUB_COMPONENT_SUMMARY.md
Normal file
161
docs/ZHIJIAO_HUB_COMPONENT_SUMMARY.md
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# 智教Hub组件实现总结
|
||||||
|
|
||||||
|
## 组件概述
|
||||||
|
|
||||||
|
智教Hub组件是一个图片展示组件,从两个GitHub仓库获取社区图片:
|
||||||
|
- **ClassIsland Hub**: https://github.com/ClassIsland/classisland-hub
|
||||||
|
- **SECTL Hub**: https://github.com/SECTL/SECTL-hub
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- ✅ 最小尺寸 2×2 cells
|
||||||
|
- ✅ 允许自由调整大小 (ResizeMode.Free)
|
||||||
|
- ✅ 支持两个数据源切换
|
||||||
|
- ✅ 自动刷新功能(可配置间隔)
|
||||||
|
- ✅ 图片左右导航
|
||||||
|
- ✅ 左下角显示图片名称
|
||||||
|
- ✅ 悬停显示导航按钮和指示器
|
||||||
|
|
||||||
|
## 文件清单
|
||||||
|
|
||||||
|
### 1. 数据模型和配置
|
||||||
|
- `LanMountainDesktop/Models/ComponentSettingsSnapshot.cs`
|
||||||
|
- 添加智教Hub配置字段
|
||||||
|
- 添加 `ZhiJiaoHubSources` 常量类
|
||||||
|
|
||||||
|
### 2. 数据服务
|
||||||
|
- `LanMountainDesktop/Services/IRecommendationDataService.cs`
|
||||||
|
- 添加 `ZhiJiaoHubQuery`, `ZhiJiaoHubImageItem`, `ZhiJiaoHubSnapshot` 类型
|
||||||
|
- 添加 `GetZhiJiaoHubImagesAsync` 接口方法
|
||||||
|
- 添加 GitHub API URL 配置
|
||||||
|
|
||||||
|
- `LanMountainDesktop/Services/RecommendationDataService.cs`
|
||||||
|
- 实现 `GetZhiJiaoHubImagesAsync` 方法
|
||||||
|
- 实现 GitHub API 图片列表获取
|
||||||
|
- 实现缓存机制(1小时缓存)
|
||||||
|
|
||||||
|
### 3. 组件实现
|
||||||
|
- `LanMountainDesktop/Views/Components/ZhiJiaoHubWidget.axaml`
|
||||||
|
- 组件UI布局(图片、渐变遮罩、名称、导航按钮、指示器)
|
||||||
|
|
||||||
|
- `LanMountainDesktop/Views/Components/ZhiJiaoHubWidget.axaml.cs`
|
||||||
|
- 组件逻辑实现
|
||||||
|
- 图片加载和显示
|
||||||
|
- 导航功能(上一张/下一张)
|
||||||
|
- 自动刷新
|
||||||
|
- 设置持久化
|
||||||
|
|
||||||
|
### 4. 设置编辑器
|
||||||
|
- `LanMountainDesktop/Views/ComponentEditors/ZhiJiaoHubComponentEditor.axaml`
|
||||||
|
- 设置界面布局
|
||||||
|
|
||||||
|
- `LanMountainDesktop/Views/ComponentEditors/ZhiJiaoHubComponentEditor.axaml.cs`
|
||||||
|
- 数据源选择
|
||||||
|
- 自动刷新开关
|
||||||
|
- 刷新间隔设置
|
||||||
|
|
||||||
|
### 5. 组件注册
|
||||||
|
- `LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs`
|
||||||
|
- 添加 `DesktopZhiJiaoHub` 常量
|
||||||
|
|
||||||
|
- `LanMountainDesktop/ComponentSystem/ComponentRegistry.cs`
|
||||||
|
- 注册组件定义(2×2最小尺寸,Free调整模式)
|
||||||
|
|
||||||
|
- `LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs`
|
||||||
|
- 注册组件运行时
|
||||||
|
|
||||||
|
- `LanMountainDesktop/Services/DesktopComponentEditorRegistryFactory.cs`
|
||||||
|
- 注册组件设置编辑器
|
||||||
|
|
||||||
|
- `LanMountainDesktop/Views/MainWindow.ComponentSystem.cs`
|
||||||
|
- 添加比例约束(允许自由调整大小)
|
||||||
|
|
||||||
|
## 技术实现细节
|
||||||
|
|
||||||
|
### 图片获取流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 调用 GitHub API 获取仓库图片目录
|
||||||
|
- ClassIsland: /repos/ClassIsland/classisland-hub/contents/images
|
||||||
|
- SECTL: /repos/SECTL/SECTL-hub/contents/docs/.vuepress/public/images
|
||||||
|
|
||||||
|
2. 解析 JSON 响应,提取图片文件信息
|
||||||
|
- 文件名(解码URL编码)
|
||||||
|
- 下载URL
|
||||||
|
|
||||||
|
3. 过滤非图片文件(只保留 .png, .jpg, .jpeg, .gif, .webp)
|
||||||
|
|
||||||
|
4. 缓存图片列表(1小时)
|
||||||
|
|
||||||
|
5. 按需加载单个图片
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据源配置
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public static class ZhiJiaoHubSources
|
||||||
|
{
|
||||||
|
public const string ClassIsland = "classisland";
|
||||||
|
public const string Sectl = "sectl";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 组件配置项
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public string ZhiJiaoHubSource { get; set; } = ZhiJiaoHubSources.ClassIsland;
|
||||||
|
public bool ZhiJiaoHubAutoRefreshEnabled { get; set; } = true;
|
||||||
|
public int ZhiJiaoHubAutoRefreshIntervalMinutes { get; set; } = 30;
|
||||||
|
public int ZhiJiaoHubCurrentImageIndex { get; set; } = 0;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用说明
|
||||||
|
|
||||||
|
### 添加组件到桌面
|
||||||
|
|
||||||
|
1. 进入桌面编辑模式
|
||||||
|
2. 从组件库选择 "ZhiJiao Hub"
|
||||||
|
3. 组件最小尺寸为 2×2,可以自由调整大小
|
||||||
|
|
||||||
|
### 切换数据源
|
||||||
|
|
||||||
|
1. 选中组件,点击设置按钮
|
||||||
|
2. 在设置面板中选择 "Image Source"
|
||||||
|
3. 可选:ClassIsland Hub 或 SECTL Hub
|
||||||
|
|
||||||
|
### 配置自动刷新
|
||||||
|
|
||||||
|
1. 在设置面板中开启/关闭 "Auto Refresh"
|
||||||
|
2. 设置刷新间隔(5-1440分钟)
|
||||||
|
|
||||||
|
### 浏览图片
|
||||||
|
|
||||||
|
- **自动**: 组件会自动轮播图片
|
||||||
|
- **手动**: 鼠标悬停显示左右箭头,点击切换
|
||||||
|
- **指示器**: 底部圆点显示当前位置
|
||||||
|
|
||||||
|
## 图片源信息
|
||||||
|
|
||||||
|
### ClassIsland Hub
|
||||||
|
- **仓库**: https://github.com/ClassIsland/classisland-hub
|
||||||
|
- **图片路径**: `/images/`
|
||||||
|
- **内容**: ClassIsland交流群/频道的有趣内容
|
||||||
|
- **数量**: 约70张图片
|
||||||
|
|
||||||
|
### SECTL Hub
|
||||||
|
- **仓库**: https://github.com/SECTL/SECTL-hub
|
||||||
|
- **图片路径**: `/docs/.vuepress/public/images/`
|
||||||
|
- **内容**: SECTL交流群的趣图
|
||||||
|
- **数量**: 约78张图片
|
||||||
|
|
||||||
|
## 后续优化建议
|
||||||
|
|
||||||
|
1. **本地缓存**: 将下载的图片缓存到本地,减少网络请求
|
||||||
|
2. **缩略图**: 生成缩略图提高加载速度
|
||||||
|
3. **收藏功能**: 允许用户收藏喜欢的图片
|
||||||
|
4. **分享功能**: 支持分享图片链接
|
||||||
|
5. **更多源**: 添加更多教育技术社区图片源
|
||||||
|
|
||||||
|
## 构建状态
|
||||||
|
|
||||||
|
✅ 构建成功,无错误
|
||||||
Reference in New Issue
Block a user