Merge branch 'develop'

This commit is contained in:
luo
2026-08-21 17:23:16 +08:00
328 changed files with 27039 additions and 5117 deletions

View File

@@ -1,17 +1,12 @@
<template>
<ElCard shadow="never" class="search-card">
<template #header>
<div class="card-header">
<span>资产查询</span>
<ElButton type="danger" @click="handleGoBack">返回上次搜索</ElButton>
</div>
</template>
<div class="search-area">
<div class="iccid-search">
<div class="iccid-input-wrapper">
<ElInput
v-model="searchIccid"
placeholder="请输入虚拟号、ICCID、IMEI、SN或MSISDN"
clearable
:prefix-icon="Search"
class="asset-search-input"
@focus="iccidInputFocused = true"
@blur="iccidInputFocused = false"
@@ -40,18 +35,19 @@
:icon="Refresh"
:disabled="refreshDisabled"
>
{{ refreshButtonText }}
同步
</ElButton>
<ElButton type="default" plain :icon="Back" @click="handleGoBack"> 返回上次搜索 </ElButton>
</div>
</div>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElCard, ElInput, ElButton } from 'element-plus'
import { Search, Refresh } from '@element-plus/icons-vue'
import { ElInput, ElButton } from 'element-plus'
import { Back, Search, Refresh } from '@element-plus/icons-vue'
import { useAssetFormatters } from '../composables/useAssetFormatters'
// Use formatters
@@ -63,19 +59,13 @@
interface Props {
hasCardInfo?: boolean
refreshDisabled?: boolean
refreshRemainingText?: string
}
const props = withDefaults(defineProps<Props>(), {
withDefaults(defineProps<Props>(), {
hasCardInfo: false,
refreshDisabled: false,
refreshRemainingText: ''
refreshDisabled: false
})
const refreshButtonText = computed(() =>
props.refreshRemainingText ? `同步(${props.refreshRemainingText})` : '同步'
)
// Emits
const emit = defineEmits<{
search: [payload: { identifier: string }]
@@ -141,103 +131,87 @@
</script>
<style scoped lang="scss">
.search-card {
.search-area {
position: relative;
z-index: 20;
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);
.iccid-search {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-start;
overflow: visible;
.card-header {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
}
}
.iccid-input-wrapper {
position: relative;
flex: 1 1 360px;
min-width: 240px;
:deep(.el-card__body) {
padding: 16px 20px;
overflow: visible;
}
.asset-search-input {
width: 100%;
.iccid-search {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-start;
.iccid-input-wrapper {
position: relative;
flex: 1 1 360px;
min-width: 240px;
.asset-search-input {
width: 100%;
:deep(.el-input__prefix) {
color: var(--el-text-color-secondary);
}
}
.iccid-magnifier {
.iccid-magnifier {
position: absolute;
top: calc(100% + 12px);
left: 0;
z-index: 2000;
white-space: nowrap;
.magnifier-arrow {
position: absolute;
top: calc(100% + 12px);
left: 0;
z-index: 1000;
white-space: nowrap;
top: -9px;
left: 80px;
width: 16px;
height: 16px;
background-color: var(--el-color-primary-light-9);
border-top: 2px solid var(--el-color-primary);
border-left: 2px solid var(--el-color-primary);
transform: rotate(45deg);
}
// 三角箭头 - 上边框样式
.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 {
max-width: min(720px, calc(100vw - 48px));
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;
overflow-wrap: anywhere;
white-space: normal;
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%);
}
.magnifier-content {
display: inline-block;
width: fit-content;
max-width: min(720px, calc(100vw - 48px));
padding: 14px 22px;
font-family: 'Courier New', Consolas, monospace;
font-size: 24px;
font-weight: 600;
line-height: 1.4;
color: var(--el-color-primary);
text-align: center;
letter-spacing: 1.5px;
overflow-wrap: anywhere;
white-space: normal;
background-color: var(--el-color-primary-light-9);
border: 2px solid var(--el-color-primary);
border-radius: 8px;
box-shadow: 0 8px 18px rgb(15 23 42 / 12%);
}
}
}
.operation-button-group {
display: flex;
gap: 12px;
margin-left: auto;
}
.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-enter-from,
.zoom-fade-leave-to {
opacity: 0;
transform: scale(0.8) translateY(-10px);
@@ -250,66 +224,47 @@
}
@media (width <= 768px) {
.search-card {
top: 16px;
.search-area {
margin-bottom: 16px;
}
:deep(.el-card__header) {
padding: 12px 16px;
}
.iccid-search {
flex-direction: column;
align-items: stretch;
:deep(.el-card__body) {
padding: 12px 16px;
}
.iccid-input-wrapper {
flex-basis: auto;
width: 100%;
min-width: 0;
:deep(.el-card__header) .card-header {
flex-direction: column;
align-items: stretch;
.iccid-magnifier {
right: 0;
left: 0;
white-space: normal;
.el-button {
width: 100%;
margin-left: 0;
}
}
.magnifier-arrow {
left: 24px;
}
.iccid-search {
flex-direction: column;
align-items: stretch;
.iccid-input-wrapper {
flex-basis: auto;
width: 100%;
min-width: 0;
.iccid-magnifier {
right: 0;
left: 0;
white-space: normal;
.magnifier-arrow {
left: 24px;
}
.magnifier-content {
display: block;
width: 100%;
padding: 12px 14px;
font-size: 18px;
letter-spacing: 1px;
}
.magnifier-content {
display: block;
width: 100%;
padding: 12px 14px;
font-size: 18px;
letter-spacing: 1px;
}
}
}
.search-action-button,
.operation-button-group,
.operation-button-group .el-button {
width: 100%;
margin-left: 0;
}
.search-action-button,
.operation-button-group,
.operation-button-group .el-button {
width: 100%;
margin-left: 0;
}
.operation-button-group {
margin-left: 0;
}
.operation-button-group {
margin-left: 0;
}
}
}

View File

@@ -1,13 +1,8 @@
<template>
<ElCard shadow="never" class="info-card current-package-card">
<template #header>
<div class="card-header">
<span class="header-title">当前生效套餐</span>
<div class="header-actions">
<slot name="header-actions" />
</div>
</div>
</template>
<div class="current-package-section">
<ElDivider class="info-section-divider" content-position="left">
<span class="section-divider-title">当前生效套餐</span>
</ElDivider>
<!-- 绑定设备提示优先于一切内容 -->
<template v-if="props.boundDeviceId">
@@ -205,35 +200,73 @@
</div>
<!-- 套餐详情表格 -->
<ElDescriptions :column="descriptionsColumn" border class="package-info-table">
<ElDescriptionsItem label="订单号">
<ElDescriptions
class="asset-descriptions"
:column="descriptionsColumn"
direction="vertical"
border
size="large"
>
<ElDescriptionsItem
label="订单号"
label-class-name="asset-label asset-label--package-order"
>
{{ currentPackage.order_no || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="套餐名称">
<ElDescriptionsItem
label="套餐名称"
label-class-name="asset-label asset-label--package-name"
>
{{ currentPackage.package_name || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="套餐成本价格" v-if="isAdminOrPlatform">
<ElDescriptionsItem
label="套餐成本价格"
label-class-name="asset-label asset-label--package-cost"
v-if="isAdminOrPlatform"
>
{{ formatAmount(currentPackage.paid_amount || 0) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="套餐零售价">
<ElDescriptionsItem
label="套餐零售价"
label-class-name="asset-label asset-label--package-retail"
>
{{ formatAmount(currentPackage.retail_amount || 0) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="套餐类型">
<ElDescriptionsItem
label="套餐类型"
label-class-name="asset-label asset-label--package-type"
>
{{ getPackageTypeName(currentPackage.package_type) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="开始时间">
<ElDescriptionsItem
label="开始时间"
label-class-name="asset-label asset-label--package-start"
>
{{ formatDateTime(currentPackage.start_time) || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="到期时间">
<ElDescriptionsItem
label="到期时间"
label-class-name="asset-label asset-label--package-expire"
>
{{ formatDateTime(currentPackage.expire_time) || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="下单时间">
<ElDescriptionsItem
label="下单时间"
label-class-name="asset-label asset-label--package-created"
>
{{ formatDateTime(currentPackage.created_at) || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem v-if="currentPackage.duration_days" label="套餐时长">
<ElDescriptionsItem
v-if="currentPackage.duration_days"
label="套餐时长"
label-class-name="asset-label asset-label--package-duration"
>
{{ currentPackage.duration_days }}
</ElDescriptionsItem>
<ElDescriptionsItem label="到期计时基准">
<ElDescriptionsItem
label="到期计时基准"
label-class-name="asset-label asset-label--package-base"
>
{{ getExpiryBaseLabel(currentPackage.expiry_base) }}
</ElDescriptionsItem>
</ElDescriptions>
@@ -241,12 +274,12 @@
<!-- 空状态 -->
<ElEmpty v-else-if="!loading" :description="errorMsg || '暂无当前生效套餐'" :image-size="100" />
</ElCard>
</div>
</template>
<script setup lang="ts">
import {
ElCard,
ElDivider,
ElButton,
ElDescriptions,
ElDescriptionsItem,
@@ -319,7 +352,7 @@
const descriptionsColumn = computed(() => {
if (width.value <= 640) return 1
if (width.value <= 1024) return 2
return 4
return 5
})
const emit = defineEmits<{
@@ -345,16 +378,113 @@
</script>
<style scoped lang="scss">
.current-package-card {
.card-header {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
.header-title {
font-size: 16px;
.current-package-section {
.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--package-order::before) {
content: '\e70f';
}
:deep(.asset-label--package-name::before) {
content: '\e775';
}
:deep(.asset-label--package-cost::before) {
color: var(--el-color-warning);
content: '\e815';
}
:deep(.asset-label--package-retail::before) {
color: var(--el-color-success);
content: '\e6f5';
}
:deep(.asset-label--package-type::before) {
content: '\e723';
}
:deep(.asset-label--package-start::before) {
content: '\e751';
}
:deep(.asset-label--package-expire::before) {
content: '\e76a';
}
:deep(.asset-label--package-created::before) {
content: '\e6ce';
}
:deep(.asset-label--package-duration::before) {
content: '\e7af';
}
:deep(.asset-label--package-base::before) {
content: '\e7b1';
}
: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;
}
}
.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;
}
}
}
@@ -473,18 +603,6 @@
}
}
.package-info-table {
margin-top: 0;
}
:deep(.el-descriptions__body) {
overflow-x: auto;
}
:deep(.el-descriptions__cell) {
word-break: break-word;
}
@media (width <= 992px) {
.flow-progress-card {
.flow-stats-container {
@@ -495,15 +613,6 @@
}
@media (width <= 768px) {
.card-header {
flex-direction: column;
align-items: flex-start;
.header-actions {
width: 100%;
}
}
.flow-progress-card {
padding: 14px;

View File

@@ -1,737 +0,0 @@
<template>
<ElCard shadow="never" class="info-card operation-logs-card" v-loading="loading">
<template #header>
<div class="card-header">
<span class="header-title">操作审计日志</span>
<div class="filter-section">
<ElSelect
v-model="filterForm.operation_type"
placeholder="操作类型"
clearable
class="operation-type-select"
@change="handleFilterChange"
>
<ElOption
v-for="option in operationTypeOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
<ElSelect
v-model="filterForm.result_status"
placeholder="执行结果"
clearable
class="result-status-select"
@change="handleFilterChange"
>
<ElOption label="成功" value="success" />
<ElOption label="失败" value="failed" />
<ElOption label="拒绝" value="denied" />
</ElSelect>
<ElButton type="primary" @click="handleQuery">查询</ElButton>
<ElButton @click="handleReset">重置</ElButton>
<ElTag type="info" size="small"> {{ total }} </ElTag>
</div>
</div>
</template>
<div class="logs-table-wrapper">
<ElTable :data="logList" class="logs-table" border max-height="400">
<ElTableColumn label="操作时间" width="170" align="center">
<template #default="{ row }">
{{ formatDateTime(row.created_at) }}
</template>
</ElTableColumn>
<ElTableColumn label="操作类型" width="160" align="center">
<template #default="{ row }">
<ElTag :type="getOperationTypeTag(row.operation_type)" size="small">
{{ row.operation_desc }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="操作人" width="130" align="center">
<template #default="{ row }">
{{ row.operator_name || '-' }}
</template>
</ElTableColumn>
<ElTableColumn label="操作人类型" width="110" align="center">
<template #default="{ row }">
{{ getOperatorTypeText(row) }}
</template>
</ElTableColumn>
<ElTableColumn label="执行结果" width="100" align="center">
<template #default="{ row }">
<ElTag :type="getResultStatusTag(row.result_status)" size="small">
{{ getResultStatusText(row.result_status) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="变更内容" min-width="320">
<template #default="{ row }">
<div v-if="getDisplayContent(row).length" class="change-content">
<span
v-for="(item, index) in getDisplayContent(row)"
:key="`${item.type}-${item.key}-${index}`"
class="change-item"
>
<span class="field-name">{{ item.fieldName }}</span>
<span class="field-value">
<template v-if="item.type === 'change'">
<span class="new-value">{{ formatFieldValue(item.key, item.afterValue) }}</span>
<ElButton
v-if="canDownloadLogFile && isDownloadableFileKey(item.key, item.afterValue)"
link
type="primary"
@click.stop="handleDownloadLogFile(item.afterValue)"
>
下载文件
</ElButton>
</template>
<span v-else :class="['message-value', item.level === 'error' ? 'is-error' : '']">
{{ item.message }}
</span>
</span>
</span>
</div>
<span v-else>-</span>
</template>
</ElTableColumn>
</ElTable>
<ElPagination
class="logs-pagination"
v-if="total > 0"
v-model:current-page="pagination.page"
v-model:page-size="pagination.page_size"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, prev, pager, next"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</ElCard>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import {
ElButton,
ElCard,
ElMessage,
ElOption,
ElPagination,
ElSelect,
ElTable,
ElTableColumn,
ElTag
} from 'element-plus'
import { AssetService, StorageService } from '@/api/modules'
import { useAuth } from '@/composables/useAuth'
import type { AssetOperationLogItem } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
interface Props {
assetIdentifier?: string
assetType?: string
downloadPermission?: string
}
type TagType = 'primary' | 'success' | 'warning' | 'info' | 'danger'
type ResultStatus = '' | 'success' | 'failed' | 'denied'
type OperationLogRow = AssetOperationLogItem & {
operator_type_code?: string
error_code?: string
error_msg?: string
request_id?: string
request_path?: string
request_method?: string
ip_address?: string
user_agent?: string
}
interface ChangeItem {
type: 'change'
key: string
fieldName: string
beforeValue?: any
afterValue?: any
}
interface MessageItem {
type: 'message'
key: string
fieldName: string
message: string
level: 'info' | 'error'
}
type DisplayItem = ChangeItem | MessageItem
const operationTypeOptions = [
{ label: '设备WiFi设置', value: 'device_set_wifi' },
{ label: '设备切卡', value: 'device_switch_card' },
{ label: '设备切卡模式', value: 'device_switch_mode' },
{ label: '设备停机', value: 'device_stop' },
{ label: '设备复机', value: 'device_start' },
{ label: '设备重启', value: 'device_reboot' },
{ label: '设备重置', value: 'device_reset' },
{ label: '设备分配', value: 'device_allocate' },
{ label: '设备回收', value: 'device_recall' },
{ label: '设备绑卡', value: 'device_bind_card' },
{ label: '设备解绑卡', value: 'device_unbind_card' },
{ label: '卡分配', value: 'card_allocate' },
{ label: '卡回收', value: 'card_recall' },
{ label: '卡停机', value: 'card_stop' },
{ label: '卡复机', value: 'card_start' },
{ label: '手动停卡', value: 'card_manual_stop' },
{ label: '手动复机', value: 'card_manual_start' },
{ label: '自动停卡', value: 'card_auto_stop' },
{ label: '自动复机', value: 'card_auto_start' },
{ label: '卡轮询状态', value: 'card_polling_status' },
{ label: '资产轮询状态', value: 'asset_polling_status' },
{ label: '实名策略', value: 'asset_realname_policy' },
{ label: '实名状态', value: 'asset_realname_status' },
{ label: '轮询开关', value: 'asset_polling' },
{ label: 'IoT卡导入任务', value: 'iot_card_import_task_create' }
] as const
const operationTypeTagMap: Record<string, TagType> = {
device_set_wifi: 'primary',
device_switch_card: 'success',
device_switch_mode: 'success',
device_stop: 'danger',
device_start: 'success',
device_reboot: 'warning',
device_reset: 'warning',
device_allocate: 'primary',
device_recall: 'warning',
device_bind_card: 'primary',
device_unbind_card: 'warning',
card_allocate: 'primary',
card_recall: 'warning',
card_stop: 'danger',
card_start: 'success',
card_manual_stop: 'danger',
card_manual_start: 'success',
card_auto_stop: 'danger',
card_auto_start: 'success',
card_polling_status: 'info',
asset_realname_policy: 'info',
asset_realname_status: 'info',
asset_polling: 'info',
asset_polling_status: 'info',
iot_card_import_task_create: 'primary'
}
const operatorTypeMap: Record<string, string> = {
system: '系统',
admin_user: '平台用户',
agent_user: '代理账号',
enterprise_user: '企业用户',
api_user: 'API用户'
}
const fieldNameMap: Record<string, string> = {
asset_type: '资产类型',
batch_no: '批次号',
card_category: '卡类型',
device_imei: '设备IMEI',
device_sn: '设备SN',
device_virtual_no: '设备虚拟号',
device_virtual_nos: '设备虚拟号',
enable_polling: '轮询开关',
file_key: '文件存储键',
first_realname_at: '首次实名时间',
iccid: 'ICCID',
iccids: 'ICCID',
network_status: '网络状态',
real_name_status: '实名状态',
realname_policy: '实名认证策略',
shop_name: '店铺',
source_service: '来源服务',
source_shop_name: '来源店铺',
status: '资产状态',
stop_reason: '停机原因',
switch_mode: '切卡模式',
target_shop_name: '目标店铺',
to_shop_name: '目标店铺'
}
const realnamePolicyMap: Record<string, string> = {
none: '无需实名',
before_order: '先实名后充值/购买',
after_order: '先充值/购买后实名'
}
const stopReasonMap: Record<string, string> = {
'': '无',
no_package: '无可用套餐',
carrier_stopped: '运营商停机',
not_realname: '未实名'
}
const assetTypeMap: Record<string, string> = {
iot_card: 'IoT卡',
device: '设备'
}
const cardCategoryMap: Record<string, string> = {
normal: '普通卡',
industry: '行业卡'
}
const assetStatusMap: Record<string, string> = {
'1': '在库',
'2': '已分销',
'3': '已激活',
'4': '已停用'
}
const newStatusMap: Record<string, string> = {
allocated: '已分配',
recalled: '已回收'
}
const props = defineProps<Props>()
const { hasAuth } = useAuth()
const loading = ref(false)
const logList = ref<OperationLogRow[]>([])
const total = ref(0)
const canDownloadLogFile = computed(() => {
return props.downloadPermission ? hasAuth(props.downloadPermission) : false
})
const pagination = reactive({
page: 1,
page_size: 20
})
const filterForm = reactive({
operation_type: '',
result_status: '' as ResultStatus
})
const loadLogs = async () => {
if (!props.assetIdentifier) return
try {
loading.value = true
const res = await AssetService.getOperationLogs(props.assetIdentifier, {
page: pagination.page,
page_size: pagination.page_size,
operation_type: filterForm.operation_type || undefined,
result_status: filterForm.result_status || undefined
})
if (res.code === 0 && res.data) {
logList.value = res.data.items || []
total.value = res.data.total || 0
}
} catch (error) {
console.error('加载操作日志失败:', error)
ElMessage.error('加载操作日志失败')
} finally {
loading.value = false
}
}
const handleQuery = () => {
pagination.page = 1
loadLogs()
}
const handleReset = () => {
filterForm.operation_type = ''
filterForm.result_status = ''
pagination.page = 1
loadLogs()
}
const handleFilterChange = () => {
handleQuery()
}
const handlePageChange = () => {
loadLogs()
}
const handleSizeChange = () => {
pagination.page = 1
loadLogs()
}
const getOperationTypeTag = (type: string): TagType => {
return operationTypeTagMap[type] || 'info'
}
const getResultStatusTag = (status: string): TagType => {
const tagMap: Record<string, TagType> = {
success: 'success',
failed: 'danger',
denied: 'warning'
}
return tagMap[status] || 'info'
}
const getResultStatusText = (status: string) => {
const textMap: Record<string, string> = {
success: '成功',
failed: '失败',
denied: '拒绝'
}
return textMap[status] || status
}
const isDownloadableFileKey = (key: string, value: unknown) => {
return key === 'file_key' && typeof value === 'string' && value.trim() !== ''
}
const handleDownloadLogFile = async (fileKey: string) => {
try {
await StorageService.downloadFileByKey(fileKey)
ElMessage.success('文件下载已开始')
} catch (error) {
console.error('下载日志文件失败:', error)
ElMessage.error('下载文件失败')
}
}
const getOperatorTypeText = (row: OperationLogRow) => {
if (row.operator_type_code && operatorTypeMap[row.operator_type_code]) {
return operatorTypeMap[row.operator_type_code]
}
if (row.operator_type && operatorTypeMap[row.operator_type]) {
return operatorTypeMap[row.operator_type]
}
return row.operator_type || row.operator_type_code || '-'
}
const formatValue = (value: any): string => {
if (value === null || value === undefined) return '空'
if (typeof value === 'boolean') return value ? '是' : '否'
if (typeof value === 'number') return String(value)
if (typeof value === 'string') return value || '空'
if (Array.isArray(value)) {
if (value.length === 0) return '空'
if (value.every((item) => ['string', 'number', 'boolean'].includes(typeof item))) {
return value.map((item) => formatValue(item)).join('、')
}
return JSON.stringify(value)
}
if (typeof value === 'object') return JSON.stringify(value)
return String(value)
}
const formatFieldValue = (key: string, value: any): string => {
if (typeof value === 'string' && (key.endsWith('_at') || key.endsWith('_time'))) {
return formatDateTime(value)
}
if (key === 'enable_polling') return value ? '开启' : '关闭'
if (key === 'realname_policy') return realnamePolicyMap[value] || formatValue(value)
if (key === 'real_name_status') return value === 1 ? '已实名' : '未实名'
if (key === 'switch_mode') {
return String(value) === '0' ? '自动' : String(value) === '1' ? '手动' : formatValue(value)
}
if (key === 'new_status') return newStatusMap[value] || formatValue(value)
if (key === 'network_status') {
return Number(value) === 1 ? '正常' : Number(value) === 0 ? '停机' : formatValue(value)
}
if (key === 'stop_reason') return stopReasonMap[String(value ?? '')] ?? formatValue(value)
if (key === 'asset_type') return assetTypeMap[String(value)] || formatValue(value)
if (key === 'card_category') return cardCategoryMap[String(value)] || formatValue(value)
if (key === 'status') return assetStatusMap[String(value)] || formatValue(value)
if (key === 'source_service') return value === 'asset_polling' ? '资产轮询' : formatValue(value)
return formatValue(value)
}
const sanitizeFieldName = (fieldName: string): string => {
return fieldName
.trim()
.replace(/[^]*/g, '')
.trim()
}
const shouldHideIdField = (key: string, fieldName: string): boolean => {
const normalizedKey = key.trim()
const normalizedKeyLower = normalizedKey.toLowerCase()
const normalizedFieldName = sanitizeFieldName(fieldName)
const normalizedFieldNameUpper = normalizedFieldName.toUpperCase()
const visibleIdentifierKeys = new Set(['iccid', 'msisdn', 'imei', 'imsi', 'sn'])
const visibleIdentifierNames = ['ICCID', 'MSISDN', 'IMEI', 'IMSI', 'SN']
if (
visibleIdentifierKeys.has(normalizedKeyLower) ||
visibleIdentifierNames.some((name) => normalizedFieldNameUpper.endsWith(name))
) {
return false
}
if (
normalizedKeyLower === 'id' ||
normalizedFieldName === '绑定记录ID' ||
normalizedFieldName === 'ID'
) {
return true
}
return (
normalizedKeyLower.endsWith('_id') ||
/^[a-z][a-z0-9]*Id$/.test(normalizedKey) ||
normalizedFieldName.endsWith('ID')
)
}
const extractOperationContent = (
row: OperationLogRow,
type: 'before' | 'after'
): Record<string, any> => {
const directContent =
type === 'before' ? row.operation_content_before : row.operation_content_after
if (directContent && Object.keys(directContent).length > 0) {
return directContent
}
const fallbackContainer = type === 'before' ? row.before_data : row.after_data
const nestedContent = fallbackContainer?.operation_content
if (nestedContent && Object.keys(nestedContent).length > 0) {
return nestedContent
}
return fallbackContainer || {}
}
const isSameValue = (beforeValue: any, afterValue: any) => {
if (beforeValue === afterValue) return true
return JSON.stringify(beforeValue) === JSON.stringify(afterValue)
}
const getChangeContent = (row: OperationLogRow): ChangeItem[] => {
const before = extractOperationContent(row, 'before')
const after = extractOperationContent(row, 'after')
const desc = row.operation_fields_desc || {}
const keys = new Set([...Object.keys(before), ...Object.keys(after)])
const changes: ChangeItem[] = []
for (const key of keys) {
const fieldName = sanitizeFieldName(desc[key] || fieldNameMap[key] || key)
if (shouldHideIdField(key, fieldName)) continue
const beforeValue = before[key]
const afterValue = after[key]
if (isSameValue(beforeValue, afterValue)) continue
changes.push({
type: 'change',
key,
fieldName,
beforeValue,
afterValue
})
}
return changes
}
const getMessageItems = (row: OperationLogRow): MessageItem[] => {
const items: MessageItem[] = []
const seen = new Set<string>()
const pushMessage = (
key: string,
fieldName: string,
message?: string,
level: 'info' | 'error' = 'info'
) => {
const normalizedMessage = message?.trim()
if (!normalizedMessage) return
const duplicateKey = `${fieldName}:${normalizedMessage}`
if (seen.has(duplicateKey)) return
seen.add(duplicateKey)
items.push({
type: 'message',
key,
fieldName,
message: normalizedMessage,
level
})
}
pushMessage('error_msg', '失败原因', row.error_msg, 'error')
pushMessage('reason', '原因说明', row.reason, row.result_status === 'failed' ? 'error' : 'info')
if (row.fail_count > 0) {
pushMessage('fail_count', '批量结果', `失败 ${row.fail_count}`, 'error')
}
row.failed_items?.slice(0, 3).forEach((item, index) => {
const detail = item?.iccid
? `${item.iccid}${item.reason || '未知原因'}`
: item?.reason || ''
pushMessage(`failed_items_${index}`, '失败明细', detail, 'error')
})
if (!items.length && row.result_status === 'failed') {
pushMessage('result_status', '执行结果', '执行失败', 'error')
}
if (!items.length && row.result_status === 'denied') {
pushMessage('result_status', '执行结果', '操作被拒绝', 'info')
}
return items
}
const getDisplayContent = (row: OperationLogRow): DisplayItem[] => {
return [...getChangeContent(row), ...getMessageItems(row)]
}
watch(
() => props.assetIdentifier,
(newVal) => {
if (newVal) {
loadLogs()
}
},
{ immediate: true }
)
</script>
<style scoped lang="scss">
.operation-logs-card {
.card-header {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
justify-content: space-between;
.header-title {
font-size: 16px;
font-weight: 500;
}
.filter-section {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: flex-end;
min-width: 0;
.operation-type-select {
width: 180px;
}
.result-status-select {
width: 120px;
}
}
}
.logs-table-wrapper {
overflow-x: auto;
.logs-table {
min-width: 980px;
:deep(.el-table__header-wrapper) {
th {
background-color: var(--el-fill-color-light);
}
}
.change-content {
display: flex;
flex-direction: column;
gap: 4px;
.change-item {
display: flex;
gap: 4px;
align-items: center;
font-size: 12px;
.field-name {
min-width: 72px;
color: var(--el-text-color-secondary);
}
.field-value {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
word-break: break-all;
.new-value {
color: var(--el-color-success);
}
.message-value {
color: var(--el-text-color-regular);
&.is-error {
color: var(--el-color-danger);
}
}
}
}
}
}
.logs-pagination {
justify-content: flex-end;
margin-top: 16px;
}
}
@media (width <= 1200px) {
.card-header {
flex-direction: column;
align-items: stretch;
.filter-section {
justify-content: flex-start;
width: 100%;
}
}
}
@media (width <= 768px) {
.card-header {
flex-direction: column;
align-items: stretch;
.filter-section {
flex-direction: column;
align-items: stretch;
:deep(.el-select),
.el-button,
.el-tag {
width: 100% !important;
}
.el-button {
margin-left: 0;
}
}
}
.logs-table-wrapper {
padding: 0 12px;
margin-right: -12px;
margin-left: -12px;
:deep(.el-pagination) {
justify-content: flex-start !important;
overflow-x: auto;
}
}
}
}
</style>

View File

@@ -13,7 +13,7 @@
</div>
</div>
</template>
<div v-loading="loading" class="package-table-wrapper">
<div v-loading="props.loading" class="package-table-wrapper">
<!-- 绑定设备提示 -->
<ElEmpty v-if="props.boundDeviceId" :image-size="100">
<template #description>
@@ -32,15 +32,20 @@
<ElTable :data="packageList" class="package-table" border>
<ElTableColumn label="订单号" width="240">
<template #default="scope">
<ElButton
type="primary"
link
@click="handleOrderNoClick(scope.row)"
v-if="hasAuth('order:package_list_detail')"
>
{{ scope.row.order_no }}
</ElButton>
<span v-else>{{ scope.row.order_no }}</span>
<div class="order-no-cell">
<ElTooltip :content="scope.row.order_no" placement="top" :show-after="300">
<ElButton
class="order-no-link"
type="primary"
link
@click="handleOrderNoClick(scope.row)"
v-if="hasAuth('order:package_list_detail')"
>
{{ scope.row.order_no }}
</ElButton>
<span v-else class="order-no-text">{{ scope.row.order_no }}</span>
</ElTooltip>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="套餐名称" width="200" show-overflow-tooltip>
@@ -291,6 +296,7 @@
v-model="createRefundDialogVisible"
:initial-order-id="currentRefundOrderId"
:initial-order-no="currentRefundOrderNo"
:initial-package-usage-id="currentRefundPackageUsageId"
@success="handleRefundSuccess"
/>
@@ -336,7 +342,7 @@
<ElDialog
v-model="expiresAtDialogVisible"
title="修改过期时间"
width="420px"
width="40%"
@closed="resetExpiresAtForm"
>
<ElForm
@@ -389,7 +395,8 @@
ElInputNumber,
ElDatePicker,
ElSelect,
ElOption
ElOption,
ElTooltip
} from 'element-plus'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
@@ -432,6 +439,7 @@
const createRefundDialogVisible = ref(false)
const currentRefundOrderId = ref<number | undefined>(undefined)
const currentRefundOrderNo = ref<string | undefined>(undefined)
const currentRefundPackageUsageId = ref<number | undefined>(undefined)
const selectedPackage = ref<PackageInfo>()
const usedDataDialogVisible = ref(false)
const expiresAtDialogVisible = ref(false)
@@ -490,10 +498,12 @@
}
currentRefundOrderId.value = row.order_id
currentRefundOrderNo.value = row.order_no
currentRefundPackageUsageId.value = row.package_usage_id ?? row.id
createRefundDialogVisible.value = true
}
const handleRefundSuccess = () => {
currentRefundPackageUsageId.value = undefined
emit('refresh')
}
@@ -749,6 +759,35 @@
white-space: nowrap;
}
.order-no-cell {
width: 100%;
min-width: 0;
:deep(.el-tooltip__trigger) {
display: block;
width: 100%;
min-width: 0;
}
.order-no-link,
.order-no-text {
display: block;
width: 100%;
min-width: 0;
max-width: 100%;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.order-no-link {
padding: 0;
margin-left: 0;
justify-content: flex-start;
}
}
.package-actions {
display: grid;
grid-template-columns: repeat(2, 1fr);

View File

@@ -12,21 +12,21 @@
<!-- 钱包余额展示 -->
<div class="wallet-balance-cards">
<div class="balance-card total">
<div class="balance-icon">💰</div>
<div class="balance-icon"><Money /></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-icon"><CircleCheck /></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-icon"><Lock /></div>
<div class="balance-info">
<div class="balance-label">冻结余额</div>
<div class="balance-value">{{ formatAmount(walletInfo.frozen_balance) }}</div>
@@ -40,6 +40,7 @@
<script setup lang="ts">
import { ElCard, ElTag, ElEmpty } from 'element-plus'
import { CircleCheck, Lock, Money } from '@element-plus/icons-vue'
import { useAssetFormatters } from '../composables/useAssetFormatters'
// Use formatters
@@ -91,6 +92,11 @@
flex-shrink: 0;
font-size: 26px;
line-height: 1;
:deep(svg) {
width: 22px;
height: 22px;
}
}
.balance-info {

View File

@@ -1,26 +1,31 @@
<template>
<ElCard shadow="never" class="info-card wallet-info" v-loading="walletLoading">
<ElCard shadow="never" class="info-card wallet-info" v-loading="props.walletLoading">
<template #header>
<div class="card-header wallet-header">
<div class="header-left">
<div class="wallet-header-top">
<span class="wallet-title">钱包</span>
<ElTag v-if="walletInfo" :type="getWalletStatusType(walletInfo.status)" size="small">
<ElTag
v-if="walletInfo"
class="wallet-status-tag"
:type="getWalletStatusType(walletInfo.status)"
size="large"
>
{{ walletInfo.status_text || '-' }}
</ElTag>
<template v-if="walletInfo">
<ElDivider class="wallet-divider" direction="vertical" />
<span class="balance-item"
>💰 总余额 <strong>{{ formatAmount(walletInfo.balance) }}</strong></span
>
<ElDivider class="wallet-divider" direction="vertical" />
<span class="balance-item"
> 可用 <strong>{{ formatAmount(walletInfo.available_balance) }}</strong></span
>
<ElDivider class="wallet-divider" direction="vertical" />
<span class="balance-item"
>🔒 冻结 <strong>{{ formatAmount(walletInfo.frozen_balance) }}</strong></span
>
</template>
</div>
<div v-if="walletInfo" class="wallet-balance-summary">
<div class="balance-item">
<span class="balance-label">总余额</span>
<strong>{{ formatAmount(walletInfo.balance) }}</strong>
</div>
<div class="balance-item">
<span class="balance-label">可用</span>
<strong>{{ formatAmount(walletInfo.available_balance) }}</strong>
</div>
<div class="balance-item">
<span class="balance-label">冻结</span>
<strong>{{ formatAmount(walletInfo.frozen_balance) }}</strong>
</div>
</div>
<div v-if="!props.boundDeviceId" class="filter-section">
<ElSelect
@@ -44,6 +49,25 @@
/>
<ElButton type="primary" @click="handleQuery">查询</ElButton>
<ElButton @click="handleReset()">重置</ElButton>
<ElButton
v-if="
isPlatformAuditUser &&
walletInfo?.wallet_id &&
hasAuth(AUDIT_PERMISSIONS.assetInfoWalletFinance)
"
@click="openWalletAudit"
>资金链路</ElButton
>
<ElButton
v-if="
isPlatformAuditUser &&
walletInfo?.resource_type &&
walletInfo?.resource_id &&
hasAuth(AUDIT_PERMISSIONS.assetInfoWalletAsset)
"
@click="openAssetAudit"
>资产审计</ElButton
>
</div>
</div>
</template>
@@ -65,7 +89,7 @@
<div v-else v-loading="loading" class="wallet-table-wrapper">
<ElTable v-if="hasTransactions" :data="transactionList" class="wallet-table" border>
<ElTableColumn prop="reference_no" label="业务编号">
<ElTableColumn prop="reference_no" label="业务编号" show-overflow-tooltip>
<template #default="scope">
<span v-if="scope.row.reference_no" class="reference-no-link">
{{ scope.row.reference_no }}
@@ -73,14 +97,14 @@
<span v-else>--</span>
</template>
</ElTableColumn>
<ElTableColumn label="交易类型" width="100" align="center">
<ElTableColumn label="交易类型" width="100">
<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">
<ElTableColumn label="变动金额" width="120">
<template #default="scope">
<span
:style="{
@@ -92,17 +116,17 @@
</span>
</template>
</ElTableColumn>
<ElTableColumn label="变动前余额" width="120" align="right">
<ElTableColumn label="变动前余额" width="120">
<template #default="scope">
{{ formatAmount(scope.row.balance_before) }}
</template>
</ElTableColumn>
<ElTableColumn label="变动后余额" width="120" align="right">
<ElTableColumn label="变动后余额" width="120">
<template #default="scope">
{{ formatAmount(scope.row.balance_after) }}
</template>
</ElTableColumn>
<ElTableColumn label="关联业务" width="100" align="center">
<ElTableColumn label="关联业务" width="120">
<template #default="scope">
<ElTag v-if="scope.row.reference_type" type="info" size="small">
{{ scope.row.reference_type === 'recharge' ? '充值' : '订单' }}
@@ -110,7 +134,7 @@
<span v-else>--</span>
</template>
</ElTableColumn>
<ElTableColumn prop="remark" label="备注" min-width="150" show-overflow-tooltip>
<ElTableColumn prop="remark" label="备注" min-width="180" show-overflow-tooltip>
<template #default="scope">
{{ scope.row.remark || '-' }}
</template>
@@ -154,8 +178,7 @@
ElDatePicker,
ElPagination,
ElEmpty,
ElMessage,
ElDivider
ElMessage
} from 'element-plus'
import { useAssetFormatters } from '../composables/useAssetFormatters'
import { formatDateTime } from '@/utils/business/format'
@@ -167,6 +190,15 @@
AssetWalletTransactionParams,
TransactionType
} from '@/types/api'
import { useAuth } from '@/composables/useAuth'
import { useUserStore } from '@/store/modules/user'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
import { isPlatformAuditAccount } from '@/utils/business/auditAccess'
import {
resolveAuditResourceTarget,
resolveFinanceAuditTarget
} from '@/utils/business/auditNavigation'
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
interface Props {
assetIdentifier: string
@@ -182,6 +214,23 @@
boundDeviceId: undefined,
boundDeviceNo: ''
})
const { hasAuth } = useAuth()
const userStore = useUserStore()
const isPlatformAuditUser = computed(() =>
isPlatformAuditAccount(userStore.info.user_type, userStore.isSuperAdmin)
)
const openWalletAudit = () => {
const target = resolveFinanceAuditTarget('wallet_id', props.walletInfo?.wallet_id)
if (target) openAuditInvestigation(target)
}
const openAssetAudit = () => {
const target = resolveAuditResourceTarget({
resourceType: props.walletInfo?.resource_type || '',
internalId: props.walletInfo?.resource_id
})
if (target) openAuditInvestigation(target)
}
const emit = defineEmits<{
(e: 'navigateToDevice', deviceNo: string): void
@@ -339,35 +388,61 @@
&.wallet-info {
.wallet-header {
display: flex;
flex-wrap: wrap;
flex-direction: column;
gap: 16px;
align-items: center;
justify-content: space-between;
align-items: stretch;
min-width: 0;
.header-left {
.wallet-header-top {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: space-between;
width: 100%;
}
.wallet-title {
font-size: 15px;
font-weight: 500;
}
.wallet-title {
font-size: 16px;
font-weight: 600;
white-space: nowrap;
}
.wallet-divider {
flex-shrink: 0;
}
.wallet-balance-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
width: 100%;
min-width: 0;
}
.balance-item {
.balance-item {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
min-width: 0;
padding: 8px 12px;
white-space: nowrap;
background: var(--el-fill-color-lighter);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
.balance-label {
font-size: 13px;
color: var(--el-text-color-regular);
strong {
font-weight: 600;
color: var(--el-text-color-primary);
}
color: var(--el-text-color-secondary);
}
strong {
font-size: 18px;
font-weight: 700;
line-height: 24px;
color: var(--el-text-color-primary);
}
}
.wallet-status-tag {
padding: 8px 14px;
font-size: 14px;
font-weight: 700;
}
.filter-section {
@@ -376,6 +451,7 @@
gap: 10px;
align-items: center;
justify-content: flex-end;
width: 100%;
min-width: 0;
.transaction-type-select {
@@ -399,9 +475,6 @@
}
.wallet-table {
width: max-content;
min-width: 900px;
:deep(.el-table__header) {
th {
font-weight: 600;
@@ -420,30 +493,14 @@
@media (width <= 1200px) {
.wallet-header {
flex-direction: column;
align-items: flex-start;
.header-left,
.filter-section {
width: 100%;
}
.filter-section {
justify-content: flex-start;
justify-content: flex-end;
}
}
}
@media (width <= 768px) {
.wallet-header {
flex-direction: column;
align-items: stretch;
.header-left,
.filter-section {
width: 100%;
}
.filter-section {
flex-direction: column;
align-items: stretch;
@@ -460,18 +517,9 @@
}
}
.header-left {
flex-direction: column;
gap: 6px;
align-items: flex-start;
.wallet-divider {
display: none;
}
.balance-item {
width: 100%;
}
.wallet-balance-summary {
grid-template-columns: 1fr;
gap: 8px;
}
}

View File

@@ -1,5 +1,11 @@
<template>
<ElDialog v-model="visible" title="套餐流量详单" width="900px" destroy-on-close>
<ElDialog
v-model="visible"
title="套餐流量详单"
width="900px"
modal-class="asset-information-dialog"
destroy-on-close
>
<div v-if="data">
<!-- 套餐信息摘要 -->
<ElDescriptions :column="2" border style="margin-bottom: 20px">
@@ -45,13 +51,13 @@
<!-- 每日流量记录表格 -->
<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">
<ElTableColumn prop="date" label="日期" width="120" />
<ElTableColumn label="当日使用流量" width="150">
<template #default="{ row }">
{{ formatDataSize(row.daily_usage_mb) }}
</template>
</ElTableColumn>
<ElTableColumn label="累计流量" width="150" align="center">
<ElTableColumn label="累计流量" width="150">
<template #default="{ row }">
{{ formatDataSize(row.cumulative_usage_mb) }}
</template>

View File

@@ -1,5 +1,11 @@
<template>
<ElDialog v-model="visible" title="往期订单" width="60%" destroy-on-close>
<ElDialog
v-model="visible"
title="往期订单"
width="60%"
modal-class="asset-information-dialog"
destroy-on-close
>
<div v-if="loading" style="padding: 40px 0; text-align: center">
<ElIcon class="is-loading" :size="40"><Loading /></ElIcon>
</div>
@@ -132,8 +138,7 @@
ElPagination,
ElAlert,
ElEmpty,
ElIcon,
ElMessage
ElIcon
} from 'element-plus'
import { Loading } from '@element-plus/icons-vue'
import { AssetService } from '@/api/modules'

View File

@@ -1,5 +1,11 @@
<template>
<ElDialog v-model="visible" title="套餐充值" width="30%" @closed="handleDialogClosed">
<ElDialog
v-model="visible"
title="套餐充值"
width="40%"
modal-class="asset-information-dialog"
@closed="handleDialogClosed"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="80px">
<ElFormItem label="资产信息">
<span style="font-weight: bold; color: #409eff">
@@ -22,7 +28,7 @@
<template v-if="!props.seriesId">
<ElOption value="__NO_PACKAGE__" disabled>
<span style="color: var(--el-text-color-warning)">
该设备未关联套餐系列无法购买套餐
{{ noSeriesMessage }}
</span>
</ElOption>
</template>
@@ -45,7 +51,7 @@
</ElSelect>
<template v-if="noSeriesId">
<div style="margin-top: 4px; font-size: 12px; color: var(--el-text-color-warning)">
该设备未关联套餐系列无法购买套餐
{{ noSeriesMessage }}
</div>
</template>
<template v-else-if="packagesEmptyWithSeriesId">
@@ -126,6 +132,7 @@
import { useUserStore } from '@/store/modules/user'
import VoucherUpload from '@/components/business/VoucherUpload.vue'
import { hasVoucherKeys, toVoucherKeyList } from '@/utils/business'
import { normalizeApiError } from '@/utils/business/apiError'
interface Props {
modelValue: boolean
@@ -181,6 +188,9 @@
})
const noSeriesId = computed(() => !props.seriesId)
const noSeriesMessage = computed(
() => `${props.assetType === 'card' ? '卡' : '设备'}未关联套餐系列,无法购买套餐`
)
const selectedPackageIsGift = computed(() => {
if (!form.package_id) return false
@@ -336,7 +346,11 @@
data.payment_voucher_key = toVoucherKeyList(form.payment_voucher_key)
}
await OrderService.createOrder(data)
const response = await OrderService.createOrder(data)
if (response.code !== 0) {
ElMessage.error(response.msg || '订单创建失败')
return
}
if (form.payment_method === 'wallet') {
ElMessage.success('订单创建成功,已自动完成支付')
@@ -352,6 +366,7 @@
return
}
console.error('创建订单失败:', error)
ElMessage.error(normalizeApiError(error).message)
} finally {
loading.value = false
}

View File

@@ -1,62 +1,86 @@
<template>
<ElDialog v-model="visible" title="设置限速" width="500px">
<ElDialog
v-model="visible"
title="设置限速"
width="500px"
:lock-scroll="true"
:z-index="4000"
modal-class="asset-information-dialog speed-limit-modal"
>
<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"
<ElFormItem label="固定档位" prop="code">
<ElSelect
v-model="form.code"
placeholder="请选择限速档位"
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>
:teleported="true"
append-to="body"
popper-class="speed-limit-select-popper"
:popper-style="{ zIndex: 4001 }"
>
<ElOption
v-for="tier in speedTiers"
:key="tier.code"
:label="tier.label"
:value="tier.code"
/>
</ElSelect>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="handleCancel">取消</ElButton>
<ElButton type="primary" @click="handleConfirm" :loading="loading"> 确认设置 </ElButton>
<ElButton
type="primary"
@click="handleConfirm"
:loading="confirmLoading"
:disabled="confirmLoading"
>
确认设置
</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 { ref, reactive, computed, onUnmounted, watch } from 'vue'
import { ElDialog, ElForm, ElFormItem, ElSelect, ElOption, ElButton } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
interface Props {
modelValue: boolean
confirmLoading?: boolean
}
type SpeedTierCode = -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'confirm', data: { download_speed: number; upload_speed: number }): void
(e: 'confirm', data: { code: SpeedTierCode }): void
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
confirmLoading: false
})
const emit = defineEmits<Emits>()
const formRef = ref<FormInstance>()
const loading = ref(false)
const speedTiers = [
{ code: -1, label: '不限速' },
{ code: 0, label: '0kbps' },
{ code: 1, label: '128Kbps' },
{ code: 2, label: '512Kbps' },
{ code: 3, label: '1Mbps' },
{ code: 4, label: '2Mbps' },
{ code: 5, label: '10Mbps' },
{ code: 6, label: '20Mbps' },
{ code: 7, label: '50Mbps' },
{ code: 8, label: '100Mbps' }
] as const
const form = reactive({
download_speed: 1024,
upload_speed: 512
})
const form = reactive<{ code: SpeedTierCode | undefined }>({ code: undefined })
const rules: FormRules = {
download_speed: [{ required: true, message: '请输入下行速率', trigger: 'blur' }],
upload_speed: [{ required: true, message: '请输入上行速率', trigger: 'blur' }]
code: [{ required: true, message: '请选择限速档位', trigger: 'change' }]
}
const visible = computed({
@@ -65,11 +89,25 @@
})
// 监听对话框打开,重置表单
watch(visible, (newVal) => {
if (newVal) {
form.download_speed = 1024
form.upload_speed = 512
}
const setLayoutScrollLock = (locked: boolean) => {
document
.querySelector<HTMLElement>('.layouts')
?.classList.toggle('speed-limit-scroll-locked', locked)
}
watch(
visible,
(newVal) => {
setLayoutScrollLock(newVal)
if (newVal) {
form.code = undefined
}
},
{ immediate: true }
)
onUnmounted(() => {
setLayoutScrollLock(false)
})
const handleCancel = () => {
@@ -77,13 +115,14 @@
}
const handleConfirm = async () => {
if (props.confirmLoading) return
if (!formRef.value) return
try {
await formRef.value.validate()
if (form.code === undefined) return
emit('confirm', {
download_speed: form.download_speed,
upload_speed: form.upload_speed
code: form.code
})
} catch (error) {
console.error('表单验证失败:', error)
@@ -91,4 +130,28 @@
}
</script>
<style lang="scss" scoped></style>
<style lang="scss">
.speed-limit-modal .el-dialog__body {
overflow: visible;
}
.speed-limit-select-popper {
z-index: 4001 !important;
overscroll-behavior: contain;
}
.speed-limit-select-popper .el-select-dropdown__wrap,
.speed-limit-select-popper .el-scrollbar__wrap {
max-height: none !important;
overflow-y: visible !important;
}
.speed-limit-modal {
overflow: hidden;
overscroll-behavior: contain;
}
.layouts.speed-limit-scroll-locked {
overflow: hidden !important;
}
</style>

View File

@@ -1,6 +1,11 @@
<template>
<ElDialog v-model="visible" title="设置切卡模式" width="500px">
<ElForm ref="formRef" :model="form" :rules="rules" label-width="96px">
<ElDialog
v-model="visible"
title="设置切卡模式"
width="45%"
modal-class="asset-information-dialog"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="80px">
<ElFormItem label="设备标识">
<span class="device-identifier">{{ deviceIdentifier || '-' }}</span>
</ElFormItem>

View File

@@ -1,5 +1,11 @@
<template>
<ElDialog v-model="visible" title="设置WiFi" width="30%" :append-to-body="true">
<ElDialog
v-model="visible"
title="设置WiFi"
width="40%"
modal-class="asset-information-dialog"
:append-to-body="true"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="80px" autocomplete="off">
<ElFormItem label="WiFi状态" prop="enabled">
<ElRadioGroup v-model="form.enabled">

View File

@@ -6,7 +6,7 @@
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { AssetService } from '@/api/modules'
import { formatRemainingTime, FrontendRateLimitError } from '@/utils/business/apiRateLimit'
import { FrontendRateLimitError } from '@/utils/business/apiRateLimit'
import type {
AssetBoundCard,
AssetWalletResponse,
@@ -123,6 +123,8 @@ export function useAssetInfo() {
updated_at: data.updated_at,
accumulated_recharge: data.accumulated_recharge,
first_commission_paid: data.first_commission_paid,
polling: data.polling ?? null,
exchange_trace: data.exchange_trace ?? null,
// 卡专属字段 (asset_type === 'card')
iccid: data.iccid || '',
@@ -138,6 +140,11 @@ export function useAssetInfo() {
gateway_extend: data.gateway_extend ?? '',
total_virtual_used_mb: data.total_virtual_used_mb ?? null,
total_virtual_remaining_mb: data.total_virtual_remaining_mb ?? null,
estimated_final_expires_at: data.estimated_final_expires_at ?? null,
days_until_final_expiry: data.days_until_final_expiry ?? null,
expiry_estimate_status: data.expiry_estimate_status,
expiry_estimate_status_name: data.expiry_estimate_status_name ?? null,
is_expiring: data.is_expiring ?? false,
bound_device_id: data.bound_device_id,
bound_device_no: data.bound_device_no || '',
bound_device_name: data.bound_device_name || '',
@@ -430,11 +437,11 @@ export function useAssetInfo() {
return true
} catch (error: any) {
if (error instanceof FrontendRateLimitError) {
ElMessage.warning(`操作过于频繁,请${formatRemainingTime(error.remainingMs)}后再试`)
ElMessage.warning('操作过于频繁,请稍后重试')
return false
}
if (error?.response?.status === 429) {
ElMessage.warning('操作过于频繁,请5分钟后再试')
ElMessage.warning('操作过于频繁,请稍后重试')
return false
}
return false

View File

@@ -11,11 +11,11 @@ import { formatRemainingTime, FrontendRateLimitError } from '@/utils/business/ap
export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Promise<void>) {
// Loading states
const enableCardLoading = ref(false)
const startFailureMessage = ref('')
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)
@@ -29,22 +29,33 @@ export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Pr
type: 'warning'
})
startFailureMessage.value = ''
enableCardLoading.value = true
const res = await AssetService.startAsset(cardInfo.value.iccid)
const res = await AssetService.startAsset(cardInfo.value.iccid, {
requestOptions: { errorMessageMode: 'none' }
})
if (res.code === 0) {
ElMessage.success('启用成功')
if (refreshAssetFn) {
await refreshAssetFn()
}
return true
}
startFailureMessage.value = res.msg || '复机失败,请稍后重试'
return false
} catch (error: any) {
if (error !== 'cancel') {
if (error instanceof FrontendRateLimitError) {
ElMessage.warning(`操作过于频繁,请${formatRemainingTime(error.remainingMs)}后再试`)
return
return false
}
console.error('启用失败:', error)
const backendMessage = error?.response?.data?.msg
startFailureMessage.value =
typeof backendMessage === 'string' && backendMessage.trim()
? backendMessage
: '复机失败,请稍后重试'
}
return false
} finally {
enableCardLoading.value = false
}
@@ -169,35 +180,6 @@ export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Pr
}
}
/**
* 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)
console.log(error?.message || '设置失败')
return false
} finally {
speedLimitLoading.value = false
}
}
/**
* Switch to different card
*/
@@ -303,11 +285,11 @@ export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Pr
return {
// Loading states
enableCardLoading,
startFailureMessage,
disableCardLoading,
manualDeactivateCardLoading,
rebootDeviceLoading,
resetDeviceLoading,
speedLimitLoading,
switchCardLoading,
setWiFiLoading,
pollingLoading,
@@ -320,7 +302,6 @@ export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Pr
// Device operations
rebootDevice,
resetDevice,
setSpeedLimit,
switchCard,
setWiFi,

View File

@@ -1,11 +1,10 @@
<template>
<div class="single-card-page">
<div class="single-card-page asset-information-page">
<!-- 资产查询区域 -->
<AssetSearchCard
ref="assetSearchCardRef"
:has-card-info="!!cardInfo"
:refresh-disabled="refreshDisabled"
:refresh-remaining-text="refreshRemainingText"
@search="handleSearch"
@refresh="handleRefresh"
/>
@@ -13,51 +12,55 @@
<!-- 卡片内容区域 -->
<div v-if="cardInfo" class="card-content-area slide-in">
<div class="main-content-layout">
<!-- 第一行基本信息 -->
<div class="row full-width">
<BasicInfoCard
:card-info="cardInfo"
:device-realtime="deviceRealtime"
v-model:polling-enabled="pollingEnabled"
:realtime-loading="realtimeLoading"
:start-disabled="startDisabled"
:stop-disabled="stopDisabled"
:start-remaining-text="startRemainingText"
:stop-remaining-text="stopRemainingText"
:get-start-remaining-text="getStartRemainingText"
:get-stop-remaining-text="getStopRemainingText"
:is-start-disabled="isStartActionDisabled"
:is-stop-disabled="isStopActionDisabled"
@enable-card="handleEnableCard"
@disable-card="handleDisableCard"
@manual-deactivate="handleManualDeactivate"
@reboot-device="handleRebootDevice"
@reset-device="handleResetDevice"
@show-switch-card="showSwitchCardDialog"
@show-switch-mode="showSwitchModeDialog"
@show-set-wifi="showSetWiFiDialog"
@show-realname-policy="showRealnamePolicyDialog"
@show-update-realname-status="showUpdateRealnameStatusDialog"
@enable-binding-card="handleEnableBindingCard"
@disable-binding-card="handleDisableBindingCard"
@update-binding-card-realname-status="handleUpdateBindingCardRealnameStatus"
@navigate-to-card="handleNavigateToCard"
@navigate-to-device="handleNavigateToDevice"
/>
</div>
<!-- 第二行当前生效套餐 -->
<div class="row full-width">
<CurrentPackageCard
:current-package="currentPackage"
:loading="currentPackageLoading"
:error-msg="currentPackageErrorMsg"
:bound-device-id="cardInfo.bound_device_id"
:bound-device-no="cardInfo.bound_device_no"
:bound-device-name="cardInfo.bound_device_name"
@show-recharge="showRechargeDialog"
@navigate-to-device="handleNavigateToDevice"
/>
<div class="overview-grid">
<!-- 第一层资产概览与当前生效套餐按信息高度纵向展开 -->
<div class="row overview-primary">
<BasicInfoCard
:card-info="cardInfo"
:device-realtime="deviceRealtime"
v-model:polling-enabled="pollingEnabled"
:realtime-loading="realtimeLoading"
:start-disabled="startDisabled"
:start-error-message="startFailureMessage"
:stop-disabled="stopDisabled"
:start-remaining-text="startRemainingText"
:stop-remaining-text="stopRemainingText"
:get-start-remaining-text="getStartRemainingText"
:get-stop-remaining-text="getStopRemainingText"
:is-start-disabled="isStartActionDisabled"
:is-stop-disabled="isStopActionDisabled"
@enable-card="handleEnableCard"
@disable-card="handleDisableCard"
@manual-deactivate="handleManualDeactivate"
@reboot-device="handleRebootDevice"
@reset-device="handleResetDevice"
@show-switch-card="showSwitchCardDialog"
@show-switch-mode="showSwitchModeDialog"
@show-set-wifi="showSetWiFiDialog"
@show-realname-policy="showRealnamePolicyDialog"
@show-speed-limit="speedLimitDialogVisible = true"
@show-update-realname-status="showUpdateRealnameStatusDialog"
@enable-binding-card="handleEnableBindingCard"
@disable-binding-card="handleDisableBindingCard"
@update-binding-card-realname-status="handleUpdateBindingCardRealnameStatus"
@navigate-to-asset="handleNavigateToAsset"
@navigate-to-card="handleNavigateToCard"
@navigate-to-device="handleNavigateToDevice"
>
<template #current-package>
<CurrentPackageCard
:current-package="currentPackage"
:loading="currentPackageLoading"
:error-msg="currentPackageErrorMsg"
:bound-device-id="cardInfo.bound_device_id"
:bound-device-no="cardInfo.bound_device_no"
:bound-device-name="cardInfo.bound_device_name"
@show-recharge="showRechargeDialog"
@navigate-to-device="handleNavigateToDevice"
/>
</template>
</BasicInfoCard>
</div>
</div>
<!-- 第三行套餐列表 -->
@@ -94,15 +97,6 @@
@navigate-to-device="handleNavigateToDevice"
/>
</div>
<!-- 第五行操作审计日志 -->
<div class="row full-width">
<OperationLogsCard
:asset-identifier="cardInfo.identifier"
:asset-type="cardInfo.asset_type"
download-permission="asset_info:download_log_file"
/>
</div>
</div>
</div>
@@ -172,6 +166,11 @@
:current-realname-status="bindingCardRealnameStatusValue"
@success="handleBindingCardRealnameStatusSuccess"
/>
<SpeedLimitDialog
v-model="speedLimitDialogVisible"
:confirm-loading="speedTierSubmitting"
@confirm="handleSpeedTierConfirm"
/>
</div>
</template>
@@ -180,7 +179,10 @@
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElCard, ElEmpty } from 'element-plus'
import { RoutesAlias } from '@/router/routesAlias'
import { DeviceService } from '@/api/modules'
import { CardService, DeviceService } from '@/api/modules'
import { JULY_PERMISSIONS } from '@/config/constants'
import { useAuth } from '@/composables/useAuth'
import { normalizeApiError } from '@/utils/business/apiError'
import {
formatRemainingTime,
FrontendRateLimitError,
@@ -194,7 +196,6 @@
import CurrentPackageCard from './components/CurrentPackageCard.vue'
import PackageListCard from './components/PackageListCard.vue'
import WalletTransactionCard from './components/WalletTransactionCard.vue'
import OperationLogsCard from './components/OperationLogsCard.vue'
// 引入对话框组件
import {
@@ -202,7 +203,8 @@
WiFiConfigDialog,
PackageRechargeDialog,
DailyRecordsDialog,
OrderHistoryDialog
OrderHistoryDialog,
SpeedLimitDialog
} from './components/dialogs'
import SwitchCardDialog from '@/components/device/SwitchCardDialog.vue'
import RealnamePolicyDialog from '@/components/device/RealnamePolicyDialog.vue'
@@ -231,6 +233,7 @@
const route = useRoute()
const router = useRouter()
const { hasAuth } = useAuth()
// ========== 组件引用 ==========
const assetSearchCardRef = ref<InstanceType<typeof AssetSearchCard>>()
@@ -258,6 +261,7 @@
// ========== 资产操作方法(使用 composable ==========
const {
enableCardLoading,
startFailureMessage,
disableCardLoading,
enableCard: enableCardOp,
disableCard: disableCardOp,
@@ -288,6 +292,8 @@
// 绑定卡实名状态更新
const bindingCardRealnameStatusDialogVisible = ref(false)
const speedLimitDialogVisible = ref(false)
const speedTierSubmitting = ref(false)
const bindingCardRealnameStatusIccid = ref('')
const bindingCardRealnameStatusValue = ref<number>(0)
@@ -332,26 +338,7 @@
const refreshSubmitting = ref(false)
let rateLimitTimer: ReturnType<typeof setInterval> | undefined
const refreshRateLimitState = computed(() => {
const identifier = cardInfo.value?.identifier
if (!identifier) {
return {
limited: false,
remainingMs: 0,
retryAt: 0
}
}
return getAssetActionRateLimitState('refresh', identifier, rateLimitNow.value)
})
const refreshDisabled = computed(
() => refreshSubmitting.value || refreshRateLimitState.value.limited
)
const refreshRemainingText = computed(() =>
refreshRateLimitState.value.limited
? formatRemainingTime(refreshRateLimitState.value.remainingMs)
: ''
)
const refreshDisabled = computed(() => refreshSubmitting.value)
const startRateLimitState = computed(() => {
const identifier = cardInfo.value?.iccid || cardInfo.value?.identifier
@@ -418,6 +405,7 @@
const handleSearch = async ({ identifier }: { identifier: string }) => {
// 更新搜索框的值
assetSearchCardRef.value?.updateSearchValue(identifier)
startFailureMessage.value = ''
// fetchAssetDetail 内部已经会并行加载所有相关数据,不需要重复调用
await fetchAssetDetail(identifier)
@@ -465,7 +453,10 @@
const handleEnableCard = async () => {
if (startDisabled.value) return
try {
await enableCardOp()
const started = await enableCardOp()
if (started && cardInfo.value) {
await fetchAssetDetail(cardInfo.value.identifier)
}
} finally {
updateRateLimitNow()
}
@@ -749,6 +740,13 @@
await handleSearch({ identifier: iccid })
}
/**
* 跳转到任意资产信息
*/
const handleNavigateToAsset = async (identifier: string) => {
await handleSearch({ identifier })
}
/**
* 跳转到绑定设备信息
*/
@@ -807,6 +805,42 @@
handleSearch({ identifier })
}
}
const handleSpeedTierConfirm = async (data: { code: -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 }) => {
if (!hasAuth(JULY_PERMISSIONS.speedTier.update)) {
ElMessage.error('暂无设置限速权限')
return
}
const iccid = cardInfo.value?.iccid
if (!iccid) {
ElMessage.error('当前卡缺少 ICCID无法设置限速')
return
}
if (speedTierSubmitting.value) return
speedTierSubmitting.value = true
try {
const response = await CardService.setSpeedTier(iccid, data.code)
if (response.code !== 0) {
speedLimitDialogVisible.value = false
await nextTick()
ElMessage.error(response.msg || '限速设置失败')
return
}
speedLimitDialogVisible.value = false
await nextTick()
ElMessage.success(`限速设置成功:${response.data.speed_tier_name}`)
await handleRefresh()
} catch (error) {
console.error('设置限速失败:', error)
speedLimitDialogVisible.value = false
await nextTick()
ElMessage.error(normalizeApiError(error).message)
} finally {
speedTierSubmitting.value = false
}
}
</script>
<style lang="scss" scoped>
@@ -956,6 +990,12 @@
}
// 主内容布局
.overview-grid {
display: flex;
flex-direction: column;
gap: 24px;
}
.main-content-layout {
display: flex;
flex-direction: column;
@@ -1024,6 +1064,10 @@
}
@media (width <= 768px) {
.overview-grid {
gap: 16px;
}
gap: 16px;
.row {
@@ -1603,3 +1647,118 @@
}
}
</style>
<style lang="scss" scoped>
.asset-information-page {
background: #ffffff;
}
</style>
<style lang="scss">
/* Dialogs are teleported to body, so their surface needs a page-level hook. */
.asset-information-dialog.el-dialog {
max-width: calc(100vw - 32px);
overflow: hidden;
border: 1px solid #e5eaf1;
border-radius: 14px;
box-shadow: 0 18px 50px rgb(15 23 42 / 18%);
.el-dialog__header {
padding: 20px 24px 16px;
margin: 0;
border-bottom: 1px solid #e5eaf1;
}
.el-dialog__title {
font-size: 16px;
font-weight: 650;
color: #172033;
letter-spacing: -0.01em;
}
.el-dialog__body {
padding: 22px 24px 20px;
}
.el-dialog__footer {
padding: 14px 24px 20px;
border-top: 1px solid #eef2f7;
}
.el-form-item {
margin-bottom: 18px;
}
.el-form-item__label {
color: #526078;
}
.el-button {
min-height: 34px;
border-radius: 8px;
}
.el-table {
--el-table-header-bg-color: #f8fafc;
--el-table-row-hover-bg-color: #f5f8ff;
--el-table-border-color: #e5eaf1;
border-radius: 9px;
}
.el-descriptions__label.el-descriptions__cell.is-bordered-label {
color: #526078;
background: #f8fafc;
}
.dialog-footer {
display: flex;
gap: 8px;
justify-content: flex-end;
}
}
@media (width <= 768px) {
.asset-information-dialog.el-dialog {
width: calc(100vw - 20px) !important;
.el-dialog__header {
padding: 16px 18px 13px;
}
.el-dialog__body {
max-height: calc(100vh - 150px);
padding: 18px;
overflow-y: auto;
}
.el-dialog__footer {
padding: 12px 18px 16px;
}
}
}
html.dark .asset-information-dialog.el-dialog {
border-color: #2b3a52;
.el-dialog__header,
.el-dialog__footer {
border-color: #2b3a52;
}
.el-dialog__title,
.el-form-item__label {
color: #e5edf8;
}
.el-table {
--el-table-header-bg-color: #202d42;
--el-table-row-hover-bg-color: #223451;
--el-table-border-color: #2b3a52;
}
.el-descriptions__label.el-descriptions__cell.is-bordered-label {
color: #afbdd0;
background: #202d42;
}
}
</style>

View File

@@ -2,6 +2,8 @@
* 资产信息页面类型定义
*/
import type { AssetExchangeTrace, AssetPollingStatus, ExpiryEstimateStatus } from '@/types/api'
// 资产类型枚举
export type AssetType = 'card' | 'device'
@@ -33,10 +35,17 @@ export interface AssetInfo {
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
}
// 设备绑定卡信息