Compare commits

...

2 Commits

Author SHA1 Message Date
lincube
91ab52ce8b change.插件sdk更新 2026-04-12 13:52:52 +08:00
lincube
4a89c2388b feat.便签组件 2026-04-12 12:14:25 +08:00
16 changed files with 952 additions and 29 deletions

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

View File

@@ -1,18 +1,53 @@
# 更新日志 / Changelog # 更新日志 / Changelog
## [0.8.3.3](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.3) - 2026-04-12
## [0.8.3.2] - 2026-04-09
### 新增 (Added) ### 新增 (Added)
-**便签组件**: 全新便签组件上线,支持 Markdown 语法
- 支持丰富的 Markdown 格式:标题、列表、加粗、斜体、代码块等
- 便签内容自动保存,方便记录和管理日常备忘。丰富信息展示途径,让作业布置也可在阑山桌面完成。
-**白板主题自适应笔色**: 白板功能新增主题自适应笔色支持
- 根据当前主题自动调整画笔颜色,确保在不同主题下都有良好的书写体验
- 深色主题下自动切换为浅色笔迹,浅色主题下使用深色笔迹
### 变更 (Changed)
- 🎨 **融合桌面设置组件库样式更新**: 优化融合桌面设置页面的组件库样式
- 提升视觉一致性和用户体验
- 统一组件风格,与整体设计语言保持协调
### 修复 (Fixed)
- 🐛 **白板无法使用问题**: 修复了白板功能无法正常使用的问题
- 问题原因: 相关依赖或初始化逻辑异常导致白板功能失效
- 修复方案: 修复了白板的依赖加载和初始化流程,恢复正常使用
- 🐛 **央官网新闻组件显示问题**: 修复了央官网新闻组件的显示异常
- 优化组件渲染逻辑,确保新闻内容正确展示
- 🐛 **课程表组件显示问题**: 修复了课程表组件的显示异常
- 优化组件布局和渲染,确保课程信息正确显示
### 移除 (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个应用的问题
- 问题原因: 文件夹弹窗未实现滚动功能,应用列表超出显示区域后被截断 - 问题原因: 文件夹弹窗未实现滚动功能,应用列表超出显示区域后被截断
- 修复方案: 为文件夹内容区域添加滚动支持,允许用户滚动查看所有应用 - 修复方案: 为文件夹内容区域添加滚动支持,允许用户滚动查看所有应用
@@ -24,13 +59,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)
-**快捷方式组件**: 新增快捷方式组件,可在阑山桌面内便捷打开系统应用与文件 -**快捷方式组件**: 新增快捷方式组件,可在阑山桌面内便捷打开系统应用与文件
- 支持创建快捷方式,统一管理应用和文件 - 支持创建快捷方式,统一管理应用和文件
- 提供单击打开和双击打开两种交互模式 - 提供单击打开和双击打开两种交互模式
@@ -38,38 +75,46 @@
- 📝 初始化更新日志文档,为后续版本发布建立基础 - 📝 初始化更新日志文档,为后续版本发布建立基础
### 变更 (Changed) ### 变更 (Changed)
- -
### 修复 (Fixed) ### 修复 (Fixed)
- -
### 移除 (Removed) ### 移除 (Removed)
- -
--- ***
所有重要的更改都将记录在此文件中。 所有重要的更改都将记录在此文件中。
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/), 格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),
并且本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 并且本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
--- ***
## [格式示例] ## \[格式示例]
### 新增 (Added) ### 新增 (Added)
- 待发布的新功能 - 待发布的新功能
### 变更 (Changed) ### 变更 (Changed)
- 待发布的变更 - 待发布的变更
### 修复 (Fixed) ### 修复 (Fixed)
- 待发布的修复 - 待发布的修复
### 移除 (Removed) ### 移除 (Removed)
- 待发布的移除项 - 待发布的移除项
--- ***
## 版本说明 ## 版本说明
### 版本号规则 ### 版本号规则
@@ -101,10 +146,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

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

View File

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

View File

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

View 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 token14px @ 1.0x
/// </summary>
Card,
/// <summary>
/// 交互控件:使用 Xs token12px @ 1.0x
/// </summary>
Control,
/// <summary>
/// 微元素徽章:使用 Micro token6px @ 1.0x
/// </summary>
Badge
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -4286,6 +4286,10 @@ public partial class MainWindow
{ {
whiteboard.ForceSaveNote(); whiteboard.ForceSaveNote();
} }
else if (contentHost?.Child is StickyNoteWidget stickyNote)
{
stickyNote.ForceSave();
}
} }
} }
} }

View File

@@ -848,6 +848,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 +861,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 +901,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