front/src/views/order/batchJobs/index.vue
2026-09-05 18:37:50 +08:00

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(50)
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>