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

This commit is contained in:
sexygoat
2026-01-31 16:33:21 +08:00
parent 16d53709ef
commit ecb79dae43
20 changed files with 1369 additions and 649 deletions

2
.env
View File

@@ -12,7 +12,7 @@ VITE_BASE_URL =
VITE_API_URL = https://cmp-api.boss160.cn
# 权限模式( frontend backend
VITE_ACCESS_MODE = frontend
VITE_ACCESS_MODE = backend
# 是否打开路由信息
VITE_OPEN_ROUTE_INFO = false

View File

@@ -8,7 +8,7 @@ import type { BaseResponse } from '@/types/api'
/**
* 文件用途枚举
*/
export type FilePurpose = 'iot_import' | 'export' | 'attachment'
export type FilePurpose = 'iot_import' | 'device_import' | 'export' | 'attachment'
/**
* 获取上传 URL 请求参数
@@ -56,18 +56,18 @@ export class StorageService extends BaseService {
* - 预签名 URL 有效期 15 分钟,请及时使用
* - 上传时 Content-Type 需与请求时一致
* - file_key 在上传成功后永久有效
* - 开发环境通过代理上传,生产环境直接上传
*
* @param uploadUrl 预签名 URL
* @param uploadUrl 预签名 URL(由对象存储生成)
* @param file 文件
* @param contentType 文件类型(需与 getUploadUrl 请求时保持一致)
*/
static async uploadFile(uploadUrl: string, file: File, contentType?: string): Promise<void> {
try {
// 开发环境下,使用代理路径避免 CORS 问题
// 开发环境使用代理解决 CORS 问题
let finalUrl = uploadUrl
if (import.meta.env.DEV) {
// 将对象存储域名替换为代理路径
// 例如http://obs-helf.cucloud.cn/cmp/... -> /obs-proxy/cmp/...
// 将对象存储域名替换为代理路径
finalUrl = uploadUrl.replace(/^https?:\/\/obs-helf\.cucloud\.cn/, '/obs-proxy')
}
@@ -81,8 +81,7 @@ export class StorageService extends BaseService {
const response = await fetch(finalUrl, {
method: 'PUT',
body: file,
headers,
mode: 'cors' // 明确指定 CORS 模式
headers
})
if (!response.ok) {

View File

@@ -1,46 +1,57 @@
import { useRoute } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/store/modules/user'
import { useCommon } from '@/composables/useCommon'
import type { AppRouteRecord } from '@/types/router'
type AuthItem = NonNullable<AppRouteRecord['meta']['authList']>[number]
const userStore = useUserStore()
/**
* 按钮权限(前后端模式通用)
* 权限判断组合式函数
* 用于在 setup 函数或表格 formatter 中判断权限
*
* 用法:
* const { hasAuth } = useAuth()
* hasAuth('add') // 检查是否拥有新增权限
* const { hasAuth, hasAllAuth } = useAuth()
* hasAuth('role:add') // 检查是否拥有权限
* hasAuth(['role:add', 'role:edit']) // 检查是否拥有任意一个权限
* hasAllAuth(['role:add', 'role:edit']) // 检查是否拥有全部权限
*/
export const useAuth = () => {
const route = useRoute()
const { isFrontendMode } = useCommon()
const { info } = storeToRefs(userStore)
// 前端按钮权限(例如:['add', 'edit']
const frontendAuthList = info.value?.buttons ?? []
// 后端路由 meta 配置的权限列表(例如:[{ auth_mark: 'add' }]
const backendAuthList: AuthItem[] = Array.isArray(route.meta.authList)
? (route.meta.authList as AuthItem[])
: []
const userStore = useUserStore()
/**
* 检查是否拥有某权限标识
* @param auth 权限标识
* 检查是否有指定权限(满足任意一个即可)
* @param permission 权限标识,可以是字符串或字符串数组
* @returns 是否有权限
*/
const hasAuth = (auth: string): boolean => {
if (isFrontendMode.value) {
return frontendAuthList.includes(auth)
const hasAuth = (permission: string | string[]): boolean => {
if (!permission) return false
if (typeof permission === 'string') {
// 单个权限(同时检查 permissions 和 buttons
return userStore.hasPermission(permission) || userStore.hasButton(permission)
}
return backendAuthList.some((item) => item?.auth_mark === auth)
if (Array.isArray(permission)) {
// 多个权限(满足任意一个即可)
return permission.some(
(perm) => userStore.hasPermission(perm) || userStore.hasButton(perm)
)
}
return false
}
/**
* 检查是否有所有指定权限(需要全部满足)
* @param permissions 权限标识数组
* @returns 是否有全部权限
*/
const hasAllAuth = (permissions: string[]): boolean => {
if (!permissions || !Array.isArray(permissions)) return false
// 需要全部满足
return permissions.every(
(perm) => userStore.hasPermission(perm) || userStore.hasButton(perm)
)
}
return {
hasAuth
hasAuth,
hasAllAuth
}
}

View File

@@ -208,12 +208,26 @@ export function useLogin() {
return
}
// 保存权限、菜单和按钮
if (response.data.permissions) {
userStore.setPermissions(response.data.permissions)
}
if (response.data.menus) {
userStore.setMenus(response.data.menus)
}
if (response.data.buttons) {
userStore.setButtons(response.data.buttons)
}
// 保存记住密码
saveCredentials(formData.username, formData.password, formData.rememberPassword)
// 显示登录成功提示
showLoginSuccessNotice()
// 等待数据持久化完成
await new Promise((resolve) => setTimeout(resolve, 100))
// 跳转到重定向页面或首页
const redirectPath = getRedirectPath(route)
await router.push(redirectPath || HOME_PAGE)

Binary file not shown.

View File

@@ -22,16 +22,20 @@ const permissionDirective: Directive = {
let hasPermission = false
if (typeof value === 'string') {
// 单个权限
hasPermission = userStore.hasPermission(value)
// 单个权限(同时检查 permissions 和 buttons
hasPermission = userStore.hasPermission(value) || userStore.hasButton(value)
} else if (Array.isArray(value)) {
// 多个权限
if (arg === 'all') {
// 需要全部满足
hasPermission = userStore.hasAllPermissions(value)
hasPermission = value.every(
(perm) => userStore.hasPermission(perm) || userStore.hasButton(perm)
)
} else {
// 满足任意一个即可
hasPermission = userStore.hasAnyPermission(value)
hasPermission = value.some(
(perm) => userStore.hasPermission(perm) || userStore.hasButton(perm)
)
}
}

View File

@@ -20,7 +20,7 @@ import { isInWhiteList, hasRoutePermission, isTokenValid, buildLoginRedirect } f
const isRouteRegistered = ref(false)
// 临时开发模式:跳过所有权限验证(开发静态页面时使用)
const DEV_MODE_SKIP_AUTH = true
const DEV_MODE_SKIP_AUTH = false
/**
* 路由全局前置守卫
@@ -232,20 +232,125 @@ async function processFrontendMenu(router: Router): Promise<void> {
}
/**
* 处理后端控制模式的菜单逻辑
* 处理后端控制模式的菜单
*/
async function processBackendMenu(router: Router): Promise<void> {
const closeLoading = loadingService.showLoading()
try {
const { menuList } = await menuService.getMenuList()
await registerAndStoreMenu(router, menuList, closeLoading)
const userStore = useUserStore()
const backendMenus = userStore.menus || []
const routeMap = buildRouteMap(asyncRoutes)
const menuList = backendMenus
.map((menu) => convertBackendMenuToRoute(menu, routeMap))
.filter((route) => route !== null)
const finalMenuList = mergeDefaultMenus(menuList, routeMap)
await registerAndStoreMenu(router, finalMenuList, closeLoading)
} catch (error) {
closeLoading()
throw error
}
}
/**
* 合并默认菜单
* 将配置的默认菜单合并到后端返回的菜单中
*/
function mergeDefaultMenus(
backendMenus: AppRouteRecord[],
routeMap: Map<string, AppRouteRecord>
): AppRouteRecord[] {
const defaultMenuPaths = ['/dashboard']
const defaultMenus: AppRouteRecord[] = defaultMenuPaths
.map((path) => {
const route = routeMap.get(path)
return route ? menuDataToRouter(route) : null
})
.filter((menu): menu is AppRouteRecord => menu !== null)
const backendPaths = new Set(backendMenus.map((m) => m.path))
const filteredDefaultMenus = defaultMenus.filter((m) => !backendPaths.has(m.path))
return [...filteredDefaultMenus, ...backendMenus]
}
/**
* 构建 URL 到路由的映射表
*/
function buildRouteMap(routes: AppRouteRecord[], parentPath = ''): Map<string, AppRouteRecord> {
const map = new Map<string, AppRouteRecord>()
routes.forEach((route) => {
// 构建完整路径
const fullPath = route.path.startsWith('/')
? route.path
: parentPath
? `${parentPath}/${route.path}`.replace(/\/+/g, '/')
: `/${route.path}`
// 存储路由映射
map.set(fullPath, route)
// 递归处理子路由
if (route.children && route.children.length > 0) {
const childMap = buildRouteMap(route.children, fullPath)
childMap.forEach((childRoute, childPath) => {
map.set(childPath, childRoute)
})
}
})
return map
}
/**
* 将后端菜单数据转换为路由格式
*/
function convertBackendMenuToRoute(
menu: any,
routeMap: Map<string, AppRouteRecord>,
parentPath = ''
): AppRouteRecord | null {
const menuUrl = menu.url || '/'
const matchedRoute = routeMap.get(menuUrl)
if (!matchedRoute) {
console.warn(`未找到与菜单 URL "${menuUrl}" 匹配的路由定义: ${menu.name}`)
return null
}
const route: AppRouteRecord = {
path: menuUrl,
name: matchedRoute.name,
component: matchedRoute.component,
meta: {
...matchedRoute.meta,
title: menu.name,
permission: menu.perm_code,
sort: menu.sort || matchedRoute.meta?.sort || 0,
icon: menu.icon || matchedRoute.meta?.icon,
// 清除前端定义的 roles 和 permissions使用后端权限控制
roles: undefined,
permissions: undefined
}
}
if (menu.children && menu.children.length > 0) {
const children = menu.children
.map((child: any) => convertBackendMenuToRoute(child, routeMap, menuUrl))
.filter((child: AppRouteRecord | null) => child !== null)
if (children.length > 0) {
route.children = children
}
}
return route
}
/**
* 注册路由并存储菜单数据
*/

View File

@@ -4,6 +4,7 @@
import type { RouteLocationNormalized } from 'vue-router'
import type { UserInfo } from '@/types/api'
import { useUserStore } from '@/store/modules/user'
/**
* 不需要登录的路由白名单
@@ -33,6 +34,7 @@ export const isInWhiteList = (path: string): boolean => {
/**
* 检查用户是否有权限访问路由
* 根据文档要求:菜单访问基于 URL按钮访问基于权限标识
*/
export const hasRoutePermission = (
route: RouteLocationNormalized,
@@ -40,7 +42,30 @@ export const hasRoutePermission = (
): boolean => {
const { roles = [], permissions = [] } = userInfo
// 如果路由没有设置权限要求,直接通过
// 如果是超级管理员,直接通过
if (userInfo.user_type === 1) {
return true
}
// 默认始终可访问的路由(不需要菜单权限)
const defaultAllowedPaths = ['/dashboard']
const isDefaultRoute = defaultAllowedPaths.some(
(allowedPath) => route.path === allowedPath || route.path.startsWith(allowedPath + '/')
)
// 检查菜单访问权限(基于 URL
const userStore = useUserStore()
const userMenus = userStore.menus || []
// 如果是默认路由,跳过菜单权限检查
if (!isDefaultRoute && userMenus.length > 0) {
const hasMenuAccess = checkMenuAccess(route.path, userMenus)
if (!hasMenuAccess) {
return false
}
}
// 如果路由没有设置额外的权限要求,直接通过
if (!route.meta?.roles && !route.meta?.permissions) {
return true
}
@@ -58,11 +83,9 @@ export const hasRoutePermission = (
if (route.meta.permissions) {
const routePermissions = route.meta.permissions as string[]
const hasPermission = routePermissions.some((permission) => {
// 支持通配符权限 *:*:*
if (permissions.includes('*:*:*')) {
return true
}
// 精确匹配或前缀匹配
return permissions.some((userPermission) => {
if (userPermission.endsWith('*')) {
const prefix = userPermission.slice(0, -1)
@@ -79,6 +102,25 @@ export const hasRoutePermission = (
return true
}
/**
* 递归检查菜单访问权限(基于 URL
*/
function checkMenuAccess(path: string, menus: any[]): boolean {
for (const menu of menus) {
// 检查当前菜单的 URL 是否匹配
if (menu.url && (path === menu.url || path.startsWith(menu.url + '/'))) {
return true
}
// 递归检查子菜单
if (menu.children && menu.children.length > 0) {
if (checkMenuAccess(path, menu.children)) {
return true
}
}
}
return false
}
/**
* 检查 Token 是否有效
* 简单检查,真实项目中应该验证 JWT 或者调用后端接口

View File

@@ -306,32 +306,32 @@ export const asyncRoutes: AppRouteRecord[] = [
keepAlive: true,
isHideTab: true
}
},
{
path: 'menu',
name: 'Menus',
component: RoutesAlias.Menu,
meta: {
title: 'menus.system.menu',
keepAlive: true,
roles: ['R_SUPER'],
authList: [
{
title: '新增',
auth_mark: 'add'
},
{
title: '编辑',
auth_mark: 'edit'
},
{
title: '删除',
auth_mark: 'delete'
}
]
}
}
// {
// path: 'menu',
// name: 'Menus',
// component: RoutesAlias.Menu,
// meta: {
// title: 'menus.system.menu',
// keepAlive: true,
// roles: ['R_SUPER'],
// authList: [
// {
// title: '新增',
// auth_mark: 'add'
// },
// {
// title: '编辑',
// auth_mark: 'edit'
// },
// {
// title: '删除',
// auth_mark: 'delete'
// }
// ]
// }
// }
// {
// path: 'nested',
// name: 'Nested',
// component: '',
@@ -741,6 +741,26 @@ export const asyncRoutes: AppRouteRecord[] = [
}
]
},
{
path: '/shop-management',
name: 'ShopManagement',
component: RoutesAlias.Home,
meta: {
title: 'menus.product.shop',
icon: '&#xe81a;'
},
children: [
{
path: 'list',
name: 'Shop',
component: RoutesAlias.Shop,
meta: {
title: 'menus.product.shop',
keepAlive: true
}
}
]
},
{
path: '/account-management',
name: 'AccountManagement',
@@ -816,71 +836,71 @@ export const asyncRoutes: AppRouteRecord[] = [
}
]
},
{
path: '/product',
name: 'Product',
component: RoutesAlias.Home,
meta: {
title: 'menus.product.title',
icon: '&#xe81a;'
},
children: [
// {
// path: 'sim-card',
// name: 'SimCardManagement',
// component: RoutesAlias.SimCardManagement,
// path: '/product',
// name: 'Product',
// component: RoutesAlias.Home,
// meta: {
// title: 'menus.product.simCard',
// title: 'menus.product.title',
// icon: '&#xe81a;'
// },
// children: [
// // {
// // path: 'sim-card',
// // name: 'SimCardManagement',
// // component: RoutesAlias.SimCardManagement,
// // meta: {
// // title: 'menus.product.simCard',
// // keepAlive: true
// // }
// // },
// // {
// // path: 'sim-card-assign',
// // name: 'SimCardAssign',
// // component: RoutesAlias.SimCardAssign,
// // meta: {
// // title: 'menus.product.simCardAssign',
// // keepAlive: true
// // }
// // },
// // {
// // path: 'package-series',
// // name: 'PackageSeries',
// // component: RoutesAlias.PackageSeries,
// // meta: {
// // title: 'menus.product.packageSeries',
// // keepAlive: true
// // }
// // },
// // {
// // path: 'package-list',
// // name: 'PackageList',
// // component: RoutesAlias.PackageList,
// // meta: {
// // title: 'menus.product.packageList',
// // keepAlive: true
// // }
// // },
// // {
// // path: 'package-assign',
// // name: 'PackageAssign',
// // component: RoutesAlias.PackageAssign,
// // meta: {
// // title: 'menus.product.packageAssign',
// // keepAlive: true
// // }
// // },
// {
// path: 'shop',
// name: 'Shop',
// component: RoutesAlias.Shop,
// meta: {
// title: 'menus.product.shop',
// keepAlive: true
// }
// },
// {
// path: 'sim-card-assign',
// name: 'SimCardAssign',
// component: RoutesAlias.SimCardAssign,
// meta: {
// title: 'menus.product.simCardAssign',
// keepAlive: true
// }
// ]
// },
// {
// path: 'package-series',
// name: 'PackageSeries',
// component: RoutesAlias.PackageSeries,
// meta: {
// title: 'menus.product.packageSeries',
// keepAlive: true
// }
// },
// {
// path: 'package-list',
// name: 'PackageList',
// component: RoutesAlias.PackageList,
// meta: {
// title: 'menus.product.packageList',
// keepAlive: true
// }
// },
// {
// path: 'package-assign',
// name: 'PackageAssign',
// component: RoutesAlias.PackageAssign,
// meta: {
// title: 'menus.product.packageAssign',
// keepAlive: true
// }
// },
{
path: 'shop',
name: 'Shop',
component: RoutesAlias.Shop,
meta: {
title: 'menus.product.shop',
keepAlive: true
}
}
]
},
{
path: '/asset-management',
name: 'AssetManagement',

View File

@@ -24,11 +24,15 @@ export const useUserStore = defineStore(
const accessToken = ref('')
const refreshToken = ref('')
const permissions = ref<string[]>([])
const menus = ref<any[]>([])
const buttons = ref<string[]>([])
const getUserInfo = computed(() => info.value)
const getSettingState = computed(() => useSettingStore().$state)
const getWorktabState = computed(() => useWorktabStore().$state)
const getPermissions = computed(() => permissions.value)
const getMenus = computed(() => menus.value)
const getButtons = computed(() => buttons.value)
const setUserInfo = (newInfo: UserInfo) => {
info.value = newInfo
@@ -38,6 +42,14 @@ export const useUserStore = defineStore(
permissions.value = perms
}
const setMenus = (menuList: any[]) => {
menus.value = menuList
}
const setButtons = (buttonList: string[]) => {
buttons.value = buttonList
}
// 检查是否是超级管理员
const isSuperAdmin = computed(() => info.value.user_type === 1)
@@ -62,6 +74,13 @@ export const useUserStore = defineStore(
return perms.every((perm) => permissions.value.includes(perm))
}
// 检查是否有某个按钮权限
const hasButton = (buttonCode: string): boolean => {
// 超级管理员拥有所有权限
if (isSuperAdmin.value) return true
return buttons.value.includes(buttonCode)
}
const setLoginStatus = (status: boolean) => {
isLogin.value = status
}
@@ -105,6 +124,8 @@ export const useUserStore = defineStore(
accessToken.value = ''
refreshToken.value = ''
permissions.value = []
menus.value = []
buttons.value = []
useWorktabStore().opened = []
sessionStorage.removeItem('iframeRoutes')
resetRouterState(router)
@@ -122,16 +143,23 @@ export const useUserStore = defineStore(
accessToken,
refreshToken,
permissions,
menus,
buttons,
getUserInfo,
getSettingState,
getWorktabState,
getPermissions,
getMenus,
getButtons,
isSuperAdmin,
setUserInfo,
setPermissions,
setMenus,
setButtons,
hasPermission,
hasAnyPermission,
hasAllPermissions,
hasButton,
setLoginStatus,
setLanguage,
setSearchHistory,
@@ -145,8 +173,18 @@ export const useUserStore = defineStore(
persist: {
key: 'user',
storage: localStorage,
// 只持久化 token 和登录状态,用户信息每次刷新都从接口获取
paths: ['accessToken', 'refreshToken', 'isLogin', 'language', 'isLock', 'lockPassword']
paths: [
'accessToken',
'refreshToken',
'isLogin',
'language',
'isLock',
'lockPassword',
'info',
'permissions',
'menus',
'buttons'
]
}
}
)

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,319 +14,304 @@ 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

@@ -30,7 +30,6 @@
<ElButton type="info" @click="handleBatchSetSeries" :disabled="!selectedDevices.length">
批量设置套餐系列
</ElButton>
<ElButton @click="handleImportDevice">导入设备</ElButton>
</template>
</ArtTableHeader>
@@ -300,22 +299,13 @@
{{ currentDeviceDetail.status_name }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="店铺名称">{{
currentDeviceDetail.shop_name || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="批次号">{{
<ElDescriptionsItem label="批次号" :span="2">{{
currentDeviceDetail.batch_no || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="激活时间">{{
currentDeviceDetail.activated_at || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">{{
<ElDescriptionsItem label="创建时间" :span="3">{{
currentDeviceDetail.created_at || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="更新时间">{{
currentDeviceDetail.updated_at || '--'
}}</ElDescriptionsItem>
</ElDescriptions>
</ElDialog>
</ElCard>
@@ -385,12 +375,9 @@
device_no: '',
device_name: '',
status: undefined as DeviceStatus | undefined,
shop_id: undefined as number | undefined,
batch_no: '',
device_type: '',
manufacturer: '',
created_at_start: '',
created_at_end: ''
manufacturer: ''
}
// 搜索表单
@@ -478,9 +465,7 @@
{ label: '最大插槽数', prop: 'max_sim_slots' },
{ label: '已绑定卡数', prop: 'bound_card_count' },
{ label: '状态', prop: 'status' },
{ label: '店铺', prop: 'shop_name' },
{ label: '批次号', prop: 'batch_no' },
{ label: '激活时间', prop: 'activated_at' },
{ label: '创建时间', prop: 'created_at' },
{ label: '操作', prop: 'operation' }
]
@@ -610,24 +595,12 @@
return h(ElTag, { type: status.type }, () => status.text)
}
},
{
prop: 'shop_name',
label: '店铺',
minWidth: 120,
formatter: (row: Device) => row.shop_name || '-'
},
{
prop: 'batch_no',
label: '批次号',
minWidth: 120,
minWidth: 160,
formatter: (row: Device) => row.batch_no || '-'
},
{
prop: 'activated_at',
label: '激活时间',
width: 180,
formatter: (row: Device) => (row.activated_at ? formatDateTime(row.activated_at) : '-')
},
{
prop: 'created_at',
label: '创建时间',
@@ -637,14 +610,10 @@
{
prop: 'operation',
label: '操作',
width: 150,
width: 100,
fixed: 'right',
formatter: (row: Device) => {
return h('div', { style: 'display: flex; gap: 8px;' }, [
h(ArtButtonTable, {
type: 'view',
onClick: () => viewDeviceDetail(row)
}),
h(ArtButtonTable, {
type: 'delete',
onClick: () => deleteDevice(row)
@@ -681,16 +650,13 @@
device_no: searchForm.device_no || undefined,
device_name: searchForm.device_name || undefined,
status: searchForm.status,
shop_id: searchForm.shop_id,
batch_no: searchForm.batch_no || undefined,
device_type: searchForm.device_type || undefined,
manufacturer: searchForm.manufacturer || undefined,
created_at_start: searchForm.created_at_start || undefined,
created_at_end: searchForm.created_at_end || undefined
manufacturer: searchForm.manufacturer || undefined
}
const res = await DeviceService.getDevices(params)
if (res.code === 0 && res.data) {
deviceList.value = res.data.list || []
deviceList.value = res.data.items || []
pagination.total = res.data.total || 0
}
} catch (error) {
@@ -735,14 +701,6 @@
selectedDevices.value = selection
}
// 查看设备详情
const viewDeviceDetail = (row: Device) => {
router.push({
path: '/asset-management/device-detail',
query: { id: row.id }
})
}
// 删除设备
const deleteDevice = (row: Device) => {
ElMessageBox.confirm(`确定删除设备 ${row.device_no} 吗?删除后将自动解绑所有卡。`, '删除确认', {
@@ -869,11 +827,6 @@
recallForm.remark = ''
}
// 导入设备
const handleImportDevice = () => {
router.push('/batch/device-import')
}
// 批量设置套餐系列
const handleBatchSetSeries = async () => {
if (selectedDevices.value.length === 0) {

View File

@@ -50,17 +50,11 @@
<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
>
<p>1. 请先下载 Excel 模板文件按照模板格式填写设备信息</p>
<p>2. 支持 Excel 格式.xlsx单次最多导入 1000 </p>
<p>3. 列格式请设置为文本格式避免长数字被转为科学计数法</p>
<p>4. 必填列device_no设备号device_name设备名称device_model设备型号device_type设备类型</p>
<p>5. 可选列manufacturer制造商max_sim_slots最大插槽数默认4iccid_1 ~ iccid_4绑定的卡ICCID</p>
</div>
</template>
</ElAlert>
@@ -77,12 +71,12 @@
:auto-upload="false"
:on-change="handleFileChange"
:limit="1"
accept=".csv"
accept=".xlsx"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text"> CSV 文件拖到此处<em>点击选择</em></div>
<div class="el-upload__text"> Excel 文件拖到此处<em>点击选择</em></div>
<template #tip>
<div class="el-upload__tip">只能上传 CSV 文件且不超过 10MB</div>
<div class="el-upload__tip">只能上传 .xlsx 格式的 Excel 文件且不超过 10MB</div>
</template>
</ElUpload>
@@ -131,18 +125,51 @@
}}</ElDescriptionsItem>
</ElDescriptions>
<ElDivider content-position="left">跳过明细</ElDivider>
<div
v-if="currentDetail.skipped_items && currentDetail.skipped_items.length"
style="max-height: 300px; overflow-y: auto; margin-bottom: 20px"
>
<ElTable :data="currentDetail.skipped_items" border size="small">
<ElTableColumn label="行号" prop="line" width="80" />
<ElTableColumn label="设备编号" prop="device_no" width="180" />
<ElTableColumn label="跳过原因" prop="reason" min-width="300">
<template #default="{ row }">
{{ row.reason || '未知原因' }}
</template>
</ElTableColumn>
</ElTable>
</div>
<ElEmpty v-else description="无跳过记录" />
<ElDivider content-position="left">警告明细</ElDivider>
<div
v-if="currentDetail.warning_items && currentDetail.warning_items.length"
style="max-height: 300px; overflow-y: auto; margin-bottom: 20px"
>
<ElTable :data="currentDetail.warning_items" border size="small">
<ElTableColumn label="行号" prop="line" width="80" />
<ElTableColumn label="设备编号" prop="device_no" width="180" />
<ElTableColumn label="警告信息" prop="reason" min-width="300">
<template #default="{ row }">
{{ row.reason || row.message || '未知警告' }}
</template>
</ElTableColumn>
</ElTable>
</div>
<ElEmpty v-else description="无警告记录" />
<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">
<ElTableColumn label="行号" prop="line" width="80" />
<ElTableColumn label="设备编号" prop="device_no" width="180" />
<ElTableColumn label="失败原因" prop="reason" min-width="300">
<template #default="{ row }">
{{ row.reason || row.error || '未知错误' }}
{{ row.reason || '未知错误' }}
</template>
</ElTableColumn>
</ElTable>
@@ -151,6 +178,22 @@
<template #footer>
<ElButton @click="detailDialogVisible = false">关闭</ElButton>
<ElButton
v-if="currentDetail.skip_count > 0"
type="warning"
:icon="Download"
@click="downloadSkippedData"
>
下载跳过数据
</ElButton>
<ElButton
v-if="currentDetail.warning_count > 0"
type="warning"
:icon="Download"
@click="downloadWarningData"
>
下载警告数据
</ElButton>
<ElButton
v-if="currentDetail.fail_count > 0"
type="primary"
@@ -475,29 +518,78 @@
}
// 下载模板
const downloadTemplate = () => {
// 使用 \t 前缀防止 Excel 将长数字转换为科学计数法
const csvContent = [
'device_no,device_name,device_model,device_type,manufacturer,max_sim_slots',
'\t862639070731999,智能水表01,WM-2000,智能水表,华为,1',
'\t862639070750932,GPS定位器01,GPS-3000,定位设备,小米,2',
'\t862639070801875,智能燃气表01,GM-1500,智能燃气表,海尔,1'
].join('\n')
const downloadTemplate = async () => {
try {
// 动态导入 xlsx 库
const XLSX = await import('xlsx')
const BOM = '\uFEFF'
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' })
// 创建示例数据
const templateData = [
{
device_no: '862639070731999',
device_name: '智能水表01',
device_model: 'WM-2000',
device_type: '智能水表',
manufacturer: '华为',
max_sim_slots: 4,
iccid_1: '89860123456789012345',
iccid_2: '',
iccid_3: '',
iccid_4: ''
},
{
device_no: '862639070750932',
device_name: 'GPS定位器01',
device_model: 'GPS-3000',
device_type: '定位设备',
manufacturer: '小米',
max_sim_slots: 2,
iccid_1: '89860123456789012346',
iccid_2: '89860123456789012347',
iccid_3: '',
iccid_4: ''
}
]
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)
// 创建工作簿
const wb = XLSX.utils.book_new()
const ws = XLSX.utils.json_to_sheet(templateData)
// 设置列宽
ws['!cols'] = [
{ wch: 20 }, // device_no
{ wch: 20 }, // device_name
{ wch: 15 }, // device_model
{ wch: 15 }, // device_type
{ wch: 15 }, // manufacturer
{ wch: 15 }, // max_sim_slots
{ wch: 22 }, // iccid_1
{ wch: 22 }, // iccid_2
{ wch: 22 }, // iccid_3
{ wch: 22 } // iccid_4
]
// 将所有单元格设置为文本格式
const range = XLSX.utils.decode_range(ws['!ref'] || 'A1')
for (let R = range.s.r; R <= range.e.r; ++R) {
for (let C = range.s.c; C <= range.e.c; ++C) {
const cellAddress = XLSX.utils.encode_cell({ r: R, c: C })
if (!ws[cellAddress]) continue
ws[cellAddress].t = 's' // 设置为字符串类型
}
}
// 添加工作表到工作簿
XLSX.utils.book_append_sheet(wb, ws, '设备导入模板')
// 导出文件
XLSX.writeFile(wb, '设备导入模板.xlsx')
ElMessage.success('设备导入模板下载成功')
} catch (error) {
console.error('下载模板失败:', error)
ElMessage.error('下载模板失败')
}
}
// 文件选择变化
@@ -510,8 +602,8 @@
return
}
if (uploadFile.raw && !uploadFile.raw.name.endsWith('.csv')) {
ElMessage.error('只能上传 CSV 文件')
if (uploadFile.raw && !uploadFile.raw.name.endsWith('.xlsx')) {
ElMessage.error('只能上传 .xlsx 格式的 Excel 文件')
uploadRef.value?.clearFiles()
fileList.value = []
return
@@ -535,7 +627,7 @@
// 提交上传
const submitUpload = async () => {
if (!fileList.value.length) {
ElMessage.warning('请先选择CSV文件')
ElMessage.warning('请先选择 Excel 文件')
return
}
@@ -546,7 +638,7 @@
ElMessage.info('正在准备上传...')
const uploadUrlRes = await StorageService.getUploadUrl({
file_name: file.name,
content_type: 'text/csv',
content_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
purpose: 'iot_import'
})
@@ -557,7 +649,7 @@
const { upload_url, file_key } = uploadUrlRes.data
ElMessage.info('正在上传文件...')
await StorageService.uploadFile(upload_url, file, 'text/csv')
await StorageService.uploadFile(upload_url, file, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
ElMessage.info('正在创建导入任务...')
const importRes = await DeviceService.importDevices({
@@ -601,6 +693,94 @@
}
}
// 下载跳过数据(从详情对话框)
const downloadSkippedData = () => {
downloadSkippedDataFromDetail(currentDetail.value, currentDetail.value.batch_no)
}
// 下载跳过数据的通用方法
const downloadSkippedDataFromDetail = (detail: any, batchNo: string) => {
const skippedReasons =
detail.skipped_items?.map((item: any) => ({
line: item.line || '-',
deviceNo: item.device_no || '-',
message: item.reason || '未知原因'
})) || []
if (skippedReasons.length === 0) {
ElMessage.warning('没有跳过数据可下载')
return
}
const headers = ['行号', '设备编号', '跳过原因']
const csvRows = [
headers.join(','),
...skippedReasons.map((item: any) =>
[item.line, `\t${item.deviceNo}`, `"${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('跳过数据下载成功')
}
// 下载警告数据(从详情对话框)
const downloadWarningData = () => {
downloadWarningDataFromDetail(currentDetail.value, currentDetail.value.batch_no)
}
// 下载警告数据的通用方法
const downloadWarningDataFromDetail = (detail: any, batchNo: string) => {
const warningReasons =
detail.warning_items?.map((item: any) => ({
line: item.line || '-',
deviceNo: item.device_no || '-',
message: item.reason || item.message || '未知警告'
})) || []
if (warningReasons.length === 0) {
ElMessage.warning('没有警告数据可下载')
return
}
const headers = ['行号', '设备编号', '警告信息']
const csvRows = [
headers.join(','),
...warningReasons.map((item: any) =>
[item.line, `\t${item.deviceNo}`, `"${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('警告数据下载成功')
}
// 下载失败数据(从详情对话框)
const downloadFailData = () => {
downloadFailDataFromDetail(currentDetail.value, currentDetail.value.batch_no)
@@ -609,11 +789,10 @@
// 下载失败数据的通用方法
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 || '未知错误'
detail.failed_items?.map((item: any) => ({
line: item.line || '-',
deviceNo: item.device_no || '-',
message: item.reason || '未知错误'
})) || []
if (failReasons.length === 0) {
@@ -621,11 +800,11 @@
return
}
const headers = ['行号', '设备编号', 'ICCID', '失败原因']
const headers = ['行号', '设备编号', '失败原因']
const csvRows = [
headers.join(','),
...failReasons.map((item: any) =>
[item.row, item.deviceCode, item.iccid, `"${item.message}"`].join(',')
[item.line, `\t${item.deviceNo}`, `"${item.message}"`].join(',')
)
]
const csvContent = csvRows.join('\n')

View File

@@ -33,18 +33,7 @@
>
批量回收
</ElButton>
<ElButton
type="info"
:disabled="selectedCards.length === 0"
@click="showSeriesBindingDialog"
>
批量设置套餐系列
</ElButton>
<ElButton type="primary" @click="cardDistribution">网卡分销</ElButton>
<ElButton type="success" @click="batchRecharge">批量充值</ElButton>
<ElButton type="danger" @click="cardRecycle">网卡回收</ElButton>
<ElButton type="info" @click="batchDownload">批量下载</ElButton>
<ElButton type="warning" @click="changePackage">变更套餐</ElButton>
<ElButton type="info" @contextmenu.prevent="showMoreMenu">更多操作</ElButton>
</template>
</ArtTableHeader>
@@ -501,6 +490,14 @@
</div>
</template>
</ElDialog>
<!-- 更多操作右键菜单 -->
<ArtMenuRight
ref="moreMenuRef"
:menu-items="moreMenuItems"
:menu-width="180"
@select="handleMoreMenuSelect"
/>
</ElCard>
</div>
</ArtTableFullScreen>
@@ -517,6 +514,8 @@
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { formatDateTime } from '@/utils/business/format'
import ArtMenuRight from '@/components/core/others/ArtMenuRight.vue'
import type { MenuItemType } from '@/components/core/others/ArtMenuRight.vue'
import type {
StandaloneIotCard,
StandaloneCardStatus,
@@ -578,6 +577,9 @@
const cardDetailLoading = ref(false)
const currentCardDetail = ref<any>(null)
// 更多操作右键菜单
const moreMenuRef = ref<InstanceType<typeof ArtMenuRight>>()
// 搜索表单初始值
const initialSearchState = {
status: undefined,
@@ -1423,6 +1425,72 @@
})
}
// 更多操作菜单项配置
const moreMenuItems = computed((): MenuItemType[] => [
{
key: 'seriesBinding',
label: '批量设置套餐系列',
icon: '&#xe88e;',
disabled: selectedCards.value.length === 0
},
{
key: 'distribution',
label: '网卡分销',
icon: '&#xe73b;'
},
{
key: 'recharge',
label: '批量充值',
icon: '&#xe63a;'
},
{
key: 'recycle',
label: '网卡回收',
icon: '&#xe850;'
},
{
key: 'download',
label: '批量下载',
icon: '&#xe78b;'
},
{
key: 'changePackage',
label: '变更套餐',
icon: '&#xe706;'
}
])
// 显示更多操作菜单
const showMoreMenu = (e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
moreMenuRef.value?.show(e)
}
// 处理更多操作菜单选择
const handleMoreMenuSelect = (item: MenuItemType) => {
switch (item.key) {
case 'seriesBinding':
showSeriesBindingDialog()
break
case 'distribution':
cardDistribution()
break
case 'recharge':
batchRecharge()
break
case 'recycle':
cardRecycle()
break
case 'download':
batchDownload()
break
case 'changePackage':
changePackage()
break
}
}
// 网卡分销 - 正在开发中
const cardDistribution = () => {
ElMessage.info('功能正在开发中')

View File

@@ -50,10 +50,10 @@
<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>1. 请先下载 Excel 模板文件按照模板格式填写IoT卡信息</p>
<p>2. 支持 Excel 格式.xlsx单次最多导入 1000 </p>
<p>3. 列格式请设置为文本格式避免长数字被转为科学计数法</p>
<p>4. 必填字段ICCIDMSISDN手机号</p>
<p>5. 必须选择运营商</p>
</div>
</template>
@@ -79,12 +79,12 @@
:auto-upload="false"
:on-change="handleFileChange"
:limit="1"
accept=".csv"
accept=".xlsx"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text"> CSV 文件拖到此处<em>点击选择</em></div>
<div class="el-upload__text"> Excel 文件拖到此处<em>点击选择</em></div>
<template #tip>
<div class="el-upload__tip">只能上传 CSV 文件且不超过 10MB</div>
<div class="el-upload__tip">只能上传 .xlsx 格式的 Excel 文件且不超过 10MB</div>
</template>
</ElUpload>
@@ -133,13 +133,31 @@
}}</ElDescriptionsItem>
</ElDescriptions>
<ElDivider content-position="left">跳过明细</ElDivider>
<div
v-if="currentDetail.skipped_items && currentDetail.skipped_items.length"
style="max-height: 300px; overflow-y: auto; margin-bottom: 20px"
>
<ElTable :data="currentDetail.skipped_items" border size="small">
<ElTableColumn label="行号" prop="line" width="80" />
<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 || '未知原因' }}
</template>
</ElTableColumn>
</ElTable>
</div>
<ElEmpty v-else description="无跳过记录" />
<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="line" width="80" />
<ElTableColumn label="ICCID" prop="iccid" width="200" />
<ElTableColumn label="MSISDN" prop="msisdn" width="150" />
<ElTableColumn label="失败原因" prop="reason" min-width="200">
@@ -153,6 +171,14 @@
<template #footer>
<ElButton @click="detailDialogVisible = false">关闭</ElButton>
<ElButton
v-if="currentDetail.skip_count > 0"
type="warning"
:icon="Download"
@click="downloadSkippedData"
>
下载跳过数据
</ElButton>
<ElButton
v-if="currentDetail.fail_count > 0"
type="primary"
@@ -513,6 +539,51 @@
}
}
// 下载跳过数据(从详情对话框)
const downloadSkippedData = () => {
downloadSkippedDataFromDetail(currentDetail.value, currentDetail.value.task_no)
}
// 下载跳过数据的通用方法
const downloadSkippedDataFromDetail = (detail: any, taskNo: string) => {
const skippedReasons =
detail.skipped_items?.map((item: any) => ({
line: item.line || '-',
iccid: item.iccid || '-',
msisdn: item.msisdn || '-',
message: item.reason || '未知原因'
})) || []
if (skippedReasons.length === 0) {
ElMessage.warning('没有跳过数据可下载')
return
}
const headers = ['行号', 'ICCID', 'MSISDN', '跳过原因']
const csvRows = [
headers.join(','),
...skippedReasons.map((item: any) =>
[item.line, 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 downloadFailData = () => {
downloadFailDataFromDetail(currentDetail.value, currentDetail.value.task_no)
@@ -521,8 +592,8 @@
// 下载失败数据的通用方法
const downloadFailDataFromDetail = (detail: any, taskNo: string) => {
const failReasons =
detail.failed_items?.map((item: any, index: number) => ({
row: index + 1,
detail.failed_items?.map((item: any) => ({
line: item.line || '-',
iccid: item.iccid || '-',
msisdn: item.msisdn || '-',
message: item.reason || item.error || '未知错误'
@@ -537,7 +608,7 @@
const csvRows = [
headers.join(','),
...failReasons.map((item: any) =>
[item.row, item.iccid, item.msisdn, `"${item.message}"`].join(',')
[item.line, item.iccid, item.msisdn, `"${item.message}"`].join(',')
)
]
const csvContent = csvRows.join('\n')
@@ -559,29 +630,58 @@
}
// 下载模板
const downloadTemplate = () => {
// 使用 \t 前缀防止 Excel 将长数字转换为科学计数法
const csvContent = [
'iccid,msisdn',
'\t89860123456789012345,\t13800138000',
'\t89860123456789012346,\t13800138001',
'\t89860123456789012347,\t13800138002'
].join('\n')
const downloadTemplate = async () => {
try {
// 动态导入 xlsx 库
const XLSX = await import('xlsx')
const BOM = '\uFEFF'
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' })
// 创建示例数据
const templateData = [
{
ICCID: '89860123456789012345',
MSISDN: '13800138000'
},
{
ICCID: '89860123456789012346',
MSISDN: '13800138001'
},
{
ICCID: '89860123456789012347',
MSISDN: '13800138002'
}
]
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)
// 创建工作簿
const wb = XLSX.utils.book_new()
const ws = XLSX.utils.json_to_sheet(templateData)
// 设置列宽
ws['!cols'] = [
{ wch: 25 }, // ICCID
{ wch: 15 } // MSISDN
]
// 将所有单元格设置为文本格式,防止科学计数法
const range = XLSX.utils.decode_range(ws['!ref'] || 'A1')
for (let R = range.s.r; R <= range.e.r; ++R) {
for (let C = range.s.c; C <= range.e.c; ++C) {
const cellAddress = XLSX.utils.encode_cell({ r: R, c: C })
if (!ws[cellAddress]) continue
ws[cellAddress].t = 's' // 设置为字符串类型
}
}
// 添加工作表到工作簿
XLSX.utils.book_append_sheet(wb, ws, 'IoT卡导入模板')
// 导出文件
XLSX.writeFile(wb, 'IoT卡导入模板.xlsx')
ElMessage.success('IoT卡导入模板下载成功')
} catch (error) {
console.error('下载模板失败:', error)
ElMessage.error('下载模板失败')
}
}
// 文件选择变化
@@ -594,8 +694,8 @@
return
}
if (uploadFile.raw && !uploadFile.raw.name.endsWith('.csv')) {
ElMessage.error('只能上传 CSV 文件')
if (uploadFile.raw && !uploadFile.raw.name.endsWith('.xlsx')) {
ElMessage.error('只能上传 .xlsx 格式的 Excel 文件')
uploadRef.value?.clearFiles()
fileList.value = []
return
@@ -625,7 +725,7 @@
}
if (!fileList.value.length) {
ElMessage.warning('请先选择CSV文件')
ElMessage.warning('请先选择 Excel 文件')
return
}
@@ -636,7 +736,7 @@
ElMessage.info('正在准备上传...')
const uploadUrlRes = await StorageService.getUploadUrl({
file_name: file.name,
content_type: 'text/csv',
content_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
purpose: 'iot_import'
})
@@ -647,7 +747,7 @@
const { upload_url, file_key } = uploadUrlRes.data
ElMessage.info('正在上传文件...')
await StorageService.uploadFile(upload_url, file, 'text/csv')
await StorageService.uploadFile(upload_url, file, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
ElMessage.info('正在创建导入任务...')
const importRes = await CardService.importIotCards({

View File

@@ -290,7 +290,6 @@
// 列配置
const columnOptions = [
{ label: '店铺ID', prop: 'shop_id' },
{ label: '店铺编码', prop: 'shop_code' },
{ label: '店铺名称', prop: 'shop_name' },
{ label: '用户名', prop: 'username' },
@@ -306,11 +305,6 @@
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'shop_id',
label: '店铺ID',
width: 100
},
{
prop: 'shop_code',
label: '店铺编码',

View File

@@ -74,11 +74,31 @@
:label="t('orderManagement.createForm.iotCardId')"
prop="iot_card_id"
>
<ElInputNumber
<ElSelect
v-model="createForm.iot_card_id"
filterable
remote
reserve-keyword
:placeholder="t('orderManagement.createForm.iotCardIdPlaceholder')"
:remote-method="searchIotCards"
:loading="cardSearchLoading"
style="width: 100%"
/>
clearable
>
<ElOption
v-for="card in iotCardOptions"
:key="card.id"
:label="`${card.iccid} (${card.msisdn || '无接入号'})`"
:value="card.id"
>
<div style="display: flex; justify-content: space-between">
<span>{{ card.iccid }}</span>
<span style="color: var(--el-text-color-secondary); font-size: 12px">
{{ card.msisdn || '无接入号' }}
</span>
</div>
</ElOption>
</ElSelect>
</ElFormItem>
<ElFormItem
v-if="createForm.order_type === 'device'"
@@ -200,7 +220,7 @@
<script setup lang="ts">
import { h } from 'vue'
import { useI18n } from 'vue-i18n'
import { OrderService } from '@/api/modules'
import { OrderService, CardService } from '@/api/modules'
import { ElMessage, ElMessageBox, ElTag } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import type {
@@ -211,7 +231,8 @@
OrderType,
BuyerType,
OrderPaymentMethod,
OrderCommissionStatus
OrderCommissionStatus,
StandaloneIotCard
} from '@/types/api'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
@@ -342,6 +363,35 @@
const orderList = ref<Order[]>([])
// IoT卡搜索相关
const iotCardOptions = ref<StandaloneIotCard[]>([])
const cardSearchLoading = ref(false)
// 搜索IoT卡根据ICCID
const searchIotCards = async (query: string) => {
if (!query) {
iotCardOptions.value = []
return
}
cardSearchLoading.value = true
try {
const res = await CardService.getStandaloneIotCards({
iccid: query,
page: 1,
page_size: 20
})
if (res.code === 0) {
iotCardOptions.value = res.data.items || []
}
} catch (error) {
console.error('Search IoT cards failed:', error)
iotCardOptions.value = []
} finally {
cardSearchLoading.value = false
}
}
// 格式化货币 - 将分转换为元
const formatCurrency = (amount: number): string => {
return `¥${(amount / 100).toFixed(2)}`
@@ -554,8 +604,29 @@
}
// 显示创建订单对话框
const showCreateDialog = () => {
const showCreateDialog = async () => {
createDialogVisible.value = true
// 默认加载20条IoT卡数据
await loadDefaultIotCards()
}
// 加载默认IoT卡列表
const loadDefaultIotCards = async () => {
cardSearchLoading.value = true
try {
const res = await CardService.getStandaloneIotCards({
page: 1,
page_size: 20
})
if (res.code === 0) {
iotCardOptions.value = res.data.items || []
}
} catch (error) {
console.error('Load default IoT cards failed:', error)
iotCardOptions.value = []
} finally {
cardSearchLoading.value = false
}
}
// 对话框关闭后的清理
@@ -568,6 +639,9 @@
createForm.package_ids = []
createForm.iot_card_id = null
createForm.device_id = null
// 清空IoT卡搜索结果
iotCardOptions.value = []
}
// 创建订单

View File

@@ -312,12 +312,51 @@
}
])
// 将扁平数据转换为树形结构
const buildTreeData = (flatData: Permission[]): Permission[] => {
const map = new Map<number, Permission>()
const result: Permission[] = []
// 先创建所有节点的映射
flatData.forEach((item) => {
map.set(item.ID, { ...item, children: [] })
})
// 构建树形结构
map.forEach((item) => {
if (item.parent_id && map.has(item.parent_id)) {
const parent = map.get(item.parent_id)!
if (!parent.children) {
parent.children = []
}
parent.children.push(item)
} else {
// 没有父节点的是根节点
result.push(item)
}
})
// 递归排序
const sortTree = (nodes: Permission[]): Permission[] => {
return nodes
.sort((a, b) => (a.sort || 0) - (b.sort || 0))
.map((node) => ({
...node,
children: node.children && node.children.length > 0 ? sortTree(node.children) : undefined
}))
}
return sortTree(result)
}
// 获取权限列表
const getPermissionList = async () => {
try {
const response = await PermissionService.getPermissions(searchForm)
if (response.code === 0) {
permissionList.value = response.data.items || []
const flatData = response.data.items || []
// 将扁平数据转换为树形结构
permissionList.value = buildTreeData(flatData)
// 构建权限树选项
buildPermissionTreeOptions()
}
@@ -332,7 +371,7 @@
return list.map((item) => ({
value: item.ID,
label: item.perm_name,
children: item.children ? buildTree(item.children) : undefined
children: item.children && item.children.length > 0 ? buildTree(item.children) : undefined
}))
}
permissionTreeOptions.value = buildTree(permissionList.value)

View File

@@ -89,24 +89,28 @@
<!-- 分配权限对话框 -->
<ElDialog v-model="permissionDialogVisible" title="分配权限" width="500px">
<ElCheckboxGroup v-model="selectedPermissions">
<div
v-for="permission in allPermissions"
:key="permission.ID"
style="margin-bottom: 12px"
<ElTree
ref="permissionTreeRef"
:data="permissionTreeData"
show-checkbox
node-key="id"
:default-checked-keys="selectedPermissions"
:props="{ children: 'children', label: 'label' }"
:default-expand-all="false"
class="permission-tree"
>
<ElCheckbox :label="permission.ID">
{{ permission.perm_name }}
<template #default="{ node, data }">
<span style="display: flex; align-items: center; gap: 8px">
<span>{{ node.label }}</span>
<ElTag
:type="permission.perm_type === 1 ? 'info' : 'success'"
:type="data.perm_type === 1 ? 'info' : 'success'"
size="small"
style="margin-left: 8px"
>
{{ permission.perm_type === 1 ? '菜单' : '按钮' }}
{{ data.perm_type === 1 ? '菜单' : '按钮' }}
</ElTag>
</ElCheckbox>
</div>
</ElCheckboxGroup>
</span>
</template>
</ElTree>
<template #footer>
<div class="dialog-footer">
<ElButton @click="permissionDialogVisible = false">取消</ElButton>
@@ -132,30 +136,34 @@
ElMessage,
ElMessageBox,
ElTag,
ElCheckbox,
ElCheckboxGroup,
ElTree,
ElSwitch
} from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import type { PlatformRole, Permission } from '@/types/api'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { formatDateTime } from '@/utils/business/format'
import { CommonStatus, getStatusText } from '@/config/constants'
defineOptions({ name: 'Role' })
const { hasAuth } = useAuth()
const dialogVisible = ref(false)
const permissionDialogVisible = ref(false)
const loading = ref(false)
const submitLoading = ref(false)
const permissionSubmitLoading = ref(false)
const tableRef = ref()
const permissionTreeRef = ref()
const currentRoleId = ref<number>(0)
const selectedPermissions = ref<number[]>([])
const originalPermissions = ref<number[]>([]) // 保存原始权限,用于对比
const allPermissions = ref<Permission[]>([])
const permissionTreeData = ref<any[]>([])
// 搜索表单初始值
const initialSearchState = {
@@ -272,20 +280,39 @@
width: 200,
fixed: 'right',
formatter: (row: any) => {
return h('div', { style: 'display: flex; gap: 8px;' }, [
const buttons = []
// 分配权限按钮
if (hasAuth('role:permission')) {
buttons.push(
h(ArtButtonTable, {
icon: '&#xe72b;',
onClick: () => showPermissionDialog(row)
}),
})
)
}
// 编辑按钮
if (hasAuth('role:edit')) {
buttons.push(
h(ArtButtonTable, {
type: 'edit',
onClick: () => showDialog('edit', row)
}),
})
)
}
// 删除按钮
if (hasAuth('role:delete')) {
buttons.push(
h(ArtButtonTable, {
type: 'delete',
onClick: () => deleteRole(row)
})
])
)
}
return h('div', { style: 'display: flex; gap: 8px;' }, buttons)
}
}
])
@@ -295,12 +322,59 @@
loadAllPermissions()
})
// 将扁平数据转换为树形结构
const buildTreeData = (flatData: Permission[]): any[] => {
const map = new Map<number, any>()
const result: any[] = []
// 先创建所有节点的映射
flatData.forEach((item) => {
map.set(item.ID, {
id: item.ID,
label: item.perm_name,
perm_type: item.perm_type,
children: []
})
})
// 构建树形结构
flatData.forEach((item) => {
const node = map.get(item.ID)!
if (item.parent_id && map.has(item.parent_id)) {
const parent = map.get(item.parent_id)!
parent.children.push(node)
} else {
// 没有父节点的是根节点
result.push(node)
}
})
// 递归排序和清理空children
const sortAndCleanTree = (nodes: any[]): any[] => {
return nodes
.sort((a, b) => {
const aItem = flatData.find((p) => p.ID === a.id)
const bItem = flatData.find((p) => p.ID === b.id)
return (aItem?.sort || 0) - (bItem?.sort || 0)
})
.map((node) => ({
...node,
children:
node.children && node.children.length > 0 ? sortAndCleanTree(node.children) : undefined
}))
}
return sortAndCleanTree(result)
}
// 加载所有权限列表
const loadAllPermissions = async () => {
try {
const res = await PermissionService.getPermissions({ page: 1, page_size: 1000 })
if (res.code === 0) {
allPermissions.value = res.data.items || []
// 构建树形数据
permissionTreeData.value = buildTreeData(allPermissions.value)
}
} catch (error) {
console.error('获取权限列表失败:', error)
@@ -343,14 +417,21 @@
// 提交分配权限
const handleAssignPermissions = async () => {
if (!permissionTreeRef.value) return
permissionSubmitLoading.value = true
try {
// 获取选中的节点(包括半选中的父节点)
const checkedKeys = permissionTreeRef.value.getCheckedKeys()
const halfCheckedKeys = permissionTreeRef.value.getHalfCheckedKeys()
const currentPermissions = [...checkedKeys, ...halfCheckedKeys]
// 对比原始权限和当前选中的权限,找出需要新增和移除的权限
const addedPermissions = selectedPermissions.value.filter(
const addedPermissions = currentPermissions.filter(
(id) => !originalPermissions.value.includes(id)
)
const removedPermissions = originalPermissions.value.filter(
(id) => !selectedPermissions.value.includes(id)
(id) => !currentPermissions.includes(id)
)
// 使用 Promise.all 并发执行新增和移除操作
@@ -532,3 +613,16 @@
}
}
</script>
<style scoped lang="scss">
.permission-tree {
:deep(.el-tree-node) {
margin: 6px 0;
}
:deep(.el-tree-node__content) {
height: 36px;
line-height: 36px;
}
}
</style>

View File

@@ -32,8 +32,9 @@ export default ({ mode }) => {
},
// 对象存储代理 - 解决开发环境 CORS 问题
'/obs-proxy': {
target: 'http://obs-helf.cucloud.cn',
target: 'https://obs-helf.cucloud.cn',
changeOrigin: true,
secure: false,
rewrite: (path) => path.replace(/^\/obs-proxy/, '')
}
},