fix: 优化体验
Some checks failed
构建并部署前端到测试环境 / build-and-deploy (push) Failing after 3h10m20s

This commit is contained in:
sexygoat
2026-05-09 16:13:39 +08:00
parent b54d8f5b32
commit 28246f5345
17 changed files with 1684 additions and 255 deletions

View File

@@ -47,7 +47,8 @@ export interface AssetResolveResponse {
cards?: Array<{
card_id: number
iccid: string
msisdn: string
msisdn?: string | null
carrier_name?: string | null
network_status: number
real_name_status: number
slot_position: number
@@ -74,6 +75,7 @@ export interface CardInfo {
msisdn?: string // 电话号码
virtual_no?: string // 虚拟号
virtual_number?: string // 虚拟号 (别名兼容)
device_virtual_no?: string | null
imei?: string
carrier_id?: number // 运营商ID
carrier_name?: string // 运营商名称
@@ -246,7 +248,8 @@ export interface AssetRealtimeStatus {
card_id: number
iccid: string
is_current: boolean
msisdn: string
msisdn?: string | null
carrier_name?: string | null
network_status: number
real_name_status: number
slot_position: number

16
src/api/shop.ts Normal file
View File

@@ -0,0 +1,16 @@
import { get } from '@/utils/request'
export interface ShopCascadeParams {
parent_id?: number
shop_name?: string
}
export interface ShopCascadeItem {
id: number
shop_name: string
has_children: boolean
}
export function getShopCascade(params?: ShopCascadeParams) {
return get<ShopCascadeItem[]>('/api/admin/shops/cascade', { params })
}

View File

@@ -0,0 +1,345 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { getShopCascade } from '@/api/shop'
import type { ShopCascadeItem } from '@/api/shop'
interface SelectedShop {
id: number
shop_name: string
}
interface Props {
show: boolean
selectedShop?: SelectedShop | null
title?: string
}
interface Emits {
(e: 'update:show', value: boolean): void
(e: 'confirm', shop: SelectedShop | null): void
(e: 'cancel'): void
}
const props = withDefaults(defineProps<Props>(), {
title: '选择店铺',
selectedShop: null,
})
const emit = defineEmits<Emits>()
const loading = ref(false)
const shopOptions = ref<ShopCascadeItem[]>([])
const breadcrumbs = ref<ShopCascadeItem[]>([])
const selectedShop = ref<SelectedShop | null>(null)
async function loadShopOptions(parentId?: number) {
loading.value = true
try {
const response = await getShopCascade(parentId !== undefined ? { parent_id: parentId } : undefined)
shopOptions.value = response || []
}
catch (error) {
console.error('加载店铺级联数据失败:', error)
shopOptions.value = []
}
finally {
loading.value = false
}
}
async function openPicker() {
selectedShop.value = props.selectedShop ? { ...props.selectedShop } : null
breadcrumbs.value = []
await loadShopOptions()
}
function close() {
emit('update:show', false)
}
function handleCancel() {
emit('cancel')
close()
}
function selectShop(option: ShopCascadeItem) {
selectedShop.value = {
id: option.id,
shop_name: option.shop_name,
}
}
async function enterChildren(option: ShopCascadeItem) {
const nextPath = [...breadcrumbs.value, option]
breadcrumbs.value = nextPath
await loadShopOptions(option.id)
}
async function goToBreadcrumb(index: number) {
if (index < 0) {
breadcrumbs.value = []
await loadShopOptions()
return
}
const nextPath = breadcrumbs.value.slice(0, index + 1)
breadcrumbs.value = nextPath
await loadShopOptions(nextPath[index]?.id)
}
function handleConfirm() {
emit('confirm', selectedShop.value)
close()
}
watch(
() => props.show,
async (show) => {
if (show) {
await openPicker()
}
},
)
</script>
<template>
<view
v-if="show"
class="shop-picker-overlay"
@click="handleCancel"
>
<view
class="shop-picker-content"
@click.stop
>
<view class="shop-picker-header">
<view class="shop-picker-action" @click="handleCancel">
<text class="text-sm text-[#666]">取消</text>
</view>
<view class="shop-picker-title">
<text class="text-base font-600 text-[#1e293b]">{{ title }}</text>
</view>
<view class="shop-picker-action text-right" @click="handleConfirm">
<text class="text-sm text-brand font-600">确定</text>
</view>
</view>
<scroll-view
class="shop-picker-breadcrumb"
scroll-x
>
<view class="shop-picker-breadcrumb-list">
<text
class="shop-picker-breadcrumb-item"
:class="{ 'shop-picker-breadcrumb-item-active': breadcrumbs.length === 0 }"
@click="goToBreadcrumb(-1)"
>
顶级店铺
</text>
<template
v-for="(item, index) in breadcrumbs"
:key="item.id"
>
<text class="shop-picker-breadcrumb-separator">/</text>
<text
class="shop-picker-breadcrumb-item"
:class="{ 'shop-picker-breadcrumb-item-active': index === breadcrumbs.length - 1 }"
@click="goToBreadcrumb(index)"
>
{{ item.shop_name }}
</text>
</template>
</view>
</scroll-view>
<view v-if="selectedShop" class="shop-picker-selection">
<text class="text-12px text-[#666]">
已选店铺
</text>
<text class="text-13px font-600 text-[#212121]">
{{ selectedShop.shop_name }}
</text>
</view>
<scroll-view
class="shop-picker-list"
scroll-y
>
<view v-if="loading" class="shop-picker-empty">
<i class="i-mdi-loading animate-spin text-18px text-brand" />
<text class="text-13px text-[#64748b]">加载中...</text>
</view>
<view v-else-if="shopOptions.length === 0" class="shop-picker-empty">
<text class="text-13px text-[#94a3b8]">暂无下级店铺</text>
</view>
<template v-else>
<view
v-for="option in shopOptions"
:key="option.id"
class="shop-picker-item"
:class="{ 'shop-picker-item-active': selectedShop?.id === option.id }"
>
<view
class="shop-picker-item-main"
@click="selectShop(option)"
>
<text class="shop-picker-item-text">{{ option.shop_name }}</text>
<i
v-if="selectedShop?.id === option.id"
class="i-mdi-check text-16px text-brand"
/>
</view>
<view
v-if="option.has_children"
class="shop-picker-next"
@click="enterChildren(option)"
>
<text class="text-12px text-[#999]">下级</text>
<i class="i-mdi-chevron-right text-14px text-[#999]" />
</view>
</view>
</template>
</scroll-view>
</view>
</view>
</template>
<style scoped>
.shop-picker-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
flex-direction: column;
justify-content: flex-end;
background: rgba(0, 0, 0, 0.5);
}
.shop-picker-content {
display: flex;
flex-direction: column;
max-height: 72vh;
background: #fff;
border-radius: 16px 16px 0 0;
}
.shop-picker-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid #f1f5f9;
}
.shop-picker-action {
min-width: 60px;
}
.shop-picker-title {
flex: 1;
text-align: center;
}
.shop-picker-breadcrumb {
white-space: nowrap;
border-bottom: 1px solid #f8fafc;
}
.shop-picker-breadcrumb-list {
display: inline-flex;
align-items: center;
min-width: 100%;
padding: 12px 16px;
}
.shop-picker-breadcrumb-item {
font-size: 12px;
color: #64748b;
}
.shop-picker-breadcrumb-item-active {
color: var(--brand-primary, #1677ff);
font-weight: 600;
}
.shop-picker-breadcrumb-separator {
margin: 0 8px;
font-size: 12px;
color: #cbd5e1;
}
.shop-picker-selection {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
background: #f8fafc;
}
.shop-picker-list {
flex: 1;
max-height: calc(72vh - 114px);
}
.shop-picker-empty {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 24px 16px;
}
.shop-picker-item {
display: flex;
align-items: stretch;
border-bottom: 1px solid #f8fafc;
}
.shop-picker-item-active {
background: #fef3f2;
}
.shop-picker-item-main {
display: flex;
flex: 1;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
}
.shop-picker-item-text {
flex: 1;
font-size: 15px;
color: #1e293b;
}
.shop-picker-item-active .shop-picker-item-text {
color: var(--brand-primary, #1677ff);
font-weight: 600;
}
.shop-picker-next {
display: flex;
align-items: center;
gap: 2px;
padding: 0 16px 0 8px;
}
.animate-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -76,6 +76,14 @@ function getPackageTypeText(type?: AssetPackage['package_type']) {
return type ? map[type] || type : '-'
}
function formatPackageEffectivePeriod(activatedAt?: string | null, expiresAt?: string | null) {
return `生效期:${formatTime(activatedAt || undefined, 'date')}${formatTime(expiresAt || undefined, 'date')}`
}
function formatCurrentPackagePeriodRange(activatedAt?: string | null, expiresAt?: string | null) {
return `生效期:${formatTime(activatedAt || undefined, 'date')} - ${formatTime(expiresAt || undefined, 'date')}`
}
// 页面加载
onMounted(() => {
// 从路由参数获取 assetType 和 assetIdentifier
@@ -506,8 +514,8 @@ function viewWallet() {
<text class="text-14px font-600 text-[#212121] block break-all">
{{ currentPackage.package_name || '-' }}
</text>
<text class="text-11px text-[#999] block mt-1 break-all">
订单号{{ currentPackage.order_no || '-' }}
<text class="text-11px text-[#64748b] block mt-1 break-all">
{{ formatCurrentPackagePeriodRange(currentPackage.activated_at, currentPackage.expires_at) }}
</text>
</view>
<view
@@ -564,14 +572,6 @@ function viewWallet() {
<text class="text-[#666]">套餐类型</text>
<text class="text-[#212121]">{{ getPackageTypeText(currentPackage.package_type) }}</text>
</view>
<view class="flex items-center justify-between text-12px">
<text class="text-[#666]">开始时间</text>
<text class="text-[#212121]">{{ formatTime(currentPackage.activated_at) }}</text>
</view>
<view class="flex items-center justify-between text-12px">
<text class="text-[#666]">到期时间</text>
<text class="text-[#212121]">{{ formatTime(currentPackage.expires_at) }}</text>
</view>
<view class="flex items-center justify-between text-12px">
<text class="text-[#666]">下单时间</text>
<text class="text-[#212121]">{{ formatTime(currentPackage.created_at) }}</text>
@@ -642,9 +642,11 @@ function viewWallet() {
<text class="text-[#666]">金额</text>
<text class="text-[#212121]">{{ formatPackagePrice(pkg.paid_amount) }}</text>
</view>
<text class="text-11px text-[#999]">
{{ pkg.activated_at || '-' }} ~ {{ pkg.expires_at || '-' }}
</text>
<view v-if="pkg.status === 1" class="mt-2 bg-[#f8fafc] rounded-8px px-2.5 py-2">
<text class="text-11px font-600 text-[#475569]">
{{ formatPackageEffectivePeriod(pkg.activated_at, pkg.expires_at) }}
</text>
</view>
</view>
</view>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { resolveAsset, getAssetRealtimeStatus, getCurrentPackage, getAssetPackages } from '@/api/assets'
import type { CardInfo, DeviceInfo, AssetRealtimeStatus, AssetPackage } from '@/api/assets'
import type { AssetRealtimeStatus, AssetPackage } from '@/api/assets'
import { formatTime, formatDataSize } from '@/utils/format'
import { formatPackageDataSize, formatPackagePrice, getPackageRemainMb, getPackageTotalMb, getPackageUsagePercent, getPackageUsedMb } from '../package-display'
@@ -122,26 +122,23 @@
return status !== undefined ? map[status] || '未知' : '未知'
}
function getRealNameStatusTagClass(status ?: number | null) {
return status === 1
? 'bg-[#e8f5e9] text-[#2e7d32]'
: 'bg-[#fff1f0] text-[#cf1322]'
}
// 获取网络状态文本 (卡片)
function getNetworkStatusText(status ?: number) {
const map : Record<number, string> = {
0: '停机',
1: '开机',
1: '正常',
}
return status !== undefined ? map[status] || '未知' : '未知'
}
// 获取切卡模式文本 (设备)
function getSwitchModeText(mode ?: string) {
const map : Record<string, string> = {
'0': '自动',
'1': '手动',
}
return mode ? map[mode] || mode : '自动'
}
// 格式化字节数
function formatBytes(bytes ?: string | number) {
function formatBytes(bytes ?: string | number | null) {
if (!bytes) return '0B'
const num = typeof bytes === 'string' ? parseInt(bytes) : bytes
if (num < 1024) return `${num}B`
@@ -174,16 +171,6 @@
return `${secs}`
}
// 获取设备保护状态文本
function getDeviceProtectText(status ?: string | null) {
const map : Record<string, string> = {
start: '已启动',
stop: '已停止',
none: '未启用',
}
return status ? map[status] || status : '未知'
}
// 获取在线状态文本
function getOnlineStatusText(status ?: number) {
return status === 1 ? '在线' : '离线'
@@ -209,6 +196,19 @@
return type ? map[type] || type : '-'
}
function formatPackageEffectivePeriod(activatedAt ?: string | null, expiresAt ?: string | null) {
return `生效期:${formatTime(activatedAt || undefined, 'date')}${formatTime(expiresAt || undefined, 'date')}`
}
function formatCurrentPackagePeriodRange(activatedAt ?: string | null, expiresAt ?: string | null) {
return `生效期:${formatTime(activatedAt || undefined, 'date')} - ${formatTime(expiresAt || undefined, 'date')}`
}
function getDeviceLastOnlineTime() {
const lastOnlineTime = realtimeStatus.value?.device_realtime?.last_online_time || deviceInfo.value?.last_online_time
return lastOnlineTime ? formatTime(lastOnlineTime) : '-'
}
// 获取信号质量等级 (基于 RSRP)
function getSignalQuality(rsrp ?: number) {
if (rsrp === undefined) return { level: '未知', text: '无信号', color: '#999' }
@@ -270,19 +270,9 @@
return status !== undefined ? map[status] : { bg: '#f5f5f5', text: '#999' }
}
// 获取设备保护状态文本
function getDeviceProtectStatusText(status ?: string) {
const map : Record<string, string> = {
none: '无保护',
basic: '基础保护',
advanced: '高级保护',
}
return status ? map[status] || status : '无保护'
}
onMounted(() => {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const currentPage = pages[pages.length - 1] as any
const options = currentPage.options || {}
// 如果有 iccid 或 virtual_no 参数,自动搜索
@@ -366,14 +356,6 @@
</text>
</view>
<!-- 批次号 -->
<view v-if="cardInfo.batch_no" class="flex items-start justify-between">
<text class="text-12px text-[#999] w-70px">批次号</text>
<text class="flex-1 text-13px text-[#212121] text-right">
{{ cardInfo.batch_no }}
</text>
</view>
<!-- 网络状态 -->
<view v-if="cardInfo.network_status !== undefined" class="flex items-start justify-between">
<text class="text-12px text-[#999] w-70px">网络状态</text>
@@ -512,8 +494,8 @@
<text class="text-14px font-600 text-[#212121] block break-all">
{{ currentPackage.package_name || '-' }}
</text>
<text class="text-11px text-[#999] block mt-1 break-all">
订单号{{ currentPackage.order_no || '-' }}
<text class="text-11px text-[#64748b] block mt-1 break-all">
{{ formatCurrentPackagePeriodRange(currentPackage.activated_at, currentPackage.expires_at) }}
</text>
</view>
<view :style="{
@@ -564,14 +546,6 @@
<text class="text-[#666]">套餐类型</text>
<text class="text-[#212121]">{{ getPackageTypeText(currentPackage.package_type) }}</text>
</view>
<view class="flex items-center justify-between text-11px">
<text class="text-[#666]">开始时间</text>
<text class="text-[#212121]">{{ formatTime(currentPackage.activated_at) }}</text>
</view>
<view class="flex items-center justify-between text-11px">
<text class="text-[#666]">到期时间</text>
<text class="text-[#212121]">{{ formatTime(currentPackage.expires_at) }}</text>
</view>
<view class="flex items-center justify-between text-11px">
<text class="text-[#666]">下单时间</text>
<text class="text-[#212121]">{{ formatTime(currentPackage.created_at) }}</text>
@@ -912,14 +886,6 @@
</text>
</view>
<!-- 制造商 -->
<view v-if="deviceInfo.manufacturer" class="flex items-start justify-between">
<text class="text-12px text-[#999] w-80px">制造商</text>
<text class="flex-1 text-13px text-[#212121] text-right">
{{ deviceInfo.manufacturer }}
</text>
</view>
<!-- 最大卡槽数 -->
<view v-if="deviceInfo.max_sim_slots" class="flex items-start justify-between">
<text class="text-12px text-[#999] w-80px">最大卡槽</text>
@@ -936,14 +902,6 @@
</text>
</view>
<!-- 批次号 -->
<view v-if="deviceInfo.batch_no" class="flex items-start justify-between">
<text class="text-12px text-[#999] w-80px">批次号</text>
<text class="flex-1 text-13px text-[#212121] text-right">
{{ deviceInfo.batch_no }}
</text>
</view>
<!-- 店铺信息 -->
<view v-if="deviceInfo.shop_name" class="flex items-start justify-between">
<text class="text-12px text-[#999] w-80px">所属店铺</text>
@@ -960,22 +918,6 @@
</text>
</view>
<!-- 保护状态 -->
<view v-if="deviceInfo.device_protect_status" class="flex items-start justify-between">
<text class="text-12px text-[#999] w-80px">保护状态</text>
<text class="flex-1 text-13px text-[#212121] text-right">
{{ getDeviceProtectStatusText(deviceInfo.device_protect_status) }}
</text>
</view>
<!-- 切卡模式 -->
<view v-if="deviceInfo.switch_mode !== undefined" class="flex items-start justify-between">
<text class="text-12px text-[#999] w-80px">切卡模式</text>
<text class="flex-1 text-13px text-[#212121] text-right">
{{ getSwitchModeText(deviceInfo.switch_mode) }}
</text>
</view>
<!-- 实名状态 -->
<view v-if="deviceInfo.real_name_status !== undefined" class="flex items-start justify-between">
<text class="text-12px text-[#999] w-80px">实名状态</text>
@@ -1033,21 +975,27 @@
<view class="space-y-2">
<view v-for="card in deviceInfo.cards" :key="card.card_id" class="bg-[#f5f5f5] rounded-12px p-3">
<view class="flex items-center justify-between mb-1">
<text class="text-12px text-[#212121]">卡槽 {{ card.slot_position }}</text>
<view class="flex items-center gap-2 min-w-0">
<text class="text-12px text-[#212121]">卡槽 {{ card.slot_position }}</text>
<text :class="[
'px-2 py-0.5 rounded-full text-11px font-600',
getRealNameStatusTagClass(card.real_name_status),
]">
{{ getRealNameStatusText(card.real_name_status) }}
</text>
</view>
<text v-if="card.is_current" class="text-11px text-white bg-[#3ed268] px-2 py-0.5 rounded">
当前
</text>
</view>
<view class="space-y-1">
<text class="text-11px text-[#666] block">ICCID: {{ card.iccid }}</text>
<text class="text-11px text-[#666] block">电话: {{ card.msisdn }}</text>
<text class="text-11px text-[#666] block">MSISDN: {{ card.msisdn || '-' }}</text>
<text class="text-11px text-[#666] block">运营商: {{ card.carrier_name || '-' }}</text>
<view class="flex items-center justify-between">
<text class="text-11px text-[#666]">
网络: {{ getNetworkStatusText(card.network_status) }}
</text>
<text class="text-11px text-[#666]">
实名: {{ getRealNameStatusText(card.real_name_status) }}
</text>
</view>
</view>
</view>
@@ -1101,6 +1049,11 @@
<text class="text-[#212121]">{{ formatDuration(realtimeStatus.device_realtime.run_time) }}</text>
</view>
<view class="flex items-center justify-between text-11px">
<text class="text-[#666]">最后上线时间</text>
<text class="text-[#212121]">{{ getDeviceLastOnlineTime() }}</text>
</view>
<!-- 信号 -->
<view class="flex items-center justify-between text-11px">
<text class="text-[#666]">信号</text>
@@ -1125,18 +1078,6 @@
<text class="text-[var(--brand-primary)] font-600">{{ formatBytesDisplay(realtimeStatus.device_realtime.daily_usage) }}</text>
</view>
<!-- 下载流量 -->
<view class="flex items-center justify-between text-11px">
<text class="text-[#666]">下载流量</text>
<text class="text-[#212121]">{{ formatBytesDisplay(realtimeStatus.device_realtime.dl_stats) }}</text>
</view>
<!-- 上传流量 -->
<view class="flex items-center justify-between text-11px">
<text class="text-[#666]">上传流量</text>
<text class="text-[#212121]">{{ formatBytesDisplay(realtimeStatus.device_realtime.ul_stats) }}</text>
</view>
<!-- 限速 -->
<view class="flex items-center justify-between text-11px">
<text class="text-[#666]">限速</text>
@@ -1265,8 +1206,8 @@
<text class="text-14px font-600 text-[#212121] block break-all">
{{ currentPackage.package_name || '-' }}
</text>
<text class="text-11px text-[#999] block mt-1 break-all">
订单号{{ currentPackage.order_no || '-' }}
<text class="text-11px text-[#64748b] block mt-1 break-all">
{{ formatCurrentPackagePeriodRange(currentPackage.activated_at, currentPackage.expires_at) }}
</text>
</view>
<view :style="{
@@ -1317,14 +1258,6 @@
<text class="text-[#666]">套餐类型</text>
<text class="text-[#212121]">{{ getPackageTypeText(currentPackage.package_type) }}</text>
</view>
<view class="flex items-center justify-between text-11px">
<text class="text-[#666]">开始时间</text>
<text class="text-[#212121]">{{ formatTime(currentPackage.activated_at) }}</text>
</view>
<view class="flex items-center justify-between text-11px">
<text class="text-[#666]">到期时间</text>
<text class="text-[#212121]">{{ formatTime(currentPackage.expires_at) }}</text>
</view>
<view class="flex items-center justify-between text-11px">
<text class="text-[#666]">下单时间</text>
<text class="text-[#212121]">{{ formatTime(currentPackage.created_at) }}</text>
@@ -1434,35 +1367,14 @@
<text class="text-[#666]">金额</text>
<text class="text-[#212121]">{{ formatPackagePrice(pkg.paid_amount) }}</text>
</view>
<view v-if="pkg.activated_at" class="flex items-center justify-between text-10px">
<text class="text-[#999]">{{ formatTime(pkg.activated_at, 'date') }}</text>
<text v-if="pkg.expires_at" class="text-[#999]"> {{ formatTime(pkg.expires_at, 'date') }}</text>
<view v-if="pkg.status === 1" class="mt-2 bg-white rounded-8px px-2.5 py-2">
<text class="text-11px font-600 text-[#475569]">{{ formatPackageEffectivePeriod(pkg.activated_at, pkg.expires_at) }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 时间信息 -->
<view class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] overflow-hidden mt-2 p-2 px-4 pb-4">
<view class="bg-[#f5f5f5] rounded-12px p-3">
<view class="space-y-1">
<view v-if="deviceInfo.created_at" class="flex items-center justify-between text-11px">
<text class="text-[#999]">创建时间</text>
<text class="text-[#212121]">{{ formatTime(deviceInfo.created_at) }}</text>
</view>
<view v-if="deviceInfo.last_online_time" class="flex items-center justify-between text-11px">
<text class="text-[#999]">最后在线</text>
<text class="text-[#212121]">{{ formatTime(deviceInfo.last_online_time) }}</text>
</view>
<view v-if="deviceInfo.last_gateway_sync_at" class="flex items-center justify-between text-11px">
<text class="text-[#999]">网关同步</text>
<text class="text-[#212121]">{{ formatTime(deviceInfo.last_gateway_sync_at) }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 空状态 -->

View File

@@ -8,7 +8,7 @@
import { stopAsset, startAsset } from '@/api/assets'
import Skeleton from '@/components/Skeleton.vue'
import SimplePicker from '@/components/SimplePicker.vue'
import { formatTime, formatDataSize } from '@/utils/format'
import ShopCascadePicker from '@/components/ShopCascadePicker.vue'
import { get } from '@/utils/request'
// 卡片信息接口
@@ -18,6 +18,7 @@
imsi ?: string
msisdn ?: string
virtual_no ?: string
device_virtual_no ?: string | null
virtual_number ?: string // 企业端字段
status ?: number
status_name ?: string
@@ -95,9 +96,15 @@
status : string
statusColor : string
detail : string
bindingStatus ?: string
raw : CardInfo | DeviceInfo
}
interface SelectedShopFilter {
id : number
shop_name : string
}
// 当前标签
const activeTab = ref<'card' | 'device'>('card')
// 资产列表
@@ -112,15 +119,21 @@
const deviceHasMore = ref(true)
const pageSize = 20
// 搜索和筛选
const searchType = ref<'iccid' | 'virtual'>('iccid')
const searchKeyword = ref('')
const carrierFilter = ref<number | undefined>(undefined)
const statusFilter = ref<number | undefined>(undefined)
const selectedShopFilter = ref<SelectedShopFilter | null>(null)
const showCarrierPicker = ref(false)
const showStatusPicker = ref(false)
const showSearchTypePicker = ref(false)
const showShopPicker = ref(false)
// 运营商列表
const carrierList = ref<CarrierInfo[]>([])
const isAgent = computed(() => userInfo.value?.user_type === 3)
const searchPlaceholder = computed(() => activeTab.value === 'card' ? '搜索ICCID' : '搜索设备号')
function hasStatusCode(error : unknown) {
return typeof error === 'object' && error !== null && 'statusCode' in error
}
// 运营商选择器选项
const carrierOptions = computed(() => [
{ label: '全部运营商', value: undefined },
@@ -134,11 +147,16 @@
{ label: '已激活', value: 3 },
{ label: '已停用', value: 4 }
]
// 搜索类型选项
const searchTypeOptions = [
{ label: 'ICCID', value: 'iccid' },
{ label: '虚拟号', value: 'virtual' }
]
function getCardBindingStatus(card : CardInfo) {
return card.device_virtual_no?.trim() ? '已绑定设备' : '未绑定设备'
}
function getCardBindingTagClass(bindingStatus ?: string) {
return bindingStatus === '已绑定设备'
? 'bg-[#e8f5e9] text-[#2e7d32]'
: 'bg-[#fff7e6] text-[#d46b08]'
}
// 合并后的资产列表
const assetList = computed<AssetItem[]>(() => {
@@ -150,7 +168,11 @@
status: card.network_status_name || (card.network_status === 1 ? '正常' : card.network_status === 0 ? '停机' : card.status_name || '未知'),
statusColor: (card.network_status === 1) ? '#52c41a' : (card.network_status === 0) ? '#999' : '#ff4d4f',
// 兼容企业端和代理端的字段
detail: `虚拟号: ${card.virtual_no || card.virtual_number || '-'} | 运营商: ${card.carrier_name || card.operator || '-'}`,
detail: `${[
`虚拟号: ${card.virtual_no || card.virtual_number || '-'}`,
`运营商: ${card.carrier_name || card.operator || '-'}`,
].filter(Boolean).join(' | ')}`,
bindingStatus: isAgent.value ? getCardBindingStatus(card) : undefined,
raw: card,
}))
@@ -215,12 +237,11 @@
if (statusFilter.value !== undefined) {
params.status = statusFilter.value
}
if (searchKeyword.value) {
if (searchType.value === 'iccid') {
params.iccid = searchKeyword.value
} else {
params.virtual_no = searchKeyword.value
}
if (searchKeyword.value.trim()) {
params.iccid = searchKeyword.value.trim()
}
if (userInfo.value?.user_type === 3 && selectedShopFilter.value) {
params.shop_id = selectedShopFilter.value.id
}
let cardsData : any
@@ -249,7 +270,7 @@
}
catch (error : any) {
console.error('加载IoT卡列表失败:', error)
if (error instanceof Error && error.message && !error.statusCode) {
if (error instanceof Error && error.message && !hasStatusCode(error)) {
uni.$u.toast(error.message)
}
}
@@ -276,12 +297,15 @@
page_size: pageSize,
}
if (searchKeyword.value) {
params.virtual_no = searchKeyword.value
if (searchKeyword.value.trim()) {
params.virtual_no = searchKeyword.value.trim()
}
if (statusFilter.value !== undefined) {
params.status = statusFilter.value
}
if (userInfo.value?.user_type === 3 && selectedShopFilter.value) {
params.shop_id = selectedShopFilter.value.id
}
let devicesData : any
@@ -309,7 +333,7 @@
}
catch (error : any) {
console.error('加载设备列表失败:', error)
if (error instanceof Error && error.message && !error.statusCode) {
if (error instanceof Error && error.message && !hasStatusCode(error)) {
uni.$u.toast(error.message)
}
}
@@ -339,7 +363,7 @@
// 加载运营商列表
async function loadCarriers() {
try {
const response = await getCarriers({ page: 1, page_size: 100 })
const response = await getCarriers({ page: 1, size: 100 })
carrierList.value = response?.items || []
} catch (error) {
console.error('加载运营商列表失败:', error)
@@ -358,11 +382,6 @@
refreshList()
}
// 选择搜索类型
function onSearchTypeConfirm(option : { label : string, value : any }) {
searchType.value = option.value
}
// 获取当前选中的运营商名称
const selectedCarrierName = computed(() => {
if (carrierFilter.value === undefined) return '运营商'
@@ -377,6 +396,15 @@
return option?.label || '状态'
})
const selectedShopName = computed(() => selectedShopFilter.value?.shop_name || '店铺')
const activeTabHasMore = computed(() => activeTab.value === 'card' ? cardHasMore.value : deviceHasMore.value)
function onShopConfirm(shop : SelectedShopFilter | null) {
if (!shop) return
selectedShopFilter.value = shop
refreshList()
}
// 查看资产详情
function viewAssetDetail(asset : AssetItem) {
if (asset.type === 'card') {
@@ -397,13 +425,7 @@
if (activeTab.value === tab) return
activeTab.value = tab
// 切换后加载对应数据
if (tab === 'card' && cardList.value.length === 0) {
loadCards(true)
} else if (tab === 'device' && deviceList.value.length === 0) {
loadDevices(true)
}
refreshList()
}
// 设备停机
@@ -411,8 +433,9 @@
event.stopPropagation()
const device = asset.raw as DeviceInfo
const identifier = device.virtual_no
if (!device.virtual_no) {
if (!identifier) {
uni.$u.toast('设备虚拟号不存在')
return
}
@@ -424,7 +447,7 @@
if (res.confirm) {
uni.showLoading({ title: '停机中...' })
try {
const result = await stopAsset(device.virtual_no)
await stopAsset(identifier)
uni.hideLoading()
// 显示停机结果 (统一接口不返回详情,仅提示成功)
@@ -452,8 +475,9 @@
event.stopPropagation()
const device = asset.raw as DeviceInfo
const identifier = device.virtual_no
if (!device.virtual_no) {
if (!identifier) {
uni.$u.toast('设备虚拟号不存在')
return
}
@@ -465,7 +489,7 @@
if (res.confirm) {
uni.showLoading({ title: '复机中...' })
try {
await startAsset(device.virtual_no)
await startAsset(identifier)
uni.hideLoading()
uni.$u.toast('复机成功')
// 刷新列表
@@ -546,15 +570,6 @@
})
}
// 滚动事件处理
function onScroll(e : any) {
const { scrollTop, scrollHeight, clientHeight } = e.target
// 当滚动到底部附近时加载更多
if (scrollHeight - scrollTop - clientHeight < 100) {
loadMore()
}
}
const statusBarHeight = ref(0)
onMounted(async () => {
@@ -578,14 +593,14 @@
</script>
<template>
<view class="bg-page min-h-screen flex flex-col">
<view class="bg-page h-screen flex flex-col overflow-hidden">
<!-- 搜索区域 -->
<view class="bg-white px-4 pt-4 pb-3 mb-3">
<view class="bg-white px-4 pt-4 pb-3 shrink-0 shadow-[0_4px_18px_rgba(15,23,42,0.06)] z-10">
<!-- 搜索框 -->
<view class="relative flex items-center">
<input v-model="searchKeyword"
class="flex-1 h-10 bg-[#f1f5f9] rounded-full pl-4 pr-12 text-sm text-[#1e293b] placeholder-[#94a3b8]"
:placeholder="searchType === 'iccid' ? '搜索ICCID' : '搜索虚拟号'" @confirm="refreshList" />
:placeholder="searchPlaceholder" @confirm="refreshList" />
<view class="absolute right-1 flex items-center">
<view v-if="searchKeyword" class="w-8 h-8 flex items-center justify-center text-[#94a3b8]"
@click="searchKeyword = ''">
@@ -611,14 +626,6 @@
</view>
</view>
<view class="flex items-center gap-1 px-3 py-1.5 bg-[#f8fafc] rounded-full flex-shrink-0"
@click="showSearchTypePicker = true">
<text class="text-12px" :class="searchType !== 'iccid' ? 'text-brand font-medium' : 'text-[#64748b]'">
{{ searchType === 'iccid' ? 'ICCID' : '虚拟号' }}
</text>
<i class="i-mdi-chevron-down text-12px text-[#94a3b8]" />
</view>
<view class="flex items-center gap-1.5 px-3 py-1.5 bg-[#f8fafc] rounded-full flex-shrink-0 max-w-36"
@click="showCarrierPicker = true">
<text class="text-12px truncate"
@@ -636,9 +643,19 @@
<i class="i-mdi-chevron-down text-12px text-[#94a3b8]" />
</view>
<view v-if="carrierFilter !== undefined || statusFilter !== undefined"
<view v-if="isAgent"
class="flex items-center gap-1.5 px-3 py-1.5 bg-[#f8fafc] rounded-full flex-shrink-0 max-w-36"
@click="showShopPicker = true">
<text class="text-12px truncate"
:class="selectedShopFilter ? 'text-brand font-medium' : 'text-[#64748b]'">
{{ selectedShopName }}
</text>
<i class="i-mdi-chevron-down text-12px text-[#94a3b8] flex-shrink-0" />
</view>
<view v-if="carrierFilter !== undefined || statusFilter !== undefined || selectedShopFilter"
class="flex items-center gap-1 px-2 py-1.5 rounded-full bg-[#ff4d4f]/10 text-[#ff4d4f] flex-shrink-0"
@click="carrierFilter = undefined; statusFilter = undefined; refreshList()">
@click="carrierFilter = undefined; statusFilter = undefined; selectedShopFilter = null; refreshList()">
<i class="i-mdi-close text-12px" />
<text class="text-12px font-medium">清除</text>
</view>
@@ -646,13 +663,13 @@
</view>
<!-- 列表内容区域 -->
<view class="safe-area-bottom min-h-screen overflow-y-auto">
<view class="safe-area-bottom flex-1 min-h-0 overflow-y-auto">
<!-- 加载状态 -->
<Skeleton v-if="loading && assetList.length === 0" type="list" />
<!-- 资产列表 -->
<view v-else class="flex-1 overflow-y-auto min-h-0" @scroll="onScroll">
<view v-if="assetList.length > 0" class="px-4 pb-4">
<view v-else class="min-h-full">
<view v-if="assetList.length > 0" class="px-4 pt-3 pb-4">
<view v-for="asset in assetList" :key="`${asset.type}-${asset.id}`"
class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] p-4 mb-3 cursor-pointer"
@click="viewAssetDetail(asset)">
@@ -677,6 +694,14 @@
<text class="text-12px text-[#999]">
{{ asset.detail }}
</text>
<view v-if="asset.type === 'card' && asset.bindingStatus" class="mt-2">
<text :class="[
'inline-flex px-2 py-1 rounded-full text-11px font-600',
getCardBindingTagClass(asset.bindingStatus),
]">
{{ asset.bindingStatus }}
</text>
</view>
</view>
<!-- 底部区域状态标签 + 操作按钮 -->
@@ -738,19 +763,30 @@
</view>
</view>
<!-- 加载更多提示 -->
<view v-if="loading" class="py-4 text-center">
<i class="i-mdi-loading animate-spin text-xl text-brand mr-2" />
<text class="text-sm text-[#64748b]">加载中...</text>
<!-- 加载更多 -->
<view v-if="activeTabHasMore" class="py-4 flex justify-center">
<view
class="min-w-32 px-5 py-2.5 rounded-full text-sm font-medium transition-all"
:class="loading
? 'bg-[#f1f5f9] text-[#94a3b8]'
: 'bg-brand text-white shadow-sm shadow-brand/30 active:scale-95'"
@click="!loading && loadMore()">
<template v-if="loading">
<i class="i-mdi-loading animate-spin text-base mr-1.5" />
<text>加载中...</text>
</template>
<template v-else>
<text>加载更多</text>
</template>
</view>
</view>
<view v-else-if="!((activeTab === 'card' && cardHasMore) || (activeTab === 'device' && deviceHasMore))"
class="py-4 text-center">
<view v-else class="py-4 text-center">
<text class="text-sm text-[#94a3b8]">没有更多了</text>
</view>
</view>
<!-- 空状态 -->
<view v-else class="px-4">
<view v-else class="px-4 pt-3 pb-4">
<view class="bg-white rounded-2xl py-12 flex flex-col items-center">
<i class="i-mdi-package-variant-closed text-6xl text-[#cbd5e1] mb-3" />
<text class="text-base text-[#64748b] mb-1">暂无数据</text>
@@ -758,17 +794,16 @@
</view>
</view>
</view>
<!-- 运营商选择器 -->
<SimplePicker v-model:show="showCarrierPicker" :options="carrierOptions" title="选择运营商"
@confirm="onCarrierConfirm" />
<!-- 状态选择器 -->
<SimplePicker v-model:show="showStatusPicker" :options="statusOptions" title="选择状态" @confirm="onStatusConfirm" />
<!-- 搜索类型选择器 -->
<SimplePicker v-model:show="showSearchTypePicker" :options="searchTypeOptions" title="选择搜索类型"
@confirm="onSearchTypeConfirm" />
</view>
<!-- 运营商选择器 -->
<SimplePicker v-model:show="showCarrierPicker" :options="carrierOptions" title="选择运营商"
@confirm="onCarrierConfirm" />
<!-- 状态选择器 -->
<SimplePicker v-model:show="showStatusPicker" :options="statusOptions" title="选择状态" @confirm="onStatusConfirm" />
<ShopCascadePicker v-model:show="showShopPicker" :selected-shop="selectedShopFilter" title="选择店铺"
@confirm="onShopConfirm" />
</view>
</template>

View File

@@ -5,7 +5,7 @@
import type { UserInfo } from '@/api/auth'
import { getFundSummary, getCommissionDailyStats } from '@/api/commission'
import type { FundSummary, DailyCommissionStats } from '@/api/commission'
// 用户信息
const userInfo = ref<UserInfo | null>(null)
@@ -36,6 +36,10 @@
return `¥${parts.join('.')}`
}
function formatAmountDisplay(amount?: number | null) {
return typeof amount === 'number' ? formatAmount(amount) : '--'
}
// 格式化日期 (MM-DD)
// 根据用户类型动态生成菜单
function formatDate(dateStr : string) {
@@ -104,7 +108,7 @@
// 加载佣金数据
async function loadCommissionData(shopName?: string) {
if (!isAgent.value || !shopId.value) return
if (!isAgent.value) return
loadingCommission.value = true
@@ -112,6 +116,12 @@
// 1. 加载佣金概览 (新接口 /shops/fund-summary)
const summary = await getFundSummary(shopName ? { shop_name: shopName } : undefined)
commissionSummary.value = summary
if (!shopId.value) {
dailyStats.value = []
return
}
const today = getTodayDate()
const todayStats = await getCommissionDailyStats(shopId.value, {
days: 1,
@@ -143,11 +153,8 @@
userInfo.value = user
// 设置店铺ID
if (user.shop_id) {
shopId.value = user.shop_id
}
const loginUserInfo = getLoginUserInfo()
shopId.value = user.shop_id || loginUserInfo?.shop_id || 0
const fundSummaryShopName = loginUserInfo?.username || user.username
console.log('首页数据加载成功:', { user })
@@ -244,33 +251,47 @@
</view>
</view>
<!-- 代理端: 佣金概览 -->
<view v-if="isAgent && commissionSummary" class="px-4 pt-4">
<text class="text-16px font-700 text-[#212121] block mb-3">可提现佣金</text>
<view class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] p-4 mb-3">
<view class="grid grid-cols-4 gap-3">
<view class="text-center">
<text class="text-11px text-[#999] block mb-1">累计</text>
<text class="text-14px font-600 text-[#212121]">
{{ formatAmount(commissionSummary.total_commission) }}
<!-- 代理端: 佣金展示 -->
<view v-if="isAgent" class="px-4 pt-4">
<view
class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] p-5 mb-3"
@click="navigateTo('/pages/agent-system/commission-center/index')"
>
<view class="flex items-center justify-between gap-3">
<view class="min-w-0 flex-1">
<text class="text-12px text-[#999]">可提现佣金</text>
<text class="text-32px font-700 text-[#212121] block mt-1 break-all">
{{ formatAmountDisplay(commissionSummary?.available_commission) }}
</text>
<text class="text-11px text-[#999] block mt-2">
当前可直接发起提现的佣金金额
</text>
</view>
<view class="text-center">
<text class="text-11px text-[#999] block mb-1">冻结</text>
<text class="text-14px font-600 text-[#212121]">
{{ formatAmount(commissionSummary.frozen_commission) }}
<view class="flex flex-col items-end gap-2">
<image src="@/static/icons/佣金中心.png" class="w-12 h-12 rounded-12px" mode="aspectFit" />
<view class="flex items-center gap-1">
<text class="text-12px text-[#999]">佣金中心</text>
<i class="i-mdi-chevron-right text-16px text-[#cbd5e1]" />
</view>
</view>
</view>
<view class="flex gap-3 mt-4 pt-4 border-t border-[#f5f5f5]">
<view class="flex-1 text-center">
<text class="text-11px text-[#999]">累计佣金</text>
<text class="text-14px font-600 text-[#212121] block mt-1">
{{ formatAmountDisplay(commissionSummary?.total_commission) }}
</text>
</view>
<view class="text-center">
<text class="text-11px text-[#999] block mb-1">已提现</text>
<text class="text-14px font-600 text-[#212121]">
{{ formatAmount(commissionSummary.withdrawn_commission) }}
<view class="flex-1 text-center border-l border-[#f5f5f5]">
<text class="text-11px text-[#999]">已提现</text>
<text class="text-14px font-600 text-[#52c41a] block mt-1">
{{ formatAmountDisplay(commissionSummary?.withdrawn_commission) }}
</text>
</view>
<view class="text-center">
<text class="text-11px text-[#999] block mb-1">可提现</text>
<text class="text-14px font-600 text-[#212121]">
{{ formatAmount(commissionSummary.available_commission) }}
<view class="flex-1 text-center border-l border-[#f5f5f5]">
<text class="text-11px text-[#999]">冻结</text>
<text class="text-14px font-600 text-[#ff4d4f] block mt-1">
{{ formatAmountDisplay(commissionSummary?.frozen_commission) }}
</text>
</view>
</view>
@@ -280,7 +301,7 @@
<view>
<view class="flex items-center justify-between mb-3">
<text class="text-16px font-700 text-[#212121]">今日佣金</text>
<text class="text-13px text-[#999]" @click="navigateTo('/pages/agent-system/commission-center/index')">更多</text>
<text class="text-13px text-[#999]" @click="navigateTo('/pages/agent-system/commission-center/index')">明细</text>
</view>
<view class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] p-4">
<view class="grid grid-cols-3 gap-3">