新增任务管理与报警管理前端,并接入车队健康数据。
运营菜单下挂地图监控/任务/报警入口,车辆卡片与任务列表同步展示运行态。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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