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
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>
|
|
|