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 @@
-
-
-
-
+
+
+
+
+
+
+
diff --git a/src/Dc.App/Views/GroupEditorWindow.xaml b/src/Dc.App/Views/GroupEditorWindow.xaml
deleted file mode 100644
index 45812bd..0000000
--- a/src/Dc.App/Views/GroupEditorWindow.xaml
+++ /dev/null
@@ -1,60 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/Dc.App/Views/GroupEditorWindow.xaml.cs b/src/Dc.App/Views/GroupEditorWindow.xaml.cs
deleted file mode 100644
index ccd3cfd..0000000
--- a/src/Dc.App/Views/GroupEditorWindow.xaml.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-using System.Windows;
-using Dc.App.Services;
-using Dc.App.ViewModels;
-
-namespace Dc.App.Views;
-
-public partial class GroupEditorWindow : ModalWindowBase
-{
- public GroupEditorWindow() => InitializeComponent();
-
- private void OnSaveClick(object sender, RoutedEventArgs e)
- {
- if (DataContext is not GroupEditorViewModel vm) return;
- var errors = vm.Validate();
- if (errors.Count > 0)
- {
- MessageDialog.Show(this, "输入错误", string.Join("\n", errors), MessageDialogKind.Warning);
- return;
- }
- DialogResult = true;
- }
-}
diff --git a/src/Dc.App/Views/GroupsView.xaml b/src/Dc.App/Views/GroupsView.xaml
deleted file mode 100644
index f9b153b..0000000
--- a/src/Dc.App/Views/GroupsView.xaml
+++ /dev/null
@@ -1,117 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/Dc.App/Views/GroupsView.xaml.cs b/src/Dc.App/Views/GroupsView.xaml.cs
deleted file mode 100644
index 267a80e..0000000
--- a/src/Dc.App/Views/GroupsView.xaml.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-using System.Windows;
-using System.Windows.Controls;
-using Dc.App.ViewModels;
-
-namespace Dc.App.Views;
-
-public partial class GroupsView : UserControl
-{
- public GroupsView() => InitializeComponent();
-
- private void OnClearFilter(object sender, RoutedEventArgs e)
- {
- if (DataContext is GroupsViewModel vm) vm.TaskFilter = null;
- }
-}
diff --git a/src/Dc.App/Views/LiveDataView.xaml b/src/Dc.App/Views/LiveDataView.xaml
index 0d5c830..fd5313e 100644
--- a/src/Dc.App/Views/LiveDataView.xaml
+++ b/src/Dc.App/Views/LiveDataView.xaml
@@ -32,8 +32,10 @@
VerticalAlignment="Center"
Margin="0,0,6,0" />
diff --git a/src/Dc.App/Views/TagsView.xaml b/src/Dc.App/Views/TagsView.xaml
index c593f0f..2eacd1e 100644
--- a/src/Dc.App/Views/TagsView.xaml
+++ b/src/Dc.App/Views/TagsView.xaml
@@ -42,23 +42,6 @@
Margin="32,0,0,0"
VerticalAlignment="Center"
Visibility="{Binding ShowFullToolbar, Converter={StaticResource BoolToVis}}">
-
-
-
-
-
-
-
-
-
-
+
diff --git a/src/Dc.App/Views/TagsView.xaml.cs b/src/Dc.App/Views/TagsView.xaml.cs
index faa90a4..6b27c3e 100644
--- a/src/Dc.App/Views/TagsView.xaml.cs
+++ b/src/Dc.App/Views/TagsView.xaml.cs
@@ -9,11 +9,6 @@ public partial class TagsView : UserControl
{
public TagsView() => InitializeComponent();
- private void OnClearFilter(object sender, RoutedEventArgs e)
- {
- if (DataContext is TagsViewModel vm) vm.GroupFilter = null;
- }
-
private void OnSearchKeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Enter && DataContext is TagsViewModel vm)
diff --git a/src/Dc.Domain/Entities/CollectorTask.cs b/src/Dc.Domain/Entities/CollectorTask.cs
index 2fbf0a0..eeb123b 100644
--- a/src/Dc.Domain/Entities/CollectorTask.cs
+++ b/src/Dc.Domain/Entities/CollectorTask.cs
@@ -26,9 +26,6 @@ public class CollectorTask : EntityBase
// false → None 端点直连。默认 true(产线安全优先,CLAUDE.md 约束)。仅 UA 生效。
public bool UseSecurity { get; set; } = true;
- [JsonIgnore]
- public List Groups { get; set; } = new();
-
[JsonIgnore]
public List Tags { get; set; } = new();
}
diff --git a/src/Dc.Domain/Entities/Group.cs b/src/Dc.Domain/Entities/Group.cs
deleted file mode 100644
index d6b7c1b..0000000
--- a/src/Dc.Domain/Entities/Group.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-using System.Text.Json.Serialization;
-
-namespace Dc.Domain.Entities;
-
-public class Group : EntityBase
-{
- public string Name { get; set; } = string.Empty;
- public string TaskId { get; set; } = string.Empty;
-
- [JsonIgnore]
- public List Tags { get; set; } = new();
-}
diff --git a/src/Dc.Domain/Entities/Tag.cs b/src/Dc.Domain/Entities/Tag.cs
index 8f13725..c519b28 100644
--- a/src/Dc.Domain/Entities/Tag.cs
+++ b/src/Dc.Domain/Entities/Tag.cs
@@ -5,7 +5,6 @@ public class Tag : EntityBase
public string Item { get; set; } = string.Empty;
public int DataType { get; set; }
public string TaskId { get; set; } = string.Empty;
- public string GroupId { get; set; } = string.Empty;
// 真实 Tag 的工程量映射;null 表示不缩放。虚拟 Tag 忽略。
public double? ScaleFactor { get; set; }
diff --git a/src/Dc.Infrastructure/Backup/BackupBundle.cs b/src/Dc.Infrastructure/Backup/BackupBundle.cs
index 105d006..b006222 100644
--- a/src/Dc.Infrastructure/Backup/BackupBundle.cs
+++ b/src/Dc.Infrastructure/Backup/BackupBundle.cs
@@ -8,13 +8,12 @@ public sealed class BackupBundle
public DateTimeOffset ExportedAt { get; set; }
public string AppVersion { get; set; } = string.Empty;
public List Tasks { get; set; } = new();
- public List Groups { get; set; } = new();
public List Tags { get; set; } = new();
public List Configs { get; set; } = new();
}
public sealed record BackupImportResult(
- int TasksImported, int GroupsImported, int TagsImported, int ConfigsImported,
+ int TasksImported, int TagsImported, int ConfigsImported,
IReadOnlyList Errors);
public enum BackupImportMode { Merge, Replace }
diff --git a/src/Dc.Infrastructure/Backup/JsonConfigBackupService.cs b/src/Dc.Infrastructure/Backup/JsonConfigBackupService.cs
index 359ca6c..2c4f5ed 100644
--- a/src/Dc.Infrastructure/Backup/JsonConfigBackupService.cs
+++ b/src/Dc.Infrastructure/Backup/JsonConfigBackupService.cs
@@ -21,7 +21,6 @@ public async Task ExportAsync(DcDbContext db, CancellationToken ct
ExportedAt = DateTimeOffset.UtcNow,
AppVersion = typeof(JsonConfigBackupService).Assembly.GetName().Version?.ToString(3) ?? "0.0.0",
Tasks = await db.Tasks.AsNoTracking().OrderBy(t => t.CreatedAt).ToListAsync(ct),
- Groups = await db.Groups.AsNoTracking().OrderBy(g => g.CreatedAt).ToListAsync(ct),
Tags = await db.Tags.AsNoTracking().OrderBy(t => t.CreatedAt).ToListAsync(ct),
Configs = await db.Configs.AsNoTracking().OrderBy(c => c.Key).ToListAsync(ct)
};
@@ -30,31 +29,27 @@ public async Task ExportAsync(DcDbContext db, CancellationToken ct
public async Task ImportAsync(DcDbContext db, BackupBundle bundle, BackupImportMode mode, CancellationToken ct = default)
{
if (bundle.SchemaVersion != 1)
- return new BackupImportResult(0, 0, 0, 0, new[] { $"不支持的 schemaVersion: {bundle.SchemaVersion}" });
+ return new BackupImportResult(0, 0, 0, new[] { $"不支持的 schemaVersion: {bundle.SchemaVersion}" });
var errors = new List();
if (mode == BackupImportMode.Replace)
{
await db.Tags.ExecuteDeleteAsync(ct);
- await db.Groups.ExecuteDeleteAsync(ct);
await db.Tasks.ExecuteDeleteAsync(ct);
await db.Configs.ExecuteDeleteAsync(ct);
}
- var (existingTaskIds, existingGroupIds, existingTagIds, existingConfigIds) = (
+ var (existingTaskIds, existingTagIds, existingConfigIds) = (
new HashSet(await db.Tasks.AsNoTracking().Select(t => t.Id).ToListAsync(ct)),
- new HashSet(await db.Groups.AsNoTracking().Select(g => g.Id).ToListAsync(ct)),
new HashSet(await db.Tags.AsNoTracking().Select(t => t.Id).ToListAsync(ct)),
new HashSet(await db.Configs.AsNoTracking().Select(c => c.Id).ToListAsync(ct)));
var tasksToAdd = bundle.Tasks.Where(t => !existingTaskIds.Contains(t.Id)).ToList();
- var groupsToAdd = bundle.Groups.Where(g => !existingGroupIds.Contains(g.Id)).ToList();
var tagsToAdd = bundle.Tags.Where(t => !existingTagIds.Contains(t.Id)).ToList();
var configsToAdd = bundle.Configs.Where(c => !existingConfigIds.Contains(c.Id)).ToList();
db.Tasks.AddRange(tasksToAdd);
- db.Groups.AddRange(groupsToAdd);
db.Tags.AddRange(tagsToAdd);
db.Configs.AddRange(configsToAdd);
@@ -62,7 +57,7 @@ public async Task ImportAsync(DcDbContext db, BackupBundle b
catch (DbUpdateException ex) { errors.Add(ex.InnerException?.Message ?? ex.Message); }
return new BackupImportResult(
- tasksToAdd.Count, groupsToAdd.Count, tagsToAdd.Count, configsToAdd.Count, errors);
+ tasksToAdd.Count, tagsToAdd.Count, configsToAdd.Count, errors);
}
public string SerializeToJson(BackupBundle bundle) =>
diff --git a/src/Dc.Infrastructure/Excel/ClosedXmlTagExcelService.cs b/src/Dc.Infrastructure/Excel/ClosedXmlTagExcelService.cs
index a6420df..a6f00b9 100644
--- a/src/Dc.Infrastructure/Excel/ClosedXmlTagExcelService.cs
+++ b/src/Dc.Infrastructure/Excel/ClosedXmlTagExcelService.cs
@@ -16,7 +16,6 @@ public IReadOnlyList Read(Stream excelStream)
var headerRow = sheet.Row(1);
var itemCol = FindColumn(headerRow, "Item") ?? 1;
var typeCol = FindColumn(headerRow, "DataType") ?? 2;
- var groupCol = FindColumn(headerRow, "GroupName") ?? 3;
var rows = new List();
var lastRow = sheet.LastRowUsed()?.RowNumber() ?? 1;
@@ -31,22 +30,20 @@ public IReadOnlyList Read(Stream excelStream)
System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture,
out var dataType);
- var groupName = sheet.Cell(r, groupCol).GetString().Trim();
- rows.Add(new TagImportRow(item, dataType, groupName));
+ rows.Add(new TagImportRow(item, dataType));
}
return rows;
}
- public void Write(IEnumerable tags, IReadOnlyDictionary groupIdToName, Stream output)
+ public void Write(IEnumerable tags, Stream output)
{
using var workbook = new XLWorkbook();
var sheet = workbook.AddWorksheet(SheetName);
sheet.Cell(1, 1).Value = "Item";
sheet.Cell(1, 2).Value = "DataType";
- sheet.Cell(1, 3).Value = "GroupName";
- sheet.Cell(1, 4).Value = "TaskId";
+ sheet.Cell(1, 3).Value = "TaskId";
sheet.Row(1).Style.Font.Bold = true;
int row = 2;
@@ -54,8 +51,7 @@ public void Write(IEnumerable tags, IReadOnlyDictionary gro
{
sheet.Cell(row, 1).Value = tag.Item;
sheet.Cell(row, 2).Value = tag.DataType;
- sheet.Cell(row, 3).Value = groupIdToName.GetValueOrDefault(tag.GroupId, string.Empty);
- sheet.Cell(row, 4).Value = tag.TaskId;
+ sheet.Cell(row, 3).Value = tag.TaskId;
row++;
}
sheet.Columns().AdjustToContents();
diff --git a/src/Dc.Infrastructure/Excel/ITagExcelService.cs b/src/Dc.Infrastructure/Excel/ITagExcelService.cs
index 39db8c4..20631d3 100644
--- a/src/Dc.Infrastructure/Excel/ITagExcelService.cs
+++ b/src/Dc.Infrastructure/Excel/ITagExcelService.cs
@@ -5,5 +5,5 @@ namespace Dc.Infrastructure.Excel;
public interface ITagExcelService
{
IReadOnlyList Read(Stream excelStream);
- void Write(IEnumerable tags, IReadOnlyDictionary groupIdToName, Stream output);
+ void Write(IEnumerable tags, Stream output);
}
diff --git a/src/Dc.Infrastructure/Excel/TagImportRow.cs b/src/Dc.Infrastructure/Excel/TagImportRow.cs
index 5374037..05b363f 100644
--- a/src/Dc.Infrastructure/Excel/TagImportRow.cs
+++ b/src/Dc.Infrastructure/Excel/TagImportRow.cs
@@ -1,3 +1,3 @@
namespace Dc.Infrastructure.Excel;
-public sealed record TagImportRow(string Item, int DataType, string GroupName);
+public sealed record TagImportRow(string Item, int DataType);
diff --git a/src/Dc.Infrastructure/Persistence/DbSchemaInitializer.cs b/src/Dc.Infrastructure/Persistence/DbSchemaInitializer.cs
index e65e47c..b8c799e 100644
--- a/src/Dc.Infrastructure/Persistence/DbSchemaInitializer.cs
+++ b/src/Dc.Infrastructure/Persistence/DbSchemaInitializer.cs
@@ -41,6 +41,59 @@ source_tag_id TEXT NOT NULL
""");
EnsureIndex(db, "udx_formula_name", "CREATE UNIQUE INDEX IF NOT EXISTS udx_formula_name ON dc_formulas (task_id, name)");
EnsureIndex(db, "udx_formula_input_alias", "CREATE UNIQUE INDEX IF NOT EXISTS udx_formula_input_alias ON dc_formula_inputs (formula_id, alias)");
+ MigrateDropGroupLayer(db);
+ }
+
+ // 分组层移除迁移(旧库一次性):dc_tags.group_id 列 + dc_groups 表去除,Tag 直接挂任务。
+ // 仅当旧 schema(dc_tags 仍有 group_id 列)时执行;新库 EnsureCreated 已建对 → no-op。幂等。
+ // 旧 dc_tags 上 group_id 带外键约束,SQLite 不能 DROP 被外键引用的列(会报
+ // "unknown column in foreign key definition"),只能整表重建(EnsureColumn 已先补齐 scale_factor 等列)。
+ private static void MigrateDropGroupLayer(DcDbContext db)
+ {
+ if (!TableExists(db, "dc_tags")) return;
+ if (!ColumnExists(db, "dc_tags", "group_id")) return; // 已是新 schema
+
+ // 显式保持连接打开:PRAGMA foreign_keys 只在「连接打开期间、事务外」逐条生效;若让每条
+ // ExecuteSqlRaw 各自开关连接,PRAGMA 会丢。旧库可能 FK=ON 且有孤儿行(task_id 无对应任务),
+ // 整表重建期间必须关 FK,否则 DROP/RENAME 触发 FK 检查中断迁移。
+ db.Database.OpenConnection();
+ try
+ {
+ db.Database.ExecuteSqlRaw("PRAGMA foreign_keys=OFF");
+
+ // 1. 防御性回填:个别旧行 task_id 可能为空,从其分组补回。(正常每 Tag 建时即带 task_id)
+ if (TableExists(db, "dc_groups"))
+ {
+ db.Database.ExecuteSqlRaw(
+ "UPDATE dc_tags SET task_id = (SELECT task_id FROM dc_groups WHERE dc_groups.id = dc_tags.group_id) " +
+ "WHERE (task_id IS NULL OR task_id = '') AND group_id IN (SELECT id FROM dc_groups)");
+ }
+ // 2. 整表重建去掉 group_id 及其外键(SQLite 不能 DROP 被外键引用的列)。
+ db.Database.ExecuteSqlRaw("DROP INDEX IF EXISTS udx_name");
+ db.Database.ExecuteSqlRaw(
+ """
+ CREATE TABLE dc_tags_new (
+ id TEXT NOT NULL PRIMARY KEY,
+ item TEXT NOT NULL,
+ data_type INTEGER NOT NULL,
+ task_id TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ scale_factor REAL NULL,
+ "offset" REAL NULL,
+ is_virtual INTEGER NOT NULL DEFAULT 0
+ )
+ """);
+ db.Database.ExecuteSqlRaw(
+ "INSERT INTO dc_tags_new (id, item, data_type, task_id, created_at, updated_at, scale_factor, \"offset\", is_virtual) " +
+ "SELECT id, item, data_type, task_id, created_at, updated_at, scale_factor, \"offset\", is_virtual FROM dc_tags");
+ db.Database.ExecuteSqlRaw("DROP TABLE dc_tags");
+ db.Database.ExecuteSqlRaw("ALTER TABLE dc_tags_new RENAME TO dc_tags");
+ db.Database.ExecuteSqlRaw("CREATE UNIQUE INDEX IF NOT EXISTS udx_name ON dc_tags (item, task_id)");
+ // 3. 删分组表。
+ db.Database.ExecuteSqlRaw("DROP TABLE IF EXISTS dc_groups");
+ }
+ finally { db.Database.CloseConnection(); }
}
// 旧库补字段:先查列是否存在,缺失才 ALTER。绝不跑注定失败的 SQL ——
diff --git a/src/Dc.Infrastructure/Persistence/DcDbContext.cs b/src/Dc.Infrastructure/Persistence/DcDbContext.cs
index 7bee132..b163575 100644
--- a/src/Dc.Infrastructure/Persistence/DcDbContext.cs
+++ b/src/Dc.Infrastructure/Persistence/DcDbContext.cs
@@ -8,7 +8,6 @@ public class DcDbContext : DbContext
public DcDbContext(DbContextOptions options) : base(options) { }
public DbSet Tags => Set();
- public DbSet Groups => Set();
public DbSet Tasks => Set();
public DbSet Configs => Set();
public DbSet Formulas => Set();
@@ -22,21 +21,14 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
{
e.ToTable("dc_tags");
e.HasKey(x => x.Id);
- e.HasIndex(x => new { x.Item, x.TaskId, x.GroupId }).IsUnique().HasDatabaseName("udx_name");
- });
-
- modelBuilder.Entity(e =>
- {
- e.ToTable("dc_groups");
- e.HasKey(x => x.Id);
- e.HasMany(x => x.Tags).WithOne().HasForeignKey(t => t.GroupId).OnDelete(DeleteBehavior.NoAction);
+ // 分组层已去除:Tag 直接挂任务,任务内 Item 唯一。
+ e.HasIndex(x => new { x.Item, x.TaskId }).IsUnique().HasDatabaseName("udx_name");
});
modelBuilder.Entity(e =>
{
e.ToTable("dc_tasks");
e.HasKey(x => x.Id);
- e.HasMany(x => x.Groups).WithOne().HasForeignKey(g => g.TaskId).OnDelete(DeleteBehavior.NoAction);
e.HasMany(x => x.Tags).WithOne().HasForeignKey(t => t.TaskId).OnDelete(DeleteBehavior.NoAction);
});
diff --git a/src/Dc.Infrastructure/Persistence/TaskStore.cs b/src/Dc.Infrastructure/Persistence/TaskStore.cs
index a675ef6..a26717e 100644
--- a/src/Dc.Infrastructure/Persistence/TaskStore.cs
+++ b/src/Dc.Infrastructure/Persistence/TaskStore.cs
@@ -24,14 +24,12 @@ public static async Task UpdateAsync(DcDbContext db, CollectorTask task)
await db.SaveChangesAsync();
}
- // 安全删除:一个事务里按 task_id 删 tags → groups → task。tag 同时挂 task 与 group,
- // 按 task_id 删可一网打尽(含分组下的),不留孤儿。EF 关系是 NoAction + ForeignKeys=false,
- // 故必须显式删子项。
+ // 安全删除:一个事务里按 task_id 删 tags → task,不留孤儿。
+ // EF 关系是 NoAction + ForeignKeys=false,故必须显式删子项。
public static async Task DeleteCascadeAsync(DcDbContext db, string taskId)
{
await using var tx = await db.Database.BeginTransactionAsync();
await db.Tags.Where(t => t.TaskId == taskId).ExecuteDeleteAsync();
- await db.Groups.Where(g => g.TaskId == taskId).ExecuteDeleteAsync();
await db.Tasks.Where(t => t.Id == taskId).ExecuteDeleteAsync();
await tx.CommitAsync();
}
diff --git a/tests/Dc.App.Tests/ViewModels/GroupEditorViewModelTests.cs b/tests/Dc.App.Tests/ViewModels/GroupEditorViewModelTests.cs
deleted file mode 100644
index 95cd137..0000000
--- a/tests/Dc.App.Tests/ViewModels/GroupEditorViewModelTests.cs
+++ /dev/null
@@ -1,58 +0,0 @@
-using Dc.App.ViewModels;
-using Dc.Domain.Entities;
-
-namespace Dc.App.Tests.ViewModels;
-
-public class GroupEditorViewModelTests
-{
- private static CollectorTask Task(string id, string server) => new() { Id = id, Server = server };
-
- [Fact]
- public void Create_WithDefaultTask_HidesSelector_TitleHasTask_LocksTask()
- {
- var t = Task("t1", "炉温");
- var vm = new GroupEditorViewModel(new[] { t }, existing: null, defaultTask: t);
-
- Assert.False(vm.ShowTaskSelector);
- Assert.Contains("新建分组", vm.Title);
- Assert.Contains("炉温", vm.Title);
- vm.Name = "G1";
- Assert.Equal("t1", vm.ToEntity().TaskId);
- }
-
- [Fact]
- public void Create_WithoutDefaultTask_ShowsSelector_TaskNull()
- {
- var t = Task("t1", "炉温");
- var vm = new GroupEditorViewModel(new[] { t }, existing: null, defaultTask: null);
-
- Assert.True(vm.ShowTaskSelector);
- Assert.Null(vm.Task);
- Assert.Equal("新建分组", vm.Title);
- }
-
- [Fact]
- public void Edit_HidesSelector_TitleHasTask()
- {
- var t = Task("t1", "炉温");
- var existing = new Group { Id = "g1", Name = "G", TaskId = "t1" };
- var vm = new GroupEditorViewModel(new[] { t }, existing, defaultTask: null);
-
- Assert.False(vm.ShowTaskSelector);
- Assert.Contains("编辑分组", vm.Title);
- Assert.Contains("炉温", vm.Title);
- }
-
- [Fact]
- public void Edit_OrphanedTask_ShowsSelector_TitlePlain()
- {
- // existing.TaskId 不在 AvailableTasks 中(任务已删)→ 无法锁定 → 显示选择器、标题退化
- var t = Task("t1", "炉温");
- var existing = new Group { Id = "g1", Name = "G", TaskId = "GONE" };
- var vm = new GroupEditorViewModel(new[] { t }, existing, defaultTask: null);
-
- Assert.True(vm.ShowTaskSelector);
- Assert.Null(vm.Task);
- Assert.Equal("编辑分组", vm.Title);
- }
-}
diff --git a/tests/Dc.App.Tests/ViewModels/TagEditorViewModelTests.cs b/tests/Dc.App.Tests/ViewModels/TagEditorViewModelTests.cs
index a09936d..f8847a5 100644
--- a/tests/Dc.App.Tests/ViewModels/TagEditorViewModelTests.cs
+++ b/tests/Dc.App.Tests/ViewModels/TagEditorViewModelTests.cs
@@ -7,36 +7,29 @@ namespace Dc.App.Tests.ViewModels;
public class TagEditorViewModelTests
{
- private static Group Grp(string id, string name, string taskId = "t1")
- => new() { Id = id, Name = name, TaskId = taskId };
-
[Fact]
- public void Create_WithDefaultGroup_HidesSelector_TitlePlain_LocksGroup()
+ public void Create_New_TitlePlain_AttachesToTask()
{
- var g = Grp("g1", "温度组");
- var vm = new TagEditorViewModel(new[] { g }, existing: null, defaultGroup: g);
+ var vm = new TagEditorViewModel("t1", existing: null);
- Assert.False(vm.ShowGroupSelector); // 分组层隐藏:不显示选择器
- Assert.Equal("新建 Tag", vm.Title); // 标题不再带分组名
+ Assert.Equal("新建 Tag", vm.Title);
vm.Item = "tag.a";
- Assert.Equal("g1", vm.ToResult().Tag.GroupId); // 仍锁定到默认分组
+ Assert.Equal("t1", vm.ToResult().Tag.TaskId); // 直接挂到任务
}
[Fact]
public void Edit_ExistingScaledRealTag_RestoresScaleFields()
{
- var g = Grp("g1", "温度组");
var existing = new Tag
{
Id = "tag1",
Item = "x",
DataType = 4,
- GroupId = "g1",
TaskId = "t1",
ScaleFactor = 0.1,
Offset = -5
};
- var vm = new TagEditorViewModel(new[] { g }, existing, defaultGroup: null);
+ var vm = new TagEditorViewModel("t1", existing);
Assert.Equal("0.1", vm.ScaleFactor);
Assert.Equal("-5", vm.Offset);
@@ -46,51 +39,24 @@ public void Edit_ExistingScaledRealTag_RestoresScaleFields()
}
[Fact]
- public void Create_WithoutDefaultGroup_ShowsSelector_GroupNull()
- {
- var g = Grp("g1", "温度组");
- var vm = new TagEditorViewModel(new[] { g }, existing: null, defaultGroup: null);
-
- Assert.True(vm.ShowGroupSelector);
- Assert.Null(vm.Group); // 不兜底选第一个分组
- Assert.Equal("新建 Tag", vm.Title);
- }
-
- [Fact]
- public void Edit_HidesSelector_TitlePlain()
+ public void Edit_TitlePlain()
{
- var g = Grp("g1", "温度组");
- var existing = new Tag { Id = "tag1", Item = "x", DataType = 4, GroupId = "g1", TaskId = "t1" };
- var vm = new TagEditorViewModel(new[] { g }, existing, defaultGroup: null);
+ var existing = new Tag { Id = "tag1", Item = "x", DataType = 4, TaskId = "t1" };
+ var vm = new TagEditorViewModel("t1", existing);
- Assert.False(vm.ShowGroupSelector);
- Assert.Equal("编辑 Tag", vm.Title);
- }
-
- [Fact]
- public void Edit_OrphanedGroup_ShowsSelector_TitlePlain()
- {
- // existing.GroupId 不在 AvailableGroups 中(分组已删)→ 无法锁定 → 显示选择器、标题退化
- var g = Grp("g1", "温度组");
- var existing = new Tag { Id = "tag1", Item = "x", DataType = 4, GroupId = "GONE", TaskId = "t1" };
- var vm = new TagEditorViewModel(new[] { g }, existing, defaultGroup: null);
-
- Assert.True(vm.ShowGroupSelector);
- Assert.Null(vm.Group);
Assert.Equal("编辑 Tag", vm.Title);
}
[Fact]
public void ToResult_RealTag_NoFormula()
{
- var g = Grp("g1", "温度组");
- var vm = new TagEditorViewModel(new[] { g }, existing: null, defaultGroup: g);
+ var vm = new TagEditorViewModel("t1", existing: null);
vm.Item = "ns=3;i=1002";
var result = vm.ToResult();
Assert.NotNull(result);
Assert.Equal("ns=3;i=1002", result.Tag.Item);
- Assert.Equal("g1", result.Tag.GroupId);
+ Assert.Equal("t1", result.Tag.TaskId);
Assert.Null(result.Formula);
Assert.Empty(result.Inputs);
}
@@ -112,10 +78,8 @@ private static Tag RealTag(string id, string item, string taskId = "t1", int dat
[Fact]
public void Virtual_ExpressionExtractsInputs_AndToResultBuildsFormula()
{
- var g = Grp("g1", "温度组");
var realT = RealTag("rt1", "Random");
- var vm = new TagEditorViewModel(new[] { g }, existing: null, defaultGroup: g,
- taskTags: new[] { realT });
+ var vm = new TagEditorViewModel("t1", existing: null, taskTags: new[] { realT });
vm.IsVirtual = true;
vm.FormulaName = "Sum";
@@ -144,11 +108,9 @@ public void Virtual_ExpressionExtractsInputs_AndToResultBuildsFormula()
[Fact]
public void Virtual_ExpressionChange_PreservesSelectedKeepsNewNull()
{
- var g = Grp("g1", "温度组");
var realT = RealTag("rt1", "Random");
var realP = RealTag("rt2", "Counter");
- var vm = new TagEditorViewModel(new[] { g }, existing: null, defaultGroup: g,
- taskTags: new[] { realT, realP });
+ var vm = new TagEditorViewModel("t1", existing: null, taskTags: new[] { realT, realP });
vm.IsVirtual = true;
vm.Expression = "T";
@@ -165,15 +127,14 @@ public void Virtual_ExpressionChange_PreservesSelectedKeepsNewNull()
[Fact]
public void Edit_ExistingVirtualTag_PreselectsInputsFromFormula()
{
- var g = Grp("g1", "温度组");
var realT = RealTag("rt1", "Random");
- var existing = new Tag { Id = "vt1", Item = "Sum", IsVirtual = true, GroupId = "g1", TaskId = "t1" };
+ var existing = new Tag { Id = "vt1", Item = "Sum", IsVirtual = true, TaskId = "t1" };
var formula = new Formula
{
Id = "f1", Name = "Sum", Expression = "T * 2", OutputTagId = "vt1", TaskId = "t1",
Inputs = new List { new() { Id = "fi1", FormulaId = "f1", Alias = "T", SourceTagId = "rt1" } }
};
- var vm = new TagEditorViewModel(new[] { g }, existing, defaultGroup: g,
+ var vm = new TagEditorViewModel("t1", existing,
taskTags: new[] { realT, existing },
existingFormulas: new[] { formula });
@@ -189,10 +150,8 @@ public void Edit_ExistingVirtualTag_PreselectsInputsFromFormula()
[Fact]
public void ToggleIsVirtual_AfterExpression_RebuildsInputBindings()
{
- var g = Grp("g1", "温度组");
var realT = RealTag("rt1", "Random");
- var vm = new TagEditorViewModel(new[] { g }, existing: null, defaultGroup: g,
- taskTags: new[] { realT });
+ var vm = new TagEditorViewModel("t1", existing: null, taskTags: new[] { realT });
// Set expression BEFORE toggling virtual on
vm.Expression = "T * 2";
Assert.Empty(vm.InputBindings); // not virtual yet → no rows
@@ -206,9 +165,7 @@ public void ToggleIsVirtual_AfterExpression_RebuildsInputBindings()
[Fact]
public void Validate_RealTag_BadScaleNumber_HasError()
{
- var g = Grp("g1", "温度组");
- var vm = new TagEditorViewModel(new[] { g }, existing: null, defaultGroup: g,
- formulaValidator: Validator());
+ var vm = new TagEditorViewModel("t1", existing: null, formulaValidator: Validator());
vm.Item = "x";
vm.ScaleFactor = "abc";
Assert.Contains(vm.Validate(), e => e.Contains("缩放"));
@@ -217,8 +174,7 @@ public void Validate_RealTag_BadScaleNumber_HasError()
[Fact]
public void Validate_RealTag_EmptyScale_NullScale()
{
- var g = Grp("g1", "温度组");
- var vm = new TagEditorViewModel(new[] { g }, existing: null, defaultGroup: g);
+ var vm = new TagEditorViewModel("t1", existing: null);
vm.Item = "x";
Assert.Null(vm.ToResult().Tag.ScaleFactor);
}
@@ -226,8 +182,7 @@ public void Validate_RealTag_EmptyScale_NullScale()
[Fact]
public void Validate_Virtual_MissingName_HasError()
{
- var g = Grp("g1", "温度组");
- var vm = new TagEditorViewModel(new[] { g }, existing: null, defaultGroup: g,
+ var vm = new TagEditorViewModel("t1", existing: null,
taskTags: new[] { RealTag("rt1", "Random") },
formulaValidator: Validator());
vm.IsVirtual = true;
@@ -238,9 +193,8 @@ public void Validate_Virtual_MissingName_HasError()
[Fact]
public void Validate_Virtual_DuplicateName_HasError()
{
- var g = Grp("g1", "温度组");
var existingVirtual = new Tag { Id = "rv1", Item = "Sum", IsVirtual = true, TaskId = "t1" };
- var vm = new TagEditorViewModel(new[] { g }, existing: null, defaultGroup: g,
+ var vm = new TagEditorViewModel("t1", existing: null,
taskTags: new[] { existingVirtual, RealTag("rt1", "Random") },
formulaValidator: Validator());
vm.IsVirtual = true;
@@ -253,8 +207,7 @@ public void Validate_Virtual_DuplicateName_HasError()
[Fact]
public void Validate_Virtual_UnselectedInput_HasError()
{
- var g = Grp("g1", "温度组");
- var vm = new TagEditorViewModel(new[] { g }, existing: null, defaultGroup: g,
+ var vm = new TagEditorViewModel("t1", existing: null,
taskTags: new[] { RealTag("rt1", "Random") },
formulaValidator: Validator());
vm.IsVirtual = true;
@@ -266,9 +219,8 @@ public void Validate_Virtual_UnselectedInput_HasError()
[Fact]
public void Validate_Virtual_StringInputTag_HasError()
{
- var g = Grp("g1", "温度组");
var strTag = RealTag("rs1", "Name", dataType: 8); // String
- var vm = new TagEditorViewModel(new[] { g }, existing: null, defaultGroup: g,
+ var vm = new TagEditorViewModel("t1", existing: null,
taskTags: new[] { strTag },
formulaValidator: Validator());
vm.IsVirtual = true;
@@ -281,8 +233,7 @@ public void Validate_Virtual_StringInputTag_HasError()
[Fact]
public void Validate_Virtual_Valid_NoErrors()
{
- var g = Grp("g1", "温度组");
- var vm = new TagEditorViewModel(new[] { g }, existing: null, defaultGroup: g,
+ var vm = new TagEditorViewModel("t1", existing: null,
taskTags: new[] { RealTag("rt1", "Random") },
formulaValidator: Validator());
vm.IsVirtual = true;
diff --git a/tests/Dc.App.Tests/ViewModels/Workspace/TaskWorkspaceViewModelTests.cs b/tests/Dc.App.Tests/ViewModels/Workspace/TaskWorkspaceViewModelTests.cs
index 4df1b7c..062f273 100644
--- a/tests/Dc.App.Tests/ViewModels/Workspace/TaskWorkspaceViewModelTests.cs
+++ b/tests/Dc.App.Tests/ViewModels/Workspace/TaskWorkspaceViewModelTests.cs
@@ -36,7 +36,7 @@ public Task SaveNewTaskAsync(CollectorTask task)
public List Deleted { get; } = new();
public Task UpdateTaskAsync(CollectorTask task) { Updated.Add(task); return Task.CompletedTask; }
public Task DeleteTaskCascadeAsync(string taskId) { Deleted.Add(taskId); return Task.CompletedTask; }
- public Task<(int Groups, int Tags)> GetCountsAsync(string taskId) => Task.FromResult((2, 5));
+ public Task GetCountsAsync(string taskId) => Task.FromResult(5);
}
private sealed class FakeOrchView : IDashboardOrchestratorView
@@ -51,26 +51,10 @@ private sealed class FakeTagPanel : IEmbeddableTagPanel
{
public bool IsEmbedded { get; set; }
public string? TaskScope { get; set; }
- public Dc.Domain.Entities.Group? GroupFilter { get; set; }
public int LoadCount;
public Task LoadAsync() { LoadCount++; return Task.CompletedTask; }
public int ImportCount;
public Task ImportAsync() { ImportCount++; return Task.CompletedTask; }
- public event Action? NavigateToGroupsRequested;
- }
-
- private sealed class FakeGroupPanel
- : CommunityToolkit.Mvvm.ComponentModel.ObservableObject, IEmbeddableGroupPanel
- {
- public bool IsEmbedded { get; set; }
- private CollectorTask? _taskFilter;
- public CollectorTask? TaskFilter { get => _taskFilter; set => SetProperty(ref _taskFilter, value); }
- private Group? _selectedGroup;
- public Group? SelectedGroup { get => _selectedGroup; private set => SetProperty(ref _selectedGroup, value); }
- public int LoadCount;
- public Task LoadAsync() { LoadCount++; return Task.CompletedTask; }
- public void SimulateSelect(Group g) => SelectedGroup = g;
- public event Action? NavigateToTasksRequested;
}
private sealed class FakeLivePanel : IEmbeddableLivePanel
@@ -124,7 +108,6 @@ private sealed class Deps
public FakeTaskSource Src = new();
public FakeOrchView Orch = new();
public FakeTagPanel Tag = new();
- public FakeGroupPanel Group = new();
public FakeLivePanel Live = new();
public FakeDiagPanel Diag = new();
public WorkspaceOverviewViewModel Overview = null!;
@@ -141,7 +124,6 @@ private static (Deps deps, TaskWorkspaceViewModel vm) BuildFull()
d.Overview, d.Tag,
orchestrator: null,
editor: null,
- groupsPanel: d.Group,
livePanel: d.Live,
diagPanel: d.Diag,
config: d.Config);
@@ -179,7 +161,6 @@ private static TaskWorkspaceViewModel BuildVm(
overview, new FakeTagPanel(),
orchestrator: orchestrator,
editor: editor,
- groupsPanel: new FakeGroupPanel(),
livePanel: new FakeLivePanel(),
diagPanel: new FakeDiagPanel(),
config: config,
@@ -299,7 +280,6 @@ public async Task SelectingTask_ConfiguresAllPanelScopes()
vm.SelectedTask = vm.AllTasks[0];
Assert.Equal("t1", d.Live.TaskFilter);
Assert.Equal("t1", d.Diag.TaskScope);
- Assert.Equal("t1", d.Group.TaskFilter?.Id);
Assert.True(d.Config.HasTask);
}
@@ -359,7 +339,6 @@ public async Task NewTaskCommand_Exists_And_Saves_Via_Editor()
overview, new FakeTagPanel(),
orchestrator: null,
editor: new SavingEditor(),
- groupsPanel: new FakeGroupPanel(),
livePanel: new FakeLivePanel(),
diagPanel: new FakeDiagPanel(),
config: config);
diff --git a/tests/Dc.Infrastructure.Tests/Backup/JsonConfigBackupTests.cs b/tests/Dc.Infrastructure.Tests/Backup/JsonConfigBackupTests.cs
index 1a7a5e6..da2cf47 100644
--- a/tests/Dc.Infrastructure.Tests/Backup/JsonConfigBackupTests.cs
+++ b/tests/Dc.Infrastructure.Tests/Backup/JsonConfigBackupTests.cs
@@ -29,12 +29,10 @@ public void Dispose()
private async Task SeedAsync()
{
var task = new CollectorTask { Id = "task-1", Server = "S1", Node = "opc.tcp://localhost", Type = 2, Interval = 1000, TcpAddress = "127.0.0.1:5000" };
- var group = new Group { Id = "group-1", Name = "G1", TaskId = task.Id };
_db.Tasks.Add(task);
- _db.Groups.Add(group);
_db.Tags.AddRange(
- new Tag { Id = "tag-1", Item = "A", DataType = 4, GroupId = "group-1", TaskId = "task-1" },
- new Tag { Id = "tag-2", Item = "B", DataType = 5, GroupId = "group-1", TaskId = "task-1" });
+ new Tag { Id = "tag-1", Item = "A", DataType = 4, TaskId = "task-1" },
+ new Tag { Id = "tag-2", Item = "B", DataType = 5, TaskId = "task-1" });
_db.Configs.Add(new ConfigEntry { Id = "cfg-1", Key = "clientID", Value = "abc" });
await _db.SaveChangesAsync();
}
@@ -46,7 +44,6 @@ public async Task ExportAndImportMerge_Roundtrip()
var bundle = await _svc.ExportAsync(_db);
Assert.Single(bundle.Tasks);
- Assert.Single(bundle.Groups);
Assert.Equal(2, bundle.Tags.Count);
Assert.Single(bundle.Configs);
@@ -68,7 +65,6 @@ public async Task ImportMerge_SkipsExistingIds()
var result = await _svc.ImportAsync(_db, bundle, BackupImportMode.Merge);
Assert.Equal(0, result.TasksImported);
- Assert.Equal(0, result.GroupsImported);
Assert.Equal(0, result.TagsImported);
Assert.Equal(0, result.ConfigsImported);
Assert.Empty(result.Errors);
@@ -83,20 +79,17 @@ public async Task ImportReplace_ClearsAndReinserts()
// Mutate bundle to have only different content
bundle.Tasks.Clear();
bundle.Tasks.Add(new CollectorTask { Id = "new-task", Server = "S2", Node = "n", Type = 2, Interval = 500, TcpAddress = "x:1" });
- bundle.Groups.Clear();
bundle.Tags.Clear();
bundle.Configs.Clear();
var result = await _svc.ImportAsync(_db, bundle, BackupImportMode.Replace);
Assert.Equal(1, result.TasksImported);
- Assert.Equal(0, result.GroupsImported);
Assert.Equal(0, result.TagsImported);
_db.ChangeTracker.Clear();
Assert.Single(_db.Tasks.AsQueryable());
Assert.Empty(_db.Tags.AsQueryable());
- Assert.Empty(_db.Groups.AsQueryable());
}
[Fact]
diff --git a/tests/Dc.Infrastructure.Tests/DcDbContextTests.cs b/tests/Dc.Infrastructure.Tests/DcDbContextTests.cs
index 5efb203..b9a652e 100644
--- a/tests/Dc.Infrastructure.Tests/DcDbContextTests.cs
+++ b/tests/Dc.Infrastructure.Tests/DcDbContextTests.cs
@@ -37,9 +37,9 @@ public void EnsureCreated_GeneratesExpectedTables()
while (rd.Read()) tables.Add(rd.GetString(0));
Assert.Contains("dc_tags", tables);
- Assert.Contains("dc_groups", tables);
Assert.Contains("dc_tasks", tables);
Assert.Contains("dc_configs", tables);
+ Assert.DoesNotContain("dc_groups", tables); // 分组层已去除
}
[Fact]
@@ -57,9 +57,9 @@ public void Columns_UseSnakeCase()
Assert.Contains("item", cols);
Assert.Contains("data_type", cols);
Assert.Contains("task_id", cols);
- Assert.Contains("group_id", cols);
Assert.Contains("created_at", cols);
Assert.Contains("updated_at", cols);
+ Assert.DoesNotContain("group_id", cols); // 分组层已去除
}
[Fact]
@@ -69,13 +69,13 @@ public async Task Tag_RoundTrip_PreservesAllFields()
{
Id = UlidGenerator.NewId(),
Item = "Random.Int1", DataType = 2,
- TaskId = "task-1", GroupId = "group-1"
+ TaskId = "task-1"
};
_db.Tags.Add(tag);
await _db.SaveChangesAsync();
var fetched = await _db.Tags.FirstOrDefaultAsync(
- t => t.TaskId == "task-1" && t.GroupId == "group-1" && t.Item == "Random.Int1");
+ t => t.TaskId == "task-1" && t.Item == "Random.Int1");
Assert.NotNull(fetched);
Assert.Equal(2, fetched!.DataType);
@@ -86,16 +86,17 @@ public async Task Tag_RoundTrip_PreservesAllFields()
[Fact]
public async Task Tag_CompositeUniqueIndex_RejectsDuplicates()
{
- _db.Tags.Add(new Tag { Id = UlidGenerator.NewId(), Item = "A.B", DataType = 1, TaskId = "t", GroupId = "g" });
+ // 唯一索引现为 (item, task_id):同任务同 Item 拒绝。
+ _db.Tags.Add(new Tag { Id = UlidGenerator.NewId(), Item = "A.B", DataType = 1, TaskId = "t" });
await _db.SaveChangesAsync();
- _db.Tags.Add(new Tag { Id = UlidGenerator.NewId(), Item = "A.B", DataType = 1, TaskId = "t", GroupId = "g" });
+ _db.Tags.Add(new Tag { Id = UlidGenerator.NewId(), Item = "A.B", DataType = 1, TaskId = "t" });
await Assert.ThrowsAsync(() => _db.SaveChangesAsync());
}
[Fact]
- public async Task Task_WithGroupsAndTags_LoadsViaInclude()
+ public async Task Task_WithTags_LoadsViaInclude()
{
var task = new CollectorTask
{
@@ -106,25 +107,76 @@ public async Task Task_WithGroupsAndTags_LoadsViaInclude()
Interval = 1000,
TcpAddress = "127.0.0.1:5000"
};
- var group = new Group { Id = UlidGenerator.NewId(), Name = "g1", TaskId = task.Id };
var tags = new[]
{
- new Tag { Id = UlidGenerator.NewId(), Item = "A", DataType = 1, TaskId = task.Id, GroupId = group.Id },
- new Tag { Id = UlidGenerator.NewId(), Item = "B", DataType = 1, TaskId = task.Id, GroupId = group.Id }
+ new Tag { Id = UlidGenerator.NewId(), Item = "A", DataType = 1, TaskId = task.Id },
+ new Tag { Id = UlidGenerator.NewId(), Item = "B", DataType = 1, TaskId = task.Id }
};
_db.Tasks.Add(task);
- _db.Groups.Add(group);
_db.Tags.AddRange(tags);
await _db.SaveChangesAsync();
_db.ChangeTracker.Clear();
- var loaded = await _db.Tasks
- .Include(t => t.Groups).ThenInclude(g => g.Tags)
- .ToListAsync();
+ var loaded = await _db.Tasks.Include(t => t.Tags).ToListAsync();
var loadedTask = Assert.Single(loaded);
- var loadedGroup = Assert.Single(loadedTask.Groups);
- Assert.Equal(2, loadedGroup.Tags.Count);
+ Assert.Equal(2, loadedTask.Tags.Count);
+ }
+
+ // 旧库一次性迁移:dc_tags.group_id + dc_groups 去除,Tag 直接挂任务。
+ [Fact]
+ public void MigrateDropGroupLayer_OldDb_DropsGroupsAndColumn_BackfillsTaskId()
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"dc-mig-{Guid.NewGuid():N}.db");
+ try
+ {
+ // 1. 造旧 schema:dc_tags 带 group_id、udx_name 含 group_id、dc_groups 表存在。
+ using (var conn = new SqliteConnection($"Data Source={path}"))
+ {
+ conn.Open();
+ void Exec(string sql) { using var c = conn.CreateCommand(); c.CommandText = sql; c.ExecuteNonQuery(); }
+ Exec("PRAGMA foreign_keys=OFF"); // 旧库 EF 连接 FK=false:允许造孤儿/空 task_id 行模拟历史数据
+ Exec("CREATE TABLE dc_tasks (id TEXT PRIMARY KEY, created_at TEXT, updated_at TEXT, server TEXT, node TEXT, type INTEGER, interval INTEGER, deviation INTEGER, tcp_address TEXT)");
+ Exec("CREATE TABLE dc_groups (id TEXT PRIMARY KEY, created_at TEXT, updated_at TEXT, name TEXT, task_id TEXT)");
+ // group_id 带外键(镜像 EF 旧 schema):SQLite 不能 DROP 被外键引用的列 → 必须整表重建,本测试守护该路径。
+ Exec("CREATE TABLE dc_tags (id TEXT PRIMARY KEY, created_at TEXT, updated_at TEXT, item TEXT, data_type INTEGER, task_id TEXT, group_id TEXT NOT NULL, " +
+ "CONSTRAINT FK_dc_tags_dc_groups_group_id FOREIGN KEY (group_id) REFERENCES dc_groups (id), " +
+ "CONSTRAINT FK_dc_tags_dc_tasks_task_id FOREIGN KEY (task_id) REFERENCES dc_tasks (id))");
+ Exec("CREATE UNIQUE INDEX udx_name ON dc_tags (item, task_id, group_id)");
+ Exec("INSERT INTO dc_groups VALUES ('g1','2020-01-01 00:00:00','2020-01-01 00:00:00','G','task-1')");
+ Exec("INSERT INTO dc_tags VALUES ('tag1','2020-01-01 00:00:00','2020-01-01 00:00:00','A',1,'task-1','g1')");
+ // 个别旧行 task_id 空 → 迁移应从分组回填
+ Exec("INSERT INTO dc_tags VALUES ('tag2','2020-01-01 00:00:00','2020-01-01 00:00:00','B',1,'','g1')");
+ }
+
+ // 2. 跑迁移
+ using (var db = new DcDbContext(DcDbContextFactory.CreateOptions(path)))
+ DbSchemaInitializer.EnsureCreated(db);
+
+ // 3. dc_groups 表 + group_id 列均已删
+ using (var conn = new SqliteConnection($"Data Source={path}"))
+ {
+ conn.Open();
+ long Scalar(string sql) { using var c = conn.CreateCommand(); c.CommandText = sql; return Convert.ToInt64(c.ExecuteScalar()); }
+ Assert.Equal(0, Scalar("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='dc_groups'"));
+ Assert.Equal(0, Scalar("SELECT COUNT(*) FROM pragma_table_info('dc_tags') WHERE name='group_id'"));
+ }
+
+ // 4. task_id 保留 + 空行已回填 + 能插新 Tag(无 group_id 不报 NOT NULL)
+ using (var db = new DcDbContext(DcDbContextFactory.CreateOptions(path)))
+ {
+ var tags = db.Tags.OrderBy(t => t.Item).ToList();
+ Assert.Equal(2, tags.Count);
+ Assert.All(tags, t => Assert.Equal("task-1", t.TaskId));
+ db.Tags.Add(new Tag { Id = "tag3", Item = "C", DataType = 1, TaskId = "task-1" });
+ db.SaveChanges();
+ }
+ }
+ finally
+ {
+ SqliteConnection.ClearAllPools();
+ if (File.Exists(path)) File.Delete(path);
+ }
}
[Fact]
@@ -152,10 +204,10 @@ public async Task Config_KeyUniqueIndex_RejectsDuplicates()
[Fact]
public async Task Formula_And_Inputs_Roundtrip()
{
- var tag = new Tag { Id = "tag1", Item = "ns=2;s=T", DataType = 6, TaskId = "t1", GroupId = "g1", IsVirtual = false, ScaleFactor = 0.1, Offset = -5 };
+ var tag = new Tag { Id = "tag1", Item = "ns=2;s=T", DataType = 6, TaskId = "t1", IsVirtual = false, ScaleFactor = 0.1, Offset = -5 };
_db.Tags.Add(tag);
- var virt = new Tag { Id = "vtag1", Item = "补偿流量", DataType = 6, TaskId = "t1", GroupId = "g1", IsVirtual = true };
+ var virt = new Tag { Id = "vtag1", Item = "补偿流量", DataType = 6, TaskId = "t1", IsVirtual = true };
_db.Tags.Add(virt);
var f = new Formula { Id = "f1", Name = "补偿流量", Expression = "T * 1.8 + 32", OutputTagId = "vtag1", OutputUnit = "F", TaskId = "t1" };
diff --git a/tests/Dc.Infrastructure.Tests/Excel/TagExcelTests.cs b/tests/Dc.Infrastructure.Tests/Excel/TagExcelTests.cs
index 0215dea..54352fb 100644
--- a/tests/Dc.Infrastructure.Tests/Excel/TagExcelTests.cs
+++ b/tests/Dc.Infrastructure.Tests/Excel/TagExcelTests.cs
@@ -12,18 +12,13 @@ public void WriteAndRead_Roundtrip()
var service = new ClosedXmlTagExcelService();
var tags = new[]
{
- new Tag { Id = "id1", Item = "Random.Int1", DataType = 2, GroupId = "g1", TaskId = "task-1" },
- new Tag { Id = "id2", Item = "Random.Real8", DataType = 5, GroupId = "g1", TaskId = "task-1" },
- new Tag { Id = "id3", Item = "中文.Item", DataType = 8, GroupId = "g2", TaskId = "task-2" }
- };
- var groupMap = new Dictionary
- {
- ["g1"] = "DemoGroup",
- ["g2"] = "中文分组"
+ new Tag { Id = "id1", Item = "Random.Int1", DataType = 2, TaskId = "task-1" },
+ new Tag { Id = "id2", Item = "Random.Real8", DataType = 5, TaskId = "task-1" },
+ new Tag { Id = "id3", Item = "中文.Item", DataType = 8, TaskId = "task-2" }
};
using var ms = new MemoryStream();
- service.Write(tags, groupMap, ms);
+ service.Write(tags, ms);
ms.Position = 0;
var rows = service.Read(ms);
@@ -31,9 +26,8 @@ public void WriteAndRead_Roundtrip()
Assert.Equal(3, rows.Count);
Assert.Equal("Random.Int1", rows[0].Item);
Assert.Equal(2, rows[0].DataType);
- Assert.Equal("DemoGroup", rows[0].GroupName);
Assert.Equal("中文.Item", rows[2].Item);
- Assert.Equal("中文分组", rows[2].GroupName);
+ Assert.Equal(8, rows[2].DataType);
}
[Fact]
@@ -42,10 +36,10 @@ public void Read_SkipsEmptyItemRows()
var service = new ClosedXmlTagExcelService();
var tags = new[]
{
- new Tag { Id = "id1", Item = "A", DataType = 1, GroupId = "g1", TaskId = "t" }
+ new Tag { Id = "id1", Item = "A", DataType = 1, TaskId = "t" }
};
using var ms = new MemoryStream();
- service.Write(tags, new Dictionary { ["g1"] = "G" }, ms);
+ service.Write(tags, ms);
ms.Position = 0;
// 二次写入数据:插入一个空 item 行
@@ -53,10 +47,8 @@ public void Read_SkipsEmptyItemRows()
var sheet = workbook.Worksheet("Tags");
sheet.Cell(3, 1).Value = "";
sheet.Cell(3, 2).Value = 9;
- sheet.Cell(3, 3).Value = "Whatever";
sheet.Cell(4, 1).Value = "B";
sheet.Cell(4, 2).Value = 2;
- sheet.Cell(4, 3).Value = "G";
using var ms2 = new MemoryStream();
workbook.SaveAs(ms2);
ms2.Position = 0;
@@ -75,12 +67,10 @@ public void Read_HandlesHeaderOrderInvariance()
{
var s = wb.AddWorksheet("Tags");
// 故意打乱列顺序
- s.Cell(1, 1).Value = "GroupName";
+ s.Cell(1, 1).Value = "DataType";
s.Cell(1, 2).Value = "Item";
- s.Cell(1, 3).Value = "DataType";
- s.Cell(2, 1).Value = "GroupA";
+ s.Cell(2, 1).Value = 4;
s.Cell(2, 2).Value = "MyItem";
- s.Cell(2, 3).Value = 4;
wb.SaveAs(ms);
}
ms.Position = 0;
@@ -91,6 +81,5 @@ public void Read_HandlesHeaderOrderInvariance()
var row = Assert.Single(rows);
Assert.Equal("MyItem", row.Item);
Assert.Equal(4, row.DataType);
- Assert.Equal("GroupA", row.GroupName);
}
}
diff --git a/tests/Dc.Integration.Tests/Persistence/TaskStoreTests.cs b/tests/Dc.Integration.Tests/Persistence/TaskStoreTests.cs
index 5b6e46a..dbc56e9 100644
--- a/tests/Dc.Integration.Tests/Persistence/TaskStoreTests.cs
+++ b/tests/Dc.Integration.Tests/Persistence/TaskStoreTests.cs
@@ -23,14 +23,11 @@ private static string Seed(out DbContextOptions opts)
using var db = new DcDbContext(opts);
DbSchemaInitializer.EnsureCreated(db);
db.Tasks.Add(new CollectorTask { Id = "task1", Server = "炉温", Node = "opc.tcp://x", Type = 2, Interval = 1000 });
- db.Groups.Add(new Group { Id = "g1", Name = "组A", TaskId = "task1" });
- db.Groups.Add(new Group { Id = "g2", Name = "组B", TaskId = "task1" });
- db.Tags.Add(new Tag { Id = "tag1", Item = "i1", TaskId = "task1", GroupId = "g1" });
- db.Tags.Add(new Tag { Id = "tag2", Item = "i2", TaskId = "task1", GroupId = "g2" });
- db.Tags.Add(new Tag { Id = "tag3", Item = "i3", TaskId = "task1", GroupId = "" });
+ db.Tags.Add(new Tag { Id = "tag1", Item = "i1", TaskId = "task1" });
+ db.Tags.Add(new Tag { Id = "tag2", Item = "i2", TaskId = "task1" });
+ db.Tags.Add(new Tag { Id = "tag3", Item = "i3", TaskId = "task1" });
db.Tasks.Add(new CollectorTask { Id = "other", Server = "s", Node = "n", Type = 2 });
- db.Groups.Add(new Group { Id = "go", Name = "其它", TaskId = "other" });
- db.Tags.Add(new Tag { Id = "tago", Item = "io", TaskId = "other", GroupId = "go" });
+ db.Tags.Add(new Tag { Id = "tago", Item = "io", TaskId = "other" });
db.SaveChanges();
return path;
}
@@ -65,7 +62,7 @@ public async Task UpdateAsync_PersistsFields_PreservesCreatedAt_AdvancesUpdatedA
}
[Fact]
- public async Task DeleteCascadeAsync_RemovesTaskGroupsTags_NoOrphans_LeavesOtherTask()
+ public async Task DeleteCascadeAsync_RemovesTaskTags_NoOrphans_LeavesOtherTask()
{
var path = Seed(out var opts);
try
@@ -76,10 +73,8 @@ public async Task DeleteCascadeAsync_RemovesTaskGroupsTags_NoOrphans_LeavesOther
await using (var db = new DcDbContext(opts))
{
Assert.Null(await db.Tasks.FindAsync("task1"));
- Assert.Equal(0, await db.Groups.CountAsync(g => g.TaskId == "task1"));
Assert.Equal(0, await db.Tags.CountAsync(t => t.TaskId == "task1"));
Assert.NotNull(await db.Tasks.FindAsync("other"));
- Assert.Equal(1, await db.Groups.CountAsync(g => g.TaskId == "other"));
Assert.Equal(1, await db.Tags.CountAsync(t => t.TaskId == "other"));
}
}