You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

443 lines
14 KiB

<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { devApi, pushTaskApi } from '../api'
import StatusTag from '../components/StatusTag.vue'
import JsonBlock from '../components/JsonBlock.vue'
import { formatAmount, formatDateTime } from '../utils/format'
import { ElMessage, ElMessageBox } from 'element-plus'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const tasks = ref([])
const filters = reactive({ status: '', targetSystem: '', keyword: '' })
const detailVisible = ref(false)
const detailLoading = ref(false)
const detail = ref(null)
const mockBehavior = ref('SUCCESS')
const savingBehavior = ref(false)
const actionLoading = ref('')
const STATUSES = [
'PENDING',
'RETRY_WAIT',
'UNKNOWN',
'SUCCESS',
'SUCCESS_WITH_EXISTING',
'FAILED'
]
const BEHAVIORS = [
{ value: 'SUCCESS', label: 'SUCCESS · 正常成功' },
{ value: 'DUPLICATE', label: 'DUPLICATE · 重复单据' },
{ value: 'TIMEOUT', label: 'TIMEOUT · 超时结果未知' },
{ value: 'ERROR', label: 'ERROR · 接口报错' }
]
const canPush = computed(() => ['PENDING', 'RETRY_WAIT'].includes(detail.value?.status))
const canReconcile = computed(() => detail.value?.status === 'UNKNOWN')
const canRetry = computed(() =>
['FAILED', 'RETRY_WAIT', 'PENDING', 'UNKNOWN'].includes(detail.value?.status)
)
async function load() {
loading.value = true
try {
tasks.value = await pushTaskApi.list({
status: filters.status || undefined,
targetSystem: filters.targetSystem || undefined,
keyword: filters.keyword || undefined
})
} finally {
loading.value = false
}
}
async function loadBehavior() {
try {
const result = await devApi.getNccBehavior()
mockBehavior.value = result.behavior
} catch (e) {
// dev endpoints may be disabled outside demo profiles
}
}
async function saveBehavior() {
savingBehavior.value = true
try {
const result = await devApi.setNccBehavior(mockBehavior.value)
mockBehavior.value = result.behavior
ElMessage.success(`模拟行为已切换为 ${result.behavior}`)
} finally {
savingBehavior.value = false
}
}
async function resetMock() {
try {
await ElMessageBox.confirm(
'清空模拟 NCC 中已接受的凭证,便于重复演示推送。确定继续?',
'重置模拟 NCC',
{ type: 'warning' }
)
} catch (e) {
return
}
await devApi.resetNcc()
ElMessage.success('模拟 NCC 数据已清空')
}
async function openDetail(id) {
detailVisible.value = true
detailLoading.value = true
detail.value = null
try {
detail.value = await pushTaskApi.detail(id)
} finally {
detailLoading.value = false
}
}
async function reloadDetail() {
if (detail.value?.id) {
detail.value = await pushTaskApi.detail(detail.value.id)
}
}
async function runAction(action) {
if (!detail.value) {
return
}
const labels = {
push: '立即推送',
retry: '重试',
reconcile: '查询核对'
}
try {
await ElMessageBox.confirm(
`确定对推送任务 #${detail.value.id}(${detail.value.voucherNo})执行“${labels[action]}”?`,
labels[action],
{ type: 'warning' }
)
} catch (e) {
return
}
actionLoading.value = action
try {
let task
if (action === 'push') {
task = await pushTaskApi.push(detail.value.id, mockBehavior.value)
} else if (action === 'retry') {
task = await pushTaskApi.retry(detail.value.id)
} else {
task = await pushTaskApi.reconcile(detail.value.id)
}
ElMessage.success(`操作完成,任务状态:${task.status}`)
await reloadDetail()
await load()
} finally {
actionLoading.value = ''
}
}
async function quickPush(row) {
try {
await ElMessageBox.confirm(
`立即推送凭证 ${row.voucherNo || ''} 至 ${row.targetSystem}?当前模拟行为:${mockBehavior.value}`,
'立即推送',
{ type: 'warning' }
)
} catch (e) {
return
}
const task = await pushTaskApi.push(row.id, mockBehavior.value)
ElMessage.success(`推送结果:${task.status}`)
await load()
}
function resetFilters() {
filters.status = ''
filters.targetSystem = ''
filters.keyword = ''
load()
}
watch(
() => route.query.taskId,
(value) => {
if (value) {
openDetail(Number(value))
}
}
)
onMounted(async () => {
await Promise.all([load(), loadBehavior()])
const taskId = Number(route.query.taskId)
if (taskId) {
await openDetail(taskId)
}
})
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">推送监控</h2>
</div>
<el-button :loading="loading" @click="load">刷新</el-button>
</div>
<el-card shadow="never" class="mock-card">
<div class="mock-bar">
<el-select v-model="mockBehavior" style="width: 220px">
<el-option v-for="b in BEHAVIORS" :key="b.value" :label="b.label" :value="b.value" />
</el-select>
<el-button :loading="savingBehavior" @click="saveBehavior">应用模拟行为</el-button>
<el-button @click="resetMock">清空模拟数据</el-button>
</div>
</el-card>
<el-card shadow="never" style="margin-top: 14px">
<div class="filter-bar">
<el-select
v-model="filters.status"
placeholder="任务状态"
clearable
style="width: 160px"
@change="load"
>
<el-option v-for="s in STATUSES" :key="s" :label="s" :value="s" />
</el-select>
<el-input
v-model="filters.targetSystem"
placeholder="目标系统"
clearable
style="width: 140px"
@keyup.enter="load"
/>
<el-input
v-model="filters.keyword"
placeholder="凭证号 / 幂等键 / 外部凭证号"
clearable
style="width: 280px"
@keyup.enter="load"
@clear="load"
/>
<el-button type="primary" @click="load">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
<span class="muted">共 {{ tasks.length }} 条</span>
</div>
<el-table v-loading="loading" :data="tasks" size="small" empty-text="暂无推送任务">
<el-table-column prop="id" label="#" width="60" />
<el-table-column label="凭证" min-width="190">
<template #default="{ row }">
<el-button
text
type="primary"
@click="router.push(`/vouchers/${row.voucherId}`)"
>
{{ row.voucherNo || `#${row.voucherId}` }}
</el-button>
</template>
</el-table-column>
<el-table-column label="任务状态" width="120">
<template #default="{ row }"><StatusTag :status="row.status" /></template>
</el-table-column>
<el-table-column prop="targetSystem" label="目标" width="90" />
<el-table-column label="尝试" width="80">
<template #default="{ row }">{{ row.attemptCount }}/{{ row.maxAttempts }}</template>
</el-table-column>
<el-table-column prop="externalVoucherNo" label="外部凭证号" min-width="150">
<template #default="{ row }">{{ row.externalVoucherNo || '-' }}</template>
</el-table-column>
<el-table-column label="错误" min-width="200">
<template #default="{ row }">
<span v-if="row.lastErrorCode" class="mono">{{ row.lastErrorCode }}</span>
<span class="muted"> {{ row.lastErrorMessage || '' }}</span>
</template>
</el-table-column>
<el-table-column label="下次重试" width="170">
<template #default="{ row }">{{ formatDateTime(row.nextRetryAt) }}</template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="190" fixed="right">
<template #default="{ row }">
<el-button text type="primary" @click="openDetail(row.id)">详情</el-button>
<el-button
v-if="['PENDING', 'RETRY_WAIT'].includes(row.status)"
text
@click="quickPush(row)"
>
推送
</el-button>
<el-button
v-else-if="row.status === 'UNKNOWN'"
text
type="warning"
@click="openDetail(row.id)"
>
核对
</el-button>
<el-button
v-else-if="row.status === 'FAILED'"
text
type="danger"
@click="openDetail(row.id)"
>
重试
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-drawer
v-model="detailVisible"
:title="detail ? `推送任务 #${detail.id}` : '推送任务'"
size="66%"
destroy-on-close
>
<div v-loading="detailLoading">
<template v-if="detail">
<div class="toolbar-actions" style="margin-bottom: 14px">
<StatusTag :status="detail.status" />
<el-button
v-if="canPush"
type="primary"
:loading="actionLoading === 'push'"
@click="runAction('push')"
>
立即推送
</el-button>
<el-button
v-if="canRetry"
type="warning"
:loading="actionLoading === 'retry'"
@click="runAction('retry')"
>
重试
</el-button>
<el-button
v-if="canReconcile"
type="warning"
plain
:loading="actionLoading === 'reconcile'"
@click="runAction('reconcile')"
>
查询核对
</el-button>
<el-button @click="router.push(`/vouchers/${detail.voucherId}`)">查看凭证</el-button>
</div>
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="凭证号">
{{ detail.voucherNo || '-' }}
</el-descriptions-item>
<el-descriptions-item label="凭证状态">
<StatusTag :status="detail.voucherStatus" />
</el-descriptions-item>
<el-descriptions-item label="目标系统">
{{ detail.targetSystem }} / {{ detail.adapterCode }}
</el-descriptions-item>
<el-descriptions-item label="尝试次数">
{{ detail.attemptCount }} / {{ detail.maxAttempts }}
</el-descriptions-item>
<el-descriptions-item label="外部凭证号">
{{ detail.externalVoucherNo || '-' }}
</el-descriptions-item>
<el-descriptions-item label="外部凭证 ID">
{{ detail.externalVoucherId || '-' }}
</el-descriptions-item>
<el-descriptions-item label="下次重试">
{{ formatDateTime(detail.nextRetryAt) }}
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ formatDateTime(detail.createdAt) }}
</el-descriptions-item>
<el-descriptions-item label="最后错误">
<span class="mono">{{ detail.lastErrorCode || '-' }}</span>
</el-descriptions-item>
<el-descriptions-item label="错误信息" :span="3">
{{ detail.lastErrorMessage || '-' }}
</el-descriptions-item>
<el-descriptions-item label="幂等键" :span="3">
<span class="mono">{{ detail.idempotencyKey }}</span>
</el-descriptions-item>
<el-descriptions-item label="凭证金额" :span="3">
<template v-if="detail.voucher">
借方 {{ formatAmount(detail.voucher.totalDebit, detail.voucher.currency) }} ·
贷方 {{ formatAmount(detail.voucher.totalCredit, detail.voucher.currency) }}
</template>
<span v-else>-</span>
</el-descriptions-item>
</el-descriptions>
<div class="section-title" style="margin-top: 18px">尝试记录</div>
<el-table :data="detail.attempts" size="small" empty-text="暂无尝试记录">
<el-table-column prop="attemptNo" label="#" width="60" />
<el-table-column label="结果" width="110">
<template #default="{ row }"><StatusTag :status="row.status" /></template>
</el-table-column>
<el-table-column label="耗时" width="90">
<template #default="{ row }">
{{ row.durationMs == null ? '-' : row.durationMs + ' ms' }}
</template>
</el-table-column>
<el-table-column prop="errorMessage" label="错误信息" min-width="200" />
<el-table-column label="时间" width="170">
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column type="expand">
<template #default="{ row }">
<div class="attempt-panels">
<div>
<div class="muted">请求</div>
<JsonBlock :value="row.requestJson" />
</div>
<div>
<div class="muted">响应</div>
<JsonBlock :value="row.responseJson" />
</div>
</div>
</template>
</el-table-column>
</el-table>
<div class="section-title" style="margin-top: 18px">最近请求快照</div>
<JsonBlock :value="detail.requestSnapshotJson" />
</template>
</div>
</el-drawer>
</div>
</template>
<style scoped>
.mock-bar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
flex-wrap: wrap;
}
.attempt-panels {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 8px 16px;
}
@media (max-width: 1100px) {
.attempt-panels {
grid-template-columns: 1fr;
}
}
</style>