fetch(modify):完善设备管理
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 2m47s

This commit is contained in:
sexygoat
2026-02-02 10:27:03 +08:00
parent 882feaf3ff
commit e08c962c40
4 changed files with 428 additions and 84 deletions

View File

@@ -109,6 +109,23 @@ export const useUserStore = defineStore(
}
}
// 清理本地状态(不调用退出接口)
const clearLocalState = () => {
info.value = {}
isLogin.value = false
isLock.value = false
lockPassword.value = ''
accessToken.value = ''
refreshToken.value = ''
permissions.value = []
menus.value = []
buttons.value = []
useWorktabStore().opened = []
sessionStorage.removeItem('iframeRoutes')
resetRouterState(router)
router.push(RoutesAlias.Login)
}
const logOut = async () => {
try {
// 调用退出登录接口
@@ -117,19 +134,7 @@ export const useUserStore = defineStore(
console.error('退出登录接口调用失败:', error)
} finally {
// 无论接口成功与否,都清理本地状态
info.value = {}
isLogin.value = false
isLock.value = false
lockPassword.value = ''
accessToken.value = ''
refreshToken.value = ''
permissions.value = []
menus.value = []
buttons.value = []
useWorktabStore().opened = []
sessionStorage.removeItem('iframeRoutes')
resetRouterState(router)
router.push(RoutesAlias.Login)
clearLocalState()
}
}
@@ -166,6 +171,7 @@ export const useUserStore = defineStore(
setLockStatus,
setLockPassword,
setToken,
clearLocalState,
logOut
}
},

View File

@@ -93,9 +93,9 @@ axiosInstance.interceptors.response.use(
const userStore = useUserStore()
const originalRequest = response.config as any
// 如果没有 refreshToken直接退出登录
// 如果没有 refreshToken直接清理本地状态(不调用退出接口)
if (!userStore.refreshToken) {
logOut()
clearLocalStateAndRedirect()
return Promise.reject(response)
}
@@ -140,16 +140,16 @@ axiosInstance.interceptors.response.use(
// 重试原请求
resolve(axiosInstance.request(originalRequest))
} else {
// 刷新失败
// 刷新失败 - 清理本地状态(不调用退出接口)
processQueue(new Error('Token refresh failed'), null)
logOut()
clearLocalStateAndRedirect()
reject(res)
}
})
.catch((err) => {
// 刷新失败
// 刷新失败 - 清理本地状态(不调用退出接口)
processQueue(err, null)
logOut()
clearLocalStateAndRedirect()
reject(err)
})
.finally(() => {
@@ -162,7 +162,16 @@ axiosInstance.interceptors.response.use(
(error) => {
if (axios.isCancel(error)) {
console.log('repeated request: ' + error.message)
return Promise.reject(error)
}
// 处理 HTTP 401 状态码 - 直接清理本地状态(不调用退出接口)
if (error.response && error.response.status === 401) {
console.warn('HTTP 401 Unauthorized - 强制退出登录')
clearLocalStateAndRedirect()
return Promise.reject(error)
}
// 注意错误处理现在在request函数中根据requestOptions处理
return Promise.reject(error)
}
@@ -270,7 +279,7 @@ const api = {
}
}
// 退出登录
// 退出登录(调用接口)
const logOut = () => {
ElMessage.error('登录已过期,请重新登录')
setTimeout(() => {
@@ -278,4 +287,12 @@ const logOut = () => {
}, 1000)
}
// 仅清理本地状态(不调用接口)- 用于401等情况
const clearLocalStateAndRedirect = () => {
ElMessage.error('登录已过期,请重新登录')
setTimeout(() => {
useUserStore().clearLocalState()
}, 1000)
}
export default api

View File

@@ -65,19 +65,16 @@
<span style="font-weight: bold; color: #409eff">{{ selectedDevices.length }}</span>
</ElFormItem>
<ElFormItem label="目标店铺" prop="target_shop_id">
<ElSelect
<ElTreeSelect
v-model="allocateForm.target_shop_id"
:data="shopTreeData"
:props="{ label: 'shop_name', value: 'id', children: 'children' }"
placeholder="请选择目标店铺"
clearable
filterable
check-strictly
style="width: 100%"
>
<ElOption
v-for="shop in shopList"
:key="shop.id"
:label="shop.name"
:value="shop.id"
/>
</ElSelect>
/>
</ElFormItem>
<ElFormItem label="备注">
<ElInput
@@ -204,9 +201,14 @@
<ElFormItem label="套餐系列分配" prop="series_allocation_id">
<ElSelect
v-model="seriesBindingForm.series_allocation_id"
placeholder="请选择套餐系列分配(选择清除关联将解除绑定"
placeholder="请选择或搜索套餐系列分配(支持系列名称搜索"
style="width: 100%"
filterable
remote
reserve-keyword
:remote-method="searchSeriesAllocations"
:loading="seriesLoading"
clearable
>
<ElOption label="清除关联" :value="0" />
<ElOption
@@ -264,49 +266,133 @@
</ElDialog>
<!-- 设备详情弹窗 -->
<ElDialog v-model="deviceDetailDialogVisible" title="设备详情" width="900px">
<ElDialog v-model="deviceDetailDialogVisible" title="设备详情" width="1000px">
<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>
<div v-else-if="currentDeviceDetail">
<!-- 设备基本信息 -->
<ElDescriptions :column="3" border style="margin-bottom: 20px">
<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.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="制造商">{{
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="批次号" :span="2">{{
currentDeviceDetail.batch_no || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="状态">
<ElTag :type="getDeviceStatusTagType(currentDeviceDetail.status)">
{{ currentDeviceDetail.status_name }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="批次号" :span="2">{{
currentDeviceDetail.batch_no || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间" :span="3">{{
currentDeviceDetail.created_at || '--'
}}</ElDescriptionsItem>
</ElDescriptions>
<ElDescriptionsItem label="创建时间" :span="3">{{
formatDateTime(currentDeviceDetail.created_at) || '--'
}}</ElDescriptionsItem>
</ElDescriptions>
</div>
</ElDialog>
<!-- 绑定卡片列表弹窗 -->
<ElDialog v-model="deviceCardsDialogVisible" title="绑定的卡片" width="900px">
<div style="margin-bottom: 10px; text-align: right">
<ElButton type="primary" size="small" @click="handleBindCard">绑定新卡</ElButton>
</div>
<div v-if="deviceCardsLoading" style="text-align: center; padding: 40px 0">
<ElIcon class="is-loading" :size="40"><Loading /></ElIcon>
</div>
<div v-else>
<ElTable :data="deviceCards" border style="width: 100%">
<ElTableColumn prop="slot_position" label="插槽位置" width="100" align="center" />
<ElTableColumn prop="iccid" label="ICCID" min-width="180" />
<ElTableColumn prop="msisdn" label="接入号" width="140" />
<ElTableColumn prop="status" label="状态" width="100" align="center">
<template #default="{ row }">
<ElTag :type="getCardStatusTagType(row.status)">
{{ getCardStatusText(row.status) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="bind_time" label="绑定时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.bind_time) || '--' }}
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="100" fixed="right" align="center">
<template #default="{ row }">
<ElButton type="danger" size="small" link @click="handleUnbindCard(row)">
解绑
</ElButton>
</template>
</ElTableColumn>
</ElTable>
<div v-if="deviceCards.length === 0" style="text-align: center; padding: 20px; color: #909399">
暂无绑定的卡片
</div>
</div>
</ElDialog>
<!-- 绑定卡弹窗 -->
<ElDialog v-model="bindCardDialogVisible" title="绑定卡到设备" width="500px">
<ElForm ref="bindCardFormRef" :model="bindCardForm" :rules="bindCardRules" label-width="100px">
<ElFormItem label="IoT卡" prop="iot_card_id">
<ElSelect
v-model="bindCardForm.iot_card_id"
placeholder="请选择或搜索IoT卡支持ICCID搜索"
filterable
remote
reserve-keyword
:remote-method="searchIotCards"
:loading="iotCardSearchLoading"
clearable
style="width: 100%"
>
<ElOption
v-for="card in iotCardList"
:key="card.id"
:label="`${card.iccid} ${card.msisdn ? '(' + card.msisdn + ')' : ''}`"
:value="card.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="插槽位置" prop="slot_position">
<ElSelect v-model="bindCardForm.slot_position" placeholder="请选择插槽位置" style="width: 100%">
<ElOption
v-for="i in currentDeviceDetail?.max_sim_slots || 4"
:key="i"
:label="`插槽 ${i}`"
:value="i"
/>
</ElSelect>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="bindCardDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleConfirmBindCard" :loading="bindCardLoading">
确认绑定
</ElButton>
</template>
</ElDialog>
</ElCard>
</div>
@@ -316,9 +402,9 @@
<script setup lang="ts">
import { h } from 'vue'
import { useRouter } from 'vue-router'
import { DeviceService, ShopService } from '@/api/modules'
import { DeviceService, ShopService, CardService } from '@/api/modules'
import { ShopSeriesAllocationService } from '@/api/modules/shopSeriesAllocation'
import { ElMessage, ElMessageBox, ElTag, ElSwitch, ElIcon } from 'element-plus'
import { ElMessage, ElMessageBox, ElTag, ElSwitch, ElIcon, ElTreeSelect } from 'element-plus'
import { Loading } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import type {
@@ -347,7 +433,7 @@
const allocateDialogVisible = ref(false)
const recallDialogVisible = ref(false)
const selectedDevices = ref<Device[]>([])
const shopList = ref<any[]>([])
const shopTreeData = ref<any[]>([])
const allocateResult = ref<AllocateDevicesResponse | null>(null)
const recallResult = ref<RecallDevicesResponse | null>(null)
@@ -369,6 +455,24 @@
const deviceDetailDialogVisible = ref(false)
const deviceDetailLoading = ref(false)
const currentDeviceDetail = ref<any>(null)
const deviceCards = ref<any[]>([])
const deviceCardsLoading = ref(false)
const deviceCardsDialogVisible = ref(false)
// 绑定卡相关
const bindCardDialogVisible = ref(false)
const bindCardLoading = ref(false)
const bindCardFormRef = ref<FormInstance>()
const iotCardList = ref<any[]>([])
const iotCardSearchLoading = ref(false)
const bindCardForm = reactive({
iot_card_id: undefined as number | undefined,
slot_position: 1
})
const bindCardRules = reactive<FormRules>({
iot_card_id: [{ required: true, message: '请选择IoT卡', trigger: 'change' }],
slot_position: [{ required: true, message: '请选择插槽位置', trigger: 'change' }]
})
// 搜索表单初始值
const initialSearchState = {
@@ -511,6 +615,173 @@
}
}
// 查看设备绑定的卡片
const handleViewCards = async (device: Device) => {
currentDeviceDetail.value = device
deviceCards.value = []
deviceCardsDialogVisible.value = true
await loadDeviceCards(device.id)
}
// 加载设备绑定的卡列表
const loadDeviceCards = async (deviceId: number) => {
deviceCardsLoading.value = true
try {
const res = await DeviceService.getDeviceCards(deviceId)
if (res.code === 0 && res.data) {
deviceCards.value = res.data.bindings || []
}
} catch (error) {
console.error('获取设备绑定的卡列表失败:', error)
} finally {
deviceCardsLoading.value = false
}
}
// 打开绑定卡弹窗
const handleBindCard = async () => {
bindCardForm.iot_card_id = undefined
bindCardForm.slot_position = 1
bindCardDialogVisible.value = true
// 加载默认的IoT卡列表
await loadDefaultIotCards()
}
// 加载默认的IoT卡列表
const loadDefaultIotCards = async () => {
iotCardSearchLoading.value = true
try {
const res = await CardService.getStandaloneIotCards({
page: 1,
page_size: 20
})
if (res.code === 0 && res.data?.items) {
iotCardList.value = res.data.items
}
} catch (error) {
console.error('获取IoT卡列表失败:', error)
} finally {
iotCardSearchLoading.value = false
}
}
// 搜索IoT卡根据ICCID
const searchIotCards = async (query: string) => {
if (!query) {
await loadDefaultIotCards()
return
}
iotCardSearchLoading.value = true
try {
const res = await CardService.getStandaloneIotCards({
page: 1,
page_size: 20,
iccid: query
})
if (res.code === 0 && res.data?.items) {
iotCardList.value = res.data.items
}
} catch (error) {
console.error('搜索IoT卡失败:', error)
} finally {
iotCardSearchLoading.value = false
}
}
// 确认绑定卡
const handleConfirmBindCard = async () => {
if (!bindCardFormRef.value || !currentDeviceDetail.value) return
await bindCardFormRef.value.validate(async (valid) => {
if (valid) {
bindCardLoading.value = true
try {
const res = await DeviceService.bindCard(currentDeviceDetail.value.id, {
iot_card_id: bindCardForm.iot_card_id!,
slot_position: bindCardForm.slot_position
})
if (res.code === 0) {
ElMessage.success('绑定成功')
bindCardDialogVisible.value = false
// 重新加载卡列表
await loadDeviceCards(currentDeviceDetail.value.id)
// 刷新设备详情以更新绑定卡数量
const detailRes = await DeviceService.getDeviceByImei(currentDeviceDetail.value.device_no)
if (detailRes.code === 0 && detailRes.data) {
currentDeviceDetail.value = detailRes.data
}
} else {
ElMessage.error(res.message || '绑定失败')
}
} catch (error: any) {
console.error('绑定卡失败:', error)
ElMessage.error(error?.message || '绑定失败')
} finally {
bindCardLoading.value = false
}
}
})
}
// 解绑卡
const handleUnbindCard = (row: any) => {
ElMessageBox.confirm(`确定要解绑 ICCID: ${row.iccid} 吗?`, '解绑确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
try {
const res = await DeviceService.unbindCard(
currentDeviceDetail.value.id,
row.iot_card_id
)
if (res.code === 0) {
ElMessage.success('解绑成功')
// 重新加载卡列表
await loadDeviceCards(currentDeviceDetail.value.id)
// 刷新设备详情以更新绑定卡数量
const detailRes = await DeviceService.getDeviceByImei(
currentDeviceDetail.value.device_no
)
if (detailRes.code === 0 && detailRes.data) {
currentDeviceDetail.value = detailRes.data
}
} else {
ElMessage.error(res.message || '解绑失败')
}
} catch (error: any) {
console.error('解绑卡失败:', error)
ElMessage.error(error?.message || '解绑失败')
}
})
.catch(() => {
// 用户取消
})
}
// 获取卡状态标签类型
const getCardStatusTagType = (status: number) => {
const typeMap: Record<number, any> = {
1: 'info', // 在库
2: 'warning', // 已分销
3: 'success', // 已激活
4: 'danger' // 已停用
}
return typeMap[status] || 'info'
}
// 获取卡状态文本
const getCardStatusText = (status: number) => {
const textMap: Record<number, string> = {
1: '在库',
2: '已分销',
3: '已激活',
4: '已停用'
}
return textMap[status] || '未知'
}
// 获取设备状态标签类型
const getDeviceStatusTagType = (status: number) => {
const typeMap: Record<number, any> = {
@@ -610,10 +881,14 @@
{
prop: 'operation',
label: '操作',
width: 100,
width: 180,
fixed: 'right',
formatter: (row: Device) => {
return h('div', { style: 'display: flex; gap: 8px;' }, [
h(ArtButtonTable, {
text: '查看卡片',
onClick: () => handleViewCards(row)
}),
h(ArtButtonTable, {
type: 'delete',
onClick: () => deleteDevice(row)
@@ -625,21 +900,57 @@
onMounted(() => {
getTableData()
loadShopList()
loadShopTree()
})
// 加载店铺列表
const loadShopList = async () => {
// 当页面被 keep-alive 激活时自动刷新数据
onActivated(() => {
getTableData()
})
// 加载店铺树形数据
const loadShopTree = async () => {
try {
const res = await ShopService.getShops({ page: 1, pageSize: 1000 })
const res = await ShopService.getShops({
page: 1,
page_size: 9999, // 获取所有数据用于构建树形结构
status: 1 // 只获取启用的店铺
})
if (res.code === 0) {
shopList.value = res.data.items || []
shopTreeData.value = buildShopTree(res.data.items || [])
}
} catch (error) {
console.error('获取店铺列表失败:', error)
}
}
// 构建店铺树形结构
const buildShopTree = (shops: any[]) => {
const map = new Map<number, any>()
const tree: any[] = []
// 先将所有项放入 map
shops.forEach((shop) => {
map.set(shop.id, { ...shop, children: [] })
})
// 构建树形结构
shops.forEach((shop) => {
const node = map.get(shop.id)!
if (shop.parent_id && map.has(shop.parent_id)) {
// 有父节点,添加到父节点的 children 中
const parent = map.get(shop.parent_id)!
if (!parent.children) parent.children = []
parent.children.push(node)
} else {
// 没有父节点或父节点不存在,作为根节点
tree.push(node)
}
})
return tree
}
// 获取设备列表
const getTableData = async () => {
loading.value = true
@@ -724,7 +1035,7 @@
}
// 批量分配
const handleBatchAllocate = () => {
const handleBatchAllocate = async () => {
if (selectedDevices.value.length === 0) {
ElMessage.warning('请先选择要分配的设备')
return
@@ -839,16 +1150,21 @@
seriesBindingDialogVisible.value = true
}
// 加载套餐系列分配列表
const loadSeriesAllocationList = async () => {
// 加载套餐系列分配列表默认加载20条
const loadSeriesAllocationList = async (seriesName?: string) => {
seriesLoading.value = true
try {
const res = await ShopSeriesAllocationService.getShopSeriesAllocations({
const params: any = {
page: 1,
page_size: 1000
})
if (res.code === 0 && res.data.list) {
seriesAllocationList.value = res.data.list
page_size: 20,
status: 1 // 只获取启用的
}
if (seriesName) {
params.series_name = seriesName
}
const res = await ShopSeriesAllocationService.getShopSeriesAllocations(params)
if (res.code === 0 && res.data.items) {
seriesAllocationList.value = res.data.items
}
} catch (error) {
console.error('获取套餐系列分配列表失败:', error)
@@ -858,6 +1174,11 @@
}
}
// 搜索套餐系列分配(根据系列名称)
const searchSeriesAllocations = async (query: string) => {
await loadSeriesAllocationList(query || undefined)
}
// 确认设置套餐系列绑定
const handleConfirmSeriesBinding = async () => {
if (!seriesBindingFormRef.value) return

View File

@@ -232,7 +232,7 @@
{
prop: 'perm_code',
label: '权限标识',
width: 200
width: 240
},
{
prop: 'perm_type',