diff --git a/MiGu.Server/Configs/OpsAuditStore.cs b/MiGu.Server/Configs/OpsAuditStore.cs index dbf826e..319f394 100644 --- a/MiGu.Server/Configs/OpsAuditStore.cs +++ b/MiGu.Server/Configs/OpsAuditStore.cs @@ -103,12 +103,15 @@ public sealed class OpsAuditStore } } - /// 最近的审计(倒序,最新在前)。 - public IReadOnlyList Recent() + /// 最近的审计(倒序,最新在前)。传入 user 时只返回该操作人(忽略大小写)。 + public IReadOnlyList Recent(string? user = null) { lock (_gate) { - var copy = new List(_entries); + IEnumerable src = _entries; + if (!string.IsNullOrWhiteSpace(user)) + src = _entries.Where(e => string.Equals(e.User, user, StringComparison.OrdinalIgnoreCase)); + var copy = src.ToList(); copy.Reverse(); return copy; } diff --git a/MiGu.Server/Controllers/OpsController.cs b/MiGu.Server/Controllers/OpsController.cs index 8f2b2ed..a8fdfa6 100644 --- a/MiGu.Server/Controllers/OpsController.cs +++ b/MiGu.Server/Controllers/OpsController.cs @@ -17,34 +17,58 @@ namespace MiGu.Server.Controllers; /// M4 修复(运维操作真实下发 + 审计落库): /// - 旧实现只把动作塞进静态内存队列、永远回 success=true,是「假操作」(前端显示 /// “暂停成功”但内核毫无反应,且重启审计全丢); -/// - 现在:命中 Ops:Dispatch 映射的 op 会**真实转发**到 SimpleLite 反射 execute, +/// - 现在:命中 Ops:Dispatch 映射的 op 会**真实转发**到 Simple3 反射 execute, /// 按内核返回如实记成功 / 失败;未映射的 op 不再假成功,明确回「未下发」; /// - 审计统一经 落盘(重启不丢)。 /// -/// 关于映射:运营语义(暂停 / 恢复 / 回库 / 重置会话 / 手动充电)与 SimpleLite 内核反射 -/// 方法(OnlineCar/OfflineCar/Repair/Blown/Reset… 见 Car.cs [MethodMember])并非 -/// 一一对应。为避免「猜错方法名 → 误操作车辆」,默认不预置车辆映射,由部署方在 -/// appsettings.json Ops:Dispatch 显式配置 "opCode": "kind:Method" 后即真实下发。 +/// 关于映射: +/// - 地图监控选中车辆:ops.car.execute 按管理端「运营维护」carActionByType +/// 勾选的 Simple3 方法下发(与管理端同一份 monitor-config)。 +/// - 运维操作页的 pause/resume/gohome 等仍走 Ops:Dispatch 显式映射。 /// [ApiController] [Authorize] [Route("api/sl/ops")] public class OpsController : ControllerBase { - public record ExecuteRequest(string OpCode, string TargetId, string? Reason, string? IdempotencyKey); + public const string CarExecuteOp = "ops.car.execute"; + + public record ExecuteRequest( + string OpCode, + string TargetId, + string? Reason, + string? IdempotencyKey, + string? Method, + Dictionary? Params, + int? SiteId); public record ExecuteResponse(bool Ok, string AuditId, string? Message); private static readonly HashSet Whitelist = new(StringComparer.Ordinal) { "ops.car.pause", "ops.car.resume", "ops.car.gohome", "ops.car.resetSession", - "ops.car.manualCharge", "ops.task.pause", "ops.task.cancel", "ops.task.reassign", + "ops.car.manualCharge", CarExecuteOp, + "ops.task.pause", "ops.task.cancel", "ops.task.reassign", "ops.task.boostPriority", "monitor.note.write" }; + private static readonly HashSet GotoMethods = new(StringComparer.OrdinalIgnoreCase) + { + "Goto", "GotoSite", "WebGotoSite" + }; + + /// + /// 地图监控车辆列表快捷动作(上线/下线/结束任务等),与 carActionByType 勾选并集。 + /// + private static readonly HashSet MapMonitorQuickMethods = new(StringComparer.Ordinal) + { + "OnlineCar", "OfflineCar", "DisableCar", "EnableCar", + "ForceStopUI", "ForceStop", "UIIntercept" + }; + private readonly OpsAuditStore _audits; private readonly IHttpClientFactory _httpFactory; private readonly InternalTokenStore _internalToken; - private readonly SimpleLiteOptions _sl; + private readonly Simple3Options _sl; private readonly ILogger _log; private readonly IReadOnlyDictionary _dispatch; @@ -52,7 +76,7 @@ public class OpsController : ControllerBase OpsAuditStore audits, IHttpClientFactory httpFactory, InternalTokenStore internalToken, - IOptions sl, + IOptions sl, IConfiguration config, ILogger log) { @@ -91,10 +115,10 @@ public class OpsController : ControllerBase // AR-4:JWT ops claim 二次校验 —— (op 在白名单) AND (op 在用户 ops claim);admin 的 "*" 特判通过。 var opsClaim = User.FindFirst("ops")?.Value ?? ""; var userOps = opsClaim.Split(' ', StringSplitOptions.RemoveEmptyEntries); - if (!userOps.Contains("*") && !userOps.Contains(req.OpCode)) + if (!CanRunOp(userOps, req.OpCode, User.FindFirst("scope")?.Value)) return StatusCode(403, new { message = $"当前账号无权执行 {req.OpCode}" }); - var user = User.Identity?.Name ?? User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anonymous"; + var user = CurrentUsername(); var scope = User.FindFirst("scope")?.Value ?? "unknown"; // 幂等:同一 IdempotencyKey 若已有成功审计,直接复用上次结果,避免前端重试 / 双击造成重复下发与重复审计。 @@ -109,10 +133,13 @@ public class OpsController : ControllerBase if (req.OpCode == "monitor.note.write") return Ok(Done(user, scope, req, "ok", req.Reason)); + if (req.OpCode == CarExecuteOp) + return Ok(await ExecuteConfiguredCarMethodAsync(user, scope, req)); + // 未配置映射:不再「假成功」,如实告知未下发(在 appsettings Ops:Dispatch 绑定后即真实生效)。 if (!_dispatch.TryGetValue(req.OpCode, out var map)) return Ok(Done(user, scope, req, "unmapped", - $"运维动作 {req.OpCode} 尚未绑定 SimpleLite 内核方法,已记录审计但未下发。" + + $"运维动作 {req.OpCode} 尚未绑定 Simple3 内核方法,已记录审计但未下发。" + $"请在 appsettings.json 的 Ops:Dispatch 配置 \"{req.OpCode}\": \"kind:Method\"。", ok: false)); @@ -120,41 +147,62 @@ public class OpsController : ControllerBase if (numericId is null) return Ok(Done(user, scope, req, "failed", $"目标 ID『{req.TargetId}』无法解析为数字", ok: false)); - // M4:真实转发到 SimpleLite 反射 execute(与前端 reflectionApi.execute 同路径,本机直连 8222)。 - string result; - string? message; - try - { - var url = $"http://127.0.0.1:{_sl.ProjectionPort}/projection/reflection/execute/" + - $"{map.Kind}/{numericId}/{Uri.EscapeDataString(map.Method)}"; - using var client = _httpFactory.CreateClient(); - client.Timeout = TimeSpan.FromSeconds(8); - using var msg = new HttpRequestMessage(HttpMethod.Post, url); - // 即使 SimpleLite 默认放行 loopback,也带上 internal token,兼容其严格模式(AllowLoopback=false)。 - if (!string.IsNullOrEmpty(_internalToken.Token)) - msg.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", _internalToken.Token); - // 运维面板已对 needConfirm 动作做过二次确认;内核 RequiresPlatformConfirm 方法需此头。 - msg.Headers.TryAddWithoutValidation("X-Platform-Confirmed", "1"); - if (!string.IsNullOrWhiteSpace(user)) - msg.Headers.TryAddWithoutValidation("X-Platform-User", user); - using var resp = await client.SendAsync(msg); - var body = await resp.Content.ReadAsStringAsync(); - var success = resp.IsSuccessStatusCode && ParseSuccess(body); - result = success ? "ok" : "failed"; - message = success ? null : $"SimpleLite 返回 {(int)resp.StatusCode}:{ExtractMessage(body)}"; - } - catch (Exception ex) - { - result = "failed"; - message = $"下发 SimpleLite 失败:{ex.GetType().Name}: {ex.Message}"; - _log.LogWarning(ex, "ops execute 转发失败 op={Op} target={Target}", req.OpCode, req.TargetId); - } - - return Ok(Done(user, scope, req, result, message, ok: result == "ok")); + var forwarded = await ForwardExecuteAsync(map.Kind, numericId.Value, map.Method, null, user); + return Ok(Done(user, scope, req, forwarded.Result, forwarded.Message, ok: forwarded.Ok)); } + /// + /// 地图监控:按管理端 monitor-config.carActionByType 勾选的方法下发。 + /// 账号需具备任意 ops.car.*(或 *);方法名必须落在该车型已配置白名单内。 + /// + private async Task ExecuteConfiguredCarMethodAsync(string user, string scope, ExecuteRequest req) + { + var method = (req.Method ?? "").Trim(); + if (!IsSafeMethodName(method)) + return Done(user, scope, req, "failed", "方法名无效", ok: false); + + var numericId = ExtractNumericId(req.TargetId); + if (numericId is null) + return Done(user, scope, req, "failed", $"目标 ID『{req.TargetId}』无法解析为数字", ok: false); + + var allowed = await LoadAllowedCarMethodsAsync(numericId.Value); + if (allowed is null) + return Done(user, scope, req, "failed", "无法读取管理端车辆动作配置(monitor-config)", ok: false); + allowed.UnionWith(MapMonitorQuickMethods); + if (allowed.Count == 0) + return Done(user, scope, req, "unmapped", "当前车型未配置动作。请在管理端「配置中心 → 运营维护」按车型勾选。", ok: false); + if (!allowed.Contains(method)) + return Done(user, scope, req, "failed", $"方法 {method} 不在该车型已配置的动作列表中", ok: false); + + var siteId = req.SiteId; + if (siteId is null && req.Params is not null && req.Params.TryGetValue("siteId", out var rawSite) + && int.TryParse(rawSite, out var parsedSite)) + siteId = parsedSite; + + if (siteId is int sid && GotoMethods.Contains(method)) + { + var gotoResult = await ForwardGotoSiteAsync(numericId.Value, sid, user); + if (gotoResult.Ok || !string.Equals(method, "WebGotoSite", StringComparison.OrdinalIgnoreCase)) + return Done(user, scope, req, gotoResult.Result, + CarAuditMessage(method, gotoResult.Ok ? $"前往站点 {sid}" : gotoResult.Message, gotoResult.Ok), + ok: gotoResult.Ok); + } + + var forwarded = await ForwardExecuteAsync("car", numericId.Value, method, req.Params, user); + return Done(user, scope, req, forwarded.Result, + CarAuditMessage(method, forwarded.Message, forwarded.Ok), + ok: forwarded.Ok); + } + + private static string CarAuditMessage(string method, string? detail, bool ok) + { + if (!ok) return string.IsNullOrWhiteSpace(detail) ? $"执行 {method} 失败" : detail; + return string.IsNullOrWhiteSpace(detail) ? $"执行 {method}" : $"{method}:{detail}"; + } + + /// 只返回当前登录人的操作记录,不提供全员查询参数。 [HttpGet("audits")] - public IActionResult Audits200() => Ok(_audits.Recent()); + public IActionResult Audits200() => Ok(_audits.Recent(CurrentUsername())); /// 写一条审计并组装响应(成功时审计落 Reason,失败 / 未下发落具体 message)。 private ExecuteResponse Done(string user, string scope, ExecuteRequest req, string result, string? message, bool ok = true) @@ -163,6 +211,14 @@ public class OpsController : ControllerBase return new ExecuteResponse(ok, entry.Id, message); } + /// 与 YARP 注入 X-Platform-User 同一套取值:登录名,不是用户 id。 + private string CurrentUsername() => + User.FindFirst("unique_name")?.Value + ?? User.Identity?.Name + ?? User.FindFirstValue(ClaimTypes.Name) + ?? User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? "anonymous"; + /// /// 前端可能传 "C01" / "M03" / "5",抽取「首段」连续数字作为内核对象 id。 /// 取首段而非拼接所有数字,避免 "AGV-12-3" 被误合并成 123。 @@ -195,4 +251,166 @@ public class OpsController : ControllerBase catch { /* ignore,下面回退裁剪原文 */ } return body.Length <= 200 ? body : body[..200] + "…"; } + + private static bool CanRunOp(string[] userOps, string opCode, string? scope) + { + if (userOps.Contains("*") || userOps.Contains(opCode)) return true; + if (opCode == CarExecuteOp && userOps.Any(o => o.StartsWith("ops.car.", StringComparison.Ordinal))) + return true; + // 管理面地图监控原先可直调反射;走网关只为记审计,不额外收权。 + return opCode == CarExecuteOp + && string.Equals(scope, PageCatalog.ScopePlatform, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsSafeMethodName(string method) => + method.Length is > 0 and <= 64 && method.All(c => char.IsAsciiLetterOrDigit(c) || c == '_'); + + private readonly record struct ForwardOutcome(bool Ok, string Result, string? Message); + + private async Task?> LoadAllowedCarMethodsAsync(int carId) + { + try + { + var bundle = await GetLiteJsonAsync($"/projection/reflection/bundle/car/{carId}"); + var config = await GetLiteJsonAsync("/projection/reflection/monitor-config"); + if (bundle is null || config is null) return null; + + var typeName = ReadString(bundle, "typeName") ?? ""; + var fullTypeName = ReadString(bundle, "fullTypeName") ?? typeName; + var map = ReadCarActionByType(config.Value); + return LookupCarMethods(map, fullTypeName, typeName); + } + catch (Exception ex) + { + _log.LogWarning(ex, "读取 monitor-config 失败 car={CarId}", carId); + return null; + } + } + + private static Dictionary ReadCarActionByType(JsonElement envelope) + { + var map = new Dictionary(StringComparer.Ordinal); + if (!TryData(envelope, out var data)) return map; + if (!data.TryGetProperty("config", out var cfg) || cfg.ValueKind != JsonValueKind.Object) + return map; + if (!cfg.TryGetProperty("carActionByType", out var cat) || cat.ValueKind != JsonValueKind.Object) + return map; + foreach (var prop in cat.EnumerateObject()) + { + if (prop.Value.ValueKind != JsonValueKind.Array) continue; + map[prop.Name] = prop.Value.EnumerateArray() + .Where(x => x.ValueKind == JsonValueKind.String) + .Select(x => x.GetString() ?? "") + .Where(x => x.Length > 0) + .ToArray(); + } + return map; + } + + private static HashSet LookupCarMethods(Dictionary map, string fullType, string shortType) + { + var shortName = shortType; + var dot = shortType.LastIndexOf('.'); + if (dot >= 0 && dot < shortType.Length - 1) shortName = shortType[(dot + 1)..]; + + if (map.TryGetValue(fullType, out var exact) && exact.Length > 0) + return new HashSet(exact, StringComparer.Ordinal); + if (map.TryGetValue(shortType, out var byType) && byType.Length > 0) + return new HashSet(byType, StringComparer.Ordinal); + if (map.TryGetValue(shortName, out var byShort) && byShort.Length > 0) + return new HashSet(byShort, StringComparer.Ordinal); + + foreach (var kv in map) + { + if (kv.Value.Length == 0) continue; + var k = kv.Key; + if (k == fullType || k == shortType || k == shortName) return new HashSet(kv.Value, StringComparer.Ordinal); + if (!string.IsNullOrEmpty(fullType) && (fullType.EndsWith('.' + k, StringComparison.Ordinal) || k.EndsWith('.' + shortName, StringComparison.Ordinal))) + return new HashSet(kv.Value, StringComparer.Ordinal); + if (!string.IsNullOrEmpty(shortName) && k.EndsWith('.' + shortName, StringComparison.Ordinal)) + return new HashSet(kv.Value, StringComparer.Ordinal); + } + return new HashSet(StringComparer.Ordinal); + } + + private static bool TryData(JsonElement envelope, out JsonElement data) + { + if (envelope.TryGetProperty("data", out data) && data.ValueKind == JsonValueKind.Object) + return true; + data = envelope; + return envelope.ValueKind == JsonValueKind.Object; + } + + private static string? ReadString(JsonElement? envelope, string name) + { + if (envelope is null) return null; + var root = envelope.Value; + if (TryData(root, out var data) && data.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String) + return v.GetString(); + if (root.TryGetProperty(name, out var direct) && direct.ValueKind == JsonValueKind.String) + return direct.GetString(); + return null; + } + + private async Task GetLiteJsonAsync(string path) + { + var call = await CallLiteAsync(HttpMethod.Get, path, actor: null); + if (!call.Ok) return null; + using var doc = JsonDocument.Parse(call.Body); + return doc.RootElement.Clone(); + } + + private async Task ForwardGotoSiteAsync(int carId, int siteId, string actor) + { + var path = $"/projection/reflection/car/{carId}/goto-site?siteId={siteId}"; + return await SendLiteExecuteAsync(path, actor); + } + + private async Task ForwardExecuteAsync( + string kind, int id, string method, Dictionary? query, string actor) + { + var path = $"/projection/reflection/execute/{kind}/{id}/{Uri.EscapeDataString(method)}"; + if (query is { Count: > 0 }) + { + var qs = string.Join("&", query + .Where(kv => !string.IsNullOrEmpty(kv.Key)) + .Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value ?? "")}")); + if (qs.Length > 0) path += "?" + qs; + } + return await SendLiteExecuteAsync(path, actor); + } + + private async Task SendLiteExecuteAsync(string path, string actor) + { + try + { + var call = await CallLiteAsync(HttpMethod.Post, path, actor); + var success = call.Ok && ParseSuccess(call.Body); + return success + ? new ForwardOutcome(true, "ok", null) + : new ForwardOutcome(false, "failed", $"Simple3 返回 {call.Status}:{ExtractMessage(call.Body)}"); + } + catch (Exception ex) + { + _log.LogWarning(ex, "ops 转发 Simple3 失败 path={Path}", path); + return new ForwardOutcome(false, "failed", $"下发 Simple3 失败:{ex.GetType().Name}: {ex.Message}"); + } + } + + private async Task<(bool Ok, int Status, string Body)> CallLiteAsync(HttpMethod method, string path, string? actor) + { + var url = $"http://127.0.0.1:{_sl.ProjectionPort}{path}"; + using var client = _httpFactory.CreateClient(); + client.Timeout = TimeSpan.FromSeconds(8); + using var msg = new HttpRequestMessage(method, url); + if (!string.IsNullOrEmpty(_internalToken.Token)) + msg.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", _internalToken.Token); + if (method == HttpMethod.Post) + msg.Headers.TryAddWithoutValidation("X-Platform-Confirmed", "1"); + if (!string.IsNullOrWhiteSpace(actor)) + msg.Headers.TryAddWithoutValidation("X-Platform-User", actor); + using var resp = await client.SendAsync(msg); + var body = await resp.Content.ReadAsStringAsync(); + return (resp.IsSuccessStatusCode, (int)resp.StatusCode, body); + } } diff --git a/frontends/apps/simple-platform-vue/src/api/ops.ts b/frontends/apps/simple-platform-vue/src/api/ops.ts index b5aafbe..cb450e6 100644 --- a/frontends/apps/simple-platform-vue/src/api/ops.ts +++ b/frontends/apps/simple-platform-vue/src/api/ops.ts @@ -11,6 +11,9 @@ export interface OpsExecuteReq { targetId: string reason?: string idempotencyKey?: string + method?: string + params?: Record + siteId?: number } export interface OpsExecuteResp { diff --git a/frontends/apps/simple-platform-vue/src/api/vehicleOps.ts b/frontends/apps/simple-platform-vue/src/api/vehicleOps.ts index 6ce6682..16afc55 100644 --- a/frontends/apps/simple-platform-vue/src/api/vehicleOps.ts +++ b/frontends/apps/simple-platform-vue/src/api/vehicleOps.ts @@ -1,3 +1,4 @@ +import { executeOp } from './ops' import { ReflectionApiError, reflectionApi } from './reflection' export type VehicleMaintenanceMode = 'online' | 'offline' | 'repair' | 'blown' @@ -54,28 +55,23 @@ export async function setVehicleMaintenance( } } -async function tryExecuteMethods(carId: number, methods: readonly string[]): Promise { - let lastErr: unknown - for (const method of methods) { - try { - await reflectionApi.execute('car', carId, method) - return true - } catch (err) { - if (!isUnsupportedMethodError(err)) throw err - lastErr = err - } - } - if (lastErr) throw lastErr - return false -} - function isUnsupportedMethodError(err: unknown): boolean { if (err instanceof ReflectionApiError && (err.code === 404 || err.code === 405)) return true const msg = err instanceof Error ? err.message : String(err) return /method|not\s*found|unsupported|not\s*supported/i.test(msg) } -/** 执行车辆快捷动作;失败抛错,由调用方提示。 */ +async function executeCarMethodViaOps(carId: number, method: string): Promise { + const resp = await executeOp({ + opCode: 'ops.car.execute', + targetId: String(carId), + method + }) + if (resp.ok) return + throw new Error(resp.message || `执行 ${method} 未成功`) +} + +/** 执行车辆快捷动作;失败抛错,由调用方提示。经运维网关以便写入运维记录。 */ export async function executeVehicleQuickAction( carId: number, action: VehicleQuickAction @@ -84,12 +80,26 @@ export async function executeVehicleQuickAction( throw new Error('无效车辆 ID') } if (action === 'endTask') { - const ok = await tryExecuteMethods(carId, END_TASK_METHODS) - if (!ok) throw new Error('当前车型不支持结束任务') - return + let lastErr: unknown + for (const method of END_TASK_METHODS) { + try { + await executeCarMethodViaOps(carId, method) + return + } catch (err) { + lastErr = err + if (!isUnsupportedMethodError(err) && !isUnconfiguredMethodError(err)) throw err + } + } + if (lastErr) throw lastErr + throw new Error('当前车型不支持结束任务') } const method = QUICK_METHOD_MAP[action] - await reflectionApi.execute('car', carId, method) + await executeCarMethodViaOps(carId, method) +} + +function isUnconfiguredMethodError(err: unknown): boolean { + const msg = err instanceof Error ? err.message : String(err) + return /不在该车型已配置|未配置动作/.test(msg) } export function openOnboardWeb(url?: string | null, ip?: string | null): void { diff --git a/frontends/apps/simple-platform-vue/src/components/workbench/MonitorSelectionPanel.vue b/frontends/apps/simple-platform-vue/src/components/workbench/MonitorSelectionPanel.vue index 9a49ca6..99c0078 100644 --- a/frontends/apps/simple-platform-vue/src/components/workbench/MonitorSelectionPanel.vue +++ b/frontends/apps/simple-platform-vue/src/components/workbench/MonitorSelectionPanel.vue @@ -97,42 +97,25 @@
- {{ readOnly ? '运维动作' : '动作' }} - {{ carActions.length }} - {{ opsCarActions.length }} + 动作 + {{ carActions.length }}
- - +
当前账号无可执行的运维动作。
+
加载动作…
+
{{ carActionHint }}
+
+ +
@@ -208,7 +191,7 @@ import { pickTargetSiteOnMap, type SitePickOption } from '@/utils/carActionExecute' -import { OPS_WHITELIST, type OpsAction } from '@/types/ops' +import { OPS_WHITELIST } from '@/types/ops' import { executeOp } from '@/api/ops' import { useAuthStore } from '@/stores/auth' import { fetchMonitorConfigCached } from '@/utils/monitorConfigCache' @@ -229,7 +212,7 @@ const props = defineProps<{ refreshKey?: number cars?: Car[] missions?: Mission[] - /** 运营端只读模式:3D 不可编辑,车辆动作改用运维白名单(executeOp + 审计),并跳过 reflection 动作/配置加载。 */ + /** 只读:3D 不可编辑,站点/路径编辑区隐藏。车辆动作无论是否只读都走运维网关并记审计。 */ readOnly?: boolean }>() @@ -468,36 +451,11 @@ const carActions = computed(() => { return allMethods.value.filter((m) => set.has(m.methodName)) }) -/** 运营端只读模式下车辆动作改用运维白名单(按当前账号权限过滤)。 */ -const opsCarActions = computed(() => - OPS_WHITELIST.filter((o) => o.target === 'car' && auth.hasOp(o.code)) -) - -async function onOpsExecute(op: OpsAction) { - const idNum = Number(props.selection?.id) - if (!Number.isFinite(idNum)) return - if (op.needConfirm) { - try { - await ElMessageBox.confirm( - `确认执行 [${op.label}]?\n目标车辆:${props.selection?.id}`, - '二次确认', - { type: 'warning' } - ) - } catch { - return - } - } - executing.value = op.code - try { - const resp = await executeOp({ opCode: op.code, targetId: String(idNum), reason: '' }) - if (resp.ok) ElMessage.success(`已执行 ${op.label}(auditId=${resp.auditId})`) - else ElMessage.warning(resp.message || `执行未成功:${op.label}`) - } catch (e) { - ElMessage.error(`执行失败:${(e as Error).message}`) - } finally { - executing.value = null - } -} +const canRunConfiguredCarOps = computed(() => { + if (!props.readOnly) return true + if (auth.hasOp('*')) return true + return OPS_WHITELIST.some((o) => o.target === 'car' && auth.hasOp(o.code)) +}) const carActionHint = computed(() => { if (!monitorConfigLoaded.value) return '加载配置中…' @@ -598,15 +556,6 @@ async function loadAll() { else actionsLoading.value = true try { - if (props.readOnly) { - // 运营端只读:只取 bundle 展示详情,不加载 reflection 动作/运营配置; - // 失败时静默降级,由 findCarInList() 用 cars 列表数据兜底。 - const bundle = await reflectionApi.getBundle(rk, idNum) - applyBundle(bundle, props.selection.name ?? '') - allMethods.value = [] - hydrated.value = true - return - } await loadMonitorRuntimeConfig(false) const bundle = await reflectionApi.getBundle(rk, idNum) applyBundle(bundle, props.selection.name ?? '') @@ -634,12 +583,14 @@ async function onExecute(m: ReflectionMethod) { sitePickTitle.value = `选择目标站点 — ${m.label || m.methodName}` executing.value = m.methodName try { - const siteId = await pickTargetSiteOnMap() - if (siteId != null) { - await onSitePickConfirm(siteId) - return + // 运营端不能调 map-edit 拾取(PlatformScope),直接走站点列表。 + if (!props.readOnly) { + const siteId = await pickTargetSiteOnMap() + if (siteId != null) { + await onSitePickConfirm(siteId) + return + } } - // 取消地图拾取时回退到站点列表对话框 sitePickSites.value = await loadSitePickOptions() sitePickOpen.value = true } catch (e) { @@ -652,19 +603,83 @@ async function onExecute(m: ReflectionMethod) { executing.value = m.methodName try { - const ok = await executeReflectionMethod(rk, idNum, m) + // 车辆动作一律走运维网关,才能写入运维记录。readOnly 只控制站点/路径编辑与 3D 可写。 + const ok = rk === 'car' + ? await executeViaOpsGateway(idNum, m) + : await executeReflectionMethod(rk, idNum, m) if (ok) await loadAll() } finally { executing.value = null } } +async function executeViaOpsGateway( + idNum: number, + m: ReflectionMethod, + extra?: { siteId?: number; params?: Record } +): Promise { + const label = m.label || m.methodName + if (m.requiresPlatformConfirm) { + try { + await ElMessageBox.confirm(m.confirmMessage?.trim() || `确认执行 [${label}]?`, '二次确认', { + type: 'warning' + }) + } catch { + return false + } + } + + const params: Record = { ...(extra?.params ?? {}) } + for (const p of m.params ?? []) { + if (params[p.name] != null) continue + try { + const { value } = await ElMessageBox.prompt( + `参数 ${p.name}(${p.typeName})`, + label, + { + inputValue: p.defaultValue ?? '', + confirmButtonText: '确定', + cancelButtonText: '取消' + } + ) + params[p.name] = value ?? '' + } catch { + return false + } + } + + try { + const resp = await executeOp({ + opCode: 'ops.car.execute', + targetId: String(idNum), + method: m.methodName, + params, + siteId: extra?.siteId + }) + if (resp.ok) { + ElMessage.success(`已执行 ${label}`) + return true + } + ElMessage.warning(resp.message || `执行未成功:${label}`) + return false + } catch (e) { + ElMessage.error(`执行失败:${(e as Error).message}`) + return false + } +} + async function onSitePickConfirm(siteId: number) { const idNum = Number(props.selection?.id) if (!Number.isFinite(idNum)) return - executing.value = pendingGotoMethod.value?.methodName ?? 'goto' + const pending = pendingGotoMethod.value + executing.value = pending?.methodName ?? 'goto' try { - const ok = await executeCarGotoSite(idNum, siteId) + const ok = pending + ? await executeViaOpsGateway(idNum, pending, { + siteId, + params: { siteId: String(siteId) } + }) + : await executeCarGotoSite(idNum, siteId) if (ok) await loadAll() } finally { executing.value = null @@ -673,6 +688,7 @@ async function onSitePickConfirm(siteId: number) { } async function onSitePickOnMap() { + if (props.readOnly) return const siteId = await pickTargetSiteOnMap() sitePickOpen.value = false if (siteId != null) await onSitePickConfirm(siteId) diff --git a/frontends/apps/simple-platform-vue/src/types/ops.ts b/frontends/apps/simple-platform-vue/src/types/ops.ts index bebbf23..6d03538 100644 --- a/frontends/apps/simple-platform-vue/src/types/ops.ts +++ b/frontends/apps/simple-platform-vue/src/types/ops.ts @@ -12,6 +12,7 @@ export const OPS_WHITELIST: OpsAction[] = [ { code: 'ops.car.gohome', label: '回原点', target: 'car', needConfirm: true, description: '指派车辆回原点' }, { code: 'ops.car.resetSession', label: '重置车辆会话', target: 'car', needConfirm: true, description: '重置车辆通信会话' }, { code: 'ops.car.manualCharge', label: '手动充电', target: 'car', needConfirm: false, description: '触发手动充电' }, + { code: 'ops.car.execute', label: '地图监控车辆动作', target: 'car', needConfirm: false, description: '地图监控里对车辆执行的配置动作' }, { code: 'ops.task.pause', label: '暂停任务', target: 'task', needConfirm: false, description: '暂停指定任务' }, { code: 'ops.task.cancel', label: '取消任务', target: 'task', needConfirm: true, description: '取消指定任务' }, { code: 'ops.task.reassign', label: '重派任务', target: 'task', needConfirm: true, description: '重新分配任务给其他车辆' }, @@ -26,6 +27,6 @@ export interface OpsAuditEntry { scope: string opCode: string target: string - result: 'ok' | 'err' + result: string message?: string } diff --git a/frontends/apps/simple-platform-vue/src/views/monitor/OpsActionPanelView.vue b/frontends/apps/simple-platform-vue/src/views/monitor/OpsActionPanelView.vue index 7ea8860..a7d914a 100644 --- a/frontends/apps/simple-platform-vue/src/views/monitor/OpsActionPanelView.vue +++ b/frontends/apps/simple-platform-vue/src/views/monitor/OpsActionPanelView.vue @@ -1,85 +1,298 @@ + +