统一暗色模式页面/顶栏/Handsontable 选中色,调整下单到仓方式文案与提示,CustomLeftMenu 卡片统一 60px;补充揽收单、问题件处理与 H5 相关页面。 Co-authored-by: Cursor <cursoragent@cursor.com>
391 lines
11 KiB
Vue
391 lines
11 KiB
Vue
<script setup lang="ts">
|
|
import { renderWarehouseActionBtn, wrapWarehouseActions } from '@/utils/warehouseActionRenderer'
|
|
import { computed, onMounted, ref } from 'vue'
|
|
import { useRoute } from 'vue-router'
|
|
import { useI18n } from 'vue-i18n'
|
|
import dayjs from 'dayjs'
|
|
import HandsontablePro from '@/components/HandsontablePro/index.vue'
|
|
import PaginationPro from '@/components/PaginationPro/index.vue'
|
|
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
|
|
import {
|
|
getOrderBatchDlxStats,
|
|
getOrderBatchJobList,
|
|
postOrderBatchJobCancel,
|
|
postOrderBatchJobRetryFailed,
|
|
} from '@/api/order'
|
|
import { $baseMessage } from '@/utils'
|
|
import { hasPermission } from '@/utils/rbac-permission'
|
|
import {
|
|
ORDER_BATCH_ACTION_OPTIONS,
|
|
ORDER_BATCH_JOB_STATUS_OPTIONS,
|
|
getBatchActionLabelKey,
|
|
getBatchJobStatusLabelKey,
|
|
} from '@/constants/orderBatchAction'
|
|
import BatchJobDetailDrawer from './components/BatchJobDetailDrawer.vue'
|
|
|
|
defineOptions({ name: 'OrderBatchJobs' })
|
|
|
|
const { t } = useI18n()
|
|
const route = useRoute()
|
|
const { tableContainerRef, tableHeight: handsontableHeight } = useTableContainerHeight()
|
|
|
|
const listLoading = ref(false)
|
|
const currentPage = ref(1)
|
|
const pageSize = ref(30)
|
|
const totalCount = ref(0)
|
|
const tableRows = ref<any[]>([])
|
|
|
|
const filterStatus = ref<string | undefined>()
|
|
const filterAction = ref<string | undefined>()
|
|
const filterJobId = ref<number | undefined>()
|
|
|
|
const dlxStats = ref<{ enabled: boolean; deadLetterMessageCount: number; dlxAlertThreshold: number; alert: boolean } | null>(null)
|
|
const dlxLoading = ref(false)
|
|
|
|
const drawerVisible = ref(false)
|
|
const activeJobId = ref<number | null>(null)
|
|
|
|
const statusOptions = computed(() =>
|
|
ORDER_BATCH_JOB_STATUS_OPTIONS.map((item) => ({
|
|
label: t(item.labelKey),
|
|
value: item.value,
|
|
})),
|
|
)
|
|
|
|
const actionOptions = computed(() =>
|
|
ORDER_BATCH_ACTION_OPTIONS.map((item) => ({
|
|
label: t(item.labelKey),
|
|
value: item.value,
|
|
})),
|
|
)
|
|
|
|
function formatTime(value?: string) {
|
|
if (!value) return '-'
|
|
const parsed = dayjs(value)
|
|
return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm:ss') : value
|
|
}
|
|
|
|
function mapRow(raw: any) {
|
|
return {
|
|
jobId: raw.jobId,
|
|
action: raw.action,
|
|
actionLabel: t(getBatchActionLabelKey(raw.action)),
|
|
status: raw.status,
|
|
statusLabel: t(getBatchJobStatusLabelKey(raw.status)),
|
|
total: raw.total,
|
|
successCount: raw.successCount,
|
|
failCount: raw.failCount,
|
|
progressText: `${raw.successCount}/${raw.total}`,
|
|
createTime: formatTime(raw.createTime),
|
|
finishedAt: formatTime(raw.finishedAt),
|
|
_raw: raw,
|
|
}
|
|
}
|
|
|
|
async function loadList() {
|
|
listLoading.value = true
|
|
try {
|
|
const { items, total } = await getOrderBatchJobList({
|
|
page: currentPage.value,
|
|
pageSize: pageSize.value,
|
|
status: filterStatus.value,
|
|
action: filterAction.value,
|
|
jobId: filterJobId.value,
|
|
})
|
|
tableRows.value = (items || []).map(mapRow)
|
|
totalCount.value = Number(total || 0)
|
|
} catch (e: any) {
|
|
$baseMessage(e?.message || t('order.batchJobs.message.loadFailed'), 'error')
|
|
tableRows.value = []
|
|
totalCount.value = 0
|
|
} finally {
|
|
listLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function loadDlxStats() {
|
|
dlxLoading.value = true
|
|
try {
|
|
dlxStats.value = await getOrderBatchDlxStats()
|
|
} catch {
|
|
dlxStats.value = null
|
|
} finally {
|
|
dlxLoading.value = false
|
|
}
|
|
}
|
|
|
|
function handleSearch() {
|
|
currentPage.value = 1
|
|
loadList()
|
|
}
|
|
|
|
function handleReset() {
|
|
filterStatus.value = undefined
|
|
filterAction.value = undefined
|
|
filterJobId.value = undefined
|
|
currentPage.value = 1
|
|
loadList()
|
|
}
|
|
|
|
function handlePageChange(page: number) {
|
|
currentPage.value = page
|
|
loadList()
|
|
}
|
|
|
|
function handlePageSizeChange(size: number) {
|
|
pageSize.value = size
|
|
currentPage.value = 1
|
|
loadList()
|
|
}
|
|
|
|
function openDetail(jobId: number) {
|
|
activeJobId.value = jobId
|
|
drawerVisible.value = true
|
|
}
|
|
|
|
async function handleRetry(row: any) {
|
|
if (!hasPermission('Order.BatchJobs.Retry')) return
|
|
try {
|
|
const result = await postOrderBatchJobRetryFailed(row.jobId)
|
|
$baseMessage(t('order.batchJobs.message.retrySuccess', { count: result.retriedCount }), 'success')
|
|
await loadList()
|
|
} catch (e: any) {
|
|
$baseMessage(e?.message || t('order.batchJobs.message.retryFailed'), 'error')
|
|
}
|
|
}
|
|
|
|
async function handleCancel(row: any) {
|
|
if (!hasPermission('Order.BatchJobs.Cancel')) return
|
|
try {
|
|
const result = await postOrderBatchJobCancel(row.jobId)
|
|
$baseMessage(t('order.batchJobs.message.cancelSuccess', { count: result.cancelledCount }), 'success')
|
|
await loadList()
|
|
} catch (e: any) {
|
|
$baseMessage(e?.message || t('order.batchJobs.message.cancelFailed'), 'error')
|
|
}
|
|
}
|
|
|
|
function handleActionClick(row: any, _rowIndex: number, action?: string) {
|
|
if (!row?.jobId || !action) return
|
|
if (action === 'view') {
|
|
openDetail(row.jobId)
|
|
return
|
|
}
|
|
if (action === 'retry') {
|
|
handleRetry(row)
|
|
return
|
|
}
|
|
if (action === 'cancel') {
|
|
handleCancel(row)
|
|
}
|
|
}
|
|
|
|
const hotColumns = computed(() => [
|
|
{ key: 'jobId', label: t('order.batchJobs.table.jobId'), width: 90 },
|
|
{ key: 'actionLabel', label: t('order.batchJobs.table.action'), width: 140 },
|
|
{ key: 'statusLabel', label: t('order.batchJobs.table.status'), width: 100 },
|
|
{ key: 'progressText', label: t('order.batchJobs.table.progress'), width: 100 },
|
|
{ key: 'failCount', label: t('order.batchJobs.table.failCountShort'), width: 80 },
|
|
{ key: 'createTime', label: t('order.batchJobs.table.createTime'), width: 170 },
|
|
{ key: 'finishedAt', label: t('order.batchJobs.table.finishedAt'), width: 170 },
|
|
])
|
|
|
|
function actionRenderer(rowData: any, _rowIndex: number) {
|
|
const actions: Array<{ key: 'view' | 'retry' | 'cancel'; label: string; show?: boolean }> = [
|
|
{ key: 'view', label: t('order.batchJobs.actions.view'), show: hasPermission('Order.BatchJobs.View') },
|
|
{ key: 'retry', label: t('order.batchJobs.actions.retryFailed'), show: hasPermission('Order.BatchJobs.Retry') && Number(rowData?.failCount || 0) > 0 },
|
|
{ key: 'cancel', label: t('order.batchJobs.actions.cancelPending'), show: hasPermission('Order.BatchJobs.Cancel') && ['pending', 'running'].includes(String(rowData?.status || '').toLowerCase()) },
|
|
]
|
|
return wrapWarehouseActions(actions.filter((item) => item.show).map((item) => renderWarehouseActionBtn(item.key, item.label)))
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await loadList()
|
|
loadDlxStats()
|
|
const jobId = Number(route.query.jobId)
|
|
if (Number.isFinite(jobId) && jobId > 0) {
|
|
openDetail(jobId)
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="batch-jobs-page">
|
|
<div class="batch-jobs-page-card">
|
|
<a-alert
|
|
v-if="dlxStats?.enabled && dlxStats.alert"
|
|
type="warning"
|
|
banner
|
|
closable
|
|
style="margin-bottom: 12px"
|
|
>
|
|
{{
|
|
t('order.batchJobs.dlx.alert', {
|
|
count: dlxStats.deadLetterMessageCount,
|
|
threshold: dlxStats.dlxAlertThreshold,
|
|
})
|
|
}}
|
|
<a-link style="margin-left: 8px" @click="loadDlxStats">{{ t('order.batchJobs.actions.refresh') }}</a-link>
|
|
</a-alert>
|
|
|
|
<div class="table-toolbar">
|
|
<div class="toolbar-left">
|
|
<a-space wrap :size="8">
|
|
<a-input-number
|
|
v-model="filterJobId"
|
|
:placeholder="t('order.batchJobs.filter.jobId')"
|
|
:min="1"
|
|
:precision="0"
|
|
allow-clear
|
|
hide-button
|
|
style="width: 140px"
|
|
@press-enter="handleSearch"
|
|
/>
|
|
<a-select
|
|
v-model="filterStatus"
|
|
:placeholder="t('order.batchJobs.filter.status')"
|
|
allow-clear
|
|
style="width: 140px"
|
|
>
|
|
<a-option v-for="opt in statusOptions" :key="opt.value" :value="opt.value">
|
|
{{ opt.label }}
|
|
</a-option>
|
|
</a-select>
|
|
<a-select
|
|
v-model="filterAction"
|
|
:placeholder="t('order.batchJobs.filter.action')"
|
|
allow-clear
|
|
style="width: 180px"
|
|
>
|
|
<a-option v-for="opt in actionOptions" :key="opt.value" :value="opt.value">
|
|
{{ opt.label }}
|
|
</a-option>
|
|
</a-select>
|
|
<a-button class="app-btn-search" @click="handleSearch">{{ t('order.batchJobs.actions.search') }}</a-button>
|
|
<a-button @click="handleReset">{{ t('order.batchJobs.actions.reset') }}</a-button>
|
|
<a-button :loading="listLoading || dlxLoading" @click="loadList(); loadDlxStats()">
|
|
{{ t('order.batchJobs.actions.refresh') }}
|
|
</a-button>
|
|
</a-space>
|
|
</div>
|
|
<div class="toolbar-right">
|
|
<PaginationPro
|
|
:total-count="totalCount"
|
|
v-model:current-page="currentPage"
|
|
v-model:page-size="pageSize"
|
|
@page-change="handlePageChange"
|
|
@page-size-change="handlePageSizeChange"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<a-spin class="list-spin" :loading="listLoading">
|
|
<div ref="tableContainerRef" class="list-wrap">
|
|
<HandsontablePro
|
|
:columns="hotColumns"
|
|
:data="tableRows"
|
|
:total="totalCount"
|
|
:current-page="currentPage"
|
|
:page-size="pageSize"
|
|
:show-action-column="true"
|
|
:action-renderer="actionRenderer"
|
|
:stretch-columns="true"
|
|
:height="handsontableHeight"
|
|
:read-only="true"
|
|
@action-click="handleActionClick"
|
|
/>
|
|
</div>
|
|
</a-spin>
|
|
</div>
|
|
|
|
<BatchJobDetailDrawer
|
|
v-model:visible="drawerVisible"
|
|
:job-id="activeJobId"
|
|
@refreshed="loadList"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped lang="less">
|
|
.batch-jobs-page {
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
padding: 16px 16px 24px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: var(--app-page-bg);
|
|
flex: 1;
|
|
min-height: 0;
|
|
overflow-x: hidden;
|
|
overflow-y: hidden;
|
|
}
|
|
|
|
.batch-jobs-page-card {
|
|
flex: 1;
|
|
min-height: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: var(--app-card-bg);
|
|
border-radius: 8px;
|
|
padding: 0;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
|
box-sizing: border-box;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.table-toolbar {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
padding: 16px 16px 0;
|
|
margin-bottom: 16px;
|
|
flex-shrink: 0;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.toolbar-left,
|
|
.toolbar-right {
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.list-spin {
|
|
flex: 1;
|
|
min-height: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 0 16px 16px;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.list-spin :deep(.arco-spin-container) {
|
|
flex: 1;
|
|
min-height: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.list-wrap {
|
|
width: 100%;
|
|
min-width: 0;
|
|
flex: 1;
|
|
min-height: 0;
|
|
box-sizing: border-box;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
overscroll-behavior: contain;
|
|
touch-action: pan-x pan-y;
|
|
}
|
|
|
|
.list-wrap :deep(.handsontable-pro-container) {
|
|
width: 100%;
|
|
flex: 1;
|
|
min-width: 0;
|
|
min-height: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
</style>
|