Merge branch 'develop'
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 设备绑定卡信息
|
||||
|
||||
Reference in New Issue
Block a user