fix: 轮询按运营商账户限流
All checks were successful
构建并部署前端到测试环境 / deploy (push) Successful in 55s

This commit is contained in:
luo
2026-10-26 14:31:15 +08:00
parent 049f8257fb
commit 06684e1fc0
88 changed files with 7919 additions and 486 deletions

View File

@@ -38,6 +38,9 @@
import { ExportTaskService } from '@/api/modules'
import { useAuth } from '@/composables/useAuth'
import { getExportTaskSceneName } from '@/config/constants'
import { isPollingPlatformExport } from '@/config/constants/exportTask'
import { useUserStore } from '@/store/modules/user'
import { isPollingPlatform } from '@/utils/business/pollingScheduler'
import type { CreateExportTaskResponse, ExportTaskFormat, ExportTaskScene } from '@/types/api'
const props = withDefaults(
@@ -64,6 +67,7 @@
}>()
const { hasAuth } = useAuth()
const user = useUserStore()
const submitting = ref(false)
const form = reactive({
format: props.defaultFormat
@@ -107,6 +111,10 @@
}
const submit = async () => {
if (isPollingPlatformExport(props.scene) && !isPollingPlatform(user.info.user_type)) {
ElMessage.warning('仅平台账号可创建该导出任务')
return
}
if (props.confirmPermission && !hasAuth(props.confirmPermission)) {
ElMessage.warning('您没有创建导出任务的权限')
return

View File

@@ -0,0 +1,58 @@
<template>
<SchedulerTable
:data="rows"
:definitions="fields"
:formatter="format"
row-key="metric_key"
@refresh="$emit('refresh')"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import SchedulerTable from '@/components/business/polling/SchedulerTable.vue'
import { gatewayDisplayValue } from '@/utils/business/gateway'
import type { GatewayOperationStats } from '@/types/api/gateway'
defineOptions({ inheritAttrs: false })
const props = defineProps<{
values: {
carrier_id?: number
carrier_name?: string
time?: string
operations: GatewayOperationStats[]
}[]
}>()
defineEmits<{ refresh: [] }>()
const rows = computed(() =>
props.values.flatMap((group, index) =>
(group.operations || []).map((op) => ({
...op,
carrier_name: group.carrier_id === 0 ? '无法解析账户的卡' : group.carrier_name,
time: group.time,
metric_key: `${index}:${op.operation}`
}))
)
)
const fields = [
['carrier_name', '运营商账户'],
['time', '时间桶起点'],
['operation_name', '操作'],
['attempts', '实际请求'],
['success', '成功'],
['success_rate', '成功率'],
['avg_qps', '平均 QPS'],
['peak_minute_qps', '峰值分钟 QPS'],
['avg_duration_ms', '平均耗时(ms)'],
['p95_duration_ms', 'p95 上界(ms,分桶近似)'],
['rate_limited', '拦截次数'],
['would_limit', '本应限流次数(观察)'],
['failures', '失败分类'],
['exempt', '豁免来源']
]
.filter(([key]) => key !== (props.values[0]?.time ? 'carrier_name' : 'time'))
.map(([prop, label]) => ({ prop, label, width: 180 }))
function format(row: Record<string, any>, key: string) {
if (key === 'success_rate') return row[key] == null ? '-' : `${(row[key] * 100).toFixed(2)}%`
if (key === 'p95_duration_ms' && row[key] === -1) return '超过最大分桶'
return gatewayDisplayValue(row[key], key)
}
</script>

View File

@@ -19,6 +19,9 @@
/></ElSelect>
</ElFormItem>
<ElFormItem label="上游可能已受理"><ElSwitch v-model="model.maybe_accepted" /></ElFormItem>
<ElFormItem v-if="model.category === 'terminal'" label="自动关闭轮询"
><ElSwitch v-model="model.auto_disable_polling" /><span>仅终态类别生效</span></ElFormItem
>
<ElFormItem label="匹配优先级"
><ElInputNumber v-model="model.priority" :min="1" :max="10000" :precision="0" /><span
>数值越小越先匹配</span
@@ -35,7 +38,15 @@
</template>
<script setup lang="ts">
import { watch } from 'vue'
import type { GatewayRuleInput, GatewayOptions } from '@/types/api/gateway'
const model = defineModel<GatewayRuleInput>({ required: true })
watch(
() => model.value.category,
(value) => {
if (value !== 'terminal' && model.value.auto_disable_polling)
model.value.auto_disable_polling = false
}
)
defineProps<{ options: GatewayOptions; disabled?: boolean }>()
</script>

View File

@@ -0,0 +1,30 @@
<template>
<div class="nullable-number">
<ElSwitch
:model-value="model != null"
active-text="指定"
:inactive-text="emptyLabel || '使用默认'"
@update:model-value="(value) => (model = value ? (min ?? 0) : null)"
/>
<ElInputNumber
v-if="model != null"
v-model="model"
:min="min"
:max="max"
:precision="precision ?? 0"
:controls="false"
/>
</div>
</template>
<script setup lang="ts">
const model = defineModel<number | null>({ required: true })
defineProps<{ min?: number; max?: number; precision?: number; emptyLabel?: string }>()
</script>
<style scoped>
.nullable-number {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
</style>

View File

@@ -0,0 +1,174 @@
<template>
<ElDialog
:model-value="true"
:title="target === 'alert' ? '关闭告警对应卡的轮询' : '批量开关轮询'"
width="min(900px, 96vw)"
:close-on-click-modal="!saving"
:show-close="!saving"
@update:model-value="
(value) => {
if (!value) emit('close')
}
"
>
<template v-if="!result">
<ElForm label-width="100px" :disabled="saving">
<ElFormItem label="目标数量">{{ ids.length }} 项(最多 500 项)</ElFormItem>
<ElFormItem label="目标状态"
><ElSwitch
v-model="enabled"
:disabled="target === 'alert' || fixedEnabled != null"
active-text="开启轮询"
inactive-text="关闭轮询"
/></ElFormItem>
<ElFormItem label="原因" required
><ElInput v-model="reason" type="textarea" :rows="3"
/></ElFormItem>
</ElForm>
<ElAlert type="warning" title="本次操作的影响(来自接口)" :closable="false"
><ul
><li v-for="line in impactLines" :key="line">{{ line }}</li></ul
></ElAlert
>
<ElButton v-if="!impact" :loading="impactLoading" @click="loadImpact">加载影响说明</ElButton>
</template>
<template v-else>
<ElAlert
:type="result.fail_count ? 'warning' : 'success'"
:closable="false"
:title="`成功 ${result.success_count},跳过 ${result.skip_count},失败 ${result.fail_count}。部分成功不回滚,请逐项核对。`"
/>
<ArtTable :data="resultRows" row-key="result_key" :pagination="false" max-height="460"
><ElTableColumn prop="identifier" label="资产标识" min-width="210" /><ElTableColumn
prop="asset_id"
label="资产 ID"
width="110" /><ElTableColumn label="结果" min-width="150"
><template #default="{ row }">{{
row.status_name ||
{ 1: '成功', 2: '跳过', 3: '失败', 4: '无权限或不存在' }[row.status as number]
}}</template></ElTableColumn
><ElTableColumn prop="reason" label="原因" min-width="250"
/></ArtTable>
<ul
><li v-for="line in result.impact" :key="line">{{ line }}</li></ul
>
</template>
<ElAlert v-if="error" type="error" :title="error" :closable="false" />
<template #footer
><ElButton :disabled="saving" @click="emit('close')">关闭</ElButton
><ElButton
v-if="!result"
type="primary"
:disabled="!impact || impactLoading"
:loading="saving"
@click="submit"
>确认提交</ElButton
></template
>
</ElDialog>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onBeforeUnmount, onDeactivated } from 'vue'
import { ElMessageBox } from 'element-plus'
import { PollingSchedulerService as Service } from '@/api/modules/pollingScheduler'
import { useUserStore } from '@/store/modules/user'
import { useAuth } from '@/composables/useAuth'
import { gatewayData, gatewayError, requireGatewayAccess } from '@/utils/business/gateway'
import {
pollingScope,
isPollingPlatform,
canPollingBatch,
validatePollingIds,
pollingReason
} from '@/utils/business/pollingScheduler'
import type { PollingImpact, PollingBatchResult } from '@/types/api/pollingScheduler'
const props = defineProps<{
target: 'iot_card' | 'device' | 'alert'
ids: number[]
permission: string
fixedEnabled?: boolean
}>()
const emit = defineEmits<{ close: []; done: [] }>()
const user = useUserStore()
const { hasAuth } = useAuth()
const scope = pollingScope(user)
const allowed = () =>
scope === pollingScope(user) &&
hasAuth(props.permission) &&
(props.target === 'alert'
? isPollingPlatform(user.info.user_type)
: canPollingBatch(user.info.user_type))
const enabled = ref(props.target === 'alert' ? false : (props.fixedEnabled ?? true))
const reason = ref('')
const impact = ref<PollingImpact | null>(null)
const impactLoading = ref(false)
const saving = ref(false)
const error = ref('')
const result = ref<PollingBatchResult | null>(null)
const impactLines = computed(() => impact.value?.[enabled.value ? 'enable' : 'disable'] || [])
const resultRows = computed(
() =>
result.value?.items.map((row, index) => ({
...row,
result_key: `${row.asset_id}:${index}`
})) || []
)
let active = true
async function loadImpact() {
impactLoading.value = true
error.value = ''
try {
requireGatewayAccess(allowed())
const data = gatewayData(await Service.getImpact())
if (active && allowed()) {
if (!Array.isArray(data.enable) || !Array.isArray(data.disable))
throw new Error('影响说明格式不完整')
impact.value = data
}
} catch (cause) {
if (active) error.value = gatewayError(cause)
} finally {
if (active) impactLoading.value = false
}
}
async function submit() {
if (saving.value || result.value) return
error.value = ''
try {
requireGatewayAccess(allowed())
validatePollingIds(props.ids)
const why = pollingReason(reason.value)
if (!impact.value || !impactLines.value.length) throw new Error('请先加载操作影响说明')
const ids = [...props.ids],
targetEnabled = enabled.value
saving.value = true
await ElMessageBox.confirm(impactLines.value.join('\n'), '确认轮询开关影响', {
type: 'warning'
})
requireGatewayAccess(active && allowed())
const data = gatewayData(
await (props.target === 'alert'
? Service.closeAlertPolling(ids, why)
: props.target === 'device'
? Service.toggleDevices({ asset_ids: ids, enable_polling: targetEnabled, reason: why })
: Service.toggleCards({ asset_ids: ids, enable_polling: targetEnabled, reason: why }))
)
if (active && allowed()) {
result.value = data
emit('done')
}
} catch (cause) {
if (active && cause !== 'cancel' && cause !== 'close') error.value = gatewayError(cause)
} finally {
if (active) saving.value = false
}
}
onMounted(loadImpact)
onDeactivated(() => {
active = false
emit('close')
})
onBeforeUnmount(() => {
active = false
})
</script>

View File

@@ -0,0 +1,125 @@
<template>
<ElDialog
:model-value="true"
title="轮询开关 CSV 导入"
width="min(700px,96vw)"
:show-close="!saving"
:close-on-click-modal="!saving"
@update:model-value="
(value) => {
if (!value) emit('close')
}
"
><ElAlert
type="info"
title="CSV 首行为表头、首列为 ICCID(19/20 位)或设备 IMEI/虚拟号;空行忽略,最多 50000 行,标识符保持文本。"
:closable="false"
/><ElForm label-width="110px" :disabled="saving"
><ElFormItem label="资产类型"
><ElSelect v-model="assetType"
><ElOption label="卡(ICCID)" value="iot_card" /><ElOption
label="设备"
value="device" /></ElSelect></ElFormItem
><ElFormItem label="目标状态"
><ElSwitch v-model="enabled" active-text="开启轮询" inactive-text="关闭轮询" /></ElFormItem
><ElFormItem label="原因" required><ElInput v-model="reason" type="textarea" /></ElFormItem
><ElFormItem label="CSV 文件" required
><input
type="file"
accept=".csv"
:disabled="saving"
@change="selectFile" /></ElFormItem></ElForm
><ElAlert v-if="error" :title="error" type="error" :closable="false" /><template #footer
><ElButton :disabled="saving" @click="emit('close')">取消</ElButton
><ElButton type="primary" :loading="saving" @click="submit"
>上传并创建任务</ElButton
></template
></ElDialog
>
</template>
<script setup lang="ts">
import { ref, onBeforeUnmount, onDeactivated } from 'vue'
import { ElMessageBox } from 'element-plus'
import { StorageService } from '@/api/modules/storage'
import { PollingSchedulerService as Service } from '@/api/modules/pollingScheduler'
import { useUserStore } from '@/store/modules/user'
import { useAuth } from '@/composables/useAuth'
import { SCHEDULER_PERMISSIONS } from '@/config/constants/pollingScheduler'
import { gatewayData, gatewayError, requireGatewayAccess } from '@/utils/business/gateway'
import {
pollingScope,
isPollingPlatform,
pollingReason,
validatePollingCsv
} from '@/utils/business/pollingScheduler'
const emit = defineEmits<{ close: []; created: [id: number] }>(),
user = useUserStore(),
{ hasAuth } = useAuth(),
assetType = ref<'iot_card' | 'device'>('iot_card'),
enabled = ref(false),
reason = ref(''),
file = ref<File>(),
saving = ref(false),
error = ref('')
let active = true
const scope = pollingScope(user)
const allowed = () =>
active &&
scope === pollingScope(user) &&
isPollingPlatform(user.info.user_type) &&
hasAuth(SCHEDULER_PERMISSIONS.imports.create)
function selectFile(event: Event) {
file.value = (event.target as HTMLInputElement).files?.[0]
}
async function submit() {
if (saving.value) return
error.value = ''
try {
requireGatewayAccess(allowed())
const why = pollingReason(reason.value),
selected = file.value
if (!selected) throw new Error('请选择 CSV 文件')
saving.value = true
validatePollingCsv(selected.name, await selected.text())
const impact = gatewayData(await Service.getImpact()),
lines = impact[enabled.value ? 'enable' : 'disable']
if (!Array.isArray(lines) || !lines.length) throw new Error('未获取到有效影响说明')
await ElMessageBox.confirm(lines.join('\n'), '确认导入影响', { type: 'warning' })
requireGatewayAccess(allowed())
const upload = gatewayData(
await StorageService.getUploadUrl({
file_name: selected.name,
content_type: 'text/csv',
purpose: 'polling_status_import'
})
)
if (!upload.file_key.startsWith('polling-status-imports/'))
throw new Error('上传文件 Key 前缀错误')
await StorageService.uploadFile(upload.upload_url, selected, 'text/csv')
requireGatewayAccess(allowed())
const task = gatewayData(
await Service.createImport({
file_key: upload.file_key,
asset_type: assetType.value,
enable_polling: enabled.value,
reason: why
})
)
if (allowed()) {
emit('created', task.id)
emit('close')
}
} catch (e) {
if (active && e !== 'cancel' && e !== 'close') error.value = gatewayError(e)
} finally {
if (active) saving.value = false
}
}
onDeactivated(() => {
active = false
emit('close')
})
onBeforeUnmount(() => {
active = false
})
</script>

View File

@@ -0,0 +1,147 @@
<template>
<ElDialog
:model-value="true"
title="单卡轮询状态"
width="min(1100px, 96vw)"
@update:model-value="
(value) => {
if (!value) emit('close')
}
"
>
<ElButton :loading="loading" @click="load">刷新状态</ElButton>
<ElAlert v-if="error" type="error" :title="error" :closable="false" />
<template v-if="state">
<ElDescriptions :column="width < 640 ? 1 : 2" border class="state-overview"
><ElDescriptionsItem v-for="item in fields" :key="item[0]" :label="item[1]">{{
gatewayDisplayValue(state[item[0] as keyof typeof state], item[0])
}}</ElDescriptionsItem></ElDescriptions
>
<h3>各检查类型状态</h3
><ArtTable :data="state.types" row-key="check_type" :pagination="false"
><ElTableColumn v-for="item in typeFields" :key="item[0]" :label="item[1]" min-width="170"
><template #default="{ row }">{{
gatewayDisplayValue(row[item[0]], item[0])
}}</template></ElTableColumn
></ArtTable
>
<h3>停复机指令</h3
><ElDescriptions v-if="state.stop_resume_command" :column="width < 640 ? 1 : 2" border
><ElDescriptionsItem v-for="item in commandFields" :key="item[0]" :label="item[1]">{{
gatewayDisplayValue(commandValue(item[0]), item[0])
}}</ElDescriptionsItem></ElDescriptions
><ElEmpty v-else description="当前没有停复机指令" />
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, onDeactivated, watch } from 'vue'
import { useUserStore } from '@/store/modules/user'
import {
pollingScope,
schedulerDisplayValue as gatewayDisplayValue
} from '@/utils/business/pollingScheduler'
import { useWindowSize } from '@vueuse/core'
import { PollingSchedulerService } from '@/api/modules/pollingScheduler'
import { useAuth } from '@/composables/useAuth'
import {
gatewayData,
gatewayError,
requireGatewayAccess,
validateGatewayInteger
} from '@/utils/business/gateway'
import type { CardPollingState } from '@/types/api/pollingScheduler'
const props = defineProps<{ cardId: number; permission: string }>()
const emit = defineEmits<{ close: [] }>()
const user = useUserStore()
watch(
() => pollingScope(user),
() => {
++sequence
state.value = null
emit('close')
},
{ flush: 'sync' }
)
const { width } = useWindowSize(),
{ hasAuth } = useAuth()
const loading = ref(false),
error = ref(''),
state = ref<CardPollingState | null>(null)
const fields = [
['iccid', 'ICCID'],
['carrier_id', '运营商账户 ID'],
['scheduler_mode', '调度模式'],
['enable_polling', '是否轮询'],
['queue', '所在队列'],
['queue_score_at', '下次可领取时间'],
['running', '执行中'],
['claim_expire_at', '处理中标记到期'],
['priority_mark', '优先标记'],
['stop_resume_queue_enabled', '停复机出口队列'],
['last_stop_resume_call_at', '最近停复机网关调用']
]
const typeFields = [
['check_type_name', '检查类型'],
['next_due_at', '下次到期'],
['last_success_at', '最近成功'],
['fail_count', '连续失败'],
['first_failed_at', '首次失败'],
['last_error', '最近错误'],
['pending_confirm', '待确认延迟(秒)']
]
const commandFields = [
['action', '动作'],
['reason', '原因'],
['scenes', '场景'],
['source', '来源'],
['version', '版本'],
['fail_count', '连续失败'],
['last_error', '最近错误'],
['pending', '待确认'],
['unknown', '连续未知'],
['written_at', '写入时间'],
['next_at', '下次可执行时间']
]
let sequence = 0
const commandValue = (key: string) =>
state.value?.stop_resume_command?.[
key as keyof NonNullable<CardPollingState['stop_resume_command']>
]
async function load() {
const scope = pollingScope(user)
const current = ++sequence
loading.value = true
error.value = ''
state.value = null
try {
requireGatewayAccess(hasAuth(props.permission))
validateGatewayInteger(props.cardId, 1, Number.MAX_SAFE_INTEGER, '卡 ID')
const data = gatewayData(await PollingSchedulerService.getCardState(props.cardId))
if (current === sequence && scope === pollingScope(user) && hasAuth(props.permission))
state.value = data
} catch (cause) {
if (current === sequence) error.value = gatewayError(cause)
} finally {
if (current === sequence) loading.value = false
}
}
onMounted(load)
onDeactivated(() => {
++sequence
emit('close')
})
onBeforeUnmount(() => {
++sequence
})
</script>
<style scoped>
.state-overview {
margin-top: 16px;
}
h3 {
margin: 20px 0 12px;
font-size: 16px;
}
</style>

View File

@@ -0,0 +1,196 @@
<template>
<div class="scheduler-detail"
><ElCard shadow="never"
><div class="detail-header"
><ElButton @click="router.back()">返回</ElButton><h2>{{ title }}</h2
><ElButton :loading="loading" @click="load">刷新</ElButton></div
><ElAlert v-if="error" :title="error" type="error" :closable="false" /><div
v-loading="loading"
><template v-if="data"
><ElDescriptions :column="width < 640 ? 1 : 2" border
><ElDescriptionsItem v-for="item in fields" :key="item[0]" :label="item[1]">{{
gatewayDisplayValue(data[item[0]], item[0])
}}</ElDescriptionsItem></ElDescriptions
><template v-if="kind === 'imports'"
><p>逐行独立处理,部分成功不回滚。</p
><ArtTable :data="data.items || []" :pagination="false" row-key="line" max-height="500"
><ElTableColumn prop="line" label="数据行号" width="100" /><ElTableColumn
prop="identifier"
label="资产标识"
min-width="230" /><ElTableColumn
prop="asset_id"
label="资产 ID"
width="110" /><ElTableColumn
prop="status_name"
label="结果"
width="140" /><ElTableColumn prop="reason" label="原因" min-width="250" /></ArtTable
><ElSwitch v-model="auto" active-text="自动刷新任务(终态停止)" /></template
><template v-if="kind === 'alerts' && data.card_id > 0"
><ElButton v-if="hasAuth(GATEWAY_PERMISSIONS.cardHistory)" @click="history = !history"
>最近停复机记录</ElButton
><ElButton v-if="hasAuth(SCHEDULER_PERMISSIONS.cardState)" @click="stateVisible = true"
>轮询状态</ElButton
><StopResumeRecords
v-if="history"
:card-id="data.card_id"
:permission="GATEWAY_PERMISSIONS.cardHistory" /><PollingStateDialog
v-if="stateVisible"
:card-id="data.card_id"
:permission="SCHEDULER_PERMISSIONS.cardState"
@close="stateVisible = false" /></template></template></div></ElCard
></div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useWindowSize } from '@vueuse/core'
import { PollingSchedulerService as Service } from '@/api/modules/pollingScheduler'
import { useAuth } from '@/composables/useAuth'
import { useUserStore } from '@/store/modules/user'
import { useSchedulerRequest } from '@/composables/useSchedulerRequest'
import { SCHEDULER_PERMISSIONS } from '@/config/constants/pollingScheduler'
import { GATEWAY_PERMISSIONS } from '@/config/constants/gateway'
import { gatewayData, validateGatewayInteger } from '@/utils/business/gateway'
import {
isPollingPlatform,
schedulerDisplayValue as gatewayDisplayValue
} from '@/utils/business/pollingScheduler'
import StopResumeRecords from '@/components/business/gateway/StopResumeRecords.vue'
import PollingStateDialog from './PollingStateDialog.vue'
const props = defineProps<{ kind: 'params' | 'alerts' | 'imports' }>(),
route = useRoute(),
router = useRouter(),
{ width } = useWindowSize(),
{ hasAuth } = useAuth(),
user = useUserStore()
const title = computed(
() =>
({ params: '运营商账户调度参数详情', alerts: '失败告警详情', imports: '轮询开关导入详情' })[
props.kind
]
)
const permission = () => SCHEDULER_PERMISSIONS[props.kind].detailPage
const { data, loading, error, load } = useSchedulerRequest<Record<string, any>>(
async () => {
const id = Number(route.params.id ?? route.params.carrier_id)
validateGatewayInteger(id, 1, Number.MAX_SAFE_INTEGER, '记录 ID')
if (props.kind === 'params') return gatewayData(await Service.getCarrierParam(id))
if (props.kind === 'alerts') return gatewayData(await Service.getAlert(id))
return gatewayData(await Service.getImport(id))
},
() =>
hasAuth(permission()) && (props.kind !== 'imports' || isPollingPlatform(user.info.user_type))
)
const maps = {
params: [
['carrier_name', '运营商账户'],
['is_default', '全部使用全局默认'],
['configured_inflight_limit', '配置执行上限'],
['inflight_limit', '生效执行上限'],
['configured_card_max_in_flight', '配置每进程并发'],
['card_max_in_flight', '生效每进程并发'],
['consume_capacity', '订阅进程容量'],
['priority_max_ratio', '优先队列占比'],
['sr_inflight_limit', '停复机执行上限'],
['stop_resume_max_in_flight', '停复机每进程并发'],
['stop_resume_min_interval', '单卡最小间隔(秒)'],
['stop_resume_confirm_delay', '未知确认延迟(秒)']
],
alerts: [
['id', '告警 ID'],
['carrier_name', '运营商账户'],
['alert_level', '告警级别'],
['iccid', 'ICCID'],
['card_id', '卡 ID'],
['check_type_name', '检查类型'],
['status_name', '状态'],
['error_category', '错误类别'],
['last_error', '上游返回/最近错误'],
['fail_count', '连续失败/窗口告警数'],
['first_failed_at', '首次失败'],
['last_failed_at', '最近失败'],
['created_at', '创建时间'],
['recovered_at', '恢复时间'],
['handle_remark', '处理备注'],
['handled_by_name', '处理人'],
['handled_at', '处理时间'],
['network_status', '网络状态'],
['stop_reason', '停机原因'],
['has_package', '有生效套餐'],
['enable_polling', '是否轮询']
],
imports: [
['task_no', '任务编号'],
['asset_type', '资产类型'],
['file_name', '文件名'],
['enable_polling', '目标轮询状态'],
['reason', '原因'],
['status_name', '任务状态'],
['creator_name', '创建人'],
['total_count', '总行数'],
['success_count', '成功'],
['skip_count', '跳过'],
['fail_count', '失败'],
['error_message', '任务级错误'],
['created_at', '创建时间'],
['started_at', '开始时间'],
['completed_at', '完成时间']
]
}
const fields = computed(() => maps[props.kind]),
history = ref(false),
stateVisible = ref(false),
auto = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined
function stop() {
if (timer) clearTimeout(timer)
timer = undefined
}
watch([data, auto], () => {
stop()
if (props.kind === 'imports' && auto.value && [1, 2].includes(data.value?.status))
timer = setTimeout(() => {
void load()
}, 3000)
})
watch(
() => route.fullPath,
() => {
stop()
history.value = false
stateVisible.value = false
void load()
}
)
onMounted(load)
onBeforeUnmount(stop)
</script>
<style scoped>
.scheduler-detail {
padding: 20px;
}
.detail-header {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
padding-bottom: 16px;
}
.detail-header h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
:deep(.el-descriptions__cell) {
overflow-wrap: anywhere;
}
@media (width <= 640px) {
.scheduler-detail {
padding: 8px;
}
}
</style>

View File

@@ -0,0 +1,57 @@
<template>
<ArtTableHeader v-model:columns="columnChecks" @refresh="$emit('refresh')"
><template #left><slot name="toolbar" /></template
></ArtTableHeader>
<ArtTable
:data="data"
:loading="loading"
:row-key="rowKey || 'id'"
:pagination="total != null"
:current-page="page || 1"
:page-size="size || 20"
:total="total || 0"
:page-sizes="pageSizes || [20, 50, 100]"
:margin-top="10"
@current-change="(value) => $emit('page', value)"
@size-change="(value) => $emit('size', value)"
@selection-change="(value) => $emit('selection', value)"
>
<ElTableColumn v-if="selectable" type="selection" width="55" :selectable="selectableRow" />
<ElTableColumn
v-for="col in columns"
:key="col.prop"
:prop="col.prop"
:label="col.label"
:min-width="col.width || 160"
show-overflow-tooltip
><template #default="{ row }">{{
formatter ? formatter(row, col.prop) : schedulerDisplayValue(row[col.prop], col.prop)
}}</template></ElTableColumn
>
<slot />
</ArtTable>
</template>
<script setup lang="ts">
import { useGatewayColumns } from '@/composables/useGateway'
import { schedulerDisplayValue } from '@/utils/business/pollingScheduler'
const props = defineProps<{
data: Record<string, any>[]
definitions: { prop: string; label: string; width?: number }[]
loading?: boolean
rowKey?: string
selectable?: boolean
selectableRow?: (row: any) => boolean
page?: number
size?: number
total?: number
pageSizes?: number[]
formatter?: (row: any, key: string) => unknown
}>()
defineEmits<{
refresh: []
page: [value: number]
size: [value: number]
selection: [rows: any[]]
}>()
const { columns, columnChecks } = useGatewayColumns(props.definitions)
</script>