fetch(modify):修改原来的bug
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m53s

This commit is contained in:
sexygoat
2026-01-31 11:18:37 +08:00
parent 8a1388608c
commit 31440b2904
62 changed files with 3025 additions and 1421 deletions

View File

@@ -43,8 +43,9 @@
try {
const res = await UserService.getUserInfo()
if (res.code === ApiStatus.success && res.data) {
// API 返回的是 { user, permissions },我们需要保存 user
// API 返回的是 { user, permissions },我们需要保存 user 和 permissions
userStore.setUserInfo(res.data.user)
userStore.setPermissions(res.data.permissions || [])
}
} catch (error) {
console.error('获取用户信息失败:', error)

View File

@@ -38,9 +38,6 @@ export class AuthorizationService extends BaseService {
id: number,
data: UpdateAuthorizationRemarkRequest
): Promise<BaseResponse<AuthorizationItem>> {
return this.put<BaseResponse<AuthorizationItem>>(
`/api/admin/authorizations/${id}/remark`,
data
)
return this.put<BaseResponse<AuthorizationItem>>(`/api/admin/authorizations/${id}/remark`, data)
}
}

View File

@@ -77,10 +77,7 @@ export class DeviceService extends BaseService {
id: number,
data: BindCardToDeviceRequest
): Promise<BaseResponse<BindCardToDeviceResponse>> {
return this.post<BaseResponse<BindCardToDeviceResponse>>(
`/api/admin/devices/${id}/cards`,
data
)
return this.post<BaseResponse<BindCardToDeviceResponse>>(`/api/admin/devices/${id}/cards`, data)
}
/**
@@ -106,19 +103,14 @@ export class DeviceService extends BaseService {
static allocateDevices(
data: AllocateDevicesRequest
): Promise<BaseResponse<AllocateDevicesResponse>> {
return this.post<BaseResponse<AllocateDevicesResponse>>(
'/api/admin/devices/allocate',
data
)
return this.post<BaseResponse<AllocateDevicesResponse>>('/api/admin/devices/allocate', data)
}
/**
* 批量回收设备
* @param data 回收参数
*/
static recallDevices(
data: RecallDevicesRequest
): Promise<BaseResponse<RecallDevicesResponse>> {
static recallDevices(data: RecallDevicesRequest): Promise<BaseResponse<RecallDevicesResponse>> {
return this.post<BaseResponse<RecallDevicesResponse>>('/api/admin/devices/recall', data)
}
@@ -128,9 +120,7 @@ export class DeviceService extends BaseService {
* 批量导入设备
* @param data 导入参数
*/
static importDevices(
data: ImportDeviceRequest
): Promise<BaseResponse<ImportDeviceResponse>> {
static importDevices(data: ImportDeviceRequest): Promise<BaseResponse<ImportDeviceResponse>> {
return this.post<BaseResponse<ImportDeviceResponse>>('/api/admin/devices/import', data)
}

View File

@@ -135,9 +135,7 @@ export class EnterpriseService extends BaseService {
* @param cardId 卡ID
*/
static resumeCard(enterpriseId: number, cardId: number): Promise<BaseResponse> {
return this.post<BaseResponse>(
`/api/admin/enterprises/${enterpriseId}/cards/${cardId}/resume`
)
return this.post<BaseResponse>(`/api/admin/enterprises/${enterpriseId}/cards/${cardId}/resume`)
}
/**
@@ -146,9 +144,7 @@ export class EnterpriseService extends BaseService {
* @param cardId 卡ID
*/
static suspendCard(enterpriseId: number, cardId: number): Promise<BaseResponse> {
return this.post<BaseResponse>(
`/api/admin/enterprises/${enterpriseId}/cards/${cardId}/suspend`
)
return this.post<BaseResponse>(`/api/admin/enterprises/${enterpriseId}/cards/${cardId}/suspend`)
}
/**

View File

@@ -87,5 +87,4 @@ export class PackageManageService extends BaseService {
const data: UpdatePackageShelfStatusRequest = { shelf_status }
return this.patch<BaseResponse>(`/api/admin/packages/${id}/shelf`, data)
}
}

View File

@@ -73,10 +73,7 @@ export class PackageSeriesService extends BaseService {
* @param id 系列ID
* @param status 状态 (1:启用, 2:禁用)
*/
static updatePackageSeriesStatus(
id: number,
status: number
): Promise<BaseResponse> {
static updatePackageSeriesStatus(id: number, status: number): Promise<BaseResponse> {
const data: UpdatePackageSeriesStatusRequest = { status }
return this.put<BaseResponse>(`/api/admin/package-series/${id}/status`, data)
}

View File

@@ -36,10 +36,7 @@ export class ShopPackageAllocationService extends BaseService {
static createShopPackageAllocation(
data: CreateShopPackageAllocationRequest
): Promise<BaseResponse<ShopPackageAllocationResponse>> {
return this.create<ShopPackageAllocationResponse>(
'/api/admin/shop-package-allocations',
data
)
return this.create<ShopPackageAllocationResponse>('/api/admin/shop-package-allocations', data)
}
/**
@@ -50,9 +47,7 @@ export class ShopPackageAllocationService extends BaseService {
static getShopPackageAllocationDetail(
id: number
): Promise<BaseResponse<ShopPackageAllocationResponse>> {
return this.getOne<ShopPackageAllocationResponse>(
`/api/admin/shop-package-allocations/${id}`
)
return this.getOne<ShopPackageAllocationResponse>(`/api/admin/shop-package-allocations/${id}`)
}
/**

View File

@@ -22,10 +22,7 @@ export class ShopSeriesAllocationService extends BaseService {
static getShopSeriesAllocations(
params?: ShopSeriesAllocationQueryParams
): Promise<PaginationResponse<ShopSeriesAllocationResponse>> {
return this.getPage<ShopSeriesAllocationResponse>(
'/api/admin/shop-series-allocations',
params
)
return this.getPage<ShopSeriesAllocationResponse>('/api/admin/shop-series-allocations', params)
}
/**

View File

@@ -94,8 +94,8 @@ export class StorageService extends BaseService {
if (error.name === 'TypeError' && error.message.includes('Failed to fetch')) {
throw new Error(
'CORS 错误: 无法上传文件到对象存储。' +
'这通常是因为对象存储服务器未正确配置 CORS 策略。' +
'请联系后端开发人员检查对象存储的 CORS 配置。'
'这通常是因为对象存储服务器未正确配置 CORS 策略。' +
'请联系后端开发人员检查对象存储的 CORS 配置。'
)
}
throw error

View File

@@ -2,7 +2,15 @@
// 强制所有元素使用小米字体
* {
font-family: 'MiSans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif !important;
font-family:
'MiSans',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Arial,
sans-serif !important;
}
.btn-icon {

View File

@@ -10,7 +10,9 @@
// 按钮粗度
--el-font-weight-primary: 400 !important;
// Element Plus 全局字体
--el-font-family: 'MiSans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif !important;
--el-font-family:
'MiSans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
sans-serif !important;
--el-component-custom-height: 36px !important;
@@ -182,7 +184,15 @@
// 修改el-button样式
.el-button {
font-family: 'MiSans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif !important;
font-family:
'MiSans',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Arial,
sans-serif !important;
&.el-button--text {
background-color: transparent !important;
@@ -202,7 +212,15 @@
border-radius: 6px !important;
font-weight: bold;
transition: all 0s !important;
font-family: 'MiSans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif !important;
font-family:
'MiSans',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Arial,
sans-serif !important;
}
// 为所有 Element Plus 组件添加小米字体
@@ -227,7 +245,15 @@
.el-upload,
.el-card,
.el-divider {
font-family: 'MiSans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif !important;
font-family:
'MiSans',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Arial,
sans-serif !important;
}
.el-checkbox-group {

View File

@@ -34,7 +34,15 @@ h5 {
body {
color: var(--art-text-gray-700);
text-align: left;
font-family: 'MiSans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-family:
'MiSans',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Arial,
sans-serif;
}
select {

Binary file not shown.

View File

@@ -252,10 +252,7 @@ export const PRICE_SOURCE_MAP = PRICE_SOURCE_OPTIONS.reduce(
map[item.value] = item
return map
},
{} as Record<
PriceSource,
{ label: string; value: PriceSource; type: 'primary' | 'warning' }
>
{} as Record<PriceSource, { label: string; value: PriceSource; type: 'primary' | 'warning' }>
)
/**

View File

@@ -1,17 +1,41 @@
import { router } from '@/router'
import { App, Directive } from 'vue'
import { App, Directive, DirectiveBinding } from 'vue'
import { useUserStore } from '@/store/modules/user'
/**
* 权限指令(后端控制模式可用)
* 权限指令
* 用法:
* v-permission="'menu:get'" - 单个权限
* v-permission="['menu:get', 'role:get']" - 多个权限(满足任意一个即可)
* v-permission:all="['menu:get', 'role:get']" - 多个权限(需要全部满足)
*
* 兼容旧的v-auth指令:
* <el-button v-auth="'add'">按钮</el-button>
*/
const authDirective: Directive = {
const permissionDirective: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding) {
const authList = (router.currentRoute.value.meta.authList as Array<{ auth_mark: string }>) || []
const userStore = useUserStore()
const { value, arg } = binding
const hasPermission = authList.some((item) => item.auth_mark === binding.value)
// 如果没有值,直接返回
if (!value) return
let hasPermission = false
if (typeof value === 'string') {
// 单个权限
hasPermission = userStore.hasPermission(value)
} else if (Array.isArray(value)) {
// 多个权限
if (arg === 'all') {
// 需要全部满足
hasPermission = userStore.hasAllPermissions(value)
} else {
// 满足任意一个即可
hasPermission = userStore.hasAnyPermission(value)
}
}
// 如果没有权限,移除元素
if (!hasPermission) {
el.parentNode?.removeChild(el)
}
@@ -19,5 +43,7 @@ const authDirective: Directive = {
}
export function setupPermissionDirective(app: App) {
app.directive('auth', authDirective)
// 注册为 v-permission 和 v-auth (向后兼容)
app.directive('permission', permissionDirective)
app.directive('auth', permissionDirective)
}

View File

@@ -442,7 +442,6 @@
"standaloneCardList": "IoT卡管理",
"iotCardTask": "IoT卡任务",
"deviceTask": "设备任务",
"taskDetail": "任务详情",
"devices": "设备管理",
"deviceDetail": "设备详情",
"assetAssign": "分配记录",
@@ -473,13 +472,6 @@
"paymentMerchant": "支付商户",
"developerApi": "开发者API",
"commissionTemplate": "分佣模板"
},
"batch": {
"title": "批量操作",
"simImport": "网卡导入",
"deviceImport": "设备导入",
"offlineBatchRecharge": "线下批量充值",
"cardChangeNotice": "换卡通知"
}
},
"table": {

View File

@@ -890,24 +890,6 @@ export const asyncRoutes: AppRouteRecord[] = [
icon: '&#xe816;'
},
children: [
{
path: 'iot-card-query',
name: 'CardSearch',
component: RoutesAlias.CardSearch,
meta: {
title: 'menus.assetManagement.cardSearch',
keepAlive: true
}
},
{
path: 'device-search',
name: 'DeviceSearch',
component: RoutesAlias.DeviceSearch,
meta: {
title: 'menus.assetManagement.deviceSearch',
keepAlive: true
}
},
{
path: 'iot-card-management',
name: 'StandaloneCardList',
@@ -935,16 +917,6 @@ export const asyncRoutes: AppRouteRecord[] = [
keepAlive: true
}
},
{
path: 'task-detail',
name: 'TaskDetail',
component: RoutesAlias.TaskDetail,
meta: {
title: 'menus.assetManagement.taskDetail',
isHide: true,
keepAlive: false
}
},
{
path: 'devices',
name: 'DeviceList',
@@ -1129,7 +1101,7 @@ export const asyncRoutes: AppRouteRecord[] = [
]
}
]
},
}
// {
// path: '/settings',
// name: 'Settings',
@@ -1167,52 +1139,5 @@ export const asyncRoutes: AppRouteRecord[] = [
// }
// }
// ]
// },
{
path: '/batch',
name: 'Batch',
component: RoutesAlias.Home,
meta: {
title: 'menus.batch.title',
icon: '&#xe820;'
},
children: [
{
path: 'sim-import',
name: 'SimImport',
component: RoutesAlias.SimImport,
meta: {
title: 'menus.batch.simImport',
keepAlive: true
}
},
{
path: 'device-import',
name: 'DeviceImport',
component: RoutesAlias.DeviceImport,
meta: {
title: 'menus.batch.deviceImport',
keepAlive: true
}
},
// {
// path: 'offline-batch-recharge',
// name: 'OfflineBatchRecharge',
// component: RoutesAlias.OfflineBatchRecharge,
// meta: {
// title: 'menus.batch.offlineBatchRecharge',
// keepAlive: true
// }
// },
// {
// path: 'card-change-notice',
// name: 'CardChangeNotice',
// component: RoutesAlias.CardChangeNotice,
// meta: {
// title: 'menus.batch.cardChangeNotice',
// keepAlive: true
// }
// }
]
}
// }
]

View File

@@ -91,12 +91,9 @@ export enum RoutesAlias {
SimCardAssign = '/product/sim-card-assign', // 号卡分配
// 资产管理
CardSearch = '/asset-management/iot-card-query', // IoT卡查询
DeviceSearch = '/asset-management/device-search', // 设备查询
StandaloneCardList = '/asset-management/iot-card-management', // IoT卡管理
IotCardTask = '/asset-management/iot-card-task', // IoT卡任务
DeviceTask = '/asset-management/device-task', // 设备任务
TaskDetail = '/asset-management/task-detail', // 任务详情
DeviceList = '/asset-management/device-list', // 设备列表
DeviceDetail = '/asset-management/device-detail', // 设备详情
AssetAssign = '/asset-management/asset-assign', // 资产分配(分配记录)
@@ -121,12 +118,7 @@ export enum RoutesAlias {
// 设置管理
PaymentMerchant = '/settings/payment-merchant', // 支付商户
DeveloperApi = '/settings/developer-api', // 开发者API
CommissionTemplate = '/settings/commission-template', // 分佣模板
// 批量操作
SimImport = '/batch/sim-import', // 网卡批量导入
DeviceImport = '/batch/device-import', // 设备批量导入
CardChangeNotice = '/batch/card-change-notice' // 换卡通知
CommissionTemplate = '/settings/commission-template' // 分佣模板
}
// 主页路由

View File

@@ -23,15 +23,45 @@ export const useUserStore = defineStore(
const searchHistory = ref<AppRouteRecord[]>([])
const accessToken = ref('')
const refreshToken = ref('')
const permissions = ref<string[]>([])
const getUserInfo = computed(() => info.value)
const getSettingState = computed(() => useSettingStore().$state)
const getWorktabState = computed(() => useWorktabStore().$state)
const getPermissions = computed(() => permissions.value)
const setUserInfo = (newInfo: UserInfo) => {
info.value = newInfo
}
const setPermissions = (perms: string[]) => {
permissions.value = perms
}
// 检查是否是超级管理员
const isSuperAdmin = computed(() => info.value.user_type === 1)
// 检查是否有某个权限
const hasPermission = (permission: string): boolean => {
// 超级管理员拥有所有权限
if (isSuperAdmin.value) return true
return permissions.value.includes(permission)
}
// 检查是否有某些权限中的任意一个
const hasAnyPermission = (perms: string[]): boolean => {
// 超级管理员拥有所有权限
if (isSuperAdmin.value) return true
return perms.some((perm) => permissions.value.includes(perm))
}
// 检查是否有所有指定权限
const hasAllPermissions = (perms: string[]): boolean => {
// 超级管理员拥有所有权限
if (isSuperAdmin.value) return true
return perms.every((perm) => permissions.value.includes(perm))
}
const setLoginStatus = (status: boolean) => {
isLogin.value = status
}
@@ -74,6 +104,7 @@ export const useUserStore = defineStore(
lockPassword.value = ''
accessToken.value = ''
refreshToken.value = ''
permissions.value = []
useWorktabStore().opened = []
sessionStorage.removeItem('iframeRoutes')
resetRouterState(router)
@@ -90,10 +121,17 @@ export const useUserStore = defineStore(
searchHistory,
accessToken,
refreshToken,
permissions,
getUserInfo,
getSettingState,
getWorktabState,
getPermissions,
isSuperAdmin,
setUserInfo,
setPermissions,
hasPermission,
hasAnyPermission,
hasAllPermissions,
setLoginStatus,
setLanguage,
setSearchHistory,

View File

@@ -6,7 +6,7 @@
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const EffectScope: (typeof import('vue'))['EffectScope']
const ElButton: (typeof import('element-plus/es'))['ElButton']
const ElMessage: (typeof import('element-plus/es'))['ElMessage']
const ElMessageBox: (typeof import('element-plus/es'))['ElMessageBox']
@@ -14,304 +14,319 @@ declare global {
const ElPopconfirm: (typeof import('element-plus/es'))['ElPopconfirm']
const ElPopover: (typeof import('element-plus/es'))['ElPopover']
const ElTableColumn: (typeof import('element-plus/es'))['ElTableColumn']
const ElTag: typeof import('element-plus/es')['ElTag']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const asyncComputed: typeof import('@vueuse/core')['asyncComputed']
const autoResetRef: typeof import('@vueuse/core')['autoResetRef']
const computed: typeof import('vue')['computed']
const computedAsync: typeof import('@vueuse/core')['computedAsync']
const computedEager: typeof import('@vueuse/core')['computedEager']
const computedInject: typeof import('@vueuse/core')['computedInject']
const computedWithControl: typeof import('@vueuse/core')['computedWithControl']
const controlledComputed: typeof import('@vueuse/core')['controlledComputed']
const controlledRef: typeof import('@vueuse/core')['controlledRef']
const createApp: typeof import('vue')['createApp']
const createEventHook: typeof import('@vueuse/core')['createEventHook']
const createGlobalState: typeof import('@vueuse/core')['createGlobalState']
const createInjectionState: typeof import('@vueuse/core')['createInjectionState']
const createPinia: typeof import('pinia')['createPinia']
const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn']
const createReusableTemplate: typeof import('@vueuse/core')['createReusableTemplate']
const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']
const createTemplatePromise: typeof import('@vueuse/core')['createTemplatePromise']
const createUnrefFn: typeof import('@vueuse/core')['createUnrefFn']
const customRef: typeof import('vue')['customRef']
const debouncedRef: typeof import('@vueuse/core')['debouncedRef']
const debouncedWatch: typeof import('@vueuse/core')['debouncedWatch']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const defineStore: typeof import('pinia')['defineStore']
const eagerComputed: typeof import('@vueuse/core')['eagerComputed']
const effectScope: typeof import('vue')['effectScope']
const extendRef: typeof import('@vueuse/core')['extendRef']
const getActivePinia: typeof import('pinia')['getActivePinia']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch']
const inject: typeof import('vue')['inject']
const injectLocal: typeof import('@vueuse/core')['injectLocal']
const isDefined: typeof import('@vueuse/core')['isDefined']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState']
const mapStores: typeof import('pinia')['mapStores']
const mapWritableState: typeof import('pinia')['mapWritableState']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onClickOutside: typeof import('@vueuse/core')['onClickOutside']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onKeyStroke: typeof import('@vueuse/core')['onKeyStroke']
const onLongPress: typeof import('@vueuse/core')['onLongPress']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onStartTyping: typeof import('@vueuse/core')['onStartTyping']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const pausableWatch: typeof import('@vueuse/core')['pausableWatch']
const provide: typeof import('vue')['provide']
const provideLocal: typeof import('@vueuse/core')['provideLocal']
const reactify: typeof import('@vueuse/core')['reactify']
const reactifyObject: typeof import('@vueuse/core')['reactifyObject']
const reactive: typeof import('vue')['reactive']
const reactiveComputed: typeof import('@vueuse/core')['reactiveComputed']
const reactiveOmit: typeof import('@vueuse/core')['reactiveOmit']
const reactivePick: typeof import('@vueuse/core')['reactivePick']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const refAutoReset: typeof import('@vueuse/core')['refAutoReset']
const refDebounced: typeof import('@vueuse/core')['refDebounced']
const refDefault: typeof import('@vueuse/core')['refDefault']
const refThrottled: typeof import('@vueuse/core')['refThrottled']
const refWithControl: typeof import('@vueuse/core')['refWithControl']
const resolveComponent: typeof import('vue')['resolveComponent']
const resolveRef: typeof import('@vueuse/core')['resolveRef']
const resolveUnref: typeof import('@vueuse/core')['resolveUnref']
const setActivePinia: typeof import('pinia')['setActivePinia']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const storeToRefs: typeof import('pinia')['storeToRefs']
const syncRef: typeof import('@vueuse/core')['syncRef']
const syncRefs: typeof import('@vueuse/core')['syncRefs']
const templateRef: typeof import('@vueuse/core')['templateRef']
const throttledRef: typeof import('@vueuse/core')['throttledRef']
const throttledWatch: typeof import('@vueuse/core')['throttledWatch']
const toRaw: typeof import('vue')['toRaw']
const toReactive: typeof import('@vueuse/core')['toReactive']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const tryOnBeforeMount: typeof import('@vueuse/core')['tryOnBeforeMount']
const tryOnBeforeUnmount: typeof import('@vueuse/core')['tryOnBeforeUnmount']
const tryOnMounted: typeof import('@vueuse/core')['tryOnMounted']
const tryOnScopeDispose: typeof import('@vueuse/core')['tryOnScopeDispose']
const tryOnUnmounted: typeof import('@vueuse/core')['tryOnUnmounted']
const unref: typeof import('vue')['unref']
const unrefElement: typeof import('@vueuse/core')['unrefElement']
const until: typeof import('@vueuse/core')['until']
const useActiveElement: typeof import('@vueuse/core')['useActiveElement']
const useAnimate: typeof import('@vueuse/core')['useAnimate']
const useArrayDifference: typeof import('@vueuse/core')['useArrayDifference']
const useArrayEvery: typeof import('@vueuse/core')['useArrayEvery']
const useArrayFilter: typeof import('@vueuse/core')['useArrayFilter']
const useArrayFind: typeof import('@vueuse/core')['useArrayFind']
const useArrayFindIndex: typeof import('@vueuse/core')['useArrayFindIndex']
const useArrayFindLast: typeof import('@vueuse/core')['useArrayFindLast']
const useArrayIncludes: typeof import('@vueuse/core')['useArrayIncludes']
const useArrayJoin: typeof import('@vueuse/core')['useArrayJoin']
const useArrayMap: typeof import('@vueuse/core')['useArrayMap']
const useArrayReduce: typeof import('@vueuse/core')['useArrayReduce']
const useArraySome: typeof import('@vueuse/core')['useArraySome']
const useArrayUnique: typeof import('@vueuse/core')['useArrayUnique']
const useAsyncQueue: typeof import('@vueuse/core')['useAsyncQueue']
const useAsyncState: typeof import('@vueuse/core')['useAsyncState']
const useAttrs: typeof import('vue')['useAttrs']
const useBase64: typeof import('@vueuse/core')['useBase64']
const useBattery: typeof import('@vueuse/core')['useBattery']
const useBluetooth: typeof import('@vueuse/core')['useBluetooth']
const useBreakpoints: typeof import('@vueuse/core')['useBreakpoints']
const useBroadcastChannel: typeof import('@vueuse/core')['useBroadcastChannel']
const useBrowserLocation: typeof import('@vueuse/core')['useBrowserLocation']
const useCached: typeof import('@vueuse/core')['useCached']
const useClipboard: typeof import('@vueuse/core')['useClipboard']
const useClipboardItems: typeof import('@vueuse/core')['useClipboardItems']
const useCloned: typeof import('@vueuse/core')['useCloned']
const useColorMode: typeof import('@vueuse/core')['useColorMode']
const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog']
const useCounter: typeof import('@vueuse/core')['useCounter']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVar: typeof import('@vueuse/core')['useCssVar']
const useCssVars: typeof import('vue')['useCssVars']
const useCurrentElement: typeof import('@vueuse/core')['useCurrentElement']
const useCycleList: typeof import('@vueuse/core')['useCycleList']
const useDark: typeof import('@vueuse/core')['useDark']
const useDateFormat: typeof import('@vueuse/core')['useDateFormat']
const useDebounce: typeof import('@vueuse/core')['useDebounce']
const useDebounceFn: typeof import('@vueuse/core')['useDebounceFn']
const useDebouncedRefHistory: typeof import('@vueuse/core')['useDebouncedRefHistory']
const useDeviceMotion: typeof import('@vueuse/core')['useDeviceMotion']
const useDeviceOrientation: typeof import('@vueuse/core')['useDeviceOrientation']
const useDevicePixelRatio: typeof import('@vueuse/core')['useDevicePixelRatio']
const useDevicesList: typeof import('@vueuse/core')['useDevicesList']
const useDisplayMedia: typeof import('@vueuse/core')['useDisplayMedia']
const useDocumentVisibility: typeof import('@vueuse/core')['useDocumentVisibility']
const useDraggable: typeof import('@vueuse/core')['useDraggable']
const useDropZone: typeof import('@vueuse/core')['useDropZone']
const useElementBounding: typeof import('@vueuse/core')['useElementBounding']
const useElementByPoint: typeof import('@vueuse/core')['useElementByPoint']
const useElementHover: typeof import('@vueuse/core')['useElementHover']
const useElementSize: typeof import('@vueuse/core')['useElementSize']
const useElementVisibility: typeof import('@vueuse/core')['useElementVisibility']
const useEventBus: typeof import('@vueuse/core')['useEventBus']
const useEventListener: typeof import('@vueuse/core')['useEventListener']
const useEventSource: typeof import('@vueuse/core')['useEventSource']
const useEyeDropper: typeof import('@vueuse/core')['useEyeDropper']
const useFavicon: typeof import('@vueuse/core')['useFavicon']
const useFetch: typeof import('@vueuse/core')['useFetch']
const useFileDialog: typeof import('@vueuse/core')['useFileDialog']
const useFileSystemAccess: typeof import('@vueuse/core')['useFileSystemAccess']
const useFocus: typeof import('@vueuse/core')['useFocus']
const useFocusWithin: typeof import('@vueuse/core')['useFocusWithin']
const useFps: typeof import('@vueuse/core')['useFps']
const useFullscreen: typeof import('@vueuse/core')['useFullscreen']
const useGamepad: typeof import('@vueuse/core')['useGamepad']
const useGeolocation: typeof import('@vueuse/core')['useGeolocation']
const useId: typeof import('vue')['useId']
const useIdle: typeof import('@vueuse/core')['useIdle']
const useImage: typeof import('@vueuse/core')['useImage']
const useInfiniteScroll: typeof import('@vueuse/core')['useInfiniteScroll']
const useIntersectionObserver: typeof import('@vueuse/core')['useIntersectionObserver']
const useInterval: typeof import('@vueuse/core')['useInterval']
const useIntervalFn: typeof import('@vueuse/core')['useIntervalFn']
const useKeyModifier: typeof import('@vueuse/core')['useKeyModifier']
const useLastChanged: typeof import('@vueuse/core')['useLastChanged']
const useLink: typeof import('vue-router')['useLink']
const useLocalStorage: typeof import('@vueuse/core')['useLocalStorage']
const useMagicKeys: typeof import('@vueuse/core')['useMagicKeys']
const useManualRefHistory: typeof import('@vueuse/core')['useManualRefHistory']
const useMediaControls: typeof import('@vueuse/core')['useMediaControls']
const useMediaQuery: typeof import('@vueuse/core')['useMediaQuery']
const useMemoize: typeof import('@vueuse/core')['useMemoize']
const useMemory: typeof import('@vueuse/core')['useMemory']
const useModel: typeof import('vue')['useModel']
const useMounted: typeof import('@vueuse/core')['useMounted']
const useMouse: typeof import('@vueuse/core')['useMouse']
const useMouseInElement: typeof import('@vueuse/core')['useMouseInElement']
const useMousePressed: typeof import('@vueuse/core')['useMousePressed']
const useMutationObserver: typeof import('@vueuse/core')['useMutationObserver']
const useNavigatorLanguage: typeof import('@vueuse/core')['useNavigatorLanguage']
const useNetwork: typeof import('@vueuse/core')['useNetwork']
const useNow: typeof import('@vueuse/core')['useNow']
const useObjectUrl: typeof import('@vueuse/core')['useObjectUrl']
const useOffsetPagination: typeof import('@vueuse/core')['useOffsetPagination']
const useOnline: typeof import('@vueuse/core')['useOnline']
const usePageLeave: typeof import('@vueuse/core')['usePageLeave']
const useParallax: typeof import('@vueuse/core')['useParallax']
const useParentElement: typeof import('@vueuse/core')['useParentElement']
const usePerformanceObserver: typeof import('@vueuse/core')['usePerformanceObserver']
const usePermission: typeof import('@vueuse/core')['usePermission']
const usePointer: typeof import('@vueuse/core')['usePointer']
const usePointerLock: typeof import('@vueuse/core')['usePointerLock']
const usePointerSwipe: typeof import('@vueuse/core')['usePointerSwipe']
const usePreferredColorScheme: typeof import('@vueuse/core')['usePreferredColorScheme']
const usePreferredContrast: typeof import('@vueuse/core')['usePreferredContrast']
const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark']
const usePreferredLanguages: typeof import('@vueuse/core')['usePreferredLanguages']
const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion']
const usePrevious: typeof import('@vueuse/core')['usePrevious']
const useRafFn: typeof import('@vueuse/core')['useRafFn']
const useRefHistory: typeof import('@vueuse/core')['useRefHistory']
const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation']
const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea']
const useScriptTag: typeof import('@vueuse/core')['useScriptTag']
const useScroll: typeof import('@vueuse/core')['useScroll']
const useScrollLock: typeof import('@vueuse/core')['useScrollLock']
const useSessionStorage: typeof import('@vueuse/core')['useSessionStorage']
const useShare: typeof import('@vueuse/core')['useShare']
const useSlots: typeof import('vue')['useSlots']
const useSorted: typeof import('@vueuse/core')['useSorted']
const useSpeechRecognition: typeof import('@vueuse/core')['useSpeechRecognition']
const useSpeechSynthesis: typeof import('@vueuse/core')['useSpeechSynthesis']
const useStepper: typeof import('@vueuse/core')['useStepper']
const useStorage: typeof import('@vueuse/core')['useStorage']
const useStorageAsync: typeof import('@vueuse/core')['useStorageAsync']
const useStyleTag: typeof import('@vueuse/core')['useStyleTag']
const useSupported: typeof import('@vueuse/core')['useSupported']
const useSwipe: typeof import('@vueuse/core')['useSwipe']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const useTemplateRefsList: typeof import('@vueuse/core')['useTemplateRefsList']
const useTextDirection: typeof import('@vueuse/core')['useTextDirection']
const useTextSelection: typeof import('@vueuse/core')['useTextSelection']
const useTextareaAutosize: typeof import('@vueuse/core')['useTextareaAutosize']
const useThrottle: typeof import('@vueuse/core')['useThrottle']
const useThrottleFn: typeof import('@vueuse/core')['useThrottleFn']
const useThrottledRefHistory: typeof import('@vueuse/core')['useThrottledRefHistory']
const useTimeAgo: typeof import('@vueuse/core')['useTimeAgo']
const useTimeout: typeof import('@vueuse/core')['useTimeout']
const useTimeoutFn: typeof import('@vueuse/core')['useTimeoutFn']
const useTimeoutPoll: typeof import('@vueuse/core')['useTimeoutPoll']
const useTimestamp: typeof import('@vueuse/core')['useTimestamp']
const useTitle: typeof import('@vueuse/core')['useTitle']
const useToNumber: typeof import('@vueuse/core')['useToNumber']
const useToString: typeof import('@vueuse/core')['useToString']
const useToggle: typeof import('@vueuse/core')['useToggle']
const useTransition: typeof import('@vueuse/core')['useTransition']
const useUrlSearchParams: typeof import('@vueuse/core')['useUrlSearchParams']
const useUserMedia: typeof import('@vueuse/core')['useUserMedia']
const useVModel: typeof import('@vueuse/core')['useVModel']
const useVModels: typeof import('@vueuse/core')['useVModels']
const useVibrate: typeof import('@vueuse/core')['useVibrate']
const useVirtualList: typeof import('@vueuse/core')['useVirtualList']
const useWakeLock: typeof import('@vueuse/core')['useWakeLock']
const useWebNotification: typeof import('@vueuse/core')['useWebNotification']
const useWebSocket: typeof import('@vueuse/core')['useWebSocket']
const useWebWorker: typeof import('@vueuse/core')['useWebWorker']
const useWebWorkerFn: typeof import('@vueuse/core')['useWebWorkerFn']
const useWindowFocus: typeof import('@vueuse/core')['useWindowFocus']
const useWindowScroll: typeof import('@vueuse/core')['useWindowScroll']
const useWindowSize: typeof import('@vueuse/core')['useWindowSize']
const watch: typeof import('vue')['watch']
const watchArray: typeof import('@vueuse/core')['watchArray']
const watchAtMost: typeof import('@vueuse/core')['watchAtMost']
const watchDebounced: typeof import('@vueuse/core')['watchDebounced']
const watchDeep: typeof import('@vueuse/core')['watchDeep']
const watchEffect: typeof import('vue')['watchEffect']
const watchIgnorable: typeof import('@vueuse/core')['watchIgnorable']
const watchImmediate: typeof import('@vueuse/core')['watchImmediate']
const watchOnce: typeof import('@vueuse/core')['watchOnce']
const watchPausable: typeof import('@vueuse/core')['watchPausable']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
const watchThrottled: typeof import('@vueuse/core')['watchThrottled']
const watchTriggerable: typeof import('@vueuse/core')['watchTriggerable']
const watchWithFilter: typeof import('@vueuse/core')['watchWithFilter']
const whenever: typeof import('@vueuse/core')['whenever']
const ElTag: (typeof import('element-plus/es'))['ElTag']
const acceptHMRUpdate: (typeof import('pinia'))['acceptHMRUpdate']
const asyncComputed: (typeof import('@vueuse/core'))['asyncComputed']
const autoResetRef: (typeof import('@vueuse/core'))['autoResetRef']
const computed: (typeof import('vue'))['computed']
const computedAsync: (typeof import('@vueuse/core'))['computedAsync']
const computedEager: (typeof import('@vueuse/core'))['computedEager']
const computedInject: (typeof import('@vueuse/core'))['computedInject']
const computedWithControl: (typeof import('@vueuse/core'))['computedWithControl']
const controlledComputed: (typeof import('@vueuse/core'))['controlledComputed']
const controlledRef: (typeof import('@vueuse/core'))['controlledRef']
const createApp: (typeof import('vue'))['createApp']
const createEventHook: (typeof import('@vueuse/core'))['createEventHook']
const createGlobalState: (typeof import('@vueuse/core'))['createGlobalState']
const createInjectionState: (typeof import('@vueuse/core'))['createInjectionState']
const createPinia: (typeof import('pinia'))['createPinia']
const createReactiveFn: (typeof import('@vueuse/core'))['createReactiveFn']
const createReusableTemplate: (typeof import('@vueuse/core'))['createReusableTemplate']
const createSharedComposable: (typeof import('@vueuse/core'))['createSharedComposable']
const createTemplatePromise: (typeof import('@vueuse/core'))['createTemplatePromise']
const createUnrefFn: (typeof import('@vueuse/core'))['createUnrefFn']
const customRef: (typeof import('vue'))['customRef']
const debouncedRef: (typeof import('@vueuse/core'))['debouncedRef']
const debouncedWatch: (typeof import('@vueuse/core'))['debouncedWatch']
const defineAsyncComponent: (typeof import('vue'))['defineAsyncComponent']
const defineComponent: (typeof import('vue'))['defineComponent']
const defineStore: (typeof import('pinia'))['defineStore']
const eagerComputed: (typeof import('@vueuse/core'))['eagerComputed']
const effectScope: (typeof import('vue'))['effectScope']
const extendRef: (typeof import('@vueuse/core'))['extendRef']
const getActivePinia: (typeof import('pinia'))['getActivePinia']
const getCurrentInstance: (typeof import('vue'))['getCurrentInstance']
const getCurrentScope: (typeof import('vue'))['getCurrentScope']
const h: (typeof import('vue'))['h']
const ignorableWatch: (typeof import('@vueuse/core'))['ignorableWatch']
const inject: (typeof import('vue'))['inject']
const injectLocal: (typeof import('@vueuse/core'))['injectLocal']
const isDefined: (typeof import('@vueuse/core'))['isDefined']
const isProxy: (typeof import('vue'))['isProxy']
const isReactive: (typeof import('vue'))['isReactive']
const isReadonly: (typeof import('vue'))['isReadonly']
const isRef: (typeof import('vue'))['isRef']
const makeDestructurable: (typeof import('@vueuse/core'))['makeDestructurable']
const mapActions: (typeof import('pinia'))['mapActions']
const mapGetters: (typeof import('pinia'))['mapGetters']
const mapState: (typeof import('pinia'))['mapState']
const mapStores: (typeof import('pinia'))['mapStores']
const mapWritableState: (typeof import('pinia'))['mapWritableState']
const markRaw: (typeof import('vue'))['markRaw']
const nextTick: (typeof import('vue'))['nextTick']
const onActivated: (typeof import('vue'))['onActivated']
const onBeforeMount: (typeof import('vue'))['onBeforeMount']
const onBeforeRouteLeave: (typeof import('vue-router'))['onBeforeRouteLeave']
const onBeforeRouteUpdate: (typeof import('vue-router'))['onBeforeRouteUpdate']
const onBeforeUnmount: (typeof import('vue'))['onBeforeUnmount']
const onBeforeUpdate: (typeof import('vue'))['onBeforeUpdate']
const onClickOutside: (typeof import('@vueuse/core'))['onClickOutside']
const onDeactivated: (typeof import('vue'))['onDeactivated']
const onErrorCaptured: (typeof import('vue'))['onErrorCaptured']
const onKeyStroke: (typeof import('@vueuse/core'))['onKeyStroke']
const onLongPress: (typeof import('@vueuse/core'))['onLongPress']
const onMounted: (typeof import('vue'))['onMounted']
const onRenderTracked: (typeof import('vue'))['onRenderTracked']
const onRenderTriggered: (typeof import('vue'))['onRenderTriggered']
const onScopeDispose: (typeof import('vue'))['onScopeDispose']
const onServerPrefetch: (typeof import('vue'))['onServerPrefetch']
const onStartTyping: (typeof import('@vueuse/core'))['onStartTyping']
const onUnmounted: (typeof import('vue'))['onUnmounted']
const onUpdated: (typeof import('vue'))['onUpdated']
const onWatcherCleanup: (typeof import('vue'))['onWatcherCleanup']
const pausableWatch: (typeof import('@vueuse/core'))['pausableWatch']
const provide: (typeof import('vue'))['provide']
const provideLocal: (typeof import('@vueuse/core'))['provideLocal']
const reactify: (typeof import('@vueuse/core'))['reactify']
const reactifyObject: (typeof import('@vueuse/core'))['reactifyObject']
const reactive: (typeof import('vue'))['reactive']
const reactiveComputed: (typeof import('@vueuse/core'))['reactiveComputed']
const reactiveOmit: (typeof import('@vueuse/core'))['reactiveOmit']
const reactivePick: (typeof import('@vueuse/core'))['reactivePick']
const readonly: (typeof import('vue'))['readonly']
const ref: (typeof import('vue'))['ref']
const refAutoReset: (typeof import('@vueuse/core'))['refAutoReset']
const refDebounced: (typeof import('@vueuse/core'))['refDebounced']
const refDefault: (typeof import('@vueuse/core'))['refDefault']
const refThrottled: (typeof import('@vueuse/core'))['refThrottled']
const refWithControl: (typeof import('@vueuse/core'))['refWithControl']
const resolveComponent: (typeof import('vue'))['resolveComponent']
const resolveRef: (typeof import('@vueuse/core'))['resolveRef']
const resolveUnref: (typeof import('@vueuse/core'))['resolveUnref']
const setActivePinia: (typeof import('pinia'))['setActivePinia']
const setMapStoreSuffix: (typeof import('pinia'))['setMapStoreSuffix']
const shallowReactive: (typeof import('vue'))['shallowReactive']
const shallowReadonly: (typeof import('vue'))['shallowReadonly']
const shallowRef: (typeof import('vue'))['shallowRef']
const storeToRefs: (typeof import('pinia'))['storeToRefs']
const syncRef: (typeof import('@vueuse/core'))['syncRef']
const syncRefs: (typeof import('@vueuse/core'))['syncRefs']
const templateRef: (typeof import('@vueuse/core'))['templateRef']
const throttledRef: (typeof import('@vueuse/core'))['throttledRef']
const throttledWatch: (typeof import('@vueuse/core'))['throttledWatch']
const toRaw: (typeof import('vue'))['toRaw']
const toReactive: (typeof import('@vueuse/core'))['toReactive']
const toRef: (typeof import('vue'))['toRef']
const toRefs: (typeof import('vue'))['toRefs']
const toValue: (typeof import('vue'))['toValue']
const triggerRef: (typeof import('vue'))['triggerRef']
const tryOnBeforeMount: (typeof import('@vueuse/core'))['tryOnBeforeMount']
const tryOnBeforeUnmount: (typeof import('@vueuse/core'))['tryOnBeforeUnmount']
const tryOnMounted: (typeof import('@vueuse/core'))['tryOnMounted']
const tryOnScopeDispose: (typeof import('@vueuse/core'))['tryOnScopeDispose']
const tryOnUnmounted: (typeof import('@vueuse/core'))['tryOnUnmounted']
const unref: (typeof import('vue'))['unref']
const unrefElement: (typeof import('@vueuse/core'))['unrefElement']
const until: (typeof import('@vueuse/core'))['until']
const useActiveElement: (typeof import('@vueuse/core'))['useActiveElement']
const useAnimate: (typeof import('@vueuse/core'))['useAnimate']
const useArrayDifference: (typeof import('@vueuse/core'))['useArrayDifference']
const useArrayEvery: (typeof import('@vueuse/core'))['useArrayEvery']
const useArrayFilter: (typeof import('@vueuse/core'))['useArrayFilter']
const useArrayFind: (typeof import('@vueuse/core'))['useArrayFind']
const useArrayFindIndex: (typeof import('@vueuse/core'))['useArrayFindIndex']
const useArrayFindLast: (typeof import('@vueuse/core'))['useArrayFindLast']
const useArrayIncludes: (typeof import('@vueuse/core'))['useArrayIncludes']
const useArrayJoin: (typeof import('@vueuse/core'))['useArrayJoin']
const useArrayMap: (typeof import('@vueuse/core'))['useArrayMap']
const useArrayReduce: (typeof import('@vueuse/core'))['useArrayReduce']
const useArraySome: (typeof import('@vueuse/core'))['useArraySome']
const useArrayUnique: (typeof import('@vueuse/core'))['useArrayUnique']
const useAsyncQueue: (typeof import('@vueuse/core'))['useAsyncQueue']
const useAsyncState: (typeof import('@vueuse/core'))['useAsyncState']
const useAttrs: (typeof import('vue'))['useAttrs']
const useBase64: (typeof import('@vueuse/core'))['useBase64']
const useBattery: (typeof import('@vueuse/core'))['useBattery']
const useBluetooth: (typeof import('@vueuse/core'))['useBluetooth']
const useBreakpoints: (typeof import('@vueuse/core'))['useBreakpoints']
const useBroadcastChannel: (typeof import('@vueuse/core'))['useBroadcastChannel']
const useBrowserLocation: (typeof import('@vueuse/core'))['useBrowserLocation']
const useCached: (typeof import('@vueuse/core'))['useCached']
const useClipboard: (typeof import('@vueuse/core'))['useClipboard']
const useClipboardItems: (typeof import('@vueuse/core'))['useClipboardItems']
const useCloned: (typeof import('@vueuse/core'))['useCloned']
const useColorMode: (typeof import('@vueuse/core'))['useColorMode']
const useConfirmDialog: (typeof import('@vueuse/core'))['useConfirmDialog']
const useCounter: (typeof import('@vueuse/core'))['useCounter']
const useCssModule: (typeof import('vue'))['useCssModule']
const useCssVar: (typeof import('@vueuse/core'))['useCssVar']
const useCssVars: (typeof import('vue'))['useCssVars']
const useCurrentElement: (typeof import('@vueuse/core'))['useCurrentElement']
const useCycleList: (typeof import('@vueuse/core'))['useCycleList']
const useDark: (typeof import('@vueuse/core'))['useDark']
const useDateFormat: (typeof import('@vueuse/core'))['useDateFormat']
const useDebounce: (typeof import('@vueuse/core'))['useDebounce']
const useDebounceFn: (typeof import('@vueuse/core'))['useDebounceFn']
const useDebouncedRefHistory: (typeof import('@vueuse/core'))['useDebouncedRefHistory']
const useDeviceMotion: (typeof import('@vueuse/core'))['useDeviceMotion']
const useDeviceOrientation: (typeof import('@vueuse/core'))['useDeviceOrientation']
const useDevicePixelRatio: (typeof import('@vueuse/core'))['useDevicePixelRatio']
const useDevicesList: (typeof import('@vueuse/core'))['useDevicesList']
const useDisplayMedia: (typeof import('@vueuse/core'))['useDisplayMedia']
const useDocumentVisibility: (typeof import('@vueuse/core'))['useDocumentVisibility']
const useDraggable: (typeof import('@vueuse/core'))['useDraggable']
const useDropZone: (typeof import('@vueuse/core'))['useDropZone']
const useElementBounding: (typeof import('@vueuse/core'))['useElementBounding']
const useElementByPoint: (typeof import('@vueuse/core'))['useElementByPoint']
const useElementHover: (typeof import('@vueuse/core'))['useElementHover']
const useElementSize: (typeof import('@vueuse/core'))['useElementSize']
const useElementVisibility: (typeof import('@vueuse/core'))['useElementVisibility']
const useEventBus: (typeof import('@vueuse/core'))['useEventBus']
const useEventListener: (typeof import('@vueuse/core'))['useEventListener']
const useEventSource: (typeof import('@vueuse/core'))['useEventSource']
const useEyeDropper: (typeof import('@vueuse/core'))['useEyeDropper']
const useFavicon: (typeof import('@vueuse/core'))['useFavicon']
const useFetch: (typeof import('@vueuse/core'))['useFetch']
const useFileDialog: (typeof import('@vueuse/core'))['useFileDialog']
const useFileSystemAccess: (typeof import('@vueuse/core'))['useFileSystemAccess']
const useFocus: (typeof import('@vueuse/core'))['useFocus']
const useFocusWithin: (typeof import('@vueuse/core'))['useFocusWithin']
const useFps: (typeof import('@vueuse/core'))['useFps']
const useFullscreen: (typeof import('@vueuse/core'))['useFullscreen']
const useGamepad: (typeof import('@vueuse/core'))['useGamepad']
const useGeolocation: (typeof import('@vueuse/core'))['useGeolocation']
const useId: (typeof import('vue'))['useId']
const useIdle: (typeof import('@vueuse/core'))['useIdle']
const useImage: (typeof import('@vueuse/core'))['useImage']
const useInfiniteScroll: (typeof import('@vueuse/core'))['useInfiniteScroll']
const useIntersectionObserver: (typeof import('@vueuse/core'))['useIntersectionObserver']
const useInterval: (typeof import('@vueuse/core'))['useInterval']
const useIntervalFn: (typeof import('@vueuse/core'))['useIntervalFn']
const useKeyModifier: (typeof import('@vueuse/core'))['useKeyModifier']
const useLastChanged: (typeof import('@vueuse/core'))['useLastChanged']
const useLink: (typeof import('vue-router'))['useLink']
const useLocalStorage: (typeof import('@vueuse/core'))['useLocalStorage']
const useMagicKeys: (typeof import('@vueuse/core'))['useMagicKeys']
const useManualRefHistory: (typeof import('@vueuse/core'))['useManualRefHistory']
const useMediaControls: (typeof import('@vueuse/core'))['useMediaControls']
const useMediaQuery: (typeof import('@vueuse/core'))['useMediaQuery']
const useMemoize: (typeof import('@vueuse/core'))['useMemoize']
const useMemory: (typeof import('@vueuse/core'))['useMemory']
const useModel: (typeof import('vue'))['useModel']
const useMounted: (typeof import('@vueuse/core'))['useMounted']
const useMouse: (typeof import('@vueuse/core'))['useMouse']
const useMouseInElement: (typeof import('@vueuse/core'))['useMouseInElement']
const useMousePressed: (typeof import('@vueuse/core'))['useMousePressed']
const useMutationObserver: (typeof import('@vueuse/core'))['useMutationObserver']
const useNavigatorLanguage: (typeof import('@vueuse/core'))['useNavigatorLanguage']
const useNetwork: (typeof import('@vueuse/core'))['useNetwork']
const useNow: (typeof import('@vueuse/core'))['useNow']
const useObjectUrl: (typeof import('@vueuse/core'))['useObjectUrl']
const useOffsetPagination: (typeof import('@vueuse/core'))['useOffsetPagination']
const useOnline: (typeof import('@vueuse/core'))['useOnline']
const usePageLeave: (typeof import('@vueuse/core'))['usePageLeave']
const useParallax: (typeof import('@vueuse/core'))['useParallax']
const useParentElement: (typeof import('@vueuse/core'))['useParentElement']
const usePerformanceObserver: (typeof import('@vueuse/core'))['usePerformanceObserver']
const usePermission: (typeof import('@vueuse/core'))['usePermission']
const usePointer: (typeof import('@vueuse/core'))['usePointer']
const usePointerLock: (typeof import('@vueuse/core'))['usePointerLock']
const usePointerSwipe: (typeof import('@vueuse/core'))['usePointerSwipe']
const usePreferredColorScheme: (typeof import('@vueuse/core'))['usePreferredColorScheme']
const usePreferredContrast: (typeof import('@vueuse/core'))['usePreferredContrast']
const usePreferredDark: (typeof import('@vueuse/core'))['usePreferredDark']
const usePreferredLanguages: (typeof import('@vueuse/core'))['usePreferredLanguages']
const usePreferredReducedMotion: (typeof import('@vueuse/core'))['usePreferredReducedMotion']
const usePrevious: (typeof import('@vueuse/core'))['usePrevious']
const useRafFn: (typeof import('@vueuse/core'))['useRafFn']
const useRefHistory: (typeof import('@vueuse/core'))['useRefHistory']
const useResizeObserver: (typeof import('@vueuse/core'))['useResizeObserver']
const useRoute: (typeof import('vue-router'))['useRoute']
const useRouter: (typeof import('vue-router'))['useRouter']
const useScreenOrientation: (typeof import('@vueuse/core'))['useScreenOrientation']
const useScreenSafeArea: (typeof import('@vueuse/core'))['useScreenSafeArea']
const useScriptTag: (typeof import('@vueuse/core'))['useScriptTag']
const useScroll: (typeof import('@vueuse/core'))['useScroll']
const useScrollLock: (typeof import('@vueuse/core'))['useScrollLock']
const useSessionStorage: (typeof import('@vueuse/core'))['useSessionStorage']
const useShare: (typeof import('@vueuse/core'))['useShare']
const useSlots: (typeof import('vue'))['useSlots']
const useSorted: (typeof import('@vueuse/core'))['useSorted']
const useSpeechRecognition: (typeof import('@vueuse/core'))['useSpeechRecognition']
const useSpeechSynthesis: (typeof import('@vueuse/core'))['useSpeechSynthesis']
const useStepper: (typeof import('@vueuse/core'))['useStepper']
const useStorage: (typeof import('@vueuse/core'))['useStorage']
const useStorageAsync: (typeof import('@vueuse/core'))['useStorageAsync']
const useStyleTag: (typeof import('@vueuse/core'))['useStyleTag']
const useSupported: (typeof import('@vueuse/core'))['useSupported']
const useSwipe: (typeof import('@vueuse/core'))['useSwipe']
const useTemplateRef: (typeof import('vue'))['useTemplateRef']
const useTemplateRefsList: (typeof import('@vueuse/core'))['useTemplateRefsList']
const useTextDirection: (typeof import('@vueuse/core'))['useTextDirection']
const useTextSelection: (typeof import('@vueuse/core'))['useTextSelection']
const useTextareaAutosize: (typeof import('@vueuse/core'))['useTextareaAutosize']
const useThrottle: (typeof import('@vueuse/core'))['useThrottle']
const useThrottleFn: (typeof import('@vueuse/core'))['useThrottleFn']
const useThrottledRefHistory: (typeof import('@vueuse/core'))['useThrottledRefHistory']
const useTimeAgo: (typeof import('@vueuse/core'))['useTimeAgo']
const useTimeout: (typeof import('@vueuse/core'))['useTimeout']
const useTimeoutFn: (typeof import('@vueuse/core'))['useTimeoutFn']
const useTimeoutPoll: (typeof import('@vueuse/core'))['useTimeoutPoll']
const useTimestamp: (typeof import('@vueuse/core'))['useTimestamp']
const useTitle: (typeof import('@vueuse/core'))['useTitle']
const useToNumber: (typeof import('@vueuse/core'))['useToNumber']
const useToString: (typeof import('@vueuse/core'))['useToString']
const useToggle: (typeof import('@vueuse/core'))['useToggle']
const useTransition: (typeof import('@vueuse/core'))['useTransition']
const useUrlSearchParams: (typeof import('@vueuse/core'))['useUrlSearchParams']
const useUserMedia: (typeof import('@vueuse/core'))['useUserMedia']
const useVModel: (typeof import('@vueuse/core'))['useVModel']
const useVModels: (typeof import('@vueuse/core'))['useVModels']
const useVibrate: (typeof import('@vueuse/core'))['useVibrate']
const useVirtualList: (typeof import('@vueuse/core'))['useVirtualList']
const useWakeLock: (typeof import('@vueuse/core'))['useWakeLock']
const useWebNotification: (typeof import('@vueuse/core'))['useWebNotification']
const useWebSocket: (typeof import('@vueuse/core'))['useWebSocket']
const useWebWorker: (typeof import('@vueuse/core'))['useWebWorker']
const useWebWorkerFn: (typeof import('@vueuse/core'))['useWebWorkerFn']
const useWindowFocus: (typeof import('@vueuse/core'))['useWindowFocus']
const useWindowScroll: (typeof import('@vueuse/core'))['useWindowScroll']
const useWindowSize: (typeof import('@vueuse/core'))['useWindowSize']
const watch: (typeof import('vue'))['watch']
const watchArray: (typeof import('@vueuse/core'))['watchArray']
const watchAtMost: (typeof import('@vueuse/core'))['watchAtMost']
const watchDebounced: (typeof import('@vueuse/core'))['watchDebounced']
const watchDeep: (typeof import('@vueuse/core'))['watchDeep']
const watchEffect: (typeof import('vue'))['watchEffect']
const watchIgnorable: (typeof import('@vueuse/core'))['watchIgnorable']
const watchImmediate: (typeof import('@vueuse/core'))['watchImmediate']
const watchOnce: (typeof import('@vueuse/core'))['watchOnce']
const watchPausable: (typeof import('@vueuse/core'))['watchPausable']
const watchPostEffect: (typeof import('vue'))['watchPostEffect']
const watchSyncEffect: (typeof import('vue'))['watchSyncEffect']
const watchThrottled: (typeof import('@vueuse/core'))['watchThrottled']
const watchTriggerable: (typeof import('@vueuse/core'))['watchTriggerable']
const watchWithFilter: (typeof import('@vueuse/core'))['watchWithFilter']
const whenever: (typeof import('@vueuse/core'))['whenever']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
export type {
Component,
ComponentPublicInstance,
ComputedRef,
DirectiveBinding,
ExtractDefaultPropTypes,
ExtractPropTypes,
ExtractPublicPropTypes,
InjectionKey,
PropType,
Ref,
MaybeRef,
MaybeRefOrGetter,
VNode,
WritableComputedRef
} from 'vue'
import('vue')
}

View File

@@ -84,9 +84,9 @@
<!-- 分配角色对话框 -->
<ElDialog v-model="roleDialogVisible" title="分配角色" width="500px">
<ElRadioGroup v-model="selectedRole" class="role-radio-group">
<div v-for="role in allRoles" :key="role.ID" class="role-radio-item">
<ElRadio :label="role.ID">
<ElCheckboxGroup v-model="selectedRoles">
<div v-for="role in allRoles" :key="role.ID" style="margin-bottom: 12px">
<ElCheckbox :label="role.ID">
{{ role.role_name }}
<ElTag
:type="role.role_type === 1 ? 'primary' : 'success'"
@@ -95,9 +95,9 @@
>
{{ role.role_type === 1 ? '平台角色' : '客户角色' }}
</ElTag>
</ElRadio>
</ElCheckbox>
</div>
</ElRadioGroup>
</ElCheckboxGroup>
<template #footer>
<div class="dialog-footer">
<ElButton @click="roleDialogVisible = false">取消</ElButton>
@@ -114,7 +114,7 @@
<script setup lang="ts">
import { h } from 'vue'
import { FormInstance, ElSwitch } from 'element-plus'
import { FormInstance, ElSwitch, ElCheckbox, ElCheckboxGroup, ElTag } from 'element-plus'
import { ElMessageBox, ElMessage } from 'element-plus'
import type { FormRules } from 'element-plus'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
@@ -134,7 +134,7 @@
const loading = ref(false)
const roleSubmitLoading = ref(false)
const currentAccountId = ref<number>(0)
const selectedRole = ref<number | undefined>(undefined)
const selectedRoles = ref<number[]>([])
const allRoles = ref<PlatformRole[]>([])
// 定义表单搜索初始值
@@ -292,7 +292,8 @@
activeText: getStatusText(CommonStatus.ENABLED),
inactiveText: getStatusText(CommonStatus.DISABLED),
inlinePrompt: true,
'onUpdate:modelValue': (val: string | number | boolean) => handleStatusChange(row, val as number)
'onUpdate:modelValue': (val: string | number | boolean) =>
handleStatusChange(row, val as number)
})
}
},
@@ -356,15 +357,18 @@
// 显示分配角色对话框
const showRoleDialog = async (row: any) => {
currentAccountId.value = row.ID
selectedRole.value = undefined
selectedRoles.value = []
// 先加载当前账号的角色,再打开对话框
try {
// 每次打开对话框时重新加载最新的角色列表
await loadAllRoles()
// 先加载当前账号的角色,再打开对话框
const res = await AccountService.getAccountRoles(row.ID)
if (res.code === 0) {
// 提取角色ID(只取第一个角色)
// 提取角色ID数组
const roles = res.data || []
selectedRole.value = roles.length > 0 ? roles[0].ID : undefined
selectedRoles.value = roles.map((role: any) => role.ID)
// 数据加载完成后再打开对话框
roleDialogVisible.value = true
}
@@ -375,17 +379,13 @@
// 提交分配角色
const handleAssignRoles = async () => {
if (selectedRole.value === undefined) {
ElMessage.warning('请选择一个角色')
return
}
roleSubmitLoading.value = true
try {
// 将单个角色ID包装成数组传给后端
await AccountService.assignRolesToAccount(currentAccountId.value, [selectedRole.value])
await AccountService.assignRolesToAccount(currentAccountId.value, selectedRoles.value)
ElMessage.success('分配角色成功')
roleDialogVisible.value = false
// 刷新列表以更新角色显示
await getAccountList()
} catch (error) {
console.error(error)
} finally {
@@ -503,13 +503,4 @@
.account-page {
// 账号管理页面样式
}
.role-radio-group {
width: 100%;
}
.role-radio-item {
padding: 8px;
margin-bottom: 16px;
}
</style>

View File

@@ -328,7 +328,8 @@
activeText: '启用',
inactiveText: '禁用',
inlinePrompt: true,
'onUpdate:modelValue': (val: string | number | boolean) => handleStatusChange(row, val as number)
'onUpdate:modelValue': (val: string | number | boolean) =>
handleStatusChange(row, val as number)
})
}
},

View File

@@ -2,7 +2,7 @@
<ArtTableFullScreen>
<div class="enterprise-cards-page" id="table-full-screen">
<!-- 企业信息卡片 -->
<ElCard shadow="never" style="margin-bottom: 16px">
<ElCard shadow="never" class="enterprise-info-card">
<template #header>
<div class="card-header">
<span>企业信息</span>
@@ -10,8 +10,12 @@
</div>
</template>
<ElDescriptions :column="3" border v-if="enterpriseInfo">
<ElDescriptionsItem label="企业名称">{{ enterpriseInfo.enterprise_name }}</ElDescriptionsItem>
<ElDescriptionsItem label="企业编号">{{ enterpriseInfo.enterprise_code }}</ElDescriptionsItem>
<ElDescriptionsItem label="企业名称">{{
enterpriseInfo.enterprise_name
}}</ElDescriptionsItem>
<ElDescriptionsItem label="企业编号">{{
enterpriseInfo.enterprise_code
}}</ElDescriptionsItem>
<ElDescriptionsItem label="联系人">{{ enterpriseInfo.contact_name }}</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
@@ -67,92 +71,51 @@
<ElDialog
v-model="allocateDialogVisible"
title="授权卡给企业"
width="700px"
width="85%"
@close="handleAllocateDialogClose"
>
<ElForm ref="allocateFormRef" :model="allocateForm" :rules="allocateRules" label-width="120px">
<ElFormItem label="ICCID列表" prop="iccids">
<ElInput
v-model="iccidsText"
type="textarea"
:rows="6"
placeholder="请输入ICCID每行一个或用逗号分隔"
@input="handleIccidsChange"
<!-- 搜索过滤条件 -->
<ArtSearchBar
v-model:filter="cardSearchForm"
:items="cardSearchFormItems"
label-width="85"
@reset="handleCardSearchReset"
@search="handleCardSearch"
></ArtSearchBar>
<!-- 卡列表 -->
<div class="card-selection-info"> 已选择 {{ selectedAvailableCards.length }} 张卡 </div>
<ArtTable
ref="availableCardsTableRef"
row-key="id"
:loading="availableCardsLoading"
:data="availableCardsList"
:currentPage="cardPagination.page"
:pageSize="cardPagination.pageSize"
:total="cardPagination.total"
:marginTop="10"
@size-change="handleCardPageSizeChange"
@current-change="handleCardPageChange"
@selection-change="handleAvailableCardsSelectionChange"
>
<template #default>
<ElTableColumn type="selection" width="55" />
<ElTableColumn
v-for="col in availableCardColumns"
:key="col.prop || col.type"
v-bind="col"
/>
<div style="color: var(--el-color-info); margin-top: 4px; font-size: 12px">
已输入 {{ allocateForm.iccids?.length || 0 }} 个ICCID
</div>
</ElFormItem>
<ElFormItem label="确认设备绑定">
<ElCheckbox v-model="allocateForm.confirm_device_bundles">
我确认已了解设备绑定关系同意一起授权
</ElCheckbox>
</ElFormItem>
</ElForm>
<!-- 预检结果 -->
<div v-if="previewData" style="margin-top: 20px">
<ElDivider content-position="left">预检结果</ElDivider>
<ElDescriptions :column="2" border>
<ElDescriptionsItem label="待授权卡数">
{{ previewData.summary.total_cards }}
</ElDescriptionsItem>
<ElDescriptionsItem label="可授权卡数">
<ElTag type="success">{{ previewData.summary.valid_cards }}</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="独立卡数">
{{ previewData.summary.standalone_cards }}
</ElDescriptionsItem>
<ElDescriptionsItem label="设备绑定数">
<ElTag type="warning">{{ previewData.summary.device_bundles }}</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="失败数" :span="2">
<ElTag type="danger">{{ previewData.summary.failed_cards }}</ElTag>
</ElDescriptionsItem>
</ElDescriptions>
<!-- 失败项详情 -->
<div v-if="previewData.failed_items && previewData.failed_items.length > 0" style="margin-top: 16px">
<ElAlert title="以下ICCID无法授权" type="error" :closable="false" style="margin-bottom: 8px" />
<ElTable :data="previewData.failed_items" border max-height="200">
<ElTableColumn prop="iccid" label="ICCID" width="180" />
<ElTableColumn prop="reason" label="失败原因" />
</ElTable>
</div>
<!-- 设备绑定详情 -->
<div v-if="previewData.device_bundles && previewData.device_bundles.length > 0" style="margin-top: 16px">
<ElAlert
title="以下ICCID与设备绑定授权后设备也将一起授权给企业"
type="warning"
:closable="false"
style="margin-bottom: 8px"
/>
<ElTable :data="previewData.device_bundles" border max-height="200">
<ElTableColumn prop="device_imei" label="设备IMEI" width="180" />
<ElTableColumn label="绑定卡数">
<template #default="{ row }">
{{ row.iccids?.length || 0 }}
</template>
</ElTableColumn>
<ElTableColumn label="ICCID列表">
<template #default="{ row }">
{{ row.iccids?.join(', ') }}
</template>
</ElTableColumn>
</ElTable>
</div>
</div>
</template>
</ArtTable>
<template #footer>
<div class="dialog-footer">
<ElButton @click="allocateDialogVisible = false">取消</ElButton>
<ElButton @click="handlePreview" :loading="previewLoading">预检</ElButton>
<ElButton
type="primary"
@click="handleAllocate"
:loading="allocateLoading"
:disabled="!previewData || previewData.summary.valid_cards === 0"
:disabled="selectedAvailableCards.length === 0"
>
确认授权
</ElButton>
@@ -191,7 +154,12 @@
</ElDialog>
<!-- 结果对话框 -->
<ElDialog v-model="resultDialogVisible" :title="resultTitle" width="700px">
<ElDialog
v-model="resultDialogVisible"
:title="resultTitle"
width="700px"
@close="handleResultDialogClose"
>
<ElDescriptions :column="2" border>
<ElDescriptionsItem label="成功数">
<ElTag type="success">{{ operationResult.success_count }}</ElTag>
@@ -203,7 +171,7 @@
<div
v-if="operationResult.failed_items && operationResult.failed_items.length > 0"
style="margin-top: 20px"
class="result-section"
>
<ElDivider content-position="left">失败项详情</ElDivider>
<ElTable :data="operationResult.failed_items" border max-height="300">
@@ -215,7 +183,7 @@
<!-- 显示授权的设备 -->
<div
v-if="operationResult.allocated_devices && operationResult.allocated_devices.length > 0"
style="margin-top: 20px"
class="result-section"
>
<ElDivider content-position="left">已授权设备</ElDivider>
<ElTable :data="operationResult.allocated_devices" border max-height="200">
@@ -242,21 +210,21 @@
<script setup lang="ts">
import { h } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { EnterpriseService } from '@/api/modules'
import { EnterpriseService, CardService } from '@/api/modules'
import { ElMessage, ElMessageBox, ElTag } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { formatDateTime } from '@/utils/business/format'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { BgColorEnum } from '@/enums/appEnum'
import type {
EnterpriseCardItem,
AllocateCardsPreviewResponse,
AllocateCardsResponse,
RecallCardsResponse,
FailedItem
RecallCardsResponse
} from '@/types/api/enterpriseCard'
import type { EnterpriseItem } from '@/types/api'
import type { StandaloneIotCard } from '@/types/api/card'
defineOptions({ name: 'EnterpriseCards' })
@@ -265,19 +233,15 @@
const loading = ref(false)
const allocateDialogVisible = ref(false)
const allocateLoading = ref(false)
const previewLoading = ref(false)
const recallDialogVisible = ref(false)
const recallLoading = ref(false)
const resultDialogVisible = ref(false)
const resultTitle = ref('')
const tableRef = ref()
const allocateFormRef = ref<FormInstance>()
const recallFormRef = ref<FormInstance>()
const selectedCards = ref<EnterpriseCardItem[]>([])
const enterpriseId = ref<number>(0)
const enterpriseInfo = ref<EnterpriseItem | null>(null)
const iccidsText = ref('')
const previewData = ref<AllocateCardsPreviewResponse | null>(null)
const operationResult = ref<AllocateCardsResponse | RecallCardsResponse>({
success_count: 0,
fail_count: 0,
@@ -285,40 +249,39 @@
allocated_devices: null
})
// 可用卡列表相关
const availableCardsTableRef = ref()
const availableCardsLoading = ref(false)
const availableCardsList = ref<StandaloneIotCard[]>([])
const selectedAvailableCards = ref<StandaloneIotCard[]>([])
// 卡搜索表单初始值
const initialCardSearchState = {
status: undefined,
carrier_id: undefined,
iccid: '',
msisdn: '',
is_distributed: undefined
}
const cardSearchForm = reactive({ ...initialCardSearchState })
const cardPagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
// 搜索表单初始值
const initialSearchState = {
iccid: '',
msisdn: '',
status: undefined as number | undefined,
authorization_status: undefined as number | undefined
device_no: '',
carrier_id: undefined as number | undefined,
status: undefined as number | undefined
}
// 搜索表单
const searchForm = reactive({ ...initialSearchState })
// 授权表单
const allocateForm = reactive({
iccids: [] as string[],
confirm_device_bundles: false
})
// 授权表单验证规则
const allocateRules = reactive<FormRules>({
iccids: [
{
required: true,
validator: (rule, value, callback) => {
if (!value || value.length === 0) {
callback(new Error('请输入至少一个ICCID'))
} else {
callback()
}
},
trigger: 'change'
}
]
})
// 回收表单
const recallForm = reactive({
reason: ''
@@ -346,16 +309,30 @@
}
},
{
label: '手机号',
prop: 'msisdn',
label: '设备号',
prop: 'device_no',
type: 'input',
config: {
clearable: true,
placeholder: '请输入手机号'
placeholder: '请输入设备号'
}
},
{
label: '卡状态',
label: '运营商',
prop: 'carrier_id',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '中国移动', value: 1 },
{ label: '中国联通', value: 2 },
{ label: '中国电信', value: 3 }
]
},
{
label: '状态',
prop: 'status',
type: 'select',
config: {
@@ -363,21 +340,74 @@
placeholder: '全部'
},
options: () => [
{ label: '激活', value: 1 },
{ label: '停机', value: 2 }
{ label: '在库', value: 1 },
{ label: '已分销', value: 2 },
{ label: '已激活', value: 3 },
{ label: '已停用', value: 4 }
]
},
}
]
// 卡列表搜索表单配置
const cardSearchFormItems: SearchFormItem[] = [
{
label: '授权状态',
prop: 'authorization_status',
label: '状态',
prop: 'status',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '有效', value: 1 },
{ label: '已回收', value: 0 }
{ label: '在库', value: 1 },
{ label: '已分销', value: 2 },
{ label: '已激活', value: 3 },
{ label: '已停用', value: 4 }
]
},
{
label: '运营商',
prop: 'carrier_id',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '中国移动', value: 1 },
{ label: '中国联通', value: 2 },
{ label: '中国电信', value: 3 }
]
},
{
label: 'ICCID',
prop: 'iccid',
type: 'input',
config: {
clearable: true,
placeholder: '请输入ICCID'
}
},
{
label: '卡接入号',
prop: 'msisdn',
type: 'input',
config: {
clearable: true,
placeholder: '请输入卡接入号'
}
},
{
label: '是否已分销',
prop: 'is_distributed',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '是', value: true },
{ label: '否', value: false }
]
}
]
@@ -385,12 +415,15 @@
// 列配置
const columnOptions = [
{ label: 'ICCID', prop: 'iccid' },
{ label: '手机号', prop: 'msisdn' },
{ label: '卡接入号', prop: 'msisdn' },
{ label: '设备号', prop: 'device_no' },
{ label: '运营商ID', prop: 'carrier_id' },
{ label: '运营商', prop: 'carrier_name' },
{ label: '卡状态', prop: 'status' },
{ label: '授权状态', prop: 'authorization_status' },
{ label: '授权时间', prop: 'authorized_at' },
{ label: '授权人', prop: 'authorizer_name' },
{ label: '套餐名称', prop: 'package_name' },
{ label: '状态', prop: 'status' },
{ label: '状态名称', prop: 'status_name' },
{ label: '网络状态', prop: 'network_status' },
{ label: '网络状态名称', prop: 'network_status_name' },
{ label: '操作', prop: 'operation' }
]
@@ -398,22 +431,44 @@
// 获取卡状态标签类型
const getCardStatusTag = (status: number) => {
switch (status) {
case 1:
return 'info'
case 2:
return 'warning'
case 3:
return 'success'
case 4:
return 'danger'
default:
return 'info'
}
}
// 获取卡状态文本 - 使用API返回的status_name
const getCardStatusText = (status: number) => {
switch (status) {
case 1:
return '在库'
case 2:
return '已分销'
case 3:
return '已激活'
case 4:
return '已停用'
default:
return '未知'
}
}
// 获取网络状态标签类型
const getNetworkStatusTag = (status: number) => {
return status === 1 ? 'success' : 'danger'
}
// 获取状态文本
const getCardStatusText = (status: number) => {
return status === 1 ? '激活' : '停机'
}
// 获取授权状态标签类型
const getAuthStatusTag = (status: number) => {
return status === 1 ? 'success' : 'info'
}
// 获取授权状态文本
const getAuthStatusText = (status: number) => {
return status === 1 ? '有效' : '已回收'
// 获取网络状态文本 - 使用API返回的network_status_name
const getNetworkStatusText = (status: number) => {
return status === 1 ? '开机' : '停机'
}
// 动态列配置
@@ -421,64 +476,77 @@
{
prop: 'iccid',
label: 'ICCID',
minWidth: 180
minWidth: 200
},
{
prop: 'msisdn',
label: '手机号',
width: 120
label: '卡接入号',
width: 130
},
{
prop: 'device_no',
label: '设备号',
width: 150
},
{
prop: 'carrier_id',
label: '运营商ID',
width: 100
},
{
prop: 'carrier_name',
label: '运营商',
width: 100
width: 120
},
{
prop: 'package_name',
label: '套餐名称',
width: 150
},
{
prop: 'status',
label: '状态',
label: '状态',
width: 100,
formatter: (row: EnterpriseCardItem) => {
return h(ElTag, { type: getCardStatusTag(row.status) }, () => getCardStatusText(row.status))
}
},
{
prop: 'authorization_status',
label: '授权状态',
prop: 'status_name',
label: '状态名称',
width: 100
},
{
prop: 'network_status',
label: '网络状态',
width: 100,
formatter: (row: EnterpriseCardItem) => {
return h(
ElTag,
{ type: getAuthStatusTag(row.authorization_status) },
() => getAuthStatusText(row.authorization_status)
return h(ElTag, { type: getNetworkStatusTag(row.network_status) }, () =>
getNetworkStatusText(row.network_status)
)
}
},
{
prop: 'authorized_at',
label: '授权时间',
width: 180,
formatter: (row: EnterpriseCardItem) =>
row.authorized_at ? formatDateTime(row.authorized_at) : '-'
},
{
prop: 'authorizer_name',
label: '授权人',
width: 120
prop: 'network_status_name',
label: '网络状态名称',
width: 130
},
{
prop: 'operation',
label: '操作',
width: 150,
width: 100,
fixed: 'right',
formatter: (row: EnterpriseCardItem) => {
return h('div', { style: 'display: flex; gap: 8px;' }, [
row.status === 2
row.network_status === 0
? h(ArtButtonTable, {
text: '复机',
iconClass: BgColorEnum.SUCCESS,
onClick: () => handleResume(row)
})
: h(ArtButtonTable, {
text: '停机',
iconClass: BgColorEnum.ERROR,
onClick: () => handleSuspend(row)
})
])
@@ -522,9 +590,9 @@
page: pagination.page,
page_size: pagination.pageSize,
iccid: searchForm.iccid || undefined,
msisdn: searchForm.msisdn || undefined,
status: searchForm.status,
authorization_status: searchForm.authorization_status
device_no: searchForm.device_no || undefined,
carrier_id: searchForm.carrier_id,
status: searchForm.status
}
// 清理空值
@@ -586,84 +654,213 @@
router.back()
}
// 显示授权对话框
const showAllocateDialog = () => {
allocateDialogVisible.value = true
iccidsText.value = ''
allocateForm.iccids = []
allocateForm.confirm_device_bundles = false
previewData.value = null
if (allocateFormRef.value) {
allocateFormRef.value.resetFields()
// 获取可用卡状态类型
const getAvailableCardStatusType = (status: number) => {
switch (status) {
case 1:
return 'info'
case 2:
return 'warning'
case 3:
return 'success'
case 4:
return 'danger'
default:
return 'info'
}
}
// 处理ICCID输入变化
const handleIccidsChange = () => {
// 解析输入的ICCID支持逗号、空格、换行分隔
const iccids = iccidsText.value
.split(/[,\s\n]+/)
.map((iccid) => iccid.trim())
.filter((iccid) => iccid.length > 0)
allocateForm.iccids = iccids
// 清除预检结果
previewData.value = null
// 获取可用卡状态文本
const getAvailableCardStatusText = (status: number) => {
switch (status) {
case 1:
return '在库'
case 2:
return '已分销'
case 3:
return '已激活'
case 4:
return '已停用'
default:
return '未知'
}
}
// 预检
const handlePreview = async () => {
if (!allocateFormRef.value) return
await allocateFormRef.value.validate(async (valid) => {
if (valid) {
previewLoading.value = true
try {
const res = await EnterpriseService.previewAllocateCards(enterpriseId.value, {
iccids: allocateForm.iccids
})
if (res.code === 0) {
previewData.value = res.data
ElMessage.success('预检完成')
}
} catch (error) {
console.error(error)
ElMessage.error('预检失败')
} finally {
previewLoading.value = false
}
// 可用卡列表列配置
const availableCardColumns = computed(() => [
{
prop: 'iccid',
label: 'ICCID',
minWidth: 180
},
{
prop: 'msisdn',
label: '卡接入号',
width: 130
},
{
prop: 'carrier_name',
label: '运营商',
width: 100
},
{
prop: 'cost_price',
label: '成本价',
width: 100,
formatter: (row: StandaloneIotCard) => `¥${(row.cost_price / 100).toFixed(2)}`
},
{
prop: 'distribute_price',
label: '分销价',
width: 100,
formatter: (row: StandaloneIotCard) => `¥${(row.distribute_price / 100).toFixed(2)}`
},
{
prop: 'status',
label: '状态',
width: 100,
formatter: (row: StandaloneIotCard) => {
return h(ElTag, { type: getAvailableCardStatusType(row.status) }, () =>
getAvailableCardStatusText(row.status)
)
}
})
},
{
prop: 'activation_status',
label: '激活状态',
width: 100,
formatter: (row: StandaloneIotCard) => {
const type = row.activation_status === 1 ? 'success' : 'info'
const text = row.activation_status === 1 ? '已激活' : '未激活'
return h(ElTag, { type }, () => text)
}
},
{
prop: 'network_status',
label: '网络状态',
width: 100,
formatter: (row: StandaloneIotCard) => {
const type = row.network_status === 1 ? 'success' : 'danger'
const text = row.network_status === 1 ? '开机' : '停机'
return h(ElTag, { type }, () => text)
}
},
{
prop: 'real_name_status',
label: '实名状态',
width: 100,
formatter: (row: StandaloneIotCard) => {
const type = row.real_name_status === 1 ? 'success' : 'warning'
const text = row.real_name_status === 1 ? '已实名' : '未实名'
return h(ElTag, { type }, () => text)
}
},
{
prop: 'data_usage_mb',
label: '累计流量(MB)',
width: 120
},
{
prop: 'first_commission_paid',
label: '首次佣金',
width: 100,
formatter: (row: StandaloneIotCard) => {
const type = row.first_commission_paid ? 'success' : 'info'
const text = row.first_commission_paid ? '已支付' : '未支付'
return h(ElTag, { type, size: 'small' }, () => text)
}
},
{
prop: 'accumulated_recharge',
label: '累计充值',
width: 100,
formatter: (row: StandaloneIotCard) => `¥${(row.accumulated_recharge / 100).toFixed(2)}`
}
])
// 获取可用卡列表
const getAvailableCardsList = async () => {
availableCardsLoading.value = true
try {
const params: any = {
page: cardPagination.page,
page_size: cardPagination.pageSize,
...cardSearchForm
}
// 清理空值
Object.keys(params).forEach((key) => {
if (params[key] === '' || params[key] === undefined) {
delete params[key]
}
})
const res = await CardService.getStandaloneIotCards(params)
if (res.code === 0) {
availableCardsList.value = res.data.items || []
cardPagination.total = res.data.total || 0
}
} catch (error) {
console.error(error)
ElMessage.error('获取卡列表失败')
} finally {
availableCardsLoading.value = false
}
}
// 处理卡列表选择变化
const handleAvailableCardsSelectionChange = (selection: StandaloneIotCard[]) => {
selectedAvailableCards.value = selection
}
// 卡列表搜索
const handleCardSearch = () => {
cardPagination.page = 1
getAvailableCardsList()
}
// 卡列表重置
const handleCardSearchReset = () => {
Object.assign(cardSearchForm, { ...initialCardSearchState })
cardPagination.page = 1
getAvailableCardsList()
}
// 卡列表分页大小变化
const handleCardPageSizeChange = (newPageSize: number) => {
cardPagination.pageSize = newPageSize
getAvailableCardsList()
}
// 卡列表页码变化
const handleCardPageChange = (newPage: number) => {
cardPagination.page = newPage
getAvailableCardsList()
}
// 显示授权对话框
const showAllocateDialog = () => {
allocateDialogVisible.value = true
selectedAvailableCards.value = []
// 重置搜索条件
Object.assign(cardSearchForm, { ...initialCardSearchState })
cardPagination.page = 1
cardPagination.pageSize = 20
// 加载可用卡列表
getAvailableCardsList()
}
// 执行授权
const handleAllocate = async () => {
if (!allocateFormRef.value) return
if (!previewData.value) {
ElMessage.warning('请先进行预检')
return
}
if (previewData.value.summary.valid_cards === 0) {
ElMessage.warning('没有可授权的卡')
return
}
// 如果有设备绑定且未确认,提示用户
if (
previewData.value.device_bundles &&
previewData.value.device_bundles.length > 0 &&
!allocateForm.confirm_device_bundles
) {
ElMessage.warning('请确认设备绑定关系')
if (selectedAvailableCards.value.length === 0) {
ElMessage.warning('请选择要授权的卡')
return
}
allocateLoading.value = true
try {
const iccids = selectedAvailableCards.value.map((card) => card.iccid)
const res = await EnterpriseService.allocateCards(enterpriseId.value, {
iccids: allocateForm.iccids,
confirm_device_bundles: allocateForm.confirm_device_bundles || undefined
iccids
})
if (res.code === 0) {
@@ -683,10 +880,7 @@
// 关闭授权对话框
const handleAllocateDialogClose = () => {
if (allocateFormRef.value) {
allocateFormRef.value.resetFields()
}
previewData.value = null
selectedAvailableCards.value = []
}
// 显示批量回收对话框
@@ -711,7 +905,7 @@
recallLoading.value = true
try {
const res = await EnterpriseService.recallCards(enterpriseId.value, {
card_ids: selectedCards.value.map((card) => card.id)
iccids: selectedCards.value.map((card) => card.iccid)
})
if (res.code === 0) {
@@ -743,6 +937,11 @@
}
}
// 关闭结果对话框
const handleResultDialogClose = () => {
getTableData()
}
// 停机卡
const handleSuspend = (row: EnterpriseCardItem) => {
ElMessageBox.confirm('确定要停机该卡吗?', '停机卡', {
@@ -786,10 +985,34 @@
<style lang="scss" scoped>
.enterprise-cards-page {
.enterprise-info-card {
margin-bottom: 16px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
justify-content: space-between;
}
.card-selection-info {
margin-top: 10px;
margin-bottom: 12px;
color: var(--el-color-info);
font-size: 14px;
}
.card-pagination {
margin-top: 16px;
text-align: right;
}
.result-section {
margin-top: 20px;
}
.mt-20 {
margin-top: 20px;
}
}
</style>

View File

@@ -209,6 +209,7 @@
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { formatDateTime } from '@/utils/business/format'
import { BgColorEnum } from '@/enums/appEnum'
defineOptions({ name: 'EnterpriseCustomer' })
@@ -367,7 +368,8 @@
{
prop: 'enterprise_code',
label: '企业编号',
minWidth: 150
minWidth: 150,
showOverflowTooltip: true
},
{
prop: 'enterprise_name',
@@ -423,7 +425,8 @@
activeText: '启用',
inactiveText: '禁用',
inlinePrompt: true,
'onUpdate:modelValue': (val: string | number | boolean) => handleStatusChange(row, val as number)
'onUpdate:modelValue': (val: string | number | boolean) =>
handleStatusChange(row, val as number)
})
}
},
@@ -436,21 +439,24 @@
{
prop: 'operation',
label: '操作',
width: 230,
width: 260,
fixed: 'right',
formatter: (row: EnterpriseItem) => {
return h('div', { style: 'display: flex; gap: 8px;' }, [
h(ArtButtonTable, {
icon: '&#xe679;',
text: '编辑',
iconClass: BgColorEnum.SECONDARY,
onClick: () => showDialog('edit', row)
}),
h(ArtButtonTable, {
text: '卡授权',
iconClass: BgColorEnum.PRIMARY,
onClick: () => manageCards(row)
}),
h(ArtButtonTable, {
icon: '&#xe72b;',
text: '修改密码',
iconClass: BgColorEnum.WARNING,
onClick: () => showPasswordDialog(row)
}),
h(ArtButtonTable, {
type: 'edit',
onClick: () => showDialog('edit', row)
})
])
}

View File

@@ -367,7 +367,8 @@
activeText: getStatusText(CommonStatus.ENABLED),
inactiveText: getStatusText(CommonStatus.DISABLED),
inlinePrompt: true,
'onUpdate:modelValue': (val: string | number | boolean) => handleStatusChange(row, val as number)
'onUpdate:modelValue': (val: string | number | boolean) =>
handleStatusChange(row, val as number)
})
}
},
@@ -447,8 +448,11 @@
currentAccountId.value = row.ID
selectedRoles.value = []
// 先加载当前账号的角色,再打开对话框
try {
// 每次打开对话框时重新加载最新的角色列表
await loadAllRoles()
// 先加载当前账号的角色,再打开对话框
const res = await PlatformAccountService.getPlatformAccountRoles(row.ID)
if (res.code === 0) {
// 提取角色ID数组
@@ -471,6 +475,8 @@
})
ElMessage.success('分配角色成功')
roleDialogVisible.value = false
// 刷新列表以更新角色显示
await getAccountList()
} catch (error) {
console.error(error)
} finally {

View File

@@ -300,15 +300,15 @@
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'id',
label: 'ID',
label: 'ID'
},
{
prop: 'username',
label: '用户名',
label: '用户名'
},
{
prop: 'phone',
label: '手机号',
label: '手机号'
},
{
prop: 'shop_name',
@@ -326,7 +326,8 @@
activeText: getStatusText(CommonStatus.ENABLED),
inactiveText: getStatusText(CommonStatus.DISABLED),
inlinePrompt: true,
'onUpdate:modelValue': (val: string | number | boolean) => handleStatusChange(row, val as number)
'onUpdate:modelValue': (val: string | number | boolean) =>
handleStatusChange(row, val as number)
})
}
},

View File

@@ -12,7 +12,9 @@
<ElSkeleton :loading="loading" :rows="10" animated>
<template #default>
<ElDescriptions v-if="recordDetail" title="基本信息" :column="3" border>
<ElDescriptionsItem label="分配单号">{{ recordDetail.allocation_no }}</ElDescriptionsItem>
<ElDescriptionsItem label="分配单号">{{
recordDetail.allocation_no
}}</ElDescriptionsItem>
<ElDescriptionsItem label="分配类型">
<ElTag :type="getAllocationTypeType(recordDetail.allocation_type)">
{{ recordDetail.allocation_name }}
@@ -23,14 +25,24 @@
{{ recordDetail.asset_type_name }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="资产标识符">{{ recordDetail.asset_identifier }}</ElDescriptionsItem>
<ElDescriptionsItem label="关联卡数量">{{ recordDetail.related_card_count }}</ElDescriptionsItem>
<ElDescriptionsItem label="资产标识符">{{
recordDetail.asset_identifier
}}</ElDescriptionsItem>
<ElDescriptionsItem label="关联卡数量">{{
recordDetail.related_card_count
}}</ElDescriptionsItem>
<ElDescriptionsItem label="关联设备ID">
{{ recordDetail.related_device_id || '-' }}
</ElDescriptionsItem>
</ElDescriptions>
<ElDescriptions v-if="recordDetail" title="所有者信息" :column="2" border style="margin-top: 20px">
<ElDescriptions
v-if="recordDetail"
title="所有者信息"
:column="2"
border
style="margin-top: 20px"
>
<ElDescriptionsItem label="来源所有者">
{{ recordDetail.from_owner_name }} ({{ recordDetail.from_owner_type }})
</ElDescriptionsItem>
@@ -39,8 +51,16 @@
</ElDescriptionsItem>
</ElDescriptions>
<ElDescriptions v-if="recordDetail" title="操作信息" :column="2" border style="margin-top: 20px">
<ElDescriptionsItem label="操作人">{{ recordDetail.operator_name }}</ElDescriptionsItem>
<ElDescriptions
v-if="recordDetail"
title="操作信息"
:column="2"
border
style="margin-top: 20px"
>
<ElDescriptionsItem label="操作人">{{
recordDetail.operator_name
}}</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">
{{ formatDateTime(recordDetail.created_at) }}
</ElDescriptionsItem>
@@ -50,7 +70,14 @@
</ElDescriptions>
<!-- 关联卡列表 -->
<div v-if="recordDetail && recordDetail.related_card_ids && recordDetail.related_card_ids.length > 0" style="margin-top: 20px">
<div
v-if="
recordDetail &&
recordDetail.related_card_ids &&
recordDetail.related_card_ids.length > 0
"
style="margin-top: 20px"
>
<ElDivider content-position="left">关联卡列表</ElDivider>
<ElTable :data="relatedCardsList" border>
<ElTableColumn type="index" label="序号" width="60" />
@@ -155,8 +182,8 @@
.allocation-record-detail-page {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
justify-content: space-between;
}
}
</style>

View File

@@ -53,11 +53,7 @@
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { formatDateTime } from '@/utils/business/format'
import type {
AssetAllocationRecord,
AllocationTypeEnum,
AssetTypeEnum
} from '@/types/api/card'
import type { AssetAllocationRecord, AllocationTypeEnum, AssetTypeEnum } from '@/types/api/card'
defineOptions({ name: 'AssetAllocationRecords' })

View File

@@ -46,7 +46,9 @@
{{ authorizationDetail.revoker_name || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="回收时间">
{{ authorizationDetail.revoked_at ? formatDateTime(authorizationDetail.revoked_at) : '-' }}
{{
authorizationDetail.revoked_at ? formatDateTime(authorizationDetail.revoked_at) : '-'
}}
</ElDescriptionsItem>
<ElDescriptionsItem label="备注" :span="2">
@@ -109,8 +111,8 @@
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
justify-content: space-between;
}
}
</style>

View File

@@ -240,10 +240,8 @@
label: '授权人类型',
width: 100,
formatter: (row: AuthorizationItem) => {
return h(
ElTag,
{ type: getAuthorizerTypeTag(row.authorizer_type) },
() => getAuthorizerTypeText(row.authorizer_type)
return h(ElTag, { type: getAuthorizerTypeTag(row.authorizer_type) }, () =>
getAuthorizerTypeText(row.authorizer_type)
)
}
},

View File

@@ -21,7 +21,7 @@
<ElDescriptionsItem label="制造商">{{ deviceInfo.manufacturer }}</ElDescriptionsItem>
<ElDescriptionsItem label="最大插槽数">{{ deviceInfo.max_sim_slots }}</ElDescriptionsItem>
<ElDescriptionsItem label="已绑定卡数">
<span style="color: #67c23a; font-weight: bold">{{ deviceInfo.bound_card_count }}</span>
<span style="font-weight: bold; color: #67c23a">{{ deviceInfo.bound_card_count }}</span>
/ {{ deviceInfo.max_sim_slots }}
</ElDescriptionsItem>
<ElDescriptionsItem label="所属店铺">
@@ -118,7 +118,11 @@
</ElSelect>
</ElFormItem>
<ElFormItem label="插槽位置" prop="slot_position">
<ElSelect v-model="bindForm.slot_position" placeholder="请选择插槽位置" style="width: 100%">
<ElSelect
v-model="bindForm.slot_position"
placeholder="请选择插槽位置"
style="width: 100%"
>
<ElOption
v-for="slot in availableSlots"
:key="slot"

View File

@@ -56,9 +56,14 @@
<!-- 批量分配对话框 -->
<ElDialog v-model="allocateDialogVisible" title="批量分配设备" width="600px">
<ElForm ref="allocateFormRef" :model="allocateForm" :rules="allocateRules" label-width="120px">
<ElForm
ref="allocateFormRef"
:model="allocateForm"
:rules="allocateRules"
label-width="120px"
>
<ElFormItem label="已选设备数">
<span style="color: #409eff; font-weight: bold">{{ selectedDevices.length }}</span>
<span style="font-weight: bold; color: #409eff">{{ selectedDevices.length }}</span>
</ElFormItem>
<ElFormItem label="目标店铺" prop="target_shop_id">
<ElSelect
@@ -93,7 +98,8 @@
style="margin-bottom: 10px"
>
<template #title>
成功分配 {{ allocateResult.success_count }} 失败 {{ allocateResult.fail_count }}
成功分配 {{ allocateResult.success_count }} 失败
{{ allocateResult.fail_count }}
</template>
</ElAlert>
<div v-if="allocateResult.failed_items && allocateResult.failed_items.length > 0">
@@ -101,7 +107,7 @@
<div
v-for="item in allocateResult.failed_items"
:key="item.device_id"
style="margin-bottom: 8px; color: #f56c6c; font-size: 12px"
style="margin-bottom: 8px; font-size: 12px; color: #f56c6c"
>
设备号: {{ item.device_no }} - {{ item.reason }}
</div>
@@ -129,7 +135,7 @@
<ElDialog v-model="recallDialogVisible" title="批量回收设备" width="600px">
<ElForm ref="recallFormRef" :model="recallForm" label-width="120px">
<ElFormItem label="已选设备数">
<span style="color: #e6a23c; font-weight: bold">{{ selectedDevices.length }}</span>
<span style="font-weight: bold; color: #e6a23c">{{ selectedDevices.length }}</span>
</ElFormItem>
<ElFormItem label="备注">
<ElInput
@@ -157,7 +163,7 @@
<div
v-for="item in recallResult.failed_items"
:key="item.device_id"
style="margin-bottom: 8px; color: #f56c6c; font-size: 12px"
style="margin-bottom: 8px; font-size: 12px; color: #f56c6c"
>
设备号: {{ item.device_no }} - {{ item.reason }}
</div>
@@ -182,10 +188,19 @@
</ElDialog>
<!-- 批量设置套餐系列绑定对话框 -->
<ElDialog v-model="seriesBindingDialogVisible" title="批量设置设备套餐系列绑定" width="600px">
<ElForm ref="seriesBindingFormRef" :model="seriesBindingForm" :rules="seriesBindingRules" label-width="120px">
<ElDialog
v-model="seriesBindingDialogVisible"
title="批量设置设备套餐系列绑定"
width="600px"
>
<ElForm
ref="seriesBindingFormRef"
:model="seriesBindingForm"
:rules="seriesBindingRules"
label-width="120px"
>
<ElFormItem label="已选设备数">
<span style="color: #409eff; font-weight: bold">{{ selectedDevices.length }}</span>
<span style="font-weight: bold; color: #409eff">{{ selectedDevices.length }}</span>
</ElFormItem>
<ElFormItem label="套餐系列分配" prop="series_allocation_id">
<ElSelect
@@ -214,15 +229,18 @@
style="margin-bottom: 10px"
>
<template #title>
成功设置 {{ seriesBindingResult.success_count }} 失败 {{ seriesBindingResult.fail_count }}
成功设置 {{ seriesBindingResult.success_count }} 失败
{{ seriesBindingResult.fail_count }}
</template>
</ElAlert>
<div v-if="seriesBindingResult.failed_items && seriesBindingResult.failed_items.length > 0">
<div
v-if="seriesBindingResult.failed_items && seriesBindingResult.failed_items.length > 0"
>
<div style="margin-bottom: 10px; font-weight: bold">失败详情</div>
<div
v-for="item in seriesBindingResult.failed_items"
:key="item.device_id"
style="margin-bottom: 8px; color: #f56c6c; font-size: 12px"
style="margin-bottom: 8px; font-size: 12px; color: #f56c6c"
>
设备号: {{ item.device_no }} - {{ item.reason }}
</div>
@@ -245,6 +263,61 @@
</div>
</template>
</ElDialog>
<!-- 设备详情弹窗 -->
<ElDialog v-model="deviceDetailDialogVisible" title="设备详情" width="900px">
<div v-if="deviceDetailLoading" style="text-align: center; padding: 40px 0">
<ElIcon class="is-loading" :size="40"><Loading /></ElIcon>
</div>
<ElDescriptions v-else-if="currentDeviceDetail" :column="3" border>
<ElDescriptionsItem label="设备ID">{{ currentDeviceDetail.id }}</ElDescriptionsItem>
<ElDescriptionsItem label="设备号" :span="2">{{
currentDeviceDetail.device_no
}}</ElDescriptionsItem>
<ElDescriptionsItem label="设备名称">{{
currentDeviceDetail.device_name || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="设备型号">{{
currentDeviceDetail.device_model || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="设备类型">{{
currentDeviceDetail.device_type || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="制造商">{{
currentDeviceDetail.manufacturer || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="最大插槽数">{{
currentDeviceDetail.max_sim_slots
}}</ElDescriptionsItem>
<ElDescriptionsItem label="已绑定卡数量">{{
currentDeviceDetail.bound_card_count
}}</ElDescriptionsItem>
<ElDescriptionsItem label="状态">
<ElTag :type="getDeviceStatusTagType(currentDeviceDetail.status)">
{{ currentDeviceDetail.status_name }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="店铺名称">{{
currentDeviceDetail.shop_name || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="批次号">{{
currentDeviceDetail.batch_no || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="激活时间">{{
currentDeviceDetail.activated_at || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">{{
currentDeviceDetail.created_at || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="更新时间">{{
currentDeviceDetail.updated_at || '--'
}}</ElDescriptionsItem>
</ElDescriptions>
</ElDialog>
</ElCard>
</div>
</ArtTableFullScreen>
@@ -255,7 +328,8 @@
import { useRouter } from 'vue-router'
import { DeviceService, ShopService } from '@/api/modules'
import { ShopSeriesAllocationService } from '@/api/modules/shopSeriesAllocation'
import { ElMessage, ElMessageBox, ElTag, ElSwitch } from 'element-plus'
import { ElMessage, ElMessageBox, ElTag, ElSwitch, ElIcon } from 'element-plus'
import { Loading } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import type {
Device,
@@ -301,6 +375,11 @@
})
const seriesBindingResult = ref<BatchSetDeviceSeriesBindingResponse | null>(null)
// 设备详情弹窗相关
const deviceDetailDialogVisible = ref(false)
const deviceDetailLoading = ref(false)
const currentDeviceDetail = ref<any>(null)
// 搜索表单初始值
const initialSearchState = {
device_no: '',
@@ -424,6 +503,40 @@
remark: ''
})
// 查看设备详情(通过弹窗)
const goToDeviceSearchDetail = async (deviceNo: string) => {
deviceDetailDialogVisible.value = true
deviceDetailLoading.value = true
currentDeviceDetail.value = null
try {
const res = await DeviceService.getDeviceByImei(deviceNo)
if (res.code === 0 && res.data) {
currentDeviceDetail.value = res.data
} else {
ElMessage.error(res.message || '查询失败')
deviceDetailDialogVisible.value = false
}
} catch (error: any) {
console.error('查询设备详情失败:', error)
ElMessage.error(error?.message || '查询失败,请检查设备号是否正确')
deviceDetailDialogVisible.value = false
} finally {
deviceDetailLoading.value = false
}
}
// 获取设备状态标签类型
const getDeviceStatusTagType = (status: number) => {
const typeMap: Record<number, any> = {
1: 'info', // 在库
2: 'warning', // 已分销
3: 'success', // 已激活
4: 'danger' // 已停用
}
return typeMap[status] || 'info'
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{
@@ -434,7 +547,17 @@
{
prop: 'device_no',
label: '设备号',
minWidth: 150
minWidth: 150,
formatter: (row: Device) => {
return h(
'span',
{
style: { color: 'var(--el-color-primary)', cursor: 'pointer' },
onClick: () => goToDeviceSearchDetail(row.device_no)
},
row.device_no
)
}
},
{
prop: 'device_name',

View File

@@ -18,9 +18,7 @@
@keyup.enter="handleSearch"
>
<template #append>
<ElButton type="primary" :loading="loading" @click="handleSearch">
查询
</ElButton>
<ElButton type="primary" :loading="loading" @click="handleSearch"> 查询 </ElButton>
</template>
</ElInput>
</ElFormItem>
@@ -38,25 +36,41 @@
<ElDescriptions :column="3" border>
<ElDescriptionsItem label="设备ID">{{ deviceDetail.id }}</ElDescriptionsItem>
<ElDescriptionsItem label="设备号" :span="2">{{ deviceDetail.device_no }}</ElDescriptionsItem>
<ElDescriptionsItem label="设备号" :span="2">{{
deviceDetail.device_no
}}</ElDescriptionsItem>
<ElDescriptionsItem label="设备名称">{{ deviceDetail.device_name || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="设备型号">{{ deviceDetail.device_model || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="设备类型">{{ deviceDetail.device_type || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="设备名称">{{
deviceDetail.device_name || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="设备型号">{{
deviceDetail.device_model || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="设备类型">{{
deviceDetail.device_type || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="制造商">{{ deviceDetail.manufacturer || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="制造商">{{
deviceDetail.manufacturer || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="最大插槽数">{{ deviceDetail.max_sim_slots }}</ElDescriptionsItem>
<ElDescriptionsItem label="已绑定卡数量">{{ deviceDetail.bound_card_count }}</ElDescriptionsItem>
<ElDescriptionsItem label="已绑定卡数量">{{
deviceDetail.bound_card_count
}}</ElDescriptionsItem>
<ElDescriptionsItem label="状态">
<ElTag :type="getStatusTagType(deviceDetail.status)">
{{ deviceDetail.status_name }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="店铺名称">{{ deviceDetail.shop_name || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="店铺名称">{{
deviceDetail.shop_name || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="批次号">{{ deviceDetail.batch_no }}</ElDescriptionsItem>
<ElDescriptionsItem label="激活时间">{{ deviceDetail.activated_at || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="激活时间">{{
deviceDetail.activated_at || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">{{ deviceDetail.created_at }}</ElDescriptionsItem>
<ElDescriptionsItem label="更新时间">{{ deviceDetail.updated_at }}</ElDescriptionsItem>
</ElDescriptions>

View File

@@ -5,6 +5,7 @@
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
:show-expand="false"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
@@ -15,7 +16,13 @@
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
/>
>
<template #left>
<ElButton type="primary" :icon="Upload" @click="importDialogVisible = true">
批量导入设备
</ElButton>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
@@ -36,25 +43,149 @@
</ArtTable>
</ElCard>
</div>
<!-- 导入对话框 -->
<ElDialog v-model="importDialogVisible" title="批量导入设备" width="700px" align-center>
<ElAlert type="info" :closable="false" style="margin-bottom: 20px">
<template #title>
<div style="line-height: 1.8">
<p><strong>导入说明</strong></p>
<p>1. 请先下载 CSV 模板文件按照模板格式填写设备信息</p>
<p>2. 支持 CSV 格式.csv单次最多导入 1000 </p>
<p>3. CSV 文件编码UTF-8推荐 GBK</p>
<p
>4.
必填字段device_no设备号device_name设备名称device_model设备型号</p
>
<p
>5.
可选字段device_type设备类型manufacturer制造商max_sim_slots最大插槽数默认1</p
>
</div>
</template>
</ElAlert>
<div style="margin-bottom: 20px">
<ElButton type="primary" :icon="Download" @click="downloadTemplate">
下载导入模板
</ElButton>
</div>
<ElUpload
ref="uploadRef"
drag
:auto-upload="false"
:on-change="handleFileChange"
:limit="1"
accept=".csv"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text"> CSV 文件拖到此处<em>点击选择</em></div>
<template #tip>
<div class="el-upload__tip">只能上传 CSV 文件且不超过 10MB</div>
</template>
</ElUpload>
<template #footer>
<ElButton @click="handleCancelImport">取消</ElButton>
<ElButton
type="primary"
:loading="uploading"
:disabled="!fileList.length"
@click="submitUpload"
>
开始导入
</ElButton>
</template>
</ElDialog>
<!-- 任务详情对话框 -->
<ElDialog v-model="detailDialogVisible" title="设备导入任务详情" width="900px" align-center>
<ElDescriptions :column="2" border>
<ElDescriptionsItem label="任务编号" :span="2">{{
currentDetail.task_no
}}</ElDescriptionsItem>
<ElDescriptionsItem label="状态">
<ElTag :type="getStatusType(currentDetail.status)">{{ currentDetail.status_text }}</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="批次号">{{ currentDetail.batch_no }}</ElDescriptionsItem>
<ElDescriptionsItem label="文件名" :span="2">{{
currentDetail.file_name
}}</ElDescriptionsItem>
<ElDescriptionsItem label="总数">{{ currentDetail.total_count }}</ElDescriptionsItem>
<ElDescriptionsItem label="成功数">
<span style="color: var(--el-color-success)">{{ currentDetail.success_count }}</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="跳过数">{{ currentDetail.skip_count }}</ElDescriptionsItem>
<ElDescriptionsItem label="失败数">
<span style="color: var(--el-color-danger)">{{ currentDetail.fail_count }}</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="警告数">
<span style="color: var(--el-color-warning)">{{ currentDetail.warning_count }}</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="开始时间">{{ currentDetail.started_at }}</ElDescriptionsItem>
<ElDescriptionsItem label="完成时间">{{ currentDetail.completed_at }}</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">{{ currentDetail.created_at }}</ElDescriptionsItem>
<ElDescriptionsItem label="错误信息" :span="2">{{
currentDetail.error_message
}}</ElDescriptionsItem>
</ElDescriptions>
<ElDivider content-position="left">失败明细</ElDivider>
<div
v-if="currentDetail.failed_items && currentDetail.failed_items.length"
style="max-height: 300px; overflow-y: auto"
>
<ElTable :data="currentDetail.failed_items" border size="small">
<ElTableColumn label="行号" type="index" width="80" :index="(index) => index + 1" />
<ElTableColumn label="设备编号" prop="device_no" width="150" />
<ElTableColumn label="ICCID" prop="iccid" width="200" />
<ElTableColumn label="失败原因" prop="reason" min-width="200">
<template #default="{ row }">
{{ row.reason || row.error || '未知错误' }}
</template>
</ElTableColumn>
</ElTable>
</div>
<ElEmpty v-else description="无失败记录" />
<template #footer>
<ElButton @click="detailDialogVisible = false">关闭</ElButton>
<ElButton
v-if="currentDetail.fail_count > 0"
type="primary"
:icon="Download"
@click="downloadFailData"
>
下载失败数据
</ElButton>
</template>
</ElDialog>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { useRouter } from 'vue-router'
import { DeviceService } from '@/api/modules'
import { ElMessage, ElTag } from 'element-plus'
import { Download, UploadFilled, Upload } from '@element-plus/icons-vue'
import type { UploadInstance } from 'element-plus'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { formatDateTime } from '@/utils/business/format'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { StorageService } from '@/api/modules/storage'
import type { DeviceImportTask, DeviceImportTaskStatus } from '@/types/api/device'
defineOptions({ name: 'DeviceTask' })
const router = useRouter()
const loading = ref(false)
const tableRef = ref()
const uploadRef = ref<UploadInstance>()
const fileList = ref<File[]>([])
const uploading = ref(false)
const importDialogVisible = ref(false)
const detailDialogVisible = ref(false)
// 搜索表单初始值
const initialSearchState = {
@@ -116,19 +247,21 @@
// 列配置
const columnOptions = [
{ label: '任务编号', prop: 'task_no' },
{ label: '批次号', prop: 'batch_no' },
{ label: '文件名', prop: 'file_name' },
{ label: '任务状态', prop: 'status' },
{ label: '文件名', prop: 'file_name' },
{ label: '总数', prop: 'total_count' },
{ label: '成功数', prop: 'success_count' },
{ label: '失败数', prop: 'fail_count' },
{ label: '跳过数', prop: 'skip_count' },
{ label: '创建时间', prop: 'created_at' },
{ label: '开始时间', prop: 'started_at' },
{ label: '完成时间', prop: 'completed_at' },
{ label: '错误信息', prop: 'error_message' },
{ label: '创建时间', prop: 'created_at' },
{ label: '操作', prop: 'operation' }
]
const taskList = ref<DeviceImportTask[]>([])
const currentDetail = ref<any>({})
// 获取状态标签类型
const getStatusType = (status: DeviceImportTaskStatus) => {
@@ -147,14 +280,22 @@
}
// 查看详情
const viewDetail = (row: DeviceImportTask) => {
router.push({
path: '/asset-management/task-detail',
query: {
id: row.id,
task_type: 'device'
const viewDetail = async (row: DeviceImportTask) => {
try {
const res = await DeviceService.getImportTaskDetail(row.id)
if (res.code === 0 && res.data) {
currentDetail.value = {
...res.data,
started_at: res.data.started_at ? formatDateTime(res.data.started_at) : '-',
completed_at: res.data.completed_at ? formatDateTime(res.data.completed_at) : '-',
created_at: res.data.created_at ? formatDateTime(res.data.created_at) : '-'
}
detailDialogVisible.value = true
}
})
} catch (error) {
console.error('获取任务详情失败:', error)
ElMessage.error('获取任务详情失败')
}
}
// 动态列配置
@@ -162,17 +303,7 @@
{
prop: 'task_no',
label: '任务编号',
width: 150
},
{
prop: 'batch_no',
label: '批次号',
width: 120
},
{
prop: 'file_name',
label: '文件名',
minWidth: 200
width: 180
},
{
prop: 'status',
@@ -182,6 +313,12 @@
return h(ElTag, { type: getStatusType(row.status) }, () => row.status_text)
}
},
{
prop: 'file_name',
label: '文件名',
minWidth: 250,
showOverflowTooltip: true
},
{
prop: 'total_count',
label: '总数',
@@ -190,15 +327,17 @@
{
prop: 'success_count',
label: '成功数',
width: 80
width: 80,
formatter: (row: DeviceImportTask) => {
return h('span', { style: { color: 'var(--el-color-success)' } }, row.success_count)
}
},
{
prop: 'fail_count',
label: '失败数',
width: 80,
formatter: (row: DeviceImportTask) => {
const type = row.fail_count > 0 ? 'danger' : 'success'
return h(ElTag, { type, size: 'small' }, () => row.fail_count)
return h('span', { style: { color: 'var(--el-color-danger)' } }, row.fail_count)
}
},
{
@@ -207,27 +346,59 @@
width: 80
},
{
prop: 'created_at',
label: '创建时间',
width: 160,
formatter: (row: DeviceImportTask) => formatDateTime(row.created_at)
prop: 'started_at',
label: '开始时间',
width: 180,
formatter: (row: DeviceImportTask) => (row.started_at ? formatDateTime(row.started_at) : '-')
},
{
prop: 'completed_at',
label: '完成时间',
width: 160,
formatter: (row: DeviceImportTask) => (row.completed_at ? formatDateTime(row.completed_at) : '-')
width: 180,
formatter: (row: DeviceImportTask) =>
row.completed_at ? formatDateTime(row.completed_at) : '-'
},
{
prop: 'error_message',
label: '错误信息',
minWidth: 200,
showOverflowTooltip: true,
formatter: (row: DeviceImportTask) => row.error_message || '-'
},
{
prop: 'created_at',
label: '创建时间',
width: 180,
formatter: (row: DeviceImportTask) => formatDateTime(row.created_at)
},
{
prop: 'operation',
label: '操作',
width: 100,
width: 180,
fixed: 'right',
formatter: (row: DeviceImportTask) => {
return h(ArtButtonTable, {
type: 'view',
onClick: () => viewDetail(row)
})
const buttons = []
// 显示"查看详情"按钮
buttons.push(
h(ArtButtonTable, {
text: '详情',
onClick: () => viewDetail(row)
})
)
// 如果有失败数据,显示"失败数据"按钮
if (row.fail_count > 0) {
buttons.push(
h(ArtButtonTable, {
text: '失败数据',
type: 'danger',
onClick: () => downloadFailDataByRow(row)
})
)
}
return h('div', { style: 'display: flex; gap: 8px;' }, buttons)
}
}
])
@@ -262,7 +433,7 @@
const res = await DeviceService.getImportTasks(params)
if (res.code === 0) {
taskList.value = res.data.list || []
taskList.value = res.data.items || []
pagination.total = res.data.total || 0
}
} catch (error) {
@@ -301,10 +472,195 @@
pagination.page = newCurrentPage
getTableData()
}
// 下载模板
const downloadTemplate = () => {
const csvContent = [
'device_no,device_name,device_model,device_type,manufacturer,max_sim_slots',
'DEV001,智能水表01,WM-2000,智能水表,华为,1',
'DEV002,GPS定位器01,GPS-3000,定位设备,小米,2',
'DEV003,智能燃气表01,GM-1500,智能燃气表,海尔,1'
].join('\n')
const BOM = '\uFEFF'
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', '设备导入模板.csv')
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
ElMessage.success('设备导入模板下载成功')
}
// 文件选择变化
const handleFileChange = (uploadFile: any) => {
const maxSize = 10 * 1024 * 1024
if (uploadFile.raw && uploadFile.raw.size > maxSize) {
ElMessage.error('文件大小不能超过 10MB')
uploadRef.value?.clearFiles()
fileList.value = []
return
}
if (uploadFile.raw && !uploadFile.raw.name.endsWith('.csv')) {
ElMessage.error('只能上传 CSV 文件')
uploadRef.value?.clearFiles()
fileList.value = []
return
}
fileList.value = uploadFile.raw ? [uploadFile.raw] : []
}
// 清空文件
const clearFiles = () => {
uploadRef.value?.clearFiles()
fileList.value = []
}
// 取消导入
const handleCancelImport = () => {
clearFiles()
importDialogVisible.value = false
}
// 提交上传
const submitUpload = async () => {
if (!fileList.value.length) {
ElMessage.warning('请先选择CSV文件')
return
}
const file = fileList.value[0]
uploading.value = true
try {
ElMessage.info('正在准备上传...')
const uploadUrlRes = await StorageService.getUploadUrl({
file_name: file.name,
content_type: 'text/csv',
purpose: 'iot_import'
})
if (uploadUrlRes.code !== 0) {
throw new Error(uploadUrlRes.msg || '获取上传地址失败')
}
const { upload_url, file_key } = uploadUrlRes.data
ElMessage.info('正在上传文件...')
await StorageService.uploadFile(upload_url, file, 'text/csv')
ElMessage.info('正在创建导入任务...')
const importRes = await DeviceService.importDevices({
file_key,
batch_no: `DEV-${Date.now()}`
})
if (importRes.code !== 0) {
throw new Error(importRes.msg || '创建导入任务失败')
}
const taskNo = importRes.data.task_no
handleCancelImport()
getTableData()
ElMessage.success({
message: `导入任务已创建!任务编号:${taskNo}`,
duration: 3000,
showClose: true
})
} catch (error: any) {
console.error('设备导入失败:', error)
ElMessage.error(error.message || '设备导入失败')
} finally {
uploading.value = false
}
}
// 从行数据下载失败数据
const downloadFailDataByRow = async (row: DeviceImportTask) => {
try {
const res = await DeviceService.getImportTaskDetail(row.id)
if (res.code === 0 && res.data) {
const detail = res.data
downloadFailDataFromDetail(detail, row.batch_no)
}
} catch (error) {
console.error('下载失败数据失败:', error)
ElMessage.error('下载失败数据失败')
}
}
// 下载失败数据(从详情对话框)
const downloadFailData = () => {
downloadFailDataFromDetail(currentDetail.value, currentDetail.value.batch_no)
}
// 下载失败数据的通用方法
const downloadFailDataFromDetail = (detail: any, batchNo: string) => {
const failReasons =
detail.failed_items?.map((item: any, index: number) => ({
row: index + 1,
deviceCode: item.device_no || '-',
iccid: item.iccid || '-',
message: item.reason || item.error || '未知错误'
})) || []
if (failReasons.length === 0) {
ElMessage.warning('没有失败数据可下载')
return
}
const headers = ['行号', '设备编号', 'ICCID', '失败原因']
const csvRows = [
headers.join(','),
...failReasons.map((item: any) =>
[item.row, item.deviceCode, item.iccid, `"${item.message}"`].join(',')
)
]
const csvContent = csvRows.join('\n')
const BOM = '\uFEFF'
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', `导入失败数据_${batchNo}.csv`)
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
ElMessage.success('失败数据下载成功')
}
</script>
<style lang="scss" scoped>
.device-task-page {
// Device task page styles
:deep(.el-icon--upload) {
margin-bottom: 16px;
font-size: 67px;
color: var(--el-text-color-placeholder);
}
:deep(.el-upload__text) {
font-size: 14px;
color: var(--el-text-color-regular);
em {
font-style: normal;
color: var(--el-color-primary);
}
}
}
</style>

View File

@@ -90,7 +90,7 @@
:placeholder="$t('enterpriseDevices.form.deviceNosPlaceholder')"
@input="handleDeviceNosChange"
/>
<div style="color: var(--el-color-info); margin-top: 4px; font-size: 12px">
<div style="margin-top: 4px; font-size: 12px; color: var(--el-color-info)">
{{
$t('enterpriseDevices.form.selectedCount', {
count: allocateForm.device_nos?.length || 0
@@ -110,9 +110,7 @@
<template #footer>
<div class="dialog-footer">
<ElButton @click="allocateDialogVisible = false">{{
$t('common.cancel')
}}</ElButton>
<ElButton @click="allocateDialogVisible = false">{{ $t('common.cancel') }}</ElButton>
<ElButton type="primary" @click="handleAllocate" :loading="allocateLoading">
{{ $t('common.confirm') }}
</ElButton>
@@ -635,8 +633,8 @@
.enterprise-devices-page {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
justify-content: space-between;
}
}
</style>

View File

@@ -19,13 +19,25 @@
>
<template #left>
<ElButton type="primary" @click="showImportDialog">导入ICCID</ElButton>
<ElButton type="success" :disabled="selectedCards.length === 0" @click="showAllocateDialog">
<ElButton
type="success"
:disabled="selectedCards.length === 0"
@click="showAllocateDialog"
>
批量分配
</ElButton>
<ElButton type="warning" :disabled="selectedCards.length === 0" @click="showRecallDialog">
<ElButton
type="warning"
:disabled="selectedCards.length === 0"
@click="showRecallDialog"
>
批量回收
</ElButton>
<ElButton type="info" :disabled="selectedCards.length === 0" @click="showSeriesBindingDialog">
<ElButton
type="info"
:disabled="selectedCards.length === 0"
@click="showSeriesBindingDialog"
>
批量设置套餐系列
</ElButton>
<ElButton type="primary" @click="cardDistribution">网卡分销</ElButton>
@@ -65,7 +77,11 @@
>
<ElForm ref="importFormRef" :model="importForm" :rules="importRules" label-width="100px">
<ElFormItem label="运营商" prop="carrier_id">
<ElSelect v-model="importForm.carrier_id" placeholder="请选择运营商" style="width: 100%">
<ElSelect
v-model="importForm.carrier_id"
placeholder="请选择运营商"
style="width: 100%"
>
<ElOption label="中国移动" :value="1" />
<ElOption label="中国联通" :value="2" />
<ElOption label="中国电信" :value="3" />
@@ -91,7 +107,7 @@
<template #tip>
<div class="el-upload__tip">
<div>只支持上传CSV文件且不超过10MB</div>
<div style="color: var(--el-color-info); margin-top: 4px">
<div style="margin-top: 4px; color: var(--el-color-info)">
CSV格式ICCID,MSISDN两列逗号分隔每行一条记录
</div>
</div>
@@ -116,9 +132,18 @@
width="600px"
@close="handleAllocateDialogClose"
>
<ElForm ref="allocateFormRef" :model="allocateForm" :rules="allocateRules" label-width="120px">
<ElForm
ref="allocateFormRef"
:model="allocateForm"
:rules="allocateRules"
label-width="120px"
>
<ElFormItem label="目标店铺" prop="to_shop_id">
<ElSelect v-model="allocateForm.to_shop_id" placeholder="请选择目标店铺" style="width: 100%">
<ElSelect
v-model="allocateForm.to_shop_id"
placeholder="请选择目标店铺"
style="width: 100%"
>
<ElOption label="店铺A" :value="1" />
<ElOption label="店铺B" :value="2" />
<ElOption label="店铺C" :value="3" />
@@ -136,22 +161,40 @@
<div>已选择 {{ selectedCards.length }} 张卡</div>
</ElFormItem>
<ElFormItem v-if="allocateForm.selection_type === 'range'" label="起始ICCID" prop="iccid_start">
<ElFormItem
v-if="allocateForm.selection_type === 'range'"
label="起始ICCID"
prop="iccid_start"
>
<ElInput v-model="allocateForm.iccid_start" placeholder="请输入起始ICCID" />
</ElFormItem>
<ElFormItem v-if="allocateForm.selection_type === 'range'" label="结束ICCID" prop="iccid_end">
<ElFormItem
v-if="allocateForm.selection_type === 'range'"
label="结束ICCID"
prop="iccid_end"
>
<ElInput v-model="allocateForm.iccid_end" placeholder="请输入结束ICCID" />
</ElFormItem>
<ElFormItem v-if="allocateForm.selection_type === 'filter'" label="运营商">
<ElSelect v-model="allocateForm.carrier_id" placeholder="请选择运营商" clearable style="width: 100%">
<ElSelect
v-model="allocateForm.carrier_id"
placeholder="请选择运营商"
clearable
style="width: 100%"
>
<ElOption label="中国移动" :value="1" />
<ElOption label="中国联通" :value="2" />
<ElOption label="中国电信" :value="3" />
</ElSelect>
</ElFormItem>
<ElFormItem v-if="allocateForm.selection_type === 'filter'" label="卡状态">
<ElSelect v-model="allocateForm.status" placeholder="请选择状态" clearable style="width: 100%">
<ElSelect
v-model="allocateForm.status"
placeholder="请选择状态"
clearable
style="width: 100%"
>
<ElOption label="在库" :value="1" />
<ElOption label="已分销" :value="2" />
<ElOption label="已激活" :value="3" />
@@ -163,7 +206,12 @@
</ElFormItem>
<ElFormItem label="备注">
<ElInput v-model="allocateForm.remark" type="textarea" :rows="3" placeholder="请输入备注信息" />
<ElInput
v-model="allocateForm.remark"
type="textarea"
:rows="3"
placeholder="请输入备注信息"
/>
</ElFormItem>
</ElForm>
<template #footer>
@@ -185,7 +233,11 @@
>
<ElForm ref="recallFormRef" :model="recallForm" :rules="recallRules" label-width="120px">
<ElFormItem label="来源店铺" prop="from_shop_id">
<ElSelect v-model="recallForm.from_shop_id" placeholder="请选择来源店铺" style="width: 100%">
<ElSelect
v-model="recallForm.from_shop_id"
placeholder="请选择来源店铺"
style="width: 100%"
>
<ElOption label="店铺A" :value="1" />
<ElOption label="店铺B" :value="2" />
<ElOption label="店铺C" :value="3" />
@@ -203,15 +255,28 @@
<div>已选择 {{ selectedCards.length }} 张卡</div>
</ElFormItem>
<ElFormItem v-if="recallForm.selection_type === 'range'" label="起始ICCID" prop="iccid_start">
<ElFormItem
v-if="recallForm.selection_type === 'range'"
label="起始ICCID"
prop="iccid_start"
>
<ElInput v-model="recallForm.iccid_start" placeholder="请输入起始ICCID" />
</ElFormItem>
<ElFormItem v-if="recallForm.selection_type === 'range'" label="结束ICCID" prop="iccid_end">
<ElFormItem
v-if="recallForm.selection_type === 'range'"
label="结束ICCID"
prop="iccid_end"
>
<ElInput v-model="recallForm.iccid_end" placeholder="请输入结束ICCID" />
</ElFormItem>
<ElFormItem v-if="recallForm.selection_type === 'filter'" label="运营商">
<ElSelect v-model="recallForm.carrier_id" placeholder="请选择运营商" clearable style="width: 100%">
<ElSelect
v-model="recallForm.carrier_id"
placeholder="请选择运营商"
clearable
style="width: 100%"
>
<ElOption label="中国移动" :value="1" />
<ElOption label="中国联通" :value="2" />
<ElOption label="中国电信" :value="3" />
@@ -222,7 +287,12 @@
</ElFormItem>
<ElFormItem label="备注">
<ElInput v-model="recallForm.remark" type="textarea" :rows="3" placeholder="请输入备注信息" />
<ElInput
v-model="recallForm.remark"
type="textarea"
:rows="3"
placeholder="请输入备注信息"
/>
</ElFormItem>
</ElForm>
<template #footer>
@@ -236,14 +306,14 @@
</ElDialog>
<!-- 分配结果对话框 -->
<ElDialog
v-model="resultDialogVisible"
:title="resultTitle"
width="700px"
>
<ElDialog v-model="resultDialogVisible" :title="resultTitle" width="700px">
<ElDescriptions :column="2" border>
<ElDescriptionsItem label="操作单号">{{ allocationResult.allocation_no }}</ElDescriptionsItem>
<ElDescriptionsItem label="待处理总数">{{ allocationResult.total_count }}</ElDescriptionsItem>
<ElDescriptionsItem label="操作单号">{{
allocationResult.allocation_no
}}</ElDescriptionsItem>
<ElDescriptionsItem label="待处理总数">{{
allocationResult.total_count
}}</ElDescriptionsItem>
<ElDescriptionsItem label="成功数">
<ElTag type="success">{{ allocationResult.success_count }}</ElTag>
</ElDescriptionsItem>
@@ -252,7 +322,10 @@
</ElDescriptionsItem>
</ElDescriptions>
<div v-if="allocationResult.failed_items && allocationResult.failed_items.length > 0" style="margin-top: 20px">
<div
v-if="allocationResult.failed_items && allocationResult.failed_items.length > 0"
style="margin-top: 20px"
>
<ElDivider content-position="left">失败项详情</ElDivider>
<ElTable :data="allocationResult.failed_items" border max-height="300">
<ElTableColumn prop="iccid" label="ICCID" width="180" />
@@ -274,7 +347,12 @@
width="600px"
@close="handleSeriesBindingDialogClose"
>
<ElForm ref="seriesBindingFormRef" :model="seriesBindingForm" :rules="seriesBindingRules" label-width="120px">
<ElForm
ref="seriesBindingFormRef"
:model="seriesBindingForm"
:rules="seriesBindingRules"
label-width="120px"
>
<ElFormItem label="已选择卡数">
<div>已选择 {{ selectedCards.length }} 张卡</div>
</ElFormItem>
@@ -307,11 +385,7 @@
</ElDialog>
<!-- 套餐系列绑定结果对话框 -->
<ElDialog
v-model="seriesBindingResultDialogVisible"
title="设置结果"
width="700px"
>
<ElDialog v-model="seriesBindingResultDialogVisible" title="设置结果" width="700px">
<ElDescriptions :column="2" border>
<ElDescriptionsItem label="成功数">
<ElTag type="success">{{ seriesBindingResult.success_count }}</ElTag>
@@ -321,7 +395,10 @@
</ElDescriptionsItem>
</ElDescriptions>
<div v-if="seriesBindingResult.failed_items && seriesBindingResult.failed_items.length > 0" style="margin-top: 20px">
<div
v-if="seriesBindingResult.failed_items && seriesBindingResult.failed_items.length > 0"
style="margin-top: 20px"
>
<ElDivider content-position="left">失败项详情</ElDivider>
<ElTable :data="seriesBindingResult.failed_items" border max-height="300">
<ElTableColumn prop="iccid" label="ICCID" width="180" />
@@ -331,11 +408,99 @@
<template #footer>
<div class="dialog-footer">
<ElButton type="primary" @click="seriesBindingResultDialogVisible = false">确定</ElButton>
<ElButton type="primary" @click="seriesBindingResultDialogVisible = false"
>确定</ElButton
>
</div>
</template>
</ElDialog>
<!-- 卡详情对话框 -->
<ElDialog v-model="cardDetailDialogVisible" title="卡片详情" width="900px">
<div v-if="cardDetailLoading" style="text-align: center; padding: 40px">
<ElIcon class="is-loading" :size="40"><Loading /></ElIcon>
<div style="margin-top: 16px">加载中...</div>
</div>
<ElDescriptions v-else-if="currentCardDetail" :column="3" border>
<ElDescriptionsItem label="卡ID">{{ currentCardDetail.id }}</ElDescriptionsItem>
<ElDescriptionsItem label="ICCID" :span="2">{{
currentCardDetail.iccid
}}</ElDescriptionsItem>
<ElDescriptionsItem label="卡接入号">{{
currentCardDetail.msisdn || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="运营商">{{
currentCardDetail.carrier_name || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="运营商类型">{{
getCarrierTypeText(currentCardDetail.carrier_type)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="卡类型">{{
currentCardDetail.card_type || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="卡业务类型">{{
getCardCategoryText(currentCardDetail.card_category)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="成本价">{{
formatCardPrice(currentCardDetail.cost_price)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="分销价">{{
formatCardPrice(currentCardDetail.distribute_price)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="状态">
<ElTag :type="getCardDetailStatusTagType(currentCardDetail.status)">
{{ getCardDetailStatusText(currentCardDetail.status) }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="激活状态">
<ElTag :type="currentCardDetail.activation_status === 1 ? 'success' : 'info'">
{{ currentCardDetail.activation_status === 1 ? '已激活' : '未激活' }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="实名状态">
<ElTag :type="currentCardDetail.real_name_status === 1 ? 'success' : 'warning'">
{{ currentCardDetail.real_name_status === 1 ? '已实名' : '未实名' }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="网络状态">
<ElTag :type="currentCardDetail.network_status === 1 ? 'success' : 'danger'">
{{ currentCardDetail.network_status === 1 ? '开机' : '停机' }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="累计流量使用"
>{{ currentCardDetail.data_usage_mb }} MB</ElDescriptionsItem
>
<ElDescriptionsItem label="首次佣金">
<ElTag :type="currentCardDetail.first_commission_paid ? 'success' : 'info'">
{{ currentCardDetail.first_commission_paid ? '已支付' : '未支付' }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="累计充值">{{
formatCardPrice(currentCardDetail.accumulated_recharge)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">{{
formatDateTime(currentCardDetail.created_at)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="更新时间" :span="2">{{
formatDateTime(currentCardDetail.updated_at)
}}</ElDescriptionsItem>
</ElDescriptions>
<ElEmpty v-else description="未找到卡片信息" />
<template #footer>
<div class="dialog-footer">
<ElButton type="primary" @click="cardDetailDialogVisible = false">关闭</ElButton>
</div>
</template>
</ElDialog>
</ElCard>
</div>
</ArtTableFullScreen>
@@ -343,9 +508,11 @@
<script setup lang="ts">
import { h } from 'vue'
import { useRouter } from 'vue-router'
import { CardService, StorageService } from '@/api/modules'
import { ShopSeriesAllocationService } from '@/api/modules/shopSeriesAllocation'
import { ElMessage, ElTag, ElUpload } from 'element-plus'
import { ElMessage, ElTag, ElUpload, ElIcon } from 'element-plus'
import { Loading } from '@element-plus/icons-vue'
import type { FormInstance, FormRules, UploadProps, UploadUserFile } from 'element-plus'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
@@ -362,6 +529,7 @@
defineOptions({ name: 'StandaloneCardList' })
const router = useRouter()
const loading = ref(false)
const importDialogVisible = ref(false)
const importLoading = ref(false)
@@ -405,13 +573,17 @@
failed_items: null
})
// 卡详情弹窗相关
const cardDetailDialogVisible = ref(false)
const cardDetailLoading = ref(false)
const currentCardDetail = ref<any>(null)
// 搜索表单初始值
const initialSearchState = {
status: undefined,
carrier_id: undefined,
iccid: '',
msisdn: '',
batch_no: '',
is_distributed: undefined
}
@@ -576,15 +748,6 @@
placeholder: '请输入卡接入号'
}
},
{
label: '批次号',
prop: 'batch_no',
type: 'input',
config: {
clearable: true,
placeholder: '请输入批次号'
}
},
{
label: '是否已分销',
prop: 'is_distributed',
@@ -653,12 +816,88 @@
}
}
// 打开卡详情弹窗
const goToCardDetail = async (iccid: string) => {
cardDetailDialogVisible.value = true
cardDetailLoading.value = true
currentCardDetail.value = null
try {
const res = await CardService.getIotCardDetailByIccid(iccid)
if (res.code === 0 && res.data) {
currentCardDetail.value = res.data
} else {
ElMessage.error(res.message || '查询失败')
cardDetailDialogVisible.value = false
}
} catch (error: any) {
console.error('查询卡片详情失败:', error)
ElMessage.error(error?.message || '查询失败请检查ICCID是否正确')
cardDetailDialogVisible.value = false
} finally {
cardDetailLoading.value = false
}
}
// 卡详情辅助函数
const getCarrierTypeText = (type: string) => {
const typeMap: Record<string, string> = {
CMCC: '中国移动',
CUCC: '中国联通',
CTCC: '中国电信',
CBN: '中国广电'
}
return typeMap[type] || type
}
const getCardCategoryText = (category: string) => {
const categoryMap: Record<string, string> = {
normal: '普通卡',
industry: '行业卡'
}
return categoryMap[category] || category
}
const getCardDetailStatusText = (status: number) => {
const statusMap: Record<number, string> = {
1: '在库',
2: '已分销',
3: '已激活',
4: '已停用'
}
return statusMap[status] || '未知'
}
const getCardDetailStatusTagType = (status: number) => {
const typeMap: Record<number, any> = {
1: 'info',
2: 'warning',
3: 'success',
4: 'danger'
}
return typeMap[status] || 'info'
}
const formatCardPrice = (price: number) => {
return `¥${(price / 100).toFixed(2)}`
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'iccid',
label: 'ICCID',
minWidth: 190
minWidth: 200,
formatter: (row: StandaloneIotCard) => {
return h(
'span',
{
style: { color: 'var(--el-color-primary)', cursor: 'pointer' },
onClick: () => goToCardDetail(row.iccid)
},
row.iccid
)
}
},
{
prop: 'msisdn',
@@ -754,7 +993,7 @@
{
prop: 'created_at',
label: '创建时间',
width: 160,
width: 180,
formatter: (row: StandaloneIotCard) => formatDateTime(row.created_at)
}
])
@@ -897,7 +1136,9 @@
})
if (importRes.code === 0) {
ElMessage.success(importRes.data.message || '导入任务已创建,请到任务管理页面查看导入进度')
ElMessage.success(
importRes.data.message || '导入任务已创建,请到任务管理页面查看导入进度'
)
importDialogVisible.value = false
getTableData()
}
@@ -1167,7 +1408,9 @@
} else if (res.data.success_count === 0) {
ElMessage.error('套餐系列绑定设置失败')
} else {
ElMessage.warning(`部分设置成功:成功 ${res.data.success_count} 项,失败 ${res.data.fail_count}`)
ElMessage.warning(
`部分设置成功:成功 ${res.data.success_count} 项,失败 ${res.data.fail_count}`
)
}
}
} catch (error) {

View File

@@ -18,9 +18,7 @@
@keyup.enter="handleSearch"
>
<template #append>
<ElButton type="primary" :loading="loading" @click="handleSearch">
查询
</ElButton>
<ElButton type="primary" :loading="loading" @click="handleSearch"> 查询 </ElButton>
</template>
</ElInput>
</ElFormItem>
@@ -44,9 +42,13 @@
<ElDescriptionsItem label="卡接入号">{{ cardDetail.msisdn || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="运营商">{{ cardDetail.carrier_name }}</ElDescriptionsItem>
<ElDescriptionsItem label="运营商类型">{{ getCarrierTypeText(cardDetail.carrier_type) }}</ElDescriptionsItem>
<ElDescriptionsItem label="运营商类型">{{
getCarrierTypeText(cardDetail.carrier_type)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="卡类型">{{ cardDetail.card_type }}</ElDescriptionsItem>
<ElDescriptionsItem label="卡业务类型">{{ getCardCategoryText(cardDetail.card_category) }}</ElDescriptionsItem>
<ElDescriptionsItem label="卡业务类型">{{
getCardCategoryText(cardDetail.card_category)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="状态">
<ElTag :type="getStatusTagType(cardDetail.status)">
@@ -73,11 +75,19 @@
<ElDescriptionsItem label="供应商">{{ cardDetail.supplier || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="店铺名称">{{ cardDetail.shop_name || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="成本价">{{ formatPrice(cardDetail.cost_price) }}</ElDescriptionsItem>
<ElDescriptionsItem label="分销价">{{ formatPrice(cardDetail.distribute_price) }}</ElDescriptionsItem>
<ElDescriptionsItem label="成本价">{{
formatPrice(cardDetail.cost_price)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="分销价">{{
formatPrice(cardDetail.distribute_price)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="累计流量使用">{{ cardDetail.data_usage_mb }} MB</ElDescriptionsItem>
<ElDescriptionsItem label="激活时间">{{ cardDetail.activated_at || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="累计流量使用"
>{{ cardDetail.data_usage_mb }} MB</ElDescriptionsItem
>
<ElDescriptionsItem label="激活时间">{{
cardDetail.activated_at || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">{{ cardDetail.created_at }}</ElDescriptionsItem>
</ElDescriptions>
</ElCard>

View File

@@ -5,6 +5,7 @@
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
:show-expand="false"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
@@ -15,7 +16,13 @@
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
/>
>
<template #left>
<ElButton type="primary" :icon="Upload" @click="importDialogVisible = true">
批量导入IoT卡
</ElButton>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
@@ -36,25 +43,152 @@
</ArtTable>
</ElCard>
</div>
<!-- 导入对话框 -->
<ElDialog v-model="importDialogVisible" title="批量导入IoT卡" width="700px" align-center>
<ElAlert type="info" :closable="false" style="margin-bottom: 20px">
<template #title>
<div style="line-height: 1.8">
<p><strong>导入说明</strong></p>
<p>1. 请先下载 CSV 模板文件按照模板格式填写IoT卡信息</p>
<p>2. 支持 CSV 格式.csv单次最多导入 1000 </p>
<p>3. CSV 文件编码UTF-8推荐 GBK</p>
<p>4. 必填字段iccidICCIDmsisdnMSISDN/手机号</p>
<p>5. 必须选择运营商</p>
</div>
</template>
</ElAlert>
<div style="margin-bottom: 20px">
<ElButton type="primary" :icon="Download" @click="downloadTemplate">
下载导入模板
</ElButton>
</div>
<ElFormItem label="运营商" required style="margin-bottom: 20px">
<ElSelect v-model="selectedCarrierId" placeholder="请选择运营商" style="width: 100%">
<ElOption label="中国移动" :value="1" />
<ElOption label="中国联通" :value="2" />
<ElOption label="中国电信" :value="3" />
</ElSelect>
</ElFormItem>
<ElUpload
ref="uploadRef"
drag
:auto-upload="false"
:on-change="handleFileChange"
:limit="1"
accept=".csv"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text"> CSV 文件拖到此处<em>点击选择</em></div>
<template #tip>
<div class="el-upload__tip">只能上传 CSV 文件且不超过 10MB</div>
</template>
</ElUpload>
<template #footer>
<ElButton @click="handleCancelImport">取消</ElButton>
<ElButton
type="primary"
:loading="uploading"
:disabled="!fileList.length || !selectedCarrierId"
@click="submitUpload"
>
开始导入
</ElButton>
</template>
</ElDialog>
<!-- 任务详情对话框 -->
<ElDialog v-model="detailDialogVisible" title="IoT卡导入任务详情" width="900px" align-center>
<ElDescriptions :column="2" border>
<ElDescriptionsItem label="任务编号" :span="2">{{
currentDetail.task_no
}}</ElDescriptionsItem>
<ElDescriptionsItem label="状态">
<ElTag :type="getStatusType(currentDetail.status)">{{ currentDetail.status_text }}</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="运营商">{{ currentDetail.carrier_name }}</ElDescriptionsItem>
<ElDescriptionsItem label="文件名" :span="2">{{
currentDetail.file_name
}}</ElDescriptionsItem>
<ElDescriptionsItem label="总数">{{ currentDetail.total_count }}</ElDescriptionsItem>
<ElDescriptionsItem label="成功数">
<span style="color: var(--el-color-success)">{{ currentDetail.success_count }}</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="跳过数">{{ currentDetail.skip_count }}</ElDescriptionsItem>
<ElDescriptionsItem label="失败数">
<span style="color: var(--el-color-danger)">{{ currentDetail.fail_count }}</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="警告数">
<span style="color: var(--el-color-warning)">{{ currentDetail.warning_count }}</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="开始时间">{{ currentDetail.started_at }}</ElDescriptionsItem>
<ElDescriptionsItem label="完成时间">{{ currentDetail.completed_at }}</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">{{ currentDetail.created_at }}</ElDescriptionsItem>
<ElDescriptionsItem label="错误信息" :span="2">{{
currentDetail.error_message
}}</ElDescriptionsItem>
</ElDescriptions>
<ElDivider content-position="left">失败明细</ElDivider>
<div
v-if="currentDetail.failed_items && currentDetail.failed_items.length"
style="max-height: 300px; overflow-y: auto"
>
<ElTable :data="currentDetail.failed_items" border size="small">
<ElTableColumn label="行号" type="index" width="80" :index="(index) => index + 1" />
<ElTableColumn label="ICCID" prop="iccid" width="200" />
<ElTableColumn label="MSISDN" prop="msisdn" width="150" />
<ElTableColumn label="失败原因" prop="reason" min-width="200">
<template #default="{ row }">
{{ row.reason || row.error || '未知错误' }}
</template>
</ElTableColumn>
</ElTable>
</div>
<ElEmpty v-else description="无失败记录" />
<template #footer>
<ElButton @click="detailDialogVisible = false">关闭</ElButton>
<ElButton
v-if="currentDetail.fail_count > 0"
type="primary"
:icon="Download"
@click="downloadFailData"
>
下载失败数据
</ElButton>
</template>
</ElDialog>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { useRouter } from 'vue-router'
import { CardService } from '@/api/modules'
import { ElMessage, ElTag } from 'element-plus'
import { ElMessage, ElTag, ElFormItem, ElSelect, ElOption } from 'element-plus'
import { Download, UploadFilled, Upload } from '@element-plus/icons-vue'
import type { UploadInstance } from 'element-plus'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { formatDateTime } from '@/utils/business/format'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { StorageService } from '@/api/modules/storage'
import type { IotCardImportTask, IotCardImportTaskStatus } from '@/types/api/card'
defineOptions({ name: 'IotCardTask' })
const router = useRouter()
const loading = ref(false)
const tableRef = ref()
const uploadRef = ref<UploadInstance>()
const fileList = ref<File[]>([])
const uploading = ref(false)
const importDialogVisible = ref(false)
const detailDialogVisible = ref(false)
const selectedCarrierId = ref<number>()
// 搜索表单初始值
const initialSearchState = {
@@ -145,6 +279,7 @@
]
const taskList = ref<IotCardImportTask[]>([])
const currentDetail = ref<any>({})
// 获取状态标签类型
const getStatusType = (status: IotCardImportTaskStatus) => {
@@ -163,14 +298,24 @@
}
// 查看详情
const viewDetail = (row: IotCardImportTask) => {
router.push({
path: '/asset-management/task-detail',
query: {
id: row.id,
task_type: 'card'
const viewDetail = async (row: IotCardImportTask) => {
try {
const res = await CardService.getIotCardImportTaskDetail(row.id)
if (res.code === 0 && res.data) {
currentDetail.value = {
...res.data,
started_at: res.data.started_at ? formatDateTime(res.data.started_at) : '-',
completed_at: res.data.completed_at ? formatDateTime(res.data.completed_at) : '-',
created_at: res.data.created_at ? formatDateTime(res.data.created_at) : '-',
carrier_name: res.data.carrier_name || '-',
error_message: res.data.error_message || '-'
}
detailDialogVisible.value = true
}
})
} catch (error) {
console.error('获取任务详情失败:', error)
ElMessage.error('获取任务详情失败')
}
}
// 动态列配置
@@ -196,7 +341,8 @@
{
prop: 'file_name',
label: '文件名',
minWidth: 250
minWidth: 250,
showOverflowTooltip: true
},
{
prop: 'total_count',
@@ -206,15 +352,17 @@
{
prop: 'success_count',
label: '成功数',
width: 80
width: 80,
formatter: (row: IotCardImportTask) => {
return h('span', { style: { color: 'var(--el-color-success)' } }, row.success_count)
}
},
{
prop: 'fail_count',
label: '失败数',
width: 80,
formatter: (row: IotCardImportTask) => {
const type = row.fail_count > 0 ? 'danger' : 'success'
return h(ElTag, { type, size: 'small' }, () => row.fail_count)
return h('span', { style: { color: 'var(--el-color-danger)' } }, row.fail_count)
}
},
{
@@ -225,37 +373,57 @@
{
prop: 'started_at',
label: '开始时间',
width: 160,
width: 180,
formatter: (row: IotCardImportTask) => (row.started_at ? formatDateTime(row.started_at) : '-')
},
{
prop: 'completed_at',
label: '完成时间',
width: 160,
formatter: (row: IotCardImportTask) => (row.completed_at ? formatDateTime(row.completed_at) : '-')
width: 180,
formatter: (row: IotCardImportTask) =>
row.completed_at ? formatDateTime(row.completed_at) : '-'
},
{
prop: 'error_message',
label: '错误信息',
minWidth: 200,
showOverflowTooltip: true,
formatter: (row: IotCardImportTask) => row.error_message || '-'
},
{
prop: 'created_at',
label: '创建时间',
width: 160,
width: 180,
formatter: (row: IotCardImportTask) => formatDateTime(row.created_at)
},
{
prop: 'operation',
label: '操作',
width: 120,
width: 180,
fixed: 'right',
formatter: (row: IotCardImportTask) => {
return h(ArtButtonTable, {
text: '查看详情',
onClick: () => viewDetail(row)
})
const buttons = []
// 显示"查看详情"按钮
buttons.push(
h(ArtButtonTable, {
text: '详情',
onClick: () => viewDetail(row)
})
)
// 如果有失败数据,显示"失败数据"按钮
if (row.fail_count > 0) {
buttons.push(
h(ArtButtonTable, {
text: '失败数据',
type: 'danger',
onClick: () => downloadFailDataByRow(row)
})
)
}
return h('div', { style: 'display: flex; gap: 8px;' }, buttons)
}
}
])
@@ -330,10 +498,202 @@
pagination.page = newCurrentPage
getTableData()
}
// 从行数据下载失败数据
const downloadFailDataByRow = async (row: IotCardImportTask) => {
try {
const res = await CardService.getIotCardImportTaskDetail(row.id)
if (res.code === 0 && res.data) {
const detail = res.data
downloadFailDataFromDetail(detail, row.task_no)
}
} catch (error) {
console.error('下载失败数据失败:', error)
ElMessage.error('下载失败数据失败')
}
}
// 下载失败数据(从详情对话框)
const downloadFailData = () => {
downloadFailDataFromDetail(currentDetail.value, currentDetail.value.task_no)
}
// 下载失败数据的通用方法
const downloadFailDataFromDetail = (detail: any, taskNo: string) => {
const failReasons =
detail.failed_items?.map((item: any, index: number) => ({
row: index + 1,
iccid: item.iccid || '-',
msisdn: item.msisdn || '-',
message: item.reason || item.error || '未知错误'
})) || []
if (failReasons.length === 0) {
ElMessage.warning('没有失败数据可下载')
return
}
const headers = ['行号', 'ICCID', 'MSISDN', '失败原因']
const csvRows = [
headers.join(','),
...failReasons.map((item: any) =>
[item.row, item.iccid, item.msisdn, `"${item.message}"`].join(',')
)
]
const csvContent = csvRows.join('\n')
const BOM = '\uFEFF'
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', `IoT卡导入失败数据_${taskNo}.csv`)
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
ElMessage.success('失败数据下载成功')
}
// 下载模板
const downloadTemplate = () => {
const csvContent = [
'iccid,msisdn',
'89860123456789012345,13800138000',
'89860123456789012346,13800138001',
'89860123456789012347,13800138002'
].join('\n')
const BOM = '\uFEFF'
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', 'IoT卡导入模板.csv')
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
ElMessage.success('IoT卡导入模板下载成功')
}
// 文件选择变化
const handleFileChange = (uploadFile: any) => {
const maxSize = 10 * 1024 * 1024
if (uploadFile.raw && uploadFile.raw.size > maxSize) {
ElMessage.error('文件大小不能超过 10MB')
uploadRef.value?.clearFiles()
fileList.value = []
return
}
if (uploadFile.raw && !uploadFile.raw.name.endsWith('.csv')) {
ElMessage.error('只能上传 CSV 文件')
uploadRef.value?.clearFiles()
fileList.value = []
return
}
fileList.value = uploadFile.raw ? [uploadFile.raw] : []
}
// 清空文件
const clearFiles = () => {
uploadRef.value?.clearFiles()
fileList.value = []
selectedCarrierId.value = undefined
}
// 取消导入
const handleCancelImport = () => {
clearFiles()
importDialogVisible.value = false
}
// 提交上传
const submitUpload = async () => {
if (!selectedCarrierId.value) {
ElMessage.warning('请先选择运营商')
return
}
if (!fileList.value.length) {
ElMessage.warning('请先选择CSV文件')
return
}
const file = fileList.value[0]
uploading.value = true
try {
ElMessage.info('正在准备上传...')
const uploadUrlRes = await StorageService.getUploadUrl({
file_name: file.name,
content_type: 'text/csv',
purpose: 'iot_import'
})
if (uploadUrlRes.code !== 0) {
throw new Error(uploadUrlRes.msg || '获取上传地址失败')
}
const { upload_url, file_key } = uploadUrlRes.data
ElMessage.info('正在上传文件...')
await StorageService.uploadFile(upload_url, file, 'text/csv')
ElMessage.info('正在创建导入任务...')
const importRes = await CardService.importIotCards({
carrier_id: selectedCarrierId.value,
file_key,
batch_no: `IOT-${Date.now()}`
})
if (importRes.code !== 0) {
throw new Error(importRes.msg || '创建导入任务失败')
}
const taskNo = importRes.data.task_no
handleCancelImport()
getTableData()
ElMessage.success({
message: `导入任务已创建!任务编号:${taskNo}`,
duration: 3000,
showClose: true
})
} catch (error: any) {
console.error('IoT卡导入失败:', error)
ElMessage.error(error.message || 'IoT卡导入失败')
} finally {
uploading.value = false
}
}
</script>
<style lang="scss" scoped>
.iot-card-task-page {
// IoT card task page styles
:deep(.el-icon--upload) {
margin-bottom: 16px;
font-size: 67px;
color: var(--el-text-color-placeholder);
}
:deep(.el-upload__text) {
font-size: 14px;
color: var(--el-text-color-regular);
em {
font-style: normal;
color: var(--el-color-primary);
}
}
}
</style>

View File

@@ -62,18 +62,8 @@
</ElDivider>
<ElTable :data="taskDetail.failed_items" border style="width: 100%">
<ElTableColumn prop="line" label="行号" width="100" />
<ElTableColumn
v-if="taskType === 'card'"
prop="iccid"
label="ICCID"
min-width="180"
/>
<ElTableColumn
v-else
prop="device_no"
label="设备号"
min-width="180"
/>
<ElTableColumn v-if="taskType === 'card'" prop="iccid" label="ICCID" min-width="180" />
<ElTableColumn v-else prop="device_no" label="设备号" min-width="180" />
<ElTableColumn prop="reason" label="失败原因" min-width="300" />
</ElTable>
</div>
@@ -85,18 +75,8 @@
</ElDivider>
<ElTable :data="taskDetail.skipped_items" border style="width: 100%">
<ElTableColumn prop="line" label="行号" width="100" />
<ElTableColumn
v-if="taskType === 'card'"
prop="iccid"
label="ICCID"
min-width="180"
/>
<ElTableColumn
v-else
prop="device_no"
label="设备号"
min-width="180"
/>
<ElTableColumn v-if="taskType === 'card'" prop="iccid" label="ICCID" min-width="180" />
<ElTableColumn v-else prop="device_no" label="设备号" min-width="180" />
<ElTableColumn prop="reason" label="跳过原因" min-width="300" />
</ElTable>
</div>
@@ -108,7 +88,15 @@
<script setup lang="ts">
import { useRouter, useRoute } from 'vue-router'
import { CardService, DeviceService } from '@/api/modules'
import { ElMessage, ElTag, ElDescriptions, ElDescriptionsItem, ElDivider, ElTable, ElTableColumn } from 'element-plus'
import {
ElMessage,
ElTag,
ElDescriptions,
ElDescriptionsItem,
ElDivider,
ElTable,
ElTableColumn
} from 'element-plus'
import { formatDateTime } from '@/utils/business/format'
import type { IotCardImportTaskDetail, IotCardImportTaskStatus } from '@/types/api/card'
import type { DeviceImportTaskDetail } from '@/types/api/device'

View File

@@ -15,8 +15,14 @@
<p>1. 请先下载 CSV 模板文件按照模板格式填写设备信息</p>
<p>2. 支持 CSV 格式.csv单次最多导入 1000 </p>
<p>3. CSV 文件编码UTF-8推荐 GBK</p>
<p>4. 必填字段device_no设备号device_name设备名称device_model设备型号</p>
<p>5. 可选字段device_type设备类型manufacturer制造商max_sim_slots最大插槽数默认1</p>
<p
>4.
必填字段device_no设备号device_name设备名称device_model设备型号</p
>
<p
>5.
可选字段device_type设备类型manufacturer制造商max_sim_slots最大插槽数默认1</p
>
<p>6. 设备号重复将自动跳过导入后可在任务管理中查看详情</p>
</div>
</template>
@@ -59,52 +65,6 @@
</ElRow>
</ElCard>
<!-- 导入统计 -->
<ElRow :gutter="20" style="margin-top: 20px">
<ElCol :xs="24" :sm="12" :lg="6">
<ElCard shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-label">今日导入</div>
<div class="stat-value">1,250</div>
</div>
<el-icon class="stat-icon" style="color: var(--el-color-primary)"><Upload /></el-icon>
</ElCard>
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6">
<ElCard shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-label">成功绑定</div>
<div class="stat-value" style="color: var(--el-color-success)">1,180</div>
</div>
<el-icon class="stat-icon" style="color: var(--el-color-success)"
><SuccessFilled
/></el-icon>
</ElCard>
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6">
<ElCard shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-label">导入失败</div>
<div class="stat-value" style="color: var(--el-color-danger)">70</div>
</div>
<el-icon class="stat-icon" style="color: var(--el-color-danger)"
><CircleCloseFilled
/></el-icon>
</ElCard>
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6">
<ElCard shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-label">成功率</div>
<div class="stat-value">94.4%</div>
</div>
<el-icon class="stat-icon" style="color: var(--el-color-warning)"
><TrendCharts
/></el-icon>
</ElCard>
</ElCol>
</ElRow>
<!-- 导入记录 -->
<ElCard shadow="never" style="margin-top: 20px">
<template #header>
@@ -117,71 +77,27 @@
style="width: 120px; margin-right: 12px"
clearable
>
<ElOption label="全部" value="" />
<ElOption label="处理" value="processing" />
<ElOption label="完成" value="success" />
<ElOption label="失败" value="failed" />
<ElOption label="全部" :value="null" />
<ElOption label="处理" :value="1" />
<ElOption label="处理中" :value="2" />
<ElOption label="已完成" :value="3" />
<ElOption label="失败" :value="4" />
</ElSelect>
<ElButton @click="refreshList">刷新</ElButton>
</div>
</div>
</template>
<ArtTable :data="filteredRecords" index>
<ArtTable
rowKey="id"
ref="tableRef"
:loading="loading"
:data="filteredRecords"
:marginTop="10"
:stripe="false"
>
<template #default>
<ElTableColumn label="导入批次号" prop="batchNo" width="180" />
<ElTableColumn label="文件名" prop="fileName" min-width="200" show-overflow-tooltip />
<ElTableColumn label="设备总数" prop="totalCount" width="100" />
<ElTableColumn label="成功数" prop="successCount" width="100">
<template #default="scope">
<span style="color: var(--el-color-success)">{{ scope.row.successCount }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="失败数" prop="failCount" width="100">
<template #default="scope">
<span style="color: var(--el-color-danger)">{{ scope.row.failCount }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="已绑定ICCID" prop="bindCount" width="120">
<template #default="scope">
<ElTag type="success" size="small">{{ scope.row.bindCount }}</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="导入状态" prop="status" width="120">
<template #default="scope">
<ElTag v-if="scope.row.status === 'processing'" type="warning">
<el-icon class="is-loading"><Loading /></el-icon>
处理中
</ElTag>
<ElTag v-else-if="scope.row.status === 'success'" type="success">完成</ElTag>
<ElTag v-else-if="scope.row.status === 'failed'" type="danger">失败</ElTag>
<ElTag v-else type="info">待处理</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="导入进度" prop="progress" width="150">
<template #default="scope">
<ElProgress
:percentage="scope.row.progress"
:status="scope.row.status === 'failed' ? 'exception' : undefined"
/>
</template>
</ElTableColumn>
<ElTableColumn label="导入时间" prop="importTime" width="180" />
<ElTableColumn label="操作人" prop="operator" width="120" />
<ElTableColumn fixed="right" label="操作" width="200">
<template #default="scope">
<el-button link :icon="View" @click="viewDetail(scope.row)">查看详情</el-button>
<el-button
v-if="scope.row.failCount > 0"
link
type="primary"
:icon="Download"
@click="downloadFailData(scope.row)"
>
失败数据
</el-button>
</template>
</ElTableColumn>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</ElCard>
@@ -189,20 +105,31 @@
<!-- 导入详情对话框 -->
<ElDialog v-model="detailDialogVisible" title="设备导入详情" width="900px" align-center>
<ElDescriptions :column="2" border>
<ElDescriptionsItem label="任务编号" :span="2">{{
currentDetail.taskNo
}}</ElDescriptionsItem>
<ElDescriptionsItem label="状态">{{ currentDetail.statusText }}</ElDescriptionsItem>
<ElDescriptionsItem label="批次号">{{ currentDetail.batchNo }}</ElDescriptionsItem>
<ElDescriptionsItem label="文件名">{{ currentDetail.fileName }}</ElDescriptionsItem>
<ElDescriptionsItem label="设备总数">{{ currentDetail.totalCount }}</ElDescriptionsItem>
<ElDescriptionsItem label="成功导入">
<ElDescriptionsItem label="文件名" :span="2">{{
currentDetail.fileName
}}</ElDescriptionsItem>
<ElDescriptionsItem label="总数">{{ currentDetail.totalCount }}</ElDescriptionsItem>
<ElDescriptionsItem label="成功数">
<span style="color: var(--el-color-success)">{{ currentDetail.successCount }}</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="导入失败">
<ElDescriptionsItem label="跳过数">{{ currentDetail.skipCount }}</ElDescriptionsItem>
<ElDescriptionsItem label="失败数">
<span style="color: var(--el-color-danger)">{{ currentDetail.failCount }}</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="已绑定ICCID">
<ElTag type="success">{{ currentDetail.bindCount }}</ElTag>
<ElDescriptionsItem label="警告数">
<span style="color: var(--el-color-warning)">{{ currentDetail.warningCount }}</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="导入时间">{{ currentDetail.importTime }}</ElDescriptionsItem>
<ElDescriptionsItem label="操作人">{{ currentDetail.operator }}</ElDescriptionsItem>
<ElDescriptionsItem label="开始时间">{{ currentDetail.startedAt }}</ElDescriptionsItem>
<ElDescriptionsItem label="完成时间">{{ currentDetail.completedAt }}</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">{{ currentDetail.createdAt }}</ElDescriptionsItem>
<ElDescriptionsItem label="错误信息" :span="2">{{
currentDetail.errorMessage
}}</ElDescriptionsItem>
</ElDescriptions>
<ElDivider content-position="left">失败明细</ElDivider>
@@ -235,22 +162,16 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ElMessage } from 'element-plus'
import { h, computed, watch } from 'vue'
import { ElMessage, ElTag, ElProgress, ElIcon, ElButton } from 'element-plus'
import { useRouter } from 'vue-router'
import {
Download,
UploadFilled,
View,
Loading,
Upload,
SuccessFilled,
CircleCloseFilled,
TrendCharts
} from '@element-plus/icons-vue'
import { Download, UploadFilled, View, Loading } from '@element-plus/icons-vue'
import type { UploadInstance } from 'element-plus'
import { StorageService } from '@/api/modules/storage'
import { DeviceService } from '@/api/modules'
import { formatDateTime } from '@/utils/business/format'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
defineOptions({ name: 'DeviceImport' })
@@ -265,107 +186,213 @@
interface ImportRecord {
id: string
taskNo: string
status: number
statusText: string
batchNo: string
fileName: string
totalCount: number
successCount: number
skipCount: number
failCount: number
bindCount: number
status: 'pending' | 'processing' | 'success' | 'failed'
progress: number
importTime: string
operator: string
warningCount: number
startedAt: string
completedAt: string
errorMessage: string
createdAt: string
failReasons?: FailReason[]
}
const uploadRef = ref<UploadInstance>()
const tableRef = ref()
const fileList = ref<File[]>([])
const uploading = ref(false)
const detailDialogVisible = ref(false)
const statusFilter = ref('')
const statusFilter = ref<number | null>(null)
const loading = ref(false)
const importRecords = ref<ImportRecord[]>([
{
id: '1',
batchNo: 'DEV20260109001',
fileName: '设备导入模板_20260109.xlsx',
totalCount: 300,
successCount: 285,
failCount: 15,
bindCount: 285,
status: 'success',
progress: 100,
importTime: '2026-01-09 11:30:00',
operator: 'admin',
failReasons: [
{ row: 12, deviceCode: 'DEV001', iccid: '89860123456789012345', message: 'ICCID 不存在' },
{ row: 23, deviceCode: 'DEV002', iccid: '89860123456789012346', message: '设备编号已存在' },
{ row: 45, deviceCode: '', iccid: '89860123456789012347', message: '设备编号为空' },
{ row: 67, deviceCode: 'DEV003', iccid: '', message: 'ICCID 为空' },
{ row: 89, deviceCode: 'DEV004', iccid: '89860123456789012348', message: '设备类型不存在' }
]
},
{
id: '2',
batchNo: 'DEV20260108001',
fileName: '智能水表设备批量导入.xlsx',
totalCount: 150,
successCount: 150,
failCount: 0,
bindCount: 150,
status: 'success',
progress: 100,
importTime: '2026-01-08 14:20:00',
operator: 'admin'
},
{
id: '3',
batchNo: 'DEV20260107001',
fileName: 'GPS定位器导入.xlsx',
totalCount: 200,
successCount: 180,
failCount: 20,
bindCount: 180,
status: 'success',
progress: 100,
importTime: '2026-01-07 10:15:00',
operator: 'operator01',
failReasons: [
{
row: 10,
deviceCode: 'GPS001',
iccid: '89860123456789012349',
message: 'ICCID 已被其他设备绑定'
},
{ row: 20, deviceCode: 'GPS002', iccid: '89860123456789012350', message: 'ICCID 状态异常' }
]
}
])
const importRecords = ref<ImportRecord[]>([])
const currentDetail = ref<ImportRecord>({
id: '',
taskNo: '',
status: 1,
statusText: '',
batchNo: '',
fileName: '',
totalCount: 0,
successCount: 0,
skipCount: 0,
failCount: 0,
bindCount: 0,
status: 'pending',
progress: 0,
importTime: '',
operator: ''
warningCount: 0,
startedAt: '',
completedAt: '',
errorMessage: '',
createdAt: ''
})
const filteredRecords = computed(() => {
if (!statusFilter.value) return importRecords.value
if (statusFilter.value === null) return importRecords.value
return importRecords.value.filter((item) => item.status === statusFilter.value)
})
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'taskNo',
label: '任务编号',
width: 200,
showOverflowTooltip: true
},
{
prop: 'status',
label: '状态',
width: 120,
formatter: (row: ImportRecord) => {
if (row.status === 1) {
return h(ElTag, { type: 'info' }, () => '待处理')
} else if (row.status === 2) {
return h(
ElTag,
{ type: 'warning' },
{
default: () => [
h(ElIcon, { class: 'is-loading' }, () => h(Loading)),
h('span', { style: { marginLeft: '4px' } }, '处理中')
]
}
)
} else if (row.status === 3) {
return h(ElTag, { type: 'success' }, () => '已完成')
} else if (row.status === 4) {
return h(ElTag, { type: 'danger' }, () => '失败')
} else {
return h(ElTag, { type: 'info' }, () => row.statusText || '-')
}
}
},
{
prop: 'batchNo',
label: '批次号',
width: 180,
showOverflowTooltip: true
},
{
prop: 'fileName',
label: '文件名',
minWidth: 200,
showOverflowTooltip: true
},
{
prop: 'totalCount',
label: '总数',
width: 100
},
{
prop: 'successCount',
label: '成功数',
width: 100,
formatter: (row: ImportRecord) => {
return h('span', { style: { color: 'var(--el-color-success)' } }, row.successCount)
}
},
{
prop: 'skipCount',
label: '跳过数',
width: 100
},
{
prop: 'failCount',
label: '失败数',
width: 100,
formatter: (row: ImportRecord) => {
return h('span', { style: { color: 'var(--el-color-danger)' } }, row.failCount)
}
},
{
prop: 'warningCount',
label: '警告数',
width: 100,
formatter: (row: ImportRecord) => {
return h('span', { style: { color: 'var(--el-color-warning)' } }, row.warningCount)
}
},
{
prop: 'startedAt',
label: '开始时间',
width: 180
},
{
prop: 'completedAt',
label: '完成时间',
width: 180
},
{
prop: 'errorMessage',
label: '错误信息',
minWidth: 200,
showOverflowTooltip: true
},
{
prop: 'createdAt',
label: '创建时间',
width: 180
},
{
prop: 'operation',
label: '操作',
width: 180,
fixed: 'right',
formatter: (row: ImportRecord) => {
const buttons = []
// 显示"查看详情"按钮
buttons.push(
h(ArtButtonTable, {
text: '详情',
onClick: () => viewDetail(row)
})
),
buttons.push(
h(ArtButtonTable, {
text: '失败数据',
type: 'danger',
onClick: () => downloadFailData(row)
})
)
return h('div', { style: 'display: flex; gap: 8px;' }, buttons)
}
}
])
const downloadTemplate = () => {
ElMessage.success('模板下载中...')
setTimeout(() => {
ElMessage.success('设备导入模板下载成功')
}, 1000)
// CSV模板内容 - 包含表头和示例数据
const csvContent = [
// 表头
'device_no,device_name,device_model,device_type,manufacturer,max_sim_slots',
// 示例数据
'DEV001,智能水表01,WM-2000,智能水表,华为,1',
'DEV002,GPS定位器01,GPS-3000,定位设备,小米,2',
'DEV003,智能燃气表01,GM-1500,智能燃气表,海尔,1'
].join('\n')
// 添加 BOM 头确保 Excel 正确识别 UTF-8 编码
const BOM = '\uFEFF'
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' })
// 创建下载链接
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', '设备导入模板.csv')
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
ElMessage.success('设备导入模板下载成功')
}
const handleFileChange = (uploadFile: any) => {
@@ -444,17 +471,15 @@
// 清空文件列表
clearFiles()
// 显示成功消息并提供跳转链接
// 刷新任务列表
await fetchImportTasks()
// 显示成功消息
ElMessage.success({
message: `导入任务已创建!任务编号:${taskNo}`,
duration: 5000,
duration: 3000,
showClose: true
})
// 3秒后跳转到任务管理页面
setTimeout(() => {
router.push('/asset-management/task-management')
}, 3000)
} catch (error: any) {
console.error('设备导入失败:', error)
ElMessage.error(error.message || '设备导入失败')
@@ -463,21 +488,133 @@
}
}
const refreshList = () => {
ElMessage.success('刷新成功')
// 获取导入任务列表
const fetchImportTasks = async () => {
loading.value = true
try {
const params: any = {
page: 1,
page_size: 100
}
// 如果有状态筛选,添加到参数
if (statusFilter.value !== null) {
params.status = statusFilter.value
}
const res = await DeviceService.getImportTasks(params)
if (res.code === 0 && res.data) {
// 将API返回的数据映射到本地格式
importRecords.value = res.data.items.map((item: any) => ({
id: item.id.toString(),
taskNo: item.task_no || '-',
status: item.status,
statusText: item.status_text || '-',
batchNo: item.batch_no || '-',
fileName: item.file_name || '-',
totalCount: item.total_count || 0,
successCount: item.success_count || 0,
skipCount: item.skip_count || 0,
failCount: item.fail_count || 0,
warningCount: item.warning_count || 0,
startedAt: item.started_at ? formatDateTime(item.started_at) : '-',
completedAt: item.completed_at ? formatDateTime(item.completed_at) : '-',
errorMessage: item.error_message || '-',
createdAt: item.created_at ? formatDateTime(item.created_at) : '-'
}))
}
} catch (error) {
console.error('获取导入任务列表失败:', error)
ElMessage.error('获取导入任务列表失败')
} finally {
loading.value = false
}
}
const viewDetail = (row: ImportRecord) => {
currentDetail.value = { ...row }
detailDialogVisible.value = true
const refreshList = () => {
fetchImportTasks()
}
const viewDetail = async (row: ImportRecord) => {
try {
const res = await DeviceService.getImportTaskDetail(Number(row.id))
if (res.code === 0 && res.data) {
const detail = res.data
currentDetail.value = {
id: detail.id.toString(),
taskNo: detail.task_no || '-',
status: detail.status,
statusText: detail.status_text || '-',
batchNo: detail.batch_no || '-',
fileName: detail.file_name || '-',
totalCount: detail.total_count || 0,
successCount: detail.success_count || 0,
skipCount: detail.skip_count || 0,
failCount: detail.fail_count || 0,
warningCount: detail.warning_count || 0,
startedAt: detail.started_at ? formatDateTime(detail.started_at) : '-',
completedAt: detail.completed_at ? formatDateTime(detail.completed_at) : '-',
errorMessage: detail.error_message || '-',
createdAt: detail.created_at ? formatDateTime(detail.created_at) : '-',
failReasons:
detail.failed_items?.map((item: any, index: number) => ({
row: index + 1,
deviceCode: item.device_no || '-',
iccid: item.iccid || '-',
message: item.reason || item.error || '未知错误'
})) || []
}
detailDialogVisible.value = true
}
} catch (error) {
console.error('获取任务详情失败:', error)
ElMessage.error('获取任务详情失败')
}
}
const downloadFailData = (row: ImportRecord) => {
ElMessage.info(`正在下载批次 ${row.batchNo} 的失败数据...`)
setTimeout(() => {
ElMessage.success('失败数据下载完成')
}, 1000)
if (!row.failReasons || row.failReasons.length === 0) {
ElMessage.warning('没有失败数据可下载')
return
}
// 生成失败数据CSV
const headers = ['行号', '设备编号', 'ICCID', '失败原因']
const csvRows = [
headers.join(','),
...row.failReasons.map((item) =>
[item.row, item.deviceCode, item.iccid, `"${item.message}"`].join(',')
)
]
const csvContent = csvRows.join('\n')
// 添加 BOM 头确保 Excel 正确识别 UTF-8 编码
const BOM = '\uFEFF'
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' })
// 创建下载链接
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', `导入失败数据_${row.batchNo}.csv`)
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
ElMessage.success('失败数据下载成功')
}
// 页面加载时获取任务列表
onMounted(() => {
fetchImportTasks()
})
// 监听状态筛选变化
watch(statusFilter, () => {
fetchImportTasks()
})
</script>
<style lang="scss" scoped>

View File

@@ -125,7 +125,12 @@
</ElTableColumn>
<ElTableColumn label="订单号" prop="order_no" min-width="180" show-overflow-tooltip />
<ElTableColumn label="ICCID" prop="iccid" min-width="150" show-overflow-tooltip />
<ElTableColumn label="设备号" prop="device_no" min-width="150" show-overflow-tooltip />
<ElTableColumn
label="设备号"
prop="device_no"
min-width="150"
show-overflow-tooltip
/>
<ElTableColumn label="入账时间" prop="created_at" width="180">
<template #default="scope">
{{ formatDateTime(scope.row.created_at) }}

View File

@@ -54,10 +54,7 @@
:placeholder="t('orderManagement.searchForm.orderTypePlaceholder')"
style="width: 100%"
>
<ElOption
:label="t('orderManagement.orderType.singleCard')"
value="single_card"
/>
<ElOption :label="t('orderManagement.orderType.singleCard')" value="single_card" />
<ElOption :label="t('orderManagement.orderType.device')" value="device" />
</ElSelect>
</ElFormItem>
@@ -154,7 +151,10 @@
</ElDescriptions>
<!-- 订单项列表 -->
<div v-if="currentOrder.items && currentOrder.items.length > 0" style="margin-top: 20px">
<div
v-if="currentOrder.items && currentOrder.items.length > 0"
style="margin-top: 20px"
>
<h4>{{ t('orderManagement.orderItems') }}</h4>
<ElTable :data="currentOrder.items" border style="margin-top: 10px">
<ElTableColumn
@@ -176,11 +176,7 @@
{{ formatCurrency(row.unit_price) }}
</template>
</ElTableColumn>
<ElTableColumn
prop="amount"
:label="t('orderManagement.items.amount')"
width="120"
>
<ElTableColumn prop="amount" :label="t('orderManagement.items.amount')" width="120">
<template #default="{ row }">
{{ formatCurrency(row.amount) }}
</template>
@@ -415,10 +411,8 @@
label: t('orderManagement.table.orderType'),
width: 120,
formatter: (row: Order) => {
return h(
ElTag,
{ type: row.order_type === 'single_card' ? 'primary' : 'success' },
() => getOrderTypeText(row.order_type)
return h(ElTag, { type: row.order_type === 'single_card' ? 'primary' : 'success' }, () =>
getOrderTypeText(row.order_type)
)
}
},
@@ -427,10 +421,8 @@
label: t('orderManagement.table.buyerType'),
width: 120,
formatter: (row: Order) => {
return h(
ElTag,
{ type: row.buyer_type === 'personal' ? 'info' : 'warning' },
() => getBuyerTypeText(row.buyer_type)
return h(ElTag, { type: row.buyer_type === 'personal' ? 'info' : 'warning' }, () =>
getBuyerTypeText(row.buyer_type)
)
}
},
@@ -439,8 +431,10 @@
label: t('orderManagement.table.paymentStatus'),
width: 120,
formatter: (row: Order) => {
return h(ElTag, { type: getPaymentStatusType(row.payment_status) }, () =>
row.payment_status_text
return h(
ElTag,
{ type: getPaymentStatusType(row.payment_status) },
() => row.payment_status_text
)
}
},

View File

@@ -48,7 +48,12 @@
:close-on-click-modal="false"
@closed="handleCostPriceDialogClosed"
>
<ElForm ref="costPriceFormRef" :model="costPriceForm" :rules="costPriceRules" label-width="120px">
<ElForm
ref="costPriceFormRef"
:model="costPriceForm"
:rules="costPriceRules"
label-width="120px"
>
<ElFormItem label="套餐名称">
<ElInput v-model="costPriceForm.package_name" disabled />
</ElFormItem>
@@ -56,7 +61,12 @@
<ElInput v-model="costPriceForm.shop_name" disabled />
</ElFormItem>
<ElFormItem label="原成本价(分)">
<ElInputNumber v-model="costPriceForm.old_cost_price" disabled :controls="false" style="width: 100%" />
<ElInputNumber
v-model="costPriceForm.old_cost_price"
disabled
:controls="false"
style="width: 100%"
/>
</ElFormItem>
<ElFormItem label="新成本价(分)" prop="cost_price">
<ElInputNumber
@@ -71,7 +81,11 @@
<template #footer>
<div class="dialog-footer">
<ElButton @click="costPriceDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleCostPriceSubmit(costPriceFormRef)" :loading="costPriceSubmitLoading">
<ElButton
type="primary"
@click="handleCostPriceSubmit(costPriceFormRef)"
:loading="costPriceSubmitLoading"
>
提交
</ElButton>
</div>
@@ -154,11 +168,7 @@
import { ShopPackageAllocationService, PackageManageService, ShopService } from '@/api/modules'
import { ElMessage, ElMessageBox, ElSwitch } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import type {
ShopPackageAllocationResponse,
PackageResponse,
ShopResponse
} from '@/types/api'
import type { ShopPackageAllocationResponse, PackageResponse, ShopResponse } from '@/types/api'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
@@ -281,7 +291,9 @@
if (value === undefined || value === null || value === '') {
callback(new Error('请输入成本价'))
} else if (form.package_base_price && value < form.package_base_price) {
callback(new Error(`成本价不能低于套餐价格 ¥${(form.package_base_price / 100).toFixed(2)}`))
callback(
new Error(`成本价不能低于套餐价格 ¥${(form.package_base_price / 100).toFixed(2)}`)
)
} else {
callback()
}
@@ -355,7 +367,8 @@
prop: 'calculated_cost_price',
label: '原计算成本价',
width: 120,
formatter: (row: ShopPackageAllocationResponse) => `¥${(row.calculated_cost_price / 100).toFixed(2)}`
formatter: (row: ShopPackageAllocationResponse) =>
`¥${(row.calculated_cost_price / 100).toFixed(2)}`
},
{
prop: 'status',
@@ -624,7 +637,7 @@
const handlePackageChange = (packageId: number | undefined) => {
if (packageId) {
// 从套餐选项中找到选中的套餐
const selectedPackage = packageOptions.value.find(pkg => pkg.id === packageId)
const selectedPackage = packageOptions.value.find((pkg) => pkg.id === packageId)
if (selectedPackage) {
// 将套餐的价格设置为成本价
form.cost_price = selectedPackage.price

View File

@@ -94,11 +94,7 @@
</ElSelect>
</ElFormItem>
<ElFormItem label="流量类型" prop="data_type">
<ElSelect
v-model="form.data_type"
placeholder="请选择流量类型"
style="width: 100%"
>
<ElSelect v-model="form.data_type" placeholder="请选择流量类型" style="width: 100%">
<ElOption
v-for="option in DATA_TYPE_OPTIONS"
:key="option.value"
@@ -116,7 +112,11 @@
placeholder="请输入真流量额度"
/>
</ElFormItem>
<ElFormItem label="虚流量额度(MB)" prop="virtual_data_mb" v-if="form.data_type === 'virtual'">
<ElFormItem
label="虚流量额度(MB)"
prop="virtual_data_mb"
v-if="form.data_type === 'virtual'"
>
<ElInputNumber
v-model="form.virtual_data_mb"
:min="0"
@@ -135,12 +135,7 @@
/>
</ElFormItem>
<ElFormItem label="价格(分)" prop="price">
<ElInputNumber
v-model="form.price"
:min="0"
:controls="false"
style="width: 100%"
/>
<ElInputNumber v-model="form.price" :min="0" :controls="false" style="width: 100%" />
</ElFormItem>
<ElFormItem label="套餐描述" prop="description">
<ElInput
@@ -387,10 +382,8 @@
label: '套餐类型',
width: 100,
formatter: (row: PackageResponse) => {
return h(
ElTag,
{ type: getPackageTypeTag(row.package_type), size: 'small' },
() => getPackageTypeLabel(row.package_type)
return h(ElTag, { type: getPackageTypeTag(row.package_type), size: 'small' }, () =>
getPackageTypeLabel(row.package_type)
)
}
},
@@ -399,10 +392,8 @@
label: '流量类型',
width: 100,
formatter: (row: PackageResponse) => {
return h(
ElTag,
{ type: getDataTypeTag(row.data_type), size: 'small' },
() => getDataTypeLabel(row.data_type)
return h(ElTag, { type: getDataTypeTag(row.data_type), size: 'small' }, () =>
getDataTypeLabel(row.data_type)
)
}
},

View File

@@ -176,7 +176,11 @@
</ElFormItem>
<ElFormItem
:label="form.one_time_commission_config.mode === 'fixed' ? '佣金金额(分)' : '佣金比例(千分比)'"
:label="
form.one_time_commission_config.mode === 'fixed'
? '佣金金额(分)'
: '佣金比例(千分比)'
"
prop="one_time_commission_config.value"
>
<ElInputNumber
@@ -197,8 +201,16 @@
<template v-if="form.one_time_commission_config.type === 'tiered'">
<ElFormItem label="梯度档位">
<div class="tier-list">
<div v-for="(tier, index) in form.one_time_commission_config.tiers" :key="index" class="tier-item">
<ElSelect v-model="tier.tier_type" placeholder="梯度类型" style="width: 120px">
<div
v-for="(tier, index) in form.one_time_commission_config.tiers"
:key="index"
class="tier-item"
>
<ElSelect
v-model="tier.tier_type"
placeholder="梯度类型"
style="width: 120px"
>
<ElOption label="销量" value="sales_count" />
<ElOption label="销售额" value="sales_amount" />
</ElSelect>
@@ -903,12 +915,14 @@
}
// 梯度类型配置
else if (form.one_time_commission_config.type === 'tiered') {
data.one_time_commission_config.tiers = form.one_time_commission_config.tiers.map((t: any) => ({
tier_type: t.tier_type,
threshold: t.threshold,
mode: t.mode,
value: t.value
}))
data.one_time_commission_config.tiers = form.one_time_commission_config.tiers.map(
(t: any) => ({
tier_type: t.tier_type,
threshold: t.threshold,
mode: t.mode,
value: t.value
})
)
}
}
@@ -962,9 +976,9 @@
}
.form-tip {
margin-top: 4px;
font-size: 12px;
color: #909399;
margin-top: 4px;
}
.tier-list {
@@ -981,26 +995,26 @@
.info-row {
display: flex;
gap: 20px;
margin-bottom: 18px;
padding: 12px;
margin-bottom: 18px;
border-radius: 4px;
}
.info-item {
flex: 1;
display: flex;
flex: 1;
align-items: center;
}
.info-label {
font-size: 14px;
margin-right: 8px;
font-size: 14px;
white-space: nowrap;
}
.info-value {
font-size: 14px;
color: var(--art-primary);
font-weight: 500;
color: var(--art-primary);
}
</style>

View File

@@ -455,7 +455,8 @@
activeText: getStatusText(CommonStatus.ENABLED),
inactiveText: getStatusText(CommonStatus.DISABLED),
inlinePrompt: true,
'onUpdate:modelValue': (val: string | number | boolean) => handleStatusChange(row, val as number)
'onUpdate:modelValue': (val: string | number | boolean) =>
handleStatusChange(row, val as number)
})
}
},

View File

@@ -48,7 +48,12 @@
<ElInput v-model="form.perm_name" placeholder="请输入权限名称" />
</ElFormItem>
<ElFormItem label="权限标识" prop="perm_code">
<ElInput v-model="form.perm_code" placeholder="请输入权限标识user:add" />
<ElInput
v-model="form.perm_code"
placeholder="例如:菜单权限:menu:role 按钮权限: role:add"
type="textarea"
:rows="2"
/>
</ElFormItem>
<ElFormItem label="权限类型" prop="perm_type">
<ElSelect v-model="form.perm_type" placeholder="请选择权限类型" style="width: 100%">

View File

@@ -18,7 +18,7 @@
@refresh="handleRefresh"
>
<template #left>
<ElButton @click="showDialog('add')">新增角色</ElButton>
<ElButton @click="showDialog('add')" v-permission="'role:add'">新增角色</ElButton>
</template>
</ArtTableHeader>
@@ -59,7 +59,12 @@
/>
</ElFormItem>
<ElFormItem label="角色类型" prop="role_type">
<ElSelect v-model="form.role_type" placeholder="请选择角色类型" style="width: 100%">
<ElSelect
v-model="form.role_type"
placeholder="请选择角色类型"
style="width: 100%"
:disabled="dialogType === 'edit'"
>
<ElOption label="平台角色" :value="1" />
<ElOption label="客户角色" :value="2" />
</ElSelect>
@@ -226,13 +231,12 @@
},
{
prop: 'role_desc',
label: '角色描述',
minWidth: 150
label: '角色描述'
},
{
prop: 'role_type',
label: '角色类型',
width: 100,
minWidth: 120,
formatter: (row: any) => {
return h(ElTag, { type: row.role_type === 1 ? 'primary' : 'success' }, () =>
row.role_type === 1 ? '平台角色' : '客户角色'
@@ -242,7 +246,7 @@
{
prop: 'status',
label: '状态',
width: 100,
minWidth: 100,
formatter: (row: any) => {
return h(ElSwitch, {
modelValue: row.status,
@@ -259,13 +263,13 @@
{
prop: 'CreatedAt',
label: '创建时间',
width: 180,
minWidth: 180,
formatter: (row: any) => formatDateTime(row.CreatedAt)
},
{
prop: 'operation',
label: '操作',
width: 180,
width: 200,
fixed: 'right',
formatter: (row: any) => {
return h('div', { style: 'display: flex; gap: 8px;' }, [
@@ -481,17 +485,22 @@
if (valid) {
submitLoading.value = true
try {
const data = {
role_name: form.role_name,
role_desc: form.role_desc,
role_type: form.role_type,
status: form.status
}
if (dialogType.value === 'add') {
const data = {
role_name: form.role_name,
role_desc: form.role_desc,
role_type: form.role_type,
status: form.status
}
await RoleService.createRole(data)
ElMessage.success('新增成功')
} else {
// 更新角色时只发送允许的字段
const data = {
role_name: form.role_name,
role_desc: form.role_desc,
status: form.status
}
await RoleService.updateRole(form.id, data)
ElMessage.success('修改成功')
}
@@ -522,5 +531,4 @@
console.error(error)
}
}
</script>