feat: 新增网关限流
All checks were successful
构建并部署前端到测试环境 / deploy (push) Successful in 56s

This commit is contained in:
luo
2026-10-24 16:22:28 +08:00
parent ecce4f6e7b
commit 049f8257fb
32 changed files with 3569 additions and 4 deletions

View File

@@ -0,0 +1,65 @@
<template>
<div>
<ArtTableHeader v-model:columns="columnChecks" @refresh="$emit('refresh')" />
<ArtTable
:data="rows"
:pagination="false"
empty-text="暂无统计数据"
max-height="460"
:index="false"
>
<ElTableColumn
v-for="key in visibleKeys"
:key="key"
:label="key"
min-width="180"
show-overflow-tooltip
>
<template #default="{ row }">{{ gatewayDisplayValue(row[key], key) }}</template>
</ElTableColumn>
</ArtTable>
<ElCollapse>
<ElCollapseItem title="查看完整响应数据(时间已格式化)" name="raw">
<pre class="gateway-data">{{ responseText }}</pre>
</ElCollapseItem>
</ElCollapse>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { gatewayRows, gatewayDisplayValue } from '@/utils/business/gateway'
defineEmits<{ refresh: [] }>()
const props = defineProps<{ values: unknown[]; data: unknown }>()
const rows = computed(() => gatewayRows(props.values))
const keys = computed(() => [...new Set(rows.value.flatMap((row) => Object.keys(row)))])
const responseText = computed(() => {
const text = gatewayDisplayValue(props.data)
if (props.data == null || typeof props.data !== 'object') return text
return JSON.stringify(JSON.parse(text), null, 2)
})
const columnChecks = ref<{ prop: string; label: string; checked: boolean }[]>([])
watch(
keys,
(values) => {
columnChecks.value = values.map((key) => ({
prop: key,
label: key,
checked: columnChecks.value.find((column) => column.prop === key)?.checked ?? true
}))
},
{ immediate: true }
)
const visibleKeys = computed(() =>
columnChecks.value.filter((column) => column.checked).map((column) => column.prop)
)
</script>
<style scoped>
.gateway-data {
max-height: 400px;
overflow: auto;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
</style>

View File

@@ -0,0 +1,22 @@
<template>
<ElAlert
v-if="error"
type="error"
:closable="false"
show-icon
:title="title || '网关枚举加载失败'"
>
<template #default>
{{ error }}
<ElButton link type="primary" :loading="loading" @click="$emit('retry')">重新加载</ElButton>
</template>
</ElAlert>
<div v-else-if="loading" role="status">{{
title ? '正在加载运营商账户…' : '正在加载网关枚举…'
}}</div>
</template>
<script setup lang="ts">
defineProps<{ error: string; loading: boolean; title?: string }>()
defineEmits<{ retry: [] }>()
</script>

View File

@@ -0,0 +1,41 @@
<template>
<ElForm label-width="130px" :disabled="disabled">
<ElFormItem label="错误分类" required>
<ElSelect v-model="model.category"
><ElOption v-for="opt in options.error_categories" :key="opt.value" v-bind="opt"
/></ElSelect>
</ElFormItem>
<ElFormItem label="关键字或正则" required
><ElInput v-model="model.keyword" maxlength="200" show-word-limit
/></ElFormItem>
<ElFormItem label="匹配方式" required>
<ElSelect v-model="model.match_type"
><ElOption v-for="opt in options.match_types" :key="opt.value" v-bind="opt"
/></ElSelect>
</ElFormItem>
<ElFormItem label="适用操作">
<ElSelect v-model="model.operations" multiple clearable placeholder="留空表示全部操作"
><ElOption v-for="opt in options.gateway_operations" :key="opt.value" v-bind="opt"
/></ElSelect>
</ElFormItem>
<ElFormItem label="上游可能已受理"><ElSwitch v-model="model.maybe_accepted" /></ElFormItem>
<ElFormItem label="匹配优先级"
><ElInputNumber v-model="model.priority" :min="1" :max="10000" :precision="0" /><span
>数值越小越先匹配</span
></ElFormItem
>
<ElFormItem label="状态"
><ElSelect v-model="model.status"
><ElOption label="启用" :value="1" /><ElOption label="停用" :value="0" /></ElSelect
></ElFormItem>
<ElFormItem label="备注"
><ElInput v-model="model.remark" type="textarea" maxlength="500" show-word-limit
/></ElFormItem>
</ElForm>
</template>
<script setup lang="ts">
import type { GatewayRuleInput, GatewayOptions } from '@/types/api/gateway'
const model = defineModel<GatewayRuleInput>({ required: true })
defineProps<{ options: GatewayOptions; disabled?: boolean }>()
</script>

View File

@@ -0,0 +1,268 @@
<template>
<div class="gateway-page" :id="cardId == null ? 'table-full-screen' : undefined">
<GatewayOptionsNotice :error="optionsError" :loading="optionsLoading" @retry="loadOptions" />
<GatewayOptionsNotice
v-if="cardId == null"
title="运营商账户加载失败"
:error="carrierError"
:loading="carrierLoading"
@retry="searchCarriers()"
/>
<ArtSearchBar
:filter="searchFilters"
:items="searchItems"
:show-expand="searchItems.length >= 4"
label-width="100px"
@update:filter="updateFilters"
@search="search"
@reset="reset"
/>
<ElCard shadow="never" class="art-table-card">
<ArtTableHeader v-model:columns="columnChecks" @refresh="load">
<template #left
><span class="gateway-help"
>时间跨度不超过 31 天;单卡清空时间时查询最近 31 天。</span
></template
>
</ArtTableHeader>
<ElAlert
v-if="error"
class="gateway-error"
type="error"
:title="error"
:closable="false"
show-icon
/>
<ArtTable
:loading="loading"
:data="rows"
row-key="id"
:current-page="pagination.page"
:page-size="pagination.page_size"
:total="total"
:page-sizes="[20, 50, 100]"
:margin-top="10"
empty-text="暂无停复机记录"
@current-change="changePage"
@size-change="changeSize"
>
<ElTableColumn
v-for="col in columns"
:key="col.prop"
:prop="col.prop"
:label="col.label"
:min-width="col.width || 140"
show-overflow-tooltip
>
<template #default="{ row }">{{ recordValue(row, col.prop) }}</template>
</ElTableColumn>
</ArtTable>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { computed, ref, reactive, watch } from 'vue'
import { GatewayService } from '@/api/modules/gateway'
import { useAuth } from '@/composables/useAuth'
import {
useGatewayList,
useGatewayOptions,
useGatewayColumns,
useGatewayCarriers
} from '@/composables/useGateway'
import type { SearchFormItem } from '@/types'
import type { StopResumeQuery, StopResumeRecord } from '@/types/api/gateway'
import {
compactGatewayQuery,
gatewayTimeRange,
recentGatewayRange,
gatewayDisplayValue,
gatewayOptionalId,
validateGatewayInteger
} from '@/utils/business/gateway'
import GatewayOptionsNotice from './GatewayOptionsNotice.vue'
const props = defineProps<{ cardId?: number; permission: string }>()
const { hasAuth } = useAuth()
const {
options: carrierOptions,
loading: carrierLoading,
error: carrierError,
search: searchCarriers,
label: carrierLabel,
select: selectCarrier
} = useGatewayCarriers(props.cardId == null)
const {
options,
error: optionsError,
loading: optionsLoading,
ready: optionsReady,
load: loadOptions,
label
} = useGatewayOptions()
const range = ref<[Date | string, Date | string] | null>(recentGatewayRange())
const filters = reactive({
iccid: '',
carrier_id: undefined as number | undefined,
action: '',
source: '',
result: '',
error_category: '',
range
})
const { rows, total, loading, error, pagination, load, search, clear } =
useGatewayList<StopResumeRecord>(
(page) => {
const time = gatewayTimeRange(range.value, props.cardId == null)
if (props.cardId != null) {
validateGatewayInteger(props.cardId, 0, Number.MAX_SAFE_INTEGER, '卡 ID')
return GatewayService.getCardRecords(props.cardId, { ...page, ...time })
}
return GatewayService.getRecords(
compactGatewayQuery({
...filters,
range: undefined,
carrier_id: gatewayOptionalId(filters.carrier_id),
...page,
...time
}) as StopResumeQuery
)
},
() => hasAuth(props.permission)
)
const columnDefinitions: { prop: keyof StopResumeRecord; label: string; width?: number }[] = [
{ prop: 'created_at', label: '创建时间', width: 200 },
{ prop: 'iccid', label: 'ICCID', width: 220 },
{ prop: 'action', label: '动作' },
{ prop: 'result', label: '结果' },
{ prop: 'error_category', label: '错误类别' },
{ prop: 'provider_message', label: '上游返回信息', width: 240 },
{ prop: 'reason', label: '原因', width: 200 },
{ prop: 'source', label: '来源' },
{ prop: 'scene', label: '场景' },
{ prop: 'attempt', label: '尝试次数' },
{ prop: 'duration_ms', label: '耗时(毫秒)' },
{ prop: 'provider_code', label: '上游返回码' },
{ prop: 'command_id', label: '命令 ID' },
{ prop: 'card_id', label: '卡 ID' },
{ prop: 'carrier_id', label: '运营商账户' },
{ prop: 'id', label: '记录 ID' }
]
const { columns, columnChecks } = useGatewayColumns(columnDefinitions)
const searchFilters = filters
function updateFilters(value: Record<string, unknown>) {
const { range: time, ...rest } = value
Object.assign(filters, rest)
range.value = time as typeof range.value
}
const searchItems = computed<SearchFormItem[]>(() => [
...(props.cardId == null
? [
{
label: 'ICCID',
prop: 'iccid',
type: 'input' as const,
config: { clearable: true, maxlength: 30, placeholder: '请输入 ICCID' }
},
{
label: '运营商账户',
prop: 'carrier_id',
type: 'select' as const,
options: carrierOptions.value,
onChange: ({ val }: { val: unknown }) => selectCarrier(val),
config: {
clearable: true,
filterable: true,
remote: true,
remoteMethod: searchCarriers,
loading: carrierLoading.value,
placeholder: '请输入运营商名称搜索'
}
},
{
label: '动作',
prop: 'action',
type: 'select' as const,
options: options.value.stop_resume_actions,
config: { clearable: true, disabled: !optionsReady.value }
},
{
label: '来源',
prop: 'source',
type: 'input' as const,
config: { clearable: true, maxlength: 50 }
},
{
label: '结果',
prop: 'result',
type: 'select' as const,
options: options.value.stop_resume_results,
config: { clearable: true, disabled: !optionsReady.value }
},
{
label: '错误类别',
prop: 'error_category',
type: 'select' as const,
options: options.value.error_categories,
config: { clearable: true, disabled: !optionsReady.value }
}
]
: []),
{
label: '记录时间',
prop: 'range',
type: 'datetimerange',
config: {
type: 'datetimerange',
rangeSeparator: '至',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间',
valueFormat: 'YYYY-MM-DDTHH:mm:ssZ',
clearable: true
}
}
])
function changePage(page: number) {
pagination.page = page
void load()
}
function changeSize(size: number) {
pagination.page_size = size
pagination.page = 1
void load()
}
function recordValue(row: StopResumeRecord, prop: keyof StopResumeRecord) {
if (prop === 'carrier_id') return carrierLabel(row.carrier_id)
if (prop === 'action') return row.action_name || label('stop_resume_actions', row.action)
if (prop === 'result') return label('stop_resume_results', row.result)
if (prop === 'error_category')
return row.error_category_name || label('error_categories', row.error_category)
return gatewayDisplayValue(row[prop], prop)
}
function reset() {
Object.assign(filters, {
iccid: '',
carrier_id: undefined,
action: '',
source: '',
result: '',
error_category: ''
})
range.value = recentGatewayRange()
void search()
}
watch(
() => [props.cardId, props.permission],
() => {
clear()
pagination.page = 1
void load()
},
{ immediate: true }
)
</script>
<style scoped lang="scss">
@use '@/views/polling-management/gateway';
</style>