运维网关支持按车辆配置方法真实下发,并按用户过滤审计。

新增 ops.car.execute 路径与前端运维操作/选中面板联动。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
黄兆尉
2026-08-26 17:50:34 +08:00
co-authored by Cursor
parent c5d5f7a726
commit c9e835aaf7
7 changed files with 681 additions and 217 deletions
+6 -3
View File
@@ -103,12 +103,15 @@ public sealed class OpsAuditStore
} }
} }
/// <summary>最近的审计(倒序,最新在前)。</summary> /// <summary>最近的审计(倒序,最新在前)。传入 user 时只返回该操作人(忽略大小写)。</summary>
public IReadOnlyList<AuditEntry> Recent() public IReadOnlyList<AuditEntry> Recent(string? user = null)
{ {
lock (_gate) lock (_gate)
{ {
var copy = new List<AuditEntry>(_entries); IEnumerable<AuditEntry> src = _entries;
if (!string.IsNullOrWhiteSpace(user))
src = _entries.Where(e => string.Equals(e.User, user, StringComparison.OrdinalIgnoreCase));
var copy = src.ToList();
copy.Reverse(); copy.Reverse();
return copy; return copy;
} }
+260 -42
View File
@@ -17,34 +17,58 @@ namespace MiGu.Server.Controllers;
/// M4 修复(运维操作真实下发 + 审计落库): /// M4 修复(运维操作真实下发 + 审计落库):
/// - 旧实现只把动作塞进静态内存队列、永远回 success=true,是「假操作」(前端显示 /// - 旧实现只把动作塞进静态内存队列、永远回 success=true,是「假操作」(前端显示
/// “暂停成功”但内核毫无反应,且重启审计全丢); /// “暂停成功”但内核毫无反应,且重启审计全丢);
/// - 现在:命中 <c>Ops:Dispatch</c> 映射的 op 会**真实转发**到 SimpleLite 反射 execute /// - 现在:命中 <c>Ops:Dispatch</c> 映射的 op 会**真实转发**到 Simple3 反射 execute
/// 按内核返回如实记成功 / 失败;未映射的 op 不再假成功,明确回「未下发」; /// 按内核返回如实记成功 / 失败;未映射的 op 不再假成功,明确回「未下发」;
/// - 审计统一经 <see cref="OpsAuditStore"/> 落盘(重启不丢)。 /// - 审计统一经 <see cref="OpsAuditStore"/> 落盘(重启不丢)。
/// ///
/// 关于映射:运营语义(暂停 / 恢复 / 回库 / 重置会话 / 手动充电)与 SimpleLite 内核反射 /// 关于映射:
/// 方法(OnlineCar/OfflineCar/Repair/Blown/Reset… 见 Car.cs <c>[MethodMember]</c>)并非 /// - 地图监控选中车辆:<c>ops.car.execute</c> 按管理端「运营维护」<c>carActionByType</c>
/// 一一对应。为避免「猜错方法名 → 误操作车辆」,默认不预置车辆映射,由部署方在 /// 勾选的 Simple3 方法下发(与管理端同一份 monitor-config)。
/// appsettings.json <c>Ops:Dispatch</c> 显式配置 <c>"opCode": "kind:Method"</c> 后即真实下发 /// - 运维操作页的 pause/resume/gohome 等仍走 <c>Ops:Dispatch</c> 显式映射
/// </summary> /// </summary>
[ApiController] [ApiController]
[Authorize] [Authorize]
[Route("api/sl/ops")] [Route("api/sl/ops")]
public class OpsController : ControllerBase 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<string, string>? Params,
int? SiteId);
public record ExecuteResponse(bool Ok, string AuditId, string? Message); public record ExecuteResponse(bool Ok, string AuditId, string? Message);
private static readonly HashSet<string> Whitelist = new(StringComparer.Ordinal) private static readonly HashSet<string> Whitelist = new(StringComparer.Ordinal)
{ {
"ops.car.pause", "ops.car.resume", "ops.car.gohome", "ops.car.resetSession", "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" "ops.task.boostPriority", "monitor.note.write"
}; };
private static readonly HashSet<string> GotoMethods = new(StringComparer.OrdinalIgnoreCase)
{
"Goto", "GotoSite", "WebGotoSite"
};
/// <summary>
/// 地图监控车辆列表快捷动作(上线/下线/结束任务等),与 carActionByType 勾选并集。
/// </summary>
private static readonly HashSet<string> MapMonitorQuickMethods = new(StringComparer.Ordinal)
{
"OnlineCar", "OfflineCar", "DisableCar", "EnableCar",
"ForceStopUI", "ForceStop", "UIIntercept"
};
private readonly OpsAuditStore _audits; private readonly OpsAuditStore _audits;
private readonly IHttpClientFactory _httpFactory; private readonly IHttpClientFactory _httpFactory;
private readonly InternalTokenStore _internalToken; private readonly InternalTokenStore _internalToken;
private readonly SimpleLiteOptions _sl; private readonly Simple3Options _sl;
private readonly ILogger<OpsController> _log; private readonly ILogger<OpsController> _log;
private readonly IReadOnlyDictionary<string, (string Kind, string Method)> _dispatch; private readonly IReadOnlyDictionary<string, (string Kind, string Method)> _dispatch;
@@ -52,7 +76,7 @@ public class OpsController : ControllerBase
OpsAuditStore audits, OpsAuditStore audits,
IHttpClientFactory httpFactory, IHttpClientFactory httpFactory,
InternalTokenStore internalToken, InternalTokenStore internalToken,
IOptions<SimpleLiteOptions> sl, IOptions<Simple3Options> sl,
IConfiguration config, IConfiguration config,
ILogger<OpsController> log) ILogger<OpsController> log)
{ {
@@ -91,10 +115,10 @@ public class OpsController : ControllerBase
// AR-4JWT ops claim 二次校验 —— (op 在白名单) AND (op 在用户 ops claim)admin 的 "*" 特判通过。 // AR-4JWT ops claim 二次校验 —— (op 在白名单) AND (op 在用户 ops claim)admin 的 "*" 特判通过。
var opsClaim = User.FindFirst("ops")?.Value ?? ""; var opsClaim = User.FindFirst("ops")?.Value ?? "";
var userOps = opsClaim.Split(' ', StringSplitOptions.RemoveEmptyEntries); 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}" }); 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"; var scope = User.FindFirst("scope")?.Value ?? "unknown";
// 幂等:同一 IdempotencyKey 若已有成功审计,直接复用上次结果,避免前端重试 / 双击造成重复下发与重复审计。 // 幂等:同一 IdempotencyKey 若已有成功审计,直接复用上次结果,避免前端重试 / 双击造成重复下发与重复审计。
@@ -109,10 +133,13 @@ public class OpsController : ControllerBase
if (req.OpCode == "monitor.note.write") if (req.OpCode == "monitor.note.write")
return Ok(Done(user, scope, req, "ok", req.Reason)); return Ok(Done(user, scope, req, "ok", req.Reason));
if (req.OpCode == CarExecuteOp)
return Ok(await ExecuteConfiguredCarMethodAsync(user, scope, req));
// 未配置映射:不再「假成功」,如实告知未下发(在 appsettings Ops:Dispatch 绑定后即真实生效)。 // 未配置映射:不再「假成功」,如实告知未下发(在 appsettings Ops:Dispatch 绑定后即真实生效)。
if (!_dispatch.TryGetValue(req.OpCode, out var map)) if (!_dispatch.TryGetValue(req.OpCode, out var map))
return Ok(Done(user, scope, req, "unmapped", return Ok(Done(user, scope, req, "unmapped",
$"运维动作 {req.OpCode} 尚未绑定 SimpleLite 内核方法,已记录审计但未下发。" + $"运维动作 {req.OpCode} 尚未绑定 Simple3 内核方法,已记录审计但未下发。" +
$"请在 appsettings.json 的 Ops:Dispatch 配置 \"{req.OpCode}\": \"kind:Method\"。", $"请在 appsettings.json 的 Ops:Dispatch 配置 \"{req.OpCode}\": \"kind:Method\"。",
ok: false)); ok: false));
@@ -120,41 +147,62 @@ public class OpsController : ControllerBase
if (numericId is null) if (numericId is null)
return Ok(Done(user, scope, req, "failed", $"目标 ID『{req.TargetId}』无法解析为数字", ok: false)); return Ok(Done(user, scope, req, "failed", $"目标 ID『{req.TargetId}』无法解析为数字", ok: false));
// M4:真实转发到 SimpleLite 反射 execute(与前端 reflectionApi.execute 同路径,本机直连 8222)。 var forwarded = await ForwardExecuteAsync(map.Kind, numericId.Value, map.Method, null, user);
string result; return Ok(Done(user, scope, req, forwarded.Result, forwarded.Message, ok: forwarded.Ok));
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")); /// <summary>
/// 地图监控:按管理端 monitor-config.carActionByType 勾选的方法下发。
/// 账号需具备任意 <c>ops.car.*</c>(或 *);方法名必须落在该车型已配置白名单内。
/// </summary>
private async Task<ExecuteResponse> 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}";
}
/// <summary>只返回当前登录人的操作记录,不提供全员查询参数。</summary>
[HttpGet("audits")] [HttpGet("audits")]
public IActionResult Audits200() => Ok(_audits.Recent()); public IActionResult Audits200() => Ok(_audits.Recent(CurrentUsername()));
/// <summary>写一条审计并组装响应(成功时审计落 Reason,失败 / 未下发落具体 message)。</summary> /// <summary>写一条审计并组装响应(成功时审计落 Reason,失败 / 未下发落具体 message)。</summary>
private ExecuteResponse Done(string user, string scope, ExecuteRequest req, string result, string? message, bool ok = true) 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); return new ExecuteResponse(ok, entry.Id, message);
} }
/// <summary>与 YARP 注入 X-Platform-User 同一套取值:登录名,不是用户 id。</summary>
private string CurrentUsername() =>
User.FindFirst("unique_name")?.Value
?? User.Identity?.Name
?? User.FindFirstValue(ClaimTypes.Name)
?? User.FindFirstValue(ClaimTypes.NameIdentifier)
?? "anonymous";
/// <summary> /// <summary>
/// 前端可能传 "C01" / "M03" / "5",抽取「首段」连续数字作为内核对象 id。 /// 前端可能传 "C01" / "M03" / "5",抽取「首段」连续数字作为内核对象 id。
/// 取首段而非拼接所有数字,避免 "AGV-12-3" 被误合并成 123。 /// 取首段而非拼接所有数字,避免 "AGV-12-3" 被误合并成 123。
@@ -195,4 +251,166 @@ public class OpsController : ControllerBase
catch { /* ignore,下面回退裁剪原文 */ } catch { /* ignore,下面回退裁剪原文 */ }
return body.Length <= 200 ? body : body[..200] + "…"; 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<HashSet<string>?> 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<string, string[]> ReadCarActionByType(JsonElement envelope)
{
var map = new Dictionary<string, string[]>(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<string> LookupCarMethods(Dictionary<string, string[]> 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<string>(exact, StringComparer.Ordinal);
if (map.TryGetValue(shortType, out var byType) && byType.Length > 0)
return new HashSet<string>(byType, StringComparer.Ordinal);
if (map.TryGetValue(shortName, out var byShort) && byShort.Length > 0)
return new HashSet<string>(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<string>(kv.Value, StringComparer.Ordinal);
if (!string.IsNullOrEmpty(fullType) && (fullType.EndsWith('.' + k, StringComparison.Ordinal) || k.EndsWith('.' + shortName, StringComparison.Ordinal)))
return new HashSet<string>(kv.Value, StringComparer.Ordinal);
if (!string.IsNullOrEmpty(shortName) && k.EndsWith('.' + shortName, StringComparison.Ordinal))
return new HashSet<string>(kv.Value, StringComparer.Ordinal);
}
return new HashSet<string>(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<JsonElement?> 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<ForwardOutcome> 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<ForwardOutcome> ForwardExecuteAsync(
string kind, int id, string method, Dictionary<string, string>? 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<ForwardOutcome> 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);
}
} }
@@ -11,6 +11,9 @@ export interface OpsExecuteReq {
targetId: string targetId: string
reason?: string reason?: string
idempotencyKey?: string idempotencyKey?: string
method?: string
params?: Record<string, string>
siteId?: number
} }
export interface OpsExecuteResp { export interface OpsExecuteResp {
@@ -1,3 +1,4 @@
import { executeOp } from './ops'
import { ReflectionApiError, reflectionApi } from './reflection' import { ReflectionApiError, reflectionApi } from './reflection'
export type VehicleMaintenanceMode = 'online' | 'offline' | 'repair' | 'blown' export type VehicleMaintenanceMode = 'online' | 'offline' | 'repair' | 'blown'
@@ -54,28 +55,23 @@ export async function setVehicleMaintenance(
} }
} }
async function tryExecuteMethods(carId: number, methods: readonly string[]): Promise<boolean> {
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 { function isUnsupportedMethodError(err: unknown): boolean {
if (err instanceof ReflectionApiError && (err.code === 404 || err.code === 405)) return true if (err instanceof ReflectionApiError && (err.code === 404 || err.code === 405)) return true
const msg = err instanceof Error ? err.message : String(err) const msg = err instanceof Error ? err.message : String(err)
return /method|not\s*found|unsupported|not\s*supported/i.test(msg) return /method|not\s*found|unsupported|not\s*supported/i.test(msg)
} }
/** 执行车辆快捷动作;失败抛错,由调用方提示。 */ async function executeCarMethodViaOps(carId: number, method: string): Promise<void> {
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( export async function executeVehicleQuickAction(
carId: number, carId: number,
action: VehicleQuickAction action: VehicleQuickAction
@@ -84,12 +80,26 @@ export async function executeVehicleQuickAction(
throw new Error('无效车辆 ID') throw new Error('无效车辆 ID')
} }
if (action === 'endTask') { if (action === 'endTask') {
const ok = await tryExecuteMethods(carId, END_TASK_METHODS) let lastErr: unknown
if (!ok) throw new Error('当前车型不支持结束任务') for (const method of END_TASK_METHODS) {
try {
await executeCarMethodViaOps(carId, method)
return 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] 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 { export function openOnboardWeb(url?: string | null, ip?: string | null): void {
@@ -97,28 +97,11 @@
<section class="card card--actions"> <section class="card card--actions">
<header class="card-hd"> <header class="card-hd">
<span>{{ readOnly ? '运维动作' : '动作' }}</span> <span>动作</span>
<span v-if="!readOnly && carActions.length" class="meta">{{ carActions.length }}</span> <span v-if="carActions.length" class="meta">{{ carActions.length }}</span>
<span v-else-if="readOnly && opsCarActions.length" class="meta">{{ opsCarActions.length }}</span>
</header> </header>
<template v-if="readOnly"> <div v-if="readOnly && !canRunConfiguredCarOps" class="muted-line">当前账号无可执行的运维动作</div>
<div v-if="!opsCarActions.length" class="muted-line">当前账号无可执行的运维动作</div> <div v-else-if="actionsLoading" class="muted-line">加载动作</div>
<div v-else class="action-grid action-grid--vehicle">
<button
v-for="op in opsCarActions"
:key="op.code"
type="button"
class="action-btn"
:class="{ 'action-btn--goto': op.needConfirm }"
:disabled="executing === op.code"
:title="op.description"
@click="onOpsExecute(op)">
{{ op.label }}
</button>
</div>
</template>
<template v-else>
<div v-if="actionsLoading" class="muted-line">加载动作</div>
<div v-else-if="!carActions.length" class="muted-line">{{ carActionHint }}</div> <div v-else-if="!carActions.length" class="muted-line">{{ carActionHint }}</div>
<div v-else class="action-grid action-grid--vehicle"> <div v-else class="action-grid action-grid--vehicle">
<button <button
@@ -128,11 +111,11 @@
class="action-btn" class="action-btn"
:class="{ 'action-btn--goto': methodNeedsSitePick(m) }" :class="{ 'action-btn--goto': methodNeedsSitePick(m) }"
:disabled="executing === m.methodName" :disabled="executing === m.methodName"
:title="m.description || m.hint || undefined"
@click="onExecute(m)"> @click="onExecute(m)">
{{ m.label || m.methodName }} {{ m.label || m.methodName }}
</button> </button>
</div> </div>
</template>
</section> </section>
</template> </template>
@@ -208,7 +191,7 @@ import {
pickTargetSiteOnMap, pickTargetSiteOnMap,
type SitePickOption type SitePickOption
} from '@/utils/carActionExecute' } from '@/utils/carActionExecute'
import { OPS_WHITELIST, type OpsAction } from '@/types/ops' import { OPS_WHITELIST } from '@/types/ops'
import { executeOp } from '@/api/ops' import { executeOp } from '@/api/ops'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { fetchMonitorConfigCached } from '@/utils/monitorConfigCache' import { fetchMonitorConfigCached } from '@/utils/monitorConfigCache'
@@ -229,7 +212,7 @@ const props = defineProps<{
refreshKey?: number refreshKey?: number
cars?: Car[] cars?: Car[]
missions?: Mission[] missions?: Mission[]
/** 运营端只读模式3D 不可编辑,车辆动作改用运维白名单(executeOp + 审计),并跳过 reflection 动作/配置加载。 */ /** 只读3D 不可编辑,站点/路径编辑区隐藏。车辆动作无论是否只读都走运维网关并记审计。 */
readOnly?: boolean readOnly?: boolean
}>() }>()
@@ -468,36 +451,11 @@ const carActions = computed(() => {
return allMethods.value.filter((m) => set.has(m.methodName)) return allMethods.value.filter((m) => set.has(m.methodName))
}) })
/** 运营端只读模式下车辆动作改用运维白名单(按当前账号权限过滤)。 */ const canRunConfiguredCarOps = computed(() => {
const opsCarActions = computed<OpsAction[]>(() => if (!props.readOnly) return true
OPS_WHITELIST.filter((o) => o.target === 'car' && auth.hasOp(o.code)) if (auth.hasOp('*')) return true
) return OPS_WHITELIST.some((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 carActionHint = computed(() => { const carActionHint = computed(() => {
if (!monitorConfigLoaded.value) return '加载配置中…' if (!monitorConfigLoaded.value) return '加载配置中…'
@@ -598,15 +556,6 @@ async function loadAll() {
else actionsLoading.value = true else actionsLoading.value = true
try { 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) await loadMonitorRuntimeConfig(false)
const bundle = await reflectionApi.getBundle(rk, idNum) const bundle = await reflectionApi.getBundle(rk, idNum)
applyBundle(bundle, props.selection.name ?? '') applyBundle(bundle, props.selection.name ?? '')
@@ -634,12 +583,14 @@ async function onExecute(m: ReflectionMethod) {
sitePickTitle.value = `选择目标站点 — ${m.label || m.methodName}` sitePickTitle.value = `选择目标站点 — ${m.label || m.methodName}`
executing.value = m.methodName executing.value = m.methodName
try { try {
// 运营端不能调 map-edit 拾取(PlatformScope),直接走站点列表。
if (!props.readOnly) {
const siteId = await pickTargetSiteOnMap() const siteId = await pickTargetSiteOnMap()
if (siteId != null) { if (siteId != null) {
await onSitePickConfirm(siteId) await onSitePickConfirm(siteId)
return return
} }
// 取消地图拾取时回退到站点列表对话框 }
sitePickSites.value = await loadSitePickOptions() sitePickSites.value = await loadSitePickOptions()
sitePickOpen.value = true sitePickOpen.value = true
} catch (e) { } catch (e) {
@@ -652,19 +603,83 @@ async function onExecute(m: ReflectionMethod) {
executing.value = m.methodName executing.value = m.methodName
try { 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() if (ok) await loadAll()
} finally { } finally {
executing.value = null executing.value = null
} }
} }
async function executeViaOpsGateway(
idNum: number,
m: ReflectionMethod,
extra?: { siteId?: number; params?: Record<string, string> }
): Promise<boolean> {
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<string, string> = { ...(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) { async function onSitePickConfirm(siteId: number) {
const idNum = Number(props.selection?.id) const idNum = Number(props.selection?.id)
if (!Number.isFinite(idNum)) return if (!Number.isFinite(idNum)) return
executing.value = pendingGotoMethod.value?.methodName ?? 'goto' const pending = pendingGotoMethod.value
executing.value = pending?.methodName ?? 'goto'
try { 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() if (ok) await loadAll()
} finally { } finally {
executing.value = null executing.value = null
@@ -673,6 +688,7 @@ async function onSitePickConfirm(siteId: number) {
} }
async function onSitePickOnMap() { async function onSitePickOnMap() {
if (props.readOnly) return
const siteId = await pickTargetSiteOnMap() const siteId = await pickTargetSiteOnMap()
sitePickOpen.value = false sitePickOpen.value = false
if (siteId != null) await onSitePickConfirm(siteId) if (siteId != null) await onSitePickConfirm(siteId)
@@ -12,6 +12,7 @@ export const OPS_WHITELIST: OpsAction[] = [
{ code: 'ops.car.gohome', label: '回原点', target: 'car', needConfirm: true, description: '指派车辆回原点' }, { code: 'ops.car.gohome', label: '回原点', target: 'car', needConfirm: true, description: '指派车辆回原点' },
{ code: 'ops.car.resetSession', 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.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.pause', label: '暂停任务', target: 'task', needConfirm: false, description: '暂停指定任务' },
{ code: 'ops.task.cancel', label: '取消任务', target: 'task', needConfirm: true, description: '取消指定任务' }, { code: 'ops.task.cancel', label: '取消任务', target: 'task', needConfirm: true, description: '取消指定任务' },
{ code: 'ops.task.reassign', 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 scope: string
opCode: string opCode: string
target: string target: string
result: 'ok' | 'err' result: string
message?: string message?: string
} }
@@ -1,85 +1,298 @@
<template> <template>
<PermissionGuard widget-id="OpsActionPanel"> <PermissionGuard widget-id="OpsActionPanel">
<el-card shadow="never"> <div class="ops-log-page">
<template #header> <header class="ops-stats">
<div style="display: flex; align-items: center; gap: 8px"> <div class="ops-stat">
<span>运维操作架构 §5.1 白名单</span> <span class="ops-stat-label">今日</span>
<el-tag size="small" type="info">scope=RCSMonitor</el-tag> <span class="ops-stat-value"><b>{{ counts.today }}</b></span>
<el-tag size="small" type="success">{{ allowedOps.length }} / {{ OPS_WHITELIST.length }} 可用</el-tag>
</div> </div>
</template> <div class="ops-stat-sep" aria-hidden="true" />
<el-table :data="OPS_WHITELIST" size="small" border> <div class="ops-stat">
<el-table-column prop="code" label="权限码" width="200"> <span class="ops-stat-label">成功</span>
<template #default="s"><code>{{ s.row.code }}</code></template> <span class="ops-stat-value"><b>{{ counts.ok }}</b></span>
</div>
<div class="ops-stat">
<span class="ops-stat-label">失败</span>
<span class="ops-stat-value" :class="{ 'is-danger': counts.fail > 0 }"><b>{{ counts.fail }}</b></span>
</div>
<div class="ops-who">
{{ displayName }}
<span>仅本人记录</span>
</div>
<div class="ops-stats-actions">
<el-button :icon="Refresh" :loading="loading" @click="reload">刷新</el-button>
</div>
</header>
<div class="ops-toolbar">
<el-input
v-model="search"
clearable
placeholder="搜索动作 / 目标 / 说明"
class="ops-search"
:prefix-icon="Search"
/>
<el-select v-model="opFilter" clearable filterable placeholder="动作" class="ops-op">
<el-option v-for="op in opOptions" :key="op.code" :label="op.label" :value="op.code" />
</el-select>
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
start-placeholder="日起"
end-placeholder="日止"
class="ops-date"
:shortcuts="dateShortcuts"
unlink-panels
/>
<span class="ops-count">{{ filteredRows.length }} / {{ rows.length }}</span>
</div>
<div class="ops-chips" role="tablist">
<button
v-for="chip in statusChips"
:key="chip.key"
type="button"
class="ops-chip"
:class="{ 'is-active': quickStatus === chip.key }"
@click="quickStatus = chip.key"
>
{{ chip.label }}<b>{{ chip.count }}</b>
</button>
</div>
<div v-loading="loading" class="ops-table-wrap">
<el-table :data="filteredRows" stripe height="100%" empty-text="暂无你的操作记录">
<el-table-column label="时间" width="176" sortable :sort-method="(a: OpsAuditEntry, b: OpsAuditEntry) => sortByTime(a.ts, b.ts)">
<template #default="{ row }">{{ formatTime(row.ts) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="label" label="操作" width="140" /> <el-table-column label="动作" min-width="140" show-overflow-tooltip>
<el-table-column prop="target" label="目标" width="80" /> <template #default="{ row }">{{ opLabel(row.opCode, row.message) }}</template>
<el-table-column prop="needConfirm" label="二次确认" width="100"> </el-table-column>
<template #default="s"> <el-table-column label="目标" width="120" show-overflow-tooltip>
<el-tag v-if="s.row.needConfirm" size="small" type="warning"></el-tag> <template #default="{ row }">{{ row.target || '—' }}</template>
<el-tag v-else size="small" effect="plain"></el-tag> </el-table-column>
<el-table-column label="结果" width="96" align="center">
<template #default="{ row }">
<el-tag size="small" :type="isOk(row.result) ? 'success' : 'danger'" effect="plain">
{{ resultLabel(row.result) }}
</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="description" label="说明" /> <el-table-column label="说明" min-width="200" show-overflow-tooltip>
<el-table-column label="操作" width="200"> <template #default="{ row }">{{ row.message || '—' }}</template>
<template #default="s">
<el-input v-model="targets[s.row.code]" placeholder="目标 ID" size="small" style="width: 100px; margin-right: 6px" />
<el-button
size="small"
:type="s.row.needConfirm ? 'warning' : 'primary'"
:disabled="!auth.hasOp(s.row.code)"
@click="execute(s.row)">执行</el-button>
</template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</el-card> </div>
<el-card shadow="never" style="margin-top: 12px"> <p class="ops-footnote">
<template #header><span>本地操作记录占位 Mock</span></template> 暂停、回库、充电等动作在地图监控里对车辆执行。谁能打开本页,在「权限与角色」里给角色勾选。
<el-timeline> </p>
<el-timeline-item v-for="a in audits" :key="a.id" :timestamp="a.ts" :type="a.result === 'ok' ? 'success' : 'danger'"> </div>
<strong>{{ a.opCode }}</strong> {{ a.target }}{{ a.user }}
</el-timeline-item>
</el-timeline>
</el-card>
</PermissionGuard> </PermissionGuard>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage } from 'element-plus'
import { Refresh, Search } from '@element-plus/icons-vue'
import PermissionGuard from '@/components/PermissionGuard.vue' import PermissionGuard from '@/components/PermissionGuard.vue'
import { OPS_WHITELIST, type OpsAction, type OpsAuditEntry } from '@/types/ops' import { listAudits } from '@/api/ops'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { executeOp, listAudits } from '@/api/ops' import { OPS_WHITELIST, type OpsAuditEntry } from '@/types/ops'
import { dateShortcuts, formatTime, isToday, parseTime, sortByTime } from '@/utils/dateTime'
type QuickKey = 'all' | 'ok' | 'fail'
const auth = useAuthStore() const auth = useAuthStore()
const targets = reactive<Record<string, string>>({}) const rows = ref<OpsAuditEntry[]>([])
const audits = ref<OpsAuditEntry[]>([]) const loading = ref(false)
const search = ref('')
const opFilter = ref<string | null>(null)
const quickStatus = ref<QuickKey>('all')
const dateRange = ref<[string, string] | null>(null)
const allowedOps = computed(() => OPS_WHITELIST.filter((op) => auth.hasOp(op.code))) const displayName = computed(() => {
const u = auth.user
async function execute(op: OpsAction) { if (!u) return ''
const tid = targets[op.code] return u.displayName && u.displayName !== u.username ? `${u.displayName} (${u.username})` : u.username
if (!tid && op.target !== 'note') {
ElMessage.warning('请填写目标 ID')
return
}
if (op.needConfirm) {
try {
await ElMessageBox.confirm(`确认执行 [${op.label}]\n目标:${tid}`, '二次确认', { type: 'warning' })
} catch { return }
}
try {
const resp = await executeOp({ opCode: op.code, targetId: tid })
ElMessage.success(`成功,auditId=${resp.auditId}`)
audits.value = await listAudits()
} catch (e) {
ElMessage.error(`失败:${e instanceof Error ? e.message : String(e)}`)
}
}
onMounted(async () => {
audits.value = await listAudits()
}) })
function isOk(result: string) {
return result === 'ok'
}
function resultLabel(result: string) {
if (result === 'ok') return '成功'
if (result === 'unmapped') return '未下发'
return '失败'
}
function opLabel(code: string, message?: string) {
if (code === 'ops.car.execute' && message?.trim()) {
const method = message.replace(/^执行\s+/, '').split('')[0]?.trim()
if (method) return method
}
return OPS_WHITELIST.find((op) => op.code === code)?.label ?? code
}
function inDateRange(row: OpsAuditEntry) {
if (!dateRange.value) return true
const t = parseTime(row.ts)
if (t == null) return false
const [from, to] = dateRange.value
return t >= Date.parse(`${from}T00:00:00`) && t <= Date.parse(`${to}T23:59:59.999`)
}
const opOptions = computed(() => {
const codes = [...new Set(rows.value.map((r) => r.opCode))]
return codes.map((code) => ({ code, label: opLabel(code) }))
})
const filteredRows = computed(() => {
const q = search.value.trim().toLowerCase()
const tokens = q ? q.split(/\s+/).filter(Boolean) : []
return rows.value.filter((row) => {
if (quickStatus.value === 'ok' && !isOk(row.result)) return false
if (quickStatus.value === 'fail' && isOk(row.result)) return false
if (opFilter.value && row.opCode !== opFilter.value) return false
if (!inDateRange(row)) return false
if (!tokens.length) return true
const hay = [opLabel(row.opCode, row.message), row.opCode, row.target, row.message, row.result].join(' ').toLowerCase()
return tokens.every((tok) => hay.includes(tok))
})
})
const counts = computed(() => ({
today: rows.value.filter((r) => isToday(r.ts)).length,
ok: rows.value.filter((r) => isOk(r.result)).length,
fail: rows.value.filter((r) => !isOk(r.result)).length
}))
const statusChips = computed(() => [
{ key: 'all' as const, label: '全部', count: rows.value.length },
{ key: 'ok' as const, label: '成功', count: counts.value.ok },
{ key: 'fail' as const, label: '失败', count: counts.value.fail }
])
function isMine(row: OpsAuditEntry) {
const me = (auth.user?.username ?? '').trim().toLowerCase()
if (!me) return false
return (row.user ?? '').trim().toLowerCase() === me
}
async function reload() {
loading.value = true
try {
rows.value = (await listAudits()).filter(isMine)
} catch (err) {
ElMessage.error(`加载操作记录失败:${(err as Error).message}`)
} finally {
loading.value = false
}
}
onMounted(() => { void reload() })
</script> </script>
<style scoped>
.ops-log-page {
display: flex;
flex-direction: column;
gap: 10px;
height: calc(100vh - 56px - 36px - 32px);
min-height: 0;
color: var(--mg-text-light);
}
.ops-stats {
flex: none;
display: flex;
align-items: center;
gap: 26px;
min-height: 54px;
padding: 8px 18px;
border-radius: 10px;
background: rgba(var(--mg-bg-card-rgb), 0.94);
border: 1px solid var(--mg-veil-border);
overflow-x: auto;
}
.ops-stat { display: flex; flex-direction: column; gap: 3px; min-width: 56px; }
.ops-stat-label { font-size: 11px; color: var(--mg-text-muted); white-space: nowrap; }
.ops-stat-value {
font-family: var(--mg-font-mono);
font-variant-numeric: tabular-nums;
font-size: 17px;
font-weight: 650;
line-height: 1.1;
}
.ops-stat-value.is-danger b { color: var(--mg-status-danger); }
.ops-stat-sep { width: 1px; height: 30px; background: var(--mg-veil-border); flex: none; }
.ops-who {
margin-left: auto;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
font-size: 13px;
color: var(--mg-text-light);
white-space: nowrap;
}
.ops-who span { font-size: 11px; color: var(--mg-text-muted); }
.ops-stats-actions { display: flex; align-items: center; flex: none; }
.ops-toolbar {
flex: none;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 8px 12px;
border-radius: 10px;
background: rgba(var(--mg-bg-card-rgb), 0.94);
border: 1px solid var(--mg-veil-border);
}
.ops-search { width: min(260px, 100%); }
.ops-op { width: 160px; }
.ops-date { width: 250px; }
.ops-count { margin-left: auto; font-size: 12px; color: var(--mg-text-muted); font-variant-numeric: tabular-nums; }
.ops-chips { flex: none; display: flex; gap: 8px; flex-wrap: wrap; }
.ops-chip {
appearance: none;
border: 1px solid var(--mg-veil-border);
background: rgba(var(--mg-bg-card-rgb), 0.9);
color: var(--mg-text-muted);
border-radius: 999px;
padding: 4px 12px;
font-size: 12px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.ops-chip b { font-family: var(--mg-font-mono); font-variant-numeric: tabular-nums; color: var(--mg-text-light); }
.ops-chip:hover { color: var(--mg-text-light); background: var(--mg-veil-2); }
.ops-chip.is-active {
color: var(--mg-primary);
border-color: rgba(var(--mg-primary-rgb), 0.45);
background: rgba(var(--mg-primary-rgb), 0.12);
}
.ops-table-wrap {
flex: 1 1 0;
min-height: 0;
border-radius: 10px;
background: rgba(var(--mg-bg-card-rgb), 0.94);
border: 1px solid var(--mg-veil-border);
overflow: hidden;
padding: 4px;
}
.ops-footnote { flex: none; margin: 0; font-size: 12px; color: var(--mg-text-muted); }
@media (max-width: 960px) {
.ops-search, .ops-date { width: 100%; }
}
</style>