From 84caca02bf9d05b73c85f519899539ed9c579596 Mon Sep 17 00:00:00 2001 From: lincube Date: Thu, 7 May 2026 16:34:31 +0800 Subject: [PATCH] Add Data settings page and storage scanner Introduce a new "Data" settings page to visualize and manage local app storage. Adds DataStorageService (scanning, disk info, clean operations), DataSettingsPageViewModel, XAML view and code-behind, and HexToColor/HexToBrush converters; registers converters in App.axaml. Also update localization strings for the new page and add icon mapping so the settings entry uses the Database icon. Enables per-category and global cleaning workflows and formatted size display. --- .trae/specs/data-settings-page/design.md | 104 +++ .trae/specs/data-settings-page/plan.md | 777 ++++++++++++++++++ LanMountainDesktop/App.axaml | 3 + .../Converters/HexToBrushConverter.cs | 31 + .../Converters/HexToColorConverter.cs | 31 + LanMountainDesktop/Localization/en-US.json | 2 + LanMountainDesktop/Localization/ja-JP.json | 2 + LanMountainDesktop/Localization/ko-KR.json | 2 + LanMountainDesktop/Localization/zh-CN.json | 2 + .../Services/DataStorageService.cs | 357 ++++++++ .../ViewModels/DataSettingsPageViewModel.cs | 181 ++++ .../SettingsPages/DataSettingsPage.axaml | 176 ++++ .../SettingsPages/DataSettingsPage.axaml.cs | 138 ++++ .../Views/SettingsWindow.axaml.cs | 1 + .../installer/LanMountainDesktop.iss | 14 +- 15 files changed, 1812 insertions(+), 9 deletions(-) create mode 100644 .trae/specs/data-settings-page/design.md create mode 100644 .trae/specs/data-settings-page/plan.md create mode 100644 LanMountainDesktop/Converters/HexToBrushConverter.cs create mode 100644 LanMountainDesktop/Converters/HexToColorConverter.cs create mode 100644 LanMountainDesktop/Services/DataStorageService.cs create mode 100644 LanMountainDesktop/ViewModels/DataSettingsPageViewModel.cs create mode 100644 LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml create mode 100644 LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml.cs diff --git a/.trae/specs/data-settings-page/design.md b/.trae/specs/data-settings-page/design.md new file mode 100644 index 0000000..3714ce3 --- /dev/null +++ b/.trae/specs/data-settings-page/design.md @@ -0,0 +1,104 @@ +# 数据设置页设计文档 + +## 概述 + +在设置窗口中新增「数据」设置页,用于可视化展示和管理阑山桌面产生的各类本地数据。采用 Fluent Design 风格的横向堆叠条形图展示存储分布。 + +## 设计目标 + +1. 让用户直观了解阑山桌面占用的存储空间 +2. 提供各类数据的占比可视化 +3. 支持按类别清理数据 +4. 显示相对于磁盘总容量的占比 + +## 页面结构 + +### 存储概览区域 + +顶部一个卡片,包含: +- **横向堆叠条形图** — 各类数据用不同颜色的分段表示 +- **总占用大小** — 阑山桌面数据总大小(如 "1.2 GB") +- **磁盘占比** — 占总磁盘空间的百分比(如 "占 C 盘 0.5%") +- **图例** — 各颜色对应的数据类型 + +### 数据类型详情列表 + +下方列表展示每类数据: +- 图标 + 名称 +- 占用大小 +- 描述/路径提示 +- 「清理」按钮(如适用) + +### 操作按钮 + +- 「刷新」— 重新扫描数据大小 +- 「一键清理」— 清理所有可清理的数据 + +## 数据类型 + +| 类型 | 颜色 | 可清理 | 路径 | +|------|------|--------|------| +| 日志文件 | 灰色 | 是 | `log/` | +| 白板笔记 | 橙色 | 是(过期) | `Whiteboards/` | +| 插件数据 | 蓝色 | 是 | `Extensions/Plugins/` | +| 插件市场缓存 | 紫色 | 是 | `PluginMarket/` | +| 壁纸文件 | 粉色 | 是 | `Wallpapers/` | +| 设置文件 | 绿色 | 否 | `settings.json` | + +## 技术实现 + +### 新增文件 + +- `LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml` — 页面视图 +- `LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml.cs` — 页面代码隐藏 +- `LanMountainDesktop/ViewModels/DataSettingsPageViewModel.cs` — 视图模型 +- `LanMountainDesktop/Services/DataStorageService.cs` — 数据扫描服务 + +### 修改文件 + +- `LanMountainDesktop/Views/SettingsWindow.axaml.cs` — 图标映射(MapIcon)添加 Database 图标 + +### 设置页注册 + +```csharp +[SettingsPageInfo( + "data", + "Data", + SettingsPageCategory.General, + IconKey = "Database", + SortOrder = 5, + TitleLocalizationKey = "settings.data.title", + DescriptionLocalizationKey = "settings.data.description")] +``` + +## 视觉设计 + +### 堆叠条形图 + +- 高度:24-32dp +- 圆角:使用 `DesignCornerRadiusSm` +- 分段间距:2dp +- 未占用空间:透明或浅色背景 + +### 颜色方案 + +使用 Material Design 颜色,与主题协调: +- 日志:Gray / BlueGray +- 白板:Orange / Amber +- 插件:Blue / Indigo +- 缓存:Purple / DeepPurple +- 壁纸:Pink +- 设置:Green / Teal + +## 交互行为 + +1. 页面加载时自动扫描数据大小(异步) +2. 显示加载指示器 +3. 清理操作需要确认对话框 +4. 清理完成后自动刷新数据 + +## 安全考虑 + +- 清理前确认用户意图 +- 设置文件不可清理(防止误删配置) +- 清理操作记录日志 diff --git a/.trae/specs/data-settings-page/plan.md b/.trae/specs/data-settings-page/plan.md new file mode 100644 index 0000000..56b616e --- /dev/null +++ b/.trae/specs/data-settings-page/plan.md @@ -0,0 +1,777 @@ +# 数据设置页实现计划 + +> **Goal:** 在设置窗口中新增「数据」设置页,可视化展示阑山桌面各类本地数据的存储占用,支持数据清理。 + +> **Architecture:** 采用 MVVM 模式,新增 DataStorageService 负责异步扫描各类数据大小,DataSettingsPage 使用 Fluent Design 横向堆叠条形图展示存储分布。 + +> **Tech Stack:** Avalonia UI, FluentAvaloniaUI, CommunityToolkit.Mvvm, C# 13 + +--- + +## 文件结构 + +| 文件 | 职责 | +|------|------| +| `LanMountainDesktop/Services/DataStorageService.cs` | 扫描各类数据目录大小,计算磁盘总容量 | +| `LanMountainDesktop/ViewModels/DataSettingsPageViewModel.cs` | 数据设置页视图模型,绑定存储数据和清理命令 | +| `LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml` | 数据设置页 XAML 视图(堆叠条形图 + 列表) | +| `LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml.cs` | 页面代码隐藏,注册设置页属性 | +| `LanMountainDesktop/Views/SettingsWindow.axaml.cs` | 修改图标映射,添加 Database 图标 | + +--- + +## Task 1: 创建 DataStorageService + +**Files:** +- Create: `LanMountainDesktop/Services/DataStorageService.cs` + +**职责:** 扫描阑山桌面各类数据的存储占用,计算磁盘总容量。 + +- [ ] **Step 1: 创建 DataStorageService** + +```csharp +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; + +namespace LanMountainDesktop.Services; + +public sealed record StorageCategoryInfo( + string Id, + string Name, + string Description, + string DirectoryPath, + bool IsCleanable, + string ColorHex); + +public sealed record StorageScanResult( + StorageCategoryInfo Category, + long SizeBytes, + double PercentageOfTotal); + +public sealed class DataStorageService +{ + private static readonly IReadOnlyList Categories = new List + { + new("logs", "日志文件", "应用运行日志", "", true, "#9E9E9E"), + new("whiteboards", "白板笔记", "桌面白板笔记数据", "", true, "#FF9800"), + new("plugins", "插件数据", "已安装插件文件", "", true, "#2196F3"), + new("market", "插件市场缓存", "插件市场元数据缓存", "", true, "#9C27B0"), + new("wallpapers", "壁纸文件", "下载的壁纸资源", "", true, "#E91E63"), + new("settings", "设置文件", "应用配置数据", "", false, "#4CAF50") + }; + + public IReadOnlyList GetCategories() => Categories; + + public async Task> ScanAsync(CancellationToken cancellationToken = default) + { + var results = new List(); + var dataRoot = AppDataPathProvider.GetDataRoot(); + var logDirectory = AppLogger.LogDirectory; + + long totalSize = 0; + var categorySizes = new Dictionary(); + + foreach (var category in Categories) + { + cancellationToken.ThrowIfCancellationRequested(); + + string path = category.Id switch + { + "logs" => logDirectory, + "settings" => dataRoot, + _ => Path.Combine(dataRoot, category.DirectoryPath) + }; + + long size = 0; + if (category.Id == "settings") + { + size = await GetSettingsSizeAsync(dataRoot, cancellationToken); + } + else if (Directory.Exists(path)) + { + size = await GetDirectorySizeAsync(path, cancellationToken); + } + + categorySizes[category.Id] = size; + totalSize += size; + } + + foreach (var category in Categories) + { + var size = categorySizes.GetValueOrDefault(category.Id, 0); + var percentage = totalSize > 0 ? (double)size / totalSize * 100 : 0; + results.Add(new StorageScanResult(category, size, percentage)); + } + + return results; + } + + public async Task GetTotalDiskSpaceAsync(CancellationToken cancellationToken = default) + { + return await Task.Run(() => + { + var dataRoot = AppDataPathProvider.GetDataRoot(); + var driveInfo = new DriveInfo(Path.GetPathRoot(dataRoot) ?? dataRoot); + return driveInfo.TotalSize; + }, cancellationToken); + } + + public async Task GetAvailableDiskSpaceAsync(CancellationToken cancellationToken = default) + { + return await Task.Run(() => + { + var dataRoot = AppDataPathProvider.GetDataRoot(); + var driveInfo = new DriveInfo(Path.GetPathRoot(dataRoot) ?? dataRoot); + return driveInfo.AvailableFreeSpace; + }, cancellationToken); + } + + public async Task CleanCategoryAsync(string categoryId, CancellationToken cancellationToken = default) + { + var category = Categories.FirstOrDefault(c => + string.Equals(c.Id, categoryId, StringComparison.OrdinalIgnoreCase)); + + if (category is null || !category.IsCleanable) + { + return false; + } + + var dataRoot = AppDataPathProvider.GetDataRoot(); + string path = categoryId switch + { + "logs" => AppLogger.LogDirectory, + _ => Path.Combine(dataRoot, category.DirectoryPath) + }; + + if (!Directory.Exists(path)) + { + return false; + } + + return await Task.Run(() => + { + try + { + if (categoryId == "logs") + { + foreach (var file in Directory.GetFiles(path, "*.log")) + { + cancellationToken.ThrowIfCancellationRequested(); + TryDeleteFile(file); + } + } + else + { + foreach (var file in Directory.GetFiles(path, "*", SearchOption.AllDirectories)) + { + cancellationToken.ThrowIfCancellationRequested(); + TryDeleteFile(file); + } + + foreach (var dir in Directory.GetDirectories(path, "*", SearchOption.AllDirectories) + .OrderByDescending(d => d.Length)) + { + cancellationToken.ThrowIfCancellationRequested(); + TryDeleteDirectory(dir); + } + } + + AppLogger.Info("DataStorage", $"Cleaned category '{categoryId}' at '{path}'."); + return true; + } + catch (Exception ex) + { + AppLogger.Warn("DataStorage", $"Failed to clean category '{categoryId}'.", ex); + return false; + } + }, cancellationToken); + } + + private static async Task GetDirectorySizeAsync(string path, CancellationToken cancellationToken) + { + return await Task.Run(() => + { + long size = 0; + try + { + foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories)) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var info = new FileInfo(file); + if (info.Exists) + { + size += info.Length; + } + } + catch + { + // Ignore files we can't access + } + } + } + catch + { + // Ignore directories we can't access + } + + return size; + }, cancellationToken); + } + + private static async Task GetSettingsSizeAsync(string dataRoot, CancellationToken cancellationToken) + { + return await Task.Run(() => + { + long size = 0; + var settingFiles = new[] { "settings.json", "plugin-settings.json", "launcher-settings.json" }; + foreach (var file in settingFiles) + { + cancellationToken.ThrowIfCancellationRequested(); + var path = Path.Combine(dataRoot, file); + if (File.Exists(path)) + { + try + { + size += new FileInfo(path).Length; + } + catch + { + // Ignore + } + } + } + + return size; + }, cancellationToken); + } + + private static void TryDeleteFile(string path) + { + try + { + File.SetAttributes(path, FileAttributes.Normal); + File.Delete(path); + } + catch + { + // Ignore deletion failures + } + } + + private static void TryDeleteDirectory(string path) + { + try + { + Directory.Delete(path, false); + } + catch + { + // Ignore deletion failures + } + } + + public static string FormatBytes(long bytes) + { + const long KB = 1024; + const long MB = KB * 1024; + const long GB = MB * 1024; + const long TB = GB * 1024; + + return bytes switch + { + >= TB => $"{bytes / (double)TB:F2} TB", + >= GB => $"{bytes / (double)GB:F2} GB", + >= MB => $"{bytes / (double)MB:F2} MB", + >= KB => $"{bytes / (double)KB:F2} KB", + _ => $"{bytes} B" + }; + } +} +``` + +--- + +## Task 2: 创建 DataSettingsPageViewModel + +**Files:** +- Create: `LanMountainDesktop/ViewModels/DataSettingsPageViewModel.cs` + +- [ ] **Step 1: 创建 ViewModel** + +```csharp +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LanMountainDesktop.Services; + +namespace LanMountainDesktop.ViewModels; + +public sealed partial class DataStorageItemViewModel : ObservableObject +{ + public string Id { get; } + public string Name { get; } + public string Description { get; } + public string ColorHex { get; } + public bool IsCleanable { get; } + + [ObservableProperty] + private string _sizeText = "--"; + + [ObservableProperty] + private double _percentage; + + [ObservableProperty] + private bool _isCleaning; + + public DataStorageItemViewModel(StorageCategoryInfo category) + { + Id = category.Id; + Name = category.Name; + Description = category.Description; + ColorHex = category.ColorHex; + IsCleanable = category.IsCleanable; + } + + public void UpdateSize(long sizeBytes, double percentage) + { + SizeText = DataStorageService.FormatBytes(sizeBytes); + Percentage = percentage; + } +} + +public sealed partial class DataSettingsPageViewModel : ViewModelBase +{ + private readonly DataStorageService _storageService = new(); + private CancellationTokenSource? _scanCts; + + [ObservableProperty] + private string _pageTitle = "数据与存储"; + + [ObservableProperty] + private string _totalSizeText = "--"; + + [ObservableProperty] + private string _diskUsageText = "--"; + + [ObservableProperty] + private double _diskUsagePercentage; + + [ObservableProperty] + private bool _isScanning; + + [ObservableProperty] + private bool _hasData; + + public ObservableCollection Items { get; } = new(); + + public DataSettingsPageViewModel() + { + var categories = _storageService.GetCategories(); + foreach (var category in categories) + { + Items.Add(new DataStorageItemViewModel(category)); + } + + _ = ScanAsync(); + } + + [RelayCommand] + private async Task ScanAsync() + { + _scanCts?.Cancel(); + _scanCts = new CancellationTokenSource(); + var token = _scanCts.Token; + + IsScanning = true; + try + { + var results = await _storageService.ScanAsync(token); + var totalSize = results.Sum(r => r.SizeBytes); + var totalDisk = await _storageService.GetTotalDiskSpaceAsync(token); + + await Dispatcher.UIThread.InvokeAsync(() => + { + TotalSizeText = DataStorageService.FormatBytes(totalSize); + DiskUsagePercentage = totalDisk > 0 ? (double)totalSize / totalDisk * 100 : 0; + DiskUsageText = $"占总磁盘 {DiskUsagePercentage:F1}%"; + HasData = totalSize > 0; + + foreach (var result in results) + { + var item = Items.FirstOrDefault(i => + string.Equals(i.Id, result.Category.Id, StringComparison.OrdinalIgnoreCase)); + item?.UpdateSize(result.SizeBytes, result.PercentageOfTotal); + } + }); + } + catch (OperationCanceledException) + { + // Ignore cancellation + } + catch (Exception ex) + { + AppLogger.Warn("DataSettings", "Failed to scan storage.", ex); + } + finally + { + IsScanning = false; + } + } + + [RelayCommand] + private async Task CleanAsync(string categoryId) + { + var item = Items.FirstOrDefault(i => + string.Equals(i.Id, categoryId, StringComparison.OrdinalIgnoreCase)); + if (item is null || !item.IsCleanable) + { + return; + } + + item.IsCleaning = true; + try + { + await _storageService.CleanCategoryAsync(categoryId); + await ScanAsync(); + } + catch (Exception ex) + { + AppLogger.Warn("DataSettings", $"Failed to clean category '{categoryId}'.", ex); + } + finally + { + item.IsCleaning = false; + } + } + + [RelayCommand] + private async Task CleanAllAsync() + { + foreach (var item in Items.Where(i => i.IsCleanable)) + { + item.IsCleaning = true; + } + + try + { + foreach (var item in Items.Where(i => i.IsCleanable)) + { + await _storageService.CleanCategoryAsync(item.Id); + } + + await ScanAsync(); + } + catch (Exception ex) + { + AppLogger.Warn("DataSettings", "Failed to clean all categories.", ex); + } + finally + { + foreach (var item in Items) + { + item.IsCleaning = false; + } + } + } +} +``` + +--- + +## Task 3: 创建 DataSettingsPage.axaml + +**Files:** +- Create: `LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml` + +- [ ] **Step 1: 创建 XAML 视图** + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +--- + +## Task 4: 创建 DataSettingsPage.axaml.cs + +**Files:** +- Create: `LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml.cs` + +- [ ] **Step 1: 创建代码隐藏** + +```csharp +using LanMountainDesktop.PluginSdk; +using LanMountainDesktop.ViewModels; + +namespace LanMountainDesktop.Views.SettingsPages; + +[SettingsPageInfo( + "data", + "Data", + SettingsPageCategory.General, + IconKey = "Database", + SortOrder = 5, + TitleLocalizationKey = "settings.data.title", + DescriptionLocalizationKey = "settings.data.description")] +public partial class DataSettingsPage : SettingsPageBase +{ + public DataSettingsPage() + : this(new DataSettingsPageViewModel()) + { + } + + public DataSettingsPage(DataSettingsPageViewModel viewModel) + { + ViewModel = viewModel; + DataContext = ViewModel; + InitializeComponent(); + } + + public DataSettingsPageViewModel ViewModel { get; } +} +``` + +--- + +## Task 5: 修改 SettingsWindow.axaml.cs 添加图标映射 + +**Files:** +- Modify: `LanMountainDesktop/Views/SettingsWindow.axaml.cs` + +- [ ] **Step 1: 在 MapIcon 方法中添加 Database 图标映射** + +在 `MapIcon` 方法的 switch 表达式中添加: + +```csharp +"Database" => Symbol.Database, +``` + +--- + +## Task 6: 添加颜色转换器(如需要) + +**Files:** +- Modify: `LanMountainDesktop/Theme/` 或 `LanMountainDesktop/Controls/` 中的资源字典 + +如果项目中没有 HexToBrushConverter,需要创建一个简单的值转换器: + +```csharp +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace LanMountainDesktop.Converters; + +public class HexToBrushConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is string hex && !string.IsNullOrWhiteSpace(hex)) + { + try + { + return new SolidColorBrush(Color.Parse(hex)); + } + catch + { + // Ignore parse errors + } + } + + return new SolidColorBrush(Colors.Gray); + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } +} +``` + +--- + +## 测试验证 + +1. 构建项目:`dotnet build LanMountainDesktop.slnx -c Debug` +2. 运行应用:`dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj` +3. 打开设置窗口,确认「数据」选项卡出现在左侧导航中 +4. 点击「数据」选项卡,确认: + - 堆叠条形图显示各类数据占比 + - 总大小和磁盘占比显示正确 + - 数据详情列表显示每类数据大小 + - 刷新按钮可以重新扫描 + - 清理按钮可以清理对应数据 diff --git a/LanMountainDesktop/App.axaml b/LanMountainDesktop/App.axaml index a3f8513..21e9f47 100644 --- a/LanMountainDesktop/App.axaml +++ b/LanMountainDesktop/App.axaml @@ -3,6 +3,7 @@ xmlns:sty="using:FluentAvalonia.Styling" xmlns:fi="using:FluentIcons.Avalonia" xmlns:mi="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" + xmlns:conv="using:LanMountainDesktop.Converters" x:Class="LanMountainDesktop.App" xmlns:local="using:LanMountainDesktop" RequestedThemeVariant="Default"> @@ -12,6 +13,8 @@ MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans, avares://LanMountainDesktop/Assets/Fonts#MiSans VF avares://LanMountainDesktop/Assets/Fonts#MiSans JP avares://LanMountainDesktop/Assets/Fonts#MiSans KR + + diff --git a/LanMountainDesktop/Converters/HexToBrushConverter.cs b/LanMountainDesktop/Converters/HexToBrushConverter.cs new file mode 100644 index 0000000..083fcd9 --- /dev/null +++ b/LanMountainDesktop/Converters/HexToBrushConverter.cs @@ -0,0 +1,31 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace LanMountainDesktop.Converters; + +public sealed class HexToBrushConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is string hex && !string.IsNullOrWhiteSpace(hex)) + { + try + { + return new SolidColorBrush(Color.Parse(hex)); + } + catch + { + // Ignore parse errors + } + } + + return Brushes.Gray; + } + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } +} diff --git a/LanMountainDesktop/Converters/HexToColorConverter.cs b/LanMountainDesktop/Converters/HexToColorConverter.cs new file mode 100644 index 0000000..035f593 --- /dev/null +++ b/LanMountainDesktop/Converters/HexToColorConverter.cs @@ -0,0 +1,31 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace LanMountainDesktop.Converters; + +public class HexToColorConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is string hex && !string.IsNullOrWhiteSpace(hex)) + { + try + { + return Color.Parse(hex); + } + catch + { + // Ignore parse errors + } + } + + return Colors.Gray; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } +} diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index 747e1db..3a7fc45 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -352,6 +352,8 @@ "settings.general.slide_transition_desc": "Use a slide-in startup transition on supported Windows builds. This option disables fade transition.", "settings.general.show_main_window_taskbar_header": "Show main desktop window in taskbar", "settings.general.show_main_window_taskbar_desc": "Keep the main desktop host window visible in the taskbar. The independent settings window always has its own taskbar entry.", + "settings.data.title": "Data", + "settings.data.description": "Review and manage local app storage and cache.", "settings.appearance.title": "Appearance", "settings.appearance.description": "Adjust theme source, system material, and window chrome.", "settings.appearance.theme_header": "Theme", diff --git a/LanMountainDesktop/Localization/ja-JP.json b/LanMountainDesktop/Localization/ja-JP.json index 6ea0f11..61315df 100644 --- a/LanMountainDesktop/Localization/ja-JP.json +++ b/LanMountainDesktop/Localization/ja-JP.json @@ -287,6 +287,8 @@ "settings.general.preview_time_label": "時刻", "settings.general.preview_date_label": "日付", "settings.general.render_mode_restart_message": "レンダリングモードの変更にはアプリの再起動が必要です。", + "settings.data.title": "データ", + "settings.data.description": "ローカルに保存されたアプリデータとキャッシュを確認・管理します。", "settings.appearance.title": "外観", "settings.appearance.description": "テーマソース、システムマテリアル、ウィンドウクロームを調整します。", "settings.appearance.theme_header": "テーマ", diff --git a/LanMountainDesktop/Localization/ko-KR.json b/LanMountainDesktop/Localization/ko-KR.json index f8c71f5..973e734 100644 --- a/LanMountainDesktop/Localization/ko-KR.json +++ b/LanMountainDesktop/Localization/ko-KR.json @@ -335,6 +335,8 @@ "settings.general.preview_time_label": "시간", "settings.general.preview_date_label": "날짜", "settings.general.render_mode_restart_message": "렌더링 모드 변경은 앱 재시작이 필요합니다.", + "settings.data.title": "데이터", + "settings.data.description": "로컬에 저장된 앱 데이터와 캐시를 확인하고 관리합니다.", "settings.appearance.title": "외관", "settings.appearance.description": "테마 소스, 시스템 소재 및 창 외관을 조정합니다.", "settings.appearance.theme_header": "테마", diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index 396207a..6619882 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -352,6 +352,8 @@ "settings.general.slide_transition_desc": "在受支持的 Windows 版本上使用滑入启动过渡。启用后会关闭淡入淡出过渡。", "settings.general.show_main_window_taskbar_header": "在任务栏显示主桌面窗口", "settings.general.show_main_window_taskbar_desc": "让主桌面宿主窗口保持在任务栏中可见。独立设置窗口始终拥有自己的任务栏入口。", + "settings.data.title": "数据", + "settings.data.description": "查看与管理本机存储中的应用数据与缓存。", "settings.appearance.title": "外观", "settings.appearance.description": "调整主题来源、系统材质与窗口外观。", "settings.appearance.theme_header": "主题", diff --git a/LanMountainDesktop/Services/DataStorageService.cs b/LanMountainDesktop/Services/DataStorageService.cs new file mode 100644 index 0000000..fe8c3cb --- /dev/null +++ b/LanMountainDesktop/Services/DataStorageService.cs @@ -0,0 +1,357 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace LanMountainDesktop.Services; + +public sealed record StorageCategoryInfo( + string Id, + string Name, + string Description, + string DirectoryPath, + bool IsCleanable, + string ColorHex); + +public sealed record StorageScanResult( + StorageCategoryInfo Category, + long SizeBytes, + double PercentageOfTotal); + +public sealed class DataStorageService +{ + private static readonly string[] SettingsRootFiles = + [ + "settings.json", + "plugin-settings.json", + "launcher-settings.json", + "app.db", + "app.db-shm", + "app.db-wal" + ]; + + private static readonly string[] CategoryRelativeDirectories = + [ + "Whiteboards", + "Extensions", + "PluginMarket", + "Wallpapers" + ]; + + private static readonly IReadOnlyList Categories = new List + { + new("logs", "日志文件", "应用运行日志", "", true, "#9E9E9E"), + new("whiteboards", "白板笔记", "桌面白板笔记数据", "Whiteboards", true, "#FF9800"), + new("plugins", "插件数据", "已安装插件文件", "Extensions/Plugins", true, "#2196F3"), + new("market", "插件市场缓存", "插件市场元数据缓存", "PluginMarket", true, "#9C27B0"), + new("wallpapers", "壁纸文件", "下载的壁纸资源", "Wallpapers", true, "#E91E63"), + new("settings", "设置文件", "应用配置数据", "", false, "#4CAF50") + }; + + public IReadOnlyList GetCategories() => Categories; + + public async Task> ScanAsync(CancellationToken cancellationToken = default) + { + var results = new List(); + var dataRoot = AppDataPathProvider.GetDataRoot(); + var logDirectory = AppLogger.LogDirectory; + + long totalSize = 0; + var categorySizes = new Dictionary(); + + foreach (var category in Categories) + { + cancellationToken.ThrowIfCancellationRequested(); + + string path = category.Id switch + { + "logs" => logDirectory, + "settings" => dataRoot, + _ => Path.Combine(dataRoot, category.DirectoryPath) + }; + + long size = 0; + if (category.Id == "settings") + { + size = await GetSettingsSizeAsync(dataRoot, cancellationToken); + } + else if (Directory.Exists(path)) + { + size = await GetDirectorySizeAsync(path, cancellationToken); + } + + categorySizes[category.Id] = size; + totalSize += size; + } + + foreach (var category in Categories) + { + var size = categorySizes.GetValueOrDefault(category.Id, 0); + var percentage = totalSize > 0 ? (double)size / totalSize * 100 : 0; + results.Add(new StorageScanResult(category, size, percentage)); + } + + return results; + } + + public async Task GetTotalDiskSpaceAsync(CancellationToken cancellationToken = default) + { + return await Task.Run(() => + { + var dataRoot = AppDataPathProvider.GetDataRoot(); + var pathRoot = Path.GetPathRoot(dataRoot); + if (string.IsNullOrWhiteSpace(pathRoot)) + { + return 0; + } + + var driveInfo = new DriveInfo(pathRoot); + return driveInfo.TotalSize; + }, cancellationToken); + } + + public async Task GetAvailableDiskSpaceAsync(CancellationToken cancellationToken = default) + { + return await Task.Run(() => + { + var dataRoot = AppDataPathProvider.GetDataRoot(); + var pathRoot = Path.GetPathRoot(dataRoot); + if (string.IsNullOrWhiteSpace(pathRoot)) + { + return 0; + } + + var driveInfo = new DriveInfo(pathRoot); + return driveInfo.AvailableFreeSpace; + }, cancellationToken); + } + + public async Task CleanCategoryAsync(string categoryId, CancellationToken cancellationToken = default) + { + var category = Categories.FirstOrDefault(c => + string.Equals(c.Id, categoryId, StringComparison.OrdinalIgnoreCase)); + + if (category is null || !category.IsCleanable) + { + return false; + } + + var dataRoot = AppDataPathProvider.GetDataRoot(); + string path = categoryId switch + { + "logs" => AppLogger.LogDirectory, + _ => Path.Combine(dataRoot, category.DirectoryPath) + }; + + if (!Directory.Exists(path)) + { + return false; + } + + return await Task.Run(() => + { + try + { + if (categoryId == "logs") + { + foreach (var file in Directory.GetFiles(path, "*.log")) + { + cancellationToken.ThrowIfCancellationRequested(); + TryDeleteFile(file); + } + } + else + { + foreach (var file in Directory.GetFiles(path, "*", SearchOption.AllDirectories)) + { + cancellationToken.ThrowIfCancellationRequested(); + TryDeleteFile(file); + } + + foreach (var dir in Directory.GetDirectories(path, "*", SearchOption.AllDirectories) + .OrderByDescending(d => d.Length)) + { + cancellationToken.ThrowIfCancellationRequested(); + TryDeleteDirectory(dir); + } + } + + AppLogger.Info("DataStorage", $"Cleaned category '{categoryId}' at '{path}'."); + return true; + } + catch (Exception ex) + { + AppLogger.Warn("DataStorage", $"Failed to clean category '{categoryId}'.", ex); + return false; + } + }, cancellationToken); + } + + private static async Task GetDirectorySizeAsync(string path, CancellationToken cancellationToken) + { + return await Task.Run(() => GetDirectorySizeCore(path, cancellationToken), cancellationToken); + } + + private static async Task GetSettingsSizeAsync(string dataRoot, CancellationToken cancellationToken) + { + return await Task.Run(() => + { + long size = 0; + var countedFiles = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var file in SettingsRootFiles) + { + cancellationToken.ThrowIfCancellationRequested(); + var path = Path.Combine(dataRoot, file); + if (File.Exists(path)) + { + try + { + var fullPath = Path.GetFullPath(path); + size += new FileInfo(fullPath).Length; + countedFiles.Add(fullPath); + } + catch + { + // Ignore + } + } + } + + // Include root-level auxiliary files (exclude known category directories and logs). + try + { + var excludedDirs = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var relativeDir in CategoryRelativeDirectories) + { + excludedDirs.Add(Path.GetFullPath(Path.Combine(dataRoot, relativeDir))); + } + + var logDir = AppLogger.LogDirectory; + if (!string.IsNullOrWhiteSpace(logDir)) + { + excludedDirs.Add(Path.GetFullPath(logDir)); + } + + foreach (var file in Directory.EnumerateFiles(dataRoot, "*", SearchOption.TopDirectoryOnly)) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var info = new FileInfo(file); + if (!info.Exists) + { + continue; + } + + var fullPath = Path.GetFullPath(file); + if (countedFiles.Contains(fullPath)) + { + continue; + } + + size += info.Length; + countedFiles.Add(fullPath); + } + catch + { + // Ignore file probe failures + } + } + + foreach (var directory in Directory.EnumerateDirectories(dataRoot, "*", SearchOption.TopDirectoryOnly)) + { + cancellationToken.ThrowIfCancellationRequested(); + var fullPath = Path.GetFullPath(directory); + if (excludedDirs.Contains(fullPath)) + { + continue; + } + + size += GetDirectorySizeCore(fullPath, cancellationToken); + } + } + catch + { + // Ignore directory enumeration errors + } + + return size; + }, cancellationToken); + } + + private static long GetDirectorySizeCore(string path, CancellationToken cancellationToken) + { + long size = 0; + try + { + foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories)) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var info = new FileInfo(file); + if (info.Exists) + { + size += info.Length; + } + } + catch + { + // Ignore files we can't access + } + } + } + catch + { + // Ignore directories we can't access + } + + return size; + } + + private static void TryDeleteFile(string path) + { + try + { + File.SetAttributes(path, FileAttributes.Normal); + File.Delete(path); + } + catch + { + // Ignore deletion failures + } + } + + private static void TryDeleteDirectory(string path) + { + try + { + Directory.Delete(path, false); + } + catch + { + // Ignore deletion failures + } + } + + public static string FormatBytes(long bytes) + { + const long KB = 1024; + const long MB = KB * 1024; + const long GB = MB * 1024; + const long TB = GB * 1024; + + return bytes switch + { + >= TB => $"{bytes / (double)TB:F2} TB", + >= GB => $"{bytes / (double)GB:F2} GB", + >= MB => $"{bytes / (double)MB:F2} MB", + >= KB => $"{bytes / (double)KB:F2} KB", + _ => $"{bytes} B" + }; + } +} diff --git a/LanMountainDesktop/ViewModels/DataSettingsPageViewModel.cs b/LanMountainDesktop/ViewModels/DataSettingsPageViewModel.cs new file mode 100644 index 0000000..ba4353f --- /dev/null +++ b/LanMountainDesktop/ViewModels/DataSettingsPageViewModel.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LanMountainDesktop.Services; + +namespace LanMountainDesktop.ViewModels; + +public sealed partial class DataStorageItemViewModel : ObservableObject +{ + public string Id { get; } + public string Name { get; } + public string Description { get; } + public string ColorHex { get; } + public bool IsCleanable { get; } + + [ObservableProperty] + private string _sizeText = "--"; + + [ObservableProperty] + private double _percentage; + + [ObservableProperty] + private bool _isCleaning; + + public DataStorageItemViewModel(StorageCategoryInfo category) + { + Id = category.Id; + Name = category.Name; + Description = category.Description; + ColorHex = category.ColorHex; + IsCleanable = category.IsCleanable; + } + + public void UpdateSize(long sizeBytes, double percentage) + { + SizeText = DataStorageService.FormatBytes(sizeBytes); + Percentage = percentage; + } +} + +public sealed partial class DataSettingsPageViewModel : ViewModelBase +{ + private readonly DataStorageService _storageService = new(); + private CancellationTokenSource? _scanCts; + + [ObservableProperty] + private string _pageTitle = "数据"; + + [ObservableProperty] + private string _totalSizeText = "--"; + + [ObservableProperty] + private string _diskUsageText = "--"; + + [ObservableProperty] + private double _diskUsagePercentage; + + [ObservableProperty] + private bool _isScanning; + + [ObservableProperty] + private bool _hasData; + + public ObservableCollection Items { get; } = new(); + + public DataSettingsPageViewModel() + { + var categories = _storageService.GetCategories(); + foreach (var category in categories) + { + Items.Add(new DataStorageItemViewModel(category)); + } + + _ = ScanAsync(); + } + + [RelayCommand] + private async Task ScanAsync() + { + _scanCts?.Cancel(); + _scanCts = new CancellationTokenSource(); + var token = _scanCts.Token; + + IsScanning = true; + try + { + var results = await _storageService.ScanAsync(token); + var totalSize = results.Sum(r => r.SizeBytes); + var totalDisk = await _storageService.GetTotalDiskSpaceAsync(token); + + await Dispatcher.UIThread.InvokeAsync(() => + { + TotalSizeText = DataStorageService.FormatBytes(totalSize); + DiskUsagePercentage = totalDisk > 0 ? (double)totalSize / totalDisk * 100 : 0; + DiskUsageText = $"占总磁盘 {DiskUsagePercentage:F1}%"; + HasData = totalSize > 0; + + foreach (var result in results) + { + var item = Items.FirstOrDefault(i => + string.Equals(i.Id, result.Category.Id, StringComparison.OrdinalIgnoreCase)); + item?.UpdateSize(result.SizeBytes, result.PercentageOfTotal); + } + }); + } + catch (OperationCanceledException) + { + // Ignore cancellation + } + catch (Exception ex) + { + AppLogger.Warn("DataSettings", "Failed to scan storage.", ex); + } + finally + { + IsScanning = false; + } + } + + [RelayCommand] + private async Task CleanAsync(string categoryId) + { + var item = Items.FirstOrDefault(i => + string.Equals(i.Id, categoryId, StringComparison.OrdinalIgnoreCase)); + if (item is null || !item.IsCleanable) + { + return; + } + + item.IsCleaning = true; + try + { + await _storageService.CleanCategoryAsync(categoryId); + await ScanAsync(); + } + catch (Exception ex) + { + AppLogger.Warn("DataSettings", $"Failed to clean category '{categoryId}'.", ex); + } + finally + { + item.IsCleaning = false; + } + } + + [RelayCommand] + private async Task CleanAllAsync() + { + foreach (var item in Items.Where(i => i.IsCleanable)) + { + item.IsCleaning = true; + } + + try + { + foreach (var item in Items.Where(i => i.IsCleanable)) + { + await _storageService.CleanCategoryAsync(item.Id); + } + + await ScanAsync(); + } + catch (Exception ex) + { + AppLogger.Warn("DataSettings", "Failed to clean all categories.", ex); + } + finally + { + foreach (var item in Items) + { + item.IsCleaning = false; + } + } + } +} diff --git a/LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml new file mode 100644 index 0000000..c6b6d51 --- /dev/null +++ b/LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml @@ -0,0 +1,176 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml.cs b/LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml.cs new file mode 100644 index 0000000..f8c6cbb --- /dev/null +++ b/LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml.cs @@ -0,0 +1,138 @@ +using System; +using System.ComponentModel; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using LanMountainDesktop.PluginSdk; +using LanMountainDesktop.ViewModels; + +namespace LanMountainDesktop.Views.SettingsPages; + +[SettingsPageInfo( + "data", + "数据", + SettingsPageCategory.General, + IconKey = "HardDrive", + SortOrder = 5, + TitleLocalizationKey = "settings.data.title", + DescriptionLocalizationKey = "settings.data.description")] +public partial class DataSettingsPage : SettingsPageBase +{ + private readonly SolidColorBrush _fallbackBrush = new(Colors.Gray); + + public DataSettingsPage() + : this(new DataSettingsPageViewModel()) + { + } + + public DataSettingsPage(DataSettingsPageViewModel viewModel) + { + ViewModel = viewModel; + DataContext = ViewModel; + InitializeComponent(); + AttachStorageBarObservers(); + RebuildStorageBar(); + } + + public DataSettingsPageViewModel ViewModel { get; } + + private void AttachStorageBarObservers() + { + ViewModel.PropertyChanged += OnViewModelPropertyChanged; + foreach (var item in ViewModel.Items) + { + item.PropertyChanged += OnStorageItemPropertyChanged; + } + } + + private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (string.Equals(e.PropertyName, nameof(DataSettingsPageViewModel.Items), StringComparison.Ordinal)) + { + RebuildStorageBar(); + return; + } + + if (string.Equals(e.PropertyName, nameof(DataSettingsPageViewModel.HasData), StringComparison.Ordinal)) + { + RebuildStorageBar(); + } + } + + private void OnStorageItemPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (string.Equals(e.PropertyName, nameof(DataStorageItemViewModel.Percentage), StringComparison.Ordinal) || + string.Equals(e.PropertyName, nameof(DataStorageItemViewModel.ColorHex), StringComparison.Ordinal)) + { + RebuildStorageBar(); + } + } + + private void RebuildStorageBar() + { + if (StorageBarGrid is null) + { + return; + } + + 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 segment = new Border + { + Background = ParseBrush(item.ColorHex), + HorizontalAlignment = HorizontalAlignment.Stretch, + VerticalAlignment = VerticalAlignment.Stretch + }; + + Grid.SetColumn(segment, idx++); + StorageBarGrid.Children.Add(segment); + } + } + + private IBrush ParseBrush(string? hex) + { + if (string.IsNullOrWhiteSpace(hex)) + { + return _fallbackBrush; + } + + try + { + return new SolidColorBrush(Color.Parse(hex)); + } + catch + { + return _fallbackBrush; + } + } +} diff --git a/LanMountainDesktop/Views/SettingsWindow.axaml.cs b/LanMountainDesktop/Views/SettingsWindow.axaml.cs index 2fdd2fd..916cc36 100644 --- a/LanMountainDesktop/Views/SettingsWindow.axaml.cs +++ b/LanMountainDesktop/Views/SettingsWindow.axaml.cs @@ -1067,6 +1067,7 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext "DeveloperBoard" => Symbol.DeveloperBoard, "FolderLink" => Symbol.FolderLink, "WindowConsole" => Symbol.WindowConsole, + "HardDrive" => Symbol.HardDrive, _ => Symbol.Settings }; } diff --git a/LanMountainDesktop/installer/LanMountainDesktop.iss b/LanMountainDesktop/installer/LanMountainDesktop.iss index b23f9be..bcca977 100644 --- a/LanMountainDesktop/installer/LanMountainDesktop.iss +++ b/LanMountainDesktop/installer/LanMountainDesktop.iss @@ -129,11 +129,11 @@ Name: "startup"; Description: "{cm:StartupTaskDescription}"; GroupDescription: " Name: "{app}\log"; Permissions: users-modify [InstallDelete] -Type: files; Name: "{app}\LanMontainDesktop.exe" -Type: files; Name: "{app}\LanMontainDesktop.dll" -Type: files; Name: "{app}\LanMontainDesktop.deps.json" -Type: files; Name: "{app}\LanMontainDesktop.runtimeconfig.json" -Type: files; Name: "{app}\LanMontainDesktop.pdb" +Type: files; Name: "{app}\LanMountainDesktop.exe" +Type: files; Name: "{app}\LanMountainDesktop.dll" +Type: files; Name: "{app}\LanMountainDesktop.deps.json" +Type: files; Name: "{app}\LanMountainDesktop.runtimeconfig.json" +Type: files; Name: "{app}\LanMountainDesktop.pdb" [Files] Source: "{#PublishDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs @@ -391,10 +391,6 @@ begin begin Params := Params + ' /SILENT'; end; - if WizardVerySilent then - begin - Params := Params + ' /VERYSILENT'; - end; { 重启安装程序并退出当前实例 } if Exec(ExpandConstant('{srcexe}'), Params, '', SW_SHOWNORMAL, ewNoWait, ResultCode) then