fix: some

This commit is contained in:
luo
2026-09-17 12:16:20 +08:00
parent 2308d82d0f
commit 76d4e4ec02
126 changed files with 11671 additions and 294 deletions

View File

@@ -0,0 +1,750 @@
<template>
<ArtTableFullScreen>
<div class="phone-asset-association-page" id="table-full-screen">
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
label-width="110"
:show-expand="false"
@reset="handleReset"
@search="handleSearch"
/>
<ElCard shadow="never" class="art-table-card">
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #actions>
<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>