feat: AGV排队管控

This commit is contained in:
zouzhiwen 2026-05-07 15:22:11 +08:00
parent d4b629cc25
commit 41729665b5
7 changed files with 483 additions and 5 deletions

View File

@ -1,3 +1,3 @@
# 开发环境 — 留空,走 vite.config.ts 中的 proxy
VITE_API_BASE=
VITE_API_BASE=http://localhost:8114
VITE_ENTRY_OPTIONS=ops,monitor

45
src/api/stationQueue.ts Normal file
View File

@ -0,0 +1,45 @@
import request from '@/utils/request'
export interface MachineStationQueueVO {
warehouse: string
stationCode: string
queueSize: number
configuredMaxCapacity: number | null
effectiveMaxCapacity: number
queuedChainCodes: string[]
}
export interface MachineStationQueueMaxUpdateDTO {
warehouse: string
stationCode: string
maxCapacity: number
}
export function listStationQueues(warehouse: string) {
return request.get<never, { code: number; data: MachineStationQueueVO[] }>(
'/api/robot/external/admin/station-queue/list',
{ params: { warehouse } },
)
}
/** 固定工作站编码(排除 A1、A2、A5 */
export function listStationCodes(warehouse: string) {
return request.get<never, { code: number; data: string[] }>(
'/api/robot/external/admin/station-queue/station-codes',
{ params: { warehouse } },
)
}
export function getStationQueueSnapshot(warehouse: string, stationCode: string) {
return request.get<never, { code: number; data: MachineStationQueueVO }>(
'/api/robot/external/admin/station-queue/snapshot',
{ params: { warehouse, stationCode } },
)
}
export function updateStationQueueMax(payload: MachineStationQueueMaxUpdateDTO) {
return request.post<never, { code: number; data: boolean }>(
'/api/robot/external/admin/station-queue/max',
payload,
)
}

View File

@ -246,6 +246,7 @@ export const callbackScenarioMap: Record<string, CallbackScenario[]> = {
HK_OUTBOUND_WAIT: [hkArriveWcs, hkArriveStation],
HK_ARRIVE_STATION: [hkArriveStation],
HK_PICK_PALLET: [{ key: 'pickPalletDone', label: 'AGV完成取货', url: '/api/robot/external/hk/pickPalletDone', fields: hkFields }],
HK_PICK_UP: [{ key: 'pickUp', label: '海康举起托盘', url: '/api/robot/external/hk/pickUp', fields: hkFields }],
HK_PUT_PALLET: [{ key: 'putPalletDone', label: 'AGV完成放货', url: '/api/robot/external/hk/putPalletDone', fields: hkFields }],
HK_LEAVE_START_POINT: [{ key: 'leaveStartPoint', label: 'AGV离开起点', url: '/api/robot/external/hk/leaveStartPoint', fields: hkFields }],
ZS_INBOUND: [{ key: 'taskInfoBack', label: '任务状态回传', url: '/api/robot/external/zs/wms/taskInfoBack', fields: zsTaskInfoBackFields }],

View File

@ -27,6 +27,10 @@
<el-icon><SetUp /></el-icon>
<template #title>提升机查看</template>
</el-menu-item>
<el-menu-item index="/station/queue">
<el-icon><Tickets /></el-icon>
<template #title>站点排队</template>
</el-menu-item>
<el-menu-item v-if="!isMonitorMode" index="/vendor/load">
<el-icon><DataLine /></el-icon>
<template #title>厂商负载</template>
@ -164,7 +168,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { useDevice } from '@/composables/useDevice'
import { Monitor, List, SetUp, Plus, DataLine, Fold, Expand, User, ArrowDown, Refresh } from '@element-plus/icons-vue'
import { Monitor, List, SetUp, Plus, DataLine, Fold, Expand, User, ArrowDown, Refresh, Tickets } from '@element-plus/icons-vue'
import { useTabsStore } from '@/stores/tabs'
const { isMobile } = useDevice()
@ -195,9 +199,17 @@ const userStore = useUserStore()
const isMonitorMode = computed(() => userStore.isMonitorMode())
const keepAliveNames = computed(() => {
if (isMonitorMode.value) {
return ['TaskChainList', 'TaskChainDetail', 'ElevatorList']
return ['TaskChainList', 'TaskChainDetail', 'ElevatorList', 'StationQueueView']
}
return ['DashboardView', 'TaskChainList', 'TaskChainDetail', 'ElevatorList', 'VendorLoadView', 'TaskGenerateView']
return [
'DashboardView',
'TaskChainList',
'TaskChainDetail',
'ElevatorList',
'StationQueueView',
'VendorLoadView',
'TaskGenerateView',
]
})
// watch watch immediate storage
@ -317,6 +329,7 @@ const tabs = computed(() => [
...(!isMonitorMode.value ? [{ path: '/dashboard', label: '首页', icon: Monitor }] : []),
{ path: '/task/chain', label: '任务链', icon: List },
{ path: '/elevator', label: '提升机', icon: SetUp },
{ path: '/station/queue', label: '排队', icon: Tickets },
...(!isMonitorMode.value ? [{ path: '/vendor/load', label: '厂商负载', icon: DataLine }] : []),
...(!isMonitorMode.value ? [{ path: '/generate', label: '生成', icon: Plus }] : []),
])
@ -324,12 +337,14 @@ const tabs = computed(() => [
const activeMenu = computed(() => {
const path = route.path
if (path.startsWith('/task/chain')) return '/task/chain'
if (path.startsWith('/station/queue')) return '/station/queue'
return path
})
const activeTab = computed(() => {
const path = route.path
if (path.startsWith('/task/chain')) return '/task/chain'
if (path.startsWith('/station/queue')) return '/station/queue'
return path
})

View File

@ -46,6 +46,12 @@ const router = createRouter({
component: () => import('@/views/elevator/ElevatorList.vue'),
meta: { title: '提升机管理' },
},
{
path: 'station/queue',
name: 'StationQueueView',
component: () => import('@/views/station/StationQueueView.vue'),
meta: { title: '站点排队' },
},
{
path: 'vendor/load',
name: 'VendorLoadView',

View File

@ -0,0 +1,411 @@
<template>
<div class="page-container">
<el-card shadow="hover" class="filter-card">
<div class="filter-actions">
<div class="filter-warehouse">
<span class="filter-label">仓库</span>
<el-input
v-model="warehouse"
placeholder="如 WH01"
clearable
@keyup.enter="loadData"
/>
</div>
<el-button type="primary" :loading="loading" @click="loadData">查询</el-button>
</div>
<el-alert type="info" show-icon :closable="false" class="hint">
站点为固定工作站列表ASRS 工作站中排除 A1A2A5 B1B4A3A4A6排队数据来自 Redis未单独配置上限时使用默认上限
</el-alert>
</el-card>
<div class="board-toolbar">
<div class="board-titles">
<span class="board-title">站点排队</span>
<span v-if="rows.length" class="board-sub">{{ rows.length }} 个站点</span>
</div>
<div class="board-actions">
<span class="board-hint">点击链码查看详情 · 排队无序Set</span>
<el-button type="primary" size="small" :loading="loading" @click="loadData">刷新</el-button>
</div>
</div>
<div class="board-body" v-loading="loading">
<el-empty v-if="rows.length === 0 && !loading" description="暂无数据,请确认仓库编码" />
<div v-else class="station-grid">
<el-card
v-for="row in rows"
:key="row.stationCode"
class="station-card"
shadow="hover"
>
<template #header>
<div class="station-card-head">
<div class="station-card-head__main">
<span class="station-code">{{ row.stationCode }}</span>
<el-tag :type="queueStressType(row)" size="small" effect="plain" class="queue-pill">
排队 {{ row.queueSize }} / 上限 {{ row.effectiveMaxCapacity }}
</el-tag>
</div>
<el-tooltip v-if="!isMonitorMode" content="修改上限" placement="top">
<el-button
type="primary"
link
size="small"
class="card-edit-btn"
:icon="Edit"
@click.stop="openEditDialog(row)"
/>
</el-tooltip>
</div>
</template>
<div class="station-card-body">
<div class="stat-row">
<span class="stat-k">已配置上限</span>
<span v-if="row.configuredMaxCapacity != null" class="stat-v">{{ row.configuredMaxCapacity }}</span>
<el-tag v-else type="info" size="small">默认</el-tag>
</div>
<div class="chains-section">
<div class="chains-head">
<span class="chains-title">排队任务链</span>
<span v-if="row.queuedChainCodes?.length" class="chains-n">{{ row.queuedChainCodes.length }} </span>
</div>
<div v-if="row.queuedChainCodes?.length" class="chains-scroll">
<el-tag
v-for="c in row.queuedChainCodes"
:key="c"
type="primary"
effect="plain"
size="small"
class="chain-chip"
@click="goChainDetail(c)"
>{{ c }}</el-tag>
</div>
<div v-else class="chains-empty"></div>
</div>
</div>
</el-card>
</div>
</div>
<el-dialog v-model="editVisible" title="修改排队上限" width="420px" destroy-on-close>
<el-form label-width="100px">
<el-form-item label="仓库">
<el-input :model-value="editForm.warehouse" disabled />
</el-form-item>
<el-form-item label="站点">
<el-input :model-value="editForm.stationCode" disabled />
</el-form-item>
<el-form-item label="排队上限">
<el-input-number v-model="editForm.maxCapacity" :min="0" :max="9999" controls-position="right" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submitEdit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'StationQueueView' })
import {
listStationQueues,
updateStationQueueMax,
type MachineStationQueueVO,
} from '@/api/stationQueue'
import { Edit } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const isMonitorMode = computed(() => userStore.isMonitorMode())
const router = useRouter()
const warehouse = ref('WH01')
const rows = ref<MachineStationQueueVO[]>([])
const loading = ref(false)
const editVisible = ref(false)
const editForm = reactive({
warehouse: '',
stationCode: '',
maxCapacity: 3,
})
const saving = ref(false)
function queueStressType(row: MachineStationQueueVO): 'success' | 'warning' | 'danger' | 'info' {
const max = row.effectiveMaxCapacity
const q = row.queueSize
if (max <= 0) return q > 0 ? 'danger' : 'info'
if (q >= max) return 'danger'
if (q / max >= 0.8) return 'warning'
return 'success'
}
async function loadData() {
const wh = warehouse.value?.trim()
if (!wh) {
ElMessage.warning('请输入仓库编码')
return
}
loading.value = true
try {
const res = await listStationQueues(wh)
rows.value = res.data || []
} catch {
rows.value = []
} finally {
loading.value = false
}
}
function goChainDetail(code: string) {
if (!code) return
router.push(`/task/chain/${encodeURIComponent(code)}`)
}
function openEditDialog(row: MachineStationQueueVO) {
editForm.warehouse = row.warehouse
editForm.stationCode = row.stationCode
editForm.maxCapacity = row.configuredMaxCapacity ?? row.effectiveMaxCapacity
editVisible.value = true
}
async function submitEdit() {
saving.value = true
try {
await updateStationQueueMax({
warehouse: editForm.warehouse.trim(),
stationCode: editForm.stationCode.trim(),
maxCapacity: editForm.maxCapacity,
})
ElMessage.success('已保存')
editVisible.value = false
await loadData()
} catch { /* interceptor */ } finally {
saving.value = false
}
}
onMounted(loadData)
</script>
<style scoped>
.page-container {
display: flex;
flex-direction: column;
gap: 16px;
}
.filter-card .hint {
margin-top: 12px;
}
.filter-actions {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px 16px;
}
.filter-warehouse {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 200px;
flex: 1;
max-width: 280px;
}
.filter-label {
font-size: 13px;
color: var(--el-text-color-regular);
}
.board-toolbar {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.board-titles {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px 12px;
}
.board-title {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.board-sub {
font-size: 13px;
color: var(--el-text-color-secondary);
}
.board-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 12px;
}
.board-hint {
font-size: 12px;
color: var(--el-text-color-secondary);
max-width: min(420px, 100%);
line-height: 1.4;
}
.board-body {
min-height: 120px;
}
.station-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 340px), 1fr));
gap: 16px;
}
.station-card :deep(.el-card__header) {
padding: 14px 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.station-card :deep(.el-card__body) {
padding: 16px;
}
.station-card-head {
display: flex;
flex-wrap: nowrap;
align-items: flex-start;
justify-content: space-between;
gap: 4px;
}
.station-card-head__main {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
min-width: 0;
flex: 1;
}
.station-code {
font-size: 17px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--el-text-color-primary);
}
.queue-pill {
flex-shrink: 0;
}
.card-edit-btn {
flex-shrink: 0;
margin: -6px -10px -6px 0;
padding: 4px 6px;
}
.station-card-body {
display: flex;
flex-direction: column;
gap: 14px;
}
.stat-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
font-size: 13px;
}
.stat-k {
color: var(--el-text-color-secondary);
}
.stat-v {
font-weight: 500;
color: var(--el-text-color-primary);
}
.chains-section {
border-radius: 8px;
background: var(--el-fill-color-lighter);
padding: 10px 12px;
}
.chains-head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.chains-title {
font-size: 13px;
font-weight: 500;
color: var(--el-text-color-regular);
}
.chains-n {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.chains-scroll {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-content: flex-start;
max-height: 168px;
overflow-y: auto;
overflow-x: hidden;
padding: 2px;
margin: 0 -2px;
scrollbar-gutter: stable;
}
.chain-chip {
cursor: pointer;
max-width: 100%;
word-break: break-all;
white-space: normal;
height: auto;
padding-top: 4px;
padding-bottom: 4px;
line-height: 1.35;
}
.chains-empty {
font-size: 13px;
color: var(--el-text-color-placeholder);
}
@media (max-width: 640px) {
.filter-warehouse {
max-width: none;
}
.board-actions {
width: 100%;
justify-content: space-between;
}
}
</style>

View File

@ -1 +1 @@
{"root":["./src/main.ts","./src/api/auth.ts","./src/api/elevator.ts","./src/api/generate.ts","./src/api/task.ts","./src/api/vendorload.ts","./src/composables/usedevice.ts","./src/constants/chainstatus.ts","./src/constants/taskstatus.ts","./src/router/index.ts","./src/stores/tabs.ts","./src/stores/user.ts","./src/utils/request.ts","./src/app.vue","./src/layouts/adminlayout.vue","./src/views/dashboard/dashboardview.vue","./src/views/elevator/elevatorlist.vue","./src/views/generate/taskgenerateview.vue","./src/views/login/loginview.vue","./src/views/task/taskchaindetail.vue","./src/views/task/taskchainlist.vue","./src/views/vendor/vendorloadview.vue","./env.d.ts"],"version":"5.7.3"}
{"root":["./src/main.ts","./src/api/auth.ts","./src/api/elevator.ts","./src/api/generate.ts","./src/api/stationqueue.ts","./src/api/task.ts","./src/api/vendorload.ts","./src/composables/usedevice.ts","./src/constants/chainstatus.ts","./src/constants/taskstatus.ts","./src/router/index.ts","./src/stores/tabs.ts","./src/stores/user.ts","./src/utils/request.ts","./src/app.vue","./src/layouts/adminlayout.vue","./src/views/dashboard/dashboardview.vue","./src/views/elevator/elevatorlist.vue","./src/views/generate/taskgenerateview.vue","./src/views/login/loginview.vue","./src/views/station/stationqueueview.vue","./src/views/task/taskchaindetail.vue","./src/views/task/taskchainlist.vue","./src/views/vendor/vendorloadview.vue","./env.d.ts"],"version":"5.7.3"}