This commit is contained in:
sexygoat
2026-03-31 18:41:52 +08:00
commit 3c62cc1cd1
527 changed files with 96725 additions and 0 deletions

View File

@@ -0,0 +1,623 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { getAssetDetail, getAssetPackages, getCurrentPackage, getAssetHistory, stopCard, startCard, stopDevice, startDevice } from '@/api/assets'
import type { AssetType, CardInfo, DeviceInfo, AssetPackage, HistoryRecord } from '@/api/assets'
// 资产类型
const assetType = ref<AssetType>('card')
// 资产ID
const assetId = ref('')
// 资产详情数据
const assetDetail = ref<CardInfo | DeviceInfo | null>(null)
// 实时状态数据
const realtimeStatus = ref<any>(null)
// 套餐列表
const packageList = ref<AssetPackage[]>([])
// 历史记录列表
const historyList = ref<HistoryRecord[]>([])
// 当前套餐
const currentPackage = ref<AssetPackage | null>(null)
// 加载状态
const loading = ref(false)
const refreshing = ref(false)
const operating = ref(false)
// 当前Tab
const activeTab = ref<'info' | 'package' | 'history'>('info')
// Tab选项
const tabs = [
{ key: 'info', label: '基本信息', icon: 'i-mdi-information' },
{ key: 'package', label: '套餐信息', icon: 'i-mdi-package-variant' },
{ key: 'history', label: '操作历史', icon: 'i-mdi-history' },
]
// 状态文本
const statusText = computed(() => {
if (!realtimeStatus.value)
return '未知'
return realtimeStatus.value.status === 'active' ? '正常' : '停机'
})
// 状态颜色
const statusColor = computed(() => {
if (!realtimeStatus.value)
return { bg: '#f5f5f5', text: '#999' }
return realtimeStatus.value.status === 'active'
? { bg: '#e8f5e9', text: '#2e7d32' }
: { bg: '#ffebee', text: '#c62828' }
})
// 页面加载
onMounted(() => {
// 从路由参数获取 assetType 和 assetId
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1] as any
assetType.value = currentPage.options.type as AssetType
assetId.value = currentPage.options.id
loadAssetDetail()
})
// 加载资产详情
async function loadAssetDetail() {
loading.value = true
try {
// 调用资产详情接口
const detailData = await getAssetDetail(assetType.value, assetId.value)
// 根据类型提取详情
if (assetType.value === 'card' && detailData.card) {
assetDetail.value = detailData.card
realtimeStatus.value = {
status: detailData.card.status,
data_usage: detailData.card.data_usage,
data_total: detailData.card.data_total,
last_update: new Date().toLocaleString('zh-CN'),
}
}
else if (assetType.value === 'device' && detailData.device) {
assetDetail.value = detailData.device
realtimeStatus.value = {
status: detailData.device.status,
card_count: detailData.device.card_count,
last_online_time: detailData.device.last_online_time,
last_update: new Date().toLocaleString('zh-CN'),
}
}
// 加载套餐列表
await loadPackages()
// 加载历史记录
if (activeTab.value === 'history') {
await loadHistory()
}
}
catch (error: any) {
console.error('加载资产详情失败:', error)
// 如果是前端逻辑错误(非 API 错误),需要手动显示提示
if (error instanceof Error && error.message && !error.statusCode) {
uni.$u.toast(error.message)
}
// API 请求错误已由拦截器统一处理
}
finally {
loading.value = false
}
}
// 加载套餐列表
async function loadPackages() {
try {
// 1. 调用当前生效套餐接口
try {
const current = await getCurrentPackage(assetType.value, Number(assetId.value))
currentPackage.value = current
} catch (error) {
console.warn('获取当前套餐失败:', error)
currentPackage.value = null
}
// 2. 调用套餐列表接口
const packagesData = await getAssetPackages(assetType.value, Number(assetId.value), {
page: 1,
page_size: 50
})
packageList.value = packagesData.items || []
// 如果没有获取到当前套餐,从列表中查找生效中的套餐
if (!currentPackage.value && packageList.value.length > 0) {
currentPackage.value = packageList.value.find(pkg => pkg.status === 1) || null
}
}
catch (error: any) {
console.error('加载套餐列表失败:', error)
}
}
// 加载历史记录
async function loadHistory() {
try {
const historyData = await getAssetHistory(assetType.value, assetId.value, {
page: 1,
pageSize: 20,
})
historyList.value = historyData.list
}
catch (error: any) {
console.error('加载历史记录失败:', error)
}
}
// 刷新状态
async function refreshStatus() {
refreshing.value = true
try {
// 重新加载数据
await loadAssetDetail()
uni.showToast({
title: '刷新成功',
icon: 'success',
})
}
catch (error: any) {
// 如果是前端逻辑错误(非 API 错误),需要手动显示提示
if (error instanceof Error && error.message && !error.statusCode) {
uni.$u.toast(error.message)
}
// API 请求错误已由拦截器统一处理
}
finally {
refreshing.value = false
}
}
// 切换Tab时加载对应数据
async function handleTabChange(tab: 'info' | 'package' | 'history') {
activeTab.value = tab
if (tab === 'history' && historyList.value.length === 0) {
await loadHistory()
}
}
// 停机
async function stopAsset() {
uni.showModal({
title: '确认停机',
content: '停机后将无法使用网络服务,确定要停机吗?',
success: async (res) => {
if (res.confirm) {
operating.value = true
try {
// 调用停机接口
if (assetType.value === 'card') {
await stopCard({ id: assetId.value })
}
else {
await stopDevice({ id: assetId.value })
}
uni.showToast({
title: '停机成功',
icon: 'success',
})
await loadAssetDetail()
}
catch (error: any) {
console.error('停机失败:', error)
uni.showToast({
title: error.msg || '停机失败',
icon: 'none',
})
}
finally {
operating.value = false
}
}
},
})
}
// 复机
async function startAsset() {
uni.showModal({
title: '确认复机',
content: '复机后将恢复网络服务,确定要复机吗?',
success: async (res) => {
if (res.confirm) {
operating.value = true
try {
// 调用复机接口
if (assetType.value === 'card') {
await startCard({ id: assetId.value })
}
else {
await startDevice({ id: assetId.value })
}
uni.showToast({
title: '复机成功',
icon: 'success',
})
await loadAssetDetail()
}
catch (error: any) {
console.error('复机失败:', error)
uni.showToast({
title: error.msg || '复机失败',
icon: 'none',
})
}
finally {
operating.value = false
}
}
},
})
}
// 查看钱包 (仅代理端)
function viewWallet() {
// 跳转到钱包页面
uni.navigateTo({
url: `/pages/agent-system/asset-wallet/index?type=${assetType.value}&id=${assetId.value}`,
})
}
</script>
<template>
<view class="bg-[#fafafa] min-h-screen pb-safe">
<!-- 加载中 -->
<view v-if="loading" class="pt-20 center">
<i class="i-mdi-loading animate-spin text-40px text-[#999]" />
</view>
<!-- 内容区域 -->
<view v-else-if="assetDetail" class="px-4 pt-4">
<!-- 状态卡片 -->
<view class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] overflow-hidden mb-3">
<!-- 顶部信息 -->
<view class="px-4 pt-4 pb-3 border-b-1px border-[#f5f5f5]">
<view class="flex items-center justify-between mb-3">
<view class="flex items-center gap-2">
<i
:class="[
'text-28px',
assetType === 'card' ? 'i-mdi-sim' : 'i-mdi-devices',
'text-[#212121]',
]"
/>
<view>
<text class="text-18px font-700 text-[#212121] block">
{{ assetType === 'card' ? '物联网卡' : '物联网设备' }}
</text>
<text class="text-12px text-[#999]">
ID: {{ assetDetail.id || '-' }}
</text>
</view>
</view>
<view
:style="{
backgroundColor: statusColor.bg,
color: statusColor.text,
}"
class="px-3 py-1 rounded-8px text-13px font-600"
>
{{ statusText }}
</view>
</view>
<text class="text-13px text-[#999]">
{{ assetDetail.operator || '-' }}
</text>
</view>
<!-- 实时状态 -->
<view class="px-4 py-4">
<text class="text-14px font-600 text-[#212121] block mb-3">
实时状态
</text>
<view class="grid grid-cols-2 gap-3">
<!-- 流量使用 -->
<view class="bg-[#f5f5f5] rounded-12px p-3">
<text class="text-12px text-[#999] block mb-1">
流量使用
</text>
<text class="text-16px font-700 text-[#212121] block">
{{ realtimeStatus?.data_usage || '-' }}
</text>
<text class="text-11px text-[#999]">
/ {{ realtimeStatus?.data_total || '-' }}
</text>
</view>
<!-- 语音使用 -->
<view class="bg-[#f5f5f5] rounded-12px p-3">
<text class="text-12px text-[#999] block mb-1">
语音使用
</text>
<text class="text-16px font-700 text-[#212121] block">
{{ realtimeStatus?.voice_usage || '-' }}
</text>
<text class="text-11px text-[#999]">
/ {{ realtimeStatus?.voice_total || '-' }}
</text>
</view>
</view>
<text class="text-11px text-[#999] block mt-3">
最后更新: {{ realtimeStatus?.last_update || '-' }}
</text>
</view>
</view>
<!-- Tab切换 -->
<view class="flex items-center gap-2 mb-3">
<view
v-for="tab in tabs"
:key="tab.key"
:class="[
'flex-1 py-3 rounded-12px center flex-col gap-1',
'transition-all duration-200',
activeTab === tab.key
? 'bg-[#212121] shadow-[0_2px_12px_rgba(0,0,0,0.1)]'
: 'bg-white shadow-[0_2px_12px_rgba(0,0,0,0.06)]',
]"
@click="handleTabChange(tab.key)"
>
<i
:class="[
tab.icon,
'text-20px',
activeTab === tab.key ? 'text-white' : 'text-[#666]',
]"
/>
<text
:class="[
'text-12px font-600',
activeTab === tab.key ? 'text-white' : 'text-[#666]',
]"
>
{{ tab.label }}
</text>
</view>
</view>
<!-- 基本信息 -->
<view v-if="activeTab === 'info'" class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] p-4 space-y-3">
<view class="flex items-start justify-between">
<text class="text-13px text-[#999] w-90px">ICCID</text>
<text class="flex-1 text-14px text-[#212121] text-right break-all">
{{ assetDetail.iccid || '-' }}
</text>
</view>
<view class="flex items-start justify-between">
<text class="text-13px text-[#999] w-90px">虚拟号</text>
<text class="flex-1 text-14px text-[#212121] text-right">
{{ assetDetail.virtual_number || '-' }}
</text>
</view>
<view class="flex items-start justify-between">
<text class="text-13px text-[#999] w-90px">IMEI</text>
<text class="flex-1 text-14px text-[#212121] text-right break-all">
{{ assetDetail.imei || '-' }}
</text>
</view>
<view class="flex items-start justify-between">
<text class="text-13px text-[#999] w-90px">运营商</text>
<text class="flex-1 text-14px text-[#212121] text-right">
{{ assetDetail.operator || '-' }}
</text>
</view>
<view class="flex items-start justify-between">
<text class="text-13px text-[#999] w-90px">创建时间</text>
<text class="flex-1 text-14px text-[#212121] text-right">
{{ assetDetail.create_time || '-' }}
</text>
</view>
<view class="flex items-start justify-between">
<text class="text-13px text-[#999] w-90px">激活时间</text>
<text class="flex-1 text-14px text-[#212121] text-right">
{{ assetDetail.activate_time || '-' }}
</text>
</view>
</view>
<!-- 套餐信息 -->
<view v-if="activeTab === 'package'" class="space-y-3">
<!-- 当前套餐 -->
<view v-if="currentPackage" class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] p-4">
<view class="flex items-center justify-between mb-3">
<text class="text-14px font-600 text-[#212121]">
当前套餐
</text>
<view class="px-3 py-1 rounded-8px bg-[#e8f5e9] text-12px font-600 text-[#2e7d32]">
{{ currentPackage.status_name || '-' }}
</view>
</view>
<text class="text-16px font-700 text-[#212121] block mb-2">
{{ currentPackage.package_name || '-' }}
</text>
<text class="text-14px text-[#ff6700] font-600 block mb-3">
已用: {{ currentPackage.virtual_used_mb || 0 }}MB / 总量: {{ currentPackage.virtual_limit_mb || 0 }}MB
</text>
<view class="flex items-center justify-between text-12px text-[#999]">
<text v-if="currentPackage.activated_at && currentPackage.expires_at">
{{ currentPackage.activated_at }} ~ {{ currentPackage.expires_at }}
</text>
<text v-else>-</text>
</view>
</view>
<!-- 所有套餐列表 -->
<view v-if="packageList.length > 0" class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] p-4">
<text class="text-14px font-600 text-[#212121] block mb-3">
套餐列表
</text>
<view
v-for="pkg in packageList"
:key="pkg.package_usage_id"
class="py-3 border-t-1px border-[#f5f5f5] first:border-t-0 first:pt-0"
>
<view class="flex items-center justify-between mb-2">
<text class="text-15px font-600 text-[#212121]">
{{ pkg.package_name || '-' }}
</text>
<view
class="px-2 py-0.5 rounded text-11px"
:style="{
backgroundColor: pkg.status === 1 ? '#e8f5e9' : '#f5f5f5',
color: pkg.status === 1 ? '#2e7d32' : '#999'
}"
>
{{ pkg.status_name || '-' }}
</view>
</view>
<view class="flex items-center justify-between mb-1">
<text class="text-12px text-[#666]">
已用: {{ pkg.virtual_used_mb || 0 }}MB / {{ pkg.virtual_limit_mb || 0 }}MB
</text>
<text class="text-11px text-[#999]">
优先级: {{ pkg.priority || '-' }}
</text>
</view>
<text class="text-11px text-[#999]">
{{ pkg.activated_at || '-' }} ~ {{ pkg.expires_at || '-' }}
</text>
</view>
</view>
<!-- 暂无套餐 -->
<view v-else class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] p-4 py-12 center flex-col">
<i class="i-mdi-package-variant-closed text-60px text-[#ddd] mb-2" />
<text class="text-13px text-[#999]">
暂无套餐数据
</text>
</view>
</view>
<!-- 操作历史 -->
<view v-if="activeTab === 'history'" class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] p-4">
<text class="text-14px font-600 text-[#212121] block mb-3">
操作记录
</text>
<!-- 暂无数据 -->
<view class="py-8 center flex-col">
<i class="i-mdi-history text-60px text-[#ddd] mb-2" />
<text class="text-13px text-[#999]">
暂无操作记录
</text>
</view>
</view>
<!-- 操作按钮区 -->
<view class="mt-4 space-y-3">
<!-- 停复机按钮 -->
<view class="flex gap-3">
<view
v-if="realtimeStatus.status === 'active'"
class="
flex-1 py-3 rounded-12px center
bg-[#ffebee]
transition-all duration-200
active:opacity-80
"
@click="stopAsset"
>
<i class="i-mdi-pause-circle text-18px text-[#c62828] mr-1" />
<text class="text-15px font-600 text-[#c62828]">
停机
</text>
</view>
<view
v-else
class="
flex-1 py-3 rounded-12px center
bg-[#e8f5e9]
transition-all duration-200
active:opacity-80
"
@click="startAsset"
>
<i class="i-mdi-play-circle text-18px text-[#2e7d32] mr-1" />
<text class="text-15px font-600 text-[#2e7d32]">
复机
</text>
</view>
<view
class="
flex-1 py-3 rounded-12px center
bg-[#212121]
transition-all duration-200
active:bg-[#000]
"
@click="viewWallet"
>
<i class="i-mdi-wallet text-18px text-white mr-1" />
<text class="text-15px font-600 text-white">
查看钱包
</text>
</view>
</view>
</view>
<!-- 底部留白 -->
<view class="h-8" />
</view>
</view>
</template>
<style scoped>
.center {
display: flex;
align-items: center;
justify-content: center;
}
.space-y-3 > view:not(:last-child) {
margin-bottom: 12px;
}
.break-all {
word-break: break-all;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,252 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getAssetWallet } from '@/api/wallet'
import type { AssetWalletInfo, AssetType } from '@/api/wallet'
import { onLoad } from '@dcloudio/uni-app'
// 资产类型和ID (从URL参数获取)
const assetType = ref<AssetType>('card')
const assetId = ref<number>(0)
// 钱包信息
const walletInfo = ref<AssetWalletInfo | null>(null)
// 加载状态
const loading = ref(false)
// 格式化金额(分转元)
function formatAmount(amount: number) {
return `¥${(amount / 100).toFixed(2)}`
}
// 获取钱包状态文本
function getStatusText(status: number, statusText?: string) {
if (statusText) return statusText
const map: Record<number, string> = {
1: '正常',
2: '冻结',
3: '已停用',
}
return map[status] || '未知'
}
// 获取钱包状态颜色
function getStatusColor(status: number) {
const map: Record<number, { bg: string, text: string }> = {
1: { bg: '#e8f5e9', text: '#2e7d32' }, // 正常
2: { bg: '#fff3e0', text: '#e65100' }, // 冻结
3: { bg: '#f5f5f5', text: '#999' }, // 已停用
}
return map[status] || { bg: '#f5f5f5', text: '#999' }
}
// 格式化时间
function formatTime(time: string) {
if (!time) return '-'
const date = new Date(time)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hour = String(date.getHours()).padStart(2, '0')
const minute = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hour}:${minute}`
}
// 页面加载时获取参数
onLoad((options) => {
if (options.asset_type) {
assetType.value = options.asset_type as AssetType
}
if (options.asset_id) {
assetId.value = Number(options.asset_id)
}
})
// 页面挂载
onMounted(() => {
if (assetId.value) {
loadWalletInfo()
}
})
// 加载钱包信息
async function loadWalletInfo() {
if (!assetId.value) {
uni.$u.toast('缺少资产ID')
return
}
loading.value = true
try {
const wallet = await getAssetWallet(assetType.value, assetId.value)
walletInfo.value = wallet
}
catch (error) {
console.error('加载资产钱包信息失败:', error)
}
finally {
loading.value = false
}
}
// 返回上一页
function goBack() {
uni.navigateBack()
}
</script>
<template>
<view class="bg-[#fafafa] min-h-screen pb-safe">
<!-- 加载中 -->
<view v-if="loading" class="pt-20 center">
<i class="i-mdi-loading animate-spin text-40px text-[#999]" />
</view>
<!-- 钱包信息 -->
<view v-else-if="walletInfo" class="px-4 pt-4">
<!-- 余额卡片 -->
<view class="bg-gradient-to-r from-[#ff6700] to-[#ff8534] rounded-16px shadow-[0_4px_16px_rgba(255,103,0,0.3)] p-4 mb-3">
<view class="flex items-center justify-between mb-3">
<view>
<text class="text-13px text-white/70 block mb-1">
可用余额
</text>
<text class="text-32px font-700 text-white block">
{{ formatAmount(walletInfo.available_balance) }}
</text>
</view>
<view
:style="{
backgroundColor: getStatusColor(walletInfo.status).bg,
color: getStatusColor(walletInfo.status).text,
}"
class="px-3 py-1 rounded-8px text-12px font-600"
>
{{ getStatusText(walletInfo.status, walletInfo.status_text) }}
</view>
</view>
<view class="grid grid-cols-2 gap-3 pt-3 border-t-1px border-white/20">
<view>
<text class="text-11px text-white/70 block mb-1">总余额</text>
<text class="text-14px font-600 text-white">
{{ formatAmount(walletInfo.balance) }}
</text>
</view>
<view>
<text class="text-11px text-white/70 block mb-1">冻结金额</text>
<text class="text-14px font-600 text-white">
{{ formatAmount(walletInfo.frozen_balance) }}
</text>
</view>
</view>
</view>
<!-- 钱包详情 -->
<view class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] p-4 mb-3">
<view class="flex items-center gap-2 mb-3">
<i class="i-mdi-information-outline text-20px text-[#ff6700]" />
<text class="text-15px font-600 text-[#212121]">
钱包详情
</text>
</view>
<view class="space-y-2">
<view class="flex items-center justify-between">
<text class="text-13px text-[#666]">钱包ID</text>
<text class="text-13px text-[#212121]">
{{ walletInfo.wallet_id }}
</text>
</view>
<view class="flex items-center justify-between">
<text class="text-13px text-[#666]">资产ID</text>
<text class="text-13px text-[#212121]">
{{ walletInfo.resource_id }}
</text>
</view>
<view class="flex items-center justify-between">
<text class="text-13px text-[#666]">资产类型</text>
<text class="text-13px text-[#212121]">
{{ walletInfo.resource_type === 'iot_card' ? 'IoT卡' : '设备' }}
</text>
</view>
<view class="flex items-center justify-between">
<text class="text-13px text-[#666]">币种</text>
<text class="text-13px text-[#212121]">
{{ walletInfo.currency }}
</text>
</view>
<view class="flex items-center justify-between">
<text class="text-13px text-[#666]">创建时间</text>
<text class="text-11px text-[#999]">
{{ formatTime(walletInfo.created_at) }}
</text>
</view>
<view class="flex items-center justify-between">
<text class="text-13px text-[#666]">更新时间</text>
<text class="text-11px text-[#999]">
{{ formatTime(walletInfo.updated_at) }}
</text>
</view>
</view>
</view>
<!-- 温馨提示 -->
<view class="bg-[#fff3e0] rounded-12px p-3 mb-3">
<view class="flex items-start gap-2">
<i class="i-mdi-information text-16px text-[#e65100] mt-0.5" />
<view class="flex-1">
<text class="text-12px text-[#666] block">
此钱包为资产专属钱包,余额仅可用于该资产相关的费用支付
</text>
</view>
</view>
</view>
</view>
<!-- 空状态 -->
<view v-else class="pt-20 center flex-col">
<i class="i-mdi-wallet-outline text-60px text-[#ddd] mb-3" />
<text class="text-14px text-[#999] mb-2">
暂无钱包信息
</text>
<text class="text-12px text-[#ccc]">
请检查资产ID是否正确
</text>
</view>
<!-- 底部留白 -->
<view class="h-8" />
</view>
</template>
<style scoped>
.center {
display: flex;
align-items: center;
justify-content: center;
}
.space-y-2 > view:not(:last-child) {
margin-bottom: 8px;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>

View File

@@ -0,0 +1,841 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { getUserInfo } from '@/api/auth'
import type { UserInfo } from '@/api/auth'
import { getEnterpriseCards, getEnterpriseDevices } from '@/api/enterprise'
import { getCarriers } from '@/api/carrier'
import type { CarrierInfo } from '@/api/carrier'
import { stopDeviceAsset, startDeviceAsset, stopCardByIccid, startCardByIccid } from '@/api/assets'
import type { DeviceStopResponse } from '@/api/assets'
import Skeleton from '@/components/Skeleton.vue'
import SimplePicker from '@/components/SimplePicker.vue'
import { formatTime, formatDataSize } from '@/utils/format'
import { get } from '@/utils/request'
// 卡片信息接口
interface CardInfo {
id: number
iccid: string
imsi?: string
msisdn?: string
virtual_no?: string
virtual_number?: string // 企业端字段
status?: number
status_name?: string
activation_status?: number
network_status?: number
network_status_name?: string
real_name_status?: number
carrier_name?: string
carrier_type?: string
operator?: string // 企业端字段
supplier?: string
batch_no?: string
series_name?: string
shop_name?: string
data_usage_mb?: number
current_month_usage_mb?: number
accumulated_recharge?: number
created_at?: string
updated_at?: string
}
// 设备信息接口
interface DeviceInfo {
id?: number
device_id?: number // 企业端字段
device_name?: string
device_model?: string
device_type?: string
manufacturer?: string
imei?: string
sn?: string
virtual_no?: string
status?: number
status_name?: string
online_status?: number
bound_card_count?: number
card_count?: number // 企业端字段
max_sim_slots?: number
switch_mode?: string
series_name?: string
shop_name?: string
software_version?: string
accumulated_recharge?: number
activated_at?: string
authorized_at?: string // 企业端字段
last_online_time?: string
created_at?: string
updated_at?: string
}
// 用户信息
const userInfo = ref<UserInfo | null>(null)
// 获取单卡列表(代理端)
function getStandaloneCards(params: any) {
return get<{ items: CardInfo[], page: number, size: number, total: number }>('/api/admin/iot-cards/standalone', { params })
}
// 获取设备列表(代理端)
function getDevices(params: any) {
return get<{ items: DeviceInfo[], page: number, size: number, total: number }>('/api/admin/devices', { params })
}
// 企业ID (企业端使用)
const enterpriseId = ref<number>(0)
// 资产类型
type AssetType = 'card' | 'device'
// 统一的资产项
interface AssetItem {
id: string
type: AssetType
name: string
status: string
statusColor: string
detail: string
raw: CardInfo | DeviceInfo
}
// 当前标签
const activeTab = ref<'card' | 'device'>('card')
// 资产列表
const cardList = ref<CardInfo[]>([])
const deviceList = ref<DeviceInfo[]>([])
// 加载状态
const loading = ref(false)
// 分页
const cardPage = ref(1)
const devicePage = ref(1)
const cardHasMore = ref(true)
const deviceHasMore = ref(true)
const pageSize = 20
// 搜索和筛选
const iccidKeyword = ref('')
const virtualNoKeyword = ref('')
const carrierFilter = ref<number | undefined>(undefined)
const statusFilter = ref<number | undefined>(undefined)
const showCarrierPicker = ref(false)
const showStatusPicker = ref(false)
// 运营商列表
const carrierList = ref<CarrierInfo[]>([])
// 运营商选择器选项
const carrierOptions = computed(() => [
{ label: '全部运营商', value: undefined },
...carrierList.value.map(c => ({ label: c.carrier_name, value: c.id }))
])
// 状态选择器选项
const statusOptions = [
{ label: '全部状态', value: undefined },
{ label: '在库', value: 1 },
{ label: '已分销', value: 2 },
{ label: '已激活', value: 3 },
{ label: '已停用', value: 4 }
]
// 合并后的资产列表
const assetList = computed<AssetItem[]>(() => {
const cards: AssetItem[] = cardList.value.map(card => ({
id: String(card.id),
type: 'card' as AssetType,
name: card.iccid,
// 兼容企业端和代理端的状态字段
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 || '-'}`,
raw: card,
}))
const devices: AssetItem[] = deviceList.value.map(device => ({
id: String(device.id || device.device_id),
type: 'device' as AssetType,
name: device.device_name || '-',
// 兼容企业端和代理端的状态字段
status: device.status_name || (device.authorized_at ? '已授权' : device.online_status === 1 ? '在线' : device.online_status === 2 ? '离线' : '未知'),
statusColor: (device.authorized_at || device.online_status === 1) ? '#52c41a' : (device.online_status === 2) ? '#999' : '#ff4d4f',
// 兼容企业端和代理端的字段
detail: `虚拟号: ${device.virtual_no || '-'} | 型号: ${device.device_model || '-'} | 卡数: ${device.bound_card_count || device.card_count || 0}`,
raw: device,
}))
if (activeTab.value === 'card') return cards
if (activeTab.value === 'device') return devices
return cards
})
// 获取企业ID企业端使用
async function getEnterpriseId() {
if (enterpriseId.value) return enterpriseId.value
const cachedUserInfo = uni.getStorageSync('user_info')
if (cachedUserInfo?.enterprise_id) {
enterpriseId.value = cachedUserInfo.enterprise_id
return enterpriseId.value
}
const apiUserInfo = await getUserInfo()
if (!apiUserInfo.enterprise_id) {
throw new Error('未找到企业ID')
}
enterpriseId.value = apiUserInfo.enterprise_id
return enterpriseId.value
}
// 加载IoT卡列表兼容企业端和代理端
async function loadCards(isRefresh = false) {
if (loading.value || (!cardHasMore.value && !isRefresh)) return
loading.value = true
try {
// 如果是刷新,重置页码
if (isRefresh) {
cardPage.value = 1
cardList.value = []
cardHasMore.value = true
}
const params: any = {
page: cardPage.value,
page_size: pageSize,
}
// 添加筛选条件
if (carrierFilter.value !== undefined) {
params.carrier_id = carrierFilter.value
}
if (statusFilter.value !== undefined) {
params.status = statusFilter.value
}
if (iccidKeyword.value) {
params.iccid = iccidKeyword.value
}
if (virtualNoKeyword.value) {
params.virtual_no = virtualNoKeyword.value
}
let cardsData: any
// 根据用户类型调用不同接口
if (userInfo.value?.user_type === 3) {
// 代理端:调用代理接口
cardsData = await getStandaloneCards(params)
} else {
// 企业端:调用企业接口
const id = await getEnterpriseId()
cardsData = await getEnterpriseCards(id, params)
}
const newItems = cardsData?.items || []
if (cardPage.value === 1) {
cardList.value = newItems
} else {
cardList.value.push(...newItems)
}
// 判断是否还有更多数据
cardHasMore.value = newItems.length >= pageSize
cardPage.value++
}
catch (error: any) {
console.error('加载IoT卡列表失败:', error)
if (error instanceof Error && error.message && !error.statusCode) {
uni.$u.toast(error.message)
}
}
finally {
loading.value = false
}
}
// 加载设备列表(兼容企业端和代理端)
async function loadDevices(isRefresh = false) {
if (loading.value || (!deviceHasMore.value && !isRefresh)) return
loading.value = true
try {
// 如果是刷新,重置页码
if (isRefresh) {
devicePage.value = 1
deviceList.value = []
deviceHasMore.value = true
}
const params: any = {
page: devicePage.value,
page_size: pageSize,
}
if (virtualNoKeyword.value) {
params.virtual_no = virtualNoKeyword.value
}
if (statusFilter.value !== undefined) {
params.status = statusFilter.value
}
let devicesData: any
// 根据用户类型调用不同接口
if (userInfo.value?.user_type === 3) {
// 代理端:调用代理接口
devicesData = await getDevices(params)
} else {
// 企业端:调用企业接口
const id = await getEnterpriseId()
devicesData = await getEnterpriseDevices(id, params)
}
const newItems = devicesData?.items || []
if (devicePage.value === 1) {
deviceList.value = newItems
} else {
deviceList.value.push(...newItems)
}
// 判断是否还有更多数据
deviceHasMore.value = newItems.length >= pageSize
devicePage.value++
}
catch (error: any) {
console.error('加载设备列表失败:', error)
if (error instanceof Error && error.message && !error.statusCode) {
uni.$u.toast(error.message)
}
}
finally {
loading.value = false
}
}
// 加载更多
function loadMore() {
if (activeTab.value === 'card') {
loadCards()
} else {
loadDevices()
}
}
// 刷新列表
function refreshList() {
if (activeTab.value === 'card') {
loadCards(true)
} else {
loadDevices(true)
}
}
// 加载运营商列表
async function loadCarriers() {
try {
const response = await getCarriers({ page: 1, page_size: 100 })
carrierList.value = response?.items || []
} catch (error) {
console.error('加载运营商列表失败:', error)
}
}
// 选择运营商
function onCarrierConfirm(option: { label: string, value: any }) {
carrierFilter.value = option.value
refreshList()
}
// 选择状态
function onStatusConfirm(option: { label: string, value: any }) {
statusFilter.value = option.value
refreshList()
}
// 获取当前选中的运营商名称
const selectedCarrierName = computed(() => {
if (carrierFilter.value === undefined) return '运营商'
const option = carrierOptions.value.find(o => o.value === carrierFilter.value)
return option?.label || '运营商'
})
// 获取当前选中的状态名称
const selectedStatusName = computed(() => {
if (statusFilter.value === undefined) return '状态'
const option = statusOptions.find(o => o.value === statusFilter.value)
return option?.label || '状态'
})
// 查看资产详情
function viewAssetDetail(asset: AssetItem) {
if (asset.type === 'card') {
const card = asset.raw as CardInfo
uni.navigateTo({
url: `/pages/agent-system/asset-search/index?iccid=${card.iccid}`,
})
} else {
const device = asset.raw as DeviceInfo
uni.navigateTo({
url: `/pages/agent-system/asset-search/index?virtual_no=${device.virtual_no}`,
})
}
}
// 切换标签
function changeTab(tab: 'card' | 'device') {
if (activeTab.value === tab) return
activeTab.value = tab
iccidKeyword.value = ''
virtualNoKeyword.value = ''
carrierFilter.value = undefined
statusFilter.value = undefined
// 切换后加载对应数据
if (tab === 'card' && cardList.value.length === 0) {
loadCards(true)
} else if (tab === 'device' && deviceList.value.length === 0) {
loadDevices(true)
}
}
// 设备停机
async function handleStopDevice(asset: AssetItem, event: Event) {
event.stopPropagation()
const device = asset.raw as DeviceInfo
const deviceId = device.id || device.device_id
if (!deviceId) {
uni.$u.toast('设备ID不存在')
return
}
uni.showModal({
title: '确认停机',
content: `确定要停机设备"${device.device_name}"吗?这将停机设备下所有已实名卡。`,
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: '停机中...' })
try {
const result = await stopDeviceAsset(Number(deviceId))
uni.hideLoading()
// 显示停机结果
const msg = `停机完成\n成功: ${result.success_count}\n失败: ${result.fail_count}\n跳过: ${result.skip_count}`
uni.showModal({
title: '停机结果',
content: msg,
showCancel: false,
success: () => {
// 刷新列表
refreshList()
}
})
} catch (error: any) {
uni.hideLoading()
// 错误信息已经在拦截器中通过 toast 显示了,这里不需要重复显示
console.error('设备停机失败:', error)
}
}
}
})
}
// 设备复机
async function handleStartDevice(asset: AssetItem, event: Event) {
event.stopPropagation()
const device = asset.raw as DeviceInfo
const deviceId = device.id || device.device_id
if (!deviceId) {
uni.$u.toast('设备ID不存在')
return
}
uni.showModal({
title: '确认复机',
content: `确定要复机设备"${device.device_name}"吗?这将复机设备下所有已实名卡。`,
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: '复机中...' })
try {
await startDeviceAsset(Number(deviceId))
uni.hideLoading()
uni.$u.toast('复机成功')
// 刷新列表
refreshList()
} catch (error: any) {
uni.hideLoading()
// 错误信息已经在拦截器中通过 toast 显示了,这里不需要重复显示
console.error('设备复机失败:', error)
}
}
}
})
}
// 单卡停机
async function handleStopCard(asset: AssetItem, event: Event) {
event.stopPropagation()
const card = asset.raw as CardInfo
if (!card.iccid) {
uni.$u.toast('ICCID不存在')
return
}
uni.showModal({
title: '确认停机',
content: `确定要停机卡"${card.iccid}"吗?`,
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: '停机中...' })
try {
await stopCardByIccid(card.iccid)
uni.hideLoading()
uni.$u.toast('停机成功')
// 刷新列表
refreshList()
} catch (error: any) {
uni.hideLoading()
// 错误信息已经在拦截器中通过 toast 显示了,这里不需要重复显示
console.error('单卡停机失败:', error)
}
}
}
})
}
// 单卡复机
async function handleStartCard(asset: AssetItem, event: Event) {
event.stopPropagation()
const card = asset.raw as CardInfo
if (!card.iccid) {
uni.$u.toast('ICCID不存在')
return
}
uni.showModal({
title: '确认复机',
content: `确定要复机卡"${card.iccid}"吗?`,
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: '复机中...' })
try {
await startCardByIccid(card.iccid)
uni.hideLoading()
uni.$u.toast('复机成功')
// 刷新列表
refreshList()
} catch (error: any) {
uni.hideLoading()
// 错误信息已经在拦截器中通过 toast 显示了,这里不需要重复显示
console.error('单卡复机失败:', error)
}
}
}
})
}
// 滚动事件处理
function onScroll(e: any) {
const { scrollTop, scrollHeight, clientHeight } = e.target
// 当滚动到底部附近时加载更多
if (scrollHeight - scrollTop - clientHeight < 100) {
loadMore()
}
}
onMounted(async () => {
// 1. 加载用户信息(判断是企业端还是代理端)
try {
const user = await getUserInfo()
userInfo.value = user
} catch (error) {
console.error('获取用户信息失败:', error)
}
// 2. 加载运营商列表
loadCarriers()
// 3. 默认加载IoT卡列表
loadCards()
})
</script>
<template>
<view class="bg-page safe-area-bottom min-h-screen">
<!-- 搜索和筛选 -->
<view class="safe-area-top px-4 pt-4 pb-3">
<!-- IoT卡搜索和筛选 -->
<view v-if="activeTab === 'card'">
<!-- ICCID搜索 -->
<view class="bg-white rounded-full px-4 py-2 flex items-center mb-2">
<i class="i-mdi-credit-card text-lg text-[#94a3b8] mr-2" />
<input
v-model="iccidKeyword"
class="flex-1 text-sm text-[#1e293b]"
placeholder="ICCID"
/>
<i
v-if="iccidKeyword"
class="i-mdi-close-circle text-lg text-[#94a3b8] ml-2"
@click="iccidKeyword = ''"
/>
</view>
<!-- 虚拟号搜索 -->
<view class="bg-white rounded-full px-4 py-2 flex items-center mb-2">
<i class="i-mdi-phone text-lg text-[#94a3b8] mr-2" />
<input
v-model="virtualNoKeyword"
class="flex-1 text-sm text-[#1e293b]"
placeholder="虚拟号"
/>
<i
v-if="virtualNoKeyword"
class="i-mdi-close-circle text-lg text-[#94a3b8] ml-2"
@click="virtualNoKeyword = ''"
/>
</view>
<!-- 筛选条件 -->
<view class="flex items-center gap-2 mb-2">
<!-- 运营商 -->
<view class="flex-1 bg-white rounded-full px-3 py-2 flex items-center" @click="showCarrierPicker = true">
<text class="flex-1 text-sm text-[#1e293b]">{{ selectedCarrierName }}</text>
<i class="i-mdi-chevron-down text-lg text-[#94a3b8]" />
</view>
<!-- 状态 -->
<view class="flex-1 bg-white rounded-full px-3 py-2 flex items-center" @click="showStatusPicker = true">
<text class="flex-1 text-sm text-[#1e293b]">{{ selectedStatusName }}</text>
<i class="i-mdi-chevron-down text-lg text-[#94a3b8]" />
</view>
</view>
<!-- 搜索按钮 -->
<view class="bg-brand rounded-full py-2.5 text-center" @click="refreshList">
<text class="text-sm font-500 text-white">搜索</text>
</view>
</view>
<!-- 设备搜索 -->
<view v-else>
<view class="bg-white rounded-full px-4 py-2 flex items-center mb-2">
<i class="i-mdi-phone text-lg text-[#94a3b8] mr-2" />
<input
v-model="virtualNoKeyword"
class="flex-1 text-sm text-[#1e293b]"
placeholder="虚拟号"
/>
<i
v-if="virtualNoKeyword"
class="i-mdi-close-circle text-lg text-[#94a3b8] ml-2"
@click="virtualNoKeyword = ''"
/>
</view>
<!-- 搜索按钮 -->
<view class="bg-brand rounded-full py-2.5 text-center" @click="refreshList">
<text class="text-sm font-500 text-white">搜索</text>
</view>
</view>
</view>
<!-- 筛选标签 -->
<view class="px-4 pb-3">
<view class="bg-white rounded-full p-1 flex items-center">
<!-- IoT卡 -->
<view
class="flex-1 py-2 rounded-full transition-fast text-center"
:class="activeTab === 'card' ? 'bg-brand' : ''"
@click="changeTab('card')"
>
<text class="text-sm font-500" :class="activeTab === 'card' ? 'text-white' : 'text-[#64748b]'">IoT卡</text>
</view>
<!-- 设备 -->
<view
class="flex-1 py-2 rounded-full transition-fast text-center"
:class="activeTab === 'device' ? 'bg-brand' : ''"
@click="changeTab('device')"
>
<text class="text-sm font-500" :class="activeTab === 'device' ? 'text-white' : 'text-[#64748b]'">设备</text>
</view>
</view>
</view>
<!-- 加载状态 -->
<Skeleton v-if="loading && assetList.length === 0" type="list" />
<!-- 资产列表 -->
<view
v-else
class="overflow-y-auto"
:style="{ height: activeTab === 'card' ? 'calc(100vh - 380px)' : 'calc(100vh - 280px)' }"
@scroll="onScroll"
>
<view v-if="assetList.length > 0" class="px-4 pb-4">
<view
v-for="asset in assetList"
:key="`${asset.type}-${asset.id}`"
class="card shadow-sm mb-3 cursor-pointer transition-fast hover:shadow-md active:scale-98"
@click="viewAssetDetail(asset)"
>
<!-- 头部区域 -->
<view class="flex items-start justify-between mb-3">
<!-- 左侧信息 -->
<view class="flex items-start gap-2 flex-1 min-w-0">
<!-- 状态指示点 -->
<view
class="w-2 h-2 rounded-full mt-1 flex-shrink-0"
:style="{ backgroundColor: asset.statusColor }"
/>
<!-- 资产名称 -->
<view class="flex-1 min-w-0">
<text class="text-base font-600 text-primary block truncate">
{{ asset.name }}
</text>
<text class="text-xs text-tertiary mt-1 block">
{{ asset.detail }}
</text>
</view>
</view>
<!-- 类型徽章 -->
<view
class="px-2 py-1 radius-sm text-xs font-500 ml-2 flex-shrink-0"
:style="{
backgroundColor: asset.type === 'card' ? 'rgba(255, 103, 0, 0.1)' : 'rgba(51, 51, 51, 0.1)',
color: asset.type === 'card' ? 'var(--brand-primary)' : 'var(--brand-secondary)',
}"
>
{{ asset.type === 'card' ? 'IoT卡' : '设备' }}
</view>
</view>
<!-- 底部状态栏 -->
<view class="flex items-center justify-between pt-3 border-t border-border-secondary">
<!-- 状态标签 -->
<view class="flex items-center gap-1">
<view
class="w-1.5 h-1.5 rounded-full"
:style="{ backgroundColor: asset.statusColor }"
/>
<text class="text-sm font-500" :style="{ color: asset.statusColor }">
{{ asset.status }}
</text>
</view>
<!-- 操作按钮组 -->
<view class="flex items-center gap-2">
<!-- IoT卡操作按钮 -->
<template v-if="asset.type === 'card'">
<!-- 根据网络状态显示对应按钮 -->
<template v-if="(asset.raw as CardInfo).network_status === 0">
<!-- 已停机,只显示复机按钮 -->
<view
class="px-3 py-1 rounded-lg flex items-center justify-center"
style="background: #52c41a; color: white;"
@click="handleStartCard(asset, $event)"
>
<text class="text-xs font-500">复机</text>
</view>
</template>
<template v-else-if="(asset.raw as CardInfo).network_status === 1">
<!-- 正常运行,只显示停机按钮 -->
<view
class="px-3 py-1 rounded-lg flex items-center justify-center"
style="background: #ff4d4f; color: white;"
@click="handleStopCard(asset, $event)"
>
<text class="text-xs font-500">停机</text>
</view>
</template>
<template v-else>
<!-- 其他状态,显示两个按钮 -->
<view
class="px-3 py-1 rounded-lg flex items-center justify-center"
style="background: #ff4d4f; color: white;"
@click="handleStopCard(asset, $event)"
>
<text class="text-xs font-500">停机</text>
</view>
<view
class="px-3 py-1 rounded-lg flex items-center justify-center"
style="background: #52c41a; color: white;"
@click="handleStartCard(asset, $event)"
>
<text class="text-xs font-500">复机</text>
</view>
</template>
</template>
<!-- 设备操作按钮 - 始终显示两个按钮 -->
<template v-else>
<view
class="px-3 py-1 rounded-lg flex items-center justify-center"
style="background: #ff4d4f; color: white;"
@click="handleStopDevice(asset, $event)"
>
<text class="text-xs font-500">停机</text>
</view>
<view
class="px-3 py-1 rounded-lg flex items-center justify-center"
style="background: #52c41a; color: white;"
@click="handleStartDevice(asset, $event)"
>
<text class="text-xs font-500">复机</text>
</view>
</template>
<!-- 箭头 -->
<i class="i-mdi-chevron-right text-xl text-quaternary" />
</view>
</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>
<view v-else-if="!((activeTab === 'card' && cardHasMore) || (activeTab === 'device' && deviceHasMore))" class="py-4 text-center">
<text class="text-sm text-[#94a3b8]">没有更多了</text>
</view>
</view>
<!-- 空状态 -->
<view v-else class="px-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>
<text class="text-sm text-[#94a3b8]">{{ iccidKeyword || virtualNoKeyword ? '未找到相关结果' : '暂无资产数据' }}</text>
</view>
</view>
</view>
<!-- 运营商选择器 -->
<SimplePicker
v-model:show="showCarrierPicker"
:options="carrierOptions"
title="选择运营商"
@confirm="onCarrierConfirm"
/>
<!-- 状态选择器 -->
<SimplePicker
v-model:show="showStatusPicker"
:options="statusOptions"
title="选择状态"
@confirm="onStatusConfirm"
/>
</view>
</template>

View File

@@ -0,0 +1,438 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { changePassword } from '@/api/auth'
// 表单数据
const formData = ref({
old_password: '',
new_password: '',
confirm_password: '',
})
// 密码可见性
const passwordVisible = ref({
old: false,
new: false,
confirm: false,
})
// 提交状态
const submitting = ref(false)
// 密码强度
const passwordStrength = computed(() => {
const password = formData.value.new_password
if (!password)
return { level: 0, text: '', color: '' }
let strength = 0
// 长度检查
if (password.length >= 8)
strength++
if (password.length >= 12)
strength++
// 复杂度检查
if (/[a-z]/.test(password))
strength++
if (/[A-Z]/.test(password))
strength++
if (/\d/.test(password))
strength++
if (/[^a-zA-Z\d]/.test(password))
strength++
if (strength <= 2)
return { level: 1, text: '弱', color: '#ff6b6b' }
if (strength <= 4)
return { level: 2, text: '中', color: '#e68815' }
return { level: 3, text: '强', color: '#3ed268' }
})
// 表单验证
const isFormValid = computed(() => {
const { old_password, new_password, confirm_password } = formData.value
return (
old_password.length > 0
&& new_password.length >= 6
&& new_password === confirm_password
)
})
// 切换密码可见性
function togglePasswordVisible(field: 'old' | 'new' | 'confirm') {
passwordVisible.value[field] = !passwordVisible.value[field]
}
// 提交修改
async function submitChange() {
// 验证表单
if (!formData.value.old_password) {
uni.showToast({
title: '请输入当前密码',
icon: 'none',
})
return
}
if (formData.value.new_password.length < 6) {
uni.showToast({
title: '新密码至少6位',
icon: 'none',
})
return
}
if (formData.value.new_password !== formData.value.confirm_password) {
uni.showToast({
title: '两次密码输入不一致',
icon: 'none',
})
return
}
if (formData.value.old_password === formData.value.new_password) {
uni.showToast({
title: '新密码不能与当前密码相同',
icon: 'none',
})
return
}
submitting.value = true
try {
// 调用修改密码接口
await changePassword({
old_password: formData.value.old_password,
new_password: formData.value.new_password,
})
uni.showToast({
title: '密码修改成功',
icon: 'success',
duration: 2000,
})
// 清除登录状态
uni.removeStorageSync('admin-token')
uni.removeStorageSync('refresh_token')
uni.removeStorageSync('user_info')
// 延迟跳转到登录页
setTimeout(() => {
uni.reLaunch({
url: '/pages/common/login/index',
})
}, 2000)
}
catch (error: any) {
console.error('修改密码失败:', error)
// 如果是前端逻辑错误(非 API 错误),需要手动显示提示
if (error instanceof Error && error.message && !error.statusCode) {
uni.$u.toast(error.message)
}
// API 请求错误已由拦截器统一处理
}
finally {
submitting.value = false
}
}
// 清空表单
function clearForm() {
formData.value = {
old_password: '',
new_password: '',
confirm_password: '',
}
}
</script>
<template>
<view class="bg-[#fafafa] min-h-screen pb-safe">
<!-- 内容区域 -->
<view class="px-4 pt-5">
<!-- 提示信息 -->
<view class="bg-[#e3f2fd] rounded-12px p-4 mb-4 flex items-start gap-3">
<i class="i-mdi-information text-20px text-[#1976d2] mt-0.5" />
<view class="flex-1">
<text class="text-13px text-[#1976d2] block mb-1 font-600">
密码安全提示
</text>
<text class="text-12px text-[#1976d2] leading-relaxed">
为了您的账号安全,建议密码长度至少8位,包含大小写字母数字和特殊字符
</text>
</view>
</view>
<!-- 表单卡片 -->
<view class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] p-4 mb-4">
<!-- 当前密码 -->
<view class="mb-4">
<text class="text-13px text-[#999] block mb-2">
当前密码 <text class="text-[#ff6b6b]">*</text>
</text>
<view class="flex items-center gap-2 px-4 py-3 rounded-12px bg-[#f5f5f5]">
<i class="i-mdi-lock text-20px text-[#999]" />
<input
v-model="formData.old_password"
:password="!passwordVisible.old"
class="flex-1 text-15px text-[#212121] placeholder:text-[#ccc]"
placeholder="请输入当前密码"
>
<i
:class="[
'text-20px text-[#999]',
passwordVisible.old ? 'i-mdi-eye-off' : 'i-mdi-eye',
]"
@click="togglePasswordVisible('old')"
/>
</view>
</view>
<!-- 新密码 -->
<view class="mb-4">
<view class="flex items-center justify-between mb-2">
<text class="text-13px text-[#999]">
新密码 <text class="text-[#ff6b6b]">*</text>
</text>
<view
v-if="formData.new_password"
class="flex items-center gap-1"
>
<text class="text-11px text-[#999]">
强度:
</text>
<text
:style="{ color: passwordStrength.color }"
class="text-11px font-600"
>
{{ passwordStrength.text }}
</text>
</view>
</view>
<view class="flex items-center gap-2 px-4 py-3 rounded-12px bg-[#f5f5f5]">
<i class="i-mdi-lock-outline text-20px text-[#999]" />
<input
v-model="formData.new_password"
:password="!passwordVisible.new"
class="flex-1 text-15px text-[#212121] placeholder:text-[#ccc]"
placeholder="请输入新密码(至少6位)"
>
<i
:class="[
'text-20px text-[#999]',
passwordVisible.new ? 'i-mdi-eye-off' : 'i-mdi-eye',
]"
@click="togglePasswordVisible('new')"
/>
</view>
<!-- 密码强度指示器 -->
<view
v-if="formData.new_password"
class="flex items-center gap-1 mt-2"
>
<view
v-for="i in 3"
:key="i"
:class="[
'flex-1 h-2px rounded-full',
i <= passwordStrength.level ? 'opacity-100' : 'opacity-20',
]"
:style="{ backgroundColor: passwordStrength.color }"
/>
</view>
</view>
<!-- 确认密码 -->
<view>
<text class="text-13px text-[#999] block mb-2">
确认密码 <text class="text-[#ff6b6b]">*</text>
</text>
<view class="flex items-center gap-2 px-4 py-3 rounded-12px bg-[#f5f5f5]">
<i class="i-mdi-lock-check text-20px text-[#999]" />
<input
v-model="formData.confirm_password"
:password="!passwordVisible.confirm"
class="flex-1 text-15px text-[#212121] placeholder:text-[#ccc]"
placeholder="请再次输入新密码"
>
<i
:class="[
'text-20px text-[#999]',
passwordVisible.confirm ? 'i-mdi-eye-off' : 'i-mdi-eye',
]"
@click="togglePasswordVisible('confirm')"
/>
</view>
<!-- 密码匹配提示 -->
<view
v-if="formData.confirm_password"
class="flex items-center gap-1 mt-2"
>
<i
:class="[
'text-14px',
formData.new_password === formData.confirm_password
? 'i-mdi-check-circle text-[#3ed268]'
: 'i-mdi-close-circle text-[#ff6b6b]',
]"
/>
<text
:class="[
'text-12px',
formData.new_password === formData.confirm_password
? 'text-[#3ed268]'
: 'text-[#ff6b6b]',
]"
>
{{ formData.new_password === formData.confirm_password ? '密码一致' : '密码不一致' }}
</text>
</view>
</view>
</view>
<!-- 密码要求说明 -->
<view class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] p-4 mb-4">
<text class="text-14px font-600 text-[#212121] block mb-3">
密码要求
</text>
<view class="space-y-2">
<view class="flex items-start gap-2">
<i
:class="[
'text-14px mt-0.5',
formData.new_password.length >= 6
? 'i-mdi-check-circle text-[#3ed268]'
: 'i-mdi-checkbox-blank-circle-outline text-[#ccc]',
]"
/>
<text class="flex-1 text-13px text-[#666]">
至少6个字符(建议8个以上)
</text>
</view>
<view class="flex items-start gap-2">
<i
:class="[
'text-14px mt-0.5',
/[a-zA-Z]/.test(formData.new_password)
? 'i-mdi-check-circle text-[#3ed268]'
: 'i-mdi-checkbox-blank-circle-outline text-[#ccc]',
]"
/>
<text class="flex-1 text-13px text-[#666]">
包含字母
</text>
</view>
<view class="flex items-start gap-2">
<i
:class="[
'text-14px mt-0.5',
/\d/.test(formData.new_password)
? 'i-mdi-check-circle text-[#3ed268]'
: 'i-mdi-checkbox-blank-circle-outline text-[#ccc]',
]"
/>
<text class="flex-1 text-13px text-[#666]">
包含数字
</text>
</view>
<view class="flex items-start gap-2">
<i
:class="[
'text-14px mt-0.5',
/[^a-zA-Z\d]/.test(formData.new_password)
? 'i-mdi-check-circle text-[#3ed268]'
: 'i-mdi-checkbox-blank-circle-outline text-[#ccc]',
]"
/>
<text class="flex-1 text-13px text-[#666]">
包含特殊字符(推荐)
</text>
</view>
</view>
</view>
<!-- 操作按钮 -->
<view class="space-y-3">
<!-- 确认修改 -->
<view
:class="[
'py-3 rounded-12px center',
'transition-all duration-200',
isFormValid && !submitting
? 'bg-[#212121] active:bg-[#000]'
: 'bg-[#ccc]',
]"
@click="submitChange"
>
<i
v-if="submitting"
class="i-mdi-loading animate-spin text-18px text-white mr-2"
/>
<text class="text-15px font-600 text-white">
{{ submitting ? '修改中...' : '确认修改' }}
</text>
</view>
<!-- 清空表单 -->
<view
class="
py-3 rounded-12px center
bg-white shadow-[0_2px_12px_rgba(0,0,0,0.06)]
transition-all duration-200
active:bg-[#f5f5f5]
"
@click="clearForm"
>
<i class="i-mdi-refresh text-18px text-[#666] mr-2" />
<text class="text-15px font-600 text-[#666]">
清空
</text>
</view>
</view>
<!-- 底部留白 -->
<view class="h-8" />
</view>
</view>
</template>
<style scoped>
.center {
display: flex;
align-items: center;
justify-content: center;
}
.space-y-3 > view:not(:last-child) {
margin-bottom: 12px;
}
.space-y-2 > view:not(:last-child) {
margin-bottom: 8px;
}
.leading-relaxed {
line-height: 1.6;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>

View File

@@ -0,0 +1,460 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { getCommissionSummary, getCommissionRecords, getCommissionStats } from '@/api/commission'
import type { CommissionSummary, CommissionRecord, CommissionStats } from '@/api/commission'
import SimplePicker from '@/components/SimplePicker.vue'
// 佣金概览
const commissionSummary = ref<CommissionSummary | null>(null)
// 佣金明细列表
const commissionRecords = ref<CommissionRecord[]>([])
// 佣金统计
const commissionStats = ref<CommissionStats | null>(null)
// 加载状态
const loading = ref(false)
const loadingRecords = ref(false)
// 分页参数
const page = ref(1)
const pageSize = 20
const total = ref(0)
// 搜索关键词
const searchKeyword = ref('')
// 佣金来源筛选
const sourceFilter = ref<string | undefined>(undefined)
const showSourcePicker = ref(false)
// 佣金来源选项
const sourceOptions = [
{ label: '全部来源', value: undefined },
{ label: '成本价差', value: 'cost_diff' },
{ label: '一次性佣金', value: 'one_time' },
]
// 获取当前选中的来源名称
const selectedSourceName = computed(() => {
if (sourceFilter.value === undefined) return '佣金来源'
const option = sourceOptions.find(o => o.value === sourceFilter.value)
return option?.label || '佣金来源'
})
// 格式化金额(分转元,带千分号)
function formatAmount(amount: number) {
const yuan = (amount / 100).toFixed(2)
const parts = yuan.split('.')
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',')
return `¥${parts.join('.')}`
}
// 获取佣金来源文本
function getSourceText(source: string) {
const map: Record<string, string> = {
cost_diff: '成本价差',
one_time: '一次性佣金',
tier_bonus: '梯度奖励',
}
return map[source] || source
}
// 获取佣金状态文本
function getStatusText(status: number) {
const map: Record<number, string> = {
1: '已入账',
2: '已失效',
}
return map[status] || '未知'
}
// 获取佣金状态颜色
function getStatusColor(status: number) {
const map: Record<number, { bg: string, text: string }> = {
1: { bg: '#e8f5e9', text: '#2e7d32' }, // 已入账
2: { bg: '#f5f5f5', text: '#999' }, // 已失效
}
return map[status] || { bg: '#f5f5f5', text: '#999' }
}
// 格式化时间
function formatTime(time: string) {
if (!time) return '-'
const date = new Date(time)
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hour = String(date.getHours()).padStart(2, '0')
const minute = String(date.getMinutes()).padStart(2, '0')
return `${month}-${day} ${hour}:${minute}`
}
// 页面加载
onMounted(() => {
loadCommissionSummary()
loadCommissionStats()
loadCommissionRecords()
})
// 加载佣金概览
async function loadCommissionSummary() {
try {
const summary = await getCommissionSummary()
commissionSummary.value = summary
}
catch (error) {
console.error('加载佣金概览失败:', error)
}
}
// 加载佣金统计
async function loadCommissionStats() {
try {
const stats = await getCommissionStats()
commissionStats.value = stats
}
catch (error) {
console.error('加载佣金统计失败:', error)
}
}
// 加载佣金明细
async function loadCommissionRecords() {
loadingRecords.value = true
try {
const params: any = {
page: page.value,
page_size: pageSize,
}
// 佣金来源筛选
if (sourceFilter.value) {
params.commission_source = sourceFilter.value
}
// 搜索关键词(支持 ICCID、虚拟号、订单号
if (searchKeyword.value) {
const keyword = searchKeyword.value.trim()
// 根据关键词类型判断搜索字段
if (/^\d{19,20}$/.test(keyword)) {
// ICCID 通常是19-20位数字
params.iccid = keyword
}
else if (/^\d{15}$/.test(keyword)) {
// 虚拟号通常是15位数字
params.virtual_no = keyword
}
else {
// 订单号
params.order_no = keyword
}
}
const response = await getCommissionRecords(params)
if (page.value === 1) {
commissionRecords.value = response.items || []
}
else {
commissionRecords.value.push(...(response.items || []))
}
total.value = response.total || 0
}
catch (error) {
console.error('加载佣金明细失败:', error)
}
finally {
loadingRecords.value = false
}
}
// 搜索
function handleSearch() {
page.value = 1
commissionRecords.value = []
loadCommissionRecords()
}
// 清空搜索
function clearSearch() {
searchKeyword.value = ''
page.value = 1
commissionRecords.value = []
loadCommissionRecords()
}
// 来源筛选变化
function onSourceConfirm(option: { label: string, value: any }) {
sourceFilter.value = option.value
page.value = 1
commissionRecords.value = []
loadCommissionRecords()
}
// 加载更多
function loadMore() {
if (commissionRecords.value.length >= total.value) {
return
}
page.value++
loadCommissionRecords()
}
// 刷新
function refreshList() {
page.value = 1
commissionRecords.value = []
loadCommissionSummary()
loadCommissionStats()
loadCommissionRecords()
}
</script>
<template>
<view class="bg-[#fafafa] min-h-screen pb-safe">
<!-- 加载中 -->
<view v-if="loading" class="pt-20 center">
<i class="i-mdi-loading animate-spin text-40px text-[#999]" />
</view>
<!-- 内容区域 -->
<view v-else class="px-4 pt-4">
<!-- 佣金概览卡片 -->
<view v-if="commissionSummary" class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] p-4 mb-3">
<view class="flex items-center justify-between mb-3">
<view class="flex items-center gap-2">
<i class="i-mdi-currency-cny text-24px text-[#ff6700]" />
<text class="text-15px font-600 text-[#212121]">
佣金概览
</text>
</view>
</view>
<view class="grid grid-cols-2 gap-3">
<view class="text-center p-3 bg-[#f5f5f5] rounded-12px">
<text class="text-11px text-[#999] block mb-1">可提现</text>
<text class="text-18px font-700 text-[#ff6700]">
{{ formatAmount(commissionSummary.available_commission) }}
</text>
</view>
<view class="text-center p-3 bg-[#f5f5f5] rounded-12px">
<text class="text-11px text-[#999] block mb-1">累计佣金</text>
<text class="text-18px font-700 text-[#212121]">
{{ formatAmount(commissionSummary.total_commission) }}
</text>
</view>
<view class="text-center p-3 bg-[#f5f5f5] rounded-12px">
<text class="text-11px text-[#999] block mb-1">冻结</text>
<text class="text-18px font-700 text-[#999]">
{{ formatAmount(commissionSummary.frozen_commission) }}
</text>
</view>
<view class="text-center p-3 bg-[#f5f5f5] rounded-12px">
<text class="text-11px text-[#999] block mb-1">已提现</text>
<text class="text-18px font-700 text-[#52c41a]">
{{ formatAmount(commissionSummary.withdrawn_commission) }}
</text>
</view>
</view>
</view>
<!-- 佣金统计卡片 -->
<view v-if="commissionStats" class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] p-4 mb-3">
<view class="flex items-center gap-2 mb-3">
<i class="i-mdi-chart-pie text-20px text-[#ff6700]" />
<text class="text-15px font-600 text-[#212121]">
佣金统计
</text>
</view>
<view class="space-y-2">
<view class="flex items-center justify-between">
<text class="text-13px text-[#666]">成本价差</text>
<view class="flex items-center gap-2">
<text class="text-14px font-600 text-[#212121]">
{{ formatAmount(commissionStats.cost_diff_amount) }}
</text>
<text class="text-11px text-[#999]">
{{ (commissionStats.cost_diff_percent / 10).toFixed(1) }}%
</text>
</view>
</view>
<view class="flex items-center justify-between">
<text class="text-13px text-[#666]">一次性佣金</text>
<view class="flex items-center gap-2">
<text class="text-14px font-600 text-[#212121]">
{{ formatAmount(commissionStats.one_time_amount) }}
</text>
<text class="text-11px text-[#999]">
{{ (commissionStats.one_time_percent / 10).toFixed(1) }}%
</text>
</view>
</view>
<view class="pt-2 border-t-1px border-[#f5f5f5]">
<view class="flex items-center justify-between">
<text class="text-13px font-600 text-[#212121]">总计</text>
<text class="text-16px font-700 text-[#ff6700]">
{{ formatAmount(commissionStats.total_amount) }}
</text>
</view>
<text class="text-11px text-[#999] mt-1 block text-right">
{{ commissionStats.total_count }}
</text>
</view>
</view>
</view>
<!-- 搜索框 -->
<view class="mb-3">
<view class="bg-white rounded-12px shadow-[0_2px_12px_rgba(0,0,0,0.06)] overflow-hidden">
<view class="flex items-center px-4 py-3 gap-2">
<i class="i-mdi-magnify text-20px text-[#999]" />
<input
v-model="searchKeyword"
class="flex-1 text-14px text-[#212121] placeholder:text-[#999]"
placeholder="搜索 ICCID / 虚拟号 / 订单号"
@confirm="handleSearch"
>
<view
v-if="searchKeyword"
class="ml-1 min-w-32px min-h-32px flex items-center justify-center"
@click="clearSearch"
>
<i class="i-mdi-close-circle text-18px text-[#999]" />
</view>
<view
class="px-4 py-2 bg-[#ff6700] text-white text-13px font-600 rounded-8px min-h-32px flex items-center justify-center"
@click="handleSearch"
>
搜索
</view>
</view>
</view>
</view>
<!-- 筛选条件 -->
<view class="flex items-center gap-2 mb-3">
<!-- 佣金来源筛选 -->
<view class="flex-1 bg-white rounded-full px-4 py-2 flex items-center shadow-[0_2px_12px_rgba(0,0,0,0.06)]" @click="showSourcePicker = true">
<text class="flex-1 text-14px text-[#212121]">{{ selectedSourceName }}</text>
<i class="i-mdi-chevron-down text-18px text-[#999]" />
</view>
</view>
<!-- 佣金明细列表 -->
<view class="mb-3">
<view class="flex items-center justify-between mb-2 px-2">
<text class="text-14px font-600 text-[#212121]">
佣金明细
</text>
<text class="text-12px text-[#999]">
{{ total }} 条记录
</text>
</view>
<view v-if="loadingRecords && page === 1" class="pt-10 center">
<i class="i-mdi-loading animate-spin text-40px text-[#999]" />
</view>
<view v-else-if="commissionRecords.length > 0" class="space-y-2">
<view
v-for="record in commissionRecords"
:key="record.id"
class="bg-white rounded-12px shadow-[0_2px_12px_rgba(0,0,0,0.06)] p-4"
>
<view class="flex items-center justify-between mb-2">
<view class="flex items-center gap-2">
<i class="i-mdi-cash-plus text-20px text-[#ff6700]" />
<text class="text-14px font-600 text-[#212121]">
{{ getSourceText(record.commission_source) }}
</text>
</view>
<view
:style="{
backgroundColor: getStatusColor(record.status).bg,
color: getStatusColor(record.status).text,
}"
class="px-2 py-1 rounded-6px text-11px font-600"
>
{{ getStatusText(record.status) }}
</view>
</view>
<view class="flex items-center justify-between">
<text class="text-12px text-[#999]">
{{ formatTime(record.created_at) }}
</text>
<text class="text-18px font-700 text-[#ff6700]">
+{{ formatAmount(record.amount) }}
</text>
</view>
<view v-if="record.order_id" class="mt-2 pt-2 border-t-1px border-[#f5f5f5]">
<text class="text-11px text-[#999]">
订单ID: {{ record.order_id }}
</text>
</view>
</view>
<!-- 加载更多 -->
<view v-if="commissionRecords.length < total" class="py-3 center">
<text v-if="loadingRecords" class="text-12px text-[#999]">
加载中...
</text>
<text v-else class="text-12px text-[#ff6700]" @click="loadMore">
加载更多
</text>
</view>
</view>
<view v-else class="pt-10 center flex-col">
<i class="i-mdi-cash-remove text-60px text-[#ddd] mb-3" />
<text class="text-14px text-[#999]">
暂无佣金记录
</text>
</view>
</view>
<!-- 底部留白 -->
<view class="h-8" />
</view>
<!-- 佣金来源选择器 -->
<SimplePicker
v-model:show="showSourcePicker"
:options="sourceOptions"
title="选择佣金来源"
@confirm="onSourceConfirm"
/>
</view>
</template>
<style scoped>
.center {
display: flex;
align-items: center;
justify-content: center;
}
.space-y-2 > view:not(:last-child) {
margin-bottom: 8px;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import { onMounted } from 'vue'
// 直接跳转到佣金中心
onMounted(() => {
uni.redirectTo({
url: '/pages/agent-system/commission-center/index',
})
})
</script>
<template>
<view class="bg-[#fafafa] min-h-screen flex items-center justify-center">
<text class="text-14px text-[#999]">跳转中...</text>
</view>
</template>

View File

@@ -0,0 +1,536 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { getUserInfo } from '@/api/auth'
import { getEnterpriseCards } from '@/api/enterprise'
import type { CardInfo } from '@/api/enterprise'
import { getCarriers } from '@/api/carrier'
import type { CarrierInfo } from '@/api/carrier'
import SimplePicker from '@/components/SimplePicker.vue'
// 企业ID (用于调用企业接口)
const enterpriseId = ref<number>(0)
// 卡片列表
const cardList = ref<CardInfo[]>([])
// 加载状态
const loading = ref(false)
const loadingMore = ref(false)
// 搜索关键词
const searchKeyword = ref('')
// 状态筛选 (undefined 表示全部)
const statusFilter = ref<number | undefined>(undefined)
// 运营商筛选
const carrierFilter = ref<number | undefined>(undefined)
const showCarrierPicker = ref(false)
// 运营商列表
const carrierList = ref<CarrierInfo[]>([])
// 网络状态选项 (network_status: 0=停机, 1=正常)
const statusOptions = [
{ label: '全部', value: undefined },
{ label: '正常', value: 1 },
{ label: '停机', value: 0 },
]
// 运营商选项
const carrierOptions = computed(() => [
{ label: '全部运营商', value: undefined },
...carrierList.value.map(c => ({ label: c.carrier_name, value: c.id }))
])
// 获取当前选中的运营商名称
const selectedCarrierName = computed(() => {
if (carrierFilter.value === undefined) return '运营商'
const option = carrierOptions.value.find(o => o.value === carrierFilter.value)
return option?.label || '运营商'
})
// 分页参数
const page = ref(1)
const pageSize = 20
const hasMore = ref(true)
// 直接显示卡片列表 (搜索由后端处理)
const filteredCards = computed(() => {
return cardList.value
})
// 统计数据
const stats = computed(() => {
const total = cardList.value.length
// network_status: 0=停机, 1=正常
const active = cardList.value.filter(c => c.network_status === 1).length
const inactive = cardList.value.filter(c => c.network_status === 0).length
// status: 1=可用, 2=已分销, 3=已停用等
const distributed = cardList.value.filter(c => c.status === 2).length
return { total, active, inactive, distributed }
})
// 加载运营商列表
async function loadCarriers() {
try {
const response = await getCarriers({ page: 1, size: 20 })
carrierList.value = response?.items || []
} catch (error) {
console.error('加载运营商列表失败:', error)
}
}
// 页面加载
onMounted(() => {
loadCarriers()
loadEnterpriseCards()
})
// 加载企业卡列表
async function loadEnterpriseCards() {
loading.value = true
try {
// 1. 获取企业ID (优先从本地存储获取)
if (!enterpriseId.value) {
const userInfo = uni.getStorageSync('user_info')
if (userInfo?.enterprise_id) {
enterpriseId.value = userInfo.enterprise_id
} else {
// 如果本地存储没有,则调用接口获取
const apiUserInfo = await getUserInfo()
if (!apiUserInfo.enterprise_id) {
throw new Error('未找到企业ID')
}
enterpriseId.value = apiUserInfo.enterprise_id
}
}
// 2. 获取企业卡片列表
const params: any = {
page: page.value,
page_size: pageSize,
}
// 只有选择了具体状态才传 status 参数
if (statusFilter.value !== undefined) {
params.status = statusFilter.value
}
// 运营商筛选
if (carrierFilter.value !== undefined) {
params.carrier_id = carrierFilter.value
}
// 添加搜索关键词支持 (根据接口文档)
if (searchKeyword.value) {
// 如果关键词看起来像ICCID (纯数字)
if (/^\d+$/.test(searchKeyword.value)) {
params.iccid = searchKeyword.value
} else {
params.virtual_no = searchKeyword.value
}
}
const cardsData = await getEnterpriseCards(enterpriseId.value, params)
if (page.value === 1) {
cardList.value = cardsData?.items || []
}
else {
cardList.value.push(...(cardsData?.items || []))
}
// 判断是否还有更多数据
const items = cardsData?.items || []
hasMore.value = items.length >= pageSize
}
catch (error: any) {
console.error('加载企业卡列表失败:', error)
// 如果是前端逻辑错误(非 API 错误),需要手动显示提示
if (error instanceof Error && error.message && !error.statusCode) {
uni.$u.toast(error.message)
}
// API 请求错误已由拦截器统一处理
}
finally {
loading.value = false
}
}
// 查看卡片详情
function viewCardDetail(card: any) {
// 跳转到资产详情页
uni.navigateTo({
url: `/pages/agent-system/asset-search/index?iccid=${card.iccid}`,
})
}
// 获取网络状态文本 (使用后端返回的 network_status_name)
function getNetworkStatusText(networkStatus: number, networkStatusName?: string) {
// 优先使用后端返回的状态名称
if (networkStatusName) {
return networkStatusName
}
// 降级处理
const map: Record<number, string> = {
0: '停机',
1: '正常',
}
return map[networkStatus] ?? '未知'
}
// 获取网络状态颜色
function getNetworkStatusColor(networkStatus: number) {
const map: Record<number, any> = {
1: { bg: '#e8f5e9', text: '#2e7d32' }, // 正常
0: { bg: '#ffebee', text: '#c62828' }, // 停机
}
return map[networkStatus] ?? { bg: '#f5f5f5', text: '#999' }
}
// 获取卡状态文本 (使用后端返回的 status_name)
function getCardStatusText(status: number, statusName?: string) {
// 优先使用后端返回的状态名称
if (statusName) {
return statusName
}
// 降级处理
const map: Record<number, string> = {
1: '可用',
2: '已分销',
3: '已停用',
}
return map[status] ?? '未知'
}
// 获取运营商图标
function getCarrierIcon(carrierName: string) {
const map: Record<string, string> = {
'中国移动': 'i-mdi-cellphone',
'中国联通': 'i-mdi-signal-variant',
'中国电信': 'i-mdi-wifi',
}
return map[carrierName] || 'i-mdi-sim'
}
// 清空搜索
function clearSearch() {
searchKeyword.value = ''
page.value = 1
hasMore.value = true
cardList.value = []
loadEnterpriseCards()
}
// 执行搜索
function handleSearch() {
page.value = 1
hasMore.value = true
cardList.value = []
loadEnterpriseCards()
}
// 刷新列表
function refreshList() {
page.value = 1
hasMore.value = true
cardList.value = []
loadEnterpriseCards()
}
// 状态筛选变化
function handleStatusChange(status: number | undefined) {
statusFilter.value = status
page.value = 1
hasMore.value = true
cardList.value = []
loadEnterpriseCards()
}
// 运营商筛选变化 (SimplePicker确认时触发)
function onCarrierConfirm(option: { label: string, value: any }) {
carrierFilter.value = option.value
page.value = 1
hasMore.value = true
cardList.value = []
loadEnterpriseCards()
}
</script>
<template>
<view class="bg-[#fafafa] min-h-screen pb-safe">
<!-- 加载中 -->
<view v-if="loading" class="pt-20 center">
<i class="i-mdi-loading animate-spin text-40px text-[#999]" />
</view>
<!-- 内容区域 -->
<view v-else class="px-4 pt-4">
<!-- 统计卡片 -->
<view class="bg-white rounded-12px shadow-[0_2px_12px_rgba(0,0,0,0.06)] p-4 mb-3">
<view class="flex items-center justify-between">
<view class="flex items-center gap-2">
<i class="i-mdi-sim text-24px text-[#ff6700]" />
<text class="text-15px font-600 text-[#212121]">
企业授权IoT卡
</text>
</view>
<view class="text-right">
<text class="text-12px text-[#999] block">
总计
</text>
<text class="text-24px font-700 text-[#ff6700]">
{{ stats.total }}
</text>
</view>
</view>
</view>
<!-- 搜索框 -->
<view class="mb-3">
<view class="bg-white rounded-12px shadow-[0_2px_12px_rgba(0,0,0,0.06)] overflow-hidden">
<view class="flex items-center px-4 py-3 gap-2">
<i class="i-mdi-magnify text-20px text-[#999]" />
<input
v-model="searchKeyword"
class="flex-1 text-14px text-[#212121] placeholder:text-[#999]"
placeholder="搜索 ICCID / 虚拟号 / 手机号"
@confirm="handleSearch"
>
<view
v-if="searchKeyword"
class="ml-1 min-w-32px min-h-32px flex items-center justify-center"
@click="clearSearch"
>
<i class="i-mdi-close-circle text-18px text-[#999]" />
</view>
<view
class="px-4 py-2 bg-[#ff6700] text-white text-13px font-600 rounded-8px min-h-32px flex items-center justify-center"
@click="handleSearch"
>
搜索
</view>
</view>
</view>
</view>
<!-- 筛选条件 -->
<view class="flex items-center gap-2 mb-3">
<!-- 运营商筛选 -->
<view class="flex-1 bg-white rounded-full px-4 py-2 flex items-center shadow-[0_2px_12px_rgba(0,0,0,0.06)]" @click="showCarrierPicker = true">
<text class="flex-1 text-14px text-[#212121]">{{ selectedCarrierName }}</text>
<i class="i-mdi-chevron-down text-18px text-[#999]" />
</view>
</view>
<!-- 状态统计和筛选 -->
<view class="bg-white rounded-12px shadow-[0_2px_12px_rgba(0,0,0,0.06)] p-4 mb-3">
<view class="grid grid-cols-3 gap-3 mb-3">
<view class="text-center">
<text class="text-11px text-[#999] block mb-1">
正常
</text>
<text class="text-20px font-700 text-[#3ed268]">
{{ stats.active }}
</text>
</view>
<view class="text-center">
<text class="text-11px text-[#999] block mb-1">
停机
</text>
<text class="text-20px font-700 text-[#ff6b6b]">
{{ stats.inactive }}
</text>
</view>
<view class="text-center">
<text class="text-11px text-[#999] block mb-1">
已分销
</text>
<text class="text-20px font-700 text-[#ff6700]">
{{ stats.distributed }}
</text>
</view>
</view>
<!-- 状态筛选 -->
<view class="flex items-center gap-2">
<view
v-for="option in statusOptions"
:key="option.value"
:class="[
'flex-1 text-center py-2 rounded-8px text-13px transition-all duration-200',
statusFilter === option.value
? 'bg-[#ff6700] text-white font-600'
: 'bg-[#f5f5f5] text-[#666]',
]"
@click="handleStatusChange(option.value)"
>
{{ option.label }}
</view>
</view>
</view>
<!-- 卡片列表 -->
<view v-if="filteredCards.length > 0" class="space-y-3 pb-4">
<view
v-for="card in filteredCards"
:key="card.id"
class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] overflow-hidden"
@click="viewCardDetail(card)"
>
<!-- 顶部状态栏 -->
<view class="px-4 pt-4 pb-3 border-b-1px border-[#f5f5f5]">
<view class="flex items-center justify-between mb-2">
<view class="flex items-center gap-2">
<i
:class="[
getCarrierIcon(card.carrier_name),
'text-24px text-[#212121]',
]"
/>
<view>
<text class="text-16px font-700 text-[#212121] block">
{{ card.carrier_name || '-' }}
</text>
<text class="text-11px text-[#999]">
{{ getCardStatusText(card.status, card.status_name) }}
</text>
</view>
</view>
<view
:style="{
backgroundColor: getNetworkStatusColor(card.network_status).bg,
color: getNetworkStatusColor(card.network_status).text,
}"
class="px-3 py-1 rounded-8px text-12px font-600"
>
{{ getNetworkStatusText(card.network_status, card.network_status_name) }}
</view>
</view>
<text class="text-12px text-[#999]">
{{ card.package_name || '暂无套餐' }}
</text>
</view>
<!-- 卡片信息 -->
<view class="px-4 py-3">
<view class="space-y-2">
<!-- ICCID -->
<view class="flex items-start justify-between">
<text class="text-12px text-[#999] w-70px">ICCID</text>
<text class="flex-1 text-13px text-[#212121] text-right break-all">
{{ card.iccid || '-' }}
</text>
</view>
<!-- 手机号 (MSISDN) -->
<view class="flex items-start justify-between">
<text class="text-12px text-[#999] w-70px">手机号</text>
<text class="flex-1 text-13px font-600 text-[#212121] text-right">
{{ card.msisdn || '-' }}
</text>
</view>
<!-- 虚拟号 -->
<view v-if="card.virtual_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">
{{ card.virtual_no }}
</text>
</view>
</view>
</view>
<!-- 使用情况 -->
<view class="px-4 pb-4">
<view class="bg-[#f5f5f5] rounded-12px p-3">
<view class="flex items-center justify-between mb-2">
<text class="text-12px text-[#999]">
流量使用
</text>
<text class="text-12px font-600 text-[#212121]">
{{ card.data_usage || '0' }} / {{ card.data_total || '0' }}
</text>
</view>
<!-- 进度条 -->
<view class="h-6px bg-[#e0e0e0] rounded-full overflow-hidden">
<view
:style="{
width: `${(parseFloat(card.data_usage || '0') / parseFloat(card.data_total || '1')) * 100}%`,
}"
:class="[
'h-full rounded-full transition-all duration-300',
(parseFloat(card.data_usage || '0') / parseFloat(card.data_total || '1')) > 0.9
? 'bg-[#ff6b6b]'
: 'bg-[#3ed268]',
]"
/>
</view>
</view>
<view class="flex items-center justify-between mt-2 text-11px text-[#999]">
<text>激活: {{ card.activate_time || '-' }}</text>
<text>到期: {{ card.expire_date || '-' }}</text>
</view>
</view>
</view>
</view>
<!-- 空状态 -->
<view v-else class="pt-20 center flex-col">
<i class="i-mdi-sim-off text-80px text-[#ddd] mb-4" />
<text class="text-15px text-[#999] mb-2">
{{ searchKeyword ? '未找到匹配的卡片' : '暂无卡片数据' }}
</text>
<text class="text-12px text-[#ccc]">
{{ searchKeyword ? '请尝试其他搜索关键词' : '请联系管理员添加卡片' }}
</text>
</view>
<!-- 底部留白 -->
<view class="h-8" />
</view>
<!-- 运营商选择器 -->
<SimplePicker
v-model:show="showCarrierPicker"
:options="carrierOptions"
title="选择运营商"
@confirm="onCarrierConfirm"
/>
</view>
</template>
<style scoped>
.center {
display: flex;
align-items: center;
justify-content: center;
}
.space-y-3 > view:not(:last-child) {
margin-bottom: 12px;
}
.space-y-2 > view:not(:last-child) {
margin-bottom: 8px;
}
.break-all {
word-break: break-all;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>

View File

@@ -0,0 +1,317 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { getUserInfo } from '@/api/auth'
import { getEnterpriseDevices } from '@/api/enterprise'
import type { DeviceInfo } from '@/api/enterprise'
// 企业ID (用于调用企业接口)
const enterpriseId = ref<number>(0)
// 设备列表
const deviceList = ref<DeviceInfo[]>([])
// 加载状态
const loading = ref(false)
// 搜索关键词
const searchKeyword = ref('')
// 不再需要状态筛选
// 直接显示设备列表 (搜索由后端处理)
const filteredDevices = computed(() => {
return deviceList.value
})
// 统计数据
const stats = computed(() => {
const total = deviceList.value.length
return { total }
})
// 页面加载
onMounted(() => {
loadEnterpriseDevices()
})
// 加载企业设备列表
async function loadEnterpriseDevices() {
loading.value = true
try {
// 1. 获取企业ID (优先从本地存储获取)
if (!enterpriseId.value) {
const userInfo = uni.getStorageSync('user_info')
if (userInfo?.enterprise_id) {
enterpriseId.value = userInfo.enterprise_id
} else {
// 如果本地存储没有,则调用接口获取
const apiUserInfo = await getUserInfo()
if (!apiUserInfo.enterprise_id) {
throw new Error('未找到企业ID')
}
enterpriseId.value = apiUserInfo.enterprise_id
}
}
// 2. 获取企业设备列表 (支持分页和虚拟号搜索)
const params: any = {
page: 1,
page_size: 100, // 暂时获取更多数据,因为没有分页加载
}
// 添加虚拟号搜索支持
if (searchKeyword.value) {
params.virtual_no = searchKeyword.value
}
const devicesData = await getEnterpriseDevices(enterpriseId.value, params)
deviceList.value = devicesData?.items || []
}
catch (error: any) {
console.error('加载企业设备列表失败:', error)
// 如果是前端逻辑错误(非 API 错误),需要手动显示提示
if (error instanceof Error && error.message && !error.statusCode) {
uni.$u.toast(error.message)
}
// API 请求错误已由拦截器统一处理
}
finally {
loading.value = false
}
}
// 查看设备详情
function viewDeviceDetail(device: any) {
// 跳转到资产详情页
uni.navigateTo({
url: `/pages/agent-system/asset-search/index?virtual_no=${device.virtual_no}`,
})
}
// 获取设备型号图标
function getDeviceModelIcon(model: string) {
// 根据设备型号返回不同图标
if (model?.includes('WM')) {
return 'i-mdi-router-wireless'
}
return 'i-mdi-devices'
}
// 格式化授权时间
function formatAuthorizedTime(time: string) {
if (!time) return '-'
try {
const date = new Date(time)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
} catch {
return time
}
}
// 清空搜索
function clearSearch() {
searchKeyword.value = ''
deviceList.value = []
loadEnterpriseDevices()
}
// 执行搜索
function handleSearch() {
deviceList.value = []
loadEnterpriseDevices()
}
// 刷新列表
function refreshList() {
deviceList.value = []
loadEnterpriseDevices()
}
</script>
<template>
<view class="bg-[#fafafa] min-h-screen pb-safe">
<!-- 加载中 -->
<view v-if="loading" class="pt-20 center">
<i class="i-mdi-loading animate-spin text-40px text-[#999]" />
</view>
<!-- 内容区域 -->
<view v-else class="px-4 pt-4">
<!-- 搜索框 -->
<view class="mb-3">
<view class="bg-white rounded-12px shadow-[0_2px_12px_rgba(0,0,0,0.06)] overflow-hidden">
<view class="flex items-center px-4 py-3 gap-2">
<i class="i-mdi-magnify text-20px text-[#999]" />
<input
v-model="searchKeyword"
class="flex-1 text-14px text-[#212121] placeholder:text-[#999]"
placeholder="搜索设备名称 / 设备型号 / 虚拟号"
@confirm="handleSearch"
>
<view
v-if="searchKeyword"
class="ml-1 min-w-32px min-h-32px flex items-center justify-center"
@click="clearSearch"
>
<i class="i-mdi-close-circle text-18px text-[#999]" />
</view>
<view
class="px-4 py-2 bg-[#ff6700] text-white text-13px font-600 rounded-8px min-h-32px flex items-center justify-center"
@click="handleSearch"
>
搜索
</view>
</view>
</view>
</view>
<!-- 统计卡片 -->
<view class="bg-white rounded-12px shadow-[0_2px_12px_rgba(0,0,0,0.06)] p-4 mb-3">
<view class="flex items-center justify-between">
<view class="flex items-center gap-2">
<i class="i-mdi-devices text-24px text-[#ff6700]" />
<text class="text-15px font-600 text-[#212121]">
企业授权设备
</text>
</view>
<view class="text-right">
<text class="text-12px text-[#999] block">
总计
</text>
<text class="text-24px font-700 text-[#ff6700]">
{{ stats.total }}
</text>
</view>
</view>
</view>
<!-- 设备列表 -->
<view v-if="filteredDevices.length > 0" class="space-y-3 pb-4">
<view
v-for="device in filteredDevices"
:key="device.virtual_no"
class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] overflow-hidden"
@click="viewDeviceDetail(device)"
>
<!-- 顶部状态栏 -->
<view class="px-4 pt-4 pb-3 border-b-1px border-[#f5f5f5]">
<view class="flex items-center justify-between mb-2">
<view class="flex items-center gap-2">
<i
:class="[
getDeviceModelIcon(device.device_model),
'text-24px text-[#212121]',
]"
/>
<view>
<text class="text-16px font-700 text-[#212121] block">
{{ device.device_name || '-' }}
</text>
<text class="text-11px text-[#999]">
{{ device.device_model || '-' }}
</text>
</view>
</view>
<view class="px-3 py-1 rounded-8px text-12px font-600 bg-[#e8f5e9] text-[#2e7d32]">
已授权
</view>
</view>
<text class="text-12px text-[#999]">
绑定卡数: {{ device.card_count || 0 }}
</text>
</view>
<!-- 设备信息 -->
<view class="px-4 py-3">
<view class="space-y-2">
<!-- 虚拟号 -->
<view class="flex items-start justify-between">
<text class="text-12px text-[#999] w-70px">虚拟号</text>
<text class="flex-1 text-13px font-600 text-[#212121] text-right break-all">
{{ device.virtual_no || '-' }}
</text>
</view>
<!-- 设备型号 -->
<view 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">
{{ device.device_model || '-' }}
</text>
</view>
<!-- 绑定卡数 -->
<view class="flex items-start justify-between">
<text class="text-12px text-[#999] w-70px">绑定卡数</text>
<text class="flex-1 text-13px font-600 text-[#ff6700] text-right">
{{ device.card_count || 0 }}
</text>
</view>
</view>
</view>
<!-- 授权时间 -->
<view class="px-4 pb-4">
<view class="bg-[#f5f5f5] rounded-12px p-3">
<view class="flex items-center justify-between">
<text class="text-11px text-[#999]">
授权时间
</text>
<text class="text-12px font-600 text-[#212121]">
{{ formatAuthorizedTime(device.authorized_at) }}
</text>
</view>
</view>
</view>
</view>
</view>
<!-- 空状态 -->
<view v-else class="pt-20 center flex-col">
<i class="i-mdi-devices-off text-80px text-[#ddd] mb-4" />
<text class="text-15px text-[#999] mb-2">
{{ searchKeyword ? '未找到匹配的设备' : '暂无设备数据' }}
</text>
<text class="text-12px text-[#ccc]">
{{ searchKeyword ? '请尝试其他搜索关键词' : '请联系管理员添加设备' }}
</text>
</view>
<!-- 底部留白 -->
<view class="h-8" />
</view>
</view>
</template>
<style scoped>
.center {
display: flex;
align-items: center;
justify-content: center;
}
.space-y-3 > view:not(:last-child) {
margin-bottom: 12px;
}
.space-y-2 > view:not(:last-child) {
margin-bottom: 8px;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>

View File

@@ -0,0 +1,392 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { getUserInfo, logout as logoutApi } from '@/api/auth'
import { clearToken } from '@/utils/auth'
import type { UserInfo } from '@/api/auth'
import { getCommissionSummary, getCommissionDailyStats } from '@/api/commission'
import type { CommissionSummary, DailyCommissionStats } from '@/api/commission'
import Skeleton from '@/components/Skeleton.vue'
// 用户信息
const userInfo = ref<UserInfo | null>(null)
// 佣金概览
const commissionSummary = ref<CommissionSummary | null>(null)
// 每日佣金统计 (最近7天)
const dailyStats = ref<DailyCommissionStats[]>([])
// 加载状态
const loading = ref(true)
const loadingCommission = ref(false)
// 是否为代理账号
const isAgent = computed(() => userInfo.value?.user_type === 3)
// 格式化金额(分转元,带千分号)
function formatAmount(amount: number) {
const yuan = (amount / 100).toFixed(2)
const parts = yuan.split('.')
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',')
return `¥${parts.join('.')}`
}
// 格式化日期 (MM-DD)
function formatDate(dateStr: string) {
const date = new Date(dateStr)
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${month}-${day}`
}
// 根据用户类型动态生成菜单
const menuItems = computed(() => {
const menus = [
{
title: '资产列表',
desc: userInfo.value?.user_type === 3 ? '代理资产列表' : '企业资产列表',
icon: 'i-mdi-view-list',
path: '/pages/agent-system/assets/index',
},
]
// 企业端显示授权卡和授权设备
if (userInfo.value?.user_type !== 3) {
menus.push(
{
title: '授权卡列表',
desc: '企业授权IoT卡',
icon: 'i-mdi-sim',
path: '/pages/agent-system/enterprise-cards/index',
},
{
title: '授权设备列表',
desc: '企业授权设备',
icon: 'i-mdi-devices',
path: '/pages/agent-system/enterprise-devices/index',
}
)
}
// 仅代理端显示佣金和提现
if (userInfo.value?.user_type === 3) {
menus.push(
{
title: '佣金中心',
desc: '收益统计',
icon: 'i-mdi-wallet',
path: '/pages/agent-system/commission-center/index',
},
{
title: '提现管理',
desc: '申请提现',
icon: 'i-mdi-cash',
path: '/pages/agent-system/withdraw/index',
}
)
}
return menus
})
// 加载佣金数据
async function loadCommissionData() {
if (!isAgent.value) return
loadingCommission.value = true
try {
// 1. 加载佣金概览
const summary = await getCommissionSummary()
commissionSummary.value = summary
// 2. 加载最近7天佣金统计
const stats = await getCommissionDailyStats({ days: 7 })
dailyStats.value = stats || []
}
catch (error) {
console.error('加载佣金数据失败:', error)
}
finally {
loadingCommission.value = false
}
}
// 加载首页数据
async function loadHomeData() {
loading.value = true
try {
// 加载用户信息
const user = await getUserInfo()
userInfo.value = user
console.log('首页数据加载成功:', { user })
// 如果是代理账号,加载佣金数据
if (user.user_type === 3) {
await loadCommissionData()
}
}
catch (error: any) {
console.error('加载首页数据失败:', error)
// 如果是前端逻辑错误(非 API 错误),需要手动显示提示
if (error instanceof Error && error.message && !error.statusCode) {
uni.$u.toast(error.message)
}
// API 请求错误已由拦截器统一处理
}
finally {
loading.value = false
}
}
// 跳转页面
function navigateTo(path: string) {
uni.navigateTo({ url: path })
}
// 退出登录
function logout() {
uni.showModal({
title: '确认退出',
content: '确定要退出登录吗?',
success: async (res) => {
if (res.confirm) {
try {
// 调用登出接口
await logoutApi()
}
catch (error) {
console.error('登出接口调用失败:', error)
}
finally {
// 清除本地登录状态
clearToken()
uni.removeStorageSync('refresh_token')
uni.removeStorageSync('user_info')
// 跳转到登录页
uni.reLaunch({
url: '/pages/common/login/index',
})
}
}
},
})
}
onMounted(() => {
loadHomeData()
})
</script>
<template>
<view class="bg-[#fafafa] min-h-screen pb-safe">
<!-- 加载骨架屏 -->
<Skeleton v-if="loading" type="home" />
<!-- 实际内容 -->
<view v-else>
<!-- 顶部用户信息 -->
<view class="bg-gradient-to-r from-[#ff6700] to-[#ff8534] px-6 pt-6 pb-5">
<view class="flex items-center justify-between">
<!-- 左侧用户信息 -->
<view class="flex-1">
<view class="flex items-center gap-2 mb-1">
<text class="text-22px font-700 text-white">
{{ userInfo?.username || '欢迎回来' }}
</text>
<view
v-if="userInfo?.user_type === 3"
class="px-2 py-0.5 bg-white/20 rounded-6px"
>
<text class="text-11px text-white font-600">代理</text>
</view>
<view
v-else
class="px-2 py-0.5 bg-white/20 rounded-6px"
>
<text class="text-11px text-white font-600">企业</text>
</view>
</view>
<view class="flex items-center gap-2">
<i class="i-mdi-phone text-14px text-white/70" />
<text class="text-13px text-white/90">
{{ userInfo?.phone || '-' }}
</text>
</view>
</view>
<!-- 右侧头像 -->
<view
class="w-56px h-56px rounded-full bg-white/20 backdrop-blur center border-2px border-white/30"
@click="navigateTo('/pages/agent-system/user-info/index')"
>
<text class="text-24px text-white font-700">
{{ userInfo?.username?.substring(0, 1) || 'U' }}
</text>
</view>
</view>
</view>
<!-- 代理端: 佣金概览 -->
<view v-if="isAgent && commissionSummary" class="px-4 pt-4">
<view class="bg-gradient-to-r from-[#ff6700] to-[#ff8534] rounded-16px shadow-[0_4px_16px_rgba(255,103,0,0.3)] p-4 mb-3">
<view class="flex items-center justify-between mb-3">
<view>
<text class="text-13px text-white/70 block mb-1">
可提现佣金
</text>
<text class="text-32px font-700 text-white block">
{{ formatAmount(commissionSummary.available_commission) }}
</text>
</view>
<i class="i-mdi-cash-multiple text-48px text-white/20" />
</view>
<view class="grid grid-cols-3 gap-3 pt-3 border-t-1px border-white/20">
<view class="text-center">
<text class="text-11px text-white/70 block mb-1">累计</text>
<text class="text-14px font-600 text-white">
{{ formatAmount(commissionSummary.total_commission) }}
</text>
</view>
<view class="text-center">
<text class="text-11px text-white/70 block mb-1">冻结</text>
<text class="text-14px font-600 text-white">
{{ formatAmount(commissionSummary.frozen_commission) }}
</text>
</view>
<view class="text-center">
<text class="text-11px text-white/70 block mb-1">已提现</text>
<text class="text-14px font-600 text-white">
{{ formatAmount(commissionSummary.withdrawn_commission) }}
</text>
</view>
</view>
</view>
<!-- 每日佣金趋势 -->
<view v-if="dailyStats.length > 0" class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] p-4 mb-3">
<view class="flex items-center justify-between mb-3">
<view class="flex items-center gap-2">
<i class="i-mdi-chart-line text-20px text-[#ff6700]" />
<text class="text-15px font-600 text-[#212121]">
近7日佣金
</text>
</view>
</view>
<!-- 简单的趋势条形图 -->
<view class="space-y-2">
<view
v-for="stat in dailyStats"
:key="stat.date"
class="flex items-center gap-2"
>
<text class="text-11px text-[#999] w-40px">
{{ formatDate(stat.date) }}
</text>
<view class="flex-1 h-20px bg-[#f5f5f5] rounded-full overflow-hidden relative">
<view
:style="{
width: `${Math.min(100, (stat.total_amount / Math.max(...dailyStats.map(s => s.total_amount))) * 100)}%`,
}"
class="h-full bg-gradient-to-r from-[#ff6700] to-[#ff8534] rounded-full"
/>
</view>
<text class="text-11px font-600 text-[#212121] w-60px text-right">
{{ formatAmount(stat.total_amount) }}
</text>
</view>
</view>
</view>
</view>
<!-- 快捷功能区 -->
<view class="px-4 pt-4">
<text class="text-13px text-[#999] block mb-3">快捷功能</text>
<!-- 功能列表 -->
<view class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] overflow-hidden mb-3">
<view
v-for="(item, index) in menuItems"
:key="item.path"
class="flex items-center px-4 py-4 active:bg-[#f8fafc] transition-all duration-200"
:class="{ 'border-t-1px border-[#f5f5f5]': index > 0 }"
@click="navigateTo(item.path)"
>
<view class="w-40px h-40px rounded-12px bg-[#fff3e0] center mr-3">
<i :class="item.icon" class="text-20px text-[#ff6700]" />
</view>
<view class="flex-1">
<text class="text-15px text-[#212121] font-600 block">{{ item.title }}</text>
<text class="text-12px text-[#999] block mt-0.5">{{ item.desc }}</text>
</view>
<i class="i-mdi-chevron-right text-20px text-[#cbd5e1]" />
</view>
</view>
</view>
<!-- 其他操作区 -->
<view class="px-4 pt-4 pb-6">
<text class="text-13px text-[#999] block mb-3">设置</text>
<view class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] overflow-hidden">
<!-- 个人信息 -->
<view
class="flex items-center px-4 py-4 active:bg-[#f8fafc] transition-all duration-200"
@click="navigateTo('/pages/agent-system/user-info/index')"
>
<i class="i-mdi-account-circle text-20px text-[#ff6700] mr-3" />
<text class="flex-1 text-15px text-[#212121]">个人信息</text>
<i class="i-mdi-chevron-right text-20px text-[#cbd5e1]" />
</view>
<!-- 修改密码 -->
<view
class="flex items-center px-4 py-4 border-t-1px border-[#f5f5f5] active:bg-[#f8fafc] transition-all duration-200"
@click="navigateTo('/pages/agent-system/change-password/index')"
>
<i class="i-mdi-lock-reset text-20px text-[#ff6700] mr-3" />
<text class="flex-1 text-15px text-[#212121]">修改密码</text>
<i class="i-mdi-chevron-right text-20px text-[#cbd5e1]" />
</view>
<!-- 退出登录 -->
<view
class="flex items-center px-4 py-4 border-t-1px border-[#f5f5f5] active:bg-[#f8fafc] transition-all duration-200"
@click="logout"
>
<i class="i-mdi-logout text-20px text-[#999] mr-3" />
<text class="flex-1 text-15px text-[#212121]">退出登录</text>
<i class="i-mdi-chevron-right text-20px text-[#cbd5e1]" />
</view>
</view>
</view>
</view>
</view>
</template>
<style scoped>
.center {
display: flex;
align-items: center;
justify-content: center;
}
.space-y-2 > view:not(:last-child) {
margin-bottom: 8px;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
function goBack() {
uni.navigateBack()
}
</script>
<template>
<view class="bg-[#fafafa] min-h-screen flex items-center justify-center pb-safe">
<view class="px-6 text-center">
<i class="i-mdi-hammer-wrench text-64px text-[#ccc] block mb-4" />
<text class="text-18px font-600 text-[#333] block mb-2">功能开发中</text>
<text class="text-14px text-[#999] block mb-6">
我的套餐功能正在开发中敬请期待
</text>
<view
class="bg-[#212121] text-white rounded-full px-8 py-3 inline-block"
@click="goBack"
>
<text class="text-15px">返回</text>
</view>
</view>
</view>
</template>

View File

@@ -0,0 +1,245 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { getEnterpriseCards } from '@/api/enterprise'
import { getUserInfo } from '@/api/auth'
interface CardInfo {
id: number
iccid: string
msisdn: string
virtual_no?: string
carrier_id: number
carrier_name?: string
package_name?: string
status: number
status_name: string
network_status: number
network_status_name: string
}
// 企业ID
const enterpriseId = ref<number>(0)
// IoT卡列表
const cardList = ref<CardInfo[]>([])
// 加载状态
const loading = ref(true)
// 套餐统计
const packageStats = computed(() => {
const total = cardList.value.length
const hasPackage = cardList.value.filter(c => c.package_name).length
const noPackage = total - hasPackage
return { total, hasPackage, noPackage }
})
// 按套餐分组
const packageGroups = computed(() => {
const groups = new Map<string, CardInfo[]>()
cardList.value.forEach(card => {
const pkg = card.package_name || '无套餐'
if (!groups.has(pkg)) {
groups.set(pkg, [])
}
groups.get(pkg)!.push(card)
})
return Array.from(groups.entries()).map(([name, cards]) => ({
name,
count: cards.length,
cards,
}))
})
// 加载数据
async function loadData() {
loading.value = true
try {
// 获取企业ID
if (!enterpriseId.value) {
const userInfo = uni.getStorageSync('user_info')
if (userInfo?.enterprise_id) {
enterpriseId.value = userInfo.enterprise_id
} else {
const apiUserInfo = await getUserInfo()
if (!apiUserInfo.enterprise_id) {
throw new Error('未找到企业ID')
}
enterpriseId.value = apiUserInfo.enterprise_id
}
}
// 获取企业卡列表
const response = await getEnterpriseCards(enterpriseId.value)
cardList.value = response?.items || []
} catch (error: any) {
console.error('加载数据失败:', error)
if (error instanceof Error && error.message && !error.statusCode) {
uni.$u.toast(error.message)
}
} finally {
loading.value = false
}
}
// 查看IoT卡详情
function viewCardDetail(card: CardInfo) {
uni.navigateTo({
url: `/pages/agent-system/asset-search/index?iccid=${card.iccid}`,
})
}
// 获取卡片状态颜色
function getStatusColor(status: number) {
const map: Record<number, { bg: string, text: string }> = {
1: { bg: '#f5f5f5', text: '#999' }, // 在库
2: { bg: '#e8f5e9', text: '#2e7d32' }, // 已分销
3: { bg: '#ffebee', text: '#c62828' }, // 已停用
}
return map[status] || { bg: '#f5f5f5', text: '#999' }
}
// 获取网络状态颜色
function getNetworkStatusColor(status: number) {
return status === 1
? { bg: '#e8f5e9', text: '#2e7d32' }
: { bg: '#ffebee', text: '#c62828' }
}
onMounted(() => {
loadData()
})
</script>
<template>
<view class="bg-[#fafafa] min-h-screen pb-safe">
<!-- 顶部统计 -->
<view class="px-4 pt-4 pb-3">
<view class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] p-4">
<text class="text-16px font-700 text-[#212121] block mb-3">套餐统计</text>
<view class="grid grid-cols-3 gap-3">
<view class="text-center">
<text class="text-24px font-700 text-[#212121] block">{{ packageStats.total }}</text>
<text class="text-11px text-[#999] mt-1">总IoT卡</text>
</view>
<view class="text-center">
<text class="text-24px font-700 text-[#ff6700] block">{{ packageStats.hasPackage }}</text>
<text class="text-11px text-[#999] mt-1">已订购</text>
</view>
<view class="text-center">
<text class="text-24px font-700 text-[#999] block">{{ packageStats.noPackage }}</text>
<text class="text-11px text-[#999] mt-1">未订购</text>
</view>
</view>
</view>
</view>
<!-- 加载状态 -->
<view v-if="loading" class="pt-20 flex flex-col items-center">
<i class="i-mdi-loading animate-spin text-40px text-[#999]" />
</view>
<!-- 套餐分组列表 -->
<view v-else class="px-4 pb-4">
<view class="space-y-3">
<view
v-for="group in packageGroups"
:key="group.name"
class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] overflow-hidden"
>
<!-- 套餐头部 -->
<view class="px-4 py-3 bg-[#f8fafc] border-b-1px border-[#f1f5f9]">
<view class="flex items-center justify-between">
<view class="flex items-center gap-2">
<i class="i-mdi-package-variant text-20px text-[#ff6700]" />
<text class="text-15px font-600 text-[#212121]">{{ group.name }}</text>
</view>
<text class="text-12px text-[#999]">{{ group.count }} </text>
</view>
</view>
<!-- IoT卡列表 -->
<view class="px-4 py-2">
<view
v-for="(card, index) in group.cards"
:key="card.id"
class="py-3 active:bg-[#f8fafc] transition-all"
:class="{ 'border-t-1px border-[#f1f5f9]': index > 0 }"
@click="viewCardDetail(card)"
>
<view class="flex items-start justify-between">
<view class="flex-1 min-w-0">
<!-- ICCID -->
<text class="text-13px text-[#212121] block mb-1 font-500">
{{ card.iccid }}
</text>
<!-- 电话号码 -->
<view class="flex items-center gap-2 mb-1">
<i class="i-mdi-phone text-14px text-[#999]" />
<text class="text-12px text-[#666]">
{{ card.msisdn }}
</text>
</view>
<!-- 状态标签 -->
<view class="flex items-center gap-2">
<!-- 卡片状态 -->
<view
:style="{
backgroundColor: getStatusColor(card.status).bg,
color: getStatusColor(card.status).text,
}"
class="px-2 py-0.5 rounded text-10px font-600"
>
{{ card.status_name }}
</view>
<!-- 网络状态 -->
<view
:style="{
backgroundColor: getNetworkStatusColor(card.network_status).bg,
color: getNetworkStatusColor(card.network_status).text,
}"
class="px-2 py-0.5 rounded text-10px font-600"
>
{{ card.network_status_name }}
</view>
</view>
</view>
<!-- 右侧箭头 -->
<view class="flex items-center ml-3">
<i class="i-mdi-chevron-right text-20px text-[#cbd5e1]" />
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 空状态 -->
<view v-if="packageGroups.length === 0" class="bg-white rounded-16px py-16 flex flex-col items-center">
<i class="i-mdi-package-variant-closed text-64px text-[#ddd] mb-3" />
<text class="text-15px text-[#999]">暂无套餐数据</text>
</view>
</view>
</view>
</template>
<style scoped>
.space-y-3 > view:not(:last-child) {
margin-bottom: 12px;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
function goBack() {
uni.navigateBack()
}
</script>
<template>
<view class="bg-[#fafafa] min-h-screen flex items-center justify-center pb-safe">
<view class="px-6 text-center">
<i class="i-mdi-hammer-wrench text-64px text-[#ccc] block mb-4" />
<text class="text-18px font-600 text-[#333] block mb-2">功能开发中</text>
<text class="text-14px text-[#999] block mb-6">
标签管理功能正在开发中敬请期待
</text>
<view
class="bg-[#212121] text-white rounded-full px-8 py-3 inline-block"
@click="goBack"
>
<text class="text-15px">返回</text>
</view>
</view>
</view>
</template>

View File

@@ -0,0 +1,264 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { getUserInfo, logout as logoutApi } from '@/api/auth'
import { clearToken } from '@/utils/auth'
import type { UserInfo } from '@/api/auth'
// 用户信息
const userInfo = ref<UserInfo | null>(null)
// 加载状态
const loading = ref(false)
// 用户类型文本(优先使用接口返回的 user_type_name
const userTypeText = computed(() => {
if (!userInfo.value)
return ''
return userInfo.value.user_type_name || '未知'
})
// 用户类型颜色
const userTypeColor = computed(() => {
if (!userInfo.value)
return { bg: '#f5f5f5', text: '#999' }
const colorMap: Record<number, { bg: string, text: string }> = {
1: { bg: '#ffebee', text: '#c62828' },
2: { bg: '#e3f2fd', text: '#1976d2' },
3: { bg: '#e8f5e9', text: '#2e7d32' },
4: { bg: '#fff3e0', text: '#e65100' },
}
return colorMap[userInfo.value.user_type] || { bg: '#f5f5f5', text: '#999' }
})
// 页面加载
onMounted(() => {
loadUserInfo()
})
// 加载用户信息
async function loadUserInfo() {
loading.value = true
try {
// 调用 API 获取用户信息
const data = await getUserInfo()
console.log('获取用户信息成功:', data)
userInfo.value = data
}
catch (error: any) {
console.error('获取用户信息失败:', error)
// 如果是前端逻辑错误(非 API 错误),需要手动显示提示
if (error instanceof Error && error.message && !error.statusCode) {
uni.$u.toast(error.message)
}
// API 请求错误已由拦截器统一处理
}
finally {
loading.value = false
}
}
// 修改密码
function changePassword() {
uni.navigateTo({
url: '/pages/agent-system/change-password/index',
})
}
// 退出登录
function logout() {
uni.showModal({
title: '确认退出',
content: '确定要退出登录吗?',
success: async (res) => {
if (res.confirm) {
try {
// 调用登出接口
await logoutApi()
}
catch (error) {
console.error('登出接口调用失败:', error)
}
finally {
// 清除本地登录状态
clearToken()
uni.removeStorageSync('refresh_token')
uni.removeStorageSync('user_info')
// 跳转到登录页
uni.reLaunch({
url: '/pages/common/login/index',
})
}
}
},
})
}
// 复制文本
function copyText(text: string, label: string) {
uni.setClipboardData({
data: text,
success: () => {
uni.showToast({
title: `已复制${label}`,
icon: 'success',
})
},
})
}
</script>
<template>
<view class="bg-[#fafafa] min-h-screen pb-safe">
<!-- 顶部背景区域 -->
<view class="bg-[#212121]">
<!-- 用户头像卡片 -->
<view v-if="userInfo" class="px-4 pt-5 pb-6">
<view class="center flex-col">
<!-- 头像 -->
<view class="w-80px h-80px rounded-full bg-[#ffffff25] center mb-3">
<i class="i-mdi-account text-48px text-white" />
</view>
<!-- 用户名 -->
<text class="text-20px font-700 text-white block mb-1">
{{ userInfo.username }}
</text>
<!-- 用户类型 -->
<view
:style="{
backgroundColor: userTypeColor.bg,
color: userTypeColor.text,
}"
class="px-3 py-1 rounded-8px text-12px font-600"
>
{{ userTypeText }}
</view>
</view>
</view>
</view>
<!-- 加载中 -->
<view v-if="loading" class="pt-20 center">
<i class="i-mdi-loading animate-spin text-40px text-[#999]" />
</view>
<!-- 内容区域 -->
<view v-else-if="userInfo" class="px-4 pt-4">
<!-- 个人信息 -->
<view class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] overflow-hidden mb-3">
<view class="px-4 py-3 border-b-1px border-[#f5f5f5]">
<text class="text-14px font-600 text-[#212121]">
个人信息
</text>
</view>
<view class="px-4 py-2">
<!-- 用户名 -->
<view
class="py-3 flex items-center justify-between border-b-1px border-[#f5f5f5]"
@click="copyText(userInfo.username, '用户名')"
>
<text class="text-14px text-[#999]">用户名</text>
<view class="flex items-center gap-2">
<text class="text-14px text-[#212121]">{{ userInfo.username }}</text>
<i class="i-mdi-content-copy text-16px text-[#999]" />
</view>
</view>
<!-- 手机号 -->
<view
class="py-3 flex items-center justify-between border-b-1px border-[#f5f5f5]"
@click="copyText(userInfo.phone, '手机号')"
>
<text class="text-14px text-[#999]">手机号</text>
<view class="flex items-center gap-2">
<text class="text-14px text-[#212121]">{{ userInfo.phone }}</text>
<i class="i-mdi-content-copy text-16px text-[#999]" />
</view>
</view>
<!-- 用户类型 -->
<view class="py-3 flex items-center justify-between">
<text class="text-14px text-[#999]">用户类型</text>
<view
:style="{
backgroundColor: userTypeColor.bg,
color: userTypeColor.text,
}"
class="px-3 py-1 rounded-6px text-12px font-600"
>
{{ userInfo.user_type_name }}
</view>
</view>
</view>
</view>
<!-- 操作按钮 -->
<view class="space-y-3">
<!-- 修改密码 -->
<view
class="
bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)]
py-3 center gap-2
transition-all duration-200
active:bg-[#f5f5f5]
"
@click="changePassword"
>
<i class="i-mdi-lock-reset text-18px text-[#666]" />
<text class="text-15px font-600 text-[#666]">
修改密码
</text>
</view>
<!-- 退出登录 -->
<view
class="
bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)]
py-3 center gap-2
transition-all duration-200
active:bg-[#ffebee]
"
@click="logout"
>
<i class="i-mdi-logout text-18px text-[#c62828]" />
<text class="text-15px font-600 text-[#c62828]">
退出登录
</text>
</view>
</view>
<!-- 底部留白 -->
<view class="h-8" />
</view>
</view>
</template>
<style scoped>
.center {
display: flex;
align-items: center;
justify-content: center;
}
.space-y-3 > view:not(:last-child) {
margin-bottom: 12px;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>

View File

@@ -0,0 +1,77 @@
// 套餐状态枚举
export const packageStatus = {
0: { name: '待生效', color: '#666', bgColor: '#f5f5f5' },
1: { name: '生效中', color: '#212121', bgColor: '#e8e8e8' },
2: { name: '已用完', color: '#999', bgColor: '#f5f5f5' },
3: { name: '已过期', color: '#999', bgColor: '#f5f5f5' },
4: { name: '已失效', color: '#999', bgColor: '#f5f5f5' },
}
// 套餐类型
export const packageTypeMap = {
formal: '正式套餐',
addon: '加油包',
}
// 佣金来源映射
export const commissionSourceMap = {
cost_diff: '成本差价',
one_time: '一次性佣金',
tier_bonus: '层级分成',
}
// 格式化流量 (MB to GB)
export function formatDataSize(mb: number): string {
if (mb < 1024)
return `${mb}MB`
return `${(mb / 1024).toFixed(1)}GB`
}
// 计算使用百分比
export function calcUsagePercent(used: number, total: number): number {
if (total === 0)
return 0
return Math.min(Math.round((used / total) * 100), 100)
}
// 格式化时间
export function formatDate(dateStr: string | null): string {
if (!dateStr)
return '--'
const date = new Date(dateStr)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
// 格式化日期时间
export function formatDateTime(dateStr: string | null): string {
if (!dateStr)
return '--'
const date = new Date(dateStr)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}
// 计算剩余天数
export function calcRemainingDays(expiresAt: string | null): number | null {
if (!expiresAt)
return null
const now = new Date()
const expireDate = new Date(expiresAt)
const diff = expireDate.getTime() - now.getTime()
return Math.ceil(diff / (1000 * 60 * 60 * 24))
}
// 格式化金额 (分转元)
export function formatAmount(cents: number): string {
return (cents / 100).toFixed(2)
}
// 获取剩余天数颜色
export function getRemainingDaysColor(days: number | null): string {
if (days === null)
return '#bdbdbd'
if (days <= 7)
return '#f44336' // 红色
if (days <= 30)
return '#ff9800' // 橙色
return '#4caf50' // 绿色
}

View File

@@ -0,0 +1,587 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { getCommissionSummary } from '@/api/commission'
import type { CommissionSummary } from '@/api/commission'
import { createWithdrawal, getWithdrawalRecords } from '@/api/withdrawal'
import type { WithdrawalRecord } from '@/api/withdrawal'
import SimplePicker from '@/components/SimplePicker.vue'
// 佣金概览
const commissionSummary = ref<CommissionSummary | null>(null)
// 提现金额(元)
const withdrawAmount = ref('')
// 账户信息
const accountName = ref('')
const accountNumber = ref('')
// 提现记录
const withdrawRecords = ref<WithdrawalRecord[]>([])
// 当前Tab
const activeTab = ref<'apply' | 'records'>('apply')
// 加载状态
const loading = ref(false)
const submitting = ref(false)
const loadingRecords = ref(false)
// 分页参数
const page = ref(1)
const pageSize = 20
const total = ref(0)
// 状态筛选
const statusFilter = ref<number | undefined>(undefined)
const showStatusPicker = ref(false)
// 状态选项
const statusOptions = [
{ label: '全部状态', value: undefined },
{ label: '审核中', value: 1 },
{ label: '已通过', value: 2 },
{ label: '已拒绝', value: 3 },
{ label: '已到账', value: 4 },
]
// 获取当前选中的状态名称
const selectedStatusName = computed(() => {
if (statusFilter.value === undefined) return '提现状态'
const option = statusOptions.find(o => o.value === statusFilter.value)
return option?.label || '提现状态'
})
// Tab选项
const tabs = [
{ key: 'apply', label: '申请提现' },
{ key: 'records', label: '提现记录' },
]
// 可提现金额(分转元)
const availableAmount = computed(() => {
if (!commissionSummary.value) return 0
return commissionSummary.value.available_commission / 100
})
// 格式化可提现金额显示(带千分号)
const formattedAvailableAmount = computed(() => {
const amount = availableAmount.value.toFixed(2)
const parts = amount.split('.')
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',')
return parts.join('.')
})
// 实际到账金额 (无手续费,带千分号)
const actualAmount = computed(() => {
const amount = parseFloat(withdrawAmount.value) || 0
const fixed = amount.toFixed(2)
const parts = fixed.split('.')
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',')
return parts.join('.')
})
// 格式化输入金额显示 (带千分号)
const formattedWithdrawAmount = computed(() => {
if (!withdrawAmount.value) return '0.00'
const amount = parseFloat(withdrawAmount.value) || 0
const fixed = amount.toFixed(2)
const parts = fixed.split('.')
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',')
return parts.join('.')
})
// 格式化金额(分转元,带千分号)
function formatAmount(amount: number) {
const yuan = (amount / 100).toFixed(2)
const parts = yuan.split('.')
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',')
return `¥${parts.join('.')}`
}
// 获取提现状态文本
function getStatusText(status: number, statusName?: string) {
if (statusName) return statusName
const map: Record<number, string> = {
1: '审核中',
2: '已通过',
3: '已拒绝',
4: '已到账',
}
return map[status] || '未知'
}
// 获取提现状态颜色
function getStatusColor(status: number) {
const map: Record<number, { bg: string, text: string }> = {
1: { bg: '#fff3e0', text: '#e65100' }, // 审核中
2: { bg: '#e8f5e9', text: '#2e7d32' }, // 已通过
3: { bg: '#ffebee', text: '#c62828' }, // 已拒绝
4: { bg: '#e3f2fd', text: '#1565c0' }, // 已到账
}
return map[status] || { bg: '#f5f5f5', text: '#999' }
}
// 格式化时间
function formatTime(time: string) {
if (!time) return '-'
const date = new Date(time)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hour = String(date.getHours()).padStart(2, '0')
const minute = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hour}:${minute}`
}
// 页面加载
onMounted(() => {
loadCommissionSummary()
loadWithdrawRecords()
})
// 加载佣金概览
async function loadCommissionSummary() {
loading.value = true
try {
const summary = await getCommissionSummary()
commissionSummary.value = summary
}
catch (error) {
console.error('加载佣金概览失败:', error)
}
finally {
loading.value = false
}
}
// 加载提现记录
async function loadWithdrawRecords() {
loadingRecords.value = true
try {
const params: any = {
page: page.value,
page_size: pageSize,
}
// 状态筛选
if (statusFilter.value !== undefined) {
params.status = statusFilter.value
}
const response = await getWithdrawalRecords(params)
if (page.value === 1) {
withdrawRecords.value = response.items || []
}
else {
withdrawRecords.value.push(...(response.items || []))
}
total.value = response.total || 0
}
catch (error) {
console.error('加载提现记录失败:', error)
}
finally {
loadingRecords.value = false
}
}
// 快捷金额按钮
function setQuickAmount(amount: number) {
withdrawAmount.value = amount.toString()
}
// 全部提现
function setAllAmount() {
withdrawAmount.value = availableAmount.value.toFixed(2)
}
// 提交提现申请
async function handleSubmit() {
// 校验
const amount = parseFloat(withdrawAmount.value)
if (!amount || amount <= 0) {
uni.$u.toast('请输入提现金额')
return
}
if (amount > availableAmount.value) {
uni.$u.toast('提现金额超过可用余额')
return
}
if (!accountName.value.trim()) {
uni.$u.toast('请输入收款人姓名')
return
}
if (!accountNumber.value.trim()) {
uni.$u.toast('请输入支付宝账号')
return
}
submitting.value = true
try {
const result = await createWithdrawal({
account_name: accountName.value.trim(),
account_number: accountNumber.value.trim(),
amount: Math.round(amount * 100), // 元转分
withdrawal_method: 'alipay',
})
uni.$u.toast('提现申请已提交')
// 清空表单
withdrawAmount.value = ''
accountName.value = ''
accountNumber.value = ''
// 刷新数据
await loadCommissionSummary()
page.value = 1
await loadWithdrawRecords()
// 切换到记录Tab
activeTab.value = 'records'
}
catch (error) {
console.error('提现申请失败:', error)
}
finally {
submitting.value = false
}
}
// 切换Tab
function handleTabChange(tab: 'apply' | 'records') {
activeTab.value = tab
if (tab === 'records') {
page.value = 1
loadWithdrawRecords()
}
}
// 状态筛选变化
function onStatusConfirm(option: { label: string, value: any }) {
statusFilter.value = option.value
page.value = 1
withdrawRecords.value = []
loadWithdrawRecords()
}
// 加载更多
function loadMore() {
if (withdrawRecords.value.length >= total.value) {
return
}
page.value++
loadWithdrawRecords()
}
</script>
<template>
<view class="bg-[#fafafa] min-h-screen pb-safe">
<!-- 可提现金额卡片 -->
<view class="px-4 pt-4 pb-3">
<view class="bg-gradient-to-r from-[#ff6700] to-[#ff8534] rounded-16px shadow-[0_4px_16px_rgba(255,103,0,0.3)] p-4">
<view class="flex items-center justify-between">
<view>
<text class="text-13px text-white/70 block mb-1">
可提现金额
</text>
<text class="text-32px font-700 text-white block">
¥{{ formattedAvailableAmount }}
</text>
</view>
<i class="i-mdi-cash-multiple text-48px text-white/20" />
</view>
</view>
</view>
<!-- Tab切换 -->
<view class="px-4 mb-3">
<view class="bg-white rounded-12px shadow-[0_2px_12px_rgba(0,0,0,0.06)] p-1 flex">
<view
v-for="tab in tabs"
:key="tab.key"
:class="[
'flex-1 text-center py-2 rounded-8px text-14px transition-all duration-200',
activeTab === tab.key
? 'bg-[#ff6700] text-white font-600'
: 'text-[#666]',
]"
@click="handleTabChange(tab.key)"
>
{{ tab.label }}
</view>
</view>
</view>
<!-- 申请提现 -->
<view v-if="activeTab === 'apply'" class="px-4">
<view class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] p-4 mb-3">
<!-- 提现金额 -->
<view class="mb-4">
<text class="text-13px text-[#666] block mb-2">提现金额</text>
<view class="flex items-center border-1px border-[#e0e0e0] rounded-8px px-3 py-2">
<text class="text-20px text-[#666] mr-1">¥</text>
<input
v-model="withdrawAmount"
type="digit"
class="flex-1 text-20px text-[#212121]"
placeholder="0.00"
>
</view>
<!-- 快捷金额 -->
<view class="flex items-center gap-2 mt-2">
<view
v-for="amount in [100, 500, 1000]"
:key="amount"
class="px-3 py-1 bg-[#f5f5f5] text-[#666] text-12px rounded-6px"
@click="setQuickAmount(amount)"
>
{{ amount }}
</view>
<view
class="px-3 py-1 bg-[#fff3e0] text-[#ff6700] text-12px rounded-6px font-600"
@click="setAllAmount"
>
全部提现
</view>
</view>
</view>
<!-- 收款人姓名 -->
<view class="mb-4 border-2px border-[#ff6700] rounded-16px p-4 bg-white shadow-[0_2px_12px_rgba(255,103,0,0.15)]">
<view class="flex items-center mb-3">
<text class="text-14px font-600 text-[#212121] mr-1">收款人姓名</text>
<text class="text-12px text-[#ff6700]">*</text>
</view>
<input
v-model="accountName"
class="w-full text-16px text-[#212121] font-500"
placeholder="请输入支付宝实名认证的真实姓名"
placeholder-style="color: #ccc"
>
</view>
<!-- 支付宝账号 -->
<view class="mb-4 border-2px border-[#ff6700] rounded-16px p-4 bg-white shadow-[0_2px_12px_rgba(255,103,0,0.15)]">
<view class="flex items-center mb-3">
<text class="text-14px font-600 text-[#212121] mr-1">支付宝账号</text>
<text class="text-12px text-[#ff6700]">*</text>
</view>
<input
v-model="accountNumber"
class="w-full text-16px text-[#212121] font-500"
placeholder="请输入支付宝账号/手机号/邮箱"
placeholder-style="color: #ccc"
>
</view>
<!-- 费用说明 -->
<view class="bg-gradient-to-r from-[#f5f5f5] to-[#fafafa] rounded-12px p-4 mb-4 border-1px border-[#e0e0e0]">
<view class="flex items-center justify-between mb-3">
<text class="text-13px text-[#666]">提现金额</text>
<text class="text-16px font-600 text-[#212121]">¥{{ formattedWithdrawAmount }}</text>
</view>
<view class="pt-3 border-t-2px border-[#ff6700]">
<view class="flex items-center justify-between">
<view>
<text class="text-14px font-600 text-[#212121] block">实际到账</text>
<text class="text-11px text-[#52c41a] block mt-0.5">
无手续费,全额到账
</text>
</view>
<text class="text-24px font-700 text-[#52c41a]">¥{{ actualAmount }}</text>
</view>
</view>
</view>
<!-- 提交按钮 -->
<view
:class="[
'w-full py-3 rounded-8px text-center text-15px font-600 transition-all duration-200',
submitting ? 'bg-[#ccc] text-white' : 'bg-[#ff6700] text-white',
]"
@click="handleSubmit"
>
{{ submitting ? '提交中...' : '提交申请' }}
</view>
<!-- 温馨提示 -->
<view class="mt-3 p-4 bg-[#fff3e0] rounded-12px">
<view class="flex items-start gap-2">
<i class="i-mdi-information text-16px text-[#e65100] mt-0.5" />
<view class="flex-1">
<text class="text-12px font-600 text-[#e65100] block mb-2">温馨提示</text>
<text class="text-12px text-[#666] block leading-relaxed">
1. 目前仅支持支付宝提现<br>
2. 无手续费,全额到账<br>
3. 收款人姓名需与支付宝实名一致<br>
4. 提现申请提交后将在1-3个工作日内处理
</text>
</view>
</view>
</view>
</view>
</view>
<!-- 提现记录 -->
<view v-else class="px-4">
<!-- 筛选条件 -->
<view class="flex items-center gap-2 mb-3">
<!-- 状态筛选 -->
<view class="flex-1 bg-white rounded-full px-4 py-2 flex items-center shadow-[0_2px_12px_rgba(0,0,0,0.06)]" @click="showStatusPicker = true">
<text class="flex-1 text-14px text-[#212121]">{{ selectedStatusName }}</text>
<i class="i-mdi-chevron-down text-18px text-[#999]" />
</view>
</view>
<!-- 记录列表 -->
<view v-if="loadingRecords && page === 1" class="pt-10 center">
<i class="i-mdi-loading animate-spin text-40px text-[#999]" />
</view>
<view v-else-if="withdrawRecords.length > 0" class="space-y-2 mb-3">
<view
v-for="record in withdrawRecords"
:key="record.id"
class="bg-white rounded-12px shadow-[0_2px_12px_rgba(0,0,0,0.06)] p-4"
>
<view class="flex items-center justify-between mb-3">
<view class="flex items-center gap-2">
<i class="i-mdi-bank-transfer text-20px text-[#ff6700]" />
<text class="text-14px font-600 text-[#212121]">
{{ record.withdrawal_no }}
</text>
</view>
<view
:style="{
backgroundColor: getStatusColor(record.status).bg,
color: getStatusColor(record.status).text,
}"
class="px-2 py-1 rounded-6px text-11px font-600"
>
{{ getStatusText(record.status, record.status_name) }}
</view>
</view>
<view class="space-y-2">
<view class="flex items-center justify-between mb-2">
<text class="text-13px text-[#666]">提现金额</text>
<text class="text-18px font-700 text-[#212121]">
{{ formatAmount(record.amount) }}
</text>
</view>
<view v-if="record.fee > 0" class="flex items-center justify-between">
<text class="text-12px text-[#999]">手续费</text>
<text class="text-13px text-[#ff6700]">
-{{ formatAmount(record.fee) }}
</text>
</view>
<view class="flex items-center justify-between pt-2 border-t-1px border-[#f5f5f5]">
<view>
<text class="text-12px text-[#999] block">实际到账</text>
<text v-if="record.fee === 0" class="text-10px text-[#52c41a] block mt-0.5">
无手续费
</text>
</view>
<text class="text-18px font-700 text-[#52c41a]">
{{ formatAmount(record.actual_amount) }}
</text>
</view>
<view class="pt-2 border-t-1px border-[#f5f5f5]">
<view class="flex items-center justify-between mb-1">
<text class="text-11px text-[#999]">收款人</text>
<text class="text-12px text-[#212121]">
{{ record.account_name }}
</text>
</view>
<view class="flex items-center justify-between mb-1">
<text class="text-11px text-[#999]">支付宝账号</text>
<text class="text-12px text-[#212121]">
{{ record.account_number }}
</text>
</view>
<view class="flex items-center justify-between">
<text class="text-11px text-[#999]">申请时间</text>
<text class="text-11px text-[#999]">
{{ formatTime(record.created_at) }}
</text>
</view>
</view>
<!-- 拒绝原因 -->
<view v-if="record.status === 3 && record.reject_reason" class="pt-2 border-t-1px border-[#f5f5f5]">
<text class="text-11px text-[#c62828] block">
拒绝原因: {{ record.reject_reason }}
</text>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="withdrawRecords.length < total" class="py-3 center">
<text v-if="loadingRecords" class="text-12px text-[#999]">
加载中...
</text>
<text v-else class="text-12px text-[#ff6700]" @click="loadMore">
加载更多
</text>
</view>
</view>
<view v-else class="pt-10 center flex-col">
<i class="i-mdi-file-document-outline text-60px text-[#ddd] mb-3" />
<text class="text-14px text-[#999]">
暂无提现记录
</text>
</view>
</view>
<!-- 底部留白 -->
<view class="h-8" />
<!-- 状态选择器 -->
<SimplePicker
v-model:show="showStatusPicker"
:options="statusOptions"
title="选择提现状态"
@confirm="onStatusConfirm"
/>
</view>
</template>
<style scoped>
.center {
display: flex;
align-items: center;
justify-content: center;
}
.space-y-2 > view:not(:last-child) {
margin-bottom: 8px;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>

View File

@@ -0,0 +1,35 @@
<template>
<div class="not-found">
<u-navbar left-icon-size="40rpx" @left-click="handleBack" />
<u-empty
mode="page"
text-size="20"
text="页面不存在"
icon="/static/images/404.png"
width="380"
height="380"
/>
</div>
</template>
<script lang="ts" setup>
import { HOME_PATH } from '@/router';
function handleBack() {
uni.$u.route({
type: 'switchTab',
url: HOME_PATH,
});
}
</script>
<style lang="scss" scoped>
.not-found {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
overflow: auto;
}
</style>

View File

@@ -0,0 +1,481 @@
<template>
<view class="page">
<!-- 背景装饰 -->
<view class="bg-decor">
<view class="shape shape-1" />
<view class="shape shape-2" />
<view class="shape shape-3" />
</view>
<!-- 登录卡片 -->
<view class="login-card">
<!-- 顶部品牌区 -->
<view class="brand-header">
<view class="welcome-info">
<view class="welcome-title">欢迎回来</view>
<view class="welcome-desc">请输入您的账号密码进行登录</view>
</view>
</view>
<!-- 表单区域 -->
<view class="form-area">
<!-- 账号输入框 -->
<view class="input-wrap" :class="{ focused: accountFocused }">
<input
v-model="account"
class="input"
type="text"
placeholder="请输入账号"
@focus="accountFocused = true"
@blur="accountFocused = false"
>
</view>
<!-- 密码输入框 -->
<view class="input-wrap" :class="{ focused: passwordFocused }">
<input
v-model="password"
class="input"
:type="showPassword ? 'text' : 'password'"
placeholder="请输入密码"
@focus="passwordFocused = true"
@blur="passwordFocused = false"
>
<image
v-if="password"
class="pwd-toggle"
:src="showPassword ? '/static/icons/preview-open.png' : '/static/icons/preview-close-one.png'"
mode="aspectFit"
@tap="togglePassword"
/>
</view>
<!-- 登录按钮 -->
<button
class="btn"
:disabled="loading"
@tap="submit"
>
{{ loading ? '登录中...' : '登录' }}
</button>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { HOME_PATH, isTabBarPath, LOGIN_PATH, removeQueryString } from '@/router';
import { setToken } from '@/utils/auth';
import { login } from '@/api/auth';
const account = ref<string>('');
const password = ref<string>('');
const accountFocused = ref<boolean>(false);
const passwordFocused = ref<boolean>(false);
const showPassword = ref<boolean>(false);
const loading = ref<boolean>(false);
let redirect = HOME_PATH;
const isFormValid = computed(() => {
return account.value.trim().length > 0 && password.value.trim().length > 0;
});
function togglePassword() {
showPassword.value = !showPassword.value;
}
async function submit() {
if (!isFormValid.value) {
if (!account.value.trim()) {
uni.$u.toast('请输入账号');
return;
}
if (!password.value.trim()) {
uni.$u.toast('请输入密码');
return;
}
}
if (loading.value) return;
loading.value = true;
try {
// 调用登录接口(禁用拦截器的自动 toast由页面层统一处理
const res = await login({
username: account.value.trim(),
password: password.value.trim(),
});
// 保存 access_token
setToken(res.access_token);
// 保存 refresh_token 到本地存储
uni.setStorageSync('refresh_token', res.refresh_token);
// 保存用户信息到本地存储
uni.setStorageSync('user_info', res.user);
// 调试日志:查看用户信息
console.log('登录成功,用户信息:', res.user);
uni.showToast({
title: '登录成功',
icon: 'success',
duration: 1500,
});
setTimeout(() => {
// 根据用户类型跳转到不同页面
// user_type: 1=超级管理员 2=平台 3=代理 4=企业
if (res.user.user_type === 3 || res.user.user_type === 4) {
// 代理商和企业用户都跳转到首页(首页会根据用户类型显示不同功能)
uni.$u.route({
type: 'redirectTo',
url: '/pages/agent-system/home/index',
});
} else {
// 其他用户(超级管理员、平台)跳转到默认页面
uni.$u.route({
type: 'redirectTo',
url: redirect,
});
}
}, 1500);
} catch (error: any) {
console.error('登录失败:', error);
// 拦截器已经显示了后端返回的错误信息,这里不需要再次提示
// 如果需要额外处理,可以访问 error.data.msg 获取错误信息
} finally {
loading.value = false;
}
}
onLoad((options: any) => {
if (options.redirect && removeQueryString(options.redirect) !== LOGIN_PATH) {
redirect = decodeURIComponent(options.redirect);
}
});
</script>
<style lang="scss" scoped>
.page {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 32rpx;
background: linear-gradient(135deg, #fafafa 0%, #f5f5f5 100%);
overflow: hidden;
animation: bgFade 0.8s ease-out;
}
@keyframes bgFade {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
/* 背景装饰 - 小米风格 */
.bg-decor {
position: absolute;
inset: 0;
pointer-events: none;
&::before {
content: '';
position: absolute;
width: 100%;
height: 100%;
background:
linear-gradient(90deg, transparent 0%, rgba(255, 103, 0, 0.02) 50%, transparent 100%);
animation: scanline 8s linear infinite;
}
.shape {
position: absolute;
}
.shape-1 {
width: 800rpx;
height: 800rpx;
top: -300rpx;
right: -200rpx;
background: radial-gradient(circle, rgba(255, 103, 0, 0.08), transparent 60%);
border-radius: 50%;
animation: pulse1 10s infinite ease-in-out;
}
.shape-2 {
width: 600rpx;
height: 600rpx;
bottom: -200rpx;
left: -150rpx;
background: radial-gradient(circle, rgba(255, 103, 0, 0.06), transparent 60%);
border-radius: 50%;
animation: pulse2 12s infinite ease-in-out;
}
.shape-3 {
width: 400rpx;
height: 400rpx;
top: 50%;
right: -100rpx;
background: radial-gradient(circle, rgba(255, 103, 0, 0.04), transparent 60%);
border-radius: 50%;
animation: pulse3 14s infinite ease-in-out;
}
}
@keyframes scanline {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
@keyframes pulse1 {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.1);
opacity: 0.8;
}
}
@keyframes pulse2 {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.15);
opacity: 0.7;
}
}
@keyframes pulse3 {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.2);
opacity: 0.6;
}
}
/* 登录卡片 - 优化风格 */
.login-card {
position: relative;
width: 100%;
max-width: 680rpx;
padding: 60rpx 48rpx;
background: #ffffff;
border-radius: 20rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.08), 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
z-index: 1;
animation: cardFadeIn 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
@keyframes cardFadeIn {
0% {
opacity: 0;
transform: translateY(20rpx);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
/* 顶部欢迎区 */
.brand-header {
margin-bottom: 64rpx;
animation: slideInDown 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.1s both;
.welcome-info {
display: flex;
flex-direction: column;
gap: 12rpx;
.welcome-title {
font-size: 48rpx;
font-weight: 600;
background: linear-gradient(135deg, #333 0%, #666 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: 1rpx;
line-height: 1.2;
}
.welcome-desc {
font-size: 26rpx;
font-weight: 400;
color: #999;
letter-spacing: 0.5rpx;
line-height: 1.5;
}
}
}
@keyframes slideInDown {
0% {
opacity: 0;
transform: translateY(-20rpx);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
/* 表单区域 - 小米风格 */
.form-area {
animation: fadeInUp 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.2s both;
}
@keyframes fadeInUp {
0% {
opacity: 0;
transform: translateY(15rpx);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
/* 输入框 - 柔和配色 */
.input-wrap {
position: relative;
display: flex;
align-items: center;
margin-bottom: 32rpx;
padding: 0 24rpx;
height: 100rpx;
background: #ffffff;
border: 2rpx solid #e8e8e8;
border-radius: 25rpx;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:last-of-type {
margin-bottom: 48rpx;
}
&.focused {
background: #ffffff;
border-color: #d4af37;
box-shadow: 0 0 0 4rpx rgba(212, 175, 55, 0.08);
transform: translateY(-2rpx);
}
.input {
flex: 1;
width: 100%;
height: 100%;
font-size: 28rpx;
color: #333;
background: transparent;
border: none;
outline: none;
padding: 0;
margin: 0;
line-height: normal;
&::placeholder {
color: #b0b0b0;
}
}
/* 移除 input 组件的默认边框 */
:deep(input) {
border: none !important;
outline: none !important;
}
.pwd-toggle {
width: 44rpx;
height: 44rpx;
margin-left: 16rpx;
flex-shrink: 0;
cursor: pointer;
opacity: 0.5;
transition: all 0.2s;
&:active {
opacity: 1;
transform: scale(1.1);
}
}
}
/* 登录按钮 - 统一橙色 */
.btn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100rpx;
font-size: 32rpx;
font-weight: 500;
color: #fff !important;
background: #ff7c24 !important;
border: none;
border-radius: 25rpx;
letter-spacing: 2rpx;
transition: all 0.3s ease;
cursor: pointer;
overflow: hidden;
box-shadow: 0 4rpx 16rpx rgba(255, 124, 36, 0.3);
padding: 0;
margin: 0;
line-height: 1;
&[disabled] {
opacity: 0.6;
cursor: not-allowed;
}
&::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, transparent, rgba(255, 255, 255, 0.3), transparent);
transform: translateX(-100%);
animation: shimmer 2.5s infinite;
}
&:active:not([disabled]) {
transform: scale(0.98);
box-shadow: 0 2rpx 10rpx rgba(255, 124, 36, 0.35);
}
&::after {
content: none;
border: none;
}
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
50% {
transform: translateX(100%);
}
100% {
transform: translateX(100%);
}
}
</style>

View File

@@ -0,0 +1,103 @@
<template>
<view class="theme-bg min-h-screen p-5" :class="themeClass">
<!-- 主题模式选择 -->
<view class="mb-5">
<view class="theme-text-content mb-5 px-5 text-28rpx font-medium">
主题模式
</view>
<view class="theme-bg-secondary overflow-hidden rounded-12rpx">
<view
v-for="option in themeOptions" :key="option.value"
class="flex items-center border-b px-5 py-7.5 transition-all duration-300"
:class="{ 'theme-bg-secondary': theme === option.value }" @click="setTheme(option.value)"
>
<view class="mr-5">
<view class="text-48rpx c-primary" :class="option.icon" />
</view>
<view class="flex-1">
<text class="theme-text mb-2 block text-28rpx font-medium">
{{ option.label }}
</text>
<text class="theme-text-tips block text-24rpx">
{{ option.description }}
</text>
</view>
<view v-if="theme === option.value" class="ml-5">
<u-icon name="checkmark" color="var(--theme-primary)" size="20" />
</view>
</view>
</view>
</view>
<!-- 主题预览 -->
<view class="mb-5">
<view class="theme-text-content mb-5 px-5 text-28rpx font-medium">
主题预览
</view>
<view class="theme-bg-secondary rounded-12rpx p-7.5">
<view class="mb-7.5">
<text class="theme-text text-28rpx font-medium">
示例内容
</text>
</view>
<view class="mb-7.5 flex flex-wrap gap-5">
<view class="min-w-120rpx flex-1">
<u-button type="primary" text="主要按钮" />
</view>
<view class="min-w-120rpx flex-1">
<u-button type="success" text="成功按钮" />
</view>
<view class="min-w-120rpx flex-1">
<u-button type="warning" text="警告按钮" />
</view>
<view class="min-w-120rpx flex-1">
<u-button type="error" text="错误按钮" />
</view>
</view>
<view class="flex flex-col gap-2.5">
<text class="theme-text text-28rpx font-medium">
主要文字颜色
</text>
<text class="theme-text-content text-26rpx">
内容文字颜色
</text>
<text class="theme-text-tips text-24rpx">
提示文字颜色
</text>
</view>
</view>
</view>
<!-- 快速切换按钮 -->
<view class="py-5 text-center">
<u-button :text="isDark ? '切换到亮色主题' : '切换到暗色主题'" type="primary" @click="toggleTheme" />
</view>
</view>
</template>
<script setup lang="ts">
import type { ThemeMode } from '@/store/modules/app/types';
import { useTheme } from '@/hooks';
import { useAppStore } from '@/store';
const { theme, isDark, setTheme, toggleTheme } = useTheme();
const appStore = useAppStore();
const themeClass = computed(() => `theme-${appStore.theme}`);
// 主题选项
const themeOptions = [
{
label: '亮色主题',
value: 'light' as ThemeMode,
icon: 'i-mdi-white-balance-sunny',
description: '适合明亮环境使用',
},
{
label: '暗色主题',
value: 'dark' as ThemeMode,
icon: 'i-mdi-moon-and-stars',
description: '适合暗光环境使用',
},
];
</script>

View File

@@ -0,0 +1,12 @@
<template>
<web-view class="h-full" :src="url" />
</template>
<script setup lang="ts">
const url = ref<string>('');
onLoad((options: any) => {
if (options.url)
url.value = decodeURIComponent(options.url);
});
</script>

View File

@@ -0,0 +1,58 @@
<template>
<view class="min-h-screen flex flex-col items-center">
<image class="mb-50rpx mt-200rpx h-200rpx w-200rpx" src="@/static/images/logo.png" width="200rpx" height="200rpx" />
<view class="flex justify-center">
<text class="font-size-36rpx">
{{ $t('home.intro') }}
</text>
</view>
<view class="mt-100rpx flex gap-30rpx">
<lang-select />
<view class="cursor-pointer" @click="toGithub">
<view class="i-mdi-github text-40rpx" />
</view>
</view>
<!-- #ifdef MP-WEIXIN -->
<!-- 隐私协议组件 -->
<agree-privacy v-model="showAgreePrivacy" :disable-check-privacy="false" @agree="handleAgree" />
<!-- #endif -->
</view>
</template>
<script setup lang="ts">
// #ifdef MP-WEIXIN
import { useShare } from '@/hooks';
// #endif
// #ifdef MP-WEIXIN
// 分享使用示例
const { onShareAppMessage, onShareTimeline } = useShare({
title: '首页',
path: 'pages/tab/home/index',
imageUrl: '',
});
onShareAppMessage();
onShareTimeline();
// #endif
const title = ref<string>();
title.value = import.meta.env.VITE_APP_TITLE;
const showAgreePrivacy = ref(false);
// 同意隐私协议
function handleAgree() {
console.log('同意隐私政策');
}
// 打开github
function toGithub() {
if (window?.open) {
window.open('https://github.com/oyjt/uniapp-vue3-template');
}
else {
uni.$u.toast('请使用浏览器打开');
}
}
</script>