Procházet zdrojové kódy

feat(aidop): 指标建模通用化 - 后端通用端点 + 前端通用 API + 通用事件常量

- 后端 AidopKanbanController.Generic.cs 新增:
  * GET  /api/AidopKanban/metric-catalog/{moduleCode}(返回 KpiMaster,附带 formulaExpr/formulaPreview)
  * PUT  /api/AidopKanban/operation-layout/{moduleCode}(L1 最多 8 个,同步更新 KpiMaster.MetricName)
- 前端 kanbanData.ts 新增:
  * MetricCatalogRow 类型 + fetchMetricCatalog(moduleCode, level?)
  * saveOperationLayout(moduleCode, body, factoryId?)
- s4LayoutEvents.ts 新增 AIDOP_LAYOUT_SAVED 通用事件(detail.moduleCode),供九宫格首页和详情页按模块联动刷新;保留旧 AIDOP_S4_LAYOUT_SAVED 兼容。
- 对应版本递增:Web 2.4.89 -> 2.4.90;Admin.NET.Web.Entry 1.0.56 -> 1.0.57。

Made-with: Cursor
skygu před 1 měsícem
rodič
revize
4ba005e5b3

+ 44 - 0
Web/src/views/aidop/api/kanbanData.ts

@@ -330,3 +330,47 @@ export async function fetchOperationLayout(
 	}
 }
 
+/** 通用指标字典(含结构化公式预览/表达式)。 */
+export interface MetricCatalogRow extends S4CatalogRow {
+	formulaExpr?: string | null;
+	formulaPreview?: string | null;
+}
+
+export async function fetchMetricCatalog(
+	moduleCode: string,
+	metricLevel?: 1 | 2 | 3
+): Promise<MetricCatalogRow[]> {
+	try {
+		const res = await service.get(`/api/AidopKanban/metric-catalog/${encodeURIComponent(moduleCode)}`, {
+			params: metricLevel ? { metricLevel } : {},
+			headers: { 'X-Silent-Error': '1' },
+		});
+		return (res.data ?? []) as MetricCatalogRow[];
+	} catch {
+		return [];
+	}
+}
+
+/** 通用保存运营布局(与 S4 PUT 同构,moduleCode 参数化)。 */
+export async function saveOperationLayout(
+	moduleCode: string,
+	body: { L1: S4LayoutL1SaveRow[]; L2: S4LayoutL2SaveRow[] },
+	factoryId = 1
+): Promise<{ ok: boolean; message?: string }> {
+	try {
+		const res = await service.put(
+			`/api/AidopKanban/operation-layout/${encodeURIComponent(moduleCode)}`,
+			body,
+			{ params: { factoryId } }
+		);
+		const d = res.data as { ok?: boolean } | undefined;
+		return { ok: Boolean(d?.ok) };
+	} catch (e: any) {
+		const msg =
+			e?.response?.data?.message ??
+			(typeof e?.response?.data === 'string' ? e.response.data : undefined) ??
+			e?.message;
+		return { ok: false, message: typeof msg === 'string' ? msg : '保存失败' };
+	}
+}
+

+ 15 - 0
Web/src/views/aidop/kanban/utils/s4LayoutEvents.ts

@@ -5,3 +5,18 @@ export function notifyS4LayoutSaved() {
 	if (typeof window === 'undefined') return
 	window.dispatchEvent(new CustomEvent(AIDOP_S4_LAYOUT_SAVED))
 }
+
+/**
+ * 通用:保存任一模块(S1~S9 除 S8)运营布局后广播。
+ * detail.moduleCode 为大写模块编码(如 "S1")。
+ * 九宫格卡片、对应详情页订阅此事件按 moduleCode 决定是否刷新。
+ */
+export const AIDOP_LAYOUT_SAVED = 'aidop-layout-saved'
+
+export function notifyLayoutSaved(moduleCode: string) {
+	if (typeof window === 'undefined') return
+	const mc = String(moduleCode || '').toUpperCase()
+	window.dispatchEvent(new CustomEvent(AIDOP_LAYOUT_SAVED, { detail: { moduleCode: mc } }))
+	// 兼容:S4 继续同时派发旧事件
+	if (mc === 'S4') window.dispatchEvent(new CustomEvent(AIDOP_S4_LAYOUT_SAVED))
+}

+ 188 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.Generic.cs

@@ -229,4 +229,192 @@ WHERE tenant_id=@t AND factory_id=@f AND module_code=@m AND is_deleted=0
     {
         public DateTime? MaxBiz { get; set; }
     }
+
+    /// <summary>
+    /// 通用指标字典(按模块)。与 s4-metric-catalog 等价,moduleCode 必填。
+    /// </summary>
+    [HttpGet("metric-catalog/{moduleCode}")]
+    public async Task<IActionResult> GetMetricCatalogGeneric(string moduleCode, [FromQuery] int? metricLevel = null)
+    {
+        var mc = (moduleCode ?? "").ToUpperInvariant();
+        if (string.IsNullOrWhiteSpace(mc)) return BadRequest(new { message = "moduleCode 必填" });
+        var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
+
+        var q = _db.Queryable<AdoSmartOpsKpiMaster>()
+            .Where(x => x.TenantId == tenantId && x.ModuleCode == mc && x.IsEnabled);
+        if (metricLevel is > 0)
+            q = q.Where(x => x.MetricLevel == metricLevel);
+        var list = await q.OrderBy(x => x.SortNo).ToListAsync();
+        return Ok(list.Select(x => new
+        {
+            id = x.Id,
+            metricCode = x.MetricCode,
+            moduleCode = x.ModuleCode,
+            metricLevel = x.MetricLevel,
+            defaultName = x.MetricName,
+            unit = x.Unit,
+            direction = x.Direction,
+            sortHint = x.SortNo,
+            parentId = x.ParentId,
+            description = x.Description,
+            formula = x.Formula,
+            formulaExpr = x.FormulaExpr,
+            formulaPreview = x.FormulaPreview,
+            calcRule = x.CalcRule,
+            dataSource = x.DataSource,
+            statFrequency = x.StatFrequency,
+            department = x.Department
+        }));
+    }
+
+    public class GenericLayoutSaveDto
+    {
+        public List<GenericLayoutL1Dto>? L1 { get; set; }
+        public List<GenericLayoutL2Dto>? L2 { get; set; }
+    }
+
+    public class GenericLayoutL1Dto
+    {
+        public string? RowId { get; set; }
+        public string? MetricCode { get; set; }
+        public string? DisplayName { get; set; }
+        public int SortNo { get; set; }
+        public string? FormulaText { get; set; }
+    }
+
+    public class GenericLayoutL2Dto
+    {
+        public string? RowId { get; set; }
+        public string? MetricCode { get; set; }
+        public string? DisplayName { get; set; }
+        public int SortNo { get; set; }
+        public string? ParentRowId { get; set; }
+        public string? FormulaText { get; set; }
+        public string? PanelZone { get; set; }
+    }
+
+    /// <summary>
+    /// 通用保存运营布局:按 moduleCode 保存 L1/L2 到 LayoutItem;同步 KpiMaster.DisplayName。
+    /// 约束:L1 最多 MaxL1PerModule;必须命中该 moduleCode 的字典项。
+    /// </summary>
+    [HttpPut("operation-layout/{moduleCode}")]
+    public async Task<IActionResult> PutOperationLayoutGeneric(string moduleCode, [FromBody] GenericLayoutSaveDto? body, [FromQuery] long factoryId = 1)
+    {
+        var mc = (moduleCode ?? "").ToUpperInvariant();
+        if (string.IsNullOrWhiteSpace(mc)) return BadRequest(new { message = "moduleCode 必填" });
+        var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
+        if (body?.L1 == null || body.L2 == null)
+            return BadRequest(new { message = "L1/L2 不能为空" });
+        if (body.L1.Count > MaxL1PerModule)
+            return BadRequest(new { message = $"L1 最多 {MaxL1PerModule} 个" });
+
+        var kpiAll = await _db.Queryable<AdoSmartOpsKpiMaster>()
+            .Where(x => x.TenantId == tenantId && x.ModuleCode == mc && x.IsEnabled)
+            .ToListAsync();
+        var kpiByCode = kpiAll.ToDictionary(x => x.MetricCode, StringComparer.OrdinalIgnoreCase);
+
+        foreach (var r in body.L1)
+        {
+            if (string.IsNullOrWhiteSpace(r.MetricCode) || !kpiByCode.TryGetValue(r.MetricCode.Trim(), out var k) || k.MetricLevel != 1)
+                return BadRequest(new { message = $"无效的 L1 指标: {r.MetricCode}" });
+        }
+
+        var l1Ids = new HashSet<string>(StringComparer.Ordinal);
+        foreach (var r in body.L1)
+        {
+            var rid = string.IsNullOrWhiteSpace(r.RowId) ? $"{mc}-L1-{r.MetricCode!.Trim()}" : r.RowId.Trim();
+            if (!l1Ids.Add(rid))
+                return BadRequest(new { message = $"重复的 L1 RowId: {rid}" });
+        }
+
+        foreach (var r in body.L2)
+        {
+            if (string.IsNullOrWhiteSpace(r.MetricCode) || !kpiByCode.TryGetValue(r.MetricCode.Trim(), out var k) || k.MetricLevel < 2)
+                return BadRequest(new { message = $"无效的 L2/L3 指标: {r.MetricCode}" });
+        }
+
+        var now = DateTime.Now;
+        try
+        {
+            _db.Ado.BeginTran();
+
+            await _db.Deleteable<AdoSmartOpsLayoutItem>()
+                .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc)
+                .ExecuteCommandAsync();
+
+            var rows = new List<AdoSmartOpsLayoutItem>();
+            foreach (var r in body.L1)
+            {
+                var rid = string.IsNullOrWhiteSpace(r.RowId) ? $"{mc}-L1-{r.MetricCode!.Trim()}" : r.RowId.Trim();
+                rows.Add(new AdoSmartOpsLayoutItem
+                {
+                    TenantId = tenantId, FactoryId = factoryId, ModuleCode = mc,
+                    RowId = rid, MetricLevel = 1, MetricCode = r.MetricCode!.Trim(),
+                    DisplayName = string.IsNullOrWhiteSpace(r.DisplayName) ? null : r.DisplayName.Trim(),
+                    SortNo = r.SortNo, ParentRowId = null,
+                    FormulaText = string.IsNullOrWhiteSpace(r.FormulaText) ? null : r.FormulaText.Trim(),
+                    PanelZone = null, IsEnabled = 1, UpdateTime = now
+                });
+            }
+
+            foreach (var r in body.L2)
+            {
+                var code = r.MetricCode!.Trim();
+                var level = kpiByCode.TryGetValue(code, out var kk) ? kk.MetricLevel : 2;
+                var prefix = $"{mc}-L{level}-";
+                var rid = string.IsNullOrWhiteSpace(r.RowId) ? $"{prefix}{code}" : r.RowId.Trim();
+                var p = string.IsNullOrWhiteSpace(r.ParentRowId) ? null : r.ParentRowId.Trim();
+                rows.Add(new AdoSmartOpsLayoutItem
+                {
+                    TenantId = tenantId, FactoryId = factoryId, ModuleCode = mc,
+                    RowId = rid, MetricLevel = level, MetricCode = code,
+                    DisplayName = string.IsNullOrWhiteSpace(r.DisplayName) ? null : r.DisplayName.Trim(),
+                    SortNo = r.SortNo, ParentRowId = p,
+                    FormulaText = string.IsNullOrWhiteSpace(r.FormulaText) ? null : r.FormulaText,
+                    PanelZone = string.IsNullOrWhiteSpace(r.PanelZone) ? null : r.PanelZone.Trim(),
+                    IsEnabled = 1, UpdateTime = now
+                });
+            }
+
+            if (rows.Count > 0)
+                await _db.Insertable(rows).ExecuteCommandAsync();
+
+            foreach (var r in body.L1.Concat<object>(body.L2).Cast<dynamic>())
+            {
+                string? code = (string?)r.MetricCode?.Trim();
+                string? dn = (string?)r.DisplayName?.Trim();
+                if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(dn)) continue;
+                if (!kpiByCode.TryGetValue(code, out var kpi)) continue;
+                if (!string.Equals(kpi.MetricName, dn, StringComparison.Ordinal))
+                {
+                    await _db.Updateable<AdoSmartOpsKpiMaster>()
+                        .SetColumns(x => new AdoSmartOpsKpiMaster { MetricName = dn, UpdatedAt = now })
+                        .Where(x => x.Id == kpi.Id)
+                        .ExecuteCommandAsync();
+                }
+            }
+
+            var hmRows = await _db.Updateable<AdoSmartOpsHomeModule>()
+                .SetColumns(x => new AdoSmartOpsHomeModule { UpdateTime = now })
+                .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc)
+                .ExecuteCommandAsync();
+            if (hmRows == 0)
+            {
+                await _db.Insertable(new AdoSmartOpsHomeModule
+                {
+                    TenantId = tenantId, FactoryId = factoryId, ModuleCode = mc,
+                    LayoutPattern = "card_grid", UpdateTime = now
+                }).ExecuteCommandAsync();
+            }
+
+            _db.Ado.CommitTran();
+        }
+        catch (Exception ex)
+        {
+            _db.Ado.RollbackTran();
+            return BadRequest(new { message = ex.Message });
+        }
+
+        return Ok(new { ok = true });
+    }
 }