diff --git a/src/Dc.App/Composition/ServiceRegistration.cs b/src/Dc.App/Composition/ServiceRegistration.cs index ce913c6..4291c3e 100644 --- a/src/Dc.App/Composition/ServiceRegistration.cs +++ b/src/Dc.App/Composition/ServiceRegistration.cs @@ -236,7 +236,14 @@ public static IServiceCollection AddDcApp(this IServiceCollection services, stri _ => 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 +251,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/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..df24b3f 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(); @@ -165,8 +168,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/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/TagEditorViewModel.cs b/src/Dc.App/ViewModels/TagEditorViewModel.cs index 7752c2f..80fe0a4 100644 --- a/src/Dc.App/ViewModels/TagEditorViewModel.cs +++ b/src/Dc.App/ViewModels/TagEditorViewModel.cs @@ -28,6 +28,10 @@ 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(); @@ -113,6 +117,7 @@ public TagEditorViewModel( { SelectedTag = AvailableInputTags.FirstOrDefault(t => t.Id == input.SourceTagId) }; + row.PropertyChanged += OnInputRowChanged; InputBindings.Add(row); } } @@ -120,6 +125,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; @@ -169,19 +188,26 @@ partial void OnSelectedGroupRowChanged(GroupRow? value) // 分组定 → TaskId 定 → 刷新可选输入 Tag(同任务真实,排除自身虚拟)。 OnPropertyChanged(nameof(Group)); RefreshAvailableInputTags(); + Revalidate(); } 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(); @@ -195,13 +221,16 @@ 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() diff --git a/src/Dc.App/ViewModels/TagsViewModel.cs b/src/Dc.App/ViewModels/TagsViewModel.cs index 9fc2881..5c10348 100644 --- a/src/Dc.App/ViewModels/TagsViewModel.cs +++ b/src/Dc.App/ViewModels/TagsViewModel.cs @@ -20,6 +20,7 @@ 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; @@ -55,16 +56,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); diff --git a/src/Dc.App/ViewModels/Workspace/TaskWorkspaceViewModel.cs b/src/Dc.App/ViewModels/Workspace/TaskWorkspaceViewModel.cs index 6b7beb4..3a1d272 100644 --- a/src/Dc.App/ViewModels/Workspace/TaskWorkspaceViewModel.cs +++ b/src/Dc.App/ViewModels/Workspace/TaskWorkspaceViewModel.cs @@ -214,11 +214,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", 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 @@ - -