Files
ruoyi-vue/src/views/system/jdorder/components/TencentDocPushMonitor.vue
2026-01-17 18:43:58 +08:00

977 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<el-dialog
title="腾讯文档推送监控"
:visible="visible"
@update:visible="handleVisibleChange"
:width="isMobile ? '100%' : '1200px'"
:close-on-click-modal="false"
@close="handleClose"
:top="isMobile ? '0' : '5vh'"
:fullscreen="isMobile"
:custom-class="isMobile ? 'mobile-push-monitor-dialog' : ''"
:modal-append-to-body="true"
:append-to-body="true"
>
<div class="push-monitor">
<!-- 倒计时和状态卡片 -->
<el-card class="countdown-card" shadow="hover">
<div class="countdown-header">
<div class="header-left">
<i class="el-icon-timer"></i>
<span class="title">自动推送倒计时</span>
</div>
<div class="header-right">
<el-tag v-if="pushStatus.isScheduled" type="warning" size="medium">
<i class="el-icon-loading"></i> 等待推送中
</el-tag>
<el-tag v-else type="info" size="medium">
<i class="el-icon-circle-check"></i> 无待推送任务
</el-tag>
</div>
</div>
<div class="countdown-content">
<div class="countdown-display" :class="{active: pushStatus.isScheduled}">
<div class="time-box">
<span class="time-value">{{ countdownDisplay.minutes }}</span>
<span class="time-label"></span>
</div>
<span class="time-separator">:</span>
<div class="time-box">
<span class="time-value">{{ countdownDisplay.seconds }}</span>
<span class="time-label"></span>
</div>
</div>
<div class="countdown-info">
<div v-if="pushStatus.scheduledTime" class="info-item">
<i class="el-icon-time"></i>
<span>预计推送时间{{ formatDateTime(pushStatus.scheduledTime) }}</span>
</div>
<div v-if="pushStatus.lastSuccessRecord" class="info-item">
<i class="el-icon-success"></i>
<span>上次推送{{ formatDateTime(pushStatus.lastSuccessRecord.endTime) }}</span>
<el-tag size="mini" type="success" style="margin-left: 10px;">
成功 {{ pushStatus.lastSuccessRecord.successCount }}
</el-tag>
</div>
</div>
<div class="countdown-actions">
<el-button
type="primary"
icon="el-icon-upload2"
:loading="pushing"
:size="isMobile ? 'small' : 'default'"
@click="handleTriggerPushNow"
>
立即推送
</el-button>
<el-button
type="warning"
icon="el-icon-close"
:disabled="!pushStatus.isScheduled"
:size="isMobile ? 'small' : 'default'"
@click="handleCancelPush"
>
取消推送
</el-button>
<el-button
icon="el-icon-refresh"
:size="isMobile ? 'small' : 'default'"
@click="loadPushStatus"
>
刷新状态
</el-button>
</div>
</div>
</el-card>
<!-- 推送记录列表 -->
<el-card class="records-card" shadow="hover">
<div slot="header" class="records-header">
<div class="header-left">
<i class="el-icon-document-copy"></i>
<span class="title">推送记录</span>
<el-tag size="mini" type="info" style="margin-left: 10px;">
{{ batchRecords.length }}
</el-tag>
</div>
<div class="header-right">
<el-button type="text" icon="el-icon-refresh" @click="loadBatchRecords">刷新</el-button>
</div>
</div>
<el-timeline v-if="batchRecords.length > 0">
<el-timeline-item
v-for="record in batchRecords"
:key="record.batchId"
:timestamp="formatDateTime(record.createTime)"
placement="top"
:type="getRecordType(record.status)"
:icon="getRecordIcon(record.status)"
>
<el-card class="record-item" shadow="hover">
<div class="record-summary" @click="toggleRecordDetail(record.batchId)">
<div class="summary-left">
<el-tag :type="getStatusTagType(record.status)" size="small">
{{ getStatusText(record.status) }}
</el-tag>
<span class="trigger-source">
{{ getTriggerSourceText(record.triggerSource) }}
</span>
<span class="record-stats">
<i class="el-icon-check" style="color: #67c23a;"></i> {{ record.successCount }}
<i class="el-icon-remove-outline" style="color: #e6a23c; margin-left: 10px;"></i> {{ record.skipCount }}
<i class="el-icon-close" style="color: #f56c6c; margin-left: 10px;"></i> {{ record.errorCount }}
</span>
</div>
<div class="summary-right">
<span class="record-range"> {{ record.startRow }} - {{ record.endRow }}</span>
<span v-if="record.durationMs" class="record-duration">
耗时 {{ formatDuration(record.durationMs) }}
</span>
<i :class="expandedRecords.includes(record.batchId) ? 'el-icon-arrow-up' : 'el-icon-arrow-down'"></i>
</div>
</div>
<!-- 详情展开区域 -->
<el-collapse-transition>
<div v-if="expandedRecords.includes(record.batchId)" class="record-detail">
<el-divider></el-divider>
<!-- 加载状态 -->
<div v-if="record.loadingDetail" class="loading-detail" v-loading="true" element-loading-text="正在加载详情...">
<div style="height: 100px;"></div>
</div>
<template v-else>
<div v-if="record.resultMessage" class="detail-message">
<div class="message-label">结果消息</div>
<div class="message-content">{{ record.resultMessage }}</div>
</div>
<div v-if="record.errorMessage" class="detail-error">
<div class="error-label">错误信息</div>
<div class="error-content">{{ record.errorMessage }}</div>
</div>
<!-- 操作日志列表 -->
<div v-if="record.operationLogs && record.operationLogs.length > 0" class="operation-logs">
<div class="logs-header">
<i class="el-icon-document"></i>
<span>操作日志{{ record.operationLogs.length }} </span>
</div>
<el-table
:data="record.operationLogs"
size="mini"
max-height="300"
stripe
>
<el-table-column prop="orderNo" label="订单号" width="150" />
<el-table-column prop="operationType" label="操作类型" width="100" />
<el-table-column prop="targetRow" label="目标行" width="80" />
<el-table-column prop="logisticsLink" label="物流链接" min-width="150" show-overflow-tooltip />
<el-table-column prop="operationStatus" label="状态" width="80">
<template slot-scope="scope">
<el-tag :type="scope.row.operationStatus === 'SUCCESS' ? 'success' : 'danger'" size="mini">
{{ scope.row.operationStatus }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="errorMessage" label="错误信息" min-width="150" show-overflow-tooltip />
</el-table>
</div>
<!-- 暂无操作日志 -->
<div v-else class="no-logs">
<i class="el-icon-info"></i>
<span>暂无操作日志</span>
</div>
</template>
</div>
</el-collapse-transition>
</el-card>
</el-timeline-item>
</el-timeline>
<el-empty v-else description="暂无推送记录"></el-empty>
</el-card>
</div>
</el-dialog>
</template>
<script>
import {
getPushStatus,
getBatchPushRecords,
getBatchPushRecordDetail,
triggerPushNow,
cancelPendingPush
} from '@/api/jarvis/tendoc'
export default {
name: 'TencentDocPushMonitor',
props: {
value: {
type: Boolean,
default: false
}
},
data() {
return {
visible: false,
pushing: false,
pushStatus: {
isScheduled: false,
scheduledTime: null,
remainingSeconds: 0,
remainingMs: 0,
countdownText: '无定时任务',
lastSuccessRecord: null
},
countdownDisplay: {
minutes: '00',
seconds: '00'
},
batchRecords: [],
expandedRecords: [],
countdownTimer: null,
refreshTimer: null
}
},
computed: {
isMobile() {
if (this.$store?.getters?.device === 'mobile') {
return true
}
if (typeof window !== 'undefined' && window.innerWidth < 768) {
return true
}
return false
}
},
watch: {
value(val) {
if (this.visible !== val) {
this.visible = val
if (val) {
this.init()
} else {
this.destroy()
}
}
}
},
methods: {
async init() {
await this.loadPushStatus()
await this.loadBatchRecords()
this.startCountdown()
this.startAutoRefresh()
},
destroy() {
this.stopCountdown()
this.stopAutoRefresh()
},
async loadPushStatus() {
try {
const res = await getPushStatus()
console.log('=== 推送状态响应 ===', res)
if (res.code === 200) {
console.log('推送状态数据:', res.data)
console.log('isScheduled:', res.data.isScheduled)
console.log('remainingSeconds:', res.data.remainingSeconds)
console.log('scheduledTime:', res.data.scheduledTime)
// 重要:使用解构赋值,确保 remainingSeconds 被正确赋值
this.pushStatus = {
...res.data,
remainingSeconds: parseInt(res.data.remainingSeconds) || 0
}
this.updateCountdownDisplay()
console.log('倒计时显示:', this.countdownDisplay)
console.log('pushStatus.remainingSeconds 已更新为:', this.pushStatus.remainingSeconds)
} else {
console.error('API返回错误:', res)
}
} catch (e) {
console.error('加载推送状态失败', e)
}
},
async loadBatchRecords() {
try {
const res = await getBatchPushRecords({ limit: 20 })
console.log('加载推送记录响应:', res)
if (res.code === 200) {
const records = res.data || []
// 确保每条记录都有 operationLogs 字段(即使为空数组)
records.forEach(record => {
if (!record.hasOwnProperty('operationLogs')) {
this.$set(record, 'operationLogs', [])
}
// 重置详情加载标记,允许重新加载
this.$set(record, 'detailLoaded', false)
})
this.batchRecords = records
console.log('推送记录数量:', records.length)
records.forEach(r => {
console.log(`记录 ${r.batchId}: operationLogs数量=${r.operationLogs ? r.operationLogs.length : 'undefined'}`)
})
}
} catch (e) {
console.error('加载推送记录失败', e)
}
},
async handleTriggerPushNow() {
try {
await this.$confirm('确定要立即执行推送吗?', '提示', {
type: 'warning'
})
this.pushing = true
const res = await triggerPushNow()
if (res.code === 200) {
this.$message.success('推送已触发')
setTimeout(() => {
this.loadPushStatus()
this.loadBatchRecords()
}, 2000)
} else {
this.$message.error(res.msg || '触发推送失败')
}
} catch (e) {
if (e !== 'cancel') {
this.$message.error('触发推送失败: ' + (e.message || '未知错误'))
}
} finally {
this.pushing = false
}
},
async handleCancelPush() {
try {
await this.$confirm('确定要取消待推送任务吗?', '提示', {
type: 'warning'
})
const res = await cancelPendingPush()
if (res.code === 200) {
this.$message.success('已取消待推送任务')
this.loadPushStatus()
} else {
this.$message.error(res.msg || '取消失败')
}
} catch (e) {
if (e !== 'cancel') {
this.$message.error('取消失败: ' + (e.message || '未知错误'))
}
}
},
async toggleRecordDetail(batchId) {
const index = this.expandedRecords.indexOf(batchId)
if (index > -1) {
// 收起
this.expandedRecords.splice(index, 1)
} else {
// 展开 - 加载详情
this.expandedRecords.push(batchId)
await this.loadRecordDetail(batchId)
}
},
async loadRecordDetail(batchId) {
try {
const record = this.batchRecords.find(r => r.batchId === batchId)
if (!record) return
// 如果已经明确加载过详情(有 loadingDetail 标记且已完成),则不再重复加载
// 注意:即使 operationLogs 为空数组,也可能是数据确实为空,需要重新加载确认
if (record.detailLoaded) {
return
}
// 显示加载状态
this.$set(record, 'loadingDetail', true)
const res = await getBatchPushRecordDetail(batchId)
console.log('加载推送详情响应:', res)
console.log('响应数据:', JSON.stringify(res.data, null, 2))
if (res.code === 200 && res.data) {
// 更新记录的详细信息
const operationLogs = res.data.operationLogs || []
console.log('操作日志数量:', operationLogs.length, 'batchId:', batchId)
console.log('操作日志详情:', operationLogs)
this.$set(record, 'operationLogs', operationLogs)
this.$set(record, 'errorMessage', res.data.errorMessage)
// 使用 resultMessage 字段,如果没有则使用 remark
this.$set(record, 'resultMessage', res.data.resultMessage || res.data.remark)
// 标记已加载详情
this.$set(record, 'detailLoaded', true)
// 如果操作日志为空,输出警告信息用于调试
if (operationLogs.length === 0) {
console.warn('操作日志为空 - batchId:', batchId, '记录数据:', res.data)
}
} else {
this.$message.warning('加载详情失败: ' + (res.msg || '未知错误'))
}
} catch (e) {
console.error('加载推送详情失败', e)
this.$message.error('加载详情失败: ' + (e.message || '未知错误'))
} finally {
const record = this.batchRecords.find(r => r.batchId === batchId)
if (record) {
this.$set(record, 'loadingDetail', false)
}
}
},
startCountdown() {
this.stopCountdown()
// 立即更新一次显示
this.updateCountdownDisplay()
this.countdownTimer = setInterval(() => {
if (this.pushStatus.remainingSeconds > 0) {
this.pushStatus.remainingSeconds--
this.pushStatus.remainingMs = this.pushStatus.remainingSeconds * 1000
this.updateCountdownDisplay()
} else if (this.pushStatus.isScheduled) {
// 倒计时结束,刷新状态
this.loadPushStatus()
this.loadBatchRecords()
}
}, 1000)
},
stopCountdown() {
if (this.countdownTimer) {
clearInterval(this.countdownTimer)
this.countdownTimer = null
}
},
startAutoRefresh() {
this.stopAutoRefresh()
// 每30秒自动刷新一次状态
this.refreshTimer = setInterval(() => {
this.loadPushStatus()
this.loadBatchRecords()
}, 30000)
},
stopAutoRefresh() {
if (this.refreshTimer) {
clearInterval(this.refreshTimer)
this.refreshTimer = null
}
},
updateCountdownDisplay() {
const seconds = this.pushStatus.remainingSeconds || 0
console.log('更新倒计时显示 - remainingSeconds:', seconds)
const minutes = Math.floor(seconds / 60)
const secs = seconds % 60
this.countdownDisplay.minutes = String(minutes).padStart(2, '0')
this.countdownDisplay.seconds = String(secs).padStart(2, '0')
console.log('倒计时显示更新为:', this.countdownDisplay.minutes + ':' + this.countdownDisplay.seconds)
},
formatDateTime(dateTime) {
if (!dateTime) return '-'
try {
// 处理多种时间格式
const date = new Date(dateTime)
if (isNaN(date.getTime())) return dateTime
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
} catch (e) {
console.error('格式化时间失败:', e, dateTime)
return dateTime
}
},
formatDuration(ms) {
if (!ms) return '-'
const seconds = Math.floor(ms / 1000)
const minutes = Math.floor(seconds / 60)
const secs = seconds % 60
return minutes > 0 ? `${minutes}${secs}` : `${secs}`
},
getStatusText(status) {
const statusMap = {
'RUNNING': '执行中',
'SUCCESS': '成功',
'PARTIAL': '部分成功',
'FAILED': '失败'
}
return statusMap[status] || status
},
getStatusTagType(status) {
const typeMap = {
'RUNNING': 'warning',
'SUCCESS': 'success',
'PARTIAL': 'warning',
'FAILED': 'danger'
}
return typeMap[status] || 'info'
},
getRecordType(status) {
const typeMap = {
'SUCCESS': 'success',
'PARTIAL': 'warning',
'FAILED': 'danger',
'RUNNING': 'primary'
}
return typeMap[status] || 'info'
},
getRecordIcon(status) {
const iconMap = {
'SUCCESS': 'el-icon-success',
'PARTIAL': 'el-icon-warning',
'FAILED': 'el-icon-error',
'RUNNING': 'el-icon-loading'
}
return iconMap[status] || 'el-icon-info'
},
getTriggerSourceText(source) {
const sourceMap = {
'DELAYED_TIMER': '延迟定时器',
'USER': '用户手动',
'SYSTEM': '系统'
}
return sourceMap[source] || source
},
handleClose() {
this.visible = false
this.expandedRecords = []
this.$emit('input', false)
},
handleVisibleChange(val) {
this.visible = val
this.$emit('input', val)
if (!val) {
this.expandedRecords = []
}
}
},
beforeDestroy() {
this.destroy()
}
}
</script>
<style scoped>
.push-monitor {
display: flex;
flex-direction: column;
gap: 20px;
}
/* 倒计时卡片 */
.countdown-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.countdown-card >>> .el-card__body {
padding: 0;
}
.countdown-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
.countdown-header .header-left {
display: flex;
align-items: center;
gap: 10px;
font-size: 18px;
font-weight: 500;
}
.countdown-header .header-left i {
font-size: 24px;
}
.countdown-content {
padding: 30px 20px 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.countdown-display {
display: flex;
align-items: center;
gap: 15px;
font-size: 48px;
font-weight: bold;
opacity: 0.5;
transition: all 0.3s;
}
.countdown-display.active {
opacity: 1;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
.time-box {
display: flex;
flex-direction: column;
align-items: center;
min-width: 80px;
padding: 10px 20px;
background: rgba(255, 255, 255, 0.2);
border-radius: 10px;
}
.time-value {
font-size: 48px;
line-height: 1;
}
.time-label {
font-size: 14px;
margin-top: 5px;
opacity: 0.8;
}
.time-separator {
font-size: 36px;
opacity: 0.6;
}
.countdown-info {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
max-width: 600px;
}
.info-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
padding: 8px 15px;
background: rgba(255, 255, 255, 0.1);
border-radius: 6px;
}
.info-item i {
font-size: 16px;
}
.countdown-actions {
display: flex;
gap: 10px;
}
/* 推送记录卡片 */
.records-card {
flex: 1;
}
.records-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.records-header .header-left {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 500;
}
.records-header .header-left i {
font-size: 20px;
color: #409eff;
}
.record-item {
cursor: pointer;
transition: all 0.3s;
}
.record-item:hover {
transform: translateY(-2px);
}
.record-summary {
display: flex;
justify-content: space-between;
align-items: center;
}
.summary-left,
.summary-right {
display: flex;
align-items: center;
gap: 15px;
}
.trigger-source {
color: #909399;
font-size: 13px;
}
.record-stats {
display: flex;
align-items: center;
font-size: 14px;
font-weight: 500;
}
.record-range,
.record-duration {
color: #606266;
font-size: 13px;
}
.record-detail {
margin-top: 15px;
}
.detail-message,
.detail-error {
margin-bottom: 15px;
}
.message-label,
.error-label {
font-weight: 500;
margin-bottom: 5px;
color: #606266;
}
.message-content {
padding: 10px;
background: #f0f9ff;
border-left: 3px solid #409eff;
border-radius: 4px;
font-size: 13px;
}
.error-content {
padding: 10px;
background: #fef0f0;
border-left: 3px solid #f56c6c;
border-radius: 4px;
font-size: 13px;
color: #f56c6c;
}
.operation-logs {
margin-top: 15px;
}
.logs-header {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
margin-bottom: 10px;
color: #606266;
}
.logs-header i {
color: #409eff;
}
.loading-detail {
text-align: center;
padding: 20px;
color: #909399;
}
.no-logs {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 30px;
color: #909399;
font-size: 14px;
}
.no-logs i {
font-size: 18px;
}
/* 移动端适配 */
@media (max-width: 768px) {
.push-monitor {
gap: 12px;
}
.countdown-header {
flex-direction: column;
align-items: flex-start;
gap: 10px;
padding: 12px;
}
.countdown-header .header-left {
font-size: 16px;
}
.countdown-header .header-left i {
font-size: 20px;
}
.countdown-content {
padding: 20px 12px 12px;
gap: 15px;
}
.countdown-display {
font-size: 32px;
gap: 10px;
}
.time-box {
min-width: 60px;
padding: 8px 12px;
}
.time-value {
font-size: 32px;
}
.time-separator {
font-size: 24px;
}
.countdown-info {
gap: 8px;
}
.info-item {
font-size: 12px;
padding: 6px 12px;
flex-wrap: wrap;
}
.countdown-actions {
flex-direction: column;
width: 100%;
gap: 8px;
}
.countdown-actions .el-button {
width: 100%;
margin: 0;
}
.records-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.records-header .header-left {
font-size: 14px;
}
.record-summary {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.summary-left,
.summary-right {
width: 100%;
flex-wrap: wrap;
gap: 8px;
}
.record-stats {
font-size: 12px;
}
.trigger-source,
.record-range,
.record-duration {
font-size: 12px;
}
}
/* 移动端全屏弹窗样式 */
::v-deep .mobile-push-monitor-dialog {
margin: 0 !important;
width: 100% !important;
height: 100% !important;
max-width: 100% !important;
max-height: 100% !important;
}
::v-deep .mobile-push-monitor-dialog .el-dialog {
margin: 0 !important;
width: 100% !important;
height: 100% !important;
max-width: 100% !important;
max-height: 100% !important;
border-radius: 0 !important;
display: flex;
flex-direction: column;
}
::v-deep .mobile-push-monitor-dialog .el-dialog__body {
flex: 1;
overflow-y: auto;
padding: 12px;
-webkit-overflow-scrolling: touch;
}
::v-deep .mobile-push-monitor-dialog .el-dialog__header {
padding: 12px 16px;
border-bottom: 1px solid #e4e7ed;
flex-shrink: 0;
}
::v-deep .mobile-push-monitor-dialog .el-dialog__title {
font-size: 16px;
}
</style>