fix:bug
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 1m5s

This commit is contained in:
sexygoat
2026-05-14 09:50:34 +08:00
parent 7f15843da0
commit f9fe76ff99
28 changed files with 1440 additions and 2518 deletions

View File

@@ -1,5 +1,5 @@
const fs = require('fs')
const content = fs.readFileSync('src/pages/agent-system/asset-search/index.vue', 'utf8')
const content = fs.readFileSync('src/pages/agent-system/asset-detail/index.vue', 'utf8')
const lines = content.split('\n')
let depth = 0

21
docs/权限按钮.md Normal file
View File

@@ -0,0 +1,21 @@
/api/auth/login 登录接口返回了权限按钮
user:{
"id": 144,
"username": "MLLxp",
"phone": "15571055000",
"user_type": 3,
"user_type_name": "代理账号",
"shop_id": 1
},
"buttons": [
"device:clear_series",
]
这个地方有返回按钮的权限
<!-- IoT卡操作按钮 -->
<!-- 设备操作按钮 -->
资产列表里面要加这个权限编码,然后判断按钮是否显示

View File

@@ -18,7 +18,7 @@
### 联动范围
- 资产搜索页中的同类套餐展示:`src/pages/agent-system/asset-search/index.vue`
- 资产详情页中的同类套餐展示:`src/pages/agent-system/asset-detail/index.vue`
### 本次覆盖的 UI 区块
@@ -140,7 +140,7 @@
### 关键实现要求
- `getPackageRemainMb` 必须改为基于“当前展示的已使用值”计算
- `asset-detail` `asset-search` 不允许维护不同口径的同名逻辑
- `asset-detail` 页面内不同区块不允许维护不同口径的同名逻辑
- 格式化继续沿用现有 `formatDataSize`,本次不单独引入新的单位换算规则
---
@@ -213,7 +213,7 @@
### 验收用例 4跨页面一致性
- 同一套餐数据在 `asset-detail` `asset-search`展示结果一致
- 同一套餐数据在 `asset-detail` 页面各区块中的展示结果一致
- 当前生效套餐卡片与套餐列表摘要使用同一套字段口径
---

2
env/.env.production vendored
View File

@@ -2,7 +2,7 @@
VITE_APP_ENV=production
# 接口地址
VITE_API_BASE_URL=https://cmp-api.boss160.cn
VITE_API_BASE_URL=https://cmp-api.xm-iot.cn
# 是否使用代理 (生产环境不使用代理)
VITE_APP_PROXY=false

2
env/.env.test vendored
View File

@@ -2,7 +2,7 @@
VITE_APP_ENV=staging
# 接口地址
VITE_API_BASE_URL=https://cmp-api.boss160.cn
VITE_API_BASE_URL=https://cmp-api.xm-iot.cn
# 是否使用代理 (测试环境不使用代理)
VITE_APP_PROXY=false

View File

@@ -0,0 +1,25 @@
# Change: Update agent asset action button permissions
## Why
The login response already returns button-level permission codes, but the agent asset list still renders IoT card and device action buttons without using those codes. This leaves action visibility disconnected from backend authorization and makes button exposure inconsistent with the authenticated session.
## What Changes
- Define agent asset-list behavior for consuming button permission codes returned from `POST /api/auth/login`
- Require each IoT card action button and device action button in the agent asset list to be bound to an explicit H5 permission code:
- IoT卡停机`asset:card_stop_h5`
- IoT卡复机`asset:card_start_h5`
- 设备停机:`asset:device_stop_h5`
- 设备复机:`asset:device_start_h5`
- Hide action buttons when the current session does not contain the corresponding permission code
- Keep asset status text and detail-page entry visible even when action buttons are hidden
## Impact
- Affected specs:
- `agent-asset-list`
- Affected code:
- `src/api/auth.ts`
- `src/pages/common/login/index.vue`
- `src/pages/agent-system/assets/index.vue`

View File

@@ -0,0 +1,46 @@
## ADDED Requirements
### Requirement: Agent Asset Action Buttons SHALL Respect Session Button Permissions
The agent asset list SHALL use the backend-provided button permission codes from the authenticated session to determine whether IoT card and device action buttons are rendered. The page SHALL use the following H5-specific permission-code mapping:
- IoT卡停机`asset:card_stop_h5`
- IoT卡复机`asset:card_start_h5`
- 设备停机:`asset:device_stop_h5`
- 设备复机:`asset:device_start_h5`
#### Scenario: Login response returns button permission codes
- **WHEN** `POST /api/auth/login` succeeds
- **THEN** the frontend SHALL treat the response `buttons` array as the source of button-level permissions for the authenticated session
- **AND** the session data used by the agent asset list SHALL preserve those button permission codes
#### Scenario: Asset action has its mapped H5 permission code and the code is present
- **WHEN** an IoT card or device action button in the agent asset list has one of the mapped H5 permission codes
- **AND** that permission code exists in the current session `buttons` array
- **THEN** the corresponding action button SHALL be rendered
#### Scenario: Asset action has its mapped H5 permission code and the code is absent
- **WHEN** an IoT card or device action button in the agent asset list has one of the mapped H5 permission codes
- **AND** that permission code does not exist in the current session `buttons` array
- **THEN** the corresponding action button SHALL NOT be rendered
#### Scenario: Session includes only a subset of asset action permissions
- **WHEN** the current session `buttons` array contains permission codes for only some of the asset actions
- **THEN** only the matching asset action buttons SHALL be shown
- **AND** non-matching asset action buttons SHALL remain hidden
#### Scenario: Session includes unrelated button codes
- **WHEN** the current session `buttons` array includes unrelated button codes such as `device:clear_series`
- **THEN** no asset action button SHALL become visible unless its own mapped H5 permission code is also present
#### Scenario: Action permissions are absent but the list item remains accessible
- **WHEN** the current session does not grant any asset action button permissions for a list item
- **THEN** the asset status text SHALL still be shown
- **AND** the detail-page entry affordance for the list item SHALL remain available
- **AND** the page SHALL NOT render placeholder or disabled action buttons in place of the hidden ones

View File

@@ -0,0 +1,16 @@
## 1. Spec
- [x] 1.1 Review `docs/权限按钮.md` and confirm the login response is the source of button permission codes
- [x] 1.2 Add OpenSpec proposal, tasks, and `agent-asset-list` delta spec for action-button permissions
- [x] 1.3 Validate the new change with strict OpenSpec validation
## 2. Implementation
- [x] 2.1 Extend login response typing and session persistence for the backend-provided `buttons` array
- [x] 2.2 Define explicit H5 permission-code mapping for agent asset-list IoT card and device action buttons:
- `asset:card_stop_h5`
- `asset:card_start_h5`
- `asset:device_stop_h5`
- `asset:device_start_h5`
- [x] 2.3 Gate asset-list action-button rendering by the current session button permissions
- [x] 2.4 Verify card and device action visibility for sessions with and without matching button permission codes

View File

@@ -29,6 +29,5 @@ The current agent-side asset experience exposes too much low-value information,
- `agent-asset-detail`
- Affected code:
- `src/pages/agent-system/assets/index.vue`
- `src/pages/agent-system/asset-search/index.vue`
- `src/pages/agent-system/asset-detail/index.vue`
- `src/api/assets.ts`

View File

@@ -49,8 +49,7 @@ This project is a Uni-app based agent and enterprise management frontend for IoT
- Asset identifiers may be ICCID or VirtualNo depending on the resource and entry point.
- Agent-side pages currently include:
- asset list: `src/pages/agent-system/assets/index.vue`
- asset search/detail: `src/pages/agent-system/asset-search/index.vue`
- secondary asset detail page: `src/pages/agent-system/asset-detail/index.vue`
- asset detail: `src/pages/agent-system/asset-detail/index.vue`
- Package display and realtime status are important parts of the asset detail experience.
## Important Constraints

View File

@@ -9,6 +9,7 @@ import { get, post, put } from '@/utils/request'
export interface LoginParams {
username: string
password: string
device?: 'h5'
}
/**
@@ -18,6 +19,7 @@ export interface LoginResponse {
access_token: string
refresh_token: string
expires_in: number
buttons?: string[]
user: {
id: number
username: string
@@ -47,6 +49,7 @@ export interface UserInfo {
status?: 'active' | 'inactive'
create_time?: string
last_login_time?: string
buttons?: string[]
}
/**
@@ -62,7 +65,12 @@ export interface ChangePasswordParams {
* POST /api/auth/login
*/
export function login(data: LoginParams) {
return post<LoginResponse>('/api/auth/login', { data })
return post<LoginResponse>('/api/auth/login', {
data: {
...data,
device: data.device ?? 'h5',
},
})
}
/**

View File

@@ -15,30 +15,6 @@
"navigationStyle": "custom"
}
},
{
"path": "pages/agent-system/my-packages/index",
"style": {
"navigationBarTitleText": "我的套餐",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/agent-system/commission/index",
"style": {
"navigationBarTitleText": "我的佣金",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/agent-system/packages/index",
"style": {
"navigationBarTitleText": "套餐列表",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/agent-system/assets/index",
"style": {
@@ -47,22 +23,6 @@
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/agent-system/tags/index",
"style": {
"navigationBarTitleText": "标签管理",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/agent-system/asset-search/index",
"style": {
"navigationBarTitleText": "资产详情",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/agent-system/asset-detail/index",
"style": {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -71,6 +71,14 @@
// 用户信息
const userInfo = ref<UserInfo | null>(null)
const sessionButtonCodes = ref<string[]>([])
const ASSET_ACTION_BUTTON_CODES = {
cardStop: 'asset:card_stop_h5',
cardStart: 'asset:card_start_h5',
deviceStop: 'asset:device_stop_h5',
deviceStart: 'asset:device_start_h5',
} as const
// 获取单卡列表(代理端)
function getStandaloneCards(params : any) {
@@ -130,10 +138,36 @@
const carrierList = ref<CarrierInfo[]>([])
const isAgent = computed(() => userInfo.value?.user_type === 3)
const searchPlaceholder = computed(() => activeTab.value === 'card' ? '搜索ICCID' : '搜索设备号')
const canShowDeviceStopButton = computed(() => hasActionButtonPermission(ASSET_ACTION_BUTTON_CODES.deviceStop))
const canShowDeviceStartButton = computed(() => hasActionButtonPermission(ASSET_ACTION_BUTTON_CODES.deviceStart))
function hasStatusCode(error : unknown) {
return typeof error === 'object' && error !== null && 'statusCode' in error
}
function getSessionButtonCodes() {
const cachedUserInfo = uni.getStorageSync('user_info')
if (!cachedUserInfo || typeof cachedUserInfo !== 'object' || !Array.isArray((cachedUserInfo as UserInfo).buttons)) {
return []
}
return (cachedUserInfo as UserInfo).buttons!.filter((code) => {
return typeof code === 'string' && code.trim().length > 0
})
}
function hasActionButtonPermission(code : string) {
return sessionButtonCodes.value.includes(code)
}
function canShowCardStopButton(card : CardInfo) {
return card.network_status !== 0 && hasActionButtonPermission(ASSET_ACTION_BUTTON_CODES.cardStop)
}
function canShowCardStartButton(card : CardInfo) {
return card.network_status !== 1 && hasActionButtonPermission(ASSET_ACTION_BUTTON_CODES.cardStart)
}
// 运营商选择器选项
const carrierOptions = computed(() => [
{ label: '全部运营商', value: undefined },
@@ -410,12 +444,12 @@
if (asset.type === 'card') {
const card = asset.raw as CardInfo
uni.navigateTo({
url: `/pages/agent-system/asset-search/index?iccid=${card.iccid}`,
url: `/pages/agent-system/asset-detail/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}`,
url: `/pages/agent-system/asset-detail/index?virtual_no=${device.virtual_no}`,
})
}
}
@@ -575,6 +609,7 @@
onMounted(async () => {
// 1. 获取状态栏高度
statusBarHeight.value = uni.getSystemInfoSync().statusBarHeight || 20
sessionButtonCodes.value = getSessionButtonCodes()
// 2. 加载用户信息(判断是企业端还是代理端)
try {
@@ -646,8 +681,7 @@
<view v-if="isAgent"
class="flex items-center gap-1.5 px-3 py-1.5 bg-[#f8fafc] rounded-full flex-shrink-0 max-w-36"
@click="showShopPicker = true">
<text class="text-12px truncate"
:class="selectedShopFilter ? 'text-brand font-medium' : 'text-[#64748b]'">
<text class="text-12px truncate" :class="selectedShopFilter ? 'text-brand font-medium' : 'text-[#64748b]'">
{{ selectedShopName }}
</text>
<i class="i-mdi-chevron-down text-12px text-[#94a3b8] flex-shrink-0" />
@@ -716,42 +750,30 @@
<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.5 rounded-10px text-12px font-medium bg-[#52c41a] text-white shadow-sm shadow-[#52c41a]/30 active:scale-95 transition-transform"
@click="handleStartCard(asset, $event)">
复机
</view>
</template>
<template v-else-if="(asset.raw as CardInfo).network_status === 1">
<view
class="px-3 py-1.5 rounded-10px text-12px font-medium bg-[#ff4d4f] text-white shadow-sm shadow-[#ff4d4f]/30 active:scale-95 transition-transform"
@click="handleStopCard(asset, $event)">
停机
</view>
</template>
<template v-else>
<view
v-if="canShowCardStopButton(asset.raw as CardInfo)"
class="px-3 py-1.5 rounded-10px text-12px font-medium bg-[#ff4d4f] text-white shadow-sm shadow-[#ff4d4f]/30 active:scale-95 transition-transform"
@click="handleStopCard(asset, $event)">
停机
</view>
<view
v-if="canShowCardStartButton(asset.raw as CardInfo)"
class="px-3 py-1.5 rounded-10px text-12px font-medium bg-[#52c41a] text-white shadow-sm shadow-[#52c41a]/30 active:scale-95 transition-transform"
@click="handleStartCard(asset, $event)">
复机
</view>
</template>
</template>
<!-- 设备操作按钮 -->
<template v-else>
<view
v-if="canShowDeviceStopButton"
class="px-3 py-1.5 rounded-10px text-12px font-medium bg-[#ff4d4f] text-white shadow-sm shadow-[#ff4d4f]/30 active:scale-95 transition-transform"
@click="handleStopDevice(asset, $event)">
停机
</view>
<view
v-if="canShowDeviceStartButton"
class="px-3 py-1.5 rounded-10px text-12px font-medium bg-[#52c41a] text-white shadow-sm shadow-[#52c41a]/30 active:scale-95 transition-transform"
@click="handleStartDevice(asset, $event)">
复机
@@ -766,11 +788,10 @@
<!-- 加载更多 -->
<view v-if="activeTabHasMore" class="py-4 flex justify-center">
<view
class="min-w-32 px-5 py-2.5 rounded-full text-sm font-medium transition-all"
class="min-w-32 px-5 py-2.5 rounded-full text-sm font-medium transition-all flex items-center justify-center"
:class="loading
? 'bg-[#f1f5f9] text-[#94a3b8]'
: 'bg-brand text-white shadow-sm shadow-brand/30 active:scale-95'"
@click="!loading && loadMore()">
: 'bg-brand text-white shadow-sm shadow-brand/30 active:scale-95'" @click="!loading && loadMore()">
<template v-if="loading">
<i class="i-mdi-loading animate-spin text-base mr-1.5" />
<text>加载中...</text>

View File

@@ -261,7 +261,6 @@ function loadMore() {
</view>
<view class="flex items-center justify-between text-12px text-[#999]">
<text>{{ formatTime(record.created_at) }}</text>
<text v-if="record.order_id">订单 {{ record.order_id }}</text>
</view>
</view>

View File

@@ -1,16 +0,0 @@
<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

@@ -54,7 +54,7 @@ const stats = computed(() => {
async function loadCarriers() {
try {
const response = await getCarriers({ page: 1, size: 50 })
const response = await getCarriers({ page: 1, size: 100 })
carrierList.value = response?.items || []
} catch (error) {
console.error('加载运营商列表失败:', error)
@@ -128,7 +128,7 @@ async function loadEnterpriseCards(isRefresh = false) {
function viewCardDetail(card: any) {
uni.navigateTo({
url: `/pages/agent-system/asset-search/index?iccid=${card.iccid}`,
url: `/pages/agent-system/asset-detail/index?iccid=${card.iccid}`,
})
}

View File

@@ -74,7 +74,7 @@ async function loadEnterpriseDevices(isRefresh = false) {
function viewDeviceDetail(device: any) {
uni.navigateTo({
url: `/pages/agent-system/asset-search/index?virtual_no=${device.virtual_no}`,
url: `/pages/agent-system/asset-detail/index?virtual_no=${device.virtual_no}`,
})
}

View File

@@ -94,8 +94,6 @@
shopId.value = user.shop_id || loginUserInfo?.shop_id || 0
const fundSummaryUsername = loginUserInfo?.username || user.username
console.log('首页数据加载成功:', { user })
// 如果是代理账号,加载佣金数据
if (user.user_type === 3) {
await loadCommissionData(fundSummaryUsername)

View File

@@ -1,23 +0,0 @@
<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

@@ -1,245 +0,0 @@
<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-[var(--brand-primary)] 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-[var(--brand-primary)]" />
<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

@@ -1,23 +0,0 @@
<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

@@ -43,7 +43,6 @@ async function loadUserInfo() {
try {
// 调用 API 获取用户信息
const data = await getUserInfo()
console.log('获取用户信息成功:', data)
userInfo.value = data
}
catch (error: any) {

View File

@@ -1,77 +0,0 @@
// 套餐状态枚举
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

@@ -106,6 +106,10 @@ async function submit() {
username: account.value.trim(),
password: password.value.trim(),
});
const sessionUser = {
...res.user,
buttons: Array.isArray(res.buttons) ? res.buttons : [],
};
// 保存 access_token
setToken(res.access_token);
@@ -113,12 +117,6 @@ async function submit() {
// 保存 refresh_token 到本地存储
uni.setStorageSync('refresh_token', res.refresh_token);
// 保存用户信息到本地存储
uni.setStorageSync('user_info', res.user);
// 调试日志:查看用户信息
console.log('登录成功,用户信息:', res.user);
// 验证用户类型 (只允许 3=代理 和 4=企业 登录)
if (res.user.user_type !== 3 && res.user.user_type !== 4) {
const typeNames: Record<number, string> = {
@@ -139,9 +137,12 @@ async function submit() {
return;
}
// 保存用户信息到本地存储
uni.setStorageSync('user_info', sessionUser);
uni.showToast({
title: '登录成功',
icon: 'success',
icon: 'none',
duration: 1500,
});

View File

@@ -2,7 +2,7 @@ import type { providerType, UserState } from './types';
import type { LoginReq } from '@/api/user/types';
import { defineStore } from 'pinia';
import { UserApi } from '@/api';
import { refreshToken } from '@/api/auth';
import { refreshToken, type UserInfo } from '@/api/auth';
import { clearToken, setToken } from '@/utils/auth';
@@ -82,12 +82,22 @@ const useUserStore = defineStore('user', {
try {
const res = await refreshToken(refresh_token);
const rawUserInfo = uni.getStorageSync('user_info');
const cachedUserInfo = rawUserInfo && typeof rawUserInfo === 'object'
? rawUserInfo as Partial<UserInfo>
: {};
const cachedButtons = Array.isArray(cachedUserInfo.buttons) ? cachedUserInfo.buttons : [];
const sessionUser = {
...cachedUserInfo,
...res.user,
buttons: Array.isArray(res.buttons) ? res.buttons : cachedButtons,
};
// 更新 access_token
setToken(res.access_token);
// 更新 refresh_token
uni.setStorageSync('refresh_token', res.refresh_token);
// 更新用户信息
uni.setStorageSync('user_info', res.user);
uni.setStorageSync('user_info', sessionUser);
return res;
}
catch (error) {

View File

@@ -29,7 +29,7 @@ export function setupRequest() {
export function request<T = any>(config: HttpRequestConfig): Promise<T> {
return new Promise((resolve, reject) => {
http.request(config).then((res: HttpResponse<IResponse<T>>) => {
console.log('[ res ] >', res);
// console.log('[ res ] >', res);
const { data } = res.data;
resolve(data as T);
}).catch((err: any) => {