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

@@ -39,7 +39,9 @@ export class CarrierService extends BaseService {
* @param data 运营商数据
*/
static updateCarrier(id: number, data: UpdateCarrierParams): Promise<BaseResponse<Carrier>> {
return this.update<Carrier>(`/api/admin/carriers/${id}`, data)
return this.put(`/api/admin/carriers/${id}`, data, {
requestOptions: { errorMessageMode: 'none' }
})
}
/**

View File

@@ -61,3 +61,4 @@ export { AssetWalletService } from './assetWallet'
export { OperationsReportsService } from './operationsReports'
export { GatewayService } from './gateway'
export { PollingSchedulerService } from './pollingScheduler'

View File

@@ -3,6 +3,13 @@
*/
import { BaseService } from '../BaseService'
import type { BaseResponse } from '@/types/api'
import type {
PollingMatchPreview,
PollingPreviewRequest,
PollingRecalculateTask,
PollingPreResetStats,
PollingTierStats
} from '@/types/api/pollingConfigExtensions'
import type {
PollingConfig,
PollingConfigQueryParams,
@@ -16,6 +23,37 @@ import type {
* 轮询配置管理服务
*/
export class PollingConfigService extends BaseService {
static previewMatch(iccid: string): Promise<BaseResponse<PollingMatchPreview>> {
return this.get('/api/admin/polling-configs/match-preview', { iccid })
}
static previewDraft(data: PollingPreviewRequest): Promise<BaseResponse<PollingMatchPreview>> {
return this.post('/api/admin/polling-configs/match-preview', data, {
params: { iccid: data.iccid }
})
}
// 文档未定义成功 data,不假定返回新的配置列表。
static updatePriorities(orderedIds: number[]): Promise<BaseResponse<unknown> | void> {
return this.put('/api/admin/polling-configs/priorities', { ordered_ids: orderedIds })
}
static recalculateDue(carrierId: number | null): Promise<BaseResponse<PollingRecalculateTask>> {
return this.post('/api/admin/polling-configs/recalculate-due', { carrier_id: carrierId })
}
static getRecalculateTask(taskId: string): Promise<BaseResponse<PollingRecalculateTask>> {
return this.get(`/api/admin/polling-configs/recalculate-due/${encodeURIComponent(taskId)}`)
}
static getPreResetStats(): Promise<BaseResponse<PollingPreResetStats>> {
return this.get('/api/admin/polling-stats/pre-reset')
}
static getTierStats(): Promise<BaseResponse<PollingTierStats>> {
return this.get('/api/admin/polling-stats/tiers')
}
/**
* 获取轮询配置列表
* @param params 查询参数

View File

@@ -0,0 +1,109 @@
import { BaseService } from '../BaseService'
import type { BaseResponse, PaginationParams } from '@/types/api/common'
import type {
CarrierParamsInput,
CarrierParamsList,
CarrierPollingParams,
PollingCheckScene,
CheckSceneInput,
PollingFailureQuery,
PollingFailureAlert,
PollingFailureSummary,
SchedulerPage,
CarrierQueues,
CardPollingState,
PollingImpact,
PollingBatchResult,
PollingToggleInput,
PollingPauseQuery,
PollingPause,
PollingImportInput,
PollingImportTask,
StopResumeDryQuery,
StopResumeDryRun
} from '@/types/api/pollingScheduler'
import type { CreateExportTaskResponse } from '@/types/api/exportTask'
export class PollingSchedulerService extends BaseService {
static getCarrierParams(): Promise<BaseResponse<CarrierParamsList>> {
return this.get('/api/admin/polling-carrier-params')
}
static getCarrierParam(id: number): Promise<BaseResponse<CarrierPollingParams>> {
return this.get(`/api/admin/polling-carrier-params/${id}`)
}
static saveCarrierParam(
id: number,
data: CarrierParamsInput
): Promise<BaseResponse<CarrierPollingParams>> {
return this.put(`/api/admin/polling-carrier-params/${id}`, data)
}
static resetCarrierParam(id: number): Promise<BaseResponse<unknown>> {
return this.delete(`/api/admin/polling-carrier-params/${id}`)
}
static getScenes(): Promise<BaseResponse<PollingCheckScene[]>> {
return this.get('/api/admin/polling-check-scenes')
}
static saveScene(scene: string, data: CheckSceneInput): Promise<BaseResponse<PollingCheckScene>> {
return this.put(`/api/admin/polling-check-scenes/${encodeURIComponent(scene)}`, data)
}
static getAlerts(
params: PollingFailureQuery
): Promise<BaseResponse<SchedulerPage<PollingFailureAlert>>> {
return this.get('/api/admin/polling-failure-alerts', params)
}
static getAlertSummary(): Promise<BaseResponse<PollingFailureSummary[]>> {
return this.get('/api/admin/polling-failure-alerts/summary')
}
static getAlert(id: number): Promise<BaseResponse<PollingFailureAlert>> {
return this.get(`/api/admin/polling-failure-alerts/${id}`)
}
static handleAlert(id: number, remark: string): Promise<BaseResponse<unknown>> {
return this.put(`/api/admin/polling-failure-alerts/${id}/status`, { status: 4, remark })
}
static closeAlertPolling(
alertIds: number[],
reason: string
): Promise<BaseResponse<PollingBatchResult>> {
return this.post('/api/admin/polling-failure-alerts/close-polling', {
alert_ids: alertIds,
reason
})
}
static getQueues(): Promise<BaseResponse<CarrierQueues>> {
return this.get('/api/admin/polling-stats/carrier-queues')
}
static getCardState(id: number): Promise<BaseResponse<CardPollingState>> {
return this.get(`/api/admin/iot-cards/${id}/polling-state`)
}
static getImpact(): Promise<BaseResponse<PollingImpact>> {
return this.get('/api/admin/polling-status/impact')
}
static toggleCards(data: PollingToggleInput): Promise<BaseResponse<PollingBatchResult>> {
return this.post('/api/admin/iot-cards/batch-update-polling-status', data)
}
static toggleDevices(data: PollingToggleInput): Promise<BaseResponse<PollingBatchResult>> {
return this.post('/api/admin/devices/batch-update-polling-status', data)
}
static getPauses(params: PollingPauseQuery): Promise<BaseResponse<SchedulerPage<PollingPause>>> {
return this.get('/api/admin/card-polling-pauses', params)
}
static getImports(
params: PaginationParams & { status?: number }
): Promise<BaseResponse<SchedulerPage<PollingImportTask>>> {
return this.get('/api/admin/polling-status-imports', params)
}
static getImport(id: number): Promise<BaseResponse<PollingImportTask>> {
return this.get(`/api/admin/polling-status-imports/${id}`)
}
static createImport(data: PollingImportInput): Promise<BaseResponse<PollingImportTask>> {
return this.post('/api/admin/polling-status-imports', data)
}
static dryRun(params: StopResumeDryQuery): Promise<BaseResponse<StopResumeDryRun>> {
return this.get('/api/admin/stop-resume-rule/dry-run', params)
}
static exportDryRun(
data: Omit<StopResumeDryQuery, 'page' | 'page_size' | 'result'> & { format: 'csv' | 'xlsx' }
): Promise<BaseResponse<CreateExportTaskResponse>> {
return this.post('/api/admin/stop-resume-rule/dry-run/export', data)
}
}

View File

@@ -24,6 +24,7 @@ const triggerBrowserDownload = (downloadUrl: string, fileName: string) => {
* 文件用途枚举
*/
export type FilePurpose =
| 'polling_status_import'
| 'iot_import'
| 'batch_purchase'
| 'device_batch_allocation'

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>

View File

@@ -0,0 +1,125 @@
import { ref, onBeforeUnmount, onDeactivated, onMounted, watch } from 'vue'
import { PollingConfigService } from '@/api/modules/pollingConfig'
import type { PollingConfig } from '@/types/api/pollingConfig'
import type { PollingRecalculateTask } from '@/types/api/pollingConfigExtensions'
import { gatewayData, gatewayError, requireGatewayAccess } from '@/utils/business/gateway'
import { pollingTaskActive } from '@/utils/business/pollingConfig'
export function usePollingConfigCandidates() {
const candidates = ref<PollingConfig[]>([])
const loading = ref(false)
const error = ref('')
let sequence = 0
async function search(query = '') {
const current = ++sequence
loading.value = true
error.value = ''
try {
const data = gatewayData(
await PollingConfigService.getPollingConfigs({
page: 1,
page_size: 20,
config_name: query.trim()
})
)
if (current === sequence) candidates.value = data.items || []
} catch (cause) {
if (current === sequence) {
error.value = gatewayError(cause)
candidates.value = []
}
} finally {
if (current === sequence) loading.value = false
}
}
onMounted(() => search())
onBeforeUnmount(() => {
++sequence
})
return { candidates, loading, error, search }
}
/** 单一任务、非重叠查询;失败、终态、关闭、切换任务和卸载都撤销旧请求。 */
export function usePollingRecalculateProgress(allowed: () => boolean) {
const taskId = ref('')
const task = ref<PollingRecalculateTask | null>(null)
const loading = ref(false)
const error = ref('')
const autoRefresh = ref(false)
let sequence = 0
let timer: ReturnType<typeof setTimeout> | undefined
let disposed = false
function stop() {
++sequence
if (timer) clearTimeout(timer)
timer = undefined
loading.value = false
}
async function query() {
if (disposed || loading.value) return
stop()
const current = sequence
const id = taskId.value.trim()
error.value = ''
loading.value = true
try {
requireGatewayAccess(allowed())
if (!id) throw new Error('请输入任务 ID')
const data = gatewayData(await PollingConfigService.getRecalculateTask(id))
if (current !== sequence || disposed) return
requireGatewayAccess(allowed())
if (data.task_id !== id) throw new Error('返回的任务 ID 与查询任务不一致')
task.value = data
if (pollingTaskActive(data.status) && autoRefresh.value)
timer = setTimeout(() => {
void query()
}, 3000)
} catch (cause) {
if (current === sequence) error.value = gatewayError(cause)
} finally {
if (current === sequence) loading.value = false
}
}
function adopt(data: PollingRecalculateTask) {
stop()
taskId.value = data.task_id
task.value = data
error.value = ''
if (allowed() && autoRefresh.value && pollingTaskActive(data.status))
timer = setTimeout(() => {
void query()
}, 3000)
}
// 同步 watch:修改输入的这一刻即使旧网络请求未结束也不再允许其回填。
watch(
taskId,
() => {
stop()
task.value = null
error.value = ''
},
{ flush: 'sync' }
)
watch(
autoRefresh,
(enabled) => {
if (!enabled) stop()
else if (!loading.value && taskId.value.trim() && pollingTaskActive(task.value?.status))
void query()
},
{ flush: 'sync' }
)
watch(allowed, (enabled) => {
if (!enabled) {
stop()
task.value = null
error.value = ''
}
})
onDeactivated(stop)
onBeforeUnmount(() => {
disposed = true
stop()
})
return { taskId, task, loading, error, autoRefresh, query, stop, adopt }
}

View File

@@ -0,0 +1,56 @@
import { ref, shallowRef, onBeforeUnmount, onDeactivated, onActivated, watch } from 'vue'
import { useUserStore } from '@/store/modules/user'
import { gatewayError, requireGatewayAccess } from '@/utils/business/gateway'
import { pollingScope } from '@/utils/business/pollingScheduler'
export function useSchedulerRequest<T>(request: () => Promise<T>, allowed: () => boolean) {
const user = useUserStore()
const data = shallowRef<T | null>(null),
loading = ref(false),
error = ref('')
let sequence = 0,
active = true
function clear() {
++sequence
data.value = null
loading.value = false
error.value = ''
}
async function load() {
if (!active) return
const current = ++sequence,
scope = pollingScope(user)
loading.value = true
error.value = ''
data.value = null
try {
requireGatewayAccess(allowed())
const result = await request()
if (active && current === sequence && scope === pollingScope(user) && allowed())
data.value = result
} catch (cause) {
if (active && current === sequence) {
data.value = null
error.value = gatewayError(cause)
}
} finally {
if (current === sequence) loading.value = false
}
}
watch(() => [pollingScope(user), allowed()], clear, { flush: 'sync' })
onDeactivated(() => {
active = false
clear()
})
onActivated(() => {
if (!active) {
active = true
void load()
}
})
onBeforeUnmount(() => {
active = false
clear()
})
return { data, loading, error, load }
}

View File

@@ -7,6 +7,9 @@ export interface ExportTaskScenePermissions {
}
export const EXPORT_TASK_DETAIL_PAGE_PERMISSION = 'export_task:detail_page'
export const isPollingPlatformExport = (scene?: ExportTaskScene) =>
!!scene &&
['polling_pause', 'polling_stop_refused_alert', 'stop_resume_rule_dry_run'].includes(scene)
export interface ExportTaskSceneConfig {
scene: ExportTaskScene
@@ -16,6 +19,36 @@ export interface ExportTaskSceneConfig {
}
export const EXPORT_TASK_SCENE_CONFIG: Record<ExportTaskScene, ExportTaskSceneConfig> = {
polling_pause: {
scene: 'polling_pause',
sceneName: '不轮询清单',
pageTitle: '导出不轮询清单',
permissions: {
page: 'export_task:polling_pause_page',
detail: 'export_task:polling_pause_detail',
download: 'export_task:polling_pause_download'
}
},
polling_stop_refused_alert: {
scene: 'polling_stop_refused_alert',
sceneName: '停机被拒告警',
pageTitle: '导出停机被拒告警',
permissions: {
page: 'export_task:polling_stop_refused_alert_page',
detail: 'export_task:polling_stop_refused_alert_detail',
download: 'export_task:polling_stop_refused_alert_download'
}
},
stop_resume_rule_dry_run: {
scene: 'stop_resume_rule_dry_run',
sceneName: '停复机规则演练',
pageTitle: '导出停复机规则演练',
permissions: {
page: 'export_task:stop_resume_rule_dry_run_page',
detail: 'export_task:stop_resume_rule_dry_run_detail',
download: 'export_task:stop_resume_rule_dry_run_download'
}
},
device: {
scene: 'device',
sceneName: '设备管理',

View File

@@ -0,0 +1,43 @@
export const POLLING_CONFIG_PERMISSIONS = {
preview: 'polling_config:match_preview',
progress: 'polling_config:recalculate_progress',
statsPage: 'polling_config_stats:page',
preReset: 'polling_config_stats:pre_reset',
tiers: 'polling_config_stats:tiers'
} as const
export const POLLING_ASSET_STATUSES = [
{ label: '在库', value: 1 },
{ label: '已销售', value: 2 },
{ label: '已换货', value: 3 },
{ label: '已停用', value: 4 }
]
export const POLLING_PACKAGE_STATES = [
{ label: '有生效套餐', value: 'active' },
{ label: '套餐已用完', value: 'depleted' },
{ label: '只有待生效套餐', value: 'pending' },
{ label: '无套餐', value: 'none' }
]
export const POLLING_CARD_CATEGORIES = [
{ label: '普通卡', value: 'normal' },
{ label: '行业卡', value: 'industry' }
]
export const POLLING_CARD_CONDITIONS = [
{ label: '未实名', value: 'not_real_name' },
{ label: '已实名', value: 'real_name' },
{ label: '已激活', value: 'activated' },
{ label: '已停用', value: 'suspended' }
]
export const POLLING_DRAFT_INTERVALS = [
{ field: 'realname_check_interval', label: '实名检查', min: 30 },
{ field: 'carddata_check_interval', label: '流量检查', min: 60 },
{ field: 'package_check_interval', label: '套餐检查', min: 60 },
{ field: 'card_status_check_interval', label: '卡状态检查', min: 30 }
] as const
export const pollingOptionLabel = (
options: { label: string; value: string | number }[],
value: unknown
) =>
options.find((option) => option.value === value)?.label ||
(value == null || value === '' ? '-' : String(value))

View File

@@ -0,0 +1,53 @@
export const SCHEDULER_PERMISSIONS = {
params: {
page: 'polling_carrier_params:page',
detailPage: 'polling_carrier_params:detail_page',
detail: 'polling_carrier_params:detail'
},
scenes: { page: 'polling_check_scene:page' },
alerts: {
page: 'polling_failure_alert:page',
detailPage: 'polling_failure_alert:detail_page',
detail: 'polling_failure_alert:detail',
summary: 'polling_failure_alert:summary',
handle: 'polling_failure_alert:handle',
close: 'polling_failure_alert:close_polling',
export: 'polling_failure_alert:export'
},
queues: { page: 'polling_carrier_queue:page' },
dryRun: { page: 'stop_resume_dry_run:page', export: 'stop_resume_dry_run:export' },
pauses: {
page: 'polling_pause:page',
restore: 'polling_pause:restore',
export: 'polling_pause:export'
},
imports: {
page: 'polling_status_import:page',
create: 'polling_status_import:create',
detailPage: 'polling_status_import:detail_page',
detail: 'polling_status_import:detail'
},
cardState: 'iot_info:polling_state',
deviceCardState: 'device_info:polling_state',
cardBatch: 'iot_card:batch_polling',
deviceBatch: 'device:batch_polling'
} as const
export const POLLING_CLOSE_SOURCES = [
['single', '单资产接口'],
['batch', '勾选批量'],
['import', '文件导入'],
['risk_auto', '风险停机或已销户自动关闭'],
['cascade', '设备级联'],
['order', '支付或下单生效'],
['expiry_change', '修改有效期'],
['alert', '停机被拒告警处理'],
['history', '历史数据']
].map(([value, label]) => ({ value, label }))
export const POLLING_CHECK_TYPES = [
['card_status', '卡状态'],
['carddata', '流量'],
['realname', '实名'],
['package', '套餐'],
['stop_resume', '停复机'],
['all', '账户级']
].map(([value, label]) => ({ value, label }))

View File

@@ -5,6 +5,8 @@ import { JULY_PERMISSIONS } from '@/config/constants/julyIteration'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
import { AUGUST_PERMISSIONS } from '@/config/constants/augustIteration'
import { GATEWAY_PERMISSIONS } from '@/config/constants/gateway'
import { POLLING_CONFIG_PERMISSIONS } from '@/config/constants/pollingConfig'
import { SCHEDULER_PERMISSIONS } from '@/config/constants/pollingScheduler'
import { PAYMENT_MERCHANT_POOL_PERMISSIONS } from '@/config/constants/paymentMerchantPools'
import {
EXPORT_TASK_DETAIL_PAGE_PERMISSION,
@@ -797,6 +799,42 @@ export const asyncRoutes: AppRouteRecord[] = [
permissions: [EXPORT_TASK_SCENE_CONFIG.operations_renewal.permissions.page]
}
},
{
path: 'export-polling-pause',
name: 'ExportPollingPause',
component: RoutesAlias.ExportPollingPause,
meta: {
title: '导出不轮询清单',
exportTaskScene: 'polling_pause',
keepAlive: true,
allowedUserTypes: [1, 2],
permissions: [EXPORT_TASK_SCENE_CONFIG.polling_pause.permissions.page]
}
},
{
path: 'export-polling-stop-refused-alert',
name: 'ExportPollingStopRefusedAlert',
component: RoutesAlias.ExportPollingStopRefusedAlert,
meta: {
title: '导出停机被拒告警',
exportTaskScene: 'polling_stop_refused_alert',
keepAlive: true,
allowedUserTypes: [1, 2],
permissions: [EXPORT_TASK_SCENE_CONFIG.polling_stop_refused_alert.permissions.page]
}
},
{
path: 'export-stop-resume-rule-dry-run',
name: 'ExportStopResumeDryRun',
component: RoutesAlias.ExportStopResumeDryRun,
meta: {
title: '导出停复机规则演练',
exportTaskScene: 'stop_resume_rule_dry_run',
keepAlive: true,
allowedUserTypes: [1, 2],
permissions: [EXPORT_TASK_SCENE_CONFIG.stop_resume_rule_dry_run.permissions.page]
}
},
{
path: 'export-task-detail',
name: 'ExportTaskDetail',
@@ -1268,6 +1306,113 @@ export const asyncRoutes: AppRouteRecord[] = [
icon: '&#xe68a;'
},
children: [
{
path: 'carrier-params',
name: 'PollingCarrierParams',
component: RoutesAlias.PollingCarrierParams,
meta: {
title: '运营商账户调度参数',
keepAlive: true,
permissions: [SCHEDULER_PERMISSIONS.params.page]
}
},
{
path: 'carrier-params/detail/:carrier_id',
name: 'PollingCarrierParamDetail',
component: RoutesAlias.PollingCarrierParamDetail,
meta: {
title: '运营商账户调度参数详情',
keepAlive: false,
isHide: true,
permissions: [SCHEDULER_PERMISSIONS.params.detailPage]
}
},
{
path: 'check-scenes',
name: 'PollingCheckScenes',
component: RoutesAlias.PollingCheckScenes,
meta: {
title: '请求检查场景',
keepAlive: true,
permissions: [SCHEDULER_PERMISSIONS.scenes.page]
}
},
{
path: 'failure-alerts',
name: 'PollingFailureAlerts',
component: RoutesAlias.PollingFailureAlerts,
meta: {
title: '失败告警',
keepAlive: true,
permissions: [SCHEDULER_PERMISSIONS.alerts.page]
}
},
{
path: 'failure-alerts/detail/:id',
name: 'PollingFailureAlertDetail',
component: RoutesAlias.PollingFailureAlertDetail,
meta: {
title: '失败告警详情',
keepAlive: false,
isHide: true,
permissions: [SCHEDULER_PERMISSIONS.alerts.detailPage]
}
},
{
path: 'carrier-queues',
name: 'PollingCarrierQueues',
component: RoutesAlias.PollingCarrierQueues,
meta: {
title: '账户队列监控',
keepAlive: true,
permissions: [SCHEDULER_PERMISSIONS.queues.page]
}
},
{
path: 'stop-resume-dry-run',
name: 'StopResumeDryRun',
component: RoutesAlias.StopResumeDryRun,
meta: {
title: '停复机规则演练',
keepAlive: true,
allowedUserTypes: [1, 2],
permissions: [SCHEDULER_PERMISSIONS.dryRun.page]
}
},
{
path: 'polling-pauses',
name: 'PollingPauses',
component: RoutesAlias.PollingPauses,
meta: {
title: '不轮询清单',
keepAlive: true,
allowedUserTypes: [1, 2],
permissions: [SCHEDULER_PERMISSIONS.pauses.page]
}
},
{
path: 'polling-status-imports',
name: 'PollingStatusImports',
component: RoutesAlias.PollingStatusImports,
meta: {
title: '轮询开关导入',
keepAlive: true,
allowedUserTypes: [1, 2],
permissions: [SCHEDULER_PERMISSIONS.imports.page]
}
},
{
path: 'polling-status-imports/detail/:id',
name: 'PollingStatusImportDetail',
component: RoutesAlias.PollingStatusImportDetail,
meta: {
title: '轮询开关导入详情',
keepAlive: false,
isHide: true,
allowedUserTypes: [1, 2],
permissions: [SCHEDULER_PERMISSIONS.imports.detailPage]
}
},
// 数据清理
{
path: 'data-cleanup',
@@ -1318,7 +1463,17 @@ export const asyncRoutes: AppRouteRecord[] = [
keepAlive: true
}
},
// 手动触发
// 轮询配置统计
{
path: 'config-stats',
name: 'PollingConfigStats',
component: RoutesAlias.PollingConfigStats,
meta: {
title: '轮询配置统计',
keepAlive: true,
permissions: [POLLING_CONFIG_PERMISSIONS.statsPage]
}
},
{
path: 'manual-trigger',
name: 'ManualTrigger',

View File

@@ -141,6 +141,20 @@ export enum RoutesAlias {
AlertHistory = '/polling-management/alert-history', // 告警历史
PollingConcurrency = '/polling-management/concurrency', // 并发控制
PollingConfig = '/polling-management/config', // 轮询配置
PollingConfigStats = '/polling-management/config-stats', // 轮询配置统计
PollingCarrierParams = '/polling-management/carrier-params',
PollingCarrierParamDetail = '/polling-management/carrier-params/detail',
PollingCheckScenes = '/polling-management/check-scenes',
PollingFailureAlerts = '/polling-management/failure-alerts',
PollingFailureAlertDetail = '/polling-management/failure-alerts/detail',
PollingCarrierQueues = '/polling-management/carrier-queues',
StopResumeDryRun = '/polling-management/stop-resume-dry-run',
PollingPauses = '/polling-management/polling-pauses',
PollingStatusImports = '/polling-management/polling-status-imports',
PollingStatusImportDetail = '/polling-management/polling-status-imports/detail',
ExportPollingPause = '/asset-management/export-task-management/export-polling-pause',
ExportPollingStopRefusedAlert = '/asset-management/export-task-management/export-polling-stop-refused-alert',
ExportStopResumeDryRun = '/asset-management/export-task-management/export-stop-resume-rule-dry-run',
ManualTrigger = '/polling-management/manual-trigger', // 手动触发
PollingMonitor = '/polling-management/monitor', // 轮询监控
PollingPriorityQueue = '/polling-management/priority-queue', // 优先队列

View File

@@ -189,7 +189,8 @@ export interface AssetResolveResponse {
expiry_estimate_status_name?: string | null // 预计最终到期时间状态名称
is_expiring?: boolean // 是否临期,由后端判断
reduction_pct: number // 展示增幅比例(小数,如 0.428571)
device_protect_status?: DeviceProtectStatus // 保护期状态:none / stop / start(仅 device)
/** @deprecated 保护期已下线,固定返回 none;不用于界面或业务判断。 */
device_protect_status?: DeviceProtectStatus
activated_at: string // 激活时间
created_at: string // 创建时间
updated_at: string // 更新时间
@@ -300,6 +301,7 @@ export interface DeviceGatewayInfo {
* 对应接口:GET /api/admin/assets/:asset_type/:id/realtime-status
*/
export interface AssetRealtimeStatusResponse {
enable_polling?: boolean
asset_type: AssetType // 资产类型
asset_id: number // 资产 ID
@@ -316,7 +318,8 @@ export interface AssetRealtimeStatusResponse {
last_card_status_check_at?: string | null // 卡状态同步时间
// ===== 设备专属字段 =====
device_protect_status?: DeviceProtectStatus // 保护期(仅 device)
/** @deprecated 固定返回 none,仅兼容旧响应。 */
device_protect_status?: DeviceProtectStatus
cards?: AssetBoundCard[] // 所有绑定卡的状态(仅 device)
online_status?: number // 在线状态(仅 device)
last_online_time?: string | null // 最后在线时间(仅 device)

View File

@@ -355,6 +355,7 @@ export enum StandaloneCardStatus {
// 单卡查询参数
export interface StandaloneCardQueryParams extends PaginationParams {
enable_polling?: boolean
keyword?: string // 通用关键字搜索
has_active_package?: boolean // has active package filter
real_name_status?: 0 | 1 // 实名状态:0 未实名 / 1 已实名

View File

@@ -37,6 +37,7 @@ export type SwitchMode = '0' | '1' // 0=自动, 1=手动
// 设备信息
export interface Device {
enable_polling?: boolean
id: number // 设备ID
virtual_no: string // 虚拟号(原 device_no)
associated_phones?: string[] // 当前关联手机号列表(按资产数据范围返回完整手机号,无关联时为空数组)
@@ -83,6 +84,7 @@ export interface Device {
// 设备查询参数
export interface DeviceQueryParams extends PaginationParams {
enable_polling?: boolean
has_active_package?: boolean // has active package filter
real_name_status?: 0 | 1 // 实名状态:0 未实名 / 1 已实名
virtual_no?: string // 虚拟号(模糊查询,原 device_no)

View File

@@ -14,6 +14,9 @@ export type ExportTaskScene =
| 'expiring_asset'
| 'operations_activation'
| 'operations_renewal'
| 'polling_pause'
| 'polling_stop_refused_alert'
| 'stop_resume_rule_dry_run'
export type ExportTaskFormat = 'xlsx' | 'csv'

View File

@@ -53,6 +53,7 @@ export interface StopResumeQuery extends CardStopResumeQuery {
}
export interface GatewayRuleInput {
auto_disable_polling?: boolean
category: string
keyword: string
match_type: string
@@ -83,6 +84,7 @@ export interface GatewayRuleTestInput {
}
export interface GatewayRuleTestResult {
auto_disable_polling?: boolean
matched: boolean
rule_id: number
category: string
@@ -116,14 +118,28 @@ export interface GatewayGroupQuery extends PaginationParams {
status?: number
}
// 文档没有定义数组内部字段,保留真实结构,不假定指标名称。
export interface GatewayOperationStats {
operation: string
operation_name: string
attempts: number
success: number
success_rate: number
avg_duration_ms: number
p95_duration_ms: number
avg_qps: number
peak_minute_qps: number
rate_limited: number
would_limit: number
exempt: Record<string, number>
failures: Record<string, number>
}
export interface GatewayCarrierStats {
minutes: number
mode: string
carriers: unknown[]
carriers: { carrier_id: number; carrier_name: string; operations: GatewayOperationStats[] }[]
}
export interface GatewayCarrierTrend {
carrier_id: number
points: unknown[]
points: { time: string; operations: GatewayOperationStats[] }[]
}

View File

@@ -110,6 +110,7 @@ export type {
UpdatePollingConfigRequest,
UpdatePollingConfigStatusRequest
} from './pollingConfig'
export * from './pollingConfigExtensions'
// 手动触发相关
export * from './manualTrigger'
@@ -159,3 +160,4 @@ export * from './assetWallet'
// 运营报表相关
export * from './operationsReport'
export * from './gateway'
export * from './pollingScheduler'

View File

@@ -1,149 +1,51 @@
/**
* 轮询配置管理相关类型定义
*/
import type { PollingConfigOverride } from './pollingConfigExtensions'
/**
* 卡业务类型
*/
export type CardCategory = 'normal' | 'industry'
/**
* 卡状态条件
*/
export type CardCondition = 'not_real_name' | 'real_name' | 'activated' | 'suspended'
/**
* 轮询配置对象
*/
export interface PollingConfig {
/** 配置ID */
export type PollingClearField =
| 'carrier_id'
| 'asset_status'
| 'package_state'
| 'card_category'
| 'card_condition'
| 'realname_check_interval'
| 'package_check_interval'
| 'carddata_check_interval'
| 'card_status_check_interval'
| 'pre_reset_window'
| 'pre_reset_carddata_interval'
export interface PollingConfig extends Omit<PollingConfigOverride, 'disabled'> {
id: number
/** 配置名称 */
config_name: string
/** 配置说明 */
description: string
/** 卡业务类型 */
card_category: CardCategory
/** 卡状态条件 */
card_condition: CardCondition
/** 运营商ID */
carrier_id: number
/** 优先级(数字越小优先级越高) */
priority: number
/** 状态(1:启用,0:禁用) */
status: number
/** 实名检查间隔(秒,NULL表示不检查) */
realname_check_interval: number | null
/** 套餐检查间隔(秒,NULL表示不检查) */
package_check_interval: number | null
/** 流量检查间隔(秒,NULL表示不检查) */
carddata_check_interval: number | null
/** 保护期一致性检查间隔(秒,NULL表示不检查) */
protect_check_interval: number | null
/** 卡开停机状态检查间隔(秒,NULL表示不检查) */
card_status_check_interval: number | null
/** 创建时间 */
carrier_name?: string
created_at: string
/** 更新时间 */
updated_at: string
warnings?: unknown
}
/**
* 轮询配置查询参数
*/
export interface PollingConfigQueryParams {
/** 页码(默认1) */
page?: number
/** 每页数量(1-100) */
page_size?: number
/** 状态(1:启用,0:禁用) */
status?: number
/** 卡状态条件 */
card_condition?: CardCondition
/** 卡业务类型 */
card_category?: CardCategory
/** 运营商ID */
carrier_id?: number
/** 配置名称(模糊搜索) */
config_name?: string
asset_status?: number
package_state?: string
}
/**
* 轮询配置列表响应
*/
export interface PollingConfigListResponse {
/** 配置列表 */
items: PollingConfig[]
/** 当前页 */
page: number
/** 每页数量 */
size?: number
page_size: number
/** 总数 */
total: number
/** 总页数 */
total_pages: number
}
/**
* 创建轮询配置请求参数
*/
export interface CreatePollingConfigRequest {
/** 配置名称(1-100字符) */
config_name: string
/** 优先级(1-1000,数字越小优先级越高) */
priority: number
/** 配置说明(最多500字符) */
description?: string
/** 卡业务类型 */
card_category?: CardCategory
/** 卡状态条件 */
card_condition?: CardCondition
/** 运营商ID */
carrier_id?: number
/** 实名检查间隔(秒,最小30,NULL表示不检查) */
realname_check_interval?: number | null
/** 套餐检查间隔(秒,最小60,NULL表示不检查) */
package_check_interval?: number | null
/** 流量检查间隔(秒,最小60,NULL表示不检查) */
carddata_check_interval?: number | null
/** 保护期一致性检查间隔(秒,最小30,NULL表示不检查) */
protect_check_interval?: number | null
/** 卡开停机状态检查间隔(秒,最小30,NULL表示不检查) */
card_status_check_interval?: number | null
export type CreatePollingConfigRequest = Omit<PollingConfigOverride, 'id' | 'disabled'>
export type UpdatePollingConfigRequest = Partial<CreatePollingConfigRequest> & {
clear_fields?: PollingClearField[]
}
/**
* 更新轮询配置请求参数
*/
export interface UpdatePollingConfigRequest {
/** 配置名称(1-100字符) */
config_name?: string
/** 优先级(1-1000,数字越小优先级越高) */
priority?: number
/** 配置说明(最多500字符) */
description?: string
/** 卡业务类型 */
card_category?: CardCategory
/** 卡状态条件 */
card_condition?: CardCondition
/** 运营商ID */
carrier_id?: number
/** 实名检查间隔(秒,最小30,NULL表示不检查) */
realname_check_interval?: number | null
/** 套餐检查间隔(秒,最小60,NULL表示不检查) */
package_check_interval?: number | null
/** 流量检查间隔(秒,最小60,NULL表示不检查) */
carddata_check_interval?: number | null
/** 保护期一致性检查间隔(秒,最小30,NULL表示不检查) */
protect_check_interval?: number | null
/** 卡开停机状态检查间隔(秒,最小30,NULL表示不检查) */
card_status_check_interval?: number | null
}
/**
* 更新轮询配置状态请求参数
*/
export interface UpdatePollingConfigStatusRequest {
/** 状态(1:启用,0:禁用) */
status: number
}

View File

@@ -0,0 +1,87 @@
/** 草稿预览与已升级持久化字段共用的配置契约。 */
export interface PollingConfigOverride {
id: number
config_name: string
priority: number
description?: string
carrier_id?: number | null
asset_status?: number | null
card_category?: string
card_condition?: string
package_state?: string | null
realname_check_interval?: number | null
carddata_check_interval?: number | null
package_check_interval?: number | null
card_status_check_interval?: number | null
pre_reset_window?: number | null
pre_reset_carddata_interval?: number | null
disabled?: boolean
}
export interface PollingMatchPreview {
card: {
asset_status: number
card_category: string
card_condition: string
card_id: number
carrier_id: number
enable_polling: boolean
iccid: string
package_state: string | null
package_state_name: string
}
matched: { config_name: string; draft: boolean; id: number; priority: number }[]
intervals: {
config_id: number
config_name: string
inherited: boolean
interval: number | null
mode: string
task_type: string
task_type_name: string
}[]
}
export interface PollingPreviewRequest {
iccid: string
overrides?: PollingConfigOverride[]
}
export interface PollingRecalculateTask {
task_id: string
status: string
total: number
processed: number
advanced: number
carrier_id: string | null
error: string
created_at: string
started_at: string
finished_at: string
}
export interface PollingPreResetPeriod {
active_package_cards: number
checked_in_window: number
not_checked_in_window: number
last_offset_buckets: { le_5: number; le_15: number; le_30: number; le_60: number; gt_60: number }
reset_at: string
}
export interface PollingPreResetStats {
carriers: {
carrier_id: number
carrier_name: string
previous: PollingPreResetPeriod | null
upcoming: PollingPreResetPeriod | null
}[]
}
export interface PollingTierStats {
by_config: { cards: number; config_id: number; config_name: string }[]
by_package_state: Record<string, number>
computed_at: string
total_cards: number
unmatched_in_stock: number
unmatched_sold_no_package: number
}

View File

@@ -0,0 +1,284 @@
import type { PaginationParams } from './common'
export interface SchedulerPage<T> {
items: T[]
total: number
page: number
size: number
}
export interface CarrierPollingParams {
carrier_id: number
carrier_name: string
is_default: boolean
configured_card_max_in_flight: number | null
configured_inflight_limit: number | null
card_max_in_flight: number
inflight_limit: number
consume_capacity: number
priority_max_ratio: number | null
sr_inflight_limit: number | null
stop_resume_confirm_delay: number | null
stop_resume_max_in_flight: number | null
stop_resume_min_interval: number | null
}
export interface CarrierParamsInput {
card_max_in_flight: number | null
inflight_limit: number | null
priority_max_ratio: number | null
sr_inflight_limit: number | null
stop_resume_confirm_delay: number | null
stop_resume_max_in_flight: number | null
stop_resume_min_interval: number | null
}
export interface CarrierParamsList {
default_card_max_in_flight: number
default_inflight_limit: number
subscriber_processes: number
items: CarrierPollingParams[]
}
export interface PollingCheckScene {
scene: string
scene_name: string
check_types: string[]
confirm_delays: number[]
cooldown: number | null
updated_at: string
}
export type CheckSceneInput = Pick<PollingCheckScene, 'check_types' | 'confirm_delays' | 'cooldown'>
export interface PollingFailureQuery extends PaginationParams {
carrier_id?: number
check_type?: string
view?: string
error_category?: string
status?: number
alert_level?: string
iccid?: string
start_time?: string
end_time?: string
}
export interface PollingFailureAlert {
id: number
alert_level: string
card_id: number
carrier_id: number
carrier_name: string
check_type: string
check_type_name: string
created_at: string
device_id?: number
enable_polling?: boolean
error_category: string
fail_count: number
first_failed_at: string
handle_remark: string
handled_at: string
handled_by: number
handled_by_name?: string
has_package?: boolean
iccid: string
last_error: string
last_failed_at: string
network_status?: number
recovered_at: string
status: number
status_name: string
stop_reason?: string
}
export interface PollingFailureSummary {
carrier_id: number
carrier_name: string
error_category: string
open_count: number
}
export interface CarrierQueue {
carrier_id: number
carrier_name: string
due_count: number
expired_claims_1h: number
inflight_limit: number
open_alerts: number
open_alerts_by_category: Record<string, number>
priority_length: number
qps_last_minute: number
queue_length: number
rate_limit_waits: number
rate_limited: number
running: number
stop_resume_due: number
stop_resume_failed_1h: number
stop_resume_pending: number
stop_resume_queue_length: number
stop_resume_retry_1h: number
stop_resume_running: number
stop_resume_success_1h: number
stop_resume_unknown_1h: number
}
export interface CarrierQueues {
init_state: string
scheduler_mode: string
items: CarrierQueue[]
}
export interface CardPollingState {
card_id: number
carrier_id: number
claim_expire_at: string
enable_polling: boolean
iccid: string
last_stop_resume_call_at: string
priority_mark: boolean
queue: string
queue_score_at: string
running: boolean
scheduler_mode: string
stop_resume_queue_enabled: boolean
stop_resume_command: null | {
action: string
fail_count: number
last_error: string
next_at: string
pending: boolean
reason: string
scenes: string[]
source: string
unknown: number
version: number
written_at: string
}
types: {
check_type: string
check_type_name: string
fail_count: number
first_failed_at: string
last_error: string
last_success_at: string
next_due_at: string
pending_confirm: number[]
}[]
}
export interface PollingImpact {
disable: string[]
enable: string[]
}
export interface PollingBatchResult {
success_count: number
skip_count: number
fail_count: number
impact: string[]
items: {
asset_id: number
identifier: string
reason: string
status: number
status_name: string
}[]
}
export interface PollingToggleInput {
asset_ids: number[]
enable_polling: boolean
reason: string
}
export interface PollingPauseQuery extends PaginationParams {
asset_type?: string
carrier_id?: number
source?: string
status?: number
identifier?: string
operator_id?: number
start_time?: string
end_time?: string
}
export interface PollingPause {
id: number
asset_id: number
asset_type: string
carrier_id: number
carrier_name: string
close_reason: string
close_source: string
close_source_name: string
closed_at: string
closed_by: number
closed_by_name: string
enable_polling: boolean
gateway_extend: string
identifier: string
import_task_id: number
restore_reason: string
restore_source: string
restore_source_name: string
restored_at: string
restored_by: number
restored_by_name: string
status: number
status_name: string
}
export interface PollingImportInput {
file_key: string
asset_type: 'iot_card' | 'device'
enable_polling: boolean
reason: string
}
export interface PollingImportTask {
id: number
asset_type: string
completed_at: string
created_at: string
creator_name: string
enable_polling: boolean
error_message: string
fail_count: number
file_name: string
reason: string
skip_count: number
started_at: string
status: number
status_name: string
success_count: number
task_no: string
total_count: number
items?: {
asset_id: number
identifier: string
line: number
reason: string
status: number
status_name: string
}[]
}
export interface StopResumeDryQuery extends PaginationParams {
carrier_id?: number
stop_reason?: string
assume_control_on?: string
assume_control_off?: string
result?: string
}
export interface StopResumeDryRow {
card_category: string
card_id: number
carrier_id: number
carrier_name: string
device_id: number
iccid: string
network_status: number
realname_ok: boolean
reason: string
reason_name: string
result: string
result_name: string
stop_reason: string
stop_reason_name: string
}
export interface StopResumeDryRun extends SchedulerPage<StopResumeDryRow> {
summary: {
carrier_id: number
carrier_name: string
count: number
reason: string
reason_name: string
result: string
result_name: string
stop_reason: string
stop_reason_name: string
}[]
totals: { no_action: number; total: number; will_resume: number; will_stop: number }
}

View File

@@ -1,6 +1,6 @@
import type { PaginationParams } from './common'
export type SystemConfigModule = 'carrier_callback' | 'c2b.payment'
export type SystemConfigModule = 'carrier_callback' | 'c2b.payment' | 'polling'
export type AllowedPaymentMethod = 'wallet' | 'wechat' | 'alipay'
export const PAYMENT_CONFIG_KEYS = {

View File

@@ -149,6 +149,7 @@ declare module 'vue' {
ElWatermark: typeof import('element-plus/es')['ElWatermark']
ExportTaskCreateDialog: typeof import('./../components/business/ExportTaskCreateDialog.vue')['default']
GatewayDynamicData: typeof import('./../components/business/gateway/GatewayDynamicData.vue')['default']
GatewayOperationTable: typeof import('./../components/business/gateway/GatewayOperationTable.vue')['default']
GatewayOptionsNotice: typeof import('./../components/business/gateway/GatewayOptionsNotice.vue')['default']
GatewayRuleFields: typeof import('./../components/business/gateway/GatewayRuleFields.vue')['default']
HorizontalSubmenu: typeof import('./../components/core/layouts/art-menus/art-horizontal-menu/widget/HorizontalSubmenu.vue')['default']
@@ -156,13 +157,19 @@ declare module 'vue' {
LoginLeftView: typeof import('./../components/core/views/login/LoginLeftView.vue')['default']
MenuLayoutSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/MenuLayoutSettings.vue')['default']
MenuStyleSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/MenuStyleSettings.vue')['default']
NullableNumber: typeof import('./../components/business/polling/NullableNumber.vue')['default']
OperatorSelect: typeof import('./../components/business/OperatorSelect.vue')['default']
PackageSelector: typeof import('./../components/business/PackageSelector.vue')['default']
PaymentVoucherDialog: typeof import('./../components/business/PaymentVoucherDialog.vue')['default']
PollingBatchDialog: typeof import('./../components/business/polling/PollingBatchDialog.vue')['default']
PollingImportDialog: typeof import('./../components/business/polling/PollingImportDialog.vue')['default']
PollingStateDialog: typeof import('./../components/business/polling/PollingStateDialog.vue')['default']
RealnamePolicyDialog: typeof import('./../components/device/RealnamePolicyDialog.vue')['default']
RefundApprovalDialog: typeof import('./../components/business/RefundApprovalDialog.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SchedulerDetail: typeof import('./../components/business/polling/SchedulerDetail.vue')['default']
SchedulerTable: typeof import('./../components/business/polling/SchedulerTable.vue')['default']
SectionTitle: typeof import('./../components/core/layouts/art-settings-panel/widget/SectionTitle.vue')['default']
SeriesGrantPackageDialog: typeof import('./../components/business/SeriesGrantPackageDialog.vue')['default']
SeriesGrantPackageTable: typeof import('./../components/business/SeriesGrantPackageTable.vue')['default']

View File

@@ -8,6 +8,9 @@ export function gatewayData<T>(response: BaseResponse<T>): T {
}
export function gatewayError(error: unknown): string {
const response = (error as { response?: { data?: { msg?: string; message?: string } } })?.response
?.data
if (response?.msg || response?.message) return response.msg || response.message!
return error instanceof Error ? error.message : '请求失败,请重试'
}
@@ -60,6 +63,8 @@ function requiredText(value: string, max: number, label: string) {
}
export function validateGatewayRule(data: GatewayRuleInput) {
if (data.auto_disable_polling && data.category !== 'terminal')
throw new Error('自动关闭轮询仅终态类别可开启')
requiredText(data.category, 100, '错误分类')
requiredText(data.keyword, 200, '关键字或正则')
requiredText(data.match_type, 100, '匹配方式')
@@ -103,6 +108,7 @@ export function gatewayRows(values: unknown[]): Record<string, unknown>[] {
/** 与店铺列表统一:展示 YYYY-MM-DD HH:mm:ss,原始响应与请求不改写。 */
export function gatewayDisplayValue(value: unknown, key = ''): string {
if (typeof value === 'boolean') return value ? '是' : '否'
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(value)) {
return formatDateTime(value)
}

View File

@@ -0,0 +1,146 @@
import type {
PollingConfigOverride,
PollingPreResetStats
} from '@/types/api/pollingConfigExtensions'
import { POLLING_DRAFT_INTERVALS } from '@/config/constants/pollingConfig'
import { validateGatewayInteger } from './gateway'
import type {
CreatePollingConfigRequest,
PollingClearField,
UpdatePollingConfigRequest
} from '@/types/api/pollingConfig'
export function pollingIccid(value: string): string {
const iccid = value.trim()
if (!iccid || iccid.length > 30) throw new Error('请输入 ICCID,最多 30 字符')
return iccid
}
export function newPollingDraft(): PollingConfigOverride {
return {
id: 0,
config_name: '',
priority: 1,
description: '',
carrier_id: null,
asset_status: null,
card_category: '',
card_condition: '',
package_state: null,
realname_check_interval: null,
carddata_check_interval: null,
package_check_interval: null,
card_status_check_interval: null,
pre_reset_window: null,
pre_reset_carddata_interval: null,
disabled: false
}
}
export function pollingDraftBody(drafts: PollingConfigOverride[]): PollingConfigOverride[] {
const ids = new Set<number>()
return drafts.map((draft) => {
validateGatewayInteger(draft.id, 0, Number.MAX_SAFE_INTEGER, '草稿配置 ID')
if (draft.id > 0 && ids.has(draft.id)) throw new Error('同一配置不能重复覆盖')
if (draft.id > 0) ids.add(draft.id)
if (draft.disabled) return { id: draft.id, disabled: true } as PollingConfigOverride
if (!draft.config_name?.trim() || draft.config_name.length > 100)
throw new Error('配置名称必填,最多 100 字符')
validateGatewayInteger(draft.priority, 1, 1000, '优先级')
if ((draft.description?.length || 0) > 500) throw new Error('配置说明最多 500 字符')
if (draft.carrier_id != null)
validateGatewayInteger(draft.carrier_id, 0, Number.MAX_SAFE_INTEGER, '运营商账户')
if (draft.asset_status != null) validateGatewayInteger(draft.asset_status, 1, 4, '资产状态')
if (
draft.package_state &&
!['active', 'depleted', 'pending', 'none'].includes(draft.package_state)
)
throw new Error('套餐状态无效')
for (const item of POLLING_DRAFT_INTERVALS) {
const value = draft[item.field]
if (value != null && value !== 0)
validateGatewayInteger(value, item.min, Number.MAX_SAFE_INTEGER, item.label)
}
const hasWindow = draft.pre_reset_window != null
const hasInterval = draft.pre_reset_carddata_interval != null
if (hasWindow !== hasInterval)
throw new Error('重置日前窗口长度与窗口内流量间隔须同时配置或同时留空')
if (hasWindow) {
validateGatewayInteger(
draft.pre_reset_window,
60,
Number.MAX_SAFE_INTEGER,
'重置日前窗口长度'
)
validateGatewayInteger(
draft.pre_reset_carddata_interval,
60,
Number.MAX_SAFE_INTEGER,
'窗口内流量间隔'
)
}
// 白名单构造,不能泄漏 UI key、status、创建时间或其他持久化字段。
const body = newPollingDraft()
for (const key of Object.keys(body) as (keyof PollingConfigOverride)[]) {
if (draft[key] !== undefined) Object.assign(body, { [key]: draft[key] })
}
return body
})
}
export function validatePollingOrder(ids: number[]) {
if (!ids.length) throw new Error('至少选择一条配置')
if (new Set(ids).size !== ids.length) throw new Error('配置 ID 不得重复')
ids.forEach((id) => validateGatewayInteger(id, 1, Number.MAX_SAFE_INTEGER, '配置 ID'))
}
export function pollingTaskActive(status?: string) {
return status === 'pending' || status === 'running'
}
export function pollingSaveBody(
value: PollingConfigOverride,
editing: boolean
): CreatePollingConfigRequest | UpdatePollingConfigRequest {
const draft = pollingDraftBody([{ ...value, disabled: false }])[0]
const data: CreatePollingConfigRequest = {
config_name: draft.config_name,
priority: draft.priority
}
for (const key of Object.keys(draft))
if (key !== 'id' && key !== 'disabled')
Object.assign(data, { [key]: draft[key as keyof PollingConfigOverride] })
const clear_fields: PollingClearField[] = []
if (editing)
for (const field of [
'carrier_id',
'asset_status',
'package_state',
'card_category',
'card_condition',
'realname_check_interval',
'package_check_interval',
'carddata_check_interval',
'card_status_check_interval',
'pre_reset_window',
'pre_reset_carddata_interval'
] as const) {
if (data[field] == null || data[field] === '') {
clear_fields.push(field)
delete data[field]
}
}
return editing ? { ...data, clear_fields } : data
}
export function pollingPreResetRows(data: PollingPreResetStats) {
return data.carriers.flatMap((carrier) =>
(['previous', 'upcoming'] as const).map((period) => ({
key: `${carrier.carrier_id}:${period}`,
carrier_id: carrier.carrier_id,
carrier_name: carrier.carrier_name,
period_name: period === 'previous' ? '前一个周期' : '即将到来周期',
period: carrier[period]
}))
)
}

View File

@@ -0,0 +1,133 @@
import type {
CarrierParamsInput,
CheckSceneInput,
PollingFailureQuery
} from '@/types/api/pollingScheduler'
import { validateGatewayInteger, compactGatewayQuery, gatewayDisplayValue } from './gateway'
const schedulerLabels: Record<string, Record<string, string>> = {
asset_type: { iot_card: '卡', device: '设备' },
alert_level: { card: '卡级', carrier: '账户级' },
scheduler_mode: { legacy: '旧分片调度', carrier: '按运营商账户调度' },
queue: { priority: '优先队列', queue: '普通队列', none: '不在轮询中' },
network_status: { 0: '停机', 1: '开机' },
card_category: { normal: '普通卡', industry: '行业卡' },
check_types: { card_status: '卡状态', carddata: '流量', realname: '实名' },
action: { stop: '停机', start: '复机' }
}
export function schedulerDisplayValue(value: unknown, key = ''): string {
if (Array.isArray(value) && schedulerLabels[key])
return value.map((item) => schedulerLabels[key][String(item)] || String(item)).join('、') || '-'
return schedulerLabels[key]?.[String(value)] || gatewayDisplayValue(value, key)
}
export const isPollingPlatform = (userType: unknown) => [1, 2].includes(Number(userType))
export const canPollingBatch = (userType: unknown) => [1, 2, 3].includes(Number(userType))
export const pollingScope = (user: {
info: { id?: unknown; user_type?: unknown; shop_id?: unknown; enterprise_id?: unknown }
isLogin?: boolean
}) =>
JSON.stringify([
user.info.id,
user.info.user_type,
user.info.shop_id,
user.info.enterprise_id,
user.isLogin
])
export function validatePollingIds(ids: number[]) {
if (!ids.length || ids.length > 500 || new Set(ids).size !== ids.length)
throw new Error('每次选择 1–500 项且不能重复')
ids.forEach((id) => validateGatewayInteger(id, 1, Number.MAX_SAFE_INTEGER, '资产/告警 ID'))
}
export function pollingReason(value: string) {
if (!value.trim()) throw new Error('请填写操作原因')
return value.trim()
}
export function schedulerTime(range: unknown) {
if (!Array.isArray(range) || !range.length) return {}
if (range.length !== 2 || range.some((value) => !value)) throw new Error('请选择完整时间范围')
const dates = range.map((value) => new Date(value))
if (dates.some((value) => !Number.isFinite(value.getTime())) || dates[1] < dates[0])
throw new Error('时间范围无效')
return {
start_time: dates[0].toISOString().replace(/\.\d{3}Z$/, 'Z'),
end_time: dates[1].toISOString().replace(/\.\d{3}Z$/, 'Z')
}
}
export function failureQuery(query: PollingFailureQuery): PollingFailureQuery {
if (query.view !== 'stop_refused') return compactGatewayQuery(query)
const { page, page_size, carrier_id, status, iccid, start_time, end_time } = query
return compactGatewayQuery({
page,
page_size,
carrier_id,
status,
iccid,
start_time,
end_time,
view: 'stop_refused'
})
}
export function validateCarrierParams(data: CarrierParamsInput, capacity: number) {
for (const field of [
'card_max_in_flight',
'inflight_limit',
'sr_inflight_limit',
'stop_resume_max_in_flight'
] as const) {
if (data[field] != null) validateGatewayInteger(data[field], 1, Number.MAX_SAFE_INTEGER, field)
}
for (const field of ['stop_resume_confirm_delay', 'stop_resume_min_interval'] as const) {
if (data[field] != null) validateGatewayInteger(data[field], 0, Number.MAX_SAFE_INTEGER, field)
}
if (
data.priority_max_ratio != null &&
(!(data.priority_max_ratio > 0) || data.priority_max_ratio > 1)
)
throw new Error('优先队列占比须大于 0 且不超过 1')
if (data.inflight_limit != null && data.inflight_limit > capacity)
throw new Error('执行上限不能大于订阅进程容量之和')
}
export function validateScene(data: CheckSceneInput) {
if (
!data.check_types.length ||
data.check_types.some((value) => !['card_status', 'carddata', 'realname'].includes(value))
)
throw new Error('请选择有效检查类型')
data.confirm_delays.forEach((value, index) => {
validateGatewayInteger(value, 1, Number.MAX_SAFE_INTEGER, '确认延迟')
if (index && value <= data.confirm_delays[index - 1]) throw new Error('确认延迟必须严格递增')
})
if (data.cooldown != null)
validateGatewayInteger(data.cooldown, 0, Number.MAX_SAFE_INTEGER, '冷却时长')
}
export function validateDryAssumptions(on: number[], off: number[]) {
if (on.some((id) => off.includes(id))) throw new Error('同一账户不能同时假设开启和关闭实名管控')
}
export function validatePollingCsv(fileName: string, text: string) {
if (!/\.csv$/i.test(fileName)) throw new Error('只支持 CSV 文件')
// 识别引号内换行与转义双引号,按 CSV 记录计数;标识符始终为文本。
const lines: string[] = []
let quoted = false,
line = ''
const content = text.replace(/^\uFEFF/, '')
for (let i = 0; i < content.length; i++) {
const char = content[i]
if (char === '"') {
if (quoted && content[i + 1] === '"') {
line += '"'
i++
} else quoted = !quoted
} else if ((char === '\n' || char === '\r') && !quoted) {
if (line.trim()) lines.push(line)
line = ''
if (char === '\r' && content[i + 1] === '\n') i++
} else line += char
}
if (quoted) throw new Error('CSV 引号未闭合')
if (line.trim()) lines.push(line)
if (lines.length < 2) throw new Error('CSV 须有表头和数据行')
if (lines.length - 1 > 50000) throw new Error('CSV 最多 50000 行数据')
// 这里只计行数,不把 ICCID、IMEI 等标识符转换成数字。
return lines.length - 1
}

View File

@@ -9,6 +9,22 @@
</ElTag>
</div>
<div class="card-header-right">
<ElButton
v-if="
cardInfo?.asset_type === 'card' &&
cardInfo.asset_id > 0 &&
hasAuth('iot_info:polling_state')
"
link
type="primary"
@click="
emit('showPollingState', {
cardId: cardInfo.asset_id,
iccid: cardInfo.iccid || cardInfo.identifier
})
"
>轮询状态</ElButton
>
<ElButton
v-if="
cardInfo?.asset_type === 'card' &&
@@ -501,6 +517,18 @@
</div>
<div class="binding-card-actions">
<ElButton
v-if="
bindingCard.card_id != null &&
bindingCard.card_id > 0 &&
hasAuth('device_info:polling_state')
"
type="primary"
@click="
emit('showPollingState', { cardId: bindingCard.card_id!, iccid: bindingCard.iccid })
"
>轮询状态</ElButton
>
<ElButton
v-if="bindingCard.card_id != null && hasAuth('device_info:stop_resume_records')"
type="primary"
@@ -938,6 +966,7 @@
// Emits
interface Emits {
(e: 'showStopResumeRecords', payload: { cardId: number; iccid: string }): void
(e: 'showPollingState', payload: { cardId: number; iccid: string }): void
(e: 'update:pollingEnabled', value: boolean): void
(e: 'enableCard'): void
(e: 'disableCard'): void

View File

@@ -249,7 +249,6 @@ export function useAssetInfo() {
max_sim_slots: data.max_sim_slots || 0,
bound_card_count: data.bound_card_count || 0,
cards: (data.cards || []).map((card) => ({ ...card })),
device_protect_status: data.device_protect_status,
online_status: data.online_status,
last_online_time: data.last_online_time,
software_version: data.software_version,
@@ -436,6 +435,8 @@ export function useAssetInfo() {
) {
return
}
if (typeof data.enable_polling === 'boolean')
cardInfo.value.enable_polling = data.enable_polling
// 更新实时状态数据
if (assetType === 'card') {
// 卡的实时状态
@@ -472,9 +473,6 @@ export function useAssetInfo() {
}
} else if (assetType === 'device') {
// 设备的实时状态
if (data.device_protect_status !== undefined) {
cardInfo.value.device_protect_status = data.device_protect_status
}
if (data.cards && data.cards.length > 0) {
// 实时 cards 可能缺少部分卡或静态字段,不能替换详情里的完整绑定列表。
cardInfo.value.cards = mergeAssetBoundCards(cardInfo.value.cards || [], data.cards)

View File

@@ -2,6 +2,8 @@ import { ref } from 'vue'
import type { Ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { AssetService, DeviceService } from '@/api/modules'
import { PollingSchedulerService } from '@/api/modules/pollingScheduler'
import { gatewayData, gatewayError } from '@/utils/business/gateway'
import { formatRemainingTime, FrontendRateLimitError } from '@/utils/business/apiRateLimit'
/**
@@ -248,6 +250,7 @@ export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Pr
* Update polling status
*/
const updatePollingStatus = async (enabled: boolean) => {
if (pollingLoading.value) return false
if (!cardInfo.value) {
ElMessage.warning('无法更新轮询状态:缺少资产信息')
return false
@@ -257,7 +260,12 @@ export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Pr
pollingLoading.value = true
const identifier =
cardInfo.value.asset_type === 'card' ? cardInfo.value.iccid : cardInfo.value.virtual_no
const target = cardInfo.value
const impact = gatewayData(await PollingSchedulerService.getImpact())
const lines = impact[enabled ? 'enable' : 'disable']
if (!Array.isArray(lines) || !lines.length) throw new Error('未获取到有效操作影响说明')
await ElMessageBox.confirm(lines.join('\n'), '确认轮询开关影响', { type: 'warning' })
if (cardInfo.value !== target) return false
const response = await AssetService.updatePollingStatus(identifier, {
enable_polling: enabled
})
@@ -265,17 +273,17 @@ export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Pr
if (response.code === 0) {
ElMessage.success(enabled ? '已开启自动轮询' : '已关闭自动轮询')
// Update local state
if (cardInfo.value) {
if (cardInfo.value === target) {
cardInfo.value.enable_polling = enabled
}
if (refreshAssetFn && cardInfo.value === target) await refreshAssetFn()
return true
} else {
ElMessage.error(response.msg || '更新轮询状态失败')
return false
}
} catch (error: any) {
console.error('更新轮询状态失败:', error)
console.log(error?.message || '更新轮询状态失败')
if (error !== 'cancel' && error !== 'close') ElMessage.error(gatewayError(error))
return false
} finally {
pollingLoading.value = false

View File

@@ -41,6 +41,7 @@
@show-realname-policy="showRealnamePolicyDialog"
@show-speed-limit="speedLimitDialogVisible = true"
@show-stop-resume-records="showStopResumeRecords"
@show-polling-state="showPollingState"
@show-update-realname-status="showUpdateRealnameStatusDialog"
@enable-binding-card="handleEnableBindingCard"
@disable-binding-card="handleDisableBindingCard"
@@ -187,6 +188,13 @@
@confirm="handleSpeedTierConfirm"
/>
</div>
<PollingStateDialog
v-if="pollingStateTarget"
:key="pollingStateTarget.cardId"
:card-id="pollingStateTarget.cardId"
:permission="pollingStatePermission"
@close="pollingStateTarget = null"
/>
</template>
<script setup lang="ts">
@@ -199,6 +207,8 @@
import { useAuth } from '@/composables/useAuth'
import { GATEWAY_PERMISSIONS } from '@/config/constants/gateway'
import StopResumeRecords from '@/components/business/gateway/StopResumeRecords.vue'
import PollingStateDialog from '@/components/business/polling/PollingStateDialog.vue'
import { SCHEDULER_PERMISSIONS } from '@/config/constants/pollingScheduler'
import {
formatRemainingTime,
FrontendRateLimitError,
@@ -302,6 +312,29 @@
const dailyRecordsDialogVisible = ref(false)
const orderHistoryDialogVisible = ref(false)
const stopResumeVisible = ref(false)
const pollingStateTarget = ref<{ cardId: number; iccid: string } | null>(null)
const pollingStatePermission = computed(() =>
cardInfo.value?.asset_type === 'card'
? SCHEDULER_PERMISSIONS.cardState
: SCHEDULER_PERMISSIONS.deviceCardState
)
function showPollingState(target: { cardId: number; iccid: string }) {
if (
!hasAuth(pollingStatePermission.value) ||
!Number.isInteger(target.cardId) ||
target.cardId <= 0
)
return
const asset = cardInfo.value
const belongs =
asset?.asset_type === 'card'
? asset.asset_id === target.cardId
: asset?.cards?.some(
(card: { card_id: number; iccid: string }) =>
card.card_id === target.cardId && card.iccid === target.iccid
)
if (belongs) pollingStateTarget.value = { ...target }
}
const stopResumeTarget = ref<{ cardId: number; iccid: string } | null>(null)
const stopResumePermission = computed(() =>
cardInfo.value?.asset_type === 'card'
@@ -333,6 +366,7 @@
() => {
stopResumeVisible.value = false
stopResumeTarget.value = null
pollingStateTarget.value = null
}
)
const realnamePolicyDialogVisible = ref(false)
@@ -381,9 +415,25 @@
// ========== 轮询状态 ==========
const pollingEnabled = ref(false)
let pollingInitializing = false
// 订单/有效期/刷新可能由后端恢复轮询,仅同步真实状态,不能触发新的开关写请求。
watch(
() => [cardInfo.value, cardInfo.value?.enable_polling],
async () => {
pollingInitializing = true
pollingEnabled.value = cardInfo.value?.enable_polling ?? false
await nextTick()
pollingInitializing = false
},
{ flush: 'sync' }
)
watch(pollingEnabled, async (val) => {
if (pollingInitializing) return
await updatePollingStatus(val)
if (!(await updatePollingStatus(val))) {
pollingInitializing = true
pollingEnabled.value = cardInfo.value?.enable_polling ?? false
await nextTick()
pollingInitializing = false
}
})
// ========== 刷新按钮状态 ==========

View File

@@ -3,8 +3,10 @@
<div class="device-list-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="searchForm"
:filter="searchForm"
@update:filter="(value) => Object.assign(searchForm, value)"
:items="searchFormItems"
:show-expand="searchFormItems.length >= 4"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
@@ -17,6 +19,15 @@
@refresh="handleRefresh"
>
<template #left>
<ElButton
v-if="
canPollingBatch(userStore.info.user_type) &&
hasAuth(SCHEDULER_PERMISSIONS.deviceBatch)
"
:disabled="!selectedDevices.length"
@click="openPollingBatch"
>批量开关轮询</ElButton
>
<ElButton
type="primary"
@click="handleBatchAllocate"
@@ -966,10 +977,18 @@
</ElCard>
</div>
</ArtTableFullScreen>
<PollingBatchDialog
v-if="pollingBatchIds"
target="device"
:ids="pollingBatchIds"
:permission="SCHEDULER_PERMISSIONS.deviceBatch"
@close="pollingBatchIds = null"
@done="getTableData"
/>
</template>
<script setup lang="ts">
import { computed, h } from 'vue'
import { computed, h, watch } from 'vue'
import { useRouter } from 'vue-router'
import { RoutesAlias } from '@/router/routesAlias'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
@@ -1001,6 +1020,9 @@
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import PollingBatchDialog from '@/components/business/polling/PollingBatchDialog.vue'
import { SCHEDULER_PERMISSIONS } from '@/config/constants/pollingScheduler'
import { canPollingBatch, pollingScope } from '@/utils/business/pollingScheduler'
import { formatDateTime } from '@/utils/business/format'
import {
getExpiryEstimateText,
@@ -1044,6 +1066,12 @@
const allocateDialogVisible = ref(false)
const recallDialogVisible = ref(false)
const selectedDevices = ref<Device[]>([])
const pollingBatchIds = ref<number[] | null>(null)
function openPollingBatch() {
if (!canPollingBatch(userStore.info.user_type) || !hasAuth(SCHEDULER_PERMISSIONS.deviceBatch))
return
pollingBatchIds.value = selectedDevices.value.map((row) => row.id)
}
const batchRealnamePolicyDialogVisible = ref(false)
const batchRealnamePolicyLoading = ref(false)
const batchRealnamePolicyError = ref('')
@@ -1342,6 +1370,7 @@
// 搜索表单初始值
const initialSearchState = {
enable_polling: undefined as boolean | undefined,
virtual_no: '',
imei: '',
device_name: '',
@@ -1363,6 +1392,13 @@
// 搜索表单
const searchForm = reactive<Record<string, any>>({ ...initialSearchState })
watch(
() => JSON.stringify([searchForm, pollingScope(userStore)]),
() => {
pollingBatchIds.value = null
selectedDevices.value = []
}
)
// 批次号仅在用户切换至筛选条件或输入批次号搜索时加载。
const loadSearchBatchNoOptions = async (batchNo?: string) => {
@@ -1614,6 +1650,7 @@
// 列配置
const columnOptions = [
{ label: '是否轮询', prop: 'enable_polling' },
{ label: '设备号', prop: 'virtual_no' },
{ label: 'IMEI', prop: 'imei' },
{ label: '设备名称', prop: 'device_name' },
@@ -1647,6 +1684,16 @@
]
const deviceList = ref<Device[]>([])
searchFormItems.push({
label: '是否轮询',
prop: 'enable_polling',
type: 'select',
options: [
{ label: '轮询中', value: true },
{ label: '已关闭', value: false }
],
config: { clearable: true }
})
// 分配表单
const allocateForm = reactive({
@@ -1867,7 +1914,7 @@
prop: 'virtual_no',
label: '设备号',
minWidth: 170,
fixed:true,
fixed: true,
showOverflowTooltip: true,
formatter: (row: Device) => {
return h(
@@ -1990,6 +2037,13 @@
return h(ElTag, { type: status.type }, () => status.text)
}
},
{
prop: 'enable_polling',
label: '是否轮询',
width: 110,
formatter: (row: Device) =>
row.enable_polling == null ? '-' : row.enable_polling ? '轮询中' : '已关闭'
},
{
prop: 'activation_status_name',
label: '激活状态',
@@ -2193,6 +2247,7 @@
imei: searchForm.imei || undefined,
device_name: searchForm.device_name || undefined,
status: searchForm.status,
enable_polling: searchForm.enable_polling ?? undefined,
activation_status: searchForm.activation_status ?? undefined,
real_name_status: searchForm.real_name_status ?? undefined,
batch_no: searchForm.batch_no || undefined,
@@ -2224,6 +2279,7 @@
imei: searchForm.imei || undefined,
device_name: searchForm.device_name || undefined,
status: searchForm.status,
enable_polling: searchForm.enable_polling ?? undefined,
activation_status: searchForm.activation_status ?? undefined,
real_name_status: searchForm.real_name_status ?? undefined,
batch_no: searchForm.batch_no || undefined,
@@ -2546,10 +2602,7 @@
const showEnterpriseDeviceAuthorizeDialog = async () => {
resetEnterpriseDeviceAuthorizeForm()
await Promise.all([
handleSearchEnterprise(),
loadTopLevelShopCascadeOptions()
])
await Promise.all([handleSearchEnterprise(), loadTopLevelShopCascadeOptions()])
enterpriseDeviceAuthorizeDialogVisible.value = true
enterpriseDeviceAuthorizeFormRef.value?.clearValidate()
}

View File

@@ -0,0 +1,5 @@
<template><ExportTaskList /></template>
<script setup lang="ts">
import ExportTaskList from '../export-task-list/index.vue'
defineOptions({ name: 'ExportPollingPause' })
</script>

View File

@@ -0,0 +1,5 @@
<template><ExportTaskList /></template>
<script setup lang="ts">
import ExportTaskList from '../export-task-list/index.vue'
defineOptions({ name: 'ExportPollingStopRefusedAlert' })
</script>

View File

@@ -0,0 +1,5 @@
<template><ExportTaskList /></template>
<script setup lang="ts">
import ExportTaskList from '../export-task-list/index.vue'
defineOptions({ name: 'ExportStopResumeDryRun' })
</script>

View File

@@ -41,7 +41,13 @@
import type { DetailSection } from '@/components/common/DetailPage.vue'
import { ExportTaskService } from '@/api/modules'
import { useAuth } from '@/composables/useAuth'
import { getExportTaskSceneConfig, getExportTaskSceneName } from '@/config/constants'
import {
getExportTaskSceneConfig,
getExportTaskSceneName,
isPollingPlatformExport
} from '@/config/constants'
import { useUserStore } from '@/store/modules/user'
import { isPollingPlatform } from '@/utils/business/pollingScheduler'
import { formatDateTime } from '@/utils/business/format'
import type { ExportTaskDetail } from '@/types/api'
import { ExportTaskStatus } from '@/types/api'
@@ -52,6 +58,7 @@
const route = useRoute()
const router = useRouter()
const { hasAuth } = useAuth()
const user = useUserStore()
const polling = useAsyncTaskPolling<ExportTaskDetail>({
storageKey: `export-task-detail:${String(route.query.id || '')}`,
autoRestore: false,
@@ -63,6 +70,8 @@
throw error
}
if (res.code !== 0 || !res.data) throw new Error(res.msg || '获取导出任务详情失败')
if (isPollingPlatformExport(res.data.scene) && !isPollingPlatform(user.info.user_type))
throw Object.assign(new Error('仅平台账号可查看该导出任务'), { status: 403 })
return res.data
},
isForbidden: (error: any) => error?.status === 403 || error?.response?.status === 403
@@ -73,7 +82,10 @@
() => getExportTaskSceneConfig(taskDetail.value?.scene)?.permissions
)
const canViewDetail = computed(
() => !!taskPermissions.value && hasAuth(taskPermissions.value.detail)
() =>
!!taskPermissions.value &&
hasAuth(taskPermissions.value.detail) &&
(!isPollingPlatformExport(taskDetail.value?.scene) || isPollingPlatform(user.info.user_type))
)
const canDownloadTask = computed(
() =>

View File

@@ -46,7 +46,9 @@
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { formatDateTime } from '@/utils/business/format'
import { RoutesAlias } from '@/router/routesAlias'
import { getExportTaskSceneConfig } from '@/config/constants'
import { getExportTaskSceneConfig, isPollingPlatformExport } from '@/config/constants'
import { useUserStore } from '@/store/modules/user'
import { isPollingPlatform } from '@/utils/business/pollingScheduler'
import type { SearchFormItem } from '@/types'
import type { ExportTaskDetail, ExportTaskItem, ExportTaskScene } from '@/types/api'
import { ExportTaskStatus } from '@/types/api'
@@ -57,6 +59,9 @@
const route = useRoute()
const router = useRouter()
const { hasAuth } = useAuth()
const user = useUserStore()
const allowedScene = (scene: ExportTaskScene) =>
!isPollingPlatformExport(scene) || isPollingPlatform(user.info.user_type)
const loading = ref(false)
const taskList = ref<ExportTaskItem[]>([])
const defaultSceneConfig = getExportTaskSceneConfig('device')!
@@ -237,6 +242,10 @@
}
const getTableData = async () => {
if (!allowedScene(currentScene.value) || !hasAuth(currentPermissions.value.page)) {
taskList.value = []
return
}
loading.value = true
try {
syncDateRange()
@@ -321,7 +330,7 @@
}
const goDetail = (row: ExportTaskItem) => {
if (!hasAuth(getRowPermissions(row).detail)) {
if (!allowedScene(row.scene) || !hasAuth(getRowPermissions(row).detail)) {
ElMessage.warning('您没有查看导出任务详情的权限')
return
}
@@ -333,7 +342,7 @@
}
const downloadTask = async (row: ExportTaskItem) => {
if (!hasAuth(getRowPermissions(row).download)) {
if (!allowedScene(row.scene) || !hasAuth(getRowPermissions(row).download)) {
ElMessage.warning('您没有下载导出任务的权限')
return
}

View File

@@ -3,8 +3,10 @@
<div class="standalone-card-list-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="formFilters"
:filter="formFilters"
@update:filter="(value) => Object.assign(formFilters, value)"
:items="formItems"
:show-expand="formItems.length >= 4"
label-width="90px"
@reset="handleReset"
@search="handleSearch"
@@ -18,6 +20,15 @@
@refresh="handleRefresh"
>
<template #left>
<ElButton
v-if="
canPollingBatch(userStore.info.user_type) &&
hasAuth(SCHEDULER_PERMISSIONS.cardBatch)
"
:disabled="!selectedCards.length"
@click="openPollingBatch"
>批量开关轮询</ElButton
>
<ElButton
type="success"
:disabled="selectedCards.length === 0"
@@ -1026,10 +1037,18 @@
</ElCard>
</div>
</ArtTableFullScreen>
<PollingBatchDialog
v-if="pollingBatchIds"
target="iot_card"
:ids="pollingBatchIds"
:permission="SCHEDULER_PERMISSIONS.cardBatch"
@close="pollingBatchIds = null"
@done="getTableData"
/>
</template>
<script setup lang="ts">
import { computed, h } from 'vue'
import { computed, h, watch } from 'vue'
import { useRouter } from 'vue-router'
import { RoutesAlias } from '@/router/routesAlias'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
@@ -1053,6 +1072,9 @@
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import PollingBatchDialog from '@/components/business/polling/PollingBatchDialog.vue'
import { SCHEDULER_PERMISSIONS } from '@/config/constants/pollingScheduler'
import { canPollingBatch, pollingScope } from '@/utils/business/pollingScheduler'
import { useUserStore } from '@/store/modules/user'
import { storeToRefs } from 'pinia'
import { formatDateTime } from '@/utils/business/format'
@@ -1094,6 +1116,12 @@
const allocateFormRef = ref<FormInstance>()
const recallFormRef = ref<FormInstance>()
const selectedCards = ref<StandaloneIotCard[]>([])
const pollingBatchIds = ref<number[] | null>(null)
function openPollingBatch() {
if (!canPollingBatch(userStore.info.user_type) || !hasAuth(SCHEDULER_PERMISSIONS.cardBatch))
return
pollingBatchIds.value = selectedCards.value.map((row) => row.id)
}
const batchRealnamePolicyDialogVisible = ref(false)
const batchRealnamePolicyLoading = ref(false)
const batchRealnamePolicyError = ref('')
@@ -1585,6 +1613,7 @@
is_authorized_to_enterprise: undefined | boolean
[key: string]: any
} = {
enable_polling: undefined,
status: undefined,
network_status: undefined,
real_name_status: undefined,
@@ -1607,6 +1636,13 @@
// 搜索表单
const formFilters = reactive({ ...initialSearchState })
watch(
() => JSON.stringify([formFilters, pollingScope(userStore)]),
() => {
pollingBatchIds.value = null
selectedCards.value = []
}
)
// 批量分配表单
const allocateForm = reactive<Partial<AllocateStandaloneCardsRequest>>({
@@ -1940,6 +1976,16 @@
})
const formItems: SearchFormItem[] = baseFormItems
formItems.push({
label: '是否轮询',
prop: 'enable_polling',
type: 'select',
options: [
{ label: '轮询中', value: true },
{ label: '已关闭', value: false }
],
config: { clearable: true }
})
// 列配置
const columnOptions = [
@@ -2068,7 +2114,7 @@
prop: 'iccid',
label: 'ICCID',
minWidth: 200,
fixed:true,
fixed: true,
formatter: (row: StandaloneIotCard) => {
return h(
'span',
@@ -2718,10 +2764,7 @@
const showEnterpriseCardRecallDialog = async () => {
resetEnterpriseCardRecallForm()
await Promise.all([
handleSearchEnterprise(),
loadAllocateCarrierOptions()
])
await Promise.all([handleSearchEnterprise(), loadAllocateCarrierOptions()])
enterpriseCardRecallDialogVisible.value = true
enterpriseCardRecallFormRef.value?.clearValidate()
}
@@ -2841,10 +2884,7 @@
selection_type:
selectedCards.value.length > 0 ? CardSelectionType.LIST : CardSelectionType.FILTER
})
await Promise.all([
loadPackageSeriesList(),
loadAllocateCarrierOptions()
])
await Promise.all([loadPackageSeriesList(), loadAllocateCarrierOptions()])
seriesBindingDialogVisible.value = true
seriesBindingFormRef.value?.clearValidate()
}

View File

@@ -0,0 +1,5 @@
<template><SchedulerDetail kind="params" /></template>
<script setup lang="ts">
import SchedulerDetail from '@/components/business/polling/SchedulerDetail.vue'
defineOptions({ name: 'PollingCarrierParamDetail' })
</script>

View File

@@ -0,0 +1,223 @@
<template>
<ArtTableFullScreen
><div id="table-full-screen" class="scheduler-page">
<ArtSearchBar
:filter="filters"
:items="searchItems"
:show-expand="searchItems.length >= 4"
label-width="110px"
@search="load"
@reset="
() => {
filters.carrier_id = undefined
load()
}
"
/>
<ElCard shadow="never" class="art-table-card">
<ElAlert v-if="error" :title="error" type="error" :closable="false" />
<p v-if="data" class="help"
>全局默认执行上限 {{ data.default_inflight_limit }};每进程并发
{{ data.default_card_max_in_flight }};订阅进程
{{ data.subscriber_processes }}。空配置使用全局默认,0 不等同于空。</p
>
<SchedulerTable
:data="rows"
:definitions="fields"
:loading="loading"
row-key="carrier_id"
@refresh="load"
>
<ElTableColumn label="操作" width="210" fixed="right"
><template #default="{ row }"
><ElButton
v-if="hasAuth(P.detail)"
link
type="primary"
@click="detail(row.carrier_id)"
>详情</ElButton
><template v-if="user.isSuperAdmin"
><ElButton link type="primary" @click="edit(row)">编辑</ElButton
><ElButton link type="danger" :disabled="saving" @click="reset(row)"
>恢复默认</ElButton
></template
></template
></ElTableColumn
>
</SchedulerTable>
</ElCard>
<ElDialog
v-model="visible"
title="运营商账户调度参数"
width="min(700px,96vw)"
:show-close="!saving"
:close-on-click-modal="!saving"
>
<ElForm label-width="200px" :disabled="saving"
><ElFormItem v-for="item in inputFields" :key="item[0]" :label="item[1]"
><NullableNumber
v-model="form[item[0]]"
:empty-label="item[0] === 'priority_max_ratio' ? '不限' : '使用默认'"
:min="
item[0] === 'priority_max_ratio'
? 0.01
: item[0].includes('interval') || item[0].includes('delay')
? 0
: 1
"
:max="item[0] === 'priority_max_ratio' ? 1 : undefined"
:precision="item[0] === 'priority_max_ratio' ? 2 : 0"
:controls="false" /></ElFormItem
></ElForm>
<p>留空使用默认;当前卡任务容量 {{ capacity }}。停复机容量由服务端独立校验。</p
><ElAlert v-if="editError" :title="editError" type="error" :closable="false" />
<template #footer
><ElButton :disabled="saving" @click="visible = false">取消</ElButton
><ElButton type="primary" :loading="saving" @click="save">保存</ElButton></template
>
</ElDialog>
</div></ArtTableFullScreen
>
</template>
<script setup lang="ts">
import { computed, reactive, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import { PollingSchedulerService as Service } from '@/api/modules/pollingScheduler'
import { useAuth } from '@/composables/useAuth'
import { useUserStore } from '@/store/modules/user'
import { useGatewayCarriers } from '@/composables/useGateway'
import { useSchedulerRequest } from '@/composables/useSchedulerRequest'
import { SCHEDULER_PERMISSIONS } from '@/config/constants/pollingScheduler'
import { gatewayData, gatewayError, requireGatewayAccess } from '@/utils/business/gateway'
import { validateCarrierParams } from '@/utils/business/pollingScheduler'
import type { CarrierParamsInput, CarrierPollingParams } from '@/types/api/pollingScheduler'
import type { SearchFormItem } from '@/types'
import SchedulerTable from '@/components/business/polling/SchedulerTable.vue'
import NullableNumber from '@/components/business/polling/NullableNumber.vue'
defineOptions({ name: 'PollingCarrierParams' })
const P = SCHEDULER_PERMISSIONS.params,
{ hasAuth } = useAuth(),
user = useUserStore(),
router = useRouter()
const { options, search, loading: carrierLoading } = useGatewayCarriers()
const filters = reactive({ carrier_id: undefined as number | undefined })
const searchItems = computed<SearchFormItem[]>(() => [
{
label: '运营商账户',
prop: 'carrier_id',
type: 'select',
options: options.value,
config: {
remote: true,
filterable: true,
clearable: true,
remoteMethod: search,
loading: carrierLoading.value
}
}
])
const { data, loading, error, load } = useSchedulerRequest(
async () => gatewayData(await Service.getCarrierParams()),
() => hasAuth(P.page)
)
const rows = computed(
() =>
data.value?.items.filter(
(row) => filters.carrier_id == null || row.carrier_id === filters.carrier_id
) || []
)
const fields = [
['carrier_name', '运营商账户'],
['is_default', '全局默认'],
['inflight_limit', '生效执行上限'],
['configured_inflight_limit', '配置执行上限'],
['card_max_in_flight', '每进程卡任务并发'],
['configured_card_max_in_flight', '配置卡任务并发'],
['consume_capacity', '订阅进程容量'],
['sr_inflight_limit', '停复机执行上限'],
['stop_resume_max_in_flight', '停复机每进程并发'],
['priority_max_ratio', '优先队列占比'],
['stop_resume_min_interval', '单卡最小间隔(秒)'],
['stop_resume_confirm_delay', '未知确认延迟(秒)']
].map(([prop, label]) => ({ prop, label }))
const inputFields = [
['card_max_in_flight', '每进程卡任务并发'],
['inflight_limit', '执行上限'],
['priority_max_ratio', '优先队列占比'],
['sr_inflight_limit', '停复机执行上限'],
['stop_resume_max_in_flight', '停复机每进程并发'],
['stop_resume_min_interval', '单卡最小间隔(秒)'],
['stop_resume_confirm_delay', '确认延迟(秒)']
]
const form = reactive<Record<string, number | null>>({}),
visible = ref(false),
saving = ref(false),
editError = ref(''),
id = ref(0),
capacity = ref(0)
function detail(carrierId: number) {
if (hasAuth(P.detail))
void router.push(`/polling-management/carrier-params/detail/${carrierId}`)
}
function edit(row: CarrierPollingParams) {
if (!user.isSuperAdmin) return
id.value = row.carrier_id
capacity.value = row.consume_capacity
for (const [key] of inputFields)
form[key] =
key === 'inflight_limit'
? row.configured_inflight_limit
: key === 'card_max_in_flight'
? row.configured_card_max_in_flight
: (row[key as keyof CarrierPollingParams] as number | null)
editError.value = ''
visible.value = true
}
async function save() {
if (saving.value) return
try {
requireGatewayAccess(user.isSuperAdmin)
validateCarrierParams(form as unknown as CarrierParamsInput, capacity.value)
saving.value = true
gatewayData(
await Service.saveCarrierParam(id.value, { ...form } as unknown as CarrierParamsInput)
)
visible.value = false
await load()
} catch (e) {
editError.value = gatewayError(e)
} finally {
saving.value = false
}
}
async function reset(row: CarrierPollingParams) {
if (!user.isSuperAdmin || saving.value) return
saving.value = true
try {
await ElMessageBox.confirm(`恢复「${row.carrier_name}」全部参数为全局默认?`, '确认', {
type: 'warning'
})
requireGatewayAccess(user.isSuperAdmin)
gatewayData(await Service.resetCarrierParam(row.carrier_id))
await load()
} catch (e) {
if (e !== 'cancel' && e !== 'close') error.value = gatewayError(e)
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.scheduler-page {
display: flex;
flex-direction: column;
min-width: 0;
}
.help {
font-size: 13px;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -0,0 +1,61 @@
<template>
<ArtTableFullScreen
><div id="table-full-screen"
><ElCard shadow="never" class="art-table-card"
><p v-if="data"
>调度模式:{{ data.scheduler_mode }};新结构初始化状态:{{
data.init_state || '未初始化'
}}。</p
><ElAlert
title="待确认停复机指令数按队列前 500 条统计;QPS 为上一分钟实际调用。刷新不会修改调度。"
type="info"
:closable="false" /><ElAlert
v-if="error"
:title="error"
type="error"
:closable="false" /><SchedulerTable
:data="data?.items || []"
:definitions="fields"
:loading="loading"
row-key="carrier_id"
@refresh="load" /></ElCard></div
></ArtTableFullScreen>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { PollingSchedulerService as Service } from '@/api/modules/pollingScheduler'
import { useAuth } from '@/composables/useAuth'
import { useSchedulerRequest } from '@/composables/useSchedulerRequest'
import { SCHEDULER_PERMISSIONS } from '@/config/constants/pollingScheduler'
import { gatewayData } from '@/utils/business/gateway'
import SchedulerTable from '@/components/business/polling/SchedulerTable.vue'
defineOptions({ name: 'PollingCarrierQueues' })
const { hasAuth } = useAuth(),
{ data, loading, error, load } = useSchedulerRequest(
async () => gatewayData(await Service.getQueues()),
() => hasAuth(SCHEDULER_PERMISSIONS.queues.page)
)
const fields = [
['carrier_name', '运营商账户'],
['queue_length', '普通队列'],
['priority_length', '优先队列'],
['due_count', '已到期卡数'],
['running', '执行中卡任务'],
['inflight_limit', '执行上限'],
['qps_last_minute', '上一分钟 QPS'],
['rate_limit_waits', '限流等待'],
['rate_limited', '限流拒绝/超时'],
['expired_claims_1h', '1小时标记到期'],
['open_alerts', '未处理卡告警'],
['open_alerts_by_category', '按类别告警'],
['stop_resume_queue_length', '停复机队列'],
['stop_resume_due', '到期指令'],
['stop_resume_running', '执行中指令'],
['stop_resume_pending', '待确认(前500条)'],
['stop_resume_success_1h', '1小时成功'],
['stop_resume_failed_1h', '1小时失败'],
['stop_resume_retry_1h', '1小时推后重试'],
['stop_resume_unknown_1h', '1小时未知']
].map(([prop, label]) => ({ prop, label }))
onMounted(load)
</script>

View File

@@ -0,0 +1,114 @@
<template>
<ArtTableFullScreen
><div id="table-full-screen"
><ElCard shadow="never" class="art-table-card"
><ElAlert v-if="error" :title="error" type="error" :closable="false" /><SchedulerTable
:data="data || []"
:definitions="fields"
:loading="loading"
row-key="scene"
@refresh="load"
><ElTableColumn v-if="user.isSuperAdmin" label="操作" width="100"
><template #default="{ row }"
><ElButton link type="primary" @click="edit(row)">编辑</ElButton></template
></ElTableColumn
></SchedulerTable
></ElCard
><ElDialog v-model="visible" title="请求检查场景" width="min(650px,96vw)"
><ElForm label-width="140px" :disabled="saving"
><ElFormItem label="场景">{{ current?.scene_name }}</ElFormItem
><ElFormItem label="检查类型"
><ElCheckboxGroup v-model="form.check_types"
><ElCheckbox label="card_status">卡状态</ElCheckbox
><ElCheckbox label="carddata">流量</ElCheckbox
><ElCheckbox label="realname">实名</ElCheckbox></ElCheckboxGroup
></ElFormItem
><ElFormItem label="确认延迟(秒)"
><ElInput
v-model="delayText"
placeholder="逗号分隔,严格递增;空表示只立即检查一次" /></ElFormItem
><ElFormItem label="冷却(秒)"
><NullableNumber
v-model="form.cooldown"
empty-label="不冷却"
:min="0"
:precision="0"
:controls="false" /></ElFormItem></ElForm
><ElAlert v-if="editError" :title="editError" type="error" :closable="false" /><template
#footer
><ElButton :disabled="saving" @click="visible = false">取消</ElButton
><ElButton type="primary" :loading="saving" @click="save">保存</ElButton></template
></ElDialog
></div
></ArtTableFullScreen
>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
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 { gatewayData, gatewayError, requireGatewayAccess } from '@/utils/business/gateway'
import { validateScene } from '@/utils/business/pollingScheduler'
import type { PollingCheckScene, CheckSceneInput } from '@/types/api/pollingScheduler'
import SchedulerTable from '@/components/business/polling/SchedulerTable.vue'
import NullableNumber from '@/components/business/polling/NullableNumber.vue'
defineOptions({ name: 'PollingCheckScenes' })
const user = useUserStore(),
{ hasAuth } = useAuth(),
{ data, loading, error, load } = useSchedulerRequest(
async () => gatewayData(await Service.getScenes()),
() => hasAuth(SCHEDULER_PERMISSIONS.scenes.page)
)
const fields = [
['scene', '编码'],
['scene_name', '场景名称'],
['check_types', '检查类型'],
['confirm_delays', '确认延迟(秒)'],
['cooldown', '冷却(秒)'],
['updated_at', '更新时间']
].map(([prop, label]) => ({ prop, label }))
const current = ref<PollingCheckScene>(),
visible = ref(false),
saving = ref(false),
editError = ref(''),
delayText = ref(''),
form = reactive<CheckSceneInput>({ check_types: [], confirm_delays: [], cooldown: null })
function edit(row: PollingCheckScene) {
if (!user.isSuperAdmin) return
current.value = row
Object.assign(form, {
check_types: [...row.check_types],
confirm_delays: [...(row.confirm_delays || [])],
cooldown: row.cooldown
})
delayText.value = form.confirm_delays.join(',')
editError.value = ''
visible.value = true
}
async function save() {
if (saving.value) return
try {
requireGatewayAccess(user.isSuperAdmin && !!current.value)
const payload = {
...form,
check_types: [...form.check_types],
confirm_delays: delayText.value.trim()
? delayText.value.split(/[,,]/).map((value) => Number(value.trim()))
: []
}
validateScene(payload)
saving.value = true
gatewayData(await Service.saveScene(current.value!.scene, payload))
visible.value = false
await load()
} catch (e) {
editError.value = gatewayError(e)
} finally {
saving.value = false
}
}
onMounted(load)
</script>

View File

@@ -1,5 +1,12 @@
<template>
<div class="app-container">
<ElAlert
type="warning"
:closable="false"
title="仅旧调度生效,按运营商账户调度时请使用运营商账户调度参数。"
>
<RouterLink to="/polling-management/carrier-params">查看运营商账户调度参数</RouterLink>
</ElAlert>
<el-card shadow="never">
<template #header>
<div class="card-header">
@@ -123,7 +130,7 @@
try {
const { data } = await PollingConcurrencyService.getConcurrencyList()
tableData.value = data.items
} catch (error) {
} catch {
console.log('加载并发配置失败')
} finally {
loading.value = false
@@ -137,7 +144,7 @@
currentRow.value = { ...data }
formData.max_concurrency = data.max_concurrency
dialogVisible.value = true
} catch (error) {
} catch {
console.log('获取并发配置失败')
}
}
@@ -189,7 +196,7 @@
ElMessage.success('修改成功')
dialogVisible.value = false
await loadData()
} catch (error) {
} catch {
console.log('修改失败')
}
})

View File

@@ -0,0 +1,348 @@
<template>
<div class="polling-stats-page" id="table-full-screen">
<ElTabs v-if="canPreReset || canTiers" v-model="activeTab">
<ElTabPane v-if="canPreReset" label="重置日前加密查流量效果" name="pre-reset">
<ArtSearchBar
:filter="filters"
:items="searchItems"
:show-expand="searchItems.length >= 4"
label-width="110px"
@update:filter="(value) => Object.assign(filters, value)"
@search="searchPreReset"
@reset="resetFilters"
/>
<ElCard shadow="never" class="art-table-card">
<ElAlert
type="info"
:closable="false"
title="窗口检查卡数为近似去重值,未检查卡数为近似值;分钟分布按接口原始桶展示。筛选只过滤本页数据。"
/>
<ElAlert v-if="carrierError" :title="carrierError" type="error" :closable="false"
><ElButton link type="primary" @click="searchCarriers()"
>重试运营商账户</ElButton
></ElAlert
>
<ArtTableHeader v-model:columns="preColumnChecks" @refresh="loadPreReset" />
<ElAlert v-if="preError" :title="preError" type="error" :closable="false"
><ElButton link type="primary" @click="loadPreReset">重试</ElButton></ElAlert
>
<ArtTable
:loading="preLoading"
:data="preRows"
row-key="key"
:pagination="false"
empty-text="暂无符合条件的周期统计"
>
<ElTableColumn
v-for="col in preColumns"
:key="col.prop"
:label="col.label"
:min-width="col.width || 140"
show-overflow-tooltip
>
<template #default="{ row }">{{ preValue(row, col.prop) }}</template>
</ElTableColumn>
</ArtTable>
</ElCard>
</ElTabPane>
<ElTabPane v-if="canTiers" label="分层轮询统计" name="tiers">
<ElCard shadow="never">
<div class="stats-heading"
><h2>分层轮询统计</h2
><ElButton type="primary" :loading="tierLoading" @click="loadTiers"
>刷新统计</ElButton
></div
>
<ElAlert
type="info"
:closable="false"
title="统计结果缓存 5 分钟,刷新仅重新请求接口,不保证强制清除缓存。"
/>
<ElAlert v-if="tierError" :title="tierError" type="error" :closable="false" />
<div v-loading="tierLoading">
<template v-if="tiers">
<ElDescriptions :column="width < 640 ? 1 : 2" border class="stats-overview">
<ElDescriptionsItem label="统计时间">{{
formatDateTime(tiers.computed_at)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="已开启轮询卡数">{{
tiers.total_cards
}}</ElDescriptionsItem>
<ElDescriptionsItem label="未匹配的在库卡数"
><ElTag :type="tiers.unmatched_in_stock > 0 ? 'danger' : 'success'">{{
tiers.unmatched_in_stock
}}</ElTag></ElDescriptionsItem
>
<ElDescriptionsItem label="未匹配的已销售无套餐卡数"
><ElTag :type="tiers.unmatched_sold_no_package > 0 ? 'danger' : 'success'">{{
tiers.unmatched_sold_no_package
}}</ElTag></ElDescriptionsItem
>
</ElDescriptions>
<ElAlert
v-if="tiers.unmatched_in_stock > 0 || tiers.unmatched_sold_no_package > 0"
type="warning"
show-icon
:closable="false"
title="存在未匹配轮询配置的在库卡或已销售无套餐卡,请检查配置。"
/>
<h3>按首个命中配置统计</h3>
<ArtTableHeader v-model:columns="tierColumnChecks" @refresh="loadTiers" />
<ArtTable
:data="tiers.by_config"
row-key="config_id"
:pagination="false"
empty-text="暂无配置分层统计"
>
<ElTableColumn
v-for="col in tierColumns"
:key="col.prop"
:label="col.label"
:min-width="180"
><template #default="{ row }">{{
col.prop === 'config_name' && row.config_id === 0
? '未匹配任何配置'
: (row[col.prop] ?? '-')
}}</template></ElTableColumn
>
</ArtTable>
<h3>按套餐状态统计</h3>
<ArtTable
:data="packageRows"
row-key="state"
:pagination="false"
empty-text="暂无套餐状态统计"
><ElTableColumn prop="label" label="套餐状态" min-width="200" /><ElTableColumn
prop="cards"
label="卡数"
min-width="150"
/></ArtTable>
</template>
<ElEmpty v-else-if="!tierLoading && !tierError" description="暂无分层统计" />
</div>
</ElCard>
</ElTabPane>
</ElTabs>
<ElEmpty v-else description="暂无统计查看权限" />
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { useWindowSize } from '@vueuse/core'
import { PollingConfigService } from '@/api/modules/pollingConfig'
import { useAuth } from '@/composables/useAuth'
import { useGatewayCarriers, useGatewayColumns } from '@/composables/useGateway'
import type { SearchFormItem } from '@/types'
import type { PollingPreResetStats, PollingTierStats } from '@/types/api/pollingConfigExtensions'
import {
POLLING_CONFIG_PERMISSIONS,
POLLING_PACKAGE_STATES,
pollingOptionLabel
} from '@/config/constants/pollingConfig'
import { pollingPreResetRows } from '@/utils/business/pollingConfig'
import { gatewayData, gatewayError, requireGatewayAccess } from '@/utils/business/gateway'
import { formatDateTime } from '@/utils/business/format'
defineOptions({ name: 'PollingConfigStats' })
const { width } = useWindowSize()
const { hasAuth } = useAuth()
const canPreReset = computed(
() =>
hasAuth(POLLING_CONFIG_PERMISSIONS.statsPage) && hasAuth(POLLING_CONFIG_PERMISSIONS.preReset)
)
const canTiers = computed(
() => hasAuth(POLLING_CONFIG_PERMISSIONS.statsPage) && hasAuth(POLLING_CONFIG_PERMISSIONS.tiers)
)
const activeTab = ref(canPreReset.value ? 'pre-reset' : 'tiers')
const {
options: carrierOptions,
loading: carrierLoading,
error: carrierError,
search: searchCarriers,
select: selectCarrier
} = useGatewayCarriers(canPreReset.value)
const filters = reactive({ carrier_id: undefined as number | undefined, period: 'all' })
const appliedFilters = ref({ ...filters })
const searchItems = computed<SearchFormItem[]>(() => [
{
label: '运营商账户',
prop: 'carrier_id',
type: 'select',
options: carrierOptions.value,
onChange: ({ val }) => selectCarrier(val),
config: {
remote: true,
filterable: true,
clearable: true,
remoteMethod: searchCarriers,
loading: carrierLoading.value,
placeholder: '输入运营商名称搜索'
}
},
{
label: '周期',
prop: 'period',
type: 'select',
options: [
{ label: '全部周期', value: 'all' },
{ label: '前一个周期', value: 'previous' },
{ label: '即将到来周期', value: 'upcoming' }
],
config: { clearable: false }
}
])
const preReset = ref<PollingPreResetStats | null>(null)
const preLoading = ref(false)
const preError = ref('')
const tiers = ref<PollingTierStats | null>(null)
const tierLoading = ref(false)
const tierError = ref('')
let preSequence = 0
let tierSequence = 0
const preRows = computed(() => {
if (!preReset.value) return []
return pollingPreResetRows(preReset.value).filter(
(row) =>
(appliedFilters.value.carrier_id == null ||
row.carrier_id === appliedFilters.value.carrier_id) &&
(appliedFilters.value.period === 'all' ||
row.key.endsWith(`:${appliedFilters.value.period}`))
)
})
const packageRows = computed(() =>
Object.entries(tiers.value?.by_package_state || {}).map(([state, cards]) => ({
state,
cards,
label: state === 'unknown' ? '未知' : pollingOptionLabel(POLLING_PACKAGE_STATES, state)
}))
)
const { columns: preColumns, columnChecks: preColumnChecks } = useGatewayColumns([
{ prop: 'carrier_name', label: '运营商账户', width: 180 },
{ prop: 'period_name', label: '周期' },
{ prop: 'reset_at', label: '重置时刻', width: 180 },
{ prop: 'active_package_cards', label: '有生效套餐卡数' },
{ prop: 'checked_in_window', label: '窗口内已检查卡数(近似)', width: 200 },
{ prop: 'not_checked_in_window', label: '窗口内未检查卡数(近似)', width: 210 },
{ prop: 'le_5', label: '距重置 ≤5 分钟' },
{ prop: 'le_15', label: '距重置 ≤15 分钟' },
{ prop: 'le_30', label: '距重置 ≤30 分钟' },
{ prop: 'le_60', label: '距重置 ≤60 分钟' },
{ prop: 'gt_60', label: '距重置 >60 分钟' }
])
const { columns: tierColumns, columnChecks: tierColumnChecks } = useGatewayColumns([
{ prop: 'config_id', label: '配置 ID' },
{ prop: 'config_name', label: '配置名称' },
{ prop: 'cards', label: '卡数' }
])
function preValue(row: ReturnType<typeof pollingPreResetRows>[number], prop: string) {
if (prop === 'carrier_name') return row.carrier_name || `运营商账户(${row.carrier_id})`
if (prop === 'period_name') return row.period_name
if (!row.period) return prop === 'reset_at' ? '无周期数据' : '-'
if (prop === 'reset_at') return formatDateTime(row.period.reset_at)
if (prop in row.period.last_offset_buckets)
return (
row.period.last_offset_buckets[prop as keyof typeof row.period.last_offset_buckets] ?? '-'
)
return row.period[prop as keyof typeof row.period] ?? '-'
}
async function loadPreReset() {
if (preLoading.value) return
const current = ++preSequence
preLoading.value = true
preError.value = ''
try {
requireGatewayAccess(canPreReset.value)
const data = gatewayData(await PollingConfigService.getPreResetStats())
if (current === preSequence && canPreReset.value) preReset.value = data
} catch (cause) {
if (current === preSequence) {
preError.value = gatewayError(cause)
preReset.value = null
}
} finally {
if (current === preSequence) preLoading.value = false
}
}
async function loadTiers() {
if (tierLoading.value) return
const current = ++tierSequence
tierLoading.value = true
tierError.value = ''
try {
requireGatewayAccess(canTiers.value)
const data = gatewayData(await PollingConfigService.getTierStats())
if (current === tierSequence && canTiers.value) tiers.value = data
} catch (cause) {
if (current === tierSequence) {
tierError.value = gatewayError(cause)
tiers.value = null
}
} finally {
if (current === tierSequence) tierLoading.value = false
}
}
function searchPreReset() {
appliedFilters.value = { ...filters }
void loadPreReset()
}
function resetFilters() {
Object.assign(filters, { carrier_id: undefined, period: 'all' })
searchPreReset()
}
watch(canPreReset, (allowed) => {
if (!allowed) {
++preSequence
preReset.value = null
preLoading.value = false
activeTab.value = 'tiers'
}
})
watch(canTiers, (allowed) => {
if (!allowed) {
++tierSequence
tiers.value = null
tierLoading.value = false
activeTab.value = 'pre-reset'
}
})
onMounted(() => {
if (canPreReset.value) void loadPreReset()
if (canTiers.value) void loadTiers()
})
onBeforeUnmount(() => {
++preSequence
++tierSequence
})
</script>
<style scoped lang="scss">
.polling-stats-page {
display: flex;
flex-direction: column;
min-width: 0;
}
.stats-heading {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.stats-heading h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.stats-overview {
margin: 16px 0;
}
h3 {
margin: 20px 0 12px;
font-size: 16px;
}
</style>

View File

@@ -0,0 +1,168 @@
<template>
<ElForm label-width="130px" :disabled="disabled">
<ElRow :gutter="20">
<ElCol :xs="24" :sm="12"
><ElFormItem label="配置名称" required
><ElInput v-model="model.config_name" maxlength="100" show-word-limit /></ElFormItem
></ElCol>
<ElCol :xs="24" :sm="12"
><ElFormItem label="优先级" required
><ElInputNumber
v-model="model.priority"
:min="1"
:max="1000"
:precision="0" /></ElFormItem
></ElCol>
<ElCol :xs="24" :sm="12"
><ElFormItem label="运营商账户"
><ElSelect
v-model="model.carrier_id"
:value-on-clear="() => null"
filterable
remote
clearable
:remote-method="search"
:loading="loading"
placeholder="不限,输入运营商名称搜索"
@change="select"
><ElOption
v-for="item in options"
:key="item.value"
v-bind="item" /></ElSelect></ElFormItem
></ElCol>
<ElCol :xs="24" :sm="12"
><ElFormItem label="资产状态"
><ElSelect
v-model="model.asset_status"
:value-on-clear="() => null"
clearable
placeholder="不限"
><ElOption
v-for="item in POLLING_ASSET_STATUSES"
:key="item.value"
v-bind="item" /></ElSelect></ElFormItem
></ElCol>
<ElCol :xs="24" :sm="12"
><ElFormItem label="卡业务类型"
><ElSelect v-model="model.card_category" clearable placeholder="不限"
><ElOption
v-for="item in POLLING_CARD_CATEGORIES"
:key="item.value"
v-bind="item" /></ElSelect></ElFormItem
></ElCol>
<ElCol :xs="24" :sm="12"
><ElFormItem label="卡状态条件"
><ElSelect v-model="model.card_condition" clearable placeholder="不限"
><ElOption
v-for="item in POLLING_CARD_CONDITIONS"
:key="item.value"
v-bind="item" /></ElSelect></ElFormItem
></ElCol>
<ElCol :xs="24" :sm="12"
><ElFormItem label="套餐状态"
><ElSelect
v-model="model.package_state"
:value-on-clear="() => null"
clearable
placeholder="不限"
><ElOption
v-for="item in POLLING_PACKAGE_STATES"
:key="item.value"
v-bind="item" /></ElSelect></ElFormItem
></ElCol>
<ElCol v-if="!persisted" :xs="24" :sm="12"
><ElFormItem label="模拟停用"><ElSwitch v-model="model.disabled" /></ElFormItem
></ElCol>
<ElCol v-for="item in POLLING_DRAFT_INTERVALS" :key="item.field" :xs="24" :sm="12"
><ElFormItem :label="item.label"
><PollingIntervalField
v-model="model[item.field]"
:min="item.min"
:disabled="disabled" /></ElFormItem
></ElCol>
<ElCol :span="24"
><ElFormItem label="重置日前加密"
><ElSwitch
v-model="preResetEnabled"
active-text="启用窗口"
inactive-text="不设置" /></ElFormItem
></ElCol>
<ElCol v-if="preResetEnabled" :xs="24" :sm="12"
><ElFormItem label="重置日前窗口(秒)"
><ElInputNumber
v-model="model.pre_reset_window"
:min="60"
:precision="0"
:controls="false"
placeholder="留空表示不设置" /></ElFormItem
></ElCol>
<ElCol v-if="preResetEnabled" :xs="24" :sm="12"
><ElFormItem label="窗口内流量间隔(秒)"
><ElInputNumber
v-model="model.pre_reset_carddata_interval"
:min="60"
:precision="0"
:controls="false"
placeholder="留空表示不设置" /></ElFormItem
></ElCol>
<ElCol :span="24"
><ElFormItem label="配置说明"
><ElInput
v-model="model.description"
type="textarea"
maxlength="500"
show-word-limit /></ElFormItem
></ElCol>
</ElRow>
<ElAlert v-if="error" :title="`运营商账户加载失败:${error}`" type="error" :closable="false"
><ElButton link type="primary" @click="search()">重试</ElButton></ElAlert
>
<p class="draft-help"
>四项检查:继承=null,禁止=0,指定间隔=正数。重置日前窗口长度与窗口内流量间隔需成对设置。</p
>
</ElForm>
</template>
<script setup lang="ts">
import { computed, watch } from 'vue'
import type { PollingConfigOverride } from '@/types/api/pollingConfigExtensions'
import { useGatewayCarriers } from '@/composables/useGateway'
import {
POLLING_ASSET_STATUSES,
POLLING_PACKAGE_STATES,
POLLING_CARD_CATEGORIES,
POLLING_CARD_CONDITIONS,
POLLING_DRAFT_INTERVALS
} from '@/config/constants/pollingConfig'
import PollingIntervalField from './PollingIntervalField.vue'
const model = defineModel<PollingConfigOverride>({ required: true })
const props = defineProps<{ disabled?: boolean; carrierName?: string; persisted?: boolean }>()
const preResetEnabled = computed({
get: () =>
model.value.pre_reset_window != null || model.value.pre_reset_carddata_interval != null,
set: (enabled: boolean) => {
model.value.pre_reset_window = enabled ? (model.value.pre_reset_window ?? 60) : null
model.value.pre_reset_carddata_interval = enabled
? (model.value.pre_reset_carddata_interval ?? 60)
: null
}
})
const { options, loading, error, search, remember, select } = useGatewayCarriers()
watch(
() => [model.value.carrier_id, props.carrierName],
() => remember(model.value.carrier_id, props.carrierName),
{ immediate: true }
)
</script>
<style scoped>
.el-select {
width: 100%;
}
.draft-help {
font-size: 13px;
line-height: 1.6;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -0,0 +1,48 @@
<template>
<div class="interval-field">
<ElSelect v-model="mode" :disabled="disabled" aria-label="检查模式">
<ElOption label="继承低优先级配置" value="inherit" />
<ElOption label="禁止检查(不再继承)" value="disabled" />
<ElOption label="指定间隔(秒)" value="interval" />
</ElSelect>
<ElInputNumber
v-if="mode === 'interval'"
v-model="model"
:min="min"
:precision="0"
:disabled="disabled"
:controls="false"
:aria-label="`检查间隔,最小 ${min} 秒`"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{ min: number; disabled?: boolean }>()
const model = defineModel<number | null | undefined>()
const mode = computed({
get: () => (model.value == null ? 'inherit' : model.value === 0 ? 'disabled' : 'interval'),
set: (value) => {
model.value = value === 'inherit' ? null : value === 'disabled' ? 0 : props.min
}
})
</script>
<style scoped lang="scss">
.interval-field {
display: flex;
flex-wrap: wrap;
gap: 8px;
width: 100%;
}
.el-select {
flex: 1;
min-width: 180px;
}
.el-input-number {
width: 120px;
}
</style>

View File

@@ -0,0 +1,309 @@
<template>
<ElDialog
:model-value="true"
title="轮询配置匹配预览(不写数据)"
width="min(1100px, 96vw)"
@update:model-value="
(value) => {
if (!value) emit('close')
}
"
>
<ElForm inline label-width="90px" :disabled="loading">
<ElFormItem label="ICCID" required
><ElInput v-model.trim="iccid" maxlength="30" placeholder="输入要预览的卡 ICCID" clearable
/></ElFormItem>
<ElFormItem label="使用草稿"><ElSwitch v-model="useDraft" /></ElFormItem>
<ElFormItem
><ElButton type="primary" :loading="loading" :disabled="!canPreview" @click="runPreview"
>开始预览</ElButton
></ElFormItem
>
</ElForm>
<template v-if="useDraft">
<ElAlert
type="info"
:closable="false"
title="未保存的草稿仅用于本次模拟;不修改真实配置。新增草稿 ID=0,覆盖已有配置保留原 ID。"
/>
<div class="preview-toolbar">
<ElButton :disabled="loading" @click="addDraft()">新增草稿</ElButton>
<ElSelect
v-model="selectedConfig"
filterable
remote
clearable
:remote-method="searchConfigs"
:loading="configsLoading"
placeholder="按配置名称搜索,选择已有配置"
:disabled="loading"
>
<ElOption
v-for="item in candidates"
:key="item.id"
:label="`${item.config_name}(${item.id})`"
:value="item.id"
/>
</ElSelect>
<ElButton
:disabled="selectedConfig == null || loading"
:loading="adding"
@click="addExisting"
>添加覆盖</ElButton
>
</div>
<ElAlert v-if="configsError" :title="configsError" type="error" :closable="false"
><ElButton link type="primary" @click="searchConfigs()">重试加载配置</ElButton></ElAlert
>
<ElEmpty v-if="!drafts.length" description="未添加覆盖配置,将使用当前配置进行 POST 预览" />
<ElCollapse v-model="expandedDrafts">
<ElCollapseItem
v-for="item in drafts"
:key="item.key"
:name="item.key"
:title="`${item.value.id === 0 ? '新增草稿' : `覆盖 #${item.value.id}`}:${item.value.config_name || '未命名'}`"
>
<ElButton type="danger" link :disabled="loading" @click="removeDraft(item.key)"
>移除此草稿</ElButton
>
<PollingDraftFields
v-model="item.value"
:carrier-name="item.carrierName"
:disabled="loading"
/>
</ElCollapseItem>
</ElCollapse>
</template>
<ElAlert v-if="error" type="error" :title="error" :closable="false" show-icon />
<div v-if="result" class="preview-result">
<h3>卡信息</h3>
<ElDescriptions :column="width < 640 ? 1 : 2" border>
<ElDescriptionsItem label="ICCID">{{ result.card.iccid }}</ElDescriptionsItem>
<ElDescriptionsItem label="卡 ID">{{ result.card.card_id }}</ElDescriptionsItem>
<ElDescriptionsItem label="资产状态">{{
pollingOptionLabel(POLLING_ASSET_STATUSES, result.card.asset_status)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="参与轮询">{{
result.card.enable_polling ? '是' : '否'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="卡业务类型">{{
pollingOptionLabel(POLLING_CARD_CATEGORIES, result.card.card_category)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="卡状态条件">{{
pollingOptionLabel(POLLING_CARD_CONDITIONS, result.card.card_condition)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="套餐状态">{{
result.card.package_state_name ||
pollingOptionLabel(POLLING_PACKAGE_STATES, result.card.package_state)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="运营商账户">{{
carrierLabel(result.card.carrier_id)
}}</ElDescriptionsItem>
</ElDescriptions>
<h3>命中配置(按接口返回顺序)</h3>
<ArtTable
:data="matchedRows"
row-key="preview_key"
:pagination="false"
empty-text="未命中配置"
>
<ElTableColumn prop="id" label="配置 ID" width="100" /><ElTableColumn
prop="config_name"
label="配置名称"
min-width="160"
/><ElTableColumn prop="priority" label="优先级" width="100" />
<ElTableColumn label="未保存草稿" width="130"
><template #default="{ row }">{{ row.draft ? '是' : '否' }}</template></ElTableColumn
>
</ArtTable>
<h3>最终检查间隔</h3>
<ArtTable
:data="result.intervals"
row-key="task_type"
:pagination="false"
empty-text="暂无检查间隔"
>
<ElTableColumn label="检查类型" min-width="150"
><template #default="{ row }">{{
row.task_type_name || row.task_type
}}</template></ElTableColumn
>
<ElTableColumn label="检查模式" min-width="150"
><template #default="{ row }">{{ intervalMode(row.mode) }}</template></ElTableColumn
>
<ElTableColumn label="间隔(秒)" width="130"
><template #default="{ row }">{{ row.interval ?? '-' }}</template></ElTableColumn
>
<ElTableColumn prop="config_id" label="来源配置 ID" width="130" /><ElTableColumn
prop="config_name"
label="来源配置"
min-width="160"
/>
<ElTableColumn label="继承取值" width="110"
><template #default="{ row }">{{ row.inherited ? '是' : '否' }}</template></ElTableColumn
>
</ArtTable>
</div>
<template #footer><ElButton @click="emit('close')">关闭</ElButton></template>
</ElDialog>
</template>
<script setup lang="ts">
import { computed, ref, watch, onBeforeUnmount } from 'vue'
import { useWindowSize } from '@vueuse/core'
import { PollingConfigService } from '@/api/modules/pollingConfig'
import { useAuth } from '@/composables/useAuth'
import { useGatewayCarriers } from '@/composables/useGateway'
import { usePollingConfigCandidates } from '@/composables/usePollingConfigExtensions'
import type {
PollingConfigOverride,
PollingMatchPreview
} from '@/types/api/pollingConfigExtensions'
import {
POLLING_CONFIG_PERMISSIONS,
POLLING_ASSET_STATUSES,
POLLING_PACKAGE_STATES,
POLLING_CARD_CATEGORIES,
POLLING_CARD_CONDITIONS,
pollingOptionLabel
} from '@/config/constants/pollingConfig'
import { newPollingDraft, pollingDraftBody, pollingIccid } from '@/utils/business/pollingConfig'
import { gatewayData, gatewayError, requireGatewayAccess } from '@/utils/business/gateway'
import PollingDraftFields from './PollingDraftFields.vue'
const props = defineProps<{ seed?: PollingConfigOverride; carrierName?: string }>()
const emit = defineEmits<{ close: [] }>()
const { width } = useWindowSize()
const { hasAuth } = useAuth()
const canPreview = computed(() => hasAuth(POLLING_CONFIG_PERMISSIONS.preview))
const { label: carrierLabel } = useGatewayCarriers()
const {
candidates,
loading: configsLoading,
error: configsError,
search: searchConfigs
} = usePollingConfigCandidates()
const iccid = ref('')
const useDraft = ref(!!props.seed)
const drafts = ref<{ key: number; value: PollingConfigOverride; carrierName?: string }[]>([])
const expandedDrafts = ref<number[]>([])
const selectedConfig = ref<number>()
const adding = ref(false)
const loading = ref(false)
const error = ref('')
const result = ref<PollingMatchPreview | null>(null)
const matchedRows = computed(() =>
(result.value?.matched || []).map((row, index) => ({
...row,
preview_key: `${row.id}:${index}`
}))
)
let nextKey = 0
let sequence = 0
let active = true
function addDraft(value?: PollingConfigOverride, carrierName?: string) {
if (loading.value || !canPreview.value) return
const key = ++nextKey
drafts.value.push({ key, value: value ? { ...value } : newPollingDraft(), carrierName })
expandedDrafts.value = [key]
}
function removeDraft(key: number) {
if (!loading.value) drafts.value = drafts.value.filter((item) => item.key !== key)
}
async function addExisting() {
if (selectedConfig.value == null || adding.value) return
adding.value = true
error.value = ''
try {
requireGatewayAccess(canPreview.value)
const data = gatewayData(
await PollingConfigService.getPollingConfigById(selectedConfig.value)
)
if (!active) return
requireGatewayAccess(canPreview.value)
if (drafts.value.some((item) => item.value.id === data.id))
throw new Error('此配置已加入覆盖列表')
const value = { ...newPollingDraft(), ...data, disabled: data.status === 0 }
addDraft(value, (data as typeof data & { carrier_name?: string }).carrier_name)
} catch (cause) {
if (active) error.value = gatewayError(cause)
} finally {
if (active) adding.value = false
}
}
if (props.seed) addDraft(props.seed, props.carrierName)
watch(
[iccid, useDraft, drafts],
() => {
++sequence
loading.value = false
result.value = null
error.value = ''
},
{ deep: true, flush: 'sync' }
)
watch(canPreview, (allowed) => {
if (!allowed) {
++sequence
result.value = null
loading.value = false
}
})
async function runPreview() {
if (loading.value || adding.value) return
const current = ++sequence
error.value = ''
result.value = null
try {
requireGatewayAccess(canPreview.value)
const id = pollingIccid(iccid.value)
const body = useDraft.value
? { iccid: id, overrides: pollingDraftBody(drafts.value.map((item) => item.value)) }
: null
loading.value = true
const data = gatewayData(
await (body
? PollingConfigService.previewDraft(body)
: PollingConfigService.previewMatch(id))
)
if (current === sequence && active && canPreview.value) result.value = data
} catch (cause) {
if (current === sequence && active) error.value = gatewayError(cause)
} finally {
if (current === sequence && active) loading.value = false
}
}
function intervalMode(mode: string) {
return (
(
{ interval: '按间隔检查', disabled: '不检查', unmatched: '无配置提供取值' } as Record<
string,
string
>
)[mode] || mode
)
}
onBeforeUnmount(() => {
active = false
++sequence
})
</script>
<style scoped lang="scss">
.preview-toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin: 16px 0;
}
.preview-toolbar .el-select {
width: 300px;
max-width: 100%;
}
.preview-result h3 {
margin: 20px 0 12px;
font-size: 16px;
}
</style>

View File

@@ -0,0 +1,199 @@
<template>
<ElDialog
:model-value="true"
title="整体调整轮询配置优先级"
width="min(760px, 96vw)"
:close-on-click-modal="!saving"
:close-on-press-escape="!saving"
:show-close="!saving"
@update:model-value="
(value) => {
if (!value) emit('close')
}
"
>
<ElAlert
type="warning"
:closable="false"
title="从上到下优先级递减,提交后依次重排为 1、2、3……。请确认后再保存。"
/>
<div class="priority-toolbar"
><ElButton :loading="loading" :disabled="saving" @click="load">重新加载全部配置</ElButton
><span>共 {{ rows.length }} 条配置</span></div
>
<ElAlert v-if="error" type="error" :title="error" :closable="false" />
<ol class="drag-order" aria-label="拖拽调整优先级">
<li
v-for="(row, index) in rows"
:key="row.id"
:draggable="!saving && !loading"
@dragstart="dragIndex = index"
@dragover.prevent
@drop.prevent="drop(index)"
>
<span>拖动排序:{{ index + 1 }}. {{ row.config_name }}(ID {{ row.id }})</span>
</li>
</ol>
<ArtTable
:loading="loading"
:data="rows"
:pagination="false"
row-key="id"
max-height="460"
empty-text="暂无配置"
>
<ElTableColumn label="新优先级" width="100"
><template #default="{ $index }">{{ $index + 1 }}</template></ElTableColumn
>
<ElTableColumn prop="id" label="配置 ID" width="100" /><ElTableColumn
prop="config_name"
label="配置名称"
min-width="180"
/>
<ElTableColumn prop="priority" label="原优先级" width="100" />
<ElTableColumn label="调整顺序" width="140"
><template #default="{ $index }"
><ElButton link :disabled="$index === 0 || saving" @click="move($index, -1)"
>上移</ElButton
><ElButton link :disabled="$index === rows.length - 1 || saving" @click="move($index, 1)"
>下移</ElButton
></template
></ElTableColumn
>
</ArtTable>
<template #footer
><ElButton :disabled="saving" @click="emit('close')">取消</ElButton
><ElButton
type="primary"
:loading="saving"
:disabled="loading || !loaded || !rows.length"
@click="save"
>保存顺序</ElButton
></template
>
</ElDialog>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { ElMessageBox, ElMessage } from 'element-plus'
import { PollingConfigService } from '@/api/modules/pollingConfig'
import { useUserStore } from '@/store/modules/user'
import type { PollingConfig } from '@/types/api/pollingConfig'
import { gatewayData, gatewayError, requireGatewayAccess } from '@/utils/business/gateway'
import { validatePollingOrder } from '@/utils/business/pollingConfig'
const emit = defineEmits<{ close: []; saved: [] }>()
const userStore = useUserStore()
const rows = ref<PollingConfig[]>([])
const loading = ref(false)
const loaded = ref(false)
const saving = ref(false)
const error = ref('')
let sequence = 0
let active = true
const dragIndex = ref<number | null>(null)
function drop(index: number) {
if (dragIndex.value == null || saving.value || loading.value || !userStore.isSuperAdmin) return
const [row] = rows.value.splice(dragIndex.value, 1)
rows.value.splice(index, 0, row)
dragIndex.value = null
}
async function load() {
if (saving.value) return
const current = ++sequence
loading.value = true
loaded.value = false
error.value = ''
rows.value = []
try {
requireGatewayAccess(userStore.isSuperAdmin)
const all: PollingConfig[] = []
let page = 1
let expected = 0
do {
const data = gatewayData(
await PollingConfigService.getPollingConfigs({ page, page_size: 100 })
)
if (!active || current !== sequence) return
requireGatewayAccess(userStore.isSuperAdmin)
if (!Array.isArray(data.items) || !Number.isSafeInteger(data.total) || data.total < 0)
throw new Error('配置分页响应无效')
if (page === 1) expected = data.total
if (data.total !== expected || (data.items.length === 0 && all.length < expected))
throw new Error('配置列表发生变化,请重新加载')
all.push(...data.items)
page++
} while (all.length < expected)
if (all.length !== expected || new Set(all.map((row) => row.id)).size !== all.length)
throw new Error('配置列表不完整或存在重复,请重新加载')
rows.value = all.sort((a, b) => a.priority - b.priority || a.id - b.id)
loaded.value = true
} catch (cause) {
if (active && current === sequence) error.value = gatewayError(cause)
} finally {
if (active && current === sequence) loading.value = false
}
}
function move(index: number, delta: number) {
if (saving.value || loading.value || !userStore.isSuperAdmin) return
const target = index + delta
if (target < 0 || target >= rows.value.length) return
const [row] = rows.value.splice(index, 1)
rows.value.splice(target, 0, row)
}
async function save() {
if (saving.value || loading.value) return
error.value = ''
try {
requireGatewayAccess(userStore.isSuperAdmin)
if (!loaded.value) throw new Error('请先完整加载配置列表')
const ids = rows.value.map((row) => row.id)
validatePollingOrder(ids)
saving.value = true
await ElMessageBox.confirm(
`确认按当前顺序重排 ${ids.length} 条配置的优先级?`,
'优先级调整确认',
{ type: 'warning' }
)
requireGatewayAccess(active && userStore.isSuperAdmin)
const response = await PollingConfigService.updatePriorities(ids)
if (response != null) gatewayData(response)
if (!active) return
ElMessage.success('优先级调整成功')
emit('saved')
emit('close')
} catch (cause) {
if (active && cause !== 'cancel' && cause !== 'close') error.value = gatewayError(cause)
} finally {
if (active) saving.value = false
}
}
onMounted(load)
onBeforeUnmount(() => {
active = false
++sequence
})
</script>
<style scoped>
.drag-order {
max-height: 180px;
padding: 8px 24px;
overflow: auto;
}
.drag-order li {
padding: 8px;
cursor: grab;
border-bottom: 1px solid var(--el-border-color);
}
.priority-toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
margin: 16px 0;
}
</style>

View File

@@ -0,0 +1,196 @@
<template>
<ElDialog
:model-value="true"
title="轮询到期时间重算 / 任务进度"
width="min(800px, 96vw)"
:close-on-click-modal="!starting"
:close-on-press-escape="!starting"
:show-close="!starting"
@update:model-value="
(value) => {
if (!value) emit('close')
}
"
>
<template v-if="userStore.isSuperAdmin">
<ElAlert
type="warning"
:closable="false"
title="异步重算只会提前检查时间,不会推后;选择全部将处理所有已开启轮询的卡。创建任务不代表重算已完成。"
/>
<ElForm inline label-width="110px" class="recalculate-form" :disabled="starting">
<ElFormItem label="处理范围"
><ElSwitch v-model="allCarriers" active-text="全部账户" inactive-text="指定账户"
/></ElFormItem>
<ElFormItem v-if="!allCarriers" label="运营商账户"
><ElSelect
v-model="carrierId"
remote
filterable
clearable
:remote-method="search"
:loading="carrierLoading"
placeholder="请输入运营商名称搜索"
@change="select"
><ElOption v-for="item in carrierOptions" :key="item.value" v-bind="item" /></ElSelect
></ElFormItem>
<ElFormItem
><ElButton type="primary" :loading="starting" @click="start"
>创建重算任务</ElButton
></ElFormItem
>
</ElForm>
<ElAlert v-if="carrierError" type="error" :title="carrierError" :closable="false"
><ElButton link type="primary" @click="search()">重试</ElButton></ElAlert
>
</template>
<ElForm
inline
label-width="110px"
class="recalculate-form"
:disabled="starting"
@submit.prevent="queryProgress"
>
<ElFormItem label="任务 ID" required
><ElInput v-model="taskId" placeholder="输入任务 ID 查询" clearable
/></ElFormItem>
<ElFormItem label="自动查询"
><ElSwitch v-model="autoRefresh" :disabled="!canProgress"
/></ElFormItem>
<ElFormItem
><ElButton type="primary" native-type="submit" :loading="loading" :disabled="!canProgress"
>查询进度</ElButton
></ElFormItem
>
</ElForm>
<ElAlert
v-if="startError || error"
type="error"
:title="startError || error"
:closable="false"
/>
<ElDescriptions v-if="task" :column="width < 640 ? 1 : 2" border>
<ElDescriptionsItem label="任务 ID">{{ task.task_id }}</ElDescriptionsItem
><ElDescriptionsItem label="状态">{{ statusLabel(task.status) }}</ElDescriptionsItem>
<ElDescriptionsItem label="运营商账户">{{
task.carrier_id == null || task.carrier_id === ''
? '全部账户'
: carrierLabel(Number(task.carrier_id))
}}</ElDescriptionsItem>
<ElDescriptionsItem label="需处理卡数">{{ task.total }}</ElDescriptionsItem
><ElDescriptionsItem label="已处理卡数">{{ task.processed }}</ElDescriptionsItem
><ElDescriptionsItem label="被提前的检查项数">{{ task.advanced }}</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">{{ formatDateTime(task.created_at) }}</ElDescriptionsItem
><ElDescriptionsItem label="开始时间">{{
formatDateTime(task.started_at)
}}</ElDescriptionsItem
><ElDescriptionsItem label="结束时间">{{
formatDateTime(task.finished_at)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="失败原因">{{ task.error || '-' }}</ElDescriptionsItem>
</ElDescriptions>
<ElEmpty
v-else-if="!loading && !error && !startError"
description="输入已有任务 ID 查询进度,查询权限不授予创建任务的能力"
/>
<template #footer
><ElButton :disabled="starting" @click="emit('close')">关闭</ElButton></template
>
</ElDialog>
</template>
<script setup lang="ts">
import { computed, ref, onBeforeUnmount } from 'vue'
import { ElMessageBox, ElMessage } from 'element-plus'
import { useWindowSize } from '@vueuse/core'
import { PollingConfigService } from '@/api/modules/pollingConfig'
import { useUserStore } from '@/store/modules/user'
import { useAuth } from '@/composables/useAuth'
import { useGatewayCarriers } from '@/composables/useGateway'
import { usePollingRecalculateProgress } from '@/composables/usePollingConfigExtensions'
import { POLLING_CONFIG_PERMISSIONS } from '@/config/constants/pollingConfig'
import {
gatewayData,
gatewayError,
requireGatewayAccess,
validateGatewayInteger
} from '@/utils/business/gateway'
import { formatDateTime } from '@/utils/business/format'
const emit = defineEmits<{ close: [] }>()
const userStore = useUserStore()
const { width } = useWindowSize()
const { hasAuth } = useAuth()
const canProgress = computed(() => hasAuth(POLLING_CONFIG_PERMISSIONS.progress))
const {
options: carrierOptions,
error: carrierError,
loading: carrierLoading,
search,
select,
label: carrierLabel
} = useGatewayCarriers(userStore.isSuperAdmin)
const { taskId, task, loading, error, autoRefresh, query, adopt } = usePollingRecalculateProgress(
() => canProgress.value
)
const allCarriers = ref(true)
const carrierId = ref<number>()
const starting = ref(false)
const startError = ref('')
let active = true
function queryProgress() {
startError.value = ''
return query()
}
async function start() {
if (starting.value) return
startError.value = ''
try {
requireGatewayAccess(userStore.isSuperAdmin)
const id = allCarriers.value ? null : carrierId.value
if (id !== null) validateGatewayInteger(id, 0, Number.MAX_SAFE_INTEGER, '运营商账户')
starting.value = true
await ElMessageBox.confirm(
allCarriers.value
? '确认对全部开启轮询的卡创建重算任务?'
: '确认对所选运营商账户创建重算任务?',
'重算确认',
{ type: 'warning' }
)
requireGatewayAccess(active && userStore.isSuperAdmin)
const data = gatewayData(await PollingConfigService.recalculateDue(id!))
if (!active) return
if (!data.task_id) throw new Error('重算响应缺少任务 ID,请核对任务后再操作,勿重复创建')
adopt(data)
autoRefresh.value = true
ElMessage.success(`已创建重算任务:${data.task_id}`)
} catch (cause) {
if (active && cause !== 'cancel' && cause !== 'close') startError.value = gatewayError(cause)
} finally {
if (active) starting.value = false
}
}
function statusLabel(status: string) {
return (
(
{ pending: '已入队', running: '执行中', completed: '已完成', failed: '失败' } as Record<
string,
string
>
)[status] || status
)
}
onBeforeUnmount(() => {
active = false
})
</script>
<style scoped>
.recalculate-form {
margin-top: 16px;
}
.el-select {
min-width: 220px;
}
</style>

View File

@@ -3,10 +3,11 @@
<div class="polling-config-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="searchForm"
:filter="searchForm"
@update:filter="(value) => Object.assign(searchForm, value)"
:items="searchFormItems"
:show-expand="true"
label-width="100"
:show-expand="searchFormItems.length >= 4"
label-width="100px"
@reset="handleReset"
@search="handleSearch"
/>
@@ -22,6 +23,15 @@
<el-button type="primary" @click="handleCreate" v-permission="'polling_config_create'">
新增配置
</el-button>
<ElButton v-if="canPreview" @click="openPreview()">匹配预览</ElButton>
<ElButton
v-if="canProgress || userStore.isSuperAdmin"
@click="recalculateVisible = true"
>重算进度</ElButton
>
<ElButton v-if="userStore.isSuperAdmin" @click="priorityVisible = true"
>调整优先级</ElButton
>
</template>
</ArtTableHeader>
@@ -46,183 +56,41 @@
</el-card>
<!-- 新增/编辑对话框 -->
<PollingPreviewDialog
v-if="previewVisible && canPreview"
:seed="previewSeed"
:carrier-name="previewCarrierName"
@close="previewVisible = false"
/>
<PollingPriorityDialog
v-if="priorityVisible && userStore.isSuperAdmin"
@close="priorityVisible = false"
@saved="handleRefresh"
/>
<PollingRecalculateDialog
v-if="recalculateVisible && (canProgress || userStore.isSuperAdmin)"
@close="recalculateVisible = false"
/>
<el-dialog
v-model="dialogVisible"
:title="dialogType === 'create' ? '新增轮询配置' : '编辑轮询配置'"
width="900px"
width="min(900px, 96vw)"
:close-on-click-modal="false"
:close-on-press-escape="!saving"
:show-close="!saving"
@closed="handleDialogClosed"
>
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="120px">
<!-- 基本信息 -->
<el-divider content-position="left">基本信息</el-divider>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="配置名称" prop="config_name">
<el-input
v-model="formData.config_name"
placeholder="请输入配置名称"
maxlength="100"
show-word-limit
clearable
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="优先级" prop="priority">
<el-input-number
v-model="formData.priority"
:min="1"
:max="1000"
:controls="false"
placeholder="数字越小优先级越高"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="24">
<el-form-item label="配置说明" prop="description">
<el-input
v-model="formData.description"
type="textarea"
:rows="2"
placeholder="请输入配置说明(最多500字符)"
maxlength="500"
show-word-limit
/>
</el-form-item>
</el-col>
</el-row>
<!-- 筛选条件 -->
<el-divider content-position="left">筛选条件</el-divider>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="卡业务类型" prop="card_category">
<el-select
v-model="formData.card_category"
placeholder="请选择卡业务类型"
style="width: 100%"
clearable
>
<el-option label="普通卡" value="normal" />
<el-option label="行业卡" value="industry" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="卡状态条件" prop="card_condition">
<el-select
v-model="formData.card_condition"
placeholder="请选择卡状态条件"
style="width: 100%"
clearable
>
<el-option label="未实名" value="not_real_name" />
<el-option label="已实名" value="real_name" />
<el-option label="已激活" value="activated" />
<el-option label="已停用" value="suspended" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="运营商" prop="carrier_id">
<el-select
v-model="formData.carrier_id"
placeholder="请选择运营商"
style="width: 100%"
filterable
remote
:remote-method="remoteSearchCarrier"
clearable
>
<el-option
v-for="carrier in carrierList"
:key="carrier.id"
:label="carrier.carrier_name"
:value="carrier.id"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<!-- 检查间隔配置 -->
<el-divider content-position="left">检查间隔配置(秒)</el-divider>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="实名检查" prop="realname_check_interval">
<el-input-number
v-model="formData.realname_check_interval"
:min="30"
:controls="false"
placeholder="最小30"
style="width: 100%"
clearable
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="套餐检查" prop="package_check_interval">
<el-input-number
v-model="formData.package_check_interval"
:min="60"
:controls="false"
placeholder="最小60"
style="width: 100%"
clearable
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="流量检查" prop="carddata_check_interval">
<el-input-number
v-model="formData.carddata_check_interval"
:min="60"
:controls="false"
placeholder="最小60"
style="width: 100%"
clearable
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="保护期检查" prop="protect_check_interval">
<el-input-number
v-model="formData.protect_check_interval"
:min="30"
:controls="false"
placeholder="最小30"
style="width: 100%"
clearable
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="卡状态检查" prop="card_status_check_interval">
<el-input-number
v-model="formData.card_status_check_interval"
:min="30"
:controls="false"
placeholder="最小30"
style="width: 100%"
clearable
/>
</el-form-item>
</el-col>
</el-row>
</el-form>
<PollingDraftFields
:model-value="formData"
:carrier-name="getCarrierName(formData.carrier_id ?? 0)"
persisted
:disabled="saving"
@update:model-value="(value) => Object.assign(formData, value)"
/>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
<ElButton v-if="canPreview" @click="previewCurrentDraft">预览未保存修改</ElButton>
<el-button :disabled="saving" @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
@@ -230,15 +98,29 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, nextTick, h } from 'vue'
import { ref, reactive, computed, onMounted, onDeactivated, nextTick, h } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { PollingConfigService, CarrierService } from '@/api/modules'
import { PollingConfigService } from '@/api/modules/pollingConfig'
import { CarrierService } from '@/api/modules/carrier'
import { useAuth } from '@/composables/useAuth'
import { useUserStore } from '@/store/modules/user'
import { POLLING_CONFIG_PERMISSIONS } from '@/config/constants/pollingConfig'
import {
POLLING_ASSET_STATUSES,
POLLING_PACKAGE_STATES,
pollingOptionLabel
} from '@/config/constants/pollingConfig'
import { newPollingDraft, pollingSaveBody } from '@/utils/business/pollingConfig'
import type { PollingConfigOverride } from '@/types/api/pollingConfigExtensions'
import PollingPreviewDialog from './components/PollingPreviewDialog.vue'
import PollingDraftFields from './components/PollingDraftFields.vue'
import { gatewayData } from '@/utils/business/gateway'
import PollingPriorityDialog from './components/PollingPriorityDialog.vue'
import PollingRecalculateDialog from './components/PollingRecalculateDialog.vue'
import type {
PollingConfig,
PollingConfigQueryParams,
CreatePollingConfigRequest,
UpdatePollingConfigRequest,
PollingCardCategory,
CardCondition
} from '@/types/api'
@@ -251,8 +133,38 @@
const tableRef = ref()
const dialogVisible = ref(false)
const dialogType = ref<'create' | 'edit'>('create')
const formRef = ref<FormInstance>()
const carrierList = ref<any[]>([])
const userStore = useUserStore()
const { hasAuth } = useAuth()
const canPreview = computed(() => hasAuth(POLLING_CONFIG_PERMISSIONS.preview))
const canProgress = computed(() => hasAuth(POLLING_CONFIG_PERMISSIONS.progress))
const previewVisible = ref(false)
const priorityVisible = ref(false)
const recalculateVisible = ref(false)
const previewSeed = ref<PollingConfigOverride>()
const previewCarrierName = ref<string>()
const editingStatus = ref(1)
onDeactivated(() => {
previewVisible.value = false
priorityVisible.value = false
recalculateVisible.value = false
})
function openPreview(seed?: PollingConfigOverride) {
if (!canPreview.value) return
previewSeed.value = seed ? { ...seed } : undefined
previewCarrierName.value = carrierList.value.find(
(carrier) => carrier.id === seed?.carrier_id
)?.carrier_name
previewVisible.value = true
}
function previewCurrentDraft() {
openPreview({
...newPollingDraft(),
...formData,
id: formData.id ?? 0,
disabled: editingStatus.value === 0
})
}
// 分页数据
const pagination = reactive({
@@ -263,6 +175,8 @@
// 搜索表单
const searchForm = reactive({
asset_status: undefined as number | undefined,
package_state: undefined as string | undefined,
config_name: '',
card_category: undefined as PollingCardCategory | undefined,
card_condition: undefined as CardCondition | undefined,
@@ -270,47 +184,8 @@
status: undefined as number | undefined
})
const formData = reactive<CreatePollingConfigRequest & { id?: number }>({
config_name: '',
priority: 1,
description: '',
card_category: undefined,
card_condition: undefined,
carrier_id: undefined,
realname_check_interval: null,
package_check_interval: null,
carddata_check_interval: null,
protect_check_interval: null,
card_status_check_interval: null
})
const formRules: FormRules = {
config_name: [
{ required: true, message: '请输入配置名称', trigger: 'blur' },
{ min: 1, max: 100, message: '配置名称长度为1-100字符', trigger: 'blur' }
],
priority: [
{ required: true, message: '请输入优先级', trigger: 'blur' },
{ type: 'number', min: 1, max: 1000, message: '优先级范围为1-1000', trigger: 'blur' }
],
description: [{ max: 500, message: '配置说明最多500字符', trigger: 'blur' }],
realname_check_interval: [
{ type: 'number', min: 30, message: '实名检查间隔最小30秒', trigger: 'blur' }
],
package_check_interval: [
{ type: 'number', min: 60, message: '套餐检查间隔最小60秒', trigger: 'blur' }
],
carddata_check_interval: [
{ type: 'number', min: 60, message: '流量检查间隔最小60秒', trigger: 'blur' }
],
protect_check_interval: [
{ type: 'number', min: 30, message: '保护期检查间隔最小30秒', trigger: 'blur' }
],
card_status_check_interval: [
{ type: 'number', min: 30, message: '卡状态检查间隔最小30秒', trigger: 'blur' }
]
}
const formData = reactive<PollingConfigOverride>(newPollingDraft())
const saving = ref(false)
// 搜索表单配置
const searchFormItems = computed<SearchFormItem[]>(() => [
{
@@ -322,6 +197,20 @@
placeholder: '请输入配置名称'
}
},
{
label: '资产状态',
prop: 'asset_status',
type: 'select',
options: POLLING_ASSET_STATUSES,
config: { clearable: true }
},
{
label: '套餐状态',
prop: 'package_state',
type: 'select',
options: POLLING_PACKAGE_STATES,
config: { clearable: true }
},
{
label: '卡业务类型',
prop: 'card_category',
@@ -351,7 +240,7 @@
]
},
{
label: '运营商',
label: '运营商账户',
prop: 'carrier_id',
type: 'select',
config: {
@@ -359,7 +248,7 @@
filterable: true,
remote: true,
remoteMethod: remoteSearchCarrier,
placeholder: '请选择运营商'
placeholder: '请输入运营商名称搜索'
},
options: () =>
carrierList.value.map((c) => ({
@@ -399,6 +288,32 @@
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'asset_status',
label: '资产状态匹配',
width: 130,
formatter: (row: PollingConfig) =>
pollingOptionLabel(POLLING_ASSET_STATUSES, row.asset_status)
},
{
prop: 'package_state',
label: '套餐状态匹配',
width: 150,
formatter: (row: PollingConfig) =>
pollingOptionLabel(POLLING_PACKAGE_STATES, row.package_state)
},
{
prop: 'pre_reset_window',
label: '重置日前窗口(秒)',
width: 180,
formatter: (row: PollingConfig) => row.pre_reset_window ?? '未设置'
},
{
prop: 'pre_reset_carddata_interval',
label: '窗口流量间隔(秒)',
width: 180,
formatter: (row: PollingConfig) => row.pre_reset_carddata_interval ?? '未设置'
},
{
prop: 'config_name',
label: '配置名称',
@@ -426,7 +341,7 @@
},
{
prop: 'carrier_id',
label: '运营商',
label: '运营商账户',
width: 120,
formatter: (row: any) => getCarrierName(row.carrier_id)
},
@@ -442,18 +357,16 @@
formatter: (row: any) => (row.status === 1 ? '启用' : '禁用')
},
{
prop: 'check_intervals',
label: '检查间隔(秒)',
width: 220,
slots: {
default: ({ row }: any) =>
h('div', { style: 'font-size: 12px; line-height: 1.5' }, [
h('div', `实名: ${row.realname_check_interval ?? '-'}`),
h('div', `套餐: ${row.package_check_interval ?? '-'}`),
h('div', `流量: ${row.carddata_check_interval ?? '-'}`),
h('div', `保护期: ${row.protect_check_interval ?? '-'}`),
h('div', `卡状态: ${row.card_status_check_interval ?? '-'}`)
])
}
formatter: (row: PollingConfig) =>
h('div', { style: 'font-size: 12px; line-height: 1.5' }, [
h('div', `实名: ${row.realname_check_interval ?? '-'}`),
h('div', `套餐: ${row.package_check_interval ?? '-'}`),
h('div', `流量: ${row.carddata_check_interval ?? '-'}`),
h('div', `卡状态: ${row.card_status_check_interval ?? '-'}`)
])
},
{
prop: 'created_at',
@@ -479,6 +392,15 @@
const getActions = (row: PollingConfig) => {
const actions: any[] = [
...(canPreview.value
? [
{
label: '匹配预览',
handler: () =>
openPreview({ ...newPollingDraft(), ...row, disabled: row.status === 0 })
}
]
: []),
{
label: '编辑',
handler: () => handleEdit(row)
@@ -546,6 +468,8 @@
const handleReset = () => {
Object.assign(searchForm, {
asset_status: undefined,
package_state: undefined,
config_name: '',
card_category: undefined,
card_condition: undefined,
@@ -571,9 +495,11 @@
}
const handleCreate = () => {
editingStatus.value = 1
dialogType.value = 'create'
Object.assign(formData, {
id: undefined,
...newPollingDraft(),
id: 0,
config_name: '',
priority: 1,
description: '',
@@ -583,20 +509,23 @@
realname_check_interval: null,
package_check_interval: null,
carddata_check_interval: null,
protect_check_interval: null,
card_status_check_interval: null
})
dialogVisible.value = true
nextTick(() => {
formRef.value?.clearValidate()
// 验证由共享 DTO 校验执行
})
}
const handleEdit = async (row: PollingConfig) => {
try {
const { data } = await PollingConfigService.getPollingConfigById(row.id)
editingStatus.value = data.status
dialogType.value = 'edit'
Object.assign(formData, {
...newPollingDraft(),
...data,
disabled: false,
id: data.id,
config_name: data.config_name,
priority: data.priority,
@@ -607,12 +536,11 @@
realname_check_interval: data.realname_check_interval,
package_check_interval: data.package_check_interval,
carddata_check_interval: data.carddata_check_interval,
protect_check_interval: data.protect_check_interval,
card_status_check_interval: data.card_status_check_interval
})
dialogVisible.value = true
await nextTick(() => {
formRef.value?.clearValidate()
// 验证由共享 DTO 校验执行
})
} catch {
console.log('获取配置详情失败')
@@ -649,44 +577,36 @@
}
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
try {
const submitData: CreatePollingConfigRequest | UpdatePollingConfigRequest = {
config_name: formData.config_name,
priority: formData.priority,
description: formData.description,
card_category: formData.card_category,
card_condition: formData.card_condition,
carrier_id: formData.carrier_id,
realname_check_interval: formData.realname_check_interval,
package_check_interval: formData.package_check_interval,
carddata_check_interval: formData.carddata_check_interval,
protect_check_interval: formData.protect_check_interval,
card_status_check_interval: formData.card_status_check_interval
}
if (dialogType.value === 'create') {
await PollingConfigService.createPollingConfig(submitData as CreatePollingConfigRequest)
ElMessage.success('创建成功')
} else {
await PollingConfigService.updatePollingConfig(formData.id!, submitData)
ElMessage.success('更新成功')
}
dialogVisible.value = false
await loadData()
} catch {
console.log(dialogType.value === 'create' ? '创建失败' : '更新失败')
if (saving.value) return
try {
const data = pollingSaveBody(formData, dialogType.value === 'edit')
saving.value = true
const response =
dialogType.value === 'create'
? await PollingConfigService.createPollingConfig(data as CreatePollingConfigRequest)
: await PollingConfigService.updatePollingConfig(formData.id, data)
const saved = gatewayData(response)
ElMessage.success(dialogType.value === 'create' ? '创建成功' : '更新成功')
const warnings =
saved?.warnings ?? (response as typeof response & { warnings?: unknown }).warnings
if (warnings && (!Array.isArray(warnings) || warnings.length)) {
await ElMessageBox.alert(
typeof warnings === 'string' ? warnings : JSON.stringify(warnings),
'配置已保存:提示信息',
{ type: 'warning' }
).catch(() => undefined)
}
})
dialogVisible.value = false
await loadData()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存失败')
} finally {
saving.value = false
}
}
const handleDialogClosed = () => {
formRef.value?.resetFields()
Object.assign(formData, newPollingDraft())
}
onMounted(() => {

View File

@@ -132,6 +132,9 @@
<ElDescriptionsItem label="上游可能已受理">{{
testResult.maybe_accepted ? '是' : '否'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="自动关闭轮询">{{
testResult.auto_disable_polling ? '是' : '否'
}}</ElDescriptionsItem>
</ElDescriptions>
<template #footer
><ElButton @click="testVisible = false">关闭</ElButton
@@ -226,6 +229,7 @@
{ prop: 'match_type', label: '匹配方式' },
{ prop: 'operations', label: '适用操作', width: 220 },
{ prop: 'maybe_accepted', label: '可能已受理' },
{ prop: 'auto_disable_polling', label: '自动关闭轮询' },
{ prop: 'priority', label: '优先级', width: 90 },
{ prop: 'status', label: '状态', width: 90 },
{ prop: 'remark', label: '备注', width: 180 },
@@ -238,6 +242,8 @@
if (key === 'match_type') return label('match_types', row.match_type)
if (key === 'operations') return operationsLabel(row.operations)
if (key === 'maybe_accepted') return row.maybe_accepted ? '是' : '否'
if (key === 'auto_disable_polling')
return row.auto_disable_polling == null ? '-' : row.auto_disable_polling ? '是' : '否'
return gatewayDisplayValue(row[key as keyof GatewayRule], key)
}
function changePage(page: number) {
@@ -283,6 +289,7 @@
keyword: '',
match_type: '',
maybe_accepted: false,
auto_disable_polling: false,
operations: [],
priority: 10,
remark: '',
@@ -295,6 +302,7 @@
keyword: row.keyword,
match_type: row.match_type,
maybe_accepted: row.maybe_accepted ?? false,
auto_disable_polling: row.auto_disable_polling,
operations: [...(row.operations || [])],
priority: row.priority ?? 10,
remark: row.remark || '',

View File

@@ -0,0 +1,5 @@
<template><SchedulerDetail kind="alerts" /></template>
<script setup lang="ts">
import SchedulerDetail from '@/components/business/polling/SchedulerDetail.vue'
defineOptions({ name: 'PollingFailureAlertDetail' })
</script>

View File

@@ -0,0 +1,299 @@
<template>
<ArtTableFullScreen
><div id="table-full-screen" class="scheduler-page"
><ElRadioGroup v-model="view" @change="changeView"
><ElRadioButton label="normal">失败告警</ElRadioButton
><ElRadioButton v-if="hasAuth(P.summary)" label="summary">账户汇总</ElRadioButton
><ElRadioButton label="stop_refused">停机被拒</ElRadioButton></ElRadioGroup
><ArtSearchBar
v-if="view !== 'summary'"
:filter="filters"
:items="searchItems"
:show-expand="searchItems.length >= 4"
label-width="110px"
@search="search"
@reset="reset" /><ElCard shadow="never" class="art-table-card"
><ElAlert v-if="error" :title="error" type="error" :closable="false" /><SchedulerTable
:key="view"
:data="rows"
:definitions="view === 'summary' ? summaryFields : fields"
:loading="loading"
:selectable="view !== 'summary' && platform && hasAuth(P.close)"
:selectable-row="(row) => row.card_id > 0"
:total="view === 'summary' ? undefined : total"
:page="page"
:size="size"
@refresh="load"
@page="
(value) => {
page = value
load()
}
"
@size="
(value) => {
size = value
search()
}
"
@selection="(value) => (selected = value)"
><template #toolbar
><ElButton
v-if="platform && hasAuth(P.close) && view !== 'summary'"
:disabled="!selected.length"
@click="bulkVisible = true"
>批量关闭卡轮询</ElButton
><ElButton
v-if="view === 'stop_refused' && platform && hasAuth(P.export)"
@click="exportVisible = true"
>导出停机被拒</ElButton
></template
><ElTableColumn v-if="view !== 'summary'" label="操作" width="180" fixed="right"
><template #default="{ row }"
><ElButton v-if="hasAuth(P.detail)" link type="primary" @click="detail(row.id)"
>详情</ElButton
><ElButton
v-if="row.status === 1 && hasAuth(P.handle)"
link
type="primary"
:disabled="handling"
@click="handle(row)"
>标记已处理</ElButton
></template
></ElTableColumn
></SchedulerTable
></ElCard
><PollingBatchDialog
v-if="bulkVisible"
target="alert"
:ids="selected.map((row) => row.id)"
:permission="P.close"
@close="bulkVisible = false"
@done="load" /><ExportTaskCreateDialog
v-if="exportVisible"
v-model="exportVisible"
scene="polling_stop_refused_alert"
:query="{ filters: exportFilters }"
:confirm-permission="P.export" /></div
></ArtTableFullScreen>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
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 { useGatewayCarriers, useGatewayOptions } from '@/composables/useGateway'
import { useSchedulerRequest } from '@/composables/useSchedulerRequest'
import { SCHEDULER_PERMISSIONS, POLLING_CHECK_TYPES } from '@/config/constants/pollingScheduler'
import { gatewayData, gatewayError, requireGatewayAccess } from '@/utils/business/gateway'
import { isPollingPlatform, schedulerTime, failureQuery } from '@/utils/business/pollingScheduler'
import type { SearchFormItem } from '@/types'
import type { PollingFailureAlert } from '@/types/api/pollingScheduler'
import SchedulerTable from '@/components/business/polling/SchedulerTable.vue'
import PollingBatchDialog from '@/components/business/polling/PollingBatchDialog.vue'
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
defineOptions({ name: 'PollingFailureAlerts' })
const P = SCHEDULER_PERMISSIONS.alerts,
{ hasAuth } = useAuth(),
user = useUserStore(),
router = useRouter(),
platform = computed(() => isPollingPlatform(user.info.user_type)),
view = ref('normal'),
page = ref(1),
size = ref(20),
selected = ref<PollingFailureAlert[]>([]),
bulkVisible = ref(false),
handling = ref(false),
exportVisible = ref(false)
const {
options: carriers,
search: searchCarriers,
loading: carrierLoading
} = useGatewayCarriers(),
{ options } = useGatewayOptions()
const filters = reactive({
carrier_id: undefined as number | undefined,
status: undefined as number | undefined,
iccid: '',
check_type: '',
error_category: '',
alert_level: '',
range: [] as string[]
})
const query = () =>
failureQuery({
...filters,
range: undefined,
...schedulerTime(filters.range),
view: view.value === 'stop_refused' ? 'stop_refused' : undefined,
page: page.value,
page_size: size.value
} as any)
const { data, loading, error, load } = useSchedulerRequest<any>(
async () =>
view.value === 'summary'
? { items: gatewayData(await Service.getAlertSummary()), total: 0 }
: gatewayData(await Service.getAlerts(query())),
() => hasAuth(P.page) && (view.value !== 'summary' || hasAuth(P.summary))
)
const rows = computed(() => data.value?.items || []),
total = computed(() => data.value?.total || 0)
const exportFilters = computed(() => {
const rest = { ...query() }
delete rest.page
delete rest.page_size
delete rest.view
return rest
})
const searchItems = computed<SearchFormItem[]>(() => [
{
label: '运营商账户',
prop: 'carrier_id',
type: 'select',
options: carriers.value,
config: {
remote: true,
filterable: true,
clearable: true,
remoteMethod: searchCarriers,
loading: carrierLoading.value
}
},
{
label: '状态',
prop: 'status',
type: 'select',
options: [
{ label: '未处理', value: 1 },
{ label: '已恢复', value: 2 },
{ label: '已关闭轮询', value: 3 },
{ label: '已处理', value: 4 }
],
config: { clearable: true }
},
{ label: 'ICCID', prop: 'iccid', type: 'input', config: { clearable: true } },
...(view.value === 'stop_refused'
? []
: [
{
label: '检查类型',
prop: 'check_type',
type: 'select' as const,
options: POLLING_CHECK_TYPES,
config: { clearable: true }
},
{
label: '错误类别',
prop: 'error_category',
type: 'select' as const,
options: options.value.error_categories,
config: { clearable: true }
},
{
label: '告警级别',
prop: 'alert_level',
type: 'select' as const,
options: [
{ label: '卡级', value: 'card' },
{ label: '账户级', value: 'carrier' }
],
config: { clearable: true }
}
]),
{
label: '最近失败起止',
prop: 'range',
type: 'datetimerange',
config: {
valueFormat: 'YYYY-MM-DDTHH:mm:ssZ',
rangeSeparator: '至',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间',
clearable: true
}
}
])
const fields = [
['carrier_name', '运营商账户'],
['iccid', 'ICCID'],
['alert_level', '告警级别'],
['check_type_name', '检查类型'],
['status_name', '状态'],
['error_category', '错误类别'],
['last_error', '上游返回/最近错误'],
['fail_count', '连续失败/窗口数'],
['last_failed_at', '最近失败'],
['first_failed_at', '首次失败'],
['network_status', '网络状态'],
['stop_reason', '停机原因'],
['enable_polling', '是否轮询'],
['has_package', '有生效套餐'],
['device_id', '设备 ID'],
['handled_by_name', '处理人']
].map(([prop, label]) => ({ prop, label }))
const summaryFields = [
['carrier_name', '运营商账户'],
['error_category', '错误类别'],
['open_count', '未处理卡告警数']
].map(([prop, label]) => ({ prop, label }))
function search() {
page.value = 1
selected.value = []
void load()
}
function reset() {
Object.assign(filters, {
carrier_id: undefined,
status: undefined,
iccid: '',
check_type: '',
error_category: '',
alert_level: '',
range: []
})
search()
}
function changeView() {
selected.value = []
bulkVisible.value = false
filters.check_type = ''
filters.error_category = ''
filters.alert_level = ''
search()
}
function detail(id: number) {
if (hasAuth(P.detail)) void router.push(`/polling-management/failure-alerts/detail/${id}`)
}
async function handle(row: PollingFailureAlert) {
if (handling.value || row.status !== 1 || !hasAuth(P.handle)) return
handling.value = true
try {
const response = await ElMessageBox.prompt('处理备注(选填)', '标记已处理')
requireGatewayAccess(hasAuth(P.handle))
gatewayData(await Service.handleAlert(row.id, response.value || ''))
await load()
} catch (e) {
if (e !== 'cancel' && e !== 'close') error.value = gatewayError(e)
} finally {
handling.value = false
}
}
watch(
() => JSON.stringify(filters),
() => {
selected.value = []
bulkVisible.value = false
}
)
onMounted(load)
</script>
<style scoped>
.scheduler-page {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
</style>

View File

@@ -10,7 +10,7 @@
type="info"
show-icon
:closable="false"
title="统计接口文档未定义汇总和趋势的内部字段,以下按接口真实字段展示,不推导指标或绘制假定趋势。"
title="observe 为只统计,enforce 为实际拦截;成功率按百分比展示,p95 为分桶近似上界,-1 表示超过最大桶。"
/>
<ArtSearchBar
:filter="summaryFilter"
@@ -28,12 +28,7 @@
<p v-if="summary" class="gateway-help"
>实际统计窗口:{{ summary.minutes }} 分钟;统计模式:{{ summary.mode || '-' }}</p
>
<GatewayDynamicData
v-if="summary"
:values="summary.carriers"
:data="summary"
@refresh="loadSummary"
/>
<GatewayOperationTable v-if="summary" :values="summary.carriers" @refresh="loadSummary" />
<ElEmpty v-else-if="!summaryLoading && !summaryError" description="暂无汇总数据" />
</div>
</ElCard>
@@ -57,12 +52,7 @@
trend.carrier_id === 0 ? '无法解析账户的卡' : carrierLabel(trend.carrier_id)
}}</p
>
<GatewayDynamicData
v-if="trend"
:values="trend.points"
:data="trend"
@refresh="loadTrend"
/>
<GatewayOperationTable v-if="trend" :values="trend.points" @refresh="loadTrend" />
<ElEmpty v-else-if="!trendLoading && !trendError" description="请填写账户并查询趋势" />
</div>
</ElCard>
@@ -83,7 +73,7 @@
requireGatewayAccess,
validateGatewayInteger
} from '@/utils/business/gateway'
import GatewayDynamicData from '@/components/business/gateway/GatewayDynamicData.vue'
import GatewayOperationTable from '@/components/business/gateway/GatewayOperationTable.vue'
import GatewayOptionsNotice from '@/components/business/gateway/GatewayOptionsNotice.vue'
defineOptions({ name: 'GatewayCallStats' })

View File

@@ -0,0 +1,238 @@
<template>
<ArtTableFullScreen
><div id="table-full-screen"
><ArtSearchBar
:filter="filters"
:items="searchItems"
:show-expand="searchItems.length >= 4"
label-width="110px"
@search="search"
@reset="reset" /><ElCard shadow="never" class="art-table-card"
><ElAlert v-if="error" :title="error" type="error" :closable="false" /><SchedulerTable
:data="data?.items || []"
:definitions="fields"
:loading="loading"
:page="page"
:size="size"
:total="data?.total || 0"
:selectable="platform && hasAuth(P.restore)"
:selectable-row="(row) => row.status === 1"
:formatter="value"
@selection="(rows) => (selected = rows)"
@refresh="load"
@page="
(v) => {
page = v
load()
}
"
@size="
(v) => {
size = v
search()
}
"
><template #toolbar
><ElButton
v-if="platform && hasAuth(P.restore)"
:disabled="!selected.length"
@click="restore"
>批量开启轮询</ElButton
><ElButton v-if="platform && hasAuth(P.export)" @click="exportVisible = true"
>导出</ElButton
></template
></SchedulerTable
></ElCard
><PollingBatchDialog
v-if="bulkVisible"
:target="bulkTarget"
:ids="selected.map((row) => row.asset_id)"
:permission="P.restore"
:fixed-enabled="true"
@close="bulkVisible = false"
@done="load" /><ExportTaskCreateDialog
v-if="exportVisible"
v-model="exportVisible"
scene="polling_pause"
:query="{ filters: queryFilters }"
:confirm-permission="P.export" /></div
></ArtTableFullScreen>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { PollingSchedulerService as Service } from '@/api/modules/pollingScheduler'
import { useUserStore } from '@/store/modules/user'
import { useAuth } from '@/composables/useAuth'
import { useGatewayCarriers } from '@/composables/useGateway'
import { useSchedulerRequest } from '@/composables/useSchedulerRequest'
import { SCHEDULER_PERMISSIONS, POLLING_CLOSE_SOURCES } from '@/config/constants/pollingScheduler'
import { gatewayData, compactGatewayQuery } from '@/utils/business/gateway'
import {
isPollingPlatform,
schedulerTime,
schedulerDisplayValue
} from '@/utils/business/pollingScheduler'
import type { PollingPause } from '@/types/api/pollingScheduler'
import type { SearchFormItem } from '@/types'
import SchedulerTable from '@/components/business/polling/SchedulerTable.vue'
import PollingBatchDialog from '@/components/business/polling/PollingBatchDialog.vue'
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
defineOptions({ name: 'PollingPauses' })
const P = SCHEDULER_PERMISSIONS.pauses,
{ hasAuth } = useAuth(),
user = useUserStore(),
platform = computed(() => isPollingPlatform(user.info.user_type)),
page = ref(1),
size = ref(20),
selected = ref<PollingPause[]>([]),
bulkVisible = ref(false),
exportVisible = ref(false),
bulkTarget = ref<'iot_card' | 'device'>('iot_card')
const { options, search: searchCarriers, loading: carrierLoading } = useGatewayCarriers(),
filters = reactive({
asset_type: '',
carrier_id: undefined as number | undefined,
source: '',
status: undefined as number | undefined,
identifier: '',
operator_id: '',
range: [] as string[]
})
const queryFilters = computed(() =>
compactGatewayQuery({
...filters,
range: undefined,
operator_id:
filters.operator_id !== '' && filters.operator_id != null
? Number(filters.operator_id)
: undefined,
...schedulerTime(filters.range)
})
)
const { data, loading, error, load } = useSchedulerRequest(
async () =>
gatewayData(
await Service.getPauses({ ...queryFilters.value, page: page.value, page_size: size.value })
),
() => platform.value && hasAuth(P.page)
)
const searchItems = computed<SearchFormItem[]>(() => [
{
label: '资产类型',
prop: 'asset_type',
type: 'select',
options: [
{ label: '卡', value: 'iot_card' },
{ label: '设备', value: 'device' }
],
config: { clearable: true }
},
{
label: '运营商账户',
prop: 'carrier_id',
type: 'select',
options: options.value,
config: {
remote: true,
filterable: true,
clearable: true,
remoteMethod: searchCarriers,
loading: carrierLoading.value
}
},
{
label: '来源',
prop: 'source',
type: 'select',
options: POLLING_CLOSE_SOURCES,
config: { clearable: true }
},
{
label: '状态',
prop: 'status',
type: 'select',
options: [
{ label: '已关闭', value: 1 },
{ label: '已恢复', value: 2 }
],
config: { clearable: true }
},
{ label: '资产标识', prop: 'identifier', type: 'input', config: { clearable: true } },
{
label: '关闭操作人 ID',
prop: 'operator_id',
type: 'input',
config: { type: 'number', min: 0, clearable: true }
},
{
label: '关闭时间起止',
prop: 'range',
type: 'datetimerange',
config: {
rangeSeparator: '至',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间',
valueFormat: 'YYYY-MM-DDTHH:mm:ssZ',
clearable: true
}
}
])
const fields = [
['identifier', '资产标识'],
['asset_type', '资产类型'],
['carrier_name', '运营商账户'],
['enable_polling', '当前是否轮询'],
['status_name', '记录状态'],
['gateway_extend', '网关扩展状态'],
['close_source_name', '关闭来源'],
['close_reason', '关闭原因'],
['closed_by_name', '关闭操作人'],
['closed_at', '关闭时间'],
['restore_source_name', '恢复来源'],
['restore_reason', '恢复原因'],
['restored_by_name', '恢复操作人'],
['restored_at', '恢复时间'],
['import_task_id', '导入任务 ID']
].map(([prop, label]) => ({ prop, label }))
function value(row: PollingPause, key: string) {
if (key === 'closed_by_name' && row.closed_by === 0) return '系统'
if (key === 'restored_by_name' && row.restored_by === 0 && row.restored_at) return '系统'
return schedulerDisplayValue(row[key as keyof PollingPause], key)
}
function search() {
page.value = 1
selected.value = []
void load()
}
function reset() {
Object.assign(filters, {
asset_type: '',
carrier_id: undefined,
source: '',
status: undefined,
identifier: '',
operator_id: '',
range: []
})
search()
}
function restore() {
if (!platform.value || !hasAuth(P.restore)) return
const types = new Set(selected.value.map((row) => row.asset_type))
if (types.size !== 1) {
ElMessage.warning('请每次选择同一资产类型')
return
}
bulkTarget.value = selected.value[0].asset_type as 'iot_card' | 'device'
bulkVisible.value = true
}
watch(
() => JSON.stringify(filters),
() => {
selected.value = []
bulkVisible.value = false
}
)
onMounted(load)
</script>

View File

@@ -0,0 +1,5 @@
<template><SchedulerDetail kind="imports" /></template>
<script setup lang="ts">
import SchedulerDetail from '@/components/business/polling/SchedulerDetail.vue'
defineOptions({ name: 'PollingStatusImportDetail' })
</script>

View File

@@ -0,0 +1,126 @@
<template>
<ArtTableFullScreen
><div id="table-full-screen"
><ArtSearchBar
:filter="filters"
:items="searchItems"
:show-expand="searchItems.length >= 4"
@search="search"
@reset="
() => {
filters.status = undefined
search()
}
" /><ElCard shadow="never" class="art-table-card"
><ElAlert v-if="error" :title="error" type="error" :closable="false" /><SchedulerTable
:data="data?.items || []"
:definitions="fields"
:loading="loading"
:page="page"
:size="size"
:total="data?.total || 0"
@refresh="load"
@page="
(value) => {
page = value
load()
}
"
@size="
(value) => {
size = value
search()
}
"
><template #toolbar
><ElButton v-if="hasAuth(P.create) && platform" @click="visible = true"
>导入轮询开关</ElButton
></template
><ElTableColumn label="操作" width="100"
><template #default="{ row }"
><ElButton v-if="hasAuth(P.detail)" link type="primary" @click="detail(row.id)"
>详情</ElButton
></template
></ElTableColumn
></SchedulerTable
></ElCard
><PollingImportDialog
v-if="visible && platform"
@close="visible = false"
@created="created" /></div
></ArtTableFullScreen>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { PollingSchedulerService as Service } from '@/api/modules/pollingScheduler'
import { useUserStore } from '@/store/modules/user'
import { useAuth } from '@/composables/useAuth'
import { useSchedulerRequest } from '@/composables/useSchedulerRequest'
import { SCHEDULER_PERMISSIONS } from '@/config/constants/pollingScheduler'
import { gatewayData } from '@/utils/business/gateway'
import { isPollingPlatform } from '@/utils/business/pollingScheduler'
import type { SearchFormItem } from '@/types'
import SchedulerTable from '@/components/business/polling/SchedulerTable.vue'
import PollingImportDialog from '@/components/business/polling/PollingImportDialog.vue'
defineOptions({ name: 'PollingStatusImports' })
const P = SCHEDULER_PERMISSIONS.imports,
{ hasAuth } = useAuth(),
user = useUserStore(),
router = useRouter(),
platform = computed(() => isPollingPlatform(user.info.user_type)),
filters = reactive({ status: undefined as number | undefined }),
page = ref(1),
size = ref(20),
visible = ref(false)
const searchItems: SearchFormItem[] = [
{
label: '任务状态',
prop: 'status',
type: 'select',
options: [
{ label: '待处理', value: 1 },
{ label: '处理中', value: 2 },
{ label: '已完成', value: 3 },
{ label: '失败', value: 4 }
],
config: { clearable: true }
}
]
const { data, loading, error, load } = useSchedulerRequest(
async () =>
gatewayData(
await Service.getImports({ ...filters, page: page.value, page_size: size.value })
),
() => platform.value && hasAuth(P.page)
)
const fields = [
['task_no', '任务编号'],
['file_name', '文件名'],
['asset_type', '资产类型'],
['enable_polling', '目标轮询状态'],
['status_name', '状态'],
['total_count', '总行数'],
['success_count', '成功'],
['skip_count', '跳过'],
['fail_count', '失败'],
['error_message', '任务级错误'],
['reason', '原因'],
['creator_name', '创建人'],
['created_at', '创建时间'],
['completed_at', '完成时间']
].map(([prop, label]) => ({ prop, label }))
function search() {
page.value = 1
void load()
}
function detail(id: number) {
if (platform.value && hasAuth(P.detail))
void router.push(`/polling-management/polling-status-imports/detail/${id}`)
}
function created(id: number) {
void load()
detail(id)
}
onMounted(load)
</script>

View File

@@ -1,6 +1,11 @@
<template>
<ArtTableFullScreen>
<div class="gateway-page" id="table-full-screen">
<ElAlert
:title="`当前限流模式:${mode || '未获取'}(observe 只统计,enforce 实际拦截)`"
type="info"
:closable="false"
/>
<GatewayOptionsNotice :error="optionsError" :loading="optionsLoading" @retry="loadOptions" />
<GatewayOptionsNotice
title="运营商账户加载失败"
@@ -421,7 +426,16 @@
if (!canDetail.value) return
await router.push(`${RoutesAlias.GatewayRateLimitGroupDetail}/${id}`)
}
onMounted(load)
const mode = ref('')
onMounted(async () => {
void load()
try {
if (hasAuth(GATEWAY_PERMISSIONS.stats.page))
mode.value = gatewayData(await GatewayService.getCarrierStats(1)).mode
} catch {
/* 模式获取失败保留未获取,不推测模式 */
}
})
</script>
<style scoped lang="scss">

View File

@@ -0,0 +1,218 @@
<template>
<ArtTableFullScreen
><div id="table-full-screen"
><ElAlert
type="info"
title="只判断、不执行,不调用网关、不修改状态。全量扫描较慢,建议按运营商账户筛选或使用导出。结果筛选仅影响明细,不影响汇总。"
:closable="false"
/><ArtSearchBar
:filter="filters"
:items="searchItems"
:show-expand="searchItems.length >= 4"
label-width="120px"
@search="search"
@reset="reset"
/><ElCard shadow="never" class="art-table-card"
><ElAlert v-if="error" :title="error" type="error" :closable="false" /><template v-if="data"
><ElDescriptions :column="2" border
><ElDescriptionsItem label="参与演练">{{ data.totals.total }}</ElDescriptionsItem
><ElDescriptionsItem label="将停机">{{ data.totals.will_stop }}</ElDescriptionsItem
><ElDescriptionsItem label="将复机">{{ data.totals.will_resume }}</ElDescriptionsItem
><ElDescriptionsItem label="不动作">{{
data.totals.no_action
}}</ElDescriptionsItem></ElDescriptions
><h3>账户 × 当前停机原因 × 判断结果 × 原因汇总</h3
><SchedulerTable
:data="data.summary"
:definitions="summaryFields"
row-key="summary_key"
@refresh="load" /></template
><SchedulerTable
:data="data?.items || []"
:definitions="fields"
:loading="loading"
row-key="card_id"
:page-sizes="[20, 50, 100, 500]"
:total="data?.total || 0"
:page="page"
:size="size"
@refresh="load"
@page="
(v) => {
page = v
load()
}
"
@size="
(v) => {
size = v
search()
}
"
><template #toolbar
><ElButton v-if="allowed && hasAuth(P.export)" :loading="exporting" @click="exportRows"
>导出演练明细</ElButton
></template
></SchedulerTable
></ElCard
></div
></ArtTableFullScreen
>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { PollingSchedulerService as Service } from '@/api/modules/pollingScheduler'
import { useAuth } from '@/composables/useAuth'
import { useUserStore } from '@/store/modules/user'
import { useGatewayCarriers } from '@/composables/useGateway'
import { useSchedulerRequest } from '@/composables/useSchedulerRequest'
import { SCHEDULER_PERMISSIONS } from '@/config/constants/pollingScheduler'
import {
gatewayData,
gatewayError,
requireGatewayAccess,
compactGatewayQuery
} from '@/utils/business/gateway'
import { isPollingPlatform, validateDryAssumptions } from '@/utils/business/pollingScheduler'
import type { SearchFormItem } from '@/types'
import SchedulerTable from '@/components/business/polling/SchedulerTable.vue'
defineOptions({ name: 'StopResumeDryRun' })
const P = SCHEDULER_PERMISSIONS.dryRun,
user = useUserStore(),
{ hasAuth } = useAuth(),
allowed = computed(() => isPollingPlatform(user.info.user_type) && hasAuth(P.page)),
page = ref(1),
size = ref(20),
exporting = ref(false)
const { options, search: searchCarriers, loading: carrierLoading } = useGatewayCarriers(),
filters = reactive({
carrier_id: undefined as number | undefined,
stop_reason: '',
result: '',
assume_control_on: [] as number[],
assume_control_off: [] as number[]
})
function query() {
validateDryAssumptions(filters.assume_control_on, filters.assume_control_off)
return compactGatewayQuery({
...filters,
assume_control_on: filters.assume_control_on.join(','),
assume_control_off: filters.assume_control_off.join(',')
})
}
const { data, loading, error, load } = useSchedulerRequest(
async () => {
const result = gatewayData(
await Service.dryRun({ ...query(), page: page.value, page_size: size.value })
)
return {
...result,
summary: result.summary.map((row, index) => ({ ...row, summary_key: String(index) }))
}
},
() => allowed.value
)
const accountConfig = {
remote: true,
filterable: true,
clearable: true,
remoteMethod: searchCarriers,
loading: carrierLoading.value
}
const searchItems = computed<SearchFormItem[]>(() => [
{
label: '运营商账户',
prop: 'carrier_id',
type: 'select',
options: options.value,
config: { ...accountConfig, loading: carrierLoading.value }
},
{
label: '当前停机原因',
prop: 'stop_reason',
type: 'input',
config: { clearable: true, placeholder: 'none 表示空;不填为全部' }
},
{
label: '明细判断结果',
prop: 'result',
type: 'select',
options: [
{ label: '将复机', value: 'will_resume' },
{ label: '将停机', value: 'will_stop' },
{ label: '不动作', value: 'no_action' }
],
config: { clearable: true }
},
{
label: '假设开启实名管控',
prop: 'assume_control_on',
type: 'select',
options: options.value,
config: { ...accountConfig, loading: carrierLoading.value, multiple: true }
},
{
label: '假设关闭实名管控',
prop: 'assume_control_off',
type: 'select',
options: options.value,
config: { ...accountConfig, loading: carrierLoading.value, multiple: true }
}
])
const fields = [
['iccid', 'ICCID'],
['carrier_name', '运营商账户'],
['card_id', '卡 ID'],
['device_id', '设备 ID'],
['card_category', '卡类别'],
['network_status', '网络状态'],
['realname_ok', '实名条件满足'],
['stop_reason_name', '当前停机原因'],
['result_name', '判断结果'],
['reason_name', '判断原因']
].map(([prop, label]) => ({ prop, label })),
summaryFields = [
['carrier_name', '运营商账户'],
['stop_reason_name', '当前停机原因'],
['result_name', '判断结果'],
['reason_name', '判断原因'],
['count', '卡数']
].map(([prop, label]) => ({ prop, label }))
function search() {
page.value = 1
void load()
}
function reset() {
Object.assign(filters, {
carrier_id: undefined,
stop_reason: '',
result: '',
assume_control_on: [],
assume_control_off: []
})
page.value = 1
}
async function exportRows() {
if (exporting.value) return
try {
requireGatewayAccess(allowed.value && hasAuth(P.export))
exporting.value = true
const params = query()
const result = gatewayData(
await Service.exportDryRun({
carrier_id: params.carrier_id,
stop_reason: params.stop_reason,
assume_control_on: params.assume_control_on,
assume_control_off: params.assume_control_off,
format: 'xlsx'
})
)
ElMessage.success(`已创建导出任务:${result.task_no || result.task_id}`)
} catch (e) {
error.value = gatewayError(e)
} finally {
exporting.value = false
}
}
</script>

View File

@@ -128,13 +128,18 @@
const moduleOptions = [
{ label: '运营商回调配置', value: 'carrier_callback' },
{ label: 'C 端支付方式配置', value: 'c2b.payment' }
{ label: 'C 端支付方式配置', value: 'c2b.payment' },
{ label: '轮询与网关调度', value: 'polling' }
]
const configEnumLabels: Record<string, string> = {
wechat_only: '仅微信支付',
alipay_only: '仅支付宝支付',
both: '同时支持微信与支付宝'
both: '同时支持微信与支付宝',
legacy: '旧分片调度',
carrier: '按运营商账户调度',
observe: '观察(只统计)',
enforce: '强制(拦截)'
}
const formatEnumLabel = (value: string) => configEnumLabels[value] || value

View File

@@ -766,36 +766,40 @@
...getTrafficThresholdPayload(),
...getRealnameControlPayload()
}
if (
canConfigureRealnameControl.value &&
originalRealnameControlEnabled.value === 0 &&
updateData.realname_control_enabled === 1
) {
try {
await ElMessageBox.confirm(
'开启通道实名管控后,该通道未实名的开机卡会在下一次评估时被停机。确认开启吗?',
'确认开启通道实名管控',
{
type: 'warning',
confirmButtonText: '确认开启',
cancelButtonText: '取消',
closeOnClickModal: false
}
)
} catch {
return
}
updateData.realname_control_confirm = true
const submit = async () => {
const response = await CarrierService.updateCarrier(form.id, updateData)
if (response.code !== 0) throw new Error(response.msg || '修改失败')
}
try {
// 先让后端返回真实影响(包括卡数),不能提前传 confirm。
await submit()
} catch (cause: any) {
const message =
cause?.response?.data?.msg || cause?.response?.data?.message || cause?.message || ''
const needsConfirm =
canConfigureRealnameControl.value &&
originalRealnameControlEnabled.value === 0 &&
updateData.realname_control_enabled === 1 &&
/确认|confirm/i.test(message) &&
/实名|realname/i.test(message)
if (!needsConfirm) throw cause
await ElMessageBox.confirm(message, '确认开启通道实名管控', {
type: 'warning',
closeOnClickModal: false
})
if (!canConfigureRealnameControl.value) return
updateData.realname_control_confirm = true
await submit()
}
await CarrierService.updateCarrier(form.id, updateData)
ElMessage.success('修改成功')
}
dialogVisible.value = false
formEl.resetFields()
await getTableData()
} catch (error) {
console.error(error)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close')
ElMessage.error(error?.response?.data?.msg || error?.message || '保存失败')
} finally {
submitLoading.value = false
}