新增任务管理与报警管理前端,并接入车队健康数据。
运营菜单下挂地图监控/任务/报警入口,车辆卡片与任务列表同步展示运行态。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -34,6 +34,26 @@ public class HealthController : ControllerBase
|
||||
[Authorize]
|
||||
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>
|
||||
/// 关闭 SimpleLite、同步最新 DLL、重新拉起。用于「前往站点」API 缺失时一键更新。
|
||||
/// 会终止本机全部 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 type { DeliveryTask } from '@/types/delivery'
|
||||
import type { CreateDeliveryPayload, DeliveryTask } from '@/types/delivery'
|
||||
|
||||
const MOCK = import.meta.env.VITE_USE_MOCK === 'true'
|
||||
|
||||
@@ -20,17 +20,72 @@ export async function listDeliveries(opts?: {
|
||||
return Array.isArray(data) ? data : []
|
||||
}
|
||||
|
||||
export async function cancelDelivery(id: number): Promise<void> {
|
||||
if (MOCK) return
|
||||
await http.post(`${BASE}/${id}/cancel`)
|
||||
/** CDM 任务快照订阅结果:来自平台库 cdm_tasks(SimpleLite 关闭时仍可读,含完整历史)。 */
|
||||
export interface CdmTaskFeed {
|
||||
online: boolean
|
||||
lastSyncAt: string | null
|
||||
tasks: DeliveryTask[]
|
||||
}
|
||||
|
||||
export async function resendDelivery(id: number): Promise<void> {
|
||||
if (MOCK) return
|
||||
await http.post(`${BASE}/${id}/resend`)
|
||||
/**
|
||||
* 任务页数据源:优先读平台快照库 /fleet/tasks(离线可读 + 历史保留);
|
||||
* 若平台端点不可用则回退到实时投影 /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
|
||||
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))
|
||||
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')
|
||||
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-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>
|
||||
<template #default="{ row }">
|
||||
<span class="cell-ellipsis" :title="row.srcLabel">{{ shortSite(row.srcLabel) }}</span>
|
||||
@@ -103,7 +103,7 @@ import {
|
||||
|
||||
const props = defineProps<{
|
||||
deliveries: DeliveryTask[]
|
||||
selectedId?: number | null
|
||||
selectedId?: string | null
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
@@ -234,7 +234,10 @@ function hideCtx() {
|
||||
const actionLabels: Record<DeliveryAction, string> = {
|
||||
cancel: '取消任务',
|
||||
resend: '重发任务',
|
||||
'force-complete': '强制完成'
|
||||
'force-complete': '强制完成',
|
||||
pause: '暂停任务',
|
||||
resume: '恢复任务',
|
||||
'change-car': '更换车辆'
|
||||
}
|
||||
|
||||
async function runAction(action: DeliveryAction) {
|
||||
@@ -384,10 +387,12 @@ onUnmounted(() => document.removeEventListener('click', onDocClick))
|
||||
z-index: 9000;
|
||||
min-width: 128px;
|
||||
padding: 4px 0;
|
||||
background: #1e1e2e;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
background: #fffefd;
|
||||
border: 1px solid rgba(40, 33, 58, 0.1);
|
||||
border-radius: 12px;
|
||||
box-shadow:
|
||||
0 1px 2px rgba(40, 33, 58, 0.06),
|
||||
0 12px 28px rgba(54, 35, 78, 0.14);
|
||||
}
|
||||
.ctx-item {
|
||||
display: block;
|
||||
@@ -395,16 +400,17 @@ onUnmounted(() => document.removeEventListener('click', onDocClick))
|
||||
padding: 8px 14px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
color: #28213a;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.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 {
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
color: #b8b0c8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
+256
-20
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="vehicle-monitor">
|
||||
<div class="overview-row">
|
||||
<div class="vehicle-monitor" :class="{ 'is-compact': compact, 'is-table': table }">
|
||||
<div v-if="!compact && !table" class="overview-row">
|
||||
<div class="overview-item">
|
||||
<div class="overview-num">{{ cars.length }}</div>
|
||||
<div class="overview-label">总数</div>
|
||||
@@ -28,10 +28,11 @@
|
||||
v-model="search"
|
||||
size="small"
|
||||
clearable
|
||||
placeholder="搜索 ID / 名称 / 任务"
|
||||
:placeholder="table ? '搜索车辆编号/名称' : '搜索 ID / 名称 / 任务'"
|
||||
class="search"
|
||||
/>
|
||||
<el-select
|
||||
v-if="!table"
|
||||
v-model="filterState"
|
||||
size="small"
|
||||
placeholder="状态"
|
||||
@@ -47,7 +48,51 @@
|
||||
</el-select>
|
||||
</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
|
||||
v-for="car in filteredCars"
|
||||
:key="car.id"
|
||||
@@ -120,6 +165,10 @@ const props = defineProps<{
|
||||
cars: Car[]
|
||||
missions: Mission[]
|
||||
selectedId?: string | null
|
||||
/** 紧凑模式:隐藏顶部概览条 */
|
||||
compact?: boolean
|
||||
/** 表格模式:筛选芯片 + 四列表格(地图监控参考布局) */
|
||||
table?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -128,6 +177,8 @@ const emit = defineEmits<{
|
||||
|
||||
const search = ref('')
|
||||
const filterState = ref<CarState | ''>('')
|
||||
type QuickFilter = 'all' | 'running' | 'idle' | 'offline' | 'fault'
|
||||
const quickFilter = ref<QuickFilter>('all')
|
||||
|
||||
const stateOptions: { value: CarState; label: string }[] = [
|
||||
{ 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 chargingCount = computed(() => props.cars.filter((c) => c.state === 'charging').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 order = (s: MissionStatus) =>
|
||||
@@ -232,10 +293,16 @@ function missionForCar(car: Car): Mission | undefined {
|
||||
|
||||
function batteryPct(car: Car): number {
|
||||
const raw = car.batterySoc ?? 0
|
||||
if (!raw) return 0
|
||||
const pct = raw > 1 ? raw : raw * 100
|
||||
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 {
|
||||
if (p < 20) return '#f56c6c'
|
||||
if (p < 50) return '#e6a23c'
|
||||
@@ -248,11 +315,29 @@ function batteryLevel(p: number): 'low' | 'mid' | '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 q = search.value.trim().toLowerCase()
|
||||
const tokens = q ? q.split(/\s+/).filter(Boolean) : []
|
||||
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
|
||||
const mission = missionForCar(c)
|
||||
const hay = [
|
||||
@@ -263,7 +348,8 @@ const filteredCars = computed(() => {
|
||||
c.ip ?? '',
|
||||
stateLabel(c.state),
|
||||
mission?.name ?? '',
|
||||
mission ? missionStatusLabel(mission.status) : ''
|
||||
mission ? missionStatusLabel(mission.status) : '',
|
||||
positionText(c)
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
@@ -290,6 +376,24 @@ function detailIdForCar(car: Car): string {
|
||||
height: 100%;
|
||||
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 {
|
||||
display: grid;
|
||||
@@ -315,10 +419,10 @@ function detailIdForCar(car: Car): string {
|
||||
color: var(--mg-text-light);
|
||||
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.running { color: var(--mg-status-success); text-shadow: 0 0 12px rgba(var(--mg-status-success-rgb), 0.45); }
|
||||
.overview-num.charging { color: var(--mg-primary-hover); text-shadow: 0 0 12px rgba(var(--mg-primary-hover-rgb), 0.55); }
|
||||
.overview-num.fault { color: var(--mg-status-danger); text-shadow: 0 0 12px rgba(var(--mg-status-danger-rgb), 0.5); }
|
||||
.overview-num.online { color: var(--mg-accent); }
|
||||
.overview-num.running { color: var(--mg-status-success); }
|
||||
.overview-num.charging { color: var(--mg-primary-hover); }
|
||||
.overview-num.fault { color: var(--mg-status-danger); }
|
||||
.overview-label {
|
||||
font-size: 11px;
|
||||
color: var(--mg-text-muted);
|
||||
@@ -333,6 +437,143 @@ function detailIdForCar(car: Car): string {
|
||||
.toolbar .search { flex: 1; min-width: 0; }
|
||||
.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 {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
@@ -400,17 +641,12 @@ function detailIdForCar(car: Car): string {
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
.dot-running { color: #6fcd45; box-shadow: 0 0 8px #6fcd45; animation: pulse 1.5s infinite; }
|
||||
.dot-charging { color: var(--mg-primary-hover); box-shadow: 0 0 8px var(--mg-primary-hover); }
|
||||
.dot-paused { color: #e6a23c; box-shadow: 0 0 6px #e6a23c; }
|
||||
.dot-fault { color: #ff7396; box-shadow: 0 0 8px #ff7396; animation: pulse 0.9s infinite; }
|
||||
.dot-running { color: #6fcd45; }
|
||||
.dot-charging { color: var(--mg-primary-hover); }
|
||||
.dot-paused { color: #e6a23c; }
|
||||
.dot-fault { color: #ff7396; }
|
||||
.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); }
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
.dot-idle { color: var(--mg-accent); }
|
||||
|
||||
.vehicle-sub {
|
||||
display: flex;
|
||||
|
||||
@@ -74,15 +74,17 @@ export function useVehicleCardState(opts: VehicleCardStateOptions) {
|
||||
|
||||
const latencyLabel = computed(() => {
|
||||
const ms = vehicle.value.latencyMs
|
||||
if (vehicle.value.reachable === false && ms == null) return '不可达'
|
||||
if (ms == null) return '—'
|
||||
if (vehicle.value.reachable === false) return '超时'
|
||||
return `${ms} ms`
|
||||
})
|
||||
|
||||
const latencyClass = computed(() => {
|
||||
const ms = vehicle.value.latencyMs
|
||||
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 ''
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,28 @@
|
||||
import {
|
||||
Collection, Connection, Cpu, Document, DocumentCopy, EditPen, Files,
|
||||
Histogram, Link, MapLocation, Monitor, Notebook, OfficeBuilding, Odometer,
|
||||
Operation, Promotion, SetUp, Setting, Tools, User, Van, VideoCamera, View
|
||||
Bell,
|
||||
Collection,
|
||||
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'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
@@ -16,19 +37,26 @@ export interface NavMenuItem {
|
||||
|
||||
export const ADMIN_MENU: NavMenuItem[] = [
|
||||
{ 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: [
|
||||
{ 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: '设计与编排' }
|
||||
{ path: '/admin/map-monitor', label: '地图监控', icon: View, key: 'admin-map-monitor', group: '概览' },
|
||||
{ path: '/admin/tasks', label: '任务管理', icon: List, key: 'admin-tasks', group: '概览' },
|
||||
{ path: '/admin/alarms', label: '报警管理', icon: Bell, key: 'admin-alarms', group: '概览' }
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/admin/design', label: '设计与编辑', icon: Tools, group: '设计与编辑',
|
||||
children: [
|
||||
{ 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'])
|
||||
|
||||
/** 旧版别名 key → 菜单 key(加载/保存时归一化,避免重复项) */
|
||||
/** 旧版别名 key -> 菜单 key */
|
||||
const LEGACY_KEY_ALIASES: Record<string, string> = {
|
||||
'platform-config': 'admin-map-editor',
|
||||
mission: 'admin-task-templates',
|
||||
@@ -33,7 +33,7 @@ const LEGACY_KEY_ALIASES: Record<string, string> = {
|
||||
tasks: 'admin-config-strategy'
|
||||
}
|
||||
|
||||
/** Platform 域下固定保留、不可删除的快捷入口(顺序即展示优先级) */
|
||||
/** Platform 域下固定保留、不可删除的快捷入口 */
|
||||
export const MANDATORY_PLATFORM_QUICK_KEYS = [
|
||||
'admin-maps',
|
||||
'admin-map-editor',
|
||||
@@ -43,7 +43,7 @@ export const MANDATORY_PLATFORM_QUICK_KEYS = [
|
||||
'admin-task-templates'
|
||||
] as const
|
||||
|
||||
/** 与后端 DashboardShortcutCatalog.DefaultPlatformKeys 对齐(均为菜单 key) */
|
||||
/** 与后端 DashboardShortcutCatalog.DefaultPlatformKeys 对齐 */
|
||||
export const DEFAULT_PLATFORM_QUICK_KEYS = [
|
||||
...MANDATORY_PLATFORM_QUICK_KEYS,
|
||||
'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))
|
||||
}
|
||||
|
||||
/** 保证固定四项始终存在且排在最前(其余项保持原顺序) */
|
||||
/** 保证固定项始终存在且排在最前 */
|
||||
export function ensureMandatoryQuickKeys(keys: string[], scope: Scope): string[] {
|
||||
const normalized = normalizeQuickKeys(keys)
|
||||
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'
|
||||
|
||||
function hoursAgo(h: number): string {
|
||||
return new Date(Date.now() - h * 3600_000).toISOString()
|
||||
}
|
||||
|
||||
export async function mockDeliveries(): Promise<DeliveryTask[]> {
|
||||
await new Promise((r) => setTimeout(r, 60))
|
||||
return [
|
||||
{
|
||||
id: 101,
|
||||
id: 'a7Kp3',
|
||||
taskId: 'WMS-20260720-001',
|
||||
missionId: 1,
|
||||
missionName: '链式搬运',
|
||||
missionTypeName: 'FengTianChainedDeliveryMission',
|
||||
missionName: '搬运任务进程',
|
||||
missionTypeName: 'TransportMission',
|
||||
srcSiteId: 1,
|
||||
srcLabel: '1-取货台 A',
|
||||
dstSiteId: 8,
|
||||
@@ -17,14 +22,18 @@ export async function mockDeliveries(): Promise<DeliveryTask[]> {
|
||||
carId: 1,
|
||||
carName: 'AGV-01',
|
||||
priority: 1,
|
||||
createTime: new Date().toISOString(),
|
||||
createTime: hoursAgo(0.5),
|
||||
startTime: hoursAgo(0.4),
|
||||
finishTime: null,
|
||||
stuckReason: null,
|
||||
overdue: false
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
id: 'b2Xq9',
|
||||
taskId: null,
|
||||
missionId: 1,
|
||||
missionName: '链式搬运',
|
||||
missionTypeName: 'FengTianChainedDeliveryMission',
|
||||
missionName: '搬运任务进程',
|
||||
missionTypeName: 'TransportMission',
|
||||
srcSiteId: 3,
|
||||
srcLabel: '3-缓存区',
|
||||
dstSiteId: 12,
|
||||
@@ -34,14 +43,39 @@ export async function mockDeliveries(): Promise<DeliveryTask[]> {
|
||||
carId: null,
|
||||
carName: null,
|
||||
priority: 0,
|
||||
createTime: new Date(Date.now() - 45 * 60_000).toISOString(),
|
||||
createTime: hoursAgo(0.8),
|
||||
startTime: null,
|
||||
finishTime: null,
|
||||
stuckReason: '无可用车辆',
|
||||
overdue: true
|
||||
},
|
||||
{
|
||||
id: 99,
|
||||
id: 'c9Lm4',
|
||||
taskId: 'WMS-20260720-003',
|
||||
missionId: 1,
|
||||
missionName: '链式搬运',
|
||||
missionTypeName: 'FengTianChainedDeliveryMission',
|
||||
missionName: '搬运任务进程',
|
||||
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,
|
||||
srcLabel: '2-原料区',
|
||||
dstSiteId: 5,
|
||||
@@ -51,8 +85,53 @@ export async function mockDeliveries(): Promise<DeliveryTask[]> {
|
||||
carId: 2,
|
||||
carName: 'AGV-02',
|
||||
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
|
||||
},
|
||||
{
|
||||
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[] = [
|
||||
{ key: 'admin-dashboard', 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-map-editor', label: '地图编辑', group: '设计与编排', scope: 'Platform' },
|
||||
{ key: 'admin-project-properties', label: '项目属性', group: '设计与编排', scope: 'Platform' },
|
||||
|
||||
@@ -30,6 +30,8 @@ const routes: RouteRecordRaw[] = [
|
||||
children: [
|
||||
{ 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: '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: '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: '场景管理' } },
|
||||
@@ -51,8 +53,8 @@ const routes: RouteRecordRaw[] = [
|
||||
// ── 旧路径深链接兼容:redirect 到聚合页对应 tab(无 name → 不计入受权限管理的页面)。 ──
|
||||
{ path: 'playback', redirect: { path: '/admin/config/ops-center', query: { tab: 'playback' } } },
|
||||
{ path: 'vehicle-hub', redirect: '/admin/config/vehicle-hub' },
|
||||
{ path: 'config/vehicle', redirect: { path: '/admin/config/vehicle-hub', query: { tab: 'maintenance' } } },
|
||||
{ path: 'config/fleet', redirect: { path: '/admin/config/vehicle-hub', query: { tab: 'fleet' } } },
|
||||
{ path: 'config/vehicle', redirect: { path: '/admin/config/vehicle-hub', query: { tab: 'ota' } } },
|
||||
{ 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/task', redirect: { path: '/admin/config/strategy', query: { tab: 'task' } } },
|
||||
{ path: 'config/traffic', redirect: { path: '/admin/config/strategy', query: { tab: 'traffic' } } },
|
||||
@@ -137,7 +139,8 @@ router.beforeEach(async (to) => {
|
||||
// 必须 await 完成后再放行,否则页面用旧 scope 的 perms 渲染一帧后才被纠正。
|
||||
// 失败(如 ops 账号尝试切 Platform 被 403)则维持原 scope,路由仍放行让用户看到 readonly UI。
|
||||
const needScope: 'Platform' | 'RCSMonitor' | null =
|
||||
to.path.startsWith('/admin') ? 'Platform'
|
||||
to.path.startsWith('/admin')
|
||||
? 'Platform'
|
||||
: to.path.startsWith('/monitor') ? 'RCSMonitor'
|
||||
: null
|
||||
if (needScope && auth.scope !== needScope) {
|
||||
@@ -153,7 +156,8 @@ router.beforeEach(async (to) => {
|
||||
// RBAC 页面级权限:scope 已切换到目标域,allowedPages 已刷新。
|
||||
// 若目标页面不在当前账号的可访问页面集合内,跳到该 scope 下首个可访问页面(菜单顺序)。
|
||||
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 fallback = list.find((p) => auth.hasPage(p.name))?.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
|
||||
}
|
||||
|
||||
/** 车队健康探测行(GET /sl/projection/fleet/health) */
|
||||
/** 车队健康探测行(优先 GET /api/fleet/health;延迟为 WatchDog TCP RTT) */
|
||||
export interface FleetHealthRow {
|
||||
carId: number
|
||||
carName?: string
|
||||
@@ -40,6 +40,8 @@ export interface FleetHealthRow {
|
||||
isAlarmActive?: boolean
|
||||
cpuPercent?: number
|
||||
memPercent?: number
|
||||
/** watchdog | onboard | none */
|
||||
latencySource?: string
|
||||
}
|
||||
|
||||
/** 车辆运维卡片合并模型 */
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
/** 插件搬运任务(AbstractChainedDeliveryMission.GetDeliveries)投影行 */
|
||||
export interface DeliveryTask {
|
||||
id: number
|
||||
/** 内部任务号(StandardScene 为 Base62 雪花串) */
|
||||
id: string
|
||||
/** 外部系统单号(可空) */
|
||||
taskId?: string | null
|
||||
missionId: number
|
||||
missionName: string
|
||||
missionTypeName: string
|
||||
@@ -14,7 +17,25 @@ export interface DeliveryTask {
|
||||
carName?: string | null
|
||||
priority: number
|
||||
createTime?: string | null
|
||||
startTime?: string | null
|
||||
finishTime?: string | null
|
||||
stuckReason?: string | null
|
||||
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)
|
||||
}
|
||||
|
||||
/** 接受 0–1 或 0–100,输出 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>
|
||||
|
||||
Reference in New Issue
Block a user