fetch(modify):修改bug
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m20s
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m20s
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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(最大插槽数,默认4)、iccid_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')
|
||||
|
||||
@@ -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: '',
|
||||
disabled: selectedCards.value.length === 0
|
||||
},
|
||||
{
|
||||
key: 'distribution',
|
||||
label: '网卡分销',
|
||||
icon: ''
|
||||
},
|
||||
{
|
||||
key: 'recharge',
|
||||
label: '批量充值',
|
||||
icon: ''
|
||||
},
|
||||
{
|
||||
key: 'recycle',
|
||||
label: '网卡回收',
|
||||
icon: ''
|
||||
},
|
||||
{
|
||||
key: 'download',
|
||||
label: '批量下载',
|
||||
icon: ''
|
||||
},
|
||||
{
|
||||
key: 'changePackage',
|
||||
label: '变更套餐',
|
||||
icon: ''
|
||||
}
|
||||
])
|
||||
|
||||
// 显示更多操作菜单
|
||||
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('功能正在开发中')
|
||||
|
||||
@@ -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. 必填字段:iccid(ICCID)、msisdn(MSISDN/手机号)</p>
|
||||
<p>1. 请先下载 Excel 模板文件,按照模板格式填写IoT卡信息</p>
|
||||
<p>2. 仅支持 Excel 格式(.xlsx),单次最多导入 1000 条</p>
|
||||
<p>3. 列格式请设置为文本格式,避免长数字被转为科学计数法</p>
|
||||
<p>4. 必填字段:ICCID、MSISDN(手机号)</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({
|
||||
|
||||
@@ -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: '店铺编码',
|
||||
|
||||
@@ -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 = []
|
||||
}
|
||||
|
||||
// 创建订单
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: '',
|
||||
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: '',
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user