622 lines
18 KiB
Vue
622 lines
18 KiB
Vue
<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>
|