mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
Compare commits
10 Commits
v0.8.3.3
...
76d13ac024
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76d13ac024 | ||
|
|
99a82d64e3 | ||
|
|
692ca3de3d | ||
|
|
d62226ffa0 | ||
|
|
91ab52ce8b | ||
|
|
4a89c2388b | ||
|
|
cb96180118 | ||
|
|
cf4b8e2132 | ||
|
|
e8ba847328 | ||
|
|
2156922039 |
45
.github/workflows/release.yml
vendored
45
.github/workflows/release.yml
vendored
@@ -109,21 +109,36 @@ jobs:
|
|||||||
$selfContained = "${{ matrix.self_contained }}" -eq "true"
|
$selfContained = "${{ matrix.self_contained }}" -eq "true"
|
||||||
$publishDir = if ($selfContained) { "publish/windows-${{ matrix.arch }}" } else { "publish/windows-${{ matrix.arch }}-lite" }
|
$publishDir = if ($selfContained) { "publish/windows-${{ matrix.arch }}" } else { "publish/windows-${{ matrix.arch }}-lite" }
|
||||||
|
|
||||||
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
|
if ($selfContained) {
|
||||||
-c Release `
|
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
|
||||||
-o ./$publishDir `
|
-c Release `
|
||||||
--self-contained:$selfContained `
|
-o ./$publishDir `
|
||||||
-r win-${{ matrix.arch }} `
|
--self-contained `
|
||||||
-p:PublishSingleFile=false `
|
-r win-${{ matrix.arch }} `
|
||||||
-p:SelfContained=$selfContained `
|
-p:PublishSingleFile=false `
|
||||||
-p:DebugType=none `
|
-p:DebugType=none `
|
||||||
-p:DebugSymbols=false `
|
-p:DebugSymbols=false `
|
||||||
-p:PublishTrimmed=false `
|
-p:PublishTrimmed=false `
|
||||||
-p:PublishReadyToRun=false `
|
-p:PublishReadyToRun=false `
|
||||||
-p:Version=${{ needs.prepare.outputs.version }} `
|
-p:Version=${{ needs.prepare.outputs.version }} `
|
||||||
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
|
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
|
||||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
|
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
|
||||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||||
|
} else {
|
||||||
|
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
|
||||||
|
-c Release `
|
||||||
|
-o ./$publishDir `
|
||||||
|
--self-contained:false `
|
||||||
|
-p:PublishSingleFile=false `
|
||||||
|
-p:DebugType=none `
|
||||||
|
-p:DebugSymbols=false `
|
||||||
|
-p:PublishTrimmed=false `
|
||||||
|
-p:PublishReadyToRun=false `
|
||||||
|
-p:Version=${{ needs.prepare.outputs.version }} `
|
||||||
|
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
|
||||||
|
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
|
||||||
|
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||||
|
}
|
||||||
|
|
||||||
Write-Host "Published to: $publishDir"
|
Write-Host "Published to: $publishDir"
|
||||||
Write-Host "Self-contained: $selfContained"
|
Write-Host "Self-contained: $selfContained"
|
||||||
|
|||||||
112
.trae/skills/refactoring-insight/SKILL.md
Normal file
112
.trae/skills/refactoring-insight/SKILL.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
---
|
||||||
|
name: "refactoring-insight"
|
||||||
|
description: "Analyzes codebase for refactoring opportunities: large files, code duplication, god classes, naming inconsistencies, tight coupling, and missing abstractions. Invoke when user asks for refactoring insight/analysis or wants to improve code architecture."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Refactoring Insight
|
||||||
|
|
||||||
|
Deep codebase analysis skill that identifies structural problems and produces prioritized refactoring recommendations.
|
||||||
|
|
||||||
|
## When to Invoke
|
||||||
|
|
||||||
|
- User asks for "refactoring insight", "refactoring analysis", "code quality analysis", "architecture review"
|
||||||
|
- User wants to understand what should be refactored in the codebase
|
||||||
|
- User asks "where are the code smells?" or "what needs refactoring?"
|
||||||
|
|
||||||
|
## Analysis Dimensions
|
||||||
|
|
||||||
|
Run all 6 dimensions in parallel where possible. For each dimension, use search agents to gather data, then synthesize findings.
|
||||||
|
|
||||||
|
### 1. Large Files / God Classes
|
||||||
|
|
||||||
|
- Find all .cs files over 300 lines, sorted by line count descending
|
||||||
|
- Identify partial classes and sum their total line count across files
|
||||||
|
- Flag classes with 15+ methods or constructors taking 8+ parameters
|
||||||
|
- Focus on: Views/, ViewModels/, Services/, plugins/
|
||||||
|
|
||||||
|
**Output**: Table of files with line counts and responsibility summary.
|
||||||
|
|
||||||
|
### 2. Code Duplication
|
||||||
|
|
||||||
|
Search for these specific duplication patterns:
|
||||||
|
|
||||||
|
- **Service boilerplate**: Repeated DI registration, `new` instantiation instead of DI
|
||||||
|
- **Data service pattern**: Services that fetch/parse/transform data similarly (Load → Map → Save)
|
||||||
|
- **Localization pattern**: `private readonly LocalizationService _localizationService = new();` and `L()` helper method repetitions
|
||||||
|
- **Helper method duplication**: Methods like `ResolveUnifiedMainRadiusValue`, `NormalizeConfig`, `ParticleState` classes copied across files
|
||||||
|
- **Error handling pattern**: Identical try-catch blocks repeated in multiple methods
|
||||||
|
- **Settings snapshot pattern**: `_settingsFacade.Settings.LoadSnapshot<T>(scope)` call sites
|
||||||
|
|
||||||
|
**Output**: List of duplicated patterns with file locations and line numbers.
|
||||||
|
|
||||||
|
### 3. Tight Coupling
|
||||||
|
|
||||||
|
- Services instantiated via `new` instead of DI injection
|
||||||
|
- ViewModels directly accessing infrastructure-layer APIs (e.g., `LoadSnapshot/SaveSnapshot`)
|
||||||
|
- Hard-coded dependencies (GitHub repo owner/name, default values)
|
||||||
|
- `Application.Current` upcasting to access services: `(Application.Current as App)?.SomeService`
|
||||||
|
- Platform-specific code embedded in cross-platform services without interface abstraction
|
||||||
|
|
||||||
|
**Output**: Table of coupling violations with severity (high/medium/low).
|
||||||
|
|
||||||
|
### 4. Naming Inconsistencies
|
||||||
|
|
||||||
|
- Service suffix inconsistency: `Service` vs `Store` vs `Helper` vs `Provider` vs `Manager` vs `Factory` for similar responsibilities
|
||||||
|
- Model suffix inconsistency: `Snapshot` vs `State` vs `Types` for similar concepts
|
||||||
|
- Platform prefix inconsistency: `Windows`/`Linux` full name vs `Mac` abbreviation
|
||||||
|
- Confusing names: services with similar names but different responsibilities (e.g., `NotificationService` vs `NotificationListenerService`)
|
||||||
|
|
||||||
|
**Output**: Categorized list of naming inconsistencies.
|
||||||
|
|
||||||
|
### 5. Missing Abstractions
|
||||||
|
|
||||||
|
- Services without corresponding interfaces (check for `I<ServiceName>` pattern)
|
||||||
|
- Common patterns that could be extracted into base classes:
|
||||||
|
- `SettingsPageViewModelBase` for shared ViewModel boilerplate
|
||||||
|
- `JsonFileSettingsService<TSnapshot>` for repeated settings persistence
|
||||||
|
- `SettingsDomainServiceBase<TState>` for Load-Map-Save pattern
|
||||||
|
- `DesktopComponentWidgetBase` for shared Widget code
|
||||||
|
- `ComponentEditorViewBase` enhancements (e.g., `_suppressEvents` pattern)
|
||||||
|
- Static singleton/Factory providers repeating thread-safe lazy-load boilerplate
|
||||||
|
|
||||||
|
**Output**: List of missing abstractions with proposed base class/interface names.
|
||||||
|
|
||||||
|
### 6. Misplaced Responsibilities
|
||||||
|
|
||||||
|
- Files in wrong directories (e.g., data access in Settings/, UI services mixed with data services)
|
||||||
|
- ViewModels containing business logic or file system operations
|
||||||
|
- Widget code-behind files with excessive logic (>200 lines)
|
||||||
|
- Platform-specific services not organized into subdirectories
|
||||||
|
|
||||||
|
**Output**: List of misplaced files/classes with recommended new locations.
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
Produce a structured report with:
|
||||||
|
|
||||||
|
1. **Summary table**: Total metrics (file count, duplication count, etc.)
|
||||||
|
2. **Priority-ranked findings**: P0 (must fix), P1 (should fix), P2 (recommended), P3 (nice to have)
|
||||||
|
3. **Each finding includes**: Problem description, affected files with links, specific line numbers, recommended action, estimated impact
|
||||||
|
|
||||||
|
### Priority Criteria
|
||||||
|
|
||||||
|
- **P0**: Files over 1000 lines with mixed responsibilities; patterns duplicated 10+ times; god classes with 20+ dependencies
|
||||||
|
- **P1**: Patterns duplicated 5-9 times; services without interfaces that are widely used; DI bypass affecting testability
|
||||||
|
- **P2**: Patterns duplicated 3-4 times; naming inconsistencies affecting readability; misplaced files
|
||||||
|
- **P3**: Minor naming variations; single-instance duplications; organizational improvements
|
||||||
|
|
||||||
|
## Project-Specific Context
|
||||||
|
|
||||||
|
This skill is aware of the LanMountainDesktop project structure:
|
||||||
|
|
||||||
|
- `LanMountainDesktop/Services/` — Business and infrastructure services
|
||||||
|
- `LanMountainDesktop/Services/Settings/` — Settings subsystem
|
||||||
|
- `LanMountainDesktop/ViewModels/` — View models
|
||||||
|
- `LanMountainDesktop/Views/Components/` — Desktop widget components
|
||||||
|
- `LanMountainDesktop/Views/ComponentEditors/` — Widget editor views
|
||||||
|
- `LanMountainDesktop/plugins/` — Plugin runtime
|
||||||
|
- `LanMountainDesktop.PluginSdk/` — Plugin SDK public API
|
||||||
|
- `LanMountainDesktop.Shared.Contracts/` — Host/plugin shared contracts
|
||||||
|
- `LanMountainDesktop.Appearance/` — Appearance and corner radius infrastructure
|
||||||
|
|
||||||
|
When analyzing, respect the project's architectural boundaries documented in `docs/ARCHITECTURE.md` and `docs/ECOSYSTEM_BOUNDARIES.md`.
|
||||||
@@ -1,100 +1,166 @@
|
|||||||
# 融合桌面组件库窗口重设计规格
|
# 融合桌面组件库窗口重设计规格
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
|
|
||||||
当前融合桌面组件库窗口(FusedDesktopComponentLibraryWindow)的UI设计较为基础,与Windows 11小组件编辑面板相比,缺乏现代化的交互体验和视觉层次。用户需要一个更直观、更美观的界面来浏览和添加组件到系统桌面(负一屏)。
|
当前融合桌面组件库窗口(FusedDesktopComponentLibraryWindow)的UI设计较为基础,与Windows 11小组件编辑面板相比,缺乏现代化的交互体验和视觉层次。用户需要一个更直观、更美观的界面来浏览和添加组件到系统桌面(负一屏)。
|
||||||
|
|
||||||
参考Windows 11小组件编辑面板的设计特点:
|
参考Windows 11小组件编辑面板的设计特点:
|
||||||
- 左侧分类列表,右侧选中组件的详细预览
|
|
||||||
- 大型组件预览区域,让用户清楚看到组件效果
|
* 左侧分类列表,右侧选中组件的详细预览
|
||||||
- 底部明显的"添加"操作按钮
|
|
||||||
- 简洁的关闭按钮(X)在右上角
|
* 大型组件预览区域,让用户清楚看到组件效果
|
||||||
- 深色主题配合毛玻璃效果
|
|
||||||
|
* 底部明显的"添加"操作按钮
|
||||||
|
|
||||||
|
* 简洁的关闭按钮(X)在右上角
|
||||||
|
|
||||||
|
* 深色主题配合毛玻璃效果
|
||||||
|
|
||||||
## What Changes
|
## What Changes
|
||||||
- **重新设计窗口布局**:从左右分栏(分类列表+组件网格)改为左侧面板+右侧预览区的布局
|
|
||||||
- **添加组件详情预览区**:选中组件后右侧显示大尺寸预览和组件信息
|
* **重新设计窗口布局**:从左右分栏(分类列表+组件网格)改为左侧面板+右侧预览区的布局
|
||||||
- **优化关闭按钮**:使用标准的X图标按钮,不使用圆形样式
|
|
||||||
- **添加底部操作栏**:包含"添加到桌面"主操作按钮和"查找更多组件"链接
|
* **添加组件详情预览区**:选中组件后右侧显示大尺寸预览和组件信息
|
||||||
- **复用阑山桌面组件库分类**:使用相同的分类ID、图标和本地化文本
|
|
||||||
- **移除搜索功能**:参考Windows 11设计,暂不提供搜索
|
* **优化关闭按钮**:使用标准的X图标按钮,不使用圆形样式
|
||||||
|
|
||||||
|
* **添加底部操作栏**:包含"添加到桌面"主操作按钮和"查找更多组件"链接
|
||||||
|
|
||||||
|
* **复用阑山桌面组件库分类**:使用相同的分类ID、图标和本地化文本
|
||||||
|
|
||||||
|
* **移除搜索功能**:参考Windows 11设计,暂不提供搜索
|
||||||
|
|
||||||
## Impact
|
## Impact
|
||||||
- 受影响文件:
|
|
||||||
- `LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml`
|
* 受影响文件:
|
||||||
- `LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml.cs`
|
|
||||||
- `LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml`
|
* `LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml`
|
||||||
- `LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs`
|
|
||||||
- `LanMountainDesktop/ViewModels/ComponentLibraryWindowViewModel.cs`(可能需要添加新属性)
|
* `LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml.cs`
|
||||||
|
|
||||||
|
* `LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml`
|
||||||
|
|
||||||
|
* `LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs`
|
||||||
|
|
||||||
|
* `LanMountainDesktop/ViewModels/ComponentLibraryWindowViewModel.cs`(可能需要添加新属性)
|
||||||
|
|
||||||
## ADDED Requirements
|
## ADDED Requirements
|
||||||
|
|
||||||
### Requirement: 窗口布局重设计
|
### Requirement: 窗口布局重设计
|
||||||
|
|
||||||
系统应提供一个类似于Windows 11小组件编辑面板的组件库窗口。
|
系统应提供一个类似于Windows 11小组件编辑面板的组件库窗口。
|
||||||
|
|
||||||
#### Scenario: 窗口整体结构
|
#### Scenario: 窗口整体结构
|
||||||
- **GIVEN** 用户从托盘菜单打开融合桌面组件库
|
|
||||||
- **WHEN** 窗口显示时
|
* **GIVEN** 用户从托盘菜单打开融合桌面组件库
|
||||||
- **THEN** 窗口应呈现:
|
|
||||||
- 顶部标题栏:左侧显示"添加小组件"标题,右侧有关闭按钮(X)
|
* **WHEN** 窗口显示时
|
||||||
- 左侧面板:分类列表(复用阑山桌面组件库的分类和图标)
|
|
||||||
- 右侧主区域:选中组件的大尺寸预览 + 组件信息 + 添加按钮
|
* **THEN** 窗口应呈现:
|
||||||
- 底部:"查找更多组件"链接
|
|
||||||
|
* 顶部标题栏:左侧显示"添加小组件"标题,右侧有关闭按钮(X)
|
||||||
|
|
||||||
|
* 左侧面板:分类列表(复用阑山桌面组件库的分类和图标)
|
||||||
|
|
||||||
|
* 右侧主区域:选中组件的大尺寸预览 + 组件信息 + 添加按钮
|
||||||
|
|
||||||
|
* 底部:"查找更多组件"链接
|
||||||
|
|
||||||
#### Scenario: 分类列表交互
|
#### Scenario: 分类列表交互
|
||||||
- **GIVEN** 左侧显示组件分类列表
|
|
||||||
- **WHEN** 用户点击某个分类
|
* **GIVEN** 左侧显示组件分类列表
|
||||||
- **THEN** 右侧应显示该分类下的第一个组件的预览
|
|
||||||
- **AND** 分类项应有选中状态视觉反馈
|
* **WHEN** 用户点击某个分类
|
||||||
- **AND** 分类图标和名称应与阑山桌面组件库保持一致
|
|
||||||
|
* **THEN** 右侧应显示该分类下的第一个组件的预览
|
||||||
|
|
||||||
|
* **AND** 分类项应有选中状态视觉反馈
|
||||||
|
|
||||||
|
* **AND** 分类图标和名称应与阑山桌面组件库保持一致
|
||||||
|
|
||||||
#### Scenario: 组件预览区
|
#### Scenario: 组件预览区
|
||||||
- **GIVEN** 用户选中一个组件
|
|
||||||
- **WHEN** 预览区显示时
|
* **GIVEN** 用户选中一个组件
|
||||||
- **THEN** 应显示:
|
|
||||||
- 组件标题(大字号)
|
* **WHEN** 预览区显示时
|
||||||
- 大尺寸组件预览图(接近实际尺寸)
|
|
||||||
- 组件描述/功能说明
|
* **THEN** 应显示:
|
||||||
- 底部"添加到桌面"按钮
|
|
||||||
|
* 组件标题(大字号)
|
||||||
|
|
||||||
|
* 大尺寸组件预览图(接近实际尺寸)
|
||||||
|
|
||||||
|
* 组件描述/功能说明
|
||||||
|
|
||||||
|
* 底部"添加到桌面"按钮
|
||||||
|
|
||||||
#### Scenario: 添加组件操作
|
#### Scenario: 添加组件操作
|
||||||
- **GIVEN** 用户查看组件预览
|
|
||||||
- **WHEN** 用户点击"添加到桌面"按钮
|
* **GIVEN** 用户查看组件预览
|
||||||
- **THEN** 组件应被添加到系统桌面(负一屏)中央
|
|
||||||
- **AND** 窗口应关闭
|
* **WHEN** 用户点击"添加到桌面"按钮
|
||||||
|
|
||||||
|
* **THEN** 组件应被添加到系统桌面(负一屏)中央
|
||||||
|
|
||||||
|
* **AND** 窗口应关闭
|
||||||
|
|
||||||
#### Scenario: 关闭按钮样式
|
#### Scenario: 关闭按钮样式
|
||||||
- **GIVEN** 窗口标题栏有关闭按钮
|
|
||||||
- **THEN** 关闭按钮应使用标准的X图标
|
* **GIVEN** 窗口标题栏有关闭按钮
|
||||||
- **AND** 不使用圆形背景或特殊样式
|
|
||||||
- **AND** 使用 `DesignCornerRadiusSm` 动态资源
|
* **THEN** 关闭按钮应使用标准的X图标
|
||||||
|
|
||||||
|
* **AND** 不使用圆形背景或特殊样式
|
||||||
|
|
||||||
|
* **AND** 使用 `DesignCornerRadiusSm` 动态资源
|
||||||
|
|
||||||
#### Scenario: 查找更多组件链接
|
#### Scenario: 查找更多组件链接
|
||||||
- **GIVEN** 窗口底部显示"查找更多组件"链接
|
|
||||||
- **WHEN** 用户点击该链接
|
* **GIVEN** 窗口底部显示"查找更多组件"链接
|
||||||
- **THEN** 应打开设置窗口的插件目录页面(后续将改为插件市场)
|
|
||||||
|
* **WHEN** 用户点击该链接
|
||||||
|
|
||||||
|
* **THEN** 应打开设置窗口的插件目录页面(后续将改为插件市场)
|
||||||
|
|
||||||
## MODIFIED Requirements
|
## MODIFIED Requirements
|
||||||
|
|
||||||
### Requirement: 组件列表展示
|
### Requirement: 组件列表展示
|
||||||
|
|
||||||
原实现使用网格展示所有组件,新实现改为:
|
原实现使用网格展示所有组件,新实现改为:
|
||||||
- 左侧列表仅显示分类(复用阑山桌面组件库的分类ID和图标映射)
|
|
||||||
- 右侧预览区一次只显示一个组件的详细信息
|
* 左侧列表仅显示分类(复用阑山桌面组件库的分类ID和图标映射)
|
||||||
- ~~移除搜索功能~~(根据Windows 11设计,暂不提供搜索)
|
|
||||||
|
* 右侧预览区一次只显示一个组件的详细信息
|
||||||
|
|
||||||
|
* ~~移除搜索功能~~(根据Windows 11设计,暂不提供搜索)
|
||||||
|
|
||||||
### Requirement: 关闭按钮圆角规范
|
### Requirement: 关闭按钮圆角规范
|
||||||
|
|
||||||
原实现关闭按钮使用硬编码 `CornerRadius="18"`,应改为使用动态资源 `DesignCornerRadiusSm`。
|
原实现关闭按钮使用硬编码 `CornerRadius="18"`,应改为使用动态资源 `DesignCornerRadiusSm`。
|
||||||
|
|
||||||
### Requirement: 分类图标复用
|
### Requirement: 分类图标复用
|
||||||
|
|
||||||
分类图标映射应与阑山桌面组件库保持一致:
|
分类图标映射应与阑山桌面组件库保持一致:
|
||||||
- Clock -> Symbol.Clock
|
|
||||||
- Date -> Symbol.CalendarDate
|
* Clock -> Symbol.Clock
|
||||||
- Weather -> Symbol.WeatherSunny
|
|
||||||
- Board -> Symbol.Edit
|
* Date -> Symbol.CalendarDate
|
||||||
- Media -> Symbol.Play
|
|
||||||
- Info -> Symbol.Info
|
* Weather -> Symbol.WeatherSunny
|
||||||
- Calculator -> Symbol.Calculator
|
|
||||||
- Study -> Symbol.Hourglass
|
* Board -> Symbol.Edit
|
||||||
- 其他 -> Symbol.Apps
|
|
||||||
|
* Media -> Symbol.Play
|
||||||
|
|
||||||
|
* Info -> Symbol.Info
|
||||||
|
|
||||||
|
* Calculator -> Symbol.Calculator
|
||||||
|
|
||||||
|
* Study -> Symbol.Hourglass
|
||||||
|
|
||||||
|
* 其他 -> Symbol.Apps
|
||||||
|
|
||||||
## REMOVED Requirements
|
## REMOVED Requirements
|
||||||
- ~~搜索功能~~:根据Windows 11小组件面板设计,暂不提供搜索功能
|
|
||||||
|
* ~~搜索功能~~:根据Windows 11小组件面板设计,暂不提供搜索功能
|
||||||
|
|
||||||
|
|||||||
111
CHANGELOG.md
111
CHANGELOG.md
@@ -1,40 +1,79 @@
|
|||||||
# 更新日志 / Changelog
|
# 更新日志 / Changelog
|
||||||
|
|
||||||
所有重要的更改都将记录在此文件中。
|
## [0.8.3.4](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.4) - 2026-04-12
|
||||||
|
|
||||||
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),
|
|
||||||
并且本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [Unreleased]
|
|
||||||
|
|
||||||
### 新增 (Added)
|
### 新增 (Added)
|
||||||
- 待发布的新功能
|
|
||||||
|
- 无
|
||||||
|
|
||||||
### 变更 (Changed)
|
### 变更 (Changed)
|
||||||
- 待发布的变更
|
|
||||||
|
- ✨ **插件设置页面支持 View 展示**: 插件设置页面现在支持使用 View 进行展示
|
||||||
|
- 插件开发者可以通过 View 自定义设置页面的 UI 和交互
|
||||||
|
- 提供更灵活的设置页面展示方式,提升插件用户体验
|
||||||
|
- 兼容原有的设置方式,平滑过渡
|
||||||
|
|
||||||
### 修复 (Fixed)
|
### 修复 (Fixed)
|
||||||
- 待发布的修复
|
|
||||||
|
- 无
|
||||||
|
|
||||||
### 移除 (Removed)
|
### 移除 (Removed)
|
||||||
- 待发布的移除项
|
|
||||||
|
|
||||||
---
|
- 无
|
||||||
|
|
||||||
## [0.8.3.2] - 2026-04-09
|
***
|
||||||
|
|
||||||
|
## [0.8.3.3](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.3) - 2026-04-12
|
||||||
|
|
||||||
### 新增 (Added)
|
### 新增 (Added)
|
||||||
|
|
||||||
|
- ✨ **便签组件**: 全新便签组件上线,支持 Markdown 语法
|
||||||
|
- 支持丰富的 Markdown 格式:标题、列表、加粗、斜体、代码块等
|
||||||
|
- 便签内容自动保存,方便记录和管理日常备忘。丰富信息展示途径,让作业布置也可在阑山桌面完成。
|
||||||
|
- ✨ **白板主题自适应笔色**: 白板功能新增主题自适应笔色支持
|
||||||
|
- 根据当前主题自动调整画笔颜色,确保在不同主题下都有良好的书写体验
|
||||||
|
- 深色主题下自动切换为浅色笔迹,浅色主题下使用深色笔迹
|
||||||
|
|
||||||
|
### 变更 (Changed)
|
||||||
|
|
||||||
|
- 🎨 **融合桌面设置组件库样式更新**: 优化融合桌面设置页面的组件库样式
|
||||||
|
- 提升视觉一致性和用户体验
|
||||||
|
- 统一组件风格,与整体设计语言保持协调
|
||||||
|
|
||||||
|
### 修复 (Fixed)
|
||||||
|
|
||||||
|
- 🐛 **白板无法使用问题**: 修复了白板功能无法正常使用的问题
|
||||||
|
- 问题原因: 相关依赖或初始化逻辑异常导致白板功能失效
|
||||||
|
- 修复方案: 修复了白板的依赖加载和初始化流程,恢复正常使用
|
||||||
|
- 🐛 **央官网新闻组件显示问题**: 修复了央官网新闻组件的显示异常
|
||||||
|
- 优化组件渲染逻辑,确保新闻内容正确展示
|
||||||
|
- 🐛 **课程表组件显示问题**: 修复了课程表组件的显示异常
|
||||||
|
- 优化组件布局和渲染,确保课程信息正确显示
|
||||||
|
- 🐛 **轻量版 .NET 10 依赖问题(实验性)**: 实验性修复了轻量版在 .NET 10 环境下的依赖问题
|
||||||
|
- 问题原因: 轻量版与 .NET 10 的依赖兼容性存在冲突
|
||||||
|
- 修复方案: 调整依赖配置,提升与 .NET 10 的兼容性(实验性修复,持续观察中)
|
||||||
|
|
||||||
|
### 移除 (Removed)
|
||||||
|
|
||||||
|
- 无
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## [0.8.3.2](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.2) - 2026-04-09
|
||||||
|
|
||||||
|
### 新增 (Added)
|
||||||
|
|
||||||
- ✨ **应用启动台图标卡片显示选项**: 新增应用启动台图标卡片显示设置
|
- ✨ **应用启动台图标卡片显示选项**: 新增应用启动台图标卡片显示设置
|
||||||
- 用户可在设置中选择是否显示应用图标的专属卡片背景
|
- 用户可在设置中选择是否显示应用图标的专属卡片背景
|
||||||
- 关闭后仅显示应用图标本身,更加简洁
|
- 关闭后仅显示应用图标本身,更加简洁
|
||||||
- 支持动态切换,实时预览效果
|
- 支持动态切换,实时预览效果
|
||||||
|
|
||||||
### 变更 (Changed)
|
### 变更 (Changed)
|
||||||
|
|
||||||
- 无
|
- 无
|
||||||
|
|
||||||
### 修复 (Fixed)
|
### 修复 (Fixed)
|
||||||
|
|
||||||
- 🐛 **应用启动台文件夹应用数量限制**: 修复了应用启动台文件夹无法查看超过12个应用的问题
|
- 🐛 **应用启动台文件夹应用数量限制**: 修复了应用启动台文件夹无法查看超过12个应用的问题
|
||||||
- 问题原因: 文件夹弹窗未实现滚动功能,应用列表超出显示区域后被截断
|
- 问题原因: 文件夹弹窗未实现滚动功能,应用列表超出显示区域后被截断
|
||||||
- 修复方案: 为文件夹内容区域添加滚动支持,允许用户滚动查看所有应用
|
- 修复方案: 为文件夹内容区域添加滚动支持,允许用户滚动查看所有应用
|
||||||
@@ -46,13 +85,15 @@
|
|||||||
- 修复方案: 在 `IncrementalUpdateItems()` 方法中同步更新课程项的文字颜色
|
- 修复方案: 在 `IncrementalUpdateItems()` 方法中同步更新课程项的文字颜色
|
||||||
|
|
||||||
### 移除 (Removed)
|
### 移除 (Removed)
|
||||||
|
|
||||||
- 🗑️ **更新页面重复标题**: 移除了更新页面中重复的更新标题,优化页面布局
|
- 🗑️ **更新页面重复标题**: 移除了更新页面中重复的更新标题,优化页面布局
|
||||||
|
|
||||||
---
|
***
|
||||||
|
|
||||||
## [0.8.3.1] - 2026-04-08
|
## [0.8.3.1](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.1) - 2026-04-08
|
||||||
|
|
||||||
### 新增 (Added)
|
### 新增 (Added)
|
||||||
|
|
||||||
- ✨ **快捷方式组件**: 新增快捷方式组件,可在阑山桌面内便捷打开系统应用与文件
|
- ✨ **快捷方式组件**: 新增快捷方式组件,可在阑山桌面内便捷打开系统应用与文件
|
||||||
- 支持创建快捷方式,统一管理应用和文件
|
- 支持创建快捷方式,统一管理应用和文件
|
||||||
- 提供单击打开和双击打开两种交互模式
|
- 提供单击打开和双击打开两种交互模式
|
||||||
@@ -60,15 +101,45 @@
|
|||||||
- 📝 初始化更新日志文档,为后续版本发布建立基础
|
- 📝 初始化更新日志文档,为后续版本发布建立基础
|
||||||
|
|
||||||
### 变更 (Changed)
|
### 变更 (Changed)
|
||||||
|
|
||||||
- 无
|
- 无
|
||||||
|
|
||||||
### 修复 (Fixed)
|
### 修复 (Fixed)
|
||||||
|
|
||||||
- 无
|
- 无
|
||||||
|
|
||||||
### 移除 (Removed)
|
### 移除 (Removed)
|
||||||
|
|
||||||
- 无
|
- 无
|
||||||
|
|
||||||
---
|
***
|
||||||
|
|
||||||
|
所有重要的更改都将记录在此文件中。
|
||||||
|
|
||||||
|
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),
|
||||||
|
并且本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## \[格式示例]
|
||||||
|
|
||||||
|
### 新增 (Added)
|
||||||
|
|
||||||
|
- 待发布的新功能
|
||||||
|
|
||||||
|
### 变更 (Changed)
|
||||||
|
|
||||||
|
- 待发布的变更
|
||||||
|
|
||||||
|
### 修复 (Fixed)
|
||||||
|
|
||||||
|
- 待发布的修复
|
||||||
|
|
||||||
|
### 移除 (Removed)
|
||||||
|
|
||||||
|
- 待发布的移除项
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
## 版本说明
|
## 版本说明
|
||||||
|
|
||||||
@@ -101,10 +172,6 @@
|
|||||||
- 🔒 **安全**: 安全相关
|
- 🔒 **安全**: 安全相关
|
||||||
- 🌐 **国际化**: 国际化/本地化
|
- 🌐 **国际化**: 国际化/本地化
|
||||||
|
|
||||||
---
|
***
|
||||||
|
|
||||||
## 链接
|
## 链接
|
||||||
|
|
||||||
[Unreleased]: https://github.com/yourorg/LanMountainDesktop/compare/v0.8.3.2...HEAD
|
|
||||||
[0.8.3.2]: https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.2
|
|
||||||
[0.8.3.1]: https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.1
|
|
||||||
|
|||||||
109
LanMountainDesktop.PluginSdk/AppearanceChangedEvent.cs
Normal file
109
LanMountainDesktop.PluginSdk/AppearanceChangedEvent.cs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 外观变更事件参数,当主题、圆角或其他外观属性变化时触发。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AppearanceChangedEvent : EventArgs
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 创建外观变更事件实例。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="snapshot">当前外观快照</param>
|
||||||
|
/// <param name="changedProperties">变更的属性集合</param>
|
||||||
|
public AppearanceChangedEvent(
|
||||||
|
PluginAppearanceSnapshot snapshot,
|
||||||
|
IReadOnlyCollection<AppearanceProperty> changedProperties)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(snapshot);
|
||||||
|
ArgumentNullException.ThrowIfNull(changedProperties);
|
||||||
|
|
||||||
|
Snapshot = snapshot;
|
||||||
|
ChangedProperties = changedProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 当前外观快照。
|
||||||
|
/// </summary>
|
||||||
|
public PluginAppearanceSnapshot Snapshot { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 变更的属性集合。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyCollection<AppearanceProperty> ChangedProperties { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 圆角是否发生变化。
|
||||||
|
/// </summary>
|
||||||
|
public bool CornerRadiusChanged => ChangedProperties.Contains(AppearanceProperty.CornerRadius);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 主题变体(亮色/暗色)是否发生变化。
|
||||||
|
/// </summary>
|
||||||
|
public bool ThemeVariantChanged => ChangedProperties.Contains(AppearanceProperty.ThemeVariant);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 强调色是否发生变化。
|
||||||
|
/// </summary>
|
||||||
|
public bool AccentColorChanged => ChangedProperties.Contains(AppearanceProperty.AccentColor);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 圆角风格是否发生变化。
|
||||||
|
/// </summary>
|
||||||
|
public bool CornerRadiusStyleChanged => ChangedProperties.Contains(AppearanceProperty.CornerRadiusStyle);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 检查指定属性是否发生变化。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="property">要检查的属性</param>
|
||||||
|
/// <returns>如果属性发生变化则返回 true</returns>
|
||||||
|
public bool HasChanged(AppearanceProperty property)
|
||||||
|
{
|
||||||
|
return ChangedProperties.Contains(property);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 检查是否有任何外观属性发生变化。
|
||||||
|
/// </summary>
|
||||||
|
public bool HasAnyChanges => ChangedProperties.Count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 可变更的外观属性枚举。
|
||||||
|
/// </summary>
|
||||||
|
public enum AppearanceProperty
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 圆角Token值发生变化。
|
||||||
|
/// </summary>
|
||||||
|
CornerRadius,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 主题变体(亮色/暗色)发生变化。
|
||||||
|
/// </summary>
|
||||||
|
ThemeVariant,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 强调色发生变化。
|
||||||
|
/// </summary>
|
||||||
|
AccentColor,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 圆角风格(Sharp/Balanced/Rounded/Open)发生变化。
|
||||||
|
/// </summary>
|
||||||
|
CornerRadiusStyle,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 壁纸发生变化。
|
||||||
|
/// </summary>
|
||||||
|
Wallpaper,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 系统材质模式发生变化。
|
||||||
|
/// </summary>
|
||||||
|
SystemMaterialMode,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 所有外观属性(用于批量更新)。
|
||||||
|
/// </summary>
|
||||||
|
All
|
||||||
|
}
|
||||||
@@ -1,10 +1,35 @@
|
|||||||
namespace LanMountainDesktop.PluginSdk;
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 插件外观上下文接口,提供主题、圆角等外观资源的访问和变更通知。
|
||||||
|
/// </summary>
|
||||||
public interface IPluginAppearanceContext
|
public interface IPluginAppearanceContext
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 当前外观快照。
|
||||||
|
/// </summary>
|
||||||
PluginAppearanceSnapshot Snapshot { get; }
|
PluginAppearanceSnapshot Snapshot { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 外观变更事件。当主题、圆角或其他外观属性发生变化时触发。
|
||||||
|
/// </summary>
|
||||||
|
event EventHandler<AppearanceChangedEvent>? Changed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 解析带缩放的圆角半径。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="baseRadius">基础圆角半径</param>
|
||||||
|
/// <param name="minimum">最小值(可选)</param>
|
||||||
|
/// <param name="maximum">最大值(可选)</param>
|
||||||
|
/// <returns>解析后的圆角半径</returns>
|
||||||
double ResolveScaledCornerRadius(double baseRadius, double? minimum = null, double? maximum = null);
|
double ResolveScaledCornerRadius(double baseRadius, double? minimum = null, double? maximum = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 根据预设解析圆角半径。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="preset">圆角预设</param>
|
||||||
|
/// <param name="minimum">最小值(可选)</param>
|
||||||
|
/// <param name="maximum">最大值(可选)</param>
|
||||||
|
/// <returns>解析后的圆角半径</returns>
|
||||||
double ResolveCornerRadius(PluginCornerRadiusPreset preset, double? minimum = null, double? maximum = null);
|
double ResolveCornerRadius(PluginCornerRadiusPreset preset, double? minimum = null, double? maximum = null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<Version>4.0.0</Version>
|
<Version>4.0.1</Version>
|
||||||
<PackageId>LanMountainDesktop.PluginSdk</PackageId>
|
<PackageId>LanMountainDesktop.PluginSdk</PackageId>
|
||||||
<IsPackable>true</IsPackable>
|
<IsPackable>true</IsPackable>
|
||||||
<Authors>LanMountainDesktop</Authors>
|
<Authors>LanMountainDesktop</Authors>
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
namespace LanMountainDesktop.PluginSdk;
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 插件外观上下文实现,提供主题、圆角等外观资源的访问和变更通知。
|
||||||
|
/// </summary>
|
||||||
public sealed class PluginAppearanceContext : IPluginAppearanceContext
|
public sealed class PluginAppearanceContext : IPluginAppearanceContext
|
||||||
{
|
{
|
||||||
|
private PluginAppearanceSnapshot _snapshot;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建插件外观上下文实例。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="snapshot">初始外观快照</param>
|
||||||
public PluginAppearanceContext(PluginAppearanceSnapshot snapshot)
|
public PluginAppearanceContext(PluginAppearanceSnapshot snapshot)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(snapshot);
|
ArgumentNullException.ThrowIfNull(snapshot);
|
||||||
ArgumentNullException.ThrowIfNull(snapshot.CornerRadiusTokens);
|
ArgumentNullException.ThrowIfNull(snapshot.CornerRadiusTokens);
|
||||||
|
|
||||||
Snapshot = snapshot with
|
_snapshot = snapshot with
|
||||||
{
|
{
|
||||||
ThemeVariant = string.IsNullOrWhiteSpace(snapshot.ThemeVariant)
|
ThemeVariant = string.IsNullOrWhiteSpace(snapshot.ThemeVariant)
|
||||||
? "Unknown"
|
? "Unknown"
|
||||||
@@ -15,8 +24,37 @@ public sealed class PluginAppearanceContext : IPluginAppearanceContext
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public PluginAppearanceSnapshot Snapshot { get; }
|
/// <inheritdoc />
|
||||||
|
public PluginAppearanceSnapshot Snapshot => _snapshot;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public event EventHandler<AppearanceChangedEvent>? Changed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新外观快照并触发变更事件。
|
||||||
|
/// 此方法由宿主调用,用于在主题、圆角等外观属性变化时通知插件。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="newSnapshot">新的外观快照</param>
|
||||||
|
/// <param name="changedProperties">变更的属性集合</param>
|
||||||
|
public void UpdateSnapshot(PluginAppearanceSnapshot newSnapshot, IReadOnlyCollection<AppearanceProperty> changedProperties)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(newSnapshot);
|
||||||
|
ArgumentNullException.ThrowIfNull(changedProperties);
|
||||||
|
|
||||||
|
_snapshot = newSnapshot with
|
||||||
|
{
|
||||||
|
ThemeVariant = string.IsNullOrWhiteSpace(newSnapshot.ThemeVariant)
|
||||||
|
? "Unknown"
|
||||||
|
: newSnapshot.ThemeVariant.Trim()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (changedProperties.Count > 0)
|
||||||
|
{
|
||||||
|
Changed?.Invoke(this, new AppearanceChangedEvent(_snapshot, changedProperties));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public double ResolveScaledCornerRadius(double baseRadius, double? minimum = null, double? maximum = null)
|
public double ResolveScaledCornerRadius(double baseRadius, double? minimum = null, double? maximum = null)
|
||||||
{
|
{
|
||||||
var value = Math.Max(0d, baseRadius);
|
var value = Math.Max(0d, baseRadius);
|
||||||
@@ -30,16 +68,17 @@ public sealed class PluginAppearanceContext : IPluginAppearanceContext
|
|||||||
return Math.Clamp(value, clampedMin, clampedMax);
|
return Math.Clamp(value, clampedMin, clampedMax);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public double ResolveCornerRadius(PluginCornerRadiusPreset preset, double? minimum = null, double? maximum = null)
|
public double ResolveCornerRadius(PluginCornerRadiusPreset preset, double? minimum = null, double? maximum = null)
|
||||||
{
|
{
|
||||||
var resolved = Math.Max(0d, Snapshot.CornerRadiusTokens.Get(preset));
|
var resolved = Math.Max(0d, _snapshot.CornerRadiusTokens.Get(preset));
|
||||||
if (!minimum.HasValue && !maximum.HasValue)
|
if (!minimum.HasValue && !maximum.HasValue)
|
||||||
{
|
{
|
||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
var clampedMin = minimum ?? resolved;
|
var clampedMin = minimum ?? 0d;
|
||||||
var clampedMax = maximum ?? resolved;
|
var clampedMax = maximum ?? double.MaxValue;
|
||||||
if (clampedMin > clampedMax)
|
if (clampedMin > clampedMax)
|
||||||
{
|
{
|
||||||
(clampedMin, clampedMax) = (clampedMax, clampedMin);
|
(clampedMin, clampedMax) = (clampedMax, clampedMin);
|
||||||
|
|||||||
137
LanMountainDesktop.PluginSdk/PluginAppearanceHelper.cs
Normal file
137
LanMountainDesktop.PluginSdk/PluginAppearanceHelper.cs
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
using Avalonia;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 插件外观辅助方法,提供统一的圆角和主题资源访问。
|
||||||
|
/// </summary>
|
||||||
|
public static class PluginAppearanceHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取桌面组件主外壳圆角半径。
|
||||||
|
/// 这是组件最外层边框应该使用的圆角值,对应 DesignCornerRadiusComponent 资源。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">外观上下文</param>
|
||||||
|
/// <returns>主外壳圆角半径(像素)</returns>
|
||||||
|
public static double GetShellCornerRadius(this IPluginAppearanceContext context)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
return context.ResolveCornerRadius(PluginCornerRadiusPreset.Component);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取内部卡片圆角半径。
|
||||||
|
/// 用于组件内部的次级卡片、内容区块等。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">外观上下文</param>
|
||||||
|
/// <returns>内部卡片圆角半径(像素)</returns>
|
||||||
|
public static double GetCardCornerRadius(this IPluginAppearanceContext context)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
return context.ResolveCornerRadius(PluginCornerRadiusPreset.Sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取控件圆角半径。
|
||||||
|
/// 用于按钮、输入框、标签等交互控件。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">外观上下文</param>
|
||||||
|
/// <returns>控件圆角半径(像素)</returns>
|
||||||
|
public static double GetControlCornerRadius(this IPluginAppearanceContext context)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
return context.ResolveCornerRadius(PluginCornerRadiusPreset.Xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取徽章/标签圆角半径。
|
||||||
|
/// 用于小徽章、标签、角标等微元素。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">外观上下文</param>
|
||||||
|
/// <returns>徽章圆角半径(像素)</returns>
|
||||||
|
public static double GetBadgeCornerRadius(this IPluginAppearanceContext context)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
return context.ResolveCornerRadius(PluginCornerRadiusPreset.Micro);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取中等面板圆角半径。
|
||||||
|
/// 用于悬浮菜单、小提示框、子面板等。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">外观上下文</param>
|
||||||
|
/// <returns>中等面板圆角半径(像素)</returns>
|
||||||
|
public static double GetMediumPanelCornerRadius(this IPluginAppearanceContext context)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
return context.ResolveCornerRadius(PluginCornerRadiusPreset.Md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取大面板圆角半径。
|
||||||
|
/// 用于对话框、设置面板等大型容器(非桌面组件)。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">外观上下文</param>
|
||||||
|
/// <returns>大面板圆角半径(像素)</returns>
|
||||||
|
public static double GetLargePanelCornerRadius(this IPluginAppearanceContext context)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
return context.ResolveCornerRadius(PluginCornerRadiusPreset.Lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将圆角预设转换为 Avalonia CornerRadius。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">外观上下文</param>
|
||||||
|
/// <param name="preset">圆角预设</param>
|
||||||
|
/// <returns>Avalonia CornerRadius 结构</returns>
|
||||||
|
public static CornerRadius ToCornerRadius(this IPluginAppearanceContext context, PluginCornerRadiusPreset preset)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
var radius = context.ResolveCornerRadius(preset);
|
||||||
|
return new CornerRadius(radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取当前主题变体(亮色/暗色)。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">外观上下文</param>
|
||||||
|
/// <returns>是否为暗色主题</returns>
|
||||||
|
public static bool IsDarkTheme(this IPluginAppearanceContext context)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
return string.Equals(context.Snapshot.ThemeVariant, "Dark", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取当前主题变体字符串。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">外观上下文</param>
|
||||||
|
/// <returns>主题变体字符串("Light" 或 "Dark")</returns>
|
||||||
|
public static string GetThemeVariant(this IPluginAppearanceContext context)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
return context.Snapshot.ThemeVariant;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 内部元素层级,用于区分不同层级的圆角需求。
|
||||||
|
/// </summary>
|
||||||
|
public enum InnerElementLevel
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 内部卡片:使用 Sm token(14px @ 1.0x)
|
||||||
|
/// </summary>
|
||||||
|
Card,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交互控件:使用 Xs token(12px @ 1.0x)
|
||||||
|
/// </summary>
|
||||||
|
Control,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 微元素徽章:使用 Micro token(6px @ 1.0x)
|
||||||
|
/// </summary>
|
||||||
|
Badge
|
||||||
|
}
|
||||||
@@ -72,14 +72,11 @@ public sealed class PluginDesktopComponentRegistration
|
|||||||
var resolved = CornerRadiusResolver is not null
|
var resolved = CornerRadiusResolver is not null
|
||||||
? CornerRadiusResolver(appearance, Math.Max(1d, cellSize))
|
? CornerRadiusResolver(appearance, Math.Max(1d, cellSize))
|
||||||
: CornerRadiusPreset == PluginCornerRadiusPreset.Default
|
: CornerRadiusPreset == PluginCornerRadiusPreset.Default
|
||||||
? appearance.ResolveScaledCornerRadius(
|
? appearance.ResolveCornerRadius(PluginCornerRadiusPreset.Component)
|
||||||
Math.Clamp(Math.Max(1d, cellSize) * 0.22, 8, 18),
|
|
||||||
8,
|
|
||||||
18)
|
|
||||||
: appearance.ResolveCornerRadius(CornerRadiusPreset);
|
: appearance.ResolveCornerRadius(CornerRadiusPreset);
|
||||||
|
|
||||||
return double.IsFinite(resolved)
|
return double.IsFinite(resolved)
|
||||||
? Math.Max(0d, resolved)
|
? Math.Max(0d, resolved)
|
||||||
: appearance.ResolveCornerRadius(PluginCornerRadiusPreset.Default);
|
: appearance.ResolveCornerRadius(PluginCornerRadiusPreset.Component);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ namespace LanMountainDesktop.PluginSdk;
|
|||||||
|
|
||||||
public static class PluginSdkInfo
|
public static class PluginSdkInfo
|
||||||
{
|
{
|
||||||
public const string ApiVersion = "4.0.0";
|
public const string ApiVersion = "4.0.1";
|
||||||
public const string ManifestFileName = "plugin.json";
|
public const string ManifestFileName = "plugin.json";
|
||||||
public const string PackageFileExtension = ".laapp";
|
public const string PackageFileExtension = ".laapp";
|
||||||
public const string DataDirectoryName = "Data";
|
public const string DataDirectoryName = "Data";
|
||||||
|
|||||||
@@ -28,6 +28,35 @@ public static class PluginServiceCollectionExtensions
|
|||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a plugin settings section with a custom AXAML view.
|
||||||
|
/// The host application will display <typeparamref name="TView"/> directly
|
||||||
|
/// in the settings window, allowing the plugin to use any Fluent Avalonia controls
|
||||||
|
/// and custom layouts — just like built-in settings pages.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TView">A <see cref="SettingsPageBase"/> subclass that defines the settings UI using AXAML.</typeparam>
|
||||||
|
public static IServiceCollection AddPluginSettingsSection<TView>(
|
||||||
|
this IServiceCollection services,
|
||||||
|
string id,
|
||||||
|
string titleLocalizationKey,
|
||||||
|
string? descriptionLocalizationKey = null,
|
||||||
|
string iconKey = "PuzzlePiece",
|
||||||
|
int sortOrder = 0)
|
||||||
|
where TView : SettingsPageBase
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
|
|
||||||
|
var builder = new PluginSettingsSectionBuilder(
|
||||||
|
id,
|
||||||
|
titleLocalizationKey,
|
||||||
|
descriptionLocalizationKey,
|
||||||
|
iconKey,
|
||||||
|
sortOrder);
|
||||||
|
builder.SetCustomView<TView>();
|
||||||
|
services.AddSingleton(builder.Build());
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
public static IServiceCollection AddPluginDesktopComponent<TControl>(
|
public static IServiceCollection AddPluginDesktopComponent<TControl>(
|
||||||
this IServiceCollection services,
|
this IServiceCollection services,
|
||||||
PluginDesktopComponentOptions options)
|
PluginDesktopComponentOptions options)
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
namespace LanMountainDesktop.PluginSdk;
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
public sealed class PluginSettingsSectionBuilder
|
public sealed class PluginSettingsSectionBuilder
|
||||||
{
|
{
|
||||||
private readonly List<SettingsOptionDefinition> _options = [];
|
private readonly List<SettingsOptionDefinition> _options = [];
|
||||||
|
private Type? _customViewType;
|
||||||
|
|
||||||
internal PluginSettingsSectionBuilder(
|
internal PluginSettingsSectionBuilder(
|
||||||
string id,
|
string id,
|
||||||
@@ -30,8 +33,46 @@ public sealed class PluginSettingsSectionBuilder
|
|||||||
|
|
||||||
public int SortOrder { get; }
|
public int SortOrder { get; }
|
||||||
|
|
||||||
|
public Type? CustomViewType => _customViewType;
|
||||||
|
|
||||||
public IReadOnlyList<SettingsOptionDefinition> Options => _options;
|
public IReadOnlyList<SettingsOptionDefinition> Options => _options;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets a custom AXAML view for this settings section.
|
||||||
|
/// The view type must be a subclass of <see cref="SettingsPageBase"/>.
|
||||||
|
/// When a custom view is provided, the host application will use it directly
|
||||||
|
/// instead of generating a page from the declared options, allowing the plugin
|
||||||
|
/// to use any Fluent Avalonia controls and custom layouts.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TView">A <see cref="SettingsPageBase"/> subclass that defines the settings UI.</typeparam>
|
||||||
|
public PluginSettingsSectionBuilder SetCustomView<TView>() where TView : SettingsPageBase
|
||||||
|
{
|
||||||
|
_customViewType = typeof(TView);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets a custom AXAML view for this settings section.
|
||||||
|
/// The view type must be a subclass of <see cref="SettingsPageBase"/>.
|
||||||
|
/// When a custom view is provided, the host application will use it directly
|
||||||
|
/// instead of generating a page from the declared options.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="viewType">A <see cref="SettingsPageBase"/> subclass type that defines the settings UI.</param>
|
||||||
|
public PluginSettingsSectionBuilder SetCustomView(Type viewType)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(viewType);
|
||||||
|
|
||||||
|
if (!typeof(SettingsPageBase).IsAssignableFrom(viewType))
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"Custom view type must be a subclass of {nameof(SettingsPageBase)}.",
|
||||||
|
nameof(viewType));
|
||||||
|
}
|
||||||
|
|
||||||
|
_customViewType = viewType;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public PluginSettingsSectionBuilder AddOption(SettingsOptionDefinition option)
|
public PluginSettingsSectionBuilder AddOption(SettingsOptionDefinition option)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(option);
|
ArgumentNullException.ThrowIfNull(option);
|
||||||
@@ -142,6 +183,7 @@ public sealed class PluginSettingsSectionBuilder
|
|||||||
_options.ToArray(),
|
_options.ToArray(),
|
||||||
DescriptionLocalizationKey,
|
DescriptionLocalizationKey,
|
||||||
IconKey,
|
IconKey,
|
||||||
SortOrder);
|
SortOrder,
|
||||||
|
_customViewType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace LanMountainDesktop.PluginSdk;
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
@@ -10,7 +11,8 @@ public sealed class PluginSettingsSectionRegistration
|
|||||||
IReadOnlyList<SettingsOptionDefinition> options,
|
IReadOnlyList<SettingsOptionDefinition> options,
|
||||||
string? descriptionLocalizationKey = null,
|
string? descriptionLocalizationKey = null,
|
||||||
string iconKey = "PuzzlePiece",
|
string iconKey = "PuzzlePiece",
|
||||||
int sortOrder = 0)
|
int sortOrder = 0,
|
||||||
|
Type? customViewType = null)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(titleLocalizationKey);
|
ArgumentException.ThrowIfNullOrWhiteSpace(titleLocalizationKey);
|
||||||
@@ -24,6 +26,15 @@ public sealed class PluginSettingsSectionRegistration
|
|||||||
IconKey = iconKey.Trim();
|
IconKey = iconKey.Trim();
|
||||||
SortOrder = sortOrder;
|
SortOrder = sortOrder;
|
||||||
Options = options ?? [];
|
Options = options ?? [];
|
||||||
|
|
||||||
|
if (customViewType is not null && !typeof(SettingsPageBase).IsAssignableFrom(customViewType))
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"Custom view type must be a subclass of {nameof(SettingsPageBase)}.",
|
||||||
|
nameof(customViewType));
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomViewType = customViewType;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Id { get; }
|
public string Id { get; }
|
||||||
@@ -37,4 +48,11 @@ public sealed class PluginSettingsSectionRegistration
|
|||||||
public int SortOrder { get; }
|
public int SortOrder { get; }
|
||||||
|
|
||||||
public IReadOnlyList<SettingsOptionDefinition> Options { get; }
|
public IReadOnlyList<SettingsOptionDefinition> Options { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When set, the host application will instantiate this <see cref="SettingsPageBase"/> subclass
|
||||||
|
/// instead of generating a page from <see cref="Options"/>.
|
||||||
|
/// This allows plugins to provide fully custom AXAML views with any Fluent Avalonia controls.
|
||||||
|
/// </summary>
|
||||||
|
public Type? CustomViewType { get; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Official SDK package for LanMountainDesktop plugins.
|
|||||||
|
|
||||||
```xml
|
```xml
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="LanMountainDesktop.PluginSdk" Version="4.0.0" />
|
<PackageReference Include="LanMountainDesktop.PluginSdk" Version="4.0.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -9,5 +9,6 @@ public enum SettingsPageCategory
|
|||||||
PluginCatalog = 35,
|
PluginCatalog = 35,
|
||||||
[Obsolete("Use PluginCatalog instead.")]
|
[Obsolete("Use PluginCatalog instead.")]
|
||||||
PluginMarket = 35,
|
PluginMarket = 35,
|
||||||
About = 40
|
About = 40,
|
||||||
|
Dev = 50
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,38 @@ public sealed class Plugin : PluginBase
|
|||||||
public override void Initialize(HostBuilderContext context, IServiceCollection services)
|
public override void Initialize(HostBuilderContext context, IServiceCollection services)
|
||||||
{
|
{
|
||||||
_ = context;
|
_ = context;
|
||||||
|
|
||||||
|
// ── Option 1: Declarative settings (simple key-value options) ──────────
|
||||||
|
// The host generates a settings page automatically from the declared options.
|
||||||
|
// Supported option types: Toggle, Text, Number, Select, Path, List.
|
||||||
|
//
|
||||||
|
// services.AddPluginSettingsSection(
|
||||||
|
// "my-plugin-settings",
|
||||||
|
// "My Plugin Settings",
|
||||||
|
// section => section
|
||||||
|
// .AddToggle("enable_feature", "Enable Feature", defaultValue: true)
|
||||||
|
// .AddNumber("refresh_interval", "Refresh Interval", defaultValue: 30, minimum: 5, maximum: 120),
|
||||||
|
// iconKey: "PuzzlePiece");
|
||||||
|
|
||||||
|
// ── Option 2: Custom AXAML view (full Fluent Avalonia controls) ────────
|
||||||
|
// Provide a SettingsPageBase subclass to use any Fluent Avalonia control
|
||||||
|
// (SettingsExpander, ColorPicker, Slider, etc.) — just like built-in pages.
|
||||||
|
//
|
||||||
|
// services.AddPluginSettingsSection<MyCustomSettingsPage>(
|
||||||
|
// "my-plugin-settings",
|
||||||
|
// "My Plugin Settings",
|
||||||
|
// iconKey: "PuzzlePiece");
|
||||||
|
//
|
||||||
|
// Or mix both: declare options AND set a custom view on the builder:
|
||||||
|
//
|
||||||
|
// services.AddPluginSettingsSection(
|
||||||
|
// "my-plugin-settings",
|
||||||
|
// "My Plugin Settings",
|
||||||
|
// section => section
|
||||||
|
// .SetCustomView<MyCustomSettingsPage>()
|
||||||
|
// .AddToggle("enable_feature", "Enable Feature"),
|
||||||
|
// iconKey: "PuzzlePiece");
|
||||||
|
|
||||||
_ = services;
|
_ = services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"description": "__PLUGIN_DESCRIPTION__",
|
"description": "__PLUGIN_DESCRIPTION__",
|
||||||
"author": "__PLUGIN_AUTHOR__",
|
"author": "__PLUGIN_AUTHOR__",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"apiVersion": "4.0.0",
|
"apiVersion": "4.0.1",
|
||||||
"entranceAssembly": "LanMountainDesktop.PluginTemplate.dll",
|
"entranceAssembly": "LanMountainDesktop.PluginTemplate.dll",
|
||||||
"sharedContracts": []
|
"sharedContracts": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,10 +35,11 @@ public sealed class CornerRadiusStyleTests
|
|||||||
Component: 24d),
|
Component: 24d),
|
||||||
ThemeVariant: "Light"));
|
ThemeVariant: "Light"));
|
||||||
|
|
||||||
// Preset resolution should return fixed values from tokens regardless of any legacy scale
|
// Preset resolution should return fixed values from tokens
|
||||||
Assert.Equal(20d, context.ResolveCornerRadius(PluginCornerRadiusPreset.Md), 3);
|
Assert.Equal(20d, context.ResolveCornerRadius(PluginCornerRadiusPreset.Md), 3);
|
||||||
Assert.Equal(20d, context.ResolveCornerRadius(PluginCornerRadiusPreset.Md, maximum: 15d), 3);
|
Assert.Equal(15d, context.ResolveCornerRadius(PluginCornerRadiusPreset.Md, maximum: 15d), 3);
|
||||||
Assert.Equal(20d, context.ResolveScaledCornerRadius(18d), 3);
|
// ResolveScaledCornerRadius returns baseRadius as-is when no min/max specified
|
||||||
|
Assert.Equal(18d, context.ResolveScaledCornerRadius(18d), 3);
|
||||||
Assert.Equal(24d, context.ResolveCornerRadius(PluginCornerRadiusPreset.Component), 3);
|
Assert.Equal(24d, context.ResolveCornerRadius(PluginCornerRadiusPreset.Component), 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,8 +61,12 @@ public sealed class CornerRadiusStyleTests
|
|||||||
96d,
|
96d,
|
||||||
appearanceContext);
|
appearanceContext);
|
||||||
|
|
||||||
Assert.Equal(24d, context.ResolveScaledCornerRadius(12d), 3);
|
// ResolveScaledCornerRadius returns baseRadius as-is when no min/max specified
|
||||||
Assert.Equal(24d, context.ResolveScaledCornerRadius(12d, 8d, 18d), 3);
|
Assert.Equal(12d, context.ResolveScaledCornerRadius(12d), 3);
|
||||||
|
// When min/max specified, value is clamped
|
||||||
|
Assert.Equal(12d, context.ResolveScaledCornerRadius(12d, 8d, 18d), 3);
|
||||||
|
// Component token access
|
||||||
|
Assert.Equal(24d, context.CornerRadiusTokens.Component, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class NullServiceProvider : IServiceProvider
|
private sealed class NullServiceProvider : IServiceProvider
|
||||||
|
|||||||
@@ -47,4 +47,5 @@ public static class BuiltInComponentIds
|
|||||||
public const string DesktopFileManager = "DesktopFileManager";
|
public const string DesktopFileManager = "DesktopFileManager";
|
||||||
public const string DesktopNotificationBox = "DesktopNotificationBox";
|
public const string DesktopNotificationBox = "DesktopNotificationBox";
|
||||||
public const string DesktopShortcut = "DesktopShortcut";
|
public const string DesktopShortcut = "DesktopShortcut";
|
||||||
|
public const string DesktopStickyNote = "DesktopStickyNote";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -327,6 +327,16 @@ public sealed class ComponentRegistry
|
|||||||
AllowStatusBarPlacement: false,
|
AllowStatusBarPlacement: false,
|
||||||
AllowDesktopPlacement: true,
|
AllowDesktopPlacement: true,
|
||||||
ResizeMode: DesktopComponentResizeMode.Free),
|
ResizeMode: DesktopComponentResizeMode.Free),
|
||||||
|
new DesktopComponentDefinition(
|
||||||
|
BuiltInComponentIds.DesktopStickyNote,
|
||||||
|
"Sticky Note",
|
||||||
|
"Notepad",
|
||||||
|
"Board",
|
||||||
|
MinWidthCells: 2,
|
||||||
|
MinHeightCells: 2,
|
||||||
|
AllowStatusBarPlacement: false,
|
||||||
|
AllowDesktopPlacement: true,
|
||||||
|
ResizeMode: DesktopComponentResizeMode.Free),
|
||||||
new DesktopComponentDefinition(
|
new DesktopComponentDefinition(
|
||||||
BuiltInComponentIds.DesktopBrowser,
|
BuiltInComponentIds.DesktopBrowser,
|
||||||
"Browser",
|
"Browser",
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<RollForward>LatestMajor</RollForward>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<Version>1.0.0</Version>
|
<Version>1.0.0</Version>
|
||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
<ApplicationIcon>Assets\logo_nightly.ico</ApplicationIcon>
|
<ApplicationIcon>Assets\logo_nightly.ico</ApplicationIcon>
|
||||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
|
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<!-- Keep Release defaults compatibility-first for desktop dependencies (WebView/interop/reflection). -->
|
<!-- Keep Release defaults compatibility-first for desktop dependencies (WebView/interop/reflection). -->
|
||||||
|
|||||||
@@ -698,6 +698,7 @@
|
|||||||
"component.editor.placement_label": "Placement ID",
|
"component.editor.placement_label": "Placement ID",
|
||||||
"component.editor.scope_label": "Scope",
|
"component.editor.scope_label": "Scope",
|
||||||
"component.editor.scope_instance": "Instance-scoped editor",
|
"component.editor.scope_instance": "Instance-scoped editor",
|
||||||
|
"component_category.all": "All",
|
||||||
"component_category.clock": "Clock",
|
"component_category.clock": "Clock",
|
||||||
"component_category.date": "Calendar",
|
"component_category.date": "Calendar",
|
||||||
"component_category.weather": "Weather",
|
"component_category.weather": "Weather",
|
||||||
|
|||||||
@@ -692,6 +692,7 @@
|
|||||||
"component.editor.placement_label": "实例 ID",
|
"component.editor.placement_label": "实例 ID",
|
||||||
"component.editor.scope_label": "作用域",
|
"component.editor.scope_label": "作用域",
|
||||||
"component.editor.scope_instance": "实例级编辑器",
|
"component.editor.scope_instance": "实例级编辑器",
|
||||||
|
"component_category.all": "全部",
|
||||||
"component_category.clock": "时钟",
|
"component_category.clock": "时钟",
|
||||||
"component_category.date": "日历",
|
"component_category.date": "日历",
|
||||||
"component_category.weather": "天气",
|
"component_category.weather": "天气",
|
||||||
|
|||||||
@@ -154,6 +154,10 @@ public sealed class AppSettingsSnapshot
|
|||||||
|
|
||||||
public List<string> DisabledPluginIds { get; set; } = [];
|
public List<string> DisabledPluginIds { get; set; } = [];
|
||||||
|
|
||||||
|
public bool IsDevModeEnabled { get; set; }
|
||||||
|
|
||||||
|
public string? DevPluginPath { get; set; }
|
||||||
|
|
||||||
#region Study Settings
|
#region Study Settings
|
||||||
|
|
||||||
public bool StudyEnabled { get; set; } = true;
|
public bool StudyEnabled { get; set; } = true;
|
||||||
|
|||||||
@@ -142,6 +142,12 @@ public sealed class ComponentSettingsSnapshot
|
|||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
#region Sticky Note Component Settings (便签组件设置)
|
||||||
|
|
||||||
|
public string StickyNoteContent { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
public ComponentSettingsSnapshot Clone()
|
public ComponentSettingsSnapshot Clone()
|
||||||
{
|
{
|
||||||
var clone = (ComponentSettingsSnapshot)MemberwiseClone();
|
var clone = (ComponentSettingsSnapshot)MemberwiseClone();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using Avalonia;
|
|||||||
using Avalonia.WebView.Desktop;
|
using Avalonia.WebView.Desktop;
|
||||||
using LanMountainDesktop.DesktopHost;
|
using LanMountainDesktop.DesktopHost;
|
||||||
using LanMountainDesktop.Models;
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.Plugins;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
using LanMountainDesktop.Services.Settings;
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ public sealed class Program
|
|||||||
public static void Main(string[] args)
|
public static void Main(string[] args)
|
||||||
{
|
{
|
||||||
AppLogger.Initialize();
|
AppLogger.Initialize();
|
||||||
|
DevPluginOptions.Parse(args);
|
||||||
RegisterGlobalExceptionLogging();
|
RegisterGlobalExceptionLogging();
|
||||||
var restartParentProcessId = AppRestartService.TryGetRestartParentProcessId(args);
|
var restartParentProcessId = AppRestartService.TryGetRestartParentProcessId(args);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
using LanMountainDesktop.PluginSdk;
|
using LanMountainDesktop.PluginSdk;
|
||||||
using LanMountainDesktop.Plugins;
|
using LanMountainDesktop.Plugins;
|
||||||
using LanMountainDesktop.Services.PluginMarket;
|
using LanMountainDesktop.Services.PluginMarket;
|
||||||
@@ -204,6 +205,10 @@ internal sealed class SettingsPageRegistry : ISettingsPageRegistry, IDisposable
|
|||||||
string? pluginId,
|
string? pluginId,
|
||||||
bool isBuiltIn)
|
bool isBuiltIn)
|
||||||
{
|
{
|
||||||
|
var isDevModeEnabled = _settingsFacade.Settings
|
||||||
|
.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App)
|
||||||
|
.IsDevModeEnabled;
|
||||||
|
|
||||||
foreach (var pageType in assembly.GetTypes()
|
foreach (var pageType in assembly.GetTypes()
|
||||||
.Where(type => !type.IsAbstract && typeof(SettingsPageBase).IsAssignableFrom(type)))
|
.Where(type => !type.IsAbstract && typeof(SettingsPageBase).IsAssignableFrom(type)))
|
||||||
{
|
{
|
||||||
@@ -214,6 +219,12 @@ internal sealed class SettingsPageRegistry : ISettingsPageRegistry, IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
var category = isBuiltIn ? pageInfo.Category : SettingsPageCategory.Plugins;
|
var category = isBuiltIn ? pageInfo.Category : SettingsPageCategory.Plugins;
|
||||||
|
|
||||||
|
if (category == SettingsPageCategory.Dev && !isDevModeEnabled)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
var sortOrder = isBuiltIn ? pageInfo.SortOrder : 100 + pageInfo.SortOrder;
|
var sortOrder = isBuiltIn ? pageInfo.SortOrder : 100 + pageInfo.SortOrder;
|
||||||
var title = ResolveLocalizedText(pageInfo.TitleLocalizationKey, pageInfo.Name);
|
var title = ResolveLocalizedText(pageInfo.TitleLocalizationKey, pageInfo.Name);
|
||||||
var description = ResolveLocalizedText(pageInfo.DescriptionLocalizationKey, null);
|
var description = ResolveLocalizedText(pageInfo.DescriptionLocalizationKey, null);
|
||||||
@@ -256,6 +267,29 @@ internal sealed class SettingsPageRegistry : ISettingsPageRegistry, IDisposable
|
|||||||
? null
|
? null
|
||||||
: localizer.GetString(section.DescriptionLocalizationKey, section.DescriptionLocalizationKey);
|
: localizer.GetString(section.DescriptionLocalizationKey, section.DescriptionLocalizationKey);
|
||||||
|
|
||||||
|
Func<ISettingsPageHostContext, Control> factory;
|
||||||
|
|
||||||
|
if (section.CustomViewType is not null)
|
||||||
|
{
|
||||||
|
var customViewType = section.CustomViewType;
|
||||||
|
var pluginServices = loadedPlugin.Services;
|
||||||
|
factory = hostContext => CreatePage(pluginServices, customViewType, hostContext);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
factory = hostContext =>
|
||||||
|
{
|
||||||
|
var page = new GeneratedPluginSettingsPage(
|
||||||
|
new PluginGeneratedSettingsPageViewModel(
|
||||||
|
_settingsFacade.Settings,
|
||||||
|
loadedPlugin.Manifest.Id,
|
||||||
|
section,
|
||||||
|
localizer));
|
||||||
|
page.InitializeHostContext(hostContext);
|
||||||
|
return page;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
_pages.Add(new SettingsPageDescriptor(
|
_pages.Add(new SettingsPageDescriptor(
|
||||||
pageId,
|
pageId,
|
||||||
title,
|
title,
|
||||||
@@ -270,17 +304,7 @@ internal sealed class SettingsPageRegistry : ISettingsPageRegistry, IDisposable
|
|||||||
hidePageTitle: false,
|
hidePageTitle: false,
|
||||||
useFullWidth: false,
|
useFullWidth: false,
|
||||||
groupId: null,
|
groupId: null,
|
||||||
hostContext =>
|
factory));
|
||||||
{
|
|
||||||
var page = new GeneratedPluginSettingsPage(
|
|
||||||
new PluginGeneratedSettingsPageViewModel(
|
|
||||||
_settingsFacade.Settings,
|
|
||||||
loadedPlugin.Manifest.Id,
|
|
||||||
section,
|
|
||||||
localizer));
|
|
||||||
page.InitializeHostContext(hostContext);
|
|
||||||
return page;
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3088,3 +3088,54 @@ public sealed class PluginGeneratedSettingsPageViewModel
|
|||||||
|
|
||||||
public string? Description { get; }
|
public string? Description { get; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed partial class DevSettingsPageViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly ISettingsFacadeService _settingsFacade;
|
||||||
|
private bool _isInitializing;
|
||||||
|
|
||||||
|
public DevSettingsPageViewModel(ISettingsFacadeService settingsFacade)
|
||||||
|
{
|
||||||
|
_settingsFacade = settingsFacade;
|
||||||
|
_isInitializing = true;
|
||||||
|
LoadSettings();
|
||||||
|
_isInitializing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _isDevModeEnabled;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _devPluginPath = string.Empty;
|
||||||
|
|
||||||
|
partial void OnIsDevModeEnabledChanged(bool value)
|
||||||
|
{
|
||||||
|
if (_isInitializing) return;
|
||||||
|
SaveField(nameof(AppSettingsSnapshot.IsDevModeEnabled), value);
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnDevPluginPathChanged(string value)
|
||||||
|
{
|
||||||
|
if (_isInitializing) return;
|
||||||
|
SaveField(nameof(AppSettingsSnapshot.DevPluginPath), value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadSettings()
|
||||||
|
{
|
||||||
|
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||||
|
IsDevModeEnabled = snapshot.IsDevModeEnabled;
|
||||||
|
DevPluginPath = snapshot.DevPluginPath ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SaveField<T>(string key, T value)
|
||||||
|
{
|
||||||
|
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||||
|
var property = typeof(AppSettingsSnapshot).GetProperty(key);
|
||||||
|
if (property is not null && property.CanWrite)
|
||||||
|
{
|
||||||
|
property.SetValue(snapshot, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
_settingsFacade.Settings.SaveSnapshot(SettingsScope.App, snapshot, changedKeys: [key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,11 +34,13 @@
|
|||||||
<TextBlock x:Name="WeekdayTextBlock"
|
<TextBlock x:Name="WeekdayTextBlock"
|
||||||
Text="周一"
|
Text="周一"
|
||||||
TextAlignment="Right"
|
TextAlignment="Right"
|
||||||
FontWeight="SemiBold" />
|
FontWeight="SemiBold"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
<TextBlock x:Name="ClassCountTextBlock"
|
<TextBlock x:Name="ClassCountTextBlock"
|
||||||
Text="0节课"
|
Text="0节课"
|
||||||
TextAlignment="Right"
|
TextAlignment="Right"
|
||||||
FontWeight="SemiBold" />
|
FontWeight="SemiBold"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
|||||||
@@ -928,7 +928,28 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
|||||||
MetaStack.Spacing = Math.Clamp(6 * scale, 3, 10);
|
MetaStack.Spacing = Math.Clamp(6 * scale, 3, 10);
|
||||||
CourseListPanel.Spacing = Math.Clamp(6 * scale, 3, 10);
|
CourseListPanel.Spacing = Math.Clamp(6 * scale, 3, 10);
|
||||||
|
|
||||||
var dateFont = Math.Clamp(66 * scale, 26, 82);
|
var dateFontByScale = Math.Clamp(66 * scale, 26, 82);
|
||||||
|
var weekdayFontByScale = Math.Clamp(34 * scale, 13, 32);
|
||||||
|
var classCountFontByScale = Math.Clamp(40 * scale, 14, 36);
|
||||||
|
|
||||||
|
// 宽度感知:当头部内容总需求超过可用宽度时,按比例缩小日期字体
|
||||||
|
var availableWidth = Math.Max(1, Bounds.Width - rootPadding.Left - rootPadding.Right);
|
||||||
|
var dateGroupEstimatedWidth = dateFontByScale * 0.6 * 3 + DateGroup.Spacing * 2;
|
||||||
|
var metaStackEstimatedWidth = classCountFontByScale * 0.6 * 4 + MetaStack.Spacing;
|
||||||
|
var headerColumnSpacing = Math.Clamp(10 * scale, 4, 16);
|
||||||
|
var totalHeaderNeed = dateGroupEstimatedWidth + headerColumnSpacing + metaStackEstimatedWidth;
|
||||||
|
|
||||||
|
var dateFont = dateFontByScale;
|
||||||
|
if (totalHeaderNeed > availableWidth)
|
||||||
|
{
|
||||||
|
var shrinkRatio = availableWidth / totalHeaderNeed;
|
||||||
|
dateFont = Math.Max(20, dateFontByScale * shrinkRatio);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为 HeaderGrid 左列设置最小宽度,防止被压缩至零
|
||||||
|
var minDateColumnWidth = dateFont * 0.6 * 3 + DateGroup.Spacing * 2;
|
||||||
|
HeaderGrid.ColumnDefinitions[0].MinWidth = minDateColumnWidth;
|
||||||
|
|
||||||
MonthTextBlock.FontSize = dateFont;
|
MonthTextBlock.FontSize = dateFont;
|
||||||
DayTextBlock.FontSize = dateFont;
|
DayTextBlock.FontSize = dateFont;
|
||||||
SlashTextBlock.FontSize = dateFont;
|
SlashTextBlock.FontSize = dateFont;
|
||||||
@@ -940,8 +961,8 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
|||||||
ClassCountTextBlock.Foreground = CreateBrush(_isNightVisual ? "#8D95A4" : "#738095");
|
ClassCountTextBlock.Foreground = CreateBrush(_isNightVisual ? "#8D95A4" : "#738095");
|
||||||
StatusTextBlock.Foreground = CreateBrush(_isNightVisual ? "#9AA2B1" : "#4B5565");
|
StatusTextBlock.Foreground = CreateBrush(_isNightVisual ? "#9AA2B1" : "#4B5565");
|
||||||
|
|
||||||
WeekdayTextBlock.FontSize = Math.Clamp(34 * scale, 13, 32);
|
WeekdayTextBlock.FontSize = weekdayFontByScale;
|
||||||
ClassCountTextBlock.FontSize = Math.Clamp(40 * scale, 14, 36);
|
ClassCountTextBlock.FontSize = classCountFontByScale;
|
||||||
StatusTextBlock.FontSize = Math.Clamp(30 * scale, 12, 30);
|
StatusTextBlock.FontSize = Math.Clamp(30 * scale, 12, 30);
|
||||||
|
|
||||||
WeekdayTextBlock.FontWeight = ToVariableWeight(Lerp(560, 700, Math.Clamp((scale - 0.60) / 1.2, 0, 1)));
|
WeekdayTextBlock.FontWeight = ToVariableWeight(Lerp(560, 700, Math.Clamp((scale - 0.60) / 1.2, 0, 1)));
|
||||||
|
|||||||
@@ -704,6 +704,24 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
|||||||
ExtraNewsItemsPanel.Spacing = Math.Clamp(6 * scale, 3, 10);
|
ExtraNewsItemsPanel.Spacing = Math.Clamp(6 * scale, 3, 10);
|
||||||
|
|
||||||
ApplyNightModeVisual();
|
ApplyNightModeVisual();
|
||||||
|
|
||||||
|
var headerHeight = refreshHeight;
|
||||||
|
var newsItemHeight = Math.Max(imageHeight, mainNewsMinHeight);
|
||||||
|
|
||||||
|
var requiredHeight = verticalPadding * 2
|
||||||
|
+ headerHeight
|
||||||
|
+ rowSpacing
|
||||||
|
+ newsItemHeight
|
||||||
|
+ rowSpacing
|
||||||
|
+ newsItemHeight;
|
||||||
|
|
||||||
|
if (_extraNewsRows.Count > 0)
|
||||||
|
{
|
||||||
|
var extraSpacing = ExtraNewsItemsPanel.Spacing * (_extraNewsRows.Count - 1);
|
||||||
|
requiredHeight += rowSpacing + extraSpacing + _extraNewsRows.Count * newsItemHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.MinHeight = requiredHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateRefreshButtonState()
|
private void UpdateRefreshButtonState()
|
||||||
@@ -842,6 +860,11 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
|||||||
oldBitmap?.Dispose();
|
oldBitmap?.Dispose();
|
||||||
_newsBitmaps[index] = bitmap;
|
_newsBitmaps[index] = bitmap;
|
||||||
imageControl.Source = bitmap;
|
imageControl.Source = bitmap;
|
||||||
|
|
||||||
|
if (bitmap != null)
|
||||||
|
{
|
||||||
|
InvalidateMeasure();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DisposeNewsBitmaps()
|
private void DisposeNewsBitmaps()
|
||||||
|
|||||||
@@ -452,6 +452,10 @@ public sealed class DesktopComponentRuntimeRegistry
|
|||||||
BuiltInComponentIds.DesktopBlackboardLandscape,
|
BuiltInComponentIds.DesktopBlackboardLandscape,
|
||||||
"component.blackboard_landscape",
|
"component.blackboard_landscape",
|
||||||
() => new WhiteboardWidget(baseWidthCells: 4)),
|
() => new WhiteboardWidget(baseWidthCells: 4)),
|
||||||
|
new DesktopComponentRuntimeRegistration(
|
||||||
|
BuiltInComponentIds.DesktopStickyNote,
|
||||||
|
"component.sticky_note",
|
||||||
|
() => new StickyNoteWidget()),
|
||||||
new DesktopComponentRuntimeRegistration(
|
new DesktopComponentRuntimeRegistration(
|
||||||
BuiltInComponentIds.DesktopBrowser,
|
BuiltInComponentIds.DesktopBrowser,
|
||||||
"component.browser",
|
"component.browser",
|
||||||
|
|||||||
51
LanMountainDesktop/Views/Components/StickyNoteWidget.axaml
Normal file
51
LanMountainDesktop/Views/Components/StickyNoteWidget.axaml
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<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"
|
||||||
|
xmlns:md="clr-namespace:Markdown.Avalonia;assembly=Markdown.Avalonia"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
d:DesignWidth="200"
|
||||||
|
d:DesignHeight="200"
|
||||||
|
x:Class="LanMountainDesktop.Views.Components.StickyNoteWidget">
|
||||||
|
|
||||||
|
<Border x:Name="RootBorder"
|
||||||
|
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||||
|
BorderBrush="#E0C878"
|
||||||
|
BorderThickness="1">
|
||||||
|
<Grid>
|
||||||
|
<md:MarkdownScrollViewer x:Name="MarkdownViewer"
|
||||||
|
Margin="14,14,14,10"
|
||||||
|
IsVisible="True" />
|
||||||
|
|
||||||
|
<TextBox x:Name="NoteTextBox"
|
||||||
|
AcceptsReturn="True"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
IsVisible="False"
|
||||||
|
Background="Transparent"
|
||||||
|
BorderThickness="0"
|
||||||
|
Margin="14,14,14,10"
|
||||||
|
FontFamily="Consolas,Cascadia Code,Courier New,monospace"
|
||||||
|
Foreground="#5D4E37" />
|
||||||
|
|
||||||
|
<Button x:Name="ToggleButton"
|
||||||
|
Width="28"
|
||||||
|
Height="28"
|
||||||
|
CornerRadius="14"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
VerticalAlignment="Top"
|
||||||
|
Margin="4,4,4,0"
|
||||||
|
Padding="0"
|
||||||
|
Background="#00000010"
|
||||||
|
BorderThickness="0"
|
||||||
|
Click="OnToggleButtonClick">
|
||||||
|
<fi:SymbolIcon x:Name="ToggleIcon"
|
||||||
|
Symbol="Edit"
|
||||||
|
IconVariant="Regular"
|
||||||
|
FontSize="13"
|
||||||
|
Foreground="#8B7D5A" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</UserControl>
|
||||||
371
LanMountainDesktop/Views/Components/StickyNoteWidget.axaml.cs
Normal file
371
LanMountainDesktop/Views/Components/StickyNoteWidget.axaml.cs
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Styling;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using FluentIcons.Common;
|
||||||
|
using LanMountainDesktop.ComponentSystem;
|
||||||
|
using LanMountainDesktop.Host.Abstractions;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views.Components;
|
||||||
|
|
||||||
|
public partial class StickyNoteWidget : UserControl,
|
||||||
|
IDesktopComponentWidget,
|
||||||
|
IComponentPlacementContextAware,
|
||||||
|
IComponentSettingsContextAware,
|
||||||
|
IDesktopPageVisibilityAwareComponentWidget,
|
||||||
|
IDisposable
|
||||||
|
{
|
||||||
|
private static readonly Color LightNoteYellow = Color.FromRgb(0xFF, 0xF9, 0xC4);
|
||||||
|
private static readonly Color LightNoteBorder = Color.FromRgb(0xE0, 0xC8, 0x78);
|
||||||
|
private static readonly Color LightNoteForeground = Color.FromRgb(0x5D, 0x4E, 0x37);
|
||||||
|
private static readonly Color LightNoteHint = Color.FromRgb(0x8B, 0x7D, 0x5A);
|
||||||
|
|
||||||
|
private static readonly Color DarkNoteYellow = Color.FromRgb(0x5D, 0x52, 0x29);
|
||||||
|
private static readonly Color DarkNoteBorder = Color.FromRgb(0x7A, 0x6D, 0x3A);
|
||||||
|
private static readonly Color DarkNoteForeground = Color.FromRgb(0xE8, 0xE0, 0xC8);
|
||||||
|
private static readonly Color DarkNoteHint = Color.FromRgb(0xA0, 0x96, 0x70);
|
||||||
|
|
||||||
|
private string _componentId = BuiltInComponentIds.DesktopStickyNote;
|
||||||
|
private string _placementId = string.Empty;
|
||||||
|
private IComponentSettingsAccessor? _settingsAccessor;
|
||||||
|
private string _markdownContent = string.Empty;
|
||||||
|
private bool _isEditing;
|
||||||
|
private bool _isDirty;
|
||||||
|
private bool _isOnActivePage = true;
|
||||||
|
private bool _isEditMode;
|
||||||
|
private bool _disposed;
|
||||||
|
private bool _isApplyingPersistedContent;
|
||||||
|
|
||||||
|
private readonly DispatcherTimer _autoSaveTimer = new()
|
||||||
|
{
|
||||||
|
Interval = TimeSpan.FromSeconds(30)
|
||||||
|
};
|
||||||
|
|
||||||
|
private CancellationTokenSource? _renderDebounceCts;
|
||||||
|
|
||||||
|
public StickyNoteWidget()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
_autoSaveTimer.Tick += OnAutoSaveTimerTick;
|
||||||
|
NoteTextBox.TextChanged += OnNoteTextBoxTextChanged;
|
||||||
|
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||||
|
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||||
|
|
||||||
|
ApplyNoteColors();
|
||||||
|
UpdateDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ApplyCellSize(double cellSize)
|
||||||
|
{
|
||||||
|
var scale = Math.Clamp(cellSize / 48d, 0.82, 2.2);
|
||||||
|
|
||||||
|
RootBorder.CornerRadius = new CornerRadius(
|
||||||
|
ComponentChromeCornerRadiusHelper.ResolveMainRectangleRadiusValue(
|
||||||
|
new ComponentChromeContext(
|
||||||
|
_componentId,
|
||||||
|
_placementId,
|
||||||
|
Math.Max(1, cellSize),
|
||||||
|
Appearance.AppearanceCornerRadiusTokenFactory.Create(
|
||||||
|
Settings.Core.GlobalAppearanceSettings.DefaultCornerRadiusStyle))));
|
||||||
|
|
||||||
|
RootBorder.Padding = new Thickness(
|
||||||
|
Math.Clamp(2 * scale, 1, 4),
|
||||||
|
Math.Clamp(2 * scale, 1, 4));
|
||||||
|
|
||||||
|
var contentMargin = Math.Clamp(12 * scale, 6, 20);
|
||||||
|
MarkdownViewer.Margin = new Thickness(contentMargin, contentMargin, contentMargin, contentMargin - 2);
|
||||||
|
NoteTextBox.Margin = new Thickness(contentMargin, contentMargin, contentMargin, contentMargin - 2);
|
||||||
|
NoteTextBox.FontSize = Math.Clamp(13 * scale, 10, 22);
|
||||||
|
|
||||||
|
var buttonSize = Math.Clamp(28 * scale, 22, 40);
|
||||||
|
ToggleButton.Width = buttonSize;
|
||||||
|
ToggleButton.Height = buttonSize;
|
||||||
|
ToggleButton.CornerRadius = new CornerRadius(buttonSize / 2d);
|
||||||
|
ToggleButton.Margin = new Thickness(Math.Clamp(4 * scale, 2, 8), Math.Clamp(4 * scale, 2, 8), Math.Clamp(4 * scale, 2, 8), 0);
|
||||||
|
ToggleIcon.FontSize = Math.Clamp(13 * scale, 10, 18);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||||
|
{
|
||||||
|
if (_isDirty && !string.IsNullOrWhiteSpace(_placementId))
|
||||||
|
{
|
||||||
|
PersistNoteImmediately();
|
||||||
|
}
|
||||||
|
|
||||||
|
_componentId = string.IsNullOrWhiteSpace(componentId)
|
||||||
|
? BuiltInComponentIds.DesktopStickyNote
|
||||||
|
: componentId.Trim();
|
||||||
|
_placementId = placementId?.Trim() ?? string.Empty;
|
||||||
|
|
||||||
|
if (_isEditing)
|
||||||
|
{
|
||||||
|
ExitEditMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
LoadPersistedContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetComponentSettingsContext(DesktopComponentSettingsContext context)
|
||||||
|
{
|
||||||
|
_settingsAccessor = context.ComponentSettingsAccessor;
|
||||||
|
LoadPersistedContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
||||||
|
{
|
||||||
|
_isOnActivePage = isOnActivePage;
|
||||||
|
_isEditMode = isEditMode;
|
||||||
|
|
||||||
|
ToggleButton.IsHitTestVisible = !isEditMode;
|
||||||
|
NoteTextBox.IsReadOnly = isEditMode;
|
||||||
|
|
||||||
|
if (isEditMode && _isEditing)
|
||||||
|
{
|
||||||
|
ExitEditMode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnToggleButtonClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_isEditing)
|
||||||
|
{
|
||||||
|
ExitEditMode();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
EnterEditMode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnterEditMode()
|
||||||
|
{
|
||||||
|
_isEditing = true;
|
||||||
|
|
||||||
|
NoteTextBox.Text = _markdownContent;
|
||||||
|
MarkdownViewer.IsVisible = false;
|
||||||
|
NoteTextBox.IsVisible = true;
|
||||||
|
ToggleIcon.Symbol = Symbol.Checkmark;
|
||||||
|
|
||||||
|
Dispatcher.UIThread.Post(() => NoteTextBox.Focus(), DispatcherPriority.Input);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ExitEditMode()
|
||||||
|
{
|
||||||
|
_isEditing = false;
|
||||||
|
|
||||||
|
var editedContent = NoteTextBox.Text ?? string.Empty;
|
||||||
|
if (editedContent != _markdownContent)
|
||||||
|
{
|
||||||
|
_markdownContent = editedContent;
|
||||||
|
_isDirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
NoteTextBox.IsVisible = false;
|
||||||
|
MarkdownViewer.IsVisible = true;
|
||||||
|
ToggleIcon.Symbol = Symbol.Edit;
|
||||||
|
|
||||||
|
UpdateDisplay();
|
||||||
|
|
||||||
|
if (_isDirty)
|
||||||
|
{
|
||||||
|
PersistNoteImmediately();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnNoteTextBoxTextChanged(object? sender, TextChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_isApplyingPersistedContent || !_isEditing)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isDirty = true;
|
||||||
|
|
||||||
|
if (!_autoSaveTimer.IsEnabled)
|
||||||
|
{
|
||||||
|
_autoSaveTimer.Start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAutoSaveTimerTick(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
_autoSaveTimer.Stop();
|
||||||
|
|
||||||
|
if (_isDirty && _isEditing)
|
||||||
|
{
|
||||||
|
_markdownContent = NoteTextBox.Text ?? string.Empty;
|
||||||
|
PersistNoteImmediately();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateDisplay()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_markdownContent))
|
||||||
|
{
|
||||||
|
MarkdownViewer.Markdown = "*Click ✏️ to write a note...*";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderDebounceCts?.Cancel();
|
||||||
|
_renderDebounceCts?.Dispose();
|
||||||
|
_renderDebounceCts = new CancellationTokenSource();
|
||||||
|
var token = _renderDebounceCts.Token;
|
||||||
|
|
||||||
|
Dispatcher.UIThread.Post(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(150, token);
|
||||||
|
if (!token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
MarkdownViewer.Markdown = _markdownContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MarkdownViewer.Markdown = $"*Error: {ex.Message}*";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadPersistedContent()
|
||||||
|
{
|
||||||
|
if (_settingsAccessor is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var snapshot = _settingsAccessor.LoadSnapshot<ComponentSettingsSnapshot>();
|
||||||
|
_isApplyingPersistedContent = true;
|
||||||
|
_markdownContent = snapshot.StickyNoteContent ?? string.Empty;
|
||||||
|
_isDirty = false;
|
||||||
|
UpdateDisplay();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
_markdownContent = string.Empty;
|
||||||
|
UpdateDisplay();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isApplyingPersistedContent = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PersistNoteImmediately()
|
||||||
|
{
|
||||||
|
if (_settingsAccessor is null || _disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var snapshot = _settingsAccessor.LoadSnapshot<ComponentSettingsSnapshot>();
|
||||||
|
snapshot.StickyNoteContent = _markdownContent;
|
||||||
|
_settingsAccessor.SaveSnapshot(snapshot,
|
||||||
|
[nameof(ComponentSettingsSnapshot.StickyNoteContent)]);
|
||||||
|
_isDirty = false;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyNoteColors()
|
||||||
|
{
|
||||||
|
var isDark = Application.Current?.ActualThemeVariant == ThemeVariant.Dark;
|
||||||
|
|
||||||
|
if (isDark)
|
||||||
|
{
|
||||||
|
RootBorder.Background = new SolidColorBrush(DarkNoteYellow);
|
||||||
|
RootBorder.BorderBrush = new SolidColorBrush(DarkNoteBorder);
|
||||||
|
NoteTextBox.Foreground = new SolidColorBrush(DarkNoteForeground);
|
||||||
|
ToggleIcon.Foreground = new SolidColorBrush(DarkNoteHint);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
RootBorder.Background = new SolidColorBrush(LightNoteYellow);
|
||||||
|
RootBorder.BorderBrush = new SolidColorBrush(LightNoteBorder);
|
||||||
|
NoteTextBox.Foreground = new SolidColorBrush(LightNoteForeground);
|
||||||
|
ToggleIcon.Foreground = new SolidColorBrush(LightNoteHint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
|
{
|
||||||
|
Application.Current!.ActualThemeVariantChanged += OnThemeVariantChanged;
|
||||||
|
ApplyNoteColors();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
|
{
|
||||||
|
Application.Current!.ActualThemeVariantChanged -= OnThemeVariantChanged;
|
||||||
|
|
||||||
|
if (_isDirty)
|
||||||
|
{
|
||||||
|
if (_isEditing)
|
||||||
|
{
|
||||||
|
_markdownContent = NoteTextBox.Text ?? string.Empty;
|
||||||
|
}
|
||||||
|
PersistNoteImmediately();
|
||||||
|
}
|
||||||
|
|
||||||
|
_autoSaveTimer.Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnThemeVariantChanged(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(ApplyNoteColors);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ForceSave()
|
||||||
|
{
|
||||||
|
if (_isEditing)
|
||||||
|
{
|
||||||
|
_markdownContent = NoteTextBox.Text ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isDirty || _isEditing)
|
||||||
|
{
|
||||||
|
PersistNoteImmediately();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
_autoSaveTimer.Stop();
|
||||||
|
_renderDebounceCts?.Cancel();
|
||||||
|
_renderDebounceCts?.Dispose();
|
||||||
|
|
||||||
|
if (_isDirty)
|
||||||
|
{
|
||||||
|
if (_isEditing)
|
||||||
|
{
|
||||||
|
_markdownContent = NoteTextBox.Text ?? string.Empty;
|
||||||
|
}
|
||||||
|
PersistNoteImmediately();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,13 +40,12 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
private WhiteboardToolMode _toolMode = WhiteboardToolMode.Pen;
|
private WhiteboardToolMode _toolMode = WhiteboardToolMode.Pen;
|
||||||
private bool? _isNightModeApplied;
|
private bool? _isNightModeApplied;
|
||||||
private SKColor _selectedInkColor = SKColors.Black;
|
private SKColor _selectedInkColor = SKColors.Black;
|
||||||
|
private bool _isUserCustomColor;
|
||||||
private float _selectedInkThickness = 2.5f;
|
private float _selectedInkThickness = 2.5f;
|
||||||
private string _componentId = BuiltInComponentIds.DesktopWhiteboard;
|
private string _componentId = BuiltInComponentIds.DesktopWhiteboard;
|
||||||
private string _placementId = string.Empty;
|
private string _placementId = string.Empty;
|
||||||
private int _noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays;
|
private int _noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays;
|
||||||
private bool _isApplyingPersistedSnapshot;
|
private bool _isApplyingPersistedSnapshot;
|
||||||
private bool? _lastBitmapCacheEnabled;
|
|
||||||
private int _lastBitmapCacheSize;
|
|
||||||
private bool _noteDirty;
|
private bool _noteDirty;
|
||||||
private int _noteLoadRevision;
|
private int _noteLoadRevision;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
@@ -121,10 +120,11 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
settings.IgnorePressure = true;
|
settings.IgnorePressure = true;
|
||||||
settings.InkThickness = _selectedInkThickness;
|
settings.InkThickness = _selectedInkThickness;
|
||||||
settings.EraserSize = new Size(20, 20);
|
settings.EraserSize = new Size(20, 20);
|
||||||
|
settings.IsBitmapCacheEnabled = true;
|
||||||
|
settings.MaxBitmapCacheSize = 2048;
|
||||||
InkCanvas.StrokeCollected += OnInkCanvasStrokeCollected;
|
InkCanvas.StrokeCollected += OnInkCanvasStrokeCollected;
|
||||||
InkCanvas.PointerReleased += OnInkCanvasPointerReleased;
|
InkCanvas.PointerReleased += OnInkCanvasPointerReleased;
|
||||||
InkCanvas.PointerCaptureLost += OnInkCanvasPointerCaptureLost;
|
InkCanvas.PointerCaptureLost += OnInkCanvasPointerCaptureLost;
|
||||||
UpdateInkCanvasCacheSettings(forceRefresh: true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ApplyCellSize(double cellSize)
|
public void ApplyCellSize(double cellSize)
|
||||||
@@ -158,7 +158,6 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
|
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
|
||||||
var eraserSize = Math.Clamp(_currentCellSize * 0.42, 12, 44);
|
var eraserSize = Math.Clamp(_currentCellSize * 0.42, 12, 44);
|
||||||
settings.EraserSize = new Size(eraserSize, eraserSize);
|
settings.EraserSize = new Size(eraserSize, eraserSize);
|
||||||
UpdateInkCanvasCacheSettings(forceRefresh: false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplyThemeVisual(bool force)
|
private void ApplyThemeVisual(bool force)
|
||||||
@@ -169,6 +168,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var wasNightMode = _isNightModeApplied;
|
||||||
_isNightModeApplied = isNightMode;
|
_isNightModeApplied = isNightMode;
|
||||||
|
|
||||||
RootBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#FF181B22") : Color.Parse("#FFF1F4F9"));
|
RootBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#FF181B22") : Color.Parse("#FFF1F4F9"));
|
||||||
@@ -177,9 +177,39 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
ToolbarBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#1AFFFFFF") : Color.Parse("#E6FFFFFF"));
|
ToolbarBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#1AFFFFFF") : Color.Parse("#E6FFFFFF"));
|
||||||
ToolbarBorder.BorderBrush = new SolidColorBrush(isNightMode ? Color.Parse("#26FFFFFF") : Color.Parse("#16000000"));
|
ToolbarBorder.BorderBrush = new SolidColorBrush(isNightMode ? Color.Parse("#26FFFFFF") : Color.Parse("#16000000"));
|
||||||
|
|
||||||
|
ApplyThemeDefaultInkColor(isNightMode, wasNightMode);
|
||||||
RefreshToolButtonVisuals();
|
RefreshToolButtonVisuals();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ApplyThemeDefaultInkColor(bool isNightMode, bool? wasNightMode)
|
||||||
|
{
|
||||||
|
if (_isUserCustomColor || wasNightMode == isNightMode)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var oldDefault = wasNightMode == true ? SKColors.White : SKColors.Black;
|
||||||
|
var newDefault = isNightMode ? SKColors.White : SKColors.Black;
|
||||||
|
|
||||||
|
if (_selectedInkColor == oldDefault)
|
||||||
|
{
|
||||||
|
_selectedInkColor = newDefault;
|
||||||
|
if (_toolMode == WhiteboardToolMode.Pen)
|
||||||
|
{
|
||||||
|
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _selectedInkColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (InkColorPicker is not null)
|
||||||
|
{
|
||||||
|
InkColorPicker.Color = new Color(
|
||||||
|
_selectedInkColor.Alpha,
|
||||||
|
_selectedInkColor.Red,
|
||||||
|
_selectedInkColor.Green,
|
||||||
|
_selectedInkColor.Blue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void SetComponentPlacementContext(string componentId, string? placementId)
|
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||||
{
|
{
|
||||||
var nextComponentId = string.IsNullOrWhiteSpace(componentId)
|
var nextComponentId = string.IsNullOrWhiteSpace(componentId)
|
||||||
@@ -431,7 +461,9 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
private void OnColorPickerColorChanged(object? sender, ColorChangedEventArgs e)
|
private void OnColorPickerColorChanged(object? sender, ColorChangedEventArgs e)
|
||||||
{
|
{
|
||||||
var color = e.NewColor;
|
var color = e.NewColor;
|
||||||
SetInkColor(new SKColor(color.R, color.G, color.B, color.A));
|
var skColor = new SKColor(color.R, color.G, color.B, color.A);
|
||||||
|
_isUserCustomColor = skColor != SKColors.Black && skColor != SKColors.White;
|
||||||
|
SetInkColor(skColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnInkThicknessSliderValueChanged(object? sender, RangeBaseValueChangedEventArgs e)
|
private void OnInkThicknessSliderValueChanged(object? sender, RangeBaseValueChangedEventArgs e)
|
||||||
@@ -713,7 +745,8 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
InkCanvas.AvaloniaSkiaInkCanvas.AddStaticStroke(staticStroke);
|
InkCanvas.AvaloniaSkiaInkCanvas.AddStaticStroke(staticStroke);
|
||||||
}
|
}
|
||||||
|
|
||||||
UpdateInkCanvasCacheSettings(forceRefresh: true);
|
InkCanvas.AvaloniaSkiaInkCanvas.UpdateBitmapCache();
|
||||||
|
InkCanvas.InvalidateVisual();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static InkStylusPoint ConvertStylusPoint(WhiteboardStylusPointSnapshot point)
|
private static InkStylusPoint ConvertStylusPoint(WhiteboardStylusPointSnapshot point)
|
||||||
@@ -766,7 +799,9 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
UpdateInkCanvasCacheSettings(forceRefresh: true);
|
InkCanvas.AvaloniaSkiaInkCanvas.UseBitmapCache(false);
|
||||||
|
InkCanvas.AvaloniaSkiaInkCanvas.InvalidateBitmapCache();
|
||||||
|
InkCanvas.InvalidateVisual();
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool HasValidPersistenceContext()
|
private bool HasValidPersistenceContext()
|
||||||
@@ -784,47 +819,4 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
|
|
||||||
return Array.Empty<InkStylusPoint>();
|
return Array.Empty<InkStylusPoint>();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateInkCanvasCacheSettings(bool forceRefresh)
|
|
||||||
{
|
|
||||||
var renderScaling = TopLevel.GetTopLevel(this)?.RenderScaling ?? 1d;
|
|
||||||
var widthPx = Math.Max(1d, CanvasBorder.Bounds.Width * renderScaling);
|
|
||||||
var heightPx = Math.Max(1d, CanvasBorder.Bounds.Height * renderScaling);
|
|
||||||
var longestSide = Math.Max(widthPx, heightPx);
|
|
||||||
var area = widthPx * heightPx;
|
|
||||||
|
|
||||||
var cacheEnabled = longestSide <= 1536d && area <= 1_400_000d;
|
|
||||||
var cacheSize = (int)Math.Clamp(Math.Ceiling(longestSide), 384d, 1536d);
|
|
||||||
if (!forceRefresh &&
|
|
||||||
_lastBitmapCacheEnabled == cacheEnabled &&
|
|
||||||
_lastBitmapCacheSize == cacheSize)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_lastBitmapCacheEnabled = cacheEnabled;
|
|
||||||
_lastBitmapCacheSize = cacheSize;
|
|
||||||
|
|
||||||
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
|
|
||||||
settings.IsBitmapCacheEnabled = cacheEnabled;
|
|
||||||
settings.MaxBitmapCacheSize = cacheSize;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
InkCanvas.AvaloniaSkiaInkCanvas.UseBitmapCache(cacheEnabled);
|
|
||||||
if (cacheEnabled)
|
|
||||||
{
|
|
||||||
InkCanvas.AvaloniaSkiaInkCanvas.UpdateBitmapCache();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
InkCanvas.AvaloniaSkiaInkCanvas.InvalidateBitmapCache();
|
|
||||||
InkCanvas.InvalidateVisual();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Keep drawing available even if the underlying cache backend rejects the cache update.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,194 +1,217 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
||||||
xmlns:fi="using:FluentIcons.Avalonia"
|
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||||
|
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
|
||||||
xmlns:converters="using:Avalonia.Data.Converters"
|
xmlns:converters="using:Avalonia.Data.Converters"
|
||||||
x:Class="LanMountainDesktop.Views.FusedDesktopComponentLibraryControl"
|
x:Class="LanMountainDesktop.Views.FusedDesktopComponentLibraryControl"
|
||||||
x:DataType="vm:ComponentLibraryWindowViewModel">
|
x:DataType="vm:ComponentLibraryWindowViewModel">
|
||||||
|
|
||||||
<UserControl.Styles>
|
<UserControl.Styles>
|
||||||
<!-- 分类列表项样式 -->
|
<!-- 分类列表项样式 - 遵循 Fluent NavigationView 风格 -->
|
||||||
<Style Selector="ListBoxItem.category-item">
|
<Style Selector="ListBoxItem.category-item">
|
||||||
<Setter Property="Padding" Value="0"/>
|
<Setter Property="Padding" Value="0"/>
|
||||||
<Setter Property="Margin" Value="0,0,0,4"/>
|
<Setter Property="Margin" Value="0,2"/>
|
||||||
<Setter Property="Background" Value="Transparent"/>
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}"/>
|
||||||
|
<Setter Property="Transitions">
|
||||||
|
<Transitions>
|
||||||
|
<BrushTransition Property="Background" Duration="0:0:0.083" Easing="0.05,0.75,0.10,1.00"/>
|
||||||
|
</Transitions>
|
||||||
|
</Setter>
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="ListBoxItem.category-item:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
<Style Selector="ListBoxItem.category-item:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
<Setter Property="Background" Value="Transparent"/>
|
<Setter Property="Background" Value="{DynamicResource SubtleFillColorSecondaryBrush}"/>
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="ListBoxItem.category-item:selected /template/ ContentPresenter#PART_ContentPresenter">
|
<Style Selector="ListBoxItem.category-item:selected /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
<Setter Property="Background" Value="Transparent"/>
|
<Setter Property="Background" Value="{DynamicResource AdaptiveNavItemSelectedBackgroundBrush}"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ListBoxItem.category-item:pressed /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource SubtleFillColorTertiaryBrush}"/>
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<!-- 分类项内容容器 - 默认状态 -->
|
<!-- 分类项图标和文字 -->
|
||||||
<Style Selector="Border.category-content">
|
<Style Selector="ListBoxItem.category-item fi|FluentIcon.category-icon">
|
||||||
<Setter Property="Background" Value="{DynamicResource AdaptiveNavItemBackgroundBrush}"/>
|
|
||||||
<Setter Property="Padding" Value="12,10"/>
|
|
||||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}"/>
|
|
||||||
</Style>
|
|
||||||
<Style Selector="ListBoxItem.category-item:selected Border.category-content">
|
|
||||||
<Setter Property="Background" Value="{DynamicResource AdaptiveAccentBrush}"/>
|
|
||||||
</Style>
|
|
||||||
<Style Selector="ListBoxItem.category-item:pointerover Border.category-content">
|
|
||||||
<Setter Property="Opacity" Value="0.9"/>
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<!-- 分类项图标和文字 - 默认状态 -->
|
|
||||||
<Style Selector="ListBoxItem.category-item fi|SymbolIcon.category-icon">
|
|
||||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}"/>
|
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}"/>
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="ListBoxItem.category-item:selected fi|SymbolIcon.category-icon">
|
<Style Selector="ListBoxItem.category-item:selected fi|FluentIcon.category-icon">
|
||||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveOnAccentBrush}"/>
|
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}"/>
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="ListBoxItem.category-item TextBlock.category-text">
|
<Style Selector="ListBoxItem.category-item TextBlock.category-text">
|
||||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}"/>
|
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}"/>
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="ListBoxItem.category-item:selected TextBlock.category-text">
|
<Style Selector="ListBoxItem.category-item:selected TextBlock.category-text">
|
||||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveOnAccentBrush}"/>
|
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}"/>
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||||
</Style>
|
</Style>
|
||||||
</UserControl.Styles>
|
</UserControl.Styles>
|
||||||
|
|
||||||
<Grid ColumnDefinitions="280,*"
|
<Grid ColumnDefinitions="Auto,*"
|
||||||
ColumnSpacing="16"
|
ColumnSpacing="0"
|
||||||
Margin="0">
|
Margin="0">
|
||||||
<!-- 分类列表 (左侧) -->
|
<!-- 左侧导航列 - 分类列表 + 底部"查找更多组件" -->
|
||||||
<Border Classes="surface-translucent-panel"
|
<Border Width="280"
|
||||||
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
|
Background="Transparent">
|
||||||
Padding="12">
|
<Grid RowDefinitions="*,Auto">
|
||||||
<ListBox x:Name="CategoryListBox"
|
<!-- 分类列表 -->
|
||||||
Background="Transparent"
|
<ListBox x:Name="CategoryListBox"
|
||||||
BorderThickness="0"
|
Grid.Row="0"
|
||||||
SelectionChanged="OnCategorySelectionChanged"
|
Background="Transparent"
|
||||||
ItemsSource="{Binding Categories}">
|
BorderThickness="0"
|
||||||
<ListBox.ItemTemplate>
|
Margin="8,8,4,0"
|
||||||
<DataTemplate x:DataType="vm:ComponentLibraryCategoryViewModel">
|
SelectionChanged="OnCategorySelectionChanged"
|
||||||
<Border Classes="category-content">
|
ItemsSource="{Binding Categories}">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:ComponentLibraryCategoryViewModel">
|
||||||
<Grid ColumnDefinitions="Auto,*"
|
<Grid ColumnDefinitions="Auto,*"
|
||||||
ColumnSpacing="12">
|
ColumnSpacing="12"
|
||||||
<fi:SymbolIcon Symbol="{Binding Icon}"
|
Margin="12,10">
|
||||||
|
<fi:FluentIcon Icon="{Binding Icon}"
|
||||||
IconVariant="Regular"
|
IconVariant="Regular"
|
||||||
FontSize="20"
|
FontSize="18"
|
||||||
Classes="category-icon"/>
|
Classes="category-icon"/>
|
||||||
<TextBlock Grid.Column="1"
|
<TextBlock Grid.Column="1"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
FontSize="14"
|
FontSize="14"
|
||||||
FontWeight="SemiBold"
|
|
||||||
Classes="category-text"
|
Classes="category-text"
|
||||||
Text="{Binding Title}"/>
|
Text="{Binding Title}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</DataTemplate>
|
||||||
</DataTemplate>
|
</ListBox.ItemTemplate>
|
||||||
</ListBox.ItemTemplate>
|
</ListBox>
|
||||||
</ListBox>
|
|
||||||
|
<!-- 底部"查找更多组件" - 在左侧导航列底部 -->
|
||||||
|
<StackPanel Grid.Row="1"
|
||||||
|
Margin="12,8,8,12">
|
||||||
|
<Border Height="1"
|
||||||
|
Background="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
||||||
|
Opacity="0.4"
|
||||||
|
Margin="0,0,0,8"/>
|
||||||
|
<Button Classes="hyperlink"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
Click="OnFindMoreComponentsClick">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
|
<fi:FluentIcon Icon="Globe" IconVariant="Regular" FontSize="14"/>
|
||||||
|
<TextBlock Text="查找更多组件"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- 组件预览区 (右侧) -->
|
<!-- 右侧内容区与左侧的分隔线 -->
|
||||||
<Border Grid.Column="1"
|
<Border Grid.Column="1"
|
||||||
Classes="surface-translucent-strong"
|
Width="1"
|
||||||
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
|
HorizontalAlignment="Left"
|
||||||
Padding="24">
|
Background="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
||||||
<Panel>
|
Opacity="0.5"/>
|
||||||
|
|
||||||
|
<!-- 组件预览区 (右侧) -->
|
||||||
|
<ScrollViewer Grid.Column="1"
|
||||||
|
VerticalScrollBarVisibility="Auto"
|
||||||
|
HorizontalScrollBarVisibility="Disabled">
|
||||||
|
<StackPanel Margin="16,8,12,8"
|
||||||
|
Spacing="0">
|
||||||
|
|
||||||
<!-- 有选中组件时的显示 -->
|
<!-- 有选中组件时的显示 -->
|
||||||
<Grid RowDefinitions="Auto,*,Auto"
|
<Panel IsVisible="{Binding SelectedComponent, Converter={x:Static converters:ObjectConverters.IsNotNull}}">
|
||||||
IsVisible="{Binding SelectedComponent, Converter={x:Static converters:ObjectConverters.IsNotNull}}">
|
|
||||||
<!-- 组件标题 -->
|
|
||||||
<TextBlock Grid.Row="0"
|
|
||||||
FontSize="28"
|
|
||||||
FontWeight="Bold"
|
|
||||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
|
||||||
Text="{Binding SelectedComponent.DisplayName}"
|
|
||||||
Margin="0,0,0,20"/>
|
|
||||||
|
|
||||||
<!-- 预览区域 -->
|
<!-- 组件展示面板 - 有独立背景色,与窗口背景形成层级分界 -->
|
||||||
<Border Grid.Row="1"
|
<Border Classes="surface-translucent-panel"
|
||||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
|
||||||
Background="{DynamicResource AdaptiveSurfaceBaseBrush}"
|
Padding="20">
|
||||||
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
<StackPanel Spacing="16">
|
||||||
BorderThickness="1"
|
<!-- 组件标题 -->
|
||||||
Padding="20"
|
<TextBlock FontSize="28"
|
||||||
HorizontalAlignment="Center"
|
FontWeight="SemiBold"
|
||||||
VerticalAlignment="Center">
|
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||||
<Grid Width="400"
|
Text="{Binding SelectedComponent.DisplayName}"/>
|
||||||
Height="300">
|
|
||||||
<!-- 预览图片 -->
|
|
||||||
<Image Source="{Binding SelectedComponent.PreviewBitmap}"
|
|
||||||
Stretch="Uniform"
|
|
||||||
HorizontalAlignment="Center"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
RenderOptions.BitmapInterpolationMode="HighQuality"
|
|
||||||
IsVisible="{Binding SelectedComponent.IsPreviewReady}"/>
|
|
||||||
|
|
||||||
<!-- 加载中状态 -->
|
<!-- 固定大小的预览卡片 -->
|
||||||
<Border IsVisible="{Binding SelectedComponent.IsPreviewPending}"
|
<Border CornerRadius="{DynamicResource DesignCornerRadiusSm}"
|
||||||
Background="{DynamicResource AdaptiveSurfaceBaseBrush}">
|
Background="{DynamicResource AdaptiveSurfaceBaseBrush}"
|
||||||
<StackPanel HorizontalAlignment="Center"
|
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
||||||
VerticalAlignment="Center"
|
BorderThickness="1"
|
||||||
Spacing="12">
|
Width="420"
|
||||||
<ProgressBar Width="120"
|
Height="300"
|
||||||
IsIndeterminate="True"/>
|
HorizontalAlignment="Center">
|
||||||
<TextBlock HorizontalAlignment="Center"
|
<Grid Margin="16">
|
||||||
TextAlignment="Center"
|
<!-- 预览图片 -->
|
||||||
FontSize="14"
|
<Image Source="{Binding SelectedComponent.PreviewBitmap}"
|
||||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
Stretch="Uniform"
|
||||||
Text="{Binding SelectedComponent.PreviewStatusText}"/>
|
HorizontalAlignment="Center"
|
||||||
</StackPanel>
|
VerticalAlignment="Center"
|
||||||
|
RenderOptions.BitmapInterpolationMode="HighQuality"
|
||||||
|
IsVisible="{Binding SelectedComponent.IsPreviewReady}"/>
|
||||||
|
|
||||||
|
<!-- 加载中状态 -->
|
||||||
|
<Border IsVisible="{Binding SelectedComponent.IsPreviewPending}"
|
||||||
|
Background="{DynamicResource AdaptiveSurfaceBaseBrush}">
|
||||||
|
<StackPanel HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Spacing="12">
|
||||||
|
<ProgressBar Width="120"
|
||||||
|
IsIndeterminate="True"/>
|
||||||
|
<TextBlock HorizontalAlignment="Center"
|
||||||
|
TextAlignment="Center"
|
||||||
|
FontSize="14"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||||
|
Text="{Binding SelectedComponent.PreviewStatusText}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- 失败状态 -->
|
||||||
|
<Border IsVisible="{Binding SelectedComponent.IsPreviewFailed}"
|
||||||
|
Background="{DynamicResource AdaptiveSurfaceBaseBrush}">
|
||||||
|
<StackPanel HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Spacing="8">
|
||||||
|
<fi:FluentIcon Icon="ImageOff"
|
||||||
|
IconVariant="Regular"
|
||||||
|
FontSize="48"
|
||||||
|
Opacity="0.5"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"/>
|
||||||
|
<TextBlock HorizontalAlignment="Center"
|
||||||
|
TextAlignment="Center"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||||
|
Text="{Binding SelectedComponent.PreviewStatusText}"/>
|
||||||
|
<TextBlock HorizontalAlignment="Center"
|
||||||
|
TextAlignment="Center"
|
||||||
|
FontSize="12"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||||
|
Text="{Binding SelectedComponent.PreviewErrorMessage}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- 失败状态 -->
|
<!-- "添加小组件"按钮 - 在面板内居中,使用主题强调色 -->
|
||||||
<Border IsVisible="{Binding SelectedComponent.IsPreviewFailed}"
|
<Button HorizontalAlignment="Center"
|
||||||
Background="{DynamicResource AdaptiveSurfaceBaseBrush}">
|
Classes="accent"
|
||||||
<StackPanel HorizontalAlignment="Center"
|
Padding="24,10"
|
||||||
VerticalAlignment="Center"
|
Tag="{Binding SelectedComponent.ComponentId}"
|
||||||
Spacing="8">
|
Click="OnAddComponentClick">
|
||||||
<fi:SymbolIcon Symbol="ImageOff"
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
FontSize="48"
|
<fi:FluentIcon Icon="Add" IconVariant="Regular" FontSize="16"/>
|
||||||
Opacity="0.5"
|
<TextBlock Text="添加小组件" FontWeight="SemiBold"/>
|
||||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"/>
|
|
||||||
<TextBlock HorizontalAlignment="Center"
|
|
||||||
TextAlignment="Center"
|
|
||||||
FontWeight="SemiBold"
|
|
||||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
|
||||||
Text="{Binding SelectedComponent.PreviewStatusText}"/>
|
|
||||||
<TextBlock HorizontalAlignment="Center"
|
|
||||||
TextAlignment="Center"
|
|
||||||
FontSize="12"
|
|
||||||
TextWrapping="Wrap"
|
|
||||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
|
||||||
Text="{Binding SelectedComponent.PreviewErrorMessage}"/>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Button>
|
||||||
</Grid>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
</Panel>
|
||||||
<!-- 底部操作区 -->
|
|
||||||
<Grid Grid.Row="2"
|
|
||||||
ColumnDefinitions="*,Auto"
|
|
||||||
Margin="0,24,0,0">
|
|
||||||
<TextBlock Grid.Column="0"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
FontSize="14"
|
|
||||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
|
||||||
Text="{Binding SelectedComponent.ComponentId, StringFormat='组件 ID: {0}'}"/>
|
|
||||||
<Button Grid.Column="1"
|
|
||||||
Classes="accent"
|
|
||||||
Padding="20,12"
|
|
||||||
Tag="{Binding SelectedComponent.ComponentId}"
|
|
||||||
Click="OnAddComponentClick">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
|
||||||
<fi:SymbolIcon Symbol="Add" FontSize="16"/>
|
|
||||||
<TextBlock Text="添加到桌面" FontWeight="SemiBold"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<!-- 空状态 -->
|
<!-- 空状态 -->
|
||||||
<Grid IsVisible="{Binding SelectedComponent, Converter={x:Static converters:ObjectConverters.IsNull}}"
|
<Grid IsVisible="{Binding SelectedComponent, Converter={x:Static converters:ObjectConverters.IsNull}}"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
VerticalAlignment="Center">
|
VerticalAlignment="Center"
|
||||||
<StackPanel Spacing="16" HorizontalAlignment="Center">
|
MinHeight="400">
|
||||||
<fi:SymbolIcon Symbol="Apps"
|
<StackPanel Spacing="16" HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<fi:FluentIcon Icon="Apps"
|
||||||
|
IconVariant="Regular"
|
||||||
FontSize="64"
|
FontSize="64"
|
||||||
Opacity="0.3"
|
Opacity="0.3"
|
||||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"/>
|
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"/>
|
||||||
@@ -198,7 +221,7 @@
|
|||||||
Text="请从左侧选择一个组件"/>
|
Text="请从左侧选择一个组件"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Panel>
|
</StackPanel>
|
||||||
</Border>
|
</ScrollViewer>
|
||||||
</Grid>
|
</Grid>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.Linq;
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.VisualTree;
|
||||||
using FluentIcons.Common;
|
using FluentIcons.Common;
|
||||||
using LanMountainDesktop.ComponentSystem;
|
using LanMountainDesktop.ComponentSystem;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
@@ -29,6 +30,8 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
|
|||||||
private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService();
|
private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService();
|
||||||
private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService();
|
private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService();
|
||||||
|
|
||||||
|
private static readonly LocalizationService _localizationService = new();
|
||||||
|
|
||||||
public FusedDesktopComponentLibraryControl()
|
public FusedDesktopComponentLibraryControl()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
@@ -76,26 +79,15 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
|
|||||||
{
|
{
|
||||||
_viewModel.Categories.Clear();
|
_viewModel.Categories.Clear();
|
||||||
|
|
||||||
|
var languageCode = _settingsFacade.Region.Get().LanguageCode;
|
||||||
|
|
||||||
// 添加"全部组件"分类
|
// 添加"全部组件"分类
|
||||||
_viewModel.Categories.Add(new ComponentLibraryCategoryViewModel(
|
_viewModel.Categories.Add(new ComponentLibraryCategoryViewModel(
|
||||||
"all",
|
"all",
|
||||||
"全部组件",
|
L(languageCode, "component_category.all", "All"),
|
||||||
Symbol.Apps,
|
Symbol.Apps,
|
||||||
Array.Empty<ComponentLibraryItemViewModel>()));
|
Array.Empty<ComponentLibraryItemViewModel>()));
|
||||||
|
|
||||||
var categoryMap = new Dictionary<string, (string Display, Symbol Icon)>
|
|
||||||
{
|
|
||||||
{ "clock", ("时钟", Symbol.Clock) },
|
|
||||||
{ "date", ("日历", Symbol.CalendarDate) },
|
|
||||||
{ "weather", ("天气", Symbol.WeatherSunny) },
|
|
||||||
{ "board", ("画板", Symbol.Edit) },
|
|
||||||
{ "media", ("媒体", Symbol.Play) },
|
|
||||||
{ "info", ("资讯", Symbol.News) },
|
|
||||||
{ "calculator", ("工具", Symbol.Calculator) },
|
|
||||||
{ "study", ("学习", Symbol.Hourglass) },
|
|
||||||
{ "file", ("文件", Symbol.Folder) }
|
|
||||||
};
|
|
||||||
|
|
||||||
var usedCategories = _allDefinitions
|
var usedCategories = _allDefinitions
|
||||||
.Select(d => d.Category)
|
.Select(d => d.Category)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
@@ -103,23 +95,62 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
|
|||||||
|
|
||||||
foreach (var cat in usedCategories)
|
foreach (var cat in usedCategories)
|
||||||
{
|
{
|
||||||
if (categoryMap.TryGetValue(cat.ToLower(), out var info))
|
var icon = ResolveCategoryIcon(cat);
|
||||||
{
|
var title = GetLocalizedCategoryTitle(languageCode, cat);
|
||||||
var categoryComponents = _allDefinitions
|
|
||||||
.Where(d => string.Equals(d.Category, cat, StringComparison.OrdinalIgnoreCase))
|
|
||||||
.OrderBy(d => d.DisplayName)
|
|
||||||
.Select(d => CreateComponentItem(d))
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
_viewModel.Categories.Add(new ComponentLibraryCategoryViewModel(
|
var categoryComponents = _allDefinitions
|
||||||
cat,
|
.Where(d => string.Equals(d.Category, cat, StringComparison.OrdinalIgnoreCase))
|
||||||
info.Display,
|
.OrderBy(d => d.DisplayName)
|
||||||
info.Icon,
|
.Select(d => CreateComponentItem(d))
|
||||||
categoryComponents));
|
.ToArray();
|
||||||
}
|
|
||||||
|
_viewModel.Categories.Add(new ComponentLibraryCategoryViewModel(
|
||||||
|
cat,
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
categoryComponents));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 分类图标映射 - 与阑山桌面 Dock 栏组件库 (MainWindow.ComponentSystem) 保持一致
|
||||||
|
/// </summary>
|
||||||
|
private static Symbol ResolveCategoryIcon(string categoryId)
|
||||||
|
{
|
||||||
|
if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase)) return Symbol.Clock;
|
||||||
|
if (string.Equals(categoryId, "Date", StringComparison.OrdinalIgnoreCase)) return Symbol.CalendarDate;
|
||||||
|
if (string.Equals(categoryId, "Weather", StringComparison.OrdinalIgnoreCase)) return Symbol.WeatherSunny;
|
||||||
|
if (string.Equals(categoryId, "Board", StringComparison.OrdinalIgnoreCase)) return Symbol.Edit;
|
||||||
|
if (string.Equals(categoryId, "Media", StringComparison.OrdinalIgnoreCase)) return Symbol.Play;
|
||||||
|
if (string.Equals(categoryId, "Info", StringComparison.OrdinalIgnoreCase)) return Symbol.Apps;
|
||||||
|
if (string.Equals(categoryId, "Calculator", StringComparison.OrdinalIgnoreCase)) return Symbol.Calculator;
|
||||||
|
if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase)) return Symbol.Hourglass;
|
||||||
|
if (string.Equals(categoryId, "File", StringComparison.OrdinalIgnoreCase)) return Symbol.Folder;
|
||||||
|
return Symbol.Apps;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 分类本地化标题 - 与阑山桌面 Dock 栏组件库 (MainWindow.ComponentSystem) 保持一致
|
||||||
|
/// </summary>
|
||||||
|
private string GetLocalizedCategoryTitle(string languageCode, string categoryId)
|
||||||
|
{
|
||||||
|
if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase)) return L(languageCode, "component_category.clock", "Clock");
|
||||||
|
if (string.Equals(categoryId, "Date", StringComparison.OrdinalIgnoreCase)) return L(languageCode, "component_category.date", "Calendar");
|
||||||
|
if (string.Equals(categoryId, "Weather", StringComparison.OrdinalIgnoreCase)) return L(languageCode, "component_category.weather", "Weather");
|
||||||
|
if (string.Equals(categoryId, "Board", StringComparison.OrdinalIgnoreCase)) return L(languageCode, "component_category.board", "Board");
|
||||||
|
if (string.Equals(categoryId, "Media", StringComparison.OrdinalIgnoreCase)) return L(languageCode, "component_category.media", "Media");
|
||||||
|
if (string.Equals(categoryId, "Info", StringComparison.OrdinalIgnoreCase)) return L(languageCode, "component_category.info", "Info");
|
||||||
|
if (string.Equals(categoryId, "Calculator", StringComparison.OrdinalIgnoreCase)) return L(languageCode, "component_category.calculator", "Calculator");
|
||||||
|
if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase)) return L(languageCode, "component_category.study", "Study");
|
||||||
|
if (string.Equals(categoryId, "File", StringComparison.OrdinalIgnoreCase)) return L(languageCode, "component_category.file", "File");
|
||||||
|
return categoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string L(string languageCode, string key, string fallback)
|
||||||
|
{
|
||||||
|
return _localizationService.GetString(languageCode, key, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
private ComponentLibraryItemViewModel CreateComponentItem(DesktopComponentDefinition definition)
|
private ComponentLibraryItemViewModel CreateComponentItem(DesktopComponentDefinition definition)
|
||||||
{
|
{
|
||||||
var previewKey = ComponentPreviewKey.ForComponentType(
|
var previewKey = ComponentPreviewKey.ForComponentType(
|
||||||
@@ -221,4 +252,22 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
|
|||||||
AddComponentRequested?.Invoke(this, componentId);
|
AddComponentRequested?.Invoke(this, componentId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnFindMoreComponentsClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
// 打开设置窗口并导航到插件目录页面
|
||||||
|
if (Application.Current is App app && app.SettingsWindowService is { } settingsWindowService)
|
||||||
|
{
|
||||||
|
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow as MainWindow;
|
||||||
|
var request = new SettingsWindowOpenRequest(
|
||||||
|
Source: "FusedDesktopComponentLibrary",
|
||||||
|
Owner: mainWindow,
|
||||||
|
PageId: "plugin-catalog");
|
||||||
|
settingsWindowService.Open(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭所在窗口
|
||||||
|
var window = this.FindAncestorOfType<Window>();
|
||||||
|
window?.Close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +1,73 @@
|
|||||||
<Window xmlns="https://github.com/avaloniaui"
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:controls="using:LanMountainDesktop.Views"
|
xmlns:controls="using:LanMountainDesktop.Views"
|
||||||
xmlns:fi="using:FluentIcons.Avalonia"
|
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
|
||||||
x:Class="LanMountainDesktop.Views.FusedDesktopComponentLibraryWindow"
|
x:Class="LanMountainDesktop.Views.FusedDesktopComponentLibraryWindow"
|
||||||
Width="860" Height="620"
|
Width="860" Height="620"
|
||||||
MinWidth="600" MinHeight="500"
|
MinWidth="600" MinHeight="500"
|
||||||
|
CanResize="True"
|
||||||
WindowStartupLocation="CenterScreen"
|
WindowStartupLocation="CenterScreen"
|
||||||
SystemDecorations="Full"
|
SystemDecorations="BorderOnly"
|
||||||
ExtendClientAreaToDecorationsHint="True"
|
ExtendClientAreaToDecorationsHint="True"
|
||||||
ExtendClientAreaChromeHints="NoChrome"
|
ExtendClientAreaChromeHints="NoChrome"
|
||||||
ExtendClientAreaTitleBarHeightHint="-1"
|
ExtendClientAreaTitleBarHeightHint="48"
|
||||||
Background="Transparent"
|
Background="Transparent"
|
||||||
TransparencyLevelHint="Mica"
|
|
||||||
Title="添加小组件">
|
Title="添加小组件">
|
||||||
|
|
||||||
<Panel>
|
<Grid x:Name="RootGrid"
|
||||||
<!-- 背景磨砂效果 -->
|
Classes="settings-scope"
|
||||||
<Border Background="{DynamicResource AdaptiveSurfaceLowBrush}"
|
Background="{DynamicResource AdaptiveSettingsWindowBackgroundBrush}"
|
||||||
Opacity="0.85" />
|
RowDefinitions="Auto,*">
|
||||||
|
<!-- 自定义标题栏 - 与 SettingsWindow 风格一致 -->
|
||||||
|
<Border x:Name="WindowTitleBarHost"
|
||||||
|
Height="48"
|
||||||
|
Padding="12,0,12,0"
|
||||||
|
Background="{DynamicResource AdaptiveSettingsWindowBackgroundBrush}"
|
||||||
|
BorderBrush="{DynamicResource AdaptiveSettingsWindowBorderBrush}"
|
||||||
|
BorderThickness="0,0,0,1"
|
||||||
|
PointerPressed="OnWindowTitleBarPointerPressed">
|
||||||
|
<Grid ColumnDefinitions="Auto,Auto,*,Auto"
|
||||||
|
ColumnSpacing="8"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<fi:FluentIcon x:Name="WindowBrandIcon"
|
||||||
|
Icon="Apps"
|
||||||
|
IconVariant="Filled"
|
||||||
|
FontSize="16"
|
||||||
|
IsHitTestVisible="False"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
|
||||||
<Grid RowDefinitions="Auto,*,Auto">
|
<TextBlock x:Name="WindowTitleTextBlock"
|
||||||
<!-- 自定义标题栏 -->
|
Grid.Column="1"
|
||||||
<Border Background="Transparent"
|
FontSize="12"
|
||||||
IsHitTestVisible="True"
|
FontWeight="SemiBold"
|
||||||
Padding="20,16">
|
IsHitTestVisible="False"
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
Text="添加小组件" />
|
||||||
<StackPanel Spacing="6" VerticalAlignment="Center">
|
|
||||||
<TextBlock Text="添加小组件"
|
|
||||||
FontWeight="SemiBold"
|
|
||||||
FontSize="20"
|
|
||||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
|
||||||
<TextBlock Text="将精美组件放置在您的系统桌面上(负一屏)"
|
|
||||||
Opacity="0.6"
|
|
||||||
FontSize="13"
|
|
||||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<Button Grid.Column="1"
|
<TextBlock Grid.Column="2"
|
||||||
Width="36" Height="36"
|
FontSize="12"
|
||||||
Padding="0"
|
Opacity="0.6"
|
||||||
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
|
IsHitTestVisible="False"
|
||||||
BorderThickness="0"
|
VerticalAlignment="Center"
|
||||||
Click="OnCloseClick">
|
Text="将精美组件放置在您的系统桌面上(负一屏)" />
|
||||||
<fi:SymbolIcon Symbol="Dismiss" FontSize="18" />
|
|
||||||
</Button>
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- 组件库控件 -->
|
<Button x:Name="CloseWindowButton"
|
||||||
<controls:FusedDesktopComponentLibraryControl x:Name="LibraryControl"
|
Grid.Column="3"
|
||||||
Grid.Row="1" />
|
Width="40"
|
||||||
|
Height="32"
|
||||||
<!-- 底部查找更多组件链接 -->
|
Padding="0"
|
||||||
<Border Grid.Row="2"
|
Background="Transparent"
|
||||||
Background="Transparent"
|
BorderThickness="0"
|
||||||
Padding="20,12">
|
Click="OnCloseClick">
|
||||||
<Button Classes="hyperlink"
|
<fi:FluentIcon Icon="Dismiss"
|
||||||
HorizontalAlignment="Center"
|
IconVariant="Regular"
|
||||||
Click="OnFindMoreComponentsClick">
|
FontSize="16" />
|
||||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
|
||||||
<fi:SymbolIcon Symbol="Globe" FontSize="14" />
|
|
||||||
<TextBlock Text="查找更多组件" />
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
</Button>
|
||||||
</Border>
|
</Grid>
|
||||||
</Grid>
|
</Border>
|
||||||
</Panel>
|
|
||||||
|
<!-- 组件库控件 -->
|
||||||
|
<controls:FusedDesktopComponentLibraryControl x:Name="LibraryControl"
|
||||||
|
Grid.Row="1"
|
||||||
|
Margin="12,8,16,8" />
|
||||||
|
</Grid>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using LanMountainDesktop.ComponentSystem;
|
using LanMountainDesktop.ComponentSystem;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
@@ -103,23 +104,11 @@ public partial class FusedDesktopComponentLibraryWindow : Window
|
|||||||
Close();
|
Close();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
private void OnWindowTitleBarPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
/// 查找更多组件链接点击处理 - 打开设置窗口的插件目录页面
|
|
||||||
/// </summary>
|
|
||||||
private void OnFindMoreComponentsClick(object? sender, RoutedEventArgs e)
|
|
||||||
{
|
{
|
||||||
// 关闭当前窗口
|
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||||
Close();
|
|
||||||
|
|
||||||
// 打开设置窗口并导航到插件目录页面
|
|
||||||
if (Application.Current is App app && app.SettingsWindowService is { } settingsWindowService)
|
|
||||||
{
|
{
|
||||||
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow as MainWindow;
|
BeginMoveDrag(e);
|
||||||
var request = new SettingsWindowOpenRequest(
|
|
||||||
Source: "FusedDesktopComponentLibrary",
|
|
||||||
Owner: mainWindow,
|
|
||||||
PageId: "plugin-catalog");
|
|
||||||
settingsWindowService.Open(request);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4286,6 +4286,10 @@ public partial class MainWindow
|
|||||||
{
|
{
|
||||||
whiteboard.ForceSaveNote();
|
whiteboard.ForceSaveNote();
|
||||||
}
|
}
|
||||||
|
else if (contentHost?.Child is StickyNoteWidget stickyNote)
|
||||||
|
{
|
||||||
|
stickyNote.ForceSave();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,8 @@
|
|||||||
<StackPanel Classes="about-page-container">
|
<StackPanel Classes="about-page-container">
|
||||||
<Border x:Name="AboutHeroCard"
|
<Border x:Name="AboutHeroCard"
|
||||||
Classes="about-hero-card"
|
Classes="about-hero-card"
|
||||||
Height="240">
|
Height="240"
|
||||||
|
PointerPressed="OnAboutHeroCardPointerPressed">
|
||||||
<Image Source="/Assets/about_banner.png"
|
<Image Source="/Assets/about_banner.png"
|
||||||
Stretch="Uniform"
|
Stretch="Uniform"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using FluentAvalonia.UI.Controls;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
using LanMountainDesktop.PluginSdk;
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
using LanMountainDesktop.Services.Settings;
|
using LanMountainDesktop.Services.Settings;
|
||||||
using LanMountainDesktop.ViewModels;
|
using LanMountainDesktop.ViewModels;
|
||||||
|
|
||||||
@@ -19,6 +25,10 @@ namespace LanMountainDesktop.Views.SettingsPages;
|
|||||||
public partial class AboutSettingsPage : SettingsPageBase
|
public partial class AboutSettingsPage : SettingsPageBase
|
||||||
{
|
{
|
||||||
private const double HeroAspectRatio = 9d / 16d;
|
private const double HeroAspectRatio = 9d / 16d;
|
||||||
|
private const int DevModeActivationClicks = 5;
|
||||||
|
|
||||||
|
private int _heroCardClickCount;
|
||||||
|
private DateTime _lastHeroCardClickTime = DateTime.MinValue;
|
||||||
|
|
||||||
public AboutSettingsPage()
|
public AboutSettingsPage()
|
||||||
: this(new AboutSettingsPageViewModel(HostSettingsFacadeProvider.GetOrCreate()))
|
: this(new AboutSettingsPageViewModel(HostSettingsFacadeProvider.GetOrCreate()))
|
||||||
@@ -60,4 +70,94 @@ public partial class AboutSettingsPage : SettingsPageBase
|
|||||||
|
|
||||||
AboutHeroCard.Height = targetHeight;
|
AboutHeroCard.Height = targetHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnAboutHeroCardPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var elapsed = now - _lastHeroCardClickTime;
|
||||||
|
|
||||||
|
if (elapsed.TotalSeconds > 3)
|
||||||
|
{
|
||||||
|
_heroCardClickCount = 1;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_heroCardClickCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastHeroCardClickTime = now;
|
||||||
|
|
||||||
|
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||||
|
var snapshot = settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||||
|
|
||||||
|
if (snapshot.IsDevModeEnabled)
|
||||||
|
{
|
||||||
|
if (_heroCardClickCount >= 3)
|
||||||
|
{
|
||||||
|
_heroCardClickCount = 0;
|
||||||
|
_ = ShowMessageAsync("开发者模式", "开发者模式已启用,无需重复操作。");
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var remaining = DevModeActivationClicks - _heroCardClickCount;
|
||||||
|
|
||||||
|
if (remaining <= 0)
|
||||||
|
{
|
||||||
|
_heroCardClickCount = 0;
|
||||||
|
PromptEnableDevMode(settingsFacade);
|
||||||
|
}
|
||||||
|
else if (remaining <= 2)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"[AboutSettingsPage] 再点击 {remaining} 次即可启用开发者模式。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void PromptEnableDevMode(ISettingsFacadeService settingsFacade)
|
||||||
|
{
|
||||||
|
var dialog = new ContentDialog
|
||||||
|
{
|
||||||
|
Title = "启用开发者模式",
|
||||||
|
Content = "开发者模式提供了插件调试、热重载等高级功能,仅供开发和调试用途。\n\n" +
|
||||||
|
"请注意:开发者不对以非开发用途使用此功能造成的任何后果负责,也不接受以非开发用途使用时产生的 Bug 反馈。\n\n" +
|
||||||
|
"确定要启用开发者模式吗?",
|
||||||
|
PrimaryButtonText = "启用",
|
||||||
|
CloseButtonText = "取消",
|
||||||
|
DefaultButton = ContentDialogButton.Close
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await dialog.ShowAsync();
|
||||||
|
if (result != ContentDialogResult.Primary)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshot = settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||||
|
snapshot.IsDevModeEnabled = true;
|
||||||
|
settingsFacade.Settings.SaveSnapshot(
|
||||||
|
SettingsScope.App,
|
||||||
|
snapshot,
|
||||||
|
changedKeys: [nameof(AppSettingsSnapshot.IsDevModeEnabled)]);
|
||||||
|
|
||||||
|
AppLogger.Info("DevMode", "Developer mode enabled via About page activation.");
|
||||||
|
|
||||||
|
_ = ShowMessageAsync("开发者模式", "已启用开发者模式。重新打开设置窗口即可看到开发者选项。");
|
||||||
|
|
||||||
|
if (HostContext is not null)
|
||||||
|
{
|
||||||
|
HostContext.RequestRestart("开发者模式已更改");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ShowMessageAsync(string title, string message)
|
||||||
|
{
|
||||||
|
var dialog = new ContentDialog
|
||||||
|
{
|
||||||
|
Title = title,
|
||||||
|
Content = message,
|
||||||
|
CloseButtonText = "确定"
|
||||||
|
};
|
||||||
|
await dialog.ShowAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
89
LanMountainDesktop/Views/SettingsPages/DevSettingsPage.axaml
Normal file
89
LanMountainDesktop/Views/SettingsPages/DevSettingsPage.axaml
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
||||||
|
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||||
|
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
|
||||||
|
x:Class="LanMountainDesktop.Views.SettingsPages.DevSettingsPage"
|
||||||
|
x:DataType="vm:DevSettingsPageViewModel">
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
|
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||||
|
|
||||||
|
<ui:InfoBar IsOpen="True"
|
||||||
|
IsClosable="False"
|
||||||
|
Severity="Warning"
|
||||||
|
Title="开发者模式"
|
||||||
|
Message="开发者模式仅供开发和调试用途。开发者不对以非开发用途使用此功能造成的任何后果负责。"
|
||||||
|
Margin="0,0,0,16" />
|
||||||
|
|
||||||
|
<ui:SettingsExpander Header="启用开发者模式"
|
||||||
|
Description="启用后可使用插件调试、开发者插件路径等高级功能">
|
||||||
|
<ui:SettingsExpander.IconSource>
|
||||||
|
<fi:SymbolIconSource Symbol="DeveloperBoard" />
|
||||||
|
</ui:SettingsExpander.IconSource>
|
||||||
|
<ui:SettingsExpander.Footer>
|
||||||
|
<ToggleSwitch IsChecked="{Binding IsDevModeEnabled}" />
|
||||||
|
</ui:SettingsExpander.Footer>
|
||||||
|
</ui:SettingsExpander>
|
||||||
|
|
||||||
|
<Separator Classes="settings-separator" />
|
||||||
|
|
||||||
|
<ui:SettingsExpander Header="开发者插件路径"
|
||||||
|
Description="指定开发中的插件目录路径,无需打包即可直接加载。多个路径用分号分隔。">
|
||||||
|
<ui:SettingsExpander.IconSource>
|
||||||
|
<fi:SymbolIconSource Symbol="FolderLink" />
|
||||||
|
</ui:SettingsExpander.IconSource>
|
||||||
|
<ui:SettingsExpanderItem>
|
||||||
|
<TextBox Text="{Binding DevPluginPath}"
|
||||||
|
Watermark="C:\path\to\plugin\bin\Debug\net10.0"
|
||||||
|
Width="360"
|
||||||
|
MinWidth="200" />
|
||||||
|
</ui:SettingsExpanderItem>
|
||||||
|
</ui:SettingsExpander>
|
||||||
|
|
||||||
|
<Separator Classes="settings-separator" />
|
||||||
|
|
||||||
|
<ui:SettingsExpander Header="命令行参数"
|
||||||
|
Description="也可以通过命令行参数或环境变量指定开发者插件路径">
|
||||||
|
<ui:SettingsExpander.IconSource>
|
||||||
|
<fi:SymbolIconSource Symbol="WindowConsole" />
|
||||||
|
</ui:SettingsExpander.IconSource>
|
||||||
|
<ui:SettingsExpanderItem>
|
||||||
|
<StackPanel Margin="0,8,0,0" Spacing="8">
|
||||||
|
<TextBlock Text="命令行参数:" FontWeight="SemiBold" />
|
||||||
|
<Border Background="{DynamicResource ControlFillColorDefaultBrush}"
|
||||||
|
CornerRadius="8"
|
||||||
|
Padding="12,8">
|
||||||
|
<TextBlock FontFamily="Cascadia Code, Consolas, monospace"
|
||||||
|
FontSize="12"
|
||||||
|
Text="--dev-plugin <path> 或 -dp <path>"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</Border>
|
||||||
|
<TextBlock Text="环境变量:" FontWeight="SemiBold" Margin="0,8,0,0" />
|
||||||
|
<Border Background="{DynamicResource ControlFillColorDefaultBrush}"
|
||||||
|
CornerRadius="8"
|
||||||
|
Padding="12,8">
|
||||||
|
<TextBlock FontFamily="Cascadia Code, Consolas, monospace"
|
||||||
|
FontSize="12"
|
||||||
|
Text="LMD_DEV_PLUGIN=<path>"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</Border>
|
||||||
|
<TextBlock Text="其他参数:" FontWeight="SemiBold" Margin="0,8,0,0" />
|
||||||
|
<Border Background="{DynamicResource ControlFillColorDefaultBrush}"
|
||||||
|
CornerRadius="8"
|
||||||
|
Padding="12,8">
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock FontFamily="Cascadia Code, Consolas, monospace"
|
||||||
|
FontSize="12"
|
||||||
|
Text="--dev-mode / -dev 启用开发者模式" />
|
||||||
|
<TextBlock FontFamily="Cascadia Code, Consolas, monospace"
|
||||||
|
FontSize="12"
|
||||||
|
Text="--hot-reload / -hr 启用热重载(预留)" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</ui:SettingsExpanderItem>
|
||||||
|
</ui:SettingsExpander>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
using LanMountainDesktop.ViewModels;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views.SettingsPages;
|
||||||
|
|
||||||
|
[SettingsPageInfo(
|
||||||
|
"dev",
|
||||||
|
"开发者",
|
||||||
|
SettingsPageCategory.Dev,
|
||||||
|
IconKey = "DeveloperBoard",
|
||||||
|
SortOrder = 0,
|
||||||
|
TitleLocalizationKey = "settings.dev.title",
|
||||||
|
DescriptionLocalizationKey = "settings.dev.description")]
|
||||||
|
public partial class DevSettingsPage : SettingsPageBase
|
||||||
|
{
|
||||||
|
public DevSettingsPage()
|
||||||
|
: this(new DevSettingsPageViewModel(HostSettingsFacadeProvider.GetOrCreate()))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public DevSettingsPage(DevSettingsPageViewModel viewModel)
|
||||||
|
{
|
||||||
|
ViewModel = viewModel;
|
||||||
|
DataContext = ViewModel;
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public DevSettingsPageViewModel ViewModel { get; }
|
||||||
|
}
|
||||||
@@ -734,8 +734,11 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
|
|||||||
"Info" => Symbol.Info,
|
"Info" => Symbol.Info,
|
||||||
"ArrowSync" => Symbol.ArrowSync,
|
"ArrowSync" => Symbol.ArrowSync,
|
||||||
"Hourglass" => Symbol.Hourglass,
|
"Hourglass" => Symbol.Hourglass,
|
||||||
"Alert" => Symbol.Alert, // 铃铛图标
|
"Alert" => Symbol.Alert,
|
||||||
"Bell" => Symbol.Alert, // Bell也映射到Alert图标
|
"Bell" => Symbol.Alert,
|
||||||
|
"DeveloperBoard" => Symbol.DeveloperBoard,
|
||||||
|
"FolderLink" => Symbol.FolderLink,
|
||||||
|
"WindowConsole" => Symbol.WindowConsole,
|
||||||
_ => Symbol.Settings
|
_ => Symbol.Settings
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -454,21 +454,73 @@ begin
|
|||||||
RegQueryStringValue(HKCU32, WebView2RuntimeKeyPath, 'pv', VersionValue);
|
RegQueryStringValue(HKCU32, WebView2RuntimeKeyPath, 'pv', VersionValue);
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
// Checks whether a .NET 10.x shared framework is installed under the given
|
||||||
|
// base path by enumerating version sub-directories and looking for one that
|
||||||
|
// starts with '10.'.
|
||||||
|
function IsDotNet10RuntimePresent(const BasePath: String): Boolean;
|
||||||
|
var
|
||||||
|
FindRec: TFindRec;
|
||||||
|
begin
|
||||||
|
Result := False;
|
||||||
|
if not DirExists(BasePath) then
|
||||||
|
begin
|
||||||
|
exit;
|
||||||
|
end;
|
||||||
|
|
||||||
|
if FindFirst(BasePath + '\*', FindRec) then
|
||||||
|
begin
|
||||||
|
try
|
||||||
|
repeat
|
||||||
|
if (FindRec.Attributes and FILE_ATTRIBUTE_DIRECTORY <> 0) and
|
||||||
|
(Length(FindRec.Name) >= 3) and
|
||||||
|
(Copy(FindRec.Name, 1, 3) = '10.') then
|
||||||
|
begin
|
||||||
|
Result := True;
|
||||||
|
exit;
|
||||||
|
end;
|
||||||
|
until not FindNext(FindRec);
|
||||||
|
finally
|
||||||
|
FindClose(FindRec);
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
|
// Returns True when the .NET 10 Desktop Runtime (or the .NET 10 Core Runtime
|
||||||
|
// which is sufficient for Avalonia apps) is found on the system.
|
||||||
|
// We check both Microsoft.WindowsDesktop.App and Microsoft.NETCore.App because
|
||||||
|
// the runtimeconfig.json may reference either framework depending on the
|
||||||
|
// publish mode and the app only needs the one it actually references.
|
||||||
function IsDotNetDesktopRuntimeInstalled(): Boolean;
|
function IsDotNetDesktopRuntimeInstalled(): Boolean;
|
||||||
var
|
var
|
||||||
RuntimePath: String;
|
BasePath: String;
|
||||||
begin
|
begin
|
||||||
Result := False;
|
Result := False;
|
||||||
|
|
||||||
RuntimePath := ExpandConstant('{commonpf64}\dotnet\shared\Microsoft.WindowsDesktop.App');
|
// Check 64-bit Program Files
|
||||||
if DirExists(RuntimePath) then
|
BasePath := ExpandConstant('{commonpf64}\dotnet\shared\Microsoft.WindowsDesktop.App');
|
||||||
|
if IsDotNet10RuntimePresent(BasePath) then
|
||||||
begin
|
begin
|
||||||
Result := True;
|
Result := True;
|
||||||
exit;
|
exit;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
RuntimePath := ExpandConstant('{commonpf}\dotnet\shared\Microsoft.WindowsDesktop.App');
|
BasePath := ExpandConstant('{commonpf64}\dotnet\shared\Microsoft.NETCore.App');
|
||||||
if DirExists(RuntimePath) then
|
if IsDotNet10RuntimePresent(BasePath) then
|
||||||
|
begin
|
||||||
|
Result := True;
|
||||||
|
exit;
|
||||||
|
end;
|
||||||
|
|
||||||
|
// Check 32-bit Program Files
|
||||||
|
BasePath := ExpandConstant('{commonpf}\dotnet\shared\Microsoft.WindowsDesktop.App');
|
||||||
|
if IsDotNet10RuntimePresent(BasePath) then
|
||||||
|
begin
|
||||||
|
Result := True;
|
||||||
|
exit;
|
||||||
|
end;
|
||||||
|
|
||||||
|
BasePath := ExpandConstant('{commonpf}\dotnet\shared\Microsoft.NETCore.App');
|
||||||
|
if IsDotNet10RuntimePresent(BasePath) then
|
||||||
begin
|
begin
|
||||||
Result := True;
|
Result := True;
|
||||||
exit;
|
exit;
|
||||||
|
|||||||
136
LanMountainDesktop/plugins/DevPluginOptions.cs
Normal file
136
LanMountainDesktop/plugins/DevPluginOptions.cs
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Plugins;
|
||||||
|
|
||||||
|
public sealed class DevPluginOptions
|
||||||
|
{
|
||||||
|
private static readonly string[] DevPluginPathArgs = ["--dev-plugin", "-dp"];
|
||||||
|
private static readonly string[] DevModeArgs = ["--dev-mode", "-dev"];
|
||||||
|
private static readonly string[] HotReloadArgs = ["--hot-reload", "-hr"];
|
||||||
|
private static readonly string EnvDevPluginPath = "LMD_DEV_PLUGIN";
|
||||||
|
private static readonly string EnvDevMode = "LMD_DEV_MODE";
|
||||||
|
|
||||||
|
public static DevPluginOptions Current { get; } = new();
|
||||||
|
|
||||||
|
public bool IsDevMode { get; private set; }
|
||||||
|
|
||||||
|
public string? DevPluginPath { get; private set; }
|
||||||
|
|
||||||
|
public bool EnableHotReload { get; private set; }
|
||||||
|
|
||||||
|
public IReadOnlyList<string> DevPluginPaths { get; private set; } = Array.Empty<string>();
|
||||||
|
|
||||||
|
private DevPluginOptions() { }
|
||||||
|
|
||||||
|
public static DevPluginOptions Parse(string[] args)
|
||||||
|
{
|
||||||
|
var options = Current;
|
||||||
|
|
||||||
|
options.IsDevMode = TryGetFlag(args, DevModeArgs) ||
|
||||||
|
string.Equals(Environment.GetEnvironmentVariable(EnvDevMode), "1", StringComparison.Ordinal) ||
|
||||||
|
string.Equals(Environment.GetEnvironmentVariable(EnvDevMode), "true", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
options.DevPluginPath = TryGetValue(args, DevPluginPathArgs) ??
|
||||||
|
Environment.GetEnvironmentVariable(EnvDevPluginPath)?.Trim();
|
||||||
|
|
||||||
|
options.EnableHotReload = TryGetFlag(args, HotReloadArgs);
|
||||||
|
|
||||||
|
if (!options.IsDevMode && !string.IsNullOrWhiteSpace(options.DevPluginPath))
|
||||||
|
{
|
||||||
|
options.IsDevMode = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.DevPluginPaths = ResolveDevPluginPaths(options.DevPluginPath);
|
||||||
|
|
||||||
|
if (options.IsDevMode)
|
||||||
|
{
|
||||||
|
AppLogger.Info(
|
||||||
|
"DevPlugin",
|
||||||
|
$"Developer mode enabled. DevPluginPath='{options.DevPluginPath}'; EnableHotReload={options.EnableHotReload}; ResolvedPaths={options.DevPluginPaths.Count}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void ApplySettingsFromSnapshot(bool isDevMode, string? devPluginPath)
|
||||||
|
{
|
||||||
|
if (isDevMode && !IsDevMode)
|
||||||
|
{
|
||||||
|
IsDevMode = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(devPluginPath) && string.IsNullOrWhiteSpace(DevPluginPath))
|
||||||
|
{
|
||||||
|
DevPluginPath = devPluginPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
var allPaths = new List<string>(DevPluginPaths);
|
||||||
|
if (!string.IsNullOrWhiteSpace(devPluginPath))
|
||||||
|
{
|
||||||
|
foreach (var path in ResolveDevPluginPaths(devPluginPath))
|
||||||
|
{
|
||||||
|
if (!allPaths.Contains(path, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
allPaths.Add(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DevPluginPaths = allPaths;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> ResolveDevPluginPaths(string? rawPath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(rawPath))
|
||||||
|
{
|
||||||
|
return Array.Empty<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var paths = rawPath.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
|
||||||
|
var resolved = new List<string>();
|
||||||
|
foreach (var path in paths)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fullPath = Path.GetFullPath(path);
|
||||||
|
if (Directory.Exists(fullPath) || File.Exists(fullPath))
|
||||||
|
{
|
||||||
|
resolved.Add(fullPath);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
AppLogger.Warn("DevPlugin", $"Developer plugin path '{path}' does not exist. It will be skipped.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("DevPlugin", $"Failed to resolve developer plugin path '{path}': {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetFlag(string[] args, string[] names)
|
||||||
|
{
|
||||||
|
return args.Any(arg => names.Any(name => string.Equals(arg, name, StringComparison.OrdinalIgnoreCase)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? TryGetValue(string[] args, string[] names)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < args.Length - 1; i++)
|
||||||
|
{
|
||||||
|
if (names.Any(name => string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
return args[i + 1]?.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,8 @@ namespace LanMountainDesktop.Services;
|
|||||||
public enum PluginCatalogSourceKind
|
public enum PluginCatalogSourceKind
|
||||||
{
|
{
|
||||||
Package = 0,
|
Package = 0,
|
||||||
Manifest = 1
|
Manifest = 1,
|
||||||
|
DevPlugin = 2
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record PluginCatalogEntry(
|
public sealed record PluginCatalogEntry(
|
||||||
@@ -16,4 +17,5 @@ public sealed record PluginCatalogEntry(
|
|||||||
bool IsLoaded,
|
bool IsLoaded,
|
||||||
string? ErrorMessage,
|
string? ErrorMessage,
|
||||||
int SettingsPageCount,
|
int SettingsPageCount,
|
||||||
int WidgetCount);
|
int WidgetCount,
|
||||||
|
bool IsDevPlugin = false);
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ public sealed class PluginLoader
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(dataDirectory);
|
Directory.CreateDirectory(dataDirectory);
|
||||||
ValidatePluginRuntimeAssets(manifest, assemblyPath, pluginDirectory);
|
ValidatePluginRuntimeAssets(manifest, assemblyPath, pluginDirectory, _options.IsDevMode);
|
||||||
AppLogger.Info(
|
AppLogger.Info(
|
||||||
"PluginLoader",
|
"PluginLoader",
|
||||||
$"LoadCore starting. PluginId='{manifest.Id}'; AssemblyPath='{assemblyPath}'; PluginDirectory='{pluginDirectory}'; DataDirectory='{dataDirectory}'.");
|
$"LoadCore starting. PluginId='{manifest.Id}'; AssemblyPath='{assemblyPath}'; PluginDirectory='{pluginDirectory}'; DataDirectory='{dataDirectory}'.");
|
||||||
@@ -721,13 +721,23 @@ public sealed class PluginLoader
|
|||||||
private static void ValidatePluginRuntimeAssets(
|
private static void ValidatePluginRuntimeAssets(
|
||||||
PluginManifest manifest,
|
PluginManifest manifest,
|
||||||
string assemblyPath,
|
string assemblyPath,
|
||||||
string pluginDirectory)
|
string pluginDirectory,
|
||||||
|
bool isDevMode)
|
||||||
{
|
{
|
||||||
var depsFilePath = Path.ChangeExtension(assemblyPath, ".deps.json");
|
var depsFilePath = Path.ChangeExtension(assemblyPath, ".deps.json");
|
||||||
if (!File.Exists(depsFilePath))
|
if (!File.Exists(depsFilePath))
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
if (isDevMode)
|
||||||
$"Plugin '{manifest.Id}' targets API {PluginSdkInfo.ApiVersion} and must include '{Path.GetFileName(depsFilePath)}' next to its main assembly.");
|
{
|
||||||
|
AppLogger.Warn(
|
||||||
|
"PluginLoader",
|
||||||
|
$"Plugin '{manifest.Id}' is missing '{Path.GetFileName(depsFilePath)}'. In developer mode this is allowed, but dependency resolution may fail at runtime.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Plugin '{manifest.Id}' targets API {PluginSdkInfo.ApiVersion} and must include '{Path.GetFileName(depsFilePath)}' next to its main assembly.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var runtimesDirectory = Path.Combine(pluginDirectory, "runtimes");
|
var runtimesDirectory = Path.Combine(pluginDirectory, "runtimes");
|
||||||
@@ -848,6 +858,8 @@ public sealed class PluginLoader
|
|||||||
|
|
||||||
private sealed class PluginRuntimeContext : IPluginRuntimeContext
|
private sealed class PluginRuntimeContext : IPluginRuntimeContext
|
||||||
{
|
{
|
||||||
|
private readonly PluginAppearanceContext _appearanceContext;
|
||||||
|
|
||||||
public PluginRuntimeContext(
|
public PluginRuntimeContext(
|
||||||
PluginManifest manifest,
|
PluginManifest manifest,
|
||||||
string pluginDirectory,
|
string pluginDirectory,
|
||||||
@@ -859,7 +871,8 @@ public sealed class PluginLoader
|
|||||||
PluginDirectory = pluginDirectory;
|
PluginDirectory = pluginDirectory;
|
||||||
DataDirectory = dataDirectory;
|
DataDirectory = dataDirectory;
|
||||||
Properties = properties;
|
Properties = properties;
|
||||||
Appearance = new PluginAppearanceContext(appearanceSnapshot);
|
_appearanceContext = new PluginAppearanceContext(appearanceSnapshot);
|
||||||
|
Appearance = _appearanceContext;
|
||||||
Services = NullServiceProvider.Instance;
|
Services = NullServiceProvider.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -898,6 +911,14 @@ public sealed class PluginLoader
|
|||||||
{
|
{
|
||||||
Services = services ?? throw new ArgumentNullException(nameof(services));
|
Services = services ?? throw new ArgumentNullException(nameof(services));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新外观快照并通知插件。
|
||||||
|
/// </summary>
|
||||||
|
internal void UpdateAppearanceSnapshot(PluginAppearanceSnapshot newSnapshot, IReadOnlyCollection<AppearanceProperty> changedProperties)
|
||||||
|
{
|
||||||
|
_appearanceContext.UpdateSnapshot(newSnapshot, changedProperties);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class PluginMessageBus : IPluginMessageBus, IDisposable
|
private sealed class PluginMessageBus : IPluginMessageBus, IDisposable
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ public sealed class PluginLoaderOptions
|
|||||||
|
|
||||||
public string PackagedDataDirectoryName { get; init; } = PluginSdkInfo.PackagedDataDirectoryName;
|
public string PackagedDataDirectoryName { get; init; } = PluginSdkInfo.PackagedDataDirectoryName;
|
||||||
|
|
||||||
|
public bool IsDevMode { get; init; }
|
||||||
|
|
||||||
public ISet<string> SharedAssemblyNames { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
public ISet<string> SharedAssemblyNames { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
typeof(IPlugin).Assembly.GetName().Name!
|
typeof(IPlugin).Assembly.GetName().Name!
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
Directory.CreateDirectory(PluginsDirectory);
|
Directory.CreateDirectory(PluginsDirectory);
|
||||||
ApplyPendingPluginDeletions();
|
ApplyPendingPluginDeletions();
|
||||||
UnloadInstalledPlugins();
|
UnloadInstalledPlugins();
|
||||||
|
MergeDevSettingsFromSnapshot();
|
||||||
AppLogger.Info("PluginRuntime", $"Loading installed plugins from '{PluginsDirectory}'.");
|
AppLogger.Info("PluginRuntime", $"Loading installed plugins from '{PluginsDirectory}'.");
|
||||||
|
|
||||||
var disabledPluginIds = GetDisabledPluginIds();
|
var disabledPluginIds = GetDisabledPluginIds();
|
||||||
@@ -108,19 +109,30 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
var selectedPluginIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
var selectedPluginIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
foreach (var candidate in candidates)
|
foreach (var candidate in candidates)
|
||||||
{
|
{
|
||||||
|
var isDevPlugin = candidate.SourceKind == PluginCatalogSourceKind.DevPlugin;
|
||||||
|
|
||||||
if (!selectedPluginIds.Add(candidate.Manifest.Id))
|
if (!selectedPluginIds.Add(candidate.Manifest.Id))
|
||||||
{
|
{
|
||||||
var duplicateFailure = PluginLoadResult.Failure(
|
if (isDevPlugin)
|
||||||
candidate.SourcePath,
|
{
|
||||||
candidate.Manifest,
|
AppLogger.Info(
|
||||||
new InvalidOperationException(
|
"DevPlugin",
|
||||||
$"Duplicate plugin id '{candidate.Manifest.Id}' was found. Source '{candidate.SourcePath}' was ignored because a higher-priority source was already selected."));
|
$"Developer plugin '{candidate.Manifest.Id}' overrides an already-registered plugin from '{candidate.SourcePath}'.");
|
||||||
_loadResults.Add(duplicateFailure);
|
}
|
||||||
LogPluginFailure("CatalogSelection", duplicateFailure, treatAsError: false);
|
else
|
||||||
continue;
|
{
|
||||||
|
var duplicateFailure = PluginLoadResult.Failure(
|
||||||
|
candidate.SourcePath,
|
||||||
|
candidate.Manifest,
|
||||||
|
new InvalidOperationException(
|
||||||
|
$"Duplicate plugin id '{candidate.Manifest.Id}' was found. Source '{candidate.SourcePath}' was ignored because a higher-priority source was already selected."));
|
||||||
|
_loadResults.Add(duplicateFailure);
|
||||||
|
LogPluginFailure("CatalogSelection", duplicateFailure, treatAsError: false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var isEnabled = !disabledPluginIds.Contains(candidate.Manifest.Id);
|
var isEnabled = isDevPlugin || !disabledPluginIds.Contains(candidate.Manifest.Id);
|
||||||
if (!isEnabled)
|
if (!isEnabled)
|
||||||
{
|
{
|
||||||
_catalog.Add(new PluginCatalogEntry(
|
_catalog.Add(new PluginCatalogEntry(
|
||||||
@@ -172,6 +184,10 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
PluginsDirectory,
|
PluginsDirectory,
|
||||||
services: _hostServices,
|
services: _hostServices,
|
||||||
hostProperties),
|
hostProperties),
|
||||||
|
PluginCatalogSourceKind.DevPlugin => _loader.LoadFromManifest(
|
||||||
|
candidate.SourcePath,
|
||||||
|
services: _hostServices,
|
||||||
|
hostProperties),
|
||||||
_ => _loader.LoadFromManifest(
|
_ => _loader.LoadFromManifest(
|
||||||
candidate.SourcePath,
|
candidate.SourcePath,
|
||||||
services: _hostServices,
|
services: _hostServices,
|
||||||
@@ -192,7 +208,8 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
true,
|
true,
|
||||||
null,
|
null,
|
||||||
loadResult.LoadedPlugin.SettingsSections.Count,
|
loadResult.LoadedPlugin.SettingsSections.Count,
|
||||||
loadResult.LoadedPlugin.DesktopComponents.Count));
|
loadResult.LoadedPlugin.DesktopComponents.Count,
|
||||||
|
IsDevPlugin: isDevPlugin));
|
||||||
AppLogger.Info(
|
AppLogger.Info(
|
||||||
"PluginRuntime",
|
"PluginRuntime",
|
||||||
$"Plugin loaded. PluginId='{loadResult.LoadedPlugin.Manifest.Id}'; SourcePath='{loadResult.SourcePath}'; ManifestVersion='{loadResult.LoadedPlugin.Manifest.Version ?? "<unknown>"}'; ApiVersion='{loadResult.LoadedPlugin.Manifest.ApiVersion ?? "<unknown>"}'; SourceKind='{candidate.SourceKind}'; SettingsSections={loadResult.LoadedPlugin.SettingsSections.Count}; Widgets={loadResult.LoadedPlugin.DesktopComponents.Count}; Editors={loadResult.LoadedPlugin.DesktopComponentEditors.Count}.");
|
$"Plugin loaded. PluginId='{loadResult.LoadedPlugin.Manifest.Id}'; SourcePath='{loadResult.SourcePath}'; ManifestVersion='{loadResult.LoadedPlugin.Manifest.Version ?? "<unknown>"}'; ApiVersion='{loadResult.LoadedPlugin.Manifest.ApiVersion ?? "<unknown>"}'; SourceKind='{candidate.SourceKind}'; SettingsSections={loadResult.LoadedPlugin.SettingsSections.Count}; Widgets={loadResult.LoadedPlugin.DesktopComponents.Count}; Editors={loadResult.LoadedPlugin.DesktopComponentEditors.Count}.");
|
||||||
@@ -208,7 +225,8 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
false,
|
false,
|
||||||
loadResult.Error?.Message,
|
loadResult.Error?.Message,
|
||||||
0,
|
0,
|
||||||
0));
|
0,
|
||||||
|
IsDevPlugin: isDevPlugin));
|
||||||
LogPluginFailure("Load", loadResult, treatAsError: true);
|
LogPluginFailure("Load", loadResult, treatAsError: true);
|
||||||
Debug.WriteLine($"[PluginRuntime] Failed to load plugin from '{loadResult.SourcePath}': {loadResult.Error}");
|
Debug.WriteLine($"[PluginRuntime] Failed to load plugin from '{loadResult.SourcePath}': {loadResult.Error}");
|
||||||
}
|
}
|
||||||
@@ -229,6 +247,14 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var catalogEntry = _catalog.FirstOrDefault(entry =>
|
||||||
|
string.Equals(entry.Manifest.Id, pluginId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (catalogEntry.IsDevPlugin && !isEnabled)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("DevPlugin", $"Cannot disable developer plugin '{pluginId}'. Developer plugins are always enabled in dev mode.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
var snapshot = LoadAppSettingsSnapshot();
|
var snapshot = LoadAppSettingsSnapshot();
|
||||||
var disabledPluginIds = snapshot.DisabledPluginIds is { Count: > 0 }
|
var disabledPluginIds = snapshot.DisabledPluginIds is { Count: > 0 }
|
||||||
? new HashSet<string>(snapshot.DisabledPluginIds, StringComparer.OrdinalIgnoreCase)
|
? new HashSet<string>(snapshot.DisabledPluginIds, StringComparer.OrdinalIgnoreCase)
|
||||||
@@ -459,12 +485,74 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DiscoverDevPluginCandidates(candidates, failures);
|
||||||
|
|
||||||
return candidates
|
return candidates
|
||||||
.OrderBy(candidate => candidate.SourceKind)
|
.OrderByDescending(candidate => candidate.SourceKind)
|
||||||
.ThenBy(candidate => candidate.SourcePath, StringComparer.OrdinalIgnoreCase)
|
.ThenBy(candidate => candidate.SourcePath, StringComparer.OrdinalIgnoreCase)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void DiscoverDevPluginCandidates(List<PluginCandidate> candidates, List<PluginLoadResult> failures)
|
||||||
|
{
|
||||||
|
var devOptions = DevPluginOptions.Current;
|
||||||
|
if (!devOptions.IsDevMode || devOptions.DevPluginPaths.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLogger.Info("DevPlugin", $"Scanning developer plugin paths. Count={devOptions.DevPluginPaths.Count}.");
|
||||||
|
|
||||||
|
foreach (var devPath in devOptions.DevPluginPaths)
|
||||||
|
{
|
||||||
|
if (File.Exists(devPath) && string.Equals(Path.GetExtension(devPath), PluginSdkInfo.PackageFileExtension, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var manifest = ReadManifestFromPackage(devPath);
|
||||||
|
candidates.Add(new PluginCandidate(devPath, manifest, PluginCatalogSourceKind.DevPlugin));
|
||||||
|
AppLogger.Info("DevPlugin", $"Found developer plugin package. PluginId='{manifest.Id}'; Path='{devPath}'.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var failure = PluginLoadResult.Failure(devPath, null, ex);
|
||||||
|
failures.Add(failure);
|
||||||
|
AppLogger.Warn("DevPlugin", $"Failed to read developer plugin package '{devPath}'.", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Directory.Exists(devPath))
|
||||||
|
{
|
||||||
|
var manifestPath = Path.Combine(devPath, PluginSdkInfo.ManifestFileName);
|
||||||
|
if (File.Exists(manifestPath))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var manifest = PluginManifest.Load(manifestPath);
|
||||||
|
candidates.Add(new PluginCandidate(manifestPath, manifest, PluginCatalogSourceKind.DevPlugin));
|
||||||
|
AppLogger.Info("DevPlugin", $"Found developer plugin manifest. PluginId='{manifest.Id}'; Path='{manifestPath}'.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var failure = PluginLoadResult.Failure(manifestPath, null, ex);
|
||||||
|
failures.Add(failure);
|
||||||
|
AppLogger.Warn("DevPlugin", $"Failed to load developer plugin manifest '{manifestPath}'.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
AppLogger.Warn("DevPlugin", $"Developer plugin directory '{devPath}' does not contain '{PluginSdkInfo.ManifestFileName}'. Skipping.");
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLogger.Warn("DevPlugin", $"Developer plugin path '{devPath}' is neither a file nor a directory. Skipping.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private IEnumerable<string> EnumerateCandidatePaths(string searchPattern)
|
private IEnumerable<string> EnumerateCandidatePaths(string searchPattern)
|
||||||
{
|
{
|
||||||
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(PluginsDirectory), ".runtime"));
|
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(PluginsDirectory), ".runtime"));
|
||||||
@@ -582,7 +670,8 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
|
|
||||||
private static PluginLoaderOptions CreateOptions()
|
private static PluginLoaderOptions CreateOptions()
|
||||||
{
|
{
|
||||||
var options = new PluginLoaderOptions();
|
var devOptions = DevPluginOptions.Current;
|
||||||
|
var options = new PluginLoaderOptions { IsDevMode = devOptions.IsDevMode };
|
||||||
AddSharedAssembly(options, typeof(App).Assembly);
|
AddSharedAssembly(options, typeof(App).Assembly);
|
||||||
AddSharedAssembly(options, typeof(IServiceCollection).Assembly);
|
AddSharedAssembly(options, typeof(IServiceCollection).Assembly);
|
||||||
AddSharedAssembly(options, typeof(HostBuilderContext).Assembly);
|
AddSharedAssembly(options, typeof(HostBuilderContext).Assembly);
|
||||||
@@ -614,6 +703,31 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void MergeDevSettingsFromSnapshot()
|
||||||
|
{
|
||||||
|
var devOptions = DevPluginOptions.Current;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var snapshot = LoadAppSettingsSnapshot();
|
||||||
|
|
||||||
|
if (snapshot.IsDevModeEnabled && !devOptions.IsDevMode)
|
||||||
|
{
|
||||||
|
devOptions.ApplySettingsFromSnapshot(isDevMode: true, devPluginPath: snapshot.DevPluginPath);
|
||||||
|
AppLogger.Info("DevPlugin", $"Developer mode enabled via settings. DevPluginPath='{snapshot.DevPluginPath}'.");
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrWhiteSpace(snapshot.DevPluginPath) && string.IsNullOrWhiteSpace(devOptions.DevPluginPath))
|
||||||
|
{
|
||||||
|
devOptions.ApplySettingsFromSnapshot(isDevMode: devOptions.IsDevMode, devPluginPath: snapshot.DevPluginPath);
|
||||||
|
AppLogger.Info("DevPlugin", $"Developer plugin path merged from settings. DevPluginPath='{snapshot.DevPluginPath}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("DevPlugin", "Failed to merge developer settings from snapshot.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void CollectContributions(LoadedPlugin loadedPlugin)
|
private void CollectContributions(LoadedPlugin loadedPlugin)
|
||||||
{
|
{
|
||||||
_exportRegistry.ReplaceExports(loadedPlugin.Manifest.Id, loadedPlugin.ExportedServices);
|
_exportRegistry.ReplaceExports(loadedPlugin.Manifest.Id, loadedPlugin.ExportedServices);
|
||||||
@@ -826,6 +940,13 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
_settingsCatalogService.RemovePluginSections(pluginId);
|
_settingsCatalogService.RemovePluginSections(pluginId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum PluginCatalogSourceKind
|
||||||
|
{
|
||||||
|
Package = 0,
|
||||||
|
Manifest = 1,
|
||||||
|
DevPlugin = 2
|
||||||
|
}
|
||||||
|
|
||||||
private sealed record PluginCandidate(
|
private sealed record PluginCandidate(
|
||||||
string SourcePath,
|
string SourcePath,
|
||||||
PluginManifest Manifest,
|
PluginManifest Manifest,
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ dotnet new install LanMountainDesktop.PluginTemplate
|
|||||||
dotnet new lmd-plugin -n MyPlugin
|
dotnet new lmd-plugin -n MyPlugin
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Plugin SDK**: `LanMountainDesktop.PluginSdk` (API 4.0.0)
|
- **Plugin SDK**: `LanMountainDesktop.PluginSdk` (API 4.0.1)
|
||||||
- **共享契约**: `LanMountainDesktop.Shared.Contracts`
|
- **共享契约**: `LanMountainDesktop.Shared.Contracts`
|
||||||
- **迁移指南**: [PLUGIN_SDK_V4_MIGRATION.md](docs/PLUGIN_SDK_V4_MIGRATION.md)
|
- **迁移指南**: [PLUGIN_SDK_V4_MIGRATION.md](docs/PLUGIN_SDK_V4_MIGRATION.md)
|
||||||
|
|
||||||
|
|||||||
683
design.md
Normal file
683
design.md
Normal file
@@ -0,0 +1,683 @@
|
|||||||
|
# UI Design System Guide (design.md)
|
||||||
|
|
||||||
|
> **目标**: 让 AI 正确使用 Fluent Avalonia / Fluent Icons / Material Avalonia,避免窗口套窗口、容器套容器
|
||||||
|
>
|
||||||
|
> **最后更新**: 2026-04-11
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一句话总结
|
||||||
|
|
||||||
|
**主界面用 Fluent + FluentIcon,编辑器用 Material + MaterialIcon,永远不要混用,保持扁平结构。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 技术栈与职责
|
||||||
|
|
||||||
|
### 1.1 库清单
|
||||||
|
|
||||||
|
| 库 | 包名 | 什么时候用 |
|
||||||
|
|---|------|----------|
|
||||||
|
| **FluentAvaloniaUI** | `FluentAvaloniaUI` | 主界面、设置页、导航 |
|
||||||
|
| **FluentIcons.Avalonia.Fluent** | `FluentIcons.Avalonia.Fluent` | 主界面图标(**首选**)|
|
||||||
|
| **FluentIcons.Avalonia** | `FluentIcons.Avalonia` | 旧图标兼容(SymbolIcon)|
|
||||||
|
| **Material.Icons.Avalonia** | `Material.Icons.Avalonia` | 编辑器图标(**仅限 ComponentEditorWindow**)|
|
||||||
|
| **Material.Avalonia** | `Material.Avalonia` | MD3 主题(**仅限 ComponentEditorWindow**)|
|
||||||
|
|
||||||
|
### 1.2 初始化顺序(App.axaml)
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Application.Styles>
|
||||||
|
<sty:FluentAvaloniaTheme /> <!-- 第 1 位:基础 Win11 风格 -->
|
||||||
|
<mi:MaterialIconStyles /> <!-- 第 2 位:全局注册 Material Icons 样式 -->
|
||||||
|
|
||||||
|
<!-- 项目自定义样式 -->
|
||||||
|
<StyleInclude Source="avares://LanMountainDesktop/Styles/FlutermotionToken.axaml" />
|
||||||
|
<StyleInclude Source="avares://LanMountainDesktop/Styles/GlassModule.axaml" />
|
||||||
|
<StyleInclude Source="avares://LanMountainDesktop/Styles/SettingsAnimations.axaml" />
|
||||||
|
<StyleInclude Source="avares://LanMountainDesktop/Styles/SettingsCardStyles.axaml" />
|
||||||
|
<StyleInclude Source="avares://LanMountainDesktop/Styles/NavigationStyles.axaml" />
|
||||||
|
</Application.Styles>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 命名空间速查表
|
||||||
|
|
||||||
|
**复制粘贴用:**
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- 必需 -->
|
||||||
|
xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
|
||||||
|
<!-- FluentAvaloniaUI 控件(主界面/设置页必需)-->
|
||||||
|
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||||
|
|
||||||
|
<!-- Fluent Icons - 新版推荐(主界面首选)-->
|
||||||
|
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
|
||||||
|
|
||||||
|
<!-- Fluent Icons - 旧版兼容(已有代码维护用)-->
|
||||||
|
<!-- xmlns:fi-legacy="using:FluentIcons.Avalonia" -->
|
||||||
|
|
||||||
|
<!-- Material Icons(仅 ComponentEditorWindow 使用)-->
|
||||||
|
<!-- xmlns:mi="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" -->
|
||||||
|
|
||||||
|
<!-- Material Theme(仅 ComponentEditorWindow 使用)-->
|
||||||
|
<!-- xmlns:themes="clr-namespace:Material.Styles.Themes;assembly=Material.Styles" -->
|
||||||
|
|
||||||
|
<!-- 项目控件 -->
|
||||||
|
xmlns:local="using:LanMountainDesktop.Controls"
|
||||||
|
xmlns:comp="using:LanMountainDesktop.Views.Components"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 图标系统
|
||||||
|
|
||||||
|
### 3.1 选择决策树
|
||||||
|
|
||||||
|
```
|
||||||
|
你在写什么?
|
||||||
|
├─ 设置页面 / 主界面 / 桌面组件?
|
||||||
|
│ └─ 用 FluentIcon(fi:FluentIcon)
|
||||||
|
│ ├─ 需要 Filled/Regular 切换?→ IconVariant="Filled" 或 "Regular"
|
||||||
|
│ └─ 简单静态图标?→ 也用 FluentIcon,不用 SymbolIcon
|
||||||
|
│
|
||||||
|
├─ ComponentEditorWindow 及其子页面?
|
||||||
|
│ └─ 用 MaterialIcon(mi:MaterialIcon)
|
||||||
|
│
|
||||||
|
└─ 其他情况?
|
||||||
|
└─ 默认 FluentIcon
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 FluentIcon 使用方法
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<fi:FluentIcon Icon="Settings" <!-- 图标名称 -->
|
||||||
|
IconVariant="Filled" <!-- Filled | Regular -->
|
||||||
|
Classes="icon-m" /> <!-- icon-s(12px) | icon-m(16px) | icon-l(20px) -->
|
||||||
|
```
|
||||||
|
|
||||||
|
**常用图标名称:**
|
||||||
|
- 导航类:`Home`, `Settings`, `Navigation`, `ArrowLeft`, `ChevronRight`, `Dismiss`
|
||||||
|
- 操作类:`Add`, `Delete`, `Edit`, `Save`, `Refresh`, `Sync`, `ArrowSync`
|
||||||
|
- 状态类:`Info`, `Warning`, `ErrorBadge`, `CheckmarkCircle`
|
||||||
|
- 外观类:`ThemeLightDark`, `ColorBackground`, `Appearance`
|
||||||
|
|
||||||
|
### 3.3 MaterialIcon 使用方法(仅限编辑器)
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<mi:MaterialIcon Kind="Close" <!-- 图标名称 -->
|
||||||
|
Width="24"
|
||||||
|
Height="24"
|
||||||
|
Foreground="{DynamicResource EditorPrimaryBrush}" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**常用 Kind 值:**
|
||||||
|
- 操作:`Close`, `Check`, `Pencil`, `Delete`, `Settings`, `Plus`
|
||||||
|
- 导航:`ArrowLeft`, `ArrowRight`, `Home`, `Menu`
|
||||||
|
- 系统:`Power`, `Lock`, `ExitToApp`, `Refresh`, `WeatherNight`
|
||||||
|
|
||||||
|
### 3.4 ❌ 禁止事项
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- ❌ 错误:同一区域混用两种图标库 -->
|
||||||
|
<StackPanel>
|
||||||
|
<fi:FluentIcon Icon="Home" /> <!-- Fluent -->
|
||||||
|
<mi:MaterialIcon Kind="Settings" /> <!-- Material -->
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- ❌ 错误:硬编码尺寸 -->
|
||||||
|
<fi:FluentIcon Icon="Settings" FontSize="18" />
|
||||||
|
|
||||||
|
<!-- ✅ 正确:使用预定义 class -->
|
||||||
|
<fi:FluentIcon Icon="Settings" Classes="icon-m" />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 容器嵌套规范(核心!)
|
||||||
|
|
||||||
|
### 4.1 最大深度限制
|
||||||
|
|
||||||
|
| 场景 | 最大层数 | 从哪里开始数 |
|
||||||
|
|-----|---------|------------|
|
||||||
|
| 普通页面 | **≤ 4 层** | Window/UserControl → ... → 叶子节点 |
|
||||||
|
| Popup/Dialog | **≤ 3 层** | Border → Content |
|
||||||
|
| 列表项/DataTemplate | **≤ 3 层** | Root → ... → 元素 |
|
||||||
|
| MainWindow 桌面布局 | **≤ 6 层** | 特殊允许(多层叠加需求)|
|
||||||
|
|
||||||
|
### 4.2 如何数层级?
|
||||||
|
|
||||||
|
从根元素到目标元素经过的容器标签数:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<UserControl> <!-- 层级 0(根)-->
|
||||||
|
<Border> <!-- 层级 1 -->
|
||||||
|
<Grid> <!-- 层级 2 -->
|
||||||
|
<StackPanel> <!-- 层级 3 -->
|
||||||
|
<Button> <!-- 层级 4(叶子节点)✅ OK -->
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</UserControl>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 推荐的标准结构
|
||||||
|
|
||||||
|
#### 结构 A:标准设置页面(3-4 层)
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<UserControl>
|
||||||
|
<ScrollViewer> <!-- 层 1 -->
|
||||||
|
<StackPanel Spacing="24"> <!-- 层 2 -->
|
||||||
|
<Border Classes="settings-section-card"> <!-- 层 3 -->
|
||||||
|
<StackPanel Spacing="16"> <!-- 层 4 ✅ -->
|
||||||
|
<!-- 内容 -->
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</UserControl>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 结构 B:卡片/面板组件(3 层)
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Border CornerRadius="..." Padding="..."> <!-- 层 1(根)-->
|
||||||
|
<Grid RowDefinitions="Auto,*"> <!-- 层 2 -->
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto"> <!-- 层 3a: Header -->
|
||||||
|
<fi:FluentIcon Classes="icon-l" />
|
||||||
|
<TextBlock Grid.Column="1" />
|
||||||
|
<ContentPresenter Grid.Column="2" />
|
||||||
|
</Grid>
|
||||||
|
<ContentControl Grid.Row="1" /> <!-- 层 3b: Body ✅ -->
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 结构 C:列表项(2-3 层)
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Border Classes="list-item"> <!-- 层 1 -->
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto"> <!-- 层 2 -->
|
||||||
|
<Border Classes="icon-host"> <!-- 层 3a -->
|
||||||
|
<fi:FluentIcon Classes="icon-m" />
|
||||||
|
</Border>
|
||||||
|
<StackPanel Grid.Column="1" Spacing="4"> <!-- 层 3b -->
|
||||||
|
<TextBlock Classes="title" />
|
||||||
|
<TextBlock Classes="subtitle" />
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Column="2">...</Button> <!-- 层 3c ✅ -->
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 ❌ 反模式:过度嵌套
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- ❌ 错误:7 层嵌套(实际项目中出现的反面教材)-->
|
||||||
|
<Grid> <!-- 1 -->
|
||||||
|
<Grid> <!-- 2 -->
|
||||||
|
<Border> <!-- 3 -->
|
||||||
|
<Grid> <!-- 4 -->
|
||||||
|
<Border> <!-- 5 -->
|
||||||
|
<Grid> <!-- 6 -->
|
||||||
|
<Border> <!-- 7 ❌ 太深了!-->
|
||||||
|
<Button Content="Click" />
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- ✅ 重构后:2 层 -->
|
||||||
|
<Grid HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<Button Content="Click"
|
||||||
|
Padding="16,8" />
|
||||||
|
</Grid>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 何时可以超过限制?
|
||||||
|
|
||||||
|
只有这 3 种情况:
|
||||||
|
|
||||||
|
1. **MainWindow 桌面布局**(壁纸层 + 组件层 + 拖拽层 + 任务栏)
|
||||||
|
2. **需要独立动画层**(Transform/Opacity 动画需要单独容器)
|
||||||
|
3. **复杂 Popup 内部**
|
||||||
|
|
||||||
|
**必须加注释说明原因:**
|
||||||
|
```xml
|
||||||
|
<!--
|
||||||
|
允许深层嵌套原因:桌面渲染需要支持以下视觉层级
|
||||||
|
- DesktopWallpaperLayer(壁纸背景)
|
||||||
|
- DesktopPagesContainer(桌面分页)
|
||||||
|
- LauncherPagePanel(启动器面板)
|
||||||
|
- Canvas 拖拽层
|
||||||
|
- BottomTaskbarContainer(任务栏)
|
||||||
|
-->
|
||||||
|
<Grid x:Name="DesktopHost">
|
||||||
|
...
|
||||||
|
</Grid>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 窗口 vs UserControl vs Border
|
||||||
|
|
||||||
|
### 5.1 什么时候用什么?
|
||||||
|
|
||||||
|
| 需求 | 用什么 | 示例 |
|
||||||
|
|-----|-------|------|
|
||||||
|
| 独立窗口(有标题栏、可拖动、任务栏可见)| **Window** | SettingsWindow, ComponentEditorWindow |
|
||||||
|
| 可复用的 UI 组件块 | **UserControl** | SettingsOptionCard, ClockWidget |
|
||||||
|
| 视觉上的卡片/面板/容器 | **Border** | 设置分区卡片、弹出面板 |
|
||||||
|
|
||||||
|
### 5.2 ❌ 禁止:窗口套窗口
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// ❌ 错误:在 XAML 中实例化另一个 Window
|
||||||
|
// <local:SettingsWindow Visibility="Visible" />
|
||||||
|
|
||||||
|
// ✅ 正确:通过代码显示独立窗口
|
||||||
|
var settings = new SettingsWindow { Owner = this };
|
||||||
|
settings.Show();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 ❌ 禁止:把 Window 当 UserControl 用
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- ❌ 错误:MainWindow 内部嵌入了一个本应是独立窗口的东西 -->
|
||||||
|
<Window x:Class="MainWindow">
|
||||||
|
<Grid>
|
||||||
|
<Border x:Name="ComponentLibraryWindow" <!-- 这看起来像窗口,但不应该是 Window -->
|
||||||
|
Width="620"
|
||||||
|
Height="320"
|
||||||
|
CornerRadius="36">
|
||||||
|
<!-- 组件库内容 -->
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
|
```
|
||||||
|
|
||||||
|
**如果它不是真正的操作系统窗口,就用 Border 或 UserControl。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 颜色与资源使用规范
|
||||||
|
|
||||||
|
### 6.1 必须使用 DynamicResource
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- ❌ 错误:硬编码颜色 -->
|
||||||
|
<TextBlock Foreground="#FF1D1B20" />
|
||||||
|
<Border Background="#FFF3EDF7" />
|
||||||
|
<Button Background="#FF6750A4" />
|
||||||
|
|
||||||
|
<!-- ✅ 正确:使用动态资源 -->
|
||||||
|
<TextBlock Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||||
|
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}" />
|
||||||
|
<Button Background="{DynamicResource AccentBrush}" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 常用资源键速查
|
||||||
|
|
||||||
|
| 资源键 | 用途 | 示例场景 |
|
||||||
|
|--------|------|---------|
|
||||||
|
| `AdaptiveTextPrimaryBrush` | 主要文本 | 标题、正文 |
|
||||||
|
| `AdaptiveTextSecondaryBrush` | 次要文本 | 描述文字、提示 |
|
||||||
|
| `AdaptiveSurfaceRaisedBrush` | 抬高表面 | 卡片背景、面板 |
|
||||||
|
| `AdaptiveSurfaceOverlayBrush` | 覆盖层 | 遮罩、弹窗背景 |
|
||||||
|
| `AccentBrush` | 强调色 | 主按钮、选中态 |
|
||||||
|
| `AppFontFamily` | 应用字体 | 全局字体设置 |
|
||||||
|
|
||||||
|
### 6.3 圆角 Token(强制!)
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- ❌ 错误:硬编码圆角 -->
|
||||||
|
<Border CornerRadius="8" />
|
||||||
|
<Button CornerRadius="12" />
|
||||||
|
|
||||||
|
<!-- ❌ 错误:桌面组件根容器用了非组件级 Token -->
|
||||||
|
<Border CornerRadius="{DynamicResource DesignCornerRadiusMd}" />
|
||||||
|
|
||||||
|
<!-- ✅ 正确 -->
|
||||||
|
<!-- 桌面组件(Widget)根容器必须且只能用这个 -->
|
||||||
|
<Border CornerRadius="{DynamicResource DesignCornerRadiusComponent}" />
|
||||||
|
|
||||||
|
<!-- 内部元素按层级选择 -->
|
||||||
|
<Border CornerRadius="{DynamicResource DesignCornerRadiusSm}" /> <!-- 小 -->
|
||||||
|
<Border CornerRadius="{DynamicResource DesignCornerRadiusMd}" /> <!-- 中 -->
|
||||||
|
<Border CornerRadius="{DynamicResource DesignCornerRadiusLg}" /> <!-- 大 -->
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. FluentAvaloniaUI 控件用法
|
||||||
|
|
||||||
|
### 7.1 NavigationView(设置页导航)
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<ui:NavigationView x:Name="RootNav"
|
||||||
|
PaneDisplayMode="Auto" <!-- 自动折叠 -->
|
||||||
|
OpenPaneLength="283" <!-- Win11 标准宽度 -->
|
||||||
|
IsSettingsVisible="False" <!-- 隐藏默认设置按钮 -->
|
||||||
|
IsBackButtonVisible="False"
|
||||||
|
SelectionChanged="OnNavChanged">
|
||||||
|
|
||||||
|
<ui:NavigationView.Resources>
|
||||||
|
<!-- 移除默认背景色 -->
|
||||||
|
<SolidColorBrush x:Key="NavigationViewContentBackground" Color="Transparent" />
|
||||||
|
<SolidColorBrush x:Key="NavigationViewContentGridBorderBrush" Color="Transparent" />
|
||||||
|
</ui:NavigationView.Resources>
|
||||||
|
|
||||||
|
<Grid Margin="12,0,16,16">
|
||||||
|
<ui:Frame x:Name="ContentFrame" />
|
||||||
|
</Grid>
|
||||||
|
</ui:NavigationView>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Frame(页面导航容器)
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<ui:Frame x:Name="ContentFrame" />
|
||||||
|
```
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// C# 代码中导航
|
||||||
|
ContentFrame.Navigate(typeof(SettingsHomePage));
|
||||||
|
// 或绑定
|
||||||
|
ContentFrame.SourcePageType = typeof(SettingsHomePage);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 InfoBar(内联通知条)
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<ui:InfoBar Title="发现新版本"
|
||||||
|
Message="v2.0.0 可用"
|
||||||
|
Severity="Informational" <!-- Informational | Warning | Error | Success -->
|
||||||
|
IsOpen="True"
|
||||||
|
ActionButtonText="立即更新"
|
||||||
|
ActionButtonClick="OnUpdateClick"
|
||||||
|
CloseButtonClick="OnDismissClick" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 ContentDialog(模态对话框)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var dialog = new ContentDialog
|
||||||
|
{
|
||||||
|
Title = "确认删除",
|
||||||
|
Content = "确定要删除吗?此操作不可撤销。",
|
||||||
|
PrimaryButtonText = "删除",
|
||||||
|
CloseButtonText = "取消",
|
||||||
|
DefaultButton = ContentDialogButton.Primary
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await dialog.ShowAsync(this);
|
||||||
|
if (result == ContentDialogResult.Primary)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Material.Avalonia 使用规范(严格限制!)
|
||||||
|
|
||||||
|
### 8.1 ⚠️ 只能在这里用
|
||||||
|
|
||||||
|
**✅ 允许:**
|
||||||
|
- `ComponentEditorWindow.axaml`
|
||||||
|
- ComponentEditorWindow 的子编辑页面
|
||||||
|
|
||||||
|
**❌ 禁止:**
|
||||||
|
- MainWindow
|
||||||
|
- SettingsWindow
|
||||||
|
- NotificationWindow
|
||||||
|
- 任何桌面组件(Widget)
|
||||||
|
- 任何其他地方
|
||||||
|
|
||||||
|
### 8.2 如何在 ComponentEditorWindow 中启用
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Window ...>
|
||||||
|
<Window.Resources>
|
||||||
|
<!-- MD3 色板(仅此窗口有效)-->
|
||||||
|
<SolidColorBrush x:Key="EditorPrimaryBrush" Color="#FF6750A4" />
|
||||||
|
<SolidColorBrush x:Key="EditorSurfaceContainerBrush" Color="#FFF3EDF7" />
|
||||||
|
<SolidColorBrush x:Key="EditorOnPrimaryBrush" Color="#FFFFFFFF" />
|
||||||
|
</Window.Resources>
|
||||||
|
|
||||||
|
<Window.Styles>
|
||||||
|
<!-- 加载 Material 主题(只影响此窗口)-->
|
||||||
|
<themes:CustomMaterialTheme BaseTheme="Light"
|
||||||
|
PrimaryColor="#6750A4"
|
||||||
|
SecondaryColor="#625B71" />
|
||||||
|
|
||||||
|
<!-- 项目自定义覆盖 -->
|
||||||
|
<StyleInclude Source="avares://LanMountainDesktop/Styles/ComponentEditorThemeResources.axaml" />
|
||||||
|
</Window.Styles>
|
||||||
|
</Window>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 MD3 组件示例
|
||||||
|
|
||||||
|
#### FAB 按钮(浮动操作按钮)
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Button x:Name="SaveFAB"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
VerticalAlignment="Bottom"
|
||||||
|
Margin="28"
|
||||||
|
Width="64" Height="64"
|
||||||
|
Background="{DynamicResource EditorPrimaryBrush}"
|
||||||
|
Foreground="{DynamicResource EditorOnPrimaryBrush}"
|
||||||
|
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||||
|
Click="OnSaveClick">
|
||||||
|
<mi:MaterialIcon Kind="Check" Width="32" Height="32" />
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Top App Bar(顶栏)
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Border Background="{DynamicResource EditorTopAppBarBackgroundBrush}"
|
||||||
|
Padding="24,16">
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||||
|
<mi:MaterialIcon Kind="Widgets"
|
||||||
|
Width="28" Height="28"
|
||||||
|
Foreground="{DynamicResource EditorPrimaryBrush}" />
|
||||||
|
<TextBlock Grid.Column="1"
|
||||||
|
FontSize="20" FontWeight="SemiBold"
|
||||||
|
Text="组件编辑器"
|
||||||
|
Margin="16,0,0,0" />
|
||||||
|
<Button Grid.Column="2" Click="OnCloseClick">
|
||||||
|
<mi:MaterialIcon Kind="Close" Width="24" Height="24" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 实战代码模板
|
||||||
|
|
||||||
|
### 模板 1:新建设置页面
|
||||||
|
|
||||||
|
文件位置:`Views/Settings/YourPageName.axaml`
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
|
||||||
|
xmlns:local="using:LanMountainDesktop.Controls"
|
||||||
|
x:Class="LanMountainDesktop.Views.Settings.YourPageName">
|
||||||
|
|
||||||
|
<ScrollViewer Padding="24,0,24,24">
|
||||||
|
<StackPanel Spacing="24">
|
||||||
|
|
||||||
|
<!-- 分区卡片 -->
|
||||||
|
<Border Classes="settings-section-card">
|
||||||
|
<StackPanel Spacing="16">
|
||||||
|
|
||||||
|
<!-- 分区标题 -->
|
||||||
|
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="12">
|
||||||
|
<fi:FluentIcon Icon="YourSectionIcon"
|
||||||
|
IconVariant="Filled"
|
||||||
|
Classes="icon-l" />
|
||||||
|
<TextBlock Grid.Column="1"
|
||||||
|
Classes="settings-section-title"
|
||||||
|
Text="分区标题" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- 选项卡 1 -->
|
||||||
|
<local:SettingsOptionCard Icon="OptionIcon1"
|
||||||
|
Title="选项标题"
|
||||||
|
Title="选项描述">
|
||||||
|
<local:SettingsOptionCard.ActionContent>
|
||||||
|
<ToggleSwitch IsChecked="{Binding YourProperty, Mode=TwoWay}" />
|
||||||
|
</local:SettingsOptionCard.ActionContent>
|
||||||
|
</local:SettingsOptionCard>
|
||||||
|
|
||||||
|
<!-- 选项卡 2 -->
|
||||||
|
<local:SettingsOptionCard Icon="OptionIcon2"
|
||||||
|
Title="另一个选项"
|
||||||
|
Title="描述信息" />
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</UserControl>
|
||||||
|
```
|
||||||
|
|
||||||
|
**嵌套检查:** ScrollViewer(1) > StackPanel(2) > Border(3) > StackPanel(4) > Items(5) ✅ (因为有 ScrollViewer 容器,5 层可接受)
|
||||||
|
|
||||||
|
### 模板 2:新建桌面小组件
|
||||||
|
|
||||||
|
文件位置:`Views/Components/YourWidget.axaml`
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="LanMountainDesktop.Views.Components.YourWidget">
|
||||||
|
|
||||||
|
<Border Classes="desktop-widget-root"
|
||||||
|
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||||
|
Padding="16,12">
|
||||||
|
<Grid RowDefinitions="Auto,*" RowSpacing="8">
|
||||||
|
|
||||||
|
<!-- 标题区 -->
|
||||||
|
<TextBlock x:Name="TitleTextBlock"
|
||||||
|
FontSize="14"
|
||||||
|
FontWeight="SemiBold" />
|
||||||
|
|
||||||
|
<!-- 内容区 -->
|
||||||
|
<ContentControl Grid.Row="1" />
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</UserControl>
|
||||||
|
```
|
||||||
|
|
||||||
|
**嵌套检查:** Border(1) > Grid(2) > Elements(3) ✅ 完美!
|
||||||
|
|
||||||
|
### 模板 3:新建独立窗口
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||||
|
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
|
||||||
|
x:Class="LanMountainDesktop.Views.YourWindow"
|
||||||
|
Width="800"
|
||||||
|
Height="600"
|
||||||
|
MinWidth="400"
|
||||||
|
MinHeight="300"
|
||||||
|
CanResize="True"
|
||||||
|
SystemDecorations="BorderOnly"
|
||||||
|
Background="Transparent"
|
||||||
|
Title="窗口标题">
|
||||||
|
|
||||||
|
<Grid RowDefinitions="Auto,*">
|
||||||
|
|
||||||
|
<!-- 自定义标题栏 -->
|
||||||
|
<Border Height="48"
|
||||||
|
Padding="12,0"
|
||||||
|
PointerPressed="OnTitleBarPressed">
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||||
|
<fi:FluentIcon Icon="WindowIcon"
|
||||||
|
IconVariant="Filled"
|
||||||
|
Classes="icon-m" />
|
||||||
|
<TextBlock Grid.Column="1"
|
||||||
|
Text="{Binding Title}"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<Button Grid.Column="2"
|
||||||
|
Click="OnCloseClick">
|
||||||
|
<fi:FluentIcon Icon="Dismiss" IconVariant="Regular" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- 主内容区 -->
|
||||||
|
<ContentControl Grid.Row="1"
|
||||||
|
Content="{Binding Content}" />
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
|
```
|
||||||
|
|
||||||
|
**嵌套检查:** Grid(1) > Border(2) > Grid(3) > Elements(4) ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. AI 编码检查清单
|
||||||
|
|
||||||
|
### 写代码前问自己
|
||||||
|
|
||||||
|
- [ ] 这个文件是设置页/主界面?→ 用 Fluent + FluentIcon
|
||||||
|
- [ ] 这个文件是 ComponentEditorWindow?→ 用 Material + MaterialIcon
|
||||||
|
- [ ] 我用了正确的命名空间吗?(见第 2 节速查表)
|
||||||
|
- [ ] 图标用了 Classes="icon-s/m/l" 而非硬编码 FontSize 吗?
|
||||||
|
|
||||||
|
### 写完代码后检查
|
||||||
|
|
||||||
|
- [ ] 数一下最大嵌套深度(见第 4.2 节)
|
||||||
|
- [ ] 有没有硬编码颜色值?(应该都用 DynamicResource)
|
||||||
|
- [ ] 有没有硬编码 CornerRadius?(应该用 DesignCornerRadiusXxx)
|
||||||
|
- [ ] 有没有在同一区域混用 FluentIcon 和 MaterialIcon?
|
||||||
|
- [ ] 是不是不小心写了 `<local:SomeWindow>` 在另一个 Window 里?
|
||||||
|
- [ ] 是不是连续写了 Border > Grid > Border > Grid 可以合并?
|
||||||
|
|
||||||
|
### 如果审查别人代码
|
||||||
|
|
||||||
|
- [ ] 发现窗口套窗口了吗?
|
||||||
|
- [ ] 发现超过 4 层的无意义嵌套了吗?(没有注释说明原因的话)
|
||||||
|
- [ ] 发现 Fluent 和 Material 控件混在同一区域了吗?
|
||||||
|
- [ ] 发现应该用 DynamicResource 的地方硬编码了吗?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录:常见错误快速修复
|
||||||
|
|
||||||
|
| 错误现象 | 问题原因 | 修复方法 |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| 图标不显示或大小不对 | 用了错误的命名空间或硬编码尺寸 | 改用 `fi:FluentIcon` + `Classes="icon-m"` |
|
||||||
|
| 圆角在设置里改了但没生效 | 硬编码了 CornerRadius | 改用 `{DynamicResource DesignCornerRadiusComponent}` |
|
||||||
|
| 深色模式下颜色刺眼 | 硬编码了颜色值 | 改用 `{DynamicResource AdaptiveTextPrimaryBrush}` 等 |
|
||||||
|
| 设置页风格和其他窗口不一致 | 混用了 Material 控件 | 统一用 FluentAvaloniaUI 控件 |
|
||||||
|
| 性能差/渲染慢 | 嵌套太深(>6 层)| 扁平化结构,合并多余容器 |
|
||||||
|
| 弹窗显示位置/大小异常 | 把 Window 当成 UserControl 嵌套了 | 改为代码中 `.Show()` 显示 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**相关文档:**
|
||||||
|
- [VISUAL_SPEC.md](./VISUAL_SPEC.md) - 视觉规范总纲
|
||||||
|
- [CORNER_RADIUS_SPEC.md](./CORNER_RADIUS_SPEC.md) - 圆角详细规范
|
||||||
|
- AGENTS.md - AI 强制规则
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
### 当前阶段
|
### 当前阶段
|
||||||
|
|
||||||
- 产品版本:`1.0.0`
|
- 产品版本:`1.0.0`
|
||||||
- Plugin SDK API 基线:`4.0.0`
|
- Plugin SDK API 基线:`4.0.1`
|
||||||
- 当前重点:持续完善宿主体验、设置页体验、组件能力与插件生态
|
- 当前重点:持续完善宿主体验、设置页体验、组件能力与插件生态
|
||||||
- 近期需求入口:以 `.trae/specs/` 中的 feature spec 为准
|
- 近期需求入口:以 `.trae/specs/` 中的 feature spec 为准
|
||||||
|
|
||||||
@@ -59,4 +59,4 @@
|
|||||||
|
|
||||||
LanMountainDesktop is a cross-platform desktop enhancement product built with Avalonia UI and .NET 10. It targets students, office users, and customization-focused users who want a programmable desktop surface for information, tools, and plugin-driven extensions.
|
LanMountainDesktop is a cross-platform desktop enhancement product built with Avalonia UI and .NET 10. It targets students, office users, and customization-focused users who want a programmable desktop surface for information, tools, and plugin-driven extensions.
|
||||||
|
|
||||||
This repository is the source of truth for the desktop host, plugin runtime, Plugin SDK, shared contracts, and core appearance/settings infrastructure. The current product version is `1.0.0`, and the active Plugin SDK baseline in this repository is `4.0.0`.
|
This repository is the source of truth for the desktop host, plugin runtime, Plugin SDK, shared contracts, and core appearance/settings infrastructure. The current product version is `1.0.0`, and the active Plugin SDK baseline in this repository is `4.0.1`.
|
||||||
|
|||||||
Reference in New Issue
Block a user