新增任务管理与报警管理前端,并接入车队健康数据。

运营菜单下挂地图监控/任务/报警入口,车辆卡片与任务列表同步展示运行态。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zhaowei.huang
2026-07-26 11:31:52 +08:00
co-authored by Cursor
parent 7056cf0872
commit 6986370e9d
23 changed files with 2097 additions and 78 deletions
@@ -34,6 +34,26 @@ public class HealthController : ControllerBase
[Authorize] [Authorize]
public IActionResult GetSimpleLiteDiagnostics() => Ok(_launcher.GetDiagnostics()); public IActionResult GetSimpleLiteDiagnostics() => Ok(_launcher.GetDiagnostics());
/// <summary>关闭本机全部 SimpleLite 进程。仅 Platform 管理端可调。</summary>
[HttpPost("simplelite/stop")]
[Authorize(Policy = "PlatformScope")]
public IActionResult StopSimpleLite()
{
var killed = _launcher.StopAll();
var diag = _launcher.GetDiagnostics();
return Ok(new { killed, diagnostics = diag });
}
/// <summary>关闭并重新拉起 SimpleLite(不同步 DLL)。仅 Platform 管理端可调。</summary>
[HttpPost("simplelite/restart")]
[Authorize(Policy = "PlatformScope")]
public IActionResult RestartSimpleLite([FromQuery] string launchMode = "webonly")
{
var result = _launcher.Restart(launchMode);
var diag = _launcher.GetDiagnostics();
return Ok(new { restart = result, diagnostics = diag });
}
/// <summary> /// <summary>
/// 关闭 SimpleLite、同步最新 DLL、重新拉起。用于「前往站点」API 缺失时一键更新。 /// 关闭 SimpleLite、同步最新 DLL、重新拉起。用于「前往站点」API 缺失时一键更新。
/// 会终止本机全部 SimpleLite 进程并重启,仅 Platform 管理端可调。 /// 会终止本机全部 SimpleLite 进程并重启,仅 Platform 管理端可调。
@@ -0,0 +1,18 @@
import http from './http'
import type { AlarmFeed } from '@/types/alarm'
const MOCK = import.meta.env.VITE_USE_MOCK === 'true'
/** 报警管理数据源:平台记录库 /fleet/alarms(离线可读 + 历史保留)。 */
export async function fetchAlarmFeed(limit = 2000): Promise<AlarmFeed> {
if (MOCK) {
const { mockAlarms } = await import('@/mock/data/alarms')
return { online: true, lastSyncAt: new Date().toISOString(), alarms: await mockAlarms() }
}
const { data } = await http.get<AlarmFeed>('/fleet/alarms', { params: { limit } })
return {
online: !!data?.online,
lastSyncAt: data?.lastSyncAt ?? null,
alarms: Array.isArray(data?.alarms) ? data.alarms : []
}
}
@@ -1,5 +1,5 @@
import http from './http' import http from './http'
import type { DeliveryTask } from '@/types/delivery' import type { CreateDeliveryPayload, DeliveryTask } from '@/types/delivery'
const MOCK = import.meta.env.VITE_USE_MOCK === 'true' const MOCK = import.meta.env.VITE_USE_MOCK === 'true'
@@ -20,17 +20,72 @@ export async function listDeliveries(opts?: {
return Array.isArray(data) ? data : [] return Array.isArray(data) ? data : []
} }
export async function cancelDelivery(id: number): Promise<void> { /** CDM 任务快照订阅结果:来自平台库 cdm_tasks(SimpleLite 关闭时仍可读,含完整历史)。 */
if (MOCK) return export interface CdmTaskFeed {
await http.post(`${BASE}/${id}/cancel`) online: boolean
lastSyncAt: string | null
tasks: DeliveryTask[]
} }
export async function resendDelivery(id: number): Promise<void> { /**
if (MOCK) return * 任务页数据源:优先读平台快照库 /fleet/tasks(离线可读 + 历史保留);
await http.post(`${BASE}/${id}/resend`) * 若平台端点不可用则回退到实时投影 /sl/projection/deliveries。
*/
export async function fetchCdmTaskFeed(limit = 1000): Promise<CdmTaskFeed> {
if (MOCK) {
const { mockDeliveries } = await import('@/mock/data/deliveries')
return { online: true, lastSyncAt: new Date().toISOString(), tasks: await mockDeliveries() }
}
try {
const { data } = await http.get<CdmTaskFeed>('/fleet/tasks', { params: { limit } })
return {
online: !!data?.online,
lastSyncAt: data?.lastSyncAt ?? null,
tasks: Array.isArray(data?.tasks) ? data.tasks : []
}
} catch {
const tasks = await listDeliveries({ includeFinished: true, includeAborted: true })
return { online: true, lastSyncAt: new Date().toISOString(), tasks }
}
} }
export async function forceCompleteDelivery(id: number): Promise<void> { export async function cancelDelivery(id: string): Promise<void> {
if (MOCK) return if (MOCK) return
await http.post(`${BASE}/${id}/force-complete`) await http.post(`${BASE}/${encodeURIComponent(id)}/cancel`)
}
export async function resendDelivery(id: string): Promise<void> {
if (MOCK) return
await http.post(`${BASE}/${encodeURIComponent(id)}/resend`)
}
export async function forceCompleteDelivery(id: string): Promise<void> {
if (MOCK) return
await http.post(`${BASE}/${encodeURIComponent(id)}/force-complete`)
}
export async function pauseDelivery(id: string): Promise<void> {
if (MOCK) return
await http.post(`${BASE}/${encodeURIComponent(id)}/pause`)
}
export async function resumeDelivery(id: string): Promise<void> {
if (MOCK) return
await http.post(`${BASE}/${encodeURIComponent(id)}/resume`)
}
export async function changeCarDelivery(id: string): Promise<void> {
if (MOCK) return
await http.post(`${BASE}/${encodeURIComponent(id)}/change-car`)
}
export async function setDeliveryPriority(id: string, value: number): Promise<void> {
if (MOCK) return
await http.post(`${BASE}/${encodeURIComponent(id)}/priority`, { value })
}
export async function createDelivery(payload: CreateDeliveryPayload): Promise<{ id: string }> {
if (MOCK) return { id: `MOCK-${Date.now()}` }
const { data } = await http.post<{ success: boolean; id: string }>(BASE, payload)
return { id: data?.id ?? '' }
} }
@@ -32,6 +32,14 @@ export async function fetchFleetHealth(): Promise<FleetHealthRow[]> {
await new Promise((r) => setTimeout(r, 120)) await new Promise((r) => setTimeout(r, 120))
return mockFleetHealth() return mockFleetHealth()
} }
// 平台侧聚合:SimpleLite 指标 + WatchDog(:9776) TCP RTT。
// 不再直打 /sl/projection/fleet/health(其探测车载 :8081,现场多数未开导致假超时 2000ms)。
try {
const { data } = await http.get<FleetHealthRow[]>('/fleet/health')
if (Array.isArray(data) && data.length > 0) return data
} catch {
/* fall through */
}
const { data } = await http.get<FleetHealthRow[]>('/sl/projection/fleet/health') const { data } = await http.get<FleetHealthRow[]>('/sl/projection/fleet/health')
return Array.isArray(data) ? data : [] return Array.isArray(data) ? data : []
} }
@@ -0,0 +1,58 @@
import http from '@/api/http'
import type { LaunchMode, RunMode } from '@/types/auth'
export interface HealthInfo {
status: string
startTime: string
uptimeSec: number
}
export interface SimpleLiteDiagnostics {
enabled: boolean
isRunning: boolean
lastLaunchMode?: string | null
projectionPort: number
projectionPortReachable: boolean
gotoSiteApiAvailable?: boolean | null
executableExists: boolean
deployHint?: string | null
}
export interface SimpleLiteLaunchResult {
started: boolean
status: string
detail: string
displayMode?: string | null
warning?: string | null
}
export function getHealth() {
return http.get<HealthInfo>('/health')
}
export function getSimpleLiteDiagnostics() {
return http.get<SimpleLiteDiagnostics>('/health/simplelite')
}
export function stopSimpleLite() {
return http.post<{ killed: number; diagnostics: SimpleLiteDiagnostics }>('/health/simplelite/stop')
}
export function restartSimpleLite(launchMode: LaunchMode) {
return http.post<{ restart: SimpleLiteLaunchResult; diagnostics: SimpleLiteDiagnostics }>(
'/health/simplelite/restart',
null,
{ params: { launchMode }, timeout: 60_000 }
)
}
/** 从诊断/会话 runMode 推断重启时使用的 launchMode。 */
export function resolveRestartLaunchMode(
lastLaunchMode: string | null | undefined,
runMode: RunMode | null | undefined
): LaunchMode {
const mode = (lastLaunchMode ?? '').toLowerCase()
if (mode === 'web') return 'WebOnly'
if (mode === 'web+local') return 'DesktopAndWeb'
return runMode === 'WebOnly' ? 'WebOnly' : 'DesktopAndWeb'
}
@@ -0,0 +1,161 @@
<template>
<div v-if="playing" class="pb-bar">
<div class="pb-file" :title="fileName">{{ fileName || '回放中' }}</div>
<div class="pb-controls">
<button type="button" class="pb-btn" title="-5s" @click="seekRel(-5000)"></button>
<button type="button" class="pb-btn" title="上一帧" @click="step(-1)"></button>
<button type="button" class="pb-btn pb-play" @click="togglePlay">
{{ autoPlaying ? '' : '' }}
</button>
<button type="button" class="pb-btn" title="下一帧" @click="step(1)"></button>
<button type="button" class="pb-btn" title="+5s" @click="seekRel(5000)"></button>
</div>
<input
class="pb-slider"
type="range"
min="0"
:max="Math.max(durationMs, 1)"
:value="elapsedMs"
@input="onSlider"
/>
<div class="pb-time">{{ formatMs(elapsedMs) }} / {{ formatMs(durationMs) }}</div>
<select class="pb-speed" :value="speed" @change="onSpeed">
<option v-for="s in speeds" :key="s" :value="s">{{ s }}x</option>
</select>
<button type="button" class="pb-btn pb-stop" @click="stop">停止</button>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { workspaceToolbarApi } from '@/api/workspaceToolbar'
const speeds = [0.25, 0.5, 1, 2, 4, 8]
const playing = ref(false)
const autoPlaying = ref(false)
const elapsedMs = ref(0)
const durationMs = ref(0)
const speed = ref(1)
const fileName = ref('')
let timer: ReturnType<typeof setInterval> | null = null
let seeking = false
function formatMs(ms: number) {
const s = Math.floor(ms / 1000)
const m = Math.floor(s / 60)
const h = Math.floor(m / 60)
const ss = String(s % 60).padStart(2, '0')
const mm = String(m % 60).padStart(2, '0')
return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`
}
async function refresh() {
try {
const st = await workspaceToolbarApi.getState()
const rec = st.recording as typeof st.recording & {
isAutoPlaying?: boolean
speed?: number
durationMs?: number
currentPlaybackFile?: string | null
}
playing.value = !!rec.isPlaying
autoPlaying.value = !!rec.isAutoPlaying
if (!seeking) elapsedMs.value = rec.elapsedMs ?? 0
durationMs.value = rec.durationMs ?? 0
speed.value = rec.speed ?? 1
fileName.value = rec.currentPlaybackFile ?? ''
} catch {
// 后端未就绪时静默
}
}
async function togglePlay() {
await workspaceToolbarApi.playPause()
await refresh()
}
async function stop() {
await workspaceToolbarApi.stopPlayback()
await refresh()
}
async function step(delta: number) {
await workspaceToolbarApi.step(delta)
await refresh()
}
async function seekRel(deltaMs: number) {
const target = Math.max(0, Math.min(durationMs.value, elapsedMs.value + deltaMs))
await workspaceToolbarApi.seekElapsed(target)
await refresh()
}
async function onSlider(e: Event) {
const v = Number((e.target as HTMLInputElement).value)
seeking = true
elapsedMs.value = v
try {
await workspaceToolbarApi.seekElapsed(v)
} finally {
seeking = false
}
}
async function onSpeed(e: Event) {
const v = Number((e.target as HTMLSelectElement).value)
await workspaceToolbarApi.setSpeed(v)
await refresh()
}
onMounted(() => {
void refresh()
timer = setInterval(() => void refresh(), 250)
})
onUnmounted(() => {
if (timer) clearInterval(timer)
})
defineExpose({ refresh })
</script>
<style scoped>
.pb-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: rgba(20, 24, 30, 0.92);
border-top: 1px solid #2a303c;
color: #eceff1;
font-size: 12px;
}
.pb-file {
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #90a4ae;
}
.pb-controls { display: flex; gap: 2px; }
.pb-btn {
background: #2a303c;
border: 1px solid #3a4150;
color: #eceff1;
border-radius: 4px;
padding: 2px 8px;
cursor: pointer;
}
.pb-btn:hover { background: #3a4150; }
.pb-play { min-width: 36px; }
.pb-stop { color: #ef9a9a; }
.pb-slider { flex: 1; min-width: 80px; }
.pb-time { font-variant-numeric: tabular-nums; color: #b0bec5; }
.pb-speed {
background: #2a303c;
border: 1px solid #3a4150;
color: #eceff1;
border-radius: 4px;
padding: 2px 4px;
}
</style>
@@ -28,7 +28,7 @@
@row-click="onRowClick" @row-click="onRowClick"
@row-contextmenu="onRowContextMenu" @row-contextmenu="onRowContextMenu"
> >
<el-table-column prop="id" label="ID" width="40" align="center" /> <el-table-column prop="id" label="ID" width="72" align="center" show-overflow-tooltip />
<el-table-column label="起点" show-overflow-tooltip> <el-table-column label="起点" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">
<span class="cell-ellipsis" :title="row.srcLabel">{{ shortSite(row.srcLabel) }}</span> <span class="cell-ellipsis" :title="row.srcLabel">{{ shortSite(row.srcLabel) }}</span>
@@ -103,7 +103,7 @@ import {
const props = defineProps<{ const props = defineProps<{
deliveries: DeliveryTask[] deliveries: DeliveryTask[]
selectedId?: number | null selectedId?: string | null
loading?: boolean loading?: boolean
}>() }>()
@@ -234,7 +234,10 @@ function hideCtx() {
const actionLabels: Record<DeliveryAction, string> = { const actionLabels: Record<DeliveryAction, string> = {
cancel: '取消任务', cancel: '取消任务',
resend: '重发任务', resend: '重发任务',
'force-complete': '强制完成' 'force-complete': '强制完成',
pause: '暂停任务',
resume: '恢复任务',
'change-car': '更换车辆'
} }
async function runAction(action: DeliveryAction) { async function runAction(action: DeliveryAction) {
@@ -384,10 +387,12 @@ onUnmounted(() => document.removeEventListener('click', onDocClick))
z-index: 9000; z-index: 9000;
min-width: 128px; min-width: 128px;
padding: 4px 0; padding: 4px 0;
background: #1e1e2e; background: #fffefd;
border: 1px solid rgba(255, 255, 255, 0.15); border: 1px solid rgba(40, 33, 58, 0.1);
border-radius: 8px; border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); box-shadow:
0 1px 2px rgba(40, 33, 58, 0.06),
0 12px 28px rgba(54, 35, 78, 0.14);
} }
.ctx-item { .ctx-item {
display: block; display: block;
@@ -395,16 +400,17 @@ onUnmounted(() => document.removeEventListener('click', onDocClick))
padding: 8px 14px; padding: 8px 14px;
border: none; border: none;
background: transparent; background: transparent;
color: #fff; color: #28213a;
font-size: 13px; font-size: 13px;
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
} }
.ctx-item:hover:not(:disabled) { .ctx-item:hover:not(:disabled) {
background: rgba(var(--mg-accent-rgb, 142, 200, 252), 0.2); background: rgba(var(--mg-primary-rgb, 117, 67, 232), 0.1);
color: var(--mg-primary, #7543e8);
} }
.ctx-item:disabled { .ctx-item:disabled {
color: rgba(255, 255, 255, 0.35); color: #b8b0c8;
cursor: not-allowed; cursor: not-allowed;
} }
</style> </style>
@@ -1,6 +1,6 @@
<template> <template>
<div class="vehicle-monitor"> <div class="vehicle-monitor" :class="{ 'is-compact': compact, 'is-table': table }">
<div class="overview-row"> <div v-if="!compact && !table" class="overview-row">
<div class="overview-item"> <div class="overview-item">
<div class="overview-num">{{ cars.length }}</div> <div class="overview-num">{{ cars.length }}</div>
<div class="overview-label">总数</div> <div class="overview-label">总数</div>
@@ -28,10 +28,11 @@
v-model="search" v-model="search"
size="small" size="small"
clearable clearable
placeholder="搜索 ID / 名称 / 任务" :placeholder="table ? '搜索车辆编号/名称' : '搜索 ID / 名称 / 任务'"
class="search" class="search"
/> />
<el-select <el-select
v-if="!table"
v-model="filterState" v-model="filterState"
size="small" size="small"
placeholder="状态" placeholder="状态"
@@ -47,7 +48,51 @@
</el-select> </el-select>
</div> </div>
<div class="vehicle-list"> <div v-if="table" class="chip-row" role="tablist" aria-label="状态筛选">
<button
v-for="chip in filterChips"
:key="chip.key"
type="button"
role="tab"
class="chip"
:class="{ 'is-active': quickFilter === chip.key }"
:aria-selected="quickFilter === chip.key"
@click="quickFilter = chip.key"
>
{{ chip.label }}
<span class="chip-n">{{ chip.count }}</span>
</button>
</div>
<div v-if="table" class="table-wrap">
<div class="table-head" aria-hidden="true">
<span class="col-id">车辆编号</span>
<span class="col-state">状态</span>
<span class="col-bat">电量</span>
<span class="col-pos">当前位置</span>
</div>
<div class="vehicle-list table-body">
<button
v-for="car in filteredCars"
:key="car.id"
type="button"
class="table-row"
:class="{ 'is-selected': car.id === selectedId }"
@click="onSelect(car)"
>
<span class="col-id" :title="car.name">{{ shortId(car) }}</span>
<span class="col-state">
<span class="state-dot" :class="`dot-${car.state}`" />
{{ stateLabel(car.state) }}
</span>
<span class="col-bat" :class="batteryLevel(batteryPct(car))">{{ batteryText(car) }}</span>
<span class="col-pos" :title="positionText(car)">{{ positionText(car) }}</span>
</button>
<div v-if="!filteredCars.length" class="empty muted">无匹配车辆</div>
</div>
</div>
<div v-else class="vehicle-list">
<div <div
v-for="car in filteredCars" v-for="car in filteredCars"
:key="car.id" :key="car.id"
@@ -120,6 +165,10 @@ const props = defineProps<{
cars: Car[] cars: Car[]
missions: Mission[] missions: Mission[]
selectedId?: string | null selectedId?: string | null
/** 紧凑模式:隐藏顶部概览条 */
compact?: boolean
/** 表格模式:筛选芯片 + 四列表格(地图监控参考布局) */
table?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -128,6 +177,8 @@ const emit = defineEmits<{
const search = ref('') const search = ref('')
const filterState = ref<CarState | ''>('') const filterState = ref<CarState | ''>('')
type QuickFilter = 'all' | 'running' | 'idle' | 'offline' | 'fault'
const quickFilter = ref<QuickFilter>('all')
const stateOptions: { value: CarState; label: string }[] = [ const stateOptions: { value: CarState; label: string }[] = [
{ value: 'idle', label: '空闲' }, { value: 'idle', label: '空闲' },
@@ -206,6 +257,16 @@ const onlineCount = computed(() => props.cars.filter((c) => c.state !== 'offline
const runningCount = computed(() => props.cars.filter((c) => c.state === 'running').length) const runningCount = computed(() => props.cars.filter((c) => c.state === 'running').length)
const chargingCount = computed(() => props.cars.filter((c) => c.state === 'charging').length) const chargingCount = computed(() => props.cars.filter((c) => c.state === 'charging').length)
const faultCount = computed(() => props.cars.filter((c) => c.state === 'fault').length) const faultCount = computed(() => props.cars.filter((c) => c.state === 'fault').length)
const idleCount = computed(() => props.cars.filter((c) => c.state === 'idle').length)
const offlineCount = computed(() => props.cars.filter((c) => c.state === 'offline').length)
const filterChips = computed(() => [
{ key: 'all' as const, label: '全部', count: props.cars.length },
{ key: 'running' as const, label: '运行中', count: runningCount.value },
{ key: 'idle' as const, label: '空闲', count: idleCount.value },
{ key: 'offline' as const, label: '离线', count: offlineCount.value },
{ key: 'fault' as const, label: '异常', count: faultCount.value }
])
const missionByCarId = computed(() => { const missionByCarId = computed(() => {
const order = (s: MissionStatus) => const order = (s: MissionStatus) =>
@@ -232,10 +293,16 @@ function missionForCar(car: Car): Mission | undefined {
function batteryPct(car: Car): number { function batteryPct(car: Car): number {
const raw = car.batterySoc ?? 0 const raw = car.batterySoc ?? 0
if (!raw) return 0
const pct = raw > 1 ? raw : raw * 100 const pct = raw > 1 ? raw : raw * 100
return Math.max(0, Math.min(100, Math.round(pct))) return Math.max(0, Math.min(100, Math.round(pct)))
} }
function batteryText(car: Car): string {
const pct = batteryPct(car)
return pct > 0 ? `${pct}%` : '—'
}
function batteryColor(p: number): string { function batteryColor(p: number): string {
if (p < 20) return '#f56c6c' if (p < 20) return '#f56c6c'
if (p < 50) return '#e6a23c' if (p < 50) return '#e6a23c'
@@ -248,11 +315,29 @@ function batteryLevel(p: number): 'low' | 'mid' | 'high' {
return 'high' return 'high'
} }
function shortId(car: Car): string {
if (car.rawId != null) return String(car.rawId).padStart(3, '0').slice(-3)
const digits = car.id.replace(/\D/g, '')
return digits ? digits.slice(-3) : car.id
}
function positionText(car: Car): string {
if (!Number.isFinite(car.x) || !Number.isFinite(car.y)) return '-'
return `${Math.round(car.x)}, ${Math.round(car.y)}`
}
const filteredCars = computed(() => { const filteredCars = computed(() => {
const q = search.value.trim().toLowerCase() const q = search.value.trim().toLowerCase()
const tokens = q ? q.split(/\s+/).filter(Boolean) : [] const tokens = q ? q.split(/\s+/).filter(Boolean) : []
return props.cars.filter((c) => { return props.cars.filter((c) => {
if (filterState.value && c.state !== filterState.value) return false if (props.table) {
if (quickFilter.value === 'running' && c.state !== 'running') return false
if (quickFilter.value === 'idle' && c.state !== 'idle') return false
if (quickFilter.value === 'offline' && c.state !== 'offline') return false
if (quickFilter.value === 'fault' && c.state !== 'fault') return false
} else if (filterState.value && c.state !== filterState.value) {
return false
}
if (!tokens.length) return true if (!tokens.length) return true
const mission = missionForCar(c) const mission = missionForCar(c)
const hay = [ const hay = [
@@ -263,7 +348,8 @@ const filteredCars = computed(() => {
c.ip ?? '', c.ip ?? '',
stateLabel(c.state), stateLabel(c.state),
mission?.name ?? '', mission?.name ?? '',
mission ? missionStatusLabel(mission.status) : '' mission ? missionStatusLabel(mission.status) : '',
positionText(c)
] ]
.join(' ') .join(' ')
.toLowerCase() .toLowerCase()
@@ -290,6 +376,24 @@ function detailIdForCar(car: Car): string {
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
} }
.vehicle-monitor.is-compact { gap: 8px; }
.vehicle-monitor.is-table { gap: 8px; }
.vehicle-monitor.is-compact .vehicle-row {
border-radius: 8px;
border-color: transparent;
background: transparent;
padding: 8px 10px;
}
.vehicle-monitor.is-compact .vehicle-row:hover {
background: var(--mg-veil-2);
border-color: transparent;
}
.vehicle-monitor.is-compact .vehicle-row.is-selected {
background: rgba(var(--mg-primary-rgb), 0.1);
border-color: transparent;
box-shadow: inset 3px 0 0 var(--mg-primary);
}
.overview-row { .overview-row {
display: grid; display: grid;
@@ -315,10 +419,10 @@ function detailIdForCar(car: Car): string {
color: var(--mg-text-light); color: var(--mg-text-light);
line-height: 1.2; line-height: 1.2;
} }
.overview-num.online { color: var(--mg-accent); text-shadow: 0 0 12px rgba(var(--mg-accent-rgb), 0.55); } .overview-num.online { color: var(--mg-accent); }
.overview-num.running { color: var(--mg-status-success); text-shadow: 0 0 12px rgba(var(--mg-status-success-rgb), 0.45); } .overview-num.running { color: var(--mg-status-success); }
.overview-num.charging { color: var(--mg-primary-hover); text-shadow: 0 0 12px rgba(var(--mg-primary-hover-rgb), 0.55); } .overview-num.charging { color: var(--mg-primary-hover); }
.overview-num.fault { color: var(--mg-status-danger); text-shadow: 0 0 12px rgba(var(--mg-status-danger-rgb), 0.5); } .overview-num.fault { color: var(--mg-status-danger); }
.overview-label { .overview-label {
font-size: 11px; font-size: 11px;
color: var(--mg-text-muted); color: var(--mg-text-muted);
@@ -333,6 +437,143 @@ function detailIdForCar(car: Car): string {
.toolbar .search { flex: 1; min-width: 0; } .toolbar .search { flex: 1; min-width: 0; }
.toolbar .filter { width: 100px; flex: none; } .toolbar .filter { width: 100px; flex: none; }
.chip-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
flex: none;
}
.chip {
appearance: none;
border: 1px solid var(--mg-veil-border);
background: transparent;
color: var(--mg-text-muted);
font-size: 11px;
font-weight: 550;
height: 26px;
padding: 0 8px;
border-radius: 6px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
transition: background 0.18s ease, color 0.18s ease, border-color 0.18s ease;
}
.chip:hover {
color: var(--mg-text-light);
border-color: var(--mg-veil-border-hi);
}
.chip.is-active {
background: rgba(var(--mg-primary-rgb), 0.12);
border-color: rgba(var(--mg-primary-rgb), 0.45);
color: var(--mg-primary);
}
.chip-n {
font-family: var(--mg-font-mono);
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.table-wrap {
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.table-head,
.table-row {
display: grid;
grid-template-columns: 56px 64px 48px minmax(0, 1fr);
gap: 6px;
align-items: center;
padding: 0 8px;
}
.table-head {
flex: none;
height: 28px;
font-size: 11px;
font-weight: 600;
color: var(--mg-text-muted);
border-bottom: 1px solid var(--mg-veil-border);
}
.table-body {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
gap: 0;
padding-right: 0;
}
.table-row {
appearance: none;
width: 100%;
border: 0;
border-bottom: 1px solid var(--mg-veil-border);
background: transparent;
color: var(--mg-text-light);
text-align: left;
height: 40px;
font-size: 12px;
cursor: pointer;
transition: background 0.15s ease;
}
.table-row:hover {
background: var(--mg-veil-1);
}
.table-row.is-selected {
background: rgba(var(--mg-primary-rgb), 0.1);
box-shadow: inset 3px 0 0 var(--mg-primary);
}
.col-id {
font-family: var(--mg-font-mono);
font-variant-numeric: tabular-nums;
font-weight: 650;
font-size: 12px;
}
.col-state {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: var(--mg-text-muted);
white-space: nowrap;
}
.col-bat {
font-family: var(--mg-font-mono);
font-variant-numeric: tabular-nums;
font-size: 11px;
font-weight: 600;
}
.col-bat.low { color: var(--mg-status-danger); }
.col-bat.mid { color: var(--mg-status-warning); }
.col-bat.high { color: var(--mg-status-success); }
.col-pos {
font-size: 11px;
color: var(--mg-text-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.state-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex: none;
background: currentColor;
}
.state-dot.dot-running,
.dot.dot-running { color: var(--mg-status-success); background: var(--mg-status-success); }
.state-dot.dot-idle,
.dot.dot-idle { color: var(--mg-status-info); background: var(--mg-status-info); }
.state-dot.dot-offline,
.dot.dot-offline { color: var(--mg-text-faint); background: var(--mg-text-faint); }
.state-dot.dot-fault,
.dot.dot-fault { color: var(--mg-status-danger); background: var(--mg-status-danger); }
.state-dot.dot-charging,
.dot.dot-charging { color: var(--mg-primary); background: var(--mg-primary); }
.state-dot.dot-paused,
.dot.dot-paused { color: var(--mg-status-warning); background: var(--mg-status-warning); }
.vehicle-list { .vehicle-list {
flex: 1 1 0; flex: 1 1 0;
min-height: 0; min-height: 0;
@@ -400,17 +641,12 @@ function detailIdForCar(car: Car): string {
border-radius: 50%; border-radius: 50%;
background: currentColor; background: currentColor;
} }
.dot-running { color: #6fcd45; box-shadow: 0 0 8px #6fcd45; animation: pulse 1.5s infinite; } .dot-running { color: #6fcd45; }
.dot-charging { color: var(--mg-primary-hover); box-shadow: 0 0 8px var(--mg-primary-hover); } .dot-charging { color: var(--mg-primary-hover); }
.dot-paused { color: #e6a23c; box-shadow: 0 0 6px #e6a23c; } .dot-paused { color: #e6a23c; }
.dot-fault { color: #ff7396; box-shadow: 0 0 8px #ff7396; animation: pulse 0.9s infinite; } .dot-fault { color: #ff7396; }
.dot-offline { color: var(--mg-text-faint); } .dot-offline { color: var(--mg-text-faint); }
.dot-idle { color: var(--mg-accent); box-shadow: 0 0 6px rgba(var(--mg-accent-rgb), 0.6); } .dot-idle { color: var(--mg-accent); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.vehicle-sub { .vehicle-sub {
display: flex; display: flex;
@@ -74,15 +74,17 @@ export function useVehicleCardState(opts: VehicleCardStateOptions) {
const latencyLabel = computed(() => { const latencyLabel = computed(() => {
const ms = vehicle.value.latencyMs const ms = vehicle.value.latencyMs
if (vehicle.value.reachable === false && ms == null) return '不可达'
if (ms == null) return '—' if (ms == null) return '—'
if (vehicle.value.reachable === false) return '超时'
return `${ms} ms` return `${ms} ms`
}) })
const latencyClass = computed(() => { const latencyClass = computed(() => {
const ms = vehicle.value.latencyMs const ms = vehicle.value.latencyMs
if (vehicle.value.reachable === false) return 'val-danger' if (vehicle.value.reachable === false) return 'val-danger'
if (ms != null && ms > 80) return 'val-warn' // WatchDog TCP RTT:局域网正常多在几十毫秒;>150 警示,>400 危险
if (ms != null && ms > 400) return 'val-danger'
if (ms != null && ms > 150) return 'val-warn'
return '' return ''
}) })
@@ -1,7 +1,28 @@
import { import {
Collection, Connection, Cpu, Document, DocumentCopy, EditPen, Files, Bell,
Histogram, Link, MapLocation, Monitor, Notebook, OfficeBuilding, Odometer, Collection,
Operation, Promotion, SetUp, Setting, Tools, User, Van, VideoCamera, View Connection,
Cpu,
Document,
DocumentCopy,
EditPen,
Files,
Histogram,
Link,
List,
MapLocation,
Monitor,
Notebook,
OfficeBuilding,
Operation,
Promotion,
SetUp,
Setting,
Tools,
User,
Van,
VideoCamera,
View
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import type { Component } from 'vue' import type { Component } from 'vue'
@@ -16,19 +37,26 @@ export interface NavMenuItem {
export const ADMIN_MENU: NavMenuItem[] = [ export const ADMIN_MENU: NavMenuItem[] = [
{ path: '/admin/dashboard', label: '总览', icon: Histogram, key: 'admin-dashboard', group: '概览' }, { path: '/admin/dashboard', label: '总览', icon: Histogram, key: 'admin-dashboard', group: '概览' },
{ path: '/admin/map-monitor', label: '地图监控', icon: View, key: 'admin-map-monitor', group: '概览' },
{ {
path: '/admin/design', label: '设计与编排', icon: Tools, group: '设计与编排', path: '/admin/operations', label: '运营管理', icon: Monitor, group: '概览',
children: [ children: [
{ path: '/admin/maps', label: '地图管理', icon: Files, key: 'admin-maps', group: '设计与编排' }, { path: '/admin/map-monitor', label: '地图监控', icon: View, key: 'admin-map-monitor', group: '概览' },
{ path: '/admin/map-editor', label: '地图编辑', icon: EditPen, key: 'admin-map-editor', group: '设计与编排' }, { path: '/admin/tasks', label: '任务管理', icon: List, key: 'admin-tasks', group: '概览' },
{ path: '/admin/project-properties', label: '项目属性', icon: Document, key: 'admin-project-properties', group: '设计与编排' }, { path: '/admin/alarms', label: '报警管理', icon: Bell, key: 'admin-alarms', group: '概览' }
{ path: '/admin/tracks', label: '场景管理', icon: Connection, key: 'admin-tracks', group: '设计与编排' }, ]
{ path: '/admin/cars', label: '车辆管理', icon: Van, key: 'admin-cars', group: '设计与编排' }, },
{ path: '/admin/processes', label: '进程管理', icon: Cpu, key: 'admin-processes', group: '设计与编排' }, {
{ path: '/admin/scripts', label: '脚本管理', icon: DocumentCopy, key: 'admin-scripts', group: '设计与编' }, path: '/admin/design', label: '设计与编辑', icon: Tools, group: '设计与编',
{ path: '/admin/task-templates', label: '任务编排', icon: Operation, key: 'admin-task-templates', group: '设计与编排' }, children: [
{ path: '/admin/simple-fields', label: '字段管理', icon: Collection, key: 'admin-simple-fields', group: '设计与编' } { path: '/admin/maps', label: '地图管理', icon: Files, key: 'admin-maps', group: '设计与编' },
{ path: '/admin/map-editor', label: '地图编辑', icon: EditPen, key: 'admin-map-editor', group: '设计与编辑' },
{ path: '/admin/project-properties', label: '项目属性', icon: Document, key: 'admin-project-properties', group: '设计与编辑' },
{ path: '/admin/tracks', label: '场景管理', icon: Connection, key: 'admin-tracks', group: '设计与编辑' },
{ path: '/admin/cars', label: '车辆管理', icon: Van, key: 'admin-cars', group: '设计与编辑' },
{ path: '/admin/processes', label: '进程管理', icon: Cpu, key: 'admin-processes', group: '设计与编辑' },
{ path: '/admin/scripts', label: '脚本管理', icon: DocumentCopy, key: 'admin-scripts', group: '设计与编辑' },
{ path: '/admin/task-templates', label: '任务编排', icon: Operation, key: 'admin-task-templates', group: '设计与编辑' },
{ path: '/admin/simple-fields', label: '字段管理', icon: Collection, key: 'admin-simple-fields', group: '设计与编辑' }
] ]
}, },
{ {
@@ -22,7 +22,7 @@ export interface QuickEntryDef {
/** 当前页即总览,不作为快捷入口候选 */ /** 当前页即总览,不作为快捷入口候选 */
const EXCLUDED_QUICK_ENTRY_KEYS = new Set(['admin-dashboard', 'monitor-dashboard']) const EXCLUDED_QUICK_ENTRY_KEYS = new Set(['admin-dashboard', 'monitor-dashboard'])
/** 旧版别名 key 菜单 key(加载/保存时归一化,避免重复项) */ /** 旧版别名 key -> 菜单 key */
const LEGACY_KEY_ALIASES: Record<string, string> = { const LEGACY_KEY_ALIASES: Record<string, string> = {
'platform-config': 'admin-map-editor', 'platform-config': 'admin-map-editor',
mission: 'admin-task-templates', mission: 'admin-task-templates',
@@ -33,7 +33,7 @@ const LEGACY_KEY_ALIASES: Record<string, string> = {
tasks: 'admin-config-strategy' tasks: 'admin-config-strategy'
} }
/** Platform 域下固定保留、不可删除的快捷入口(顺序即展示优先级) */ /** Platform 域下固定保留、不可删除的快捷入口 */
export const MANDATORY_PLATFORM_QUICK_KEYS = [ export const MANDATORY_PLATFORM_QUICK_KEYS = [
'admin-maps', 'admin-maps',
'admin-map-editor', 'admin-map-editor',
@@ -43,7 +43,7 @@ export const MANDATORY_PLATFORM_QUICK_KEYS = [
'admin-task-templates' 'admin-task-templates'
] as const ] as const
/** 与后端 DashboardShortcutCatalog.DefaultPlatformKeys 对齐(均为菜单 key */ /** 与后端 DashboardShortcutCatalog.DefaultPlatformKeys 对齐 */
export const DEFAULT_PLATFORM_QUICK_KEYS = [ export const DEFAULT_PLATFORM_QUICK_KEYS = [
...MANDATORY_PLATFORM_QUICK_KEYS, ...MANDATORY_PLATFORM_QUICK_KEYS,
'admin-config-system-center', 'admin-config-system-center',
@@ -76,7 +76,7 @@ export function isMandatoryQuickKey(key: string, scope: Scope): boolean {
return (MANDATORY_PLATFORM_QUICK_KEYS as readonly string[]).includes(normalizeQuickKey(key)) return (MANDATORY_PLATFORM_QUICK_KEYS as readonly string[]).includes(normalizeQuickKey(key))
} }
/** 保证固定项始终存在且排在最前(其余项保持原顺序) */ /** 保证固定项始终存在且排在最前 */
export function ensureMandatoryQuickKeys(keys: string[], scope: Scope): string[] { export function ensureMandatoryQuickKeys(keys: string[], scope: Scope): string[] {
const normalized = normalizeQuickKeys(keys) const normalized = normalizeQuickKeys(keys)
if (scope !== 'Platform') return normalized if (scope !== 'Platform') return normalized
@@ -0,0 +1,27 @@
import type { VehicleAlarm } from '@/types/alarm'
function minsAgo(m: number): string {
return new Date(Date.now() - m * 60_000).toISOString()
}
export async function mockAlarms(): Promise<VehicleAlarm[]> {
await new Promise((r) => setTimeout(r, 60))
return [
{
id: 'a1', carId: 309, carName: 'AGV-309', info: '导航失联', level: 2,
status: 'active', firstAt: minsAgo(6), lastAt: minsAgo(0), resolvedAt: null, durationSecs: null, acknowledged: false
},
{
id: 'a2', carId: 415, carName: 'Kiva-415', info: '急停触发', level: 3,
status: 'active', firstAt: minsAgo(2), lastAt: minsAgo(0), resolvedAt: null, durationSecs: null, acknowledged: false
},
{
id: 'a3', carId: 572, carName: 'AGV-572', info: '电量低', level: 1,
status: 'cleared', firstAt: minsAgo(120), lastAt: minsAgo(95), resolvedAt: minsAgo(95), durationSecs: 1500, acknowledged: false
},
{
id: 'a4', carId: 888, carName: 'Kiva-888', info: '放货点被占用', level: 1,
status: 'cleared', firstAt: minsAgo(1440), lastAt: minsAgo(1420), resolvedAt: minsAgo(1420), durationSecs: 1200, acknowledged: false
}
]
}
@@ -1,13 +1,18 @@
import type { DeliveryTask } from '@/types/delivery' import type { DeliveryTask } from '@/types/delivery'
function hoursAgo(h: number): string {
return new Date(Date.now() - h * 3600_000).toISOString()
}
export async function mockDeliveries(): Promise<DeliveryTask[]> { export async function mockDeliveries(): Promise<DeliveryTask[]> {
await new Promise((r) => setTimeout(r, 60)) await new Promise((r) => setTimeout(r, 60))
return [ return [
{ {
id: 101, id: 'a7Kp3',
taskId: 'WMS-20260720-001',
missionId: 1, missionId: 1,
missionName: '链式搬运', missionName: '搬运任务进程',
missionTypeName: 'FengTianChainedDeliveryMission', missionTypeName: 'TransportMission',
srcSiteId: 1, srcSiteId: 1,
srcLabel: '1-取货台 A', srcLabel: '1-取货台 A',
dstSiteId: 8, dstSiteId: 8,
@@ -17,14 +22,18 @@ export async function mockDeliveries(): Promise<DeliveryTask[]> {
carId: 1, carId: 1,
carName: 'AGV-01', carName: 'AGV-01',
priority: 1, priority: 1,
createTime: new Date().toISOString(), createTime: hoursAgo(0.5),
startTime: hoursAgo(0.4),
finishTime: null,
stuckReason: null,
overdue: false overdue: false
}, },
{ {
id: 102, id: 'b2Xq9',
taskId: null,
missionId: 1, missionId: 1,
missionName: '链式搬运', missionName: '搬运任务进程',
missionTypeName: 'FengTianChainedDeliveryMission', missionTypeName: 'TransportMission',
srcSiteId: 3, srcSiteId: 3,
srcLabel: '3-缓存区', srcLabel: '3-缓存区',
dstSiteId: 12, dstSiteId: 12,
@@ -34,14 +43,39 @@ export async function mockDeliveries(): Promise<DeliveryTask[]> {
carId: null, carId: null,
carName: null, carName: null,
priority: 0, priority: 0,
createTime: new Date(Date.now() - 45 * 60_000).toISOString(), createTime: hoursAgo(0.8),
startTime: null,
finishTime: null,
stuckReason: '无可用车辆',
overdue: true overdue: true
}, },
{ {
id: 99, id: 'c9Lm4',
taskId: 'WMS-20260720-003',
missionId: 1, missionId: 1,
missionName: '链式搬运', missionName: '搬运任务进程',
missionTypeName: 'FengTianChainedDeliveryMission', missionTypeName: 'TransportMission',
srcSiteId: 4,
srcLabel: '4-线边库',
dstSiteId: 9,
dstLabel: '9-包装台',
status: '放货中',
statusCode: 'Putting',
carId: 3,
carName: 'Kiva-03',
priority: 2,
createTime: hoursAgo(1.2),
startTime: hoursAgo(1.0),
finishTime: null,
stuckReason: null,
overdue: false
},
{
id: 'd1Nb7',
taskId: 'WMS-20260719-088',
missionId: 1,
missionName: '搬运任务进程',
missionTypeName: 'TransportMission',
srcSiteId: 2, srcSiteId: 2,
srcLabel: '2-原料区', srcLabel: '2-原料区',
dstSiteId: 5, dstSiteId: 5,
@@ -51,8 +85,53 @@ export async function mockDeliveries(): Promise<DeliveryTask[]> {
carId: 2, carId: 2,
carName: 'AGV-02', carName: 'AGV-02',
priority: 1, priority: 1,
createTime: new Date(Date.now() - 3600_000).toISOString(), createTime: hoursAgo(6),
startTime: hoursAgo(5.8),
finishTime: hoursAgo(5.4),
stuckReason: null,
overdue: false overdue: false
},
{
id: 'e5Rt2',
taskId: null,
missionId: 1,
missionName: '搬运任务进程',
missionTypeName: 'TransportMission',
srcSiteId: 6,
srcLabel: '6-暂存',
dstSiteId: 7,
dstLabel: '7-发运',
status: '已取消',
statusCode: 'Canceled',
carId: 4,
carName: 'Kiva-04',
priority: 0,
createTime: hoursAgo(26),
startTime: null,
finishTime: hoursAgo(25.5),
stuckReason: null,
overdue: false
},
{
id: 'f8Wy6',
taskId: 'WMS-20260720-077',
missionId: 1,
missionName: '搬运任务进程',
missionTypeName: 'TransportMission',
srcSiteId: 10,
srcLabel: '10-入库口',
dstSiteId: 11,
dstLabel: '11-立体库',
status: '任务异常',
statusCode: 'Error',
carId: 5,
carName: 'Kiva-05',
priority: 3,
createTime: hoursAgo(3),
startTime: hoursAgo(2.8),
finishTime: null,
stuckReason: '放货点被占用',
overdue: true
} }
] ]
} }
@@ -12,6 +12,8 @@ import type {
const PAGES: PageDef[] = [ const PAGES: PageDef[] = [
{ key: 'admin-dashboard', label: '总览', group: '概览', scope: 'Platform' }, { key: 'admin-dashboard', label: '总览', group: '概览', scope: 'Platform' },
{ key: 'admin-map-monitor', label: '地图监控', group: '概览', scope: 'Platform' }, { key: 'admin-map-monitor', label: '地图监控', group: '概览', scope: 'Platform' },
{ key: 'admin-tasks', label: '任务管理', group: '概览', scope: 'Platform' },
{ key: 'admin-alarms', label: '报警管理', group: '概览', scope: 'Platform' },
{ key: 'admin-maps', label: '地图管理', group: '设计与编排', scope: 'Platform' }, { key: 'admin-maps', label: '地图管理', group: '设计与编排', scope: 'Platform' },
{ key: 'admin-map-editor', label: '地图编辑', group: '设计与编排', scope: 'Platform' }, { key: 'admin-map-editor', label: '地图编辑', group: '设计与编排', scope: 'Platform' },
{ key: 'admin-project-properties', label: '项目属性', group: '设计与编排', scope: 'Platform' }, { key: 'admin-project-properties', label: '项目属性', group: '设计与编排', scope: 'Platform' },
@@ -30,6 +30,8 @@ const routes: RouteRecordRaw[] = [
children: [ children: [
{ path: 'dashboard', name: 'admin-dashboard', component: () => import('@/views/admin/DashboardView.vue'), meta: { title: '总览' } }, { path: 'dashboard', name: 'admin-dashboard', component: () => import('@/views/admin/DashboardView.vue'), meta: { title: '总览' } },
{ path: 'map-monitor', name: 'admin-map-monitor', component: () => import('@/views/admin/MapMonitorView.vue'), meta: { title: '地图监控' } }, { path: 'map-monitor', name: 'admin-map-monitor', component: () => import('@/views/admin/MapMonitorView.vue'), meta: { title: '地图监控' } },
{ path: 'tasks', name: 'admin-tasks', component: () => import('@/views/admin/TaskManagementView.vue'), meta: { title: '任务管理' } },
{ path: 'alarms', name: 'admin-alarms', component: () => import('@/views/admin/AlarmManagementView.vue'), meta: { title: '报警管理' } },
{ path: 'maps', name: 'admin-maps', component: () => import('@/views/admin/MapManagementView.vue'), meta: { title: '地图管理' } }, { path: 'maps', name: 'admin-maps', component: () => import('@/views/admin/MapManagementView.vue'), meta: { title: '地图管理' } },
{ path: 'map-editor', name: 'admin-map-editor', component: () => import('@/views/admin/MapEditorView.vue'), meta: { title: '地图编辑' } }, { path: 'map-editor', name: 'admin-map-editor', component: () => import('@/views/admin/MapEditorView.vue'), meta: { title: '地图编辑' } },
{ path: 'tracks', name: 'admin-tracks', component: () => import('@/views/admin/TrackTableView.vue'), meta: { title: '场景管理' } }, { path: 'tracks', name: 'admin-tracks', component: () => import('@/views/admin/TrackTableView.vue'), meta: { title: '场景管理' } },
@@ -51,8 +53,8 @@ const routes: RouteRecordRaw[] = [
// ── 旧路径深链接兼容:redirect 到聚合页对应 tab(无 name → 不计入受权限管理的页面)。 ── // ── 旧路径深链接兼容:redirect 到聚合页对应 tab(无 name → 不计入受权限管理的页面)。 ──
{ path: 'playback', redirect: { path: '/admin/config/ops-center', query: { tab: 'playback' } } }, { path: 'playback', redirect: { path: '/admin/config/ops-center', query: { tab: 'playback' } } },
{ path: 'vehicle-hub', redirect: '/admin/config/vehicle-hub' }, { path: 'vehicle-hub', redirect: '/admin/config/vehicle-hub' },
{ path: 'config/vehicle', redirect: { path: '/admin/config/vehicle-hub', query: { tab: 'maintenance' } } }, { path: 'config/vehicle', redirect: { path: '/admin/config/vehicle-hub', query: { tab: 'ota' } } },
{ path: 'config/fleet', redirect: { path: '/admin/config/vehicle-hub', query: { tab: 'fleet' } } }, { path: 'config/fleet', redirect: { path: '/admin/config/vehicle-hub', query: { tab: 'ota' } } },
{ path: 'config/routing', redirect: { path: '/admin/config/strategy', query: { tab: 'routing' } } }, { path: 'config/routing', redirect: { path: '/admin/config/strategy', query: { tab: 'routing' } } },
{ path: 'config/task', redirect: { path: '/admin/config/strategy', query: { tab: 'task' } } }, { path: 'config/task', redirect: { path: '/admin/config/strategy', query: { tab: 'task' } } },
{ path: 'config/traffic', redirect: { path: '/admin/config/strategy', query: { tab: 'traffic' } } }, { path: 'config/traffic', redirect: { path: '/admin/config/strategy', query: { tab: 'traffic' } } },
@@ -137,7 +139,8 @@ router.beforeEach(async (to) => {
// 必须 await 完成后再放行,否则页面用旧 scope 的 perms 渲染一帧后才被纠正。 // 必须 await 完成后再放行,否则页面用旧 scope 的 perms 渲染一帧后才被纠正。
// 失败(如 ops 账号尝试切 Platform 被 403)则维持原 scope,路由仍放行让用户看到 readonly UI。 // 失败(如 ops 账号尝试切 Platform 被 403)则维持原 scope,路由仍放行让用户看到 readonly UI。
const needScope: 'Platform' | 'RCSMonitor' | null = const needScope: 'Platform' | 'RCSMonitor' | null =
to.path.startsWith('/admin') ? 'Platform' to.path.startsWith('/admin')
? 'Platform'
: to.path.startsWith('/monitor') ? 'RCSMonitor' : to.path.startsWith('/monitor') ? 'RCSMonitor'
: null : null
if (needScope && auth.scope !== needScope) { if (needScope && auth.scope !== needScope) {
@@ -153,7 +156,8 @@ router.beforeEach(async (to) => {
// RBAC 页面级权限:scope 已切换到目标域,allowedPages 已刷新。 // RBAC 页面级权限:scope 已切换到目标域,allowedPages 已刷新。
// 若目标页面不在当前账号的可访问页面集合内,跳到该 scope 下首个可访问页面(菜单顺序)。 // 若目标页面不在当前账号的可访问页面集合内,跳到该 scope 下首个可访问页面(菜单顺序)。
const name = typeof to.name === 'string' ? to.name : '' const name = typeof to.name === 'string' ? to.name : ''
if (name && MANAGED_PAGE_NAMES.has(name) && !auth.hasPage(name)) { const pageKey = typeof to.meta.pageKey === 'string' ? to.meta.pageKey : name
if (pageKey && MANAGED_PAGE_NAMES.has(pageKey) && !auth.hasPage(pageKey)) {
const list = auth.scope === 'RCSMonitor' ? MONITOR_PAGES : ADMIN_PAGES const list = auth.scope === 'RCSMonitor' ? MONITOR_PAGES : ADMIN_PAGES
const fallback = list.find((p) => auth.hasPage(p.name))?.path const fallback = list.find((p) => auth.hasPage(p.name))?.path
if (fallback && fallback !== to.path) { if (fallback && fallback !== to.path) {
@@ -0,0 +1,24 @@
/** 车辆报警记录(平台侧 vehicle_alarms 投影) */
export interface VehicleAlarm {
id: string
carId: number
carName: string
/** 报警文案(车体_AlarmInfo */
info: string
/** 报警级别(车体_AlarmLevel,未知 0 */
level: number
/** active | cleared */
status: string
firstAt: string
lastAt: string
resolvedAt?: string | null
durationSecs?: number | null
acknowledged?: boolean
}
/** 报警订阅结果:来自平台库(离线可读 + 历史保留)。 */
export interface AlarmFeed {
online: boolean
lastSyncAt: string | null
alarms: VehicleAlarm[]
}
@@ -25,7 +25,7 @@ export interface Car {
lstatus?: string lstatus?: string
} }
/** 车队健康探测行(GET /sl/projection/fleet/health */ /** 车队健康探测行(优先 GET /api/fleet/health;延迟为 WatchDog TCP RTT */
export interface FleetHealthRow { export interface FleetHealthRow {
carId: number carId: number
carName?: string carName?: string
@@ -40,6 +40,8 @@ export interface FleetHealthRow {
isAlarmActive?: boolean isAlarmActive?: boolean
cpuPercent?: number cpuPercent?: number
memPercent?: number memPercent?: number
/** watchdog | onboard | none */
latencySource?: string
} }
/** 车辆运维卡片合并模型 */ /** 车辆运维卡片合并模型 */
@@ -1,6 +1,9 @@
/** 插件搬运任务(AbstractChainedDeliveryMission.GetDeliveries)投影行 */ /** 插件搬运任务(AbstractChainedDeliveryMission.GetDeliveries)投影行 */
export interface DeliveryTask { export interface DeliveryTask {
id: number /** 内部任务号(StandardScene 为 Base62 雪花串) */
id: string
/** 外部系统单号(可空) */
taskId?: string | null
missionId: number missionId: number
missionName: string missionName: string
missionTypeName: string missionTypeName: string
@@ -14,7 +17,25 @@ export interface DeliveryTask {
carName?: string | null carName?: string | null
priority: number priority: number
createTime?: string | null createTime?: string | null
startTime?: string | null
finishTime?: string | null
stuckReason?: string | null
overdue?: boolean overdue?: boolean
} }
export type DeliveryAction = 'cancel' | 'resend' | 'force-complete' export type DeliveryAction =
| 'cancel'
| 'resend'
| 'force-complete'
| 'pause'
| 'resume'
| 'change-car'
/** 新建搬运任务入参 */
export interface CreateDeliveryPayload {
src: number
dst: number
priority?: number
taskId?: string
carType?: string
}
@@ -0,0 +1,58 @@
/** 从反射 KV / 投影数值解析电量,统一成 0–1 比例(未知返回 null)。 */
const SOC_KEY_RE = /(^|_)(Soc|SOC|batterySoc|BatterySoc|电量)$/i
export function parseSocNumber(raw: unknown): number | null {
if (raw == null || raw === '') return null
if (typeof raw === 'number') {
if (!Number.isFinite(raw)) return null
return normalizeSocRatio(raw)
}
const s = String(raw).trim().replace(/%$/, '')
const n = Number(s)
if (!Number.isFinite(n)) return null
return normalizeSocRatio(n)
}
/** 接受 01 或 0100,输出 0–1。 */
export function normalizeSocRatio(n: number): number {
const ratio = n > 1 ? n / 100 : n
return Math.max(0, Math.min(1, ratio))
}
export function socToPercent(ratio: number): number {
return Math.max(0, Math.min(100, Math.round(ratio <= 1 ? ratio * 100 : ratio)))
}
export function extractSocFromKv(
rows: Array<{ key?: string | null; value?: string | null }> | null | undefined
): number | null {
if (!rows?.length) return null
const preferred = ['车体_Soc', 'Soc', 'batterySoc', 'BatterySoc', '电量']
for (const key of preferred) {
const hit = rows.find((r) => r.key === key)
const parsed = parseSocNumber(hit?.value)
if (parsed != null) return parsed
}
for (const r of rows) {
if (!r.key || !SOC_KEY_RE.test(r.key)) continue
const parsed = parseSocNumber(r.value)
if (parsed != null) return parsed
}
return null
}
export function extractSocFromFieldMap(map: Record<string, string> | null | undefined): number | null {
if (!map) return null
const preferred = ['车体_Soc', 'Soc', 'batterySoc', 'BatterySoc', '电量', 'battery']
for (const key of preferred) {
const parsed = parseSocNumber(map[key])
if (parsed != null) return parsed
}
for (const [key, value] of Object.entries(map)) {
if (!SOC_KEY_RE.test(key)) continue
const parsed = parseSocNumber(value)
if (parsed != null) return parsed
}
return null
}
@@ -0,0 +1,82 @@
import type { Car, CarState } from '@/types/car'
import { extractSocFromKv, normalizeSocRatio, parseSocNumber } from '@/utils/batterySoc'
type Kv = { key?: string | null; value?: string | null }
function kv(rows: Kv[] | null | undefined, ...keys: string[]): string {
if (!rows?.length) return ''
for (const key of keys) {
const hit = rows.find((r) => r.key === key)
if (hit?.value != null && hit.value !== '') return String(hit.value)
}
return ''
}
/**
* SimpleLite /projection/cars 会把「正常但未初始化」「导航失联」等一律标成 fault,
* 与地图标签/车体 AlarmLevel 不一致。这里用 lstatus + 车体 status 重新推导。
*/
export function deriveCarState(car: Pick<Car, 'state' | 'lstatus'>, statusRows?: Kv[] | null): CarState {
const lstatus = (car.lstatus ?? '').trim()
const alarmLevelRaw = kv(statusRows, '车体_AlarmLevel', 'AlarmLevel')
const alarmInfo = kv(statusRows, '车体_AlarmInfo', 'AlarmInfo').trim()
const riskAlarm = kv(statusRows, '车体_RiskPositionAlarm', 'RiskPositionAlarm')
const drive = kv(statusRows, '车体_driveStatus', 'driveStatus')
const openCharge = kv(statusRows, '车体_OpenChargeByClumsy', 'OpenChargeByClumsy')
const alarmLevel = Number(alarmLevelRaw)
const hasAlarm =
(Number.isFinite(alarmLevel) && alarmLevel > 0) ||
(/true/i.test(riskAlarm)) ||
(alarmInfo.length > 0 && !/^\/$|^-$|^none$/i.test(alarmInfo))
// 明确健康:以「正常」开头(含「正常但未初始化」)→ 绝不是故障
if (/^正常/.test(lstatus)) {
if (/充电/.test(lstatus)) return 'charging'
if (/暂停|挂起/.test(lstatus)) return 'paused'
if (/离线/.test(lstatus)) return 'offline'
if (hasAlarm) return 'fault'
if (/charg|充电/i.test(openCharge) && /true/i.test(openCharge)) return 'charging'
if (/Drive(Run|Move|Busy)|Running|运行/i.test(drive)) return 'running'
if (/DriveStop|Idle|空闲|停止/i.test(drive)) return 'idle'
// 未初始化但仍「正常」:视为空闲,而不是故障
return 'idle'
}
if (/离线|失联/.test(lstatus)) return 'offline'
if (/充电/.test(lstatus)) return 'charging'
if (/暂停|挂起/.test(lstatus)) return 'paused'
if (hasAlarm || (/故障|异常|检修/.test(lstatus) && !/正常/.test(lstatus))) return 'fault'
if (/运行|工作|执行|忙/.test(lstatus)) return 'running'
if (/空闲|待机/.test(lstatus)) return 'idle'
// 有车体 status 时,别盲信投影里的 fault
if (statusRows?.length) {
if (hasAlarm) return 'fault'
if (/Drive(Run|Move|Busy)|Running/i.test(drive)) return 'running'
if (/DriveStop|Idle/i.test(drive)) return 'idle'
}
// 投影已给非 fault 时保留;fault 且无佐证则降为 idle,避免「全员故障」误报
if (car.state && car.state !== 'fault') return car.state
return 'idle'
}
export function applyRuntimeEnrichment(car: Car, statusRows?: Kv[] | null): Car {
const socFromStatus = extractSocFromKv(statusRows)
let batterySoc: number
if (socFromStatus != null) {
batterySoc = normalizeSocRatio(socFromStatus)
} else if (statusRows != null) {
// 已拉到 status 但无 Soc(如纯模拟车)→ 清掉投影写死的 0.8,避免假电量
batterySoc = 0
} else {
batterySoc = parseSocNumber(car.batterySoc) ?? 0
}
return {
...car,
batterySoc,
state: deriveCarState(car, statusRows)
}
}
@@ -0,0 +1,43 @@
export const dateShortcuts = [
{ text: '今天', value: () => { const s = formatDate(new Date()); return [s, s] as [string, string] } },
{ text: '近 7 天', value: () => rangeDays(6) },
{ text: '近 30 天', value: () => rangeDays(29) }
]
export function rangeDays(n: number): [string, string] {
const end = new Date()
const start = new Date()
start.setDate(end.getDate() - n)
return [formatDate(start), formatDate(end)]
}
export function formatDate(d: Date): string {
const p = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
}
export function parseTime(raw?: string | null): number | null {
if (!raw) return null
const t = Date.parse(raw)
return Number.isFinite(t) ? t : null
}
export function formatTime(raw?: string | null): string {
const t = parseTime(raw)
if (t == null) return '—'
const d = new Date(t)
const p = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
}
export function sortByTime(a?: string | null, b?: string | null) {
return (parseTime(a) ?? 0) - (parseTime(b) ?? 0)
}
export function isToday(raw?: string | null): boolean {
const t = parseTime(raw)
if (t == null) return false
const d = new Date(t)
const now = new Date()
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate()
}
@@ -0,0 +1,373 @@
<template>
<div class="alarm-mgmt-page">
<!-- 统计条 -->
<header class="am-stats">
<div class="am-stat">
<span class="am-stat-label">当前活跃</span>
<span class="am-stat-value" :class="{ 'am-danger': counts.active > 0 }"><b>{{ counts.active }}</b></span>
</div>
<div class="am-stat-sep" aria-hidden="true" />
<div class="am-stat">
<span class="am-stat-label">今日新增</span>
<span class="am-stat-value"><b>{{ counts.today }}</b></span>
</div>
<div class="am-stat">
<span class="am-stat-label">已恢复</span>
<span class="am-stat-value"><b>{{ counts.cleared }}</b></span>
</div>
<div class="am-stat">
<span class="am-stat-label">涉及车辆</span>
<span class="am-stat-value"><b>{{ counts.cars }}</b></span>
</div>
<div class="am-stats-actions">
<span class="am-live" :class="{ 'is-live': autoRefresh }" @click="autoRefresh = !autoRefresh">
{{ autoRefresh ? '自动刷新' : '已暂停' }}
</span>
<el-button :icon="Refresh" :loading="loading" @click="reload">刷新</el-button>
</div>
</header>
<el-alert
v-if="!online"
type="warning"
show-icon
:closable="false"
class="am-offline"
title="SimpleLite 未连接:以下为平台最近一次采集的报警快照。"
:description="lastSyncAt ? `最近同步:${formatTime(lastSyncAt)}` : '暂无同步记录'"
/>
<!-- 筛选条 -->
<div class="am-toolbar">
<el-input v-model="search" clearable placeholder="搜索车辆 / 报警信息" class="am-search" :prefix-icon="Search" />
<el-select v-model="carFilter" clearable filterable placeholder="车辆" class="am-car">
<el-option v-for="c in carOptions" :key="c.value" :label="c.label" :value="c.value" />
</el-select>
<el-select v-model="levelFilter" clearable placeholder="级别" class="am-level">
<el-option label="严重" :value="'danger'" />
<el-option label="警告" :value="'warning'" />
<el-option label="提示" :value="'info'" />
</el-select>
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
start-placeholder="首次起"
end-placeholder="首次止"
class="am-date"
:shortcuts="dateShortcuts"
unlink-panels
/>
<span class="am-count">{{ filteredRows.length }} / {{ rows.length }}</span>
</div>
<!-- 快捷芯片 -->
<div class="am-chips" role="tablist">
<button
v-for="chip in statusChips"
:key="chip.key"
type="button"
class="am-chip"
:class="{ 'is-active': quickStatus === chip.key }"
@click="quickStatus = chip.key"
>
{{ chip.label }}<b>{{ chip.count }}</b>
</button>
</div>
<!-- 报警表 -->
<div v-loading="loading" class="am-table-wrap">
<el-table
:data="filteredRows"
stripe
height="100%"
highlight-current-row
:row-class-name="rowClassName"
empty-text="暂无匹配报警"
>
<el-table-column label="车辆" width="140" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ row.carName || `#${row.carId}` }}</span>
</template>
</el-table-column>
<el-table-column label="级别" width="90" align="center">
<template #default="{ row }">
<el-tag size="small" :type="levelTagType(row.level)" effect="plain">{{ levelLabel(row.level) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="报警信息" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span class="am-info">{{ row.info || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="96" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.status === 'active' ? 'danger' : 'info'" effect="plain">
{{ row.status === 'active' ? '活跃' : '已恢复' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="首次触发" width="160" sortable :sort-method="(a, b) => sortByTime(a.firstAt, b.firstAt)">
<template #default="{ row }">{{ formatTime(row.firstAt) }}</template>
</el-table-column>
<el-table-column label="最近更新" width="160">
<template #default="{ row }">{{ formatTime(row.lastAt) }}</template>
</el-table-column>
<el-table-column label="恢复时间" width="160">
<template #default="{ row }">{{ formatTime(row.resolvedAt) }}</template>
</el-table-column>
<el-table-column label="持续时长" width="110" align="right">
<template #default="{ row }">{{ durationText(row) }}</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click.stop="locateOnMap(row)">定位</el-button>
</template>
</el-table-column>
</el-table>
</div>
<p class="am-footnote">
报警来自车体状态(车体_AlarmInfo / 车体_AlarmLevel),由平台按车对帐记录:出现即开、消失即恢复,历史长期保留。
</p>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Refresh, Search } from '@element-plus/icons-vue'
import { fetchAlarmFeed } from '@/api/alarm'
import type { VehicleAlarm } from '@/types/alarm'
import { dateShortcuts, formatTime, isToday, parseTime, sortByTime } from '@/utils/dateTime'
type QuickKey = 'all' | 'active' | 'cleared'
type TagType = 'success' | 'warning' | 'danger' | 'info' | 'primary'
type LevelKey = 'danger' | 'warning' | 'info'
const router = useRouter()
const rows = ref<VehicleAlarm[]>([])
const loading = ref(false)
const autoRefresh = ref(true)
const online = ref(true)
const lastSyncAt = ref<string | null>(null)
const search = ref('')
const carFilter = ref<number | null>(null)
const levelFilter = ref<LevelKey | null>(null)
const quickStatus = ref<QuickKey>('all')
const dateRange = ref<[string, string] | null>(null)
let pollTimer: ReturnType<typeof setInterval> | null = null
function levelKeyOf(level: number): LevelKey {
if (level >= 2) return 'danger'
if (level === 1) return 'warning'
return 'info'
}
function levelLabel(level: number): string {
return { danger: '严重', warning: '警告', info: '提示' }[levelKeyOf(level)]
}
function levelTagType(level: number): TagType {
return levelKeyOf(level)
}
function durationText(row: VehicleAlarm): string {
let secs = row.durationSecs ?? null
if (secs == null && row.status === 'active') {
const t = parseTime(row.firstAt)
if (t != null) secs = Math.max(0, Math.round((Date.now() - t) / 1000))
}
if (secs == null) return '—'
if (secs < 60) return `${secs}`
const m = Math.floor(secs / 60)
const s = secs % 60
if (m < 60) return s ? `${m}${s}` : `${m}`
const h = Math.floor(m / 60)
return `${h}${m % 60}`
}
function inDateRange(row: VehicleAlarm): boolean {
if (!dateRange.value) return true
const t = parseTime(row.firstAt)
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 carOptions = computed(() => {
const map = new Map<number, string>()
for (const r of rows.value) if (!map.has(r.carId)) map.set(r.carId, r.carName || `#${r.carId}`)
return [...map.entries()].map(([value, label]) => ({ value, label })).sort((a, b) => a.value - b.value)
})
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 === 'active' && row.status !== 'active') return false
if (quickStatus.value === 'cleared' && row.status !== 'cleared') return false
if (carFilter.value != null && row.carId !== carFilter.value) return false
if (levelFilter.value && levelKeyOf(row.level) !== levelFilter.value) return false
if (!inDateRange(row)) return false
if (!tokens.length) return true
const hay = [row.carName, String(row.carId), row.info, String(row.level)].join(' ').toLowerCase()
return tokens.every((tok) => hay.includes(tok))
})
})
const counts = computed(() => ({
active: rows.value.filter((r) => r.status === 'active').length,
cleared: rows.value.filter((r) => r.status === 'cleared').length,
today: rows.value.filter((r) => isToday(r.firstAt)).length,
cars: new Set(rows.value.filter((r) => r.status === 'active').map((r) => r.carId)).size
}))
const statusChips = computed(() => [
{ key: 'all' as const, label: '全部', count: rows.value.length },
{ key: 'active' as const, label: '活跃', count: counts.value.active },
{ key: 'cleared' as const, label: '已恢复', count: counts.value.cleared }
])
function rowClassName({ row }: { row: VehicleAlarm }) {
return row.status === 'active' ? 'is-active-alarm-row' : ''
}
function locateOnMap(row: VehicleAlarm) {
if (!row.carId) { ElMessage.info('无车辆信息'); return }
void router.push({ path: '/admin/map-monitor', query: { focusKind: 'car', focusId: String(row.carId) } })
}
async function reload() {
loading.value = true
try {
const feed = await fetchAlarmFeed(2000)
rows.value = feed.alarms
online.value = feed.online
lastSyncAt.value = feed.lastSyncAt
} catch (err) {
ElMessage.error(`加载报警失败:${(err as Error).message}`)
} finally {
loading.value = false
}
}
onMounted(() => {
void reload()
pollTimer = setInterval(() => { if (autoRefresh.value) void reload() }, 6000)
})
onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer)
})
</script>
<style scoped>
.alarm-mgmt-page {
display: flex;
flex-direction: column;
gap: 10px;
height: calc(100vh - 56px - 36px - 32px);
min-height: 0;
color: var(--mg-text-light);
}
.am-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;
}
.am-stat { display: flex; flex-direction: column; gap: 3px; min-width: 56px; }
.am-stat-label { font-size: 11px; color: var(--mg-text-muted); white-space: nowrap; }
.am-stat-value {
font-family: var(--mg-font-mono);
font-variant-numeric: tabular-nums;
font-size: 17px;
font-weight: 650;
line-height: 1.1;
}
.am-stat-value.am-danger b { color: var(--mg-status-danger); }
.am-stat-sep { width: 1px; height: 30px; background: var(--mg-veil-border); flex: none; }
.am-stats-actions { margin-left: auto; display: flex; align-items: center; gap: 10px; flex: none; }
.am-live {
font-size: 12px;
color: var(--mg-text-muted);
cursor: pointer;
padding: 2px 10px;
border-radius: 999px;
border: 1px solid var(--mg-veil-border);
user-select: none;
}
.am-live.is-live { color: var(--mg-status-success); }
.am-offline { flex: none; }
.am-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);
}
.am-search { width: min(260px, 100%); }
.am-car { width: 150px; }
.am-level { width: 120px; }
.am-date { width: 250px; }
.am-count { margin-left: auto; font-size: 12px; color: var(--mg-text-muted); font-variant-numeric: tabular-nums; }
.am-chips { flex: none; display: flex; gap: 8px; flex-wrap: wrap; }
.am-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;
}
.am-chip b { font-family: var(--mg-font-mono); font-variant-numeric: tabular-nums; color: var(--mg-text-light); }
.am-chip:hover { color: var(--mg-text-light); background: var(--mg-veil-2); }
.am-chip.is-active {
color: var(--mg-primary);
border-color: rgba(var(--mg-primary-rgb), 0.45);
background: rgba(var(--mg-primary-rgb), 0.12);
}
.am-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;
}
.am-table-wrap :deep(.is-active-alarm-row) { --el-table-tr-bg-color: rgba(239, 68, 68, 0.08); }
.am-info { color: var(--mg-status-danger); }
.am-footnote { flex: none; margin: 0; font-size: 12px; color: var(--mg-text-muted); }
@media (max-width: 960px) {
.am-search, .am-date { width: 100%; }
}
</style>
@@ -0,0 +1,712 @@
<template>
<div class="task-mgmt-page">
<!-- 统计条 -->
<header class="tm-stats">
<div class="tm-stat">
<span class="tm-stat-label">任务总数</span>
<span class="tm-stat-value"><b>{{ rows.length }}</b></span>
</div>
<div class="tm-stat-sep" aria-hidden="true" />
<div class="tm-stat">
<span class="tm-stat-label">等待</span>
<span class="tm-stat-value"><b>{{ counts.waiting }}</b></span>
</div>
<div class="tm-stat">
<span class="tm-stat-label">执行中</span>
<span class="tm-stat-value tm-ok"><b>{{ counts.running }}</b></span>
</div>
<div class="tm-stat">
<span class="tm-stat-label">已完成</span>
<span class="tm-stat-value"><b>{{ counts.done }}</b></span>
</div>
<div class="tm-stat">
<span class="tm-stat-label">异常</span>
<span class="tm-stat-value" :class="{ 'tm-danger': counts.error > 0 }"><b>{{ counts.error }}</b></span>
</div>
<div class="tm-stat">
<span class="tm-stat-label">超时</span>
<span class="tm-stat-value" :class="{ 'tm-warn': counts.overdue > 0 }"><b>{{ counts.overdue }}</b></span>
</div>
<div class="tm-stats-actions">
<span class="tm-live" :class="{ 'is-live': autoRefresh }" @click="autoRefresh = !autoRefresh">
{{ autoRefresh ? '自动刷新' : '已暂停' }}
</span>
<el-button :icon="Refresh" :loading="loading" @click="reload">刷新</el-button>
<el-button type="primary" :icon="Plus" @click="openCreate">新建任务</el-button>
</div>
</header>
<el-alert
v-if="!online"
type="warning"
show-icon
:closable="false"
class="tm-offline"
title="SimpleLite 未连接:以下为平台最近一次快照;取消/重发/新建等操作需 SimpleLite 在线,离线执行会提示失败。"
:description="lastSyncAt ? `最近同步:${formatTime(lastSyncAt)}` : '暂无同步记录'"
/>
<!-- 筛选条 -->
<div class="tm-toolbar">
<el-input
v-model="search"
clearable
placeholder="搜索任务号 / 单号 / 站点 / 车辆 / 进程"
class="tm-search"
:prefix-icon="Search"
/>
<el-select v-model="statusFilter" clearable placeholder="状态" class="tm-status">
<el-option v-for="opt in statusOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
start-placeholder="下发起"
end-placeholder="下发止"
class="tm-date"
:shortcuts="dateShortcuts"
unlink-panels
/>
<el-checkbox v-model="includeFinished">含已完成</el-checkbox>
<el-checkbox v-model="includeAborted">含取消/异常</el-checkbox>
<span class="tm-count">{{ filteredRows.length }} / {{ rows.length }}</span>
</div>
<!-- 快捷芯片 -->
<div class="tm-chips" role="tablist">
<button
v-for="chip in statusChips"
:key="chip.key"
type="button"
class="tm-chip"
:class="{ 'is-active': quickStatus === chip.key }"
@click="quickStatus = chip.key"
>
{{ chip.label }}<b>{{ chip.count }}</b>
</button>
</div>
<!-- 任务表 -->
<div v-loading="loading" class="tm-table-wrap">
<el-table
:data="filteredRows"
stripe
height="100%"
highlight-current-row
:row-class-name="rowClassName"
empty-text="暂无匹配任务"
@row-click="openDetail"
>
<el-table-column label="任务号" min-width="150" show-overflow-tooltip>
<template #default="{ row }">
<div class="tm-idcell">
<span class="tm-id">{{ row.id }}</span>
<span v-if="row.taskId" class="tm-taskid">单号 {{ row.taskId }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="取货点" min-width="130" show-overflow-tooltip>
<template #default="{ row }">{{ row.srcLabel || `站点 ${row.srcSiteId}` }}</template>
</el-table-column>
<el-table-column label="放货点" min-width="130" show-overflow-tooltip>
<template #default="{ row }">{{ row.dstLabel || `站点 ${row.dstSiteId}` }}</template>
</el-table-column>
<el-table-column label="车辆" width="110" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.carName || row.carId">{{ row.carName || `#${row.carId}` }}</span>
<span v-else class="muted">未分配</span>
</template>
</el-table-column>
<el-table-column label="状态" width="96" align="center">
<template #default="{ row }">
<el-tag size="small" :type="statusTagType(row.statusCode)" effect="plain">
{{ row.status || row.statusCode }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="priority" label="优先级" width="76" align="center" sortable />
<el-table-column label="下发时间" width="160" sortable :sort-method="(a, b) => sortByTime(a.createTime, b.createTime)">
<template #default="{ row }">{{ formatTime(row.createTime) }}</template>
</el-table-column>
<el-table-column label="结束时间" width="160">
<template #default="{ row }">{{ formatTime(row.finishTime) }}</template>
</el-table-column>
<el-table-column label="卡住原因" min-width="120" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.stuckReason" class="tm-stuck">{{ row.stuckReason }}</span>
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }">
<div class="tm-ops">
<el-button link type="primary" size="small" @click.stop="openDetail(row)">详情</el-button>
<el-button link type="warning" size="small" :disabled="!canCancel(row) || acting" @click.stop="runAction(row, 'cancel')">取消</el-button>
<el-button link type="primary" size="small" :disabled="acting" @click.stop="runAction(row, 'resend')">重发</el-button>
<el-dropdown trigger="click" @command="(cmd: string) => onMore(row, cmd)">
<el-button link type="info" size="small" @click.stop>
更多<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="force-complete" :disabled="!canCancel(row)">强制完成</el-dropdown-item>
<el-dropdown-item command="pause" :disabled="!canPause(row)">暂停</el-dropdown-item>
<el-dropdown-item command="resume" :disabled="!canResume(row)">恢复</el-dropdown-item>
<el-dropdown-item command="change-car" :disabled="!canChangeCar(row)">换车</el-dropdown-item>
<el-dropdown-item command="priority" divided>改优先级</el-dropdown-item>
<el-dropdown-item command="locate" :disabled="!row.carId">地图定位</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
</div>
<p class="tm-footnote">
数据来自 CDM 搬运任务投影(StandardScene TransportDelivery)。超时 = 下发超过 30 分钟仍未结束。
</p>
<!-- 详情抽屉 -->
<el-drawer
v-model="detailVisible"
modal-class="tm-detail-drawer-modal"
title="任务详情"
size="420px"
:with-header="true"
>
<div v-if="detailRow" class="tm-detail">
<div v-for="item in detailItems" :key="item.k" class="tm-detail-row">
<span class="tm-detail-k">{{ item.k }}</span>
<span class="tm-detail-v" :class="item.cls">{{ item.v }}</span>
</div>
</div>
</el-drawer>
<!-- 新建任务弹窗 -->
<el-dialog v-model="createVisible" title="新建搬运任务" width="460px" destroy-on-close append-to-body>
<el-form label-width="80px" class="tm-create-form">
<el-form-item label="取货点">
<el-select v-model="createForm.src" filterable clearable placeholder="选择取货站点" class="tm-create-select">
<el-option v-for="s in siteOptions" :key="s.value" :label="s.label" :value="s.value" />
</el-select>
</el-form-item>
<el-form-item label="放货点">
<el-select v-model="createForm.dst" filterable clearable placeholder="选择放货站点" class="tm-create-select">
<el-option v-for="s in siteOptions" :key="s.value" :label="s.label" :value="s.value" />
</el-select>
</el-form-item>
<el-form-item label="优先级">
<el-input-number v-model="createForm.priority" :min="0" :max="999" />
</el-form-item>
<el-form-item label="车型">
<el-input v-model="createForm.carType" placeholder="默认 Car" />
</el-form-item>
<el-form-item label="外部单号">
<el-input v-model="createForm.taskId" placeholder="可选外部系统单号" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="creating" :disabled="!createForm.src && !createForm.dst" @click="submitCreate">
下发
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowDown, Plus, Refresh, Search } from '@element-plus/icons-vue'
import {
cancelDelivery,
changeCarDelivery,
createDelivery,
fetchCdmTaskFeed,
forceCompleteDelivery,
pauseDelivery,
resendDelivery,
resumeDelivery,
setDeliveryPriority
} from '@/api/delivery'
import { listSites } from '@/api/projection'
import type { DeliveryTask } from '@/types/delivery'
import { dateShortcuts, formatTime, parseTime, sortByTime } from '@/utils/dateTime'
type QuickKey = 'all' | 'waiting' | 'running' | 'done' | 'aborted' | 'error'
type TagType = 'success' | 'warning' | 'danger' | 'info' | 'primary'
type MoreCmd = 'force-complete' | 'pause' | 'resume' | 'change-car' | 'priority' | 'locate'
const router = useRouter()
const rows = ref<DeliveryTask[]>([])
const loading = ref(false)
const acting = ref(false)
const autoRefresh = ref(true)
const online = ref(true)
const lastSyncAt = ref<string | null>(null)
const search = ref('')
const statusFilter = ref<string | null>(null)
const quickStatus = ref<QuickKey>('all')
const dateRange = ref<[string, string] | null>(null)
const includeFinished = ref(true)
const includeAborted = ref(true)
const detailVisible = ref(false)
const detailRow = ref<DeliveryTask | null>(null)
const createVisible = ref(false)
const creating = ref(false)
const createForm = reactive<{ src?: number; dst?: number; priority: number; carType: string; taskId: string }>({
src: undefined,
dst: undefined,
priority: 0,
carType: '',
taskId: ''
})
const siteOptions = ref<{ value: number; label: string }[]>([])
let pollTimer: ReturnType<typeof setInterval> | null = null
const statusOptions = [
{ value: 'Waiting', label: '等待 / 已下发' },
{ value: 'Fetching', label: '取货中' },
{ value: 'Putting', label: '放货中' },
{ value: 'Finished', label: '已完成' },
{ value: 'Canceled', label: '已取消' },
{ value: 'Terminated', label: '已终止' },
{ value: 'Error', label: '异常' },
{ value: 'Suspended', label: '挂起' }
]
function isTerminal(code: string) {
return ['Finished', 'Canceled', 'Terminated', 'Error'].includes(code)
}
function isRunning(code: string) {
return code === 'Fetching' || code === 'Putting'
}
function isAborted(code: string) {
return code === 'Canceled' || code === 'Terminated'
}
function matchesQuick(row: DeliveryTask, key: QuickKey): boolean {
const c = row.statusCode
switch (key) {
case 'waiting': return c === 'Waiting' || c === 'Suspended'
case 'running': return isRunning(c)
case 'done': return c === 'Finished'
case 'aborted': return isAborted(c)
case 'error': return c === 'Error'
default: return true
}
}
function inDateRange(row: DeliveryTask): boolean {
if (!dateRange.value) return true
const t = parseTime(row.createTime)
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 filteredRows = computed(() => {
const q = search.value.trim().toLowerCase()
const tokens = q ? q.split(/\s+/).filter(Boolean) : []
return rows.value.filter((row) => {
if (!includeFinished.value && row.statusCode === 'Finished') return false
if (!includeAborted.value && (isAborted(row.statusCode) || row.statusCode === 'Error')) return false
if (statusFilter.value && row.statusCode !== statusFilter.value) return false
if (!matchesQuick(row, quickStatus.value)) return false
if (!inDateRange(row)) return false
if (!tokens.length) return true
const hay = [
row.id, row.taskId ?? '', row.srcLabel, row.dstLabel, row.status, row.statusCode,
row.carName ?? '', String(row.carId ?? ''), row.missionName, row.missionTypeName
].join(' ').toLowerCase()
return tokens.every((tok) => hay.includes(tok))
})
})
const counts = computed(() => ({
waiting: rows.value.filter((r) => matchesQuick(r, 'waiting')).length,
running: rows.value.filter((r) => matchesQuick(r, 'running')).length,
done: rows.value.filter((r) => r.statusCode === 'Finished').length,
error: rows.value.filter((r) => r.statusCode === 'Error').length,
overdue: rows.value.filter((r) => r.overdue).length
}))
const statusChips = computed(() => {
const base = rows.value
const count = (key: QuickKey) => base.filter((r) => matchesQuick(r, key)).length
return [
{ key: 'all' as const, label: '全部', count: base.length },
{ key: 'waiting' as const, label: '等待', count: count('waiting') },
{ key: 'running' as const, label: '执行中', count: count('running') },
{ key: 'done' as const, label: '已完成', count: count('done') },
{ key: 'aborted' as const, label: '取消/终止', count: count('aborted') },
{ key: 'error' as const, label: '异常', count: count('error') }
]
})
function statusTagType(code: string): TagType {
switch (code) {
case 'Fetching':
case 'Putting': return 'success'
case 'Waiting':
case 'Suspended': return 'primary'
case 'Finished': return 'info'
case 'Canceled':
case 'Terminated': return 'warning'
case 'Error': return 'danger'
default: return 'info'
}
}
function rowClassName({ row }: { row: DeliveryTask }) {
return row.overdue ? 'is-overdue-row' : ''
}
function canCancel(row: DeliveryTask) { return !isTerminal(row.statusCode) }
function canPause(row: DeliveryTask) { return isRunning(row.statusCode) }
function canResume(row: DeliveryTask) { return row.statusCode === 'Terminated' || row.statusCode === 'Suspended' }
function canChangeCar(row: DeliveryTask) {
return row.statusCode === 'Suspended' || row.statusCode === 'Waiting'
}
async function reload() {
loading.value = true
try {
const feed = await fetchCdmTaskFeed(2000)
rows.value = feed.tasks
online.value = feed.online
lastSyncAt.value = feed.lastSyncAt
} catch (err) {
ElMessage.error(`加载任务失败:${(err as Error).message}`)
} finally {
loading.value = false
}
}
const actionLabels: Record<string, string> = {
cancel: '取消任务',
resend: '重发任务',
'force-complete': '强制完成',
pause: '暂停任务',
resume: '恢复任务',
'change-car': '换车'
}
async function runAction(row: DeliveryTask, action: string) {
try {
await ElMessageBox.confirm(
`确定对任务 ${row.id}${row.srcLabel}${row.dstLabel})执行「${actionLabels[action]}」?`,
'确认操作',
{ type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' }
)
} catch {
return
}
acting.value = true
try {
if (action === 'cancel') await cancelDelivery(row.id)
else if (action === 'resend') await resendDelivery(row.id)
else if (action === 'force-complete') await forceCompleteDelivery(row.id)
else if (action === 'pause') await pauseDelivery(row.id)
else if (action === 'resume') await resumeDelivery(row.id)
else if (action === 'change-car') await changeCarDelivery(row.id)
ElMessage.success(`${actionLabels[action]} 已提交`)
await reload()
} catch (err) {
ElMessage.error(`${actionLabels[action]} 失败:${(err as Error).message}`)
} finally {
acting.value = false
}
}
async function onMore(row: DeliveryTask, cmd: string) {
const c = cmd as MoreCmd
if (c === 'locate') { locateOnMap(row); return }
if (c === 'priority') { await editPriority(row); return }
await runAction(row, c)
}
async function editPriority(row: DeliveryTask) {
try {
const { value } = await ElMessageBox.prompt('设置任务优先级(数值越大越优先)', '改优先级', {
inputValue: String(row.priority ?? 0),
inputPattern: /^-?\d+$/,
inputErrorMessage: '请输入整数',
confirmButtonText: '保存',
cancelButtonText: '取消'
})
acting.value = true
await setDeliveryPriority(row.id, Number(value))
ElMessage.success('优先级已更新')
await reload()
} catch (err) {
if (err !== 'cancel') ElMessage.error(`更新失败:${(err as Error).message}`)
} finally {
acting.value = false
}
}
function locateOnMap(row: DeliveryTask) {
if (!row.carId) { ElMessage.info('该任务尚未分配车辆'); return }
void router.push({ path: '/admin/map-monitor', query: { focusKind: 'car', focusId: String(row.carId) } })
}
function openDetail(row: DeliveryTask) {
detailRow.value = row
detailVisible.value = true
}
const detailItems = computed(() => {
const r = detailRow.value
if (!r) return [] as { k: string; v: string; cls?: string }[]
return [
{ k: '任务号', v: r.id },
{ k: '外部单号', v: r.taskId || '—' },
{ k: '状态', v: r.status || r.statusCode },
{ k: '取货点', v: r.srcLabel || String(r.srcSiteId) },
{ k: '放货点', v: r.dstLabel || String(r.dstSiteId) },
{ k: '车辆', v: r.carName || (r.carId ? `#${r.carId}` : '未分配') },
{ k: '优先级', v: String(r.priority ?? 0) },
{ k: '进程', v: r.missionName || r.missionTypeName || '—' },
{ k: '下发时间', v: formatTime(r.createTime) },
{ k: '开始时间', v: formatTime(r.startTime) },
{ k: '结束时间', v: formatTime(r.finishTime) },
{ k: '卡住原因', v: r.stuckReason || '—', cls: r.stuckReason ? 'tm-danger-text' : undefined },
{ k: '是否超时', v: r.overdue ? '是' : '否', cls: r.overdue ? 'tm-warn-text' : undefined }
]
})
async function loadSites() {
try {
const sites = await listSites()
siteOptions.value = sites
.map((s) => {
const num = parseInt(String(s.id).replace(/\D/g, ''), 10)
return { value: num, label: `${num} · ${s.name || '未命名'}` }
})
.filter((o) => Number.isFinite(o.value))
.sort((a, b) => a.value - b.value)
} catch {
siteOptions.value = []
}
}
function openCreate() {
createForm.src = undefined
createForm.dst = undefined
createForm.priority = 0
createForm.carType = ''
createForm.taskId = ''
if (!siteOptions.value.length) void loadSites()
createVisible.value = true
}
async function submitCreate() {
if (!createForm.src && !createForm.dst) {
ElMessage.warning('取货点或放货点至少填一个')
return
}
creating.value = true
try {
const res = await createDelivery({
src: createForm.src ?? 0,
dst: createForm.dst ?? 0,
priority: createForm.priority || 0,
carType: createForm.carType.trim() || undefined,
taskId: createForm.taskId.trim() || undefined
})
ElMessage.success(`任务已下发${res.id ? `${res.id}` : ''}`)
createVisible.value = false
await reload()
} catch (err) {
ElMessage.error(`下发失败:${(err as Error).message}`)
} finally {
creating.value = false
}
}
onMounted(() => {
void reload()
void loadSites()
pollTimer = setInterval(() => {
if (autoRefresh.value && !createVisible.value && !acting.value) void reload()
}, 6000)
})
onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer)
})
</script>
<style scoped>
.task-mgmt-page {
display: flex;
flex-direction: column;
gap: 10px;
height: calc(100vh - 56px - 36px - 32px);
min-height: 0;
color: var(--mg-text-light);
}
.tm-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;
}
.tm-stat { display: flex; flex-direction: column; gap: 3px; min-width: 56px; }
.tm-stat-label { font-size: 11px; color: var(--mg-text-muted); white-space: nowrap; }
.tm-stat-value {
font-family: var(--mg-font-mono);
font-variant-numeric: tabular-nums;
font-size: 17px;
font-weight: 650;
line-height: 1.1;
}
.tm-stat-value.tm-ok b { color: var(--mg-status-success); }
.tm-stat-value.tm-warn b { color: var(--mg-status-warning); }
.tm-stat-value.tm-danger b { color: var(--mg-status-danger); }
.tm-stat-sep { width: 1px; height: 30px; background: var(--mg-veil-border); flex: none; }
.tm-stats-actions { margin-left: auto; display: flex; align-items: center; gap: 10px; flex: none; }
.tm-live {
font-size: 12px;
color: var(--mg-text-muted);
cursor: pointer;
padding: 2px 10px;
border-radius: 999px;
border: 1px solid var(--mg-veil-border);
user-select: none;
}
.tm-live.is-live { color: var(--mg-status-success); border-color: rgba(var(--mg-status-success-rgb, 34,197,94), 0.4); }
.tm-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);
}
.tm-search { width: min(280px, 100%); }
.tm-status { width: 130px; }
.tm-date { width: 250px; }
.tm-count {
margin-left: auto;
font-size: 12px;
color: var(--mg-text-muted);
font-variant-numeric: tabular-nums;
}
.tm-chips { flex: none; display: flex; gap: 8px; flex-wrap: wrap; }
.tm-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;
}
.tm-chip b { font-family: var(--mg-font-mono); font-variant-numeric: tabular-nums; color: var(--mg-text-light); }
.tm-chip:hover { color: var(--mg-text-light); background: var(--mg-veil-2); }
.tm-chip.is-active {
color: var(--mg-primary);
border-color: rgba(var(--mg-primary-rgb), 0.45);
background: rgba(var(--mg-primary-rgb), 0.12);
}
.tm-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;
}
.tm-table-wrap :deep(.is-overdue-row) { --el-table-tr-bg-color: rgba(245, 158, 11, 0.08); }
.tm-table-wrap :deep(.el-table__row) { cursor: pointer; }
.tm-idcell { display: flex; flex-direction: column; line-height: 1.25; }
.tm-id { font-family: var(--mg-font-mono); font-size: 12px; }
.tm-taskid { font-size: 11px; color: var(--mg-text-muted); }
.tm-stuck { color: var(--mg-status-danger); }
.muted { color: var(--mg-text-muted); }
/* 操作列:flex 均匀间距(el-dropdown 会打断 el-button 相邻 margin,导致「重发」和「更多」贴住);
本主题把 primary 的 link 按钮渲染成实心紫底+白字,需强制成透明底+彩色文字,四个按钮风格统一 */
.tm-ops {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: nowrap;
}
.tm-ops :deep(.el-button) { margin: 0; }
.tm-ops :deep(.el-button.is-link) {
margin-left: 0;
padding: 0;
height: auto;
min-height: 0;
border: none;
background: transparent !important;
box-shadow: none !important;
vertical-align: middle;
font-size: 13px;
}
.tm-ops :deep(.el-button--primary.is-link) { color: var(--mg-primary) !important; }
.tm-ops :deep(.el-button--warning.is-link) { color: var(--mg-status-warning) !important; }
.tm-ops :deep(.el-button--info.is-link) { color: var(--mg-text-muted) !important; }
.tm-ops :deep(.el-button.is-link.is-disabled) { color: var(--mg-text-muted) !important; opacity: 0.45; }
.tm-ops :deep(.el-button.is-link:not(.is-disabled):hover) { text-decoration: underline; }
.tm-ops :deep(.el-dropdown) { line-height: 1; }
.tm-offline { flex: none; }
.tm-footnote { flex: none; margin: 0; font-size: 12px; color: var(--mg-text-muted); }
.tm-detail { display: flex; flex-direction: column; gap: 2px; }
.tm-detail-row {
display: grid;
grid-template-columns: 88px 1fr;
gap: 8px;
padding: 8px 4px;
border-bottom: 1px solid var(--mg-veil-border);
}
.tm-detail-k { color: var(--mg-text-muted); font-size: 13px; }
.tm-detail-v { font-size: 13px; word-break: break-all; }
.tm-detail-v.tm-danger-text { color: var(--mg-status-danger); }
.tm-detail-v.tm-warn-text { color: var(--mg-status-warning); }
.tm-create-form { padding-right: 8px; }
.tm-create-select { width: 100%; }
@media (max-width: 960px) {
.tm-search, .tm-date { width: 100%; }
}
</style>