diff --git a/src/Dc.App/App.xaml b/src/Dc.App/App.xaml index c307f5b..27cc62c 100644 --- a/src/Dc.App/App.xaml +++ b/src/Dc.App/App.xaml @@ -31,9 +31,6 @@ - - - diff --git a/src/Dc.App/Composition/ServiceRegistration.cs b/src/Dc.App/Composition/ServiceRegistration.cs index ce913c6..0c0399d 100644 --- a/src/Dc.App/Composition/ServiceRegistration.cs +++ b/src/Dc.App/Composition/ServiceRegistration.cs @@ -142,7 +142,6 @@ public static IServiceCollection AddDcApp(this IServiceCollection services, stri // SnackbarPresenter 在 App 启动 Show 前经 SetSnackbarPresenter 接线(见 App.xaml.cs)。 services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -199,9 +198,6 @@ public static IServiceCollection AddDcApp(this IServiceCollection services, stri new Dc.App.ViewModels.Workspace.WorkspaceOverviewViewModel( sp.GetRequiredService(), () => DateTimeOffset.UtcNow)); - // GroupsViewModel 仅工作台用(无全局导航路由),复用单例即可 - services.AddSingleton( - sp => sp.GetRequiredService()); // 全局监控分离(S4):LiveData/Diagnostics 同时挂「全局监控」导航与工作台 tab, // 工作台用独立实例,避免工作台设的 TaskFilter/TaskScope 污染全局视图。 services.AddSingleton( @@ -224,7 +220,6 @@ public static IServiceCollection AddDcApp(this IServiceCollection services, stri sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), @@ -235,8 +230,14 @@ public static IServiceCollection AddDcApp(this IServiceCollection services, stri services.AddSingleton( _ => System.Windows.Application.Current?.Dispatcher ?? System.Windows.Threading.Dispatcher.CurrentDispatcher); - services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(sp => new TagsViewModel( + sp.GetRequiredService>(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + // 空状态「浏览节点添加 Tag」CTA → 切到浏览页(发现→批量加 Tag 主路径) + navigate: key => sp.GetRequiredService().NavigateCommand.Execute(key))); services.AddSingleton(sp => new LiveDataViewModel( sp.GetRequiredService(), System.Windows.Application.Current?.Dispatcher ?? System.Windows.Threading.Dispatcher.CurrentDispatcher, @@ -244,7 +245,16 @@ public static IServiceCollection AddDcApp(this IServiceCollection services, stri .NavigateCommand.Execute(key), showNavigateCta: true, dbFactory: sp.GetRequiredService>())); - services.AddSingleton(); + services.AddSingleton(sp => new BrowseViewModel( + sp.GetServices(), + sp.GetRequiredService>(), + sp.GetRequiredService(), + // 批量加 Tag 后:选中目标任务 + 跳到「采集任务」(发现→配置→看数据 一条线)。 + onTagsAdded: taskId => + { + sp.GetRequiredService().RequestSelect(taskId); + sp.GetRequiredService().NavigateCommand.Execute("workspace"); + })); services.AddSingleton(sp => new DiagnosticsViewModel( sp.GetRequiredService(), navigate: key => sp.GetRequiredService() diff --git a/src/Dc.App/Controls/EmptyState.cs b/src/Dc.App/Controls/EmptyState.cs index c817339..81dd821 100644 --- a/src/Dc.App/Controls/EmptyState.cs +++ b/src/Dc.App/Controls/EmptyState.cs @@ -55,4 +55,21 @@ public ICommand? ActionCommand get => (ICommand?)GetValue(ActionCommandProperty); set => SetValue(ActionCommandProperty, value); } + + // 可选次操作(幽灵按钮)。SecondaryActionText 为空时不显示。 + public static readonly DependencyProperty SecondaryActionTextProperty = DependencyProperty.Register( + nameof(SecondaryActionText), typeof(string), typeof(EmptyState), new PropertyMetadata(null)); + public string? SecondaryActionText + { + get => (string?)GetValue(SecondaryActionTextProperty); + set => SetValue(SecondaryActionTextProperty, value); + } + + public static readonly DependencyProperty SecondaryActionCommandProperty = DependencyProperty.Register( + nameof(SecondaryActionCommand), typeof(ICommand), typeof(EmptyState), new PropertyMetadata(null)); + public ICommand? SecondaryActionCommand + { + get => (ICommand?)GetValue(SecondaryActionCommandProperty); + set => SetValue(SecondaryActionCommandProperty, value); + } } diff --git a/src/Dc.App/Services/GroupEditorDialog.cs b/src/Dc.App/Services/GroupEditorDialog.cs deleted file mode 100644 index 01832ce..0000000 --- a/src/Dc.App/Services/GroupEditorDialog.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Windows; -using Dc.App.ViewModels; -using Dc.App.Views; -using Dc.Domain.Entities; - -namespace Dc.App.Services; - -public sealed class GroupEditorDialog : IGroupEditorDialog -{ - public Group? Edit(IEnumerable availableTasks, Group? existing, CollectorTask? defaultTask = null) - { - var vm = new GroupEditorViewModel(availableTasks, existing, defaultTask); - var window = new GroupEditorWindow - { - DataContext = vm, - Owner = Application.Current.MainWindow - }; - return window.ShowDialog() == true ? vm.ToEntity() : null; - } -} diff --git a/src/Dc.App/Services/IGroupEditorDialog.cs b/src/Dc.App/Services/IGroupEditorDialog.cs deleted file mode 100644 index 911db1d..0000000 --- a/src/Dc.App/Services/IGroupEditorDialog.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Dc.Domain.Entities; - -namespace Dc.App.Services; - -public interface IGroupEditorDialog -{ - Group? Edit(IEnumerable availableTasks, Group? existing, CollectorTask? defaultTask = null); -} diff --git a/src/Dc.App/Services/ITagEditorDialog.cs b/src/Dc.App/Services/ITagEditorDialog.cs index 5c8ea40..4a1814a 100644 --- a/src/Dc.App/Services/ITagEditorDialog.cs +++ b/src/Dc.App/Services/ITagEditorDialog.cs @@ -5,9 +5,8 @@ namespace Dc.App.Services; public interface ITagEditorDialog { TagEditResult? Edit( - IEnumerable availableGroups, + string taskId, Tag? existing, - Group? defaultGroup = null, Func? taskLookup = null, IReadOnlyCollection? taskTags = null, IReadOnlyCollection? existingFormulas = null); diff --git a/src/Dc.App/Services/TagEditorDialog.cs b/src/Dc.App/Services/TagEditorDialog.cs index d840eb7..7ba8c64 100644 --- a/src/Dc.App/Services/TagEditorDialog.cs +++ b/src/Dc.App/Services/TagEditorDialog.cs @@ -18,15 +18,14 @@ public TagEditorDialog(IBrowseDialog browseDialog, IFormulaValidator formulaVali } public TagEditResult? Edit( - IEnumerable availableGroups, + string taskId, Tag? existing, - Group? defaultGroup = null, Func? taskLookup = null, IReadOnlyCollection? taskTags = null, IReadOnlyCollection? existingFormulas = null) { var vm = new TagEditorViewModel( - availableGroups, existing, defaultGroup, _browseDialog, taskLookup, + taskId, existing, _browseDialog, taskLookup, taskTags, existingFormulas, _formulaValidator); var window = new TagEditorWindow { diff --git a/src/Dc.App/Theme/Tokens.xaml b/src/Dc.App/Theme/Tokens.xaml index 508fa0c..b1573b0 100644 --- a/src/Dc.App/Theme/Tokens.xaml +++ b/src/Dc.App/Theme/Tokens.xaml @@ -421,6 +421,23 @@ + diff --git a/src/Dc.App/ViewModels/BrowseViewModel.cs b/src/Dc.App/ViewModels/BrowseViewModel.cs index dfe230a..b2c4d71 100644 --- a/src/Dc.App/ViewModels/BrowseViewModel.cs +++ b/src/Dc.App/ViewModels/BrowseViewModel.cs @@ -18,6 +18,7 @@ public partial class BrowseViewModel : ObservableObject, IAsyncDisposable // 可空:导航页注入(走 DI)→ 启用批量「加为 Tag」;Tag 编辑器内嵌的单点取用对话框传 null → 不启用。 private readonly IDbContextFactory? _dbFactory; private readonly ITaskEditorDialog? _taskEditor; + private readonly Action? _onTagsAdded; // 批量加 Tag 后回调(taskId):由组合根接「选中该任务+跳工作台」 private IOpcBrowser? _browser; private readonly Stack<(string? Id, string Name)> _path = new(); @@ -56,11 +57,13 @@ public partial class BrowseViewModel : ObservableObject, IAsyncDisposable public BrowseViewModel( IEnumerable browserFactories, IDbContextFactory? dbFactory = null, - ITaskEditorDialog? taskEditor = null) + ITaskEditorDialog? taskEditor = null, + Action? onTagsAdded = null) { _factories = browserFactories.ToDictionary(f => f.Protocol); _dbFactory = dbFactory; _taskEditor = taskEditor; + _onTagsAdded = onTagsAdded; // 暴露注册的所有协议;DI 顺序 = UA, DA → UI 下拉同序 AvailableProtocols = _factories.Keys.ToArray(); if (_dbFactory is not null) _ = LoadTasksAsync(); @@ -129,15 +132,13 @@ private async Task AddToNewTaskAsync() await AddCheckedAsTagsAsync(created.Id, created.DisplayName); } - // 把勾选的叶子节点批量建成 Tag,落到任务的默认分组(分组层隐藏);同任务已存在的 Item 跳过。 + // 把勾选的叶子节点批量建成 Tag,直接挂到任务;同任务已存在的 Item 跳过。 private async Task AddCheckedAsTagsAsync(string taskId, string taskDisplay) { if (_dbFactory is null) return; var picked = Children.Where(r => r.IsItem && r.IsChecked).ToList(); if (picked.Count == 0) return; - var group = await DefaultTaskGroup.EnsureAsync(_dbFactory, taskId); - await using var db = await _dbFactory.CreateDbContextAsync(); var existing = (await db.Tags.AsNoTracking().Where(t => t.TaskId == taskId) .Select(t => t.Item).ToListAsync()).ToHashSet(StringComparer.Ordinal); @@ -149,8 +150,7 @@ private async Task AddCheckedAsTagsAsync(string taskId, string taskDisplay) Id = UlidGenerator.NewId(), Item = r.Node.Id, DataType = MapDataType(r.DataTypeText), - TaskId = taskId, - GroupId = group.Id + TaskId = taskId }) .ToList(); @@ -165,8 +165,11 @@ private async Task AddCheckedAsTagsAsync(string taskId, string taskDisplay) var skipped = picked.Count - toAdd.Count; var msg = $"已添加 {toAdd.Count} 个 Tag 到「{taskDisplay}」" + (skipped > 0 ? $",跳过 {skipped} 个重复 Item" : "") - + "。去「采集任务」启动即可采集。"; + + "。即将跳到「采集任务」,启动即可采集。"; MessageDialog.Show("加为 Tag", msg, MessageDialogKind.Success); + + // 跳到采集任务并选中目标任务(发现→配置→看数据 一条线);组合根接线,BrowseViewModel 不依赖具体 VM。 + if (toAdd.Count > 0) _onTagsAdded?.Invoke(taskId); } // 浏览到的数据类型名(UA: Float/Double/...; 或选项 DisplayName)→ Tag.DataType 码;认不出落 0(默认/自动)。 diff --git a/src/Dc.App/ViewModels/ConfigEntryEditorViewModel.cs b/src/Dc.App/ViewModels/ConfigEntryEditorViewModel.cs index f4f35b8..16e574f 100644 --- a/src/Dc.App/ViewModels/ConfigEntryEditorViewModel.cs +++ b/src/Dc.App/ViewModels/ConfigEntryEditorViewModel.cs @@ -8,6 +8,9 @@ public partial class ConfigEntryEditorViewModel : ObservableObject [ObservableProperty] private string _title; [ObservableProperty] private string _key = string.Empty; [ObservableProperty] private string _value = string.Empty; + // 实时校验:无错才可保存 + 首条错误内联红字(对齐任务/Tag 编辑器)。 + [ObservableProperty] private bool _canSave; + [ObservableProperty] private string _validationError = string.Empty; public string? OriginalId { get; } public bool KeyIsReadOnly => OriginalId is not null; @@ -25,6 +28,16 @@ public ConfigEntryEditorViewModel(ConfigEntry? existing) _key = existing.Key; _value = existing.Value; } + Revalidate(); + } + + partial void OnKeyChanged(string value) => Revalidate(); + + private void Revalidate() + { + var errs = Validate(); + CanSave = errs.Count == 0; + ValidationError = errs.Count == 0 ? string.Empty : errs[0]; } public IReadOnlyList Validate() diff --git a/src/Dc.App/ViewModels/GroupEditorViewModel.cs b/src/Dc.App/ViewModels/GroupEditorViewModel.cs deleted file mode 100644 index 92b3434..0000000 --- a/src/Dc.App/ViewModels/GroupEditorViewModel.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System.Collections.ObjectModel; -using CommunityToolkit.Mvvm.ComponentModel; -using Dc.Domain.Entities; - -namespace Dc.App.ViewModels; - -public partial class GroupEditorViewModel : ObservableObject -{ - [ObservableProperty] private string _title; - [ObservableProperty] private string _name = string.Empty; - [ObservableProperty] private CollectorTask? _task; - - public string? OriginalId { get; } - public bool ShowTaskSelector { get; } - public ObservableCollection AvailableTasks { get; } = new(); - - public GroupEditorViewModel(IEnumerable tasks, Group? existing, CollectorTask? defaultTask = null) - { - foreach (var t in tasks) AvailableTasks.Add(t); - - if (existing is null) - { - _task = defaultTask; - _title = _task is null ? "新建分组" : $"新建分组 · 任务:{_task.Server}"; - } - else - { - OriginalId = existing.Id; - _name = existing.Name; - _task = AvailableTasks.FirstOrDefault(t => t.Id == existing.TaskId); - _title = _task is null ? "编辑分组" : $"编辑分组 · 任务:{_task.Server}"; - } - - ShowTaskSelector = _task is null; - } - - public IReadOnlyList Validate() - { - var errors = new List(); - if (string.IsNullOrWhiteSpace(Name)) errors.Add("名称不能为空"); - if (Task is null) errors.Add("必须选择所属任务"); - return errors; - } - - public Group ToEntity() => new() - { - Id = OriginalId ?? string.Empty, - Name = Name.Trim(), - TaskId = Task!.Id - }; -} diff --git a/src/Dc.App/ViewModels/GroupRow.cs b/src/Dc.App/ViewModels/GroupRow.cs deleted file mode 100644 index e44cd1a..0000000 --- a/src/Dc.App/ViewModels/GroupRow.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Dc.Domain.Entities; - -namespace Dc.App.ViewModels; - -/// -/// 分组列表行:Group 实体 + 人类可读的任务名 + 显隐该列的标志。 -/// 此前 GroupsView「所属任务」列直接绑 TaskId(26 位 ULID);内嵌模式(工作区选了任务再看 -/// 分组 tab)该列还是冗余的。现 GroupRow 暴露 TaskName + ShowTaskColumn,实体经 .Group 透传。 -/// -public sealed class GroupListRow -{ - public Group Group { get; } - public string Id => Group.Id; - public string Name => Group.Name; - public string TaskName { get; } - public DateTime CreatedAt => Group.CreatedAt; - - public GroupListRow(Group group, string taskName) - { - Group = group; - TaskName = taskName; - } -} diff --git a/src/Dc.App/ViewModels/GroupsViewModel.cs b/src/Dc.App/ViewModels/GroupsViewModel.cs deleted file mode 100644 index e51b4f6..0000000 --- a/src/Dc.App/ViewModels/GroupsViewModel.cs +++ /dev/null @@ -1,189 +0,0 @@ -using System.Collections.ObjectModel; -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using Dc.App.Services; -using Dc.Domain.Entities; -using Dc.App.ViewModels.Workspace; -using Dc.Infrastructure.Orchestration; -using Dc.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; - -namespace Dc.App.ViewModels; - -public partial class GroupsViewModel : ObservableObject, IEmbeddableGroupPanel -{ - private readonly IDbContextFactory _dbFactory; - private readonly IGroupEditorDialog _editor; - private readonly TaskOrchestrator _orchestrator; - private readonly Dictionary _taskById = new(); - - [ObservableProperty] private string _title = "分组管理"; - [ObservableProperty] private bool _isLoading; - [ObservableProperty] private GroupListRow? _selectedRow; - [ObservableProperty] private CollectorTask? _taskFilter; - [ObservableProperty] private bool _isEmbedded; - - // 嵌入主从视图时隐藏标题 + 筛选区域,避免把右侧按钮挤出视区 - public bool ShowFullToolbar => !IsEmbedded; - // 内嵌模式(工作区已选任务)下「所属任务」列冗余——上下文已定任务,隐藏。 - public bool ShowTaskColumn => !IsEmbedded; - public string EmbeddedTitle => TaskFilter is null ? "分组 ▸ (未选任务)" : $"分组 ▸ {TaskDisplayName(TaskFilter)}"; - // 无任务时无法新建分组(分组必须挂任务)。独立页无宿主可跳 → 只文字引导; - // 内嵌模式 CTA 跳「采集任务」页签(事件经宿主 TaskWorkspaceViewModel 订阅)。 - public string? CreateTaskCtaText => IsEmbedded ? "去新建任务" : null; - - /// 内嵌模式无任务时,请求宿主(采集任务工作区)切到任务列表新建任务。 - public event Action? NavigateToTasksRequested; - - /// - // IEmbeddableGroupPanel 契约暴露 Group?;实际选中行见 SelectedRow。透传实体给宿主切 Tag tab。 - public Group? SelectedGroup => SelectedRow?.Group; - - public ObservableCollection Groups { get; } = new(); - public ObservableCollection AvailableTasks { get; } = new(); - - public GroupsViewModel(IDbContextFactory dbFactory, IGroupEditorDialog editor, TaskOrchestrator orchestrator) - { - _dbFactory = dbFactory; - _editor = editor; - _orchestrator = orchestrator; - _ = LoadAsync(); - } - - partial void OnIsEmbeddedChanged(bool value) - { - OnPropertyChanged(nameof(ShowFullToolbar)); - OnPropertyChanged(nameof(ShowTaskColumn)); - OnPropertyChanged(nameof(CreateTaskCtaText)); - } - partial void OnTaskFilterChanged(CollectorTask? value) - { - OnPropertyChanged(nameof(EmbeddedTitle)); - _ = LoadAsync(); - } - - // 任务可读名:统一用 CollectorTask.DisplayName(Name → Server → Id)。 - private static string TaskDisplayName(CollectorTask t) => t.DisplayName; - - [RelayCommand] - public async Task LoadAsync() - { - if (IsLoading) return; - IsLoading = true; - try - { - await using var db = await _dbFactory.CreateDbContextAsync(); - var tasks = await db.Tasks.AsNoTracking().OrderBy(t => t.CreatedAt).ToListAsync(); - AvailableTasks.Clear(); - _taskById.Clear(); - foreach (var t in tasks) { AvailableTasks.Add(t); _taskById[t.Id] = t; } - NewCommand.NotifyCanExecuteChanged(); // 任务数变化 → 「新建」可用性 + 空状态刷新 - - var q = db.Groups.AsNoTracking().AsQueryable(); - if (TaskFilter is not null) q = q.Where(g => g.TaskId == TaskFilter.Id); - var list = await q.OrderBy(g => g.CreatedAt).ToListAsync(); - Groups.Clear(); - foreach (var g in list) - { - var taskName = _taskById.TryGetValue(g.TaskId, out var t) ? TaskDisplayName(t) : g.TaskId; - Groups.Add(new GroupListRow(g, taskName)); - } - } - finally - { - IsLoading = false; - } - } - - // 无任务时禁用(空状态引导去新建任务),双重防御。 - private bool CanNew => AvailableTasks.Count > 0; - - [RelayCommand(CanExecute = nameof(CanNew))] - private async Task NewAsync() - { - if (AvailableTasks.Count == 0) return; - var edited = _editor.Edit(AvailableTasks, null, TaskFilter); - if (edited is null) return; - - edited.Id = UlidGenerator.NewId(); - await using var db = await _dbFactory.CreateDbContextAsync(); - db.Groups.Add(edited); - await db.SaveChangesAsync(); - var taskName = _taskById.TryGetValue(edited.TaskId, out var t) ? TaskDisplayName(t) : edited.TaskId; - Groups.Add(new GroupListRow(edited, taskName)); - } - - [RelayCommand(CanExecute = nameof(HasSelection))] - private async Task EditAsync() - { - if (SelectedRow is null) return; - var edited = _editor.Edit(AvailableTasks, SelectedRow.Group); - if (edited is null) return; - - await using var db = await _dbFactory.CreateDbContextAsync(); - var entity = await db.Groups.FirstOrDefaultAsync(g => g.Id == edited.Id); - if (entity is null) return; - entity.Name = edited.Name; - entity.TaskId = edited.TaskId; - await db.SaveChangesAsync(); - - var idx = Groups.IndexOf(SelectedRow); - if (idx >= 0) - { - var taskName = _taskById.TryGetValue(entity.TaskId, out var t) ? TaskDisplayName(t) : entity.TaskId; - var row = new GroupListRow(entity, taskName); - Groups[idx] = row; - SelectedRow = row; // 替换后重选,避免下游 TagsPanel.GroupFilter 被清空 - } - } - - [RelayCommand(CanExecute = nameof(HasSelection))] - private async Task DeleteAsync() - { - var row = SelectedRow; - if (row is null) return; - var group = row.Group; - - // 确认文案不带 ULID(对用户无意义),只显分组名。 - var confirm = MessageDialog.Confirm("删除确认", - $"确定删除分组 {group.Name}?\n会同时清除该分组下的所有 Tag。", - MessageDialogKind.Warning); - if (!confirm) return; - - await using var db = await _dbFactory.CreateDbContextAsync(); - // 查出待删 Tag 的 item,热卸载用 - var tagsInGroup = await db.Tags.AsNoTracking() - .Where(t => t.GroupId == group.Id) - .Select(t => new { t.TaskId, t.Item }) - .ToListAsync(); - - await db.Groups.Where(g => g.Id == group.Id).ExecuteDeleteAsync(); - await db.Tags.Where(t => t.GroupId == group.Id).ExecuteDeleteAsync(); - - if (_orchestrator.RunningTaskIds.Contains(group.TaskId) && tagsInGroup.Count > 0) - { - try - { - await _orchestrator.RemoveTagsAsync(group.TaskId, - tagsInGroup.Select(t => t.Item).ToArray()); - } - catch { /* swallow */ } - } - - Groups.Remove(row); - } - - // 空状态 CTA:请求宿主切到任务列表。独立页无宿主 → 事件无人订阅 → 按钮不显示。 - [RelayCommand] - private void GoCreateTask() => NavigateToTasksRequested?.Invoke(); - - private bool HasSelection() => SelectedRow is not null; - - partial void OnSelectedRowChanged(GroupListRow? value) - { - DeleteCommand.NotifyCanExecuteChanged(); - EditCommand.NotifyCanExecuteChanged(); - // 接口契约 SelectedGroup 透传自 SelectedRow.Group;通知宿主(TaskWorkspaceViewModel)切 Tag tab。 - OnPropertyChanged(nameof(SelectedGroup)); - } -} diff --git a/src/Dc.App/ViewModels/LiveDataViewModel.cs b/src/Dc.App/ViewModels/LiveDataViewModel.cs index ccb345f..bac5576 100644 --- a/src/Dc.App/ViewModels/LiveDataViewModel.cs +++ b/src/Dc.App/ViewModels/LiveDataViewModel.cs @@ -82,7 +82,8 @@ internal void DebounceTickForTest() private volatile LiveFlushStats _statsSnapshot = new(0, 0, 0, 0, 0); public ObservableCollection Rows { get; } = new(); - public ObservableCollection AvailableTaskIds { get; } = new(); + // 任务筛选下拉项:Id + 可读名(Display)。下拉显名、按 Id 过滤(TaskFilter 仍是 id,RowsView 不变)。 + public ObservableCollection AvailableTasks { get; } = new(); public ICollectionView RowsView { get; } private bool _running; @@ -140,8 +141,14 @@ private async Task LoadTaskNamesAsync() try { _taskNames = await TaskNames.LoadAsync(_dbFactory); - // 回填已存在行的可读名(行可能先于名字加载就创建)。Start 在 UI 线程,await 续回 UI 线程,改 Rows 安全。 + // 回填已存在行的可读名(行可能先于名字加载就创建)。Start 在 UI 线程,await 续回 UI 线程,改集合安全。 foreach (var row in Rows) row.TaskName = ResolveTaskName(row.TaskId); + // 同步刷新任务筛选下拉的显示名(此前以 id 兜底加入) + for (var i = 0; i < AvailableTasks.Count; i++) + { + var name = ResolveTaskName(AvailableTasks[i].Id); + if (AvailableTasks[i].Display != name) AvailableTasks[i] = new TaskPick(AvailableTasks[i].Id, name); + } } catch { /* 名字解析失败不影响数据流,列回退显示 id */ } } @@ -239,7 +246,8 @@ private LiveFlushStats ComputeFlushStats() private void Apply(string taskId, TagValue v, int rawCount) { - if (!AvailableTaskIds.Contains(taskId)) AvailableTaskIds.Add(taskId); + if (AvailableTasks.All(t => t.Id != taskId)) + AvailableTasks.Add(new TaskPick(taskId, ResolveTaskName(taskId))); var key = $"{taskId}::{v.Item}"; if (!_rowIndex.TryGetValue(key, out var row)) @@ -269,7 +277,7 @@ private void Clear() _searchDebounce?.Stop(); // 清空后避免挂起的防抖回调刷新已清集合 _rowIndex.Clear(); Rows.Clear(); - AvailableTaskIds.Clear(); + AvailableTasks.Clear(); RowCount = 0; // 重置 flush 统计累计器,避免清空后陈旧统计(ring 数组无需清零,count=0 即 n=0) diff --git a/src/Dc.App/ViewModels/SettingsViewModel.cs b/src/Dc.App/ViewModels/SettingsViewModel.cs index 0411aaa..0a1913a 100644 --- a/src/Dc.App/ViewModels/SettingsViewModel.cs +++ b/src/Dc.App/ViewModels/SettingsViewModel.cs @@ -53,7 +53,7 @@ private async Task ExportBackupAsync() var json = _backup.SerializeToJson(bundle); await File.WriteAllTextAsync(path, json); MessageDialog.Show("导出成功", - $"已导出 {bundle.Tasks.Count} 任务 / {bundle.Groups.Count} 分组 / {bundle.Tags.Count} Tag / {bundle.Configs.Count} 配置到 {path}", + $"已导出 {bundle.Tasks.Count} 任务 / {bundle.Tags.Count} Tag / {bundle.Configs.Count} 配置到 {path}", MessageDialogKind.Success); } catch (Exception ex) @@ -85,7 +85,7 @@ private async Task ImportBackupAsync() await using var db = await _dbFactory.CreateDbContextAsync(); var result = await _backup.ImportAsync(db, bundle, mode); - var msg = $"已导入 任务 {result.TasksImported} / 分组 {result.GroupsImported} / Tag {result.TagsImported} / 配置 {result.ConfigsImported}"; + var msg = $"已导入 任务 {result.TasksImported} / Tag {result.TagsImported} / 配置 {result.ConfigsImported}"; if (result.Errors.Count > 0) msg += $"\n错误 ({result.Errors.Count}):\n" + string.Join("\n", result.Errors.Take(5)); MessageDialog.Show("导入结果", msg, result.Errors.Count > 0 ? MessageDialogKind.Warning : MessageDialogKind.Success); diff --git a/src/Dc.App/ViewModels/TagEditorViewModel.cs b/src/Dc.App/ViewModels/TagEditorViewModel.cs index 7752c2f..8e1ccda 100644 --- a/src/Dc.App/ViewModels/TagEditorViewModel.cs +++ b/src/Dc.App/ViewModels/TagEditorViewModel.cs @@ -6,12 +6,12 @@ using Dc.App.Services; using Dc.Domain.Entities; using Dc.Infrastructure.Orchestration; -using Group = Dc.Domain.Entities.Group; namespace Dc.App.ViewModels; public partial class TagEditorViewModel : ObservableObject { + private readonly string _taskId; // 分组层已去除:Tag 直接挂任务,编辑器固定此任务 private readonly IBrowseDialog? _browseDialog; private readonly Func? _taskLookup; private readonly IReadOnlyCollection? _taskTags; @@ -28,52 +28,35 @@ public partial class TagEditorViewModel : ObservableObject [ObservableProperty] private string _expression = string.Empty; [ObservableProperty] private string _outputUnit = string.Empty; + // 实时校验:无错才可保存(对齐任务编辑器),首条错误内联红字提示。 + [ObservableProperty] private bool _canSave; + [ObservableProperty] private string _validationError = string.Empty; + public ObservableCollection InputBindings { get; } = new(); public ObservableCollection AvailableInputTags { get; } = new(); - // 下拉项包装:Group 实体 + 人类可读 Display。此前 ItemTemplate 直接绑 {TaskId}(26 位 ULID), - // 折叠态显示「组名 — 01KVAS6GHVSDC5B62ZTXXY596Z」。现 Display 用任务显示名;同任务下只显组名。 - public ObservableCollection AvailableGroups { get; } = new(); - [ObservableProperty] private GroupRow? _selectedGroupRow; - - public Group? Group => SelectedGroupRow?.Group; - public string? OriginalId { get; } public IReadOnlyList DataTypeOptions => OpcDataTypeOption.All; - public bool ShowGroupSelector { get; } public TagEditorViewModel( - IEnumerable groups, + string taskId, Tag? existing, - Group? defaultGroup = null, IBrowseDialog? browseDialog = null, Func? taskLookup = null, IReadOnlyCollection? taskTags = null, IReadOnlyCollection? existingFormulas = null, IFormulaValidator? formulaValidator = null) { + _taskId = taskId; _taskTags = taskTags; _existingFormulas = existingFormulas; _formulaValidator = formulaValidator; _browseDialog = browseDialog; _taskLookup = taskLookup; - // 任务名解析:用 CollectorTask.DisplayName(Name → Server → Id);无 taskLookup 则回退空(只显组名)。 - var rows = groups.Select(g => - { - var task = taskLookup?.Invoke(g.TaskId); - var taskName = task is not null ? task.DisplayName : null; - return new GroupRow(g, taskName); - }).ToList(); - var multiTask = rows.Select(r => r.Group.TaskId).Distinct().Count() > 1; - foreach (var r in rows) r.FinalizeDisplay(multiTask); - foreach (var r in rows) AvailableGroups.Add(r); - - GroupRow? selected; if (existing is null) { - selected = defaultGroup is null ? null : rows.FirstOrDefault(r => r.Group.Id == defaultGroup.Id); - _title = "新建 Tag"; // 分组层已对用户隐藏,标题不再带分组名 + _title = "新建 Tag"; } else { @@ -84,10 +67,8 @@ public TagEditorViewModel( ? existing.ScaleFactor.Value.ToString(CultureInfo.InvariantCulture) : string.Empty; _offset = existing.Offset.HasValue ? existing.Offset.Value.ToString(CultureInfo.InvariantCulture) : string.Empty; - selected = rows.FirstOrDefault(r => r.Group.Id == existing.GroupId); - _title = "编辑 Tag"; // 分组层已对用户隐藏,标题不再带分组名 + _title = "编辑 Tag"; } - _selectedGroupRow = selected; Formula? existingVirtualFormula = null; // 编辑已存在虚拟 Tag:回填公式字段并预选输入。 @@ -103,7 +84,6 @@ public TagEditorViewModel( } } - ShowGroupSelector = selected is null; RefreshAvailableInputTags(); if (existingVirtualFormula is not null) { @@ -113,6 +93,7 @@ public TagEditorViewModel( { SelectedTag = AvailableInputTags.FirstOrDefault(t => t.Id == input.SourceTagId) }; + row.PropertyChanged += OnInputRowChanged; InputBindings.Add(row); } } @@ -120,6 +101,20 @@ public TagEditorViewModel( { RebuildInputBindings(); } + Revalidate(); // 初始态:新建真实 Tag(Item 空)→ 保存禁用,直到填合法 + } + + // 实时校验:复用 Validate(),无错才可保存,首条错误内联提示。 + private void Revalidate() + { + var errs = Validate(); + CanSave = errs.Count == 0; + ValidationError = errs.Count == 0 ? string.Empty : errs[0]; + } + + private void OnInputRowChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(InputBindingRow.SelectedTag)) Revalidate(); } public bool CanBrowse => _browseDialog is not null; @@ -154,38 +149,34 @@ private void Browse() { if (_browseDialog is null) return; string? initialUri = null; - if (Group is not null && _taskLookup is not null) - { - var task = _taskLookup(Group.TaskId); - if (task is not null && !string.IsNullOrWhiteSpace(task.Node)) - initialUri = task.Node; - } + var task = _taskLookup?.Invoke(_taskId); + if (task is not null && !string.IsNullOrWhiteSpace(task.Node)) + initialUri = task.Node; var nodeId = _browseDialog.PickNodeId(initialUri); if (!string.IsNullOrWhiteSpace(nodeId)) Item = nodeId; } - partial void OnSelectedGroupRowChanged(GroupRow? value) - { - // 分组定 → TaskId 定 → 刷新可选输入 Tag(同任务真实,排除自身虚拟)。 - OnPropertyChanged(nameof(Group)); - RefreshAvailableInputTags(); - } - partial void OnIsVirtualChanged(bool value) { - if (value) RebuildInputBindings(); - else InputBindings.Clear(); + if (value) RebuildInputBindings(); // 内部 Revalidate + else { InputBindings.Clear(); Revalidate(); } } partial void OnExpressionChanged(string value) { - if (IsVirtual) RebuildInputBindings(); + if (IsVirtual) RebuildInputBindings(); // 内部 Revalidate } + // 实时校验:这些字段变化即重算可保存性。 + partial void OnItemChanged(string value) => Revalidate(); + partial void OnScaleFactorChanged(string value) => Revalidate(); + partial void OnOffsetChanged(string value) => Revalidate(); + partial void OnFormulaNameChanged(string value) => Revalidate(); + private void RefreshAvailableInputTags() { AvailableInputTags.Clear(); - if (_taskTags is null || Group is null) return; + if (_taskTags is null) return; foreach (var t in _taskTags.Where(t => !t.IsVirtual && t.Id != OriginalId)) AvailableInputTags.Add(t); } @@ -195,19 +186,21 @@ private void RebuildInputBindings() { var aliases = ExtractAliases(Expression); var prevByAlias = InputBindings.ToDictionary(r => r.Alias, r => r.SelectedTag, StringComparer.OrdinalIgnoreCase); + foreach (var r in InputBindings) r.PropertyChanged -= OnInputRowChanged; // 退订旧行,避免重复触发 InputBindings.Clear(); foreach (var alias in aliases) { var row = new InputBindingRow(alias); if (prevByAlias.TryGetValue(alias, out var sel)) row.SelectedTag = sel; + row.PropertyChanged += OnInputRowChanged; InputBindings.Add(row); } + Revalidate(); } public IReadOnlyList Validate() { var errors = new List(); - if (Group is null) errors.Add("必须选择所属分组"); if (!IsVirtual) { @@ -255,8 +248,7 @@ public TagEditResult ToResult() Id = OriginalId ?? string.Empty, Item = IsVirtual ? FormulaName.Trim() : Item.Trim(), DataType = DataType.Code, - GroupId = Group!.Id, - TaskId = Group!.TaskId, + TaskId = _taskId, IsVirtual = IsVirtual, ScaleFactor = IsVirtual ? null : ParseDouble(ScaleFactor), Offset = IsVirtual ? null : ParseDouble(Offset) @@ -272,7 +264,7 @@ public TagEditResult ToResult() Expression = Expression, OutputTagId = tag.Id, // 调用方在持久化时回填真实 Id OutputUnit = string.IsNullOrWhiteSpace(OutputUnit) ? null : OutputUnit.Trim(), - TaskId = Group!.TaskId + TaskId = _taskId }; var inputs = InputBindings .Where(r => r.SelectedTag is not null) @@ -291,31 +283,6 @@ public TagEditResult ToResult() => double.TryParse(s?.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : null; } -/// -/// 分组下拉项:Group 实体 + 可读 Display。Display 在 FinalizeDisplay 后确定: -/// 跨任务(独立 Tag 管理页)显示「组名 — 任务名」,同任务(嵌入工作区)只显组名。 -/// -public sealed class GroupRow -{ - public Group Group { get; } - public string Display { get; private set; } - private readonly string? _taskName; - - public GroupRow(Group group, string? taskName) - { - Group = group; - _taskName = taskName; - Display = group.Name; - } - - internal void FinalizeDisplay(bool multiTask) - { - Display = multiTask && !string.IsNullOrWhiteSpace(_taskName) - ? $"{Group.Name} — {_taskName}" - : Group.Name; - } -} - /// /// 输入映射行:从表达式提取的别名(只读)+ 用户选的同任务真实 Tag。 /// diff --git a/src/Dc.App/ViewModels/TagRow.cs b/src/Dc.App/ViewModels/TagRow.cs index 6816db5..b277b2f 100644 --- a/src/Dc.App/ViewModels/TagRow.cs +++ b/src/Dc.App/ViewModels/TagRow.cs @@ -3,9 +3,8 @@ namespace Dc.App.ViewModels; /// -/// Tag 列表行:包装 Tag 实体 + 人类可读的分组名/任务名。 -/// 此前 DataGrid「分组」「任务」列直接绑 GroupId/TaskId(26 位 ULID),折叠态满屏 -/// 01KVAS6GHVSDC5B62ZTXXY596Z。现网格显名;Tag 实体经 .Tag 透传给编辑/删除/热同步。 +/// Tag 列表行:包装 Tag 实体 + 人类可读的任务名。 +/// 此前「任务」列直接绑 TaskId(26 位 ULID);现网格显名,Tag 实体经 .Tag 透传给编辑/删除/热同步。 /// public sealed class TagRow { @@ -13,14 +12,12 @@ public sealed class TagRow public string Id => Tag.Id; public string Item => Tag.Item; public int DataType => Tag.DataType; - public string GroupName { get; } public string TaskName { get; } public DateTime CreatedAt => Tag.CreatedAt; - public TagRow(Tag tag, string groupName, string taskName) + public TagRow(Tag tag, string taskName) { Tag = tag; - GroupName = groupName; TaskName = taskName; } } diff --git a/src/Dc.App/ViewModels/TagsViewModel.cs b/src/Dc.App/ViewModels/TagsViewModel.cs index 9fc2881..9f913a2 100644 --- a/src/Dc.App/ViewModels/TagsViewModel.cs +++ b/src/Dc.App/ViewModels/TagsViewModel.cs @@ -20,34 +20,23 @@ public partial class TagsViewModel : ObservableObject, IEmbeddableTagPanel private readonly ITagExcelService _excel; private readonly IFilePicker _filePicker; private readonly TaskOrchestrator _orchestrator; + private readonly Action? _navigate; // 导航到其它页(空状态「浏览节点」CTA → "browse") [ObservableProperty] private string _title = "Tag 管理"; [ObservableProperty] private bool _isLoading; [ObservableProperty] private TagRow? _selectedTag; - [ObservableProperty] private Group? _groupFilter; [ObservableProperty] private string _searchText = string.Empty; [ObservableProperty] private bool _isEmbedded; [ObservableProperty] private string? _taskScope; - /// - public event Action? NavigateToGroupsRequested; - // 嵌入主从视图时隐藏标题 + 筛选区域,避免把右侧按钮挤出视区 public bool ShowFullToolbar => !IsEmbedded; - // 分组层已对用户隐藏(Tag 直接挂任务),嵌入态只显「Tag」。 + // 分组层已去除(Tag 直接挂任务),嵌入态只显「Tag」。 public string EmbeddedTitle => "Tag"; - // 无分组时无法新建 Tag——禁用「新建」并用空状态引导,而非弹阻塞 MessageBox 把用户挡在死路上。 - // CTA 仅内嵌模式显示(有「分组」页签可跳);独立页只给文字引导。 - public string? CreateGroupCtaText => IsEmbedded ? "去创建分组" : null; - partial void OnIsEmbeddedChanged(bool value) - { - OnPropertyChanged(nameof(ShowFullToolbar)); - OnPropertyChanged(nameof(CreateGroupCtaText)); - } + partial void OnIsEmbeddedChanged(bool value) => OnPropertyChanged(nameof(ShowFullToolbar)); public ObservableCollection Tags { get; } = new(); - public ObservableCollection AvailableGroups { get; } = new(); private readonly Dictionary _taskById = new(); public TagsViewModel( @@ -55,16 +44,22 @@ public TagsViewModel( ITagEditorDialog editor, ITagExcelService excel, IFilePicker filePicker, - TaskOrchestrator orchestrator) + TaskOrchestrator orchestrator, + Action? navigate = null) { _dbFactory = dbFactory; _editor = editor; _excel = excel; _filePicker = filePicker; _orchestrator = orchestrator; + _navigate = navigate; _ = LoadAsync(); } + // 空状态主 CTA:跳到「浏览节点」(发现→批量加 Tag 主路径)。 + [RelayCommand] + private void BrowseNodes() => _navigate?.Invoke("browse"); + private bool IsTaskRunning(string taskId) => _orchestrator.RunningTaskIds.Contains(taskId); @@ -94,15 +89,7 @@ public async Task LoadAsync() try { await using var db = await _dbFactory.CreateDbContextAsync(); - // 分组按当前任务 TaskScope 过滤——否则 Tag 编辑器的所属分组下拉会列出其它任务的分组, - // 选中后 tag.TaskId 取自该分组的 TaskId(见 TagEditorViewModel.Build),导致 Tag 静默落到错误任务下、永不订阅。 - var groupsQuery = db.Groups.AsNoTracking().AsQueryable(); - if (TaskScope is not null) groupsQuery = groupsQuery.Where(g => g.TaskId == TaskScope); - var groups = await groupsQuery.OrderBy(g => g.CreatedAt).ToListAsync(); - AvailableGroups.Clear(); - foreach (var g in groups) AvailableGroups.Add(g); - NewCommand.NotifyCanExecuteChanged(); // 分组数变化 → 「新建」可用性 + 空状态刷新 - var groupNameById = groups.ToDictionary(g => g.Id, g => g.Name); + NewCommand.NotifyCanExecuteChanged(); // 任务上下文变化 → 「新建」可用性 + 空状态刷新 var tasks = await db.Tasks.AsNoTracking().ToListAsync(); _taskById.Clear(); @@ -113,7 +100,6 @@ string TaskName(string tid) => var q = db.Tags.AsNoTracking().AsQueryable(); if (TaskScope is not null) q = q.Where(t => t.TaskId == TaskScope); - if (GroupFilter is not null) q = q.Where(t => t.GroupId == GroupFilter.Id); if (!string.IsNullOrWhiteSpace(SearchText)) { var pattern = $"%{SearchText.Trim()}%"; @@ -122,10 +108,7 @@ string TaskName(string tid) => var list = await q.OrderBy(t => t.Item).Take(500).ToListAsync(); Tags.Clear(); foreach (var t in list) - { - var gName = groupNameById.TryGetValue(t.GroupId, out var gn) ? gn : t.GroupId; - Tags.Add(new TagRow(t, gName, TaskName(t.TaskId))); - } + Tags.Add(new TagRow(t, TaskName(t.TaskId))); } finally { @@ -133,17 +116,14 @@ string TaskName(string tid) => } } - // Tag 直接挂任务:有任务上下文即可新建。分组层已隐藏,新建时自动落到任务的「默认分组」。 + // Tag 直接挂任务:有任务上下文即可新建。 private bool CanNew => TaskScope is not null; [RelayCommand(CanExecute = nameof(CanNew))] private async Task NewAsync() { if (TaskScope is null) return; - // 确保当前任务有「默认分组」并作为默认传给编辑器 → 编辑器隐藏分组选择器,Tag 自动落到该任务。 - var defaultGroup = await DefaultTaskGroup.EnsureAsync(_dbFactory, TaskScope); - if (AvailableGroups.All(g => g.Id != defaultGroup.Id)) AvailableGroups.Add(defaultGroup); - var result = await EditTagAsync(existing: null, defaultGroup: defaultGroup); + var result = await EditTagAsync(existing: null); if (result is null) return; await PersistNewAsync(result); } @@ -197,17 +177,23 @@ private async Task DeleteAsync() Tags.Remove(row); } - // Tag → TagRow:分组名/任务名解析与 LoadAsync 同口径。 + // Tag → TagRow:任务名解析与 LoadAsync 同口径。 private TagRow ToRow(Tag t) { - var gName = AvailableGroups.FirstOrDefault(g => g.Id == t.GroupId)?.Name ?? t.GroupId; var taskName = _taskById.TryGetValue(t.TaskId, out var tk) ? tk.DisplayName : t.TaskId; - return new TagRow(t, gName, taskName); + return new TagRow(t, taskName); } [RelayCommand] public async Task ImportAsync() { + // Tag 直接挂任务:导入落到当前任务(TaskScope)。无任务上下文(独立页)则无法导入。 + if (TaskScope is null) + { + MessageDialog.Show("无法导入", "请先在采集任务里选中一个任务再导入 Tag。", MessageDialogKind.Warning); + return; + } + var path = _filePicker.PickOpenFile("Excel 工作簿|*.xlsx", "导入 Tag"); if (path is null) return; @@ -224,27 +210,16 @@ public async Task ImportAsync() } await using var db = await _dbFactory.CreateDbContextAsync(); - var groups = await db.Groups.AsNoTracking().ToDictionaryAsync(g => g.Name, g => g); var inserted = 0; var errors = new List(); - var batch = new List(); - foreach (var row in rows) + var batch = rows.Select(row => new Tag { - if (!groups.TryGetValue(row.GroupName, out var grp)) - { - errors.Add($"未知分组: {row.GroupName} (Item={row.Item})"); - continue; - } - batch.Add(new Tag - { - Id = UlidGenerator.NewId(), - Item = row.Item, - DataType = row.DataType, - GroupId = grp.Id, - TaskId = grp.TaskId - }); - } + Id = UlidGenerator.NewId(), + Item = row.Item, + DataType = row.DataType, + TaskId = TaskScope + }).ToList(); if (batch.Count > 0) { @@ -286,10 +261,9 @@ private async Task ExportAsync() if (path is null) return; await using var db = await _dbFactory.CreateDbContextAsync(); - var groupMap = await db.Groups.AsNoTracking().ToDictionaryAsync(g => g.Id, g => g.Name); var q = db.Tags.AsNoTracking().AsQueryable(); - if (GroupFilter is not null) q = q.Where(t => t.GroupId == GroupFilter.Id); + if (TaskScope is not null) q = q.Where(t => t.TaskId == TaskScope); if (!string.IsNullOrWhiteSpace(SearchText)) { var pattern = $"%{SearchText.Trim()}%"; @@ -300,7 +274,7 @@ private async Task ExportAsync() try { await using var fs = File.Create(path); - _excel.Write(list, groupMap, fs); + _excel.Write(list, fs); MessageDialog.Show("导出成功", $"已导出 {list.Count} 条到 {path}", MessageDialogKind.Success); } catch (Exception ex) @@ -309,32 +283,18 @@ private async Task ExportAsync() } } - // 空状态 CTA:请求宿主(采集任务工作区)切到「分组」页签。独立页无宿主,事件无人订阅→按钮不显示。 - [RelayCommand] - private void GoCreateGroup() => NavigateToGroupsRequested?.Invoke(); - - partial void OnGroupFilterChanged(Group? value) - { - OnPropertyChanged(nameof(EmbeddedTitle)); - _ = LoadAsync(); - } - - // 供 NewAsync/EditAsync 调用。defaultGroup:新建时传任务的默认分组(编辑器据此隐藏分组选择器)。 - private async Task EditTagAsync(Tag? existing, Group? defaultGroup = null) + // 供 NewAsync/EditAsync 调用。 + private async Task EditTagAsync(Tag? existing) { // 任务上下文:优先当前工作台 TaskScope;独立页(无 TaskScope)编辑现有 Tag 时用其 TaskId。 - // 独立页新建虚拟测点无任务上下文 → AvailableInputTags 为空,虚拟创建受限(已知限制)。 string? taskId = TaskScope ?? existing?.TaskId; - IReadOnlyCollection? taskTags = null; + if (taskId is null) return null; // 无任务上下文无法编辑 + var taskTags = await LoadTaskTagsAsync(taskId); IReadOnlyCollection? existingFormulas = null; - if (taskId is not null) - { - taskTags = await LoadTaskTagsAsync(taskId); - if (existing is not null && existing.IsVirtual) - existingFormulas = await LoadTaskFormulasAsync(taskId); - } + if (existing is not null && existing.IsVirtual) + existingFormulas = await LoadTaskFormulasAsync(taskId); - return _editor.Edit(AvailableGroups, existing, defaultGroup ?? GroupFilter, + return _editor.Edit(taskId, existing, taskIdLookup => _taskById.TryGetValue(taskIdLookup, out var t) ? t : null, taskTags, existingFormulas); } @@ -413,7 +373,6 @@ private async Task PersistEditAsync(Tag existing, TagEditResult result) entity.Item = result.Tag.Item; entity.DataType = result.Tag.DataType; - entity.GroupId = result.Tag.GroupId; entity.TaskId = result.Tag.TaskId; entity.IsVirtual = result.Tag.IsVirtual; entity.ScaleFactor = result.Tag.ScaleFactor; diff --git a/src/Dc.App/ViewModels/Workspace/DbWorkspaceTaskSource.cs b/src/Dc.App/ViewModels/Workspace/DbWorkspaceTaskSource.cs index 1376f34..efcb9e4 100644 --- a/src/Dc.App/ViewModels/Workspace/DbWorkspaceTaskSource.cs +++ b/src/Dc.App/ViewModels/Workspace/DbWorkspaceTaskSource.cs @@ -45,12 +45,10 @@ public async Task> LoadTasksAsync() return (task, descriptors, formulas); } - public async Task<(int Groups, int Tags)> GetCountsAsync(string taskId) + public async Task GetCountsAsync(string taskId) { await using var db = await _dbFactory.CreateDbContextAsync(); - var groups = await db.Groups.AsNoTracking().CountAsync(g => g.TaskId == taskId); - var tags = await db.Tags.AsNoTracking().CountAsync(t => t.TaskId == taskId); - return (groups, tags); + return await db.Tags.AsNoTracking().CountAsync(t => t.TaskId == taskId); } public async Task> GetConfiguredTagCountsAsync(IReadOnlyCollection taskIds) diff --git a/src/Dc.App/ViewModels/Workspace/DefaultTaskGroup.cs b/src/Dc.App/ViewModels/Workspace/DefaultTaskGroup.cs deleted file mode 100644 index 41115bb..0000000 --- a/src/Dc.App/ViewModels/Workspace/DefaultTaskGroup.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Dc.Domain.Entities; -using Dc.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; - -namespace Dc.App.ViewModels.Workspace; - -/// -/// 分组层已对用户隐藏(Tag 直接挂任务):每个任务隐式持有一个「默认分组」,承载其全部 Tag。 -/// Tag 新建(TagsViewModel)与浏览批量加 Tag(BrowseViewModel)共用此兜底,避免重复实现。 -/// -internal static class DefaultTaskGroup -{ - public const string Name = "默认分组"; - - /// 取任务最早的分组作默认;没有则建一个(SaveChanges 自动补时间戳)。 - public static async Task EnsureAsync(IDbContextFactory dbFactory, string taskId) - { - await using var db = await dbFactory.CreateDbContextAsync(); - var existing = await db.Groups.AsNoTracking() - .Where(g => g.TaskId == taskId) - .OrderBy(g => g.CreatedAt) - .FirstOrDefaultAsync(); - if (existing is not null) return existing; - - var grp = new Group { Id = UlidGenerator.NewId(), Name = Name, TaskId = taskId }; - db.Groups.Add(grp); - await db.SaveChangesAsync(); - return grp; - } -} diff --git a/src/Dc.App/ViewModels/Workspace/IEmbeddableGroupPanel.cs b/src/Dc.App/ViewModels/Workspace/IEmbeddableGroupPanel.cs deleted file mode 100644 index e35abf5..0000000 --- a/src/Dc.App/ViewModels/Workspace/IEmbeddableGroupPanel.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.ComponentModel; -using Dc.Domain.Entities; - -namespace Dc.App.ViewModels.Workspace; - -public interface IEmbeddableGroupPanel : INotifyPropertyChanged -{ - bool IsEmbedded { get; set; } - CollectorTask? TaskFilter { get; set; } - Group? SelectedGroup { get; } - Task LoadAsync(); - - /// - /// 内嵌模式下,分组面板无任务时请求跳到任务列表(新建任务)。 - /// 独立页不触发(无宿主可跳)。 - /// - event Action? NavigateToTasksRequested; -} diff --git a/src/Dc.App/ViewModels/Workspace/IEmbeddableTagPanel.cs b/src/Dc.App/ViewModels/Workspace/IEmbeddableTagPanel.cs index bbe71aa..40b6ff7 100644 --- a/src/Dc.App/ViewModels/Workspace/IEmbeddableTagPanel.cs +++ b/src/Dc.App/ViewModels/Workspace/IEmbeddableTagPanel.cs @@ -1,18 +1,9 @@ -using Dc.Domain.Entities; - namespace Dc.App.ViewModels.Workspace; public interface IEmbeddableTagPanel { bool IsEmbedded { get; set; } string? TaskScope { get; set; } - Group? GroupFilter { get; set; } Task LoadAsync(); Task ImportAsync(); - - /// - /// 内嵌模式下,Tag 面板无分组时请求跳转到「分组」页签创建分组。 - /// 独立页不触发(无 Groups tab 可跳)。 - /// - event Action? NavigateToGroupsRequested; } diff --git a/src/Dc.App/ViewModels/Workspace/IWorkspaceTaskSource.cs b/src/Dc.App/ViewModels/Workspace/IWorkspaceTaskSource.cs index 267f872..be233eb 100644 --- a/src/Dc.App/ViewModels/Workspace/IWorkspaceTaskSource.cs +++ b/src/Dc.App/ViewModels/Workspace/IWorkspaceTaskSource.cs @@ -19,11 +19,11 @@ public interface IWorkspaceTaskSource /// Persist edits to an existing task (preserves CreatedAt). Task UpdateTaskAsync(CollectorTask task); - /// Delete a task and cascade-delete its groups and tags in one transaction. + /// Delete a task and cascade-delete its tags in one transaction. Task DeleteTaskCascadeAsync(string taskId); - /// 选中任务的分组数 / Tag 数(tab 计数 badge 用)。默认 0,便于测试 fake 不必实现。 - Task<(int Groups, int Tags)> GetCountsAsync(string taskId) => Task.FromResult((0, 0)); + /// 选中任务的 Tag 数(tab 计数 badge 用)。默认 0,便于测试 fake 不必实现。 + Task GetCountsAsync(string taskId) => Task.FromResult(0); /// /// 批量取各任务的「已配置 Tag 数」(DB 口径,与运行状态无关)。用于任务列表 badge—— diff --git a/src/Dc.App/ViewModels/Workspace/TaskWorkspaceViewModel.cs b/src/Dc.App/ViewModels/Workspace/TaskWorkspaceViewModel.cs index 6b7beb4..4b288d9 100644 --- a/src/Dc.App/ViewModels/Workspace/TaskWorkspaceViewModel.cs +++ b/src/Dc.App/ViewModels/Workspace/TaskWorkspaceViewModel.cs @@ -27,7 +27,6 @@ public sealed partial class TaskWorkspaceViewModel : ObservableObject public WorkspaceOverviewViewModel Overview { get; } public IEmbeddableTagPanel TagsPanel { get; } - public IEmbeddableGroupPanel GroupsPanel { get; } public IEmbeddableLivePanel LivePanel { get; } public IEmbeddableDiagPanel DiagPanel { get; } public WorkspaceConfigViewModel Config { get; } @@ -38,7 +37,6 @@ public sealed partial class TaskWorkspaceViewModel : ObservableObject [ObservableProperty] private int _runningCount; [ObservableProperty] private int _stoppedCount; [ObservableProperty] private int _alertCount; - [ObservableProperty] private int _selectedGroupCount; // tab badge:分组 [ObservableProperty] private int _selectedTagCount; // tab badge:Tag [ObservableProperty] private string _selectedTab = "overview"; [ObservableProperty] private object? _currentTabContent; @@ -54,7 +52,6 @@ public TaskWorkspaceViewModel( IEmbeddableTagPanel tagsPanel, TaskOrchestrator? orchestrator = null, ITaskEditorDialog? editor = null, - IEmbeddableGroupPanel? groupsPanel = null, IEmbeddableLivePanel? livePanel = null, IEmbeddableDiagPanel? diagPanel = null, WorkspaceConfigViewModel? config = null, @@ -73,13 +70,11 @@ public TaskWorkspaceViewModel( TagsPanel = tagsPanel; TagsPanel.IsEmbedded = true; - // 分组层已对用户隐藏(Tag 直接挂任务):不再有「分组」页签,GroupsPanel 仅保留为隐式默认分组的载体。 - GroupsPanel = groupsPanel ?? new NullGroupPanel(); + // 分组层已去除:Tag 直接挂任务,不再有「分组」页签。 LivePanel = livePanel ?? new NullLivePanel(); DiagPanel = diagPanel ?? new NullDiagPanel(); Config = config ?? new WorkspaceConfigViewModel(editor ?? new NullTaskEditorDialog()); - GroupsPanel.IsEmbedded = true; // 任务编辑持久化走 VM(main 的任务 CRUD);PersistEditedAsync 内部已 LoadAsync 刷新列表。 Config.Edited += async edited => await PersistEditedAsync(edited); @@ -104,21 +99,17 @@ partial void OnSelectedTaskChanged(TaskMasterRow? value) Overview.Sample(); var task = value is null ? null : _tasksById.GetValueOrDefault(value.TaskId); - GroupsPanel.TaskFilter = task; - _ = GroupsPanel.LoadAsync(); LivePanel.TaskFilter = value?.TaskId; DiagPanel.TaskScope = value?.TaskId; Config.SetTask(task); - if (value is null) { SelectedGroupCount = 0; SelectedTagCount = 0; } + if (value is null) SelectedTagCount = 0; else _ = LoadCountsAsync(value.TaskId); } private async Task LoadCountsAsync(string taskId) { - var (g, t) = await _source.GetCountsAsync(taskId); - SelectedGroupCount = g; - SelectedTagCount = t; + SelectedTagCount = await _source.GetCountsAsync(taskId); } partial void OnSelectedTabChanged(string value) => UpdateTabContent(); @@ -214,11 +205,17 @@ public async Task LoadAsync() FilteredTasks.Refresh(); - // 重建后恢复选中:按 TaskId 找回对应新行(启动/停止/刷新都经此处) - if (prevSelectedId is not null) - SelectedTask = AllTasks.FirstOrDefault(r => r.TaskId == prevSelectedId); + // 优先用外部请求的选中(浏览批量加 Tag 后跳回工作台);否则恢复原选中(启动/停止/刷新都经此处)。 + var targetId = _pendingSelectId ?? prevSelectedId; + _pendingSelectId = null; + if (targetId is not null) + SelectedTask = AllTasks.FirstOrDefault(r => r.TaskId == targetId); } + private string? _pendingSelectId; + /// 请求下次 LoadAsync 时选中该任务(浏览批量加 Tag 后跳回工作台用)。 + public void RequestSelect(string taskId) => _pendingSelectId = taskId; + private static string ProtocolLabel(byte type) => type switch { 1 => "DA", @@ -300,9 +297,9 @@ public async Task DeleteSelectedAsync() var name = SelectedTask.Name; // 先确认,确认后才产生任何副作用(取消 = 真 no-op,不停不删) - var (g, t) = await _source.GetCountsAsync(id); + var t = await _source.GetCountsAsync(id); if (!_confirm.Confirm("删除任务", - $"将删除任务「{name}」及其 {g} 个分组、{t} 个 Tag,不可恢复。确定删除?")) + $"将删除任务「{name}」及其 {t} 个 Tag,不可恢复。确定删除?")) return; // 确认后:运行中先停,再级联删 @@ -328,21 +325,6 @@ public async Task ImportAsync() // ── Null-object stubs for optional panels ────────────────────────────── - private sealed class NullGroupPanel - : CommunityToolkit.Mvvm.ComponentModel.ObservableObject, IEmbeddableGroupPanel - { - public bool IsEmbedded { get; set; } - private Dc.Domain.Entities.CollectorTask? _taskFilter; - public Dc.Domain.Entities.CollectorTask? TaskFilter - { - get => _taskFilter; - set => SetProperty(ref _taskFilter, value); - } - public Dc.Domain.Entities.Group? SelectedGroup => null; - public Task LoadAsync() => Task.CompletedTask; - public event Action? NavigateToTasksRequested; - } - private sealed class NullLivePanel : IEmbeddableLivePanel { public string? TaskFilter { get; set; } diff --git a/src/Dc.App/Views/ConfigEntryEditorWindow.xaml b/src/Dc.App/Views/ConfigEntryEditorWindow.xaml index 011d7b4..6f50b78 100644 --- a/src/Dc.App/Views/ConfigEntryEditorWindow.xaml +++ b/src/Dc.App/Views/ConfigEntryEditorWindow.xaml @@ -25,10 +25,17 @@ - -