fix: 运营商管理
All checks were successful
构建并部署前端到测试环境 / deploy (push) Successful in 54s

This commit is contained in:
luo
2026-09-29 09:15:05 +08:00
parent 6655638116
commit 5364916b1d
26 changed files with 1095 additions and 40 deletions

View File

@@ -0,0 +1,24 @@
/** 运营商通道阈值命中记录查询 API */
import { BaseService } from '../BaseService'
import type { BaseResponse } from '@/types/api'
import type {
CarrierTrafficThresholdHitPageResult,
CarrierTrafficThresholdHitQueryParams
} from '@/types/api/carrierTrafficThresholdHit'
export class CarrierTrafficThresholdHitService extends BaseService {
/**
* 查询通道阈值命中记录
* GET /api/admin/carrier-traffic-threshold-hits
* 仅超级管理员和平台账号可访问,后端负责卡数据范围过滤。
*/
static getHits(
params?: CarrierTrafficThresholdHitQueryParams
): Promise<BaseResponse<CarrierTrafficThresholdHitPageResult>> {
return this.get<BaseResponse<CarrierTrafficThresholdHitPageResult>>(
'/api/admin/carrier-traffic-threshold-hits',
params
)
}
}

View File

@@ -16,6 +16,7 @@ export { StorageService } from './storage'
export { AuthorizationService } from './authorization'
export { DeviceService } from './device'
export { CarrierService } from './carrier'
export { CarrierTrafficThresholdHitService } from './carrierTrafficThresholdHit'
export { PackageSeriesService } from './packageSeries'
export { PackageManageService } from './packageManage'
export { ShopSeriesGrantService } from './shopSeriesGrant'

View File

@@ -356,6 +356,8 @@
"role": "Role Manage",
"permission": "Permission Manage",
"carrierManagement": "Carrier Management",
"carrierDetail": "Carrier Details",
"carrierTrafficThresholdHits": "Carrier Threshold Hit Records",
"userCenter": "User Center",
"menu": "Menu Manage",
"nested": "Nested Menu",

View File

@@ -355,6 +355,8 @@
"role": "角色管理",
"permission": "权限管理",
"carrierManagement": "运营商管理",
"carrierDetail": "运营商详情",
"carrierTrafficThresholdHits": "通道阈值命中记录",
"userCenter": "个人中心"
},
"packageManagement": {

View File

@@ -238,7 +238,7 @@ async function processFrontendMenu(router: Router): Promise<void> {
throw new Error('获取用户角色失败')
}
const filteredMenuList = filterMenuByRoles(menuList, roles)
const filteredMenuList = filterMenuByRoles(menuList, roles, Number(userStore.info.user_type))
await new Promise((resolve) => setTimeout(resolve, 300))
await registerAndStoreMenu(router, filteredMenuList, closeLoading)
} catch (error) {
@@ -264,7 +264,11 @@ async function processBackendMenu(router: Router): Promise<void> {
}
// 普通用户:使用后端返回的菜单
const backendMenus = userStore.menus || []
let backendMenus = userStore.menus || []
if (Number(userStore.info.user_type) === 2) {
backendMenus = ensureCarrierManagementMenuEntries(backendMenus)
userStore.setMenus(backendMenus)
}
const routeMap = buildRouteMap(asyncRoutes)
const menuList = backendMenus
@@ -279,6 +283,46 @@ async function processBackendMenu(router: Router): Promise<void> {
}
}
/**
* Backend menu mode may return the existing System menu without the newly added
* carrier detail and threshold-hit entries. Add those as sibling routes under System
* for platform accounts, without introducing a nested RouterView layout.
*/
function ensureCarrierManagementMenuEntries(menus: any[]): any[] {
const requiredEntries = [
{
url: `${RoutesAlias.CarrierDetail}/:id`,
name: 'menus.system.carrierDetail'
},
{
url: RoutesAlias.CarrierTrafficThresholdHits,
name: 'menus.system.carrierTrafficThresholdHits'
}
]
let systemMenuFound = false
const visit = (items: any[]): any[] =>
items.map((item) => {
const children = Array.isArray(item.children) ? visit(item.children) : []
const itemUrl = item.url || item.path
if (itemUrl === '/system') {
systemMenuFound = true
const existingUrls = new Set(children.map((child) => child.url || child.path))
const missingEntries = requiredEntries.filter((entry) => !existingUrls.has(entry.url))
return { ...item, children: [...children, ...missingEntries] }
}
return Array.isArray(item.children) ? { ...item, children } : item
})
const enrichedMenus = visit(menus)
if (systemMenuFound) return enrichedMenus
const systemMenu = { url: '/system', name: 'menus.system.title', children: requiredEntries }
return [...enrichedMenus, systemMenu]
}
/**
* 合并默认菜单
* 将配置的默认菜单合并到后端返回的菜单中
@@ -448,15 +492,21 @@ function handleMenuError(error: unknown): void {
/**
* 根据角色过滤菜单
*/
const filterMenuByRoles = (menu: AppRouteRecord[], roles: string[]): AppRouteRecord[] => {
const filterMenuByRoles = (
menu: AppRouteRecord[],
roles: string[],
userType: number
): AppRouteRecord[] => {
return menu.reduce((acc: AppRouteRecord[], item) => {
const itemRoles = item.meta?.roles
const hasPermission = !itemRoles || itemRoles.some((role) => roles?.includes(role))
const allowedUserTypes = item.meta?.allowedUserTypes as number[] | undefined
const hasRole = !itemRoles || itemRoles.some((role) => roles?.includes(role))
const hasAllowedUserType = !allowedUserTypes || allowedUserTypes.includes(userType)
if (hasPermission) {
if (hasRole && hasAllowedUserType) {
const filteredItem = { ...item }
if (filteredItem.children?.length) {
filteredItem.children = filterMenuByRoles(filteredItem.children, roles)
filteredItem.children = filterMenuByRoles(filteredItem.children, roles, userType)
}
acc.push(filteredItem)
}

View File

@@ -205,4 +205,3 @@ export const buildLoginRedirect = (currentPath: string): string => {
}
return `/auth/login?redirect=${encodeURIComponent(currentPath)}`
}

View File

@@ -118,6 +118,26 @@ export const asyncRoutes: AppRouteRecord[] = [
keepAlive: true
}
},
{
path: 'carrier-management/detail/:id',
name: 'CarrierDetail',
component: RoutesAlias.CarrierDetail,
meta: {
title: 'menus.system.carrierDetail',
isHide: true,
keepAlive: false
}
},
{
path: 'carrier-traffic-threshold-hits',
name: 'CarrierTrafficThresholdHits',
component: RoutesAlias.CarrierTrafficThresholdHitsPage,
meta: {
title: 'menus.system.carrierTrafficThresholdHits',
keepAlive: true,
allowedUserTypes: [1, 2]
}
},
{
path: 'user-center',
name: 'UserCenter',

View File

@@ -21,6 +21,9 @@ export enum RoutesAlias {
RoleDetail = '/system/role/detail', // 角色详情
Permission = '/system/permission', // 权限管理
CarrierManagement = '/system/carrier-management', // 运营商管理
CarrierDetail = '/system/carrier-management/detail', // 运营商详情
CarrierTrafficThresholdHits = '/system/carrier-traffic-threshold-hits', // 通道阈值命中记录
CarrierTrafficThresholdHitsPage = '/system/carrier-management/traffic-threshold-hits', // 通道阈值命中记录组件
UserCenter = '/system/user-center', // 用户中心
// 审计中心

View File

@@ -1 +1,2 @@
店铺编码,操作类型,业务员登录账号,备注
店铺编码,操作类型,业务员登录账号,备注
,仅支持「换绑」或「清空」,注意: 清空不要填写业务员登录账号,
1 店铺编码 操作类型 业务员登录账号 备注
2 仅支持「换绑」或「清空」 注意: 清空不要填写业务员登录账号

View File

@@ -0,0 +1 @@
order_no
1 order_no

View File

@@ -12,16 +12,22 @@ export enum CarrierType {
CBN = 'CBN' // 中国广电
}
export type CarrierTrafficThresholdEnabled = 0 | 1
export type CarrierTrafficThresholdUnit = 'MB' | 'GB'
// 运营商响应数据
export interface Carrier {
id: number
carrier_code: string
carrier_name: string
carrier_type: CarrierType
description?: string
description?: string | null
data_reset_day?: number | null // 上游流量重置日(1-28)
realname_link_type?: string // 实名链接类型
realname_link_template?: string // 实名链接模板
realname_link_type?: string | null // 实名链接类型
realname_link_template?: string | null // 实名链接模板
traffic_threshold_enabled?: CarrierTrafficThresholdEnabled | null // 通道流量阈值开关
traffic_threshold_unit?: CarrierTrafficThresholdUnit | null // 通道流量阈值单位
traffic_threshold_value?: number | null // 通道流量阈值数值
status: number
created_at: string
updated_at: string
@@ -39,19 +45,25 @@ export interface CreateCarrierParams {
carrier_code: string
carrier_name: string
carrier_type: CarrierType
description?: string
description?: string | null
data_reset_day?: number | null // 上游流量重置日(1-28)
realname_link_type?: string // 实名链接类型
realname_link_template?: string // 实名链接模板
realname_link_type?: string | null // 实名链接类型
realname_link_template?: string | null // 实名链接模板
traffic_threshold_enabled?: CarrierTrafficThresholdEnabled | null // 通道流量阈值开关
traffic_threshold_unit?: CarrierTrafficThresholdUnit | null // 通道流量阈值单位
traffic_threshold_value?: number | null // 通道流量阈值数值
}
// 更新运营商请求参数
export interface UpdateCarrierParams {
carrier_name?: string
description?: string
description?: string | null
data_reset_day?: number | null // 上游流量重置日(1-28)
realname_link_type?: string // 实名链接类型
realname_link_template?: string // 实名链接模板
realname_link_type?: string | null // 实名链接类型
realname_link_template?: string | null // 实名链接模板
traffic_threshold_enabled?: CarrierTrafficThresholdEnabled | null // 通道流量阈值开关
traffic_threshold_unit?: CarrierTrafficThresholdUnit | null // 通道流量阈值单位
traffic_threshold_value?: number | null // 通道流量阈值数值
}
// 更新运营商状态参数

View File

@@ -0,0 +1,35 @@
/** 运营商通道阈值命中记录 */
export interface CarrierTrafficThresholdHitQueryParams {
page?: number
page_size?: number
carrier_id?: number
card_id?: number
period_start?: string
start_time?: string
end_time?: string
}
export interface CarrierTrafficThresholdHitItem {
id: number
card_id: number
carrier_id: number
hit_traffic_mb: number | null
hit_threshold_value: number | null
hit_threshold_unit: string
period_start: string
judged_at: string
trigger_source: string
status: string
unlocked_at: string | null
resume_result: string
failure_reason: string
created_at: string
}
export interface CarrierTrafficThresholdHitPageResult {
items: CarrierTrafficThresholdHitItem[] | null
page: number
size: number
total: number
}

View File

@@ -66,6 +66,7 @@ export * from './enterpriseDevice'
// 运营商相关
export * from './carrier'
export * from './carrierTrafficThresholdHit'
// 订单相关
export * from './order'

View File

@@ -133,6 +133,7 @@ declare module 'vue' {
ElRow: typeof import('element-plus/es')['ElRow']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']

View File

@@ -56,20 +56,27 @@
>
<ElForm ref="createFormRef" :model="createForm" :rules="createRules">
<ElFormItem label="CSV文件" prop="file">
<VoucherUpload
ref="csvUploadRef"
v-model="csvFileKeys"
voucher-name="CSV文件"
:max-count="1"
purpose="attachment"
:max-size-mb="10"
single-column-csv
:max-csv-rows="1000"
accept=".csv"
tip="仅支持 UTF-8 单列 CSV,每行一个订单号,最多 1000 行,最大 10MB"
@uploading-change="csvUploading = $event"
@files-change="handleCsvFilesChange"
/>
<div class="csv-upload-field">
<div class="template-download-row">
<ElButton link type="primary" :icon="Download" @click="downloadTemplate">
下载模板
</ElButton>
</div>
<VoucherUpload
ref="csvUploadRef"
v-model="csvFileKeys"
voucher-name="CSV文件"
:max-count="1"
purpose="attachment"
:max-size-mb="10"
single-column-csv
:max-csv-rows="1000"
accept=".csv"
tip="仅支持 UTF-8 单列 CSV,每行一个订单号,最多 1000 行,最大 10MB"
@uploading-change="csvUploading = $event"
@files-change="handleCsvFilesChange"
/>
</div>
</ElFormItem>
<ElFormItem label="凭证附件">
<VoucherUpload
@@ -123,7 +130,7 @@
ElMessage,
ElTag
} from 'element-plus'
import { Upload } from '@element-plus/icons-vue'
import { Download, Upload } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import { OrderPackageInvalidateTaskService } from '@/api/modules'
import type {
@@ -390,6 +397,21 @@
getTableData()
}
const downloadTemplate = () => {
try {
const link = document.createElement('a')
link.href = new URL('@/template/订单批量作废模板.csv', import.meta.url).href
link.download = '订单批量作废模板.csv'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
ElMessage.success('模板下载成功')
} catch (error) {
console.error('下载作废任务模板失败:', error)
ElMessage.error('模板下载失败')
}
}
const handleCsvFilesChange = (files: Array<{ file_name?: string }>) => {
csvFileName.value = files[0]?.file_name || ''
createForm.file = csvFileName.value
@@ -449,6 +471,16 @@
</script>
<style scoped>
.csv-upload-field {
width: 100%;
}
.template-download-row {
display: flex;
justify-content: flex-end;
margin-bottom: 8px;
}
.drawer-footer {
display: flex;
gap: 12px;

View File

@@ -0,0 +1,140 @@
<template>
<ArtTableFullScreen>
<div class="carrier-detail-page" id="table-full-screen">
<ElCard shadow="never" class="art-table-card">
<template #header>
<div class="detail-header">
<span>运营商详情</span>
<ElButton @click="goBack">返回列表</ElButton>
</div>
</template>
<ElSkeleton v-if="loading" :rows="8" animated />
<ElResult v-else-if="!carrier" icon="error" title="暂无运营商详情">
<template #extra>
<ElButton type="primary" @click="goBack">返回运营商列表</ElButton>
</template>
</ElResult>
<ElDescriptions v-else :column="descriptionColumns" border>
<ElDescriptionsItem label="运营商编码">
{{ carrier.carrier_code || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="运营商名称">
{{ carrier.carrier_name || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="运营商类型">
{{ getCarrierTypeLabel(carrier.carrier_type) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="状态">
{{ getStatusText(carrier.status) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="运营商描述" :span="descriptionColumns">
{{ carrier.description || '未配置' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="流量重置日">
{{ carrier.data_reset_day ?? '未配置' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="实名链接类型">
{{ carrier.realname_link_type || '未配置' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="实名链接模板" :span="descriptionColumns">
{{ carrier.realname_link_template || '未配置' }}
</ElDescriptionsItem>
<template v-if="canConfigureTrafficThreshold">
<ElDescriptionsItem label="通道阈值状态">
{{ getThresholdEnabledLabel(carrier.traffic_threshold_enabled) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="通道阈值单位">
{{ carrier.traffic_threshold_unit || '未配置' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="通道阈值数值">
{{ formatThresholdValue(carrier.traffic_threshold_value) }}
</ElDescriptionsItem>
</template>
<ElDescriptionsItem label="创建时间">
{{ formatDateTime(carrier.created_at) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="更新时间">
{{ formatDateTime(carrier.updated_at) }}
</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useWindowSize } from '@vueuse/core'
import { CarrierService } from '@/api/modules'
import { usePermission } from '@/composables/usePermission'
import { RoutesAlias } from '@/router/routesAlias'
import { formatDateTime } from '@/utils/business/format'
import { getStatusText, getCarrierTypeLabel } from '@/config/constants'
import type { Carrier } from '@/types/api'
defineOptions({ name: 'CarrierDetail' })
const route = useRoute()
const router = useRouter()
const { isPlatformAccount } = usePermission()
const canConfigureTrafficThreshold = computed(() => isPlatformAccount.value)
const { width } = useWindowSize()
const descriptionColumns = computed(() => (width.value < 768 ? 1 : 2))
const loading = ref(false)
const carrier = ref<Carrier | null>(null)
const getThresholdEnabledLabel = (enabled?: 0 | 1 | null) => {
if (enabled === 1) return '启用'
if (enabled === 0) return '停用'
return '未配置'
}
const formatThresholdValue = (value?: number | null) =>
value === null || value === undefined ? '未配置' : String(value)
const loadCarrier = async () => {
const id = Number(route.params.id)
if (!Number.isSafeInteger(id) || id < 1) {
carrier.value = null
return
}
loading.value = true
try {
const res = await CarrierService.getCarrierDetail(id)
if (res.code === 0 && res.data) {
carrier.value = res.data
} else {
carrier.value = null
}
} catch (error) {
console.error('获取运营商详情失败:', error)
carrier.value = null
} finally {
loading.value = false
}
}
const goBack = () => {
void router.push(RoutesAlias.CarrierManagement)
}
watch(
() => route.params.id,
() => void loadCarrier()
)
onMounted(() => {
void loadCarrier()
})
</script>
<style scoped lang="scss">
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
}
</style>

View File

@@ -45,9 +45,15 @@
<ElDialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '新增运营商' : '编辑运营商'"
width="30%"
width="min(94vw, 760px)"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="120px">
<ElForm
ref="formRef"
:model="form"
:rules="rules"
label-width="120px"
class="carrier-form-grid"
>
<ElFormItem label="运营商编码" prop="carrier_code">
<ElInput
v-model="form.carrier_code"
@@ -80,7 +86,7 @@
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="运营商描述" prop="description">
<ElFormItem label="运营商描述" prop="description" class="carrier-form-span">
<ElInput
v-model="form.description"
type="textarea"
@@ -102,6 +108,42 @@
运营商每月清零网关计数器的日期(1-28日),默认为1日
</div>
</ElFormItem>
<ElFormItem v-if="canConfigureTrafficThreshold" label="通道阈值状态">
<ElSelect
v-model="form.traffic_threshold_mode"
placeholder="请选择,留空表示未配置"
style="width: 100%"
@change="handleTrafficThresholdModeChange"
>
<ElOption label="未配置" value="unconfigured" />
<ElOption label="停用" value="disabled" />
<ElOption label="启用" value="enabled" />
</ElSelect>
</ElFormItem>
<template v-if="canConfigureTrafficThreshold">
<ElFormItem label="通道阈值单位" prop="traffic_threshold_unit">
<ElSelect
v-model="form.traffic_threshold_unit"
placeholder="请选择阈值单位"
style="width: 100%"
:disabled="form.traffic_threshold_mode !== 'enabled'"
>
<ElOption label="MB" value="MB" />
<ElOption label="GB" value="GB" />
</ElSelect>
</ElFormItem>
<ElFormItem label="通道阈值数值" prop="traffic_threshold_value">
<ElInput
v-model="form.traffic_threshold_value"
type="number"
min="0"
step="any"
:disabled="form.traffic_threshold_mode !== 'enabled'"
placeholder="请输入大于0的阈值数值"
style="width: 100%"
/>
</ElFormItem>
</template>
<ElFormItem label="实名链接类型" prop="realname_link_type">
<ElSelect
v-model="form.realname_link_type"
@@ -117,6 +159,7 @@
v-if="form.realname_link_type === 'template'"
label="实名链接模板"
prop="realname_link_template"
class="carrier-form-span"
>
<ElInput
v-model="form.realname_link_template"
@@ -145,13 +188,16 @@
<script setup lang="ts">
import { h, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { CarrierService } from '@/api/modules'
import { ElMessage, ElMessageBox, ElTag, ElSwitch, ElButton } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import type { Carrier, CarrierType } from '@/types/api'
import type { Carrier, CarrierType, CreateCarrierParams } from '@/types/api'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import { usePermission } from '@/composables/usePermission'
import { RoutesAlias } from '@/router/routesAlias'
import { generateCarrierCode } from '@/utils/codeGenerator'
import { formatDateTime } from '@/utils/business/format'
import {
@@ -164,7 +210,10 @@
defineOptions({ name: 'CarrierManagement' })
const router = useRouter()
const { hasAuth } = useAuth()
const { isPlatformAccount } = usePermission()
const canConfigureTrafficThreshold = computed(() => isPlatformAccount.value)
const canUpdateCarrierStatus = hasAuth('carrier:update_status')
const dialogVisible = ref(false)
const loading = ref(false)
@@ -235,6 +284,13 @@
{ label: '运营商名称', prop: 'carrier_name' },
{ label: '运营商类型', prop: 'carrier_type' },
{ label: '运营商描述', prop: 'description' },
...(canConfigureTrafficThreshold.value
? [
{ label: '通道阈值状态', prop: 'traffic_threshold_enabled' },
{ label: '通道阈值单位', prop: 'traffic_threshold_unit' },
{ label: '通道阈值数值', prop: 'traffic_threshold_value' }
]
: []),
...(canUpdateCarrierStatus ? [{ label: '状态', prop: 'status' }] : []),
{ label: '创建时间', prop: 'created_at' }
]
@@ -296,6 +352,9 @@
carrier_type: null,
description: '',
data_reset_day: 1,
traffic_threshold_mode: 'unconfigured',
traffic_threshold_unit: null,
traffic_threshold_value: null,
realname_link_type: 'none',
realname_link_template: ''
})
@@ -314,7 +373,17 @@
prop: 'carrier_name',
label: '运营商名称',
minWidth: 150,
showOverflowTooltip: true
showOverflowTooltip: true,
formatter: (row: Carrier) =>
h(
ElButton,
{
type: 'primary',
link: true,
onClick: () => void router.push(`${RoutesAlias.CarrierDetail}/${row.id}`)
},
() => row.carrier_name || '-'
)
},
{
prop: 'carrier_type',
@@ -335,6 +404,28 @@
minWidth: 200,
showOverflowTooltip: true
},
...(canConfigureTrafficThreshold.value
? [
{
prop: 'traffic_threshold_enabled',
label: '通道阈值状态',
width: 130,
formatter: (row: Carrier) => getThresholdEnabledLabel(row.traffic_threshold_enabled)
},
{
prop: 'traffic_threshold_unit',
label: '通道阈值单位',
width: 120,
formatter: (row: Carrier) => row.traffic_threshold_unit || '未配置'
},
{
prop: 'traffic_threshold_value',
label: '通道阈值数值',
width: 140,
formatter: (row: Carrier) => formatThresholdValue(row.traffic_threshold_value)
}
]
: []),
...(canUpdateCarrierStatus
? [
{
@@ -482,6 +573,9 @@
form.carrier_type = row.carrier_type
form.description = row.description
form.data_reset_day = row.data_reset_day || 1
form.traffic_threshold_mode = getThresholdMode(row.traffic_threshold_enabled)
form.traffic_threshold_unit = row.traffic_threshold_unit ?? null
form.traffic_threshold_value = row.traffic_threshold_value ?? null
form.realname_link_type = row.realname_link_type || 'none'
form.realname_link_template = row.realname_link_template || ''
} else {
@@ -491,6 +585,9 @@
form.carrier_type = null
form.description = ''
form.data_reset_day = 1
form.traffic_threshold_mode = 'unconfigured'
form.traffic_threshold_unit = null
form.traffic_threshold_value = null
form.realname_link_type = 'none'
form.realname_link_template = ''
}
@@ -501,6 +598,49 @@
})
}
const getThresholdEnabledLabel = (enabled?: 0 | 1 | null) => {
if (enabled === 1) return '启用'
if (enabled === 0) return '停用'
return '未配置'
}
const getThresholdMode = (enabled?: 0 | 1 | null) => {
if (enabled === 1) return 'enabled'
if (enabled === 0) return 'disabled'
return 'unconfigured'
}
const handleTrafficThresholdModeChange = (mode: string) => {
if (mode === 'unconfigured') {
form.traffic_threshold_unit = null
form.traffic_threshold_value = null
}
}
const formatThresholdValue = (value?: number | null) =>
value === null || value === undefined ? '未配置' : String(value)
const getTrafficThresholdPayload = () => {
if (!canConfigureTrafficThreshold.value) return {}
if (form.traffic_threshold_mode === 'unconfigured') {
return {
traffic_threshold_enabled: null,
traffic_threshold_unit: null,
traffic_threshold_value: null
}
}
if (form.traffic_threshold_mode === 'disabled') {
return { traffic_threshold_enabled: 0 as const }
}
return {
traffic_threshold_enabled: 1 as const,
traffic_threshold_unit: form.traffic_threshold_unit,
traffic_threshold_value: Number(form.traffic_threshold_value)
}
}
// 处理编码生成
const handleGenerateCarrierCode = () => {
form.carrier_code = generateCarrierCode()
@@ -536,11 +676,22 @@
const handleSubmit = async (formEl: FormInstance | undefined) => {
if (!formEl) return
if (
canConfigureTrafficThreshold.value &&
form.traffic_threshold_mode === 'enabled' &&
(!form.traffic_threshold_unit ||
!Number.isFinite(Number(form.traffic_threshold_value)) ||
Number(form.traffic_threshold_value) <= 0)
) {
ElMessage.warning('启用通道阈值时,请填写阈值单位和大于0的阈值数值')
return
}
await formEl.validate(async (valid) => {
if (valid) {
submitLoading.value = true
try {
const data = {
const data: CreateCarrierParams = {
carrier_code: form.carrier_code,
carrier_name: form.carrier_name,
carrier_type: form.carrier_type as CarrierType,
@@ -548,7 +699,8 @@
data_reset_day: form.data_reset_day || null,
realname_link_type: form.realname_link_type,
realname_link_template:
form.realname_link_type === 'template' ? form.realname_link_template : undefined
form.realname_link_type === 'template' ? form.realname_link_template : undefined,
...getTrafficThresholdPayload()
}
if (dialogType.value === 'add') {
@@ -561,7 +713,8 @@
data_reset_day: form.data_reset_day || null,
realname_link_type: form.realname_link_type,
realname_link_template:
form.realname_link_type === 'template' ? form.realname_link_template : undefined
form.realname_link_type === 'template' ? form.realname_link_template : undefined,
...getTrafficThresholdPayload()
}
await CarrierService.updateCarrier(form.id, updateData)
ElMessage.success('修改成功')
@@ -599,4 +752,28 @@
.carrier-page {
height: 100%;
}
.carrier-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 24px;
:deep(.el-form-item) {
margin-right: 0;
}
}
.carrier-form-span {
grid-column: 1 / -1;
}
@media (max-width: 640px) {
.carrier-form-grid {
grid-template-columns: minmax(0, 1fr);
}
.carrier-form-span {
grid-column: auto;
}
}
</style>

View File

@@ -0,0 +1,7 @@
<template>
<CarrierTrafficThresholdHitsPage />
</template>
<script setup lang="ts">
import CarrierTrafficThresholdHitsPage from '@/views/system/carrier-traffic-threshold-hits/index.vue'
</script>

View File

@@ -0,0 +1,421 @@
<template>
<ArtTableFullScreen>
<div class="carrier-threshold-hit-page" id="table-full-screen">
<ElResult
v-if="!canAccess"
icon="warning"
title="无访问权限"
sub-title="仅超级管理员和平台账号可查看通道阈值命中记录"
/>
<template v-else>
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
label-width="105"
:show-expand="false"
@reset="handleReset"
@search="handleSearch"
/>
<ElCard shadow="never" class="art-table-card">
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
/>
<ArtTable
:loading="loading"
:data="hitList"
:currentPage="pagination.page"
:pageSize="pagination.page_size"
:total="pagination.total"
:marginTop="10"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</ElCard>
</template>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, h, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElTag } from 'element-plus'
import { CarrierService, CardService, CarrierTrafficThresholdHitService } from '@/api/modules'
import { usePermission } from '@/composables/usePermission'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { formatDateTime } from '@/utils/business/format'
import type { SearchFormItem } from '@/types'
import type {
Carrier,
CarrierTrafficThresholdHitItem,
CarrierTrafficThresholdHitQueryParams,
StandaloneCardQueryParams,
StandaloneIotCard
} from '@/types/api'
defineOptions({ name: 'CarrierTrafficThresholdHits' })
const { isPlatformAccount } = usePermission()
const canAccess = computed(() => isPlatformAccount.value)
const loading = ref(false)
const carrierOptionsLoading = ref(false)
const cardOptionsLoading = ref(false)
const carrierOptions = ref<Carrier[]>([])
const cardOptions = ref<StandaloneIotCard[]>([])
const hitList = ref<CarrierTrafficThresholdHitItem[]>([])
const pagination = reactive({
page: 1,
page_size: 20,
total: 0
})
const initialSearchState = {
carrier_id: undefined as number | undefined,
card_id: undefined as number | undefined,
period_start: '',
judged_at_range: [] as string[],
start_time: '',
end_time: ''
}
const searchForm = ref({ ...initialSearchState })
const preserveSelectedOption = <T extends { id: number }>(
results: T[],
currentOptions: T[],
selectedId?: number
): T[] => {
if (!selectedId || results.some((item) => item.id === selectedId)) return results
const selectedOption = currentOptions.find((item) => item.id === selectedId)
return selectedOption ? [selectedOption, ...results.slice(0, 19)] : results
}
const searchCarriers = async (keyword = '') => {
if (!canAccess.value) return
carrierOptionsLoading.value = true
try {
const res = await CarrierService.getCarriers({
page: 1,
page_size: 20,
carrier_name: keyword.trim() || undefined
})
if (res.code === 0) {
carrierOptions.value = preserveSelectedOption(
res.data.items || [],
carrierOptions.value,
searchForm.value.carrier_id
)
}
} catch (error) {
console.error('搜索运营商通道失败:', error)
} finally {
carrierOptionsLoading.value = false
}
}
const searchCards = async (keyword = '') => {
if (!canAccess.value) return
cardOptionsLoading.value = true
try {
const params: StandaloneCardQueryParams = {
page: 1,
page_size: 20,
iccid: keyword.trim() || undefined
}
const res = await CardService.getStandaloneIotCards(params)
if (res.code === 0) {
cardOptions.value = preserveSelectedOption(
res.data.items || [],
cardOptions.value,
searchForm.value.card_id
)
}
} catch (error) {
console.error('搜索IoT卡失败:', error)
} finally {
cardOptionsLoading.value = false
}
}
const searchFormItems = computed<SearchFormItem[]>(() => [
{
label: '运营商通道',
prop: 'carrier_id',
type: 'select',
config: {
clearable: true,
filterable: true,
remote: true,
reserveKeyword: true,
remoteMethod: searchCarriers,
loading: carrierOptionsLoading.value,
placeholder: '输入运营商名称搜索'
},
options: () =>
carrierOptions.value.map((carrier) => ({
label: carrier.carrier_name,
value: carrier.id
}))
},
{
label: 'IoT卡',
prop: 'card_id',
type: 'select',
config: {
clearable: true,
filterable: true,
remote: true,
reserveKeyword: true,
remoteMethod: searchCards,
loading: cardOptionsLoading.value,
placeholder: '输入ICCID搜索'
},
options: () =>
cardOptions.value.map((card) => ({
label: card.iccid,
value: card.id
}))
},
{
label: '计费周期起点',
prop: 'period_start',
type: 'datetime',
config: {
type: 'datetime',
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'YYYY-MM-DDTHH:mm:ssZ',
placeholder: '请选择周期起点'
}
},
{
label: '判定时间',
prop: 'judged_at_range',
type: 'datetimerange',
config: {
type: 'datetimerange',
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'YYYY-MM-DDTHH:mm:ssZ',
rangeSeparator: '至',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间'
}
}
])
const formatNullableNumber = (value: number | null | undefined, unit = '') => {
if (value === null || value === undefined) return '无'
const formatted = Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 2 })
return unit ? `${formatted} ${unit}` : formatted
}
const formatThresholdValue = (row: CarrierTrafficThresholdHitItem) => {
if (row.hit_threshold_value === null || row.hit_threshold_value === undefined) return '无'
const value = formatNullableNumber(row.hit_threshold_value)
return row.hit_threshold_unit ? `${value} ${row.hit_threshold_unit}` : `${value}(单位:无)`
}
const statusMap: Record<string, { label: string; type: 'warning' | 'success' | 'info' }> = {
locked: { label: '持锁中', type: 'warning' },
unlocked: { label: '已跨期解锁', type: 'success' }
}
const triggerSourceMap: Record<string, string> = {
polling: '周期轮询',
manual_sync: '手动同步',
manual_override: '人工纠偏',
carrier_callback: '运营商回调',
business_event: '业务成功后的观测'
}
const resumeResultMap: Record<string, string> = {
resumed: '已发起复机',
skipped: '仅解锁未复机',
manual: '转人工核对'
}
const { columnChecks, columns } = useCheckedColumns(() => [
{ prop: 'id', label: '记录ID', width: 100 },
{ prop: 'card_id', label: 'IoT卡ID', width: 110 },
{ prop: 'carrier_id', label: '运营商通道ID', width: 140 },
{
prop: 'hit_traffic_mb',
label: '命中流量',
width: 140,
formatter: (row: CarrierTrafficThresholdHitItem) =>
formatNullableNumber(row.hit_traffic_mb, 'MB')
},
{
prop: 'hit_threshold_value',
label: '命中阈值',
width: 140,
formatter: formatThresholdValue
},
{
prop: 'period_start',
label: '计费周期起点',
width: 180,
formatter: (row: CarrierTrafficThresholdHitItem) => formatDateTime(row.period_start)
},
{
prop: 'judged_at',
label: '判定时间',
width: 180,
formatter: (row: CarrierTrafficThresholdHitItem) => formatDateTime(row.judged_at)
},
{
prop: 'trigger_source',
label: '触发来源',
minWidth: 160,
formatter: (row: CarrierTrafficThresholdHitItem) =>
row.trigger_source ? triggerSourceMap[row.trigger_source] || row.trigger_source : '无'
},
{
prop: 'status',
label: '锁状态',
width: 120,
formatter: (row: CarrierTrafficThresholdHitItem) => {
const status = statusMap[row.status]
return h(ElTag, { type: status?.type || 'info' }, () => status?.label || row.status || '无')
}
},
{
prop: 'unlocked_at',
label: '解锁时间',
width: 180,
formatter: (row: CarrierTrafficThresholdHitItem) =>
row.unlocked_at ? formatDateTime(row.unlocked_at) : '无'
},
{
prop: 'resume_result',
label: '复机结果',
minWidth: 150,
formatter: (row: CarrierTrafficThresholdHitItem) =>
row.resume_result
? resumeResultMap[row.resume_result] || row.resume_result
: '未处理或历史记录'
},
{
prop: 'failure_reason',
label: '安全原因',
minWidth: 180,
showOverflowTooltip: true,
formatter: (row: CarrierTrafficThresholdHitItem) => row.failure_reason || '无'
},
{
prop: 'created_at',
label: '记录创建时间',
width: 180,
formatter: (row: CarrierTrafficThresholdHitItem) => formatDateTime(row.created_at)
}
])
const columnOptions = [
{ label: '记录ID', prop: 'id' },
{ label: 'IoT卡ID', prop: 'card_id' },
{ label: '运营商通道ID', prop: 'carrier_id' },
{ label: '命中流量', prop: 'hit_traffic_mb' },
{ label: '命中阈值', prop: 'hit_threshold_value' },
{ label: '计费周期起点', prop: 'period_start' },
{ label: '判定时间', prop: 'judged_at' },
{ label: '触发来源', prop: 'trigger_source' },
{ label: '锁状态', prop: 'status' },
{ label: '解锁时间', prop: 'unlocked_at' },
{ label: '复机结果', prop: 'resume_result' },
{ label: '安全原因', prop: 'failure_reason' },
{ label: '记录创建时间', prop: 'created_at' }
]
const syncJudgedAtRange = () => {
if (
Array.isArray(searchForm.value.judged_at_range) &&
searchForm.value.judged_at_range.length === 2
) {
searchForm.value.start_time = searchForm.value.judged_at_range[0]
searchForm.value.end_time = searchForm.value.judged_at_range[1]
} else {
searchForm.value.start_time = ''
searchForm.value.end_time = ''
}
}
const getTableData = async () => {
if (!canAccess.value) return
const params: CarrierTrafficThresholdHitQueryParams = {
carrier_id: searchForm.value.carrier_id ?? undefined,
card_id: searchForm.value.card_id ?? undefined,
period_start: searchForm.value.period_start || undefined,
start_time: searchForm.value.start_time || undefined,
end_time: searchForm.value.end_time || undefined,
page: pagination.page,
page_size: pagination.page_size
}
loading.value = true
try {
const res = await CarrierTrafficThresholdHitService.getHits(params)
if (res.code === 0 && res.data) {
hitList.value = res.data.items || []
pagination.page = res.data.page || pagination.page
pagination.page_size = res.data.size || pagination.page_size
pagination.total = res.data.total || 0
} else {
ElMessage.error(res.msg || '查询通道阈值命中记录失败')
}
} catch (error: any) {
if (error?.status === 403 || error?.response?.status === 403) {
ElMessage.error('当前账号无权查看通道阈值命中记录')
} else {
ElMessage.error('查询通道阈值命中记录失败,请稍后重试')
}
} finally {
loading.value = false
}
}
const handleSearch = () => {
syncJudgedAtRange()
pagination.page = 1
void getTableData()
}
const handleReset = () => {
searchForm.value = { ...initialSearchState, judged_at_range: [] }
pagination.page = 1
void getTableData()
}
const handleRefresh = () => void getTableData()
const handleSizeChange = (pageSize: number) => {
pagination.page_size = Math.min(pageSize, 100)
pagination.page = 1
void getTableData()
}
const handleCurrentChange = (page: number) => {
pagination.page = page
void getTableData()
}
onMounted(() => {
if (canAccess.value) {
void getTableData()
void searchCarriers()
void searchCards()
}
})
</script>
<style scoped lang="scss">
.carrier-threshold-hit-page {
height: 100%;
}
</style>