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

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)
ElMessage.success('设备导入模板下载成功')
// 设置列宽
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)
ElMessage.success('IoT卡导入模板下载成功')
// 设置列宽
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"
>
<ElCheckbox :label="permission.ID">
{{ permission.perm_name }}
<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"
>
<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;' }, [
h(ArtButtonTable, {
icon: '&#xe72b;',
onClick: () => showPermissionDialog(row)
}),
h(ArtButtonTable, {
type: 'edit',
onClick: () => showDialog('edit', row)
}),
h(ArtButtonTable, {
type: 'delete',
onClick: () => deleteRole(row)
})
])
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>