Files
one-pipe-system/src/views/asset-management/phone-asset-association/index.vue
luo 08889d2e42
Some checks failed
构建并部署前端到测试环境 / build-and-deploy (push) Failing after 28m40s
fix: some
2026-09-18 18:11:53 +08:00

750 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<ArtTableFullScreen>
<div class="phone-asset-association-page" id="table-full-screen">
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
:show-expand="false"
@reset="handleReset"
@search="handleSearch"
/>
<ElCard shadow="never" class="art-table-card">
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<ElButton
type="danger"
plain
:disabled="selectedRows.length === 0"
@click="openBatchUnbindDialog"
v-if="canBatchUnbind"
>
批量解绑
</ElButton>
<ElButton type="primary" plain @click="openImportDialog" v-if="canImportCreate">
CSV解绑导入
</ElButton>
<ElButton @click="goUnbindImportTasks" v-if="canImportPage">解绑导入任务</ElButton>
</template>
</ArtTableHeader>
<ArtTable
:loading="loading"
:data="associationList"
:currentPage="pagination.page"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
:actions="getActions"
:actionsWidth="100"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
@selection-change="handleSelectionChange"
>
<template #default>
<ElTableColumn type="selection" width="55" />
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</ElCard>
</div>
<!-- 单条解绑 -->
<ElDialog
v-model="unbindDialogVisible"
title="解除手机号资产关联"
width="480px"
:close-on-click-modal="false"
>
<ElForm ref="unbindFormRef" :model="unbindForm" :rules="unbindRules" label-width="90px">
<ElFormItem label="资产标识">
<span>{{ unbindForm.asset_identifier || '-' }}</span>
</ElFormItem>
<ElFormItem label="手机号">
<span>{{ unbindForm.phone || '-' }}</span>
</ElFormItem>
<ElFormItem label="解除原因" prop="reason">
<ElInput
v-model="unbindForm.reason"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请填写解除原因1~500 字符)"
/>
</ElFormItem>
<ElFormItem label="二次确认" prop="confirmed">
<ElCheckbox v-model="unbindForm.confirmed">我已确认解除该关联关系</ElCheckbox>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="unbindDialogVisible = false">取消</ElButton>
<ElButton type="danger" :loading="unbindLoading" @click="confirmUnbind">确认解绑</ElButton>
</template>
</ElDialog>
<!-- 批量解绑 -->
<ElDialog
v-model="batchUnbindDialogVisible"
title="批量解除手机号资产关联"
width="520px"
:close-on-click-modal="false"
>
<ElAlert
type="warning"
:closable="false"
:title="`已选择 ${selectedRows.length} 项资产按资产类型与ID去重后执行上限 200`"
style="margin-bottom: 12px"
/>
<ElForm ref="batchFormRef" :model="batchForm" :rules="batchRules" label-width="90px">
<ElFormItem label="解除原因" prop="reason">
<ElInput
v-model="batchForm.reason"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请填写解除原因1~500 字符)"
/>
</ElFormItem>
<ElFormItem label="二次确认" prop="confirmed">
<ElCheckbox v-model="batchForm.confirmed">我已确认解除上述全部关联关系</ElCheckbox>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="batchUnbindDialogVisible = false">取消</ElButton>
<ElButton type="danger" :loading="batchUnbindLoading" @click="confirmBatchUnbind">
确认批量解绑
</ElButton>
</template>
</ElDialog>
<!-- 批量解绑结果 -->
<ElDialog v-model="batchResultVisible" title="批量解绑结果" width="560px">
<div class="batch-result-summary">
<span>成功 {{ batchResult?.success_count ?? 0 }} </span>
<span class="text-danger">失败 {{ batchResult?.fail_count ?? 0 }} </span>
</div>
<ElTable :data="batchResult?.items || []" max-height="320" size="small" border>
<ElTableColumn prop="asset_type" label="资产类型" width="110" />
<ElTableColumn prop="asset_id" label="资产ID" width="90" />
<ElTableColumn prop="success" label="结果" width="80" />
<ElTableColumn prop="unbound_count" label="解除关系数" width="100" />
<ElTableColumn prop="reason" label="失败原因" show-overflow-tooltip />
</ElTable>
<template #footer>
<ElButton type="primary" @click="batchResultVisible = false">关闭</ElButton>
</template>
</ElDialog>
<!-- CSV 解绑导入 -->
<ElDialog
v-model="importDialogVisible"
title="CSV 解绑导入"
width="520px"
:close-on-click-modal="false"
@closed="handleImportDialogClosed"
>
<div class="import-tips">
<span>模板表头</span>
<ElButton link type="primary" @click="downloadTemplate">下载模板</ElButton>
<div class="import-tips-detail">
资产标识,备注备注可选支持 UTF-8可带 BOM UTF-8 GBK 解码无行数上限
</div>
</div>
<ElForm ref="importFormRef" :model="importForm" :rules="importRules" label-width="90px">
<ElFormItem label="CSV 文件" prop="file">
<ElUpload
ref="uploadRef"
drag
:auto-upload="false"
:limit="1"
accept=".csv"
:on-change="handleFileChange"
:on-exceed="handleFileExceed"
>
<ElIcon class="el-icon--upload"><UploadFilled /></ElIcon>
<div class="el-upload__text">拖拽文件到此处<em>点击选择 .csv 文件</em></div>
</ElUpload>
</ElFormItem>
<ElFormItem label="解除原因" prop="reason">
<ElInput
v-model="importForm.reason"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请填写任务级解除原因1~500 字符)"
/>
</ElFormItem>
<ElFormItem label="二次确认" prop="confirmed">
<ElCheckbox v-model="importForm.confirmed">我已确认按模板批量解除关联关系</ElCheckbox>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="importDialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="importLoading" @click="confirmImport"
>创建导入任务</ElButton
>
</template>
</ElDialog>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, h, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import {
ElMessage,
ElTag,
FormInstance,
FormRules,
UploadFile,
UploadInstance
} from 'element-plus'
import { UploadFilled } from '@element-plus/icons-vue'
import { PhoneAssetAssociationService, StorageService } from '@/api/modules'
import { useAuth } from '@/composables/useAuth'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { formatDateTime } from '@/utils/business/format'
import { normalizeApiError } from '@/utils/business/apiError'
import { RoutesAlias } from '@/router/routesAlias'
import { AUGUST_PERMISSIONS } from '@/config/constants'
import type { SearchFormItem } from '@/types'
import type {
BatchUnbindAssetItem,
BatchUnbindPhoneAssetAssociationResponse,
PhoneAssetAssociation,
PhoneAssetAssociationStatus
} from '@/types/api'
defineOptions({ name: 'PhoneAssetAssociation' })
const router = useRouter()
const { hasAuth } = useAuth()
const perms = AUGUST_PERMISSIONS.phoneAssetAssociation
const canBatchUnbind = computed(() => hasAuth(perms.unbindBatch))
const canImportCreate = computed(() => hasAuth(perms.unbindImportCreate))
const canImportPage = computed(() => hasAuth(perms.unbindImportPage))
const canUnbind = computed(() => hasAuth(perms.unbind))
const loading = ref(false)
const associationList = ref<PhoneAssetAssociation[]>([])
const selectedRows = ref<PhoneAssetAssociation[]>([])
const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
const initialSearchState = {
asset_identifier: '',
phone: '',
status: undefined as PhoneAssetAssociationStatus | undefined,
dateRange: [] as string[],
created_at_start: '',
created_at_end: ''
}
const searchForm = reactive({ ...initialSearchState })
const statusOptions = [
{ label: '有效', value: 1 as const },
{ label: '已失效', value: 0 as const }
]
const searchFormItems: SearchFormItem[] = [
{
label: '资产标识',
prop: 'asset_identifier',
type: 'input',
config: { clearable: true, placeholder: 'ICCID / 虚拟号 / IMEI / SN / 接入号(精确匹配)' }
},
{
label: '手机号',
prop: 'phone',
type: 'input',
config: { clearable: true, placeholder: '完整手机号(精确匹配)' }
},
{
label: '关联状态',
prop: 'status',
type: 'select',
config: { clearable: true, placeholder: '全部' },
options: () => statusOptions
},
{
label: '创建时间',
prop: 'dateRange',
type: 'datetimerange',
config: {
type: 'datetimerange',
rangeSeparator: '至',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间',
valueFormat: 'YYYY-MM-DDTHH:mm:ssZ'
}
}
]
const columnOptions = [
{ label: '资产类型', prop: 'asset_type' },
{ label: '资产标识', prop: 'asset_identifier' },
{ label: '手机号', prop: 'phone' },
{ label: '建立时间', prop: 'established_at' },
{ label: '来源', prop: 'source' },
{ label: '状态', prop: 'status' },
{ label: '失效时间', prop: 'invalidated_at' },
{ label: '失效方式', prop: 'invalidation_method_name' },
{ label: '失效原因', prop: 'invalidation_reason' }
]
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'asset_type',
label: '资产类型',
width: 100,
formatter: (row: PhoneAssetAssociation) =>
row.asset_type === 'iot_card' ? '物联网卡' : '设备'
},
{
prop: 'asset_identifier',
label: '资产标识',
minWidth: 200,
showOverflowTooltip: true,
formatter: (row: PhoneAssetAssociation) =>
h(
'span',
{
style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;',
onClick: () => goToAsset(row)
},
row.asset_identifier
)
},
{ prop: 'phone', label: '手机号', width: 140 },
{
prop: 'established_at',
label: '建立时间',
minWidth: 170,
formatter: (row: PhoneAssetAssociation) => formatDateTime(row.established_at) || '-'
},
{
prop: 'source',
label: '来源',
width: 120,
formatter: () => 'H5 短信验证'
},
{
prop: 'status',
label: '状态',
width: 90,
formatter: (row: PhoneAssetAssociation) =>
h(
ElTag,
{ type: row.status === 1 ? 'success' : 'info' },
() => row.status_name || (row.status === 1 ? '有效' : '已失效')
)
},
{
prop: 'invalidated_at',
label: '失效时间',
minWidth: 170,
formatter: (row: PhoneAssetAssociation) => formatDateTime(row.invalidated_at) || '-'
},
{
prop: 'invalidation_method_name',
label: '失效方式',
width: 130,
formatter: (row: PhoneAssetAssociation) => row.invalidation_method_name || '-'
},
{
prop: 'invalidation_reason',
label: '失效原因',
minWidth: 160,
showOverflowTooltip: true,
formatter: (row: PhoneAssetAssociation) => row.invalidation_reason || '-'
}
])
// ========== 列表数据 ==========
const syncDateRange = () => {
if (Array.isArray(searchForm.dateRange) && searchForm.dateRange.length === 2) {
searchForm.created_at_start = searchForm.dateRange[0] || ''
searchForm.created_at_end = searchForm.dateRange[1] || ''
} else {
searchForm.created_at_start = ''
searchForm.created_at_end = ''
}
}
const toRfc3339 = (date: string, endOfDay = false) => {
if (!date) return undefined
if (date.includes('T')) return date
return `${date}T${endOfDay ? '23:59:59' : '00:00:00'}+08:00`
}
const getTableData = async () => {
loading.value = true
try {
syncDateRange()
const res = await PhoneAssetAssociationService.getAssociations({
page: pagination.page,
page_size: pagination.pageSize,
asset_identifier: searchForm.asset_identifier || undefined,
phone: searchForm.phone || undefined,
status: searchForm.status,
created_at_start: toRfc3339(searchForm.created_at_start),
created_at_end: toRfc3339(searchForm.created_at_end, true)
})
if (res.code === 0 && res.data) {
associationList.value = res.data.items || []
pagination.total = res.data.total || 0
} else {
ElMessage.error(res.msg || '获取手机号资产关联列表失败')
}
} catch (error) {
console.error('获取手机号资产关联列表失败:', error)
ElMessage.error(normalizeApiError(error).message || '获取手机号资产关联列表失败')
} finally {
loading.value = false
}
}
const handleReset = () => {
Object.assign(searchForm, { ...initialSearchState })
pagination.page = 1
getTableData()
}
const handleSearch = () => {
pagination.page = 1
getTableData()
}
const handleRefresh = () => {
getTableData()
}
const handleSizeChange = (pageSize: number) => {
pagination.pageSize = pageSize
getTableData()
}
const handleCurrentChange = (page: number) => {
pagination.page = page
getTableData()
}
const handleSelectionChange = (rows: PhoneAssetAssociation[]) => {
selectedRows.value = rows
}
const goToAsset = (row: PhoneAssetAssociation) => {
router.push({
path: RoutesAlias.AssetInformation,
query:
row.asset_type === 'iot_card'
? { iccid: row.asset_identifier }
: { virtual_no: row.asset_identifier }
})
}
const getActions = (row: PhoneAssetAssociation) => {
const actions: any[] = []
if (row.status === 1 && canUnbind.value) {
actions.push({ label: '解绑', handler: () => openUnbindDialog(row), type: 'danger' })
}
return actions
}
// ========== 单条解绑 ==========
const unbindDialogVisible = ref(false)
const unbindLoading = ref(false)
const unbindFormRef = ref<FormInstance>()
const unbindForm = reactive<{
id: number
asset_identifier: string
phone: string
reason: string
confirmed: boolean
}>({
id: 0,
asset_identifier: '',
phone: '',
reason: '',
confirmed: false
})
const unbindRules: FormRules = {
reason: [
{ required: true, message: '请填写解除原因', trigger: 'blur' },
{ min: 1, max: 500, message: '解除原因长度为 1~500 字符', trigger: 'blur' }
],
confirmed: [
{
validator: (_rule, value: boolean, callback) => {
if (!value) callback(new Error('请先勾选二次确认'))
else callback()
},
trigger: 'change'
}
]
}
const openUnbindDialog = (row: PhoneAssetAssociation) => {
unbindForm.id = row.id
unbindForm.asset_identifier = row.asset_identifier
unbindForm.phone = row.phone
unbindForm.reason = ''
unbindForm.confirmed = false
unbindDialogVisible.value = true
}
const confirmUnbind = async () => {
if (!(await unbindFormRef.value?.validate().catch(() => false))) return
unbindLoading.value = true
try {
const res = await PhoneAssetAssociationService.unbindAssociation(
unbindForm.id,
unbindForm.reason.trim(),
unbindForm.confirmed
)
if (res.code !== 0) {
ElMessage.error(res.msg || '解除关联失败')
return
}
ElMessage.success('解除关联成功')
unbindDialogVisible.value = false
getTableData()
} catch (error) {
console.error('解除关联失败:', error)
ElMessage.error(normalizeApiError(error).message || '解除关联失败')
} finally {
unbindLoading.value = false
}
}
// ========== 批量解绑 ==========
const batchUnbindDialogVisible = ref(false)
const batchUnbindLoading = ref(false)
const batchFormRef = ref<FormInstance>()
const batchForm = reactive({ reason: '', confirmed: false, assets: [] as BatchUnbindAssetItem[] })
const batchResult = ref<BatchUnbindPhoneAssetAssociationResponse | null>(null)
const batchResultVisible = ref(false)
const batchRules: FormRules = {
reason: [
{ required: true, message: '请填写解除原因', trigger: 'blur' },
{ min: 1, max: 500, message: '解除原因长度为 1~500 字符', trigger: 'blur' }
],
confirmed: [
{
validator: (_rule, value: boolean, callback) => {
if (!value) callback(new Error('请先勾选二次确认'))
else callback()
},
trigger: 'change'
}
]
}
const buildUniqueAssets = (rows: PhoneAssetAssociation[]): BatchUnbindAssetItem[] => {
const map = new Map<string, BatchUnbindAssetItem>()
rows.forEach((row) => {
if (row.status !== 1) return
const key = `${row.asset_type}:${row.asset_id}`
if (!map.has(key)) map.set(key, { asset_type: row.asset_type, asset_id: row.asset_id })
})
return Array.from(map.values()).slice(0, 200)
}
const openBatchUnbindDialog = () => {
batchForm.assets = buildUniqueAssets(selectedRows.value)
if (batchForm.assets.length === 0) {
ElMessage.warning('请选择有效关联的资产')
return
}
batchForm.reason = ''
batchForm.confirmed = false
batchResult.value = null
batchUnbindDialogVisible.value = true
}
const confirmBatchUnbind = async () => {
if (!(await batchFormRef.value?.validate().catch(() => false))) return
batchUnbindLoading.value = true
try {
const res = await PhoneAssetAssociationService.batchUnbind({
assets: batchForm.assets,
reason: batchForm.reason.trim(),
confirmed: batchForm.confirmed
})
if (res.code !== 0 || !res.data) {
ElMessage.error(res.msg || '批量解绑失败')
return
}
batchResult.value = res.data
batchUnbindDialogVisible.value = false
batchResultVisible.value = true
getTableData()
} catch (error) {
console.error('批量解绑失败:', error)
ElMessage.error(normalizeApiError(error).message || '批量解绑失败')
} finally {
batchUnbindLoading.value = false
}
}
// ========== CSV 解绑导入 ==========
const importDialogVisible = ref(false)
const importLoading = ref(false)
const uploadRef = ref<UploadInstance>()
const importFormRef = ref<FormInstance>()
const importForm = reactive({
file: null as File | null,
reason: '',
confirmed: false
})
const importRules: FormRules = {
file: [
{
validator: (_rule, _value, callback) => {
if (!importForm.file) callback(new Error('请选择 CSV 文件'))
else callback()
},
trigger: 'change'
}
],
reason: [
{ required: true, message: '请填写任务级解除原因', trigger: 'blur' },
{ min: 1, max: 500, message: '解除原因长度为 1~500 字符', trigger: 'blur' }
],
confirmed: [
{
validator: (_rule, value: boolean, callback) => {
if (!value) callback(new Error('请先勾选二次确认'))
else callback()
},
trigger: 'change'
}
]
}
const openImportDialog = () => {
importForm.file = null
importForm.reason = ''
importForm.confirmed = false
importDialogVisible.value = true
}
const handleImportDialogClosed = () => {
uploadRef.value?.clearFiles()
}
const handleFileChange = (file: UploadFile) => {
const rawFile = file.raw
if (!rawFile) return
if (!rawFile.name.toLowerCase().endsWith('.csv')) {
ElMessage.error('解绑导入仅支持 .csv 文件')
uploadRef.value?.clearFiles()
importForm.file = null
return
}
importForm.file = rawFile
}
const handleFileExceed = () => {
ElMessage.warning('仅支持上传一个 CSV 文件')
}
const downloadTemplate = () => {
const content = '\uFEFF资产标识,备注\n'
const blob = new Blob([content], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = '手机号资产解绑导入模板.csv'
link.click()
URL.revokeObjectURL(url)
}
const goUnbindImportTasks = () => {
router.push(RoutesAlias.PhoneAssetUnbindImportTasks)
}
const confirmImport = async () => {
if (!(await importFormRef.value?.validate().catch(() => false))) return
const file = importForm.file
if (!file) return
importLoading.value = true
try {
const uploadUrlRes = await StorageService.getUploadUrl({
file_name: file.name,
content_type: 'text/csv',
purpose: 'phone_unbind_import'
})
if (uploadUrlRes.code !== 0 || !uploadUrlRes.data) {
ElMessage.error(uploadUrlRes.msg || '获取上传地址失败')
return
}
const { upload_url, file_key } = uploadUrlRes.data
await StorageService.uploadFile(upload_url, file, 'text/csv')
const res = await PhoneAssetAssociationService.createUnbindImport({
file_key,
reason: importForm.reason.trim(),
confirmed: importForm.confirmed
})
if (res.code !== 0 || !res.data) {
ElMessage.error(res.msg || '创建解绑导入任务失败')
return
}
ElMessage.success(`解绑导入任务已创建!任务编号:${res.data.task_no}`)
importDialogVisible.value = false
goUnbindImportTasks()
} catch (error) {
console.error('创建解绑导入任务失败:', error)
ElMessage.error(normalizeApiError(error).message || '创建解绑导入任务失败')
} finally {
importLoading.value = false
}
}
onMounted(() => {
getTableData()
})
</script>
<style scoped lang="scss">
.phone-asset-association-page {
height: 100%;
}
.batch-result-summary {
margin-bottom: 12px;
font-size: 14px;
.text-danger {
color: var(--el-color-danger);
margin-left: 16px;
}
}
.import-tips {
margin-bottom: 12px;
font-size: 13px;
color: var(--el-text-color-secondary);
.import-tips-detail {
margin-top: 4px;
line-height: 1.6;
}
}
</style>