mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
Compare commits
9 Commits
v0.7.8
...
9045624105
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9045624105 | ||
|
|
2dc729c9db | ||
|
|
5804627f53 | ||
|
|
7a268489c9 | ||
|
|
148e4c894a | ||
|
|
f84111e837 | ||
|
|
bd2313fe7e | ||
|
|
372b5b7adc | ||
|
|
74703582e7 |
45
.github/README.md
vendored
45
.github/README.md
vendored
@@ -1,45 +0,0 @@
|
||||
# LanMountainDesktop
|
||||
|
||||
> 你的桌面,不止一面。
|
||||
|
||||
`LanMountainDesktop` 是一个基于 Avalonia 的桌面壳层项目,目标不是“做一个启动器”,而是把桌面变成可编排的信息与交互空间。
|
||||
|
||||
## 项目定位
|
||||
- 以网格化布局组织桌面组件,支持多页桌面与组件自由摆放。
|
||||
- 提供顶部状态栏 + 底部任务栏的桌面框架,强调信息密度与可读性平衡。
|
||||
- 通过主题色、日夜模式、玻璃视觉与动画系统,形成统一的视觉语言。
|
||||
- 通过组件注册机制与 JSON 扩展入口,让桌面能力可持续扩展。
|
||||
|
||||
## 核心能力
|
||||
- 桌面组件系统:天气、时钟、计时器、课程表、日历、白板、音乐控制、学习环境等组件可组合使用。
|
||||
- 壁纸系统:支持图片与视频壁纸,并可在设置中实时预览。
|
||||
- 主题系统:支持日夜模式、主题色与调色联动(Monet 风格色板)。
|
||||
- 个性化设置:网格密度、状态栏间距、任务栏布局、语言与时区等可持久化配置。
|
||||
- 本地化:内置 `zh-CN` 与 `en-US` 资源。
|
||||
|
||||
## 工程结构
|
||||
- `LanMountainDesktop/`:桌面端主程序(Avalonia)。
|
||||
- `LanMountainDesktop.RecommendationBackend/`:推荐内容后端服务(ASP.NET Core Minimal API)。
|
||||
- `docs/`:视觉与圆角等规范文档。
|
||||
- `LanMountainDesktop/ComponentSystem/`:组件定义、注册、放置规则与扩展入口。
|
||||
|
||||
## 技术栈
|
||||
- .NET 10(`net10.0`)
|
||||
- Avalonia 11
|
||||
- FluentAvalonia + FluentIcons.Avalonia
|
||||
- LibVLCSharp(用于视频相关能力)
|
||||
- WebView.Avalonia(嵌入式网页组件能力)
|
||||
|
||||
## 扩展机制(摘要)
|
||||
- 组件系统通过 `ComponentRegistry` 合并内置组件与扩展组件。
|
||||
- 运行时会扫描 `Extensions/Components/*.json`(相对应用输出目录)加载第三方组件清单。
|
||||
- 扩展契约与字段说明见组件系统文档:`LanMountainDesktop/ComponentSystem/README.md`。
|
||||
|
||||
## 当前状态
|
||||
- 项目包含桌面端与推荐后端两个子项目,并在同一 `LanMountainDesktop.slnx` 工作区中维护。
|
||||
- 配置默认写入本地:`%LOCALAPPDATA%\LanMountainDesktop\settings.json`。
|
||||
- 当前体验以 Windows 为主要目标平台。
|
||||
- SDK 版本由仓库根目录 `global.json` 锁定。
|
||||
|
||||
## 运行说明
|
||||
运行与环境准备已拆分到独立文档:[`run.md`](./run.md)
|
||||
133
.github/READMEmd
vendored
Normal file
133
.github/READMEmd
vendored
Normal file
@@ -0,0 +1,133 @@
|
||||
# 阑山桌面 / LanMountainDesktop
|
||||
|
||||
> 你的桌面,不止一面
|
||||
|
||||
[](https://dotnet.microsoft.com/)
|
||||
[](https://avaloniaui.net/)
|
||||
[](LICENSE)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **温馨提示**:本项目有部分成分由**氛围编程 (Vibe Coding)** 方式编写。
|
||||
>
|
||||
> 如果您对此类项目有固有的排斥感,请无视此项目,谢谢。
|
||||
|
||||
## 简介
|
||||
|
||||
**阑山桌面**是一个跨平台桌面环境增强工具,面向需要高频查看信息、追求桌面效率与个性化体验的用户。
|
||||
|
||||
基于 Avalonia UI 和 .NET 10 构建,支持 Windows、Linux、macOS 三大平台。
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## 核心特性
|
||||
|
||||
### 📊 信息聚合
|
||||
- 课程表、日历、天气、新闻、热搜
|
||||
- 所有信息一目了然,无需频繁切换窗口
|
||||
|
||||
### 🎯 效率工具
|
||||
- 自习环境监测、计时器、知识卡片
|
||||
- 最近文档、浏览器快捷入口
|
||||
- 常用工具组件一键触达
|
||||
|
||||
### 🎨 个性化桌面
|
||||
- 自由布局,随心所欲摆放组件
|
||||
- 多页桌面,工作学习场景分离
|
||||
- 主题切换、玻璃效果、圆角风格
|
||||
|
||||
### 🔌 插件生态
|
||||
- 通过 `.laapp` 插件扩展功能
|
||||
- 官方 Plugin SDK 支持自定义组件
|
||||
- 设置页、组件、集成功能一站式接入
|
||||
|
||||
## 为谁而设计
|
||||
|
||||
| 用户类型 | 典型场景 |
|
||||
|---------|---------|
|
||||
| 🎓 学生用户 | 课程表、自习监测、计时、天气和日常信息聚合 |
|
||||
| 💼 办公用户 | 日历、资讯、最近文档、常用工具入口 |
|
||||
| 🎨 效率爱好者 | 自由布局、主题切换、插件扩展 |
|
||||
| 🇨🇳 中文用户 | 本地化界面、农历和节假日等本地语境支持 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
- .NET SDK 10
|
||||
|
||||
### 构建与运行
|
||||
|
||||
```bash
|
||||
# 还原依赖
|
||||
dotnet restore
|
||||
|
||||
# 构建项目
|
||||
dotnet build LanMountainDesktop.slnx -c Debug
|
||||
|
||||
# 运行桌面宿主
|
||||
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
|
||||
```
|
||||
|
||||
### 运行测试
|
||||
|
||||
```bash
|
||||
dotnet test LanMountainDesktop.slnx -c Debug
|
||||
```
|
||||
|
||||
## 插件开发
|
||||
|
||||
阑山桌面支持通过 Plugin SDK 开发自定义插件:
|
||||
|
||||
```bash
|
||||
# 安装插件模板
|
||||
dotnet new install LanMountainDesktop.PluginTemplate
|
||||
|
||||
# 创建新插件
|
||||
dotnet new lmd-plugin -n MyPlugin
|
||||
```
|
||||
|
||||
- **Plugin SDK**: `LanMountainDesktop.PluginSdk` (API 4.0.0)
|
||||
- **共享契约**: `LanMountainDesktop.Shared.Contracts`
|
||||
- **迁移指南**: [PLUGIN_SDK_V4_MIGRATION.md](docs/PLUGIN_SDK_V4_MIGRATION.md)
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
LanMountainDesktop/
|
||||
├── LanMountainDesktop/ # 桌面宿主应用
|
||||
├── LanMountainDesktop.PluginSdk/ # 官方插件 SDK
|
||||
├── LanMountainDesktop.Shared.Contracts/ # 宿主与插件共享契约
|
||||
├── LanMountainDesktop.Appearance/ # 主题与外观基础设施
|
||||
├── LanMountainDesktop.Settings.Core/# 设置持久化基础设施
|
||||
└── LanMountainDesktop.Tests/ # 测试项目
|
||||
```
|
||||
|
||||
## 生态边界
|
||||
|
||||
| 项目 | 职责 |
|
||||
|-----|------|
|
||||
| **本仓库** | 桌面宿主、插件运行时、Plugin SDK、共享契约 |
|
||||
| [LanAirApp](https://github.com/yourorg/LanAirApp) | 插件市场元数据、开发者生态材料 |
|
||||
| [LanMountainDesktop.SamplePlugin](https://github.com/yourorg/LanMountainDesktop.SamplePlugin) | 官方示例插件 |
|
||||
|
||||
## 文档索引
|
||||
|
||||
- [产品定位](docs/PRODUCT.md) - 产品愿景与目标用户
|
||||
- [架构说明](docs/ARCHITECTURE.md) - 仓库结构与运行时主线
|
||||
- [开发指南](docs/DEVELOPMENT.md) - 构建、测试、调试
|
||||
- [视觉规范](docs/VISUAL_SPEC.md) - 主题、颜色、玻璃层级
|
||||
- [圆角规范](docs/CORNER_RADIUS_SPEC.md) - 圆角层级与动态规则
|
||||
- [贡献指南](docs/CONTRIBUTING.md) - PR、spec、文档协作规则
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **UI 框架**: [Avalonia UI](https://avaloniaui.net/)
|
||||
- **开发平台**: [.NET 10](https://dotnet.microsoft.com/)
|
||||
- **支持平台**: Windows 10+, Linux, macOS
|
||||
|
||||
## 许可证
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
|
||||
@@ -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. 权威来源
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -9,5 +9,6 @@ public enum PluginCornerRadiusPreset
|
||||
Md = 4,
|
||||
Lg = 5,
|
||||
Xl = 6,
|
||||
Island = 7
|
||||
Island = 7,
|
||||
Component = 8
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -6,6 +6,8 @@ public enum SettingsPageCategory
|
||||
Appearance = 10,
|
||||
Components = 20,
|
||||
Plugins = 30,
|
||||
PluginCatalog = 35,
|
||||
[Obsolete("Use PluginCatalog instead.")]
|
||||
PluginMarket = 35,
|
||||
About = 40
|
||||
}
|
||||
|
||||
@@ -9,4 +9,5 @@ public sealed record AppearanceCornerRadiusTokens(
|
||||
CornerRadius Md,
|
||||
CornerRadius Lg,
|
||||
CornerRadius Xl,
|
||||
CornerRadius Island);
|
||||
CornerRadius Island,
|
||||
CornerRadius Component);
|
||||
|
||||
@@ -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())
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -6,7 +6,7 @@ using Markdown.Avalonia;
|
||||
|
||||
namespace LanMountainDesktop.Helpers;
|
||||
|
||||
public static class PluginMarketMarkdownHelper
|
||||
public static class PluginCatalogMarkdownHelper
|
||||
{
|
||||
private static Markdown.Avalonia.Markdown? _engine;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -203,6 +203,54 @@
|
||||
"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.master_switch_header": "Study Feature",
|
||||
"settings.study.master_switch_desc": "Enable study environment monitoring and focus timer. When disabled, related components will not collect any data.",
|
||||
"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 +466,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 +578,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.",
|
||||
@@ -832,6 +885,8 @@
|
||||
"recording.widget.hint.saved_format": "Saved {0}",
|
||||
"recording.widget.save_picker_title": "Save recording file",
|
||||
"recording.widget.save_picker_type": "WAV audio",
|
||||
"study.widget.disabled_title": "Study Feature Disabled",
|
||||
"study.widget.disabled_hint": "Please enable in Settings",
|
||||
"study.environment.status_label": "Environment",
|
||||
"study.environment.status.initializing": "Initializing",
|
||||
"study.environment.status.ready": "Ready",
|
||||
@@ -972,5 +1027,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."
|
||||
}
|
||||
|
||||
@@ -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": "ロード済み",
|
||||
|
||||
@@ -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": "로드됨",
|
||||
|
||||
@@ -206,6 +206,54 @@
|
||||
"settings.weather.location_required": "天气位置不能为空。",
|
||||
"settings.weather.location_current_format": "当前天气位置:{0}",
|
||||
"settings.weather.location_saved_format": "天气位置已保存:{0}",
|
||||
"settings.study.title": "自习",
|
||||
"settings.study.description": "配置自习环境监测、专注计时和提醒设置。",
|
||||
"settings.study.master_switch_header": "自习功能",
|
||||
"settings.study.master_switch_desc": "启用自习环境监测和专注计时功能。关闭后,相关组件将不会采集任何数据。",
|
||||
"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 +461,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 +524,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 +572,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 +584,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": "已加载",
|
||||
@@ -825,6 +878,8 @@
|
||||
"recording.widget.hint.saved_format": "已保存 {0}",
|
||||
"recording.widget.save_picker_title": "保存录音文件",
|
||||
"recording.widget.save_picker_type": "WAV 音频",
|
||||
"study.widget.disabled_title": "自习功能未启用",
|
||||
"study.widget.disabled_hint": "请在设置中开启",
|
||||
"study.environment.status_label": "环境状态",
|
||||
"study.environment.status.initializing": "初始化中",
|
||||
"study.environment.status.ready": "待机",
|
||||
@@ -966,5 +1021,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 仓库获取并缓存在本地。"
|
||||
}
|
||||
|
||||
@@ -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,38 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public List<string> DisabledPluginIds { get; set; } = [];
|
||||
|
||||
#region Study Settings
|
||||
|
||||
public bool StudyEnabled { get; set; } = true;
|
||||
|
||||
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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
512
LanMountainDesktop/Services/ZhiJiaoHubCacheService.cs
Normal file
512
LanMountainDesktop/Services/ZhiJiaoHubCacheService.cs
Normal file
@@ -0,0 +1,512 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Security.Authentication;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LanMountainDesktop.Models;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed record ZhiJiaoHubLocalImageItem(
|
||||
string Name,
|
||||
string OriginalUrl,
|
||||
string LocalPath,
|
||||
int Index);
|
||||
|
||||
public sealed record ZhiJiaoHubLocalSnapshot(
|
||||
IReadOnlyList<ZhiJiaoHubLocalImageItem> Images,
|
||||
string Source,
|
||||
DateTimeOffset LastUpdated,
|
||||
int TotalCount);
|
||||
|
||||
public sealed record ZhiJiaoHubSyncResult(
|
||||
bool Success,
|
||||
ZhiJiaoHubLocalSnapshot? Snapshot,
|
||||
int DownloadedCount,
|
||||
int SkippedCount,
|
||||
int FailedCount,
|
||||
string? ErrorMessage = null);
|
||||
|
||||
public sealed class ZhiJiaoHubCacheService : IDisposable
|
||||
{
|
||||
private static readonly HttpClient DownloadClient;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private readonly string _cacheDirectory;
|
||||
private readonly string _manifestPath;
|
||||
private readonly object _manifestLock = new();
|
||||
private bool _isDisposed;
|
||||
|
||||
static ZhiJiaoHubCacheService()
|
||||
{
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
SslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13,
|
||||
AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate
|
||||
};
|
||||
|
||||
DownloadClient = new HttpClient(handler)
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
DownloadClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop/1.0");
|
||||
}
|
||||
|
||||
public ZhiJiaoHubCacheService()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var dataDirectory = Path.Combine(appData, "LanMountainDesktop", "cache", "zhijiaohub");
|
||||
_cacheDirectory = dataDirectory;
|
||||
_manifestPath = Path.Combine(dataDirectory, "manifest.json");
|
||||
}
|
||||
|
||||
public string CacheDirectory => _cacheDirectory;
|
||||
|
||||
public bool HasLocalCache(string source)
|
||||
{
|
||||
lock (_manifestLock)
|
||||
{
|
||||
if (!File.Exists(_manifestPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_manifestPath);
|
||||
var manifest = JsonSerializer.Deserialize<CacheManifest>(json, JsonOptions);
|
||||
return manifest?.Entries?.ContainsKey(source) == true &&
|
||||
manifest.Entries[source].Images.Count > 0 &&
|
||||
Directory.Exists(GetSourceDirectory(source));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ZhiJiaoHubLocalSnapshot? LoadLocalSnapshot(string source)
|
||||
{
|
||||
lock (_manifestLock)
|
||||
{
|
||||
if (!File.Exists(_manifestPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_manifestPath);
|
||||
var manifest = JsonSerializer.Deserialize<CacheManifest>(json, JsonOptions);
|
||||
if (manifest?.Entries?.TryGetValue(source, out var entry) != true)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sourceDir = GetSourceDirectory(source);
|
||||
var images = entry.Images
|
||||
.Where(img => File.Exists(Path.Combine(sourceDir, img.LocalFileName)))
|
||||
.Select((img, idx) => new ZhiJiaoHubLocalImageItem(
|
||||
img.Name,
|
||||
img.OriginalUrl,
|
||||
Path.Combine(sourceDir, img.LocalFileName),
|
||||
idx))
|
||||
.ToList();
|
||||
|
||||
if (images.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ZhiJiaoHubLocalSnapshot(
|
||||
images,
|
||||
source,
|
||||
entry.LastUpdated,
|
||||
images.Count);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Dictionary<string, string> LoadLocalPathMap(string source)
|
||||
{
|
||||
lock (_manifestLock)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!File.Exists(_manifestPath))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_manifestPath);
|
||||
var manifest = JsonSerializer.Deserialize<CacheManifest>(json, JsonOptions);
|
||||
if (manifest?.Entries?.TryGetValue(source, out var entry) != true)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var sourceDir = GetSourceDirectory(source);
|
||||
foreach (var img in entry.Images)
|
||||
{
|
||||
var localPath = Path.Combine(sourceDir, img.LocalFileName);
|
||||
if (File.Exists(localPath))
|
||||
{
|
||||
result[img.OriginalUrl] = localPath;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string? GetLocalPath(string source, string originalUrl)
|
||||
{
|
||||
lock (_manifestLock)
|
||||
{
|
||||
if (!File.Exists(_manifestPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_manifestPath);
|
||||
var manifest = JsonSerializer.Deserialize<CacheManifest>(json, JsonOptions);
|
||||
if (manifest?.Entries?.TryGetValue(source, out var entry) != true)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var img = entry.Images.FirstOrDefault(i =>
|
||||
string.Equals(i.OriginalUrl, originalUrl, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (img == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sourceDir = GetSourceDirectory(source);
|
||||
var localPath = Path.Combine(sourceDir, img.LocalFileName);
|
||||
return File.Exists(localPath) ? localPath : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string?> DownloadAndSaveImageAsync(
|
||||
string source,
|
||||
string name,
|
||||
string remoteUrl,
|
||||
string mirrorSource,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sourceDir = GetSourceDirectory(source);
|
||||
Directory.CreateDirectory(sourceDir);
|
||||
|
||||
var fileName = GetSafeFileName(name, remoteUrl);
|
||||
var localPath = Path.Combine(sourceDir, fileName);
|
||||
|
||||
if (File.Exists(localPath))
|
||||
{
|
||||
AddToManifest(source, name, remoteUrl, fileName);
|
||||
return localPath;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var downloadUrl = ResolveDownloadUrl(remoteUrl, mirrorSource);
|
||||
using var response = await DownloadClient.GetAsync(downloadUrl, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var fileStream = File.Create(localPath);
|
||||
await response.Content.CopyToAsync(fileStream, cancellationToken);
|
||||
|
||||
AddToManifest(source, name, remoteUrl, fileName);
|
||||
return localPath;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ZhiJiaoHubSyncResult> SyncImagesAsync(
|
||||
string source,
|
||||
IReadOnlyList<ZhiJiaoHubImageItem> remoteImages,
|
||||
string mirrorSource,
|
||||
IProgress<(int Current, int Total, string Status)>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (remoteImages == null || remoteImages.Count == 0)
|
||||
{
|
||||
return new ZhiJiaoHubSyncResult(false, null, 0, 0, 0, "No images to sync");
|
||||
}
|
||||
|
||||
var sourceDir = GetSourceDirectory(source);
|
||||
Directory.CreateDirectory(sourceDir);
|
||||
|
||||
var downloadedCount = 0;
|
||||
var skippedCount = 0;
|
||||
var failedCount = 0;
|
||||
var localImages = new List<CachedImageInfo>();
|
||||
|
||||
for (var i = 0; i < remoteImages.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var remoteImage = remoteImages[i];
|
||||
var fileName = GetSafeFileName(remoteImage.Name, remoteImage.Url);
|
||||
var localPath = Path.Combine(sourceDir, fileName);
|
||||
|
||||
progress?.Report((i + 1, remoteImages.Count, $"Downloading {remoteImage.Name}..."));
|
||||
|
||||
if (File.Exists(localPath))
|
||||
{
|
||||
skippedCount++;
|
||||
localImages.Add(new CachedImageInfo(remoteImage.Name, remoteImage.Url, fileName));
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var downloadUrl = ResolveDownloadUrl(remoteImage.Url, mirrorSource);
|
||||
using var response = await DownloadClient.GetAsync(downloadUrl, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var fileStream = File.Create(localPath);
|
||||
await response.Content.CopyToAsync(fileStream, cancellationToken);
|
||||
|
||||
downloadedCount++;
|
||||
localImages.Add(new CachedImageInfo(remoteImage.Name, remoteImage.Url, fileName));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (localImages.Count == 0)
|
||||
{
|
||||
return new ZhiJiaoHubSyncResult(false, null, downloadedCount, skippedCount, failedCount, "All downloads failed");
|
||||
}
|
||||
|
||||
SaveManifest(source, localImages);
|
||||
|
||||
var snapshot = new ZhiJiaoHubLocalSnapshot(
|
||||
localImages.Select((img, idx) => new ZhiJiaoHubLocalImageItem(
|
||||
img.Name,
|
||||
img.OriginalUrl,
|
||||
Path.Combine(sourceDir, img.LocalFileName),
|
||||
idx)).ToList(),
|
||||
source,
|
||||
DateTimeOffset.UtcNow,
|
||||
localImages.Count);
|
||||
|
||||
return new ZhiJiaoHubSyncResult(true, snapshot, downloadedCount, skippedCount, failedCount);
|
||||
}
|
||||
|
||||
public void ClearCache(string? source = null)
|
||||
{
|
||||
lock (_manifestLock)
|
||||
{
|
||||
if (source != null)
|
||||
{
|
||||
var sourceDir = GetSourceDirectory(source);
|
||||
if (Directory.Exists(sourceDir))
|
||||
{
|
||||
Directory.Delete(sourceDir, true);
|
||||
}
|
||||
|
||||
if (File.Exists(_manifestPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_manifestPath);
|
||||
var manifest = JsonSerializer.Deserialize<CacheManifest>(json, JsonOptions);
|
||||
if (manifest?.Entries != null && manifest.Entries.ContainsKey(source))
|
||||
{
|
||||
manifest.Entries.Remove(source);
|
||||
File.WriteAllText(_manifestPath, JsonSerializer.Serialize(manifest, JsonOptions));
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (Directory.Exists(_cacheDirectory))
|
||||
{
|
||||
Directory.Delete(_cacheDirectory, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string GetSourceDirectory(string source)
|
||||
{
|
||||
return Path.Combine(_cacheDirectory, source.ToLowerInvariant().Replace(" ", "-"));
|
||||
}
|
||||
|
||||
private static string GetSafeFileName(string name, string url)
|
||||
{
|
||||
var ext = Path.GetExtension(new Uri(url).AbsolutePath);
|
||||
if (string.IsNullOrEmpty(ext) || ext.Length > 5)
|
||||
{
|
||||
ext = ".jpg";
|
||||
}
|
||||
|
||||
var safeName = string.Concat(name.Split(Path.GetInvalidFileNameChars()));
|
||||
if (string.IsNullOrWhiteSpace(safeName))
|
||||
{
|
||||
safeName = Guid.NewGuid().ToString("N")[..8];
|
||||
}
|
||||
|
||||
return $"{safeName}{ext}";
|
||||
}
|
||||
|
||||
private static string ResolveDownloadUrl(string originalUrl, string mirrorSource)
|
||||
{
|
||||
if (string.Equals(mirrorSource, ZhiJiaoHubMirrorSources.GhProxy, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ZhiJiaoHubMirrorSources.GhProxyBaseUrl.TrimEnd('/') + "/" + originalUrl;
|
||||
}
|
||||
|
||||
return originalUrl;
|
||||
}
|
||||
|
||||
private void AddToManifest(string source, string name, string originalUrl, string localFileName)
|
||||
{
|
||||
lock (_manifestLock)
|
||||
{
|
||||
CacheManifest manifest;
|
||||
if (File.Exists(_manifestPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_manifestPath);
|
||||
manifest = JsonSerializer.Deserialize<CacheManifest>(json, JsonOptions) ?? new CacheManifest();
|
||||
}
|
||||
catch
|
||||
{
|
||||
manifest = new CacheManifest();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
manifest = new CacheManifest();
|
||||
}
|
||||
|
||||
if (!manifest.Entries.TryGetValue(source, out var entry))
|
||||
{
|
||||
entry = new CacheEntry(new List<CachedImageInfo>(), DateTimeOffset.UtcNow);
|
||||
manifest.Entries[source] = entry;
|
||||
}
|
||||
|
||||
var existingIndex = entry.Images.FindIndex(i =>
|
||||
string.Equals(i.OriginalUrl, originalUrl, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (existingIndex >= 0)
|
||||
{
|
||||
entry.Images[existingIndex] = new CachedImageInfo(name, originalUrl, localFileName);
|
||||
}
|
||||
else
|
||||
{
|
||||
entry.Images.Add(new CachedImageInfo(name, originalUrl, localFileName));
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(_manifestPath)!);
|
||||
File.WriteAllText(_manifestPath, JsonSerializer.Serialize(manifest, JsonOptions));
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveManifest(string source, List<CachedImageInfo> images)
|
||||
{
|
||||
lock (_manifestLock)
|
||||
{
|
||||
CacheManifest manifest;
|
||||
if (File.Exists(_manifestPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_manifestPath);
|
||||
manifest = JsonSerializer.Deserialize<CacheManifest>(json, JsonOptions) ?? new CacheManifest();
|
||||
}
|
||||
catch
|
||||
{
|
||||
manifest = new CacheManifest();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
manifest = new CacheManifest();
|
||||
}
|
||||
|
||||
manifest.Entries[source] = new CacheEntry(images, DateTimeOffset.UtcNow);
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(_manifestPath)!);
|
||||
File.WriteAllText(_manifestPath, JsonSerializer.Serialize(manifest, JsonOptions));
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
_isDisposed = true;
|
||||
}
|
||||
|
||||
private sealed class CacheManifest
|
||||
{
|
||||
public Dictionary<string, CacheEntry> Entries { get; set; } = new();
|
||||
}
|
||||
|
||||
private sealed class CacheEntry
|
||||
{
|
||||
public List<CachedImageInfo> Images { get; set; }
|
||||
public DateTimeOffset LastUpdated { get; set; }
|
||||
|
||||
public CacheEntry(List<CachedImageInfo> images, DateTimeOffset lastUpdated)
|
||||
{
|
||||
Images = images;
|
||||
LastUpdated = lastUpdated;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CachedImageInfo
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string OriginalUrl { get; set; }
|
||||
public string LocalFileName { get; set; }
|
||||
|
||||
public CachedImageInfo(string name, string originalUrl, string localFileName)
|
||||
{
|
||||
Name = name;
|
||||
OriginalUrl = originalUrl;
|
||||
LocalFileName = localFileName;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
@@ -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,562 @@ 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 - Master Switch
|
||||
|
||||
[ObservableProperty]
|
||||
private string _masterSwitchHeader = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _masterSwitchDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _studyEnabled = true;
|
||||
|
||||
partial void OnStudyEnabledChanged(bool value)
|
||||
{
|
||||
if (!_isInitializing)
|
||||
{
|
||||
SaveMasterSwitch();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#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);
|
||||
|
||||
// Master switch
|
||||
StudyEnabled = appSnapshot.StudyEnabled;
|
||||
|
||||
// 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 SaveMasterSwitch()
|
||||
{
|
||||
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
appSnapshot.StudyEnabled = StudyEnabled;
|
||||
_settingsFacade.Settings.SaveSnapshot(SettingsScope.App, appSnapshot,
|
||||
changedKeys: [nameof(AppSettingsSnapshot.StudyEnabled)]);
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
MasterSwitchHeader = L("settings.study.master_switch_header", "自习功能");
|
||||
MasterSwitchDescription = L("settings.study.master_switch_desc", "启用自习环境监测和专注计时功能。关闭后,相关组件将不会采集任何数据。");
|
||||
|
||||
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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
x:Class="LanMountainDesktop.Views.ComponentEditors.ZhiJiaoHubComponentEditor">
|
||||
<StackPanel Spacing="16">
|
||||
<!-- 数据源选择 -->
|
||||
<Border Classes="component-editor-card"
|
||||
Padding="20">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock x:Name="SourceLabelTextBlock"
|
||||
Classes="component-editor-section-title" />
|
||||
<ComboBox x:Name="SourceComboBox"
|
||||
Classes="component-editor-select"
|
||||
HorizontalAlignment="Stretch"
|
||||
SelectionChanged="OnSourceSelectionChanged">
|
||||
<ComboBoxItem x:Name="ClassIslandItem"
|
||||
Classes="component-editor-select-item"
|
||||
Tag="classisland" />
|
||||
<ComboBoxItem x:Name="SectlItem"
|
||||
Classes="component-editor-select-item"
|
||||
Tag="sectl" />
|
||||
</ComboBox>
|
||||
<TextBlock x:Name="SourceDescriptionTextBlock"
|
||||
Classes="component-editor-secondary-text"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 镜像加速源选择 -->
|
||||
<Border Classes="component-editor-card"
|
||||
Padding="20">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock x:Name="MirrorSourceLabelTextBlock"
|
||||
Classes="component-editor-section-title" />
|
||||
<ComboBox x:Name="MirrorSourceComboBox"
|
||||
Classes="component-editor-select"
|
||||
HorizontalAlignment="Stretch"
|
||||
SelectionChanged="OnMirrorSourceSelectionChanged">
|
||||
<ComboBoxItem x:Name="DirectMirrorItem"
|
||||
Classes="component-editor-select-item"
|
||||
Tag="direct" />
|
||||
<ComboBoxItem x:Name="GhProxyMirrorItem"
|
||||
Classes="component-editor-select-item"
|
||||
Tag="gh-proxy" />
|
||||
</ComboBox>
|
||||
<TextBlock x:Name="MirrorSourceDescriptionTextBlock"
|
||||
Classes="component-editor-secondary-text"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 自动刷新设置 -->
|
||||
<Border Classes="component-editor-card"
|
||||
Padding="20">
|
||||
<StackPanel Spacing="16">
|
||||
<TextBlock x:Name="RefreshSettingsLabelTextBlock"
|
||||
Classes="component-editor-section-title" />
|
||||
|
||||
<!-- 自动刷新开关 -->
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock x:Name="AutoRefreshLabelTextBlock"
|
||||
Classes="component-editor-primary-text" />
|
||||
<TextBlock x:Name="AutoRefreshDescriptionTextBlock"
|
||||
Classes="component-editor-secondary-text"
|
||||
FontSize="12" />
|
||||
</StackPanel>
|
||||
<ToggleSwitch x:Name="AutoRefreshToggle"
|
||||
Grid.Column="1"
|
||||
IsCheckedChanged="OnAutoRefreshChanged" />
|
||||
</Grid>
|
||||
|
||||
<!-- 刷新间隔 -->
|
||||
<StackPanel x:Name="IntervalPanel"
|
||||
Spacing="8">
|
||||
<TextBlock x:Name="IntervalLabelTextBlock"
|
||||
Classes="component-editor-primary-text" />
|
||||
<NumericUpDown x:Name="IntervalNumeric"
|
||||
Classes="component-editor-numeric"
|
||||
Minimum="5"
|
||||
Maximum="1440"
|
||||
Increment="5"
|
||||
ValueChanged="OnIntervalValueChanged" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 说明信息 -->
|
||||
<Border Classes="component-editor-card"
|
||||
Padding="20">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock x:Name="AboutLabelTextBlock"
|
||||
Classes="component-editor-section-title" />
|
||||
<TextBlock x:Name="AboutDescriptionTextBlock"
|
||||
Classes="component-editor-secondary-text"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,152 @@
|
||||
using System;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
|
||||
namespace LanMountainDesktop.Views.ComponentEditors;
|
||||
|
||||
public partial class ZhiJiaoHubComponentEditor : ComponentEditorViewBase
|
||||
{
|
||||
private bool _suppressEvents;
|
||||
|
||||
public ZhiJiaoHubComponentEditor()
|
||||
: this(null)
|
||||
{
|
||||
}
|
||||
|
||||
public ZhiJiaoHubComponentEditor(DesktopComponentEditorContext? context)
|
||||
: base(context)
|
||||
{
|
||||
InitializeComponent();
|
||||
ApplyLocalization();
|
||||
LoadState();
|
||||
}
|
||||
|
||||
private void ApplyLocalization()
|
||||
{
|
||||
// 标题
|
||||
SourceLabelTextBlock.Text = L("zhijiaohub.settings.source", "图片源");
|
||||
ClassIslandItem.Content = L("zhijiaohub.settings.classisland", "ClassIsland 图库");
|
||||
SectlItem.Content = L("zhijiaohub.settings.sectl", "SECTL 图库");
|
||||
|
||||
// 数据源描述
|
||||
SourceDescriptionTextBlock.Text = L("zhijiaohub.settings.source_desc",
|
||||
"选择图片来源。ClassIsland 图库包含 ClassIsland 社区的趣味瞬间,SECTL 图库包含 SECTL 社区的内容。");
|
||||
|
||||
// 镜像加速源
|
||||
MirrorSourceLabelTextBlock.Text = L("zhijiaohub.settings.mirror_source", "镜像加速");
|
||||
DirectMirrorItem.Content = L("zhijiaohub.settings.mirror_direct", "直连(GitHub)");
|
||||
GhProxyMirrorItem.Content = L("zhijiaohub.settings.mirror_ghproxy", "镜像加速(推荐)");
|
||||
MirrorSourceDescriptionTextBlock.Text = L("zhijiaohub.settings.mirror_source_desc",
|
||||
"如果图片加载缓慢或失败,请尝试使用镜像加速。镜像加速通过第三方代理服务加速 GitHub 访问。");
|
||||
|
||||
// 刷新设置
|
||||
RefreshSettingsLabelTextBlock.Text = L("zhijiaohub.settings.refresh", "刷新设置");
|
||||
AutoRefreshLabelTextBlock.Text = L("zhijiaohub.settings.auto_refresh", "自动刷新");
|
||||
AutoRefreshDescriptionTextBlock.Text = L("zhijiaohub.settings.auto_refresh_desc",
|
||||
"定期自动刷新图片列表。");
|
||||
IntervalLabelTextBlock.Text = L("zhijiaohub.settings.interval", "刷新间隔(分钟)");
|
||||
|
||||
// 关于
|
||||
AboutLabelTextBlock.Text = L("zhijiaohub.settings.about", "关于");
|
||||
AboutDescriptionTextBlock.Text = L("zhijiaohub.settings.about_desc",
|
||||
"智教Hub 展示来自教育技术社区的有趣图片。图片从 GitHub 仓库获取并缓存在本地。");
|
||||
}
|
||||
|
||||
private void LoadState()
|
||||
{
|
||||
_suppressEvents = true;
|
||||
|
||||
var snapshot = LoadSnapshot();
|
||||
|
||||
// 数据源
|
||||
var source = ZhiJiaoHubSources.Normalize(snapshot.ZhiJiaoHubSource);
|
||||
SourceComboBox.SelectedItem = source switch
|
||||
{
|
||||
ZhiJiaoHubSources.Sectl => SectlItem,
|
||||
_ => ClassIslandItem
|
||||
};
|
||||
|
||||
// 镜像加速源
|
||||
var mirrorSource = ZhiJiaoHubMirrorSources.Normalize(snapshot.ZhiJiaoHubMirrorSource);
|
||||
MirrorSourceComboBox.SelectedItem = mirrorSource switch
|
||||
{
|
||||
ZhiJiaoHubMirrorSources.GhProxy => GhProxyMirrorItem,
|
||||
_ => DirectMirrorItem
|
||||
};
|
||||
|
||||
// 自动刷新
|
||||
AutoRefreshToggle.IsChecked = snapshot.ZhiJiaoHubAutoRefreshEnabled;
|
||||
|
||||
// 刷新间隔
|
||||
var interval = Math.Clamp(snapshot.ZhiJiaoHubAutoRefreshIntervalMinutes, 5, 1440);
|
||||
IntervalNumeric.Value = interval;
|
||||
IntervalPanel.IsVisible = snapshot.ZhiJiaoHubAutoRefreshEnabled;
|
||||
|
||||
_suppressEvents = false;
|
||||
}
|
||||
|
||||
private void OnSourceSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var source = SourceComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tag
|
||||
? ZhiJiaoHubSources.Normalize(tag)
|
||||
: ZhiJiaoHubSources.ClassIsland;
|
||||
|
||||
var snapshot = LoadSnapshot();
|
||||
snapshot.ZhiJiaoHubSource = source;
|
||||
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ZhiJiaoHubSource));
|
||||
}
|
||||
|
||||
private void OnMirrorSourceSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var mirrorSource = MirrorSourceComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tag
|
||||
? ZhiJiaoHubMirrorSources.Normalize(tag)
|
||||
: ZhiJiaoHubMirrorSources.Direct;
|
||||
|
||||
var snapshot = LoadSnapshot();
|
||||
snapshot.ZhiJiaoHubMirrorSource = mirrorSource;
|
||||
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ZhiJiaoHubMirrorSource));
|
||||
}
|
||||
|
||||
private void OnAutoRefreshChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var isEnabled = AutoRefreshToggle.IsChecked ?? true;
|
||||
IntervalPanel.IsVisible = isEnabled;
|
||||
|
||||
var snapshot = LoadSnapshot();
|
||||
snapshot.ZhiJiaoHubAutoRefreshEnabled = isEnabled;
|
||||
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ZhiJiaoHubAutoRefreshEnabled));
|
||||
}
|
||||
|
||||
private void OnIntervalValueChanged(object? sender, NumericUpDownValueChangedEventArgs e)
|
||||
{
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var interval = (int)Math.Clamp(IntervalNumeric.Value ?? 30, 5, 1440);
|
||||
|
||||
var snapshot = LoadSnapshot();
|
||||
snapshot.ZhiJiaoHubAutoRefreshIntervalMinutes = interval;
|
||||
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ZhiJiaoHubAutoRefreshIntervalMinutes));
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<Border x:Name="RootBorder"
|
||||
Classes="surface-translucent-panel"
|
||||
Padding="0"
|
||||
CornerRadius="24">
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -9,15 +9,15 @@
|
||||
x:Class="LanMountainDesktop.Views.Components.DailyWord2x2Widget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
CornerRadius="30"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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())
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -53,6 +53,7 @@ public partial class StudyDeductionReasonsWidget : UserControl, IDesktopComponen
|
||||
private bool _isOnActivePage = true;
|
||||
private bool _isCompactMode;
|
||||
private bool _isUltraCompactMode;
|
||||
private bool _studyEnabled = true;
|
||||
private string _languageCode = "zh-CN";
|
||||
|
||||
private readonly record struct DeductionMetrics(
|
||||
@@ -98,7 +99,10 @@ public partial class StudyDeductionReasonsWidget : UserControl, IDesktopComponen
|
||||
{
|
||||
_isAttached = true;
|
||||
ReloadLanguageCode();
|
||||
_ = _studyAnalyticsService.StartOrResumeMonitoring();
|
||||
if (_studyEnabled)
|
||||
{
|
||||
_ = _studyAnalyticsService.StartOrResumeMonitoring();
|
||||
}
|
||||
UpdateTimerState();
|
||||
RefreshVisual();
|
||||
}
|
||||
@@ -142,10 +146,20 @@ public partial class StudyDeductionReasonsWidget : UserControl, IDesktopComponen
|
||||
|
||||
private void RefreshVisual()
|
||||
{
|
||||
var snapshot = _studyAnalyticsService.GetSnapshot();
|
||||
var panelColor = ResolvePanelBackgroundColor();
|
||||
ApplyTypographyByBackground(panelColor);
|
||||
|
||||
if (!_studyEnabled)
|
||||
{
|
||||
ModeTextBlock.Text = L("study.widget.disabled_hint", "请在设置中开启");
|
||||
ApplyModeBadgeColor(panelColor, Color.Parse("#FF9AA0A6"));
|
||||
ApplyLocalizedLabels();
|
||||
ApplyUnavailableMetrics();
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = _studyAnalyticsService.GetSnapshot();
|
||||
|
||||
var isSessionRunning = snapshot.Session.State == StudySessionRuntimeState.Running;
|
||||
var isSessionReport = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null;
|
||||
var isSessionView = isSessionRunning || isSessionReport;
|
||||
@@ -595,6 +609,7 @@ public partial class StudyDeductionReasonsWidget : UserControl, IDesktopComponen
|
||||
{
|
||||
var snapshot = _settingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
_studyEnabled = snapshot.StudyEnabled;
|
||||
}
|
||||
|
||||
private void ApplyVariableFontFamily()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -30,6 +30,7 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg
|
||||
private bool _isAttached;
|
||||
private bool _isOnActivePage = true;
|
||||
private bool _isDisposed;
|
||||
private bool _studyEnabled = true;
|
||||
private IDisposable? _monitoringLease;
|
||||
|
||||
public StudyEnvironmentWidget()
|
||||
@@ -132,6 +133,13 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg
|
||||
|
||||
private void UpdateMonitoringLeaseState()
|
||||
{
|
||||
if (!_studyEnabled)
|
||||
{
|
||||
_monitoringLease?.Dispose();
|
||||
_monitoringLease = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isAttached)
|
||||
{
|
||||
_monitoringLease ??= _monitoringLeaseCoordinator.AcquireLease();
|
||||
@@ -147,6 +155,7 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg
|
||||
var appSnapshot = _appSettingsService.Load();
|
||||
var componentSnapshot = _componentSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||
_studyEnabled = appSnapshot.StudyEnabled;
|
||||
_showDisplayDb = componentSnapshot.StudyEnvironmentShowDisplayDb;
|
||||
_showDbfs = componentSnapshot.StudyEnvironmentShowDbfs;
|
||||
_componentColorScheme = componentSnapshot.ColorSchemeSource;
|
||||
@@ -158,6 +167,17 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg
|
||||
|
||||
private void RefreshVisual()
|
||||
{
|
||||
if (!_studyEnabled)
|
||||
{
|
||||
StatusTitleTextBlock.Text = L("study.widget.disabled_title", "自习功能未启用");
|
||||
StatusValueTextBlock.Text = L("study.widget.disabled_hint", "请在设置中开启");
|
||||
StatusValueTextBlock.Foreground = TryResolveThemeBrush("AdaptiveTextSecondaryBrush", "#FF9AA0A6");
|
||||
NoiseValueTextBlock.Text = "--";
|
||||
NoiseSubValueTextBlock.IsVisible = false;
|
||||
UpdateAdaptiveLayout();
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = _studyAnalyticsService.GetSnapshot();
|
||||
var isSessionReport = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null;
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -53,6 +53,7 @@ public partial class StudyInterruptDensityWidget : UserControl, IDesktopComponen
|
||||
private bool _isOnActivePage = true;
|
||||
private bool _isCompactMode;
|
||||
private bool _isUltraCompactMode;
|
||||
private bool _studyEnabled = true;
|
||||
private string _languageCode = "zh-CN";
|
||||
private IDisposable? _monitoringLease;
|
||||
|
||||
@@ -151,6 +152,13 @@ public partial class StudyInterruptDensityWidget : UserControl, IDesktopComponen
|
||||
|
||||
private void UpdateMonitoringLeaseState()
|
||||
{
|
||||
if (!_studyEnabled)
|
||||
{
|
||||
_monitoringLease?.Dispose();
|
||||
_monitoringLease = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var shouldMonitor = _isAttached && _isOnActivePage;
|
||||
if (shouldMonitor)
|
||||
{
|
||||
@@ -164,11 +172,21 @@ public partial class StudyInterruptDensityWidget : UserControl, IDesktopComponen
|
||||
|
||||
private void RefreshVisual()
|
||||
{
|
||||
var snapshot = _studyAnalyticsService.GetSnapshot();
|
||||
var panelColor = ResolvePanelBackgroundColor();
|
||||
ApplyTypographyByBackground(panelColor);
|
||||
ApplyLocalizedLabels();
|
||||
|
||||
if (!_studyEnabled)
|
||||
{
|
||||
ModeTextBlock.Text = L("study.widget.disabled_hint", "请在设置中开启");
|
||||
ApplyModeBadgeColor(panelColor, Color.Parse("#FF9AA0A6"));
|
||||
DensityValueTextBlock.Text = "--";
|
||||
DensityUnitTextBlock.Text = "";
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = _studyAnalyticsService.GetSnapshot();
|
||||
|
||||
var isSessionRunning = snapshot.Session.State == StudySessionRuntimeState.Running;
|
||||
var isSessionReport = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null;
|
||||
var isSessionView = isSessionRunning || isSessionReport;
|
||||
@@ -528,6 +546,7 @@ public partial class StudyInterruptDensityWidget : UserControl, IDesktopComponen
|
||||
{
|
||||
var snapshot = _settingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
_studyEnabled = snapshot.StudyEnabled;
|
||||
}
|
||||
|
||||
private void ApplyVariableFontFamily()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -70,6 +70,7 @@ public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidge
|
||||
private bool _isOnActivePage = true;
|
||||
private bool _isSubscribed;
|
||||
private bool _isDisposed;
|
||||
private bool _studyEnabled = true;
|
||||
private int _framesSinceCompaction;
|
||||
private IDisposable? _monitoringLease;
|
||||
|
||||
@@ -263,6 +264,13 @@ public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidge
|
||||
|
||||
private void UpdateMonitoringLeaseState()
|
||||
{
|
||||
if (!_studyEnabled)
|
||||
{
|
||||
_monitoringLease?.Dispose();
|
||||
_monitoringLease = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isAttached)
|
||||
{
|
||||
_monitoringLease ??= _monitoringLeaseCoordinator.AcquireLease();
|
||||
@@ -278,6 +286,15 @@ public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidge
|
||||
var panelColor = ResolvePanelBackgroundColor();
|
||||
ApplyTypographyByBackground(panelColor);
|
||||
|
||||
if (!_studyEnabled)
|
||||
{
|
||||
StatusTextBlock.Text = L("study.widget.disabled_title", "自习功能未启用");
|
||||
RealtimeValueTextBlock.Text = L("study.widget.disabled_hint", "请在设置中开启");
|
||||
ApplyStatusBadgeStyle(StatusVisualKind.Default, panelColor);
|
||||
ChartControl.UpdateSeries([]);
|
||||
return;
|
||||
}
|
||||
|
||||
var isSessionReport = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null;
|
||||
if (isSessionReport && snapshot.LastSessionReport is not null)
|
||||
{
|
||||
@@ -578,6 +595,7 @@ public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidge
|
||||
{
|
||||
var snapshot = _settingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
_studyEnabled = snapshot.StudyEnabled;
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -56,6 +56,7 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
||||
private bool _isDisposed;
|
||||
private bool _isCompactMode;
|
||||
private bool _isUltraCompactMode;
|
||||
private bool _studyEnabled = true;
|
||||
private IDisposable? _monitoringLease;
|
||||
|
||||
private readonly record struct DistributionStats(
|
||||
@@ -157,6 +158,13 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
||||
|
||||
private void UpdateMonitoringLeaseState()
|
||||
{
|
||||
if (!_studyEnabled)
|
||||
{
|
||||
_monitoringLease?.Dispose();
|
||||
_monitoringLease = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isAttached)
|
||||
{
|
||||
_monitoringLease ??= _monitoringLeaseCoordinator.AcquireLease();
|
||||
@@ -169,13 +177,23 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
||||
|
||||
private void RefreshVisual()
|
||||
{
|
||||
var snapshot = _studyAnalyticsService.GetSnapshot();
|
||||
var panelColor = ResolvePanelBackgroundColor();
|
||||
ApplyTypographyByBackground(panelColor);
|
||||
|
||||
TitleTextBlock.Text = L("study.noise_distribution.title", "Noise Level Distribution");
|
||||
ApplyLocalizedAxisLabels();
|
||||
|
||||
if (!_studyEnabled)
|
||||
{
|
||||
ModeTextBlock.Text = L("study.widget.disabled_hint", "请在设置中开启");
|
||||
ApplyModeBadgeColor(panelColor, Color.Parse("#FF9AA0A6"));
|
||||
ChartControl.UpdateSeries([], 45);
|
||||
SummaryTextBlock.Text = "--";
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = _studyAnalyticsService.GetSnapshot();
|
||||
|
||||
var isSessionRunning = snapshot.Session.State == StudySessionRuntimeState.Running;
|
||||
var isSessionReport = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null;
|
||||
var isSessionView = isSessionRunning || isSessionReport;
|
||||
@@ -570,6 +588,7 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
||||
{
|
||||
var snapshot = _settingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
_studyEnabled = snapshot.StudyEnabled;
|
||||
}
|
||||
|
||||
private void ApplyVariableFontFamily()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -57,6 +57,7 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi
|
||||
private bool _isCompactMode;
|
||||
private bool _isUltraCompactMode;
|
||||
private bool _isExpandedMode;
|
||||
private bool _studyEnabled = true;
|
||||
private string _languageCode = "zh-CN";
|
||||
private IDisposable? _monitoringLease;
|
||||
|
||||
@@ -140,6 +141,13 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi
|
||||
|
||||
private void UpdateMonitoringLeaseState()
|
||||
{
|
||||
if (!_studyEnabled)
|
||||
{
|
||||
_monitoringLease?.Dispose();
|
||||
_monitoringLease = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var shouldMonitor = _isAttached && _isOnActivePage;
|
||||
if (shouldMonitor)
|
||||
{
|
||||
@@ -153,12 +161,22 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi
|
||||
|
||||
private void RefreshVisual()
|
||||
{
|
||||
var snapshot = _studyAnalyticsService.GetSnapshot();
|
||||
ApplyLocalizedLabels();
|
||||
|
||||
var panelColor = ResolvePanelBackgroundColor();
|
||||
ApplyTypographyByBackground(panelColor);
|
||||
|
||||
if (!_studyEnabled)
|
||||
{
|
||||
TitleTextBlock.Text = L("study.widget.disabled_title", "自习功能未启用");
|
||||
ModeTextBlock.Text = L("study.widget.disabled_hint", "请在设置中开启");
|
||||
CurrentScoreTextBlock.Text = "--";
|
||||
CurrentLabelTextBlock.Text = "";
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = _studyAnalyticsService.GetSnapshot();
|
||||
|
||||
var realtimeScore = ComputeRealtimeScore(snapshot);
|
||||
if (snapshot.DataMode == StudyDataMode.Realtime && realtimeScore is { } score)
|
||||
{
|
||||
@@ -676,6 +694,7 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi
|
||||
{
|
||||
var snapshot = _settingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
_studyEnabled = snapshot.StudyEnabled;
|
||||
}
|
||||
|
||||
private void ApplyVariableFontFamily()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -64,6 +64,7 @@ public partial class StudySessionControlWidget : UserControl, IDesktopComponentW
|
||||
private bool _isDisposed;
|
||||
private bool _isCompactMode;
|
||||
private bool _isUltraCompactMode;
|
||||
private bool _studyEnabled = true;
|
||||
private IDisposable? _monitoringLease;
|
||||
private string? _transientMessage;
|
||||
private DateTimeOffset _transientMessageExpireAt;
|
||||
@@ -147,6 +148,13 @@ public partial class StudySessionControlWidget : UserControl, IDesktopComponentW
|
||||
|
||||
private void UpdateMonitoringLeaseState()
|
||||
{
|
||||
if (!_studyEnabled)
|
||||
{
|
||||
_monitoringLease?.Dispose();
|
||||
_monitoringLease = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var shouldMonitor = _isAttached && _isOnActivePage;
|
||||
if (shouldMonitor)
|
||||
{
|
||||
@@ -193,11 +201,21 @@ public partial class StudySessionControlWidget : UserControl, IDesktopComponentW
|
||||
|
||||
private void RefreshVisual()
|
||||
{
|
||||
var snapshot = _studyAnalyticsService.GetSnapshot();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var panelColor = ResolvePanelBackgroundColor();
|
||||
ApplyTypographyByBackground(panelColor);
|
||||
|
||||
if (!_studyEnabled)
|
||||
{
|
||||
PrimaryTextBlock.Text = L("study.widget.disabled_title", "自习功能未启用");
|
||||
SecondaryTextBlock.Text = L("study.widget.disabled_hint", "请在设置中开启");
|
||||
ActionIcon.Kind = MaterialIconKind.Settings;
|
||||
ApplyActionBadgeStyle(panelColor, Color.Parse("#FF9AA0A6"));
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = _studyAnalyticsService.GetSnapshot();
|
||||
|
||||
if (_transientMessage is not null && now > _transientMessageExpireAt)
|
||||
{
|
||||
_transientMessage = null;
|
||||
@@ -469,6 +487,7 @@ public partial class StudySessionControlWidget : UserControl, IDesktopComponentW
|
||||
{
|
||||
var snapshot = _settingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
_studyEnabled = snapshot.StudyEnabled;
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -58,6 +58,7 @@ public partial class StudySessionHistoryWidget : UserControl, IDesktopComponentW
|
||||
private bool _isDisposed;
|
||||
private bool _isCompactMode;
|
||||
private bool _isUltraCompactMode;
|
||||
private bool _studyEnabled = true;
|
||||
private string? _loadingSessionId;
|
||||
private HistoryDialogMode _dialogMode;
|
||||
private string? _dialogSessionId;
|
||||
@@ -179,6 +180,19 @@ public partial class StudySessionHistoryWidget : UserControl, IDesktopComponentW
|
||||
TitleTextBlock.Text = L("study.session_history.title", "Session History");
|
||||
TitleTextBlock.Foreground = CreateAdaptiveBrush(panelSamples, PrimaryColorCandidates, MinTextContrast);
|
||||
|
||||
if (!_studyEnabled)
|
||||
{
|
||||
if (_dialogMode != HistoryDialogMode.None)
|
||||
{
|
||||
CloseDialog();
|
||||
}
|
||||
|
||||
SessionListPanel.Children.Clear();
|
||||
StatusTextBlock.Text = L("study.widget.disabled_hint", "请在设置中开启");
|
||||
StatusTextBlock.Foreground = CreateAdaptiveBrush(panelSamples, SecondaryColorCandidates, MinTextContrast);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_transientStatus is not null && DateTimeOffset.UtcNow > _transientStatusExpireAt)
|
||||
{
|
||||
_transientStatus = null;
|
||||
@@ -473,6 +487,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)
|
||||
@@ -576,6 +595,7 @@ public partial class StudySessionHistoryWidget : UserControl, IDesktopComponentW
|
||||
{
|
||||
var snapshot = _settingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
_studyEnabled = snapshot.StudyEnabled;
|
||||
}
|
||||
|
||||
private void UpdateAdaptiveLayout()
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
97
LanMountainDesktop/Views/Components/ZhiJiaoHubWidget.axaml
Normal file
97
LanMountainDesktop/Views/Components/ZhiJiaoHubWidget.axaml
Normal file
@@ -0,0 +1,97 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="96"
|
||||
d:DesignHeight="96"
|
||||
x:Class="LanMountainDesktop.Views.Components.ZhiJiaoHubWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
ClipToBounds="True"
|
||||
BorderThickness="0"
|
||||
Background="#1A1A1A">
|
||||
<Grid x:Name="MainGrid">
|
||||
<!-- 图片显示 -->
|
||||
<Image x:Name="CurrentImage"
|
||||
Stretch="UniformToFill"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
|
||||
<!-- 左下角渐变遮罩 -->
|
||||
<Border x:Name="GradientOverlay"
|
||||
VerticalAlignment="Bottom"
|
||||
HorizontalAlignment="Stretch"
|
||||
Height="60">
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0%,0%" EndPoint="0%,100%">
|
||||
<GradientStop Offset="0" Color="#00000000" />
|
||||
<GradientStop Offset="1" Color="#CC000000" />
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
</Border>
|
||||
|
||||
<!-- 图片名称 -->
|
||||
<TextBlock x:Name="ImageNameTextBlock"
|
||||
Text=""
|
||||
Foreground="#FFFFFF"
|
||||
FontSize="11"
|
||||
FontWeight="Medium"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Bottom"
|
||||
Margin="10,0,10,8" />
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<StackPanel x:Name="LoadingPanel"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="8"
|
||||
IsVisible="False">
|
||||
<TextBlock x:Name="LoadingTextBlock"
|
||||
Text="加载中..."
|
||||
Foreground="#AAAAAA"
|
||||
FontSize="12" />
|
||||
<ProgressBar x:Name="LoadingProgressBar"
|
||||
IsIndeterminate="True"
|
||||
Width="60"
|
||||
Height="2"
|
||||
Foreground="#4A9EFF" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<TextBlock x:Name="ErrorTextBlock"
|
||||
Text=""
|
||||
Foreground="#FF6666"
|
||||
FontSize="10"
|
||||
TextWrapping="Wrap"
|
||||
TextAlignment="Center"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Margin="10"
|
||||
IsVisible="False" />
|
||||
|
||||
<!-- 指示器 -->
|
||||
<Border x:Name="IndicatorBorder"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,6,0"
|
||||
Background="Transparent">
|
||||
<StackPanel x:Name="IndicatorPanel"
|
||||
Orientation="Vertical"
|
||||
Spacing="4">
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 触摸/鼠标捕获层 -->
|
||||
<Border x:Name="InputCaptureBorder"
|
||||
Background="Transparent"
|
||||
PointerPressed="OnPointerPressed"
|
||||
PointerMoved="OnPointerMoved"
|
||||
PointerReleased="OnPointerReleased"
|
||||
PointerWheelChanged="OnPointerWheelChanged" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
847
LanMountainDesktop/Views/Components/ZhiJiaoHubWidget.axaml.cs
Normal file
847
LanMountainDesktop/Views/Components/ZhiJiaoHubWidget.axaml.cs
Normal file
@@ -0,0 +1,847 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class ZhiJiaoHubWidget : UserControl,
|
||||
IDesktopComponentWidget,
|
||||
IRecommendationInfoAwareComponentWidget,
|
||||
IComponentSettingsContextAware,
|
||||
IComponentPlacementContextAware
|
||||
{
|
||||
private const double BaseCellSize = 48d;
|
||||
private const double SwipeThreshold = 50;
|
||||
|
||||
private readonly DispatcherTimer _refreshTimer = new();
|
||||
|
||||
private IRecommendationInfoService _recommendationService = new RecommendationDataService();
|
||||
private IComponentSettingsAccessor? _componentSettingsAccessor;
|
||||
private ISettingsService _appSettingsService = HostSettingsFacadeProvider.GetOrCreate().Settings;
|
||||
|
||||
private CancellationTokenSource? _refreshCts;
|
||||
private CancellationTokenSource? _backgroundDownloadCts;
|
||||
|
||||
private string _source = ZhiJiaoHubSources.ClassIsland;
|
||||
private string _mirrorSource = ZhiJiaoHubMirrorSources.Direct;
|
||||
private string _componentId = BuiltInComponentIds.DesktopZhiJiaoHub;
|
||||
private string _placementId = string.Empty;
|
||||
private double _currentCellSize = BaseCellSize;
|
||||
private bool _isAttached;
|
||||
private bool _isInitializing;
|
||||
private bool _autoRefreshEnabled = true;
|
||||
private int _pendingImageIndex = 0;
|
||||
|
||||
private IReadOnlyList<ZhiJiaoHubHybridImageItem> _images = [];
|
||||
private int _currentImageIndex = 0;
|
||||
|
||||
private readonly Dictionary<int, Bitmap> _imageCache = new();
|
||||
private readonly object _cacheLock = new();
|
||||
private const int MaxCacheSize = 5;
|
||||
|
||||
private bool _isDragging;
|
||||
private Point _dragStartPoint;
|
||||
private double _dragOffset;
|
||||
private int _lastSwipeDirection = 0;
|
||||
private bool _isInErrorState;
|
||||
|
||||
private static readonly HttpClient ImageHttpClient = new(new HttpClientHandler
|
||||
{
|
||||
AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate
|
||||
})
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
|
||||
public ZhiJiaoHubWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
if (Design.IsDesignMode)
|
||||
{
|
||||
ApplyCellSize(_currentCellSize);
|
||||
return;
|
||||
}
|
||||
|
||||
_refreshTimer.Tick += OnRefreshTimerTick;
|
||||
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
|
||||
ApplyCellSize(_currentCellSize);
|
||||
ApplyLoadingState();
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
|
||||
LoadSettings();
|
||||
_ = InitializeAsync();
|
||||
UpdateTimers();
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = false;
|
||||
_refreshTimer.Stop();
|
||||
_refreshCts?.Cancel();
|
||||
_backgroundDownloadCts?.Cancel();
|
||||
|
||||
lock (_cacheLock)
|
||||
{
|
||||
foreach (var bitmap in _imageCache.Values)
|
||||
{
|
||||
bitmap.Dispose();
|
||||
}
|
||||
_imageCache.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
var scale = _currentCellSize / BaseCellSize;
|
||||
|
||||
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(12 * scale, 4, 24));
|
||||
|
||||
var fontSize = Math.Clamp(11 * scale, 9, 18);
|
||||
ImageNameTextBlock.FontSize = fontSize;
|
||||
LoadingTextBlock.FontSize = Math.Clamp(12 * scale, 10, 16);
|
||||
ErrorTextBlock.FontSize = Math.Clamp(10 * scale, 8, 14);
|
||||
|
||||
GradientOverlay.Height = Math.Clamp(60 * scale, 30, 100);
|
||||
|
||||
ImageNameTextBlock.Margin = new Thickness(
|
||||
Math.Clamp(10 * scale, 5, 20),
|
||||
0,
|
||||
Math.Clamp(10 * scale, 5, 20),
|
||||
Math.Clamp(8 * scale, 4, 16));
|
||||
|
||||
IndicatorBorder.Margin = new Thickness(0, 0, Math.Clamp(6 * scale, 3, 12), 0);
|
||||
}
|
||||
|
||||
public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService)
|
||||
{
|
||||
}
|
||||
|
||||
public void SetComponentSettingsContext(DesktopComponentSettingsContext context)
|
||||
{
|
||||
_componentId = context.ComponentId;
|
||||
_placementId = context.PlacementId ?? string.Empty;
|
||||
_componentSettingsAccessor = context.ComponentSettingsAccessor;
|
||||
|
||||
LoadSettings();
|
||||
|
||||
if (_isAttached)
|
||||
{
|
||||
_ = InitializeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||
{
|
||||
_componentId = componentId;
|
||||
_placementId = placementId ?? string.Empty;
|
||||
}
|
||||
|
||||
public void RefreshFromSettings()
|
||||
{
|
||||
LoadSettings();
|
||||
UpdateTimers();
|
||||
if (_isAttached)
|
||||
{
|
||||
_ = InitializeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadSettings()
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = _componentSettingsAccessor?.LoadSnapshot<ComponentSettingsSnapshot>();
|
||||
if (snapshot is not null)
|
||||
{
|
||||
_source = ZhiJiaoHubSources.Normalize(snapshot.ZhiJiaoHubSource);
|
||||
_mirrorSource = ZhiJiaoHubMirrorSources.Normalize(snapshot.ZhiJiaoHubMirrorSource);
|
||||
_autoRefreshEnabled = snapshot.ZhiJiaoHubAutoRefreshEnabled;
|
||||
_pendingImageIndex = snapshot.ZhiJiaoHubCurrentImageIndex;
|
||||
|
||||
var intervalMinutes = Math.Clamp(snapshot.ZhiJiaoHubAutoRefreshIntervalMinutes, 5, 1440);
|
||||
_refreshTimer.Interval = TimeSpan.FromMinutes(intervalMinutes);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveCurrentImageIndex()
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = _componentSettingsAccessor?.LoadSnapshot<ComponentSettingsSnapshot>()
|
||||
?? new ComponentSettingsSnapshot();
|
||||
snapshot.ZhiJiaoHubCurrentImageIndex = _currentImageIndex;
|
||||
_componentSettingsAccessor?.SaveSnapshot(snapshot, [nameof(ComponentSettingsSnapshot.ZhiJiaoHubCurrentImageIndex)]);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateTimers()
|
||||
{
|
||||
if (_autoRefreshEnabled)
|
||||
{
|
||||
_refreshTimer.Start();
|
||||
}
|
||||
else
|
||||
{
|
||||
_refreshTimer.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task InitializeAsync()
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isInitializing = true;
|
||||
_refreshCts?.Cancel();
|
||||
_backgroundDownloadCts?.Cancel();
|
||||
_refreshCts = new CancellationTokenSource();
|
||||
var ct = _refreshCts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
LoadingTextBlock.Text = "加载中...";
|
||||
ApplyLoadingState();
|
||||
});
|
||||
|
||||
var result = await _recommendationService.GetZhiJiaoHubHybridImagesAsync(_source, _mirrorSource, ct);
|
||||
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.Success || result.Data == null || result.Data.Images.Count == 0)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
ApplyErrorState(result.ErrorMessage ?? "无法获取图片列表");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_images = result.Data.Images;
|
||||
_currentImageIndex = Math.Clamp(_pendingImageIndex, 0, Math.Max(0, _images.Count - 1));
|
||||
_pendingImageIndex = 0;
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
UpdateIndicators();
|
||||
});
|
||||
|
||||
await LoadAndDisplayCurrentImageAsync();
|
||||
|
||||
if (result.Data.CachedCount < result.Data.TotalCount)
|
||||
{
|
||||
_ = StartBackgroundDownloadAsync();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
ApplyErrorState($"初始化失败: {ex.Message}");
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isInitializing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StartBackgroundDownloadAsync()
|
||||
{
|
||||
_backgroundDownloadCts?.Cancel();
|
||||
_backgroundDownloadCts = new CancellationTokenSource();
|
||||
var ct = _backgroundDownloadCts.Token;
|
||||
|
||||
await _recommendationService.StartBackgroundDownloadAsync(
|
||||
_source,
|
||||
_images,
|
||||
_mirrorSource,
|
||||
(downloaded, total, name) =>
|
||||
{
|
||||
if (!ct.IsCancellationRequested)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
LoadingTextBlock.Text = $"后台缓存 {downloaded}/{total}";
|
||||
});
|
||||
}
|
||||
},
|
||||
ct);
|
||||
}
|
||||
|
||||
private async Task LoadAndDisplayCurrentImageAsync(int direction = 0)
|
||||
{
|
||||
if (_images.Count == 0)
|
||||
{
|
||||
ApplyErrorState("暂无图片");
|
||||
return;
|
||||
}
|
||||
|
||||
var imageItem = _images[_currentImageIndex];
|
||||
|
||||
try
|
||||
{
|
||||
Bitmap? cachedBitmap = null;
|
||||
lock (_cacheLock)
|
||||
{
|
||||
_imageCache.TryGetValue(_currentImageIndex, out cachedBitmap);
|
||||
}
|
||||
|
||||
if (cachedBitmap != null)
|
||||
{
|
||||
CurrentImage.Source = cachedBitmap;
|
||||
ImageNameTextBlock.Text = imageItem.Name;
|
||||
ApplyContentVisibleState();
|
||||
_ = Task.Run(async () => await PreloadAdjacentImagesAsync(direction));
|
||||
return;
|
||||
}
|
||||
|
||||
if (imageItem.IsCached && !string.IsNullOrEmpty(imageItem.LocalPath) && File.Exists(imageItem.LocalPath))
|
||||
{
|
||||
await LoadFromLocalPathAsync(imageItem.LocalPath, imageItem.Name);
|
||||
_ = Task.Run(async () => await PreloadAdjacentImagesAsync(direction));
|
||||
return;
|
||||
}
|
||||
|
||||
await LoadFromRemoteUrlAsync(imageItem, direction);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
ApplyErrorState($"图片加载失败: {ex.Message}");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadFromLocalPathAsync(string localPath, string name)
|
||||
{
|
||||
await using var fileStream = File.OpenRead(localPath);
|
||||
var bitmap = new Bitmap(fileStream);
|
||||
|
||||
lock (_cacheLock)
|
||||
{
|
||||
if (_imageCache.Count >= MaxCacheSize)
|
||||
{
|
||||
CleanupFarthestCacheUnsafe();
|
||||
}
|
||||
_imageCache[_currentImageIndex] = bitmap;
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
CurrentImage.Source = bitmap;
|
||||
ImageNameTextBlock.Text = name;
|
||||
ApplyContentVisibleState();
|
||||
});
|
||||
}
|
||||
|
||||
private async Task LoadFromRemoteUrlAsync(ZhiJiaoHubHybridImageItem imageItem, int direction)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
LoadingTextBlock.Text = "加载图片...";
|
||||
ApplyLoadingState();
|
||||
});
|
||||
|
||||
var imageUrl = imageItem.RemoteUrl;
|
||||
if (string.Equals(_mirrorSource, ZhiJiaoHubMirrorSources.GhProxy, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
imageUrl = ZhiJiaoHubMirrorSources.GhProxyBaseUrl.TrimEnd('/') + "/" + imageItem.RemoteUrl;
|
||||
}
|
||||
|
||||
using var response = await ImageHttpClient.GetAsync(imageUrl);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var imageStream = await response.Content.ReadAsStreamAsync();
|
||||
|
||||
var bitmap = new Bitmap(imageStream);
|
||||
|
||||
lock (_cacheLock)
|
||||
{
|
||||
if (_imageCache.Count >= MaxCacheSize)
|
||||
{
|
||||
CleanupFarthestCacheUnsafe();
|
||||
}
|
||||
_imageCache[_currentImageIndex] = bitmap;
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
CurrentImage.Source = bitmap;
|
||||
ImageNameTextBlock.Text = imageItem.Name;
|
||||
ApplyContentVisibleState();
|
||||
});
|
||||
|
||||
_ = CacheImageInBackgroundAsync(imageItem);
|
||||
_ = Task.Run(async () => await PreloadAdjacentImagesAsync(direction));
|
||||
}
|
||||
|
||||
private async Task CacheImageInBackgroundAsync(ZhiJiaoHubHybridImageItem imageItem)
|
||||
{
|
||||
if (imageItem.IsCached)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var image = new ZhiJiaoHubImageItem(imageItem.Name, imageItem.RemoteUrl, imageItem.Index);
|
||||
var localPath = await _recommendationService.DownloadAndCacheImageAsync(_source, image, _mirrorSource);
|
||||
|
||||
if (!string.IsNullOrEmpty(localPath))
|
||||
{
|
||||
var index = imageItem.Index;
|
||||
if (index >= 0 && index < _images.Count)
|
||||
{
|
||||
var updatedImage = _images[index] with
|
||||
{
|
||||
LocalPath = localPath,
|
||||
IsCached = true
|
||||
};
|
||||
var newImages = _images.ToList();
|
||||
newImages[index] = updatedImage;
|
||||
_images = newImages;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PreloadAdjacentImagesAsync(int direction = 0)
|
||||
{
|
||||
if (_images.Count <= 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var indicesToPreload = new List<int>();
|
||||
var currentIndex = _currentImageIndex;
|
||||
|
||||
lock (_cacheLock)
|
||||
{
|
||||
if (direction <= 0)
|
||||
{
|
||||
var nextIndex = (currentIndex + 1) % _images.Count;
|
||||
if (!_imageCache.ContainsKey(nextIndex))
|
||||
{
|
||||
indicesToPreload.Add(nextIndex);
|
||||
}
|
||||
|
||||
var nextNextIndex = (currentIndex + 2) % _images.Count;
|
||||
if (!_imageCache.ContainsKey(nextNextIndex) && indicesToPreload.Count < 3)
|
||||
{
|
||||
indicesToPreload.Add(nextNextIndex);
|
||||
}
|
||||
}
|
||||
|
||||
if (direction >= 0)
|
||||
{
|
||||
var prevIndex = (currentIndex - 1 + _images.Count) % _images.Count;
|
||||
if (!_imageCache.ContainsKey(prevIndex))
|
||||
{
|
||||
indicesToPreload.Add(prevIndex);
|
||||
}
|
||||
|
||||
var prevPrevIndex = (currentIndex - 2 + _images.Count) % _images.Count;
|
||||
if (!_imageCache.ContainsKey(prevPrevIndex) && indicesToPreload.Count < 3)
|
||||
{
|
||||
indicesToPreload.Add(prevPrevIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (indicesToPreload.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var preloadTasks = indicesToPreload.Select(async index =>
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (_cacheLock)
|
||||
{
|
||||
if (_imageCache.ContainsKey(index))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_imageCache.Count >= MaxCacheSize)
|
||||
{
|
||||
CleanupFarthestCacheUnsafe();
|
||||
}
|
||||
}
|
||||
|
||||
var imageItem = _images[index];
|
||||
Bitmap? bitmap = null;
|
||||
|
||||
if (imageItem.IsCached && !string.IsNullOrEmpty(imageItem.LocalPath) && File.Exists(imageItem.LocalPath))
|
||||
{
|
||||
await using var fileStream = File.OpenRead(imageItem.LocalPath);
|
||||
bitmap = new Bitmap(fileStream);
|
||||
}
|
||||
else
|
||||
{
|
||||
var imageUrl = imageItem.RemoteUrl;
|
||||
if (string.Equals(_mirrorSource, ZhiJiaoHubMirrorSources.GhProxy, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
imageUrl = ZhiJiaoHubMirrorSources.GhProxyBaseUrl.TrimEnd('/') + "/" + imageItem.RemoteUrl;
|
||||
}
|
||||
|
||||
using var response = await ImageHttpClient.GetAsync(imageUrl);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var imageStream = await response.Content.ReadAsStreamAsync();
|
||||
bitmap = new Bitmap(imageStream);
|
||||
|
||||
_ = CacheImageInBackgroundAsync(imageItem);
|
||||
}
|
||||
|
||||
if (bitmap != null)
|
||||
{
|
||||
lock (_cacheLock)
|
||||
{
|
||||
if (!_imageCache.ContainsKey(index))
|
||||
{
|
||||
_imageCache[index] = bitmap;
|
||||
}
|
||||
else
|
||||
{
|
||||
bitmap.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}).ToList();
|
||||
|
||||
await Task.WhenAll(preloadTasks);
|
||||
}
|
||||
|
||||
private void CleanupFarthestCacheUnsafe()
|
||||
{
|
||||
if (_imageCache.Count == 0) return;
|
||||
|
||||
var farthestKey = -1;
|
||||
var maxDistance = -1;
|
||||
var currentIndex = _currentImageIndex;
|
||||
var imageCount = _images.Count;
|
||||
|
||||
foreach (var key in _imageCache.Keys)
|
||||
{
|
||||
if (key == currentIndex) continue;
|
||||
|
||||
var forwardDistance = (key - currentIndex + imageCount) % imageCount;
|
||||
var backwardDistance = (currentIndex - key + imageCount) % imageCount;
|
||||
var distance = Math.Min(forwardDistance, backwardDistance);
|
||||
|
||||
if (distance > maxDistance)
|
||||
{
|
||||
maxDistance = distance;
|
||||
farthestKey = key;
|
||||
}
|
||||
}
|
||||
|
||||
if (farthestKey >= 0)
|
||||
{
|
||||
if (_imageCache.TryGetValue(farthestKey, out var bitmap))
|
||||
{
|
||||
bitmap.Dispose();
|
||||
}
|
||||
_imageCache.Remove(farthestKey);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (_isInErrorState)
|
||||
{
|
||||
_ = RefreshCurrentComponentAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_images.Count <= 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isDragging = true;
|
||||
_dragStartPoint = e.GetPosition(this);
|
||||
_dragOffset = 0;
|
||||
}
|
||||
|
||||
private void OnPointerMoved(object? sender, PointerEventArgs e)
|
||||
{
|
||||
if (!_isDragging || _images.Count <= 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var currentPoint = e.GetPosition(this);
|
||||
_dragOffset = currentPoint.Y - _dragStartPoint.Y;
|
||||
}
|
||||
|
||||
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||
{
|
||||
if (!_isDragging)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isDragging = false;
|
||||
|
||||
if (Math.Abs(_dragOffset) > SwipeThreshold)
|
||||
{
|
||||
if (_dragOffset > 0)
|
||||
{
|
||||
_lastSwipeDirection = 1;
|
||||
SwitchToPrevImage();
|
||||
}
|
||||
else
|
||||
{
|
||||
_lastSwipeDirection = -1;
|
||||
SwitchToNextImage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPointerWheelChanged(object? sender, PointerWheelEventArgs e)
|
||||
{
|
||||
if (_images.Count <= 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Delta.Y > 0)
|
||||
{
|
||||
_lastSwipeDirection = 1;
|
||||
SwitchToPrevImage();
|
||||
}
|
||||
else if (e.Delta.Y < 0)
|
||||
{
|
||||
_lastSwipeDirection = -1;
|
||||
SwitchToNextImage();
|
||||
}
|
||||
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void SwitchToPrevImage()
|
||||
{
|
||||
if (_images.Count <= 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_currentImageIndex = (_currentImageIndex - 1 + _images.Count) % _images.Count;
|
||||
SaveCurrentImageIndex();
|
||||
UpdateIndicators();
|
||||
|
||||
if (TryDisplayCachedImage(_currentImageIndex))
|
||||
{
|
||||
_ = Task.Run(async () => await PreloadAdjacentImagesAsync(_lastSwipeDirection));
|
||||
return;
|
||||
}
|
||||
|
||||
_ = LoadAndDisplayCurrentImageAsync(_lastSwipeDirection);
|
||||
}
|
||||
|
||||
private void SwitchToNextImage()
|
||||
{
|
||||
if (_images.Count <= 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_currentImageIndex = (_currentImageIndex + 1) % _images.Count;
|
||||
SaveCurrentImageIndex();
|
||||
UpdateIndicators();
|
||||
|
||||
if (TryDisplayCachedImage(_currentImageIndex))
|
||||
{
|
||||
_ = Task.Run(async () => await PreloadAdjacentImagesAsync(_lastSwipeDirection));
|
||||
return;
|
||||
}
|
||||
|
||||
_ = LoadAndDisplayCurrentImageAsync(_lastSwipeDirection);
|
||||
}
|
||||
|
||||
private bool TryDisplayCachedImage(int index)
|
||||
{
|
||||
if (_images.Count == 0 || index < 0 || index >= _images.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Bitmap? cachedBitmap = null;
|
||||
lock (_cacheLock)
|
||||
{
|
||||
_imageCache.TryGetValue(index, out cachedBitmap);
|
||||
}
|
||||
|
||||
if (cachedBitmap != null)
|
||||
{
|
||||
var imageItem = _images[index];
|
||||
CurrentImage.Source = cachedBitmap;
|
||||
ImageNameTextBlock.Text = imageItem.Name;
|
||||
ApplyContentVisibleState();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void ApplyLoadingState()
|
||||
{
|
||||
_isInErrorState = false;
|
||||
CurrentImage.IsVisible = false;
|
||||
ImageNameTextBlock.IsVisible = false;
|
||||
GradientOverlay.IsVisible = false;
|
||||
ErrorTextBlock.IsVisible = false;
|
||||
LoadingPanel.IsVisible = true;
|
||||
}
|
||||
|
||||
private void ApplyContentVisibleState()
|
||||
{
|
||||
_isInErrorState = false;
|
||||
LoadingPanel.IsVisible = false;
|
||||
ErrorTextBlock.IsVisible = false;
|
||||
CurrentImage.IsVisible = true;
|
||||
ImageNameTextBlock.IsVisible = true;
|
||||
GradientOverlay.IsVisible = true;
|
||||
}
|
||||
|
||||
private void ApplyErrorState(string message)
|
||||
{
|
||||
_isInErrorState = true;
|
||||
CurrentImage.IsVisible = false;
|
||||
ImageNameTextBlock.IsVisible = false;
|
||||
GradientOverlay.IsVisible = false;
|
||||
LoadingPanel.IsVisible = false;
|
||||
ErrorTextBlock.Text = message + "\n点击任意区域重试";
|
||||
ErrorTextBlock.IsVisible = true;
|
||||
}
|
||||
|
||||
private void UpdateIndicators()
|
||||
{
|
||||
IndicatorPanel.Children.Clear();
|
||||
|
||||
if (_images.Count <= 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var maxIndicators = Math.Min(_images.Count, 7);
|
||||
var startIndex = Math.Max(0, _currentImageIndex - maxIndicators / 2);
|
||||
var endIndex = Math.Min(_images.Count, startIndex + maxIndicators);
|
||||
|
||||
if (endIndex - startIndex < maxIndicators)
|
||||
{
|
||||
startIndex = Math.Max(0, endIndex - maxIndicators);
|
||||
}
|
||||
|
||||
for (var i = startIndex; i < endIndex; i++)
|
||||
{
|
||||
var dot = new Border
|
||||
{
|
||||
Width = 6,
|
||||
Height = 6,
|
||||
CornerRadius = new CornerRadius(3),
|
||||
Margin = new Thickness(2, 0),
|
||||
Background = i == _currentImageIndex
|
||||
? Brushes.White
|
||||
: new SolidColorBrush(Color.FromArgb(128, 255, 255, 255))
|
||||
};
|
||||
|
||||
IndicatorPanel.Children.Add(dot);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRefreshTimerTick(object? sender, EventArgs e)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ = InitializeAsync();
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
{
|
||||
var cellSize = Math.Min(e.NewSize.Width, e.NewSize.Height) / 2;
|
||||
ApplyCellSize(cellSize);
|
||||
}
|
||||
|
||||
private async Task RefreshCurrentComponentAsync()
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_refreshCts?.Cancel();
|
||||
_backgroundDownloadCts?.Cancel();
|
||||
_refreshCts = new CancellationTokenSource();
|
||||
|
||||
lock (_cacheLock)
|
||||
{
|
||||
foreach (var bitmap in _imageCache.Values)
|
||||
{
|
||||
bitmap.Dispose();
|
||||
}
|
||||
_imageCache.Clear();
|
||||
}
|
||||
|
||||
_images = [];
|
||||
_currentImageIndex = 0;
|
||||
|
||||
await InitializeAsync();
|
||||
}
|
||||
}
|
||||
@@ -1481,6 +1481,15 @@ public partial class MainWindow
|
||||
new ComponentScaleRule(WidthUnit: 1, HeightUnit: 1, MinScale: 4));
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user