feat(delivery): 新增插件搬运任务列表面板

- 新增 delivery 的 api/types 与 mock 数据,对接 SimpleLite /projection/deliveries(取消 / 重发 / 强制完成)
- 新增 workbench/MissionListPanel 展示搬运任务,支持按完成 / 中止状态过滤
- components.d.ts 自动注册 MissionListPanel
This commit is contained in:
zhaowei.huang
2026-05-29 23:52:13 +08:00
parent 00cfcf2897
commit ea036760cb
5 changed files with 525 additions and 0 deletions
+1
View File
@@ -74,6 +74,7 @@ declare module 'vue' {
FloatingAlarmCard: typeof import('./src/components/map-monitor/FloatingAlarmCard.vue')['default'] FloatingAlarmCard: typeof import('./src/components/map-monitor/FloatingAlarmCard.vue')['default']
FloatingAlarmStack: typeof import('./src/components/map-monitor/FloatingAlarmStack.vue')['default'] FloatingAlarmStack: typeof import('./src/components/map-monitor/FloatingAlarmStack.vue')['default']
MapMonitorConfigGroup: typeof import('./src/components/config/MapMonitorConfigGroup.vue')['default'] MapMonitorConfigGroup: typeof import('./src/components/config/MapMonitorConfigGroup.vue')['default']
MissionListPanel: typeof import('./src/components/workbench/MissionListPanel.vue')['default']
MonitorSelectionPanel: typeof import('./src/components/workbench/MonitorSelectionPanel.vue')['default'] MonitorSelectionPanel: typeof import('./src/components/workbench/MonitorSelectionPanel.vue')['default']
PermissionGuard: typeof import('./src/components/PermissionGuard.vue')['default'] PermissionGuard: typeof import('./src/components/PermissionGuard.vue')['default']
PluginListPanel: typeof import('./src/components/reflection/PluginListPanel.vue')['default'] PluginListPanel: typeof import('./src/components/reflection/PluginListPanel.vue')['default']
@@ -0,0 +1,36 @@
import http from './http'
import type { DeliveryTask } from '@/types/delivery'
const MOCK = import.meta.env.VITE_USE_MOCK === 'true'
const BASE = '/sl/projection/deliveries'
export async function listDeliveries(opts?: {
includeFinished?: boolean
includeAborted?: boolean
}): Promise<DeliveryTask[]> {
if (MOCK) {
const { mockDeliveries } = await import('@/mock/data/deliveries')
return mockDeliveries()
}
const params: Record<string, string> = {}
if (opts?.includeFinished === false) params.includeFinished = 'false'
if (opts?.includeAborted === false) params.includeAborted = 'false'
const { data } = await http.get<DeliveryTask[]>(BASE, { params })
return Array.isArray(data) ? data : []
}
export async function cancelDelivery(id: number): Promise<void> {
if (MOCK) return
await http.post(`${BASE}/${id}/cancel`)
}
export async function resendDelivery(id: number): Promise<void> {
if (MOCK) return
await http.post(`${BASE}/${id}/resend`)
}
export async function forceCompleteDelivery(id: number): Promise<void> {
if (MOCK) return
await http.post(`${BASE}/${id}/force-complete`)
}
@@ -0,0 +1,410 @@
<template>
<div class="mission-list-panel">
<div class="toolbar">
<el-input
v-model="search"
size="small"
clearable
placeholder="搜索任务 ID / 站点 / 车辆"
class="search"
/>
<el-checkbox v-model="showFinished" size="small" @change="emitRefresh">
含已完成
</el-checkbox>
<el-checkbox v-model="showAborted" size="small" @change="emitRefresh">
含取消/异常
</el-checkbox>
</div>
<el-table
:data="filteredRows"
size="small"
stripe
highlight-current-row
:row-class-name="rowClassName"
height="100%"
class="delivery-table"
table-layout="fixed"
@row-click="onRowClick"
@row-contextmenu="onRowContextMenu"
>
<el-table-column prop="id" label="ID" width="40" align="center" />
<el-table-column label="起点" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-ellipsis" :title="row.srcLabel">{{ shortSite(row.srcLabel) }}</span>
</template>
</el-table-column>
<el-table-column label="终点" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-ellipsis" :title="row.dstLabel">{{ shortSite(row.dstLabel) }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="52" align="center">
<template #default="{ row }">
<span
class="status-pill"
:class="`status-pill--${statusTagType(row.statusCode)}`"
:title="row.status"
>
{{ shortStatus(row.status) }}
</span>
</template>
</el-table-column>
<el-table-column label="车辆" width="56" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.carName" class="cell-ellipsis" :title="row.carName">
{{ shortCar(row.carName) }}
</span>
<span v-else class="muted"></span>
</template>
</el-table-column>
</el-table>
<div v-if="!filteredRows.length" class="empty muted">暂无搬运任务</div>
<div
v-show="ctxVisible"
class="ctx-menu"
:style="{ left: `${ctxX}px`, top: `${ctxY}px` }"
@click.stop
>
<button
type="button"
class="ctx-item"
:disabled="!canCancel(ctxRow)"
@click="runAction('cancel')"
>
取消任务
</button>
<button type="button" class="ctx-item" @click="runAction('resend')">
重发任务
</button>
<button
type="button"
class="ctx-item"
:disabled="!canForceComplete(ctxRow)"
@click="runAction('force-complete')"
>
强制完成
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { DeliveryTask, DeliveryAction } from '@/types/delivery'
import {
cancelDelivery,
forceCompleteDelivery,
resendDelivery
} from '@/api/delivery'
const props = defineProps<{
deliveries: DeliveryTask[]
selectedId?: number | null
loading?: boolean
}>()
const emit = defineEmits<{
select: [task: DeliveryTask]
refresh: [opts: { includeFinished: boolean; includeAborted: boolean }]
actionDone: []
}>()
const search = ref('')
const showFinished = ref(true)
const showAborted = ref(true)
const ctxVisible = ref(false)
const ctxX = ref(0)
const ctxY = ref(0)
const ctxRow = ref<DeliveryTask | null>(null)
const filteredRows = computed(() => {
const q = search.value.trim().toLowerCase()
const tokens = q ? q.split(/\s+/).filter(Boolean) : []
return props.deliveries.filter((row) => {
if (!tokens.length) return true
const hay = [
String(row.id),
row.srcLabel,
row.dstLabel,
row.status,
row.carName ?? '',
row.missionName
]
.join(' ')
.toLowerCase()
return tokens.every((t) => hay.includes(t))
})
})
type TagType = 'success' | 'warning' | 'danger' | 'info' | 'primary'
/** 站点列:优先显示名称段,完整内容靠 tooltip */
function shortSite(label: string, maxLen = 9): string {
const idx = label.indexOf('-')
const name = idx >= 0 ? label.slice(idx + 1) : label
if (name.length <= maxLen) return name
return `${name.slice(0, maxLen)}`
}
function shortStatus(status: string): string {
const map: Record<string, string> = {
已下发: '待发',
执行中: '执行',
已完成: '完成',
已取消: '取消',
强制结束: '强结',
任务异常: '异常'
}
return map[status] ?? (status.length > 4 ? `${status.slice(0, 3)}` : status)
}
function shortCar(name: string, maxLen = 6): string {
if (name.length <= maxLen) return name
return `${name.slice(0, maxLen)}`
}
function statusTagType(code: string): TagType {
switch (code) {
case 'Fetching':
case 'Putting':
return 'success'
case 'Waiting':
return 'primary'
case 'Finished':
return 'info'
case 'Canceled':
case 'Terminated':
return 'warning'
case 'Error':
return 'danger'
default:
return 'info'
}
}
function rowClassName({ row }: { row: DeliveryTask }) {
const classes: string[] = []
if (row.id === props.selectedId) classes.push('is-selected-row')
if (row.overdue) classes.push('is-overdue-row')
return classes.join(' ')
}
function isTerminal(code: string) {
return ['Finished', 'Canceled', 'Terminated', 'Error'].includes(code)
}
function canCancel(row: DeliveryTask | null) {
return row != null && !isTerminal(row.statusCode)
}
function canForceComplete(row: DeliveryTask | null) {
return row != null && !isTerminal(row.statusCode)
}
function emitRefresh() {
emit('refresh', {
includeFinished: showFinished.value,
includeAborted: showAborted.value
})
}
function onRowClick(row: DeliveryTask) {
hideCtx()
emit('select', row)
}
function onRowContextMenu(row: DeliveryTask, _col: unknown, e: MouseEvent) {
e.preventDefault()
ctxRow.value = row
ctxX.value = e.clientX
ctxY.value = e.clientY
ctxVisible.value = true
}
function hideCtx() {
ctxVisible.value = false
ctxRow.value = null
}
const actionLabels: Record<DeliveryAction, string> = {
cancel: '取消任务',
resend: '重发任务',
'force-complete': '强制完成'
}
async function runAction(action: DeliveryAction) {
const row = ctxRow.value
hideCtx()
if (!row) return
if (action === 'cancel' && !canCancel(row)) return
if (action === 'force-complete' && !canForceComplete(row)) return
try {
await ElMessageBox.confirm(
`确定对任务 #${row.id}${row.srcLabel}${row.dstLabel})执行「${actionLabels[action]}」?`,
'确认操作',
{ type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' }
)
} catch {
return
}
try {
if (action === 'cancel') await cancelDelivery(row.id)
else if (action === 'resend') await resendDelivery(row.id)
else await forceCompleteDelivery(row.id)
ElMessage.success(`${actionLabels[action]} 已提交`)
emit('actionDone')
} catch (err) {
ElMessage.error(`${actionLabels[action]} 失败:${(err as Error).message}`)
}
}
function onDocClick() {
hideCtx()
}
onMounted(() => document.addEventListener('click', onDocClick))
onUnmounted(() => document.removeEventListener('click', onDocClick))
</script>
<style scoped>
.mission-list-panel {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
height: 100%;
position: relative;
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
flex: none;
flex-wrap: wrap;
}
.toolbar .search {
flex: 1;
min-width: 120px;
}
.delivery-table {
flex: 1;
min-height: 0;
width: 100%;
overflow: hidden;
}
.mission-list-panel :deep(.delivery-table .el-table__inner-wrapper),
.mission-list-panel :deep(.delivery-table .el-table__header-wrapper),
.mission-list-panel :deep(.delivery-table .el-table__body-wrapper) {
overflow-x: hidden !important;
}
.mission-list-panel :deep(.delivery-table table) {
table-layout: fixed;
width: 100% !important;
}
.mission-list-panel :deep(.delivery-table th.el-table__cell),
.mission-list-panel :deep(.delivery-table td.el-table__cell) {
padding: 3px 2px;
}
.mission-list-panel :deep(.delivery-table .cell) {
padding: 0 2px;
font-size: 11px;
line-height: 1.35;
}
.mission-list-panel :deep(.delivery-table th .cell) {
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.cell-ellipsis {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-pill {
display: inline-block;
max-width: 100%;
padding: 0 3px;
border-radius: 3px;
font-size: 10px;
line-height: 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status-pill--success { color: var(--mg-status-success); }
.status-pill--primary { color: var(--mg-status-info); }
.status-pill--warning { color: var(--mg-status-warning); }
.status-pill--danger { color: var(--mg-status-danger); }
.status-pill--info { color: var(--mg-text-muted); }
.mission-list-panel :deep(.el-table),
.mission-list-panel :deep(.el-table th.el-table__cell),
.mission-list-panel :deep(.el-table .cell) {
color: var(--mg-text-light);
background: transparent;
}
.mission-list-panel :deep(.el-table tr) {
background: rgba(255, 255, 255, 0.03);
}
.mission-list-panel :deep(.el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell) {
background: rgba(255, 255, 255, 0.06);
}
.mission-list-panel :deep(.is-selected-row) td {
background: rgba(109, 40, 217, 0.35) !important;
}
.mission-list-panel :deep(.is-overdue-row) td {
color: var(--mg-status-danger) !important;
}
.empty {
text-align: center;
padding: 16px 0;
font-size: 12px;
}
.muted {
color: var(--mg-text-dim);
}
.ctx-menu {
position: fixed;
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);
}
.ctx-item {
display: block;
width: 100%;
padding: 8px 14px;
border: none;
background: transparent;
color: #fff;
font-size: 13px;
text-align: left;
cursor: pointer;
}
.ctx-item:hover:not(:disabled) {
background: rgba(var(--mg-accent-rgb, 142, 200, 252), 0.2);
}
.ctx-item:disabled {
color: rgba(255, 255, 255, 0.35);
cursor: not-allowed;
}
</style>
@@ -0,0 +1,58 @@
import type { DeliveryTask } from '@/types/delivery'
export async function mockDeliveries(): Promise<DeliveryTask[]> {
await new Promise((r) => setTimeout(r, 60))
return [
{
id: 101,
missionId: 1,
missionName: '链式搬运',
missionTypeName: 'FengTianChainedDeliveryMission',
srcSiteId: 1,
srcLabel: '1-取货台 A',
dstSiteId: 8,
dstLabel: '8-放货台 B',
status: '执行中',
statusCode: 'Fetching',
carId: 1,
carName: 'AGV-01',
priority: 1,
createTime: new Date().toISOString(),
overdue: false
},
{
id: 102,
missionId: 1,
missionName: '链式搬运',
missionTypeName: 'FengTianChainedDeliveryMission',
srcSiteId: 3,
srcLabel: '3-缓存区',
dstSiteId: 12,
dstLabel: '12-工位 W2',
status: '已下发',
statusCode: 'Waiting',
carId: null,
carName: null,
priority: 0,
createTime: new Date(Date.now() - 45 * 60_000).toISOString(),
overdue: true
},
{
id: 99,
missionId: 1,
missionName: '链式搬运',
missionTypeName: 'FengTianChainedDeliveryMission',
srcSiteId: 2,
srcLabel: '2-原料区',
dstSiteId: 5,
dstLabel: '5-成品区',
status: '已完成',
statusCode: 'Finished',
carId: 2,
carName: 'AGV-02',
priority: 1,
createTime: new Date(Date.now() - 3600_000).toISOString(),
overdue: false
}
]
}
@@ -0,0 +1,20 @@
/** 插件搬运任务(AbstractChainedDeliveryMission.GetDeliveries)投影行 */
export interface DeliveryTask {
id: number
missionId: number
missionName: string
missionTypeName: string
srcSiteId: number
srcLabel: string
dstSiteId: number
dstLabel: string
status: string
statusCode: string
carId?: number | null
carName?: string | null
priority: number
createTime?: string | null
overdue?: boolean
}
export type DeliveryAction = 'cancel' | 'resend' | 'force-complete'