fix(operator): fix operator edit issue
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m35s

This commit is contained in:
sexygoat
2026-04-10 09:50:00 +08:00
parent c2c1644799
commit 9a6f085cde
20 changed files with 4082 additions and 2782 deletions

View File

@@ -20,6 +20,8 @@ export interface Carrier {
carrier_type: CarrierType
description?: string
data_reset_day?: number | null // 上游流量重置日(1-28)
realname_link_type?: string // 实名链接类型
realname_link_template?: string // 实名链接模板
status: number
created_at: string
updated_at: string
@@ -39,6 +41,8 @@ export interface CreateCarrierParams {
carrier_type: CarrierType
description?: string
data_reset_day?: number | null // 上游流量重置日(1-28)
realname_link_type?: string // 实名链接类型
realname_link_template?: string // 实名链接模板
}
// 更新运营商请求参数
@@ -46,6 +50,8 @@ export interface UpdateCarrierParams {
carrier_name?: string
description?: string
data_reset_day?: number | null // 上游流量重置日(1-28)
realname_link_type?: string // 实名链接类型
realname_link_template?: string // 实名链接模板
}
// 更新运营商状态参数

View File

@@ -0,0 +1,189 @@
<template>
<ElCard shadow="never" class="search-card">
<template #header>
<div class="card-header">
<span>资产查询</span>
</div>
</template>
<div class="iccid-search">
<div class="iccid-input-wrapper">
<ElInput
v-model="searchIccid"
placeholder="请输入虚拟号、ICCID、IMEI、SN或MSISDN"
clearable
style="width: 500px"
@focus="iccidInputFocused = true"
@blur="iccidInputFocused = false"
@keyup.enter="handleSearch"
/>
<!-- 放大显示区域 -->
<Transition name="zoom-fade">
<div v-if="iccidInputFocused && searchIccid" class="iccid-magnifier">
<div class="magnifier-arrow"></div>
<div class="magnifier-content">
{{ formatIccidWithDashes(searchIccid) }}
</div>
</div>
</Transition>
</div>
<ElButton type="primary" @click="handleSearch" :icon="Search" style="margin-left: 16px">
查询
</ElButton>
<!-- 操作按钮组 -->
<div v-if="hasCardInfo" class="operation-button-group">
<ElButton @click="handleRefresh" type="primary" :icon="Refresh"> 刷新 </ElButton>
</div>
</div>
</ElCard>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ElCard, ElInput, ElButton } from 'element-plus'
import { Search, Refresh } from '@element-plus/icons-vue'
import { useAssetFormatters } from '../composables/useAssetFormatters'
// Use formatters
const { formatIccidWithDashes } = useAssetFormatters()
// Props
interface Props {
hasCardInfo?: boolean
}
withDefaults(defineProps<Props>(), {
hasCardInfo: false
})
// Emits
const emit = defineEmits<{
search: [payload: { identifier: string }]
refresh: []
}>()
// State
const searchIccid = ref('')
const iccidInputFocused = ref(false)
// Methods
const handleSearch = () => {
if (!searchIccid.value.trim()) {
return
}
emit('search', { identifier: searchIccid.value.trim() })
}
const handleRefresh = () => {
emit('refresh')
}
</script>
<style scoped lang="scss">
.search-card {
margin-bottom: 20px;
overflow: visible;
background: var(--el-bg-color, #fff);
:deep(.el-card__header) {
padding: 16px 20px;
background: var(--el-fill-color-light);
border-bottom: 1px solid var(--el-border-color-lighter);
}
:deep(.el-card__body) {
padding: 16px 20px;
overflow: visible;
}
.iccid-search {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-start;
.iccid-input-wrapper {
position: relative;
.iccid-magnifier {
position: absolute;
top: calc(100% + 12px);
left: 0;
z-index: 1000;
white-space: nowrap;
// 三角箭头 - 上边框样式
.magnifier-arrow {
position: absolute;
top: -9px;
left: 80px;
width: 16px;
height: 16px;
background-color: var(--el-bg-color, #fff);
border-top: 2px solid var(--el-color-primary);
border-left: 2px solid var(--el-color-primary);
transform: rotate(45deg);
}
// 内容区域
.magnifier-content {
padding: 16px 24px;
font-family: 'Courier New', Consolas, monospace;
font-size: 28px;
font-weight: 600;
line-height: 1.4;
color: var(--el-text-color-primary);
letter-spacing: 3px;
background-color: var(--el-bg-color, #fff);
border: 2px solid var(--el-color-primary);
border-radius: 8px;
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
}
}
}
.operation-button-group {
display: flex;
gap: 12px;
margin-left: auto;
}
}
}
// 放大效果的过渡动画
.zoom-fade-enter-active,
.zoom-fade-leave-active {
transition: all 0.2s ease;
}
.zoom-fade-enter-from {
opacity: 0;
transform: scale(0.8) translateY(-10px);
}
.zoom-fade-leave-to {
opacity: 0;
transform: scale(0.8) translateY(-10px);
}
.zoom-fade-enter-to,
.zoom-fade-leave-from {
opacity: 1;
transform: scale(1) translateY(0);
}
@media (width <= 768px) {
.search-card {
top: 16px;
margin-bottom: 16px;
:deep(.el-card__header) {
padding: 12px 16px;
}
:deep(.el-card__body) {
padding: 12px 16px;
}
}
}
</style>

View File

@@ -0,0 +1,604 @@
<template>
<ElCard shadow="never" class="info-card basic-info">
<template #header>
<div class="card-header">
<div class="card-header-left">
<span>{{ cardInfo?.asset_type === 'card' ? '卡信息' : '设备信息' }}</span>
<ElTag :type="cardInfo?.asset_type === 'card' ? 'success' : 'primary'" size="small">
{{ cardInfo?.asset_type === 'card' ? 'IoT卡' : '设备' }}
</ElTag>
</div>
<div class="card-header-right">
<ElTooltip content="开启后系统将自动轮询更新资产状态" placement="top">
<div class="polling-switch-wrapper">
<span class="polling-label">自动轮询</span>
<ElSwitch
:model-value="pollingEnabled"
@update:model-value="handlePollingChange"
active-text=""
inactive-text=""
inline-prompt
size="default"
/>
</div>
</ElTooltip>
</div>
</div>
</template>
<ElDescriptions :column="4" border>
<!-- 卡专属字段 -->
<template v-if="cardInfo?.asset_type === 'card'">
<ElDescriptionsItem label="ICCID">{{ cardInfo?.iccid || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="运营商账户">{{
cardInfo?.carrier_name || '-'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="实名状态">
<ElTag :type="getRealNameStatusType(cardInfo?.real_name_status)" size="small">
{{ getRealNameStatusName(cardInfo?.real_name_status) }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="店铺">{{ cardInfo?.shop_name || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="套餐系列">{{ cardInfo?.series_name || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="虚拟号">{{ cardInfo?.virtual_no || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="网络状态">
<ElTag :type="cardInfo?.network_status === 1 ? 'success' : 'danger'" size="small">
{{ cardInfo?.network_status === 1 ? '正常' : '停机' }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="本月已用流量">{{
formatDataSize(cardInfo?.current_month_usage_mb || 0)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="实名时间">{{
cardInfo?.real_name_at ? formatDateTime(cardInfo.real_name_at) : '-'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="MSISDN">{{ cardInfo?.msisdn || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="资产状态">
<ElTag :type="getAssetStatusType(cardInfo?.status)" size="small">
{{ getAssetStatusName(cardInfo?.status) }}
</ElTag>
</ElDescriptionsItem>
</template>
<!-- 设备专属字段 -->
<template v-if="cardInfo?.asset_type === 'device'">
<ElDescriptionsItem label="IMEI">{{ cardInfo?.imei || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="设备类型">{{ cardInfo?.device_type || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="实名状态">
<ElTag :type="getRealNameStatusType(currentCard?.real_name_status)" size="small">
{{ getRealNameStatusName(currentCard?.real_name_status) }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="店铺">{{ cardInfo?.shop_name || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="设备名称">{{ cardInfo?.device_name || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="设备状态">
<ElTag :type="getAssetStatusType(cardInfo?.status)" size="small">
{{ getAssetStatusName(cardInfo?.status) }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="虚拟号">{{ cardInfo?.virtual_no || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="SN">{{ cardInfo?.sn || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="设备型号">{{
cardInfo?.device_model || '-'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="供应商">{{ cardInfo?.supplier || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="已绑定卡数">{{
cardInfo?.bound_card_count || 0
}}</ElDescriptionsItem>
<ElDescriptionsItem label="最大卡槽数">{{
cardInfo?.max_sim_slots || '-'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="激活时间">{{
cardInfo?.activated_at || '-'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="实名时间">{{
cardInfo?.real_name_at ? formatDateTime(cardInfo.real_name_at) : '-'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="信号强度">
<ElTag :type="getSignalStrengthType()" size="small">
{{ calculateSignalStrength() }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="在线状态">
<ElTag :type="getOnlineStatusType(cardInfo?.online_status)" size="small">
{{ getOnlineStatusName(cardInfo?.online_status) }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="固件版本">{{
cardInfo?.software_version || '-'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="切卡模式">{{
getSwitchModeName(cardInfo?.switch_mode)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="最后在线时间">{{
formatDateTime(cardInfo?.last_online_time) || '-'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="最后同步时间">{{
formatDateTime(cardInfo?.last_gateway_sync_at) || '-'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="制造商">{{ cardInfo?.manufacturer || '-' }}</ElDescriptionsItem>
</template>
</ElDescriptions>
<!-- 设备绑定卡列表 -->
<template v-if="cardInfo?.asset_type === 'device'">
<ElDivider content-position="left">绑定卡列表</ElDivider>
<ElTable
v-if="cardInfo.cards && cardInfo.cards.length > 0"
:data="sortedDeviceCards"
border
style="margin-top: 16px"
>
<ElTableColumn label="ICCID">
<template #default="scope">
<ElButton type="primary" link @click="handleNavigateToCardInfo(scope.row.iccid)">
{{ scope.row.iccid }}
</ElButton>
</template>
</ElTableColumn>
<ElTableColumn prop="msisdn" label="MSISDN" />
<ElTableColumn prop="carrier_name" label="运营商" width="120" />
<ElTableColumn prop="slot_position" label="卡槽位置" align="center" width="130">
<template #default="scope">
<div style="display: flex; gap: 10px; align-items: center; justify-content: center">
<span>SIM-{{ scope.row.slot_position }}</span>
<ElTag v-if="scope.row.is_current" type="primary" size="small">当前</ElTag>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="网络状态" align="center" width="100">
<template #default="scope">
<ElTag :type="scope.row.network_status === 1 ? 'success' : 'danger'" size="small">
{{ scope.row.network_status === 1 ? '正常' : '停机' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="实名状态" align="center" width="100">
<template #default="scope">
<ElTag :type="getRealNameStatusType(scope.row.real_name_status)" size="small">
{{ getRealNameStatusName(scope.row.real_name_status) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="实名时间" width="170">
<template #default="scope">
{{ scope.row.real_name_at ? formatDateTime(scope.row.real_name_at) : '-' }}
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="200" align="center">
<template #default="scope">
<!-- 根据网络状态显示启用或停用按钮 -->
<ElButton
v-if="scope.row.network_status === 0"
type="success"
size="small"
link
@click="handleEnableBindingCard(scope.row)"
v-permission="'iot_card:start'"
>
启用此卡
</ElButton>
<ElButton
v-if="scope.row.network_status === 1"
type="warning"
size="small"
link
@click="handleDisableBindingCard(scope.row)"
v-permission="'iot_card:stop'"
>
停用此卡
</ElButton>
</template>
</ElTableColumn>
</ElTable>
<ElEmpty v-else description="暂无绑定卡" :image-size="80" style="margin-top: 16px" />
<!-- 设备实时信息 -->
<ElDivider content-position="left">设备实时信息</ElDivider>
<div v-if="deviceRealtime" style="margin-top: 16px">
<ElDescriptions :column="4" border>
<!-- 基本状态 -->
<ElDescriptionsItem label="在线状态">
<ElTag :type="deviceRealtime.online_status === 1 ? 'success' : 'danger'" size="small">
{{ deviceRealtime.online_status === 1 ? '在线' : '离线' }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="设备状态">
<ElTag :type="deviceRealtime.status === 1 ? 'success' : 'info'" size="small">
{{ deviceRealtime.status === 1 ? '正常' : '禁用' }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="电量">
{{
deviceRealtime.battery_level !== null && deviceRealtime.battery_level !== undefined
? `${deviceRealtime.battery_level}%`
: '-'
}}
</ElDescriptionsItem>
<!-- 时间信息 -->
<ElDescriptionsItem label="本次开机时长">
{{ formatDuration(deviceRealtime.run_time) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="本次联网时长">
{{ formatDuration(deviceRealtime.connect_time) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="最后在线时间">
{{ formatDateTime(deviceRealtime.last_online_time) || '-' }}
</ElDescriptionsItem>
<!-- 信号信息 -->
<ElDescriptionsItem label="信号强度(RSSI)">
{{ deviceRealtime.rssi || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="接收功率(RSRP)">
{{ deviceRealtime.rsrp ? `${deviceRealtime.rsrp} dBm` : '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="接收质量(RSRQ)">
{{ deviceRealtime.rsrq ? `${deviceRealtime.rsrq} dB` : '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="信噪比(SINR)">
{{ deviceRealtime.sinr ? `${deviceRealtime.sinr} dB` : '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="最大客户端数">
{{ deviceRealtime.max_clients || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="当前使用ICCID">
{{ deviceRealtime.current_iccid || '-' }}
</ElDescriptionsItem>
<!-- WiFi 信息 -->
<ElDescriptionsItem label="WiFi 状态">
<ElTag :type="deviceRealtime.wifi_enabled ? 'success' : 'info'" size="small">
{{ deviceRealtime.wifi_enabled ? '已开启' : '已关闭' }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="WiFi 名称">
{{ deviceRealtime.ssid || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="WiFi 密码">
{{ deviceRealtime.wifi_password || '-' }}
</ElDescriptionsItem>
<!-- 网络信息 -->
<ElDescriptionsItem label="IP 地址">
{{ deviceRealtime.ip_address || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="WAN IP">
{{ deviceRealtime.wan_ip || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="LAN IP">
{{ deviceRealtime.lan_ip || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="MAC 地址">
{{ deviceRealtime.mac_address || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="IMEI">
{{ deviceRealtime.imei || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="IMSI">
{{ deviceRealtime.imsi || '-' }}
</ElDescriptionsItem>
<!-- 流量信息 -->
<ElDescriptionsItem label="今日流量">
{{ formatDataSize(Number(deviceRealtime.daily_usage || 0) / (1024 * 1024)) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="本次下载">
{{ formatDataSize(Number(deviceRealtime.dl_stats || 0) / (1024 * 1024)) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="本次上传">
{{ formatDataSize(Number(deviceRealtime.ul_stats || 0) / (1024 * 1024)) }}
</ElDescriptionsItem>
<!-- 设备配置 -->
<ElDescriptionsItem label="限速">
{{ deviceRealtime.limit_speed ? `${deviceRealtime.limit_speed} KB/s` : '不限速' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="固件版本">
{{ deviceRealtime.software_version || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="切卡模式">
{{
deviceRealtime.switch_mode === '0'
? '自动'
: deviceRealtime.switch_mode === '1'
? '手动'
: '-'
}}
</ElDescriptionsItem>
<ElDescriptionsItem label="上报周期">
{{ deviceRealtime.sync_interval ? `${deviceRealtime.sync_interval}` : '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="设备ID">
{{ deviceRealtime.device_id || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="信息更新时间">
{{ formatDateTime(deviceRealtime.last_update_time) || '-' }}
</ElDescriptionsItem>
</ElDescriptions>
</div>
<ElAlert
v-else
title="设备实时信息不可用"
type="warning"
:closable="false"
style="margin-top: 16px"
>
<template #default>
<div>Gateway 不可达或设备离线,无法获取实时信息</div>
</template>
</ElAlert>
</template>
<!-- IoT卡操作按钮 -->
<div
v-if="cardInfo?.asset_type === 'card'"
class="card-operations"
style="margin-top: 16px; text-align: right"
>
<ElButton type="success" @click="handleEnableCard"> 启用此卡 </ElButton>
<ElButton type="warning" @click="handleDisableCard"> 停用此卡 </ElButton>
<ElButton type="danger" @click="handleManualDeactivate"> 手动停用 </ElButton>
</div>
<!-- 设备操作按钮 -->
<div v-else class="device-operations" style="margin-top: 16px; text-align: right">
<ElButton type="primary" @click="handleRebootDevice"> 重启设备 </ElButton>
<ElButton type="warning" @click="handleResetDevice"> 恢复出厂 </ElButton>
<ElButton type="success" @click="handleShowSwitchCard"> 切换SIM卡 </ElButton>
<ElButton type="primary" @click="handleShowSetWiFi"> 设置WiFi </ElButton>
</div>
</ElCard>
</template>
<script setup lang="ts">
import { computed, toRef } from 'vue'
import {
ElCard,
ElDescriptions,
ElDescriptionsItem,
ElTag,
ElTooltip,
ElSwitch,
ElDivider,
ElTable,
ElTableColumn,
ElButton,
ElEmpty,
ElAlert
} from 'element-plus'
import { useAssetFormatters } from '../composables/useAssetFormatters'
import { formatDateTime } from '@/utils/business/format'
// Props
interface BindingCard {
id: number
iccid: string
msisdn?: string
carrier_name?: string
slot_position?: number
is_current: boolean
network_status?: number
real_name_status?: number
real_name_at?: string
}
interface AssetInfo {
asset_type: 'card' | 'device'
iccid?: string
imei?: string
msisdn?: string
carrier_name?: string
real_name_status?: number
network_status?: number
current_month_usage_mb?: number
device_type?: string
device_name?: string
device_model?: string
virtual_no?: string
sn?: string
cards?: BindingCard[]
status?: number
shop_name?: string
series_name?: string
real_name_at?: string
supplier?: string
bound_card_count?: number
max_sim_slots?: number
activated_at?: string
online_status?: number
software_version?: string
switch_mode?: string
last_online_time?: string
last_gateway_sync_at?: string
manufacturer?: string
}
interface DeviceRealtimeInfo {
online_status?: number
status?: number
battery_level?: number
run_time?: string
connect_time?: string
last_online_time?: string
rssi?: string
rsrp?: string
rsrq?: string
sinr?: string
max_clients?: number
current_iccid?: string
wifi_enabled?: boolean
ssid?: string
wifi_password?: string
ip_address?: string
wan_ip?: string
lan_ip?: string
mac_address?: string
imei?: string
imsi?: string
daily_usage?: string
dl_stats?: string
ul_stats?: string
limit_speed?: number
software_version?: string
switch_mode?: string
sync_interval?: number
device_id?: string
last_update_time?: string
}
interface Props {
cardInfo: AssetInfo
deviceRealtime: DeviceRealtimeInfo | null
pollingEnabled: boolean
}
const props = defineProps<Props>()
// Emits
interface Emits {
(e: 'update:pollingEnabled', value: boolean): void
(e: 'enableCard'): void
(e: 'disableCard'): void
(e: 'manualDeactivate'): void
(e: 'rebootDevice'): void
(e: 'resetDevice'): void
(e: 'showSpeedLimit'): void
(e: 'showSwitchCard'): void
(e: 'showSetWiFi'): void
(e: 'enableBindingCard', payload: { card: BindingCard }): void
(e: 'disableBindingCard', payload: { card: BindingCard }): void
}
const emit = defineEmits<Emits>()
// 使用格式化工具
const deviceRealtimeRef = toRef(props, 'deviceRealtime')
const {
formatDataSize,
formatDuration,
getRealNameStatusName,
getRealNameStatusType,
getAssetStatusName,
getAssetStatusType,
getOnlineStatusName,
getOnlineStatusType,
calculateSignalStrength,
getSignalStrengthType,
getSwitchModeName
} = useAssetFormatters(deviceRealtimeRef)
// 排序后的设备卡列表(当前卡排在最前面)
const sortedDeviceCards = computed(() => {
if (!props.cardInfo?.cards || props.cardInfo.cards.length === 0) {
return []
}
const cards = [...props.cardInfo.cards]
return cards.sort((a, b) => {
if (a.is_current && !b.is_current) return -1
if (!a.is_current && b.is_current) return 1
return 0
})
})
// 获取当前使用的卡
const currentCard = computed(() => {
if (!props.cardInfo?.cards || props.cardInfo.cards.length === 0) {
return null
}
return props.cardInfo.cards.find((card: BindingCard) => card.is_current) || null
})
// 处理轮询开关变化
const handlePollingChange = (value: boolean) => {
emit('update:pollingEnabled', value)
}
// 跳转到卡片信息
const handleNavigateToCardInfo = (iccid: string) => {
// 可以通过路由跳转或触发搜索
console.log('Navigate to card:', iccid)
}
// 卡操作
const handleEnableCard = () => {
emit('enableCard')
}
const handleDisableCard = () => {
emit('disableCard')
}
const handleManualDeactivate = () => {
emit('manualDeactivate')
}
// 设备操作
const handleRebootDevice = () => {
emit('rebootDevice')
}
const handleResetDevice = () => {
emit('resetDevice')
}
const handleShowSwitchCard = () => {
emit('showSwitchCard')
}
const handleShowSetWiFi = () => {
emit('showSetWiFi')
}
// 绑定卡操作
const handleEnableBindingCard = (card: BindingCard) => {
emit('enableBindingCard', { card })
}
const handleDisableBindingCard = (card: BindingCard) => {
emit('disableBindingCard', { card })
}
</script>
<style scoped lang="scss">
.info-card {
&.basic-info {
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
.card-header-left {
display: flex;
gap: 8px;
align-items: center;
}
.card-header-right {
.polling-switch-wrapper {
display: flex;
gap: 8px;
align-items: center;
.polling-label {
font-size: 14px;
color: var(--el-text-color-regular);
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,226 @@
<template>
<ElCard shadow="never" class="info-card current-package-card">
<template #header>
<div class="card-header">
<div class="card-header-left">
<span class="header-title">当前生效套餐</span>
<ElTag
v-if="currentPackage"
:type="getPackageStatusTagType(currentPackage.status)"
size="small"
>
{{ currentPackage?.status_name || '-' }}
</ElTag>
</div>
<div v-if="currentPackage" class="card-header-right">
<ElButton type="primary" size="small" @click="handleShowRecharge"> 套餐充值 </ElButton>
</div>
</div>
</template>
<!-- 显示错误信息 -->
<ElAlert v-if="errorMsg" :title="errorMsg" type="warning" :closable="false" />
<!-- 显示套餐详情 -->
<div v-else-if="currentPackage">
<ElDescriptions :column="3" border>
<!-- 套餐基本信息 -->
<ElDescriptionsItem label="套餐名称">
{{ currentPackage.package_name || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="套餐价格">
{{ formatAmount(currentPackage.package_price) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="套餐总量">
{{ formatDataSize(currentPackage.package_total_data || 0) }}
</ElDescriptionsItem>
<!-- 套餐时长 -->
<ElDescriptionsItem v-if="currentPackage.duration_days" label="套餐时长" :span="3">
{{ currentPackage.duration_days }}
</ElDescriptionsItem>
</ElDescriptions>
<!-- 真流量进度 -->
<div class="traffic-progress-wrapper">
<div class="progress-info">
<span class="progress-label">真流量</span>
<span class="progress-text">
已用 {{ formatDataSize(currentPackage.real_data_used || 0) }} / 剩余
{{ formatDataSize(currentPackage.real_data_remaining || 0) }} / 总量
{{ formatDataSize(currentPackage.real_data_total || 0) }}
</span>
</div>
<ElProgress
:percentage="
getUsageProgress(
currentPackage.real_data_used || 0,
currentPackage.real_data_total || 0
)
"
:color="
getProgressColorByPercentage(
getUsageProgress(
currentPackage.real_data_used || 0,
currentPackage.real_data_total || 0
)
)
"
:stroke-width="20"
/>
</div>
<!-- 虚流量进度 -->
<div class="traffic-progress-wrapper">
<div class="progress-info">
<span class="progress-label">虚流量</span>
<span class="progress-text">
已用 {{ formatDataSize(currentPackage.virtual_data_used || 0) }} / 剩余
{{ formatDataSize(currentPackage.virtual_data_remaining || 0) }} / 总量
{{ formatDataSize(currentPackage.virtual_data_total || 0) }}
</span>
</div>
<ElProgress
:percentage="
getUsageProgress(
currentPackage.virtual_data_used || 0,
currentPackage.virtual_data_total || 0
)
"
:color="
getProgressColorByPercentage(
getUsageProgress(
currentPackage.virtual_data_used || 0,
currentPackage.virtual_data_total || 0
)
)
"
:stroke-width="20"
/>
</div>
<!-- 时间信息 -->
<ElDescriptions :column="3" border style="margin-top: 16px">
<ElDescriptionsItem label="开始时间">
{{ formatDateTime(currentPackage.start_time) || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="到期时间">
{{ formatDateTime(currentPackage.expire_time) || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="状态">
<ElTag :type="getPackageStatusTagType(currentPackage.status)" size="small">
{{ currentPackage.status_name || '-' }}
</ElTag>
</ElDescriptionsItem>
</ElDescriptions>
</div>
<!-- 空状态 -->
<ElEmpty v-else-if="!loading" :description="errorMsg || '暂无当前生效套餐'" :image-size="100" />
</ElCard>
</template>
<script setup lang="ts">
import {
ElCard,
ElTag,
ElButton,
ElDescriptions,
ElDescriptionsItem,
ElProgress,
ElEmpty,
ElAlert
} from 'element-plus'
import { useAssetFormatters } from '../composables/useAssetFormatters'
import { formatDateTime } from '@/utils/business/format'
import type { PackageInfo } from '../types'
// Use formatters
const {
formatAmount,
formatDataSize,
getPackageStatusTagType,
getUsageProgress,
getProgressColorByPercentage
} = useAssetFormatters()
// Props
interface Props {
currentPackage: PackageInfo | null
loading?: boolean
errorMsg?: string
}
withDefaults(defineProps<Props>(), {
loading: false,
errorMsg: ''
})
// Emits
const emit = defineEmits<{
showRecharge: []
}>()
// Methods
const handleShowRecharge = () => {
emit('showRecharge')
}
</script>
<style scoped lang="scss">
.current-package-card {
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
.card-header-left {
display: flex;
gap: 12px;
align-items: center;
.header-title {
font-size: 16px;
font-weight: 500;
}
}
.card-header-right {
display: flex;
gap: 8px;
align-items: center;
}
}
.traffic-progress-wrapper {
padding: 16px;
margin-top: 16px;
background: var(--el-fill-color-light);
border-radius: 8px;
.progress-info {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
.progress-label {
font-size: 15px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.progress-text {
font-size: 13px;
color: var(--el-text-color-regular);
}
}
}
.package-actions {
margin-top: 16px;
text-align: right;
}
}
</style>

View File

@@ -0,0 +1,220 @@
<template>
<ElCard shadow="never" class="info-card package-info">
<template #header>
<div class="card-header">
<span>套餐列表</span>
</div>
</template>
<div class="package-table-wrapper">
<ElTable
v-if="packageList && packageList.length > 0"
:data="packageList"
class="package-table"
border
max-height="400"
>
<ElTableColumn label="套餐名称" min-width="150">
<template #default="scope">
<ElButton
type="primary"
link
class="package-name-link"
@click="handleShowDailyRecords(scope.row)"
v-permission="'package-usage:daily-records:view'"
>
{{ scope.row.package_name }}
</ElButton>
</template>
</ElTableColumn>
<ElTableColumn prop="package_price" label="套餐价格" width="120">
<template #default="scope">
{{ formatAmount(scope.row.package_price) }}
</template>
</ElTableColumn>
<ElTableColumn prop="status_name" label="套餐状态" width="100">
<template #default="scope">
<ElTag :type="getPackageStatusTagType(scope.row.status)" size="small">
{{ scope.row.status_name }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="data_limit_mb" label="总流量" width="120">
<template #default="scope">
{{ formatDataSize(scope.row.data_limit_mb) }}
</template>
</ElTableColumn>
<ElTableColumn label="真流量" width="280">
<template #default="scope">
<div class="traffic-progress">
<div class="progress-text">
<span class="used">已用: {{ formatDataSize(scope.row.data_usage_mb) }}</span>
<span class="remaining">
剩余: {{ formatDataSize(scope.row.data_limit_mb - scope.row.data_usage_mb) }}
</span>
<span class="total">总量: {{ formatDataSize(scope.row.data_limit_mb) }}</span>
</div>
<ElProgress
:percentage="getUsageProgress(scope.row.data_usage_mb, scope.row.data_limit_mb)"
:color="
getProgressColorByPercentage(
getUsageProgress(scope.row.data_usage_mb, scope.row.data_limit_mb)
)
"
/>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="虚流量" width="280">
<template #default="scope">
<div class="traffic-progress">
<div class="progress-text">
<span class="used">已用: {{ formatDataSize(scope.row.virtual_used_mb || 0) }}</span>
<span class="remaining">
剩余: {{ formatDataSize(scope.row.virtual_remain_mb || 0) }}
</span>
<span class="total">
总量: {{ formatDataSize(scope.row.virtual_limit_mb || 0) }}
</span>
</div>
<ElProgress
:percentage="
getUsageProgress(scope.row.virtual_used_mb, scope.row.virtual_limit_mb)
"
:color="
getProgressColorByPercentage(
getUsageProgress(scope.row.virtual_used_mb, scope.row.virtual_limit_mb)
)
"
/>
</div>
</template>
</ElTableColumn>
<ElTableColumn prop="activated_at" label="开始时间" width="170" show-overflow-tooltip>
<template #default="scope">
{{ formatDateTime(scope.row.activated_at) }}
</template>
</ElTableColumn>
<ElTableColumn prop="expires_at" label="到期时间" width="170" show-overflow-tooltip>
<template #default="scope">
{{ formatDateTime(scope.row.expires_at) }}
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="100" align="center" fixed="right">
<template #default="scope">
<ElButton
type="primary"
link
@click="handleShowDailyRecords(scope.row)"
v-permission="'package-usage:daily-records:view'"
>
流量详单
</ElButton>
</template>
</ElTableColumn>
</ElTable>
<ElEmpty v-else description="暂无套餐数据" :image-size="100" />
</div>
</ElCard>
</template>
<script setup lang="ts">
import {
ElCard,
ElTable,
ElTableColumn,
ElTag,
ElButton,
ElProgress,
ElEmpty
} from 'element-plus'
import { useAssetFormatters } from '../composables/useAssetFormatters'
import { formatDateTime } from '@/utils/business/format'
// Props
interface PackageInfo {
id: number
package_id: number
package_name: string
package_price: number
data_limit_mb: number
data_usage_mb: number
virtual_limit_mb: number
virtual_used_mb: number
virtual_remain_mb: number
activated_at: string
expires_at: string
status: number
status_name: string
}
interface Props {
packageList: PackageInfo[]
}
defineProps<Props>()
// Emits
interface Emits {
(e: 'showDailyRecords', payload: { packageRow: PackageInfo }): void
}
const emit = defineEmits<Emits>()
// 使用格式化工具
const {
formatDataSize,
formatAmount,
getPackageStatusTagType,
getUsageProgress,
getProgressColorByPercentage
} = useAssetFormatters()
// 显示流量详单
const handleShowDailyRecords = (packageRow: PackageInfo) => {
emit('showDailyRecords', { packageRow })
}
</script>
<style scoped lang="scss">
.info-card {
&.package-info {
.package-table-wrapper {
.package-table {
:deep(.el-table__header) {
th {
font-weight: 600;
color: var(--el-text-color-primary, #374151);
}
}
.package-name-link {
font-weight: 500;
}
.traffic-progress {
.progress-text {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
font-size: 12px;
.used {
color: var(--el-color-primary);
}
.remaining {
color: var(--el-color-success);
}
.total {
color: var(--el-text-color-secondary);
}
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,140 @@
<template>
<ElCard shadow="never" class="info-card wallet-overview-card">
<template #header>
<div class="card-header">
<span>钱包概况</span>
<ElTag v-if="walletInfo" :type="getWalletStatusType(walletInfo.status)" size="small">
{{ walletInfo.status_text || '-' }}
</ElTag>
</div>
</template>
<div v-if="walletInfo">
<!-- 钱包余额展示 -->
<div class="wallet-balance-cards">
<div class="balance-card total">
<div class="balance-icon">💰</div>
<div class="balance-info">
<div class="balance-label">总余额</div>
<div class="balance-value">{{ formatAmount(walletInfo.balance) }}</div>
</div>
</div>
<div class="balance-card available">
<div class="balance-icon"></div>
<div class="balance-info">
<div class="balance-label">可用余额</div>
<div class="balance-value">{{ formatAmount(walletInfo.available_balance) }}</div>
</div>
</div>
<div class="balance-card frozen">
<div class="balance-icon">🔒</div>
<div class="balance-info">
<div class="balance-label">冻结余额</div>
<div class="balance-value">{{ formatAmount(walletInfo.frozen_balance) }}</div>
</div>
</div>
</div>
</div>
<ElEmpty v-else-if="!loading" description="该资产暂无钱包记录" :image-size="100" />
</ElCard>
</template>
<script setup lang="ts">
import { ElCard, ElTag, ElEmpty } from 'element-plus'
import { useAssetFormatters } from '../composables/useAssetFormatters'
// Use formatters
const { formatAmount, getWalletStatusType } = useAssetFormatters()
// Props
interface WalletInfo {
balance: number
available_balance: number
frozen_balance: number
status: number
status_text?: string
}
interface Props {
walletInfo: WalletInfo | null
loading?: boolean
}
withDefaults(defineProps<Props>(), {
loading: false
})
</script>
<style scoped lang="scss">
.wallet-overview-card {
.wallet-balance-cards {
display: flex;
flex-direction: column;
gap: 8px;
.balance-card {
display: flex;
gap: 10px;
align-items: center;
padding: 10px 14px;
background: var(--el-fill-color-light);
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
transition: all 0.3s ease;
&:hover {
border-color: var(--el-color-primary);
box-shadow: 0 2px 6px rgb(0 0 0 / 6%);
transform: translateX(2px);
}
.balance-icon {
flex-shrink: 0;
font-size: 20px;
line-height: 1;
}
.balance-info {
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
.balance-label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.balance-value {
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
&.total {
.balance-value {
color: var(--el-color-primary);
}
}
&.available {
.balance-value {
color: var(--el-color-success);
}
}
&.frozen {
.balance-value {
color: var(--el-color-warning);
}
}
}
}
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
</style>

View File

@@ -0,0 +1,326 @@
<template>
<ElCard shadow="never" class="info-card wallet-info">
<template #header>
<div class="card-header wallet-header">
<div class="header-left">
<span>钱包流水</span>
<ElTag type="info" size="small"> {{ total }} </ElTag>
</div>
<div class="filter-section">
<ElSelect
v-model="filterForm.transaction_type"
placeholder="交易类型"
clearable
style="width: 150px"
@change="handleFilterChange"
>
<ElOption label="充值" value="recharge" />
<ElOption label="扣款" value="deduct" />
<ElOption label="退款" value="refund" />
</ElSelect>
<ElDatePicker
v-model="filterForm.date_range"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
style="width: 360px"
@change="handleDateChange"
/>
<ElButton type="primary" @click="handleFilterChange">查询</ElButton>
<ElButton @click="handleResetFilter">重置</ElButton>
</div>
</div>
</template>
<div class="wallet-table-wrapper">
<!-- 流水表格 -->
<ElTable
v-if="transactionList && transactionList.length > 0"
:data="transactionList"
class="wallet-table"
border
v-loading="loading"
>
<ElTableColumn label="交易类型" width="100" align="center">
<template #default="scope">
<ElTag :type="getTransactionTypeTag(scope.row.transaction_type)" size="small">
{{ scope.row.transaction_type_text }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="变动金额" width="120" align="right">
<template #default="scope">
<span
:style="{
color: scope.row.amount > 0 ? '#67c23a' : '#f56c6c',
fontWeight: 600
}"
>
{{ formatAmount(scope.row.amount) }}
</span>
</template>
</ElTableColumn>
<ElTableColumn label="变动前余额" width="120" align="right">
<template #default="scope">
{{ formatAmount(scope.row.balance_before) }}
</template>
</ElTableColumn>
<ElTableColumn label="变动后余额" width="120" align="right">
<template #default="scope">
{{ formatAmount(scope.row.balance_after) }}
</template>
</ElTableColumn>
<ElTableColumn label="关联业务" width="100" align="center">
<template #default="scope">
<ElTag v-if="scope.row.reference_type" type="info" size="small">
{{ scope.row.reference_type === 'recharge' ? '充值' : '订单' }}
</ElTag>
<span v-else>--</span>
</template>
</ElTableColumn>
<ElTableColumn prop="reference_no" label="业务编号" min-width="180" show-overflow-tooltip>
<template #default="scope">
<span v-if="scope.row.reference_no" class="reference-no-link">
{{ scope.row.reference_no }}
</span>
<span v-else>--</span>
</template>
</ElTableColumn>
<ElTableColumn prop="remark" label="备注" min-width="150" show-overflow-tooltip>
<template #default="scope">
{{ scope.row.remark || '-' }}
</template>
</ElTableColumn>
<ElTableColumn label="创建时间" width="170" show-overflow-tooltip>
<template #default="scope">
{{ formatDateTime(scope.row.created_at) }}
</template>
</ElTableColumn>
</ElTable>
<ElEmpty v-else-if="!loading" description="暂无钱包流水数据" :image-size="100" />
<!-- 分页器 -->
<div v-if="total > 0" class="pagination-wrapper">
<ElPagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.page_size"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handlePageSizeChange"
@current-change="handlePageChange"
/>
</div>
</div>
</ElCard>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import {
ElCard,
ElTable,
ElTableColumn,
ElTag,
ElButton,
ElSelect,
ElOption,
ElDatePicker,
ElPagination,
ElEmpty,
ElMessage
} from 'element-plus'
import { useAssetFormatters } from '../composables/useAssetFormatters'
import { formatDateTime } from '@/utils/business/format'
import { AssetService } from '@/api/modules'
// Props
interface Props {
assetIdentifier: string // ICCID or virtual_no
}
const props = defineProps<Props>()
// 使用格式化工具
const { formatAmount, getTransactionTypeTag } = useAssetFormatters()
// State
const loading = ref(false)
const transactionList = ref<any[]>([])
const total = ref(0)
const pagination = ref({
page: 1,
page_size: 20
})
const filterForm = ref<{
transaction_type: string | null
date_range: [Date, Date] | null
}>({
transaction_type: null,
date_range: null
})
// API调用参数
const queryParams = ref({
page: 1,
page_size: 20,
transaction_type: null as string | null,
start_time: null as string | null,
end_time: null as string | null
})
// 加载钱包流水列表
const loadTransactions = async () => {
if (!props.assetIdentifier) return
try {
loading.value = true
const response = await AssetService.getWalletTransactions(
props.assetIdentifier,
queryParams.value
)
if (response.code === 0 && response.data) {
transactionList.value = response.data.list || []
total.value = response.data.total || 0
}
} catch (error: any) {
if (error?.response?.status === 403) {
ElMessage.warning('企业账号禁止查看钱包流水')
} else {
console.error('获取钱包流水失败:', error)
}
} finally {
loading.value = false
}
}
// 处理日期范围变化
const handleDateChange = (value: [Date, Date] | null) => {
if (value && value.length === 2) {
// 转换为 RFC3339 格式
queryParams.value.start_time = value[0].toISOString()
queryParams.value.end_time = value[1].toISOString()
} else {
queryParams.value.start_time = null
queryParams.value.end_time = null
}
}
// 处理筛选条件变化
const handleFilterChange = () => {
queryParams.value.page = 1
pagination.value.page = 1
queryParams.value.transaction_type = filterForm.value.transaction_type
loadTransactions()
}
// 重置筛选条件
const handleResetFilter = () => {
filterForm.value = {
transaction_type: null,
date_range: null
}
queryParams.value = {
page: 1,
page_size: 20,
transaction_type: null,
start_time: null,
end_time: null
}
pagination.value = {
page: 1,
page_size: 20
}
loadTransactions()
}
// 处理分页大小变化
const handlePageSizeChange = (size: number) => {
queryParams.value.page_size = size
queryParams.value.page = 1
pagination.value.page = 1
loadTransactions()
}
// 处理页码变化
const handlePageChange = (page: number) => {
queryParams.value.page = page
loadTransactions()
}
// 监听资产标识符变化
watch(
() => props.assetIdentifier,
(newVal) => {
if (newVal) {
// 重置状态
handleResetFilter()
}
},
{ immediate: true }
)
// 组件挂载时加载数据
onMounted(() => {
if (props.assetIdentifier) {
loadTransactions()
}
})
</script>
<style scoped lang="scss">
.info-card {
&.wallet-info {
.wallet-header {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
justify-content: space-between;
.header-left {
display: flex;
gap: 8px;
align-items: center;
}
.filter-section {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
}
.wallet-table-wrapper {
.wallet-table {
:deep(.el-table__header) {
th {
font-weight: 600;
color: var(--el-text-color-primary, #374151);
}
}
.reference-no-link {
color: var(--el-color-primary);
text-decoration: underline;
text-decoration-style: dashed;
text-underline-offset: 2px;
cursor: pointer;
&:hover {
color: var(--el-color-primary-light-3);
text-decoration-style: solid;
}
}
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
}
}
}
</style>

View File

@@ -0,0 +1,205 @@
<template>
<ElDialog v-model="visible" title="套餐流量详单" width="900px" destroy-on-close>
<div v-if="data">
<!-- 套餐信息摘要 -->
<ElDescriptions :column="2" border style="margin-bottom: 20px">
<ElDescriptionsItem label="套餐名称">
{{ data.package_name }}
</ElDescriptionsItem>
<ElDescriptionsItem label="套餐使用记录ID">
{{ data.package_usage_id }}
</ElDescriptionsItem>
<ElDescriptionsItem label="总使用流量" :span="2">
{{ formatDataSize(data.total_usage_mb) }}
</ElDescriptionsItem>
</ElDescriptions>
<!-- 筛选器 -->
<div style="display: flex; gap: 12px; align-items: center; margin-bottom: 16px">
<span style="font-weight: 500">筛选方式</span>
<ElRadioGroup v-model="filterType" @change="handleFilterTypeChange">
<ElRadio value="all">全部</ElRadio>
<ElRadio value="day">按日</ElRadio>
<ElRadio value="month">按月</ElRadio>
</ElRadioGroup>
<ElDatePicker
v-if="filterType === 'day'"
v-model="selectedDate"
type="date"
placeholder="选择日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
clearable
@change="filterRecordsByDate"
style="width: 200px"
/>
<ElDatePicker
v-if="filterType === 'month'"
v-model="selectedMonth"
type="month"
placeholder="选择月份"
format="YYYY-MM"
value-format="YYYY-MM"
clearable
@change="filterRecordsByMonth"
style="width: 200px"
/>
</div>
<!-- 每日流量记录表格 -->
<ElTable :data="filteredRecords" border stripe max-height="400" v-loading="loading">
<ElTableColumn prop="date" label="日期" width="120" align="center" />
<ElTableColumn label="当日使用流量" width="150" align="center">
<template #default="{ row }">
{{ formatDataSize(row.daily_usage_mb) }}
</template>
</ElTableColumn>
<ElTableColumn label="累计流量" width="150" align="center">
<template #default="{ row }">
{{ formatDataSize(row.cumulative_usage_mb) }}
</template>
</ElTableColumn>
<ElTableColumn label="使用进度" min-width="200">
<template #default="{ row }">
<ElProgress
:percentage="getUsageProgress(row.cumulative_usage_mb, data.total_usage_mb)"
:color="
getProgressColor(getUsageProgress(row.cumulative_usage_mb, data.total_usage_mb))
"
/>
</template>
</ElTableColumn>
</ElTable>
</div>
<div v-else-if="!loading" style="padding: 40px 0; text-align: center">
<ElEmpty description="暂无流量详单数据" />
</div>
</ElDialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import {
ElDialog,
ElDescriptions,
ElDescriptionsItem,
ElRadioGroup,
ElRadio,
ElDatePicker,
ElTable,
ElTableColumn,
ElProgress,
ElEmpty,
ElMessage
} from 'element-plus'
import { CardService } from '@/api/modules'
interface Props {
modelValue: boolean
packageUsageId?: number
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const loading = ref(false)
const data = ref<any>(null)
const filterType = ref<'all' | 'day' | 'month'>('all')
const selectedDate = ref<string>('')
const selectedMonth = ref<string>('')
const filteredRecords = ref<any[]>([])
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
// 监听对话框打开,加载数据
watch(visible, async (newVal) => {
if (newVal && props.packageUsageId) {
await loadDailyRecords()
} else if (!newVal) {
// 关闭时重置数据
data.value = null
filterType.value = 'all'
selectedDate.value = ''
selectedMonth.value = ''
filteredRecords.value = []
}
})
const loadDailyRecords = async () => {
if (!props.packageUsageId) return
try {
loading.value = true
const res = await CardService.getPackageDailyRecords(props.packageUsageId)
if (res.code === 0 && res.data) {
data.value = res.data
filteredRecords.value = res.data.records || []
}
} catch (error) {
console.error('加载流量详单失败:', error)
ElMessage.error('加载流量详单失败')
} finally {
loading.value = false
}
}
const formatDataSize = (mb: number) => {
if (mb >= 1024) {
return `${(mb / 1024).toFixed(2)}GB`
}
return `${mb.toFixed(2)}MB`
}
const handleFilterTypeChange = () => {
selectedDate.value = ''
selectedMonth.value = ''
filteredRecords.value = data.value?.records || []
}
const filterRecordsByDate = () => {
if (!data.value?.records) return
if (!selectedDate.value) {
filteredRecords.value = data.value.records
return
}
filteredRecords.value = data.value.records.filter((record: any) => {
return record.date === selectedDate.value
})
}
const filterRecordsByMonth = () => {
if (!data.value?.records) return
if (!selectedMonth.value) {
filteredRecords.value = data.value.records
return
}
filteredRecords.value = data.value.records.filter((record: any) => {
return record.date.startsWith(selectedMonth.value)
})
}
const getUsageProgress = (cumulative: number, total: number): number => {
if (!total || total <= 0) return 0
const percentage = (cumulative / total) * 100
return Math.min(Math.round(percentage * 100) / 100, 100)
}
const getProgressColor = (percentage: number): string => {
if (percentage < 50) return '#67c23a'
if (percentage < 80) return '#e6a23c'
return '#f56c6c'
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,232 @@
<template>
<ElDialog v-model="visible" title="往期订单" width="60%" destroy-on-close>
<div v-if="loading" style="padding: 40px 0; text-align: center">
<ElIcon class="is-loading" :size="40"><Loading /></ElIcon>
</div>
<div v-else-if="data">
<!-- 当前代订单 -->
<div class="generation-section">
<h3 style="margin-bottom: 16px"> 当前代 ({{ data.current_generation.identifier }}) </h3>
<ElTable :data="data.current_generation.items" border>
<ElTableColumn prop="order_no" label="订单号" width="200" />
<ElTableColumn prop="order_type" label="订单类型" width="120" />
<ElTableColumn prop="payment_status_text" label="支付状态" width="100">
<template #default="{ row }">
<ElTag :type="getPaymentStatusType(row.payment_status)">
{{ row.payment_status_text }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="total_amount" label="总金额" width="120">
<template #default="{ row }"> ¥{{ (row.total_amount / 100).toFixed(2) }} </template>
</ElTableColumn>
<ElTableColumn prop="payment_method" label="支付方式" width="120" />
<ElTableColumn prop="paid_at" label="支付时间" width="180">
<template #default="{ row }">
{{ row.paid_at ? formatDateTime(row.paid_at) : '-' }}
</template>
</ElTableColumn>
<ElTableColumn label="订单项" min-width="200">
<template #default="{ row }">
<div v-for="(item, idx) in row.items" :key="idx" style="margin-bottom: 4px">
{{ item.package_name }} × {{ item.quantity }}
</div>
</template>
</ElTableColumn>
<ElTableColumn prop="created_at" label="创建时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.created_at) }}
</template>
</ElTableColumn>
</ElTable>
<!-- 分页 -->
<div style="margin-top: 16px; text-align: right">
<ElPagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.page_size"
:total="data.current_generation.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handlePageSizeChange"
@current-change="handlePageChange"
/>
</div>
</div>
<!-- 前代订单 -->
<div
v-if="data.previous_generations && data.previous_generations.length > 0"
style="margin-top: 32px"
>
<div
v-for="gen in data.previous_generations"
:key="gen.generation"
class="generation-section"
style="margin-bottom: 24px"
>
<h3 style="margin-bottom: 16px">
{{ gen.generation }} ({{ gen.identifier }})
<span v-if="gen.exchange_no" style="margin-left: 12px; font-size: 14px; color: #909399">
换货单号: {{ gen.exchange_no }}
</span>
<span
v-if="gen.exchanged_at"
style="margin-left: 12px; font-size: 14px; color: #909399"
>
换货时间: {{ formatDateTime(gen.exchanged_at) }}
</span>
</h3>
<ElTable :data="gen.items" border>
<ElTableColumn prop="order_no" label="订单号" width="200" />
<ElTableColumn prop="order_type" label="订单类型" width="120" />
<ElTableColumn prop="payment_status_text" label="支付状态" width="100">
<template #default="{ row }">
<ElTag :type="getPaymentStatusType(row.payment_status)">
{{ row.payment_status_text }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="total_amount" label="总金额" width="120">
<template #default="{ row }"> ¥{{ (row.total_amount / 100).toFixed(2) }} </template>
</ElTableColumn>
<ElTableColumn prop="payment_method" label="支付方式" width="120" />
<ElTableColumn prop="paid_at" label="支付时间" width="180">
<template #default="{ row }">
{{ row.paid_at ? formatDateTime(row.paid_at) : '-' }}
</template>
</ElTableColumn>
<ElTableColumn label="订单项" min-width="200">
<template #default="{ row }">
<div v-for="(item, idx) in row.items" :key="idx" style="margin-bottom: 4px">
{{ item.package_name }} × {{ item.quantity }}
</div>
</template>
</ElTableColumn>
<ElTableColumn prop="created_at" label="创建时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.created_at) }}
</template>
</ElTableColumn>
</ElTable>
</div>
<ElAlert v-if="data.truncated" type="warning" :closable="false" style="margin-top: 16px">
换货链超过10代仅显示最近10代的订单记录
</ElAlert>
</div>
</div>
<div v-else style="padding: 40px 0; text-align: center">
<ElEmpty description="暂无订单数据" />
</div>
</ElDialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import {
ElDialog,
ElTable,
ElTableColumn,
ElTag,
ElPagination,
ElAlert,
ElEmpty,
ElIcon,
ElMessage
} from 'element-plus'
import { Loading } from '@element-plus/icons-vue'
import { AssetService } from '@/api/modules'
import { formatDateTime } from '@/utils/business/format'
interface Props {
modelValue: boolean
assetIdentifier?: string
assetType?: 'card' | 'device'
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const loading = ref(false)
const data = ref<any>(null)
const pagination = reactive({
page: 1,
page_size: 20
})
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
// 监听对话框打开,加载数据
watch(visible, async (newVal) => {
if (newVal && props.assetIdentifier) {
pagination.page = 1
pagination.page_size = 20
await loadOrderHistory()
} else if (!newVal) {
// 关闭时重置数据
data.value = null
}
})
const loadOrderHistory = async () => {
if (!props.assetIdentifier) return
try {
loading.value = true
const response = await AssetService.getAssetOrders(props.assetIdentifier, {
page: pagination.page,
page_size: pagination.page_size,
include_previous: true // 包含换货前代的订单
})
if (response.code === 0 && response.data) {
data.value = response.data
}
} catch (error: any) {
console.error('加载往期订单失败:', error)
ElMessage.error(error?.message || '加载往期订单失败')
} finally {
loading.value = false
}
}
const handlePageSizeChange = (size: number) => {
pagination.page_size = size
loadOrderHistory()
}
const handlePageChange = (page: number) => {
pagination.page = page
loadOrderHistory()
}
const getPaymentStatusType = (status: number): 'success' | 'warning' | 'danger' | 'info' => {
const statusMap: Record<number, 'success' | 'warning' | 'danger' | 'info'> = {
1: 'warning', // 待支付
2: 'success', // 已支付
3: 'danger', // 已取消
4: 'info' // 已关闭
}
return statusMap[status] || 'info'
}
</script>
<style lang="scss" scoped>
.generation-section {
h3 {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
</style>

View File

@@ -0,0 +1,237 @@
<template>
<ElDialog v-model="visible" title="套餐充值" width="600px" @closed="handleDialogClosed">
<ElForm ref="formRef" :model="form" :rules="rules" label-width="120px">
<ElFormItem label="资产信息">
<span style="font-weight: bold; color: #409eff">
{{ assetIdentifier }}
</span>
</ElFormItem>
<ElFormItem label="选择套餐" prop="package_id">
<ElSelect
v-model="form.package_id"
placeholder="请选择套餐"
filterable
remote
reserve-keyword
:remote-method="handlePackageSearch"
:loading="packageSearchLoading"
clearable
style="width: 100%"
>
<ElOption
v-for="pkg in packageOptions"
:key="pkg.id"
:label="`${pkg.package_name} (¥${((pkg.cost_price || 0) / 100).toFixed(2)})`"
:value="pkg.id"
>
<div style="display: flex; justify-content: space-between">
<span>{{ pkg.package_name }}</span>
<span style="font-size: 12px; color: var(--el-text-color-secondary)">
¥{{ ((pkg.cost_price || 0) / 100).toFixed(2) }}
</span>
</div>
</ElOption>
</ElSelect>
</ElFormItem>
<ElFormItem label="支付方式" prop="payment_method">
<ElSelect v-model="form.payment_method" placeholder="请选择支付方式" style="width: 100%">
<ElOption label="钱包支付" value="wallet" />
<ElOption v-if="showOfflinePayment" label="线下支付" value="offline" />
</ElSelect>
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary)">
<template v-if="form.payment_method === 'wallet'">
提示: 使用钱包支付时,订单将直接完成
</template>
<template v-else-if="form.payment_method === 'offline'">
提示: 线下支付订单需要手动确认支付
</template>
</div>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="handleCancel">取消</ElButton>
<ElButton type="primary" @click="handleConfirm" :loading="loading"> 确认充值 </ElButton>
</div>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import {
ElDialog,
ElForm,
ElFormItem,
ElSelect,
ElOption,
ElButton,
ElMessage
} from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { OrderService, PackageManageService } from '@/api/modules'
import type { CreateOrderRequest, PackageResponse } from '@/types/api'
import { useUserStore } from '@/store/modules/user'
interface Props {
modelValue: boolean
assetIdentifier: string
assetType: 'card' | 'device'
seriesId?: number
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'confirm'): void
(e: 'success'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const userStore = useUserStore()
const formRef = ref<FormInstance>()
const loading = ref(false)
const packageSearchLoading = ref(false)
const packageOptions = ref<PackageResponse[]>([])
const form = reactive({
package_id: undefined as number | undefined,
payment_method: 'wallet' as 'wallet' | 'offline'
})
const rules: FormRules = {
package_id: [{ required: true, message: '请选择套餐', trigger: 'change' }],
payment_method: [{ required: true, message: '请选择支付方式', trigger: 'change' }]
}
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
// 只有平台用户(user_type 1:超级管理员, 2:平台用户)才显示线下支付选项
const showOfflinePayment = computed(() => {
return userStore.info.user_type === 1 || userStore.info.user_type === 2
})
// 监听对话框打开,加载套餐列表
watch(visible, async (newVal) => {
if (newVal) {
await loadPackages()
}
})
const loadPackages = async () => {
if (!props.seriesId) return
try {
packageSearchLoading.value = true
const response = await PackageManageService.getPackages({
series_id: props.seriesId,
page: 1,
page_size: 20,
status: 1, // 只获取启用的套餐
shelf_status: 1 // 只获取已上架的套餐
})
if (response.code === 0 && response.data) {
packageOptions.value = response.data.items || []
}
} catch (error: any) {
console.error('加载套餐列表失败:', error)
ElMessage.error(error?.message || '加载套餐列表失败')
} finally {
packageSearchLoading.value = false
}
}
const handlePackageSearch = async (query: string) => {
if (!props.seriesId) return
try {
packageSearchLoading.value = true
const response = await PackageManageService.getPackages({
package_name: query || undefined,
series_id: props.seriesId,
page: 1,
page_size: 20,
status: 1,
shelf_status: 1
})
if (response.code === 0 && response.data) {
packageOptions.value = response.data.items || []
}
} catch (error: any) {
console.error('搜索套餐失败:', error)
} finally {
packageSearchLoading.value = false
}
}
const handleCancel = () => {
visible.value = false
}
const handleConfirm = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (!form.package_id) {
ElMessage.error('请选择套餐')
return
}
loading.value = true
// 创建订单(后端接收数组,前端单选转为单元素数组)
const data: CreateOrderRequest = {
identifier: props.assetIdentifier,
package_ids: [form.package_id],
payment_method: form.payment_method
}
await OrderService.createOrder(data)
// 根据支付方式显示不同的成功消息
if (form.payment_method === 'wallet') {
ElMessage.success('订单创建成功,已自动完成支付')
} else {
ElMessage.success('订单创建成功')
}
visible.value = false
emit('confirm')
emit('success')
} catch (error: any) {
// 用户取消确认对话框
if (error === 'cancel') {
return
}
console.error('创建订单失败:', error)
ElMessage.error(error?.message || '创建订单失败')
} finally {
loading.value = false
}
}
const handleDialogClosed = () => {
formRef.value?.resetFields()
form.package_id = undefined
form.payment_method = 'wallet'
packageOptions.value = []
}
</script>
<style lang="scss" scoped>
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@@ -0,0 +1,94 @@
<template>
<ElDialog v-model="visible" title="设置限速" width="500px">
<ElForm ref="formRef" :model="form" :rules="rules" label-width="120px">
<ElFormItem label="下行速率" prop="download_speed">
<ElInputNumber
v-model="form.download_speed"
:min="1"
:step="128"
controls-position="right"
style="width: 100%"
/>
<div style="margin-top: 4px; font-size: 12px; color: #909399">单位: KB/s</div>
</ElFormItem>
<ElFormItem label="上行速率" prop="upload_speed">
<ElInputNumber
v-model="form.upload_speed"
:min="1"
:step="128"
controls-position="right"
style="width: 100%"
/>
<div style="margin-top: 4px; font-size: 12px; color: #909399">单位: KB/s</div>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="handleCancel">取消</ElButton>
<ElButton type="primary" @click="handleConfirm" :loading="loading"> 确认设置 </ElButton>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { ElDialog, ElForm, ElFormItem, ElInputNumber, ElButton } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
interface Props {
modelValue: boolean
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'confirm', data: { download_speed: number; upload_speed: number }): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const formRef = ref<FormInstance>()
const loading = ref(false)
const form = reactive({
download_speed: 1024,
upload_speed: 512
})
const rules: FormRules = {
download_speed: [{ required: true, message: '请输入下行速率', trigger: 'blur' }],
upload_speed: [{ required: true, message: '请输入上行速率', trigger: 'blur' }]
}
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
// 监听对话框打开,重置表单
watch(visible, (newVal) => {
if (newVal) {
form.download_speed = 1024
form.upload_speed = 512
}
})
const handleCancel = () => {
visible.value = false
}
const handleConfirm = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
emit('confirm', {
download_speed: form.download_speed,
upload_speed: form.upload_speed
})
} catch (error) {
console.error('表单验证失败:', error)
}
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,165 @@
<template>
<ElDialog v-model="visible" title="切换SIM卡" width="600px">
<div v-if="loading" style="padding: 40px; text-align: center">
<ElIcon class="is-loading" :size="40"><Loading /></ElIcon>
<div style="margin-top: 16px">加载设备绑定的卡列表中...</div>
</div>
<template v-else>
<ElAlert
v-if="bindingCards.length === 0"
title="该设备暂无绑定的SIM卡"
type="warning"
:closable="false"
style="margin-bottom: 20px"
>
<template #default>
<div>当前设备没有绑定任何SIM卡,无法进行切换操作</div>
<div>请先为设备绑定SIM卡后再进行切换</div>
</template>
</ElAlert>
<ElForm
v-if="bindingCards.length > 0"
ref="formRef"
:model="form"
:rules="rules"
label-width="120px"
>
<ElFormItem label="目标ICCID" prop="target_iccid">
<ElSelect
v-model="form.target_iccid"
placeholder="请选择要切换到的目标ICCID"
style="width: 100%"
clearable
>
<ElOption
v-for="card in bindingCards"
:key="card.iccid"
:label="`${card.iccid} - 插槽${card.slot_position} - ${card.carrier_name}`"
:value="card.iccid"
>
<div style="display: flex; align-items: center; justify-content: space-between">
<span>{{ card.iccid }}</span>
<ElTag size="small" style="margin-left: 10px">插槽{{ card.slot_position }}</ElTag>
</div>
</ElOption>
</ElSelect>
<div style="margin-top: 8px; font-size: 12px; color: #909399">
当前设备共绑定 {{ bindingCards.length }} 张SIM卡
</div>
</ElFormItem>
</ElForm>
</template>
<template #footer>
<ElButton @click="handleCancel">取消</ElButton>
<ElButton
v-if="bindingCards.length > 0"
type="primary"
@click="handleConfirm"
:loading="confirmLoading"
>
确认切换
</ElButton>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import {
ElDialog,
ElForm,
ElFormItem,
ElSelect,
ElOption,
ElButton,
ElAlert,
ElIcon,
ElTag,
ElMessage
} from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { Loading } from '@element-plus/icons-vue'
import { DeviceService } from '@/api/modules'
interface Props {
modelValue: boolean
deviceId: number
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'confirm', data: { target_iccid: string }): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const formRef = ref<FormInstance>()
const loading = ref(false)
const confirmLoading = ref(false)
const bindingCards = ref<any[]>([])
const form = reactive({
target_iccid: ''
})
const rules: FormRules = {
target_iccid: [{ required: true, message: '请选择目标ICCID', trigger: 'change' }]
}
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
// 监听对话框打开,加载绑定卡列表
watch(visible, async (newVal) => {
if (newVal) {
form.target_iccid = ''
bindingCards.value = []
await loadDeviceCards()
}
})
const loadDeviceCards = async () => {
if (!props.deviceId) return
try {
loading.value = true
const res = await DeviceService.getDeviceCards(props.deviceId)
if (res.code === 0 && res.data) {
bindingCards.value = res.data.bindings || []
if (bindingCards.value.length === 0) {
ElMessage.warning('该设备暂无绑定的SIM卡')
}
}
} catch (error) {
console.error('加载设备绑定卡列表失败:', error)
ElMessage.error('加载卡列表失败')
} finally {
loading.value = false
}
}
const handleCancel = () => {
visible.value = false
}
const handleConfirm = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
emit('confirm', {
target_iccid: form.target_iccid
})
} catch (error) {
console.error('表单验证失败:', error)
}
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,117 @@
<template>
<ElDialog v-model="visible" title="设置WiFi" width="500px">
<ElForm ref="formRef" :model="form" :rules="rules" label-width="120px">
<ElFormItem label="WiFi状态" prop="enabled">
<ElRadioGroup v-model="form.enabled">
<ElRadio :value="1">启用</ElRadio>
<ElRadio :value="0">禁用</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem label="WiFi名称" prop="ssid">
<ElInput
v-model="form.ssid"
placeholder="请输入WiFi名称1-32个字符"
maxlength="32"
show-word-limit
clearable
/>
</ElFormItem>
<ElFormItem label="WiFi密码" prop="password">
<ElInput
v-model="form.password"
type="password"
placeholder="请输入WiFi密码8-63个字符"
maxlength="63"
show-word-limit
show-password
clearable
/>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="handleCancel">取消</ElButton>
<ElButton type="primary" @click="handleConfirm" :loading="loading"> 确认设置 </ElButton>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import {
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElRadioGroup,
ElRadio,
ElButton
} from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
interface Props {
modelValue: boolean
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'confirm', data: { enabled: number; ssid: string; password: string }): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const formRef = ref<FormInstance>()
const loading = ref(false)
const form = reactive({
enabled: 1,
ssid: '',
password: ''
})
const rules: FormRules = {
ssid: [
{ required: true, message: '请输入WiFi名称', trigger: 'blur' },
{ min: 1, max: 32, message: 'WiFi名称长度为1-32个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入WiFi密码', trigger: 'blur' },
{ min: 8, max: 63, message: 'WiFi密码长度为8-63个字符', trigger: 'blur' }
]
}
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
// 监听对话框打开,重置表单
watch(visible, (newVal) => {
if (newVal) {
form.enabled = 1
form.ssid = ''
form.password = ''
}
})
const handleCancel = () => {
visible.value = false
}
const handleConfirm = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
emit('confirm', {
enabled: form.enabled,
ssid: form.ssid,
password: form.password
})
} catch (error) {
console.error('表单验证失败:', error)
}
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,6 @@
export { default as SpeedLimitDialog } from './SpeedLimitDialog.vue'
export { default as SwitchCardDialog } from './SwitchCardDialog.vue'
export { default as WiFiConfigDialog } from './WiFiConfigDialog.vue'
export { default as PackageRechargeDialog } from './PackageRechargeDialog.vue'
export { default as DailyRecordsDialog } from './DailyRecordsDialog.vue'
export { default as OrderHistoryDialog } from './OrderHistoryDialog.vue'

View File

@@ -0,0 +1,249 @@
/**
* 资产信息格式化工具函数
*/
import type { Ref } from 'vue'
export function useAssetFormatters(deviceRealtime?: Ref<any>) {
// 格式化数据大小MB转GB/MB
const formatDataSize = (mb: number) => {
if (mb >= 1024) {
return `${(mb / 1024).toFixed(2)}GB`
}
return `${mb.toFixed(2)}MB`
}
// 格式化金额(分转元)
const formatAmount = (amount: number | undefined) => {
if (amount === undefined || amount === null) return '¥0.00'
const yuan = amount / 100
return `¥${yuan.toFixed(2)}`
}
// 格式化时长(秒转为时分秒)
const formatDuration = (seconds?: string) => {
if (!seconds) return '-'
const sec = Number(seconds)
if (isNaN(sec)) return '-'
const hours = Math.floor(sec / 3600)
const minutes = Math.floor((sec % 3600) / 60)
const secs = sec % 60
if (hours > 0) {
return `${hours}小时${minutes}${secs}`
} else if (minutes > 0) {
return `${minutes}${secs}`
} else {
return `${secs}`
}
}
// 格式化ICCID为4位一组用横杠分隔
const formatIccidWithDashes = (iccid: string) => {
if (!iccid) return ''
return iccid.replace(/(\d{4})(?=\d)/g, '$1-')
}
// 获取实名状态名称
const getRealNameStatusName = (status?: number) => {
if (status === undefined || status === null) return '未知'
const statusMap: Record<number, string> = {
0: '未实名',
1: '已实名'
}
return statusMap[status] || '未知'
}
// 获取实名状态标签类型
const getRealNameStatusType = (status?: number): 'info' | 'success' | 'warning' | 'danger' => {
if (status === undefined || status === null) return 'info'
const map: Record<number, 'info' | 'success' | 'warning' | 'danger'> = {
0: 'info',
1: 'success'
}
return map[status] || 'info'
}
// 获取资产状态名称
const getAssetStatusName = (status?: number) => {
if (status === undefined || status === null) return '未知'
const map: Record<number, string> = {
1: '在库',
2: '已分销',
3: '已激活',
4: '已停用'
}
return map[status] || '未知'
}
// 获取资产状态标签类型
const getAssetStatusType = (
status?: number
): 'primary' | 'success' | 'warning' | 'info' | 'danger' => {
if (status === undefined || status === null) return 'info'
const map: Record<number, 'primary' | 'success' | 'warning' | 'info' | 'danger'> = {
1: 'info', // 在库
2: 'success', // 已分销
3: 'success', // 已激活
4: 'danger' // 已停用
}
return map[status] || 'info'
}
// 获取在线状态名称
const getOnlineStatusName = (status?: number) => {
const map: Record<number, string> = {
0: '未知',
1: '在线',
2: '离线'
}
return map[status ?? 0] || '未知'
}
// 获取在线状态标签类型
const getOnlineStatusType = (
status?: number
): 'primary' | 'success' | 'warning' | 'info' | 'danger' => {
const map: Record<number, 'primary' | 'success' | 'warning' | 'info' | 'danger'> = {
0: 'info', // 未知
1: 'success', // 在线
2: 'danger' // 离线
}
return map[status ?? 0] || 'info'
}
// 获取钱包状态标签类型
const getWalletStatusType = (status?: number) => {
const map: Record<number, any> = {
1: 'success', // 正常
2: 'warning', // 冻结
3: 'danger' // 关闭
}
return map[status || 1] || 'info'
}
// 获取套餐状态标签类型
const getPackageStatusTagType = (
status?: number
): 'primary' | 'success' | 'warning' | 'info' | 'danger' => {
if (status === undefined || status === null) return 'info'
const map: Record<number, 'primary' | 'success' | 'warning' | 'info' | 'danger'> = {
0: 'info', // 待生效
1: 'success', // 生效中
2: 'warning', // 已用完
3: 'danger', // 已过期
4: 'info' // 已失效
}
return map[status] || 'info'
}
// 计算信号强度等级基于RSSI、RSRP、RSRQ
const calculateSignalStrength = () => {
if (!deviceRealtime?.value) return '-'
const rssi = deviceRealtime.value.rssi ? Number(deviceRealtime.value.rssi) : null
const rsrp = deviceRealtime.value.rsrp ? Number(deviceRealtime.value.rsrp) : null
// 优先使用RSRP判断更准确
// RSRP范围通常在-140到-44 dBm之间0或正值表示无效数据
if (rsrp !== null && !isNaN(rsrp) && rsrp < 0) {
if (rsrp >= -80) return '优秀'
if (rsrp >= -90) return '良好'
if (rsrp >= -100) return '一般'
if (rsrp >= -110) return '较差'
return '极差'
}
// 其次使用RSSI
// RSSI范围通常在-113到-51 dBm之间0或正值表示无效数据
if (rssi !== null && !isNaN(rssi) && rssi < 0) {
if (rssi >= -70) return '优秀'
if (rssi >= -85) return '良好'
if (rssi >= -100) return '一般'
if (rssi >= -110) return '较差'
return '极差'
}
return '-'
}
// 获取信号强度标签类型
const getSignalStrengthType = (): 'primary' | 'success' | 'warning' | 'info' | 'danger' => {
const strength = calculateSignalStrength()
const map: Record<string, 'primary' | 'success' | 'warning' | 'info' | 'danger'> = {
: 'success',
: 'success',
: 'warning',
: 'danger',
: 'danger'
}
return map[strength] || 'info'
}
// 获取切卡模式名称
const getSwitchModeName = (mode?: string) => {
const map: Record<string, string> = {
'0': '自动',
'1': '手动'
}
return map[mode || ''] || '-'
}
// 获取交易类型标签颜色
const getTransactionTypeTag = (type: string | undefined) => {
const map: Record<string, any> = {
recharge: 'success', // 充值
deduct: 'danger', // 扣款
refund: 'warning' // 退款
}
return map[type || ''] || 'info'
}
// 获取支付状态标签类型
const getPaymentStatusType = (status: number): 'success' | 'warning' | 'danger' | 'info' => {
const statusMap: Record<number, 'success' | 'warning' | 'danger' | 'info'> = {
1: 'warning', // 待支付
2: 'success', // 已支付
3: 'danger', // 已取消
4: 'info' // 已关闭
}
return statusMap[status] || 'info'
}
// 计算使用进度百分比
const getUsageProgress = (cumulative: number, total: number): number => {
if (!total || total <= 0) return 0
const percentage = (cumulative / total) * 100
return Math.min(Math.round(percentage * 100) / 100, 100)
}
// 根据百分比获取进度条颜色
const getProgressColorByPercentage = (percentage: number): string => {
if (percentage < 50) return '#67c23a'
if (percentage < 80) return '#e6a23c'
return '#f56c6c'
}
return {
formatDataSize,
formatAmount,
formatDuration,
formatIccidWithDashes,
getRealNameStatusName,
getRealNameStatusType,
getAssetStatusName,
getAssetStatusType,
getOnlineStatusName,
getOnlineStatusType,
getWalletStatusType,
getPackageStatusTagType,
calculateSignalStrength,
getSignalStrengthType,
getSwitchModeName,
getTransactionTypeTag,
getPaymentStatusType,
getUsageProgress,
getProgressColorByPercentage
}
}

View File

@@ -0,0 +1,352 @@
/**
* 资产信息数据管理 Composable
* 负责资产详情数据的加载和状态管理
*/
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { AssetService } from '@/api/modules'
import type { AssetWalletResponse } from '@/types/api'
/**
* 格式化数据大小(MB -> GB/MB)
*/
const formatDataSize = (mb: number) => {
if (mb >= 1024) {
return `${(mb / 1024).toFixed(2)}GB`
}
return `${mb.toFixed(2)}MB`
}
export function useAssetInfo() {
// 状态管理
const loading = ref(false)
const cardInfo = ref<any>(null) // 使用 any 以保持与原始实现一致
const deviceRealtime = ref<DeviceRealtimeInfo | null>(null)
const currentPackage = ref<PackageInfo | null>(null)
const currentPackageLoading = ref(false)
const currentPackageErrorMsg = ref('')
const packageList = ref<PackageInfo[]>([])
const walletInfo = ref<AssetWalletResponse | null>(null)
const walletLoading = ref(false)
/**
* 获取资产详情
* @param identifier - 资产标识符(ICCID、IMEI、SN、虚拟号或MSISDN)
*/
const fetchAssetDetail = async (identifier: string) => {
try {
loading.value = true
const response = await AssetService.resolveAsset(identifier)
if (response.code === 0 && response.data) {
const data = response.data
// 映射API数据到页面显示格式
cardInfo.value = {
// 基本信息
asset_type: data.asset_type, // card 或 device
asset_id: data.asset_id,
virtual_no: data.virtual_no,
status: data.status,
batch_no: data.batch_no,
shop_id: data.shop_id,
shop_name: data.shop_name,
series_id: data.series_id,
series_name: data.series_name,
real_name_status: data.real_name_status,
activated_at: data.activated_at,
created_at: data.created_at,
updated_at: data.updated_at,
accumulated_recharge: data.accumulated_recharge,
first_commission_paid: data.first_commission_paid,
// 卡专属字段 (asset_type === 'card')
iccid: data.iccid || '',
msisdn: data.msisdn || '',
imsi: data.imsi || '',
carrier_id: data.carrier_id,
carrier_type: data.carrier_type || '',
carrier_name: data.carrier_name || '',
supplier: data.supplier || '',
network_status: data.network_status,
bound_device_id: data.bound_device_id,
bound_device_no: data.bound_device_no || '',
bound_device_name: data.bound_device_name || '',
card_category: data.card_category || '',
activation_status: data.activation_status,
enable_polling: data.enable_polling,
// 设备专属字段 (asset_type === 'device')
device_name: data.device_name || '',
device_model: data.device_model || '',
device_type: data.device_type || '',
manufacturer: data.manufacturer || '',
imei: data.imei || '',
sn: data.sn || '',
max_sim_slots: data.max_sim_slots || 0,
bound_card_count: data.bound_card_count || 0,
cards: data.cards || [],
device_protect_status: data.device_protect_status,
online_status: data.online_status,
last_online_time: data.last_online_time,
software_version: data.software_version,
switch_mode: data.switch_mode,
last_gateway_sync_at: data.last_gateway_sync_at,
// 流量/套餐信息
current_package: data.current_package || '',
packageSeries: data.series_name || '-',
packageTotalFlow: formatDataSize(data.package_total_mb || 0),
usedFlow: formatDataSize(data.package_used_mb || 0),
remainFlow: formatDataSize(data.package_remain_mb || 0),
usedFlowPercentage:
data.package_total_mb > 0
? `${((data.package_used_mb / data.package_total_mb) * 100).toFixed(2)}%`
: '0.00%',
packageList: [] // 套餐列表需要单独调用API获取
}
// 获取资产标识符并加载其他数据
const assetIdentifier = data.asset_type === 'card' ? data.iccid : data.virtual_no
if (assetIdentifier) {
// 并行调用多个接口: 当前生效套餐、套餐列表、实时状态、钱包概况
Promise.all([
loadCurrentPackage(assetIdentifier),
loadPackageList(assetIdentifier),
loadRealtimeStatus(assetIdentifier, data.asset_type),
loadAssetWallet(assetIdentifier)
])
}
} else {
ElMessage.error(response.msg || '查询失败')
cardInfo.value = null
}
} catch (error: any) {
console.error('获取卡片信息失败:', error)
if (error?.response?.status === 404) {
ElMessage.error('未找到该资产')
} else if (error?.response?.status === 403) {
ElMessage.error('无权限访问该资产')
} else {
ElMessage.error(error?.message || '获取卡片信息失败')
}
cardInfo.value = null
} finally {
loading.value = false
}
}
/**
* 加载套餐列表
* @param identifier - 资产标识符
*/
const loadPackageList = async (identifier: string) => {
try {
const response = await AssetService.getAssetPackages(identifier, {
page: 1,
page_size: 50
})
if (response.code === 0 && response.data) {
// 使用分页响应中的 items 数组
cardInfo.value.packageList = response.data.items || []
}
} catch (error) {
console.error('获取套餐列表失败:', error)
}
}
/**
* 加载当前生效套餐
* @param identifier - 资产标识符
*/
const loadCurrentPackage = async (identifier: string) => {
try {
currentPackageLoading.value = true
// 清空之前的错误信息
currentPackageErrorMsg.value = ''
const response = await AssetService.getCurrentPackage(identifier)
if (response.code === 0) {
if (response.data) {
const pkg = response.data
// 保存完整的当前套餐数据
cardInfo.value.currentPackageDetail = {
package_usage_id: pkg.package_usage_id,
package_id: pkg.package_id,
package_name: pkg.package_name,
package_type: pkg.package_type,
status: pkg.status,
status_name: pkg.status_name,
data_limit_mb: pkg.data_limit_mb,
data_usage_mb: pkg.data_usage_mb,
virtual_limit_mb: pkg.virtual_limit_mb,
virtual_used_mb: pkg.virtual_used_mb,
virtual_remain_mb: pkg.virtual_remain_mb,
virtual_ratio: pkg.virtual_ratio,
activated_at: pkg.activated_at,
expires_at: pkg.expires_at,
created_at: pkg.created_at,
master_usage_id: pkg.master_usage_id,
priority: pkg.priority,
usage_type: pkg.usage_type
}
// 同时更新流量显示信息
cardInfo.value.current_package = pkg.package_name
cardInfo.value.packageTotalFlow = formatDataSize(pkg.virtual_limit_mb || 0)
cardInfo.value.usedFlow = formatDataSize(pkg.virtual_used_mb || 0)
cardInfo.value.remainFlow = formatDataSize(pkg.virtual_remain_mb || 0)
cardInfo.value.usedFlowPercentage =
(pkg.virtual_limit_mb || 0) > 0
? `${(((pkg.virtual_used_mb || 0) / (pkg.virtual_limit_mb || 1)) * 100).toFixed(2)}%`
: '0.00%'
} else {
// code 为 0 但 data 为空,表示暂无当前生效套餐(正常情况)
currentPackageErrorMsg.value = '暂无当前生效套餐'
}
} else {
// 接口返回 code 不为 0,保存错误信息
currentPackageErrorMsg.value = response.msg || '获取当前套餐失败'
}
} catch (error: any) {
// 404表示无当前生效套餐,这是正常情况
if (error?.response?.status === 404) {
currentPackageErrorMsg.value = '暂无当前生效套餐'
} else {
console.error('获取当前套餐失败:', error)
currentPackageErrorMsg.value = error?.message || '获取当前套餐失败'
}
} finally {
currentPackageLoading.value = false
}
}
/**
* 加载实时状态
* @param identifier - 资产标识符
* @param assetType - 资产类型(card/device)
*/
const loadRealtimeStatus = async (identifier: string, assetType: string) => {
try {
const response = await AssetService.getRealtimeStatus(identifier)
if (response.code === 0 && response.data) {
const data = response.data
// 更新实时状态数据
if (assetType === 'card') {
// 卡的实时状态
if (data.network_status !== undefined) {
cardInfo.value.network_status = data.network_status
}
if (data.real_name_status !== undefined) {
cardInfo.value.real_name_status = data.real_name_status
}
if (data.last_sync_time !== undefined) {
cardInfo.value.last_sync_time = data.last_sync_time
}
if (data.current_month_usage_mb !== undefined) {
cardInfo.value.current_month_usage_mb = data.current_month_usage_mb
}
} 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) {
cardInfo.value.cards = data.cards
}
if (data.online_status !== undefined) {
cardInfo.value.online_status = data.online_status
}
if (data.last_online_time !== undefined) {
cardInfo.value.last_online_time = data.last_online_time
}
if (data.software_version !== undefined) {
cardInfo.value.software_version = data.software_version
}
if (data.switch_mode !== undefined) {
cardInfo.value.switch_mode = data.switch_mode
}
if (data.last_gateway_sync_at !== undefined) {
cardInfo.value.last_gateway_sync_at = data.last_gateway_sync_at
}
// 设备实时信息(Gateway 数据)
if (data.device_realtime !== undefined) {
deviceRealtime.value = data.device_realtime
console.log('设备实时信息:', deviceRealtime.value)
}
}
}
} catch (error) {
console.error('获取实时状态失败:', error)
}
}
/**
* 加载钱包概况
* @param identifier - 资产标识符
*/
const loadAssetWallet = async (identifier: string) => {
try {
walletLoading.value = true
const response = await AssetService.getAssetWallet(identifier)
if (response.code === 0 && response.data) {
walletInfo.value = response.data
}
} catch (error: any) {
if (error?.response?.status === 403) {
// 企业账号禁止查看钱包信息,不显示错误
walletInfo.value = null
} else {
console.error('获取钱包概况失败:', error)
}
} finally {
walletLoading.value = false
}
}
/**
* 刷新资产数据(调用refresh接口后重新加载)
* @param identifier - 资产标识符
* @param assetType - 资产类型(card/device)
*/
const refreshAsset = async (identifier: string, assetType: string) => {
try {
await AssetService.refreshAsset(identifier)
ElMessage.success('刷新成功')
// 刷新成功后重新加载实时状态数据
if (identifier) {
await loadRealtimeStatus(identifier, assetType)
}
} catch (error: any) {
// 检查429错误(冷却期)
if (error?.response?.status === 429) {
ElMessage.warning('刷新过于频繁,请稍后再试')
} else {
ElMessage.error(error?.message || '刷新失败')
}
throw error
}
}
return {
// State
loading,
cardInfo,
deviceRealtime,
currentPackage,
currentPackageLoading,
currentPackageErrorMsg,
packageList,
walletInfo,
walletLoading,
// Methods
fetchAssetDetail,
loadPackageList,
loadCurrentPackage,
loadRealtimeStatus,
loadAssetWallet,
refreshAsset
}
}

View File

@@ -0,0 +1,319 @@
import { ref } from 'vue'
import type { Ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { AssetService, DeviceService } from '@/api/modules'
/**
* Asset operations composable
* Manages asset operation methods (enable/disable cards, device operations, etc.)
*/
export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Promise<void>) {
// Loading states
const enableCardLoading = ref(false)
const disableCardLoading = ref(false)
const manualDeactivateCardLoading = ref(false)
const rebootDeviceLoading = ref(false)
const resetDeviceLoading = ref(false)
const speedLimitLoading = ref(false)
const switchCardLoading = ref(false)
const setWiFiLoading = ref(false)
const pollingLoading = ref(false)
/**
* Enable card
*/
const enableCard = async () => {
try {
await ElMessageBox.confirm('确认要启用此卡吗?', '提示', {
type: 'warning'
})
enableCardLoading.value = true
const res = await AssetService.startAsset(cardInfo.value.iccid)
if (res.code === 0) {
ElMessage.success('启用成功')
if (refreshAssetFn) {
await refreshAssetFn()
}
}
} catch (error: any) {
if (error !== 'cancel') {
console.error('启用失败:', error)
ElMessage.error(error?.message || '启用失败')
}
} finally {
enableCardLoading.value = false
}
}
/**
* Disable card with confirmation
*/
const disableCard = async () => {
try {
await ElMessageBox.confirm('确认要停用此卡吗?', '提示', {
type: 'warning'
})
disableCardLoading.value = true
const res = await AssetService.stopAsset(cardInfo.value.iccid)
if (res.code === 0) {
ElMessage.success('停用成功')
if (refreshAssetFn) {
await refreshAssetFn()
}
}
} catch (error: any) {
if (error !== 'cancel') {
console.error('停用失败:', error)
ElMessage.error(error?.message || '停用失败')
}
} finally {
disableCardLoading.value = false
}
}
/**
* Manually deactivate card with strong warning confirmation
*/
const manualDeactivateCard = async () => {
try {
await ElMessageBox.confirm(
'手动停用后卡片将永久停用,无法再次使用。此操作不可逆,请谨慎操作。确认要手动停用吗?',
'警告',
{
type: 'error',
confirmButtonText: '确认停用',
cancelButtonText: '取消'
}
)
manualDeactivateCardLoading.value = true
const identifier =
cardInfo.value.asset_type === 'card' ? cardInfo.value.iccid : cardInfo.value.virtual_no
const res = await AssetService.deactivateAsset(identifier)
if (res.code === 0) {
ElMessage.success('手动停用成功')
if (refreshAssetFn) {
await refreshAssetFn()
}
}
} catch (error: any) {
if (error !== 'cancel') {
console.error('手动停用失败:', error)
ElMessage.error(error?.message || '手动停用失败')
}
} finally {
manualDeactivateCardLoading.value = false
}
}
/**
* Reboot device with confirmation
*/
const rebootDevice = async () => {
try {
await ElMessageBox.confirm('确认要重启设备吗?', '提示', {
type: 'warning'
})
rebootDeviceLoading.value = true
const res = await DeviceService.rebootDevice(cardInfo.value.imei)
if (res.code === 0) {
ElMessage.success(res.data?.message || '重启指令已发送')
if (refreshAssetFn) {
await refreshAssetFn()
}
}
} catch (error: any) {
if (error !== 'cancel') {
console.error('重启失败:', error)
ElMessage.error(error?.message || '重启失败')
}
} finally {
rebootDeviceLoading.value = false
}
}
/**
* Factory reset device with strong warning confirmation
*/
const resetDevice = async () => {
try {
await ElMessageBox.confirm('恢复出厂设置将清除设备所有数据和配置,确认要继续吗?', '警告', {
type: 'error',
confirmButtonText: '确认恢复',
cancelButtonText: '取消'
})
resetDeviceLoading.value = true
const res = await DeviceService.resetDevice(cardInfo.value.imei)
if (res.code === 0) {
ElMessage.success(res.data?.message || '恢复出厂设置指令已发送')
if (refreshAssetFn) {
await refreshAssetFn()
}
}
} catch (error: any) {
if (error !== 'cancel') {
console.error('恢复出厂失败:', error)
ElMessage.error(error?.message || '恢复出厂失败')
}
} finally {
resetDeviceLoading.value = false
}
}
/**
* Set device speed limit
*/
const setSpeedLimit = async (form: { download_speed: number; upload_speed: number }) => {
try {
speedLimitLoading.value = true
const res = await DeviceService.setSpeedLimit(cardInfo.value.imei, {
download_speed: form.download_speed,
upload_speed: form.upload_speed
})
if (res.code === 0) {
ElMessage.success('限速设置成功')
if (refreshAssetFn) {
await refreshAssetFn()
}
return true
} else {
ElMessage.error(res.msg || '设置失败')
return false
}
} catch (error: any) {
console.error('设置限速失败:', error)
ElMessage.error(error?.message || '设置失败')
return false
} finally {
speedLimitLoading.value = false
}
}
/**
* Switch to different card
*/
const switchCard = async (form: { target_iccid: string }) => {
try {
switchCardLoading.value = true
const res = await DeviceService.switchCard(cardInfo.value.imei, {
target_iccid: form.target_iccid
})
if (res.code === 0) {
ElMessage.success('切换SIM卡指令已发送')
if (refreshAssetFn) {
await refreshAssetFn()
}
return true
} else {
ElMessage.error(res.msg || '切换失败')
return false
}
} catch (error: any) {
console.error('切换SIM卡失败:', error)
ElMessage.error(error?.message || '切换失败')
return false
} finally {
switchCardLoading.value = false
}
}
/**
* Configure WiFi settings
*/
const setWiFi = async (form: { enabled: number; ssid: string; password: string }) => {
try {
setWiFiLoading.value = true
const res = await DeviceService.setWiFi(cardInfo.value.imei, {
enabled: form.enabled,
ssid: form.ssid,
password: form.password
})
if (res.code === 0) {
ElMessage.success('WiFi设置成功')
if (refreshAssetFn) {
await refreshAssetFn()
}
return true
} else {
ElMessage.error(res.msg || '设置失败')
return false
}
} catch (error: any) {
console.error('设置WiFi失败:', error)
ElMessage.error(error?.message || '设置WiFi失败')
return false
} finally {
setWiFiLoading.value = false
}
}
/**
* Update polling status
*/
const updatePollingStatus = async (enabled: boolean) => {
if (!cardInfo.value) {
ElMessage.warning('无法更新轮询状态:缺少资产信息')
return false
}
try {
pollingLoading.value = true
const identifier =
cardInfo.value.asset_type === 'card' ? cardInfo.value.iccid : cardInfo.value.virtual_no
const response = await AssetService.updatePollingStatus(identifier, {
enable_polling: enabled
})
if (response.code === 0) {
ElMessage.success(enabled ? '已开启自动轮询' : '已关闭自动轮询')
// Update local state
if (cardInfo.value) {
cardInfo.value.enable_polling = enabled
}
return true
} else {
ElMessage.error(response.msg || '更新轮询状态失败')
return false
}
} catch (error: any) {
console.error('更新轮询状态失败:', error)
ElMessage.error(error?.message || '更新轮询状态失败')
return false
} finally {
pollingLoading.value = false
}
}
return {
// Loading states
enableCardLoading,
disableCardLoading,
manualDeactivateCardLoading,
rebootDeviceLoading,
resetDeviceLoading,
speedLimitLoading,
switchCardLoading,
setWiFiLoading,
pollingLoading,
// IoT card operations
enableCard,
disableCard,
manualDeactivateCard,
// Device operations
rebootDevice,
resetDevice,
setSpeedLimit,
switchCard,
setWiFi,
// Polling
updatePollingStatus
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,141 @@
/**
* 资产信息页面类型定义
*/
// 资产类型枚举
export type AssetType = 'card' | 'device'
// 资产基本信息接口
export interface AssetInfo {
id: number
asset_id: number
asset_type: AssetType
identifier: string
iccid?: string
imei?: string
msisdn?: string
carrier_name?: string
real_name_status?: number
network_status?: string
current_month_used_data?: number
device_type?: string
device_name?: string
device_status?: number
online_status?: number
virtual_no?: string
sn?: string
cards?: BindingCard[]
status?: number
}
// 设备绑定卡信息
export interface BindingCard {
id: number
iccid: string
is_current: boolean
status?: number
carrier_name?: string
}
// 设备实时信息接口
export interface DeviceRealtimeInfo {
device_id?: number
status?: string
online_status?: number
report_time?: string
boot_time?: string
running_time?: string
rssi?: string
rsrp?: string
rsrq?: string
sinr?: string
band?: string
pci?: string
wifi_enabled?: number
wifi_ssid?: string
wifi_password?: string
network_type?: string
ip_address?: string
apn?: string
current_iccid?: string
uplink_rate?: number
downlink_rate?: number
uplink_speed_limit?: number
downlink_speed_limit?: number
switch_mode?: string
imei?: string
model?: string
firmware_version?: string
}
// 套餐信息接口
export interface PackageInfo {
id: number
package_id: number
package_name: string
package_price: number
package_total_data: number
real_data_used: number
real_data_remaining: number
real_data_total: number
virtual_data_used: number
virtual_data_remaining: number
virtual_data_total: number
start_time: string
expire_time: string
status: number
duration_days?: number
}
// 钱包信息接口 (使用已有的 AssetWalletResponse)
export interface WalletInfo {
total_balance: number
available_balance: number
frozen_balance: number
status: number
}
// 限速表单接口
export interface SpeedLimitForm {
download_speed: number
upload_speed: number
}
// 切卡表单接口
export interface SwitchCardForm {
target_iccid: string
}
// WiFi配置表单接口
export interface WiFiForm {
enabled: number
ssid: string
password: string
}
// 充值表单接口
export interface RechargeForm {
package_id?: number
payment_method: 'wallet' | 'offline'
payment_voucher?: string
}
// 流量详单日记录
export interface DailyRecord {
date: string
data_used: number
}
// 流量详单数据
export interface DailyRecordsData {
package_info: PackageInfo
records: DailyRecord[]
}
// 订单历史数据
export interface OrderHistoryData {
current_order?: any
previous_orders: any[]
total: number
replacement_chain?: string[]
}

View File

@@ -540,7 +540,10 @@
carrier_name: form.carrier_name,
carrier_type: form.carrier_type as CarrierType,
description: form.description || undefined,
data_reset_day: form.data_reset_day || null
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
}
if (dialogType.value === 'add') {
@@ -550,7 +553,10 @@
const updateData = {
carrier_name: form.carrier_name,
description: form.description || undefined,
data_reset_day: form.data_reset_day || null
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
}
await CarrierService.updateCarrier(form.id, updateData)
ElMessage.success('修改成功')