first
This commit is contained in:
41
src/App.vue
Normal file
41
src/App.vue
Normal 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
398
src/api/assets.ts
Normal 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
111
src/api/auth.ts
Normal 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
47
src/api/carrier.ts
Normal 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
128
src/api/commission.ts
Normal 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
12
src/api/common/index.ts
Normal 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
21
src/api/common/types.ts
Normal 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
75
src/api/enterprise.ts
Normal 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
4
src/api/index.ts
Normal 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
18
src/api/user/index.ts
Normal 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
30
src/api/user/types.ts
Normal 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
38
src/api/wallet.ts
Normal 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
89
src/api/withdrawal.ts
Normal 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
0
src/components/.gitkeep
Normal file
182
src/components/SimplePicker.vue
Normal file
182
src/components/SimplePicker.vue
Normal 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>
|
||||
95
src/components/Skeleton.vue
Normal file
95
src/components/Skeleton.vue
Normal 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>
|
||||
214
src/components/agree-privacy/index.vue
Normal file
214
src/components/agree-privacy/index.vue
Normal 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>
|
||||
51
src/components/lang-select/index.vue
Normal file
51
src/components/lang-select/index.vue
Normal 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>
|
||||
32
src/components/theme-picker/index.vue
Normal file
32
src/components/theme-picker/index.vue
Normal 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
9
src/hooks/index.ts
Normal 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 };
|
||||
33
src/hooks/use-clipboard/index.ts
Normal file
33
src/hooks/use-clipboard/index.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
24
src/hooks/use-loading/index.ts
Normal file
24
src/hooks/use-loading/index.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
326
src/hooks/use-location/index.ts
Normal file
326
src/hooks/use-location/index.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
30
src/hooks/use-location/types.ts
Normal file
30
src/hooks/use-location/types.ts
Normal 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;
|
||||
}
|
||||
24
src/hooks/use-modal/index.ts
Normal file
24
src/hooks/use-modal/index.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
10
src/hooks/use-permission/index.ts
Normal file
10
src/hooks/use-permission/index.ts
Normal 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());
|
||||
}
|
||||
48
src/hooks/use-share/index.ts
Normal file
48
src/hooks/use-share/index.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { ShareOptions } from './types';
|
||||
|
||||
/**
|
||||
* 小程序分享
|
||||
* @param {object} options
|
||||
* @example
|
||||
* // 必须要调用onShareAppMessage,onShareTimeline才能正常分享
|
||||
* // 因为小程序平台,必须在注册页面时,主动配置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
|
||||
}
|
||||
6
src/hooks/use-share/types.ts
Normal file
6
src/hooks/use-share/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface ShareOptions {
|
||||
title?: string;
|
||||
path?: string;
|
||||
query?: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
38
src/hooks/use-theme/index.ts
Normal file
38
src/hooks/use-theme/index.ts
Normal 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
21
src/locales/index.ts
Normal 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
11
src/locales/langs/en.ts
Normal 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',
|
||||
},
|
||||
};
|
||||
11
src/locales/langs/zh-Hans.ts
Normal file
11
src/locales/langs/zh-Hans.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export default {
|
||||
locale: {
|
||||
'auto': '系统',
|
||||
'en': '英语',
|
||||
'zh-hans': '中文',
|
||||
},
|
||||
home: {
|
||||
'intro': '欢迎来到uni-app演示',
|
||||
'toggle-langs': '切换语言',
|
||||
},
|
||||
};
|
||||
14
src/main.ts
Normal file
14
src/main.ts
Normal 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
78
src/manifest.json
Normal 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
176
src/pages.json
Normal 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"
|
||||
}
|
||||
}
|
||||
623
src/pages/agent-system/asset-detail/index.vue
Normal file
623
src/pages/agent-system/asset-detail/index.vue
Normal 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>
|
||||
1160
src/pages/agent-system/asset-search/index.vue
Normal file
1160
src/pages/agent-system/asset-search/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
252
src/pages/agent-system/asset-wallet/index.vue
Normal file
252
src/pages/agent-system/asset-wallet/index.vue
Normal 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>
|
||||
841
src/pages/agent-system/assets/index.vue
Normal file
841
src/pages/agent-system/assets/index.vue
Normal 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>
|
||||
438
src/pages/agent-system/change-password/index.vue
Normal file
438
src/pages/agent-system/change-password/index.vue
Normal 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>
|
||||
460
src/pages/agent-system/commission-center/index.vue
Normal file
460
src/pages/agent-system/commission-center/index.vue
Normal 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>
|
||||
16
src/pages/agent-system/commission/index.vue
Normal file
16
src/pages/agent-system/commission/index.vue
Normal 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>
|
||||
536
src/pages/agent-system/enterprise-cards/index.vue
Normal file
536
src/pages/agent-system/enterprise-cards/index.vue
Normal 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>
|
||||
317
src/pages/agent-system/enterprise-devices/index.vue
Normal file
317
src/pages/agent-system/enterprise-devices/index.vue
Normal 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>
|
||||
392
src/pages/agent-system/home/index.vue
Normal file
392
src/pages/agent-system/home/index.vue
Normal 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>
|
||||
1512
src/pages/agent-system/index.vue
Normal file
1512
src/pages/agent-system/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
23
src/pages/agent-system/my-packages/index.vue
Normal file
23
src/pages/agent-system/my-packages/index.vue
Normal 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>
|
||||
245
src/pages/agent-system/packages/index.vue
Normal file
245
src/pages/agent-system/packages/index.vue
Normal 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>
|
||||
23
src/pages/agent-system/tags/index.vue
Normal file
23
src/pages/agent-system/tags/index.vue
Normal 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>
|
||||
264
src/pages/agent-system/user-info/index.vue
Normal file
264
src/pages/agent-system/user-info/index.vue
Normal 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>
|
||||
77
src/pages/agent-system/utils.ts
Normal file
77
src/pages/agent-system/utils.ts
Normal 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' // 绿色
|
||||
}
|
||||
587
src/pages/agent-system/withdraw/index.vue
Normal file
587
src/pages/agent-system/withdraw/index.vue
Normal 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>
|
||||
35
src/pages/common/404/index.vue
Normal file
35
src/pages/common/404/index.vue
Normal 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>
|
||||
481
src/pages/common/login/index.vue
Normal file
481
src/pages/common/login/index.vue
Normal 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>
|
||||
103
src/pages/common/theme/index.vue
Normal file
103
src/pages/common/theme/index.vue
Normal 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>
|
||||
12
src/pages/common/webview/index.vue
Normal file
12
src/pages/common/webview/index.vue
Normal 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>
|
||||
58
src/pages/tab/home/index.vue
Normal file
58
src/pages/tab/home/index.vue
Normal 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
21
src/plugins/index.ts
Normal 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
57
src/plugins/permission.ts
Normal 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
28
src/plugins/ui.ts
Normal 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
85
src/router/index.ts
Normal 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;
|
||||
}
|
||||
0
src/static/icons/.gitkeep
Normal file
0
src/static/icons/.gitkeep
Normal file
BIN
src/static/icons/preview-close-one.png
Normal file
BIN
src/static/icons/preview-close-one.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
BIN
src/static/icons/preview-open.png
Normal file
BIN
src/static/icons/preview-open.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
BIN
src/static/images/404.png
Normal file
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
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
BIN
src/static/images/pay.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
5
src/static/styles/common.scss
Normal file
5
src/static/styles/common.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
page {
|
||||
font-size: $u-font-base;
|
||||
color: $u-main-color;
|
||||
background-color: $u-bg-color;
|
||||
}
|
||||
280
src/static/styles/theme.scss
Normal file
280
src/static/styles/theme.scss
Normal 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
27
src/store/index.ts
Normal 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;
|
||||
47
src/store/modules/app/index.ts
Normal file
47
src/store/modules/app/index.ts
Normal 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;
|
||||
5
src/store/modules/app/types.ts
Normal file
5
src/store/modules/app/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type ThemeMode = 'light' | 'dark' | 'auto';
|
||||
export interface AppState {
|
||||
systemInfo: UniApp.GetSystemInfoResult;
|
||||
theme: string;
|
||||
}
|
||||
105
src/store/modules/user/index.ts
Normal file
105
src/store/modules/user/index.ts
Normal 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;
|
||||
16
src/store/modules/user/types.ts
Normal file
16
src/store/modules/user/types.ts
Normal 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
55
src/uni.scss
Normal 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
15
src/utils/auth/index.ts
Normal 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
28
src/utils/common/index.ts
Normal 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
59
src/utils/format.ts
Normal 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
5
src/utils/index.ts
Normal 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
57
src/utils/modals/index.ts
Normal 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
25
src/utils/modals/types.ts
Normal 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;
|
||||
}
|
||||
58
src/utils/request/index.ts
Normal file
58
src/utils/request/index.ts
Normal 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;
|
||||
239
src/utils/request/interceptors.ts
Normal file
239
src/utils/request/interceptors.ts
Normal 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 };
|
||||
46
src/utils/request/status.ts
Normal file
46
src/utils/request/status.ts
Normal 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},请检查网络或联系管理员!`;
|
||||
};
|
||||
7
src/utils/request/types.ts
Normal file
7
src/utils/request/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// 返回res.data的interface (匹配 API 文档格式)
|
||||
export interface IResponse<T = any> {
|
||||
code: number;
|
||||
msg: string;
|
||||
timestamp: string;
|
||||
data: T;
|
||||
}
|
||||
25
src/utils/storage/index.ts
Normal file
25
src/utils/storage/index.ts
Normal 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;
|
||||
Reference in New Issue
Block a user