Files
one-pipe-system/src/views/asset-management/asset-information/components/BasicInfoCard.vue
luo 65cc63674e
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m44s
fix: 格式化时间
2026-09-08 14:33:59 +08:00

1794 lines
55 KiB
Vue

<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">
<ElButton v-if="auditTarget" type="primary" link @click="openAssetAudit">
{{ isSubjectActivityView ? '活动记录' : '审计记录' }}
</ElButton>
<ElButton
v-if="
cardInfo?.asset_type === 'card' &&
hasAuth(JULY_PERMISSIONS.speedTier.view) &&
hasAuth(JULY_PERMISSIONS.speedTier.update)
"
type="primary"
link
@click="emit('showSpeedLimit')"
>
设置限速
</ElButton>
<ElTooltip content="开启后系统将自动轮询更新资产状态" placement="top">
<div v-if="canShowPolling" 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
class="asset-descriptions"
:column="descriptionsColumn"
direction="vertical"
border
size="large"
>
<!-- 卡专属字段 -->
<template v-if="cardInfo?.asset_type === 'card'">
<ElDescriptionsItem
label="ICCID"
label-class-name="asset-label asset-label--identifier-iccid"
>{{ cardInfo?.iccid || '-' }}</ElDescriptionsItem
>
<ElDescriptionsItem
label="MSISDN"
label-class-name="asset-label asset-label--identifier-msisdn"
>{{ cardInfo?.msisdn || '-' }}</ElDescriptionsItem
>
<ElDescriptionsItem
label="运营商账户"
label-class-name="asset-label asset-label--operator-account"
>{{ carrierAccountDisplay }}</ElDescriptionsItem
>
<ElDescriptionsItem
label="实名状态"
label-class-name="asset-label asset-label--realname-status"
>
<ElTag :type="getRealNameStatusType(cardInfo?.real_name_status)" size="small">
{{ getRealNameStatusName(cardInfo?.real_name_status) }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem
v-if="canShowSensitiveFields"
label="实名认证策略"
label-class-name="asset-label asset-label--realname-policy"
>
{{ getRealnamePolicyName(cardInfo?.realname_policy) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="店铺" label-class-name="asset-label asset-label--shop">{{
cardInfo?.shop_name || '-'
}}</ElDescriptionsItem>
<ElDescriptionsItem
label="套餐系列"
label-class-name="asset-label asset-label--package-series"
>{{ cardInfo?.series_name || '-' }}</ElDescriptionsItem
>
<ElDescriptionsItem
label="虚拟号"
label-class-name="asset-label asset-label--virtual-number"
>{{ cardInfo?.virtual_no || '-' }}</ElDescriptionsItem
>
<ElDescriptionsItem
label="网络状态"
label-class-name="asset-label asset-label--network-status"
>
<ElTag :type="cardInfo?.network_status === 1 ? 'success' : 'danger'" size="small">
{{ cardInfo?.network_status === 1 ? '正常' : '停机' }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem
v-if="canShowCardMonthUsage"
label="本月已用流量"
label-class-name="asset-label asset-label--usage-month"
>{{ formatDataSize(cardInfo?.current_month_usage_mb || 0) }}</ElDescriptionsItem
>
<ElDescriptionsItem
v-if="canShowCardMonthUsage"
label="运营商周期月内已用流量"
label-class-name="asset-label asset-label--usage-cycle"
>{{ formatDataSize(cardInfo?.last_gateway_reading_mb || 0) }}</ElDescriptionsItem
>
<ElDescriptionsItem
label="实名时间"
label-class-name="asset-label asset-label--realname-time"
>{{
cardInfo?.real_name_at ? formatAssetInfoTime(cardInfo.real_name_at) : '-'
}}</ElDescriptionsItem
>
<ElDescriptionsItem
label="实名同步时间"
label-class-name="asset-label asset-label--realname-sync"
>{{ formatAssetInfoTime(cardInfo?.last_real_name_check_at) }}</ElDescriptionsItem
>
<ElDescriptionsItem
label="IMEI"
label-class-name="asset-label asset-label--identifier-imei"
>{{ cardInfo?.gateway_card_imei || '-' }}</ElDescriptionsItem
>
<ElDescriptionsItem
label="资产状态"
label-class-name="asset-label asset-label--asset-status"
>
<ElTag :type="getAssetStatusType(cardInfo?.status)" size="small">
{{ getAssetStatusName(cardInfo?.status) }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem
label="卡状态同步时间"
label-class-name="asset-label asset-label--card-sync"
>{{ formatAssetInfoTime(cardInfo?.last_card_status_check_at) }}</ElDescriptionsItem
>
<ElDescriptionsItem
label="流量同步时间"
label-class-name="asset-label asset-label--traffic-sync"
>{{ formatAssetInfoTime(cardInfo?.last_data_check_at) }}</ElDescriptionsItem
>
<ElDescriptionsItem
label="运营商状态"
label-class-name="asset-label asset-label--operator-status"
:span="1"
>
<span class="gateway-extend-text">{{ cardInfo?.gateway_extend || '-' }}</span>
</ElDescriptionsItem>
<ElDescriptionsItem
label="所有套餐已用量"
label-class-name="asset-label asset-label--package-used"
>
{{ formatUsageSummaryMb(cardInfo?.total_virtual_used_mb) }}
</ElDescriptionsItem>
<ElDescriptionsItem
label="所有套餐剩余量"
label-class-name="asset-label asset-label--package-remaining"
>
{{ formatUsageSummaryMb(cardInfo?.total_virtual_remaining_mb) }}
</ElDescriptionsItem>
<ElDescriptionsItem
label="预计套餐到期时间"
label-class-name="asset-label asset-label--package-expire-estimate"
>
<span
:class="getExpiryEstimateClass(cardInfo)"
:title="getExpiryEstimateTooltip(cardInfo)"
>
{{ getExpiryEstimateText(cardInfo) }}
</span>
</ElDescriptionsItem>
<template v-if="cardInfo?.bound_device_id">
<ElDescriptionsItem
label="绑定设备号"
label-class-name="asset-label asset-label--bound-device-number"
>
<ElButton
type="primary"
link
@click="emit('navigateToDevice', cardInfo!.bound_device_no!)"
>
{{ cardInfo?.bound_device_no || '-' }}
</ElButton>
</ElDescriptionsItem>
<ElDescriptionsItem
label="绑定设备名称"
label-class-name="asset-label asset-label--bound-device-name"
>
{{ cardInfo?.bound_device_name || '-' }}
</ElDescriptionsItem>
</template>
</template>
<!-- 设备专属字段 -->
<template v-if="cardInfo?.asset_type === 'device'">
<ElDescriptionsItem
label="IMEI"
label-class-name="asset-label asset-label--identifier-imei"
>{{ cardInfo?.imei || '-' }}</ElDescriptionsItem
>
<ElDescriptionsItem
label="设备名称"
label-class-name="asset-label asset-label--device-name"
>{{ cardInfo?.device_name || '-' }}</ElDescriptionsItem
>
<ElDescriptionsItem label="店铺" label-class-name="asset-label asset-label--shop">{{
cardInfo?.shop_name || '-'
}}</ElDescriptionsItem>
<ElDescriptionsItem
label="设备状态"
label-class-name="asset-label asset-label--device-status"
>
<ElTag :type="getAssetStatusType(cardInfo?.status)" size="small">
{{ getAssetStatusName(cardInfo?.status) }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem
label="实名状态"
label-class-name="asset-label asset-label--realname-status"
>
<ElTag
v-if="selectedDeviceStatusCard"
:type="getRealNameStatusType(selectedDeviceStatusCard.real_name_status)"
size="small"
>
{{ getRealNameStatusName(selectedDeviceStatusCard.real_name_status) }}
</ElTag>
<span v-else>-</span>
</ElDescriptionsItem>
<ElDescriptionsItem
label="虚拟号"
label-class-name="asset-label asset-label--virtual-number"
>{{ cardInfo?.virtual_no || '-' }}</ElDescriptionsItem
>
<ElDescriptionsItem
label="设备型号"
label-class-name="asset-label asset-label--device-model"
>{{ cardInfo?.device_model || '-' }}</ElDescriptionsItem
>
<ElDescriptionsItem
label="实名时间"
label-class-name="asset-label asset-label--realname-time"
>{{
selectedDeviceStatusCard?.real_name_at
? formatAssetInfoTime(selectedDeviceStatusCard.real_name_at)
: '-'
}}</ElDescriptionsItem
>
<ElDescriptionsItem
label="在线状态"
label-class-name="asset-label asset-label--online-status"
>
<ElTag
v-if="cardInfo?.online_status !== undefined"
:type="getOnlineStatusType(cardInfo?.online_status)"
size="small"
>
{{ getOnlineStatusName(cardInfo?.online_status) }}
</ElTag>
<span v-else>-</span>
</ElDescriptionsItem>
<ElDescriptionsItem
label="激活状态"
label-class-name="asset-label asset-label--activation-status"
>
{{ cardInfo?.activation_status_name || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem
label="切卡模式"
label-class-name="asset-label asset-label--switch-mode"
>{{ getSwitchModeName(cardInfo?.switch_mode) }}</ElDescriptionsItem
>
<ElDescriptionsItem
label="已绑定卡数"
label-class-name="asset-label asset-label--bound-card-count"
>{{ cardInfo?.bound_card_count || 0 }}</ElDescriptionsItem
>
<ElDescriptionsItem
label="最大卡槽数"
label-class-name="asset-label asset-label--max-slots"
>{{ cardInfo?.max_sim_slots || '-' }}</ElDescriptionsItem
>
<ElDescriptionsItem
v-if="canShowSensitiveFields"
label="实名认证策略"
label-class-name="asset-label asset-label--realname-policy"
>
{{ getRealnamePolicyName(cardInfo?.realname_policy) }}
</ElDescriptionsItem>
<ElDescriptionsItem
label="设备类型"
label-class-name="asset-label asset-label--device-type"
>{{ cardInfo?.device_type || '-' }}</ElDescriptionsItem
>
<ElDescriptionsItem
v-if="canShowSensitiveFields"
label="制造商"
label-class-name="asset-label asset-label--manufacturer"
>{{ cardInfo?.manufacturer || '-' }}</ElDescriptionsItem
>
<ElDescriptionsItem
label="最后在线时间"
label-class-name="asset-label asset-label--last-online"
>{{ formatDateTime(cardInfo?.last_online_time) || '-' }}</ElDescriptionsItem
>
<ElDescriptionsItem label="SN" label-class-name="asset-label asset-label--identifier-sn">{{
cardInfo?.sn || '-'
}}</ElDescriptionsItem>
<ElDescriptionsItem
label="所有套餐已用量"
label-class-name="asset-label asset-label--package-used"
>
{{ formatUsageSummaryMb(cardInfo?.total_virtual_used_mb) }}
</ElDescriptionsItem>
<ElDescriptionsItem
label="所有套餐剩余量"
label-class-name="asset-label asset-label--package-remaining"
>
{{ formatUsageSummaryMb(cardInfo?.total_virtual_remaining_mb) }}
</ElDescriptionsItem>
<ElDescriptionsItem
label="预计套餐到期时间"
label-class-name="asset-label asset-label--package-expire-estimate"
>
<span
:class="getExpiryEstimateClass(cardInfo)"
:title="getExpiryEstimateTooltip(cardInfo)"
>
{{ getExpiryEstimateText(cardInfo) }}
</span>
</ElDescriptionsItem>
<ElDescriptionsItem
label="最后同步时间"
label-class-name="asset-label asset-label--last-sync"
>{{ formatDateTime(deviceRealtime?.last_sync_time) || '-' }}</ElDescriptionsItem
>
</template>
</ElDescriptions>
<template v-if="previousExchangeAsset || nextExchangeAsset">
<ElDivider content-position="left">换货链路</ElDivider>
<ElDescriptions :column="descriptionsColumn" border size="large">
<ElDescriptionsItem v-if="previousExchangeAsset" label="前代资产">
<div class="exchange-asset">
<ElTag type="success" size="small">旧资产</ElTag>
<ElButton
v-if="previousExchangeAsset.can_view"
type="primary"
link
@click="handleNavigateToExchangeAsset(previousExchangeAsset)"
>
{{ previousExchangeAsset.identifier }}
</ElButton>
<span v-else>{{ previousExchangeAsset.identifier }}</span>
<span v-if="previousExchangeAsset.exchange_no" class="exchange-no">
换货单:{{ previousExchangeAsset.exchange_no }}
</span>
</div>
</ElDescriptionsItem>
<ElDescriptionsItem v-if="nextExchangeAsset" label="后代资产">
<div class="exchange-asset">
<ElTag type="warning" size="small">已换出旧资产</ElTag>
<ElButton
v-if="nextExchangeAsset.can_view"
type="primary"
link
@click="handleNavigateToExchangeAsset(nextExchangeAsset)"
>
{{ nextExchangeAsset.identifier }}
</ElButton>
<span v-else>{{ nextExchangeAsset.identifier }}</span>
<span v-if="nextExchangeAsset.exchange_no" class="exchange-no">
换货单:{{ nextExchangeAsset.exchange_no }}
</span>
</div>
</ElDescriptionsItem>
</ElDescriptions>
</template>
<!-- 设备绑定卡列表 -->
<template v-if="cardInfo?.asset_type === 'device'">
<ElDivider class="info-section-divider" content-position="left">
<span class="section-divider-title">绑定卡列表</span>
</ElDivider>
<div
v-if="cardInfo.cards && cardInfo.cards.length > 0"
class="binding-card-list"
:class="`binding-card-list--${Math.min(sortedDeviceCards.length, 3)}`"
>
<ElCard
v-for="bindingCard in sortedDeviceCards"
:key="bindingCard.iccid"
class="binding-card"
shadow="never"
:class="{ 'binding-card--current': bindingCard.is_current }"
>
<div class="binding-card-header">
<div class="binding-card-identifier">
<ElButton type="primary" link @click="handleNavigateToCardInfo(bindingCard.iccid)">
{{ bindingCard.iccid }}
</ElButton>
<el-icon class="copy-icon" @click="handleCopyICCID(bindingCard.iccid)" title="复制">
<CopyDocument />
</el-icon>
</div>
<ElTag
v-if="bindingCard.is_current"
class="binding-card-current-tag"
type="primary"
size="default"
>
当前
</ElTag>
</div>
<div class="binding-card-details">
<div class="binding-card-field">
<span class="binding-card-field-label">MSISDN</span>
<span class="binding-card-field-value">{{ bindingCard.msisdn || '-' }}</span>
</div>
<div class="binding-card-field">
<span class="binding-card-field-label">运营商</span>
<span class="binding-card-field-value">{{ getCarrierAccountDisplay(bindingCard) }}</span>
</div>
<div class="binding-card-field">
<span class="binding-card-field-label">卡槽位置</span>
<span class="binding-card-field-value">
SIM-{{ bindingCard.slot_position ?? '-' }}
</span>
</div>
<div class="binding-card-field">
<span class="binding-card-field-label">网络状态</span>
<span class="binding-card-field-value">
<ElTag :type="bindingCard.network_status === 1 ? 'success' : 'danger'" size="small">
{{ bindingCard.network_status === 1 ? '正常' : '停机' }}
</ElTag>
</span>
</div>
<div class="binding-card-field">
<span class="binding-card-field-label">实名状态</span>
<span class="binding-card-field-value">
<ElTag :type="getRealNameStatusType(bindingCard.real_name_status)" size="small">
{{ getRealNameStatusName(bindingCard.real_name_status) }}
</ElTag>
</span>
</div>
<div class="binding-card-field">
<span class="binding-card-field-label">实名认证策略</span>
<span class="binding-card-field-value">
{{ getRealnamePolicyName(bindingCard.realname_policy) }}
</span>
</div>
<div class="binding-card-field">
<span class="binding-card-field-label">实名时间</span>
<span class="binding-card-field-value">
{{
bindingCard.real_name_at
? formatAssetInfoTime(bindingCard.real_name_at)
: '-'
}}
</span>
</div>
<div class="binding-card-field">
<span class="binding-card-field-label">运营商停机原因</span>
<span class="binding-card-field-value">{{ bindingCard.gateway_extend || '-' }}</span>
</div>
</div>
<div class="binding-card-actions">
<ElButton
v-if="bindingCard.network_status === 0"
type="success"
size="default"
:disabled="isBindingStartDisabled(bindingCard)"
@click="handleEnableBindingCard(bindingCard)"
v-permission="'iot_info:start'"
>
{{ getBindingStartButtonText(bindingCard) }}
</ElButton>
<ElButton
v-if="bindingCard.network_status === 1"
type="warning"
size="default"
:disabled="isBindingStopDisabled(bindingCard)"
@click="handleDisableBindingCard(bindingCard)"
v-permission="'iot_info:stop'"
>
{{ getBindingStopButtonText(bindingCard) }}
</ElButton>
<ElButton
type="primary"
size="default"
@click="handleUpdateBindingCardRealnameStatus(bindingCard)"
v-permission="'bound_card:update_realname_status'"
>
更新实名状态
</ElButton>
<ElButton
v-if="getBindingCardAuditTarget(bindingCard)"
type="primary"
size="default"
@click="openBindingCardAudit(bindingCard)"
>
卡审计
</ElButton>
</div>
</ElCard>
</div>
<ElEmpty v-else description="暂无绑定卡" :image-size="80" style="margin-top: 16px" />
<slot name="current-package" />
<!-- 设备实时信息 -->
<ElDivider class="info-section-divider" content-position="left">
<span class="section-divider-title">设备实时信息</span>
</ElDivider>
<div v-if="hasDeviceRealtimeSection" v-loading="realtimeLoading" class="realtime-section">
<ElDescriptions
class="asset-descriptions"
:column="descriptionsColumn"
direction="vertical"
border
size="large"
>
<ElDescriptionsItem
v-for="item in realtimeDescriptionItems"
:key="item.key"
:label="item.label"
:label-class-name="`asset-label asset-label--${item.icon}`"
:span="item.span ?? 1"
>
<ElTag v-if="item.tagType" :type="item.tagType" size="small">
{{ item.value }}
</ElTag>
<span v-else>{{ item.value }}</span>
</ElDescriptionsItem>
</ElDescriptions>
</div>
<ElEmpty v-else description="暂无实时信息" :image-size="80" style="margin-top: 16px" />
</template>
<slot v-if="cardInfo?.asset_type !== 'device'" name="current-package" />
<!-- IoT卡操作按钮 -->
<ElAlert
v-if="startErrorMessage"
:title="startErrorMessage"
type="error"
:closable="false"
show-icon
style="margin-top: 16px"
/>
<div
v-if="cardInfo?.asset_type === 'card'"
class="card-operations"
style="margin-top: 16px; text-align: right"
>
<ElButton
v-if="cardInfo?.network_status === 0"
v-permission="'iot_info:start'"
type="success"
:disabled="startDisabled"
@click="handleEnableCard"
>
{{ startRemainingText ? `启用此卡(${startRemainingText})` : '启用此卡' }}
</ElButton>
<ElButton
v-if="cardInfo?.network_status === 1"
v-permission="'iot_info:stop'"
type="warning"
:disabled="stopDisabled"
@click="handleDisableCard"
>
{{ stopRemainingText ? `停用此卡(${stopRemainingText})` : '停用此卡' }}
</ElButton>
<!--<ElButton type="danger" v-permission="'iot_info:handler_stop'" @click="handleManualDeactivate"> 手动停用 </ElButton>-->
<!-- 手动停用暂时不开放 -->
<!--suppress VueUnrecognizedDirective -->
<ElButton
v-if="canShowRealnamePolicy"
v-permission="'asset:card_realname_policy'"
type="primary"
@click="handleShowRealnamePolicy"
>
实名认证策略
</ElButton>
<ElButton
v-permission="'iot_info:update_realname_status'"
type="primary"
@click="handleShowUpdateRealnameStatus"
>
更新实名状态
</ElButton>
</div>
<!-- 设备操作按钮 -->
<div v-else class="device-operations" style="margin-top: 16px; text-align: right">
<ElButton v-permission="'device_info:switch_mode'" type="info" @click="handleShowSwitchMode"
>设置切卡模式</ElButton
>
<ElButton v-permission="'device_info:reboot'" type="primary" @click="handleRebootDevice">
重启设备
</ElButton>
<ElButton v-permission="'device_info:reset'" type="warning" @click="handleResetDevice">
恢复出厂
</ElButton>
<ElButton
v-permission="'device_info:switch_card'"
type="success"
@click="handleShowSwitchCard"
>
切换SIM卡
</ElButton>
<ElButton v-permission="'device_info:set_wifi'" type="primary" @click="handleShowSetWiFi">
设置WiFi
</ElButton>
<ElButton
v-if="canShowRealnamePolicy"
v-permission="'asset_info:device_realname_policy'"
type="primary"
@click="handleShowRealnamePolicy"
>
实名认证策略
</ElButton>
</div>
</ElCard>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useWindowSize } from '@vueuse/core'
import {
ElCard,
ElDescriptions,
ElDescriptionsItem,
ElTag,
ElTooltip,
ElSwitch,
ElDivider,
ElButton,
ElAlert,
ElEmpty,
ElMessageBox,
ElMessage
} from 'element-plus'
import { CopyDocument } from '@element-plus/icons-vue'
import type {
AssetExchangeTrace,
AssetExchangeTraceAsset,
AssetPollingStatus,
ExpiryEstimateStatus
} from '@/types/api'
import { useAssetFormatters } from '../composables/useAssetFormatters'
import { useAuth } from '@/composables/useAuth'
import { JULY_PERMISSIONS } from '@/config/constants'
import { useUserStore } from '@/store/modules/user'
import { formatDateTime } from '@/utils/business/format'
import {
getExpiryEstimateText,
getExpiryEstimateTooltip,
getExpiryEstimateClass
} from '@/utils/business/expiryEstimate'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
import { resolveAuditResourceTarget } from '@/utils/business/auditNavigation'
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
// Props
interface BindingCard {
card_id?: number
iccid: string
msisdn?: string
carrier_type?: string
carrier_name?: string
slot_position?: number
is_current: boolean
network_status?: number
real_name_status?: number
real_name_at?: string | null
realname_policy?: string
gateway_extend?: string
last_data_check_at?: string | null
last_real_name_check_at?: string | null
last_card_status_check_at?: string | null
}
interface AssetInfo {
asset_id: number
asset_type: 'card' | 'device'
identifier: string
iccid?: string
imei?: string
msisdn?: string
carrier_type?: string
carrier_name?: string
real_name_status?: number
activation_status_name?: string
realname_policy?: string
network_status?: number
current_month_usage_mb?: number
last_gateway_reading_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 | null
supplier?: string
gateway_card_imei?: string
gateway_extend?: string
total_virtual_used_mb?: number | null
total_virtual_remaining_mb?: number | null
estimated_final_expires_at?: string | null
days_until_final_expiry?: number | null
expiry_estimate_status?: ExpiryEstimateStatus
expiry_estimate_status_name?: string | null
is_expiring?: boolean
last_sync_time?: string | null
last_data_check_at?: string | null
last_real_name_check_at?: string | null
last_card_status_check_at?: string | null
polling?: AssetPollingStatus | null
exchange_trace?: AssetExchangeTrace | null
bound_device_id?: number
bound_device_no?: string
bound_device_name?: 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
manufacturer?: string
}
interface DeviceRealtimeInfo {
online_status?: number
status?: number
battery_level?: number | null
run_time?: string
connect_time?: string
last_online_time?: string | null
client_number?: number
max_clients?: number
current_iccid?: string
signal_quality?: string
signal_bad_reason?: string
wifi_enabled?: boolean | number
ssid?: string
wifi_password?: string
ip_address?: string
wan_ip?: string
lan_ip?: string
mac_address?: string
imei?: string
imsi?: string
limit_speed?: number
software_version?: string
switch_mode?: string
sync_interval?: number
device_id?: string
last_sync_time?: string | null
last_update_time?: string | null
}
interface Props {
cardInfo: AssetInfo
deviceRealtime: DeviceRealtimeInfo | null
pollingEnabled: boolean
realtimeLoading?: boolean
startDisabled?: boolean
startErrorMessage?: string
stopDisabled?: boolean
startRemainingText?: string
stopRemainingText?: string
getStartRemainingText?: (identifier: string) => string
getStopRemainingText?: (identifier: string) => string
isStartDisabled?: (identifier: string) => boolean
isStopDisabled?: (identifier: string) => boolean
}
const props = withDefaults(defineProps<Props>(), {
realtimeLoading: false,
startDisabled: false,
startErrorMessage: '',
stopDisabled: false,
startRemainingText: '',
stopRemainingText: ''
})
const { hasAuth } = useAuth()
const userStore = useUserStore()
const isSubjectActivityView = computed(() => [3, 4].includes(Number(userStore.info.user_type)))
const auditTarget = computed(() => {
const userType = Number(userStore.info.user_type)
const isCard = props.cardInfo.asset_type === 'card'
const permission =
userType === 3
? isCard
? AUDIT_PERMISSIONS.agentAssetInfoCardActivity
: AUDIT_PERMISSIONS.agentAssetInfoDeviceActivity
: userType === 4
? isCard
? AUDIT_PERMISSIONS.enterpriseAssetInfoCardActivity
: AUDIT_PERMISSIONS.enterpriseAssetInfoDeviceActivity
: AUDIT_PERMISSIONS.assetInfoEntry
if (!hasAuth(permission)) return null
return resolveAuditResourceTarget({
userType,
resourceType: props.cardInfo.asset_type === 'card' ? 'iot_card' : 'device',
internalId: props.cardInfo.asset_id,
businessIdentifier:
props.cardInfo.asset_type === 'card' ? props.cardInfo.iccid : props.cardInfo.virtual_no
})
})
const openAssetAudit = () => {
if (auditTarget.value) openAuditInvestigation(auditTarget.value)
}
const getBindingCardAuditTarget = (card: BindingCard) => {
const userType = Number(userStore.info.user_type)
const permission =
userType === 3
? AUDIT_PERMISSIONS.agentAssetInfoBindingCardActivity
: userType === 4
? AUDIT_PERMISSIONS.enterpriseAssetInfoBindingCardActivity
: AUDIT_PERMISSIONS.assetInfoBindingCardEntry
if (!hasAuth(permission)) return null
return resolveAuditResourceTarget({
userType,
resourceType: 'iot_card',
internalId: card.card_id,
businessIdentifier: card.iccid
})
}
const openBindingCardAudit = (card: BindingCard) => {
const target = getBindingCardAuditTarget(card)
if (target) openAuditInvestigation(target)
}
const { width } = useWindowSize()
const CARD_MONTH_USAGE_PERMISSION = 'asset_info:view_card_month_usage'
const realnamePolicyPermission = computed(() => {
return props.cardInfo?.asset_type === 'card'
? 'asset:card_realname_policy'
: 'asset:device_realname_policy'
})
const canShowRealnamePolicy = computed(() => hasAuth(realnamePolicyPermission.value))
const pollingPermission = computed(() => {
return props.cardInfo?.asset_type === 'card'
? 'iot_info:auto_polling'
: 'device_info:auto_polling'
})
const canShowPolling = computed(() => hasAuth(pollingPermission.value))
const canShowCardMonthUsage = computed(() => {
const userType = Number(userStore.info.user_type ?? 0)
return ![3, 4].includes(userType) && hasAuth(CARD_MONTH_USAGE_PERMISSION)
})
const isAgentOrEnterpriseAccount = computed(() => {
const userType = Number(userStore.info.user_type ?? 0)
return [3, 4].includes(userType)
})
const canShowSensitiveFields = computed(() => {
return !isAgentOrEnterpriseAccount.value
})
const getCarrierAccountDisplay = (card?: {
carrier_type?: string
carrier_name?: string
}) => {
if (isAgentOrEnterpriseAccount.value) {
return getCarrierTypeName(card?.carrier_type)
}
return card?.carrier_name || '-'
}
const carrierAccountDisplay = computed(() => getCarrierAccountDisplay(props.cardInfo))
const descriptionsColumn = computed(() => {
if (width.value <= 640) return 1
if (width.value <= 1024) return 2
return 5
})
// 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: 'showSwitchMode'): void
(e: 'show-set-wifi'): void
(e: 'showRealnamePolicy'): void
(e: 'showUpdateRealnameStatus'): void
(e: 'enableBindingCard', payload: { card: BindingCard }): void
(e: 'disableBindingCard', payload: { card: BindingCard }): void
(e: 'updateBindingCardRealnameStatus', payload: { card: BindingCard }): void
(e: 'navigateToCard', iccid: string): void
(e: 'navigateToDevice', deviceNo: string): void
(e: 'navigateToAsset', identifier: string): void
}
const emit = defineEmits<Emits>()
// 使用格式化工具
const {
formatDataSize,
formatDuration,
getRealNameStatusName,
getRealNameStatusType,
getRealnamePolicyName,
getCarrierTypeName,
getAssetStatusName,
getAssetStatusType,
getOnlineStatusName,
getOnlineStatusType,
getSignalQualityText,
getSignalQualityType,
getSignalBadReasonText,
getSwitchModeName
} = useAssetFormatters()
// 排序后的设备卡列表(按卡槽位置排序,当前卡排在最前面)
type RealtimeDescriptionItem = {
key: string
label: string
value: string
icon: string
tagType?: 'primary' | 'success' | 'warning' | 'info' | 'danger'
span?: number
}
const realtimeDescriptionItems = computed<RealtimeDescriptionItem[]>(() => {
const realtime = props.deviceRealtime
return [
{
key: 'status',
label: '在线状态',
value: getOnlineStatusName(realtime?.online_status),
icon: 'online-status',
tagType: getOnlineStatusType(realtime?.online_status)
},
{
key: 'battery',
label: '电量',
icon: 'battery',
value:
realtime?.battery_level !== null && realtime?.battery_level !== undefined
? `${realtime.battery_level}%`
: '-'
},
{
key: 'boot',
label: '本次开机时长',
value: formatDuration(realtime?.run_time),
icon: 'boot-time'
},
{
key: 'connect',
label: '本次联网时长',
value: formatDuration(realtime?.connect_time),
icon: 'connection-time'
},
{
key: 'online',
label: '最后在线时间',
value: formatDateTime(realtime?.last_online_time) || '-',
icon: 'last-online'
},
{
key: 'realname',
label: '实名同步时间',
value: formatAssetInfoTime(props.cardInfo?.last_real_name_check_at),
icon: 'realname-sync'
},
{
key: 'cardSync',
label: '卡状态同步时间',
value: formatAssetInfoTime(props.cardInfo?.last_card_status_check_at),
icon: 'card-sync'
},
{
key: 'trafficSync',
label: '流量同步时间',
value: formatAssetInfoTime(props.cardInfo?.last_data_check_at),
icon: 'traffic-sync'
},
{
key: 'signal',
label: '信号综合判断',
value: getSignalQualityText(realtime?.signal_quality),
icon: 'signal-quality',
tagType: getSignalQualityType(realtime?.signal_quality)
},
{
key: 'signalReason',
label: '信号较弱原因',
value: getSignalBadReasonText(realtime?.signal_bad_reason),
icon: 'signal-reason'
},
{
key: 'clients',
label: '已连接设备数',
value: `${realtime?.client_number ?? '-'} / ${realtime?.max_clients ?? '-'}`,
icon: 'connected-clients'
},
{
key: 'wifi',
label: 'WiFi 状态',
value:
realtime?.wifi_enabled === undefined ? '-' : realtime.wifi_enabled ? '已开启' : '已关闭',
icon: 'wifi-status',
tagType: realtime?.wifi_enabled ? 'success' : 'info'
},
{
key: 'wifiName',
label: 'WiFi 名称',
value: realtime?.ssid || '-',
icon: 'wifi-name'
},
{
key: 'wifiPassword',
label: 'WiFi 密码',
value: realtime?.wifi_password || '-',
icon: 'wifi-password'
},
{ key: 'wan', label: 'WAN IP', value: realtime?.wan_ip || '-', icon: 'wan-ip' },
{ key: 'lan', label: 'LAN IP', value: realtime?.lan_ip || '-', icon: 'lan-ip' },
{
key: 'mac',
label: 'MAC 地址',
value: realtime?.mac_address || '-',
icon: 'mac-address'
},
{
key: 'limit',
label: '限速',
value:
realtime?.limit_speed === undefined
? '-'
: realtime.limit_speed
? `${realtime.limit_speed} KB/s`
: '不限速',
icon: 'speed-limit'
},
{
key: 'switchMode',
label: '切卡模式',
value:
realtime?.switch_mode === '0' ? '自动' : realtime?.switch_mode === '1' ? '手动' : '-',
icon: 'switch-mode'
},
{
key: 'interval',
label: '上报周期',
value: realtime?.sync_interval ? `${realtime.sync_interval} 秒` : '-',
icon: 'report-interval'
},
{
key: 'firmware',
label: '固件版本',
value: realtime?.software_version || '-',
icon: 'firmware-version',
span: descriptionsColumn.value >= 5 ? 2 : 1
},
{
key: 'updated',
label: '信息更新时间',
value: formatDateTime(realtime?.last_update_time) || '-',
icon: 'realtime-updated'
}
]
})
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 (a.slot_position ?? 0) - (b.slot_position ?? 0)
})
})
const previousExchangeAsset = computed(
() => props.cardInfo.exchange_trace?.previous_asset ?? null
)
const nextExchangeAsset = computed(() => props.cardInfo.exchange_trace?.next_asset ?? null)
// 设备详情里的实名状态和实名时间从选中的绑定卡取值:
// 只有一张卡时直接取该卡,多张卡时仅取 is_current=true 的卡。
const selectedDeviceStatusCard = computed(() => {
if (!props.cardInfo?.cards || props.cardInfo.cards.length === 0) {
return null
}
if (props.cardInfo.cards.length === 1) {
return props.cardInfo.cards[0]
}
return props.cardInfo.cards.find((card: BindingCard) => card.is_current) || null
})
const hasDeviceRealtimeSection = computed(
() =>
Boolean(props.deviceRealtime) ||
props.cardInfo?.last_data_check_at !== undefined ||
props.cardInfo?.last_real_name_check_at !== undefined ||
props.cardInfo?.last_card_status_check_at !== undefined
)
const formatUsageSummaryMb = (value?: number | null) => {
if (value === undefined || value === null) return '-'
return formatDataSize(value)
}
/**
* 接口中的这几项时间以其原始文本为基准直接加 8 小时;时区后缀不参与换算。
* 例如 2026-08-06T11:54:26.693382+08:00 显示为 2026-08-06 19:54:26。
*/
const formatAssetInfoTime = (value?: string | null) => {
if (!value?.trim()) return '-'
const timeText = value.trim()
const match = timeText.match(
/^(\d{4})-(\d{2})-(\d{2})(?:T|\s)(\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/i
)
if (!match) return '-'
const [, year, month, day, hours, minutes, seconds = '00'] = match
const date = new Date(
Date.UTC(
Number(year),
Number(month) - 1,
Number(day),
Number(hours) + 8,
Number(minutes),
Number(seconds)
)
)
return formatDateTime(date)
}
// 处理轮询开关变化
const handlePollingChange = (value: string | number | boolean) => {
emit('update:pollingEnabled', Boolean(value))
}
// 跳转到卡片信息
const handleNavigateToCardInfo = (iccid: string) => {
// 触发搜索事件,让父组件处理
emit('navigateToCard', iccid)
}
const handleNavigateToExchangeAsset = (asset: AssetExchangeTraceAsset) => {
if (!asset.can_view) return
emit('navigateToAsset', asset.identifier)
}
// 复制ICCID
const handleCopyICCID = async (iccid: string) => {
try {
await navigator.clipboard.writeText(iccid)
ElMessage.success('ICCID已复制')
} catch {
ElMessage.error('复制失败')
}
}
// 卡操作
const handleEnableCard = () => {
if (props.startDisabled) return
emit('enableCard')
}
const handleDisableCard = () => {
if (props.stopDisabled) return
emit('disableCard')
}
// 设备操作
const handleRebootDevice = () => {
emit('rebootDevice')
}
const handleResetDevice = () => {
emit('resetDevice')
}
const handleShowSwitchCard = () => {
emit('showSwitchCard')
}
const handleShowSwitchMode = () => {
emit('showSwitchMode')
}
const handleShowSetWiFi = () => {
emit('show-set-wifi')
}
const handleShowRealnamePolicy = () => {
emit('showRealnamePolicy')
}
const handleShowUpdateRealnameStatus = () => {
emit('showUpdateRealnameStatus')
}
// 绑定卡操作
const getBindingStartRemainingText = (card: BindingCard) =>
props.getStartRemainingText?.(card.iccid) || ''
const getBindingStopRemainingText = (card: BindingCard) =>
props.getStopRemainingText?.(card.iccid) || ''
const isBindingStartDisabled = (card: BindingCard) => props.isStartDisabled?.(card.iccid) || false
const isBindingStopDisabled = (card: BindingCard) => props.isStopDisabled?.(card.iccid) || false
const getBindingStartButtonText = (card: BindingCard) => {
const remainingText = getBindingStartRemainingText(card)
return remainingText ? `启用此卡(${remainingText})` : '启用此卡'
}
const getBindingStopButtonText = (card: BindingCard) => {
const remainingText = getBindingStopRemainingText(card)
return remainingText ? `停用此卡(${remainingText})` : '停用此卡'
}
const handleEnableBindingCard = (card: BindingCard) => {
if (isBindingStartDisabled(card)) return
ElMessageBox.confirm(`确定要启用此卡(${card.iccid})吗?`, '启用确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(() => {
emit('enableBindingCard', { card })
})
.catch(() => {})
}
const handleDisableBindingCard = (card: BindingCard) => {
if (isBindingStopDisabled(card)) return
ElMessageBox.confirm(`确定要停用此卡(${card.iccid})吗?`, '停用确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(() => {
emit('disableBindingCard', { card })
})
.catch(() => {})
}
const handleUpdateBindingCardRealnameStatus = (card: BindingCard) => {
emit('updateBindingCardRealnameStatus', { card })
}
</script>
<style scoped lang="scss">
.info-card {
&.basic-info {
.gateway-extend-text {
word-break: break-all;
}
.expiry-estimate--critical,
.expiry-estimate--warning,
.expiry-estimate--notice {
font-weight: 500;
}
.expiry-estimate--critical {
color: var(--el-color-danger);
}
.expiry-estimate--warning {
color: #8e44ad;
}
.expiry-estimate--notice {
color: #e83e8c;
}
.card-header {
display: flex;
gap: 12px;
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);
}
}
}
}
.info-section-divider {
display: flex;
height: 20px;
align-items: center;
border-top: 0;
:deep(.el-divider__text) {
position: static;
display: inline-flex;
padding: 0;
font-size: 15px;
font-weight: 600;
line-height: 20px;
color: var(--el-text-color-primary);
background-color: transparent;
transform: none;
}
.section-divider-title {
display: inline-flex;
gap: 8px;
align-items: center;
&::before {
width: 3px;
height: 16px;
content: '';
background-color: var(--el-color-primary);
border-radius: 2px;
}
}
}
.asset-descriptions {
:deep(.el-descriptions__label.is-vertical-label) {
padding: 8px 15px;
font-size: 12px;
font-weight: 500;
line-height: 18px;
color: var(--el-text-color-secondary);
background-color: var(--el-fill-color-extra-light);
}
:deep(.asset-label::before) {
display: inline-block;
width: 16px;
height: 16px;
margin-right: 6px;
font-family: iconfont-sys, sans-serif !important;
font-size: 16px;
line-height: 1;
vertical-align: -3px;
color: var(--el-color-primary);
content: '';
opacity: 0.82;
}
:deep(.asset-label--identifier-iccid::before) {
content: '\e81a';
}
:deep(.asset-label--identifier-msisdn::before) {
content: '\e776';
}
:deep(.asset-label--operator-account::before) {
content: '\e70d';
}
:deep(.asset-label--realname-status::before) {
color: var(--el-color-success);
content: '\e88b';
}
:deep(.asset-label--realname-policy::before) {
color: var(--el-color-warning);
content: '\e75d';
}
:deep(.asset-label--shop::before) {
content: '\e737';
}
:deep(.asset-label--package-series::before) {
content: '\e762';
}
:deep(.asset-label--virtual-number::before) {
content: '\e7bd';
}
:deep(.asset-label--network-status::before) {
content: '\e757';
}
:deep(.asset-label--usage-month::before) {
content: '\e812';
}
:deep(.asset-label--usage-cycle::before) {
content: '\e82a';
}
:deep(.asset-label--realname-time::before) {
content: '\e6f7';
}
:deep(.asset-label--realname-sync::before) {
content: '\e793';
}
:deep(.asset-label--identifier-imei::before) {
content: '\e8d3';
}
:deep(.asset-label--asset-status::before) {
color: var(--el-color-success);
content: '\e88c';
}
:deep(.asset-label--card-sync::before) {
content: '\e7d3';
}
:deep(.asset-label--traffic-sync::before) {
content: '\e7e1';
}
:deep(.asset-label--operator-status::before) {
content: '\e871';
}
:deep(.asset-label--package-used::before) {
content: '\e742';
}
:deep(.asset-label--package-remaining::before) {
content: '\e7d9';
}
:deep(.asset-label--package-expire-estimate::before) {
content: '\e6f3';
}
:deep(.asset-label--bound-device-number::before) {
content: '\e851';
}
:deep(.asset-label--bound-device-name::before) {
content: '\e7d8';
}
:deep(.asset-label--device-name::before) {
content: '\e7d8';
}
:deep(.asset-label--device-status::before) {
color: var(--el-color-success);
content: '\e708';
}
:deep(.asset-label--activation-status::before) {
color: var(--el-color-warning);
content: '\e81c';
}
:deep(.asset-label--device-model::before) {
content: '\e7a7';
}
:deep(.asset-label--online-status::before) {
content: '\e6b2';
}
:deep(.asset-label--switch-mode::before) {
content: '\e72b';
}
:deep(.asset-label--bound-card-count::before) {
content: '\e730';
}
:deep(.asset-label--max-slots::before) {
content: '\e620';
}
:deep(.asset-label--device-type::before) {
content: '\e740';
}
:deep(.asset-label--manufacturer::before) {
content: '\e8a6';
}
:deep(.asset-label--last-online::before) {
content: '\e751';
}
:deep(.asset-label--identifier-sn::before) {
content: '\e72a';
}
:deep(.asset-label--last-sync::before) {
content: '\e614';
}
:deep(.asset-label--battery::before) {
color: var(--el-color-success);
content: '\e6e5';
}
:deep(.asset-label--boot-time::before) {
content: '\e827';
}
:deep(.asset-label--connection-time::before) {
content: '\e790';
}
:deep(.asset-label--signal-quality::before) {
content: '\e808';
}
:deep(.asset-label--signal-reason::before) {
color: var(--el-color-warning);
content: '\e600';
}
:deep(.asset-label--connected-clients::before) {
content: '\e608';
}
:deep(.asset-label--wifi-status::before) {
content: '\e7ee';
}
:deep(.asset-label--wifi-name::before) {
content: '\e841';
}
:deep(.asset-label--wifi-password::before) {
color: var(--el-color-warning);
content: '\e716';
}
:deep(.asset-label--wan-ip::before) {
content: '\e611';
}
:deep(.asset-label--lan-ip::before) {
content: '\e607';
}
:deep(.asset-label--mac-address::before) {
content: '\e68b';
}
:deep(.asset-label--speed-limit::before) {
content: '\e827';
}
:deep(.asset-label--report-interval::before) {
content: '\e7ac';
}
:deep(.asset-label--firmware-version::before) {
content: '\e726';
}
:deep(.asset-label--realtime-updated::before) {
content: '\e6b3';
}
:deep(.el-descriptions__content.is-vertical-content) {
padding: 8px 15px;
font-size: 14px;
font-weight: 500;
line-height: 23px;
color: var(--el-text-color-primary);
overflow-wrap: anywhere;
}
}
.binding-card-list {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.binding-card-list--1 {
grid-template-columns: 1fr;
}
.binding-card-list--2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.binding-card {
min-width: 0;
}
.binding-card-header,
.binding-card-identifier,
.binding-card-actions {
display: flex;
align-items: center;
}
.binding-card-header {
justify-content: space-between;
}
.binding-card-details {
margin-top: 12px;
overflow: hidden;
border: 1px solid var(--el-border-color);
border-radius: 6px;
}
.binding-card-field {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 42px;
min-width: 0;
padding: 0 12px;
border-bottom: 1px solid var(--el-border-color-lighter);
&:last-child {
border-bottom: 0;
}
}
.binding-card-field-label {
flex: 0 0 auto;
font-size: 14px;
color: var(--el-text-color-secondary);
}
.binding-card-field-value {
min-width: 0;
font-size: 15px;
font-weight: 500;
color: var(--el-text-color-primary);
text-align: right;
}
.binding-card--current {
border-color: var(--el-color-primary);
box-shadow: 0 0 0 2px var(--el-color-primary-light-8);
}
.binding-card-current-tag {
font-size: 14px;
font-weight: 600;
}
.binding-card-identifier {
min-width: 0;
.el-button {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 16px;
font-weight: 600;
line-height: 24px;
}
.copy-icon {
flex: 0 0 auto;
margin-left: 6px;
color: var(--el-color-primary);
cursor: pointer;
}
}
.binding-card-actions {
display: flex;
flex-wrap: nowrap;
gap: 8px;
padding-top: 12px;
margin-top: 12px;
.el-button {
flex: 1 1 0;
min-width: 0;
width: 100%;
margin-left: 0;
}
}
.card-operations,
.device-operations {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
.el-button {
margin-left: 0;
}
}
.exchange-asset {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
.exchange-no {
color: var(--el-text-color-secondary);
}
}
@media (width <= 768px) {
.binding-card-list {
grid-template-columns: 1fr;
gap: 12px;
}
.card-header {
flex-direction: column;
align-items: flex-start;
.card-header-left,
.card-header-right {
width: 100%;
}
.card-header-right {
display: flex;
justify-content: flex-start;
}
}
.card-operations,
.device-operations {
justify-content: stretch;
.el-button {
flex: 1 1 120px;
}
}
}
}
}
</style>