Compare commits

...

7 Commits

Author SHA1 Message Date
lincube
5804627f53 0.8.0.1
修圆角
2026-03-30 20:28:39 +08:00
lincube
7a268489c9 ci.圆角
修了下新的圆角。
2026-03-30 16:34:45 +08:00
lincube
148e4c894a 0.8.0
圆角设计更新
2026-03-30 15:28:51 +08:00
lincube
f84111e837 0.7.9.2
自习设置,优化设置选项卡图标,加入智教hub组件
2026-03-30 02:40:10 +08:00
lincube
bd2313fe7e 0.7.9.1 2026-03-29 15:34:17 +08:00
lincube
372b5b7adc 0.7.9
更新功能优化、插件市场优化,反正就是优化了很多东西
2026-03-25 11:27:30 +08:00
lincube
74703582e7 0.7.8.1
凤凰网新闻组件优化、央广网新闻组件优化。
2026-03-25 07:44:55 +08:00
116 changed files with 6591 additions and 1241 deletions

45
.github/README.md vendored
View File

@@ -1,45 +0,0 @@
# LanMountainDesktop
> 你的桌面,不止一面。
`LanMountainDesktop` 是一个基于 Avalonia 的桌面壳层项目,目标不是“做一个启动器”,而是把桌面变成可编排的信息与交互空间。
## 项目定位
- 以网格化布局组织桌面组件,支持多页桌面与组件自由摆放。
- 提供顶部状态栏 + 底部任务栏的桌面框架,强调信息密度与可读性平衡。
- 通过主题色、日夜模式、玻璃视觉与动画系统,形成统一的视觉语言。
- 通过组件注册机制与 JSON 扩展入口,让桌面能力可持续扩展。
## 核心能力
- 桌面组件系统:天气、时钟、计时器、课程表、日历、白板、音乐控制、学习环境等组件可组合使用。
- 壁纸系统:支持图片与视频壁纸,并可在设置中实时预览。
- 主题系统支持日夜模式、主题色与调色联动Monet 风格色板)。
- 个性化设置:网格密度、状态栏间距、任务栏布局、语言与时区等可持久化配置。
- 本地化:内置 `zh-CN``en-US` 资源。
## 工程结构
- `LanMountainDesktop/`桌面端主程序Avalonia
- `LanMountainDesktop.RecommendationBackend/`推荐内容后端服务ASP.NET Core Minimal API
- `docs/`:视觉与圆角等规范文档。
- `LanMountainDesktop/ComponentSystem/`:组件定义、注册、放置规则与扩展入口。
## 技术栈
- .NET 10`net10.0`
- Avalonia 11
- FluentAvalonia + FluentIcons.Avalonia
- LibVLCSharp用于视频相关能力
- WebView.Avalonia嵌入式网页组件能力
## 扩展机制(摘要)
- 组件系统通过 `ComponentRegistry` 合并内置组件与扩展组件。
- 运行时会扫描 `Extensions/Components/*.json`(相对应用输出目录)加载第三方组件清单。
- 扩展契约与字段说明见组件系统文档:`LanMountainDesktop/ComponentSystem/README.md`
## 当前状态
- 项目包含桌面端与推荐后端两个子项目,并在同一 `LanMountainDesktop.slnx` 工作区中维护。
- 配置默认写入本地:`%LOCALAPPDATA%\LanMountainDesktop\settings.json`
- 当前体验以 Windows 为主要目标平台。
- SDK 版本由仓库根目录 `global.json` 锁定。
## 运行说明
运行与环境准备已拆分到独立文档:[`run.md`](./run.md)

133
.github/READMEmd vendored Normal file
View File

@@ -0,0 +1,133 @@
# 阑山桌面 / LanMountainDesktop
> 你的桌面,不止一面
[![.NET 10](https://img.shields.io/badge/.NET-10-512BD4)](https://dotnet.microsoft.com/)
[![Avalonia UI](https://img.shields.io/badge/Avalonia%20UI-11.2-blue)](https://avaloniaui.net/)
[![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
> [!IMPORTANT]
> **温馨提示**:本项目有部分成分由**氛围编程 (Vibe Coding)** 方式编写。
>
> 如果您对此类项目有固有的排斥感,请无视此项目,谢谢。
## 简介
**阑山桌面**是一个跨平台桌面环境增强工具,面向需要高频查看信息、追求桌面效率与个性化体验的用户。
基于 Avalonia UI 和 .NET 10 构建,支持 Windows、Linux、macOS 三大平台。
![Platform](https://img.shields.io/badge/Windows-✓-0078D4)
![Platform](https://img.shields.io/badge/Linux-✓-FCC624?logo=linux&logoColor=black)
![Platform](https://img.shields.io/badge/macOS-✓-000000?logo=apple)
## 核心特性
### 📊 信息聚合
- 课程表、日历、天气、新闻、热搜
- 所有信息一目了然,无需频繁切换窗口
### 🎯 效率工具
- 自习环境监测、计时器、知识卡片
- 最近文档、浏览器快捷入口
- 常用工具组件一键触达
### 🎨 个性化桌面
- 自由布局,随心所欲摆放组件
- 多页桌面,工作学习场景分离
- 主题切换、玻璃效果、圆角风格
### 🔌 插件生态
- 通过 `.laapp` 插件扩展功能
- 官方 Plugin SDK 支持自定义组件
- 设置页、组件、集成功能一站式接入
## 为谁而设计
| 用户类型 | 典型场景 |
|---------|---------|
| 🎓 学生用户 | 课程表、自习监测、计时、天气和日常信息聚合 |
| 💼 办公用户 | 日历、资讯、最近文档、常用工具入口 |
| 🎨 效率爱好者 | 自由布局、主题切换、插件扩展 |
| 🇨🇳 中文用户 | 本地化界面、农历和节假日等本地语境支持 |
## 快速开始
### 环境要求
- .NET SDK 10
### 构建与运行
```bash
# 还原依赖
dotnet restore
# 构建项目
dotnet build LanMountainDesktop.slnx -c Debug
# 运行桌面宿主
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
```
### 运行测试
```bash
dotnet test LanMountainDesktop.slnx -c Debug
```
## 插件开发
阑山桌面支持通过 Plugin SDK 开发自定义插件:
```bash
# 安装插件模板
dotnet new install LanMountainDesktop.PluginTemplate
# 创建新插件
dotnet new lmd-plugin -n MyPlugin
```
- **Plugin SDK**: `LanMountainDesktop.PluginSdk` (API 4.0.0)
- **共享契约**: `LanMountainDesktop.Shared.Contracts`
- **迁移指南**: [PLUGIN_SDK_V4_MIGRATION.md](docs/PLUGIN_SDK_V4_MIGRATION.md)
## 项目结构
```
LanMountainDesktop/
├── LanMountainDesktop/ # 桌面宿主应用
├── LanMountainDesktop.PluginSdk/ # 官方插件 SDK
├── LanMountainDesktop.Shared.Contracts/ # 宿主与插件共享契约
├── LanMountainDesktop.Appearance/ # 主题与外观基础设施
├── LanMountainDesktop.Settings.Core/# 设置持久化基础设施
└── LanMountainDesktop.Tests/ # 测试项目
```
## 生态边界
| 项目 | 职责 |
|-----|------|
| **本仓库** | 桌面宿主、插件运行时、Plugin SDK、共享契约 |
| [LanAirApp](https://github.com/yourorg/LanAirApp) | 插件市场元数据、开发者生态材料 |
| [LanMountainDesktop.SamplePlugin](https://github.com/yourorg/LanMountainDesktop.SamplePlugin) | 官方示例插件 |
## 文档索引
- [产品定位](docs/PRODUCT.md) - 产品愿景与目标用户
- [架构说明](docs/ARCHITECTURE.md) - 仓库结构与运行时主线
- [开发指南](docs/DEVELOPMENT.md) - 构建、测试、调试
- [视觉规范](docs/VISUAL_SPEC.md) - 主题、颜色、玻璃层级
- [圆角规范](docs/CORNER_RADIUS_SPEC.md) - 圆角层级与动态规则
- [贡献指南](docs/CONTRIBUTING.md) - PR、spec、文档协作规则
## 技术栈
- **UI 框架**: [Avalonia UI](https://avaloniaui.net/)
- **开发平台**: [.NET 10](https://dotnet.microsoft.com/)
- **支持平台**: Windows 10+, Linux, macOS
## 许可证
[MIT](LICENSE)

View File

@@ -62,6 +62,7 @@ dotnet test LanMountainDesktop.slnx -c Debug
### UI
- 主题、资源和视觉语义优先遵守 `docs/VISUAL_SPEC.md``docs/CORNER_RADIUS_SPEC.md`
- **组件圆角**:所有内置与插件组件的根边框必须使用 `{DynamicResource DesignCornerRadiusComponent}` 资源。
- 设置页相关改动通常同时落在 `Views/``ViewModels/``Services/``.trae/specs/`
- UI 启动与窗口生命周期主线在 `Program.cs``App.axaml.cs`
@@ -76,6 +77,7 @@ dotnet test LanMountainDesktop.slnx -c Debug
- 设置持久化和 scope 变化优先检查 `LanMountainDesktop.Settings.Core/`
- 外观、圆角、主题资源优先检查 `LanMountainDesktop.Appearance/` 与专题规范
- **圆角统一**桌面组件Widget必须统一使用动态资源 `DesignCornerRadiusComponent`。严禁在组件根容器使用硬编码数值或非组件级令牌(如 `Xs`, `Md` 等),以确保全局圆角缩放设置能正确应用到所有组件。
## 6. 权威来源

View File

@@ -11,12 +11,13 @@ public static class AppearanceCornerRadiusTokenFactory
var normalizedScale = GlobalAppearanceSettings.NormalizeCornerRadiusScale(scale);
return new AppearanceCornerRadiusTokens(
Radius(6, normalizedScale),
Radius(10, normalizedScale),
Radius(12, normalizedScale),
Radius(14, normalizedScale),
Radius(18, normalizedScale),
Radius(24, normalizedScale),
Radius(30, normalizedScale),
Radius(36, normalizedScale));
Radius(20, normalizedScale),
Radius(28, normalizedScale),
Radius(32, normalizedScale),
Radius(36, normalizedScale),
Radius(18, normalizedScale));
}
private static CornerRadius Radius(double value, double scale)

View File

@@ -9,5 +9,6 @@ public enum PluginCornerRadiusPreset
Md = 4,
Lg = 5,
Xl = 6,
Island = 7
Island = 7,
Component = 8
}

View File

@@ -10,13 +10,14 @@ public sealed record PluginCornerRadiusTokens(
double Md,
double Lg,
double Xl,
double Island)
double Island,
double Component)
{
public double Get(PluginCornerRadiusPreset preset)
{
return preset switch
{
PluginCornerRadiusPreset.Default => Md,
PluginCornerRadiusPreset.Default => Component,
PluginCornerRadiusPreset.Micro => Micro,
PluginCornerRadiusPreset.Xs => Xs,
PluginCornerRadiusPreset.Sm => Sm,
@@ -24,7 +25,8 @@ public sealed record PluginCornerRadiusTokens(
PluginCornerRadiusPreset.Lg => Lg,
PluginCornerRadiusPreset.Xl => Xl,
PluginCornerRadiusPreset.Island => Island,
_ => Md
PluginCornerRadiusPreset.Component => Component,
_ => Component
};
}
@@ -44,6 +46,7 @@ public sealed record PluginCornerRadiusTokens(
tokens.Md.TopLeft,
tokens.Lg.TopLeft,
tokens.Xl.TopLeft,
tokens.Island.TopLeft);
tokens.Island.TopLeft,
tokens.Component.TopLeft);
}
}

View File

@@ -6,7 +6,9 @@ public static class SettingsCategories
public const string Appearance = "Appearance";
public const string Components = "Components";
public const string Plugins = "Plugins";
public const string PluginMarket = "PluginMarket";
public const string PluginCatalog = "PluginCatalog";
[Obsolete("Use PluginCatalog instead.")]
public const string PluginMarket = PluginCatalog;
public const string Update = "Update";
public const string About = "About";
public const string Advanced = "Advanced";

View File

@@ -6,6 +6,8 @@ public enum SettingsPageCategory
Appearance = 10,
Components = 20,
Plugins = 30,
PluginCatalog = 35,
[Obsolete("Use PluginCatalog instead.")]
PluginMarket = 35,
About = 40
}

View File

@@ -9,4 +9,5 @@ public sealed record AppearanceCornerRadiusTokens(
CornerRadius Md,
CornerRadius Lg,
CornerRadius Xl,
CornerRadius Island);
CornerRadius Island,
CornerRadius Component);

View File

@@ -19,7 +19,7 @@ public sealed class BuiltInDesktopHostCornerRadiusBaselineTests
var registry = new DesktopComponentRuntimeRegistry(
ComponentRegistry.CreateDefault(),
DesktopComponentRuntimeRegistry.GetDefaultRegistrations());
var expected = AppearanceCornerRadiusTokenFactory.Create(globalScale).Lg.TopLeft;
var expected = AppearanceCornerRadiusTokenFactory.Create(globalScale).Component.TopLeft;
foreach (var descriptor in registry.GetDesktopComponents())
{

View File

@@ -40,12 +40,13 @@ public sealed class CornerRadiusScaleTests
GlobalCornerRadiusScale: 0d,
CornerRadiusTokens: PluginCornerRadiusTokens.FromShared(new AppearanceCornerRadiusTokens(
new CornerRadius(6),
new CornerRadius(10),
new CornerRadius(12),
new CornerRadius(14),
new CornerRadius(18),
new CornerRadius(24),
new CornerRadius(30),
new CornerRadius(36))),
new CornerRadius(20),
new CornerRadius(28),
new CornerRadius(32),
new CornerRadius(36),
new CornerRadius(8))),
ThemeVariant: "Unknown"));
var context = new PluginDesktopComponentContext(
@@ -76,7 +77,8 @@ public sealed class CornerRadiusScaleTests
Md: 36d,
Lg: 48d,
Xl: 60d,
Island: 72d),
Island: 72d,
Component: 16d),
ThemeVariant: "Light"));
Assert.Equal(36d, context.ResolveCornerRadius(PluginCornerRadiusPreset.Md), 3);

View File

@@ -48,11 +48,12 @@ public sealed class DesktopComponentRuntimeRegistrationCornerRadiusTests
GlobalCornerRadiusScale: globalScale,
CornerRadiusTokens: new AppearanceCornerRadiusTokens(
new CornerRadius(6),
new CornerRadius(10),
new CornerRadius(12),
new CornerRadius(14),
new CornerRadius(18),
new CornerRadius(24),
new CornerRadius(30),
new CornerRadius(36)));
new CornerRadius(20),
new CornerRadius(28),
new CornerRadius(32),
new CornerRadius(36),
new CornerRadius(8)));
}
}

View File

@@ -53,8 +53,8 @@ public sealed class InfoRecommendationHostCornerRadiusTests
var max = descriptor.ResolveCornerRadius(CreateChromeContext(componentId, cellSize, 2.5d));
Assert.Equal(0d, zero, 3);
Assert.Equal(24d, unit, 3);
Assert.Equal(60d, max, 3);
Assert.Equal(18d, unit, 3);
Assert.Equal(45d, max, 3);
Assert.True(zero <= unit && unit <= max);
}

View File

@@ -43,4 +43,5 @@ public static class BuiltInComponentIds
public const string DesktopBrowser = "DesktopBrowser";
public const string DesktopOfficeRecentDocuments = "DesktopOfficeRecentDocuments";
public const string DesktopRemovableStorage = "DesktopRemovableStorage";
public const string DesktopZhiJiaoHub = "DesktopZhiJiaoHub";
}

View File

@@ -390,7 +390,17 @@ public sealed class ComponentRegistry
MinWidthCells: 2,
MinHeightCells: 2,
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);

View File

@@ -6,7 +6,7 @@ using Markdown.Avalonia;
namespace LanMountainDesktop.Helpers;
public static class PluginMarketMarkdownHelper
public static class PluginCatalogMarkdownHelper
{
private static Markdown.Avalonia.Markdown? _engine;

View File

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

View File

@@ -203,6 +203,52 @@
"settings.weather.alert_list_desc": "One exclusion rule per line.",
"settings.weather.no_tls_toggle": "Allow non-TLS request fallback",
"settings.weather.footer_hint": "Desktop weather widgets will reuse the location and alert exclusion settings configured here.",
"settings.study.title": "Study",
"settings.study.description": "Configure study environment monitoring, focus timer, and alert settings.",
"settings.study.noise_header": "Noise Monitoring",
"settings.study.noise_description": "Configure microphone sampling rate and noise scoring sensitivity.",
"settings.study.sampling_rate_label": "Sampling Rate",
"settings.study.sampling_rate_desc": "Time interval for microphone audio sampling. Higher frequency captures noise changes more accurately but increases power consumption.",
"settings.study.sampling_rate_20ms": "20ms (High)",
"settings.study.sampling_rate_50ms": "50ms (Standard)",
"settings.study.sampling_rate_100ms": "100ms (Power Saving)",
"settings.study.sampling_rate_200ms": "200ms (Low Power)",
"settings.study.sensitivity_label": "Noise Sensitivity",
"settings.study.sensitivity_desc": "Scoring threshold determines what level of noise is considered interference. Stricter thresholds detect quieter noises.",
"settings.study.sensitivity_relaxed": "Relaxed (-45dBFS)",
"settings.study.sensitivity_standard": "Standard (-50dBFS)",
"settings.study.sensitivity_strict": "Strict (-55dBFS)",
"settings.study.sensitivity_very_strict": "Very Strict (-60dBFS)",
"settings.study.current_threshold_format": "Current scoring threshold: {0} dBFS",
"settings.study.timer_header": "Focus Timer",
"settings.study.timer_description": "Configure focus and break session durations.",
"settings.study.focus_duration_label": "Focus Duration",
"settings.study.focus_duration_desc": "Duration of a single focus session (minutes).",
"settings.study.break_duration_label": "Break Duration",
"settings.study.break_duration_desc": "Duration of a short break session (minutes).",
"settings.study.long_break_duration_label": "Long Break Duration",
"settings.study.long_break_duration_desc": "Duration of a long break session (minutes).",
"settings.study.sessions_before_long_break_label": "Long Break Interval",
"settings.study.sessions_before_long_break_desc": "Number of focus sessions before a long break.",
"settings.study.auto_start_break_label": "Auto-start Break",
"settings.study.auto_start_break_desc": "Automatically start break timer when focus session ends.",
"settings.study.auto_start_focus_label": "Auto-start Focus",
"settings.study.auto_start_focus_desc": "Automatically start focus timer when break ends.",
"settings.study.alert_header": "Alert Settings",
"settings.study.alert_description": "Configure noise interference alerts.",
"settings.study.noise_alert_enabled_label": "Enable Noise Alert",
"settings.study.noise_alert_enabled_desc": "Show an alert when noise interference exceeds tolerance threshold.",
"settings.study.max_interrupts_label": "Max Tolerated Interrupts",
"settings.study.max_interrupts_desc": "Maximum noise interference events per minute before triggering an alert.",
"settings.study.display_header": "Display Settings",
"settings.study.display_description": "Configure how noise data is displayed.",
"settings.study.show_realtime_db_label": "Show Realtime Decibel",
"settings.study.show_realtime_db_desc": "Display decibel values in real-time on components.",
"settings.study.baseline_db_label": "Baseline Display Decibel",
"settings.study.baseline_db_desc": "Calibrated baseline decibel value for converting dBFS to user-readable dB.",
"settings.study.avg_window_label": "Averaging Window",
"settings.study.avg_window_desc": "Time window for smoothing noise display. Larger values make display more stable but slower to respond.",
"settings.study.footer_hint": "These settings affect the behavior of study environment monitoring components.",
"settings.weather.location_header": "Weather Location",
"settings.weather.location_desc": "Set the location used by weather widgets.",
"settings.weather.location_placeholder": "e.g. Beijing",
@@ -418,6 +464,11 @@
"settings.update.channel_preview_desc": "Preview builds may contain newer features but can be less stable.",
"settings.update.download_threads_label": "Download Threads",
"settings.update.download_threads_desc": "Set the number of parallel download threads for application update packages.",
"settings.update.force_check_label": "Force Check Update",
"settings.update.force_check_desc": "Force check for updates from GitHub, ignoring version comparison.",
"settings.update.status_force_checking": "Force checking GitHub releases...",
"settings.update.status_force_no_asset": "Release found but no compatible installer available.",
"settings.update.status_force_available_format": "Release {0} is available. Click Download & Install.",
"settings.update.install_now_button": "Install Now",
"settings.update.status_downloaded_confirm": "Update downloaded. Review it and choose when to install.",
"settings.update.status_downloaded_exit": "Update downloaded. It will be installed when you exit the app.",
@@ -525,10 +576,10 @@
"settings.plugins.source_manifest": "Loose manifest",
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
"settings.plugins.detail_format": "Settings pages: {0} | Widgets: {1}",
"settings.nav.plugin_market": "Plugin Market",
"settings.plugin_market.title": "Plugin Market",
"settings.plugin_market.subtitle": "Browse plugins from the official LanAirApp source and stage installs.",
"settings.plugin_market.unavailable": "Plugin runtime is not available, so the official market cannot be opened right now.",
"settings.nav.plugin_catalog": "Plugin Catalog",
"settings.plugin_catalog.title": "Plugin Catalog",
"settings.plugin_catalog.subtitle": "Browse plugins from the official LanAirApp source and stage installs.",
"settings.plugin_catalog.unavailable": "Plugin runtime is not available, so the official catalog cannot be opened right now.",
"settings.update.status_idle": "No update check has been performed yet.",
"settings.update.status_preferences_saved": "Update preferences saved.",
"settings.update.status_check_failed": "Failed to check for updates.",
@@ -972,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.button": "OK",
"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."
}

View File

@@ -418,6 +418,11 @@
"settings.update.channel_preview_desc": "プレビュービルドは新しい機能が含まれる可能性がありますが、安定性が低い場合があります。",
"settings.update.download_threads_label": "ダウンロードスレッド",
"settings.update.download_threads_desc": "アプリケーションのアップデートパッケージの並列ダウンロードスレッド数を設定します。",
"settings.update.force_check_label": "強制アップデート確認",
"settings.update.force_check_desc": "GitHubから強制的に最新バージョンを取得し、バージョン比較を無視します。",
"settings.update.status_force_checking": "GitHubリリースを強制確認中...",
"settings.update.status_force_no_asset": "リリースは見つかりましたが、互換性のあるインストーラーがありません。",
"settings.update.status_force_available_format": "リリース {0} が利用可能です。「ダウンロードしてインストール」をクリックしてください。",
"settings.update.install_now_button": "今すぐインストール",
"settings.update.status_downloaded_confirm": "アップデートがダウンロードされました。確認してインストールのタイミングを選択してください。",
"settings.update.status_downloaded_exit": "アップデートがダウンロードされました。アプリの終了時にインストールされます。",
@@ -477,7 +482,7 @@
"settings.plugins.refresh_button": "プラグインを更新",
"settings.plugins.refresh_success_installed_format": "{0}個のインストール済みプラグインをロードしました。",
"settings.plugins.refresh_success_format": "{0}個のインストール済みプラグインと{1}個のマーケットプレイスエントリをロードしました。",
"settings.plugins.refresh_failed": "プラグインマーケットインデックスのロードに失敗しました。",
"settings.plugins.refresh_failed": "プラグインカタログインデックスのロードに失敗しました。",
"settings.plugins.marketplace_header": "マーケットプレイス",
"settings.plugins.marketplace_empty": "現在、マーケットプレイスのプラグインはありません。",
"settings.plugins.delete_button_short": "削除",
@@ -525,10 +530,10 @@
"settings.plugins.source_manifest": "ルーズマニフェスト",
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
"settings.plugins.detail_format": "設定ページ: {0} | ウィジェット: {1}",
"settings.nav.plugin_market": "プラグインマーケット",
"settings.plugin_market.title": "プラグインマーケット",
"settings.plugin_market.subtitle": "公式LanAirAppソースからプラグインを参照し、インストールをステージングします。",
"settings.plugin_market.unavailable": "プラグインランタイムが利用できないため、公式マーケットを開けません。",
"settings.nav.plugin_catalog": "プラグインカタログ",
"settings.plugin_catalog.title": "プラグインカタログ",
"settings.plugin_catalog.subtitle": "公式LanAirAppソースからプラグインを参照し、インストールをステージングします。",
"settings.plugin_catalog.unavailable": "プラグインランタイムが利用できないため、公式カタログを開けません。",
"settings.update.status_idle": "アップデートの確認はまだ実行されていません。",
"settings.update.status_preferences_saved": "アップデート設定が保存されました。",
"settings.update.status_check_failed": "アップデートの確認に失敗しました。",
@@ -537,15 +542,15 @@
"settings.window.drawer_default": "詳細",
"market.toolbar.search_placeholder": "プラグインを検索",
"market.toolbar.refresh": "更新",
"market.status.loading": "公式プラグインマーケットをロード中...",
"market.status.loading": "公式プラグインカタログをロード中...",
"market.status.loaded_network_format": "公式ソースから{0}個のプラグインをロードしました。",
"market.status.loaded_cache_format": "公式ソースが利用できません。キャッシュから{0}個のプラグインをロードしました。理由: {1}",
"market.status.load_failed_format": "プラグインマーケットのロードに失敗しました: {0}",
"market.status.load_failed_format": "プラグインカタログのロードに失敗しました: {0}",
"market.status.installing_format": "プラグイン「{0}」をダウンロードしてステージング中...",
"market.status.install_success_format": "プラグイン「{0}」がステージングされました。適用するにはアプリを再起動してください。",
"market.status.install_failed_format": "プラグインのインストールに失敗しました: {0}",
"market.status.host_incompatible_format": "このホストは古すぎます。バージョン{0}以降が必要です。",
"market.list.empty": "プラグインマーケットはまだロードされていません。",
"market.list.empty": "プラグインカタログはまだロードされていません。",
"market.list.no_results": "現在の検索に一致するプラグインはありません。",
"market.card.subtitle_format": "{0} | v{1}",
"market.card.loaded": "ロード済み",

View File

@@ -210,6 +210,52 @@
"settings.weather.location_required": "날씨 위치는 비워둘 수 없습니다.",
"settings.weather.location_current_format": "현재 날씨 위치: {0}",
"settings.weather.location_saved_format": "날씨 위치 저장됨: {0}",
"settings.study.title": "공부",
"settings.study.description": "공부 환경 모니터링, 집중 타이머 및 알림 설정을 구성합니다.",
"settings.study.noise_header": "소음 모니터링",
"settings.study.noise_description": "마이크 샘플링 주파수와 소음 감도를 구성합니다.",
"settings.study.sampling_rate_label": "샘플링 주파수",
"settings.study.sampling_rate_desc": "마이크가 오디오를 샘플링하는 시간 간격입니다. 더 높은 주파수는 소음 변화를 더 정확하게 포착하지만 배터리 소모가 증가합니다.",
"settings.study.sampling_rate_20ms": "20ms (고주파)",
"settings.study.sampling_rate_50ms": "50ms (표준)",
"settings.study.sampling_rate_100ms": "100ms (절전)",
"settings.study.sampling_rate_200ms": "200ms (저전력)",
"settings.study.sensitivity_label": "소음 감도",
"settings.study.sensitivity_desc": "평가 임계값은 어떤 수준의 소음이 간주되는지 결정합니다. 임계값이 엄격할수록 미세한 소음도 감지하기 쉽습니다.",
"settings.study.sensitivity_relaxed": "느슨함 (-45dBFS)",
"settings.study.sensitivity_standard": "표준 (-50dBFS)",
"settings.study.sensitivity_strict": "엄격 (-55dBFS)",
"settings.study.sensitivity_very_strict": "매우 엄격 (-60dBFS)",
"settings.study.current_threshold_format": "현재 평가 임계값: {0} dBFS",
"settings.study.timer_header": "집중 타이머",
"settings.study.timer_description": "집중 시간과 휴식 시간을 구성합니다.",
"settings.study.focus_duration_label": "집중 시간",
"settings.study.focus_duration_desc": "한 번의 집중 세션 지속 시간(분)입니다.",
"settings.study.break_duration_label": "휴식 시간",
"settings.study.break_duration_desc": "짧은 휴식 세션의 지속 시간(분)입니다.",
"settings.study.long_break_duration_label": "긴 휴식 시간",
"settings.study.long_break_duration_desc": "긴 휴식 세션의 지속 시간(분)입니다.",
"settings.study.sessions_before_long_break_label": "긴 휴식 간격",
"settings.study.sessions_before_long_break_desc": "긴 휴식을 트리거하기 전의 집중 세션 수입니다.",
"settings.study.auto_start_break_label": "휴식 자동 시작",
"settings.study.auto_start_break_desc": "집중 세션이 끝나면 자동으로 휴식 타이머를 시작합니다.",
"settings.study.auto_start_focus_label": "집중 자동 시작",
"settings.study.auto_start_focus_desc": "휴식 세션이 끝나면 자동으로 집중 타이머를 시작합니다.",
"settings.study.alert_header": "알림 설정",
"settings.study.alert_description": "소음 방해 알림을 구성합니다.",
"settings.study.noise_alert_enabled_label": "소음 알림 활성화",
"settings.study.noise_alert_enabled_desc": "허용 임계값을 초과하는 소음 방해가 감지되면 알림을 표시합니다.",
"settings.study.max_interrupts_label": "최대 허용 중단 횟수",
"settings.study.max_interrupts_desc": "분당 허용되는 최대 소음 방해 이벤트 수로, 이 값을 초과하면 알림이 트리거됩니다.",
"settings.study.display_header": "표시 설정",
"settings.study.display_description": "소음 데이터 표시 방법을 구성합니다.",
"settings.study.show_realtime_db_label": "실시간 데시벨 표시",
"settings.study.show_realtime_db_desc": "구성 요소에 실시간 데시벨 값을 표시합니다.",
"settings.study.baseline_db_label": "기준 표시 데시벨",
"settings.study.baseline_db_desc": "dBFS를 사용자가 읽을 수 있는 dB로 변환하기 위한 보정된 기준 데시벨 값입니다.",
"settings.study.avg_window_label": "평균 시간 창",
"settings.study.avg_window_desc": "소음 평활 표시를 위한 시간 창입니다. 값이 클수록 표시가 더 안정적이지만 응답이 느려집니다.",
"settings.study.footer_hint": "이러한 설정은 공부 환경 모니터링 구성 요소의 동작에 영향을 미칩니다.",
"weather.widget.location_not_configured": "날씨 위치가 구성되지 않음",
"weather.widget.configure_hint": "설정 > 날씨에서 구성을 완료하세요",
"weather.widget.loading": "로딩 중...",
@@ -418,6 +464,11 @@
"settings.update.channel_preview_desc": "미리보기 버전은 더 빠른 새 기능을 포함할 수 있지만 안정성이 낮을 수 있습니다.",
"settings.update.download_threads_label": "다운로드 스레드 수",
"settings.update.download_threads_desc": "앱 업데이트 설치 패키지에 사용할 병렬 다운로드 스레드 수를 설정합니다.",
"settings.update.force_check_label": "강제 업데이트 확인",
"settings.update.force_check_desc": "버전 비교를 무시하고 GitHub에서 강제로 최신 버전을 가져옵니다.",
"settings.update.status_force_checking": "GitHub 릴리스 강제 확인 중...",
"settings.update.status_force_no_asset": "릴리스를 찾았지만 호환되는 설치 프로그램이 없습니다.",
"settings.update.status_force_available_format": "릴리스 {0}을(를) 사용할 수 있습니다. '다운로드 및 설치'를 클릭하세요.",
"settings.update.install_now_button": "지금 설치",
"settings.update.status_downloaded_confirm": "업데이트가 다운로드되었습니다. 확인 후 설치 시기를 선택하세요.",
"settings.update.status_downloaded_exit": "업데이트가 다운로드되었습니다. 앱 종료 시 설치됩니다.",
@@ -476,8 +527,8 @@
"settings.plugins.refresh_button": "플러그인 새로고침",
"settings.plugins.refresh_success_installed_format": "{0}개 설치된 플러그인을 로드했습니다.",
"settings.plugins.refresh_success_format": "{0}개 설치된 플러그인과 {1}개 마켓 항목을 로드했습니다.",
"settings.plugins.refresh_failed": "플러그인 마켓 인덱스 로드 실패.",
"settings.plugins.marketplace_header": "플러그인 마켓",
"settings.plugins.refresh_failed": "플러그인 카탈로그 인덱스 로드 실패.",
"settings.plugins.marketplace_header": "플러그인 카탈로그",
"settings.plugins.marketplace_empty": "현재 사용 가능한 마켓 플러그인이 없습니다.",
"settings.plugins.delete_button_short": "삭제",
"settings.plugins.install_button_short": "설치",
@@ -524,10 +575,10 @@
"settings.plugins.source_manifest": "매니페스트 파일",
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
"settings.plugins.detail_format": "설정 페이지: {0} | 컴포넌트: {1}",
"settings.nav.plugin_market": "플러그인 마켓",
"settings.plugin_market.title": "플러그인 마켓",
"settings.plugin_market.subtitle": "LanAirApp 공식 소스의 플러그인을 탐색하고 로컬에 설치 스테이징합니다.",
"settings.plugin_market.unavailable": "플러그인 런타임을 사용할 수 없어 일시적으로 공식 마켓을 열 수 없습니다.",
"settings.nav.plugin_catalog": "플러그인 카탈로그",
"settings.plugin_catalog.title": "플러그인 카탈로그",
"settings.plugin_catalog.subtitle": "LanAirApp 공식 소스의 플러그인을 탐색하고 로컬에 설치 스테이징합니다.",
"settings.plugin_catalog.unavailable": "플러그인 런타임을 사용할 수 없어 일시적으로 공식 카탈로그를 열 수 없습니다.",
"settings.update.status_idle": "아직 업데이트 확인이 수행되지 않았습니다.",
"settings.update.status_preferences_saved": "업데이트 설정이 저장되었습니다.",
"settings.update.status_check_failed": "업데이트 확인 실패.",
@@ -536,15 +587,15 @@
"settings.window.drawer_default": "상세 정보",
"market.toolbar.search_placeholder": "플러그인 검색",
"market.toolbar.refresh": "새로고침",
"market.status.loading": "공식 플러그인 마켓 로딩 중...",
"market.status.loading": "공식 플러그인 카탈로그 로딩 중...",
"market.status.loaded_network_format": "공식 소스에서 {0}개 플러그인을 로드했습니다.",
"market.status.loaded_cache_format": "공식 소스를 일시적으로 사용할 수 없어 캐시에서 {0}개 플러그인을 로드했습니다. 원인: {1}",
"market.status.load_failed_format": "플러그인 마켓 로드 실패: {0}",
"market.status.load_failed_format": "플러그인 카탈로그 로드 실패: {0}",
"market.status.installing_format": "플러그인 \"{0}\" 다운로드 및 스테이징 중...",
"market.status.install_success_format": "플러그인 \"{0}\" 스테이징 완료. 앱 재시작 후 적용됩니다.",
"market.status.install_failed_format": "플러그인 설치 실패: {0}",
"market.status.host_incompatible_format": "현재 호스트 버전이 너무 낮습니다. 최소 {0}이(가) 필요합니다.",
"market.list.empty": "플러그인 마켓이 아직 로드되지 않았습니다.",
"market.list.empty": "플러그인 카탈로그이 아직 로드되지 않았습니다.",
"market.list.no_results": "현재 검색과 일치하는 플러그인이 없습니다.",
"market.card.subtitle_format": "{0} | v{1}",
"market.card.loaded": "로드됨",

View File

@@ -206,6 +206,52 @@
"settings.weather.location_required": "天气位置不能为空。",
"settings.weather.location_current_format": "当前天气位置:{0}",
"settings.weather.location_saved_format": "天气位置已保存:{0}",
"settings.study.title": "自习",
"settings.study.description": "配置自习环境监测、专注计时和提醒设置。",
"settings.study.noise_header": "噪音监测",
"settings.study.noise_description": "配置麦克风采集频率和噪音评分敏感度。",
"settings.study.sampling_rate_label": "采集频率",
"settings.study.sampling_rate_desc": "麦克风采集音频的时间间隔。更高的频率会更准确地捕捉噪音变化,但会增加电量消耗。",
"settings.study.sampling_rate_20ms": "20ms (高频)",
"settings.study.sampling_rate_50ms": "50ms (标准)",
"settings.study.sampling_rate_100ms": "100ms (节能)",
"settings.study.sampling_rate_200ms": "200ms (低功耗)",
"settings.study.sensitivity_label": "噪音敏感度",
"settings.study.sensitivity_desc": "评分阈值决定了什么级别的噪音会被认为是干扰。阈值越严格,越容易检测到轻微噪音。",
"settings.study.sensitivity_relaxed": "宽松 (-45dBFS)",
"settings.study.sensitivity_standard": "标准 (-50dBFS)",
"settings.study.sensitivity_strict": "严格 (-55dBFS)",
"settings.study.sensitivity_very_strict": "极严 (-60dBFS)",
"settings.study.current_threshold_format": "当前评分阈值: {0} dBFS",
"settings.study.timer_header": "专注计时",
"settings.study.timer_description": "配置专注时段和休息时段的时长。",
"settings.study.focus_duration_label": "专注时长",
"settings.study.focus_duration_desc": "单次专注时段的持续时间(分钟)。",
"settings.study.break_duration_label": "休息时长",
"settings.study.break_duration_desc": "短休息时段的持续时间(分钟)。",
"settings.study.long_break_duration_label": "长休息时长",
"settings.study.long_break_duration_desc": "长休息时段的持续时间(分钟)。",
"settings.study.sessions_before_long_break_label": "长休息间隔",
"settings.study.sessions_before_long_break_desc": "经过几个专注时段后触发长休息。",
"settings.study.auto_start_break_label": "自动开始休息",
"settings.study.auto_start_break_desc": "专注时段结束后自动开始休息计时。",
"settings.study.auto_start_focus_label": "自动开始专注",
"settings.study.auto_start_focus_desc": "休息时段结束后自动开始专注计时。",
"settings.study.alert_header": "提醒设置",
"settings.study.alert_description": "配置噪音干扰提醒。",
"settings.study.noise_alert_enabled_label": "启用噪音提醒",
"settings.study.noise_alert_enabled_desc": "当检测到超过容忍阈值的噪音干扰时显示提醒。",
"settings.study.max_interrupts_label": "最大容忍打断次数",
"settings.study.max_interrupts_desc": "每分钟最多允许多少次噪音干扰事件,超过此值将触发提醒。",
"settings.study.display_header": "显示设置",
"settings.study.display_description": "配置噪音数据的显示方式。",
"settings.study.show_realtime_db_label": "显示实时分贝",
"settings.study.show_realtime_db_desc": "在组件中实时显示分贝值。",
"settings.study.baseline_db_label": "基准显示分贝",
"settings.study.baseline_db_desc": "校准后的显示分贝基准值,用于将 dBFS 转换为用户可读的 dB 值。",
"settings.study.avg_window_label": "平均时间窗",
"settings.study.avg_window_desc": "噪音平滑显示的时间窗口,较大的值会使显示更稳定但响应更慢。",
"settings.study.footer_hint": "这些设置将影响自习环境监测组件的行为。",
"weather.widget.location_not_configured": "尚未配置天气位置",
"weather.widget.configure_hint": "请前往 设置 > 天气 完成配置",
"weather.widget.loading": "加载中...",
@@ -413,6 +459,11 @@
"settings.update.channel_preview_desc": "预览版可能包含更早的新功能,但稳定性可能较低。",
"settings.update.download_threads_label": "下载线程数",
"settings.update.download_threads_desc": "设置应用更新安装包使用的并行下载线程数。",
"settings.update.force_check_label": "强制检查更新",
"settings.update.force_check_desc": "强制从 GitHub 获取最新版本,忽略版本比较。",
"settings.update.status_force_checking": "正在强制检查 GitHub Release...",
"settings.update.status_force_no_asset": "已找到发布版本,但没有可用的兼容安装包。",
"settings.update.status_force_available_format": "发布版本 {0} 可用,点击“下载并安装”继续。",
"settings.update.install_now_button": "立即安装",
"settings.update.status_downloaded_confirm": "更新已下载完成,请查看并选择安装时机。",
"settings.update.status_downloaded_exit": "更新已下载完成,将在你退出应用时安装。",
@@ -471,8 +522,8 @@
"settings.plugins.refresh_button": "刷新插件",
"settings.plugins.refresh_success_installed_format": "已加载 {0} 个已安装插件。",
"settings.plugins.refresh_success_format": "已加载 {0} 个已安装插件和 {1} 个市场条目。",
"settings.plugins.refresh_failed": "加载插件市场索引失败。",
"settings.plugins.marketplace_header": "插件市场",
"settings.plugins.refresh_failed": "加载插件目录索引失败。",
"settings.plugins.marketplace_header": "插件目录",
"settings.plugins.marketplace_empty": "当前没有可用的市场插件。",
"settings.plugins.delete_button_short": "删除",
"settings.plugins.install_button_short": "安装",
@@ -519,10 +570,10 @@
"settings.plugins.source_manifest": "散装清单",
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
"settings.plugins.detail_format": "设置页:{0} | 组件:{1}",
"settings.nav.plugin_market": "插件市场",
"settings.plugin_market.title": "插件市场",
"settings.plugin_market.subtitle": "浏览来自 LanAirApp 官方源的插件,并将安装暂存到本地。",
"settings.plugin_market.unavailable": "插件运行时不可用,暂时无法打开官方市场。",
"settings.nav.plugin_catalog": "插件目录",
"settings.plugin_catalog.title": "插件目录",
"settings.plugin_catalog.subtitle": "浏览来自 LanAirApp 官方源的插件,并将安装暂存到本地。",
"settings.plugin_catalog.unavailable": "插件运行时不可用,暂时无法打开官方目录。",
"settings.update.status_idle": "尚未执行更新检查。",
"settings.update.status_preferences_saved": "更新偏好已保存。",
"settings.update.status_check_failed": "检查更新失败。",
@@ -531,15 +582,15 @@
"settings.window.drawer_default": "详情",
"market.toolbar.search_placeholder": "搜索插件",
"market.toolbar.refresh": "刷新",
"market.status.loading": "正在加载官方插件市场...",
"market.status.loading": "正在加载官方插件目录...",
"market.status.loaded_network_format": "已从官方源加载 {0} 个插件。",
"market.status.loaded_cache_format": "官方源暂时不可用,已从缓存加载 {0} 个插件。原因:{1}",
"market.status.load_failed_format": "加载插件市场失败:{0}",
"market.status.load_failed_format": "加载插件目录失败:{0}",
"market.status.installing_format": "正在下载并暂存插件“{0}”...",
"market.status.install_success_format": "插件“{0}”已暂存完成。重启应用后生效。",
"market.status.install_failed_format": "安装插件失败:{0}",
"market.status.host_incompatible_format": "当前宿主版本过低,至少需要 {0}。",
"market.list.empty": "插件市场尚未加载。",
"market.list.empty": "插件目录尚未加载。",
"market.list.no_results": "没有匹配当前搜索的插件。",
"market.card.subtitle_format": "{0} | v{1}",
"market.card.loaded": "已加载",
@@ -966,5 +1017,19 @@
"single_instance.notice.description": "应用已经运行,无需多次点击打开。",
"single_instance.notice.button": "确定",
"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 仓库获取并缓存在本地。"
}

View File

@@ -95,6 +95,8 @@ public sealed class AppSettingsSnapshot
public long? LastUpdateCheckUtcMs { get; set; }
public string? PendingUpdateSha256 { get; set; }
public List<string> TopStatusComponentIds { get; set; } = [];
public List<string> PinnedTaskbarActions { get; set; } =
@@ -116,6 +118,36 @@ public sealed class AppSettingsSnapshot
public List<string> DisabledPluginIds { get; set; } = [];
#region Study Settings
public int? StudyFrameMs { get; set; }
public double? StudyScoreThresholdDbfs { get; set; }
public int? StudyFocusDurationMinutes { get; set; }
public int? StudyBreakDurationMinutes { get; set; }
public int? StudyLongBreakDurationMinutes { get; set; }
public int? StudySessionsBeforeLongBreak { get; set; }
public bool? StudyAutoStartBreak { get; set; }
public bool? StudyAutoStartFocus { get; set; }
public bool? StudyNoiseAlertEnabled { get; set; }
public int? StudyMaxInterruptsPerMinute { get; set; }
public bool? StudyShowRealtimeDb { get; set; }
public double? StudyBaselineDb { get; set; }
public int? StudyAvgWindowSec { get; set; }
#endregion
public AppSettingsSnapshot Clone()
{
var clone = (AppSettingsSnapshot)MemberwiseClone();

View File

@@ -73,6 +73,17 @@ public sealed class ComponentSettingsSnapshot
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()
{
var clone = (ComponentSettingsSnapshot)MemberwiseClone();
@@ -107,3 +118,56 @@ public sealed class ComponentSettingsSnapshot
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;
}
}

View File

@@ -476,6 +476,7 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
resources["DesignCornerRadiusLg"] = snapshot.CornerRadiusTokens.Lg;
resources["DesignCornerRadiusXl"] = snapshot.CornerRadiusTokens.Xl;
resources["DesignCornerRadiusIsland"] = snapshot.CornerRadiusTokens.Island;
resources["DesignCornerRadiusComponent"] = snapshot.CornerRadiusTokens.Component;
}
public AppearanceMaterialSurface GetMaterialSurface(MaterialSurfaceRole role)

View File

@@ -262,7 +262,12 @@ public static class DesktopComponentEditorRegistryFactory
nameof(ComponentSettingsSnapshot.Stcn24ForumAutoRefreshIntervalMinutes),
nameof(ComponentSettingsSnapshot.Stcn24ForumSourceType)
]
}))
})),
[BuiltInComponentIds.DesktopZhiJiaoHub] = new(
BuiltInComponentIds.DesktopZhiJiaoHub,
context => new ZhiJiaoHubComponentEditor(context),
preferredWidth: 480d,
preferredHeight: 520d)
};
foreach (var componentId in GetBuiltInDesktopComponentIds(componentRegistry))

View File

@@ -5,6 +5,7 @@ using System.IO;
using System.Linq;
using System.Net.Http;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -14,7 +15,8 @@ namespace LanMountainDesktop.Services;
public sealed record GitHubReleaseAsset(
string Name,
string BrowserDownloadUrl,
long SizeBytes);
long SizeBytes,
string? Sha256 = null);
public sealed record GitHubReleaseInfo(
string TagName,
@@ -31,12 +33,16 @@ public sealed record UpdateCheckResult(
string LatestVersionText,
GitHubReleaseInfo? Release,
GitHubReleaseAsset? PreferredAsset,
string? ErrorMessage);
string? ErrorMessage,
bool ForceMode = false);
public sealed record UpdateDownloadResult(
bool Success,
string? FilePath,
string? ErrorMessage);
string? ErrorMessage,
bool HashVerified = false,
string? ExpectedHash = null,
string? ActualHash = null);
public sealed class GitHubReleaseUpdateService : IDisposable
{
@@ -101,7 +107,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable
bool includePrerelease,
CancellationToken cancellationToken = default)
{
var normalizedCurrentVersionText = NormalizeVersion(currentVersion).ToString(3);
var normalizedCurrentVersion = NormalizeVersion(currentVersion);
var normalizedCurrentVersionText = FormatVersionText(normalizedCurrentVersion);
if (string.IsNullOrWhiteSpace(_owner) || string.IsNullOrWhiteSpace(_repo))
{
@@ -135,7 +142,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
var hasParsedTagVersion = TryParseVersion(release.TagName, out var parsedTagVersion);
var latestVersionText = hasParsedTagVersion && parsedTagVersion is not null
? parsedTagVersion.ToString(3)
? FormatVersionText(parsedTagVersion)
: release.TagName;
var isUpdateAvailable = parsedTagVersion is not null && parsedTagVersion > currentVersion;
@@ -169,6 +176,81 @@ public sealed class GitHubReleaseUpdateService : IDisposable
}
}
public async Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
Version currentVersion,
bool includePrerelease,
CancellationToken cancellationToken = default)
{
var normalizedCurrentVersion = NormalizeVersion(currentVersion);
var normalizedCurrentVersionText = FormatVersionText(normalizedCurrentVersion);
if (string.IsNullOrWhiteSpace(_owner) || string.IsNullOrWhiteSpace(_repo))
{
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: "-",
Release: null,
PreferredAsset: null,
ErrorMessage: "Repository information is not configured.",
ForceMode: true);
}
try
{
var release = includePrerelease
? await GetLatestReleaseIncludingPrereleaseAsync(cancellationToken)
: await GetLatestStableReleaseAsync(cancellationToken);
if (release is null)
{
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: "-",
Release: null,
PreferredAsset: null,
ErrorMessage: "No release data was returned from GitHub.",
ForceMode: true);
}
var hasParsedTagVersion = TryParseVersion(release.TagName, out var parsedTagVersion);
var latestVersionText = hasParsedTagVersion && parsedTagVersion is not null
? FormatVersionText(parsedTagVersion)
: release.TagName;
var preferredAsset = SelectPreferredInstallerAsset(release.Assets);
return new UpdateCheckResult(
Success: true,
IsUpdateAvailable: true,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: latestVersionText,
Release: release,
PreferredAsset: preferredAsset,
ErrorMessage: null,
ForceMode: true);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: "-",
Release: null,
PreferredAsset: null,
ErrorMessage: ex.Message,
ForceMode: true);
}
}
public async Task<UpdateDownloadResult> DownloadAssetAsync(
GitHubReleaseAsset asset,
string destinationFilePath,
@@ -206,9 +288,128 @@ public sealed class GitHubReleaseUpdateService : IDisposable
progressAdapter,
cancellationToken);
return result.Success
? new UpdateDownloadResult(true, result.FilePath ?? destinationFilePath, null)
: new UpdateDownloadResult(false, null, result.ErrorMessage);
if (!result.Success)
{
return new UpdateDownloadResult(false, null, result.ErrorMessage);
}
var filePath = result.FilePath ?? destinationFilePath;
var (hashVerified, actualHash) = await VerifyFileHashAsync(filePath, asset.Sha256, cancellationToken);
if (!string.IsNullOrEmpty(asset.Sha256) && !hashVerified)
{
return new UpdateDownloadResult(
false,
filePath,
$"Hash verification failed. Expected: {asset.Sha256}, Actual: {actualHash}",
false,
asset.Sha256,
actualHash);
}
return new UpdateDownloadResult(true, filePath, null, hashVerified, asset.Sha256, actualHash);
}
public async Task<UpdateDownloadResult> RedownloadAssetAsync(
GitHubReleaseAsset asset,
string destinationFilePath,
string downloadSource,
int maxParallelSegments,
IProgress<double>? progress = null,
CancellationToken cancellationToken = default)
{
if (File.Exists(destinationFilePath))
{
try
{
File.Delete(destinationFilePath);
}
catch (Exception ex)
{
AppLogger.Warn("Update", $"Failed to delete existing file for redownload: {destinationFilePath}", ex);
}
}
var partFile = destinationFilePath + ".part";
if (File.Exists(partFile))
{
try
{
File.Delete(partFile);
}
catch (Exception ex)
{
AppLogger.Warn("Update", $"Failed to delete part file for redownload: {partFile}", ex);
}
}
var packageFile = destinationFilePath + ".download";
if (File.Exists(packageFile))
{
try
{
File.Delete(packageFile);
}
catch (Exception ex)
{
AppLogger.Warn("Update", $"Failed to delete package file for redownload: {packageFile}", ex);
}
}
return await DownloadAssetAsync(asset, destinationFilePath, downloadSource, maxParallelSegments, progress, cancellationToken);
}
public static async Task<(bool Success, string? Hash)> VerifyFileHashAsync(
string filePath,
string? expectedHash,
CancellationToken cancellationToken = default)
{
if (!File.Exists(filePath))
{
return (false, null);
}
if (string.IsNullOrWhiteSpace(expectedHash))
{
var computedHash = await ComputeFileSha256Async(filePath, cancellationToken);
return (true, computedHash);
}
var actualHash = await ComputeFileSha256Async(filePath, cancellationToken);
var verified = string.Equals(
expectedHash?.Trim().ToLowerInvariant(),
actualHash?.Trim().ToLowerInvariant(),
StringComparison.OrdinalIgnoreCase);
return (verified, actualHash);
}
public static async Task<string?> ComputeFileSha256Async(string filePath, CancellationToken cancellationToken = default)
{
if (!File.Exists(filePath))
{
return null;
}
try
{
using var stream = new FileStream(
filePath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
81920,
FileOptions.Asynchronous | FileOptions.SequentialScan);
using var sha256 = SHA256.Create();
var hashBytes = await sha256.ComputeHashAsync(stream, cancellationToken);
return Convert.ToHexString(hashBytes).ToLowerInvariant();
}
catch (Exception ex)
{
AppLogger.Warn("Update", $"Failed to compute SHA256 for file: {filePath}", ex);
return null;
}
}
public async Task<GitHubReleaseInfo?> GetReleaseByTagAsync(
@@ -343,13 +544,102 @@ public sealed class GitHubReleaseUpdateService : IDisposable
continue;
}
assets.Add(new GitHubReleaseAsset(assetName, browserDownloadUrl, sizeBytes));
assets.Add(new GitHubReleaseAsset(assetName, browserDownloadUrl, sizeBytes, null));
}
}
var sha256Map = BuildSha256MapFromAssets(assets, element);
if (sha256Map.Count > 0)
{
assets = assets.Select(a =>
sha256Map.TryGetValue(a.Name, out var hash)
? a with { Sha256 = hash }
: a).ToList();
}
return new GitHubReleaseInfo(tagName, name, isPrerelease, isDraft, publishedAt, assets);
}
private static Dictionary<string, string> BuildSha256MapFromAssets(List<GitHubReleaseAsset> assets, JsonElement releaseElement)
{
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var asset in assets)
{
if (asset.Name.EndsWith(".sha256", StringComparison.OrdinalIgnoreCase) ||
asset.Name.EndsWith(".sha256sum", StringComparison.OrdinalIgnoreCase))
{
var baseName = asset.Name[..asset.Name.LastIndexOf('.')];
var targetAsset = assets.FirstOrDefault(a =>
a.Name.Equals(baseName, StringComparison.OrdinalIgnoreCase) ||
a.Name.StartsWith(baseName + ".", StringComparison.OrdinalIgnoreCase));
if (targetAsset is not null && !map.ContainsKey(targetAsset.Name))
{
map[targetAsset.Name] = asset.BrowserDownloadUrl;
}
}
}
if (releaseElement.TryGetProperty("body", out var bodyNode) &&
bodyNode.ValueKind == JsonValueKind.String)
{
var body = bodyNode.GetString() ?? string.Empty;
ParseSha256FromBody(body, assets, map);
}
return map;
}
private static void ParseSha256FromBody(string body, List<GitHubReleaseAsset> assets, Dictionary<string, string> map)
{
var lines = body.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
var trimmedLine = line.Trim();
if (string.IsNullOrEmpty(trimmedLine) || trimmedLine.StartsWith("#"))
{
continue;
}
var parts = trimmedLine.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 2)
{
var hash = parts[0];
var fileName = parts[1];
if (hash.Length == 64 && IsHexString(hash))
{
foreach (var asset in assets)
{
if (asset.Name.Equals(fileName, StringComparison.OrdinalIgnoreCase) ||
fileName.Equals("*" + asset.Name, StringComparison.OrdinalIgnoreCase))
{
if (!map.ContainsKey(asset.Name))
{
map[asset.Name] = hash.ToLowerInvariant();
}
break;
}
}
}
}
}
}
private static bool IsHexString(string value)
{
foreach (var c in value)
{
if (!Uri.IsHexDigit(c))
{
return false;
}
}
return true;
}
private static GitHubReleaseAsset? SelectPreferredInstallerAsset(IReadOnlyList<GitHubReleaseAsset> assets)
{
if (assets is null || assets.Count == 0 || !OperatingSystem.IsWindows())
@@ -452,8 +742,18 @@ public sealed class GitHubReleaseUpdateService : IDisposable
{
var major = Math.Max(0, version.Major);
var minor = Math.Max(0, version.Minor);
var build = Math.Max(0, version.Build);
return new Version(major, minor, build);
var build = Math.Max(0, version.Build >= 0 ? version.Build : 0);
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)

View File

@@ -52,6 +52,35 @@ public sealed record ExchangeRateQuery(
string? TargetCurrency = null,
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>(
bool Success,
T? Data,
@@ -285,6 +314,14 @@ public sealed record RecommendationApiOptions
public int DefaultBaiduHotSearchCount { 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
@@ -325,5 +362,37 @@ public interface IRecommendationInfoService
ExchangeRateQuery query,
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();
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
@@ -53,6 +53,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
Dictionary<string, decimal> Rates,
DateTimeOffset ExpireAt,
DateTimeOffset FetchedAt);
private sealed record ZhiJiaoHubCacheEntry(ZhiJiaoHubSnapshot Snapshot, DateTimeOffset ExpireAt);
private sealed record ArtworkCandidate(
string Title,
string? Artist,
@@ -80,6 +81,8 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, ExchangeRateTableCacheEntry> _exchangeRateCacheByBaseCurrency =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, ZhiJiaoHubCacheEntry> _zhiJiaoHubCacheBySource =
new(StringComparer.OrdinalIgnoreCase);
private int _dailyNewsRotationCursor;
static RecommendationDataService()
@@ -94,7 +97,15 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
_options = options ?? new RecommendationApiOptions();
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
};
@@ -128,6 +139,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
_dailyWordCache = null;
_stcn24ForumPostsCacheBySource.Clear();
_exchangeRateCacheByBaseCurrency.Clear();
_zhiJiaoHubCacheBySource.Clear();
}
}
@@ -3194,4 +3206,368 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
? text
: $"{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);
}
}

View File

@@ -67,7 +67,8 @@ public sealed record UpdateSettingsState(
string? PendingUpdateInstallerPath,
string? PendingUpdateVersion,
long? PendingUpdatePublishedAtUtcMs,
long? LastUpdateCheckUtcMs);
long? LastUpdateCheckUtcMs,
string? PendingUpdateSha256);
public sealed record PluginManagementSettingsState(IReadOnlyList<string> DisabledPluginIds);
public enum PluginPackageSourceKind
{
@@ -175,14 +176,6 @@ public sealed record PluginCatalogItemInfo(
public IReadOnlyList<PluginCatalogSharedContractInfo> SharedContracts => Manifest.SharedContracts;
public IReadOnlyList<PluginCatalogDependencyInfo> Dependencies =>
Manifest.SharedContracts
.Select(contract => new PluginCatalogDependencyInfo(
contract.Id,
contract.Version,
contract.AssemblyName))
.ToArray();
public DateTimeOffset PublishedAt => Publication.PublishedAt;
public DateTimeOffset UpdatedAt => Publication.UpdatedAt;
@@ -192,82 +185,6 @@ public sealed record PluginCatalogItemInfo(
public string ReleaseAssetName => Publication.ReleaseAssetName;
public string ReleaseNotes => Repository.ReleaseNotes;
public static implicit operator PluginMarketPluginInfo(PluginCatalogItemInfo item)
{
return new PluginMarketPluginInfo(
item.Id,
item.Name,
item.Description,
item.Author,
item.Version,
item.ApiVersion,
item.MinHostVersion,
item.DownloadUrl,
item.ReleaseTag,
item.ReleaseAssetName,
item.IconUrl,
item.ReadmeUrl,
item.HomepageUrl,
item.RepositoryUrl,
item.Tags.ToArray(),
item.Dependencies.Select(dependency => new PluginMarketDependencyInfo(
dependency.Id,
dependency.Version,
dependency.AssemblyName)).ToArray(),
item.PublishedAt,
item.UpdatedAt);
}
public static implicit operator PluginCatalogItemInfo(PluginMarketPluginInfo plugin)
{
return new PluginCatalogItemInfo(
new PluginCatalogManifestInfo(
plugin.Id,
plugin.Name,
plugin.Description,
plugin.Author,
plugin.Version,
plugin.ApiVersion,
string.Empty,
plugin.Dependencies
.Select(dependency => new PluginCatalogSharedContractInfo(
dependency.Id,
dependency.Version,
dependency.AssemblyName))
.ToArray()),
new PluginCatalogCompatibilityInfo(
plugin.MinHostVersion,
plugin.ApiVersion),
new PluginCatalogRepositoryInfo(
plugin.IconUrl,
plugin.RepositoryUrl,
plugin.ReadmeUrl,
plugin.HomepageUrl,
plugin.RepositoryUrl,
plugin.Tags,
string.Empty),
new PluginCatalogPublicationInfo(
plugin.ReleaseTag,
plugin.ReleaseAssetName,
plugin.PublishedAt,
plugin.UpdatedAt,
0,
string.Empty,
null),
string.IsNullOrWhiteSpace(plugin.DownloadUrl)
? []
: [
new PluginPackageSourceInfo(
string.IsNullOrWhiteSpace(plugin.ReleaseTag)
? PluginPackageSourceKind.RawFallback
: PluginPackageSourceKind.ReleaseAsset,
plugin.DownloadUrl,
string.Empty,
0)
],
[]);
}
}
public sealed record PluginCatalogIndexResult(
@@ -277,19 +194,7 @@ public sealed record PluginCatalogIndexResult(
string? Source,
string? SourceLocation,
string? WarningMessage,
string? ErrorMessage)
{
public static implicit operator PluginMarketIndexResult(PluginCatalogIndexResult result)
{
return new PluginMarketIndexResult(
result.Success,
result.Plugins.Select(plugin => (PluginMarketPluginInfo)plugin).ToArray(),
result.Source,
result.SourceLocation,
result.WarningMessage,
result.ErrorMessage);
}
}
string? ErrorMessage);
public sealed record PluginInstallDiagnostic(
string Code,
@@ -302,73 +207,6 @@ public sealed record PluginCatalogInstallResult(
string? PluginName,
PluginManifest? InstalledManifest,
IReadOnlyList<PluginInstallDiagnostic> Diagnostics,
string? ErrorMessage)
{
public static implicit operator PluginMarketInstallResult(PluginCatalogInstallResult result)
{
return new PluginMarketInstallResult(
result.Success,
result.PluginId,
result.PluginName,
result.ErrorMessage);
}
}
public sealed record PluginCatalogDependencyInfo(
string Id,
string Version,
string AssemblyName)
{
public static implicit operator PluginMarketDependencyInfo(PluginCatalogDependencyInfo dependency)
{
return new PluginMarketDependencyInfo(
dependency.Id,
dependency.Version,
dependency.AssemblyName);
}
}
[Obsolete("Use PluginCatalogSharedContractInfo and PluginCatalogItemInfo instead.")]
public sealed record PluginMarketDependencyInfo(
string Id,
string Version,
string AssemblyName);
[Obsolete("Use PluginCatalogItemInfo instead.")]
public sealed record PluginMarketPluginInfo(
string Id,
string Name,
string Description,
string Author,
string Version,
string ApiVersion,
string MinHostVersion,
string DownloadUrl,
string ReleaseTag,
string ReleaseAssetName,
string IconUrl,
string ReadmeUrl,
string HomepageUrl,
string RepositoryUrl,
IReadOnlyList<string> Tags,
IReadOnlyList<PluginMarketDependencyInfo> Dependencies,
DateTimeOffset PublishedAt,
DateTimeOffset UpdatedAt);
[Obsolete("Use PluginCatalogIndexResult instead.")]
public sealed record PluginMarketIndexResult(
bool Success,
IReadOnlyList<PluginMarketPluginInfo> Plugins,
string? Source,
string? SourceLocation,
string? WarningMessage,
string? ErrorMessage);
[Obsolete("Use PluginCatalogInstallResult instead.")]
public sealed record PluginMarketInstallResult(
bool Success,
string? PluginId,
string? PluginName,
string? ErrorMessage);
public interface IPluginCatalogSourceProvider
@@ -488,6 +326,7 @@ public interface IUpdateSettingsService
UpdateSettingsState Get();
void Save(UpdateSettingsState state);
Task<UpdateCheckResult> CheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default);
Task<UpdateCheckResult> ForceCheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default);
Task<UpdateDownloadResult> DownloadAssetAsync(
GitHubReleaseAsset asset,
string destinationFilePath,
@@ -495,6 +334,13 @@ public interface IUpdateSettingsService
int maxParallelSegments,
IProgress<double>? progress = null,
CancellationToken cancellationToken = default);
Task<UpdateDownloadResult> RedownloadAssetAsync(
GitHubReleaseAsset asset,
string destinationFilePath,
string downloadSource,
int maxParallelSegments,
IProgress<double>? progress = null,
CancellationToken cancellationToken = default);
}
public interface ILauncherCatalogService
@@ -523,13 +369,6 @@ public interface IPluginCatalogSettingsService : IPluginCatalogSourceProvider
Task<PluginCatalogInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default);
}
[Obsolete("Use IPluginCatalogSettingsService instead.")]
public interface IPluginMarketSettingsService : IPluginCatalogSettingsService
{
Task<PluginMarketIndexResult> LoadIndexAsync(CancellationToken cancellationToken = default);
new Task<PluginMarketInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default);
}
public interface IApplicationInfoService
{
string GetAppVersionText();
@@ -554,8 +393,6 @@ public interface ISettingsFacadeService
ILauncherPolicyService LauncherPolicy { get; }
IPluginManagementSettingsService PluginManagement { get; }
IPluginCatalogSettingsService PluginCatalog { get; }
[Obsolete("Use PluginCatalog instead.")]
IPluginMarketSettingsService PluginMarket { get; }
IApplicationInfoService ApplicationInfo { get; }
}

View File

@@ -678,7 +678,8 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
snapshot.PendingUpdateInstallerPath,
snapshot.PendingUpdateVersion,
snapshot.PendingUpdatePublishedAtUtcMs,
snapshot.LastUpdateCheckUtcMs);
snapshot.LastUpdateCheckUtcMs,
snapshot.PendingUpdateSha256);
}
public void Save(UpdateSettingsState state)
@@ -707,6 +708,9 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
snapshot.LastUpdateCheckUtcMs = state.LastUpdateCheckUtcMs is > 0
? state.LastUpdateCheckUtcMs
: null;
snapshot.PendingUpdateSha256 = string.IsNullOrWhiteSpace(state.PendingUpdateSha256)
? null
: state.PendingUpdateSha256.Trim().ToLowerInvariant();
_settingsService.SaveSnapshot(
SettingsScope.App,
snapshot,
@@ -721,7 +725,8 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
nameof(AppSettingsSnapshot.PendingUpdateInstallerPath),
nameof(AppSettingsSnapshot.PendingUpdateVersion),
nameof(AppSettingsSnapshot.PendingUpdatePublishedAtUtcMs),
nameof(AppSettingsSnapshot.LastUpdateCheckUtcMs)
nameof(AppSettingsSnapshot.LastUpdateCheckUtcMs),
nameof(AppSettingsSnapshot.PendingUpdateSha256)
]);
}
@@ -733,6 +738,14 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
return _releaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
}
public Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
Version currentVersion,
bool includePrerelease,
CancellationToken cancellationToken = default)
{
return _releaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
}
public Task<UpdateDownloadResult> DownloadAssetAsync(
GitHubReleaseAsset asset,
string destinationFilePath,
@@ -750,6 +763,23 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
cancellationToken);
}
public Task<UpdateDownloadResult> RedownloadAssetAsync(
GitHubReleaseAsset asset,
string destinationFilePath,
string downloadSource,
int maxParallelSegments,
IProgress<double>? progress = null,
CancellationToken cancellationToken = default)
{
return _releaseUpdateService.RedownloadAssetAsync(
asset,
destinationFilePath,
downloadSource,
maxParallelSegments,
progress,
cancellationToken);
}
public void Dispose()
{
_releaseUpdateService.Dispose();
@@ -829,14 +859,14 @@ internal sealed class PluginManagementSettingsService : IPluginManagementSetting
}
}
internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService, IDisposable
internal sealed class PluginCatalogSettingsService : IPluginCatalogSettingsService, IDisposable
{
private PluginRuntimeService? _pluginRuntimeService;
private AirAppMarketIndexService _indexService;
private AirAppMarketInstallService? _installService;
private readonly Dictionary<string, AirAppMarketPluginEntry> _cachedPlugins = new(StringComparer.OrdinalIgnoreCase);
public PluginMarketSettingsService(PluginRuntimeService? pluginRuntimeService)
public PluginCatalogSettingsService(PluginRuntimeService? pluginRuntimeService)
{
_pluginRuntimeService = pluginRuntimeService;
@@ -875,11 +905,6 @@ internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService
return LoadCatalogCoreAsync(cancellationToken);
}
async Task<PluginMarketIndexResult> IPluginMarketSettingsService.LoadIndexAsync(CancellationToken cancellationToken)
{
return await LoadCatalogCoreAsync(cancellationToken).ConfigureAwait(false);
}
public Task<PluginCatalogInstallResult> InstallAsync(
string pluginId,
CancellationToken cancellationToken = default)
@@ -887,13 +912,6 @@ internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService
return InstallCatalogCoreAsync(pluginId, cancellationToken);
}
async Task<PluginMarketInstallResult> IPluginMarketSettingsService.InstallAsync(
string pluginId,
CancellationToken cancellationToken)
{
return await InstallCatalogCoreAsync(pluginId, cancellationToken).ConfigureAwait(false);
}
private async Task<PluginCatalogIndexResult> LoadCatalogCoreAsync(CancellationToken cancellationToken = default)
{
var result = await _indexService.LoadAsync(cancellationToken).ConfigureAwait(false);
@@ -1055,23 +1073,25 @@ internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService
private static IReadOnlyList<PluginPackageSourceInfo> BuildPackageSources(AirAppMarketPluginEntry entry)
{
if (string.IsNullOrWhiteSpace(entry.DownloadUrl))
var sources = entry.GetPackageSourcesInInstallOrder();
if (sources.Count == 0)
{
return [];
}
var sourceKind = entry.HasReleaseDownloadMetadata
? PluginPackageSourceKind.ReleaseAsset
: PluginPackageSourceKind.RawFallback;
return
[
new PluginPackageSourceInfo(
sourceKind,
entry.DownloadUrl,
return sources
.Select(source => new PluginPackageSourceInfo(
source.SourceKind switch
{
LanMountainDesktop.Services.PluginMarket.PluginPackageSourceKind.ReleaseAsset => PluginPackageSourceKind.ReleaseAsset,
LanMountainDesktop.Services.PluginMarket.PluginPackageSourceKind.RawFallback => PluginPackageSourceKind.RawFallback,
LanMountainDesktop.Services.PluginMarket.PluginPackageSourceKind.WorkspaceLocal => PluginPackageSourceKind.WorkspaceLocal,
_ => PluginPackageSourceKind.RawFallback
},
source.Url,
entry.Sha256,
entry.PackageSizeBytes)
];
entry.PackageSizeBytes))
.ToArray();
}
private static IReadOnlyList<PluginCatalogSourceInfo> BuildCatalogSources(
@@ -1165,7 +1185,7 @@ internal sealed class ApplicationInfoService : IApplicationInfoService
internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposable
{
private readonly UpdateSettingsService _updateSettingsService;
private readonly PluginMarketSettingsService _pluginMarketSettingsService;
private readonly PluginCatalogSettingsService _pluginCatalogSettingsService;
private readonly PluginManagementSettingsService _pluginManagementSettingsService;
private readonly WeatherSettingsService _weatherSettingsService;
@@ -1188,9 +1208,8 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl
LauncherPolicy = new LauncherPolicyService();
_pluginManagementSettingsService = new PluginManagementSettingsService(Settings, pluginRuntimeService);
PluginManagement = _pluginManagementSettingsService;
_pluginMarketSettingsService = new PluginMarketSettingsService(pluginRuntimeService);
PluginCatalog = _pluginMarketSettingsService;
PluginMarket = _pluginMarketSettingsService;
_pluginCatalogSettingsService = new PluginCatalogSettingsService(pluginRuntimeService);
PluginCatalog = _pluginCatalogSettingsService;
ApplicationInfo = new ApplicationInfoService();
}
@@ -1224,20 +1243,18 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl
public IPluginCatalogSettingsService PluginCatalog { get; }
public IPluginMarketSettingsService PluginMarket { get; }
public IApplicationInfoService ApplicationInfo { get; }
public void BindPluginRuntime(PluginRuntimeService? pluginRuntimeService)
{
_pluginManagementSettingsService.SetPluginRuntime(pluginRuntimeService);
_pluginMarketSettingsService.SetPluginRuntime(pluginRuntimeService);
_pluginCatalogSettingsService.SetPluginRuntime(pluginRuntimeService);
}
public void Dispose()
{
_weatherSettingsService.Dispose();
_updateSettingsService.Dispose();
_pluginMarketSettingsService.Dispose();
_pluginCatalogSettingsService.Dispose();
}
}

View File

@@ -13,7 +13,15 @@ namespace LanMountainDesktop.Services;
public sealed record UpdatePendingInfo(
string InstallerPath,
string VersionText,
DateTimeOffset? PublishedAt);
DateTimeOffset? PublishedAt,
string? Sha256 = null);
public sealed record UpdateVerifyResult(
bool Success,
bool HashMatched,
string? ExpectedHash,
string? ActualHash,
string? ErrorMessage);
public sealed record UpdateInstallerLaunchResult(
bool Success,
@@ -56,6 +64,7 @@ public sealed class UpdateWorkflowService
public async Task<UpdateCheckResult> CheckForUpdatesAsync(
Version currentVersion,
bool isForce = false,
CancellationToken cancellationToken = default)
{
var state = _settingsFacade.Update.Get();
@@ -64,10 +73,15 @@ public sealed class UpdateWorkflowService
UpdateSettingsValues.ChannelPreview,
StringComparison.OrdinalIgnoreCase);
var result = await _settingsFacade.Update.CheckForUpdatesAsync(
currentVersion,
includePrerelease,
cancellationToken);
var result = isForce
? await _settingsFacade.Update.ForceCheckForUpdatesAsync(
currentVersion,
includePrerelease,
cancellationToken)
: await _settingsFacade.Update.CheckForUpdatesAsync(
currentVersion,
includePrerelease,
cancellationToken);
SaveState(state with
{
@@ -77,6 +91,13 @@ public sealed class UpdateWorkflowService
return result;
}
public async Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
Version currentVersion,
CancellationToken cancellationToken = default)
{
return await CheckForUpdatesAsync(currentVersion, true, cancellationToken);
}
public async Task<UpdateDownloadResult> DownloadReleaseAsync(
UpdateCheckResult checkResult,
IProgress<double>? progress = null,
@@ -95,7 +116,13 @@ public sealed class UpdateWorkflowService
string.Equals(existingPending.VersionText, checkResult.LatestVersionText, StringComparison.OrdinalIgnoreCase) &&
File.Exists(existingPending.InstallerPath))
{
return new UpdateDownloadResult(true, existingPending.InstallerPath, null);
var verifyResult = await VerifyPendingUpdateAsync();
if (verifyResult.Success)
{
return new UpdateDownloadResult(true, existingPending.InstallerPath, null, verifyResult.HashMatched, verifyResult.ExpectedHash, verifyResult.ActualHash);
}
AppLogger.Warn("UpdateWorkflow", $"Existing installer hash verification failed, will redownload. Expected: {verifyResult.ExpectedHash}, Actual: {verifyResult.ActualHash}");
}
Directory.CreateDirectory(_updatesDirectory);
@@ -119,13 +146,111 @@ public sealed class UpdateWorkflowService
PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue
? null
: checkResult.Release.PublishedAt.ToUnixTimeMilliseconds(),
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
PendingUpdateSha256 = result.ActualHash
});
}
return result;
}
public async Task<UpdateDownloadResult> RedownloadReleaseAsync(
UpdateCheckResult checkResult,
IProgress<double>? progress = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(checkResult);
if (!checkResult.Success || !checkResult.IsUpdateAvailable || checkResult.Release is null || checkResult.PreferredAsset is null)
{
return new UpdateDownloadResult(false, null, "No compatible update asset is available.");
}
var state = _settingsFacade.Update.Get();
var existingPending = GetPendingUpdate(state);
if (existingPending is not null && File.Exists(existingPending.InstallerPath))
{
try
{
File.Delete(existingPending.InstallerPath);
AppLogger.Info("UpdateWorkflow", $"Deleted existing installer for redownload: {existingPending.InstallerPath}");
}
catch (Exception ex)
{
AppLogger.Warn("UpdateWorkflow", $"Failed to delete existing installer: {existingPending.InstallerPath}", ex);
}
}
ClearPendingUpdate();
Directory.CreateDirectory(_updatesDirectory);
var fileName = SanitizeFileName(checkResult.PreferredAsset.Name);
var destinationPath = Path.Combine(_updatesDirectory, fileName);
state = _settingsFacade.Update.Get();
var result = await _settingsFacade.Update.DownloadAssetAsync(
checkResult.PreferredAsset,
destinationPath,
state.UpdateDownloadSource,
state.UpdateDownloadThreads,
progress,
cancellationToken);
if (result.Success)
{
SaveState(state with
{
PendingUpdateInstallerPath = result.FilePath ?? destinationPath,
PendingUpdateVersion = checkResult.LatestVersionText,
PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue
? null
: checkResult.Release.PublishedAt.ToUnixTimeMilliseconds(),
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
PendingUpdateSha256 = result.ActualHash
});
}
return result;
}
public async Task<UpdateVerifyResult> VerifyPendingUpdateAsync()
{
var state = _settingsFacade.Update.Get();
var pending = GetPendingUpdate(state);
if (pending is null)
{
return new UpdateVerifyResult(false, false, null, null, "No pending update available.");
}
if (!File.Exists(pending.InstallerPath))
{
return new UpdateVerifyResult(false, false, null, null, "Installer file does not exist.");
}
var expectedHash = pending.Sha256;
var actualHash = await GitHubReleaseUpdateService.ComputeFileSha256Async(pending.InstallerPath);
if (string.IsNullOrEmpty(expectedHash))
{
return new UpdateVerifyResult(true, true, null, actualHash, null);
}
var hashMatched = string.Equals(
expectedHash?.Trim().ToLowerInvariant(),
actualHash?.Trim().ToLowerInvariant(),
StringComparison.OrdinalIgnoreCase);
return new UpdateVerifyResult(
hashMatched,
hashMatched,
expectedHash,
actualHash,
hashMatched ? null : $"Hash mismatch. Expected: {expectedHash}, Actual: {actualHash}");
}
public async Task AutoCheckIfEnabledAsync(
Version currentVersion,
CancellationToken cancellationToken = default)
@@ -135,7 +260,7 @@ public sealed class UpdateWorkflowService
try
{
// Always check for updates on startup (removed AutoCheckUpdates check)
var result = await CheckForUpdatesAsync(currentVersion, cancellationToken);
var result = await CheckForUpdatesAsync(currentVersion, isForce: false, cancellationToken);
if (!result.Success || !result.IsUpdateAvailable || result.PreferredAsset is null)
{
return;
@@ -193,7 +318,8 @@ public sealed class UpdateWorkflowService
{
PendingUpdateInstallerPath = null,
PendingUpdateVersion = null,
PendingUpdatePublishedAtUtcMs = null
PendingUpdatePublishedAtUtcMs = null,
PendingUpdateSha256 = null
});
}
@@ -262,7 +388,8 @@ public sealed class UpdateWorkflowService
return new UpdatePendingInfo(
installerPath,
string.IsNullOrWhiteSpace(state.PendingUpdateVersion) ? Path.GetFileNameWithoutExtension(installerPath) : state.PendingUpdateVersion,
publishedAt);
publishedAt,
state.PendingUpdateSha256);
}
private void SaveState(UpdateSettingsState state)

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

View File

@@ -18,7 +18,7 @@
<Setter Property="Background" Value="{DynamicResource EditorSelectFieldBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource EditorSelectOutlineBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusComponent}" />
<Setter Property="Padding" Value="16,14,12,14" />
<Setter Property="MinHeight" Value="56" />
<Setter Property="FontSize" Value="14" />
@@ -40,7 +40,7 @@
<Setter Property="Background" Value="{DynamicResource EditorSelectFieldBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource EditorSelectOutlineBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusComponent}" />
<Setter Property="Padding" Value="16,14,12,14" />
<Setter Property="MinHeight" Value="56" />
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorPrimaryTextBrush}" />

View File

@@ -11,6 +11,7 @@
<CornerRadius x:Key="DesignCornerRadiusSm">14</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusXs">12</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusIsland">36</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusComponent">18</CornerRadius>
</Styles.Resources>
<Style Selector="TextBlock">

View File

@@ -267,7 +267,7 @@
<Setter Property="Background" Value="{DynamicResource AdaptiveAccentLightBrush}" />
</Style>
<Style Selector="Button.plugin-market-row-button">
<Style Selector="Button.plugin-catalog-row-button">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="0" />
@@ -275,11 +275,11 @@
<Setter Property="HorizontalAlignment" Value="Stretch" />
</Style>
<Style Selector="Button.plugin-market-row-button:pointerover">
<Style Selector="Button.plugin-catalog-row-button:pointerover">
<Setter Property="Background" Value="Transparent" />
</Style>
<Style Selector="Button.plugin-market-icon-button">
<Style Selector="Button.plugin-catalog-icon-button">
<Setter Property="Width" Value="36" />
<Setter Property="Height" Value="36" />
<Setter Property="Padding" Value="0" />
@@ -290,11 +290,11 @@
<Setter Property="BorderThickness" Value="1" />
</Style>
<Style Selector="Button.plugin-market-icon-button:pointerover">
<Style Selector="Button.plugin-catalog-icon-button:pointerover">
<Setter Property="Background" Value="{DynamicResource AdaptiveSurfaceRaisedBrush}" />
</Style>
<Style Selector="Button.plugin-market-icon-button fi|SymbolIcon">
<Style Selector="Button.plugin-catalog-icon-button fi|SymbolIcon">
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
<Setter Property="FontSize" Value="16" />
</Style>

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
@@ -15,7 +15,7 @@ using LanMountainDesktop.Services.Settings;
namespace LanMountainDesktop.ViewModels;
public enum PluginMarketPrimaryActionState
public enum PluginCatalogPrimaryActionState
{
Install,
Update,
@@ -24,13 +24,13 @@ public enum PluginMarketPrimaryActionState
Incompatible
}
public sealed partial class PluginMarketItemViewModel : ViewModelBase
public sealed partial class PluginCatalogItemViewModel : ViewModelBase
{
private readonly LocalizationService _localizationService;
private readonly string _languageCode;
private bool _isLoadingIcon;
public PluginMarketItemViewModel(
public PluginCatalogItemViewModel(
PluginCatalogItemInfo plugin,
LocalizationService localizationService,
string languageCode)
@@ -104,7 +104,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
public bool HasIcon => IconBitmap is not null;
public PluginMarketPrimaryActionState ActionState { get; private set; }
public PluginCatalogPrimaryActionState ActionState { get; private set; }
partial void OnIconBitmapChanged(Bitmap? value)
{
@@ -164,7 +164,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
{
if (IsInstalling)
{
ActionState = IsUpdateAvailable ? PluginMarketPrimaryActionState.Update : PluginMarketPrimaryActionState.Install;
ActionState = IsUpdateAvailable ? PluginCatalogPrimaryActionState.Update : PluginCatalogPrimaryActionState.Install;
ActionSymbol = Symbol.ArrowClockwise;
ActionTooltip = L("market.button.installing", "Installing...");
IsActionEnabled = false;
@@ -173,7 +173,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
if (!IsCompatibleWithHost)
{
ActionState = PluginMarketPrimaryActionState.Incompatible;
ActionState = PluginCatalogPrimaryActionState.Incompatible;
ActionSymbol = Symbol.Warning;
ActionTooltip = string.Format(
CultureInfo.CurrentCulture,
@@ -185,7 +185,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
if (RequiresRestart)
{
ActionState = PluginMarketPrimaryActionState.RestartRequired;
ActionState = PluginCatalogPrimaryActionState.RestartRequired;
ActionSymbol = Symbol.ArrowClockwise;
ActionTooltip = L("market.button.restart", "Restart to apply");
IsActionEnabled = true;
@@ -194,7 +194,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
if (IsUpdateAvailable)
{
ActionState = PluginMarketPrimaryActionState.Update;
ActionState = PluginCatalogPrimaryActionState.Update;
ActionSymbol = Symbol.ArrowSync;
ActionTooltip = L("market.button.update", "Update");
IsActionEnabled = true;
@@ -203,14 +203,14 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
if (IsInstalled)
{
ActionState = PluginMarketPrimaryActionState.Installed;
ActionState = PluginCatalogPrimaryActionState.Installed;
ActionSymbol = Symbol.CheckmarkCircle;
ActionTooltip = L("market.button.installed", "Installed");
IsActionEnabled = false;
return;
}
ActionState = PluginMarketPrimaryActionState.Install;
ActionState = PluginCatalogPrimaryActionState.Install;
ActionSymbol = Symbol.ArrowDownload;
ActionTooltip = L("market.button.install", "Install");
IsActionEnabled = true;
@@ -242,20 +242,20 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
=> _localizationService.GetString(_languageCode, key, fallback);
}
public sealed partial class PluginMarketDetailViewModel : ViewModelBase
public sealed partial class PluginCatalogDetailViewModel : ViewModelBase
{
private readonly LocalizationService _localizationService;
private readonly string _languageCode;
private readonly AirAppMarketReadmeService _readmeService;
private readonly Func<PluginMarketItemViewModel, Task> _primaryActionAsync;
private readonly Func<PluginCatalogItemViewModel, Task> _primaryActionAsync;
private bool _isInitialized;
public PluginMarketDetailViewModel(
PluginMarketItemViewModel item,
public PluginCatalogDetailViewModel(
PluginCatalogItemViewModel item,
LocalizationService localizationService,
string languageCode,
AirAppMarketReadmeService readmeService,
Func<PluginMarketItemViewModel, Task> primaryActionAsync)
Func<PluginCatalogItemViewModel, Task> primaryActionAsync)
{
Item = item;
_localizationService = localizationService;
@@ -273,7 +273,7 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase
EmptyDependenciesText = L("market.detail.dependencies_empty", "No dependencies were declared by this plugin.");
}
public PluginMarketItemViewModel Item { get; }
public PluginCatalogItemViewModel Item { get; }
public ObservableCollection<PluginCatalogSharedContractInfo> Dependencies { get; }
@@ -375,7 +375,7 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase
=> _localizationService.GetString(_languageCode, key, fallback);
}
public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
public sealed partial class PluginCatalogSettingsPageViewModel : ViewModelBase
{
private readonly ISettingsFacadeService _settingsFacade;
private readonly IPluginCatalogSettingsService _pluginCatalog;
@@ -386,9 +386,9 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
private readonly Dictionary<string, InstalledPluginInfo> _installedPlugins = new(StringComparer.OrdinalIgnoreCase);
private readonly Version? _hostVersion;
private bool _isInitialized;
private bool _hasLoadedMarket;
private bool _hasLoadedCatalog;
public PluginMarketSettingsPageViewModel(
public PluginCatalogSettingsPageViewModel(
ISettingsFacadeService settingsFacade,
LocalizationService localizationService,
AirAppMarketIconService iconService,
@@ -402,16 +402,16 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
_languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode);
Version.TryParse(_settingsFacade.ApplicationInfo.GetAppVersionText(), out _hostVersion);
RefreshLocalizedText();
StatusMessage = L("market.status.loading", "Loading the official plugin market...");
StatusMessage = L("market.status.loading", "Loading the official plugin catalog...");
}
public event Action<string?>? RestartRequested;
public event Action<PluginMarketItemViewModel>? DetailsRequested;
public event Action<PluginCatalogItemViewModel>? DetailsRequested;
public ObservableCollection<PluginMarketItemViewModel> MarketPlugins { get; } = [];
public ObservableCollection<PluginCatalogItemViewModel> CatalogPlugins { get; } = [];
public ObservableCollection<PluginMarketItemViewModel> FilteredPlugins { get; } = [];
public ObservableCollection<PluginCatalogItemViewModel> FilteredPlugins { get; } = [];
[ObservableProperty]
private string _statusMessage = string.Empty;
@@ -454,9 +454,9 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
await RefreshAsync();
}
public PluginMarketDetailViewModel CreateDetailViewModel(PluginMarketItemViewModel item)
public PluginCatalogDetailViewModel CreateDetailViewModel(PluginCatalogItemViewModel item)
{
return new PluginMarketDetailViewModel(
return new PluginCatalogDetailViewModel(
item,
_localizationService,
_languageCode,
@@ -475,35 +475,35 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
try
{
IsBusy = true;
StatusMessage = L("market.status.loading", "Loading the official plugin market...");
StatusMessage = L("market.status.loading", "Loading the official plugin catalog...");
RefreshInstalledSnapshot();
var result = await _pluginCatalog.LoadCatalogAsync();
if (!result.Success)
{
_hasLoadedMarket = false;
MarketPlugins.Clear();
_hasLoadedCatalog = false;
CatalogPlugins.Clear();
FilteredPlugins.Clear();
ShowEmptyState = true;
EmptyStateText = string.IsNullOrWhiteSpace(result.ErrorMessage)
? L("market.list.empty", "The plugin market has not been loaded yet.")
? L("market.list.empty", "The plugin catalog has not been loaded yet.")
: result.ErrorMessage;
StatusMessage = string.IsNullOrWhiteSpace(result.ErrorMessage)
? L("market.status.load_failed_format", "Failed to load the plugin market: Unknown")
? L("market.status.load_failed_format", "Failed to load the plugin catalog: Unknown")
: string.Format(
CultureInfo.CurrentCulture,
L("market.status.load_failed_format", "Failed to load the plugin market: {0}"),
L("market.status.load_failed_format", "Failed to load the plugin catalog: {0}"),
result.ErrorMessage);
return;
}
_hasLoadedMarket = true;
MarketPlugins.Clear();
_hasLoadedCatalog = true;
CatalogPlugins.Clear();
foreach (var plugin in result.Plugins)
{
var item = new PluginMarketItemViewModel(plugin, _localizationService, _languageCode);
var item = new PluginCatalogItemViewModel(plugin, _localizationService, _languageCode);
item.ApplyInstallState(ResolveInstalledPlugin(plugin.Id), _hostVersion);
MarketPlugins.Add(item);
CatalogPlugins.Add(item);
_ = item.EnsureIconLoadedAsync(_iconService);
}
@@ -513,12 +513,12 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
? string.Format(
CultureInfo.CurrentCulture,
L("market.status.loaded_cache_format", "Official source unavailable. Loaded {0} plugin(s) from cache. Reason: {1}"),
MarketPlugins.Count,
CatalogPlugins.Count,
result.WarningMessage ?? L("market.detail.unknown", "Unknown"))
: string.Format(
CultureInfo.CurrentCulture,
L("market.status.loaded_network_format", "Loaded {0} plugin(s) from the official source."),
MarketPlugins.Count);
CatalogPlugins.Count);
}
finally
{
@@ -527,7 +527,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
}
[RelayCommand]
private void OpenDetails(PluginMarketItemViewModel? item)
private void OpenDetails(PluginCatalogItemViewModel? item)
{
if (item is null)
{
@@ -538,19 +538,19 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
}
[RelayCommand]
private Task ExecutePrimaryActionAsync(PluginMarketItemViewModel? item)
private Task ExecutePrimaryActionAsync(PluginCatalogItemViewModel? item)
{
return item is null ? Task.CompletedTask : ExecutePrimaryActionCoreAsync(item);
}
private async Task ExecutePrimaryActionCoreAsync(PluginMarketItemViewModel item)
private async Task ExecutePrimaryActionCoreAsync(PluginCatalogItemViewModel item)
{
if (item.IsInstalling)
{
return;
}
if (item.ActionState == PluginMarketPrimaryActionState.RestartRequired)
if (item.ActionState == PluginCatalogPrimaryActionState.RestartRequired)
{
RestartRequested?.Invoke(RestartRequiredMessage);
return;
@@ -614,7 +614,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
private void RefreshItemStates()
{
foreach (var item in MarketPlugins)
foreach (var item in CatalogPlugins)
{
item.ApplyInstallState(ResolveInstalledPlugin(item.PluginId), _hostVersion);
}
@@ -642,7 +642,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
{
FilteredPlugins.Clear();
IEnumerable<PluginMarketItemViewModel> filtered = MarketPlugins;
IEnumerable<PluginCatalogItemViewModel> filtered = CatalogPlugins;
var query = SearchText?.Trim();
if (!string.IsNullOrWhiteSpace(query))
{
@@ -660,8 +660,8 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
}
ShowEmptyState = FilteredPlugins.Count == 0;
EmptyStateText = !_hasLoadedMarket
? L("market.list.empty", "The plugin market has not been loaded yet.")
EmptyStateText = !_hasLoadedCatalog
? L("market.list.empty", "The plugin catalog has not been loaded yet.")
: string.IsNullOrWhiteSpace(query)
? L("settings.plugins.marketplace_empty", "No marketplace plugins are available right now.")
: L("market.list.no_results", "No plugins match the current search.");
@@ -669,12 +669,12 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
private void RefreshLocalizedText()
{
PageTitle = L("settings.plugin_market.title", "Plugin Market");
PageDescription = L("settings.plugin_market.subtitle", "Browse plugins from the official LanAirApp source and stage installs.");
PageTitle = L("settings.plugin_catalog.title", "Plugin Catalog");
PageDescription = L("settings.plugin_catalog.subtitle", "Browse plugins from the official LanAirApp source and stage installs.");
SearchPlaceholder = L("market.toolbar.search_placeholder", "Search plugins");
RefreshButtonText = L("market.toolbar.refresh", "Refresh");
RestartRequiredMessage = L("settings.plugins.restart_required", "Plugin changes take effect after restart.");
EmptyStateText = L("market.list.empty", "The plugin market has not been loaded yet.");
EmptyStateText = L("market.list.empty", "The plugin catalog has not been loaded yet.");
}
private string L(string key, string fallback)

View File

@@ -1517,6 +1517,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private string _installNowButtonText = string.Empty;
[ObservableProperty]
private string _redownloadButtonText = string.Empty;
[ObservableProperty]
private string _latestVersionText = string.Empty;
@@ -1556,6 +1559,12 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private string _downloadThreadsDescription = string.Empty;
[ObservableProperty]
private string _forceCheckUpdateLabel = string.Empty;
[ObservableProperty]
private string _forceCheckUpdateDescription = string.Empty;
[ObservableProperty]
private string _stableChannelText = string.Empty;
@@ -1619,6 +1628,8 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
public bool IsInstallButtonVisible => HasPendingInstaller;
public bool IsRedownloadButtonVisible => HasPendingInstaller && !IsDownloading;
public string DownloadThreadsValueText =>
UpdateSettingsValues.NormalizeDownloadThreads((int)Math.Round(DownloadThreadsSliderValue)).ToString(CultureInfo.CurrentCulture);
@@ -1838,6 +1849,19 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
[RelayCommand(CanExecute = nameof(CanCheckForUpdates))]
private async Task CheckForUpdatesAsync()
{
await CheckForUpdatesCoreAsync(isForce: false);
}
private bool CanForceCheckUpdate() => !IsBusy;
[RelayCommand(CanExecute = nameof(CanForceCheckUpdate))]
private async Task ForceCheckUpdateAsync()
{
await CheckForUpdatesCoreAsync(isForce: true);
}
private async Task CheckForUpdatesCoreAsync(bool isForce)
{
try
{
@@ -1845,9 +1869,11 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
IsDownloadProgressVisible = false;
DownloadProgressValue = 0;
DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
UpdateStatus = L("settings.update.status_checking", "Checking GitHub releases...");
UpdateStatus = isForce
? L("settings.update.status_force_checking", "Force checking GitHub releases...")
: L("settings.update.status_checking", "Checking GitHub releases...");
var result = await _updateWorkflowService.CheckForUpdatesAsync(_currentVersion);
var result = await _updateWorkflowService.CheckForUpdatesAsync(_currentVersion, isForce);
_lastCheckResult = result.Success ? result : null;
RefreshLastCheckedFromSettings();
@@ -1863,16 +1889,16 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
}
ApplyCheckResultDisplay(result);
if (!result.IsUpdateAvailable)
if (!result.IsUpdateAvailable && !isForce)
{
return;
}
if (result.PreferredAsset is null)
{
UpdateStatus = L(
"settings.update.status_asset_missing",
"A new release is available, but no compatible installer was found.");
UpdateStatus = isForce
? L("settings.update.status_force_no_asset", "Release found but no compatible installer available.")
: L("settings.update.status_asset_missing", "A new release is available, but no compatible installer was found.");
return;
}
@@ -1884,7 +1910,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
UpdateStatus = string.Format(
CultureInfo.CurrentCulture,
L("settings.update.status_available_format", "New version {0} is available. Click Download & Install."),
isForce
? L("settings.update.status_force_available_format", "Release {0} is available. Click Download & Install.")
: L("settings.update.status_available_format", "New version {0} is available. Click Download & Install."),
result.LatestVersionText);
}
finally
@@ -1926,6 +1954,59 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
result.ErrorMessage ?? L("settings.update.status_installer_missing", "Installer file was not found after download."));
}
private bool CanRedownloadUpdate() => !IsBusy && HasPendingInstaller && _lastCheckResult is not null;
[RelayCommand(CanExecute = nameof(CanRedownloadUpdate))]
private async Task RedownloadUpdateAsync()
{
if (_lastCheckResult is null || !_lastCheckResult.Success || !_lastCheckResult.IsUpdateAvailable || _lastCheckResult.PreferredAsset is null)
{
UpdateStatus = L("settings.update.status_redownload_no_check", "Please check for updates first before redownloading.");
return;
}
try
{
IsDownloading = true;
IsDownloadProgressVisible = true;
DownloadProgressValue = 0;
DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
UpdateStatus = L("settings.update.status_redownloading", "Redownloading installer...");
var progress = new Progress<double>(value =>
{
DownloadProgressValue = Math.Clamp(value * 100d, 0d, 100d);
DownloadProgressText = string.Format(
CultureInfo.CurrentCulture,
L("settings.update.download_progress_format", "Download progress: {0:F0}%"),
DownloadProgressValue);
});
var downloadResult = await _updateWorkflowService.RedownloadReleaseAsync(_lastCheckResult, progress);
if (!downloadResult.Success)
{
UpdateStatus = string.Format(
CultureInfo.CurrentCulture,
L("settings.update.status_redownload_failed_format", "Redownload failed: {0}"),
downloadResult.ErrorMessage ?? L("settings.update.status_check_failed", "Failed to check for updates."));
return;
}
ApplyPendingState(_settingsFacade.Update.Get());
UpdateStatus = downloadResult.HashVerified
? BuildPendingReadyStatus()
: string.Format(
CultureInfo.CurrentCulture,
L("settings.update.status_downloaded_no_hash_format", "Update downloaded. Hash: {0}"),
downloadResult.ActualHash ?? "N/A");
}
finally
{
IsDownloading = false;
IsDownloadProgressVisible = false;
}
}
private void RefreshLocalizedText()
{
PageTitle = L("settings.update.title", "Update");
@@ -1939,9 +2020,12 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
UpdateModeLabel = L("settings.update.mode_label", "Update Mode");
DownloadThreadsLabel = L("settings.update.download_threads_label", "Download Threads");
DownloadThreadsDescription = L("settings.update.download_threads_desc", "Choose how many parallel download threads are used for application updates.");
ForceCheckUpdateLabel = L("settings.update.force_check_label", "Force Check Update");
ForceCheckUpdateDescription = L("settings.update.force_check_desc", "Force check for updates from GitHub, ignoring version comparison.");
CheckForUpdatesButtonText = L("settings.update.check_button", "Check for Updates");
DownloadButtonText = L("settings.update.download_install_button", "Download & Install");
InstallNowButtonText = L("settings.update.install_now_button", "Install Now");
RedownloadButtonText = L("settings.update.redownload_button", "Redownload");
CurrentVersionLabel = L("settings.update.current_version_label", "Current Version");
LatestVersionLabel = L("settings.update.latest_version_label", "Latest Release");
PublishedAtLabel = L("settings.update.published_at_label", "Published At");
@@ -2147,7 +2231,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
{
OnPropertyChanged(nameof(IsDownloadButtonVisible));
OnPropertyChanged(nameof(IsInstallButtonVisible));
OnPropertyChanged(nameof(IsRedownloadButtonVisible));
OnPropertyChanged(nameof(DownloadThreadsValueText));
RedownloadUpdateCommand.NotifyCanExecuteChanged();
}
private IReadOnlyList<SelectionOption> CreateUpdateChannelOptions()
@@ -2236,6 +2322,527 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
=> _localizationService.GetString(_languageCode, key, fallback);
}
public sealed partial class StudySettingsPageViewModel : ViewModelBase
{
private readonly ISettingsFacadeService _settingsFacade;
private readonly LocalizationService _localizationService = new();
private readonly string _languageCode;
private bool _isInitializing;
private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault();
public StudySettingsPageViewModel(ISettingsFacadeService settingsFacade)
{
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
_languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode);
RefreshLocalizedText();
_isInitializing = true;
LoadSettings();
_isInitializing = false;
}
#region Properties - Noise Monitoring
[ObservableProperty]
private string _noiseMonitoringHeader = string.Empty;
[ObservableProperty]
private string _noiseMonitoringDescription = string.Empty;
[ObservableProperty]
private string _samplingRateLabel = string.Empty;
[ObservableProperty]
private string _samplingRateDescription = string.Empty;
[ObservableProperty]
private int _samplingRateMs = 50;
[ObservableProperty]
private string _samplingRateValueText = string.Empty;
[ObservableProperty]
private string _noiseSensitivityLabel = string.Empty;
[ObservableProperty]
private string _noiseSensitivityDescription = string.Empty;
[ObservableProperty]
private double _noiseSensitivityDbfs = -50;
[ObservableProperty]
private string _noiseSensitivityValueText = string.Empty;
[ObservableProperty]
private string _currentThresholdText = string.Empty;
partial void OnNoiseSensitivityDbfsChanged(double value)
{
UpdateSensitivityText();
UpdateThresholdText();
if (!_isInitializing)
{
SaveNoiseSettings();
}
}
partial void OnSamplingRateMsChanged(int value)
{
UpdateSamplingRateText();
if (!_isInitializing)
{
SaveNoiseSettings();
}
}
private void UpdateSamplingRateText()
{
SamplingRateValueText = $"{SamplingRateMs}ms";
}
private void UpdateSensitivityText()
{
NoiseSensitivityValueText = $"{NoiseSensitivityDbfs:F0} dBFS";
}
#endregion
#region Properties - Focus Timer
[ObservableProperty]
private string _focusTimerHeader = string.Empty;
[ObservableProperty]
private string _focusTimerDescription = string.Empty;
[ObservableProperty]
private string _focusDurationLabel = string.Empty;
[ObservableProperty]
private string _focusDurationDescription = string.Empty;
[ObservableProperty]
private int _focusDurationMinutes = 25;
[ObservableProperty]
private string _focusDurationValueText = string.Empty;
[ObservableProperty]
private string _breakDurationLabel = string.Empty;
[ObservableProperty]
private string _breakDurationDescription = string.Empty;
[ObservableProperty]
private int _breakDurationMinutes = 5;
[ObservableProperty]
private string _breakDurationValueText = string.Empty;
[ObservableProperty]
private string _longBreakDurationLabel = string.Empty;
[ObservableProperty]
private string _longBreakDurationDescription = string.Empty;
[ObservableProperty]
private int _longBreakDurationMinutes = 15;
[ObservableProperty]
private string _longBreakDurationValueText = string.Empty;
[ObservableProperty]
private string _sessionsBeforeLongBreakLabel = string.Empty;
[ObservableProperty]
private string _sessionsBeforeLongBreakDescription = string.Empty;
[ObservableProperty]
private int _sessionsBeforeLongBreak = 4;
[ObservableProperty]
private string _sessionsBeforeLongBreakValueText = string.Empty;
[ObservableProperty]
private string _autoStartBreakLabel = string.Empty;
[ObservableProperty]
private string _autoStartBreakDescription = string.Empty;
[ObservableProperty]
private bool _autoStartBreak;
[ObservableProperty]
private string _autoStartFocusLabel = string.Empty;
[ObservableProperty]
private string _autoStartFocusDescription = string.Empty;
[ObservableProperty]
private bool _autoStartFocus;
partial void OnFocusDurationMinutesChanged(int value)
{
UpdateFocusDurationText();
if (!_isInitializing)
{
SaveTimerSettings();
}
}
partial void OnBreakDurationMinutesChanged(int value)
{
UpdateBreakDurationText();
if (!_isInitializing)
{
SaveTimerSettings();
}
}
partial void OnLongBreakDurationMinutesChanged(int value)
{
UpdateLongBreakDurationText();
if (!_isInitializing)
{
SaveTimerSettings();
}
}
partial void OnSessionsBeforeLongBreakChanged(int value)
{
UpdateSessionsBeforeLongBreakText();
if (!_isInitializing)
{
SaveTimerSettings();
}
}
partial void OnAutoStartBreakChanged(bool value)
{
if (!_isInitializing)
{
SaveTimerSettings();
}
}
partial void OnAutoStartFocusChanged(bool value)
{
if (!_isInitializing)
{
SaveTimerSettings();
}
}
private void UpdateFocusDurationText()
{
FocusDurationValueText = $"{FocusDurationMinutes} 分钟";
}
private void UpdateBreakDurationText()
{
BreakDurationValueText = $"{BreakDurationMinutes} 分钟";
}
private void UpdateLongBreakDurationText()
{
LongBreakDurationValueText = $"{LongBreakDurationMinutes} 分钟";
}
private void UpdateSessionsBeforeLongBreakText()
{
SessionsBeforeLongBreakValueText = $"{SessionsBeforeLongBreak} 次";
}
#endregion
#region Properties - Alert
[ObservableProperty]
private string _alertHeader = string.Empty;
[ObservableProperty]
private string _alertDescription = string.Empty;
[ObservableProperty]
private string _noiseAlertEnabledLabel = string.Empty;
[ObservableProperty]
private string _noiseAlertEnabledDescription = string.Empty;
[ObservableProperty]
private bool _noiseAlertEnabled;
[ObservableProperty]
private string _maxInterruptsPerMinuteLabel = string.Empty;
[ObservableProperty]
private string _maxInterruptsPerMinuteDescription = string.Empty;
[ObservableProperty]
private int _maxInterruptsPerMinute = 6;
partial void OnNoiseAlertEnabledChanged(bool value)
{
if (!_isInitializing)
{
SaveAlertSettings();
}
}
partial void OnMaxInterruptsPerMinuteChanged(int value)
{
if (!_isInitializing)
{
SaveAlertSettings();
}
}
#endregion
#region Properties - Display
[ObservableProperty]
private string _displayHeader = string.Empty;
[ObservableProperty]
private string _displayDescription = string.Empty;
[ObservableProperty]
private string _showRealtimeDbLabel = string.Empty;
[ObservableProperty]
private string _showRealtimeDbDescription = string.Empty;
[ObservableProperty]
private bool _showRealtimeDb = true;
[ObservableProperty]
private string _baselineDbLabel = string.Empty;
[ObservableProperty]
private string _baselineDbDescription = string.Empty;
[ObservableProperty]
private double _baselineDb = 45;
[ObservableProperty]
private string _baselineDbValueText = string.Empty;
[ObservableProperty]
private string _avgWindowSecLabel = string.Empty;
[ObservableProperty]
private string _avgWindowSecDescription = string.Empty;
[ObservableProperty]
private int _avgWindowSec = 1;
[ObservableProperty]
private string _avgWindowSecValueText = string.Empty;
partial void OnShowRealtimeDbChanged(bool value)
{
if (!_isInitializing)
{
SaveDisplaySettings();
}
}
partial void OnBaselineDbChanged(double value)
{
UpdateBaselineDbText();
if (!_isInitializing)
{
SaveDisplaySettings();
}
}
partial void OnAvgWindowSecChanged(int value)
{
UpdateAvgWindowSecText();
if (!_isInitializing)
{
SaveDisplaySettings();
}
}
#endregion
[ObservableProperty]
private string _footerHint = string.Empty;
private void UpdateBaselineDbText()
{
BaselineDbValueText = $"{BaselineDb:F0} dB";
}
private void UpdateAvgWindowSecText()
{
AvgWindowSecValueText = $"{AvgWindowSec} 秒";
}
private void LoadSettings()
{
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
// Noise settings
SamplingRateMs = appSnapshot.StudyFrameMs is > 0 ? appSnapshot.StudyFrameMs.Value : 50;
NoiseSensitivityDbfs = appSnapshot.StudyScoreThresholdDbfs ?? -50;
// Timer settings
FocusDurationMinutes = appSnapshot.StudyFocusDurationMinutes is > 0 ? appSnapshot.StudyFocusDurationMinutes.Value : 25;
BreakDurationMinutes = appSnapshot.StudyBreakDurationMinutes is > 0 ? appSnapshot.StudyBreakDurationMinutes.Value : 5;
LongBreakDurationMinutes = appSnapshot.StudyLongBreakDurationMinutes is > 0 ? appSnapshot.StudyLongBreakDurationMinutes.Value : 15;
SessionsBeforeLongBreak = appSnapshot.StudySessionsBeforeLongBreak is > 0 ? appSnapshot.StudySessionsBeforeLongBreak.Value : 4;
AutoStartBreak = appSnapshot.StudyAutoStartBreak ?? false;
AutoStartFocus = appSnapshot.StudyAutoStartFocus ?? false;
// Alert settings
NoiseAlertEnabled = appSnapshot.StudyNoiseAlertEnabled ?? false;
MaxInterruptsPerMinute = appSnapshot.StudyMaxInterruptsPerMinute is > 0 ? appSnapshot.StudyMaxInterruptsPerMinute.Value : 6;
// Display settings
ShowRealtimeDb = appSnapshot.StudyShowRealtimeDb ?? true;
BaselineDb = appSnapshot.StudyBaselineDb ?? 45;
AvgWindowSec = appSnapshot.StudyAvgWindowSec ?? 1;
UpdateSamplingRateText();
UpdateSensitivityText();
UpdateThresholdText();
UpdateFocusDurationText();
UpdateBreakDurationText();
UpdateLongBreakDurationText();
UpdateSessionsBeforeLongBreakText();
UpdateBaselineDbText();
UpdateAvgWindowSecText();
}
private void SaveNoiseSettings()
{
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
appSnapshot.StudyFrameMs = SamplingRateMs;
appSnapshot.StudyScoreThresholdDbfs = NoiseSensitivityDbfs;
_settingsFacade.Settings.SaveSnapshot(SettingsScope.App, appSnapshot,
changedKeys: [nameof(AppSettingsSnapshot.StudyFrameMs), nameof(AppSettingsSnapshot.StudyScoreThresholdDbfs)]);
UpdateThresholdText();
UpdateStudyAnalyticsConfig();
}
private void SaveTimerSettings()
{
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
appSnapshot.StudyFocusDurationMinutes = FocusDurationMinutes;
appSnapshot.StudyBreakDurationMinutes = BreakDurationMinutes;
appSnapshot.StudyLongBreakDurationMinutes = LongBreakDurationMinutes;
appSnapshot.StudySessionsBeforeLongBreak = SessionsBeforeLongBreak;
appSnapshot.StudyAutoStartBreak = AutoStartBreak;
appSnapshot.StudyAutoStartFocus = AutoStartFocus;
_settingsFacade.Settings.SaveSnapshot(SettingsScope.App, appSnapshot,
changedKeys: [
nameof(AppSettingsSnapshot.StudyFocusDurationMinutes),
nameof(AppSettingsSnapshot.StudyBreakDurationMinutes),
nameof(AppSettingsSnapshot.StudyLongBreakDurationMinutes),
nameof(AppSettingsSnapshot.StudySessionsBeforeLongBreak),
nameof(AppSettingsSnapshot.StudyAutoStartBreak),
nameof(AppSettingsSnapshot.StudyAutoStartFocus)
]);
}
private void SaveAlertSettings()
{
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
appSnapshot.StudyNoiseAlertEnabled = NoiseAlertEnabled;
appSnapshot.StudyMaxInterruptsPerMinute = MaxInterruptsPerMinute;
_settingsFacade.Settings.SaveSnapshot(SettingsScope.App, appSnapshot,
changedKeys: [nameof(AppSettingsSnapshot.StudyNoiseAlertEnabled), nameof(AppSettingsSnapshot.StudyMaxInterruptsPerMinute)]);
UpdateStudyAnalyticsConfig();
}
private void SaveDisplaySettings()
{
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
appSnapshot.StudyShowRealtimeDb = ShowRealtimeDb;
appSnapshot.StudyBaselineDb = BaselineDb;
appSnapshot.StudyAvgWindowSec = AvgWindowSec;
_settingsFacade.Settings.SaveSnapshot(SettingsScope.App, appSnapshot,
changedKeys: [nameof(AppSettingsSnapshot.StudyShowRealtimeDb), nameof(AppSettingsSnapshot.StudyBaselineDb), nameof(AppSettingsSnapshot.StudyAvgWindowSec)]);
UpdateStudyAnalyticsConfig();
}
private void UpdateStudyAnalyticsConfig()
{
var currentConfig = _studyAnalyticsService.GetConfig();
var newConfig = currentConfig with
{
FrameMs = SamplingRateMs,
ScoreThresholdDbfs = NoiseSensitivityDbfs,
BaselineDb = BaselineDb,
AvgWindowSec = AvgWindowSec,
ShowRelativeDb = ShowRealtimeDb,
MaxSegmentsPerMin = MaxInterruptsPerMinute,
AlertSoundEnabled = NoiseAlertEnabled
};
_studyAnalyticsService.UpdateConfig(newConfig);
}
private void UpdateThresholdText()
{
CurrentThresholdText = string.Format(
CultureInfo.CurrentCulture,
L("settings.study.current_threshold_format", "当前评分阈值: {0} dBFS"),
NoiseSensitivityDbfs);
}
private void RefreshLocalizedText()
{
NoiseMonitoringHeader = L("settings.study.noise_header", "噪音监测");
NoiseMonitoringDescription = L("settings.study.noise_description", "配置麦克风采集频率和噪音评分敏感度。");
SamplingRateLabel = L("settings.study.sampling_rate_label", "采集频率");
SamplingRateDescription = L("settings.study.sampling_rate_desc", "麦克风采集音频的时间间隔。更高的频率会更准确地捕捉噪音变化,但会增加电量消耗。");
NoiseSensitivityLabel = L("settings.study.sensitivity_label", "噪音敏感度");
NoiseSensitivityDescription = L("settings.study.sensitivity_desc", "评分阈值决定了什么级别的噪音会被认为是干扰。阈值越严格,越容易检测到轻微噪音。");
FocusTimerHeader = L("settings.study.timer_header", "专注计时");
FocusTimerDescription = L("settings.study.timer_description", "配置专注时段和休息时段的时长。");
FocusDurationLabel = L("settings.study.focus_duration_label", "专注时长");
FocusDurationDescription = L("settings.study.focus_duration_desc", "单次专注时段的持续时间(分钟)。");
BreakDurationLabel = L("settings.study.break_duration_label", "休息时长");
BreakDurationDescription = L("settings.study.break_duration_desc", "短休息时段的持续时间(分钟)。");
LongBreakDurationLabel = L("settings.study.long_break_duration_label", "长休息时长");
LongBreakDurationDescription = L("settings.study.long_break_duration_desc", "长休息时段的持续时间(分钟)。");
SessionsBeforeLongBreakLabel = L("settings.study.sessions_before_long_break_label", "长休息间隔");
SessionsBeforeLongBreakDescription = L("settings.study.sessions_before_long_break_desc", "经过几个专注时段后触发长休息。");
AutoStartBreakLabel = L("settings.study.auto_start_break_label", "自动开始休息");
AutoStartBreakDescription = L("settings.study.auto_start_break_desc", "专注时段结束后自动开始休息计时。");
AutoStartFocusLabel = L("settings.study.auto_start_focus_label", "自动开始专注");
AutoStartFocusDescription = L("settings.study.auto_start_focus_desc", "休息时段结束后自动开始专注计时。");
AlertHeader = L("settings.study.alert_header", "提醒设置");
AlertDescription = L("settings.study.alert_description", "配置噪音干扰提醒。");
NoiseAlertEnabledLabel = L("settings.study.noise_alert_enabled_label", "启用噪音提醒");
NoiseAlertEnabledDescription = L("settings.study.noise_alert_enabled_desc", "当检测到超过容忍阈值的噪音干扰时显示提醒。");
MaxInterruptsPerMinuteLabel = L("settings.study.max_interrupts_label", "最大容忍打断次数");
MaxInterruptsPerMinuteDescription = L("settings.study.max_interrupts_desc", "每分钟最多允许多少次噪音干扰事件,超过此值将触发提醒。");
DisplayHeader = L("settings.study.display_header", "显示设置");
DisplayDescription = L("settings.study.display_description", "配置噪音数据的显示方式。");
ShowRealtimeDbLabel = L("settings.study.show_realtime_db_label", "显示实时分贝");
ShowRealtimeDbDescription = L("settings.study.show_realtime_db_desc", "在组件中实时显示分贝值。");
BaselineDbLabel = L("settings.study.baseline_db_label", "基准显示分贝");
BaselineDbDescription = L("settings.study.baseline_db_desc", "校准后的显示分贝基准值,用于将 dBFS 转换为用户可读的 dB 值。");
AvgWindowSecLabel = L("settings.study.avg_window_label", "平均时间窗");
AvgWindowSecDescription = L("settings.study.avg_window_desc", "噪音平滑显示的时间窗口,较大的值会使显示更稳定但响应更慢。");
FooterHint = L("settings.study.footer_hint", "这些设置将影响自习环境监测组件的行为。");
UpdateThresholdText();
}
private string L(string key, string fallback)
=> _localizationService.GetString(_languageCode, key, fallback);
}
public sealed class PluginGeneratedSettingsPageViewModel
{
public PluginGeneratedSettingsPageViewModel(

View File

@@ -53,7 +53,7 @@
<!-- MD3 Button Styles -->
<Style Selector="Button.component-editor-footer-button">
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusComponent}" />
<Setter Property="Background" Value="{DynamicResource EditorPrimaryBrush}" />
<Setter Property="Foreground" Value="{DynamicResource EditorOnPrimaryBrush}" />
<Setter Property="Height" Value="40" />
@@ -99,37 +99,43 @@
</Grid>
</Border>
<Panel Grid.Row="1">
<ScrollViewer Classes="component-editor-scroll-host"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<ContentControl x:Name="EditorContentHost"
Margin="24,0,24,100"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Stretch" />
</ScrollViewer>
<Border Grid.Row="1"
Background="{DynamicResource EditorSurfaceContainerBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Margin="16,0,16,16"
ClipToBounds="True">
<Panel>
<ScrollViewer Classes="component-editor-scroll-host"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<ContentControl x:Name="EditorContentHost"
Margin="24,0,24,100"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Stretch" />
</ScrollViewer>
<!-- Floating Save Button (MD3 Style) -->
<Button x:Name="SaveFAB"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Margin="28"
Width="64"
Height="64"
Background="{DynamicResource EditorPrimaryBrush}"
Foreground="{DynamicResource EditorOnPrimaryBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
Classes="accent"
Click="OnCloseClick">
<Button.Styles>
<Style Selector="Button:pointerover">
<Setter Property="RenderTransform" Value="scale(1.05)" />
</Style>
</Button.Styles>
<mi:MaterialIcon Kind="Check"
Width="32"
Height="32" />
</Button>
</Panel>
<!-- Floating Save Button (MD3 Style) -->
<Button x:Name="SaveFAB"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Margin="28"
Width="64"
Height="64"
Background="{DynamicResource EditorPrimaryBrush}"
Foreground="{DynamicResource EditorOnPrimaryBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Classes="accent"
Click="OnCloseClick">
<Button.Styles>
<Style Selector="Button:pointerover">
<Setter Property="RenderTransform" Value="scale(1.05)" />
</Style>
</Button.Styles>
<mi:MaterialIcon Kind="Check"
Width="32"
Height="32" />
</Button>
</Panel>
</Border>
</Grid>
</Window>

View File

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

View File

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

View File

@@ -87,14 +87,14 @@
<Border Width="240"
Height="220"
Margin="6"
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Padding="10"
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1">
<Grid RowDefinitions="*,Auto,Auto"
RowSpacing="8">
<Border CornerRadius="{DynamicResource DesignCornerRadiusXs}"
<Border CornerRadius="{DynamicResource DesignCornerRadiusSm}"
Background="{DynamicResource AdaptiveGlassPanelBackgroundBrush}"
BorderThickness="1"
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"

View File

@@ -230,7 +230,7 @@ public partial class ComponentLibraryWindow : Window
if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase))
{
return Symbol.Apps;
return Symbol.Hourglass;
}
return Symbol.Apps;

View File

@@ -8,7 +8,7 @@
x:Class="LanMountainDesktop.Views.Components.AnalogClockWidget">
<Border x:Name="RootBorder"
CornerRadius="42"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
Padding="14">
<Border.Background>

View File

@@ -9,7 +9,7 @@
x:Class="LanMountainDesktop.Views.Components.BaiduHotSearchWidget">
<Border x:Name="RootBorder"
CornerRadius="34"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Background="Transparent"
ClipToBounds="True"
BorderThickness="0"
@@ -17,7 +17,7 @@
<Grid>
<Border x:Name="CardBorder"
Background="#FCFCFD"
CornerRadius="34"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
BorderBrush="Transparent"
BorderThickness="0"
Padding="16,14,16,14">

View File

@@ -9,7 +9,7 @@
x:Class="LanMountainDesktop.Views.Components.BilibiliHotSearchWidget">
<Border x:Name="RootBorder"
CornerRadius="34"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Background="Transparent"
ClipToBounds="True"
BorderThickness="0"
@@ -17,7 +17,7 @@
<Grid>
<Border x:Name="CardBorder"
Background="#FCFCFD"
CornerRadius="34"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
BorderBrush="Transparent"
BorderThickness="0"
Padding="16,14,16,14">

View File

@@ -9,15 +9,17 @@
x:Class="LanMountainDesktop.Views.Components.BrowserWidget">
<Border x:Name="RootBorder"
Background="#F4F7FC"
CornerRadius="24"
ClipToBounds="True"
Padding="10">
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Padding="0"
ClipToBounds="True">
<Grid RowDefinitions="*,Auto"
RowSpacing="8">
<Border x:Name="WebViewHostBorder"
Grid.Row="0"
CornerRadius="16"
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
ClipToBounds="True"
Background="#FFFFFFFF"
BorderBrush="#22000000"
@@ -50,7 +52,7 @@
<Border x:Name="AddressBarBorder"
Grid.Row="1"
CornerRadius="14"
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
Background="#ECF2FA"
BorderBrush="#22000000"
BorderThickness="1"

View File

@@ -2,9 +2,11 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LanMountainDesktop.Views.Components.ClassScheduleWidget">
<Border x:Name="RootBorder"
ClipToBounds="True"
CornerRadius="28"
BorderThickness="1">
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Padding="14,12">
<Grid x:Name="LayoutGrid"
RowDefinitions="Auto,*">
<Grid x:Name="HeaderGrid"

View File

@@ -10,7 +10,7 @@
<Border x:Name="RootBorder"
Classes="surface-translucent-panel"
Padding="0"
CornerRadius="24">
CornerRadius="{DynamicResource DesignCornerRadiusXs}">
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Center"
VerticalAlignment="Center">

View File

@@ -9,7 +9,7 @@
x:Class="LanMountainDesktop.Views.Components.CnrDailyNewsWidget">
<Border x:Name="RootBorder"
CornerRadius="34"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Background="Transparent"
ClipToBounds="True"
BorderThickness="0"
@@ -17,11 +17,12 @@
<Grid>
<Border x:Name="CardBorder"
Background="#FCFCFD"
CornerRadius="34"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
BorderBrush="Transparent"
BorderThickness="0"
Padding="16,14,16,14">
<Grid RowDefinitions="Auto,Auto,Auto,Auto"
<Grid x:Name="ContentGrid"
RowDefinitions="Auto,Auto,Auto,Auto"
RowSpacing="8">
<Grid Grid.Row="0"
ColumnDefinitions="*,Auto"
@@ -93,7 +94,7 @@
Grid.Column="1"
Width="160"
Height="90"
CornerRadius="16"
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
ClipToBounds="True"
Background="#E6E6E6">
<Image x:Name="News1Image"
@@ -121,7 +122,7 @@
Grid.Column="1"
Width="160"
Height="90"
CornerRadius="16"
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
ClipToBounds="True"
Background="#E6E6E6">
<Image x:Name="News2Image"

View File

@@ -625,17 +625,18 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
{
var scale = ResolveScale();
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells;
var unifiedMainRectangle = ResolveUnifiedMainRectangle();
RootBorder.CornerRadius = unifiedMainRectangle;
RootBorder.Padding = new Thickness(0);
var horizontalPadding = Math.Clamp(16 * scale, 8, 24);
var verticalPadding = Math.Clamp(14 * scale, 7, 22);
CardBorder.CornerRadius = unifiedMainRectangle;
CardBorder.Padding = new Thickness(
Math.Clamp(16 * scale, 8, 24),
Math.Clamp(14 * scale, 7, 22),
Math.Clamp(16 * scale, 8, 24),
Math.Clamp(14 * scale, 7, 22));
CardBorder.Padding = new Thickness(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding);
var innerWidth = Math.Max(100, totalWidth - horizontalPadding * 2);
var headlineFont = Math.Clamp(24 * scale, 12, 34);
BrandPrimaryTextBlock.FontSize = headlineFont;
@@ -649,7 +650,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
RefreshGlyphIcon.FontSize = Math.Clamp(19 * scale, 11, 24);
RefreshLabelTextBlock.FontSize = Math.Clamp(22 * scale, 11, 29);
var imageWidth = Math.Clamp(totalWidth * 0.20, 60, 170);
var imageWidth = Math.Clamp(innerWidth * 0.22, 60, 170);
var imageHeight = Math.Clamp(imageWidth * 0.56, 38, 94);
News1ImageHost.Width = imageWidth;
News1ImageHost.Height = imageHeight;
@@ -657,6 +658,8 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
News2ImageHost.Height = imageHeight;
News1ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(16 * scale, 8, 22);
News2ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(16 * scale, 8, 22);
News1ImageHost.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#E6E6E6"));
News2ImageHost.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#E6E6E6"));
var columnGap = Math.Clamp(12 * scale, 6, 18);
NewsItem1Grid.ColumnSpacing = columnGap;
@@ -664,25 +667,29 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
NewsItem1Grid.ColumnDefinitions[1].Width = new GridLength(imageWidth);
NewsItem2Grid.ColumnDefinitions[1].Width = new GridLength(imageWidth);
var availableTextWidth = Math.Max(
84,
totalWidth - imageWidth - columnGap - Math.Clamp(20 * scale, 10, 32));
var availableTextWidth = Math.Max(80, innerWidth - imageWidth - columnGap);
News1TitleTextBlock.MaxWidth = availableTextWidth;
News2TitleTextBlock.MaxWidth = availableTextWidth;
var newsFont = Math.Clamp(21 * scale, 10.5, 28);
News1TitleTextBlock.FontSize = newsFont;
News2TitleTextBlock.FontSize = newsFont;
var mainNewsLineHeight = newsFont * 1.14;
var mainNewsLineHeight = newsFont * 1.2;
News1TitleTextBlock.LineHeight = mainNewsLineHeight;
News2TitleTextBlock.LineHeight = mainNewsLineHeight;
var mainNewsMinHeight = mainNewsLineHeight * 2;
var mainNewsMinHeight = mainNewsLineHeight * 2.2;
News1TitleTextBlock.MinHeight = mainNewsMinHeight;
News2TitleTextBlock.MinHeight = mainNewsMinHeight;
StatusTextBlock.FontSize = Math.Clamp(16 * scale, 9, 24);
News1TitleTextBlock.MaxLines = 2;
News2TitleTextBlock.MaxLines = 2;
var rowSpacing = Math.Clamp(8 * scale, 4, 14);
if (ContentGrid is Grid contentGrid && contentGrid.RowDefinitions.Count >= 4)
{
contentGrid.RowSpacing = rowSpacing;
}
foreach (var row in _extraNewsRows)
{
row.RootGrid.ColumnSpacing = columnGap;
@@ -694,11 +701,12 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
row.ImageHost.Width = imageWidth;
row.ImageHost.Height = imageHeight;
row.ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(16 * scale, 8, 22);
row.ImageHost.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#E6E6E6"));
row.TitleTextBlock.MaxWidth = availableTextWidth;
row.TitleTextBlock.FontSize = Math.Clamp(19 * scale, 10, 25);
row.TitleTextBlock.LineHeight = row.TitleTextBlock.FontSize * 1.12;
row.TitleTextBlock.MinHeight = row.TitleTextBlock.LineHeight * 2;
row.TitleTextBlock.LineHeight = row.TitleTextBlock.FontSize * 1.2;
row.TitleTextBlock.MinHeight = row.TitleTextBlock.LineHeight * 2.2;
row.TitleTextBlock.MaxLines = 2;
}

View File

@@ -9,15 +9,15 @@ namespace LanMountainDesktop.Views.Components;
internal static class ComponentChromeCornerRadiusHelper
{
public static double ResolveMainRectangleRadiusValue(ComponentChromeContext? chromeContext = null, double fallback = 24d)
public static double ResolveMainRectangleRadiusValue(ComponentChromeContext? chromeContext = null, double fallback = 18d)
{
if (chromeContext is not null)
{
return Math.Max(0d, chromeContext.CornerRadiusTokens.Lg.TopLeft);
return Math.Max(0d, chromeContext.CornerRadiusTokens.Component.TopLeft);
}
var snapshot = HostAppearanceThemeProvider.GetOrCreate().GetCurrent();
var resolved = snapshot.CornerRadiusTokens.Lg.TopLeft;
var resolved = snapshot.CornerRadiusTokens.Component.TopLeft;
return double.IsFinite(resolved)
? Math.Max(0d, resolved)
: Math.Max(0d, fallback * ResolveScale(chromeContext));

View File

@@ -9,7 +9,7 @@
x:Class="LanMountainDesktop.Views.Components.DailyArtworkWidget">
<Border x:Name="RootBorder"
CornerRadius="34"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
BorderThickness="0"
Background="#D5D5D5">
@@ -88,7 +88,7 @@
Grid.Row="2"
Width="118"
Height="3"
CornerRadius="2"
CornerRadius="{DynamicResource DesignCornerRadiusMicro}"
HorizontalAlignment="Left"
Margin="0,0,0,10"
Background="#F0F0F0" />

View File

@@ -16,7 +16,7 @@
<StackPanel x:Name="RootStackPanel" Spacing="16">
<Border x:Name="CoverImageBorder"
CornerRadius="12"
CornerRadius="{DynamicResource DesignCornerRadiusXs}"
ClipToBounds="True"
Background="#f8f5ec"
PointerPressed="OnCoverImagePointerPressed"
@@ -75,7 +75,7 @@
<Border x:Name="OverviewBorder"
Background="#f8f5ec"
CornerRadius="8"
CornerRadius="{DynamicResource DesignCornerRadiusXs}"
Padding="12"
Margin="0,0,0,8">
<StackPanel x:Name="OverviewStackPanel" Spacing="12"/>
@@ -85,7 +85,7 @@
Content="展开更多新闻 ▼"
FontSize="14"
Padding="16,8"
CornerRadius="8"
CornerRadius="{DynamicResource DesignCornerRadiusXs}"
Background="Transparent"
BorderBrush="#bb5649"
BorderThickness="1"

View File

@@ -9,7 +9,7 @@
x:Class="LanMountainDesktop.Views.Components.DailyPoetryWidget">
<Border x:Name="RootBorder"
CornerRadius="34"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
BorderThickness="0"
Background="#C20A0A"
@@ -75,7 +75,7 @@
Height="26"
VerticalAlignment="Center"
Margin="0,0,8,0"
CornerRadius="3"
CornerRadius="{DynamicResource DesignCornerRadiusMicro}"
Background="#6BF2A497" />
<TextBlock x:Name="AuthorTextBlock"

View File

@@ -9,15 +9,15 @@
x:Class="LanMountainDesktop.Views.Components.DailyWord2x2Widget">
<Border x:Name="RootBorder"
CornerRadius="30"
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
Background="Transparent"
ClipToBounds="True"
BorderThickness="0"
Padding="0">
<Grid>
<Border x:Name="CardBorder"
Background="#FCFBFA"
CornerRadius="30"
Background="#FCFCFD"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
BorderBrush="Transparent"
BorderThickness="0"
Padding="12,11,12,11"

View File

@@ -10,15 +10,15 @@
x:Class="LanMountainDesktop.Views.Components.DailyWordWidget">
<Border x:Name="RootBorder"
CornerRadius="34"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Background="Transparent"
ClipToBounds="True"
BorderThickness="0"
Padding="0">
<Grid>
<Border x:Name="CardBorder"
Background="#FCFBFA"
CornerRadius="34"
Background="#FCFCFD"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
BorderBrush="Transparent"
BorderThickness="0"
Padding="16,14,16,14">

View File

@@ -8,10 +8,11 @@
x:Class="LanMountainDesktop.Views.Components.DateWidget">
<Border x:Name="RootBorder"
Background="{DynamicResource AdaptiveSurfaceBaseBrush}"
CornerRadius="28"
ClipToBounds="True"
Padding="12">
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Padding="14,12">
<Viewbox Stretch="Uniform">
<Grid x:Name="LayoutRoot"
Width="460"
@@ -86,7 +87,7 @@
Grid.Column="1"
Background="{DynamicResource AdaptiveLayer2Brush}"
BorderThickness="1"
CornerRadius="24"
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
BoxShadow="0 8 18 #1A000000"
Padding="14">
<Grid x:Name="RightPanelGrid"

View File

@@ -471,7 +471,11 @@ public sealed class DesktopComponentRuntimeRegistry
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.HolidayCalendar,
"component.holiday_calendar",
() => new HolidayCalendarWidget())
() => new HolidayCalendarWidget()),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopZhiJiaoHub,
"component.zhijiao_hub",
() => new ZhiJiaoHubWidget())
];
}

View File

@@ -9,7 +9,7 @@
<UserControl.Styles>
<Style Selector="Button">
<Setter Property="CornerRadius" Value="16" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
<Setter Property="Background" Value="#F8F9FB" />
<Setter Property="BorderBrush" Value="#00000000" />
<Setter Property="BorderThickness" Value="0" />
@@ -21,10 +21,11 @@
</UserControl.Styles>
<Border x:Name="RootBorder"
CornerRadius="34"
ClipToBounds="True"
Padding="12"
Background="#ECEDEF">
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Padding="14,12">
<Viewbox Stretch="Uniform">
<Grid x:Name="LayoutRoot"
Width="304"
@@ -39,7 +40,7 @@
<Border x:Name="FromCurrencyRowBorder"
Grid.Row="0"
Grid.Column="0"
CornerRadius="16"
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
Background="#F8F9FB"
Padding="12,8"
PointerPressed="OnFromCurrencyRowPointerPressed">
@@ -80,7 +81,7 @@
<Border x:Name="ToCurrencyRowBorder"
Grid.Row="1"
Grid.Column="0"
CornerRadius="16"
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
Background="#F8F9FB"
Padding="12,8"
PointerPressed="OnToCurrencyRowPointerPressed">
@@ -122,7 +123,7 @@
Grid.Row="0"
Grid.RowSpan="2"
Grid.Column="1"
CornerRadius="16"
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
Background="#F8F9FB"
BorderBrush="#00000000"
BorderThickness="0"

View File

@@ -8,16 +8,16 @@
x:Class="LanMountainDesktop.Views.Components.ExtendedWeatherWidget">
<Border x:Name="RootBorder"
CornerRadius="28"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
Background="#6B7B8F">
<Grid>
<Border x:Name="BackgroundImageLayer"
CornerRadius="28"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True" />
<Border x:Name="BackgroundMotionLayer"
CornerRadius="28"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
Opacity="0.26"
RenderTransformOrigin="0.5,0.5">
@@ -31,12 +31,12 @@
</Border>
<Border x:Name="BackgroundTintLayer"
CornerRadius="28"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
Opacity="0.12" />
<Border x:Name="BackgroundLightLayer"
CornerRadius="28"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
Opacity="0.54">
<Border.Background>
@@ -53,7 +53,7 @@
</Border>
<Border x:Name="BackgroundShadeLayer"
CornerRadius="28"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
Opacity="0.70">
<Border.Background>

View File

@@ -8,10 +8,11 @@
x:Class="LanMountainDesktop.Views.Components.HolidayCalendarWidget">
<Border x:Name="RootBorder"
Background="#DCE7FA"
CornerRadius="34"
ClipToBounds="True"
Padding="14">
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Padding="14,12">
<Grid x:Name="LayoutRoot"
RowDefinitions="1.1*,2.3*,0.62*,0.78*,0.95*"
RowSpacing="8">

View File

@@ -9,14 +9,14 @@
x:Class="LanMountainDesktop.Views.Components.HourlyWeatherWidget">
<Border x:Name="RootBorder"
CornerRadius="28"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
Background="#6B7B8F">
<Grid>
<Border x:Name="BackgroundImageLayer" CornerRadius="28" ClipToBounds="True" />
<Border x:Name="BackgroundImageLayer" CornerRadius="{DynamicResource DesignCornerRadiusComponent}" ClipToBounds="True" />
<Border x:Name="BackgroundMotionLayer"
CornerRadius="28"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
Opacity="0.25"
RenderTransformOrigin="0.5,0.5">
@@ -28,9 +28,9 @@
</Border.RenderTransform>
</Border>
<Border x:Name="BackgroundTintLayer" CornerRadius="28" ClipToBounds="True" Opacity="0.12" />
<Border x:Name="BackgroundTintLayer" CornerRadius="{DynamicResource DesignCornerRadiusComponent}" ClipToBounds="True" Opacity="0.12" />
<Border x:Name="BackgroundLightLayer" CornerRadius="28" ClipToBounds="True" Opacity="0.52">
<Border x:Name="BackgroundLightLayer" CornerRadius="{DynamicResource DesignCornerRadiusComponent}" ClipToBounds="True" Opacity="0.52">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#45FFFFFF" Offset="0" />
@@ -40,7 +40,7 @@
</Border.Background>
</Border>
<Border x:Name="BackgroundShadeLayer" CornerRadius="28" ClipToBounds="True" Opacity="0.68">
<Border x:Name="BackgroundShadeLayer" CornerRadius="{DynamicResource DesignCornerRadiusComponent}" ClipToBounds="True" Opacity="0.68">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#00000000" Offset="0.42" />

View File

@@ -9,7 +9,7 @@
x:Class="LanMountainDesktop.Views.Components.IfengNewsWidget">
<Border x:Name="RootBorder"
CornerRadius="32"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Background="Transparent"
ClipToBounds="True"
BorderThickness="0"
@@ -17,24 +17,41 @@
<Grid>
<Border x:Name="CardBorder"
Background="#FCFCFD"
CornerRadius="32"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
BorderBrush="Transparent"
BorderThickness="0"
Padding="14,14,14,14">
<Grid x:Name="ContentGrid"
RowDefinitions="Auto,Auto,Auto,Auto,Auto"
RowSpacing="8">
RowDefinitions="Auto,*">
<Grid x:Name="HeaderGrid"
Grid.Row="0"
ColumnDefinitions="*,Auto"
ColumnSpacing="10">
<TextBlock x:Name="BrandTextBlock"
Text="凤凰网新闻"
Foreground="#E24B2D"
FontSize="28"
FontWeight="Bold"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
ColumnDefinitions="Auto,*"
ColumnSpacing="10"
Margin="0,0,0,8">
<StackPanel Orientation="Horizontal"
Spacing="0"
VerticalAlignment="Center">
<TextBlock x:Name="BrandTextBlock"
Text="鳳凰網"
Foreground="#E24B2D"
FontSize="20"
FontWeight="Bold"
VerticalAlignment="Center" />
<Border x:Name="NewsBadge"
Background="#E24B2D"
CornerRadius="{DynamicResource DesignCornerRadiusMicro}"
Padding="6,2"
Margin="4,0,0,0"
VerticalAlignment="Center">
<TextBlock x:Name="NewsBadgeText"
Text="新聞"
Foreground="White"
FontSize="20"
FontWeight="Bold"
VerticalAlignment="Center" />
</Border>
</StackPanel>
<Button x:Name="RefreshButton"
Grid.Column="1"
@@ -58,129 +75,18 @@
</Button>
</Grid>
<Border x:Name="NewsItem1Host"
Grid.Row="1"
Tag="0"
Background="Transparent"
Padding="0,2"
PointerPressed="OnNewsItemPointerPressed">
<Grid x:Name="NewsItem1Grid"
ColumnDefinitions="*,Auto"
ColumnSpacing="10">
<TextBlock x:Name="NewsItem1TextBlock"
Text="新闻标题"
Foreground="#202327"
FontSize="22"
FontWeight="SemiBold"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"
MaxLines="2"
VerticalAlignment="Top" />
<Border x:Name="NewsItem1ImageHost"
Grid.Column="1"
Width="148"
Height="84"
CornerRadius="12"
ClipToBounds="True"
Background="#E6E8EC">
<Image x:Name="NewsItem1Image"
Stretch="UniformToFill" />
</Border>
</Grid>
</Border>
<Border x:Name="NewsItem2Host"
Grid.Row="2"
Tag="1"
Background="Transparent"
Padding="0,2"
PointerPressed="OnNewsItemPointerPressed">
<Grid x:Name="NewsItem2Grid"
ColumnDefinitions="*,Auto"
ColumnSpacing="10">
<TextBlock x:Name="NewsItem2TextBlock"
Text="新闻标题"
Foreground="#202327"
FontSize="22"
FontWeight="SemiBold"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"
MaxLines="2"
VerticalAlignment="Top" />
<Border x:Name="NewsItem2ImageHost"
Grid.Column="1"
Width="148"
Height="84"
CornerRadius="12"
ClipToBounds="True"
Background="#E6E8EC">
<Image x:Name="NewsItem2Image"
Stretch="UniformToFill" />
</Border>
</Grid>
</Border>
<Border x:Name="NewsItem3Host"
Grid.Row="3"
Tag="2"
Background="Transparent"
Padding="0,2"
PointerPressed="OnNewsItemPointerPressed">
<Grid x:Name="NewsItem3Grid"
ColumnDefinitions="*,Auto"
ColumnSpacing="10">
<TextBlock x:Name="NewsItem3TextBlock"
Text="新闻标题"
Foreground="#202327"
FontSize="22"
FontWeight="SemiBold"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"
MaxLines="2"
VerticalAlignment="Top" />
<Border x:Name="NewsItem3ImageHost"
Grid.Column="1"
Width="148"
Height="84"
CornerRadius="12"
ClipToBounds="True"
Background="#E6E8EC">
<Image x:Name="NewsItem3Image"
Stretch="UniformToFill" />
</Border>
</Grid>
</Border>
<Border x:Name="NewsItem4Host"
Grid.Row="4"
Tag="3"
Background="Transparent"
Padding="0,2"
PointerPressed="OnNewsItemPointerPressed">
<Grid x:Name="NewsItem4Grid"
ColumnDefinitions="*,Auto"
ColumnSpacing="10">
<TextBlock x:Name="NewsItem4TextBlock"
Text="新闻标题"
Foreground="#202327"
FontSize="22"
FontWeight="SemiBold"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"
MaxLines="2"
VerticalAlignment="Top" />
<Border x:Name="NewsItem4ImageHost"
Grid.Column="1"
Width="148"
Height="84"
CornerRadius="12"
ClipToBounds="True"
Background="#E6E8EC">
<Image x:Name="NewsItem4Image"
Stretch="UniformToFill" />
</Border>
</Grid>
</Border>
<ScrollViewer x:Name="NewsScrollViewer"
Grid.Row="1"
VerticalScrollBarVisibility="Auto">
<StackPanel x:Name="NewsStackPanel" Spacing="6">
<TextBlock x:Name="LoadingTextBlock"
Text="正在加载..."
Foreground="#6A6F77"
FontSize="14"
HorizontalAlignment="Center"
IsVisible="False" />
</StackPanel>
</ScrollViewer>
</Grid>
</Border>

View File

@@ -36,7 +36,7 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
private const double BaseCellSize = 48d;
private const int BaseWidthCells = 4;
private const int BaseHeightCells = 4;
private const int MaxDisplayItemCount = 4;
private const int MaxDisplayItemCount = 12;
private static readonly IReadOnlyList<int> SupportedAutoRefreshIntervalsMinutes = RefreshIntervalCatalog.SupportedIntervalsMinutes;
private readonly DispatcherTimer _refreshTimer = new()
@@ -47,9 +47,9 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
private LanMountainDesktop.PluginSdk.ISettingsService _appSettingsService = LanMountainDesktop.Services.Settings.HostSettingsFacadeProvider.GetOrCreate().Settings;
private IComponentInstanceSettingsStore _componentSettingsService = HostComponentSettingsStoreProvider.GetOrCreate();
private readonly LocalizationService _localizationService = new();
private readonly List<DailyNewsItemSnapshot> _activeItems = [];
private readonly List<NewsItemVisual> _itemVisuals = [];
private readonly Bitmap?[] _newsBitmaps = new Bitmap?[MaxDisplayItemCount];
private readonly Dictionary<string, DailyNewsItemSnapshot> _newsByUrl = new(StringComparer.OrdinalIgnoreCase);
private readonly List<NewsItemControl> _itemControls = [];
private readonly Dictionary<string, Bitmap> _imageCache = new();
private IRecommendationInfoService _recommendationService = DefaultRecommendationService;
private CancellationTokenSource? _refreshCts;
@@ -61,28 +61,13 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
private bool _autoRefreshEnabled = true;
private bool _isNightVisual = true;
private sealed record NewsItemVisual(
Border Host,
Grid RowGrid,
TextBlock TitleTextBlock,
Border ImageHost,
Image ImageControl);
public IfengNewsWidget()
{
InitializeComponent();
BrandTextBlock.FontFamily = MiSansFontFamily;
NewsItem1TextBlock.FontFamily = MiSansFontFamily;
NewsItem2TextBlock.FontFamily = MiSansFontFamily;
NewsItem3TextBlock.FontFamily = MiSansFontFamily;
NewsItem4TextBlock.FontFamily = MiSansFontFamily;
StatusTextBlock.FontFamily = MiSansFontFamily;
_itemVisuals.Add(new NewsItemVisual(NewsItem1Host, NewsItem1Grid, NewsItem1TextBlock, NewsItem1ImageHost, NewsItem1Image));
_itemVisuals.Add(new NewsItemVisual(NewsItem2Host, NewsItem2Grid, NewsItem2TextBlock, NewsItem2ImageHost, NewsItem2Image));
_itemVisuals.Add(new NewsItemVisual(NewsItem3Host, NewsItem3Grid, NewsItem3TextBlock, NewsItem3ImageHost, NewsItem3Image));
_itemVisuals.Add(new NewsItemVisual(NewsItem4Host, NewsItem4Grid, NewsItem4TextBlock, NewsItem4ImageHost, NewsItem4Image));
LoadingTextBlock.FontFamily = MiSansFontFamily;
_refreshTimer.Tick += OnRefreshTimerTick;
AttachedToVisualTree += OnAttachedToVisualTree;
@@ -135,7 +120,7 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
_isAttached = false;
_refreshTimer.Stop();
CancelRefreshRequest();
DisposeNewsBitmaps();
DisposeImageCache();
UpdateRefreshButtonState();
}
@@ -191,18 +176,19 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
CardBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#1B2129") : Color.Parse("#FCFCFD"));
RootBorder.BorderBrush = new SolidColorBrush(_isNightVisual ? Color.Parse("#33FFFFFF") : Color.Parse("#00000000"));
BrandTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
BrandTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#FF6B5A") : Color.Parse("#E24B2D"));
NewsBadge.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#FF6B5A") : Color.Parse("#E24B2D"));
RefreshButton.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#EFF1F5"));
RefreshGlyphIcon.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#5E6671"));
foreach (var visual in _itemVisuals)
{
visual.Host.Background = Brushes.Transparent;
visual.TitleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
}
StatusTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#8B95A5") : Color.Parse("#6A6F77"));
LoadingTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#8B95A5") : Color.Parse("#6A6F77"));
foreach (var control in _itemControls)
{
control.ApplyNightMode(_isNightVisual);
}
}
private async void OnRefreshTimerTick(object? sender, EventArgs e)
@@ -217,22 +203,6 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
e.Handled = true;
}
private void OnNewsItemPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed ||
sender is not Border host ||
host.Tag is null ||
!int.TryParse(host.Tag.ToString(), out var index) ||
index < 0 ||
index >= _activeItems.Count)
{
return;
}
TryOpenUrl(_activeItems[index].Url);
e.Handled = true;
}
private async Task RefreshNewsAsync(bool forceRefresh)
{
if (!_isAttached || _isRefreshing)
@@ -272,7 +242,6 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
}
catch (OperationCanceledException)
{
// Ignore canceled requests.
}
catch
{
@@ -296,100 +265,90 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
private async Task ApplySnapshotAsync(DailyNewsSnapshot snapshot, CancellationToken cancellationToken)
{
BrandTextBlock.Text = L("ifeng.widget.brand", "凤凰网新闻");
ToolTip.SetTip(RefreshButton, L("ifeng.widget.refresh_tooltip", "刷新"));
_activeItems.Clear();
foreach (var item in snapshot.Items)
var newItems = snapshot.Items
.Where(item => !string.IsNullOrWhiteSpace(item.Url) && !_newsByUrl.ContainsKey(item.Url))
.ToList();
if (newItems.Count == 0 && _itemControls.Count == 0)
{
if (string.IsNullOrWhiteSpace(item.Title) || string.IsNullOrWhiteSpace(item.Url))
{
continue;
}
_activeItems.Add(item);
if (_activeItems.Count >= MaxDisplayItemCount)
{
break;
}
}
var fallbackText = L("ifeng.widget.fallback_item", "暂无新闻");
for (var i = 0; i < _itemVisuals.Count; i++)
{
var visual = _itemVisuals[i];
visual.Host.IsVisible = true;
visual.TitleTextBlock.Text = i < _activeItems.Count
? NormalizeCompactText(_activeItems[i].Title)
: fallbackText;
SetNewsBitmap(i, null);
}
StatusTextBlock.IsVisible = false;
UpdateInteractionState();
UpdateAdaptiveLayout();
var tasks = Enumerable.Range(0, MaxDisplayItemCount)
.Select(index => TryDownloadBitmapAsync(
index < _activeItems.Count ? _activeItems[index].ImageUrl : null,
cancellationToken))
.ToArray();
var bitmaps = await Task.WhenAll(tasks);
if (cancellationToken.IsCancellationRequested || !_isAttached)
{
foreach (var bitmap in bitmaps)
{
bitmap?.Dispose();
}
ApplyEmptyState();
return;
}
for (var i = 0; i < bitmaps.Length; i++)
foreach (var item in newItems)
{
SetNewsBitmap(i, bitmaps[i]);
_newsByUrl[item.Url] = item;
}
await Dispatcher.UIThread.InvokeAsync(() =>
{
if (!_isAttached) return;
LoadingTextBlock.IsVisible = false;
StatusTextBlock.IsVisible = false;
foreach (var item in newItems)
{
var control = new NewsItemControl(item, _isNightVisual);
control.Clicked += (s, url) => TryOpenUrl(url);
NewsStackPanel.Children.Insert(NewsStackPanel.Children.Count - 1, control);
_itemControls.Add(control);
}
UpdateAdaptiveLayout();
});
var imageTasks = newItems.Select(async item =>
{
var bitmap = await TryDownloadBitmapAsync(item.ImageUrl, cancellationToken);
if (bitmap != null && !cancellationToken.IsCancellationRequested)
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
if (_imageCache.TryGetValue(item.Url, out var oldBitmap))
{
oldBitmap.Dispose();
}
_imageCache[item.Url] = bitmap;
var control = _itemControls.FirstOrDefault(c => c.NewsUrl == item.Url);
control?.SetImage(bitmap);
});
}
});
await Task.WhenAll(imageTasks);
}
private void ApplyLoadingState()
{
BrandTextBlock.Text = L("ifeng.widget.brand", "凤凰网新闻");
ToolTip.SetTip(RefreshButton, L("ifeng.widget.refresh_tooltip", "刷新"));
_activeItems.Clear();
var loadingText = L("ifeng.widget.loading_item", "加载中...");
for (var i = 0; i < _itemVisuals.Count; i++)
{
var visual = _itemVisuals[i];
visual.Host.IsVisible = true;
visual.TitleTextBlock.Text = loadingText;
SetNewsBitmap(i, null);
}
StatusTextBlock.Text = L("ifeng.widget.loading", "加载中...");
StatusTextBlock.IsVisible = true;
UpdateInteractionState();
LoadingTextBlock.Text = L("ifeng.widget.loading", "加载中...");
LoadingTextBlock.IsVisible = true;
StatusTextBlock.IsVisible = false;
UpdateAdaptiveLayout();
}
private void ApplyFailedState()
{
BrandTextBlock.Text = L("ifeng.widget.brand", "凤凰网新闻");
ToolTip.SetTip(RefreshButton, L("ifeng.widget.refresh_tooltip", "刷新"));
_activeItems.Clear();
var fallbackText = L("ifeng.widget.fallback_item", "暂无新闻");
for (var i = 0; i < _itemVisuals.Count; i++)
{
var visual = _itemVisuals[i];
visual.Host.IsVisible = true;
visual.TitleTextBlock.Text = fallbackText;
SetNewsBitmap(i, null);
}
LoadingTextBlock.IsVisible = false;
StatusTextBlock.Text = L("ifeng.widget.fetch_failed", "新闻获取失败");
StatusTextBlock.IsVisible = true;
UpdateInteractionState();
UpdateAdaptiveLayout();
}
private void ApplyEmptyState()
{
ToolTip.SetTip(RefreshButton, L("ifeng.widget.refresh_tooltip", "刷新"));
LoadingTextBlock.IsVisible = false;
StatusTextBlock.Text = L("ifeng.widget.fallback_item", "暂无新闻");
StatusTextBlock.IsVisible = true;
UpdateAdaptiveLayout();
}
@@ -408,26 +367,13 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
var verticalPadding = Math.Clamp(14 * softScale, 8, 20);
CardBorder.Padding = new Thickness(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding);
var rowSpacing = Math.Clamp(8 * softScale, 4, 12);
ContentGrid.RowSpacing = rowSpacing;
HeaderGrid.ColumnSpacing = Math.Clamp(10 * softScale, 6, 16);
var headerHeight = Math.Clamp(totalHeight * 0.10, 28, 54);
HeaderGrid.Height = headerHeight;
HeaderGrid.Margin = new Thickness(0, 0, 0, Math.Clamp(8 * softScale, 4, 12));
var innerWidth = Math.Max(150, totalWidth - horizontalPadding * 2d);
var innerHeight = Math.Max(160, totalHeight - verticalPadding * 2d);
var availableRowsHeight = Math.Max(120, innerHeight - rowSpacing * 4d);
var headerHeight = Math.Clamp(availableRowsHeight * 0.16, 24, 54);
var itemHeight = Math.Max(32, (availableRowsHeight - headerHeight) / 4d);
if (ContentGrid.RowDefinitions.Count >= 5)
{
ContentGrid.RowDefinitions[0].Height = new GridLength(headerHeight);
for (var i = 1; i <= 4; i++)
{
ContentGrid.RowDefinitions[i].Height = new GridLength(itemHeight);
}
}
BrandTextBlock.FontSize = Math.Clamp(headerHeight * 0.62, 14, 30);
var brandFontSize = Math.Clamp(headerHeight * 0.62, 14, 30);
BrandTextBlock.FontSize = brandFontSize;
NewsBadgeText.FontSize = brandFontSize;
var refreshSize = Math.Clamp(headerHeight * 0.84, 22, 44);
RefreshButton.Width = refreshSize;
@@ -435,51 +381,25 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
RefreshButton.CornerRadius = new CornerRadius(refreshSize / 2d);
RefreshGlyphIcon.FontSize = Math.Clamp(refreshSize * 0.44, 10, 20);
var innerWidth = Math.Max(150, totalWidth - horizontalPadding * 2d);
var imageWidth = Math.Clamp(innerWidth * 0.27, 82, 176);
var imageHeight = Math.Clamp(imageWidth * 0.56, 46, 98);
var columnGap = Math.Clamp(itemHeight * 0.20, 6, 14);
var rowPadding = Math.Clamp(itemHeight * 0.08, 1, 5);
var textWidth = Math.Max(84, innerWidth - imageWidth - columnGap);
var titleFont = Math.Clamp(itemHeight * 0.32, 12, 24);
var baseTitleFont = 14;
var areaFactor = (totalWidth * totalHeight) / (BaseWidthCells * BaseCellSize * BaseHeightCells * BaseCellSize);
var adaptiveTitleFont = baseTitleFont * Math.Sqrt(Math.Clamp(areaFactor, 0.6, 2.5));
var titleFont = Math.Clamp(adaptiveTitleFont, 11, 26);
foreach (var visual in _itemVisuals)
foreach (var control in _itemControls)
{
visual.Host.Padding = new Thickness(0, rowPadding, 0, rowPadding);
visual.RowGrid.ColumnSpacing = columnGap;
if (visual.RowGrid.ColumnDefinitions.Count > 1)
{
visual.RowGrid.ColumnDefinitions[1].Width = new GridLength(imageWidth);
}
visual.ImageHost.Width = imageWidth;
visual.ImageHost.Height = imageHeight;
visual.ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(imageHeight * 0.15, 8, 16);
visual.TitleTextBlock.MaxWidth = textWidth;
visual.TitleTextBlock.FontSize = titleFont;
visual.TitleTextBlock.LineHeight = titleFont * 1.12;
visual.TitleTextBlock.MinHeight = visual.TitleTextBlock.LineHeight * 2;
visual.TitleTextBlock.MaxLines = 2;
control.UpdateLayout(softScale, innerWidth, imageWidth, imageHeight, titleFont);
}
StatusTextBlock.FontSize = Math.Clamp(titleFont, 10, 20);
StatusTextBlock.FontSize = Math.Clamp(titleFont, 10, 24);
LoadingTextBlock.FontSize = Math.Clamp(titleFont, 10, 24);
ApplyNightModeVisual();
}
private void UpdateInteractionState()
{
for (var i = 0; i < _itemVisuals.Count; i++)
{
var visual = _itemVisuals[i];
var enabled = i < _activeItems.Count && !string.IsNullOrWhiteSpace(_activeItems[i].Url);
visual.Host.IsHitTestVisible = enabled;
visual.Host.Opacity = enabled ? 1.0 : 0.68;
visual.Host.Cursor = enabled
? new Cursor(StandardCursorType.Hand)
: new Cursor(StandardCursorType.Arrow);
}
}
private void UpdateRefreshButtonState()
{
var enabled = _isAttached && !_isRefreshing;
@@ -515,7 +435,6 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
}
catch
{
// Keep fallback defaults.
}
_autoRefreshEnabled = enabled;
@@ -614,7 +533,6 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
}
catch
{
// Ignore malformed URLs or shell launch failures.
}
}
@@ -640,32 +558,13 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
return uri.ToString();
}
private void SetNewsBitmap(int index, Bitmap? bitmap)
private void DisposeImageCache()
{
if (index < 0 || index >= _newsBitmaps.Length)
foreach (var bitmap in _imageCache.Values)
{
bitmap?.Dispose();
return;
}
var visual = _itemVisuals[index];
var oldBitmap = _newsBitmaps[index];
if (ReferenceEquals(visual.ImageControl.Source, oldBitmap))
{
visual.ImageControl.Source = null;
}
oldBitmap?.Dispose();
_newsBitmaps[index] = bitmap;
visual.ImageControl.Source = bitmap;
}
private void DisposeNewsBitmaps()
{
for (var i = 0; i < _newsBitmaps.Length; i++)
{
SetNewsBitmap(i, null);
bitmap.Dispose();
}
_imageCache.Clear();
}
private double ResolveScale()
@@ -715,4 +614,142 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
cts.Cancel();
cts.Dispose();
}
private sealed class NewsItemControl : Border
{
private readonly DailyNewsItemSnapshot _item;
private readonly Grid _grid;
private readonly TextBlock _titleTextBlock;
private readonly Border _imageHost;
private readonly Image _imageControl;
private bool _isNightVisual;
private Point _pointerPressedPosition;
private bool _isPointerPressed;
public string NewsUrl => _item.Url;
public NewsItemControl(DailyNewsItemSnapshot item, bool isNightVisual)
{
_item = item;
_isNightVisual = isNightVisual;
Padding = new Thickness(0, 4);
Background = Brushes.Transparent;
Cursor = new Cursor(StandardCursorType.Hand);
PointerPressed += OnPointerPressed;
PointerReleased += OnPointerReleased;
PointerCaptureLost += OnPointerCaptureLost;
_titleTextBlock = new TextBlock
{
Text = NormalizeCompactText(item.Title),
Foreground = new SolidColorBrush(isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327")),
FontFamily = MiSansFontFamily,
FontWeight = FontWeight.SemiBold,
TextWrapping = TextWrapping.Wrap,
TextTrimming = TextTrimming.CharacterEllipsis,
MaxLines = 2,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Top
};
_imageControl = new Image
{
Stretch = Stretch.UniformToFill
};
_imageHost = new Border
{
Width = 148,
Height = 84,
CornerRadius = new CornerRadius(12),
ClipToBounds = true,
Background = new SolidColorBrush(isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#E6E8EC")),
Child = _imageControl
};
_grid = new Grid
{
ColumnDefinitions = ColumnDefinitions.Parse("*,Auto"),
ColumnSpacing = 10
};
Grid.SetColumn(_imageHost, 1);
_grid.Children.Add(_titleTextBlock);
_grid.Children.Add(_imageHost);
Child = _grid;
}
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
_isPointerPressed = true;
_pointerPressedPosition = e.GetPosition(this);
e.Handled = true;
}
}
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e)
{
if (!_isPointerPressed)
{
return;
}
_isPointerPressed = false;
var releasePosition = e.GetPosition(this);
var distance = Math.Sqrt(
Math.Pow(releasePosition.X - _pointerPressedPosition.X, 2) +
Math.Pow(releasePosition.Y - _pointerPressedPosition.Y, 2));
if (distance < 5)
{
Clicked?.Invoke(this, _item.Url);
}
e.Handled = true;
}
private void OnPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e)
{
_isPointerPressed = false;
}
public void ApplyNightMode(bool isNightVisual)
{
_isNightVisual = isNightVisual;
_titleTextBlock.Foreground = new SolidColorBrush(isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
_imageHost.Background = new SolidColorBrush(isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#E6E8EC"));
}
public void UpdateLayout(double scale, double innerWidth, double imageWidth, double imageHeight, double titleFont)
{
var columnGap = Math.Clamp(imageHeight * 0.20, 6, 14);
_grid.ColumnSpacing = columnGap;
if (_grid.ColumnDefinitions.Count > 1)
{
_grid.ColumnDefinitions[1] = new ColumnDefinition(new GridLength(imageWidth));
}
_imageHost.Width = imageWidth;
_imageHost.Height = imageHeight;
_imageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(imageHeight * 0.15, 8, 16);
var textWidth = Math.Max(84, innerWidth - imageWidth - columnGap);
_titleTextBlock.MaxWidth = textWidth;
_titleTextBlock.FontSize = titleFont;
_titleTextBlock.LineHeight = titleFont * 1.12;
_titleTextBlock.MinHeight = _titleTextBlock.LineHeight * 2;
}
public void SetImage(Bitmap bitmap)
{
_imageControl.Source = bitmap;
}
public event EventHandler<string>? Clicked;
}
}

View File

@@ -9,15 +9,15 @@
x:Class="LanMountainDesktop.Views.Components.JuyaNewsWidget">
<Border x:Name="RootBorder"
CornerRadius="24"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Background="Transparent"
ClipToBounds="True"
BorderThickness="0"
Padding="0">
<Grid>
<Border x:Name="CardBorder"
Background="#fefefe"
CornerRadius="24"
Background="#FCFCFD"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
BorderBrush="Transparent"
BorderThickness="0"
Padding="16,14,16,14">
@@ -52,7 +52,7 @@
<Button x:Name="RefreshButton"
Grid.Column="1"
Padding="8,4"
CornerRadius="8"
CornerRadius="{DynamicResource DesignCornerRadiusXs}"
Background="Transparent"
BorderBrush="#bb5649"
BorderThickness="1"

View File

@@ -8,10 +8,11 @@
x:Class="LanMountainDesktop.Views.Components.LunarCalendarWidget">
<Border x:Name="RootBorder"
Background="#EFE6D9"
CornerRadius="30"
ClipToBounds="True"
Padding="16">
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Padding="14,12">
<Viewbox Stretch="Uniform">
<Grid x:Name="LayoutRoot"
Width="300"

View File

@@ -11,9 +11,8 @@
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="28"
ClipToBounds="True"
Padding="14">
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Padding="14,12">
<Viewbox Stretch="Uniform">
<Grid x:Name="LayoutRoot"
Width="280"

View File

@@ -9,14 +9,14 @@
x:Class="LanMountainDesktop.Views.Components.MultiDayWeatherWidget">
<Border x:Name="RootBorder"
CornerRadius="28"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
Background="#6B7B8F">
<Grid>
<Border x:Name="BackgroundImageLayer" CornerRadius="28" ClipToBounds="True" />
<Border x:Name="BackgroundImageLayer" CornerRadius="{DynamicResource DesignCornerRadiusComponent}" ClipToBounds="True" />
<Border x:Name="BackgroundMotionLayer"
CornerRadius="28"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
Opacity="0.25"
RenderTransformOrigin="0.5,0.5">
@@ -28,9 +28,9 @@
</Border.RenderTransform>
</Border>
<Border x:Name="BackgroundTintLayer" CornerRadius="28" ClipToBounds="True" Opacity="0.12" />
<Border x:Name="BackgroundTintLayer" CornerRadius="{DynamicResource DesignCornerRadiusComponent}" ClipToBounds="True" Opacity="0.12" />
<Border x:Name="BackgroundLightLayer" CornerRadius="28" ClipToBounds="True" Opacity="0.52">
<Border x:Name="BackgroundLightLayer" CornerRadius="{DynamicResource DesignCornerRadiusComponent}" ClipToBounds="True" Opacity="0.52">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#45FFFFFF" Offset="0" />
@@ -40,7 +40,7 @@
</Border.Background>
</Border>
<Border x:Name="BackgroundShadeLayer" CornerRadius="28" ClipToBounds="True" Opacity="0.68">
<Border x:Name="BackgroundShadeLayer" CornerRadius="{DynamicResource DesignCornerRadiusComponent}" ClipToBounds="True" Opacity="0.68">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#00000000" Offset="0.42" />

View File

@@ -66,7 +66,7 @@
</UserControl.Styles>
<Border x:Name="RootBorder"
CornerRadius="30"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
BorderThickness="1"
BorderBrush="#52FFFFFF"
@@ -75,11 +75,11 @@
<Grid>
<Grid IsHitTestVisible="False">
<Border x:Name="DynamicBackgroundBase"
CornerRadius="30"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
Background="#B89E7B" />
<Border x:Name="BackdropCoverHost"
CornerRadius="30"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True">
<Image x:Name="BackdropCoverImage"
IsVisible="False"
@@ -91,10 +91,10 @@
</Image>
</Border>
<Border x:Name="DynamicGradientOverlay"
CornerRadius="30"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True" />
<Border x:Name="DynamicSoftLightOverlay"
CornerRadius="30"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True" />
</Grid>
@@ -111,7 +111,7 @@
<Border x:Name="CoverBorder"
Width="56"
Height="56"
CornerRadius="12"
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
ClipToBounds="True"
BorderThickness="1"
BorderBrush="#77FFFFFF"

View File

@@ -10,11 +10,11 @@
x:Class="LanMountainDesktop.Views.Components.OfficeRecentDocumentsWidget">
<Border x:Name="RootBorder"
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
Background="#2D5A8E"
ClipToBounds="True"
BorderThickness="0"
Padding="0">
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Padding="14,12">
<Grid>
<Border x:Name="AccentCorner"
Width="140"

View File

@@ -9,7 +9,7 @@
x:Class="LanMountainDesktop.Views.Components.RecordingWidget">
<Border x:Name="RootBorder"
CornerRadius="34"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Padding="0"
ClipToBounds="True"
Background="#ECEFF3"
@@ -23,7 +23,7 @@
Height="300"
HorizontalAlignment="Center"
VerticalAlignment="Center"
CornerRadius="34"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
BorderBrush="#00000000"
BorderThickness="0"
Background="Transparent">
@@ -65,7 +65,7 @@
Grid.Column="1"
Width="2"
Height="32"
CornerRadius="1"
CornerRadius="{DynamicResource DesignCornerRadiusMicro}"
Background="#F14A40"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
@@ -74,7 +74,7 @@
Grid.Column="2"
Margin="4,0,0,0"
Height="2"
CornerRadius="1"
CornerRadius="{DynamicResource DesignCornerRadiusMicro}"
Background="#A3A8B3"
Opacity="0.55"
HorizontalAlignment="Stretch"

View File

@@ -9,9 +9,11 @@
x:Class="LanMountainDesktop.Views.Components.RemovableStorageWidget">
<Border x:Name="RootBorder"
CornerRadius="28"
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
Padding="16"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Padding="14,12"
ClipToBounds="True">
<Grid>
<Border x:Name="AccentOrb"

View File

@@ -9,7 +9,7 @@
x:Class="LanMountainDesktop.Views.Components.Stcn24ForumWidget">
<Border x:Name="RootBorder"
CornerRadius="28"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Background="Transparent"
ClipToBounds="True"
BorderThickness="0"
@@ -17,7 +17,7 @@
<Grid>
<Border x:Name="CardBorder"
Background="#FCFCFD"
CornerRadius="28"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
BorderBrush="Transparent"
BorderThickness="0"
Padding="12,12,12,12">
@@ -69,12 +69,12 @@
</Grid>
<Border x:Name="PostItem1Host"
Grid.Row="1"
Tag="0"
Background="#F7F8FA"
CornerRadius="10"
Padding="8,6"
PointerPressed="OnPostItemPointerPressed">
Grid.Row="1"
Tag="0"
Background="#F7F8FA"
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
Padding="8,6"
PointerPressed="OnPostItemPointerPressed">
<Grid x:Name="PostItem1Grid"
ColumnDefinitions="Auto,*"
ColumnSpacing="8">

View File

@@ -7,10 +7,11 @@
d:DesignHeight="220"
x:Class="LanMountainDesktop.Views.Components.StudyDeductionReasonsWidget">
<Border x:Name="RootBorder"
Classes="surface-translucent-strong"
CornerRadius="22"
Padding="12,10"
ClipToBounds="True">
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Padding="14,12">
<Grid x:Name="ContentRootGrid"
RowDefinitions="Auto,*,Auto"
RowSpacing="8">
@@ -28,7 +29,7 @@
<Border x:Name="ModeBadgeBorder"
Grid.Column="1"
Padding="8,3"
CornerRadius="8"
CornerRadius="{DynamicResource DesignCornerRadiusXs}"
BorderThickness="1"
BorderBrush="#88FFFFFF"
Background="#553B82F6"
@@ -46,7 +47,7 @@
Grid.Row="1"
Spacing="6">
<Border x:Name="SustainedRowBorder"
CornerRadius="10"
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
Background="#2CFFFFFF"
BorderBrush="#33FFFFFF"
BorderThickness="1"
@@ -75,7 +76,7 @@
</Border>
<Border x:Name="TimeRowBorder"
CornerRadius="10"
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
Background="#2CFFFFFF"
BorderBrush="#33FFFFFF"
BorderThickness="1"
@@ -104,7 +105,7 @@
</Border>
<Border x:Name="SegmentRowBorder"
CornerRadius="10"
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
Background="#2CFFFFFF"
BorderBrush="#33FFFFFF"
BorderThickness="1"

View File

@@ -10,7 +10,7 @@
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="18"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Padding="14,10">
<Grid x:Name="LayoutGrid"
ColumnDefinitions="*,Auto"

View File

@@ -7,10 +7,11 @@
d:DesignHeight="220"
x:Class="LanMountainDesktop.Views.Components.StudyInterruptDensityWidget">
<Border x:Name="RootBorder"
Classes="surface-translucent-strong"
CornerRadius="22"
Padding="14,10"
ClipToBounds="True">
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Padding="14,12">
<Grid x:Name="ContentRootGrid"
RowDefinitions="Auto,*,Auto"
RowSpacing="8">
@@ -28,7 +29,7 @@
<Border x:Name="ModeBadgeBorder"
Grid.Column="1"
Padding="8,3"
CornerRadius="8"
CornerRadius="{DynamicResource DesignCornerRadiusXs}"
BorderThickness="1"
BorderBrush="#88FFFFFF"
Background="#553B82F6"
@@ -78,7 +79,7 @@
Spacing="6"
VerticalAlignment="Center">
<Border x:Name="CountCardBorder"
CornerRadius="10"
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
Background="#2CFFFFFF"
BorderBrush="#33FFFFFF"
BorderThickness="1"
@@ -101,7 +102,7 @@
</Border>
<Border x:Name="DurationCardBorder"
CornerRadius="10"
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
Background="#2CFFFFFF"
BorderBrush="#33FFFFFF"
BorderThickness="1"

View File

@@ -8,10 +8,11 @@
d:DesignHeight="320"
x:Class="LanMountainDesktop.Views.Components.StudyNoiseCurveWidget">
<Border x:Name="RootBorder"
Classes="surface-translucent-strong"
CornerRadius="24"
Padding="14,10"
ClipToBounds="True">
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Padding="14,12">
<Grid RowDefinitions="Auto,*"
RowSpacing="8">
<Grid Grid.Row="0"
@@ -19,7 +20,7 @@
ColumnSpacing="8">
<Border x:Name="StatusBadgeBorder"
Padding="8,3"
CornerRadius="8"
CornerRadius="{DynamicResource DesignCornerRadiusXs}"
Background="#7A0E2235"
BorderBrush="#88FFFFFF"
BorderThickness="1"

View File

@@ -8,10 +8,11 @@
d:DesignHeight="320"
x:Class="LanMountainDesktop.Views.Components.StudyNoiseDistributionWidget">
<Border x:Name="RootBorder"
Classes="surface-translucent-strong"
CornerRadius="24"
Padding="14,10"
ClipToBounds="True">
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Padding="14,12">
<Grid x:Name="ContentRootGrid"
RowDefinitions="Auto,*"
RowSpacing="8">
@@ -38,7 +39,7 @@
<Border x:Name="ModeBadgeBorder"
Grid.Column="2"
Padding="8,3"
CornerRadius="8"
CornerRadius="{DynamicResource DesignCornerRadiusXs}"
BorderThickness="1"
BorderBrush="#88FFFFFF"
Background="#553B82F6"

View File

@@ -7,10 +7,11 @@
d:DesignHeight="360"
x:Class="LanMountainDesktop.Views.Components.StudyScoreOverviewWidget">
<Border x:Name="RootBorder"
Classes="surface-translucent-strong"
CornerRadius="24"
Padding="16,14"
ClipToBounds="True">
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Padding="14,12">
<Grid x:Name="ContentRootGrid"
RowDefinitions="Auto,Auto,*,Auto"
RowSpacing="8">
@@ -30,7 +31,7 @@
<Border x:Name="ModeBadgeBorder"
Grid.Column="1"
Padding="8,3"
CornerRadius="8"
CornerRadius="{DynamicResource DesignCornerRadiusXs}"
BorderThickness="1"
BorderBrush="#88FFFFFF"
Background="#553B82F6"
@@ -70,7 +71,7 @@
ColumnDefinitions="*,*,*"
ColumnSpacing="10">
<Border x:Name="AverageCardBorder"
CornerRadius="12"
CornerRadius="{DynamicResource DesignCornerRadiusXs}"
Background="#24FFFFFF"
BorderBrush="#2EFFFFFF"
BorderThickness="1"
@@ -95,7 +96,7 @@
<Border x:Name="MinimumCardBorder"
Grid.Column="1"
CornerRadius="12"
CornerRadius="{DynamicResource DesignCornerRadiusXs}"
Background="#24FFFFFF"
BorderBrush="#2EFFFFFF"
BorderThickness="1"
@@ -120,7 +121,7 @@
<Border x:Name="MaximumCardBorder"
Grid.Column="2"
CornerRadius="12"
CornerRadius="{DynamicResource DesignCornerRadiusXs}"
Background="#24FFFFFF"
BorderBrush="#2EFFFFFF"
BorderThickness="1"

View File

@@ -8,8 +8,10 @@
d:DesignHeight="150"
x:Class="LanMountainDesktop.Views.Components.StudySessionControlWidget">
<Border x:Name="RootBorder"
Classes="surface-translucent-strong"
CornerRadius="18"
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Padding="14,10"
ClipToBounds="True">
<Grid x:Name="LayoutGrid"

View File

@@ -7,10 +7,11 @@
d:DesignHeight="220"
x:Class="LanMountainDesktop.Views.Components.StudySessionHistoryWidget">
<Border x:Name="RootBorder"
Classes="surface-translucent-strong"
CornerRadius="22"
Padding="12,10"
ClipToBounds="True">
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Padding="14,12">
<Grid>
<Grid x:Name="ContentRootGrid"
RowDefinitions="Auto,*,Auto"
@@ -24,7 +25,7 @@
TextTrimming="CharacterEllipsis" />
<Border Grid.Row="1"
CornerRadius="10"
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
Background="#1AFFFFFF"
BorderBrush="#26FFFFFF"
BorderThickness="1"
@@ -55,7 +56,7 @@
<Border x:Name="DialogCardBorder"
HorizontalAlignment="Stretch"
VerticalAlignment="Center"
CornerRadius="12"
CornerRadius="{DynamicResource DesignCornerRadiusXs}"
BorderThickness="1"
Padding="12">
<StackPanel Spacing="10">
@@ -79,12 +80,12 @@
<Button x:Name="DialogCancelButton"
Grid.Column="0"
Content="Cancel"
CornerRadius="8"
CornerRadius="{DynamicResource DesignCornerRadiusXs}"
Height="30" />
<Button x:Name="DialogConfirmButton"
Grid.Column="1"
Content="Confirm"
CornerRadius="8"
CornerRadius="{DynamicResource DesignCornerRadiusXs}"
Height="30" />
</Grid>
</StackPanel>

View File

@@ -473,6 +473,11 @@ public partial class StudySessionHistoryWidget : UserControl, IDesktopComponentW
_dialogSessionId = null;
_dialogSessionLabel = string.Empty;
DialogRenameTextBox.Text = string.Empty;
DialogOverlayBorder.IsVisible = false;
if (_currentSnapshot is not null)
{
RenderSnapshot(_currentSnapshot);
}
}
private void OnDialogRenameTextBoxKeyDown(object? sender, KeyEventArgs e)

View File

@@ -8,10 +8,11 @@
x:Class="LanMountainDesktop.Views.Components.TimerWidget">
<Border x:Name="RootBorder"
CornerRadius="34"
ClipToBounds="True"
Padding="14"
Background="#E8EAEE">
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Padding="14,12">
<Viewbox Stretch="Uniform">
<Grid x:Name="LayoutRoot"
Width="300"
@@ -21,7 +22,7 @@
Height="224"
HorizontalAlignment="Center"
VerticalAlignment="Center"
CornerRadius="32"
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
BorderThickness="1">
<Grid ColumnDefinitions="96,2,*">
<Grid Grid.Column="0"
@@ -69,14 +70,14 @@
Grid.Row="0"
Height="3"
Width="18"
CornerRadius="2"
CornerRadius="{DynamicResource DesignCornerRadiusMicro}"
HorizontalAlignment="Left"
Background="#D0D6E1" />
<Border x:Name="ScaleMark2"
Grid.Row="1"
Height="3"
Width="16"
CornerRadius="2"
CornerRadius="{DynamicResource DesignCornerRadiusMicro}"
Margin="0,18,0,0"
HorizontalAlignment="Left"
Background="#D0D6E1" />
@@ -84,7 +85,7 @@
Grid.Row="2"
Height="3"
Width="14"
CornerRadius="2"
CornerRadius="{DynamicResource DesignCornerRadiusMicro}"
Margin="0,18,0,0"
HorizontalAlignment="Left"
Background="#D0D6E1" />
@@ -92,7 +93,7 @@
Grid.Row="3"
Height="3"
Width="12"
CornerRadius="2"
CornerRadius="{DynamicResource DesignCornerRadiusMicro}"
Margin="0,18,0,0"
HorizontalAlignment="Left"
Background="#D0D6E1" />

View File

@@ -8,12 +8,12 @@
x:Class="LanMountainDesktop.Views.Components.WeatherClockWidget">
<Border x:Name="RootBorder"
Background="#FFFFFF"
BorderBrush="#14000000"
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="22"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
Padding="12,8">
Padding="14,12">
<Grid x:Name="ContentGrid"
ColumnDefinitions="*,Auto"
ColumnSpacing="10">

View File

@@ -9,16 +9,16 @@
x:Class="LanMountainDesktop.Views.Components.WeatherWidget">
<Border x:Name="RootBorder"
CornerRadius="30"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
Background="#68A9EC">
Background="#6B7B8F">
<Grid>
<Border x:Name="BackgroundImageLayer"
CornerRadius="30"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True" />
<Border x:Name="BackgroundMotionLayer"
CornerRadius="30"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
Opacity="0.20"
RenderTransformOrigin="0.5,0.5">
@@ -32,12 +32,12 @@
</Border>
<Border x:Name="BackgroundTintLayer"
CornerRadius="30"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
Opacity="0.16" />
<Border x:Name="BackgroundLightLayer"
CornerRadius="30"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
Opacity="0.62">
<Border.Background>
@@ -54,7 +54,7 @@
</Border>
<Border x:Name="BackgroundShadeLayer"
CornerRadius="30"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
Opacity="0.74">
<Border.Background>

View File

@@ -11,10 +11,12 @@
<Grid>
<Border x:Name="RootBorder"
Background="#F1F4F9"
CornerRadius="20"
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
Padding="8">
Padding="12">
<Grid RowDefinitions="*,Auto"
RowSpacing="8">
<Border x:Name="CanvasBorder"
@@ -22,7 +24,7 @@
Background="#FFFFFF"
BorderBrush="#24000000"
BorderThickness="1"
CornerRadius="14"
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
ClipToBounds="True">
<inking:InkCanvas x:Name="InkCanvas" />
</Border>
@@ -33,7 +35,7 @@
Background="#E6FFFFFF"
BorderBrush="#16000000"
BorderThickness="1"
CornerRadius="14"
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
Padding="8,6">
<StackPanel x:Name="ToolbarButtonsPanel"
Orientation="Horizontal"
@@ -101,7 +103,7 @@
<Border Background="{DynamicResource AdaptiveSurfaceBaseBrush}"
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
BorderThickness="1"
CornerRadius="8"
CornerRadius="{DynamicResource DesignCornerRadiusXs}"
Padding="12">
<StackPanel Spacing="12">
<ColorView x:Name="InkColorPicker"

View File

@@ -8,12 +8,11 @@
x:Class="LanMountainDesktop.Views.Components.WorldClockWidget">
<Border x:Name="RootBorder"
Background="#F4F5F7"
BorderBrush="#16000000"
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="26"
ClipToBounds="True"
Padding="10,8">
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Padding="14,12">
<Grid x:Name="ClockHostGrid"
ColumnDefinitions="*,*,*,*"
ColumnSpacing="8" />

View 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>

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

View File

@@ -1481,6 +1481,15 @@ public partial class MainWindow
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;
}
@@ -2602,7 +2611,7 @@ public partial class MainWindow
if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase))
{
return Symbol.Apps;
return Symbol.Hourglass;
}
if (string.Equals(categoryId, "File", StringComparison.OrdinalIgnoreCase))

View File

@@ -44,7 +44,16 @@ public partial class MainWindow
if (changedKeys.All(key =>
string.Equals(key, nameof(AppSettingsSnapshot.ThemeColorMode), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.SystemMaterialMode), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.SelectedWallpaperSeed), StringComparison.OrdinalIgnoreCase)))
string.Equals(key, nameof(AppSettingsSnapshot.SelectedWallpaperSeed), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.LastUpdateCheckUtcMs), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.PendingUpdateInstallerPath), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.PendingUpdateVersion), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.PendingUpdatePublishedAtUtcMs), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.IncludePrereleaseUpdates), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.UpdateChannel), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.UpdateMode), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.UpdateDownloadSource), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.UpdateDownloadThreads), StringComparison.OrdinalIgnoreCase)))
{
return;
}
@@ -443,10 +452,13 @@ public partial class MainWindow
currentVersion = new Version(0, 0, 0);
}
var normalizedVersion = new Version(
Math.Max(0, currentVersion.Major),
Math.Max(0, currentVersion.Minor),
Math.Max(0, currentVersion.Build));
var major = Math.Max(0, currentVersion.Major);
var minor = Math.Max(0, currentVersion.Minor);
var build = Math.Max(0, currentVersion.Build >= 0 ? currentVersion.Build : 0);
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(
async () =>
@@ -581,6 +593,7 @@ public partial class MainWindow
var latestUpdateState = _updateSettingsService.Get();
var latestThemeState = _themeSettingsService.Get();
var latestPrivacyState = _settingsFacade.Privacy.Get();
var existingSnapshot = _settingsService.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
return new AppSettingsSnapshot
{
GridShortSideCells = _targetShortSideCells,
@@ -632,7 +645,21 @@ public partial class MainWindow
ClockDisplayFormat = _clockDisplayFormat == ClockDisplayFormat.HourMinute ? "HourMinute" : "HourMinuteSecond",
StatusBarClockTransparentBackground = _statusBarClockTransparentBackground,
StatusBarSpacingMode = _statusBarSpacingMode,
StatusBarCustomSpacingPercent = _statusBarCustomSpacingPercent
StatusBarCustomSpacingPercent = _statusBarCustomSpacingPercent,
DisabledPluginIds = existingSnapshot.DisabledPluginIds,
StudyFrameMs = existingSnapshot.StudyFrameMs,
StudyScoreThresholdDbfs = existingSnapshot.StudyScoreThresholdDbfs,
StudyFocusDurationMinutes = existingSnapshot.StudyFocusDurationMinutes,
StudyBreakDurationMinutes = existingSnapshot.StudyBreakDurationMinutes,
StudyLongBreakDurationMinutes = existingSnapshot.StudyLongBreakDurationMinutes,
StudySessionsBeforeLongBreak = existingSnapshot.StudySessionsBeforeLongBreak,
StudyAutoStartBreak = existingSnapshot.StudyAutoStartBreak,
StudyAutoStartFocus = existingSnapshot.StudyAutoStartFocus,
StudyNoiseAlertEnabled = existingSnapshot.StudyNoiseAlertEnabled,
StudyMaxInterruptsPerMinute = existingSnapshot.StudyMaxInterruptsPerMinute,
StudyShowRealtimeDb = existingSnapshot.StudyShowRealtimeDb,
StudyBaselineDb = existingSnapshot.StudyBaselineDb,
StudyAvgWindowSec = existingSnapshot.StudyAvgWindowSec
};
}

View File

@@ -8,7 +8,7 @@ namespace LanMountainDesktop.Views.SettingsPages;
"components",
"Components",
SettingsPageCategory.Components,
IconKey = "Apps",
IconKey = "AppFolder",
SortOrder = 20,
TitleLocalizationKey = "settings.components.title",
DescriptionLocalizationKey = "settings.components.description")]

View File

@@ -8,7 +8,7 @@ namespace LanMountainDesktop.Views.SettingsPages;
"launcher",
"App Launcher",
SettingsPageCategory.Components,
IconKey = "Apps",
IconKey = "AppsListDetail",
SortOrder = 10,
Scope = SettingsScope.Launcher,
TitleLocalizationKey = "settings.launcher.title",

View File

@@ -4,8 +4,8 @@
xmlns:mdxaml="https://github.com/whistyun/Markdown.Avalonia"
xmlns:helpers="using:LanMountainDesktop.Helpers"
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
x:Class="LanMountainDesktop.Views.SettingsPages.PluginMarketDetailDrawer"
x:DataType="vm:PluginMarketDetailViewModel">
x:Class="LanMountainDesktop.Views.SettingsPages.PluginCatalogDetailDrawer"
x:DataType="vm:PluginCatalogDetailViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Classes="settings-page-container"
Margin="0,0,0,8">
@@ -41,7 +41,7 @@
</StackPanel>
<Button Grid.Column="2"
Classes="plugin-market-icon-button"
Classes="plugin-catalog-icon-button"
VerticalAlignment="Center"
Command="{Binding PerformPrimaryActionCommand}"
IsEnabled="{Binding Item.IsActionEnabled}"
@@ -103,7 +103,7 @@
TextWrapping="Wrap" />
<mdxaml:MarkdownScrollViewer IsVisible="{Binding HasReadmeContent}"
Markdown="{Binding ReadmeMarkdown}"
Engine="{x:Static helpers:PluginMarketMarkdownHelper.Engine}" />
Engine="{x:Static helpers:PluginCatalogMarkdownHelper.Engine}" />
</StackPanel>
</Border>

View File

@@ -3,14 +3,14 @@ using LanMountainDesktop.ViewModels;
namespace LanMountainDesktop.Views.SettingsPages;
public partial class PluginMarketDetailDrawer : UserControl
public partial class PluginCatalogDetailDrawer : UserControl
{
public PluginMarketDetailDrawer()
public PluginCatalogDetailDrawer()
{
InitializeComponent();
}
public PluginMarketDetailDrawer(PluginMarketDetailViewModel viewModel)
public PluginCatalogDetailDrawer(PluginCatalogDetailViewModel viewModel)
{
DataContext = viewModel;
InitializeComponent();

View File

@@ -3,9 +3,9 @@
xmlns:vm="using:LanMountainDesktop.ViewModels"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
x:Class="LanMountainDesktop.Views.SettingsPages.PluginMarketSettingsPage"
x:Class="LanMountainDesktop.Views.SettingsPages.PluginCatalogSettingsPage"
x:Name="Root"
x:DataType="vm:PluginMarketSettingsPageViewModel">
x:DataType="vm:PluginCatalogSettingsPageViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Classes="settings-page-container settings-page-animated">
<ui:SettingsExpander Header="{Binding RefreshButtonText}"
@@ -47,7 +47,7 @@
</Style>
</ListBox.Styles>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:PluginMarketItemViewModel">
<DataTemplate x:DataType="vm:PluginCatalogItemViewModel">
<Border Classes="settings-list-item">
<Grid ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="14">
@@ -70,7 +70,7 @@
</Border>
<Button Grid.Column="1"
Classes="plugin-market-row-button"
Classes="plugin-catalog-row-button"
Command="{Binding #Root.DataContext.OpenDetailsCommand}"
CommandParameter="{Binding}">
<StackPanel Spacing="4"
@@ -83,7 +83,7 @@
</Button>
<Button Grid.Column="2"
Classes="plugin-market-icon-button"
Classes="plugin-catalog-icon-button"
VerticalAlignment="Center"
Command="{Binding #Root.DataContext.ExecutePrimaryActionCommand}"
CommandParameter="{Binding}"

View File

@@ -9,21 +9,21 @@ using LanMountainDesktop.ViewModels;
namespace LanMountainDesktop.Views.SettingsPages;
[SettingsPageInfo(
"plugin-market",
"Plugin Market",
SettingsPageCategory.PluginMarket,
"plugin-catalog",
"Plugin Catalog",
SettingsPageCategory.PluginCatalog,
IconKey = "ShoppingBag",
SortOrder = 35,
TitleLocalizationKey = "settings.plugin_market.title",
DescriptionLocalizationKey = "settings.plugin_market.subtitle")]
public partial class PluginMarketSettingsPage : SettingsPageBase
TitleLocalizationKey = "settings.plugin_catalog.title",
DescriptionLocalizationKey = "settings.plugin_catalog.subtitle")]
public partial class PluginCatalogSettingsPage : SettingsPageBase
{
public PluginMarketSettingsPage()
public PluginCatalogSettingsPage()
: this(Design.IsDesignMode ? CreateDesignTimeViewModel() : CreateDefaultViewModel())
{
}
public PluginMarketSettingsPage(PluginMarketSettingsPageViewModel viewModel)
public PluginCatalogSettingsPage(PluginCatalogSettingsPageViewModel viewModel)
{
ViewModel = viewModel;
ViewModel.RestartRequested += OnRestartRequested;
@@ -32,7 +32,7 @@ public partial class PluginMarketSettingsPage : SettingsPageBase
InitializeComponent();
}
public PluginMarketSettingsPageViewModel ViewModel { get; }
public PluginCatalogSettingsPageViewModel ViewModel { get; }
public override async void OnNavigatedTo(object? parameter)
{
@@ -44,22 +44,22 @@ public partial class PluginMarketSettingsPage : SettingsPageBase
await ViewModel.InitializeAsync();
}
private static PluginMarketSettingsPageViewModel CreateDefaultViewModel()
private static PluginCatalogSettingsPageViewModel CreateDefaultViewModel()
{
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
var localizationService = new LocalizationService();
return new PluginMarketSettingsPageViewModel(
return new PluginCatalogSettingsPageViewModel(
settingsFacade,
localizationService,
new AirAppMarketIconService(),
new AirAppMarketReadmeService());
}
private static PluginMarketSettingsPageViewModel CreateDesignTimeViewModel()
private static PluginCatalogSettingsPageViewModel CreateDesignTimeViewModel()
{
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
var localizationService = new LocalizationService();
var viewModel = new PluginMarketSettingsPageViewModel(
var viewModel = new PluginCatalogSettingsPageViewModel(
settingsFacade,
localizationService,
new AirAppMarketIconService(),
@@ -68,8 +68,8 @@ public partial class PluginMarketSettingsPage : SettingsPageBase
var previewHostVersion = new Version(1, 2, 0);
var items = new[]
{
CreateMarketItem(
new PluginMarketPluginInfo(
CreateCatalogItemViewModel(
CreateCatalogItem(
"news-tiles",
"News Tiles",
"Brings editorial news cards and ticker rows to the desktop.",
@@ -91,8 +91,8 @@ public partial class PluginMarketSettingsPage : SettingsPageBase
localizationService,
installedPlugin: null,
previewHostVersion),
CreateMarketItem(
new PluginMarketPluginInfo(
CreateCatalogItemViewModel(
CreateCatalogItem(
"workspace-pulse",
"Workspace Pulse",
"Tracks active projects and shows a compact productivity summary.",
@@ -125,8 +125,8 @@ public partial class PluginMarketSettingsPage : SettingsPageBase
true,
null),
previewHostVersion),
CreateMarketItem(
new PluginMarketPluginInfo(
CreateCatalogItemViewModel(
CreateCatalogItem(
"glass-panels",
"Glass Panels",
"Adds experimental acrylic surfaces for plugin-powered widgets.",
@@ -152,7 +152,7 @@ public partial class PluginMarketSettingsPage : SettingsPageBase
foreach (var item in items)
{
viewModel.MarketPlugins.Add(item);
viewModel.CatalogPlugins.Add(item);
viewModel.FilteredPlugins.Add(item);
}
@@ -167,24 +167,87 @@ public partial class PluginMarketSettingsPage : SettingsPageBase
RequestRestart(reason ?? ViewModel.RestartRequiredMessage);
}
private async void OnDetailsRequested(PluginMarketItemViewModel item)
private async void OnDetailsRequested(PluginCatalogItemViewModel item)
{
var detailViewModel = ViewModel.CreateDetailViewModel(item);
var drawer = new PluginMarketDetailDrawer(detailViewModel);
var drawer = new PluginCatalogDetailDrawer(detailViewModel);
OpenDrawer(drawer, detailViewModel.DrawerTitle);
await detailViewModel.InitializeAsync();
}
private static PluginMarketItemViewModel CreateMarketItem(
PluginMarketPluginInfo plugin,
private static PluginCatalogItemViewModel CreateCatalogItemViewModel(
PluginCatalogItemInfo plugin,
LocalizationService localizationService,
InstalledPluginInfo? installedPlugin,
Version hostVersion)
{
var languageCode = localizationService.NormalizeLanguageCode(
HostSettingsFacadeProvider.GetOrCreate().Region.Get().LanguageCode);
var item = new PluginMarketItemViewModel(plugin, localizationService, languageCode);
var item = new PluginCatalogItemViewModel(plugin, localizationService, languageCode);
item.ApplyInstallState(installedPlugin, hostVersion);
return item;
}
private static PluginCatalogItemInfo CreateCatalogItem(
string id,
string name,
string description,
string author,
string version,
string apiVersion,
string minHostVersion,
string downloadUrl,
string releaseTag,
string releaseAssetName,
string iconUrl,
string readmeUrl,
string homepageUrl,
string repositoryUrl,
string[] tags,
PluginCatalogSharedContractInfo[] sharedContracts,
DateTimeOffset publishedAt,
DateTimeOffset updatedAt)
{
return new PluginCatalogItemInfo(
new PluginCatalogManifestInfo(
id,
name,
description,
author,
version,
apiVersion,
string.Empty,
sharedContracts),
new PluginCatalogCompatibilityInfo(
minHostVersion,
apiVersion),
new PluginCatalogRepositoryInfo(
iconUrl,
homepageUrl,
readmeUrl,
homepageUrl,
repositoryUrl,
tags,
string.Empty),
new PluginCatalogPublicationInfo(
releaseTag,
releaseAssetName,
publishedAt,
updatedAt,
0,
string.Empty,
null),
string.IsNullOrWhiteSpace(downloadUrl)
? []
: [
new PluginPackageSourceInfo(
string.IsNullOrWhiteSpace(releaseTag)
? LanMountainDesktop.Services.Settings.PluginPackageSourceKind.RawFallback
: LanMountainDesktop.Services.Settings.PluginPackageSourceKind.ReleaseAsset,
downloadUrl,
string.Empty,
0)
],
[]);
}
}

View File

@@ -47,7 +47,7 @@
<mdxaml:MarkdownScrollViewer IsVisible="{Binding HasContent}"
Markdown="{Binding MarkdownContent}"
Engine="{x:Static helpers:PluginMarketMarkdownHelper.Engine}" />
Engine="{x:Static helpers:PluginCatalogMarkdownHelper.Engine}" />
</StackPanel>
</Border>
</StackPanel>

View File

@@ -8,7 +8,7 @@ namespace LanMountainDesktop.Views.SettingsPages;
"status-bar",
"Status Bar",
SettingsPageCategory.Components,
IconKey = "Apps",
IconKey = "MatchAppLayout",
SortOrder = 15,
TitleLocalizationKey = "settings.status_bar.title",
DescriptionLocalizationKey = "settings.status_bar.description")]

View File

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

Some files were not shown because too many files have changed in this diff Show More