mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 15:44:25 +08:00
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.
This commit is contained in:
104
.trae/specs/data-settings-page/design.md
Normal file
104
.trae/specs/data-settings-page/design.md
Normal file
@@ -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. 清理完成后自动刷新数据
|
||||
|
||||
## 安全考虑
|
||||
|
||||
- 清理前确认用户意图
|
||||
- 设置文件不可清理(防止误删配置)
|
||||
- 清理操作记录日志
|
||||
777
.trae/specs/data-settings-page/plan.md
Normal file
777
.trae/specs/data-settings-page/plan.md
Normal file
@@ -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<StorageCategoryInfo> Categories = new List<StorageCategoryInfo>
|
||||
{
|
||||
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<StorageCategoryInfo> GetCategories() => Categories;
|
||||
|
||||
public async Task<IReadOnlyList<StorageScanResult>> ScanAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = new List<StorageScanResult>();
|
||||
var dataRoot = AppDataPathProvider.GetDataRoot();
|
||||
var logDirectory = AppLogger.LogDirectory;
|
||||
|
||||
long totalSize = 0;
|
||||
var categorySizes = new Dictionary<string, long>();
|
||||
|
||||
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<long> 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<long> 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<bool> 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<long> 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<long> 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<DataStorageItemViewModel> 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
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
x:Class="LanMountainDesktop.Views.SettingsPages.DataSettingsPage"
|
||||
x:DataType="vm:DataSettingsPageViewModel">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Classes="settings-page-container settings-page-animated"
|
||||
Spacing="16">
|
||||
|
||||
<!-- 存储概览卡片 -->
|
||||
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
Padding="20">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="存储概览"
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold" />
|
||||
|
||||
<!-- 堆叠条形图 -->
|
||||
<Grid Height="28"
|
||||
IsVisible="{Binding HasData}">
|
||||
<Border Background="{DynamicResource ControlFillColorTertiaryBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
|
||||
ClipToBounds="True">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
x:Name="StorageBarPanel">
|
||||
<!-- 动态生成分段 -->
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- 总大小和磁盘占比 -->
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0"
|
||||
Orientation="Horizontal"
|
||||
Spacing="8">
|
||||
<TextBlock Text="{Binding TotalSizeText}"
|
||||
FontSize="24"
|
||||
FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding DiskUsageText}"
|
||||
VerticalAlignment="Bottom"
|
||||
Margin="0,0,0,4"
|
||||
Opacity="0.7" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1"
|
||||
Command="{Binding ScanCommand}"
|
||||
IsEnabled="{Binding !IsScanning}"
|
||||
VerticalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="6">
|
||||
<fi:FluentIcon Icon="ArrowSync"
|
||||
IconVariant="Regular"
|
||||
FontSize="14" />
|
||||
<TextBlock Text="刷新" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<!-- 图例 -->
|
||||
<ItemsControl ItemsSource="{Binding Items}"
|
||||
IsVisible="{Binding HasData}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal"
|
||||
ItemWidth="140"
|
||||
ItemHeight="28" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:DataStorageItemViewModel">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="6"
|
||||
VerticalAlignment="Center">
|
||||
<Border Width="12"
|
||||
Height="12"
|
||||
CornerRadius="2"
|
||||
Background="{Binding ColorHex, Converter={StaticResource HexToBrushConverter}}" />
|
||||
<TextBlock Text="{Binding Name}"
|
||||
FontSize="12"
|
||||
Opacity="0.8" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 数据类型详情列表 -->
|
||||
<TextBlock Text="数据详情"
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold"
|
||||
Margin="0,8,0,0" />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Items}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:DataStorageItemViewModel">
|
||||
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
|
||||
Padding="16"
|
||||
Margin="0,4">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto"
|
||||
ColumnSpacing="12">
|
||||
<Border Grid.Column="0"
|
||||
Width="12"
|
||||
Height="12"
|
||||
CornerRadius="2"
|
||||
Background="{Binding ColorHex, Converter={StaticResource HexToBrushConverter}}"
|
||||
VerticalAlignment="Center" />
|
||||
|
||||
<StackPanel Grid.Column="1"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Name}"
|
||||
FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding Description}"
|
||||
FontSize="12"
|
||||
Opacity="0.6" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Grid.Column="2"
|
||||
Text="{Binding SizeText}"
|
||||
VerticalAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Opacity="0.8" />
|
||||
|
||||
<Button Grid.Column="3"
|
||||
Command="{Binding $parent[ItemsControl].((vm:DataSettingsPageViewModel)DataContext).CleanCommand}"
|
||||
CommandParameter="{Binding Id}"
|
||||
IsVisible="{Binding IsCleanable}"
|
||||
IsEnabled="{Binding !IsCleaning}"
|
||||
VerticalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="4">
|
||||
<fi:FluentIcon Icon="Delete"
|
||||
IconVariant="Regular"
|
||||
FontSize="14" />
|
||||
<TextBlock Text="清理" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- 一键清理 -->
|
||||
<Button Command="{Binding CleanAllCommand}"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center"
|
||||
Margin="0,8">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="6">
|
||||
<fi:FluentIcon Icon="Broom"
|
||||
IconVariant="Regular"
|
||||
FontSize="16" />
|
||||
<TextBlock Text="一键清理所有可清理数据" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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. 点击「数据」选项卡,确认:
|
||||
- 堆叠条形图显示各类数据占比
|
||||
- 总大小和磁盘占比显示正确
|
||||
- 数据详情列表显示每类数据大小
|
||||
- 刷新按钮可以重新扫描
|
||||
- 清理按钮可以清理对应数据
|
||||
@@ -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 @@
|
||||
<FontFamily x:Key="AppFontFamily">MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans, avares://LanMountainDesktop/Assets/Fonts#MiSans VF</FontFamily>
|
||||
<FontFamily x:Key="AppFontFamilyJP">avares://LanMountainDesktop/Assets/Fonts#MiSans JP</FontFamily>
|
||||
<FontFamily x:Key="AppFontFamilyKR">avares://LanMountainDesktop/Assets/Fonts#MiSans KR</FontFamily>
|
||||
<conv:HexToColorConverter x:Key="HexToColorConverter" />
|
||||
<conv:HexToBrushConverter x:Key="HexToBrushConverter" />
|
||||
</Application.Resources>
|
||||
|
||||
<Application.DataTemplates>
|
||||
|
||||
31
LanMountainDesktop/Converters/HexToBrushConverter.cs
Normal file
31
LanMountainDesktop/Converters/HexToBrushConverter.cs
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
31
LanMountainDesktop/Converters/HexToColorConverter.cs
Normal file
31
LanMountainDesktop/Converters/HexToColorConverter.cs
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "テーマ",
|
||||
|
||||
@@ -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": "테마",
|
||||
|
||||
@@ -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": "主题",
|
||||
|
||||
357
LanMountainDesktop/Services/DataStorageService.cs
Normal file
357
LanMountainDesktop/Services/DataStorageService.cs
Normal file
@@ -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<StorageCategoryInfo> Categories = new List<StorageCategoryInfo>
|
||||
{
|
||||
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<StorageCategoryInfo> GetCategories() => Categories;
|
||||
|
||||
public async Task<IReadOnlyList<StorageScanResult>> ScanAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = new List<StorageScanResult>();
|
||||
var dataRoot = AppDataPathProvider.GetDataRoot();
|
||||
var logDirectory = AppLogger.LogDirectory;
|
||||
|
||||
long totalSize = 0;
|
||||
var categorySizes = new Dictionary<string, long>();
|
||||
|
||||
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<long> 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<long> 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<bool> 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<long> GetDirectorySizeAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
return await Task.Run(() => GetDirectorySizeCore(path, cancellationToken), cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<long> GetSettingsSizeAsync(string dataRoot, CancellationToken cancellationToken)
|
||||
{
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
long size = 0;
|
||||
var countedFiles = new HashSet<string>(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<string>(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"
|
||||
};
|
||||
}
|
||||
}
|
||||
181
LanMountainDesktop/ViewModels/DataSettingsPageViewModel.cs
Normal file
181
LanMountainDesktop/ViewModels/DataSettingsPageViewModel.cs
Normal file
@@ -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<DataStorageItemViewModel> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
176
LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml
Normal file
176
LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml
Normal file
@@ -0,0 +1,176 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
x:Class="LanMountainDesktop.Views.SettingsPages.DataSettingsPage"
|
||||
x:DataType="vm:DataSettingsPageViewModel">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Classes="settings-page-container settings-page-animated"
|
||||
Spacing="0">
|
||||
|
||||
<ui:FASettingsExpander Classes="settings-expander-card"
|
||||
Header="存储概览"
|
||||
Description="本地应用数据的总占用及对当前磁盘的估算占比。"
|
||||
IsExpanded="True">
|
||||
<ui:FASettingsExpander.Footer>
|
||||
<Button Padding="12,8"
|
||||
Command="{Binding ScanCommand}"
|
||||
IsEnabled="{Binding !IsScanning}">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="8">
|
||||
<fi:FluentIcon Icon="ArrowSync"
|
||||
IconVariant="Regular"
|
||||
FontSize="14" />
|
||||
<TextBlock Text="重新扫描" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</ui:FASettingsExpander.Footer>
|
||||
|
||||
<ui:FASettingsExpanderItem>
|
||||
<StackPanel Spacing="16">
|
||||
<Border Height="22"
|
||||
Background="{DynamicResource ControlFillColorTertiaryBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
|
||||
ClipToBounds="True"
|
||||
IsVisible="{Binding HasData}">
|
||||
<Grid x:Name="StorageBarGrid" />
|
||||
</Border>
|
||||
|
||||
<ProgressBar Height="8"
|
||||
Minimum="0"
|
||||
Maximum="100"
|
||||
IsIndeterminate="{Binding IsScanning}"
|
||||
Value="{Binding DiskUsagePercentage}" />
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="16">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding TotalSizeText}"
|
||||
FontSize="22"
|
||||
FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding DiskUsageText}"
|
||||
FontSize="13"
|
||||
Opacity="0.72" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Text="正在扫描…"
|
||||
FontSize="13"
|
||||
Opacity="0.72"
|
||||
IsVisible="{Binding IsScanning}" />
|
||||
|
||||
<TextBlock Text="暂无可用数据。完成首次扫描后将显示占比与图例。"
|
||||
FontSize="13"
|
||||
Opacity="0.72"
|
||||
TextWrapping="Wrap"
|
||||
IsVisible="{Binding !HasData}" />
|
||||
|
||||
<StackPanel Spacing="10"
|
||||
IsVisible="{Binding HasData}">
|
||||
<TextBlock Classes="settings-item-label"
|
||||
Text="分类图例" />
|
||||
<ItemsControl ItemsSource="{Binding Items}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:DataStorageItemViewModel">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="8"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,16,8">
|
||||
<Ellipse Width="10"
|
||||
Height="10"
|
||||
VerticalAlignment="Center"
|
||||
Fill="{Binding ColorHex, Converter={StaticResource HexToBrushConverter}}" />
|
||||
<TextBlock Text="{Binding Name}"
|
||||
FontSize="13"
|
||||
VerticalAlignment="Center"
|
||||
Opacity="0.85" />
|
||||
<TextBlock Text="{Binding Percentage, StringFormat={}{0:F1}%}"
|
||||
FontSize="12"
|
||||
VerticalAlignment="Center"
|
||||
Opacity="0.65" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ui:FASettingsExpanderItem>
|
||||
</ui:FASettingsExpander>
|
||||
|
||||
<ui:FASettingsExpander Classes="settings-expander-card"
|
||||
Header="数据详情"
|
||||
Description="按类别查看占用;可在允许清理的类别上释放空间。"
|
||||
IsExpanded="True">
|
||||
<ui:FASettingsExpanderItem>
|
||||
<ItemsControl ItemsSource="{Binding Items}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:DataStorageItemViewModel">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto"
|
||||
ColumnSpacing="14"
|
||||
Margin="0,0,0,18">
|
||||
<Ellipse Grid.Column="0"
|
||||
Width="10"
|
||||
Height="10"
|
||||
VerticalAlignment="Center"
|
||||
Fill="{Binding ColorHex, Converter={StaticResource HexToBrushConverter}}" />
|
||||
|
||||
<StackPanel Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="4">
|
||||
<TextBlock Classes="settings-item-label"
|
||||
Text="{Binding Name}" />
|
||||
<TextBlock Classes="settings-item-description"
|
||||
Text="{Binding Description}" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Grid.Column="2"
|
||||
Text="{Binding SizeText}"
|
||||
VerticalAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
FontSize="13"
|
||||
Opacity="0.9" />
|
||||
|
||||
<Button Grid.Column="3"
|
||||
Command="{Binding $parent[ItemsControl].((vm:DataSettingsPageViewModel)DataContext).CleanCommand}"
|
||||
CommandParameter="{Binding Id}"
|
||||
IsVisible="{Binding IsCleanable}"
|
||||
IsEnabled="{Binding !IsCleaning}"
|
||||
VerticalAlignment="Center"
|
||||
Padding="10,6">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="6">
|
||||
<fi:FluentIcon Icon="Delete"
|
||||
IconVariant="Regular"
|
||||
FontSize="14" />
|
||||
<TextBlock Text="清理" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ui:FASettingsExpanderItem>
|
||||
</ui:FASettingsExpander>
|
||||
|
||||
<Button Classes="settings-accent-button"
|
||||
Command="{Binding CleanAllCommand}"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center"
|
||||
Margin="0,4,0,0">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="8">
|
||||
<fi:FluentIcon Icon="Broom"
|
||||
IconVariant="Regular"
|
||||
FontSize="16" />
|
||||
<TextBlock Text="一键清理所有可清理数据" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
138
LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml.cs
Normal file
138
LanMountainDesktop/Views/SettingsPages/DataSettingsPage.axaml.cs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1067,6 +1067,7 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext
|
||||
"DeveloperBoard" => Symbol.DeveloperBoard,
|
||||
"FolderLink" => Symbol.FolderLink,
|
||||
"WindowConsole" => Symbol.WindowConsole,
|
||||
"HardDrive" => Symbol.HardDrive,
|
||||
_ => Symbol.Settings
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user