fetch(add): 运营商管理
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 2m23s

This commit is contained in:
sexygoat
2026-01-27 16:06:48 +08:00
parent c07e481b5b
commit 6127b21c2c
20 changed files with 1502 additions and 42 deletions

View File

@@ -0,0 +1,38 @@
# Change: 新增运营商管理功能
## Why
当前系统缺少对运营商基础信息的统一管理能力。需要提供一个集中的运营商管理模块,用于维护运营商的基础信息(名称、编码、类型、描述等),以便后续在网卡、套餐等业务模块中关联使用。
运营商管理是物联网卡管理系统的基础数据模块,需要支持对运营商的 CRUD 操作以及状态管理,方便运营人员统一维护运营商信息。
## What Changes
- 新增运营商管理 API 服务层CarrierService
- 新增运营商相关 TypeScript 类型定义
- 新增运营商管理页面,支持以下功能:
- 运营商列表查询(支持按名称、类型、状态筛选)
- 创建运营商
- 编辑运营商信息
- 删除运营商
- 状态切换(启用/禁用)
- 查看运营商详情
- 新增运营商类型常量配置CMCC/CUCC/CTCC/CBN
- 在账户管理菜单下新增运营商管理入口
- 新增路由配置
## Impact
- **新增文件**:
- `src/api/modules/carrier.ts` - API 服务层
- `src/types/api/carrier.ts` - TypeScript 类型定义
- `src/views/finance/carrier-management/index.vue` - 运营商管理页面
- `src/config/constants/carrierTypes.ts` - 运营商类型常量
- **修改文件**:
- `src/api/modules/index.ts` - 导出 CarrierService
- `src/types/api/index.ts` - 导出 carrier 类型
- `src/router/routesAlias.ts` - 新增路由别名
- `src/router/routes/asyncRoutes.ts` - 新增路由配置
- `src/config/constants/index.ts` - 导出运营商类型常量
- **受影响的业务模块**: 账户管理
- **不涉及破坏性变更**

View File

@@ -0,0 +1,200 @@
# Carrier Management Specification
## ADDED Requirements
### Requirement: 运营商列表查询
系统 SHALL 提供运营商列表查询功能,支持分页和多条件筛选。
#### Scenario: 获取所有运营商列表
- **WHEN** 用户访问运营商管理页面
- **THEN** 系统应显示运营商列表,包含运营商 ID、名称、编码、类型、描述、状态、创建时间、更新时间
- **AND** 列表应支持分页展示
#### Scenario: 按运营商名称模糊搜索
- **WHEN** 用户在搜索框输入运营商名称并点击搜索
- **THEN** 系统应返回名称匹配的运营商列表
- **AND** 支持模糊匹配
#### Scenario: 按运营商类型筛选
- **WHEN** 用户选择运营商类型CMCC/CUCC/CTCC/CBN并点击搜索
- **THEN** 系统应返回指定类型的运营商列表
#### Scenario: 按状态筛选
- **WHEN** 用户选择状态(启用/禁用)并点击搜索
- **THEN** 系统应返回指定状态的运营商列表
#### Scenario: 组合条件筛选
- **WHEN** 用户同时指定多个筛选条件
- **THEN** 系统应返回满足所有条件的运营商列表
### Requirement: 创建运营商
系统 SHALL 提供创建运营商的功能,允许运营人员添加新的运营商信息。
#### Scenario: 成功创建运营商
- **WHEN** 用户点击"新增运营商"按钮并填写完整的必填信息(运营商编码、运营商名称、运营商类型)
- **THEN** 系统应创建新的运营商记录
- **AND** 显示成功消息
- **AND** 刷新运营商列表
#### Scenario: 创建运营商时必填字段验证
- **WHEN** 用户提交创建表单但缺少必填字段
- **THEN** 系统应显示验证错误消息
- **AND** 阻止表单提交
#### Scenario: 运营商编码长度验证
- **WHEN** 用户输入的运营商编码长度不在 1-50 个字符之间
- **THEN** 系统应显示验证错误消息
#### Scenario: 运营商名称长度验证
- **WHEN** 用户输入的运营商名称长度不在 1-100 个字符之间
- **THEN** 系统应显示验证错误消息
#### Scenario: 运营商描述长度验证
- **WHEN** 用户输入的运营商描述超过 500 个字符
- **THEN** 系统应显示验证错误消息
### Requirement: 编辑运营商
系统 SHALL 允许用户编辑现有运营商的信息。
#### Scenario: 成功编辑运营商
- **WHEN** 用户点击编辑按钮并修改运营商信息后提交
- **THEN** 系统应更新运营商记录
- **AND** 显示成功消息
- **AND** 刷新运营商列表
#### Scenario: 编辑时只能修改名称和描述
- **WHEN** 用户编辑运营商时
- **THEN** 系统应只允许修改运营商名称和描述
- **AND** 运营商编码、运营商类型应不可修改
#### Scenario: 编辑时表单验证
- **WHEN** 用户修改运营商信息但不符合验证规则
- **THEN** 系统应显示验证错误消息
- **AND** 阻止表单提交
### Requirement: 删除运营商
系统 SHALL 允许用户删除运营商记录。
#### Scenario: 成功删除运营商
- **WHEN** 用户点击删除按钮并确认删除操作
- **THEN** 系统应删除该运营商记录
- **AND** 显示成功消息
- **AND** 刷新运营商列表
#### Scenario: 删除前二次确认
- **WHEN** 用户点击删除按钮
- **THEN** 系统应显示确认对话框
- **AND** 提示用户确认删除操作
#### Scenario: 取消删除操作
- **WHEN** 用户在确认对话框中点击取消
- **THEN** 系统应取消删除操作
- **AND** 保留运营商记录
### Requirement: 运营商状态管理
系统 SHALL 提供运营商状态切换功能(启用/禁用)。
#### Scenario: 成功切换运营商状态
- **WHEN** 用户点击状态开关
- **THEN** 系统应立即更新运营商状态
- **AND** 显示成功消息
- **AND** UI 应反映新的状态
#### Scenario: 状态切换失败时回滚
- **WHEN** 状态切换请求失败
- **THEN** 系统应恢复原状态
- **AND** 显示错误消息
### Requirement: 获取运营商详情
系统 SHALL 提供获取单个运营商详细信息的功能。
#### Scenario: 成功获取运营商详情
- **WHEN** 系统需要获取特定运营商的详细信息
- **THEN** 系统应返回该运营商的完整信息
- **AND** 包含运营商 ID、编码、名称、类型、描述、状态、创建时间、更新时间
### Requirement: 运营商类型定义
系统 SHALL 支持以下运营商类型。
#### Scenario: 运营商类型枚举
- **WHEN** 系统处理运营商类型
- **THEN** 系统应支持以下类型:
- CMCC: 中国移动
- CUCC: 中国联通
- CTCC: 中国电信
- CBN: 中国广电
### Requirement: 数据展示和格式化
系统 SHALL 正确展示和格式化运营商数据。
#### Scenario: 运营商类型显示
- **WHEN** 在列表中显示运营商类型
- **THEN** 系统应将类型代码转换为可读的中文名称
- **AND** 使用不同的标签颜色区分不同类型
#### Scenario: 状态显示
- **WHEN** 在列表中显示运营商状态
- **THEN** 系统应使用开关组件展示状态
- **AND** 启用状态显示为绿色"启用"
- **AND** 禁用状态显示为红色"禁用"
#### Scenario: 时间格式化
- **WHEN** 显示创建时间和更新时间
- **THEN** 系统应将时间格式化为 YYYY-MM-DD HH:mm:ss 格式
### Requirement: 权限控制
系统 SHALL 对运营商管理功能进行权限控制。
#### Scenario: 基于角色的访问控制
- **WHEN** 用户访问运营商管理页面
- **THEN** 系统应验证用户是否有访问权限
- **AND** 无权限用户应被重定向到 403 页面
### Requirement: 错误处理
系统 SHALL 正确处理各种错误情况。
#### Scenario: API 请求失败处理
- **WHEN** API 请求失败
- **THEN** 系统应显示友好的错误消息
- **AND** 不应中断用户操作流程
#### Scenario: 网络错误处理
- **WHEN** 发生网络错误
- **THEN** 系统应提示用户检查网络连接
- **AND** 允许用户重试操作

View File

@@ -0,0 +1,48 @@
# Implementation Tasks
## 1. 类型定义和常量配置
- [x] 1.1 创建运营商类型定义文件 `src/types/api/carrier.ts`
- [x] 1.2 在 `src/types/api/index.ts` 中导出 carrier 类型
- [x] 1.3 创建运营商类型常量 `src/config/constants/carrierTypes.ts`
- [x] 1.4 在 `src/config/constants/index.ts` 中导出运营商类型常量
## 2. API 服务层
- [x] 2.1 创建 CarrierService 类 `src/api/modules/carrier.ts`
- [x] 2.2 实现获取运营商列表接口(支持分页和筛选)
- [x] 2.3 实现创建运营商接口
- [x] 2.4 实现更新运营商接口
- [x] 2.5 实现删除运营商接口
- [x] 2.6 实现获取运营商详情接口
- [x] 2.7 实现更新运营商状态接口
- [x] 2.8 在 `src/api/modules/index.ts` 中导出 CarrierService
## 3. 路由配置
- [x] 3.1 在 `src/router/routesAlias.ts` 添加运营商管理路由别名
- [x] 3.2 在 `src/router/routes/asyncRoutes.ts` 添加运营商管理路由配置
## 4. 运营商管理页面
- [x] 4.1 创建运营商管理页面组件 `src/views/finance/carrier-management/index.vue`
- [x] 4.2 实现搜索栏(运营商名称、运营商类型、状态筛选)
- [x] 4.3 实现运营商列表表格展示
- [x] 4.4 实现列筛选和表格头部工具栏
- [x] 4.5 实现分页功能
- [x] 4.6 实现新增运营商对话框
- [x] 4.7 实现编辑运营商对话框
- [x] 4.8 实现删除运营商确认
- [x] 4.9 实现状态开关切换
- [x] 4.10 实现表单验证规则
- [x] 4.11 实现数据加载和错误处理
## 5. 集成测试
- [x] 5.1 测试运营商列表查询功能(包括筛选和分页)
- [x] 5.2 测试创建运营商功能
- [x] 5.3 测试编辑运营商功能
- [x] 5.4 测试删除运营商功能
- [x] 5.5 测试状态切换功能
- [x] 5.6 验证表单验证规则是否正确
- [x] 5.7 验证权限控制是否正确

View File

@@ -79,13 +79,21 @@ export class CardService extends BaseService {
}
/**
* 根据ICCID获取单卡信息
* 根据ICCID获取单卡信息(旧接口,用于现有功能)
* @param iccid ICCID
*/
static getCardByIccid(iccid: string): Promise<BaseResponse<Card>> {
return this.getOne<Card>(`/api/cards/iccid/${iccid}`)
}
/**
* 通过ICCID查询单卡详情新接口用于单卡查询页面
* @param iccid ICCID
*/
static getIotCardDetailByIccid(iccid: string): Promise<BaseResponse<any>> {
return this.getOne<any>(`/api/admin/iot-cards/by-iccid/${iccid}`)
}
/**
* 网卡操作(充值、停复机、增减流量等)
* @param params 操作参数

View File

@@ -0,0 +1,73 @@
/**
* 运营商管理相关 API
*/
import { BaseService } from '../BaseService'
import type {
Carrier,
CarrierQueryParams,
CreateCarrierParams,
UpdateCarrierParams,
UpdateCarrierStatusParams,
BaseResponse,
PaginationResponse
} from '@/types/api'
export class CarrierService extends BaseService {
/**
* 获取运营商列表
* GET /api/admin/carriers
* @param params 查询参数
*/
static getCarriers(params?: CarrierQueryParams): Promise<PaginationResponse<Carrier>> {
return this.getPage<Carrier>('/api/admin/carriers', params)
}
/**
* 创建运营商
* POST /api/admin/carriers
* @param data 运营商数据
*/
static createCarrier(data: CreateCarrierParams): Promise<BaseResponse<Carrier>> {
return this.create<Carrier>('/api/admin/carriers', data)
}
/**
* 更新运营商
* PUT /api/admin/carriers/{id}
* @param id 运营商ID
* @param data 运营商数据
*/
static updateCarrier(id: number, data: UpdateCarrierParams): Promise<BaseResponse<Carrier>> {
return this.update<Carrier>(`/api/admin/carriers/${id}`, data)
}
/**
* 删除运营商
* DELETE /api/admin/carriers/{id}
* @param id 运营商ID
*/
static deleteCarrier(id: number): Promise<BaseResponse> {
return this.remove(`/api/admin/carriers/${id}`)
}
/**
* 获取运营商详情
* GET /api/admin/carriers/{id}
* @param id 运营商ID
*/
static getCarrierDetail(id: number): Promise<BaseResponse<Carrier>> {
return this.getOne<Carrier>(`/api/admin/carriers/${id}`)
}
/**
* 更新运营商状态
* PUT /api/admin/carriers/{id}/status
* @param id 运营商ID
* @param status 状态 (1:启用, 0:禁用)
*/
static updateCarrierStatus(id: number, status: number): Promise<BaseResponse> {
const data: UpdateCarrierStatusParams = { status }
return this.update(`/api/admin/carriers/${id}/status`, data)
}
}

View File

@@ -42,6 +42,14 @@ export class DeviceService extends BaseService {
return this.getOne<Device>(`/api/admin/devices/${id}`)
}
/**
* 通过设备号查询设备详情
* @param imei 设备号(IMEI)
*/
static getDeviceByImei(imei: string): Promise<BaseResponse<Device>> {
return this.getOne<Device>(`/api/admin/devices/by-imei/${imei}`)
}
/**
* 删除设备
* @param id 设备ID

View File

@@ -20,6 +20,7 @@ export { CustomerAccountService } from './customerAccount'
export { StorageService } from './storage'
export { AuthorizationService } from './authorization'
export { DeviceService } from './device'
export { CarrierService } from './carrier'
// TODO: 按需添加其他业务模块
// export { PackageService } from './package'

View File

@@ -0,0 +1,32 @@
/**
* 运营商类型配置
*/
import { CarrierType } from '@/types/api'
// 运营商类型选项
export const CARRIER_TYPE_OPTIONS = [
{ label: '中国移动', value: CarrierType.CMCC, color: '#4CAF50' },
{ label: '中国联通', value: CarrierType.CUCC, color: '#2196F3' },
{ label: '中国电信', value: CarrierType.CTCC, color: '#FF9800' },
{ label: '中国广电', value: CarrierType.CBN, color: '#9C27B0' }
]
// 运营商类型映射
export const CARRIER_TYPE_MAP = CARRIER_TYPE_OPTIONS.reduce(
(map, item) => {
map[item.value] = item
return map
},
{} as Record<CarrierType, { label: string; value: CarrierType; color: string }>
)
// 获取运营商类型标签
export function getCarrierTypeLabel(type: CarrierType): string {
return CARRIER_TYPE_MAP[type]?.label || type
}
// 获取运营商类型颜色
export function getCarrierTypeColor(type: CarrierType): string {
return CARRIER_TYPE_MAP[type]?.color || '#666'
}

View File

@@ -19,3 +19,6 @@ export * from './status'
// IoT卡相关
export * from './iotCard'
// 运营商类型相关
export * from './carrierTypes'

View File

@@ -834,6 +834,26 @@ export const asyncRoutes: AppRouteRecord[] = [
}
]
},
{
path: '/my-simcard',
name: 'MySimcard',
component: RoutesAlias.Home,
meta: {
title: '我的网卡',
icon: '&#xe81a;'
},
children: [
{
path: 'single-card',
name: 'SingleCard',
component: RoutesAlias.SingleCard,
meta: {
title: '单卡信息',
keepAlive: true
}
}
]
},
{
path: '/asset-management',
name: 'AssetManagement',
@@ -844,11 +864,20 @@ export const asyncRoutes: AppRouteRecord[] = [
},
children: [
{
path: 'single-card',
name: 'SingleCard',
component: RoutesAlias.SingleCard,
path: 'card-search',
name: 'CardSearch',
component: RoutesAlias.CardSearch,
meta: {
title: 'menus.assetManagement.singleCard',
title: '单卡查询',
keepAlive: true
}
},
{
path: 'device-search',
name: 'DeviceSearch',
component: RoutesAlias.DeviceSearch,
meta: {
title: '设备查询',
keepAlive: true
}
},
@@ -975,6 +1004,15 @@ export const asyncRoutes: AppRouteRecord[] = [
keepAlive: true
}
},
{
path: 'carrier-management',
name: 'CarrierManagement',
component: RoutesAlias.CarrierManagement,
meta: {
title: '运营商管理',
keepAlive: true
}
},
// {
// path: 'my-account',
// name: 'MyAccount',
@@ -1102,24 +1140,24 @@ export const asyncRoutes: AppRouteRecord[] = [
keepAlive: true
}
},
{
path: 'offline-batch-recharge',
name: 'OfflineBatchRecharge',
component: RoutesAlias.OfflineBatchRecharge,
meta: {
title: 'menus.batch.offlineBatchRecharge',
keepAlive: true
}
},
{
path: 'card-change-notice',
name: 'CardChangeNotice',
component: RoutesAlias.CardChangeNotice,
meta: {
title: 'menus.batch.cardChangeNotice',
keepAlive: true
}
}
// {
// path: 'offline-batch-recharge',
// name: 'OfflineBatchRecharge',
// component: RoutesAlias.OfflineBatchRecharge,
// meta: {
// title: 'menus.batch.offlineBatchRecharge',
// keepAlive: true
// }
// },
// {
// path: 'card-change-notice',
// name: 'CardChangeNotice',
// component: RoutesAlias.CardChangeNotice,
// meta: {
// title: 'menus.batch.cardChangeNotice',
// keepAlive: true
// }
// }
]
}
]

View File

@@ -90,6 +90,8 @@ export enum RoutesAlias {
SimCardAssign = '/product/sim-card-assign', // 号卡分配
// 资产管理
CardSearch = '/asset-management/card-search', // 单卡查询
DeviceSearch = '/asset-management/device-search', // 设备查询
StandaloneCardList = '/asset-management/card-list', // 单卡列表(未绑定设备)
TaskManagement = '/asset-management/task-management', // 任务管理
TaskDetail = '/asset-management/task-detail', // 任务详情
@@ -104,6 +106,7 @@ export enum RoutesAlias {
// 账户管理
CustomerAccountList = '/finance/customer-account', // 客户账号
MyAccount = '/finance/my-account', // 我的账户
CarrierManagement = '/finance/carrier-management', // 运营商管理
// 佣金管理
WithdrawalApproval = '/finance/commission/withdrawal-approval', // 提现审批

View File

@@ -449,3 +449,32 @@ export interface AssetAllocationRecord {
export interface AssetAllocationRecordDetail extends AssetAllocationRecord {
related_card_ids: number[] // 关联卡ID列表
}
// ========== 单卡详情查询相关 ==========
// IoT卡详情响应对应 /api/admin/iot-cards/by-iccid/{iccid} 接口)
export interface IotCardDetailResponse {
id: number // 卡ID
iccid: string // ICCID
imsi: string // IMSI
msisdn: string // 卡接入号
carrier_id: number // 运营商ID
carrier_name: string // 运营商名称
carrier_type: string // 运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)
card_type: string // 卡类型
card_category: string // 卡业务类型 (normal:普通卡, industry:行业卡)
status: number // 状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)
activation_status: number // 激活状态 (0:未激活, 1:已激活)
network_status: number // 网络状态 (0:停机, 1:开机)
real_name_status: number // 实名状态 (0:未实名, 1:已实名)
batch_no: string // 批次号
supplier: string // 供应商
shop_id: number | null // 店铺ID
shop_name: string // 店铺名称
cost_price: number // 成本价(分)
distribute_price: number // 分销价(分)
data_usage_mb: number // 累计流量使用(MB)
activated_at: string | null // 激活时间
created_at: string // 创建时间
updated_at: string // 更新时间
}

51
src/types/api/carrier.ts Normal file
View File

@@ -0,0 +1,51 @@
/**
* 运营商管理相关类型定义
*/
import type { PaginationParams } from './common'
// 运营商类型枚举
export enum CarrierType {
CMCC = 'CMCC', // 中国移动
CUCC = 'CUCC', // 中国联通
CTCC = 'CTCC', // 中国电信
CBN = 'CBN' // 中国广电
}
// 运营商响应数据
export interface Carrier {
id: number
carrier_code: string
carrier_name: string
carrier_type: CarrierType
description?: string
status: number
created_at: string
updated_at: string
}
// 运营商列表查询参数
export interface CarrierQueryParams extends PaginationParams {
carrier_name?: string
carrier_type?: CarrierType
status?: number
}
// 创建运营商请求参数
export interface CreateCarrierParams {
carrier_code: string
carrier_name: string
carrier_type: CarrierType
description?: string
}
// 更新运营商请求参数
export interface UpdateCarrierParams {
carrier_name?: string
description?: string
}
// 更新运营商状态参数
export interface UpdateCarrierStatusParams {
status: number
}

View File

@@ -52,3 +52,6 @@ export * from './authorization'
// 企业卡授权相关
export * from './enterpriseCard'
// 运营商相关
export * from './carrier'

View File

@@ -0,0 +1,208 @@
<template>
<div class="card-search-page">
<!-- 搜索区域 -->
<ElCard shadow="never" class="search-card">
<template #header>
<div class="card-header">
<span>单卡查询</span>
</div>
</template>
<div class="search-content">
<ElForm :model="searchForm" label-width="100px">
<ElFormItem label="ICCID">
<ElInput
v-model="searchForm.iccid"
placeholder="请输入ICCID"
clearable
@keyup.enter="handleSearch"
>
<template #append>
<ElButton type="primary" :loading="loading" @click="handleSearch">
查询
</ElButton>
</template>
</ElInput>
</ElFormItem>
</ElForm>
</div>
</ElCard>
<!-- 卡片详情区域 -->
<ElCard v-if="cardDetail" shadow="never" class="detail-card" style="margin-top: 16px">
<template #header>
<div class="card-header">
<span>卡片详情</span>
</div>
</template>
<ElDescriptions :column="3" border>
<ElDescriptionsItem label="卡ID">{{ cardDetail.id }}</ElDescriptionsItem>
<ElDescriptionsItem label="ICCID" :span="2">{{ cardDetail.iccid }}</ElDescriptionsItem>
<ElDescriptionsItem label="IMSI">{{ cardDetail.imsi || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="卡接入号">{{ cardDetail.msisdn || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="运营商">{{ cardDetail.carrier_name }}</ElDescriptionsItem>
<ElDescriptionsItem label="运营商类型">{{ getCarrierTypeText(cardDetail.carrier_type) }}</ElDescriptionsItem>
<ElDescriptionsItem label="卡类型">{{ cardDetail.card_type }}</ElDescriptionsItem>
<ElDescriptionsItem label="卡业务类型">{{ getCardCategoryText(cardDetail.card_category) }}</ElDescriptionsItem>
<ElDescriptionsItem label="状态">
<ElTag :type="getStatusTagType(cardDetail.status)">
{{ getStatusText(cardDetail.status) }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="激活状态">
<ElTag :type="cardDetail.activation_status === 1 ? 'success' : 'info'">
{{ cardDetail.activation_status === 1 ? '已激活' : '未激活' }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="网络状态">
<ElTag :type="cardDetail.network_status === 1 ? 'success' : 'danger'">
{{ cardDetail.network_status === 1 ? '开机' : '停机' }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="实名状态">
<ElTag :type="cardDetail.real_name_status === 1 ? 'success' : 'warning'">
{{ cardDetail.real_name_status === 1 ? '已实名' : '未实名' }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="批次号">{{ cardDetail.batch_no }}</ElDescriptionsItem>
<ElDescriptionsItem label="供应商">{{ cardDetail.supplier || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="店铺名称">{{ cardDetail.shop_name || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="成本价">{{ formatPrice(cardDetail.cost_price) }}</ElDescriptionsItem>
<ElDescriptionsItem label="分销价">{{ formatPrice(cardDetail.distribute_price) }}</ElDescriptionsItem>
<ElDescriptionsItem label="累计流量使用">{{ cardDetail.data_usage_mb }} MB</ElDescriptionsItem>
<ElDescriptionsItem label="激活时间">{{ cardDetail.activated_at || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">{{ cardDetail.created_at }}</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
<!-- 空状态 -->
<ElEmpty v-if="searched && !cardDetail && !loading" description="未找到相关卡片信息" />
</div>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { CardService } from '@/api/modules/card'
defineOptions({ name: 'CardSearch' })
const loading = ref(false)
const searched = ref(false)
const searchForm = reactive({
iccid: ''
})
const cardDetail = ref<any>(null)
// 查询卡片详情
const handleSearch = async () => {
if (!searchForm.iccid.trim()) {
ElMessage.warning('请输入ICCID')
return
}
loading.value = true
searched.value = true
cardDetail.value = null
try {
const res = await CardService.getIotCardDetailByIccid(searchForm.iccid.trim())
if (res.code === 0 && res.data) {
cardDetail.value = res.data
ElMessage.success('查询成功')
} else {
ElMessage.error(res.message || '查询失败')
}
} catch (error: any) {
console.error('查询卡片详情失败:', error)
ElMessage.error(error?.message || '查询失败请检查ICCID是否正确')
} finally {
loading.value = false
}
}
// 获取运营商类型文本
const getCarrierTypeText = (type: string) => {
const typeMap: Record<string, string> = {
CMCC: '中国移动',
CUCC: '中国联通',
CTCC: '中国电信',
CBN: '中国广电'
}
return typeMap[type] || type
}
// 获取卡业务类型文本
const getCardCategoryText = (category: string) => {
const categoryMap: Record<string, string> = {
normal: '普通卡',
industry: '行业卡'
}
return categoryMap[category] || category
}
// 获取状态文本
const getStatusText = (status: number) => {
const statusMap: Record<number, string> = {
1: '在库',
2: '已分销',
3: '已激活',
4: '已停用'
}
return statusMap[status] || '未知'
}
// 获取状态标签类型
const getStatusTagType = (status: number) => {
const typeMap: Record<number, any> = {
1: 'info',
2: 'warning',
3: 'success',
4: 'danger'
}
return typeMap[status] || 'info'
}
// 格式化价格(分转元)
const formatPrice = (price: number) => {
return `¥${(price / 100).toFixed(2)}`
}
</script>
<style lang="scss" scoped>
.card-search-page {
padding: 16px;
.card-header {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.search-content {
max-width: 800px;
}
.detail-card {
animation: fadeIn 0.3s ease-in;
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -258,14 +258,14 @@
type: 'select',
config: {
clearable: true,
placeholder: '请选择状态',
options: [
{ label: '在库', value: 1 },
{ label: '已分销', value: 2 },
{ label: '已激活', value: 3 },
{ label: '已停用', value: 4 }
]
}
placeholder: '请选择状态'
},
options: () => [
{ label: '在库', value: 1 },
{ label: '已分销', value: 2 },
{ label: '已激活', value: 3 },
{ label: '已停用', value: 4 }
]
},
{
label: '批次号',

View File

@@ -0,0 +1,152 @@
<template>
<div class="device-search-page">
<!-- 搜索区域 -->
<ElCard shadow="never" class="search-card">
<template #header>
<div class="card-header">
<span>设备查询</span>
</div>
</template>
<div class="search-content">
<ElForm :model="searchForm" label-width="100px">
<ElFormItem label="设备号(IMEI)">
<ElInput
v-model="searchForm.imei"
placeholder="请输入设备号(IMEI)"
clearable
@keyup.enter="handleSearch"
>
<template #append>
<ElButton type="primary" :loading="loading" @click="handleSearch">
查询
</ElButton>
</template>
</ElInput>
</ElFormItem>
</ElForm>
</div>
</ElCard>
<!-- 设备详情区域 -->
<ElCard v-if="deviceDetail" shadow="never" class="detail-card" style="margin-top: 16px">
<template #header>
<div class="card-header">
<span>设备详情</span>
</div>
</template>
<ElDescriptions :column="3" border>
<ElDescriptionsItem label="设备ID">{{ deviceDetail.id }}</ElDescriptionsItem>
<ElDescriptionsItem label="设备号" :span="2">{{ deviceDetail.device_no }}</ElDescriptionsItem>
<ElDescriptionsItem label="设备名称">{{ deviceDetail.device_name || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="设备型号">{{ deviceDetail.device_model || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="设备类型">{{ deviceDetail.device_type || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="制造商">{{ deviceDetail.manufacturer || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="最大插槽数">{{ deviceDetail.max_sim_slots }}</ElDescriptionsItem>
<ElDescriptionsItem label="已绑定卡数量">{{ deviceDetail.bound_card_count }}</ElDescriptionsItem>
<ElDescriptionsItem label="状态">
<ElTag :type="getStatusTagType(deviceDetail.status)">
{{ deviceDetail.status_name }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="店铺名称">{{ deviceDetail.shop_name || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="批次号">{{ deviceDetail.batch_no }}</ElDescriptionsItem>
<ElDescriptionsItem label="激活时间">{{ deviceDetail.activated_at || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">{{ deviceDetail.created_at }}</ElDescriptionsItem>
<ElDescriptionsItem label="更新时间">{{ deviceDetail.updated_at }}</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
<!-- 空状态 -->
<ElEmpty v-if="searched && !deviceDetail && !loading" description="未找到相关设备信息" />
</div>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { DeviceService } from '@/api/modules/device'
defineOptions({ name: 'DeviceSearch' })
const loading = ref(false)
const searched = ref(false)
const searchForm = reactive({
imei: ''
})
const deviceDetail = ref<any>(null)
// 查询设备详情
const handleSearch = async () => {
if (!searchForm.imei.trim()) {
ElMessage.warning('请输入设备号(IMEI)')
return
}
loading.value = true
searched.value = true
deviceDetail.value = null
try {
const res = await DeviceService.getDeviceByImei(searchForm.imei.trim())
if (res.code === 0 && res.data) {
deviceDetail.value = res.data
ElMessage.success('查询成功')
} else {
ElMessage.error(res.message || '查询失败')
}
} catch (error: any) {
console.error('查询设备详情失败:', error)
ElMessage.error(error?.message || '查询失败,请检查设备号是否正确')
} finally {
loading.value = false
}
}
// 获取状态标签类型
const getStatusTagType = (status: number) => {
const typeMap: Record<number, any> = {
1: 'info', // 在库
2: 'warning', // 已分销
3: 'success', // 已激活
4: 'danger' // 已停用
}
return typeMap[status] || 'info'
}
</script>
<style lang="scss" scoped>
.device-search-page {
padding: 16px;
.card-header {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.search-content {
max-width: 800px;
}
.detail-card {
animation: fadeIn 0.3s ease-in;
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -140,18 +140,15 @@
const contextMenuItems: MenuItemType[] = [
{
key: 'cardOperation',
label: '卡务操作',
icon: '&#xe72b;'
label: '卡务操作'
},
{
key: 'download',
label: '下载',
icon: '&#xe614;'
label: '下载'
},
{
key: 'delete',
label: '删除',
icon: '&#xe783;',
showLine: true
}
]

View File

@@ -0,0 +1,464 @@
<template>
<ArtTableFullScreen>
<div class="carrier-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
label-width="85"
:show-expand="false"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
<ElCard shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<ElButton @click="showDialog('add')">新增运营商</ElButton>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="carrierList"
:currentPage="pagination.page"
:pageSize="pagination.pageSize"
: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>
<!-- 新增/编辑对话框 -->
<ElDialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '新增运营商' : '编辑运营商'"
width="30%"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="120px">
<ElFormItem label="运营商编码" prop="carrier_code">
<ElInput
v-model="form.carrier_code"
placeholder="请输入运营商编码"
:disabled="dialogType === 'edit'"
/>
</ElFormItem>
<ElFormItem label="运营商名称" prop="carrier_name">
<ElInput v-model="form.carrier_name" placeholder="请输入运营商名称" />
</ElFormItem>
<ElFormItem label="运营商类型" prop="carrier_type">
<ElSelect
v-model="form.carrier_type"
placeholder="请选择运营商类型"
style="width: 100%"
:disabled="dialogType === 'edit'"
>
<ElOption
v-for="item in CARRIER_TYPE_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="运营商描述" prop="description">
<ElInput
v-model="form.description"
type="textarea"
:rows="3"
placeholder="请输入运营商描述"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit(formRef)" :loading="submitLoading">
提交
</ElButton>
</div>
</template>
</ElDialog>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h, watch, nextTick } from 'vue'
import { CarrierService } from '@/api/modules'
import { ElMessage, ElMessageBox, ElTag, ElSwitch } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import type { Carrier, CarrierType } from '@/types/api'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { formatDateTime } from '@/utils/business/format'
import {
CommonStatus,
getStatusText,
CARRIER_TYPE_OPTIONS,
getCarrierTypeLabel,
getCarrierTypeColor
} from '@/config/constants'
defineOptions({ name: 'CarrierManagement' })
const dialogVisible = ref(false)
const loading = ref(false)
const submitLoading = ref(false)
const tableRef = ref()
// 搜索表单初始值
const initialSearchState = {
carrier_name: '',
carrier_type: null,
status: null
}
// 搜索表单
const searchForm = reactive({ ...initialSearchState })
// 搜索表单配置
const searchFormItems: SearchFormItem[] = [
{
label: '运营商名称',
prop: 'carrier_name',
type: 'input',
config: {
clearable: true,
placeholder: '请输入运营商名称'
}
},
{
label: '运营商类型',
prop: 'carrier_type',
type: 'select',
config: {
clearable: true,
placeholder: '请选择运营商类型'
},
options: () => [
{ label: '中国移动', value: 'CMCC' },
{ label: '中国联通', value: 'CUCC' },
{ label: '中国电信', value: 'CTCC' },
{ label: '中国广电', value: 'CBN' }
]
},
{
label: '状态',
prop: 'status',
type: 'select',
config: {
clearable: true,
placeholder: '请选择状态'
},
options: () => [
{ label: '启用', value: CommonStatus.ENABLED },
{ label: '禁用', value: CommonStatus.DISABLED }
]
}
]
// 分页
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
// 列配置
const columnOptions = [
{ label: '运营商编码', prop: 'carrier_code' },
{ label: '运营商名称', prop: 'carrier_name' },
{ label: '运营商类型', prop: 'carrier_type' },
{ label: '运营商描述', prop: 'description' },
{ label: '状态', prop: 'status' },
{ label: '创建时间', prop: 'created_at' },
{ label: '操作', prop: 'operation' }
]
const formRef = ref<FormInstance>()
const rules = reactive<FormRules>({
carrier_code: [
{ required: true, message: '请输入运营商编码', trigger: 'blur' },
{ min: 1, max: 50, message: '长度在 1 到 50 个字符', trigger: 'blur' }
],
carrier_name: [
{ required: true, message: '请输入运营商名称', trigger: 'blur' },
{ min: 1, max: 100, message: '长度在 1 到 100 个字符', trigger: 'blur' }
],
carrier_type: [{ required: true, message: '请选择运营商类型', trigger: 'change' }],
description: [{ max: 500, message: '描述不能超过500个字符', trigger: 'blur' }]
})
const form = reactive<any>({
id: 0,
carrier_code: '',
carrier_name: '',
carrier_type: null,
description: ''
})
const carrierList = ref<Carrier[]>([])
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'carrier_code',
label: '运营商编码',
minWidth: 160
},
{
prop: 'carrier_name',
label: '运营商名称',
minWidth: 150
},
{
prop: 'carrier_type',
label: '运营商类型',
width: 120,
formatter: (row: any) => {
const color = getCarrierTypeColor(row.carrier_type)
return h(
ElTag,
{ style: `background-color: ${color}; border-color: ${color}; color: #fff;` },
() => getCarrierTypeLabel(row.carrier_type)
)
}
},
{
prop: 'description',
label: '运营商描述',
minWidth: 200,
showOverflowTooltip: true
},
{
prop: 'status',
label: '状态',
width: 100,
formatter: (row: any) => {
return h(ElSwitch, {
modelValue: row.status,
activeValue: CommonStatus.ENABLED,
inactiveValue: CommonStatus.DISABLED,
activeText: getStatusText(CommonStatus.ENABLED),
inactiveText: getStatusText(CommonStatus.DISABLED),
inlinePrompt: true,
'onUpdate:modelValue': (val: string | number | boolean) =>
handleStatusChange(row, val as number)
})
}
},
{
prop: 'created_at',
label: '创建时间',
width: 180,
formatter: (row: any) => formatDateTime(row.created_at)
},
{
prop: 'operation',
label: '操作',
width: 150,
fixed: 'right',
formatter: (row: any) => {
return h('div', { style: 'display: flex; gap: 8px;' }, [
h(ArtButtonTable, {
type: 'edit',
onClick: () => showDialog('edit', row)
}),
h(ArtButtonTable, {
type: 'delete',
onClick: () => deleteCarrier(row)
})
])
}
}
])
onMounted(() => {
getTableData()
})
// 监听对话框关闭,清除验证状态
watch(dialogVisible, (val) => {
if (!val) {
nextTick(() => {
formRef.value?.clearValidate()
})
}
})
// 获取运营商列表
const getTableData = async () => {
loading.value = true
try {
const params = {
page: pagination.page,
page_size: pagination.pageSize,
carrier_name: searchForm.carrier_name || undefined,
carrier_type: searchForm.carrier_type || undefined,
status: searchForm.status !== null ? searchForm.status : undefined
}
const res = await CarrierService.getCarriers(params)
if (res.code === 0) {
carrierList.value = res.data.items || []
pagination.total = res.data.total || 0
}
} catch (error) {
console.error(error)
} finally {
loading.value = false
}
}
// 重置搜索
const handleReset = () => {
Object.assign(searchForm, { ...initialSearchState })
pagination.page = 1
getTableData()
}
// 搜索
const handleSearch = () => {
pagination.page = 1
getTableData()
}
// 刷新表格
const handleRefresh = () => {
getTableData()
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getTableData()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.page = newCurrentPage
getTableData()
}
const dialogType = ref('add')
// 显示新增/编辑对话框
const showDialog = (type: string, row?: any) => {
dialogVisible.value = true
dialogType.value = type
if (type === 'edit' && row) {
form.id = row.id
form.carrier_code = row.carrier_code
form.carrier_name = row.carrier_name
form.carrier_type = row.carrier_type
form.description = row.description
} else {
form.id = 0
form.carrier_code = ''
form.carrier_name = ''
form.carrier_type = null
form.description = ''
}
// 清除表单验证状态
nextTick(() => {
formRef.value?.clearValidate()
})
}
// 删除运营商
const deleteCarrier = (row: any) => {
ElMessageBox.confirm(`确定删除运营商 ${row.carrier_name} 吗?`, '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
})
.then(async () => {
try {
await CarrierService.deleteCarrier(row.id)
ElMessage.success('删除成功')
await getTableData()
} catch (error) {
console.error(error)
}
})
.catch(() => {
// 用户取消删除
})
}
// 提交表单
const handleSubmit = async (formEl: FormInstance | undefined) => {
if (!formEl) return
await formEl.validate(async (valid) => {
if (valid) {
submitLoading.value = true
try {
const data = {
carrier_code: form.carrier_code,
carrier_name: form.carrier_name,
carrier_type: form.carrier_type as CarrierType,
description: form.description || undefined
}
if (dialogType.value === 'add') {
await CarrierService.createCarrier(data)
ElMessage.success('新增成功')
} else {
const updateData = {
carrier_name: form.carrier_name,
description: form.description || undefined
}
await CarrierService.updateCarrier(form.id, updateData)
ElMessage.success('修改成功')
}
dialogVisible.value = false
formEl.resetFields()
await getTableData()
} catch (error) {
console.error(error)
} finally {
submitLoading.value = false
}
}
})
}
// 状态切换
const handleStatusChange = async (row: any, newStatus: number) => {
const oldStatus = row.status
// 先更新UI
row.status = newStatus
try {
await CarrierService.updateCarrierStatus(row.id, newStatus)
ElMessage.success('状态切换成功')
} catch (error) {
// 切换失败,恢复原状态
row.status = oldStatus
console.error(error)
}
}
</script>
<style scoped lang="scss">
.carrier-page {
height: 100%;
}
</style>

View File

@@ -177,16 +177,32 @@
<div class="operation-group primary-operations">
<h4 class="group-title">主要操作</h4>
<div class="operation-buttons">
<ElButton @click="handleOperation('recharge')" class="operation-btn">
<ElButton
@click="handleOperation('recharge')"
:loading="operationLoading"
class="operation-btn"
>
套餐充值
</ElButton>
<ElButton @click="handleOperation('activate')" class="operation-btn">
<ElButton
@click="handleOperation('activate')"
:loading="operationLoading"
class="operation-btn"
>
激活
</ElButton>
<ElButton @click="handleOperation('suspend')" class="operation-btn">
<ElButton
@click="handleOperation('suspend')"
:loading="operationLoading"
class="operation-btn"
>
保号停机
</ElButton>
<ElButton @click="handleOperation('resume')" class="operation-btn">
<ElButton
@click="handleOperation('resume')"
:loading="operationLoading"
class="operation-btn"
>
保号复机
</ElButton>
</div>
@@ -293,20 +309,39 @@
ElEmpty,
ElSkeleton,
ElDescriptions,
ElDescriptionsItem
ElDescriptionsItem,
ElMessageBox
} from 'element-plus'
import { useRoute } from 'vue-router'
import { EnterpriseService } from '@/api/modules/enterprise'
defineOptions({ name: 'SingleCard' })
const route = useRoute()
const loading = ref(true)
const operationLoading = ref(false)
// 从 URL 获取参数
const enterpriseId = computed(() => {
const id = route.query.enterpriseId || route.query.enterprise_id
return id ? Number(id) : null
})
const cardId = computed(() => {
const urlId = route.query.cardId || route.query.card_id
if (urlId) {
return Number(urlId)
}
// 如果URL中没有,从卡片数据中获取
return cardInfo.value?.id ? Number(cardInfo.value.id) : null
})
// 卡片信息
const cardInfo = ref<any>(null)
// 模拟卡片数据
const mockCardData = {
id: 1, // 卡片ID
iccid: '8986062357007989203',
accessNumber: '1440012345678',
imei: '860123456789012',
@@ -411,7 +446,7 @@
}
// 处理操作按钮点击
const handleOperation = (operation: string) => {
const handleOperation = async (operation: string) => {
if (!cardInfo.value) {
ElMessage.warning('请先查询卡片信息')
return
@@ -439,6 +474,75 @@
deviceOperation: '设备操作'
}
// 保号停机
if (operation === 'suspend') {
// 检查必需的参数
if (!enterpriseId.value || !cardId.value) {
ElMessage.error('缺少必需的参数企业ID或卡片ID')
return
}
try {
await ElMessageBox.confirm(
`确认要对卡片 ${cardInfo.value.iccid} 进行保号停机操作吗?`,
'保号停机确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
operationLoading.value = true
await EnterpriseService.suspendCard(enterpriseId.value, cardId.value)
ElMessage.success('保号停机操作成功')
// 刷新卡片信息
await fetchCardDetail()
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error?.message || '保号停机操作失败')
}
} finally {
operationLoading.value = false
}
return
}
// 保号复机
if (operation === 'resume') {
// 检查必需的参数
if (!enterpriseId.value || !cardId.value) {
ElMessage.error('缺少必需的参数企业ID或卡片ID')
return
}
try {
await ElMessageBox.confirm(
`确认要对卡片 ${cardInfo.value.iccid} 进行保号复机操作吗?`,
'保号复机确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
operationLoading.value = true
await EnterpriseService.resumeCard(enterpriseId.value, cardId.value)
ElMessage.success('保号复机操作成功')
// 刷新卡片信息
await fetchCardDetail()
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error?.message || '保号复机操作失败')
}
} finally {
operationLoading.value = false
}
return
}
// 其他操作暂时只显示提示
ElMessage.info(`执行${operationNames[operation] || operation}操作`)
}
</script>