运维网关支持按车辆配置方法真实下发,并按用户过滤审计。
新增 ops.car.execute 路径与前端运维操作/选中面板联动。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -11,6 +11,9 @@ export interface OpsExecuteReq {
|
||||
targetId: string
|
||||
reason?: string
|
||||
idempotencyKey?: string
|
||||
method?: string
|
||||
params?: Record<string, string>
|
||||
siteId?: number
|
||||
}
|
||||
|
||||
export interface OpsExecuteResp {
|
||||
|
||||
@@ -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<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 {
|
||||
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<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(
|
||||
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 {
|
||||
|
||||
+100
-84
@@ -97,42 +97,25 @@
|
||||
|
||||
<section class="card card--actions">
|
||||
<header class="card-hd">
|
||||
<span>{{ readOnly ? '运维动作' : '动作' }}</span>
|
||||
<span v-if="!readOnly && carActions.length" class="meta">{{ carActions.length }}</span>
|
||||
<span v-else-if="readOnly && opsCarActions.length" class="meta">{{ opsCarActions.length }}</span>
|
||||
<span>动作</span>
|
||||
<span v-if="carActions.length" class="meta">{{ carActions.length }}</span>
|
||||
</header>
|
||||
<template v-if="readOnly">
|
||||
<div v-if="!opsCarActions.length" 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 class="action-grid action-grid--vehicle">
|
||||
<button
|
||||
v-for="m in carActions"
|
||||
:key="m.methodName"
|
||||
type="button"
|
||||
class="action-btn"
|
||||
:class="{ 'action-btn--goto': methodNeedsSitePick(m) }"
|
||||
:disabled="executing === m.methodName"
|
||||
@click="onExecute(m)">
|
||||
{{ m.label || m.methodName }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="readOnly && !canRunConfiguredCarOps" class="muted-line">当前账号无可执行的运维动作。</div>
|
||||
<div v-else-if="actionsLoading" class="muted-line">加载动作…</div>
|
||||
<div v-else-if="!carActions.length" class="muted-line">{{ carActionHint }}</div>
|
||||
<div v-else class="action-grid action-grid--vehicle">
|
||||
<button
|
||||
v-for="m in carActions"
|
||||
:key="m.methodName"
|
||||
type="button"
|
||||
class="action-btn"
|
||||
:class="{ 'action-btn--goto': methodNeedsSitePick(m) }"
|
||||
:disabled="executing === m.methodName"
|
||||
:title="m.description || m.hint || undefined"
|
||||
@click="onExecute(m)">
|
||||
{{ m.label || m.methodName }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -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<OpsAction[]>(() =>
|
||||
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<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) {
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,85 +1,298 @@
|
||||
<template>
|
||||
<PermissionGuard widget-id="OpsActionPanel">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div style="display: flex; align-items: center; gap: 8px">
|
||||
<span>运维操作(架构 §5.1 白名单)</span>
|
||||
<el-tag size="small" type="info">scope=RCSMonitor</el-tag>
|
||||
<el-tag size="small" type="success">{{ allowedOps.length }} / {{ OPS_WHITELIST.length }} 可用</el-tag>
|
||||
<div class="ops-log-page">
|
||||
<header class="ops-stats">
|
||||
<div class="ops-stat">
|
||||
<span class="ops-stat-label">今日</span>
|
||||
<span class="ops-stat-value"><b>{{ counts.today }}</b></span>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="OPS_WHITELIST" size="small" border>
|
||||
<el-table-column prop="code" label="权限码" width="200">
|
||||
<template #default="s"><code>{{ s.row.code }}</code></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="label" label="操作" width="140" />
|
||||
<el-table-column prop="target" label="目标" width="80" />
|
||||
<el-table-column prop="needConfirm" label="二次确认" width="100">
|
||||
<template #default="s">
|
||||
<el-tag v-if="s.row.needConfirm" size="small" type="warning">是</el-tag>
|
||||
<el-tag v-else size="small" effect="plain">否</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="说明" />
|
||||
<el-table-column label="操作" width="200">
|
||||
<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>
|
||||
</el-card>
|
||||
<div class="ops-stat-sep" aria-hidden="true" />
|
||||
<div class="ops-stat">
|
||||
<span class="ops-stat-label">成功</span>
|
||||
<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>
|
||||
|
||||
<el-card shadow="never" style="margin-top: 12px">
|
||||
<template #header><span>本地操作记录(占位 Mock)</span></template>
|
||||
<el-timeline>
|
||||
<el-timeline-item v-for="a in audits" :key="a.id" :timestamp="a.ts" :type="a.result === 'ok' ? 'success' : 'danger'">
|
||||
<strong>{{ a.opCode }}</strong> → {{ a.target }}({{ a.user }})
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</el-card>
|
||||
<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 label="动作" min-width="140" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ opLabel(row.opCode, row.message) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="目标" width="120" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.target || '—' }}</template>
|
||||
</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>
|
||||
</el-table-column>
|
||||
<el-table-column label="说明" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.message || '—' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<p class="ops-footnote">
|
||||
暂停、回库、充电等动作在地图监控里对车辆执行。谁能打开本页,在「权限与角色」里给角色勾选。
|
||||
</p>
|
||||
</div>
|
||||
</PermissionGuard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Refresh, Search } from '@element-plus/icons-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 { 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 targets = reactive<Record<string, string>>({})
|
||||
const audits = ref<OpsAuditEntry[]>([])
|
||||
const rows = 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
|
||||
if (!u) return ''
|
||||
return u.displayName && u.displayName !== u.username ? `${u.displayName} (${u.username})` : u.username
|
||||
})
|
||||
|
||||
async function execute(op: OpsAction) {
|
||||
const tid = targets[op.code]
|
||||
if (!tid && op.target !== 'note') {
|
||||
ElMessage.warning('请填写目标 ID')
|
||||
return
|
||||
}
|
||||
if (op.needConfirm) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认执行 [${op.label}]?\n目标:${tid}`, '二次确认', { type: 'warning' })
|
||||
} catch { return }
|
||||
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 {
|
||||
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)}`)
|
||||
rows.value = (await listAudits()).filter(isMine)
|
||||
} catch (err) {
|
||||
ElMessage.error(`加载操作记录失败:${(err as Error).message}`)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
audits.value = await listAudits()
|
||||
})
|
||||
onMounted(() => { void reload() })
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user