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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zhaowei.huang
2026-07-26 11:31:52 +08:00
co-authored by Cursor
parent 7056cf0872
commit 6986370e9d
23 changed files with 2097 additions and 78 deletions
@@ -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>