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,8 @@
{
"permissions": {
"allow": [
"Bash(ls -la \"/d/github/LanMountainDesktop/.claude/worktrees/agent-a4c5412322421ab67\" && ls -la \"/d/github/LanMountainDesktop\" && ls -la \"/d/github\")",
"Read(//d/github/**)"
]
}
}

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.PluginSdk;
using LanMountainDesktop.Services; using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings; using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Appearance;
using LanMountainDesktop.Settings.Core; using LanMountainDesktop.Settings.Core;
using LanMountainDesktop.Shared.Contracts.Launcher; using LanMountainDesktop.Shared.Contracts.Launcher;
@@ -887,6 +888,9 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
private int _shortSideCells; private int _shortSideCells;
[ObservableProperty]
private double _screenAspectRatio = 16.0 / 9.0;
[ObservableProperty] [ObservableProperty]
private int _edgeInsetPercent; private int _edgeInsetPercent;
@@ -914,6 +918,9 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
private string _cornerRadiusStyle = GlobalAppearanceSettings.DefaultCornerRadiusStyle; private string _cornerRadiusStyle = GlobalAppearanceSettings.DefaultCornerRadiusStyle;
[ObservableProperty]
private double _cornerRadiusPreviewValue = 24;
[ObservableProperty] [ObservableProperty]
private IReadOnlyList<SelectionOption> _cornerRadiusStyleOptions = []; private IReadOnlyList<SelectionOption> _cornerRadiusStyleOptions = [];
@@ -947,6 +954,7 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
SelectedCornerRadiusStyle = CornerRadiusStyleOptions.FirstOrDefault(option => SelectedCornerRadiusStyle = CornerRadiusStyleOptions.FirstOrDefault(option =>
string.Equals(option.Value, CornerRadiusStyle, StringComparison.OrdinalIgnoreCase)) string.Equals(option.Value, CornerRadiusStyle, StringComparison.OrdinalIgnoreCase))
?? CornerRadiusStyleOptions.FirstOrDefault(o => o.Value == GlobalAppearanceSettings.DefaultCornerRadiusStyle); ?? CornerRadiusStyleOptions.FirstOrDefault(o => o.Value == GlobalAppearanceSettings.DefaultCornerRadiusStyle);
CornerRadiusPreviewValue = AppearanceCornerRadiusTokenFactory.Create(CornerRadiusStyle).Component.TopLeft;
} }
partial void OnShortSideCellsChanged(int value) partial void OnShortSideCellsChanged(int value)
@@ -987,6 +995,7 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
} }
CornerRadiusStyle = value.Value; CornerRadiusStyle = value.Value;
CornerRadiusPreviewValue = AppearanceCornerRadiusTokenFactory.Create(value.Value).Component.TopLeft;
SaveComponentCornerRadius(); 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:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LanMountainDesktop.ViewModels" xmlns:vm="using:LanMountainDesktop.ViewModels"
xmlns:controls="using:LanMountainDesktop.Controls" xmlns:controls="using:LanMountainDesktop.Controls"
@@ -8,6 +8,32 @@
x:DataType="vm:ComponentsSettingsPageViewModel"> x:DataType="vm:ComponentsSettingsPageViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto"> <ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Classes="settings-page-container settings-page-animated"> <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" <controls:IconText Icon="Apps"
Text="{Binding ComponentsHeader}" Text="{Binding ComponentsHeader}"
Margin="0,0,0,4" /> 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.PluginSdk;
using LanMountainDesktop.Services.Settings; using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.ViewModels; using LanMountainDesktop.ViewModels;
@@ -24,6 +27,26 @@ public partial class ComponentsSettingsPage : SettingsPageBase
ViewModel = viewModel; ViewModel = viewModel;
DataContext = ViewModel; DataContext = ViewModel;
InitializeComponent(); 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; } public ComponentsSettingsPageViewModel ViewModel { get; }

View File

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

View File

@@ -55,7 +55,8 @@ public partial class DataSettingsPage : SettingsPageBase
return; 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(); RebuildStorageBar();
} }
@@ -80,43 +81,31 @@ public partial class DataSettingsPage : SettingsPageBase
StorageBarGrid.ColumnDefinitions.Clear(); StorageBarGrid.ColumnDefinitions.Clear();
StorageBarGrid.Children.Clear(); StorageBarGrid.Children.Clear();
if (!ViewModel.HasData)
{
return;
}
var visibleItems = ViewModel.Items var visibleItems = ViewModel.Items
.Where(item => item.Percentage > 0) .Where(item => item.Percentage > 0)
.OrderByDescending(item => item.Percentage) .OrderByDescending(item => item.Percentage)
.ToList(); .ToList();
if (visibleItems.Count == 0)
{
return;
}
var totalPercent = visibleItems.Sum(item => item.Percentage);
if (totalPercent <= 0)
{
return;
}
var idx = 0; var idx = 0;
foreach (var item in visibleItems) foreach (var item in visibleItems)
{ {
var normalized = Math.Max(0.1, item.Percentage / totalPercent * 100d); var width = Math.Max(0.1, item.Percentage);
StorageBarGrid.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(normalized, GridUnitType.Star))); StorageBarGrid.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(width, GridUnitType.Star)));
var segment = new Border var segment = new Border
{ {
Background = ParseBrush(item.ColorHex), Background = ParseBrush(item.ColorHex),
HorizontalAlignment = HorizontalAlignment.Stretch, HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Stretch VerticalAlignment = VerticalAlignment.Stretch
}; };
Grid.SetColumn(segment, idx++); Grid.SetColumn(segment, idx++);
StorageBarGrid.Children.Add(segment); 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) 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:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LanMountainDesktop.ViewModels" xmlns:vm="using:LanMountainDesktop.ViewModels"
xmlns:controls="using:LanMountainDesktop.Controls"
xmlns:ui="using:FluentAvalonia.UI.Controls" xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:fi="using:FluentIcons.Avalonia"
xmlns:symbol="using:FluentIcons.Common"
x:Class="LanMountainDesktop.Views.SettingsPages.LauncherSettingsPage" x:Class="LanMountainDesktop.Views.SettingsPages.LauncherSettingsPage"
x:DataType="vm:LauncherSettingsPageViewModel"> x:DataType="vm:LauncherSettingsPageViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto"> <ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Classes="settings-page-container settings-page-animated"> <StackPanel Classes="settings-page-container settings-page-animated">
<Border Classes="settings-section-card"> <controls:IconText Icon="Apps"
<Grid ColumnDefinitions="Auto,*,Auto" Text="{Binding LauncherHeader}"
ColumnSpacing="18"> Margin="0,0,0,4" />
<Border Classes="settings-section-card-icon-host" <TextBlock Classes="settings-item-description"
Width="72" Margin="0,0,0,16"
Height="72" Text="{Binding LauncherSubtitle}" />
Padding="10">
<fi:SymbolIcon Symbol="Apps"
FontSize="34"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<StackPanel Grid.Column="1" <ui:FASettingsExpander Header="{Binding AppearanceHeader}"
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}"
Description="{Binding AppearanceDescription}" Description="{Binding AppearanceDescription}"
IsExpanded="True"> IsExpanded="True">
<ui:FASettingsExpander.IconSource> <ui:FASettingsExpander.IconSource>
@@ -71,11 +34,16 @@
</ui:FASettingsExpanderItem> </ui:FASettingsExpanderItem>
</ui:FASettingsExpander> </ui:FASettingsExpander>
<ui:FASettingsExpander Classes="settings-expander-card" <Separator Classes="settings-separator" />
Margin="0,24,0,14"
Header="{Binding HiddenHeader}" <ui:FASettingsExpander Header="{Binding HiddenHeader}"
Description="{Binding HiddenDescription}" Description="{Binding HiddenDescription}"
IsExpanded="True"> IsExpanded="True">
<ui:FASettingsExpander.Footer>
<TextBlock Classes="settings-item-description"
Text="{Binding HiddenCountText}"
VerticalAlignment="Center" />
</ui:FASettingsExpander.Footer>
<ui:FASettingsExpander.IconSource> <ui:FASettingsExpander.IconSource>
<ui:FAFontIconSource Glyph="&#xF1C80;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" /> <ui:FAFontIconSource Glyph="&#xF1C80;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FASettingsExpander.IconSource> </ui:FASettingsExpander.IconSource>

196
SECURITY_AUDIT_REPORT.md Normal file
View File

@@ -0,0 +1,196 @@
# 安全审计报告
**项目**: LanMountainDesktop
**审计日期**: 2026-05-11
**审计范围**: 整体代码库安全性评估
**审计方法**: 自动化静态代码分析 + 架构审查
---
## 执行摘要
本次审计对 LanMountainDesktop 代码库进行了系统性安全评估,重点关注认证与访问控制、注入向量、外部交互以及敏感数据处理等高风险攻击面。
**审计结论**: 发现 **4 个已确认的中等及以上严重度漏洞**,建议立即修复。
---
## 已确认漏洞
### 漏洞 #1 - PostHog API Key 硬编码(高严重度)
| 属性 | 详情 |
|------|------|
| **严重度** | 高 |
| **CWE** | CWE-798 - 使用硬编码凭证 |
| **位置** | `LanMountainDesktop/Services/PostHogUsageTelemetryService.cs:14` |
| **攻击者画像** | 源代码仓库的任何访问者(包括外部攻击者通过代码泄露或供应链攻击) |
| **可控输入** | 无(静态硬编码密钥) |
**代码路径**:
```csharp
// PostHogUsageTelemetryService.cs:14
private const string PostHogApiKey = "phc_bhQZvKDDfsEdLT6kkRFvrWMT8Pc5aCGGsnxoc5ijSf9";
```
**影响**:
- 攻击者可能滥用此 API Key 向 PostHog 项目发送伪造遥测数据
- 可能导致遥测数据污染或服务滥用
- API Key 暴露在公开仓库中,任何人都能获取
**修复建议**:
```csharp
private const string PostHogApiKey = Environment.GetEnvironmentVariable("POSTHOG_API_KEY")
?? throw new InvalidOperationException("PostHog API key not configured.");
```
---
### 漏洞 #2 - Sentry DSN 硬编码(高严重度)
| 属性 | 详情 |
|------|------|
| **严重度** | 高 |
| **CWE** | CWE-798 - 使用硬编码凭证 |
| **位置** | `LanMountainDesktop/Services/SentryCrashTelemetryService.cs:15` |
| **攻击者画像** | 源代码仓库的任何访问者 |
| **可控输入** | 无(静态硬编码密钥) |
**代码路径**:
```csharp
// SentryCrashTelemetryService.cs:15
private const string SentryDsn = "https://f2aad3a1c63b5f2213ad82683ce93c06@o4511049423257600.ingest.us.sentry.io/4511049425813504";
```
**影响**:
- Sentry DSN 等同于项目的访问凭证
- 攻击者可利用此 DSN 向项目发送伪造崩溃报告
- 可能导致崩溃数据污染或敏感信息收集
**修复建议**:
```csharp
private const string SentryDsn = Environment.GetEnvironmentVariable("SENTRY_DSN")
?? throw new InvalidOperationException("Sentry DSN not configured.");
```
---
### 漏洞 #3 - 小米天气 API 签名密钥硬编码(高严重度)
| 属性 | 详情 |
|------|------|
| **严重度** | 高 |
| **CWE** | CWE-798 - 使用硬编码凭证 |
| **位置** | `LanMountainDesktop/Services/XiaomiWeatherService.cs:25` |
| **攻击者画像** | 源代码仓库的任何访问者 |
| **可控输入** | 无(静态硬编码密钥) |
**代码路径**:
```csharp
// XiaomiWeatherService.cs:25
public string Sign { get; init; } = "zUFJoAR2ZVrDy1vF3D07";
```
**影响**:
- 第三方 API 凭证暴露在公开仓库
- 可能导致天气服务被滥用
- 如密钥有权限限制,攻击者可能突破限制
**修复建议**:
```csharp
public string Sign { get; init; } = Environment.GetEnvironmentVariable("XIAOMI_WEATHER_SIGN") ?? "";
```
---
### 漏洞 #4 - Sentry PII 收集配置(中等严重度)
| 属性 | 详情 |
|------|------|
| **严重度** | 中等 |
| **CWE** | CWE-359 - 个人身份信息PII意外暴露 |
| **位置** | `LanMountainDesktop/Services/SentryCrashTelemetryService.cs:212` |
| **攻击者画像** | Sentry 后端管理员、内部威胁或数据泄露事件 |
| **可控输入** | 用户环境的机器名、用户名等系统信息 |
| **利用路径** | `程序启动 → TelemetryIdentityService.Initialize()` → 遥测数据上报 |
**代码路径**:
```csharp
// SentryCrashTelemetryService.cs:212
options.SendDefaultPii = true;
```
**影响**:
- `SendDefaultPii = true` 配置会收集和上报用户 IP 地址
- 可能违反隐私法规(如 GDPR要求
- 在崩溃报告中可能暴露用户敏感信息
**修复建议**:
```csharp
options.SendDefaultPii = false; // 默认收集 PII
options.SendDefaultPii = TelemetryEnvironmentInfo.IsTelemetryPiiAllowed(); // 或根据用户同意状态动态设置
```
---
## 未发现漏洞的区域
经过系统性审计,以下区域未发现中等及以上严重度的已确认漏洞:
### 认证与访问控制
- 单实例服务实现正确(使用互斥体)
- IPC 通信使用命名管道,无明显认证绕过风险
- 插件隔离使用独立进程边界
### 注入向量
- SQLite 使用参数化查询,无 SQL 注入风险
- JSON 反序列化使用强类型上下文,无反序列化漏洞
- 文件路径操作使用 `Path.Combine`,有基本的路径遍历防护
- 未发现命令执行注入
### 外部交互
- HTTP 请求正确使用 `HttpClient` 和超时配置
- Webhook/回调 URL 使用 `Uri.EscapeDataString` 编码
- 下载服务验证目标路径,无路径遍历风险
### 敏感数据处理
- 数据库本地存储,使用 WAL 模式
- 设置数据通过 JSON 序列化存储在用户目录
- 日志文件路径正确隔离在应用数据目录
---
## 架构安全评估
| 组件 | 安全评级 | 说明 |
|------|----------|------|
| 插件系统 | 良好 | 使用独立进程隔离 |
| IPC 通信 | 良好 | 命名管道通信,进程边界隔离 |
| 更新系统 | 良好 | 支持签名验证 |
| 遥测系统 | **需改进** | 存在硬编码凭证和 PII 配置问题 |
| 数据存储 | 良好 | 使用标准加密实践 |
---
## 修复优先级
| 优先级 | 漏洞 | 预计工作量 |
|--------|------|------------|
| P0 - 紧急 | #1 PostHog API Key | 低 |
| P0 - 紧急 | #2 Sentry DSN | 低 |
| P0 - 紧急 | #3 Xiaomi Weather Sign | 低 |
| P1 - 高 | #4 SendDefaultPii | 低 |
---
## 建议的安全改进
1. **实施密钥管理**: 使用环境变量或密钥管理服务(如 Azure Key Vault、AWS Secrets Manager存储所有 API 凭证
2. **添加密钥扫描**: 在 CI/CD 流程中集成 secrets scanning如 GitGuardian、trufflehog
3. **隐私合规审查**: 确认遥测数据收集符合当地隐私法规要求
4. **代码审计**: 建议进行定期安全审计
---
*报告生成工具: 自动安全审计系统*
*审计方法: 静态代码分析 + 架构审查*

View File

@@ -0,0 +1,57 @@
# Git 提交分析报告 - 2026-05-11
## 摘要
**日期**: 2026-05-11
**新提交数量**: 0
**状态**: ⚠️ 无新提交
---
## 详细说明
今天2026-05-11没有新的 Git 提交记录。
### 最近一次提交信息
- **提交哈希**: `d8f75e86be9054b29303dec01ec434ccb4db2b7f`
- **作者**: lincube <lincube3@hotmail.com>
- **提交时间**: 2026-05-07 21:39:21 +0800
- **提交信息**: Add IPC backoff/retries and safer disposal
### 仓库状态
当前分支:`setting`
分支状态:与 `origin/setting` 保持同步
### 待提交更改
当前工作目录中存在以下未提交的更改:
**已修改文件**:
- LanMountainDesktop/ViewModels/SettingsViewModels.cs
- LanMountainDesktop/Views/SettingsPages/ComponentsSettingsPage.axaml
- LanMountainDesktop/Views/SettingsPages/ComponentsSettingsPage.axaml.cs
- LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml
- LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml.cs
- LanMountainDesktop/Views/SettingsPages/LauncherSettingsPage.axaml
**未跟踪文件**:
- LanMountainDesktop/Controls/CornerRadiusPreviewControl.cs
- LanMountainDesktop/Controls/GridPreviewControl.cs
- SECURITY_AUDIT_REPORT.md
- mockup-noise-level.html
---
## 下一步建议
1. 如果有未提交的更改,请先提交
2. 推送更改到远程仓库
3. 明天再次运行此分析脚本
## 报告生成信息
- **生成时间**: 2026-05-11
- **分析工具**: Git Commit Analyzer
- **输出目录**: docs/auto_commit_md/

898
mockup-noise-level.html Normal file
View File

@@ -0,0 +1,898 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>噪音等级组件改造 Mockup v2</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;600;700&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--quiet: #34D399;
--normal: #60A5FA;
--noisy: #F59E0B;
--extreme: #EF4444;
--bg: #0B1220;
--surface: #131D2E;
--surface-raised: #182438;
--border: rgba(255,255,255,0.08);
--text-primary: #EFF3FF;
--text-secondary: #8B9BB8;
--text-muted: #5C6D86;
--radius-component: 24px;
--radius-xs: 12px;
}
body {
font-family: 'Noto Sans SC', -apple-system, sans-serif;
background: var(--bg);
color: var(--text-primary);
min-height: 100vh;
padding: 40px 20px;
}
.page-header {
text-align: center;
margin-bottom: 48px;
}
.page-header h1 {
font-size: 28px;
font-weight: 700;
letter-spacing: -0.5px;
margin-bottom: 8px;
}
.page-header p {
font-size: 14px;
color: var(--text-secondary);
}
.section {
max-width: 1200px;
margin: 0 auto 56px;
}
.section-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 6px;
color: var(--text-primary);
}
.section-desc {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 24px;
line-height: 1.6;
}
.mockup-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 24px;
}
.widget-card {
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: var(--radius-component);
padding: 16px 14px;
position: relative;
overflow: hidden;
animation: fadeIn 0.5s ease both;
}
.widget-card:nth-child(2) { animation-delay: 0.08s; }
.widget-label {
position: absolute;
top: -1px;
right: 20px;
background: rgba(96, 165, 250, 0.2);
border: 1px solid rgba(96, 165, 250, 0.3);
border-top: none;
border-radius: 0 0 8px 8px;
padding: 3px 10px;
font-size: 11px;
font-weight: 600;
color: #8BE8FF;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* ====== 通用组件样式 ====== */
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.chart-title {
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
}
.chart-badge {
padding: 3px 8px;
border-radius: var(--radius-xs);
border: 1px solid rgba(255,255,255,0.3);
font-size: 12px;
font-weight: 600;
color: #fff;
transition: background 0.4s ease;
}
.chart-area {
position: relative;
height: 170px;
border-radius: 8px;
overflow: hidden;
}
.y-axis {
position: absolute;
left: 0;
top: 0;
bottom: 22px;
width: 52px;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 4px 0;
z-index: 3;
}
.y-label {
font-size: 10px;
padding-left: 2px;
color: var(--text-muted);
transition: color 0.4s ease, font-weight 0.3s ease, opacity 0.4s ease;
opacity: 0.45;
}
.y-label.active {
font-weight: 600;
opacity: 1;
}
.y-label.active.l-quiet { color: var(--quiet); }
.y-label.active.l-normal { color: var(--normal); }
.y-label.active.l-noisy { color: var(--noisy); }
.y-label.active.l-extreme { color: var(--extreme); }
.curve-area {
position: absolute;
left: 56px;
right: 0;
top: 0;
bottom: 22px;
}
.curve-area svg { width: 100%; height: 100%; }
.x-axis {
position: absolute;
left: 56px;
right: 0;
bottom: 0;
height: 22px;
display: flex;
justify-content: space-between;
align-items: center;
}
.x-label {
font-size: 10px;
color: var(--text-muted);
}
/* ====== 聚光灯渐变核心 ====== */
.spotlight-layer {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 1;
transition: opacity 0.5s ease;
}
.spotlight-glow {
position: absolute;
left: 0;
right: 0;
height: 55%;
border-radius: 50%;
filter: blur(20px);
opacity: 0;
transition: top 0.6s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.5s ease,
background 0.5s ease,
height 0.5s ease;
}
.spotlight-glow.active {
opacity: 1;
}
/* 分隔线 - 等级边界 */
.level-divider {
position: absolute;
left: 52px;
right: 0;
height: 1px;
background: rgba(255,255,255,0.04);
z-index: 2;
}
/* ====== 指示条 ====== */
.indicator-track {
position: absolute;
right: 6px;
top: 8px;
bottom: 30px;
width: 3px;
border-radius: 1.5px;
background: rgba(255,255,255,0.04);
z-index: 4;
}
.indicator-dot {
position: absolute;
right: 2px;
width: 11px;
height: 11px;
border-radius: 50%;
transform: translate(50%, -50%);
z-index: 5;
transition: top 0.6s cubic-bezier(0.4, 0, 0.2, 1),
background 0.4s ease,
box-shadow 0.4s ease;
}
@keyframes glowPulse {
0%, 100% { opacity: 0.7; }
50% { opacity: 1; }
}
.indicator-dot::after {
content: '';
position: absolute;
inset: -4px;
border-radius: 50%;
background: inherit;
opacity: 0.25;
animation: glowPulse 2.5s ease-in-out infinite;
}
/* ====== 条形图 ====== */
.bar-section {
margin-top: 10px;
}
.bar-label {
font-size: 11px;
color: var(--text-muted);
margin-bottom: 5px;
}
.bar-track {
height: 6px;
border-radius: 3px;
overflow: hidden;
display: flex;
background: rgba(255,255,255,0.03);
}
.bar-seg {
height: 100%;
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.bar-seg.quiet { background: var(--quiet); }
.bar-seg.normal { background: var(--normal); }
.bar-seg.noisy { background: var(--noisy); }
.bar-seg.extreme { background: var(--extreme); }
.bar-legend {
display: flex;
gap: 10px;
margin-top: 6px;
}
.bar-legend-item {
display: flex;
align-items: center;
gap: 3px;
font-size: 10px;
color: var(--text-muted);
}
.bar-legend-dot {
width: 5px;
height: 5px;
border-radius: 50%;
}
/* ====== Baseline ====== */
.baseline-bands {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
z-index: 0;
}
.baseline-band { flex: 1; }
.baseline-band.extreme { background: rgba(239,68,68,0.09); }
.baseline-band.noisy { background: rgba(245,158,11,0.08); }
.baseline-band.normal { background: rgba(96,165,250,0.08); }
.baseline-band.quiet { background: rgba(52,211,153,0.09); }
/* ====== 控制面板 ====== */
.controls {
max-width: 1200px;
margin: 0 auto 32px;
padding: 20px 24px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-component);
}
.controls-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 16px;
color: var(--text-secondary);
}
.controls-row {
display: flex;
gap: 32px;
flex-wrap: wrap;
}
.control-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.control-label {
font-size: 12px;
color: var(--text-muted);
}
.control-buttons {
display: flex;
gap: 6px;
}
.control-btn {
padding: 6px 14px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface-raised);
color: var(--text-secondary);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
font-family: inherit;
}
.control-btn:hover {
background: rgba(255,255,255,0.08);
color: var(--text-primary);
}
.control-btn.active-quiet { border-color: rgba(52,211,153,0.4); background: rgba(52,211,153,0.15); color: var(--quiet); }
.control-btn.active-normal { border-color: rgba(96,165,250,0.4); background: rgba(96,165,250,0.15); color: var(--normal); }
.control-btn.active-noisy { border-color: rgba(245,158,11,0.4); background: rgba(245,158,11,0.15); color: var(--noisy); }
.control-btn.active-extreme { border-color: rgba(239,68,68,0.4); background: rgba(239,68,68,0.15); color: var(--extreme); }
/* ====== 对比标注 ====== */
.comparison-note {
max-width: 1200px;
margin: 0 auto 40px;
padding: 16px 20px;
background: rgba(96,165,250,0.06);
border: 1px solid rgba(96,165,250,0.12);
border-radius: 12px;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.7;
}
.comparison-note strong {
color: var(--text-primary);
}
.comparison-note .highlight {
padding: 1px 6px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.comparison-note .hl-quiet { background: rgba(52,211,153,0.15); color: var(--quiet); }
.comparison-note .hl-normal { background: rgba(96,165,250,0.15); color: var(--normal); }
.comparison-note .hl-noisy { background: rgba(245,158,11,0.15); color: var(--noisy); }
.comparison-note .hl-extreme { background: rgba(239,68,68,0.15); color: var(--extreme); }
</style>
</head>
<body>
<div class="page-header">
<h1>噪音等级组件改造 · 聚光灯方案</h1>
<p>StudyNoiseDistributionWidget 视觉升级 Mockup v2 — 局部渐变聚焦,而非全图渐变</p>
</div>
<div class="comparison-note">
<strong>设计理念:</strong>不是整张图都铺满渐变色带,而是只在<strong>当前等级附近</strong>产生一个柔和的聚光灯光晕。
其余区域保持低调暗淡,让用户视线自然聚焦到当前所处等级。
当等级切换时,光晕平滑滑动到新位置,颜色同步过渡。
<br><br>
<span class="highlight hl-quiet">Quiet 安静</span>
<span class="highlight hl-normal">Normal 正常</span>
<span class="highlight hl-noisy">Noisy 嘈杂</span>
<span class="highlight hl-extreme">Extreme 极端</span>
&nbsp;点击下方按钮切换等级,观察光晕移动效果
</div>
<!-- 控制面板 -->
<div class="controls">
<div class="controls-title">🎛 模拟噪音等级切换</div>
<div class="controls-row">
<div class="control-group">
<div class="control-label">当前等级</div>
<div class="control-buttons" id="level-buttons">
<button class="control-btn" data-level="quiet" onclick="setLevel('quiet')">Quiet 安静</button>
<button class="control-btn active-normal" data-level="normal" onclick="setLevel('normal')">Normal 正常</button>
<button class="control-btn" data-level="noisy" onclick="setLevel('noisy')">Noisy 嘈杂</button>
<button class="control-btn" data-level="extreme" onclick="setLevel('extreme')">Extreme 极端</button>
</div>
</div>
</div>
</div>
<!-- 当前实现 Baseline -->
<div class="section">
<div class="section-title">当前实现 (Baseline)</div>
<div class="section-desc">四色硬切色带,各等级区域均匀着色,没有视觉焦点。用户需要主动寻找"我在哪个等级"。</div>
<div class="mockup-row">
<div class="widget-card">
<div class="chart-header">
<span class="chart-title">噪音等级分布</span>
<span class="chart-badge" style="background: rgba(47,93,168,0.55);">Realtime</span>
</div>
<div class="chart-area">
<div class="baseline-bands">
<div class="baseline-band extreme"></div>
<div class="baseline-band noisy"></div>
<div class="baseline-band normal"></div>
<div class="baseline-band quiet"></div>
</div>
<div class="y-axis">
<span class="y-label">Extreme</span>
<span class="y-label">Noisy</span>
<span class="y-label">Normal</span>
<span class="y-label">Quiet</span>
</div>
<div class="curve-area">
<svg viewBox="0 0 280 140" preserveAspectRatio="none">
<defs>
<linearGradient id="fill0" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#EF4444" stop-opacity="0.34"/>
<stop offset="28%" stop-color="#F59E0B" stop-opacity="0.34"/>
<stop offset="62%" stop-color="#60A5FA" stop-opacity="0.34"/>
<stop offset="100%" stop-color="#34D399" stop-opacity="0.34"/>
</linearGradient>
</defs>
<path d="M0,95 C20,92 40,78 60,72 C80,66 100,56 120,50 C140,44 160,52 180,48 C200,44 220,38 240,42 C260,46 280,54 280,52 L280,140 L0,140 Z" fill="url(#fill0)"/>
<path d="M0,95 C20,92 40,78 60,72 C80,66 100,56 120,50 C140,44 160,52 180,48 C200,44 220,38 240,42 C260,46 280,54 280,52" fill="none" stroke="#52AEEA" stroke-width="1.5"/>
<circle cx="280" cy="52" r="4" fill="#fff" stroke="#52AEEA" stroke-width="1.2"/>
<circle cx="280" cy="52" r="10" fill="rgba(82,174,234,0.2)"/>
</svg>
</div>
<div class="x-axis">
<span class="x-label">-12s</span>
<span class="x-label">-6s</span>
<span class="x-label">Now</span>
</div>
</div>
</div>
</div>
</div>
<!-- 聚光灯方案 -->
<div class="section">
<div class="section-title">聚光灯方案 — 局部渐变聚焦</div>
<div class="section-desc">只在当前等级附近产生柔和光晕其余区域保持暗淡。等级切换时光晕平滑滑动Y轴标签联动高亮右侧指示条标注当前位置。下方条形图展示分布占比。</div>
<div class="mockup-row">
<!-- 标准尺寸 -->
<div class="widget-card">
<div class="widget-label">推荐</div>
<div class="chart-header">
<span class="chart-title">噪音等级分布</span>
<span class="chart-badge" id="spot-badge" style="background: rgba(47,93,168,0.55);">Realtime</span>
</div>
<div class="chart-area" id="spot-chart">
<!-- 极淡的分隔线标记等级边界 -->
<div class="level-divider" style="top: 25%;"></div>
<div class="level-divider" style="top: 50%;"></div>
<div class="level-divider" style="top: 75%;"></div>
<!-- 聚光灯光晕层 -->
<div class="spotlight-layer">
<div class="spotlight-glow active" id="spot-glow"
style="top: 35%; height: 50%; background: radial-gradient(ellipse at center, rgba(96,165,250,0.14) 0%, rgba(96,165,250,0.04) 50%, transparent 80%);">
</div>
</div>
<!-- Y轴 -->
<div class="y-axis">
<span class="y-label l-extreme" id="sy-extreme">Extreme</span>
<span class="y-label l-noisy" id="sy-noisy">Noisy</span>
<span class="y-label l-normal active" id="sy-normal">Normal</span>
<span class="y-label l-quiet" id="sy-quiet">Quiet</span>
</div>
<!-- 指示条 -->
<div class="indicator-track"></div>
<div class="indicator-dot" id="spot-dot" style="top: 55%; background: var(--normal); box-shadow: 0 0 8px rgba(96,165,250,0.5);"></div>
<!-- 曲线 -->
<div class="curve-area" style="right: 18px;">
<svg viewBox="0 0 270 140" preserveAspectRatio="none">
<defs>
<linearGradient id="fillSpot" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#EF4444" stop-opacity="0.28"/>
<stop offset="28%" stop-color="#F59E0B" stop-opacity="0.28"/>
<stop offset="62%" stop-color="#60A5FA" stop-opacity="0.28"/>
<stop offset="100%" stop-color="#34D399" stop-opacity="0.28"/>
</linearGradient>
</defs>
<path d="M0,95 C20,92 40,78 60,72 C80,66 100,56 120,50 C140,44 160,52 180,48 C200,44 220,38 240,42 C260,46 270,54 270,52 L270,140 L0,140 Z" fill="url(#fillSpot)"/>
<path d="M0,95 C20,92 40,78 60,72 C80,66 100,56 120,50 C140,44 160,52 180,48 C200,44 220,38 240,42 C260,46 270,54 270,52" fill="none" stroke="#8BE8FF" stroke-width="1.8"/>
<circle cx="270" cy="52" r="3.5" fill="#fff" stroke="#52D6FF" stroke-width="1.2" id="spot-latest-dot"/>
<circle cx="270" cy="52" r="9" fill="rgba(82,214,255,0.12)" id="spot-latest-glow"/>
</svg>
</div>
<div class="x-axis">
<span class="x-label">-12s</span>
<span class="x-label">-6s</span>
<span class="x-label">Now</span>
</div>
</div>
<!-- 条形图 -->
<div class="bar-section">
<div class="bar-label">等级分布</div>
<div class="bar-track" id="spot-bar">
<div class="bar-seg quiet" style="width: 35%;"></div>
<div class="bar-seg normal" style="width: 40%;"></div>
<div class="bar-seg noisy" style="width: 18%;"></div>
<div class="bar-seg extreme" style="width: 7%;"></div>
</div>
<div class="bar-legend" id="spot-legend">
<span class="bar-legend-item"><span class="bar-legend-dot" style="background:var(--quiet);"></span>安静 35%</span>
<span class="bar-legend-item"><span class="bar-legend-dot" style="background:var(--normal);"></span>正常 40%</span>
<span class="bar-legend-item"><span class="bar-legend-dot" style="background:var(--noisy);"></span>嘈杂 18%</span>
<span class="bar-legend-item"><span class="bar-legend-dot" style="background:var(--extreme);"></span>极端 7%</span>
</div>
</div>
</div>
<!-- 紧凑模式 -->
<div class="widget-card">
<div class="widget-label">紧凑模式</div>
<div class="chart-header">
<span class="chart-title">噪音等级分布</span>
<span class="chart-badge" id="spot-badge-c" style="background: rgba(47,93,168,0.55);">Session</span>
</div>
<div class="chart-area" style="height: 120px;" id="spot-chart-c">
<div class="level-divider" style="top: 25%;"></div>
<div class="level-divider" style="top: 50%;"></div>
<div class="level-divider" style="top: 75%;"></div>
<div class="spotlight-layer">
<div class="spotlight-glow active" id="spot-glow-c"
style="top: 35%; height: 50%; background: radial-gradient(ellipse at center, rgba(96,165,250,0.14) 0%, rgba(96,165,250,0.04) 50%, transparent 80%);">
</div>
</div>
<div class="y-axis" style="width: 36px;">
<span class="y-label l-extreme" id="syc-extreme" style="font-size:8px;">Ext</span>
<span class="y-label l-noisy" id="syc-noisy" style="font-size:8px;">Noisy</span>
<span class="y-label l-normal active" id="syc-normal" style="font-size:8px;">Norm</span>
<span class="y-label l-quiet" id="syc-quiet" style="font-size:8px;">Quiet</span>
</div>
<div class="indicator-track" style="right: 4px; width: 2px;"></div>
<div class="indicator-dot" id="spot-dot-c" style="right: 0; width: 8px; height: 8px; top: 55%; background: var(--normal); box-shadow: 0 0 6px rgba(96,165,250,0.4);"></div>
<div class="curve-area" style="left: 40px; right: 14px;">
<svg viewBox="0 0 260 95" preserveAspectRatio="none">
<path d="M0,65 C30,60 60,50 90,45 C120,40 150,48 180,42 C210,36 240,40 260,38 L260,95 L0,95 Z" fill="url(#fillSpot)" opacity="0.7"/>
<path d="M0,65 C30,60 60,50 90,45 C120,40 150,48 180,42 C210,36 240,40 260,38" fill="none" stroke="#8BE8FF" stroke-width="1.4"/>
</svg>
</div>
<div class="x-axis" style="left: 40px;">
<span class="x-label">-12s</span>
<span class="x-label">-6s</span>
<span class="x-label">Now</span>
</div>
</div>
<div class="bar-section">
<div class="bar-track" style="height: 4px; border-radius: 2px;">
<div class="bar-seg quiet" style="width: 55%;"></div>
<div class="bar-seg normal" style="width: 30%;"></div>
<div class="bar-seg noisy" style="width: 10%;"></div>
<div class="bar-seg extreme" style="width: 5%;"></div>
</div>
</div>
</div>
</div>
</div>
<!-- 四种等级状态展示 -->
<div class="section">
<div class="section-title">四种等级状态一览</div>
<div class="section-desc">同时展示四个等级的聚光灯效果,可以直观对比光晕位置、颜色和强度的差异。</div>
<div class="mockup-row" style="grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));">
<div class="widget-card" style="padding: 12px;">
<div style="font-size: 12px; font-weight: 600; color: var(--quiet); margin-bottom: 8px;">● Quiet 安静</div>
<div class="chart-area" style="height: 100px;">
<div class="level-divider" style="top: 25%;"></div>
<div class="level-divider" style="top: 50%;"></div>
<div class="level-divider" style="top: 75%;"></div>
<div class="spotlight-layer">
<div class="spotlight-glow active" style="top: 60%; height: 45%; background: radial-gradient(ellipse at center, rgba(52,211,153,0.16) 0%, rgba(52,211,153,0.04) 50%, transparent 80%);"></div>
</div>
<div class="y-axis" style="width: 32px; bottom: 16px;">
<span class="y-label" style="font-size:8px;">Ext</span>
<span class="y-label" style="font-size:8px;">Noisy</span>
<span class="y-label" style="font-size:8px;">Norm</span>
<span class="y-label l-quiet active" style="font-size:8px;">Quiet</span>
</div>
<div class="indicator-track" style="right: 3px; width: 2px;"></div>
<div class="indicator-dot" style="right: 0; width: 7px; height: 7px; top: 82%; background: var(--quiet); box-shadow: 0 0 6px rgba(52,211,153,0.5);"></div>
<div class="curve-area" style="left: 36px; right: 12px;">
<svg viewBox="0 0 220 80" preserveAspectRatio="none">
<path d="M0,55 C20,52 40,58 60,60 C80,62 100,65 120,63 C140,61 160,58 180,60 C200,62 220,64 220,63 L220,80 L0,80 Z" fill="url(#fillSpot)" opacity="0.5"/>
<path d="M0,55 C20,52 40,58 60,60 C80,62 100,65 120,63 C140,61 160,58 180,60 C200,62 220,64 220,63" fill="none" stroke="#8BE8FF" stroke-width="1.2"/>
<circle cx="220" cy="63" r="2.5" fill="#34D399" stroke="#fff" stroke-width="0.8"/>
</svg>
</div>
</div>
</div>
<div class="widget-card" style="padding: 12px;">
<div style="font-size: 12px; font-weight: 600; color: var(--normal); margin-bottom: 8px;">● Normal 正常</div>
<div class="chart-area" style="height: 100px;">
<div class="level-divider" style="top: 25%;"></div>
<div class="level-divider" style="top: 50%;"></div>
<div class="level-divider" style="top: 75%;"></div>
<div class="spotlight-layer">
<div class="spotlight-glow active" style="top: 35%; height: 50%; background: radial-gradient(ellipse at center, rgba(96,165,250,0.14) 0%, rgba(96,165,250,0.04) 50%, transparent 80%);"></div>
</div>
<div class="y-axis" style="width: 32px; bottom: 16px;">
<span class="y-label" style="font-size:8px;">Ext</span>
<span class="y-label" style="font-size:8px;">Noisy</span>
<span class="y-label l-normal active" style="font-size:8px;">Norm</span>
<span class="y-label" style="font-size:8px;">Quiet</span>
</div>
<div class="indicator-track" style="right: 3px; width: 2px;"></div>
<div class="indicator-dot" style="right: 0; width: 7px; height: 7px; top: 55%; background: var(--normal); box-shadow: 0 0 6px rgba(96,165,250,0.5);"></div>
<div class="curve-area" style="left: 36px; right: 12px;">
<svg viewBox="0 0 220 80" preserveAspectRatio="none">
<path d="M0,50 C20,48 40,42 60,38 C80,34 100,40 120,36 C140,32 160,38 180,35 C200,32 220,38 220,36 L220,80 L0,80 Z" fill="url(#fillSpot)" opacity="0.5"/>
<path d="M0,50 C20,48 40,42 60,38 C80,34 100,40 120,36 C140,32 160,38 180,35 C200,32 220,38 220,36" fill="none" stroke="#8BE8FF" stroke-width="1.2"/>
<circle cx="220" cy="36" r="2.5" fill="#60A5FA" stroke="#fff" stroke-width="0.8"/>
</svg>
</div>
</div>
</div>
<div class="widget-card" style="padding: 12px;">
<div style="font-size: 12px; font-weight: 600; color: var(--noisy); margin-bottom: 8px;">● Noisy 嘈杂</div>
<div class="chart-area" style="height: 100px;">
<div class="level-divider" style="top: 25%;"></div>
<div class="level-divider" style="top: 50%;"></div>
<div class="level-divider" style="top: 75%;"></div>
<div class="spotlight-layer">
<div class="spotlight-glow active" style="top: 15%; height: 45%; background: radial-gradient(ellipse at center, rgba(245,158,11,0.16) 0%, rgba(245,158,11,0.04) 50%, transparent 80%);"></div>
</div>
<div class="y-axis" style="width: 32px; bottom: 16px;">
<span class="y-label" style="font-size:8px;">Ext</span>
<span class="y-label l-noisy active" style="font-size:8px;">Noisy</span>
<span class="y-label" style="font-size:8px;">Norm</span>
<span class="y-label" style="font-size:8px;">Quiet</span>
</div>
<div class="indicator-track" style="right: 3px; width: 2px;"></div>
<div class="indicator-dot" style="right: 0; width: 7px; height: 7px; top: 30%; background: var(--noisy); box-shadow: 0 0 6px rgba(245,158,11,0.5);"></div>
<div class="curve-area" style="left: 36px; right: 12px;">
<svg viewBox="0 0 220 80" preserveAspectRatio="none">
<path d="M0,30 C20,28 40,22 60,18 C80,14 100,20 120,16 C140,12 160,18 180,15 C200,12 220,16 220,14 L220,80 L0,80 Z" fill="url(#fillSpot)" opacity="0.5"/>
<path d="M0,30 C20,28 40,22 60,18 C80,14 100,20 120,16 C140,12 160,18 180,15 C200,12 220,16 220,14" fill="none" stroke="#FF8BE8" stroke-width="1.2"/>
<circle cx="220" cy="14" r="2.5" fill="#F59E0B" stroke="#fff" stroke-width="0.8"/>
</svg>
</div>
</div>
</div>
<div class="widget-card" style="padding: 12px;">
<div style="font-size: 12px; font-weight: 600; color: var(--extreme); margin-bottom: 8px;">● Extreme 极端</div>
<div class="chart-area" style="height: 100px;">
<div class="level-divider" style="top: 25%;"></div>
<div class="level-divider" style="top: 50%;"></div>
<div class="level-divider" style="top: 75%;"></div>
<div class="spotlight-layer">
<div class="spotlight-glow active" style="top: 0%; height: 40%; background: radial-gradient(ellipse at center, rgba(239,68,68,0.18) 0%, rgba(239,68,68,0.05) 50%, transparent 80%); animation: extremePulse 2s ease-in-out infinite;"></div>
</div>
<div class="y-axis" style="width: 32px; bottom: 16px;">
<span class="y-label l-extreme active" style="font-size:8px;">Ext</span>
<span class="y-label" style="font-size:8px;">Noisy</span>
<span class="y-label" style="font-size:8px;">Norm</span>
<span class="y-label" style="font-size:8px;">Quiet</span>
</div>
<div class="indicator-track" style="right: 3px; width: 2px;"></div>
<div class="indicator-dot" style="right: 0; width: 7px; height: 7px; top: 10%; background: var(--extreme); box-shadow: 0 0 8px rgba(239,68,68,0.6);"></div>
<div class="curve-area" style="left: 36px; right: 12px;">
<svg viewBox="0 0 220 80" preserveAspectRatio="none">
<path d="M0,14 C20,12 40,8 60,6 C80,4 100,10 120,7 C140,4 160,8 180,5 C200,2 220,6 220,4 L220,80 L0,80 Z" fill="url(#fillSpot)" opacity="0.5"/>
<path d="M0,14 C20,12 40,8 60,6 C80,4 100,10 120,7 C140,4 160,8 180,5 C200,2 220,6 220,4" fill="none" stroke="#FF8BE8" stroke-width="1.2"/>
<circle cx="220" cy="4" r="2.5" fill="#EF4444" stroke="#fff" stroke-width="0.8"/>
</svg>
</div>
</div>
</div>
</div>
</div>
@keyframes extremePulse {
0%, 100% { opacity: 0.8; }
50% { opacity: 1; }
}
<script>
const levels = {
quiet: {
color: '#34D399',
cssVar: 'var(--quiet)',
label: '安静',
glowTop: '58%',
glowHeight: '45%',
glowBg: 'radial-gradient(ellipse at center, rgba(52,211,153,0.16) 0%, rgba(52,211,153,0.04) 50%, transparent 80%)',
dotTop: '80%',
dotShadow: '0 0 8px rgba(52,211,153,0.5)',
badgeBg: 'rgba(15,107,73,0.8)',
barWidths: [55, 30, 10, 5],
barLegends: ['安静 55%', '正常 30%', '嘈杂 10%', '极端 5%'],
latestDotColor: '#34D399',
latestGlowColor: 'rgba(52,211,153,0.12)',
},
normal: {
color: '#60A5FA',
cssVar: 'var(--normal)',
label: '正常',
glowTop: '30%',
glowHeight: '50%',
glowBg: 'radial-gradient(ellipse at center, rgba(96,165,250,0.14) 0%, rgba(96,165,250,0.04) 50%, transparent 80%)',
dotTop: '55%',
dotShadow: '0 0 8px rgba(96,165,250,0.5)',
badgeBg: 'rgba(47,93,168,0.55)',
barWidths: [35, 40, 18, 7],
barLegends: ['安静 35%', '正常 40%', '嘈杂 18%', '极端 7%'],
latestDotColor: '#60A5FA',
latestGlowColor: 'rgba(96,165,250,0.12)',
},
noisy: {
color: '#F59E0B',
cssVar: 'var(--noisy)',
label: '嘈杂',
glowTop: '10%',
glowHeight: '45%',
glowBg: 'radial-gradient(ellipse at center, rgba(245,158,11,0.16) 0%, rgba(245,158,11,0.04) 50%, transparent 80%)',
dotTop: '28%',
dotShadow: '0 0 8px rgba(245,158,11,0.5)',
badgeBg: 'rgba(128,80,24,0.8)',
barWidths: [15, 25, 42, 18],
barLegends: ['安静 15%', '正常 25%', '嘈杂 42%', '极端 18%'],
latestDotColor: '#F59E0B',
latestGlowColor: 'rgba(245,158,11,0.12)',
},
extreme: {
color: '#EF4444',
cssVar: 'var(--extreme)',
label: '极端',
glowTop: '-5%',
glowHeight: '40%',
glowBg: 'radial-gradient(ellipse at center, rgba(239,68,68,0.18) 0%, rgba(239,68,68,0.05) 50%, transparent 80%)',
dotTop: '10%',
dotShadow: '0 0 10px rgba(239,68,68,0.6)',
badgeBg: 'rgba(141,42,58,0.8)',
barWidths: [5, 12, 30, 53],
barLegends: ['安静 5%', '正常 12%', '嘈杂 30%', '极端 53%'],
latestDotColor: '#EF4444',
latestGlowColor: 'rgba(239,68,68,0.15)',
}
};
function setLevel(level) {
const cfg = levels[level];
document.querySelectorAll('#level-buttons .control-btn').forEach(b => {
b.className = 'control-btn';
});
const activeBtn = document.querySelector(`#level-buttons .control-btn[data-level="${level}"]`);
if (activeBtn) activeBtn.classList.add(`active-${level}`);
['spot', 'spot-c'].forEach(prefix => {
const glow = document.getElementById(prefix === 'spot' ? 'spot-glow' : 'spot-glow-c');
const dot = document.getElementById(prefix === 'spot' ? 'spot-dot' : 'spot-dot-c');
if (glow) {
glow.style.top = cfg.glowTop;
glow.style.height = cfg.glowHeight;
glow.style.background = cfg.glowBg;
if (level === 'extreme') {
glow.style.animation = 'extremePulse 2s ease-in-out infinite';
} else {
glow.style.animation = 'none';
}
}
if (dot) {
dot.style.top = cfg.dotTop;
dot.style.background = cfg.color;
dot.style.boxShadow = cfg.dotShadow;
}
});
['extreme', 'noisy', 'normal', 'quiet'].forEach(l => {
const isCurrent = l === level;
['sy', 'syc'].forEach(prefix => {
const el = document.getElementById(`${prefix}-${l}`);
if (el) el.classList.toggle('active', isCurrent);
});
});
const badge = document.getElementById('spot-badge');
const badgeC = document.getElementById('spot-badge-c');
[badge, badgeC].forEach(b => {
if (b) { b.textContent = cfg.label; b.style.background = cfg.badgeBg; }
});
const latestDot = document.getElementById('spot-latest-dot');
const latestGlow = document.getElementById('spot-latest-glow');
if (latestDot) latestDot.setAttribute('fill', cfg.latestDotColor);
if (latestGlow) latestGlow.setAttribute('fill', cfg.latestGlowColor);
const barTrack = document.getElementById('spot-bar');
if (barTrack) {
const segs = barTrack.querySelectorAll('.bar-seg');
const classes = ['quiet', 'normal', 'noisy', 'extreme'];
cfg.barWidths.forEach((w, i) => { if (segs[i]) segs[i].style.width = w + '%'; });
}
const legend = document.getElementById('spot-legend');
if (legend) {
const items = legend.querySelectorAll('.bar-legend-item');
cfg.barLegends.forEach((text, i) => { if (items[i]) items[i].childNodes[1].textContent = ' ' + text; });
}
}
setLevel('normal');
</script>
</body>
</html>