Compare commits

...

12 Commits

Author SHA1 Message Date
lincube
7a268489c9 ci.圆角
修了下新的圆角。
2026-03-30 16:34:45 +08:00
lincube
148e4c894a 0.8.0
圆角设计更新
2026-03-30 15:28:51 +08:00
lincube
f84111e837 0.7.9.2
自习设置,优化设置选项卡图标,加入智教hub组件
2026-03-30 02:40:10 +08:00
lincube
bd2313fe7e 0.7.9.1 2026-03-29 15:34:17 +08:00
lincube
372b5b7adc 0.7.9
更新功能优化、插件市场优化,反正就是优化了很多东西
2026-03-25 11:27:30 +08:00
lincube
74703582e7 0.7.8.1
凤凰网新闻组件优化、央广网新闻组件优化。
2026-03-25 07:44:55 +08:00
lincube
26ff11b16b 0.7.8 2026-03-24 23:15:32 +08:00
lincube
b83cfb47b0 0.7.7.2
笔迹粗细大小调节
2026-03-24 20:16:44 +08:00
lincube
a0bb83c743 0.7.7.1 2026-03-24 17:47:54 +08:00
lincube
af2e7b4f2f 0.7.7
橘鸦新闻
2026-03-24 09:33:56 +08:00
lincube
798124e500 0.7.6.3 2026-03-23 22:43:54 +08:00
lincube
95ecb06668 0.7.6.2
在应用启动台上,也可以正常滑动
2026-03-23 21:13:08 +08:00
155 changed files with 12644 additions and 1498 deletions

45
.github/README.md vendored
View File

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

133
.github/READMEmd vendored Normal file
View File

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

93
AGENTS.md Normal file
View File

@@ -0,0 +1,93 @@
# LanMountainDesktop AI Guide
本文件是 AI 助手进入本仓库时的第一入口。面向 Codex、Cursor、Trae 等工具,目标是减少重复探索,快速定位权威文档、关键目录和执行约束。
## 1. 项目目标与仓库边界
- 本仓库是阑山桌面桌面宿主、宿主侧插件运行时、Plugin SDK、共享契约与基础外观/设置能力的权威来源。
- 不要把插件市场元数据、开发者门户或官方示例插件实现当作本仓库内容维护。
- 市场和生态材料属于兄弟仓库 `LanAirApp`
- 官方示例插件属于独立仓库 `LanMountainDesktop.SamplePlugin`
边界详情看:
- `docs/ECOSYSTEM_BOUNDARIES.md`
- `docs/ARCHITECTURE.md`
## 2. 关键目录地图
- `LanMountainDesktop/`: 主宿主应用,包含 UI、服务、组件系统、主题与插件运行时接入
- `LanMountainDesktop/ComponentSystem/`: 内置组件定义、注册、扩展加载
- `LanMountainDesktop/plugins/`: 宿主侧插件运行时、安装与 market 集成
- `LanMountainDesktop/Views/` and `ViewModels/`: UI 页面、窗口与视图模型
- `LanMountainDesktop/Services/`: 设置、遥测、启动、持久化、业务服务
- `LanMountainDesktop.PluginSdk/`: 插件 SDK 公共接口和默认打包行为
- `LanMountainDesktop.Shared.Contracts/`: 宿主/插件共享契约
- `LanMountainDesktop.Tests/`: 宿主与 SDK 测试
- `.trae/specs/`: feature 级规格、任务拆解和验收清单
更详细映射看 `docs/ai/CODEBASE_MAP.md`
## 3. 常用命令
```bash
dotnet restore
dotnet build LanMountainDesktop.slnx -c Debug
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
dotnet test LanMountainDesktop.slnx -c Debug
```
插件本地包生成:
```powershell
./scripts/Pack-PluginPackages.ps1
```
## 4. 改动前后必做检查
改动前:
- 先确认需求是否已经在 `.trae/specs/` 中存在
- 先确认产品、架构、专题规范分别以哪份文档为准
- 避免沿用旧根目录产品文档中的过时事实
改动后:
- 至少检查构建和与改动相关的测试
- 如果行为、流程、边界或命令变化,更新对应文档
- 如果是新功能或行为调整,补齐或更新 `.trae/specs/<feature>/`
## 5. 高频区域注意事项
### UI
- 主题、资源和视觉语义优先遵守 `docs/VISUAL_SPEC.md``docs/CORNER_RADIUS_SPEC.md`
- **组件圆角**:所有内置与插件组件的根边框必须使用 `{DynamicResource DesignCornerRadiusComponent}` 资源。
- 设置页相关改动通常同时落在 `Views/``ViewModels/``Services/``.trae/specs/`
- UI 启动与窗口生命周期主线在 `Program.cs``App.axaml.cs`
### 插件
- SDK 公共 API 以 `LanMountainDesktop.PluginSdk/` 为准
- 共享契约以 `LanMountainDesktop.Shared.Contracts/` 为准
- market 数据来源默认是兄弟仓库 `..\\LanAirApp`
- 迁移或 breaking change 优先同步 `docs/PLUGIN_SDK_V4_MIGRATION.md`
### 设置与主题
- 设置持久化和 scope 变化优先检查 `LanMountainDesktop.Settings.Core/`
- 外观、圆角、主题资源优先检查 `LanMountainDesktop.Appearance/` 与专题规范
- **圆角统一**桌面组件Widget必须统一使用动态资源 `DesignCornerRadiusComponent`。严禁在组件根容器使用硬编码数值或非组件级令牌(如 `Xs`, `Md` 等),以确保全局圆角缩放设置能正确应用到所有组件。
## 6. 权威来源
- 产品定位:`docs/PRODUCT.md`
- 架构与模块职责:`docs/ARCHITECTURE.md`
- 运行、构建、测试、打包:`docs/DEVELOPMENT.md`
- feature 规格:`.trae/specs/`
- 视觉规范:`docs/VISUAL_SPEC.md`
- 圆角规范:`docs/CORNER_RADIUS_SPEC.md`
- 生态边界:`docs/ECOSYSTEM_BOUNDARIES.md`
- SDK v4 迁移:`docs/PLUGIN_SDK_V4_MIGRATION.md`
如果多个文档都提到同一件事,以 `docs/ai/DOC_SOURCES.md` 列出的权威来源为准。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.12" />

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Bilibili</title><path d="M17.813 4.653h.854c1.51.054 2.769.578 3.773 1.574 1.004.995 1.524 2.249 1.56 3.76v7.36c-.036 1.51-.556 2.769-1.56 3.773s-2.262 1.524-3.773 1.56H5.333c-1.51-.036-2.769-.556-3.773-1.56S.036 18.858 0 17.347v-7.36c.036-1.511.556-2.765 1.56-3.76 1.004-.996 2.262-1.52 3.773-1.574h.774l-1.174-1.12a1.234 1.234 0 0 1-.373-.906c0-.356.124-.658.373-.907l.027-.027c.267-.249.573-.373.92-.373.347 0 .653.124.92.373L9.653 4.44c.071.071.134.142.187.213h4.267a.836.836 0 0 1 .16-.213l2.853-2.747c.267-.249.573-.373.92-.373.347 0 .662.151.929.4.267.249.391.551.391.907 0 .355-.124.657-.373.906zM5.333 7.24c-.746.018-1.373.276-1.88.773-.506.498-.769 1.13-.786 1.894v7.52c.017.764.28 1.395.786 1.893.507.498 1.134.756 1.88.773h13.334c.746-.017 1.373-.275 1.88-.773.506-.498.769-1.129.786-1.893v-7.52c-.017-.765-.28-1.396-.786-1.894-.507-.497-1.134-.755-1.88-.773zM8 11.107c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c0-.373.129-.689.386-.947.258-.257.574-.386.947-.386zm8 0c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c.017-.391.15-.711.4-.96.249-.249.56-.373.933-.373Z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>WeChat</title><path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -33,6 +33,7 @@ public static class BuiltInComponentIds
public const string DesktopDailyWord2x2 = "DesktopDailyWord2x2";
public const string DesktopCnrDailyNews = "DesktopCnrDailyNews";
public const string DesktopIfengNews = "DesktopIfengNews";
public const string DesktopJuyaNews = "DesktopJuyaNews";
public const string DesktopBilibiliHotSearch = "DesktopBilibiliHotSearch";
public const string DesktopBaiduHotSearch = "DesktopBaiduHotSearch";
public const string DesktopStcn24Forum = "DesktopStcn24Forum";
@@ -42,4 +43,5 @@ public static class BuiltInComponentIds
public const string DesktopBrowser = "DesktopBrowser";
public const string DesktopOfficeRecentDocuments = "DesktopOfficeRecentDocuments";
public const string DesktopRemovableStorage = "DesktopRemovableStorage";
public const string DesktopZhiJiaoHub = "DesktopZhiJiaoHub";
}

View File

@@ -261,6 +261,16 @@ public sealed class ComponentRegistry
MinHeightCells: 4,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopJuyaNews,
"橘鸦早报",
"News",
"Info",
MinWidthCells: 4,
MinHeightCells: 4,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true,
ResizeMode: DesktopComponentResizeMode.Free),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopBilibiliHotSearch,
"Bilibili Hot Search",
@@ -380,7 +390,17 @@ public sealed class ComponentRegistry
MinWidthCells: 2,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true)
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopZhiJiaoHub,
"智教Hub",
"Image",
"Info",
MinWidthCells: 2,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true,
ResizeMode: DesktopComponentResizeMode.Free)
};
return new ComponentRegistry(builtIn);

View File

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

View File

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

View File

@@ -203,6 +203,52 @@
"settings.weather.alert_list_desc": "One exclusion rule per line.",
"settings.weather.no_tls_toggle": "Allow non-TLS request fallback",
"settings.weather.footer_hint": "Desktop weather widgets will reuse the location and alert exclusion settings configured here.",
"settings.study.title": "Study",
"settings.study.description": "Configure study environment monitoring, focus timer, and alert settings.",
"settings.study.noise_header": "Noise Monitoring",
"settings.study.noise_description": "Configure microphone sampling rate and noise scoring sensitivity.",
"settings.study.sampling_rate_label": "Sampling Rate",
"settings.study.sampling_rate_desc": "Time interval for microphone audio sampling. Higher frequency captures noise changes more accurately but increases power consumption.",
"settings.study.sampling_rate_20ms": "20ms (High)",
"settings.study.sampling_rate_50ms": "50ms (Standard)",
"settings.study.sampling_rate_100ms": "100ms (Power Saving)",
"settings.study.sampling_rate_200ms": "200ms (Low Power)",
"settings.study.sensitivity_label": "Noise Sensitivity",
"settings.study.sensitivity_desc": "Scoring threshold determines what level of noise is considered interference. Stricter thresholds detect quieter noises.",
"settings.study.sensitivity_relaxed": "Relaxed (-45dBFS)",
"settings.study.sensitivity_standard": "Standard (-50dBFS)",
"settings.study.sensitivity_strict": "Strict (-55dBFS)",
"settings.study.sensitivity_very_strict": "Very Strict (-60dBFS)",
"settings.study.current_threshold_format": "Current scoring threshold: {0} dBFS",
"settings.study.timer_header": "Focus Timer",
"settings.study.timer_description": "Configure focus and break session durations.",
"settings.study.focus_duration_label": "Focus Duration",
"settings.study.focus_duration_desc": "Duration of a single focus session (minutes).",
"settings.study.break_duration_label": "Break Duration",
"settings.study.break_duration_desc": "Duration of a short break session (minutes).",
"settings.study.long_break_duration_label": "Long Break Duration",
"settings.study.long_break_duration_desc": "Duration of a long break session (minutes).",
"settings.study.sessions_before_long_break_label": "Long Break Interval",
"settings.study.sessions_before_long_break_desc": "Number of focus sessions before a long break.",
"settings.study.auto_start_break_label": "Auto-start Break",
"settings.study.auto_start_break_desc": "Automatically start break timer when focus session ends.",
"settings.study.auto_start_focus_label": "Auto-start Focus",
"settings.study.auto_start_focus_desc": "Automatically start focus timer when break ends.",
"settings.study.alert_header": "Alert Settings",
"settings.study.alert_description": "Configure noise interference alerts.",
"settings.study.noise_alert_enabled_label": "Enable Noise Alert",
"settings.study.noise_alert_enabled_desc": "Show an alert when noise interference exceeds tolerance threshold.",
"settings.study.max_interrupts_label": "Max Tolerated Interrupts",
"settings.study.max_interrupts_desc": "Maximum noise interference events per minute before triggering an alert.",
"settings.study.display_header": "Display Settings",
"settings.study.display_description": "Configure how noise data is displayed.",
"settings.study.show_realtime_db_label": "Show Realtime Decibel",
"settings.study.show_realtime_db_desc": "Display decibel values in real-time on components.",
"settings.study.baseline_db_label": "Baseline Display Decibel",
"settings.study.baseline_db_desc": "Calibrated baseline decibel value for converting dBFS to user-readable dB.",
"settings.study.avg_window_label": "Averaging Window",
"settings.study.avg_window_desc": "Time window for smoothing noise display. Larger values make display more stable but slower to respond.",
"settings.study.footer_hint": "These settings affect the behavior of study environment monitoring components.",
"settings.weather.location_header": "Weather Location",
"settings.weather.location_desc": "Set the location used by weather widgets.",
"settings.weather.location_placeholder": "e.g. Beijing",
@@ -418,6 +464,11 @@
"settings.update.channel_preview_desc": "Preview builds may contain newer features but can be less stable.",
"settings.update.download_threads_label": "Download Threads",
"settings.update.download_threads_desc": "Set the number of parallel download threads for application update packages.",
"settings.update.force_check_label": "Force Check Update",
"settings.update.force_check_desc": "Force check for updates from GitHub, ignoring version comparison.",
"settings.update.status_force_checking": "Force checking GitHub releases...",
"settings.update.status_force_no_asset": "Release found but no compatible installer available.",
"settings.update.status_force_available_format": "Release {0} is available. Click Download & Install.",
"settings.update.install_now_button": "Install Now",
"settings.update.status_downloaded_confirm": "Update downloaded. Review it and choose when to install.",
"settings.update.status_downloaded_exit": "Update downloaded. It will be installed when you exit the app.",
@@ -525,10 +576,10 @@
"settings.plugins.source_manifest": "Loose manifest",
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
"settings.plugins.detail_format": "Settings pages: {0} | Widgets: {1}",
"settings.nav.plugin_market": "Plugin Market",
"settings.plugin_market.title": "Plugin Market",
"settings.plugin_market.subtitle": "Browse plugins from the official LanAirApp source and stage installs.",
"settings.plugin_market.unavailable": "Plugin runtime is not available, so the official market cannot be opened right now.",
"settings.nav.plugin_catalog": "Plugin Catalog",
"settings.plugin_catalog.title": "Plugin Catalog",
"settings.plugin_catalog.subtitle": "Browse plugins from the official LanAirApp source and stage installs.",
"settings.plugin_catalog.unavailable": "Plugin runtime is not available, so the official catalog cannot be opened right now.",
"settings.update.status_idle": "No update check has been performed yet.",
"settings.update.status_preferences_saved": "Update preferences saved.",
"settings.update.status_check_failed": "Failed to check for updates.",
@@ -972,5 +1023,19 @@
"single_instance.notice.description": "The app is already running. There is no need to click multiple times to open it.",
"single_instance.notice.button": "OK",
"market.status.install_success_restart_format": "✓ Plugin '{0}' installed successfully! Please restart the application to activate it.",
"market.dialog.restart_message_format": "Plugin '{0}' has been installed successfully.\n\nTo use this plugin, you need to restart the application now.\n\nWould you like to restart?"
"market.dialog.restart_message_format": "Plugin '{0}' has been installed successfully.\n\nTo use this plugin, you need to restart the application now.\n\nWould you like to restart?",
"zhijiaohub.settings.source": "Image Source",
"zhijiaohub.settings.classisland": "ClassIsland Gallery",
"zhijiaohub.settings.sectl": "SECTL Gallery",
"zhijiaohub.settings.source_desc": "Select the image source. ClassIsland Gallery contains fun moments from the ClassIsland community, SECTL Gallery contains content from the SECTL community.",
"zhijiaohub.settings.mirror_source": "Mirror Acceleration",
"zhijiaohub.settings.mirror_direct": "Direct (GitHub)",
"zhijiaohub.settings.mirror_ghproxy": "Mirror Acceleration (Recommended)",
"zhijiaohub.settings.mirror_source_desc": "If images load slowly or fail, try using mirror acceleration. Mirror acceleration speeds up GitHub access through third-party proxy services.",
"zhijiaohub.settings.refresh": "Refresh Settings",
"zhijiaohub.settings.auto_refresh": "Auto Refresh",
"zhijiaohub.settings.auto_refresh_desc": "Automatically refresh the image list periodically.",
"zhijiaohub.settings.interval": "Refresh Interval (minutes)",
"zhijiaohub.settings.about": "About",
"zhijiaohub.settings.about_desc": "ZhiJiaoHub displays interesting images from the educational technology community. Images are fetched from GitHub repositories and cached locally."
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -73,6 +73,17 @@ public sealed class ComponentSettingsSnapshot
public List<string>? OfficeRecentDocumentsEnabledSources { get; set; }
// 智教Hub组件配置
public string ZhiJiaoHubSource { get; set; } = ZhiJiaoHubSources.ClassIsland;
public string ZhiJiaoHubMirrorSource { get; set; } = ZhiJiaoHubMirrorSources.Direct;
public bool ZhiJiaoHubAutoRefreshEnabled { get; set; } = true;
public int ZhiJiaoHubAutoRefreshIntervalMinutes { get; set; } = 30;
public int ZhiJiaoHubCurrentImageIndex { get; set; } = 0;
public ComponentSettingsSnapshot Clone()
{
var clone = (ComponentSettingsSnapshot)MemberwiseClone();
@@ -107,3 +118,56 @@ public sealed class ComponentSettingsSnapshot
return clone;
}
}
// 智教Hub数据源常量
public static class ZhiJiaoHubSources
{
public const string ClassIsland = "classisland";
public const string Sectl = "sectl";
public static string Normalize(string? value)
{
return value?.ToLowerInvariant() switch
{
"sectl" => Sectl,
_ => ClassIsland
};
}
}
// 智教Hub镜像加速源常量
public static class ZhiJiaoHubMirrorSources
{
public const string Direct = "direct";
public const string GhProxy = "gh-proxy";
public const string GhProxyBaseUrl = "https://gh-proxy.com/";
public static string Normalize(string? value)
{
return string.Equals(value, GhProxy, StringComparison.OrdinalIgnoreCase)
? GhProxy
: Direct;
}
public static string ApplyMirror(string url, string? mirrorSource)
{
if (string.IsNullOrWhiteSpace(url))
{
return url;
}
if (!string.Equals(Normalize(mirrorSource), GhProxy, StringComparison.OrdinalIgnoreCase))
{
return url;
}
if (url.StartsWith("https://raw.githubusercontent.com/", StringComparison.OrdinalIgnoreCase) ||
url.StartsWith("https://github.com/", StringComparison.OrdinalIgnoreCase))
{
return GhProxyBaseUrl.TrimEnd('/') + "/" + url;
}
return url;
}
}

View File

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

View File

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

View File

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

View File

@@ -52,6 +52,35 @@ public sealed record ExchangeRateQuery(
string? TargetCurrency = null,
bool ForceRefresh = false);
public sealed record ZhiJiaoHubQuery(
string? Source = null,
int? ImageIndex = null,
bool ForceRefresh = false,
string? MirrorSource = null);
public sealed record ZhiJiaoHubImageItem(
string Name,
string Url,
int Index);
public sealed record ZhiJiaoHubSnapshot(
IReadOnlyList<ZhiJiaoHubImageItem> Images,
int CurrentIndex,
string Source);
public sealed record ZhiJiaoHubHybridImageItem(
string Name,
string RemoteUrl,
string? LocalPath,
int Index,
bool IsCached);
public sealed record ZhiJiaoHubHybridSnapshot(
IReadOnlyList<ZhiJiaoHubHybridImageItem> Images,
string Source,
int CachedCount,
int TotalCount);
public sealed record RecommendationQueryResult<T>(
bool Success,
T? Data,
@@ -285,6 +314,14 @@ public sealed record RecommendationApiOptions
public int DefaultBaiduHotSearchCount { get; init; } = 4;
public int DefaultStcn24ForumPostCount { get; init; } = 4;
public string ClassIslandHubApiUrl { get; init; } = "https://api.github.com/repos/ClassIsland/classisland-hub/contents/images";
public string SectlHubApiUrl { get; init; } = "https://api.github.com/repos/SECTL/SECTL-hub/contents/images";
public string ClassIslandHubRawUrlTemplate { get; init; } = "https://raw.githubusercontent.com/ClassIsland/classisland-hub/main/images/{0}";
public string SectlHubRawUrlTemplate { get; init; } = "https://raw.githubusercontent.com/SECTL/SECTL-hub/main/images/{0}";
}
public interface IRecommendationInfoService
@@ -325,5 +362,37 @@ public interface IRecommendationInfoService
ExchangeRateQuery query,
CancellationToken cancellationToken = default);
Task<RecommendationQueryResult<ZhiJiaoHubSnapshot>> GetZhiJiaoHubImagesAsync(
ZhiJiaoHubQuery query,
CancellationToken cancellationToken = default);
Task<RecommendationQueryResult<ZhiJiaoHubHybridSnapshot>> GetZhiJiaoHubHybridImagesAsync(
string source,
string mirrorSource,
CancellationToken cancellationToken = default);
Task<string?> DownloadAndCacheImageAsync(
string source,
ZhiJiaoHubImageItem image,
string mirrorSource,
CancellationToken cancellationToken = default);
Task StartBackgroundDownloadAsync(
string source,
IReadOnlyList<ZhiJiaoHubHybridImageItem> images,
string mirrorSource,
Action<int, int, string>? onProgress = null,
CancellationToken cancellationToken = default);
Task<ZhiJiaoHubSyncResult> SyncZhiJiaoHubImagesAsync(
string source,
string mirrorSource,
IProgress<(int Current, int Total, string Status)>? progress = null,
CancellationToken cancellationToken = default);
ZhiJiaoHubLocalSnapshot? LoadZhiJiaoHubLocalSnapshot(string source);
bool HasZhiJiaoHubLocalCache(string source);
void ClearCache();
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
@@ -53,6 +53,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
Dictionary<string, decimal> Rates,
DateTimeOffset ExpireAt,
DateTimeOffset FetchedAt);
private sealed record ZhiJiaoHubCacheEntry(ZhiJiaoHubSnapshot Snapshot, DateTimeOffset ExpireAt);
private sealed record ArtworkCandidate(
string Title,
string? Artist,
@@ -80,6 +81,8 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, ExchangeRateTableCacheEntry> _exchangeRateCacheByBaseCurrency =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, ZhiJiaoHubCacheEntry> _zhiJiaoHubCacheBySource =
new(StringComparer.OrdinalIgnoreCase);
private int _dailyNewsRotationCursor;
static RecommendationDataService()
@@ -94,7 +97,15 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
_options = options ?? new RecommendationApiOptions();
if (httpClient is null)
{
_httpClient = new HttpClient
// 配置 HttpClientHandler 以支持所有 TLS 版本
var handler = new HttpClientHandler
{
SslProtocols = System.Security.Authentication.SslProtocols.Tls12 |
System.Security.Authentication.SslProtocols.Tls13,
ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => true
};
_httpClient = new HttpClient(handler)
{
Timeout = _options.RequestTimeout
};
@@ -128,6 +139,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
_dailyWordCache = null;
_stcn24ForumPostsCacheBySource.Clear();
_exchangeRateCacheByBaseCurrency.Clear();
_zhiJiaoHubCacheBySource.Clear();
}
}
@@ -3194,4 +3206,368 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
? text
: $"{text[..maxLength]}...";
}
// 智教Hub相关方法
public async Task<RecommendationQueryResult<ZhiJiaoHubSnapshot>> GetZhiJiaoHubImagesAsync(
ZhiJiaoHubQuery query,
CancellationToken cancellationToken = default)
{
var normalizedQuery = query ?? new ZhiJiaoHubQuery();
var source = ZhiJiaoHubSources.Normalize(normalizedQuery.Source);
var mirrorSource = ZhiJiaoHubMirrorSources.Normalize(normalizedQuery.MirrorSource);
var cacheKey = $"{source}|{mirrorSource}";
if (!normalizedQuery.ForceRefresh && TryGetZhiJiaoHubFromCache(cacheKey, out var cached))
{
return RecommendationQueryResult<ZhiJiaoHubSnapshot>.Ok(cached);
}
try
{
var snapshot = await FetchZhiJiaoHubSnapshotAsync(source, mirrorSource, cancellationToken);
SetZhiJiaoHubCache(cacheKey, snapshot);
return RecommendationQueryResult<ZhiJiaoHubSnapshot>.Ok(snapshot);
}
catch (OperationCanceledException)
{
throw;
}
catch (HttpRequestException ex)
{
return RecommendationQueryResult<ZhiJiaoHubSnapshot>.Fail("upstream_network_error", ex.Message);
}
catch (Exception ex)
{
return RecommendationQueryResult<ZhiJiaoHubSnapshot>.Fail("upstream_parse_error", ex.Message);
}
}
private async Task<ZhiJiaoHubSnapshot> FetchZhiJiaoHubSnapshotAsync(string source, string mirrorSource, CancellationToken cancellationToken)
{
var (owner, repo, path) = source switch
{
ZhiJiaoHubSources.Sectl => ("SECTL", "SECTL-hub", "docs/.vuepress/public/images"),
_ => ("ClassIsland", "classisland-hub", "images")
};
var contentsUrl = $"https://api.github.com/repos/{owner}/{repo}/contents/{path}";
// 如果使用镜像加速,代理 GitHub API 请求
if (string.Equals(mirrorSource, ZhiJiaoHubMirrorSources.GhProxy, StringComparison.OrdinalIgnoreCase))
{
contentsUrl = ZhiJiaoHubMirrorSources.GhProxyBaseUrl.TrimEnd('/') + "/" + contentsUrl;
}
try
{
var images = await FetchImagesFromContentsApi(owner, repo, path, contentsUrl, mirrorSource, cancellationToken);
if (images.Count == 0)
{
throw new InvalidOperationException("未找到图片文件");
}
// 随机打乱图片顺序
var random = new Random();
var shuffled = images.OrderBy(_ => random.Next()).ToList();
// 重新设置索引
for (int i = 0; i < shuffled.Count; i++)
{
var item = shuffled[i];
shuffled[i] = item with { Index = i };
}
return new ZhiJiaoHubSnapshot(shuffled, 0, source);
}
catch (HttpRequestException ex) when (ex.Message.Contains("403") || ex.Message.Contains("rate limit"))
{
throw new HttpRequestException("GitHub API 速率限制,请稍后重试");
}
catch (Exception ex)
{
throw new HttpRequestException($"获取图片列表失败: {ex.Message}");
}
}
private async Task<List<ZhiJiaoHubImageItem>> FetchImagesFromContentsApi(string owner, string repo, string path, string contentsUrl, string mirrorSource, CancellationToken cancellationToken)
{
var images = new List<ZhiJiaoHubImageItem>();
using var request = new HttpRequestMessage(HttpMethod.Get, contentsUrl);
request.Headers.TryAddWithoutValidation("User-Agent", "LanMountainDesktop/1.0");
request.Headers.TryAddWithoutValidation("Accept", "application/vnd.github+json");
request.Headers.TryAddWithoutValidation("X-GitHub-Api-Version", "2022-11-28");
using var response = await _httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var errorText = await response.Content.ReadAsStringAsync(cancellationToken);
if ((int)response.StatusCode == 403)
{
throw new HttpRequestException("GitHub API 速率限制,请稍后重试");
}
throw new HttpRequestException($"API 返回错误: {(int)response.StatusCode} - {Truncate(errorText, 200)}");
}
var responseText = await response.Content.ReadAsStringAsync(cancellationToken);
using var document = JsonDocument.Parse(responseText);
var root = document.RootElement;
if (root.ValueKind != JsonValueKind.Array)
{
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("message", out var messageNode))
{
var errorMessage = messageNode.GetString();
throw new InvalidOperationException($"GitHub API 错误: {errorMessage}");
}
throw new InvalidOperationException("Invalid response format from GitHub API.");
}
int index = 0;
foreach (var item in root.EnumerateArray())
{
var type = ReadString(item, "type");
if (type != "file")
{
continue;
}
var name = ReadString(item, "name");
var downloadUrl = ReadString(item, "download_url");
if (string.IsNullOrWhiteSpace(name))
{
continue;
}
// 只处理图片文件
var extension = Path.GetExtension(name).ToLowerInvariant();
if (extension != ".png" && extension != ".jpg" && extension != ".jpeg" && extension != ".gif" && extension != ".webp")
{
continue;
}
// 解码文件名
var decodedName = Uri.UnescapeDataString(name);
decodedName = Path.GetFileNameWithoutExtension(decodedName);
// 构造图片 URL
string imageUrl;
if (!string.IsNullOrWhiteSpace(downloadUrl))
{
imageUrl = downloadUrl;
}
else
{
imageUrl = $"https://raw.githubusercontent.com/{owner}/{repo}/main/{path}/{Uri.EscapeDataString(name)}";
}
// 应用镜像加速到图片 URL
imageUrl = ZhiJiaoHubMirrorSources.ApplyMirror(imageUrl, mirrorSource);
images.Add(new ZhiJiaoHubImageItem(decodedName, imageUrl, index));
index++;
}
return images;
}
private bool TryGetZhiJiaoHubFromCache(string cacheKey, out ZhiJiaoHubSnapshot snapshot)
{
lock (_cacheGate)
{
if (_zhiJiaoHubCacheBySource.TryGetValue(cacheKey, out var cacheEntry) &&
cacheEntry.ExpireAt > DateTimeOffset.UtcNow)
{
snapshot = cacheEntry.Snapshot;
return true;
}
}
snapshot = null!;
return false;
}
private void SetZhiJiaoHubCache(string cacheKey, ZhiJiaoHubSnapshot snapshot)
{
lock (_cacheGate)
{
// 使用较长的缓存时间1小时因为图片列表不常变化
_zhiJiaoHubCacheBySource[cacheKey] = new ZhiJiaoHubCacheEntry(
snapshot,
DateTimeOffset.UtcNow.Add(TimeSpan.FromHours(1)));
}
}
private readonly ZhiJiaoHubCacheService _zhiJiaoHubCacheService = new();
public async Task<ZhiJiaoHubSyncResult> SyncZhiJiaoHubImagesAsync(
string source,
string mirrorSource,
IProgress<(int Current, int Total, string Status)>? progress = null,
CancellationToken cancellationToken = default)
{
var normalizedSource = ZhiJiaoHubSources.Normalize(source);
var normalizedMirror = ZhiJiaoHubMirrorSources.Normalize(mirrorSource);
try
{
var query = new ZhiJiaoHubQuery(normalizedSource, ForceRefresh: true, MirrorSource: normalizedMirror);
var result = await GetZhiJiaoHubImagesAsync(query, cancellationToken);
if (!result.Success || result.Data == null)
{
return new ZhiJiaoHubSyncResult(
false,
null,
0,
0,
0,
result.ErrorMessage ?? "Failed to fetch image list");
}
return await _zhiJiaoHubCacheService.SyncImagesAsync(
normalizedSource,
result.Data.Images,
normalizedMirror,
progress,
cancellationToken);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return new ZhiJiaoHubSyncResult(false, null, 0, 0, 0, ex.Message);
}
}
public ZhiJiaoHubLocalSnapshot? LoadZhiJiaoHubLocalSnapshot(string source)
{
var normalizedSource = ZhiJiaoHubSources.Normalize(source);
return _zhiJiaoHubCacheService.LoadLocalSnapshot(normalizedSource);
}
public bool HasZhiJiaoHubLocalCache(string source)
{
var normalizedSource = ZhiJiaoHubSources.Normalize(source);
return _zhiJiaoHubCacheService.HasLocalCache(normalizedSource);
}
public async Task<RecommendationQueryResult<ZhiJiaoHubHybridSnapshot>> GetZhiJiaoHubHybridImagesAsync(
string source,
string mirrorSource,
CancellationToken cancellationToken = default)
{
var normalizedSource = ZhiJiaoHubSources.Normalize(source);
var normalizedMirror = ZhiJiaoHubMirrorSources.Normalize(mirrorSource);
var localPathMap = _zhiJiaoHubCacheService.LoadLocalPathMap(normalizedSource);
try
{
var query = new ZhiJiaoHubQuery(normalizedSource, ForceRefresh: true, MirrorSource: normalizedMirror);
var result = await GetZhiJiaoHubImagesAsync(query, cancellationToken);
if (!result.Success || result.Data == null)
{
return RecommendationQueryResult<ZhiJiaoHubHybridSnapshot>.Fail(
result.ErrorCode ?? "upstream_error",
result.ErrorMessage ?? "Failed to fetch image list");
}
var hybridImages = result.Data.Images.Select((img, idx) =>
{
var hasLocal = localPathMap.TryGetValue(img.Url, out var localPath);
return new ZhiJiaoHubHybridImageItem(
img.Name,
img.Url,
hasLocal ? localPath : null,
idx,
hasLocal);
}).ToList();
var snapshot = new ZhiJiaoHubHybridSnapshot(
hybridImages,
normalizedSource,
hybridImages.Count(i => i.IsCached),
hybridImages.Count);
return RecommendationQueryResult<ZhiJiaoHubHybridSnapshot>.Ok(snapshot);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return RecommendationQueryResult<ZhiJiaoHubHybridSnapshot>.Fail("upstream_network_error", ex.Message);
}
}
public async Task<string?> DownloadAndCacheImageAsync(
string source,
ZhiJiaoHubImageItem image,
string mirrorSource,
CancellationToken cancellationToken = default)
{
var normalizedSource = ZhiJiaoHubSources.Normalize(source);
var normalizedMirror = ZhiJiaoHubMirrorSources.Normalize(mirrorSource);
return await _zhiJiaoHubCacheService.DownloadAndSaveImageAsync(
normalizedSource,
image.Name,
image.Url,
normalizedMirror,
cancellationToken);
}
public Task StartBackgroundDownloadAsync(
string source,
IReadOnlyList<ZhiJiaoHubHybridImageItem> images,
string mirrorSource,
Action<int, int, string>? onProgress = null,
CancellationToken cancellationToken = default)
{
var normalizedSource = ZhiJiaoHubSources.Normalize(source);
var normalizedMirror = ZhiJiaoHubMirrorSources.Normalize(mirrorSource);
return Task.Run(async () =>
{
var uncachedImages = images.Where(i => !i.IsCached).ToList();
var total = uncachedImages.Count;
var downloaded = 0;
foreach (var image in uncachedImages)
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
try
{
var localPath = await _zhiJiaoHubCacheService.DownloadAndSaveImageAsync(
normalizedSource,
image.Name,
image.RemoteUrl,
normalizedMirror,
cancellationToken);
if (localPath != null)
{
downloaded++;
}
onProgress?.Invoke(downloaded, total, image.Name);
}
catch
{
}
}
}, cancellationToken);
}
}

View File

@@ -1,13 +1,16 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.PluginMarket;
using LanMountainDesktop.Settings.Core;
namespace LanMountainDesktop.Services.Settings;
namespace LanMountainDesktop.Services.Settings
{
public enum WallpaperMediaType
{
@@ -64,44 +67,173 @@ public sealed record UpdateSettingsState(
string? PendingUpdateInstallerPath,
string? PendingUpdateVersion,
long? PendingUpdatePublishedAtUtcMs,
long? LastUpdateCheckUtcMs);
long? LastUpdateCheckUtcMs,
string? PendingUpdateSha256);
public sealed record PluginManagementSettingsState(IReadOnlyList<string> DisabledPluginIds);
public sealed record PluginMarketDependencyInfo(
public enum PluginPackageSourceKind
{
ReleaseAsset = 0,
RawFallback = 1,
WorkspaceLocal = 2
}
public sealed record PluginCatalogSourceInfo(
string Id,
string Name,
string? Description,
string? SourceUrl,
string? CachePath,
bool IsOfficial,
int Priority);
public sealed record PluginCatalogSharedContractInfo(
string Id,
string Version,
string AssemblyName);
public sealed record PluginMarketPluginInfo(
public sealed record PluginCapabilityInfo(
string Id,
string? Version,
string? AssemblyName);
public sealed record PluginPackageSourceInfo(
PluginPackageSourceKind Kind,
string Url,
string Sha256,
long PackageSizeBytes);
public sealed record PluginCatalogManifestInfo(
string Id,
string Name,
string Description,
string Author,
string Version,
string ApiVersion,
string EntranceAssembly,
IReadOnlyList<PluginCatalogSharedContractInfo> SharedContracts);
public sealed record PluginCatalogCompatibilityInfo(
string MinHostVersion,
string DownloadUrl,
string ReleaseTag,
string ReleaseAssetName,
string ApiVersion);
public sealed record PluginCatalogRepositoryInfo(
string IconUrl,
string ProjectUrl,
string ReadmeUrl,
string HomepageUrl,
string RepositoryUrl,
IReadOnlyList<string> Tags,
IReadOnlyList<PluginMarketDependencyInfo> Dependencies,
string ReleaseNotes);
public sealed record PluginCatalogPublicationInfo(
string ReleaseTag,
string ReleaseAssetName,
DateTimeOffset PublishedAt,
DateTimeOffset UpdatedAt);
public sealed record PluginMarketIndexResult(
DateTimeOffset UpdatedAt,
long PackageSizeBytes,
string Sha256,
string? Md5);
public sealed record PluginCatalogItemInfo(
PluginCatalogManifestInfo Manifest,
PluginCatalogCompatibilityInfo Compatibility,
PluginCatalogRepositoryInfo Repository,
PluginCatalogPublicationInfo Publication,
IReadOnlyList<PluginPackageSourceInfo> PackageSources,
IReadOnlyList<PluginCapabilityInfo> Capabilities)
{
public string Id => Manifest.Id;
public string Name => Manifest.Name;
public string Description => Manifest.Description;
public string Author => Manifest.Author;
public string Version => Manifest.Version;
public string ApiVersion => Manifest.ApiVersion;
public string MinHostVersion => Compatibility.MinHostVersion;
public string DownloadUrl => PackageSources.FirstOrDefault()?.Url ?? string.Empty;
public string Sha256 => Publication.Sha256;
public long PackageSizeBytes => Publication.PackageSizeBytes;
public string IconUrl => Repository.IconUrl;
public string ProjectUrl => Repository.ProjectUrl;
public string ReadmeUrl => Repository.ReadmeUrl;
public string HomepageUrl => Repository.HomepageUrl;
public string RepositoryUrl => Repository.RepositoryUrl;
public IReadOnlyList<string> Tags => Repository.Tags;
public IReadOnlyList<PluginCatalogSharedContractInfo> SharedContracts => Manifest.SharedContracts;
public DateTimeOffset PublishedAt => Publication.PublishedAt;
public DateTimeOffset UpdatedAt => Publication.UpdatedAt;
public string ReleaseTag => Publication.ReleaseTag;
public string ReleaseAssetName => Publication.ReleaseAssetName;
public string ReleaseNotes => Repository.ReleaseNotes;
}
public sealed record PluginCatalogIndexResult(
bool Success,
IReadOnlyList<PluginMarketPluginInfo> Plugins,
IReadOnlyList<PluginCatalogItemInfo> Plugins,
IReadOnlyList<PluginCatalogSourceInfo> Sources,
string? Source,
string? SourceLocation,
string? WarningMessage,
string? ErrorMessage);
public sealed record PluginMarketInstallResult(
public sealed record PluginInstallDiagnostic(
string Code,
string Message,
string? Details = null);
public sealed record PluginCatalogInstallResult(
bool Success,
string? PluginId,
string? PluginName,
PluginManifest? InstalledManifest,
IReadOnlyList<PluginInstallDiagnostic> Diagnostics,
string? ErrorMessage);
public interface IPluginCatalogSourceProvider
{
Task<PluginCatalogIndexResult> LoadCatalogAsync(CancellationToken cancellationToken = default);
}
public interface IPluginCatalogService : IPluginCatalogSourceProvider
{
Task<PluginCatalogInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default);
}
public interface IPackageSourceResolver
{
IReadOnlyList<PluginPackageSourceInfo> ResolveSources(PluginCatalogItemInfo item);
}
public interface IPluginCompatibilityEvaluator
{
PluginInstallDiagnostic? Evaluate(PluginCatalogItemInfo item, Version? hostVersion);
}
public interface IPluginInstallOrchestrator
{
Task<PluginCatalogInstallResult> InstallAsync(PluginCatalogItemInfo item, CancellationToken cancellationToken = default);
}
public interface IGridSettingsService
{
GridSettingsState Get();
@@ -194,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,
@@ -201,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
@@ -223,10 +363,10 @@ public interface IPluginManagementSettingsService
bool DeleteInstalledPlugin(string pluginId);
}
public interface IPluginMarketSettingsService
public interface IPluginCatalogSettingsService : IPluginCatalogSourceProvider
{
Task<PluginMarketIndexResult> LoadIndexAsync(CancellationToken cancellationToken = default);
Task<PluginMarketInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default);
new Task<PluginCatalogIndexResult> LoadCatalogAsync(CancellationToken cancellationToken = default);
Task<PluginCatalogInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default);
}
public interface IApplicationInfoService
@@ -252,6 +392,18 @@ public interface ISettingsFacadeService
ILauncherCatalogService LauncherCatalog { get; }
ILauncherPolicyService LauncherPolicy { get; }
IPluginManagementSettingsService PluginManagement { get; }
IPluginMarketSettingsService PluginMarket { get; }
IPluginCatalogSettingsService PluginCatalog { get; }
IApplicationInfoService ApplicationInfo { get; }
}
}
namespace LanMountainDesktop.Services.PluginMarket
{
internal enum PluginPackageSourceKind
{
ReleaseAsset = 0,
RawFallback = 1,
WorkspaceLocal = 2
}
}

View File

@@ -678,7 +678,8 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
snapshot.PendingUpdateInstallerPath,
snapshot.PendingUpdateVersion,
snapshot.PendingUpdatePublishedAtUtcMs,
snapshot.LastUpdateCheckUtcMs);
snapshot.LastUpdateCheckUtcMs,
snapshot.PendingUpdateSha256);
}
public void Save(UpdateSettingsState state)
@@ -707,6 +708,9 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
snapshot.LastUpdateCheckUtcMs = state.LastUpdateCheckUtcMs is > 0
? state.LastUpdateCheckUtcMs
: null;
snapshot.PendingUpdateSha256 = string.IsNullOrWhiteSpace(state.PendingUpdateSha256)
? null
: state.PendingUpdateSha256.Trim().ToLowerInvariant();
_settingsService.SaveSnapshot(
SettingsScope.App,
snapshot,
@@ -721,7 +725,8 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
nameof(AppSettingsSnapshot.PendingUpdateInstallerPath),
nameof(AppSettingsSnapshot.PendingUpdateVersion),
nameof(AppSettingsSnapshot.PendingUpdatePublishedAtUtcMs),
nameof(AppSettingsSnapshot.LastUpdateCheckUtcMs)
nameof(AppSettingsSnapshot.LastUpdateCheckUtcMs),
nameof(AppSettingsSnapshot.PendingUpdateSha256)
]);
}
@@ -733,6 +738,14 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
return _releaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
}
public Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
Version currentVersion,
bool includePrerelease,
CancellationToken cancellationToken = default)
{
return _releaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
}
public Task<UpdateDownloadResult> DownloadAssetAsync(
GitHubReleaseAsset asset,
string destinationFilePath,
@@ -750,6 +763,23 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
cancellationToken);
}
public Task<UpdateDownloadResult> RedownloadAssetAsync(
GitHubReleaseAsset asset,
string destinationFilePath,
string downloadSource,
int maxParallelSegments,
IProgress<double>? progress = null,
CancellationToken cancellationToken = default)
{
return _releaseUpdateService.RedownloadAssetAsync(
asset,
destinationFilePath,
downloadSource,
maxParallelSegments,
progress,
cancellationToken);
}
public void Dispose()
{
_releaseUpdateService.Dispose();
@@ -829,14 +859,14 @@ internal sealed class PluginManagementSettingsService : IPluginManagementSetting
}
}
internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService, IDisposable
internal sealed class PluginCatalogSettingsService : IPluginCatalogSettingsService, IDisposable
{
private PluginRuntimeService? _pluginRuntimeService;
private AirAppMarketIndexService _indexService;
private AirAppMarketInstallService? _installService;
private readonly Dictionary<string, AirAppMarketPluginEntry> _cachedPlugins = new(StringComparer.OrdinalIgnoreCase);
public PluginMarketSettingsService(PluginRuntimeService? pluginRuntimeService)
public PluginCatalogSettingsService(PluginRuntimeService? pluginRuntimeService)
{
_pluginRuntimeService = pluginRuntimeService;
@@ -870,14 +900,29 @@ internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService
_installService = new AirAppMarketInstallService(_pluginRuntimeService, dataRoot);
}
public async Task<PluginMarketIndexResult> LoadIndexAsync(CancellationToken cancellationToken = default)
public Task<PluginCatalogIndexResult> LoadCatalogAsync(CancellationToken cancellationToken = default)
{
var result = await _indexService.LoadAsync(cancellationToken);
return LoadCatalogCoreAsync(cancellationToken);
}
public Task<PluginCatalogInstallResult> InstallAsync(
string pluginId,
CancellationToken cancellationToken = default)
{
return InstallCatalogCoreAsync(pluginId, cancellationToken);
}
private async Task<PluginCatalogIndexResult> LoadCatalogCoreAsync(CancellationToken cancellationToken = default)
{
var result = await _indexService.LoadAsync(cancellationToken).ConfigureAwait(false);
var sources = BuildCatalogSources(result.Source?.ToString(), result.SourceLocation, result.WarningMessage);
if (!result.Success || result.Document is null)
{
return new PluginMarketIndexResult(
_cachedPlugins.Clear();
return new PluginCatalogIndexResult(
false,
[],
sources,
result.Source?.ToString(),
result.SourceLocation,
result.WarningMessage,
@@ -889,81 +934,191 @@ internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService
.Select(entry =>
{
_cachedPlugins[entry.Id] = entry;
return new PluginMarketPluginInfo(
entry.Id,
entry.Name,
entry.Description,
entry.Author,
entry.Version,
entry.ApiVersion,
entry.MinHostVersion,
entry.DownloadUrl,
entry.ReleaseTag,
entry.ReleaseAssetName,
entry.IconUrl,
entry.ReadmeUrl,
entry.HomepageUrl,
entry.RepositoryUrl,
entry.Tags,
entry.SharedContracts
.Select(contract => new PluginMarketDependencyInfo(
contract.Id,
contract.Version,
contract.AssemblyName))
.ToArray(),
entry.PublishedAt,
entry.UpdatedAt);
return MapCatalogItem(entry);
})
.ToArray();
return new PluginMarketIndexResult(
return new PluginCatalogIndexResult(
true,
plugins,
sources,
result.Source?.ToString(),
result.SourceLocation,
result.WarningMessage,
null);
}
public async Task<PluginMarketInstallResult> InstallAsync(
private async Task<PluginCatalogInstallResult> InstallCatalogCoreAsync(
string pluginId,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(pluginId))
{
return new PluginMarketInstallResult(false, null, null, "Plugin id is required.");
return new PluginCatalogInstallResult(
false,
null,
null,
null,
[new PluginInstallDiagnostic("invalid_request", "Plugin id is required.")],
"Plugin id is required.");
}
if (_installService is null || _pluginRuntimeService is null)
{
return new PluginMarketInstallResult(
return new PluginCatalogInstallResult(
false,
pluginId,
null,
null,
[new PluginInstallDiagnostic("runtime_unavailable", "Plugin runtime is unavailable.")],
"Plugin runtime is unavailable.");
}
if (!_cachedPlugins.TryGetValue(pluginId, out var entry))
{
var load = await LoadIndexAsync(cancellationToken);
var load = await LoadCatalogCoreAsync(cancellationToken).ConfigureAwait(false);
if (!load.Success)
{
return new PluginMarketInstallResult(false, pluginId, null, load.ErrorMessage);
return new PluginCatalogInstallResult(
false,
pluginId,
null,
null,
[new PluginInstallDiagnostic("catalog_load_failed", load.ErrorMessage ?? "Failed to load the plugin catalog.")],
load.ErrorMessage);
}
if (!_cachedPlugins.TryGetValue(pluginId, out entry))
{
return new PluginMarketInstallResult(false, pluginId, null, "Plugin was not found in market index.");
return new PluginCatalogInstallResult(
false,
pluginId,
null,
null,
[new PluginInstallDiagnostic("not_found", "Plugin was not found in the official catalog.")],
"Plugin was not found in the official catalog.");
}
}
var result = await _installService.InstallAsync(entry, cancellationToken);
var result = await _installService.InstallAsync(entry, cancellationToken).ConfigureAwait(false);
if (!result.Success)
{
return new PluginMarketInstallResult(false, entry.Id, entry.Name, result.ErrorMessage);
return new PluginCatalogInstallResult(
false,
entry.Id,
entry.Name,
null,
[new PluginInstallDiagnostic("install_failed", result.ErrorMessage ?? "Plugin install failed.")],
result.ErrorMessage);
}
return new PluginMarketInstallResult(true, result.Manifest?.Id ?? entry.Id, result.Manifest?.Name ?? entry.Name, null);
return new PluginCatalogInstallResult(
true,
result.Manifest?.Id ?? entry.Id,
result.Manifest?.Name ?? entry.Name,
result.Manifest,
[],
null);
}
private static PluginCatalogItemInfo MapCatalogItem(AirAppMarketPluginEntry entry)
{
var manifest = new PluginCatalogManifestInfo(
entry.Id,
entry.Name,
entry.Description,
entry.Author,
entry.Version,
entry.ApiVersion,
string.Empty,
entry.SharedContracts
.Select(contract => new PluginCatalogSharedContractInfo(
contract.Id,
contract.Version,
contract.AssemblyName))
.ToArray());
var compatibility = new PluginCatalogCompatibilityInfo(
entry.MinHostVersion,
entry.ApiVersion);
var repository = new PluginCatalogRepositoryInfo(
entry.IconUrl,
entry.ProjectUrl,
entry.ReadmeUrl,
entry.HomepageUrl,
entry.RepositoryUrl,
entry.Tags.ToArray(),
entry.ReleaseNotes);
var publication = new PluginCatalogPublicationInfo(
entry.ReleaseTag,
entry.ReleaseAssetName,
entry.PublishedAt,
entry.UpdatedAt,
entry.PackageSizeBytes,
entry.Sha256,
null);
var sources = BuildPackageSources(entry);
return new PluginCatalogItemInfo(
manifest,
compatibility,
repository,
publication,
sources,
[]);
}
private static IReadOnlyList<PluginPackageSourceInfo> BuildPackageSources(AirAppMarketPluginEntry entry)
{
var sources = entry.GetPackageSourcesInInstallOrder();
if (sources.Count == 0)
{
return [];
}
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))
.ToArray();
}
private static IReadOnlyList<PluginCatalogSourceInfo> BuildCatalogSources(
string? sourceId,
string? sourceLocation,
string? warningMessage)
{
if (string.IsNullOrWhiteSpace(sourceId) && string.IsNullOrWhiteSpace(sourceLocation))
{
return [];
}
var normalizedSourceId = string.IsNullOrWhiteSpace(sourceId)
? "plugin-catalog"
: sourceId.Trim();
return
[
new PluginCatalogSourceInfo(
normalizedSourceId,
normalizedSourceId,
string.IsNullOrWhiteSpace(warningMessage) ? null : warningMessage.Trim(),
string.IsNullOrWhiteSpace(sourceLocation) ? null : sourceLocation.Trim(),
null,
true,
0)
];
}
public void Dispose()
@@ -1030,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;
@@ -1053,8 +1208,8 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl
LauncherPolicy = new LauncherPolicyService();
_pluginManagementSettingsService = new PluginManagementSettingsService(Settings, pluginRuntimeService);
PluginManagement = _pluginManagementSettingsService;
_pluginMarketSettingsService = new PluginMarketSettingsService(pluginRuntimeService);
PluginMarket = _pluginMarketSettingsService;
_pluginCatalogSettingsService = new PluginCatalogSettingsService(pluginRuntimeService);
PluginCatalog = _pluginCatalogSettingsService;
ApplicationInfo = new ApplicationInfoService();
}
@@ -1086,20 +1241,20 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl
public IPluginManagementSettingsService PluginManagement { get; }
public IPluginMarketSettingsService PluginMarket { get; }
public IPluginCatalogSettingsService PluginCatalog { get; }
public IApplicationInfoService ApplicationInfo { get; }
public void BindPluginRuntime(PluginRuntimeService? pluginRuntimeService)
{
_pluginManagementSettingsService.SetPluginRuntime(pluginRuntimeService);
_pluginMarketSettingsService.SetPluginRuntime(pluginRuntimeService);
_pluginCatalogSettingsService.SetPluginRuntime(pluginRuntimeService);
}
public void Dispose()
{
_weatherSettingsService.Dispose();
_updateSettingsService.Dispose();
_pluginMarketSettingsService.Dispose();
_pluginCatalogSettingsService.Dispose();
}
}

View File

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

View File

@@ -0,0 +1,512 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Security.Authentication;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Services;
public sealed record ZhiJiaoHubLocalImageItem(
string Name,
string OriginalUrl,
string LocalPath,
int Index);
public sealed record ZhiJiaoHubLocalSnapshot(
IReadOnlyList<ZhiJiaoHubLocalImageItem> Images,
string Source,
DateTimeOffset LastUpdated,
int TotalCount);
public sealed record ZhiJiaoHubSyncResult(
bool Success,
ZhiJiaoHubLocalSnapshot? Snapshot,
int DownloadedCount,
int SkippedCount,
int FailedCount,
string? ErrorMessage = null);
public sealed class ZhiJiaoHubCacheService : IDisposable
{
private static readonly HttpClient DownloadClient;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
WriteIndented = true
};
private readonly string _cacheDirectory;
private readonly string _manifestPath;
private readonly object _manifestLock = new();
private bool _isDisposed;
static ZhiJiaoHubCacheService()
{
var handler = new HttpClientHandler
{
SslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13,
AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate
};
DownloadClient = new HttpClient(handler)
{
Timeout = TimeSpan.FromSeconds(30)
};
DownloadClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop/1.0");
}
public ZhiJiaoHubCacheService()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var dataDirectory = Path.Combine(appData, "LanMountainDesktop", "cache", "zhijiaohub");
_cacheDirectory = dataDirectory;
_manifestPath = Path.Combine(dataDirectory, "manifest.json");
}
public string CacheDirectory => _cacheDirectory;
public bool HasLocalCache(string source)
{
lock (_manifestLock)
{
if (!File.Exists(_manifestPath))
{
return false;
}
try
{
var json = File.ReadAllText(_manifestPath);
var manifest = JsonSerializer.Deserialize<CacheManifest>(json, JsonOptions);
return manifest?.Entries?.ContainsKey(source) == true &&
manifest.Entries[source].Images.Count > 0 &&
Directory.Exists(GetSourceDirectory(source));
}
catch
{
return false;
}
}
}
public ZhiJiaoHubLocalSnapshot? LoadLocalSnapshot(string source)
{
lock (_manifestLock)
{
if (!File.Exists(_manifestPath))
{
return null;
}
try
{
var json = File.ReadAllText(_manifestPath);
var manifest = JsonSerializer.Deserialize<CacheManifest>(json, JsonOptions);
if (manifest?.Entries?.TryGetValue(source, out var entry) != true)
{
return null;
}
var sourceDir = GetSourceDirectory(source);
var images = entry.Images
.Where(img => File.Exists(Path.Combine(sourceDir, img.LocalFileName)))
.Select((img, idx) => new ZhiJiaoHubLocalImageItem(
img.Name,
img.OriginalUrl,
Path.Combine(sourceDir, img.LocalFileName),
idx))
.ToList();
if (images.Count == 0)
{
return null;
}
return new ZhiJiaoHubLocalSnapshot(
images,
source,
entry.LastUpdated,
images.Count);
}
catch
{
return null;
}
}
}
public Dictionary<string, string> LoadLocalPathMap(string source)
{
lock (_manifestLock)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (!File.Exists(_manifestPath))
{
return result;
}
try
{
var json = File.ReadAllText(_manifestPath);
var manifest = JsonSerializer.Deserialize<CacheManifest>(json, JsonOptions);
if (manifest?.Entries?.TryGetValue(source, out var entry) != true)
{
return result;
}
var sourceDir = GetSourceDirectory(source);
foreach (var img in entry.Images)
{
var localPath = Path.Combine(sourceDir, img.LocalFileName);
if (File.Exists(localPath))
{
result[img.OriginalUrl] = localPath;
}
}
return result;
}
catch
{
return result;
}
}
}
public string? GetLocalPath(string source, string originalUrl)
{
lock (_manifestLock)
{
if (!File.Exists(_manifestPath))
{
return null;
}
try
{
var json = File.ReadAllText(_manifestPath);
var manifest = JsonSerializer.Deserialize<CacheManifest>(json, JsonOptions);
if (manifest?.Entries?.TryGetValue(source, out var entry) != true)
{
return null;
}
var img = entry.Images.FirstOrDefault(i =>
string.Equals(i.OriginalUrl, originalUrl, StringComparison.OrdinalIgnoreCase));
if (img == null)
{
return null;
}
var sourceDir = GetSourceDirectory(source);
var localPath = Path.Combine(sourceDir, img.LocalFileName);
return File.Exists(localPath) ? localPath : null;
}
catch
{
return null;
}
}
}
public async Task<string?> DownloadAndSaveImageAsync(
string source,
string name,
string remoteUrl,
string mirrorSource,
CancellationToken cancellationToken = default)
{
var sourceDir = GetSourceDirectory(source);
Directory.CreateDirectory(sourceDir);
var fileName = GetSafeFileName(name, remoteUrl);
var localPath = Path.Combine(sourceDir, fileName);
if (File.Exists(localPath))
{
AddToManifest(source, name, remoteUrl, fileName);
return localPath;
}
try
{
var downloadUrl = ResolveDownloadUrl(remoteUrl, mirrorSource);
using var response = await DownloadClient.GetAsync(downloadUrl, cancellationToken);
response.EnsureSuccessStatusCode();
await using var fileStream = File.Create(localPath);
await response.Content.CopyToAsync(fileStream, cancellationToken);
AddToManifest(source, name, remoteUrl, fileName);
return localPath;
}
catch
{
return null;
}
}
public async Task<ZhiJiaoHubSyncResult> SyncImagesAsync(
string source,
IReadOnlyList<ZhiJiaoHubImageItem> remoteImages,
string mirrorSource,
IProgress<(int Current, int Total, string Status)>? progress = null,
CancellationToken cancellationToken = default)
{
if (remoteImages == null || remoteImages.Count == 0)
{
return new ZhiJiaoHubSyncResult(false, null, 0, 0, 0, "No images to sync");
}
var sourceDir = GetSourceDirectory(source);
Directory.CreateDirectory(sourceDir);
var downloadedCount = 0;
var skippedCount = 0;
var failedCount = 0;
var localImages = new List<CachedImageInfo>();
for (var i = 0; i < remoteImages.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var remoteImage = remoteImages[i];
var fileName = GetSafeFileName(remoteImage.Name, remoteImage.Url);
var localPath = Path.Combine(sourceDir, fileName);
progress?.Report((i + 1, remoteImages.Count, $"Downloading {remoteImage.Name}..."));
if (File.Exists(localPath))
{
skippedCount++;
localImages.Add(new CachedImageInfo(remoteImage.Name, remoteImage.Url, fileName));
continue;
}
try
{
var downloadUrl = ResolveDownloadUrl(remoteImage.Url, mirrorSource);
using var response = await DownloadClient.GetAsync(downloadUrl, cancellationToken);
response.EnsureSuccessStatusCode();
await using var fileStream = File.Create(localPath);
await response.Content.CopyToAsync(fileStream, cancellationToken);
downloadedCount++;
localImages.Add(new CachedImageInfo(remoteImage.Name, remoteImage.Url, fileName));
}
catch (Exception)
{
failedCount++;
}
}
if (localImages.Count == 0)
{
return new ZhiJiaoHubSyncResult(false, null, downloadedCount, skippedCount, failedCount, "All downloads failed");
}
SaveManifest(source, localImages);
var snapshot = new ZhiJiaoHubLocalSnapshot(
localImages.Select((img, idx) => new ZhiJiaoHubLocalImageItem(
img.Name,
img.OriginalUrl,
Path.Combine(sourceDir, img.LocalFileName),
idx)).ToList(),
source,
DateTimeOffset.UtcNow,
localImages.Count);
return new ZhiJiaoHubSyncResult(true, snapshot, downloadedCount, skippedCount, failedCount);
}
public void ClearCache(string? source = null)
{
lock (_manifestLock)
{
if (source != null)
{
var sourceDir = GetSourceDirectory(source);
if (Directory.Exists(sourceDir))
{
Directory.Delete(sourceDir, true);
}
if (File.Exists(_manifestPath))
{
try
{
var json = File.ReadAllText(_manifestPath);
var manifest = JsonSerializer.Deserialize<CacheManifest>(json, JsonOptions);
if (manifest?.Entries != null && manifest.Entries.ContainsKey(source))
{
manifest.Entries.Remove(source);
File.WriteAllText(_manifestPath, JsonSerializer.Serialize(manifest, JsonOptions));
}
}
catch
{
}
}
}
else
{
if (Directory.Exists(_cacheDirectory))
{
Directory.Delete(_cacheDirectory, true);
}
}
}
}
private string GetSourceDirectory(string source)
{
return Path.Combine(_cacheDirectory, source.ToLowerInvariant().Replace(" ", "-"));
}
private static string GetSafeFileName(string name, string url)
{
var ext = Path.GetExtension(new Uri(url).AbsolutePath);
if (string.IsNullOrEmpty(ext) || ext.Length > 5)
{
ext = ".jpg";
}
var safeName = string.Concat(name.Split(Path.GetInvalidFileNameChars()));
if (string.IsNullOrWhiteSpace(safeName))
{
safeName = Guid.NewGuid().ToString("N")[..8];
}
return $"{safeName}{ext}";
}
private static string ResolveDownloadUrl(string originalUrl, string mirrorSource)
{
if (string.Equals(mirrorSource, ZhiJiaoHubMirrorSources.GhProxy, StringComparison.OrdinalIgnoreCase))
{
return ZhiJiaoHubMirrorSources.GhProxyBaseUrl.TrimEnd('/') + "/" + originalUrl;
}
return originalUrl;
}
private void AddToManifest(string source, string name, string originalUrl, string localFileName)
{
lock (_manifestLock)
{
CacheManifest manifest;
if (File.Exists(_manifestPath))
{
try
{
var json = File.ReadAllText(_manifestPath);
manifest = JsonSerializer.Deserialize<CacheManifest>(json, JsonOptions) ?? new CacheManifest();
}
catch
{
manifest = new CacheManifest();
}
}
else
{
manifest = new CacheManifest();
}
if (!manifest.Entries.TryGetValue(source, out var entry))
{
entry = new CacheEntry(new List<CachedImageInfo>(), DateTimeOffset.UtcNow);
manifest.Entries[source] = entry;
}
var existingIndex = entry.Images.FindIndex(i =>
string.Equals(i.OriginalUrl, originalUrl, StringComparison.OrdinalIgnoreCase));
if (existingIndex >= 0)
{
entry.Images[existingIndex] = new CachedImageInfo(name, originalUrl, localFileName);
}
else
{
entry.Images.Add(new CachedImageInfo(name, originalUrl, localFileName));
}
Directory.CreateDirectory(Path.GetDirectoryName(_manifestPath)!);
File.WriteAllText(_manifestPath, JsonSerializer.Serialize(manifest, JsonOptions));
}
}
private void SaveManifest(string source, List<CachedImageInfo> images)
{
lock (_manifestLock)
{
CacheManifest manifest;
if (File.Exists(_manifestPath))
{
try
{
var json = File.ReadAllText(_manifestPath);
manifest = JsonSerializer.Deserialize<CacheManifest>(json, JsonOptions) ?? new CacheManifest();
}
catch
{
manifest = new CacheManifest();
}
}
else
{
manifest = new CacheManifest();
}
manifest.Entries[source] = new CacheEntry(images, DateTimeOffset.UtcNow);
Directory.CreateDirectory(Path.GetDirectoryName(_manifestPath)!);
File.WriteAllText(_manifestPath, JsonSerializer.Serialize(manifest, JsonOptions));
}
}
public void Dispose()
{
if (_isDisposed) return;
_isDisposed = true;
}
private sealed class CacheManifest
{
public Dictionary<string, CacheEntry> Entries { get; set; } = new();
}
private sealed class CacheEntry
{
public List<CachedImageInfo> Images { get; set; }
public DateTimeOffset LastUpdated { get; set; }
public CacheEntry(List<CachedImageInfo> images, DateTimeOffset lastUpdated)
{
Images = images;
LastUpdated = lastUpdated;
}
}
private sealed class CachedImageInfo
{
public string Name { get; set; }
public string OriginalUrl { get; set; }
public string LocalFileName { get; set; }
public CachedImageInfo(string name, string originalUrl, string localFileName)
{
Name = name;
OriginalUrl = originalUrl;
LocalFileName = localFileName;
}
}
}

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ using LanMountainDesktop.Services.Settings;
namespace LanMountainDesktop.ViewModels;
public enum PluginMarketPrimaryActionState
public enum PluginCatalogPrimaryActionState
{
Install,
Update,
@@ -24,14 +24,14 @@ 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(
PluginMarketPluginInfo plugin,
public PluginCatalogItemViewModel(
PluginCatalogItemInfo plugin,
LocalizationService localizationService,
string languageCode)
{
@@ -46,7 +46,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
ActionTooltip = L("market.button.install", "Install");
}
public PluginMarketPluginInfo Info { get; }
public PluginCatalogItemInfo Info { get; }
public string PluginId => Info.Id;
@@ -64,7 +64,11 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
public string ReadmeUrl => Info.ReadmeUrl;
public IReadOnlyList<PluginMarketDependencyInfo> Dependencies => Info.Dependencies;
public IReadOnlyList<PluginCatalogSharedContractInfo> Dependencies => Info.SharedContracts;
public IReadOnlyList<PluginPackageSourceInfo> PackageSources => Info.PackageSources;
public IReadOnlyList<PluginCapabilityInfo> Capabilities => Info.Capabilities;
public string IconFallbackText { get; }
@@ -100,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)
{
@@ -160,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;
@@ -169,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,
@@ -181,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;
@@ -190,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;
@@ -199,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;
@@ -238,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;
@@ -259,7 +263,7 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase
_readmeService = readmeService;
_primaryActionAsync = primaryActionAsync;
Dependencies = new ObservableCollection<PluginMarketDependencyInfo>(item.Dependencies);
Dependencies = new ObservableCollection<PluginCatalogSharedContractInfo>(item.Dependencies);
VersionLabel = L("market.detail.version", "Version");
PublisherLabel = L("market.detail.author", "Author");
ApiVersionLabel = L("market.detail.api_version", "API Version");
@@ -269,9 +273,9 @@ 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<PluginMarketDependencyInfo> Dependencies { get; }
public ObservableCollection<PluginCatalogSharedContractInfo> Dependencies { get; }
public string DrawerTitle => Item.Name;
@@ -306,6 +310,10 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase
public bool HasReadmeContent => !IsReadmeLoading && !HasReadmeError && !string.IsNullOrWhiteSpace(ReadmeMarkdown);
public IReadOnlyList<PluginPackageSourceInfo> PackageSources => Item.PackageSources;
public IReadOnlyList<PluginCapabilityInfo> Capabilities => Item.Capabilities;
public async Task InitializeAsync()
{
if (_isInitialized)
@@ -367,9 +375,10 @@ 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;
private readonly LocalizationService _localizationService;
private readonly AirAppMarketIconService _iconService;
private readonly AirAppMarketReadmeService _readmeService;
@@ -377,31 +386,32 @@ 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,
AirAppMarketReadmeService readmeService)
{
_settingsFacade = settingsFacade;
_pluginCatalog = _settingsFacade.PluginCatalog;
_localizationService = localizationService;
_iconService = iconService;
_readmeService = readmeService;
_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;
@@ -444,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,
@@ -465,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 _settingsFacade.PluginMarket.LoadIndexAsync();
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);
}
@@ -503,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
{
@@ -517,7 +527,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
}
[RelayCommand]
private void OpenDetails(PluginMarketItemViewModel? item)
private void OpenDetails(PluginCatalogItemViewModel? item)
{
if (item is null)
{
@@ -528,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;
@@ -559,7 +569,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
L("market.status.installing_format", "Downloading and staging plugin '{0}'..."),
item.Name);
var result = await _settingsFacade.PluginMarket.InstallAsync(item.PluginId);
var result = await _pluginCatalog.InstallAsync(item.PluginId);
if (result.Success)
{
RefreshInstalledSnapshot();
@@ -604,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);
}
@@ -632,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))
{
@@ -650,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.");
@@ -659,12 +669,12 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
private void RefreshLocalizedText()
{
PageTitle = L("settings.plugin_market.title", "Plugin Market");
PageDescription = L("settings.plugin_market.subtitle", "Browse plugins from the official LanAirApp source and stage installs.");
PageTitle = L("settings.plugin_catalog.title", "Plugin Catalog");
PageDescription = L("settings.plugin_catalog.subtitle", "Browse plugins from the official LanAirApp source and stage installs.");
SearchPlaceholder = L("market.toolbar.search_placeholder", "Search plugins");
RefreshButtonText = L("market.toolbar.refresh", "Refresh");
RestartRequiredMessage = L("settings.plugins.restart_required", "Plugin changes take effect after restart.");
EmptyStateText = L("market.list.empty", "The plugin market has not been loaded yet.");
EmptyStateText = L("market.list.empty", "The plugin catalog has not been loaded yet.");
}
private string L(string key, string fallback)

View File

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

View File

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

View File

@@ -0,0 +1,102 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
x:Class="LanMountainDesktop.Views.ComponentEditors.ZhiJiaoHubComponentEditor">
<StackPanel Spacing="16">
<!-- 数据源选择 -->
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="SourceLabelTextBlock"
Classes="component-editor-section-title" />
<ComboBox x:Name="SourceComboBox"
Classes="component-editor-select"
HorizontalAlignment="Stretch"
SelectionChanged="OnSourceSelectionChanged">
<ComboBoxItem x:Name="ClassIslandItem"
Classes="component-editor-select-item"
Tag="classisland" />
<ComboBoxItem x:Name="SectlItem"
Classes="component-editor-select-item"
Tag="sectl" />
</ComboBox>
<TextBlock x:Name="SourceDescriptionTextBlock"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<!-- 镜像加速源选择 -->
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="MirrorSourceLabelTextBlock"
Classes="component-editor-section-title" />
<ComboBox x:Name="MirrorSourceComboBox"
Classes="component-editor-select"
HorizontalAlignment="Stretch"
SelectionChanged="OnMirrorSourceSelectionChanged">
<ComboBoxItem x:Name="DirectMirrorItem"
Classes="component-editor-select-item"
Tag="direct" />
<ComboBoxItem x:Name="GhProxyMirrorItem"
Classes="component-editor-select-item"
Tag="gh-proxy" />
</ComboBox>
<TextBlock x:Name="MirrorSourceDescriptionTextBlock"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<!-- 自动刷新设置 -->
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="16">
<TextBlock x:Name="RefreshSettingsLabelTextBlock"
Classes="component-editor-section-title" />
<!-- 自动刷新开关 -->
<Grid ColumnDefinitions="*,Auto">
<StackPanel Spacing="4">
<TextBlock x:Name="AutoRefreshLabelTextBlock"
Classes="component-editor-primary-text" />
<TextBlock x:Name="AutoRefreshDescriptionTextBlock"
Classes="component-editor-secondary-text"
FontSize="12" />
</StackPanel>
<ToggleSwitch x:Name="AutoRefreshToggle"
Grid.Column="1"
IsCheckedChanged="OnAutoRefreshChanged" />
</Grid>
<!-- 刷新间隔 -->
<StackPanel x:Name="IntervalPanel"
Spacing="8">
<TextBlock x:Name="IntervalLabelTextBlock"
Classes="component-editor-primary-text" />
<NumericUpDown x:Name="IntervalNumeric"
Classes="component-editor-numeric"
Minimum="5"
Maximum="1440"
Increment="5"
ValueChanged="OnIntervalValueChanged" />
</StackPanel>
</StackPanel>
</Border>
<!-- 说明信息 -->
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="8">
<TextBlock x:Name="AboutLabelTextBlock"
Classes="component-editor-section-title" />
<TextBlock x:Name="AboutDescriptionTextBlock"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
</StackPanel>
</Border>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,152 @@
using System;
using Avalonia.Controls;
using Avalonia.Interactivity;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Views.ComponentEditors;
public partial class ZhiJiaoHubComponentEditor : ComponentEditorViewBase
{
private bool _suppressEvents;
public ZhiJiaoHubComponentEditor()
: this(null)
{
}
public ZhiJiaoHubComponentEditor(DesktopComponentEditorContext? context)
: base(context)
{
InitializeComponent();
ApplyLocalization();
LoadState();
}
private void ApplyLocalization()
{
// 标题
SourceLabelTextBlock.Text = L("zhijiaohub.settings.source", "图片源");
ClassIslandItem.Content = L("zhijiaohub.settings.classisland", "ClassIsland 图库");
SectlItem.Content = L("zhijiaohub.settings.sectl", "SECTL 图库");
// 数据源描述
SourceDescriptionTextBlock.Text = L("zhijiaohub.settings.source_desc",
"选择图片来源。ClassIsland 图库包含 ClassIsland 社区的趣味瞬间SECTL 图库包含 SECTL 社区的内容。");
// 镜像加速源
MirrorSourceLabelTextBlock.Text = L("zhijiaohub.settings.mirror_source", "镜像加速");
DirectMirrorItem.Content = L("zhijiaohub.settings.mirror_direct", "直连GitHub");
GhProxyMirrorItem.Content = L("zhijiaohub.settings.mirror_ghproxy", "镜像加速(推荐)");
MirrorSourceDescriptionTextBlock.Text = L("zhijiaohub.settings.mirror_source_desc",
"如果图片加载缓慢或失败,请尝试使用镜像加速。镜像加速通过第三方代理服务加速 GitHub 访问。");
// 刷新设置
RefreshSettingsLabelTextBlock.Text = L("zhijiaohub.settings.refresh", "刷新设置");
AutoRefreshLabelTextBlock.Text = L("zhijiaohub.settings.auto_refresh", "自动刷新");
AutoRefreshDescriptionTextBlock.Text = L("zhijiaohub.settings.auto_refresh_desc",
"定期自动刷新图片列表。");
IntervalLabelTextBlock.Text = L("zhijiaohub.settings.interval", "刷新间隔(分钟)");
// 关于
AboutLabelTextBlock.Text = L("zhijiaohub.settings.about", "关于");
AboutDescriptionTextBlock.Text = L("zhijiaohub.settings.about_desc",
"智教Hub 展示来自教育技术社区的有趣图片。图片从 GitHub 仓库获取并缓存在本地。");
}
private void LoadState()
{
_suppressEvents = true;
var snapshot = LoadSnapshot();
// 数据源
var source = ZhiJiaoHubSources.Normalize(snapshot.ZhiJiaoHubSource);
SourceComboBox.SelectedItem = source switch
{
ZhiJiaoHubSources.Sectl => SectlItem,
_ => ClassIslandItem
};
// 镜像加速源
var mirrorSource = ZhiJiaoHubMirrorSources.Normalize(snapshot.ZhiJiaoHubMirrorSource);
MirrorSourceComboBox.SelectedItem = mirrorSource switch
{
ZhiJiaoHubMirrorSources.GhProxy => GhProxyMirrorItem,
_ => DirectMirrorItem
};
// 自动刷新
AutoRefreshToggle.IsChecked = snapshot.ZhiJiaoHubAutoRefreshEnabled;
// 刷新间隔
var interval = Math.Clamp(snapshot.ZhiJiaoHubAutoRefreshIntervalMinutes, 5, 1440);
IntervalNumeric.Value = interval;
IntervalPanel.IsVisible = snapshot.ZhiJiaoHubAutoRefreshEnabled;
_suppressEvents = false;
}
private void OnSourceSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (_suppressEvents)
{
return;
}
var source = SourceComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tag
? ZhiJiaoHubSources.Normalize(tag)
: ZhiJiaoHubSources.ClassIsland;
var snapshot = LoadSnapshot();
snapshot.ZhiJiaoHubSource = source;
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ZhiJiaoHubSource));
}
private void OnMirrorSourceSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (_suppressEvents)
{
return;
}
var mirrorSource = MirrorSourceComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tag
? ZhiJiaoHubMirrorSources.Normalize(tag)
: ZhiJiaoHubMirrorSources.Direct;
var snapshot = LoadSnapshot();
snapshot.ZhiJiaoHubMirrorSource = mirrorSource;
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ZhiJiaoHubMirrorSource));
}
private void OnAutoRefreshChanged(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
var isEnabled = AutoRefreshToggle.IsChecked ?? true;
IntervalPanel.IsVisible = isEnabled;
var snapshot = LoadSnapshot();
snapshot.ZhiJiaoHubAutoRefreshEnabled = isEnabled;
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ZhiJiaoHubAutoRefreshEnabled));
}
private void OnIntervalValueChanged(object? sender, NumericUpDownValueChangedEventArgs e)
{
if (_suppressEvents)
{
return;
}
var interval = (int)Math.Clamp(IntervalNumeric.Value ?? 30, 5, 1440);
var snapshot = LoadSnapshot();
snapshot.ZhiJiaoHubAutoRefreshIntervalMinutes = interval;
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ZhiJiaoHubAutoRefreshIntervalMinutes));
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,10 +9,12 @@
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"

View File

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

View File

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

View File

@@ -9,7 +9,7 @@
x:Class="LanMountainDesktop.Views.Components.CnrDailyNewsWidget">
<Border x:Name="RootBorder"
CornerRadius="34"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Background="Transparent"
ClipToBounds="True"
BorderThickness="0"
@@ -17,11 +17,12 @@
<Grid>
<Border x:Name="CardBorder"
Background="#FCFCFD"
CornerRadius="34"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
BorderBrush="Transparent"
BorderThickness="0"
Padding="16,14,16,14">
<Grid RowDefinitions="Auto,Auto,Auto,Auto"
<Grid x:Name="ContentGrid"
RowDefinitions="Auto,Auto,Auto,Auto"
RowSpacing="8">
<Grid Grid.Row="0"
ColumnDefinitions="*,Auto"

View File

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

View File

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

View File

@@ -9,7 +9,7 @@
x:Class="LanMountainDesktop.Views.Components.DailyArtworkWidget">
<Border x:Name="RootBorder"
CornerRadius="34"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
BorderThickness="0"
Background="#D5D5D5">

View File

@@ -0,0 +1,105 @@
<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.Components.DailyNewsView">
<UserControl.Styles>
<Style Selector="Button.link-button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="4"/>
<Setter Property="Cursor" Value="Hand"/>
</Style>
</UserControl.Styles>
<StackPanel x:Name="RootStackPanel" Spacing="16">
<Border x:Name="CoverImageBorder"
CornerRadius="12"
ClipToBounds="True"
Background="#f8f5ec"
PointerPressed="OnCoverImagePointerPressed"
Cursor="Hand">
<Image x:Name="CoverImage"
Stretch="UniformToFill"/>
</Border>
<Grid ColumnDefinitions="*,Auto">
<TextBlock x:Name="DateTextBlock"
Grid.Column="0"
FontSize="20"
FontWeight="Bold"
Foreground="#bb5649"
VerticalAlignment="Center"/>
<StackPanel Grid.Column="1"
Orientation="Horizontal"
Spacing="8"
VerticalAlignment="Center">
<Button x:Name="BilibiliButton"
Classes="link-button"
Width="32"
Height="32"
Padding="0"
CornerRadius="16"
Background="#FB7299"
Cursor="Hand"
Click="OnBilibiliButtonClick"
ToolTip.Tip="观看视频版">
<Path Stretch="Uniform"
Width="18"
Height="18"
Fill="White"
Data="M17.813 4.653h.854c1.51.054 2.769.578 3.773 1.574 1.004.995 1.524 2.249 1.56 3.76v7.36c-.036 1.51-.556 2.769-1.56 3.773s-2.262 1.524-3.773 1.56H5.333c-1.51-.036-2.769-.556-3.773-1.56S.036 18.858 0 17.347v-7.36c.036-1.511.556-2.765 1.56-3.76 1.004-.996 2.262-1.52 3.773-1.574h.774l-1.174-1.12a1.234 1.234 0 0 1-.373-.906c0-.356.124-.658.373-.907l.027-.027c.267-.249.573-.373.92-.373.347 0 .653.124.92.373L9.653 4.44c.071.071.134.142.187.213h4.267a.836.836 0 0 1 .16-.213l2.853-2.747c.267-.249.573-.373.92-.373.347 0 .662.151.929.4.267.249.391.551.391.907 0 .355-.124.657-.373.906zM5.333 7.24c-.746.018-1.373.276-1.88.773-.506.498-.769 1.13-.786 1.894v7.52c.017.764.28 1.395.786 1.893.507.498 1.134.756 1.88.773h13.334c.746-.017 1.373-.275 1.88-.773.506-.498.769-1.129.786-1.893v-7.52c-.017-.765-.28-1.396-.786-1.894-.507-.497-1.134-.755-1.88-.773zM8 11.107c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c0-.373.129-.689.386-.947.258-.257.574-.386.947-.386zm8 0c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c.017-.391.15-.711.4-.96.249-.249.56-.373.933-.373Z"/>
</Button>
<Button x:Name="WechatButton"
Classes="link-button"
Width="32"
Height="32"
Padding="0"
CornerRadius="16"
Background="#07C160"
Cursor="Hand"
Click="OnWechatButtonClick"
ToolTip.Tip="阅读原文">
<Path Stretch="Uniform"
Width="18"
Height="18"
Fill="White"
Data="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z"/>
</Button>
</StackPanel>
</Grid>
<Border x:Name="OverviewBorder"
Background="#f8f5ec"
CornerRadius="8"
Padding="12"
Margin="0,0,0,8">
<StackPanel x:Name="OverviewStackPanel" Spacing="12"/>
</Border>
<Button x:Name="ShowMoreButton"
Content="展开更多新闻 ▼"
FontSize="14"
Padding="16,8"
CornerRadius="8"
Background="Transparent"
BorderBrush="#bb5649"
BorderThickness="1"
Foreground="#bb5649"
Cursor="Hand"
Click="OnShowMoreButtonClick"/>
<StackPanel x:Name="DetailedNewsStackPanel"
Spacing="16"
IsVisible="False"/>
<Border x:Name="DateSeparatorBorder"
Height="1"
Background="#e6e6e6"
Margin="0,8,0,0"/>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,526 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
namespace LanMountainDesktop.Views.Components;
public partial class DailyNewsView : UserControl
{
private static readonly HttpClient HttpClient = new()
{
Timeout = TimeSpan.FromSeconds(10)
};
private readonly JuyaDailyNews _news;
private Bitmap? _coverBitmap;
private bool _isNightMode;
private bool _isExpanded;
public event EventHandler? CoverImageClicked;
public event EventHandler<string>? NewsItemClicked;
public DailyNewsView(JuyaDailyNews news, bool isNightMode)
{
InitializeComponent();
_news = news;
_isNightMode = isNightMode;
var dateStr = news.Date.ToString("yyyy年M月d日");
var dayOfWeek = news.Date.ToString("dddd");
DateTextBlock.Text = $"{dateStr} {dayOfWeek}";
_ = LoadCoverImageAsync(news.CoverImageUrl);
if (string.IsNullOrWhiteSpace(news.BilibiliUrl))
{
BilibiliButton.IsVisible = false;
}
if (string.IsNullOrWhiteSpace(news.IssueUrl))
{
WechatButton.IsVisible = false;
}
if (news.OverviewCategories.Any())
{
foreach (var category in news.OverviewCategories)
{
var categoryPanel = new StackPanel { Spacing = 6 };
var categoryHeader = new TextBlock
{
Text = $"{category.Icon} {category.Name}",
FontSize = 15,
FontWeight = FontWeight.SemiBold,
Foreground = new SolidColorBrush(isNightMode ? Color.Parse("#d4736a") : Color.Parse("#bb5649"))
};
categoryPanel.Children.Add(categoryHeader);
foreach (var item in category.Items)
{
var itemPanel = new StackPanel
{
Orientation = Avalonia.Layout.Orientation.Horizontal,
Spacing = 4
};
var bulletText = new TextBlock
{
Text = "•",
FontSize = 13,
Foreground = new SolidColorBrush(isNightMode ? Color.Parse("#9a9590") : Color.Parse("#757575"))
};
itemPanel.Children.Add(bulletText);
if (!string.IsNullOrWhiteSpace(item.Url))
{
var linkButton = new HyperlinkButton
{
Content = item.Title,
NavigateUri = new Uri(item.Url),
FontSize = 13,
Foreground = new SolidColorBrush(isNightMode ? Color.Parse("#9a9590") : Color.Parse("#757575")),
Padding = new Thickness(0)
};
itemPanel.Children.Add(linkButton);
}
else
{
var titleText = new TextBlock
{
Text = item.Title,
FontSize = 13,
TextWrapping = TextWrapping.Wrap,
Foreground = new SolidColorBrush(isNightMode ? Color.Parse("#9a9590") : Color.Parse("#757575"))
};
itemPanel.Children.Add(titleText);
}
if (item.Number.HasValue)
{
var numberText = new TextBlock
{
Text = $"#{item.Number}",
FontSize = 11,
Foreground = new SolidColorBrush(isNightMode ? Color.Parse("#d4736a") : Color.Parse("#bb5649")),
FontWeight = FontWeight.SemiBold,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
};
itemPanel.Children.Add(numberText);
}
categoryPanel.Children.Add(itemPanel);
}
OverviewStackPanel.Children.Add(categoryPanel);
}
}
else
{
OverviewBorder.IsVisible = false;
}
if (!news.DetailedNews.Any())
{
ShowMoreButton.IsVisible = false;
}
else
{
foreach (var detailedItem in news.DetailedNews)
{
var newsPanel = CreateDetailedNewsPanel(detailedItem, isNightMode);
DetailedNewsStackPanel.Children.Add(newsPanel);
}
}
ApplyNightMode(isNightMode);
}
private Border CreateDetailedNewsPanel(JuyaDetailedNewsItem detailedItem, bool isNightMode)
{
var primaryColor = isNightMode ? "#d4736a" : "#bb5649";
var textColor = isNightMode ? "#e8e4e0" : "#34495e";
var secondaryTextColor = isNightMode ? "#9a9590" : "#757575";
var mainBorder = new Border
{
Background = Brushes.Transparent,
BorderBrush = new SolidColorBrush(Color.Parse("#e6e6e6")),
BorderThickness = new Thickness(0, 0, 0, 1),
Padding = new Thickness(0, 0, 0, 16)
};
var mainStack = new StackPanel { Spacing = 12 };
mainBorder.Child = mainStack;
var headerPanel = new StackPanel
{
Orientation = Avalonia.Layout.Orientation.Horizontal,
Spacing = 8
};
if (detailedItem.Number > 0)
{
var numberBadge = new Border
{
Background = new SolidColorBrush(Color.Parse(primaryColor)),
CornerRadius = new CornerRadius(4),
Padding = new Thickness(6, 2),
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
};
var numberText = new TextBlock
{
Text = $"#{detailedItem.Number}",
FontSize = 12,
FontWeight = FontWeight.Bold,
Foreground = Brushes.White
};
numberBadge.Child = numberText;
headerPanel.Children.Add(numberBadge);
}
var titleText = new TextBlock
{
Text = detailedItem.Title,
FontSize = 16,
FontWeight = FontWeight.SemiBold,
Foreground = new SolidColorBrush(Color.Parse(textColor)),
TextWrapping = TextWrapping.Wrap,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
};
headerPanel.Children.Add(titleText);
mainStack.Children.Add(headerPanel);
if (!string.IsNullOrWhiteSpace(detailedItem.BodyText))
{
var bodyText = new TextBlock
{
Text = detailedItem.BodyText,
FontSize = 14,
LineHeight = 22,
TextWrapping = TextWrapping.Wrap,
Foreground = new SolidColorBrush(Color.Parse(textColor))
};
mainStack.Children.Add(bodyText);
}
if (detailedItem.RelatedLinks.Any())
{
var linksPanel = new StackPanel { Spacing = 4 };
var linksHeader = new TextBlock
{
Text = "相关链接:",
FontSize = 12,
Foreground = new SolidColorBrush(Color.Parse(secondaryTextColor))
};
linksPanel.Children.Add(linksHeader);
foreach (var link in detailedItem.RelatedLinks.Take(3))
{
var linkButton = new HyperlinkButton
{
Content = link.Length > 50 ? link.Substring(0, 50) + "..." : link,
NavigateUri = new Uri(link),
FontSize = 12,
Foreground = new SolidColorBrush(Color.Parse(primaryColor))
};
linksPanel.Children.Add(linkButton);
}
mainStack.Children.Add(linksPanel);
}
return mainBorder;
}
private void OnShowMoreButtonClick(object? sender, RoutedEventArgs e)
{
_isExpanded = !_isExpanded;
DetailedNewsStackPanel.IsVisible = _isExpanded;
ShowMoreButton.Content = _isExpanded ? "收起新闻 ▲" : "展开更多新闻 ▼";
}
private void OnBilibiliButtonClick(object? sender, RoutedEventArgs e)
{
if (!string.IsNullOrWhiteSpace(_news.BilibiliUrl))
{
TryOpenUrl(_news.BilibiliUrl);
}
e.Handled = true;
}
private void OnWechatButtonClick(object? sender, RoutedEventArgs e)
{
if (!string.IsNullOrWhiteSpace(_news.IssueUrl))
{
TryOpenUrl(_news.IssueUrl);
}
e.Handled = true;
}
private static void TryOpenUrl(string? url)
{
if (string.IsNullOrWhiteSpace(url))
{
return;
}
try
{
var startInfo = new ProcessStartInfo
{
FileName = url,
UseShellExecute = true
};
Process.Start(startInfo);
}
catch
{
}
}
private async Task LoadCoverImageAsync(string? imageUrl)
{
if (string.IsNullOrWhiteSpace(imageUrl))
{
return;
}
try
{
using var response = await HttpClient.GetAsync(imageUrl);
if (response.IsSuccessStatusCode)
{
await using var stream = await response.Content.ReadAsStreamAsync();
var bitmap = new Bitmap(stream);
_coverBitmap = bitmap;
await Dispatcher.UIThread.InvokeAsync(() =>
{
CoverImage.Source = bitmap;
});
}
}
catch
{
}
}
private void OnCoverImagePointerPressed(object? sender, PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
CoverImageClicked?.Invoke(this, EventArgs.Empty);
e.Handled = true;
}
}
public void ApplyNightMode(bool isNightMode)
{
_isNightMode = isNightMode;
var primaryColor = isNightMode ? "#d4736a" : "#bb5649";
var textColor = isNightMode ? "#e8e4e0" : "#34495e";
var secondaryTextColor = isNightMode ? "#9a9590" : "#757575";
var separatorColor = isNightMode ? "#3d3a3a" : "#e6e6e6";
var coverBgColor = isNightMode ? "#3d3a3a" : "#f8f5ec";
var overviewBgColor = isNightMode ? "#3d3a3a" : "#f8f5ec";
DateTextBlock.Foreground = new SolidColorBrush(Color.Parse(primaryColor));
DateSeparatorBorder.Background = new SolidColorBrush(Color.Parse(separatorColor));
CoverImageBorder.Background = new SolidColorBrush(Color.Parse(coverBgColor));
OverviewBorder.Background = new SolidColorBrush(Color.Parse(overviewBgColor));
ShowMoreButton.BorderBrush = new SolidColorBrush(Color.Parse(primaryColor));
ShowMoreButton.Foreground = new SolidColorBrush(Color.Parse(primaryColor));
foreach (var child in OverviewStackPanel.Children)
{
if (child is StackPanel categoryPanel && categoryPanel.Children.Count > 0)
{
if (categoryPanel.Children[0] is TextBlock categoryHeader)
{
categoryHeader.Foreground = new SolidColorBrush(Color.Parse(primaryColor));
}
for (int i = 1; i < categoryPanel.Children.Count; i++)
{
if (categoryPanel.Children[i] is StackPanel itemPanel)
{
foreach (var itemChild in itemPanel.Children)
{
if (itemChild is TextBlock textBlock)
{
if (textBlock.Text.StartsWith("#"))
{
textBlock.Foreground = new SolidColorBrush(Color.Parse(primaryColor));
}
else
{
textBlock.Foreground = new SolidColorBrush(Color.Parse(secondaryTextColor));
}
}
else if (itemChild is HyperlinkButton linkBtn)
{
linkBtn.Foreground = new SolidColorBrush(Color.Parse(secondaryTextColor));
}
}
}
}
}
}
foreach (var child in DetailedNewsStackPanel.Children)
{
if (child is Border mainBorder && mainBorder.Child is StackPanel mainStack)
{
mainBorder.BorderBrush = new SolidColorBrush(Color.Parse(separatorColor));
foreach (var stackChild in mainStack.Children)
{
if (stackChild is StackPanel headerPanel)
{
foreach (var headerChild in headerPanel.Children)
{
if (headerChild is Border numberBadge && numberBadge.Child is TextBlock numberText)
{
numberBadge.Background = new SolidColorBrush(Color.Parse(primaryColor));
}
else if (headerChild is TextBlock titleText)
{
titleText.Foreground = new SolidColorBrush(Color.Parse(textColor));
}
}
}
else if (stackChild is TextBlock bodyText)
{
bodyText.Foreground = new SolidColorBrush(Color.Parse(textColor));
}
else if (stackChild is StackPanel linksPanel)
{
foreach (var linkChild in linksPanel.Children)
{
if (linkChild is TextBlock linksHeader)
{
linksHeader.Foreground = new SolidColorBrush(Color.Parse(secondaryTextColor));
}
else if (linkChild is HyperlinkButton linkButton)
{
linkButton.Foreground = new SolidColorBrush(Color.Parse(primaryColor));
}
}
}
}
}
}
}
public void UpdateLayout(double scale, double availableWidth)
{
var coverHeight = availableWidth * 9 / 16;
CoverImageBorder.Width = availableWidth;
CoverImageBorder.Height = coverHeight;
DateTextBlock.FontSize = Math.Clamp(20 * scale, 16, 26);
ShowMoreButton.FontSize = Math.Clamp(14 * scale, 12, 16);
var buttonSize = Math.Clamp(32 * scale, 24, 40);
BilibiliButton.Width = buttonSize;
BilibiliButton.Height = buttonSize;
BilibiliButton.CornerRadius = new CornerRadius(buttonSize / 2);
WechatButton.Width = buttonSize;
WechatButton.Height = buttonSize;
WechatButton.CornerRadius = new CornerRadius(buttonSize / 2);
foreach (var child in OverviewStackPanel.Children)
{
if (child is StackPanel categoryPanel && categoryPanel.Children.Count > 0)
{
if (categoryPanel.Children[0] is TextBlock categoryHeader)
{
categoryHeader.FontSize = Math.Clamp(15 * scale, 13, 18);
}
for (int i = 1; i < categoryPanel.Children.Count; i++)
{
if (categoryPanel.Children[i] is StackPanel itemPanel)
{
foreach (var itemChild in itemPanel.Children)
{
if (itemChild is TextBlock textBlock)
{
textBlock.FontSize = Math.Clamp(13 * scale, 11, 15);
}
else if (itemChild is HyperlinkButton linkBtn)
{
linkBtn.FontSize = Math.Clamp(13 * scale, 11, 15);
}
}
}
}
}
}
foreach (var child in DetailedNewsStackPanel.Children)
{
if (child is Border mainBorder && mainBorder.Child is StackPanel mainStack)
{
foreach (var stackChild in mainStack.Children)
{
if (stackChild is StackPanel headerPanel)
{
foreach (var headerChild in headerPanel.Children)
{
if (headerChild is Border numberBadge && numberBadge.Child is TextBlock numberText)
{
numberText.FontSize = Math.Clamp(12 * scale, 10, 14);
}
else if (headerChild is TextBlock titleText)
{
titleText.FontSize = Math.Clamp(16 * scale, 14, 20);
}
}
}
else if (stackChild is TextBlock bodyText)
{
bodyText.FontSize = Math.Clamp(14 * scale, 12, 16);
bodyText.LineHeight = 22 * scale;
}
else if (stackChild is StackPanel linksPanel)
{
foreach (var linkChild in linksPanel.Children)
{
if (linkChild is TextBlock linksHeader)
{
linksHeader.FontSize = Math.Clamp(12 * scale, 10, 14);
}
else if (linkChild is HyperlinkButton linkButton)
{
linkButton.FontSize = Math.Clamp(12 * scale, 10, 14);
}
}
}
}
}
}
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnDetachedFromVisualTree(e);
_coverBitmap?.Dispose();
_coverBitmap = null;
}
}

View File

@@ -9,7 +9,7 @@
x:Class="LanMountainDesktop.Views.Components.DailyPoetryWidget">
<Border x:Name="RootBorder"
CornerRadius="34"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
BorderThickness="0"
Background="#C20A0A"

View File

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

View File

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

View File

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

View File

@@ -428,6 +428,10 @@ public sealed class DesktopComponentRuntimeRegistry
BuiltInComponentIds.DesktopIfengNews,
"component.ifeng_news",
() => new IfengNewsWidget()),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopJuyaNews,
"component.juya_news",
() => new JuyaNewsWidget()),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopBilibiliHotSearch,
"component.bilibili_hot_search",
@@ -467,7 +471,11 @@ public sealed class DesktopComponentRuntimeRegistry
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.HolidayCalendar,
"component.holiday_calendar",
() => new HolidayCalendarWidget())
() => new HolidayCalendarWidget()),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopZhiJiaoHub,
"component.zhijiao_hub",
() => new ZhiJiaoHubWidget())
];
}

View File

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

View File

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

View File

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

View File

@@ -9,14 +9,14 @@
x:Class="LanMountainDesktop.Views.Components.HourlyWeatherWidget">
<Border x:Name="RootBorder"
CornerRadius="28"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
Background="#6B7B8F">
<Grid>
<Border x:Name="BackgroundImageLayer" CornerRadius="28" ClipToBounds="True" />
<Border x:Name="BackgroundImageLayer" CornerRadius="{DynamicResource DesignCornerRadiusComponent}" ClipToBounds="True" />
<Border x:Name="BackgroundMotionLayer"
CornerRadius="28"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
Opacity="0.25"
RenderTransformOrigin="0.5,0.5">

View File

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

View File

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

View File

@@ -0,0 +1,106 @@
<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"
xmlns:fi="using:FluentIcons.Avalonia"
mc:Ignorable="d"
d:DesignWidth="640"
d:DesignHeight="640"
x:Class="LanMountainDesktop.Views.Components.JuyaNewsWidget">
<Border x:Name="RootBorder"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Background="Transparent"
ClipToBounds="True"
BorderThickness="0"
Padding="0">
<Grid>
<Border x:Name="CardBorder"
Background="#FCFCFD"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
BorderBrush="Transparent"
BorderThickness="0"
Padding="16,14,16,14">
<Grid x:Name="ContentGrid"
RowDefinitions="Auto,*">
<!-- Header -->
<Grid x:Name="HeaderGrid"
Grid.Row="0"
ColumnDefinitions="*,Auto"
ColumnSpacing="10"
Margin="0,0,0,12">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Spacing="10">
<Border x:Name="AvatarBorder"
Width="36"
Height="36"
CornerRadius="18"
ClipToBounds="True"
Background="#f8f5ec">
<Image x:Name="AvatarImage"
Source="avares://LanMountainDesktop/Assets/juya_avatar.jpg"
Stretch="UniformToFill"/>
</Border>
<TextBlock x:Name="BrandTextBlock"
Text="橘鸦Juya"
Foreground="#bb5649"
FontSize="20"
FontWeight="Bold"
VerticalAlignment="Center" />
</StackPanel>
<Button x:Name="RefreshButton"
Grid.Column="1"
Padding="8,4"
CornerRadius="8"
Background="Transparent"
BorderBrush="#bb5649"
BorderThickness="1"
Foreground="#bb5649"
Focusable="False"
ToolTip.Tip="刷新今日新闻"
Click="OnRefreshButtonClick">
<StackPanel Orientation="Horizontal" Spacing="4">
<fi:SymbolIcon x:Name="RefreshIcon"
Symbol="ArrowSync"
IconVariant="Regular"
FontSize="14"
Foreground="#bb5649" />
<TextBlock x:Name="RefreshButtonText"
Text="刷新"
FontSize="13"
VerticalAlignment="Center" />
</StackPanel>
</Button>
</Grid>
<!-- 滚动内容区 -->
<ScrollViewer x:Name="ContentScrollViewer"
Grid.Row="1"
VerticalScrollBarVisibility="Auto"
ScrollChanged="OnScrollChanged">
<StackPanel x:Name="NewsStackPanel" Spacing="16">
<!-- 加载提示 -->
<TextBlock x:Name="LoadingTextBlock"
Text="正在加载..."
Foreground="#757575"
FontSize="14"
HorizontalAlignment="Center"
IsVisible="False" />
</StackPanel>
</ScrollViewer>
</Grid>
</Border>
<TextBlock x:Name="StatusTextBlock"
IsVisible="False"
Text="Loading"
Foreground="#757575"
FontSize="16"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,827 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Xml.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Styling;
using Avalonia.Threading;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class JuyaNewsWidget : UserControl, IDesktopComponentWidget
{
private static readonly FontFamily MiSansFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans");
private static readonly HttpClient HttpClient = new()
{
Timeout = TimeSpan.FromSeconds(15)
};
private const string RssUrl = "https://imjuya.github.io/juya-ai-daily/rss.xml";
private const double BaseCellSize = 48d;
private const int BaseWidthCells = 4;
private const int BaseHeightCells = 4;
private const int InitialLoadDays = 3;
private const int LoadMoreDays = 3;
private const int MaxCachedDays = 30;
private readonly Dictionary<DateTime, JuyaDailyNews> _cachedNews = new();
private readonly List<DateTime> _loadedDates = new();
private readonly List<DailyNewsView> _dailyViews = new();
private double _currentCellSize = BaseCellSize;
private bool _isAttached;
private bool _isLoading;
private bool _isNightVisual;
private DateTime _earliestLoadedDate = DateTime.Today;
public JuyaNewsWidget()
{
InitializeComponent();
BrandTextBlock.FontFamily = MiSansFontFamily;
LoadingTextBlock.FontFamily = MiSansFontFamily;
StatusTextBlock.FontFamily = MiSansFontFamily;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
ActualThemeVariantChanged += OnActualThemeVariantChanged;
ApplyCellSize(_currentCellSize);
ApplyLoadingState();
}
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
UpdateAdaptiveLayout();
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = true;
_ = LoadInitialNewsAsync();
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = false;
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
ApplyCellSize(_currentCellSize);
}
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
{
_isNightVisual = ResolveNightMode();
UpdateAdaptiveLayout();
}
private bool ResolveNightMode()
{
if (ActualThemeVariant == ThemeVariant.Dark)
{
return true;
}
if (ActualThemeVariant == ThemeVariant.Light)
{
return false;
}
if (this.TryFindResource("AdaptiveSurfaceBaseBrush", out var value) &&
value is ISolidColorBrush brush)
{
return CalculateRelativeLuminance(brush.Color) < 0.45;
}
return true;
}
private static double CalculateRelativeLuminance(Color color)
{
static double ToLinear(double channel)
{
return channel <= 0.03928
? channel / 12.92
: Math.Pow((channel + 0.055) / 1.055, 2.4);
}
var r = ToLinear(color.R / 255d);
var g = ToLinear(color.G / 255d);
var b = ToLinear(color.B / 255d);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
private void ApplyNightModeVisual()
{
// 卡片背景
CardBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2d2a2a") : Color.Parse("#fefefe"));
// 品牌标题
BrandTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#d4736a") : Color.Parse("#bb5649"));
// 刷新按钮
RefreshButton.BorderBrush = new SolidColorBrush(_isNightVisual ? Color.Parse("#d4736a") : Color.Parse("#bb5649"));
RefreshButton.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#d4736a") : Color.Parse("#bb5649"));
// 头像背景
AvatarBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#3d3a3a") : Color.Parse("#f8f5ec"));
// 状态文字
StatusTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#9a9590") : Color.Parse("#757575"));
LoadingTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#9a9590") : Color.Parse("#757575"));
// 更新所有日期视图的样式
foreach (var view in _dailyViews)
{
view.ApplyNightMode(_isNightVisual);
}
}
private async Task LoadInitialNewsAsync()
{
if (!_isAttached || _isLoading)
{
return;
}
_isLoading = true;
LoadingTextBlock.IsVisible = true;
StatusTextBlock.IsVisible = false;
try
{
// 解析RSS获取所有新闻
var allNews = await FetchJuyaNewsAsync();
if (!_isAttached)
{
return;
}
// 缓存新闻数据
foreach (var news in allNews)
{
_cachedNews[news.Date.Date] = news;
}
// 加载最近几天的新闻
var today = DateTime.Today;
var datesToLoad = Enumerable.Range(0, InitialLoadDays)
.Select(i => today.AddDays(-i))
.Where(d => _cachedNews.ContainsKey(d))
.OrderByDescending(d => d)
.ToList();
await Dispatcher.UIThread.InvokeAsync(() =>
{
if (!_isAttached) return;
NewsStackPanel.Children.Clear();
_dailyViews.Clear();
_loadedDates.Clear();
foreach (var date in datesToLoad)
{
AddDailyNewsToView(_cachedNews[date]);
_loadedDates.Add(date);
}
if (_loadedDates.Any())
{
_earliestLoadedDate = _loadedDates.Min();
}
LoadingTextBlock.IsVisible = false;
StatusTextBlock.IsVisible = false;
UpdateAdaptiveLayout();
});
}
catch
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
if (!_isAttached) return;
StatusTextBlock.Text = "加载失败";
StatusTextBlock.IsVisible = true;
LoadingTextBlock.IsVisible = false;
});
}
finally
{
_isLoading = false;
}
}
private async Task<List<JuyaDailyNews>> FetchJuyaNewsAsync()
{
var result = new List<JuyaDailyNews>();
try
{
// 使用字节数组获取内容,确保正确解码 UTF-8
var response = await HttpClient.GetByteArrayAsync(RssUrl);
var rssContent = System.Text.Encoding.UTF8.GetString(response);
var doc = XDocument.Parse(rssContent);
var contentNs = XNamespace.Get("http://purl.org/rss/1.0/modules/content/");
var items = doc.Descendants("item");
foreach (var item in items)
{
var title = item.Element("title")?.Value ?? "";
var link = item.Element("link")?.Value ?? "";
var pubDate = item.Element("pubDate")?.Value ?? "";
var contentEncoded = item.Element(contentNs + "encoded")?.Value ?? "";
// 解析日期
if (!DateTime.TryParse(pubDate, out var date))
{
date = DateTime.Today;
}
// 提取封面图URL
var coverImageUrl = ExtractCoverImageUrl(contentEncoded);
// 提取视频链接
var (bilibiliUrl, youtubeUrl) = ExtractVideoUrls(contentEncoded);
// 解析概览(简短列表)
var overviewCategories = ParseOverview(contentEncoded);
// 解析详细内容
var detailedNews = ParseDetailedNews(contentEncoded);
var news = new JuyaDailyNews(
Date: date,
Title: title,
CoverImageUrl: coverImageUrl,
IssueUrl: link,
BilibiliUrl: bilibiliUrl,
YoutubeUrl: youtubeUrl,
OverviewCategories: overviewCategories,
DetailedNews: detailedNews,
FetchedAt: DateTimeOffset.Now
);
result.Add(news);
}
}
catch
{
// 返回空列表
}
return result.OrderByDescending(n => n.Date).ToList();
}
private static string ExtractCoverImageUrl(string content)
{
if (string.IsNullOrWhiteSpace(content))
{
return "";
}
var match = Regex.Match(content, @"<img[^>]+src=[""']([^""']+)[""']", RegexOptions.IgnoreCase);
return match.Success ? match.Groups[1].Value : "";
}
private static (string bilibili, string youtube) ExtractVideoUrls(string content)
{
if (string.IsNullOrWhiteSpace(content))
{
return ("", "");
}
string bilibiliUrl = "";
string youtubeUrl = "";
var bilibiliMatch = Regex.Match(content, @"<a[^>]+href=[""'](https?://(?:www\.)?bilibili\.com/[^""']+)[""'][^>]*>", RegexOptions.IgnoreCase);
if (bilibiliMatch.Success)
{
bilibiliUrl = bilibiliMatch.Groups[1].Value;
}
var youtubeMatch = Regex.Match(content, @"<a[^>]+href=[""'](https?://(?:www\.)?(?:youtube\.com|youtu\.be)/[^""']+)[""'][^>]*>", RegexOptions.IgnoreCase);
if (youtubeMatch.Success)
{
youtubeUrl = youtubeMatch.Groups[1].Value;
}
return (bilibiliUrl, youtubeUrl);
}
private static List<JuyaOverviewCategory> ParseOverview(string content)
{
var categories = new List<JuyaOverviewCategory>();
if (string.IsNullOrWhiteSpace(content))
{
return categories;
}
var categoryIcons = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["要闻"] = "📌",
["开发生态"] = "💻",
["产品应用"] = "📱",
["产品发布"] = "🚀",
["模型发布"] = "🤖",
["行业动态"] = "📈",
["技术与洞察"] = "🔍",
["学术研究"] = "📚",
["研究"] = "🔬",
["开源"] = "🔓",
["投资"] = "💰",
["融资"] = "💵",
["商业"] = "💼",
["市场"] = "📊",
["AI绘画"] = "🎨",
["设计"] = "✏️",
["创意"] = "💡",
["前瞻与传闻"] = "🔮",
["趋势"] = "📉",
["预测"] = "🔭",
["政策"] = "📋",
["法规"] = "⚖️",
["监管"] = "🛡️",
["硬件"] = "🔧",
["芯片"] = "🖥️",
["基础设施"] = "🏗️",
["其他"] = "•",
["要点"] = "📋",
["摘要"] = "📝"
};
var overviewMatch = Regex.Match(content, @"<h2>\s*概览\s*</h2>(.*?)(?:<hr>|$)", RegexOptions.Singleline | RegexOptions.IgnoreCase);
if (!overviewMatch.Success)
{
return categories;
}
var overviewContent = overviewMatch.Groups[1].Value;
var h3Matches = Regex.Matches(overviewContent, @"<h3>([^<]+)</h3>\s*<ul>(.*?)</ul>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
foreach (Match match in h3Matches)
{
var categoryName = match.Groups[1].Value.Trim();
var listContent = match.Groups[2].Value;
var icon = categoryIcons.GetValueOrDefault(categoryName, "•");
var items = new List<JuyaOverviewItem>();
var itemMatches = Regex.Matches(listContent, @"<li>(.*?)</li>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
foreach (Match itemMatch in itemMatches)
{
var itemText = itemMatch.Groups[1].Value;
string itemTitle;
string itemUrl;
int? number = null;
var linkMatch = Regex.Match(itemText, @"<a[^>]+href=[""']([^""']+)[""'][^>]*>(.*?)</a>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
if (linkMatch.Success)
{
itemUrl = linkMatch.Groups[1].Value;
var linkText = Regex.Replace(linkMatch.Groups[2].Value, @"<[^>]+>", "").Trim();
var beforeLink = itemText.Substring(0, itemText.IndexOf("<a", StringComparison.OrdinalIgnoreCase));
itemTitle = Regex.Replace(beforeLink, @"<[^>]+>", "").Trim();
if (string.IsNullOrWhiteSpace(itemTitle))
{
itemTitle = linkText;
}
}
else
{
itemTitle = Regex.Replace(itemText, @"<[^>]+>", "").Trim();
itemUrl = "";
}
var numberMatch = Regex.Match(itemText, @"<code>\s*#(\d+)\s*</code>|#(\d+)");
if (numberMatch.Success)
{
number = int.Parse(numberMatch.Groups[1].Success ? numberMatch.Groups[1].Value : numberMatch.Groups[2].Value);
}
itemTitle = Regex.Replace(itemTitle, @"^\s*#\d+\s*", "").Trim();
itemTitle = Regex.Replace(itemTitle, @"[→↗\s]+$", "").Trim();
if (!string.IsNullOrWhiteSpace(itemTitle) && itemTitle.Length > 1)
{
items.Add(new JuyaOverviewItem(itemTitle, itemUrl, number));
}
}
if (items.Any())
{
categories.Add(new JuyaOverviewCategory(categoryName, icon, items));
}
}
return categories;
}
private static List<JuyaDetailedNewsItem> ParseDetailedNews(string content)
{
var newsItems = new List<JuyaDetailedNewsItem>();
if (string.IsNullOrWhiteSpace(content))
{
return newsItems;
}
var detailedMatch = Regex.Match(content, @"<hr>(.*)$", RegexOptions.Singleline | RegexOptions.IgnoreCase);
if (!detailedMatch.Success)
{
return newsItems;
}
var detailedContent = detailedMatch.Groups[1].Value;
var newsMatches = Regex.Matches(detailedContent, @"<h2>(.*?)</h2>(.*?)(?=<h2>|<hr>|$)", RegexOptions.Singleline | RegexOptions.IgnoreCase);
foreach (Match match in newsMatches)
{
var headerContent = match.Groups[1].Value;
var bodyContent = match.Groups[2].Value;
var numberMatch = Regex.Match(headerContent, @"<code>\s*#(\d+)\s*</code>");
if (!numberMatch.Success)
{
numberMatch = Regex.Match(headerContent, @"#(\d+)");
}
int? number = numberMatch.Success ? int.Parse(numberMatch.Groups[1].Value) : null;
string title;
var linkMatch = Regex.Match(headerContent, @"<a[^>]*>(.*?)</a>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
if (linkMatch.Success)
{
title = Regex.Replace(linkMatch.Groups[1].Value, @"<[^>]+>", "").Trim();
}
else
{
title = Regex.Replace(headerContent, @"<code>.*?</code>", "", RegexOptions.Singleline | RegexOptions.IgnoreCase);
title = Regex.Replace(title, @"<[^>]+>", "").Trim();
title = Regex.Replace(title, @"#\d+", "").Trim();
}
var bodyText = ExtractBodyText(bodyContent);
var relatedLinks = new List<string>();
var linkMatches = Regex.Matches(bodyContent, @"<a[^>]+href=[""']([^""']+)[""'][^>]*>", RegexOptions.IgnoreCase);
foreach (Match linkMatch2 in linkMatches)
{
var url = linkMatch2.Groups[1].Value;
if (!string.IsNullOrWhiteSpace(url) && !relatedLinks.Contains(url))
{
relatedLinks.Add(url);
}
}
if (!string.IsNullOrWhiteSpace(title) && !string.IsNullOrWhiteSpace(bodyText))
{
newsItems.Add(new JuyaDetailedNewsItem(title, number ?? 0, bodyText, relatedLinks));
}
}
return newsItems;
}
private static string ExtractBodyText(string htmlContent)
{
if (string.IsNullOrWhiteSpace(htmlContent))
{
return "";
}
// 提取 blockquote 内容
var blockquoteMatch = Regex.Match(htmlContent, @"<blockquote>(.*?)</blockquote>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
if (blockquoteMatch.Success)
{
var text = blockquoteMatch.Groups[1].Value;
// 移除 <p> 标签但保留内容
text = Regex.Replace(text, @"<p>(.*?)</p>", "$1\n\n", RegexOptions.Singleline | RegexOptions.IgnoreCase);
// 移除其他 HTML 标签
text = Regex.Replace(text, @"<[^>]+>", "");
// 清理多余空白
text = Regex.Replace(text, @"\n{3,}", "\n\n");
return text.Trim();
}
// 如果没有 blockquote提取所有 <p> 标签内容
var paragraphs = Regex.Matches(htmlContent, @"<p>(.*?)</p>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
if (paragraphs.Count > 0)
{
var text = string.Join("\n\n", paragraphs.Cast<Match>().Select(m =>
Regex.Replace(m.Groups[1].Value, @"<[^>]+>", "").Trim()));
return text.Trim();
}
// 最后尝试直接移除所有 HTML 标签
return Regex.Replace(htmlContent, @"<[^>]+>", "").Trim();
}
private void AddDailyNewsToView(JuyaDailyNews news)
{
var view = new DailyNewsView(news, _isNightVisual);
view.CoverImageClicked += (s, e) => TryOpenUrl(news.IssueUrl);
view.NewsItemClicked += (s, url) => TryOpenUrl(url);
NewsStackPanel.Children.Add(view);
_dailyViews.Add(view);
}
private async void OnScrollChanged(object? sender, ScrollChangedEventArgs e)
{
if (_isLoading || !_isAttached)
{
return;
}
var scrollViewer = (ScrollViewer)sender!;
var offset = scrollViewer.Offset;
var extent = scrollViewer.Extent;
var viewport = scrollViewer.Viewport;
if (offset.Y >= extent.Height - viewport.Height - 200)
{
await LoadMoreNewsAsync();
}
}
private async Task LoadMoreNewsAsync()
{
if (_isLoading || !_isAttached)
{
return;
}
var nextDates = Enumerable.Range(1, LoadMoreDays)
.Select(i => _earliestLoadedDate.AddDays(-i))
.Where(d => _cachedNews.ContainsKey(d) && !_loadedDates.Contains(d))
.ToList();
if (!nextDates.Any())
{
return;
}
_isLoading = true;
LoadingTextBlock.IsVisible = true;
try
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
if (!_isAttached) return;
foreach (var date in nextDates.OrderByDescending(d => d))
{
AddDailyNewsToView(_cachedNews[date]);
_loadedDates.Add(date);
}
_earliestLoadedDate = _loadedDates.Min();
LoadingTextBlock.IsVisible = false;
UpdateAdaptiveLayout();
});
}
finally
{
_isLoading = false;
}
}
private async void OnRefreshButtonClick(object? sender, RoutedEventArgs e)
{
e.Handled = true;
if (_isLoading)
{
return;
}
_isLoading = true;
RefreshButtonText.Text = "刷新中...";
RefreshIcon.IsEnabled = false;
try
{
var allNews = await FetchJuyaNewsAsync();
if (!_isAttached)
{
return;
}
var today = DateTime.Today;
var todayNews = allNews.FirstOrDefault(n => n.Date.Date == today);
if (todayNews != null)
{
_cachedNews[today] = todayNews;
await Dispatcher.UIThread.InvokeAsync(() =>
{
if (!_isAttached) return;
var existingIndex = _loadedDates.IndexOf(today);
if (existingIndex >= 0 && _dailyViews.Count > existingIndex)
{
var oldView = _dailyViews[existingIndex];
var insertIndex = NewsStackPanel.Children.IndexOf(oldView);
if (insertIndex >= 0)
{
NewsStackPanel.Children.RemoveAt(insertIndex);
_dailyViews.RemoveAt(existingIndex);
var newView = new DailyNewsView(todayNews, _isNightVisual);
newView.CoverImageClicked += (s, e) => TryOpenUrl(todayNews.IssueUrl);
NewsStackPanel.Children.Insert(insertIndex, newView);
_dailyViews.Insert(existingIndex, newView);
}
}
else
{
var newView = new DailyNewsView(todayNews, _isNightVisual);
newView.CoverImageClicked += (s, e) => TryOpenUrl(todayNews.IssueUrl);
NewsStackPanel.Children.Insert(0, newView);
_dailyViews.Insert(0, newView);
_loadedDates.Insert(0, today);
}
RefreshButtonText.Text = "刷新";
RefreshIcon.IsEnabled = true;
UpdateAdaptiveLayout();
});
}
else
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
RefreshButtonText.Text = "刷新";
RefreshIcon.IsEnabled = true;
});
}
}
catch
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
RefreshButtonText.Text = "刷新";
RefreshIcon.IsEnabled = true;
});
}
finally
{
_isLoading = false;
}
}
private void TryOpenUrl(string? url)
{
if (string.IsNullOrWhiteSpace(url))
{
return;
}
try
{
var startInfo = new ProcessStartInfo
{
FileName = url,
UseShellExecute = true
};
Process.Start(startInfo);
}
catch
{
// 忽略错误
}
}
private void ApplyLoadingState()
{
StatusTextBlock.Text = "加载中...";
StatusTextBlock.IsVisible = true;
}
private void UpdateAdaptiveLayout()
{
var scale = ResolveScale();
var softScale = Math.Clamp(scale, 0.80, 1.32);
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells;
var unifiedMainRectangle = ResolveUnifiedMainRectangle();
RootBorder.CornerRadius = unifiedMainRectangle;
CardBorder.CornerRadius = unifiedMainRectangle;
var horizontalPadding = Math.Clamp(16 * softScale, 10, 24);
var verticalPadding = Math.Clamp(14 * softScale, 8, 20);
CardBorder.Padding = new Thickness(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding);
var headerHeight = Math.Clamp(40 * softScale, 28, 56);
HeaderGrid.Height = headerHeight;
BrandTextBlock.FontSize = Math.Clamp(20 * softScale, 14, 26);
var avatarSize = Math.Clamp(36 * softScale, 24, 48);
AvatarBorder.Width = avatarSize;
AvatarBorder.Height = avatarSize;
AvatarBorder.CornerRadius = new CornerRadius(avatarSize / 2);
var buttonFontSize = Math.Clamp(13 * softScale, 10, 16);
RefreshButton.FontSize = buttonFontSize;
RefreshButton.Padding = new Thickness(
Math.Clamp(8 * softScale, 6, 12),
Math.Clamp(4 * softScale, 2, 6)
);
StatusTextBlock.FontSize = Math.Clamp(16 * softScale, 12, 22);
LoadingTextBlock.FontSize = Math.Clamp(14 * softScale, 11, 18);
foreach (var view in _dailyViews)
{
view.UpdateLayout(softScale, totalWidth - horizontalPadding * 2);
}
ApplyNightModeVisual();
}
private double ResolveScale()
{
var expectedWidth = _currentCellSize * BaseWidthCells;
var expectedHeight = _currentCellSize * BaseHeightCells;
if (expectedWidth <= 0 || expectedHeight <= 0)
{
return 1d;
}
var actualWidth = Bounds.Width > 1 ? Bounds.Width : expectedWidth;
var actualHeight = Bounds.Height > 1 ? Bounds.Height : expectedHeight;
var scaleX = actualWidth / expectedWidth;
var scaleY = actualHeight / expectedHeight;
return Math.Clamp(Math.Min(scaleX, scaleY), 0.72, 2.4);
}
private CornerRadius ResolveUnifiedMainRectangle() => new(ResolveUnifiedMainRadiusValue());
private static double ResolveUnifiedMainRadiusValue() =>
HostAppearanceThemeProvider.GetOrCreate().GetCurrent().CornerRadiusTokens.Lg.TopLeft;
}
// 数据模型
public sealed record JuyaDailyNews(
DateTime Date,
string Title,
string CoverImageUrl,
string IssueUrl,
string BilibiliUrl,
string YoutubeUrl,
IReadOnlyList<JuyaOverviewCategory> OverviewCategories,
IReadOnlyList<JuyaDetailedNewsItem> DetailedNews,
DateTimeOffset FetchedAt);
public sealed record JuyaOverviewCategory(
string Name,
string Icon,
IReadOnlyList<JuyaOverviewItem> Items);
public sealed record JuyaOverviewItem(
string Title,
string Url,
int? Number);
public sealed record JuyaDetailedNewsItem(
string Title,
int Number,
string BodyText,
IReadOnlyList<string> RelatedLinks);

View File

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

View File

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

View File

@@ -9,14 +9,14 @@
x:Class="LanMountainDesktop.Views.Components.MultiDayWeatherWidget">
<Border x:Name="RootBorder"
CornerRadius="28"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
Background="#6B7B8F">
<Grid>
<Border x:Name="BackgroundImageLayer" CornerRadius="28" ClipToBounds="True" />
<Border x:Name="BackgroundImageLayer" CornerRadius="{DynamicResource DesignCornerRadiusComponent}" ClipToBounds="True" />
<Border x:Name="BackgroundMotionLayer"
CornerRadius="28"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
Opacity="0.25"
RenderTransformOrigin="0.5,0.5">

View File

@@ -66,7 +66,7 @@
</UserControl.Styles>
<Border x:Name="RootBorder"
CornerRadius="30"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
BorderThickness="1"
BorderBrush="#52FFFFFF"
@@ -75,11 +75,11 @@
<Grid>
<Grid IsHitTestVisible="False">
<Border x:Name="DynamicBackgroundBase"
CornerRadius="30"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
Background="#B89E7B" />
<Border x:Name="BackdropCoverHost"
CornerRadius="30"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True">
<Image x:Name="BackdropCoverImage"
IsVisible="False"

View File

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

View File

@@ -9,7 +9,7 @@
x:Class="LanMountainDesktop.Views.Components.RecordingWidget">
<Border x:Name="RootBorder"
CornerRadius="34"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Padding="0"
ClipToBounds="True"
Background="#ECEFF3"

View File

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

View File

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

View File

@@ -7,10 +7,11 @@
d:DesignHeight="220"
x:Class="LanMountainDesktop.Views.Components.StudyDeductionReasonsWidget">
<Border x:Name="RootBorder"
Classes="surface-translucent-strong"
CornerRadius="22"
Padding="12,10"
ClipToBounds="True">
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Padding="14,12">
<Grid x:Name="ContentRootGrid"
RowDefinitions="Auto,*,Auto"
RowSpacing="8">

View File

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

View File

@@ -7,10 +7,11 @@
d:DesignHeight="220"
x:Class="LanMountainDesktop.Views.Components.StudyInterruptDensityWidget">
<Border x:Name="RootBorder"
Classes="surface-translucent-strong"
CornerRadius="22"
Padding="14,10"
ClipToBounds="True">
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Padding="14,12">
<Grid x:Name="ContentRootGrid"
RowDefinitions="Auto,*,Auto"
RowSpacing="8">

View File

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

View File

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

View File

@@ -7,10 +7,11 @@
d:DesignHeight="360"
x:Class="LanMountainDesktop.Views.Components.StudyScoreOverviewWidget">
<Border x:Name="RootBorder"
Classes="surface-translucent-strong"
CornerRadius="24"
Padding="16,14"
ClipToBounds="True">
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Padding="14,12">
<Grid x:Name="ContentRootGrid"
RowDefinitions="Auto,Auto,*,Auto"
RowSpacing="8">

View File

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

View File

@@ -7,10 +7,11 @@
d:DesignHeight="220"
x:Class="LanMountainDesktop.Views.Components.StudySessionHistoryWidget">
<Border x:Name="RootBorder"
Classes="surface-translucent-strong"
CornerRadius="22"
Padding="12,10"
ClipToBounds="True">
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Padding="14,12">
<Grid>
<Grid x:Name="ContentRootGrid"
RowDefinitions="Auto,*,Auto"

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
<UserControl xmlns="https://github.com/avaloniaui"
<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"
@@ -9,86 +9,126 @@
d:DesignHeight="480"
x:Class="LanMountainDesktop.Views.Components.WhiteboardWidget">
<Border x:Name="RootBorder"
Background="#F1F4F9"
CornerRadius="20"
ClipToBounds="True"
Padding="8">
<Grid RowDefinitions="*,Auto"
RowSpacing="8">
<Border x:Name="CanvasBorder"
Grid.Row="0"
Background="#FFFFFF"
BorderBrush="#24000000"
BorderThickness="1"
CornerRadius="14"
ClipToBounds="True">
<inking:InkCanvas x:Name="InkCanvas" />
</Border>
<Grid>
<Border x:Name="RootBorder"
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
Padding="12">
<Grid RowDefinitions="*,Auto"
RowSpacing="8">
<Border x:Name="CanvasBorder"
Grid.Row="0"
Background="#FFFFFF"
BorderBrush="#24000000"
BorderThickness="1"
CornerRadius="14"
ClipToBounds="True">
<inking:InkCanvas x:Name="InkCanvas" />
</Border>
<Border x:Name="ToolbarBorder"
Grid.Row="1"
HorizontalAlignment="Center"
Background="#E6FFFFFF"
BorderBrush="#16000000"
<Border x:Name="ToolbarBorder"
Grid.Row="1"
HorizontalAlignment="Center"
Background="#E6FFFFFF"
BorderBrush="#16000000"
BorderThickness="1"
CornerRadius="14"
Padding="8,6">
<StackPanel x:Name="ToolbarButtonsPanel"
Orientation="Horizontal"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="8">
<Button x:Name="PenButton"
Width="30"
Height="30"
Padding="0"
CornerRadius="15"
ToolTip.Tip="Pen"
Click="OnPenButtonClick">
<fi:SymbolIcon x:Name="PenIcon"
Symbol="Pen"
IconVariant="Regular"
FontSize="14" />
</Button>
<Button x:Name="EraserButton"
Width="30"
Height="30"
Padding="0"
CornerRadius="15"
ToolTip.Tip="Eraser"
Click="OnEraserButtonClick">
<fi:SymbolIcon x:Name="EraserIcon"
Symbol="EraserTool"
IconVariant="Regular"
FontSize="14" />
</Button>
<Button x:Name="ClearButton"
Width="30"
Height="30"
Padding="0"
CornerRadius="15"
ToolTip.Tip="Clear"
Click="OnClearButtonClick">
<fi:SymbolIcon x:Name="ClearIcon"
Symbol="Delete"
IconVariant="Regular"
FontSize="14" />
</Button>
<Button x:Name="ExportButton"
Width="30"
Height="30"
Padding="0"
CornerRadius="15"
ToolTip.Tip="Export SVG"
Click="OnExportButtonClick">
<fi:SymbolIcon x:Name="ExportIcon"
Symbol="ArrowExport"
IconVariant="Regular"
FontSize="14" />
</Button>
</StackPanel>
</Border>
</Grid>
</Border>
<Popup x:Name="ColorPickerPopup"
Placement="Top"
PlacementTarget="{Binding #PenButton}"
IsLightDismissEnabled="True"
WindowManagerAddShadowHint="False">
<Border Background="{DynamicResource AdaptiveSurfaceBaseBrush}"
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
BorderThickness="1"
CornerRadius="14"
Padding="8,6">
<StackPanel x:Name="ToolbarButtonsPanel"
Orientation="Horizontal"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="8">
<Button x:Name="PenButton"
Width="30"
Height="30"
Padding="0"
CornerRadius="15"
ToolTip.Tip="Pen"
Click="OnPenButtonClick">
<fi:SymbolIcon x:Name="PenIcon"
Symbol="Pen"
IconVariant="Regular"
FontSize="14" />
</Button>
<Button x:Name="EraserButton"
Width="30"
Height="30"
Padding="0"
CornerRadius="15"
ToolTip.Tip="Eraser"
Click="OnEraserButtonClick">
<fi:SymbolIcon x:Name="EraserIcon"
Symbol="EraserTool"
IconVariant="Regular"
FontSize="14" />
</Button>
<Button x:Name="ClearButton"
Width="30"
Height="30"
Padding="0"
CornerRadius="15"
ToolTip.Tip="Clear"
Click="OnClearButtonClick">
<fi:SymbolIcon x:Name="ClearIcon"
Symbol="Delete"
IconVariant="Regular"
FontSize="14" />
</Button>
<Button x:Name="ExportButton"
Width="30"
Height="30"
Padding="0"
CornerRadius="15"
ToolTip.Tip="Export SVG"
Click="OnExportButtonClick">
<fi:SymbolIcon x:Name="ExportIcon"
Symbol="ArrowExport"
IconVariant="Regular"
FontSize="14" />
</Button>
CornerRadius="8"
Padding="12">
<StackPanel Spacing="12">
<ColorView x:Name="InkColorPicker"
IsAlphaEnabled="False"
IsColorSpectrumVisible="True"
IsColorPaletteVisible="True"
IsHexInputVisible="True"
ColorChanged="OnColorPickerColorChanged" />
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<TextBlock Grid.Column="0"
Text="粗细"
VerticalAlignment="Center"
FontSize="12" />
<Slider x:Name="InkThicknessSlider"
Grid.Column="1"
Minimum="1"
Maximum="8"
Value="2.5"
SmallChange="0.5"
LargeChange="1"
ValueChanged="OnInkThicknessSliderValueChanged" />
</Grid>
</StackPanel>
</Border>
</Grid>
</Border>
</Popup>
</Grid>
</UserControl>

View File

@@ -6,6 +6,7 @@ using System.Reflection;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Platform.Storage;
@@ -38,7 +39,8 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
private double _currentCellSize = 48;
private WhiteboardToolMode _toolMode = WhiteboardToolMode.Pen;
private bool? _isNightModeApplied;
private SKColor _currentInkColor = SKColors.Black;
private SKColor _selectedInkColor = SKColors.Black;
private float _selectedInkThickness = 2.5f;
private string _componentId = BuiltInComponentIds.DesktopWhiteboard;
private string _placementId = string.Empty;
private int _noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays;
@@ -66,9 +68,27 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
ApplyCellSize(_currentCellSize);
RefreshFromSettings();
ApplyThemeVisual(force: true);
InitializeColorPicker();
SetToolMode(WhiteboardToolMode.Pen);
}
private void InitializeColorPicker()
{
if (InkColorPicker is not null)
{
InkColorPicker.Color = new Color(
_selectedInkColor.Alpha,
_selectedInkColor.Red,
_selectedInkColor.Green,
_selectedInkColor.Blue);
}
if (InkThicknessSlider is not null)
{
InkThicknessSlider.Value = _selectedInkThickness;
}
}
public int NoteRetentionDays => _noteRetentionDays;
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
@@ -97,7 +117,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
InkCanvas.EditingMode = InkCanvasEditingMode.Ink;
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
settings.IgnorePressure = true;
settings.InkThickness = 2.5f;
settings.InkThickness = _selectedInkThickness;
settings.EraserSize = new Size(20, 20);
settings.IsBitmapCacheEnabled = true;
settings.MaxBitmapCacheSize = 2048;
@@ -135,7 +155,6 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
}
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
settings.InkThickness = (float)Math.Clamp(_currentCellSize * 0.06, 2.0, 6.0);
var eraserSize = Math.Clamp(_currentCellSize * 0.42, 12, 44);
settings.EraserSize = new Size(eraserSize, eraserSize);
}
@@ -149,7 +168,6 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
}
_isNightModeApplied = isNightMode;
_currentInkColor = isNightMode ? SKColors.White : SKColors.Black;
RootBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#FF181B22") : Color.Parse("#FFF1F4F9"));
CanvasBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#FF000000") : Color.Parse("#FFFFFFFF"));
@@ -157,8 +175,6 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
ToolbarBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#1AFFFFFF") : Color.Parse("#E6FFFFFF"));
ToolbarBorder.BorderBrush = new SolidColorBrush(isNightMode ? Color.Parse("#26FFFFFF") : Color.Parse("#16000000"));
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _currentInkColor;
RecolorAllStrokes(_currentInkColor);
RefreshToolButtonVisuals();
}
@@ -204,6 +220,30 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
}
}
public void ForceSaveNote()
{
if (_disposed || !HasValidPersistenceContext())
{
return;
}
if (!_noteDirty)
{
return;
}
_noteDirty = false;
_noteSaveTimer.Stop();
var noteSnapshot = BuildNoteSnapshot();
try
{
_notePersistenceService.SaveNote(_componentId, _placementId, noteSnapshot, _noteRetentionDays);
}
catch
{
}
}
public void Dispose()
{
if (_disposed)
@@ -300,12 +340,31 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
if (mode == WhiteboardToolMode.Pen)
{
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _currentInkColor;
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _selectedInkColor;
}
RefreshToolButtonVisuals();
}
private void SetInkColor(SKColor color)
{
_selectedInkColor = color;
if (_toolMode == WhiteboardToolMode.Pen)
{
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _selectedInkColor;
}
RefreshToolButtonVisuals();
}
private void SetInkThickness(float thickness)
{
_selectedInkThickness = Math.Clamp(thickness, 1.0f, 8.0f);
if (_toolMode == WhiteboardToolMode.Pen)
{
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkThickness = _selectedInkThickness;
}
}
private void RefreshToolButtonVisuals()
{
var isNightMode = _isNightModeApplied ?? ResolveIsNightMode();
@@ -350,7 +409,32 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
private void OnPenButtonClick(object? sender, RoutedEventArgs e)
{
SetToolMode(WhiteboardToolMode.Pen);
if (_toolMode == WhiteboardToolMode.Pen && ColorPickerPopup is not null)
{
if (ColorPickerPopup.IsOpen)
{
ColorPickerPopup.Close();
}
else
{
ColorPickerPopup.Open();
}
}
else
{
SetToolMode(WhiteboardToolMode.Pen);
}
}
private void OnColorPickerColorChanged(object? sender, ColorChangedEventArgs e)
{
var color = e.NewColor;
SetInkColor(new SKColor(color.R, color.G, color.B, color.A));
}
private void OnInkThicknessSliderValueChanged(object? sender, RangeBaseValueChangedEventArgs e)
{
SetInkThickness((float)e.NewValue);
}
private void OnEraserButtonClick(object? sender, RoutedEventArgs e)
@@ -509,14 +593,13 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
_noteDirty = false;
_noteSaveTimer.Stop();
var noteSnapshot = BuildNoteSnapshot();
var componentId = _componentId;
var placementId = _placementId;
var retentionDays = _noteRetentionDays;
_ = Task.Run(() => _notePersistenceService.SaveNote(
componentId,
placementId,
noteSnapshot,
retentionDays));
try
{
_notePersistenceService.SaveNote(_componentId, _placementId, noteSnapshot, _noteRetentionDays);
}
catch
{
}
}
private async void SchedulePersistedNoteLoad()
@@ -553,7 +636,6 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
{
ClearAllStrokes();
ApplyNoteSnapshot(noteSnapshot);
RecolorAllStrokes(_currentInkColor);
}
finally
{

View File

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

View File

@@ -0,0 +1,97 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
d:DesignWidth="96"
d:DesignHeight="96"
x:Class="LanMountainDesktop.Views.Components.ZhiJiaoHubWidget">
<Border x:Name="RootBorder"
CornerRadius="{DynamicResource 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>

View File

@@ -0,0 +1,847 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.Views.Components;
public partial class ZhiJiaoHubWidget : UserControl,
IDesktopComponentWidget,
IRecommendationInfoAwareComponentWidget,
IComponentSettingsContextAware,
IComponentPlacementContextAware
{
private const double BaseCellSize = 48d;
private const double SwipeThreshold = 50;
private readonly DispatcherTimer _refreshTimer = new();
private IRecommendationInfoService _recommendationService = new RecommendationDataService();
private IComponentSettingsAccessor? _componentSettingsAccessor;
private ISettingsService _appSettingsService = HostSettingsFacadeProvider.GetOrCreate().Settings;
private CancellationTokenSource? _refreshCts;
private CancellationTokenSource? _backgroundDownloadCts;
private string _source = ZhiJiaoHubSources.ClassIsland;
private string _mirrorSource = ZhiJiaoHubMirrorSources.Direct;
private string _componentId = BuiltInComponentIds.DesktopZhiJiaoHub;
private string _placementId = string.Empty;
private double _currentCellSize = BaseCellSize;
private bool _isAttached;
private bool _isInitializing;
private bool _autoRefreshEnabled = true;
private int _pendingImageIndex = 0;
private IReadOnlyList<ZhiJiaoHubHybridImageItem> _images = [];
private int _currentImageIndex = 0;
private readonly Dictionary<int, Bitmap> _imageCache = new();
private readonly object _cacheLock = new();
private const int MaxCacheSize = 5;
private bool _isDragging;
private Point _dragStartPoint;
private double _dragOffset;
private int _lastSwipeDirection = 0;
private bool _isInErrorState;
private static readonly HttpClient ImageHttpClient = new(new HttpClientHandler
{
AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate
})
{
Timeout = TimeSpan.FromSeconds(30)
};
public ZhiJiaoHubWidget()
{
InitializeComponent();
if (Design.IsDesignMode)
{
ApplyCellSize(_currentCellSize);
return;
}
_refreshTimer.Tick += OnRefreshTimerTick;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
ApplyCellSize(_currentCellSize);
ApplyLoadingState();
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = true;
LoadSettings();
_ = InitializeAsync();
UpdateTimers();
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = false;
_refreshTimer.Stop();
_refreshCts?.Cancel();
_backgroundDownloadCts?.Cancel();
lock (_cacheLock)
{
foreach (var bitmap in _imageCache.Values)
{
bitmap.Dispose();
}
_imageCache.Clear();
}
}
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
var scale = _currentCellSize / BaseCellSize;
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(12 * scale, 4, 24));
var fontSize = Math.Clamp(11 * scale, 9, 18);
ImageNameTextBlock.FontSize = fontSize;
LoadingTextBlock.FontSize = Math.Clamp(12 * scale, 10, 16);
ErrorTextBlock.FontSize = Math.Clamp(10 * scale, 8, 14);
GradientOverlay.Height = Math.Clamp(60 * scale, 30, 100);
ImageNameTextBlock.Margin = new Thickness(
Math.Clamp(10 * scale, 5, 20),
0,
Math.Clamp(10 * scale, 5, 20),
Math.Clamp(8 * scale, 4, 16));
IndicatorBorder.Margin = new Thickness(0, 0, Math.Clamp(6 * scale, 3, 12), 0);
}
public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService)
{
}
public void SetComponentSettingsContext(DesktopComponentSettingsContext context)
{
_componentId = context.ComponentId;
_placementId = context.PlacementId ?? string.Empty;
_componentSettingsAccessor = context.ComponentSettingsAccessor;
LoadSettings();
if (_isAttached)
{
_ = InitializeAsync();
}
}
public void SetComponentPlacementContext(string componentId, string? placementId)
{
_componentId = componentId;
_placementId = placementId ?? string.Empty;
}
public void RefreshFromSettings()
{
LoadSettings();
UpdateTimers();
if (_isAttached)
{
_ = InitializeAsync();
}
}
private void LoadSettings()
{
try
{
var snapshot = _componentSettingsAccessor?.LoadSnapshot<ComponentSettingsSnapshot>();
if (snapshot is not null)
{
_source = ZhiJiaoHubSources.Normalize(snapshot.ZhiJiaoHubSource);
_mirrorSource = ZhiJiaoHubMirrorSources.Normalize(snapshot.ZhiJiaoHubMirrorSource);
_autoRefreshEnabled = snapshot.ZhiJiaoHubAutoRefreshEnabled;
_pendingImageIndex = snapshot.ZhiJiaoHubCurrentImageIndex;
var intervalMinutes = Math.Clamp(snapshot.ZhiJiaoHubAutoRefreshIntervalMinutes, 5, 1440);
_refreshTimer.Interval = TimeSpan.FromMinutes(intervalMinutes);
}
}
catch
{
}
}
private void SaveCurrentImageIndex()
{
try
{
var snapshot = _componentSettingsAccessor?.LoadSnapshot<ComponentSettingsSnapshot>()
?? new ComponentSettingsSnapshot();
snapshot.ZhiJiaoHubCurrentImageIndex = _currentImageIndex;
_componentSettingsAccessor?.SaveSnapshot(snapshot, [nameof(ComponentSettingsSnapshot.ZhiJiaoHubCurrentImageIndex)]);
}
catch
{
}
}
private void UpdateTimers()
{
if (_autoRefreshEnabled)
{
_refreshTimer.Start();
}
else
{
_refreshTimer.Stop();
}
}
private async Task InitializeAsync()
{
if (_isInitializing)
{
return;
}
_isInitializing = true;
_refreshCts?.Cancel();
_backgroundDownloadCts?.Cancel();
_refreshCts = new CancellationTokenSource();
var ct = _refreshCts.Token;
try
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
LoadingTextBlock.Text = "加载中...";
ApplyLoadingState();
});
var result = await _recommendationService.GetZhiJiaoHubHybridImagesAsync(_source, _mirrorSource, ct);
if (ct.IsCancellationRequested)
{
return;
}
if (!result.Success || result.Data == null || result.Data.Images.Count == 0)
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
ApplyErrorState(result.ErrorMessage ?? "无法获取图片列表");
});
return;
}
_images = result.Data.Images;
_currentImageIndex = Math.Clamp(_pendingImageIndex, 0, Math.Max(0, _images.Count - 1));
_pendingImageIndex = 0;
await Dispatcher.UIThread.InvokeAsync(() =>
{
UpdateIndicators();
});
await LoadAndDisplayCurrentImageAsync();
if (result.Data.CachedCount < result.Data.TotalCount)
{
_ = StartBackgroundDownloadAsync();
}
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
ApplyErrorState($"初始化失败: {ex.Message}");
});
}
finally
{
_isInitializing = false;
}
}
private async Task StartBackgroundDownloadAsync()
{
_backgroundDownloadCts?.Cancel();
_backgroundDownloadCts = new CancellationTokenSource();
var ct = _backgroundDownloadCts.Token;
await _recommendationService.StartBackgroundDownloadAsync(
_source,
_images,
_mirrorSource,
(downloaded, total, name) =>
{
if (!ct.IsCancellationRequested)
{
Dispatcher.UIThread.Post(() =>
{
LoadingTextBlock.Text = $"后台缓存 {downloaded}/{total}";
});
}
},
ct);
}
private async Task LoadAndDisplayCurrentImageAsync(int direction = 0)
{
if (_images.Count == 0)
{
ApplyErrorState("暂无图片");
return;
}
var imageItem = _images[_currentImageIndex];
try
{
Bitmap? cachedBitmap = null;
lock (_cacheLock)
{
_imageCache.TryGetValue(_currentImageIndex, out cachedBitmap);
}
if (cachedBitmap != null)
{
CurrentImage.Source = cachedBitmap;
ImageNameTextBlock.Text = imageItem.Name;
ApplyContentVisibleState();
_ = Task.Run(async () => await PreloadAdjacentImagesAsync(direction));
return;
}
if (imageItem.IsCached && !string.IsNullOrEmpty(imageItem.LocalPath) && File.Exists(imageItem.LocalPath))
{
await LoadFromLocalPathAsync(imageItem.LocalPath, imageItem.Name);
_ = Task.Run(async () => await PreloadAdjacentImagesAsync(direction));
return;
}
await LoadFromRemoteUrlAsync(imageItem, direction);
}
catch (Exception ex)
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
ApplyErrorState($"图片加载失败: {ex.Message}");
});
}
}
private async Task LoadFromLocalPathAsync(string localPath, string name)
{
await using var fileStream = File.OpenRead(localPath);
var bitmap = new Bitmap(fileStream);
lock (_cacheLock)
{
if (_imageCache.Count >= MaxCacheSize)
{
CleanupFarthestCacheUnsafe();
}
_imageCache[_currentImageIndex] = bitmap;
}
await Dispatcher.UIThread.InvokeAsync(() =>
{
CurrentImage.Source = bitmap;
ImageNameTextBlock.Text = name;
ApplyContentVisibleState();
});
}
private async Task LoadFromRemoteUrlAsync(ZhiJiaoHubHybridImageItem imageItem, int direction)
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
LoadingTextBlock.Text = "加载图片...";
ApplyLoadingState();
});
var imageUrl = imageItem.RemoteUrl;
if (string.Equals(_mirrorSource, ZhiJiaoHubMirrorSources.GhProxy, StringComparison.OrdinalIgnoreCase))
{
imageUrl = ZhiJiaoHubMirrorSources.GhProxyBaseUrl.TrimEnd('/') + "/" + imageItem.RemoteUrl;
}
using var response = await ImageHttpClient.GetAsync(imageUrl);
response.EnsureSuccessStatusCode();
var imageStream = await response.Content.ReadAsStreamAsync();
var bitmap = new Bitmap(imageStream);
lock (_cacheLock)
{
if (_imageCache.Count >= MaxCacheSize)
{
CleanupFarthestCacheUnsafe();
}
_imageCache[_currentImageIndex] = bitmap;
}
await Dispatcher.UIThread.InvokeAsync(() =>
{
CurrentImage.Source = bitmap;
ImageNameTextBlock.Text = imageItem.Name;
ApplyContentVisibleState();
});
_ = CacheImageInBackgroundAsync(imageItem);
_ = Task.Run(async () => await PreloadAdjacentImagesAsync(direction));
}
private async Task CacheImageInBackgroundAsync(ZhiJiaoHubHybridImageItem imageItem)
{
if (imageItem.IsCached)
{
return;
}
try
{
var image = new ZhiJiaoHubImageItem(imageItem.Name, imageItem.RemoteUrl, imageItem.Index);
var localPath = await _recommendationService.DownloadAndCacheImageAsync(_source, image, _mirrorSource);
if (!string.IsNullOrEmpty(localPath))
{
var index = imageItem.Index;
if (index >= 0 && index < _images.Count)
{
var updatedImage = _images[index] with
{
LocalPath = localPath,
IsCached = true
};
var newImages = _images.ToList();
newImages[index] = updatedImage;
_images = newImages;
}
}
}
catch
{
}
}
private async Task PreloadAdjacentImagesAsync(int direction = 0)
{
if (_images.Count <= 1)
{
return;
}
var indicesToPreload = new List<int>();
var currentIndex = _currentImageIndex;
lock (_cacheLock)
{
if (direction <= 0)
{
var nextIndex = (currentIndex + 1) % _images.Count;
if (!_imageCache.ContainsKey(nextIndex))
{
indicesToPreload.Add(nextIndex);
}
var nextNextIndex = (currentIndex + 2) % _images.Count;
if (!_imageCache.ContainsKey(nextNextIndex) && indicesToPreload.Count < 3)
{
indicesToPreload.Add(nextNextIndex);
}
}
if (direction >= 0)
{
var prevIndex = (currentIndex - 1 + _images.Count) % _images.Count;
if (!_imageCache.ContainsKey(prevIndex))
{
indicesToPreload.Add(prevIndex);
}
var prevPrevIndex = (currentIndex - 2 + _images.Count) % _images.Count;
if (!_imageCache.ContainsKey(prevPrevIndex) && indicesToPreload.Count < 3)
{
indicesToPreload.Add(prevPrevIndex);
}
}
}
if (indicesToPreload.Count == 0)
{
return;
}
var preloadTasks = indicesToPreload.Select(async index =>
{
try
{
lock (_cacheLock)
{
if (_imageCache.ContainsKey(index))
{
return;
}
if (_imageCache.Count >= MaxCacheSize)
{
CleanupFarthestCacheUnsafe();
}
}
var imageItem = _images[index];
Bitmap? bitmap = null;
if (imageItem.IsCached && !string.IsNullOrEmpty(imageItem.LocalPath) && File.Exists(imageItem.LocalPath))
{
await using var fileStream = File.OpenRead(imageItem.LocalPath);
bitmap = new Bitmap(fileStream);
}
else
{
var imageUrl = imageItem.RemoteUrl;
if (string.Equals(_mirrorSource, ZhiJiaoHubMirrorSources.GhProxy, StringComparison.OrdinalIgnoreCase))
{
imageUrl = ZhiJiaoHubMirrorSources.GhProxyBaseUrl.TrimEnd('/') + "/" + imageItem.RemoteUrl;
}
using var response = await ImageHttpClient.GetAsync(imageUrl);
response.EnsureSuccessStatusCode();
var imageStream = await response.Content.ReadAsStreamAsync();
bitmap = new Bitmap(imageStream);
_ = CacheImageInBackgroundAsync(imageItem);
}
if (bitmap != null)
{
lock (_cacheLock)
{
if (!_imageCache.ContainsKey(index))
{
_imageCache[index] = bitmap;
}
else
{
bitmap.Dispose();
}
}
}
}
catch
{
}
}).ToList();
await Task.WhenAll(preloadTasks);
}
private void CleanupFarthestCacheUnsafe()
{
if (_imageCache.Count == 0) return;
var farthestKey = -1;
var maxDistance = -1;
var currentIndex = _currentImageIndex;
var imageCount = _images.Count;
foreach (var key in _imageCache.Keys)
{
if (key == currentIndex) continue;
var forwardDistance = (key - currentIndex + imageCount) % imageCount;
var backwardDistance = (currentIndex - key + imageCount) % imageCount;
var distance = Math.Min(forwardDistance, backwardDistance);
if (distance > maxDistance)
{
maxDistance = distance;
farthestKey = key;
}
}
if (farthestKey >= 0)
{
if (_imageCache.TryGetValue(farthestKey, out var bitmap))
{
bitmap.Dispose();
}
_imageCache.Remove(farthestKey);
}
}
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (_isInErrorState)
{
_ = RefreshCurrentComponentAsync();
return;
}
if (_images.Count <= 1)
{
return;
}
_isDragging = true;
_dragStartPoint = e.GetPosition(this);
_dragOffset = 0;
}
private void OnPointerMoved(object? sender, PointerEventArgs e)
{
if (!_isDragging || _images.Count <= 1)
{
return;
}
var currentPoint = e.GetPosition(this);
_dragOffset = currentPoint.Y - _dragStartPoint.Y;
}
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e)
{
if (!_isDragging)
{
return;
}
_isDragging = false;
if (Math.Abs(_dragOffset) > SwipeThreshold)
{
if (_dragOffset > 0)
{
_lastSwipeDirection = 1;
SwitchToPrevImage();
}
else
{
_lastSwipeDirection = -1;
SwitchToNextImage();
}
}
}
private void OnPointerWheelChanged(object? sender, PointerWheelEventArgs e)
{
if (_images.Count <= 1)
{
return;
}
if (e.Delta.Y > 0)
{
_lastSwipeDirection = 1;
SwitchToPrevImage();
}
else if (e.Delta.Y < 0)
{
_lastSwipeDirection = -1;
SwitchToNextImage();
}
e.Handled = true;
}
private void SwitchToPrevImage()
{
if (_images.Count <= 1)
{
return;
}
_currentImageIndex = (_currentImageIndex - 1 + _images.Count) % _images.Count;
SaveCurrentImageIndex();
UpdateIndicators();
if (TryDisplayCachedImage(_currentImageIndex))
{
_ = Task.Run(async () => await PreloadAdjacentImagesAsync(_lastSwipeDirection));
return;
}
_ = LoadAndDisplayCurrentImageAsync(_lastSwipeDirection);
}
private void SwitchToNextImage()
{
if (_images.Count <= 1)
{
return;
}
_currentImageIndex = (_currentImageIndex + 1) % _images.Count;
SaveCurrentImageIndex();
UpdateIndicators();
if (TryDisplayCachedImage(_currentImageIndex))
{
_ = Task.Run(async () => await PreloadAdjacentImagesAsync(_lastSwipeDirection));
return;
}
_ = LoadAndDisplayCurrentImageAsync(_lastSwipeDirection);
}
private bool TryDisplayCachedImage(int index)
{
if (_images.Count == 0 || index < 0 || index >= _images.Count)
{
return false;
}
Bitmap? cachedBitmap = null;
lock (_cacheLock)
{
_imageCache.TryGetValue(index, out cachedBitmap);
}
if (cachedBitmap != null)
{
var imageItem = _images[index];
CurrentImage.Source = cachedBitmap;
ImageNameTextBlock.Text = imageItem.Name;
ApplyContentVisibleState();
return true;
}
return false;
}
private void ApplyLoadingState()
{
_isInErrorState = false;
CurrentImage.IsVisible = false;
ImageNameTextBlock.IsVisible = false;
GradientOverlay.IsVisible = false;
ErrorTextBlock.IsVisible = false;
LoadingPanel.IsVisible = true;
}
private void ApplyContentVisibleState()
{
_isInErrorState = false;
LoadingPanel.IsVisible = false;
ErrorTextBlock.IsVisible = false;
CurrentImage.IsVisible = true;
ImageNameTextBlock.IsVisible = true;
GradientOverlay.IsVisible = true;
}
private void ApplyErrorState(string message)
{
_isInErrorState = true;
CurrentImage.IsVisible = false;
ImageNameTextBlock.IsVisible = false;
GradientOverlay.IsVisible = false;
LoadingPanel.IsVisible = false;
ErrorTextBlock.Text = message + "\n点击任意区域重试";
ErrorTextBlock.IsVisible = true;
}
private void UpdateIndicators()
{
IndicatorPanel.Children.Clear();
if (_images.Count <= 1)
{
return;
}
var maxIndicators = Math.Min(_images.Count, 7);
var startIndex = Math.Max(0, _currentImageIndex - maxIndicators / 2);
var endIndex = Math.Min(_images.Count, startIndex + maxIndicators);
if (endIndex - startIndex < maxIndicators)
{
startIndex = Math.Max(0, endIndex - maxIndicators);
}
for (var i = startIndex; i < endIndex; i++)
{
var dot = new Border
{
Width = 6,
Height = 6,
CornerRadius = new CornerRadius(3),
Margin = new Thickness(2, 0),
Background = i == _currentImageIndex
? Brushes.White
: new SolidColorBrush(Color.FromArgb(128, 255, 255, 255))
};
IndicatorPanel.Children.Add(dot);
}
}
private void OnRefreshTimerTick(object? sender, EventArgs e)
{
if (_isInitializing)
{
return;
}
_ = InitializeAsync();
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
var cellSize = Math.Min(e.NewSize.Width, e.NewSize.Height) / 2;
ApplyCellSize(cellSize);
}
private async Task RefreshCurrentComponentAsync()
{
if (_isInitializing)
{
return;
}
_refreshCts?.Cancel();
_backgroundDownloadCts?.Cancel();
_refreshCts = new CancellationTokenSource();
lock (_cacheLock)
{
foreach (var bitmap in _imageCache.Values)
{
bitmap.Dispose();
}
_imageCache.Clear();
}
_images = [];
_currentImageIndex = 0;
await InitializeAsync();
}
}

View File

@@ -1481,6 +1481,15 @@ public partial class MainWindow
new ComponentScaleRule(WidthUnit: 1, HeightUnit: 1, MinScale: 4));
}
if (string.Equals(componentId, BuiltInComponentIds.DesktopZhiJiaoHub, StringComparison.OrdinalIgnoreCase))
{
// ZhiJiao Hub allows free resize but starts at 2x2
// Allow any aspect ratio, minimum 2x2
var width = Math.Max(2, span.WidthCells);
var height = Math.Max(2, span.HeightCells);
return (width, height);
}
return span;
}
@@ -2602,7 +2611,7 @@ public partial class MainWindow
if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase))
{
return Symbol.Apps;
return Symbol.Hourglass;
}
if (string.Equals(categoryId, "File", StringComparison.OrdinalIgnoreCase))
@@ -3276,4 +3285,19 @@ public partial class MainWindow
_isComponentLibraryComponentGestureActive = false;
ApplyComponentLibraryComponentOffset();
}
internal void SaveAllWhiteboardNotes()
{
foreach (var pageGrid in _desktopPageComponentGrids.Values)
{
foreach (var host in pageGrid.Children.OfType<Border>())
{
var contentHost = TryGetContentHost(host);
if (contentHost?.Child is WhiteboardWidget whiteboard)
{
whiteboard.ForceSaveNote();
}
}
}
}
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
@@ -552,7 +552,6 @@ public partial class MainWindow
{
if (node is Control control)
{
// Avoid swiping pages when interacting with desktop components/widgets.
if (control.Classes.Contains("desktop-component") ||
control.Classes.Contains("desktop-component-host"))
{
@@ -560,7 +559,31 @@ public partial class MainWindow
}
}
if (node is Button or TextBox or ComboBox or ListBoxItem or Slider or ToggleSwitch)
if (node is Button button && IsLauncherTileButton(button))
{
continue;
}
if (node is TextBox or ComboBox or ListBoxItem or Slider or ToggleSwitch)
{
return true;
}
}
return false;
}
private static bool IsLauncherTileButton(Button? button)
{
if (button is null)
{
return false;
}
foreach (var node in button.GetSelfAndVisualAncestors())
{
if (node is WrapPanel panel &&
(panel.Name == "LauncherRootTilePanel" || panel.Name == "LauncherFolderTilePanel"))
{
return true;
}
@@ -611,7 +634,17 @@ public partial class MainWindow
private static bool IsDesktopSwipeBlockingNode(object node)
{
if (node is Button or TextBox or ComboBox or Slider or ToggleSwitch or ListBoxItem or ScrollViewer)
if (node is ScrollViewer scrollViewer && IsLauncherScrollViewer(scrollViewer))
{
return false;
}
if (node is Button button && IsLauncherTileButton(button))
{
return false;
}
if (node is TextBox or ComboBox or Slider or ToggleSwitch or ListBoxItem)
{
return true;
}
@@ -625,13 +658,23 @@ public partial class MainWindow
}
var typeName = node.GetType().Name;
return typeName.Contains("Button", StringComparison.OrdinalIgnoreCase) ||
typeName.Contains("WebView", StringComparison.OrdinalIgnoreCase) ||
return typeName.Contains("WebView", StringComparison.OrdinalIgnoreCase) ||
typeName.Contains("ScrollBar", StringComparison.OrdinalIgnoreCase) ||
typeName.Contains("NumericUpDown", StringComparison.OrdinalIgnoreCase) ||
typeName.Contains("TextPresenter", StringComparison.OrdinalIgnoreCase);
}
private static bool IsLauncherScrollViewer(ScrollViewer? scrollViewer)
{
if (scrollViewer is null)
{
return false;
}
return scrollViewer.Name == "LauncherRootScrollViewer" ||
scrollViewer.Name == "LauncherFolderScrollViewer";
}
private bool TryGetPointerPositionInDesktopViewport(PointerEventArgs e, out Point point)
{
point = default;

View File

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

View File

@@ -500,6 +500,7 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
var wasVisible = IsVisible;
var windowState = WindowState.ToString();
SaveAllWhiteboardNotes();
PersistSettings();
_componentEditorWindowService.Close();
if (_detachedComponentLibraryWindow is not null)

View File

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

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