Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@

## [Unreleased]

### Added
- **BaseCRUDVM — 批量刪除預覽**:新增 `GetDeletePreviewString()` 虛擬方法,回傳實體的人類可讀標籤(依序搜尋 Name/Title/Code/ITCode 等屬性,否則回退至主鍵);同時新增 `IBaseCRUDVM<T>.GetDeletePreviewString()` 介面成員(#619)。
- **`_FrameworkController` — 批量刪除預覽端點**:POST `/_Framework/GetDeletePreview` — 接受 vmType 名稱與最多 10 個 ID,回傳 `[{id, label}]` 陣列供前端確認對話框使用(#619)。
- **`_FrameworkController` — 批量指派角色端點**:POST `/_Framework/BatchAssignRoles` — 將一個角色指派給多位使用者(upsert `FrameworkUserRole`),完成後清除受影響的快取(#619)。
- **ComboBox 遠端搜尋**:`wt:combobox` 新增 `remote-url` 屬性;設定後啟用 xmSelect `remoteSearch + remoteMethod`,每次輸入觸發 `GET {remote-url}?q=<keyword>` 並即時更新選項(#565)。
- **TreeSelect 懶加載**:`wt:tree` 新增 `lazy-url` 屬性;設定後啟用 xmSelect `lazy + load`,展開節點時觸發 `GET {lazy-url}?id=<nodeValue>` 並動態載入子節點(#565)。
- **ETL 管理 UI 改進**:`EtlJobListVM` 覆寫 `InitGridAction()` 增加 Create/Edit/Delete 標準動作,以及每行操作按鈕:立即執行(確認 POST)、暫停、恢復、中止、執行記錄(開啟篩選後的 RunLog 對話框);新增 ConsecutiveFailureCount 欄位(#540)。
- **ETL Create/Edit 表單補全**:Demo 的 Create.cshtml 與 Edit.cshtml 補入 QueryTemplate(textarea)、AlertEmail、AlertWebhookUrl、AlertAfterConsecutiveFailures 欄位(#540)。
- **BaseCRUDVM — 樂觀並行衝突處理**:`DoEdit` / `DoEditAsync` 捕獲 `DbUpdateConcurrencyException`,設定 `IsConcurrencyConflict = true` 並新增模型錯誤,而非直接拋出例外;新增介面屬性 `IBaseCRUDVM<T>.IsConcurrencyConflict`(#620)。
- **BaseImportVM — 匯入進度回報**:`BatchSaveData(IProgress<ImportProgress>? progress = null)` 在驗證與儲存階段回報 `ImportProgress { Processed, Total, Phase }`(#607)。
- **BaseImportVM — 行內錯誤清單**:新增 `InlineErrors` 屬性(最多 `InlineErrorLimit` 筆,預設 50)供 API 端點直接回傳驗證錯誤(#615)。
- **BaseTemplateVM — 欄位說明列**:`ShowDescriptionRow = true`(預設)時,於模板第二行插入淺綠色斜體說明列,標示 Required/Optional、資料類型、min/max 限制;匯入時自動識別並跳過說明列(v2 標記)(#615)。

### Deprecated
- **Workflow API**:內建 Elsa workflow 整合(`IWorkflow`、`FrameworkWorkflow`、`ApproveTimeLine`、`ApproveInfo`、`FlowInfoTagHelper`、`IBaseCRUDVM` 工作流程方法、`DataContext.FrameworkWorkflows`)標記為 `[Obsolete]`,將於下一個主版本移除(#586)。
- **遷移指引**:若仍需工作流程功能,請直接引用 Elsa 或改用其他工作流程引擎;移除 `IWorkflow` 介面實作及相關 TagHelper。
Expand Down
6 changes: 6 additions & 0 deletions demo/WalkingTec.Mvvm.Demo/Views/_EtlJob/Create.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@
<wt:textbox field="Entity.WatermarkTimeZone" />
<wt:textbox field="Entity.RetryCount" />
<wt:textbox field="Entity.TimeoutMinutes" />
<wt:textbox field="Entity.AlertEmail" />
<wt:textbox field="Entity.AlertWebhookUrl" />
<wt:textbox field="Entity.AlertAfterConsecutiveFailures" />
</wt:row>
<wt:row items-per-row="ItemsPerRowEnum.One">
<wt:textarea field="Entity.QueryTemplate" height="200" />
</wt:row>
<wt:row align="AlignEnum.Right">
<wt:submitbutton />
Expand Down
6 changes: 6 additions & 0 deletions demo/WalkingTec.Mvvm.Demo/Views/_EtlJob/Edit.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@
<wt:textbox field="Entity.WatermarkTimeZone" />
<wt:textbox field="Entity.RetryCount" />
<wt:textbox field="Entity.TimeoutMinutes" />
<wt:textbox field="Entity.AlertEmail" />
<wt:textbox field="Entity.AlertWebhookUrl" />
<wt:textbox field="Entity.AlertAfterConsecutiveFailures" />
</wt:row>
<wt:row items-per-row="ItemsPerRowEnum.One">
<wt:textarea field="Entity.QueryTemplate" height="200" />
</wt:row>
<wt:hidden field="Entity.ID" />
<wt:row align="AlignEnum.Right">
Expand Down
68 changes: 68 additions & 0 deletions src/WalkingTec.Mvvm.Core/Analysis/AnalysisQueryEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -459,12 +459,80 @@ private static void ValidateFields(AnalysisQueryRequest req, Dictionary<string,
}
}

/// <summary>
/// 將篩選條件中的相對日期 token(@today、@thisWeek 等)展開為 Gte/Lte 對。
/// 不含 token 的條件直接原樣返回,不建立新物件。
/// </summary>
internal static List<FilterCondition> ResolveRelativeDates(List<FilterCondition> filters)
{
bool hasTokens = false;
foreach (var f in filters)
if (f.Value.StartsWith("@", StringComparison.Ordinal)) { hasTokens = true; break; }
if (!hasTokens) return filters;

var result = new List<FilterCondition>(filters.Count + 4);
var today = DateTime.Today;
var mondayOffset = ((int)today.DayOfWeek + 6) % 7;

foreach (var f in filters)
{
if (!f.Value.StartsWith("@", StringComparison.Ordinal))
{
result.Add(f);
continue;
}

DateTime start, end;
switch (f.Value.ToLowerInvariant())
{
case "@today":
start = today; end = today;
break;
case "@thisweek":
start = today.AddDays(-mondayOffset);
end = start.AddDays(6);
break;
case "@lastweek":
start = today.AddDays(-mondayOffset - 7);
end = start.AddDays(6);
break;
case "@thismonth":
start = new DateTime(today.Year, today.Month, 1);
end = start.AddMonths(1).AddDays(-1);
break;
case "@lastmonth":
start = new DateTime(today.Year, today.Month, 1).AddMonths(-1);
end = new DateTime(today.Year, today.Month, 1).AddDays(-1);
break;
case "@last30days":
start = today.AddDays(-30);
end = today;
break;
case "@thisquarter":
start = new DateTime(today.Year, ((today.Month - 1) / 3) * 3 + 1, 1);
end = today;
break;
case "@ytd":
start = new DateTime(today.Year, 1, 1);
end = today;
break;
default:
throw new InvalidOperationException($"Unknown relative date token '{f.Value}'.");
}

result.Add(new FilterCondition { Field = f.Field, Operator = FilterOperator.Gte, Value = start.ToString("yyyy-MM-dd") });
result.Add(new FilterCondition { Field = f.Field, Operator = FilterOperator.Lte, Value = end.ToString("yyyy-MM-dd 23:59:59") });
}
return result;
}

private static IQueryable<TModel> ApplyFilters<TModel>(
IQueryable<TModel> query,
List<FilterCondition>? filters,
Dictionary<string, AnalysisFieldMeta> whitelist)
{
if (filters == null || filters.Count == 0) return query;
filters = ResolveRelativeDates(filters);

var param = Expression.Parameter(typeof(TModel), "x");
Expression? body = null;
Expand Down
55 changes: 54 additions & 1 deletion src/WalkingTec.Mvvm.Core/BaseCRUDVM.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,14 @@ namespace WalkingTec.Mvvm.Core
/// </summary>
bool ByPassBaseValidation { get; set; }

/// <summary>
/// True if the last DoEdit / DoEditAsync call failed due to an optimistic concurrency conflict.
/// </summary>
bool IsConcurrencyConflict { get; }

/// <summary>Returns a one-line human-readable label for the entity; used by bulk-delete preview (#619).</summary>
string GetDeletePreviewString();

void Validate();
IModelStateService? MSD { get; }
}
Expand All @@ -114,6 +122,12 @@ where mi.GetGenericArguments().Count() == 3
[JsonIgnore]
public bool ByPassBaseValidation { get; set; }

/// <summary>
/// Set to true by DoEdit / DoEditAsync when EF throws DbUpdateConcurrencyException.
/// </summary>
[JsonIgnore]
public bool IsConcurrencyConflict { get; private set; }

//保存读取时Include的内容
private List<Expression<Func<TModel, object>>>? _toInclude { get; set; }

Expand All @@ -139,6 +153,28 @@ public IQueryable<TModel> GetBaseQuery()
{
return DC!.Set<TModel>();
}

/// <summary>
/// Returns a one-line human-readable summary of <see cref="Entity"/> used by the
/// bulk-delete preview dialog (#619). Override to provide a richer label.
/// The default implementation looks for Name / Title / Code / ITCode properties
/// in that order; falls back to the primary key value.
/// </summary>
public virtual string GetDeletePreviewString()
{
var searchNames = new[] { "Name", "Title", "Code", "ITCode", "SchoolName", "RoleName" };
foreach (var n in searchNames)
{
var prop = typeof(TModel).GetProperty(n);
if (prop != null)
{
var v = prop.GetValue(Entity)?.ToString();
if (!string.IsNullOrWhiteSpace(v)) return v;
}
}
return Entity.GetID()?.ToString() ?? string.Empty;
}

/// <summary>
/// 设定添加和修改时对于重复数据的判断,子类进行相关操作时应重载这个函数
/// </summary>
Expand Down Expand Up @@ -546,6 +582,11 @@ public virtual void DoEdit(bool updateAllFields = false)
{
DC!.SaveChanges();
}
catch (DbUpdateConcurrencyException)
{
IsConcurrencyConflict = true;
MSD?.AddModelError(" ", Localizer?["Sys.ConcurrencyConflict"] ?? "The record was modified by another user. Please reload and try again.");
}
catch
{
MSD?.AddModelError(" ", Localizer?["Sys.EditFailed"] ?? "Edit failed");
Expand All @@ -569,7 +610,19 @@ public virtual async Task DoEditAsync(bool updateAllFields = false)
DoEditPrepare(updateAllFields);
AppendChangeLog("Edit", SerializeScalarProps(_auditSnapshot), SerializeScalarProps(Entity));

await DC!.SaveChangesAsync();
try
{
await DC!.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
IsConcurrencyConflict = true;
MSD?.AddModelError(" ", Localizer?["Sys.ConcurrencyConflict"] ?? "The record was modified by another user. Please reload and try again.");
}
catch
{
MSD?.AddModelError(" ", Localizer?["Sys.EditFailed"] ?? "Edit failed");
}
//删除不需要的附件
if (DeletedFileIds != null && DeletedFileIds.Count > 0 && Wtm?.ServiceProvider != null)
{
Expand Down
54 changes: 52 additions & 2 deletions src/WalkingTec.Mvvm.Core/BaseImportVM.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,20 @@

namespace WalkingTec.Mvvm.Core
{
/// <summary>
/// Progress snapshot reported by <see cref="BaseImportVM{T,P}.BatchSaveData"/> via
/// <see cref="IProgress{T}"/> when importing large Excel files (#607).
/// </summary>
public readonly struct ImportProgress
{
/// <summary>Number of rows that have been processed so far.</summary>
public int Processed { get; init; }
/// <summary>Total number of rows to process.</summary>
public int Total { get; init; }
/// <summary>Human-readable description of the current phase (e.g. "Validating", "Saving").</summary>
public string Phase { get; init; }
}

/// <summary>
/// 导入接口
/// </summary>
Expand Down Expand Up @@ -60,6 +74,25 @@ public class BaseImportVM<T, P> : BaseVM, IBaseImport<T>
[JsonIgnore]
public TemplateErrorListVM ErrorListVM { get; set; }

/// <summary>
/// Maximum number of errors surfaced via <see cref="InlineErrors"/> (#615).
/// Defaults to 50. Set to 0 to disable inline errors.
/// </summary>
[JsonIgnore]
public int InlineErrorLimit { get; set; } = 50;

/// <summary>
/// Returns up to <see cref="InlineErrorLimit"/> validation errors so the UI can
/// display them inline (without requiring the user to download an error file).
/// Returns an empty list when there are no errors or <see cref="InlineErrorLimit"/>
/// is 0 (#615).
/// </summary>
[JsonIgnore]
public IReadOnlyList<ErrorMessage> InlineErrors =>
InlineErrorLimit <= 0
? Array.Empty<ErrorMessage>()
: ErrorListVM.EntityList.Take(InlineErrorLimit).ToList();

/// <summary>
/// 是否验证模板类型(当其他系统模板导入到某模块时可设置为False)
/// </summary>
Expand Down Expand Up @@ -312,9 +345,17 @@ public virtual void SetTemplateData()
}
}

// If the template was generated with a description row (v2), skip it (#615).
bool hasDescriptionRow = xssfworkbook.GetSheetAt(1)?.GetRow(0)?.GetCell(3)?.ToString() == "v2";

//向TemplateData中赋值
int rowIndex = 2;
rows.MoveNext();
rows.MoveNext(); // skip header row
if (hasDescriptionRow)
{
rows.MoveNext(); // skip description row
rowIndex = 3;
}
while (rows.MoveNext())
{
XSSFRow row = (XSSFRow)rows.Current;
Expand Down Expand Up @@ -910,8 +951,12 @@ private void TryValidateProperty(object? value, ValidationContext context, IColl
/// <summary>
/// 保存指定表中的数据
/// </summary>
/// <param name="progress">
/// Optional progress sink. Reports <see cref="ImportProgress"/> after every processed
/// row during the validation and save phases so callers can display a progress bar.
/// </param>
/// <returns>成功返回True,失败返回False</returns>
public virtual bool BatchSaveData()
public virtual bool BatchSaveData(IProgress<ImportProgress>? progress = null)
{
//删除不必要的附件
if (DeletedFileIds != null && DeletedFileIds.Count > 0 && Wtm!.ServiceProvider != null)
Expand All @@ -926,6 +971,8 @@ public virtual bool BatchSaveData()

//进行赋值
SetEntityList();
int total = EntityList.Count;
int processed = 0;
foreach (var entity in EntityList)
{
var context = new ValidationContext(entity);
Expand All @@ -935,6 +982,7 @@ public virtual bool BatchSaveData()
{
ErrorListVM.EntityList.Add(new ErrorMessage { Message = validationResults.FirstOrDefault()?.ErrorMessage ?? "Error", ExcelIndex = entity.ExcelIndex, Index = entity.ExcelIndex });
}
progress?.Report(new ImportProgress { Processed = ++processed, Total = total, Phase = "Validating" });
}
if (ErrorListVM.EntityList.Count > 0)
{
Expand All @@ -952,6 +1000,7 @@ public virtual bool BatchSaveData()
var ModelType = typeof(P);
//循环数据列表
List<P> ListAdd = new List<P>();
processed = 0;
foreach (var item in EntityList)
{
//根据唯一性的设定查找数据库中是否有同样的数据
Expand Down Expand Up @@ -1041,6 +1090,7 @@ public virtual bool BatchSaveData()
{
DC!.Set<P>().Add(item);
}
progress?.Report(new ImportProgress { Processed = ++processed, Total = total, Phase = "Saving" });
}

if (ErrorListVM.EntityList.Count > 0)
Expand Down
Loading
Loading