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

41
src/App.vue Normal file
View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import { onHide, onLaunch, onShow } from '@dcloudio/uni-app';
import { useAppStore } from '@/store';
// #ifdef MP-WEIXIN
import { mpUpdate } from '@/utils/index';
// #endif
const appStore = useAppStore();
onLaunch(() => {
console.log('App Launch');
// 初始化系统信息
appStore.initSystemInfo();
// #ifdef H5
// 隐藏加载动画
setTimeout(() => {
document.body.classList.add('app-loaded');
}, 100);
// #endif
// #ifdef MP-WEIXIN
mpUpdate();
// #endif
});
onShow(() => {
console.log('App Show');
});
onHide(() => {
console.log('App Hide');
});
</script>
<style lang="scss">
/* 每个页面公共css */
@import 'uview-plus/index.scss';
@import '@/static/styles/common.scss';
</style>

398
src/api/assets.ts Normal file
View File

@@ -0,0 +1,398 @@
/**
* 资产模块 API
*/
import { get, post } from '@/utils/request'
/**
* 资产类型
*/
export type AssetType = 'card' | 'device'
/**
* 资产状态
*/
export type AssetStatus = 'active' | 'inactive' | 'suspended' | 'online' | 'offline' | 'fault'
/**
* 资产解析响应
*/
export interface AssetResolveResponse {
asset_type: 'card' | 'device'
asset_id: number
virtual_no: string
status: number
batch_no?: string
first_commission_paid?: boolean
accumulated_recharge?: number
created_at?: string
updated_at?: string
real_name_status?: number
current_package?: string
package_total_mb?: number
package_used_mb?: number
package_remain_mb?: number
// 卡片特有字段
iccid?: string
carrier_id?: number
msisdn?: string
card_category?: string
enable_polling?: boolean
// 设备特有字段
shop_id?: number
shop_name?: string
series_id?: number
series_name?: string
device_protect_status?: string
bound_card_count?: number
cards?: Array<{
card_id: number
iccid: string
msisdn: string
network_status: number
real_name_status: number
slot_position: number
is_current: boolean
}>
device_name?: string
imei?: string
device_model?: string
device_type?: string
max_sim_slots?: number
manufacturer?: string
last_online_time?: string
switch_mode?: string
last_gateway_sync_at?: string
[key: string]: any
}
/**
* 卡片信息
*/
export interface CardInfo {
id: number | string
iccid: string
msisdn?: string // 电话号码
virtual_no?: string // 虚拟号
virtual_number?: string // 虚拟号 (别名兼容)
imei?: string
carrier_id?: number // 运营商ID
carrier_name?: string // 运营商名称
operator?: string // 运营商 (别名兼容)
package_name?: string // 套餐名称
current_package?: string // 当前套餐 (别名兼容)
status?: number // 卡状态 (1=在库, 2=激活, 3=停用等)
status_name?: string // 状态名称
network_status?: number // 网络状态 (0=停机, 1=正常等)
network_status_name?: string // 网络状态名称
data_usage?: string
data_total?: string
expire_date?: string
activate_time?: string
enterprise_id?: number
enterprise_name?: string
device_id?: string
device_name?: string
[key: string]: any
}
/**
* 设备信息
*/
export interface DeviceInfo {
id?: number | string
device_id?: number // 设备ID
virtual_no?: string // 设备虚拟号
device_name?: string // 设备名称
device_model?: string // 设备型号
card_count?: number // 绑定卡数量
authorized_at?: string // 授权时间
sn?: string
device_type?: string
status?: 'online' | 'offline' | 'fault'
location?: string
last_online_time?: string
create_time?: string
enterprise_id?: number
enterprise_name?: string
[key: string]: any
}
/**
* 资产详情响应
*/
export interface AssetDetailResponse {
type: AssetType
card?: CardInfo
device?: DeviceInfo
}
/**
* 历史记录
*/
export interface HistoryRecord {
id: number
time: string
action: string
operator?: string
details?: string
[key: string]: any
}
/**
* 操作参数
*/
export interface OperationParams {
id: string
reason?: string
}
/**
* 资产解析 (智能识别)
* GET /api/admin/assets/resolve/:identifier
*/
export function resolveAsset(identifier: string) {
return get<AssetResolveResponse>(`/api/admin/assets/resolve/${identifier}`)
}
/**
* 获取资产详情
* GET /api/admin/assets/:type/:id
*/
export function getAssetDetail(type: AssetType, id: string) {
return get<AssetDetailResponse>(`/api/admin/assets/${type}/${id}`)
}
/**
* 获取卡片详情
* GET /api/admin/cards/:id
*/
export function getCardDetail(id: string) {
return get<CardInfo>(`/api/admin/cards/${id}`)
}
/**
* 获取设备详情
* GET /api/admin/devices/:id
*/
export function getDeviceDetail(id: string) {
return get<DeviceInfo>(`/api/admin/devices/${id}`)
}
/**
* 设备实时信息
*/
export interface DeviceRealtime {
device_id?: string
device_name?: string
device_type?: string
imei?: string
imsi?: string
mac_address?: string
software_version?: string
online_status?: number
status?: number
battery_level?: number
client_number?: number
max_clients?: number
connect_time?: string
run_time?: string
daily_usage?: string
dl_stats?: string
ul_stats?: string
limit_speed?: number
ip_address?: string
lan_ip?: string
wan_ip?: string
ssid?: string
wifi_enabled?: boolean
wifi_password?: string
current_iccid?: string
switch_mode?: string
rsrp?: number
rsrq?: number
rssi?: string
sinr?: number
last_online_time?: string
last_update_time?: string
sync_interval?: number
gateway_msg?: string | null
}
/**
* 资产实时状态响应
*/
export interface AssetRealtimeStatus {
asset_id: number
asset_type: 'card' | 'device'
current_month_usage_mb?: number | null
last_sync_time?: string | null
network_status?: number | null
real_name_status?: number | null
device_protect_status?: string | null
cards?: Array<{
card_id: number
iccid: string
is_current: boolean
msisdn: string
network_status: number
real_name_status: number
slot_position: number
}>
device_realtime?: DeviceRealtime | null
}
/**
* 获取资产实时状态
* GET /api/admin/assets/:asset_type/:id/realtime-status
*/
export function getAssetRealtimeStatus(assetType: AssetType, id: number) {
return get<AssetRealtimeStatus>(`/api/admin/assets/${assetType}/${id}/realtime-status`)
}
/**
* 资产套餐信息
*/
export interface AssetPackage {
package_id: number
package_name: string
package_type: 'formal' | 'addon'
status: number
status_name: string
usage_type: string
priority: number
package_usage_id: number
master_usage_id: number | null
data_limit_mb: number
data_usage_mb: number
virtual_limit_mb: number
virtual_used_mb: number
virtual_remain_mb: number
virtual_ratio: number
created_at: string
activated_at: string | null
expires_at: string | null
}
/**
* 获取资产套餐列表
* GET /api/admin/assets/:asset_type/:id/packages
*/
export function getAssetPackages(assetType: AssetType, id: number, params?: { page?: number, page_size?: number }) {
return get<{
items: AssetPackage[]
page: number
page_size: number
total: number
}>(`/api/admin/assets/${assetType}/${id}/packages`, { params })
}
/**
* 获取当前生效套餐
* GET /api/admin/assets/:asset_type/:id/current-package
*/
export function getCurrentPackage(assetType: AssetType, id: number) {
return get<AssetPackage>(`/api/admin/assets/${assetType}/${id}/current-package`)
}
/**
* 获取资产历史记录
* GET /api/admin/assets/:type/:id/history
*/
export function getAssetHistory(type: AssetType, id: string, params?: { page?: number, pageSize?: number }) {
return get<{
list: HistoryRecord[]
total: number
page: number
pageSize: number
}>(`/api/admin/assets/${type}/${id}/history`, { params })
}
/**
* 停用卡片
* POST /api/admin/cards/:id/stop
*/
export function stopCard(data: OperationParams) {
return post<void>(`/api/admin/cards/${data.id}/stop`, {
data: { reason: data.reason },
})
}
/**
* 启用卡片
* POST /api/admin/cards/:id/start
*/
export function startCard(data: OperationParams) {
return post<void>(`/api/admin/cards/${data.id}/start`, {
data: { reason: data.reason },
})
}
/**
* 停用设备
* POST /api/admin/devices/:id/stop
*/
export function stopDevice(data: OperationParams) {
return post<void>(`/api/admin/devices/${data.id}/stop`, {
data: { reason: data.reason },
})
}
/**
* 启用设备
* POST /api/admin/devices/:id/start
*/
export function startDevice(data: OperationParams) {
return post<void>(`/api/admin/devices/${data.id}/start`, {
data: { reason: data.reason },
})
}
/**
* 设备停机失败项
*/
export interface DeviceStopFailedItem {
iccid: string
reason: string
}
/**
* 设备停机响应
*/
export interface DeviceStopResponse {
success_count: number
fail_count: number
skip_count: number
failed_items: DeviceStopFailedItem[]
}
/**
* 设备停机 (批量停机设备下所有已实名卡)
* POST /api/admin/assets/device/:device_id/stop
*/
export function stopDeviceAsset(deviceId: number) {
return post<DeviceStopResponse>(`/api/admin/assets/device/${deviceId}/stop`)
}
/**
* 设备复机 (批量复机设备下所有已实名卡)
* POST /api/admin/assets/device/:device_id/start
*/
export function startDeviceAsset(deviceId: number) {
return post<void>(`/api/admin/assets/device/${deviceId}/start`)
}
/**
* 单卡停机 (通过ICCID)
* POST /api/admin/assets/card/:iccid/stop
*/
export function stopCardByIccid(iccid: string) {
return post<void>(`/api/admin/assets/card/${iccid}/stop`)
}
/**
* 单卡复机 (通过ICCID)
* POST /api/admin/assets/card/:iccid/start
*/
export function startCardByIccid(iccid: string) {
return post<void>(`/api/admin/assets/card/${iccid}/start`)
}

111
src/api/auth.ts Normal file
View File

@@ -0,0 +1,111 @@
/**
* 认证模块 API
*/
import { get, post } from '@/utils/request'
/**
* 登录请求参数
*/
export interface LoginParams {
username: string
password: string
}
/**
* 登录响应
*/
export interface LoginResponse {
access_token: string
refresh_token: string
expires_in: number
user: {
id: number
username: string
user_type: 1 | 2 | 3 | 4 // 1=超级管理员 2=平台 3=代理 4=企业
enterprise_id?: number
shop_id?: number
}
}
/**
* 用户信息
*/
export interface UserInfo {
id: number
username: string
phone: string
user_type: 1 | 2 | 3 | 4
user_type_name: string
shop_id?: number
enterprise_id?: number
// 以下字段为可选扩展字段(根据实际接口返回)
nickname?: string
shop_name?: string
enterprise_name?: string
email?: string
avatar?: string
status?: 'active' | 'inactive'
create_time?: string
last_login_time?: string
}
/**
* 修改密码参数
*/
export interface ChangePasswordParams {
old_password: string
new_password: string
}
/**
* 登录
* POST /api/auth/login
*/
export function login(data: LoginParams) {
return post<LoginResponse>('/api/auth/login', { data })
}
/**
* 登出
* POST /api/auth/logout
*/
export function logout() {
return post<void>('/api/auth/logout')
}
/**
* 获取当前用户信息响应
*/
export interface GetUserInfoResponse {
user: UserInfo
permissions: string[]
}
/**
* 获取当前用户信息
* GET /api/auth/me
*/
export function getUserInfo() {
return get<GetUserInfoResponse>('/api/auth/me').then(res => res.user)
}
/**
* 刷新Token
* POST /api/auth/refresh
*/
export function refreshToken(refresh_token: string) {
return post<LoginResponse>('/api/auth/refresh', {
data: { refresh_token },
})
}
/**
* 修改密码
* PUT /api/auth/password
*/
export function changePassword(data: ChangePasswordParams) {
return post<void>('/api/auth/password', {
data,
method: 'PUT',
})
}

47
src/api/carrier.ts Normal file
View File

@@ -0,0 +1,47 @@
/**
* 运营商模块 API
*/
import { get } from '@/utils/request'
/**
* 运营商信息
*/
export interface CarrierInfo {
id: number
carrier_code: string
carrier_name: string
carrier_type: string // CTCC, CMCC, CUCC
description?: string
data_reset_day?: number
realname_link_type?: string
realname_link_template?: string
status?: number
created_at?: string
updated_at?: string
}
/**
* 运营商列表参数
*/
export interface CarrierListParams {
page?: number
size?: number
}
/**
* 列表响应
*/
export interface CarrierListResponse {
items: CarrierInfo[]
total: number
page: number
size: number
}
/**
* 获取运营商列表
* GET /api/admin/carriers
*/
export function getCarriers(params?: CarrierListParams) {
return get<CarrierListResponse>('/api/admin/carriers', { params })
}

128
src/api/commission.ts Normal file
View File

@@ -0,0 +1,128 @@
/**
* 佣金模块 API (代理端)
*/
import { get, post } from '@/utils/request'
/**
* 佣金概览
*/
export interface CommissionSummary {
shop_id: number
shop_name: string
total_commission: number // 累计佣金(分)
available_commission: number // 可提现佣金(分)
frozen_commission: number // 冻结佣金(分)
unwithdraw_commission: number // 未提现佣金(分)
withdrawing_commission: number // 提现中金额(分)
withdrawn_commission: number // 已提现佣金(分)
}
/**
* 佣金记录
*/
export interface CommissionRecord {
id: number
amount: number // 佣金金额(分)
commission_source: string // 佣金来源: cost_diff | one_time | tier_bonus
status: number // 状态: 1=已入账, 2=已失效
status_name: string
order_id: number
shop_id: number
created_at: string
}
/**
* 佣金统计
*/
export interface CommissionStats {
total_amount: number // 总佣金(分)
total_count: number // 总笔数
cost_diff_amount: number // 成本价差收入(分)
cost_diff_count: number // 成本价差笔数
cost_diff_percent: number // 成本价差占比(千分比)
one_time_amount: number // 一次性佣金(分)
one_time_count: number // 一次性佣金笔数
one_time_percent: number // 一次性佣金占比(千分比)
}
/**
* 每日佣金统计
*/
export interface DailyCommissionStats {
date: string // 日期 YYYY-MM-DD
total_amount: number // 当日佣金(分)
total_count: number // 当日笔数
}
/**
* 佣金记录查询参数
*/
export interface CommissionRecordsParams {
page?: number
page_size?: number
commission_source?: string // 佣金来源
iccid?: string // ICCID模糊查询
virtual_no?: string // 设备虚拟号(模糊查询)
order_no?: string // 订单号(模糊查询)
}
/**
* 佣金统计查询参数
*/
export interface CommissionStatsParams {
shop_id?: number
start_time?: string
end_time?: string
}
/**
* 每日佣金统计查询参数
*/
export interface DailyCommissionStatsParams {
shop_id?: number
start_date?: string // YYYY-MM-DD
end_date?: string // YYYY-MM-DD
days?: number // 查询天数默认30最大365
}
/**
* 列表响应
*/
export interface ListResponse<T> {
items: T[]
page: number
size: number
total: number
}
/**
* 获取我的佣金概览
* GET /api/admin/my/commission-summary
*/
export function getCommissionSummary() {
return get<CommissionSummary>('/api/admin/my/commission-summary')
}
/**
* 获取我的佣金明细
* GET /api/admin/my/commission-records
*/
export function getCommissionRecords(params?: CommissionRecordsParams) {
return get<ListResponse<CommissionRecord>>('/api/admin/my/commission-records', { params })
}
/**
* 获取我的佣金统计
* GET /api/admin/my/commission-stats
*/
export function getCommissionStats(params?: CommissionStatsParams) {
return get<CommissionStats>('/api/admin/my/commission-stats', { params })
}
/**
* 获取我的每日佣金统计
* GET /api/admin/my/commission-daily-stats
*/
export function getCommissionDailyStats(params?: DailyCommissionStatsParams) {
return get<DailyCommissionStats[]>('/api/admin/my/commission-daily-stats', { params })
}

12
src/api/common/index.ts Normal file
View File

@@ -0,0 +1,12 @@
/**
* 通用接口
*/
import type { SendCodeReq, SendCodeRes, UploadRes } from './types';
import { post, upload } from '@/utils/request';
// 文件上传
export const uploadFile = (filePath: string) =>
upload<UploadRes>('/common/upload', { filePath, name: 'file' });
// 发送验证码
export const sendCode = (data: SendCodeReq) => post<SendCodeRes>('/sendCode', { data });

21
src/api/common/types.ts Normal file
View File

@@ -0,0 +1,21 @@
export interface CommonReq {
[key: string]: any;
}
export interface CommonRes {
[key: string]: any;
}
export interface UploadRes {
file: string;
url: string;
}
export interface SendCodeReq {
phone: number;
code: number;
}
export interface SendCodeRes {
code: number;
}

75
src/api/enterprise.ts Normal file
View File

@@ -0,0 +1,75 @@
/**
* 企业资产模块 API (企业端/代理端查看企业资产)
* 用于代理端查看企业的授权资产列表
*/
import { get } from '@/utils/request'
import type { CardInfo, DeviceInfo } from './assets'
/**
* 企业信息
*/
export interface EnterpriseInfo {
id: number
name: string
total_cards?: number
total_devices?: number
[key: string]: any
}
/**
* 企业卡片列表参数
*/
export interface EnterpriseCardsParams {
page?: number
page_size?: number
status?: number // 卡状态 (具体值待后端确认)
carrier_id?: number // 运营商ID
iccid?: string // ICCID (模糊查询)
virtual_no?: string // 虚拟号 (模糊查询)
}
/**
* 企业设备列表参数
*/
export interface EnterpriseDevicesParams {
page?: number
page_size?: number
virtual_no?: string // 虚拟号 (模糊搜索)
}
/**
* 列表响应
*/
export interface ListResponse<T> {
items: T[]
total: number
page?: number
size?: number
}
/**
* 获取企业卡片列表
* GET /api/admin/enterprises/:id/cards
* @param id 企业ID (来自登录返回的 user.enterprise_id)
*/
export function getEnterpriseCards(id: number, params?: EnterpriseCardsParams) {
return get<ListResponse<CardInfo>>(`/api/admin/enterprises/${id}/cards`, { params })
}
/**
* 获取企业设备列表
* GET /api/admin/enterprises/:id/devices
* @param id 企业ID (来自登录返回的 user.enterprise_id)
*/
export function getEnterpriseDevices(id: number, params?: EnterpriseDevicesParams) {
return get<{ items: DeviceInfo[], total: number }>(`/api/admin/enterprises/${id}/devices`, { params })
}
/**
* 获取企业信息
* GET /api/admin/enterprises/:id
* @param id 企业ID (来自登录返回的 user.enterprise_id)
*/
export function getEnterpriseInfo(id: number) {
return get<EnterpriseInfo>(`/api/admin/enterprises/${id}`)
}

4
src/api/index.ts Normal file
View File

@@ -0,0 +1,4 @@
import * as CommonApi from './common';
import * as UserApi from './user';
export { CommonApi, UserApi };

18
src/api/user/index.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { LoginByCodeReq, LoginByCodeRes, LoginReq, LoginRes, ProfileReq, ProfileRes } from './types';
/**
* 用户信息相关接口
*/
import type { CommonRes } from '@/api/common/types';
import { get, post } from '@/utils/request';
/** 获取用户信息 */
export const profile = (params?: ProfileReq) => get<ProfileRes>('/user/profile', { params });
/** 登录 */
export const login = (data: LoginReq) => post<LoginRes>('/user/login', { data, custom: { auth: false } });
/** 验证码登录 */
export const loginByCode = (data: LoginByCodeReq) => post<LoginByCodeRes>('/user/loginByCode', { data });
/** 退出登录 */
export const logout = () => post<CommonRes>('/user/logout');

30
src/api/user/types.ts Normal file
View File

@@ -0,0 +1,30 @@
export interface ProfileReq {
user_id?: string;
}
export interface ProfileRes {
user_id?: string;
user_name?: string;
avatar?: string;
token?: string;
}
export interface LoginReq {
phone: string;
code: string;
}
export interface LoginRes {
token: string;
user_id: number;
user_name: string;
avatar: string;
}
export interface LoginByCodeReq {
code: string;
}
export interface LoginByCodeRes {
[key: string]: any;
}

38
src/api/wallet.ts Normal file
View File

@@ -0,0 +1,38 @@
/**
* 资产钱包模块 API (代理端)
* ⚠️ 企业账号不可调用
*/
import { get } from '@/utils/request'
/**
* 资产类型
*/
export type AssetType = 'card' | 'device'
/**
* 资产钱包信息
*/
export interface AssetWalletInfo {
wallet_id: number // 钱包ID
balance: number // 总余额(分)
available_balance: number // 可用余额(分)= 总余额 - 冻结余额
frozen_balance: number // 冻结金额(分)
currency: string // 币种(固定 CNY
status: number // 钱包状态
status_text: string // 状态说明
resource_id: number // 资产ID
resource_type: string // 资产类型iot_card / device
created_at: string // 创建时间
updated_at: string // 更新时间
}
/**
* 获取资产钱包信息
* GET /api/admin/assets/{asset_type}/{id}/wallet
* ⚠️ 企业账号不可调用
* @param assetType 资产类型: card / device
* @param id 资产ID
*/
export function getAssetWallet(assetType: AssetType, id: number) {
return get<AssetWalletInfo>(`/api/admin/assets/${assetType}/${id}/wallet`)
}

89
src/api/withdrawal.ts Normal file
View File

@@ -0,0 +1,89 @@
/**
* 提现模块 API (代理端)
*/
import { get, post } from '@/utils/request'
/**
* 提现申请参数
*/
export interface WithdrawalCreateParams {
account_name: string // 收款人姓名
account_number: string // 收款账号(支付宝)
amount: number // 提现金额(分)
withdrawal_method: string // 提现方式固定alipay
}
/**
* 提现申请响应
*/
export interface WithdrawalCreateResponse {
id: number // 提现申请ID
withdrawal_no: string // 提现单号
amount: number // 申请金额(分)
actual_amount: number // 实际到账金额(分)
fee: number // 手续费(分)
fee_rate: number // 手续费率(基点)
status: number // 状态
status_name: string // 状态名称
created_at: string // 申请时间
}
/**
* 提现记录
*/
export interface WithdrawalRecord {
id: number // 提现ID
withdrawal_no: string // 提现单号
applicant_name: string // 申请人
amount: number // 提现金额(分)
actual_amount: number // 实际到账(分)
fee: number // 手续费
fee_rate: number // 手续费率
status: number // 状态1待审 2通过 3拒绝 4到账
status_name: string // 状态名称
withdrawal_method: string // 提现方式(支付宝/微信/银行卡)
account_name: string // 收款人
account_number: string // 收款账号
bank_name?: string // 银行
created_at: string // 申请时间
processed_at?: string // 处理时间
reject_reason?: string // 拒绝原因
remark?: string // 备注
}
/**
* 提现列表参数
*/
export interface WithdrawalListParams {
page?: number
page_size?: number
status?: number // 状态1=待审批2=已通过3=已拒绝)
start_time?: string
end_time?: string
}
/**
* 列表响应
*/
export interface ListResponse<T> {
items: T[]
page: number
size: number
total: number
}
/**
* 发起提现申请
* POST /api/admin/my/withdrawal-requests
*/
export function createWithdrawal(data: WithdrawalCreateParams) {
return post<WithdrawalCreateResponse>('/api/admin/my/withdrawal-requests', { data })
}
/**
* 获取我的提现记录
* GET /api/admin/my/withdrawal-requests
*/
export function getWithdrawalRecords(params?: WithdrawalListParams) {
return get<ListResponse<WithdrawalRecord>>('/api/admin/my/withdrawal-requests', { params })
}

0
src/components/.gitkeep Normal file
View File

View File

@@ -0,0 +1,182 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
interface PickerOption {
label: string
value: any
}
interface Props {
show: boolean
options: PickerOption[]
title?: string
}
interface Emits {
(e: 'update:show', value: boolean): void
(e: 'confirm', option: PickerOption): void
(e: 'cancel'): void
}
const props = withDefaults(defineProps<Props>(), {
title: '请选择'
})
const emit = defineEmits<Emits>()
const selectedIndex = ref(0)
// 关闭弹窗
function close() {
emit('update:show', false)
}
// 取消
function handleCancel() {
emit('cancel')
close()
}
// 确认
function handleConfirm() {
if (props.options.length > 0) {
emit('confirm', props.options[selectedIndex.value])
}
close()
}
// 选择选项
function selectOption(index: number) {
selectedIndex.value = index
}
</script>
<template>
<view
v-if="show"
class="simple-picker-overlay"
@click="handleCancel"
>
<view
class="simple-picker-content"
@click.stop
>
<!-- 头部 -->
<view class="simple-picker-header">
<view class="simple-picker-cancel" @click="handleCancel">
<text class="text-sm text-[#666]">取消</text>
</view>
<view class="simple-picker-title">
<text class="text-base font-600 text-[#1e293b]">{{ title }}</text>
</view>
<view class="simple-picker-confirm" @click="handleConfirm">
<text class="text-sm text-brand font-600">确定</text>
</view>
</view>
<!-- 选项列表 -->
<scroll-view
class="simple-picker-list"
scroll-y
>
<view
v-for="(option, index) in options"
:key="index"
class="simple-picker-item"
:class="{ 'simple-picker-item-active': selectedIndex === index }"
@click="selectOption(index)"
>
<text class="simple-picker-item-text">{{ option.label }}</text>
<i
v-if="selectedIndex === index"
class="i-mdi-check text-lg text-brand"
/>
</view>
</scroll-view>
</view>
</view>
</template>
<style scoped>
.simple-picker-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 9999;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.simple-picker-content {
background-color: #ffffff;
border-radius: 16px 16px 0 0;
max-height: 60vh;
display: flex;
flex-direction: column;
}
.simple-picker-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid #f1f5f9;
}
.simple-picker-cancel,
.simple-picker-confirm {
min-width: 60px;
padding: 4px 8px;
}
.simple-picker-cancel {
text-align: left;
}
.simple-picker-confirm {
text-align: right;
}
.simple-picker-title {
flex: 1;
text-align: center;
}
.simple-picker-list {
flex: 1;
overflow-y: auto;
max-height: calc(60vh - 57px);
}
.simple-picker-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid #f8fafc;
transition: background-color 0.2s;
}
.simple-picker-item:active {
background-color: #f8fafc;
}
.simple-picker-item-active {
background-color: #fef3f2;
}
.simple-picker-item-text {
font-size: 15px;
color: #1e293b;
flex: 1;
}
.simple-picker-item-active .simple-picker-item-text {
color: var(--brand-color, #ff6700);
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,95 @@
<template>
<view class="skeleton">
<view v-if="type === 'home'" class="skeleton-home">
<!-- 顶部用户信息骨架 -->
<view class="safe-area-top bg-white px-4 pt-4 pb-4">
<view class="flex items-center justify-between">
<view>
<view class="skeleton-block h-6 w-32 mb-2" />
<view class="skeleton-block h-4 w-24" />
</view>
<view class="skeleton-circle w-12 h-12" />
</view>
</view>
<!-- 数据卡片骨架 -->
<view class="px-4 pt-4">
<view class="bg-white rounded-2xl p-5">
<view class="grid grid-cols-2 gap-6">
<view>
<view class="skeleton-block h-3 w-16 mb-2" />
<view class="skeleton-block h-8 w-24" />
</view>
<view>
<view class="skeleton-block h-3 w-16 mb-2" />
<view class="skeleton-block h-8 w-24" />
</view>
</view>
</view>
</view>
<!-- 快捷功能骨架 -->
<view class="px-4 pt-6 pb-4">
<view class="skeleton-block h-4 w-16 mb-3" />
<view class="bg-white rounded-2xl overflow-hidden">
<view v-for="i in 5" :key="i" class="flex items-center px-4 py-4" :class="{ 'border-t border-[#f1f5f9]': i > 1 }">
<view class="skeleton-circle w-10 h-10 mr-3" />
<view class="flex-1">
<view class="skeleton-block h-4 w-20 mb-1" />
<view class="skeleton-block h-3 w-32" />
</view>
</view>
</view>
</view>
</view>
<view v-else-if="type === 'list'" class="skeleton-list">
<view class="px-4 pt-4">
<view v-for="i in 3" :key="i" class="bg-white rounded-2xl p-4 mb-3">
<view class="flex items-start justify-between mb-3">
<view class="flex-1">
<view class="skeleton-block h-4 w-40 mb-2" />
<view class="skeleton-block h-3 w-48" />
</view>
<view class="skeleton-block h-5 w-12 rounded-full" />
</view>
<view class="flex items-center justify-between pt-3 border-t border-[#f1f5f9]">
<view class="skeleton-block h-3 w-16" />
<view class="skeleton-block h-4 w-4" />
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
defineProps<{
type?: 'home' | 'list'
}>()
</script>
<style scoped>
.skeleton-block {
background: linear-gradient(90deg, #f1f5f9 25%, #e2e8f0 50%, #f1f5f9 75%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s ease-in-out infinite;
border-radius: 4px;
}
.skeleton-circle {
background: linear-gradient(90deg, #f1f5f9 25%, #e2e8f0 50%, #f1f5f9 75%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s ease-in-out infinite;
border-radius: 50%;
}
@keyframes skeleton-loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
</style>

View File

@@ -0,0 +1,214 @@
<template>
<u-popup :show="modelValue" round="20" @close="closeAgreePrivacy">
<view class="p-30rpx">
<view class="text-lg text-black font-bold">
<span>{{ initTitle }}</span>
</view>
<view class="flex flex-col">
<span class="pt-30rpx text-black font-bold">{{ initSubTitle }}</span>
<span class="pt-30rpx text-sm text-black">1.为向您提供基本的服务我们会遵循正当合法必要的原则收集和使用必要的信息</span>
<span class="pt-30rpx text-sm text-black">2.基于您的授权我们可能会收集和使用您的相关信息您有权拒绝或取消授权</span>
<span class="pt-30rpx text-sm text-black">3.未经您的授权同意我们不会将您的信息共享给第三方或用于您未授权的其他用途</span>
<span class="pt-30rpx text-sm text-black">4.详细信息请您完整阅读<text class="text-decoration" @click="openPrivacyContract">{{
initPrivacyContractName
}}</text></span>
</view>
<view class="mt-30rpx flex items-center justify-around pt-10rpx">
<view class="min-w-100px">
<button class="button button-default" @click="disagree">
拒绝
</button>
</view>
<view class="min-w-100px">
<button
:id="agreePrivacyId"
class="button button-primary"
open-type="agreePrivacyAuthorization"
@agreeprivacyauthorization="agree"
>
同意
</button>
</view>
</view>
</view>
</u-popup>
</template>
<script setup lang="ts">
interface AgreePrivacyProps {
modelValue: boolean;
// 标题
title: string;
// 副标题
subTitle: string;
// 禁止自动检测隐私
disableCheckPrivacy: boolean;
// 按钮id 必填项不填写时授权按钮id必须为agree-btn
agreePrivacyId: string;
}
const props = withDefaults(defineProps<AgreePrivacyProps>(), {
modelValue: false,
title: '',
subTitle: '',
disableCheckPrivacy: true,
agreePrivacyId: 'agree-btn',
});
const emit = defineEmits(['update:modelValue', 'needPrivacyAuthorization', 'agree', 'disagree']);
// 初始化的标题
const initTitle = ref<string>('隐私政策概要');
// 初始化的副标题
const initSubTitle = ref<string>('');
// 隐私政策
const initPrivacyContractName = ref<string>('隐私政策');
// 打开隐私
function openAgreePrivacy() {
emit('update:modelValue', true);
}
// 关闭隐私
function closeAgreePrivacy() {
emit('update:modelValue', false);
}
// 需要初始化的数据
function initData() {
initTitle.value = props.title || initTitle.value;
initSubTitle.value
= props.subTitle
|| `亲爱的用户,感谢您一直以来的支持!为了更好地保护您的权益,同时遵守相关监管要求,请认真阅读${initPrivacyContractName.value},特向您说明如下:`;
}
// 检测是否授权
function checkPrivacySetting() {
wx.getPrivacySetting({
success: (res: any) => {
// 未授权弹框
if (res.needAuthorization) {
initPrivacyContractName.value = res.privacyContractName;
initData();
// 是否禁用 自动检测隐私并弹框
if (!props.disableCheckPrivacy) {
// 需要弹出隐私协议
openAgreePrivacy();
}
}
else {
// 用户已经同意过隐私协议,所以不需要再弹出隐私协议,也能调用已声明过的隐私接口
// wx.getUserProfile()
}
},
fail: (e: any) => {
console.log(e);
},
});
}
// 打开隐私政策
function openPrivacyContract() {
wx.openPrivacyContract({
success: () => {}, // 打开成功
fail: (e: any) => {
uni.$u.toast(`打开失败:${e}`);
}, // 打开失败
});
}
// 同意
function agree(e: any) {
const buttonId = e.target.id || 'agree-btn';
emit('agree', buttonId);
emit('update:modelValue', false);
}
// 拒绝
function disagree() {
emit('disagree');
closeAgreePrivacy();
}
onMounted(() => {
// 检测是否授权
checkPrivacySetting();
// // 监听授权
// wx.onNeedPrivacyAuthorization((resolve, eventInfo) => {
// emit('update:modelValue', true);
// // 回调
// emit('needPrivacyAuthorization', resolve, eventInfo);
// });
});
</script>
<style scoped lang="scss">
.button {
position: relative;
box-sizing: border-box;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
//height: 80rpx;
padding: 10px 20px;
font-size: 14px;
font-weight: 500;
line-height: 1.5;
color: #fff;
text-align: center;
text-decoration: none;
border-radius: 18rpx;
//border-width: 1px;
//border-style: solid;
}
.button-lg {
position: relative;
box-sizing: border-box;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
//height: 80rpx;
padding: 12px 22px;
font-size: 14px;
font-weight: 700;
line-height: 1.5;
color: #fff;
text-align: center;
text-decoration: none;
border-radius: 20rpx;
//border-width: 1px;
//border-style: solid;
}
.button-default {
color: #07c160;
background-color: rgb(0 0 0 / 5%);
}
.button-primary {
color: #fff;
background-color: #07c160;
}
button {
padding: 0;
margin: 0;
line-height: inherit;
outline: none;
background-color: transparent;
border-radius: 0;
}
button::after {
border: none;
}
.text-decoration {
color: #07c160;
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,51 @@
<template>
<view>
<picker
range-key="label"
:range="langOptions"
:value="langIndex"
@change="handleLangChange"
>
<slot>
<view class="i-mdi-language" :style="langStyle" />
</slot>
</picker>
</view>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
const props = defineProps({
size: {
type: Number,
default: 40,
required: false,
},
});
const emit = defineEmits(['change']);
const { locale, t } = useI18n();
const langStyle = {
fontSize: `${props.size}rpx`,
};
const langOptions = computed(() => {
return [
{ label: t('locale.en'), value: 'en' },
{ label: t('locale.zh-hans'), value: 'zh-Hans' },
];
});
const langIndex = computed(() => {
return langOptions.value.findIndex((item) => {
return item.value === locale.value;
});
});
function handleLangChange(event: any) {
const lang = langOptions.value[event.detail.value].value;
locale.value = lang;
uni.setLocale(lang);
emit('change', lang);
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,32 @@
<template>
<view class="flex flex-wrap gap-3">
<view
v-for="(item, index) in colors" :key="index"
class="mb-10rpx h-40rpx w-40rpx center cursor-pointer border-4rpx border-gray-300 rounded-4rpx border-solid"
:class="{ 'border-white': theme === item.name }" :style="{ backgroundColor: item.color }"
@click="changeTheme(item.name)"
>
<view v-if="theme === item.name" class="i-mdi-check text-32rpx c-#fff" />
</view>
</view>
</template>
<script setup lang="ts">
import { useAppStore } from '@/store';
const appStore = useAppStore();
const colors = [{
name: '',
color: '#21d59d',
}, {
name: 'blue',
color: '#3c9cff',
}];
const theme = computed(() => appStore.getTheme);
function changeTheme(theme: string) {
appStore.setTheme(theme);
}
</script>

9
src/hooks/index.ts Normal file
View File

@@ -0,0 +1,9 @@
import useClipboard from './use-clipboard';
import useLoading from './use-loading';
import useLocation from './use-location';
import useModal from './use-modal';
import usePermission from './use-permission';
import useShare from './use-share';
import useTheme from './use-theme';
export { useClipboard, useLoading, useLocation, useModal, usePermission, useShare, useTheme };

View File

@@ -0,0 +1,33 @@
/**
* 剪切板
* @example
* const {setClipboardData, getClipboardData} = useClipboard()
* // 设置剪切板
* setClipboardData({data: '1234567890'})
* // 获取剪切板
* const data = await getClipboardData()
*/
export default function useClipboard() {
const setClipboardData = ({ data, showToast = true }: UniApp.SetClipboardDataOptions) => {
return new Promise<string>((resolve, reject) => {
uni.setClipboardData({
data,
showToast,
success: ({ data }) => resolve(data),
fail: error => reject(error),
});
});
};
const getClipboardData = () => {
return new Promise<string>((resolve, reject) => {
uni.getClipboardData({
success: ({ data }) => resolve(data),
fail: error => reject(error),
});
});
};
return {
setClipboardData,
getClipboardData,
};
}

View File

@@ -0,0 +1,24 @@
/**
* loading 提示框
* @example
* const {showLoading, hideLoading} = useLoading()
* // 显示loading
* showLoading()
* // 隐藏loading
* hideLoading()
*/
export default function useLoading() {
const showLoading = (content = '加载中') => {
uni.showLoading({
title: content,
mask: true,
});
};
const hideLoading = () => {
uni.hideLoading();
};
return {
showLoading,
hideLoading,
};
}

View File

@@ -0,0 +1,326 @@
import type { AddressInfo, LocationInfo, LocationOptions } from './types';
/**
* 定位hooks提供定位相关功能
* - 获取位置
* - 位置监听
* - 地址解析
* - 距离计算
*/
export default function useLocation() {
// 当前位置信息
const location = ref<LocationInfo | null>(null);
// 定位状态
const isLocating = ref(false);
// 是否正在监听位置
const isWatching = ref(false);
// 定位错误信息
const error = ref<any>(null);
// 历史位置
const historyLocations = ref<LocationInfo[]>([]);
// 监听位置的定时器ID
let watchId: number | null = null;
/**
* 获取当前位置
* @param options 定位选项
*/
const getLocation = (options: LocationOptions = {}) => {
isLocating.value = true;
error.value = null;
const defaultOptions: LocationOptions = {
type: 'gcj02',
altitude: false,
isHighAccuracy: false,
};
const finalOptions = { ...defaultOptions, ...options };
return new Promise<LocationInfo>((resolve, reject) => {
uni.getLocation({
type: finalOptions.type,
altitude: finalOptions.altitude,
isHighAccuracy: finalOptions.isHighAccuracy,
highAccuracyExpireTime: finalOptions.highAccuracyExpireTime,
success: (res) => {
// 更新当前位置
const locationData: LocationInfo = {
...res,
timestamp: Date.now(),
};
location.value = locationData;
// 添加到历史记录
historyLocations.value.push(locationData);
// 只保留最近的20条记录
if (historyLocations.value.length > 20) {
historyLocations.value.shift();
}
finalOptions.success && finalOptions.success(res);
resolve(locationData);
},
fail: (err) => {
error.value = err;
finalOptions.fail && finalOptions.fail(err);
reject(err);
},
complete: () => {
isLocating.value = false;
finalOptions.complete && finalOptions.complete();
},
});
});
};
/**
* 使用地理编码获取地址信息
* @param latitude 纬度
* @param longitude 经度
*/
const getAddress = (latitude: number, longitude: number) => {
return new Promise<AddressInfo>((resolve, reject) => {
// #ifdef APP-PLUS
uni.request({
url: `https://apis.map.qq.com/ws/geocoder/v1/?location=${latitude},${longitude}&key=YOUR_KEY`,
success: (res: any) => {
if (res.data && res.data.status === 0) {
const addressComponent = res.data.result.address_component;
const formattedAddress = res.data.result.formatted_addresses.recommend;
const addressInfo: AddressInfo = {
nation: addressComponent.nation,
province: addressComponent.province,
city: addressComponent.city,
district: addressComponent.district,
street: addressComponent.street,
streetNum: addressComponent.street_number,
poiName: res.data.result.poi_count > 0 ? res.data.result.pois[0].title : '',
cityCode: res.data.result.ad_info.city_code,
};
if (location.value) {
location.value.address = addressInfo;
location.value.formatted = formattedAddress;
}
resolve(addressInfo);
}
else {
reject(new Error('获取地址信息失败'));
}
},
fail: (err) => {
reject(err);
},
});
// #endif
// #ifndef APP-PLUS
// 其他平台可以使用uni.getLocation的geocode参数获取仅App和微信小程序支持
// 或者使用其他地图服务的API
reject(new Error('当前平台不支持地址解析'));
// #endif
});
};
/**
* 停止监听位置
*/
const stopWatchLocation = () => {
if (watchId !== null) {
clearInterval(watchId);
watchId = null;
}
isWatching.value = false;
};
/**
* 开始监听位置变化
* @param options 定位选项
* @param interval 监听间隔,单位毫秒
*/
const watchLocation = (options: LocationOptions = {}, interval: number = 5000) => {
// 已经在监听,先停止
if (isWatching.value) {
stopWatchLocation();
}
isWatching.value = true;
// 首次定位
getLocation(options).catch((err) => {
console.error('监听位置首次定位失败', err);
});
// 定时获取位置
watchId = window.setInterval(() => {
if (isWatching.value) {
getLocation(options).catch((err) => {
console.error('监听位置更新失败', err);
});
}
}, interval);
return watchId;
};
/**
* 计算两点间距离(米)
* @param lat1 第一个点的纬度
* @param lon1 第一个点的经度
* @param lat2 第二个点的纬度
* @param lon2 第二个点的经度
* @returns 距离,单位:米
*/
const calculateDistance = (lat1: number, lon1: number, lat2: number, lon2: number): number => {
const R = 6371000; // 地球半径,单位米
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLon = ((lon2 - lon1) * Math.PI) / 180;
const a
= Math.sin(dLat / 2) * Math.sin(dLat / 2)
+ Math.cos((lat1 * Math.PI) / 180)
* Math.cos((lat2 * Math.PI) / 180)
* Math.sin(dLon / 2)
* Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
const distance = R * c;
return distance;
};
/**
* 获取当前位置到目标位置的距离
* @param targetLat 目标位置纬度
* @param targetLon 目标位置经度
* @returns 距离,单位:米,如果当前没有位置信息则返回-1
*/
const getDistanceFromCurrent = (targetLat: number, targetLon: number): number => {
if (!location.value) {
return -1;
}
return calculateDistance(
location.value.latitude,
location.value.longitude,
targetLat,
targetLon,
);
};
/**
* 格式化距离显示
* @param distance 距离,单位:米
* @returns 格式化后的距离字符串
*/
const formatDistance = (distance: number): string => {
if (distance < 0) {
return '未知距离';
}
else if (distance < 1000) {
return `${Math.round(distance)}`;
}
else {
return `${(distance / 1000).toFixed(1)}公里`;
}
};
/**
* 打开导航
* @param latitude 目标纬度
* @param longitude 目标经度
* @param name 目标名称
* @param address 目标地址
*/
const openLocation = (
latitude: number,
longitude: number,
name: string = '',
address: string = '',
) => {
return new Promise<void>((resolve, reject) => {
uni.openLocation({
latitude,
longitude,
name,
address,
success: () => resolve(),
fail: err => reject(err),
});
});
};
/**
* 选择位置
*/
const chooseLocation = () => {
return new Promise<UniApp.ChooseLocationSuccess>((resolve, reject) => {
uni.chooseLocation({
success: (res) => {
// 更新当前位置
if (res.latitude && res.longitude) {
const locationData: LocationInfo = {
latitude: res.latitude,
longitude: res.longitude,
accuracy: 0,
verticalAccuracy: 0,
horizontalAccuracy: 0,
altitude: 0,
speed: 0,
timestamp: Date.now(),
address: {
province: '',
city: '',
district: '',
street: '',
poiName: res.name,
},
formatted: res.address,
};
location.value = locationData;
}
resolve(res);
},
fail: err => reject(err),
});
});
};
// 自动清理
onUnmounted(() => {
stopWatchLocation();
});
return {
// 状态
location,
isLocating,
isWatching,
error,
historyLocations,
// 方法
getLocation,
getAddress,
watchLocation,
stopWatchLocation,
calculateDistance,
getDistanceFromCurrent,
formatDistance,
openLocation,
chooseLocation,
};
}

View File

@@ -0,0 +1,30 @@
// 定位选项
export interface LocationOptions {
type?: 'wgs84' | 'gcj02';
altitude?: boolean;
isHighAccuracy?: boolean;
highAccuracyExpireTime?: number;
success?: (res: UniApp.GetLocationSuccess) => void;
fail?: (err: any) => void;
complete?: () => void;
}
// 地址信息
export interface AddressInfo {
nation?: string;
province?: string;
city?: string;
district?: string;
street?: string;
streetNum?: string;
poiName?: string;
postalCode?: string;
cityCode?: string;
}
// 位置信息
export interface LocationInfo extends UniApp.GetLocationSuccess {
address?: AddressInfo;
formatted?: string;
timestamp?: number;
}

View File

@@ -0,0 +1,24 @@
/**
* Dialog 提示框
* @example
* const {showModal} = useModal()
* showModal('提示内容')
*/
export default function useModal() {
const showModal = (content: string, options: UniApp.ShowModalOptions) => {
return new Promise((resolve, reject) => {
uni.showModal({
title: '温馨提示',
content,
showCancel: false,
confirmColor: '#1677FF',
success: res => resolve(res),
fail: () => reject(new Error('Alert 调用失败 !')),
...options,
});
});
};
return {
showModal,
};
}

View File

@@ -0,0 +1,10 @@
import { hasPerm } from '@/plugins/permission';
import { currentRoute } from '@/router';
// 对某些特殊场景需要在页面onShow生命周期中校验权限:
// 1.微信小程序端点击tabbar的底层逻辑不触发uni.switchTab
// 2.h5在浏览器地址栏输入url后跳转不触发uni的路由api
// 3.首次启动加载的页面不触发uni的路由api
export default async function usePermission() {
return hasPerm(currentRoute());
}

View File

@@ -0,0 +1,48 @@
import type { ShareOptions } from './types';
/**
* 小程序分享
* @param {object} options
* @example
* // 必须要调用onShareAppMessageonShareTimeline才能正常分享
* // 因为小程序平台必须在注册页面时主动配置onShareAppMessage, onShareTimeline才可以
* // 组合式API是运行时才能注册框架不可能默认给每个页面都开启这两个分享所以必须在页面代码里包含这两个API的字符串才会主动去注册。
* // 相关说明链接https://ask.dcloud.net.cn/question/150353
* const {onShareAppMessage, onShareTimeline} = useShare({title: '分享标题', path: 'pages/index/index', query: 'id=1', imageUrl: 'https://xxx.png'})
* onShareAppMessage()
* onShareTimeline()
*/
export default function useShare(options?: ShareOptions) {
// #ifdef MP-WEIXIN
const title = options?.title ?? '';
const path = options?.path ?? '';
const query = options?.query ?? '';
const imageUrl = options?.imageUrl ?? '';
const shareApp = (params: ShareOptions = {}) => {
onShareAppMessage(() => {
return {
title,
path: path ? `${path}${query ? `?${query}` : ''}` : '',
imageUrl,
...params,
};
});
};
const shareTime = (params: ShareOptions = {}) => {
onShareTimeline(() => {
return {
title,
query: options?.query ?? '',
imageUrl,
...params,
};
});
};
return {
onShareAppMessage: shareApp,
onShareTimeline: shareTime,
};
// #endif
}

View File

@@ -0,0 +1,6 @@
export interface ShareOptions {
title?: string;
path?: string;
query?: string;
imageUrl?: string;
}

View File

@@ -0,0 +1,38 @@
import type { ThemeMode } from '@/store/modules/app/types';
import { computed } from 'vue';
import { useAppStore } from '@/store';
/**
* 主题Hook
*/
export default function useTheme() {
const appStore = useAppStore();
// 当前主题模式
const theme = computed(() => appStore.getTheme);
// 是否为深色主题
const isDark = computed(() => appStore.getTheme === 'dark');
/**
* 设置主题
*/
const setTheme = (mode: ThemeMode) => {
appStore.setTheme(mode);
};
/**
* 切换主题
*/
const toggleTheme = () => {
const newTheme = theme.value === 'light' ? 'dark' : 'light';
appStore.setTheme(newTheme);
};
return {
theme,
isDark,
setTheme,
toggleTheme,
};
}

21
src/locales/index.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { App } from 'vue';
import { createI18n } from 'vue-i18n';
import en from './langs/en';
import zhHans from './langs/zh-Hans';
const i18n = createI18n({
legacy: false, // 必须设置false才能使用Composition API
globalInjection: true, // 为每个组件注入$为前缀的全局属性和函数
locale: uni.getLocale(),
messages: {
en,
'zh-Hans': zhHans,
},
});
function setupI18n(app: App) {
app.use(i18n);
}
export { i18n };
export default setupI18n;

11
src/locales/langs/en.ts Normal file
View File

@@ -0,0 +1,11 @@
export default {
locale: {
'auto': 'System',
'en': 'English',
'zh-hans': 'Chinese',
},
home: {
'intro': 'Welcome to uni-app demo',
'toggle-langs': 'Change languages',
},
};

View File

@@ -0,0 +1,11 @@
export default {
locale: {
'auto': '系统',
'en': '英语',
'zh-hans': '中文',
},
home: {
'intro': '欢迎来到uni-app演示',
'toggle-langs': '切换语言',
},
};

14
src/main.ts Normal file
View File

@@ -0,0 +1,14 @@
import { createSSRApp } from 'vue';
import App from '@/App.vue';
import setupPlugins from '@/plugins';
// 引入UnoCSS
import 'virtual:uno.css';
export function createApp() {
const app = createSSRApp(App);
app.use(setupPlugins);
return {
app,
};
}

78
src/manifest.json Normal file
View File

@@ -0,0 +1,78 @@
{
"name" : "H5",
"appid" : "__UNI__2527119",
"description" : "",
"versionName" : "1.0.0",
"versionCode" : "100",
"transformPx" : false,
/* 5+App */
"app-plus" : {
"usingComponents" : true,
"nvueStyleCompiler" : "uni-app",
"compilerVersion" : 3,
"splashscreen" : {
"alwaysShowBeforeRender" : true,
"waiting" : true,
"autoclose" : true,
"delay" : 0
},
/* */
"modules" : {},
/* */
"distribute" : {
/* android */
"android" : {
"permissions" : [
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
]
},
/* ios */
"ios" : {},
/* SDK */
"sdkConfigs" : {}
}
},
/* */
"quickapp" : {},
/* */
"mp-weixin" : {
"appid" : "",
"setting" : {
"urlCheck" : false
},
"usingComponents" : true
},
"mp-alipay" : {
"usingComponents" : true
},
"mp-baidu" : {
"usingComponents" : true
},
"mp-toutiao" : {
"usingComponents" : true
},
"uniStatistics" : {
"enable" : false
},
"vueVersion" : "3",
"h5" : {
"router" : {
"mode" : "hash",
"base" : "/h5/"
}
}
}

176
src/pages.json Normal file
View File

@@ -0,0 +1,176 @@
{
"easycom": {
"custom": {
"^u--(.*)": "uview-plus/components/u-$1/u-$1.vue",
"^up-(.*)": "uview-plus/components/u-$1/u-$1.vue",
"^u-([^-].*)": "uview-plus/components/u-$1/u-$1.vue",
"^(?!z-paging-refresh|z-paging-load-more)z-paging(.*)": "z-paging/components/z-paging$1/z-paging$1.vue"
}
},
"pages": [
{
"path": "pages/agent-system/home/index",
"style": {
"navigationBarTitleText": "卡管H5",
"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": {
"navigationBarTitleText": "资产列表",
"navigationBarBackgroundColor": "#ffffff",
"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": {
"navigationBarTitleText": "资产详情",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/agent-system/commission-center/index",
"style": {
"navigationBarTitleText": "佣金中心",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/agent-system/withdraw/index",
"style": {
"navigationBarTitleText": "提现管理",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/agent-system/asset-wallet/index",
"style": {
"navigationBarTitleText": "资产钱包",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/agent-system/user-info/index",
"style": {
"navigationBarTitleText": "个人信息",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/agent-system/change-password/index",
"style": {
"navigationBarTitleText": "修改密码",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/agent-system/enterprise-cards/index",
"style": {
"navigationBarTitleText": "企业卡列表",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/agent-system/enterprise-devices/index",
"style": {
"navigationBarTitleText": "企业设备列表",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black"
}
}
],
"subPackages": [
{
"root": "pages/common",
"pages": [
{
"path": "login/index",
"style": {
"navigationBarTitleText": "登录",
"navigationStyle": "custom"
}
},
{
"path": "webview/index",
"style": {
"navigationBarTitleText": "网页"
}
},
{
"path": "404/index",
"style": {
"navigationBarTitleText": "404",
"navigationStyle": "custom"
}
},
{
"path": "theme/index",
"style": {
"navigationBarTitleText": "主题设置"
}
}
]
}
],
"preloadRule": {
"pages/tab/home/index": {
"network": "all",
"packages": ["pages/common"]
}
},
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "uni-app",
"navigationBarBackgroundColor": "#F8F8F8",
"backgroundColor": "#F8F8F8"
}
}

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>

21
src/plugins/index.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { App } from 'vue';
import setupI18n from '@/locales';
import setupStore from '@/store';
import setupRequest from '@/utils/request';
import setupPermission from './permission';
import setupUI from './ui';
export default {
install(app: App) {
// UI扩展配置
setupUI(app);
// 状态管理
setupStore(app);
// 国际化
setupI18n(app);
// 路由拦截
setupPermission();
// 网络请求
setupRequest();
},
};

57
src/plugins/permission.ts Normal file
View File

@@ -0,0 +1,57 @@
import {
ERROR404_PATH,
isPathExists,
LOGIN_PATH,
removeQueryString,
routes,
} from '@/router';
import { isLogin } from '@/utils/auth';
// 白名单路由
const whiteList = ['/'];
routes.forEach((item) => {
if (item.needLogin !== true) {
whiteList.push(item.path);
}
});
/**
* 权限校验
* @param {string} path
* @returns {boolean} 是否有权限
*/
export function hasPerm(path = '') {
if (!isPathExists(path) && path !== '/') {
uni.redirectTo({
url: ERROR404_PATH,
});
return false;
}
// 在白名单中或有token直接放行
const hasPermission
= whiteList.includes(removeQueryString(path)) || isLogin();
if (!hasPermission) {
// 将用户的目标路径传递过去,这样可以实现用户登录之后,直接跳转到目标页面
uni.redirectTo({
url: `${LOGIN_PATH}?redirect=${encodeURIComponent(path)}`,
});
}
return hasPermission;
}
function setupPermission() {
// 注意拦截uni.switchTab本身没有问题。但是在微信小程序端点击tabbar的底层逻辑并不是触发uni.switchTab。
// 所以误认为拦截无效此类场景的解决方案是在tabbar页面的页面生命周期onShow中处理。
['navigateTo', 'redirectTo', 'reLaunch', 'switchTab'].forEach((item) => {
// https://uniapp.dcloud.net.cn/api/interceptor.html
uni.addInterceptor(item, {
// 页面跳转前进行拦截, invoke根据返回值进行判断是否继续执行跳转
invoke(args) {
// args为所拦截api中的参数比如拦截的是uni.redirectTo(OBJECT)则args对应的是OBJECT参数
return hasPerm(args.url);
},
});
});
}
export default setupPermission;

28
src/plugins/ui.ts Normal file
View File

@@ -0,0 +1,28 @@
import type { App } from 'vue';
import uviewPlus, { setConfig } from 'uview-plus';
function setupUI(app: App) {
// 下面的在特殊场景下才需要配置通常不用配置即可直接使用uview-plus框架。
// 调用setConfig方法方法内部会进行对象属性深度合并可以放心嵌套配置
// 需要在app.use(uview-plus)之后执行
setConfig({
// 修改$u.config对象的属性
config: {
// 修改默认单位为rpx相当于执行 uni.$u.config.unit = 'rpx'
unit: 'px',
},
// 修改$u.props对象的属性
props: {
// 修改radio组件的size参数的默认值相当于执行 uni.$u.props.radio.size = 30
radio: {
// size: 20
},
// 其他组件属性配置
// ......
},
});
app.use(uviewPlus);
}
export default setupUI;

85
src/router/index.ts Normal file
View File

@@ -0,0 +1,85 @@
import pagesJson from '@/pages.json';
// 路径常量
export const HOME_PATH = '/pages/agent-system/home/index';
export const LOGIN_PATH = '/pages/common/login/index';
export const ERROR404_PATH = '/pages/common/404/index';
/**
* 解析路由地址
* @param {object} pagesJson
* @returns [{"path": "/pages/tab/home/index","needLogin": false},...]
*/
function parseRoutes(pagesJson = {} as any) {
if (!pagesJson.pages) {
pagesJson.pages = [];
}
if (!pagesJson.subPackages) {
pagesJson.subPackages = [];
}
function parsePages(pages = [] as any, rootPath = '') {
const routes = [];
for (let i = 0; i < pages.length; i++) {
routes.push({
path: rootPath ? `/${rootPath}/${pages[i].path}` : `/${pages[i].path}`,
needLogin: pages[i].needLogin === true,
});
}
return routes;
}
function parseSubPackages(subPackages = [] as any) {
const routes = [];
for (let i = 0; i < subPackages.length; i++) {
routes.push(...parsePages(subPackages[i].pages, subPackages[i].root));
}
return routes;
}
return [
...parsePages(pagesJson.pages),
...parseSubPackages(pagesJson.subPackages),
];
}
export const routes = parseRoutes(pagesJson);
/**
* 当前路由
* @returns {string} 当前路由
*/
export function currentRoute() {
// getCurrentPages() 至少有1个元素所以不再额外判断
const pages = getCurrentPages();
const currentPage = pages[pages.length - 1] as any;
return currentPage?.$page?.fullPath || currentPage.route;
}
/**
* 去除查询字符串
* @param {string} path
* @returns {string} 去除查询字符串后的路径
*/
export function removeQueryString(path = '') {
return path.split('?')[0];
}
/**
* 路径是否存在
* @param {string} path
* @returns {boolean} 路径是否存在
*/
export function isPathExists(path = '') {
const cleanPath = removeQueryString(path);
return routes.some(item => item.path === cleanPath);
}
/**
* 是否是tabbar页面路径
* @param {string} path
* @returns {boolean} 是否是tabbar页面
*/
export function isTabBarPath(path = '') {
// 已移除 tabBar 配置,始终返回 false
return false;
}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

BIN
src/static/images/404.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

BIN
src/static/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
src/static/images/pay.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View File

@@ -0,0 +1,5 @@
page {
font-size: $u-font-base;
color: $u-main-color;
background-color: $u-bg-color;
}

View File

@@ -0,0 +1,280 @@
/* ================================
GT Agent Company - Design System
小米风格 + 现代扁平化设计
================================ */
:root,
page {
/* === 品牌色 Brand Colors - 只保留3个核心颜色 === */
--brand-primary: #d4af37; /* 主色 - 金色 */
--brand-secondary: #1e293b; /* 辅色 - 深灰/黑色 */
--brand-accent: #ffffff; /* 强调 - 白色 */
/* === 语义色 Semantic Colors === */
--color-success: #52c41a; /* 成功 - Green */
--color-success-bg: #f6ffed; /* 成功背景 */
--color-warning: #faad14; /* 警告 - Amber */
--color-warning-bg: #fffbe6; /* 警告背景 */
--color-error: #ff4d4f; /* 错误 - Red */
--color-error-bg: #fff2f0; /* 错误背景 */
--color-info: #1890ff; /* 信息 - Blue */
--color-info-bg: #e6f7ff; /* 信息背景 */
/* === 文字色阶 Text Colors === */
--text-primary: #212121; /* 标题/重要文字 */
--text-secondary: #666666; /* 正文 */
--text-tertiary: #999999; /* 辅助信息 */
--text-quaternary: #cccccc; /* 禁用/占位符 */
--text-inverse: #ffffff; /* 反色文字 */
/* === 背景色 Background Colors === */
--bg-page: #fafafa; /* 页面背景 */
--bg-container: #ffffff; /* 容器/卡片背景 */
--bg-elevated: #ffffff; /* 悬浮层背景 */
--bg-muted: #f5f5f5; /* 静音背景 */
--bg-overlay: rgba(0, 0, 0, 0.45); /* 遮罩层 */
/* === 边框色 Border Colors === */
--border-primary: #e8e8e8; /* 主边框 */
--border-secondary: #f0f0f0; /* 次边框 */
--border-light: #fafafa; /* 浅边框 */
/* === 阴影 Shadows === */
--shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.06);
--shadow-md: 0 2px 8px 0 rgba(0, 0, 0, 0.08);
--shadow-lg: 0 4px 16px 0 rgba(0, 0, 0, 0.12);
--shadow-xl: 0 8px 24px 0 rgba(0, 0, 0, 0.15);
--shadow-brand: 0 4px 12px 0 rgba(255, 103, 0, 0.2);
/* === 圆角 Radius === */
--radius-xs: 4px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--radius-full: 9999px;
/* === 间距 Spacing === */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 12px;
--space-lg: 16px;
--space-xl: 24px;
--space-2xl: 32px;
--space-3xl: 48px;
/* === 字号 Font Sizes === */
--text-xs: 11px; /* 徽章/小标签 */
--text-sm: 12px; /* 次要信息 */
--text-base: 14px; /* 正文 */
--text-lg: 16px; /* 卡片标题 */
--text-xl: 18px; /* 页面标题 */
--text-2xl: 20px; /* Logo */
--text-3xl: 24px; /* 统计数值 */
--text-4xl: 32px; /* 大数字 */
--text-5xl: 40px; /* 主要金额 */
/* === 行高 Line Heights === */
--leading-tight: 1.2;
--leading-normal: 1.5;
--leading-relaxed: 1.75;
/* === 过渡 Transitions === */
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 350ms cubic-bezier(0.4, 0, 0.2, 1);
/* === Z-index === */
--z-base: 1;
--z-dropdown: 1000;
--z-sticky: 1020;
--z-fixed: 1030;
--z-modal-backdrop: 1040;
--z-modal: 1050;
--z-popover: 1060;
--z-tooltip: 1070;
--z-toast: 1080;
}
/* === 暗色主题 Dark Theme === */
.theme-dark {
--brand-primary: #ff8533; /* 暗色下橙色更柔和 */
--color-success: #73d13d;
--color-warning: #ffc53d;
--color-error: #ff7875;
--color-info: #40a9ff;
--text-primary: #ffffff;
--text-secondary: #b0b8c1;
--text-tertiary: #8a8f95;
--text-quaternary: #6c757d;
--text-inverse: #1a1a1a;
--bg-page: #1a1a1a;
--bg-container: #2d2d2d;
--bg-elevated: #3a3a3a;
--bg-muted: #242424;
--bg-overlay: rgba(0, 0, 0, 0.65);
--border-primary: #3a3a3a;
--border-secondary: #2d2d2d;
--border-light: #242424;
--shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.3);
--shadow-md: 0 2px 8px 0 rgba(0, 0, 0, 0.4);
--shadow-lg: 0 4px 16px 0 rgba(0, 0, 0, 0.5);
--shadow-xl: 0 8px 24px 0 rgba(0, 0, 0, 0.6);
}
/* === 全局重置 Global Resets === */
page {
background-color: var(--bg-page);
color: var(--text-secondary);
font-size: var(--text-base);
line-height: var(--leading-normal);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* === 工具类 Utility Classes === */
.text-primary { color: var(--text-primary); }
.text-secondary { color: var(--text-secondary); }
.text-tertiary { color: var(--text-tertiary); }
.text-brand { color: var(--brand-primary); }
.text-success { color: var(--color-success); }
.text-warning { color: var(--color-warning); }
.text-error { color: var(--color-error); }
.bg-page { background-color: var(--bg-page); }
.bg-container { background-color: var(--bg-container); }
.bg-muted { background-color: var(--bg-muted); }
.bg-brand { background-color: var(--brand-primary); }
.bg-brand-light { background-color: var(--brand-primary-light); }
.border-primary { border-color: var(--border-primary); }
.border-secondary { border-color: var(--border-secondary); }
.shadow-sm { box-shadow: var(--shadow-sm); }
.shadow-md { box-shadow: var(--shadow-md); }
.shadow-lg { box-shadow: var(--shadow-lg); }
.shadow-brand { box-shadow: var(--shadow-brand); }
.radius-xs { border-radius: var(--radius-xs); }
.radius-sm { border-radius: var(--radius-sm); }
.radius-md { border-radius: var(--radius-md); }
.radius-lg { border-radius: var(--radius-lg); }
.radius-full { border-radius: var(--radius-full); }
.transition-all { transition: all var(--transition-base); }
.transition-fast { transition: all var(--transition-fast); }
/* === 组件样式 Component Styles === */
/* 卡片 */
.card {
background-color: var(--bg-container);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
padding: var(--space-lg);
transition: var(--transition-base);
}
.card:active {
transform: scale(0.98);
box-shadow: var(--shadow-xs);
}
/* 按钮 */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--space-sm) var(--space-lg);
border-radius: var(--radius-sm);
font-size: var(--text-base);
font-weight: 500;
line-height: var(--leading-tight);
transition: var(--transition-fast);
cursor: pointer;
user-select: none;
}
.btn-primary {
background-color: var(--brand-primary);
color: var(--text-inverse);
box-shadow: var(--shadow-brand);
}
.btn-primary:hover {
background-color: var(--brand-primary-light);
}
.btn-primary:active {
background-color: var(--brand-primary-dark);
transform: scale(0.96);
}
/* 输入框 */
.input {
width: 100%;
padding: var(--space-md);
border: 1px solid var(--border-primary);
border-radius: var(--radius-sm);
font-size: var(--text-base);
color: var(--text-primary);
background-color: var(--bg-container);
transition: var(--transition-fast);
}
.input:focus {
border-color: var(--brand-primary);
outline: none;
box-shadow: 0 0 0 3px rgba(255, 103, 0, 0.1);
}
.input::placeholder {
color: var(--text-quaternary);
}
/* 徽章 */
.badge {
display: inline-block;
padding: 2px var(--space-sm);
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 500;
line-height: var(--leading-tight);
}
.badge-success {
background-color: var(--color-success-bg);
color: var(--color-success);
}
.badge-warning {
background-color: var(--color-warning-bg);
color: var(--color-warning);
}
.badge-error {
background-color: var(--color-error-bg);
color: var(--color-error);
}
/* 分隔线 */
.divider {
height: 1px;
background-color: var(--border-secondary);
margin: var(--space-lg) 0;
}
/* 安全区域 */
.safe-area-bottom {
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
.safe-area-top {
padding-top: constant(safe-area-inset-top);
padding-top: env(safe-area-inset-top);
}

27
src/store/index.ts Normal file
View File

@@ -0,0 +1,27 @@
import type { App } from 'vue';
import { createPinia } from 'pinia';
// pinia 持久化插件
import { createPersistedState } from 'pinia-plugin-persistedstate';
// 导入子模块
import useAppStore from './modules/app';
import useUserStore from './modules/user';
// 安装pinia状态管理插件
function setupStore(app: App) {
const store = createPinia();
const piniaPersist = createPersistedState({
storage: {
getItem: uni.getStorageSync,
setItem: uni.setStorageSync,
},
});
store.use(piniaPersist);
app.use(store);
}
// 导出模块
export { useAppStore, useUserStore };
export default setupStore;

View File

@@ -0,0 +1,47 @@
import type { AppState } from './types';
import { defineStore } from 'pinia';
import storage from '@/utils/storage';
// 缓存的主题
const THEME_KEY = 'app-theme';
const useAppStore = defineStore('app', {
state: (): AppState => ({
systemInfo: {} as UniApp.GetSystemInfoResult,
theme: storage.get(THEME_KEY) || 'light',
}),
getters: {
getSystemInfo(): UniApp.GetSystemInfoResult {
return this.systemInfo;
},
getTheme(): string {
return this.theme;
},
},
actions: {
setSystemInfo(info: UniApp.GetSystemInfoResult) {
this.systemInfo = info;
},
initSystemInfo() {
uni.getSystemInfo({
success: (res: UniApp.GetSystemInfoResult) => {
this.setSystemInfo(res);
},
fail: (err: any) => {
console.error(err);
},
});
},
/**
* 设置主题
*/
setTheme(theme: string) {
this.theme = theme;
// 保存到本地存储
storage.set(THEME_KEY, this.theme);
},
},
});
export default useAppStore;

View File

@@ -0,0 +1,5 @@
export type ThemeMode = 'light' | 'dark' | 'auto';
export interface AppState {
systemInfo: UniApp.GetSystemInfoResult;
theme: string;
}

View File

@@ -0,0 +1,105 @@
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 { clearToken, setToken } from '@/utils/auth';
const useUserStore = defineStore('user', {
state: (): UserState => ({
user_id: '',
user_name: '江阳小道',
avatar: '',
token: '',
}),
getters: {
userInfo(state: UserState): UserState {
return { ...state };
},
},
actions: {
// 设置用户的信息
setInfo(partial: Partial<UserState>) {
this.$patch(partial);
},
// 重置用户信息
resetInfo() {
this.$reset();
},
// 获取用户信息
async info() {
const result = await UserApi.profile();
this.setInfo(result);
},
// 异步登录并存储token
login(loginForm: LoginReq) {
return new Promise((resolve, reject) => {
UserApi.login(loginForm).then((res) => {
const token = res.token;
if (token) {
setToken(token);
}
resolve(res);
}).catch((error) => {
reject(error);
});
});
},
// Logout
async logout() {
await UserApi.logout();
this.resetInfo();
clearToken();
},
// 小程序授权登录
authLogin(provider: providerType = 'weixin') {
return new Promise((resolve, reject) => {
uni.login({
provider,
success: async (result: UniApp.LoginRes) => {
if (result.code) {
const res = await UserApi.loginByCode({ code: result.code });
resolve(res);
}
else {
reject(new Error(result.errMsg));
}
},
fail: (err: any) => {
console.error(`login error: ${err}`);
reject(err);
},
});
});
},
// 刷新 Token
async refreshToken() {
const refresh_token = uni.getStorageSync('refresh_token');
if (!refresh_token) {
throw new Error('No refresh token available');
}
try {
const res = await refreshToken(refresh_token);
// 更新 access_token
setToken(res.access_token);
// 更新 refresh_token
uni.setStorageSync('refresh_token', res.refresh_token);
// 更新用户信息
uni.setStorageSync('user_info', res.user);
return res;
}
catch (error) {
// 刷新失败,清除登录状态
clearToken();
uni.removeStorageSync('refresh_token');
uni.removeStorageSync('user_info');
throw error;
}
},
},
persist: true,
});
export default useUserStore;

View File

@@ -0,0 +1,16 @@
export type RoleType = '' | '*' | 'user';
export interface UserState {
user_id?: string;
user_name?: string;
avatar?: string;
token?: string;
}
export type providerType
= | 'weixin'
| 'qq'
| 'sinaweibo'
| 'xiaomi'
| 'apple'
| 'univerify'
| undefined;

55
src/uni.scss Normal file
View File

@@ -0,0 +1,55 @@
@import 'uview-plus/theme.scss';
@import '@/static/styles/theme.scss';
/* 颜色变量 - 使用CSS变量支持主题切换 */
/* 行为相关颜色 */
$u-primary: var(--theme-primary);
$u-primary-dark: var(--theme-primary-dark);
$u-success: var(--theme-success);
$u-warning: var(--theme-warning);
$u-error: var(--theme-error);
/* 文字基本颜色 */
$u-main-color: var(--theme-main-color);
$u-content-color: var(--theme-content-color);
$u-tips-color: var(--theme-tips-color);
$u-light-color: var(--theme-light-color);
$u-disabled-color: var(--theme-disabled-color);
/* 背景颜色 */
$u-bg-color: var(--theme-bg-color);
/* 边框颜色 */
$u-border-color: var(--theme-border-color);
/* 尺寸变量 */
/* 文字尺寸 */
$u-font-sm: 24rpx;
$u-font-base: 28rpx;
$u-font-lg: 32rpx;
/* 图片尺寸 */
$u-img-sm: 40rpx;
$u-img-base: 52rpx;
$u-img-lg: 80rpx;
/* Border Radius */
$u-border-radius-sm: 4rpx;
$u-border-radius-base: 6rpx;
$u-border-radius-lg: 12rpx;
$u-border-radius-circle: 50%;
/* 水平间距 */
$u-spacing-row-sm: 10rpx;
$u-spacing-row-base: 20rpx;
$u-spacing-row-lg: 30rpx;
/* 垂直间距 */
$u-spacing-col-sm: 8rpx;
$u-spacing-col-base: 16rpx;
$u-spacing-col-lg: 24px;
/* 透明度 */
$u-opacity-disabled: 0.3;

15
src/utils/auth/index.ts Normal file
View File

@@ -0,0 +1,15 @@
const TokenKey = 'admin-token';
const TokenPrefix = 'Bearer ';
function isLogin() {
return !!uni.getStorageSync(TokenKey);
}
function getToken() {
return uni.getStorageSync(TokenKey);
}
function setToken(token: string) {
uni.setStorageSync(TokenKey, token);
}
function clearToken() {
uni.removeStorageSync(TokenKey);
}
export { clearToken, getToken, isLogin, setToken, TokenPrefix };

28
src/utils/common/index.ts Normal file
View File

@@ -0,0 +1,28 @@
// 小程序更新检测
export function mpUpdate() {
const updateManager = uni.getUpdateManager();
updateManager.onCheckForUpdate((res) => {
// 请求完新版本信息的回调
console.log(res.hasUpdate);
});
updateManager.onUpdateReady(() => {
uni.showModal({
title: '更新提示',
content: '检测到新版本,是否下载新版本并重启小程序?',
success(res) {
if (res.confirm) {
// 新的版本已经下载好,调用 applyUpdate 应用新版本并重启
updateManager.applyUpdate();
}
},
});
});
updateManager.onUpdateFailed(() => {
// 新的版本下载失败
uni.showModal({
title: '已经有新版本了哟~',
content: '新版本已经上线啦~,请您删除当前小程序,重新搜索打开哟~',
showCancel: false,
});
});
}

59
src/utils/format.ts Normal file
View File

@@ -0,0 +1,59 @@
/**
* 格式化工具函数
*/
/**
* 格式化时间
* @param dateStr ISO 8601 格式的时间字符串
* @param format 格式类型: 'datetime' | 'date' | 'time'
* @returns 格式化后的时间字符串
*/
export function formatTime(dateStr?: string, format: 'datetime' | 'date' | 'time' = 'datetime'): string {
if (!dateStr) return '-'
try {
const date = new Date(dateStr)
if (isNaN(date.getTime())) return dateStr
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
if (format === 'date') {
return `${year}-${month}-${day}`
} else if (format === 'time') {
return `${hours}:${minutes}:${seconds}`
} else {
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
} catch (error) {
return dateStr
}
}
/**
* 格式化流量大小
* @param mb MB 数值
* @returns 格式化后的流量字符串
*/
export function formatDataSize(mb?: number): string {
if (mb === undefined || mb === 0) return '0MB'
if (mb >= 1024) {
return `${(mb / 1024).toFixed(2)}GB`
}
return `${mb}MB`
}
/**
* 格式化金额
* @param amount 金额
* @param decimals 小数位数
* @returns 格式化后的金额字符串
*/
export function formatAmount(amount?: number, decimals: number = 2): string {
if (amount === undefined || amount === null) return '0.00'
return amount.toFixed(decimals)
}

5
src/utils/index.ts Normal file
View File

@@ -0,0 +1,5 @@
export * from './auth';
export * from './common';
export * from './modals';
export * from './request';
export * from './storage';

57
src/utils/modals/index.ts Normal file
View File

@@ -0,0 +1,57 @@
import type { ILoadingOptions, IShowModalOptions, IShowToastOptions } from './types';
/**
* 轻提示
* @param {string} content 提示内容
* @param {object} option 配置
*/
export function Toast(content: string, option: IShowToastOptions = {}) {
uni.showToast({
title: content,
icon: 'none',
mask: true,
duration: 1500,
...option,
});
}
/**
* Loading 提示框
* @param {string} content 提示内容
*/
export const Loading: ILoadingOptions = {
show: (content = '加载中') => {
uni.showLoading({
title: content,
mask: true,
});
},
hide: () => {
uni.hideLoading();
},
};
/**
* Dialog 提示框
* @param {string} content 提示内容
* @param {object} option 配置
*/
export function Dialog(content: string, option: IShowModalOptions = {}) {
option.showCancel = false;
return new Promise((resolve, reject) => {
uni.showModal({
title: '温馨提示',
content,
showCancel: false,
confirmColor: '#1677FF',
success(res) {
if (res.confirm)
resolve(res);
},
fail() {
reject(new Error('Alert 调用失败 !'));
},
...option,
});
});
}

25
src/utils/modals/types.ts Normal file
View File

@@ -0,0 +1,25 @@
export interface IShowToastOptions {
title?: string;
icon?: 'success' | 'loading' | 'error' | 'none';
image?: string;
duration?: number;
position?: 'top' | 'center' | 'bottom';
mask?: boolean;
}
export interface ILoadingOptions {
show?: (content?: string) => void;
hide?: () => void;
}
export interface IShowModalOptions {
title?: string;
content?: string;
showCancel?: boolean;
cancelText?: string;
cancelColor?: string;
confirmText?: string;
confirmColor?: string;
editable?: boolean;
placeholderText?: string;
}

View File

@@ -0,0 +1,58 @@
// 引入配置
import type { HttpRequestConfig, HttpResponse } from 'uview-plus/libs/luch-request/index';
import type { IResponse } from './types';
import Request from 'uview-plus/libs/luch-request/index';
import { requestInterceptors, responseInterceptors } from './interceptors';
const http = new Request();
// 引入拦截器配置
export function setupRequest() {
http.setConfig((defaultConfig: HttpRequestConfig) => {
/* defaultConfig 为默认全局配置 */
defaultConfig.baseURL = import.meta.env.VITE_API_BASE_URL;
// 设置超时时间为 5 秒
defaultConfig.timeout = 5000;
// #ifdef H5
if (import.meta.env.VITE_APP_PROXY === 'true') {
// H5开发环境使用代理baseURL设置为空字符串
// 请求路径中的 /api 会被代理转发到 VITE_API_BASE_URL
defaultConfig.baseURL = '';
}
// #endif
return defaultConfig;
});
requestInterceptors(http);
responseInterceptors(http);
}
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);
const { data } = res.data;
resolve(data as T);
}).catch((err: any) => {
console.error('[ err ] >', err);
reject(err);
});
});
}
export function get<T = any>(url: string, config?: HttpRequestConfig): Promise<T> {
return request({ ...config, url, method: 'GET' });
}
export function post<T = any>(url: string, config?: HttpRequestConfig): Promise<T> {
return request({ ...config, url, method: 'POST' });
}
export function upload<T = any>(url: string, config?: HttpRequestConfig): Promise<T> {
return request({ ...config, url, method: 'UPLOAD' });
}
export function download<T = any>(url: string, config?: HttpRequestConfig): Promise<T> {
return request({ ...config, url, method: 'DOWNLOAD' });
}
export default setupRequest;

View File

@@ -0,0 +1,239 @@
import type {
HttpError,
HttpRequestAbstract,
HttpRequestConfig,
HttpResponse,
} from 'uview-plus/libs/luch-request/index';
import { useUserStore } from '@/store';
import { clearToken, getToken } from '@/utils/auth';
import storage from '@/utils/storage';
import { showMessage } from './status';
// 重试队列,每一项将是一个待执行的函数形式
let requestQueue: (() => void)[] = [];
// 防止重复提交
const repeatSubmit = (config: HttpRequestConfig) => {
const requestObj = {
url: config.url,
data: typeof config.data === 'object' ? JSON.stringify(config.data) : config.data,
time: new Date().getTime(),
};
const sessionObj = storage.getJSON('sessionObj');
if (!sessionObj) {
storage.setJSON('sessionObj', requestObj);
}
else {
const s_url = sessionObj.url; // 请求地址
const s_data = sessionObj.data; // 请求数据
const s_time = sessionObj.time; // 请求时间
const interval = 1000; // 间隔时间(ms),小于此时间视为重复提交
if (s_data === requestObj.data && requestObj.time - s_time < interval && s_url === requestObj.url) {
const message = '数据正在处理,请勿重复提交';
console.warn(`[${s_url}]: ${message}`);
return Promise.reject(new Error(message));
}
else {
storage.setJSON('sessionObj', requestObj);
}
}
};
// 是否正在刷新token的标记
let isRefreshing: boolean = false;
// 跳转到登录页
const redirectToLogin = () => {
// 清除登录状态
clearToken();
uni.removeStorageSync('refresh_token');
uni.removeStorageSync('user_info');
// 跳转到登录页
uni.reLaunch({
url: '/pages/common/login/index',
});
};
// 刷新token
const refreshToken = async (http: HttpRequestAbstract, config: HttpRequestConfig) => {
// 是否在获取token中,防止重复获取
if (!isRefreshing) {
// 修改登录状态为true
isRefreshing = true;
try {
// 调用刷新 token 接口
await useUserStore().refreshToken();
// 刷新完成之后,开始执行队列请求
requestQueue.forEach(cb => cb());
// 重试完了清空这个队列
requestQueue = [];
isRefreshing = false;
// 重新执行本次请求
return http.request(config);
}
catch (error) {
// 刷新失败,清空队列并跳转到登录页
requestQueue = [];
isRefreshing = false;
redirectToLogin();
return Promise.reject(error);
}
}
return new Promise<HttpResponse<any>>((resolve) => {
// 将resolve放进队列用一个函数形式来保存等登录后直接执行
requestQueue.push(() => {
resolve(http.request(config));
});
});
};
function requestInterceptors(http: HttpRequestAbstract) {
/**
* 请求拦截
* @param {object} http
*/
http.interceptors.request.use(
(config: HttpRequestConfig) => {
// 可使用async await 做异步操作
// 初始化请求拦截器时会执行此方法此时data为undefined赋予默认{}
config.data = config.data || {};
// 自定义参数
const custom = config?.custom;
// 是否需要设置 token
const isToken = custom?.auth === false;
if (getToken() && !isToken && config.header) {
// token设置 (使用 Authorization Bearer 格式)
config.header.Authorization = `Bearer ${getToken()}`;
}
// 是否显示 loading
if (custom?.loading) {
uni.showLoading({
title: '加载中',
mask: true,
});
}
// 是否需要防止数据重复提交
const isRepeatSubmit = custom?.repeatSubmit === false;
if (!isRepeatSubmit && (config.method === 'POST' || config.method === 'UPLOAD')) {
repeatSubmit(config);
}
return config;
},
(config: any) => // 可使用async await 做异步操作
Promise.reject(config),
);
}
function responseInterceptors(http: HttpRequestAbstract) {
/**
* 响应拦截
* @param {object} http
*/
http.interceptors.response.use((response: HttpResponse) => {
/* 对响应成功做点什么 可使用async await 做异步操作 */
const data = response.data;
// 配置参数
const config = response.config;
// 自定义参数
const custom = config?.custom;
// 登录状态失效尝试刷新token
if (data.code === 401) {
return refreshToken(http, config);
}
// 隐藏loading
if (custom?.loading) {
uni.hideLoading();
}
// 请求成功则返回结果 (API 文档约定 code=0 为成功)
if (data.code === 0) {
return response || {};
}
// 如果没有显式定义custom的toast参数为false的话默认对报错进行toast弹出提示
if (custom?.toast !== false) {
uni.$u.toast(data.msg || '请求失败');
}
// 请求失败则抛出错误
return Promise.reject(data);
}, (response: HttpError) => {
// 自定义参数
const custom = response.config?.custom;
// 隐藏loading
if (custom?.loading !== false) {
uni.hideLoading();
}
// HTTP状态码处理
const statusCode = response.statusCode;
// 检查是否是超时错误或网络错误
const isTimeout = response.errMsg?.includes('timeout')
|| response.errMsg?.includes('超时')
|| response.errMsg?.includes('request:fail')
|| !statusCode; // 没有状态码通常表示网络问题或超时
// 尝试解析响应体中的错误信息
let errorMessage = '';
try {
// 如果响应体中有 data 且包含 msg 字段,优先使用后端返回的错误信息
if (response.data && typeof response.data === 'object') {
errorMessage = response.data.msg || '';
}
else if (typeof response.data === 'string') {
// 尝试解析 JSON 字符串
const parsedData = JSON.parse(response.data);
errorMessage = parsedData.msg || '';
}
}
catch (e) {
// 解析失败,使用默认错误信息
}
// 超时错误或网络异常 - 显示服务异常提示
if (isTimeout) {
if (custom?.toast !== false) {
uni.$u.toast('服务异常,请稍后重试');
}
return Promise.reject(response);
}
// 401 未授权 - 跳转登录页
if (statusCode === 401) {
if (custom?.toast !== false) {
uni.$u.toast(errorMessage || '登录已过期,请重新登录');
}
redirectToLogin();
return Promise.reject(response);
}
// 500 服务器错误 - 跳转登录页
if (statusCode === 500) {
if (custom?.toast !== false) {
uni.$u.toast(errorMessage || '服务器错误,请重新登录');
}
redirectToLogin();
return Promise.reject(response);
}
// 其他错误(包括 400 等),显示后端返回的错误信息
if (custom?.toast !== false) {
// 优先使用后端返回的 msg其次使用状态码对应的默认提示
const message = errorMessage || (statusCode ? showMessage(statusCode) : '网络连接异常,请稍后再试!');
uni.$u.toast(message);
}
return Promise.reject(response);
});
}
export { requestInterceptors, responseInterceptors };

View File

@@ -0,0 +1,46 @@
/**
* 根据状态码,生成对应的错误信息
* @param {number|string} status 状态码
* @returns {string} 错误信息
*/
export const showMessage = (status: number | string): string => {
let message = '';
switch (status) {
case 400:
message = '请求错误(400)';
break;
case 401:
message = '未授权,请重新登录(401)';
break;
case 403:
message = '拒绝访问(403)';
break;
case 404:
message = '请求出错(404)';
break;
case 408:
message = '请求超时(408)';
break;
case 500:
message = '服务器错误(500)';
break;
case 501:
message = '服务未实现(501)';
break;
case 502:
message = '网络错误(502)';
break;
case 503:
message = '服务不可用(503)';
break;
case 504:
message = '网络超时(504)';
break;
case 505:
message = 'HTTP版本不受支持(505)';
break;
default:
message = `连接出错(${status})!`;
}
return `${message},请检查网络或联系管理员!`;
};

View File

@@ -0,0 +1,7 @@
// 返回res.data的interface (匹配 API 文档格式)
export interface IResponse<T = any> {
code: number;
msg: string;
timestamp: string;
data: T;
}

View File

@@ -0,0 +1,25 @@
const storage = {
set(key: string | null, value: string | null) {
if (key !== null && value !== null)
uni.setStorageSync(key, value);
},
get(key: string | null) {
if (key === null)
return null;
return uni.getStorageSync(key);
},
setJSON(key: any, jsonValue: any) {
if (jsonValue !== null)
this.set(key, JSON.stringify(jsonValue));
},
getJSON(key: any) {
const value = this.get(key);
if (value) return JSON.parse(value);
},
remove(key: string) {
uni.removeStorageSync(key);
},
};
export default storage;