Add preview controls and settings UI tweaks

Introduce GridPreviewControl and CornerRadiusPreviewControl for visual previews and wire them into the Components settings (add ScreenAspectRatio, CornerRadiusPreviewValue, and screen aspect init). Refactor ComponentsSettingsPage UI to show live previews. Improve DataSettingsPage layout and storage bar logic (use item percentages directly, include remaining segment, adjust visuals and visibility triggers). Simplify LauncherSettingsPage header/appearance layout. Add SECURITY_AUDIT_REPORT.md, analysis summary, mockup HTML, and a local .claude settings file.
This commit is contained in:
lincube
2026-05-11 18:06:36 +08:00
parent d8f75e86be
commit f0319b7deb
12 changed files with 1560 additions and 129 deletions

View File

@@ -0,0 +1,101 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
namespace LanMountainDesktop.Controls;
public class CornerRadiusPreviewControl : Control
{
public static readonly StyledProperty<double> RadiusProperty =
AvaloniaProperty.Register<CornerRadiusPreviewControl, double>(nameof(Radius), 24);
public static readonly StyledProperty<IBrush?> ShapeBrushProperty =
AvaloniaProperty.Register<CornerRadiusPreviewControl, IBrush?>(nameof(ShapeBrush));
public static readonly StyledProperty<IBrush?> GuideBrushProperty =
AvaloniaProperty.Register<CornerRadiusPreviewControl, IBrush?>(nameof(GuideBrush));
public static readonly StyledProperty<IBrush?> FillBrushProperty =
AvaloniaProperty.Register<CornerRadiusPreviewControl, IBrush?>(nameof(FillBrush));
public double Radius
{
get => GetValue(RadiusProperty);
set => SetValue(RadiusProperty, value);
}
public IBrush? ShapeBrush
{
get => GetValue(ShapeBrushProperty);
set => SetValue(ShapeBrushProperty, value);
}
public IBrush? GuideBrush
{
get => GetValue(GuideBrushProperty);
set => SetValue(GuideBrushProperty, value);
}
public IBrush? FillBrush
{
get => GetValue(FillBrushProperty);
set => SetValue(FillBrushProperty, value);
}
static CornerRadiusPreviewControl()
{
AffectsRender<CornerRadiusPreviewControl>(
RadiusProperty,
ShapeBrushProperty,
GuideBrushProperty,
FillBrushProperty);
}
public override void Render(DrawingContext context)
{
var w = Bounds.Width;
var h = Bounds.Height;
if (w <= 0 || h <= 0) return;
var shapeBrush = ShapeBrush ?? Brushes.Gray;
var guideBrush = GuideBrush ?? Brushes.Gray;
var fillBrush = FillBrush ?? new SolidColorBrush(Colors.Gray, 0.08);
var padding = 24.0;
var maxShapeW = w - padding * 2;
var maxShapeH = h - padding * 2;
var shapeSize = Math.Min(maxShapeW, maxShapeH);
if (shapeSize < 20) return;
var ox = (w - shapeSize) / 2;
var oy = (h - shapeSize) / 2;
var r = Math.Min(Radius, shapeSize * 0.45);
r = Math.Max(0, r);
var shapeRect = new Rect(ox, oy, shapeSize, shapeSize);
var shapePen = new Pen(shapeBrush, 1.5);
var dashPen = new Pen(guideBrush, 0.75, new DashStyle([4, 3], 0));
context.DrawRectangle(fillBrush, shapePen, shapeRect, r, r);
if (r > 4)
{
var arcCenterX = ox + r;
var arcCenterY = oy + r;
context.DrawLine(
dashPen,
new Point(arcCenterX, oy + r * 0.2),
new Point(arcCenterX, oy + r * 0.9));
context.DrawLine(
dashPen,
new Point(ox + r * 0.2, arcCenterY),
new Point(ox + r * 0.9, arcCenterY));
context.DrawEllipse(null, new Pen(guideBrush, 0.75), new Point(arcCenterX, arcCenterY), 2, 2);
}
}
}

View File

@@ -0,0 +1,159 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
namespace LanMountainDesktop.Controls;
public class GridPreviewControl : Control
{
public static readonly StyledProperty<int> CellsProperty =
AvaloniaProperty.Register<GridPreviewControl, int>(nameof(Cells), 12);
public static readonly StyledProperty<double> AspectRatioProperty =
AvaloniaProperty.Register<GridPreviewControl, double>(nameof(AspectRatio), 16.0 / 9.0);
public static readonly StyledProperty<int> EdgeInsetPercentProperty =
AvaloniaProperty.Register<GridPreviewControl, int>(nameof(EdgeInsetPercent), 0);
public static readonly StyledProperty<IBrush?> GridBrushProperty =
AvaloniaProperty.Register<GridPreviewControl, IBrush?>(nameof(GridBrush));
public static readonly StyledProperty<IBrush?> ScreenBorderBrushProperty =
AvaloniaProperty.Register<GridPreviewControl, IBrush?>(nameof(ScreenBorderBrush));
public static readonly StyledProperty<IBrush?> InsetBrushProperty =
AvaloniaProperty.Register<GridPreviewControl, IBrush?>(nameof(InsetBrush));
public int Cells
{
get => GetValue(CellsProperty);
set => SetValue(CellsProperty, value);
}
public double AspectRatio
{
get => GetValue(AspectRatioProperty);
set => SetValue(AspectRatioProperty, value);
}
public int EdgeInsetPercent
{
get => GetValue(EdgeInsetPercentProperty);
set => SetValue(EdgeInsetPercentProperty, value);
}
public IBrush? GridBrush
{
get => GetValue(GridBrushProperty);
set => SetValue(GridBrushProperty, value);
}
public IBrush? ScreenBorderBrush
{
get => GetValue(ScreenBorderBrushProperty);
set => SetValue(ScreenBorderBrushProperty, value);
}
public IBrush? InsetBrush
{
get => GetValue(InsetBrushProperty);
set => SetValue(InsetBrushProperty, value);
}
static GridPreviewControl()
{
AffectsRender<GridPreviewControl>(
CellsProperty,
AspectRatioProperty,
EdgeInsetPercentProperty,
GridBrushProperty,
ScreenBorderBrushProperty,
InsetBrushProperty);
}
public override void Render(DrawingContext context)
{
var w = Bounds.Width;
var h = Bounds.Height;
if (w <= 0 || h <= 0) return;
var ratio = AspectRatio > 0 ? AspectRatio : 16.0 / 9.0;
double screenW, screenH;
if (w / h > ratio)
{
screenH = h;
screenW = h * ratio;
}
else
{
screenW = w;
screenH = w / ratio;
}
var offsetX = (w - screenW) / 2;
var offsetY = (h - screenH) / 2;
var borderBrush = ScreenBorderBrush ?? Brushes.Gray;
var gridBrush = GridBrush ?? Brushes.Gray;
var insetBrush = InsetBrush ?? new SolidColorBrush(Colors.Gray, 0.12);
var borderThickness = 1.5;
var borderPen = new Pen(borderBrush, borderThickness);
var cells = Math.Max(1, Cells);
var cellSize = screenW / cells;
var rows = (int)Math.Floor(screenH / cellSize);
var insetPercent = Math.Clamp(EdgeInsetPercent, 0, 30);
var insetRatio = insetPercent / 100d;
var insetPx = Math.Min(cellSize * insetRatio, screenH * 0.15);
var contentX = offsetX + insetPx;
var contentY = offsetY + insetPx;
var contentW = Math.Max(1, screenW - insetPx * 2);
var contentH = Math.Max(1, screenH - insetPx * 2);
var screenRect = new Rect(offsetX, offsetY, screenW, screenH);
var contentRect = new Rect(contentX, contentY, contentW, contentH);
var cornerRadius = Math.Min(6, Math.Min(screenW, screenH) * 0.03);
using (context.PushClip(screenRect))
{
if (insetPx > 0.5)
{
context.DrawRectangle(insetBrush, null, screenRect, cornerRadius, cornerRadius);
context.DrawRectangle(
new SolidColorBrush(Colors.Transparent),
new Pen(borderBrush, 0.75, new DashStyle([3, 3], 0)),
contentRect,
cornerRadius * 0.6,
cornerRadius * 0.6);
}
var dashSegment = Math.Max(2, cellSize * 0.25);
var dashPen = new Pen(gridBrush, 1.0, new DashStyle([dashSegment, dashSegment], 0));
for (var col = 1; col < cells; col++)
{
var x = contentX + col * (contentW / cells);
if (x < contentX + contentW)
{
context.DrawLine(dashPen, new Point(x, contentY), new Point(x, contentY + contentH));
}
}
for (var row = 1; row < rows; row++)
{
var y = contentY + row * (contentH / rows);
if (y < contentY + contentH)
{
context.DrawLine(dashPen, new Point(contentX, y), new Point(contentX + contentW, y));
}
}
}
var adjustedRect = screenRect.Deflate(borderThickness / 2);
context.DrawRectangle(null, borderPen, adjustedRect, cornerRadius, cornerRadius);
}
}

View File

@@ -16,6 +16,7 @@ using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Appearance;
using LanMountainDesktop.Settings.Core;
using LanMountainDesktop.Shared.Contracts.Launcher;
@@ -887,6 +888,9 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private int _shortSideCells;
[ObservableProperty]
private double _screenAspectRatio = 16.0 / 9.0;
[ObservableProperty]
private int _edgeInsetPercent;
@@ -914,6 +918,9 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private string _cornerRadiusStyle = GlobalAppearanceSettings.DefaultCornerRadiusStyle;
[ObservableProperty]
private double _cornerRadiusPreviewValue = 24;
[ObservableProperty]
private IReadOnlyList<SelectionOption> _cornerRadiusStyleOptions = [];
@@ -947,6 +954,7 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
SelectedCornerRadiusStyle = CornerRadiusStyleOptions.FirstOrDefault(option =>
string.Equals(option.Value, CornerRadiusStyle, StringComparison.OrdinalIgnoreCase))
?? CornerRadiusStyleOptions.FirstOrDefault(o => o.Value == GlobalAppearanceSettings.DefaultCornerRadiusStyle);
CornerRadiusPreviewValue = AppearanceCornerRadiusTokenFactory.Create(CornerRadiusStyle).Component.TopLeft;
}
partial void OnShortSideCellsChanged(int value)
@@ -987,6 +995,7 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
}
CornerRadiusStyle = value.Value;
CornerRadiusPreviewValue = AppearanceCornerRadiusTokenFactory.Create(value.Value).Component.TopLeft;
SaveComponentCornerRadius();
}

View File

@@ -1,4 +1,4 @@
<UserControl xmlns="https://github.com/avaloniaui"
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LanMountainDesktop.ViewModels"
xmlns:controls="using:LanMountainDesktop.Controls"
@@ -8,6 +8,32 @@
x:DataType="vm:ComponentsSettingsPageViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Classes="settings-page-container settings-page-animated">
<Grid ColumnDefinitions="*,Auto"
Height="180"
Margin="0,0,0,16">
<controls:GridPreviewControl Cells="{Binding ShortSideCells}"
AspectRatio="{Binding ScreenAspectRatio}"
EdgeInsetPercent="{Binding EdgeInsetPercent}"
GridBrush="{DynamicResource TextFillColorTertiaryBrush}"
ScreenBorderBrush="{DynamicResource TextFillColorSecondaryBrush}"
InsetBrush="{DynamicResource ControlFillColorSecondaryBrush}"
Margin="0,0,16,0" />
<StackPanel Grid.Column="1"
Width="140"
Spacing="4">
<controls:CornerRadiusPreviewControl Radius="{Binding CornerRadiusPreviewValue}"
ShapeBrush="{DynamicResource AccentFillColorDefaultBrush}"
GuideBrush="{DynamicResource TextFillColorTertiaryBrush}"
FillBrush="{DynamicResource AccentFillColorDefaultBrush}"
Height="120" />
<TextBlock Text="{Binding CornerRadiusPreviewValue, StringFormat='{}{0}px'}"
FontSize="11"
Opacity="0.65"
HorizontalAlignment="Center"
FontFamily="Consolas, Courier New" />
</StackPanel>
</Grid>
<controls:IconText Icon="Apps"
Text="{Binding ComponentsHeader}"
Margin="0,0,0,4" />

View File

@@ -1,3 +1,6 @@
using System.Linq;
using Avalonia.Controls;
using Avalonia.Platform;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.ViewModels;
@@ -24,6 +27,26 @@ public partial class ComponentsSettingsPage : SettingsPageBase
ViewModel = viewModel;
DataContext = ViewModel;
InitializeComponent();
InitScreenAspectRatio();
}
private void InitScreenAspectRatio()
{
try
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null) return;
var screen = topLevel.Screens.Primary ?? topLevel.Screens.All.FirstOrDefault();
if (screen is not null && screen.Bounds.Height > 0)
{
ViewModel.ScreenAspectRatio = (double)screen.Bounds.Width / screen.Bounds.Height;
}
}
catch
{
// 无法获取屏幕信息时保持默认 16:9
}
}
public ComponentsSettingsPageViewModel ViewModel { get; }

View File

@@ -28,77 +28,74 @@
</ui:FASettingsExpander.Footer>
<ui:FASettingsExpanderItem>
<StackPanel Spacing="16">
<Border Height="22"
Background="{DynamicResource ControlFillColorTertiaryBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
ClipToBounds="True"
IsVisible="{Binding HasData}">
<Grid x:Name="StorageBarGrid" />
</Border>
<ProgressBar Height="8"
Minimum="0"
Maximum="100"
IsIndeterminate="{Binding IsScanning}"
Value="{Binding DiskUsagePercentage}" />
<Grid ColumnDefinitions="*,Auto"
ColumnSpacing="16">
<StackPanel Spacing="4">
<TextBlock Text="{Binding TotalSizeText}"
FontSize="22"
FontWeight="SemiBold" />
<TextBlock Text="{Binding DiskUsageText}"
<StackPanel Spacing="14">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Spacing="2">
<TextBlock Text="总占用"
FontSize="13"
Opacity="0.72" />
Opacity="0.65" />
<TextBlock Text="{Binding TotalSizeText}"
FontSize="26"
FontWeight="SemiBold" />
</StackPanel>
<StackPanel Grid.Column="1"
Spacing="2"
HorizontalAlignment="Right">
<TextBlock Text="占磁盘比例"
FontSize="13"
Opacity="0.65"
HorizontalAlignment="Right" />
<TextBlock Text="{Binding DiskUsageText}"
FontSize="18"
FontWeight="SemiBold"
HorizontalAlignment="Right" />
</StackPanel>
</Grid>
<Border Height="14"
Background="{DynamicResource ControlFillColorTertiaryBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
ClipToBounds="True">
<Grid x:Name="StorageBarGrid" />
</Border>
<TextBlock Text="正在扫描…"
FontSize="13"
Opacity="0.72"
Opacity="0.65"
IsVisible="{Binding IsScanning}" />
<TextBlock Text="暂无可用数据。完成首次扫描后将显示占比与图例。"
FontSize="13"
Opacity="0.72"
Opacity="0.65"
TextWrapping="Wrap"
IsVisible="{Binding !HasData}" />
<StackPanel Spacing="10"
IsVisible="{Binding HasData}">
<TextBlock Classes="settings-item-label"
Text="分类图例" />
<ItemsControl ItemsSource="{Binding Items}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:DataStorageItemViewModel">
<StackPanel Orientation="Horizontal"
Spacing="8"
VerticalAlignment="Center"
Margin="0,0,16,8">
<Ellipse Width="10"
Height="10"
VerticalAlignment="Center"
Fill="{Binding ColorHex, Converter={StaticResource HexToBrushConverter}}" />
<TextBlock Text="{Binding Name}"
FontSize="13"
VerticalAlignment="Center"
Opacity="0.85" />
<TextBlock Text="{Binding Percentage, StringFormat={}{0:F1}%}"
FontSize="12"
VerticalAlignment="Center"
Opacity="0.65" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<ItemsControl ItemsSource="{Binding Items}"
IsVisible="{Binding HasData}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:DataStorageItemViewModel">
<StackPanel Orientation="Horizontal"
Spacing="6"
VerticalAlignment="Center"
Margin="0,0,14,6">
<Border Width="10"
Height="10"
CornerRadius="{DynamicResource DesignCornerRadiusMicro}"
VerticalAlignment="Center"
Background="{Binding ColorHex, Converter={StaticResource HexToBrushConverter}}" />
<TextBlock Text="{Binding Name}"
FontSize="12"
VerticalAlignment="Center"
Opacity="0.85" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ui:FASettingsExpanderItem>
</ui:FASettingsExpander>

View File

@@ -55,7 +55,8 @@ public partial class DataSettingsPage : SettingsPageBase
return;
}
if (string.Equals(e.PropertyName, nameof(DataSettingsPageViewModel.HasData), StringComparison.Ordinal))
if (string.Equals(e.PropertyName, nameof(DataSettingsPageViewModel.HasData), StringComparison.Ordinal) ||
string.Equals(e.PropertyName, nameof(DataSettingsPageViewModel.DiskUsagePercentage), StringComparison.Ordinal))
{
RebuildStorageBar();
}
@@ -80,43 +81,31 @@ public partial class DataSettingsPage : SettingsPageBase
StorageBarGrid.ColumnDefinitions.Clear();
StorageBarGrid.Children.Clear();
if (!ViewModel.HasData)
{
return;
}
var visibleItems = ViewModel.Items
.Where(item => item.Percentage > 0)
.OrderByDescending(item => item.Percentage)
.ToList();
if (visibleItems.Count == 0)
{
return;
}
var totalPercent = visibleItems.Sum(item => item.Percentage);
if (totalPercent <= 0)
{
return;
}
var idx = 0;
foreach (var item in visibleItems)
{
var normalized = Math.Max(0.1, item.Percentage / totalPercent * 100d);
StorageBarGrid.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(normalized, GridUnitType.Star)));
var width = Math.Max(0.1, item.Percentage);
StorageBarGrid.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(width, GridUnitType.Star)));
var segment = new Border
{
Background = ParseBrush(item.ColorHex),
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Stretch
};
Grid.SetColumn(segment, idx++);
StorageBarGrid.Children.Add(segment);
}
var remaining = 100d - ViewModel.DiskUsagePercentage;
if (remaining > 0)
{
StorageBarGrid.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(remaining, GridUnitType.Star)));
}
}
private IBrush ParseBrush(string? hex)

View File

@@ -1,58 +1,21 @@
<UserControl xmlns="https://github.com/avaloniaui"
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LanMountainDesktop.ViewModels"
xmlns:controls="using:LanMountainDesktop.Controls"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:fi="using:FluentIcons.Avalonia"
xmlns:symbol="using:FluentIcons.Common"
x:Class="LanMountainDesktop.Views.SettingsPages.LauncherSettingsPage"
x:DataType="vm:LauncherSettingsPageViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Classes="settings-page-container settings-page-animated">
<Border Classes="settings-section-card">
<Grid ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="18">
<Border Classes="settings-section-card-icon-host"
Width="72"
Height="72"
Padding="10">
<fi:SymbolIcon Symbol="Apps"
FontSize="34"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<controls:IconText Icon="Apps"
Text="{Binding LauncherHeader}"
Margin="0,0,0,4" />
<TextBlock Classes="settings-item-description"
Margin="0,0,0,16"
Text="{Binding LauncherSubtitle}" />
<StackPanel Grid.Column="1"
Spacing="4"
VerticalAlignment="Center">
<TextBlock Classes="settings-card-header"
Text="{Binding LauncherHeader}" />
<TextBlock Classes="settings-card-description"
Text="{Binding LauncherSubtitle}" />
<TextBlock Classes="settings-item-description"
Margin="0,10,0,0"
Text="{Binding HiddenHint}"
TextWrapping="Wrap" />
</StackPanel>
<StackPanel Grid.Column="2"
Spacing="4"
HorizontalAlignment="Right"
VerticalAlignment="Center">
<TextBlock Classes="settings-section-title"
FontSize="28"
HorizontalAlignment="Right"
Margin="0"
Text="{Binding HiddenCountText}" />
<TextBlock Classes="settings-item-description"
HorizontalAlignment="Right"
Text="{Binding HiddenSummary}" />
</StackPanel>
</Grid>
</Border>
<ui:FASettingsExpander Classes="settings-expander-card"
Header="{Binding AppearanceHeader}"
<ui:FASettingsExpander Header="{Binding AppearanceHeader}"
Description="{Binding AppearanceDescription}"
IsExpanded="True">
<ui:FASettingsExpander.IconSource>
@@ -71,11 +34,16 @@
</ui:FASettingsExpanderItem>
</ui:FASettingsExpander>
<ui:FASettingsExpander Classes="settings-expander-card"
Margin="0,24,0,14"
Header="{Binding HiddenHeader}"
<Separator Classes="settings-separator" />
<ui:FASettingsExpander Header="{Binding HiddenHeader}"
Description="{Binding HiddenDescription}"
IsExpanded="True">
<ui:FASettingsExpander.Footer>
<TextBlock Classes="settings-item-description"
Text="{Binding HiddenCountText}"
VerticalAlignment="Center" />
</ui:FASettingsExpander.Footer>
<ui:FASettingsExpander.IconSource>
<ui:FAFontIconSource Glyph="&#xF1C80;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FASettingsExpander.IconSource>