This commit is contained in:
@@ -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
|
||||
|
||||
58
src/components/business/gateway/GatewayOperationTable.vue
Normal file
58
src/components/business/gateway/GatewayOperationTable.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
30
src/components/business/polling/NullableNumber.vue
Normal file
30
src/components/business/polling/NullableNumber.vue
Normal 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>
|
||||
174
src/components/business/polling/PollingBatchDialog.vue
Normal file
174
src/components/business/polling/PollingBatchDialog.vue
Normal 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>
|
||||
125
src/components/business/polling/PollingImportDialog.vue
Normal file
125
src/components/business/polling/PollingImportDialog.vue
Normal 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>
|
||||
147
src/components/business/polling/PollingStateDialog.vue
Normal file
147
src/components/business/polling/PollingStateDialog.vue
Normal 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>
|
||||
196
src/components/business/polling/SchedulerDetail.vue
Normal file
196
src/components/business/polling/SchedulerDetail.vue
Normal 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>
|
||||
57
src/components/business/polling/SchedulerTable.vue
Normal file
57
src/components/business/polling/SchedulerTable.vue
Normal 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>
|
||||
Reference in New Issue
Block a user