Compare commits

...

10 Commits

Author SHA1 Message Date
lincube
cb86ca10e7 0.6.8
小黑板数据持久化。
2026-03-19 16:27:16 +08:00
lincube
b3a74aa072 0.6.7.2
文档组件优化
2026-03-19 08:39:25 +08:00
lincube
b436bfa884 0.6.7.1
多平台适配
2026-03-19 02:02:07 +08:00
lincube
081abeb688 0 6 7
可移动存储组件
2026-03-19 00:17:21 +08:00
lincube
594a62132f 0.6.6
滑动优化
2026-03-18 20:09:00 +08:00
lincube
15e589aedd 0.6.5
流畅性优化测试
2026-03-17 18:36:10 +08:00
lincube
ac4617f5cf 0.6.4 2026-03-17 14:57:41 +08:00
lincube
0645598753 0.6.3.1
最近文件查看优化,课程表组件优化,插件安装优化。
2026-03-17 12:30:30 +08:00
lincube
dadd132b4f 0.6.3
优化了文本框焦点,优化了更新体验,优化了遥测,披露了收集的数据。
2026-03-17 01:01:48 +08:00
lincube
298defb829 0.6.2
删除了视频壁纸功能,为纯色背景添加了自定义颜色选项。
2026-03-16 21:08:54 +08:00
85 changed files with 6158 additions and 745 deletions

View File

@@ -0,0 +1,16 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "LanMountainDesktop"
[setup]
script = ""
[[actions]]
name = "运行"
icon = "run"
command = "dotnet run --project 'C:\\Users\\USER693091\\Documents\\GitHub\\LanMountainDesktop\\LanMountainDesktop\\LanMountainDesktop.csproj"
[[actions]]
name = "构建"
icon = "tool"
command = "dotnet build 'C:\\Users\\USER693091\\Documents\\GitHub\\LanMountainDesktop\\LanMountainDesktop.slnx"

View File

@@ -0,0 +1,578 @@
# 移除视频壁纸功能 - 技术设计文档
## 1. 概述
### 1.1 设计目标
本设计文档描述如何从 LanMountainDesktop 项目中完全移除视频壁纸功能,包括:
- 移除 LibVLC 相关依赖
- 清理主窗口中的视频壁纸代码
- 简化壁纸设置页面
- 清理本地化资源
### 1.2 技术约束
- 保持现有图片壁纸和纯色壁纸功能完整
- 确保应用构建和运行正常
- 不引入新的外部依赖
---
## 2. 架构变更
### 2.1 变更概览图
```
┌─────────────────────────────────────────────────────────────────┐
│ 变更前架构 │
├─────────────────────────────────────────────────────────────────┤
│ MainWindow │
│ ├── DesktopWallpaperLayer (背景层) │
│ │ ├── DesktopWallpaperImageLayer (图片层) │
│ │ ├── DesktopVideoWallpaperImage (视频海报层) │
│ │ └── DesktopVideoWallpaperView (VLC视频播放层) │
│ ├── _libVlc, _videoWallpaperPlayer, _videoWallpaperMedia │
│ └── StartVideoWallpaper(), StopVideoWallpaper() │
│ │
│ WallpaperSettingsPage │
│ ├── 类型选择: Image | Video | SolidColor │
│ └── 视频预览区域 │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 变更后架构 │
├─────────────────────────────────────────────────────────────────┤
│ MainWindow │
│ ├── DesktopWallpaperLayer (背景层) │
│ │ └── DesktopWallpaperImageLayer (图片层) │
│ └── (移除所有视频相关字段和方法) │
│ │
│ WallpaperSettingsPage │
│ ├── 类型选择: Image | SolidColor │
│ └── (移除视频预览区域) │
└─────────────────────────────────────────────────────────────────┘
```
### 2.2 组件变更清单
| 组件 | 变更类型 | 说明 |
|------|----------|------|
| LanMountainDesktop.csproj | 修改 | 移除 LibVLC 包引用 |
| MainWindow.axaml | 修改 | 移除视频控件和命名空间 |
| MainWindow.axaml.cs | 修改 | 移除视频相关字段和清理代码 |
| MainWindow.SettingsHardCut.Stubs.cs | 修改 | 移除视频壁纸方法 |
| AppearanceThemeService.cs | 修改 | 移除视频种子提取器 |
| WallpaperSettingsPage.axaml | 修改 | 移除视频类型UI |
| WallpaperSettingsPageViewModel.cs | 修改 | 移除视频相关属性 |
| SettingsContracts.cs | 修改 | 移除 Video 枚举值 |
| SettingsDomainServices.cs | 修改 | 移除视频扩展名检测 |
| zh-CN.json | 修改 | 移除视频相关本地化文本 |
---
## 3. 详细设计
### 3.1 项目依赖变更 (LanMountainDesktop.csproj)
#### 3.1.1 移除的包引用
```xml
<!-- 移除以下包引用 -->
<PackageReference Include="LibVLCSharp.Avalonia" Version="3.9.5" />
<PackageReference Include="VideoLAN.LibVLC.Windows" Version="3.0.23" Condition="..." />
<PackageReference Include="VideoLAN.LibVLC.Mac" Version="3.1.3.1" Condition="..." />
```
#### 3.1.2 变更影响
- 减少约 100MB+ 的依赖包大小
- 简化构建和发布流程
- 移除平台特定的原生库依赖
---
### 3.2 主窗口 XAML 变更 (MainWindow.axaml)
#### 3.2.1 移除命名空间声明
```xml
<!-- 移除此行 -->
xmlns:vlc="clr-namespace:LibVLCSharp.Avalonia;assembly=LibVLCSharp.Avalonia"
```
#### 3.2.2 移除视频壁纸控件
移除以下控件约第126-137行
```xml
<!-- 移除 DesktopVideoWallpaperImage -->
<Image x:Name="DesktopVideoWallpaperImage"
IsVisible="False"
IsHitTestVisible="False"
Stretch="UniformToFill"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" />
<!-- 移除 DesktopVideoWallpaperView -->
<vlc:VideoView x:Name="DesktopVideoWallpaperView"
IsVisible="False"
IsHitTestVisible="False"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" />
```
---
### 3.3 主窗口代码变更 (MainWindow.axaml.cs)
#### 3.3.1 移除 using 声明
```csharp
// 移除以下 using如果存在
using LibVLCSharp.Shared;
using LibVLCSharp.Avalonia;
```
#### 3.3.2 移除静态字段
```csharp
// 移除以下字段约第68-71行
private static readonly HashSet<string> SupportedVideoExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".mp4", ".mkv", ".webm", ".avi", ".mov", ".m4v"
};
```
#### 3.3.3 移除实例字段
```csharp
// 移除以下字段约第123-146行
private Bitmap? _videoWallpaperPosterBitmap;
private string? _videoWallpaperPosterPath;
private string? _wallpaperVideoPath;
private LibVLC? _libVlc;
private MediaPlayer? _videoWallpaperPlayer;
private Media? _videoWallpaperMedia;
private readonly object _desktopVideoFrameSync = new();
private MediaPlayer.LibVLCVideoLockCb? _desktopVideoLockCallback;
private MediaPlayer.LibVLCVideoUnlockCb? _desktopVideoUnlockCallback;
private MediaPlayer.LibVLCVideoDisplayCb? _desktopVideoDisplayCallback;
private DispatcherTimer? _desktopVideoFrameRefreshTimer;
private IntPtr _desktopVideoFrameBufferPtr;
private byte[]? _desktopVideoStagingBuffer;
private WriteableBitmap? _desktopVideoBitmap;
private int _desktopVideoFrameWidth;
private int _desktopVideoFrameHeight;
private int _desktopVideoFramePitch;
private int _desktopVideoFrameBufferSize;
private int _desktopVideoFrameDirtyFlag;
```
#### 3.3.4 修改 OnClosed 方法
移除视频相关清理代码约第336-350行
```csharp
// 移除以下代码行
StopVideoWallpaper();
_videoWallpaperMedia?.Dispose();
_videoWallpaperMedia = null;
_videoWallpaperPlayer?.Dispose();
_videoWallpaperPlayer = null;
_desktopVideoFrameRefreshTimer?.Stop();
_desktopVideoFrameRefreshTimer = null;
_videoWallpaperPosterBitmap?.Dispose();
_videoWallpaperPosterBitmap = null;
_videoWallpaperPosterPath = null;
_libVlc?.Dispose();
_libVlc = null;
```
---
### 3.4 主窗口 Stub 方法变更 (MainWindow.SettingsHardCut.Stubs.cs)
#### 3.4.1 移除 using 声明
```csharp
// 移除以下 using第19-20行
using LibVLCSharp.Shared;
using LibVLCSharp.Avalonia;
```
#### 3.4.2 移除方法
移除以下完整方法:
| 方法名 | 行号范围 | 说明 |
|--------|----------|------|
| `StartVideoWallpaper` | 337-383 | 启动视频壁纸播放 |
| `StopVideoWallpaper` | 385-395 | 停止视频壁纸播放 |
| `TryCaptureVideoWallpaperPosterFrame` | 666-751 | 捕获视频海报帧 |
| `ApplyVideoWallpaperPosterVisibility` | 647-664 | 控制视频海报可见性 |
#### 3.4.3 修改 UpdateWallpaperDisplay 方法
简化为仅处理图片壁纸:
```csharp
private void UpdateWallpaperDisplay()
{
// 移除视频分支,仅保留图片处理
StopVideoWallpaper(); // 移除此调用
ApplyWallpaperBrush();
}
```
修改后:
```csharp
private void UpdateWallpaperDisplay()
{
ApplyWallpaperBrush();
}
```
#### 3.4.4 修改 ApplyWallpaperBrush 方法
移除所有 `ApplyVideoWallpaperPosterVisibility` 调用:
```csharp
// 移除以下调用
ApplyVideoWallpaperPosterVisibility(showPoster: false);
ApplyVideoWallpaperPosterVisibility(showPoster: _videoWallpaperPosterBitmap is not null);
```
#### 3.4.5 修改 SetWallpaperState 方法
移除视频类型处理分支约第238-247行
```csharp
// 移除以下代码块
var requestedTypeIsVideo = string.Equals(_wallpaperType, "Video", StringComparison.OrdinalIgnoreCase);
if (SupportedVideoExtensions.Contains(extension) || requestedTypeIsVideo)
{
_wallpaperMediaType = WallpaperMediaType.Video;
_wallpaperVideoPath = _wallpaperPath;
_wallpaperDisplayState = File.Exists(_wallpaperPath)
? WallpaperDisplayState.CurrentValidWallpaper
: WallpaperDisplayState.TemporarilyUnavailable;
return;
}
```
---
### 3.5 外观主题服务变更 (AppearanceThemeService.cs)
#### 3.5.1 移除接口和类
移除以下代码约第92-184行
```csharp
// 移除接口
internal interface IVideoWallpaperSeedExtractor
{
IReadOnlyList<Color> ExtractSeedCandidates(string videoPath, MonetColorService monetColorService);
}
// 移除实现类
internal sealed class LibVlcVideoWallpaperSeedExtractor : IVideoWallpaperSeedExtractor
{
// ... 整个类实现
}
```
---
### 3.6 壁纸设置页面 XAML 变更 (WallpaperSettingsPage.axaml)
#### 3.6.1 移除视频预览区域
移除以下代码约第29-44行
```xml
<Border Background="#FFF6F7F9"
IsVisible="{Binding IsVideo}">
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="12">
<fi:FluentIcon Icon="Video"
Width="72"
Height="72"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<TextBlock Text="{Binding VideoModeHintText}"
Width="300"
TextAlignment="Center"
TextWrapping="Wrap"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
</StackPanel>
</Border>
```
#### 3.6.2 移除视频模式提示文本
移除以下代码约第150-154行
```xml
<TextBlock Margin="0,8,0,0"
IsVisible="{Binding IsVideo}"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="{Binding VideoModeHintText}"
TextWrapping="Wrap" />
```
#### 3.6.3 修改填充方式设置可见性绑定
```xml
<!-- 修改前 -->
IsVisible="{Binding IsImageOrVideo}"
<!-- 修改后 -->
IsVisible="{Binding IsImage}"
```
---
### 3.7 壁纸设置 ViewModel 变更 (WallpaperSettingsPageViewModel.cs)
#### 3.7.1 移除属性
```csharp
// 移除以下属性
[ObservableProperty]
private bool _isImageOrVideo;
[ObservableProperty]
private bool _isVideo;
[ObservableProperty]
private string _videoModeHintText = string.Empty;
```
#### 3.7.2 修改 CreateWallpaperTypes 方法
```csharp
// 修改前
private IReadOnlyList<SelectionOption> CreateWallpaperTypes()
{
return
[
new SelectionOption("Image", L("settings.wallpaper.type.image", "Image")),
new SelectionOption("Video", L("settings.wallpaper.type.video", "Video")),
new SelectionOption("SolidColor", L("settings.wallpaper.type.solid_color", "Solid Color"))
];
}
// 修改后
private IReadOnlyList<SelectionOption> CreateWallpaperTypes()
{
return
[
new SelectionOption("Image", L("settings.wallpaper.type.image", "Image")),
new SelectionOption("SolidColor", L("settings.wallpaper.type.solid_color", "Solid Color"))
];
}
```
#### 3.7.3 修改 UpdateVisibility 方法
移除 IsVideo 和 IsImageOrVideo 的赋值:
```csharp
// 移除以下行
IsVideo = SelectedWallpaperType?.Value == "Video";
IsImageOrVideo = SelectedWallpaperType?.Value is "Image" or "Video";
```
#### 3.7.4 修改 RefreshLocalizedText 方法
```csharp
// 移除以下行
VideoModeHintText = L("settings.wallpaper.video_mode", "Video wallpaper uses automatic fill mode.");
```
---
### 3.8 设置契约变更 (SettingsContracts.cs)
#### 3.8.1 修改 WallpaperMediaType 枚举
```csharp
// 修改前
public enum WallpaperMediaType
{
None,
Image,
Video
}
// 修改后
public enum WallpaperMediaType
{
None,
Image
}
```
---
### 3.9 设置域服务变更 (SettingsDomainServices.cs)
#### 3.9.1 移除视频扩展名集合
```csharp
// 移除以下字段约第150-153行
private static readonly HashSet<string> VideoExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".mp4", ".mkv", ".webm", ".avi", ".mov", ".m4v"
};
```
#### 3.9.2 修改 DetectMediaType 方法
```csharp
// 修改前
public WallpaperMediaType DetectMediaType(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return WallpaperMediaType.None;
}
var extension = Path.GetExtension(path.Trim());
if (string.IsNullOrWhiteSpace(extension))
{
return WallpaperMediaType.None;
}
if (ImageExtensions.Contains(extension))
{
return WallpaperMediaType.Image;
}
if (VideoExtensions.Contains(extension))
{
return WallpaperMediaType.Video;
}
return WallpaperMediaType.None;
}
// 修改后
public WallpaperMediaType DetectMediaType(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return WallpaperMediaType.None;
}
var extension = Path.GetExtension(path.Trim());
if (string.IsNullOrWhiteSpace(extension))
{
return WallpaperMediaType.None;
}
if (ImageExtensions.Contains(extension))
{
return WallpaperMediaType.Image;
}
return WallpaperMediaType.None;
}
```
---
### 3.10 本地化文件变更 (zh-CN.json)
#### 3.10.1 移除的本地化键
```json
// 移除以下键值对
"settings.wallpaper.type.video": "视频",
"settings.wallpaper.video_applied": "视频壁纸已应用。",
"settings.wallpaper.video_mode": "视频壁纸使用自动填充模式。",
"settings.wallpaper.video_restored": "已恢复保存的视频壁纸。",
"settings.wallpaper.video_not_found": "未找到视频壁纸文件。",
"settings.wallpaper.video_player_unavailable": "视频播放器不可用。",
"settings.wallpaper.video_play_failed_format": "播放视频壁纸失败:{0}"
```
#### 3.10.2 修改描述文本
```json
// 修改前
"settings.wallpaper.description": "选择图片或视频后可立即设为应用窗口壁纸。",
// 修改后
"settings.wallpaper.description": "选择图片后可立即设为应用窗口壁纸。",
```
---
## 4. 数据模型变更
### 4.1 WallpaperMediaType 枚举简化
```
变更前: None | Image | Video
变更后: None | Image
```
### 4.2 设置存储兼容性
现有用户设置中如果包含 `Type: "Video"` 的壁纸配置:
- 应用将无法识别该类型
- 将回退到纯色背景
- 用户需要重新选择图片壁纸
---
## 5. 风险评估
### 5.1 潜在风险
| 风险 | 影响 | 缓解措施 |
|------|------|----------|
| 现有视频壁纸用户设置失效 | 中 | 应用会自动回退到纯色背景 |
| 遗漏的视频相关代码引用 | 低 | 编译器会报告未定义类型错误 |
| 本地化键遗漏 | 低 | 运行时会显示键名而非翻译文本 |
### 5.2 回滚策略
如需回滚,可通过 Git 恢复以下文件:
- LanMountainDesktop.csproj
- MainWindow.axaml / .axaml.cs
- MainWindow.SettingsHardCut.Stubs.cs
- AppearanceThemeService.cs
- WallpaperSettingsPage.axaml
- WallpaperSettingsPageViewModel.cs
- SettingsContracts.cs
- SettingsDomainServices.cs
- zh-CN.json
---
## 6. 验证清单
### 6.1 编译验证
- [ ] 项目编译无错误
- [ ] 无 LibVLC 相关类型引用警告
- [ ] 无未使用变量警告
### 6.2 功能验证
- [ ] 应用正常启动
- [ ] 图片壁纸正常显示
- [ ] 纯色壁纸正常显示
- [ ] 壁纸设置页面正常打开
- [ ] 类型选择器仅显示"图片"和"纯色"
- [ ] 壁纸导入功能正常工作
### 6.3 清理验证
- [ ] 无 LibVLC 相关 DLL 在输出目录
- [ ] 无视频相关本地化文本残留
- [ ] 无视频相关 UI 控件残留

View File

@@ -0,0 +1,206 @@
# 移除视频壁纸功能规格说明书
## Why
当前 LanMountainDesktop 项目包含视频壁纸功能,该功能引入了以下复杂性和依赖:
1. 引入了 LibVLCSharp.Avalonia、VideoLAN.LibVLC.Windows、VideoLAN.LibVLC.Mac 等重型依赖
2. 在主窗口中残留大量视频壁纸相关代码和字段
3. 在设置页面中保留了视频类型选择器和相关 UI 元素
4. 在本地化文件中保留了视频壁纸相关文本
5. 增加了应用复杂度和维护成本
用户决定移除该功能以简化代码库。
## What Changes
- 移除 LibVLCSharp.Avalonia 及 VideoLAN.LibVLC.* NuGet 依赖
- 移除 AppearanceThemeService.cs 中的 LibVlcVideoWallpaperSeedExtractor 类和 IVideoWallpaperSeedExtractor 接口
- 移除 MainWindow.axaml.cs 中的视频壁纸相关字段和清理代码
- 移除 MainWindow.SettingsHardCut.Stubs.cs 中的视频壁纸相关方法
- 移除 MainWindow.axaml 中的 DesktopVideoWallpaperImage 和 DesktopVideoWallpaperView 控件
- 移除 WallpaperSettingsPage.axaml 中的视频类型选择器和视频模式提示
- 移除 WallpaperSettingsPageViewModel.cs 中的 IsVideo、VideoModeHintText 等属性
- 移除 SettingsContracts.cs 中 WallpaperMediaType 枚举的 Video 值
- 移除 SettingsDomainServices.cs 中 WallpaperMediaService 类的视频扩展名检测逻辑
- 移除本地化文件中的视频壁纸相关文本
## Impact
### Affected specs
- 壁纸设置功能规格
- 主窗口桌面层规格
### Affected code
- `LanMountainDesktop.csproj` - NuGet 依赖配置
- `Services/AppearanceThemeService.cs` - 视频壁纸种子提取器
- `Views/MainWindow.axaml.cs` - 主窗口字段和清理逻辑
- `Views/MainWindow.SettingsHardCut.Stubs.cs` - 视频壁纸控制方法
- `Views/MainWindow.axaml` - 视频壁纸 UI 控件
- `Views/SettingsPages/WallpaperSettingsPage.axaml` - 壁纸设置页面 UI
- `ViewModels/WallpaperSettingsPageViewModel.cs` - 壁纸设置 ViewModel
- `Services/Settings/SettingsContracts.cs` - 壁纸媒体类型枚举
- `Services/Settings/SettingsDomainServices.cs` - 壁纸媒体服务
- `Localization/zh-CN.json` - 本地化文本
---
## REMOVED Requirements
### Requirement: 视频壁纸播放功能
**Reason**: 用户决定移除视频壁纸功能以简化代码库,减少重型依赖
**Migration**:
- 用户如需动态壁纸,可使用静态图片壁纸替代
- 现有视频壁纸设置将被重置为纯色背景
#### Scenario: 视频壁纸播放
- **GIVEN** 用户选择了视频文件作为壁纸
- **WHEN** 系统检测到视频格式
- **THEN** 系统不再支持视频壁纸播放
- **AND THEN** 系统提示用户该文件类型不受支持
### Requirement: LibVLC 依赖
**Reason**: 移除视频壁纸功能后不再需要 LibVLC 库
**Migration**: 从项目依赖中移除以下包:
- LibVLCSharp.Avalonia
- VideoLAN.LibVLC.Windows
- VideoLAN.LibVLC.Mac
### Requirement: 视频壁纸种子提取
**Reason**: 移除视频壁纸功能后不再需要从视频中提取颜色种子
**Migration**: 移除 `LibVlcVideoWallpaperSeedExtractor` 类和 `IVideoWallpaperSeedExtractor` 接口
### Requirement: 视频壁纸 UI 控件
**Reason**: 移除视频壁纸功能后不再需要视频显示控件
**Migration**: 移除 `DesktopVideoWallpaperImage``DesktopVideoWallpaperView` 控件
### Requirement: 视频类型选择器
**Reason**: 移除视频壁纸功能后不再需要视频类型选项
**Migration**: 从壁纸类型选择器中移除"视频"选项
---
## MODIFIED Requirements
### Requirement: 壁纸媒体类型检测
**当前**: 支持检测 None、Image、Video 三种类型
**修改后**: 仅支持检测 None、Image 两种类型
#### Scenario: 检测媒体类型
- **WHEN** 用户选择壁纸文件
- **THEN** 系统仅检测图片格式(.png, .jpg, .jpeg, .bmp, .gif, .webp
- **AND THEN** 视频格式文件将被识别为不受支持的类型
### Requirement: 壁纸类型选项
**当前**: 提供图片、视频、纯色三种类型选项
**修改后**: 仅提供图片、纯色两种类型选项
#### Scenario: 壁纸类型选择
- **WHEN** 用户打开壁纸设置页面
- **THEN** 类型选择器仅显示"图片"和"纯色"选项
- **AND THEN** "视频"选项不再显示
### Requirement: 壁纸设置页面预览
**当前**: 根据类型显示图片预览、视频预览或纯色预览
**修改后**: 根据类型显示图片预览或纯色预览
#### Scenario: 预览显示
- **WHEN** 用户选择壁纸类型
- **THEN** 系统仅显示图片预览或纯色预览
- **AND THEN** 视频预览区域不再显示
### Requirement: 主窗口壁纸显示
**当前**: 支持显示静态图片壁纸和视频壁纸
**修改后**: 仅支持显示静态图片壁纸
#### Scenario: 壁纸显示更新
- **WHEN** 用户应用新壁纸
- **THEN** 系统仅处理静态图片壁纸显示
- **AND THEN** 视频壁纸播放逻辑不再执行
---
## ADDED Requirements
### Requirement: 清理残留代码
系统 SHALL 完全移除视频壁纸功能相关的所有代码和资源。
#### Scenario: 主窗口字段清理
- **WHEN** 执行代码清理
- **THEN** 移除以下字段:
- `_videoWallpaperPosterBitmap`
- `_videoWallpaperPosterPath`
- `_libVlc`
- `_videoWallpaperPlayer`
- `_videoWallpaperMedia`
- `_wallpaperVideoPath`
#### Scenario: 主窗口方法清理
- **WHEN** 执行代码清理
- **THEN** 移除以下方法:
- `StartVideoWallpaper`
- `StopVideoWallpaper`
- `TryCaptureVideoWallpaperPosterFrame`
- `ApplyVideoWallpaperPosterVisibility`
- `UpdateWallpaperDisplay` 中的视频处理分支
#### Scenario: ViewModel 属性清理
- **WHEN** 执行代码清理
- **THEN** 移除以下属性:
- `IsVideo`
- `VideoModeHintText`
- `IsImageOrVideo`(改为 `IsImage`
#### Scenario: 本地化文本清理
- **WHEN** 执行代码清理
- **THEN** 移除以下本地化键:
- `settings.wallpaper.type.video`
- `settings.wallpaper.video_applied`
- `settings.wallpaper.video_mode`
- `settings.wallpaper.video_restored`
- `settings.wallpaper.video_not_found`
- `settings.wallpaper.video_player_unavailable`
- `settings.wallpaper.video_play_failed_format`
### Requirement: 依赖项清理
系统 SHALL 从项目文件中移除 LibVLC 相关 NuGet 包引用。
#### Scenario: NuGet 包移除
- **WHEN** 执行依赖清理
- **THEN** 移除以下包引用:
- `LibVLCSharp.Avalonia`
- `VideoLAN.LibVLC.Windows`
- `VideoLAN.LibVLC.Mac`
### Requirement: 构建验证
系统 SHALL 在移除视频壁纸功能后保持正常构建和运行。
#### Scenario: 构建成功
- **WHEN** 执行项目构建
- **THEN** 构建成功无错误
- **AND THEN** 所有现有测试通过
#### Scenario: 应用启动
- **WHEN** 启动应用程序
- **THEN** 应用正常启动
- **AND THEN** 壁纸设置功能正常工作(仅支持图片和纯色)

View File

@@ -0,0 +1,600 @@
# 移除视频壁纸功能 - 编码任务清单
## 任务概览
本文档将技术设计分解为可执行的编码任务,按依赖关系排序执行。
---
## 任务 1: 移除项目依赖
**优先级**: P0 (最高)
**依赖**: 无
**预估工作量**: 5 分钟
### 描述
从项目文件中移除 LibVLC 相关的 NuGet 包引用。
### 输入
- `LanMountainDesktop/LanMountainDesktop.csproj`
### 输出
- 修改后的 `LanMountainDesktop.csproj`,移除以下包引用:
- `LibVLCSharp.Avalonia`
- `VideoLAN.LibVLC.Windows`
- `VideoLAN.LibVLC.Mac`
### 验收标准
- [ ] 项目文件中不再包含 LibVLC 相关包引用
- [ ] 执行 `dotnet restore` 成功
### 执行提示
```
编辑 LanMountainDesktop.csproj移除以下 PackageReference 节点:
1. <PackageReference Include="LibVLCSharp.Avalonia" Version="3.9.5" />
2. <PackageReference Include="VideoLAN.LibVLC.Windows" ... />
3. <PackageReference Include="VideoLAN.LibVLC.Mac" ... />
```
---
## 任务 2: 移除主窗口 XAML 视频控件
**优先级**: P0
**依赖**: 任务 1
**预估工作量**: 10 分钟
### 描述
从 MainWindow.axaml 中移除视频壁纸相关的 XAML 控件和命名空间声明。
### 输入
- `LanMountainDesktop/Views/MainWindow.axaml`
### 输出
- 移除 LibVLC 命名空间声明
- 移除 `DesktopVideoWallpaperImage` 控件
- 移除 `DesktopVideoWallpaperView` 控件
### 验收标准
- [ ] XAML 中无 `xmlns:vlc` 命名空间
- [ ] XAML 中无 `DesktopVideoWallpaperImage` 元素
- [ ] XAML 中无 `DesktopVideoWallpaperView` 元素
### 执行提示
```
编辑 MainWindow.axaml
1. 移除第 9 行: xmlns:vlc="clr-namespace:LibVLCSharp.Avalonia;assembly=LibVLCSharp.Avalonia"
2. 移除第 126-131 行: <Image x:Name="DesktopVideoWallpaperImage" ... />
3. 移除第 133-137 行: <vlc:VideoView x:Name="DesktopVideoWallpaperView" ... />
```
---
## 任务 3: 移除主窗口代码视频字段
**优先级**: P0
**依赖**: 任务 1
**预估工作量**: 15 分钟
### 描述
从 MainWindow.axaml.cs 中移除视频壁纸相关的字段声明。
### 输入
- `LanMountainDesktop/Views/MainWindow.axaml.cs`
### 输出
- 移除 `SupportedVideoExtensions` 静态字段
- 移除所有视频相关实例字段
### 验收标准
- [ ]`SupportedVideoExtensions` 字段
- [ ]`_videoWallpaperPosterBitmap` 字段
- [ ]`_videoWallpaperPosterPath` 字段
- [ ]`_wallpaperVideoPath` 字段
- [ ]`_libVlc` 字段
- [ ]`_videoWallpaperPlayer` 字段
- [ ]`_videoWallpaperMedia` 字段
- [ ]`_desktopVideoFrameSync` 及相关视频帧处理字段
### 执行提示
```
编辑 MainWindow.axaml.cs
1. 移除第 68-71 行的 SupportedVideoExtensions 定义
2. 移除第 123-146 行的所有视频相关字段
```
---
## 任务 4: 移除主窗口 OnClosed 清理代码
**优先级**: P0
**依赖**: 任务 3
**预估工作量**: 5 分钟
### 描述
从 MainWindow.axaml.cs 的 OnClosed 方法中移除视频相关清理代码。
### 输入
- `LanMountainDesktop/Views/MainWindow.axaml.cs` (OnClosed 方法)
### 输出
- 简化的 OnClosed 方法,无视频清理逻辑
### 验收标准
- [ ] OnClosed 方法中无 `StopVideoWallpaper()` 调用
- [ ] OnClosed 方法中无 `_videoWallpaperMedia` 相关清理
- [ ] OnClosed 方法中无 `_videoWallpaperPlayer` 相关清理
- [ ] OnClosed 方法中无 `_libVlc` 相关清理
### 执行提示
```
编辑 MainWindow.axaml.cs 的 OnClosed 方法,移除以下代码行:
- StopVideoWallpaper();
- _videoWallpaperMedia?.Dispose(); _videoWallpaperMedia = null;
- _videoWallpaperPlayer?.Dispose(); _videoWallpaperPlayer = null;
- _desktopVideoFrameRefreshTimer?.Stop(); _desktopVideoFrameRefreshTimer = null;
- _videoWallpaperPosterBitmap?.Dispose(); _videoWallpaperPosterBitmap = null;
- _videoWallpaperPosterPath = null;
- _libVlc?.Dispose(); _libVlc = null;
```
---
## 任务 5: 移除主窗口 Stub 方法
**优先级**: P0
**依赖**: 任务 1
**预估工作量**: 20 分钟
### 描述
从 MainWindow.SettingsHardCut.Stubs.cs 中移除视频壁纸相关方法和 using 声明。
### 输入
- `LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs`
### 输出
- 移除 LibVLC using 声明
- 移除 `StartVideoWallpaper` 方法
- 移除 `StopVideoWallpaper` 方法
- 移除 `TryCaptureVideoWallpaperPosterFrame` 方法
- 移除 `ApplyVideoWallpaperPosterVisibility` 方法
### 验收标准
- [ ]`using LibVLCSharp.Shared;`
- [ ]`using LibVLCSharp.Avalonia;`
- [ ]`StartVideoWallpaper` 方法定义
- [ ]`StopVideoWallpaper` 方法定义
- [ ]`TryCaptureVideoWallpaperPosterFrame` 方法定义
- [ ]`ApplyVideoWallpaperPosterVisibility` 方法定义
### 执行提示
```
编辑 MainWindow.SettingsHardCut.Stubs.cs
1. 移除第 19-20 行的 using 声明
2. 移除 StartVideoWallpaper 方法(第 337-383 行)
3. 移除 StopVideoWallpaper 方法(第 385-395 行)
4. 移除 ApplyVideoWallpaperPosterVisibility 方法(第 647-664 行)
5. 移除 TryCaptureVideoWallpaperPosterFrame 方法(第 666-751 行)
```
---
## 任务 6: 简化壁纸状态处理逻辑
**优先级**: P0
**依赖**: 任务 5
**预估工作量**: 15 分钟
### 描述
修改 MainWindow.SettingsHardCut.Stubs.cs 中的壁纸状态处理方法,移除视频类型分支。
### 输入
- `LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs`
### 输出
- 简化的 `SetWallpaperState` 方法
- 简化的 `UpdateWallpaperDisplay` 方法
- 简化的 `ApplyWallpaperBrush` 方法
### 验收标准
- [ ] `SetWallpaperState` 中无视频类型检测分支
- [ ] `SetWallpaperState` 中无 `_wallpaperVideoPath` 赋值
- [ ] `UpdateWallpaperDisplay` 中无 `StopVideoWallpaper()` 调用
- [ ] `ApplyWallpaperBrush` 中无 `ApplyVideoWallpaperPosterVisibility` 调用
### 执行提示
```
编辑 MainWindow.SettingsHardCut.Stubs.cs
1. SetWallpaperState 方法:
- 移除 requestedTypeIsVideo 变量定义
- 移除视频类型检测 if 块SupportedVideoExtensions.Contains 检查)
2. UpdateWallpaperDisplay 方法:
- 移除视频类型分支,仅保留 ApplyWallpaperBrush() 调用
3. ApplyWallpaperBrush 方法:
- 移除所有 ApplyVideoWallpaperPosterVisibility 调用
```
---
## 任务 7: 移除外观主题服务视频提取器
**优先级**: P1
**依赖**: 任务 1
**预估工作量**: 10 分钟
### 描述
从 AppearanceThemeService.cs 中移除视频壁纸种子提取器接口和实现类。
### 输入
- `LanMountainDesktop/Services/AppearanceThemeService.cs`
### 输出
- 移除 `IVideoWallpaperSeedExtractor` 接口
- 移除 `LibVlcVideoWallpaperSeedExtractor`
### 验收标准
- [ ]`IVideoWallpaperSeedExtractor` 接口定义
- [ ]`LibVlcVideoWallpaperSeedExtractor` 类定义
### 执行提示
```
编辑 AppearanceThemeService.cs
移除第 92-184 行的接口和类定义:
- IVideoWallpaperSeedExtractor 接口
- LibVlcVideoWallpaperSeedExtractor 类
```
---
## 任务 8: 简化壁纸设置页面 XAML
**优先级**: P1
**依赖**: 无
**预估工作量**: 10 分钟
### 描述
从 WallpaperSettingsPage.axaml 中移除视频预览区域和相关 UI 元素。
### 输入
- `LanMountainDesktop/Views/SettingsPages/WallpaperSettingsPage.axaml`
### 输出
- 移除视频预览 Border 区域
- 移除视频模式提示 TextBlock
- 修改填充方式可见性绑定
### 验收标准
- [ ] 无视频预览 BorderIsVisible="{Binding IsVideo}"
- [ ] 无 VideoModeHintText 绑定的 TextBlock
- [ ] 填充方式设置绑定改为 `IsVisible="{Binding IsImage}"`
### 执行提示
```
编辑 WallpaperSettingsPage.axaml
1. 移除第 29-44 行的视频预览 Border
2. 移除第 150-154 行的视频模式提示 TextBlock
3. 修改第 132 行: IsVisible="{Binding IsImageOrVideo}" 改为 IsVisible="{Binding IsImage}"
```
---
## 任务 9: 简化壁纸设置 ViewModel
**优先级**: P1
**依赖**: 任务 8
**预估工作量**: 15 分钟
### 描述
从 WallpaperSettingsPageViewModel.cs 中移除视频相关属性和方法逻辑。
### 输入
- `LanMountainDesktop/ViewModels/WallpaperSettingsPageViewModel.cs`
### 输出
- 移除 `_isImageOrVideo``_isVideo``_videoModeHintText` 属性
- 修改 `CreateWallpaperTypes` 方法
- 修改 `UpdateVisibility` 方法
- 修改 `RefreshLocalizedText` 方法
### 验收标准
- [ ]`IsImageOrVideo` 属性
- [ ]`IsVideo` 属性
- [ ]`VideoModeHintText` 属性
- [ ] `CreateWallpaperTypes` 仅返回 Image 和 SolidColor 选项
- [ ] `UpdateVisibility` 中无 IsVideo、IsImageOrVideo 赋值
- [ ] `RefreshLocalizedText` 中无 VideoModeHintText 赋值
### 执行提示
```
编辑 WallpaperSettingsPageViewModel.cs
1. 移除第 76-77 行的 _isImageOrVideo 字段和属性
2. 移除第 85-86 行的 _isVideo 字段和属性
3. 移除第 94-95 行的 _videoModeHintText 字段和属性
4. 修改 CreateWallpaperTypes 方法,移除 Video 选项
5. 修改 UpdateVisibility 方法,移除 IsVideo 和 IsImageOrVideo 赋值
6. 修改 RefreshLocalizedText 方法,移除 VideoModeHintText 赋值
```
---
## 任务 10: 简化壁纸媒体类型枚举
**优先级**: P1
**依赖**: 无
**预估工作量**: 5 分钟
### 描述
从 SettingsContracts.cs 中移除 WallpaperMediaType 枚举的 Video 值。
### 输入
- `LanMountainDesktop/Services/Settings/SettingsContracts.cs`
### 输出
- 简化的 `WallpaperMediaType` 枚举
### 验收标准
- [ ] `WallpaperMediaType` 枚举仅包含 `None``Image`
### 执行提示
```
编辑 SettingsContracts.cs
修改第 11-16 行的枚举定义:
public enum WallpaperMediaType
{
None,
Image
}
```
---
## 任务 11: 简化壁纸媒体服务
**优先级**: P1
**依赖**: 任务 10
**预估工作量**: 10 分钟
### 描述
从 SettingsDomainServices.cs 中移除视频扩展名检测逻辑。
### 输入
- `LanMountainDesktop/Services/Settings/SettingsDomainServices.cs`
### 输出
- 移除 `VideoExtensions` 字段
- 简化 `DetectMediaType` 方法
### 验收标准
- [ ]`VideoExtensions` 字段定义
- [ ] `DetectMediaType` 方法中无视频扩展名检测逻辑
### 执行提示
```
编辑 SettingsDomainServices.cs
1. 移除第 150-153 行的 VideoExtensions 字段定义
2. 修改 DetectMediaType 方法,移除视频检测分支
```
---
## 任务 12: 清理本地化文件
**优先级**: P2
**依赖**: 无
**预估工作量**: 5 分钟
### 描述
从 zh-CN.json 中移除视频壁纸相关的本地化文本。
### 输入
- `LanMountainDesktop/Localization/zh-CN.json`
### 输出
- 移除视频相关本地化键
- 修改壁纸描述文本
### 验收标准
- [ ]`settings.wallpaper.type.video`
- [ ]`settings.wallpaper.video_applied`
- [ ]`settings.wallpaper.video_mode`
- [ ]`settings.wallpaper.video_restored`
- [ ]`settings.wallpaper.video_not_found`
- [ ]`settings.wallpaper.video_player_unavailable`
- [ ]`settings.wallpaper.video_play_failed_format`
- [ ] `settings.wallpaper.description` 文本已更新
### 执行提示
```
编辑 zh-CN.json
1. 移除以下键值对:
- "settings.wallpaper.type.video"
- "settings.wallpaper.video_applied"
- "settings.wallpaper.video_mode"
- "settings.wallpaper.video_restored"
- "settings.wallpaper.video_not_found"
- "settings.wallpaper.video_player_unavailable"
- "settings.wallpaper.video_play_failed_format"
2. 修改描述文本:
"settings.wallpaper.description": "选择图片后可立即设为应用窗口壁纸。"
```
---
## 任务 13: 构建验证
**优先级**: P0
**依赖**: 任务 1-12 全部完成
**预估工作量**: 10 分钟
### 描述
验证项目在移除视频壁纸功能后能够正常构建。
### 输入
- 整个项目
### 输出
- 构建成功确认
### 验收标准
- [ ] `dotnet build` 执行成功,无编译错误
- [ ] 无 LibVLC 相关类型未定义错误
- [ ] 无未使用变量警告(或已处理)
### 执行提示
```
在项目根目录执行:
dotnet build LanMountainDesktop/LanMountainDesktop.csproj
检查输出:
- 确认无编译错误
- 确认无 LibVLC 相关类型引用错误
```
---
## 任务 14: 功能验证
**优先级**: P0
**依赖**: 任务 13
**预估工作量**: 15 分钟
### 描述
验证应用在移除视频壁纸功能后核心功能正常工作。
### 输入
- 构建后的应用
### 输出
- 功能验证报告
### 验收标准
- [ ] 应用正常启动
- [ ] 图片壁纸正常显示
- [ ] 纯色壁纸正常显示
- [ ] 壁纸设置页面正常打开
- [ ] 类型选择器仅显示"图片"和"纯色"选项
- [ ] 壁纸导入功能正常工作
### 执行提示
```
运行应用:
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
手动验证:
1. 应用启动无崩溃
2. 打开设置 -> 壁纸页面
3. 确认类型选择器仅有"图片"和"纯色"
4. 测试选择图片壁纸
5. 测试选择纯色壁纸
```
---
## 任务依赖关系图
```
任务 1 (移除依赖)
├── 任务 2 (XAML控件)
├── 任务 3 (代码字段)
│ └── 任务 4 (OnClosed清理)
├── 任务 5 (Stub方法)
│ └── 任务 6 (状态处理逻辑)
└── 任务 7 (主题服务)
任务 8 (设置页面XAML)
└── 任务 9 (设置ViewModel)
任务 10 (枚举简化)
└── 任务 11 (媒体服务)
任务 12 (本地化) - 独立
任务 13 (构建验证) - 依赖所有任务
└── 任务 14 (功能验证)
```
---
## 执行顺序建议
按以下顺序执行可确保依赖关系正确:
1. **第一批** (可并行): 任务 1, 任务 8, 任务 10, 任务 12
2. **第二批** (可并行): 任务 2, 任务 3, 任务 5, 任务 7, 任务 9, 任务 11
3. **第三批** (可并行): 任务 4, 任务 6
4. **第四批**: 任务 13
5. **第五批**: 任务 14

View File

@@ -4,6 +4,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<Version>1.0.0</Version>
</PropertyGroup>
<ItemGroup>

View File

@@ -0,0 +1,157 @@
using System;
using System.IO;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class WhiteboardNotePersistenceServiceTests
{
[Fact]
public void SaveNote_ThenLoadNote_RoundTripsSnapshot()
{
using var sandbox = new WhiteboardNotePersistenceSandbox();
var service = sandbox.CreateService();
var snapshot = CreateSampleSnapshot();
service.SaveNote("DesktopWhiteboard", "whiteboard-1", snapshot, retentionDays: 15);
var loaded = service.LoadNote("DesktopWhiteboard", "whiteboard-1", retentionDays: 15);
Assert.Single(loaded.Strokes);
Assert.Equal(2, loaded.Strokes[0].Points.Count);
Assert.Equal("#FF112233", loaded.Strokes[0].Color);
Assert.True(loaded.SavedUtc > DateTimeOffset.MinValue);
}
[Fact]
public void LoadNote_RemovesExpiredSnapshot_WhenRetentionExceeded()
{
using var sandbox = new WhiteboardNotePersistenceSandbox();
var service = sandbox.CreateService();
service.SaveNote("DesktopWhiteboard", "expired-board", CreateSampleSnapshot(), retentionDays: 7);
sandbox.OverrideSavedTimestamp("DesktopWhiteboard", "expired-board", DateTimeOffset.UtcNow.AddDays(-10), retentionDays: 7);
var loaded = service.LoadNote("DesktopWhiteboard", "expired-board", retentionDays: 7);
Assert.Empty(loaded.Strokes);
Assert.False(sandbox.Exists("DesktopWhiteboard", "expired-board"));
}
[Fact]
public void DeleteExpiredNotesBatch_RemovesExpiredRows_AndKeepsFreshRows()
{
using var sandbox = new WhiteboardNotePersistenceSandbox();
var service = sandbox.CreateService();
service.SaveNote("DesktopWhiteboard", "expired-a", CreateSampleSnapshot(), retentionDays: 7);
service.SaveNote("DesktopWhiteboard", "expired-b", CreateSampleSnapshot(), retentionDays: 7);
service.SaveNote("DesktopWhiteboard", "fresh-c", CreateSampleSnapshot(), retentionDays: 15);
sandbox.OverrideSavedTimestamp("DesktopWhiteboard", "expired-a", DateTimeOffset.UtcNow.AddDays(-9), retentionDays: 7);
sandbox.OverrideSavedTimestamp("DesktopWhiteboard", "expired-b", DateTimeOffset.UtcNow.AddDays(-8), retentionDays: 7);
sandbox.OverrideSavedTimestamp("DesktopWhiteboard", "fresh-c", DateTimeOffset.UtcNow.AddDays(-2), retentionDays: 15);
var deletedCount = service.DeleteExpiredNotesBatch(batchSize: 10);
Assert.Equal(2, deletedCount);
Assert.False(sandbox.Exists("DesktopWhiteboard", "expired-a"));
Assert.False(sandbox.Exists("DesktopWhiteboard", "expired-b"));
Assert.True(sandbox.Exists("DesktopWhiteboard", "fresh-c"));
}
private static WhiteboardNoteSnapshot CreateSampleSnapshot()
{
return new WhiteboardNoteSnapshot
{
Strokes =
[
new WhiteboardStrokeSnapshot
{
Color = "#FF112233",
InkThickness = 3.5d,
IgnorePressure = true,
Points =
[
new WhiteboardStylusPointSnapshot { X = 12, Y = 34, Pressure = 0.4d, Width = 2, Height = 2 },
new WhiteboardStylusPointSnapshot { X = 48, Y = 64, Pressure = 0.7d, Width = 2, Height = 2 }
]
}
]
};
}
private sealed class WhiteboardNotePersistenceSandbox : IDisposable
{
private readonly string _directoryPath = Path.Combine(
Path.GetTempPath(),
"LanMountainDesktop.WhiteboardNoteTests",
Guid.NewGuid().ToString("N"));
private readonly string _databasePath;
public WhiteboardNotePersistenceSandbox()
{
Directory.CreateDirectory(_directoryPath);
_databasePath = Path.Combine(_directoryPath, "whiteboard-tests.db");
}
public WhiteboardNotePersistenceService CreateService()
{
return new WhiteboardNotePersistenceService(new AppDatabaseService(_databasePath));
}
public void OverrideSavedTimestamp(string componentId, string placementId, DateTimeOffset savedUtc, int retentionDays)
{
var expiresUtc = savedUtc.AddDays(WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays));
using var connection = new AppDatabaseService(_databasePath).OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
UPDATE whiteboard_notes
SET saved_at_utc_ms = $savedAtUtcMs,
expires_at_utc_ms = $expiresAtUtcMs,
updated_at_utc_ms = $updatedAtUtcMs
WHERE component_id = $componentId
AND placement_id = $placementId;
""";
command.Parameters.AddWithValue("$savedAtUtcMs", savedUtc.ToUnixTimeMilliseconds());
command.Parameters.AddWithValue("$expiresAtUtcMs", expiresUtc.ToUnixTimeMilliseconds());
command.Parameters.AddWithValue("$updatedAtUtcMs", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
command.Parameters.AddWithValue("$componentId", componentId);
command.Parameters.AddWithValue("$placementId", placementId);
command.ExecuteNonQuery();
}
public bool Exists(string componentId, string placementId)
{
using var connection = new AppDatabaseService(_databasePath).OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
SELECT COUNT(1)
FROM whiteboard_notes
WHERE component_id = $componentId
AND placement_id = $placementId;
""";
command.Parameters.AddWithValue("$componentId", componentId);
command.Parameters.AddWithValue("$placementId", placementId);
return Convert.ToInt32(command.ExecuteScalar()) > 0;
}
public void Dispose()
{
try
{
if (Directory.Exists(_directoryPath))
{
Directory.Delete(_directoryPath, true);
}
}
catch
{
// Temporary test directories are best-effort cleanup.
}
}
}
}

View File

@@ -67,6 +67,15 @@ public partial class App : Application
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
(Current as App)?._hostApplicationLifecycle;
// 隐私政策查看事件
public static event Action? CurrentPrivacyPolicyViewRequested;
// 触发隐私政策查看事件的方法
public static void RaisePrivacyPolicyViewRequested()
{
CurrentPrivacyPolicyViewRequested?.Invoke();
}
public PluginRuntimeService? PluginRuntimeService => _pluginRuntimeService;
public ISettingsFacadeService SettingsFacade => _settingsFacade;
public IHostApplicationLifecycle HostApplicationLifecycle => _hostApplicationLifecycle;
@@ -501,6 +510,8 @@ public partial class App : Application
if (languageChanged)
{
// 清除本地化缓存,强制重新加载语言文件
_localizationService.ClearCache();
ApplyCurrentCultureFromSettings();
if (_trayIcons is not null)
{

View File

@@ -0,0 +1,326 @@
# LanMountainDesktop 隐私政策
**最后更新日期2026年3月17日**
---
## 引言
欢迎使用 LanMountainDesktop我们非常重视您的隐私保护。本隐私政策旨在向您说明我们如何收集、使用、存储和保护您的数据。
**请在使用本应用前仔细阅读本隐私政策。使用本应用即表示您同意本政策的条款。**
---
## 1. 数据收集范围
### 1.1 我们收集的数据
当您启用匿名数据收集功能时,我们会收集以下数据:
#### 匿名崩溃数据
- **崩溃报告**:应用崩溃时的错误日志和堆栈跟踪
- **设备信息**操作系统版本、设备型号、架构x64/x86
- **应用版本**:当前使用的应用版本号
- **设备标识符**匿名生成的唯一设备ID不包含个人信息
#### 匿名使用数据
- **应用启动和关闭事件**:记录应用何时启动和关闭
- **功能使用统计**:哪些功能被使用、使用频率
- **设置变更**:用户更改了哪些设置(不包含具体设置值)
- **界面交互**:点击了哪些按钮、访问了哪些页面
- **设备信息**:操作系统、应用版本、设备类型
### 1.2 始终收集的基础数据
**重要说明:** 为了统计应用的用户数量和日活跃用户,即使您关闭了匿名数据收集开关,我们仍会收集以下基础数据:
-**应用启动事件**:用于统计日活跃用户
-**设备标识符**:用于区分不同用户(不包含个人信息)
-**应用版本**:用于统计版本分布
**这些基础数据不包含任何个人身份信息,仅用于统计用户数量和应用使用情况。**
### 1.3 我们不收集的数据
我们**明确承诺不收集**以下数据:
- ❌ 个人身份信息(姓名、邮箱、电话等)
- ❌ 真实姓名或用户名
- ❌ 地理位置信息(精确位置)
- ❌ 文件内容或文档数据
- ❌ 密码或凭据信息
- ❌ 网络浏览历史
- ❌ 联系人信息
- ❌ 照片、视频或音频文件
---
## 2. 数据收集目的
我们收集数据的目的如下:
### 2.1 基础数据用途(始终收集)
- **统计用户数量**:了解应用的用户规模
- **统计日活跃用户**:了解应用的活跃程度
- **版本分布统计**:了解用户使用的版本情况
### 2.2 崩溃数据用途
- **提高应用稳定性**:识别和修复崩溃问题
- **优化性能**:分析性能瓶颈
- **改进用户体验**:了解应用在不同设备上的表现
### 2.3 使用数据用途
- **功能优化**:了解哪些功能最受欢迎,优先改进
- **用户体验改进**:优化界面设计和交互流程
- **统计分析**:了解用户规模和使用趋势
- **产品决策**:基于数据做出产品发展方向决策
---
## 3. 数据存储和处理
### 3.1 数据存储位置
我们使用以下第三方服务存储和处理数据:
#### Sentry崩溃报告
- **用途**:崩溃数据收集和分析
- **位置**:美国
- **官网**https://sentry.io
- **隐私政策**https://sentry.io/privacy/
#### PostHog使用分析
- **用途**:用户行为分析和统计
- **位置**:美国
- **官网**https://posthog.com
- **隐私政策**https://posthog.com/privacy
### 3.2 数据保留期限
- **崩溃数据**保留90天后自动删除
- **使用数据**保留12个月后自动删除
- **设备标识符**:永久保留(用于统计日活用户)
### 3.3 数据安全措施
我们采取以下安全措施保护您的数据:
- ✅ 数据传输使用HTTPS加密
- ✅ 数据存储使用加密技术
- ✅ 访问权限严格控制
- ✅ 定期安全审计
---
## 4. 数据共享
### 4.1 我们不会出售您的数据
我们**明确承诺**
- ❌ 不会出售您的个人数据
- ❌ 不会将您的数据用于广告目的
- ❌ 不会与第三方共享可识别个人的数据
### 4.2 必要的共享
我们仅在以下情况下共享数据:
- **服务提供商**与Sentry和PostHog共享数据以提供服务
- **法律要求**:在法律要求或政府机构合法要求时
---
## 5. 您的权利
### 5.1 选择权
您完全控制详细数据收集:
- **匿名崩溃数据**:可在设置中开启或关闭
- **匿名使用数据**:可在设置中开启或关闭
- **基础数据**:始终收集(用于统计用户数量)
**注意:** 即使关闭所有开关,我们仍会收集基础数据(应用启动事件和设备标识符)以统计用户数量。
### 5.2 数据访问权
您可以:
- 查看我们收集的数据类型
- 了解数据的使用目的
- 了解数据的存储位置
### 5.3 数据删除权
您可以:
- 随时关闭详细数据收集功能
- 删除本地存储的设备标识符
- 联系我们删除已收集的数据
---
## 6. 设备标识符
### 6.1 什么是设备标识符?
设备标识符是一个随机生成的唯一字符串,用于:
- 统计日活用户数量
- 区分不同设备
- 分析用户使用趋势
### 6.2 设备标识符的特点
- **匿名性**:不包含任何个人信息
- **随机性**通过SHA256算法生成
- **唯一性**:每个设备有唯一标识符
- **持久性**即使刷新设备ID仍能关联到同一用户
- **可重置**:您可以在设置中刷新设备标识符
### 6.3 设备标识符刷新
当您刷新设备标识符时:
- 生成新的设备ID
- **持久用户ID保持不变**,确保仍能关联到同一用户
- 统计数据不会丢失
### 6.4 设备标识符示例
```
a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
```
---
## 7. 儿童隐私保护
本应用不面向13岁以下儿童。我们不会故意收集儿童的个人信息。如果您发现我们无意中收集了儿童的数据请联系我们我们将立即删除相关数据。
---
## 8. 国际数据传输
由于我们的服务提供商位于美国,您的数据可能会被传输到美国。我们确保:
- 数据传输符合相关法律法规
- 服务提供商遵守GDPR等隐私法规
- 采取适当的安全措施保护数据
---
## 9. 隐私政策更新
我们可能会不时更新本隐私政策。更新时,我们将:
- 在本页面更新"最后更新日期"
- 在应用内通知您重大变更
- 继续使用应用即表示您同意更新后的政策
---
## 10. 联系我们
如果您对本隐私政策有任何疑问或建议,请通过以下方式联系我们:
- **GitHub Issues**https://github.com/wwiinnddyy/LanMountainDesktop/issues
- **电子邮件**[您的邮箱地址]
---
## 11. 法律依据
### 11.1 GDPR合规
如果您位于欧洲经济区EEA我们的数据处理基于
- **同意**:您明确同意数据收集
- **合法利益**:改进应用性能和用户体验
### 11.2 CCPA合规
如果您是加州居民,您有权:
- 知道我们收集了哪些数据
- 要求删除您的数据
- 选择退出数据销售(我们不销售数据)
---
## 12. 第三方链接
本应用可能包含第三方网站链接。我们不对这些网站的隐私政策负责。请阅读这些网站的隐私政策。
---
## 13. 数据收集示例
### 13.1 崩溃报告示例
```json
{
"event_id": "abc123",
"timestamp": "2024-01-01T12:00:00Z",
"device_id": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
"app_version": "1.0.0",
"os_name": "Windows",
"os_version": "10.0.19041",
"error_message": "NullReferenceException",
"stack_trace": "..."
}
```
### 13.2 使用数据示例
```json
{
"event": "app_online",
"timestamp": "2024-01-01T12:00:00Z",
"distinct_id": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
"properties": {
"app_version": "1.0.0",
"os_name": "Windows",
"event_type": "app_start",
"analytics_enabled": true
}
}
```
### 13.3 基础数据示例(始终收集)
```json
{
"event": "$pageview",
"timestamp": "2024-01-01T12:00:00Z",
"distinct_id": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
"properties": {
"$current_url": "app://main",
"$title": "LanMountainDesktop"
}
}
```
---
## 14. 您的同意
使用本应用即表示您:
- ✅ 已阅读并理解本隐私政策
- ✅ 同意我们按照本政策收集和使用数据
- ✅ 了解您可以随时撤回同意(详细数据收集)
- ✅ 了解基础数据将始终收集以统计用户数量
---
## 15. 免责声明
本隐私政策仅适用于 LanMountainDesktop 应用。我们不对以下情况负责:
- 第三方服务的隐私政策
- 您自行分享的数据
- 不可抗力导致的数据泄露
---
**感谢您信任阑山桌面LanMountainDesktop**
我们承诺保护您的隐私,并持续改进我们的隐私保护措施。

View File

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

View File

@@ -336,6 +336,15 @@ public sealed class ComponentRegistry
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopRemovableStorage,
"Removable Storage",
"Storage",
"File",
MinWidthCells: 2,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.Date,
"Calendar",

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
@@ -21,6 +21,9 @@
<ItemGroup>
<Folder Include="Models\" />
<AvaloniaResource Include="Assets\**" />
<AvaloniaResource Include="Localization\**" />
<EmbeddedResource Include="Assets\Documents\Privacy.md" />
<EmbeddedResource Include="Localization\*.json" />
<None Include="Localization\*.json" CopyToOutputDirectory="PreserveNewest" />
<None Include="Extensions\Components\*.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
@@ -52,15 +55,18 @@
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.0" />
<PackageReference Include="LibVLCSharp.Avalonia" Version="3.9.5" />
<PackageReference Include="MudTools.OfficeInterop" Version="2.0.8" />
<PackageReference Include="MudTools.OfficeInterop.Word" Version="2.0.8" />
<PackageReference Include="MudTools.OfficeInterop.Excel" Version="2.0.8" />
<PackageReference Include="MudTools.OfficeInterop.PowerPoint" Version="2.0.8" />
<PackageReference Include="PortAudioSharp2" Version="1.0.6" />
<PackageReference Include="MaterialColorUtilities" Version="0.3.0" />
<PackageReference Include="PostHog" Version="2.4.0" />
<PackageReference Include="Sentry" Version="4.0.0" />
<PackageReference Include="System.Runtime.WindowsRuntime" Version="4.7.0" />
<PackageReference Include="System.Drawing.Common" Version="10.0.0" />
<PackageReference Include="VideoLAN.LibVLC.Windows" Version="3.0.23" Condition="('$(RuntimeIdentifier)' == '' and $([MSBuild]::IsOSPlatform('Windows')))&#xA; or '$(RuntimeIdentifier)' == 'win-x64'&#xA; or '$(RuntimeIdentifier)' == 'win-x86'" />
<PackageReference Include="VideoLAN.LibVLC.Mac" Version="3.1.3.1" Condition="('$(RuntimeIdentifier)' == '' and $([MSBuild]::IsOSPlatform('OSX')))&#xA; or '$(RuntimeIdentifier)' == 'osx-x64'" />
<PackageReference Include="WebView.Avalonia" Version="11.0.0.1" />
<PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />

View File

@@ -7,7 +7,12 @@
"tray.menu.restart": "Restart App",
"tray.menu.exit": "Exit App",
"button.back_to_windows": "Back to Windows",
"button.back_to_platform": "Back to {0}",
"tooltip.back_to_windows": "Back to Windows",
"tooltip.back_to_platform": "Back to {0}",
"platform.windows": "Windows",
"platform.linux": "Linux",
"platform.macos": "macOS",
"tooltip.open_settings": "Settings",
"settings.title": "Settings",
"settings.shell.title": "Settings",
@@ -86,6 +91,8 @@
"settings.status_bar.description": "Choose which components appear on the top status bar.",
"settings.status_bar.clock_header": "Clock Component",
"settings.status_bar.clock_description": "Display a clock on the top status bar.",
"settings.status_bar.clock_transparent_background_label": "Transparent background",
"settings.status_bar.clock_transparent_background_desc": "Remove the capsule background and keep only the clock text.",
"settings.status_bar.spacing_header": "Component Spacing",
"settings.status_bar.spacing_desc": "Adjust spacing between status bar components.",
"settings.status_bar.spacing_mode_compact": "Compact",
@@ -99,6 +106,11 @@
"settings.privacy.crash_upload_description": "Help us improve application stability.",
"settings.privacy.usage_upload_title": "Anonymous usage data uploads",
"settings.privacy.usage_upload_description": "Help us improve application features.",
"settings.privacy.device_id_title": "Device ID",
"settings.privacy.device_id_description": "Unique identifier for this device. Click refresh to regenerate.",
"settings.privacy.refresh_device_id": "Refresh",
"settings.privacy.policy_hint_prefix": "For more details, please ",
"settings.privacy.view_policy": "view our privacy policy",
"settings.weather.title": "Weather",
"settings.weather.description": "Configure weather location, Xiaomi weather preview, and startup positioning behavior.",
"settings.weather.location_source_header": "Location Source",
@@ -398,6 +410,7 @@
"common.monet": "Monet",
"desktop.page_index_format": "Desktop {0}",
"launcher.title": "App Launcher",
"launcher.folder": "Folder",
"launcher.subtitle": "Apps and folders from Windows Start Menu",
"launcher.subtitle_linux": "Installed apps discovered from Linux desktop entries",
"launcher.empty": "No Start Menu entries found.",
@@ -554,6 +567,7 @@
"component_category.info": "Info",
"component_category.calculator": "Calculator",
"component_category.study": "Study",
"component_category.file": "File",
"component.date": "Calendar",
"component.month_calendar": "Month Calendar",
"component.lunar_calendar": "Lunar Calendar",
@@ -582,6 +596,19 @@
"component.blackboard_landscape": "Blackboard (Landscape)",
"component.browser": "Browser",
"component.office_recent_documents": "Recent Documents",
"whiteboard.settings.desc": "Each blackboard keeps its own note history and saves it independently.",
"whiteboard.settings.retention.title": "Note retention",
"whiteboard.settings.retention.desc": "Choose how long this blackboard should keep saved notes before expired data is removed automatically.",
"whiteboard.settings.retention.option": "{0} days",
"whiteboard.settings.instance_scope": "This retention setting is stored per blackboard component instance.",
"office_recent_documents.settings.desc": "Choose which Windows and Office sources this widget should scan for recent documents.",
"office_recent_documents.settings.sources_title": "Recent document sources",
"office_recent_documents.settings.sources_desc": "You can combine multiple sources. Registry selection also keeps the Office interop MRU fallback available.",
"office_recent_documents.settings.source.registry": "Office registry MRU",
"office_recent_documents.settings.source.recent_folders": "Windows Recent folders",
"office_recent_documents.settings.source.jump_lists": "Windows Jump Lists",
"office_recent_documents.settings.hint": "If you disable all sources, this widget will stay empty until at least one source is enabled again.",
"component.removable_storage": "Removable Storage",
"component.holiday_calendar": "Holiday Calendar",
"component.study_environment": "Environment",
"component.study_session_control": "Study Session Control",
@@ -783,6 +810,20 @@
"study.environment.settings.show_display_db": "Show display dB",
"study.environment.settings.show_dbfs": "Show dBFS",
"study.environment.settings.hint": "At least one display mode must stay enabled.",
"removable_storage.settings.desc": "Show a connected USB drive with quick open and eject actions.",
"removable_storage.settings.behavior_title": "Behavior",
"removable_storage.settings.behavior_desc": "The widget automatically watches for removable drives and switches to the newest inserted USB drive.",
"removable_storage.action.open": "Open",
"removable_storage.action.eject": "Eject",
"removable_storage.widget.default_name": "Removable Drive",
"removable_storage.widget.empty_title": "No device inserted",
"removable_storage.widget.empty_subtitle": "Insert a USB drive to show it here.",
"removable_storage.widget.empty_hint": "Buttons stay disabled until a removable device is inserted.",
"removable_storage.widget.ready": "Ready to open or eject.",
"removable_storage.widget.ejecting": "Ejecting drive...",
"removable_storage.widget.eject_failed": "Could not eject this drive. Close any files on it and try again.",
"removable_storage.widget.open_failed": "Failed to open this drive.",
"removable_storage.widget.refresh_failed": "Drive list refresh failed.",
"study.session_control.action.start": "Start Study Session",
"study.session_control.action.stop": "Stop Study Session",
"study.session_control.idle_hint": "Tap the right button to start",
@@ -887,5 +928,7 @@
"placement.tile": "Tile",
"single_instance.notice.title": "App already running",
"single_instance.notice.description": "The app is already running. There is no need to click multiple times to open it.",
"single_instance.notice.button": "OK"
"single_instance.notice.button": "OK",
"market.status.install_success_restart_format": "✓ Plugin '{0}' installed successfully! Please restart the application to activate it.",
"market.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?"
}

View File

@@ -7,7 +7,12 @@
"tray.menu.restart": "重启应用",
"tray.menu.exit": "退出应用",
"button.back_to_windows": "回到Windows",
"button.back_to_platform": "回到{0}",
"tooltip.back_to_windows": "回到Windows",
"tooltip.back_to_platform": "回到{0}",
"platform.windows": "Windows",
"platform.linux": "Linux",
"platform.macos": "macOS",
"tooltip.open_settings": "设置",
"settings.title": "设置",
"settings.shell.title": "设置",
@@ -31,13 +36,14 @@
"settings.nav.plugins": "插件",
"settings.nav.about": "关于",
"settings.wallpaper.title": "壁纸",
"settings.wallpaper.description": "选择图片或视频后可立即设为应用窗口壁纸。",
"settings.wallpaper.description": "选择图片后可立即设为应用窗口壁纸。",
"settings.wallpaper.current_label": "当前壁纸",
"settings.wallpaper.type_label": "壁纸类型",
"settings.wallpaper.type.image": "图片",
"settings.wallpaper.type.video": "视频",
"settings.wallpaper.type.solid_color": "纯色",
"settings.wallpaper.color_label": "壁纸颜色",
"settings.wallpaper.custom_color_tooltip": "自定义颜色",
"settings.wallpaper.custom_color_apply": "应用",
"settings.wallpaper.placement_label": "显示方式",
"settings.wallpaper.placement_desc": "调整图像在桌面上的填充方式。",
"settings.wallpaper.pick_button": "选择文件",
@@ -46,20 +52,14 @@
"settings.wallpaper.storage_unavailable": "存储提供器不可用。",
"settings.wallpaper.import_failed": "导入壁纸文件失败。",
"settings.wallpaper.image_applied": "图片壁纸已应用。",
"settings.wallpaper.video_applied": "视频壁纸已应用。",
"settings.wallpaper.unsupported_file": "所选文件类型不受支持。",
"settings.wallpaper.apply_failed_format": "应用壁纸失败:{0}",
"settings.wallpaper.mode_format": "壁纸模式:{0}。",
"settings.wallpaper.video_mode": "视频壁纸使用自动填充模式。",
"settings.wallpaper.cleared": "背景已恢复为纯色。",
"settings.wallpaper.default_status": "当前使用纯色背景。",
"settings.wallpaper.saved_not_found": "未找到已保存的壁纸文件,已使用纯色背景。",
"settings.wallpaper.restored": "已恢复保存的壁纸。",
"settings.wallpaper.video_restored": "已恢复保存的视频壁纸。",
"settings.wallpaper.restore_failed": "恢复已保存壁纸失败,已使用纯色背景。",
"settings.wallpaper.video_not_found": "未找到视频壁纸文件。",
"settings.wallpaper.video_player_unavailable": "视频播放器不可用。",
"settings.wallpaper.video_play_failed_format": "播放视频壁纸失败:{0}",
"settings.grid.title": "网格布局",
"settings.grid.description": "每个组件至少占用一个格子(最小 1x1。",
"settings.grid.short_side_label": "短边格数",
@@ -85,12 +85,13 @@
"settings.color.theme_ready_format": "主题色已就绪:{0}。",
"settings.color.theme_applied_format": "{0}主题色已应用:{1}。",
"settings.color.theme_updated_wallpaper": "壁纸已更新,莫奈色已刷新。",
"settings.color.theme_updated_video": "视频壁纸已更新,主题色已刷新。",
"settings.color.theme_cleared_wallpaper": "壁纸已清除,莫奈色已刷新。",
"settings.status_bar.title": "状态栏",
"settings.status_bar.description": "选择顶部状态栏显示的组件。",
"settings.status_bar.clock_header": "时间组件",
"settings.status_bar.clock_description": "在顶部状态栏显示时钟。",
"settings.status_bar.clock_transparent_background_label": "透明背景",
"settings.status_bar.clock_transparent_background_desc": "移除胶囊背景,仅保留时钟文字。",
"settings.status_bar.spacing_header": "组件间距",
"settings.status_bar.spacing_desc": "调整状态栏组件之间的间距。",
"settings.status_bar.spacing_mode_compact": "紧凑",
@@ -104,6 +105,11 @@
"settings.privacy.crash_upload_description": "帮助我们提高应用稳定性。",
"settings.privacy.usage_upload_title": "匿名上传使用数据",
"settings.privacy.usage_upload_description": "帮助我们改善应用功能。",
"settings.privacy.device_id_title": "设备标识符",
"settings.privacy.device_id_description": "此设备的唯一标识符。点击刷新以重新生成。",
"settings.privacy.refresh_device_id": "刷新",
"settings.privacy.policy_hint_prefix": "了解更多详情,请",
"settings.privacy.view_policy": "查看我们的隐私政策",
"settings.weather.title": "天气",
"settings.weather.description": "配置天气位置、小米天气预览和启动时的位置刷新行为。",
"settings.weather.location_source_header": "位置来源",
@@ -393,7 +399,6 @@
"settings.footer": "LanMountainDesktop 设置",
"filepicker.title": "选择壁纸",
"filepicker.image_files": "图片文件",
"filepicker.video_files": "视频文件",
"common.day": "日间",
"common.night": "夜间",
"common.back": "返回",
@@ -403,6 +408,7 @@
"common.monet": "莫奈",
"desktop.page_index_format": "桌面 {0}",
"launcher.title": "应用启动台",
"launcher.folder": "文件夹",
"launcher.subtitle": "按 Windows 开始菜单结构显示所有应用与文件夹",
"launcher.subtitle_linux": "显示从 Linux .desktop 条目扫描到的已安装应用",
"launcher.empty": "未找到开始菜单条目。",
@@ -559,6 +565,7 @@
"component_category.info": "信息推荐",
"component_category.calculator": "计算器",
"component_category.study": "自习",
"component_category.file": "文件",
"component.date": "日历",
"component.month_calendar": "月历",
"component.lunar_calendar": "农历",
@@ -587,6 +594,18 @@
"component.blackboard_landscape": "横向小黑板",
"component.browser": "浏览器",
"component.office_recent_documents": "最近文档",
"whiteboard.settings.desc": "每个小黑板都会独立保存自己的笔记历史。",
"whiteboard.settings.retention.title": "笔记保留时间",
"whiteboard.settings.retention.desc": "选择这个小黑板在过期笔记被自动删除前,应当保留已保存笔记多久。",
"whiteboard.settings.retention.option": "{0} 天",
"whiteboard.settings.instance_scope": "这个保留时间设置会按每个小黑板组件实例单独存储。",
"office_recent_documents.settings.desc": "选择此小组件需要扫描的 Windows 和 Office 最近文档来源。",
"office_recent_documents.settings.sources_title": "最近文档来源",
"office_recent_documents.settings.sources_desc": "可以同时选择多个来源。勾选注册表来源时,还会保留 Office interop 的 MRU 回退。",
"office_recent_documents.settings.source.registry": "Office 注册表 MRU",
"office_recent_documents.settings.source.recent_folders": "Windows 最近文件夹",
"office_recent_documents.settings.source.jump_lists": "Windows 跳转列表",
"office_recent_documents.settings.hint": "如果关闭全部来源,此小组件会保持空白,直到再次至少启用一个来源。",
"component.holiday_calendar": "节假日日历",
"component.study_environment": "环境",
"component.study_session_control": "自习时段控制",
@@ -783,6 +802,21 @@
"study.environment.value.unavailable": "--",
"study.environment.value.display_format": "{0:F1} dB",
"study.environment.value.dbfs_format": "{0:F1} dBFS",
"component.removable_storage": "可移动存储",
"removable_storage.settings.desc": "在桌面上显示已连接的 U 盘,并提供打开与弹出操作。",
"removable_storage.settings.behavior_title": "行为",
"removable_storage.settings.behavior_desc": "组件会自动监听可移动存储设备,并优先显示最新插入的 U 盘。",
"removable_storage.action.open": "打开",
"removable_storage.action.eject": "弹出",
"removable_storage.widget.default_name": "可移动磁盘",
"removable_storage.widget.empty_title": "未插入设备",
"removable_storage.widget.empty_subtitle": "插入 U 盘后会自动显示在这里。",
"removable_storage.widget.empty_hint": "在插入可移动设备之前,底部按钮会保持置灰不可点击。",
"removable_storage.widget.ready": "已准备好,可直接打开或弹出。",
"removable_storage.widget.ejecting": "正在弹出设备...",
"removable_storage.widget.eject_failed": "无法弹出该设备,请先关闭正在占用它的文件后再试。",
"removable_storage.widget.open_failed": "打开该设备失败。",
"removable_storage.widget.refresh_failed": "刷新可移动存储列表失败。",
"study.environment.settings.title": "环境组件设置",
"study.environment.settings.desc": "配置右侧实时噪音值显示内容。",
"study.environment.settings.show_display_db": "显示 display dB",
@@ -892,5 +926,7 @@
"placement.tile": "平铺",
"single_instance.notice.title": "应用已经运行",
"single_instance.notice.description": "应用已经运行,无需多次点击打开。",
"single_instance.notice.button": "确定"
"single_instance.notice.button": "确定",
"market.status.install_success_restart_format": "✓ 插件'{0}'安装成功!请重启应用以激活它。",
"market.dialog.restart_message_format": "插件'{0}'已成功安装。\n\n要使用此插件您需要立即重启应用。\n\n是否立即重启"
}

View File

@@ -62,8 +62,6 @@ public sealed class AppSettingsSnapshot
public string AppRenderMode { get; set; } = "Default";
public bool AutoCheckUpdates { get; set; } = true;
public bool IncludePrereleaseUpdates { get; set; }
public bool UploadAnonymousCrashData { get; set; }
@@ -72,6 +70,8 @@ public sealed class AppSettingsSnapshot
public string? DeviceId { get; set; }
public string? PersistentUserId { get; set; }
public string UpdateChannel { get; set; } = "stable";
public string UpdateMode { get; set; } = "download_then_confirm";
@@ -101,6 +101,8 @@ public sealed class AppSettingsSnapshot
public string ClockDisplayFormat { get; set; } = "HourMinuteSecond";
public bool StatusBarClockTransparentBackground { get; set; }
public string StatusBarSpacingMode { get; set; } = "Relaxed";
public int StatusBarCustomSpacingPercent { get; set; } = 12;

View File

@@ -58,12 +58,16 @@ public sealed class ComponentSettingsSnapshot
public int WeatherAutoRefreshIntervalMinutes { get; set; } = 12;
public int WhiteboardNoteRetentionDays { get; set; } = 15;
public bool Stcn24ForumAutoRefreshEnabled { get; set; } = true;
public int Stcn24ForumAutoRefreshIntervalMinutes { get; set; } = 20;
public string Stcn24ForumSourceType { get; set; } = Stcn24ForumSourceTypes.LatestCreated;
public List<string>? OfficeRecentDocumentsEnabledSources { get; set; }
public ComponentSettingsSnapshot Clone()
{
var clone = (ComponentSettingsSnapshot)MemberwiseClone();
@@ -91,6 +95,9 @@ public sealed class ComponentSettingsSnapshot
clone.WorldClockTimeZoneIds = WorldClockTimeZoneIds is { Count: > 0 }
? new List<string>(WorldClockTimeZoneIds)
: [];
clone.OfficeRecentDocumentsEnabledSources = OfficeRecentDocumentsEnabledSources is not null
? new List<string>(OfficeRecentDocumentsEnabledSources)
: null;
return clone;
}

View File

@@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace LanMountainDesktop.Models;
public static class OfficeRecentDocumentSourceTypes
{
public const string Registry = "registry";
public const string RecentFolders = "recent_folders";
public const string JumpLists = "jump_lists";
public static IReadOnlyList<string> SupportedValues { get; } =
[
Registry,
RecentFolders,
JumpLists
];
public static IReadOnlyList<string> DefaultValues => SupportedValues;
public static IReadOnlyList<string> NormalizeValues(IEnumerable<string>? values, bool useDefaultWhenEmpty)
{
if (values is null)
{
return useDefaultWhenEmpty ? DefaultValues : Array.Empty<string>();
}
var normalized = values
.Select(NormalizeValue)
.OfType<string>()
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (normalized.Length == 0 && useDefaultWhenEmpty)
{
return DefaultValues;
}
return normalized;
}
private static string? NormalizeValue(string? value)
{
return value?.Trim().ToLowerInvariant() switch
{
Registry => Registry,
RecentFolders => RecentFolders,
JumpLists => JumpLists,
_ => null
};
}
}

View File

@@ -0,0 +1,23 @@
namespace LanMountainDesktop.Models;
public static class WhiteboardNoteRetentionPolicy
{
public const int MinimumDays = 7;
public const int MaximumDays = 15;
public const int DefaultDays = MaximumDays;
public static int NormalizeDays(int days)
{
if (days < MinimumDays)
{
return MinimumDays;
}
if (days > MaximumDays)
{
return MaximumDays;
}
return days;
}
}

View File

@@ -0,0 +1,60 @@
using System;
using System.Collections.Generic;
namespace LanMountainDesktop.Models;
public sealed class WhiteboardNoteSnapshot
{
public int Version { get; set; } = 1;
public DateTimeOffset SavedUtc { get; set; }
public List<WhiteboardStrokeSnapshot> Strokes { get; set; } = [];
public WhiteboardNoteSnapshot Clone()
{
var clone = (WhiteboardNoteSnapshot)MemberwiseClone();
clone.Strokes = Strokes is { Count: > 0 }
? new List<WhiteboardStrokeSnapshot>(Strokes.ConvertAll(stroke => stroke?.Clone() ?? new WhiteboardStrokeSnapshot()))
: [];
return clone;
}
}
public sealed class WhiteboardStrokeSnapshot
{
public string Color { get; set; } = "#FF000000";
public double InkThickness { get; set; } = 2.5d;
public bool IgnorePressure { get; set; } = true;
public List<WhiteboardStylusPointSnapshot> Points { get; set; } = [];
public WhiteboardStrokeSnapshot Clone()
{
var clone = (WhiteboardStrokeSnapshot)MemberwiseClone();
clone.Points = Points is { Count: > 0 }
? new List<WhiteboardStylusPointSnapshot>(Points.ConvertAll(point => point?.Clone() ?? new WhiteboardStylusPointSnapshot()))
: [];
return clone;
}
}
public sealed class WhiteboardStylusPointSnapshot
{
public double X { get; set; }
public double Y { get; set; }
public double Pressure { get; set; } = 0.5d;
public double Width { get; set; }
public double Height { get; set; }
public WhiteboardStylusPointSnapshot Clone()
{
return (WhiteboardStylusPointSnapshot)MemberwiseClone();
}
}

View File

@@ -43,6 +43,7 @@ sealed class Program
var diagnostics = StartupDiagnosticsService.Run(args);
StartupDiagnosticsService.ShowLegacyExecutableWarningIfNeeded(diagnostics);
ScheduleWhiteboardNoteStartupCleanup();
try
{
@@ -88,6 +89,25 @@ sealed class Program
return builder;
}
private static void ScheduleWhiteboardNoteStartupCleanup()
{
_ = Task.Run(() =>
{
try
{
var deletedCount = new WhiteboardNotePersistenceService().DeleteExpiredNotesBatch(batchSize: 512);
if (deletedCount > 0)
{
AppLogger.Info("Startup", $"Deleted {deletedCount} expired whiteboard notes during startup maintenance.");
}
}
catch (Exception ex)
{
AppLogger.Warn("Startup", "Failed to run whiteboard note startup maintenance.", ex);
}
});
}
private static SingleInstanceService AcquireSingleInstance(int? restartParentProcessId)
{
var singleInstance = SingleInstanceService.CreateDefault();

View File

@@ -29,6 +29,16 @@ public sealed class AppDatabaseService
_databasePath = Path.Combine(dataDirectory, "app.db");
}
public AppDatabaseService(string databasePath)
{
if (string.IsNullOrWhiteSpace(databasePath))
{
throw new ArgumentException("Database path cannot be null or whitespace.", nameof(databasePath));
}
_databasePath = databasePath;
}
public SqliteConnection OpenConnection()
{
var directory = Path.GetDirectoryName(_databasePath);

View File

@@ -15,7 +15,6 @@ using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Theme;
using LibVLCSharp.Shared;
using Microsoft.Win32;
namespace LanMountainDesktop.Services;
@@ -89,11 +88,6 @@ internal interface IMaterialSurfaceService
AppearanceMaterialSurface GetSurface(ThemeColorContext context, MaterialSurfaceRole role);
}
internal interface IVideoWallpaperSeedExtractor
{
IReadOnlyList<Color> ExtractSeedCandidates(string videoPath, MonetColorService monetColorService);
}
internal readonly record struct WallpaperSeedSourceDescriptor(
string SourceKind,
string SourceKey,
@@ -114,75 +108,6 @@ internal readonly record struct WallpaperPaletteResolution(
Color EffectiveSeedColor,
string? ResolvedWallpaperPath);
internal sealed class LibVlcVideoWallpaperSeedExtractor : IVideoWallpaperSeedExtractor
{
public IReadOnlyList<Color> ExtractSeedCandidates(string videoPath, MonetColorService monetColorService)
{
if (string.IsNullOrWhiteSpace(videoPath) || !File.Exists(videoPath))
{
return [];
}
var snapshotPath = Path.Combine(
Path.GetTempPath(),
$"lanmountaindesktop-video-seed-{Guid.NewGuid():N}.png");
try
{
using var libVlc = new LibVLC("--no-audio", "--intf=dummy", "--no-video-title-show");
using var media = new Media(libVlc, new Uri(videoPath));
using var mediaPlayer = new MediaPlayer(libVlc)
{
Media = media
};
mediaPlayer.Play();
var stopwatch = Stopwatch.StartNew();
while (stopwatch.Elapsed < TimeSpan.FromSeconds(5))
{
Thread.Sleep(180);
if (!mediaPlayer.TakeSnapshot(0, snapshotPath, 320, 180))
{
continue;
}
var fileInfo = new FileInfo(snapshotPath);
if (!fileInfo.Exists || fileInfo.Length <= 0)
{
continue;
}
using var bitmap = new Bitmap(snapshotPath);
return monetColorService.ExtractSeedCandidates(bitmap);
}
}
catch (Exception ex)
{
AppLogger.Warn(
"Appearance.VideoWallpaperPalette",
$"Failed to extract wallpaper seed candidates from video '{videoPath}'.",
ex);
}
finally
{
try
{
if (File.Exists(snapshotPath))
{
File.Delete(snapshotPath);
}
}
catch
{
// Best effort cleanup only.
}
}
return [];
}
}
internal sealed class SystemWallpaperService : ISystemWallpaperService
{
public bool IsSupported => OperatingSystem.IsWindows();
@@ -477,7 +402,6 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
private readonly ISystemWallpaperService _systemWallpaperService;
private readonly IWindowMaterialService _windowMaterialService;
private readonly IMaterialSurfaceService _materialSurfaceService;
private readonly IVideoWallpaperSeedExtractor _videoWallpaperSeedExtractor;
private readonly MonetColorService _monetColorService = new();
private readonly string _liveThemeColorMode;
private readonly string _liveSystemMaterialMode;
@@ -490,14 +414,12 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
ISettingsFacadeService settingsFacade,
ISystemWallpaperService systemWallpaperService,
IWindowMaterialService windowMaterialService,
IMaterialSurfaceService materialSurfaceService,
IVideoWallpaperSeedExtractor? videoWallpaperSeedExtractor = null)
IMaterialSurfaceService materialSurfaceService)
{
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
_systemWallpaperService = systemWallpaperService ?? throw new ArgumentNullException(nameof(systemWallpaperService));
_windowMaterialService = windowMaterialService ?? throw new ArgumentNullException(nameof(windowMaterialService));
_materialSurfaceService = materialSurfaceService ?? throw new ArgumentNullException(nameof(materialSurfaceService));
_videoWallpaperSeedExtractor = videoWallpaperSeedExtractor ?? new LibVlcVideoWallpaperSeedExtractor();
var initialThemeState = _settingsFacade.Theme.Get();
_liveThemeColorMode = ThemeAppearanceValues.NormalizeThemeColorMode(
initialThemeState.ThemeColorMode,
@@ -886,7 +808,6 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
IReadOnlyList<Color> seedCandidates = source.SourceKind switch
{
"app_wallpaper" or "system_wallpaper" => ExtractImageSeedCandidates(source.FilePath),
"app_video" => ExtractVideoSeedCandidates(source.FilePath),
"app_solid" when source.SolidColor is { } solidColor => new[] { solidColor },
_ => []
};
@@ -920,16 +841,6 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
}
}
private IReadOnlyList<Color> ExtractVideoSeedCandidates(string? wallpaperPath)
{
if (string.IsNullOrWhiteSpace(wallpaperPath) || !File.Exists(wallpaperPath))
{
return [];
}
return _videoWallpaperSeedExtractor.ExtractSeedCandidates(wallpaperPath, _monetColorService);
}
private WallpaperSeedSourceDescriptor ResolveWallpaperSeedSource(WallpaperSettingsState wallpaperState)
{
if (string.Equals(wallpaperState.Type, "SolidColor", StringComparison.OrdinalIgnoreCase) &&
@@ -960,16 +871,6 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
wallpaperPath,
null);
}
if (appWallpaperMediaType == WallpaperMediaType.Video)
{
return new WallpaperSeedSourceDescriptor(
"app_video",
CreateWallpaperSourceKey("app_video", wallpaperPath),
wallpaperPath,
wallpaperPath,
null);
}
}
var systemWallpaper = _systemWallpaperService.GetWallpaperPath();

View File

@@ -106,6 +106,8 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
public void DeleteForComponent(string componentId, string? placementId)
{
_ = new WhiteboardNotePersistenceService().DeleteNote(componentId, placementId);
if (_settingsService is not null)
{
_settingsService.SaveSnapshot(

View File

@@ -15,6 +15,7 @@ public sealed class DeviceIdService
{
private static DeviceIdService? _instance;
private string? _deviceId;
private string? _persistentUserId; // 持久化的用户ID用于关联设备
private readonly ISettingsFacadeService _settingsFacade;
private bool _isInitialized;
@@ -43,6 +44,19 @@ public sealed class DeviceIdService
}
}
// 持久化的用户ID用于跨设备关联用户
public string PersistentUserId
{
get
{
if (_persistentUserId is null)
{
throw new InvalidOperationException("PersistentUserId not initialized");
}
return _persistentUserId;
}
}
private void EnsureDeviceId()
{
if (_isInitialized)
@@ -56,13 +70,22 @@ public sealed class DeviceIdService
{
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
// 初始化或生成持久化用户ID只生成一次永不改变
if (string.IsNullOrEmpty(snapshot.PersistentUserId))
{
snapshot.PersistentUserId = GeneratePersistentUserId();
AppLogger.Info("DeviceId", $"Generated new persistent user ID: {snapshot.PersistentUserId}");
}
_persistentUserId = snapshot.PersistentUserId;
// 初始化或生成设备ID可以刷新
if (string.IsNullOrEmpty(snapshot.DeviceId))
{
snapshot.DeviceId = GenerateDeviceId();
_settingsFacade.Settings.SaveSnapshot(
SettingsScope.App,
snapshot,
changedKeys: [nameof(AppSettingsSnapshot.DeviceId)]);
changedKeys: [nameof(AppSettingsSnapshot.DeviceId), nameof(AppSettingsSnapshot.PersistentUserId)]);
_deviceId = snapshot.DeviceId;
AppLogger.Info("DeviceId", $"Generated new device ID: {_deviceId}");
}
@@ -75,6 +98,7 @@ public sealed class DeviceIdService
catch (Exception ex)
{
_deviceId = GenerateDeviceId();
_persistentUserId = GeneratePersistentUserId();
AppLogger.Warn("DeviceId", $"Failed to persist device ID, using generated ID: {_deviceId}", ex);
}
}
@@ -87,6 +111,15 @@ public sealed class DeviceIdService
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(deviceInfo));
return Convert.ToHexString(hash)[..32].ToLower();
}
private static string GeneratePersistentUserId()
{
// 生成一个永久性的用户ID基于机器名和用户名的哈希
var userInfo = $"{Environment.MachineName}|{Environment.UserName}|LanMountainDesktop";
using var sha = System.Security.Cryptography.SHA256.Create();
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(userInfo));
return Convert.ToHexString(hash)[..32].ToLower();
}
}
public sealed class UserBehaviorAnalyticsService : IDisposable
@@ -140,9 +173,18 @@ public sealed class UserBehaviorAnalyticsService : IDisposable
TimeSpan.FromSeconds(10),
TimeSpan.FromSeconds(30));
// 发送PostHog标准的$pageview事件用于统计日活始终发送不受开关影响
CaptureEvent("$pageview", new Dictionary<string, object>
{
{ "$current_url", "app://main" },
{ "$title", "LanMountainDesktop" }
});
// 发送应用启动事件(始终发送,用于统计用户数量)
CaptureEvent("app_online", new Dictionary<string, object>
{
{ "event_type", "app_start" }
{ "event_type", "app_start" },
{ "analytics_enabled", _isEnabled }
});
AppLogger.Info("UserBehaviorAnalytics", $"Analytics initialized. DeviceId={_deviceIdService.DeviceId}, Enabled={_isEnabled}");
@@ -382,10 +424,22 @@ public sealed class UserBehaviorAnalyticsService : IDisposable
try
{
// 基础事件($pageview, app_online, app_shutdown等始终发送用于统计用户数量
bool isBasicEvent = eventName.StartsWith("$") ||
eventName == "app_online" ||
eventName == "app_shutdown" ||
eventName == "$identify";
// 非基础事件只有在启用时才发送
if (!isBasicEvent && !_isEnabled)
{
return;
}
var eventData = new UserBehaviorEvent
{
Event = eventName,
DistinctId = _deviceIdService.DeviceId,
DistinctId = _deviceIdService.PersistentUserId, // 使用持久化用户ID
Timestamp = DateTimeOffset.UtcNow,
Properties = properties ?? new Dictionary<string, object>(),
IncludeDetailedData = _isEnabled
@@ -542,21 +596,29 @@ public sealed class UserBehaviorAnalyticsService : IDisposable
{
var userProperties = new Dictionary<string, object>
{
{ "$device_id", distinctId },
{ "$app_version", GetAppVersion() },
{ "$os", GetOsName() },
{ "$os_version", GetOsVersion() }
{ "$os_version", GetOsVersion() },
{ "$device_type", GetDeviceModel() },
{ "$device_id", _deviceIdService.DeviceId } // 当前设备ID
};
// PostHog正确的$identify格式
// 使用PersistentUserId作为distinct_id确保设备ID刷新后仍能关联到同一用户
var requestBody = new Dictionary<string, object>
{
{ "api_key", PostHogApiKey },
{ "event", "$identify" },
{ "distinct_id", _deviceIdService.PersistentUserId }, // 使用持久化用户ID
{ "timestamp", DateTimeOffset.UtcNow.ToString("o") },
{ "properties", new Dictionary<string, object>
{
{ "distinct_id", distinctId },
{ "$set", userProperties }
{ "$set", userProperties },
{ "$set_once", new Dictionary<string, object>
{
{ "first_app_open", DateTimeOffset.UtcNow.ToString("o") }
}
}
}
}
};
@@ -796,6 +858,9 @@ public sealed class CrashReportService
});
ConfigureCrashReportingScope();
// 显式开始会话跟踪
SentrySdk.StartSession();
AppLogger.Info("CrashReport", $"Sentry crash reporting initialized. DeviceId={_deviceIdService.DeviceId}");
@@ -849,7 +914,10 @@ public sealed class CrashReportService
{
if (_isEnabled && _isInitialized)
{
AppLogger.Info("CrashReport", $"Shutdown event will be sent via Sentry. DeviceId={_deviceIdService.DeviceId}");
// 结束Sentry会话
SentrySdk.EndSession();
SentrySdk.Flush(TimeSpan.FromSeconds(3));
AppLogger.Info("CrashReport", $"Shutdown event sent via Sentry. DeviceId={_deviceIdService.DeviceId}");
return;
}

View File

@@ -72,6 +72,18 @@ public static class DesktopComponentEditorRegistryFactory
[BuiltInComponentIds.DesktopStudyEnvironment] = new(
BuiltInComponentIds.DesktopStudyEnvironment,
context => new StudyEnvironmentComponentEditor(context)),
[BuiltInComponentIds.DesktopRemovableStorage] = new(
BuiltInComponentIds.DesktopRemovableStorage,
context => new RemovableStorageComponentEditor(context)),
[BuiltInComponentIds.DesktopWhiteboard] = new(
BuiltInComponentIds.DesktopWhiteboard,
context => new WhiteboardComponentEditor(context)),
[BuiltInComponentIds.DesktopBlackboardLandscape] = new(
BuiltInComponentIds.DesktopBlackboardLandscape,
context => new WhiteboardComponentEditor(context)),
[BuiltInComponentIds.DesktopOfficeRecentDocuments] = new(
BuiltInComponentIds.DesktopOfficeRecentDocuments,
context => new OfficeRecentDocumentsComponentEditor(context)),
[BuiltInComponentIds.DesktopWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopWeather),
[BuiltInComponentIds.DesktopWeatherClock] = CreateWeatherRegistration(BuiltInComponentIds.DesktopWeatherClock),
[BuiltInComponentIds.DesktopHourlyWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopHourlyWeather),

View File

@@ -0,0 +1,19 @@
using System;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Services;
public interface IWhiteboardNotePersistenceService
{
WhiteboardNoteSnapshot LoadNote(string componentId, string? placementId, int retentionDays);
void SaveNote(string componentId, string? placementId, WhiteboardNoteSnapshot snapshot, int retentionDays);
bool DeleteNote(string componentId, string? placementId);
bool TryDeleteExpiredNote(string componentId, string? placementId, int retentionDays);
bool IsExpired(WhiteboardNoteSnapshot snapshot, int retentionDays, DateTimeOffset? now = null);
DateTimeOffset? GetExpirationUtc(WhiteboardNoteSnapshot snapshot, int retentionDays);
}

View File

@@ -1,6 +1,7 @@
using System;
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Text.Json;
namespace LanMountainDesktop.Services;
@@ -16,6 +17,23 @@ public sealed class LocalizationService
private readonly Dictionary<string, Dictionary<string, string>> _cache =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// 清除指定语言代码的缓存,强制下次重新加载。
/// 在语言切换时调用此方法以确保加载最新的语言文件。
/// </summary>
public void ClearCache(string? languageCode = null)
{
if (string.IsNullOrWhiteSpace(languageCode))
{
_cache.Clear();
}
else
{
var normalizedCode = NormalizeLanguageCode(languageCode);
_cache.Remove(normalizedCode);
}
}
public string NormalizeLanguageCode(string? languageCode)
{
return string.Equals(languageCode, "en-US", StringComparison.OrdinalIgnoreCase)
@@ -42,14 +60,17 @@ public sealed class LocalizationService
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
try
{
var filePath = Path.Combine(AppContext.BaseDirectory, "Localization", $"{languageCode}.json");
if (File.Exists(filePath))
var json = TryLoadFromFileSystem(languageCode);
if (string.IsNullOrEmpty(json))
{
json = TryLoadFromEmbeddedResource(languageCode);
}
if (!string.IsNullOrEmpty(json))
{
var json = File.ReadAllText(filePath);
// Defensive: tolerate accidentally duplicated UTF-8 BOM characters at file start.
json = json.TrimStart('\uFEFF');
var data = JsonSerializer.Deserialize<Dictionary<string, string>>(json, JsonOptions);
if (data is not null)
if (data is not null && data.Count > 0)
{
result = new Dictionary<string, string>(data, StringComparer.OrdinalIgnoreCase);
}
@@ -60,7 +81,48 @@ public sealed class LocalizationService
// Keep empty table for resilience.
}
_cache[languageCode] = result;
// 只有当语言表非空时才缓存,这样如果加载失败可以下次重试
if (result.Count > 0)
{
_cache[languageCode] = result;
}
return result;
}
private string? TryLoadFromFileSystem(string languageCode)
{
try
{
var filePath = Path.Combine(AppContext.BaseDirectory, "Localization", $"{languageCode}.json");
if (File.Exists(filePath))
{
return File.ReadAllText(filePath);
}
}
catch
{
// Continue to next method
}
return null;
}
private string? TryLoadFromEmbeddedResource(string languageCode)
{
try
{
var assembly = Assembly.GetExecutingAssembly();
var resourceName = $"LanMountainDesktop.Localization.{languageCode}.json";
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream != null)
{
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
}
catch
{
// Continue to next method
}
return null;
}
}

View File

@@ -1,18 +1,25 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Text;
using System.Text.Json;
using LanMountainDesktop.Services.Settings;
using System.Text.RegularExpressions;
using System.Threading;
using LanMountainDesktop.Models;
using Microsoft.Win32;
using MudTools.OfficeInterop;
using MudTools.OfficeInterop.Excel;
using MudTools.OfficeInterop.Word;
namespace LanMountainDesktop.Services;
public interface IOfficeRecentDocumentsService
{
List<OfficeRecentDocument> GetRecentDocuments(int maxCount = 20);
List<OfficeRecentDocument> GetRecentDocuments(int maxCount = 20, IReadOnlyCollection<string>? enabledSources = null);
void OpenDocument(string filePath);
}
@@ -24,78 +31,67 @@ public sealed class OfficeRecentDocument
public DateTime LastModifiedTime { get; set; }
public long FileSizeBytes { get; set; }
public string IconGlyph { get; set; } = string.Empty;
internal DateTime? RecentAccessTime { get; set; }
internal int SourcePriority { get; set; }
internal int SourceOrder { get; set; }
}
public sealed class OfficeRecentDocumentsService : IOfficeRecentDocumentsService
{
private const string LogCategory = "OfficeRecentDocs";
private static readonly string[] OfficeExtensions = { ".doc", ".docx", ".dot", ".dotx", ".rtf" };
private static readonly string[] ExcelExtensions = { ".xls", ".xlsx", ".xlsm", ".xlsb", ".csv" };
private static readonly string[] PowerPointExtensions = { ".ppt", ".pptx", ".pptm", ".pps", ".ppsx" };
private static readonly Regex OfficeFilePathRegex = new(
@"(?:[A-Z]:\\|\\\\)[^\x00-\x1F""<>|]+?\.(?:docx?|dotx?|rtf|xlsx?|xlsm|xlsb|csv|pptx?|pptm|ppsx?)",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex OfficeMruTimestampRegex = new(
@"\[T(?<filetime>[0-9A-F]+)\]",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
public List<OfficeRecentDocument> GetRecentDocuments(int maxCount = 20)
public List<OfficeRecentDocument> GetRecentDocuments(int maxCount = 20, IReadOnlyCollection<string>? enabledSources = null)
{
var documents = new List<OfficeRecentDocument>();
var recentPaths = GetRecentFolders();
var normalizedSources = OfficeRecentDocumentSourceTypes.NormalizeValues(
enabledSources,
useDefaultWhenEmpty: enabledSources is null);
foreach (var recentPath in recentPaths)
if (!OperatingSystem.IsWindows() || normalizedSources.Count == 0)
{
if (!Directory.Exists(recentPath))
{
continue;
}
return documents;
}
try
{
var files = Directory.GetFiles(recentPath, "*.lnk");
foreach (var lnkPath in files)
{
var targetPath = GetShortcutTarget(lnkPath);
if (string.IsNullOrEmpty(targetPath))
{
continue;
}
var useRegistry = normalizedSources.Contains(OfficeRecentDocumentSourceTypes.Registry, StringComparer.OrdinalIgnoreCase);
var useRecentFolders = normalizedSources.Contains(OfficeRecentDocumentSourceTypes.RecentFolders, StringComparer.OrdinalIgnoreCase);
var useJumpLists = normalizedSources.Contains(OfficeRecentDocumentSourceTypes.JumpLists, StringComparer.OrdinalIgnoreCase);
var extension = Path.GetExtension(targetPath).ToLowerInvariant();
if (!IsOfficeFile(extension))
{
continue;
}
if (useRegistry)
{
TryGetFromRegistry(documents);
}
if (!System.IO.File.Exists(targetPath))
{
continue;
}
if (useRecentFolders)
{
TryGetFromRecentFolders(documents);
}
try
{
var fileInfo = new FileInfo(targetPath);
var doc = new OfficeRecentDocument
{
FileName = Path.GetFileNameWithoutExtension(targetPath),
FilePath = targetPath,
Extension = extension,
LastModifiedTime = fileInfo.LastWriteTime,
FileSizeBytes = fileInfo.Length,
IconGlyph = GetIconGlyph(extension)
};
if (useJumpLists)
{
TryGetFromJumpLists(documents);
}
if (!documents.Any(d => d.FilePath == targetPath))
{
documents.Add(doc);
}
}
catch
{
}
}
}
catch
{
}
if (useRegistry && documents.Count < maxCount)
{
TryGetFromMudToolsInterop(documents);
}
return documents
.OrderByDescending(d => d.LastModifiedTime)
.GroupBy(d => d.FilePath, StringComparer.OrdinalIgnoreCase)
.Select(MergeDocuments)
.OrderByDescending(d => d.RecentAccessTime ?? DateTime.MinValue)
.ThenBy(d => d.SourcePriority)
.ThenBy(d => d.SourceOrder)
.ThenByDescending(d => d.LastModifiedTime)
.Take(maxCount)
.ToList();
}
@@ -109,30 +105,587 @@ public sealed class OfficeRecentDocumentsService : IOfficeRecentDocumentsService
FileName = filePath,
UseShellExecute = true
};
Process.Start(startInfo);
}
catch (Exception ex)
{
AppLogger.Warn(LogCategory, $"Failed to open Office document '{filePath}'.", ex);
}
}
private static OfficeRecentDocument MergeDocuments(IGrouping<string, OfficeRecentDocument> group)
{
var preferred = group
.OrderByDescending(d => d.RecentAccessTime ?? DateTime.MinValue)
.ThenBy(d => d.SourcePriority)
.ThenBy(d => d.SourceOrder)
.ThenByDescending(d => d.LastModifiedTime)
.First();
return new OfficeRecentDocument
{
FileName = preferred.FileName,
FilePath = preferred.FilePath,
Extension = preferred.Extension,
LastModifiedTime = group.Max(d => d.LastModifiedTime),
FileSizeBytes = preferred.FileSizeBytes,
IconGlyph = preferred.IconGlyph,
RecentAccessTime = group
.Where(d => d.RecentAccessTime.HasValue)
.Select(d => d.RecentAccessTime)
.Max(),
SourcePriority = preferred.SourcePriority,
SourceOrder = preferred.SourceOrder
};
}
[SupportedOSPlatform("windows")]
private void TryGetFromMudToolsInterop(List<OfficeRecentDocument> documents)
{
try
{
RunOnStaThread(() =>
{
var sourceOrder = 0;
TryGetFromWordInterop(documents, ref sourceOrder);
TryGetFromExcelInterop(documents, ref sourceOrder);
});
}
catch (Exception ex)
{
AppLogger.Warn(LogCategory, "MudTools.OfficeInterop recent-document read failed.", ex);
}
}
[SupportedOSPlatform("windows")]
private void TryGetFromWordInterop(List<OfficeRecentDocument> documents, ref int sourceOrder)
{
if (!TryGetOfficeApplication("Word.Application", out var comObject, out var createdNew))
{
return;
}
object? application = null;
try
{
application = WordFactory.Connection(comObject!);
if (createdNew)
{
TrySetProperty(comObject, "Visible", false);
TrySetProperty(application, "DisplayAlerts", WdAlertLevel.wdAlertsNone);
}
AddInteropRecentFiles(documents, GetPropertyValue(application, "RecentFiles"), 0, ref sourceOrder);
}
catch (Exception ex)
{
AppLogger.Warn(LogCategory, "Failed to read Word recent files via MudTools.OfficeInterop.", ex);
}
finally
{
CleanupOfficeApplication(application, comObject, createdNew);
}
}
[SupportedOSPlatform("windows")]
private void TryGetFromExcelInterop(List<OfficeRecentDocument> documents, ref int sourceOrder)
{
if (!TryGetOfficeApplication("Excel.Application", out var comObject, out var createdNew))
{
return;
}
object? application = null;
try
{
application = ExcelFactory.Connection(comObject!);
if (createdNew)
{
TrySetProperty(comObject, "Visible", false);
TrySetProperty(application, "DisplayAlerts", false);
}
AddInteropRecentFiles(documents, GetPropertyValue(application, "RecentFiles"), 0, ref sourceOrder);
}
catch (Exception ex)
{
AppLogger.Warn(LogCategory, "Failed to read Excel recent files via MudTools.OfficeInterop.", ex);
}
finally
{
CleanupOfficeApplication(application, comObject, createdNew);
}
}
private void AddInteropRecentFiles(
List<OfficeRecentDocument> documents,
object? recentFiles,
int sourcePriority,
ref int sourceOrder)
{
if (recentFiles == null)
{
return;
}
var count = GetIntProperty(recentFiles, "Count");
var itemProperty = recentFiles.GetType().GetProperty("Item");
if (count <= 0 || itemProperty == null)
{
return;
}
for (var index = 1; index <= count; index++)
{
try
{
var recentFile = itemProperty.GetValue(recentFiles, new object[] { index });
var filePath = GetStringProperty(recentFile, "Path");
AddDocumentIfExists(documents, filePath, sourcePriority, sourceOrder++, null);
}
catch
{
// Ignore a single malformed MRU entry and keep processing the rest.
}
}
}
[SupportedOSPlatform("windows")]
private static bool TryGetOfficeApplication(string progId, out object? comObject, out bool createdNew)
{
comObject = null;
createdNew = false;
var applicationType = Type.GetTypeFromProgID(progId, throwOnError: false);
if (applicationType == null)
{
return false;
}
try
{
comObject = Activator.CreateInstance(applicationType);
createdNew = comObject != null;
return comObject != null;
}
catch
{
return false;
}
}
[SupportedOSPlatform("windows")]
private static void CleanupOfficeApplication(object? application, object? comObject, bool createdNew)
{
try
{
if (createdNew && application != null)
{
InvokeParameterlessMethod(application, "Quit");
}
}
catch
{
}
try
{
if (application is IDisposable disposable)
{
disposable.Dispose();
}
}
catch
{
}
ReleaseComObject(application);
if (!ReferenceEquals(application, comObject))
{
ReleaseComObject(comObject);
}
}
[SupportedOSPlatform("windows")]
private static void ReleaseComObject(object? instance)
{
if (instance == null || !Marshal.IsComObject(instance))
{
return;
}
try
{
Marshal.FinalReleaseComObject(instance);
}
catch
{
}
}
private static List<string> GetRecentFolders()
[SupportedOSPlatform("windows")]
private static void RunOnStaThread(Action action)
{
var folders = new List<string>();
Exception? exception = null;
using var finished = new ManualResetEventSlim();
var thread = new Thread(() =>
{
try
{
action();
}
catch (Exception ex)
{
exception = ex;
}
finally
{
finished.Set();
}
});
thread.IsBackground = true;
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
finished.Wait();
if (exception != null)
{
throw new InvalidOperationException("Failed to run Office interop on STA thread.", exception);
}
}
[SupportedOSPlatform("windows")]
private void TryGetFromRegistry(List<OfficeRecentDocument> documents)
{
try
{
using var officeRoot = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Office");
if (officeRoot == null)
{
return;
}
var versions = officeRoot
.GetSubKeyNames()
.Where(IsOfficeVersionKey)
.OrderByDescending(ParseVersionKey)
.ToList();
var sourceOrder = 0;
foreach (var version in versions)
{
TryGetFromRegistryApp(documents, version, "Word", ref sourceOrder);
TryGetFromRegistryApp(documents, version, "Excel", ref sourceOrder);
TryGetFromRegistryApp(documents, version, "PowerPoint", ref sourceOrder);
}
}
catch (Exception ex)
{
AppLogger.Warn(LogCategory, "Failed to read Office MRU entries from the registry.", ex);
}
}
[SupportedOSPlatform("windows")]
private void TryGetFromRegistryApp(List<OfficeRecentDocument> documents, string version, string appName, ref int sourceOrder)
{
TryGetFromRegistryMruKey(documents, $@"Software\Microsoft\Office\{version}\{appName}\File MRU", ref sourceOrder);
using var userMruRoot = Registry.CurrentUser.OpenSubKey($@"Software\Microsoft\Office\{version}\{appName}\User MRU");
if (userMruRoot == null)
{
return;
}
foreach (var identityKey in userMruRoot.GetSubKeyNames())
{
TryGetFromRegistryMruKey(
documents,
$@"Software\Microsoft\Office\{version}\{appName}\User MRU\{identityKey}\File MRU",
ref sourceOrder);
}
}
[SupportedOSPlatform("windows")]
private void TryGetFromRegistryMruKey(List<OfficeRecentDocument> documents, string registryPath, ref int sourceOrder)
{
using var key = Registry.CurrentUser.OpenSubKey(registryPath);
if (key == null)
{
return;
}
var entries = key
.GetValueNames()
.Where(name => name.StartsWith("Item ", StringComparison.OrdinalIgnoreCase))
.Select(name => new
{
Name = name,
Order = ParseMruItemOrder(name),
Value = key.GetValue(name) as string
})
.Where(entry => !string.IsNullOrWhiteSpace(entry.Value))
.OrderBy(entry => entry.Order);
foreach (var entry in entries)
{
var (filePath, recentAccessTime) = ParseOfficeMruValue(entry.Value!);
AddDocumentIfExists(documents, filePath, 1, sourceOrder++, recentAccessTime);
}
}
private void TryGetFromRecentFolders(List<OfficeRecentDocument> documents)
{
try
{
var linkFiles = GetRecentFolders()
.Where(Directory.Exists)
.SelectMany(path => Directory.EnumerateFiles(path, "*.lnk"))
.Select(path => new FileInfo(path))
.OrderByDescending(info => info.LastWriteTimeUtc)
.ToList();
var sourceOrder = 0;
foreach (var linkFile in linkFiles)
{
var targetPath = GetShortcutTarget(linkFile.FullName);
AddDocumentIfExists(documents, targetPath, 2, sourceOrder++, linkFile.LastWriteTime);
}
}
catch (Exception ex)
{
AppLogger.Warn(LogCategory, "Failed to read Windows Recent shortcut folders.", ex);
}
}
private void TryGetFromJumpLists(List<OfficeRecentDocument> documents)
{
try
{
var jumpListFiles = GetJumpListFolders()
.Where(Directory.Exists)
.SelectMany(path => Directory.EnumerateFiles(path, "*.automaticDestinations-ms")
.Concat(Directory.EnumerateFiles(path, "*.customDestinations-ms")))
.Select(path => new FileInfo(path))
.OrderByDescending(info => info.LastWriteTimeUtc)
.ToList();
var sourceOrder = 0;
foreach (var jumpListFile in jumpListFiles)
{
TryParseJumpListFile(jumpListFile, documents, ref sourceOrder);
}
}
catch (Exception ex)
{
AppLogger.Warn(LogCategory, "Failed to read Windows Jump Lists for Office documents.", ex);
}
}
private void TryParseJumpListFile(FileInfo jumpListFile, List<OfficeRecentDocument> documents, ref int sourceOrder)
{
try
{
var bytes = File.ReadAllBytes(jumpListFile.FullName);
foreach (var filePath in ExtractPossiblePaths(bytes))
{
AddDocumentIfExists(documents, filePath, 3, sourceOrder++, jumpListFile.LastWriteTime);
}
}
catch
{
// Ignore a single Jump List file and keep scanning the rest.
}
}
private static IEnumerable<string> ExtractPossiblePaths(byte[] bytes)
{
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var text in new[]
{
Encoding.Unicode.GetString(bytes),
Encoding.Latin1.GetString(bytes)
})
{
foreach (Match match in OfficeFilePathRegex.Matches(text))
{
var normalizedPath = NormalizeFilePath(match.Value);
if (!string.IsNullOrWhiteSpace(normalizedPath))
{
paths.Add(normalizedPath);
}
}
}
return paths;
}
private void AddDocumentIfExists(
List<OfficeRecentDocument> documents,
string? filePath,
int sourcePriority,
int sourceOrder,
DateTime? recentAccessTime)
{
try
{
var normalizedPath = NormalizeFilePath(filePath);
if (string.IsNullOrWhiteSpace(normalizedPath))
{
return;
}
var extension = Path.GetExtension(normalizedPath).ToLowerInvariant();
if (!IsOfficeFile(extension) || !File.Exists(normalizedPath))
{
return;
}
var fileInfo = new FileInfo(normalizedPath);
documents.Add(new OfficeRecentDocument
{
FileName = Path.GetFileNameWithoutExtension(normalizedPath),
FilePath = normalizedPath,
Extension = extension,
LastModifiedTime = fileInfo.LastWriteTime,
FileSizeBytes = fileInfo.Length,
IconGlyph = GetIconGlyph(extension),
RecentAccessTime = recentAccessTime,
SourcePriority = sourcePriority,
SourceOrder = sourceOrder
});
}
catch
{
// Ignore a single file and keep processing the rest of the MRU list.
}
}
private static IEnumerable<string> GetRecentFolders()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
folders.Add(Path.Combine(appData, "Microsoft", "Word", "Recent"));
folders.Add(Path.Combine(appData, "Microsoft", "Excel", "Recent"));
folders.Add(Path.Combine(appData, "Microsoft", "PowerPoint", "Recent"));
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
return folders;
return new[]
{
Path.Combine(appData, "Microsoft", "Windows", "Recent"),
Path.Combine(appData, "Microsoft", "Word", "Recent"),
Path.Combine(appData, "Microsoft", "Excel", "Recent"),
Path.Combine(appData, "Microsoft", "PowerPoint", "Recent"),
Path.Combine(localAppData, "Microsoft", "Office", "Word", "Recent"),
Path.Combine(localAppData, "Microsoft", "Office", "Excel", "Recent"),
Path.Combine(localAppData, "Microsoft", "Office", "PowerPoint", "Recent")
}.Distinct(StringComparer.OrdinalIgnoreCase);
}
private static IEnumerable<string> GetJumpListFolders()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
return new[]
{
Path.Combine(appData, "Microsoft", "Windows", "Recent", "AutomaticDestinations"),
Path.Combine(appData, "Microsoft", "Windows", "Recent", "CustomDestinations"),
Path.Combine(localAppData, "Microsoft", "Windows", "Recent", "AutomaticDestinations"),
Path.Combine(localAppData, "Microsoft", "Windows", "Recent", "CustomDestinations")
}.Distinct(StringComparer.OrdinalIgnoreCase);
}
private static bool IsOfficeVersionKey(string keyName)
{
return Version.TryParse(keyName, out _);
}
private static Version ParseVersionKey(string keyName)
{
return Version.TryParse(keyName, out var version) ? version : new Version(0, 0);
}
private static int ParseMruItemOrder(string valueName)
{
var numberText = valueName["Item ".Length..];
return int.TryParse(numberText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var number)
? number
: int.MaxValue;
}
private static (string? FilePath, DateTime? RecentAccessTime) ParseOfficeMruValue(string rawValue)
{
var filePath = ExtractOfficeFilePath(rawValue);
DateTime? recentAccessTime = null;
var timestampMatch = OfficeMruTimestampRegex.Match(rawValue);
if (timestampMatch.Success &&
long.TryParse(timestampMatch.Groups["filetime"].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var fileTime) &&
fileTime > 0)
{
try
{
recentAccessTime = DateTime.FromFileTimeUtc(fileTime).ToLocalTime();
}
catch
{
recentAccessTime = null;
}
}
return (filePath, recentAccessTime);
}
private static string? ExtractOfficeFilePath(string rawValue)
{
if (string.IsNullOrWhiteSpace(rawValue))
{
return null;
}
var markerIndex = rawValue.LastIndexOf('*');
var candidate = markerIndex >= 0
? rawValue[(markerIndex + 1)..]
: rawValue;
var normalizedCandidate = NormalizeFilePath(candidate);
if (!string.IsNullOrWhiteSpace(normalizedCandidate) && IsOfficeFile(Path.GetExtension(normalizedCandidate)))
{
return normalizedCandidate;
}
var match = OfficeFilePathRegex.Match(rawValue);
return match.Success ? NormalizeFilePath(match.Value) : null;
}
private static string? NormalizeFilePath(string? rawPath)
{
if (string.IsNullOrWhiteSpace(rawPath))
{
return null;
}
var candidate = rawPath.Trim('\0', ' ', '"');
candidate = Environment.ExpandEnvironmentVariables(candidate);
if (Uri.TryCreate(candidate, UriKind.Absolute, out var uri) && uri.IsFile)
{
candidate = uri.LocalPath;
}
candidate = candidate.Replace('/', '\\');
return string.IsNullOrWhiteSpace(candidate) ? null : candidate;
}
private static bool IsOfficeFile(string extension)
{
return OfficeExtensions.Contains(extension) ||
ExcelExtensions.Contains(extension) ||
PowerPointExtensions.Contains(extension);
return OfficeExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase) ||
ExcelExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase) ||
PowerPointExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase);
}
private static string GetIconGlyph(string extension)
@@ -150,4 +703,40 @@ public sealed class OfficeRecentDocumentsService : IOfficeRecentDocumentsService
{
return ShortcutHelper.GetShortcutTarget(lnkPath);
}
private static object? GetPropertyValue(object? instance, string propertyName)
{
return instance?.GetType().GetProperty(propertyName)?.GetValue(instance);
}
private static string? GetStringProperty(object? instance, string propertyName)
{
return GetPropertyValue(instance, propertyName) as string;
}
private static int GetIntProperty(object instance, string propertyName)
{
var value = GetPropertyValue(instance, propertyName);
return value switch
{
int intValue => intValue,
short shortValue => shortValue,
long longValue => (int)longValue,
_ => 0
};
}
private static void TrySetProperty(object? instance, string propertyName, object value)
{
var property = instance?.GetType().GetProperty(propertyName);
if (property?.CanWrite == true)
{
property.SetValue(instance, value);
}
}
private static void InvokeParameterlessMethod(object instance, string methodName)
{
instance.GetType().GetMethod(methodName, Type.EmptyTypes)?.Invoke(instance, null);
}
}

View File

@@ -0,0 +1,310 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
namespace LanMountainDesktop.Services;
public sealed record RemovableStorageDrive(
string RootPath,
string DriveLetter,
string? VolumeLabel);
public interface IRemovableStorageService
{
IReadOnlyList<RemovableStorageDrive> GetConnectedDrives();
bool OpenDrive(string rootPath);
bool EjectDrive(string rootPath);
}
public sealed class RemovableStorageService : IRemovableStorageService
{
public IReadOnlyList<RemovableStorageDrive> GetConnectedDrives()
{
var drives = new List<RemovableStorageDrive>();
foreach (var drive in DriveInfo.GetDrives())
{
try
{
if (drive.DriveType != DriveType.Removable || !drive.IsReady)
{
continue;
}
var rootPath = NormalizeRootPath(drive.Name);
if (string.IsNullOrWhiteSpace(rootPath))
{
continue;
}
var driveLetter = rootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var volumeLabel = string.IsNullOrWhiteSpace(drive.VolumeLabel)
? null
: drive.VolumeLabel.Trim();
drives.Add(new RemovableStorageDrive(rootPath, driveLetter, volumeLabel));
}
catch (Exception ex)
{
AppLogger.Warn("RemovableStorage", $"Failed to inspect drive '{drive.Name}'.", ex);
}
}
return drives
.OrderBy(drive => drive.DriveLetter, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
public bool OpenDrive(string rootPath)
{
var normalizedRootPath = NormalizeRootPath(rootPath);
if (string.IsNullOrWhiteSpace(normalizedRootPath))
{
return false;
}
try
{
Process.Start(new ProcessStartInfo
{
FileName = normalizedRootPath,
UseShellExecute = true
});
return true;
}
catch (Exception ex)
{
AppLogger.Warn("RemovableStorage", $"Failed to open drive '{normalizedRootPath}'.", ex);
return false;
}
}
public bool EjectDrive(string rootPath)
{
if (!OperatingSystem.IsWindows())
{
return false;
}
var normalizedRootPath = NormalizeRootPath(rootPath);
if (string.IsNullOrWhiteSpace(normalizedRootPath))
{
return false;
}
object? shellApplication = null;
object? computerFolder = null;
object? driveItem = null;
try
{
var shellType = Type.GetTypeFromProgID("Shell.Application");
if (shellType is null)
{
return false;
}
shellApplication = Activator.CreateInstance(shellType);
if (shellApplication is null)
{
return false;
}
computerFolder = shellType.InvokeMember(
"NameSpace",
BindingFlags.InvokeMethod,
binder: null,
target: shellApplication,
args: [17]);
if (computerFolder is null)
{
return false;
}
var driveToken = normalizedRootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
driveItem = computerFolder.GetType().InvokeMember(
"ParseName",
BindingFlags.InvokeMethod,
binder: null,
target: computerFolder,
args: [driveToken]);
if (driveItem is null)
{
return false;
}
if (TryInvokeVerb(driveItem, "Eject"))
{
return true;
}
return TryInvokeLocalizedEjectVerb(driveItem);
}
catch (Exception ex)
{
AppLogger.Warn("RemovableStorage", $"Failed to eject drive '{normalizedRootPath}'.", ex);
return false;
}
finally
{
ReleaseComObject(driveItem);
ReleaseComObject(computerFolder);
ReleaseComObject(shellApplication);
}
}
private static bool TryInvokeLocalizedEjectVerb(object driveItem)
{
object? verbs = null;
try
{
verbs = driveItem.GetType().InvokeMember(
"Verbs",
BindingFlags.InvokeMethod,
binder: null,
target: driveItem,
args: null);
if (verbs is null)
{
return false;
}
var verbsType = verbs.GetType();
var countObject = verbsType.InvokeMember(
"Count",
BindingFlags.GetProperty,
binder: null,
target: verbs,
args: null);
var count = countObject is null
? 0
: Convert.ToInt32(countObject, CultureInfo.InvariantCulture);
for (var index = 0; index < count; index++)
{
object? verb = null;
try
{
verb = verbsType.InvokeMember(
"Item",
BindingFlags.InvokeMethod,
binder: null,
target: verbs,
args: [index]);
if (verb is null)
{
continue;
}
var verbNameObject = verb.GetType().InvokeMember(
"Name",
BindingFlags.GetProperty,
binder: null,
target: verb,
args: null);
var verbName = Convert.ToString(verbNameObject, CultureInfo.InvariantCulture);
if (!IsEjectVerbName(verbName))
{
continue;
}
verb.GetType().InvokeMember(
"DoIt",
BindingFlags.InvokeMethod,
binder: null,
target: verb,
args: null);
return true;
}
finally
{
ReleaseComObject(verb);
}
}
return false;
}
finally
{
ReleaseComObject(verbs);
}
}
private static bool TryInvokeVerb(object driveItem, string verbName)
{
try
{
driveItem.GetType().InvokeMember(
"InvokeVerb",
BindingFlags.InvokeMethod,
binder: null,
target: driveItem,
args: [verbName]);
return true;
}
catch
{
return false;
}
}
private static bool IsEjectVerbName(string? verbName)
{
if (string.IsNullOrWhiteSpace(verbName))
{
return false;
}
var normalized = string.Concat(
verbName
.Where(character => !char.IsWhiteSpace(character) && character != '&'))
.Trim();
return normalized.Contains("Eject", StringComparison.OrdinalIgnoreCase) ||
normalized.Contains("弹出", StringComparison.Ordinal) ||
normalized.Contains("安全删除", StringComparison.Ordinal) ||
normalized.Contains("卸载", StringComparison.Ordinal);
}
private static string NormalizeRootPath(string? rootPath)
{
if (string.IsNullOrWhiteSpace(rootPath))
{
return string.Empty;
}
var trimmed = rootPath.Trim();
if (trimmed.Length == 1 && char.IsLetter(trimmed[0]))
{
return string.Create(CultureInfo.InvariantCulture, $"{trimmed}:{Path.DirectorySeparatorChar}");
}
if (trimmed.Length == 2 && char.IsLetter(trimmed[0]) && trimmed[1] == ':')
{
return trimmed + Path.DirectorySeparatorChar;
}
var normalized = trimmed.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
var resolvedRoot = Path.GetPathRoot(normalized);
return string.IsNullOrWhiteSpace(resolvedRoot)
? normalized
: resolvedRoot;
}
private static void ReleaseComObject(object? value)
{
if (value is not null && Marshal.IsComObject(value))
{
Marshal.FinalReleaseComObject(value);
}
}
}

View File

@@ -11,12 +11,11 @@ namespace LanMountainDesktop.Services.Settings;
public enum WallpaperMediaType
{
None,
Image,
Video
Image
}
public sealed record GridSettingsState(int ShortSideCells, string SpacingPreset, int EdgeInsetPercent);
public sealed record WallpaperSettingsState(string? WallpaperPath, string Type, string? Color, string Placement);
public sealed record WallpaperSettingsState(string? WallpaperPath, string Type, string? Color, string Placement, string? CustomColor = null);
public sealed record ThemeAppearanceSettingsState(
bool IsNightMode,
string? ThemeColor,
@@ -30,6 +29,7 @@ public sealed record StatusBarSettingsState(
bool EnableDynamicTaskbarActions,
string TaskbarLayoutMode,
string ClockDisplayFormat,
bool ClockTransparentBackground,
string SpacingMode,
int CustomSpacingPercent);
public sealed record WeatherSettingsState(
@@ -48,7 +48,6 @@ public sealed record PrivacySettingsState(
bool UploadAnonymousCrashData,
bool UploadAnonymousUsageData);
public sealed record UpdateSettingsState(
bool AutoCheckUpdates,
bool IncludePrereleaseUpdates,
string UpdateChannel,
string UpdateMode,

View File

@@ -147,11 +147,6 @@ internal sealed class WallpaperMediaService : IWallpaperMediaService
".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp"
};
private static readonly HashSet<string> VideoExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".mp4", ".mkv", ".webm", ".avi", ".mov", ".m4v"
};
private readonly string _wallpapersDirectory;
public WallpaperMediaService()
@@ -180,11 +175,6 @@ internal sealed class WallpaperMediaService : IWallpaperMediaService
return WallpaperMediaType.Image;
}
if (VideoExtensions.Contains(extension))
{
return WallpaperMediaType.Video;
}
return WallpaperMediaType.None;
}
@@ -371,6 +361,7 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
snapshot.EnableDynamicTaskbarActions,
snapshot.TaskbarLayoutMode,
snapshot.ClockDisplayFormat,
snapshot.StatusBarClockTransparentBackground,
snapshot.StatusBarSpacingMode,
snapshot.StatusBarCustomSpacingPercent);
}
@@ -383,6 +374,7 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
snapshot.EnableDynamicTaskbarActions = state.EnableDynamicTaskbarActions;
snapshot.TaskbarLayoutMode = state.TaskbarLayoutMode;
snapshot.ClockDisplayFormat = state.ClockDisplayFormat;
snapshot.StatusBarClockTransparentBackground = state.ClockTransparentBackground;
snapshot.StatusBarSpacingMode = state.SpacingMode;
snapshot.StatusBarCustomSpacingPercent = state.CustomSpacingPercent;
_settingsService.SaveSnapshot(
@@ -395,6 +387,7 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
nameof(AppSettingsSnapshot.EnableDynamicTaskbarActions),
nameof(AppSettingsSnapshot.TaskbarLayoutMode),
nameof(AppSettingsSnapshot.ClockDisplayFormat),
nameof(AppSettingsSnapshot.StatusBarClockTransparentBackground),
nameof(AppSettingsSnapshot.StatusBarSpacingMode),
nameof(AppSettingsSnapshot.StatusBarCustomSpacingPercent)
]);
@@ -638,7 +631,6 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
snapshot.UpdateChannel,
snapshot.IncludePrereleaseUpdates);
return new UpdateSettingsState(
snapshot.AutoCheckUpdates,
string.Equals(normalizedChannel, UpdateSettingsValues.ChannelPreview, StringComparison.OrdinalIgnoreCase),
normalizedChannel,
UpdateSettingsValues.NormalizeMode(snapshot.UpdateMode),
@@ -656,7 +648,6 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
var normalizedChannel = UpdateSettingsValues.NormalizeChannel(
state.UpdateChannel,
state.IncludePrereleaseUpdates);
snapshot.AutoCheckUpdates = state.AutoCheckUpdates;
snapshot.IncludePrereleaseUpdates = string.Equals(
normalizedChannel,
UpdateSettingsValues.ChannelPreview,
@@ -682,7 +673,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
snapshot,
changedKeys:
[
nameof(AppSettingsSnapshot.AutoCheckUpdates),
nameof(AppSettingsSnapshot.IncludePrereleaseUpdates),
nameof(AppSettingsSnapshot.IncludePrereleaseUpdates),
nameof(AppSettingsSnapshot.UpdateChannel),
nameof(AppSettingsSnapshot.UpdateMode),

View File

@@ -294,6 +294,8 @@ internal sealed class SettingsWindowService : ISettingsWindowService
if (languageChanged)
{
var regionState = _settingsFacade.Region.Get();
// 清除本地化缓存,强制重新加载语言文件
_localizationService.ClearCache();
_viewModel.RefreshLanguage(regionState.LanguageCode);
_pageRegistry.Rebuild();
_window.ReloadPages(_viewModel.CurrentPageId);

View File

@@ -131,13 +131,10 @@ public sealed class UpdateWorkflowService
CancellationToken cancellationToken = default)
{
var state = _settingsFacade.Update.Get();
if (!state.AutoCheckUpdates)
{
return;
}
try
{
// Always check for updates on startup (removed AutoCheckUpdates check)
var result = await CheckForUpdatesAsync(currentVersion, cancellationToken);
if (!result.Success || !result.IsUpdateAvailable || result.PreferredAsset is null)
{
@@ -145,12 +142,14 @@ public sealed class UpdateWorkflowService
}
var normalizedMode = UpdateSettingsValues.NormalizeMode(state.UpdateMode);
if (string.Equals(normalizedMode, UpdateSettingsValues.ModeManual, StringComparison.OrdinalIgnoreCase))
// For "Silent Download" and "Silent Install" modes, automatically download the update
if (string.Equals(normalizedMode, UpdateSettingsValues.ModeDownloadThenConfirm, StringComparison.OrdinalIgnoreCase) ||
string.Equals(normalizedMode, UpdateSettingsValues.ModeSilentOnExit, StringComparison.OrdinalIgnoreCase))
{
return;
await DownloadReleaseAsync(result, cancellationToken: cancellationToken);
}
await DownloadReleaseAsync(result, cancellationToken: cancellationToken);
// For "Manual" mode, just check but don't download
}
catch (OperationCanceledException)
{

View File

@@ -0,0 +1,338 @@
using System;
using System.Text.Json;
using LanMountainDesktop.Models;
using Microsoft.Data.Sqlite;
namespace LanMountainDesktop.Services;
public sealed class WhiteboardNotePersistenceService : IWhiteboardNotePersistenceService
{
private const int DefaultCleanupBatchSize = 256;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private readonly object _schemaSyncRoot = new();
private readonly AppDatabaseService _databaseService;
private bool _schemaInitialized;
public WhiteboardNotePersistenceService(AppDatabaseService? databaseService = null)
{
_databaseService = databaseService ?? AppDatabaseServiceFactory.CreateDefault();
}
public WhiteboardNoteSnapshot LoadNote(string componentId, string? placementId, int retentionDays)
{
if (!TryNormalizeKeys(componentId, placementId, out var normalizedComponentId, out var normalizedPlacementId))
{
return new WhiteboardNoteSnapshot();
}
try
{
using var connection = OpenConnection();
DeleteExpiredInternal(
connection,
normalizedComponentId,
normalizedPlacementId,
WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays),
DateTimeOffset.UtcNow);
using var command = connection.CreateCommand();
command.CommandText = """
SELECT note_json, saved_at_utc_ms
FROM whiteboard_notes
WHERE component_id = $componentId
AND placement_id = $placementId
LIMIT 1;
""";
command.Parameters.AddWithValue("$componentId", normalizedComponentId);
command.Parameters.AddWithValue("$placementId", normalizedPlacementId);
using var reader = command.ExecuteReader();
if (!reader.Read() || reader.IsDBNull(0))
{
return new WhiteboardNoteSnapshot();
}
var json = reader.GetString(0);
if (string.IsNullOrWhiteSpace(json))
{
return new WhiteboardNoteSnapshot();
}
var snapshot = JsonSerializer.Deserialize<WhiteboardNoteSnapshot>(json, JsonOptions) ?? new WhiteboardNoteSnapshot();
if (!reader.IsDBNull(1))
{
snapshot.SavedUtc = DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64(1));
}
if (IsExpired(snapshot, retentionDays))
{
DeleteNote(normalizedComponentId, normalizedPlacementId);
return new WhiteboardNoteSnapshot();
}
return snapshot.Clone();
}
catch
{
return new WhiteboardNoteSnapshot();
}
}
public void SaveNote(string componentId, string? placementId, WhiteboardNoteSnapshot snapshot, int retentionDays)
{
if (!TryNormalizeKeys(componentId, placementId, out var normalizedComponentId, out var normalizedPlacementId))
{
return;
}
try
{
var nowUtc = DateTimeOffset.UtcNow;
var persistedSnapshot = snapshot?.Clone() ?? new WhiteboardNoteSnapshot();
persistedSnapshot.SavedUtc = nowUtc;
var expiresUtc = GetExpirationUtc(persistedSnapshot, retentionDays) ?? nowUtc.AddDays(WhiteboardNoteRetentionPolicy.DefaultDays);
var json = JsonSerializer.Serialize(persistedSnapshot, JsonOptions);
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
INSERT INTO whiteboard_notes(
component_id,
placement_id,
note_json,
saved_at_utc_ms,
expires_at_utc_ms,
updated_at_utc_ms)
VALUES(
$componentId,
$placementId,
$noteJson,
$savedAtUtcMs,
$expiresAtUtcMs,
$updatedAtUtcMs)
ON CONFLICT(component_id, placement_id) DO UPDATE SET
note_json = excluded.note_json,
saved_at_utc_ms = excluded.saved_at_utc_ms,
expires_at_utc_ms = excluded.expires_at_utc_ms,
updated_at_utc_ms = excluded.updated_at_utc_ms;
""";
command.Parameters.AddWithValue("$componentId", normalizedComponentId);
command.Parameters.AddWithValue("$placementId", normalizedPlacementId);
command.Parameters.AddWithValue("$noteJson", json);
command.Parameters.AddWithValue("$savedAtUtcMs", persistedSnapshot.SavedUtc.ToUnixTimeMilliseconds());
command.Parameters.AddWithValue("$expiresAtUtcMs", expiresUtc.ToUnixTimeMilliseconds());
command.Parameters.AddWithValue("$updatedAtUtcMs", nowUtc.ToUnixTimeMilliseconds());
command.ExecuteNonQuery();
}
catch
{
// Keep whiteboard usable even when persistence is unavailable.
}
}
public bool DeleteNote(string componentId, string? placementId)
{
if (!TryNormalizeKeys(componentId, placementId, out var normalizedComponentId, out var normalizedPlacementId))
{
return false;
}
try
{
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
DELETE FROM whiteboard_notes
WHERE component_id = $componentId
AND placement_id = $placementId;
""";
command.Parameters.AddWithValue("$componentId", normalizedComponentId);
command.Parameters.AddWithValue("$placementId", normalizedPlacementId);
return command.ExecuteNonQuery() > 0;
}
catch
{
return false;
}
}
public bool TryDeleteExpiredNote(string componentId, string? placementId, int retentionDays)
{
if (!TryNormalizeKeys(componentId, placementId, out var normalizedComponentId, out var normalizedPlacementId))
{
return false;
}
try
{
using var connection = OpenConnection();
return DeleteExpiredInternal(
connection,
normalizedComponentId,
normalizedPlacementId,
WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays),
DateTimeOffset.UtcNow);
}
catch
{
return false;
}
}
public int DeleteExpiredNotesBatch(int batchSize = DefaultCleanupBatchSize, DateTimeOffset? now = null)
{
try
{
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
DELETE FROM whiteboard_notes
WHERE rowid IN (
SELECT rowid
FROM whiteboard_notes
WHERE expires_at_utc_ms <= $nowUtcMs
ORDER BY expires_at_utc_ms ASC
LIMIT $batchSize
);
""";
command.Parameters.AddWithValue("$nowUtcMs", (now ?? DateTimeOffset.UtcNow).ToUnixTimeMilliseconds());
command.Parameters.AddWithValue("$batchSize", NormalizeBatchSize(batchSize));
return command.ExecuteNonQuery();
}
catch
{
return 0;
}
}
public bool IsExpired(WhiteboardNoteSnapshot snapshot, int retentionDays, DateTimeOffset? now = null)
{
if (snapshot is null)
{
return false;
}
var expirationUtc = GetExpirationUtc(snapshot, retentionDays);
if (!expirationUtc.HasValue)
{
return false;
}
return expirationUtc.Value <= (now ?? DateTimeOffset.UtcNow);
}
public DateTimeOffset? GetExpirationUtc(WhiteboardNoteSnapshot snapshot, int retentionDays)
{
if (snapshot is null || snapshot.SavedUtc == default)
{
return null;
}
return snapshot.SavedUtc.AddDays(WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays));
}
private SqliteConnection OpenConnection()
{
var connection = _databaseService.OpenConnection();
EnsureSchema(connection);
return connection;
}
private void EnsureSchema(SqliteConnection connection)
{
if (_schemaInitialized)
{
return;
}
lock (_schemaSyncRoot)
{
if (_schemaInitialized)
{
return;
}
using var command = connection.CreateCommand();
command.CommandText = """
CREATE TABLE IF NOT EXISTS whiteboard_notes (
component_id TEXT NOT NULL,
placement_id TEXT NOT NULL,
note_json TEXT NOT NULL,
saved_at_utc_ms INTEGER NOT NULL,
expires_at_utc_ms INTEGER NOT NULL,
updated_at_utc_ms INTEGER NOT NULL,
PRIMARY KEY (component_id, placement_id)
);
CREATE INDEX IF NOT EXISTS idx_whiteboard_notes_expires_at
ON whiteboard_notes(expires_at_utc_ms);
""";
command.ExecuteNonQuery();
_schemaInitialized = true;
}
}
private static bool DeleteExpiredInternal(
SqliteConnection connection,
string componentId,
string placementId,
int retentionDays,
DateTimeOffset nowUtc)
{
using var selectCommand = connection.CreateCommand();
selectCommand.CommandText = """
SELECT saved_at_utc_ms
FROM whiteboard_notes
WHERE component_id = $componentId
AND placement_id = $placementId
LIMIT 1;
""";
selectCommand.Parameters.AddWithValue("$componentId", componentId);
selectCommand.Parameters.AddWithValue("$placementId", placementId);
var scalar = selectCommand.ExecuteScalar();
if (scalar is not long savedAtUtcMs)
{
return false;
}
var savedUtc = DateTimeOffset.FromUnixTimeMilliseconds(savedAtUtcMs);
var expiresUtc = savedUtc.AddDays(WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays));
if (expiresUtc > nowUtc)
{
return false;
}
using var deleteCommand = connection.CreateCommand();
deleteCommand.CommandText = """
DELETE FROM whiteboard_notes
WHERE component_id = $componentId
AND placement_id = $placementId;
""";
deleteCommand.Parameters.AddWithValue("$componentId", componentId);
deleteCommand.Parameters.AddWithValue("$placementId", placementId);
return deleteCommand.ExecuteNonQuery() > 0;
}
private static bool TryNormalizeKeys(
string componentId,
string? placementId,
out string normalizedComponentId,
out string normalizedPlacementId)
{
normalizedComponentId = componentId?.Trim() ?? string.Empty;
normalizedPlacementId = placementId?.Trim() ?? string.Empty;
return !string.IsNullOrWhiteSpace(normalizedComponentId);
}
private static int NormalizeBatchSize(int batchSize)
{
return batchSize <= 0
? DefaultCleanupBatchSize
: Math.Clamp(batchSize, 1, 4096);
}
}

View File

@@ -1,5 +1,6 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:assists="clr-namespace:Material.Styles.Assists;assembly=Material.Styles">
<Style Selector="Window.component-editor-window">
<Setter Property="Background" Value="{DynamicResource EditorWindowBackgroundBrush}" />
</Style>
@@ -74,7 +75,21 @@
</Style>
<Style Selector="Window.component-editor-window RadioButton">
<Setter Property="Theme" Value="{StaticResource MaterialRadioButton}" />
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorPrimaryTextBrush}" />
<Setter Property="Margin" Value="0,2" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="assists:SelectionControlAssist.Size" Value="20" />
<Setter Property="assists:SelectionControlAssist.Foreground" Value="{DynamicResource ComponentEditorSecondaryTextBrush}" />
<Setter Property="assists:SelectionControlAssist.InnerForeground" Value="{DynamicResource EditorPrimaryBrush}" />
</Style>
<Style Selector="Window.component-editor-window RadioButton:pointerover">
<Setter Property="assists:SelectionControlAssist.Foreground" Value="{DynamicResource EditorSelectOutlineStrongBrush}" />
</Style>
<Style Selector="Window.component-editor-window RadioButton:checked">
<Setter Property="assists:SelectionControlAssist.Foreground" Value="{DynamicResource EditorPrimaryBrush}" />
</Style>
<Style Selector="Window.component-editor-window ToggleSwitch">

View File

@@ -165,7 +165,13 @@
<Setter Property="RenderTransform" Value="scale(1.05)" />
</Style>
<Style Selector="Border.glass-panel">
<!--
半透明表面样式类
注意:这些样式使用纯色半透明画刷模拟玻璃效果,并非真正的 Mica/Acrylic 模糊材质。
真正的 Mica/Acrylic 效果仅通过 WindowTransparencyLevel 在独立窗口上应用。
-->
<Style Selector="Border.surface-translucent-panel">
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassPanelBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
<Setter Property="BorderThickness" Value="1.2" />
@@ -174,7 +180,7 @@
<Setter Property="BoxShadow" Value="0 4 12 #1A000000" />
</Style>
<Style Selector="Border.glass-strong">
<Style Selector="Border.surface-translucent-strong">
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassStrongBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassStrongBorderBrush}" />
<Setter Property="BorderThickness" Value="1.5" />
@@ -183,7 +189,7 @@
<Setter Property="BoxShadow" Value="0 8 24 #26000000" />
</Style>
<Style Selector="Border.glass-island">
<Style Selector="Border.surface-translucent-island">
<Setter Property="Background" Value="{DynamicResource AdaptiveDockGlassBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveDockGlassBorderBrush}" />
<Setter Property="BorderThickness" Value="1.5" />
@@ -197,7 +203,7 @@
</Setter>
</Style>
<Style Selector="Border.mica-strong">
<Style Selector="Border.surface-solid-strong">
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassStrongBackgroundBrush}" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="36" />
@@ -205,11 +211,18 @@
<Setter Property="BoxShadow" Value="0 8 22 #2A000000" />
</Style>
<Style Selector="Border.glass-overlay">
<Style Selector="Border.surface-translucent-overlay">
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassOverlayBackgroundBrush}" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="0" />
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassOverlayOpacity}" />
</Style>
<!-- 向后兼容的旧样式类(已弃用) -->
<Style Selector="Border.glass-panel" />
<Style Selector="Border.glass-strong" />
<Style Selector="Border.glass-island" />
<Style Selector="Border.mica-strong" />
<Style Selector="Border.glass-overlay" />
</Styles>

View File

@@ -564,11 +564,19 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
{
RefreshInstalledSnapshot();
RefreshItemStates();
// 设置更明显的状态消息
var pluginName = result.PluginName ?? item.Name;
StatusMessage = string.Format(
CultureInfo.CurrentCulture,
L("market.status.install_success_format", "Plugin '{0}' has been staged. Restart the app to apply it."),
result.PluginName ?? item.Name);
RestartRequested?.Invoke(RestartRequiredMessage);
L("market.status.install_success_restart_format", "Plugin '{0}' installed successfully! Please restart the application to activate it."),
pluginName);
// 触发重启提醒
RestartRequested?.Invoke(string.Format(
CultureInfo.CurrentCulture,
L("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?"),
pluginName));
return;
}

View File

@@ -0,0 +1,92 @@
using System;
using System.IO;
using System.Reflection;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.ViewModels;
public sealed partial class PrivacyPolicyViewModel : ViewModelBase
{
private readonly LocalizationService _localizationService = new();
private readonly string _languageCode;
[ObservableProperty]
private string _title = string.Empty;
[ObservableProperty]
private string _description = string.Empty;
[ObservableProperty]
private string _loadingText = string.Empty;
[ObservableProperty]
private string _errorText = string.Empty;
[ObservableProperty]
private string _markdownContent = string.Empty;
[ObservableProperty]
private bool _isLoading = true;
[ObservableProperty]
private bool _hasError;
[ObservableProperty]
private bool _hasContent;
public PrivacyPolicyViewModel()
{
_languageCode = "zh-CN";
RefreshLocalizedText();
LoadPrivacyPolicy();
}
private void RefreshLocalizedText()
{
Title = L("settings.privacy.policy_title", "Privacy Policy");
Description = L("settings.privacy.policy_description", "Learn how we collect, use, and protect your data.");
LoadingText = L("settings.privacy.policy_loading", "Loading privacy policy...");
}
private async void LoadPrivacyPolicy()
{
try
{
IsLoading = true;
HasError = false;
HasContent = false;
// 从嵌入资源加载隐私政策Markdown文件
var assembly = Assembly.GetExecutingAssembly();
var resourceName = "LanMountainDesktop.Assets.Documents.Privacy.md";
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream is null)
{
throw new FileNotFoundException($"Privacy policy resource not found: {resourceName}");
}
using var reader = new StreamReader(stream);
var markdown = await reader.ReadToEndAsync();
MarkdownContent = markdown;
IsLoading = false;
HasContent = true;
AppLogger.Info("PrivacyPolicy", "Privacy policy loaded successfully.");
}
catch (Exception ex)
{
AppLogger.Warn("PrivacyPolicy", "Failed to load privacy policy.", ex);
IsLoading = false;
HasError = true;
ErrorText = L("settings.privacy.policy_error", "Failed to load privacy policy. Please try again later.");
}
}
private string L(string key, string fallback)
=> _localizationService.GetString(_languageCode, key, fallback);
}

View File

@@ -15,6 +15,8 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
private readonly string _languageCode;
private bool _isInitializing;
public event Action? ViewPrivacyPolicyRequested;
public PrivacySettingsPageViewModel(ISettingsFacadeService settingsFacade)
{
_settingsFacade = settingsFacade;
@@ -59,6 +61,12 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
[ObservableProperty]
private string _refreshDeviceIdText = string.Empty;
[ObservableProperty]
private string _viewPrivacyPolicyText = string.Empty;
[ObservableProperty]
private string _privacyPolicyHintPrefix = string.Empty;
public void Load()
{
var state = _settingsFacade.Privacy.Get();
@@ -130,6 +138,25 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
DeviceIdHeader = L("settings.privacy.device_id_title", "Device ID");
DeviceIdDescription = L("settings.privacy.device_id_description", "Unique identifier for this device. Click refresh to regenerate.");
RefreshDeviceIdText = L("settings.privacy.refresh_device_id", "Refresh");
PrivacyPolicyHintPrefix = L("settings.privacy.policy_hint_prefix", "For more details, please ");
ViewPrivacyPolicyText = L("settings.privacy.view_policy", "view our privacy policy");
}
[RelayCommand]
private void ViewPrivacyPolicy()
{
try
{
// 触发隐私政策查看事件
AppLogger.Info("PrivacySettings", "User requested to view privacy policy.");
// 发送事件通知显示隐私政策
ViewPrivacyPolicyRequested?.Invoke();
}
catch (Exception ex)
{
AppLogger.Warn("PrivacySettings", "Failed to view privacy policy.", ex);
}
}
private string L(string key, string fallback)

View File

@@ -268,12 +268,17 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase
partial void OnSelectedLanguageChanged(SelectionOption value)
{
RefreshPreview();
if (_isInitializing || value is null)
{
return;
}
// 更新语言代码并刷新UI文本
_languageCode = _localizationService.NormalizeLanguageCode(value.Value);
RefreshLocalizedText();
RefreshPreview();
// 保存设置
_settingsFacade.Region.Save(new RegionSettingsState(
value.Value,
NormalizeTimeZoneId(SelectedTimeZone?.Id)));
@@ -1329,9 +1334,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
LoadStateFromSettings();
}
[ObservableProperty]
private bool _autoCheckUpdates;
[ObservableProperty]
private string _selectedUpdateChannelValue = UpdateSettingsValues.ChannelStable;
@@ -1380,9 +1382,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private string _preferencesDescription = string.Empty;
[ObservableProperty]
private string _autoCheckUpdatesLabel = string.Empty;
[ObservableProperty]
private string _updateChannelLabel = string.Empty;
@@ -1520,16 +1519,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
private bool IsBusy => IsCheckingForUpdates || IsDownloading;
partial void OnAutoCheckUpdatesChanged(bool value)
{
if (_isInitializing)
{
return;
}
SaveUpdateSettings();
}
partial void OnSelectedUpdateChannelOptionChanged(SelectionOption? value)
{
if (value is not null &&
@@ -1729,7 +1718,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
var current = _settingsFacade.Update.Get();
_settingsFacade.Update.Save(current with
{
AutoCheckUpdates = AutoCheckUpdates,
IncludePrereleaseUpdates = string.Equals(
SelectedUpdateChannelValue,
UpdateSettingsValues.ChannelPreview,
@@ -1841,7 +1829,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
StatusCardDescription = L("settings.update.status_card_description", "Check for updates and review the latest release information.");
PreferencesHeader = L("settings.update.preferences_header", "Update Preferences");
PreferencesDescription = L("settings.update.preferences_description", "Choose your release channel, download source, behavior, and download speed.");
AutoCheckUpdatesLabel = L("settings.update.auto_check_toggle", "Automatically check for updates on startup");
UpdateChannelLabel = L("settings.update.channel_label", "Update Channel");
UpdateSourceLabel = L("settings.update.source_label", "Download Source");
UpdateModeLabel = L("settings.update.mode_label", "Update Mode");
@@ -1870,7 +1857,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
{
var update = _settingsFacade.Update.Get();
_isInitializing = true;
AutoCheckUpdates = update.AutoCheckUpdates;
SelectedUpdateChannelValue = UpdateSettingsValues.NormalizeChannel(update.UpdateChannel, update.IncludePrereleaseUpdates);
SelectedUpdateSourceValue = UpdateSettingsValues.NormalizeDownloadSource(update.UpdateDownloadSource);
SelectedUpdateModeValue = UpdateSettingsValues.NormalizeMode(update.UpdateMode);

View File

@@ -39,6 +39,9 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private SelectionOption _selectedClockFormat = new("HourMinuteSecond", "Hour:Minute:Second");
[ObservableProperty]
private bool _clockTransparentBackground;
[ObservableProperty]
private SelectionOption _selectedSpacingMode = new("Relaxed", "Relaxed");
@@ -66,6 +69,12 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private string _clockFormatLabel = string.Empty;
[ObservableProperty]
private string _clockTransparentBackgroundLabel = string.Empty;
[ObservableProperty]
private string _clockTransparentBackgroundDescription = string.Empty;
[ObservableProperty]
private string _spacingHeader = string.Empty;
@@ -88,6 +97,7 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
SelectedClockFormat = ClockFormats.FirstOrDefault(option =>
string.Equals(option.Value, clockFormat, StringComparison.OrdinalIgnoreCase))
?? ClockFormats[1];
ClockTransparentBackground = state.ClockTransparentBackground;
var spacingMode = NormalizeSpacingMode(state.SpacingMode);
SelectedSpacingMode = SpacingModes.FirstOrDefault(option =>
@@ -117,6 +127,16 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
Save();
}
partial void OnClockTransparentBackgroundChanged(bool value)
{
if (_isInitializing)
{
return;
}
Save();
}
partial void OnSelectedSpacingModeChanged(SelectionOption value)
{
IsCustomSpacingVisible = string.Equals(value?.Value, "Custom", StringComparison.OrdinalIgnoreCase);
@@ -163,6 +183,7 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
state.EnableDynamicTaskbarActions,
state.TaskbarLayoutMode,
SelectedClockFormat.Value,
ClockTransparentBackground,
NormalizeSpacingMode(SelectedSpacingMode.Value),
Math.Clamp(CustomSpacingPercent, 0, 30)));
}
@@ -194,6 +215,8 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
ClockHeader = L("settings.status_bar.clock_header", "Clock Component");
ClockDescription = L("settings.status_bar.clock_description", "Display a clock on the top status bar.");
ClockFormatLabel = L("settings.status_bar.clock_format_label", "Clock format");
ClockTransparentBackgroundLabel = L("settings.status_bar.clock_transparent_background_label", "Transparent background");
ClockTransparentBackgroundDescription = L("settings.status_bar.clock_transparent_background_desc", "Remove the capsule background and keep only the clock text.");
SpacingHeader = L("settings.status_bar.spacing_header", "Component Spacing");
SpacingDescription = L("settings.status_bar.spacing_desc", "Adjust spacing between status bar components.");
CustomSpacingLabel = L("settings.status_bar.spacing_custom_label", "Custom spacing (%)");

View File

@@ -82,23 +82,24 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private bool _isImage;
[ObservableProperty]
private bool _isVideo;
[ObservableProperty]
private Bitmap? _previewImage;
[ObservableProperty]
private IBrush? _previewBrush;
// 自定义颜色持久化
[ObservableProperty]
private string _videoModeHintText = string.Empty;
private Color _customColor = Colors.White;
[ObservableProperty]
private IBrush _customColorBrush = new SolidColorBrush(Colors.White);
public void Load()
{
var wallpaper = _settingsFacade.Wallpaper.Get();
WallpaperPath = wallpaper.WallpaperPath ?? string.Empty;
SelectedWallpaperType = WallpaperTypes.FirstOrDefault(t => t.Value == wallpaper.Type) ?? WallpaperTypes[0];
SelectedColor = wallpaper.Color ?? PresetColors[0];
@@ -108,7 +109,14 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
SelectedWallpaperPlacement = WallpaperPlacements.FirstOrDefault(option =>
string.Equals(option.Value, wallpaperPlacement, StringComparison.OrdinalIgnoreCase))
?? WallpaperPlacements[0];
// 加载自定义颜色
if (!string.IsNullOrWhiteSpace(wallpaper.CustomColor) && Color.TryParse(wallpaper.CustomColor, out var customColor))
{
CustomColor = customColor;
CustomColorBrush = new SolidColorBrush(customColor);
}
UpdateVisibility();
UpdatePreviewFromCurrentSelection();
}
@@ -124,8 +132,7 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
private void UpdateVisibility()
{
IsImage = SelectedWallpaperType?.Value == "Image";
IsVideo = SelectedWallpaperType?.Value == "Video";
IsImageOrVideo = SelectedWallpaperType?.Value is "Image" or "Video";
IsImageOrVideo = IsImage;
IsSolidColor = SelectedWallpaperType?.Value == "SolidColor";
}
@@ -135,6 +142,16 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
SaveWallpaper();
}
partial void OnCustomColorChanged(Color value)
{
CustomColorBrush = new SolidColorBrush(value);
// 将自定义颜色应用到壁纸
var colorHex = $"#{value.A:X2}{value.R:X2}{value.G:X2}{value.B:X2}";
SelectedColor = colorHex;
if (_isInitializing) return;
SaveWallpaper();
}
public async Task ImportWallpaperAsync(string sourcePath)
{
var importedPath = await _settingsFacade.WallpaperMedia.ImportAssetAsync(sourcePath);
@@ -222,11 +239,13 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
var normalizedPath = SelectedWallpaperType?.Value == "SolidColor" || string.IsNullOrWhiteSpace(WallpaperPath)
? null
: WallpaperPath;
var customColorHex = $"#{CustomColor.A:X2}{CustomColor.R:X2}{CustomColor.G:X2}{CustomColor.B:X2}";
_settingsFacade.Wallpaper.Save(new WallpaperSettingsState(
normalizedPath,
selectedType,
SelectedColor,
selectedPlacement));
selectedPlacement,
customColorHex));
}
private IReadOnlyList<SelectionOption> CreateWallpaperPlacements()
@@ -246,7 +265,6 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
return
[
new SelectionOption("Image", L("settings.wallpaper.type.image", "Image")),
new SelectionOption("Video", L("settings.wallpaper.type.video", "Video")),
new SelectionOption("SolidColor", L("settings.wallpaper.type.solid_color", "Solid Color"))
];
}
@@ -257,7 +275,7 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
[
"#D8A7B1", "#B6C9BB", "#A2B5BB", "#E6E2D3",
"#B5A397", "#C5C1C0", "#D4BE8D", "#C08261",
"#8E9775", "#9FBAD3", "#E5BAA2", "#4E596F"
"#8E9775", "#9FBAD3", "#E5BAA2"
];
}
@@ -271,7 +289,6 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
WallpaperPlacementDescription = L("settings.wallpaper.placement_desc", "Adjust how the image fills the desktop.");
ImportWallpaperButtonText = L("settings.wallpaper.pick_button", "Import Wallpaper");
FilePickerTitle = L("filepicker.title", "Select wallpaper");
VideoModeHintText = L("settings.wallpaper.video_mode", "Video wallpaper uses automatic fill mode.");
}
private string L(string key, string fallback)

View File

@@ -241,6 +241,7 @@ public partial class ComponentEditorWindow : Window
"DataLine" => MaterialIconKind.ChartLine,
"Edit" => MaterialIconKind.Pencil,
"Calculator" => MaterialIconKind.Calculator,
"Storage" => MaterialIconKind.UsbFlashDrive,
"Globe" => MaterialIconKind.Web,
"Play" => MaterialIconKind.Play,
_ => MaterialIconKind.Settings

View File

@@ -22,14 +22,17 @@
<StackPanel Spacing="12">
<TextBlock x:Name="ColorSchemeHeaderTextBlock"
Classes="component-editor-section-title" />
<StackPanel Spacing="8">
<RadioButton x:Name="FollowSystemRadioButton"
GroupName="ColorScheme"
IsCheckedChanged="OnColorSchemeChanged" />
<RadioButton x:Name="UseNativeRadioButton"
GroupName="ColorScheme"
IsCheckedChanged="OnColorSchemeChanged" />
</StackPanel>
<ComboBox x:Name="ColorSchemeComboBox"
Classes="component-editor-select"
HorizontalAlignment="Stretch"
SelectionChanged="OnColorSchemeSelectionChanged">
<ComboBoxItem x:Name="FollowSystemColorSchemeItem"
Classes="component-editor-select-item"
Tag="follow_system" />
<ComboBoxItem x:Name="UseNativeColorSchemeItem"
Classes="component-editor-select-item"
Tag="native" />
</ComboBox>
</StackPanel>
</Border>

View File

@@ -68,40 +68,37 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
var colorSchemeSource = snapshot.ColorSchemeSource;
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Class Schedule";
DescriptionTextBlock.Text = L("schedule.settings.desc", "导入 ClassIsland 的 CSES 课表文件并选择启用项。");
ColorSchemeHeaderTextBlock.Text = L("component.settings.color_scheme", "配色方案");
FollowSystemRadioButton.Content = L("component.color_scheme.follow_system", "跟随系统配色");
UseNativeRadioButton.Content = L("component.color_scheme.native", "使用组件自定义配色");
AddScheduleButton.Content = L("schedule.settings.add", "添加课表");
EmptyStateTextBlock.Text = L("schedule.settings.empty", "暂无导入课表");
DescriptionTextBlock.Text = L(
"schedule.settings.desc",
"Import a ClassIsland CSES schedule file and choose which one to use.");
ColorSchemeHeaderTextBlock.Text = L("component.settings.color_scheme", "Color Scheme");
FollowSystemColorSchemeItem.Content = L("component.color_scheme.follow_system", "Follow system color scheme");
UseNativeColorSchemeItem.Content = L("component.color_scheme.native", "Use component custom color scheme");
AddScheduleButton.Content = L("schedule.settings.add", "Add Schedule");
EmptyStateTextBlock.Text = L("schedule.settings.empty", "No imported schedules yet.");
_suppressEvents = true;
if (string.IsNullOrEmpty(colorSchemeSource) ||
colorSchemeSource == ThemeAppearanceValues.ColorSchemeFollowSystem)
{
FollowSystemRadioButton.IsChecked = true;
}
else
{
UseNativeRadioButton.IsChecked = true;
}
ColorSchemeComboBox.SelectedItem =
string.IsNullOrEmpty(colorSchemeSource) ||
string.Equals(colorSchemeSource, ThemeAppearanceValues.ColorSchemeFollowSystem, StringComparison.OrdinalIgnoreCase)
? FollowSystemColorSchemeItem
: UseNativeColorSchemeItem;
_suppressEvents = false;
}
private void OnColorSchemeChanged(object? sender, RoutedEventArgs e)
private void OnColorSchemeSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
var useNative = UseNativeRadioButton.IsChecked == true;
var colorSchemeSource = useNative
? ThemeAppearanceValues.ColorSchemeNative
var colorSchemeSource = ColorSchemeComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tag
? tag
: ThemeAppearanceValues.ColorSchemeFollowSystem;
var snapshot = LoadSnapshot();
@@ -121,11 +118,11 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
var files = await storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = L("schedule.settings.picker_title", "选择 ClassIsland 课表文件"),
Title = L("schedule.settings.picker_title", "Choose ClassIsland schedule file"),
AllowMultiple = false,
FileTypeFilter =
[
new FilePickerFileType(L("schedule.settings.picker_file_type", "ClassIsland CSES 课表"))
new FilePickerFileType(L("schedule.settings.picker_file_type", "ClassIsland CSES Schedule"))
{
Patterns = ["*.cses", "*.yaml", "*.yml"]
}
@@ -155,7 +152,7 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
{
Id = Guid.NewGuid().ToString("N"),
DisplayName = Path.GetFileNameWithoutExtension(importedPath)?.Trim()
?? L("schedule.settings.unnamed", "未命名课表"),
?? L("schedule.settings.unnamed", "Untitled Schedule"),
FilePath = importedPath
});
_activeScheduleId = _importedSchedules[^1].Id;
@@ -219,7 +216,7 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
var title = new TextBlock
{
Text = string.IsNullOrWhiteSpace(item.DisplayName)
? L("schedule.settings.unnamed", "未命名课表")
? L("schedule.settings.unnamed", "Untitled Schedule")
: item.DisplayName,
FontWeight = FontWeight.SemiBold
};
@@ -234,7 +231,7 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
var deleteButton = new Button
{
Content = L("schedule.settings.delete", "删除"),
Content = L("schedule.settings.delete", "Delete"),
Tag = item.Id,
Padding = new Thickness(12, 8),
HorizontalAlignment = HorizontalAlignment.Right

View File

@@ -0,0 +1,42 @@
<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.OfficeRecentDocumentsComponentEditor">
<StackPanel Spacing="16">
<Border Classes="component-editor-hero-card"
Padding="24">
<StackPanel Spacing="8">
<TextBlock x:Name="HeadlineTextBlock"
Classes="component-editor-headline"
TextWrapping="Wrap" />
<TextBlock x:Name="DescriptionTextBlock"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="SourcesHeaderTextBlock"
Classes="component-editor-section-title" />
<TextBlock x:Name="SourcesDescriptionTextBlock"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
<CheckBox x:Name="RegistryCheckBox"
IsCheckedChanged="OnSourceSelectionChanged" />
<CheckBox x:Name="RecentFoldersCheckBox"
IsCheckedChanged="OnSourceSelectionChanged" />
<CheckBox x:Name="JumpListsCheckBox"
IsCheckedChanged="OnSourceSelectionChanged" />
</StackPanel>
</Border>
<TextBlock x:Name="HintTextBlock"
Classes="component-editor-secondary-text"
Margin="12,0"
TextWrapping="Wrap" />
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,87 @@
using System;
using System.Linq;
using Avalonia.Interactivity;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Views.ComponentEditors;
public partial class OfficeRecentDocumentsComponentEditor : ComponentEditorViewBase
{
private bool _suppressEvents;
public OfficeRecentDocumentsComponentEditor()
: this(null)
{
}
public OfficeRecentDocumentsComponentEditor(DesktopComponentEditorContext? context)
: base(context)
{
InitializeComponent();
ApplyState();
}
private void ApplyState()
{
var snapshot = LoadSnapshot();
var enabledSources = OfficeRecentDocumentSourceTypes.NormalizeValues(
snapshot.OfficeRecentDocumentsEnabledSources,
useDefaultWhenEmpty: snapshot.OfficeRecentDocumentsEnabledSources is null);
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? L(
"component.office_recent_documents",
"Recent Documents");
DescriptionTextBlock.Text = L(
"office_recent_documents.settings.desc",
"Choose which Windows and Office sources this widget should scan for recent documents.");
SourcesHeaderTextBlock.Text = L(
"office_recent_documents.settings.sources_title",
"Recent document sources");
SourcesDescriptionTextBlock.Text = L(
"office_recent_documents.settings.sources_desc",
"You can combine multiple sources. Registry selection also keeps the Office interop MRU fallback available.");
RegistryCheckBox.Content = L(
"office_recent_documents.settings.source.registry",
"Office registry MRU");
RecentFoldersCheckBox.Content = L(
"office_recent_documents.settings.source.recent_folders",
"Windows Recent folders");
JumpListsCheckBox.Content = L(
"office_recent_documents.settings.source.jump_lists",
"Windows Jump Lists");
HintTextBlock.Text = L(
"office_recent_documents.settings.hint",
"If you disable all sources, this widget will stay empty until at least one source is enabled again.");
_suppressEvents = true;
RegistryCheckBox.IsChecked = enabledSources.Contains(OfficeRecentDocumentSourceTypes.Registry, StringComparer.OrdinalIgnoreCase);
RecentFoldersCheckBox.IsChecked = enabledSources.Contains(OfficeRecentDocumentSourceTypes.RecentFolders, StringComparer.OrdinalIgnoreCase);
JumpListsCheckBox.IsChecked = enabledSources.Contains(OfficeRecentDocumentSourceTypes.JumpLists, StringComparer.OrdinalIgnoreCase);
_suppressEvents = false;
}
private void OnSourceSelectionChanged(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
var selectedSources = new[]
{
RegistryCheckBox.IsChecked == true ? OfficeRecentDocumentSourceTypes.Registry : null,
RecentFoldersCheckBox.IsChecked == true ? OfficeRecentDocumentSourceTypes.RecentFolders : null,
JumpListsCheckBox.IsChecked == true ? OfficeRecentDocumentSourceTypes.JumpLists : null
}
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Cast<string>()
.ToList();
var snapshot = LoadSnapshot();
snapshot.OfficeRecentDocumentsEnabledSources = selectedSources;
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.OfficeRecentDocumentsEnabledSources));
}
}

View File

@@ -0,0 +1,50 @@
<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.RemovableStorageComponentEditor">
<StackPanel Spacing="16">
<Border Classes="component-editor-hero-card"
Padding="24">
<StackPanel Spacing="8">
<TextBlock x:Name="HeadlineTextBlock"
Classes="component-editor-headline"
TextWrapping="Wrap" />
<TextBlock x:Name="DescriptionTextBlock"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="ColorSchemeHeaderTextBlock"
Classes="component-editor-section-title" />
<ComboBox x:Name="ColorSchemeComboBox"
Classes="component-editor-select"
HorizontalAlignment="Stretch"
SelectionChanged="OnColorSchemeSelectionChanged">
<ComboBoxItem x:Name="FollowSystemColorSchemeItem"
Classes="component-editor-select-item"
Tag="follow_system" />
<ComboBoxItem x:Name="UseNativeColorSchemeItem"
Classes="component-editor-select-item"
Tag="native" />
</ComboBox>
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="10">
<TextBlock x:Name="BehaviorHeaderTextBlock"
Classes="component-editor-section-title" />
<TextBlock x:Name="BehaviorTextBlock"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
</StackPanel>
</Border>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,67 @@
using System;
using Avalonia.Controls;
using Avalonia.Interactivity;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.ComponentEditors;
public partial class RemovableStorageComponentEditor : ComponentEditorViewBase
{
private bool _suppressEvents;
public RemovableStorageComponentEditor()
: this(null)
{
}
public RemovableStorageComponentEditor(DesktopComponentEditorContext? context)
: base(context)
{
InitializeComponent();
ApplyState();
}
private void ApplyState()
{
var snapshot = LoadSnapshot();
var colorSchemeSource = snapshot.ColorSchemeSource;
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Removable Storage";
DescriptionTextBlock.Text = L(
"removable_storage.settings.desc",
"Show a connected USB drive with quick open and eject actions.");
ColorSchemeHeaderTextBlock.Text = L("component.settings.color_scheme", "Color Scheme");
FollowSystemColorSchemeItem.Content = L("component.color_scheme.follow_system", "Follow system color scheme");
UseNativeColorSchemeItem.Content = L("component.color_scheme.native", "Use component custom color scheme");
BehaviorHeaderTextBlock.Text = L("removable_storage.settings.behavior_title", "Behavior");
BehaviorTextBlock.Text = L(
"removable_storage.settings.behavior_desc",
"The widget automatically watches for removable drives and switches to the newest inserted USB drive.");
_suppressEvents = true;
ColorSchemeComboBox.SelectedItem =
string.IsNullOrWhiteSpace(colorSchemeSource) ||
string.Equals(colorSchemeSource, ThemeAppearanceValues.ColorSchemeFollowSystem, StringComparison.OrdinalIgnoreCase)
? FollowSystemColorSchemeItem
: UseNativeColorSchemeItem;
_suppressEvents = false;
}
private void OnColorSchemeSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
var snapshot = LoadSnapshot();
snapshot.ColorSchemeSource = ColorSchemeComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tag
? tag
: ThemeAppearanceValues.ColorSchemeFollowSystem;
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ColorSchemeSource));
}
}

View File

@@ -6,7 +6,7 @@
mc:Ignorable="d"
x:Class="LanMountainDesktop.Views.ComponentEditors.StudyEnvironmentComponentEditor">
<StackPanel Spacing="16">
<Border Classes="component-editor-hero_card"
<Border Classes="component-editor-hero-card"
Padding="24">
<StackPanel Spacing="8">
<TextBlock x:Name="HeadlineTextBlock"
@@ -23,14 +23,17 @@
<StackPanel Spacing="12">
<TextBlock x:Name="ColorSchemeHeaderTextBlock"
Classes="component-editor-section-title" />
<StackPanel Spacing="8">
<RadioButton x:Name="FollowSystemRadioButton"
GroupName="ColorScheme"
IsCheckedChanged="OnColorSchemeChanged" />
<RadioButton x:Name="UseNativeRadioButton"
GroupName="ColorScheme"
IsCheckedChanged="OnColorSchemeChanged" />
</StackPanel>
<ComboBox x:Name="ColorSchemeComboBox"
Classes="component-editor-select"
HorizontalAlignment="Stretch"
SelectionChanged="OnColorSchemeSelectionChanged">
<ComboBoxItem x:Name="FollowSystemColorSchemeItem"
Classes="component-editor-select-item"
Tag="follow_system" />
<ComboBoxItem x:Name="UseNativeColorSchemeItem"
Classes="component-editor-select-item"
Tag="native" />
</ComboBox>
</StackPanel>
</Border>
@@ -44,7 +47,7 @@
</StackPanel>
</Border>
<Border Classes="component-editor_card"
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="DbfsHeaderTextBlock"

View File

@@ -1,3 +1,5 @@
using System;
using Avalonia.Controls;
using Avalonia.Interactivity;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
@@ -34,43 +36,40 @@ public partial class StudyEnvironmentComponentEditor : ComponentEditorViewBase
}
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Study Environment";
DescriptionTextBlock.Text = L("study.environment.settings.desc", "配置右侧实时噪音值显示内容。");
ColorSchemeHeaderTextBlock.Text = L("component.settings.color_scheme", "配色方案");
FollowSystemRadioButton.Content = L("component.color_scheme.follow_system", "跟随系统配色");
UseNativeRadioButton.Content = L("component.color_scheme.native", "使用组件自定义配色");
DisplayDbToggleSwitch.Content = L("study.environment.settings.show_display_db", "显示 display dB");
DbfsToggleSwitch.Content = L("study.environment.settings.show_dbfs", "显示 dBFS");
HintTextBlock.Text = L("study.environment.settings.hint", "至少启用一种显示方式。");
DescriptionTextBlock.Text = L(
"study.environment.settings.desc",
"Configure the realtime audio level information shown on the right side.");
ColorSchemeHeaderTextBlock.Text = L("component.settings.color_scheme", "Color Scheme");
FollowSystemColorSchemeItem.Content = L("component.color_scheme.follow_system", "Follow system color scheme");
UseNativeColorSchemeItem.Content = L("component.color_scheme.native", "Use component custom color scheme");
DisplayDbToggleSwitch.Content = L("study.environment.settings.show_display_db", "Show display dB");
DbfsToggleSwitch.Content = L("study.environment.settings.show_dbfs", "Show dBFS");
HintTextBlock.Text = L("study.environment.settings.hint", "At least one display mode must stay enabled.");
_suppressEvents = true;
if (string.IsNullOrEmpty(colorSchemeSource) ||
colorSchemeSource == ThemeAppearanceValues.ColorSchemeFollowSystem)
{
FollowSystemRadioButton.IsChecked = true;
}
else
{
UseNativeRadioButton.IsChecked = true;
}
ColorSchemeComboBox.SelectedItem =
string.IsNullOrEmpty(colorSchemeSource) ||
string.Equals(colorSchemeSource, ThemeAppearanceValues.ColorSchemeFollowSystem, StringComparison.OrdinalIgnoreCase)
? FollowSystemColorSchemeItem
: UseNativeColorSchemeItem;
DisplayDbToggleSwitch.IsChecked = showDisplayDb;
DbfsToggleSwitch.IsChecked = showDbfs;
_suppressEvents = false;
}
private void OnColorSchemeChanged(object? sender, RoutedEventArgs e)
private void OnColorSchemeSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
var useNative = UseNativeRadioButton.IsChecked == true;
var colorSchemeSource = useNative
? ThemeAppearanceValues.ColorSchemeNative
var colorSchemeSource = ColorSchemeComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tag
? tag
: ThemeAppearanceValues.ColorSchemeFollowSystem;
var snapshot = LoadSnapshot();

View File

@@ -0,0 +1,40 @@
<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.WhiteboardComponentEditor">
<StackPanel Spacing="16">
<Border Classes="component-editor-hero-card"
Padding="24">
<StackPanel Spacing="8">
<TextBlock x:Name="HeadlineTextBlock"
Classes="component-editor-headline"
TextWrapping="Wrap" />
<TextBlock x:Name="DescriptionTextBlock"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="RetentionHeaderTextBlock"
Classes="component-editor-section-title" />
<TextBlock x:Name="RetentionDescriptionTextBlock"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
<ComboBox x:Name="RetentionComboBox"
Classes="component-editor-select"
HorizontalAlignment="Stretch"
SelectionChanged="OnRetentionSelectionChanged" />
</StackPanel>
</Border>
<TextBlock x:Name="InstanceHintTextBlock"
Classes="component-editor-secondary-text"
Margin="12,0"
TextWrapping="Wrap" />
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,106 @@
using System;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Interactivity;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Views.ComponentEditors;
public partial class WhiteboardComponentEditor : ComponentEditorViewBase
{
private bool _suppressEvents;
public WhiteboardComponentEditor()
: this(null)
{
}
public WhiteboardComponentEditor(DesktopComponentEditorContext? context)
: base(context)
{
InitializeComponent();
BuildRetentionOptions();
ApplyState();
}
private void BuildRetentionOptions()
{
RetentionComboBox.Items.Clear();
for (var days = WhiteboardNoteRetentionPolicy.MinimumDays; days <= WhiteboardNoteRetentionPolicy.MaximumDays; days++)
{
var item = new ComboBoxItem
{
Tag = days.ToString(),
Content = L(
"whiteboard.settings.retention.option",
"{0} days").Replace("{0}", days.ToString())
};
item.Classes.Add("component-editor-select-item");
RetentionComboBox.Items.Add(item);
}
}
private void ApplyState()
{
var snapshot = LoadSnapshot();
var retentionDays = NormalizeRetentionDays(snapshot.WhiteboardNoteRetentionDays);
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Blackboard";
DescriptionTextBlock.Text = L(
"whiteboard.settings.desc",
"Each blackboard keeps its own note history and saves it independently.");
RetentionHeaderTextBlock.Text = L(
"whiteboard.settings.retention.title",
"Note retention");
RetentionDescriptionTextBlock.Text = L(
"whiteboard.settings.retention.desc",
"Choose how long this blackboard should keep saved notes before expired data is removed automatically.");
InstanceHintTextBlock.Text = L(
"whiteboard.settings.instance_scope",
"This retention setting is stored per blackboard component instance.");
_suppressEvents = true;
RetentionComboBox.SelectedItem = RetentionComboBox.Items
.OfType<ComboBoxItem>()
.FirstOrDefault(item =>
item.Tag is string tag &&
int.TryParse(tag, out var days) &&
days == retentionDays);
_suppressEvents = false;
}
private void OnRetentionSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
var snapshot = LoadSnapshot();
snapshot.WhiteboardNoteRetentionDays = GetSelectedRetentionDays();
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.WhiteboardNoteRetentionDays));
}
private int GetSelectedRetentionDays()
{
if (RetentionComboBox.SelectedItem is ComboBoxItem item &&
item.Tag is string tag &&
int.TryParse(tag, out var days))
{
return NormalizeRetentionDays(days);
}
return WhiteboardNoteRetentionPolicy.DefaultDays;
}
private static int NormalizeRetentionDays(int days)
{
return WhiteboardNoteRetentionPolicy.NormalizeDays(
days <= 0
? WhiteboardNoteRetentionPolicy.DefaultDays
: days);
}
}

View File

@@ -38,7 +38,7 @@
<Grid Grid.Row="1"
ColumnDefinitions="240,*"
ColumnSpacing="12">
<Border Classes="glass-panel"
<Border Classes="surface-translucent-panel"
CornerRadius="24"
Padding="10">
<ListBox x:Name="CategoryListBox"
@@ -70,7 +70,7 @@
</Border>
<Border Grid.Column="1"
Classes="glass-strong"
Classes="surface-translucent-strong"
CornerRadius="24"
Padding="10">
<ScrollViewer VerticalScrollBarVisibility="Auto"

View File

@@ -43,7 +43,7 @@
<Grid Grid.Row="1">
<ScrollViewer x:Name="ContentScrollViewer"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Disabled">
VerticalScrollBarVisibility="Auto">
<StackPanel x:Name="CourseListPanel" />
</ScrollViewer>

View File

@@ -198,12 +198,32 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
return;
}
if (courseIndex < CourseListPanel.Children.Count)
// 确保在UI线程执行
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
if (courseIndex >= CourseListPanel.Children.Count)
{
return;
}
var targetChild = CourseListPanel.Children[courseIndex];
if (targetChild == null || !targetChild.IsArrangeValid)
{
return;
}
var bounds = targetChild.Bounds;
ContentScrollViewer.Offset = new Vector(0, bounds.Position.Y);
}
var scrollViewerHeight = ContentScrollViewer.Bounds.Height;
var contentHeight = CourseListPanel.Bounds.Height;
// 计算滚动位置,使当前课程居中显示
var targetOffset = bounds.Position.Y - (scrollViewerHeight / 2) + (bounds.Height / 2);
// 确保不超出边界
targetOffset = Math.Max(0, Math.Min(targetOffset, contentHeight - scrollViewerHeight));
ContentScrollViewer.Offset = new Vector(0, targetOffset);
}, Avalonia.Threading.DispatcherPriority.Loaded);
}
public void RefreshFromSettings()
@@ -298,6 +318,15 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
var currentIndex = FindCurrentCourseIndex();
_lastCurrentCourseIndex = currentIndex;
HideStatus();
// 初始化时自动跳转到当前课程
if (currentIndex >= 0)
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
ScrollToCurrentCourse(currentIndex);
}, Avalonia.Threading.DispatcherPriority.Loaded);
}
}
RenderScheduleItems();
@@ -484,10 +513,9 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
: CreateBrush("#FF4D5A");
var normalBulletBrush = CreateBrush(_isNightVisual ? "#B8BEC9" : "#9AA3B2");
var visibleItems = _courseItems.Take(maxVisibleItems).ToList();
for (var i = 0; i < visibleItems.Count; i++)
for (var i = 0; i < _courseItems.Count; i++)
{
var item = visibleItems[i];
var item = _courseItems[i];
var bulletBrush = item.IsCurrent ? currentBrush : normalBulletBrush;
var bullet = new Border

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"
@@ -8,7 +8,7 @@
x:Class="LanMountainDesktop.Views.Components.ClockWidget">
<Border x:Name="RootBorder"
Classes="glass-panel"
Classes="surface-translucent-panel"
Padding="0"
CornerRadius="24">
<StackPanel Orientation="Horizontal"

View File

@@ -23,6 +23,8 @@ public partial class ClockWidget : UserControl, IDesktopComponentWidget, ITimeZo
private TimeZoneService? _timeZoneService;
private ClockDisplayFormat _displayFormat = ClockDisplayFormat.HourMinuteSecond;
private bool _transparentBackground;
private double _lastAppliedCellSize = 100;
public ClockWidget()
{
@@ -44,11 +46,32 @@ public partial class ClockWidget : UserControl, IDesktopComponentWidget, ITimeZo
}
}
public bool TransparentBackground
{
get => _transparentBackground;
set
{
if (_transparentBackground == value)
{
return;
}
_transparentBackground = value;
ApplyChrome();
ApplyCellSize(_lastAppliedCellSize);
}
}
public void SetDisplayFormat(ClockDisplayFormat format)
{
DisplayFormat = format;
}
public void SetTransparentBackground(bool transparentBackground)
{
TransparentBackground = transparentBackground;
}
public void SetTimeZoneService(TimeZoneService timeZoneService)
{
ClearTimeZoneService();
@@ -101,6 +124,8 @@ public partial class ClockWidget : UserControl, IDesktopComponentWidget, ITimeZo
public void ApplyCellSize(double cellSize)
{
_lastAppliedCellSize = cellSize;
// --- Class Island “满盈”风格算法 ---
// 1. 计算组件高度:保持与任务栏核心比例一致 (0.74x)
@@ -129,8 +154,39 @@ public partial class ClockWidget : UserControl, IDesktopComponentWidget, ITimeZo
{
panel.Spacing = Math.Clamp(cellSize * 0.06, 2, 8);
}
if (_transparentBackground)
{
RootBorder.MinWidth = 0;
RootBorder.Padding = new Thickness(Math.Clamp(cellSize * 0.06, 4, 10), 0);
return;
}
// 确保清除可能存在的固定 Padding由代码控制“紧密感”
RootBorder.MinWidth = cellSize * 2.2;
RootBorder.Padding = new Thickness(Math.Clamp(cellSize * 0.15, 12, 24), 0);
}
private void ApplyChrome()
{
if (_transparentBackground)
{
RootBorder.Classes.Remove("glass-panel");
RootBorder.Background = Brushes.Transparent;
RootBorder.BorderBrush = Brushes.Transparent;
RootBorder.BorderThickness = new Thickness(0);
RootBorder.BoxShadow = default;
return;
}
if (!RootBorder.Classes.Contains("glass-panel"))
{
RootBorder.Classes.Add("glass-panel");
}
RootBorder.ClearValue(Border.BackgroundProperty);
RootBorder.ClearValue(Border.BorderBrushProperty);
RootBorder.ClearValue(Border.BorderThicknessProperty);
RootBorder.ClearValue(Border.BoxShadowProperty);
}
}

View File

@@ -444,6 +444,11 @@ public sealed class DesktopComponentRuntimeRegistry
"component.office_recent_documents",
() => new OfficeRecentDocumentsWidget(),
cellSize => Math.Clamp(cellSize * 0.50, 10, 24)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopRemovableStorage,
"component.removable_storage",
() => new RemovableStorageWidget(),
cellSize => Math.Clamp(cellSize * 0.46, 12, 26)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.HolidayCalendar,
"component.holiday_calendar",

View File

@@ -11,7 +11,7 @@
<Border x:Name="RootBorder"
CornerRadius="34"
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
Background="#2D5A8E"
ClipToBounds="True"
BorderThickness="0"
Padding="0">
@@ -23,15 +23,15 @@
VerticalAlignment="Top"
Margin="0,-40,-40,0"
CornerRadius="70"
Background="{DynamicResource SystemAccentColorLight2Brush}"
Opacity="0.2"
Background="#4A90D9"
Opacity="0.3"
IsHitTestVisible="False" />
<Grid RowDefinitions="Auto,*" RowSpacing="8" Margin="16,14,16,14">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
<TextBlock x:Name="HeaderTextBlock"
Text="最近文档"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Foreground="#D8FFFFFF"
FontSize="18"
FontWeight="SemiBold"
VerticalAlignment="Center" />
@@ -48,7 +48,7 @@
PointerPressed="OnRefreshPointerPressed">
<fi:SymbolIcon Symbol="ArrowSync"
FontSize="14"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
Foreground="#B8FFFFFF" />
</Button>
</Grid>
@@ -68,14 +68,14 @@
Width="130"
Height="90"
CornerRadius="10"
Background="{DynamicResource AdaptiveGlassPanelBackgroundBrush}"
Background="#3AFFFFFF"
Padding="10"
Cursor="Hand"
PointerPressed="OnDocumentCardPointerPressed">
<Grid RowDefinitions="Auto,*,Auto">
<TextBlock Grid.Row="0"
Text="{Binding FileName}"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Foreground="#D8FFFFFF"
FontSize="12"
FontWeight="Medium"
TextTrimming="CharacterEllipsis"
@@ -84,7 +84,7 @@
VerticalAlignment="Top" />
<TextBlock Grid.Row="2"
Text="{Binding TimeAgo}"
Foreground="{DynamicResource AdaptiveTextTertiaryBrush}"
Foreground="#9AFFFFFF"
FontSize="10"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
@@ -99,7 +99,7 @@
<TextBlock x:Name="StatusTextBlock"
IsVisible="False"
Text="暂无最近文档"
Foreground="{DynamicResource AdaptiveTextTertiaryBrush}"
Foreground="#9AFFFFFF"
FontSize="14"
HorizontalAlignment="Center"
VerticalAlignment="Center" />

View File

@@ -1,17 +1,23 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Input;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
using LanMountainDesktop.Views.Components;
namespace LanMountainDesktop.Views.Components;
public partial class OfficeRecentDocumentsWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget
public partial class OfficeRecentDocumentsWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, IComponentPlacementContextAware
{
private readonly IOfficeRecentDocumentsService _recentDocumentsService;
private readonly IComponentInstanceSettingsStore _componentSettingsStore = HostComponentSettingsStoreProvider.GetOrCreate();
private List<OfficeRecentDocument> _documents = new();
private string _componentId = BuiltInComponentIds.DesktopOfficeRecentDocuments;
private string _placementId = string.Empty;
private IReadOnlyList<string> _enabledSources = OfficeRecentDocumentSourceTypes.DefaultValues;
private bool _isOnActivePage;
private bool _isEditMode;
private bool _isLoading;
@@ -20,6 +26,7 @@ public partial class OfficeRecentDocumentsWidget : UserControl, IDesktopComponen
{
InitializeComponent();
_recentDocumentsService = new OfficeRecentDocumentsService();
ReloadSettings();
}
public void ApplyCellSize(double cellSize)
@@ -44,27 +51,45 @@ public partial class OfficeRecentDocumentsWidget : UserControl, IDesktopComponen
}
}
private void LoadDocuments()
public void SetComponentPlacementContext(string componentId, string? placementId)
{
_componentId = string.IsNullOrWhiteSpace(componentId)
? BuiltInComponentIds.DesktopOfficeRecentDocuments
: componentId.Trim();
_placementId = placementId?.Trim() ?? string.Empty;
ReloadSettings();
}
private async void LoadDocuments()
{
if (_isLoading)
{
return;
}
try
{
_isLoading = true;
ReloadSettings();
StatusTextBlock.IsVisible = false;
DocumentsItemsControl.ItemsSource = null;
_documents = _recentDocumentsService.GetRecentDocuments(20);
var enabledSources = _enabledSources.ToArray();
_documents = await Task.Run(() => _recentDocumentsService.GetRecentDocuments(20, enabledSources));
if (_documents.Count == 0)
{
StatusTextBlock.Text = "暂无最近文档";
StatusTextBlock.Text = "\u6682\u65e0\u6700\u8fd1\u6587\u6863";
StatusTextBlock.IsVisible = true;
return;
}
UpdateDisplay();
}
catch
catch (Exception ex)
{
StatusTextBlock.Text = "加载失败";
AppLogger.Warn("OfficeRecentDocsWidget", "Failed to load recent Office documents.", ex);
StatusTextBlock.Text = "\u52a0\u8f7d\u5931\u8d25";
StatusTextBlock.IsVisible = true;
}
finally
@@ -73,6 +98,14 @@ public partial class OfficeRecentDocumentsWidget : UserControl, IDesktopComponen
}
}
private void ReloadSettings()
{
var snapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
_enabledSources = OfficeRecentDocumentSourceTypes.NormalizeValues(
snapshot.OfficeRecentDocumentsEnabledSources,
useDefaultWhenEmpty: snapshot.OfficeRecentDocumentsEnabledSources is null);
}
private void UpdateDisplay()
{
var displayItems = _documents.Select(d => new OfficeRecentDocumentViewModel
@@ -90,15 +123,29 @@ public partial class OfficeRecentDocumentsWidget : UserControl, IDesktopComponen
var span = DateTime.Now - dateTime;
if (span.TotalMinutes < 1)
return "刚刚";
{
return "\u521a\u521a";
}
if (span.TotalMinutes < 60)
return $"{(int)span.TotalMinutes} 分钟前";
{
return $"{(int)span.TotalMinutes} \u5206\u949f\u524d";
}
if (span.TotalHours < 24)
return $"{(int)span.TotalHours} 小时前";
{
return $"{(int)span.TotalHours} \u5c0f\u65f6\u524d";
}
if (span.TotalDays < 7)
return $"{(int)span.TotalDays} 天前";
{
return $"{(int)span.TotalDays} \u5929\u524d";
}
if (span.TotalDays < 30)
return $"{(int)(span.TotalDays / 7)} 周前";
{
return $"{(int)(span.TotalDays / 7)} \u5468\u524d";
}
return dateTime.ToString("MM/dd");
}

View File

@@ -0,0 +1,119 @@
<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="280"
d:DesignHeight="280"
x:Class="LanMountainDesktop.Views.Components.RemovableStorageWidget">
<Border x:Name="RootBorder"
CornerRadius="28"
BorderThickness="1"
Padding="16"
ClipToBounds="True">
<Grid>
<Border x:Name="AccentOrb"
Width="132"
Height="132"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Margin="0,-48,-48,0"
CornerRadius="66"
IsHitTestVisible="False" />
<Border x:Name="AccentGlow"
Height="76"
Margin="-18,0,-18,-34"
HorizontalAlignment="Stretch"
VerticalAlignment="Bottom"
CornerRadius="38"
Opacity="0.42"
IsHitTestVisible="False" />
<Grid x:Name="LayoutGrid"
RowDefinitions="Auto,*,Auto,Auto"
RowSpacing="10">
<Grid x:Name="HeaderGrid"
ColumnDefinitions="Auto,*"
ColumnSpacing="12">
<Border x:Name="IconBadge"
Width="44"
Height="44"
CornerRadius="22"
VerticalAlignment="Top">
<fi:FluentIcon x:Name="DriveIcon"
Icon="UsbStick"
IconVariant="Regular"
FontSize="24"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<StackPanel x:Name="HeaderTextStack"
Grid.Column="1"
Spacing="2"
VerticalAlignment="Center">
<TextBlock x:Name="DriveNameTextBlock"
FontWeight="SemiBold"
TextWrapping="NoWrap"
TextTrimming="CharacterEllipsis" />
<TextBlock x:Name="DriveDetailTextBlock"
TextWrapping="NoWrap"
TextTrimming="CharacterEllipsis" />
</StackPanel>
</Grid>
<TextBlock x:Name="StatusTextBlock"
Grid.Row="1"
VerticalAlignment="Center"
TextWrapping="Wrap" />
<Button x:Name="OpenButton"
Grid.Row="2"
Height="42"
Padding="14,0"
CornerRadius="999"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Click="OnOpenClick">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<fi:FluentIcon x:Name="OpenButtonIcon"
Icon="OpenFolder"
IconVariant="Regular"
FontSize="16"
VerticalAlignment="Center" />
<TextBlock x:Name="OpenButtonTextBlock"
Grid.Column="1"
FontWeight="SemiBold"
VerticalAlignment="Center" />
</Grid>
</Button>
<Button x:Name="EjectButton"
Grid.Row="3"
Height="42"
Padding="14,0"
CornerRadius="999"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Click="OnEjectClick">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<fi:FluentIcon x:Name="EjectButtonIcon"
Icon="ArrowEject"
IconVariant="Regular"
FontSize="16"
VerticalAlignment="Center" />
<TextBlock x:Name="EjectButtonTextBlock"
Grid.Column="1"
FontWeight="SemiBold"
VerticalAlignment="Center" />
</Grid>
</Button>
</Grid>
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,596 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
using FluentIcons.Avalonia;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Theme;
namespace LanMountainDesktop.Views.Components;
public partial class RemovableStorageWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, IComponentPlacementContextAware, IDisposable
{
private readonly record struct RemovableStoragePalette(
Color BackgroundFrom,
Color BackgroundTo,
Color Border,
Color AccentOrb,
Color AccentGlow,
Color IconBadgeBackground,
Color IconForeground,
Color PrimaryText,
Color SecondaryText,
Color StatusText,
Color Accent,
Color OnAccent,
Color SecondaryButtonBackground,
Color SecondaryButtonBorder,
Color SecondaryButtonForeground,
Color DisabledButtonBackground,
Color DisabledButtonBorder,
Color DisabledButtonForeground);
private readonly DispatcherTimer _pollTimer = new()
{
Interval = TimeSpan.FromSeconds(2)
};
private readonly IRemovableStorageService _removableStorageService = new RemovableStorageService();
private readonly LocalizationService _localizationService = new();
private ISettingsService _settingsService = HostSettingsFacadeProvider.GetOrCreate().Settings;
private IComponentInstanceSettingsStore _componentSettingsStore = HostComponentSettingsStoreProvider.GetOrCreate();
private IReadOnlyList<RemovableStorageDrive> _connectedDrives = Array.Empty<RemovableStorageDrive>();
private string _componentId = BuiltInComponentIds.DesktopRemovableStorage;
private string _placementId = string.Empty;
private string _languageCode = "zh-CN";
private string? _componentColorScheme;
private string _selectedDriveRootPath = string.Empty;
private string? _statusOverrideText;
private double _currentCellSize = 48;
private bool _isAttached;
private bool _isOnActivePage = true;
private bool _isRefreshing;
private bool _isDisposed;
public RemovableStorageWidget()
{
InitializeComponent();
_pollTimer.Tick += OnPollTimerTick;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
ActualThemeVariantChanged += OnActualThemeVariantChanged;
ApplyCellSize(_currentCellSize);
ReloadSettings();
ApplyVisualState();
}
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
ApplyLayoutMetrics();
}
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
{
_ = isEditMode;
var shouldRefresh = !_isOnActivePage && isOnActivePage;
_isOnActivePage = isOnActivePage;
UpdatePollingState();
if (shouldRefresh)
{
_ = RefreshDriveListAsync();
}
}
public void SetComponentPlacementContext(string componentId, string? placementId)
{
_componentId = string.IsNullOrWhiteSpace(componentId)
? BuiltInComponentIds.DesktopRemovableStorage
: componentId.Trim();
_placementId = placementId?.Trim() ?? string.Empty;
RefreshFromSettings();
}
public void RefreshFromSettings()
{
ReloadSettings();
ApplyVisualState();
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_ = sender;
_ = e;
_isAttached = true;
UpdatePollingState();
_ = RefreshDriveListAsync();
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_ = sender;
_ = e;
_isAttached = false;
UpdatePollingState();
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
_ = sender;
_ = e;
ApplyLayoutMetrics();
}
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
{
_ = sender;
_ = e;
ApplyVisualState();
}
private async void OnPollTimerTick(object? sender, EventArgs e)
{
_ = sender;
_ = e;
await RefreshDriveListAsync();
}
private async void OnOpenClick(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
var drive = GetSelectedDrive();
if (drive is null)
{
return;
}
if (_removableStorageService.OpenDrive(drive.RootPath))
{
_statusOverrideText = L("removable_storage.widget.ready", "Ready to open or eject.");
ApplyVisualState();
return;
}
_statusOverrideText = L("removable_storage.widget.open_failed", "Failed to open this drive.");
ApplyVisualState();
await RefreshDriveListAsync();
}
private async void OnEjectClick(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
var drive = GetSelectedDrive();
if (drive is null)
{
return;
}
_statusOverrideText = L("removable_storage.widget.ejecting", "Ejecting drive...");
ApplyVisualState();
var ejected = _removableStorageService.EjectDrive(drive.RootPath);
_statusOverrideText = ejected
? L("removable_storage.widget.ejecting", "Ejecting drive...")
: L("removable_storage.widget.eject_failed", "Could not eject this drive. Close any files on it and try again.");
ApplyVisualState();
await RefreshDriveListAsync();
}
private async Task RefreshDriveListAsync()
{
if (_isDisposed || _isRefreshing)
{
return;
}
_isRefreshing = true;
try
{
var previousDriveRoots = new HashSet<string>(
_connectedDrives.Select(drive => drive.RootPath),
StringComparer.OrdinalIgnoreCase);
var latestDrives = await Task.Run(() => _removableStorageService.GetConnectedDrives());
if (_isDisposed)
{
return;
}
var newlyInsertedDrive = latestDrives.FirstOrDefault(drive => !previousDriveRoots.Contains(drive.RootPath));
_connectedDrives = latestDrives;
if (newlyInsertedDrive is not null)
{
_selectedDriveRootPath = newlyInsertedDrive.RootPath;
}
else if (string.IsNullOrWhiteSpace(_selectedDriveRootPath) ||
!_connectedDrives.Any(drive => string.Equals(drive.RootPath, _selectedDriveRootPath, StringComparison.OrdinalIgnoreCase)))
{
_selectedDriveRootPath = _connectedDrives.FirstOrDefault().RootPath ?? string.Empty;
}
if (_connectedDrives.Count == 0)
{
_selectedDriveRootPath = string.Empty;
_statusOverrideText = null;
}
else if (newlyInsertedDrive is not null)
{
_statusOverrideText = null;
}
ReloadSettings();
ApplyVisualState();
}
catch (Exception ex)
{
AppLogger.Warn("RemovableStorageWidget", "Failed to refresh removable storage widget.", ex);
_statusOverrideText = L("removable_storage.widget.refresh_failed", "Drive list refresh failed.");
ApplyVisualState();
}
finally
{
_isRefreshing = false;
}
}
private void ReloadSettings()
{
try
{
var appSettings = _settingsService.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
var componentSettings = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
_languageCode = _localizationService.NormalizeLanguageCode(appSettings.LanguageCode);
_componentColorScheme = componentSettings.ColorSchemeSource;
}
catch
{
_languageCode = _localizationService.NormalizeLanguageCode(_languageCode);
}
}
private void ApplyVisualState()
{
var drive = GetSelectedDrive();
var hasDrive = drive is not null;
var palette = ResolvePalette();
RootBorder.Background = CreateGradientBrush(palette.BackgroundFrom, palette.BackgroundTo);
RootBorder.BorderBrush = CreateBrush(palette.Border);
AccentOrb.Background = CreateBrush(palette.AccentOrb);
AccentGlow.Background = CreateBrush(palette.AccentGlow);
IconBadge.Background = CreateBrush(palette.IconBadgeBackground);
DriveIcon.Foreground = CreateBrush(palette.IconForeground);
DriveNameTextBlock.Foreground = CreateBrush(palette.PrimaryText);
DriveDetailTextBlock.Foreground = CreateBrush(palette.SecondaryText);
StatusTextBlock.Foreground = CreateBrush(palette.StatusText);
if (hasDrive)
{
ApplyButtonPalette(
OpenButton,
OpenButtonIcon,
OpenButtonTextBlock,
palette.Accent,
palette.OnAccent,
palette.Accent);
ApplyButtonPalette(
EjectButton,
EjectButtonIcon,
EjectButtonTextBlock,
palette.SecondaryButtonBackground,
palette.SecondaryButtonForeground,
palette.SecondaryButtonBorder);
}
else
{
ApplyButtonPalette(
OpenButton,
OpenButtonIcon,
OpenButtonTextBlock,
palette.DisabledButtonBackground,
palette.DisabledButtonForeground,
palette.DisabledButtonBorder);
ApplyButtonPalette(
EjectButton,
EjectButtonIcon,
EjectButtonTextBlock,
palette.DisabledButtonBackground,
palette.DisabledButtonForeground,
palette.DisabledButtonBorder);
}
OpenButton.IsEnabled = hasDrive;
EjectButton.IsEnabled = hasDrive;
OpenButtonTextBlock.Text = L("removable_storage.action.open", "Open");
EjectButtonTextBlock.Text = L("removable_storage.action.eject", "Eject");
if (hasDrive)
{
var selectedDrive = drive!;
DriveNameTextBlock.Text = ResolveDriveName(selectedDrive);
DriveDetailTextBlock.Text = selectedDrive.DriveLetter;
StatusTextBlock.Text = _statusOverrideText ??
L("removable_storage.widget.ready", "Ready to open or eject.");
}
else
{
DriveNameTextBlock.Text = L("removable_storage.widget.empty_title", "No device inserted");
DriveDetailTextBlock.Text = L("removable_storage.widget.empty_subtitle", "Insert a USB drive to show it here.");
StatusTextBlock.Text = L("removable_storage.widget.empty_hint", "Buttons stay disabled until a removable device is inserted.");
}
ApplyLayoutMetrics();
}
private void ApplyLayoutMetrics()
{
var scale = ResolveScale();
var width = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * 2;
var cornerRadius = Math.Clamp(_currentCellSize * 0.44, 18, 34);
RootBorder.CornerRadius = new CornerRadius(cornerRadius);
RootBorder.Padding = new Thickness(
Math.Clamp(16 * scale, 10, 24),
Math.Clamp(15 * scale, 10, 22),
Math.Clamp(16 * scale, 10, 24),
Math.Clamp(15 * scale, 10, 22));
LayoutGrid.RowSpacing = Math.Clamp(10 * scale, 8, 16);
HeaderGrid.ColumnSpacing = Math.Clamp(12 * scale, 8, 16);
HeaderTextStack.Spacing = Math.Clamp(2 * scale, 1, 4);
var badgeSize = Math.Clamp(44 * scale, 38, 60);
IconBadge.Width = badgeSize;
IconBadge.Height = badgeSize;
IconBadge.CornerRadius = new CornerRadius(badgeSize * 0.5);
DriveIcon.FontSize = Math.Clamp(24 * scale, 20, 32);
DriveNameTextBlock.FontSize = Math.Clamp(16 * scale, 13, 24);
DriveDetailTextBlock.FontSize = Math.Clamp(11.5 * scale, 10, 16);
StatusTextBlock.FontSize = Math.Clamp(12 * scale, 10, 17);
StatusTextBlock.MaxWidth = Math.Max(96, width - (RootBorder.Padding.Left + RootBorder.Padding.Right));
var buttonHeight = Math.Clamp(42 * scale, 38, 54);
var buttonPadding = Math.Clamp(14 * scale, 10, 20);
var buttonCornerRadius = Math.Clamp(buttonHeight * 0.5, 18, 999);
OpenButton.Height = buttonHeight;
OpenButton.Padding = new Thickness(buttonPadding, 0);
OpenButton.CornerRadius = new CornerRadius(buttonCornerRadius);
EjectButton.Height = buttonHeight;
EjectButton.Padding = new Thickness(buttonPadding, 0);
EjectButton.CornerRadius = new CornerRadius(buttonCornerRadius);
OpenButtonIcon.FontSize = Math.Clamp(16 * scale, 14, 20);
EjectButtonIcon.FontSize = Math.Clamp(16 * scale, 14, 20);
OpenButtonTextBlock.FontSize = Math.Clamp(13 * scale, 11.5, 18);
EjectButtonTextBlock.FontSize = Math.Clamp(13 * scale, 11.5, 18);
AccentOrb.Width = Math.Clamp(width * 0.44, 96, 176);
AccentOrb.Height = AccentOrb.Width;
AccentOrb.CornerRadius = new CornerRadius(AccentOrb.Width * 0.5);
AccentGlow.Height = Math.Clamp(76 * scale, 52, 110);
AccentGlow.CornerRadius = new CornerRadius(AccentGlow.Height * 0.5);
}
private RemovableStorageDrive? GetSelectedDrive()
{
if (_connectedDrives.Count == 0)
{
return null;
}
if (!string.IsNullOrWhiteSpace(_selectedDriveRootPath))
{
var selected = _connectedDrives.FirstOrDefault(drive =>
string.Equals(drive.RootPath, _selectedDriveRootPath, StringComparison.OrdinalIgnoreCase));
if (selected is not null)
{
return selected;
}
}
return _connectedDrives[0];
}
private string ResolveDriveName(RemovableStorageDrive drive)
{
return string.IsNullOrWhiteSpace(drive.VolumeLabel)
? L("removable_storage.widget.default_name", "Removable Drive")
: drive.VolumeLabel.Trim();
}
private RemovableStoragePalette ResolvePalette()
{
var useMonetColor = ComponentColorSchemeHelper.ShouldUseMonetColor(
_componentColorScheme,
ComponentColorSchemeHelper.GetCurrentGlobalThemeColorMode());
if (!useMonetColor)
{
var nativeAccent = Color.Parse("#FF65A8FF");
var nativeBackgroundFrom = Color.Parse("#FF10345F");
var nativeBackgroundTo = Color.Parse("#FF0D213E");
var nativePrimaryText = Color.Parse("#FFF4F8FF");
var nativeSecondaryText = Color.Parse("#C8D9F5FF");
var nativeDisabled = Color.Parse("#30465D7A");
return new RemovableStoragePalette(
nativeBackgroundFrom,
nativeBackgroundTo,
Color.Parse("#6A97D6FF"),
Color.Parse("#2F8BC5FF"),
Color.Parse("#4C79BFFF"),
Color.Parse("#335BAAFF"),
Color.Parse("#FFF5FAFF"),
nativePrimaryText,
nativeSecondaryText,
Color.Parse("#D8E7FFFF"),
nativeAccent,
ColorMath.EnsureContrast(Color.Parse("#FF071420"), nativeAccent, 4.5),
Color.Parse("#24FFFFFF"),
Color.Parse("#5A9ACDFF"),
nativePrimaryText,
nativeDisabled,
Color.Parse("#4D6782A0"),
Color.Parse("#8FA8BDD1"));
}
var surfaceRaised = ResolveThemeColor("AdaptiveSurfaceRaisedBrush", "#FF1A2332");
var surfaceOverlay = ResolveThemeColor("AdaptiveSurfaceOverlayBrush", "#FF111827");
var accent = ResolveThemeColor("AdaptiveAccentBrush", "#FF61A8FF");
var onAccent = ResolveThemeColor("AdaptiveOnAccentBrush", "#FFFFFFFF");
var primaryText = ResolveThemeColor("AdaptiveTextPrimaryBrush", "#FFF8FAFC");
var secondaryText = ResolveThemeColor("AdaptiveTextSecondaryBrush", "#FFD0D7E3");
var mutedText = ResolveThemeColor("AdaptiveTextMutedBrush", "#FFAFB8C7");
var disabledButtonBackground = ColorMath.WithAlpha(ColorMath.Blend(surfaceRaised, surfaceOverlay, 0.35), 0xD8);
var disabledButtonBorder = ColorMath.WithAlpha(ColorMath.Blend(surfaceRaised, accent, 0.18), 0x88);
var disabledButtonForeground = ColorMath.WithAlpha(primaryText, 0x88);
var backgroundFrom = ColorMath.Blend(surfaceRaised, accent, 0.18);
var backgroundTo = ColorMath.Blend(surfaceOverlay, surfaceRaised, 0.46);
var border = ColorMath.WithAlpha(ColorMath.Blend(accent, surfaceRaised, 0.38), 0xB8);
var iconBadgeBackground = ColorMath.Blend(surfaceRaised, accent, 0.28);
var iconForeground = ColorMath.EnsureContrast(accent, iconBadgeBackground, 3.0);
var secondaryButtonBackground = ColorMath.WithAlpha(ColorMath.Blend(surfaceRaised, accent, 0.10), 0xE6);
var secondaryButtonBorder = ColorMath.WithAlpha(ColorMath.Blend(accent, surfaceRaised, 0.46), 0xC6);
return new RemovableStoragePalette(
backgroundFrom,
backgroundTo,
border,
ColorMath.WithAlpha(accent, 0x28),
ColorMath.WithAlpha(ColorMath.Blend(accent, backgroundFrom, 0.26), 0x74),
iconBadgeBackground,
iconForeground,
primaryText,
secondaryText,
mutedText,
accent,
onAccent,
secondaryButtonBackground,
secondaryButtonBorder,
primaryText,
disabledButtonBackground,
disabledButtonBorder,
disabledButtonForeground);
}
private Color ResolveThemeColor(string resourceKey, string fallbackHex)
{
if (this.TryFindResource(resourceKey, out var resource))
{
if (resource is ISolidColorBrush solidBrush)
{
return solidBrush.Color;
}
if (resource is SolidColorBrush directSolidBrush)
{
return directSolidBrush.Color;
}
}
return Color.Parse(fallbackHex);
}
private double ResolveScale()
{
var cellScale = Math.Clamp(_currentCellSize / 48d, 0.72, 2.2);
var widthScale = Bounds.Width > 1 ? Math.Clamp(Bounds.Width / 220d, 0.72, 2.4) : 1;
var heightScale = Bounds.Height > 1 ? Math.Clamp(Bounds.Height / 220d, 0.72, 2.4) : 1;
return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale)), 0.72, 2.2);
}
private string L(string key, string fallback)
{
return _localizationService.GetString(_languageCode, key, fallback);
}
private static void ApplyButtonPalette(
Button button,
FluentIcon icon,
TextBlock textBlock,
Color background,
Color foreground,
Color border)
{
button.Background = CreateBrush(background);
button.BorderBrush = CreateBrush(border);
button.BorderThickness = new Thickness(1);
button.Foreground = CreateBrush(foreground);
icon.Foreground = CreateBrush(foreground);
textBlock.Foreground = CreateBrush(foreground);
}
private static IBrush CreateGradientBrush(Color from, Color to)
{
return new LinearGradientBrush
{
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
GradientStops = new GradientStops
{
new GradientStop(from, 0),
new GradientStop(to, 1)
}
};
}
private static SolidColorBrush CreateBrush(Color color)
{
return new(color);
}
private void UpdatePollingState()
{
if (_isAttached && _isOnActivePage)
{
if (!_pollTimer.IsEnabled)
{
_pollTimer.Start();
}
return;
}
_pollTimer.Stop();
}
public void Dispose()
{
if (_isDisposed)
{
return;
}
_isDisposed = true;
_pollTimer.Stop();
_pollTimer.Tick -= OnPollTimerTick;
AttachedToVisualTree -= OnAttachedToVisualTree;
DetachedFromVisualTree -= OnDetachedFromVisualTree;
SizeChanged -= OnSizeChanged;
ActualThemeVariantChanged -= OnActualThemeVariantChanged;
}
}

View File

@@ -7,7 +7,7 @@
d:DesignHeight="220"
x:Class="LanMountainDesktop.Views.Components.StudyDeductionReasonsWidget">
<Border x:Name="RootBorder"
Classes="glass-strong"
Classes="surface-translucent-strong"
CornerRadius="22"
Padding="12,10"
ClipToBounds="True">

View File

@@ -7,7 +7,7 @@
d:DesignHeight="220"
x:Class="LanMountainDesktop.Views.Components.StudyInterruptDensityWidget">
<Border x:Name="RootBorder"
Classes="glass-strong"
Classes="surface-translucent-strong"
CornerRadius="22"
Padding="14,10"
ClipToBounds="True">

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"
@@ -8,7 +8,7 @@
d:DesignHeight="320"
x:Class="LanMountainDesktop.Views.Components.StudyNoiseCurveWidget">
<Border x:Name="RootBorder"
Classes="glass-strong"
Classes="surface-translucent-strong"
CornerRadius="24"
Padding="14,10"
ClipToBounds="True">

View File

@@ -8,7 +8,7 @@
d:DesignHeight="320"
x:Class="LanMountainDesktop.Views.Components.StudyNoiseDistributionWidget">
<Border x:Name="RootBorder"
Classes="glass-strong"
Classes="surface-translucent-strong"
CornerRadius="24"
Padding="14,10"
ClipToBounds="True">

View File

@@ -7,7 +7,7 @@
d:DesignHeight="360"
x:Class="LanMountainDesktop.Views.Components.StudyScoreOverviewWidget">
<Border x:Name="RootBorder"
Classes="glass-strong"
Classes="surface-translucent-strong"
CornerRadius="24"
Padding="16,14"
ClipToBounds="True">

View File

@@ -8,7 +8,7 @@
d:DesignHeight="150"
x:Class="LanMountainDesktop.Views.Components.StudySessionControlWidget">
<Border x:Name="RootBorder"
Classes="glass-strong"
Classes="surface-translucent-strong"
CornerRadius="18"
Padding="14,10"
ClipToBounds="True">

View File

@@ -7,7 +7,7 @@
d:DesignHeight="220"
x:Class="LanMountainDesktop.Views.Components.StudySessionHistoryWidget">
<Border x:Name="RootBorder"
Classes="glass-strong"
Classes="surface-translucent-strong"
CornerRadius="22"
Padding="12,10"
ClipToBounds="True">

View File

@@ -1,4 +1,5 @@
using System;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
@@ -9,13 +10,18 @@ using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using Avalonia.Styling;
using Avalonia.Threading;
using DotNetCampus.Inking;
using DotNetCampus.Inking.Primitive;
using FluentIcons.Avalonia;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
using SkiaSharp;
namespace LanMountainDesktop.Views.Components;
public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IComponentPlacementContextAware, IDisposable
{
private enum WhiteboardToolMode
{
@@ -24,11 +30,22 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
}
private static readonly PropertyInfo? StrokeColorProperty = typeof(SkiaStroke).GetProperty(nameof(SkiaStroke.Color));
private static readonly PropertyInfo? StrokePointListProperty = typeof(SkiaStroke).GetProperty("PointList");
private readonly int _baseWidthCells;
private readonly IComponentInstanceSettingsStore _componentSettingsStore = HostComponentSettingsStoreProvider.GetOrCreate();
private readonly IWhiteboardNotePersistenceService _notePersistenceService = new WhiteboardNotePersistenceService();
private readonly DispatcherTimer _noteSaveTimer = new() { Interval = TimeSpan.FromMinutes(5) };
private double _currentCellSize = 48;
private WhiteboardToolMode _toolMode = WhiteboardToolMode.Pen;
private bool? _isNightModeApplied;
private SKColor _currentInkColor = SKColors.Black;
private string _componentId = BuiltInComponentIds.DesktopWhiteboard;
private string _placementId = string.Empty;
private int _noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays;
private bool _isApplyingPersistedSnapshot;
private bool _noteDirty;
private int _noteLoadRevision;
private bool _disposed;
public WhiteboardWidget()
: this(baseWidthCells: 2)
@@ -43,21 +60,26 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
ActualThemeVariantChanged += OnActualThemeVariantChanged;
_noteSaveTimer.Tick += OnNoteSaveTimerTick;
ConfigureInkCanvas();
ApplyCellSize(_currentCellSize);
RefreshFromSettings();
ApplyThemeVisual(force: true);
SetToolMode(WhiteboardToolMode.Pen);
}
public int NoteRetentionDays => _noteRetentionDays;
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
ApplyThemeVisual(force: true);
SchedulePersistedNoteLoad();
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
// Keep all state in-memory for lightweight re-attach scenarios.
PersistNoteImmediately();
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
@@ -79,6 +101,9 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
settings.EraserSize = new Size(20, 20);
settings.IsBitmapCacheEnabled = true;
settings.MaxBitmapCacheSize = 2048;
InkCanvas.StrokeCollected += OnInkCanvasStrokeCollected;
InkCanvas.PointerReleased += OnInkCanvasPointerReleased;
InkCanvas.PointerCaptureLost += OnInkCanvasPointerCaptureLost;
}
public void ApplyCellSize(double cellSize)
@@ -134,6 +159,63 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
RefreshToolButtonVisuals();
}
public void SetComponentPlacementContext(string componentId, string? placementId)
{
var nextComponentId = string.IsNullOrWhiteSpace(componentId)
? BuiltInComponentIds.DesktopWhiteboard
: componentId.Trim();
var nextPlacementId = placementId?.Trim() ?? string.Empty;
if (_noteDirty &&
HasValidPersistenceContext() &&
(string.Compare(_componentId, nextComponentId, StringComparison.OrdinalIgnoreCase) != 0 ||
string.Compare(_placementId, nextPlacementId, StringComparison.OrdinalIgnoreCase) != 0))
{
PersistNoteImmediately();
}
_componentId = nextComponentId;
_placementId = nextPlacementId;
RefreshFromSettings();
ClearAllStrokes();
SchedulePersistedNoteLoad();
}
public void RefreshFromSettings()
{
try
{
if (!HasValidPersistenceContext())
{
_noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays;
return;
}
var snapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
_noteRetentionDays = NormalizeRetentionDays(snapshot.WhiteboardNoteRetentionDays);
_notePersistenceService.TryDeleteExpiredNote(_componentId, _placementId, _noteRetentionDays);
}
catch
{
_noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays;
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_noteSaveTimer.Stop();
_noteSaveTimer.Tick -= OnNoteSaveTimerTick;
InkCanvas.StrokeCollected -= OnInkCanvasStrokeCollected;
InkCanvas.PointerReleased -= OnInkCanvasPointerReleased;
InkCanvas.PointerCaptureLost -= OnInkCanvasPointerCaptureLost;
}
private void RecolorAllStrokes(SKColor targetColor)
{
for (var i = 0; i < InkCanvas.Strokes.Count; i++)
@@ -183,6 +265,14 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
return false;
}
private static int NormalizeRetentionDays(int days)
{
return WhiteboardNoteRetentionPolicy.NormalizeDays(
days <= 0
? WhiteboardNoteRetentionPolicy.DefaultDays
: days);
}
private static double CalculateRelativeLuminance(Color color)
{
static double ToLinear(double channel)
@@ -267,25 +357,8 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
private void OnClearButtonClick(object? sender, RoutedEventArgs e)
{
var strokeList = InkCanvas.Strokes.ToList();
foreach (var stroke in strokeList)
{
try
{
if (ReferenceEquals(stroke.InkCanvas, InkCanvas.AvaloniaSkiaInkCanvas))
{
InkCanvas.AvaloniaSkiaInkCanvas.RemoveStaticStroke(stroke);
}
}
catch
{
// Keep the widget alive even if one stroke removal fails.
}
}
InkCanvas.AvaloniaSkiaInkCanvas.UseBitmapCache(false);
InkCanvas.AvaloniaSkiaInkCanvas.InvalidateBitmapCache();
InkCanvas.InvalidateVisual();
ClearAllStrokes();
QueueNoteSave();
}
private async void OnExportButtonClick(object? sender, RoutedEventArgs e)
@@ -358,4 +431,273 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
svgCanvas.Flush();
}
private void OnInkCanvasStrokeCollected(object? sender, DotNetCampus.Inking.Contexts.AvaloniaSkiaInkCanvasStrokeCollectedEventArgs e)
{
_ = sender;
_ = e;
QueueNoteSave();
}
private void OnInkCanvasPointerReleased(object? sender, Avalonia.Input.PointerReleasedEventArgs e)
{
_ = sender;
_ = e;
QueueNoteSave();
}
private void OnInkCanvasPointerCaptureLost(object? sender, Avalonia.Input.PointerCaptureLostEventArgs e)
{
_ = sender;
_ = e;
QueueNoteSave();
}
private void OnNoteSaveTimerTick(object? sender, EventArgs e)
{
_ = sender;
if (_disposed || _isApplyingPersistedSnapshot || !HasValidPersistenceContext())
{
_noteSaveTimer.Stop();
return;
}
if (!_noteDirty)
{
_noteSaveTimer.Stop();
return;
}
var noteSnapshot = BuildNoteSnapshot();
var componentId = _componentId;
var placementId = _placementId;
var retentionDays = _noteRetentionDays;
_noteDirty = false;
_noteSaveTimer.Stop();
_ = Task.Run(() => _notePersistenceService.SaveNote(componentId, placementId, noteSnapshot, retentionDays));
}
private void QueueNoteSave()
{
if (_disposed || _isApplyingPersistedSnapshot || !HasValidPersistenceContext())
{
return;
}
_noteDirty = true;
if (!_noteSaveTimer.IsEnabled)
{
_noteSaveTimer.Start();
}
}
private void PersistNoteImmediately()
{
if (_disposed || _isApplyingPersistedSnapshot || !HasValidPersistenceContext())
{
return;
}
if (!_noteDirty)
{
return;
}
_noteDirty = false;
_noteSaveTimer.Stop();
var noteSnapshot = BuildNoteSnapshot();
var componentId = _componentId;
var placementId = _placementId;
var retentionDays = _noteRetentionDays;
_ = Task.Run(() => _notePersistenceService.SaveNote(
componentId,
placementId,
noteSnapshot,
retentionDays));
}
private async void SchedulePersistedNoteLoad()
{
if (!HasValidPersistenceContext())
{
return;
}
var revision = ++_noteLoadRevision;
var componentId = _componentId;
var placementId = _placementId;
var retentionDays = _noteRetentionDays;
try
{
var noteSnapshot = await Task.Run(() => _notePersistenceService.LoadNote(componentId, placementId, retentionDays));
if (_disposed || revision != _noteLoadRevision ||
!string.Equals(_componentId, componentId, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(_placementId, placementId, StringComparison.OrdinalIgnoreCase))
{
return;
}
Dispatcher.UIThread.Post(() =>
{
if (_disposed || revision != _noteLoadRevision)
{
return;
}
_isApplyingPersistedSnapshot = true;
try
{
ClearAllStrokes();
ApplyNoteSnapshot(noteSnapshot);
RecolorAllStrokes(_currentInkColor);
}
finally
{
_isApplyingPersistedSnapshot = false;
}
});
}
catch
{
// Best effort only. Whiteboard should stay usable if persistence is unavailable.
}
}
private WhiteboardNoteSnapshot BuildNoteSnapshot()
{
return new WhiteboardNoteSnapshot
{
Strokes = InkCanvas.Strokes
.Select(BuildStrokeSnapshot)
.Where(static stroke => stroke.Points.Count > 0)
.ToList()
};
}
private static WhiteboardStrokeSnapshot BuildStrokeSnapshot(SkiaStroke stroke)
{
var pointList = TryGetStrokePoints(stroke);
return new WhiteboardStrokeSnapshot
{
Color = ToHexColor(stroke.Color),
InkThickness = stroke.InkThickness,
IgnorePressure = stroke.IgnorePressure,
Points = pointList
.Select(static point => new WhiteboardStylusPointSnapshot
{
X = point.X,
Y = point.Y,
Pressure = point.Pressure,
Width = point.Width ?? 0,
Height = point.Height ?? 0
})
.ToList()
};
}
private void ApplyNoteSnapshot(WhiteboardNoteSnapshot snapshot)
{
if (snapshot.Strokes.Count == 0)
{
return;
}
var renderer = InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkStrokeRenderer;
foreach (var strokeSnapshot in snapshot.Strokes)
{
var stylusPoints = strokeSnapshot.Points
.Select(ConvertStylusPoint)
.ToList();
if (stylusPoints.Count == 0)
{
continue;
}
var path = renderer.RenderInkToPath(stylusPoints, strokeSnapshot.InkThickness);
var staticStroke = SkiaStroke.CreateStaticStroke(
InkId.NewId(),
path,
new StylusPointListSpan(stylusPoints, 0, stylusPoints.Count),
ParseStrokeColor(strokeSnapshot.Color),
(float)strokeSnapshot.InkThickness,
strokeSnapshot.IgnorePressure,
renderer);
InkCanvas.AvaloniaSkiaInkCanvas.AddStaticStroke(staticStroke);
}
InkCanvas.AvaloniaSkiaInkCanvas.UpdateBitmapCache();
InkCanvas.InvalidateVisual();
}
private static InkStylusPoint ConvertStylusPoint(WhiteboardStylusPointSnapshot point)
{
return new InkStylusPoint(point.X, point.Y, (float)Math.Clamp(point.Pressure, 0f, 1f))
{
Width = point.Width > 0 ? point.Width : null,
Height = point.Height > 0 ? point.Height : null
};
}
private static SKColor ParseStrokeColor(string? value)
{
if (!string.IsNullOrWhiteSpace(value))
{
try
{
var color = Color.Parse(value);
return new SKColor(color.R, color.G, color.B, color.A);
}
catch
{
// Fall through to the default color.
}
}
return SKColors.Black;
}
private static string ToHexColor(SKColor color)
{
return $"#{color.Alpha:X2}{color.Red:X2}{color.Green:X2}{color.Blue:X2}";
}
private void ClearAllStrokes()
{
var strokeList = InkCanvas.Strokes.ToList();
foreach (var stroke in strokeList)
{
try
{
if (ReferenceEquals(stroke.InkCanvas, InkCanvas.AvaloniaSkiaInkCanvas))
{
InkCanvas.AvaloniaSkiaInkCanvas.RemoveStaticStroke(stroke);
}
}
catch
{
// Keep the widget alive even if one stroke removal fails.
}
}
InkCanvas.AvaloniaSkiaInkCanvas.UseBitmapCache(false);
InkCanvas.AvaloniaSkiaInkCanvas.InvalidateBitmapCache();
InkCanvas.InvalidateVisual();
}
private bool HasValidPersistenceContext()
{
return !string.IsNullOrWhiteSpace(_componentId) &&
!string.IsNullOrWhiteSpace(_placementId);
}
private static IReadOnlyList<InkStylusPoint> TryGetStrokePoints(SkiaStroke stroke)
{
if (StrokePointListProperty?.GetValue(stroke) is IReadOnlyList<InkStylusPoint> pointList)
{
return pointList;
}
return Array.Empty<InkStylusPoint>();
}
}

View File

@@ -398,10 +398,12 @@ public partial class MainWindow
_clockDisplayFormat = snapshot.ClockDisplayFormat == "HourMinute"
? ClockDisplayFormat.HourMinute
: ClockDisplayFormat.HourMinuteSecond;
_statusBarClockTransparentBackground = snapshot.StatusBarClockTransparentBackground;
if (ClockWidget is not null)
{
ClockWidget.SetDisplayFormat(_clockDisplayFormat);
ClockWidget.SetTransparentBackground(_statusBarClockTransparentBackground);
}
}
@@ -413,6 +415,7 @@ public partial class MainWindow
if (ClockWidget is not null)
{
ClockWidget.IsVisible = showClock;
ClockWidget.SetTransparentBackground(_statusBarClockTransparentBackground);
if (showClock)
{
ClockWidget.SetDisplayFormat(_clockDisplayFormat);
@@ -964,6 +967,7 @@ public partial class MainWindow
DisposeComponentIfNeeded(host);
contentHost.Child = component;
ApplyDesktopEditStateToHost(host, _isComponentLibraryOpen);
InvalidateDesktopPageAwareComponentContextCache();
UpdateDesktopPageAwareComponentContext();
if (_selectedDesktopComponentHost == host)
{
@@ -1102,6 +1106,7 @@ public partial class MainWindow
ClearTimeZoneServiceBindings(pageGrid.Children.OfType<Control>().ToList());
pageGrid.Children.Clear();
InvalidateDesktopPageAwareComponentContextCache();
var maxColumns = pageGrid.ColumnDefinitions.Count;
var maxRows = pageGrid.RowDefinitions.Count;
@@ -1204,6 +1209,7 @@ public partial class MainWindow
pageGrid.Children.Add(host);
_desktopComponentPlacements.Add(placement);
InvalidateDesktopPageAwareComponentContextCache();
UpdateDesktopPageAwareComponentContext();
PersistSettings();
@@ -1577,14 +1583,86 @@ public partial class MainWindow
}
}
private void InvalidateDesktopPageAwareComponentContextCache()
{
_desktopPageContextInitialized = false;
_desktopPageContextActiveMask = 0;
}
private int BuildDesktopPageAwareComponentActiveMask()
{
if (_isSettingsOpen)
{
return 0;
}
var activeMask = 0;
if (_desktopSurfacePageWidth > 1 &&
_desktopPagesHostTransform is not null &&
(_isDesktopSwipeActive ||
_desktopPageContextSettlingSourceIndex is not null ||
_desktopPageContextSettlingTargetIndex is not null))
{
var viewportLeft = -_desktopPagesHostTransform.X;
var viewportRight = viewportLeft + _desktopSurfacePageWidth;
for (var pageIndex = 0; pageIndex < _desktopPageCount; pageIndex++)
{
var pageLeft = pageIndex * _desktopSurfacePageWidth;
var pageRight = pageLeft + _desktopSurfacePageWidth;
if (pageRight > viewportLeft + 0.5d && pageLeft < viewportRight - 0.5d)
{
activeMask |= 1 << pageIndex;
}
}
}
if (_currentDesktopSurfaceIndex >= 0 && _currentDesktopSurfaceIndex < _desktopPageCount)
{
activeMask |= 1 << _currentDesktopSurfaceIndex;
}
if (_desktopPageContextSettlingSourceIndex is int sourceIndex &&
sourceIndex >= 0 &&
sourceIndex < _desktopPageCount)
{
activeMask |= 1 << sourceIndex;
}
if (_desktopPageContextSettlingTargetIndex is int targetIndex &&
targetIndex >= 0 &&
targetIndex < _desktopPageCount)
{
activeMask |= 1 << targetIndex;
}
return activeMask;
}
private void UpdateDesktopPageAwareComponentContext()
{
var activeDesktopPageIndex = _isSettingsOpen ? -1 : _currentDesktopSurfaceIndex;
var isEditMode = _isComponentLibraryOpen || _isSettingsOpen;
var activeMask = BuildDesktopPageAwareComponentActiveMask();
var pageUpdateMask = !_desktopPageContextInitialized || isEditMode != _desktopPageContextEditMode
? _desktopPageComponentGrids.Keys.Aggregate(0, (mask, pageIndex) => mask | (1 << pageIndex))
: activeMask ^ _desktopPageContextActiveMask;
if (_desktopPageContextInitialized &&
pageUpdateMask == 0 &&
isEditMode == _desktopPageContextEditMode &&
activeMask == _desktopPageContextActiveMask)
{
return;
}
foreach (var pair in _desktopPageComponentGrids)
{
var isOnActivePage = pair.Key == activeDesktopPageIndex;
var pageBit = 1 << pair.Key;
if ((pageUpdateMask & pageBit) == 0)
{
continue;
}
var isOnActivePage = (activeMask & pageBit) != 0;
foreach (var host in pair.Value.Children.OfType<Border>())
{
if (!host.Classes.Contains(DesktopComponentHostClass))
@@ -1598,6 +1676,10 @@ public partial class MainWindow
}
}
}
_desktopPageContextInitialized = true;
_desktopPageContextEditMode = isEditMode;
_desktopPageContextActiveMask = activeMask;
}
private static void ApplyDesktopPageContext(Control root, bool isOnActivePage, bool isEditMode)
@@ -2702,6 +2784,11 @@ public partial class MainWindow
return Symbol.Apps;
}
if (string.Equals(categoryId, "File", StringComparison.OrdinalIgnoreCase))
{
return Symbol.Folder;
}
return Symbol.Apps;
}
@@ -2747,6 +2834,11 @@ public partial class MainWindow
return L("component_category.study", "Study");
}
if (string.Equals(categoryId, "File", StringComparison.OrdinalIgnoreCase))
{
return L("component_category.file", "File");
}
return categoryId;
}

View File

@@ -5,6 +5,7 @@ using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Animation;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
@@ -16,6 +17,7 @@ using Avalonia.VisualTree;
using FluentAvalonia.UI.Controls;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
using LanMountainDesktop.Theme;
namespace LanMountainDesktop.Views;
@@ -54,6 +56,8 @@ public partial class MainWindow
private int _currentDesktopSurfaceIndex;
private double _desktopSurfacePageWidth;
private TranslateTransform? _desktopPagesHostTransform;
private Transitions? _desktopPagesHostSnapTransitions;
private bool _desktopPagesHostTransitionsSuspended;
private bool _isDesktopSwipeActive;
private bool _isDesktopSwipeDirectionLocked;
private Point _desktopSwipeStartPoint;
@@ -62,6 +66,12 @@ public partial class MainWindow
private long _desktopSwipeLastTimestamp;
private double _desktopSwipeVelocityX;
private double _desktopSwipeBaseOffset;
private bool _desktopPageContextInitialized;
private bool _desktopPageContextEditMode;
private int _desktopPageContextActiveMask;
private int? _desktopPageContextSettlingSourceIndex;
private int? _desktopPageContextSettlingTargetIndex;
private int _desktopPageContextSettleRevision;
private int LauncherSurfaceIndex => Math.Max(MinDesktopPageCount, _desktopPageCount);
@@ -164,6 +174,15 @@ public partial class MainWindow
DesktopPagesHost.RenderTransform = _desktopPagesHostTransform;
}
if (_desktopPagesHostTransitionsSuspended)
{
_desktopPagesHostTransform.Transitions = null;
}
else
{
_desktopPagesHostSnapTransitions ??= _desktopPagesHostTransform.Transitions;
}
var viewportRow = gridMetrics.RowCount > 2 ? 1 : 0;
var viewportRowSpan = gridMetrics.RowCount > 2 ? gridMetrics.RowCount - 2 : 1;
var pageWidth = Math.Max(1, gridMetrics.GridWidthPx);
@@ -200,6 +219,7 @@ public partial class MainWindow
DesktopPagesContainer.Width = pageWidth * _desktopPageCount;
DesktopPagesContainer.Height = pageHeight;
_desktopPageComponentGrids.Clear();
InvalidateDesktopPageAwareComponentContextCache();
for (var index = 0; index < _desktopPageCount; index++)
{
DesktopPagesContainer.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(pageWidth, GridUnitType.Pixel)));
@@ -354,6 +374,88 @@ public partial class MainWindow
UpdateDesktopPageAwareComponentContext();
}
private void SetDesktopPagesHostSnapAnimationEnabled(bool enabled)
{
if (_desktopPagesHostTransform is null)
{
return;
}
if (enabled)
{
if (!_desktopPagesHostTransitionsSuspended)
{
return;
}
_desktopPagesHostTransform.Transitions = _desktopPagesHostSnapTransitions;
_desktopPagesHostTransitionsSuspended = false;
return;
}
if (_desktopPagesHostTransitionsSuspended)
{
return;
}
_desktopPagesHostSnapTransitions ??= _desktopPagesHostTransform.Transitions;
_desktopPagesHostTransform.Transitions = null;
_desktopPagesHostTransitionsSuspended = true;
}
private void ClearDesktopPageContextSettle(bool refreshContext)
{
_desktopPageContextSettleRevision++;
_desktopPageContextSettlingSourceIndex = null;
_desktopPageContextSettlingTargetIndex = null;
if (refreshContext)
{
UpdateDesktopPageAwareComponentContext();
}
}
private void BeginDesktopPageContextSettle(int previousIndex, int targetIndex)
{
var sourceIndex = previousIndex >= 0 && previousIndex < _desktopPageCount
? previousIndex
: (int?)null;
var destinationIndex = targetIndex >= 0 && targetIndex < _desktopPageCount
? targetIndex
: (int?)null;
if (sourceIndex == destinationIndex && destinationIndex is not null)
{
ClearDesktopPageContextSettle(refreshContext: false);
return;
}
if (sourceIndex is null && destinationIndex is null)
{
ClearDesktopPageContextSettle(refreshContext: false);
return;
}
_desktopPageContextSettleRevision++;
var settleRevision = _desktopPageContextSettleRevision;
_desktopPageContextSettlingSourceIndex = sourceIndex;
_desktopPageContextSettlingTargetIndex = destinationIndex;
DispatcherTimer.RunOnce(
() =>
{
if (settleRevision != _desktopPageContextSettleRevision)
{
return;
}
_desktopPageContextSettlingSourceIndex = null;
_desktopPageContextSettlingTargetIndex = null;
UpdateDesktopPageAwareComponentContext();
},
FluttermotionToken.Page + TimeSpan.FromMilliseconds(36));
}
private void MoveSurfaceBy(int delta)
{
if (delta == 0)
@@ -373,9 +475,11 @@ public partial class MainWindow
return;
}
var previousIndex = _currentDesktopSurfaceIndex;
_currentDesktopSurfaceIndex = target;
BeginDesktopPageContextSettle(previousIndex, target);
ApplyDesktopSurfaceOffset();
PersistSettings();
SchedulePersistSettings(delayMs: Math.Max(280, (int)FluttermotionToken.Page.TotalMilliseconds + 80));
}
private bool CanSwipeDesktopSurface()
@@ -426,6 +530,7 @@ public partial class MainWindow
return;
}
ClearDesktopPageContextSettle(refreshContext: false);
_isDesktopSwipeActive = true;
_isDesktopSwipeDirectionLocked = false;
_desktopSwipeStartPoint = pointerInViewport;
@@ -603,6 +708,7 @@ public partial class MainWindow
}
_isDesktopSwipeDirectionLocked = true;
SetDesktopPagesHostSnapAnimationEnabled(enabled: false);
if (e.Pointer.Captured != DesktopPagesViewport)
{
e.Pointer.Capture(DesktopPagesViewport);
@@ -621,6 +727,7 @@ public partial class MainWindow
}
_desktopPagesHostTransform.X = tentative;
UpdateDesktopPageAwareComponentContext();
e.Handled = true;
}
@@ -656,6 +763,7 @@ public partial class MainWindow
_desktopSwipeLastTimestamp = 0;
if (wasDirectionLocked)
{
SetDesktopPagesHostSnapAnimationEnabled(enabled: true);
ApplyDesktopSurfaceOffset();
}
}
@@ -682,6 +790,8 @@ public partial class MainWindow
return false;
}
SetDesktopPagesHostSnapAnimationEnabled(enabled: true);
var deltaX = _desktopSwipeCurrentPoint.X - _desktopSwipeStartPoint.X;
var deltaY = _desktopSwipeCurrentPoint.Y - _desktopSwipeStartPoint.Y;
var absDeltaX = Math.Abs(deltaX);

View File

@@ -3,8 +3,9 @@ using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Media.Imaging;
@@ -16,8 +17,6 @@ using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
using LanMountainDesktop.Theme;
using LanMountainDesktop.Views.Components;
using LibVLCSharp.Shared;
using LibVLCSharp.Avalonia;
namespace LanMountainDesktop.Views;
@@ -34,6 +33,11 @@ public partial class MainWindow
{
_ = sender;
if (_suppressOwnSettingsReloadCount > 0)
{
return;
}
if (e.Scope == SettingsScope.App && e.ChangedKeys is { Count: > 0 })
{
var changedKeys = e.ChangedKeys.ToArray();
@@ -99,7 +103,11 @@ public partial class MainWindow
private void ApplyLocalization()
{
Title = L("app.title", "LanMountainDesktop");
BackToWindowsTextBlock.Text = L("button.back_to_windows", "Back to Windows");
var platformName = OperatingSystem.IsWindows() ? "Windows"
: OperatingSystem.IsMacOS() ? "macOS"
: "Linux";
BackToWindowsTextBlock.Text = Lf("button.back_to_platform", "Back to {0}", platformName);
ToolTip.SetTip(BackToWindowsButton, Lf("tooltip.back_to_platform", "Back to {0}", platformName));
ComponentLibraryTitleTextBlock.Text = L("component_library.title", "Widgets");
LauncherTitleTextBlock.Text = L("launcher.title", "App Launcher");
LauncherSubtitleTextBlock.Text = OperatingSystem.IsLinux()
@@ -213,7 +221,6 @@ public partial class MainWindow
_wallpaperType = string.IsNullOrWhiteSpace(type) ? "Image" : type.Trim();
_wallpaperPlacement = WallpaperImageBrushFactory.NormalizePlacement(placement);
_wallpaperSolidColor = TryParseColor(color, out var parsedColor) ? parsedColor : null;
_wallpaperVideoPath = null;
_wallpaperDisplayState = WallpaperDisplayState.NoWallpaperConfigured;
_wallpaperBitmap?.Dispose();
@@ -235,17 +242,6 @@ public partial class MainWindow
}
var extension = Path.GetExtension(_wallpaperPath);
var requestedTypeIsVideo = string.Equals(_wallpaperType, "Video", StringComparison.OrdinalIgnoreCase);
if (SupportedVideoExtensions.Contains(extension) || requestedTypeIsVideo)
{
_wallpaperMediaType = WallpaperMediaType.Video;
_wallpaperVideoPath = _wallpaperPath;
_wallpaperDisplayState = File.Exists(_wallpaperPath)
? WallpaperDisplayState.CurrentValidWallpaper
: WallpaperDisplayState.TemporarilyUnavailable;
return;
}
if (!SupportedImageExtensions.Contains(extension))
{
_wallpaperMediaType = WallpaperMediaType.Image;
@@ -285,7 +281,6 @@ public partial class MainWindow
if (_wallpaperMediaType == WallpaperMediaType.SolidColor && _wallpaperSolidColor.HasValue)
{
DesktopWallpaperLayer.Background = new SolidColorBrush(_wallpaperSolidColor.Value);
ApplyVideoWallpaperPosterVisibility(showPoster: false);
return;
}
@@ -296,7 +291,6 @@ public partial class MainWindow
DesktopWallpaperLayer.Background = _defaultDesktopBackground ?? CreateNeutralWallpaperFallbackBrush();
DesktopWallpaperImageLayer.Background = WallpaperImageBrushFactory.Create(_wallpaperBitmap, _wallpaperPlacement);
DesktopWallpaperImageLayer.IsVisible = true;
ApplyVideoWallpaperPosterVisibility(showPoster: false);
return;
}
@@ -308,92 +302,17 @@ public partial class MainWindow
DesktopWallpaperLayer.Background = _defaultDesktopBackground ?? CreateNeutralWallpaperFallbackBrush();
DesktopWallpaperImageLayer.Background = WallpaperImageBrushFactory.Create(_lastValidWallpaperBitmap, _wallpaperPlacement);
DesktopWallpaperImageLayer.IsVisible = true;
ApplyVideoWallpaperPosterVisibility(showPoster: false);
return;
}
DesktopWallpaperLayer.Background = _defaultDesktopBackground ?? CreateNeutralWallpaperFallbackBrush();
ApplyVideoWallpaperPosterVisibility(
showPoster: _wallpaperMediaType == WallpaperMediaType.Video && _videoWallpaperPosterBitmap is not null);
}
private void UpdateWallpaperDisplay()
{
if (_wallpaperMediaType == WallpaperMediaType.Video)
{
if (!string.IsNullOrWhiteSpace(_wallpaperVideoPath))
{
StartVideoWallpaper(_wallpaperVideoPath);
}
}
else
{
StopVideoWallpaper();
}
ApplyWallpaperBrush();
}
private void StartVideoWallpaper(string videoPath)
{
if (string.IsNullOrWhiteSpace(videoPath) || !File.Exists(videoPath))
{
ApplyVideoWallpaperPosterVisibility(showPoster: _videoWallpaperPosterBitmap is not null);
return;
}
try
{
_libVlc ??= new LibVLC();
_videoWallpaperPlayer ??= new MediaPlayer(_libVlc);
if (_videoWallpaperMedia?.Mrl != videoPath)
{
_videoWallpaperMedia?.Dispose();
_videoWallpaperMedia = new Media(_libVlc, new Uri(videoPath));
_videoWallpaperPlayer.Media = _videoWallpaperMedia;
}
if (DesktopVideoWallpaperView is { } videoView)
{
videoView.MediaPlayer = _videoWallpaperPlayer;
videoView.IsVisible = true;
}
if (!string.Equals(_videoWallpaperPosterPath, videoPath, StringComparison.OrdinalIgnoreCase))
{
ApplyVideoWallpaperPosterVisibility(showPoster: false);
}
else
{
ApplyVideoWallpaperPosterVisibility(showPoster: _videoWallpaperPosterBitmap is not null);
}
if (!_videoWallpaperPlayer.IsPlaying)
{
_videoWallpaperPlayer.Play();
}
TryCaptureVideoWallpaperPosterFrame(videoPath);
}
catch
{
ApplyVideoWallpaperPosterVisibility(showPoster: _videoWallpaperPosterBitmap is not null);
}
}
private void StopVideoWallpaper()
{
if (DesktopVideoWallpaperView is { } videoView)
{
videoView.IsVisible = false;
}
_videoWallpaperPlayer?.Stop();
_wallpaperVideoPath = null;
ApplyVideoWallpaperPosterVisibility(showPoster: false);
}
private double CalculateCurrentBackgroundLuminance()
{
var brush = DesktopWallpaperLayer.Background;
@@ -473,6 +392,7 @@ public partial class MainWindow
private void PersistSettings()
{
_persistSettingsRevision++;
if (_suppressSettingsPersistence)
{
return;
@@ -480,6 +400,8 @@ public partial class MainWindow
try
{
// Saving our own state should not trigger a full external reload cycle.
_suppressOwnSettingsReloadCount++;
_settingsService.SaveSnapshot(SettingsScope.App, BuildAppSettingsSnapshot());
_componentLayoutStore.SaveLayout(BuildDesktopLayoutSettingsSnapshot());
_settingsService.SaveSnapshot(SettingsScope.Launcher, BuildLauncherSettingsSnapshot());
@@ -488,11 +410,29 @@ public partial class MainWindow
{
AppLogger.Warn("SettingsRuntime", "Failed to persist settings.", ex);
}
finally
{
if (_suppressOwnSettingsReloadCount > 0)
{
_suppressOwnSettingsReloadCount--;
}
}
}
private void SchedulePersistSettings(int delayMs = 200)
{
DispatcherTimer.RunOnce(PersistSettings, TimeSpan.FromMilliseconds(Math.Max(0, delayMs)));
var revision = ++_persistSettingsRevision;
DispatcherTimer.RunOnce(
() =>
{
if (revision != _persistSettingsRevision)
{
return;
}
PersistSettings();
},
TimeSpan.FromMilliseconds(Math.Max(0, delayMs)));
}
internal void ReloadFromPersistedSettings()
@@ -564,6 +504,7 @@ public partial class MainWindow
var latestWeatherState = _weatherSettingsService.Get();
var latestUpdateState = _updateSettingsService.Get();
var latestThemeState = _themeSettingsService.Get();
var latestPrivacyState = _settingsFacade.Privacy.Get();
return new AppSettingsSnapshot
{
GridShortSideCells = _targetShortSideCells,
@@ -595,7 +536,8 @@ public partial class MainWindow
WeatherNoTlsRequests = latestWeatherState.NoTlsRequests,
AutoStartWithWindows = _autoStartWithWindows,
AppRenderMode = _selectedAppRenderMode,
AutoCheckUpdates = latestUpdateState.AutoCheckUpdates,
UploadAnonymousCrashData = latestPrivacyState.UploadAnonymousCrashData,
UploadAnonymousUsageData = latestPrivacyState.UploadAnonymousUsageData,
IncludePrereleaseUpdates = latestUpdateState.IncludePrereleaseUpdates,
UpdateChannel = latestUpdateState.UpdateChannel,
UpdateMode = latestUpdateState.UpdateMode,
@@ -610,6 +552,7 @@ public partial class MainWindow
EnableDynamicTaskbarActions = _enableDynamicTaskbarActions,
TaskbarLayoutMode = _taskbarLayoutMode,
ClockDisplayFormat = _clockDisplayFormat == ClockDisplayFormat.HourMinute ? "HourMinute" : "HourMinuteSecond",
StatusBarClockTransparentBackground = _statusBarClockTransparentBackground,
StatusBarSpacingMode = _statusBarSpacingMode,
StatusBarCustomSpacingPercent = _statusBarCustomSpacingPercent
};
@@ -644,112 +587,6 @@ public partial class MainWindow
}
}
private void ApplyVideoWallpaperPosterVisibility(bool showPoster)
{
if (DesktopVideoWallpaperImage is not { } posterImage)
{
return;
}
if (!showPoster ||
_videoWallpaperPosterBitmap is null ||
!string.Equals(_videoWallpaperPosterPath, _wallpaperVideoPath, StringComparison.OrdinalIgnoreCase))
{
posterImage.IsVisible = false;
return;
}
posterImage.Source = _videoWallpaperPosterBitmap;
posterImage.IsVisible = true;
}
private void TryCaptureVideoWallpaperPosterFrame(string videoPath)
{
if (_videoWallpaperPlayer is null || string.IsNullOrWhiteSpace(videoPath))
{
return;
}
_ = Task.Run(async () =>
{
var snapshotPath = Path.Combine(
Path.GetTempPath(),
$"lanmountaindesktop-wallpaper-poster-{Guid.NewGuid():N}.png");
try
{
for (var attempt = 0; attempt < 12; attempt++)
{
await Task.Delay(250).ConfigureAwait(false);
if (_wallpaperMediaType != WallpaperMediaType.Video ||
!string.Equals(_wallpaperVideoPath, videoPath, StringComparison.OrdinalIgnoreCase) ||
_videoWallpaperPlayer is null)
{
return;
}
if (!_videoWallpaperPlayer.TakeSnapshot(0, snapshotPath, 640, 360))
{
continue;
}
if (!File.Exists(snapshotPath))
{
continue;
}
var fileInfo = new FileInfo(snapshotPath);
if (fileInfo.Length <= 0)
{
continue;
}
Bitmap posterBitmap;
await using (var stream = File.OpenRead(snapshotPath))
{
posterBitmap = new Bitmap(stream);
}
await Dispatcher.UIThread.InvokeAsync(() =>
{
if (_wallpaperMediaType != WallpaperMediaType.Video ||
!string.Equals(_wallpaperVideoPath, videoPath, StringComparison.OrdinalIgnoreCase))
{
posterBitmap.Dispose();
return;
}
_videoWallpaperPosterBitmap?.Dispose();
_videoWallpaperPosterBitmap = posterBitmap;
_videoWallpaperPosterPath = videoPath;
ApplyVideoWallpaperPosterVisibility(showPoster: true);
});
return;
}
}
catch
{
// Best effort poster capture only.
}
finally
{
try
{
if (File.Exists(snapshotPath))
{
File.Delete(snapshotPath);
}
}
catch
{
// Best effort cleanup only.
}
}
});
}
private DesktopLayoutSettingsSnapshot BuildDesktopLayoutSettingsSnapshot()
{
return new DesktopLayoutSettingsSnapshot

View File

@@ -1,4 +1,4 @@
<Window xmlns="https://github.com/avaloniaui"
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LanMountainDesktop.ViewModels"
xmlns:ui="using:FluentAvalonia.UI.Controls"
@@ -6,7 +6,7 @@
xmlns:ic="using:FluentIcons.Avalonia.Fluent"
xmlns:mi="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:comp="using:LanMountainDesktop.Views.Components"
xmlns:vlc="clr-namespace:LibVLCSharp.Avalonia;assembly=LibVLCSharp.Avalonia"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
@@ -123,18 +123,7 @@
VerticalAlignment="Stretch"
Background="Transparent" />
<Image x:Name="DesktopVideoWallpaperImage"
IsVisible="False"
IsHitTestVisible="False"
Stretch="UniformToFill"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" />
<vlc:VideoView x:Name="DesktopVideoWallpaperView"
IsVisible="False"
IsHitTestVisible="False"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" />
<Grid x:Name="DesktopGrid"
HorizontalAlignment="Center"
@@ -156,7 +145,9 @@
<TranslateTransform>
<TranslateTransform.Transitions>
<Transitions>
<DoubleTransition Property="X" Duration="{StaticResource FluttermotionToken.Duration.Page}" />
<DoubleTransition Property="X"
Duration="{StaticResource FluttermotionToken.Duration.Page}"
Easing="0.22,1,0.36,1" />
</Transitions>
</TranslateTransform.Transitions>
</TranslateTransform>
@@ -167,7 +158,7 @@
<Border x:Name="LauncherPagePanel"
Grid.Column="1"
Classes="glass-panel"
Classes="surface-translucent-panel"
ClipToBounds="False"
CornerRadius="36"
Padding="18">
@@ -175,11 +166,9 @@
<StackPanel Spacing="4">
<TextBlock x:Name="LauncherTitleTextBlock"
FontSize="24"
FontWeight="SemiBold"
Text="App Launcher" />
FontWeight="SemiBold" />
<TextBlock x:Name="LauncherSubtitleTextBlock"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="Apps and folders from Windows Start Menu." />
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
</StackPanel>
<Grid Grid.Row="1"
@@ -197,7 +186,7 @@
Opacity="{DynamicResource AdaptiveGlassOverlayOpacity}"
PointerPressed="OnLauncherFolderOverlayPointerPressed">
<Border x:Name="LauncherFolderPanel"
Classes="mica-strong"
Classes="surface-solid-strong"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Margin="52"
@@ -225,8 +214,7 @@
Grid.Column="1"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontWeight="SemiBold"
Text="Folder" />
FontWeight="SemiBold" />
<Button x:Name="LauncherFolderCloseButton"
Grid.Column="2"
Width="38"
@@ -275,7 +263,7 @@
</Border>
<Border x:Name="BottomTaskbarContainer"
Classes="glass-island"
Classes="surface-translucent-island"
Grid.Row="0"
Grid.Column="0"
Grid.ColumnSpan="1"
@@ -299,8 +287,7 @@
Background="Transparent"
BorderThickness="0"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Click="OnMinimizeClick"
ToolTip.Tip="&#22238;&#21040;Windows">
Click="OnMinimizeClick">
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Center"
VerticalAlignment="Center"
@@ -309,8 +296,7 @@
Icon="Window"
IconVariant="Regular" />
<TextBlock x:Name="BackToWindowsTextBlock"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Text="&#22238;&#21040;Windows" />
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
</StackPanel>
</Button>
</Grid>
@@ -445,7 +431,7 @@
<Border x:Name="ComponentLibraryWindow"
IsVisible="False"
Opacity="0"
Classes="glass-strong"
Classes="surface-translucent-strong"
HorizontalAlignment="Center"
VerticalAlignment="Bottom"
Width="620"
@@ -490,7 +476,7 @@
</Grid>
<Border Grid.Row="1"
Classes="glass-panel"
Classes="surface-translucent-panel"
CornerRadius="12"
Padding="14">
<Grid>

View File

@@ -25,7 +25,7 @@ using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Theme;
using LanMountainDesktop.Views.Components;
using LibVLCSharp.Shared;
namespace LanMountainDesktop.Views;
@@ -35,7 +35,6 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
{
None,
Image,
Video,
SolidColor
}
@@ -65,10 +64,6 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
{
".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp"
};
private static readonly HashSet<string> SupportedVideoExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".mp4", ".mkv", ".webm", ".avi", ".mov", ".m4v"
};
private static readonly TaskbarActionId[] DefaultPinnedTaskbarActions =
[
TaskbarActionId.MinimizeToWindows
@@ -120,30 +115,11 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
private Bitmap? _wallpaperBitmap;
private Bitmap? _lastValidWallpaperBitmap;
private string? _lastValidWallpaperPath;
private Bitmap? _videoWallpaperPosterBitmap;
private string? _videoWallpaperPosterPath;
private WallpaperMediaType _wallpaperMediaType;
private WallpaperDisplayState _wallpaperDisplayState = WallpaperDisplayState.NoWallpaperConfigured;
private string _wallpaperPlacement = WallpaperImageBrushFactory.Fill;
private string? _wallpaperVideoPath;
private string _wallpaperType = "Image";
private Color? _wallpaperSolidColor;
private LibVLC? _libVlc;
private MediaPlayer? _videoWallpaperPlayer;
private Media? _videoWallpaperMedia;
private readonly object _desktopVideoFrameSync = new();
private MediaPlayer.LibVLCVideoLockCb? _desktopVideoLockCallback;
private MediaPlayer.LibVLCVideoUnlockCb? _desktopVideoUnlockCallback;
private MediaPlayer.LibVLCVideoDisplayCb? _desktopVideoDisplayCallback;
private DispatcherTimer? _desktopVideoFrameRefreshTimer;
private IntPtr _desktopVideoFrameBufferPtr;
private byte[]? _desktopVideoStagingBuffer;
private WriteableBitmap? _desktopVideoBitmap;
private int _desktopVideoFrameWidth;
private int _desktopVideoFrameHeight;
private int _desktopVideoFramePitch;
private int _desktopVideoFrameBufferSize;
private int _desktopVideoFrameDirtyFlag;
private string? _wallpaperPath;
private string _wallpaperStatus = "Current background uses solid color.";
private IReadOnlyList<Color> _recommendedColors = Array.Empty<Color>();
@@ -155,6 +131,7 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
private string _gridSpacingPreset = "Relaxed";
private string _statusBarSpacingMode = "Relaxed";
private int _statusBarCustomSpacingPercent = 12;
private bool _statusBarClockTransparentBackground;
private int _desktopEdgeInsetPercent = DefaultEdgeInsetPercent;
private string _taskbarLayoutMode = TaskbarLayoutBottomFullRowMacStyle;
private string _languageCode = "zh-CN";
@@ -177,6 +154,8 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
private bool _isWeatherPreviewInProgress;
private ClockDisplayFormat _clockDisplayFormat = ClockDisplayFormat.HourMinuteSecond;
private bool _externalSettingsReloadPending;
private int _persistSettingsRevision;
private int _suppressOwnSettingsReloadCount;
private double CurrentDesktopPitch => _currentDesktopCellSize + _currentDesktopCellGap;
public MainWindow()
@@ -333,21 +312,9 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
_detachedComponentLibraryWindow.Close();
}
_detachedComponentLibraryWindow = null;
StopVideoWallpaper();
DisposeLauncherResources();
_videoWallpaperMedia?.Dispose();
_videoWallpaperMedia = null;
_videoWallpaperPlayer?.Dispose();
_videoWallpaperPlayer = null;
_desktopVideoFrameRefreshTimer?.Stop();
_desktopVideoFrameRefreshTimer = null;
_videoWallpaperPosterBitmap?.Dispose();
_videoWallpaperPosterBitmap = null;
_videoWallpaperPosterPath = null;
_lastValidWallpaperBitmap?.Dispose();
_lastValidWallpaperBitmap = null;
_libVlc?.Dispose();
_libVlc = null;
if (_recommendationInfoService is IDisposable recommendationServiceDisposable)
{
recommendationServiceDisposable.Dispose();

View File

@@ -16,8 +16,11 @@
<ui:SettingsExpander.Footer>
<Grid ColumnDefinitions="*,Auto"
ColumnSpacing="12">
<TextBox Text="{Binding SearchText}"
Watermark="{Binding SearchPlaceholder}" />
<TextBox x:Name="SearchTextBox"
Text="{Binding SearchText}"
Watermark="{Binding SearchPlaceholder}"
Focusable="True"
IsTabStop="True" />
<Button Grid.Column="1"
Command="{Binding RefreshCommand}"
Content="{Binding RefreshButtonText}" />

View File

@@ -0,0 +1,55 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LanMountainDesktop.ViewModels"
xmlns:mdxaml="https://github.com/whistyun/Markdown.Avalonia"
xmlns:helpers="using:LanMountainDesktop.Helpers"
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
x:Class="LanMountainDesktop.Views.SettingsPages.PrivacyPolicyDrawer"
x:DataType="vm:PrivacyPolicyViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Classes="settings-page-container"
Margin="0,0,0,8">
<Border Classes="settings-section-card">
<StackPanel Spacing="12">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="12">
<Border Classes="settings-section-card-icon-host"
Width="48"
Height="48">
<Viewbox Stretch="Uniform">
<fi:SymbolIcon Symbol="Document" />
</Viewbox>
</Border>
<StackPanel Grid.Column="1"
Spacing="4"
VerticalAlignment="Center">
<TextBlock Classes="settings-card-header"
Margin="0"
Text="{Binding Title}" />
<TextBlock Classes="settings-item-description"
Text="{Binding Description}" />
</StackPanel>
</Grid>
</StackPanel>
</Border>
<Border Classes="settings-section-card">
<StackPanel Spacing="12">
<TextBlock Classes="settings-item-description"
IsVisible="{Binding IsLoading}"
Text="{Binding LoadingText}" />
<TextBlock Classes="settings-item-description"
IsVisible="{Binding HasError}"
Text="{Binding ErrorText}"
TextWrapping="Wrap" />
<mdxaml:MarkdownScrollViewer IsVisible="{Binding HasContent}"
Markdown="{Binding MarkdownContent}"
Engine="{x:Static helpers:PluginMarketMarkdownHelper.Engine}" />
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -0,0 +1,17 @@
using Avalonia.Controls;
using LanMountainDesktop.ViewModels;
namespace LanMountainDesktop.Views.SettingsPages;
public partial class PrivacyPolicyDrawer : UserControl
{
public PrivacyPolicyDrawer()
{
InitializeComponent();
}
public PrivacyPolicyDrawer(PrivacyPolicyViewModel viewModel) : this()
{
DataContext = viewModel;
}
}

View File

@@ -45,10 +45,13 @@
FontSize="12"
Opacity="0.7"
Margin="0,4,0,8" />
<TextBox Text="{Binding DeviceId}"
<TextBox x:Name="DeviceIdTextBox"
Text="{Binding DeviceId}"
IsReadOnly="True"
FontFamily="Consolas"
FontSize="12" />
FontSize="12"
Focusable="False"
IsTabStop="False" />
</StackPanel>
<Button Grid.Column="1"
Content="{Binding RefreshDeviceIdText}"
@@ -58,6 +61,28 @@
Classes="accent-button" />
</Grid>
</Border>
<StackPanel Orientation="Horizontal"
Margin="0,16,0,0"
Spacing="4">
<TextBlock Text="{Binding PrivacyPolicyHintPrefix}"
FontSize="13"
VerticalAlignment="Center" />
<Button Content="{Binding ViewPrivacyPolicyText}"
Command="{Binding ViewPrivacyPolicyCommand}"
Background="Transparent"
BorderThickness="0"
Padding="0"
FontSize="13"
Foreground="{DynamicResource SystemAccentColor}"
Cursor="Hand">
<Button.Styles>
<Style Selector="Button:pointerover /template/ ContentPresenter">
<Setter Property="TextBlock.Foreground" Value="{DynamicResource SystemAccentColorDark1}" />
</Style>
</Button.Styles>
</Button>
</StackPanel>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -22,9 +22,17 @@ public partial class PrivacySettingsPage : SettingsPageBase
public PrivacySettingsPage(PrivacySettingsPageViewModel viewModel)
{
ViewModel = viewModel;
ViewModel.ViewPrivacyPolicyRequested += OnViewPrivacyPolicyRequested;
DataContext = ViewModel;
InitializeComponent();
}
public PrivacySettingsPageViewModel ViewModel { get; }
private void OnViewPrivacyPolicyRequested()
{
var privacyPolicyViewModel = new PrivacyPolicyViewModel();
var drawer = new PrivacyPolicyDrawer(privacyPolicyViewModel);
OpenDrawer(drawer, privacyPolicyViewModel.Title);
}
}

View File

@@ -38,6 +38,20 @@
</ComboBox>
</Grid>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem>
<Grid ColumnDefinitions="*,Auto"
ColumnSpacing="16">
<StackPanel Spacing="2">
<TextBlock Text="{Binding ClockTransparentBackgroundLabel}" />
<TextBlock Text="{Binding ClockTransparentBackgroundDescription}"
Opacity="0.75"
TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1"
IsChecked="{Binding ClockTransparentBackground}"
VerticalAlignment="Center" />
</Grid>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
<Separator Classes="settings-separator" />

View File

@@ -21,12 +21,17 @@
<Setter Property="FontSize" Value="12" />
<Setter Property="Opacity" Value="0.68" />
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
<Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="MaxWidth" Value="200" />
</Style>
<Style Selector="TextBlock.update-kv-value">
<Setter Property="FontSize" Value="14" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
<Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="MaxWidth" Value="200" />
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
</Style>
</UserControl.Styles>
@@ -64,10 +69,12 @@
Content="{Binding CheckForUpdatesButtonText}" />
</Grid>
<Grid ColumnDefinitions="*,*"
ColumnSpacing="14"
RowSpacing="12">
<StackPanel Grid.Column="0"
<Grid ColumnDefinitions="Auto,*"
RowDefinitions="Auto,Auto"
ColumnSpacing="20"
RowSpacing="16">
<StackPanel Grid.Row="0"
Grid.Column="0"
Spacing="4">
<TextBlock Classes="update-kv-label"
Text="{Binding CurrentVersionLabel}" />
@@ -75,7 +82,8 @@
Text="{Binding CurrentVersionText}" />
</StackPanel>
<StackPanel Grid.Column="1"
<StackPanel Grid.Row="0"
Grid.Column="1"
Spacing="4"
IsVisible="{Binding IsLatestVersionVisible}">
<TextBlock Classes="update-kv-label"
@@ -105,18 +113,27 @@
</StackPanel>
</Grid>
<StackPanel Spacing="8">
<StackPanel Spacing="12"
HorizontalAlignment="Left">
<TextBlock Classes="settings-item-description"
Text="{Binding UpdateStatus}" />
Text="{Binding UpdateStatus}"
TextWrapping="Wrap"
HorizontalAlignment="Left"
MaxWidth="500" />
<ProgressBar Minimum="0"
Maximum="100"
Value="{Binding DownloadProgressValue}"
IsVisible="{Binding IsDownloadProgressVisible}" />
IsVisible="{Binding IsDownloadProgressVisible}"
HorizontalAlignment="Stretch"
Margin="0,4,0,4" />
<TextBlock Classes="settings-item-description"
IsVisible="{Binding IsDownloadProgressVisible}"
Text="{Binding DownloadProgressText}" />
Text="{Binding DownloadProgressText}"
TextWrapping="Wrap"
HorizontalAlignment="Left"
Margin="0,4,0,0" />
</StackPanel>
<StackPanel Orientation="Horizontal"
@@ -210,15 +227,7 @@
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander Classes="settings-expander-card"
Header="{Binding AutoCheckUpdatesLabel}">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="ClockAlarm" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding AutoCheckUpdates}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -21,27 +21,11 @@
CornerRadius="48"
BoxShadow="0 12 32 #50000000">
<Panel Background="{DynamicResource AdaptiveSurfaceBaseBrush}" Margin="2">
<!-- 图片/视频预览 -->
<!-- 图片预览 -->
<Border Background="#FFF6F7F9"
IsVisible="{Binding IsImage}">
<Border Background="{Binding PreviewBrush}" />
</Border>
<Border Background="#FFF6F7F9"
IsVisible="{Binding IsVideo}">
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="12">
<fi:FluentIcon Icon="Video"
Width="72"
Height="72"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<TextBlock Text="{Binding VideoModeHintText}"
Width="300"
TextAlignment="Center"
TextWrapping="Wrap"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
</StackPanel>
</Border>
<!-- 纯色预览 -->
<Border Background="{Binding SelectedColor}"
IsVisible="{Binding IsSolidColor}" />
@@ -52,31 +36,104 @@
<!-- 右侧:颜色选择网格 -->
<StackPanel Grid.Column="1" VerticalAlignment="Center" Spacing="12" IsVisible="{Binding IsSolidColor}">
<TextBlock Text="{Binding WallpaperColorLabel}"
FontSize="14"
FontWeight="SemiBold"
<TextBlock Text="{Binding WallpaperColorLabel}"
FontSize="14"
FontWeight="SemiBold"
Opacity="0.8" />
<ItemsControl ItemsSource="{Binding PresetColors}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Columns="4" Rows="3" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Button Width="48" Height="48" Margin="4" Padding="0"
Background="{Binding}"
BorderThickness="0"
CornerRadius="6"
Command="{Binding $parent[UserControl].((vm:WallpaperSettingsPageViewModel)DataContext).SelectColorCommand}"
CommandParameter="{Binding}"
ToolTip.Tip="{Binding}">
<!-- 简单的悬停与选中效果由 Button 默认样式提供,
由于使用了低饱和度颜色,背景色本身就很柔和 -->
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<UniformGrid Columns="4" Rows="3">
<!-- 预设颜色 1-11 -->
<Button Width="48" Height="48" Margin="4" Padding="0"
Background="#D8A7B1"
BorderThickness="0"
CornerRadius="6"
Command="{Binding SelectColorCommand}"
CommandParameter="#D8A7B1"
ToolTip.Tip="#D8A7B1" />
<Button Width="48" Height="48" Margin="4" Padding="0"
Background="#B6C9BB"
BorderThickness="0"
CornerRadius="6"
Command="{Binding SelectColorCommand}"
CommandParameter="#B6C9BB"
ToolTip.Tip="#B6C9BB" />
<Button Width="48" Height="48" Margin="4" Padding="0"
Background="#A2B5BB"
BorderThickness="0"
CornerRadius="6"
Command="{Binding SelectColorCommand}"
CommandParameter="#A2B5BB"
ToolTip.Tip="#A2B5BB" />
<Button Width="48" Height="48" Margin="4" Padding="0"
Background="#E6E2D3"
BorderThickness="0"
CornerRadius="6"
Command="{Binding SelectColorCommand}"
CommandParameter="#E6E2D3"
ToolTip.Tip="#E6E2D3" />
<Button Width="48" Height="48" Margin="4" Padding="0"
Background="#B5A397"
BorderThickness="0"
CornerRadius="6"
Command="{Binding SelectColorCommand}"
CommandParameter="#B5A397"
ToolTip.Tip="#B5A397" />
<Button Width="48" Height="48" Margin="4" Padding="0"
Background="#C5C1C0"
BorderThickness="0"
CornerRadius="6"
Command="{Binding SelectColorCommand}"
CommandParameter="#C5C1C0"
ToolTip.Tip="#C5C1C0" />
<Button Width="48" Height="48" Margin="4" Padding="0"
Background="#D4BE8D"
BorderThickness="0"
CornerRadius="6"
Command="{Binding SelectColorCommand}"
CommandParameter="#D4BE8D"
ToolTip.Tip="#D4BE8D" />
<Button Width="48" Height="48" Margin="4" Padding="0"
Background="#C08261"
BorderThickness="0"
CornerRadius="6"
Command="{Binding SelectColorCommand}"
CommandParameter="#C08261"
ToolTip.Tip="#C08261" />
<Button Width="48" Height="48" Margin="4" Padding="0"
Background="#8E9775"
BorderThickness="0"
CornerRadius="6"
Command="{Binding SelectColorCommand}"
CommandParameter="#8E9775"
ToolTip.Tip="#8E9775" />
<Button Width="48" Height="48" Margin="4" Padding="0"
Background="#9FBAD3"
BorderThickness="0"
CornerRadius="6"
Command="{Binding SelectColorCommand}"
CommandParameter="#9FBAD3"
ToolTip.Tip="#9FBAD3" />
<Button Width="48" Height="48" Margin="4" Padding="0"
Background="#E5BAA2"
BorderThickness="0"
CornerRadius="6"
Command="{Binding SelectColorCommand}"
CommandParameter="#E5BAA2"
ToolTip.Tip="#E5BAA2" />
<!-- 第12个位置自定义颜色选择器 -->
<Button Width="48" Height="48" Margin="4" Padding="0"
Background="{Binding CustomColorBrush}"
BorderThickness="0"
CornerRadius="6"
ToolTip.Tip="自定义颜色">
<Button.Flyout>
<Flyout Placement="BottomEdgeAlignedLeft">
<StackPanel Width="260" Spacing="12">
<ColorPicker Color="{Binding CustomColor}" />
</StackPanel>
</Flyout>
</Button.Flyout>
</Button>
</UniformGrid>
</StackPanel>
</Grid>
@@ -105,7 +162,7 @@
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<!-- 图片/视频文件选择 -->
<!-- 图片文件选择 -->
<ui:SettingsExpander Header="{Binding WallpaperPathLabel}"
IsVisible="{Binding IsImage}"
Margin="0,4,0,0">
@@ -129,7 +186,7 @@
<!-- 填充方式 -->
<ui:SettingsExpander Header="{Binding WallpaperPlacementLabel}"
Description="{Binding WallpaperPlacementDescription}"
IsVisible="{Binding IsImageOrVideo}"
IsVisible="{Binding IsImage}"
Margin="0,4,0,0">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="Maximize" />
@@ -147,12 +204,6 @@
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<TextBlock Margin="0,8,0,0"
IsVisible="{Binding IsVideo}"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="{Binding VideoModeHintText}"
TextWrapping="Wrap" />
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -96,8 +96,11 @@
<StackPanel Spacing="14">
<Grid ColumnDefinitions="*,Auto"
ColumnSpacing="12">
<TextBox Text="{Binding SearchKeyword}"
Watermark="{Binding SearchPlaceholder}" />
<TextBox x:Name="SearchKeywordTextBox"
Text="{Binding SearchKeyword}"
Watermark="{Binding SearchPlaceholder}"
Focusable="True"
IsTabStop="True" />
<Button Grid.Column="1"
Command="{Binding SearchCommand}"
Content="{Binding SearchButtonText}" />
@@ -178,12 +181,18 @@
</Grid>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem>
<TextBox Text="{Binding LocationKey}"
Watermark="{Binding LocationKeyPlaceholder}" />
<TextBox x:Name="LocationKeyTextBox"
Text="{Binding LocationKey}"
Watermark="{Binding LocationKeyPlaceholder}"
Focusable="True"
IsTabStop="True" />
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem>
<TextBox Text="{Binding LocationName}"
Watermark="{Binding LocationNamePlaceholder}" />
<TextBox x:Name="LocationNameTextBox"
Text="{Binding LocationName}"
Watermark="{Binding LocationNamePlaceholder}"
Focusable="True"
IsTabStop="True" />
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
@@ -230,11 +239,14 @@
<fi:SymbolIconSource Symbol="Warning" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<TextBox Width="360"
<TextBox x:Name="ExcludedAlertsTextBox"
Width="360"
MinHeight="120"
AcceptsReturn="True"
TextWrapping="Wrap"
Text="{Binding ExcludedAlerts}" />
Text="{Binding ExcludedAlerts}"
Focusable="True"
IsTabStop="True" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>

View File

@@ -5,6 +5,7 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>1.0.0</Version>
</PropertyGroup>
</Project>