This commit is contained in:
@@ -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' }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -61,3 +61,4 @@ export { AssetWalletService } from './assetWallet'
|
||||
|
||||
export { OperationsReportsService } from './operationsReports'
|
||||
export { GatewayService } from './gateway'
|
||||
export { PollingSchedulerService } from './pollingScheduler'
|
||||
|
||||
@@ -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 查询参数
|
||||
|
||||
109
src/api/modules/pollingScheduler.ts
Normal file
109
src/api/modules/pollingScheduler.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ const triggerBrowserDownload = (downloadUrl: string, fileName: string) => {
|
||||
* 文件用途枚举
|
||||
*/
|
||||
export type FilePurpose =
|
||||
| 'polling_status_import'
|
||||
| 'iot_import'
|
||||
| 'batch_purchase'
|
||||
| 'device_batch_allocation'
|
||||
|
||||
@@ -38,6 +38,9 @@
|
||||
import { ExportTaskService } from '@/api/modules'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { getExportTaskSceneName } from '@/config/constants'
|
||||
import { isPollingPlatformExport } from '@/config/constants/exportTask'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { isPollingPlatform } from '@/utils/business/pollingScheduler'
|
||||
import type { CreateExportTaskResponse, ExportTaskFormat, ExportTaskScene } from '@/types/api'
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -64,6 +67,7 @@
|
||||
}>()
|
||||
|
||||
const { hasAuth } = useAuth()
|
||||
const user = useUserStore()
|
||||
const submitting = ref(false)
|
||||
const form = reactive({
|
||||
format: props.defaultFormat
|
||||
@@ -107,6 +111,10 @@
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
if (isPollingPlatformExport(props.scene) && !isPollingPlatform(user.info.user_type)) {
|
||||
ElMessage.warning('仅平台账号可创建该导出任务')
|
||||
return
|
||||
}
|
||||
if (props.confirmPermission && !hasAuth(props.confirmPermission)) {
|
||||
ElMessage.warning('您没有创建导出任务的权限')
|
||||
return
|
||||
|
||||
58
src/components/business/gateway/GatewayOperationTable.vue
Normal file
58
src/components/business/gateway/GatewayOperationTable.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<SchedulerTable
|
||||
:data="rows"
|
||||
:definitions="fields"
|
||||
:formatter="format"
|
||||
row-key="metric_key"
|
||||
@refresh="$emit('refresh')"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import SchedulerTable from '@/components/business/polling/SchedulerTable.vue'
|
||||
import { gatewayDisplayValue } from '@/utils/business/gateway'
|
||||
import type { GatewayOperationStats } from '@/types/api/gateway'
|
||||
defineOptions({ inheritAttrs: false })
|
||||
const props = defineProps<{
|
||||
values: {
|
||||
carrier_id?: number
|
||||
carrier_name?: string
|
||||
time?: string
|
||||
operations: GatewayOperationStats[]
|
||||
}[]
|
||||
}>()
|
||||
defineEmits<{ refresh: [] }>()
|
||||
const rows = computed(() =>
|
||||
props.values.flatMap((group, index) =>
|
||||
(group.operations || []).map((op) => ({
|
||||
...op,
|
||||
carrier_name: group.carrier_id === 0 ? '无法解析账户的卡' : group.carrier_name,
|
||||
time: group.time,
|
||||
metric_key: `${index}:${op.operation}`
|
||||
}))
|
||||
)
|
||||
)
|
||||
const fields = [
|
||||
['carrier_name', '运营商账户'],
|
||||
['time', '时间桶起点'],
|
||||
['operation_name', '操作'],
|
||||
['attempts', '实际请求'],
|
||||
['success', '成功'],
|
||||
['success_rate', '成功率'],
|
||||
['avg_qps', '平均 QPS'],
|
||||
['peak_minute_qps', '峰值分钟 QPS'],
|
||||
['avg_duration_ms', '平均耗时(ms)'],
|
||||
['p95_duration_ms', 'p95 上界(ms,分桶近似)'],
|
||||
['rate_limited', '拦截次数'],
|
||||
['would_limit', '本应限流次数(观察)'],
|
||||
['failures', '失败分类'],
|
||||
['exempt', '豁免来源']
|
||||
]
|
||||
.filter(([key]) => key !== (props.values[0]?.time ? 'carrier_name' : 'time'))
|
||||
.map(([prop, label]) => ({ prop, label, width: 180 }))
|
||||
function format(row: Record<string, any>, key: string) {
|
||||
if (key === 'success_rate') return row[key] == null ? '-' : `${(row[key] * 100).toFixed(2)}%`
|
||||
if (key === 'p95_duration_ms' && row[key] === -1) return '超过最大分桶'
|
||||
return gatewayDisplayValue(row[key], key)
|
||||
}
|
||||
</script>
|
||||
@@ -19,6 +19,9 @@
|
||||
/></ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="上游可能已受理"><ElSwitch v-model="model.maybe_accepted" /></ElFormItem>
|
||||
<ElFormItem v-if="model.category === 'terminal'" label="自动关闭轮询"
|
||||
><ElSwitch v-model="model.auto_disable_polling" /><span>仅终态类别生效</span></ElFormItem
|
||||
>
|
||||
<ElFormItem label="匹配优先级"
|
||||
><ElInputNumber v-model="model.priority" :min="1" :max="10000" :precision="0" /><span
|
||||
>数值越小越先匹配</span
|
||||
@@ -35,7 +38,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue'
|
||||
import type { GatewayRuleInput, GatewayOptions } from '@/types/api/gateway'
|
||||
const model = defineModel<GatewayRuleInput>({ required: true })
|
||||
watch(
|
||||
() => model.value.category,
|
||||
(value) => {
|
||||
if (value !== 'terminal' && model.value.auto_disable_polling)
|
||||
model.value.auto_disable_polling = false
|
||||
}
|
||||
)
|
||||
defineProps<{ options: GatewayOptions; disabled?: boolean }>()
|
||||
</script>
|
||||
|
||||
30
src/components/business/polling/NullableNumber.vue
Normal file
30
src/components/business/polling/NullableNumber.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div class="nullable-number">
|
||||
<ElSwitch
|
||||
:model-value="model != null"
|
||||
active-text="指定"
|
||||
:inactive-text="emptyLabel || '使用默认'"
|
||||
@update:model-value="(value) => (model = value ? (min ?? 0) : null)"
|
||||
/>
|
||||
<ElInputNumber
|
||||
v-if="model != null"
|
||||
v-model="model"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:precision="precision ?? 0"
|
||||
:controls="false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
const model = defineModel<number | null>({ required: true })
|
||||
defineProps<{ min?: number; max?: number; precision?: number; emptyLabel?: string }>()
|
||||
</script>
|
||||
<style scoped>
|
||||
.nullable-number {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
174
src/components/business/polling/PollingBatchDialog.vue
Normal file
174
src/components/business/polling/PollingBatchDialog.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<ElDialog
|
||||
:model-value="true"
|
||||
:title="target === 'alert' ? '关闭告警对应卡的轮询' : '批量开关轮询'"
|
||||
width="min(900px, 96vw)"
|
||||
:close-on-click-modal="!saving"
|
||||
:show-close="!saving"
|
||||
@update:model-value="
|
||||
(value) => {
|
||||
if (!value) emit('close')
|
||||
}
|
||||
"
|
||||
>
|
||||
<template v-if="!result">
|
||||
<ElForm label-width="100px" :disabled="saving">
|
||||
<ElFormItem label="目标数量">{{ ids.length }} 项(最多 500 项)</ElFormItem>
|
||||
<ElFormItem label="目标状态"
|
||||
><ElSwitch
|
||||
v-model="enabled"
|
||||
:disabled="target === 'alert' || fixedEnabled != null"
|
||||
active-text="开启轮询"
|
||||
inactive-text="关闭轮询"
|
||||
/></ElFormItem>
|
||||
<ElFormItem label="原因" required
|
||||
><ElInput v-model="reason" type="textarea" :rows="3"
|
||||
/></ElFormItem>
|
||||
</ElForm>
|
||||
<ElAlert type="warning" title="本次操作的影响(来自接口)" :closable="false"
|
||||
><ul
|
||||
><li v-for="line in impactLines" :key="line">{{ line }}</li></ul
|
||||
></ElAlert
|
||||
>
|
||||
<ElButton v-if="!impact" :loading="impactLoading" @click="loadImpact">加载影响说明</ElButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<ElAlert
|
||||
:type="result.fail_count ? 'warning' : 'success'"
|
||||
:closable="false"
|
||||
:title="`成功 ${result.success_count},跳过 ${result.skip_count},失败 ${result.fail_count}。部分成功不回滚,请逐项核对。`"
|
||||
/>
|
||||
<ArtTable :data="resultRows" row-key="result_key" :pagination="false" max-height="460"
|
||||
><ElTableColumn prop="identifier" label="资产标识" min-width="210" /><ElTableColumn
|
||||
prop="asset_id"
|
||||
label="资产 ID"
|
||||
width="110" /><ElTableColumn label="结果" min-width="150"
|
||||
><template #default="{ row }">{{
|
||||
row.status_name ||
|
||||
{ 1: '成功', 2: '跳过', 3: '失败', 4: '无权限或不存在' }[row.status as number]
|
||||
}}</template></ElTableColumn
|
||||
><ElTableColumn prop="reason" label="原因" min-width="250"
|
||||
/></ArtTable>
|
||||
<ul
|
||||
><li v-for="line in result.impact" :key="line">{{ line }}</li></ul
|
||||
>
|
||||
</template>
|
||||
<ElAlert v-if="error" type="error" :title="error" :closable="false" />
|
||||
<template #footer
|
||||
><ElButton :disabled="saving" @click="emit('close')">关闭</ElButton
|
||||
><ElButton
|
||||
v-if="!result"
|
||||
type="primary"
|
||||
:disabled="!impact || impactLoading"
|
||||
:loading="saving"
|
||||
@click="submit"
|
||||
>确认提交</ElButton
|
||||
></template
|
||||
>
|
||||
</ElDialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, onBeforeUnmount, onDeactivated } from 'vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { PollingSchedulerService as Service } from '@/api/modules/pollingScheduler'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { gatewayData, gatewayError, requireGatewayAccess } from '@/utils/business/gateway'
|
||||
import {
|
||||
pollingScope,
|
||||
isPollingPlatform,
|
||||
canPollingBatch,
|
||||
validatePollingIds,
|
||||
pollingReason
|
||||
} from '@/utils/business/pollingScheduler'
|
||||
import type { PollingImpact, PollingBatchResult } from '@/types/api/pollingScheduler'
|
||||
const props = defineProps<{
|
||||
target: 'iot_card' | 'device' | 'alert'
|
||||
ids: number[]
|
||||
permission: string
|
||||
fixedEnabled?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{ close: []; done: [] }>()
|
||||
const user = useUserStore()
|
||||
const { hasAuth } = useAuth()
|
||||
const scope = pollingScope(user)
|
||||
const allowed = () =>
|
||||
scope === pollingScope(user) &&
|
||||
hasAuth(props.permission) &&
|
||||
(props.target === 'alert'
|
||||
? isPollingPlatform(user.info.user_type)
|
||||
: canPollingBatch(user.info.user_type))
|
||||
const enabled = ref(props.target === 'alert' ? false : (props.fixedEnabled ?? true))
|
||||
const reason = ref('')
|
||||
const impact = ref<PollingImpact | null>(null)
|
||||
const impactLoading = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const result = ref<PollingBatchResult | null>(null)
|
||||
const impactLines = computed(() => impact.value?.[enabled.value ? 'enable' : 'disable'] || [])
|
||||
const resultRows = computed(
|
||||
() =>
|
||||
result.value?.items.map((row, index) => ({
|
||||
...row,
|
||||
result_key: `${row.asset_id}:${index}`
|
||||
})) || []
|
||||
)
|
||||
let active = true
|
||||
async function loadImpact() {
|
||||
impactLoading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
requireGatewayAccess(allowed())
|
||||
const data = gatewayData(await Service.getImpact())
|
||||
if (active && allowed()) {
|
||||
if (!Array.isArray(data.enable) || !Array.isArray(data.disable))
|
||||
throw new Error('影响说明格式不完整')
|
||||
impact.value = data
|
||||
}
|
||||
} catch (cause) {
|
||||
if (active) error.value = gatewayError(cause)
|
||||
} finally {
|
||||
if (active) impactLoading.value = false
|
||||
}
|
||||
}
|
||||
async function submit() {
|
||||
if (saving.value || result.value) return
|
||||
error.value = ''
|
||||
try {
|
||||
requireGatewayAccess(allowed())
|
||||
validatePollingIds(props.ids)
|
||||
const why = pollingReason(reason.value)
|
||||
if (!impact.value || !impactLines.value.length) throw new Error('请先加载操作影响说明')
|
||||
const ids = [...props.ids],
|
||||
targetEnabled = enabled.value
|
||||
saving.value = true
|
||||
await ElMessageBox.confirm(impactLines.value.join('\n'), '确认轮询开关影响', {
|
||||
type: 'warning'
|
||||
})
|
||||
requireGatewayAccess(active && allowed())
|
||||
const data = gatewayData(
|
||||
await (props.target === 'alert'
|
||||
? Service.closeAlertPolling(ids, why)
|
||||
: props.target === 'device'
|
||||
? Service.toggleDevices({ asset_ids: ids, enable_polling: targetEnabled, reason: why })
|
||||
: Service.toggleCards({ asset_ids: ids, enable_polling: targetEnabled, reason: why }))
|
||||
)
|
||||
if (active && allowed()) {
|
||||
result.value = data
|
||||
emit('done')
|
||||
}
|
||||
} catch (cause) {
|
||||
if (active && cause !== 'cancel' && cause !== 'close') error.value = gatewayError(cause)
|
||||
} finally {
|
||||
if (active) saving.value = false
|
||||
}
|
||||
}
|
||||
onMounted(loadImpact)
|
||||
onDeactivated(() => {
|
||||
active = false
|
||||
emit('close')
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
active = false
|
||||
})
|
||||
</script>
|
||||
125
src/components/business/polling/PollingImportDialog.vue
Normal file
125
src/components/business/polling/PollingImportDialog.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<ElDialog
|
||||
:model-value="true"
|
||||
title="轮询开关 CSV 导入"
|
||||
width="min(700px,96vw)"
|
||||
:show-close="!saving"
|
||||
:close-on-click-modal="!saving"
|
||||
@update:model-value="
|
||||
(value) => {
|
||||
if (!value) emit('close')
|
||||
}
|
||||
"
|
||||
><ElAlert
|
||||
type="info"
|
||||
title="CSV 首行为表头、首列为 ICCID(19/20 位)或设备 IMEI/虚拟号;空行忽略,最多 50000 行,标识符保持文本。"
|
||||
:closable="false"
|
||||
/><ElForm label-width="110px" :disabled="saving"
|
||||
><ElFormItem label="资产类型"
|
||||
><ElSelect v-model="assetType"
|
||||
><ElOption label="卡(ICCID)" value="iot_card" /><ElOption
|
||||
label="设备"
|
||||
value="device" /></ElSelect></ElFormItem
|
||||
><ElFormItem label="目标状态"
|
||||
><ElSwitch v-model="enabled" active-text="开启轮询" inactive-text="关闭轮询" /></ElFormItem
|
||||
><ElFormItem label="原因" required><ElInput v-model="reason" type="textarea" /></ElFormItem
|
||||
><ElFormItem label="CSV 文件" required
|
||||
><input
|
||||
type="file"
|
||||
accept=".csv"
|
||||
:disabled="saving"
|
||||
@change="selectFile" /></ElFormItem></ElForm
|
||||
><ElAlert v-if="error" :title="error" type="error" :closable="false" /><template #footer
|
||||
><ElButton :disabled="saving" @click="emit('close')">取消</ElButton
|
||||
><ElButton type="primary" :loading="saving" @click="submit"
|
||||
>上传并创建任务</ElButton
|
||||
></template
|
||||
></ElDialog
|
||||
>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, onBeforeUnmount, onDeactivated } from 'vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { StorageService } from '@/api/modules/storage'
|
||||
import { PollingSchedulerService as Service } from '@/api/modules/pollingScheduler'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { SCHEDULER_PERMISSIONS } from '@/config/constants/pollingScheduler'
|
||||
import { gatewayData, gatewayError, requireGatewayAccess } from '@/utils/business/gateway'
|
||||
import {
|
||||
pollingScope,
|
||||
isPollingPlatform,
|
||||
pollingReason,
|
||||
validatePollingCsv
|
||||
} from '@/utils/business/pollingScheduler'
|
||||
const emit = defineEmits<{ close: []; created: [id: number] }>(),
|
||||
user = useUserStore(),
|
||||
{ hasAuth } = useAuth(),
|
||||
assetType = ref<'iot_card' | 'device'>('iot_card'),
|
||||
enabled = ref(false),
|
||||
reason = ref(''),
|
||||
file = ref<File>(),
|
||||
saving = ref(false),
|
||||
error = ref('')
|
||||
let active = true
|
||||
const scope = pollingScope(user)
|
||||
const allowed = () =>
|
||||
active &&
|
||||
scope === pollingScope(user) &&
|
||||
isPollingPlatform(user.info.user_type) &&
|
||||
hasAuth(SCHEDULER_PERMISSIONS.imports.create)
|
||||
function selectFile(event: Event) {
|
||||
file.value = (event.target as HTMLInputElement).files?.[0]
|
||||
}
|
||||
async function submit() {
|
||||
if (saving.value) return
|
||||
error.value = ''
|
||||
try {
|
||||
requireGatewayAccess(allowed())
|
||||
const why = pollingReason(reason.value),
|
||||
selected = file.value
|
||||
if (!selected) throw new Error('请选择 CSV 文件')
|
||||
saving.value = true
|
||||
validatePollingCsv(selected.name, await selected.text())
|
||||
const impact = gatewayData(await Service.getImpact()),
|
||||
lines = impact[enabled.value ? 'enable' : 'disable']
|
||||
if (!Array.isArray(lines) || !lines.length) throw new Error('未获取到有效影响说明')
|
||||
await ElMessageBox.confirm(lines.join('\n'), '确认导入影响', { type: 'warning' })
|
||||
requireGatewayAccess(allowed())
|
||||
const upload = gatewayData(
|
||||
await StorageService.getUploadUrl({
|
||||
file_name: selected.name,
|
||||
content_type: 'text/csv',
|
||||
purpose: 'polling_status_import'
|
||||
})
|
||||
)
|
||||
if (!upload.file_key.startsWith('polling-status-imports/'))
|
||||
throw new Error('上传文件 Key 前缀错误')
|
||||
await StorageService.uploadFile(upload.upload_url, selected, 'text/csv')
|
||||
requireGatewayAccess(allowed())
|
||||
const task = gatewayData(
|
||||
await Service.createImport({
|
||||
file_key: upload.file_key,
|
||||
asset_type: assetType.value,
|
||||
enable_polling: enabled.value,
|
||||
reason: why
|
||||
})
|
||||
)
|
||||
if (allowed()) {
|
||||
emit('created', task.id)
|
||||
emit('close')
|
||||
}
|
||||
} catch (e) {
|
||||
if (active && e !== 'cancel' && e !== 'close') error.value = gatewayError(e)
|
||||
} finally {
|
||||
if (active) saving.value = false
|
||||
}
|
||||
}
|
||||
onDeactivated(() => {
|
||||
active = false
|
||||
emit('close')
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
active = false
|
||||
})
|
||||
</script>
|
||||
147
src/components/business/polling/PollingStateDialog.vue
Normal file
147
src/components/business/polling/PollingStateDialog.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<ElDialog
|
||||
:model-value="true"
|
||||
title="单卡轮询状态"
|
||||
width="min(1100px, 96vw)"
|
||||
@update:model-value="
|
||||
(value) => {
|
||||
if (!value) emit('close')
|
||||
}
|
||||
"
|
||||
>
|
||||
<ElButton :loading="loading" @click="load">刷新状态</ElButton>
|
||||
<ElAlert v-if="error" type="error" :title="error" :closable="false" />
|
||||
<template v-if="state">
|
||||
<ElDescriptions :column="width < 640 ? 1 : 2" border class="state-overview"
|
||||
><ElDescriptionsItem v-for="item in fields" :key="item[0]" :label="item[1]">{{
|
||||
gatewayDisplayValue(state[item[0] as keyof typeof state], item[0])
|
||||
}}</ElDescriptionsItem></ElDescriptions
|
||||
>
|
||||
<h3>各检查类型状态</h3
|
||||
><ArtTable :data="state.types" row-key="check_type" :pagination="false"
|
||||
><ElTableColumn v-for="item in typeFields" :key="item[0]" :label="item[1]" min-width="170"
|
||||
><template #default="{ row }">{{
|
||||
gatewayDisplayValue(row[item[0]], item[0])
|
||||
}}</template></ElTableColumn
|
||||
></ArtTable
|
||||
>
|
||||
<h3>停复机指令</h3
|
||||
><ElDescriptions v-if="state.stop_resume_command" :column="width < 640 ? 1 : 2" border
|
||||
><ElDescriptionsItem v-for="item in commandFields" :key="item[0]" :label="item[1]">{{
|
||||
gatewayDisplayValue(commandValue(item[0]), item[0])
|
||||
}}</ElDescriptionsItem></ElDescriptions
|
||||
><ElEmpty v-else description="当前没有停复机指令" />
|
||||
</template>
|
||||
</ElDialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, onDeactivated, watch } from 'vue'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import {
|
||||
pollingScope,
|
||||
schedulerDisplayValue as gatewayDisplayValue
|
||||
} from '@/utils/business/pollingScheduler'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import { PollingSchedulerService } from '@/api/modules/pollingScheduler'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import {
|
||||
gatewayData,
|
||||
gatewayError,
|
||||
requireGatewayAccess,
|
||||
validateGatewayInteger
|
||||
} from '@/utils/business/gateway'
|
||||
import type { CardPollingState } from '@/types/api/pollingScheduler'
|
||||
const props = defineProps<{ cardId: number; permission: string }>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
const user = useUserStore()
|
||||
watch(
|
||||
() => pollingScope(user),
|
||||
() => {
|
||||
++sequence
|
||||
state.value = null
|
||||
emit('close')
|
||||
},
|
||||
{ flush: 'sync' }
|
||||
)
|
||||
const { width } = useWindowSize(),
|
||||
{ hasAuth } = useAuth()
|
||||
const loading = ref(false),
|
||||
error = ref(''),
|
||||
state = ref<CardPollingState | null>(null)
|
||||
const fields = [
|
||||
['iccid', 'ICCID'],
|
||||
['carrier_id', '运营商账户 ID'],
|
||||
['scheduler_mode', '调度模式'],
|
||||
['enable_polling', '是否轮询'],
|
||||
['queue', '所在队列'],
|
||||
['queue_score_at', '下次可领取时间'],
|
||||
['running', '执行中'],
|
||||
['claim_expire_at', '处理中标记到期'],
|
||||
['priority_mark', '优先标记'],
|
||||
['stop_resume_queue_enabled', '停复机出口队列'],
|
||||
['last_stop_resume_call_at', '最近停复机网关调用']
|
||||
]
|
||||
const typeFields = [
|
||||
['check_type_name', '检查类型'],
|
||||
['next_due_at', '下次到期'],
|
||||
['last_success_at', '最近成功'],
|
||||
['fail_count', '连续失败'],
|
||||
['first_failed_at', '首次失败'],
|
||||
['last_error', '最近错误'],
|
||||
['pending_confirm', '待确认延迟(秒)']
|
||||
]
|
||||
const commandFields = [
|
||||
['action', '动作'],
|
||||
['reason', '原因'],
|
||||
['scenes', '场景'],
|
||||
['source', '来源'],
|
||||
['version', '版本'],
|
||||
['fail_count', '连续失败'],
|
||||
['last_error', '最近错误'],
|
||||
['pending', '待确认'],
|
||||
['unknown', '连续未知'],
|
||||
['written_at', '写入时间'],
|
||||
['next_at', '下次可执行时间']
|
||||
]
|
||||
let sequence = 0
|
||||
const commandValue = (key: string) =>
|
||||
state.value?.stop_resume_command?.[
|
||||
key as keyof NonNullable<CardPollingState['stop_resume_command']>
|
||||
]
|
||||
async function load() {
|
||||
const scope = pollingScope(user)
|
||||
const current = ++sequence
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
state.value = null
|
||||
try {
|
||||
requireGatewayAccess(hasAuth(props.permission))
|
||||
validateGatewayInteger(props.cardId, 1, Number.MAX_SAFE_INTEGER, '卡 ID')
|
||||
const data = gatewayData(await PollingSchedulerService.getCardState(props.cardId))
|
||||
if (current === sequence && scope === pollingScope(user) && hasAuth(props.permission))
|
||||
state.value = data
|
||||
} catch (cause) {
|
||||
if (current === sequence) error.value = gatewayError(cause)
|
||||
} finally {
|
||||
if (current === sequence) loading.value = false
|
||||
}
|
||||
}
|
||||
onMounted(load)
|
||||
onDeactivated(() => {
|
||||
++sequence
|
||||
emit('close')
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
++sequence
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.state-overview {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 20px 0 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
196
src/components/business/polling/SchedulerDetail.vue
Normal file
196
src/components/business/polling/SchedulerDetail.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<div class="scheduler-detail"
|
||||
><ElCard shadow="never"
|
||||
><div class="detail-header"
|
||||
><ElButton @click="router.back()">返回</ElButton><h2>{{ title }}</h2
|
||||
><ElButton :loading="loading" @click="load">刷新</ElButton></div
|
||||
><ElAlert v-if="error" :title="error" type="error" :closable="false" /><div
|
||||
v-loading="loading"
|
||||
><template v-if="data"
|
||||
><ElDescriptions :column="width < 640 ? 1 : 2" border
|
||||
><ElDescriptionsItem v-for="item in fields" :key="item[0]" :label="item[1]">{{
|
||||
gatewayDisplayValue(data[item[0]], item[0])
|
||||
}}</ElDescriptionsItem></ElDescriptions
|
||||
><template v-if="kind === 'imports'"
|
||||
><p>逐行独立处理,部分成功不回滚。</p
|
||||
><ArtTable :data="data.items || []" :pagination="false" row-key="line" max-height="500"
|
||||
><ElTableColumn prop="line" label="数据行号" width="100" /><ElTableColumn
|
||||
prop="identifier"
|
||||
label="资产标识"
|
||||
min-width="230" /><ElTableColumn
|
||||
prop="asset_id"
|
||||
label="资产 ID"
|
||||
width="110" /><ElTableColumn
|
||||
prop="status_name"
|
||||
label="结果"
|
||||
width="140" /><ElTableColumn prop="reason" label="原因" min-width="250" /></ArtTable
|
||||
><ElSwitch v-model="auto" active-text="自动刷新任务(终态停止)" /></template
|
||||
><template v-if="kind === 'alerts' && data.card_id > 0"
|
||||
><ElButton v-if="hasAuth(GATEWAY_PERMISSIONS.cardHistory)" @click="history = !history"
|
||||
>最近停复机记录</ElButton
|
||||
><ElButton v-if="hasAuth(SCHEDULER_PERMISSIONS.cardState)" @click="stateVisible = true"
|
||||
>轮询状态</ElButton
|
||||
><StopResumeRecords
|
||||
v-if="history"
|
||||
:card-id="data.card_id"
|
||||
:permission="GATEWAY_PERMISSIONS.cardHistory" /><PollingStateDialog
|
||||
v-if="stateVisible"
|
||||
:card-id="data.card_id"
|
||||
:permission="SCHEDULER_PERMISSIONS.cardState"
|
||||
@close="stateVisible = false" /></template></template></div></ElCard
|
||||
></div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import { PollingSchedulerService as Service } from '@/api/modules/pollingScheduler'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { useSchedulerRequest } from '@/composables/useSchedulerRequest'
|
||||
import { SCHEDULER_PERMISSIONS } from '@/config/constants/pollingScheduler'
|
||||
import { GATEWAY_PERMISSIONS } from '@/config/constants/gateway'
|
||||
import { gatewayData, validateGatewayInteger } from '@/utils/business/gateway'
|
||||
import {
|
||||
isPollingPlatform,
|
||||
schedulerDisplayValue as gatewayDisplayValue
|
||||
} from '@/utils/business/pollingScheduler'
|
||||
import StopResumeRecords from '@/components/business/gateway/StopResumeRecords.vue'
|
||||
import PollingStateDialog from './PollingStateDialog.vue'
|
||||
const props = defineProps<{ kind: 'params' | 'alerts' | 'imports' }>(),
|
||||
route = useRoute(),
|
||||
router = useRouter(),
|
||||
{ width } = useWindowSize(),
|
||||
{ hasAuth } = useAuth(),
|
||||
user = useUserStore()
|
||||
const title = computed(
|
||||
() =>
|
||||
({ params: '运营商账户调度参数详情', alerts: '失败告警详情', imports: '轮询开关导入详情' })[
|
||||
props.kind
|
||||
]
|
||||
)
|
||||
const permission = () => SCHEDULER_PERMISSIONS[props.kind].detailPage
|
||||
const { data, loading, error, load } = useSchedulerRequest<Record<string, any>>(
|
||||
async () => {
|
||||
const id = Number(route.params.id ?? route.params.carrier_id)
|
||||
validateGatewayInteger(id, 1, Number.MAX_SAFE_INTEGER, '记录 ID')
|
||||
if (props.kind === 'params') return gatewayData(await Service.getCarrierParam(id))
|
||||
if (props.kind === 'alerts') return gatewayData(await Service.getAlert(id))
|
||||
return gatewayData(await Service.getImport(id))
|
||||
},
|
||||
() =>
|
||||
hasAuth(permission()) && (props.kind !== 'imports' || isPollingPlatform(user.info.user_type))
|
||||
)
|
||||
const maps = {
|
||||
params: [
|
||||
['carrier_name', '运营商账户'],
|
||||
['is_default', '全部使用全局默认'],
|
||||
['configured_inflight_limit', '配置执行上限'],
|
||||
['inflight_limit', '生效执行上限'],
|
||||
['configured_card_max_in_flight', '配置每进程并发'],
|
||||
['card_max_in_flight', '生效每进程并发'],
|
||||
['consume_capacity', '订阅进程容量'],
|
||||
['priority_max_ratio', '优先队列占比'],
|
||||
['sr_inflight_limit', '停复机执行上限'],
|
||||
['stop_resume_max_in_flight', '停复机每进程并发'],
|
||||
['stop_resume_min_interval', '单卡最小间隔(秒)'],
|
||||
['stop_resume_confirm_delay', '未知确认延迟(秒)']
|
||||
],
|
||||
alerts: [
|
||||
['id', '告警 ID'],
|
||||
['carrier_name', '运营商账户'],
|
||||
['alert_level', '告警级别'],
|
||||
['iccid', 'ICCID'],
|
||||
['card_id', '卡 ID'],
|
||||
['check_type_name', '检查类型'],
|
||||
['status_name', '状态'],
|
||||
['error_category', '错误类别'],
|
||||
['last_error', '上游返回/最近错误'],
|
||||
['fail_count', '连续失败/窗口告警数'],
|
||||
['first_failed_at', '首次失败'],
|
||||
['last_failed_at', '最近失败'],
|
||||
['created_at', '创建时间'],
|
||||
['recovered_at', '恢复时间'],
|
||||
['handle_remark', '处理备注'],
|
||||
['handled_by_name', '处理人'],
|
||||
['handled_at', '处理时间'],
|
||||
['network_status', '网络状态'],
|
||||
['stop_reason', '停机原因'],
|
||||
['has_package', '有生效套餐'],
|
||||
['enable_polling', '是否轮询']
|
||||
],
|
||||
imports: [
|
||||
['task_no', '任务编号'],
|
||||
['asset_type', '资产类型'],
|
||||
['file_name', '文件名'],
|
||||
['enable_polling', '目标轮询状态'],
|
||||
['reason', '原因'],
|
||||
['status_name', '任务状态'],
|
||||
['creator_name', '创建人'],
|
||||
['total_count', '总行数'],
|
||||
['success_count', '成功'],
|
||||
['skip_count', '跳过'],
|
||||
['fail_count', '失败'],
|
||||
['error_message', '任务级错误'],
|
||||
['created_at', '创建时间'],
|
||||
['started_at', '开始时间'],
|
||||
['completed_at', '完成时间']
|
||||
]
|
||||
}
|
||||
const fields = computed(() => maps[props.kind]),
|
||||
history = ref(false),
|
||||
stateVisible = ref(false),
|
||||
auto = ref(false)
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
function stop() {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = undefined
|
||||
}
|
||||
watch([data, auto], () => {
|
||||
stop()
|
||||
if (props.kind === 'imports' && auto.value && [1, 2].includes(data.value?.status))
|
||||
timer = setTimeout(() => {
|
||||
void load()
|
||||
}, 3000)
|
||||
})
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
stop()
|
||||
history.value = false
|
||||
stateVisible.value = false
|
||||
void load()
|
||||
}
|
||||
)
|
||||
onMounted(load)
|
||||
onBeforeUnmount(stop)
|
||||
</script>
|
||||
<style scoped>
|
||||
.scheduler-detail {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.detail-header h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.el-descriptions__cell) {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
.scheduler-detail {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
57
src/components/business/polling/SchedulerTable.vue
Normal file
57
src/components/business/polling/SchedulerTable.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<ArtTableHeader v-model:columns="columnChecks" @refresh="$emit('refresh')"
|
||||
><template #left><slot name="toolbar" /></template
|
||||
></ArtTableHeader>
|
||||
<ArtTable
|
||||
:data="data"
|
||||
:loading="loading"
|
||||
:row-key="rowKey || 'id'"
|
||||
:pagination="total != null"
|
||||
:current-page="page || 1"
|
||||
:page-size="size || 20"
|
||||
:total="total || 0"
|
||||
:page-sizes="pageSizes || [20, 50, 100]"
|
||||
:margin-top="10"
|
||||
@current-change="(value) => $emit('page', value)"
|
||||
@size-change="(value) => $emit('size', value)"
|
||||
@selection-change="(value) => $emit('selection', value)"
|
||||
>
|
||||
<ElTableColumn v-if="selectable" type="selection" width="55" :selectable="selectableRow" />
|
||||
<ElTableColumn
|
||||
v-for="col in columns"
|
||||
:key="col.prop"
|
||||
:prop="col.prop"
|
||||
:label="col.label"
|
||||
:min-width="col.width || 160"
|
||||
show-overflow-tooltip
|
||||
><template #default="{ row }">{{
|
||||
formatter ? formatter(row, col.prop) : schedulerDisplayValue(row[col.prop], col.prop)
|
||||
}}</template></ElTableColumn
|
||||
>
|
||||
<slot />
|
||||
</ArtTable>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useGatewayColumns } from '@/composables/useGateway'
|
||||
import { schedulerDisplayValue } from '@/utils/business/pollingScheduler'
|
||||
const props = defineProps<{
|
||||
data: Record<string, any>[]
|
||||
definitions: { prop: string; label: string; width?: number }[]
|
||||
loading?: boolean
|
||||
rowKey?: string
|
||||
selectable?: boolean
|
||||
selectableRow?: (row: any) => boolean
|
||||
page?: number
|
||||
size?: number
|
||||
total?: number
|
||||
pageSizes?: number[]
|
||||
formatter?: (row: any, key: string) => unknown
|
||||
}>()
|
||||
defineEmits<{
|
||||
refresh: []
|
||||
page: [value: number]
|
||||
size: [value: number]
|
||||
selection: [rows: any[]]
|
||||
}>()
|
||||
const { columns, columnChecks } = useGatewayColumns(props.definitions)
|
||||
</script>
|
||||
125
src/composables/usePollingConfigExtensions.ts
Normal file
125
src/composables/usePollingConfigExtensions.ts
Normal 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 }
|
||||
}
|
||||
56
src/composables/useSchedulerRequest.ts
Normal file
56
src/composables/useSchedulerRequest.ts
Normal 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 }
|
||||
}
|
||||
@@ -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: '设备管理',
|
||||
|
||||
43
src/config/constants/pollingConfig.ts
Normal file
43
src/config/constants/pollingConfig.ts
Normal 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))
|
||||
53
src/config/constants/pollingScheduler.ts
Normal file
53
src/config/constants/pollingScheduler.ts
Normal 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 }))
|
||||
@@ -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: ''
|
||||
},
|
||||
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',
|
||||
|
||||
@@ -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', // 优先队列
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 已实名
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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[] }[]
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
87
src/types/api/pollingConfigExtensions.ts
Normal file
87
src/types/api/pollingConfigExtensions.ts
Normal 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
|
||||
}
|
||||
284
src/types/api/pollingScheduler.ts
Normal file
284
src/types/api/pollingScheduler.ts
Normal 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 }
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
7
src/types/components.d.ts
vendored
7
src/types/components.d.ts
vendored
@@ -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']
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
146
src/utils/business/pollingConfig.ts
Normal file
146
src/utils/business/pollingConfig.ts
Normal 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]
|
||||
}))
|
||||
)
|
||||
}
|
||||
133
src/utils/business/pollingScheduler.ts
Normal file
133
src/utils/business/pollingScheduler.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 刷新按钮状态 ==========
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<template><ExportTaskList /></template>
|
||||
<script setup lang="ts">
|
||||
import ExportTaskList from '../export-task-list/index.vue'
|
||||
defineOptions({ name: 'ExportPollingPause' })
|
||||
</script>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template><ExportTaskList /></template>
|
||||
<script setup lang="ts">
|
||||
import ExportTaskList from '../export-task-list/index.vue'
|
||||
defineOptions({ name: 'ExportPollingStopRefusedAlert' })
|
||||
</script>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template><ExportTaskList /></template>
|
||||
<script setup lang="ts">
|
||||
import ExportTaskList from '../export-task-list/index.vue'
|
||||
defineOptions({ name: 'ExportStopResumeDryRun' })
|
||||
</script>
|
||||
@@ -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(
|
||||
() =>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
5
src/views/polling-management/carrier-params/detail.vue
Normal file
5
src/views/polling-management/carrier-params/detail.vue
Normal 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>
|
||||
223
src/views/polling-management/carrier-params/index.vue
Normal file
223
src/views/polling-management/carrier-params/index.vue
Normal 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>
|
||||
61
src/views/polling-management/carrier-queues/index.vue
Normal file
61
src/views/polling-management/carrier-queues/index.vue
Normal 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>
|
||||
114
src/views/polling-management/check-scenes/index.vue
Normal file
114
src/views/polling-management/check-scenes/index.vue
Normal 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>
|
||||
@@ -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('修改失败')
|
||||
}
|
||||
})
|
||||
|
||||
348
src/views/polling-management/config-stats/index.vue
Normal file
348
src/views/polling-management/config-stats/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 || '',
|
||||
|
||||
5
src/views/polling-management/failure-alerts/detail.vue
Normal file
5
src/views/polling-management/failure-alerts/detail.vue
Normal 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>
|
||||
299
src/views/polling-management/failure-alerts/index.vue
Normal file
299
src/views/polling-management/failure-alerts/index.vue
Normal 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>
|
||||
@@ -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' })
|
||||
|
||||
238
src/views/polling-management/polling-pauses/index.vue
Normal file
238
src/views/polling-management/polling-pauses/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
126
src/views/polling-management/polling-status-imports/index.vue
Normal file
126
src/views/polling-management/polling-status-imports/index.vue
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
218
src/views/polling-management/stop-resume-dry-run/index.vue
Normal file
218
src/views/polling-management/stop-resume-dry-run/index.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user