robot-admin-web/src/views/task/TaskChainList.vue

622 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="page-container">
<!-- ========== PC ========== -->
<template v-if="!isMobile">
<el-card shadow="hover" class="filter-card">
<el-form :model="query" inline>
<el-form-item label="任务链编号">
<el-input v-model="query.code" placeholder="请输入" clearable style="width: 180px" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="query.status" placeholder="全部" clearable style="width: 140px">
<el-option v-for="(label, value) in chainStatusMap" :key="value" :label="label" :value="value" />
</el-select>
</el-form-item>
<el-form-item label="类型">
<el-select v-model="query.type" placeholder="全部" clearable style="width: 160px">
<el-option v-for="(label, value) in typeMap" :key="value" :label="label" :value="value" />
</el-select>
</el-form-item>
<el-form-item label="业务追踪号">
<el-input v-model="query.businessNo" placeholder="请输入" clearable style="width: 180px" />
</el-form-item>
<el-form-item label="源容器">
<el-input v-model="query.sourceContainer" placeholder="请输入" clearable style="width: 150px" />
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="dateRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 360px"
/>
<el-button link type="primary" class="quick-date-btn" @click="setToday">今日</el-button>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="hover" class="table-card">
<template #header>
<div class="card-header">
<span>任务链列表</span>
<div>
<el-button type="success" size="small" @click="handleScan">手动扫描执行</el-button>
</div>
</div>
</template>
<el-table
ref="tableRef"
:data="tableData"
stripe
border
v-loading="loading"
>
<el-table-column prop="code" label="任务链编号" min-width="170" show-overflow-tooltip />
<el-table-column prop="type" label="类型" width="140">
<template #default="{ row }">
<el-tag size="small">{{ typeMap[row.type] || row.type }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag v-bind="getChainStatusTag(row.status)" size="small">
{{ chainStatusMap[row.status] || row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="firstTaskVendor" label="首任务厂商" width="100" />
<el-table-column prop="businessNo" label="业务追踪号" min-width="150" show-overflow-tooltip />
<el-table-column prop="sourceContainer" label="源容器" width="130" show-overflow-tooltip />
<el-table-column prop="targetLocation" label="目标库位" width="130" show-overflow-tooltip />
<el-table-column prop="priority" label="优先级" width="80" align="center" />
<el-table-column prop="elevatorId" label="提升机" width="80" align="center">
<template #default="{ row }">
{{ row.elevatorId ? `#${row.elevatorId}` : '-' }}
</template>
</el-table-column>
<el-table-column prop="executionTime" label="执行时间" width="90" align="center">
<template #default="{ row }">
{{ row.executionTime ? `${row.executionTime}s` : '-' }}
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="170" />
<el-table-column prop="completeTime" label="完成时间" width="170">
<template #default="{ row }">
{{ row.completeTime || '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button text type="primary" size="small" @click="goDetail(row.code)">详情</el-button>
<el-button
v-if="row.status === 'CREATED' || row.status === 'READY'"
text type="warning" size="small"
@click="handleExecute(row)"
>
执行
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.size"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadData"
@current-change="loadData"
/>
</div>
</el-card>
</template>
<!-- ========== 移动端 ========== -->
<template v-else>
<!-- 搜索栏 -->
<div class="m-search-bar">
<el-input
v-model="query.code"
placeholder="搜索任务链编号"
clearable
:prefix-icon="Search"
@keyup.enter="handleSearch"
@clear="handleSearch"
/>
<el-badge :value="activeFilterCount" :hidden="activeFilterCount === 0" :max="9">
<el-button :icon="Filter" circle @click="filterDrawerVisible = true" />
</el-badge>
</div>
<!-- 日期快筛 -->
<div class="m-date-tabs">
<span
:class="['m-status-chip', { 'm-status-chip-active': isTodayFilter }]"
@click="setToday"
>今日</span>
<span
:class="['m-status-chip', { 'm-status-chip-active': !dateRange?.length }]"
@click="clearDateRange"
>全部</span>
</div>
<!-- 状态快筛 -->
<div class="m-status-tabs">
<span
v-for="(label, value) in { '': '全部', ...chainStatusMap }"
:key="value"
:class="['m-status-chip', { 'm-status-chip-active': query.status === value }]"
@click="onStatusChip(value as string)"
>{{ label }}</span>
</div>
<!-- 卡片列表 -->
<div v-loading="loading" class="m-card-list">
<div
v-for="row in tableData"
:key="row.code"
class="m-chain-card"
@click="goDetail(row.code)"
>
<div class="m-chain-card-top">
<span class="m-chain-code">{{ row.code }}</span>
<el-tag v-bind="getChainStatusTag(row.status)" size="small">
{{ chainStatusMap[row.status] || row.status }}
</el-tag>
</div>
<div class="m-chain-card-body">
<div class="m-chain-field">
<span class="m-field-label">类型</span>
<el-tag size="small" type="info">{{ typeMap[row.type] || row.type }}</el-tag>
</div>
<div v-if="row.sourceContainer" class="m-chain-field">
<span class="m-field-label">源容器</span>
<span class="m-field-value">{{ row.sourceContainer }}</span>
</div>
<div v-if="row.targetLocation" class="m-chain-field">
<span class="m-field-label">目标库位</span>
<span class="m-field-value">{{ row.targetLocation }}</span>
</div>
<div v-if="row.elevatorId" class="m-chain-field">
<span class="m-field-label">提升机</span>
<span class="m-field-value">#{{ row.elevatorId }}</span>
</div>
</div>
<div class="m-chain-card-footer">
<span class="m-chain-time">{{ row.createTime }}</span>
<el-icon><ArrowRight /></el-icon>
</div>
</div>
<el-empty v-if="!loading && tableData.length === 0" description="暂无数据" />
</div>
<!-- 移动端分页 -->
<div class="m-pagination">
<el-pagination
v-model:current-page="query.page"
:total="total"
:page-size="query.size"
layout="prev, slot, next"
small
@current-change="loadData"
>
<span class="m-page-info">{{ query.page }} / {{ Math.max(Math.ceil(total / query.size), 1) }}</span>
</el-pagination>
</div>
<!-- 筛选抽屉 -->
<el-drawer
v-model="filterDrawerVisible"
title="筛选条件"
direction="btt"
size="auto"
:with-header="true"
>
<el-form :model="query" label-position="top">
<el-form-item label="创建时间">
<div class="m-drawer-date-actions">
<el-button :type="isTodayFilter ? 'primary' : 'default'" @click="setToday">今日</el-button>
<el-button @click="clearDateRange">清空</el-button>
</div>
</el-form-item>
<el-form-item label="任务链编号">
<el-input v-model="query.code" placeholder="请输入" clearable />
</el-form-item>
<el-form-item label="类型">
<el-select v-model="query.type" placeholder="全部" clearable style="width: 100%">
<el-option v-for="(label, value) in typeMap" :key="value" :label="label" :value="value" />
</el-select>
</el-form-item>
<el-form-item label="业务追踪号">
<el-input v-model="query.businessNo" placeholder="请输入" clearable />
</el-form-item>
<el-form-item label="源容器">
<el-input v-model="query.sourceContainer" placeholder="请输入" clearable />
</el-form-item>
</el-form>
<div class="m-drawer-footer">
<el-button @click="handleReset">重置</el-button>
<el-button type="primary" @click="filterDrawerVisible = false; handleSearch()">确认筛选</el-button>
</div>
</el-drawer>
</template>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'TaskChainList' })
import { dispatchExecute, dispatchScan, getChainPage, type TaskChainVO } from '@/api/task'
import { ElMessage, ElMessageBox, type TableInstance } from 'element-plus'
import { Search, Filter, ArrowRight } from '@element-plus/icons-vue'
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useDevice } from '@/composables/useDevice'
import { chainStatusMap, getChainStatusTag } from '@/constants/chainStatus'
const { isMobile } = useDevice()
const router = useRouter()
const tableRef = ref<TableInstance>()
const loading = ref(false)
const tableData = ref<TaskChainVO[]>([])
const total = ref(0)
const dateRange = ref<string[]>([])
const filterDrawerVisible = ref(false)
function onHeaderDblClick(e: MouseEvent) {
const th = (e.target as HTMLElement).closest('th')
if (!th || !tableRef.value) return
const colId = Array.from(th.classList).find((c) => c.startsWith('el-table_'))
if (!colId) return
const el = tableRef.value.$el as HTMLElement
const cells = el.querySelectorAll<HTMLElement>(`.el-table__body .${colId} .cell`)
if (!cells.length) return
const measure = document.createElement('span')
measure.style.cssText = 'visibility:hidden;position:absolute;white-space:nowrap;'
document.body.appendChild(measure)
let maxWidth = 0
cells.forEach((cell) => {
const style = getComputedStyle(cell)
measure.style.fontSize = style.fontSize
measure.style.fontFamily = style.fontFamily
measure.style.fontWeight = style.fontWeight
measure.innerHTML = cell.innerHTML
maxWidth = Math.max(maxWidth, measure.offsetWidth)
})
document.body.removeChild(measure)
if (maxWidth > 0) {
const columns = (tableRef.value as unknown as { store: { states: { columns: { value: { id: string; realWidth: number; width: number }[] } } } }).store.states.columns.value
const col = columns.find((c) => c.id === colId)
if (col) {
const newWidth = Math.min(Math.max(maxWidth + 32, 80), 800)
col.width = newWidth
col.realWidth = newWidth
nextTick(() => tableRef.value?.doLayout())
}
}
}
let headerEl: HTMLElement | null = null
onMounted(() => {
nextTick(() => {
if (tableRef.value) {
headerEl = (tableRef.value.$el as HTMLElement).querySelector('.el-table__header-wrapper')
headerEl?.addEventListener('dblclick', onHeaderDblClick)
}
})
})
onUnmounted(() => {
headerEl?.removeEventListener('dblclick', onHeaderDblClick)
})
const query = reactive({
page: 1,
size: 10,
code: '',
status: '',
type: '',
businessNo: '',
sourceContainer: '',
})
const activeFilterCount = computed(() => {
let n = 0
if (query.type) n++
if (query.businessNo) n++
if (query.sourceContainer) n++
if (dateRange.value?.length) n++
return n
})
const isTodayFilter = computed(() => {
const range = dateRange.value
if (!range?.length || range.length < 2) return false
const today = new Date()
const start = range[0]
const end = range[1]
const todayStr = today.toISOString().slice(0, 10)
return start?.startsWith(todayStr) && end?.startsWith(todayStr)
})
function getTodayRange(): [string, string] {
const d = new Date()
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return [`${y}-${m}-${day} 00:00:00`, `${y}-${m}-${day} 23:59:59`]
}
function setToday() {
dateRange.value = getTodayRange()
handleSearch()
}
function clearDateRange() {
dateRange.value = []
handleSearch()
}
const typeMap: Record<string, string> = {
INBOUND: '入库', QUICK_PALLET_INBOUND: '托盘快速入库',
OUTBOUND: '出库', OUTBOUND_NO_WAIT: '出库(不等待)', MOVE: '移库',
}
async function loadData() {
loading.value = true
try {
const params = {
...query,
startTime: dateRange.value?.[0] || undefined,
endTime: dateRange.value?.[1] || undefined,
}
const res = await getChainPage(params)
tableData.value = res.data.records || []
if (res.data.total > 0) {
total.value = res.data.total
}
} catch { /* handled by interceptor */ } finally {
loading.value = false
}
}
function handleSearch() {
query.page = 1
loadData()
}
function handleReset() {
query.code = ''
query.status = ''
query.type = ''
query.businessNo = ''
query.sourceContainer = ''
dateRange.value = []
handleSearch()
}
function onStatusChip(value: string) {
query.status = value
handleSearch()
}
function goDetail(code: string) {
router.push(`/task/chain/${code}`)
}
async function handleScan() {
try {
const result = await ElMessageBox.prompt('请输入仓库编码', '手动扫描执行', {
confirmButtonText: '执行',
cancelButtonText: '取消',
inputPattern: /\S+/,
inputErrorMessage: '仓库编码不能为空',
})
if (typeof result === 'object' && 'value' in result) {
await dispatchScan(result.value)
ElMessage.success('扫描执行已触发')
loadData()
}
} catch { /* cancelled */ }
}
async function handleExecute(row: TaskChainVO) {
try {
await ElMessageBox.confirm(`确认手动执行任务链 ${row.code}`, '确认', { type: 'warning' })
await dispatchExecute(row.warehouse, row.code)
ElMessage.success('执行已触发')
loadData()
} catch { /* cancelled */ }
}
onMounted(loadData)
</script>
<style scoped>
/* ===== 公共 ===== */
.page-container {
display: flex;
flex-direction: column;
gap: 16px;
}
/* ===== PC ===== */
.filter-card :deep(.el-card__body) {
padding-bottom: 2px;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
/* ===== 移动端 ===== */
.m-search-bar {
display: flex;
align-items: center;
gap: 8px;
}
.m-status-tabs {
display: flex;
gap: 6px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 2px;
}
.m-status-tabs::-webkit-scrollbar {
display: none;
}
.m-status-chip {
flex-shrink: 0;
padding: 4px 12px;
border-radius: 14px;
font-size: 12px;
color: #606266;
background: #f0f2f5;
cursor: pointer;
transition: all 0.2s;
-webkit-tap-highlight-color: transparent;
}
.m-status-chip-active {
background: #409eff;
color: #fff;
}
.m-card-list {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 200px;
}
.m-chain-card {
background: #fff;
border-radius: 10px;
padding: 14px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: box-shadow 0.2s;
}
.m-chain-card:active {
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.12);
}
.m-chain-card-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.m-chain-code {
font-size: 14px;
font-weight: 600;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 65%;
}
.m-chain-card-body {
display: flex;
flex-wrap: wrap;
gap: 6px 16px;
margin-bottom: 10px;
}
.m-chain-field {
display: flex;
align-items: center;
gap: 4px;
}
.m-field-label {
font-size: 12px;
color: #909399;
}
.m-field-value {
font-size: 12px;
color: #303133;
}
.m-chain-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
color: #c0c4cc;
font-size: 12px;
}
.m-chain-time {
color: #909399;
}
.m-pagination {
display: flex;
justify-content: center;
padding: 4px 0;
}
.m-page-info {
font-size: 13px;
color: #606266;
margin: 0 8px;
}
.m-drawer-footer {
display: flex;
gap: 12px;
padding-top: 16px;
}
.m-drawer-footer .el-button {
flex: 1;
}
.quick-date-btn {
margin-left: 8px;
}
.m-date-tabs {
display: flex;
gap: 6px;
margin-bottom: 8px;
}
.m-drawer-date-actions {
display: flex;
gap: 8px;
}
</style>