From 87afe57bfad276aaa09579e36cb3e9092ae6bd67 Mon Sep 17 00:00:00 2001 From: Oliver Wong Date: Tue, 26 May 2026 14:14:11 +0800 Subject: [PATCH] WIP: GUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 静态分析没有问题了。但是一尝试扫描文件夹就炸。怀疑是权限问题,但不确定。 --- src/Core/Database.cs | 82 +++++++++++++ src/GUI/Converters/ValueConverters.cs | 112 ++++++++++++++++++ src/GUI/GUI.csproj | 1 + src/GUI/Services/SyncChannel.cs | 26 ++++ src/GUI/Services/SyncChannelManager.cs | 18 +++ src/GUI/ViewModels/FileListViewModel.cs | 118 +++++++++++++++++++ src/GUI/ViewModels/MainWindowViewModel.cs | 61 +++++++++- src/GUI/ViewModels/MetadataPanelViewModel.cs | 113 ++++++++++++++++++ src/GUI/ViewModels/PlayerBarViewModel.cs | 73 ++++++++++++ src/GUI/ViewModels/ScanProgressViewModel.cs | 109 +++++++++++++++++ src/GUI/ViewModels/TagTreeViewModel.cs | 92 +++++++++++++++ src/GUI/Views/FileListView.axaml | 93 +++++++++++++++ src/GUI/Views/FileListView.axaml.cs | 11 ++ src/GUI/Views/MainWindow.axaml | 52 +++++++- src/GUI/Views/MainWindow.axaml.cs | 20 ++++ src/GUI/Views/MetadataPanelView.axaml | 96 +++++++++++++++ src/GUI/Views/MetadataPanelView.axaml.cs | 11 ++ src/GUI/Views/PlayerBarView.axaml | 50 ++++++++ src/GUI/Views/PlayerBarView.axaml.cs | 11 ++ src/GUI/Views/ScanProgressView.axaml | 34 ++++++ src/GUI/Views/ScanProgressView.axaml.cs | 11 ++ src/GUI/Views/TagTreeView.axaml | 53 +++++++++ src/GUI/Views/TagTreeView.axaml.cs | 11 ++ 23 files changed, 1251 insertions(+), 7 deletions(-) create mode 100644 src/GUI/Converters/ValueConverters.cs create mode 100644 src/GUI/Services/SyncChannel.cs create mode 100644 src/GUI/Services/SyncChannelManager.cs create mode 100644 src/GUI/ViewModels/FileListViewModel.cs create mode 100644 src/GUI/ViewModels/MetadataPanelViewModel.cs create mode 100644 src/GUI/ViewModels/PlayerBarViewModel.cs create mode 100644 src/GUI/ViewModels/ScanProgressViewModel.cs create mode 100644 src/GUI/ViewModels/TagTreeViewModel.cs create mode 100644 src/GUI/Views/FileListView.axaml create mode 100644 src/GUI/Views/FileListView.axaml.cs create mode 100644 src/GUI/Views/MetadataPanelView.axaml create mode 100644 src/GUI/Views/MetadataPanelView.axaml.cs create mode 100644 src/GUI/Views/PlayerBarView.axaml create mode 100644 src/GUI/Views/PlayerBarView.axaml.cs create mode 100644 src/GUI/Views/ScanProgressView.axaml create mode 100644 src/GUI/Views/ScanProgressView.axaml.cs create mode 100644 src/GUI/Views/TagTreeView.axaml create mode 100644 src/GUI/Views/TagTreeView.axaml.cs diff --git a/src/Core/Database.cs b/src/Core/Database.cs index d792f79..a4c1281 100755 --- a/src/Core/Database.cs +++ b/src/Core/Database.cs @@ -464,5 +464,87 @@ public static class Database return connection.ExecuteScalar(sql, parameters); } + /// + /// 获取所有唯一的分类值 + /// + public static IEnumerable GetAllCategories() + { + using IDbConnection connection = GetConnection(); + connection.Open(); + const string sql = "SELECT DISTINCT category FROM audio_files WHERE category IS NOT NULL AND category != '' ORDER BY category;"; + return connection.Query(sql); + } + + /// + /// 解析 Keywords 字段,返回所有唯一的标签 + /// + public static IEnumerable GetAllKeywords() + { + using IDbConnection connection = GetConnection(); + connection.Open(); + const string sql = "SELECT keywords FROM audio_files WHERE keywords IS NOT NULL AND keywords != '';"; + var rows = connection.Query(sql); + var allKeywords = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var row in rows) + { + if (string.IsNullOrWhiteSpace(row)) continue; + foreach (var part in row.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (!string.IsNullOrWhiteSpace(part)) + allKeywords.Add(part); + } + } + return allKeywords.OrderBy(k => k, StringComparer.OrdinalIgnoreCase); + } + + /// + /// 更新音频文件的可编辑字段 + /// + public static bool UpdateEntry(AudioFileMeta meta) + { + try + { + using IDbConnection connection = GetConnection(); + connection.Open(); + const string sql = """ + UPDATE audio_files SET + rating = @Rating, + notes = @Notes, + keywords = @Keywords, + category = @Category, + subcategory = @Subcategory, + description = @Description, + comments = @Comments, + fx_name = @FxName, + genre = @Genre, + style = @Style, + mood = @Mood, + library = @Library, + project_name = @ProjectName, + designer = @Designer, + recordist = @Recordist, + publisher = @Publisher, + manufacturer = @Manufacturer, + microphone = @Microphone, + location = @Location, + user1 = @User1, + user2 = @User2, + user3 = @User3, + user4 = @User4, + user5 = @User5, + user6 = @User6, + user7 = @User7, + user8 = @User8 + WHERE id = @Id; + """; + connection.Execute(sql, meta); + return true; + } + catch (Exception) + { + return false; + } + } + #endregion } diff --git a/src/GUI/Converters/ValueConverters.cs b/src/GUI/Converters/ValueConverters.cs new file mode 100644 index 0000000..1f18aa3 --- /dev/null +++ b/src/GUI/Converters/ValueConverters.cs @@ -0,0 +1,112 @@ +using System.Globalization; +using Avalonia.Data.Converters; + +namespace GUI.Converters; + +public class DurationFormatConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is not double duration) return "--:--"; + var ts = TimeSpan.FromSeconds(duration); + if (ts.TotalHours >= 1) + return ts.ToString(@"h\:mm\:ss\.ff"); + return ts.ToString(@"m\:ss\.ff"); + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} + +public class SampleRateFormatConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is not double rate) return "--"; + return $"{rate / 1000.0:F1}k"; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} + +public class ChannelsFormatConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return value switch + { + int ch when ch == 1 => "Mono", + int ch when ch == 2 => "Stereo", + null => "--", + int ch => $"{ch}ch", + _ => "--" + }; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} + +public class RatingStarsConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is not uint rating || rating == 0) return "☆☆☆☆☆"; + return new string('★', (int)rating) + new string('☆', 5 - (int)rating); + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} + +public class DurationToSliderMaxConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is not double duration) return 1.0; + return Math.Ceiling(duration); + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} + +public class PositionFormatConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is not double pos) return "0:00"; + var ts = TimeSpan.FromSeconds(pos); + return ts.ToString(@"m\:ss"); + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} + +public class InverseBoolConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is bool b) return !b; + return true; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is bool b) return !b; + return true; + } +} + +public static class ValueConverters +{ + public static readonly IValueConverter DurationFormat = new DurationFormatConverter(); + public static readonly IValueConverter SampleRateFormat = new SampleRateFormatConverter(); + public static readonly IValueConverter ChannelsFormat = new ChannelsFormatConverter(); + public static readonly IValueConverter RatingStars = new RatingStarsConverter(); + public static readonly IValueConverter DurationToSliderMax = new DurationToSliderMaxConverter(); + public static readonly IValueConverter PositionFormat = new PositionFormatConverter(); + public static readonly IValueConverter InverseBool = new InverseBoolConverter(); +} diff --git a/src/GUI/GUI.csproj b/src/GUI/GUI.csproj index 59b4e5a..4a94da3 100644 --- a/src/GUI/GUI.csproj +++ b/src/GUI/GUI.csproj @@ -3,6 +3,7 @@ WinExe net10.0 enable + enable app.manifest true diff --git a/src/GUI/Services/SyncChannel.cs b/src/GUI/Services/SyncChannel.cs new file mode 100644 index 0000000..07ebbc3 --- /dev/null +++ b/src/GUI/Services/SyncChannel.cs @@ -0,0 +1,26 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using OCES.Resonance.Core; + +namespace GUI.Services; + +public partial class SyncChannel : ObservableObject +{ + public string Id { get; } + + [ObservableProperty] + private AudioFileMeta? _selectedFile; + + [ObservableProperty] + private string? _filterCategory; + + [ObservableProperty] + private string? _filterKeyword; + + [ObservableProperty] + private string _searchText = string.Empty; + + public SyncChannel(string id) + { + Id = id; + } +} diff --git a/src/GUI/Services/SyncChannelManager.cs b/src/GUI/Services/SyncChannelManager.cs new file mode 100644 index 0000000..4fca06d --- /dev/null +++ b/src/GUI/Services/SyncChannelManager.cs @@ -0,0 +1,18 @@ +namespace GUI.Services; + +public class SyncChannelManager +{ + private readonly Dictionary _channels = new(); + + public SyncChannel GetOrCreate(string id) + { + if (!_channels.TryGetValue(id, out var channel)) + { + channel = new SyncChannel(id); + _channels[id] = channel; + } + return channel; + } + + public SyncChannel Default => GetOrCreate("default"); +} diff --git a/src/GUI/ViewModels/FileListViewModel.cs b/src/GUI/ViewModels/FileListViewModel.cs new file mode 100644 index 0000000..4f126a5 --- /dev/null +++ b/src/GUI/ViewModels/FileListViewModel.cs @@ -0,0 +1,118 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using GUI.Services; +using OCES.Resonance.Core; + +namespace GUI.ViewModels; + +public partial class FileListViewModel : ViewModelBase +{ + private readonly SyncChannel _channel; + + public ObservableCollection Files { get; } = new(); + + [ObservableProperty] private AudioFileMeta? _selectedFile; + + [ObservableProperty] private string _searchText = string.Empty; + + [ObservableProperty] private string? _selectedSortBy = "date_added"; + [ObservableProperty] private bool _sortDescending = true; + + [ObservableProperty] private double? _filterMinDuration; + [ObservableProperty] private double? _filterMaxDuration; + [ObservableProperty] private int? _filterSampleRate; + [ObservableProperty] private int? _filterChannels; + + [ObservableProperty] private bool _isLoading; + + public string[] SortOptions { get; } = + [ + "date_added", "filename", "duration", "sample_rate", "channels", "rating", "type", "category" + ]; + + public FileListViewModel(SyncChannel channel) + { + _channel = channel; + + _channel.PropertyChanged += (_, e) => + { + if (e.PropertyName is nameof(SyncChannel.FilterCategory) + or nameof(SyncChannel.FilterKeyword) + or nameof(SyncChannel.SearchText)) + { + SearchText = _channel.SearchText; + RefreshFiles(); + } + }; + } + + partial void OnSelectedFileChanged(AudioFileMeta? value) + { + _channel.SelectedFile = value; + } + + [RelayCommand] + private void RefreshFiles() + { + try + { + IsLoading = true; + Files.Clear(); + + var entries = Database.QueryEntries( + searchText: string.IsNullOrWhiteSpace(_channel.SearchText) ? null : _channel.SearchText, + category: _channel.FilterCategory, + sortBy: SelectedSortBy, + sortDescending: SortDescending, + minDuration: FilterMinDuration, + maxDuration: FilterMaxDuration, + sampleRate: FilterSampleRate, + channels: FilterChannels, + limit: 500 + ); + + // 如果有 FilterKeyword,在客户端过滤(Keywords 字段 LIKE 搜索) + foreach (var entry in entries) + { + if (!string.IsNullOrWhiteSpace(_channel.FilterKeyword)) + { + if (entry.Keywords == null || + !entry.Keywords.Split(',', StringSplitOptions.TrimEntries) + .Any(k => k.Equals(_channel.FilterKeyword, StringComparison.OrdinalIgnoreCase))) + continue; + } + Files.Add(entry); + } + + IsLoading = false; + } + catch (Exception) + { + IsLoading = false; + } + } + + [RelayCommand] + private void ApplyFilters() + { + RefreshFiles(); + } + + [RelayCommand] + private void ResetFilters() + { + FilterMinDuration = null; + FilterMaxDuration = null; + FilterSampleRate = null; + FilterChannels = null; + _channel.SearchText = string.Empty; + SearchText = string.Empty; + RefreshFiles(); + } + + partial void OnSearchTextChanged(string value) + { + _channel.SearchText = value; + } +} diff --git a/src/GUI/ViewModels/MainWindowViewModel.cs b/src/GUI/ViewModels/MainWindowViewModel.cs index 7a2194a..28db2a9 100644 --- a/src/GUI/ViewModels/MainWindowViewModel.cs +++ b/src/GUI/ViewModels/MainWindowViewModel.cs @@ -1,6 +1,63 @@ -namespace GUI.ViewModels; +using CommunityToolkit.Mvvm.ComponentModel; +using GUI.Services; + +namespace GUI.ViewModels; public partial class MainWindowViewModel : ViewModelBase { - public string Greeting { get; } = "Welcome to Avalonia!"; + private readonly SyncChannelManager _channelManager; + private readonly SyncChannel _channel; + + public TagTreeViewModel TagTreeVM { get; } + public FileListViewModel FileListVM { get; } + public MetadataPanelViewModel MetadataPanelVM { get; } + public PlayerBarViewModel PlayerBarVM { get; } + public ScanProgressViewModel ScanProgressVM { get; } + + [ObservableProperty] private bool _isScanning; + + public MainWindowViewModel() + { + _channelManager = new SyncChannelManager(); + _channel = _channelManager.Default; + + TagTreeVM = new TagTreeViewModel(_channel); + FileListVM = new FileListViewModel(_channel); + MetadataPanelVM = new MetadataPanelViewModel(_channel); + PlayerBarVM = new PlayerBarViewModel(_channel); + ScanProgressVM = new ScanProgressViewModel(); + + ScanProgressVM.ScanCompleted += OnScanCompleted; + + // 启动时加载已有数据 + LoadExistingData(); + } + + private void LoadExistingData() + { + try + { + OCES.Resonance.Core.Database.InitializeDatabase(); + TagTreeVM.LoadDataCommand.Execute(null); + FileListVM.RefreshFilesCommand.Execute(null); + } + catch (Exception) + { + // 数据库尚未初始化或暂无数据,静默处理 + } + } + + private void OnScanCompleted() + { + IsScanning = false; + TagTreeVM.LoadDataCommand.Execute(null); + FileListVM.RefreshFilesCommand.Execute(null); + } + + public void RequestScan(string directory) + { + if (string.IsNullOrWhiteSpace(directory)) return; + IsScanning = true; + ScanProgressVM.StartScanCommand.Execute(directory); + } } diff --git a/src/GUI/ViewModels/MetadataPanelViewModel.cs b/src/GUI/ViewModels/MetadataPanelViewModel.cs new file mode 100644 index 0000000..4d0620c --- /dev/null +++ b/src/GUI/ViewModels/MetadataPanelViewModel.cs @@ -0,0 +1,113 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using GUI.Services; +using OCES.Resonance.Core; + +namespace GUI.ViewModels; + +public partial class MetadataPanelViewModel : ViewModelBase +{ + private readonly SyncChannel _channel; + + [ObservableProperty] private AudioFileMeta? _currentFile; + + [ObservableProperty] private bool _hasFile; + + [ObservableProperty] private string _keywordsText = string.Empty; + [ObservableProperty] private string _notesText = string.Empty; + [ObservableProperty] private string _categoryText = string.Empty; + [ObservableProperty] private string _descriptionText = string.Empty; + [ObservableProperty] private uint _rating; + [ObservableProperty] private bool _isDirty; + + public MetadataPanelViewModel(SyncChannel channel) + { + _channel = channel; + + _channel.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(SyncChannel.SelectedFile)) + { + LoadFile(_channel.SelectedFile); + } + }; + } + + private void LoadFile(AudioFileMeta? file) + { + CurrentFile = file; + if (file == null) + { + KeywordsText = string.Empty; + NotesText = string.Empty; + CategoryText = string.Empty; + DescriptionText = string.Empty; + Rating = 0; + IsDirty = false; + HasFile = false; + return; + } + + KeywordsText = file.Keywords ?? string.Empty; + NotesText = file.Notes ?? string.Empty; + CategoryText = file.Category ?? string.Empty; + DescriptionText = file.Description ?? string.Empty; + Rating = file.Rating ?? 0; + IsDirty = false; + HasFile = true; + } + + partial void OnKeywordsTextChanged(string value) + { + if (CurrentFile != null && value != (CurrentFile.Keywords ?? string.Empty)) + IsDirty = true; + } + + partial void OnNotesTextChanged(string value) + { + if (CurrentFile != null && value != (CurrentFile.Notes ?? string.Empty)) + IsDirty = true; + } + + partial void OnCategoryTextChanged(string value) + { + if (CurrentFile != null && value != (CurrentFile.Category ?? string.Empty)) + IsDirty = true; + } + + partial void OnDescriptionTextChanged(string value) + { + if (CurrentFile != null && value != (CurrentFile.Description ?? string.Empty)) + IsDirty = true; + } + + partial void OnRatingChanged(uint value) + { + if (CurrentFile != null && value != (CurrentFile.Rating ?? 0)) + IsDirty = true; + } + + [RelayCommand] + private void Save() + { + if (CurrentFile == null) return; + + CurrentFile.Keywords = KeywordsText; + CurrentFile.Notes = NotesText; + CurrentFile.Category = CategoryText; + CurrentFile.Description = DescriptionText; + CurrentFile.Rating = Rating; + + Database.UpdateEntry(CurrentFile); + IsDirty = false; + + // 刷新 selected file 以反映变更 + _channel.SelectedFile = CurrentFile; + } + + [RelayCommand] + private void SetRating(uint rating) + { + Rating = rating; + } +} diff --git a/src/GUI/ViewModels/PlayerBarViewModel.cs b/src/GUI/ViewModels/PlayerBarViewModel.cs new file mode 100644 index 0000000..6a89e67 --- /dev/null +++ b/src/GUI/ViewModels/PlayerBarViewModel.cs @@ -0,0 +1,73 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using GUI.Services; +using OCES.Resonance.Core; + +namespace GUI.ViewModels; + +public partial class PlayerBarViewModel : ViewModelBase +{ + private readonly SyncChannel _channel; + + [ObservableProperty] private AudioFileMeta? _currentFile; + [ObservableProperty] private bool _hasFile; + [ObservableProperty] private string _currentFileName = "未选择文件"; + + [ObservableProperty] private bool _isPlaying; + [ObservableProperty] private double _currentPosition; + [ObservableProperty] private double _duration; + [ObservableProperty] private double _volume = 0.8; + + public PlayerBarViewModel(SyncChannel channel) + { + _channel = channel; + + _channel.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(SyncChannel.SelectedFile)) + { + LoadFile(_channel.SelectedFile); + } + }; + } + + private void LoadFile(AudioFileMeta? file) + { + CurrentFile = file; + IsPlaying = false; + CurrentPosition = 0; + + if (file == null) + { + CurrentFileName = "未选择文件"; + Duration = 0; + HasFile = false; + return; + } + + CurrentFileName = file.Filename; + Duration = file.Duration; + HasFile = true; + } + + [RelayCommand] + private void PlayPause() + { + if (CurrentFile == null) return; + IsPlaying = !IsPlaying; + // TODO: 接入音频播放库后实现真正播放 + } + + [RelayCommand] + private void Stop() + { + IsPlaying = false; + CurrentPosition = 0; + // TODO: 接入音频播放库后实现真正停止 + } + + partial void OnCurrentPositionChanged(double value) + { + // TODO: 接入音频播放库后实现 seek + } +} diff --git a/src/GUI/ViewModels/ScanProgressViewModel.cs b/src/GUI/ViewModels/ScanProgressViewModel.cs new file mode 100644 index 0000000..d6151b3 --- /dev/null +++ b/src/GUI/ViewModels/ScanProgressViewModel.cs @@ -0,0 +1,109 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using OCES.Resonance.Core; + +namespace GUI.ViewModels; + +public partial class ScanProgressViewModel : ViewModelBase +{ + [ObservableProperty] private bool _isScanning; + [ObservableProperty] private string _statusText = "准备扫描..."; + [ObservableProperty] private int _totalFiles; + [ObservableProperty] private int _processedFiles; + [ObservableProperty] private int _addedFiles; + [ObservableProperty] private int _skippedFiles; + [ObservableProperty] private double _progress; + [ObservableProperty] private string _currentFile = string.Empty; + + public event Action? ScanCompleted; + + [RelayCommand] + private void StartScan(string directory) + { + if (string.IsNullOrWhiteSpace(directory)) return; + + Task.Run(() => ScanDirectoryInternal(directory)); + } + + private void ScanDirectoryInternal(string directory) + { + try + { + IsScanning = true; + StatusText = "正在发现文件..."; + + Database.InitializeDatabase(); + + var scanner = new AudioFileScanner(); + var reader = new AudioMetadataReader(); + var validator = new AudioFileMetaValidator(); + + var files = scanner.ScanDirectory(directory, recursive: true).ToList(); + TotalFiles = files.Count; + ProcessedFiles = 0; + AddedFiles = 0; + SkippedFiles = 0; + Progress = 0; + + StatusText = $"发现 {TotalFiles} 个文件,正在解析元数据..."; + + foreach (var filePath in files) + { + CurrentFile = System.IO.Path.GetFileName(filePath); + ProcessedFiles++; + + try + { + var fileInfo = new FileInfo(filePath); + var meta = reader.ReadMetadata(fileInfo); + + if (meta == null) + { + SkippedFiles++; + UpdateProgress(); + continue; + } + + if (!validator.Validate(meta, out _)) + { + SkippedFiles++; + UpdateProgress(); + continue; + } + + if (Database.EntryExists(meta.Md5, meta.Path)) + { + SkippedFiles++; + UpdateProgress(); + continue; + } + + Database.AddEntry(meta); + AddedFiles++; + } + catch (Exception) + { + SkippedFiles++; + } + + UpdateProgress(); + } + + StatusText = $"扫描完成:新增 {AddedFiles},跳过 {SkippedFiles}"; + } + catch (Exception ex) + { + StatusText = $"扫描出错:{ex.Message}"; + } + finally + { + IsScanning = false; + ScanCompleted?.Invoke(); + } + } + + private void UpdateProgress() + { + Progress = TotalFiles > 0 ? (double)ProcessedFiles / TotalFiles * 100 : 0; + } +} diff --git a/src/GUI/ViewModels/TagTreeViewModel.cs b/src/GUI/ViewModels/TagTreeViewModel.cs new file mode 100644 index 0000000..6e81a39 --- /dev/null +++ b/src/GUI/ViewModels/TagTreeViewModel.cs @@ -0,0 +1,92 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using GUI.Services; +using OCES.Resonance.Core; + +namespace GUI.ViewModels; + +public partial class TagTreeViewModel : ViewModelBase +{ + private readonly SyncChannel _channel; + + public ObservableCollection CategoryNodes { get; } = new(); + public ObservableCollection KeywordNodes { get; } = new(); + + [ObservableProperty] private TagTreeNode? _selectedNode; + + public TagTreeViewModel(SyncChannel channel) + { + _channel = channel; + } + + [RelayCommand] + private void LoadData() + { + try + { + CategoryNodes.Clear(); + KeywordNodes.Clear(); + + var categories = Database.GetAllCategories(); + foreach (var cat in categories) + { + CategoryNodes.Add(new TagTreeNode { Name = cat, Type = TagTreeNodeType.Category }); + } + + var keywords = Database.GetAllKeywords(); + foreach (var kw in keywords) + { + KeywordNodes.Add(new TagTreeNode { Name = kw, Type = TagTreeNodeType.Keyword }); + } + } + catch (Exception) + { + // 数据库尚未初始化或暂无数据 + } + } + + partial void OnSelectedNodeChanged(TagTreeNode? value) + { + if (value == null) + { + _channel.FilterCategory = null; + _channel.FilterKeyword = null; + return; + } + + switch (value.Type) + { + case TagTreeNodeType.Category: + _channel.FilterCategory = value.Name; + _channel.FilterKeyword = null; + break; + case TagTreeNodeType.Keyword: + _channel.FilterCategory = null; + _channel.FilterKeyword = value.Name; + break; + } + } + + [RelayCommand] + private void ClearFilter() + { + SelectedNode = null; + _channel.FilterCategory = null; + _channel.FilterKeyword = null; + } +} + +public partial class TagTreeNode : ObservableObject +{ + public string Name { get; set; } = string.Empty; + public TagTreeNodeType Type { get; set; } + + public override string ToString() => Name; +} + +public enum TagTreeNodeType +{ + Category, + Keyword +} diff --git a/src/GUI/Views/FileListView.axaml b/src/GUI/Views/FileListView.axaml new file mode 100644 index 0000000..a43200d --- /dev/null +++ b/src/GUI/Views/FileListView.axaml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + +